Skip to content

Null pointer optimization - #555

Closed
gabriel-barrett wants to merge 6 commits into
mainfrom
null-pointer-optimization
Closed

Null pointer optimization#555
gabriel-barrett wants to merge 6 commits into
mainfrom
null-pointer-optimization

Conversation

@gabriel-barrett

Copy link
Copy Markdown
Member

Implements niche optimization. Memory pointers now start at 1 and are never 0.
Lists are now a bit more efficient:

enum List<A> {
  Nil,
  Cons(&(A, List<A>))
}

is a single number, 0 being Nil.

@argumentcomputer argumentcomputer deleted a comment from argument-ci-bot Bot Aug 12, 2026
@argumentcomputer argumentcomputer deleted a comment from argument-ci-bot Bot Aug 12, 2026
Memory pointers now start at 1: the executor, the codegen template, the
trace builder, both Lean evaluators, the --interp interpreter and the
BigUint limb-chain builders all mint insertion-index + 1 and dereference
ptr - 1 (0 never binds). The memory circuit enforces it with a new
constraint, IsFirstRow * is_real * (ptr - 1): together with the
existing increment chain, real-row pointers are exactly 1..height, so 0
becomes the IMPOSSIBLE pointer.

This reserves the value 0 as a null-pointer niche for enum layouts (a
pointer-typed field can never legitimately hold 0), which the follow-up
null-pointer optimization builds on.

Codegen regenerated. Kernel FFT pins drift microscopically (~+0.001%:
the kernel byte-decomposes some pointer values, and every pointer
shifted by one) - all 71 pins and the shard aggregate re-measured.
aiur (cargo + prove suite incl. interpreter parity), ixvm, multi-stark
and recursive-verifier suites pass; fmt, clippy, deny clean.
A datatype with exactly two constructors, one nullary and the other
carrying a top-level pointer field, drops its tag slot: pointers are
1-based (0 is pinned impossible by the memory circuit), so the pointer
field doubles as the discriminant. The nullary constructor is the
all-zero vector; the payload constructor is its bare fields. Nat-likes
shrink 2 -> 1, list nodes lose a column everywhere (ListNode<U64>
10 -> 9, ByteStream cons 3 -> 2, RBTreeMap nodes, ...).

Matches on niche types dispatch on the pointer slot: the nullary arm is
case 0, the payload arm is the DEFAULT arm - its inequality witness
against 0 exists precisely because real pointers are nonzero - and a
wildcard or explicit default covers whichever constructor is not
listed (case 0 with a payload arm present; dead and NOT compiled when
both are). The not-compiled part matters: a compiled-but-discarded arm
still consumes a selector index, desynchronizing stored selector ids
from the layout's selector count (caught by rbtree_map_balance_fix,
whose fallthrough patterns hit exactly that as a ColumnOutOfRange at
system build).

Implemented in the layout pass (hasPointerNiche + niche slot on
ConstructorLayout), lowering (construction, match dispatch,
match-level arm classification via NicheMatchCtx), the Source
flatten/unflatten mirrors (also fixing latent tagless mis-unflatten),
and the flat-level BigUint limb-chain builders on both the Rust and
Lean sides (memory[10] -> memory[9], Nil = zeros, walk ends at
next_ptr == 0). Value-level evaluators are layout-transparent and
unchanged. Codegen regenerated (kernel shrinks ~5%); all 71 kernel FFT
pins and the shard aggregate re-measured (0-1.2% cheaper).

aiur (cargo + prove suite incl. divmod hints and parity), ixvm,
multi-stark and recursive-verifier suites pass; fmt, clippy, deny
clean.
A niche match listing the payload constructor with no nullary arm and
no default would otherwise route the nullary value (niche slot 0) into
the payload default arm and execute it with garbage bindings — partial
matches are legal (there is no exhaustiveness check; tagged layouts
surface MatchNoCase at runtime). Such matches now get a synthesized
case-0 arm asserting 0 = 1: a runtime error mirroring MatchNoCase, and
an unsatisfiable arm in-circuit.

Codegen regenerated; kernel FFT pins re-measured (+0.01..+1.4% from
the extra arms on partial matches in the kernel/verifier sources).
Prove-suite cases for the niche layout: construction of both variants,
both match directions, the wildcard-covers-nullary form (compiles to
case 0), non-tail matchContinue dispatch, a pointer field at nonzero
offset (NichePair, niche slot 1), and Nil-crossing recursion on the
width-1 extreme (PNat: the value IS the bare pointer).
@gabriel-barrett
gabriel-barrett force-pushed the null-pointer-optimization branch from ab615ca to 0ec1526 Compare August 12, 2026 21:57
The list VALUE is now one field element: 0 IS Nil (the impossible
pointer), anything else points at the (head, tail) cell. No tag slot,
no stored Nil node, and every traversal's base case is a plain field
match instead of a memory load — across the kernel and the recursive
verifier, list emptiness checks no longer touch the memory channel.

Sweep of ~1,400 sites across 28 files: store(ListNode.Nil) -> List.Nil
(now free), store(ListNode.Cons(x, r)) -> List.Cons(store((x, r))),
match load(l) -> match l with the load moved into the Cons arm, and the
deref-let uncons idiom updated. The unconstrainedBigUintDivMod contract
follows: the typing rule takes the niche list datatype by value (no
longer a pointer), and the flat-level builders on both sides
(read/build/find_klimbs_u64, BytecodeEval's limb chains) plus the
value-level mirrors (SourceEval, Interpret — Cons/Nil now identified
by shape, not declaration order) build 0-terminated chains with
(limb, tail) cells and no Nil row. The test toplevel's BNode migrates
to the same shape.

Codegen regenerated; all 71 kernel FFT pins and the shard aggregate
re-measured (all down 0.0-0.3% — the proxy is blake3-dominated; the
structural win is the removed per-traversal load and the deleted Nil
rows per memory width).

aiur (cargo + prove suite incl. divmod hints and parity), ixvm,
multi-stark and recursive-verifier suites pass; fmt, clippy, deny
clean.
@gabriel-barrett
gabriel-barrett force-pushed the null-pointer-optimization branch from 0ec1526 to 40596b4 Compare August 12, 2026 22:13
Same treatment as List: a Layer value is ONE field element — 0 is Nil,
anything else points at the (previous layer, digest) cell. No stored
Nil node, the layer parameters of the blake3 chunk folds
(blake3_compress_chunks/_block/_finish, b3_io_chunks, b3_lane_chunks,
b3_rows_chunks) pass the bare value, and the per-layer dereference in
blake3_compress_layer / blake3_next_layer moves into the Push arm.

Codegen regenerated; all 71 kernel FFT pins and the shard aggregate
re-measured (all down 0.0-0.13%). aiur-prove, ixvm (pins + parity),
multi-stark and recursive-verifier suites pass; fmt, clippy, deny
clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant