Repository navigation
async: the Threads test on arm64 (a fence-pair hand-off) and its two timing races - #88
Merged
Merged
Conversation
Threads.ProducerConsumerAndPollerShareOneConverter (A4) failed in both ci-arm64.yml runs since it landed (the 2026-10-12 schedule and today's dispatch, issue #84): on native Linux arm64 the servo never reached locked (ppm 890, 621, then the 1500 rail at the three checks) while every two-second ppm mean stayed within 50 of 500, the signature of the block-beat noise the file comment describes when the push/pull order departs from the simulator's. The pacing counters are relaxed by design, so they add no happens-before edge that would hide a missing fence in the ring. Relaxed alone, though, lets weakly-ordered silicon show a side's counter before the ring's own index, so the consumer could take its turn a block early. Each hand-off now goes through a fence pair: a release fence before a side bumps its counter, an acquire fence once the other side has read it (and the same around the burst). That orders the hand-off in the C++ model on every target; ThreadSanitizer does not model standalone fences, so it still sees no edge but the ring's. Both TSan jobs build with clang, where the fences raise no -Wtsan warning. ci-arm64.yml now also runs Threads.* twenty times in Release, since one run is one sample of the scheduler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
Two timing races in the A4 test remained beside the arm64 hand-off, found by looping it on x86 with the fences in: 2 failures in 1500 runs, both polled.saw_filling. The filling state lasts only from a (re)start to the next pulls, so the unpaced poller saw it only if it happened to sample that window. - After reset_from_consumer() the consumer now waits until the poller reports a transition into filling (a counter the poller bumps). Only a pull leaves filling, so the wait is bounded by the poller's next snapshot, and saw_filling is checked, not sampled. - Before the burst and before the reset the consumer waits out the producer's turn. The mode change could otherwise cut a turn short, and the reset could meet a push in flight; either moves the sequence a block off the simulator's. Looped after the change: 500 of 500 under clang, 200 of 200 under GCC, 10 TSan passes of the ring and threads suites with no report; clang-tidy clean. The arm64 run of the fences alone (run 38552020538) passed the suite, twenty repeats and the TSan loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
tap
pushed a commit
that referenced
this pull request
Oct 14, 2026
The roadmap's A6 (row P6). The ratchet gated totals, which are means in disguise; a real-time budget is spent by the one call that carries a fade-in or a resync. Each engine now has callmax_* workloads (<engine>/bench/icount/callmax_main.cpp) that bracket every pull() or process() with tools/probe/call_marker.h's two markers: - The counting plugin finds the markers by symbol while it translates (qemu_plugin_insn_symbol, the guest image's symbol table: system mode on the Arm legs, user mode on Hexagon). Only the markers' first instructions get an execution callback, so every other workload counts exactly as before: the original and the extended plugin give the same count on the same binary. At exit it prints the largest bracketed count, which call it was, how many and their sum (TAP_SR_CALL_MAX). - scripts/icount.py gates that maximum, not the total, for a callmax_* scenario, two-sided at ±3 % like every count, on M33, M55 and Hexagon. - The workloads: async's stream paces two virtual clocks 500 ppm apart in 16-frame blocks through the fill and fade, the servo's promotions, whole-sample slips, ring wraps and a hard resync (ok=1 only if it locked, resynchronized once and never underran); bridge's and rational's are the 2 s economy streams the totals already count, both directions, Q15 and float. What the maxima show (recorded locally with the CI toolchains: arm-none- eabi-gcc 13.2.1, qemu-system-arm 8.2, clang 19.1.5 and the plugin-enabled qemu-hexagon 8.2.2): - async's worst pull is the fade-in after the fill on the Arm cores (Q15 on M33: 32,878 instructions at call 8 of 12,000, 41 % above the mean; the fade's gain ramp is double math, in software there) and the hard resync's in float on Hexagon (call 6005, just past the burst). - bridge's and rational's come in the first calls, as the history first fills, except in float on M33, where they fall mid-stream within 3 % of the mean. Every existing scenario reproduces within tolerance. A few read above their baselines by a constant that predates this change: async's rows that construct a converter +182 on M33 and +37 on M55, and on Hexagon every row +36 (+68 or +69 for those async rows). CI's ratchet on #88's head, which is main plus a test-only change, shows the same deltas row for row on all three targets, so those baselines are left as they are. The book's plugin chapter and the root CLAUDE.md describe the extension; the README tables are regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
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.
What this changes
Makes the A4 test
Threads.ProducerConsumerAndPollerShareOneConverterdeterministic, on native arm64 and everywhere else. The fix is in the test; the converter is unchanged.ci-arm64.ymlalso runsThreads.*twenty times in Release now. Closes #84.Why
On native Linux arm64 the test failed in both
ci-arm64.ymlruns since it landed: the 2026-10-12 schedule and today's dispatch. In both, the servo never reachedlocked. Instantaneous ppm read 890, then 621, then hit the 1500 rail at the three checks. Every two-second ppm mean still stayed within 50 of 500.That is the block-beat noise the test's file comment already predicts when the push/pull order departs from the simulator's. The cause is the test's pacing. Its counters are relaxed on purpose, so that ThreadSanitizer sees no ordering from them and only the ring's own acquire/release is under test. But relaxed alone lets weakly-ordered hardware show a side's counter before the ring's index. The consumer could then take its turn a block early.
The fix: each turn hand-off now goes through a fence pair. The side that finishes a turn issues a release fence before bumping its counter; the side that sees the counter issues an acquire fence before acting. That orders the hand-off under the C++ memory model on every target. ThreadSanitizer doesn't model standalone fences, so it still sees no ordering except the ring's, and the test keeps its power. Both TSan jobs build with clang, where the fences raise no
-Wtsanwarning.Two more races, found while verifying the fix. Looping the fenced test on x86 gave 2 failures in 1500 runs, both
polled.saw_filling:reset_from_consumer(), the consumer now waits until the poller reports a transition into filling. Only a pull leaves filling, so the wait is bounded by the poller's next snapshot.Verification
Notes for the reviewer
🤖 Generated with Claude Code
https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf