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ソリューションよりも30倍速いHaskellソリューションを思いついた(私の作成したテスト式を使って)。

主な変更点:

  1. Parsec/StringをAttoparsec/ByteStringに変更します
  2. fact関数で、readmany1 digitをに変更しますdecimal
  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倍向上します。

[更新:FParsec0.9がリリースされました。http://www.quanttec.com/fparsecを参照してください]

JonのF#パーサー実装を2つのFParsec実装に対してテストしました。最初のFParsecパーサーは、djahandarieのパーサーを直接翻訳したものです。2つ目は、FParsecの埋め込み可能な演算子の優先順位コンポーネントを使用します。入力として、パラメータ10のJonのOCamlスクリプトで生成された文字列を使用しました。これにより、入力サイズは約2.66MBになります。すべてのパーサーはリリースモードでコンパイルされ、32ビットの.NET4CLRで実行されました。純粋な解析時間のみを測定し、起動時間や、入力文字列(FParsecパーサーの場合)またはcharリスト(Jonのパーサー)の作成に必要な時間は含まれていません。

私は次の数値を測定しました(括弧内のv。0.9の更新された数値):

  • ジョンの手巻きパーサー:〜230ms
  • FParsecパーサー#1:〜270ms(〜235ms)
  • FParsecパーサー#2:〜110ms(〜102ms)

これらの数値に照らして、パーサーコンビネーターは、少なくともこの特定の問題に対して、特にFParsecを考慮に入れると、確実に競争力のあるパフォーマンスを提供できると思います。

  • 読みやすいエラーメッセージを自動的に生成します。
  • 入力として非常に大きなファイルをサポートし(任意のバックトラッキングを使用)、
  • 宣言型のランタイム構成可能な演算子優先順位パーサーモジュールが付属しています。

2つの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コンビネーターライブラリがありました(「レイジーレクシングは高速」を参照)Manuel MT Chakravarty-テーブルは実行時に生成されたため、コード生成の手間はありませんでした。ライブラリは少し使用されました。最初はFFIプリプロセッサの1つで使用されていましたが、Hackageにアップロードされたことがないと思うので、通常の使用には少し不便だったのかもしれません。

上記のOCamlコードでは、パーサーはchar-listで直接一致しているため、リストの破棄がホスト言語で行われるのと同じくらい高速になります(Haskellで再実装された場合はParsecよりもはるかに高速になります)。Christian Lindigには、パーサーコンビネーターのセットとレクサーコンビネーターのセットを備えたOCamlライブラリがありました-レクサーコンビネーターは確かにManuel Chakravartyのものよりもはるかに単純であり、レクサーを作成する前にこのライブラリを追跡してベンチマークする価値があるかもしれません発生器。

于 2010-12-30T08:35:57.933 に答える
8

既知の高速パーサーライブラリの1つを試しましたか?Parsecの目的は、実際にはスピードではなく、使いやすさと明快さです。attoparsecのようなものと比較すると、特に文字列タイプが(ByteStringではなくString)より等しくなる可能性が高いため、より公平な比較になる可能性があります。

また、どのコンパイルフラグが使用されたのだろうか。これは悪名高いJonHarropによる別のトローリングポストであり、Haskellコードに最適化がまったく使用されていなくても驚かないでしょう。

于 2010-12-30T04:55:30.057 に答える