Skip to content

perf(jit): compile Thread.current / Thread#[] / Mutex intrinsics inline #389

Open

Description

@linyiru

Part of #385, following #381.

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 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

  1. 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 as try_thread_class_intrinsic and try_mutex_*.
    • Emit a method_gen guard 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.
  2. Slot-cached Mutex ivars (also an interpreter win). Cache the slots of @owner, @depth and @waiters in ThreadIntrinsics, verified with Class::ivar_shape_name_at as in ADR 0035. This drops lock_native + unlock_native from ~43 ns to ~14 ns. It is independent of (1) and can land first.
  3. 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.
  4. 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 bare yield.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions