Repository navigation
Format 1.4: compact entry metadata and key-less pages for exact digests - #39
Merged
Merged
Conversation
Two new page-level layouts, both written by default and gated per page by NodeFlags (readers accept 1.3 and 1.4 files): - CompactMeta: entry metadata becomes an absolute ushort offset array (entry_count + 1 slots); every length is derived from adjacent offsets. Leaf pages that store keys append ushort key lengths; internal pages need none (payload per entry is key + 8-byte child ordinal). Bit 15 of a leaf offset flags an overflow entry, so the layout applies when pageSize <= 32767 and larger pages keep the classic records. Leaf meta: 8B -> 2-4B per entry. Internal meta: 6B -> 0-2B. - OmittedKeys: when the key encoding declares IsKeyDigestExact (Int64: the sign-flipped digest is a bijection), pages store no key bytes at all. The digest array doubles as the key column: comparisons reduce to digest compares, and enumeration reconstructs key bytes via the new IKeyEncoding.TryDecodeKeyFromDigest. Internal pages drop their meta area entirely (payload = dense child ordinal array). Never combined with Eytzinger (entry i's sorted-order digest must be addressable). An Int64 table entry with an 8-byte value drops from 32B to 18B of fixed cost (digest 8 + offset 2 + value 8); a 4KB leaf holds ~227 entries instead of ~128, which widens fanout and roughly doubles cache density. Digests are now computed once when entries are appended instead of being re-derived from the page buffer at flush time, which is what allows the key bytes to be absent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compact GetMeta read offset[i] and offset[i+1] as two ushort loads; they are adjacent on the page, so a single unaligned 4-byte load covers both (the format is little-endian only). Recovers the fixed-key lookup and range-scan cost of the derived-length layout: 1M point lookups and GetRange are back at parity with the classic meta records. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since format 1.4 the option no longer represented a real trade-off: for exact-digest encodings the digest column replaces the key bytes, so disabling digests made files bigger (12B vs 10B fixed cost per Int64 entry) and lookups slower at the same time. Digests are now always written when the encoding supports them; an encoding whose keys collide badly in the first 8 bytes (long shared prefixes degrade the digest lower-bound scan) opts out by declaring SupportsKeyDigest => false, which is what the reader's digest-less search path continues to serve alongside 1.3 files. PmcProbe loses its no-digest row: faking the layout with a digest-less custom encoding would swap the devirtualized Int64 comparer for the interface-dispatch fallback and contaminate exactly the comparison that row existed for. The historical numbers (measured against 1.3) stay in its README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The digest-capability flag (IKeyEncoding.SupportsKeyDigest) is removed; GetKeyDigest becomes a required, order-preserving member and every tree page carries a digest array. Encodings whose order cannot spread keys into 64 bits should normalize at encode time (store a collation key). - Uuidv7 gains a real digest: Guid.CompareTo orders field-by-field with unsigned comparisons, so the first three fields packed in comparison order preserve it (and lead with the UUIDv7 timestamp). - Ulid gains the big-endian first 8 bytes (its comparison is byte-lexicographic), covering timestamp + 2 random bytes. - The readers drop the digest-less search paths entirely: the HasKeyDigests page flag (bit 8, formats 1.1-1.3) is retired, the hasKeyDigest plumbing through TreeWalker and both node readers is gone, and the meta base is computed from an unconditional digest area. Because readers now assume the digest area, 1.3 files are rejected (strict 1.4-only, as 1.3 was strict in its day). - New KeyDigestContractTest verifies the order-preserving contract pairwise over randomized keys for i64/ascii/uuidv7/ulid, plus i64 digest round-tripping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Summary
Storage format 1.4: shrinks the fixed per-entry cost of every B+Tree page with two new page-level layouts, both written by default and gated per page by
NodeFlags.CompactMeta
Entry metadata becomes an absolute
ushort offsets[entry_count + 1]array; every length is derived from adjacent offsets, and the final slot closes the last entry's payload.ushort key_lengths[entry_count](the key/value boundary is the one thing offsets cannot derive): meta 8B → 4B per entry.0xFFFFvalue-length sentinel. This is why the layout requirespageSize <= 32767; larger pages keep the classic meta records automatically.OmittedKeys
When the key encoding declares the new
IKeyEncoding.IsKeyDigestExact(Int64: the sign-flipped digest is a bijection), pages store no key bytes at all:RangeIterator.CurrentKey) reconstructs the original bytes via the newIKeyEncoding.TryDecodeKeyFromDigest.An Int64 entry with an 8-byte value drops from 32B to 18B of fixed cost; a 4KB leaf holds ~227 entries instead of ~128, which widens fanout and roughly doubles cache/page density.
Digests are mandatory (option and capability flag both removed)
With 1.4 the
DatabaseBuilder.KeyDigestsswitch no longer represented a real trade-off: for exact-digest encodings the digest column replaces the key bytes, so disabling digests made files bigger (12B vs 10B fixed cost per Int64 entry) and lookups slower at the same time. Going further, the capability flagIKeyEncoding.SupportsKeyDigestis removed too:GetKeyDigestis now a required, order-preserving member, and every tree page carries a digest array — "the key column is always separated" becomes a format invariant rather than a per-table mode.Guid.CompareTocomparison order — leading with the UUIDv7 timestamp), and Ulid gains the big-endian first 8 bytes (byte-lexicographic order). Both are covered by a new pairwise order-preservation contract test.HasKeyDigestspage flag (bit 8, formats 1.1–1.3) is retired and the per-searchhasKeyDigestplumbing is gone.no-digestrow (that layout no longer exists; the historical 1.3 numbers stay in its README).Compatibility
IsKeyDigestExact/TryDecodeKeyFromDigestare default interface members. Non-unique secondary indexes (rid-suffixed composite keys) have inexact digests and keep their keys.DatabaseBuilder.KeyDigestsis removed,IKeyEncoding.SupportsKeyDigestis removed, andIKeyEncoding.GetKeyDigestloses its default implementation (custom encodings must now provide an order-preserving digest).Measured (Apple M5, .NET 10, same-machine ShortRun; µs / 1000 lookups)
The hot search loop only touches the digest array, so per-probe speed is unchanged; the wins come from density (more entries per page/cache line), and grow with working-set size and randomness. Fixed-key rows are known to swing ±20–40% between builds from code alignment; the 10k fixed-key delta is within that band while 1M fixed is at parity (the second commit fuses the adjacent offset loads into one 4-byte read, which recovered the initial fixed-key/range regression).
File size (pageSize 4096, Int64 keys):
Tests
95/95 passing. New
CompactMetaTestcovers: Int64 omitted-keys flags + full lookups over negative/positive keys, iterator key reconstruction from digests, overflow entries through the bit-15 path (point + range reads), ASCII compact pages (keys kept), thepageSize > 32767fallback to classic records, Eytzinger pages keeping their keys, and the file-size shrink.KeyDigestContractTestverifies the order-preserving digest contract pairwise over randomized keys for i64/ascii/uuidv7/ulid, plus i64 digest round-tripping. Two existing assertions changed only because leaves now hold more entries (4 instead of 3 in the 128-byte-page fixture); the digest-less-page test was removed together with the code path it covered.🤖 Generated with Claude Code