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_field and d_field: no more than 2% median regression from the new I-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.

fixturearmMach msnative msallocated KiBGC
record_newdense/default21.5218.9316,42320
generic-template33.2941.3442,20461
no shape43.2160.8742,20463
binarytreesdense/default78.0456.4242,24127
generic-template83.8159.6552,70738
no shape113.7667.5952,70636
record_templatedense/default34.9132.4926,72935
generic-template48.1136.8126,72235
no shape62.9643.7726,73042

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:

armbinarytrees Mach/native msallocationGC
dense/default74.79 / 47.2543.25 MBabout 27
generic-template83.63 / 44.8653.96 MBabout 38
no shape80.37 / 51.6653.96 MBabout 36
generic-template repeat80.34 / 44.7353.96 MBabout 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_read constructs one three-field record, then performs five million constant-key velocity reads into a loop-carried checksum;
  • shaped_runtime_write constructs 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.

fixturearmMach msnative msactor allocation BMach instructions
shaped_runtime_readdense/default83.6756.96624 / 75255,000,046
generic-template79.5364.71712 / 84055,000,046
no shape78.5864.46712 / 84055,000,046
shaped_runtime_writedense/default75.6746.55624 / 76845,000,067
generic-template72.2951.86712 / 85645,000,067
no shape72.3450.52712 / 85645,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 sqrt calls.

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:

armMach msnative msMach instructionsactor allocation B (Mach/native)GCQBE IL lines
accepted selector260.08156.36119,501,7992,440 / 2,4320 / 040,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 armMach msnative msMach instructionsactor allocation B (Mach/native)GCQBE IL lines
force cold eligibility278.07175.93119,501,7982,256 / 2,2480 / 039,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:

  1. Prove one stable exact shape for every element of the non-growing bodies array, carrying it through bodies[i] and loop backedges. This must make all five records dense and all hot sites monomorphic before PGO is credited.
  2. 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.
  3. 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.
  4. 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.
  5. Mark the exact linked math.sqrt leaf 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