Skip to content

Charge an empty run of words nothing, and a quotient filter's read what building it allocates (#336) - #341

Merged
mattlorimor merged 5 commits into
masterfrom
fix/336-read-charge-leads
Nov 6, 2026
Merged

mattlorimor merged 5 commits into
masterfrom
fix/336-read-charge-leads

Conversation

@mattlorimor

Copy link
Copy Markdown
Owner

Fixes #336.

Both leads in #336 were real, and both are fixed, following the coordinator's plan on #336, approved by Matt.

Part 1: an empty run of words is charged and priced nothing

BeginWords' streamed path charged an empty run 24 bytes, an array's header, for a buffer that is the runtime's one empty array. SkipWords' price did the same. Both now use CostOfBytes, the rule #338 gave ReadBytes, so an empty run costs nothing and every other run costs what it did. Price and charge still move together.

Which charges move, measured as the least of 7 runs in NoGCRegion:

  • Only CountSketch, DDSketch and InvertibleBloomLookupTable call these methods. Of those, only DDSketch writes an empty run of words: a store with no buckets.
  • A new DDSketch read from the large form is charged 240, not 288, for its two empty stores. One holding values of only one sign is charged 24 less (4,000, not 4,024).
  • What those reads allocate doesn't change (592 and 4,352).
  • Ordinary-envelope reads don't move. Neither do CountSketch and InvertibleBloomLookupTable: their readers refuse an empty run, so every payload they read has a word in each run.

Test: TestPayloadReader.TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing.

  • It reads a run of 0 bytes and a run of one word, from a held payload and in the large form, measured in a no-GC region.
  • It holds the charge, the allocation and the price to the same cost: 0 for the empty run, 32 for one word. The one-word run guards the other direction.
  • Red on 6ce3fe1: "a run of 0 bytes of words read in the large form was charged 24 bytes".

Mutation (scripts/mutate.sh, full suite each): 7 of 8 killed.

Mutant Result
BeginWords' charge back to ArrayOf killed, 2 tests
SkipWords' price back to ArrayOf killed, 2 tests
CostOfBytes charges an empty run killed, 2 tests
CostOfBytes charges nothing for any run killed, 62 tests
A new buffer for an empty run killed, 1 test (the new one)
== 0 → <= 0 survived: equivalent, since no caller passes a negative count
== 0 → != 0 killed, 62 tests
== 0 → == 1 killed, 3 tests

Part 2: QuotientFilter's read is charged what building its filter allocates

Measured first, as the least of 7 runs in NoGCRegion at seven shapes: 2, 4 and 256 slots, 2^16, 2^20, and the one-slot version-1 table at two remainder widths. The numbers matched on 6ce3fe1, after part 1, and under the segments GC with 3 processors.

  • Build, which a read uses to make its filter, allocates 136 bytes beyond the table at every shape. That is the filter's own 96 bytes, plus a 40-byte table of two words, which the constructor it goes through (new QuotientFilter(1, 0.5, hash)) makes and the read then replaces.
  • The charge was one small object, 64 bytes, so a read was charged 72 bytes less than it allocated. The budget's slack hid it.

Fix: TableCost now charges the table plus three small objects, 192 bytes: at least the 136, and within one small object of it. InfiniFilter's and MementoFilter's segment tests hold the same "within one small object" rule. The reader and the price both use TableCost, so they still agree. A read is charged 128 bytes more than before.

Test: TestQuotientFilter.TestAReadIsChargedWhatBuildingItsFilterAllocates.

  • Five shapes. Each asserts that building allocates no more than TableCost, and that TableCost is less than one small object above it.
  • Build and TableCost are now internal so the test can reach them.
  • Red: "a filter of 2 slots of 1-bit remainders allocated 176 bytes to build for a read, and a read is charged 104 for it".

Mutation: two, one or four small objects. All three killed, each by the new test only.

Docs

Interaction with #335

#335 (in progress) moves CountSketch, DDSketch and InvertibleBloomLookupTable to read in place. Once it lands, BeginWords, ReadWords() and SkipWords may have no callers left. That PR will say what's unused, and part 1's DDSketch figures will be superseded by its own.

Left for later

  • QuotientFilter.Build makes and throws away a two-word table on every read. Building without it would let the charge drop to two small objects, and the new test fails until the charge does, so that change can't slip in unnoticed.
  • A whole QuotientFilter read allocates 72 bytes beyond everything it charges, the same at every shape and in both envelopes. That's outside TableCost, probably in the envelope. Not investigated.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx

