Skip to content

Keep small member trees inside their edge record - #84

Merged
venkat1701 merged 5 commits into
mainfrom
perf/inline-members
Oct 12, 2026
Merged

venkat1701 merged 5 commits into
mainfrom
perf/inline-members

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

Stacked on #83. Closes #55.

Every hyperedge kept its members in a tree of its own, even with three members, so reading them meant loading the edge record and then one more page. At scale 1 that is 100,000 extra pages and node-cache entries. This PR keeps small member trees inside the edge record and stops holding one Incidence object per member in plain member leaves.

What changes

  • Tagged references, format 6. A tree reference is now a tag byte and a body: 0 empty, 1 a stored page, 2 an inline leaf. Ref.Inline(Leaf) only accepts a frozen leaf with 1 to 32 entries, no nested trees and an encoded size within Ref.maxEncodedSize() (108 bytes). Only Ref.read(in, schema) can decode an inline leaf; the schema-less Ref.read(in) refuses one, so roots, branch children, WAL Root records, postings and property directories never hold one. The storage format goes from 5 to 6, and older directories are refused with the usual export-and-reload message.
  • Inlining at commit. Materializer inlines a pending leaf only for nested references in codecs that opt in (ValueCodec.inlinesSmallTrees(), only AtomCodec today) and only when it fits. A tree that grows past the limit gets a page; one that shrinks back below it becomes inline the next time its leaf is rewritten.
  • Equality by content. Ref.same and Inline.equals compare inline trees by their entries, so conflict checks and the set algebra still see an unchanged edge as unchanged. A stored and an inline reference are never "same", which only sends a caller down its slower path.
  • No page overflow. A pending reference is still budgeted at the full 108 bytes while an edge changes. At commit it becomes either a page reference or an inline leaf, and both fit that budget, so a frozen catalog leaf can only get smaller.
  • Reference counts, compaction, recovery and the feed needed no special handling. An inline tree has no page and no nested references, TreeWalker only reports stored references, and a catalog leaf whose edges are all inline leaves REFERS_TO_PAGES clear. Compaction moves whole pages, so inline trees travel with their catalog page.
  • Implied member values. When a member leaf has no per-member columns, it now keeps only its key array and builds Incidence.of(key) when a value is asked for. The file format is unchanged by this part, and the write path still works on real objects. A decoded 200-member set leaf now holds 8.8 bytes per member on the heap instead of 76.8.
  • ValueCodec.forEachRef visits nested references without building records. TreeWalker, TreeVerifier and the heap estimate use it, and the catalog leaf's heap estimate now includes its inline trees.

Testing

  • Full suite: 191 tests pass, and each commit builds and passes on its own.
  • New InlineMembersTest (24 cases):
    • edges with 0, 1, 3, 32 and 33 members, and a wide 20-member edge that has to be stored;
    • ordered edges with both trees inline, then grown and shrunk;
    • six trips across the limit and back, then a delete, checking reference counts after each step;
    • a random small-edge model;
    • the crash matrix over WAL modes and crash points, plus recovery of inline edges committed after the last checkpoint;
    • compaction, time travel and the change feed across the limit;
    • conflict detection between two transactions touching the same small edge;
    • the page budget.
  • TreeTest covers the encoding, content equality, the fit rule, and inline summaries and fingerprints matching the stored page's. For implied values it checks equal values, a smaller heap estimate, and byte-identical re-encoding.
  • EngineTest checks that a format-5 directory is refused.
  • Native image: examples/clinical-claims.hql loads and hstore check passes on 20 pages, where the same data needed 105. After a SIGKILL partway through a 20,000-insert load, reopening replayed 475 commits and the reference counts matched. COMPACT ran and check passed again.

Measurements

HStore only, scale 1, async, 64 MiB node cache, 3 alternating runs per build, medians. The machine was loaded, so trust allocation, bytes read and disk over throughput.

#83 this PR
Disk after load 197 MiB 145 MiB
Disk after the deletes 430 MiB 297 MiB
Disk after a full compaction 202 MiB 66 MiB
Heap kept after loading 54 MiB 39 MiB
read.members 616k/s, 979 B allocated, 73 B read per op 727k/s, 493 B allocated, 0 read
read.twohop 64k/s, 8.8 KB allocated per op 90k/s, 5.9 KB
ingest.edges allocation per edge 58 KB 35 KB

Known costs

  • large.probe allocates about 64 bytes more per lookup, because a single member's Incidence is now built when asked for.
  • Decoding a catalog leaf now builds an inline leaf per edge. churn.delete allocation moved between 75 and 91 KB per op across runs, which looks like a wash but is worth watching.
  • A stored member tree that shrinks below the limit stays a page until its leaf is next rewritten.
  • The tag byte costs one byte per branch child, so a 16 KiB branch holds 137 children instead of 138. Empty references shrink from 8 bytes to 1.

A stored reference used to start with a raw page id, with page id 0
meaning an empty tree. References now start with a one byte tag
(empty, stored, inline), and the new inline variant carries a small
frozen leaf directly in the parent value instead of pointing at a page.

Inline leaves are limited to 32 entries, no nested trees, and an
encoded size no bigger than a stored reference can take, so a parent
leaf that budgeted for a reference can never overflow. Inline
references compare by their entries, not by identity, and a catalog
leaf counts the heap of the inline leaves it holds.

Nothing creates inline references yet. The storage format moves to 6
because every reference on disk gained the tag byte.
When a commit writes a catalog leaf, a member or order tree that is a
single leaf of at most 32 entries, small enough to fit where a stored
reference would go, now stays in the edge record instead of getting a
page of its own. A three member edge no longer costs an extra page, an
extra cache entry and an extra read.

Only codecs that opt in through inlinesSmallTrees get this, which today
is just the catalog. Slot roots and branch children are still always
written as pages. The page quota check skips trees that will be inlined.

Growing past the limit turns the tree into a page on its next commit,
and shrinking below it brings it back the next time its leaf is
rewritten. Reference counts need no special handling since an inline
tree has no page to count.

Refs #55
…aves

Most member leaves have no roles, weights, validity, data or qualifiers,
so every Incidence in them is just Incidence.of(member). Such a leaf now
keeps only its member ids (the keys themselves for set edges) and builds
the Incidence when someone asks for a value. The file format is the same:
the codec spots a leaf whose column flags are all clear, and writes one
back out straight from the ids.

A decoded 200 member leaf drops from about 77 to 9 bytes per member on
the heap for set edges and to 17 for ordered ones, and decoding it
allocates about 2 KB instead of 16 KB. Writable copies still hold real
Incidence objects, so the write path is unchanged.
Covers the tagged reference encoding, when a tree is kept inline and why
that can't overflow a page, how equality, reference counts, compaction
and the change feed treat inline trees, the leaves that build their
Incidence values on demand, and the format 6 refusal message. The
example outputs from clinical-claims.hql were regenerated, and the
benchmark page gets a before and after comparison.
@venkat1701
venkat1701 changed the base branch from perf/member-reads to main October 12, 2026 07:56
@venkat1701
venkat1701 merged commit 9cab09c into main Oct 12, 2026
@venkat1701
venkat1701 deleted the perf/inline-members branch October 12, 2026 08:01
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.

Small edges always get their own membership tree

1 participant