Repository navigation
perf: stop allocating to pin a generation on every read - #96
Merged
Merged
Conversation
Every read transaction allocated an AtomicBoolean only to make close() idempotent. A volatile field set by compare-and-set through a VarHandle does the same without the extra object.
Every snapshot and transaction pinned its generation by merging a count into a ConcurrentSkipListMap<Long, Integer>, and unpinned it with computeIfPresent. With one reader at a time the entry was inserted and removed on every read, so a single-lookup read transaction allocated about 130 bytes in boxed keys and skip-list nodes before it read anything. history now maps each generation id to a Retained entry that holds the generation, an AtomicInteger reader count and a dropped flag. current points at the current generation's entry, and a Snapshot or Transaction keeps the entry it pinned. Pinning increments the count and checks the flag; trimHistory sets the flag and checks the count, and backs off if a reader got there first. oldestPinned walks the history, which holds at most history_limit entries plus the pinned ones. Opening and closing a snapshot now allocates only the Snapshot: 32 bytes, against 180 before, measured by a new test. The database module's tests can now read jdk.management for that measurement.
Tree.first() and Tree.last() built a TreeCursor, with its path and slot arrays, to return one entry. Reader checks the last key of the tenants tree at the start of every read transaction, so each single-lookup read paid for a cursor. Both now descend the leftmost or rightmost child to a leaf and fall back to the cursor only if that leaf is empty.
venkat1701
force-pushed
the
perf/read-allocation
branch
from
October 14, 2026 13:30
fa7a565 to
a1af75a
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 #57
Where main stands
Most of #57 was fixed before this branch. Since
forEachIncidentEdgelanded in #85, a batched incidencelookup allocates about 1 B. The published results (
benchmarks/report/results, commit9291e18, scale 1,async) show
read.incidenceat 1 B per lookup,mixed.readat 64 to 119 B per read with 1 to 3collections in the 10 s run, and
latency.readat 163 to 293 B per lookup. The issue reported 1.2 to1.9 KiB per lookup and 1.4 s of GC pauses.
What remained was a fixed cost per read transaction. A lookup that opens its own transaction, as
latency.readandmixed.readdo, paid about 170 B before it read anything. An allocation profile (JFRjdk.ObjectAllocationSample, small TLABs) showed where it went:TransactionManager.pinandunpinkept a count per generation in aConcurrentSkipListMap<Long, Integer>.With one reader at a time, every read inserted and removed the map entry, so each read allocated boxed
Longkeys, a skip-list node and an index node. This was about three quarters of the cost.Readerchecks the last key of the tenants tree once per transaction, andTree.last()built aTreeCursorwith its path and slot arrays to return one entry.Snapshotheld anAtomicBooleanto makeclose()idempotent.Changes
historynow maps each generation id to aRetainedentry: the generation, anAtomicIntegerreadercount and a
droppedflag.currentpoints at the current generation's entry, and aSnapshotorTransactionkeeps the entry it pinned, so pinning and unpinning only touch that counter. A readerincrements and then checks
dropped.trimHistorysetsdroppedand then checks the count, and backsoff if a reader got there first.
oldestPinned()walks the history, which holds at mosthistory_limitentries plus the pinned ones. Trimming keeps the same rules as before: pinned and current generations
stay, and a pinned generation is skipped rather than treated as a barrier.
Tree.first()andTree.last()descend the leftmost or rightmost child to a leaf. They fall back to thecursor only if that leaf is empty.
Snapshot.close()sets a volatile field with compare-and-set through aVarHandle.No public API changes. The benchmark adapter is unchanged.
Measurements
The numbers come from a small probe, not part of this branch, that drives
the benchmark's own
HStoreStoreadapter at scale 1 with a 64 MiB cache. It reads the reading thread'sgetCurrentThreadAllocatedBytes, and for the mixed rungetTotalThreadAllocatedBytes. Main is0c847f7and the branch is this one, using the same probe on the same machine. Each read workload ran 5 times with 3
passes each, and the table shows the median over the 15 passes.
read.incidence(1,000 lookups per transaction)read.members(1,000 lookups per transaction)read.twohopmixed.read(7 readers, one writer at 1,000 updates/s, 10 s, all threads)The mixed row divides all allocation, including the writer's and the background threads', by the number of
reads, so it also moves with read throughput. The machine was loaded, so the GC counts only show a tendency.
A new test,
ReadAllocationTest, opens and closes 10,000 snapshots after a warm-up at a generation abovethe
Longcache. Main allocates 180 B per snapshot and this branch allocates 32 B, which is theSnapshotitself. The test fails on main. To read the allocation counter, the database module's testsnow add and read
jdk.management, in the same way they already do forjdk.httpserver.What is left
A member lookup still creates a catalog
Tree, anOptional, aHyperedgeand its two member trees. TheJIT removes them in some runs and not in others, which is where the 40 B in
read.membersand the 372 B inread.twohopcome from. Removing them needs a member walk that bypassesHyperedge, and I left thatfor a separate change. The 40 B left in a single-lookup transaction is the
Snapshotand theReader.Tests
./mvnw -q install: engine 181, database 36, server 15, all passing.EngineTest.aPinnedGenerationOutlivesTheHistoryLimitUntilItsReadersClose: withhistory_limit2, anopen snapshot and an open transaction keep their generation across later commits, and
oldestPinnedreports it. A second
close()is a no-op, and the generation is trimmed after both close.TreeTest.firstAndLastFollowEditsAtBothEnds: coversfirstandlaston a multi-level tree while bothends are edited, and on an empty tree.
ReadAllocationTest.openingAndClosingASnapshotAllocatesOnlyTheSnapshot, described above.