Repository navigation
perf(jit): compile Thread.current / Thread#[] / Mutex intrinsics inline #389
Description
Can rubyrs beat CRuby on Thread.current / Thread.current[:k] / Mutex?
Not in the interpreter. In JIT-compiled code, yes.
Measured 2026-09-24 on a Mac Studio M2 Max, in ns/op with the empty-loop cost subtracted. The machine was loaded while these ran, so the ratios are more reliable than the absolute numbers.
| shape | rubyrs interp | tier-2 | native JIT | CRuby 3.4.8 | +YJIT |
|---|---|---|---|---|---|
Thread.current |
39.1 | 38.7 | 74.7 | 9.3 | 9.6 |
Thread.current[:k] |
87.3 | 92.3 | 125.7 | 20.1 | 20.8 |
Mutex lock+unlock |
107.5 | 108.8 | 140.4 | 36.6 | 35.7 |
floor: const Thread |
14.6 | 4.3 | |||
floor: nil.nil? |
21.6 | 5.3 | |||
| empty loop iteration (absolute) | 36.4 | 36.2 | 1.4 | 15.4 | 15.1 |
- The perf: fast paths for Thread.current, Fiber.current, Mutex#synchronize #381 fast paths already sit on the interpreter's generic floor, which is one constant load plus one call plus a hash read. Only B-work (Tracking: structural interpreter work to reach CRuby-interp parity (B1–B4) #384) moves that floor.
- The native JIT compiles the empty loop to 1.4 ns, but every call inside the loop bails to the interpreter, so these shapes get slower under it.
- tier-2 runs these call sites through
step()and gains nothing. - YJIT does not help here either; these are C functions in CRuby.
The floor if the JIT emits the serve inline
A Rust probe drove the native work directly: one method_gen guard, no operand stack, and ivars read and written through pre-resolved slots.
| op | current vm/thread.rs serve |
ideal | CRuby |
|---|---|---|---|
Thread.current |
10.6 | 4.0 | 9.3 |
Thread.current[:k] (current hash lookup) |
35.6 | 15.4 | 20.1 |
Thread.current[:k] (small-table linear scan) |
5.5 | 20.1 | |
Mutex lock+unlock |
58.0 | 14.4 | 36.6 |
Adding the 1.4 ns compiled loop, the JIT path would run about 2–4× faster than CRuby on these shapes.
Proposal
- JIT admission. Teach the native/tier-2 JIT to recognize call sites that resolve to the captured intrinsics in
Vm::thread_intr, following the same identity rule astry_thread_class_intrinsicandtry_mutex_*.- Emit a
method_genguard plus inline code (or a thin helper) with no interpreter frame or operand stack. - Deopt to the interpreter on a guard failure, or on any case where the serve would decline: a non-Symbol key, contention, waiters present, a subclass or singleton receiver, or execution inside a fiber.
- Emit a
- Slot-cached Mutex ivars (also an interpreter win). Cache the slots of
@owner,@depthand@waitersinThreadIntrinsics, verified withClass::ivar_shape_name_atas in ADR 0035. This dropslock_native+unlock_nativefrom ~43 ns to ~14 ns. It is independent of (1) and can land first. - Hash lookup.
Thread.current[:k]only beats CRuby by a real margin when the small-hash lookup is cheap; that is perf(hash): small-table linear lookup for Symbol/immediate keys #388. Mutex#synchronize. This also needs the block inlined at the call site (perf: interpreter block invocation from native iterators #382 / B4). Today it costs the same as one bareyield.
Watch for one trap. Once a Mutex is promoted to the old generation, every ivar write pushes it onto heap.remembered, and there is no dedupe. The probe ran on a young Mutex, so a compiled lock/unlock would pay that cost too. It is worth deduping, for example with a per-object remembered bit.
An ADR is required first, per #385.
Activity