Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

docs/v2/mimir/exec-graph.md

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.

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:

  1. 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).

  1. The poll that returns is Ready (the discriminant fact of §5.2), so the poller's "pending, poll again" branch is not taken.

  2. 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 k no pair (x → f) is made with x a site only k can reach and f a first site of k only k can 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

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

  1. The derived view equals what the schema-7 driver wrote (§8.1, row 1). Tool: crates/mimir-driver/examples/legacy_check.rs.

  2. 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).

  1. 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.

  2. Not compared, because rustc has no second print of them: k_args, drop_key, st_callee, the drop state.

  3. Held by rustc and NOT in the row (counted by the check; Heimdall, before the code_data rows): the register of an inline-asm operand (191 operands at 60 sites), the reason of an unwind terminate (422,387), the spread argument of a rust-call body (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 the code_data rows, 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

10. What is NOT covered

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.