josevalim
NimbleParsec - a simple and fast parser combinator for Elixir
Yes, yet another parser combinator library!
Most of the parser combinators in the ecosystem are either compile-time, often using AST traversal and macros, which hurts composition, or are runtime based, which means it is slow when parsing. But more importantly, I haven’t found no library compiles parser combinator down to binary matches that rely on the VM optimizations.
So over the last 48h I built a yet another parsec combinator library for Elixir called NimbleParsec. The combinator composition happens fully at runtime which is then compiled down to binary matching. It works similar to quoted expressions in Elixir. The combinators build an AST which is then compiled down to binary match clauses. See the link above for an example and the code it compiles down to.
I have ran @OvermindDL1 benchmark scripts and I got these results:
$ mix bench
Erlang/OTP 20 [erts-9.0] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false]
Elixir 1.7.0-dev
Benchmark suite executing with the following configuration:
warmup: 2.0s
time: 3.0s
parallel: 1
inputs: parse_datetime, parse_int_10
Estimated total run time: 30.0s
Benchmarking with input parse_datetime:
Benchmarking combine...
Benchmarking ex_spirit...
Benchmarking nimble...
Warning: The function you are trying to benchmark is super fast, making measures more unreliable! See: https://github.com/PragTob/benchee/wiki/Benchee-Warnings#fast-execution-warning
Benchmarking with input parse_int_10:
Benchmarking combine...
Benchmarking ex_spirit...
Benchmarking nimble...
Warning: The function you are trying to benchmark is super fast, making measures more unreliable! See: https://github.com/PragTob/benchee/wiki/Benchee-Warnings#fast-execution-warning
##### With input parse_datetime #####
Name ips average deviation median
nimble 1425.75 K 0.70 μs ±437.03% 0.70 μs
ex_spirit 177.70 K 5.63 μs ±115.86% 5.00 μs
combine 95.83 K 10.44 μs ±93.60% 9.00 μs
Comparison:
nimble 1425.75 K
ex_spirit 177.70 K - 8.02x slower
combine 95.83 K - 14.88x slower
##### With input parse_int_10 #####
Name ips average deviation median
nimble 949.95 K 1.05 μs ±216.77% 1.00 μs
ex_spirit 463.71 K 2.16 μs ±950.25% 2.00 μs
combine 338.62 K 2.95 μs ±760.53% 2.00 μs
Comparison:
nimble 949.95 K
ex_spirit 463.71 K - 2.05x slower
combine 338.62 K - 2.81x slower
The above shows that for parsing datetimes, nimble is 8x faster than ex_spirit and 14x faster than combine. For the integer case, nimble is only twice faster, but it is worth noting Nimble’s integer parser is written on top of existing combinators while the integer parser for both ex_spirit and combine are written by hand. So nimble is beating hand-written code there.
I did not measure memory usage but that should also decrease wth nimble thanks to binary matching.
I have also benchmarked compilation times by compiling the same datetime parser 30 times. combine takes 1s, which makes sense as it is runtime based. nimble takes 2s and ex_spirit takes 6s.
While nimble is extremely new, I think most of the primitives are there, so you should be able to build almost anything. Improvements, PRs and feedback are very welcome, thanks!
Most Liked
tmbb
I’m the author of Makeup (a library for syntax highlighting of source code) and ExDocMakeup (a markdown processor that can be used with ExDoc that uses Makeup for syntax highlighting of code examples in the docs). It was a discussion around Makeup that initially prompted the development of NimbleParsec. Up until the most recent version, Makeup used to depend on the ExSpirit library by @OvermindDL1, which is much more flexible than NimbleParsec (as the discussion above shows), but much, much slower.
Using Benchee, I’ve prepared some benchmarks pitting the current version of Makeup (0.4.0) against the old version. I’ve gotten aproximately 10x speedups in both compilation time and runtime.
Here’s how to interpret the benchmarks below:
- Formatter performance: the part that takes up a list of tokens and converts it into an HTML fragment
- Lexer: the part that reads the raw source code and produces a list of tokens
- Lexer compilation time: this was done by invoking the Elixir compiler at runtime on the relevant file. This is a good proxy measure of the “real” compilation process (it’s pretty cool that’s so easy to compile code at runtime in Elixir - never us this in production, though!)
The most important number is the “Lexer + Formatter”, because that’s what the user will do most of the time. The benchmarks use a fake elixir file with about 250 lines written by the authors of the python library Pygments (which is similar to makeup) to demonstrate most of the syntax rules.
ExSpirit (old version):
Name ips average deviation median 99th %
Formatter performance 124.40 8.04 ms ±97.25% 15 ms 16 ms
Reading file from disk + Lexer + Formatter (end to end) 7.29 137.24 ms ±7.99% 140 ms 172 ms
Lexer performance 7.18 139.33 ms ±9.72% 140 ms 157 ms
Lexer + Formatter 6.83 146.43 ms ±10.56% 141 ms 187 ms
Lexer compilation time 0.0355 28156 ms ±0.00% 28156 ms 28156 ms
NimbleParsec (new version):
Name ips average deviation median 99th %
Formatter performance 620.02 1.61 ms ±32.96% 1.60 ms 3.20 ms
Lexer performance 108.31 9.23 ms ±4.86% 9.40 ms 9.40 ms
Lexer + Formatter 94.72 10.56 ms ±69.37% 15 ms 16 ms
Reading file from disk + Lexer + Formatter (end to end) 92.32 10.83 ms ±66.61% 15 ms 16 ms
Lexer compilation time 0.26 3812.50 ms ±0.01% 3812.50 ms 3813 ms
The secret to the Formatter’s performance improvements was to use iolists whenever possible instead of binaries after a suggestion by @josevalim I’d never have thought that the difference would be so great. It turns out that concatenating strings on the BEAM is very slow and requires lots of copying. @josevalim has been extremely helpful in suggesting improvements to the lexer, answering questions about NimbleParsec and giving useful tips on how to increase performance of my library.
Performance improvements in the lexer were mainly due to the use of NimbleParsec (and to an obsessive amount of microbenchmaking). The performance of my lexer is now pretty much the same as the performance of the Elixir formatter, which means there are probably very little gains to be had. Makeup is quite similar to the Elixir formatter in that it parses files and prints a beautified version of the output. It was also written by people which are probably smarter than me and which have some actual knowledge of the internals of the BEAM.
This newest version of Makeup itself is not yet ready for prime time. You can help by running it on examples of real elixir code, inspecting the output and submitting an issue with examples tha look wrong. Such examples can be included in the test suite, which sadly doesn’t cover all syntax rules yet (and may never cover all rule combinations, that’s why it’s important to run it on real code to spot issues).
All of this will be easier after I’ve updated the docs with more useful information.
From my experience in the last few days, NimbleParsec seems to be ready to be used for real projects. It’s the ideal option for those who need to parse context-free languages, and with some postprocessing steps it can be used to help parse some context-sensitive ones. I don’t believe I’ve found any bug while using it for Makeup.
Thanks to @josevalim for writing this great library and for all the help and to @OvermindDL1 who’s written ExSpirit, without which Makeup would have never been written. Although quite slow, ExSpirit is way faster than almost anything out there and is still the only Elixir parser library that supports parsing context-sensitive languages.
If you’ll only take away one thing from this post, take this: string concatenation is slow. Iolists are the secret sauce that will make your programs go fast. Don’t replace your code without benchmarking it, though. Performance on the BEAM is weird, don’t trust your instincts from other languages.
mischov
defmodule NimbleParsec do
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
Where has this been my whole life?
OvermindDL1
+++
Yeah definitely should be outputting IOlists, even with ExSpirit. ^.^;
I’m really curious about when it will be able to parse context-sensitive languages, then it can fully replace ExSpirit and I can deprecate it (less stuff for me to manage/update, the better!). ![]()
Heh thanks, it was birthed because I needed it and nothing else out supported the features I needed. ^.^
I really want NimbleParsec to replace it, needs a few more features to be able to do that though. ![]()
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance








