Skip to content

perf: stop allocating to pin a generation on every read - #96

Merged
venkat1701 merged 3 commits into
mainfrom
perf/read-allocation
Oct 14, 2026
Merged

venkat1701 merged 3 commits into
mainfrom
perf/read-allocation

Conversation

@venkat1701

Copy link
Copy Markdown
Collaborator

Closes #57

Where main stands

Most of #57 was fixed before this branch. Since forEachIncidentEdge landed in #85, a batched incidence
lookup allocates about 1 B. The published results (benchmarks/report/results, commit 9291e18, scale 1,
async) show read.incidence at 1 B per lookup, mixed.read at 64 to 119 B per read with 1 to 3
collections in the 10 s run, and latency.read at 163 to 293 B per lookup. The issue reported 1.2 to
1.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.read and mixed.read do, paid about 170 B before it read anything. An allocation profile (JFR
jdk.ObjectAllocationSample, small TLABs) showed where it went:

  • TransactionManager.pin and unpin kept a count per generation in a ConcurrentSkipListMap<Long, Integer>.
    With one reader at a time, every read inserted and removed the map entry, so each read allocated boxed
    Long keys, a skip-list node and an index node. This was about three quarters of the cost.
  • Reader checks the last key of the tenants tree once per transaction, and Tree.last() built a
    TreeCursor with its path and slot arrays to return one entry.
  • Snapshot held an AtomicBoolean to make close() idempotent.

Changes

  • history now maps each generation id to a Retained entry: 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, so pinning and unpinning only touch that counter. A reader
    increments and then checks dropped. trimHistory sets dropped and then 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. 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() and Tree.last() descend the leftmost or rightmost child to a leaf. They fall back to the
    cursor only if that leaf is empty.
  • Snapshot.close() sets a volatile field with compare-and-set through a VarHandle.

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 HStoreStore adapter at scale 1 with a 64 MiB cache. It reads the reading thread's
getCurrentThreadAllocatedBytes, and for the mixed run getTotalThreadAllocatedBytes. Main is 0c847f7
and 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.

Workload main branch
one incidence lookup per read transaction 172.8 B 40.8 B
one member lookup per read transaction 171.9 B 40.0 B
read.incidence (1,000 lookups per transaction) 1.4 B 1.2 B
read.members (1,000 lookups per transaction) 40.3 B 40.1 B
read.twohop 372.5 B 372.2 B
mixed.read (7 readers, one writer at 1,000 updates/s, 10 s, all threads) 173.6 B per read, 3 GCs 67.8 B per read, 2 GCs

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 above
the Long cache. Main allocates 180 B per snapshot and this branch allocates 32 B, which is the
Snapshot itself. The test fails on main. To read the allocation counter, the database module's tests
now add and read jdk.management, in the same way they already do for jdk.httpserver.

What is left

A member lookup still creates a catalog Tree, an Optional, a Hyperedge and its two member trees. The
JIT removes them in some runs and not in others, which is where the 40 B in read.members and the 372 B in
read.twohop come from. Removing them needs a member walk that bypasses Hyperedge, and I left that
for a separate change. The 40 B left in a single-lookup transaction is the Snapshot and the Reader.

Tests

./mvnw -q install: engine 181, database 36, server 15, all passing.

  • EngineTest.aPinnedGenerationOutlivesTheHistoryLimitUntilItsReadersClose: with history_limit 2, an
    open snapshot and an open transaction keep their generation across later commits, and oldestPinned
    reports it. A second close() is a no-op, and the generation is trimmed after both close.
  • TreeTest.firstAndLastFollowEditsAtBothEnds: covers first and last on a multi-level tree while both
    ends are edited, and on an empty tree.
  • ReadAllocationTest.openingAndClosingASnapshotAllocatesOnlyTheSnapshot, described above.

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
venkat1701 force-pushed the perf/read-allocation branch from fa7a565 to a1af75a Compare October 14, 2026 13:30
@venkat1701
venkat1701 merged commit 7ca952f into main Oct 14, 2026
@venkat1701
venkat1701 deleted the perf/read-allocation branch October 14, 2026 13:31
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.

Reads allocate 1 to 2 KiB per lookup

1 participant