编译不安全的Haskell代码

20

似乎Haskell试图成为一种安全的语言,并试图帮助程序员避免错误。例如,pred/succ在越界时会抛出错误,div 1 0也会抛出错误。这些安全的Haskell计算是什么,它们会带来什么开销?

是否可以关闭GHC中的这种安全性,因为在没有缺陷的程序中它们不应该是必要的?那样能够提高运行速度的性能吗?

对于C后端,有一个选项-ffast-math。LLVM后端或LLVM有类似的性能选项吗?


1
这将非常有用。 - singpolyma
6
@电话 我明白你打算编写的无缺陷程序将被没有错误的用户在一个免疫恶意软件和无误差操作系统上使用。 - AndrewC
@AndrewC 我明白没有错误的程序通常是不存在的。但我想要了解一下Haskell/GHC引入的额外安全性的概述。并且也想知道是否有可能以某种方式对它们进行控制。 - telephone
@AndrewC 很遗憾,我经常听到“如果你需要速度,请用C语言编写”。但是即使使用了不安全的功能,Haskell仍然比C更加安全。我们只想把使用Haskell的不安全特性作为与切换到一些完全不安全的语言(如C)相比的首选备选项。 - Artyom
@AndrewC 我认为不必担心低级实现,而是依赖于编写自然代码是明智的选择。但我仍然想知道Haskell增加了多少开销,这是来自Prelude还是编译器,以及这种开销是否会成为问题。在这种情况下,我不会预先优化程序,我只想更多地了解在Haskell中编码的知识 :) - telephone
显示剩余4条评论
1个回答

25
抱歉,之前回答中的基准测试确实存在严重缺陷。
问题和解决方案,如果我们不深究,确实,pred、succ和其他函数在发生各种错误(如溢出和除以零)时会引发异常。普通算术函数只是低级不安全函数的包装器;例如,看一下Int32的div实现即可。
div     x@(I32# x#) y@(I32# y#)
    | y == 0                     = divZeroError
    | x == minBound && y == (-1) = overflowError
    | otherwise                  = I32# (x# `divInt32#` y#)

你可以注意到在实际进行除法操作之前会有两个检查!
但这并不是最糟糕的。对于数组,我们还需要进行边界范围检查-有时会严重拖慢代码的速度。这个问题通常通过提供特殊变体的函数来解决,其中禁用了一些检查(例如unsafeAt)。
正如Daniel Fischer here 所指出的,确实有一种解决方案可以让您使用单个pragma来禁用/启用检查。不幸的是,它非常繁琐:你必须复制the source of GHC.Int并从每个函数中剪切出检查。当然,GHC.Int不是这种功能的唯一来源。
如果您真的想能够禁用这些检查,您需要:
  1. 列出所有你将要使用的不安全函数。
  2. 要么编写一个包含重写规则的文件(如Daniel的文章所述)并导入它,要么只需执行 import Prelude hiding (succ, pred, div, ...)import Unsafe (succ, pred, div, ...)。后一种变体不允许简单地在安全和不安全函数之间切换。

问题的根源和实际解决方案的指针

假设有一个已知不为零的数字(因此不需要检查)。现在,这个信息是由谁知道的?是编译器还是你?如果是前者,我们当然可以期望编译器不会执行任何检查。但在后一种情况下,我们的知识是无用的——除非我们能以某种方式告诉编译器。因此,问题是:如何编码我们拥有的知识?这是一个众所周知的问题,有多种解决方案。显而易见的解决方案是让程序员显式地使用不安全函数(unsafeRem)。另一种解决方案是引入一些编译器魔法:

{-# ASSUME x/=0 #-}
gcd x y = ...

但是,我们函数式程序员有{类型}。我们习惯于使用类型编码信息。其中一些人擅长此类操作。因此,最明智的解决方案要么是引入一组Unsafe类型,要么是切换到依赖类型(即学习Agda)。

有关非空列表的更多信息,请阅读相关资料。那里的问题更多地关注安全性而不是性能,但问题是相同的。

情况并不糟糕

让我们尝试衡量安全和不安全rem之间的差异:

{-# LANGUAGE MagicHash #-}

import GHC.Exts
import Criterion.Main

--assuming a >= b
--the type signatures are needed to prevent defaulting to Integer
safeGCD, unsafeGCD :: Int -> Int -> Int
safeGCD   a b = if b == 0 then a else safeGCD   b (rem a b)
unsafeGCD a b = if b == 0 then a else unsafeGCD b (unsafeRem a b)

{-# INLINE unsafeRem #-}
unsafeRem (I# a) (I# b) = I# (remInt# a b)

main = defaultMain [bench "safe"   $ whnf (safeGCD   12452650) 11090050,
                    bench "unsafe" $ whnf (unsafeGCD 12452650) 11090050]

这个差别似乎并不是那么大:

$ ghc -O2 ../bench/bench.hs && ../bench/bench

benchmarking unsafe
mean: 215.8124 ns, lb 212.4020 ns, ub 220.1521 ns, ci 0.950
std dev: 19.71321 ns, lb 16.04204 ns, ub 23.83883 ns, ci 0.950

benchmarking safe
mean: 250.8196 ns, lb 246.7827 ns, ub 256.1225 ns, ci 0.950
std dev: 23.44088 ns, lb 19.06654 ns, ub 28.23992 ns, ci 0.950

了解你的敌人

澄清正在添加的安全开销。

首先,如果安全措施可能导致异常,您可以在此处了解相关信息。这里列出了所有可能抛出的异常类型。

程序员引起的异常(没有人为开销):

  • ErrorCall:由error引起:
  • AssertionFailed:由assert引起。

标准库抛出的异常(重写库即可消除安全开销):

  • ArithException:其中之一是除以零。还包括溢出/下溢和一些不常见的情况。
  • ArrayException:当索引超出范围或尝试引用未定义元素时会发生。
  • IOException:不用担心这些,与IO开销相比,开销微不足道。

运行时异常(由GHC引起,无法避免):

  • AsyncException:堆栈和堆溢出。仅有轻微的开销。
  • PatternMatchFail:没有开销(与if...then...else...中的else一样)。
  • Rec*Error:当您尝试访问记录的不存在字段时发生。需要执行字段存在性检查,因此会产生一些开销。
  • NoMethodError:没有开销。
  • 关于并发的各种异常(死锁等):我必须承认我对它们一无所知。

其次,如果存在不会引起异常的安全措施,我真的很想听听(然后针对GHC提交错误报告)。

备注

顺便说一下,-ffast-math并没有影响任何检查(它们是在Haskell代码中完成的,而不是在C代码中)。它只是以某些边缘情况的精度为代价,使浮点运算更快。


优化会让这些数字变得更好吗? - telephone
9
@telephone :加速一个慢但正确的程序比修复一个快速有漏洞的程序更容易。在安全检查对于程序的运行速度产生较大影响时禁用安全检查比在性能不重要时手动打开安全检查更为明智。如果你一开始就知道项目中的每一行代码都需要最佳性能(这是相当罕见的情况),就不要用Haskell编写,而要用C编写。 - Ben
1
@Ben 我同意过早优化是没有建设性的。但是除非我要求,否则我不想从编译器获得帮助。我想保持简单和干净,特别是对于所有基元(Prelude)。 - telephone
我将此回答标记为被接受的,因为它回答了许多问题。如果有人有更多补充,例如关于GHC的,他们也可以添加一个新的回答。 - telephone
@电话 抱歉!没有3倍的开销。对此我感到很抱歉,没有注意到 safeGCD 已经默认为 Integer -> Integer -> Integer... - Artyom
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接