20

我对 Haskell 有点了解,我想知道是否可以在 Haskell 中编写类似矩阵矩阵乘积的东西,如下所示:

  1. 纯功能:在其类型签名中没有IOState单子(我不关心函数体中发生了什么。也就是说,我不关心函数体是否使用单子,只要整个函数是纯的)。我可能想在纯函数中使用这个矩阵矩阵乘积。
  2. 内存安全:没有 malloc 或指针。我知道可以在 Haskell 中“编写 C”,但是您会失去内存安全性。实际上,用 C 语言编写此代码并将其与 Haskell 接口也会失去内存安全性。
  3. 与 Java 一样高效。具体而言,假设我在谈论一个简单的三重循环、单精度、连续的列优先布局(float[]、 not float[][])和大小为 1000x1000 的矩阵以及单核 CPU。(如果您每个周期进行 0.5-2 次浮点运算,那么您可能就在球场上。)

(我不希望这听起来像是一个挑战,但请注意,Java 可以轻松满足上述所有要求。)

我已经知道了

  1. 三重循环实现并不是最有效的。这是相当缓存无视的。在这种特殊情况下,最好使用编写良好的BLAS实现。然而,人们不能总是指望 C 库可用于尝试做的事情。我想知道是否可以用普通的 Haskell 编写相当有效的代码。
  2. 有些人写了整个研究论文来证明#3。但是,我不是计算机科学研究人员。我想知道是否有可能在 Haskell 中保持简单的事情。
  3. Haskell 简介有一个矩阵乘积实现。但它不能满足上述要求。

解决意见:

我有三个原因:首先,“没有 malloc 或指针”的要求还没有明确定义(我挑战你编写任何不使用指针的 Haskell 代码);

我看到很多 Haskell 程序没有使用Ptr. 也许它指的是在机器指令级别将使用指针这一事实?那并非我的本意。我指的是 Haskell 源代码的抽象级别。

其次,对 CS 研究的攻击是不合适的(而且我无法想象比使用别人已经为你编写的代码更简单的事情了);第三,Hackage上有很多矩阵包(问这个问题的准备工作应该包括review和reject每一个)。

看来您的 #2 和 #3 是相同的(“使用现有库”)。我对矩阵产品感兴趣,因为它可以简单地测试 Haskell 自己可以做什么,以及它是否允许您“让简单的事情保持简单”。我可以很容易地想出一个没有任何现成库的数值问题,但是我必须解释这个问题,而每个人都已经知道什么是矩阵乘积。

Java怎么可能满足1.?任何 Java 方法本质上都是 :: IORef Arg -> ... -> IORef This -> IO Ret

这实际上是我问题的根源(+1)。虽然 Java 没有声称可以跟踪纯度,但 Haskell 确实如此。在 Java 中,函数是否为纯函数在注释中指明。我可以声称矩阵乘积是纯的,即使我在函数体中进行了突变。问题是 Haskell 的方法(在类型系统中编码的纯度)是否与效率、内存安全和简单性兼容。

4

3 回答 3

20

与 Java 一样高效。具体而言,假设我正在谈论一个简单的三重循环、单精度、连续列主要布局(float[],而不是 float[][])和大小为 1000x1000 的矩阵,以及一个单核 CPU。(如果您每个周期进行 0.5-2 次浮点运算,那么您可能就在球场上)

所以像

public class MatrixProd {
    static float[] matProd(float[] a, int ra, int ca, float[] b, int rb, int cb) {
        if (ca != rb) {
            throw new IllegalArgumentException("Matrices not fitting");
        }
        float[] c = new float[ra*cb];
        for(int i = 0; i < ra; ++i) {
            for(int j = 0; j < cb; ++j) {
                float sum = 0;
                for(int k = 0; k < ca; ++k) {
                    sum += a[i*ca+k]*b[k*cb+j];
                }
                c[i*cb+j] = sum;
            }
        }
        return c;
    }

    static float[] mkMat(int rs, int cs, float x, float d) {
        float[] arr = new float[rs*cs];
        for(int i = 0; i < rs; ++i) {
            for(int j = 0; j < cs; ++j) {
                arr[i*cs+j] = x;
                x += d;
            }
        }
        return arr;
    }

