Skip to main content

revmc_runtime/runtime/
mod.rs

1//! Runtime JIT backend: O(1) compiled-function lookup with background compilation.
2//!
3//! - Startup AOT preload from [`ArtifactStore::load_all`] into an immutable in-memory map.
4//! - O(1) [`JitBackend::lookup`] that only reads the resident map.
5//! - Fire-and-forget lookup-observed events to the backend thread.
6//! - Background JIT compilation for hot keys (threshold-based promotion).
7
8use crate::{
9    EvmCompilerFn,
10    eyre::{self, WrapErr},
11};
12use api::LoadedLibrary;
13use backend::{Command, CompileJitRequest, EventQueue, PrepareAotRequest, ResidentMap};
14use crossbeam_channel as chan;
15use crossbeam_queue::ArrayQueue;
16use revm_primitives::{B256, hardfork::SpecId, hints_util::cold_path};
17use stats::RuntimeStats;
18use std::{
19    ops::ControlFlow,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, AtomicUsize, Ordering},
23    },
24    time::Duration,
25};
26use worker::SyncNotifier;
27
28mod api;
29pub use api::{
30    AotRequest, CompiledProgram, InterpretReason, LookupDecision, LookupRequest, ProgramKind,
31};
32
33mod config;
34pub use config::{CompilationEvent, CompilationKind, JitMode, RuntimeConfig, RuntimeTuning};
35
36mod backend;
37
38mod stats;
39pub use stats::RuntimeStatsSnapshot;
40
41mod storage;
42pub use storage::{
43    ArtifactKey, ArtifactManifest, ArtifactStore, BackendSelection, RuntimeArtifactStore,
44    RuntimeCacheKey, StoredArtifact,
45};
46
47#[cfg(all(feature = "llvm", unix))]
48mod out_of_process;
49
50mod worker;
51
52/// Runs the out-of-process JIT helper if this process was launched as one.
53///
54/// Returns [`ControlFlow::Break`] after the helper request has been handled and
55/// the caller should exit immediately. Normal application startup should
56/// continue on [`ControlFlow::Continue`].
57pub fn maybe_run_jit_helper() -> eyre::Result<ControlFlow<()>> {
58    #[cfg(all(feature = "llvm", unix))]
59    {
60        out_of_process::maybe_run_jit_helper()
61    }
62    #[cfg(not(all(feature = "llvm", unix)))]
63    {
64        if std::env::var_os("REVMC_JIT_HELPER").is_some() {
65            eyre::bail!("out-of-process JIT helper is only available on Unix with LLVM")
66        }
67        Ok(ControlFlow::Continue(()))
68    }
69}
70
71#[cfg(test)]
72mod tests;
73
74/// State shared between [`JitBackend`] (via [`BackendInner`]) and the backend
75/// thread.
76///
77/// Held by the backend thread as `Arc<BackendShared>`. Crucially, the backend
78/// thread does NOT hold an `Arc<BackendInner>` — that would create a reference
79/// cycle since `BackendInner::Drop` is what signals the thread to stop. Keeping
80/// thread-lifecycle fields outside this struct lets `BackendInner` drop (and
81/// trigger shutdown) as soon as the last `JitBackend` clone is released.
82#[derive(derive_more::Debug)]
83pub(crate) struct BackendShared {
84    /// Shared resident compiled map.
85    #[debug(skip)]
86    resident: ResidentMap,
87    /// Lock-free queue of events.
88    #[debug(skip)]
89    events: EventQueue,
90    /// Number of active out-of-process helper pauses.
91    pause_depth: AtomicUsize,
92    /// Shared stats counters.
93    #[debug(skip)]
94    stats: Arc<RuntimeStats>,
95}
96
97/// Inner state for [`JitBackend`]. Owns the backend thread lifecycle.
98#[derive(derive_more::Debug)]
99pub(crate) struct BackendInner {
100    /// State shared with the backend thread.
101    shared: Arc<BackendShared>,
102    /// Global enable flag.
103    enabled: AtomicBool,
104    /// Whether the backend thread has been spawned and is draining the command channel.
105    started: AtomicBool,
106    /// Blocking mode: every lookup synchronously compiles and never falls back.
107    blocking: bool,
108    /// Tuning knobs (Copy). Cached for hot-path eligibility checks.
109    tuning: crate::runtime::config::RuntimeTuning,
110    /// Bounded channel for control commands (compile_jit, prepare_aot, clears,
111    /// shutdown). The lookup hot path does NOT use this — see
112    /// [`BackendShared::events`].
113    #[debug(skip)]
114    tx: chan::Sender<Command>,
115    /// Backend thread + done signal. `None` after shutdown.
116    #[debug(skip)]
117    thread: std::sync::Mutex<Option<BackendThread>>,
118    /// Shutdown timeout.
119    shutdown_timeout: Duration,
120    /// State for lazily spawning the backend thread.
121    #[debug(skip)]
122    lazy_spawn: std::sync::Mutex<Option<LazySpawnState>>,
123}
124
125/// State kept around for lazily spawning the backend thread.
126struct LazySpawnState {
127    rx: chan::Receiver<Command>,
128    config: RuntimeConfig,
129}
130
131/// Backend thread handle and its completion signal.
132struct BackendThread {
133    handle: std::thread::JoinHandle<()>,
134    done_rx: chan::Receiver<()>,
135}
136
137/// JIT compilation backend with O(1) compiled-function lookup.
138///
139/// Created via [`JitBackend::new`] or [`JitBackend::disabled`].
140/// This type is cheaply clonable (backed by `Arc`).
141/// All clones share the same backend thread, resident map, and statistics.
142#[derive(Clone, Debug)]
143pub struct JitBackend {
144    inner: Arc<BackendInner>,
145}
146
147impl JitBackend {
148    /// Creates a disabled backend that performs no compilation and spawns no threads.
149    ///
150    /// All [`lookup`](Self::lookup) calls return `LookupDecision::Interpret(Disabled)`.
151    /// Call [`set_enabled`](Self::set_enabled) to lazily spawn the backend thread with
152    /// a default [`RuntimeConfig`].
153    pub fn disabled() -> Self {
154        Self::new_inner(RuntimeConfig::default()).expect("default config cannot fail")
155    }
156
157    /// Creates a backend from the given config.
158    ///
159    /// If [`enabled`](RuntimeConfig::enabled) is `true`, the backend thread is spawned
160    /// immediately and AOT artifacts are preloaded. Otherwise, both are deferred until the
161    /// first [`set_enabled(true)`](Self::set_enabled) call.
162    pub fn new(mut config: RuntimeConfig) -> eyre::Result<Self> {
163        config = config.with_env_overrides()?;
164        Self::new_inner(config)
165    }
166
167    fn new_inner(mut config: RuntimeConfig) -> eyre::Result<Self> {
168        if config.blocking {
169            config.enabled = true;
170            config.tuning.jit_hot_threshold = 0;
171        }
172        #[cfg(not(unix))]
173        if config.jit_mode == JitMode::OutOfProcess {
174            eyre::bail!("out-of-process JIT is only available on Unix");
175        }
176        if config.aot && config.store.is_none() {
177            eyre::bail!("AOT mode requires an artifact store");
178        }
179
180        let enabled = config.enabled;
181        let (tx, rx) = chan::bounded::<Command>(config.tuning.channel_capacity);
182        let events = ArrayQueue::new(config.tuning.channel_capacity);
183        let tuning = config.tuning;
184        let shared = Arc::new(BackendShared {
185            resident: ResidentMap::default(),
186            events,
187            pause_depth: AtomicUsize::new(0),
188            stats: Arc::new(RuntimeStats::default()),
189        });
190        let this = Self {
191            inner: Arc::new(BackendInner {
192                shared,
193                enabled: AtomicBool::new(false),
194                started: AtomicBool::new(false),
195                blocking: config.blocking,
196                tx,
197                thread: std::sync::Mutex::new(None),
198                shutdown_timeout: config.tuning.shutdown_timeout,
199                tuning,
200                lazy_spawn: std::sync::Mutex::new(Some(LazySpawnState { rx, config })),
201            }),
202        };
203        this.set_enabled(enabled)?;
204        Ok(this)
205    }
206
207    /// Looks up a compiled function for the given request.
208    ///
209    /// In normal mode this never blocks. In [`blocking`](RuntimeConfig::blocking) mode,
210    /// a miss triggers synchronous JIT compilation and the call blocks until it completes.
211    pub fn lookup(&self, mut req: LookupRequest) -> LookupDecision {
212        let inner = &*self.inner;
213        let shared = &*inner.shared;
214
215        if !inner.enabled.load(Ordering::Relaxed) {
216            cold_path();
217            return LookupDecision::Interpret(InterpretReason::Disabled);
218        }
219        if inner.blocking {
220            cold_path();
221            return self.lookup_blocking(req);
222        }
223        if !inner.tuning.should_compile(&req.code) {
224            cold_path();
225            return LookupDecision::Interpret(InterpretReason::Ineligible);
226        }
227
228        let decision = if let Some(program_ref) = shared.resident.try_get(&req.key).try_unwrap() {
229            let program = Arc::clone(&program_ref);
230            drop(program_ref);
231            req.code.clear();
232            LookupDecision::Compiled(program)
233        } else {
234            LookupDecision::Interpret(InterpretReason::NotReady)
235        };
236
237        if let Err(_v) = shared.events.push(req) {
238            cold_path();
239            shared.stats.events_dropped.fetch_add(1, Ordering::Relaxed);
240        }
241
242        decision
243    }
244
245    /// Checks the resident map for a compiled program without enqueuing an event.
246    pub fn get_compiled(&self, code_hash: B256, spec_id: SpecId) -> Option<Arc<CompiledProgram>> {
247        let key = RuntimeCacheKey { code_hash, spec_id };
248        self.inner.shared.resident.get(&key).map(|entry| Arc::clone(&entry))
249    }
250
251    /// Like [`get_compiled`](Self::get_compiled), but also records hit/miss stats.
252    pub fn get_compiled_tracked(
253        &self,
254        code_hash: B256,
255        spec_id: SpecId,
256    ) -> Option<Arc<CompiledProgram>> {
257        let result = self.get_compiled(code_hash, spec_id);
258        if result.is_some() {
259            self.inner.shared.stats.lookup_hits.fetch_add(1, Ordering::Relaxed);
260        } else {
261            self.inner.shared.stats.lookup_misses.fetch_add(1, Ordering::Relaxed);
262        }
263        result
264    }
265
266    /// Looks up a compiled function, blocking until compilation completes if not yet ready.
267    ///
268    /// If the function is already compiled, returns it immediately. Otherwise, enqueues
269    /// a synchronous JIT compilation request and blocks until the result is available.
270    /// Returns [`LookupDecision::Interpret`] if the bytecode is ineligible for
271    /// compilation (see [`RuntimeTuning::should_compile`]) or compilation fails.
272    pub fn lookup_blocking(&self, req: LookupRequest) -> LookupDecision {
273        if !self.inner.tuning.should_compile(&req.code) {
274            return LookupDecision::Interpret(InterpretReason::Ineligible);
275        }
276        let code_hash = req.key.code_hash;
277        let spec_id = req.key.spec_id;
278        if let Some(program) = self.get_compiled_tracked(code_hash, spec_id) {
279            return LookupDecision::Compiled(program);
280        }
281        if self.compile_jit_sync(req).is_err() {
282            return LookupDecision::Interpret(InterpretReason::JitFailed);
283        }
284        match self.get_compiled_tracked(code_hash, spec_id) {
285            Some(program) => LookupDecision::Compiled(program),
286            None => LookupDecision::Interpret(InterpretReason::JitFailed),
287        }
288    }
289
290    /// Enqueues an explicit JIT compilation request for the given bytecode.
291    ///
292    /// Blocks if the command channel is full to guarantee delivery.
293    pub fn compile_jit(&self, req: LookupRequest) {
294        let _ = self.ensure_started();
295        let cmd = Command::CompileJit(CompileJitRequest {
296            key: req.key,
297            bytecode: req.code,
298            sync_notifier: SyncNotifier::none(),
299        });
300        let _ = self.inner.tx.send(cmd);
301    }
302
303    /// Enqueues a JIT compilation request and blocks until the compilation completes.
304    ///
305    /// Returns `Ok(())` when the compiled function is available in the resident map,
306    /// or when the compilation fails. Use [`get_compiled`](Self::get_compiled) to
307    /// retrieve the result after this returns.
308    pub fn compile_jit_sync(&self, req: LookupRequest) -> eyre::Result<()> {
309        self.ensure_started()?;
310        let (tx, rx) = chan::bounded(1);
311        let cmd = Command::CompileJit(CompileJitRequest {
312            key: req.key,
313            bytecode: req.code,
314            sync_notifier: SyncNotifier::new(tx),
315        });
316        self.inner.tx.send(cmd).map_err(|_| eyre::eyre!("backend channel closed"))?;
317        rx.recv().map_err(|_| eyre::eyre!("backend shut down before compilation completed"))
318    }
319
320    /// Enqueues a single AOT preparation request.
321    ///
322    /// This is enqueue-only and returns immediately. The compilation happens
323    /// asynchronously on the worker pool. The resulting artifact is persisted
324    /// via [`ArtifactStore::store`] and loaded into the resident map.
325    pub fn prepare_aot(&self, req: AotRequest) {
326        self.prepare_aot_batch(vec![req]);
327    }
328
329    /// Enqueues a batch of AOT preparation requests.
330    ///
331    /// Blocks if the command channel is full to guarantee delivery.
332    pub fn prepare_aot_batch(&self, reqs: Vec<AotRequest>) {
333        let _ = self.ensure_started();
334        let owned: Vec<PrepareAotRequest> = reqs
335            .into_iter()
336            .map(|r| PrepareAotRequest {
337                key: RuntimeCacheKey { code_hash: r.code_hash, spec_id: r.spec_id },
338                bytecode: r.code,
339            })
340            .collect();
341        let cmd = Command::PrepareAot(owned);
342        let _ = self.inner.tx.send(cmd);
343    }
344
345    /// Clears the in-memory resident compiled map.
346    ///
347    /// All compiled programs are removed from the map. Active references
348    /// held by callers remain valid until dropped.
349    pub fn clear_resident(&self) {
350        let _ = self.inner.tx.send(Command::ClearResident);
351    }
352
353    /// Clears persisted artifacts from the artifact store.
354    pub fn clear_persisted(&self) {
355        let _ = self.inner.tx.send(Command::ClearPersisted);
356    }
357
358    /// Clears both the resident map and persisted artifacts.
359    pub fn clear_all(&self) {
360        let _ = self.inner.tx.send(Command::ClearAll);
361    }
362
363    /// Returns whether the runtime is enabled.
364    pub fn enabled(&self) -> bool {
365        self.inner.enabled.load(Ordering::Relaxed)
366    }
367
368    /// Pauses out-of-process helper execution.
369    ///
370    /// Resident compiled functions are still returned by [`lookup`](Self::lookup), and lookup
371    /// events are still processed for stats, hotness tracking, and compilation dispatch. In
372    /// out-of-process mode, the helper process group is stopped until the pause depth returns to
373    /// zero, so dispatched helper requests remain buffered and resume once the helper continues.
374    /// In in-process mode, pause only tracks pause depth.
375    ///
376    /// Pause delivery is best-effort and never blocks: callers pause around block validation on
377    /// the engine's critical path. If the backend thread is not running or the command channel
378    /// is full, the command is skipped or dropped instead of blocking.
379    pub fn pause(&self) {
380        if self.inner.shared.pause_depth.fetch_add(1, Ordering::Relaxed) == 0 {
381            self.try_send_control(Command::Pause);
382        }
383    }
384
385    /// Resumes out-of-process helper execution once all active pauses have been released.
386    ///
387    /// Resume delivery is best-effort and never blocks, like [`pause`](Self::pause).
388    pub fn resume(&self) {
389        if self
390            .inner
391            .shared
392            .pause_depth
393            .try_update(Ordering::Relaxed, Ordering::Relaxed, |depth| Some(depth.saturating_sub(1)))
394            .is_ok_and(|depth| depth == 1)
395        {
396            self.try_send_control(Command::Resume);
397        }
398    }
399
400    /// Sends a best-effort control command without ever blocking the caller.
401    ///
402    /// The command channel is bounded, so a plain `send` can block. Pause/resume are issued on
403    /// the caller's block-validation critical path and must never block there:
404    ///
405    /// - If the backend thread has not been started (the runtime was constructed but compilation
406    ///   was never enabled), nothing drains the channel. Queued commands would only accumulate
407    ///   until the channel fills and `send` blocks the caller forever, so the send is skipped
408    ///   entirely — there is no helper to pause anyway.
409    /// - If the channel is full, the command is dropped and recorded in
410    ///   [`commands_dropped`](RuntimeStatsSnapshot::commands_dropped). Pause/resume signals are
411    ///   idempotent and re-issued on the next pause cycle, so a dropped command self-heals.
412    fn try_send_control(&self, cmd: Command) {
413        if !self.inner.started.load(Ordering::Relaxed) {
414            return;
415        }
416        if self.inner.tx.try_send(cmd).is_err() {
417            self.inner.shared.stats.commands_dropped.fetch_add(1, Ordering::Relaxed);
418        }
419    }
420
421    /// Returns whether out-of-process helper execution is paused.
422    pub fn is_paused(&self) -> bool {
423        self.inner.shared.pause_depth.load(Ordering::Relaxed) != 0
424    }
425
426    /// Sets whether the runtime is enabled, spawning the backend thread on first enable.
427    ///
428    /// When `enabled` is `true` and the backend thread has not been spawned yet, this
429    /// lazily starts it using the config provided at construction time.
430    pub fn set_enabled(&self, enabled: bool) -> eyre::Result<()> {
431        debug!(enabled, "set_enabled");
432        self.inner.enabled.store(enabled, Ordering::Relaxed);
433        if enabled {
434            self.ensure_started()?;
435        }
436        Ok(())
437    }
438
439    /// Returns a point-in-time snapshot of runtime statistics.
440    pub fn stats(&self) -> RuntimeStatsSnapshot {
441        self.inner.stats()
442    }
443
444    /// Spawns the backend thread if it hasn't been started yet.
445    fn ensure_started(&self) -> eyre::Result<()> {
446        let mut guard = self.inner.lazy_spawn.lock().unwrap();
447        let Some(lazy) = guard.take() else {
448            return Ok(());
449        };
450
451        let LazySpawnState { rx, config } = lazy;
452
453        debug!(
454            blocking = self.inner.blocking,
455            workers = config.tuning.jit_worker_count,
456            jit_mode = ?config.jit_mode,
457            hot_threshold = config.tuning.jit_hot_threshold,
458            channel_capacity = config.tuning.channel_capacity,
459            "spawning backend thread",
460        );
461
462        // Preload AOT artifacts into the already-allocated resident map.
463        match Self::preload_aot(config.store.as_deref()) {
464            Ok(entries) => {
465                for (key, prog) in entries {
466                    self.inner.shared.resident.insert(key, prog);
467                }
468            }
469            Err(e) => {
470                // Restore lazy_spawn so a subsequent attempt can retry.
471                *guard = Some(LazySpawnState { rx, config });
472                return Err(e);
473            }
474        }
475
476        drop(guard);
477
478        let (done_tx, done_rx) = chan::bounded::<()>(1);
479        let shared = Arc::clone(&self.inner.shared);
480
481        let thread = std::thread::Builder::new()
482            .name(config.thread_name.clone())
483            .spawn(move || {
484                backend::run(shared, rx, config);
485                let _ = done_tx.send(());
486            })
487            .wrap_err("failed to spawn backend thread")?;
488
489        *self.inner.thread.lock().unwrap() = Some(BackendThread { handle: thread, done_rx });
490        self.inner.started.store(true, Ordering::Relaxed);
491        Ok(())
492    }
493
494    /// Preloads AOT artifacts from the store as `Arc<CompiledProgram>`s ready to insert.
495    fn preload_aot(
496        store: Option<&dyn ArtifactStore>,
497    ) -> eyre::Result<Vec<(RuntimeCacheKey, Arc<CompiledProgram>)>> {
498        let Some(store) = store else {
499            debug!("no artifact store configured, skipping AOT preload");
500            return Ok(Vec::new());
501        };
502
503        let span = info_span!("aot_preload");
504        let _enter = span.enter();
505
506        let artifacts = store.load_all()?;
507        info!(count = artifacts.len(), "loading AOT artifacts");
508
509        let mut out: Vec<(RuntimeCacheKey, Arc<CompiledProgram>)> =
510            Vec::with_capacity(artifacts.len());
511        let mut seen = alloy_primitives::map::HashSet::<RuntimeCacheKey>::default();
512        let mut loaded = 0u64;
513        let mut failed = 0u64;
514
515        for (artifact_key, stored) in artifacts {
516            match Self::load_artifact(&artifact_key, &stored) {
517                Ok(program) => {
518                    let key = artifact_key.runtime;
519                    if !seen.insert(key) {
520                        warn!(
521                            code_hash = %key.code_hash,
522                            spec_id = ?key.spec_id,
523                            "duplicate artifact key, keeping first",
524                        );
525                        continue;
526                    }
527                    out.push((key, Arc::new(program)));
528                    loaded += 1;
529                }
530                Err(e) => {
531                    warn!(
532                        code_hash = %artifact_key.runtime.code_hash,
533                        error = %e,
534                        "failed to load artifact, skipping",
535                    );
536                    failed += 1;
537                }
538            }
539        }
540
541        info!(loaded, failed, "AOT preload complete");
542        Ok(out)
543    }
544
545    /// Loads a single artifact: `dlopen`s the dylib path and resolves the symbol.
546    fn load_artifact(key: &ArtifactKey, stored: &StoredArtifact) -> eyre::Result<CompiledProgram> {
547        let library = unsafe { libloading::Library::new(&stored.dylib_path) }
548            .wrap_err_with(|| format!("dlopen {:?}", stored.dylib_path))?;
549
550        let func: EvmCompilerFn = unsafe {
551            let sym: libloading::Symbol<'_, EvmCompilerFn> = library
552                .get(stored.manifest.symbol_name.as_bytes())
553                .wrap_err_with(|| format!("symbol '{}'", stored.manifest.symbol_name))?;
554            *sym
555        };
556
557        let library = Arc::new(LoadedLibrary::new(library));
558        Ok(CompiledProgram::new_aot(key.runtime, func, library))
559    }
560}
561
562impl BackendShared {
563    /// Returns a point-in-time snapshot of runtime statistics.
564    ///
565    /// `command_queue_len` is omitted because the command channel sender lives
566    /// on [`BackendInner`], not [`BackendShared`].
567    pub(crate) fn stats(&self) -> RuntimeStatsSnapshot {
568        self.stats.snapshot(stats::RuntimeStatsGauges {
569            resident_entries: self.resident.len() as u64,
570            events_queued: self.events.len() as u64,
571            command_queue_len: 0,
572        })
573    }
574}
575
576impl BackendInner {
577    /// Returns a point-in-time snapshot of runtime statistics.
578    pub(crate) fn stats(&self) -> RuntimeStatsSnapshot {
579        let mut snap = self.shared.stats();
580        snap.command_queue_len = self.tx.len() as u64;
581        snap
582    }
583
584    fn shutdown(&self) -> eyre::Result<()> {
585        debug!("shutting down JIT backend");
586        if let Some(ct) = self.thread.lock().unwrap().take() {
587            // Ignoring send error — backend may already be gone.
588            let _ = self.tx.send(Command::Shutdown);
589
590            // Wait for the thread to signal completion, with a timeout.
591            match ct.done_rx.recv_timeout(self.shutdown_timeout) {
592                Ok(()) | Err(chan::RecvTimeoutError::Disconnected) => {}
593                Err(chan::RecvTimeoutError::Timeout) => {
594                    eyre::bail!(
595                        "backend thread did not exit within timeout ({:?})",
596                        self.shutdown_timeout
597                    );
598                }
599            }
600
601            // Thread signaled done, join should return immediately.
602            ct.handle.join().map_err(|_| eyre::eyre!("backend thread panicked"))?;
603        }
604        Ok(())
605    }
606}
607
608impl Drop for BackendInner {
609    fn drop(&mut self) {
610        if let Err(err) = self.shutdown() {
611            warn!(%err, "failed to shutdown JIT backend");
612        }
613    }
614}