Repository navigation
Read CountSketch, IBLT and DDSketch runs of words straight into storage (#335) - #342
Merged
Merged
Conversation
…(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
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 #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,InvertibleBloomLookupTableandDDSketchno longer copy a run out whole on read. Each reads its runs' words straight into its own storage, as #328 does forQuotientFilter. That is the coordinator's plan on #335, approved by Matt.The change
BeginWordsInPlace, which charges nothing. Its words go straight into storage throughReadWords(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).CountSketchreads a row at a time.InvertibleBloomLookupTablereads the counts, then the hash sums. Its key sums are still read as the array the table keeps.DDSketchreads into the windowReservesizes. A newStore.Restoreworks out the total and bounds in one pass, and still refuses a total past au64.TestReadBudgetand C20's exact-price sweep pass, with no allowance.BeginWords,ReadWords(),SkipWords, their fields,PieceForandPayloadSource.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.MemoryMarshal.Cast<long, ulong>.CountSketch's cells and the IBLT's counts arelong[], whileReadWordstakesSpan<ulong>. The cast bridges that at those two call sites. It gives the same bitsReadInt64LittleEndiandid, and needs nounsafeor pointers, but the library hasn't usedMemoryMarshalbefore. The alternative is a secondReadWordsoverload forlong, with a duplicate loop inPayloadBuffer.cs.Measured in
NoGCRegion(before → after)CountSketch, 3 rows of 10,000DDSketch, 19,991 + 11,995 bucketsEach 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
CapacityClaimsor the capacity tests:CountSketch≈ 8.95 → 13.42 millionDDSketch≈ 44.74 → 67.11 millionCountSketch's andDDSketch's 24 bytes become 16, with what the 16 is made of. Both claims now check a slope of 16.DDSketchis charged 240 from the large form, not 288.DDSketchof one value is charged 272 there, not 328. That's 24 bytes for its empty store, and 32 for the piece its other store's run was read through.QuotientFilterbullet is untouched.Tests, and how each failed first
TestAReadHoldsItsCountersOnce,…CountsAndHashSumsOnceand…BucketsOncehold each read within 4 KiB of its payload plus its storage, on both sides. Red at6ce3fe1:CountSketch: "the read allocated 720,520 bytes, 240,388 more than its payload and its rows".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".TestAPriceTakesARunOfWordsPastOneArrayAsItsReaderDoeswalks 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.TestARunItsStructureKeepsInStorageOfItsOwnIsReadInPiecesIntoItis nowTestNoRunIsReadFromTheLargeFormInPiecesThroughTheArrayOverload. 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.TestAnEmptyRunOfWordsIsTheEmptyArrayAndCostsNothingcalledBeginWords, which this PR removes. It is nowTestAnEmptyRunOfWordsReadInPlaceCostsNothing.TestAStoreHoldingNoBucketsCostsAReadNothingpins the same guarantee where the library writes an empty run. ADDSketchwith 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;TestReadBudgetand C20's sweep both pass that mutant.TestADDSketchRunOfMoreCountsThanOneArrayHoldsIsRefusedAsThatallowed 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:CountSketch, both IBLT runs andDDSketch, each with the price's run held to one array) aren't equivalent.a6ef2d9's test kills each.After the merge with #341: 14 more mutants, all killed, each by the whole suite and by its own test alone.
ReadWordsreading through a heap buffer, or allocating only for an empty run.The tables are in the commit messages.
Verification
-warnaserror: exit 0.df3f985: 2,523 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