Fil-C on Nix

Filnix wiki

pcre2-jit.md

PCRE2's JIT (sljit) and the Fil-C LLVM JIT

PCRE2's JIT compiles a pattern to machine code with sljit, a small portable assembler. Fil-C has no executable memory, so that JIT cannot run. This note records what the Fil-C package does today, whether sljit could emit LLVM IR for Fil-C's own JIT instead, and what a prototype measured. The prototype is in experiments/pcre2-sljit-llvm.

Summary

Why sljit cannot run

sljit writes machine code into memory it maps executable and then calls it. Fil-C's runtime rejects PROT_EXEC in mmap and mprotect (from_user_prot in filc_runtime.c returns EINVAL), and a function pointer without a function capability cannot be called. --enable-jit-sealloc does not help: it only changes how the executable mapping is obtained.

The staging toolchain has a different route. filc_llvm_jit.h exposes a subset of the LLVM-C API and FilLLVMJIT_CompileModule, which runs the module through the Fil-C pass and links it into the process. PHP's opcache JIT already uses it (.#php85-jit). So the question is whether sljit's LIR can be lowered to LLVM IR.

How the lowering works

pcre2_jit_compile.c (14,400 lines) calls about 45 sljit_emit_* functions: moves, two-operand arithmetic that sets flags, compare and jump, labels, calls to C helpers, and a "fast call" that stores a return address in a register. A backend implements those and sljit_generate_code. The prototype does the same lowering from a text dump of the LIR:

The last point is the one that reaches into PCRE2. It embeds host pointers as plain word immediates and displacements: the UCD tables, utf8_table4 - 0xc0, the character tables, class bitmaps inside the compiled pattern, and tables from allocate_read_only_data. A backend cannot tell these from integers. A real port tags them at about 40 sites in pcre2_jit_compile.c, or addresses pattern data relative to the pcre2_code argument.

Measurements

Nanoseconds per pcre2_match call over an HTML-like subject, PCRE2 10.49, 16-core orb. "Native" is gcc with sljit and the SIMD fast-forward paths off; they are x86-specific and optional, and with them native is up to 3 times faster again on literal scans. Reproduce with experiments/pcre2-sljit-llvm/run.sh.

Pattern Native sljit Fil-C interpreter with match patch Fil-C LLVM JIT JIT compile, ms
tag 75 1250 1020 347 217
email 702 33500 27500 1706 135
word 24 422 193 93 44
alt 259 4060 3320 535 111
entity 82 571 330 286 112
shortcode 588 1220 805 861 302
key 25 404 163 97 47
date 24 524 301 171 216
utf 2369 34200 26400 1450 75
lit 258 2270 1880 342 67

The patterns are in pats.h. Runs vary by about 10%.

A warm WordPress request makes 331,164 pcre2_match calls over 206 patterns (docs/php-port-notes.md). Compiling all of them costs roughly 25 seconds per process without the cache. Against the patched interpreter, email pays back its compile after about 5,000 calls, but tag, word and key save under 1 µs per call and need 300,000 to 700,000. So only scan-heavy or very hot patterns in long-lived workers are worth compiling.

What a real backend needs

A rough estimate is 4 to 8 weeks to pass PCRE2's JIT tests, before the toolchain questions.

What the prototype does not cover

It replays LIR that a native build dumped, so there is no sljit backend yet. Callouts, script runs and (*MARK) searches abort. Partial matching, sljit_emit_select, backtrack stack growth past 32 KiB, and the 16- and 32-bit libraries are untested. Ten patterns are not PCRE2's test suite.

Another route: a regex compiler on nanofil

experiments/regex-nanofil tries a compiler that does not involve sljit or LLVM: 728 lines of Prolog turn the bytecode pcre2_compile makes into nanofil's unchecked IR, and nanofil, as and ld -shared make a shared object to dlopen. PCRE2 keeps parsing, numbering groups and finding where a match can start; a pattern with an opcode the compiler does not know stays on the interpreter.

On nine of the ten patterns here, and on 27 more that exercise atomic and possessive groups, lookbehind, multiline and group loops, it agrees with the interpreter from every start offset of the subject. The nine are 4.8 to 25 times faster than the interpreter: at or above native sljit's speed on five of them and ahead of the LLVM JIT on seven. It needs no executable memory, no link flag and no LLVM in the process.

nanofil takes 0.15 to 0.8 s per pattern, so the experiment also has a patch to libpcre2 that serves pcre2_jit_compile from a cache directory of shared objects, named by PCRE2_RX_CACHE, and runs the compiler on a miss. With it pcre2_match is 3 to 17 times faster than the patched interpreter on the nine patterns, and a cache hit costs 0.1 to 0.9 ms. PCRE2's testinput1 and testinput2 give the interpreter's output with -jit; 1383 of their 2485 patterns are compiled and the rest stay on the interpreter. It is not wired into pkgsFilc.pcre2.

The compiler also runs on Trealla alone, with the same output as on SWI-Prolog, but a miss then takes 1 to 4 s with a native Trealla and 0.9 to 17 s inside a Fil-C one, against 0.15 to 0.8 s. The README has the numbers, the cache rules and what is not compiled.