【问题标题】:Non-trivial algorithm conversion from imperative to functional从命令式到函数式的非平凡算法转换
【发布时间】:2021-01-13 04:34:13
【问题描述】:

为了加强我(薄弱的)函数式编程技能,我正在研究 PieglTiller 将所有算法转换为 Haskell 的 NURBS 书 .这是一个非常好的和有启发性的过程,但我被算法 2.2 卡住了,这里是伪代码(一个类似 C 的 reworked-by-me 版本):

double[] BasisFuns( int i, double u, int p, double U[]) {
  double N[p+1];
  double left[p+1];
  double ritght[p+1];
  N[0]=1.0;
  for (j=1; j<=p; j++) {
    left[j]  = u - U[i+1-j];
    right[j] = U[i+j] - u;
    double saved = 0.0;
    for (r=O; r<j; r++) {
      double temp= N[r]/(right[r+1]+left[j-r]);
      N[r] = saved+right[r+1]*temp;
      saved = left[j-r]*temp;
    }
    N[j] = saved;
  }
  return N;
}

外层循环看起来很简单,但内层循环对N 的元素进行了所有这些必要的有序修改,这让我很头疼。

我开始这样设置:

baseFunc :: RealFrac a => Int -> Int -> a -> [a] -> [a]
baseFunc i p u knots
  [ ??? | j <- [1..p], r <- [0..j] ]
  where
  left = [ u - knots !! (i+1-j) | j <- [ 1 .. p ]
  right= [ knots !! (i+j) - u   | j <- [ 1 .. p ]

但我觉得我可能完全偏离了道路。

我已经基于 Eq. 2.5,因此在这里我希望保持命令式版本的性能。

【问题讨论】:

  • 我不确定这是否可以很好地翻译。它利用了对数组的大量随机访问,并且到处都有突变。特别是在性能是一个问题的情况下,我会将其转换为 Haskell,保持其命令性并使用 STUArrayST monad。
  • 直接从 C 翻译可能不是保持性能的方式。编译器做的比你想象的要多。
  • @chepner 这本书还提供了递归方程(方程 2.5)。我不确定它们是否适合这个场地。无论如何,从他们开始,在 Haskell 中获得一个有效的实现真的很容易,但是获得与上面的伪代码一样高效的东西似乎...... hard
  • 诚然,在您拥有两个工作实现之前,您无法真正进行基准测试,但我怀疑您低估了编译器可以使用递归定义执行的功能。我怀疑即使是优化的 C 编译器也会产生与你所期望的 C 代码完全不同的东西。

标签: list algorithm haskell functional-programming


【解决方案1】:

我不确定它是否会起作用,但是...:

baseFunc :: RealFrac a => Int -> Int -> a -> [a] -> [a]
baseFunc i p u knots =
    foldl' helper [1.0] [1..p]

    where
    left = [ u - knots !! (i+1-j) | j <- [ 1 .. p ] ]
    right= [ knots !! (i+j) - u   | j <- [ 1 .. p ] ]

    helper  N  j = outer_loop j N left right

    inner_loop :: RealFrac a => Int -> Int -> [a] -> [a] -> [a] -> a -> (a, a)
    inner_loop  r  j  N  left  right  saved =
        let temp = N !! r / (right !! (r+1) + left !! (j-r))
        in  (saved + right !! (r+1) * temp, left !! (j-r) * temp)

    outer_loop :: RealFrac a => Int -> [a] -> [a] -> [a] -> [a]
    outer_loop  j  N  left  right =
        let (new_N, saved) = foldl' helper (N, 0.0) [0..j-1]
            helper  (prev_N, saved)  r = 
                let (N_r, new_saved) = inner_loop r j prev_N left right saved
                in  (insertAt r N_r prev_N, new_saved)
        in  new_N ++ [saved]

【讨论】:

    【解决方案2】:

    虽然从 Fortran/Matlab/C “一切都是数组”风格开始,当然可以将数值算法转换为 Haskell(并且,使用未装箱的可变向量,性能通常不会差多少),但这确实没有抓住重点关于使用函数式语言。 底层数学实际上比命令式编程更接近函数式编程,所以最好从那里开始。具体来说,递归公式

    几乎可以按字面意思翻译成 Haskell,比翻译成命令式语言要好得多:

    baseFuncs :: [Double]  -- ^ Knots, \(\{u_i\}_i\)
              -> Int       -- ^ Index \(i\) at which to evaluate
              -> Int       -- ^ Spline degree \(p\)
              -> Double    -- ^ Position \(u\) at which to evaluate
              -> Double
    baseFuncs us i 0 u
      | u >= us!!i, u < us!!(i+1)  = 1
      | otherwise                  = 0
    baseFuncs us i p u
          = (u - us!!i)/(us!!(i+p) - us!!i) * baseFuncs us i (p-1) u
           + (us!!(i+p+1) - u)/(us!!(i+p+1) - us!!(i+1)) * baseFuncs us (i+1) (p-1) u
    

    不幸的是,由于多种原因,这实际上效率不高。

    首先,列表不适合随机访问。一个简单的解决方法是切换到未装箱(但纯)的向量。当我们在这里时,让我们将它们包装在一个新类型中,因为 ui 应该是严格增加的。谈论类型:直接访问是不安全的;我们可以通过将p 和段数带到类型级别并只允许索引i &lt; n-p 来解决这个问题,但我不会在这里讨论。

    另外,在递归中一直传递usu 很尴尬,最好只绑定一次,然后使用辅助函数向下传递:

    import Data.Vector.Unboxed (Vector, (!))
    import qualified Data.Vector.Unboxed as VU
    
    newtype Knots = Knots {getIncreasingKnotsSeq :: Vector Double}
    
    baseFuncs :: Knots     -- ^ \(\{u_i\}_i\)
              -> Int       -- ^ Index \(i\) at which to evaluate
              -> Int       -- ^ Spline degree \(p\)
              -> Double    -- ^ Position \(u\) at which to evaluate
              -> Double
    baseFuncs (Knots us) i₀ p₀ u = go i₀ p₀
     where go i 0
            | u >= us!i
            , i>=VU.length us-1 || u < us!(i+1)  = 1
            | otherwise                          = 0
           go i p
               = (u - us!i)/(us!(i+p) - us!i) * go i (p-1)
                + (us!(i+p+1) - u)/(us!(i+p+1) - us!(i+1)) * go (i+1) (p-1)
    

    另一件不是最优的事情是我们不会在相邻的递归调用之间共享较低级别的评估。 (评估实际上跨越了一个具有 p22 节点的有向图,但我们将其评估为具有 2p 节点的树。)对于大型 p 来说,这是一个 巨大 的低效率,但对于典型的低度样条。

    避免这种低效率的方法是记忆。 C 版本使用 N 数组显式执行此操作,但是 - 这是 Haskell - 我们可以懒惰地通过使用通用记忆库来节省分配正确大小的工作,例如memo-trie:

    import Data.MemoTrie (memo2)
    
    baseFuncs (Knots us) i₀ p₀ u = go' i₀ p₀
     where go i 0
            | u >= us!i
            , i>=VU.length us || u < us!(i+1)  = 1
            | otherwise                        = 0
           go i p
               = (u - us!i)/(us!(i+p) - us!i) * go' i (p-1)
                + (us!(i+p+1) - u)/(us!(i+p+1) - us!(i+1)) * go' (i+1) (p-1)
           go' = memo2 go
    

    那是无脑版本(“只需记住 go 的整个域”)。正如 dfeuer 所说,很容易只显式记住实际被评估的区域,然后我们可以再次使用有效的未装箱向量:

    baseFuncs (Knots us) i₀ p₀ u = VU.unsafeHead $ gol i₀ p₀
     where gol i 0 = VU.generate (p₀+1) $ \j ->
            if u >= us!(i+j)
                && (i+j>=VU.length us || u < us!(i+j+1))
             then 1 else 0
           gol i p = case gol i (p-1) of
            res' -> VU.izipWith
             (\j l r -> let i' = i+j
                in (u - us!i')/(us!(i'+p) - us!i') * l
                + (us!(i'+p+1) - u)/(us!(i'+p+1) - us!(i'+1)) * r)
             res' (VU.unsafeTail res')
    

    (我可以在这里安全地使用unsafeHeadunsafeTail,因为在每个递归级别,压缩都会将长度减少1,所以在顶层我还剩下p₀ - (p₀-1) = 1 元素。)

    我认为,这个版本应该与 C 版本具有相同的渐近性。通过一些更小的改进,例如预先计算间隔长度和预先检查参数是否在允许的范围内,以便可以进行所有访问unsafe,它的性能可能非常接近 C 版本。

    正如 - 再次 - dfeuer 所说,甚至可能不需要在此处使用向量,因为我只是将结果压缩在一起。对于这类东西,即使使用普通列表,GHC 也可以非常擅长优化代码。但是,我不会在这里进一步研究性能。


    我用来确认它确实有效的测试:

    https://gist.github.com/leftaroundabout/4fd6ef8642029607e1b222783b9d1c1e

    【讨论】:

    • 非常感谢您的深刻见解。你的第一个版本和我写的很相似。但是这个函数在下面的计算中被调用了很多次,我讨厌浪费时间和电力!如果 Haskell 在这样一个纯粹的计算任务上无法轻松匹配命令式版本的性能,那么将我 10 多年的 C++ 经验分开可能对我来说没什么好处
    • 可以通过编写命令式代码(在ST monad中)轻松匹配命令式版本的性能。或者只是用 C 语言编写它并使用 FFI 链接到 Haskell,这始终是一种选择。我的观点是,这通常不是一种明智的方法,因为您失去了使用函数式语言的好处。但是可以肯定的是,当您确定此函数是瓶颈时,您仍然可以随时将其更改为丑陋的命令式样式。
    • 任何 log(n) 性能损失都来自 MemoTrie。为什么不直接使用盒装向量,让懒惰发挥作用呢?
    • @DarioP 我根据大卫的评论进行了更新。如果您检查性能落后于 C 的程度,我会很想听听...
    • 我仍然需要能够有效地对 Haskell 代码进行基准测试。但是,如果您愿意,这里是 C 版本 gist.github.com/dpellegr/3a1f266d34bd7f4462e6bf7004380074。针对特定度数p执行计算。对于更接近真实场景的基准测试,将节点向量的大小增加到至少几百可能会有所帮助。
    【解决方案3】:

    (免责声明:我对这里计算的内容一无所知。)

    数组U 中的访问模式似乎如下:从索引i 向外,我们消费左边和右边的值。我们可以想象有两个列表精确地包含在这些元素序列中。事实上,我们可以像这样从源列表中构造出这样的列表:

    pryAt :: Int -> [a] -> ([a], [a])
    pryAt i xs = go i ([], xs)
      where
        go 0 a = a
        go n (us, v : vs) = go (pred n) (v : us, vs)
    -- pryAt 5 ['a'..'x']
    -- ("edcba","fghijklmnopqrstuvwx")
    

    对于随机访问容器,我们可以有专门的 pryAt 版本,因为遍历整个列表直到到达 ith 元素将是低效的。

    在外部循环中,我们有数组Nleftright,它们随着每次迭代而增长(N 似乎在每次迭代时也完全重建)。我们可以将它们表示为列表。在每次迭代中,我们还消耗了左右一对U的元素。

    以下数据类型表示外循环迭代开始时的情况:

    data State = State
      { ns :: [Double],
        left :: [Double],
        right :: [Double],
        us :: ([Double], [Double])
      }
    

    假设我们已经实现了outerStep :: State -&gt; State,我们可以简单地转动曲柄p 次:

    basis :: Int -> Double -> Int -> [Double] -> [Double]
    basis i u p us =
      ns $ iterate outerStep initial !! p
      where
        initial =
          State
            { ns = [1.0],
              left = [],
              right = [],
              us = pryAt i us
            }
    

    outerStep 做了什么?我们向leftright 添加新元素,然后从头开始重新创建整个N 列表,同时携带saved 累加器。这是mapAccumR。我们需要一些额外的信息:right 值(与N 方向相同)和left 值(反向)所以我们需要预先压缩它们:

        outerStep (State {ns, left, right, us = (ul : uls, ur : urs)}) =
          let left' = u - ul : left
              right' = ur - u : right
              (saved', ns') = mapAccumR innerStep 0.0 $ zip3 ns right' (reverse left')
           in State
                { ns = saved' : ns',
                  left = left',
                  right = right',
                  us = (uls, urs)
                }
    

    下面是内部步骤的计算:

        innerStep saved (n, r, l) = 
            let temp = n / (r - l)
                n' = saved + r
                saved' = l * temp
             in (saved', n')
    

    除了纠正可能的错误外,还需要做更多的工作,因为当前形式的basis 函数可能会泄漏内存(特别是mapAccumR 会产生大量的thunk)。也许可以重写它以使用像 iterate'foldl' 这样的函数来保持其累加器严格。

    【讨论】:

    • mapAccumR 一起,reverse 对于列表来说是有问题的。
    • 也许mapAccumL 可以用来代替mapAccumRmapAccumL 对开箱即用的累加器并不严格,但似乎我们可以通过对累加器设置严格的函数参数来确保:let (_,xs) = mapAccumL (\ !acc b -&gt; (acc + b,b)) undefined (take 10000000 [0::Int ..]) in take 10 xsmail.haskell.org/pipermail/libraries/2012-November/018775.html 另外,也许Data.Sequence.Seqs 可以用来代替列表。
    猜你喜欢
    • 1970-01-01
    • 2013-10-11
    • 1970-01-01
    • 2012-09-11
    • 2011-01-19
    • 2011-09-30
    • 1970-01-01
    • 2011-06-15
    • 2012-10-17
    相关资源
    最近更新 更多