Skip to content

perf: warm reads up fairly and visit incident edges without records - #85

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

venkat1701 merged 5 commits into
mainfrom
perf/steady-reads

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

Stacked on #84.

Why

After #83 and #84, read.members still came out at about 0.6x HyperGraphDB. Profiling it outside the benchmark showed something else: once the JIT has compiled the read path, HStore enumerates members at about 6M per second and HyperGraphDB at 2 to 3M. The benchmark warmed reads up on 10% of their probes, and large.scan on 40 passes, a few milliseconds of work. Most of the timed pass ran before compilation finished, so the numbers mostly measured how fast each engine's code got compiled. That's also why large.scan swung between 0.4x and 8.6x from one run to the next.

The same profiling turned up some per-read allocation worth removing.

What changes

  • Warm-up. Every read workload, large.scan and latency.read now repeat the whole workload, untimed, for at least 2 seconds first. Both engines get the same warm-up. read.incidence.cold still runs cold. docs/benchmarks.md explains why.
  • Inline trees. Tree.forEachKey and forEachValue loop over an inline leaf directly instead of building a TreeCursor with its path arrays for a single leaf.
  • Reader.forEachIncidentEdge(atom, action). Hands the ids of the edges containing an atom to a LongConsumer, straight from the posting list, with the same tenant check as incident. Reader.incident builds an Incident record per edge, with its role names, which callers that only want ids don't need.
  • Bench adapter. HStore reads incidence sets through the new visitor and collects two-hop neighbours into a reused long[] instead of a chain of streams.

No format change.

Testing

  • Full suite: 192 tests pass, and each commit builds on its own.
  • New tests check the visitors against the streams they replace: inline trees in TreeTest, ordered inline edges in InlineMembersTest, and inline and promoted posting lists in DatabaseTest. I broke each fast path on purpose (dropped or changed an entry) and the tests caught every case.

Numbers

HStore against HyperGraphDB, scale 1, async, 64 MiB caches. The earlier column is the run behind #83, with the old warm-up. The new column is this branch: the median of 2 HStore runs and 3 HyperGraphDB runs.

Workload Ratio, earlier Ratio, this branch HStore heap per op, earlier → now HyperGraphDB heap per op
read.incidence 14.6× 9.4× 1.2 KiB → 1 B 118 B
read.members 0.57× 2.4× 973 B → 20 B 49 B
read.comembership 12.6× 12.7× 4.3 KiB → 4.0 KiB 8.1 KiB
read.twohop 2.1× 5.2× 8.9 KiB → 373 B 1.8 KiB
latency.read 4.9× 9.5× 1.5 KiB → 213 B 4.1 KiB
large.scan 8.6× 5.3× 0 B → 0 B 0 B

HyperGraphDB's own numbers went up too once it was warmed up properly. read.incidence went from 51k to 455k per second, which is why its ratio dropped even though HStore got faster.

A third HStore run stopped partway: background compaction found the active segment's file channel closed, and so did the shutdown after it. Eight more runs of this branch went through cleanly, and I haven't found what closed the channel in that one. The failure matches a problem that exists on main as well: when a thread is interrupted during file I/O, the JDK closes that channel for every thread. That gets its own issue and PR.

Measured on a laptop under load, so only the ratios mean much. The Azure rerun of the full comparison is still to do.

A member tree small enough to sit inside its edge record is a single
leaf, but forEachKey and forEachValue still built a TreeCursor with its
path arrays to read it. Loop over the leaf directly instead. Two-hop
reads visit many small edges, and this was most of what they allocated.
The HStore adapter built a stream per probe, flat-mapped member streams
into it and sorted through the stream API. Visit member ids into a
reused long array and sort and count it in place, the same way the
HyperGraphDB adapter fills one HashSet per probe.
Reads were warmed on 10% of their probes and large.scan on 40 passes, a
few milliseconds of work. Most of the timed pass then ran before the JIT
had compiled the read path, so the numbers mostly showed how quickly
each engine's code got compiled. read.members came out several times
below HStore's steady rate, and large.scan swung between runs by a
factor of ten.

Repeat each read workload, large.scan and latency.read untimed for at
least two seconds first. Both engines get the same warm-up, and
read.incidence.cold still runs cold.
Reader.incident builds a stream with an Incident record per edge,
resolving role names for each one. Callers that only need the edge ids
paid for all of that. Reader.forEachIncidentEdge hands the ids to a
LongConsumer straight from the posting list, inline or promoted, with
the same tenant check as incident.
The HStore adapter counted incident edges through Reader.incident,
which allocated about 1.1 KiB per lookup against 98 B for HyperGraphDB.
Count them through the new visitor instead, in the incidence, latency,
mixed and two-hop workloads.
@venkat1701
venkat1701 changed the base branch from perf/inline-members to main October 12, 2026 08:00
@venkat1701
venkat1701 merged commit 45daa18 into main Oct 12, 2026
@venkat1701
venkat1701 deleted the perf/steady-reads branch October 12, 2026 08:03
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