pperl: Doing a REJIT

The previous post - pperl 0.6.12+ - Shall we play a game? - closed with a promise: the next release will compete in every benchmark of the Computer Language Benchmarks Game with the best scripted language entry there. The current build already takes several rows outright; the tally belongs to the release post. This one is about a row where the promise cannot be kept with the guns we have. On regex-redux the opponent is V8's Irregexp, a compiler that turns regular expressions into machine code, and no amount of work on the Perl JIT will close that gap, because the time is not spent in Perl ops at all. The answer is a second compiler: ReJIT.

The promise, and the scoreboard so far

The promise stands as written: every Game benchmark, against the best scripted entry, with a general-purpose JIT rather than one tailored to benchmark code. Several rows already go to the current build; publishing the numbers before the release they belong to would repeat the mistake of printing tables that age in our favour, so they wait. What does not wait is the row that dictated new machinery.

regex-redux: outgunned at home

regex-redux is the Game's regular-expression benchmark: nine alternation patterns counted over a DNA sequence with //g, five IUB-code substitutions with s///g, essentially all of it inside the regex engine. Regular expressions are the domain Perl defined; this should be a home game.

It is not. pperl runs the workload through its regex engine - the Rust reimplementation of perl5's backtracker - at perl5 parity: roughly nine billion instructions on our measurement input, for pperl and perl5 alike. node retires 1.33 billion on the same input. That is a factor of seven, in instruction count, before anyone talks about clock speed.

The reason node is there is Irregexp, V8's regex compiler. It is a backtracker, like perl's engine - leftmost-greedy, capture semantics and all - but it does not interpret a pattern, it compiles the pattern to native code: character classes become mask-and-compare instructions, backtracking becomes a pop and an indirect jump, and each subject encoding gets its own code object. Against that, an interpreter dispatching per character through a switch is structurally behind, however tight the switch.

And this is the point where the existing JIT cannot help. The pperl JIT compiles Perl ops; on this row over 99% of the instructions retire inside a single op, the match itself. An engine gap cannot be closed from the op side. Hence the second compiler.

ReJIT

ReJIT compiles regular expressions to machine code, and the design decision worth explaining is what it compiles. There are three architectures in the field. Irregexp compiles its own pattern graph. RE2-class engines (rust-regex, V8's linear mode) run guaranteed-linear automata over a restricted semantics. PCRE2's JIT does neither: it compiles the interpreter's own compiled-pattern bytecode, arm by arm, and falls back to the interpreter for whatever it declines.

ReJIT takes the PCRE2 shape. perl's regex compiler already does the hard front-end work - parsing, optimization, trie construction with Aho-Corasick start classes, anchoring and substring analysis - and its output, the regnode program, is the specification of what a match must do. ReJIT compiles that program to native code through the same Cranelift backend the op JIT uses, and splices in behind the engine's public boundary: patterns it admits get compiled code, patterns it declines keep the stock engine, permanently and at zero per-match cost. Compiling the engine's own program rather than reinventing a pattern representation is what makes fidelity a construction property instead of a test-suite aspiration.

Irregexp still gets mined - for mechanics, not architecture: the heap-grown backtrack stack of code addresses, captures as a register array saved and restored through that stack, one compiled body per subject encoding, compilation deferred until a pattern is demonstrably hot.

What we will not ship is an RE2-style linear engine, for two reasons. First, perl already ships the tier such an engine would occupy: the substring and start-class presearch machinery in front of the matcher is a prefilter, and duplicating it buys nothing. Second, a breadth-first engine cannot express the backtracking-order phenotype - which branch wins, what $1 holds under alternation, what (*SKIP) and its relatives do - and in Perl that phenotype is observable behaviour, not an implementation detail.

Where the instructions go

We took a census of the row before designing the tier. 83% of the instructions are the count loop, 16% the substitutions, 1% everything else. The interesting split is inside the count loop.

perl's optimizer builds its trie - and the Aho-Corasick scan that makes trie'd alternations fast - only when every branch of the alternation starts with a literal. Five of the nine count patterns qualify and run comparatively fast. The other four start a branch with a character class ([cgt]gggtaaa|tttaccc[acg] and friends), get no trie and no start class, and are scanned branch by branch from every position. Those four cost 64% of the count phase - about 55% of the entire benchmark.

So the single biggest target is exactly the shape perl's optimizer refuses to optimize: a class-headed alternation, compiled into a machine-code scan loop. The trie'd scan is the second target, at roughly a quarter of the row. All fifteen patterns of the benchmark sit inside the first compiled subset - no captures, no lookaround, no backreferences anywhere in the row.

Captures are in that first subset anyway. In our 1,844-file test corpus, $1-style extraction outnumbers backreferences 268 to 14; a captureless tier would compile the counting workloads and decline nearly every extracting one, which is the majority real-world shape of =~.

Semantics are not negotiable

The house rules from the previous post apply unchanged: every gap is fixed as a general construct or not at all, and the semantics are perl's, byte for byte.

One example of why compiled-regex prior art cannot simply be copied. In /(?:(a)|(b))+/ matched against "ab", perl leaves $1 holding "a" - a quantified group retains the last successful value of its inner captures across iterations. ECMAScript clears them at each iteration entry, so JavaScript reports capture 1 undefined, and Irregexp implements exactly that. Copying its register-clearing verbatim would be a wrong-match bug by construction. The engines agree on almost everything; the tier is built for the places they do not.

The enforcement is mechanical, not stylistic. In test mode every compiled match is re-run through the stock engine and compared - not the verdict alone, but the full capture state, since a divergence there stays invisible until $' or @- is read much later. perl's own regression corpus runs under that differential mode, including the 2,210-case table it maintains for the engine and the variants of it that force the trie optimization off and on. And the perl-specific machinery that does not compile - (?{...}) code blocks, pattern recursion, locale-dependent folding - is declined at admission and stays on the stock engine, which remains the universal fallback.

Staging

ReJIT lands in stages: the instrumentation and the differential oracle first, then the scan-loop tier described above, then quantifier depth, then the utf8 axis. For the scan-loop tier alone our prediction on the regex-redux row is a 3-5x reduction in instruction count - printed here so the release post can be checked against it.

As always: no dates. This is to update status: the promise from the previous post now has its missing weapon under construction.

  • Richard C. Jelinek, PetaMem s.r.o.

Leave a comment

About PetaMem

user-pic All things Perl.