Skip to content

Commit e595e9f

Browse files
committed
feat(api): improve JSRuntime performance
1 parent 8611fd5 commit e595e9f

1 file changed

Lines changed: 92 additions & 30 deletions

File tree

src/js_runtime.rs

Lines changed: 92 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ pub use self::{
1111
};
1212
use crate::{config::JsRuntimeConfig, js_runtime::script::ScriptDefinition};
1313
use anyhow::{Context, anyhow};
14-
use deno_core::{Extension, RuntimeOptions, scope, serde_v8, v8};
14+
use deno_core::{Extension, JsRuntimeForSnapshot, RuntimeOptions, scope, serde_v8, v8};
1515
use serde::{Serialize, de::DeserializeOwned};
1616
use std::{
17+
num::NonZeroUsize,
1718
sync::{
18-
Arc,
19+
Arc, OnceLock,
1920
atomic::{AtomicUsize, Ordering},
2021
},
22+
thread,
2123
time::{Duration, Instant},
2224
};
2325
use tokio::{
@@ -43,47 +45,106 @@ const SCRIPT_EXCLUDED_OPS: [&str; 6] = [
4345
"op_eval_context",
4446
];
4547

48+
/// Cached V8 startup snapshot, built once per process in `init_platform`. The
49+
/// snapshot is deliberately empty - it captures the default V8 context state
50+
/// *before* any extensions are registered. Per-worker `deno_core::JsRuntime`
51+
/// instances still get their own `retrack_ext` extension, so behaviour is
52+
/// unchanged; the snapshot exists purely to amortise context bootstrap cost
53+
/// across every script execution in the process.
54+
static STARTUP_SNAPSHOT: OnceLock<&'static [u8]> = OnceLock::new();
55+
56+
/// Build a blank V8 startup snapshot once and cache the resulting byte slice
57+
/// in a `OnceLock`. The bytes are intentionally leaked so the slice can live
58+
/// for the process lifetime with a `'static` lifetime - V8 requires snapshot
59+
/// blobs to outlive the isolates they're attached to.
60+
fn startup_snapshot() -> &'static [u8] {
61+
STARTUP_SNAPSHOT.get_or_init(|| {
62+
let runtime = JsRuntimeForSnapshot::new(RuntimeOptions::default());
63+
let snapshot = runtime.snapshot();
64+
Box::leak(snapshot.into_vec().into_boxed_slice())
65+
})
66+
}
67+
68+
/// Pick a sensible worker count. We clamp between 2 and 8 so that small boxes
69+
/// still get parallelism and large ones don't pay for per-worker V8 overhead
70+
/// we can't actually saturate. The previous implementation always used 1.
71+
fn default_worker_count() -> usize {
72+
thread::available_parallelism()
73+
.map(NonZeroUsize::get)
74+
.unwrap_or(4)
75+
.clamp(2, 8)
76+
}
77+
4678
/// An abstraction over the V8/Deno runtime that allows any utilities to execute custom user
4779
/// JavaScript scripts.
4880
pub struct JsRuntime {
49-
tx: mpsc::Sender<ScriptTask>,
81+
/// Per-worker senders. Incoming work is dispatched round-robin across
82+
/// these, giving us up to `workers.len()` concurrent script executions
83+
/// without any additional synchronisation on the caller side.
84+
workers: Vec<mpsc::Sender<ScriptTask>>,
85+
next: AtomicUsize,
5086
}
5187

