fixed tables: drop elision, ride the bounded payloads at their bound - #820
Draft
gafferongames wants to merge 1 commit into
Draft
gafferongames wants to merge 1 commit into
gafferongames wants to merge 1 commit into
Conversation
The project owner's ruling on the Schema Fixed Table wire (form 1): "I think
for fixed tables we should drop elision." / "Keep it simple. Fixed tables are
meant to be the fast equivalent of types, in table form."
internal/codegen/cpptable is the reference. A table the compiler derives as
FIXED (no pointer, map or unbounded array anywhere in its by-value closure)
now writes every declared field, whatever it holds, and rides `[..Max]T` of a
constant-width element and `bytes(N)` at their bound with zero padding behind
the count. A VARIABLE table keeps today's wire entirely, and so does a map's
generated ENTRY. The reader is untouched: padding lies between where a count,
length or `L` said the value stopped and where the field ends, which is what a
reader already steps over.
THREE OF THE RULING'S CASES ARE REFUSED BY THE WIRE, not by a choice, and are
stopped here with their reason in docs/SPEC-TABLES.md §3:
* `string(N)` / `wstring(N)` — kind 12 and kind 33 admit no zero byte among
their payload, and the one length they carry IS the character count, so
padding is either malformed or reads back as content.
* a union — a reader checks every set arm's `L` against the arm the
reference names, and `None`'s payload is the zero arm reference alone with
no `L` after it.
* an absent `?T` — presence on this wire IS the field riding.
Those three, plus the canonical LEB128 id references (one byte for a file's
first 127 ids, two for the next 16,256, and a reference's width is a property
of the FILE, not the type), are why the ruling's stated consequence does not
follow: a fixed body is BOUNDED and STABLE, not one constant size per type.
MeasureBody therefore stays a function. Measured on the paired corpus's 64
identical logical records, the body's spread narrows from 94 bytes to 56 and
does not reach zero.
BenchMixed loses its lookback conditional, per the owner's "that is a
disqualifying thing for a fixed table", keeping has_extra, extra and
idle_ticks as plain fields so both wires still carry the same logical data.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT. The rule landed for every construct that can carry slack. Three of the ruling's own cases are refused by the wire itself, and the ruling's stated consequence — one constant size per type — does not follow. That is the finding this PR is really carrying; Glenn's ruling on it decides whether the rest should land.
The decisions, as given
— the project owner, on the Schema Fixed Table wire (form 1), final form.
The rule, as implemented
A table the compiler derives as FIXED — no pointer, map or unbounded array anywhere in its by-value closure (
ir.VariableTables) — writes every declared field, whatever it holds, in declaration order. A VARIABLE table keeps today's wire entirely, and so does a map's generated ENTRY, which is only ever a body inside a variable table's wire. The class is the table's, not the file's: a fixed table nested by value inside a variable one still takes this rule.enum,flags,fixed/ufixed, 128-bit[N]T[..Max]T,Tof one constant widthMaxelements, the slack zero-filled[..Max]T,Ta table/enum/union/pointerMaxof a self-framed element is not a number the writer knowsbytes(N)Nbytes, the slack zero-filled[Enum]TNonestring(N),wstring(N)N(below)?TPadding is zero and no reader reads it: a count, a length or the enclosing
Lalways stands in front of it. This is the one writer-side exception to §3's "nothing is aligned and nothing is padded", and it is an exception in the SLACK only.STOPPED CASES — refused by the wire, not by a choice
1.
string(N)andwstring(N)cannot ride at their bound. SPEC-TABLES §3, kind12: "L, thenLbytes, WELL-FORMED UTF-8 with no zero byte among them"; kind33the same over code units. The generated reader enforces it —TableUtf8Validopens withif ( lead == 0 ) { return false; }. TheLa string carries is its character count; there is no second number a reader could trust.N − lengthbytes of slack are therefore either malformed (zero bytes) or read back as content. Padding needs a second length inside the payload, which is a READ-side change.2. A union cannot ride at the widest arm. Two separate refusals:
Lis checked against the arm the reference names — a scalar arm against that kind's exact width (if ( sub.size != W ) { … malformed; }), a body arm against its own terminator (if ( sub.offset != sub.size ) { … malformed; }), a string arm by reading the wholeLas text. An arm padded to a sibling's width is that arm's own framing damage.None's payload is the zero arm reference alone. §3: "arm id reference, and when it is not0,kind (u8),L, …". The reader doesif ( arm_ref == 0 ) { type = None; break; }without ever reading anL, so the field ends at that byte and the next bytes are the next field's header. There is nowhere to put padding.3. An absent
?Tcannot ride. Presence on this wire IS the field riding (§2.3) — the reader sets_present = truebecause the field arrived. There is no present flag for an absent optional to clear. It stays elided, the one elision a fixed body keeps.AND THE BODY IS NOT ONE CONSTANT SIZE
Beyond those three: every field header, every enum value and every union arm names its id by a reference — a canonical unsigned LEB128 index into the file's own first-use id table. A reference is one byte for the file's first 127 distinct ids and two for the next 16,256, and which one a given field takes is a property of the FILE the body sits in, not of the type. Canonical LEB128 forbids padding a reference to a fixed width (a non-minimal encoding is malformed), so the widths cannot be normalized without teaching the reader.
MeasureBodytherefore stays a function; noconstexprconstant is emitted. Emitting one would be a lie the corpus disproves. What did change is that a fixed table'sMeasureBodyno longer asks what the fields hold — it reads only the counts and lengths of the payloads that carry one, and a body of plain scalars reads nothing at all ((void) value;is emitted for exactly that reason).Measured, not argued. The paired corpus, 64 identical logical records, one table body each:
The spread narrows and does not reach zero.
BYTE ACCOUNT
Paired corpus (64 identical logical records; packet 438 bytes throughout):
Growth by cause:
idle_ticksnow rides)payload bytes(16), 8 used → 16Bound padding reaches exactly one field in this shape.
stats [..80]MixedStatandentities [1..8]MixedEntityare TABLE-element arrays (stopped case),player_name string(15)is a string (stopped case),loadout [4]uint8is already full width.C++ table conformance corpus, per pinned instance (old → new). The cost is concentrated in default-heavy records, which is where elision was earning its keep:
scalars_defaultkeyed_defaultprofile_elidechain_value_emptyroot_defaultkeyed_configv2_seamsv1_cfgloadout_fullwide_blobroot_fullarchivescalars_fullgraph_deepThe compiler's synthetic wide-table probes move 95 → 4710 (49.6x) and 181 → 8328 (46.0x).
CORPUS IDS
BenchMixedloses its lookback conditional;has_extra,extraandidle_ticksstay as plain fields, so both wires still carry the same logical data. The packet is still 438 bytes — the four new bits land in the tail padding afteralign— but its content moved, so the id moved.6b213fbfa1a03a99fd0748942dc6ae0907c57b4fa34b2700b5867287a028004dBenchMixed's worst case moves 3626 → 3630 bits and its pinned wire 3504 → 3508 bits, both inside 438 bytes.The four standalone packet benches (
bench/{cpp,c,go,cs}, BENCH-STANDARD.md) all nameBenchMixedthrough generated code and nothing else — no runner carries a field list, and every one reads the variant file blind at filesize/64. No runner source moves. What moves istestdata/wire/bench_mixed.bin,bench/corpus/variants/bench_mixed.variants.bin, the pins intest/bench/main.cpp, the vary mapping inbench/tools/variantgen/main.go, and the corpus id on every published row — rows on6b213fbfa1a03a99stop being divisible against rows on the new axis (BENCH-STANDARD §1.6, §5.3).GATES
Green:
go build,go vet,gofmt,make check,make shape-gate(the variantgen ratchet moved 94 → 96 for the twoIdleTicksreferences),internal/goldens(re-pinned),compiler/TestCppTableHeaderAndPut128Bytes(re-pinned viaSCHEMA_UPDATE_HEADER_GOLDEN=1), C++ table generation + compile clean under-Wall -Wextra -Werror -Wshadow, everytestdata/wire/tables/*.binre-pinned and save → load → save byte-stable for every pinned instance (the round trip is proven; no codec defect),SCHEMA_UPDATE_WIRE_GOLDENS=1 ./build/schema_test_bench,make bench-variants,./build/schema_test_bench_paired pinandverify.Red, C++ — 30 assertions in
test/tables/main.cppthat encode the elision contract, and three compiler tests with inline byte pins. These are not re-pinned in this PR on purpose: several of them ask questions the new rule re-answers and that answer is Glenn's, not mine. The clusters:wrote == empty_wire_bytes— an all-default table no longer saves the 10-byte empty wire (:374,:395,:2155,:2405,:3388,:3449,:5315,:5399)unknown/kind_mismatch(:426,:427,:452,:453,:484,:502,:523,:533,:545,:570,:590,:602,:5345,:5355)w_bare_optional > w_bare_value,w_ptr > w_value,!from_bare.link_present(:2309,:2316,:2324,:5406,:5415) — a semantic question for Glenn: with a default-valuedTnow riding, a bare value read by a build that declares?Treads as PRESENT, where it used to read absent. §2.3's "?T,*Tand a plain nesting are wire-identical" now cuts the other way.uint8_t buffer[64]too small for the new wire (:380,:382)compiler:TestTableClosureCountDefaults,TestCppTableRefOrdinalBytes,TestCppTableSaveMeasureCacheBytesRed, the other eight ports.
testdata/wire/is C++-pinned and byte-compared by every leg, so all of them go red until they mirror the emitter: the C, Go, Rust, C#, JS, Dart, Java and Elixir wire tests; the paired gate for C, Go and C# againstbench/paired/corpus/*; andmake generated-currentforgenerated/bench/{c,cs,go,paired/*}.Not run (they need the ports mirrored first, or are CI-only with no Makefile target):
build/schema_test_tables_asan,tables-wire-fuzz,conformance, the negative-control plan,generated-current, golangci-lint v2.12.2, modernize v0.23.0.EMITTER HOOKS FOR THE PORTS TO MIRROR
The C++ change is two files: the class predicate and flag in
internal/codegen/cpptable/cpptable.go(fixedNoElide,tableGen.noElide, andTableWriter::zeros), and every ride decision ininternal/codegen/cpptable/codecs.go(emitTableMeasureField,emitTableWriteField,emitKeyedSlotRides,emitUnionPayloadMeasure,emitUnionPayloadSave,emitArrayField, pluspadsAtBound/countedPad).internal/codegen/ctable/wire.go:557emitWireFieldHelper,:627wireKeyedRides,:642emitWireScalarLeafMeasure,:674emitWireWrite; element write atinternal/codegen/ctable/codecs.go:422internal/codegen/gotable/write.go:256emitFieldRides(the elision predicate),:188emitWireField,:50emitTableMeasure,:144emitTableWrite; the arithmetic-measure fast path at:34arithMeasureFieldEligibleand:95emitArithMeasureFieldassumes elision and needs the same gateinternal/codegen/cstable/wire.go:75leafRideCondition(the elision predicate),:179emitWriteBodyTyped,:252emitSaveTyped,:82emitCollectTyped(the id-interning pass, which must stop skipping defaulted ids); measure ininternal/codegen/cstable/measure.goRust, JS, Dart, Java and Elixir take the same shape in
internal/codegen/{rusttable,jstable,darttable,javatable,elixirtable}.WHAT I'D ASK GLENN
L— or do fixed tables live with bounded-and-stable?[..Max]Tandbytes(N)costs bytes and buys only the writer's branch removal. Keep it, or keep no-elision alone?T-read-as-present-?T(above) is a tolerance change that follows from no-elision. Intended?🤖 Generated with Claude Code