diff --git a/CAPACITY.md b/CAPACITY.md index 4c95fd6..9a39ee4 100644 --- a/CAPACITY.md +++ b/CAPACITY.md @@ -402,9 +402,10 @@ of 2^27. The same table, with each slot also holding the memento that lets it answer a range query: `F + 1 + m + 3` bits for a range up to 2^m wide, so a wider range costs more per slot. The items counted are entries, one for each block of `range` consecutive keys, so -keys that fall in blocks already present add nothing. A range of 256 reads back a table of -2^27 slots, and a range of 65,536 only 2^26. A slot is held in a 64-bit word, so the -constructor refuses a fingerprint and a range that make it wider. +keys that fall in blocks already present add nothing. A read holds each table twice, in the +payload and in the table its words are read straight into, so the default budget reads back +a table of 2^27 slots at a range of 256 and at a range of 65,536. A slot is held in a 64-bit +word, so the constructor refuses a fingerprint and a range that make it wider. ## Retains k diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a4cb2..7097139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -790,6 +790,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 widest table an array holds, 2^31 slots of 63 bits, is still taken and priced for its words. No writer wrote such a table: the constructor and every doubling stop short of it. +- **A `MementoFilter` read holds each table once, and is charged for it once.** Its reader + read a table's words a word at a time into an array of their own and then copied them + into the table the segment builds, so a read held, and was charged for, every table + twice, as an `InfiniFilter` read did before #313. The words are now read straight into + the table, in pieces through a 16 KiB buffer on the stack, as an `InfiniFilter`'s are, + and the price and the charges are one table each and the segment, so they still come to + the same sum. The segment and its codec are charged as two small objects, 128 bytes, + where they were charged as one beside the second table: measured in a no-GC region they + are 120 bytes, more than the one. Measured in a no-GC region, a filter of 2-bit + fingerprints whose chain holds 110,672 bytes of tables allocated 333,024 bytes to read + and now allocates 222,352, and in the large form, where the payload is not held, 222,424 + and now 111,752. So the default budget now reads a filter of 8-bit fingerprints at a + range of 65,536 up to a table of 2^27 slots, 100,663,296 items, where it stopped at 2^26; + at a range of 256 it still stops at 2^27. A payload claiming a table no array holds is + still refused for that before anything is priced or allocated. (#327) + - **An `InfiniFilter` read holds each table once, and is charged for it once.** Its reader read a table's words into an array of their own and then copied them into the table the segment builds, so a read held, and was charged for, every table twice: a table of 2^32 diff --git a/FORMAT.md b/FORMAT.md index b643f52..5db40fc 100644 --- a/FORMAT.md +++ b/FORMAT.md @@ -608,8 +608,7 @@ gigabytes, and loads only with a budget raised to match. What each read is charg | `CountMinSketch`, `CountSketch`, `PrivateCountMinSketch`, `SublimeCountMinSketch` | every row, before it is read; Sublime also its packed rows | | `TopK`, `HeavyKeeper` | a heap sized by the elements held, not by `k`; HeavyKeeper also its bucket arrays | | `ThetaSketch`, `TupleSketch`, `MinHashSignature`, `Grafite`, `SetSketch`, `InvertibleBloomLookupTable`, `DDSketch`, `HyperLogLogPlus` (sparse) | arrays sized by the values the payload carries | -| `InfiniFilter` | each table, its words read straight into the segment | -| `MementoFilter` | each table twice: as read, and as the segment built from it | +| `InfiniFilter`, `MementoFilter` | each table, its words read straight into the segment | | `DpswSketch` | its checkpoints, rebuilt from the substream size, and a list of segments per substream; each segment's sketch as above | ## Payloads diff --git a/ProbabilisticDataStructures/MementoFilter.cs b/ProbabilisticDataStructures/MementoFilter.cs index 7ba05c9..4797e55 100644 --- a/ProbabilisticDataStructures/MementoFilter.cs +++ b/ProbabilisticDataStructures/MementoFilter.cs @@ -839,21 +839,19 @@ private static MementoFilter ReadPayload( // has the same width, and if a payload says otherwise the lowest limit holds. filter.QuotientBitsLimit = Math.Min(filter.QuotientBitsLimit, QuotientBitsLimitFor(slotBits)); - // Read into one array, then copied into the table the segment builds. + // The segment's table is allocated, and the words, which have no length before + // them, are read straight into it. Read a word at a time into an array of their + // own and copied into the table, every table was held, and charged, twice + // (#327). The shape's check, above, holds the words to what one array holds. budget.Charge(SegmentCost(words)); - var data = new ulong[words]; - var anyBitSet = false; - for (var w = 0; w < words; w++) - { - data[w] = reader.ReadUInt64(); - anyBitSet |= data[w] != 0; - } + var segment = MementoSegment.Restore(quotientBits, fingerprintBits, mementoBits, count); + reader.ReadWords(segment.Data); // The one table with a history. A slot of exactly 64 bits was written with // a mask of zero, so such a table counted every key it was given and stored // none: its count is positive and its words are all zero, which no table // that holds an entry can be, since an entry sets its slot's metadata. - if (slotBits == MementoSegment.MaxSlotBits && count > 0 && !anyBitSet) + if (slotBits == MementoSegment.MaxSlotBits && count > 0 && !segment.Data.AsSpan().ContainsAnyExcept(0UL)) { throw new InvalidDataException( $"Table {i} counts {count} entries and every word of it is zero. " + @@ -868,8 +866,6 @@ private static MementoFilter ReadPayload( // since decoding a run walks it, and then what a run says about its own boxes, // once for each occupied run, so that a box the codec cannot decode is refused // now rather than by the first query that reads it. - var segment = MementoSegment.Restore( - quotientBits, fingerprintBits, mementoBits, count, data); segment.CheckMetadata(i); segment.CheckRuns(i); filter.Segments.Add(segment); @@ -903,9 +899,8 @@ private static void CheckHash(HashId recorded, Func, ulong>? } /// - /// Prices what allocates beyond the envelope: each table's - /// words, read into one array and copied into the table. See - /// . + /// Prices what allocates beyond the envelope: each table, which its + /// words are read straight into. See . /// internal static void Price(ref PayloadReader reader, ushort version) { @@ -929,9 +924,13 @@ internal static void Price(ref PayloadReader reader, ushort version) } } - /// One table's words, read into one array and copied into the table. - private static long SegmentCost(uint words) => ReadBudget.Sum( - ReadBudget.Times(2, ReadBudget.ArrayOf(words, sizeof(ulong))), + /// + /// One table, its words read straight into it, and the segment that holds it with its + /// codec, which come to more than one small object allows for, so are charged as two. + /// + internal static long SegmentCost(uint words) => ReadBudget.Sum( + ReadBudget.ArrayOf(words, sizeof(ulong)), + ReadBudget.SmallObject, ReadBudget.SmallObject); private static void CheckChain(int mementoBits, uint segmentCount) diff --git a/ProbabilisticDataStructures/MementoSegment.cs b/ProbabilisticDataStructures/MementoSegment.cs index 59f7f83..34034dd 100644 --- a/ProbabilisticDataStructures/MementoSegment.cs +++ b/ProbabilisticDataStructures/MementoSegment.cs @@ -129,19 +129,12 @@ private void Shape(int quotientBits, int fingerprintBits, int mementoBits) } /// - /// Restores a segment from stored fields without rebuilding its table. + /// A segment of a stored shape and count, its table allocated and every word of it + /// nought, for the reader to read the stored words straight into (#327), and then to + /// check its metadata and its runs. /// - internal static MementoSegment Restore( - int quotientBits, int fingerprintBits, int mementoBits, uint count, - ulong[] data) - { - var segment = new MementoSegment(quotientBits, fingerprintBits, mementoBits) - { - Count = count, - }; - data.CopyTo(segment.Data, 0); - return segment; - } + internal static MementoSegment Restore(int quotientBits, int fingerprintBits, int mementoBits, uint count) => + new(quotientBits, fingerprintBits, mementoBits) { Count = count }; /// /// Empties the table where it is, leaving it as a new one of its shape would be: no diff --git a/README.md b/README.md index 9616d8c..fd4ff76 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ building at the scale it describes, which is the measurement as of commit 426348 | `InfiniFilter` | items added, 8-bit fingerprints | 6,442,450,944 | 201,326,592 | 805,306,368 | | `InfiniFilter` | items added, 16-bit fingerprints | 3,221,225,472 | 100,663,296 | 402,653,184 | | `MementoFilter` | items added, one per block, range 256, 8-bit fingerprints | 1,610,612,736 | 100,663,296 | 402,653,184 | -| `MementoFilter` | items added, one per block, range 65,536, 8-bit fingerprints | 1,610,612,736 | 50,331,648 | 402,653,184 | +| `MementoFilter` | items added, one per block, range 65,536, 8-bit fingerprints | 1,610,612,736 | 100,663,296 | 402,653,184 | #### Retains k diff --git a/TestProbabilisticDataStructures/CapacityClaims.cs b/TestProbabilisticDataStructures/CapacityClaims.cs index e1792f5..7f42670 100644 --- a/TestProbabilisticDataStructures/CapacityClaims.cs +++ b/TestProbabilisticDataStructures/CapacityClaims.cs @@ -1328,6 +1328,22 @@ static bool ReadsUpTo(int f, int q) => return SlotBits(16) == 20 && SlotBits(8) == 12 && ReadsUpTo(8, 28) && ReadsUpTo(16, 27); }), new Claim("`F + 1 + m + 3` bits", () => ChargeLine.Known.ContainsKey("MementoFilter 256")), + // In bytes a word of the table, as Infini's: eight for the payload's copy of the + // word, and eight for the table it is read into, which it once was twice (#327). + new Claim("A read holds each table twice, in the payload and in the table its words are read straight into", () => + Near(Slope("MementoFilter 256"), 16, 0.01) && Near(Slope("MementoFilter 65536"), 16, 0.01)), + new Claim("so the default budget reads back a table of 2^27 slots at a range of 256 and at a range of 65,536", () => + { + static bool ReadsUpTo(ulong range, int q) + { + var slotBits = 8 + 1 + (int)Math.Ceiling(Math.Log2(range)) + 3; + var line = ChargeLine.Known[$"MementoFilter {range}"]; + return line.Surely((ulong)MementoSegment.Words(1u << q, slotBits)) + && !line.Possibly((ulong)MementoSegment.Words(1u << (q + 1), slotBits)); + } + + return ReadsUpTo(256, 27) && ReadsUpTo(65536, 27); + }), // ---- Inverse and the retaining sketches ----------------------------------------- new Claim("reads back at 8 bytes a slot, and a full one at 56", () => diff --git a/TestProbabilisticDataStructures/TestExpandingTableLimits.cs b/TestProbabilisticDataStructures/TestExpandingTableLimits.cs index 8362786..9a09896 100644 --- a/TestProbabilisticDataStructures/TestExpandingTableLimits.cs +++ b/TestProbabilisticDataStructures/TestExpandingTableLimits.cs @@ -522,9 +522,11 @@ public void TestAMementoTableNoArrayHoldsIsRefusedByNameBeforeAnythingIsPricedOr { problems.Add($"2^31 slots of 63 bits: the price did not walk the table an array holds: {fitting.Stopped.Message}"); } - else if (fitting.Priced < 2 * fits.Words * sizeof(ulong)) + else if (fitting.Priced < fits.Words * sizeof(ulong) || fitting.Priced >= 2 * fits.Words * sizeof(ulong)) { - problems.Add($"2^31 slots of 63 bits: the price came to {fitting.Priced:N0} bytes, less than the table's words twice over"); + // Once: the words are read straight into the table (#327), where they were + // read into an array of their own and copied, and priced twice over. + problems.Add($"2^31 slots of 63 bits: the price came to {fitting.Priced:N0} bytes, not the table's words once"); } var (fitsThrown, fitsAllocated, _) = Read(fits, ReadOptions.Default); diff --git a/TestProbabilisticDataStructures/TestLargeEnvelopeReading.cs b/TestProbabilisticDataStructures/TestLargeEnvelopeReading.cs index bfb29ea..2c26213 100644 --- a/TestProbabilisticDataStructures/TestLargeEnvelopeReading.cs +++ b/TestProbabilisticDataStructures/TestLargeEnvelopeReading.cs @@ -1197,6 +1197,62 @@ public void TestACountMinSketchsCountersAreReadFromTheLargeFormInPiecesStraightI } } + /// + /// A 's table, whose words have no length before them, is read + /// from the large form in pieces straight into its segment's table (#327), as an Infini + /// filter's is (#313): through the stream's span overload, every piece as long as a piece + /// but the last, one after another from the table's first byte to its last, and nothing + /// else read among them. Shown with the piece as it ships and lowered to eight words. + /// + /// + /// Before #327 the words were read a word at a time into an array of their own, eight + /// bytes asked of the stream and appended to the checksum for each, and then copied into + /// the table. The table is found as the bytes its words are written as, which nothing + /// else in the payload repeats; the budget sample is one table longer than a piece as it + /// ships, so it is read in more than one at either length. + /// + [TestMethod] + public void TestAMementoFiltersTableIsReadFromTheLargeFormInPiecesStraightIntoItsSegment() + { + var sample = TestReadBudget.Samples.Value[StructureId.MementoFilter]; + var large = TestReadBudget.InTheLargeForm(sample).Bytes; + var filter = (MementoFilter)sample.Read(sample.Bytes, ReadOptions.Unlimited); + Assert.HasCount(1, filter.Segments, "premise: the sample is not one table"); + + var words = filter.Segments[0].Data; + var table = new byte[words.Length * sizeof(ulong)]; + for (var w = 0; w < words.Length; w++) + { + BinaryPrimitives.WriteUInt64LittleEndian(table.AsSpan(w * sizeof(ulong)), words[w]); + } + + long at = large.AsSpan().IndexOf(table); + Assert.IsGreaterThanOrEqualTo(0L, at, "the table is not in the payload in the large form"); + Assert.AreEqual(at, (long)large.AsSpan().LastIndexOf(table), "the table appears in the payload twice"); + Assert.IsGreaterThan((long)ShippedBlockPiece, (long)table.Length, + $"the table's {table.Length:N0} bytes are no longer than a piece as it ships"); + var end = at + table.Length; + + foreach (var piece in new int?[] { null, Piece }) + { + var length = (long)(piece ?? ShippedBlockPiece); + var reads = SecondPass(sample, large, piece).Where(a => a.From < end && at < a.To).ToList(); + var expected = new List(); + for (var from = at; from < end; from += length) + { + expected.Add(new Asked(false, from, Math.Min(from + length, end), false)); + } + + Console.WriteLine( + $"[TABLE] MementoFilter: {table.Length:N0} bytes at {at:N0}, piece {length:N0}: " + + $"{reads.Count:N0} reads, the first {reads[0].Count:N0} bytes"); + CollectionAssert.AreEqual(expected, reads, + $"the table's {table.Length:N0} bytes at {at:N0}, with the piece at {length:N0}, were read in " + + $"{reads.Count:N0} reads, the first of {reads[0].Count:N0} bytes through the " + + $"{(reads[0].Array ? "array" : "span")} overload, and not in {expected.Count:N0} pieces through the span overload"); + } + } + /// /// An 's table, whose words have no length before them, is read /// from the large form in pieces straight into its segment's table (#313), as a count-min diff --git a/TestProbabilisticDataStructures/TestMementoFilter.cs b/TestProbabilisticDataStructures/TestMementoFilter.cs index 4a261c8..448fab6 100644 --- a/TestProbabilisticDataStructures/TestMementoFilter.cs +++ b/TestProbabilisticDataStructures/TestMementoFilter.cs @@ -241,6 +241,108 @@ public void TestRemovingLeavesEverythingElseIntact() } } + /// + /// A read holds each table once and is charged for it once (#327), as an + /// read is (#313). In the ordinary envelope, what it allocates + /// and is charged beyond the payload it holds is the tables it builds, read into them and + /// not first into arrays of their own and copied; in the large form, where the payload is + /// not held, it is the tables alone. + /// + /// + /// Two-bit fingerprints are the narrowest, and an entry's runs out after the fewest + /// expansions, so a filter built for 2,048 keys and given 48,000 keeps a table behind the + /// first, of the size it had when they ran out. The slack, 4 KiB, is short of the first + /// table and of the second, which a premise checks, so a second copy of either fails it, + /// as one of every table does. Each figure is held from below too, to the payload and the tables, + /// so that a measurement of nothing cannot pass. The reads are counted in a no-GC region + /// (#293). + /// + [TestMethod] + public void TestAReadHoldsEachTableOnce() + { + const long Slack = 4 * 1024; + + var filter = new MementoFilter(64, 2, initialCapacity: 2048); + for (ulong i = 0; i < 48_000; i++) + { + filter.Add(i * 7919); + } + + long TableBytes(MementoSegment segment) => ReadBudget.ArrayOf((ulong)segment.Data.Length, sizeof(ulong)); + var tables = filter.Segments.Sum(TableBytes); + Assert.IsGreaterThan(1, filter.Segments.Count, "premise: the chain does not hold tables behind the first"); + Assert.IsGreaterThan(Slack, TableBytes(filter.Segments[1]), + $"premise: a copy of the second table could hide in the slack: tables of {string.Join(", ", filter.Segments.Select(TableBytes))} bytes"); + + var bytes = filter.ToByteArray(); + long payload = bytes.Length - PersistenceFormat.EnvelopeLength; + MementoFilter.ReadFrom(new MemoryStream(bytes, writable: false)); + var ordinary = NoGCRegion.Allocated(() => MementoFilter.ReadFrom(new MemoryStream(bytes, writable: false))); + var charged = CapacityModel.ChargeOf(StructureId.MementoFilter, bytes); + + var large = LargeForm.WriteLarge(filter); + Assert.IsTrue(LargeForm.IsLarge(large), "the filter was not written in the large form"); + MementoFilter.ReadFrom(new MemoryStream(large, writable: false)); + var streamed = NoGCRegion.Allocated(() => MementoFilter.ReadFrom(new MemoryStream(large, writable: false))); + var budget = new ReadBudget(long.MaxValue, StructureId.MementoFilter); + CollectionAssert.AreEqual(bytes, + MementoFilter.ReadFrom(new MemoryStream(large, writable: false), new ReadOptions(budget)).ToByteArray(), + "the large form does not read back as the filter"); + + Console.WriteLine( + $"[HOLDS ONCE] MementoFilter: tables of {string.Join(", ", filter.Segments.Select(TableBytes))} bytes, {tables:N0} in all, payload {payload:N0}; " + + $"ordinary read {ordinary:N0}, charged {charged:N0}; large-form read {streamed:N0}, charged {budget.Charged:N0}"); + + Assert.IsGreaterThanOrEqualTo(payload + tables, ordinary, + $"the read allocated {ordinary:N0} bytes, less than the {payload:N0}-byte payload it holds and the {tables:N0} bytes of its tables"); + Assert.IsLessThanOrEqualTo(payload + tables + Slack, ordinary, + $"the read allocated {ordinary:N0} bytes, {ordinary - payload - tables:N0} more than its payload and its tables"); + Assert.IsGreaterThanOrEqualTo(payload + tables, charged, + $"the read was charged {charged:N0} bytes, less than its payload and its tables"); + Assert.IsLessThanOrEqualTo(payload + tables + Slack, charged, + $"the read was charged {charged:N0} bytes, {charged - payload - tables:N0} more than its payload and its tables"); + + Assert.IsGreaterThanOrEqualTo(tables, streamed, + $"the read of the large form allocated {streamed:N0} bytes, less than the {tables:N0} bytes of its tables"); + Assert.IsLessThanOrEqualTo(tables + Slack, streamed, + $"the read of the large form allocated {streamed:N0} bytes, {streamed - tables:N0} more than its tables"); + Assert.IsGreaterThanOrEqualTo(tables, budget.Charged, + $"the read of the large form was charged {budget.Charged:N0} bytes, less than its tables"); + Assert.IsLessThanOrEqualTo(tables + Slack, budget.Charged, + $"the read of the large form was charged {budget.Charged:N0} bytes, {budget.Charged - tables:N0} more than its tables"); + } + + /// + /// A read charges each segment at least what restoring it allocates, and less than a + /// small object past that: its table, and the segment with its codec, which come to more + /// than one small object allows for. The restores are counted in a no-GC region. + /// + /// + /// Probed on 5752769, a segment and its codec came to 120 bytes beyond the table, at + /// tables of 2^2, 2^8 and 2^12 slots, and were charged + /// , 64, beside two tables. Charged one table (#327), + /// they would be 56 bytes a segment under what the read allocates. The shapes are the + /// narrowest table, where the segment is the largest share of what is allocated, the + /// widest fingerprint, and two between. The first restore of each is not counted, so that + /// what the runtime does once is not taken for the segment. + /// + [TestMethod] + public void TestASegmentIsChargedWhatRestoringItAllocates() + { + foreach (var (q, f, m) in new[] { (2, 2, 1), (2, 32, 8), (8, 8, 3), (12, 16, 3) }) + { + var words = (uint)MementoSegment.Restore(q, f, m, 1).Data.Length; + var charged = MementoFilter.SegmentCost(words); + var allocated = NoGCRegion.Allocated(() => MementoSegment.Restore(q, f, m, 1)); + Assert.IsLessThanOrEqualTo(charged, allocated, + $"a segment of 2^{q} slots, {f}-bit fingerprints and {m}-bit mementos allocated {allocated:N0} bytes " + + $"to restore, and a read is charged {charged:N0} for it"); + Assert.IsLessThan(ReadBudget.SmallObject, charged - allocated, + $"a segment of 2^{q} slots, {f}-bit fingerprints and {m}-bit mementos is charged {charged:N0} bytes, " + + $"{charged - allocated:N0} past the {allocated:N0} restoring it allocated"); + } + } + /// /// The filter grows rather than filling up, and keys added before a growth /// survive it.