    public static void main(String[] args) {
        int sz = 100;
        float strt = -32, del = 0.0625f;
        if (args.length > 0) {
            sz = Integer.parseInt(args[0]);
        }
        if (args.length > 1) {
            strt = Float.parseFloat(args[1]);
        }
        if (args.length > 2) {
            del = Float.parseFloat(args[2]);
        }
        float[] a = mkMat(sz,sz,strt,del);
        float[] b = mkMat(sz,sz,strt-16,del);
        System.out.println(a[sz*sz-1]);
        System.out.println(b[sz*sz-1]);
        long t0 = System.currentTimeMillis();
        float[] c = matProd(a,sz,sz,b,sz,sz);
        System.out.println(c[sz*sz-1]);
        long t1 = System.currentTimeMillis();
        double dur = (t1-t0)*1e-3;
        System.out.println(dur);
    }
}

我想?(我在编码之前没有正确阅读规范,所以布局是行主要的,但由于访问模式是相同的,这不会像混合布局那样产生影响,所以我假设没关系。)

我没有花任何时间思考一个聪明的算法或低级优化技巧(无论如何在 Java 中不会有太多收获)。我只是写了简单的循环,因为

我不希望这听起来像是一个挑战,但请注意,Java 可以轻松满足上述所有要求

这就是 Java轻松提供的东西,所以我会接受它。

(如果您每个周期进行 0.5-2 次浮点运算,那么您可能就在球场上)

恐怕在 Java 和 Haskell 中都没有。通过简单的三重循环,太多的缓存未命中无法达到该吞吐量。

在 Haskell 中做同样的事情,再次没有考虑聪明,一个简单直接的三重循环:

