The executable graph
Mímir's Rust → graph engine writes a graph that is 1:1 with the code, and mimir-exec executes that graph like a program. This chapter describes what is built (schema 8). The plan it came from is exec-graph-plan.md; where the two differ, this chapter is right.
Words: a unit is one crate compiled once. A body is the MIR of one fn, method, closure or coroutine. A site is one statement or terminator of a body. An instance is a body with concrete generic arguments. The boundary is the place where Rust code calls C or the kernel.
1. What changed against schema 7
| schema 7 | schema 8 |
per fn a mir_body node (fingerprints); per block a mir_block node with a text payload, CFG_SUCC edges; own crates only |
ONE lossless table code_mir: one row per body, for every crate. mir_body is a derived column; mir_block and CFG_SUCC are not stored: a reader derives them from the same row |
| a constant is its type | its value (k_lo, k_hi, k_bytes), every switch value |
| no cleanup blocks, no marker statements, 128 statements per block | every block, every statement |
a drop has no callee |
the dropped type's key, resolved per instance; rustc's own answer whether the place is initialised there (live, dead, maybe) |
| generic and trait calls are widened to every impl in the room | resolved per instance by rustc (code_instances); only dyn and fn-pointer calls stay widened, to what REACHED code builds |
| the possible set, no order | the system-call order graph per entry, argument facts, phases |
The lens results did not change: see §8.
2. Dataflow
cargo mimir-dump pass 1: cargo check, RUSTC_WRAPPER = mimir-driver, every unit
mir_promoted body ─► code_mir row ─────────────► <stem>.mir.arrow (1 row / body, zstd)
└ legacy(row) ───────────► nodes.arrow mir_body.ty (derived column)
cargo mimir-dump --exec pass 2: the same check with -Zbuild-std; only a bin unit works
from lang_start + main, through every crate:
Instance::try_resolve, vtables, fn pointers ───► <bin>.inst.arrow code_instances (1 row / instance)
compiler shims (drop glue, adapters) ──────────► <bin>.mir.arrow code_mir rows of kind shim
<bin>.exec.json counts
cargo mimir-dump --std once per toolchain: std, core, alloc … as pass 1
lens loader / room MirSide legacy(row) on demand ─► mir_block text + CFG_SUCC (bytes of schema 7)
mimir-exec code_mir ⋈ code_instances ⋈ the SYSCALLS map
reach → values → order → entries
exec.nodes exec.edges exec.marks (ipc | znippy | graphar) <bin>.possible.json
3. The tables
Every column is listed in tables.md and in crates/mimir-schema/src/code_mir.rs. The shape: one row holds a whole body as parallel arrays (a struct of arrays in Arrow List columns). A string lives in the row's own pool (strs), a string column holds indices into it. No text payload is parsed anywhere.
-
code_mir: identitydef(the body's def key). Blocks (blk_end,blk_flags), sites (st_kind,st_word,st_dest,st_ty,st_aux,st_callee, position relative to the owner's first line), operands (op_kind,op_ref), places and projections (pl_*,pj_*), constants with values (k_*), targets with roles and switch values (tg_*),drop_key, local types and names (local_ty,dbg_name). -
code_instances: identityinst.body(thecode_mir.defit runs), per term what it resolves to (res_def,res_args,res_kind,res_inst,res_line,res_col), the vtables and fn pointers the instance makes (vt_*,fp_*), generic constant values (kv_*), the instance's v0 symbol. A vtable is a row of its own ({vtable}<dyn>|<type>, flags bit 2) with the instance behind each slot. -
code_data: identityid. One row per static a unit defines and one per unnamed allocation a constant or a static points at, transitively (the instance pass adds the statics of other crates the program names):size, the alignment and the flags (mutable, thread local,#[used], cut),bytes(an uninitialised byte and the bytes of a pointer are 0), and every pointer in it:ptr_off,ptr_to(the target's id),ptr_sig(a fn pointer's signature key). A vtable has no row: its content is thecode_instancesrow of the same name.
A pointer constant names its target with these ids (k_def of a kk::PTR constant, ptr_to of a row): a static by its def key, a fn by fn:<instance key>, a vtable by {vtable}<dyn key>|<type key>, unnamed memory by alloc:<16 hex digits> (a hash of its size, alignment, mutability, bytes and pointers, so equal allocations are one row and the id is the same in every compiler session), the id of a type by typeid:<type key>. kk::PTR means "the value is the ADDRESS k_def + k_lo"; kk::BY_REF means "the value is the CONTENT of row k_def from offset k_lo" (a by-value constant that holds pointers; one without pointers stays kk::BYTES).
Every row carries content, a hash of its other columns: the lake's delta key. base_line is outside the hash and positions are relative to it, so moving a fn re-emits nothing. A key that names a crate has a version-free form (code_mir::stable_key) the lake keys on, so a dependency bump keeps the identity of the rows that name the crate.
3.1 The derived view
mimir_schema::code_mir::legacy computes the schema-7 view of a row:
text(site) the schema-7 spelling (a constant is k:<short type>, a call is call <st_callee>⟨args⟩->dest)
canon(b) canonical_locals(text) of each data statement and of the terminator, then the types of the
block's locals in order of first use
fp(b) fnv64(canon(b)); a call with a named callee: fnv64(hex16(fp) + callee)
succ(b) the terminator's normal targets that are no cleanup block, consecutive repeats removed
Only non-cleanup blocks and data statements are in the view. The driver writes mir_body.ty with it. The lens loader (mimir-lens/src/coverage/v2.rs) derives the mir_block nodes and CFG_SUCC edges of the workspace's own units at load and puts them where the schema-7 driver wrote them; the room's MirSide derives the same text from the lake's code_mir rows. A dependency's blocks are derived only when a reader asks.
3.2 Keys
A key is our own print of a def, a type or an instance, the same in every compiler session (mimir-driver/src/walk/keys.rs):
def key <package>[<target>]@<version>::<def path with disambiguators> std::fs::File::open app[bin=app]@0.1.0::main
type key primitives by name; an ADT = def key + ⟨args⟩; &T, &mut T, *const T, [T; n], (A,B);
a generic parameter of the body = #<index>; regions are not printed;
dyn = principal + projection bounds + auto traits (sorted); a closure = its def key + the
arguments of the fn it sits in
instance key def key + ⟨args⟩; a compiler shim: {<kind>} + def key + ⟨args⟩
A toolchain crate has no version. Before a term is printed its types are normalised the way rustc's PostAnalysisNormalize does, so an opaque type prints as its hidden type in both passes.
4. The instance pass
For a body D with generic parameters P and a term t = (callee, G) in it, G written over P, and an instance of D with arguments A:
resolve(D, A, t) = Instance::try_resolve(tcx, fully_monomorphized, callee, G[P := A])
This is the call rustc's mono collector makes. The pass (mimir-driver/src/walk/inst.rs) starts at std::rt::lang_start and main, walks rustc's MIR of every instance it reaches, in every crate, and writes one code_instances row per instance:
| the term | the answer (res_kind) |
| a call that resolves to a fn, a closure, a shim | 0: the instance key |
a call on dyn Trait |
1: <trait + its projection bounds>#<vtable slot> |
| an intrinsic, a tuple-struct constructor | 2 |
| a foreign item | 3: its link name (the boundary), unless a Rust fn of the program defines that symbol (the allocator, the panic runtime, a #[no_mangle] fn): then 0 |
| a call through a fn pointer | 4: the signature's key |
| a drop | 0 the drop glue; 1 slot 0 for dyn; 5 nothing to drop |
| an assert, an unwind-terminate | 0: the panic lang item |
| a coroutine made here | construct: which async body's object is made |
A term is keyed as pass 1 keyed it in the body's row (k_def, k_args; a drop: drop + drop_key), and by its source position as the second key (a call through a pointer held in a local has no first key; rustc's MIR of an instance can name another callee at the same place, e.g. the run-time arm of const_eval_select).
Why a second pass: a unit's own session sees the toolchain's generic bodies only as optimised, inlined MIR, and no body of a non-generic fn of another crate. The pass therefore checks the workspace once more with -Zbuild-std, and the driver itself sets -Z always-encode-mir, -Z inline-mir=no and -Z mir-opt-level=0: every crate's metadata then holds the MIR of every body, not inlined. A unit of the standard library build is told from a user crate of the same name (libc, hashbrown) by -Z force-unstable-if-unmarked.
The allocator: __rust_alloc and its siblings resolve to the #[global_allocator]'s fns when the program has one, else to std's __rdl_alloc family. The start-up: lang_start is an ordinary instance, handed main as a fn pointer.
5. The executor (mimir-exec)
Stable Rust, no rayon, no petgraph. Program::load reads the instance table of one program and only the bodies its instances run.
5.1 Reach
From the entries over the answers of each instance. A dyn call goes to the vtables REACHED instances build (vt_dyn = the trait and its projection bounds, so dyn Fn() -> i32 and dyn FnOnce() are two keys), a fn-pointer call to the pointers REACHED instances make (casts, constants, statics). Both sets grow with the reach: a fixed point. On the generic-dyn fixture the unused impl is not reached; schema 7 took every impl in the room.
5.2 Values
V(i, x) = ⊥ | a set of at most 8 integers | ⊤ join = ∪, a 9th value gives ⊤
D(i, x), D(i, x.f) the same domain for the DISCRIMINANT held in a local or in one field of it
x = const c V[x] ⊔= {c} x = op(a, b) V[x] ⊔= { op(u, v) }
x = copy / move y V[x] ⊔= V[y], D likewise x = cast(a) V[x] ⊔= { u mod 2^bits }
x = Variant_n(…) D[x] ⊔= {n} x = (a, b) D[x.k] ⊔= D[operand k]
x = discr(y) V[x] ⊔= D[y]
x = call j(a…) V(j, param k) ⊔= V[ak]; V[x] ⊔= V(j, _0); D[x], D[x.f] from j's return
x = poll of an async body D[x] ⊔= {0} (Poll::Ready: §5.4)
a local that is borrowed, or written in a way the pass does not follow: ⊤
Two more facts per local, over the code_data rows (the same join, sets of at most 8 row ids, ⊤ when unknown):
P(i, x) the rows x can point at L(i, x) the rows x's value was loaded from
x = const ptr d P[x] ⊔= {d} x = const byref d L[x] ⊔= {d}
x = copy / move y P[x] ⊔= P[y], L[x] ⊔= L[y]
x = copy / move (*y)… L[x] ⊔= P[y]; P[x] ⊔= the rows the pointers of P[y]'s rows name (a load, any offset)
x = copy / move y.f… L[x] ⊔= L[y]; P[x] ⊔= the rows the pointers of L[y]'s rows name
x = &(*y)… P[x] ⊔= P[y] x = cast(y) as a copy of y
x = call j(a…) P(j, k) ⊔= P[ak], L likewise; P[x] ⊔= P(j, _0), L[x] ⊔= L(j, _0)
anything else ⊤
A call through a fn pointer x with L[x] known goes EXACTLY to the fn: targets of those rows (of the call's signature); a dyn call on a receiver x with L[x] known goes to the {vtable} targets of those rows (of the call's trait), slot by slot. The reach is repeated with these targets fixed until nothing changes, so an instance only a dropped target reached is not reached any more. Everything else keeps the whole-program set of §5.1. The count is exact_sites (the CLI: "dynamic calls made exact by data rows"). On the static-table fixture a fn and an impl that reached code turns into pointers but never calls are not possible; the whole-program set had them.
Flow-insensitive inside a body, one value per instance and local, to the fixed point. Two uses: a switch whose operand is a known set takes only the arms it can (Heimdall: see §8), and the argument facts of a boundary call: for the arguments Heimdall's probe records (heimdall-probe/src/facts.rs: socket 0 1 2, open 1, openat 2, ioctl 1, mmap / mprotect 2, clone 0, prctl 0, fcntl 1) the set of values, or no fact = any value. The raw door syscall(nr, …) with a constant nr is named as that call.
5.3 The order relation
B = the boundary sites (an instance + a block). Per instance i:
First(i) ⊆ B the sites that can be the first system call of a call to i
Last(i), Eps(i) the last ones before i RETURNS; it can return without any
LastU(i), EpsU(i) the same for i left by UNWINDING
Next ⊆ B × B "after s the next system call can be s'"
A forward dataflow over the blocks of i, cleanup blocks included, with L ⊆ B ∪ {ε} = the sites that can be the last call since i was entered:
entry of i L = {ε}
boundary site s x ∈ L: x = ε ? First(i) ∋ s : Next ∋ (x, s); L' = {s}
call of instance j x ∈ L: x = ε ? First(i) ⊇ First(j) : Next ⊇ {x} × First(j)
on the return edge L' = Last(j) ∪ (Eps(j) ? L : ∅)
on the unwind edge U' = LastU(j) ∪ (EpsU(j) ? L : ∅) (to the cleanup block, or out of i)
drop a call of the drop glue; state maybe: the skip is taken too; dead: nothing
assert the check holds: L; it fails: the panic fn's unwind effect on the unwind edge
merge L = L1 ∪ L2
return / resume Last(i) ⊇ L \ {ε}, Eps(i) ∨= ε ∈ L / LastU(i), EpsU(i)
Sets under ∪ (the boolean semiring), all finite and growing: the least fixed point, computed with a worklist (an instance is looked at again when a callee's summary grew). Calls and returns match: a callee returns to its own call site, so Next holds no pair only a mismatched return could make. What unwinds: _Unwind_RaiseException at the boundary.
5.4 Async bodies
An async body is executed as ONE activation from its start to its return: a poll goes on where the last one stopped (rustc's state machine does exactly that). Three rules:
- A yield is a gap. The body goes on at the resume block; in between, the thread can run
W(k):
W(k) = Exec ∪ Plain(k) ∪ ⋃ { Reach(o) : o async, o ∉ Anc(k) ∪ Desc(k) ∪ {k} } ∪ (Reach(k) if k runs in a spawned task)
Exec = the code outside every async body (runtime, executor, parking); Plain(k) = the plain fns between k and its nearest async caller (a combinator's own poll); the other async bodies are the siblings of a join! and other tasks. Anc(k) are suspended with k, Desc(k) are inside it. At a yield: Next ⊇ L × W(k), L' = L ∪ W(k).
-
The poll that returns is
Ready(the discriminant fact of §5.2), so the poller's "pending, poll again" branch is not taken. -
A once-made body does not start again. When the one place that makes the body's object runs at most once (no loop around it, no recursion, not a spawned task), then outside
kno pair (x → f) is made withxa site onlykcan reach andfa first site ofkonlykcan reach.
Any other coroutine (a gen block) is entered at every resume point: no state is modelled, which is sound and coarser.
5.5 Entries and phases
- process:
lang_start(the C runtime's start-up before it is the map's start-up list, as in schema 7). - thread: a fn pointer handed to
pthread_createat the boundary (its targets are an entry, not a call), and a spawn mark (an instance ofstd::thread::spawn,Builder::spawn,tokio::spawn,spawn_blocking… starts its first type argument).sigaction,atexit,__cxa_thread_atexit_implcallbacks are entries of kindsignal/exit/thread-exit. - An entry's graph is
Nextcut to the sites its instances hold; its start nodes areFirst(entry). - Phases of the process entry are read from the shape of the program's own top body (
main, or the once-made async body it runs): when that body has exactly ONE loop that reaches the boundary, a site isbeforeit, in theloop(servewhen the loop reachesaccept,epoll_wait,poll…), orafterit. Any other shape is said (shape unclear: … N loops) and no phase is given.
5.6 Output and stores
exec.nodes (one row per entry and site: node id, instance, block, file, line, callee, system calls with the map's "may", first, unwind, phase, the instance's symbol, argument facts), exec.edges (entry, from, to), exec.marks (entries, the phase verdict, notes). The schemas are in crates/mimir-exec/src/output.rs. <bin>.possible.json is the possible set as the projection of the graph, in the mimir.possible-syscalls/1 shape.
The three tables are one SET in a store of choice (crates/mimir-store, --format):
| format | where | published | on disk |
ipc (default) |
nornir-mimir-store |
yes | one <set>.<table>.arrow per table, Arrow IPC, zstd buffers |
znippy |
nornir-mimir-store, feature znippy |
yes | one <set>.znippy (the lens graph-package's writer, OpenZL) |
graphar |
nornir-mimir-store-graphar, binary mimir-exec-graphar |
NO | <set>.gar/: vertices = exec.nodes, edges = exec.edges with their properties, Parquet chunks (zstd) by default |
graph-ar is proposed for donation to Apache, so no published crate names it or knut; the store registers itself through mimir_store::register_store. Each store has a round-trip test on one shared fixture.
Notes are part of the result: raw door (a number chosen at run time), unmapped C function (it may make any call), unmatched term (no answer in code_instances: nothing is assumed, the call is passed), no body, virtual call without target.
6. Running it
D=<dir of cargo-mimir-dump and mimir-driver, nightly-2026-10-05>
$D/cargo-mimir-dump --driver $D/mimir-driver --out out # pass 1
$D/cargo-mimir-dump --exec --driver $D/mimir-driver --out out # pass 2 (target/mimir-exec)
$D/cargo-mimir-dump --std --driver $D/mimir-driver --manifest-path probe/Cargo.toml --out std-out --repo @std
mimir-exec --dump out --dump std-out --bin <program> --out graph [--format ipc|znippy|graphar]
[--only call,call…] the order graph of a chosen set of calls
[--follow call,call…] print the order of those calls per entry
[--why NAME] every site that makes a call, with one call chain from the entry
[--notes N]
The consumer runs pass 2 after each commit's dump when MIMIR_INSTANCE_PASS=1 is set; the lake keeps a program's rows under the owner <pkg>@<ver>:bin:<program>+exec.
7. Worked example: A { B C D }* F
crates/mimir-exec/tests/fixtures/abcdf: a() (getppid), then a loop of b() (getegid) and tokio::join!(c(round), d(round)) (getuid, getgid: two futures on one thread, each yields first on alternate rounds), then f() (geteuid), under #[tokio::main(flavor = "current_thread")].
mimir-exec --dump out --dump std-out --only getppid,getegid,getuid,getgid,geteuid --follow …
entry 0 process follows:
^→A A→B A→F B→C B→D B→B B→F C→D C→C C→B C→F D→C D→D D→B D→F
phases (shape of abcdf[bin=abcdf]@0.1.0::main::{closure#0}): before 1 site, loop 3 sites, after 1 site
A real run, recorded with ptrace in the test: A (B D C)(B C D)(B D C) F. Every neighbour pair is in the relation; nothing comes before A, nothing after F. C→C, D→D, B→B and A→F are the over-approximation: a join whose futures may already be done, and a loop that may run no round.
8. Measured
All numbers: 2026-11-06, host oden, other lanes compiling on the host at the same time (the 1-minute load is given where it matters). No number is rounded in our favour; byte counts are exact.
8.1 The lens gate: the lenses give the same answers
The lenses read the derived view, so their results must not move. Checked three ways, schema 7 = nornir main f4f3ab73 with its own driver, schema 8 = this branch with its own driver, the same four source trees (Holger h32 9dbe5cbf, Holger main c2220d76, Gunnar c17a6ef4 default and metered):
| check | result |
| the derived view against the rows the schema-7 driver wrote (Heimdall, 390 units) | 158,819 mir_body payloads, 160,778 mir_block payloads, 168,959 CFG_SUCC pairs: 0 differ |
every lens through the door (dwarves-lens): all with and without sinks (25 lenses), --json and text, and by name reflexion (with and without a spec), arch-recover, arch-report (JSON, text, with a plan), refactor-price, layer-stack (module, crate, component), seam-interface, concept-scatter, ports-adapters (module, component), reach-set, verdict, export-refactor-envelope, single-impl over two builds: 97 runs per side |
97 of 97 outputs byte-identical (after the dump's path and the lens build's git hash are replaced); every exit code 0 |
the golden tests of nornir-mimir-lens |
Holger 32 of 32, Gunnar 23 of 23, the plain tests 211 passed, 0 failed (main: 31 of 32 for the reason below, 23 of 23, 208) |
The one golden that moved is not from schema 8: cfg-sprawl on Holger (item_gates 875 → 769, body_gates 1318 → 1079, gated_out_stubs 517 → 333) differs on main since 0d57a9a0 ("include_str! is no source for the cfg scan"). The old driver scanned three files a second time under a path with .. in it (88 + 86 + 10 = 184 stubs; their gates twice). The golden is re-pinned with that reason (a2916ebb).
Cost of the derived view at load (the door's lens all --json, cold = no graph cache, both sides run at the same time; warm = from the .dwgc cache):
| dump | cold wall, schema 7 → 8 | cold peak RSS, 7 → 8 | warm (24 more runs), 7 → 8 |
| Holger h32 (360 units) | 22.6 → 24.3 s | 5,056,664 → 4,336,300 kB | 102.9 → 101.0 s |
| Gunnar default (506 units) | 19.0 → 20.5 s | 5,032,316 → 4,632,772 kB | 107.2 → 106.5 s |
| Gunnar metered (19 units) | 10.5 → 11.4 s | 1,559,748 → 1,634,928 kB | 56.1 → 56.4 s |
| Holger main (386 units) | 19.5 → 21.1 s | 5,648,380 → 4,758,616 kB | 104.1 → 103.0 s |
The golden test binaries (glibc malloc, several tests cold-load one dump at the same time): Holger cold 3:00.90 → 3:05.33 and 15,633,956 → 18,139,700 kB, warm 2:56.02 → 2:44.52 and 16,212,572 → 15,454,388 kB; Gunnar cold 1:42.04 → 1:44.83 and 10,289,172 → 12,232,468 kB, warm 1:40.47 → 1:40.72 and 10,402,272 → 9,883,440 kB.
The cold load itself (read + derive + decode of the Holger dump, load trace): 0.9 s against 0.7 s. So: a cold lens all is 0.9 to 1.7 s slower (4 to 8 %), its peak memory is lower on three dumps and 5 % higher on the small one, the cold test binaries peak 16 to 19 % higher, and every warm run is unchanged. The dump the lenses read is 2.3 to 2.5 times smaller on disk (Holger h32: 914,235,990 B against 2,324,001,189 B), because the block rows and their edges are no longer written as uncompressed text.
8.2 Size: schema 8 is NOT smaller like for like
The Heimdall workspace (9c4659d, 390 units, 5 programs), both dumps measured by one tool that re-writes every file plain and with zstd buffers (it reproduces the on-disk byte count of every file exactly).
| stored as | schema 7 | schema 8, first pass | schema 8 with the instance pass |
| as written | 2,090,103,006 | 1,991,047,850 (−4.7 %) | 2,039,182,641 (−2.4 %) |
| every file uncompressed | 2,090,103,006 | 3,091,781,930 (+47.9 %) | 3,506,620,849 (+67.8 %) |
| every file with zstd buffers | 213,230,942 | 335,925,418 (+57.5 %) | 384,060,209 (+80.1 %) |
"As written" is smaller only because the new files (mir, inst, data) have zstd buffers (level 3) and the graph files (nodes, edges, spans, symbols) have none, on both sides.
| file kind | schema 7: rows | on disk | Arrow data | schema 8: rows | on disk | Arrow data | with zstd |
| nodes | 1,694,339 | 798,106,428 | 793,100,362 | 1,432,552 | 649,242,108 | 644,592,571 | 83,724,220 |
| edges | 3,097,215 | 855,762,380 | 852,266,279 | 2,817,299 | 761,240,204 | 757,956,232 | 61,958,156 |
| spans | 2,162,241 | 395,373,804 | 393,318,288 | 2,162,241 | 395,373,804 | 393,318,288 | 35,658,604 |
| symbols | 105,309 | 35,900,204 | 34,273,344 | 105,309 | 35,900,204 | 34,273,344 | 5,292,908 |
mir (code_mir, 390 files) |
204,374 | 144,248,100 | 1,207,647,465 | 144,248,100 | |||
| mir of shims (5 programs) | 14,449 | 3,486,634 | 26,467,993 | 3,486,634 | |||
inst (code_instances, 5 programs) |
302,189 | 44,634,178 | 385,290,468 | 44,634,178 | |||
data (code_data) |
0 | 10,050 | 0 | 10,050 |
"Arrow data" = the sum of every buffer of every column (validity, offsets, values, children). The nodes shrink by exactly the 261,787 mir_block rows and the edges by exactly the 279,916 CFG_SUCC rows; every other row count is equal.
Where the MIR bytes are:
| scope | schema 7 | Arrow data | zstd | schema 8 | Arrow data | zstd | new / old |
| own crates (106 units) | 9,106 mir_body + 261,787 mir_block + 279,916 CFG_SUCC rows |
254,236,559 | 23,651,264 | 12,766 bodies (346,069 blocks, 1,337,544 sites) + the mir_body rows |
136,689,133 | 20,184,908 | 0.54 × / 0.85 × |
| dependencies (284 units) | 176,208 mir_body rows (fingerprints only) |
129,482,057 | 18,640,320 | 191,608 bodies (2,656,614 blocks, 10,955,059 sites) + the mir_body rows |
1,211,947,842 | 144,718,232 | 9.4 × / 7.8 × |
| all | 383,748,152 | 42,291,584 | 1,348,636,975 | 164,903,140 | 3.5 × / 3.9 × |
So the growth is the dependencies: they held a fingerprint per body and hold every body now. For the own crates the new form is smaller AND holds more (80,513 cleanup blocks, 715,091 marker statements, the constant values): the old block rows spent 62.8 MB on repeated id / parent / name strings and 68.6 MB on CFG_SUCC end strings for 35.8 MB of payload text.
| own crates | schema 7 (block rows + CFG_SUCC) |
schema 8 (code_mir) |
| per block | 927.2 B, 82.6 B with zstd (261,787 blocks) | 361.7 B, 52.5 B with zstd (346,069 blocks, cleanup included) |
| per site | 455.2 B, 40.6 B with zstd (533,204 = data statements + terminators) | 93.6 B, 13.58 B with zstd (1,337,544 = 276,384 data + 715,091 markers + 346,069 terminators) |
27.5 % of the code_mir data is the per-row string pool strs.
The instance tables of the five programs: heimdall 247,015 rows, 37,148,194 B; the four small ones 11,390 to 16,976 rows, 1,533,786 to 2,300,578 B each. The lake holds the same code_mir rows in 92,948,944 B (Parquet) against 144,248,100 B in the dump files.
The three stores, on the code_mir rows of an earlier dump of the same workspace (385 units): ipc 139,083,418 B, znippy 119,962,644 B, graphar with Parquet chunks 97,626,096 B (with uncompressed Arrow IPC chunks 1,930,380,388 B: that is why Parquet is the default).
The nordisk universe (estimate from these ratios and the 14.6 M block events of the nordisk room; one workspace measured, so a range): 1.9 GB to 14.4 GB with zstd (middle 4.0 GB), 14.5 GB to 122 GB uncompressed. The 20 GB budget holds for the compressed form only. The low end counts each dependency body once, the high end once per repo; the instance tables are 0.27 GB (every program small) to 6.4 GB (every program as big as heimdall). The whole-workspace consume replaces this estimate with a measurement (§8.6).
8.3 Dump time
The Heimdall workspace, cold target dir, three runs each, 1-minute load 7 to 27:
| wall | peak RSS | |
| schema-7 driver | 33.16, 33.09, 32.66 s | 1,980,668 kB |
| schema-8 driver, pass 1 | 37.28, 36.56, 36.09 s (+10.5 % on the median) | 2,314,152 kB (+17 %) |
pass 2 (--exec, 5 programs, a second check with -Zbuild-std) |
69.67, 66.46, 64.62 s | 2,580,520 kB |
The golden dumps (pass 1 only, one run each): Holger h32 89.3 s against 94.6 s, Gunnar default 90.1 against 91.7 s, Holger main 127.4 against 124.1 s, Gunnar metered 57.6 against 39.2 s (load 16.8 against an unrecorded one; not a clean pair).
8.4 Losslessness
-
The derived view equals what the schema-7 driver wrote (§8.1, row 1). Tool:
crates/mimir-driver/examples/legacy_check.rs. -
Every row against rustc's own MIR.
MIMIR_MIR_VERIFY=<dir>makes the driver decode each row as a reader does (through Arrow and back) and compare it with a second, independent walk of the rustc body (walk/verify.rs: its own match on statements, terminators, rvalues, operands and places, constants evaluated again; it calls nothing of the writer). At the end of a unit the written file is read back and must hold the checked rows.
| Heimdall (390 units + 5 instance passes) | toolchain (26 units) | |
| bodies | 218,823 | 43,558 |
| blocks (cleanup) | 3,047,821 (596,439) | 334,421 (59,996) |
| sites | 12,350,462 | 1,452,117 |
| operands / places / projection steps | 6,690,217 / 9,656,072 / 2,033,152 | 783,223 / 1,163,776 / 190,194 |
| constant operands | 1,370,772 | 182,897 |
| switch values / targets | 400,574 / 4,920,791 | 37,495 / 462,168 |
| mismatches | 0 | 0 |
The test of the check proves it can fail (MIMIR_MIR_VERIFY_FAULT=const flips one constant bit; the report shows both sides).
-
Side by side: 20 bodies picked with a fixed seed from each of three crates (
heimdall_seccomp,serde_json,core), rustc's own print (.rustc.mir) next to the printed row (.code_mir.txt,mimir_schema::code_mir::pretty). Example: rustc prints_0 = const LIMIT, the row prints_0 = const 4660_u64. -
Not compared, because rustc has no second print of them:
k_args,drop_key,st_callee, the drop state. -
Held by rustc and NOT in the row (counted by the check; Heimdall, before the
code_datarows): the register of an inline-asm operand (191 operands at 60 sites), the reason of an unwindterminate(422,387), the spread argument of arust-callbody (3,032 bodies), coroutine layout data (48 bodies). The target of a pointer constant into unnamed memory or a vtable (32,770 operands then) is named since thecode_datarows, and the check compares a pointer constant WITH its target: both sides print a digest of the allocation (size, alignment, mutability, the bytes with pointer bytes zeroed, each pointer's offset and target kind), one level deep (data_rows_checked). Not stored and not counted: source scopes, span ends, the borrow kind of a fake borrow, the payloads of coverage and fake-read statements.
8.5 The executor on a real program
Heimdall's heimdall binary (wasm host, cranelift, tokio, rayon):
| instance pass | 243,119 instances, 3,896 vtables, 765,223 resolved terms, 0 unresolved by rustc, 0 without a body, 143 foreign items; collector 15.5 s |
| read by the executor | instances 37,148,194 B, bodies scanned 167,023,922 B; 62,457 bodies loaded |
| reached | 217,286 instances; 53,319 switches narrowed by values |
| result | 208 boundary sites, 43,264 next pairs on the process entry, 88 possible system calls, 8 entries (1 process, 7 thread) |
| time, memory | load 2.96 s, execute 107.30 s; 1 min 50.79 s wall, 2,179,900 kB peak RSS |
| output | 459,486 B (ipc, 3 files) |
| notes | 22 raw doors, 14 unmapped C functions (blake3's assembly), 52 unmatched terms, 32 virtual calls without a target, 1 instance without a body; 2 missing body rows (semver, host-only in pass 1) |
| phases | none: main has no loop that reaches the boundary (said in exec.marks) |
The abcdf fixture: 12,060 instances, 4,410 reached, 42 sites, 1,687 pairs; load 0.26 s, execute 0.31 s.
8.6 Still to measure
Holger's scan worker, Gunnar's server and Iggy; the whole-workspace consume of workspace_nordisk (wall time, peak RSS, bytes per table); the possible set against the old widened set and against Heimdall's probe sets.
Reproduce
L=/home/rickard/lanes/exec-graph # the lane dir on oden
# sizes (a scratch tool with the driver's arrow-ipc and writer options)
$L/bin/crun m7 "$L/tgt/proof-measure/release/proof-measure $L/cmp/heimdall/out7 > $L/proof/sizes/out7.jsonl"
$L/bin/crun m8 "$L/tgt/proof-measure/release/proof-measure $L/fx/heimdall/out > $L/proof/sizes/out8.jsonl"
python3 $L/proof/measure/agg.py $L/proof/sizes/out7.jsonl $L/proof/sizes/out8.jsonl
python3 $L/proof/measure/derive.py $L/proof/sizes/out7.jsonl $L/proof/sizes/out8.jsonl
# dump times
$L/bin/crun t "$L/proof/time-run.sh all" # -> $L/proof/time/times.tsv
# the derived view against a schema-7 dump
cargo run --release --example legacy_check -- <schema-7 dump> <schema-8 dump> # in crates/mimir-driver
# every row against rustc, with 20 side-by-side bodies per unit
DRIVER_DIR=<dir of cargo-mimir-dump + mimir-driver> EXEC=1 \
crates/mimir-driver/scripts/verify-lossless.sh <workspace dir> <fresh out dir> 20:nornir-2026-11-06
DRIVER_DIR=<…> crates/mimir-driver/scripts/verify-lossless.sh --std <fresh out dir> 20:nornir-2026-11-06
# the lens gate: both doors on both dump sets, then the diff
bash $L/tmp/gate-run.sh && bash $L/tmp/gate-diff.sh
DUMP_ROOT=$L/gold8 LABEL=<name> NORNIR=<worktree> TGT=$L/tgt/lens8 bash $L/baseline/runtests.sh
9. Tests and gates
crates/mimir-schema/tests/code_mir.rs: the rows through Arrow and back, the derived view, content hashes, stable keys.crates/mimir-driver/tests/mir_facts.rs,exec_pass.rs: the lossless rows of a fixture (values, names, cleanup blocks, every statement), the instance pass (entries, a generic call, vtables, fn pointers, drop glue, the allocator, the boundary).crates/mimir-driver/examples/legacy_check.rs: the derived view against a schema-7 dump of the same sources, row for row.crates/mimir-driver/tests/mir_verify.rs,src/walk/verify.rs,scripts/verify-lossless.sh: every decoded row against a second walk of the rustc body (MIMIR_MIR_VERIFY=<dir>; off by default), and the proof that the check can fail.crates/mimir-schema/tests/code_mir_pretty.rs: the printed form of a row.crates/mimir-exec/tests/fixtures.rs(--ignored, needs the driver): six programs with a known order, executed on the graph and compared with a real run recorded with ptrace in the test:A { B C D }* F, generic + dyn, aDropthat closes a file, a panic / unwind path (the FULL graph against the real trace), a thread spawn, argument facts.crates/mimir-store/tests,crates/mimir-store-graphar/tests: one round trip per store.- The lens goldens: see §8.
10. What is NOT covered
- C, libc and assembly: the boundary map only (
mimir-ingest/src/boundary/libc_map.rs). A C function's control flow is not read; one the map does not know is a note and may make any call. dlopen, and a raw system call whose number is not a constant: notes.- Values in memory: value sets are per local and one field deep. A flag that travels through a struct or the heap is "any value" (
File::open's flags are). - Time across threads: the order graph is per entry.
- Two live futures of the same async fn on one thread outside a spawned task (
join!(c(1), c(2))): the mutual order of their own sites is not complete. - Signal handlers installed through a struct field (
sigaction), and constructors (.init_array) are not entries yet. code_datarows are not GLOBAL_ALLOCATOR-flagged (the attribute's static is not told apart), and a static whose initialiser rustc cannot evaluate has no row (data_static_failedinstats.json). The executor reads a row offset-insensitively: a load through a pointer into a row can be any pointer of that row.- Inline assembly: the register of each operand and the options are not stored; which operand is the system-call number cannot be read from the row (Heimdall: 60 sites). The site is at the boundary as
asm. - Not stored either (§8.4): the reason of an unwind
terminate, the spread argument of arust-callbody, coroutine layout data, source scopes, span ends. - By-move bodies of async closures have no pass-1 row; pass 2 writes them as instance bodies.
- A crate compiled only for the host in pass 1 (a build dependency) and for the target in pass 2 has no body rows (2 fns of
semverin Heimdall). - Proc macros and build scripts are programs of the build, not dumped.
11. Node ids for a run-time recorder
A node is <instance key>#<block>. A recorder cannot know either part; it joins through three facts every node row carries:
symbol rustc's v0 symbol of the instance compare DEMANGLED without hashes: rustc_demangle's {:#} form
callee the link name of the C function called the first non-Rust frame's symbol (`open64`, `write`, `syscall`)
file:line of the call the DWARF line of the return address in that Rust frame
A recorder (Heimdall's probe: seccomp USER_NOTIF or ptrace) emits per system call: thread id, sequence number in that thread, system call number and the recorded arguments, frame = the demangled v0 symbol of the nearest Rust frame below the C function, callee, file:line. The node is the row with that (symbol demangled, callee, line); line only picks among several calls of one C function in one instance. The recorder's next-call pairs per thread are then edges over node ids, to be laid on exec.edges of the entry that thread belongs to (the main thread: the process entry; a thread whose start frame is a spawned closure: that entry). The symbols of a release build carry other crate hashes than the dump's: that is why the join is on the demangled form. Heimdall is not changed by this work.