Repository navigation
Read members and scan large edges without per-member objects - #83
Merged
Merged
Conversation
Every cursor allocated two 48-slot arrays for its path, about 416 bytes, even though trees here are three or four levels deep. That was close to half of what a member lookup allocated. The arrays now start with room for four levels and double when a descent goes deeper, up to the same limit of 48.
Tree.keys() mapped a stream of Entry objects to their keys, so every key read allocated an entry and went through stream plumbing. keys() is now a primitive spliterator over each leaf's key array, and Tree.forEachKey, Tree.forEachValue and TreeCursor.forEachRemainingKey hand keys or values to a consumer directly. Hyperedge.forEachMemberId and Reader.forEachMemberId use them to give member ids without allocating anything per member.
Each node cache shard kept its entries in a ConcurrentHashMap<Long, Slot>, so every lookup boxed the page number. A shard now keeps an open-addressing table of slots probed from the page number's hash. Readers probe it without a lock, reading slots with acquire ordering. Writers change it only under the shard's existing admission lock and publish slots with release ordering. The table stays at most half full and is rebuilt into a new array when it would pass that.
Page numbers and atom ids are handed out in order, so keys in a node are close to evenly spread. Leaf.search and Branch.route on nodes with 16 or more keys now guess the position from where the key falls between the first and last key, check 8 places around the guess, and fall back to a binary search over what is left. Results are exactly what the binary search gives, which a new test checks over evenly spread, random, skewed and split key sets.
…perly The HStore adapter counted members through LongStream, building a stream and an entry per member, while the HyperGraphDB adapter loops over the link's array. It now uses Reader.forEachMemberId. large.scan ran 20 passes right after large.ingest and its checkpoint, a window of 10 to 40 ms that background maintenance ran into, and counted allocation from every thread. It now warms up with 40 passes and times 400. The single-threaded read workloads count only the reading thread's allocation.
This was referenced Oct 11, 2026
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.
Member reads and the large-edge scan were the two workloads where HyperGraphDB clearly beat HStore in the benchmark (0.27 to 0.50 and 0.21 to 0.62 times its speed). Profiling showed neither was I/O: both spent their time creating short-lived objects. This PR removes most of them and fixes how
large.scanwas measured. It changes no file format.What changes
Tree.keys()is now a primitive spliterator over each leaf's key array instead of a stream ofEntryobjects. NewTree.forEachKey,Tree.forEachValueandTreeCursor.forEachRemainingKeyhand keys or values straight to a consumer, andHyperedge.forEachMemberId/Reader.forEachMemberIdgive member ids with nothing allocated per member.ConcurrentHashMap<Long, Slot>, so every lookup boxed the page number. A shard now keeps an open-addressing table that readers probe without a lock (acquire reads) and writers change under the existing admission lock (release writes). The table is kept at most half full and rebuilt into a new array when it would pass that.Leaf.searchandBranch.routeon nodes with 16 or more keys guess the position, check 8 places around it, and only then binary search what's left. Results are exactly the same as before.Reader.forEachMemberId, the same way the HyperGraphDB adapter loops over a link's array.large.scanused to time 20 passes right afterlarge.ingestand its checkpoint, a 10 to 40 ms window that background maintenance ran into, and it counted allocation from every thread. It now warms up with 40 passes and times 400, and the single-threaded read workloads count only the reading thread's allocation.Not included
docs/storage/pages.mdrecords that it made decoding two to five times slower: the JIT only makesByteCursorfast when every segment it sees is the same kind. The heap copy stays.Testing
TreeTest.keysAndCursorsWalkADeepTreeInOrderbuilds a tree more than five levels deep, so cursors have to grow their path, and checkskeys(),forEachKey,forEachValue, descending order,ceiling,floorandforEachRemainingKeyafter a seek against aTreeMap.NodeSearchTestcomparesLeaf.searchandBranch.routeagainst a plain binary search on 320,000 probes over evenly spread, random, cubic and split key sets.NodeCacheTestcases: 50,000 pages found and absent ones not found, and four reader threads checking every hit is the right page while a writer inserts, replaces and evicts 300,000 times.Timing
The laptop was loaded (load average around 17), so these are a single run and only the direction is worth reading. Scale 1, 64 MiB caches, both engines, checksums agreeing on every workload:
large.scanread.membersread.twohopread.incidenceread.membersstill allocates about 1 KB per call, mostly from cache misses on each hyperedge's separate member page. The inline-members PR addresses that. A clean Azure run is still needed before quoting any of these numbers.