解析器组合器可以提高效率吗?

时间:2010-12-30 01:46:54

标签: haskell f# parser-generator parser-combinators parsec

大约6年前,我在OCaml中对自己的解析器组合器进行了基准测试,发现它们比当时提供的解析器生成器慢〜5倍。我最近重新审视了这个主题,并对Haskell的Parsec与使用F#编写的简单手工precedence climbing parser进行了对比,并惊讶地发现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 个答案:

答案 0 :(得分:59)

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

主要变化:

  1. 将Parsec / String更改为Attoparsec / ByteString
  2. fact功能中,更改read&amp; many1 digitdecimal
  3. 严格chainl1递归(删除$!for lazier version)。
  4. 我尽量保持你所拥有的一切尽可能相似。

    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的优先攀爬解析器更快。也许这将是一个有趣的测试?

答案 1 :(得分:32)

我目前正在研究FParsec的下一个版本(v.9。),在许多情况下,相对于current version,性能最多会提高2倍。

[更新:FParsec 0.9已发布,请参阅http://www.quanttec.com/fparsec]

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

我测量了以下数字(pare 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

Parser#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

答案 2 :(得分:13)

简而言之,解析器组合器很慢。

有一个用于构建词法分析器的Haskell组合器库(请参阅“Lazy Lexing is Fast”Manuel M. T. Chakravarty) - 因为这些表是在运行时生成的,所以没有代码生成的麻烦。该库得到了一些使用 - 它最初用于其中一个FFI预处理器,但我认为它没有上传到Hackage,所以也许它对于常规使用来说有点太不方便了。

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

答案 3 :(得分:8)

您是否尝试过一种已知的快速解析器库? Parsec的目标从来没有真正的速度,但易用性和清晰度。比较像attoparsec这样的东西可能是一个更公平的比较,特别是因为字符串类型可能更平等(ByteString而不是String)。

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