Repository navigation
Charge an empty run of words nothing, and a quotient filter's read what building it allocates (#336) - #341
Merged
Conversation
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
This was referenced Nov 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
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.
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 useCostOfBytes, the rule #338 gaveReadBytes, 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:CountSketch,DDSketchandInvertibleBloomLookupTablecall these methods. Of those, onlyDDSketchwrites an empty run of words: a store with no buckets.DDSketchread 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).CountSketchandInvertibleBloomLookupTable: their readers refuse an empty run, so every payload they read has a word in each run.Test:
TestPayloadReader.TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing.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.BeginWords' charge back toArrayOfSkipWords' price back toArrayOfCostOfBytescharges an empty runCostOfBytescharges nothing for any run== 0→<= 0== 0→!= 0== 0→== 1Part 2:
QuotientFilter's read is charged what building its filter allocatesMeasured first, as the least of 7 runs in
NoGCRegionat 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 on6ce3fe1, 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.Fix:
TableCostnow 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 useTableCost, so they still agree. A read is charged 128 bytes more than before.Test:
TestQuotientFilter.TestAReadIsChargedWhatBuildingItsFilterAllocates.TableCost, and thatTableCostis less than one small object above it.BuildandTableCostare nowinternalso the test can reach them.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,DDSketchandInvertibleBloomLookupTableto read in place. Once it lands,BeginWords,ReadWords()andSkipWordsmay 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.Buildmakes 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.QuotientFilterread allocates 72 bytes beyond everything it charges, the same at every shape and in both envelopes. That's outsideTableCost, probably in the envelope. Not investigated.Verification
-warnaserror: exit 0, 0 warnings.🤖 Generated with Claude Code
https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx