¿Se pueden hacer eficientes los combinadores parser?


Hace alrededor de 6 años, comparé mis propios combinadores de analizadores en OCaml y descubrí que eran ~5× más lentos que los generadores de analizadores que se ofrecían en ese momento. Recientemente revisé este tema y comparé el Parsec de Haskell contra un simple analizador de escalada de precedencia hecho a mano escrito en F# y me sorprendió encontrar que el F# era 25× más rápido que el Haskell.

Aquí está el código Haskell que utilicé para leer una gran expresión matemática de file, analizar y evaluar it:

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"

Y aquí está mi analizador de escalada de precedencia autónomo en 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

Mi impresión es que incluso los combinadores de analizadores de última generación pierden mucho tiempo rastreando. ¿Es correcto? Si es así, ¿es posible escribir combinadores de parser que generen máquinas de estado para obtener un rendimiento competitivo o es necesario utilizar la generación de código?

EDITAR:

Aquí está el script OCaml que usé para generar una expresión ~2Mb para evaluación comparativa:

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
Author: Jon Harrop, 2010-12-30

4 answers

Actualmente estoy trabajando en la próxima versión de FParsec (v. 0.9), que en muchas situaciones mejorar el rendimiento hasta en un factor de 2 en relación al versión actual.

[Actualización: FParsec 0.9 ha sido lanzado, ver http://www.quanttec.com/fparsec ]

He probado la implementación de F# parser de Jon contra dos implementaciones de FParsec. El primer analizador de FParsec es una traducción directa del analizador de djahandarie. El segundo utiliza el embeddable de FParsec componente precedencia del operador. Como entrada utilicé una cadena generada con el script OCaml de Jon con el parámetro 10, lo que me da un tamaño de entrada de aproximadamente 2.66 MB. Todos los analizadores fueron compilados en modo release y se ejecutaron en el. NET 4 CLR de 32 bits. Solo medí el tiempo de análisis puro y no incluí el tiempo de inicio o el tiempo necesario para construir la cadena de entrada (para los analizadores de FParsec) o la lista de caracteres (analizador de Jon).

Medí los siguientes números (números actualizados para v. 0.9 en parens):

  • El analizador hecho a mano de Jon: ~230ms
  • Analizador de FParsec # 1: ~270ms (~235ms)
  • Analizador de FParsec # 2: ~110ms (~102ms)

A la luz de estos números, diría que los combinadores de analizadores definitivamente pueden ofrecer un rendimiento competitivo, al menos para este problema en particular, especialmente si se tiene en cuenta que FParsec

  • genera automáticamente mensajes de error altamente legibles,
  • soporta archivos muy grandes como entrada (con un retroceso arbitrario), y
  • viene con un módulo de analizador de precedencia de operador configurable en tiempo de ejecución declarativo.

Aquí está el código para las dos implementaciones de FParsec:

Parser #1 (Traducción del parser de 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 (Implementación idiomática de 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
 31
Author: Stephan Tolksdorf,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-04-27 18:43:10

Se me ocurrió una solución Haskell que es 30× más rápida que la solución Haskell que publicaste (con mi expresión de prueba inventada).

Cambios importantes:

  1. Cambie Parsec / String a Attoparsec / ByteString
  2. En la función fact, cambiar read & many1 digit a decimal
  3. Hizo la recursión chainl1 estricta(remove $! para la versión más perezosa).

Traté de mantener todo lo demás que tenía lo más similar posible.

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)

Supongo que lo que concluí a partir de esto es que la mayoría de la desaceleración para el combinador de parser fue que estaba sentado en una base ineficiente, no que era un combinador de parser, per se.

Me imagino que con más tiempo y perfilado esto podría ir más rápido, ya que me detuve cuando pasé la marca de 25×.

No se si esto sería más rápido que el analizador de escalada precedente portado a Haskell. ¿Tal vez sería una prueba interesante?

 58
Author: Darius Jahandarie,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-12-30 06:51:35

En pocas palabras, los combinadores de parser son lentos para el lexing.

Había una biblioteca Haskell combinator para construir lexers (ver "Lazy Lexing is Fast" Manuel M. T. Chakravarty) - como las tablas se generaban en tiempo de ejecución, no había la molestia de la generación de código. La biblioteca se usó un poco - inicialmente se usó en uno de los preprocesadores FFI, pero no creo que nunca se haya subido a Hackage, así que tal vez fue un poco demasiado inconveniente para el uso regular.

En el código OCaml arriba, el analizador está coincidiendo directamente con las listas de caracteres, por lo que puede ser tan rápido como la desestructuración de listas en el lenguaje host (sería mucho más rápido que Parsec si se volviera a implementar en Haskell). Christian Lindig tenía una biblioteca OCaml que tenía un conjunto de combinadores de analizador y un conjunto de combinadores de lexer - los combinadores de lexer eran ciertamente mucho más simples que los de Manuel Chakravarty, y podría valer la pena rastrear esta biblioteca y marcarla antes de escribir un lexer generador.

 13
Author: stephen tetley,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-12-30 08:35:57

¿Ha probado una de las bibliotecas de análisis rápido conocidas? Los objetivos de Parsec nunca han sido realmente la velocidad, sino la facilidad de uso y la claridad. Comparar con algo como attoparsec puede ser una comparación más justa, especialmente porque los tipos de cadena son probablemente más iguales (ByteString en lugar de String).

También me pregunto qué banderas de compilación se usaron. Siendo este otro trolling post del infame Jon Harrop, no me sorprendería si no se utilizaran optimizaciones para el Haskell codificar.

 8
Author: Axman6,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-05-29 16:44:05