【问题标题】:Why is the F# version of this program 6x faster than the Haskell one?为什么这个程序的 F# 版本比 Haskell 快 6 倍?
【发布时间】:2016-05-30 13:20:23
【问题描述】:

Haskell 版本(1.03s):

module Main where
  import qualified Data.Text as T
  import qualified Data.Text.IO as TIO
  import Control.Monad
  import Control.Applicative ((<$>))
  import Data.Vector.Unboxed (Vector,(!))
  import qualified Data.Vector.Unboxed as V

  solve :: Vector Int -> Int
  solve ar =
    V.foldl' go 0 ar' where
      ar' = V.zip ar (V.postscanr' max 0 ar)
      go sr (p,m) = sr + m - p

  main = do
    t <- fmap (read . T.unpack) TIO.getLine -- With Data.Text, the example finishes 15% faster.
    T.unlines . map (T.pack . show . solve . V.fromList . map (read . T.unpack) . T.words)
      <$> replicateM t (TIO.getLine >> TIO.getLine) >>= TIO.putStr

F#版本(0.17s):

open System

let solve (ar : uint64[]) =
    let ar' = 
        let t = Array.scanBack max ar 0UL |> fun x -> Array.take (x.Length-1) x
        Array.zip ar t

    let go sr (p,m) = sr + m - p
    Array.fold go 0UL ar'

let getIntLine() =
    Console.In.ReadLine().Split [|' '|]
    |> Array.choose (fun x -> if x <> "" then uint64 x |> Some else None)    

let getInt() = getIntLine().[0]

let t = getInt()
for i=1 to int t do
    getInt() |> ignore
    let ar = getIntLine()
    printfn "%i" (solve ar)

以上两个程序是Stock Maximize problem的解决方案,时间是Run Code按钮的第一个测试用例。

由于某种原因,F# 版本的速度大约快了 6 倍,但我很确定,如果我用命令式循环替换慢速库函数,我可以将其速度提高至少 3 倍,更有可能提高 10 倍。

Haskell 版本是否可以进行类似的改进?

我这样做是出于学习目的,总的来说,我发现很难弄清楚如何编写高效的 Haskell 代码。

【问题讨论】:

  • 只是为了记录,你在测量什么?整个程序的执行(从命令行)或主体的运行时间(使用 sn-p 中未显示的东西)?
  • 我曾想象过,对于纯函数式代码,Haskell 至少会领先于 F#。在当前的4.0 版本中,F# 甚至没有内联折叠、扫描和压缩。我想,对于大致相似的纯功能代码,Haskell 应该超过 F#,因为它的纯度提供了优化。
  • 我会分别分析 IO 代码和实际计算。您的输入代码当前从文本转换为字符串(使用 T.unpack),然后使用已知非常慢的“读取”。
  • 为什么 Haskell 版本使用replicateM 读取所有内容,然后才开始计算? F# 版本不这样做。你不能移动solve 以便在replicateM 中调用它(并打印它的结果)吗?现在,您似乎正在处理 F# 中不存在的大型列表/文本字符串。
  • @MarkoGrdinic 仅供参考,大多数人都远离wall of text questions。如果你的问题简短而中肯,你会得到更好的回答。

标签: performance haskell f#


【解决方案1】:

如果您切换到 ByteString 并坚持使用普通的 Haskell 列表(而不是向量),您将获得更有效的解决方案。您也可以用一次左折叠重写求解函数并绕过 zip 和右扫描 (1)。 总的来说,在我的机器上,与您的 Haskell 解决方案相比,我的性能提高了 20 倍 (2)

下面的 Haskell 代码比 F# 代码执行得更快:

import Data.List (unfoldr)
import Control.Applicative ((<$>))
import Control.Monad (replicateM_)
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as C

parse :: ByteString -> [Int]
parse = unfoldr $ C.readInt . C.dropWhile (== ' ')

solve :: [Int] -> Int
solve xs = foldl go (const 0) xs minBound
    where go f x s = if s < x then f x else s - x + f s

main = do
    [n] <- parse <$> B.getLine
    replicateM_ n $ B.getLine >> B.getLine >>= print . solve . parse

1。有关此答案的早期版本,请参阅 edits,它使用 zipscanr 实现 solve
2。 HackerRank 网站的性能提升幅度更大。

