From f2dd3fab0227380fd085560618ffb2261526eabd Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 24 Jul 2026 11:55:08 -0700 Subject: [PATCH] feat: dispatch hot blocks through Wasm --- PLAN.md | 4 +- README.md | 2 +- crates/aarch64/src/lib.rs | 220 +++++++++++++++++++++++++++- crates/browser-runtime/src/lib.rs | 231 +++++++++++++++++++++++++++++- crates/linux-runtime/src/lib.rs | 61 +++++++- docs/architecture.md | 8 +- web/index.html | 12 ++ web/src/main.ts | 30 ++++ web/src/probe.ts | 6 + web/src/probe.worker.ts | 6 + web/tests/probe.spec.ts | 6 + 11 files changed, 569 insertions(+), 17 deletions(-) diff --git a/PLAN.md b/PLAN.md index e349896..8827fe6 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 profiling and initial basic-block Wasm lowering implemented +**Status:** Phase 3 complete; Phase 4 hot-block Wasm dispatch implemented for the initial scalar subset **Primary implementation language:** Rust **Initial browser target:** Google Chrome **Guest architecture:** AArch64, little-endian, Linux userspace @@ -1863,6 +1863,8 @@ The first Phase 4 checkpoint is implemented. The interpreter identifies basic-bl The initial basic-block lowering checkpoint is also implemented. Decoder output now crosses into the project-owned `binarrow-execution-ir` crate, where blocks are validated as non-empty, contiguous single-entry sequences with a final terminator and scratch storage no longer exposes Icicle types. `binarrow-wasm-backend` lowers its scalar Tier-1 subset against an explicit imported state-memory ABI, emits deterministic modules and translation metrics, and returns structured unsupported-operation results for interpreter fallback. A checked-in three-instruction AArch64 fixture sets `x0` to 40, adds two, and loops; Chromium lifts and dynamically compiles that real block, verifies the translated state contains `x0 == 42`, and verifies its next PC is the block entry. Hot-dispatch integration, broader operation coverage, and the translation cache are next. +Hot-dispatch integration is now implemented for the initial scalar subset. The interpreter offers blocks to a backend after a deterministic execution threshold, charges translated instructions against the same process budget, applies replacement architectural state atomically, and remembers unsupported block identities for interpreter fallback. The browser backend compiles and caches core Wasm modules by guest address plus instruction encodings, reuses a module-local imported state memory, and exposes translated-block, translated-instruction, fallback, compilation, cache-hit, and emitted-byte counters. The Chromium infinite-loop regression executes 64 cold iterations in the interpreter and the next 64 from one generated Wasm module with 63 cache hits. Broader operation coverage, explicit cache-invalidation regressions, a larger differential suite, function-table dispatch, and benchmark evidence remain before Phase 4 is complete. + Do not begin the full web IDE before item 30 passes. --- diff --git a/README.md b/README.md index ba17690..84c9f93 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,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. 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. +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 profiler offers hot blocks to the scalar Wasm backend; supported blocks compile and execute from a session-local module cache while unsupported blocks remain in the interpreter. The UI reports hotness, translated execution, fallback, compilation, cache-hit, and emitted-byte metrics. 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 e5ac201..634b824 100644 --- a/crates/aarch64/src/lib.rs +++ b/crates/aarch64/src/lib.rs @@ -5,13 +5,14 @@ //! types, keeping Linux services independent of the decoder implementation. use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, sync::{Arc, OnceLock}, }; +pub use binarrow_execution_ir::BasicBlock; use binarrow_execution_ir::{ - BasicBlock, Flag, FloatArithmetic, FloatComparison, InvalidBasicBlock, LiftedInstruction, - Operation, Place, Storage, Value, ValueSource, + Flag, FloatArithmetic, FloatComparison, InvalidBasicBlock, LiftedInstruction, Operation, Place, + Storage, Value, ValueSource, }; use binarrow_guest_memory::{AddressSpace, MemoryError}; use binarrow_runtime_core::{GuestAddress, MemoryAccess, ResourceLimit, Trap}; @@ -317,6 +318,21 @@ pub struct BlockProfile { pub executions: u64, } +/// Observable execution-tier counters owned by the interpreter. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TieredExecutionMetrics { + pub translated_block_executions: u64, + pub translated_instructions: u64, + pub interpreter_fallback_blocks: u64, +} + +/// Host/backend implementation capable of atomically executing one block. +pub trait BlockExecutor { + /// Return a replacement architectural state when the block executed, or + /// `None` when this block must remain in the interpreter. + fn execute(&mut self, block: &BasicBlock, state: &Aarch64State) -> Option; +} + /// Why a complete normalized basic block could not be lifted. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BlockLiftError { @@ -360,6 +376,8 @@ pub struct Interpreter { block_executions: BTreeMap, active_block_next_pc: Option, dropped_block_entries: u64, + tiered_metrics: TieredExecutionMetrics, + fallback_blocks: BTreeSet>, recent_pcs: [GuestAddress; RECENT_PC_COUNT], recent_pc_cursor: usize, recent_pc_count: usize, @@ -387,6 +405,8 @@ impl Interpreter { block_executions: BTreeMap::new(), active_block_next_pc: None, dropped_block_entries: 0, + tiered_metrics: TieredExecutionMetrics::default(), + fallback_blocks: BTreeSet::new(), recent_pcs: [GuestAddress::NULL; RECENT_PC_COUNT], recent_pc_cursor: 0, recent_pc_count: 0, @@ -431,6 +451,12 @@ impl Interpreter { self.dropped_block_entries } + /// Current interpreter/translated-tier counters. + #[must_use] + pub const fn tiered_metrics(&self) -> TieredExecutionMetrics { + self.tiered_metrics + } + /// Decode a complete basic block without executing or mutating guest state. /// /// # Errors @@ -478,9 +504,7 @@ impl Interpreter { ) -> 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); + self.record_recent_pc(pc); let (encoding, cached) = self.decode_instruction(memory, pc)?; let mut candidate = state.clone(); @@ -530,6 +554,12 @@ impl Interpreter { } } + fn record_recent_pc(&mut self, pc: GuestAddress) { + 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); + } + fn decode_instruction( &mut self, memory: &AddressSpace, @@ -597,6 +627,108 @@ impl Interpreter { } Err(Trap::ResourceLimit(ResourceLimit::Instructions)) } + + /// Execute with hot blocks offered to a translated-block executor. + /// + /// Every translated guest instruction consumes the same budget as an + /// interpreted instruction. Unsupported blocks are remembered by their + /// address/encoding identity and continue through [`Self::step`]. + /// + /// # Errors + /// + /// Returns the same traps as [`Self::run_until_supervisor_call`]. + pub fn run_until_supervisor_call_tiered( + &mut self, + state: &mut Aarch64State, + memory: &mut AddressSpace, + instruction_budget: u64, + hot_threshold: u64, + max_block_instructions: u32, + executor: &mut E, + ) -> Result { + let mut completed = 0_u64; + while completed < instruction_budget { + let remaining = instruction_budget - completed; + if let Some(translated) = self.try_translated_block( + state, + memory, + remaining, + hot_threshold, + max_block_instructions, + executor, + ) { + completed += translated; + continue; + } + match self.step(state, memory)? { + StepOutcome::Advanced(_) => completed += 1, + StepOutcome::SupervisorCall(supervisor_call) => { + return Ok(RunResult { + supervisor_call, + executed_instructions: completed + 1, + }); + } + } + } + Err(Trap::ResourceLimit(ResourceLimit::Instructions)) + } + + #[allow(clippy::too_many_arguments)] + fn try_translated_block( + &mut self, + state: &mut Aarch64State, + memory: &AddressSpace, + remaining_budget: u64, + hot_threshold: u64, + max_block_instructions: u32, + executor: &mut E, + ) -> Option { + if hot_threshold == 0 + || self.block_executions.get(&state.pc()).copied().unwrap_or(0) < hot_threshold + { + return None; + } + let block = self + .lift_basic_block(memory, state.pc(), max_block_instructions) + .ok()?; + let instruction_count = u64::try_from(block.instructions().len()).ok()?; + if instruction_count > remaining_budget { + return None; + } + let identity = block + .instructions() + .iter() + .map(|instruction| (instruction.pc.get(), instruction.encoding)) + .collect::>(); + if self.fallback_blocks.contains(&identity) { + return None; + } + let Some(translated_state) = executor.execute(&block, state) else { + if self.fallback_blocks.len() < MAX_PROFILED_BLOCKS { + self.fallback_blocks.insert(identity); + } + self.tiered_metrics.interpreter_fallback_blocks = self + .tiered_metrics + .interpreter_fallback_blocks + .saturating_add(1); + return None; + }; + for instruction in block.instructions() { + self.record_recent_pc(instruction.pc); + } + self.record_block_entry(block.start()); + self.active_block_next_pc = None; + *state = translated_state; + self.tiered_metrics.translated_block_executions = self + .tiered_metrics + .translated_block_executions + .saturating_add(1); + self.tiered_metrics.translated_instructions = self + .tiered_metrics + .translated_instructions + .saturating_add(instruction_count); + Some(instruction_count) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2657,13 +2789,32 @@ fn memory_trap(error: &MemoryError, fallback: GuestAddress, access: MemoryAccess #[cfg(test)] mod tests { + use binarrow_execution_ir::BasicBlock; use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_runtime_core::{GuestAddress, MemoryAccess, ResourceLimit, Trap}; use super::{ - Aarch64State, BlockLiftError, BlockProfile, Interpreter, RegisterClass, StepOutcome, + Aarch64State, BlockExecutor, BlockLiftError, BlockProfile, Interpreter, RegisterClass, + StepOutcome, TieredExecutionMetrics, }; + struct LoopBlockExecutor { + executions: u64, + supported: bool, + } + + impl BlockExecutor for LoopBlockExecutor { + fn execute(&mut self, block: &BasicBlock, state: &Aarch64State) -> Option { + self.executions += 1; + if !self.supported { + return None; + } + let mut translated = state.clone(); + translated.set_pc(block.start()); + Some(translated) + } + } + const CODE_ADDRESS: GuestAddress = GuestAddress::new(0x1000); const PROBE_CODE: &[u8] = &[ 0x40, 0x05, 0x80, 0xd2, // mov x0, #42 @@ -2815,6 +2966,61 @@ mod tests { assert_eq!(interpreter.dropped_block_entries(), 0); } + #[test] + fn dispatches_hot_blocks_and_counts_translated_instructions() { + 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 mut executor = LoopBlockExecutor { + executions: 0, + supported: true, + }; + + let error = interpreter + .run_until_supervisor_call_tiered(&mut state, &mut memory, 12, 2, 4, &mut executor) + .unwrap_err(); + + assert_eq!(error, Trap::ResourceLimit(ResourceLimit::Instructions)); + assert_eq!(executor.executions, 10); + assert_eq!(interpreter.block_profiles()[0].executions, 12); + assert_eq!( + interpreter.tiered_metrics(), + TieredExecutionMetrics { + translated_block_executions: 10, + translated_instructions: 10, + interpreter_fallback_blocks: 0, + } + ); + assert_eq!(interpreter.recent_pcs().len(), 12); + } + + #[test] + fn remembers_unsupported_blocks_and_falls_back_to_interpretation() { + 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 mut executor = LoopBlockExecutor { + executions: 0, + supported: false, + }; + + let error = interpreter + .run_until_supervisor_call_tiered(&mut state, &mut memory, 12, 2, 4, &mut executor) + .unwrap_err(); + + assert_eq!(error, Trap::ResourceLimit(ResourceLimit::Instructions)); + assert_eq!(executor.executions, 1); + assert_eq!(interpreter.block_profiles()[0].executions, 12); + assert_eq!( + interpreter.tiered_metrics(), + TieredExecutionMetrics { + translated_block_executions: 0, + translated_instructions: 0, + interpreter_fallback_blocks: 1, + } + ); + } + #[test] fn runs_until_svc_and_reports_the_linux_abi_register() { let mut memory = executable_memory(SYSCALL_CODE); diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 641ebcf..7410af0 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -1,8 +1,10 @@ //! WebAssembly entry point for Worker-hosted guest execution. use core::convert::Infallible; +#[cfg(target_arch = "wasm32")] +use std::collections::BTreeMap; -use binarrow_aarch64::Interpreter; +use binarrow_aarch64::{Aarch64State, BasicBlock, BlockExecutor, Interpreter}; use binarrow_host_api::{ DeterministicSystem, HostInput, HostTerminal, HostTime, TerminalInputRead, TerminalStream, }; @@ -11,6 +13,8 @@ use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_proces use binarrow_memory_fs::MemoryFileSystem; use binarrow_runtime_core::{MemoryAccess, ResourceLimit, Trap}; use binarrow_wasm_backend::compile as compile_wasm_block; +#[cfg(target_arch = "wasm32")] +use binarrow_wasm_backend::{GENERAL_REGISTERS_OFFSET, STACK_POINTER_OFFSET, STATE_BYTES}; use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] @@ -25,13 +29,41 @@ export function binarrow_random_seed() { const bytes = crypto.getRandomValues(new Uint8Array(8)); return new DataView(bytes.buffer).getBigUint64(0, true); } +const binarrowTranslatedBlocks = new Map(); +export function binarrow_execute_translated(cacheKey, moduleBytes, stateBytes) { + let cached = binarrowTranslatedBlocks.get(cacheKey); + if (cached === undefined) { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const module = new WebAssembly.Module(moduleBytes); + const instance = new WebAssembly.Instance(module, { binarrow: { state: memory } }); + cached = { memory, run: instance.exports.run }; + binarrowTranslatedBlocks.set(cacheKey, cached); + } + const memoryBytes = new Uint8Array(cached.memory.buffer, 0, stateBytes.length); + memoryBytes.fill(0); + memoryBytes.set(stateBytes); + const nextPc = cached.run(0); + stateBytes.set(memoryBytes); + return nextPc; +} ")] extern "C" { fn binarrow_realtime_nanoseconds() -> u64; fn binarrow_monotonic_nanoseconds() -> u64; fn binarrow_random_seed() -> u64; + #[wasm_bindgen(catch)] + fn binarrow_execute_translated( + cache_key: &str, + module_bytes: &[u8], + state_bytes: &mut [u8], + ) -> Result; } +const HOT_BLOCK_THRESHOLD: u64 = 64; +const MAX_TRANSLATED_BLOCK_INSTRUCTIONS: u32 = 64; +#[cfg(target_arch = "wasm32")] +const MAX_TRANSLATED_BLOCKS: usize = 4096; + const COMPILER_HELLO_ELF: &[u8] = include_bytes!("../../../guest-tests/compiler-hello/compiler-hello.aarch64.elf"); const LIBC_HELLO_ELF: &[u8] = @@ -72,6 +104,12 @@ pub struct BrowserExecution { profiled_blocks: u64, hottest_block_start: u64, hottest_block_executions: u64, + translated_block_executions: u64, + translated_instructions: u64, + translation_fallback_blocks: u64, + compiled_blocks: u64, + translation_cache_hits: u64, + compiled_wasm_bytes: u64, filesystem_snapshot: Vec, input_max_bytes: u64, } @@ -86,6 +124,132 @@ pub struct BrowserTranslation { wasm_bytes: u32, } +#[derive(Clone, Copy, Debug, Default)] +struct BrowserTranslationMetrics { + compiled_blocks: u64, + cache_hits: u64, + compiled_wasm_bytes: u64, +} + +#[derive(Default)] +struct BrowserBlockExecutor { + #[cfg(target_arch = "wasm32")] + cache: BTreeMap, CachedBrowserBlock>, + metrics: BrowserTranslationMetrics, +} + +#[cfg(target_arch = "wasm32")] +struct CachedBrowserBlock { + cache_key: String, + wasm_module: Vec, +} + +impl BlockExecutor for BrowserBlockExecutor { + fn execute(&mut self, block: &BasicBlock, state: &Aarch64State) -> Option { + #[cfg(target_arch = "wasm32")] + { + return self.execute_in_browser(block, state); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (block, state); + None + } + } +} + +#[cfg(target_arch = "wasm32")] +impl BrowserBlockExecutor { + fn execute_in_browser( + &mut self, + block: &BasicBlock, + state: &Aarch64State, + ) -> Option { + let identity = block + .instructions() + .iter() + .map(|instruction| (instruction.pc.get(), instruction.encoding)) + .collect::>(); + if self.cache.contains_key(&identity) { + self.metrics.cache_hits = self.metrics.cache_hits.saturating_add(1); + } else { + if self.cache.len() >= MAX_TRANSLATED_BLOCKS { + return None; + } + let compiled = compile_wasm_block(block).ok()?; + self.metrics.compiled_blocks = self.metrics.compiled_blocks.saturating_add(1); + self.metrics.compiled_wasm_bytes = self + .metrics + .compiled_wasm_bytes + .saturating_add(u64::try_from(compiled.bytes().len()).unwrap_or(u64::MAX)); + self.cache.insert( + identity.clone(), + CachedBrowserBlock { + cache_key: translation_cache_key(&identity), + wasm_module: compiled.bytes().to_vec(), + }, + ); + } + let cached = self.cache.get(&identity)?; + let mut state_bytes = encode_translation_state(state); + let next_pc = + binarrow_execute_translated(&cached.cache_key, &cached.wasm_module, &mut state_bytes) + .ok()?; + decode_translation_state(state, &state_bytes, next_pc) + } +} + +#[cfg(target_arch = "wasm32")] +fn translation_cache_key(identity: &[(u64, u32)]) -> String { + identity + .iter() + .map(|(pc, encoding)| format!("{pc:016x}:{encoding:08x}")) + .collect::>() + .join(",") +} + +#[cfg(target_arch = "wasm32")] +fn encode_translation_state(state: &Aarch64State) -> Vec { + let mut bytes = vec![0; usize::try_from(STATE_BYTES).expect("state ABI fits host usize")]; + for index in 0_u8..31 { + let offset = usize::try_from(GENERAL_REGISTERS_OFFSET + u64::from(index) * 8) + .expect("state ABI register offset fits host usize"); + bytes[offset..offset + 8].copy_from_slice( + &state + .x(index) + .expect("AArch64 general register index is valid") + .to_le_bytes(), + ); + } + let sp_offset = + usize::try_from(STACK_POINTER_OFFSET).expect("state ABI SP offset fits host usize"); + bytes[sp_offset..sp_offset + 8].copy_from_slice(&state.sp().get().to_le_bytes()); + bytes +} + +#[cfg(target_arch = "wasm32")] +fn decode_translation_state( + original: &Aarch64State, + bytes: &[u8], + next_pc: u64, +) -> Option { + if bytes.len() != usize::try_from(STATE_BYTES).ok()? { + return None; + } + let mut state = original.clone(); + for index in 0_u8..31 { + let offset = usize::try_from(GENERAL_REGISTERS_OFFSET + u64::from(index) * 8).ok()?; + let value = u64::from_le_bytes(bytes.get(offset..offset + 8)?.try_into().ok()?); + state.set_x(index, value).ok()?; + } + let sp_offset = usize::try_from(STACK_POINTER_OFFSET).ok()?; + state.set_sp(binarrow_runtime_core::GuestAddress::new( + u64::from_le_bytes(bytes.get(sp_offset..sp_offset + 8)?.try_into().ok()?), + )); + state.set_pc(binarrow_runtime_core::GuestAddress::new(next_pc)); + Some(state) +} + #[wasm_bindgen] impl BrowserTranslation { #[wasm_bindgen(getter)] @@ -247,6 +411,42 @@ impl BrowserExecution { self.hottest_block_executions } + #[wasm_bindgen(getter)] + #[must_use] + pub fn translated_block_executions(&self) -> u64 { + self.translated_block_executions + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn translated_instructions(&self) -> u64 { + self.translated_instructions + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn translation_fallback_blocks(&self) -> u64 { + self.translation_fallback_blocks + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn compiled_blocks(&self) -> u64 { + self.compiled_blocks + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn translation_cache_hits(&self) -> u64 { + self.translation_cache_hits + } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn compiled_wasm_bytes(&self) -> u64 { + self.compiled_wasm_bytes + } + #[wasm_bindgen(getter)] #[must_use] pub fn filesystem_snapshot(&self) -> Vec { @@ -338,6 +538,7 @@ pub struct BrowserGuestSession { filesystem: MemoryFileSystem, terminal: CapturedTerminal, system: DeterministicSystem, + block_executor: BrowserBlockExecutor, startup_failure: Option, finished: bool, } @@ -369,11 +570,14 @@ impl BrowserGuestSession { "guest session has no runnable process".to_owned(), ); }; - let execution = process.run_until_event( + let execution = process.run_until_event_tiered( &mut self.terminal, &mut self.filesystem, &mut self.system, &mut host_input, + &mut self.block_executor, + HOT_BLOCK_THRESHOLD, + MAX_TRANSLATED_BLOCK_INSTRUCTIONS, ); let (outcome, diagnostic_code, diagnostic_message, exit_code, input_max_bytes) = match execution { @@ -404,6 +608,7 @@ impl BrowserGuestSession { process, &self.terminal, &self.filesystem, + self.block_executor.metrics, ObservedState { outcome, diagnostic_code, @@ -557,6 +762,7 @@ fn start_guest_session( filesystem, terminal: CapturedTerminal::default(), system: host_system(), + block_executor: BrowserBlockExecutor::default(), startup_failure, finished: false, } @@ -580,6 +786,7 @@ fn observed_execution( process: &Process, terminal: &CapturedTerminal, filesystem: &MemoryFileSystem, + translation_metrics: BrowserTranslationMetrics, state: ObservedState, ) -> BrowserExecution { let filesystem_snapshot = match filesystem.export_snapshot() { @@ -589,6 +796,7 @@ fn observed_execution( } }; let block_metrics = block_metrics(process); + let tiered_metrics = process.tiered_execution_metrics(); BrowserExecution { outcome: state.outcome.to_owned(), diagnostic_code: state.diagnostic_code, @@ -608,6 +816,12 @@ fn observed_execution( profiled_blocks: block_metrics.profiled_blocks, hottest_block_start: block_metrics.hottest_block_start, hottest_block_executions: block_metrics.hottest_block_executions, + translated_block_executions: tiered_metrics.translated_block_executions, + translated_instructions: tiered_metrics.translated_instructions, + translation_fallback_blocks: tiered_metrics.interpreter_fallback_blocks, + compiled_blocks: translation_metrics.compiled_blocks, + translation_cache_hits: translation_metrics.cache_hits, + compiled_wasm_bytes: translation_metrics.compiled_wasm_bytes, filesystem_snapshot, input_max_bytes: state.input_max_bytes, } @@ -706,6 +920,7 @@ fn execute_fixture( } }; let block_metrics = block_metrics(&process); + let tiered_metrics = process.tiered_execution_metrics(); BrowserExecution { outcome: outcome.to_owned(), @@ -721,6 +936,12 @@ fn execute_fixture( profiled_blocks: block_metrics.profiled_blocks, hottest_block_start: block_metrics.hottest_block_start, hottest_block_executions: block_metrics.hottest_block_executions, + translated_block_executions: tiered_metrics.translated_block_executions, + translated_instructions: tiered_metrics.translated_instructions, + translation_fallback_blocks: tiered_metrics.interpreter_fallback_blocks, + compiled_blocks: 0, + translation_cache_hits: 0, + compiled_wasm_bytes: 0, filesystem_snapshot: match filesystem.export_snapshot() { Ok(snapshot) => snapshot, Err(error) => { @@ -775,6 +996,12 @@ impl BrowserExecution { profiled_blocks: 0, hottest_block_start: 0, hottest_block_executions: 0, + translated_block_executions: 0, + translated_instructions: 0, + translation_fallback_blocks: 0, + compiled_blocks: 0, + translation_cache_hits: 0, + compiled_wasm_bytes: 0, filesystem_snapshot: Vec::new(), input_max_bytes: 0, } diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index e355bda..fafe952 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -3,7 +3,10 @@ use core::fmt; use std::collections::BTreeMap; -use binarrow_aarch64::{Aarch64State, BlockProfile, Interpreter, InterpreterInitializationError}; +use binarrow_aarch64::{ + Aarch64State, BlockExecutor, BlockProfile, Interpreter, InterpreterInitializationError, + TieredExecutionMetrics, +}; use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_host_api::{ ClosedInput, DeterministicSystem, FileAccess, FileMetadata, FileOpenFlags, FileOpenOptions, @@ -131,6 +134,18 @@ struct PendingInput { arguments: [u64; 6], } +struct DisabledBlockExecutor; + +impl BlockExecutor for DisabledBlockExecutor { + fn execute( + &mut self, + _block: &binarrow_aarch64::BasicBlock, + _state: &Aarch64State, + ) -> Option { + None + } +} + /// Observable result of one completed Linux syscall dispatch. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SyscallOutcome { @@ -503,6 +518,12 @@ impl Process { self.interpreter.dropped_block_entries() } + /// Current interpreter/translated-tier counters. + #[must_use] + pub const fn tiered_execution_metrics(&self) -> TieredExecutionMetrics { + self.interpreter.tiered_metrics() + } + /// Run until `exit`/`exit_group`, a host failure, or a project resource trap. /// /// Unsupported syscall numbers return `-ENOSYS` in `x0` and execution @@ -570,6 +591,39 @@ impl Process { filesystem: &mut F, system: &mut S, input: &mut I, + ) -> Result> { + self.run_until_event_tiered( + terminal, + filesystem, + system, + input, + &mut DisabledBlockExecutor, + 0, + 0, + ) + } + + /// Run with hot basic blocks offered to a translated executor. + /// + /// # Errors + /// + /// Returns the same structured execution failures as [`Self::run_until_event`]. + #[allow(clippy::too_many_arguments)] + pub fn run_until_event_tiered< + T: HostTerminal, + F: HostFileSystem, + S: HostSystem, + I: HostInput, + E: BlockExecutor, + >( + &mut self, + terminal: &mut T, + filesystem: &mut F, + system: &mut S, + input: &mut I, + executor: &mut E, + hot_threshold: u64, + max_block_instructions: u32, ) -> Result> { if let Some(request) = self.resume_pending_input(input) { return Ok(ExecutionEvent::Input(request)); @@ -579,10 +633,13 @@ impl Process { .limits .instruction_budget .saturating_sub(self.executed_instructions); - let stop = match self.interpreter.run_until_supervisor_call( + let stop = match self.interpreter.run_until_supervisor_call_tiered( &mut self.state, &mut self.memory, remaining, + hot_threshold, + max_block_instructions, + executor, ) { Ok(stop) => stop, Err(Trap::ResourceLimit(ResourceLimit::Instructions)) => { diff --git a/docs/architecture.md b/docs/architecture.md index 2d93270..934ebf8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Owns the decoder-independent normalized operation vocabulary and validated basic ### `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`. 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. +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 and offers hot blocks through a project-owned executor trait. Successful translated blocks consume the ordinary instruction budget and atomically replace architectural state; unsupported identities are remembered for interpreter fallback. Decode, semantic, alignment, memory, and budget failures remain project-owned traps. ### `binarrow-elf` @@ -76,11 +76,11 @@ Consumes a loaded process image, owns its architectural execution state and desc ### `binarrow-browser-runtime` -Provides the narrow wasm-bindgen boundary used by the Worker. It embeds deterministic C, Rust, and non-terminating fixtures, applies browser-supplied resource limits, invokes the production loader/runtime, captures the terminal host trait, and exposes output, exit status, counters, trace events, and stable structured diagnostics. The TypeScript controller starts one run at a time and implements unconditional cancellation by terminating and recreating the Worker; no AArch64 or Linux behavior is reimplemented in TypeScript. +Provides the narrow wasm-bindgen boundary used by the Worker. It embeds deterministic C, Rust, and non-terminating fixtures, applies browser-supplied resource limits, invokes the production loader/runtime, captures the terminal host trait, and exposes output, exit status, counters, trace events, translation metrics, and stable structured diagnostics. Its Tier-1 executor compiles supported hot blocks, caches modules by guest address and instruction encodings, exchanges scalar architectural state through the backend ABI, and invokes the generated module synchronously inside the Worker. The TypeScript controller starts one run at a time and implements unconditional cancellation by terminating and recreating the Worker; no AArch64 or Linux behavior is reimplemented in TypeScript. ### `binarrow-wasm-backend` -Consumes validated project-owned basic blocks and emits deterministic core WebAssembly modules. The initial Tier-1 state ABI imports a bounded Memory32 state page, stores `x0..x30` followed by SP, and exports `run(i32 state_base) -> i64 next_pc`. Scalar register, scratch, arithmetic, comparison, flag-helper, and external-branch operations lower directly; unsupported memory, vector, floating-point, supervisor, or internal-control operations return a structured fallback reason. Chromium dynamically compiles a real three-instruction AArch64 block, verifies its `x0 = 42` state mutation, and compares its next PC with the loop entry. +Consumes validated project-owned basic blocks and emits deterministic core WebAssembly modules. The initial Tier-1 state ABI imports a bounded Memory32 state page, stores `x0..x30` followed by SP, and exports `run(i32 state_base) -> i64 next_pc`. Scalar register, scratch, arithmetic, comparison, flag-helper, and external-branch operations lower directly; unsupported memory, vector, floating-point, supervisor, or internal-control operations return a structured fallback reason. Chromium dynamically compiles a real three-instruction AArch64 block, verifies its `x0 = 42` state mutation, and compares its next PC with the loop entry. The normal browser execution path also dispatches a hot self-loop through one cached generated module while preserving instruction-budget and profiling accounting. ### `binarrow-cli` @@ -100,4 +100,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 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. +Phase 3's VFS, OPFS persistence, host services, image packaging, and native/browser CPython checkpoints are complete. Phase 4 now has bounded profiling, scalar basic-block lowering, browser-side dynamic compilation, hot dispatch, interpreter fallback, a session-local module cache, and observable translation metrics. Broader lowering, cache invalidation tests, function-table dispatch, differential coverage, and performance evidence remain. diff --git a/web/index.html b/web/index.html index 20d649a..1cc25c9 100644 --- a/web/index.html +++ b/web/index.html @@ -90,6 +90,18 @@
—
Hottest block executions
—
+
Translated block executions
+
—
+
Translated instructions
+
—
+
Interpreter fallback blocks
+
—
+
Compiled blocks
+
—
+
Translation cache hits
+
—
+
Compiled Wasm bytes
+
—
Diagnostic code
—
Diagnostic
diff --git a/web/src/main.ts b/web/src/main.ts index fb5c9fa..9300da5 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -32,6 +32,20 @@ const hottestBlock = requiredElement("#hottest-block"); const hottestBlockExecutions = requiredElement( "#hottest-block-executions", ); +const translatedBlockExecutions = requiredElement( + "#translated-block-executions", +); +const translatedInstructions = requiredElement( + "#translated-instructions", +); +const translationFallbackBlocks = requiredElement( + "#translation-fallback-blocks", +); +const compiledBlocks = requiredElement("#compiled-blocks"); +const translationCacheHits = requiredElement( + "#translation-cache-hits", +); +const compiledWasmBytes = requiredElement("#compiled-wasm-bytes"); const diagnosticCode = requiredElement("#diagnostic-code"); const diagnosticMessage = requiredElement("#diagnostic-message"); const fixture = requiredElement("#fixture"); @@ -112,6 +126,12 @@ function clearExecution(): void { profiledBlockCount.textContent = "—"; hottestBlock.textContent = "—"; hottestBlockExecutions.textContent = "—"; + translatedBlockExecutions.textContent = "—"; + translatedInstructions.textContent = "—"; + translationFallbackBlocks.textContent = "—"; + compiledBlocks.textContent = "—"; + translationCacheHits.textContent = "—"; + compiledWasmBytes.textContent = "—"; renderDiagnostic(); } @@ -131,6 +151,16 @@ function renderExecution(execution: ExecutionReport): void { ? "—" : `0x${execution.hottestBlockStart.toString(16)}`; hottestBlockExecutions.textContent = String(execution.hottestBlockExecutions); + translatedBlockExecutions.textContent = String( + execution.translatedBlockExecutions, + ); + translatedInstructions.textContent = String(execution.translatedInstructions); + translationFallbackBlocks.textContent = String( + execution.translationFallbackBlocks, + ); + compiledBlocks.textContent = String(execution.compiledBlocks); + translationCacheHits.textContent = String(execution.translationCacheHits); + compiledWasmBytes.textContent = String(execution.compiledWasmBytes); renderDiagnostic(execution.diagnostic); if (execution.outcome === "waiting-input") { diff --git a/web/src/probe.ts b/web/src/probe.ts index cfb6580..de87c01 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -59,6 +59,12 @@ export interface ExecutionReport { profiledBlocks: bigint; hottestBlockStart: bigint | null; hottestBlockExecutions: bigint; + translatedBlockExecutions: bigint; + translatedInstructions: bigint; + translationFallbackBlocks: bigint; + compiledBlocks: bigint; + translationCacheHits: bigint; + compiledWasmBytes: bigint; inputMaxBytes: bigint; } diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index b5270b6..107dc8e 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -238,6 +238,12 @@ function executionReport( ? null : result.hottest_block_start, hottestBlockExecutions: result.hottest_block_executions, + translatedBlockExecutions: result.translated_block_executions, + translatedInstructions: result.translated_instructions, + translationFallbackBlocks: result.translation_fallback_blocks, + compiledBlocks: result.compiled_blocks, + translationCacheHits: result.translation_cache_hits, + compiledWasmBytes: result.compiled_wasm_bytes, inputMaxBytes: result.input_max_bytes, }; } diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index f702060..7c1344f 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -94,6 +94,12 @@ test("surfaces instruction exhaustion as a structured diagnostic", async ({ 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"); + await expect(page.locator("#translated-block-executions")).toHaveText("64"); + await expect(page.locator("#translated-instructions")).toHaveText("64"); + await expect(page.locator("#translation-fallback-blocks")).toHaveText("0"); + await expect(page.locator("#compiled-blocks")).toHaveText("1"); + await expect(page.locator("#translation-cache-hits")).toHaveText("63"); + await expect(page.locator("#compiled-wasm-bytes")).not.toHaveText("0"); }); test("round trips a file through the bounded in-memory filesystem", async ({ -- 2.51.2