<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>ayles</title>
    <subtitle>Notes on eBPF, systems programming, and other low-level things</subtitle>
    <link rel="self" type="application/atom+xml" href="https://ayles.github.io/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://ayles.github.io"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-09T00:00:00+00:00</updated>
    <id>https://ayles.github.io/atom.xml</id>
    <entry xml:lang="en">
        <title>DOOM in the kernel, or fibers in eBPF</title>
        <published>2026-09-09T00:00:00+00:00</published>
        <updated>2026-09-09T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ayles.github.io/doom-in-kernel/"/>
        <id>https://ayles.github.io/doom-in-kernel/</id>
        
        <content type="html" xml:base="https://ayles.github.io/doom-in-kernel/">&lt;p&gt;DOOM is not supposed to run inside eBPF. Linux should reject a program like
that before executing its first instruction.&lt;/p&gt;
&lt;p&gt;BPF has a tiny stack, five argument registers, and limited call depth.
Recursion is forbidden. A loop must be finite not merely because the programmer
says so, but in terms the verifier can prove. You cannot simply store a pointer
in memory, load it later, and dereference it: the kernel must remember where it
came from and what it is allowed to address.&lt;/p&gt;
&lt;p&gt;And yet an unmodified Linux kernel accepts my BPF object, checks it with the
stock verifier, and runs it through the stock JIT. DOOM initialization, game
logic, and rendering all execute in the kernel. One game tick, including the
complete frame, finishes in a single BPF invocation. Userspace supplies the WAD
and keyboard input and gets back a pointer to the finished framebuffer.&lt;/p&gt;
&lt;p&gt;First, a little context on eBPF. It lets user-supplied programs run inside the
Linux kernel without a kernel module. A program is compiled to bytecode for a
small register machine, loaded with the &lt;code&gt;bpf(2)&lt;/code&gt; system call, and attached to a
hook—for example, an incoming packet or a system-call tracepoint. The kernel&#39;s
JIT compiles the bytecode to machine code, which runs whenever the hook fires.
But before the program can run, the verifier must accept it. That is where the
constraints above come from: code the verifier cannot prove safe is rejected.
This check, rather than the bytecode itself, is what makes DOOM inside eBPF
look impossible.&lt;/p&gt;
&lt;p&gt;The project is called &lt;a rel=&quot;external&quot; href=&quot;https://github.com/ayles/bpf-capsule&quot;&gt;BPF Capsule&lt;/a&gt;. It
is a compiler and runtime for large C programs inside ordinary BPF, with no
kernel patches and no separate virtual machine in userspace. The oldest
supported target profile is Linux 5.15. A profile determines which kernel
capabilities the compiler may use. I have loaded and run the programs on both
x86-64 and arm64.&lt;/p&gt;
&lt;p&gt;Nobody needs games in the kernel, of course. But complex application logic is
useful there: parsing packets, for example, or keeping statistics about them.
When such a program does not fit eBPF&#39;s constraints, it has to be simplified
and rewritten by hand until the verifier is satisfied. Capsule explores another
path: it takes C, C++, or &lt;code&gt;no_std&lt;/code&gt; Rust code and transforms it into a shape
that stock Linux accepts.&lt;/p&gt;
&lt;p&gt;DOOM is not the application here but a stress test for that approach. Lua,
QuickJS, SQLite, zlib, wasm3, llama2.c, &lt;code&gt;no_std&lt;/code&gt; Rust, and CPython 3.14 run on
the same scheme today, and Lua and Python inspect live packets straight from
XDP. This article follows the road from a hand-trimmed port through a slow
interpreter to regions and fibers—and measures what they cost at run time.&lt;/p&gt;
&lt;p&gt;You can try it with one command on any supported kernel. You need Nix and a WAD
file — for obvious reasons the WAD is not in the repository — and the rest of
the requirements are in the
&lt;a rel=&quot;external&quot; href=&quot;https://github.com/ayles/bpf-capsule/blob/6733c4531f06f95a32a35c2084b3dcf1a4263746/README.md#build&quot;&gt;README&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellsession&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt; sudo nix run github:ayles/bpf-capsule#doom -- /path/to/doom1.wad tty&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trick is the shape of the program presented to the verifier. First I got
DOOM to compile to BPF and run without a verifier at all. Then I cut out
everything the kernel disliked, lied to it about pointers, and forced loops
into one special form. When even that stopped scaling, I wrote a virtual
machine inside eBPF. The current machine of regions, fibers, and a software
stack grew out of it.&lt;/p&gt;
&lt;p&gt;These approaches broke one after another, and every failure suggested what had
to be built next.&lt;/p&gt;
&lt;p&gt;&lt;video controls preload=&quot;none&quot; playsinline loop width=&quot;960&quot; height=&quot;560&quot;
       poster=&quot;/doom-in-kernel/doom-capsule.webp&quot;
       aria-label=&quot;DOOM executing in the kernel; the panel on the right shows live samples from BPF Capsule JIT functions&quot;&gt;
