【问题标题】:Best way of "looping over a 2-D array", using Repa“循环二维数组”的最佳方式,使用 Repa
【发布时间】:2011-08-21 19:55:21
【问题描述】:

我觉得 Haskell 的数组库 Repa 很有趣,想做一个简单的程序,尝试了解如何使用它。我还使用列表做了一个简单的实现,事实证明它要快得多。我的主要问题是如何改进下面的 Repa 代码以使其最有效(并且希望也非常易读)。我是使用 Haskell 的新手,我在 Repa 上找不到任何易于理解的教程 [editHaskell Wiki 有一个,我写这篇文章时不知何故忘记了],所以不要'不要假设我什么都知道。 :) 例如,我不确定何时使用 force 或 deepSeqArray。

该程序用于通过以下方式近似计算球体的体积:

  1. 指定了球体的中心点和半径,以及已知包含球体的长方体内的等距坐标。
  2. 该程序获取每个坐标,计算到球体中心的距离,如果它小于球体的半径,则使用它来累加球体的总(近似)体积。

下面展示了两个版本,一个使用列表,一个使用repa。我知道代码效率低下,尤其是对于这个用例,但我的想法是稍后让它变得更复杂。

对于以下值,并使用“ghc -Odph -fllvm -fforce-recomp -rtsopts -threaded”进行编译,列表版本需要 1.4 秒,而 repa 版本使用 +RTS -N1 需要 12 秒,使用 +RTS -N1 需要 10 秒+RTS -N2,虽然没有转换火花(我有一台运行 Windows 7 64、GHC 7.0.2 和 llvm 2.8 的双核 Intel 机器(Core 2 Duo E7400 @ 2.8 GHz))。 (在下面的 main 中注释掉正确的行,只运行其中一个版本。)

感谢您的帮助!

import Data.Array.Repa as R
import qualified Data.Vector.Unboxed as V
import Prelude as P

-- Calculate the volume of a sphere by putting it in a bath of coordinates. Generate coordinates (x,y,z) in a cuboid. Then, for each coordinate, check if it is inside the sphere. Sum those coordinates and multiply by the coordinate grid step size to find an approximate volume.


particles = [(0,0,0)] -- used for the list alternative --[(0,0,0),(0,2,0)]
particles_repa = [0,0,0::Double] -- used for the repa alternative, can currently just be one coordinate

-- Radius of the particle
a = 4

-- Generate the coordinates. Could this be done more efficiently, and at the same time simple? In Matlab I would use ndgrid.
step = 0.1 --0.05
xrange = [-10,-10+step..10] :: [Double]
yrange = [-10,-10+step..10]
zrange = [-10,-10+step..10]

-- All coordinates as triples. These are used directly in the list version below.
coords = [(x,y,z)  | x <- xrange, y <- yrange, z <- zrange]

---- List code ----

volumeIndividuals = fromIntegral (length particles) * 4*pi*a**3/3

volumeInside = step**3 * fromIntegral (numberInsideParticles particles coords)

numberInsideParticles particles coords = length $ filter (==True) $ P.map (insideParticles particles) coords

insideParticles particles coord =  any (==True) $ P.map (insideParticle coord) particles

insideParticle (xc,yc,zc) (xp,yp,zp) = ((xc-xp)^2+(yc-yp)^2+(zc-zp)^2) < a**2
---- End list code ----

---- Repa code ----

-- Put the coordinates in a Nx3 array.
xcoords = P.map (\(x,_,_) -> x) coords
ycoords = P.map (\(_,y,_) -> y) coords
zcoords = P.map (\(_,_,z) -> z) coords

-- Total number of coordinates
num_coords = (length xcoords) ::Int

xcoords_r = fromList (Z :. num_coords :. (1::Int)) xcoords
ycoords_r = fromList (Z :. num_coords :. (1::Int)) ycoords
zcoords_r = fromList (Z :. num_coords :. (1::Int)) zcoords

rcoords = xcoords_r R.++ ycoords_r R.++ zcoords_r

-- Put the particle coordinates in an array, then extend (replicate) this array so that its size becomes the same as that of rcoords
particle = fromList (Z :. (1::Int) :. (3::Int)) particles_repa
particle_slice = slice particle (Z :. (0::Int) :. All)
particle_extended = extend (Z :. num_coords :. All) particle_slice

