Skip to content

Commit 00ae9d1

Browse files
Yuriy Butenkoclaude
authored andcommitted
perf(v8): memoize the snapshot cache key instead of re-digesting per exec
`snapshot_cache_key` SHA-256s the bridge bundle (~2.5 MB) plus the userland bundle. Both are process-lifetime constants, and every execution derives the key four times: the snapshot-cache lookup, the warm-worker pool key on the pre-warm path, and again on the claim path inside session creation. Each digest measured ~7 ms, so a warm guest exec spent ~28 ms hashing two strings that never change — the flat ~14 ms "snapshot-ready/pre-warm handshake" and the flat ~14 ms "JS execution dispatch" on the launch path were almost entirely this. Memoize by content: a bounded process-wide table keyed on full equality of the bridge and userland text. A memcmp over the same bytes is ~35x cheaper than the digest, and content equality means a lookup can only return the key of a bundle byte-identical to the one asked for, so the memo is indistinguishable from recomputing and cannot surface another caller's bundle. Also hash the two inputs as streaming updates rather than concatenating them into a fresh 2.8 MB buffer first; the digest is unchanged. Sidecar phases for a warm `sh -c "echo x"`: snapshot-ready + pre-warm handshake 14.0 -> 0.3 ms, JS execution dispatch 14.3 -> 0.5 ms, execution finish 28.5 -> 1.1 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3497abe commit 00ae9d1

1 file changed

Lines changed: 91 additions & 15 deletions

File tree

crates/v8-runtime/src/snapshot.rs

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::collections::HashMap;
44
use std::io::{Read, Write};
55
use std::process::{Command, Stdio};
6-
use std::sync::{Arc, Condvar, Mutex};
6+
use std::sync::{Arc, Condvar, Mutex, OnceLock};
77

88
use sha2::{Digest, Sha256};
99

@@ -746,26 +746,77 @@ impl SnapshotCache {
746746
}
747747
}
748748

749+
/// How many distinct (bridge, userland) pairs keep a memoized digest. A process
750+
/// sees one bridge bundle and a handful of userland bundles, so this is sized for
751+
/// "all of them" rather than for eviction pressure.
752+
const SNAPSHOT_KEY_MEMO_CAPACITY: usize = 4;
753+
754+
struct SnapshotKeyMemoEntry {
755+
bridge_code: Box<str>,
756+
userland_code: Option<Box<str>>,
757+
key: SnapshotCacheKey,
758+
}
759+
760+
impl SnapshotKeyMemoEntry {
761+
fn matches(&self, bridge_code: &str, userland_code: Option<&str>) -> bool {
762+
&*self.bridge_code == bridge_code && self.userland_code.as_deref() == userland_code
763+
}
764+
}
765+
766+
fn snapshot_key_memo() -> &'static Mutex<Vec<SnapshotKeyMemoEntry>> {
767+
static MEMO: OnceLock<Mutex<Vec<SnapshotKeyMemoEntry>>> = OnceLock::new();
768+
MEMO.get_or_init(|| Mutex::new(Vec::new()))
769+
}
770+
749771
/// Cache key over bridge + optional userland code. With no userland this is just
750772
/// the sha256 of the bridge code (a NUL separator is only added when userland is
751773
/// present), so existing bridge-only entries keep their historical keys.
774+
///
775+
/// The digest is memoized by content. The bridge bundle alone is ~2.5 MB, and
776+
/// every execution derives this key several times (the snapshot-cache lookup plus
777+
/// the warm-worker pool key on both the pre-warm and the claim path), so the
778+
/// re-digesting cost ~7 ms per call. Matching by full content equality — a memcmp,
779+
/// roughly 35x cheaper than the digest — keeps the memo indistinguishable from
780+
/// recomputing: it can only return the key of a bundle byte-identical to the one
781+
/// asked for, so no caller can observe another caller's bundle through it.
752782
pub fn snapshot_cache_key(bridge_code: &str, userland_code: Option<&str>) -> SnapshotCacheKey {
753-
match userland_code {
754-
None => {
755-
let mut hasher = Sha256::new();
756-
hasher.update(bridge_code.as_bytes());
757-
hasher.finalize().into()
758-
}
759-
Some(userland_code) => {
760-
let mut buf = Vec::with_capacity(bridge_code.len() + 1 + userland_code.len());
761-
buf.extend_from_slice(bridge_code.as_bytes());
762-
buf.push(0);
763-
buf.extend_from_slice(userland_code.as_bytes());
764-
let mut hasher = Sha256::new();
765-
hasher.update(&buf);
766-
hasher.finalize().into()
783+
if let Some(key) = snapshot_key_memo()
784+
.lock()
785+
.unwrap()
786+
.iter()
787+
.find(|entry| entry.matches(bridge_code, userland_code))
788+
.map(|entry| entry.key)
789+
{
790+
return key;
791+
}
792+
793+
let key = compute_snapshot_cache_key(bridge_code, userland_code);
794+
795+
let mut memo = snapshot_key_memo().lock().unwrap();
796+
if !memo
797+
.iter()
798+
.any(|entry| entry.matches(bridge_code, userland_code))
799+
{
800+
if memo.len() >= SNAPSHOT_KEY_MEMO_CAPACITY {
801+
memo.remove(0);
767802
}
803+
memo.push(SnapshotKeyMemoEntry {
804+
bridge_code: Box::from(bridge_code),
805+
userland_code: userland_code.map(Box::from),
806+
key,
807+
});
768808
}
809+
key
810+
}
811+
812+
fn compute_snapshot_cache_key(bridge_code: &str, userland_code: Option<&str>) -> SnapshotCacheKey {
813+
let mut hasher = Sha256::new();
814+
hasher.update(bridge_code.as_bytes());
815+
if let Some(userland_code) = userland_code {
816+
hasher.update([0u8]);
817+
hasher.update(userland_code.as_bytes());
818+
}
819+
hasher.finalize().into()
769820
}
770821

771822
#[doc(hidden)]
@@ -2102,6 +2153,31 @@ mod tests {
21022153
);
21032154
}
21042155

2156+
#[test]
2157+
fn snapshot_cache_key_memo_agrees_with_a_fresh_digest() {
2158+
// The memo must be indistinguishable from recomputing, including after it
2159+
// has been filled past its capacity and the earliest entries evicted.
2160+
let inputs: Vec<(String, Option<String>)> = (0..SNAPSHOT_KEY_MEMO_CAPACITY + 3)
2161+
.flat_map(|index| {
2162+
let bridge = format!("memo-bridge-{index}");
2163+
[
2164+
(bridge.clone(), None),
2165+
(bridge, Some(format!("memo-userland-{index}"))),
2166+
]
2167+
})
2168+
.collect();
2169+
2170+
for round in 0..3 {
2171+
for (bridge, userland) in &inputs {
2172+
assert_eq!(
2173+
snapshot_cache_key(bridge, userland.as_deref()),
2174+
compute_snapshot_cache_key(bridge, userland.as_deref()),
2175+
"round {round}: memoized key must equal a fresh digest for {bridge}"
2176+
);
2177+
}
2178+
}
2179+
}
2180+
21052181
#[test]
21062182
fn create_snapshot_with_userland_rejects_oversized_userland_code() {
21072183
let bridge_code = "(function(){})();";

0 commit comments

Comments
 (0)