Module: Rigor::Runtime::Jit
- Defined in:
- lib/rigor/runtime/jit.rb
Overview
Deferred YJIT enablement for the long-running analysis commands.
Ruby 3.3+ ships YJIT but leaves it off unless the process opts in
(--yjit, RUBY_YJIT_ENABLE=1, or RubyVM::YJIT.enable). Enabling it
unconditionally at boot is a net LOSS on short rigor check / coverage
runs: the JIT compile cost is paid up front, but a run that finishes in a
couple of seconds ends before it amortizes. Measured cold A/B (2026-07-13,
Ruby 4.0.5): mastodon app/models 4.0s -> 5.0s (0.80x), kramdown lib
1.7s -> 2.55s (0.66x), dependabot 1.6s -> 2.34s (0.68x). Long runs win big
in the same measurement: mastodon app+lib 25.2s -> 14.9s (1.69x),
redmine app/models 8.6s -> 5.5s (1.56x). Allocations and diagnostics are
byte-identical under YJIT — the tradeoff is purely wall-time.
The resolution is to enable YJIT only once a run has proven long enough to
amortize the compile cost. Jit.enable_after spawns one daemon thread that
sleeps the deadline then enables: a run that finishes before the deadline
never pays JIT compile (the sub-deadline penalty zone is avoided by
construction), while a long run enables mid-flight and JITs the remaining
— dominant — work. Long-lived servers (rigor lsp / rigor mcp) instead
enable at boot via Jit.enable_now, since they always run long.
Opt-out: RIGOR_DISABLE_YJIT=1 makes every entry point a no-op (the pin
the byte-identical-diagnostics gate uses). If YJIT is already enabled (the
user passed --yjit / RUBY_YJIT_ENABLE=1) or is unavailable (a build
without YJIT, or a non-MRI Ruby), every entry point is a no-op too.
fork copies only the calling thread, so the Jit.enable_after sleeper dies
in every child of a fork pool — a worker would run its whole slice
interpreted while the parent JITs work it no longer does. Jit.rearm_after_fork
is the child-side entry point that repairs this, and it carries the
remaining deadline rather than restarting the window; see its own
comment for why that distinction is worth the bookkeeping.
Constant Summary collapse
- DISABLE_ENV =
Opt-out switch:
RIGOR_DISABLE_YJIT=1disables both entry points. "RIGOR_DISABLE_YJIT"- DEADLINE_ENV =
Advanced / calibration override of the enable_after deadline, in float seconds. Unset (or unparseable / negative) falls back to DEFAULT_DEADLINE_SECONDS. Exposed so the deadline can be swept during calibration and tuned per-project without an engine change.
"RIGOR_YJIT_DEADLINE"- DEFAULT_DEADLINE_SECONDS =
The amortization deadline for enable_after, in seconds.
Calibrated by interleaved cold A/B measurement on 2026-07-13 (Ruby 4.0.5, arm64-darwin25). Enabling YJIT mid-run costs a ~1s compile burst, so a run whose length is only ~0-1s longer than the deadline pays the burst without time to amortize and LOSES (measured:
app/modelsat a 3.0s deadline regressed 4.14s -> 4.68s). The short-run cases cluster at <=1.7s (kramdown / dependabot) and ~4.0-4.9s (mastodonapp/models~4.1s, mail ~4.9s), with the next case at redmine ~7s. 5.0s parks that danger zone in the empty 5-6s gap: every case <=4.9s finishes before the deadline fires (guaranteed no-YJIT parity — the sleeping thread is killed at exit), while runs long enough to pay off (>=~15s: gitlab, mastodonapp+lib) enable mid-flight and JIT their dominant tail — the mastodonapp+libwin is preserved ~100% (25.4s -> 15.1s, vs 15.1s always-on). 5.0
Class Method Summary collapse
-
.available? ⇒ Boolean
Whether this Ruby exposes
RubyVM::YJIT.enable. -
.deadline_seconds ⇒ Numeric
The resolved Jit.enable_after deadline: DEADLINE_ENV when it parses to a non-negative float, else DEFAULT_DEADLINE_SECONDS.
-
.disabled? ⇒ Boolean
Whether the opt-out env switch is set.
-
.enable_after(seconds) ⇒ Thread?
Spawns one background thread that sleeps
secondsthen calls Jit.enable_now. -
.enable_now ⇒ Boolean
Enables YJIT now, unless opted out, already enabled, or unavailable.
-
.rearm_after_fork ⇒ Thread?
Re-arms the deferred-YJIT deadline inside a freshly forked child, carrying the remaining window rather than restarting it.
Class Method Details
.available? ⇒ Boolean
Returns whether this Ruby exposes RubyVM::YJIT.enable.
180 181 182 |
# File 'lib/rigor/runtime/jit.rb', line 180 def available? defined?(RubyVM::YJIT.enable) ? true : false end |
.deadline_seconds ⇒ Numeric
The resolved enable_after deadline: DEADLINE_ENV when it parses to a non-negative float, else DEFAULT_DEADLINE_SECONDS.
171 172 173 174 175 176 177 |
# File 'lib/rigor/runtime/jit.rb', line 171 def deadline_seconds raw = ENV.fetch(DEADLINE_ENV, nil) return DEFAULT_DEADLINE_SECONDS if raw.nil? || raw.empty? value = Float(raw, exception: false) value && value >= 0 ? value : DEFAULT_DEADLINE_SECONDS end |
.disabled? ⇒ Boolean
Returns whether the opt-out env switch is set.
185 186 187 |
# File 'lib/rigor/runtime/jit.rb', line 185 def disabled? ENV[DISABLE_ENV] == "1" end |
.enable_after(seconds) ⇒ Thread?
Spawns one background thread that sleeps seconds then calls
enable_now. The thread is a daemon in effect — Ruby kills all live
threads when the main thread exits — so a run that finishes before the
deadline simply never enables YJIT, which is the point.
Returns nil (and spawns no thread) when the feature is already a no-op up front (unavailable / opted out / already enabled), so callers never pay for a thread that could only no-op.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/rigor/runtime/jit.rb', line 95 def enable_after(seconds) return nil unless available? return nil if disabled? return nil if RubyVM::YJIT.enabled? # Record when the window actually closes, so a child forked partway # through it can re-arm with what is LEFT ({rearm_after_fork}) instead # of restarting the wait. Monotonic, so a wall-clock adjustment mid-run # cannot move the deadline; and monotonic time survives `fork`, so the # value stays comparable in a child. # # Thread-safety: this is written on the arming thread before any `fork` # and read only in children *after* the fork, each of which sees a # private copy of the parent's memory. No reader and writer ever share a # process, so the plain attribute needs no lock. @deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds thread = Thread.new do sleep(seconds) enable_now end # A background perf optimization must never spill a backtrace onto the # run's stderr; on any unexpected failure, stay silent and leave YJIT # off. The deadline sleep gives ample time to set this before the body # could raise. thread.report_on_exception = false thread end |
.enable_now ⇒ Boolean
Enables YJIT now, unless opted out, already enabled, or unavailable.
An imperative command, not a query — the boolean reports whether this
call is what enabled YJIT (so the ?-suffix predicate convention does
not apply; hence the Naming/PredicateMethod exemption).
75 76 77 78 79 80 81 82 |
# File 'lib/rigor/runtime/jit.rb', line 75 def enable_now # rubocop:disable Naming/PredicateMethod return false unless available? return false if disabled? return false if RubyVM::YJIT.enabled? RubyVM::YJIT.enable true end |
.rearm_after_fork ⇒ Thread?
Re-arms the deferred-YJIT deadline inside a freshly forked child, carrying the remaining window rather than restarting it.
fork copies only the calling thread, so the parent's enable_after
sleeper never fires in a child: without re-arming, a worker runs its
entire slice interpreted however long that takes, and a fork pool can be
slower than sequential (measured: coverage --protection --mutation lib/rigor/analysis at 37s sequential against 67s at eight workers).
Re-arming with a fresh full deadline fixes that but overshoots the
other way — the parent has normally already burned part of the window
before it forks, so every child sits out the whole window again. That is
pure warm-up loss, and it is paid once per worker, so it grows with the
worker count exactly where a pool is supposed to pay off.
The remaining window is what the amortization contract actually says: the deadline exists to keep short runs off the JIT, and a child is a continuation of the parent's run, not a new one. When the deadline has already passed, the run has proven itself long — enable straight away.
Re-arming goes through enable_after, so the child records the same absolute deadline it inherited and a grandchild fork carries it too.
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/rigor/runtime/jit.rb', line 150 def rearm_after_fork return nil unless available? return nil if disabled? return nil if RubyVM::YJIT.enabled? # No recorded deadline means nothing in this process ever armed one — a # caller that forks without going through a CLI entry point, or a spec. # Degrade to a fresh full window, never to no YJIT at all. return enable_after(deadline_seconds) if @deadline_at.nil? remaining = @deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC) return enable_after(remaining) if remaining.positive? enable_now nil end |