-- Calculate the squared difference between the (x,y,z) coordinates of the particle and the coordinates of the cuboid.
squared_diff = deepSeqArrays [rcoords,particle_extended] ((force2 rcoords) -^ (force2 particle_extended)) **^ 2
(**^) arr pow = R.map (**pow) arr

xslice = slice squared_diff (Z :. All :. (0::Int))
yslice = slice squared_diff (Z :. All :. (1::Int))
zslice = slice squared_diff (Z :. All :. (2::Int))

-- Calculate the distance between each coordinate and the particle center
sum_squared_diff = [xslice,yslice,zslice] `deepSeqArrays` xslice +^ yslice +^ zslice

-- Do the rest using vector, since I didn't get the repa variant working.
ssd_vec = toVector sum_squared_diff

-- Determine the number of the coordinates that are within the particle (instead of taking the square root to get the distances above, I compare to the square of the radius here, to improve performance)
total_within = fromIntegral (V.length $ V.filter (<a**2) ssd_vec)
--total_within = foldAll (\x acc -> if x < a**2 then acc+1 else acc) 0 sum_squared_diff

-- Finally, calculate an approximation of the volume of the sphere by taking the volume of the cubes with side step, multiplied with the number of coordinates within the sphere.
volumeInside_repa = step**3 * total_within 

-- Helper function that shows the size of a 2-D array.
rsize = reverse . listOfShape . (extent :: Array DIM2 Double -> DIM2)

---- End repa code ----

-- Comment out the list or the repa version if you want to time the calculations separately.
main = do
    putStrLn $ "Step = " P.++ show step
    putStrLn $ "Volume of individual particles = " P.++ show volumeIndividuals
    putStrLn $ "Volume of cubes inside particles (list) = " P.++ show volumeInside
    putStrLn $ "Volume of cubes inside particles (repa) = " P.++ show volumeInside_repa

编辑:一些背景解释了我为什么要编写上面的代码:

我主要在 Matlab 中编写代码,而我的性能提升经验主要来自该领域。在 Matlab 中,您通常希望使用直接在矩阵上操作的函数进行计算,以提高性能。我在 Matlab R2010b 中实现上述问题,使用下面显示的矩阵版本需要 0.9 秒,使用嵌套循环需要 15 秒。虽然我知道 Haskell 与 Matlab 非常不同,但我希望在 Haskell 中从使用列表到使用 Repa 数组会提高代码的性能。列表-> Repa 数组-> 向量的转换在那里,因为我不够熟练,无法用更好的东西替换它们。这就是我要求输入的原因。 :) 上面的时间数字因此是主观的,因为它可能比语言能力更能衡量我的表现,但它现在对我来说是一个有效的指标,因为什么决定了我会做什么使用取决于是否可以使它工作。

tl;dr:我知道我上面的 Repa 代码可能是愚蠢的或病态的,但这是我现在能做的最好的事情。我希望能够编写更好的 Haskell 代码,我希望你能在这个方向上帮助我(dons 已经这样做了)。 :)

function archimedes_simple()

particles = [0 0 0]';
a = 4;

step = 0.1;

xrange = [-10:step:10];
yrange = [-10:step:10];
zrange = [-10:step:10];

[X,Y,Z] = ndgrid(xrange,yrange,zrange);
dists2 = bsxfun(@minus,X,particles(1)).^2+ ...
    bsxfun(@minus,Y,particles(2)).^2+ ...
    bsxfun(@minus,Z,particles(3)).^2;
inside = dists2 < a^2;
num_inside = sum(inside(:));

disp('');
disp(['Step = ' num2str(step)]);
disp(['Volume of individual particles = ' num2str(size(particles,2)*4*pi*a^3/3)]);
disp(['Volume of cubes inside particles = ' num2str(step^3*num_inside)]);

end

Edit 2:更快、更简单的 Repa 代码版本

我现在已经阅读了更多关于 Repa 的内容,并进行了一些思考。下面是一个新的 Repa 版本。在这种情况下,我使用 Repa 扩展函数从值列表(类似于 Matlab 中 ndgrid 的工作方式)将 x、y 和 z 坐标创建为 3-D 数组。然后我映射这些阵列以计算到球形粒子的距离。最后,我折叠生成的 3-D 距离数组,计算球内有多少坐标,然后将其乘以一个常数因子以获得近似体积。我的算法实现现在和上面的Matlab版本更相似了,不再有任何向量的转换。

