Repository navigation
perf: cut what edge ingest allocates by about a third - #97
Merged
Merged
Conversation
Each subscriber calls replay(acknowledged) after every commit. replay starts at the nearest index entry, which can be up to 63 frames back, and read and checksummed the payload of every one of those frames only to drop them. With three subscribers (statistics, materialized views, semantic index) and 1,000 hyperedges per commit, ingest.edges at scale 1 allocated 39.2 KB per hyperedge across all threads before this change and 32.0 KB after it (median of three runs of the same probe). FrameReader now takes the generation to skip through and steps over the payload of an older frame after reading its 16-byte header. It also reuses one header buffer instead of allocating a buffer and a memory segment per frame. restore() and truncateAfter() still read and verify every frame.
TreeDiff expanded a leaf by pushing one Element object per entry onto the walker's stack. The change log diffs every changed catalog and incidence leaf at commit, and Hyperedge.diff diffs every loaded member tree, so most of those objects were thrown away a moment later. A leaf now goes on the stack as one Run that holds the leaf and the position of the next entry. The deltas and their order are unchanged. A randomized test checks diff against an oracle in both directions and against an empty tree.
BulkBuilder allocated a 64-entry key array and value array up front. Hyperedge.build creates two builders per hyperedge, members and the order index, and the order index stays empty for a SET edge, so a 4-member edge allocated about 1.6 KB of arrays to hold four entries. The arrays now start empty and grow to 8 entries on the first add, then double as before.
IncidenceCodec.encode built a stream per column to find which columns any value uses. The change feed encodes each member change on its own, so a hyperedge with four members created 24 streams at commit. A plain loop gives the same flags.
Writer.load took a List<Long> and, for every member, checked write permission again, decoded the edge and its type, streamed over the roles, interned the role set and streamed over the weight. Only the member's own existence check depends on the member. load(edge, long[], spec) checks the edge and roles and builds the first incidence the way add() does, then checks each remaining member and copies the first incidence's columns. The List<Long> overload unboxes into the array and calls it. Errors are the same as before and come in the same order. Writer.assign checks required properties with a loop instead of a stream.
The adapter boxed every member id into a List<Long> before loading a hyperedge. It now uses the long[] overload of Writer.load, which is part of the public API.
venkat1701
force-pushed
the
perf/ingest-allocation
branch
from
October 14, 2026 13:31
ed3d20b to
419b19c
Compare
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.
Closes #56
Where main stands
The issue measured 54.8 KiB per hyperedge for HStore and 21.6 KiB for HyperGraphDB. The published results at
9291e18(benchmarks/report/results/s1-async) haveingest.edgesat 37.2 KB per hyperedge for HStore and30.0 KB for HyperGraphDB at scale 1, so HStore still allocated more. At scale 4 HStore was already well ahead,
at 104 KB against 892 KB.
An allocation profile of the ingest (JFR
jdk.ObjectAllocationSample) showed that about a third of the byteswere not on the ingesting thread at all:
replay(acknowledged)after every commit.
replaystarts at the nearest index entry, which can be up to 63 frames back. It readand checksummed the payload of each of those frames and then dropped it.
On the ingesting thread the cost was spread across many places. The largest were:
TreeDiffpushed oneElementper leaf entry onto its stack. The change log diffs every changed catalogand incidence leaf at commit, and
Hyperedge.diffdiffs every loaded member tree.BulkBuilderstarted with 64-entry key and value arrays.Hyperedge.buildmakes two builders per edge,and for a SET edge one of them stays empty.
IncidenceCodec.encodebuilt a stream per column to compute its flags. The feed encodes each memberchange on its own.
Writer.loadtook aList<Long>. For every member it rechecked write permission, decoded the edge andits type, streamed over the roles and the weight, and interned the role set.
Changes
ChangeFeed.FrameReadertakes the generation to skip through. It steps over the payload of an olderframe after reading its 16-byte header, and it reuses one header buffer.
restore()andtruncateAfter()still read and verify every frame.TreeDiffputs a whole leaf on its stack as oneRun(the leaf and a position). The deltas and theirorder are unchanged.
BulkBuilderarrays start empty, grow to 8 entries, then double.IncidenceCodec.encodefinds the present columns with a loop.Writer.load(long edge, long[] members, MemberSpec spec). It checks the edge and roles once, buildsthe first incidence the way
adddoes, then checks each remaining member and copies the firstincidence's columns. The
List<Long>overload unboxes into an array and calls it. The errors are thesame as before and come in the same order.
Writer.assignchecks required properties with a loop.HStoreStore) now passes member ids as along[]through the new publicoverload, the way a user loading from primitive ids would. The other workloads are unchanged.
docs/storage/change-feed.mdand theWritertable indocs/database/data-model.mddescribe thechanges.
Measurements
The numbers come from a small probe, not part of this branch, that runs the
benchmark's
ingestNodesand theningestEdgesat scale 1 (100,000 hyperedges, 1,000 per transaction)through the benchmark's own store adapters. It reports
getTotalThreadAllocatedBytesfor the whole process,as the comparison harness does, and the ingesting thread's own bytes. Main is
0c847f7, and every rowuses the same probe.
The GC count over a 10 s ingest is too coarse to show a
difference.
The effect of each commit is the median of 3 runs per commit, measured all threads:
TreeDiffWriter.loadlong[]Single runs vary by a few KB when the JIT compiles a path differently, so treat the per-commit steps as
approximate. I didn't rerun scale 4.
What is left
These would each need a decision, so they are not in this branch:
Statisticsrecomputes its histograms from a full scan of the catalog and the reverse index every 10,000changes. A 1,000-edge transaction makes about 5,000 changes, so during a bulk load that is a full scan every
second transaction, and its cost grows with the square of the load size. Making the threshold a fraction of the store size would fix that, but it changes how stale
the planner's statistics can get.
Statisticsa subscription that only sees the change counts, would change theChangeFeedAPI.ArrayListand once inList.copyOf, and a written leaf is copied on first write in each transaction.Tests
./mvnw -q install: engine 185, database 36, server 15, all passing.ChangeFeedTest.replayReadsOnlyTheHeaderOfAFrameItSkips: corrupts the payload of frame 2 and checksthat
replay(2)still returns 3, 4 and 5, whilereplay(0)stops at the bad frame. On main,replay(2)returns nothing, so the test fails there.
TreeTest.diffMatchesAnOracleAcrossRandomEdits(5 seeds): comparesTreeDiffwith an oracle in bothdirections, against itself, and against an empty tree.
DatabaseTest.bulkLoadedMembersMatchMembersAddedOneAtATime: members loaded from along[]or aList<Long>match members added one at a time with roles, weight and validity. It also covers an emptyload, an undeclared role, a missing member in the middle of the array, and a load into a node.
The
TreeDiff,BulkBuilder,IncidenceCodecandWriterchanges only reduce allocation, so their testscheck that behaviour is unchanged. They pass on main as well.