【讨论】:

  • 我知道 String 函数应该很慢,但是 Text 函数也应该很慢吗?
  • @MarkoGrdinic 仅带有 ascii 文本,我的 猜测ByteString.Char8 的执行速度比 Text 快;但依赖于您自己的基准。
  • 如果您恢复对主要solve 函数使用未装箱的向量,您可以将其速度提高大约五倍 - 0.02 而不是 0.1,现在问题已经得到很好的分析 sprunge.us/ PUYW
  • 意识到在普通类型的 foldl :: Foldable t => (b -> a -> b) -> b -> ta -> b 中,“b”可能是“c”类型-> d",在这种情况下,类型将是(去掉多余的括号)可折叠 t => ((c ->d) -> a -> c ->d) -> (c -> d) -> ta -> c -> d,它与他传递的参数的形状相匹配。换句话说,他的累加器是一个函数,当他在列表中运行时会发生变化,从 (const 0) 开始。
  • @גלעדברקן 几天前有一个different question,Behzad 以类似的方式写了答案,我花了很长时间分析 lambda 作为累加器的用法。就个人而言,我确实认为这很令人困惑,使用元组而不是累积额外值会更有意义。理解它还需要了解 lambda 演算的基础知识。
【解决方案2】:

如果我想在 F# 中快速做到这一点,我会避免使用 solve 中的所有高阶函数,而只需编写一个 C 风格的命令式循环:

let solve (ar : uint64[]) =
  let mutable sr, m = 0UL, 0UL
  for i in ar.Length-1 .. -1 .. 0 do
    let p = ar.[i]
    m <- max p m
    sr <- sr + m - p
  sr

根据我的测量,这比你的 F# 快 11 倍。

然后性能受限于 IO 层(unicode 解析)和字符串拆分。这可以通过读取字节缓冲区并手动编写词法分析器来优化:

let buf = Array.create 65536 0uy
let mutable idx = 0
let mutable length = 0

do
  use stream = System.Console.OpenStandardInput()
  let rec read m =
    let c =
      if idx < length then
        idx <- idx + 1
      else
        length <- stream.Read(buf, 0, buf.Length)
        idx <- 1
      buf.[idx-1]
    if length > 0 && '0'B <= c && c <= '9'B then
      read (10UL * m + uint64(c - '0'B))
    else
      m
  let read() = read 0UL
  for _ in 1UL .. read() do
    Array.init (read() |> int) (fun _ -> read())
    |> solve
    |> System.Console.WriteLine

【讨论】:

  • 当我看到一个带有 F# 和 Haskell 两个词的问题时,我就开始扫描页面寻找 Harrop 博士的回答!
  • @Michael:我的程序在这台机器上运行大约需要 0.000001 秒,因此 HackerRank 正在测量 F# 的 JIT 编译时间 + 运行时间与 Haskell 的运行时间。
  • 不确定“像 C 一样编写”是一个合法的响应。当然它会更快,但使用 F# 或 Haskell 的全部意义在于,我不必像 C 那样编写。
  • @3noch:正如 Yaron Minsky 所说的“不要对纯洁持清教态度”。
  • @3noch:当然,这就是 F# 或 Haskell 的全部意义所在,但是问一个关于合理快速代码的问题“我怎样才能让它更快”不可避免地问“如何我可以让它更像 C"。这怎么不是合法的回应?
【解决方案3】:

仅作记录,F# 版本也不是最优的。在这一点上我认为这并不重要,但如果人们想比较性能,那么值得注意的是它可以做得更快。

我并没有很努力地尝试过(你当然可以通过使用受限突变来使其更快,这不会违反 F# 的本质),但是在正确的地方使用 Seq 而不是 Array 进行简单的更改(避免分配临时数组)使代码大约快 2 到 3 倍:

let solve (ar : uint64[]) =
    let ar' = Seq.zip ar (Array.scanBack max ar 0UL)    
    let go sr (p,m) = sr + m - p
    Seq.fold go 0UL ar'

如果您使用Seq.zip,您也可以放弃take 调用(因为Seq.zip 会自动截断序列)。使用#time 使用以下 sn-p 测量:

let rnd = Random()
let inp = Array.init 100000 (fun _ -> uint64 (rnd.Next()))
for a in 0 .. 10 do ignore (solve inp) // Measure this line

原始代码大约需要 150 毫秒,使用新版本大约需要 50-75 毫秒。

【讨论】:

  • @JonHarrop 我希望你能加入游戏 :-)。不要担心被否决,整个问题可能很快就会被删除,因为它“不符合问答格式”或其他什么。
  • @AdamCopley:平心而论,不应该。
  • 这位操作员要求审查他的工作代码,以考虑性能,以及如何编写更高效的 Haskell 代码。他们的关键是它是工作代码。这怎么不是审核请求?
  • @AdamCopley OP 询问了 Haskell 和 F# 之间的性能差异。提供了每种语言的示例 sn-ps 代码,建议或多或少等效。在基准测试中,示例代码至关重要,因此显然需要讨论、调整或替换这些示例。但问题与代码无关,也不是代码审查请求。那里完全是题外话。
  • 如果提问者询问如何改进工作代码的 sn-p,对我来说这是一个审查请求,简单明了,没有争论。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2011-08-22
  • 2020-12-10
  • 2021-05-30
  • 1970-01-01
  • 2015-02-10
  • 2014-07-13
  • 2018-11-12
相关资源
最近更新 更多