新版本在我的电脑上运行时间大约为 5 秒,与上面相比有了相当大的改进。如果我在编译时使用“线程”,是否与“+RTS -N2”结合使用,时间是相同的,但是线程版本确实最大限度地利用了我计算机的两个内核。然而,我确实看到几滴“-N2”运行到 3.1 秒,但后来无法重现它们。也许它对同时运行的其他进程非常敏感?基准测试时我已经关闭了计算机上的大多数程序,但仍有一些程序在运行,例如后台进程。

如果我们使用“-N2”并添加运行时开关以关闭并行 GC (-qg),时间会持续下降到 ~4.1 秒,并且使用 -qa 来“使用操作系统设置线程亲和性(实验性) )”,时间缩短到约 3.5 秒。查看使用“+RTS -s”运行程序的输出,使用 -qg 执行的 GC 少得多。

今天下午我会看看我是否可以在 8 核计算机上运行代码,只是为了好玩。 :)

import Data.Array.Repa as R
import Prelude as P
import qualified Data.List as L

-- Calculate the volume of a spherical particle by putting it in a bath of coordinates.     Generate coordinates (x,y,z) in a cuboid. Then, for each coordinate, check if it is     inside the sphere. Sum those coordinates and multiply by the coordinate grid step size to     find an approximate volume.

particles :: [(Double,Double,Double)]
particles = [(0,0,0)]

-- Radius of the spherical particle
a = 4

volume_individuals = fromIntegral (length particles) * 4*pi*a^3/3

-- Generate the coordinates. 
step = 0.1
coords_list = [-10,-10+step..10] :: [Double]
num_coords = (length coords_list) :: Int

coords :: Array DIM1 Double
coords = fromList (Z :. (num_coords ::Int)) coords_list

coords_slice :: Array DIM1 Double
coords_slice = slice coords (Z :. All)

-- x, y and z are 3-D arrays, where the same index into each array can be used to find a     single coordinate, e.g. (x(i,j,k),y(i,j,k),z(i,j,k)).
x,y,z :: Array DIM3 Double
x = extend (Z :. All :. num_coords :. num_coords) coords_slice
y = extend (Z :. num_coords :. All :. num_coords) coords_slice
z = extend (Z :. num_coords :. num_coords :. All) coords_slice

-- Calculate the squared distance from each coordinate to the center of the spherical     particle.
dist2 :: (Double, Double, Double) -> Array DIM3 Double
dist2 particle = ((R.map (squared_diff xp) x) + (R.map (squared_diff yp) y) + (R.map (    squared_diff zp) z)) 
    where
        (xp,yp,zp) = particle
        squared_diff xi xa = (xa-xi)^2

-- Count how many of the coordinates are within the spherical particle.
num_inside_particle :: (Double,Double,Double) -> Double
num_inside_particle particle = foldAll (\acc x -> if x<a^2 then acc+1 else acc) 0 (force     $ dist2 particle)

-- Calculate the approximate volume covered by the spherical particle.
volume_inside :: [Double]
volume_inside = P.map ((*step^3) . num_inside_particle) particles

main = do
    putStrLn $ "Step = " P.++ show step
    putStrLn $ "Volume of individual particles = " P.++ show volume_individuals
    putStrLn $ "Volume of cubes inside each particle (repa) = " P.++ (P.concat . (    L.intersperse ", ") . P.map show) volume_inside

-- As an alternative, y and z could be generated from x, but this was slightly slower in     my tests (~0.4 s).
--y = permute_dims_3D x
--z = permute_dims_3D y

-- Permute the dimensions in a 3-D array, (forward, cyclically)
permute_dims_3D a = backpermute (swap e) swap a
    where
        e = extent a
        swap (Z :. i:. j :. k) = Z :. k :. i :. j

新代码的空间分析

与下面的 Don Stewart 制作的配置文件类型相同,但使用的是新的 Repa 代码。

