NURLNURL registrynurl-lang.org →

← nwasm

nwasm 2.3.0 API

rjit.nu

Copyright (c) 2026 The NURL Project Developers SPDX-License-Identifier: MIT OR Apache-2.0

rjit.nu — tier 8: a register-allocating JIT over the predecoded records.

The template tier (interp.nu, __jit_try) lowers every record through rax against a frame in memory, with the hottest few slots pinned to a handful of registers for the whole function. That is a baseline compiler: whatever does not fit the pins pays a store and a reload on every access, and a value that crosses a loop edge pays a store-forward on every iteration of its dependency chain. This tier is an optimizing one over the same records and the same calling convention:

  1. every record's slot uses and defs are decoded once;
  2. records split into basic blocks, and every branch that moves values

(a block's results, a loop's params) gets a synthetic EDGE block that holds those moves as one parallel copy;

  1. slot liveness is solved over the blocks;
  2. each slot's definitions and uses are grouped into WEBS — the

independent values that happen to share a storage slot (the operand-stack slots are reused for every expression in the body);

  1. webs get a register by linear scan over their live hulls — a web

live across a call takes a callee-saved register or memory;

  1. records are emitted with operands wherever their webs live.

A spilled web lives in its slot's frame home, [rbx + 8*slot]: two webs of one slot are never live on the same path at once, so they can share it. The frame, the argument and result windows, every call-out and every trap stub are the template tier's own, so the two tiers call each other through the same direct-entry table and fall back to the same interpreter bridges.

This file is pure: it turns records into machine code plus the patch lists that need the page address. interp.nu owns the page.

API

: Rj

: Rj {
    ( Vec u ) buf  // the emitted code
    ( Vec i ) st  // scalars — rjs_*
    ( Vec i ) code  // the records, 6 words each (copy)
    ( Vec i ) aux  // br_table rows, 4 words each (copy)
    ( Vec i ) kv  // the constant pool (copy)
    ( Vec i ) ltype  // per local slot: 0 i32, 1 i64, 2 f32, 3 f64, 4 a reference (null = -1)
    ( Vec i ) rsig  // per record: a call's callee nparams*65536 + nresults (-1 none/unknown)
    ( Vec i ) uoff  // per record (n+1): its uses are [uoff[r], uoff[r+1])
    ( Vec i ) uslot
    ( Vec i ) uweb
    ( Vec i ) doff  // per record (n+1): its defs are [doff[r], doff[r+1])
    ( Vec i ) dslot
    ( Vec i ) dweb
    ( Vec i ) bfirst  // per block: first record (real) / branch record (edge)
    ( Vec i ) blast  // per block: one past its last record (real) / -1 (edge)
    ( Vec i ) btgt  // per block: edge → its target record, real → -1
    ( Vec i ) bmd  // edge block moves: dst base
    ( Vec i ) bms  // … src base
    ( Vec i ) bmn  // … count
    ( Vec i ) soff  // successor lists: [soff[k], soff[k+1]) in ssucc
    ( Vec i ) ssucc
    ( Vec i ) rblk  // record → the real block holding it
    ( Vec i ) eblk  // per record: first edge block it owns (-1 none)
    ( Vec i ) lin  // live-in bitsets, nb x W words
    ( Vec i ) lout  // live-out bitsets
    ( Vec i ) emoff  // per block: edge-move occurrences [emoff[k], emoff[k+1])
    ( Vec i ) emu  // … source web per moved value
    ( Vec i ) emd  // … destination web per moved value
    ( Vec i ) uf  // union-find parent over web ids
    ( Vec i ) nslot  // per raw web id: its slot
    ( Vec i ) xpos  // interval extension list: (raw id, position) pairs
    ( Vec i ) entw  // entry-defined raw web ids (params / zeroed locals)
    ( Vec i ) wslot  // per web: slot
    ( Vec i ) wcls  // per web: votes (int*65536 + float); then 0 gpr / 1 xmm
    ( Vec i ) wwt  // per web: spill weight
    ( Vec i ) wst  // per web: first position
    ( Vec i ) wen  // per web: last position
    ( Vec i ) wuse  // per web: number of uses
    ( Vec i ) wloc  // per web: location (rjl_*)
    ( Vec i ) whint  // per web: a web whose register it would like
    ( Vec i ) wcc  // per web: 1 when live across a call
    ( Vec i ) wsens  // per web: 1 when some consumer reads bits 32..63 (an i32 def must sign-extend)
    ( Vec i ) wdx  // per web: 1 when live across a record that clobbers rdx (div/rem, bit counts)
    ( Vec i ) dxr  // those records, ascending
    ( Vec i ) wcx  // per web: 1 when it touches a record whose lowering writes rcx
    ( Vec i ) cxr  // those records, ascending
    ( Vec i ) cxb  // per record: 1 when a web in rcx is live across it (a borrower parks rcx)
    ( Vec i ) wzx  // per web: 1 when every value it holds has bits 32..63 clear (an address can index as it stands)
    ( Vec i ) cs_off  // direct call sites: the rel32's buffer offset …
    ( Vec i ) cs_fx  // … the callee …
    ( Vec i ) cs_ab  // … its argument base …
    ( Vec i ) cs_nr  // … its result count (the site's slow stub needs all four) …
    ( Vec i ) cs_kind  // … 1 for a register-argument site …
    ( Vec i ) cs_np  // … and its argument count
    ( Vec i ) depth  // per record: loop weight (8^depth, capped)
    ( Vec i ) callr  // records that clobber caller-saved registers, ascending
    ( Vec i ) tgt  // per record: 1 when some branch targets it
    ( Vec i ) lab  // record / stub label → code offset
    ( Vec i ) pat_at  // rel32 sites …
    ( Vec i ) pat_rec  // … and the label each one names
    ( Vec i ) pta_off  // absolute jump-table entries (code offset) …
    ( Vec i ) pta_stub  // … and the code offset they hold
    ( Vec i ) ptr_off  // absolute entries naming a record label …
    ( Vec i ) ptr_rec  // … resolved into pta_* once labels are known
    ( Vec i ) litv  // the literal pool: wide constants read RIP-relative …
    ( Vec i ) lsite  // … the disp32 sites that read them …
    ( Vec i ) lidx  // … and which literal each one names
    ( Vec i ) uover  // per use: the register a cached read of a spilled web takes (-1 none — rj_cache)
    ( Vec i ) wflag  // per web: 1 when only fused flags read it (it gets no place)
    ( Vec i ) clhead  // per record: its first cache load (-1 none) …
    ( Vec i ) clnext  // … the next one at the same record …
    ( Vec i ) clreg  // … the register it fills …
    ( Vec i ) clslot  // … from this slot's frame home
    ( Vec i ) wlcar  // per web: 1 when a record inside a loop writes it
    ( Vec i ) rpi32  // per record: a call's callee parameters that are i32, bit k for parameter k (rj_set_pi32)
}

── the per-function compile context ──────────────────────────── Every field is a growable vector, so helpers mutate through the handle; the few scalars live in st under the rjs_* indices below.

@ rjs_n → i

scalar state indices

@ rjs_nl → i

@ rjs_ns → i

@ rjs_np → i

@ rjs_nr → i

@ rjs_sb → i

@ rjs_nb → i

@ rjs_nreal → i

@ rjs_w → i

@ rjs_nw → i

@ rjs_nimp → i

@ rjs_spcell → i

@ rjs_trapfn → i

@ rjs_cofn → i

@ rjs_coenv → i

@ rjs_fidx → i

@ rjs_used → i

@ rjs_fweb → i

@ rjs_fcc → i

@ rjs_fail → i

@ rjs_nraw → i

@ rjs_fsel → i

@ rjs_forbid → i

@ rjs_sigoff → i

@ rjs_tbloff → i

@ rjs_fl → i

@ rjs_fast → i

@ rjs_fastoff → i

@ rjs_glob → i

@ rjs_pcc → i

@ rjs_aidx → i

@ rjs_nst → i

@ rj_get Rj c i k → i

@ rj_set Rj c i k i v → v

@ rj_fail Rj c i why → v

@ rj_failed Rj c → b

@ rj_rw Rj c i r i k → i

a record word: 0 op, 1 A, 2 B, 3 C, 4 D, 5 W5

@ rjl_mem → i

── locations ─────────────────────────────────────────────────── 0..15 a GPR (rax=0 … r15=15), 16..31 an XMM register (16 + n), rjl_mem the slot's frame home, rjl_imm a constant-pool value.

@ rjl_imm → i

@ rj_isg i l → b

@ rj_isx i l → b

@ rj_b Rj c i x → v

── the byte stream ─────────────────────────────────────────────

@ rj_d Rj c i x → v

@ rj_q Rj c i x → v

@ rj_here Rj c → i

@ rj_patch32 Rj c i at i dst → v