mattlorimor and others added 4 commits November 5, 2026 21:14
A run of eight-byte words that a structure unpacks into storage of its own is
begun with PayloadReader.BeginWords and priced with SkipWords. From a held
payload it is read whole as a run of bytes, which since #329 charges an empty
run nothing. In the large form it is read from the stream in pieces through a
buffer, which for an empty run is already Array.Empty<byte>, and that buffer
was charged, and priced, as ReadBudget.ArrayOf(0, 1): an array header, 24
bytes, for an array never made (#336).

The test begins and reads a run of 0 bytes and a run of one word, from a held
payload and in the large form (a u64 prefix, streamed), counted in a no-GC
region after one uncounted read (the piece length is a thread static, whose
storage the first read on a thread allocates), and holds what each is charged
(Spent) and allocates to the same cost: nothing for the empty run,
ReadBudget.ArrayOf(8, 1) = 32 for one word. It then
prices each run from a held payload, from an ordinary payload's stream and in
the large form, and holds the price to that cost too. The one-word run is the
other direction, so that a cost of nothing for every run fails.

Red, measured on 6ce3fe1:

  a run of 0 bytes of words read in the large form was charged 24 bytes
  expected: 0  actual: 24

The held cases pass before it (charged 0, allocated 0), as #329 left them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
BeginWords reads a run of words from the large form through a buffer of
PieceFor(count) bytes, and for an empty run that buffer is Array.Empty<byte>(),
which allocates nothing; it was charged ReadBudget.ArrayOf(0, 1), 24 bytes, and
SkipWords priced the same. Both now use CostOfBytes, the rule #329 gave
ReadBytes: nothing for an empty run, an array of the bytes otherwise. Price and
charge move together, so they still come to the same sum (#336).

Which structures move. BeginWords and SkipWords have three callers, CountSketch,
DDSketch and InvertibleBloomLookupTable, and only a DDSketch writes an empty
run of words, a store holding no buckets. CountSketch refuses a width or depth
of nought, and an InvertibleBloomLookupTable fewer cells than its four hashes,
so every payload either reads holds a word in each run. Measured, priced =
charged (Spent), allocated the least of seven no-GC-region reads, which agreed:

                                          before        after        allocated
  DDSketch new, large form                   288          240        592 = 592
  DDSketch 100 positive values, large       4,024        4,000      4,352 = 4,352
  DDSketch 100 of each sign, large          7,760        7,760      8,112 = 8,112
  DDSketch new, ordinary envelope             216          216        568 = 568
  CountSketch(0.5, 0.5), large                384          384        584 = 584
  InvertibleBloomLookupTable(4, 8), large   1,120        1,120      1,248 = 1,248

So a new DDSketch moves by 48 bytes in the large form (two empty stores), one
holding values of one sign by 24, and nothing else moves.

The #329 CHANGELOG entry said a run of words read in pieces "is charged as it
was"; it now says it is charged by the same rule since #336, so that the
release's notes do not contradict each other.

TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing, red on the parent
(charged 24 for the empty large-form run), passes; TestPayloadReader passes
whole (21 tests).

Mutation, through scripts/mutate.sh with MUTATION_JOBS=2, on this commit, the full
suite (2,512 tests) for each:

  M1 BeginWords' streamed charge back to ArrayOf(piece, 1)    killed (2 tests)
  M2 SkipWords' streamed price back to ArrayOf(piece, 1)      killed (2 tests)
  M3 CostOfBytes: charge an empty run too                     killed (2 tests)
  M4 CostOfBytes: charge nothing for any run                  killed (62 tests)
  M5 BeginWords: a new buffer for an empty run                killed (1 test)
  M6 CostOfBytes: count == 0 -> count <= 0                    SURVIVED
  M7 CostOfBytes: count == 0 -> count != 0                    killed (62 tests)
  M8 CostOfBytes: count == 0 -> count == 1                    killed (3 tests)

Attributed on the branch tip (PayloadBuffer.cs as here), each scoped to one test:
M1 and M2 are each killed by the new test and by
TestReadBudget.TestEveryStructureInTheLargeFormIsChargedHonestlyAndNamesOneTotal
(the price and the charge no longer agree; the DDSketch sample's negative store
is empty, which is the row inferred, not isolated); M5 by the new test alone,
which is the only test holding what the buffer allocates. M3, M4, M7 and M8
reach ReadBytes as well, and were not attributed.

M6 is equivalent: CostOfBytes is given a count from ReadArrayRunLength, at most
Array.MaxLength and never negative, or a piece, Math.Min of a run length held
to Remaining (never negative, as every read is bounds-checked) and
PayloadSource.PieceBytes, which only the suite lowers, and only to 64. No
caller passes a negative count, so <= 0 and == 0 agree on every input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
…r allocates (red)

#336 part (2): a read charges the filter it builds as TableCost, its table and
one ReadBudget.SmallObject (64 bytes), and what that object costs had never been
measured properly; two probes of the constructor in round 9 disagreed, 96 and
160 bytes.

Measured on 6ce3fe1, and again on the part (1) fix with Build internal and under
the segments GC (DOTNET_GCName=libclrgc.so, three processors), identically: the
least of seven runs each in a no-GC region, after one
uncounted warm-up; all seven runs agreed at every shape:

  QuotientFilter.Build (what a read builds), beyond its table
    q  r   wrapped   table      TableCost   allocated   beyond table   gap
    1  1   no            40         104         176          136         72
    2  61  no            72         136         208          136         72
    8  7   no           360         424         496          136         72
    16 16  no       155,688     155,752     155,824          136         72
    20 3   no       786,472     786,536     786,608          136         72
    32 7   yes (v1)      40         104         176          136         72
    32 29  yes (v1)      40         104         176          136         72

  new QuotientFilter(n, rate), beyond its table
    (1, 0.5) 96; (100, 0.01) 96; (50,000, 0.001) 96; (1,000,000, 0.0001) 96,
    with the hash given or left to the default (the default is cached).

So the filter object is 96 bytes, and Build allocates 40 more: it makes the
filter through the public constructor, new QuotientFilter(1, 0.5, hash), whose
Shape allocates a two-word table (TableWords(2, 1) = 2, 24 + 16 = 40 bytes)
before Build shapes it again with the payload's widths. A read is charged 64
for 136: 72 bytes under, at every shape. The issue's 96 is the constructor's
object; 160 did not recur in any run here.

Whole reads agree (measured on 6ce3fe1 only). Read through FromByteArray, least of seven in a
region, allocated minus charged is 144 bytes at four shapes in both envelopes
(e.g. 100 items at 1%: ordinary 1,088 against 944, large form 808 against 664):
the 72 here, and 72 the read allocates outside what TableCost names.

The test builds a filter for each of five shapes (the narrowest table, the
widest remainder, the issue's 100 items at 1%, 2^16 slots, and version 1's
one-slot table for 32 quotient bits) and holds TableCost to at least what the
build allocates and to less than a small object past it. Build and TableCost
are made internal for it; nothing else in the library changes here.

Red:

  a filter of 2 slots of 1-bit remainders allocated 176 bytes to build for a
  read, and a read is charged 104 for it  (upper bound: 104, actual: 176)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
TableCost charged the filter a read builds as one ReadBudget.SmallObject, 64
bytes, beside its table, and Build allocates 136 beyond the table (measured in
the parent's message): the filter object, 96 bytes, and the two-word table, 40
bytes, of the new QuotientFilter(1, 0.5, hash) that Build starts from before it
shapes the filter to the payload. TableCost now charges three small objects,
192 bytes: at least what Build allocates, and 56 past it, under one small
object, as the InfiniFilter and MementoFilter segments are held (#329, #327).
The reader and its price both call TableCost, so they still come to the same
sum (#336).

Measured after the fix, the least of seven whole reads in a no-GC region
through FromByteArray, allocated minus charged (was 144 at each):

  (1, 0.5)            ordinary 448 vs 432 (16)    large form 488 vs 472 (16)
  (100, 0.01)         ordinary 1,088 vs 1,072     large form 808 vs 792
  (50,000, 0.001)     ordinary 426,432 vs 426,416 large form 213,480 vs 213,464
  (1,000,000, 0.0001) ordinary 8,913,344 vs 8,913,328, large form 4,456,936 vs 4,456,920

What a read allocates is unchanged; its charge is 128 bytes more. The 16 left
is outside TableCost: 72 bytes a read allocates beyond everything else it
charges, at each shape and in both forms measured, less the 56 TableCost now
charges past Build.

Left as it is, and reported rather than changed: Build allocating the
throwaway two-word table at all. A read need not make it, and without it the
filter would be charged as two small objects, as a segment is; the test's
upper bound (less than a small object past what Build allocates) then fails
until the charge drops to two, so the change cannot be made silently.

TestAReadIsChargedWhatBuildingItsFilterAllocates, red on the parent (176
allocated against 104 charged at two slots), passes, with the rest of
TestQuotientFilter*.

Mutation, through scripts/mutate.sh with MUTATION_JOBS=2, on this commit, the full
suite for each, then each scoped to the new test alone:

  N1 TableCost: two small objects, not three    killed (1 test; the new one)
  N2 TableCost: one small object, as it was     killed (1 test; the new one)
  N3 TableCost: four small objects              killed (1 test; the new one)

Which assertion each failed was not captured; by the measured numbers, N1
and N2 fall below the lower bound (176 allocated at two slots, against 168 and 104), and
N3 past the upper one (256 beyond the table, 120 past the 136 allocated, at
least a small object).

That the new test is the only one to kill N2, the charge as it was, is the gap
the read-budget sweep's slack hid.

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
@mattlorimor
mattlorimor merged commit 414abbf into master Nov 6, 2026
9 checks passed
@mattlorimor
mattlorimor deleted the fix/336-read-charge-leads branch November 6, 2026 06:28
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.

Read-charge leads: BeginWords' streamed empty piece charged 24 bytes; QuotientFilter's object charge unmeasured

1 participant