It is very relevant
> The rarity of JIT compilers makes me believe that implementing a JIT compiler historically was too difficult for it to be worthwhile.
That's only true of writing a JIT from scratch. There's no rarity of JITs, it's just that LLVM (and other frameworks) are often used. Every major interpreter has a JIT compiler. PCRE2 has a JIT compiler. There are JIT frameworks out there with much faster code-generation than LLVM: Cranelift, GNU Lightning, Mir. I doubt they could do code-generation faster than a custom copy-and-patch JIT, but they'd be much faster than LLVM.
[0] https://www.pinaraf.info/2024/03/look-ma-i-wrote-a-new-jit-c... , discussed: https://news.ycombinator.com/item?id=39742916
I might use this approach to generate the stencils for a JIT firewall I’ve been experimenting with.
It also occurs to me that this could be used to generate eBPF byte code on the fly as well
Besides run time, JIT is available also when the code is compiled or loaded for execution (i.e., do you have a compilation or loading speed-up in mind? no problem, you can also compile that speed-up into native machine code, and so ad infinitum...).
By not using LLVM, you're missing all the optimizations it does.
It was the limits of 8 bit home computers hardware that made the interpreter version be more widely known.
Same to Lisp, Smalltalk, and many other languages.
Fully agree with you.
Template-based code generators suffer from bad code quality due to missing register allocation.
Our TPDE-based compilers compile a bit slower than template-based code generation but the generated code is much smaller and faster ([2] Fig. 2). Also for database workloads ([2] Fig. 6).
All that said, Postgres' main limitation is that it (IIRC) only compiles single expressions from operators, not pipelines. This fundamentally limits the achievable performance improvement compared to databases that perform more extensive query compilation.
[1]: https://aengelke.net/pubs/2403-cgo.pdf [2]: https://aengelke.net/pubs/2602-cgo1.pdf
PS: sorry for the promotion of my own research here, just couldn't resist.
Except that using LLVM has high latency limitting it's applicability. Postgres just disabled LLVM by default because of this[0].
[0] https://www.postgresql.org/message-id/E1w8GWU-002bSL-31%40ge...
I believe this because every time I use AI for domains that I consider myself above competent, if it is anything beyond UI components or a simple CRUD endpoints, I cringe at the quality of what it generates.
This has made me to be extremely cautious of starting working in a new domain with AI if I want anything beyond throw away quick hacks or junk, shy of quick bug fixes perhaps.
Some human, somewhere, has to describe how to turn high-level language constructs into machine code. "When you see this pattern, emit this sequence of bytes." That's just templates and stencils. There's no magic for turning source code into machine code by divining the ISA at compile time.
Anything that's taking source code and, at the time of execution, is compiling it to machine code on-the-fly is JIT compilation. Regardless how long it takes, lack of optimization, or which machine is the target (x86, ARM32/64, RISC-V, JVM, WebAssembly), it's JIT.
LLVM is a large dependency if you need to JIT. There are plenty of smaller (and much faster) alternatives which are much better fit for smaller projects. Larger projects usually roll out their own jit-pipeline because they can integrate better with the source language/interpreter and apply tricks LLVM is not well suited to (say, LLVM is not great at deoptimisation). I think only Julia is really a heavy user of LLVM JIT, also it is known for extremely slow repl from time to time.
Fun fact: even Apple themselves have JIT. JavaScriptCore on iOS has JIT, it's just that the App Store policies forbid any application submissions with JIT or trying to mmap/mprotect an executable region. There used to be apps on TrollStore that runs JIT
There's a strong 'diminishing returns' effect in striking a balance between compile time and the performance of the generated code. I'd expect a more lightweight (less optimising) JIT engine to be able to produce code with pretty respectable performance while taking only a fraction of the time that LLVM takes. There's a follow-up to the blog post I linked above, which bears this out. [0] (I don't know if that JIT solution is production-ready or viable for merging into postgres, mind.)
The blog post [0] gives this performance comparison:
> So, on our stupid benchmark, doing 10 times a simple SELECT * FROM demo WHERE a = 42 on a 10 million rows table...
PostgreSQL No JIT LLVM JIT Copyjit
---------- ------ -------- -------
Average time (ms) 120 106 (-12%) 101 (-15%)
Compilation time (ms) 0 19 0.06
Instructions 13,350,766,209 10,643,820,667 (-21%) 12,769,013,536 (-5%)
Cycles 4,660,821,596 4,005,881,863 (-14%) 3,924,602,439 (-16%)
Branches 2,322,470,659 1,798,221,785 (-23%) 2,031,456,214 (-13%)
[0] https://www.pinaraf.info/2025/12/jit-episode-iii-warp-speed-...And yet, a good portion of software that runs today's world is written in scripting languages & executed using interpreters.
Which is okay! Imho: multiply [# of users] with [how often each user sees that software's effect] and [how much that contributes to the overall user experience], then you get a ballpark idea of how much $$/effort is worth spending on optimization.
In other words: for a one-off, don't bother. But as usercount, frequency of use by individual users, poor UX or RAM/CPU consumption goes up, progress from script -> compiled -> optimizing compiler -> (if necessary) hand-optimized assembly as needed. And of course consider high-level design, data structures, algorithms etc in that process. A change there might be more effective than a switch from interpreted -> optimizing compiler.
"Developer time" should not factor into that much (again: imho) unless users=developers.
Thoughtlessly putting every change through a (slow?) pipeline that does 'random' toolbox-of-optimizations without need, is wasteful. Apply that toolbox as needed while keeping the above in mind.
> Not sure where the idea comes from that Cranelift is much faster than LLVM -O0
Cranelift describes itself as a fast, secure, relatively simple and innovative compiler backend. [0] Interesting that LLVM can compete there, with its optimisations dialed down.
> Postgres' main limitation is that it (IIRC) only compiles single expressions from operators, not pipelines. This fundamentally limits the achievable performance improvement compared to databases that perform more extensive query compilation.
That sounds pretty limiting. That's separate from query optimisation though, right? The query optimiser is presumably able to reason 'broadly' and not just at the level of individual expressions? High-level query-plan optimisation must be much more consequential than effective use of JIT compilation.
On reflection I wonder if I overstated the widespread use of JIT and of JIT compiler frameworks. All the 'major' well-resourced high-profile JIT-based interpreters I can think of don't use an off-the-shelf JIT framework for their backend, which makes sense as they want to carefully tune the code-generation. OpenJDK, OpenJ9, .Net, V8, SpiderMonkey, JavaScriptCore. LuaJIT and Python's new JIT don't use one either, nor does the Linux kernel's BPF engine.
The Guile Scheme interpreter uses a fork of the GNU Lightning JIT library. [0] Julia and (as mentioned) Postgres use LLVM for their JITs. I'm trying to think of other projects that use a JIT framework/library.
Similarly, I can't think of many problem domains where it makes sense to use JIT. The ones that spring to mind are interpreters (of course), regex engines, and DBMSs. JIT can also help in high-performance computing, to tailor the code to the particular problem and the particular CPU. [1] I don't think there are many other contexts where it makes sense to use JIT though.
JIT compilation brings its own drawbacks in portability (both between hardware platforms and operating systems), complexity, and perhaps cybersecurity, which might also limit its adoption, even if a good JIT framework could help with all three.
[0] https://doc.guix.gnu.org/guile/latest/en/html_node/Just_002d...
[1] https://www.intel.com/content/www/us/en/developer/articles/t...
Alternatively, only allow for the execution of cryptographly signed static linked binaries, this naturally includes the interpreter above.
Allowing code execution allows code execution, that's it, that's the entirety of it.
Besides all the other mentioned points, I think a good remaining less-discussed point is that quality in software has always been in the eye of the beholder. You may very well see the AI output as low quality, I may not, and its not necessarily clear who is right or wrong, because there was always little precedent in objectively evaluating code quality.
This is a long standing issue, and was never resolved before AI happened, and the coming of AI has not really changed things, except that AI is under a magnifying glass obviously. How do we objectively measure the quality of code? There's some general consensus on things, but surprisingly little is true professional agreed upon consensus.
If I can make up a figure, I would guess 95% of software engineering quality rhetoric, and craftmanship advice, is just strongly held opinions.
This is not something I can prove, but if I look at the (still ongoing.....) debates on very basic ideas like clean code, and the reactions from also-great programmers like Carmack, Blow & Muratori, it is clear to me that there is little consensus on even the fundamentals of software design.
If all these people can produce excellent working software while disagreeing on these fundamentals (of quality), it means we do not yet understand what the fundamentals are.
It generally performs better if the tasks are broken done into small manageable pieces, and the person is actually reviewing and calling out problems, which usually requires the person to be a competent engineer in the problem domain to begin with.
But yes, I have personally used it to build what the OP calls a JIT. I would usually write that by hand and it would take me one week. The AI does it in an hour.
[0] https://www.sbcl.org/manual/#compiler-only-implementation
Also, on my machine, compiling the identity lambda form takes about 200 usec with (optimize (compilation-speed 3) (debug 0)). SBCL could use a faster JIT mode, perhaps at compilation-speed 3/speed 0. Perhaps there are some other internal special variables that could be tweaked to reduce compile time.
That's not exactly blazing fast for a low level C-like language, but it's not bad. It's infinitely faster than what I've ever gotten a toy interpreter to be.
Yes, yes, and yes. For databases, query optimization (esp. join ordering for larger queries, which heavily depends on estimates) is fundamental. Query optimization happens at the level of the query plan, JIT compilation is only relevant afterwards. A bad query plan leads to asymptotically worse performance (e.g., bad join ordering with huge intermediate results).
On query plan execution: The "classical" model as used in e.g. Postgres is a pull-based iterator model, where operators implement a next() method yielding the next tuple and in there recursively call next() on their child operators (e.g., a next() of a select operator calls next() on its child operator, then applies the predicate [what Postgres JIT-compiles], and returns the tuple if the predicate was true). This can happen one tuple at a time (Postgres) or "vectorized" where multiple tuples are processed at once (e.g. DuckDB). A query-compiling database will split the tree into pipelines and compile each pipeline as one function (e.g., a pipeline will iterate over all the tuples from a source (e.g. tablescan) and a select operator then becomes an if statement inside that loop). This results in pretty tight loops, avoids per-tuple dispatch overhead, and enables more optimizations inside the JIT-ted code (e.g., tuple values don't need to be reloaded from memory all the time). (I find the original paper on query compilation [1] to be well readable.)
In fairness, UI components are probably one of the hardest things to do completely correctly, even with just HTML. As soon as you start thinking about i18n, screen reader support, color contrast, keyboard controls, and all of the layout and positioning you're trying to achieve at different viewport sizes, it's extremely hard for a "just competent" engineer to do an S-tier job. Even with the most vanilla default built in components it's not easy to get this correct, and I think we all cringe at what competent engineers create by hand in this domain.
> - Android Runtime Just-In-Time (JIT) compilation/profiling is fully disabled and replaced with full ahead-of-time (AOT) compilation. The only JIT compilation in the base OS is the V8 JavaScript JIT which is disabled by default for the Vanadium browser with per-site exception support.
> - Dynamic code loading for both native code or Java/Kotlin classes is blocked for nearly the entire base OS. […]
> - Dynamic code loading for both native code or Java/Kotlin classes can be disabled for user installed apps via 3 exploit protection toggles: […]
That’s not a joke. A lot went on to be programmers professionally. And judging by the quality of closed & open source code I witness daily those figures from university accurately depict people’s capabilities.
Now that said, if you can’t really code then using AI will be a godsend to said individuals.
In a long run session Fable 5 generated a Disney principled (physically based) shading/lighting engine from scratch, both with a CPU (SIMD accelerated) backend _and_ a full GPU Vulkan backend. Exceptional performance too; the CPU backend runs almost realtime and literally looks better than some AAA games outright. Took it about ~8 hours wall time total time to achieve this.
See https://github.com/marcoheisig/Petalisp#why-is-petalisp-writ...
Also often the difficulty with writing code is simply knowing where to start - getting past the blank page. AI can help a lot with that. Often there's a task where I've got kind of writers block, but you can ask AI to do it and suddenly it's like "ah yeah, sort of but actually that's not quite right we should do it this way".
The drawback to this approach is there can be some bloat because you can only mprotect at page granulariy so a jitted function that only takes say 10 bytes to represent would take up a full page in memory, but this is extreme and in practice, the overhead is unlikely to be worth worrying about.
Absolutely, at the end of the day the only metrics that matter are performance and code validity. A lot of the "this code is awful" arguments I hear just boil down to "this code is stylistically awful" and never talk about it's performance.
> Anyone who thinks AI is good with writing code that is hard to write for the operator, not due to lack of basic software engineering know how but complexity of the domain, either has access to models beyond what is available to the public or is completely lost.
I used Fable on a Zephyr project with time sensitive code for LR-WPAN and it broke everything. Literally made the code worst to the point that the devices stopped connecting.
If I need to be a domain expert anyway, the value of the tool goes down by orders of magnitude. Same if I need to first break the task down into pieces and keep reviewing all the output. That sounds to me like >80% of the work I'd need to do anyway.
If I need to design and understand all of the code anyway, I might as well skip the whole process of repeatedly fixing the subpar-at-every-level LLM output and write it all myself.
Personally, I've found the greatest value in asking for simple tasks, like wiring up APIs, generating boilerplate, bug finding etc. Anything that requires effort to do but results in either very little or very simple output, so that I can easily verify its correctness.
But give the LLM anything remotely complex to generate and it cakes its pants.
Mine doesn't have the SB-INTERPRETER package, so I doubt binding the variable has an effect.
the tell-tale mark of AI code is highly over-engineered local solutions to trivial problems that don't matter, or that were already solved better elsewhere and that no sane human would ever duplicate.
The JIT must also only be allowed to call into specific code, controlled by the runtime, and nothing else.
JITs do not grant the ability to bypass any OS/system sandboxes. The lack of W^X doesn't do that, either. If a process opts out of W^X, such as to enable a JIT, it's voluntarily making itself less hardened, but at the end of the day this isn't any more meaningful than the program being allowed to be written in, say, C, which also voluntarily reduces the processes security hardening.
In this example usage, one would hope that authentication already happened before the JIT processed the command. So an authorized user can attack themselves is the only realistic risk, which is hardly significant
You absolutely don't. You only need to be roughly aware of what the code needs to be doing. Similar to how a software architect historically didn't personally oversee every line of code in an org, only it's overall structure. The implementation specific details can be left to the AI.
You could read it as quality in the operational correctness sense, but just as well in the software architectural design sense. My comment indeed applies to only one of those.
However, why judge correctness as a "cringe on quality", rather than just objectively saying its producing errors. This is why my response is in the software direction.
One of the competing open Common Lisp implementations, CCL, has a much faster compiler (albeit one that produces worse code). This can be useful in development.
It's how you do JIT on macOS, where W^X is enforced, for example.
There's plenty of scope for harm just within the process, even ignoring the possibility of escaping the process. In the case of a database server, essentially everything of value takes place within the database process (or processes). That process presumably has both access to the raw database data, and network access. We wouldn't want it sending data to an attacker's server.
> one would hope that authentication already happened before the JIT processed the command
We'd hope, yes, but SQL injection issues are still somewhat common. Also, an organisation might trust their DBMS to enforce permissions, and a JIT bug is the kind of thing that might allow non-permissioned data access. A DBMS should be hardened against malicious queries, just as a browser should be hardened against malicious JavaScript.
In web browsers, the numbers show JIT compilers are a major cause of security issues. I don't know if there are hard numbers on JIT engines causing security issues in DBMSs though.
It has to do that anyway?
And my point about C is literally that even without a JIT, applications can still have arbitrary execution vulnerabilities.
A JIT intended to run untrusted code as part of a sandbox, like a browser, is a big risk. But that's because of the untrusted code part, not the JIT. By comparison, something like a Python or Java JIT is as near as makes no difference completely risk free. The JIT is working on exclusively "trusted" code. Same basic concept applies here with this database usage.
Of course, but the process has every right to decide for itself if it wants to take that risk. Just like it decides if it wants to take the risk of a memory unsafe language, forgoing fuzzing, or going all out with formal verification.
Which at this point most companies would rather save money and forbid JIT altogether.
Note that mainframes and micros have JIT environments that aren't at the same safety level as regular desktop PCs.
For example,
https://medium.com/@dhemanthc/ibm-i-architecture-how-timi-an...
Historically, fast JIT compilation was a black art. To write a fast JIT compiler, you would need to know how to write assembly. Case in point: there is no production-ready database today that has its own JIT compiler. They all either use LLVM or generate C/C++ code. Both of these options suffer from high compile times, which limits their applicability. Now, with the use of AI, it’s easier than ever to write a JIT compiler with fast compile times by directly targeting assembly. This is also one area of opportunity for new databases to improve on old ones. When building pgrust, I initially thought it would be really hard to implement a JIT compiler. In the end, I found it much easier than I expected due to AI assistance and it ends up being part of the reason why pgrust is so fast. The pgrust JIT compiler compiles code in around 5μs, which enables us to JIT compile every SQL query, not just a subset of them. In this post, I’ll walk you through how you can build your own fast JIT compiler. We’ll build a simple regular expression engine that uses JIT compilation as an example.
JIT compilation is the practice of generating compiled code at runtime or “Just In Time”. When done right, it can result in big performance wins, often on the order of 2-5x and sometimes even more. The main use case for JIT compilation is when there’s information you gain at runtime that drastically alters the behavior of your program. This is particularly common with programming language interpreters; they receive the code to execute at runtime. JIT compilers are also useful in domains beyond programming languages, such as parsing data. Sometimes you don’t know the schema of the data you’re parsing until runtime, and a JIT can help with that.
To kick things off, let’s implement a toy regular expression engine. To keep things simple, we’ll support only two features: literal strings and repetition (i.e. the regex *). We’ll also skip the parser and represent the regular expression as already parsed Rust structures. This means we’ll be able to support strings such as:
but no alternation or lookbehind or anything like that.
In code this is pretty simple. We’ll have 3 types of Nodes: a literal string node, a repetition node, and a concatenation node, which is the combination of two nodes. This ends up looking like this:
enum Node { Literal(&'static str), Concatenation(Box, Box), Repetition(Box), }
fn literal(text: &'static str) -> Node { Node::Literal(text) }
fn concatenation(left: Node, right: Node) -> Node { Node::Concatenation(Box::new(left), Box::new(right)) }
fn repetition(body: Node) -> Node { Node::Repetition(Box::new(body)) }
Writing an interpreter for our regular expression engine is also straightforward:
fn match_node(node: &Node, input: &[u8], pos: usize, next: &dyn Fn(usize) -> bool) -> bool { match node { Node::Literal(text) => { let literal = text.as_bytes(); input[pos..].starts_with(literal) && next(pos + literal.len()) }
Node::Concatenation(left, right) => {
match\_node(left, input, pos, &|left\_end| {
match\_node(right, input, left\_end, next)
})
}
Node::Repetition(body) => {
match\_node(body, input, pos, &|body\_end| {
match\_node(node, input, body\_end, next)
}) || next(pos)
}
}
}
fn interp_match(regex: &Node, input: &str) -> bool { let bytes = input.as_bytes(); match_node(regex, bytes, 0, &|pos| pos == bytes.len()) }
Now this regular expression engine is pretty simple. It’s under 20 lines of code, but let’s see how it does in terms of performance. For comparison, we’ll compare the code against handwritten code implemented specifically for the regex. For our example we’ll use the regex b(an)*. The handwritten code ends up looking like:
fn handwritten_b_an_star(input: &str) -> bool { let bytes = input.as_bytes(); let mut pos = 0;
if pos == bytes.len() || bytes\[pos\] != b'b' {
return false;
}
pos += 1;
while pos < bytes.len() {
if bytes\[pos\] != b'a' {
return false;
}
pos += 1;
if pos == bytes.len() || bytes\[pos\] != b'n' {
return false;
}
pos += 1;
}
true
}
(There are ways you could optimize this code and make it much faster, but for our purposes it serves as a good comparison)
When I benchmark a couple of examples against these two, I get that the handwritten version is 10-20x faster than the interpreter. Clearly a lot of room for improvement.
Now let’s take a look at how we can use JIT compilation to get a general regular expression engine that performs as well as the handwritten version.
There are two steps to JIT compile code. First you generate the assembly for the code you want to run. Once you have the code, you then package the assembly code into a function that you can call like any other code into your program.
To generate the assembly, we will use a variant of an approach called copy-and-patch. The idea is that we have a series of templates in assembly for the different operations we want to JIT compile. These templates are called “stencils”. When we want to JIT compile an operation, we take the associated stencil and make small tweaks based on the specifics of the operation. Very similar to filling in a real stencil. By stringing together several of these filled stencils, we can construct a program at runtime that has similar performance to the handwritten version.
Here’s the path we’ll take: first we’ll look at the ARM64 code we want to generate for b(an)*. Then we’ll turn repeated instruction sequences into reusable stencils, write an emitter that fills and combines those stencils from the regex AST, and finally copy the generated instructions into executable memory so Rust can call them like a normal function.
To walk you through how this works, it’s easiest to start with the generated code and work backwards to the JIT compiler itself. Again, we’re working with the regex “b(an)*”. To lay out some design decisions:
For the state of our program we will use the following registers:
For the inputs into our program, we will be passed:
Now that we’ve taken care of that, let’s walk through the generated assembly part by part. This is specifically on macOS with ARM64. First up, we have the prologue, which initializes the program. All it does is initialize the stack by setting the top of the stack and the bottom of the stack to the value passed in:
0: aa0103e2 mov x2, x1
Next up, we have the code that checks for the character b. If it sees a character that’s not b, we jump to a block of code that handles fallback logic. Otherwise, we advance our position in the string:
; CHAR 'b' 4: 39400009 ldrb w9, [x0] ; load current input byte 8: 7101893f cmp w9, #0x62 ; is it 'b'? c: 54000281 b.ne 0x5c ; no -> fallback block 10: 91000400 add x0, x0, #1 ; yes -> advance input
Next up, we have the repetition (an)*. For the repetition, we need to do the backtracking. If we backtrack here, that means we jump immediately to the end of the loop. That means we need to store both the address of the instruction after the loop and our position in the string on the stack.
14: d2800989 movz x9, #0x004c ; build resume address 18: f2a00009 movk x9, #0x0000, lsl #16 ; = 0x1_0000_004c 1c: f2c00029 movk x9, #0x0001, lsl #32 ; (the loop exit) 20: f2e00009 movk x9, #0x0000, lsl #48 ; 24: a8810029 stp x9, x0, [x1], #16 ; push (exit, pos) onto stack
With that in place, we can now execute the body of the repetition. This will check for the characters ‘a’ and ‘n’ and, if it sees them, go back to the top of the repetition, but at a new string location.
; CHAR 'a' 28: 39400009 ldrb w9, [x0] 2c: 7101853f cmp w9, #0x61 ; 'a'? 30: 54000161 b.ne 0x5c ; no -> fallback block 34: 91000400 add x0, x0, #1
; CHAR 'n' 38: 39400009 ldrb w9, [x0] 3c: 7101b93f cmp w9, #0x6e ; 'n'? 40: 540000e1 b.ne 0x5c ; no -> fallback block 44: 91000400 add x0, x0, #1
; JMP 48: 17fffff3 b 0x14 ; back to top of loop
Now we’re past the loop. This is where the backtracking will jump once we backtrack. Once we finish the repetition, we’re at the end of the regex. All we have to do now is check if we’re at the end of the string. If we are at the end of the string, we return 1 for success. If we are not, that means the regex failed to match, and we need to run the fail logic to do a fallback.
4c: 39400009 ldrb w9, [x0] 50: 35000069 cbnz w9, 0x5c ; not at NUL -> fallback block 54: d2800020 mov x0, #1 ; success 58: d65f03c0 ret
And then finally, we have the fallback logic. This checks if the stack is empty. If it is, we return 0. If it’s not empty, we pop both the fallback address and the fallback string position off the stack, and then jump to the fallback address.
5c: eb02003f cmp x1, x2 ; any frames left? 60: 54000060 b.eq 0x6c ; no -> give up 64: a9ff0029 ldp x9, x0, [x1, #-16]! ; pop (resume, pos) 68: d61f0120 br x9 ; jump there 6c: d2800000 mov x0, #0 ; no match 70: d65f03c0 ret
Now that you’ve had the chance to see the compiled code, you should start to get a sense of how the copy-and-patch compiler would work. We have common sets of instructions with only minor differences between them. For each of these blocks of functions, we can write a function to generate the respective code. Each function will take in values to use to modify the code. For example, one of the arguments to stencil_char will be the char in the regex to compare against. We’ll insert that char directly into the machine code.
The prologue is straightforward since it’s just a block of code:
const PROLOGUE_WORDS: usize = 1;
fn stencil_prologue() -> [u32; PROLOGUE_WORDS] { [0xAA0103E2] // mov x2, x1 }
For character comparison, we need to insert the character we’re comparing against and where to jump for the fallback logic:
const CHAR_WORDS: usize = 4;
fn stencil_char(byte: u8, stencil_pos: usize, fail_pos: usize) -> [u32; CHAR_WORDS] { [ 0x39400009, // ldrb w9, [x0] 0x7100013F | ((byte as u32) << 10), // cmp w9, #byte 0x54000001 | cond_branch_offset(stencil_pos + 2, fail_pos), // b.ne fail 0x91000400, // add x0, x0, #1 ] }
For the repetition, we have the start of the loop that pushes onto the stack and the jump onto the end:
const SPLIT_WORDS: usize = 5;
fn stencil_split(resume_addr: u64) -> [u32; SPLIT_WORDS] { [ 0xD2800009 | addr_bits(resume_addr, 0), // movz x9, #addr[0..16] 0xF2A00009 | addr_bits(resume_addr, 1), // movk x9, #addr[16..32], lsl 16 0xF2C00009 | addr_bits(resume_addr, 2), // movk x9, #addr[32..48], lsl 32 0xF2E00009 | addr_bits(resume_addr, 3), // movk x9, #addr[48..64], lsl 48 0xA8810029, // stp x9, x0, [x1], #16 ] }
const JMP_WORDS: usize = 1;
fn stencil_jmp(stencil_pos: usize, target_pos: usize) -> [u32; JMP_WORDS] { [0x14000000 | branch_offset(stencil_pos, target_pos)] // b target }
And then we have the match and fail blocks which are pretty clean:
const MATCH_WORDS: usize = 4;
fn stencil_match(stencil_pos: usize, fail_pos: usize) -> [u32; MATCH_WORDS] { [ 0x39400009, // ldrb w9, [x0] 0x35000009 | cond_branch_offset(stencil_pos + 1, fail_pos), // cbnz w9, fail 0xD2800020, // mov x0, #1 0xD65F03C0, // ret ] }
const FAIL_WORDS: usize = 6;
fn stencil_fail() -> [u32; FAIL_WORDS] { [ 0xEB02003F, // cmp x1, x2 0x54000060, // b.eq +3 (to the mov below) 0xA9FF0029, // ldp x9, x0, [x1, #-16]! 0xD61F0120, // br x9 0xD2800000, // mov x0, #0 0xD65F03C0, // ret ] }
For completeness, here’s the helper functions we used which just help us insert specific data into the instructions:
// Compute the branch-offset field for a conditional branch (b.ne / cbnz): // the instruction count from branch to target, stored in bits 5..24. fn cond_branch_offset(branch_pos: usize, target_pos: usize) -> u32 { let instr_count = target_pos as i64 - branch_pos as i64; // may be negative (((instr_count as u64) & 0x7FFFF) << 5) as u32 }
// Compute the branch-offset field for an unconditional branch (b): // same idea, but stored in bits 0..26. fn branch_offset(branch_pos: usize, target_pos: usize) -> u32 { let instr_count = target_pos as i64 - branch_pos as i64; // may be negative ((instr_count as u64) & 0x3FF_FFFF) as u32 }
// Extract 16 bits of an absolute address, positioned for a movz/movk immediate. fn addr_bits(addr: u64, part: usize) -> u32 { (((addr >> (16 * part)) & 0xFFFF) as u32) << 5 }
Now the code that drives it:
// Computes how many instructions a node compiles to. fn node_words(node: &Node) -> usize { match node { Node::Literal(text) => text.len() * CHAR_WORDS, Node::Concatenation(left, right) => node_words(left) + node_words(right), Node::Repetition(body) => SPLIT_WORDS + node_words(body) + JMP_WORDS, } }
struct Emitter { code: Vec, fail: usize, // word offset of the shared fail block base: u64, // runtime address of code[0], for absolute-address holes }
impl Emitter { // Returns the offset where the next instruction will be placed. fn pos(&self) -> usize { self.code.len() }
// Appends a filled stencil to the code buffer.
fn emit(&mut self, stencil: &\[u32\]) {
self.code.extend\_from\_slice(stencil);
}
// Emits the code for one node, recursing into children.
fn emit\_node(&mut self, node: &Node) {
match node {
Node::Literal(text) => {
for &byte in text.as\_bytes() {
self.emit(&stencil\_char(byte, self.pos(), self.fail));
}
}
Node::Concatenation(left, right) => {
self.emit\_node(left);
self.emit\_node(right);
}
Node::Repetition(body) => {
let split\_at = self.pos();
let exit = split\_at + SPLIT\_WORDS + node\_words(body) + JMP\_WORDS;
self.emit(&stencil\_split(self.base + exit as u64 \* 4));
self.emit\_node(body);
self.emit(&stencil\_jmp(self.pos(), split\_at));
}
}
}
}
// Generates the complete program: prologue, the compiled AST, MATCH, fail block. fn generate_code(regex: &Node, base: u64) -> Vec { let nwords = PROLOGUE_WORDS + node_words(regex) + MATCH_WORDS + FAIL_WORDS; let mut emitter = Emitter { code: Vec::with_capacity(nwords), fail: nwords - FAIL_WORDS, base, }; emitter.emit(&stencil_prologue()); emitter.emit_node(regex); let match_at = emitter.pos(); emitter.emit(&stencil_match(match_at, emitter.fail)); emitter.emit(&stencil_fail()); assert_eq!(emitter.pos(), nwords); emitter.code }
And that’s the hard part! Personally, writing assembly is where I find AI the most helpful. My main experience with assembly is completing the microcorruption CTF. I’ve never actually written assembly myself. I would really struggle to figure out the exact instructions needed and how to modify them to get the output I wanted. With AI, I can give my coding agent the general shape of how I want the JIT compiler to work, and it can handle a lot of these details for me.
To finish our compiler we need to actually load the code. To do this, we’ll use mmap to allocate a block of memory that is readable, writable, and executable. We’ll then copy the code into that memory and convert that block of memory into a function which we then call:
const BSTACK_MAX: usize = 4096;
// These functions are included in the mac system library unsafe extern "C" { fn pthread_jit_write_protect_np(enabled: libc::c_int); fn sys_icache_invalidate(start: *mut libc::c_void, len: libc::size_t); }
type MatchFn = unsafe extern "C" fn(input: *const u8, bstack: *mut u64) -> u64;
struct Jit { buf: *mut u32, nbytes: usize, bstack: Vec, }
impl Jit { fn compile(regex: &Node) -> Jit { let nwords = PROLOGUE_WORDS + node_words(regex) + MATCH_WORDS + FAIL_WORDS; let nbytes = nwords * 4;
unsafe {
let buf = libc::mmap(
std::ptr::null\_mut(),
nbytes,
libc::PROT\_READ | libc::PROT\_WRITE | libc::PROT\_EXEC,
libc::MAP\_PRIVATE | libc::MAP\_ANON | libc::MAP\_JIT,
-1,
0,
) as \*mut u32;
assert!(buf as \*mut libc::c\_void != libc::MAP\_FAILED, "mmap failed");
let code = generate\_code(regex, buf as u64);
pthread\_jit\_write\_protect\_np(0); // make the region writable (Apple W^X)
std::slice::from\_raw\_parts\_mut(buf, code.len()).copy\_from\_slice(&code);
pthread\_jit\_write\_protect\_np(1); // back to executable
sys\_icache\_invalidate(buf as \*mut libc::c\_void, nbytes);
Jit { buf, nbytes, bstack: vec!\[0; BSTACK\_MAX \* 2\] }
}
}
// Runs the generated code. Input must end with a NUL byte.
fn is\_match(&mut self, nul\_terminated: &\[u8\]) -> bool {
debug\_assert\_eq!(nul\_terminated.last(), Some(&0));
unsafe {
let matcher: MatchFn = std::mem::transmute(self.buf);
matcher(nul\_terminated.as\_ptr(), self.bstack.as\_mut\_ptr()) != 0
}
}
}
impl Drop for Jit { fn drop(&mut self) { unsafe { libc::munmap(self.buf as *mut libc::c_void, self.nbytes); } } }
With all of this complete, let’s compare the performance of the different implementations we built:
| Input length | Interpreter | JIT | Handwritten | JIT speedup | Handwritten speedup |
|---|---|---|---|---|---|
| 9 | 45 ns | 3.8 ns | 3.8 ns | 11.7x | 11.9x |
| 33 | 103 ns | 7.9 ns | 10.5 ns | 13.0x | 9.8x |
| 129 | 597 ns | 30 ns | 32 ns | 19.7x | 18.6x |
| 513 | 1,955 ns | 126 ns | 120 ns | 15.5x | 16.2x |
| 2,049 | 8,301 ns | 470 ns | 393 ns | 17.7x | 21.1x |
So JIT and the hand-rolled implementation are pretty much neck and neck. Sometimes the JIT version is faster, and sometimes the hand-rolled version is faster.
There’s been a meme circulating about how AI doesn’t help because “code was never the hard part.” I think that’s true in some domains, but in others, writing the code absolutely was the hard part. JIT compilers are a great example of that. For many pieces of software, a JIT compiler would help a lot with speeding up the code. The rarity of JIT compilers makes me believe that implementing a JIT compiler historically was too difficult for it to be worthwhile. LLMs have lowered the barrier to entry and made it much easier to write a JIT compiler. This is the thesis behind pgrust. Databases historically were the hardest piece of software to build and were limited because of that. Now, with AI, we can be more ambitious about the type of software we build.
Thanks for reading, and if you want to support the project, the best way to support pgrust is to give us a star on GitHub. If you want to follow along:
Weekly updates on pgrust, including the follow-up on JIT compilation.