Skip to content

Read members and scan large edges without per-member objects - #83

Merged
venkat1701 merged 5 commits into
mainfrom
perf/member-reads
Oct 12, 2026
Merged

venkat1701 merged 5 commits into
mainfrom
perf/member-reads

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

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.scan was measured. It changes no file format.

What changes

  • Tree cursors size their path to the tree. Every cursor allocated two 48-slot arrays, about 416 bytes, which 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.
  • Keys and values without entries. Tree.keys() is now a primitive spliterator over each leaf's key array instead of a stream of Entry objects. New Tree.forEachKey, Tree.forEachValue and TreeCursor.forEachRemainingKey hand keys or values straight to a consumer, and Hyperedge.forEachMemberId / Reader.forEachMemberId give member ids with nothing allocated per member.
  • Node cache hits don't box. Each shard used a 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.
  • Interpolation before binary search. Page numbers and atom ids are handed out in order, so Leaf.search and Branch.route on 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.
  • Benchmark fixes. The HStore adapter now reads member ids through Reader.forEachMemberId, the same way the HyperGraphDB adapter loops over a link's array. large.scan used to time 20 passes right after large.ingest and 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

  • Decoding pages straight from the memory map. The profile suggested it, but docs/storage/pages.md records that it made decoding two to five times slower: the JIT only makes ByteCursor fast when every segment it sees is the same kind. The heap copy stays.
  • Keeping small member lists inline in the edge record (Small edges always get their own membership tree #55). It needs a format change, so it comes in a separate PR on top of this one.

Testing

  • Full suite: 157 tests pass, and each commit compiles on its own.
  • New TreeTest.keysAndCursorsWalkADeepTreeInOrder builds a tree more than five levels deep, so cursors have to grow their path, and checks keys(), forEachKey, forEachValue, descending order, ceiling, floor and forEachRemainingKey after a seek against a TreeMap.
  • New NodeSearchTest compares Leaf.search and Branch.route against a plain binary search on 320,000 probes over evenly spread, random, cubic and split key sets.
  • New NodeCacheTest cases: 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:

Workload HStore HyperGraphDB Ratio Ratio in the Azure runs before this PR
large.scan 646M members/s 75M 8.6 0.62
read.members 668k/s 1.17M 0.57 0.27
read.twohop 42k/s 20k 2.1 1.39
read.incidence 745k/s 51k 14.6 9.85

read.members still 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.

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.
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.

1 participant