【问题讨论】:

    标签: arrays performance haskell profiling repa


    【解决方案1】:

    代码审查说明

    • 47.8% 的时间花在 GC 上。
    • 1.5G 分配在堆上 (!)
    • repa 代码看起来比列表代码复杂很多
    • 大量并行 GC 发生
    • 我可以在 -N4 机器上获得高达 300% 的效率
    • 加入更多类型签名会更容易分析...
    • rsize 没有使用(看起来很贵!)
    • 您将repa 数组转换为向量,为什么?
    • 您对(**) 的所有使用都可以替换为Int 上更便宜的(^)
    • 存在大量可疑的大型常量列表。这些都必须转换为数组——这似乎很昂贵。
    • any (==True)or 相同

    时间分析

    COST CENTRE                    MODULE               %time %alloc
    
    squared_diff                   Main                  25.0   27.3
    insideParticle                 Main                  13.8   15.3
    sum_squared_diff               Main                   9.8    5.6
    rcoords                        Main                   7.4    5.6
    particle_extended              Main                   6.8    9.0
    particle_slice                 Main                   5.0    7.6
    insideParticles                Main                   5.0    4.4
    yslice                         Main                   3.6    3.0
    xslice                         Main                   3.0    3.0
    ssd_vec                        Main                   2.8    2.1
    **^                            Main                   2.6    1.4
    

    表明,您的函数squared_diff 有点可疑:

    squared_diff :: Array DIM2 Double
    squared_diff = deepSeqArrays [rcoords,particle_extended]
                        ((force2 rcoords) -^ (force2 particle_extended)) **^ 2    
    

    虽然我没有看到任何明显的解决方法。

    空间分析

    空间轮廓没有什么太令人惊奇的地方:您可以清楚地看到列表阶段,然后是矢量阶段。列表阶段分配了很多,然后被回收。

    按类型分解堆,我们看到最初分配了很多列表和元组(按需),然后分配并保存了一大块数组:

    再一次,有点像我们期望看到的......数组的东西并没有比列表代码分配更多的东西(事实上,总体上少了一点),但它只是需要更长的时间来运行。

    使用保持器分析检查空间泄漏

    那里有一些有趣的东西,但没有什么令人吃惊的。 zcoords 保留列表程序执行的长度,然后为 repa 运行分配一些数组 (SYSTEM)。

    检查核心

    所以在这一点上,我首先假设您确实在列表和数组中实现了相同的算法(即在数组情况下没有做额外的工作),并且没有明显的空间泄漏。所以我怀疑是严重优化的repa代码。再来看看核心(用ghc-core.

    • 基于列表的代码看起来不错。
    • 数组代码看起来很合理(即出现未装箱的基元),但非常复杂,而且很多。

    内联所有 CAF

    我在所有顶级数组定义中添加了内联编译指示,希望删除一些 CAfs,并让 GHC 更加努力地优化数组代码。这确实让 GHC 难以编译模块(分配高达 4.3G 和 10 分钟,同时工作)。这对我来说是一个线索,表明 GHC 以前无法很好地优化这个程序,因为当我提高阈值时它会有新的事情要做。

    操作

    • 使用 -H 可以减少花费在 GC 上的时间。
    • 尝试消除从列表到重复到向量的转换。
    • 所有这些 CAF(顶级常量数据结构)有点奇怪——一个真正的程序不会是顶级常量的列表——事实上,这个模块是病态的,导致很多值被保留很长一段时间,而不是被优化掉。向内浮动本地定义。
    • 向 Repa 的作者 Ben Lippmeier 寻求帮助,特别是因为正在发生一些时髦的优化问题。

    【讨论】:

    • 如果可以的话,我会至少投票十次。你哪里来的时间写出这么完美的答案?
    • 哇。 o_O。哇,这是一个惊人的答案。现在是睡觉时间,但明天我会调查你所有的 cmets,希望能得到一些有用的回应。 (我也是 stackoverflow 的新手,所以任何不礼貌的行为都是由于无知造成的。)
    • 首先,再次感谢您的回答。我试图描述自己,但显然我没有使用正确的开关和工具。用于生成剖析图的工具的名称是什么? Code Review: Points 1-4 and 8: Repa 代码复杂,需要大量的 GC 和堆分配:这些都是我希望找到改进的地方。其他要点:如果您认为它可以澄清问题,我可以编辑我的问题以添加类型签名。
    • rsize 只是我在 ghci 中尝试不同的东西时使用的,用于检查给定二维数组的尺寸。我并不是要经常运行它,但也不明白为什么它会很贵。最后我将 repa 数组转换为向量,因为我不知道如何使用 repa 数组完成最后一步。我没有意识到我可以使用^。不过,性能并没有明显变化。我同意在 Repa 版本中应该直接将“大型常量列表”创建为 Repa 数组,但我不知道该怎么做。
    • 一旦我内化了那些是平等的,我将从任何 (==True) 改变。 :) 时间分析和空间分析 我假设您在同时“打开”列表代码和 Repa 代码的情况下运行这些?无论如何,有趣的信息。检查核心我将尝试自己内联顶级数组,看看是否有任何变化。行动 “向内浮动本地定义”是什么意思?当然,我会用谷歌搜索它,但如果你有一个方便的链接,将不胜感激。考虑并测试您的建议后,我会询问 Ben Lippmeier。
    【解决方案2】:

    我将代码更改为强制使用rcoordsparticle_extended,并发现我们在其中直接失去了大部分时间:

    COST CENTRE                    MODULE               %time %alloc
    
    rcoords                        Main                  32.6   34.4
    particle_extended              Main                  21.5   27.2
    **^                            Main                   9.8   12.7
    

    对这段代码最大的改进显然是以更好的方式生成这两个常量输入。

    请注意,这基本上是一种惰性流式算法,而您浪费时间的地方是一次性分配至少两个 24361803 元素数组,然后可能至少分配一次或两次以上的沉没成本,或者放弃分享。我认为,这段代码最好的情况是,使用非常好的优化器和无数的重写规则,将大致匹配列表版本(也可以很容易地并行化)。

    我认为 Dons 说 Ben & co 是对的。会对这个基准感兴趣,但我非常怀疑这对于严格的数组库来说不是一个好的用例,我怀疑 matlab 在其 ngrid 函数后面隐藏了一些巧妙的优化(优化,我会授予, 移植到 repa 可能很有用。]

    编辑:

    这是并行化列表代码的一种快速而肮脏的方法。导入Control.Parallel.Strategies,然后将numberInsideParticles写成:

    numberInsideParticles particles coords = length $ filter id $ 
        withStrategy (parListChunk 2000 rseq) $ P.map (insideParticles particles) coords
    

    当我们扩大核心时,这显示了良好的加速(一个核心 12 秒到 8 个核心时的 3.7 秒),但是 spark 创建的开销意味着即使是 8 个核心,我们也只能匹配单核非并行版本。我尝试了一些替代策略并得到了类似的结果。同样,我不确定我们可以比这里的单线程列表版本做得更好。由于对每个单独粒子的计算非常便宜,我们主要强调分配,而不是计算。我想像这样的事情最大的胜利将是矢量化计算,据我所知,这几乎需要手工编码。

    还要注意,并行版本大约 70% 的时间花在 GC 上,而单核版本花 1% 的时间在那里(即,分配尽可能有效地融合了。)。

    【讨论】:

    • 感谢您的分析!我现在创建了一个新的 Repa 版本(参见上面问题中的编辑),其中坐标矩阵的生成要快得多。您如何以简单的方式轻松并行化列表版本?我想我可以拆分坐标的输入流,但是我怎么知道将它拆分成多少部分,这取决于“+RTS -Nx”中的 x?另外,在我看来,这对我来说既是一种并行数组算法,也是一种流算法——需要完成许多独立的计算,执行顺序并不重要。
    • @imladris -- 您可以访问numCapabilities 提供的操作系统线程数,但请参见上文 -- 最好将其拆分成足够大的块以包含真正的“工作”并让rts 弄清楚如何处理产生的火花。它比数组更具流式传输的原因是计算不需要上下文(即完全独立)并且胜利在于减少分配,而不是减少处理。
    • 感谢您的意见。我将在上面尝试您的策略代码。难道不可能在每个处理器上运行一个流(或者上面已经发生了什么?)?好的,您可能是对的,基本思想本身更像是一种流式算法。但是,对于流式传输,需要根据需要计算每个要检查的坐标,而我在数组版本中预先计算所有坐标。我希望数组案例中的后续计算(实际工作)因此会更快,并且当我进行更多计算时它会得到回报。看来我错了。
    • @imladris -- 发生的情况是列表以流式方式分成给定大小的块,并且这些块被触发以进行评估。每个火花的评估由 RTS 分配给可用的处理器。您还可以手动创建不同“扇区”的列表,然后使用parTuple 等显式地并行运行它们。我的结果比parListChunk 代码更差,所以我没有追求。
    【解决方案3】:

    我在 Haskell wiki 中添加了一些关于如何优化 Repa 程序的建议: http://www.haskell.org/haskellwiki/Numeric_Haskell:_A_Repa_Tutorial#Optimising_Repa_programs

    【讨论】:

      猜你喜欢
      • 2014-01-07
      • 2020-01-05
      • 1970-01-01
      • 2021-10-26
      • 2010-11-03
      • 1970-01-01
      • 1970-01-01
      • 2013-11-08
      相关资源
      最近更新 更多