patch a rel32 at at to land on code offset dst

@ rj_fits8 i v → b

@ rj_fits32 i v → b

@ rj_rex Rj c i w i r i x i bb i force → v

── x86-64 encoding ───────────────────────────────────────────── REX = 0100WRXB; force emits it even when every bit is clear (the spl/bpl/sil/dil byte registers exist only under a REX prefix).

@ rj_modrr Rj c i reg i rm → v

@ rj_modmem Rj c i reg i base i idx i sc i disp → v

[base + idx<<sc + disp]; idx < 0 = no index

@ rj_rr Rj c i pfx i w i esc i opc i reg i rm i b8 → v

[pfx] [REX] [0F] opc ModRM(reg, rm) — register form. b8 forces REX for the 8-bit view of registers 4..7 in whichever operand is a byte register (1 = rm, 2 = reg, 3 = both).

@ rj_rm Rj c i pfx i w i esc i opc i reg i base i idx i sc i disp i b8 → v

the same with a memory operand

@ rj_home i s → i

frame home of slot s

@ rj_mov_rr Rj c i w i dst i src → v

@ rj_ldf Rj c i w i dst i s → v

mov dst, [rbx + 8*s]

@ rj_stf Rj c i w i s i src → v

mov [rbx + 8*s], src

@ rj_mov_ri Rj c i dst i imm i keep → v

dst ← imm, shortest form. keep = 1 when the flags must survive (no xor).

@ rj_alu_rr Rj c i w i ext i dst i src → v

ALU groups: add 0, or 1, and 4, sub 5, xor 6, cmp 7

@ rj_alu_rf Rj c i w i ext i dst i s → v

@ rj_alu_ri Rj c i w i ext i dst i imm → v

imm must fit 32 bits (sign-extended for w=1)

@ rj_alu_fi Rj c i w i ext i s i imm → v

op qword/dword [rbx+8s], imm32

@ rj_test_rr Rj c i w i a i bb → v

@ rj_imul_rr Rj c i w i dst i src → v

@ rj_imul_rf Rj c i w i dst i s → v

@ rj_imul_rri Rj c i w i dst i src i imm → v

@ rj_shift_ri Rj c i w i ext i dst i imm → v

shifts: rol 0, ror 1, shl 4, shr 5, sar 7

@ rj_shift_cl Rj c i w i ext i dst → v

: ~ i g_rjbmi -1

x86-64-v3 (BMI1/BMI2, LZCNT) in the emitted code: -1 = ask the CPU on first use, 0 = baseline x86-64 only, 1 = use it

@ rj_set_bmi i v → v

@ rj_bmi → b

@ rj_shx Rj c i w i pp i dst i src i cnt → v

BMI2 shlx (pp 1) / sarx (pp 2) / shrx (pp 3): dst = src shifted by cnt, any three registers, the count masked like the legacy forms; flags untouched. VEX.LZ.0F38.W F7 /r, the count in vvvv.

@ rj_tmp_on Rj c i reg → i

A second scratch GPR for an instruction whose own register is reg: rax when that is not rax, else rcx — parked in xmm1 until rj_tmp_off, since rcx may hold a web (movq leaves the flags alone)

@ rj_tmp_off Rj c i t → v

@ rj_movsxd Rj c i dst i src → v

movsxd dst, src32

@ rj_mov32 Rj c i dst i src → v

mov dst32, src32 (zero-extends)

@ rj_setcc Rj c i cc i rm → v

@ rj_movzx8 Rj c i dst i src → v

@ rj_cmov_rr Rj c i w i cc i dst i src → v

@ rj_cmov_rf Rj c i w i cc i dst i s → v

@ rj_push Rj c i r → v

@ rj_pop Rj c i r → v

@ rj_lea Rj c i w i dst i base i idx i sc i disp → v

lea dst, [base + idx<<sc + disp] (w=0: the 32-bit form, which wraps) An rbp / r13 base takes a displacement byte even for 0, and base + index

scaled, dst takes the base first (a mov — no flags touched)

@ rj_ripop Rj c i pfx i w i esc i opc i reg i v → v

[pfx] [REX] [0F] opc ModRM(reg, [rip + literal v]) — a wide constant as a memory operand. The disp32 is the instruction's last field (no immediate may follow), patched once the pool's place is known.

@ rj_jcc Rj c i cc i label → v

── jumps and labels ──────────────────────────────────────────── Labels 0..n-1 are records; n.. are the trap stubs (see rj_stub_*).

@ rj_jmp Rj c i label → v

@ rj_jcc_fwd Rj c i cc → i

a forward jump to a not-yet-known local offset: returns the rel32 site

@ rj_jmp_fwd Rj c → i

@ rj_land Rj c i site → v

@ rj_stub_oob Rj c → i

stub labels, after the n record labels

@ rj_stub_div0 Rj c → i

@ rj_stub_gate Rj c → i

@ rj_stub_ovf Rj c → i

@ rj_stub_iovf Rj c → i

@ rj_stub_inval Rj c → i

@ rj_lab_entry Rj c → i

@ rj_lab_fast Rj c → i

@ rj_cmpcc i op → i

condition nibbles: o 0 no 1 b 2 ae 3 e 4 ne 5 be 6 a 7 s 8 ns 9 p 10 np 11 l 12 ge 13 le 14 g 15 compare micro-op (56..75) → the nibble that means "true"

@ rj_ccswap i cc → i

the nibble for the same test with the operands exchanged

@ rj_memkind i op → i

── what a record reads and writes ────────────────────────────── The template tier's memory-op table, verbatim: width4 + signed2 + store.

@ rj_isalu i op → b

two-operand integer ALU: dst = B op C

@ rj_isrot i op → b

@ rj_isfused i op → b

fused i64 pairs: dst = (B op1 C) op2 D

@ rj_hastarget i op → b

a record whose A field is a branch target (a word offset)

@ rj_isbranch i op → b

a record that leaves the block: anything with a target, or no fall-through

@ rj_iscall i op → b

a record whose emitted code calls out of the function (clobbers every caller-saved register)

@ rj_op_ok i op → b

The records this tier lowers; a function holding anything else stays on the template tier (or the interpreter).

@ rj_isfbin i op → b

the float records this tier lowers

@ rj_isfcmp i op → b

@ rj_isfconv i op → b

@ rj_isfminmax i op → b

@ rj_isfloat i op → b

@ rj_use Rj c i s → v

@ rj_def Rj c i s → v

@ rj_sig_np i sig → i

a call record's signature word: canon<<32 | nparams<<16 | nresults (canon: call_indirect's expected type, canonicalised; -1 = unknown)

@ rj_sig_nr i sig → i

@ rj_sig_canon i sig → i

@ rj_set_pi32 Rj c ( Vec i ) v → v

per call record: which of the callee's parameters are i32 (bit k), so an i32 argument is sign-extended where it is passed rather than where it is made; without it every argument counts as read in full

@ rj_pi32 Rj c i r i k → b

@ rj_argext Rj c i r i k i o → b

argument k of call record r (use o) is an i32 whose web skips the sign extension — the call must do it

@ rj_decode Rj c i r → v

Decode record r's uses and defs, in the fixed per-op order the emitter reads them back in.

@ rj_tgtrec i w → i

── blocks ──────────────────────────────────────────────────────

@ rj_target Rj c i r → i

branch target of a record with one (A field), else -1

@ rj_mark Rj c i t i r → v

t is a branch target of record r: 2 marks a loop head (a backward target)

@ rj_edge Rj c i r i t i dst i src i n → i

new edge block for branch record r → target record t moving n values

@ rj_blocks Rj c → v

@ rj_isconst Rj c i s → b

── liveness ────────────────────────────────────────────────────

@ rj_shr i a i n → i

logical shift right (>> is arithmetic on i)

@ rj_bit ( Vec i ) v i base i s → b

@ rj_bset ( Vec i ) v i base i s → v

@ rj_gen1 Rj c ( Vec i ) gen ( Vec i ) kill i base i s → v

@ rj_live Rj c → v

@ rj_newraw Rj c i s → i

