Skip to content

Fix two race conditions - #5155

Draft
ZacSharp wants to merge 1 commit into
cabaletta:1.21.4from
ZacSharp:pr/1.21.4/elytra/threadSafetyFix
Draft

ZacSharp wants to merge 1 commit into
cabaletta:1.21.4from
ZacSharp:pr/1.21.4/elytra/threadSafetyFix

Conversation

@ZacSharp

@ZacSharp ZacSharp commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Had this sitting in my workdir for more than a week and didn't get around to benchmark it, so I'll just put this up as a draft.

Does the same as #5156 but maybe faster by avoiding implicit synchronization and some allocations. However this is more complicated and has a footgun more (reusing a boi on the wrong thread), so unless someone benchmarks this to be faster, #5156 is the way to go.

Draft due to missing benchmark.

Fixes #5136

1. `ElytraBehavior` needs a thread safe `BlockStateInterface` for the solver
2. The cache in `BlockStateOctreeInterface` is not thread safe
0Mattias pushed a commit to 0Mattias/cheesecake that referenced this pull request Oct 26, 2026
ZacSharp's 74dd28b2, ported as it is, in place of the #5156 port below: the
cache goes back to three plain fields and is no longer shared. Every reader
makes its own through getBoi(), which also puts it in a WeakHashMap-backed
set that the writer walks under the write lock to clear them all; the solver
context carries one, and the obstacle check makes one per tick. The fork's
invalidate() is what the walk calls. The thread-safe BlockStateInterface
stays, since both PRs make that change. Pushed so that CI's in-world test
flies the four elytra stages with this PR's code too.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyYJgBP5vLVRjrcXVvTizn
@0Mattias

Copy link
Copy Markdown
Contributor

hey sorry I dropped the ball on this/the earlier issue, I did a bunch of benchmarking on several systems that I can share. ill make a comment soon but I need to make sure the numbers and verdict are right

0Mattias pushed a commit to 0Mattias/cheesecake that referenced this pull request Oct 26, 2026
…waway)

A stage passes or fails on what the game thread does, so an exception on
a background thread -- the one cabaletta/baritone#5155's WeakHashMap could
throw on the write executor -- never fails the test and never reaches the
job log, which only greps the stage lines out of autotest.log. Count and
print them after the verdict so the flight says so either way.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyYJgBP5vLVRjrcXVvTizn
@0Mattias

Copy link
Copy Markdown
Contributor

Here they are as promised! I flew the fork's two in-game nether elytra test stages (under and above the roof) with every get0 call recorded, then replayed those calls with JMH through each cache design, with this PR's code and #5156's copied in as they are, on five machines (x86 linux with jdk 25 and 21, x86 windows, arm linux, m1 mac). Per get0 call on 1.21.4's java chunks, lowest to highest across machines and flights:

one thread, vs today two threads at once
today's code 2.7 to 7.2 ns 8.6 to 73 ns
cache per solver, no registry -6% to +32% 2.6 to 7.5 ns
cache per solver as in this PR -5% to +25% –
record as in #5156 +4% to +34% 14 to 45 ns
record + volatile (my proposal in #5136) +4% to +59% 15 to 75 ns
ThreadLocal +48% to +147% –
  • A cache per solver is within 6% of today everywhere except the m1 on the above-roof flight (+32%, +25% with the registry). The registry's getBoi adds a couple of percent on that flight and nothing below the roof.
  • The record is slower on every machine and both flights (Fix two race conditions #5156 beyond the confidence intervals in 9 of 10 runs) and allocates 24 bytes per chunk change, which was every 16 calls here.
  • When two solves overlap, every shared cache gets 2 to 12x slower per call; a cache per solver doesn't care. Overlap was 0 to 5% of solving time in these flights. For scale, get0 is about 5% of a solve, so even the worst case above is about 2% of a solve. This is about which option is free, not about anyone noticing in game.
  • 26.3's native chunks say the same (per solver -8% to +13%, the records +1% to +44%).
  • I also ported this PR onto my fork and ran its full in-game test twice (all stages, four elytra flights). Passed both times, no exceptions from the mod in the client log.

Raw JMH json and gc profiles: https://github.com/0Mattias/cheesecake/actions/runs/40240588255. The flights with this PR's code: https://github.com/0Mattias/cheesecake/actions/runs/40257752077 and https://github.com/0Mattias/cheesecake/actions/runs/40259191833.

Two things in the code that Claude noticed:

bois isn't thread safe. SolverContext(true) in onPostTick calls getBoi() with no lock held, while queueForPacking/queueCacheCulling iterate the set under the write lock on the write executor. That's a WeakHashMap added to and iterated from two threads at once: a CME in the packing task at best, a boi that never gets invalidated at worst. The other two getBoi() calls are under the read lock (pathfindAroundObstacles, and tick() via ElytraProcess), so they're fine. Two flights didn't hit it, which for a race like this doesn't say much.

I'd probably drop the registry entirely. Every boi here is used inside a single read lock hold (obstacle check: created and used under the lock; main thread solve: ElytraProcess holds the read lock around behavior.tick(); async solve: only inside the solver's own lock hold), and chunks only change under the write lock, so a boi can't go stale unless someone keeps it across a lock release. Your javadoc already says most of this; add "don't keep it across a release of the read lock" and that's the whole contract. (Searches insert region cache chunks under the read lock, but nothing invalidates for those today either.) Then getBoi() is new BlockStateOctreeInterface(this) and the WeakHashMap, the forEach lines and the imports go.

Numbers per machine (1.21.4)

Each column is a different machine, so only compare within a column. Each cell is above the roof / below the roof. Flights: above the roof 853 solves and 2,980,424 lookups over 21.6 s, below the roof 1,022 solves and 4,172,079 lookups over 24.9 s. JMH, 3 forks; confidence intervals and the 26.3 tables are in the run's json and job summaries.

One thread. First row ns per call, the rest relative to it:

x86 Linux x86 Linux, JDK 21 x86 Windows ARM Linux M1 Mac
today's code 2.66 / 4.05 3.95 / 6.24 4.78 / 7.16 4.56 / 6.77 3.78 / 5.67
cache per solver, no registry -2% / -6% -1% / -3% +6% / -2% +1% / -2% +32% / -4%
cache per solver as in #5155 +3% / -5% +5% / 0% +6% / -2% +4% / +2% +25% / 0%
record as in #5156 +16% / +4% +16% / +4% +24% / +6% +5% / +4% +34% / +14%
record + volatile +22% / +11% +22% / +5% +22% / +4% +40% / +18% +59% / +38%
ThreadLocal +69% / +74% +72% / +48% +76% / +54% +101% / +83% +147% / +82%

Two threads at once, ns per call (the per-solver cache gives each thread its own):

x86 Linux x86 Linux, JDK 21 x86 Windows ARM Linux M1 Mac
today's code 10 / 14 42 / 73 26 / 34 10 / 14 8.6 / 15
cache per solver 2.6 / 3.9 4.0 / 6.2 5.8 / 7.5 4.5 / 6.8 5.5 / 6.1
record as in #5156 17 / 22 34 / 45 31 / 35 17 / 22 15 / 14
record + volatile 18 / 24 55 / 75 37 / 40 21 / 25 15 / 18

The record is 24 bytes per chunk change (about 1.5 B per call here). Today's code boxes a Long of the same size for the map key on each change, which the JIT removed in some runs and not others.

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.

BlockStateOctreeInterface can answer from the wrong chunk when two threads use it

2 participants