Problem. How do you translate a deliberately restricted x86-64 instruction set without obscuring what is supported? Implementation. Recode now has validated translation, separate integer-program APIs and a managed JIT with guarded trace exits. Verification. Interpreter/native correctness tests and performance measurements document both working paths and limitations.

Recode started as an experiment in translating a deliberately small part of x86-64 into new executable code. I wanted to work through instruction decoding, an intermediate representation, machine-code emission and memory protection without hiding those pieces behind a larger compiler. The scope grew into an execution library with an interpreter, a managed whole-function JIT, a loop-tracing tier and a separate public API for building integer programs.

There are still two distinct input models. The original translator accepts a narrow set of accumulator instructions in a byte buffer. The newer integer-program API constructs its own validated IR with virtual registers, two integer arguments and optional bounded read-only data. Treating them as the same frontend would overstate what the decoder can do.

The first translator

The original contract was effectively uint64_t f(uint64_t), following the Linux x86-64 calling convention. The decoder accepts specific encodings for moving an argument into RAX, arithmetic with immediate values, comparisons and tests, selected branches, NOP and RET. It rejects unsupported prefixes, addressing modes, registers, external branches, decoded memory operands and syscalls. The supported encodings are enumerated in the README, not inferred from what an x86 CPU could execute.

Decoding produces an instruction-level IR. Validation rejects truncated encodings, unreachable padding, jumps into the middle of an instruction, reads of an uninitialized RAX and conditional jumps with undefined zero flag state. Basic-block analysis checks information flowing through joins and backedges. The optimizer folds runs of ADD and SUB when the combined immediate is representable; it retains a flag-setting operation when dropping a zero delta would change a later ZF-dependent branch.

Emission lays blocks out in input order, resolves branch displacements after instruction sizes are known, and puts an ENDBR64 marker at generated entry points. Native pages are mapped writable, filled with emitted instructions, then changed to readable and executable. There is no simultaneously writable/executable mapping in this path. The caller owns the low-level compiled mapping and must release it through the API.

Input:   mov rax, rdi
         add rax, 7
         sub rax, 2
         nop
         ret

Output:  ENDBR64
         mov rax, rdi
         add rax, 5
         ret

This is the repository's documented small translation example. It is not arbitrary x86-64 optimization or a general ELF rewriting pass. See decoder.c, ir.c, optimizer.c and codegen.c.

Adding control flow

The first straight-line translator had a much simpler correctness problem. Conditional branches introduced control-flow joins, loops and state that could no longer be checked one instruction at a time. A JZ is only meaningful if a reachable predecessor established ZF; an ADD/SUB optimization may preserve RAX but still change the result of a later branch. Branch targets must stay valid even after emitted instruction widths change.

I added basic-block analysis, initialization checks across every reachable predecessor, ZF liveness and fixed-width rel32 relocation. The graph permits multiple returns, backward branches and nonterminating loops. It does not attempt to prove termination. Those constraints are why validation is a first-class compilation step rather than an optional debugging pass.

Building the runtime

A low-level compiler that gives callers a function pointer is useful for experiments, but it leaves lifetime and repeated-call decisions to the embedding application. Recode's managed runtime registers a validated program, assigns it a numeric handle and owns the IR after registration. Calls begin in an interpreter. Under the fixed policy they can compile after a threshold; under the default adaptive policy they may compile when a sampled cost model estimates sufficient future reuse. Interpretation remains available if compilation or capacity allocation fails.

The executable-code cache has a page-rounded budget, 4 MiB by default, with LRU eviction. The budget covers executable mappings, not every allocation in the process. Code and runtime statistics account for separate persistent IR and metadata storage. Native executions pin their mapping so eviction cannot unmap instructions being executed by another thread. Calls release the cache mutex before running native code, then release the pin on completion. Removing an active handle is rejected, and final destruction requires the caller to stop concurrent API entry and join its threads.

The timing model is intentionally not a guarantee. Compilation has a cold cost; dispatch, lock acquisition and bookkeeping have costs on every managed call. A tiny rule may remain cheaper to interpret, or the adaptive estimate may decide not to compile it. The cache must also continue working when mapping, protection, allocation or cleanup operations fail.