── webs ──────────────────────────────────────────────────────── A raw web id names one definition (or one block's live-in value of a slot); union-find merges the ids that reach a common use. Every id of a set belongs to the same slot — merges only ever join a block's live-out value of slot s with a successor's live-in value of s.

@ rj_find Rj c i x0 → i

@ rj_union Rj c i a i bb → v

@ rj_bpos0 Rj c i k → i

a block's first and last position: 2r = record r's reads, 2r+1 its writes

@ rj_bpos1 Rj c i k → i

@ rj_bits ( Vec i ) v i base i w ( Vec i ) out → v

the set bits of words [base, base+w) of v, ascending

@ rj_xpos Rj c i id i pos → v

@ rj_webs Rj c → v

@ rj_ucls i op i k → i

── per-web facts ─────────────────────────────────────────────── Use k / the def of op: 1 when it is an integer value, 2 a float, 0 no opinion (moves, selects and calls carry whatever they are handed).

@ rj_dcls i op → i

@ rj_vote Rj c i wv i cls i wt → v

@ rj_ext Rj c i wv i pos → v

@ rj_addwt Rj c i wv i wt → v

@ rj_hint Rj c i dw i sw → v

@ rj_canon Rj c → v

@ rj_depths Rj c → v

loop weights: every backward branch multiplies the span it closes by 8 A loop counts once, from its head to its furthest back edge — each continue is another branch to the same head, not another level.

@ rj_usens i op i k → b

Does use k of op read bits 32..63 of its operand? Every i32 a slot holds is sign-extended (the predecoder drops i64.extend_i32_s on the strength of it), but only these consumers can tell: an i32 def whose web feeds none of them skips the movsxd. Copies (MOV, SEL's values, edge moves) pass the question on to their destination instead of answering it.

@ rj_kin32 Rj c i r i k → b

is use k of record r a constant in [0, 2^32)?

@ rj_lowk Rj c i r i op i k → i

The i64 ops whose result's low half needs only their operands' low halves (+ - * & | ^, shl, and the fused pairs built from them) answer for use k themselves: 1 the operand's high half matters exactly when the result's does (the def decides, as for a copy — rj_narrow then computes it with a 32-bit instruction), 0 never (a shift count is read mod 64; the other side of an and with a constant below 2^32 lands in bits the mask clears), 2 always; -1 for any other op (rj_usens decides).

@ rj_narrow Rj c i r i op → b

Does record r compute its i64 result with a 32-bit instruction? Only when no consumer reads the high half, and only where the low half comes out the same: + - * & | ^, shl by a constant below 32, the fused pairs of those. The 32-bit instruction leaves the high half clear (rj_defzx).

@ rj_sens Rj c → v

the sensitivity facts: direct consumers, then back through every copy

@ rj_dcanon Rj c i od → b

must record r's i32 def number k come out sign-extended?

@ rj_facts Rj c → v

@ rj_gpool i k → i

── linear scan ───────────────────────────────────────────────── GPR order: caller-saved first (no prologue cost), callee-saved after; a web live across a call may only take a callee-saved one. Ordered by what a register costs the function that takes it: rsi and rdx cost nothing; r9/r10/r8 one instruction at every call and return; the callee-saved ones a push and a pop per invocation; rdi (the context) a push, a pop and a reload before every call.

@ rj_calleesaved i l → b

@ rj_allowed i l i cls i cross → b

@ rj_clobdx i op → b

records whose lowering uses rdx as scratch (its webs may not be live across them): div/rem, and the bit scans on a CPU without lzcnt/tzcnt

@ rj_ukonst Rj c i r i k → b

is use k of record r a constant-pool slot?

@ rj_clobcx Rj c i r → b

Records whose lowering may leave something of its own in rcx — no web that touches one may live there. Every other record's lowering was checked to keep rcx intact: rax is its scratch, and where it needs a second one it parks rcx in xmm1 (rj_tmp_on, the stores' cxb test).

@ rj_crosses ( Vec i ) rs i ws i we → b

is some record c of the ascending list rs inside (ws, we) — 2c ≥ ws, 2c+2 ≤ we?

@ rj_touches ( Vec i ) rs i ws i we → b

is some record c of the ascending list rs touched by [ws, we] — its reads (2c) or writes (2c+1) inside it?

@ rj_dens Rj c i wv → i

spill priority: weight per unit of live length — a short temporary on a dependency chain keeps its register, a sparse long-lived value gives it up. A value a loop writes counts eight times over: spilled, every write is a store its next read waits on (a loop-carried chain through memory), where a loop-invariant one only reloads, and the load issues early.

@ rj_alloc Rj c → v

: ~ i g_rjtrace -1

NURL_NWASM_RJIT_TRACE=<fidx>: that function's webs and where they went

@ rj_set_trace i f → v

@ rj_trace Rj c → v

@ rj_defzx Rj c i r i op i o → i

What a def leaves in bits 32..63 of its web: 1 clear (a 32-bit x86 instruction wrote it, or a small non-negative value), 0 unknown, 2 a copy of use 0, 3 a copy of uses 0 and 1 (SEL's two values).

@ rj_kzx Rj c i s → b

is the value a constant-pool slot holds zero-extended as mov_ri materialises it?

@ rj_zx Rj c → v

wzx: every def of the web leaves bits 32..63 clear. Parameters arrive canonical (sign-extended), a reference local starts at -1, anything not listed in rj_defzx is unknown; copies (MOV, SEL, edge moves) are clear exactly when their sources are, settled by a fixpoint.

@ rj_bfree ( Vec i ) bm i words i l i p i q → b

bits [p, q] of register l's row in the position bitmap: all clear?

@ rj_bmark ( Vec i ) bm i words i l i p i q → v

@ rj_cgpr i k → i

the GPRs a cached read may take: rdx rsi r8 r9 r10 rbp r12..r15

@ rj_cache Rj c → v

@ rj_cxbusy Rj c → v

cxb: per record, 1 when a web in rcx is live across it — read before, still needed after — so a record that borrows rcx parks it first. The webs in rcx never overlap, so this is linear in the function.

@ rj_uloc Rj c i o → i

── operands ────────────────────────────────────────────────────

@ rj_us Rj c i o → i

@ rj_dloc Rj c i o → i

@ rj_ds Rj c i o → i

@ rj_dlive Rj c i o → b

@ rj_kval Rj c i s → i

@ rj_u Rj c i r i k → i

use #k / def #k of record r

@ rj_dd Rj c i r i k → i

@ rj_movq_xg Rj c i x i g → v

movq xmm, r64 / movq r64, xmm

@ rj_movq_gx Rj c i g i x → v

@ rj_ldfx Rj c i x i s → v

movsd xmm, [rbx+8s] / movsd [rbx+8s], xmm

@ rj_stfx Rj c i s i x → v

@ rj_movx Rj c i dst i src → v

@ rj_ldg Rj c i reg i loc i s i keep → v

GPR reg ← operand (64-bit). keep = 1: the flags must survive.

@ rj_ldx Rj c i x i loc i s → v

XMM x ← operand

@ rj_stg Rj c i loc i s i reg → v

def location ← GPR reg

@ rj_stx Rj c i loc i s i x → v

def location ← XMM x

@ rj_move Rj c i dl i ds i sl i ss → v

a whole-value copy between two operand places (dl/ds ← sl/ss)

@ rj_greg i loc → i

A register operand's GPR, or -1 when the operand lives elsewhere.

@ rj_opsrc Rj c i w i ext i reg i loc i s → v

op R, operand for the ALU group ext, width w. An operand in an xmm register goes through a scratch GPR (rj_tmp_on).

@ rj_sx32 i v → i

── lowering: integer arithmetic ────────────────────────────────

@ rj_e_bin Rj c i r i ext i w → v

dst = a OP b, OP one of the ALU groups (add 0, or 1, and 4, sub 5, xor 6); w = 1 for i64, 0 for i32 (the result is then sign-extended — every i32 a slot holds is canonical, and the predecoder relies on it).

@ rj_e_shift Rj c i r i ext i w0 → v

shifts and rotates: ext shl 4, shr 5, sar 7, rol 0, ror 1

@ rj_imulsrc Rj c i w i reg i loc i s → v

imul R, operand

@ rj_e_mul Rj c i r i w0 → v

@ rj_fop1 Rj c i op i w i tr i l2 i s2 → v

the fused i64 pairs: dst = (s1 op1 s2) op2 x

@ rj_shrx_by Rj c i tr i l i s → v

tr >>u= operand with shrx: the count from its register, else a scratch (tr is never the count's register — the callers keep them apart)

@ rj_fop1_from Rj c i op i w i tr i l1 i s1 i l2 i s2 → v

tr = s1 op1 s2 for the fused pairs, straight from s1's register when it has one: imul tr, s1, imm / lea tr, [s1 + s2 or imm] — no copy of s1 first

@ rj_e_fused Rj c i r → v

@ rj_cmp1 Rj c i w i al i as i bl i bs → v

── compares and selects ──────────────────────────────────────── cmp a, b (width w), a not an immediate

@ rj_cmp Rj c i w i al i as i bl i bs i cc → i

cmp for the condition nibble cc; returns the nibble that holds after any operand exchange

@ rj_cfuse Rj c i r → i

Does record r's value feed only the IFZ / BRIF right behind it, so the pair can become one flag-setting instruction and a jcc? 1: i32/i64.and with an imm32 → test; 2: SHRUAND by anything, mask 1 → bt. 0: neither.

@ rj_selrun Rj c i r i wd → i

the fused-SEL run right behind a compare whose 0/1 is web wd

@ rj_setbool Rj c i cc i dl i ds i pre → v

materialise a compare's 0/1 into dst after the flags are set; pre = 1 when dst was zeroed before the compare (then setcc alone finishes it)

@ rj_e_cmp Rj c i r → v

@ rj_testop Rj c i w i loc i s → v

test operand (w) — sets ZF from it; an immediate goes through rax

@ rj_e_cfuse Rj c i r i kind → v

test a, imm / bt x, s for the IFZ / BRIF behind (rj_cfuse): only the flags; rjs_pcc tells the branch which jcc means "the value is zero"

@ rj_andz Rj c i q → i

Is record q an and with a constant whose value only the eqz right behind it reads (same width, no branch lands between)? 1: test x, imm32; 2: bt x, b for a single-bit 64-bit mask; 0: neither. The and itself emits nothing.

@ rj_e_andtest Rj c i q i ak → i

the flags for eqz(and(x, k)) from x itself; returns the jcc/cmov nibble that means "the and is zero"

@ rj_e_eqz Rj c i r i w → v

@ rj_cmovsrc Rj c i cc i reg i loc i s → v

cmov<cc> R, operand (64-bit); immediates and xmm through a scratch GPR (the flags survive: the loads are movs, the park is a movq)

@ rj_e_sel Rj c i r → v

@ rj_e_ext Rj c i r → v

i32.wrap_i64 (dst = sign-extended low half), i64.extend_i32_u (zero-extended low half), the extendN_s family

@ rj_e_clz Rj c i r → v

i32.clz: bsr + cmov (baseline x86-64, no lzcnt)

@ rj_addr Rj c i bl i bs i xl i xs i hasx i bz → v

── lowering: linear memory (guard-page mode: no bounds checks) ── eax ← the wrapped 32-bit address (base + idx), idx an operand or none — or, when the base alone is the address and its web is zero-extended (bz), nothing at all: its own register becomes the index (rjs_aidx)

@ rj_rj32 Rj c i s → i

a constant's low 32 bits, sign-extended (a displacement / imm32)

@ rj_mrm Rj c i pfx i w i esc i opc i reg i off i b8 → v

[r11 + idx + off], idx the register rj_addr left the address in (rax unless a zero-extended base)

@ rj_uzx Rj c i o → i

use o's web holds only zero-extended values (a constant: no — rj_addr folds those)

@ rj_ldmem Rj c i tr i wid i sg i off → v

load wid bytes (sg: sign-extend) from [r11+rax+off] into GPR tr

@ rj_ldto Rj c i od i wid i sg i off → v

the load into its def's place: xmm defs take an 8- or 4-byte float load directly

@ rj_e_load Rj c i r → v

@ rj_e_store Rj c i r → v

@ rj_e_loadshl Rj c i r → v

LOADSHL: dst = mem[(x << k) w32 + off]; LOADSHLADD adds a base first

@ rj_e_loadop Rj c i r → v

LOADMULI64 / LOADADDI64: dst = mem64[base w32 + off] * / + x

@ rj_goto Rj c i r i t → v

── lowering: control ───────────────────────────────────────────

@ rj_place i loc i s → i

the destination of an edge move, as a place no other location aliases

@ rj_pmove Rj c ( Vec i ) dl ( Vec i ) ds ( Vec i ) sl ( Vec i ) ss → v

One parallel copy: dl[k]/ds[k] ← sl[k]/ss[k] for every k at once. A move goes once nothing still pending reads its destination; a cycle is broken through xmm1 (a scratch no web lives in — any GPR may hold one).

@ rj_e_moves Rj c i e → v

An edge block's parallel copy.

@ rj_e_brm Rj c i r → v

the edge block a branch record owns (BRM / BRIFM)

@ rj_e_brifm Rj c i r → v

@ rj_e_brtbl Rj c i r → v

@ rj_e_brifc Rj c i r → v

@ rj_alu3 Rj c i ext i w i al i as i bl i bs i dl i ds b cn b nolea → i

dst = a OP b into dst's place; returns the GPR holding the result nolea: the flags must come from the op itself (an lea sets none)

@ rj_e_addbr Rj c i r → v

ADDBRIFC: dst = s1 + s2; branch when (dst cmp rhs) holds

@ rj_areg i k → i

── the register calling convention (tier 8 → tier 8) ────────── A callee with at most five parameters and at most one result gets a second, FAST entry: argument k arrives in rj_areg(k), the result leaves in rax, and nothing is stored to or reloaded from the caller's window. rcx is still the callee's frame base and rsi the window (the slab-full path spills the arguments there), rdi the context. Neither side keeps r8/r9/r10 as invariants across a fast call: tier 8 rematerialises the anchor wherever it reads it, reserves r9 for the globals base only in a function that touches globals (and reloads it after every call), and reads the memory size from the context. The memory entry (+28) — the driver's, the template tier's and the bridges' way in — is a wrapper: it loads the window into the argument registers, calls the fast entry, stores the result back and re-establishes the invariants its callers rely on.

@ rj_fastsig i sig → b

@ rj_restore_ctx Rj c → v

rdi back to the context (a callee and every call-out need it there)

@ rj_reglob Rj c → v

r9 back to the globals base after something may have clobbered it

@ rj_callout Rj c i kind → v

── lowering: calls ───────────────────────────────────────────── the inline call-out into the runtime (the template tier's own sequence)

@ rj_uses Rj c i l → b

r9/r10 are cross-call invariants (globals base, memory bytes); a function that allocated them puts them back before control leaves it

@ rj_restore_inv Rj c → v

The invariants a template-tier function, a bridge or a memory-entry caller expects: rdi the context, r8 the anchor, r9 the globals base, r10 the memory size. Re-established before every memory-ABI call, call-out and memory-entry return — unconditionally, since a fast callee may have left anything in r8..r10.

@ rj_argsp Rj c → i

the args pointer's stack offset: above the saved context when rdi was handed out

@ rj_frameless Rj c → b

@ rj_frame_end Rj c → v

lea rcx,[rbx + nslots*8] — the callee's frame base (the end of ours)

@ rj_args_home Rj c i r i np i ab → v

a memory-ABI call's arguments into the window [rbx + 8*(ab + k)], each i32 one the web left zero-extended sign-extended there

@ rj_e_call Rj c i r → v

@ rj_e_ret Rj c i r → v

@ rj_magicu i dd i w → ( Vec i )

── division by a constant ────────────────────────────────────── Hacker's Delight magicu2, W = 32 (w 0) or 64 (w 1): unsigned division by d (3 ≤ d < 2^W, not a power of two) is q = mulhu(x, M) >> s, or — the add indicator a = 1 — the same with the W+1-bit magic M + 2^W. [M, a, s]

@ rj_magics i dd i w → ( Vec i )

Hacker's Delight magic (signed), W = 32 / 64, 3 ≤ |d| < 2^(W-1): q = mulhs(x, M) (+ x when d > 0 and M < 0, − x when d < 0 and M > 0), arithmetic-shifted by s, plus its own sign bit. M comes back as the signed W-bit value, sign-extended. [M, s]

@ rj_ldw Rj c i reg i loc i s i w i sgn → v

reg ← operand; an i32 (w 0) widened from its low half — sign-extended when sgn, zero-extended otherwise (a div/rem operand need not be canonical)

@ rj_grp3 Rj c i ext i rm → v

one-operand group 3 on rcx: mul 4, imul 5, neg 3 (on rm)

@ rj_imul_k Rj c i k → v

rax ← rax * k (64-bit)

@ rj_rem_tail Rj c → v

rax ← rcx − rax: the remainder from x (rcx) and q·d (rax)

@ rj_e_divk Rj c i r → b

Division by a constant divisor: shifts for a power of two, otherwise a multiply by the reciprocal (Hacker's Delight ch. 10) — no div, no zero test. Leaves the record to rj_e_div (F) when the divisor is not a constant, is 0, is −1 under a signed quotient (MIN / −1 traps), is the signed minimum, or is ≥ 2^63 unsigned.

@ rj_e_div Rj c i r → v

── lowering: the rest ──────────────────────────────────────────

@ rj_globmode Rj c → v

Globals touched only outside loops leave r9 to the allocator: each access then loads their base from the context (rj_globase)

@ rj_globase Rj c i s → i

the register holding the globals base: r9 when reserved, else s loaded from the context (in rdi, or saved at [rsp] when rdi was handed out)

@ rj_e_gget Rj c i r → v

@ rj_e_gset Rj c i r → v

@ rj_e_const Rj c i r → v

@ rj_e_mov Rj c i r → v

@ rj_saved i k → i

── prologue, epilogue, stubs ───────────────────────────────────

@ rj_epilogue Rj c → v

@ rj_entry1 Rj c i wv → v

one entry web's initial value: a parameter from the caller's window (arg0 in rdx when there is exactly one), anything else zero

@ rj_prologue Rj c → v

@ rj_stubs Rj c → v

@ rj_align16 Rj c → v

multi-byte NOPs up to the next 16-byte boundary

@ rj_e_rec Rj c i r → v

── the record walk ─────────────────────────────────────────────

@ rj_emit Rj c → v

@ rj_new ( Vec i ) code ( Vec i ) aux ( Vec i ) kv ( Vec i ) ltype i n i nl i ns i np i nr i sb → Rj

── entry ───────────────────────────────────────────────────────

@ rj_compile Rj c ( Vec i ) rsig i nimp i spcell i trapfn i cofn i coenv i fidx i sigoff i tbloff → b

Compile the context's records. rsig: per record, a call's callee nparams*65536 + nresults (-1 for every other record). True when buf holds the function — record and stub labels in lab, absolute jump-table entries in pta_off/pta_stub (page-relative) — false to leave it to the template tier (rjs_fail says why).

@ rj_fsrc Rj c i pf i opc i x i loc i s → v

x op= operand (opc: 88 add, 89 mul, 92 sub, 94 div, 81 sqrt)

@ rj_fopc i op → i

@ rj_vex Rj c i pp i dst i v1 i bb i xx → v

── AVX (VEX) scalar forms, on an x86-64-v3 CPU ───────────────── vop x, v1, src: three operands, so a result in a register of its own needs no movaps copy first, and none carries a dependency on the destination's old value. pp: 2 = the ss forms (F3), 3 = sd (F2). Only the 128-bit forms run — they clear bits 128..255, so mixing them with the SSE forms costs no transition.

@ rj_vfsrc Rj c i pp i opc i x i v1 i loc i s → v

vop x, v1, operand (the float operand of rj_fsrc's kinds)

@ rj_fbin3 Rj c i pf i opc i al i as i bl i bs i dl i ds → v

dst = a op b into dst's place

@ rj_e_fbin Rj c i r → v

@ rj_fun_dst Rj c i od → i

a one-operand float result computed in place: x ← a (a full-register write, which also breaks the false dependency the scalar forms carry on their destination's upper bits), then body runs on x

@ rj_fun_fin Rj c i od i x → v

@ rj_e_fsqrt Rj c i r → v

@ rj_e_fsign Rj c i r → v

f64.abs / f64.neg: clear / flip the sign bit, on whichever side the web lives

@ rj_e_fround Rj c i r → v

f64 ceil / floor / trunc / nearest: roundsd (SSE4.1)

@ rj_fcmp1 Rj c i pf i al i as i bl i bs → v

ucomisd/ucomiss a, b with a in an xmm register

@ rj_e_fcmp Rj c i r → v

@ rj_e_fconv Rj c i r → v

int → float and float ↔ float conversions

@ rj_e_ffused Rj c i r → v

the fused f64 records

@ rj_e_bits Rj c i r → v

── lowering: the remaining integer unaries ───────────────────── ctz / popcnt / clz on 32 or 64 bits; bsf/bsr + cmov, popcnt (POPCNT)

@ rj_e_isnull Rj c i r → v

@ rj_e_f32sign Rj c i r → v

── lowering: f32 sign / rounding, min / max / copysign ─────────

@ rj_e_f32round Rj c i r → v

@ rj_e_fminmax Rj c i r → v

min / max with wasm semantics (any NaN → the canonical NaN; equal operands combine bitwise so min(±0,∓0) = -0 and max(±0,∓0) = +0); copysign on the bits

@ rj_e_trunc Rj c i r → v

── lowering: truncations and the unsigned 64-bit conversions ─── float → int, trapping: NaN → "invalid conversion to integer", a value whose truncation leaves the target range → "integer overflow". An f32 source is widened to f64 first (exact), so one set of bounds serves.

@ rj_e_cvtu64 Rj c i r → v

f32/f64.convert_i64_u: halve with a sticky bit, convert, double

@ rj_reload_defs Rj c i r → v

── lowering: call-outs (memory.grow, call_indirect, the 0xfc bridge) ── Every call-out reads its operands from frame homes and leaves its results there.

@ rj_e_memsize Rj c i r → v

@ rj_e_memgrow Rj c i r → v

@ rj_e_callind Rj c i r → v

call_indirect: resolve the table entry inline and call the callee's direct entry; an index out of range, a null entry, a signature mismatch, an import or a callee not compiled yet all take the bridge, which traps with the interpreter's own message or runs the call there.

@ rj_fcb_fast Rj c i r → ( Vec i )

memory.copy (fc 10) and memory.fill (fc 11) without the bridge: the operands in rax/rdx/rcx, both bounds checked up front against the context's memory size (no partial writes), then rep movsb / rep stosb. An out-of-bounds operand — and a copy whose destination starts inside its own source, which a forward copy would smear — takes the bridge, which traps with the interpreter's message or does the overlap right. Returns the bridge-path sites (empty: no fast path for this op).

@ rj_e_fcb Rj c i r → v


interp.nu

packages/nwasm/src/interp.nu — a small WebAssembly interpreter (pure NURL).

Executes wasm: i32/i64/f32/f64 const, locals, globals, the integer and float arithmetic/comparison/bitwise ops, all int↔float conversions, structured control flow (block / loop / if / else / br / br_if / return), drop / select, direct and indirect calls, and linear memory load/store. Each value occupies one 64-bit cell; i32 wraps + sign-extends to 32 bits, and floats are held as their IEEE-754 bit pattern (reinterpreted via std/floatbits for arithmetic). Imports (the WASI surface) are the remaining milestone — so this runs self-contained compute modules today.

Control flow uses an explicit control stack. Entering a block/if computes its matching end by a one-pass immediate-skipping scan; br to a loop re-enters at the loop start, to a block jumps past its end.

API

& m @ rint f x → f

round-to-nearest-even (wasm f*.nearest) — libm rint honours the default mode.

& c @ nurl_rand_fill *u buf i n → i

OS entropy (runtime helper; getrandom/urandom under the hood).

& c @ nurl_code_alloc i n → *u

The JIT substrate: an executable page and a call through a raw address. On wasm32 alloc returns 0 (no executable memory) and the runtime stays on the interpreter — a capability probe, not an error.

& c @ nurl_code_seal *u p i n → i

& c @ nurl_call_code *u fn *u a → i

& c @ nurl_call_code_at *u fn *u a i off → i

& c @ nurl_call_code2 *u fn *u a *u b → i

& c @ nurl_call_code2_sj *u fn *u a *u b → i

& c @ nurl_code_trap_addr → i

& c @ nurl_call_code_at2 *u fn i off *u a *u b → i

& c @ nurl_code_free *u p i n → v

& c @ mprotect *u addr i len i32 prot → i32

Page permissions for the JIT code arena (PROT_READ|PROT_WRITE = 3, PROT_READ|PROT_EXEC = 5): functions are packed into shared pages, so a page holding sealed code turns writable only while a neighbour is copied in, then executable again.

& c @ getpid → i32

NURL_NWASM_PERFMAP=1: a perf JIT map (/tmp/perf-<pid>.map, "start size name" per line), which perf report reads to name generated code

& c @ creat s path i32 mode → i32

& c @ nurl_vmem_reserve i span → *u

Guard-page linear memory: an 8 GiB PROT_NONE reservation swallows every address a 32-bit index + 32-bit offset can form, so JIT code needs no bounds checks — an out-of-bounds access faults and the runtime's SIGSEGV handler steers the faulting frame to its function's trap stub. Reserve returning 0 means the platform has no fault-to-trap plumbing and the bounds-checked path stays — a capability probe, like nurl_code_alloc.

& c @ nurl_vmem_commit *u base i old i new → i

& c @ nurl_vmem_release *u base i span → v

& c @ nurl_guard_mem_add *u base i span → i

& c @ nurl_guard_mem_del *u base → v

& c @ nurl_guard_code_room → i

& c @ nurl_guard_code_add *u code i len *u stub → i

& c @ nurl_guard_code_del *u code → v

& c @ pthread_mutex_init *u m *u attr → i32

The one lock behind the 0xfe atomics (see __atom_init). Declared here rather than taken from std/thread.nu because the handles live in file globals, and a NURL global holds a pointer as an integer.

& c @ pthread_mutex_lock *u m → i32

& c @ pthread_mutex_unlock *u m → i32

& c @ pthread_cond_init *u c *u attr → i32

& c @ pthread_cond_wait *u c *u m → i32

& c @ pthread_cond_broadcast *u c → i32

& c @ unlink s path → i32

unlink(2): path_unlink_file must NOT remove directories (remove(3) would).

& c @ nurl_tcp_connect s host i port → i

── raw sockets, for the "nurl_net" host-import bridge ──────────── The listen/accept/read/write/close/err_kind/peer_addr/set_timeout half of this ABI is preamble-declared by the compiler; the rest lives in stdlib FFI declares, so name it here the same way std/dns.nu and ext/http_pure.nu do.

& c @ nurl_tcp_timeout_ms i handle → i

& c @ nurl_tcp_set_send_buffer i handle i bytes → i

& c @ nurl_tcp_set_write_deadline i handle i ns → v

& c @ nurl_tcp_write_deadline i handle → i

& c @ nurl_tcp_write_wait_ms i handle → i

& c @ nurl_tcp_write_nowait i handle s bytes i length → i

& c @ nurl_tcp_read_nowait i handle s bytes i length → i

& c @ nurl_tcp_wait_io i handle i events i timeout_ms → i

& c @ nurl_tcp_get_fd i handle → i

& c @ nurl_tcp_set_nonblock i handle i on → v

& c @ nurl_tcp_ref i handle → v

& c @ nurl_tcp_unref i handle → v

& c @ nurl_tcp_local_addr i handle → s

& c @ nurl_udp_bind s host i port → i

& c @ nurl_udp_connect i handle s host i port → i

& c @ nurl_udp_send i handle s buf i n → i

& c @ nurl_udp_recv i handle s buf i n → i

& c @ nurl_udp_send_to i handle s buf i n s host i port → i

& c @ nurl_udp_recv_from i handle s buf i n → i

& c @ nurl_udp_close i handle → v

& c @ nurl_udp_err_kind i handle → i

& c @ nurl_udp_peer_addr i handle → s

& c @ nurl_udp_local_addr i handle → s

& c @ nurl_udp_set_timeout i handle i ms → v

& c @ nurl_udp_set_nonblock i handle i on → v

& c @ nurl_udp_family i handle → i

& c @ nurl_udp_get_fd i handle → i

& c @ nurl_udp_set_broadcast i handle i on → i

& c @ nurl_udp_join_group i handle s group s iface → i

& c @ nurl_udp_leave_group i handle s group s iface → i

& c @ nurl_udp_set_multicast_ttl i handle i ttl → i

& c @ nurl_udp_set_multicast_loop i handle i on → i

& c @ nurl_dns_resolve s host → s

& c @ nurl_dns_resolve_port s host i port → s

& c @ nurl_dns_reverse s ip → s

& cuda @ cuInit i32 flags → i32

── CUDA driver + NVRTC (the GPU host-import bridge) ────────────── A wasm module built from a GPU-using NURL package (packages/gpu → onnx → objdet) imports these under module "env"; nwasm resolves them to the real libcuda/libnvrtc here, marshalling guest linear memory ↔ host. nurl.sh auto-links libcuda/libnvrtc when these symbols appear, and links stub objects on a GPU-less host (so nwasm always builds; the guest then just sees nonzero CUresult codes). Handle types match the portable i64 model in packages/gpu/src/cuda.nu.

& cuda @ cuDeviceGetCount *u count → i32

& cuda @ cuDeviceGet *u device i32 ordinal → i32

& cuda @ cuDeviceGetName *u name i32 len i32 dev → i32

& cuda @ cuCtxCreate *u pctx i32 flags i32 dev → i32

& cuda @ cuCtxDestroy i ctx → i32

& cuda @ cuCtxSynchronize → i32

& cuda @ cuModuleLoadData *u module *u image → i32

& cuda @ cuModuleUnload i module → i32

& cuda @ cuModuleGetFunction *u hfunc i hmod s name → i32

& cuda @ cuMemAlloc *u dptr i bytesize → i32

& cuda @ cuMemFree i dptr → i32

& cuda @ cuMemcpyHtoD i dst *u src i n → i32

& cuda @ cuMemcpyDtoH *u dst i src i n → i32

& cuda @ cuLaunchKernel i f i32 gx i32 gy i32 gz i32 bx i32 by i32 bz i32 sh i stream *u params i extra → i32

& nvrtc @ nvrtcCreateProgram *u prog s src s name i32 nh *u headers *u incs → i32

& nvrtc @ nvrtcCompileProgram i prog i32 nopt *u opts → i32

& nvrtc @ nvrtcGetPTXSize i prog *u sz → i32

& nvrtc @ nvrtcGetPTX i prog *u ptx → i32

& nvrtc @ nvrtcGetProgramLogSize i prog *u sz → i32

& nvrtc @ nvrtcGetProgramLog i prog *u log → i32

& nvrtc @ nvrtcDestroyProgram *u prog → i32

: Arg { ( Vec u ) bytes }

: Ctrl { i is_loop i start_pc i end_pc i height i arity }

: WFd { i kind ( Vec u ) data i pos ( Vec u ) host ( Vec u ) name b writable b dirty b append }

A WASI file descriptor. kind: 0 closed, 1 stdio, 2 preopened dir, 3 file, 4 opened (non-preopen) directory. For a file, data holds the contents, pos the read/write offset, host the on-disk path (for open/flush), and name (a dir) the guest-visible preopen name reported by fd_prestat_*. append = O_APPEND: every write lands at the end regardless of pos.

: InterpImpl

: InterpImpl {
    s mod  // *ModuleImpl
    ( Vec i ) vs  // value stack
    ( Vec u ) mem  // linear memory (bytes)
    i mem_pages  // current size in 64 KiB pages
    // Bytes the guest may address = mem_pages * 64 KiB. Usually the whole
    // buffer, but a SHARED memory reserves its declared maximum up front —
    // growth must never move the buffer, because another thread is reading
    // it through the same pointer — so there the buffer is larger than
    // what the guest may touch, and every bounds check reads this instead
    // of the Vec's length.
    i mem_bytes
    // Guard-page mode: the raw base of an 8 GiB PROT_NONE reservation
    // (0 = the memory lives in the `mem` Vec instead). Growth commits
    // pages in place, so this address never moves; every reader goes
    // through __mem_base, which picks whichever representation is live.
    i mem_raw
    ( Vec i ) globals  // mutable global values
    ( Vec i ) table  // runtime funcref table (mutable via table.set/grow/…)
    ( Vec i ) data_dropped  // 1 per data segment once dropped / active
    ( Vec i ) elem_dropped  // 1 per element segment once dropped / active
    ( Vec s ) argv  // *Arg — WASI program arguments (argv[0] = program)
    ( Vec s ) envp  // *Arg — WASI environment entries ("NAME=VALUE")
    ( Vec s ) fds  // *WFd — file-descriptor table (0/1/2 stdio, 3 preopen, …)
    // Why execution stopped, as one word so the driver's hot loop tests one
    // load instead of two byte fields: bit 0 = trapped, bit 1 = exited (via
    // proc_exit). `interp_trapped` / `interp_exited` are the readers; nothing
    // outside `__trap` and `__wasi_proc_exit` sets it.
    i halt
    i exit_code
    ( Vec u ) trapmsg
    i pending_call  // callee set by call/call_indirect for the driver (-1 none)
    i max_depth  // frame-stack depth limit (trap when exceeded)
    i fuel  // remaining budget in predecoded records (-1 = unlimited)
    i jit_ctx_free  // freelist of reusable 7-word JIT call contexts (0 = empty)
    i jit_lc_fidx  // last JIT callee fidx (-1 = none) and its resolved PFunc
    i jit_lc_pf
    // Inline call-out bridge: a capture-decomposed closure the emitted
    // code calls C-ABI-style instead of parking the whole native frame
    // chain. cur_cd is the live JIT context for the innermost run.
    i jit_co_fn
    i jit_co_env
    i jit_cur_cd
    // Tier-6 direct-call state. The slab is a bump stack of raw slot
    // frames for JIT execution. jit_spcell is the JIT anchor block, held
    // in r8 for the whole native execution: word 0 = the absolute
    // address of the next free slab slot, word 1 = the slab end, words
    // 2.. = one direct-entry address per defined function (0 = call
    // through the driver).
    i jit_slab  // raw slab base address (0 = not yet allocated)
    i jit_slab_end  // slab base + byte length
    i jit_spcell  // address of the anchor block [sp, slab end, ftab..]
    // The code arena: compiled functions are packed into shared chunks
    // instead of one mapping each — a page per function put every entry
    // point at the same page offset, so the hot ones fought over the same
    // L1i sets, and spread the code over an iTLB entry apiece. Each chunk
    // starts with a 64-byte header [previous chunk, chunk bytes].
    i jit_arena  // current chunk (0 = none yet)
    i jit_arena_used  // bytes handed out in it
    i jit_arena_cap  // its size
    // Direct call sites waiting for their callee (tier 8 calls rel32; a site
    // aims at its own stub until the callee's entry exists): per defined
    // function the head of a list of sites, linked through jit_lnext, each
    // the absolute address of a rel32 in the arena.
    ( Vec i ) jit_lhead
    ( Vec i ) jit_lnext
    ( Vec i ) jit_laddr
    ( Vec i ) jit_lkind  // 1: a register-argument site (links to the callee's fast entry)
    ( Vec i ) jit_fast  // per defined function: tier 8's register-argument entry (0 = none)
    b gpu_ok  // env/CUDA host imports enabled (opt-in; default off)
    b net_ok  // nurl_net host imports (real sockets) enabled (opt-in; default off)
    // Shared memory changes what a narrow store is allowed to touch: see
    // __mem_store. Cached here because every store reads it.
    b shared_mem
    // Guest socket handle → host handle. The guest never sees a host
    // pointer: it gets an index into this table, so a forged handle can
    // only miss, never become a host address the runtime dereferences.
    // Slot 0 is reserved — the stdlib reads handle 0 as "failed".
    ( Vec i ) nethandles
    ( Vec i ) netkinds  // 1 = TCP, 2 = UDP (which close the runtime owes it)
    b cap  // capture stdout/stderr into capout/caperr instead of the host streams
    ( Vec u ) capout  // captured module stdout (raw bytes, NULs preserved)
    ( Vec u ) caperr  // captured module stderr
    ( Vec s ) pfuncs  // *PFunc per defined function, predecoded lazily (#s 0 until first call)
    // Threads (wasi-threads). A spawned thread runs its OWN Interp — own
    // value stack, frames, globals and therefore its own `__stack_pointer`,
    // exactly as the proposal's "one instance per thread" says — over the
    // SHARED linear memory, table and module. `owner` points at the
    // instantiating Interp (0 in it), and the host-side tables that must
    // stay common (file descriptors, socket handles, captured output) are
    // reached through it; `__host` is that hop.
    s owner  // *InterpImpl of the instantiating thread, 0 when this IS it
    i next_tid  // owner only: thread ids handed to wasi.thread-spawn
    ( Vec i ) thread_kids  // owner only: live child *InterpImpl, for memory.grow
    ( Vec Thread ) thread_joins  // owner only: host threads, joined before free
    i tstart_fidx  // cached export index of `wasi_thread_start` (-2 = not looked up)
    i sp_global  // cached global index of `__stack_pointer` (-2 = not looked up)
    // The module this instance runs, held so it outlives every user of `mod`
    // (owner only; a thread borrows its owner's).
    Module modh
}

: Interp { s ctl }

An Interp is a handle on its instance state in an rcbox (stdlib/core/rcbox.nu): every copy is the same instance, and the last owner releases it. A spawned thread's instance is not one of these: it is a raw *InterpImpl made and released by the thread that runs it (see __wasi_thread_spawn), because it shares its owner's memory, table and argv by handle and must never drop them.

@ Interp_drop sink Interp h → v

: Frame { i fidx ( Vec i ) regs i pos i end i code_start s pins i ret_dst s prev i depth }

One activation record on the explicit call stack: the function, its locals, its control stack, and the instruction cursor [pos, end) — pos/end are record indices into the function's predecoded instruction array, not byte positions; pins borrows the *PFunc owned by it.pfuncs.

: PFunc { ( Vec i ) code ( Vec i ) aux i count i nlocals i nslots i nparams i nresults i code_start i sbase ( Vec i ) kv s free ( Vec i ) bytes s jit i jitlen ( Vec i ) refl }

A predecoded function body in REGISTER FORM. wasm validation guarantees a static stack height at every instruction, so the value at stack position h lives in slot (nlocals + h) of one flat per-frame array — locals first, stack after — and predecode resolves every operand to an absolute slot index. There is no value-stack traffic and no runtime control stack left: local.get/set/tee become register moves, block/loop/end emit nothing at all, and branches are direct jumps that carry (dst, src, n) result-move triples computed statically. Layout: 6 x i64 per record — [0] micro-op a dense internal opcode, NOT the wasm byte — see __iop [1..4] A B C D operands: slot indices, jump targets (record indices), immediates, packed move triples. A load's D is its index slot — the address add folded into the access, or the pool's zero slot when there was none (__fuse_addr). [5] BYTE the instruction's byte offset in the module image, for trap backtraces — the one thing byte positions are still good for aux holds br_table rows, 4 words per label: target, dst, src, n. The 0xfc family is the one group still bridged through the old value stack: the record copies its operands from slots onto it.vs, runs the existing executor arm, and copies the result back — correctness identical, speed unchanged for them, and no duplicated semantics. Floats and the int/float conversions are register form like everything else. nparams / nresults are the callee's arity, copied out of the type section once at predecode time. Every call and every return needs them, and re-deriving them per call means walking funcs → typeidx → types and two vec_lens inside module_func_type — pure repeat work on a value that is fixed for the life of the module.

: ~ i g_guard 1

Guard-page memory is on wherever the runtime supports it; main.nu turns it off for NURL_NWASM_GUARD=0 (the A/B and debugging escape).

@ interp_disable_guard → v

% Drop InterpImpl

% Drop InterpImpl {
    unsafe @ drop InterpImpl it → v {
        : i tjn ( vec_len [Thread] . it thread_joins )
        : ~ i tji 0
        ~ < tji tjn {
            ?? ( vec_get [Thread] . it thread_joins tji ) { T t → { ( thread_join t ) } F → {} }
            = tji + tji 1
        }
        ( __net_close_handles . it nethandles . it netkinds )
        ? != 0 . it mem_raw {
            ( nurl_guard_mem_del # *u . it mem_raw )
            ( nurl_vmem_release # *u . it mem_raw ( __vmem_span ) )
        } {}
        ( __free_args . it argv )
        ( __free_args . it envp )
        : i fn ( vec_len [s] . it fds )
        : ~ i fi 0
        ~ < fi fn { ?? ( vec_get [s] . it fds fi ) { T pp → ( __freefd pp ) F → {} } = fi + fi 1 }
        ( __free_pfuncs . it pfuncs )
        ( __jit_state_free . it jit_ctx_free . it jit_slab . it jit_spcell . it jit_co_env . it jit_arena )
    }
}

The raw parts of an instance — the Arg / WFd / *PFunc records behind its tables, the guard reservation, the JIT state, the guest's open sockets — released by its last owner (Interp_drop); its Vecs and its module handle are dropped after this returns. Threads first: the guest's own join only proves the thread's BODY finished, and the host thread is still inside the interpreter for a moment after that, reading this memory and table.

@ interp_free sink Interp it → v

Let go of it now rather than at the end of its owner's scope.

@ interp_trapped Interp it__h → b

@ interp_exited Interp it__h → b

: PBlk { i kind i base i params i results i live_entry i t0 i else_br ( Vec i ) patches }

One open block/loop/if during predecode. patches holds forward-branch sites that must learn "the record index just past this frame's end": value site2 → record A-field, site2+1 → aux word site.

: ~ i g_jit 0 // tier-0 JIT opt-in (NURL_NWASM_JIT); off by default

Execute function fidx to completion on an EXPLICIT frame stack — guest recursion depth is bounded by max_depth, not by the host's native stack. Arguments are already on the value stack; results are left on top.

Two loops, not one. The outer loop owns the frame stack: it loads the top frame's bases into registers and, when the inner loop comes back, either returns from that frame or picks up the one a call just pushed. The inner loop owns the records of a single frame and its condition is one compare — everything that has to leave a frame (a call, a return, a trap, exhausted fuel) parks the cursor at pend and falls out of it. That is what the old single loop paid for on every record: a vec_len on the frame stack, a reload test, and two byte loads for the trap and exit flags, none of which can change between two records of the same basic block.

: ~ i g_jit_depth 0 // JIT call-out nesting; beyond a cap, callees interpret (1M-deep-safe)

: ~ i g_pin 1 // tier-7 slot pinning (NURL_NWASM_PIN=0 keeps every slot in memory; A/B, debug)

: ~ i g_jitdump 0 // NURL_NWASM_JIT_DUMP=1: emit every sealed page as decimal bytes on stderr

: ~ i g_rjit 1 // tier 8 (rjit.nu) first; NURL_NWASM_RJIT=0 keeps the template tier alone (A/B, debug)

: ~ i g_rjdbg 0 // NURL_NWASM_RJIT_DBG=1: say on stderr which functions tier 8 declined, and why

: ~ i g_perfmap -1 // NURL_NWASM_PERFMAP=1: fd of /tmp/perf-<pid>.map (-1 = off)

@ interp_enable_jit → v

@ interp_disable_rjit → v

@ interp_disable_bmi2 → v

NURL_NWASM_BMI2=0: tier 8 sticks to baseline x86-64 (no shlx/sarx/shrx, lzcnt, tzcnt) even on a CPU that has them — for testing those paths

@ interp_rjit_trace i f → v

@ interp_enable_rjdbg → v

@ interp_disable_pin → v

@ interp_enable_jitdump → v

: ~ i g_jit_noax 0

Set by an emitter that used a pin-direct form: the record ran without touching rax, so the walk keeps its rax cache instead of assuming the result went through it. Consumed (and reset) once per record.

@ exec_func Interp it__h i fidx → v

@ interp_flush Interp it__h → v

@ interp_net_close_all Interp it__h → v

: ~ i g_atom_mx 0 // *pthread_mutex_t, the one atomics lock

: ~ i g_atom_cv 0 // *pthread_cond_t, where waiters sleep

: ~ i g_atom_gen 0 // bumped by every notify; a waiter watches it change

: ~ i g_atom_waiters 0 // sleepers right now, for notify's return value


module.nu

packages/nwasm/src/module.nu — WebAssembly binary decoder (pure NURL).

Decodes a wasm32 module's structure: the magic/version header and the sections this runtime understands (type, function, table, memory, global, export, element, code, data). Imports and the custom/start sections are skipped. The byte cursor + LEB128 readers here are reused by the interpreter (interp.nu) to walk instruction streams.

Memory model: a Module is a handle on the decoded module in an rcbox (stdlib/core/rcbox.nu) — every copy is the same module, and the last owner releases it (module_free is an early release, optional). A Wc cursor is a plain value that owns nothing.

API

: Wc { * u data i pos i len i cap }

A cursor over bytes it does not own (the module image outlives it): a value in a mutable local, passed inout, so there is nothing to release. len is where the cursor's input ends (wc_eof / wc_avail; callers narrow it to one function body), cap the buffer's real end, which bounds every read — past it a read yields 0, as an out-of-range vec_get did.

@ wc_new ( Vec u ) buf → Wc

@ wc_free sink Wc c → v

Nothing to release: a Wc owns no memory.

@ wc_eof inout Wc c → b

@ wc_uleb inout Wc c → i

Unsigned LEB128 → i (NURL i is 64-bit, covers u32/u64).

Only the first ten groups can carry a 64-bit value. A longer encoding is malformed wasm, and << x shift with shift >= 64 is poison in LLVM (docs/spec.md, § operators) — a corrupted continuation byte turning a terminal group into a running one is exactly how a module reaches it. Groups past the tenth are consumed, so pos still lands after the encoding, but they contribute nothing: the value stays inside 64 bits and every caller's range check still sees a number it can judge.

@ wc_sleb inout Wc c → i

Signed LEB128 → i (sign-extended).

@ wc_skip inout Wc c i n → v

Skip n bytes.

@ wc_avail inout Wc c → i

Bytes physically remaining in the input (never negative).

: FuncType { ( Vec i ) params ( Vec i ) results }

: WFunc { i typeidx ( Vec i ) locals i code_start i code_end }

A defined function: its type index, its declared (non-param) local valtypes expanded flat, and the [start,end) byte range of its instruction stream.

: WExport { ( Vec u ) name i kind i index }

: DataSeg { i offset ( Vec u ) bytes i passive }

A data segment. Active (passive=0): copied to memory at offset during instantiation. Passive (passive=1): source material for memory.init only.

: ElemSeg { ( Vec i ) funcs i passive }

An element segment: function indices (−1 = null ref). Active segments are applied to the table at decode; passive ones feed table.init; declared (and applied active) ones count as dropped at instantiation.

: NameBuf { ( Vec u ) bytes }

An owned byte buffer behind an opaque pointer (name-section entries).

: WImport { ( Vec u ) module ( Vec u ) field i typeidx }

An imported function (the only import kind this runtime resolves): module + field name select the host (WASI) implementation; typeidx gives its signature. Non-function imports are a decode error (nothing satisfies them).

: ModuleImpl

: ModuleImpl {
    ( Vec s ) types  // *FuncType
    ( Vec i ) functypes  // type index per defined function
    ( Vec s ) funcs  // *WFunc
    ( Vec s ) exports  // *WExport
    ( Vec u ) code  // the whole module byte image (functions index into it)
    i has_mem
    i mem_min  // initial pages (64 KiB each)
    i mem_max  // 0 if unbounded
    i mem_shared  // 1 = threads-proposal shared memory (max is mandatory)
    ( Vec s ) datas  // *DataSeg
    ( Vec i ) global_init  // initial value per global
    ( Vec i ) global_mut  // 1 if mutable
    i has_table
    ( Vec i ) table  // initial table image: function indices (−1 = null)
    i table_max  // declared table maximum (0 = none)
    ( Vec s ) elems  // *ElemSeg — element segments, in order
    ( Vec s ) imports  // *WImport (imported functions, in index order)
    i num_import_funcs  // imported funcs occupy func indices 0..n-1
    i start_func  // start-section function index (-1 = none)
    ( Vec i ) name_idx  // function indices with a "name"-section entry…
    ( Vec s ) name_str  // …and their names (*( Vec u )), parallel (sparse)
    b ok
    ( Vec u ) err
}

: Module { s ctl }

A Module is a handle on its decoded state in an rcbox (stdlib/core/rcbox.nu): every copy is the same module, and the last owner releases it.

@ Module_drop sink Module h → v

% Drop ModuleImpl

% Drop ModuleImpl {
    unsafe @ drop ModuleImpl m → v {
        : i tn ( vec_len [s] . m types )
        : ~ i k 0
        ~ < k tn { ?? ( vec_get [s] . m types k ) { T pp → ? != # i pp 0 { ( __ft_free # *FuncType pp ) } {} F → {} } = k + k 1 }
        : i fn ( vec_len [s] . m funcs )
        : ~ i j 0
        ~ < j fn { ?? ( vec_get [s] . m funcs j ) { T pp → ? != # i pp 0 { : *WFunc f # *WFunc pp ( vec_free [i] . f locals ) ( nurl_free # s f ) } {} F → {} } = j + j 1 }
        : i en ( vec_len [s] . m exports )
        : ~ i e 0
        ~ < e en { ?? ( vec_get [s] . m exports e ) { T pp → ? != # i pp 0 { : *WExport x # *WExport pp ( vec_free [u] . x name ) ( nurl_free # s x ) } {} F → {} } = e + e 1 }
        : i dn ( vec_len [s] . m datas )
        : ~ i d 0
        ~ < d dn { ?? ( vec_get [s] . m datas d ) { T pp → ? != # i pp 0 { : *DataSeg ds # *DataSeg pp ( vec_free [u] . ds bytes ) ( nurl_free # s ds ) } {} F → {} } = d + d 1 }
        : i eln ( vec_len [s] . m elems )
        : ~ i el 0
        ~ < el eln { ?? ( vec_get [s] . m elems el ) { T pp → ? != # i pp 0 { : *ElemSeg es # *ElemSeg pp ( vec_free [i] . es funcs ) ( nurl_free # s es ) } {} F → {} } = el + el 1 }
        : i in ( vec_len [s] . m imports )
        : ~ i ii 0
        ~ < ii in { ?? ( vec_get [s] . m imports ii ) { T pp → ? != # i pp 0 { : *WImport w # *WImport pp ( vec_free [u] . w module ) ( vec_free [u] . w field ) ( nurl_free # s w ) } {} F → {} } = ii + ii 1 }
        : i nn ( vec_len [s] . m name_str )
        : ~ i ni 0
        ~ < ni nn { ?? ( vec_get [s] . m name_str ni ) { T pp → ? != # i pp 0 { : *NameBuf nb # *NameBuf pp ( vec_free [u] . nb bytes ) ( nurl_free # s nb ) } {} F → {} } = ni + ni 1 }
    }
}

The section records are raw blocks behind the ( Vec s ) tables; the last owner of the Module releases them here, and the tables themselves (with every other Vec of the module) are dropped after this returns.

@ module_free sink Module m → v

Let go of m now rather than at the end of its owner's scope.

@ module_func_name Module m__h i fidx → ( Vec u )

@ module_export_global Module m__h s name → i

@ module_export_func Module m__h s name → i

@ module_func_type Module m__h i fidx → s


main.nu

packages/nwasm/src/main.nu — nwasm: a WebAssembly runtime in pure NURL.

nwasm run --invoke <export> <module.wasm> [int args…]

Loads a wasm module and either runs its _start as a wasm32-wasi command or invokes one exported function directly, printing the result. The engine itself is module.nu (decoder) + interp.nu (interpreter and template JIT).

API

@ usage → v

@ run_invoke s export s path i first_arg i argc i allow_gpu i allow_net → i

@ run_command s path i prog_start i argc ( Vec String ) dirs ( Vec String ) envs i fuel i allow_gpu i allow_net → i

WASI command: run the module's _start with argv = [module, prog args…], the given preopened directories and environment entries.

@ main → i

Every host option is read from the argv PREFIX that ends at the module path — a guest's own --help, --version or --allow-gpu belongs to the guest. (Scanning all of argv for those three meant nwasm run app.wasm --help printed the RUNTIME's usage and never started the module; the bug surfaced on the first guest with a CLI of its own.) -- ends the host options explicitly, for a module path that starts with a dash.