Skip to content

Read path: empty runs cost nothing, InfiniSegment charged what it allocates, one ReadWords (#329) - #338

Merged
mattlorimor merged 5 commits into
masterfrom
fix/329-read-path-leftovers
Nov 6, 2026
Merged

mattlorimor merged 5 commits into
masterfrom
fix/329-read-path-leftovers

Conversation

@mattlorimor

Copy link
Copy Markdown
Owner

Part of #329: parts (1)–(3). Part (4) is in #333.

These are three read-path leftovers, per the coordinator's plan on #329, approved by Matt.

(1) An empty run costs nothing

ReadBytes now returns Array.Empty for an empty run, whether the payload is held or streamed, and charges nothing. PriceBytes matches.

Charges that moved (measured with a probe that reads every budget sample, an empty instance of each structure, and empty-key cases):

  • DDSketch, held: a new sketch 264 → 216 bytes; the budget sample 480,072 → 480,048.
  • TopK, HeavyKeeper and VarOpt holding an empty key or item: 24 bytes less for each empty run, in both forms, and 24 bytes less allocated from the large form.
  • Nothing else moved.

C16's sweeps, TestReadBudget and TestCapacityDocumentation all stay green.

Red: TestAnEmptyRunIsTheEmptyArrayAndCostsNothing failed with "a run of 0 read from a held payload was charged 24 bytes". It also holds a one-byte run to 32 bytes, so a cost that is always zero fails too.

(2) The InfiniSegment undercharge was real

Measured: a segment is 80 bytes beyond its table at every shape tried, against SmallObject's 64. SegmentCost now charges it as two small objects.

Red: TestASegmentIsChargedWhatRestoringItAllocates failed with "allocated 120 bytes to restore, and a read is charged 104". Only this test catches it, because the read budget's slack hid it. No documented figure moved.

(3) One ReadWords

Infini and Cuckoo now both call PayloadReader.ReadWords(Span<ulong>), with no change in behaviour. The overload is byte-identical to the one in #328's PR, and a trial merge both ways was clean.

Mutation

24 killed and 1 survivor, the same equivalent buffer cut as in #289. The table is in f9736ad's message.

Verification

  • Clean Release build with -warnaserror: exit 0.
  • Full suite: 2,504 passed, 0 failed, run by the author and again by the coordinator in a clean clone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx

mattlorimor and others added 5 commits November 5, 2026 19:33
…ocate (red) (#329)

Two tests, one for each of #329's first two parts.

TestAnEmptyRunIsTheEmptyArrayAndCostsNothing (TestPayloadReader) reads
an empty run with PayloadReader.ReadBytes, from a held payload and from
a stream, and asks for the runtime's empty array, charged, priced and
allocated at nothing, counted in a no-GC region. A run of one byte is
held the other way, to an array of its own at 32 bytes, so a cost of
nothing for every run fails too.

Red on 05a5669, at the first check:

  a run of 0 read from a held payload was charged 24 bytes

Probed on 05a5669, every figure the test asks for:

                       charged  allocated  empty array  priced
  empty, held               24          0  yes              24
  empty, from a stream      24         24  no               24
  one byte, held            32         32  no               32
  one byte, from a stream   32         32  no               32

TestASegmentIsChargedWhatRestoringItAllocates (TestInfiniFilter) holds
InfiniFilter.SegmentCost, which the reader charges and the price prices
for each table, to what InfiniSegment.Restore allocates for the same
shape, counted in a no-GC region after one uncounted restore: at least
that, and less than a small object (64 bytes) past it. Red on 05a5669,
at the narrowest table:

  a segment of 2^2 slots of 2-bit fingerprints allocated 120 bytes to
  restore, and a read is charged 104 for it

The table is 40 bytes of that, so the segment is 80 against the 64 of
ReadBudget.SmallObject. Probed on 05a5669 at tables of 2^4, 2^10 and
2^16 slots, the segment beyond its table is 80 bytes at each.

For the second test to compile, SegmentCost goes from private to
internal here; nothing else in the library changes in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
PayloadReader.ReadBytes charged ReadBudget.ArrayOf(0, 1), an array's
header of 24 bytes, for an empty run: from a held payload, where the
span it copies out is the runtime's empty array and nothing is
allocated, and from a stream, where it allocated new byte[0]. PriceBytes
priced the same 24, so price and charge agreed on a cost the read did
not have (held) or need (from a stream).

Both now go through CostOfBytes, nothing for an empty run and the array
otherwise, and from a stream an empty run is Array.Empty<byte>(). The
held path is unchanged but for the charge. TestAnEmptyRunIsTheEmptyArray-
AndCostsNothing passes: charged, priced and allocated nothing and the
runtime's empty array from both, and a run of one byte 32 from both.

Which charges move, measured by a probe (not committed) that read, at
05a5669 and here, every budget sample (TestReadBudget.Samples) and an
empty instance of each structure in the empty round-trip sweep, held and
from the large form, plus a TopK, a HeavyKeeper and a VarOpt holding an
empty key or item:

                                 held charge      large charge   large alloc
  new DDSketch                   264 -> 216       288 (same)     592 (same)
  DDSketch budget sample     480,072 -> 480,048   same           same
  TopK, one empty key        436,640 -> 436,616   110,064 -> 110,040   109,904 -> 109,880
  HeavyKeeper, one empty key   3,720 -> 3,696     2,448 -> 2,424       2,488 -> 2,464
  VarOpt, one empty item         840 -> 816         856 -> 832         1,032 -> 1,008

24 bytes an empty run, everywhere one is read through ReadBytes, and
nothing else moved: every other sample and empty structure is charged as
it was. The DDSketch's large-form figures do not move because its runs
there are begun by BeginWords, which reads from a stream in pieces and
does not go through ReadBytes.

TestReadsAllocateOnlyWhatTheyHold (C16's sweeps), TestReadBudget,
TestPayloadReader, TestLargeEnvelope*, TestBuckets64 and
TestCapacityDocumentation all pass with it: 379 of 379.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
InfiniFilter.SegmentCost, which the reader charges and the price prices
for each table, was the table's words and ReadBudget.SmallObject, 64
bytes, for the segment. An InfiniSegment declares four ints, five 64-bit
fields and a reference, 64 bytes of fields and a 16-byte header, and
measured in a no-GC region it is 80 bytes beyond its table at every
shape probed (tables of 2^4, 2^10 and 2^16 slots, and 2^2 in the test):
16 bytes a table under what a read allocates, by construction, at every
table. So the charge was not the upper bound it is meant to be, though
the slack of every read-budget sample absorbed it, as #329 said it
would.

The segment is now charged as two small objects, 128 bytes, the bound
NestedChecksumCost already uses for two objects. The reader and the
price both call SegmentCost, so price and charge still come to one sum.
TestASegmentIsChargedWhatRestoringItAllocates passes: at each shape the
charge is at least what restoring the segment allocates, and 48 bytes
past it, short of a small object.

Each read of an InfiniFilter is charged 64 bytes more a table; no
figure in the README or CAPACITY.md moves (TestCapacityDocumentation
passes), and the charge line slopes are a table's words, as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
#329)

InfiniFilter.ReadWords (#313) and CuckooBloomFilter.ReadWords (#230)
were the same method: read as many words as a span holds straight into
it, in pieces through a stack buffer of PayloadReader.BlockPieceFor
(16 KiB) with ReadBlock. Both are replaced by
PayloadReader.ReadWords(Span<ulong>), whose body is theirs, with no
change in what is read, allocated or charged. InfiniFilter no longer
needs System.Buffers.Binary.

The overload, and the two crefs that name the existing ReadWords() and
would otherwise be ambiguous with it (CS0419 under -warnaserror), are
written exactly as #328's branch writes them, where the quotient
filter's reader takes them up, so that the two branches add the same
lines and merge either way round.

TestInfiniFilter, the cuckoo tests, TestReadBudget, TestLargeEnvelope-
Reading (its Infini pieces test among them), TestReadsAllocateOnlyWhat-
TheyHold and TestPersistence*: 642 of 642 pass.

Mutation of #329's three parts, scripts/mutate.sh, MUTATION_JOBS=2, at
this commit's tree (5752769, before this table was added to its
message), whole suite unless a test is named in brackets:

  (1) cost: empty run charged as an array    killed (1)
  (1) cost: condition flipped                killed (61)
  (1) cost: bound shifted to one byte        killed (2)
  (1) stream: new byte[0] for an empty run   killed (1)
  (1) stream: empty-array condition flipped  killed (45)
  (1) held: charged as at 05a5669            killed (2)
  (1) stream: charged as at 05a5669          killed (1)
  (1) price: priced as at 05a5669            killed (2)
  (1) price: priced nothing                  killed (59)
  (2) segment: one small object (05a5669)    killed (1)
  (2) segment: three small objects           killed (1)
  (2) segment: no small object               killed (1)
  (3) words: pieces of one word              killed (1)
  (3) words: buffer not cut to the run       SURVIVED (all 2,504)
  (3) words: last piece read whole           killed (17)
  (3) words: last piece not read             killed (67)
  (3) words: last word of a piece dropped    killed (51)
  (3) words: read big-endian                 killed (61)
  (3) infini: table not read                 killed (43)
  (3) cuckoo: occupancy not read             killed (47)
  [empty run] held charged as at 05a5669     killed (1)
  [empty run] stream new byte[0]             killed (1)
  [segment] one small object (05a5669)       killed (1)
  [segment] three small objects              killed (1)
  [infini pieces] pieces of one word         killed (1)

(1) mutates CostOfBytes' condition (dropped, flipped, its bound moved
to a run of one byte), the stream path's choice of the empty array
(dropped, flipped), and each of the three places it is charged or
priced, put back to ReadBudget.ArrayOf. The bound moved to one byte is
what the test's run of one byte is for; the empty array dropped from the
stream path allocates 24 bytes it is not charged for, and only the new
test sees either that or the held charge put back ([empty run]).

(2) mutates SegmentCost's count of small objects: one, as at 05a5669,
fails only the new test, since the read-budget slack absorbs 16 bytes a
table, which is why it went unnoticed; three fails its bound from above.

(3) mutates the shared overload, now reached through Infini and the
cuckoo filter, and drops each caller's read. The Infini pieces test
alone kills one-word pieces ([infini pieces]), so the overload is held
there as #313 held its own copy. The survivor is the one #289 and #313
adjudicated: the loop cuts every piece to Math.Min(left, piece.Length),
which is Math.Min(left, BlockPieceBytes) whether or not the buffer was
cut to the run, since left never exceeds the run, so the same bytes are
read in the same calls; only stack use differs, which no test sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
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