-
Notifications
You must be signed in to change notification settings - Fork 529
Description
What was I exploring, and why?
Pierre has expressed interest in replacing the current Brython parser with a PEG parser generated from Python's official grammar. He's implemented a version of this -- a parser which takes a grammar as a JS object -- but it's significantly slower than the current parser.
Recently, he said:
I didn't make performance tests but when running the built-in test suite it is obvious that it's still significantly slower, . . .
It's a shame really because it would make the implementation much easier to maintain, and more compliant with standard Python...
I agree! I think an automatically generated parser has strong pros. Like Pierre said, it'd help with maintainability and compliance with CPython. I also think a generated parser would would have fewer bugs and make the process of adding new syntax less error prone. I think maintainability, correctness, and stability are all great goals.
So I wanted to explore these questions:
- How much slower is Brython's PEG parser than its handmade parser, really?
- Why is the current PEG parser implementation slow?
- What is a reasonable path forward for a PEG parser?
I'm hoping my exploration gets somebody excited about continuing the great progress Pierre has already made in this arena.
How much slower is Brython's PEG parser, really?
I wrote a script (based on the existing node_bridge.js script) to test the parser's performance. It takes a Brython implementation as an argument and uses that implementation to parse most of the pyperformance benchmarks. This seemed like a nice choice because they're Python-official, varied, and meant to look like real world code.
The performance results were:
Handmade parser: 1.2s
PEG parser: 3.9s
So a possible answer to this question is: Brython's PEG parser is 3-3.5x slower than its handmade parser. Noticeably slower, maybe slow enough to be an argument against adopting the current PEG parser.
Why is the current PEG parser implementation slow?
I ran my perf script with node's --prof flag and generated a profile from it. I noticed that a large chunk of time was spent in a Proxy used to lazily generate tokens. Replacing the Proxy with an equivalent function improved the performance quite a bit.
New performance results on the pyperformance suite:
Handmade parser: 1.2s
PEG parser: 3.9s
Improved PEG parser: 2.8s
Not bad! Unfortunately there was no more low hanging fruit after this point. I traced the execution of the parser on a simple example to make sure it wasn't doing extra work (I once wrote a sometimes-accidentally-exponential typechecker), and it seemed to me to be working correctly.
I then looked at performance on individual pyperformance benchmarks to find the benchmarks that had the worst performance relative to the handmade parser. This led me to learn that parsing a file full of string literals is extra bad for the PEG parser. In fact, parsing a single integer literal is pretty bad. From this point on, parsing the simple file 1 became a standard test for me.
I think parsing an integer is slow because it's a case where the parser has to visit many, many grammar rules. You can see all of the rules Brython's PEG parser visits to parse 1 here. It's a lot of rules! And each of those rules triggers many additional function calls.
I'm certainly no JavaScript optimization expert, but here are my guesses for why the existing PEG parser is slower than the handmade parser, at a high level:
- The PEG parser is making a lot of function calls.
- All of the frequently-called functions are polymorphic, and taking inputs of varying shapes, so JS engines may be having a hard time optimizing them. There might be clues toward this in the node profile (if I'm reading it right) where ~10% of the time is spent doing megamorphic IC loads.
There may be another large optimization opportunity I'm missing here. But I think the current "generic parser" architecture comes with some inherent slowness. (I did try standardizing the grammar rules to all have the same keys, with some null values, but that didn't seem to make a difference).
What is a reasonable path forward for a PEG parser?
I think much of Pierre's existing work on the PEG parser could be used to support a parser generator. Instead of storing the grammar as an object and visiting rules dynamically, we could pre-generate a function for each grammar rule, and inline some of that rule's evaluation so there are fewer function calls.
CPython takes this approach and uses this program to generate the C code for its parser. Brython could modify this file to produce JavaScript output instead.
I did a fast, ugly, proof of concept of this that used some not-totally-correct JS utilities and a custom grammar with a JS subheader and trailer, whose actions are totally wrong but are at least syntactically valid JS. To be clear, all of this works so poorly that it doesn't successfully parse 1. But it does at least visit the same set of rules as Brython's PEG parser, which I think was enough to learn something.
Here's how long it took for a couple parsers to parse 1 1,000 times:
Handmade parser: 22ms
PEG parser: 175ms
Improved PEG parser: 124ms
New, generated PEG parser: 54ms
Pretty good! That means a generated parser might perform only ~2x slower than the handmade parser in the worst cases.
The "generated parser" was also ~2.5x faster than the "improved parser" for this test. If that speedup applied to the average case, then a generated parser might be just as fast as the handmade parser on average.
There's really not a lot of data here. But I think this is an approach worth exploring. The performance of this approach shows some tentative promise, I was able to get pretty far with it with only about a day and a half of work, and the idea of using CPython's tooling to generate Brython's parser feels compelling to me. We'd be taking advantage of optimization and research that the CPython project has done for their own parser, and adding that to Brython almost for free. Generating "C-like" JavaScript code also feels like a good strategy for generating fast and optimization-friendly code.
CPython's goal was to keep the PEG parser's performance within 10% of the old parser in terms of speed and memory consumption. I'm not sure if Brython has to be that strict.
The benefits of a parser generator for this project feel large enough to me to justify some slowdown. Probably not a 3.5x slowdown though. I'm not sure where that line is for me.
Thanks for reading!
I had fun diving into this. A nice unintended side effect of this exploration was learning some of the nitty gritty details of CPython's parser.
I hope what I've learned is helpful (or at least interesting!) to the Brython community. And I hope this research inspires somebody to build on Brython's PEG parser journey.
-- Austin