{-# LANGUAGE BangPatterns #-}
module MatProd where

import Data.Array.ST
import Data.Array.Unboxed

matProd :: UArray Int Float -> Int -> Int -> UArray Int Float -> Int -> Int -> UArray Int Float
matProd a ra ca b rb cb =
    let (al,ah)     = bounds a
        (bl,bh)     = bounds b
        {-# INLINE getA #-}
        getA i j    = a!(i*ca + j)
        {-# INLINE getB #-}
        getB i j    = b!(i*cb + j)
        {-# INLINE idx #-}
        idx i j     = i*cb + j
    in if al /= 0 || ah+1 /= ra*ca || bl /= 0 || bh+1 /= rb*cb || ca /= rb
         then error $ "Matrices not fitting: " ++ show (ra,ca,al,ah,rb,cb,bl,bh)
         else runSTUArray $ do
            arr <- newArray (0,ra*cb-1) 0
            let outer i j
                    | ra <= i   = return arr
                    | cb <= j   = outer (i+1) 0
                    | otherwise = do
                        !x <- inner i j 0 0
                        writeArray arr (idx i j) x
                        outer i (j+1)
                inner i j k !y
                    | ca <= k   = return y
                    | otherwise = inner i j (k+1) (y + getA i k * getB k j)
            outer 0 0

mkMat :: Int -> Int -> Float -> Float -> UArray Int Float
mkMat rs cs x d = runSTUArray $ do
    let !r = rs - 1
        !c = cs - 1
        {-# INLINE idx #-}
        idx i j = cs*i + j
    arr <- newArray (0,rs*cs-1) 0
    let outer i j y
            | r < i     = return arr
            | c < j     = outer (i+1) 0 y
            | otherwise = do
                writeArray arr (idx i j) y
                outer i (j+1) (y + d)
    outer 0 0 x

和调用模块

module Main (main) where

import System.Environment (getArgs)
import Data.Array.Unboxed

import System.CPUTime
import Text.Printf

import MatProd

main :: IO ()
main = do
    args <- getArgs
    let (sz, strt, del) = case args of
                            (a:b:c:_) -> (read a, read b, read c)
                            (a:b:_)   -> (read a, read b, 0.0625)
                            (a:_)     -> (read a, -32, 0.0625)
                            _         -> (100, -32, 0.0625)
        a = mkMat sz sz strt del
        b = mkMat sz sz (strt - 16) del
    print (a!(sz*sz-1))
    print (b!(sz*sz-1))
    t0 <- getCPUTime
    let c = matProd a sz sz b sz sz
    print $ c!(sz*sz-1)
    t1 <- getCPUTime
    printf "%.6f\n" (fromInteger (t1-t0)*1e-12 :: Double)

所以我们用两种语言做几乎完全相同的事情。用 编译 Haskell -O2,用 javac 编译 Java

$ java MatrixProd 1000 "-13.7" 0.013
12915.623
12899.999
8.3592897E10
8.193
$ ./vmmult 1000 "-13.7" 0.013
12915.623
12899.999
8.35929e10
8.558699

结果时间非常接近。

如果我们将 Java 代码编译为本机,使用gcj -O3 -Wall -Wextra --main=MatrixProd -fno-bounds-check -fno-store-check -o jmatProd MatrixProd.java,

$ ./jmatProd 1000 "-13.7" 0.013
12915.623
12899.999
8.3592896512E10
8.215

仍然没有太大的区别。

作为特殊奖励,C 中的相同算法(gcc -O3):

$ ./cmatProd 1000 "-13.7" 0.013
12915.623047
12899.999023
8.35929e+10
8.079759

因此,当涉及使用浮点数的计算密集型任务时,这表明简单的 Java 和简单的 Haskell 之间没有根本区别(在处理中到大数的整数运算时,GHC 对 GMP 的使用使得 Haskell 的性能大大优于 Java 的 BigInteger对于许多任务,但这当然是库问题,而不是语言问题),并且使用这种算法,两者都接近 C。

不过,平心而论,这是因为访问模式每隔纳秒就会导致一次缓存未命中,所以在所有三种语言中,这种计算都是受内存限制的。

如果我们通过将行优先矩阵与列优先矩阵相乘来改进访问模式,一切都会变得更快,gcc 编译的 C 完成它 1.18 秒,java 需要 1.23 秒,ghc 编译的 Haskell 大约需要 5.8 秒,这可以通过使用 llvm 后端减少到 3 秒。

在这里,数组库的范围检查真的很痛苦。使用未经检查的数组访问(应该在检查错误之后,因为检查已经在控制循环的代码中完成),GHC 的本机后端在 2.4 秒内完成,通过 llvm 后端让计算在 1.55 秒内完成,这是不错的,虽然比 C 和 Java 慢得多。使用原语GHC.Prim而不是数组库,llvm 后端生成的代码可以在 1.16 秒内运行(同样,每次访问都没有边界检查,但是在这种情况下,之前可以很容易地证明在计算过程中只生成有效的索引,所以在这里,没有牺牲内存安全¹;检查每次访问使时间达到 1.96 秒,仍然明显优于数组的边界检查图书馆)。

底线:GHC 需要(更多)更快的边界检查分支,优化器还有改进的空间,但原则上,“Haskell 的方法(在类型系统中编码的纯度)与效率、内存安全和简单性兼容“,我们只是还没到那里。就目前而言,必须决定自己愿意牺牲多少。


¹ 是的,这是一种特殊情况,通常省略边界检查确实会牺牲内存安全,或者至少很难证明它没有。

于 2012-08-02T12:01:55.950 回答
12

有两个角度来解决这个问题。

  1. 沿着这些方向进行的研究正在进行中。现在,有很多 Haskell 程序员比我聪明;我经常被提醒和谦卑的事实。其中一个可能会过来纠正我,但我不知道有任何简单的方法可以将安全的 Haskell 原语组合成顶级矩阵乘法例程。你谈论的那些论文听起来像是一个好的开始。

    但是,我不是计算机科学研究人员。我想知道是否有可能在 Haskell 中保持简单的事情。

    如果你引用那些论文,也许我们可以帮助破译它们。

  2. 沿着这些思路,软件工程是容易理解的、直接的,甚至是容易的。精明的 Haskell 编码器会在 BLAS 周围使用薄包装器,或者在 Hackage 中寻找这样的包装器。

破译尖端研究是一个持续的过程,它将知识从研究人员转移到工程师。计算机科学研究人员 CAR Hoare 率先发现了快速排序并发表了一篇关于它的论文。今天,很少有计算机科学专业的毕业生不能亲自从记忆中实现快速排序(至少,那些最近毕业的人)。

一点历史

几乎这个确切的问题在历史上已经被问过几次了。

  1. 是否可以在 Fortran 中编写与汇编一样快的矩阵算术?

  2. 是否可以在 C 中编写与 Fortran 一样快的矩阵算术?

  3. 是否可以用 Java 编写与 C 一样快的矩阵算术?

  4. 是否可以在 Haskell 中编写与 Java 一样快的矩阵算术?

到目前为止,答案一直是“还没有”,其次是“足够接近”。使这成为可能的进步来自编写代码的改进、编译器的改进以及编程语言本身的改进。

作为一个具体的例子,直到 C99 编译器在过去十年中变得普遍,C 在许多实际应用程序中都无法超越 Fortran。在 Fortran 中,假定不同的数组具有彼此不同的存储空间,而在 C 中通常不是这种情况。因此,允许 Fortran 编译器进行 C 编译器无法进行的优化。好吧,直到 C99 出现并且您可以将restrict限定符添加到您的代码中。

Fortran 编译器等待着。最终,处理器变得足够复杂,以至于编写好的汇编代码变得更加困难,而编译器变得足够复杂,以至于 Fortran 速度很快。

然后 C 程序员一直等到 2000 年代才有能力编写与 Fortran 匹配的代码。在那之前,他们使用的是用 Fortran 或汇编程序(或两者兼有)编写的库,或者忍受降低的速度。

同样,Java 程序员必须等待 JIT 编译器,并且必须等待特定的优化出现。JIT 编译器最初是一个深奥的研究概念,直到它们成为日常生活的一部分。边界检查优化也是必要的,以避免对每个数组访问进行测试和分支。

回到哈斯克尔

因此,很明显 Haskell 程序员正在“等待”,就像他们之前的 Java、C 和 Fortran 程序员一样。我们还在等什么?

  • 也许我们只是在等待有人编写代码,并向我们展示它是如何完成的。

  • 也许我们正在等待编译器变得更好。

  • 也许我们正在等待 Haskell 语言本身的更新。

也许我们正在等待上述的某种组合。

关于纯度

纯度和单子在 Haskell 中经常混为一谈。这样做的原因是因为在 Haskell 中,不纯函数总是使用IOmonad。例如,Statemonad 是 100% 纯的。因此,当您说“纯”和“类型签名不使用Statemonad”时,它们实际上是完全独立且独立的要求。

但是,你也可以IO在纯函数的实现中使用 monad,事实上,这很容易:

addSix :: Int -> Int
addSix n = unsafePerformIO $ return (n + 6)

好的,是的,这是一个愚蠢的功能,但它是纯粹的。它甚至显然是纯粹的。纯度测试是双重的:

  1. 对于相同的输入,它是否给出相同的结果?是的。

  2. 它会产生任何语义上显着的副作用吗?不。

我们喜欢纯函数的原因是纯函数比不纯函数更容易组合和操作。它们如何实施并不重要。我不知道你是否意识到这一点,但是Integer两者ByteString基本上都是不纯 C 函数的包装器,即使接口是纯的。(有一个新的实现工作Integer,我不知道它有多远。)

最终答案

问题是 Haskell 的方法(在类型系统中编码的纯度)是否与效率、内存安全和简单性兼容。

这部分的答案是“是”,因为我们可以从 BLAS 中获取简单的函数并将它们放入一个纯粹的、类型安全的包装器中。包装器的类型编码了函数的安全性,即使 Haskell 编译器无法证明函数的实现是纯的。我们unsafePerformIO在它的实现中使用它既是对我们已经证明了函数的纯度的承认,也是对我们无法在 Haskell 的类型系统中表达该证明的一种让步。

但答案也是“还没有”,因为我不知道如何完全在 Haskell 中实现该功能。

这方面的研究正在进行中。人们正在关注像Coq这样的证明系统和像Agda这样的新语言,以及 GHC 本身的发展。为了了解什么样的类型系统,我们需要证明可以安全使用高性能 BLAS 例程。这些工具也可以与 Java 等其他语言一起使用。例如,您可以在 Coq 中编写证明您的 Java 实现是纯的。

我为“是和否”的答案道歉,但没有其他答案会承认工程师(关心“是”)和研究人员(关心“还没有”)的贡献。

PS请引用论文。

于 2012-08-02T03:36:29.727 回答
8

与 Java 一样,Haskell 并不是编写数字代码的最佳语言。

Haskell 的大量数字代码生成是……平均的。它没有像英特尔和 GCC 那样的多年研究。

相反,Haskell 为您提供的是一种将您的“快速”代码与应用程序的其余部分干净地接口的方法。请记住,3% 的代码负责 97% 的应用程序运行时间。1

使用 Haskell,您可以调用这些高度优化的函数,并与您的其余代码完美地交互:通过非常好的 C 外部函数接口。事实上,如果您愿意,您可以使用架构的汇编语言编写数字代码并获得更高的性能!为应用程序的性能要求高的部分使用 C 并不是一个错误——它是一个特性。

但我离题了。

通过隔离这些高度优化的函数,并使用与您的其余 Haskell 代码类似的接口,您可以使用 Haskell 非常强大的重写规则执行高级优化,它允许您编写规则,例如reverse . reverse == id在编译时自动减少复杂表达式2 . 这导致了速度极快、功能纯正且易于使用的库,例如 Data.Text 3和 Data.Vector [4]。

通过结合高低级别的优化,我们最终得到了一个更加优化的实现,每一半(“C/asm”和“Haskell”)都相对容易阅读。低级优化是用它的母语(C 或汇编)完成的,高级优化有一个特殊的DSL(Haskell 重写规则),其余的代码完全没有注意到它。

总之,是的,Haskell 可以比 Java 更快。但它通过 C 获取原始FLOPS来作弊。这在 Java 中要困难得多(而且 Java 的 FFI 开销要高得多),因此可以避免。在 Haskell 中,这是很自然的。如果您的应用程序花费大量时间进行数值计算,那么您可能不考虑使用 Haskell 或 Java,而是使用 Fortran 来满足您的需求。如果您的应用程序将大部分时间花在一小部分性能敏感代码上,那么 Haskell FFI 是您最好的选择。如果您的应用程序不花任何时间在数字代码上......然后使用您喜欢的任何东西。=)

Haskell(也不是 Java,就此而言)不是 Fortran。

1这些数字是虚构的,但你明白我的意思。

2 http://www.cse.unsw.edu.au/~dons/papers/CLS07.html

3 http://hackage.haskell.org/package/text

[4] http://hackage.haskell.org/package/vector


现在已经不碍事了,回答你的实际问题:

不,目前在 Haskell 中编写矩阵乘法并不明智。目前,REPA 是执行此操作的规范方法 [5]。该实现部分破坏了内存安全(他们使用 unsafeSlice),但“破坏内存安全”与该函数隔离,实际上非常安全(但不容易被编译器验证),并且如果出现问题很容易删除(替换“ unsafeSlice”与“切片”)。

但这是哈斯克尔!很少会孤立地考虑函数的性能特征。这可能是一件坏事(在空间泄漏的情况下),或者是一件非常非常好的事情。

尽管使用的矩阵乘法算法很幼稚,但它在原始基准测试中的表现会更差。但我们的代码很少看起来像基准测试。

如果您是一名拥有数百万数据点的科学家并想要乘以巨大的矩阵怎么办?[7]

对于这些人,我们有 mmultP [6]。这会执行矩阵乘法,但是是数据并行的,并且受 REPA 的嵌套数据并行性约束。另请注意,代码与顺序版本基本没有变化。

对于那些不乘以大矩阵而是乘以许多小矩阵的人来说,往往会有其他代码与所述矩阵交互。可能将其切割成列向量并找到它们的点积,也许找到它的特征值,也许完全是别的东西。与 C 不同,Haskell 知道,尽管您喜欢孤立地解决问题,但通常在那里找不到最有效的解决方案。

与 ByteString、Text 和 Vector 一样,REPA 数组也需要融合。2顺便说一句,您实际上应该阅读2 - 这是一篇写得很好的论文。这与相关代码的积极内联和 REPA 的高度并行性相结合,使我们能够在幕后通过非常先进的高级优化来表达这些高级数学概念。

虽然目前尚不知道用纯函数式语言编写有效的矩阵乘法的方法,但我们可以稍微接近(没有自动矢量化,一些过度取消引用以获取实际数据等),但与IFORT或海合会可以。但是程序并不存在于一个岛上,并且在 Haskell 中使整个岛表现良好比 Java 容易得多。

[5] http://hackage.haskell.org/packages/archive/repa-algorithms/3.2.1.1/doc/html/src/Data-Array-Repa-Algorithms-Matrix.html#mmultS

[6] http://hackage.haskell.org/packages/archive/repa-algorithms/3.2.1.1/doc/html/src/Data-Array-Repa-Algorithms-Matrix.html#mmultP

[7] 实际上,最好的方法是使用 GPU。Haskell 有一些 GPU DSL 可用,这使得它可以在本地完成。他们真的很整洁!

于 2012-08-02T04:47:33.283 回答