Recode execution architecture Two separate paths: restricted x86-64 bytes are decoded and checked into accumulator IR; an integer-program builder produces separately validated integer IR. Each can be interpreted or compiled with a managed cache. Only the accumulator interpreter supports trace compilation. Only the integer-program path supports bounded read-only memory views. Accumulator byte path Public integer-program path Restricted x86-64 byte bufferSingle uint64_t argument, fixed encodings C integer-program builder0 to 2 arguments, virtual registers Decode, CFG and ZF validationAccumulator IR, branch relocation Builder validationInteger IR, registers, branches, loads Interpret or compile whole functionManaged call / accumulator stateTrace recording only for this path Interpret or compile whole functionInteger-program execution and resultsOptional bounded read-only byte view Managed runtime, code cache and executionExecutable mappings: RW to RX, pinned during callsPage budget, LRU eviction, fallback to interpreter Separate IRs; TRACE is not supported for integer programs.
Execution paths in the current source. The two representations have different validation and runtime capabilities; the lower cache and ownership layer serves native executions from both.

Implementing the tracing JIT

The TRACE policy is deliberately separate from adaptive whole-function compilation. It runs the accumulator interpreter, counts actually executed backward edges at eligible loop headers and records a hot path through the existing instruction step loop. The bounded recording stores each operation's source IR index, actual successor and alternate successor for conditional branches. A trace ending at the header or a return can be validated and emitted as native code with a guard for every recorded conditional.

A trace assumes the branch decisions it observed, not fixed argument values. When a guard fails, native execution snapshots the state after the conditional and before the alternate successor. The interpreter resumes at that original IR position. A completed cyclic trace also yields after a bounded native iteration quantum; it does not spin indefinitely without returning control to the runtime.

The countdown loop from the repository makes the state transfer concrete:

0 INPUT
1 SUB 1       ; trace header
2 JNZ 1       ; recorded path takes the backedge
3 RET

On the final decrement:
  RAX = 0, ZF = 1
  recorded JNZ direction fails
  side exit: argument = original input, pc = 3
  interpreter executes RET once

Internal continuation state carries RAX, ZF, the immutable argument and the next IR position (pc), plus a native-iteration quantum. A side exit resumes after the failed guard, so the decrement and branch are not replayed. A native RET has a distinct return reason. This is precise continuation for the modeled accumulator state, not general deoptimization of a full x86 register file or restoration of arbitrary CPU flags.

Tracing JIT with guarded side exits The accumulator interpreter observes executed backward edges, records a hot loop path, validates and compiles a native trace. A matching guard continues native execution. A failing guard snapshots RAX, ZF, immutable argument, next IR pc and native quantum, then resumes in the interpreter. Native return or quantum yield have separate completion paths. Accumulator TRACE policy only Interpreter steps loopExecuted backward edgesincrement header counter Record hot pathOperations, source/next pcand branch alternatives Validate and compileGuarded native tracepublished into code cache Execute native traceGuard checks recorded pathContinue on matching edge next iteration guard mismatch Snapshot continuation stateRAX, ZF, immutable argument, next IR pcplus native-iteration quantum Resume interpreterAt alternate successorNo repeated guard or SUB Native RET reports returned state; cyclic traces yield after 4,096 iterations. This models the documented accumulator state, not arbitrary x86 CPU state.
A branch mismatch returns a state snapshot to the interpreter at the non-recorded successor. If native execution reaches RET, it reports completion instead; cyclic traces periodically yield.

The trace compiler can eliminate recorded unconditional jumps and combine some arithmetic across NOPs and those jumps. It does not implement a register allocator, specialize the initial argument as a constant or trace integer-program APIs. The narrower model keeps state reconstruction explicit, but guard checks and interpreter continuation still cost time. See tracing.md and trace.c.

Making Recode embeddable

I later added an installable CMake library with static and shared builds, public headers, a namespaced Recode::recode target and external consumer examples. The integer builder is not a general x86 decoder. It builds bounded, validated programs containing virtual registers, zero to two integer arguments, arithmetic, bitwise operations, shifts, signed/unsigned comparisons, conditional branches and returns. Those programs run through an independent interpreter or whole-function native compilation under the managed runtime.

