Skip to content

Release the GIL while parsing expressions of 32 bytes or more - #59

Merged
hardbyte merged 4 commits into
mainfrom
brian/jolly-franklin-75fkzx-parse-detach
Sep 15, 2026
Merged

hardbyte merged 4 commits into
mainfrom
brian/jolly-franklin-75fkzx-parse-detach

Conversation

@hardbyte

Copy link
Copy Markdown
Owner

Why

First half of PR 2 in the #45 plan: the part of GIL release that is unambiguously a win. Parsing is pure Rust (nothing in Program::compile can call back into Python) and it is the expensive half of evaluate(): about 8 µs for a one-token expression, 70 µs for a policy-sized one, milliseconds for large literals, against a detach/attach round trip of well under 100 ns. Today every thread parsing an expression serialises on the interpreter.

What

  • compile(), and the parse step inside evaluate(), run Program::compile inside py.detach(...) when the expression is 32 bytes or more. catch_unwind sits inside the detached region so a parser panic is caught before control crosses PyO3's re-attach guard.
  • Expressions shorter than 32 bytes keep the GIL. Their parse (8 to 13 µs) sits at the point where re-acquiring a contended GIL (~14 µs with four threads waiting, on this 4-core box) cancels the released work. Measured: releasing the parse of evaluate("x + y") gained 1.6× on two threads but lost 25% on four. Length is a faithful proxy here because parse time grows with the input, so the gate can only cost a missed speedup for short expressions, never a regression, and the constant has margin for machines whose GIL handoff is slower than this one. (This is a different situation from execute(), where neither callback presence nor context size predicts the work, which is why execution stays attached for now.)
  • Execution of compiled programs still holds the GIL; the work-aware gate for that is the remaining half of Release the GIL during evaluation, and decide on free-threaded (cp314t) support #45.

Numbers

4 cores, release build, min of repeats, fixed total work per thread count.

1 thread 4 threads, before 4 threads, after
evaluate(policy) 12.5k/s 0.96× 3.02×
compile(policy) 13.2k/s 0.96× 3.19×
evaluate("x + y") (below the gate) 61k/s 0.94× 0.96×

Single-thread latency is unchanged within noise: compile(policy) 69.6 → 69.6 µs, evaluate(policy) 73.6 → 73.2 µs, compile("x + y") 12.3 → 12.3 µs.

Tests

tests/test_gil_release.py detects the release by overlap, not speed: a spinning thread counts iterations while the main thread parses a 20,000-element list literal (~250 ms). With the GIL held the count stays near zero; released, it climbs into the millions. That is a binary signal, so it does not depend on the runner's core count or load. Skipped on free-threaded builds, where the spinner runs regardless. Also: parse results and errors identical on both sides of the length gate; 400 concurrent compiles across 8 threads each produce the right program. Full suite: 564 passed, 1 skipped, 5 xfailed; clippy, fmt, ruff clean.

Independent of #58 (touches different regions of src/lib.rs); the CHANGELOG Unreleased section will need a one-line merge with whichever lands second.

https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW


Generated by Claude Code

compile(), and the parse inside evaluate(), now run the CEL parser detached
from the interpreter. Parsing is pure Rust (nothing in Program::compile can
call back into Python) and it is the expensive half of evaluate(): about 8 us
for a one-token expression, 70 us for a policy-sized one and milliseconds for
large literals, against a detach/attach round trip of well under 100 ns.
Threads that parse concurrently now scale with cores.

Measured on 4 cores (min of repeats, fixed total work):

  evaluate(policy)   1 thread 12.5k/s   4 threads: 0.96x before, 3.02x after
  compile(policy)    1 thread 13.2k/s   4 threads: 0.96x before, 3.19x after
  single-thread latency unchanged within noise (69.6 vs 69.6 us for the policy)

Expressions shorter than 32 bytes keep the GIL. Their parse (8-13 us) sits at
the point where the cost of re-acquiring a contended GIL, about 14 us with four
threads waiting on this machine, cancels the released work: releasing it for
evaluate("x + y") gained 1.6x on two threads but lost 25% on four. Length is a
faithful proxy here because parse time grows with the input, so the bound can
only cost a missed speedup, never a regression; it also leaves margin for
machines whose re-acquire is slower than the one this was tuned on.

Execution still holds the GIL; the work-aware gate for that is tracked in #45.
catch_unwind sits inside the detached region so a parser panic is caught before
control crosses PyO3's re-attach guard.

Tests detect the release by overlap rather than speed: a spinning thread counts
iterations during a ~400 ms parse, which stays near zero with the GIL held and
climbs into the millions with it released, independent of core count.

Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The doc comments on PARSE_DETACH_MIN_LEN and compile_program, and the
CHANGELOG entry, narrated the measurements behind the 32-byte bound. A
reader needs the constraint (a short parse costs about as much as
re-acquiring a contended GIL) and where the numbers live (issue #45),
not the numbers themselves.

Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW
@hardbyte
hardbyte merged commit 9ebb796 into main Sep 15, 2026
18 of 19 checks passed
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.

2 participants