Skip to content

Read CountSketch, IBLT and DDSketch runs of words straight into storage (#335) - #342

Merged
mattlorimor merged 11 commits into
masterfrom
fix/335-read-runs-in-place
Nov 6, 2026
Merged

mattlorimor merged 11 commits into
masterfrom
fix/335-read-runs-in-place

Conversation

@mattlorimor

Copy link
Copy Markdown
Owner

Fixes #335.

Stacked on #341 (#336). Both change how runs of words are read, and one release ships both, so this branch merges #341's in and reconciles them. GitHub retargets this PR to master once #341 merges.

CountSketch, InvertibleBloomLookupTable and DDSketch no longer copy a run out whole on read. Each reads its runs' words straight into its own storage, as #328 does for QuotientFilter. That is the coordinator's plan on #335, approved by Matt.

The change

  • The three readers. Each run begins with BeginWordsInPlace, which charges nothing. Its words go straight into storage through ReadWords(Span<ulong>), in stack pieces of up to 16 KiB. The storage is still charged before it is allocated, after the run's length is checked against the payload (Readers and their prices refuse a payload that claims more than it holds, before charging or allocating for it #322).
    • CountSketch reads a row at a time.
    • InvertibleBloomLookupTable reads the counts, then the hash sums. Its key sums are still read as the array the table keeps.
    • DDSketch reads into the window Reserve sizes. A new Store.Restore works out the total and bounds in one pass, and still refuses a total past a u64.
  • The prices begin each run the same way, skip it, and price only the storage. Price and charge still match: TestReadBudget and C20's exact-price sweep pass, with no allowance.
  • Removed: BeginWords, ReadWords(), SkipWords, their fields, PieceFor and PayloadSource.ReadPiece(byte[], int). Nothing calls them now. This includes the code Charge an empty run of words nothing, and a quotient filter's read what building it allocates (#336) #341 changed.
  • For review: MemoryMarshal.Cast<long, ulong>. CountSketch's cells and the IBLT's counts are long[], while ReadWords takes Span<ulong>. The cast bridges that at those two call sites. It gives the same bits ReadInt64LittleEndian did, and needs no unsafe or pointers, but the library hasn't used MemoryMarshal before. The alternative is a second ReadWords overload for long, with a duplicate loop in PayloadBuffer.cs.

Measured in NoGCRegion (before → after)

Structure Ordinary read Ordinary charge Large-form read Large-form charge
CountSketch, 3 rows of 10,000 720,520 → 480,496 720,320 → 480,296 306,120 → 240,560 305,920 → 240,360
IBLT, 15,000 cells of 8-byte keys 960,432 → 720,384 960,304 → 720,256 491,560 → 360,440 491,432 → 360,312
DDSketch, 19,991 + 11,995 buckets 768,328 → 512,392 767,976 → 512,040 387,648 → 256,528 387,296 → 256,176

Each drop is exactly the run copies in the ordinary form, or the 65,560-byte heap pieces in the large form. The new tests give the same figures under the segments GC, as macOS runs it.

Docs, all held by CapacityClaims or the capacity tests:

Tests, and how each failed first

  • TestAReadHoldsItsCountersOnce, …CountsAndHashSumsOnce and …BucketsOnce hold each read within 4 KiB of its payload plus its storage, on both sides. Red at 6ce3fe1:
    • CountSketch: "the read allocated 720,520 bytes, 240,388 more than its payload and its rows".
    • IBLT: 240,340 more.
    • DDSketch: 256,448 more.
  • TestARunOfWordsIsReadFromTheLargeFormInPiecesStraightIntoItsStorage, one row per structure, checks the exact sequence of pieces, at the shipped piece size and lowered. It also checks that a run claimed past one array is refused for missing bytes. Red, for example: CountSketch "was read in 4 reads, the first of 65,536 bytes through the array overload, and not in 15 pieces through the span overload".
  • TestAPriceTakesARunOfWordsPastOneArrayAsItsReaderDoes walks each price over runs larger than 2^31 bytes, on a small stream holding only the fields and run lengths. The price must equal the storage exactly. It kills the four price mutants the sweep missed.
  • Renamed: TestARunItsStructureKeepsInStorageOfItsOwnIsReadInPiecesIntoIt is now TestNoRunIsReadFromTheLargeFormInPiecesThroughTheArrayOverload. Its old "read in pieces" half would check nothing after this change. It now says that no structure reads a run in pieces through the array overload, with a guard that the lowered piece takes effect.
  • Ported from Charge an empty run of words nothing, and a quotient filter's read what building it allocates (#336) #341: TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing called BeginWords, which this PR removes. It is now TestAnEmptyRunOfWordsReadInPlaceCostsNothing.
    • Reading a run of nought or one word in place, from a held payload and in the large form, charges and allocates nothing.
    • Pricing the run as the structures do prices nothing.
    • Red on a mutant: "a run of 0 bytes of words read in place from a held payload was charged 24 bytes".
  • TestAStoreHoldingNoBucketsCostsAReadNothing pins the same guarantee where the library writes an empty run. A DDSketch with values of one sign must cost exactly its window more than an empty one. It is the only test that catches a mutant charging and pricing an empty store's window on both sides; TestReadBudget and C20's sweep both pass that mutant.
  • Tightened: TestADDSketchRunOfMoreCountsThanOneArrayHoldsIsRefusedAsThat allowed 128 KiB, for a heap piece the read no longer makes. The refusal measures 3,432 bytes, under both collectors, and the bound is now 8 KiB. Red on the old heap piece: 68,992 bytes.

Mutation

scripts/mutate.sh: 50 mutants, 45 killed by the main sweep. Of the 5 survivors:

  • Four price mutants (CountSketch, both IBLT runs and DDSketch, each with the price's run held to one array) aren't equivalent. a6ef2d9's test kills each.
  • One is argued equivalent: "DDSketch taking empty buckets too". A store's bounds are always buckets that hold a count, on every payload the library writes, and the old reader behaved the same.

After the merge with #341: 14 more mutants, all killed, each by the whole suite and by its own test alone.

  • The begin step charging or pricing an empty run.
  • ReadWords reading through a heap buffer, or allocating only for an empty run.
  • The two-sided empty-store mutant.
  • The two heap-piece mutants.

The tables are in the commit messages.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx

mattlorimor and others added 10 commits November 5, 2026 21:15
…(red) (#335)

Three tests, one per structure, in the shape of #328's
TestAReadHoldsItsTableOnce: in the ordinary envelope, a read allocates
and is charged its payload and the storage it builds, within 4 KiB; in
the large form, which is not held, the storage alone, within 4 KiB.
Each figure is held from below too, so a measurement of nothing fails.
The reads are counted in NoGCRegion.

- TestCountSketch.TestAReadHoldsItsCountersOnce: three rows of 10,000
  counters, 80,000 bytes a row. The storage is the rows.
- TestInvertibleBloomLookupTable.TestAReadHoldsItsCountsAndHashSumsOnce:
  a table for 10,000 differences, 15,000 cells of 8-byte keys. The
  storage is the counts, the key sums and the hash sums.
- TestDDSketch.TestAReadHoldsItsBucketsOnce: 20,000 positive buckets
  and 12,000 negative ones. The storage is the two windows, each sized
  by its store's span.

Every run of words in them is longer than a 64 KiB heap piece plus the
slack. So a whole-run copy of any one run fails the slack, and so does
the heap piece BeginWords reads through in the large form.

Red, at 6ce3fe1, each failing its first upper bound (ordinary read):

  CountSketch: "the read allocated 720,520 bytes, 240,388 more than
    its payload and its rows"
  InvertibleBloomLookupTable: "the read allocated 960,432 bytes,
    240,340 more than its payload and its arrays"
  DDSketch: "the read allocated 768,328 bytes, 256,448 more than its
    payload and its windows"

Measured at 6ce3fe1, in a no-GC region:

                     payload  storage  ordinary  charged    large  charged
  CountSketch        240,012  240,120   720,520  720,320  306,120  305,920
  Invertible table   360,020  360,072   960,432  960,304  491,560  491,432
  DDSketch           255,944  255,936   768,328  767,976  387,648  387,296

Past the payload and the storage, the ordinary read holds a copy of
each run of words: 240,024 bytes for CountSketch, 2 x 120,024 for the
table, and 159,952 + 95,984 for DDSketch. The large form holds a
65,560-byte heap piece for each run instead.

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

TestARunOfWordsIsReadFromTheLargeFormInPiecesStraightIntoItsStorage,
one row each for CountSketch, DDSketch and InvertibleBloomLookupTable,
in the shape of #328's quotient filter test. Each run of words in the
budget sample's large form is to be read through the stream's span
overload, one piece after another from its first byte to its last.
Every piece is to be as long as a piece but the last, with nothing
else read among them. That holds with the piece as it ships (16 KiB)
and lowered to eight words. A count sketch's run is read a row at a
time, so its pieces start again at each row. Claimed a byte past one
array at its u64 prefix, each run is to be refused for the bytes the
stream lacks, not as more than one array holds.

The runs are found where FORMAT.md lays them out, and the layout checks
itself: each run is as long as its prefix says, and the last ends where
the payload does. A guard needs at least one run of words that holds
something.

Red, at 6ce3fe1, each row at the shipped piece:

  CountSketch: "run of 240,000 bytes at 38, with the piece at 16,384,
    was read in 4 reads, the first of 65,536 bytes through the array
    overload, and not in 15 pieces through the span overload"
  DDSketch: "run of 159,928 bytes at 74 ... 3 reads, the first of
    65,536 bytes through the array overload, and not in 10 pieces"
  InvertibleBloomLookupTable: "run of 120,000 bytes at 38 ... 2 reads,
    the first of 65,536 bytes through the array overload, and not in
    8 pieces"

BeginWords reads such a run from a stream through a heap buffer of
PayloadSource.PieceBytes, 64 KiB as it ships.

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

Each reader began its runs of words with PayloadReader.BeginWords. From
a held payload that copies the run out whole (ReadBytes), and the
reader then unpacked the copy into storage of its own: a count sketch's
cells into its rows, an invertible table's counts and hash sums into
their arrays, and each DDSketch store's buckets into its window. So a
read held, and was charged for, each run twice besides the payload. From
the large form, which is not held, BeginWords read each run through a
heap buffer of PayloadSource.PieceBytes (64 KiB, or the run where
shorter), charged too.

Each run is now begun with #328's BeginWordsInPlace, which holds the
run's length to the payload and charges nothing. Its words are read
straight into the storage with ReadWords(Span<ulong>), in pieces through
a stack buffer of BlockPieceFor (16 KiB, or the run where shorter):
- CountSketch: a row at a time.
- InvertibleBloomLookupTable: the counts, then the hash sums. The key
  sums are still read as the array the table keeps.
- DDSketch: into the window Reserve sizes. A new Store.Restore then
  takes the window's total and bounds in one pass, as Add did a count
  at a time, refusing a total past a u64 as before.
A signed word (a count sketch cell, an invertible table count) is read
as the unsigned word with the same bits, through
MemoryMarshal.Cast<long, ulong>. That is the bit pattern the old
ReadInt64LittleEndian gave. The storage is still charged before it is
allocated, after the run's length is held to the payload (#322). An
empty run, a DDSketch store holding no buckets, is begun and charges
and allocates nothing.

Each price begins the run the same way, skips it, and prices the
storage alone, as the reader charges it, so price and charge still come
to one sum (TestReadBudget holds them equal for every sample, and C20's
sweep holds every cut to its price exactly).

Measured on the red tests' structures, in a no-GC region:

                          6ce3fe1      this
  CountSketch, three rows of 10,000 counters
    ordinary read         720,520   480,496
    ordinary charge       720,320   480,296
    large-form read       306,120   240,560
    large-form charge     305,920   240,360
  InvertibleBloomLookupTable, 15,000 cells of 8-byte keys
    ordinary read         960,432   720,384
    ordinary charge       960,304   720,256
    large-form read       491,560   360,440
    large-form charge     491,432   360,312
  DDSketch, 19,991 positive buckets and 11,995 negative
    ordinary read         768,328   512,392
    ordinary charge       767,976   512,040
    large-form read       387,648   256,528
    large-form charge     387,296   256,176

Each drop is exactly the copies, or the 65,560-byte heap pieces, that
the red commit's message lists. Price and charge move together.

Small structures, measured the same way. A new DDSketch(0.01) is
charged 216 from the ordinary envelope at both commits, and 240 from
the large form, where it was 288: the two empty heap pieces BeginWords
charged 24 bytes each for are gone. #336, on its own branch, is reported
(not measured here) to remove the same 48 bytes by
charging BeginWords' empty piece nothing. A DDSketch of one value is
charged 256, not 288, ordinary, and 272, not 328, large. A new
CountSketch(0.5, 0.5) is charged 296, not 352, and 328, not 384. A new
InvertibleBloomLookupTable(4, 4) is charged 1,016, not 1,368, and 696,
not 1,048.

Documents moved by it, through the capacity checks:
- README: the default budget now reads a CountSketch of 5 rows up to a
  width of about 13.42 million (was 8.95 million), an invertible table
  up to about 14.91 million differences of 8-byte keys (was 11.18) and
  7.46 million of 32-byte keys (was 6.39), and a DDSketch up to a span
  of about 67.11 million buckets a sign (was 44.74).
- CAPACITY.md: "A read charges 24 bytes a counter" (CountSketch) and
  "24 bytes a bucket of span" (DDSketch) become 16, with what the
  16 is: the payload and the storage the words are read straight into.
  Their claims now check slopes of 16 (were 24).
- FORMAT.md: the read-charge rows say CountSketch's cells, and the
  invertible table's counts and hash sums and DDSketch's buckets, are
  read straight into their storage.
- CHANGELOG: an entry under Fixed.

TestARunItsStructureKeepsInStorageOfItsOwnIsReadInPiecesIntoIt held
these three to reading their runs in pieces through the stream's array
overload. None does now, so its pieced half would have checked nothing.
It is now TestNoRunIsReadFromTheLargeFormInPiecesThroughTheArrayOverload:
through the StructureId roster, no structure reads a run in pieces
through the array overload, and a run read whole is still refused past
one array as more than one holds. A guard needs some sample to make
more span reads with the piece lowered than as it ships, so a piece
that was never lowered cannot pass. The previous commit's test holds
the three structures' span pieces instead. The remark on
TestADDSketchRunOfMoreCountsThanOneArrayHoldsIsRefusedAsThat no longer
says a heap piece is allocated for the run.

Mutation, scripts/mutate.sh, MUTATION_JOBS=2, at this commit's tree
(4064792, before this table was added to its message), whole suite
unless a test is named in brackets. "as at 6ce3fe1" is the file as it
was there, whole; "reader: as at 6ce3fe1, price now" puts back the old
reader alone, under the new price.

  cs reader: as at 6ce3fe1, price now       killed (7)
  cs price: as at 6ce3fe1 (SkipWords)       killed (5)
  cs reader+price: as at 6ce3fe1            killed (5)
  cs reader: rows not charged               killed (2)
  cs reader: last row skipped, not read     killed (27)
  cs reader: begun held to one array        killed (1)
  cs price: begun held to one array         SURVIVED (all 2,517)
  cs price: run not skipped                 killed (2)
  ib reader: as at 6ce3fe1, price now       killed (9)
  ib price: as at 6ce3fe1 (SkipWords)       killed (6)
  ib reader+price: as at 6ce3fe1            killed (4)
  ib reader: counts skipped, not read       killed (26)
  ib reader: hash sums skipped, not read    killed (26)
  ib reader: counts begun held to one array killed (1)
  ib reader: hashes begun held to one array killed (1)
  ib price: counts begun held to one array  SURVIVED (all 2,517)
  ib price: hashes begun held to one array  SURVIVED (all 2,517)
  ib price: counts not skipped              killed (6)
  ib price: hashes not skipped              killed (1)
  ib reader: counts not charged             killed (4)
  ib reader: hash sums not charged          killed (2)
  dd reader: as at 6ce3fe1, price now       killed (7)
  dd price: as at 6ce3fe1 (SkipWords)       killed (5)
  dd reader+price: as at 6ce3fe1            killed (5)
  dd reader: words skipped, not read        killed (39)
  dd reader: window's counts not taken      killed (39)
  dd reader: window not charged             killed (2)
  dd reader: an empty store reserves a window  killed (2)
  dd price: an empty store priced a window  killed (2)
  dd reader: begun held to one array        killed (2)
  dd price: begun held to one array         SURVIVED (all 2,517)
  dd price: run not skipped                 killed (2)
  dd taking: empty buckets taken too        SURVIVED (all 2,517)
  dd taking: overflow not refused           killed (1)
  dd taking: overflow refused at the edge   killed (2)
  dd taking: lowest by Max                  killed (31)
  dd taking: lowest is the last             killed (23)
  dd taking: highest not set                killed (31)
  dd taking: total not added                killed (39)
  dd taking: last bucket not taken          killed (39)
  dd taking: first bucket not taken         killed (39)
  [cs holds once] as at 6ce3fe1             killed (1)
  [ib holds once] as at 6ce3fe1             killed (1)
  [dd holds once] as at 6ce3fe1             killed (1)
  [pieces] cs as at 6ce3fe1                 killed (1)
  [pieces] ib as at 6ce3fe1                 killed (1)
  [pieces] dd as at 6ce3fe1                 killed (1)
  [no array pieces] cs as at 6ce3fe1        killed (1)
  [no array pieces] ib as at 6ce3fe1        killed (1)
  [no array pieces] dd as at 6ce3fe1        killed (1)

45 killed, 5 survived. "begun held to one array" puts BeginRun, which
holds a run to one array, in place of BeginWordsInPlace. "dd taking" is
the new Store.Restore. Each red test alone kills the old reader and
price.

The four price survivors are not equivalent. A price that refuses a
large-form run past one array is set aside, and the read is admitted at
what had been priced, so its reader's charges, not its price, refuse a
budget too small for the whole. Nothing a payload the library writes
shows that. The next commit's test walks each price on its own over
runs past 2^31 bytes, and kills all four; its message has the run.

"empty buckets taken too" drops Restore's skip of a zero count. It is
equivalent on every payload this library writes. Store.Add is only
given 1 (Add) or a nonzero amount (Merge, under amount != 0), and
Restore sets the bounds only at counts that hold something. So a
store's bounds are always buckets that hold a count, and WriteTo writes
from one to the other. Interior zeros then change neither bound nor the
total. A zero at a run's edge needs one of its counts and the recorded
total both edited: the total check refuses a single edit. Building such
a payload is beyond the one-field pattern the brief allows. The old
reader's per-count "amount != 0" had the same property.

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

The sweep of the commit before this one left the price-side mutants of
the begin alive. In each of CountSketch, DDSketch and the invertible
table, they begin a run of words with BeginRun, held to one array,
instead of BeginWordsInPlace. Every test passed with them in.

A price that refuses such a run is set aside. The read is admitted at
what had been priced, and its reader then charges the rest. So a budget
too small for the whole is refused by the reader instead of the price,
naming the same total, after the payload's fields have been read a
second time. The invertible table's reader would first allocate its
counts and key sums, gigabytes, before it reached its hash sums. Nothing
a read of a payload the library writes can show this.

TestAPriceTakesARunOfWordsPastOneArrayAsItsReaderDoes walks each price
on its own, over a reader of the large form. Each payload has runs of
more than 2^31 bytes: three rows of 2^27 counters, a DDSketch store of
3 x 10^8 buckets, and a table of 3 x 10^8 cells of one-byte keys. It
lies on a stream (Patched) that holds the fields and the runs' lengths
where FORMAT.md lays them out, and zeros between. The price must not
refuse, and must price exactly the storage its reader charges. That
equality is also what fails a price that prices nothing.

Watched failing on the CountSketch mutant, by hand: "Payload holds a
3221225472-byte run, which cannot be read into a single array."

The sweep's survivors, again, scripts/mutate.sh, MUTATION_JOBS=2, at
this commit's tree (48175a5, before this run was added to its message),
whole suite unless a test is named in brackets:

  cs price: begun held to one array               killed (1)
  ib price: counts begun held to one array        killed (1)
  ib price: hashes begun held to one array        killed (1)
  dd price: begun held to one array               killed (1)
  dd taking: empty buckets taken too              SURVIVED (all 2,520)
  [price past one array] cs begun held to one array      killed (1)
  [price past one array] ib counts begun held to one array  killed (1)
  [price past one array] ib hashes begun held to one array  killed (1)
  [price past one array] dd begun held to one array      killed (1)
  [price past one array] cs price prices nothing         killed (1)

The one test each mutant is killed by is this one; the bracketed runs
show it alone. "prices nothing" drops CountSketch's price of its rows,
for the guard the equality makes. The survivor is the one the previous
commit's message argues equivalent on every payload the library
writes.

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

CountSketch, InvertibleBloomLookupTable and DDSketch were their only
callers, and two commits back they began reading their runs of words
in place. Removed with them, as nothing else used them:
- PayloadReader's words and wordsLeft fields, which held the run
  BeginWords began;
- PieceFor, the heap buffer it read through;
- PayloadSource.ReadPiece(byte[], int), the array-overload read that
  ReadWords() made.

The doc comments that named them now name BeginWordsInPlace or
ReadWords(Span<ulong>). PayloadSource.PieceLength and PieceBytes stay:
the suite lowers the piece through LoweredPieceLength, which lowers
BlockPieceBytes, and the tests use PieceLength as the heap piece the
reads no longer make. As shipped, PieceLength (64 KiB) now bounds
nothing, since BlockPieceLength (16 KiB) is shorter; its doc says so.

Kept apart from the fix so it can be dropped or adjusted where #336's
branch meets this one. #336 changes BeginWords' streamed charge and
SkipWords' price, and adds a test that calls BeginWords directly
(TestPayloadReader.TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing).
That test would need BeginWordsInPlace, or this commit dropped.

No behaviour changes: the members had no callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
Stacks #335 on #336 (42d85c5), with a merge rather than a rebase, so
that the commits verified on each branch stay as they were.

Resolved:
- PayloadBuffer.cs: this branch's side, whole. Its last commit removed
  BeginWords, ReadWords() and SkipWords, which nothing calls once the
  three structures read their runs in place. #336 changed only those
  members' empty-piece charge and price, and CostOfBytes' doc comment,
  which named BeginWords. All go with them, and CostOfBytes' doc is as
  it was at 6ce3fe1.
- TestPayloadReader.cs: this branch's side, which leaves out #336's
  TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing and its helpers.
  They call the removed members, so the merge would not build with
  them. The next commit ports the test to the in-place read.

Taken from #336 as it is: the QuotientFilter charge for the filter its
read builds, its test, and the CHANGELOG. A later commit folds #336's
empty-run bullet into #335's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
#336's TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothing read a run
of words through BeginWords, ReadWords() and SkipWords. This branch
removes them, and the merge before this commit left the test out. The
guarantee it held still has to hold for the read that replaces them:
an empty run of words costs, prices and allocates nothing, from a held
payload and in the large form.

TestAnEmptyRunOfWordsReadInPlaceCostsNothing holds the in-place read to
it. BeginWordsInPlace and ReadWords(Span<ulong>) read a run of nought
or one word into storage made outside the measured region, and must
charge nothing and allocate nothing, in a no-GC region. A run of one
word costs nothing either, since its storage is its structure's to
charge, but its word has to arrive. A price that begins the run and
skips it, as each structure's price does, must price nothing: from a
held payload, from an ordinary payload's stream, and in the large form.

#336's test took its other direction from a run of one word costing a
piece. Read in place, no run costs anything of its own, so the other
direction is now the same runs read whole with ReadBytes. That read must
cost its array, 32 bytes for one word and nothing for none. So a
measurement that sees nothing fails. WordsPayload is #336's helper, as
it was.

Watched failing, by hand, with BeginWordsInPlace charging an array's
header for an empty run: "a run of 0 bytes of words read in place from
a held payload was charged 24 bytes".

Mutation, scripts/mutate.sh, MUTATION_JOBS=2, at 1c6def9: this
commit's code and tests, with the next three commits' on top. Whole
suite, then this test alone:

  begin: charges an empty run          killed (4)   alone: killed (1)
  begin: prices an empty run           killed (5)   alone: killed (1)
  words: buffer on the heap            killed (30)  alone: killed (1)
  words: an empty run allocates        killed (1)   alone: killed (1)

"begin" is BeginWordsInPlace charging, or pricing, an array's header
for an empty run. "words" is ReadWords(Span<ulong>) reading through a
heap buffer (GC.AllocateUninitializedArray), or allocating an empty
array (Array.CreateInstance) for an empty run only. In the whole suite,
that last mutant is killed by this test alone.

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

An empty store's run is the only empty run of words the library
writes, so it is where the guarantee of the test before this one meets
a structure. Its reader returns before charging or reserving a window
for an empty span, and its price prices one only for a span above
nought. Each of those checks alone was already killed in this branch's
sweep, as price and charge then disagree. But a read that charged an
empty store's window and priced it the same would leave the two equal,
and nothing held it.

TestAStoreHoldingNoBucketsCostsAReadNothing compares two sketches
rather than pinning either one, so that what a sketch's objects and
envelope cost is not written into the test. A sketch of one sign (100
values, a window of 1,880 bytes) must be charged, and must allocate in
a no-GC region, exactly its window more than a sketch holding none.
From a held payload it also costs the bytes its payload grows by; from
the large form, nothing more. Measured:

                     none            one sign
  held payload       216 / 568       3,952 / 4,304  (window 1,880, payload +1,856)
  large form         240 / 592       2,120 / 2,472  (window 1,880)

(charged / allocated). An empty store charged 24 bytes would charge the
empty sketch twice that and the one-sign sketch once, 24 short. A
window charged nothing would leave only the payload's growth: the
other direction. The empty sketch's 216 is the figure #329 measured, and
its 240 the one #336 measured.

Watched failing:
- On the mutant above, the early return dropped and the price taken at
  a span of nought: "from a held payload, a sketch of one sign was
  charged 3,712 bytes more than one holding none, and its window and
  payload are 3,736 more". TestReadBudget and
  TestReadsAllocateOnlyWhatTheyHold pass on it.
- At 6ce3fe1, where the run was copied: 5,616 more, the copy's 1,880.

The span helper the holds-once test used is now shared by both tests.

Mutation, scripts/mutate.sh, MUTATION_JOBS=2, at 1c6def9: this
commit's code and tests, with the next two commits' on top. The mutant
is the one watched above, as a whole file:

  an empty store charged and priced a window
      whole suite: killed (2)   this test alone: killed (1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
TestADDSketchRunOfMoreCountsThanOneArrayHoldsIsRefusedAsThat held the
refusal's allocation under PayloadSource.PieceLength + 64 KiB, 128 KiB.
That bound left room for the 64 KiB heap piece BeginWords read the run
through. Since #335 the run is begun in place, with no buffer, so the
bound no longer shows anything a regression would bring back.

Measured, in a no-GC region, least of five: the refusal allocates 3,432
bytes. That is the same on each of four runs, under the regions
collector and under the segments one (DOTNET_GCName=libclrgc.so
DOTNET_PROCESSOR_COUNT=3), on Linux x64.

The bound is now RefusalRoom, 8 KiB: a little more than twice that, an
eighth of the old heap piece, and half the 16 KiB stack buffer, so a
buffer of either on the heap fails it. The test prints what it
measured.

Watched failing, by hand:
- A heap piece of PayloadSource.PieceBytes allocated in DDSketch's
  store reader after its run is begun, as BeginWords did:
  "the refusal allocated 68,992 bytes, more than a refusal and its
  reader take: a buffer for the run's words". That is 3,432 and one
  65,560-byte piece; the old bound let it through.
- The same piece allocated in BeginWordsInPlace, so in the price's pass
  as well: 134,552 bytes.

Mutation, scripts/mutate.sh, MUTATION_JOBS=2, at 1c6def9: this
commit's code and tests, with the CHANGELOG fold on top. The mutants
are the two pieces watched above:

  dd reader: a heap piece for its run   killed (6)   this test alone: killed (1)
  begin: a heap piece for every run     killed (19)  this test alone: killed (1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KK3MspqAcczxT45xCfLgyx
One release ships both, so the notes describe where it leaves a read
against the last release, not the step between the two branches.

- #336's bullet on an empty run of words read from the large form is
  folded into #335's. Its measured figure stays: a new DDSketch read
  from the large form is charged 240 bytes, not 288, and allocates what
  it did. Two of its sentences were true only of the step: "one holding
  values of one sign only is charged 24 bytes less than it was", and
  CountSketch and the invertible table "are charged what they were".
  Against the last release, a DDSketch of one sign also stops paying
  for its other store's piece, and those two stop paying for their
  copies. So the bullet gives a DDSketch of one positive value instead,
  measured on this branch: charged 272 from the large form, not 328 at
  6ce3fe1, 24 bytes for its empty store and 32 for the other store's
  piece. It cites both issues. Its headline says an empty run of words
  costs nothing.
- #329's bullet ended "A run of words read from the large form in
  pieces is not read this way, and is charged by the same rule since
  #336." At release no run of words is read in pieces through a buffer
  of its own. It now says such a run is read straight into its
  structure's storage and costs nothing of its own, empty or not
  (#335).

#336's QuotientFilter bullet is as it was.

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 6e2e5ec into master Nov 6, 2026
9 checks passed
@mattlorimor
mattlorimor deleted the fix/335-read-runs-in-place branch November 6, 2026 06:35
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.

CountSketch, IBLT and DDSketch still copy a run whole on read (BeginWords), as QuotientFilter did before #328

1 participant