Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Dense static record shapes — performance experiment
Status: retained experimental implementation on compiler_optimizing, with
final integrated correctness and performance validation complete. This remains
a branch result, not a compatibility promise.
Objective and constraints
The target is a generally useful reduction in record allocation, GC traffic, and constant-key lookup cost without a JIT shape-transition system. Records remain semantically unordered, nullable assignment still means deletion, stone still freezes mutation, and class/record identity plus the opaque C word remain unchanged.
The experiment is memory-sensitive: Pit targets machines from modern hosts down
to old consoles. No per-record shape pointer or duplicate key table was added.
The existing record header is reinterpreted only while its I bit is set.
Representation
Generic records retain the old layout:
header: I=0, cap56=hash capacity mask
len, class_id, record_id, opaque
inline key/value hash slots
Shaped records use:
header: I=1, cap56=(runtime-local immutable shape descriptor pointer / 8)
len, class_id, record_id, opaque
inline dense PitValue values in canonical descriptor order
For three fields this is 56 bytes instead of the generic mask-7 record’s 144 bytes. The descriptor is allocated once in the runtime CT arena and shared by all actors and records with that key set. Its cost is 24 bytes of header plus 8 bytes per key on this 64-bit build. It contains CT/immediate text keys and is not scanned or rewritten by actor GC.
Shape identity is the canonical text-content-sorted key set, not source
construction order. {x, velocity} and {velocity, x} therefore share one
descriptor. Field value expressions still execute in source order; sparse
final-IR facts map each source store to its canonical dense offset. Long and
Unicode compile-time text keys are supported. An embedded U+0000 is the sole
v1 exclusion: that literal stays generic because both target descriptor bridges
are C-string based. Carrying an explicit length would grow every native key
entry from 8 to 16 bytes for a property-name form with negligible practical
use; generic record semantics remain unchanged.
Mutation policy
- Existing-key overwrite stays dense and uses the ordinary write barrier.
- Missing-key assignment of null remains a no-op.
- Existing-key deletion or missing non-null insertion converts that one record permanently to the generic representation.
- Conversion allocates an appropriately sized generic record, preserves class/record ID and opaque data, rehashes retained fields, and leaves the ordinary growth-forward pointer so every alias observes the replacement.
- No shape-transition graph is built in this version.
This policy keeps the common fixed-record case small without imposing a global transition system or changing dynamic-record semantics.
Compiler architecture
pit-compiler/record_shapes.cm is the sole selection/proof implementation. It
runs after final streamline mutation and emits sparse instruction-index facts;
semantic mcode is not changed. The proof requires a complete non-null constant
key set on one statically known construction path and currently selects hot-loop
or recursive constructions. It rejects duplicate/dynamic keys, nullable values,
unknown control, handlers, and observable escape before construction completes.
Both targets consume the same facts:
- Native emits one portable UTF-8 descriptor per canonical key set, allocates a
shaped record through the runtime helper, and initializes proven fields by
direct dense offsets. Generic field probes gained one
I-bit branch; shaped accesses without an exact-offset proof use the semantic C helper. - Mach serializes a pointer-free length-prefixed hexadecimal UTF-8 key spec. Code load interns the runtime-local descriptor once, rewrites constructor operands to compact descriptor ordinals, and executes Mach-internal shaped constructor/dense load/store operations. No semantic mcode opcode was added.
- Straight-line exact receivers can carry direct dense-load facts until a call, control join, aliasing operation, or key-set mutation ends the proof.
The diagnostic A/B lever is PIT_STREAMLINE_DISABLE=record_shapes.
The former native-only seeded generic-hash template and duplicate QBE shape recognizer were deleted. They represented the same decision twice, supported only short immediate keys at first, retained generic record size, and made Mach unable to share the optimization.
Correctness coverage
The native runtime fixture covers descriptor interning/accounting, long and
Unicode keys, exact object size, dense reads/writes, missing-null, enumeration,
GC relocation of values, add/delete conversion, stone disruption, preservation
of class/record ID/opaque data, and distinct runtime-local descriptors across
two PitRuntime instances. Compiler coverage checks canonical key-set sharing
and opposite source-store mappings. Mach coverage exercises construction,
reads, escape through an array, and shaped-to-generic transition.
Required performance gates
Results must be recorded for both enabled and the diagnostic-disabled build:
record_new: Mach/native time, allocation, GC count, instruction/code size.- Generic controls
record_fieldandd_field: no more than 2% median regression from the newI-bit branch/helper routing. - Record write/read and allocation microbenchmarks, plus shootout/macro rows to detect unrelated drift.
- Runtime shape descriptor count/bytes and actor heap peak/traffic.
Do not retain the optimization if allocation savings fail to produce useful end-to-end movement or if generic-record controls exceed the regression gate.
The composed pre-shape campaign baseline was record_new 33.01 ms Mach /
18.27 ms native, 10,505,170 Mach instructions, 42,204 KiB allocation, 65 GCs;
record_field was 109.89 / 53.30 ms. The predecessor native seeded-hash
template had already moved controlled native record_new 21.55 -> 14.04 ms
(-34.8%) and reduced generated IL/assembly, but left 42,205 KiB allocation and
Mach unchanged. Dense shapes must improve on that construction result rather
than merely rediscover it.
Final controlled three-arm result — 2026-07-14
The final measurement used one controlled matrix with identical sources and fixtures. Only the record-shape selector changed between arms:
- dense/default: dense static shapes and generic record templates enabled;
- generic-template:
--disable-passes dense_record_shapes; - no shape:
--disable-passes record_shapes.
Times are medians in milliseconds. Allocation is the benchmark actor’s reported allocation traffic, in KiB; GC is its collection count.
| fixture | arm | Mach ms | native ms | allocated KiB | GC |
|---|---|---|---|---|---|
record_new | dense/default | 21.52 | 18.93 | 16,423 | 20 |
| generic-template | 33.29 | 41.34 | 42,204 | 61 | |
| no shape | 43.21 | 60.87 | 42,204 | 63 | |
binarytrees | dense/default | 78.04 | 56.42 | 42,241 | 27 |
| generic-template | 83.81 | 59.65 | 52,707 | 38 | |
| no shape | 113.76 | 67.59 | 52,706 | 36 | |
record_template | dense/default | 34.91 | 32.49 | 26,729 | 35 |
| generic-template | 48.11 | 36.81 | 26,722 | 35 | |
| no shape | 62.96 | 43.77 | 26,730 | 42 |
The dense/default arm beats the generic-template arm on every timed row. The
largest result is record_new: Mach is 35.4% faster, native is 54.2% faster,
allocation falls 61.1%, and collections fall from 61 to 20. On
binarytrees, dense shapes improve Mach by 6.9% and native by 5.4% while
removing 19.9% of allocation traffic and 11 collections. On
record_template, they improve Mach by 27.4% and native by 11.7%.
record_template is the important retention/control case. Its allocation is
effectively unchanged across all three arms (an eight-KiB total spread), and
dense/default and generic-template both collect 35 times. The time win there
cannot be credited to less allocation or fewer collections: it is
direct evidence for cheaper shaped construction/access. Conversely,
record_new and binarytrees show that the same representation also pays the
expected memory/GC dividend when many compact records are live or churned.
The generic record path stayed neutral. The aggregate median across the
three record_field pairs was 116.73 ms Mach / 53.54 ms native with dense
shapes and 116.12 / 53.36 with generic templates: +0.53% / +0.34%, inside the
2% regression gate. Thus the win did not come from globally changing generic
record read/write behavior.
For composed-branch attribution, compare record_new with B0 (33.01 ms Mach,
18.27 ms native, 42,204 KiB, 65 GC), rather than treating the synthetic
no-shape arm as the historical executable. Dense/default is 34.8% faster in
Mach, 3.6% slower in native (rough parity), allocates 61.1% less, and reduces
collections from 65 to 20. The strong result worth retaining is therefore the
Mach and memory/GC improvement with native performance preserved; the
controlled selector matrix separately proves the dense lowering itself is
substantially faster than either fallback generated by the final compiler.
No semantic mcode opcode was added. Shape selection is final-IR metadata
consumed by Mach and QBE; a shaped record uses the existing record header’s
I bit and multiplexes cap56 between a shifted shape pointer (I=1) and
generic capacity (I=0). Adding or deleting a field converts one-way to the
generic representation. This keeps the per-record memory win without adding
another header word or penalizing records that cannot keep a static shape.
Final integrated canary and disposition
The later clean-daemon campaign canary found a real tradeoff that the first three-arm run did not expose:
| arm | binarytrees Mach/native ms | allocation | GC |
|---|---|---|---|
| dense/default | 74.79 / 47.25 | 43.25 MB | about 27 |
| generic-template | 83.63 / 44.86 | 53.96 MB | about 38 |
| no shape | 80.37 / 51.66 | 53.96 MB | about 36 |
| generic-template repeat | 80.34 / 44.73 | 53.96 MB | about 38 |
Dense/default is repeatably about 5% slower native than generic-template on
this allocation-heavy workload. It is still 8-10% faster in Mach, about 10%
faster native than no shapes, and removes roughly 20% of allocation traffic.
The mechanism remains retained because the representation benefit is general:
a common three-field record is 56 rather than 144 bytes, the shared descriptor
is only 24 + 8 * key_count bytes once per runtime shape, and generic records
pay no extra per-instance word. The native binarytrees gap is an explicit
optimization target; it is not evidence for adding a larger shape-transition
system or abandoning the density win.
Final branch validation covered the runtime capacity/GC fixture in the 7/7 Meson run, 145/145 focused compiler checks, 1,086/1,086 isolated VM checks, 1,921/1,921 warmed full-suite checks, and 3,733/3,733 deterministic fuzz checks over 500 programs. The final 68-row aggregate returned exact Mach/native oracles for every row. No nursery allocation was active in these measurements.
Isolated persistent shaped-record access — 2026-07-14
Two permanent microbenchmarks now separate access from construction and GC:
shaped_runtime_readconstructs one three-field record, then performs five million constant-keyvelocityreads into a loop-carried checksum;shaped_runtime_writeconstructs the same record, then performs five million non-null existing-key writes and returns a three-field readback.
The literal is placed in a one-trip syntactic loop so the current hot-site
selector creates a dense record before the timed loop. There is no allocation
in either timed loop and every arm collected zero times. Exact receiver
identity does not currently survive the timed loop’s backedge, so these
rows deliberately measure the shaped-record runtime fallback, not compiler-
proven direct-offset loads/stores. The names say runtime to preserve that
distinction.
Times below are milliseconds, each the harness median of seven executions. Dense/default and generic-template are the medians of three separate clean- daemon harness invocations; no-shape is one clean-daemon invocation.
| fixture | arm | Mach ms | native ms | actor allocation B | Mach instructions |
|---|---|---|---|---|---|
shaped_runtime_read | dense/default | 83.67 | 56.96 | 624 / 752 | 55,000,046 |
| generic-template | 79.53 | 64.71 | 712 / 840 | 55,000,046 | |
| no shape | 78.58 | 64.46 | 712 / 840 | 55,000,046 | |
shaped_runtime_write | dense/default | 75.67 | 46.55 | 624 / 768 | 45,000,067 |
| generic-template | 72.29 | 51.86 | 712 / 856 | 45,000,067 | |
| no shape | 72.34 | 50.52 | 712 / 856 | 45,000,067 |
The allocation column is Mach / native; the only representation-dependent
difference is exactly 88 bytes, the expected 144 - 56 saving for the one
record. Native shaped fallback is 12.0% faster for reads and 10.2% faster for
writes than generic-template. Mach is instead 5.2% slower for reads and 4.7%
slower for writes. This is not dispatch-count movement: the Mach instruction
census is identical in every arm.
The lowering explains the split. QBE contains $pit_record_template_0 and
calls __new_record_template_ss, proving that the instance is dense, but the
hot access is still __load_field_ss -> pit_rt_load_field_lit; there is no
fixed-offset emitted load. Native’s ordinary literal helper computes a generic
record probe on every access, while the three-entry shaped descriptor can be
searched cheaply. Mach’s fused constant-field path already carries a
precomputed generic key hash, so its generic table probe is slightly cheaper
than linearly searching the descriptor. The direct-offset lowering is selected
only for point-local accesses immediately following construction today.
Consequently, shapes are useful beyond allocation in native code, but these rows also identify the next concrete shape lever: persist exact shape identity through non-escaping loop-carried values, or add a target-private guarded shape/offset specialization. That proof must be benchmarked separately; these results must not be cited as evidence that it already exists.
Nbody diagnosis and cold-shape falsification — 2026-07-14
nbody is not primarily a local-arithmetic benchmark in Pit. The five bodies
are seven-field records stored in an array, and 50,000 steps execute 500,000
unordered pair interactions. A source and profile census gives approximately:
- 10,500,146 record-field loads (18 hot pair-loop sites, six hot position-loop sites, and 146 cold executions);
- 3,750,000 record-field stores;
- 1,000,000 array loads;
- 15.5 million arithmetic operations; and
- 500,000
sqrtcalls.
Thus most numeric operands cross a generic heap-record boundary immediately
before use. Point-specific numeric facts and raw QBE lanes make arithmetic on
known local values fast, but a generic record load materializes an unknown
tagged PitValue. The subsequent operation must branch on/decode its numeric
representation, and the value often fails to remain raw across the loop. The
OCaml port instead has a statically typed mutable all-float record, C# has
declared double fields, and tracing runtimes can specialize the observed
layout and field types. Those implementations perform direct-offset raw-double
loads and stores in the hot loop. This semantic lowering difference, rather
than GC, explains why the scalar arithmetic microbenchmarks do not predict the
nbody result.
A clean ordinary harness invocation measured:
| arm | Mach ms | native ms | Mach instructions | actor allocation B (Mach/native) | GC | QBE IL lines |
|---|---|---|---|---|---|---|
| accepted selector | 260.08 | 156.36 | 119,501,799 | 2,440 / 2,432 | 0 / 0 | 40,157 |
The benchmark allocates only its initial bodies and support objects. The hot
loop allocates nothing and collects nothing, so nursery or collector tuning
cannot materially close this gap. The existing native_call microbenchmark
also puts 500,000 native calls at about 8.39 ms; even treating that as a rough
upper bound makes sqrt only a modest part of the 156 ms native result.
The real CLI PGO collector recorded all 46 instrumented record-load sites and 10,500,146 executions, but zero shape targets. The profile was 9,781 bytes persisted and used 16,324 bytes peak while collecting. This is not a PGO storage failure: the current selector only makes dense shapes for construction sites it already considers hot (or for narrow recursive-safe cases), while the five bodies are constructed once before the hot access loop. PGO can only name a dense shape target, so generic records are invisible to the current shape-offset consumer.
To measure the ceiling without retaining a policy change, one selector line was temporarily changed so otherwise-eligible cold fixed-key literals could be dense. The compiler was reseeded, the benchmark and PGO collector were run, and the line and boot artifacts were then restored. The result was negative:
| diagnostic arm | Mach ms | native ms | Mach instructions | actor allocation B (Mach/native) | GC | QBE IL lines |
|---|---|---|---|---|---|---|
| force cold eligibility | 278.07 | 175.93 | 119,501,798 | 2,256 / 2,248 | 0 / 0 | 39,769 |
Mach regressed 6.9% and native regressed 12.5%. The exact 184-byte allocation reduction is one seven-field generic record becoming dense; only one of the five literals passed the remaining exact-construction/non-null proof. Forced PGO confirmed the same partial result: 32 sites acquired one target, but hot sites saw it for only 200,000 of 500,000 executions (the one shaped body’s 40% share). There were 2,100,029 target observations in total; the persisted profile grew to 13,722 bytes and collector peak to 19,428 bytes. This partial, runtime-fallback shape made access slower and did not meet the coverage needed for a useful direct-offset specialization. Broadly shaping cold literals is therefore rejected, not retained as an optimization.
The next nbody work should be staged so each lever is independently
falsifiable:
- Prove one stable exact shape for every element of the non-growing
bodiesarray, carrying it throughbodies[i]and loop backedges. This must make all five records dense and all hot sites monomorphic before PGO is credited. - Emit guarded/direct shape-offset loads and stores so shaped access no longer calls the generic literal-field helper. Measure this separately from record density; the forced fallback experiment shows density alone loses here.
- Carry the proven/PGO-observed numeric field type into persistent raw float lanes. A more aggressive typed numeric record layout could store raw doubles directly, but changing record representation/degradation rules is a runtime design decision rather than a compiler-only consequence.
- Revisit native loop registerization with a pressure/cost model. The current conservative candidate cap, introduced to protect other shootout programs, is liable to reject this 34-slot float-heavy loop wholesale.
- Mark the exact linked
math.sqrtleaf as no-allocation/no-callback and test a direct intrinsic lowering. This is useful but cannot explain or recover the order-of-magnitude field-boundary cost by itself.
For Mach, direct shape/PIC field access and fused numeric guards can still remove helper work, but a register-style persistent-unboxing layer is mainly a native lever. The decisive shared requirement is no longer faster arithmetic: it is preserving layout and numeric facts across array/record reads so the hot arithmetic can actually consume raw values.
Source: plans/archive/perf-2026-07/perf-record-shapes.md