35

大约 6 年前,我在 OCaml 中对自己的解析器组合器进行了基准测试,发现它们比当时提供的解析器生成器慢约 5 倍。我最近重新审视了这个主题,并将 Haskell 的 Parsec 与用 F# 编写的简单的手动优先爬升解析器进行了基准测试,并惊讶地发现 F# 比 Haskell 快 25 倍。

这是我用来从文件中读取大型数学表达式、解析和评估它的 Haskell 代码:

import Control.Applicative
import Text.Parsec hiding ((<|>))

expr = chainl1 term ((+) <$ char '+' <|> (-) <$ char '-')

term = chainl1 fact ((*) <$ char '*' <|> div <$ char '/')

fact = read <$> many1 digit <|> char '(' *> expr <* char ')'

eval :: String -> Int
eval = either (error . show) id . parse expr "" . filter (/= ' ')

main :: IO ()
main = do
    file <- readFile "expr"
    putStr $ show $ eval file
    putStr "\n"

这是我在 F# 中的自包含优先级攀爬解析器:

let rec (|Expr|) = function
  | P(f, xs) -> Expr(loop (' ', f, xs))
  | xs -> invalidArg "Expr" (sprintf "%A" xs)
and loop = function
  | ' ' as oop, f, ('+' | '-' as op)::P(g, xs)
  | (' ' | '+' | '-' as oop), f, ('*' | '/' as op)::P(g, xs) ->
      let h, xs = loop (op, g, xs)
      match op with
      | '+' -> (+) | '-' -> (-) | '*' -> (*) | '/' | _ -> (/)
      |> fun op -> loop (oop, op f h, xs)
  | _, f, xs -> f, xs
and (|P|_|) = function
  | '('::Expr(f, ')'::xs) -> Some(P(f, xs))
  | c::_ as xs when '0' <= c && c <= '9' ->
      let rec loop n = function
        | c2::xs when '0' <= c2 && c2 <= '9' -> loop (10*n + int(string c2)) xs
        | xs -> Some(P(n, xs))
      loop 0 xs
  | _ -> None

我的印象是,即使是最先进的解析器组合器也会浪费大量时间进行回溯。那是对的吗?如果是这样,是否可以编写生成状态机的解析器组合器以获得具有竞争力的性能,或者是否有必要使用代码生成?

编辑:

这是我用来生成 ~2Mb 表达式以进行基准测试的 OCaml 脚本:

open Printf

let rec f ff n =
  if n=0 then fprintf ff "1" else
    fprintf ff "%a+%a*(%a-%a)" f (n-1) f (n-1) f (n-1) f (n-1)

let () =
  let n = try int_of_string Sys.argv.(1) with _ -> 3 in
  fprintf stdout "%a\n" f n
4

4 回答 4

62

我提出了一个 Haskell 解决方案,它比您发布的 Haskell 解决方案快 30 倍(使用我编造的测试表达式)。

主要变化:

  1. 将 Parsec/String 更改为 Attoparsec/ByteString
  2. fact函数中,将read&更改many1 digitdecimal
  3. 使chainl1递归严格(删除 $! 用于惰性版本)。

我试图让你拥有的其他一切尽可能相似。

import Control.Applicative
import Data.Attoparsec
import Data.Attoparsec.Char8
import qualified Data.ByteString.Char8 as B

expr :: Parser Int
expr = chainl1 term ((+) <$ char '+' <|> (-) <$ char '-')

term :: Parser Int
term = chainl1 fact ((*) <$ char '*' <|> div <$ char '/')

fact :: Parser Int
fact = decimal <|> char '(' *> expr <* char ')'

eval :: B.ByteString -> Int
eval = either (error . show) id . eitherResult . parse expr . B.filter (/= ' ')

chainl1 :: (Monad f, Alternative f) => f a -> f (a -> a -> a) -> f a
chainl1 p op = p >>= rest where
  rest x = do f <- op
              y <- p
              rest $! (f x y)
           <|> pure x

main :: IO ()
main = B.readFile "expr" >>= (print . eval)

我想我从中得出的结论是,解析器组合器的大部分减速是因为它位于一个低效的基础上,而不是它本身就是一个解析器组合器。

我想如果有更多的时间和分析,这可能会更快,因为当我超过 25 倍标记时我停止了。

