diff --git a/PLAN.md b/PLAN.md index 0c2385b..d5de76c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,7 +1,7 @@ # AArch64 ELF-to-WebAssembly Browser Runtime ## Engineering Build Plan and Agent Handoff -**Status:** Phase 3 complete; Phase 4 P-code/IR-to-Wasm translation is next +**Status:** Phase 3 complete; Phase 4 hot-block profiling implemented **Primary implementation language:** Rust **Initial browser target:** Google Chrome **Guest architecture:** AArch64, little-endian, Linux userspace @@ -1859,6 +1859,8 @@ Phase 2 is complete. `binarrow-memory-fs` supplies a bounded ephemeral `/tmp` re Phase 3 is complete. `guest-tests/cpython/build.sh` checksum-pins CPython 3.12.13, isolates all build and Zig caches under the repository's ignored `.tmp` directory, cross-compiles a static AArch64 musl interpreter, links the configured dependency-free extension modules into the executable, and packages the standard library with its empty pre-dynamic-linking `lib-dynload` landmark. `guest-tests/cpython/verify.sh` checks the version and runs the bounded `-c 'print(6 * 7)'` regression with a deterministic CLI random seed. The freshly built broader module profile added focused semantics for NEON table lookup, unsigned variable shifts, extraction, and 64-bit-group reversal. The browser UI and Worker accept externally built ELF bytes, `argv[0]`, and additional arguments. `guest-tests/cpython/verify-browser.sh` installs the packaged standard-library image, uploads that interpreter, and verifies `print(6 * 7)` with exit status zero in Chromium. This completes every Phase 3 acceptance criterion; Phase 4 begins with hot-block profiling and basic-block Wasm lowering, while shared native extensions remain deferred to Phase 7. +The first Phase 4 checkpoint is implemented. The interpreter identifies basic-block boundaries from normalized external-branch and supervisor-call operations, counts successful block entries in a deterministic bounded profile, and exposes the ordered results through the Linux process boundary. Browser reports include the tracked-block count, hottest guest address, and its execution count. Focused regressions prove a tight self-loop reaches exactly the configured hotness while instruction exhaustion, syscall behavior, and ordinary fixture outputs remain unchanged. Basic-block Wasm lowering is next. + Do not begin the full web IDE before item 30 passes. --- diff --git a/README.md b/README.md index 4d9278f..f76e4e1 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The browser build generates its Memory64, JSPI, and P-code `.wasm` probes before ## Scope -The browser controller can start the checked-in freestanding C, musl C, Rust `std`, filesystem, and infinite-loop fixtures or a user-supplied static AArch64 ELF with explicit instruction, syscall, output, committed-memory, and filesystem limits. Execution errors return stable diagnostic codes instead of rejected JavaScript calls. The Stop action terminates the active Worker, so even a guest that never reaches a syscall or yield point can be interrupted and the runtime restarted. Chromium verifies the C/Rust outputs, uploaded-ELF transfer, in-memory file round trip, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. The interpreter's interim browser memory design is recorded in [ADR-0002](docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md). See [PLAN.md](PLAN.md) for the roadmap and [docs/architecture.md](docs/architecture.md) for the current boundaries. +The browser controller can start the checked-in freestanding C, musl C, Rust `std`, filesystem, and infinite-loop fixtures or a user-supplied static AArch64 ELF with explicit instruction, syscall, output, committed-memory, and filesystem limits. Execution errors return stable diagnostic codes instead of rejected JavaScript calls. The Stop action terminates the active Worker, so even a guest that never reaches a syscall or yield point can be interrupted and the runtime restarted. Chromium verifies the C/Rust outputs, uploaded-ELF transfer, in-memory file round trip, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. Phase 4's bounded hot-block profiler reports the number of observed basic blocks plus the hottest entry and its execution count without changing interpreter dispatch. The interpreter's interim browser memory design is recorded in [ADR-0002](docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md). See [PLAN.md](PLAN.md) for the roadmap and [docs/architecture.md](docs/architecture.md) for the current boundaries. ## License diff --git a/crates/aarch64/src/lib.rs b/crates/aarch64/src/lib.rs index 313ebd6..3a447c6 100644 --- a/crates/aarch64/src/lib.rs +++ b/crates/aarch64/src/lib.rs @@ -19,6 +19,7 @@ const VECTOR_REGISTER_COUNT: usize = 32; const INSTRUCTION_SIZE: u64 = 4; const INSTRUCTION_BYTES: usize = 4; const MAX_DECODE_CACHE_ENTRIES: usize = 65_536; +const MAX_PROFILED_BLOCKS: usize = 65_536; const RECENT_PC_COUNT: usize = 64; const NZCV_MASK: u32 = 0xf000_0000; const NEGATIVE_FLAG_BIT: u32 = 31; @@ -305,11 +306,21 @@ pub struct RunResult { pub executed_instructions: u64, } +/// Deterministic execution count for one interpreted basic-block entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BlockProfile { + pub start: GuestAddress, + pub executions: u64, +} + /// Stateful SLEIGH decoder and normalized-operation interpreter. pub struct Interpreter { language: &'static SleighData, runtime: SleighRuntime, decode_cache: BTreeMap<(u64, u32), CachedInstruction>, + block_executions: BTreeMap, + active_block_next_pc: Option, + dropped_block_entries: u64, recent_pcs: [GuestAddress; RECENT_PC_COUNT], recent_pc_cursor: usize, recent_pc_count: usize, @@ -319,6 +330,7 @@ pub struct Interpreter { struct CachedInstruction { next_pc: GuestAddress, operations: Arc<[Operation]>, + ends_basic_block: bool, } impl Interpreter { @@ -333,6 +345,9 @@ impl Interpreter { language: language()?, runtime: SleighRuntime::new(0), decode_cache: BTreeMap::new(), + block_executions: BTreeMap::new(), + active_block_next_pc: None, + dropped_block_entries: 0, recent_pcs: [GuestAddress::NULL; RECENT_PC_COUNT], recent_pc_cursor: 0, recent_pc_count: 0, @@ -352,6 +367,31 @@ impl Interpreter { .collect() } + /// Return tracked block entries ordered by descending execution count and + /// then ascending guest address. + #[must_use] + pub fn block_profiles(&self) -> Vec { + let mut profiles = self + .block_executions + .iter() + .map(|(&start, &executions)| BlockProfile { start, executions }) + .collect::>(); + profiles.sort_unstable_by(|left, right| { + right + .executions + .cmp(&left.executions) + .then_with(|| left.start.cmp(&right.start)) + }); + profiles + } + + /// Number of block-entry observations omitted after the bounded profiler + /// reached its distinct-address limit. + #[must_use] + pub const fn dropped_block_entries(&self) -> u64 { + self.dropped_block_entries + } + /// Fetch, decode, and interpret one instruction. /// /// # Errors @@ -365,6 +405,7 @@ impl Interpreter { memory: &mut AddressSpace, ) -> Result { let pc = state.pc(); + let starts_basic_block = self.active_block_next_pc != Some(pc); self.recent_pcs[self.recent_pc_cursor] = pc; self.recent_pc_cursor = (self.recent_pc_cursor + 1) % RECENT_PC_COUNT; self.recent_pc_count = (self.recent_pc_count + 1).min(RECENT_PC_COUNT); @@ -392,9 +433,16 @@ impl Interpreter { let operations: Arc<[Operation]> = lower_block(self.language, &block.instructions) .ok_or(Trap::UnsupportedInstruction { pc, encoding })? .into(); + let ends_basic_block = operations.iter().any(|operation| { + matches!( + operation, + Operation::Branch { .. } | Operation::SupervisorCall { .. } + ) + }); let cached = CachedInstruction { next_pc, operations, + ends_basic_block, }; if self.decode_cache.len() < MAX_DECODE_CACHE_ENTRIES { self.decode_cache.insert(cache_key, cached.clone()); @@ -411,6 +459,14 @@ impl Interpreter { }; candidate.set_pc(resolved_next_pc); *state = candidate; + if starts_basic_block { + self.record_block_entry(pc); + } + self.active_block_next_pc = if cached.ends_basic_block { + None + } else { + Some(resolved_next_pc) + }; let instruction = ExecutedInstruction { pc, @@ -431,6 +487,16 @@ impl Interpreter { }) } + fn record_block_entry(&mut self, pc: GuestAddress) { + if let Some(executions) = self.block_executions.get_mut(&pc) { + *executions = executions.saturating_add(1); + } else if self.block_executions.len() < MAX_PROFILED_BLOCKS { + self.block_executions.insert(pc, 1); + } else { + self.dropped_block_entries = self.dropped_block_entries.saturating_add(1); + } + } + /// Execute until SLEIGH raises the Linux supervisor-call boundary. /// /// # Errors @@ -2830,7 +2896,7 @@ mod tests { use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_runtime_core::{GuestAddress, MemoryAccess, ResourceLimit, Trap}; - use super::{Aarch64State, Interpreter, RegisterClass, StepOutcome}; + use super::{Aarch64State, BlockProfile, Interpreter, RegisterClass, StepOutcome}; const CODE_ADDRESS: GuestAddress = GuestAddress::new(0x1000); const PROBE_CODE: &[u8] = &[ @@ -2895,6 +2961,9 @@ mod tests { 0x00, 0x58, 0x20, 0x0e, // cnt v0.8b, v0.8b 0x16, 0xb8, 0x31, 0x0e, // addv b22, v0.8b ]; + const TIGHT_LOOP_CODE: &[u8] = &[ + 0x00, 0x00, 0x00, 0x14, // b . + ]; #[test] fn state_models_integer_vector_and_userspace_control_registers() { @@ -2945,6 +3014,34 @@ mod tests { interpreter.step(&mut state, &mut memory).unwrap(); assert_eq!(state.x(0), Some(42)); assert_eq!(interpreter.decode_cache.len(), 2); + assert_eq!( + interpreter.block_profiles(), + [BlockProfile { + start: CODE_ADDRESS, + executions: 2, + }] + ); + } + + #[test] + fn profiles_repeated_basic_block_entries_without_changing_execution() { + let mut memory = executable_memory(TIGHT_LOOP_CODE); + let mut state = Aarch64State::new(CODE_ADDRESS, GuestAddress::new(0x8000)); + let mut interpreter = Interpreter::new().unwrap(); + + let error = interpreter + .run_until_supervisor_call(&mut state, &mut memory, 12) + .unwrap_err(); + + assert_eq!(error, Trap::ResourceLimit(ResourceLimit::Instructions)); + assert_eq!( + interpreter.block_profiles(), + [BlockProfile { + start: CODE_ADDRESS, + executions: 12, + }] + ); + assert_eq!(interpreter.dropped_block_entries(), 0); } #[test] diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index eb067b3..b570fb3 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -65,6 +65,9 @@ pub struct BrowserExecution { executed_instructions: u64, dispatched_syscalls: u64, output_bytes: u64, + profiled_blocks: u64, + hottest_block_start: u64, + hottest_block_executions: u64, filesystem_snapshot: Vec, input_max_bytes: u64, } @@ -131,6 +134,24 @@ impl BrowserExecution { self.output_bytes } + #[wasm_bindgen(getter)] + #[must_use] + pub fn profiled_blocks(&self) -> u64 { + self.profiled_blocks + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn hottest_block_start(&self) -> u64 { + self.hottest_block_start + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn hottest_block_executions(&self) -> u64 { + self.hottest_block_executions + } + #[wasm_bindgen(getter)] #[must_use] pub fn filesystem_snapshot(&self) -> Vec { @@ -472,6 +493,7 @@ fn observed_execution( return BrowserExecution::diagnostic("filesystem.snapshot_failed", error.to_string()); } }; + let block_metrics = block_metrics(process); BrowserExecution { outcome: state.outcome.to_owned(), diagnostic_code: state.diagnostic_code, @@ -488,6 +510,9 @@ fn observed_execution( executed_instructions: process.executed_instructions(), dispatched_syscalls: process.dispatched_syscalls(), output_bytes: process.output_bytes(), + profiled_blocks: block_metrics.profiled_blocks, + hottest_block_start: block_metrics.hottest_block_start, + hottest_block_executions: block_metrics.hottest_block_executions, filesystem_snapshot, input_max_bytes: state.input_max_bytes, } @@ -585,6 +610,7 @@ fn execute_fixture( ("diagnostic", code.to_owned(), message, -1) } }; + let block_metrics = block_metrics(&process); BrowserExecution { outcome: outcome.to_owned(), @@ -597,6 +623,9 @@ fn execute_fixture( executed_instructions: process.executed_instructions(), dispatched_syscalls: process.dispatched_syscalls(), output_bytes: process.output_bytes(), + profiled_blocks: block_metrics.profiled_blocks, + hottest_block_start: block_metrics.hottest_block_start, + hottest_block_executions: block_metrics.hottest_block_executions, filesystem_snapshot: match filesystem.export_snapshot() { Ok(snapshot) => snapshot, Err(error) => { @@ -648,12 +677,31 @@ impl BrowserExecution { executed_instructions: 0, dispatched_syscalls: 0, output_bytes: 0, + profiled_blocks: 0, + hottest_block_start: 0, + hottest_block_executions: 0, filesystem_snapshot: Vec::new(), input_max_bytes: 0, } } } +struct BlockMetrics { + profiled_blocks: u64, + hottest_block_start: u64, + hottest_block_executions: u64, +} + +fn block_metrics(process: &Process) -> BlockMetrics { + let profiles = process.block_profiles(); + let hottest = profiles.first(); + BlockMetrics { + profiled_blocks: u64::try_from(profiles.len()).unwrap_or(u64::MAX), + hottest_block_start: hottest.map_or(0, |profile| profile.start.get()), + hottest_block_executions: hottest.map_or(0, |profile| profile.executions), + } +} + fn fixture(name: &str) -> Option<(&'static [u8], &'static [u8])> { match name { "compiler-c" => Some((COMPILER_HELLO_ELF, b"/compiler-hello")), @@ -796,6 +844,8 @@ mod tests { assert_eq!(result.exit_code, -1); assert_eq!(result.executed_instructions, 128); assert_eq!(result.dispatched_syscalls, 0); + assert_eq!(result.profiled_blocks, 1); + assert_eq!(result.hottest_block_executions, 128); } #[test] @@ -928,6 +978,8 @@ mod tests { assert_eq!(completed.outcome, "exited"); assert_eq!(completed.exit_code, 0); assert_eq!(completed.stdout, "compiled hello\n"); + assert!(completed.profiled_blocks > 0); + assert!(completed.hottest_block_executions > 0); } #[test] diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index 2339db2..e355bda 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -3,7 +3,7 @@ use core::fmt; use std::collections::BTreeMap; -use binarrow_aarch64::{Aarch64State, Interpreter, InterpreterInitializationError}; +use binarrow_aarch64::{Aarch64State, BlockProfile, Interpreter, InterpreterInitializationError}; use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_host_api::{ ClosedInput, DeterministicSystem, FileAccess, FileMetadata, FileOpenFlags, FileOpenOptions, @@ -491,6 +491,18 @@ impl Process { self.interpreter.recent_pcs() } + /// Return interpreted basic-block profiles in deterministic hotness order. + #[must_use] + pub fn block_profiles(&self) -> Vec { + self.interpreter.block_profiles() + } + + /// Return block-entry observations dropped by the bounded profiler. + #[must_use] + pub const fn dropped_block_entries(&self) -> u64 { + self.interpreter.dropped_block_entries() + } + /// Run until `exit`/`exit_group`, a host failure, or a project resource trap. /// /// Unsupported syscall numbers return `-ENOSYS` in `x0` and execution @@ -2109,6 +2121,9 @@ mod tests { assert_eq!(process.dispatched_syscalls(), 0); assert_eq!(process.output_bytes(), 0); assert!(process.trace().is_empty()); + assert_eq!(process.block_profiles().len(), 1); + assert_eq!(process.block_profiles()[0].executions, 128); + assert_eq!(process.dropped_block_entries(), 0); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 3dc1d7b..23d12e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ Owns small architecture-neutral vocabulary shared by future execution and host c ### `binarrow-aarch64` -Owns userspace architectural state (`x0..x30`, SP, PC, SIMD registers, NZCV, FPCR/FPSR, and `TPIDR_EL0`) and the checked interpreter boundary. Its public API contains only project types. The pinned Ghidra AArch64 specification is compiled once through Icicle's filesystem-free SLEIGH compiler, decoded instructions are lifted to Icicle P-code, and the supported P-code subset is lowered into project-owned operations before execution. The current operations cover scalar integer arithmetic, division, logical/shift/comparison operations, NZCV flags, single-thread exclusive accesses and memory barriers, 128-bit vector state and memory, byte popcount/reduction, persistent `TPIDR_EL0`, checked little-endian loads/stores, external branches, and label-resolved internal P-code branches. Scratch values preserve P-code byte slices up to 128 bits. Single-step and instruction-budgeted run APIs stop structurally at `CallSupervisor`, exposing the `svc` immediate and Linux syscall number from `x8`. Decode, semantic, alignment, memory, and budget failures remain project-owned traps. +Owns userspace architectural state (`x0..x30`, SP, PC, SIMD registers, NZCV, FPCR/FPSR, and `TPIDR_EL0`) and the checked interpreter boundary. Its public API contains only project types. The pinned Ghidra AArch64 specification is compiled once through Icicle's filesystem-free SLEIGH compiler, decoded instructions are lifted to Icicle P-code, and the supported P-code subset is lowered into project-owned operations before execution. The current operations cover scalar integer arithmetic, division, logical/shift/comparison operations, NZCV flags, single-thread exclusive accesses and memory barriers, 128-bit vector state and memory, byte popcount/reduction, persistent `TPIDR_EL0`, checked little-endian loads/stores, external branches, and label-resolved internal P-code branches. Scratch values preserve P-code byte slices up to 128 bits. Single-step and instruction-budgeted run APIs stop structurally at `CallSupervisor`, exposing the `svc` immediate and Linux syscall number from `x8`. A bounded profiler identifies guest basic-block entries from normalized branch and supervisor-call operations, counts successful entries, and returns stable hotness ordering for the future translator. Decode, semantic, alignment, memory, and budget failures remain project-owned traps. ### `binarrow-elf` @@ -92,4 +92,4 @@ The Icicle feasibility spike is isolated under `experiments/icicle`; it is not a `experiments/icicle-wasm` separately validates Icicle's lightweight `pcode` crate in raw `wasm32-unknown-unknown`. The production `binarrow-aarch64` crate pins `pcode`, `sleigh-runtime`, and the filesystem-free portion of `sleigh-compile` at that same revision and keeps their types private. The required AHash/getrandom browser backend is selected in the workspace's target configuration; `icicle-cpu` and its native VM/JIT remain outside the production graph. -Phase 2's Worker control, structured diagnostics, bounded in-memory filesystem, and shared native/browser fixture matrix are complete. Phase 3 begins by turning the regular-file store into a mountable VFS with directory mutation, metadata, OPFS persistence, terminal input, clocks, and randomness. +Phase 3's VFS, OPFS persistence, host services, image packaging, and native/browser CPython checkpoints are complete. Phase 4 has begun with bounded basic-block profiling in the interpreter and observable Worker metrics; Wasm lowering and translated dispatch remain separate future boundaries. diff --git a/web/index.html b/web/index.html index d69e178..20d649a 100644 --- a/web/index.html +++ b/web/index.html @@ -84,6 +84,12 @@
—
Output bytes
—
+
Profiled blocks
+
—
+
Hottest block
+
—
+
Hottest block executions
+
—
Diagnostic code
—
Diagnostic
diff --git a/web/src/main.ts b/web/src/main.ts index 3917923..fb5c9fa 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -27,6 +27,11 @@ const exitCode = requiredElement("#exit-code"); const instructionCount = requiredElement("#instruction-count"); const syscallCount = requiredElement("#syscall-count"); const outputCount = requiredElement("#output-count"); +const profiledBlockCount = requiredElement("#profiled-block-count"); +const hottestBlock = requiredElement("#hottest-block"); +const hottestBlockExecutions = requiredElement( + "#hottest-block-executions", +); const diagnosticCode = requiredElement("#diagnostic-code"); const diagnosticMessage = requiredElement("#diagnostic-message"); const fixture = requiredElement("#fixture"); @@ -104,6 +109,9 @@ function clearExecution(): void { instructionCount.textContent = "—"; syscallCount.textContent = "—"; outputCount.textContent = "—"; + profiledBlockCount.textContent = "—"; + hottestBlock.textContent = "—"; + hottestBlockExecutions.textContent = "—"; renderDiagnostic(); } @@ -117,6 +125,12 @@ function renderExecution(execution: ExecutionReport): void { instructionCount.textContent = String(execution.executedInstructions); syscallCount.textContent = String(execution.dispatchedSyscalls); outputCount.textContent = String(execution.outputBytes); + profiledBlockCount.textContent = String(execution.profiledBlocks); + hottestBlock.textContent = + execution.hottestBlockStart === null + ? "—" + : `0x${execution.hottestBlockStart.toString(16)}`; + hottestBlockExecutions.textContent = String(execution.hottestBlockExecutions); renderDiagnostic(execution.diagnostic); if (execution.outcome === "waiting-input") { diff --git a/web/src/probe.ts b/web/src/probe.ts index 277e851..88480a2 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -55,6 +55,9 @@ export interface ExecutionReport { executedInstructions: bigint; dispatchedSyscalls: bigint; outputBytes: bigint; + profiledBlocks: bigint; + hottestBlockStart: bigint | null; + hottestBlockExecutions: bigint; inputMaxBytes: bigint; } diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index 4fd1fc1..40c3ce1 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -201,6 +201,12 @@ function executionReport( executedInstructions: result.executed_instructions, dispatchedSyscalls: result.dispatched_syscalls, outputBytes: result.output_bytes, + profiledBlocks: result.profiled_blocks, + hottestBlockStart: + result.hottest_block_executions === 0n + ? null + : result.hottest_block_start, + hottestBlockExecutions: result.hottest_block_executions, inputMaxBytes: result.input_max_bytes, }; } diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 179172f..e700018 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -91,6 +91,8 @@ test("surfaces instruction exhaustion as a structured diagnostic", async ({ "guest instruction limit exhausted", ); await expect(page.locator("#instruction-count")).toHaveText("128"); + await expect(page.locator("#profiled-block-count")).toHaveText("1"); + await expect(page.locator("#hottest-block-executions")).toHaveText("128"); }); test("round trips a file through the bounded in-memory filesystem", async ({