Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/secure-exec-client/src/transport.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! `SidecarTransport`: spawns a native sidecar binary and speaks the existing framed
//! BARE protocol over its stdio.
//!
//! This mirrors the TypeScript `NativeSidecarProcessClient`. Generated wire payloads are the native
//! This mirrors the TypeScript `Sidecar`. Generated wire payloads are the native
//! transport path.
//!
//! Request-id direction is load-bearing: host-initiated `Request`/`Response` frames use positive ids
Expand Down
4 changes: 2 additions & 2 deletions examples/native-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { NativeSidecarProcessClient } from "@secure-exec/core/sidecar-client";
import { Sidecar } from "@secure-exec/core/sidecar-client";

const decoder = new TextDecoder();

const client = NativeSidecarProcessClient.spawn({
const client = Sidecar.spawn({
cwd: process.cwd(),
});

Expand Down
80 changes: 80 additions & 0 deletions packages/benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Secure Exec Benchmarks

These benchmarks measure the public `secure-exec` SDK paths used by consumers.

## Cold Start Matrix

`coldstart.bench.ts` writes machine-readable JSON to stdout and human-readable progress to stderr. It measures:

- `owned-sidecar`: `NodeRuntime.create()` owns a fresh sidecar for each runtime.
- `shared-sidecar`: a `Sidecar` is created once per batch and passed to `NodeRuntime.create({ sidecar })`; sidecar setup is measured separately and excluded from cold start.
- `resident-runner`: a shared sidecar plus `runtime.createResidentRunner()`, so repeated tiny snippets reuse one live guest Node process.

The result JSON includes hardware metadata, aggregate cold/warm latency, and phase timings such as `session_open`, `vm_create`, `runtime_mount_wasm`, `first_exec`, and resident-runner phases.

## Run

Build a release sidecar first for meaningful timings:

```bash
cargo build --release -p secure-exec-sidecar
```

Run the full benchmark suite:

```bash
pnpm --dir packages/benchmarks bench
```

This writes timestamped files under `packages/benchmarks/results/`:

```text
coldstart-YYYYMMDD-HHMMSS.json
coldstart-YYYYMMDD-HHMMSS.log
memory-YYYYMMDD-HHMMSS.json
memory-YYYYMMDD-HHMMSS.log
```

Run only the cold-start matrix:

```bash
SECURE_EXEC_SIDECAR_BIN="$PWD/target/release/secure-exec-sidecar" \
pnpm --silent --dir packages/benchmarks bench:coldstart \
> packages/benchmarks/results/coldstart-local.json \
2> packages/benchmarks/results/coldstart-local.log
```

Quick smoke run:

```bash
BENCH_BATCH_SIZES=1 \
BENCH_ITERATIONS=1 \
BENCH_WARMUP=0 \
BENCH_SCENARIOS=owned-sidecar,shared-sidecar,resident-runner \
SECURE_EXEC_SIDECAR_BIN="$PWD/target/release/secure-exec-sidecar" \
pnpm --silent --dir packages/benchmarks bench:coldstart
```

Useful knobs:

```text
BENCH_BATCH_SIZES=1,10,50,100,200
BENCH_ITERATIONS=5
BENCH_WARMUP=1
BENCH_SCENARIOS=owned-sidecar,shared-sidecar,resident-runner
BENCH_MAX_LIVE_RUNTIMES=8
BENCH_MAX_RESIDENT_RUNNERS=1
BENCH_EXEC_TIMEOUT_MS=30000
SECURE_EXEC_SIDECAR_BIN=/abs/path/to/secure-exec-sidecar
```

## Checked-In Results

Current captured results:

- `results/coldstart-final.json`
- `results/coldstart-final.log`
- `results/coldstart-resident-full-matrix-20260619.json`
- `results/coldstart-resident-full-matrix-20260619.log`

`coldstart-final.*` is the latest full run from June 19, 2026. It includes the three SDK scenarios above. It also contains an `isolate-only` reference row from a lower-level one-off V8 snapshot/restore benchmark; that row is captured for comparison but is not part of the normal SDK benchmark command.
141 changes: 108 additions & 33 deletions packages/benchmarks/bench-utils.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
/**
* Shared utilities for the Secure Exec cold-start, warm-start, and memory
* benchmarks.
*
* The benchmarks drive the public SDK exactly as a consumer would:
*
* import { NodeRuntime } from "secure-exec";
* const runtime = await NodeRuntime.create();
* await runtime.exec("export const x = 1;");
* await runtime.dispose();
*
* `NodeRuntime.create()` boots an out-of-process sidecar VM: it spawns the
* sidecar binary, opens a session, creates a VM with a bootstrapped root
* filesystem, and mounts the shell + Node runtimes. There is no in-process
* isolate path. The benchmarks therefore measure full VM boot cost, not
* isolate creation.
*/

import { readFileSync } from "node:fs";
import os from "node:os";
import { NodeRuntime } from "secure-exec";
import {
NodeRuntime,
Sidecar,
type NodeRuntimeBootTiming,
type NodeRuntimeCreateOptions,
} from "secure-exec";

/**
* The full matrix matches the original harness: batch sizes 1/10/50/100/200,
* 5 recorded iterations, 1 warmup discarded. All four are overridable via env so
* a quick smoke run is possible without editing the file, e.g.:
*
* BENCH_BATCH_SIZES=1,5 BENCH_ITERATIONS=2 BENCH_WARMUP=0 tsx coldstart.bench.ts
*/
function numList(envVar: string, fallback: number[]): number[] {
const raw = process.env[envVar];
if (!raw) return fallback;
Expand All @@ -34,7 +20,8 @@ function numList(envVar: string, fallback: number[]): number[] {
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n > 0);
}
function num(envVar: string, fallback: number): number {

export function num(envVar: string, fallback: number): number {
const raw = process.env[envVar];
if (raw === undefined) return fallback;
const n = Number(raw);
Expand All @@ -46,21 +33,47 @@ export const ITERATIONS = num("BENCH_ITERATIONS", 5);
export const WARMUP_ITERATIONS = num("BENCH_WARMUP", 1);
export const MEMORY_ITERATIONS = num("BENCH_MEMORY_ITERATIONS", 5);

/**
* A trivial guest program: just enough to confirm the runtime is live and the
* first `exec()` round-trips. Keeps the measurement focused on runtime boot,
* not workload.
*/
export const TRIVIAL_CODE = "export const x = 1;";
export const RESIDENT_TRIVIAL_CODE =
"globalThis.__benchValue = (globalThis.__benchValue ?? 0) + 1;";

/**
* Cap concurrency below available parallelism to leave headroom for the bench
* harness, Node's event loop, and each sidecar's own threads.
*/
export const MAX_CONCURRENCY = Math.max(1, os.availableParallelism() - 4);
export const MAX_LIVE_RUNTIMES = Math.max(
1,
num("BENCH_MAX_LIVE_RUNTIMES", Math.min(8, MAX_CONCURRENCY)),
);
export const MAX_RESIDENT_RUNNERS = Math.max(
1,
num("BENCH_MAX_RESIDENT_RUNNERS", 1),
);
export const EXEC_TIMEOUT_MS = Math.max(
1,
num("BENCH_EXEC_TIMEOUT_MS", 30_000),
);

export async function createBenchRuntime(): Promise<NodeRuntime> {
return NodeRuntime.create();
export type BenchScenario =
| "owned-sidecar"
| "shared-sidecar"
| "resident-runner";

export const SCENARIOS: BenchScenario[] = (
process.env.BENCH_SCENARIOS?.split(",").map((s) => s.trim()) ?? [
"owned-sidecar",
"shared-sidecar",
"resident-runner",
]
).filter((s): s is BenchScenario =>
["owned-sidecar", "shared-sidecar", "resident-runner"].includes(s),
);

export async function createBenchRuntime(
options: Pick<NodeRuntimeCreateOptions, "sidecar" | "onBootTiming"> = {},
): Promise<NodeRuntime> {
return NodeRuntime.create(options);
}

export function createBenchSidecar(): Sidecar {
return Sidecar.spawn();
}

export function percentile(sorted: number[], p: number): number {
Expand Down Expand Up @@ -94,15 +107,36 @@ export function formatBytes(bytes: number): string {
return `${round(mb, 2)} MB`;
}

function readMemInfo(): Record<string, string> {
try {
const entries = readFileSync("/proc/meminfo", "utf8")
.trim()
.split("\n")
.map((line) => {
const [key, value] = line.split(":");
return [key, value.trim()] as const;
});
return Object.fromEntries(entries);
} catch {
return {};
}
}

export function getHardware() {
const cpus = os.cpus();
const memInfo = readMemInfo();
return {
cpu: cpus[0]?.model ?? "unknown",
cores: os.availableParallelism(),
ram: `${round(os.totalmem() / 1024 ** 3, 1)} GB`,
memAvailable: memInfo.MemAvailable,
swapTotal: memInfo.SwapTotal,
swapFree: memInfo.SwapFree,
swapCached: memInfo.SwapCached,
node: process.version,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
loadAverage: os.loadavg().map((n) => round(n, 2)),
};
}

Expand All @@ -118,6 +152,47 @@ export async function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

export type PhaseSamples = Record<string, number[]>;

export function createBootTimingRecorder(phases: PhaseSamples) {
return (timing: NodeRuntimeBootTiming) => {
(phases[timing.phase] ??= []).push(timing.durationMs);
};
}

export function mergePhaseSamples(target: PhaseSamples, source: PhaseSamples) {
for (const [phase, samples] of Object.entries(source)) {
(target[phase] ??= []).push(...samples);
}
}

export function summarizePhases(phases: PhaseSamples) {
return Object.fromEntries(
Object.entries(phases).map(([phase, samples]) => [phase, stats(samples)]),
);
}

export async function runLimited<T>(
count: number,
concurrency: number,
fn: (index: number) => Promise<T>,
): Promise<T[]> {
const results: T[] = new Array(count);
let next = 0;
const workers = Array.from(
{ length: Math.min(count, Math.max(1, concurrency)) },
async () => {
for (;;) {
const index = next++;
if (index >= count) return;
results[index] = await fn(index);
}
},
);
await Promise.all(workers);
return results;
}

/** Print a table to stderr for human readability. */
export function printTable(
headers: string[],
Expand Down
Loading
Loading