5288
impl JsRuntime {
5389
/// Initializes the JS runtime platform, should be called only once and in the main thread.
5490
pub fn init_platform(config: &JsRuntimeConfig) -> anyhow::Result<Self> {
5591
deno_core::JsRuntime::init_platform(None);
5692

57-
// JsRuntime will be initialized in the dedicated thread.
58-
let (tx, mut rx) = mpsc::channel::<ScriptTask>(config.channel_buffer_size);
93+
// Build the shared V8 snapshot on the main thread before any worker is
94+
// spawned, so every worker can cheaply clone the pointer later.
95+
let _ = startup_snapshot();
96+
97+
let worker_count = default_worker_count();
98+
let mut workers = Vec::with_capacity(worker_count);
99+
for idx in 0..worker_count {
100+
workers.push(Self::spawn_worker(idx, config.channel_buffer_size)?);
101+
}
102+
103+
Ok(Self {
104+
workers,
105+
next: AtomicUsize::new(0),
106+
})
107+
}
108+
109+
/// Spawn a single long-lived worker thread that owns a `CurrentThread`
110+
/// tokio runtime + `LocalSet` and drains `ScriptTask`s from its channel.
111+
/// Returns the sender half for dispatching work to this worker.
112+
fn spawn_worker(
113+
index: usize,
114+
channel_buffer: usize,
115+
) -> anyhow::Result<mpsc::Sender<ScriptTask>> {
116+
let (tx, mut rx) = mpsc::channel::<ScriptTask>(channel_buffer);
59117
let rt = Builder::new_current_thread()
60118
.enable_all()
61119
.build()
62-
.context("Unable to initialize JS runtime worker thread.")?;
63-
std::thread::spawn(move || {
64-
let local = LocalSet::new();
65-
local.spawn_local(async move {
66-
while let Some(task) = rx.recv().await {
67-
match task.script {
68-
Script::ApiTargetConfigurator(def) => {
69-
JsRuntime::handle_script(&task.config, def).await;
70-
}
71-
Script::ApiTargetExtractor(def) => {
72-
JsRuntime::handle_script(&task.config, def).await;
73-
}
74-
Script::ActionFormatter(def) => {
75-
JsRuntime::handle_script(&task.config, def).await;
76-
}
77-
Script::Custom(def) => {
78-
JsRuntime::handle_script(&task.config, def).await;
120+
.context("Unable to initialize JS runtime worker tokio runtime")?;
121+
let name = format!("retrack-js-worker-{index}");
122+
thread::Builder::new()
123+
.name(name)
124+
.spawn(move || {
125+
let local = LocalSet::new();
126+
local.spawn_local(async move {
127+
while let Some(task) = rx.recv().await {
128+
match task.script {
129+
Script::ApiTargetConfigurator(def) => {
130+
JsRuntime::handle_script(&task.config, def).await;
131+
}
132+
Script::ApiTargetExtractor(def) => {
133+
JsRuntime::handle_script(&task.config, def).await;
134+
}
135+
Script::ActionFormatter(def) => {
136+
JsRuntime::handle_script(&task.config, def).await;
137+
}
138+
Script::Custom(def) => {
139+
JsRuntime::handle_script(&task.config, def).await;
140+
}
79141
}
80142
}
81-
}
82-
});
83-
rt.block_on(local);
84-
});
85-
86-
Ok(Self { tx })
143+
});
144+
rt.block_on(local);
145+
})
146+
.context("Failed to spawn JS runtime worker thread")?;
147+
Ok(tx)
87148
}
88149

89150
/// Executes a user script and returns the result.
@@ -95,7 +156,8 @@ impl JsRuntime {
95156
) -> Result<Option<ScriptResult>, anyhow::Error> {
96157
let (script_result_tx, script_result_rx) = oneshot::channel();
97158

98-
self.tx
159+
let slot = self.next.fetch_add(1, Ordering::Relaxed) % self.workers.len();
160+
self.workers[slot]
99161
.send(ScriptTask {
100162
config: script_config,
101163
script: script_args.build(script_src, script_result_tx).0,
@@ -128,6 +190,7 @@ impl JsRuntime {
128190
create_params: Some(
129191
v8::Isolate::create_params().heap_limits(1_048_576, config.max_heap_size),
130192
),
193+
startup_snapshot: Some(startup_snapshot()),
131194
// Disable certain built-in operations.
132195
extensions: vec![Extension {
133196
name: "retrack_ext",
@@ -933,7 +996,6 @@ pub mod tests {
933996
"Deno.core.writeSync()",
934997
"Deno.core.writeTypeError()",
935998
"Error()",
936-
"ErrorStackTraceLimit=10",
937999
"EvalError()",
9381000
"FinalizationRegistry()",
9391001
"Float32Array()",

0 commit comments

Comments
 (0)