&lt;source src=&quot;/doom-in-kernel/doom-capsule.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;a href=&quot;/doom-in-kernel/doom-capsule.mp4&quot;&gt;Open the recording.&lt;/a&gt;
&lt;/video&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The game looks especially pixelated because the frame is rendered with
terminal characters over SSH. DOOM runs inside the kernel BPF JIT on the left.
On the right are samples from real &lt;code&gt;bpf_dispatch_output_scalar_*&lt;/code&gt; functions:
physical functions into which Capsule packed the regions.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;why-this-should-be-impossible&quot;&gt;Why this should be impossible&lt;/h2&gt;
&lt;p&gt;On paper, eBPF is a small register architecture with an LLVM backend. It sounds
simple: write C, run &lt;code&gt;clang -target bpf&lt;/code&gt;, and get an object the kernel can
load.&lt;/p&gt;
&lt;p&gt;In practice, “write C” means writing in two rather different languages at once.
LLVM understands one. The Linux verifier understands the other.&lt;/p&gt;
&lt;p&gt;I became intimately familiar with that boundary while working on
&lt;a rel=&quot;external&quot; href=&quot;https://github.com/yandex/perforator&quot;&gt;Perforator&lt;/a&gt;. That is where I accumulated
enough frustration with the current BPF stack to go this far.&lt;/p&gt;
&lt;p&gt;Before loading a program, the verifier symbolically executes it. For every
register it tracks not only a value or range, but a meaning: an ordinary number
(&lt;code&gt;SCALAR_VALUE&lt;/code&gt;), a pointer to the stack, packet data, a map value, or a
&lt;code&gt;bpf_arena&lt;/code&gt;. It explores branches, merges states, and proves two things: every
memory access is allowed, and every execution path eventually terminates.&lt;/p&gt;
&lt;p&gt;That creates constraints an ordinary program barely notices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;r1&lt;/code&gt; through &lt;code&gt;r5&lt;/code&gt; are all the argument registers in the classic ABI;&lt;/li&gt;
&lt;li&gt;the call graph must be acyclic, and call depth is limited;&lt;/li&gt;
&lt;li&gt;only 512 bytes of stack are available along a call chain;&lt;/li&gt;
&lt;li&gt;one loaded program may contain no more than 256 BPF functions;&lt;/li&gt;
&lt;li&gt;after processing roughly a million instructions, the verifier gives up.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last limit is not an execution-time limit. Even a short loop can exhaust
the budget if the analyzer must revisit it with enough distinct states. A
finite loop is legal in itself; the problem starts when the kernel cannot
prove its bound or has to enumerate too many possibilities.&lt;/p&gt;
&lt;p&gt;Memory is more entertaining still. To the CPU, a pointer is ultimately just a
number. To the verifier, it is a number with a biography. It may know that
&lt;code&gt;r10 - 8&lt;/code&gt; points into a valid BPF stack slot, or that &lt;code&gt;data + n&lt;/code&gt; remains within
a packet after a check against &lt;code&gt;data_end&lt;/code&gt;. Store that pointer as ordinary 64
bits in a map and load it back, and the CPU gets the same address while the
verifier gets a number with no right to be dereferenced.&lt;/p&gt;
&lt;p&gt;Normal C programs constantly put pointers in structures, pass those structures
through several functions, and load the pointers much later. Somewhere along
that route, the verifier loses the proof.&lt;/p&gt;
&lt;h3 id=&quot;a-recent-llvm-example&quot;&gt;A recent LLVM example&lt;/h3&gt;
&lt;p&gt;Writing a valid bounds check in C is not enough: the kernel sees the code after
optimization. Here is a real fragment of &lt;a rel=&quot;external&quot; href=&quot;https://github.com/ayles/bpf-capsule/blob/6733c4531f06f95a32a35c2084b3dcf1a4263746/examples/lua-xdp/lua_xdp_runtime.c&quot;&gt;packet-processing BPF
code&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;c&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;size_t&lt;/span&gt;&lt;span&gt; at &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;span&gt; offset &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;+&lt;/span&gt;&lt;span&gt; index&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;asm&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; volatile&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;+r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;at&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;at &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;&amp;amp;=&lt;/span&gt;&lt;span&gt; PACKET_CAPACITY &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;data &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;+&lt;/span&gt;&lt;span&gt; at &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span&gt; data_end&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-4&quot;&gt;1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;byte &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-12&quot;&gt; data&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;at&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mask bounds &lt;code&gt;at&lt;/code&gt;, and the following comparison proves the packet boundary.
Late in the pipeline, however, LLVM can express the index again in terms of the
original &lt;code&gt;offset&lt;/code&gt; and &lt;code&gt;index&lt;/code&gt; loaded from a resumable loop frame. Both forms
mean the same thing to the CPU. In one form, the old verifier in the supported
Linux 5.15 profile sees a bounded index next to the access; in the other, it
loses the proof it needs. The empty inline assembly is not needed by the CPU
and emits no BPF instruction. It exists to make LLVM preserve the exact data
dependency the kernel understands.&lt;/p&gt;
&lt;p&gt;This is the unpleasant third language between C and the machine: sometimes a
program must not only be safe, but carry its safety proof through the optimizer
in a recognizable shape.&lt;/p&gt;
&lt;h2 id=&quot;first-produce-any-bpf-at-all&quot;&gt;First, produce any BPF at all&lt;/h2&gt;
&lt;p&gt;Before involving the kernel, there is an intermediate step: compile DOOM to BPF
and run the object in a userspace virtual machine. With no verifier, code
generation bugs can be separated from failures to prove safety.&lt;/p&gt;
&lt;p&gt;I based the experiment on &lt;a rel=&quot;external&quot; href=&quot;https://github.com/Daivuk/PureDOOM&quot;&gt;PureDOOM&lt;/a&gt;, a
port that packages the whole engine into one C header and exposes a short
embedding interface. It is convenient for an experiment like this while leaving
DOOM itself almost entirely ordinary C.&lt;/p&gt;
&lt;p&gt;Even without the verifier, arbitrary C does not become BPF by itself. The
classic ABI has nowhere to put a sixth argument, and BPF has neither
floating-point operations nor indirect calls. Large structure returns,
variable-size &lt;code&gt;memcpy&lt;/code&gt;, and some 128-bit arithmetic need lowering as well. I
also had to extend uBPF&#39;s program counter and add missing instructions,
sections, and ELF relocations.&lt;/p&gt;
&lt;p&gt;BPF globals do not become ordinary process memory: &lt;code&gt;.data&lt;/code&gt; and &lt;code&gt;.bss&lt;/code&gt; become
map values, while ELF relocations tell the loader which map and which offset
each address in the code refers to.&lt;/p&gt;
&lt;p&gt;After those changes, DOOM ran in uBPF. The stack was not a fundamental obstacle
there: the VM&#39;s frame size and total memory reserve can simply be increased.
This proved LLVM could emit working BPF code, but said nothing about whether a
real kernel would accept it.&lt;/p&gt;
&lt;p&gt;DOOM had been run in a userspace BPF machine before. One example is &lt;a rel=&quot;external&quot; href=&quot;https://lpc.events/event/18/contributions/1936/&quot;&gt;Flying the
nest — a BPF port of Doom&lt;/a&gt;,
which used its own νBPF VM. Inside a VM, you can change the machine&#39;s rules. My
goal was different: an object accepted by the ordinary Linux verifier and
executed by the ordinary in-kernel BPF JIT.&lt;/p&gt;
&lt;h2 id=&quot;porting-with-scissors&quot;&gt;Porting with scissors&lt;/h2&gt;
&lt;p&gt;The next step was loading the program into a real kernel. The freedom of uBPF
ended there: I could not increase the 512-byte frame, call depth, or verifier
budget. The first attempt was as direct as possible—take PureDOOM and delete
everything that did not fit. A native build remained the reference so frames
could later be compared byte for byte.&lt;/p&gt;
&lt;p&gt;The Git history from that period reads like an amputation log:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Removed sound&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove args parsing and demo playback&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove networking&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove file I/O&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove internal gettime call&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove dynamic memory allocation&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fixup some functions to take 5 arguments or less&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Remove indirect calls&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Get rid of recursion; inline the hell out of this code&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Function pointers are everywhere in DOOM: action tables, thinker functions,
renderer callbacks. I replaced them with one &lt;code&gt;indirect_call.c&lt;/code&gt; containing a
chain of comparisons against every known destination. An unknown target ended
the game, while recursive BSP traversal became an array and a manual stack.&lt;/p&gt;
&lt;p&gt;Functions then had to be inlined to fit the real BPF stack and call-depth
limits. That quickly became a dead end. Inlining reduces depth but increases
the number of values live at once and the number of register spills. Prevent
inlining and the individual functions fit, but the graph remains too deep and
occasionally recursive. One more level of inlining merely changes which limit
the program hits first.&lt;/p&gt;
&lt;p&gt;At some point it became clear that I was no longer porting DOOM. I was doing a
compiler&#39;s job by hand. The useful result was less a working binary than a list
of mechanical transformations.&lt;/p&gt;
&lt;p&gt;The first LLVM pass contained two hacks. One tried to make arbitrary memory
access acceptable to the verifier. The other forced every loop into one form
the kernel could prove. Almost the whole project eventually grew out of those
two hacks. By then this was clearly a project rather than an amusement: it got
a repository, and I named the whole construction BPF Capsule.&lt;/p&gt;
&lt;h2 id=&quot;hack-one-launder-a-pointer&quot;&gt;Hack one: launder a pointer&lt;/h2&gt;
&lt;p&gt;The problem looked like this. DOOM stores a real pointer in a heap or global
structure, then loads and dereferences it several calls later. The CPU gets the
same 64 bits. After the load, the verifier sees an ordinary number: the
pointer&#39;s origin and permitted bounds are gone. The special treatment of the
real BPF stack does not help; DOOM&#39;s arbitrary heap will not fit in 512 bytes.&lt;/p&gt;
&lt;p&gt;Before an access, that number has to be tied again to an object known by the
kernel. It sounds as if subtracting the start of &lt;code&gt;.data&lt;/code&gt; or &lt;code&gt;.bss&lt;/code&gt; should be
enough. But to the verifier the first value is a scalar and the second is a
&lt;code&gt;PTR_TO_MAP_VALUE&lt;/code&gt; obtained from an ELF relocation. The kernel forbids
&lt;code&gt;scalar - pointer&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Reversing the subtraction does not help. To the CPU, &lt;code&gt;end_ptr - x&lt;/code&gt; would be a
small distance to the section&#39;s end. But the verifier does not know that &lt;code&gt;x&lt;/code&gt;
contains an address from the same map: it sees an enormous or unknown pointer
offset, outside the range allowed by &lt;code&gt;BPF_MAX_VAR_OFF&lt;/code&gt; (&lt;code&gt;2^29&lt;/code&gt;). Reducing the
two bases afterwards is too late; the first operation is already forbidden.&lt;/p&gt;
&lt;p&gt;The same section base therefore had to exist in two forms—double-entry
bookkeeping. A laundered copy could be subtracted from the unknown address,
while the original copy, still carrying its verifier biography, could be used
after checking the result:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;x, loaded from memory              start of .data (PTR_TO_MAP_VALUE)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        |                                 |          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        |            launder              |          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        |         scalar base &amp;lt;-----------+          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        |             |                              |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        +--&amp;gt; offset = x - base                       |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                      |                              |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;             check 0 &amp;lt;= offset &amp;lt;= size - width       |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                      |                              |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                      +--&amp;gt; base + offset &amp;lt;-----------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                v&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                       valid address in .data&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first working version laundered a base rather crudely. The pass created a
&lt;code&gt;volatile&lt;/code&gt; cell called &lt;code&gt;globalConv&lt;/code&gt; in a map and a function roughly like this:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;c&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; *&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;bpf_ptr_to_scalar&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; *&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-12&quot;&gt;ptr&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    globalConv &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;span&gt; ptr&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    return&lt;/span&gt;&lt;span&gt; globalConv&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A BTF type table shipped alongside the program. It deliberately told the kernel
that this function took no arguments and returned &lt;code&gt;u64&lt;/code&gt;, even though the
machine code used &lt;code&gt;r1&lt;/code&gt;. The store into the map consequently looked like a store
of an ordinary number, and the caller received a scalar as well. The comment
was honest: &lt;code&gt;Fool the verifier into thinking that there are no args&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The section base needed another ugly trick. The pass inserted one synthetic
global at the beginning of both &lt;code&gt;.data&lt;/code&gt; and &lt;code&gt;.bss&lt;/code&gt;; a reference to it became
the real base of the corresponding map after load. At first, section size was
computed as the sum of LLVM globals, but the final layout and alignment do not
exist until ELF emission. In the last surviving version of this experiment, the
computed result was simply overwritten by two hard-coded constants.&lt;/p&gt;
&lt;p&gt;Comparisons then bounded the scalar &lt;code&gt;offset&lt;/code&gt;, and adding it to the untouched
base produced a &lt;code&gt;PTR_TO_MAP_VALUE&lt;/code&gt; again. Every uncertain read or write grew a
router:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;unknown address&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      +-- inside .data? --&amp;gt; known base + checked offset&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      +-- inside .bss?  --&amp;gt; known base + checked offset&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      `-- elsewhere    --&amp;gt; fault&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That was enough for individual sections, but not for all memory. A pointer to
the BPF stack cannot make the same round trip through a map: the kernel either
sees a pointer leak or returns a useless scalar. Address-taken locals that
survive a call therefore still need separate storage.&lt;/p&gt;
&lt;p&gt;Once memory accesses began to pass, the verifier reached the loops and spent
its million-instruction budget there instead.&lt;/p&gt;
&lt;h2 id=&quot;hack-two-one-counter-to-rule-them-all&quot;&gt;Hack two: one counter to rule them all&lt;/h2&gt;
&lt;p&gt;The first loop pass wrapped every loop in &lt;code&gt;bpf_iter_num_new&lt;/code&gt;/&lt;code&gt;next&lt;/code&gt;/&lt;code&gt;destroy&lt;/code&gt;
with a large emergency bound. It then grew more aggressive: every counter and
pointer advanced by the loop was expressed through a single iteration number,
&lt;code&gt;n&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If the source loop advanced &lt;code&gt;i&lt;/code&gt;, &lt;code&gt;j&lt;/code&gt;, and &lt;code&gt;p&lt;/code&gt; together, the transformed loop
reconstructed them:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;i = i0 + n * i_step&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;j = j0 + n * j_step&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;p = p0 + n * p_step&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The verifier saw one bounded iterator instead of a knot of related loop
variables—PHI nodes in LLVM IR. On a clean example this looked great. After
&lt;code&gt;-O2&lt;/code&gt;, however, the IR contained subtractions, narrow counters, several exits,
and a rewritten control-flow graph. Every new shape needed another rule.&lt;/p&gt;
&lt;p&gt;This also revealed a funny paradox: sometimes the verifier is faster when it
knows less. An exact initial counter value makes it walk each iteration as a
distinct state. An unknown value lets similar states merge.&lt;/p&gt;
&lt;p&gt;Before entering the canonical loop, the counters were stored in &lt;code&gt;volatile&lt;/code&gt;
memory and loaded back. The verifier then saw a range rather than an exact
constant, allowing states from different iterations to merge.&lt;/p&gt;
&lt;p&gt;This carried most loops through the verifier. But a compiler can spend forever
learning every new LLVM IR shape. I needed a way to execute arbitrary control
flow without making the kernel see all of it at once.&lt;/p&gt;
&lt;h2 id=&quot;the-simplest-complete-solution&quot;&gt;The simplest complete solution&lt;/h2&gt;
&lt;p&gt;If program code is stored as data, the verifier does not need to analyze its
control-flow graph. It checks one small interpreter. The next instruction
number, registers, stack, and guest call frames live in maps. Each BPF
invocation interprets a fixed number of instructions and then returns.
Termination is obvious.&lt;/p&gt;
&lt;p&gt;I tested this literally: first an interpreter for real eBPF inside eBPF, then
an RV64IM interpreter and loader for ordinary RISC-V ELF files. A checksum and
zlib produced correct results, but an archived single-core zlib measurement was
roughly &lt;strong&gt;60×&lt;/strong&gt; slower than native code. Most of the time, the kernel was not
running zlib at all. It was running &lt;code&gt;switch (opcode)&lt;/code&gt;: the virtual machine
returned to the dispatcher after every guest instruction.&lt;/p&gt;
&lt;p&gt;The unit of interpretation had to be much larger than one instruction. The
virtual machine state, however, was worth keeping.&lt;/p&gt;
&lt;h2 id=&quot;what-if-one-instruction-is-a-piece-of-the-program&quot;&gt;What if one instruction is a piece of the program?&lt;/h2&gt;
&lt;p&gt;Instead of an &lt;code&gt;add&lt;/code&gt;, &lt;code&gt;load&lt;/code&gt;, or &lt;code&gt;jump&lt;/code&gt;, one operation in the new machine
contains a whole piece of already compiled code. Ordinary BPF runs inside that
piece, then saves its state and returns to a small dispatcher. I call such a
piece a &lt;strong&gt;region&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A region is bounded: it ends at a complex call, a return, a &lt;code&gt;yield&lt;/code&gt;, an
inconvenient loop backedge, or wherever the compiler decides to cut an
oversized graph. It runs in full, saves live values, and returns to the
dispatcher. Capsule can suspend the computation only at that boundary; this
does not prevent other fibers from running concurrently. The dispatcher invokes
the next region. To the verifier this is an ordinary caller–callee boundary,
not another part of DOOM&#39;s enormous control-flow graph.&lt;/p&gt;
&lt;p&gt;A normal call is split roughly like this:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;render_frame:   [ 17 ] ---call---&amp;gt; R_DrawPlanes: [ 42 ] ---&amp;gt; [ 43 ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                                                               |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                [ 18 ] &amp;lt;--------------- return ----------------&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                continuation of render_frame&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;at every arrow the region saves its live values and the number of the next&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;region, returns to the dispatcher, and the dispatcher calls that region&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A region does not have to become a separate BPF function. LLVM generates code
and allocates registers for a whole group of regions at once. Usually, a group
contains all the regions of one source function; a large function may be split
into several groups. Capsule packs these groups, largest first, into physical
BPF functions, always choosing the one that currently holds the least code.
One physical function therefore usually contains regions from several source
functions.&lt;/p&gt;
&lt;p&gt;One BPF function per region would quickly hit the 256-function limit. One
function for the whole program is bad as well: the kernel repeatedly performs
live-value analysis across the entire monster and load time explodes. Instead,
the object contains several physical functions of roughly equal size:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;groups of regions, one per             physical BPF functions&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;source function&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;render: 17, 18     ----+               function 0: 42, 43, 44&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;planes: 42, 43, 44 ----+-- packing --&amp;gt; function 1: 17, 18, 61&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;things: 61         ----+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;packed region number: [ 16 bits: region inside the function | 8 bits: function ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        selected by a compare tree            selected by a mask&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A region&#39;s number is packed accordingly. Its low byte names the physical
function that owns the region, so the entry code selects that function with a
mask and a switch; a balanced compare tree inside the function then selects the
exact region. The principle is simple: a number in fiber state selects a large
piece of ordinary JIT code. Source C function boundaries define the software
stack; physical BPF function boundaries keep the object digestible for the
verifier.&lt;/p&gt;
&lt;p&gt;Sometimes even that is not enough. The verifier gives one loaded program a
single exploration budget of about a million instructions, and the CPython
interpreter does not fit into it. For such programs, &lt;code&gt;bpf-capsule-ld --freplace&lt;/code&gt; moves physical functions into BPF extensions that live in the same
ELF. A stub that fails when called stays behind for each of them in the base
program, and the host attaches the extensions with &lt;code&gt;freplace&lt;/code&gt; before
initialization. Each extension is a separately loaded program with a budget of
its own, while fibers, memory, and continuations keep working through them
unchanged. This needs BPF trampolines: any supported kernel on x86-64, and
Linux 6.0 or newer on arm64.&lt;/p&gt;
&lt;p&gt;With or without extensions, this remains BPF compiled by the stock JIT, not
interpreted LLVM IR or another ISA. The verifier checks every instruction
inside a region, and the kernel JIT compiles it. What remains of the virtual
machine is an explicit next-operation number, stack, and state, but dispatch
happens at the boundary of a large piece of work rather than after every &lt;code&gt;add&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;where-the-fibers-and-second-stack-came-from&quot;&gt;Where the fibers and second stack came from&lt;/h2&gt;
&lt;p&gt;Once a function call is split by a region boundary, the ordinary BPF call stack
is no longer enough. Arguments, locals, and the return address need somewhere
to survive the transition. That became a software stack in Capsule memory.&lt;/p&gt;
&lt;p&gt;The caller places everything that must cross the boundary there, creates a
callee frame, records the callee&#39;s first region, and returns to the dispatcher.&lt;/p&gt;
&lt;p&gt;Later, the callee writes its result into the caller-owned part of that frame,
restores the continuation number, and returns through the dispatcher too.&lt;/p&gt;
&lt;p&gt;This has a small ABI of its own. A frame looks roughly like this:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;             higher addresses&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        +--------------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        | variadic arguments             |  layout known at the call site&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        | fixed arguments                |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        | optional result area           |  written by the callee&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;fp+16 --+--------------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        | region to run after return     |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; fp+8 --+--------------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        | caller&amp;#39;s fp                    |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   fp --+--------------------------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        | callee locals and saved values |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   sp --+--------------------------------+  allocated-stack frontier&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;             lower addresses&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The stack grows toward lower addresses: &lt;code&gt;fp&lt;/code&gt; marks the current frame boundary,
while &lt;code&gt;sp&lt;/code&gt; marks the lower edge of allocated space. A call changes only &lt;code&gt;fp&lt;/code&gt;,
&lt;code&gt;sp&lt;/code&gt;, and the next region number. Each call site already knows how LLVM lowered
its arguments, so it allocates exactly the outgoing area that call needs.
Values, including structures passed by value, live directly in that area: a
field is read at a constant offset from &lt;code&gt;fp&lt;/code&gt;, without first loading a pointer
to a separate copy. Variadic arguments follow the fixed prefix, and &lt;code&gt;va_list&lt;/code&gt;
is simply a cursor through that tail. A return performs the three state changes
in reverse. A sixth argument, deep call chain, or recursion therefore consumes
no additional registers or frames in the real BPF ABI: as far as the kernel is
concerned, each region still returns normally.&lt;/p&gt;
&lt;p&gt;Recursion does not turn into recursive calls between BPF functions. Every
source call merely pushes another software frame. A function pointer becomes an
ordinary 64-bit value in the code range just above the data window: &lt;code&gt;window + 4 GiB + the number of its entry region&lt;/code&gt;. An indirect call recovers the region
number by truncating that value to its low 32 bits, and the dispatcher enters
the region it names.&lt;/p&gt;
&lt;p&gt;The real BPF stack does not disappear. Its 512 bytes serve as scratch space for
the current region. Values that must outlive a region are moved into the
software frame.&lt;/p&gt;
&lt;p&gt;The current region, the stack and frame pointers, the saved state, and one
slice of software stack together make a &lt;strong&gt;fiber&lt;/strong&gt;. There may be several fibers:
each has separate state and stack storage, while globals and the heap are
shared. A &lt;code&gt;_Thread_local&lt;/code&gt; variable gets one instance per fiber: the compiler
collects all such variables into one block and indexes it by fiber number, so a
library written for threads sees a thread where Capsule has a fiber.&lt;/p&gt;
&lt;p&gt;The fiber&#39;s control record stores the next region in &lt;code&gt;resume_region_id&lt;/code&gt;: an
integer ID, not a processor instruction address or a counter to increment.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;capsule_yield()&lt;/code&gt; deliberately uses the same boundary. State remains in the
fiber, while the caller receives a number that can resume the work in a later
BPF invocation. The number includes the fiber generation, so a stale value
cannot accidentally resume a different task that has reused the same slot.&lt;/p&gt;
&lt;p&gt;Loops no longer have to be normalized into one exact IR pattern after
optimization. A small loop with a proven bound remains an ordinary BPF loop. A
hot dynamic loop may execute several iterations inside one region. In the worst
case, a backedge saves the next iteration&#39;s live values and returns to the
dispatcher.&lt;/p&gt;
&lt;h3 id=&quot;why-the-verifier-accepts-this-dispatcher&quot;&gt;Why the verifier accepts this dispatcher&lt;/h3&gt;
&lt;p&gt;The dispatcher consists of three bounded loops. The innermost one, the step,
runs up to thirty-two regions and returns. The level above calls the step up
to 2,048 times; the entry program calls that level up to 64 times. Each of the
two inner loops is a global BPF function. The verifier checks it once, rather
than analyzing it again at every call site. Analysis costs therefore add up,
while the number of transitions at runtime multiplies: 32 × 2,048 × 64 is
about 4.2 million regions in one invocation, even though the control-flow
graphs the kernel checks remain small.&lt;/p&gt;
&lt;p&gt;Batching regions inside the step also buys speed: every region after the first
reuses one function call instead of paying for its own.&lt;/p&gt;
&lt;p&gt;When the computation finishes, &lt;code&gt;capsule_call()&lt;/code&gt; returns &lt;code&gt;CAPSULE_OK&lt;/code&gt;. If it
uses the entire transition budget, the BPF invocation still terminates and
returns &lt;code&gt;CAPSULE_PENDING&lt;/code&gt; with a continuation number. A later invocation may
pass that number to &lt;code&gt;capsule_continue()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For DOOM, I expect initialization and every frame to fit in one invocation.
Capsule knows nothing about that expectation and has no game-specific behavior.
The example integration simply never calls &lt;code&gt;capsule_continue()&lt;/code&gt; and treats any
&lt;code&gt;PENDING&lt;/code&gt; as a regression.&lt;/p&gt;
&lt;h2 id=&quot;what-pointer-laundering-became&quot;&gt;What pointer laundering became&lt;/h2&gt;
&lt;p&gt;The old &lt;code&gt;globalConv&lt;/code&gt; was enough for the first prototype, but an entire memory
model built on lies in BTF was not viable. The current design starts with one
4-GiB-aligned virtual window containing globals, the heap, and software stacks.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 the in-kernel BPF code&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   v v v v v v v v v v v v v v v v v v v v v v v v v&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   +-----------------+-----------+-----------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   |     globals     |    heap   |   fiber stacks  |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   +-----------------+-----------+-----------------+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 the userspace process&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   window base, 4-GiB aligned                + 4 GiB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   one range of addresses, the same on both sides&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A Capsule pointer is an ordinary &lt;code&gt;window_base + displacement&lt;/code&gt; address. It has
the same 64-bit value inside BPF and in the userspace process. Its low 32 bits
are the displacement within the window.&lt;/p&gt;
&lt;p&gt;Ordinary globals move into this window: &lt;code&gt;static&lt;/code&gt; variables, strings, tables,
and zero-filled buffers. Their initial values must arrive there too; how that
happens depends on the memory backend.&lt;/p&gt;
&lt;p&gt;Explicitly sectioned objects stay outside this transformation. Maps in
&lt;code&gt;SEC(&quot;.maps&quot;)&lt;/code&gt; and control blocks such as DOOM&#39;s &lt;code&gt;SEC(&quot;.data.ctrl&quot;)&lt;/code&gt; remain
ordinary BPF maps loaded by libbpf. The control block carries pointers, sizes,
and input; the WAD and framebuffer themselves live in the shared window.&lt;/p&gt;
&lt;p&gt;On Linux 6.9 and newer (6.10 on arm64, where JIT support for the arena landed
later), the window is backed by &lt;code&gt;bpf_arena&lt;/code&gt;: libbpf loads globals with non-zero
initial contents from ELF, and Capsule initialization allocates zero pages for
the remaining globals, heap, and stacks. A full pointer can be stored,
compared, and returned as an ordinary number—a scalar to the verifier. Before
dereferencing it, the compiler runs it through the special BPF
&lt;code&gt;addr_space_cast&lt;/code&gt; instruction. The verifier marks the temporary result as
&lt;code&gt;PTR_TO_ARENA&lt;/code&gt;, and only that result touches memory:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;full window + offset pointer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   +-- store / compare / return --&amp;gt; the same 64 bits, a scalar&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   `-- addr_space_cast --&amp;gt; PTR_TO_ARENA --&amp;gt; memory access&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On kernels without a usable arena, the same four gigabytes are assembled from
4-MiB pieces. The compiler lays globals out consecutively, serializes their
initial values into a flat byte image, and cuts the first pieces of that image
into separate global-data maps. How many is up to the linker, which takes
whatever is left of the map budget — usually 32. A piece containing any
non-zero byte becomes &lt;code&gt;.data.heapN&lt;/code&gt;; an entirely zero-filled piece becomes
&lt;code&gt;.bss.heapN&lt;/code&gt;. Only one of the two maps exists for any index. All later pieces,
including software stacks, become 4-MiB values in one &lt;code&gt;ARRAY&lt;/code&gt; map.&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;full pointer p&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   offset = low 32 bits of p&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   piece  = offset &amp;gt;&amp;gt; 22         within = offset &amp;amp; (4 MiB - 1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   +-- piece known at compile time --&amp;gt; its .data.heapN / .bss.heapN + within&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   `-- unknown --&amp;gt; switch over direct maps&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                   `-- beyond them --&amp;gt; ARRAY lookup for the tail&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The general router is expensive, so not every access uses it. If the compiler
can see that &lt;code&gt;p&lt;/code&gt; derives from a particular global, it selects that map
immediately and leaves only a mask and addition. On the arena tier, one
&lt;code&gt;addr_space_cast&lt;/code&gt; result is similarly reused for several accesses from the same
base.&lt;/p&gt;
&lt;p&gt;A boundary between two pieces needs care. A map value cannot be read past its
end, and an access lying across two pieces would need two map lookups. Pieces
are 4-MiB aligned, so an access aligned to its own width cannot cross one: a
naturally aligned &lt;code&gt;uint64_t&lt;/code&gt; lies entirely inside a single piece. The compiler
therefore trusts LLVM&#39;s alignment information. Before routing, it splits every
load or store whose alignment is smaller than its width into naturally aligned
fragments, each selecting its own map: an eight-byte load with four-byte
alignment becomes two four-byte loads; with byte alignment, it becomes eight
single-byte loads. Claiming an alignment that is not there is undefined
behavior, exactly as on any other platform.&lt;/p&gt;
&lt;p&gt;The userspace process maps the same pages at the same addresses. BPF can
therefore return an &lt;code&gt;unsigned char *&lt;/code&gt; to the framebuffer, and the process can
check its bounds, read it directly, and follow pointers stored in shared
memory. No handles, object serialization, or copying through a special map API
are required.&lt;/p&gt;
&lt;h3 id=&quot;the-heap-and-non-suspending-operations&quot;&gt;The heap and non-suspending operations&lt;/h3&gt;
&lt;p&gt;The shared window provides an address space, but &lt;code&gt;malloc()&lt;/code&gt; still needs an
implementation. Capsule uses TLSF: its metadata and blocks live in the shared
heap, while &lt;code&gt;malloc()&lt;/code&gt; and &lt;code&gt;free()&lt;/code&gt; execute inside BPF with the rest of the
program. Fibers share one heap, but each keeps its own software stack.
Userspace allocates from the same heap through &lt;code&gt;bpf_capsule_malloc()&lt;/code&gt;, which
runs the guest allocator in the kernel through &lt;code&gt;BPF_PROG_TEST_RUN&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;While TLSF holds a lock and edits free lists, the computation must not return
to the dispatcher. &lt;code&gt;CAPSULE_NOSUSPEND&lt;/code&gt; marks allocator functions: the compiler
must prove that each function and everything it calls finish without
suspension. An unprovable loop or suspendable call becomes a build error. A
short TLSF operation therefore stays in one ordinary BPF call with no
dispatcher boundary: it cannot suspend between acquiring and releasing its
lock. If the lock is busy, ordinary &lt;code&gt;malloc()&lt;/code&gt; retries outside that operation.&lt;/p&gt;
&lt;h3 id=&quot;where-the-transformation-stops&quot;&gt;Where the transformation stops&lt;/h3&gt;
&lt;p&gt;Not every pointer can or should be transformed. Globals explicitly placed in a
BPF section remain ordinary maps. A local whose address is passed to a BPF
helper remains on the real 512-byte BPF stack. A pointer returned by a helper
or kfunc keeps its verifier type as well. If the kernel can already prove an
access, the compiler does not route it through the shared window.&lt;/p&gt;
&lt;p&gt;XDP makes the boundary especially clear. An ordinary entry program starts
Capsule code like this:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;c&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;SEC&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;xdp&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-6&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt; observe&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;struct&lt;/span&gt;&lt;span&gt; xdp_md &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-12&quot;&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    size_t&lt;/span&gt;&lt;span&gt; output_size&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    struct&lt;/span&gt;&lt;span&gt; capsule_result r &lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;        capsule_call_ctx&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; &amp;amp;&lt;/span&gt;&lt;span&gt;output_size&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; run_lua&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; ==&lt;/span&gt;&lt;span&gt; CAPSULE_PENDING&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        (&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;void&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-6 z-d-7&quot;&gt;capsule_reset&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;continuation&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;    return&lt;/span&gt;&lt;span&gt; XDP_PASS&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;observe&lt;/code&gt; remains an ordinary BPF program, while &lt;code&gt;ctx&lt;/code&gt; is a special pointer
whose history is tracked by the verifier. It is not an argument of &lt;code&gt;run_lua&lt;/code&gt;:
the &lt;code&gt;_ctx&lt;/code&gt; suffix explicitly selects a separate channel between ordinary BPF
and Capsule. This pointer cannot be stored in a software frame; after a reload
from a map or arena it would be only a scalar. The compiler therefore splits
regions into two classes:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;capsule_call(...)               --&amp;gt; scalar dispatcher --&amp;gt; scalar regions only&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;capsule_continue(...)           --&amp;gt; scalar dispatcher --&amp;gt; scalar regions only&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;capsule_call_ctx(ctx, ...)      --&amp;gt; ctx dispatcher    --&amp;gt; both region classes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;capsule_continue_ctx(ctx, ...)  --&amp;gt; ctx dispatcher    --&amp;gt; both region classes&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A scalar region needs only a fiber number and saved state. A context region
also receives the real &lt;code&gt;struct xdp_md *&lt;/code&gt; as its first BPF-function argument.
That keeps &lt;code&gt;ctx&lt;/code&gt; in registers and on the real BPF stack throughout the call
chain, never in fiber memory. Deep inside Capsule code, it can be obtained once
through &lt;code&gt;capsule_borrowed_ctx()&lt;/code&gt; and then used as an ordinary variable; the
compiler carries the hidden argument across region boundaries.&lt;/p&gt;
&lt;p&gt;On continuation, the context is not restored from the fiber. The calling BPF
program supplies it again through &lt;code&gt;capsule_continue_ctx()&lt;/code&gt;. If a yield resumes
immediately within the same XDP invocation, it will be the same &lt;code&gt;ctx&lt;/code&gt;. If
continuation happens later, the current &lt;code&gt;ctx&lt;/code&gt; may describe another packet:
Capsule promises no identity between them. Lua-XDP expects packet parsing to
finish in the original invocation and resets the fiber on any &lt;code&gt;PENDING&lt;/code&gt;. A
long-running computation that needs packet bytes must copy them into the shared
window first.&lt;/p&gt;
&lt;p&gt;Even within the original invocation, a packet pointer obtained from &lt;code&gt;ctx&lt;/code&gt;
cannot be stored in a software frame or carried into the next region. The
compiler can reinsert the root &lt;code&gt;ctx&lt;/code&gt;, but &lt;code&gt;data&lt;/code&gt; and &lt;code&gt;data_end&lt;/code&gt; must be loaded
and checked again. Attempting to preserve a packet pointer stops the build.&lt;/p&gt;
&lt;h2 id=&quot;what-else-the-compiler-has-to-do&quot;&gt;What else the compiler has to do&lt;/h2&gt;
&lt;p&gt;Regions solve control flow, but do not add the missing pieces of the BPF ABI.
The compiler also:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;packs calls with many or variable numbers of arguments;&lt;/li&gt;
&lt;li&gt;lowers large structure returns;&lt;/li&gt;
&lt;li&gt;replaces floating-point and 128-bit arithmetic with the software
implementations from compiler-rt, compiled into the same object;&lt;/li&gt;
&lt;li&gt;expands dynamic &lt;code&gt;memcpy&lt;/code&gt;, &lt;code&gt;memmove&lt;/code&gt;, and &lt;code&gt;memset&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;turns function pointers into packed region IDs;&lt;/li&gt;
&lt;li&gt;repairs BTF names and types;&lt;/li&gt;
&lt;li&gt;moves excess temporary values out of the BPF stack after register allocation,
while leaving pointers whose types the verifier must see on that stack.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Exactly one computation involving &lt;code&gt;double&lt;/code&gt; survives into PureDOOM&#39;s executable
code. It would be easy to rewrite, but I left it alone: this path goes through
the same soft-float lowering as any other program.&lt;/p&gt;
&lt;p&gt;LLVM itself remains unpatched. &lt;code&gt;bpf-capsule-cc&lt;/code&gt; emits bitcode; &lt;code&gt;bpf-capsule-ld&lt;/code&gt;
links the whole program with the runtime, runs Capsule&#39;s passes, and only then
gives the result to LLVM&#39;s ordinary BPF backend for ELF and BTF emission. The C
library is Picolibc, linked as a bitcode archive from which the linker extracts
only the members the program reaches. A small platform layer supplies what the
C library expects from a system: a fiber-local &lt;code&gt;errno&lt;/code&gt;, the TLSF heap, and OS
functions that fail until the application replaces them.&lt;/p&gt;
&lt;h2 id=&quot;what-remains-of-the-doom-integration&quot;&gt;What remains of the DOOM integration&lt;/h2&gt;
&lt;p&gt;After all this work, the integration is almost boring. PureDOOM is designed to
be embedded: the surrounding program supplies a small set of C functions for
memory, WAD reads, time, input, and exit. In Capsule these are not calls into
userspace: &lt;code&gt;malloc()&lt;/code&gt;/&lt;code&gt;free()&lt;/code&gt;, WAD reads, and the engine itself are compiled
into one BPF object and execute in the kernel.&lt;/p&gt;
&lt;p&gt;Userspace participates only at the outer boundary. At startup it allocates a
buffer in Capsule memory, copies the WAD once, and gives the engine an ordinary
&lt;code&gt;unsigned char *&lt;/code&gt; and size. From then on, BPF code reads the WAD in place.&lt;/p&gt;
&lt;p&gt;Initialization is one BPF entry point. Each game tick, including complete
rendering, is another. After rendering, BPF publishes a pointer to the
framebuffer inside the shared window. Userspace checks the &lt;code&gt;320 * 200 * 4&lt;/code&gt;
range and reads the pixels directly for terminal or PPM output.&lt;/p&gt;
&lt;p&gt;A deterministic test supplies one input sequence and requires the native and
in-kernel engines to produce identical frames on each supported profile. It
catches both a wrong image and an unexpected &lt;code&gt;PENDING&lt;/code&gt; during frame processing.&lt;/p&gt;
&lt;h2 id=&quot;the-price-of-getting-large-code-into-the-kernel&quot;&gt;The price of getting large code into the kernel&lt;/h2&gt;
&lt;p&gt;There is no single honest number for &quot;Capsule is N times slower.&quot; The cost
depends on how often execution crosses a region boundary, how much memory the
program touches, and how much floating point it does. So every example measures
itself: the in-kernel figure comes from BPF&#39;s own accounting, the native one
from the CPU time of the same workload in userspace. Loading and verification
are outside these timings. CPython&#39;s native mode uses the flake&#39;s host Python
3.14; both Python timings include interpreter startup.&lt;/p&gt;
&lt;p&gt;All numbers below come from two machines with the governor set to
&lt;code&gt;performance&lt;/code&gt;, each run pinned to one core. The first is an Intel i7-12700K on
Linux 7.1.3, measured on a performance core at 4.9 GHz, with the examples built
for the 6.9 profile:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Example&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Native&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;In kernel&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Ratio&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DOOM, one frame&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.105 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.367 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;3.5×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SQLite&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;43.8 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;289.4 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;6.6×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lua&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;70.1 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;524.0 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;7.5×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;QuickJS&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;116.6 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1068.0 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;9.2×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CPython&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;120.9 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1889.2 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;15.6×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;llama2.c, Q8&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;14.6 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;274.8 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;18.8×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;llama2.c, FP32&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;7.5 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;471.2 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;62.9×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The second is an ARM64 machine on Linux 7.0.12, measured on one of its big
Cortex-A720 cores at 2.6 GHz — the same idea as the Intel P-core, next to
smaller Cortex-A520 cores that would give quite different numbers. There the
examples are built for the 6.10 profile, the first one with an arena on arm64:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Example&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Native&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;In kernel&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Ratio&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DOOM, one frame&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.227 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.913 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;4.0×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SQLite&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;81.4 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;639.2 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;7.9×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lua&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;127.6 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1090.4 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;8.5×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;QuickJS&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;224.0 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2313.1 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;10.3×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CPython&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;242.3 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4487.9 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;18.5×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;llama2.c, Q8&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;23.2 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;539.3 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;23.2×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;llama2.c, FP32&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;13.0 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;775.2 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;59.6×&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Every row is a median of three runs. The
&lt;a rel=&quot;external&quot; href=&quot;https://github.com/ayles/bpf-capsule/tree/6733c4531f06f95a32a35c2084b3dcf1a4263746/examples&quot;&gt;workloads and build recipes&lt;/a&gt;
are in the repository; the flake pins the toolchain, including LLVM 23. From a
checkout, the Lua comparison looks like this (&lt;code&gt;-610&lt;/code&gt; on arm64):&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellsession&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt; sudo taskset -c 0 nix run .#lua-69 -- examples/lua/benchmark.lua&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt; taskset -c 0 nix run .#lua-69 -- --native examples/lua/benchmark.lua&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are comparisons within each example, not a race between interpreters:
their benchmark scripts differ. The shape of the table matters more than any
single number. Integer and pointer code — DOOM, SQLite — runs a few times
slower than native userspace; the earlier instruction-by-instruction VM was
about sixty times slower on zlib. Interpreters land around an order of
magnitude, because their own dispatch loops also cross region boundaries.
CPython sits at the far end of that group on both machines. Reference counting,
allocation, and helper calls all add work in the places Capsule makes expensive;
the table does not isolate their individual contributions.&lt;/p&gt;
&lt;p&gt;Floating point is the outlier: llama2.c&#39;s FP32 model is sixty times slower
because the target has no FPU and every operation becomes a call into software
floating point. Q8 replaces much of that arithmetic with integer work, and the
penalty falls to about nineteen to twenty-three times.&lt;/p&gt;
&lt;p&gt;Compatibility costs too. The same example without a suffix — &lt;code&gt;nix run .#lua&lt;/code&gt; —
is built for Linux 5.15, where there is no arena and dynamic heap accesses go
through the map router. The Lua benchmark then takes 983.6 ms on the Intel
machine instead of 524.0. Old kernels are supported, not free.&lt;/p&gt;
&lt;p&gt;Every number so far comes from a program that runs to completion in its own
time. A packet observer does not get that luxury. The ready-to-run &lt;a rel=&quot;external&quot; href=&quot;https://github.com/ayles/bpf-capsule/tree/6733c4531f06f95a32a35c2084b3dcf1a4263746/examples/lua-xdp&quot;&gt;Lua-XDP
example&lt;/a&gt;
attaches Lua 5.5.1 to a real XDP hook. Its CPython twin gives each fiber its
own interpreter, with the pure-Python standard library stored in Capsule
memory. A script supplied at startup sees every received packet and emits
results through a ring buffer:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellsession&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt; sudo nix run .#lua-xdp-69 -- examples/lua-xdp/packet_observer.lua eth0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The natural question is what that costs a real link. Both machines have a
gigabit interface, so I measured a download with and without an observer,
using the same arena profiles as above (&lt;code&gt;-610&lt;/code&gt; on arm64). Per-packet time is
the XDP program&#39;s accumulated kernel run time divided by its invocation count.
For these runs the receive queue&#39;s interrupt was pinned to a performance core
through &lt;code&gt;/proc/irq/N/smp_affinity_list&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Observer&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Intel, download&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;per packet&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;ARM64, download&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;per packet&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;none&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;995.9 Mbit/s&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;—&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;994.5 Mbit/s&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lua&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;643.3 Mbit/s&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;16.7 µs&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;295.5 Mbit/s&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;35.4 µs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CPython&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;368.2 Mbit/s&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;30.8 µs&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;76.3 Mbit/s&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;131.1 µs&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both example observers format one line per packet and ship it to userspace.
Without that output, parsing the same headers costs 5.8 µs per packet in Lua
and 25.1 µs in CPython on the ARM64 machine, and the Lua one leaves the gigabit
almost intact, at 964.6 Mbit/s. These link measurements include the effects of
packet sizes, network conditions, and draining the output ring buffer. They
are workload results, not a universal throughput limit for either interpreter.&lt;/p&gt;
&lt;h2 id=&quot;llvm-and-the-verifier-still-do-not-agree&quot;&gt;LLVM and the verifier still do not agree&lt;/h2&gt;
&lt;p&gt;The verifier solves the right problem: a bug in a loaded program must not crash
the kernel. LLVM is equally entitled to replace a program with a semantically
equivalent one. The trouble is at the boundary. Two forms can be identical to
the CPU while only one lets the verifier recognize the required proof.&lt;/p&gt;
&lt;p&gt;Today this contract depends on &lt;code&gt;volatile&lt;/code&gt;, inline assembly, control-flow shape,
and BTF. An LLVM update can remove a necessary instruction; a verifier change
can break an old proof. The log usually identifies the final forbidden access,
not the earlier point where a pointer bound was lost.&lt;/p&gt;
&lt;p&gt;This is not merely archaeology from my project: at the time of writing, LLVM
carries two bugs with small reproducers. The first is &lt;a rel=&quot;external&quot; href=&quot;https://github.com/llvm/llvm-project/issues/208984&quot;&gt;a BPF code-generation
bug where a conditional branch through an empty block lands on the wrong
instruction&lt;/a&gt;: the compiler
silently emits a plausible but incorrect object. The second is that adding &lt;code&gt;-g&lt;/code&gt;
can &lt;a rel=&quot;external&quot; href=&quot;https://github.com/llvm/llvm-project/issues/208141&quot;&gt;change a C++ function prototype and drop an
argument&lt;/a&gt;: the declaration
and call no longer agree, producing incorrect code or crashing the compiler.&lt;/p&gt;
&lt;p&gt;The answer to that gap is not to weaken the verifier but to write an explicit
contract between it and the compiler: operations whose meaning is guaranteed to
survive optimization, diagnostics that track pointer provenance, and end-to-end
tests across LLVM IR, BPF, BTF, and several kernel versions. Physical ABI
limits should likewise be transformed by a shared layer rather than worked
around anew in every large BPF project.&lt;/p&gt;
&lt;p&gt;BPF Capsule ships its own passes because that layer does not exist yet. A good
outcome for the project would be deleting those passes in favor of shared
infrastructure, not maintaining a private LLVM pipeline forever.&lt;/p&gt;
&lt;h2 id=&quot;what-capsule-does-not-do&quot;&gt;What Capsule does not do&lt;/h2&gt;
&lt;p&gt;A few limits are worth stating plainly before anyone builds on this.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This is research software, not a security boundary between guest components.
The kernel&#39;s BPF checks still apply, but Capsule does not protect one part
of a program from another.&lt;/li&gt;
&lt;li&gt;There is no operating system inside: no files, sockets, processes, or
threads. There is a C library, an allocator, and fibers; anything resembling
a system call is implemented by the application or supplied by the host.&lt;/li&gt;
&lt;li&gt;Every capacity is finite and fixed at build or load time: the number of
fibers, the size of the stack and heap, the budget of a single call.&lt;/li&gt;
&lt;li&gt;The proofs the verifier accepts depend on LLVM and kernel versions. CI checks
the supported profiles, but a new version of either may require changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-the-kernel-ultimately-sees&quot;&gt;What the kernel ultimately sees&lt;/h2&gt;
&lt;p&gt;The tests go beyond successful loading: they compare llama2.c&#39;s generated
tokens with a native run, check DOOM&#39;s frames, and exercise CPython imports and
packet processing from two CPUs. CI builds each supported capability profile
and runs it on a compatible packaged kernel, not necessarily the exact version
named by the profile. CPython is tested only on profiles with arena memory and
the other features its port requires; it is not a Linux 5.15 example.&lt;/p&gt;
&lt;p&gt;One more port has already run end to end: the &lt;code&gt;scx_rustland&lt;/code&gt; scheduler, moved
into the kernel on Capsule, schedules real tasks with numbers comparable to its
userspace original. A good scheduler on Capsule is a different project, and a
different article.&lt;/p&gt;
&lt;p&gt;LLM tools sped the work up considerably. Alongside a day job, I probably would
not have brought the project this far without them.&lt;/p&gt;
&lt;p&gt;The verifier never proves that DOOM terminates. It proves that the next region
and the bounded dispatcher terminate. At runtime, Capsule assembles the
complete game out of those finite pieces.&lt;/p&gt;
&lt;h2 id=&quot;further-reading&quot;&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/ayles/bpf-capsule&quot;&gt;BPF Capsule&lt;/a&gt; — source, examples, and
instructions for running them.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://docs.kernel.org/bpf/verifier.html&quot;&gt;Linux verifier&lt;/a&gt; — register types,
value ranges, stack behavior, and state merging.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://docs.kernel.org/bpf/bpf_design_QA.html&quot;&gt;BPF Design Q&amp;amp;A&lt;/a&gt; — calling
convention and verifier constraints.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://www.rfc-editor.org/rfc/rfc9669.html&quot;&gt;RFC 9669: BPF ISA&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=317460317a02a1af512697e6e964298dedd8a163&quot;&gt;The commit that introduced &lt;code&gt;bpf_arena&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://lpc.events/event/18/contributions/1936/&quot;&gt;Flying the nest — a BPF port of Doom&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
</feed>
