Skip to content

Commit 66beb5e

Browse files
cemililikclaude
andcommitted
feat(perf): T-029 Phase 2 — EL0 syscall round-trip micro-bench
Completes T-029: the EL0↔EL1↔EL0 syscall round-trip, timed kernel-side, with no CNTVCT_EL0 exposure to EL0 (AC#4). Combined with the Phase-1 ctx+IPC benches so one `perf-bench` measurement build reports all three. Design (maintainer-approved: hand-assembled image + combined build): - A hand-assembled 12-byte raw-flat EL0 image (`mov x8,#0; svc #0; b .-4`, BENCH_EL0_IMAGE) loops a REJECTED syscall (number 0 → BadSyscallNumber → Resume, before any cap is touched — no yield, no side effect, no log). So each svc is the pure fixed round-trip overhead (trap + 272-byte frame save + decode + return), and the task monopolises the CPU (never switches) → back-to-back syscall_entry entries. - perf_bench::run loads it via the existing load_image + task_create_from_image + add_user_task (added FIRST, dispatched first), alongside the Phase-1 driver/partner/idle. - A feature-gated hook in syscall_entry — el0_roundtrip_tick(effect) — times consecutive entries (safe Timer::now_ns), skips a warm-up, accumulates N deltas, prints the mean, then returns SyscallEffect::Terminate(0). The EXISTING (T-028) Terminate arm ends the EL0 bench via task_exit_current and dispatches the driver → the ctx/IPC benches run next. - Un-gated USER_TASK_STACK / USER_TASK_TABLE: now the EL0 bench task's SP_EL1 + an empty cap table (the rejected svc consults no capability). No new `unsafe` operation: timing reuses safe now_ns (UNSAFE-2026-0015); the EL0 load reuses load_image (UNSAFE-2026-0025/0026/0027) + add_user_task/enter_el0 (UNSAFE-2026-0032); the Terminate is applied by the audited arm (UNSAFE-2026-0008). UNSAFE-2026-0014 gains a Phase-2 Amendment. Numbers (QEMU-virt/TCG, relative-only): EL0 syscall round-trip 9 108 ns/syscall (N=50 000) — ≈3.6x a context switch in the same run. Gates: fmt clean; kernel-clippy ±feature clean (-D warnings; run() gets the same too_many_lines allow kernel_main_high uses); host tests pass; build ±feature; feature-off byte-identity (.text/.data/.bss + footprint identical to base, only 32 .rodata panic-loc bytes shift); feature-off smoke = unchanged demo trace; one feature-on run captured all three numbers + `perf-bench complete`; Miri --workspace --exclude bsp 0 UB. Docs: baseline §Micro-measurements Phase 2 section filled in (section complete); T-029 AC#4/#6 flipped, all ACs met; UNSAFE-2026-0014 Phase-2 Amendment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9c61a8e commit 66beb5e

6 files changed

Lines changed: 220 additions & 33 deletions

File tree

bsp-qemu-virt/src/main.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ static TASK_IDLE_STACK: TaskStack = TaskStack::new();
263263
/// handler on this stack (the trap frame + the gate-#1 dispatch call tree); a
264264
/// uniform 4 KiB `TaskStack` is ample (≫ the ~272-byte frame + walk depth) and
265265
/// satisfies `add_user_task`'s ≥1-page / 16-byte-aligned `SP_EL1` contract.
266-
#[cfg(not(feature = "perf-bench"))]
266+
/// Reused as the EL0 bench task's `SP_EL1` under `perf-bench` (T-029 Phase 2).
267267
static USER_TASK_STACK: TaskStack = TaskStack::new();
268268

269269
// ─── Global kernel state ──────────────────────────────────────────────────────
@@ -469,7 +469,8 @@ static FAILCLOSED_TABLE: StaticCell<CapabilityTable> = StaticCell::new();
469469
/// `console_write` against **its own** caps (gate #3 / T-026). BSP-owned; the
470470
/// scheduler only records the `*mut` per [ADR-0021]. Distinct from the
471471
/// kernel-side `BOOTSTRAP_AS_TABLE` (which holds the AS + Task management caps).
472-
#[cfg(not(feature = "perf-bench"))]
472+
/// Under `perf-bench` (T-029 Phase 2) it is the EL0 bench task's table, seeded
473+
/// **empty** — the bench's rejected no-op `svc` touches no capability.
473474
static USER_TASK_TABLE: StaticCell<CapabilityTable> = StaticCell::new();
474475

475476
/// Task kernel-object arena — global per [ADR-0016]. Although the v1 demo

bsp-qemu-virt/src/perf_bench.rs

Lines changed: 191 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,19 @@
4646
//! [ADR-0021]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0021-raw-pointer-scheduler-ipc-bridge.md
4747
4848
use core::fmt::Write;
49-
use core::sync::atomic::{AtomicU8, Ordering};
49+
use core::sync::atomic::{AtomicU64, AtomicU8, Ordering};
5050

51-
use tyrne_hal::{Cpu, FmtWriter, Timer};
51+
use tyrne_hal::{Cpu, FmtWriter, Timer, VirtAddr};
5252
use tyrne_kernel::cap::{CapHandle, CapObject, CapRights, Capability, CapabilityTable};
5353
use tyrne_kernel::ipc::{IpcQueues, Message};
5454
use tyrne_kernel::mm::BOOTSTRAP_ADDRESS_SPACE_HANDLE;
5555
use tyrne_kernel::obj::endpoint::{create_endpoint, Endpoint, EndpointArena};
5656
use tyrne_kernel::obj::task::{create_task, Task, TaskArena};
57+
use tyrne_kernel::obj::task_loader::{load_image, task_create_from_image};
5758
use tyrne_kernel::sched::{
5859
ipc_recv_and_yield, ipc_send_and_yield, register_idle, start, yield_now, Scheduler,
5960
};
61+
use tyrne_kernel::syscall::SyscallEffect;
6062

6163
use crate::console::Pl011Uart;
6264
use crate::cpu::QemuVirtCpu;
@@ -86,9 +88,54 @@ static PHASE: AtomicU8 = AtomicU8::new(PHASE_CTX);
8688
/// `insert_root` lands at index 0 / generation 0 — asserted in [`run`].
8789
const BENCH_EP_CAP: CapHandle = CapHandle::from_raw(0, 0);
8890

89-
/// Set up the two bench tasks and start the scheduler. Never returns: after the
90-
/// driver prints its results it parks, so the measurement build halts (the QEMU
91-
/// harness captures the `tyrne: perf …` lines and stops the guest).
91+
// ── EL0 syscall round-trip bench (Phase 2) ───────────────────────────────────
92+
93+
/// Timed EL0 syscall round-trips; after this many the kernel terminates the EL0
94+
/// bench task (see [`el0_roundtrip_tick`]) and hands off to the ctx/IPC benches.
95+
const N_EL0_ROUNDTRIPS: u64 = 50_000;
96+
/// Untimed EL0 round-trips before the timed window (steady state).
97+
const EL0_WARMUP: u64 = 256;
98+
99+
/// A minimal raw-flat EL0 program (entry at offset 0, [ADR-0029] format) that
100+
/// loops a **rejected** syscall, so the kernel can time the EL0↔EL1↔EL0
101+
/// round-trip from `syscall_entry` (T-029 Phase 2 / AC#4) **without** exposing
102+
/// `CNTVCT_EL0` to EL0. Hand-assembled aarch64:
103+
///
104+
/// ```text
105+
/// _start: mov x8, #0 ; D2800008 — syscall number 0 = reserved-invalid
106+
/// loop: svc #0 ; D4000001 — trap to EL1 → BadSyscallNumber → Resume
107+
/// b loop ; 17FFFFFF — branch back to the svc (offset −4)
108+
/// ```
109+
///
110+
/// Number 0 decodes to `BadSyscallNumber` **before** any capability is touched
111+
/// (no cap lookup, no yield, no log), so each `svc` is the *pure* fixed
112+
/// round-trip overhead (trap + 272-byte frame save + decode + return) with no
113+
/// side effect — and the task never switches away, so `syscall_entry` sees
114+
/// back-to-back entries until the kernel forces termination after N.
115+
#[rustfmt::skip]
116+
static BENCH_EL0_IMAGE: [u8; 12] = [
117+
0x08, 0x00, 0x80, 0xd2, // mov x8, #0
118+
0x01, 0x00, 0x00, 0xd4, // svc #0
119+
0xff, 0xff, 0xff, 0x17, // b .-4 (back to the svc)
120+
];
121+
122+
/// Timestamp of the previous `syscall_entry` (0 = "no previous yet"). The
123+
/// entry-to-entry delta is one full EL0 round-trip.
124+
static EL0_PREV_NS: AtomicU64 = AtomicU64::new(0);
125+
/// Count of `syscall_entry` entries seen so far (the EL0 bench task is the only
126+
/// `SVC` source, so every entry is one of its round-trips).
127+
static EL0_ENTRY_COUNT: AtomicU64 = AtomicU64::new(0);
128+
/// Accumulated ns across the N timed deltas (mean = this / `N_EL0_ROUNDTRIPS`).
129+
static EL0_ACCUM_NS: AtomicU64 = AtomicU64::new(0);
130+
131+
/// Set up the EL0 bench task + the two kernel bench tasks and start the
132+
/// scheduler. Never returns: after the driver prints its results it parks, so
133+
/// the measurement build halts (the QEMU harness captures the `tyrne: perf …`
134+
/// lines and stops the guest).
135+
#[allow(
136+
clippy::too_many_lines,
137+
reason = "linear measurement-build setup top-to-bottom for auditability — mirrors `kernel_main_high`; splitting into helpers would scatter the load→create→publish→schedule order each step depends on"
138+
)]
92139
pub fn run(cpu: &QemuVirtCpu, console: &Pl011Uart) -> ! {
93140
{
94141
let mut w = FmtWriter(console);
@@ -98,24 +145,86 @@ pub fn run(cpu: &QemuVirtCpu, console: &Pl011Uart) -> ! {
98145
);
99146
}
100147

101-
// Task arena + the three task handles (driver, partner, idle).
148+
// Task arena.
102149
// SAFETY: `.bss`-resident StaticCell; single-core and the scheduler is not
103150
// started, so this write-once publish is unaliased. Audit: UNSAFE-2026-0001.
104151
unsafe {
105152
(*crate::TASK_ARENA.0.get()).write(TaskArena::default());
106153
}
107-
// SAFETY: `TASK_ARENA` was just written; the momentary `&mut` is scoped to
108-
// these `create_task` calls and drops before any task runs (no cross-switch
109-
// borrow per [ADR-0021]). Audit: UNSAFE-2026-0014.
110-
let (driver_h, partner_h, idle_h) = unsafe {
154+
155+
// Load the EL0 bench image into a fresh address space, reusing the
156+
// bootstrap-AS loader path — PMM / MMU / AS arena / the bootstrap AS
157+
// authority cap are all initialised before `kernel_main_high`'s workload
158+
// fork. The image just loops a rejected `svc` (see `BENCH_EL0_IMAGE`); the
159+
// kernel times consecutive `syscall_entry` entries and terminates it.
160+
// SAFETY: those five statics are written before the fork (single-core, no
161+
// task running); the momentary `&mut`/`&`s drop at this block's end and
162+
// never cross a switch ([ADR-0021]). Audit: UNSAFE-2026-0010 +
163+
// UNSAFE-2026-0014 (+ the loader's own UNSAFE-2026-0025/0026/0027 for the
164+
// page-table writes / frame zero-fill / image byte-copy).
165+
let loaded = unsafe {
166+
let pmm = (*crate::PMM.0.get()).assume_init_mut();
167+
let mmu = (*crate::MMU.0.get()).assume_init_ref();
168+
let table = (*crate::BOOTSTRAP_AS_TABLE.0.get()).assume_init_mut();
169+
let arena = (*crate::AS_ARENA.0.get()).assume_init_mut();
170+
let parent_cap = *(*crate::BOOTSTRAP_AS_CAP.0.get()).assume_init_ref();
171+
load_image(
172+
&BENCH_EL0_IMAGE,
173+
pmm,
174+
mmu,
175+
table,
176+
arena,
177+
parent_cap,
178+
CapRights::empty(),
179+
VirtAddr(crate::USERSPACE_IMAGE_BASE_VA),
180+
crate::USERSPACE_STACK_PAGES,
181+
)
182+
.expect("perf-bench: load EL0 bench image failed")
183+
};
184+
185+
// The three kernel task handles (driver, partner, idle) + the EL0 bench task
186+
// (turned into a runnable Task via the T-024 bridge, then resolved to a
187+
// `TaskHandle`).
188+
// SAFETY: `TASK_ARENA` + `BOOTSTRAP_AS_TABLE` are written above; the momentary
189+
// `&mut`s are scoped to this block and drop before any task runs (no
190+
// cross-switch borrow per [ADR-0021]). Audit: UNSAFE-2026-0010 + UNSAFE-2026-0014.
191+
let (driver_h, partner_h, idle_h, el0_task_h, el0_as_h) = unsafe {
111192
let arena = &mut *crate::TASK_ARENA.as_mut_ptr();
112193
let d = create_task(arena, Task::new(0, BOOTSTRAP_ADDRESS_SPACE_HANDLE))
113194
.expect("perf-bench: create driver task failed");
114195
let p = create_task(arena, Task::new(1, BOOTSTRAP_ADDRESS_SPACE_HANDLE))
115196
.expect("perf-bench: create partner task failed");
116197
let i = create_task(arena, Task::new(2, BOOTSTRAP_ADDRESS_SPACE_HANDLE))
117198
.expect("perf-bench: create idle task failed");
118-
(d, p, i)
199+
200+
let bootstrap_table = (*crate::BOOTSTRAP_AS_TABLE.0.get()).assume_init_mut();
201+
let el0_as_h = {
202+
let cap = bootstrap_table
203+
.lookup(loaded.as_cap)
204+
.expect("perf-bench: loaded AS cap is stale");
205+
match cap.object() {
206+
CapObject::AddressSpace(h) => h,
207+
_ => panic!("perf-bench: loaded.as_cap is not an AddressSpace cap"),
208+
}
209+
};
210+
let task_cap = task_create_from_image(
211+
&loaded,
212+
bootstrap_table,
213+
arena,
214+
3,
215+
CapRights::DUPLICATE | CapRights::DERIVE | CapRights::REVOKE,
216+
)
217+
.expect("perf-bench: task_create_from_image failed");
218+
let el0_task_h = {
219+
let cap = bootstrap_table
220+
.lookup(task_cap)
221+
.expect("perf-bench: minted Task cap is stale");
222+
match cap.object() {
223+
CapObject::Task(h) => h,
224+
_ => panic!("perf-bench: task_create_from_image returned a non-Task cap"),
225+
}
226+
};
227+
(d, p, i, el0_task_h, el0_as_h)
119228
};
120229

121230
// One endpoint: the driver holds SEND, the partner holds RECV. Least
@@ -139,25 +248,43 @@ pub fn run(cpu: &QemuVirtCpu, console: &Pl011Uart) -> ! {
139248
"perf-bench: endpoint caps must resolve to (index 0, generation 0)"
140249
);
141250

142-
// Publish the IPC state + the two tables before the scheduler starts.
251+
// Publish the IPC state + the two tables + the EL0 task's own (empty) table.
143252
// SAFETY: single-core; no task is running yet. Audit: UNSAFE-2026-0001.
144253
unsafe {
145254
(*crate::EP_ARENA.0.get()).write(ep_arena);
146255
(*crate::IPC_QUEUES.0.get()).write(IpcQueues::new());
147256
(*crate::TABLE_A.0.get()).write(table_d);
148257
(*crate::TABLE_B.0.get()).write(table_p);
258+
// Gate #3 sources this for the EL0 bench task; left empty — the rejected
259+
// no-op `svc` (number 0 → BadSyscallNumber) looks up no capability.
260+
(*crate::USER_TASK_TABLE.0.get()).write(CapabilityTable::new());
149261
}
150262

151-
// Scheduler: the driver is added first so it runs first and drives the
152-
// sequence; the partner cooperates; idle sits in the dispatcher fallback
153-
// slot ([ADR-0026]) and is never reached while either task is Ready.
263+
// Scheduler: the EL0 bench task is added FIRST so `start` dispatches it
264+
// first; it monopolises the CPU (its rejected `svc` is `Resume`, never
265+
// yields) until the kernel terminates it after N round-trips, which then
266+
// dispatches the driver — so the ctx/IPC benches run next. Idle sits in the
267+
// dispatcher fallback slot ([ADR-0026]).
154268
let mut sched = Scheduler::<QemuVirtCpu>::new();
155-
// SAFETY: `add_task` / `register_idle` call `init_context`; each stack top
156-
// is 16-byte aligned (TaskStack repr) and outlives the run; entries are
157-
// `fn() -> !`. The momentary `&mut Scheduler` does not cross a switch
158-
// ([ADR-0021]). Audit: UNSAFE-2026-0009 + UNSAFE-2026-0011 (`top()`) +
159-
// UNSAFE-2026-0014.
269+
// SAFETY: `add_user_task` / `add_task` / `register_idle` call
270+
// `init_user_context` / `init_context`; each stack top is 16-byte aligned
271+
// (TaskStack repr) and outlives the run; kernel entries are `fn() -> !`; the
272+
// EL0 task's `user_sp` (loader page-aligned) + `kernel_stack_top` are
273+
// 16-byte-aligned + non-null (debug-asserted by `add_user_task`). No `&mut
274+
// Scheduler` crosses a switch ([ADR-0021]). Audit: UNSAFE-2026-0009 +
275+
// UNSAFE-2026-0011 (`top()`) + UNSAFE-2026-0014 + UNSAFE-2026-0032 (`enter_el0`).
160276
unsafe {
277+
sched
278+
.add_user_task(
279+
cpu,
280+
el0_task_h,
281+
el0_as_h,
282+
loaded.entry_va.0,
283+
loaded.stack_top_va.0,
284+
crate::USER_TASK_STACK.top(),
285+
crate::USER_TASK_TABLE.as_mut_ptr(),
286+
)
287+
.expect("perf-bench: add EL0 bench task failed");
161288
sched
162289
.add_task(
163290
cpu,
@@ -194,7 +321,7 @@ pub fn run(cpu: &QemuVirtCpu, console: &Pl011Uart) -> ! {
194321
let mut w = FmtWriter(console);
195322
let _ = writeln!(
196323
w,
197-
"tyrne: perf-bench starting scheduler (ctx-switch + IPC, N={N_CTX_ROUNDTRIPS}/{N_IPC_CYCLES}, warmup={WARMUP})"
324+
"tyrne: perf-bench starting scheduler (el0-roundtrip + ctx-switch + IPC, N={N_EL0_ROUNDTRIPS}/{N_CTX_ROUNDTRIPS}/{N_IPC_CYCLES}, warmup={EL0_WARMUP}/{WARMUP})"
198325
);
199326
}
200327

@@ -358,3 +485,46 @@ fn partner_recv(cpu: &QemuVirtCpu) {
358485
.expect("perf-bench: ipc_recv_and_yield failed");
359486
}
360487
}
488+
489+
/// Per-entry hook called by `syscall_entry` (feature-gated) for the EL0 bench
490+
/// task — the only `SVC` source in this build, so every entry is one of its
491+
/// round-trips. Times the entry-to-entry delta (one full EL0↔EL1↔EL0 round-trip,
492+
/// hook-to-hook), skips the first delta + `EL0_WARMUP`, accumulates the next
493+
/// `N_EL0_ROUNDTRIPS`, then prints the mean and returns
494+
/// [`SyscallEffect::Terminate`] — which ends the EL0 bench (`task_exit_current`)
495+
/// and hands off to the driver/partner ctx+IPC benches. Otherwise returns
496+
/// `effect` unchanged (the rejected `svc`'s `Resume`), so the EL0 task loops.
497+
///
498+
/// `Relaxed` atomics: single-core and `syscall_entry` runs with IRQs masked, so
499+
/// entries are strictly serial — there is no concurrent access to order.
500+
pub fn el0_roundtrip_tick(effect: SyscallEffect) -> SyscallEffect {
501+
// SAFETY: `CPU` / `CONSOLE` are initialised before `start()`; single-core
502+
// cooperative, IRQs masked in the SVC handler. Audit: UNSAFE-2026-0010.
503+
let cpu = unsafe { (*crate::CPU.0.get()).assume_init_ref() };
504+
let now = cpu.now_ns();
505+
let prev = EL0_PREV_NS.swap(now, Ordering::Relaxed);
506+
let n = EL0_ENTRY_COUNT.fetch_add(1, Ordering::Relaxed);
507+
508+
// n == 0: first entry — no previous timestamp, so no delta yet.
509+
// 1..=EL0_WARMUP: warm-up deltas (skipped).
510+
// EL0_WARMUP+1 ..= EL0_WARMUP+N: the N timed deltas.
511+
if n == 0 || n <= EL0_WARMUP {
512+
return effect;
513+
}
514+
EL0_ACCUM_NS.fetch_add(now.saturating_sub(prev), Ordering::Relaxed);
515+
if n == EL0_WARMUP + N_EL0_ROUNDTRIPS {
516+
let total = EL0_ACCUM_NS.load(Ordering::Relaxed);
517+
// SAFETY: as above — `CONSOLE` initialised before `start()`. UNSAFE-2026-0010.
518+
let console = unsafe { (*crate::CONSOLE.0.get()).assume_init_ref() };
519+
let mut w = FmtWriter(console);
520+
let _ = writeln!(
521+
w,
522+
"tyrne: perf el0 syscall round-trip = {} ns/syscall (kernel-side, hook-to-hook; rejected no-op svc; N={N_EL0_ROUNDTRIPS}, {total} ns total)",
523+
total / N_EL0_ROUNDTRIPS
524+
);
525+
// End the EL0 bench task; `syscall_entry`'s Terminate arm switches to the
526+
// next ready task (the driver) → the ctx/IPC benches run next.
527+
return SyscallEffect::Terminate(0);
528+
}
529+
effect
530+
}

bsp-qemu-virt/src/syscall.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ pub unsafe extern "C" fn syscall_entry(frame: *mut SyscallTrapFrame) {
205205
dispatch(&mut ctx, args)
206206
};
207207

208+
// T-029 Phase 2 (perf-bench measurement build only): time consecutive EL0
209+
// syscall round-trips kernel-side and, after N, force `Terminate` to end the
210+
// bench EL0 task (handing off to the ctx/IPC benches). Feature-gated, so
211+
// production `syscall_entry` is byte-identical. See `perf_bench`.
212+
#[cfg(feature = "perf-bench")]
213+
let effect = crate::perf_bench::el0_roundtrip_tick(effect);
214+
208215
match effect {
209216
SyscallEffect::Resume(r) => {
210217
// SAFETY: write the status (x0) + payload (x1..x7) back into the

docs/analysis/reviews/performance-optimization-reviews/2026-06-01-B6-closure.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,15 @@ cargo build --target aarch64-unknown-none -p tyrne-bsp-qemu-virt --features perf
7171

7272
**These are QEMU-virt TCG emulated-time figures, not real-silicon latencies.** QEMU's virtual `CNTVCT_EL0` advances on emulated time and TCG executes far slower than hardware, so treat them as **order-of-magnitude + relative** — the same harness-floor caveat the [B5 leg](2026-05-29-B5-closure.md) and §Metric 3 record. They are a baseline for *relative* regression tracking (e.g. "did a scheduler change move ns/switch?"), not absolute performance claims; real-hardware numbers await the Raspberry Pi 4 bring-up. Each figure is the mean over N = 50 000 iterations (after a 256-iteration warm-up), so *within-run* variance is negligible; the 16 ns counter granularity is ≪ the per-op cost. **Cross-run absolutes drift ≈20–25 % with host load under TCG** — a second run measured 2 816 ns/switch and 17 684 ns/cycle — so the figures above are one representative sample, not a stable absolute. The **stable, meaningful** signal is the *ratio* (an IPC round-trip costs ≈4.3–4.5× a context switch across both runs) and same-run deltas, which is what relative regression tracking uses.
7373

74-
### Phase 2 — EL0 syscall round-trip (pending)
74+
### Phase 2 — EL0 syscall round-trip (2026-06-01)
7575

76-
The EL0↔EL1↔EL0 syscall round-trip — timed kernel-side around `syscall_entry` for a looping EL0 bench task, still **without** exposing `CNTVCT_EL0` to EL0 — lands in **T-029 Phase 2** (a separate PR), which will complete this section.
76+
A minimal EL0 bench program (a hand-assembled 12-byte raw-flat image: `mov x8,#0; svc #0; b .-4`) loops a **rejected** syscall (number 0 → `BadSyscallNumber``Resume`, before any capability is touched — no yield, no side effect). The kernel times **consecutive `syscall_entry` entries** (the EL0 bench task is the only `SVC` source), reading `CNTVCT_EL0` **kernel-side only**`CNTVCT_EL0` is **never** exposed to EL0 (no `CNTKCTL_EL1.EL0VCTEN`; AC#4). After N the hook returns `SyscallEffect::Terminate`, ending the EL0 task and handing off to the ctx/IPC benches — so one measurement build reports all three.
77+
78+
| primitive | QEMU-virt / TCG | composition |
79+
|---|---|---|
80+
| **EL0 syscall round-trip** | **9 108 ns/syscall** | one EL0↔EL1↔EL0 round-trip, hook-to-hook: `SVC` trap → 272-byte frame save → `dispatch` (decode → `BadSyscallNumber`) → `ERET` → 1 EL0 instr (`b`) → next `SVC`; N = 50 000 |
81+
82+
This is the **pure fixed overhead** every syscall pays (trap + frame + decode + return), independent of the cap/IPC/console work a real syscall adds on top. In the same run it was ≈**3.6× a context switch** (2 546 ns/switch that run) — the round-trip's 272-byte frame save/restore + `ERET` is heavier than a kernel-internal switch. Same TCG / relative-only caveat as above (cross-run absolutes drift with host load; the *ratio* is the stable signal).
7783

7884
## Verdict
7985

0 commit comments

Comments
 (0)