Skip to content

Assert the GVL is released, not that the machine is fast - #102

Merged
hmsk merged 3 commits into
mainfrom
test/assert-gvl-release-directly
Aug 27, 2026
Merged

Assert the GVL is released, not that the machine is fast#102
hmsk merged 3 commits into
mainfrom
test/assert-gvl-release-directly

Conversation

@hmsk

@hmsk hmsk commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Replaces #100, which I closed. That one skipped the timing comparisons on the musl legs, which was the wrong instrument twice over: it is the same reflex as the pend_on_ubuntu skips this repository carried for months under "unresolved stack overflow on Ubuntu of GitHub Actions", which turned out to be #82; and it was asymmetric, since macOS has flapped on the same assertion and was not being excluded.

The instrument was the problem

assert_run_in_parallel gated merges on a wall-clock ratio: the same work in one thread against the same work split in two, required at 0.8. That measures a consequence of the property rather than the property, and the consequence depends on how many cores the runner has and what else is on them.

where ratio verdict
macOS, recorded in the helper's own comment 0.811, 0.831 commit was fine
musl container, #94 and #96 0.817, 0.855 commit was fine; one took #67 red

Widening was never available: the helper documents that a genuinely GVL-held workload can measure 0.83, so there was no room between a real failure and the noise. The failure message already named what it wanted: "work may not be releasing the GVL".

Ask the lock instead

A sibling thread sleeps in a loop. Each time it wakes it needs the GVL back before it can do anything, so work that holds the GVL stops it dead and work that releases lets it tick. Sleeping rather than spinning keeps it off the cores the work wants. The idiom is already here: run_threads in the Blocking tests asserts interleaving for the same reason.

Against a baseline, not a number

The first version of this asserted a fixed tick count, and CI rejected it, which is the useful part of this pull request's history. The sibling needs a core as well as the lock. The module bytecode caller measured 9 ticks of 11 locally and 2 of 12 on a macOS runner, on identical work, because a saturated runner starves the sibling even when the GVL is free. No fixed threshold separates that from work that stopped releasing.

So each assertion now runs a deliberately GVL-held workload in the same conditions over an equal window, and requires the work under test to beat it by three. Load lands on both, so what is left is the lock:

workload released held baseline
eval_code on a pure VM 34 / 42 1 / 42
Runnable#run on a pure VM 32 / 40 1 / 42
compile 34 / 42 1 / 42
module bytecode preload 24 / 30 1 / 42

Against 0.855 versus 0.8 previously.

The negative control was also wrong, and CI caught that too

It built and disposed a VM inside the measured region, and both of those release the GVL. That is why it ticked 2 rather than 0, and why it passed on musl 3.2 where enough rounds fitted in the window to clear the old threshold. The reference workload now keeps construction and teardown outside the measurement.

It is a test: if assert_releases_gvl ever stops raising for work that holds the GVL, the four tests above have become decorative, and that is not something a measurement can report about itself.

Details

  • Window is 150ms; short callers repeat inside it. The module bytecode caller's unit of work is 14ms, which would otherwise be measured on timer resolution rather than on the lock.
  • GVL_DEBUG=1 prints released and held counts per call, which is how both tables here were produced.
  • trials: and attempts: are gone. There is no statistic left to retry.

Verified

Three runs each on macOS arm64, ruby:3.4-slim, ruby:3.4-alpine and ruby:3.2-alpine, the containers pinned to two cores with --cpuset-cpus 0,1. Twelve of twelve, 591 runs each. CI green on all ten legs, including the macOS 4.0 and musl 3.2 legs that failed the first version.

What this gives up is the claim that the work scales on the machine running the tests. A shared two-core runner was never able to tell us that, and it is not what the gem promises. benchmark.yml is the place for it as a measurement.

hmsk and others added 3 commits August 27, 2026 01:27
assert_run_in_parallel gated merges on a wall-clock ratio: the same work
in one thread against two, required at 0.8. That measures a consequence of
the property rather than the property, and the consequence depends on the
core count and load of a shared runner. It flapped accordingly, at 0.811
and 0.831 on macOS and 0.817 and 0.855 in a musl container, each time on a
commit that was fine, and once took an unrelated pull request red. Its own
failure message named what it was really after: "work may not be releasing
the GVL".

A sibling thread answers that directly. It sleeps in a loop, and each time
it wakes it needs the GVL back before it can do anything. Work that holds
the GVL stops it dead; work that releases lets it tick throughout. Sleeping
rather than spinning keeps it off the cores the work wants, so this reads
the lock rather than the machine. The idiom is already in this suite, in
run_threads, which the Blocking tests use to assert ordering.

The margin is no longer marginal. Released work ticks 16 of 21 available
slots; the same work with a bridge registered, which takes the GVL-held
path by construction, ticks 1 of 21. The assertion asks for 3.

That negative control is now a test. If it ever stops raising, the other
four have quietly become decorative, which is the failure mode a timing
assertion cannot report on itself.

Short workloads repeat up to a 50ms window, so a caller whose unit of work
is a few milliseconds is measured on the lock rather than on timer
resolution. GVL_DEBUG=1 prints ticks, opportunities and elapsed per call.

Verified three runs each on macOS arm64, and on glibc and musl containers
pinned to two cores, green in all nine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first version of this asserted a fixed tick count, and CI showed why
that does not hold: the sibling needs a core as well as the lock. The
module bytecode caller measured 9 ticks of 11 locally and 2 of 12 on a
macOS runner, on identical work, because a saturated runner starves the
sibling even when the GVL is free. A fixed threshold cannot tell that
apart from work that stopped releasing.

Each assertion now runs a deliberately GVL-held workload in the same
conditions, over an equal window, and requires the work under test to beat
it by three. Machine load lands on both, so what is left is the lock:

  eval_code            released 34/42   held 1/42
  Runnable#run         released 32/40   held 1/42
  compile              released 34/42   held 1/42
  module bytecode      released 24/30   held 1/42

The baseline also fixes the negative control, which was not actually
holding the GVL: it built and disposed a VM inside the measured region,
and both of those release. That is why it ticked 2 rather than 0 and
passed on musl 3.2, where enough rounds fit in the window to reach the old
threshold. The reference workload now keeps construction and teardown
outside the measurement, and ticks 1 of 42.

Window is 150ms, and short callers repeat inside it.

Verified three runs each on macOS arm64, ruby:3.4-slim, ruby:3.4-alpine
and ruby:3.2-alpine, the containers pinned to two cores. Twelve of twelve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The macOS legs of the pull_request run failed on the module bytecode
caller at 3 ticks against a required 4, while the same commit passed the
push run, which is how thin the margin still was.

The cause was the sampling interval, not the lock. That caller releases in
short bursts around each VM construction and teardown, eleven rounds
inside the window, and a sibling sleeping 5ms at a time walked past most
of them: 24 ticks of 30 locally, 3 of 30 on the runner.

Sampling every 1ms multiplies what a releasing workload scores and leaves
a holding one exactly where it was, because there is nothing there to find
however often you look:

                    5ms                1ms
  module bytecode   24/30  held 1/42   126/163  held 1/210
  eval_code         34/42  held 1/42   162/206  held 1/215

Margin raised to 5 over the baseline to match. Separation is now around
150 to 1 rather than 3 to 1.

Nine runs green: macOS arm64, and ruby:3.4-slim and ruby:3.4-alpine pinned
to two cores, three each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hmsk
hmsk merged commit 654acbc into main Aug 27, 2026
21 checks passed
@hmsk
hmsk deleted the test/assert-gvl-release-directly branch August 27, 2026 09:01
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.

1 participant