The integer-program path also supports caller-owned read-only memory views. A view supplies a pointer and length for unsigned little-endian U8, U16, U32 and U64 loads. Each load checks bounds, returns RC_BOUNDS on failure and leaves the caller's result unchanged. No stores or raw pointer operands are exposed. The runtime never retains or embeds the view's pointer; the caller remains responsible for the buffer's validity and lifetime for the duration of each call. The data-rules example exercises this path against an independently computed C result.

The original byte pipeline remains useful for studying control flow, emission and tracing; the builder is the practical entry point for applications that want to describe bounded integer rules. Keeping the two APIs separate avoids promising arbitrary native-code ingestion or integer-program tracing. Relevant interfaces are recode.h and recode_integer.h.

Performance and trade-offs

Recode's benchmarks report native kernels separately from public calls, and cold compilation separately from steady execution. That distinction matters. Removing NOP padding from integer kernels in the v0.7 work reduced both emitted bytes and kernel time in tested rules, but managed dispatch still outweighed the execution cost of many small programs. The pinned before/after runs used an Intel N150, GCC 16.2.1, CPU 0 affinity and repeated alternating measurements. CPU frequency was not controlled.

v0.6 to v0.7 workloadNative bytesKernel ns/call
Arithmetic251 to 1169.452 to 4.466
Bitwise331 to 17810.558 to 4.457
Conditional rule411 to 20910.324 to 4.778

Values above are the documented pinned median comparisons from v0.7 performance verification. They are not claims about an arbitrary program or a shipping 1.0 release.

The v1.0 preparation measurements also document regressions. In the measured arithmetic rule, public fixed-tier execution increased from 37.393 to 45.815 ns/call between the reported v0.7 and v1.0 test builds. The interpreter's expanded dispatch and per-call view/error plumbing added overhead, while the native arithmetic kernel's median increased from 4.236 to 4.841 ns. Those observations are not enough to assign every difference to one source change.

For the documented 20,000-call countdown trace workload, whole-function JIT measured 49.242 ns/call and warm TRACE 177.198 ns/call in the v1.0 preparation run. Direct C measured 1.672 ns/call, although optimized C can simplify work the IR still executes. These numbers come from specific synthetic fixtures, not representative application-wide speedups. The trade-off is clear in those tests: recording, guard checks, runtime dispatch and continuation cost more than the trace saves. See release verification for method, ranges and raw CSV references.

Testing and correctness

The repository uses an independent reference interpreter to compare results with decoded, optimized and native execution. It includes deterministic randomized byte/control-flow cases, integer-program differential tests, guard-page checks for truncated instructions and bounded loads, ABI sentinels and relocation checks. Tests inject allocator, mmap, mprotect and munmap failures; concurrency cases exercise eviction, mapping pins, call-scoped memory views and lifecycle restrictions.

Release preparation records GCC and Clang Release builds, ASan/UBSan builds, focused GCC ThreadSanitizer suites, relocated static/shared installs and independently built consumers. It also documents an optional 100,000-input Clang fuzz run and a source/security review without reportable findings. That review was not claimed as an independent human security audit or formal verification. C sanitizers instrument the implementation, not the generated machine code. Tests and review reduce the error surface but do not establish sandbox safety or a proof of race freedom.

Current state and limitations

This article describes the earlier runtime architecture and its release-preparation state at the time it was written. Since then, Recode has published v1.5.0 and v1.6.0 on 2 November 2026. v1.5 added a public integer batch API and external compiler comparisons; v1.6 improved native lowering, bounded register assignment, batch fast paths and compilation preparation. For the current measured results, use the two release-specific engineering notes linked below rather than treating this original account as the latest performance report.

The supported platform is Linux x86-64 with 64-bit pointers and executable anonymous mappings. Recode does not load ELF files or execute arbitrary native applications. Its byte decoder excludes arbitrary memory operands, external calls and syscalls; the integer-program API allows only its documented bounded loads and operations. TRACE works only on the accumulator representation. The runtime does not impose execution deadlines or total-process memory limits, and it is not a sandbox. Recode remains open source under the MIT license and is still under active development.

Source and implementation history

Back to engineering work