我不知道这是否会比移植到 Haskell 的优先级攀升解析器更快。也许这将是一个有趣的测试?

于 2010-12-30T06:51:35.997 回答
32

我目前正在开发 FParsec 的下一个版本(v. 0.9),它在许多情况下将相对于当前版本将性能提高多达 2 倍。

[更新:FParsec 0.9 已经发布,见http://www.quanttec.com/fparsec ]

我已经针对两个 FParsec 实现测试了 Jon 的 F# 解析器实现。第一个 FParsec 解析器是 djahandarie 解析器的直接翻译。第二个使用 FParsec 的可嵌入运算符优先级组件。作为输入,我使用了由 Jon 的 OCaml 脚本生成的字符串,参数为 10,这给了我大约 2.66MB 的输入大小。所有解析器都在发布模式下编译,并在 32 位 .NET 4 CLR 上运行。我只测量了纯解析时间,没有包括启动时间或构造输入字符串(对于 FParsec 解析器)或字符列表(Jon 的解析器)所需的时间。

我测量了以下数字(括号中为 v. 0.9 的更新数字):

  • Jon 的手动解析器:~230ms
  • FParsec 解析器 #1:~270ms (~235ms)
  • FParsec 解析器 #2:~110ms (~102ms)

鉴于这些数字,我想说解析器组合器绝对可以提供有竞争力的性能,至少对于这个特定的问题,特别是如果你考虑到 FParsec

  • 自动生成高度可读的错误消息,
  • 支持非常大的文件作为输入(带有任意回溯),并且
  • 带有一个声明性的、运行时可配置的运算符优先级解析器模块。

这是两个 FParsec 实现的代码:

解析器 #1(djahandarie 解析器的翻译):

open FParsec

let str s = pstring s
let expr, exprRef = createParserForwardedToRef()

let fact = pint32 <|> between (str "(") (str ")") expr
let term =   chainl1 fact ((str "*" >>% (*)) <|> (str "/" >>% (/)))
do exprRef:= chainl1 term ((str "+" >>% (+)) <|> (str "-" >>% (-)))

let parse str = run expr str

解析器#2(惯用的 FParsec 实现):

open FParsec

let opp = new OperatorPrecedenceParser<_,_,_>()
type Assoc = Associativity

let str s = pstring s
let noWS = preturn () // dummy whitespace parser

opp.AddOperator(InfixOperator("-", noWS, 1, Assoc.Left, (-)))
opp.AddOperator(InfixOperator("+", noWS, 1, Assoc.Left, (+)))
opp.AddOperator(InfixOperator("*", noWS, 2, Assoc.Left, (*)))
opp.AddOperator(InfixOperator("/", noWS, 2, Assoc.Left, (/)))

let expr = opp.ExpressionParser
let term = pint32 <|> between (str "(") (str ")") expr
opp.TermParser <- term

let parse str = run expr str
于 2010-12-31T00:23:38.123 回答
13

简而言之,解析器组合器的词法分析速度很慢。

有一个用于构建词法分析器的 Haskell 组合库(请参阅“Lazy Lexing is Fast”Manuel MT Chakravarty)——由于表是在运行时生成的,因此没有代码生成的麻烦。该库使用了一点——它最初用于 FFI 预处理器之一,但我认为它从未上传到 Hackage,所以对于常规使用来说可能有点太不方便了。

在上面的 OCaml 代码中,解析器直接匹配字符列表,因此它可以与宿主语言中的列表解构一样快(如果在 Haskell 中重新实现它会比 Parsec 快得多)。Christian Lindig 有一个 OCaml 库,其中包含一组解析器组合器和一组词法分析器组合器 - 词法分析器组合器肯定比 Manuel Chakravarty 的简单得多,在编写词法分析器之前跟踪这个库并对其进行基准测试可能是值得的发电机。

于 2010-12-30T08:35:57.933 回答
8

您是否尝试过已知的快速解析器库之一?Parsec 的目标从来不是真正的速度,而是易用性和清晰度。与 attoparsec 之类的比较可能是更公平的比较,尤其是因为字符串类型可能更相等(ByteString而不是String)。

我也想知道使用了哪些编译标志。这是臭名昭著的 Jon Harrop 的另一篇巨魔帖子,如果没有对 Haskell 代码进行任何优化,我也不会感到惊讶。

于 2010-12-30T04:55:30.057 回答