Skip to main content

javm_bench/
lib.rs

1//! Shared runners for the bench harness (`benches/bench.rs`) and the
2//! sub-VM benches (`benches/sub_vm_recurse.rs`,
3//! `benches/sub_vm_data_recurse.rs`).
4//!
5//! The bench measures the full per-invocation lifecycle:
6//!   * `Nub::put_cap_with_hash` for each cap the invocation requires
7//!     (Data blobs the Image references, the Image itself, the empty
8//!     root cnode, the Instance). Each put is a single
9//!     `BTreeMap::get + refcount.fetch_add(1)` after warm-up — i.e. a
10//!     few tens of nanoseconds per cap.
11//!   * `Nub::invoke_cached(instance_hash, endpoint, args, gas)`.
12//!
13//! - `run_interpreter` — `Nub::new_local()` drives the byte-PVM
14//!   interpreter (`javm-exec`) in-process.
15//! - `run_recompiler` — the process-wide `Nub::hyperlight()` singleton
16//!   drives the in-kernel JIT path through the same `invoke_cached` API.
17//!
18//! `BuiltCaps` holds the pre-built `Cap` graph + its precomputed
19//! hashes. Construction happens once per workload at bench warm-up via
20//! [`BuiltCaps::for_image`]; the iter loop reuses the resulting handles.
21//!
22//! Linux x86-64 only — `nub` pulls the Hyperlight host stack
23//! unconditionally.
24
25#![cfg(all(target_os = "linux", target_arch = "x86_64"))]
26
27use criterion::{BenchmarkId, Criterion, Throughput};
28use javm_cap::NUM_REGS;
29use javm_cap::image::{Image, PinnedCap};
30use javm_cap::slot::Key;
31use javm_cap::{Cap, CapHash};
32use nub::{HyperlightNubGuard, InvocationResult, Nub, SCRATCHPAD_HEAD_LEN};
33
34/// HostCall(0) — the trampoline halt all bench programs end on
35/// (`ecalli 0`). Both backends surface it as `exit_reason=4,
36/// exit_arg=0`.
37const EXIT_HOSTCALL: u32 = 4;
38
39/// Default initial-gas budget for the bench.
40pub const INITIAL_GAS: u64 = 100_000_000_000;
41
42/// Pre-built `Cap` graph for one (image, endpoint) bench cell.
43///
44/// Built once at warm-up via [`Self::for_image`]; the iter loop puts each
45/// cap with its precomputed hash and invokes. The first iter pays the
46/// full deep-clone cost (caps move into the Nub's cache allocator);
47/// subsequent iters hit the idempotent fast path (refcount bump only).
48pub struct BuiltCaps {
49    /// Cap::Data blobs for each pinned-slot Data + each initial-slot Data,
50    /// paired with their content hashes.
51    pub data_caps: Vec<(CapHash, Cap)>,
52    /// Cap::Image referencing the data_caps above by hash.
53    pub image_cap: Cap,
54    pub image_hash: CapHash,
55    /// Empty Cap::CNode (V1 has no per-instance slot bindings).
56    pub cnode_cap: Cap,
57    pub cnode_hash: CapHash,
58    /// Cap::Instance with the bench's flat (ro, rw) overlay layout.
59    pub instance_cap: Cap,
60    pub instance_hash: CapHash,
61    pub endpoint_idx: u8,
62}
63
64impl BuiltCaps {
65    /// Build the full `Cap` graph for `image[endpoint_idx]`. All
66    /// hashes are precomputed once here.
67    pub fn for_image(image: &Image, endpoint_idx: u8) -> Self {
68        let endpoint = image
69            .endpoints
70            .get(&Key::from(endpoint_idx))
71            .unwrap_or_else(|| panic!("endpoint {endpoint_idx} not declared"));
72
73        // 1. Build a Cap::Data per non-empty pinned/initial slot. Track
74        //    each slot's resolved CapHash so the Image can reference them.
75        let mut data_caps: Vec<(CapHash, Cap)> = Vec::new();
76        let mut pinned_hashes: Vec<(Key, CapHash)> = Vec::new();
77        let mut initial_hashes: Vec<(Key, CapHash)> = Vec::new();
78
79        for (slot, pinned) in &image.pinned_slots {
80            let (h, cap) = match pinned {
81                PinnedCap::Data { desc } => {
82                    let cap = Cap::data_from_desc(&image.arena, desc);
83                    let h = ssz::hash_tree_root(&cap);
84                    (h, Some(cap))
85                }
86                PinnedCap::Image { content_hash } => {
87                    // Sub-Image hash assumed already-published; carry it
88                    // through to the image_with_slots builder.
89                    (*content_hash, None)
90                }
91            };
92            pinned_hashes.push((slot.clone(), h));
93            if let Some(c) = cap {
94                data_caps.push((h, c));
95            }
96        }
97        for (slot, desc) in &image.initial_slots {
98            let cap = Cap::data_from_desc(&image.arena, desc);
99            let h = ssz::hash_tree_root(&cap);
100            initial_hashes.push((slot.clone(), h));
101            data_caps.push((h, cap));
102        }
103
104        // 2. Build the Cap::Image referencing the data caps by hash.
105        let image_cap = Cap::image_with_slots(image, &pinned_hashes, &initial_hashes)
106            .expect("image_with_slots");
107        let image_hash = ssz::hash_tree_root(&image_cap);
108
109        // 3. Empty root CNode (V1: no per-instance slot bindings).
110        let cnode_cap = Cap::empty_cnode();
111        let cnode_hash = ssz::hash_tree_root(&cnode_cap);
112
113        // 4. Build the Instance with the bench's memory image.
114        let mem = image.instance_mem_backing();
115
116        let mut regs = [0u64; NUM_REGS];
117        for (&i, &v) in &endpoint.initial_regs {
118            if let Some(slot) = regs.get_mut(i as usize) {
119                *slot = v;
120            }
121        }
122
123        let instance_cap =
124            Cap::instance_with_mem([0u8; 32], image_hash, cnode_hash, mem, regs, 0, 0);
125        let instance_hash = ssz::hash_tree_root(&instance_cap);
126
127        BuiltCaps {
128            data_caps,
129            image_cap,
130            image_hash,
131            cnode_cap,
132            cnode_hash,
133            instance_cap,
134            instance_hash,
135            endpoint_idx,
136        }
137    }
138
139    /// Put every cap into `nub`'s cache via `put_cap_with_hash`.
140    /// Idempotent re-puts after the first call are refcount bumps only.
141    pub fn put_into(&self, nub: &Nub) {
142        for (h, cap) in &self.data_caps {
143            nub.put_cap_with_hash(*h, cap)
144                .unwrap_or_else(|e| panic!("put_cap_with_hash data: {e}"));
145        }
146        nub.put_cap_with_hash(self.image_hash, &self.image_cap)
147            .unwrap_or_else(|e| panic!("put_cap_with_hash image: {e}"));
148        nub.put_cap_with_hash(self.cnode_hash, &self.cnode_cap)
149            .unwrap_or_else(|e| panic!("put_cap_with_hash cnode: {e}"));
150        nub.put_cap_with_hash(self.instance_hash, &self.instance_cap)
151            .unwrap_or_else(|e| panic!("put_cap_with_hash instance: {e}"));
152    }
153}
154
155/// Cloneable handle to the process-wide Hyperlight Nub.
156pub type NubGuard = HyperlightNubGuard;
157
158/// Bench-side accessor for the long-lived Hyperlight Nub. Returned
159/// guard holds the singleton mutex for the duration of one
160/// criterion `iter_batched` step (setup + routine).
161pub fn nub_hyperlight_lock() -> NubGuard {
162    Nub::hyperlight().expect("Hyperlight sandbox")
163}
164
165/// Bench helper: drive one invocation through an already-locked Nub.
166/// Used inside `iter_batched`'s routine closure so the timed body is
167/// just the host-call round-trip + JIT path (no mutex acquire, no
168/// cap publish, no sandbox rebuild).
169pub fn invoke(nub: &Nub, built: &BuiltCaps) -> (u64, u64) {
170    let result = nub
171        .invoke_cached(built.instance_hash, built.endpoint_idx, [0; 4], INITIAL_GAS)
172        .unwrap_or_else(|e| panic!("invoke_cached: {e}"));
173    finish(&result)
174}
175
176/// Drive `built[endpoint_idx]` through the PVM2 (RISC-V) interpreter via
177/// a fresh `Nub::new_local()` (the Local backend has no per-invocation
178/// state, so a fresh Nub each call is fine — and matches the chain's
179/// per-event allocation model).
180pub fn run_interpreter(built: &BuiltCaps) -> (u64, u64) {
181    let nub = Nub::new_local();
182    built.put_into(&nub);
183    let result = nub
184        .invoke_cached(built.instance_hash, built.endpoint_idx, [0; 4], INITIAL_GAS)
185        .unwrap_or_else(|e| panic!("interpreter invoke_cached: {e}"));
186    finish(&result)
187}
188
189/// Drive `built[endpoint_idx]` through the in-kernel JIT via the long-
190/// lived Hyperlight `Nub`. **Warm-cache** path: subsequent calls with
191/// the same Image hit the JIT compile cache. Useful for measuring
192/// steady-state execute throughput in isolation.
193pub fn run_recompiler(built: &BuiltCaps) -> (u64, u64) {
194    let nub = nub_hyperlight_lock();
195    built.put_into(&nub);
196    let result = nub
197        .invoke_cached(built.instance_hash, built.endpoint_idx, [0; 4], INITIAL_GAS)
198        .unwrap_or_else(|e| panic!("recompiler invoke_cached: {e}"));
199    finish(&result)
200}
201
202fn finish(result: &InvocationResult) -> (u64, u64) {
203    assert_eq!(
204        result.exit_reason, EXIT_HOSTCALL,
205        "unexpected exit_reason {} (exit_arg={})",
206        result.exit_reason, result.exit_arg,
207    );
208    assert_eq!(
209        result.exit_arg, 0,
210        "expected HostCall(0) trampoline halt, got HostCall({})",
211        result.exit_arg,
212    );
213    let gas_used = INITIAL_GAS.saturating_sub(result.gas_remaining);
214    (result.return_value, gas_used)
215}
216
217/// `exit_reason` reported when a recompiler run aborts the VM — a guest
218/// CPU fault delivered as an unhandled IDT vector (e.g. `#DE` from `idiv`
219/// on INT_MIN/-1) surfaces as a host `Err(GuestAborted)`, not an
220/// [`InvocationResult`]. Distinct from every real PVM2 exit reason (0..=7).
221pub const ABORT_SENTINEL: u32 = u32::MAX;
222
223/// Raw invocation outcome for differential testing — the four
224/// [`InvocationResult`] fields with **no clean-halt assertion**.
225///
226/// `run_interpreter`/`run_recompiler` assert a clean `HostCall(0)` halt
227/// (via `finish`) and panic otherwise — correct for benches, wrong for a
228/// differential harness that must *observe* a divergent exit. These raw
229/// variants surface whatever happened, with `exit_reason = ABORT_SENTINEL`
230/// for the recompiler's guest-abort path.
231///
232/// `gas_used` is `INITIAL_GAS - gas_remaining`; on an abort it is 0 (no
233/// `InvocationResult` was produced).
234///
235/// `scratchpad_head` is the running Instance's scratchpad (`slot[0]`) region head
236/// — the lossless, model-conformant result readback that supersedes the former
237/// x10 fold. The fuzz differential compares it across engines and against the
238/// oracle gold (see `javm_fuzz::replay`).
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct RawRun {
241    pub exit_reason: u32,
242    pub exit_arg: u32,
243    pub return_value: u64,
244    pub gas_used: u64,
245    pub scratchpad_head: [u8; SCRATCHPAD_HEAD_LEN],
246}
247
248impl RawRun {
249    fn from_result(r: &InvocationResult) -> Self {
250        RawRun {
251            exit_reason: r.exit_reason,
252            exit_arg: r.exit_arg,
253            return_value: r.return_value,
254            gas_used: INITIAL_GAS.saturating_sub(r.gas_remaining),
255            scratchpad_head: r.scratchpad_head,
256        }
257    }
258
259    fn aborted() -> Self {
260        RawRun {
261            exit_reason: ABORT_SENTINEL,
262            exit_arg: 0,
263            return_value: 0,
264            gas_used: 0,
265            scratchpad_head: [0u8; SCRATCHPAD_HEAD_LEN],
266        }
267    }
268}
269
270/// Interpreter run with no clean-halt assertion (cf. [`run_interpreter`]).
271/// The Local backend never aborts the host, so `invoke_cached` returns
272/// `Ok` in practice; an `Err` is still mapped to the abort sentinel for
273/// symmetry with the recompiler.
274pub fn run_interpreter_raw(built: &BuiltCaps) -> RawRun {
275    let nub = Nub::new_local();
276    built.put_into(&nub);
277    match nub.invoke_cached(built.instance_hash, built.endpoint_idx, [0; 4], INITIAL_GAS) {
278        Ok(r) => RawRun::from_result(&r),
279        Err(_) => RawRun::aborted(),
280    }
281}
282
283/// Recompiler run with no clean-halt assertion (cf. [`run_recompiler`]).
284///
285/// On a guest abort (e.g. `#DE`), `invoke_cached` returns `Err` and the
286/// sandbox is poisoned; we report [`ABORT_SENTINEL`] and do **not** rebuild it
287/// (rebuilding was the source of the host-heap corruption). A caller that hits
288/// an abort should stop — every subsequent invoke on the poisoned sandbox also
289/// returns `Err` → `ABORT_SENTINEL`. (For valid PVM2 code no abort occurs, so
290/// long differential sweeps run uninterrupted.)
291pub fn run_recompiler_raw(built: &BuiltCaps) -> RawRun {
292    let nub = nub_hyperlight_lock();
293    built.put_into(&nub);
294    match nub.invoke_cached(built.instance_hash, built.endpoint_idx, [0; 4], INITIAL_GAS) {
295        Ok(r) => RawRun::from_result(&r),
296        Err(_) => RawRun::aborted(),
297    }
298}
299
300// ============================================================================
301// Sub-VM recurse benches (shared driver)
302// ============================================================================
303//
304// `benches/sub_vm_recurse.rs` and `benches/sub_vm_data_recurse.rs` differ
305// only in which guest blob they ship and their label, so the whole build +
306// invoke + criterion driver lives here and each bench file is a one-liner.
307
308/// cnode slot holding the recurse Image (each level inherits it from its
309/// parent — see `dispatch_host_call` in `nub-arch-x86::call_loop`).
310const SLOT_IMAGE_RECURSE: u32 = 3;
311
312/// Top-of-recursion `Cap::Instance` published for a sub-VM bench.
313pub struct SubVmTop {
314    pub top_instance: CapHash,
315    pub image_hash: CapHash,
316}
317
318/// Publish an Image's data slots (`Cap::Data` per pinned/initial
319/// slot) and the `Cap::Image` that references them into `nub`, and
320/// return the image hash.
321///
322/// The bench guests ship `.rodata` + a small stack (and, for the data
323/// variants, a pinned blob) via their image mappings; the in-kernel
324/// call_loop reads those bytes from the shared cache when building a
325/// frame's mem image.
326fn publish_image(nub: &mut Nub, image: &Image) -> CapHash {
327    let mut data_caps: Vec<(CapHash, Cap)> = Vec::new();
328    let mut pinned_hashes: Vec<(Key, CapHash)> = Vec::new();
329    let mut initial_hashes: Vec<(Key, CapHash)> = Vec::new();
330    for (slot, pinned) in &image.pinned_slots {
331        let (h, maybe_cap) = match pinned {
332            PinnedCap::Data { desc } => {
333                let cap = Cap::data_from_desc(&image.arena, desc);
334                let h = ssz::hash_tree_root(&cap);
335                (h, Some(cap))
336            }
337            PinnedCap::Image { content_hash } => (*content_hash, None),
338        };
339        pinned_hashes.push((slot.clone(), h));
340        if let Some(cap) = maybe_cap {
341            data_caps.push((h, cap));
342        }
343    }
344    for (slot, desc) in &image.initial_slots {
345        let cap = Cap::data_from_desc(&image.arena, desc);
346        let h = ssz::hash_tree_root(&cap);
347        initial_hashes.push((slot.clone(), h));
348        data_caps.push((h, cap));
349    }
350    for (h, cap) in &data_caps {
351        nub.put_cap_with_hash(*h, cap).expect("put data");
352    }
353    let image_cap =
354        Cap::image_with_slots(image, &pinned_hashes, &initial_hashes).expect("image_with_slots");
355    let image_hash = ssz::hash_tree_root(&image_cap);
356    nub.put_cap_with_hash(image_hash, &image_cap)
357        .expect("put image");
358    image_hash
359}
360
361/// Publish `cnode` and an endpoint-0 `Cap::Instance` (backed by
362/// `image`'s memory) into `nub`, and return the instance hash. The
363/// `image_hash` must be the hash returned by [`publish_image`] for the
364/// same `image`.
365fn publish_instance(
366    nub: &mut Nub,
367    image: &Image,
368    image_hash: CapHash,
369    cnode: javm_cap::CNodeCap,
370) -> CapHash {
371    const ENDPOINT_IDX: u8 = 0;
372
373    let cnode_cap = Cap::CNode(cnode);
374    let cnode_hash = ssz::hash_tree_root(&cnode_cap);
375    nub.put_cap_with_hash(cnode_hash, &cnode_cap)
376        .expect("put cnode");
377
378    let endpoint = image
379        .endpoints
380        .get(&Key::from(ENDPOINT_IDX))
381        .expect("endpoint 0");
382    let mut regs = [0u64; NUM_REGS];
383    for (&i, &v) in &endpoint.initial_regs {
384        if let Some(slot) = regs.get_mut(i as usize) {
385            *slot = v;
386        }
387    }
388
389    let mem = image.instance_mem_backing();
390    let inst_cap = Cap::instance_with_mem([0u8; 32], image_hash, cnode_hash, mem, regs, 0, 0);
391    let inst_hash = ssz::hash_tree_root(&inst_cap);
392    nub.put_cap_with_hash(inst_hash, &inst_cap)
393        .expect("put instance");
394    inst_hash
395}
396
397/// Build + publish (once) the recurse Image, its cnode, and the top
398/// Instance into `nub` from the SSZ-encoded Image `blob`. Returns the
399/// top instance hash so the bench loop can invoke it directly.
400pub fn build_sub_vm_top(nub: &mut Nub, blob: &[u8]) -> SubVmTop {
401    use javm_cap::{CNodeCap, CapHashOrRef};
402    use ssz::Decode;
403
404    let image = Image::from_ssz_bytes(blob).expect("decode sub-vm image");
405    let image_hash = publish_image(nub, &image);
406
407    // The top instance's cnode holds the recurse image at
408    // SLOT_IMAGE_RECURSE; every spawned child inherits it, so each
409    // level finds the same image to derive_spawn again.
410    let mut cn = CNodeCap::new();
411    cn.set(
412        &Key::from(SLOT_IMAGE_RECURSE as u8),
413        Some(CapHashOrRef::Hash(image_hash)),
414    )
415    .expect("set image slot");
416
417    let inst_hash = publish_instance(nub, &image, image_hash, cn);
418    SubVmTop {
419        top_instance: inst_hash,
420        image_hash,
421    }
422}
423
424/// One sub-VM bench iteration: invoke the top instance with `depth` and
425/// panic unless it halted cleanly on the trampoline HostCall(0).
426pub fn invoke_sub_vm(nub: &Nub, top: &SubVmTop, depth: u64) {
427    let result = nub
428        .invoke_cached(top.top_instance, 0, [depth, 0, 0, 0], INITIAL_GAS)
429        .expect("invoke_cached");
430    assert!(
431        result.exit_reason == EXIT_HOSTCALL && result.exit_arg == 0,
432        "sub-VM exited non-cleanly: reason={} arg={} ret={} gas={}",
433        result.exit_reason,
434        result.exit_arg,
435        result.return_value,
436        result.gas_remaining,
437    );
438}
439
440/// Like [`invoke_sub_vm`] but returns `(return_value, gas_used)` after asserting
441/// a clean trampoline halt. Used by the `sub_vm_gas_parity` test to check the
442/// recompiler's category-#3 charge is identical per recursion level (gas affine
443/// in depth — a multi-frame determinism guard).
444pub fn invoke_sub_vm_gas(nub: &Nub, top: &SubVmTop, depth: u64) -> (u64, u64) {
445    let result = nub
446        .invoke_cached(top.top_instance, 0, [depth, 0, 0, 0], INITIAL_GAS)
447        .expect("invoke_cached");
448    assert!(
449        result.exit_reason == EXIT_HOSTCALL && result.exit_arg == 0,
450        "sub-VM exited non-cleanly: reason={} arg={} ret={} gas={}",
451        result.exit_reason,
452        result.exit_arg,
453        result.return_value,
454        result.gas_remaining,
455    );
456    (
457        result.return_value,
458        INITIAL_GAS.saturating_sub(result.gas_remaining),
459    )
460}
461
462/// Like [`invoke_sub_vm`] but also asserts the top-level return value. Used by
463/// the data-recurse correctness check to confirm each level reads its pinned
464/// RO data + writes its initial RW data correctly (not just that it halts).
465pub fn invoke_sub_vm_expect(nub: &Nub, top: &SubVmTop, depth: u64, expected_return: u64) {
466    let result = nub
467        .invoke_cached(top.top_instance, 0, [depth, 0, 0, 0], INITIAL_GAS)
468        .expect("invoke_cached");
469    assert!(
470        result.exit_reason == EXIT_HOSTCALL && result.exit_arg == 0,
471        "sub-VM exited non-cleanly: reason={} arg={} ret={} gas={}",
472        result.exit_reason,
473        result.exit_arg,
474        result.return_value,
475        result.gas_remaining,
476    );
477    assert_eq!(
478        result.return_value, expected_return,
479        "sub-VM depth {depth} returned {} (expected {expected_return})",
480        result.return_value,
481    );
482}
483
484/// First 8 bytes of a `CapHash` as lowercase hex (bench logging).
485pub fn hex_short(h: &CapHash) -> String {
486    use std::fmt::Write as _;
487    let mut s = String::with_capacity(16);
488    for b in &h[..8] {
489        let _ = write!(s, "{b:02x}");
490    }
491    s
492}
493
494/// Run the full sub-VM recurse criterion bench for `blob`, labelled
495/// `label`. Publishes the top instance once (the kernel stays warm: the
496/// JIT cache holds the compiled image, the cap cache holds the
497/// Image/Top-Instance/CNode), runs a depth-0/1 sanity check, then sweeps
498/// depths {10, 100, 1000}.
499pub fn run_recurse_bench(c: &mut Criterion, blob: &[u8], label: &str) {
500    let top = build_sub_vm_top(&mut nub_hyperlight_lock(), blob);
501    eprintln!(
502        "[{label}] image_hash={} top_instance={}",
503        hex_short(&top.image_hash),
504        hex_short(&top.top_instance),
505    );
506
507    // depth 0 (single CALL/HALT) + depth 1 sanity before the loop —
508    // catches top-frame / direct-mapping setup bugs early.
509    {
510        let nub = nub_hyperlight_lock();
511        invoke_sub_vm(&nub, &top, 0);
512        invoke_sub_vm(&nub, &top, 1);
513    }
514    eprintln!("[{label}] depth 0/1 ok");
515
516    let mut g = c.benchmark_group(label);
517    g.sample_size(20);
518    for &depth in &[10u64, 100, 1_000] {
519        g.throughput(Throughput::Elements(depth));
520        g.bench_with_input(BenchmarkId::from_parameter(depth), &depth, |b, &d| {
521            b.iter(|| invoke_sub_vm(&nub_hyperlight_lock(), &top, d))
522        });
523    }
524    g.finish();
525}
526
527// ---- page-table-cache bench (A repeatedly CALLs a resident B) ----
528
529/// Top instance of the page-table-cache bench: a single image whose
530/// endpoint 0 (`A`) `derive_spawn`s a child of the same image once and
531/// repeatedly `host_call`s its echo endpoint (`B`).
532pub struct PtCacheTop {
533    /// Caller `A`'s instance hash — the one the bench invokes.
534    pub top_instance: CapHash,
535    /// The shared image hash (kept warm in the JIT cache).
536    pub image_hash: CapHash,
537}
538
539/// Build + publish (once) the page-table-cache image and its top
540/// Instance into `nub` from the SSZ-encoded Image `blob`. Returns the
541/// top instance hash so the bench loop can invoke endpoint 0 directly.
542pub fn build_pt_cache_top(nub: &mut Nub, blob: &[u8]) -> PtCacheTop {
543    use javm_cap::CNodeCap;
544    use ssz::Decode;
545
546    let image = Image::from_ssz_bytes(blob).expect("decode pt-cache image");
547    let image_hash = publish_image(nub, &image);
548    // Empty cnode: the guest's `derive_spawn` reads an empty image
549    // slot and falls back to its own image to mint the resident child.
550    let top_instance = publish_instance(nub, &image, image_hash, CNodeCap::new());
551    PtCacheTop {
552        top_instance,
553        image_hash,
554    }
555}
556
557/// `A` returns `Σ_{i<n} i = n·(n−1)/2` (each echo returns its index).
558/// `wrapping`-safe to mirror the guest's `wrapping_add` accumulator.
559fn pt_cache_expected_return(n: u64) -> u64 {
560    let mut acc = 0u64;
561    for i in 0..n {
562        acc = acc.wrapping_add(i);
563    }
564    acc
565}
566
567/// One page-table-cache bench iteration: invoke endpoint 0, which
568/// `host_call`s the resident `B` `n` times. Asserts a clean trampoline
569/// halt and that the summed echoes match. Returns
570/// `(return_value, gas_used)`.
571pub fn invoke_pt_cache(nub: &Nub, top: &PtCacheTop, n: u64) -> (u64, u64) {
572    let result = nub
573        .invoke_cached(top.top_instance, 0, [n, 0, 0, 0], INITIAL_GAS)
574        .expect("invoke_cached");
575    assert!(
576        result.exit_reason == EXIT_HOSTCALL && result.exit_arg == 0,
577        "pt-cache exited non-cleanly: reason={} arg={} ret={} gas={}",
578        result.exit_reason,
579        result.exit_arg,
580        result.return_value,
581        result.gas_remaining,
582    );
583    assert_eq!(
584        result.return_value,
585        pt_cache_expected_return(n),
586        "pt-cache n={n} summed echoes {} (expected {})",
587        result.return_value,
588        pt_cache_expected_return(n),
589    );
590    (
591        result.return_value,
592        INITIAL_GAS.saturating_sub(result.gas_remaining),
593    )
594}
595
596/// Run the page-table-cache criterion bench: endpoint 0 `host_call`s
597/// the resident `B` a swept number of times. Publishes the top once
598/// (the kernel stays warm), runs an n=1/2 sanity check, then sweeps
599/// `{10, 100, 1000}` CALLs with `Throughput::Elements(n)` so the
600/// reported figure is per-CALL.
601pub fn run_pt_cache_bench(c: &mut Criterion, blob: &[u8], label: &str) {
602    let top = build_pt_cache_top(&mut nub_hyperlight_lock(), blob);
603    eprintln!(
604        "[{label}] image={} top={}",
605        hex_short(&top.image_hash),
606        hex_short(&top.top_instance),
607    );
608
609    {
610        let nub = nub_hyperlight_lock();
611        invoke_pt_cache(&nub, &top, 1);
612        invoke_pt_cache(&nub, &top, 2);
613    }
614    eprintln!("[{label}] n 1/2 ok");
615
616    let mut g = c.benchmark_group(label);
617    g.sample_size(20);
618    for &n in &[10u64, 100, 1_000] {
619        g.throughput(Throughput::Elements(n));
620        g.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| {
621            b.iter(|| invoke_pt_cache(&nub_hyperlight_lock(), &top, n))
622        });
623    }
624    g.finish();
625}