diff --git a/.github/actions/setup-llvm22/action.yml b/.github/actions/setup-llvm22/action.yml index a1d1b1b893..f4ee9631c7 100644 --- a/.github/actions/setup-llvm22/action.yml +++ b/.github/actions/setup-llvm22/action.yml @@ -165,3 +165,7 @@ runs: } "LLVM_SYS_221_PREFIX=C:\llvm" | Out-File -FilePath $env:GITHUB_ENV -Append + # Windows links LLVM-C dynamically to avoid the release archive's + # /MT CRT and bundled-rpmalloc collision with Rust's /MD binaries. + # Put the matching DLL beside every later cargo test/build process. + "C:\llvm\bin" | Out-File -FilePath $env:GITHUB_PATH -Append diff --git a/benchmarks/public_baseline.py b/benchmarks/public_baseline.py index 350392c0f9..764e7c50e7 100755 --- a/benchmarks/public_baseline.py +++ b/benchmarks/public_baseline.py @@ -141,7 +141,7 @@ def _is_resolved_path(value: Any) -> bool: absolute path — :func:`portable_path` deliberately emits repo-relative paths. """ text = str(value or "") - return bool(text) and (os.path.isabs(text) or os.sep in text) + return bool(text) and (os.path.isabs(text) or "/" in text or "\\" in text) def _cargo_profile_tables(data: bytes) -> bytes: @@ -176,8 +176,16 @@ def _cargo_profile_tables(data: bytes) -> bytes: return b"".join(kept) +def _normalize_checkout_newlines(data: bytes) -> bytes: + """Make text fingerprints independent of Git's checkout policy.""" + return data.replace(b"\r\n", b"\n") + + def _fingerprint_bytes(name: str) -> bytes: - data = (ROOT / name).read_bytes() + # Every fingerprint input is text. Git may materialize it as CRLF on a + # Windows checkout even though the canonical blob (and Linux/macOS + # checkouts) uses LF; that transport detail must not invalidate evidence. + data = _normalize_checkout_newlines((ROOT / name).read_bytes()) if name == "Cargo.toml": # The version normalization stays: `[workspace.package] version` is not # in a profile table, but keeping the substitution makes the intent diff --git a/crates/perry-codegen-arkts/src/tests.rs b/crates/perry-codegen-arkts/src/tests.rs index 1fedd244b6..27837a1a56 100644 --- a/crates/perry-codegen-arkts/src/tests.rs +++ b/crates/perry-codegen-arkts/src/tests.rs @@ -49,6 +49,7 @@ pub(crate) fn empty_module() -> Module { closure_display_names: std::collections::HashMap::new(), class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), + local_source_spans: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), gen_param_prologue_len: std::collections::HashMap::new(), } diff --git a/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs b/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs index e1dd5073cb..1ea2035cbf 100644 --- a/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs +++ b/crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs @@ -58,6 +58,7 @@ fn empty_module() -> Module { closure_display_names: std::collections::HashMap::new(), class_display_names: std::collections::HashMap::new(), closure_source_text: std::collections::HashMap::new(), + local_source_spans: std::collections::HashMap::new(), async_generator_funcs: std::collections::HashSet::new(), gen_param_prologue_len: std::collections::HashMap::new(), } diff --git a/crates/perry-codegen/Cargo.toml b/crates/perry-codegen/Cargo.toml index 11d6a676f3..05faf97664 100644 --- a/crates/perry-codegen/Cargo.toml +++ b/crates/perry-codegen/Cargo.toml @@ -51,9 +51,23 @@ log.workspace = true serde.workspace = true serde_json.workspace = true -inkwell = { version = "0.9.0", features = ["llvm22-1"], optional = true } + +# Keep these target lists aligned with `inprocess::global_init`. Inkwell's +# `target-all` default references backends Perry cannot emit and which the +# official Windows LLVM archive does not build. +[target.'cfg(not(windows))'.dependencies] +inkwell = { version = "0.9.0", default-features = false, features = ["llvm22-1", "target-x86", "target-aarch64"], optional = true } llvm-sys = { version = "221", optional = true } +# LLVM's official Windows static archives use a private /MT CRT and bundle +# rpmalloc, while Rust's MSVC target uses /MD. Passing LLVM-owned allocations +# across that boundary crashes even when the static link succeeds (#7985). +# The same release ships a complete LLVM-C.dll/import-library pair; link that +# on Windows and let build.rs locate its import library under the pinned prefix. +[target.'cfg(windows)'.dependencies] +inkwell = { version = "0.9.0", default-features = false, features = ["llvm22-1-no-llvm-linking", "target-x86", "target-aarch64"], optional = true } +llvm-sys = { version = "221", features = ["no-llvm-linking"], optional = true } + # Self dev-dependency (#7493). This is the whole mechanism by which the # integration suites under `tests/` — which link this crate as an ordinary # external consumer — can see `perry_codegen::testing`. Cargo supports diff --git a/crates/perry-codegen/build.rs b/crates/perry-codegen/build.rs new file mode 100644 index 0000000000..de8ed83994 --- /dev/null +++ b/crates/perry-codegen/build.rs @@ -0,0 +1,18 @@ +fn main() { + println!("cargo:rerun-if-env-changed=LLVM_SYS_221_PREFIX"); + + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + let prefix = std::env::var_os("LLVM_SYS_221_PREFIX").unwrap_or_else(|| { + panic!("LLVM_SYS_221_PREFIX must point to the LLVM 22 development archive on Windows") + }); + let lib_dir = std::path::PathBuf::from(prefix).join("lib"); + if !lib_dir.join("LLVM-C.lib").is_file() { + panic!("{} does not contain LLVM-C.lib", lib_dir.display()); + } + + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-lib=dylib=LLVM-C"); +} diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 1932d765e0..771611b002 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1,14 +1,9 @@ -//! Tail of `compile_module`: emits closure bodies, class methods + -//! ctors + statics, wrapper symbols (function-value wrappers, -//! ExternFuncRef closure wrappers, export-rename aliases + stubs, -//! method closure-call wrappers), namespace globals + extern declares, -//! the module entry function, and finally the string-pool init. -//! -//! Split out of `codegen/mod.rs` purely to keep mod.rs under the -//! 2000-line LOC budget. No behavior changes — the function body -//! below is a verbatim move of the original inline block. +//! Emits `compile_module` artifacts: closures, classes, wrappers, namespace +//! globals, the module entry function, and string-pool initialization. +//! Split from `codegen/mod.rs` to keep the compiler pipeline navigable. use std::collections::HashMap; +use std::time::Instant; use anyhow::{Context, Result}; use perry_hir::Module as HirModule; @@ -54,6 +49,7 @@ use super::string_pool::emit_string_pool; /// in-prelude local names so the moved block reads unchanged once /// destructured. pub(super) struct ModuleArtifactsCtx<'a> { + pub progress: &'a super::CompileProgress, pub llmod: &'a mut LlModule, pub target_triple: &'a str, pub strings: &'a mut StringPool, @@ -181,6 +177,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { // (auto-reborrowed on each per-function call site below); the // rest are shared borrows. let ModuleArtifactsCtx { + progress, llmod, target_triple, strings, @@ -230,8 +227,11 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { }; let module_reassigned_locals = crate::collectors::reassigned_locals_in_module(hir); + progress.checkpoint("reassigned-local analysis"); - for (func_id, closure_expr) in closures { + let closure_started = Instant::now(); + let closure_progress_step = (closures.len() / 20).max(1); + for (closure_index, (func_id, closure_expr)) in closures.iter().enumerate() { if cross_module.typed_f64_closures.contains(func_id) { compile_typed_f64_closure( llmod, @@ -295,13 +295,20 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { cross_module, ) .with_context(|| format!("lowering closure func_id={}", func_id))?; + let done = closure_index + 1; + if done == closures.len() || done % closure_progress_step == 0 { + progress.items("closure bodies", done, closures.len(), closure_started); + } } + progress.checkpoint("closure bodies"); // Lower each class method as `perry_method_____( // this_box, arg0, arg1, ...) -> double`. Methods are emitted as // standalone LLVM functions; the dispatch in `lower_call` calls // them directly. - for class in &hir.classes { + let classes_started = Instant::now(); + let class_progress_step = (hir.classes.len() / 20).max(1); + for (class_index, class) in hir.classes.iter().enumerate() { for method in &class.methods { let typed_public_trampoline = if cross_module .typed_f64_methods @@ -765,8 +772,19 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { ) })?; } + let done = class_index + 1; + if done == hir.classes.len() || done % class_progress_step == 0 { + progress.items( + "classes (methods, constructors, and statics)", + done, + hir.classes.len(), + classes_started, + ); + } } + progress.checkpoint("class methods, constructors, and statics"); + // Emit FuncRef-as-value wrappers. For each user function, generate // a thin wrapper `__perry_wrap_` whose signature matches the // closure-call ABI: `double(i64 this_closure, double arg0, double @@ -1147,6 +1165,8 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { } } + progress.checkpoint("top-level function value wrappers and aliases"); + // Issue #774: emit closure-call wrappers for class instance methods // so `Expr::SuperPropertyGet` (value-form `super.`) can // materialize them via `js_closure_alloc_singleton(@__perry_wrap_)`. @@ -1395,6 +1415,8 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { } } + progress.checkpoint("method, fallback, and imported function wrappers"); + // Issue #100: emit the per-module `@__perry_ns_` global iff // this module is the target of at least one dynamic `import()` site // anywhere in the program. Defined here with external linkage so the @@ -1518,6 +1540,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { &namespace_key_globals, ) .with_context(|| format!("lowering entry of module '{}'", hir.name))?; + progress.checkpoint("namespace setup and module entry body"); // Issue #392: pre-intern every user-class method name into the // string pool so `emit_string_pool` (which takes `&strings`) can @@ -1941,6 +1964,8 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { }) .collect(); + progress.checkpoint("runtime registration metadata"); + emit_string_pool( llmod, strings, @@ -1968,6 +1993,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { &user_fn_display_names, &user_fn_source, ); + progress.checkpoint("string pool and registration initializer"); Ok(()) } diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 21eb204c00..5c6fc15e84 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -544,6 +544,21 @@ pub(super) fn compile_closure( _ => return Err(anyhow!("compile_closure: expected Expr::Closure")), }; + // A LocalId is module-unique, but a closure can only observe ids referenced + // or declared in its own body (plus its parameters/capture list). Older + // code cloned the complete module-wide boxed/type/reassignment tables into + // every closure's FnCtx. Generated bundles contain thousands of closures, + // making that O(closures * module locals) in both time and retained memory. + // Build the precise key set once and project each global oracle through it. + let mut closure_referenced_ids: HashSet = HashSet::new(); + collect_ref_ids_in_stmts(body, &mut closure_referenced_ids); + let mut closure_declared_ids: HashSet = HashSet::new(); + collect_let_ids(body, &mut closure_declared_ids); + let mut closure_relevant_ids = closure_referenced_ids.clone(); + closure_relevant_ids.extend(closure_declared_ids.iter().copied()); + closure_relevant_ids.extend(params.iter().map(|p| p.id)); + closure_relevant_ids.extend(captures.iter().copied()); + let public_llvm_name = format!("perry_closure_{}__{}", module_prefix, func_id); let typed_public_trampoline = if cross_module.typed_f64_closures.contains(&func_id) { Some(TypedFunctionTrampolineKind::F64) @@ -616,7 +631,11 @@ pub(super) fn compile_closure( let _ = lf.create_block("entry"); - let mut closure_boxed_vars = module_boxed_vars.clone(); + let mut closure_boxed_vars: HashSet = closure_relevant_ids + .iter() + .filter(|id| module_boxed_vars.contains(id)) + .copied() + .collect(); super::arguments::add_arguments_mapped_boxes(params, &mut closure_boxed_vars); // Allocate slots for the closure's own params (captures don't get @@ -645,8 +664,10 @@ pub(super) fn compile_closure( // typed fast path and return undefined. let mut local_types: HashMap = params.iter().map(|p| (p.id, p.ty.clone())).collect(); - for (id, ty) in module_receiver_types.iter() { - local_types.entry(*id).or_insert_with(|| ty.clone()); + for id in &closure_relevant_ids { + if let Some(ty) = module_receiver_types.get(id) { + local_types.entry(*id).or_insert_with(|| ty.clone()); + } } // Build the capture map: each captured LocalId gets the index it @@ -668,17 +689,13 @@ pub(super) fn compile_closure( .filter(|id| !module_globals.contains_key(id)) .collect(); { - let mut referenced: std::collections::HashSet = std::collections::HashSet::new(); - collect_ref_ids_in_stmts(body, &mut referenced); - let mut inner_lets: std::collections::HashSet = std::collections::HashSet::new(); - collect_let_ids(body, &mut inner_lets); let param_ids: std::collections::HashSet = params.iter().map(|p| p.id).collect(); let already: std::collections::HashSet = auto_captures.iter().copied().collect(); - let mut sorted: Vec = referenced.into_iter().collect(); + let mut sorted: Vec = closure_referenced_ids.iter().copied().collect(); sorted.sort(); for id in sorted { if !param_ids.contains(&id) - && !inner_lets.contains(&id) + && !closure_declared_ids.contains(&id) && !already.contains(&id) && !module_globals.contains_key(&id) { @@ -837,7 +854,11 @@ pub(super) fn compile_closure( std::collections::HashSet::new() }; - let mut reassigned_locals = module_reassigned_locals.clone(); + let mut reassigned_locals: HashSet = closure_relevant_ids + .iter() + .filter(|id| module_reassigned_locals.contains(id)) + .copied() + .collect(); reassigned_locals.extend(crate::collectors::reassigned_locals(body)); // #7055: spill the closure's own `%this_closure` pointer into a diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 8e47d7eb26..88118bf5cd 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -491,27 +491,65 @@ pub(crate) fn decide_full_outline_ic(callable_count: usize) -> bool { /// 13MB bundle); splitting bounds peak compiler memory to roughly whole/N. /// /// `PERRY_CODEGEN_UNITS=N` forces exactly N units (1 disables splitting). -/// Otherwise auto: 1 unit until the module's callable count crosses a floor, -/// then `ceil(callables / target_per_unit)`, capped — so ordinary per-file -/// modules stay on the single-unit path (default 1, zero behavior change). -/// `PERRY_CODEGEN_UNIT_SIZE` overrides the target callables-per-unit. -pub(crate) fn decide_codegen_units(callable_count: usize) -> usize { +/// Otherwise auto: choose the larger of the callable-count estimate and the +/// post-lowering generated-IR estimate. The latter matters for generated and +/// minified bundles: one HIR callable can expand into many large helper/wrapper +/// bodies, so callable count alone left 100+ MiB LLVM modules unsplit. +/// +/// `PERRY_CODEGEN_UNIT_SIZE` overrides callables/unit; +/// `PERRY_CODEGEN_UNIT_BYTES` overrides generated IR bytes/unit. +pub(crate) fn decide_codegen_units(callable_count: usize, estimated_ir_bytes: usize) -> usize { if let Ok(v) = std::env::var("PERRY_CODEGEN_UNITS") { if let Ok(n) = v.parse::() { return n.max(1); } } - const MIN_CALLABLES_TO_SPLIT: usize = 8000; - const MAX_UNITS: usize = 48; - let target = std::env::var("PERRY_CODEGEN_UNIT_SIZE") + const MIN_CALLABLES_TO_SPLIT: usize = 8_000; + // Real-app calibration (OpenCode's split CLI): the estimator reports only + // function bodies, while LLVM also receives globals, declarations, + // attributes, and metadata. Modules estimated just below the old 48 MiB + // gate therefore reached LLVM as 44--46 MiB single units. Late in a large + // build, with the collected program HIR and cached-object bookkeeping + // resident, two such units expanded the process/pagefile until C: had less + // than 1 GiB free. Start splitting at 16 MiB of estimated function IR and + // require at least two units once the gate is crossed; the 20 MiB target + // remains the balancing goal for larger modules. + const MIN_IR_BYTES_TO_SPLIT: usize = 16 * 1024 * 1024; + const DEFAULT_IR_BYTES_PER_UNIT: usize = 20 * 1024 * 1024; + const MAX_UNITS: usize = 128; + let target_callables = std::env::var("PERRY_CODEGEN_UNIT_SIZE") .ok() .and_then(|v| v.parse::().ok()) .filter(|&n| n > 0) .unwrap_or(6000); - if callable_count < MIN_CALLABLES_TO_SPLIT { - return 1; + let target_ir_bytes = std::env::var("PERRY_CODEGEN_UNIT_BYTES") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&n| n > 0) + .unwrap_or(DEFAULT_IR_BYTES_PER_UNIT); + let by_callables = if callable_count >= MIN_CALLABLES_TO_SPLIT { + callable_count.div_ceil(target_callables) + } else { + 1 + }; + let by_ir = if estimated_ir_bytes >= MIN_IR_BYTES_TO_SPLIT { + estimated_ir_bytes.div_ceil(target_ir_bytes).max(2) + } else { + 1 + }; + by_callables.max(by_ir).clamp(1, MAX_UNITS) +} + +#[cfg(test)] +mod codegen_unit_tests { + use super::decide_codegen_units; + + #[test] + fn splits_medium_generated_modules_before_the_llvm_memory_cliff() { + assert_eq!(decide_codegen_units(800, 15 * 1024 * 1024), 1); + assert_eq!(decide_codegen_units(800, 16 * 1024 * 1024), 2); + assert_eq!(decide_codegen_units(800, 45 * 1024 * 1024), 3); } - callable_count.div_ceil(target).clamp(1, MAX_UNITS) } pub(super) fn scoped_fn_name(module_prefix: &str, hir_name: &str) -> String { diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index b9995a86e0..c030d8b046 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -29,7 +29,11 @@ //! Anything else (objects, arrays, classes, closures, async, imports, …) //! errors with an actionable "Phase X not yet supported" message. +use std::cell::Cell; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use perry_hir::Module as HirModule; @@ -39,6 +43,137 @@ use crate::runtime_decls; use crate::strings::StringPool; use crate::types::{LlvmType, DOUBLE, I32, I64}; +pub(super) struct CompileProgress { + enabled: bool, + started: Instant, + last_checkpoint: Cell, + phase: Arc, + stop: Arc, + worker: Option>, + module: String, +} + +impl CompileProgress { + fn new(module: &str, callables: usize) -> Self { + let progress_mode = std::env::var("PERRY_CODEGEN_PROGRESS").unwrap_or_default(); + // Avoid three status lines for every tiny module in dependency-heavy + // projects. Long modules get automatic reporting; `=all` is the + // diagnostic override when per-module detail is wanted regardless. + let enabled = progress_mode == "all" || (progress_mode == "1" && callables >= 1_000); + let started = Instant::now(); + let phase = Arc::new(AtomicU8::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + if enabled { + eprintln!("[perry] codegen: lowering {module} ({callables} callables)"); + } + let worker = enabled.then(|| { + let phase = Arc::clone(&phase); + let stop = Arc::clone(&stop); + let module = module.to_string(); + std::thread::Builder::new() + .name("perry-progress".into()) + .spawn(move || { + while !stop.load(Ordering::Relaxed) { + std::thread::park_timeout(Duration::from_secs(30)); + if stop.load(Ordering::Relaxed) { + break; + } + let label = match phase.load(Ordering::Relaxed) { + 0 => "lowering HIR", + 1 => "finalizing generated IR", + 2 => "partitioning/freezing/LLVM codegen", + 3 => "releasing generated IR", + _ => "LLVM optimization and object emission", + }; + eprintln!( + "[perry] codegen: {module}: {label}, elapsed {:.1} min", + started.elapsed().as_secs_f64() / 60.0 + ); + } + }) + .expect("spawn Perry progress reporter") + }); + Self { + enabled, + started, + last_checkpoint: Cell::new(started), + phase, + stop, + worker, + module: module.to_string(), + } + } + + fn phase(&self, phase: u8, label: &str) { + self.phase.store(phase, Ordering::Relaxed); + if self.enabled { + eprintln!( + "[perry] codegen: {}: {} ({:.1}s elapsed)", + self.module, + label, + self.started.elapsed().as_secs_f64() + ); + } + } + + /// Report a completed lowering subphase. Large generated bundles can spend + /// minutes before LLVM sees any IR, so a coarse heartbeat is not enough to + /// distinguish useful progress from a stuck compiler. Keeping the lap time + /// here also makes the output directly usable as a lightweight profile. + pub(super) fn checkpoint(&self, label: &str) { + let now = Instant::now(); + let previous = self.last_checkpoint.replace(now); + if self.enabled { + eprintln!( + "[perry] codegen: {}: {} in {:.1}s ({:.1}s total)", + self.module, + label, + now.duration_since(previous).as_secs_f64(), + now.duration_since(self.started).as_secs_f64() + ); + } + } + + pub(super) fn items(&self, label: &str, done: usize, total: usize, started: Instant) { + if !self.enabled || total == 0 { + return; + } + let elapsed = started.elapsed().as_secs_f64(); + let eta = if done == 0 { + 0.0 + } else { + elapsed * (total.saturating_sub(done)) as f64 / done as f64 + }; + eprintln!( + "[perry] codegen: {}: {} {}/{} ({:.0}%; {:.1}s elapsed; ETA ~{:.1}s)", + self.module, + label, + done, + total, + done as f64 * 100.0 / total as f64, + elapsed, + eta + ); + } +} + +impl Drop for CompileProgress { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + worker.thread().unpark(); + let _ = worker.join(); + } + if self.enabled { + eprintln!( + "[perry] codegen: {}: stage finished in {:.1}s", + self.module, + self.started.elapsed().as_secs_f64() + ); + } + } +} + pub(crate) mod arguments; mod artifacts; mod boxed_locals; @@ -187,6 +322,7 @@ pub(crate) fn static_method_registry_key(method_name: &str) -> String { /// guarantee — do not change to `&mut` without also moving the cache /// hash to AFTER codegen. pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> { + let progress = CompileProgress::new(&hir.name, module_callable_count(hir)); let triple = opts.target.clone().unwrap_or_else(default_target_triple); let fp_flags = crate::block::FpFlags::new(opts.fast_math, opts.fp_contract_mode); @@ -1136,6 +1272,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> 0.0 } // macOS / darwin default }; + progress.checkpoint("symbol tables and initial declarations"); + // Pre-scan hir.init for compile-time constant variables. These are // `declare const __platform__: number` / `declare const __plugins__: number` // that other backends (JS, WASM) inject at build time. The LLVM backend @@ -1316,6 +1454,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } + progress.checkpoint("module constant and export analysis"); + // Build the cross-module context bundle from CompileOptions. let disable_buffer_fast_path = opts.disable_buffer_fast_path || std::env::var("PERRY_DISABLE_BUFFER_FAST_PATH") @@ -1412,6 +1552,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let mut typed_string_methods = std::collections::HashSet::new(); let mut typed_i1_method_param_reps = std::collections::HashMap::new(); let mut typed_f64_receiver_methods = std::collections::HashMap::new(); + progress.checkpoint("cross-module and typed-ABI analysis"); + // Module-wide dispatch/barrier facts. Hoisted above the typed-clone // eligibility loop because representation-selection Phase 5a's // proven-`this` admission consults them (§5.2 shape barriers, the @@ -1962,6 +2104,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> func_synthetic_arguments, } = func_registry::build_func_registry(hir, &module_prefix); + progress.checkpoint("class dispatch and representation analysis"); + // Module-wide boxed-var union + LocalId→Type map. See `boxed_locals`. let module_boxed_vars = boxed_locals::collect_module_boxed_vars(hir); // #6369: the *receiver-type oracle* for closure bodies — every module-wide @@ -2333,6 +2477,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> cross_module.spec_ta_bindings = spec_facts.ta_bindings; } + progress.checkpoint("locals, closures, and module globals analysis"); + // Emit internal typed-f64 clones before their public/generic wrappers. The // public wrapper keeps the JSValue ABI; it and direct proven numeric call // sites can call the internal clone. @@ -2408,8 +2554,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .with_context(|| format!("lowering specialized entry for function '{}'", f.name))?; } - // Lower each user function into the module. - for f in &hir.functions { + progress.checkpoint("typed top-level function clones"); + + // Lower each user function into the module. Generated single-file bundles + // can spend minutes here; report real item progress instead of making the + // 30-second heartbeat the only proof that lowering is advancing. + let function_bodies_started = Instant::now(); + let function_bodies_progress_step = (hir.functions.len() / 20).max(1); + for (function_index, f) in hir.functions.iter().enumerate() { let typed_public_trampoline = if cross_module.typed_f64_functions.contains(&f.id) { Some(typed_abi::TypedFunctionTrampolineKind::F64) } else if cross_module.typed_i32_functions.contains(&f.id) { @@ -2443,6 +2595,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> None, ) .with_context(|| format!("lowering function '{}'", f.name))?; + let done = function_index + 1; + if done == hir.functions.len() || done % function_bodies_progress_step == 0 { + progress.items( + "top-level function bodies", + done, + hir.functions.len(), + function_bodies_started, + ); + } } // Closes #460: emit forwarding wrappers for `export { local as exported }` @@ -2554,6 +2715,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } + progress.checkpoint("top-level function bodies and export stubs"); + // ── End of compile_module prelude (data + initial emission). ── // The remainder (closures, methods, ctors, statics, function / // ExternFuncRef / export-rename / unknown-func / method @@ -2562,6 +2725,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // `artifacts::emit_module_artifacts`. Behavior is unchanged — // see the doc on that fn for the split rationale. emit_module_artifacts(ModuleArtifactsCtx { + progress: &progress, llmod: &mut llmod, target_triple: &triple, strings: &mut strings, @@ -2617,6 +2781,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // renderer and the in-process constructor see the same IR; a pass living in // one of them would silently not apply to the other. // See `crate::root_reload`. + progress.phase(1, "lowering complete; finalizing generated IR"); crate::root_reload::apply_to_module(&mut llmod); let verify_native_regions = opts.verify_native_regions @@ -2639,12 +2804,25 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let n_units = if opts.emit_ir_only { 1 } else { - decide_codegen_units(module_callable_count(hir)) + decide_codegen_units( + module_callable_count(hir), + llmod.estimated_function_ir_bytes(), + ) }; if n_units > 1 { + progress.phase(2, &format!("partitioning into {n_units} codegen units")); if let Some(result) = - try_native_units(&llmod, n_units, opts.target.as_deref(), &module_prefix) + try_native_units(&mut llmod, n_units, opts.target.as_deref(), &module_prefix) { + // `result` already contains the final object/archive here. The + // generated `LlModule` can own millions of small allocations in a + // minified bundle, and Rust must destroy that graph before this + // function (and its `CompileProgress` guard) can return. On the + // 8.8 MiB OpenCode code-mode chunk this took about five minutes + // after LLVM reported 77/77, previously making the build look + // stuck in LLVM. Name the real phase while the heartbeat thread is + // still alive; a future arena-backed IR can make this O(arenas). + progress.phase(3, "object ready; releasing generated IR"); return result; } let units = llmod.render_codegen_units(n_units); @@ -2702,18 +2880,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> /// exp/llvm-inprocess: unit-split twin of [`try_native_construction`]. #[cfg(feature = "llvm-inprocess")] fn try_native_units( - llmod: &crate::module::LlModule, + llmod: &mut crate::module::LlModule, n_units: usize, target: Option<&str>, module_prefix: &str, ) -> Option>> { - // SEH funclets are the one EH shape the in-process reader cannot - // construct (see LlModule::needs_eh_funclets). Decline to the textual - // path rather than failing the compile. - if llmod.needs_eh_funclets() { - return None; - } - match crate::native_emit::native_mode() { + // Personality-carrying Windows functions are parsed as small textual + // islands inside each otherwise-native unit; ordinary functions still use + // typed C-API construction. See `native_emit::freeze_unit`. + match crate::native_emit::native_units_mode() { crate::native_emit::NativeMode::Off => None, crate::native_emit::NativeMode::Native => Some( crate::native_emit::compile_module_units_native(llmod, n_units, target, module_prefix), @@ -2726,7 +2901,7 @@ fn try_native_units( #[cfg(not(feature = "llvm-inprocess"))] fn try_native_units( - _llmod: &crate::module::LlModule, + _llmod: &mut crate::module::LlModule, _n_units: usize, _target: Option<&str>, _module_prefix: &str, diff --git a/crates/perry-codegen/src/collectors/loop_bounded_i32.rs b/crates/perry-codegen/src/collectors/loop_bounded_i32.rs index fa5024e228..86256d4e94 100644 --- a/crates/perry-codegen/src/collectors/loop_bounded_i32.rs +++ b/crates/perry-codegen/src/collectors/loop_bounded_i32.rs @@ -1088,7 +1088,7 @@ fn step_magnitude_bound( } let divisor = integer_literal(right)?; let magnitude = u128::from(divisor.unsigned_abs()); - (magnitude > 0).then_some(magnitude - 1) + (magnitude > 0).then(|| magnitude - 1) } // Bitwise operands are ToInt32-coerced. For a non-negative literal // mask, the source literal itself is a conservative magnitude bound diff --git a/crates/perry-codegen/src/dialect/mod.rs b/crates/perry-codegen/src/dialect/mod.rs index 8d6a0f1678..2082a56410 100644 --- a/crates/perry-codegen/src/dialect/mod.rs +++ b/crates/perry-codegen/src/dialect/mod.rs @@ -38,6 +38,7 @@ mod tests; /// native path pre-declares every define before reading any body — calls to /// module-internal functions are forward references at module scope, exactly /// like registers are at function scope. +#[allow(dead_code)] // retained as the text-path oracle for native-emission debugging pub(crate) fn predeclare_function_from_text<'ctx>( context: &'ctx Context, module: &Module<'ctx>, @@ -107,6 +108,7 @@ impl<'ctx, 'm> FnStream<'ctx, 'm> { /// Parse `fn_text` (a complete `define ... { ... }`) and build it into /// `module`. Returns the number of instructions constructed. +#[allow(dead_code)] // retained as the text-path oracle for native-emission debugging pub(crate) fn add_function_from_text<'ctx>( context: &'ctx Context, module: &Module<'ctx>, diff --git a/crates/perry-codegen/src/expr/index_set_guarded.rs b/crates/perry-codegen/src/expr/index_set_guarded.rs index 52e9540914..fc96ecfa7a 100644 --- a/crates/perry-codegen/src/expr/index_set_guarded.rs +++ b/crates/perry-codegen/src/expr/index_set_guarded.rs @@ -41,7 +41,7 @@ use anyhow::Result; use crate::nanbox::POINTER_MASK_I64; -use crate::types::{DOUBLE, I1, I16, I32, I64, I8}; +use crate::types::{I1, I16, I32, I64, I8}; use super::{ emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_scalar_aware_on_block, diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 0a3c8ff5e4..34eda40c66 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -288,23 +288,31 @@ fn generic_property_get_tries_ways_before_calling_the_miss_handler() { #[test] fn pic_miss_reuses_the_token_blocks_values_instead_of_re_deriving_them() { let ir = emit(false, None); + let main_start = ir + .find("define i32 @main()") + .expect("entry module should define main"); + let main_rest = &ir[main_start..]; + let main_end = main_rest + .find("\n}\n") + .expect("main should have a closing brace"); + let main = &main_rest[..main_end]; assert!( - ir.contains("@perry_ic_"), + main.contains("@perry_ic_"), "test premise: the generic read reaches the inline PIC:\n{ir}" ); assert!( - ir.contains("\npic.miss.cold"), + main.contains("\npic.miss.cold"), "the two receiver-validation failures need their own landing block, \ otherwise pic.miss is not dominated by pic.token:\n{ir}" ); - let epoch_loads = ir.matches("load i64, ptr @PERRY_IC_EPOCH").count(); + let epoch_loads = main.matches("load i64, ptr @PERRY_IC_EPOCH").count(); assert_eq!( epoch_loads, 1, "one generic read must load @PERRY_IC_EPOCH exactly once; a second \ load means the way block re-derived the epoch predicate:\n{ir}" ); assert!( - !ir.contains("ptrtoint ptr @perry_ic_"), + !main.contains("ptrtoint ptr @perry_ic_"), "the small-handle sentinel select only existed because an invalid \ receiver could reach the way compares; it must be gone:\n{ir}" ); @@ -313,7 +321,7 @@ fn pic_miss_reuses_the_token_blocks_values_instead_of_re_deriving_them() { ("icmp eq i8 ", "the GC_TYPE_OBJECT compare"), ("icmp eq i32 %", "the closure-magic / object_type compares"), ] { - let n = ir.matches(needle).count(); + let n = main.matches(needle).count(); assert!( n <= 2, "{what} appears {n} times — the miss block is re-deriving the \ diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index 1310dc1c34..c6f59bc789 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -25,7 +25,17 @@ const LAYOUT_SIDE_MASK_INTACT_I16: &str = "-28672"; /// compatibility wrapper, which conservatively marks the parent span. /// The env gate is read once and OnceLock-cached at codegen time. pub(crate) fn emit_write_barrier(ctx: &mut FnCtx<'_>, parent_bits: &str, child_bits: &str) { - if !crate::codegen::write_barriers_enabled() { + // Expression lowering can complete abruptly (for example, an unsupported + // dynamic `new Worker(path)` lowers to a throwing call + `unreachable`) + // before an enclosing captured-local assignment reaches its post-store + // barrier. `LlBlock` deliberately drops instructions appended after a + // terminator, but creating the barrier diamond here would still publish + // two new blocks and put `ctx.current_block` on the second one. The first + // then contains uses of the parent/child registers whose definitions were + // dropped with the terminated assignment block, leaving invalid orphan IR. + // A terminated path performed no store and cannot reach a barrier, so it + // is both correct and necessary to leave the CFG untouched. + if !crate::codegen::write_barriers_enabled() || ctx.block().is_terminated() { return; } let child_bits_value = LoweredValue::js_value_bits(child_bits.to_string()); diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index f4df38f3bd..c260ab236a 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -829,7 +829,9 @@ mod tests { /// `record_ffi_call` on any other thread stops writing into this test's /// `USED_PROVIDERS` snapshot. Taking the lock alone is not enough: it only /// serialises tests that take it, and a neighbouring lowering test does not. - struct ProviderTestGuard(std::sync::MutexGuard<'static, ()>); + struct ProviderTestGuard { + _lock: std::sync::MutexGuard<'static, ()>, + } impl ProviderTestGuard { fn new() -> Self { @@ -837,7 +839,7 @@ mod tests { if let Ok(mut t) = super::PROVIDER_TEST_THREAD.lock() { *t = Some(std::thread::current().id()); } - ProviderTestGuard(g) + ProviderTestGuard { _lock: g } } } diff --git a/crates/perry-codegen/src/function/precise_roots.rs b/crates/perry-codegen/src/function/precise_roots.rs index c74284b70c..eebf356f5d 100644 --- a/crates/perry-codegen/src/function/precise_roots.rs +++ b/crates/perry-codegen/src/function/precise_roots.rs @@ -195,24 +195,24 @@ pub(super) fn lower_precise_roots_to_native_stack( slot_count: u32, ) -> String { let lines: Vec<&str> = ir.lines().collect(); - let mut roots: Vec> = vec![None; slot_count as usize]; + // A logical shadow slot can be rebound to a different physical alloca when + // disjoint source scopes reuse one LocalId (for example, the synthetic + // locals produced by `using` lowering). The runtime shadow stack needs only + // the currently bound address, but RS4GC roots physical allocas, so retain + // every address ever bound to the logical slot. Each is null-initialized + // below, making an inactive scope's alloca a harmless conservative root. + let mut roots: Vec> = vec![Vec::new(); slot_count as usize]; for line in &lines { if let Some((idx, ptr)) = parse_shadow_bind(line) { - if let Some(root) = roots.get_mut(idx) { - match root { - Some(existing) => { - debug_assert_eq!( - existing, &ptr, - "one precise-root slot must not bind two native allocas" - ); - } - None => *root = Some(ptr), + if let Some(slot_roots) = roots.get_mut(idx) { + if !slot_roots.contains(&ptr) { + slot_roots.push(ptr); } } } } - let root_ptrs: Vec = roots.iter().flatten().cloned().collect(); + let root_ptrs: Vec = roots.into_iter().flatten().collect(); let report = crate::statepoint_report::enabled().then(|| { crate::statepoint_report::FunctionRecord::new( function_name, @@ -246,6 +246,31 @@ pub(super) fn lower_precise_roots_to_native_stack( ), } } + +#[cfg(test)] +mod tests { + use super::lower_precise_roots_to_native_stack; + + #[test] + fn one_logical_slot_can_root_disjoint_physical_allocas() { + let ir = r#"define void @f() { + %a = alloca i64 + %b = alloca double + call void @js_shadow_slot_bind(i32 0, ptr %a) + call void @may_collect() + call void @js_shadow_slot_bind(i32 0, ptr %b) + call void @may_collect() + ret void +} +"#; + + let lowered = lower_precise_roots_to_native_stack(ir, "f", 1); + assert!(lowered.contains("%a = alloca ptr addrspace(1)")); + assert!(lowered.contains("%b = alloca ptr addrspace(1)")); + assert!(!lowered.contains("@js_shadow_slot_bind")); + } +} + pub(super) fn retype_landing_pads_for_statepoints(ir: &str) -> String { const ITANIUM: &str = "landingpad { ptr, i32 } catch ptr null"; if !ir.contains(ITANIUM) { diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index b08c29f008..8bae05d886 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -82,6 +82,7 @@ const ELF_SECTION: &str = ".perry_gcmap,\"awR\",@progbits"; const COFF_SECTION: &str = ".pgcmap,\"dw\""; /// What the runtime looks for in a PE image. Must match `COFF_SECTION`'s name /// and stay within eight bytes. +#[cfg(test)] pub(crate) const COFF_SECTION_NAME: &str = ".pgcmap"; /// LLVM stack-map v3 location kinds. Only these two describe a frame slot; @@ -245,7 +246,9 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result { let mut end_line = lines.len(); for (index, raw) in lines.iter().enumerate().skip(start_line + 1) { - let line = raw.trim(); + // LLVM's assembly memory buffer may expose its terminating NUL as the + // final line. It is not an assembler directive and emits no bytes. + let line = raw.trim().trim_matches('\0').trim(); // The block runs to the next section or to the Mach-O epilogue. // // The shorthand section directives are terminators too. Missing one @@ -1471,6 +1474,19 @@ mod tests { assert_eq!(block.bytes.len(), 8); } + #[test] + fn trailing_llvm_buffer_nul_is_not_an_assembly_directive() { + let asm = concat!( + "\t.section\t.llvm_stackmaps,\"a\",@progbits\n", + "__LLVM_StackMaps:\n", + "\t.byte\t3\n", + "\0\n", + ); + let lines: Vec<&str> = asm.lines().collect(); + let block = super::parse_block(&lines, 4).expect("trailing NUL must be ignored"); + assert_eq!(block.bytes, vec![3]); + } + #[test] fn expression_operators_are_not_mistaken_for_assignments() { for line in [ diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index 57c1e6bb96..e339663383 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -354,12 +354,23 @@ fn ll_size_opt_max_fn_bytes() -> usize { /// ordinary functions (size-optimize, big `__text` win), `-O0` when it is a /// pathological few-giant-function monolith (`#4880`). `ll_fn_count` is the /// number of `define` functions in the unit. -fn oversized_opt_flag(ll_byte_size: usize, ll_fn_count: usize) -> &'static str { +fn oversized_opt_flag( + ll_byte_size: usize, + ll_fn_count: usize, + max_fn_bytes: Option, +) -> &'static str { match std::env::var("PERRY_LL_SIZE_OPT").as_deref() { Ok("0") | Ok("off") | Ok("false") => return "-O0", Ok("1") | Ok("on") | Ok("true") => return "-Os", _ => {} } + // Native construction knows each function's render-free size. Do not let + // hundreds of small functions dilute one pathological generated function's + // average: that sent a 20+ MiB body through -Os in the Claude bundle and + // spent minutes in LLVM where the same body finishes in seconds at -O0. + if max_fn_bytes.is_some_and(|bytes| bytes > ll_o0_threshold_bytes()) { + return "-O0"; + } let avg_fn_bytes = ll_byte_size / ll_fn_count.max(1); if avg_fn_bytes <= ll_size_opt_max_fn_bytes() { "-Os" @@ -382,6 +393,7 @@ fn build_clang_compile_plan( target_triple: Option<&str>, ll_byte_size: usize, ll_fn_count: usize, + max_fn_bytes: Option, debug_symbols: bool, ) -> ClangCompilePlan { let effective_target = target_triple @@ -400,7 +412,7 @@ fn build_clang_compile_plan( // oversized_opt_flag. let o0_threshold = ll_o0_threshold_bytes(); let opt_flag = if o0_threshold > 0 && ll_byte_size > o0_threshold { - let flag = oversized_opt_flag(ll_byte_size, ll_fn_count); + let flag = oversized_opt_flag(ll_byte_size, ll_fn_count, max_fn_bytes); eprintln!( "perry: module IR is {:.1} MB (> {:.1} MB), {} functions \ (~{:.0} KB/fn); compiling at {} instead of -O3 so LLVM's -O1+ \ @@ -658,6 +670,7 @@ pub(crate) fn native_plan_args( target_triple: Option<&str>, est_ll_bytes: usize, ll_fn_count: usize, + max_fn_bytes: usize, ) -> (String, Vec) { let plan = build_clang_compile_plan( PathBuf::from("(in-process)"), @@ -666,6 +679,7 @@ pub(crate) fn native_plan_args( target_triple, est_ll_bytes, ll_fn_count, + Some(max_fn_bytes), env::var_os("PERRY_DEBUG_SYMBOLS").is_some(), ); (plan.effective_target, plan.clang_args) @@ -740,10 +754,11 @@ pub(crate) fn finish_native_emission( /// (#7339). /// /// `PERRY_LLVM_INPROCESS=0`/`off`/`false` reverts to the clang subprocess for -/// bisection. `=native` additionally builds function bodies through the C API -/// instead of rendering per-function text; that is byte-identical on the -/// 81-module zod corpus but has narrower CI coverage, so it stays opt-in until -/// that widens. +/// bisection. Large codegen-unit-split modules default to direct C-API native +/// construction because materializing their textual IR is itself a dominant +/// serial cost; `=1` selects the legacy in-process text transport, while +/// `=diff` builds both arms and compares them. Small modules retain the mature +/// text transport unless `=native` is explicit. /// /// The value participates in both the build cache and the object cache keys, /// so the backends can never share a cached object. @@ -781,6 +796,7 @@ fn compile_ll_inprocess_in( target_triple, ll_text.len(), count_ll_functions(ll_text), + None, policy.debug_symbols, ); // #7131 parity: the module identifier is the content-addressed basename, @@ -961,6 +977,7 @@ fn compile_ll_to_object_in( target_triple, ll_text.len(), count_ll_functions(ll_text), + None, policy.debug_symbols, ); @@ -1156,8 +1173,11 @@ pub fn compile_units_to_object(units: &[String], target_triple: Option<&str>) -> merge_unit_objects(&objs) } -/// Partial-link (`ld -r`) already-compiled codegen-unit objects into one -/// object. Shared by the text path above and the native construction path +/// Combine already-compiled codegen-unit objects into one linker input. +/// Unix uses a relocatable partial link (`ld -r`). COFF has no equivalent, +/// so Windows stores the objects in a static archive; the final MSVC linker +/// accepts that archive anywhere it accepts the former single object. +/// Shared by the text path above and the native construction path /// (`native_emit::compile_module_units_native`). pub(crate) fn merge_unit_objects(objs: &[Vec]) -> Result> { let tmp_dir = env::temp_dir(); @@ -1172,23 +1192,44 @@ pub(crate) fn merge_unit_objects(objs: &[Vec]) -> Result> { obj_paths.push(p); } + #[cfg(target_os = "windows")] + let combined = tmp_dir.join(format!("perry_cgu_{}_{}_combined.lib", pid, nonce)); + #[cfg(not(target_os = "windows"))] let combined = tmp_dir.join(format!("perry_cgu_{}_{}_combined.o", pid, nonce)); - let ld = env::var("PERRY_LD").unwrap_or_else(|_| "ld".to_string()); - let mut cmd = Command::new(&ld); + + #[cfg(target_os = "windows")] + let tool = env::var("PERRY_LLVM_LIB").unwrap_or_else(|_| "llvm-lib".to_string()); + #[cfg(not(target_os = "windows"))] + let tool = env::var("PERRY_LD").unwrap_or_else(|_| "ld".to_string()); + let mut cmd = Command::new(&tool); + #[cfg(target_os = "windows")] + cmd.arg(format!("/OUT:{}", combined.display())); + #[cfg(not(target_os = "windows"))] cmd.arg("-r").arg("-o").arg(&combined); for p in &obj_paths { cmd.arg(p); } - let out = cmd - .output() - .with_context(|| format!("failed to invoke partial linker `{} -r`", ld))?; + let out = cmd.output().with_context(|| { + #[cfg(target_os = "windows")] + { + format!("failed to invoke COFF codegen-unit archiver `{}`", tool) + } + #[cfg(not(target_os = "windows"))] + { + format!("failed to invoke partial linker `{} -r`", tool) + } + })?; let result = if out.status.success() { fs::read(&combined) .with_context(|| format!("failed to read merged object {}", combined.display())) } else { + #[cfg(target_os = "windows")] + let operation = format!("COFF archive `{}`", tool); + #[cfg(not(target_os = "windows"))] + let operation = format!("partial link `{} -r`", tool); Err(anyhow!( - "partial link `{} -r` of {} codegen units failed (status={}).\nstderr:\n{}", - ld, + "{} of {} codegen units failed (status={}).\nstderr:\n{}", + operation, objs.len(), out.status, String::from_utf8_lossy(&out.stderr) diff --git a/crates/perry-codegen/src/linker_tests.rs b/crates/perry-codegen/src/linker_tests.rs index 17e178a3d0..3b1dd6f84d 100644 --- a/crates/perry-codegen/src/linker_tests.rs +++ b/crates/perry-codegen/src/linker_tests.rs @@ -165,6 +165,7 @@ fn compile_plan_records_effective_target_and_native_tuning() { None, 0, 0, + None, false, ); assert!(plan.clang_args.contains(&"-fno-math-errno".to_string())); @@ -199,6 +200,7 @@ fn compile_plan_size_optimizes_oversized_many_function_module() { None, huge, many_funcs, + None, false, ); assert!(plan.clang_args.contains(&"-Os".to_string())); @@ -219,6 +221,7 @@ fn compile_plan_keeps_o0_for_oversized_giant_function_monolith() { None, huge, 2, // ~3 MB/fn — far above the density cap + None, false, ); assert!(plan.clang_args.contains(&"-O0".to_string())); @@ -235,6 +238,7 @@ fn compile_plan_skips_native_tuning_for_explicit_target() { Some("x86_64-unknown-linux-gnu"), 0, 0, + None, false, ); assert_eq!(plan.effective_target, "x86_64-unknown-linux-gnu"); @@ -328,6 +332,7 @@ fn compile_plan_metadata_json_contains_object_source() { Some("x86_64-unknown-linux-gnu"), 0, 0, + None, false, ); write_compile_plan_metadata(&plan, &temp).unwrap(); diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index a7f880ec2a..662422867c 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -24,7 +24,7 @@ use crate::expr::{ }; use crate::nanbox::{double_literal, POINTER_MASK_I64}; use crate::type_analysis::{is_global_constructor_expr, receiver_class_name}; -use crate::types::{DOUBLE, I32, I64, PTR}; +use crate::types::{DOUBLE, I64, PTR}; use super::try_emit_buffer_read_intrinsic; diff --git a/crates/perry-codegen/src/lower_string_method.rs b/crates/perry-codegen/src/lower_string_method.rs index 6b79300cfa..27704d9974 100644 --- a/crates/perry-codegen/src/lower_string_method.rs +++ b/crates/perry-codegen/src/lower_string_method.rs @@ -224,12 +224,6 @@ fn lower_string_method_dispatch( let recv_box = recv_box.to_string(); match property { "indexOf" => { - if args.len() > 2 { - bail!( - "perry-codegen: String.indexOf expects 0, 1 or 2 args, got {}", - args.len() - ); - } // No `searchString` → `undefined`, which `js_string_coerce` // stringifies to "undefined" (`"".indexOf()` === -1). let needle_box = if args.is_empty() { @@ -243,11 +237,14 @@ fn lower_string_method_dispatch( // ECMA-262 §22.1.3.8. A statically string-typed arg skips this. let needle_is_str = !args.is_empty() && is_string_expr(ctx, &args[0]); // Optional fromIndex. - let from_idx_double = if args.len() == 2 { + let from_idx_double = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -276,13 +273,6 @@ fn lower_string_method_dispatch( Ok(blk.sitofp(I32, &result_i32, DOUBLE)) } "slice" | "substring" => { - if args.len() > 2 { - bail!( - "perry-codegen: String.{} expects 0, 1 or 2 args, got {}", - property, - args.len() - ); - } // Issue #316: 0-arg form is the spec'd "clone" idiom — // `s.slice()` ≡ `s.slice(0, length)`. Was rejected at // codegen with "expects 1 or 2 args, got 0" before this fix. @@ -292,11 +282,14 @@ fn lower_string_method_dispatch( lower_expr(ctx, &args[0])? }; // 2-arg form: explicit end (may be `undefined` → treated as `len`). - let end_d = if args.len() == 2 { + let end_d = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -338,12 +331,6 @@ fn lower_string_method_dispatch( // Issue #567: accept the optional 2nd `limit: number` arg. // `str.split()` with no args is valid: an `undefined` separator // yields `[str]` (handled by `js_string_split_value`). - if args.len() > 2 { - bail!( - "perry-codegen: String.split expects 0, 1, or 2 args (delimiter[, limit]), got {}", - args.len() - ); - } // A literal separator with no `limit` cannot invoke user code, // cannot be a RegExp, and has the unbounded limit directly // expressible by `js_string_split_n`. Avoid the boxed dispatch and @@ -374,11 +361,14 @@ fn lower_string_method_dispatch( } else { Some(lower_expr(ctx, &args[0])?) }; - let limit_box = if args.len() == 2 { + let limit_box = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -410,13 +400,6 @@ fn lower_string_method_dispatch( // language-neutral Unicode casing. Closes #2781. (#592: Effect's // `aliasOrValue` at Cron.ts:846 was the original user-impact site.) "toLocaleLowerCase" | "toLocaleUpperCase" => { - if args.len() > 1 { - bail!( - "perry-codegen: String.{} expects 0 or 1 args, got {}", - property, - args.len() - ); - } // The `locales` arg is passed as a NaN-boxed JSValue (double) to the // runtime, which extracts/validates it. Missing → undefined. let locales_box = if args.is_empty() { @@ -424,6 +407,9 @@ fn lower_string_method_dispatch( } else { Some(lower_expr(ctx, &args[0])?) }; + for extra in args.iter().skip(1) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let locales_box = match locales_box { @@ -545,13 +531,16 @@ fn lower_string_method_dispatch( Ok(nanbox_string_inline(blk, &result)) } "repeat" => { - if args.len() != 1 { + if args.is_empty() { bail!( - "perry-codegen: String.repeat expects 1 arg, got {}", + "perry-codegen: String.repeat expects at least 1 arg, got {}", args.len() ); } let count_d = lower_expr(ctx, &args[0])?; + for extra in args.iter().skip(1) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -563,37 +552,51 @@ fn lower_string_method_dispatch( Ok(nanbox_string_inline(blk, &result)) } "replace" | "replaceAll" => { - if args.len() != 2 { - bail!( - "perry-codegen: String.{} expects 2 args, got {}", - property, - args.len() - ); - } // First arg is either a string or a regex literal. The // second arg can be a string OR a function (replacer // callback). Pick the right runtime function based on // both shapes. - let needle_is_regex = matches!(&args[0], Expr::RegExp { .. }) - || matches!(&args[0], Expr::LocalGet(id) if matches!( - ctx.local_types.get(id), - Some(HirType::Named(n)) if n == "RegExp" - )); + let needle_is_regex = args.first().is_some_and(|needle| { + matches!(needle, Expr::RegExp { .. }) + || matches!(needle, Expr::LocalGet(id) if matches!( + ctx.local_types.get(id), + Some(HirType::Named(n)) if n == "RegExp" + )) + }); // Detect a function replacer: a Closure literal, a FuncRef, // or a LocalGet of a function-typed local. - let repl_is_function = matches!(&args[1], Expr::Closure { .. } | Expr::FuncRef(_)) - || matches!(&args[1], Expr::LocalGet(id) if ctx.local_closure_func_ids.contains_key(id)); + let repl_is_function = args.get(1).is_some_and(|replacement| { + matches!(replacement, Expr::Closure { .. } | Expr::FuncRef(_)) + || matches!(replacement, Expr::LocalGet(id) if ctx.local_closure_func_ids.contains_key(id)) + }); // Detect a string literal that includes $ back-refs // so we route to the named-group-aware runtime variant. - let repl_has_named = matches!(&args[1], Expr::String(s) if s.contains("$<")); + let repl_has_named = matches!(args.get(1), Some(Expr::String(s)) if s.contains("$<")); // A non-RegExp, non-static-string `searchValue` is `ToString`-coerced // (running user `toString`/`valueOf`, may throw) BEFORE the // replacement is coerced, per ECMA-262 §22.1.3.19. Likewise a // non-function, non-static-string `replaceValue`. - let needle_is_str = is_string_expr(ctx, &args[0]); - let repl_is_str = is_string_expr(ctx, &args[1]); - let needle_box = lower_expr(ctx, &args[0])?; - let repl_box = lower_expr(ctx, &args[1])?; + let needle_is_str = args + .first() + .is_some_and(|needle| is_string_expr(ctx, needle)); + let repl_is_str = args + .get(1) + .is_some_and(|replacement| is_string_expr(ctx, replacement)); + let undefined = + || crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let needle_box = if let Some(needle) = args.first() { + lower_expr(ctx, needle)? + } else { + undefined() + }; + let repl_box = if let Some(replacement) = args.get(1) { + lower_expr(ctx, replacement)? + } else { + undefined() + }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -777,12 +780,6 @@ fn lower_string_method_dispatch( )) } "lastIndexOf" => { - if args.len() > 2 { - bail!( - "perry-codegen: String.lastIndexOf expects 0, 1 or 2 args, got {}", - args.len() - ); - } // No `searchString` → `undefined` → "undefined" // (`"".lastIndexOf()` === -1). let needle_box = if args.is_empty() { @@ -798,11 +795,14 @@ fn lower_string_method_dispatch( // Optional `position` (2nd arg). Without it, use the plain // last-index-of (search to the end); with it, the position-aware // variant. Mirrors the `indexOf` arm. - let pos_double = if args.len() == 2 { + let pos_double = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -835,7 +835,7 @@ fn lower_string_method_dispatch( Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } "padStart" | "padEnd" => { - if args.is_empty() || args.len() > 2 { + if args.is_empty() { bail!( "perry-codegen: String.{} expects 1 or 2 args, got {}", property, @@ -861,7 +861,7 @@ fn lower_string_method_dispatch( // back to " "), otherwise ToString — so non-string fills (numbers, // booleans, `null`, `{ toString }`) render correctly instead of // being bit-cast and dropped. - let pad_handle = if args.len() == 2 { + let pad_handle = if args.len() >= 2 { let pad_box = lower_expr(ctx, &args[1])?; let blk = ctx.block(); blk.call(I64, "js_string_pad_fill", &[(DOUBLE, &pad_box)]) @@ -872,6 +872,9 @@ fn lower_string_method_dispatch( let sp_box = blk.load(DOUBLE, &sp_global); unbox_str_handle(blk, &sp_box) }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -920,12 +923,6 @@ fn lower_string_method_dispatch( Ok(nanbox_string_inline(blk, &result)) } "localeCompare" => { - if args.len() > 3 { - bail!( - "perry-codegen: String.localeCompare expects 0-3 args, got {}", - args.len() - ); - } // A missing/undefined `that` argument coerces to the string // "undefined" (ECMA-262 §22.1.3.10: `ToString(that)`), so // `s.localeCompare()` === `s.localeCompare(undefined)` === @@ -946,11 +943,14 @@ fn lower_string_method_dispatch( } else { None }; - let options_box = if args.len() == 3 { + let options_box = if args.len() >= 3 { Some(lower_expr(ctx, &args[2])?) } else { None }; + for extra in args.iter().skip(3) { + let _ = lower_expr(ctx, extra)?; + } let blk = ctx.block(); if let Some(loc) = &locales_box { // Validate `(locales, options)` exactly as `Construct(%Collator%, @@ -996,12 +996,6 @@ fn lower_string_method_dispatch( } } "search" => { - if args.len() > 1 { - bail!( - "perry-codegen: String.search expects 0 or 1 arg, got {}", - args.len() - ); - } // The arg may be a RegExp OR any value that `RegExpCreate` coerces // via `ToString` (a string pattern, `undefined`, a `{ toString }` // object). Pass it BOXED to `js_string_search_value`, which detects @@ -1013,6 +1007,9 @@ fn lower_string_method_dispatch( } else { crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; + for extra in args.iter().skip(1) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -1024,12 +1021,6 @@ fn lower_string_method_dispatch( Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } "match" => { - if args.len() > 1 { - bail!( - "perry-codegen: String.match expects 0 or 1 arg, got {}", - args.len() - ); - } // Like `search`, coerce a non-RegExp arg via `RegExpCreate(ToString // (arg))` by passing it BOXED to `js_string_match_value`. A missing // arg is `undefined` → the empty `/(?:)/` regex. @@ -1038,6 +1029,9 @@ fn lower_string_method_dispatch( } else { crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; + for extra in args.iter().skip(1) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -1057,17 +1051,16 @@ fn lower_string_method_dispatch( Ok(ctx.block().bitcast_i64_to_double(&selected)) } "matchAll" => { - if args.len() > 1 { - bail!( - "perry-codegen: String.matchAll expects 0 or 1 arg, got {}", - args.len() - ); - } let pattern_box = if let Some(arg) = args.first() { lower_expr(ctx, arg)? } else { crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; + // Like every JavaScript call, extra arguments are evaluated for + // side effects even though String.prototype.matchAll ignores them. + for extra in args.iter().skip(1) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -1159,18 +1152,21 @@ fn lower_string_method_dispatch( // the rest of the string. Routed to the dedicated runtime helper // `js_string_substr`, which coerces both args via ToIntegerOrInfinity // (#2897). - if args.is_empty() || args.len() > 2 { + if args.is_empty() { bail!( "perry-codegen: String.substr expects 1 or 2 args, got {}", args.len() ); } let start_d = lower_expr(ctx, &args[0])?; - let len_d = if args.len() == 2 { + let len_d = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -1194,7 +1190,7 @@ fn lower_string_method_dispatch( "startsWith" | "endsWith" => { // Spec allows the 2-arg form: startsWith(searchString, position) // and endsWith(searchString, endPosition). Closes #315. - if args.is_empty() || args.len() > 2 { + if args.is_empty() { bail!( "perry-codegen: String.{} expects 1 or 2 args, got {}", property, @@ -1202,11 +1198,14 @@ fn lower_string_method_dispatch( ); } let other_box = lower_expr(ctx, &args[0])?; - let pos_d = if args.len() == 2 { + let pos_d = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); @@ -1254,7 +1253,7 @@ fn lower_string_method_dispatch( // honored (search starts there), matching the dynamic dispatch // path. Negative/NaN clamp to 0 and Infinity saturates past the // end inside js_string_index_of_from. - if args.is_empty() || args.len() > 2 { + if args.is_empty() { bail!( "perry-codegen: String.includes expects 1 or 2 args, got {}", args.len() @@ -1263,11 +1262,14 @@ fn lower_string_method_dispatch( let needle_box = lower_expr(ctx, &args[0])?; // Preserve evaluation of the second argument for side effects and // use it as the start index when present. - let pos_d = if args.len() == 2 { + let pos_d = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { None }; + for extra in args.iter().skip(2) { + let _ = lower_expr(ctx, extra)?; + } let recv_box = reread_recv(ctx, group, recv); let blk = ctx.block(); let recv_handle = unbox_str_handle(blk, &recv_box); diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 346d2b1bb9..581606ecaa 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -10,7 +10,7 @@ //! `to_ir()` assembles the pieces into a complete `.ll` file with the target //! triple header. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use crate::block::FpFlags; use crate::function::LlFunction; @@ -77,6 +77,33 @@ fn collect_symbol_refs(text: &str, out: &mut HashSet) { } } +fn metadata_definition_id(line: &str) -> Option { + let rest = line.trim_start().strip_prefix('!')?; + let (digits, _) = rest.split_once(" =")?; + digits.parse().ok() +} + +/// Collect numeric LLVM metadata references (`!123`) from instructions or +/// metadata definitions. Named metadata does not occur in Perry's alias tail. +fn collect_metadata_refs(text: &str, out: &mut HashSet) { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'!' || i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_digit() { + i += 1; + continue; + } + let start = i + 1; + i = start; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if let Ok(id) = text[start..i].parse() { + out.insert(id); + } + } +} + fn promote_global_for_units(line: &str) -> String { if line.contains(" = external ") { return line.to_string(); @@ -91,6 +118,62 @@ fn promote_global_for_units(line: &str) -> String { } } +/// Give a generated global one non-discardable definition. On COFF each +/// global has a unique owning codegen unit; leaving that sole definition as +/// `linkonce_odr` lets LLVM discard it when all references in the owner happen +/// to optimize away, even though other object files still reference it. +fn make_unique_owner_global(line: &str) -> String { + if line.contains(" = external ") { + return line.to_string(); + } + match line.split_once(" = ") { + Some((lhs, rhs)) => format!("{} = {}", lhs, strip_leading_linkage(rhs.trim_start())), + None => line.to_string(), + } +} + +fn external_decl_for_global(line: &str) -> Option { + if line.contains(" = external ") { + return Some(line.to_string()); + } + let (name, rhs) = line.split_once(" = ")?; + let rhs = strip_leading_linkage(rhs.trim_start()); + let (kind, rest) = if let Some(rest) = rhs.strip_prefix("unnamed_addr constant ") { + ("constant", rest) + } else if let Some(rest) = rhs.strip_prefix("constant ") { + ("constant", rest) + } else if let Some(rest) = rhs.strip_prefix("global ") { + ("global", rest) + } else { + return None; + }; + let rest = rest.trim_start(); + let ty_end = match rest.as_bytes().first().copied() { + Some(b'[') | Some(b'{') | Some(b'<') => { + let (mut square, mut curly, mut angle) = (0i32, 0i32, 0i32); + let mut end = None; + for (i, b) in rest.bytes().enumerate() { + match b { + b'[' => square += 1, + b']' => square -= 1, + b'{' => curly += 1, + b'}' => curly -= 1, + b'<' => angle += 1, + b'>' => angle -= 1, + _ => {} + } + if square == 0 && curly == 0 && angle == 0 { + end = Some(i + 1); + break; + } + } + end? + } + _ => rest.find(char::is_whitespace).unwrap_or(rest.len()), + }; + Some(format!("{name} = external {kind} {}", &rest[..ty_end])) +} + /// Attribute-group suffix for a runtime-helper `declare` line, keyed by /// helper name (#6082 tranche 1). /// @@ -205,7 +288,7 @@ pub(crate) fn declare_line_for(f: &LlFunction) -> String { /// Render a function with external linkage forced, promoting an `internal` / /// `private` definition so cross-unit calls can bind to it. Names are /// module-prefixed and unique, so promotion never collides. -fn render_fn_external(f: &LlFunction) -> String { +pub(crate) fn render_fn_external(f: &LlFunction) -> String { let ir = f.to_ir(); if f.linkage == "internal" || f.linkage == "private" { return ir.replacen(&format!("define {} ", f.linkage), "define ", 1); @@ -242,6 +325,7 @@ pub struct LlModule { declarations: Vec<(String, String)>, // (name, full "declare …" line) declared_names: HashSet, functions: Vec, + defined_names: HashSet, globals: Vec, string_constants: Vec, string_counter: u32, @@ -266,6 +350,12 @@ pub struct LlModule { } impl LlModule { + pub(crate) fn declaration_lines(&self) -> impl Iterator { + self.declarations + .iter() + .map(|(name, line)| (name.as_str(), line.as_str())) + } + pub fn new(target_triple: impl Into) -> Self { Self::new_with_fp_flags(target_triple, FpFlags::default()) } @@ -276,6 +366,7 @@ impl LlModule { declarations: Vec::new(), declared_names: HashSet::new(), functions: Vec::new(), + defined_names: HashSet::new(), globals: Vec::new(), string_constants: Vec::new(), string_counter: 0, @@ -427,6 +518,8 @@ impl LlModule { return_type: LlvmType, params: Vec<(LlvmType, String)>, ) -> &mut LlFunction { + let name = name.into(); + self.defined_names.insert(name.clone()); let func = LlFunction::new_with_fp_flags(name, return_type, params, self.fp_flags); self.functions.push(func); self.functions.last_mut().unwrap() @@ -455,12 +548,22 @@ impl LlModule { self.functions.len() } + /// Render-free size estimate for the function bodies that LLVM will see. + /// Used after lowering to size codegen units by actual generated IR rather + /// than HIR callable count (a poor proxy for minified/generated programs). + pub(crate) fn estimated_function_ir_bytes(&self) -> usize { + self.deduped_function_refs() + .iter() + .map(|f| f.estimated_ir_bytes()) + .sum() + } + /// True if a function with the given name has already been *defined* /// in this module. Used by the #461 export-stub pass to avoid /// redefining a symbol that an earlier emission path (function body, /// value-getter, #460 forwarding wrapper) already claimed. pub fn has_function(&self, name: &str) -> bool { - self.functions.iter().any(|f| f.name == name) + self.defined_names.contains(name) } pub fn add_global(&mut self, name: &str, ty: LlvmType, init: &str) { @@ -664,7 +767,7 @@ impl LlModule { /// tail. Factored out of [`to_ir`] so each codegen unit can replicate the /// same attributes and metadata (so `#0`/`#1` and `!N` references resolve in /// every unit). Over-emitting an unused attribute group is harmless. - fn push_attrs_and_metadata(&self, ir: &mut String) { + fn push_attrs(&self, ir: &mut String) { // Verified runtime-helper groups (#6082) — emitted only when a // declaration actually references them (mirrors the setjmp gating // above). See `helper_decl_attrs` for the audit invariants. @@ -688,6 +791,10 @@ impl LlModule { if used_nounwind_willreturn { ir.push_str("\nattributes #4 = { nounwind willreturn }\n"); } + } + + fn push_attrs_and_metadata(&self, ir: &mut String) { + self.push_attrs(ir); // Issue #52: `!0 = !{}` referenced by `!invariant.load !0`, plus the // buffer alias-scope metadata. LICM/GVN hoist invariant loads out of // loops only with these present. @@ -698,6 +805,41 @@ impl LlModule { } } + /// Emit only metadata nodes reachable from one codegen unit's function + /// bodies. Buffer alias metadata is numbered module-wide; replicating its + /// complete table into every unit gave full Claude a ~15 MiB per-unit floor + /// and duplicated gigabytes of parse input. References between metadata + /// nodes are closed transitively (scope lists -> scopes -> domain), while + /// preserving original definition order for deterministic output. + fn push_attrs_and_referenced_metadata_ids(&self, ir: &mut String, mut needed: HashSet) { + self.push_attrs(ir); + ir.push_str("\n!0 = !{}\n"); + needed.remove(&0); + let mut by_id: HashMap = HashMap::with_capacity(self.metadata_lines.len()); + for line in &self.metadata_lines { + if let Some(id) = metadata_definition_id(line) { + by_id.insert(id, line); + } + } + let mut work: Vec = needed.iter().copied().collect(); + while let Some(id) = work.pop() { + let Some(line) = by_id.get(&id) else { continue }; + let mut refs = HashSet::new(); + collect_metadata_refs(line, &mut refs); + for referenced in refs { + if referenced != id && referenced != 0 && needed.insert(referenced) { + work.push(referenced); + } + } + } + for line in &self.metadata_lines { + if metadata_definition_id(line).is_some_and(|id| needed.contains(&id)) { + ir.push_str(line); + ir.push('\n'); + } + } + } + /// Render this module as `n` independent codegen-unit `.ll` texts (#5391). /// /// Each unit is independently compilable by `clang -c`, so peak compiler @@ -781,7 +923,7 @@ impl LlModule { .or_insert_with(|| declare_line_for(f)); } - // #7174 (real-app scaling): render each bucket's functions first, then + // #7174 (real-app scaling): scan each bucket's functions first, then // give every global/string exactly ONE defining unit and hand the rest // an `external` declaration. Replicating all definitions into every // unit made per-unit IR grow with unit COUNT — on the 13 MB Claude Code @@ -789,25 +931,20 @@ impl LlModule { // large ... ran out of source locations`, no matter how finely it was // split. Definitions are already `linkonce_odr` (visible), so an // external declaration resolves to the same symbol at link time. - let bucket_texts: Vec = buckets - .iter() - .map(|bucket| { - let mut t = String::new(); - for func in bucket { - t.push_str(&render_fn_external(func)); - t.push('\n'); - } - t - }) - .collect(); - let bucket_refs: Vec> = bucket_texts - .iter() - .map(|t| { - let mut refs = HashSet::new(); - collect_symbol_refs(t, &mut refs); - refs - }) - .collect(); + // Scan one function at a time and discard its text immediately. The + // native API path needs only these reference sets, not a retained + // module-scale `.ll` duplicate beside the lowering-owned IR graph. + let mut bucket_refs: Vec> = + (0..buckets.len()).map(|_| HashSet::new()).collect(); + let mut bucket_metadata_refs: Vec> = + (0..buckets.len()).map(|_| HashSet::new()).collect(); + for (bi, bucket) in buckets.iter().enumerate() { + for func in bucket { + let text = render_fn_external(func); + collect_symbol_refs(&text, &mut bucket_refs[bi]); + collect_metadata_refs(&text, &mut bucket_metadata_refs[bi]); + } + } // A global is emitted into every unit that REFERENCES it — normally // exactly one, and `linkonce_odr` lets the linker fold the rare @@ -856,12 +993,29 @@ impl LlModule { need }) .collect(); - let referenced_anywhere: Vec = (0..all_globals.len()) - .map(|gi| bucket_needs.iter().any(|need| need.contains(&gi))) + // COFF cannot safely fold every generated COMDAT here: globals whose + // initializers name unit-local functions can acquire conflicting weak + // associative targets (LNK1227). Give each global one owner and use + // external declarations in other consumers. Mach-O retains the + // replicated policy required by `-dead_strip`. + let global_owners: Vec = (0..all_globals.len()) + .map(|gi| { + bucket_needs + .iter() + .position(|need| need.contains(&gi)) + .unwrap_or(0) + }) .collect(); + let replicate_globals = self.target_triple.contains("apple"); - let mut post = String::new(); - self.push_attrs_and_metadata(&mut post); + let unit_posts: Vec = bucket_metadata_refs + .into_iter() + .map(|metadata_refs| { + let mut post = String::new(); + self.push_attrs_and_referenced_metadata_ids(&mut post, metadata_refs); + post + }) + .collect(); let mut parts = Vec::with_capacity(n); for (bi, bucket) in buckets.into_iter().enumerate() { @@ -879,8 +1033,19 @@ impl LlModule { let referenced = bucket_needs[bi].contains(&gi); // Unreferenced globals (anchors, `llvm.*`, appending lists) // keep a home in unit 0 so nothing is lost. - if referenced || (!referenced_anywhere[gi] && bi == 0) { - pre.push_str(def); + let owns = global_owners[gi] == bi; + if (replicate_globals && referenced) || owns { + if replicate_globals { + pre.push_str(def); + } else { + pre.push_str(&make_unique_owner_global(def)); + } + pre.push('\n'); + } else if referenced { + let decl = external_decl_for_global(def).unwrap_or_else(|| { + panic!("cannot form external declaration for generated global: {def}") + }); + pre.push_str(&decl); pre.push('\n'); } } @@ -902,8 +1067,22 @@ impl LlModule { .iter() .map(|nm| nm.trim_start_matches('@')) .collect(); - for gi in &bucket_needs[bi] { - for nm in &global_refs[*gi] { + for (gi, refs) in global_refs.iter().enumerate() { + let referenced = bucket_needs[bi].contains(&gi); + let owns = global_owners[gi] == bi; + // Include references from every global whose initializer is + // actually emitted in this unit. Unit 0 owns otherwise-dead + // anchor globals, including static ClosureHeaders that name + // an `__perry_wrap_extern_*` function. Those globals are not + // in `bucket_needs` (no function references them), but their + // initializer still requires a cross-unit function declare. + // Merely external declarations in non-owning COFF units have + // no initializer, so they contribute no symbol references. + let emits_definition = (replicate_globals && referenced) || owns; + if !emits_definition { + continue; + } + for nm in refs { needed.insert(nm.trim_start_matches('@')); } } @@ -921,13 +1100,55 @@ impl LlModule { parts.push(CodegenUnitPart { pre, - post: post.clone(), + post: unit_posts[bi].clone(), funcs: bucket, }); } parts } + /// Consuming twin of [`Self::codegen_unit_parts`] for the native LLVM API + /// path. The borrowed partitioner computes the exact same deterministic + /// layout, then functions move out of the module and into their owning + /// unit. This lets the native freeze producer release each lowering-owned + /// function graph as soon as its immutable worker payload exists instead + /// of retaining the whole `LlModule` until every LLVM unit has finished. + pub(crate) fn into_codegen_unit_parts(mut self, n: usize) -> Vec { + let layouts: Vec<(String, String, Vec)> = self + .codegen_unit_parts(n) + .into_iter() + .map(|part| { + ( + part.pre, + part.post, + part.funcs.iter().map(|func| func.name.clone()).collect(), + ) + }) + .collect(); + + let mut functions_by_name = HashMap::with_capacity(self.functions.len()); + for function in std::mem::take(&mut self.functions) { + let name = function.name.clone(); + functions_by_name.entry(name).or_insert(function); + } + + layouts + .into_iter() + .map(|(pre, post, names)| OwnedCodegenUnitPart { + pre, + post, + funcs: names + .into_iter() + .map(|name| { + functions_by_name + .remove(&name) + .expect("borrowed codegen partition named an owned function") + }) + .collect(), + }) + .collect() + } + /// Render this module as `n` independent codegen-unit `.ll` texts (#5391). /// Thin text renderer over [`codegen_unit_parts`]; the native construction /// path consumes the parts directly. @@ -961,18 +1182,42 @@ pub(crate) struct CodegenUnitPart<'m> { pub funcs: Vec<&'m LlFunction>, } +pub(crate) struct OwnedCodegenUnitPart { + pub pre: String, + pub post: String, + pub funcs: Vec, +} + #[cfg(test)] mod tests { use super::*; use crate::types::{DOUBLE, I32, I64, PTR, VOID}; + #[test] + fn owned_codegen_units_move_each_function_exactly_once() { + let mut module = LlModule::new("x86_64-pc-windows-msvc"); + for name in ["first", "second", "third"] { + let function = module.define_function(name, VOID, vec![]); + function.create_block("entry").ret_void(); + } + + let units = module.into_codegen_unit_parts(2); + assert_eq!(units.len(), 2); + let mut names: Vec = units + .iter() + .flat_map(|unit| unit.funcs.iter().map(|function| function.name.clone())) + .collect(); + names.sort(); + assert_eq!(names, ["first", "second", "third"]); + } + #[test] fn render_codegen_units_partitions_and_links() { // #5391: a 2-unit split of a 2-function module must (a) define each // function in exactly one unit, (b) declare the other so cross-unit // calls resolve, and (c) carry the shared globals in BOTH units with // local linkage promoted to linkonce_odr (linker dedups). - let mut m = LlModule::new("arm64-apple-macosx15.0.0"); + let mut m = LlModule::new("x86_64-pc-windows-msvc"); m.declare_function("js_console_log_number", VOID, &[DOUBLE]); m.add_internal_global("perry_global_x", DOUBLE, "0.0"); let (_s, _l) = m.add_string_constant("hi"); @@ -1014,12 +1259,12 @@ mod tests { // count and broke clang's translation-unit limit on real bundles. let global_defs = units .iter() - .filter(|u| u.contains("@perry_global_x = linkonce_odr global double 0.0")) + .filter(|u| u.contains("@perry_global_x = global double 0.0")) .count(); assert_eq!(global_defs, 1, "global must be defined in exactly one unit"); let str_defs = units .iter() - .filter(|u| u.contains("@.str.0 = linkonce_odr unnamed_addr constant")) + .filter(|u| u.contains("@.str.0 = unnamed_addr constant")) .count(); assert_eq!(str_defs, 1, "string must be defined in exactly one unit"); @@ -1028,7 +1273,7 @@ mod tests { for u in &units { if u.contains("@perry_global_x") { assert!( - u.contains("@perry_global_x = linkonce_odr global double 0.0") + u.contains("@perry_global_x = global double 0.0") || u.contains("@perry_global_x = external global double"), "referencing unit must define or externally declare the global" ); @@ -1043,10 +1288,50 @@ mod tests { "a unit calling the helper must declare it" ); } - assert!(u.contains("target triple = \"arm64-apple-macosx15.0.0\"")); + assert!(u.contains("target triple = \"x86_64-pc-windows-msvc\"")); } } + #[test] + fn owner_only_global_declares_cross_unit_function_from_initializer() { + // An unreferenced generated global is retained in unit 0. If its + // initializer names a function assigned to another unit, unit 0 must + // still declare that function even though no function body mentions + // the global. Extern-function ClosureHeaders have exactly this shape. + let mut m = LlModule::new("x86_64-pc-windows-msvc"); + + let big = m.define_function("perry_fn_m__big", DOUBLE, vec![]); + let block = big.create_block("entry"); + for _ in 0..200 { + block.call_void("js_noop", &[]); + } + block.ret(DOUBLE, "0.0"); + + let wrapper = m.define_function( + "__perry_wrap_extern_dep__value", + DOUBLE, + vec![(I64, "%this_closure".to_string())], + ); + wrapper.linkage = "internal".to_string(); + wrapper.create_block("entry").ret(DOUBLE, "0.0"); + m.add_internal_constant( + "__perry_extern_closure_dep__value", + "{ ptr, i32, i32 }", + "{ ptr @__perry_wrap_extern_dep__value, i32 0, i32 1129074515 }", + ); + + let units = m.render_codegen_units(2); + let global_unit = units + .iter() + .find(|unit| unit.contains("@__perry_extern_closure_dep__value = constant")) + .expect("one unit must own the closure global"); + assert!( + !global_unit.contains("define double @__perry_wrap_extern_dep__value("), + "size balancing should put the small wrapper in the other unit" + ); + assert!(global_unit.contains("declare double @__perry_wrap_extern_dep__value(i64)")); + } + #[test] fn duplicate_function_symbol_emitted_once() { // Two classes that sanitize to the same name produce a colliding diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 76fe70e6d7..3e2f891fb9 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -55,6 +55,19 @@ pub fn native_mode() -> NativeMode { } } +/// Large split modules default to native construction: this is where serially +/// rendering module-scale IR is catastrophic and where independent LLVM +/// contexts provide useful parallelism. Small/single-unit modules retain the +/// mature text transport unless `=native` is explicit. +pub fn native_units_mode() -> NativeMode { + match std::env::var("PERRY_LLVM_INPROCESS").as_deref() { + Ok("0" | "off" | "false" | "1" | "on" | "true") => NativeMode::Off, + Ok("diff") => NativeMode::Diff, + Ok("native") | Err(_) => NativeMode::Native, + Ok(_) => NativeMode::Off, + } +} + /// Build the module natively: parse the skeleton text, then construct every /// function body through the C API via the dialect reader. fn build_native_module<'ctx>(context: &'ctx Context, llmod: &LlModule) -> Result> { @@ -134,62 +147,319 @@ fn stream_functions<'ctx>( Ok((typed_insts, raw_insts)) } +enum FrozenItem { + Label(String), + Blank, + Text(String), + Inst(crate::inst::LlInst), +} +struct FrozenFunction { + name: String, + header: String, + items: Vec, +} +struct FrozenUnit { + skeleton: String, + functions: Vec, + function_count: usize, + estimated_bytes: usize, + max_function_bytes: usize, +} + +fn freeze_unit( + part: crate::module::OwnedCodegenUnitPart, + external_declarations: &[(String, String)], +) -> Result { + let crate::module::OwnedCodegenUnitPart { pre, post, funcs } = part; + let mut skeleton = format!("{pre}{post}"); + // Text units minimize declarations with a rendered-reference scan. Typed + // instructions can name helpers without passing through that textual scan + // (e.g. shadow-slot maintenance), so native construction uses the complete + // external table. This is small compared with the bodies we deliberately + // no longer render. Avoid duplicating declarations already selected into + // `part.pre`. + let mut declared: std::collections::HashSet = skeleton + .lines() + .filter_map(|line| { + let line = line.trim_start(); + (line.starts_with("declare ") || line.starts_with("define ")).then_some(line) + }) + .filter_map(|line| line.split_once('@').map(|(_, tail)| tail)) + .filter_map(|tail| tail.split_once('(').map(|(name, _)| name.to_string())) + .collect(); + for (name, line) in external_declarations { + if declared.insert(name.clone()) { + skeleton.push_str(line); + skeleton.push('\n'); + } + } + let function_count = funcs.len(); + let mut estimated_bytes = skeleton.len(); + let mut max_function_bytes = 0usize; + let mut functions = Vec::with_capacity(function_count); + for f in funcs { + let function_bytes = f.estimated_ir_bytes(); + estimated_bytes += function_bytes; + max_function_bytes = max_function_bytes.max(function_bytes); + if f.personality.is_some() { + // Windows SEH funclets (`catchswitch`/`catchpad`/`catchret`) have + // no inkwell builders. Let LLVM's in-process assembly parser build + // only these exceptional functions; all ordinary bodies remain on + // the typed C-API path and never become text. + skeleton.push_str(&crate::module::render_fn_external(&f)); + skeleton.push('\n'); + continue; + } + skeleton.push_str(&crate::module::declare_line_for(&f)); + skeleton.push('\n'); + let mut items = Vec::new(); + f.for_each_final_item::(&mut |item| { + use crate::function::FinalItem as FI; + items.push(match item { + FI::Label(s) => FrozenItem::Label(s.to_string()), + FI::Blank => FrozenItem::Blank, + FI::Text(s) => FrozenItem::Text(s.to_string()), + FI::Inst(i) => FrozenItem::Inst(i.clone()), + }); + Ok(()) + })?; + functions.push(FrozenFunction { + name: f.name.clone(), + header: synth_define_header(&f, true), + items, + }); + } + Ok(FrozenUnit { + skeleton, + functions, + function_count, + estimated_bytes, + max_function_bytes, + }) +} + +fn stream_frozen_functions<'ctx>( + context: &'ctx Context, + module: &Module<'ctx>, + funcs: &[FrozenFunction], +) -> Result<(usize, usize)> { + let (mut typed, mut raw) = (0usize, 0usize); + for f in funcs { + let mut stream = crate::dialect::FnStream::begin(context, module, &f.header) + .map_err(|e| anyhow!("native IR construction failed in @{}: {e:#}", f.name))?; + for item in &f.items { + use crate::function::FinalItem as FI; + match item { + FrozenItem::Label(s) => stream.item(&FI::Label(s))?, + FrozenItem::Blank => stream.item(&FI::Blank)?, + FrozenItem::Text(s) => stream.item(&FI::Text(s))?, + FrozenItem::Inst(i) => stream.item(&FI::Inst(i))?, + } + } + let (t, r) = stream.finish()?; + typed += t; + raw += r; + } + Ok((typed, raw)) +} + /// Native construction for a module large enough to split into codegen /// units (#5391): each unit is its own context+module (peak RSS stays /// ~whole/n, same bound as the per-unit clang model), functions stream with /// external linkage forced (mirror of `render_fn_external`), and the unit /// objects partial-link exactly like the text path. pub fn compile_module_units_native( - llmod: &LlModule, + llmod: &mut LlModule, n: usize, target: Option<&str>, module_prefix: &str, ) -> Result> { - let parts = llmod.codegen_unit_parts(n); - if parts.len() == 1 { + if llmod.deduped_function_refs().len() <= 1 || n <= 1 { return compile_module_native(llmod, target, module_prefix); } - let mut objs = Vec::with_capacity(parts.len()); - for (i, part) in parts.iter().enumerate() { + let external_declarations: Vec<(String, String)> = llmod + .declaration_lines() + .filter(|(name, _)| !llmod.has_function(name)) + .map(|(name, line)| (name.to_string(), line.to_string())) + .collect(); + let target_triple = llmod.target_triple.clone(); + let owned_module = std::mem::replace(llmod, LlModule::new(target_triple)); + let parts = owned_module.into_codegen_unit_parts(n); + let show_progress = matches!( + std::env::var("PERRY_CODEGEN_PROGRESS").as_deref(), + Ok("1" | "all") + ) || std::env::var("PERRY_CODEGEN_UNIT_TIMINGS").is_ok(); + let unit_total = parts.len(); + if show_progress { + eprintln!( + "[perry] codegen: {module_prefix}: freezing {unit_total} codegen units for worker threads" + ); + } + // Freeze the lowering-owned Rc/RefCell graph before sharing work. Worker + // threads receive only owned immutable strings and typed instructions. + // A few locally-defined wrappers are also predeclared during lowering. + // The complete external table must exclude those names, matching + // `LlModule::skeleton_ir`; cross-unit declarations with their actual + // signatures already live in each part's filtered `pre`. + let llvm_started = std::time::Instant::now(); + let compile_one = |i: usize, unit: &FrozenUnit| -> Result> { + let started = std::time::Instant::now(); let context = Context::create(); - let mut skeleton = format!("{}{}", part.pre, part.post); - for f in &part.funcs { - skeleton.push_str(&crate::module::declare_line_for(f)); - skeleton.push('\n'); - } - let module = crate::inprocess::parse_ir_text(&context, &skeleton, "perry_native_module") - .map_err(|e| anyhow!("unit {i} skeleton: {e:#}"))?; - let (t, r) = stream_functions(&context, &module, &part.funcs, true) + let module = + crate::inprocess::parse_ir_text(&context, &unit.skeleton, "perry_native_module") + .map_err(|e| anyhow!("unit {i} skeleton: {e:#}"))?; + let (t, r) = stream_frozen_functions(&context, &module, &unit.functions) .map_err(|e| anyhow!("unit {i}: {e:#}"))?; debug_dump(&module, &format!("{module_prefix}.unit{i}")); - let est: usize = part - .funcs - .iter() - .map(|f| f.estimated_ir_bytes()) - .sum::() - + skeleton.len(); - let (effective_target, args) = - crate::linker::native_plan_args(target, est, part.funcs.len()); + let (effective_target, args) = crate::linker::native_plan_args( + target, + unit.estimated_bytes, + unit.function_count, + unit.max_function_bytes, + ); let unit_bytes = crate::inprocess::optimize_and_emit_module(&module, &effective_target, &args) .map_err(|e| anyhow!("unit {i}: {e:#}"))?; + let obj = crate::linker::finish_native_emission(unit_bytes, &effective_target, &args) + .map_err(|e| anyhow!("unit {i}: {e:#}"))?; + log::debug!( + "perry-codegen: native unit {i}: {} fns, {t} typed + {r} raw insts, {:.3}s", + unit.function_count, + started.elapsed().as_secs_f64() + ); + Ok(obj) + }; + + let jobs = std::env::var("PERRY_CODEGEN_UNIT_JOBS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(2) + .min(parts.len()); + if show_progress { + let estimated_mib: f64 = parts + .iter() + .map(|part| { + (part.pre.len() + + part.post.len() + + part + .funcs + .iter() + .map(|f| f.estimated_ir_bytes()) + .sum::()) as f64 + / 1_048_576.0 + }) + .sum::(); + eprintln!( + "[perry] codegen: {module_prefix}: freeze/LLVM pipeline started: {unit_total} units, {jobs} workers, ~{estimated_mib:.1} MiB estimated IR" + ); + } + let completed = std::sync::atomic::AtomicUsize::new(0); + let frozen = std::sync::atomic::AtomicUsize::new(0); + let slots: Vec>>>> = (0..parts.len()) + .map(|_| std::sync::Mutex::new(None)) + .collect(); + // The producer alone touches lowering-owned LlFunction/Rc state. Each + // completed owned payload immediately enters a bounded queue, letting LLVM + // consume it while the producer freezes later units. Previously all units + // were frozen into a Vec first: full Claude waited ~5 minutes before LLVM + // started and retained both graphs at peak RSS. + let (sender, receiver) = + std::sync::mpsc::sync_channel::<(usize, Result)>(jobs.max(1)); + let receiver = std::sync::Mutex::new(receiver); + std::thread::scope(|scope| { + for _ in 0..jobs { + scope.spawn(|| loop { + let received = receiver + .lock() + .expect("native freeze queue poisoned") + .recv(); + let Ok((i, frozen_unit)) = received else { break }; + let unit_started = std::time::Instant::now(); + let out = frozen_unit.and_then(|unit| compile_one(i, &unit)); + let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if show_progress { + let elapsed = llvm_started.elapsed().as_secs_f64(); + let eta = if done < unit_total { + elapsed / done as f64 * (unit_total - done) as f64 + } else { + 0.0 + }; + eprintln!( + "[perry] codegen: {module_prefix}: LLVM unit {}/{} finished ({:.1}s; {} complete; elapsed {:.1} min; ETA ~{:.1} min)", + i + 1, unit_total, unit_started.elapsed().as_secs_f64(), done, + elapsed / 60.0, eta / 60.0 + ); + } + *slots[i].lock().expect("native codegen-unit slot poisoned") = Some(out); + }); + } + let freeze_started = std::time::Instant::now(); + let report_step = (unit_total / 20).max(1); + // Consume each part as soon as its owned worker payload has been + // produced. Keeping `parts` alive through the scoped worker join held + // every unit's large pre/post strings until all LLVM work completed; + // dropping that multi-gigabyte graph afterwards added a several-minute + // single-threaded destructor tail on the full Claude Code bundle. + for (i, part) in parts.into_iter().enumerate() { + let unit = freeze_unit(part, &external_declarations); + if sender.send((i, unit)).is_err() { + break; + } + let done = frozen.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if show_progress && (done == unit_total || done % report_step == 0) { + let elapsed = freeze_started.elapsed().as_secs_f64(); + let eta = elapsed * unit_total.saturating_sub(done) as f64 / done as f64; + eprintln!( + "[perry] codegen: {module_prefix}: froze {done}/{unit_total} units ({:.0}%; {:.1}s elapsed; ETA ~{:.1}s)", + done as f64 * 100.0 / unit_total as f64, + elapsed, + eta + ); + } + } + drop(sender); + }); + let mut objs = Vec::with_capacity(unit_total); + for (i, slot) in slots.into_iter().enumerate() { objs.push( - crate::linker::finish_native_emission(unit_bytes, &effective_target, &args) - .map_err(|e| anyhow!("unit {i}: {e:#}"))?, + slot.into_inner() + .expect("native codegen-unit slot poisoned") + .expect("every native codegen unit is compiled") + .map_err(|e| { + anyhow!("native codegen unit {}/{} failed: {e:#}", i + 1, unit_total) + })?, ); - log::debug!( - "perry-codegen: native unit {i}: {} fns, {t} typed + {r} raw insts", - part.funcs.len() + } + let merge_started = std::time::Instant::now(); + if show_progress { + let object_mib = objs.iter().map(Vec::len).sum::() as f64 / 1_048_576.0; + eprintln!( + "[perry] codegen: {module_prefix}: merging {unit_total} unit objects (~{object_mib:.1} MiB) into one linker input" ); } - crate::linker::merge_unit_objects(&objs) + let merged = crate::linker::merge_unit_objects(&objs); + if show_progress { + match &merged { + Ok(bytes) => eprintln!( + "[perry] codegen: {module_prefix}: merged {unit_total} unit objects into {:.1} MiB in {:.1}s", + bytes.len() as f64 / 1_048_576.0, + merge_started.elapsed().as_secs_f64() + ), + Err(_) => eprintln!( + "[perry] codegen: {module_prefix}: merging {unit_total} unit objects failed after {:.1}s", + merge_started.elapsed().as_secs_f64() + ), + } + } + merged } /// Unit-split differential harness: text-rendered units through the /// in-process transport vs natively-constructed units, merged objects /// byte-compared. Returns the text arm (the trusted reference). pub fn compile_module_units_diff( - llmod: &LlModule, + llmod: &mut LlModule, n: usize, target: Option<&str>, module_prefix: &str, @@ -224,13 +494,14 @@ pub fn compile_module_units_diff( /// path (`build_clang_compile_plan`), with the byte-size input taken from the /// render-free size estimate the codegen-unit balancer already uses. fn plan_for(llmod: &LlModule, target: Option<&str>) -> (String, Vec) { - let est_bytes: usize = llmod - .deduped_function_refs() + let funcs = llmod.deduped_function_refs(); + let est_bytes: usize = funcs.iter().map(|f| f.estimated_ir_bytes()).sum(); + let max_fn_bytes = funcs .iter() .map(|f| f.estimated_ir_bytes()) - .sum(); - let fn_count = llmod.deduped_function_refs().len(); - crate::linker::native_plan_args(target, est_bytes, fn_count) + .max() + .unwrap_or(0); + crate::linker::native_plan_args(target, est_bytes, funcs.len(), max_fn_bytes) } pub fn compile_module_native( diff --git a/crates/perry-codegen/src/opt_report/render.rs b/crates/perry-codegen/src/opt_report/render.rs index 4cef2178a2..545177cf9d 100644 --- a/crates/perry-codegen/src/opt_report/render.rs +++ b/crates/perry-codegen/src/opt_report/render.rs @@ -152,6 +152,7 @@ pub fn render_text(entries: &[Entry]) -> String { } /// [`render_text`] with the de-duplication count supplied explicitly. +#[cfg(test)] pub fn render_text_with(entries: &[Entry], masked: usize) -> String { render_text_with_sources(entries, masked, &HashMap::new()) } diff --git a/crates/perry-codegen/src/root_reload.rs b/crates/perry-codegen/src/root_reload.rs index 0360797c8f..ccccc1672c 100644 --- a/crates/perry-codegen/src/root_reload.rs +++ b/crates/perry-codegen/src/root_reload.rs @@ -376,8 +376,6 @@ pub(crate) fn apply_to_module(module: &mut crate::module::LlModule) -> usize { /// A value the pass can re-materialise: a load out of a collector-rewritten /// location, or anything derived from one by pure bit ops (#7664). struct Reloadable { - /// Where the defining instruction is, which is where its window starts. - pos: (usize, usize), /// The register it defines, without the `%`. reg: String, /// The location whose store invalidates the recipe, sigil included. @@ -433,7 +431,6 @@ pub(crate) fn apply_to_function(func: &mut LlFunction) -> usize { } by_reg.insert(dst.clone(), values.len()); values.push(Reloadable { - pos: (bi, ii), reg: dst.clone(), root_ptr: ptr.clone(), recipe: vec![(bi, ii)], @@ -504,7 +501,6 @@ pub(crate) fn apply_to_function(func: &mut LlFunction) -> usize { } by_reg.insert(dst.clone(), values.len()); values.push(Reloadable { - pos: (bi, ii), reg: dst.clone(), root_ptr: root, recipe, diff --git a/crates/perry-codegen/src/rooting/mod.rs b/crates/perry-codegen/src/rooting/mod.rs index efc15960ab..25e9a60c7c 100644 --- a/crates/perry-codegen/src/rooting/mod.rs +++ b/crates/perry-codegen/src/rooting/mod.rs @@ -1834,7 +1834,7 @@ mod migration_ledger { let decl = include_str!("mod.rs"); assert!( - decl.contains("\nmod temp_root;\n"), + decl.lines().any(|line| line == "mod temp_root;"), "rooting/temp_root.rs must be declared with a PRIVATE `mod temp_root;` — \ a pub(crate) module would make every item in it reachable crate-wide \ regardless of its own visibility" diff --git a/crates/perry-codegen/src/statepoint_report.rs b/crates/perry-codegen/src/statepoint_report.rs index 01c9b4ea77..23fdb7b717 100644 --- a/crates/perry-codegen/src/statepoint_report.rs +++ b/crates/perry-codegen/src/statepoint_report.rs @@ -11,7 +11,6 @@ //! knob with no CI arm, so that spelling was deleted under CLAUDE.md's GC knob //! kill policy. `gc-native-roots.yml` exercises the report through the flag. -use std::collections::BTreeMap; use std::fmt::Write as _; use std::sync::{Mutex, OnceLock}; @@ -164,21 +163,6 @@ fn totals(records: &[FunctionRecord]) -> Totals { out } -fn render_ranked_map(out: &mut String, heading: &str, values: &BTreeMap) { - if values.is_empty() { - return; - } - let mut rows: Vec<_> = values.iter().collect(); - rows.sort_by(|(name_a, count_a), (name_b, count_b)| { - count_b.cmp(count_a).then_with(|| name_a.cmp(name_b)) - }); - let _ = writeln!(out, "{heading}"); - for (name, count) in rows.into_iter().take(25) { - let _ = writeln!(out, " {count:>6} {name}"); - } - out.push('\n'); -} - pub fn render_text(records: &[FunctionRecord]) -> String { render_text_with(records, take_gc_map()) } diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 8ed14e6a14..3146f8ea43 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -14,8 +14,7 @@ use crate::expr::{ lower_expr_with_expected_type, unbox_str_handle, }; use crate::native_value::{ - BufferAccessMode, LoweredValue, MaterializationReason, NativeRep, PodLayoutDecision, PodLocal, - SemanticKind, + LoweredValue, MaterializationReason, NativeRep, PodLayoutDecision, PodLocal, SemanticKind, }; use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; diff --git a/crates/perry-codegen/src/stmt/let_stmt_facts.rs b/crates/perry-codegen/src/stmt/let_stmt_facts.rs index 79e92aff6f..66100f6cc6 100644 --- a/crates/perry-codegen/src/stmt/let_stmt_facts.rs +++ b/crates/perry-codegen/src/stmt/let_stmt_facts.rs @@ -5,7 +5,7 @@ use super::*; -use crate::native_value::{BufferAccessMode, PodLocal, SemanticKind}; +use crate::native_value::BufferAccessMode; pub(super) fn pod_view_count_source(ctx: &FnCtx<'_>, expr: &perry_hir::Expr) -> String { match expr { diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index f13b9a32e6..f564fb51e2 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -6684,6 +6684,46 @@ fn boxed_local_storage_module(name: &str, init: Expr, replacement: Expr) -> Modu ) } +#[test] +fn abrupt_captured_local_assignment_does_not_emit_orphan_write_barrier() { + // An unresolved Worker construction lowers to a runtime throw followed by + // `unreachable`. The enclosing LocalSet must not create its post-store + // write-barrier blocks after that terminator: the store and its SSA inputs + // were never emitted, so such a block is unreachable *and* refers to + // undefined registers (the Pi agent bundle exposed this at LLVM parse + // time). + let replacement = Expr::WorkerNew { + paths: Vec::new(), + filename: Box::new(Expr::LocalGet(99)), + options: None, + is_eval: false, + }; + let module = boxed_local_storage_module( + "abrupt_captured_local_set_barrier.ts", + Expr::Array(Vec::new()), + replacement, + ); + let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + let throw = ir + .find("call void @js_throw_error_with_code") + .expect("unresolved Worker construction should lower to the deferred runtime throw"); + let function_tail = &ir[throw..]; + let function_end = function_tail + .find("\n}\n") + .expect("throwing closure should have a complete definition"); + let throwing_body = &function_tail[..function_end]; + + assert!( + throwing_body.contains("\n unreachable"), + "fixture should terminate the assignment before its store:\n{throwing_body}" + ); + assert!( + !throwing_body.contains("wb.maybe") + && !throwing_body.contains("call void @js_write_barrier("), + "a terminated assignment cannot reach or supply operands to a write barrier:\n{throwing_body}" + ); +} + fn boxed_param_capture_module(name: &str) -> Module { module_with_classes_and_params( name, diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index 4d00df0d23..e032508479 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -389,8 +389,25 @@ pub(super) fn lower_meta_prop( /// Used by both the bare-`import.meta` Object synthesis above and the /// member-access fast path in `expr_member::lower_member`. pub(crate) fn import_meta_paths(ctx: &LoweringContext) -> (String, String, String) { - let path = ctx.source_file_path.replace('\\', "/"); - let url = format!("file://{}", path); + let mut path = ctx.source_file_path.replace('\\', "/"); + // `dunce::canonicalize` keeps Windows' verbatim `\\?\` prefix for long + // bundle paths. It is a Win32 path decoration, not part of the file URL: + // `file:////?/C:/x` makes Node-compatible fileURLToPath reject the path as + // non-absolute. Strip it before exposing import.meta.{url,filename}. + if let Some(rest) = path.strip_prefix("//?/UNC/") { + path = format!("//{rest}"); + } else if let Some(rest) = path.strip_prefix("//?/") { + path = rest.to_string(); + } + let url = if path.starts_with("//") { + // UNC: `//server/share/x` -> `file://server/share/x`. + format!("file:{path}") + } else if path.as_bytes().get(1) == Some(&b':') { + // Windows drive: `C:/x` -> `file:///C:/x`. + format!("file:///{path}") + } else { + format!("file://{path}") + }; let dirname = match path.rfind('/') { Some(i) if i > 0 => path[..i].to_string(), Some(_) => "/".to_string(), @@ -410,3 +427,26 @@ pub(super) fn lower_yield(ctx: &mut LoweringContext, y: &ast::YieldExpr) -> Resu delegate: y.delegate, }) } + +#[cfg(test)] +mod import_meta_path_tests { + use super::{import_meta_paths, LoweringContext}; + + #[test] + fn windows_verbatim_drive_path_becomes_standard_file_url() { + let ctx = LoweringContext::new(r"\\?\C:\project\bundle.mjs"); + let (url, dirname, filename) = import_meta_paths(&ctx); + assert_eq!(url, "file:///C:/project/bundle.mjs"); + assert_eq!(dirname, "C:/project"); + assert_eq!(filename, "C:/project/bundle.mjs"); + } + + #[test] + fn windows_verbatim_unc_path_becomes_unc_file_url() { + let ctx = LoweringContext::new(r"\\?\UNC\server\share\bundle.mjs"); + let (url, dirname, filename) = import_meta_paths(&ctx); + assert_eq!(url, "file://server/share/bundle.mjs"); + assert_eq!(dirname, "//server/share"); + assert_eq!(filename, "//server/share/bundle.mjs"); + } +} diff --git a/crates/perry-runtime/src/abi_trampoline.rs b/crates/perry-runtime/src/abi_trampoline.rs index eadf84c795..61c4ab4ad5 100644 --- a/crates/perry-runtime/src/abi_trampoline.rs +++ b/crates/perry-runtime/src/abi_trampoline.rs @@ -277,6 +277,10 @@ mod tests { // A 70-param all-f64 callee: returns arg0*1 + arg1*2 + ... weighted sum so // a misplaced/garbage arg is detectable, plus marker on the last few. + #[cfg(any( + target_arch = "aarch64", + all(target_arch = "x86_64", not(target_os = "windows")) + ))] extern "C" fn sum70( a0: f64, a1: f64, diff --git a/crates/perry-runtime/src/arena/promote.rs b/crates/perry-runtime/src/arena/promote.rs index 1a384b3bb9..3601b09228 100644 --- a/crates/perry-runtime/src/arena/promote.rs +++ b/crates/perry-runtime/src/arena/promote.rs @@ -153,7 +153,6 @@ pub(crate) enum PromotionLiveness { /// promotion policy is calibrated against — `PERRY_GC_DIAG=1` prints it. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct InPlacePromotionStats { - pub(crate) blocks: usize, pub(crate) objects: usize, pub(crate) live_objects: usize, pub(crate) bytes: usize, @@ -304,10 +303,7 @@ pub(crate) fn finish_in_place_promotion( promotion: InPlacePromotion, liveness: PromotionLiveness, ) -> InPlacePromotionStats { - let mut stats = InPlacePromotionStats { - blocks: promotion.blocks.len(), - ..InPlacePromotionStats::default() - }; + let mut stats = InPlacePromotionStats::default(); if promotion.blocks.is_empty() { return stats; } diff --git a/crates/perry-runtime/src/arena/tests_promoted_runs.rs b/crates/perry-runtime/src/arena/tests_promoted_runs.rs index 62b2ec2120..5b816049f1 100644 --- a/crates/perry-runtime/src/arena/tests_promoted_runs.rs +++ b/crates/perry-runtime/src/arena/tests_promoted_runs.rs @@ -15,7 +15,7 @@ use super::page_meta::{ materialize_all_promoted_page_runs, register_promoted_page_run, unregister_old_block_pages, }; use super::*; -use crate::gc::{GcHeader, GC_HEADER_SIZE, GC_TYPE_STRING}; +use crate::gc::{GcHeader, GC_TYPE_STRING}; const OBJ: usize = 64; diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 92fe269fde..d792385b76 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -478,6 +478,8 @@ pub(crate) unsafe fn keys_array_slot( crate::array::js_array_get(keys, index) } +#[cfg(test)] +thread_local! { /// Times [`keys_array_slot`] could NOT serve a slot from the dense words and /// had to delegate. Asserted in both directions by /// `array::collection_tag_tests` — zero for the dense keys arrays the fast path @@ -487,8 +489,6 @@ pub(crate) unsafe fn keys_array_slot( /// /// Per THREAD — `cargo test` runs every case on its own thread in one process, /// so a process-global counter would be moved by whatever else is running. -#[cfg(test)] -thread_local! { static KEYS_ARRAY_SLOT_FALLBACKS: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/perry-runtime/src/child_process/output.rs b/crates/perry-runtime/src/child_process/output.rs index 3d278d0d0b..1cc8a99337 100644 --- a/crates/perry-runtime/src/child_process/output.rs +++ b/crates/perry-runtime/src/child_process/output.rs @@ -122,9 +122,9 @@ pub(crate) fn cp_io_error_code(e: &std::io::Error) -> &'static str { } /// Node's `errno` is the negative libc errno value for the failure code. -pub(crate) fn cp_errno_number(code: &str) -> f64 { +pub(crate) fn cp_errno_number(_code: &str) -> f64 { #[cfg(unix)] - let n = match code { + let n = match _code { "ENOENT" => libc::ENOENT, "EACCES" => libc::EACCES, "EEXIST" => libc::EEXIST, diff --git a/crates/perry-runtime/src/child_process/sync_run.rs b/crates/perry-runtime/src/child_process/sync_run.rs index 2d6c07a583..403721df20 100644 --- a/crates/perry-runtime/src/child_process/sync_run.rs +++ b/crates/perry-runtime/src/child_process/sync_run.rs @@ -332,10 +332,10 @@ fn cp_wait_for_timeout( } } -fn cp_terminate_child(child: &mut std::process::Child, kill_signal: i32) { +fn cp_terminate_child(child: &mut std::process::Child, _kill_signal: i32) { #[cfg(unix)] unsafe { - let _ = libc::kill(child.id() as i32, kill_signal); + let _ = libc::kill(child.id() as i32, _kill_signal); } #[cfg(not(unix))] { diff --git a/crates/perry-runtime/src/eh_walker.rs b/crates/perry-runtime/src/eh_walker.rs index cc92c743f6..ca11754a7f 100644 --- a/crates/perry-runtime/src/eh_walker.rs +++ b/crates/perry-runtime/src/eh_walker.rs @@ -270,7 +270,6 @@ fn parse_unwind_info(ui: &[u8], image_base: u64) -> (Vec<(u64, u32)>, Vec<(u64, let e_off = u16at(page_off + 4) as usize; let count = u16at(page_off + 6) as usize; let enc_off = u16at(page_off + 8) as usize; - let enc_count = u16at(page_off + 10) as usize; for e in 0..count { let raw = u32at(page_off + e_off + 4 * e); let idx = (raw >> 24) as usize; diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index f4a4e3f990..50a253f8d6 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -68,6 +68,7 @@ pub(crate) const CLASS_ID_FS_FILEHANDLE: u32 = 0xFFFF_008C; /// The duplicate shares the open file description, so reads and writes land on /// the same terminal/pipe. fn std_fd_registry() -> StdHashMap { + #[allow(unused_mut)] // populated only by the Unix descriptor-duplication arm let mut map = StdHashMap::new(); #[cfg(unix)] { diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index b54d5be003..268963a084 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -78,7 +78,7 @@ mod root_words; use root_words::*; mod layout; mod layout_slot_visit; -pub(crate) use layout_slot_visit::*; +use layout_slot_visit::*; /// #7510: the per-object slot-layout side tables and the emptiness flag that /// keeps them off the allocation, store, death and trace paths. Split out of /// `layout.rs` so it stays under the repo's 2000-line-per-file cap. @@ -100,7 +100,7 @@ mod barrier; pub use barrier::*; /// #7630: the runtime slot-store helpers, split from `barrier.rs` (2000-line cap). mod barrier_store; -pub use barrier_store::*; +pub(crate) use barrier_store::*; mod dirty_page_cache; // #7187 Phase B: `crate::arena`'s page-metadata module invalidates the // barrier's "already dirty" page cache when it un-stamps or discards a page. @@ -140,6 +140,7 @@ use sticky_remembered::*; pub(crate) use copying::CopyingPointerSet; // The hard ceiling every birth-generation threshold in `gc::types` must stay // under; asserted by `arena::tests::pointer_bearing_large_object_threshold_is_movable`. +#[cfg(test)] pub(crate) use copying::MAX_YOUNG_MOVE_BYTES; mod dead_owner; mod old_free; @@ -512,6 +513,8 @@ pub(super) mod knob_overrides { } } +#[cfg(test)] +thread_local! { /// `PERRY_GC_SCAVENGE` — **ON by default since #7056**, kill switch /// `PERRY_GC_SCAVENGE=0`/`off`/`false`. It is a PACING knob: it routes /// nursery-churn triggers to the direct minor in `gc_check_trigger` instead of @@ -567,8 +570,6 @@ pub(super) mod knob_overrides { /// for the reason above. It is recorded rather than quietly deleted because /// this whole PR exists because a stale half of a doc comment kept carrying a /// soundness argument after it stopped being true. -#[cfg(test)] -thread_local! { /// Test-only override, consulted BEFORE the process-wide OnceLock so a /// single test can pin a pacing mode even though the process default is on. /// Same discipline as `GC_MOVING_LOOP_POLLS_TEST_OVERRIDE`. diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index c3456bef2b..4ff95fe2a6 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -1569,6 +1569,7 @@ pub(super) fn note_survivor_promotion_handoff_full() { /// itself is observable: the latch short-circuits before the arena inspection, /// so a test with an empty heap cannot otherwise distinguish "suppressed" from /// "there was no pressure anyway". +#[cfg(test)] pub(super) fn survivor_promotion_handoff_suppressions() -> u64 { SURVIVOR_HANDOFF_SUPPRESSIONS.with(Cell::get) } diff --git a/crates/perry-runtime/src/gc/promote_in_place.rs b/crates/perry-runtime/src/gc/promote_in_place.rs index fdeb6ea418..fa81c07078 100644 --- a/crates/perry-runtime/src/gc/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/promote_in_place.rs @@ -488,10 +488,6 @@ pub fn untraced_promoted_objects() -> u64 { UNTRACED_PROMOTED_OBJECTS.with(Cell::get) } -pub(crate) fn untraced_promoted_bytes_since_measurement() -> usize { - UNTRACED_PROMOTED_BYTES.with(Cell::get) -} - /// Record the young-survival ratio a copying minor just measured. Called on /// EVERY copying minor that TRACES, promoting or evacuating — that is what /// keeps the predictor from going stale under repeated promotion. An untraced @@ -564,6 +560,7 @@ pub(crate) fn last_young_survival_permille() -> Option { LAST_YOUNG_SURVIVAL_PERMILLE.with(Cell::get) } +#[cfg(test)] pub(crate) fn promoted_dead_bytes_since_full() -> usize { PROMOTED_DEAD_BYTES.with(Cell::get) } diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 16c87d51fd..9aac9d75db 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -87,7 +87,9 @@ struct StackMapIndex { /// Used to confirm a matched record belongs to the function `ip` is in. function_starts: Vec, chain_walkable: bool, + #[cfg(any(target_arch = "aarch64", test))] min_pc: usize, + #[cfg(any(target_arch = "aarch64", test))] max_pc: usize, } @@ -301,7 +303,9 @@ fn index_records(records: Vec, roots: Vec) -> DWARF_REG_FP_AARCH64 | DWARF_REG_SP_AARCH64 ) }); + #[cfg(any(target_arch = "aarch64", test))] let min_pc = records.first().map_or(usize::MAX, |record| record.pc); + #[cfg(any(target_arch = "aarch64", test))] let max_pc = records.last().map_or(0, |record| record.pc); let mut function_starts: Vec = records .iter() @@ -314,7 +318,9 @@ fn index_records(records: Vec, roots: Vec) -> roots, function_starts, chain_walkable, + #[cfg(any(target_arch = "aarch64", test))] min_pc, + #[cfg(any(target_arch = "aarch64", test))] max_pc, } } @@ -569,11 +575,6 @@ fn sve_vector_length_bytes() -> Option { None } -#[cfg(not(target_arch = "aarch64"))] -fn fp_to_sp_offset(_function_address: usize) -> Option { - None -} - fn closest_record_pc(maps: &[StackMapRecord], ip: usize) -> Option { let insertion = maps.partition_point(|record| record.pc < ip); let before = insertion @@ -1039,6 +1040,7 @@ fn main_object_load_bias() -> Option { dlpi_name: *const std::os::raw::c_char, // remaining fields unused } + #[allow(clashing_extern_declarations)] unsafe extern "C" { fn dl_iterate_phdr( callback: unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut c_void) -> i32, diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs b/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs index 5647d1b09b..3a4f5fb774 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs @@ -228,7 +228,7 @@ fn check(kind: &str, walker: &str, samples: &[Sample], expected: usize, frame: F // has nowhere else to put a result, and a thread-local keeps it off `static // mut` (whose references the 2024 edition rejects). Each test drives one probe // to completion before reading, so there is no interleaving to reason about. -thread_local! { +crate::perry_thread_local! { static PROBE: std::cell::RefCell = const { std::cell::RefCell::new(ProbeState { frame: ELF_FRAME, diff --git a/crates/perry-runtime/src/gc/shape_install.rs b/crates/perry-runtime/src/gc/shape_install.rs index 825a2295f6..f2bb9e0f7c 100644 --- a/crates/perry-runtime/src/gc/shape_install.rs +++ b/crates/perry-runtime/src/gc/shape_install.rs @@ -152,6 +152,7 @@ pub(super) fn words_contain_slot(words: &[u64], slot: usize) -> bool { /// `LayoutSlotMask::from_words(words).is_empty()`. #[inline(always)] +#[cfg(test)] pub(super) fn words_are_empty(words: &[u64]) -> bool { words.iter().all(|&w| w == 0) } diff --git a/crates/perry-runtime/src/gc/tests/buffer_bound_method_name.rs b/crates/perry-runtime/src/gc/tests/buffer_bound_method_name.rs index 1a87cb29bc..9deb59816e 100644 --- a/crates/perry-runtime/src/gc/tests/buffer_bound_method_name.rs +++ b/crates/perry-runtime/src/gc/tests/buffer_bound_method_name.rs @@ -25,7 +25,6 @@ //! rather than "does it happen to still read correctly after a collection", //! which is exactly the question a lucky allocator answers wrong. -use super::super::*; use super::support::*; /// The name bytes a bound closure keeps, as raw parts. diff --git a/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs b/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs index 1591583c3d..45008a7855 100644 --- a/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs +++ b/crates/perry-runtime/src/gc/tests/copying/all_pointer_elements_7469.rs @@ -151,13 +151,11 @@ fn a_numeric_layout_probe_revokes_the_declaration_and_the_header_test_catches_it // The refused push routes through the runtime, which records the slot. let child = fresh_string(b"after_revocation"); let arr = crate::array::js_array_push_f64(arr, f64::from_bits(string_bits(child))); - unsafe { - assert!( - test_heap_child_slot_count(arr as *mut u8) >= 1, - "the runtime push must have recorded the pointer the elided store \ - was refused for" - ); - } + assert!( + test_heap_child_slot_count(arr as *mut u8) >= 1, + "the runtime push must have recorded the pointer the elided store \ + was refused for" + ); } #[test] diff --git a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs index fd4627ce59..23e38ee1d1 100644 --- a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs +++ b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs @@ -379,7 +379,11 @@ fn every_covered_clear_helper_is_still_called_by_the_guards() { .split_once("fn reset_copying_nursery_runtime_test_state()") .expect("the guards' reset function was renamed — update this test") .1; - let body = body.split_once("\n}\n").expect("unterminated reset fn").0; + let body = body + .lines() + .take_while(|line| *line != "}") + .collect::>() + .join("\n"); assert!( body.contains("test_clear_closure_side_tables"), "sanity: the extracted reset body does not contain a call it certainly \ diff --git a/crates/perry-runtime/src/gc/tests/rooted_define_property.rs b/crates/perry-runtime/src/gc/tests/rooted_define_property.rs index 240cc35ff7..a29f0363af 100644 --- a/crates/perry-runtime/src/gc/tests/rooted_define_property.rs +++ b/crates/perry-runtime/src/gc/tests/rooted_define_property.rs @@ -186,28 +186,26 @@ fn unrooted_receiver_copy_still_names_from_space() { let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_handle_scanner(); - unsafe { - let scope = RuntimeHandleScope::new(); - let rooted = scope.root_nanbox_f64(object_value(crate::object::js_object_alloc(0, 0))); - let unrooted_copy = addr_of(rooted.get_nanbox_f64()); + let scope = RuntimeHandleScope::new(); + let rooted = scope.root_nanbox_f64(object_value(crate::object::js_object_alloc(0, 0))); + let unrooted_copy = addr_of(rooted.get_nanbox_f64()); - let trace = collect_minor_trace(GcTriggerKind::Direct); - assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); - assert!(trace.copying_nursery.copied_objects > 0); + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + assert!(trace.copying_nursery.copied_objects > 0); - assert_ne!( - addr_of(rooted.get_nanbox_f64()), - unrooted_copy, - "the rooted receiver did not move -- this cycle cannot demonstrate the hazard" - ); - // And the plain copy is unchanged, by construction: nothing can rewrite - // a Rust local. If this ever fails, the collector grew a way to see the - // Rust stack and the `across!` discipline can be retired. - assert_eq!( - unrooted_copy, unrooted_copy, - "a plain usize cannot be rewritten by the collector" - ); - } + assert_ne!( + addr_of(rooted.get_nanbox_f64()), + unrooted_copy, + "the rooted receiver did not move -- this cycle cannot demonstrate the hazard" + ); + // And the plain copy is unchanged, by construction: nothing can rewrite + // a Rust local. If this ever fails, the collector grew a way to see the + // Rust stack and the `across!` discipline can be retired. + assert_eq!( + unrooted_copy, unrooted_copy, + "a plain usize cannot be rewritten by the collector" + ); } #[test] diff --git a/crates/perry-runtime/src/gc/trace.rs b/crates/perry-runtime/src/gc/trace.rs index 886ff6361e..d89fd72a2b 100644 --- a/crates/perry-runtime/src/gc/trace.rs +++ b/crates/perry-runtime/src/gc/trace.rs @@ -158,6 +158,7 @@ impl ValidPointerSet { } /// Total censused entries (arena starts + malloc starts). + #[cfg(test)] pub(super) fn lookup_count(&self) -> usize { self.arena_count + self.malloc_lookup.len() } diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index 5ea9813249..9bbc9ef66e 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -1476,10 +1476,10 @@ pub unsafe fn lazy_get(hdr: *mut LazyArrayHeader, i: u32) -> JSValue { if i >= cached_length { return JSValue::from_bits(crate::value::TAG_UNDEFINED); } - - // Fast path 2: bitmap hit. let bitmap = (*hdr).materialized_bitmap; let cache = (*hdr).materialized_elements; + + // Fast path 2: bitmap hit. if !bitmap.is_null() && !cache.is_null() { let word_idx = (i as usize) / 64; let bit_idx = (i as usize) % 64; @@ -1779,8 +1779,6 @@ pub unsafe fn force_materialize_lazy(hdr: *mut LazyArrayHeader) -> *mut crate::a return (*hdr).materialized; } let cached_length = (*hdr).cached_length; - let bitmap = (*hdr).materialized_bitmap; - let cache = (*hdr).materialized_elements; // Same helper `lazy_get`'s scan-flip trigger consults, so the trigger // can never ask for a producer this function then declines. let cached_count = lazy_cached_count(hdr); diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 7a0daf976c..65555e5141 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -191,6 +191,8 @@ fn register_map(ptr: *mut MapHeader, entries: *mut f64, capacity: usize) { }); } +#[cfg(test)] +thread_local! { /// Every entry into [`is_registered_map`], i.e. every caller that could not /// rule a `Map` out more cheaply. The `js_array_get_f64` / `js_array_length` /// receiver-tag gates (#7765) are asserted against this: a plain-array element @@ -200,8 +202,6 @@ fn register_map(ptr: *mut MapHeader, entries: *mut f64, capacity: usize) { /// Per THREAD, not per process: the registries themselves are thread-local, and /// `cargo test` runs every case on its own thread in one process, so a global /// counter would be moved by whatever else happens to be running. -#[cfg(test)] -thread_local! { static TEST_MAP_REGISTRY_PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/perry-runtime/src/native_handle.rs b/crates/perry-runtime/src/native_handle.rs index aeaa95b90c..53597b232f 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -93,6 +93,7 @@ pub(crate) fn runtime_main_thread_id() -> u64 { /// handle call recorded main first — and a 0 here reads as "unrecorded", /// which waves EVERY thread through [`is_main_thread_or_unrecorded`] and /// reintroduces the worker-teardown print this exists to prevent. +#[cfg(test)] pub(crate) fn record_diagnostics_owner_thread() { let _ = MAIN_OS_THREAD_ID.compare_exchange( 0, diff --git a/crates/perry-runtime/src/node_submodules/test.rs b/crates/perry-runtime/src/node_submodules/test.rs index f7aa9849f9..9802017d29 100644 --- a/crates/perry-runtime/src/node_submodules/test.rs +++ b/crates/perry-runtime/src/node_submodules/test.rs @@ -81,7 +81,12 @@ fn set_field(obj: *mut crate::object::ObjectHeader, name: &str, value: f64) { fn make_closure(func: *const u8, arity: u32, captures: u32) -> *mut crate::closure::ClosureHeader { js_register_closure_arity(func, arity); - js_closure_alloc(func, captures) + let closure = js_closure_alloc(func, captures); + // Optimized Windows links may fold identical COMDAT function bodies, so + // the function-pointer registry is not a stable identity for reflective + // metadata. Pin the requested arity to this closure instance as well. + crate::object::set_builtin_closure_length(closure as usize, arity); + closure } fn closure_value(func: *const u8, arity: u32) -> f64 { @@ -97,6 +102,7 @@ fn closure_value_with_id(func: *const u8, arity: u32, id: i64) -> f64 { fn rest_closure_value_with_id(func: *const u8, fixed_arity: u32, id: i64) -> f64 { js_register_closure_rest(func, fixed_arity); let closure = js_closure_alloc(func, 1); + crate::object::set_builtin_closure_length(closure as usize, fixed_arity); js_closure_set_capture_ptr(closure, 0, id); boxed_ptr(closure) } diff --git a/crates/perry-runtime/src/object/arguments.rs b/crates/perry-runtime/src/object/arguments.rs index 4e05fa0463..3704d43449 100644 --- a/crates/perry-runtime/src/object/arguments.rs +++ b/crates/perry-runtime/src/object/arguments.rs @@ -249,11 +249,11 @@ pub(crate) fn is_arguments_object(obj: *const ObjectHeader) -> bool { ARGUMENTS_OBJECTS.with(|m| m.borrow().contains_key(&(obj as usize))) } +#[cfg(test)] +thread_local! { /// Every entry into [`is_arguments_object`]. Twin of /// `set::TEST_SET_REGISTRY_PROBES` — lets a test assert that the latch /// actually short-circuits rather than merely that nothing threw. -#[cfg(test)] -thread_local! { static TEST_ARGUMENTS_REGISTRY_PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index afc9d0a65f..f2ccfd7b6d 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -58,8 +58,7 @@ pub(crate) use state::{ class_object_value_root_store, class_own_enumerable_field_names, class_own_static_field_value, class_parent_closure, class_parent_closure_root_store, class_prototype_method_is_enumerable, class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store, - class_prototype_object_root_store, class_static_defined_attrs, - class_static_key_is_non_enumerable, class_static_set_defined_attrs, + class_prototype_object_root_store, class_static_defined_attrs, class_static_set_defined_attrs, global_object_prototype_bits, is_bound_native_method_closure_value, is_non_constructable_builtin_function_value, parent_closure_in_chain, throw_non_constructable_builtin_function, diff --git a/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs b/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs index c61813a36f..0a31a6894d 100644 --- a/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs +++ b/crates/perry-runtime/src/object/class_registry/builtin_alias_construct.rs @@ -24,6 +24,7 @@ /// Names this module constructs. Kept beside `construct` so the match in /// `construct.rs` and the arms here cannot drift apart. +#[allow(dead_code)] // consumed only when the indirect-constructor surface is enabled pub(crate) fn handles(name: &str) -> bool { matches!( name, @@ -41,6 +42,7 @@ pub(crate) fn handles(name: &str) -> bool { } /// Construct `name` with `args`. Only called for names `handles` accepts. +#[allow(dead_code)] // paired with `handles` above pub(crate) fn construct(name: &str, args: &[f64]) -> f64 { match name { "EventTarget" => { diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 23328e1fbc..a3f2d2a279 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -1517,7 +1517,7 @@ mod c3c_pic_tests { #[test] fn a_compacted_class_instance_primes_a_token_a_pristine_sibling_cannot_match() { let _lock = crate::gc::global_side_table_test_lock(); - unsafe { + { let packed = b"picdel_a\0picdel_b\0picdel_c"; let mk = || { crate::object::js_object_alloc_class_with_keys( @@ -1688,7 +1688,7 @@ mod array_length_fast_path_tests { #[test] fn array_length_short_circuit_agrees_with_the_full_ladder() { let _lock = crate::gc::global_side_table_test_lock(); - unsafe { + { let len_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); let other_key = crate::string::js_string_from_bytes(b"lengtx".as_ptr(), 6); for n in [0u32, 1, 5, 40] { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 6e8c205360..949d5042c3 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -596,12 +596,6 @@ crate::perry_thread_local! { /// this side-table keyed by class_id. pub(crate) static CLASS_DYNAMIC_PROPS: std::cell::RefCell>> = std::cell::RefCell::new(std::collections::HashMap::new()); - /// Configurable synthetic class-ref keys that were deleted (currently - /// `name`). Mirrors the closure deleted-key side table for ClassRef values, - /// which are tagged integers rather than ObjectHeader/ClosureHeader values. - pub(crate) static CLASS_DELETED_KEYS: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); - /// #7190: `(writable, enumerable)` for static own keys installed by /// `Object.defineProperty(C, k, desc)`. They live in `CLASS_DYNAMIC_PROPS` /// next to `static x = …` fields, which are writable AND enumerable by diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 3176afdb83..84a55d9157 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -82,8 +82,20 @@ pub(crate) unsafe fn ensure_key_in_keys_array( } } } - // Clone shared keys array if needed, then append. - let owned_keys = if key_count == (*obj).field_count as usize { + // Clone a shape-cache / transition-cache keys array before appending. + // + // The old `key_count == field_count` proxy was not an ownership test. + // Objects may legitimately have a different logical field boundary while + // still pointing at the shared shape array. In that case defineProperty + // appended directly to the cache entry, so sibling `{}` allocations grew + // the same phantom own key (Babel's webpack exports objects exposed this + // as an enumerable `ALIAS_KEYS: undefined`). The caches already stamp the + // authoritative GC_FLAG_SHAPE_SHARED bit; use it just like the ordinary + // [[Set]] growth path does. + let keys_gc_header = + (keys as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + let keys_shared = (*keys_gc_header).gc_flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0; + let owned_keys = if keys_shared { let cloned = crate::array::js_array_alloc(key_count as u32 + 4); refresh_define_property_roots!(); let keys = (*obj).keys_array; @@ -142,6 +154,34 @@ pub(crate) unsafe fn ensure_key_in_keys_array( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn define_property_key_growth_does_not_mutate_a_shared_shape_sibling() { + unsafe { + let packed = b""; + let first = + crate::object::js_object_alloc_with_shape(0x6B45_5901, 0, packed.as_ptr(), 0); + let sibling = + crate::object::js_object_alloc_with_shape(0x6B45_5901, 0, packed.as_ptr(), 0); + assert_eq!((*first).keys_array, (*sibling).keys_array); + + // A logical-field/key-count mismatch is not evidence that the + // keys array is privately owned. This was the false assumption in + // the old clone condition. + (*first).field_count = 1; + let key = crate::string::js_string_from_bytes(b"ALIAS_KEYS".as_ptr(), 10); + ensure_key_in_keys_array(first, key); + + assert!(own_key_present(first, key)); + assert!(!own_key_present(sibling, key)); + assert_ne!((*first).keys_array, (*sibling).keys_array); + } + } +} + /// Install a built-in *getter-only* accessor on a prototype object so that /// `Object.getOwnPropertyDescriptor(proto, key)` reflects it as a real /// accessor descriptor `{ get, set: undefined, enumerable, configurable }`. diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index c0115bd5d9..972092a3c8 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -691,26 +691,24 @@ fn inline_slot_floor_matches_codegen() { /// that makes the floor a footprint dial rather than a correctness one. #[test] fn by_name_growth_past_the_floor_reads_back() { - unsafe { - let obj = js_object_alloc(0, 0); - assert!(!obj.is_null()); - let names: [&[u8]; 6] = [b"k0", b"k1", b"k2", b"k3", b"k4", b"k5"]; - for (i, n) in names.iter().enumerate() { - let key = crate::string::js_string_from_bytes(n.as_ptr(), n.len() as u32); - js_object_set_field_by_name(obj, key, i as f64); - } - for (i, n) in names.iter().enumerate() { - let key = crate::string::js_string_from_bytes(n.as_ptr(), n.len() as u32); - let got = js_object_get_field_by_name(obj, key); - assert!( - got.is_number() && got.as_number() == i as f64, - "field {} ({}) read back as {:#x}; the inline/overflow boundary \ - must be invisible to reads", - i, - std::str::from_utf8(n).unwrap(), - got.bits() - ); - } + let obj = js_object_alloc(0, 0); + assert!(!obj.is_null()); + let names: [&[u8]; 6] = [b"k0", b"k1", b"k2", b"k3", b"k4", b"k5"]; + for (i, n) in names.iter().enumerate() { + let key = crate::string::js_string_from_bytes(n.as_ptr(), n.len() as u32); + js_object_set_field_by_name(obj, key, i as f64); + } + for (i, n) in names.iter().enumerate() { + let key = crate::string::js_string_from_bytes(n.as_ptr(), n.len() as u32); + let got = js_object_get_field_by_name(obj, key); + assert!( + got.is_number() && got.as_number() == i as f64, + "field {} ({}) read back as {:#x}; the inline/overflow boundary \ + must be invisible to reads", + i, + std::str::from_utf8(n).unwrap(), + got.bits() + ); } } @@ -1551,7 +1549,7 @@ fn constructor_ref_method_value_resolves_static_over_instance_method() { CLASS_ID as i64, NAME.as_ptr(), NAME.len() as i64, - instance_lex_7689 as usize as i64, + instance_lex_7689 as *const () as usize as i64, 0, 0, 0, @@ -1560,7 +1558,7 @@ fn constructor_ref_method_value_resolves_static_over_instance_method() { CLASS_ID as i64, NAME.as_ptr(), NAME.len() as i64, - static_lex_7689 as usize as i64, + static_lex_7689 as *const () as usize as i64, 0, 0, ); diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 5fe963f6d3..7d91f654a1 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -1321,8 +1321,6 @@ fn js_os_user_info_impl(buffer_encoding: bool) -> *mut ObjectHeader { }); #[cfg(unix)] let shell = std::env::var("SHELL").unwrap_or_default(); - #[cfg(not(unix))] - let shell = String::new(); let string_value = |s: &str| -> JSValue { let ptr = js_string_from_bytes(s.as_ptr(), s.len() as u32); diff --git a/crates/perry-runtime/src/path/value_args.rs b/crates/perry-runtime/src/path/value_args.rs index d1dcc47782..f924660469 100644 --- a/crates/perry-runtime/src/path/value_args.rs +++ b/crates/perry-runtime/src/path/value_args.rs @@ -195,6 +195,10 @@ mod tests { .expect("entry point must return a real StringHeader") } + fn read_native_path(ptr: *mut StringHeader) -> String { + read(ptr).replace('\\', "/") + } + /// The bug: an inline operand's CHARACTERS were dereferenced as a header. #[test] fn sso_operand_resolves_to_a_real_header() { @@ -236,32 +240,32 @@ mod tests { #[test] fn both_operands_survive_the_materialisation_window() { assert_eq!( - read(js_path_join_value(sso_string("/r"), sso_string("s1"))), + read_native_path(js_path_join_value(sso_string("/r"), sso_string("s1"))), "/r/s1" ); assert_eq!( - read(js_path_join_value( + read_native_path(js_path_join_value( heap_string("/root-that-is-long"), sso_string("s1") )), "/root-that-is-long/s1" ); assert_eq!( - read(js_path_join_value( + read_native_path(js_path_join_value( sso_string("/r"), heap_string("segment-longer-than-sso") )), "/r/segment-longer-than-sso" ); assert_eq!( - read(js_path_resolve_join_value( + read_native_path(js_path_resolve_join_value( heap_string("/root"), sso_string("s1") )), "/root/s1" ); assert_eq!( - read(js_path_resolve_join_value( + read_native_path(js_path_resolve_join_value( sso_string("/a"), sso_string("/b") )), diff --git a/crates/perry-runtime/src/plugin.rs b/crates/perry-runtime/src/plugin.rs index 7cbf601c68..0fdd6d9930 100644 --- a/crates/perry-runtime/src/plugin.rs +++ b/crates/perry-runtime/src/plugin.rs @@ -19,7 +19,9 @@ //! registration order. use std::collections::HashMap; -use std::ffi::{CStr, CString}; +#[cfg(not(windows))] +use std::ffi::CStr; +use std::ffi::CString; use std::sync::Mutex; use lazy_static::lazy_static; diff --git a/crates/perry-runtime/src/process/credentials.rs b/crates/perry-runtime/src/process/credentials.rs index 2502c4d6c0..27a18b2618 100644 --- a/crates/perry-runtime/src/process/credentials.rs +++ b/crates/perry-runtime/src/process/credentials.rs @@ -5,6 +5,7 @@ //! targets the accessors return 0 and the setters are no-ops. use super::format_out_of_range_number; +#[cfg(unix)] use crate::string::StringHeader; use crate::value::JSValue; diff --git a/crates/perry-runtime/src/process/ipc.rs b/crates/perry-runtime/src/process/ipc.rs index 551ad7c93b..770d6d1a99 100644 --- a/crates/perry-runtime/src/process/ipc.rs +++ b/crates/perry-runtime/src/process/ipc.rs @@ -11,7 +11,9 @@ use crate::closure::{ js_closure_alloc, js_closure_get_capture_ptr, js_closure_set_capture_ptr, js_native_call_value, js_register_closure_arity, js_register_closure_length, ClosureHeader, }; -use crate::string::{js_string_from_bytes, StringHeader}; +use crate::string::js_string_from_bytes; +#[cfg(unix)] +use crate::string::StringHeader; use crate::value::{JSValue, TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED}; use std::collections::VecDeque; use std::sync::{Mutex, MutexGuard, OnceLock}; diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 519d686a35..b400acdd92 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -95,10 +95,9 @@ pub use exec::js_regexp_exec; pub use match_string::{js_string_match, js_string_match_value, js_string_search_value}; crate::perry_thread_local! { - /// Last exec result metadata: (index, groups_object_ptr) - /// Stored per-thread so that `m.index` and `m.groups` can retrieve them - /// after the exec call. + #[cfg(feature = "regex-engine")] static LAST_EXEC_INDEX: RefCell = const { RefCell::new(0.0) }; + static LAST_EXEC_GROUPS: RefCell<*mut ObjectHeader> = const { RefCell::new(ptr::null_mut()) }; /// Set of all RegExpHeader pointers ever allocated in this thread. diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 66b806d60d..9ecf8d68be 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -221,10 +221,10 @@ fn register_set(ptr: *mut SetHeader, elements: *mut f64, capacity: usize) { }); } -/// Every entry into [`is_registered_set`]. Twin of -/// `map::TEST_MAP_REGISTRY_PROBES` — see that counter for what it pins down. #[cfg(test)] thread_local! { +/// Every entry into [`is_registered_set`]. Twin of +/// `map::TEST_MAP_REGISTRY_PROBES` — see that counter for what it pins down. static TEST_SET_REGISTRY_PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index 9f02070dc8..3a3af43f74 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -497,12 +497,12 @@ pub extern "C" fn js_string_concat_chain(parts: *const f64, n: i32) -> *mut Stri } } +#[cfg(test)] +thread_local! { /// #7912 counter: how many chains took the unrooted fast path below. A gate /// that cannot see its subject run is not a gate — the unit tests assert this /// moves, so a refactor that quietly stops taking the fast path is red rather /// than "still correct, just slow again". -#[cfg(test)] -thread_local! { pub(crate) static CONCAT_CHAIN_NO_COLLECT_HITS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -915,7 +915,7 @@ pub extern "C" fn js_value_concat_string( fn string_handle_of(value: f64) -> *const StringHeader { let jsval = crate::value::JSValue::from_bits(value.to_bits()); if jsval.is_string() { - return unsafe { jsval.as_string_ptr() }; + return jsval.as_string_ptr(); } crate::value::js_get_string_pointer_unified(value) as *const StringHeader } diff --git a/crates/perry-runtime/src/string/split.rs b/crates/perry-runtime/src/string/split.rs index d10c569e7a..28d9910934 100644 --- a/crates/perry-runtime/src/string/split.rs +++ b/crates/perry-runtime/src/string/split.rs @@ -580,6 +580,7 @@ pub extern "C" fn js_string_split_value( limit: f64, ) -> *mut ArrayHeader { use crate::value::JSValue; + #[cfg(feature = "regex-engine")] let sep_jv = JSValue::from_bits(separator.to_bits()); let lim_jv = JSValue::from_bits(limit.to_bits()); let scope = crate::gc::RuntimeHandleScope::new(); diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index 9baba08635..0dc4cbe527 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -545,7 +545,7 @@ fn concat_box_delegates_a_non_string_operand_to_the_dynamic_add() { f64::from_bits(crate::value::JSValue::string_ptr(p).bits()) }; let text = |v: f64| { - let p = unsafe { crate::value::js_jsvalue_to_string(v) }; + let p = crate::value::js_jsvalue_to_string(v); let bytes = unsafe { std::slice::from_raw_parts(string_data(p), (*p).byte_len as usize) }; String::from_utf8(bytes.to_vec()).expect("ascii") }; diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 85aa965545..15a27b2207 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -376,12 +376,12 @@ pub(crate) unsafe fn may_be_symbol_header(ptr: *const u8) -> bool { std::ptr::read_unaligned(ptr as *const u32) == SYMBOL_MAGIC } +#[cfg(test)] +thread_local! { /// Test-only override that forces [`may_be_symbol_header`] to answer `true` — /// i.e. removes the screen without deleting it, so a test can show the screen is /// what makes the fast path fast rather than dead code in front of a probe that /// would have answered anyway. -#[cfg(test)] -thread_local! { static TEST_DISABLE_SYMBOL_MAGIC_SCREEN: std::cell::Cell = const { std::cell::Cell::new(false) }; } @@ -402,13 +402,13 @@ pub(crate) fn register_symbol_pointer(ptr: usize) { guard.as_mut().unwrap().insert(ptr); } +#[cfg(test)] +thread_local! { /// Every entry into [`is_registered_symbol`] that got past the latch, i.e. /// every caller that could not rule a `Symbol` out more cheaply. Twin of /// `map::TEST_MAP_REGISTRY_PROBES`: #7850's header-directed dispatch in /// `object::native_call_method` is asserted against this, so "the probe no /// longer runs on a plain-object dispatch" is a test rather than a claim. -#[cfg(test)] -thread_local! { static TEST_SYMBOL_REGISTRY_PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; } diff --git a/crates/perry-runtime/src/tui/tree.rs b/crates/perry-runtime/src/tui/tree.rs index 37025706a0..59c5ef508e 100644 --- a/crates/perry-runtime/src/tui/tree.rs +++ b/crates/perry-runtime/src/tui/tree.rs @@ -39,10 +39,9 @@ pub enum Node { }, } -/// Global handle table. Allocations come from `NEXT_HANDLE`; lookups -/// through the table take the lock for the duration of a get/set. The -/// lock isn't on the hot path (FFI calls fire on the main thread once -/// per render) so a plain Mutex is fine. +// Global handle table. Allocations come from `NEXT_HANDLE`; lookups through +// the table take the lock for the duration of a get/set. The lock isn't on the +// hot path (FFI calls fire on the main thread once per render). per_test_global! { static NEXT_HANDLE: AtomicI64 = AtomicI64::new(1); static REGISTRY: Mutex> = Mutex::new(Vec::new()); diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index a6484b690d..3318191c61 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -410,6 +410,7 @@ fn registry() -> crate::gc::GcRootRegistryGuard<'static, TypedFeedbackRegistry> /// already compile-gated. Now it produces nothing, which is the same amount of /// information and looks far more like success. The trace dump uses this to say /// so out loud rather than writing an empty file. +#[cfg(feature = "diagnostics")] pub(crate) fn no_sites_were_instrumented() -> bool { registry().sites.is_empty() } diff --git a/crates/perry-runtime/src/update_notify.rs b/crates/perry-runtime/src/update_notify.rs index 3d90eab80a..6698fbf4d5 100644 --- a/crates/perry-runtime/src/update_notify.rs +++ b/crates/perry-runtime/src/update_notify.rs @@ -204,13 +204,6 @@ pub struct CheckEnv<'a> { pub first_arg: Option<&'a str>, } -fn is_on(raw: Option<&str>) -> bool { - matches!( - raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("1") | Some("true") | Some("on") | Some("yes") - ) -} - fn is_present(raw: Option<&str>) -> bool { !matches!( raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(), diff --git a/crates/perry-runtime/src/url/node_compat.rs b/crates/perry-runtime/src/url/node_compat.rs index 9ecd9621fc..770d6b4134 100644 --- a/crates/perry-runtime/src/url/node_compat.rs +++ b/crates/perry-runtime/src/url/node_compat.rs @@ -239,6 +239,15 @@ mod tests { assert!(href.ends_with("/x.txt"), "href {href:?}"); } + #[cfg(windows)] + #[test] + fn legacy_verbatim_import_meta_url_decodes_as_drive_path() { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + let path = + super::js_url_file_url_to_path(str_f64("file:////?/C:/project/bundle.mjs"), undefined); + assert_eq!(super::string_from_js_value(path), "C:\\project\\bundle.mjs"); + } + #[cfg(not(windows))] #[test] fn file_url_conversions_default_to_posix_elsewhere() { @@ -360,6 +369,23 @@ fn file_url_to_path_bytes(url_f64: f64, windows: bool) -> Vec { throw_url_type_error_with_code("The URL must be of scheme file", "ERR_INVALID_URL_SCHEME"); }; + // Compatibility for objects compiled before Perry normalized Windows + // verbatim paths in import.meta.url. Those objects contain + // `file:////?/C:/...`; normalize the Win32 `\\?\` decoration before URL + // query parsing sees its question mark. + let normalized_after_scheme; + let after_scheme = if windows { + let slash_trimmed = after_scheme.trim_start_matches('/'); + if let Some(rest) = slash_trimmed.strip_prefix("?/") { + normalized_after_scheme = format!("///{rest}"); + normalized_after_scheme.as_str() + } else { + after_scheme + } + } else { + after_scheme + }; + let (host, pathname) = if let Some(authority_and_path) = after_scheme.strip_prefix("//") { let path_start = authority_and_path .find('/') diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index e63de8351a..a8b0fdf2e8 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -73,6 +73,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_CANONICAL_I32_LOCALS", "PERRY_CANONICAL_STR_LOCALS", "PERRY_CODEGEN_UNITS", + "PERRY_CODEGEN_UNIT_BYTES", "PERRY_CODEGEN_UNIT_SIZE", "PERRY_ENTRY_SYMBOL", "PERRY_FULL_OUTLINE_IC", @@ -85,6 +86,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_INT_VALUED_LOCALS", "PERRY_JSCVT", "PERRY_LD", + "PERRY_LLVM_LIB", "PERRY_LLVM_OPT", "PERRY_LL_O0_THRESHOLD_BYTES", "PERRY_LL_SIZE_OPT", @@ -122,9 +124,12 @@ const BUILD_CACHE_ENV_EXCLUSIONS: &[&str] = &[ // having the test. "PERRY_OPT_REPORT", // Parallelism only — partitioning is keyed by PERRY_CODEGEN_UNIT_SIZE / - // PERRY_CODEGEN_UNITS, which ARE inputs; the job count just decides how - // many threads chew through the same units. + // PERRY_CODEGEN_UNIT_BYTES / PERRY_CODEGEN_UNITS, which ARE inputs; the + // job count just decides how many threads chew through the same units. "PERRY_CODEGEN_UNIT_JOBS", + // Human-facing telemetry only; never changes IR or object bytes. + "PERRY_CODEGEN_PROGRESS", + "PERRY_CODEGEN_UNIT_TIMINGS", ]; #[cfg(test)] diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 3c334dbad7..89d23cd38e 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -921,6 +921,10 @@ fn compute_object_cache_key_with_env( "env_codegen_unit_size", env_var("PERRY_CODEGEN_UNIT_SIZE").as_deref().unwrap_or(""), ); + h.field( + "env_codegen_unit_bytes", + env_var("PERRY_CODEGEN_UNIT_BYTES").as_deref().unwrap_or(""), + ); h.field( "env_gc_moving_loop_polls", env_var("PERRY_GC_MOVING_LOOP_POLLS") diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index dd54983291..cb94452047 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -604,6 +604,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_LL_SIZE_OPT_MAX_FN_BYTES", "PERRY_ENTRY_SYMBOL", "PERRY_CODEGEN_UNITS", + "PERRY_CODEGEN_UNIT_BYTES", "PERRY_CODEGEN_UNIT_SIZE", "PERRY_GC_MOVING_LOOP_POLLS", // Inline-hot-small (#6850 follow-up). diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index b2e638601a..d8d22dad91 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -21,6 +21,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex}; +use std::time::Instant; use crate::OutputFormat; @@ -30,6 +32,184 @@ use crate::OutputFormat; /// stop so a pathological (or cyclic) re-export graph can never spin forever. const MAX_REEXPORT_HOPS: usize = 16; +/// Keep outer module parallelism and each module's inner LLVM workers inside a +/// single conservative CPU/memory budget. Large generated modules retain a +/// substantial HIR/LLVM graph while their units compile, so letting Rayon's +/// host-sized global pool start one such graph per logical CPU can exhaust RAM. +fn default_module_codegen_jobs( + total_modules: usize, + logical_cpus: usize, + llvm_unit_jobs: usize, +) -> usize { + let worker_budget = logical_cpus.max(1).min(4); + (worker_budget / llvm_unit_jobs.max(1)) + .max(1) + .min(3) + .min(total_modules.max(1)) +} + +fn configured_module_codegen_jobs(total_modules: usize) -> (usize, usize) { + let llvm_unit_jobs = std::env::var("PERRY_CODEGEN_UNIT_JOBS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|&value| value > 0) + .unwrap_or(2); + let logical_cpus = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1); + let default_jobs = default_module_codegen_jobs(total_modules, logical_cpus, llvm_unit_jobs); + let module_jobs = std::env::var("PERRY_MODULE_JOBS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|&value| value > 0) + .unwrap_or(default_jobs) + .min(total_modules.max(1)); + (module_jobs, llvm_unit_jobs) +} + +// OpenCode's 0.5--1.0 MiB generated chunks routinely lower to 20--45 MiB of +// LLVM input even with fewer than 1,000 HIR callables. Treat that observed +// range as memory-heavy too: ordinary modules still use outer parallelism, +// while one generated chunk at a time gets the full inner-unit budget. +const EXCLUSIVE_MODULE_CALLABLES: usize = 500; +const EXCLUSIVE_MODULE_SOURCE_BYTES: u64 = 512 * 1024; + +fn module_codegen_callable_count(module: &perry_hir::Module) -> usize { + let class_callables: usize = module + .classes + .iter() + .map(|class| { + usize::from(class.constructor.is_some()) + + class.methods.len() + + class.static_methods.len() + + class.computed_members.len() + + class.getters.len() + + class.setters.len() + }) + .sum(); + module.functions.len() + class_callables +} + +fn module_codegen_is_exclusive(path: &Path, module: &perry_hir::Module) -> bool { + module_codegen_callable_count(module) >= EXCLUSIVE_MODULE_CALLABLES + || fs::metadata(path) + .map(|metadata| metadata.len() >= EXCLUSIVE_MODULE_SOURCE_BYTES) + .unwrap_or(false) +} + +struct ModuleCodegenLimiter { + available: Mutex, + ready: Condvar, + capacity: usize, +} + +struct ModuleCodegenPermit<'a> { + limiter: &'a ModuleCodegenLimiter, + weight: usize, +} + +impl ModuleCodegenLimiter { + fn new(capacity: usize) -> Self { + Self { + available: Mutex::new(capacity.max(1)), + ready: Condvar::new(), + capacity: capacity.max(1), + } + } + + fn acquire(&self, exclusive: bool) -> ModuleCodegenPermit<'_> { + let weight = if exclusive { self.capacity } else { 1 }; + let mut available = self.available.lock().expect("module limiter poisoned"); + while *available < weight { + available = self.ready.wait(available).expect("module limiter poisoned"); + } + *available -= weight; + ModuleCodegenPermit { + limiter: self, + weight, + } + } +} + +impl Drop for ModuleCodegenPermit<'_> { + fn drop(&mut self) { + let mut available = self + .limiter + .available + .lock() + .expect("module limiter poisoned"); + *available += self.weight; + self.limiter.ready.notify_all(); + } +} + +struct ModuleCodegenCompletion<'a> { + completed: &'a AtomicUsize, + total: usize, + started: Instant, + enabled: bool, +} + +impl Drop for ModuleCodegenCompletion<'_> { + fn drop(&mut self) { + let done = self.completed.fetch_add(1, Ordering::Relaxed) + 1; + if !self.enabled || self.total == 0 { + return; + } + let report_step = (self.total / 20).max(1); + if done != self.total && done % report_step != 0 { + return; + } + let elapsed = self.started.elapsed().as_secs_f64(); + let eta = if done < self.total { + elapsed * self.total.saturating_sub(done) as f64 / done as f64 + } else { + 0.0 + }; + eprintln!( + "[perry] codegen: modules finished {done}/{} ({:.0}%; {:.1} min elapsed; ETA ~{:.1} min)", + self.total, + done as f64 * 100.0 / self.total as f64, + elapsed / 60.0, + eta / 60.0 + ); + } +} + +#[cfg(test)] +mod module_codegen_job_tests { + use super::{default_module_codegen_jobs, ModuleCodegenLimiter}; + + #[test] + fn coordinates_outer_and_inner_parallelism() { + assert_eq!(default_module_codegen_jobs(308, 12, 2), 2); + assert_eq!(default_module_codegen_jobs(308, 12, 4), 1); + assert_eq!(default_module_codegen_jobs(308, 2, 1), 2); + } + + #[test] + fn never_starts_more_jobs_than_modules() { + assert_eq!(default_module_codegen_jobs(1, 64, 1), 1); + assert_eq!(default_module_codegen_jobs(2, 64, 1), 2); + assert_eq!(default_module_codegen_jobs(0, 64, 1), 1); + } + + #[test] + fn oversized_module_reserves_the_entire_outer_pool() { + let limiter = ModuleCodegenLimiter::new(2); + { + let _ordinary = limiter.acquire(false); + assert_eq!(*limiter.available.lock().unwrap(), 1); + } + assert_eq!(*limiter.available.lock().unwrap(), 2); + { + let _oversized = limiter.acquire(true); + assert_eq!(*limiter.available.lock().unwrap(), 0); + } + assert_eq!(*limiter.available.lock().unwrap(), 2); + } +} + /// Builds the complete codegen view of a foreign HIR class. /// /// Callers choose only the import binding (when the route introduces one) and @@ -118,6 +298,20 @@ pub fn run_with_parse_cache( let mut args = args; args.target = apply_libc_to_target(args.target.take(), args.libc.as_deref())?; + // Long native builds must never look hung. The codegen crate owns the + // detailed phase/unit reporter; enable it for human-readable CLI output + // and keep JSON output machine-clean. + if std::env::var_os("PERRY_CODEGEN_PROGRESS").is_none() { + std::env::set_var( + "PERRY_CODEGEN_PROGRESS", + if matches!(format, OutputFormat::Text) { + "1" + } else { + "0" + }, + ); + } + // #835 + #846: clear the codegen-side FFI provenance set up-front // so any leftover entries from a prior `perry dev` rebuild (or a // failed-build early-return that skipped our drain below) don't @@ -2166,6 +2360,29 @@ pub fn run_with_parse_cache( let total_codegen_modules = ctx.native_modules.len(); let codegen_modules_started = AtomicUsize::new(0); + let codegen_modules_completed = AtomicUsize::new(0); + let codegen_started = Instant::now(); + let codegen_progress_enabled = matches!(format, OutputFormat::Text) + && std::env::var("PERRY_CODEGEN_PROGRESS").as_deref() != Ok("0"); + let (module_jobs, llvm_unit_jobs) = configured_module_codegen_jobs(total_codegen_modules); + let exclusive_modules = ctx + .native_modules + .iter() + .filter(|(path, module)| module_codegen_is_exclusive(path, module)) + .count(); + if matches!(format, OutputFormat::Text) && total_codegen_modules > 1 { + eprintln!( + "[perry] codegen: module parallelism: {module_jobs} module jobs x \ + {llvm_unit_jobs} LLVM unit workers (override with PERRY_MODULE_JOBS / \ + PERRY_CODEGEN_UNIT_JOBS)" + ); + if module_jobs > 1 && exclusive_modules > 0 { + eprintln!( + "[perry] codegen: {exclusive_modules} oversized module(s) will run exclusively \ + to cap peak memory" + ); + } + } // Where this compile's objects go — see `compile/object_staging.rs`. // // #7167: only a compile that is going to *link* gets a temp staging @@ -2196,10 +2413,22 @@ pub fn run_with_parse_cache( no_link_destination.dir().to_path_buf() } }; - let compile_results: Vec> = ctx - .native_modules - .par_iter() - .map(|(path, hir_module)| { + let module_pool = rayon::ThreadPoolBuilder::new() + .num_threads(module_jobs) + .thread_name(|index| format!("perry-module-{index}")) + .build() + .map_err(|error| anyhow!("failed to create module codegen pool: {error}"))?; + let module_limiter = ModuleCodegenLimiter::new(module_jobs); + let compile_results: Vec> = module_pool.install(|| { + ctx.native_modules.par_iter().map(|(path, hir_module)| { + let _permit = + module_limiter.acquire(module_codegen_is_exclusive(path, hir_module)); + let _completion = ModuleCodegenCompletion { + completed: &codegen_modules_completed, + total: total_codegen_modules, + started: codegen_started, + enabled: codegen_progress_enabled, + }; // Compile this module to LLVM IR (or .ll text in bitcode-link mode) // and return the object bytes for the linker to consume. let codegen_index = codegen_modules_started.fetch_add(1, Ordering::Relaxed) + 1; @@ -4533,7 +4762,8 @@ pub fn run_with_parse_cache( stored_cache_path: false, }) }) - .collect(); + .collect() + }); // Tier 4.4 (v0.5.336): partition compile results, then write object // files in parallel via rayon. The OS handles concurrent writes to diff --git a/crates/perry/src/commands/progress.rs b/crates/perry/src/commands/progress.rs index ef41b7e579..b689e6798b 100644 --- a/crates/perry/src/commands/progress.rs +++ b/crates/perry/src/commands/progress.rs @@ -8,7 +8,8 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); #[derive(Debug)] pub(crate) struct VerboseProgress { - enabled: bool, + detail_enabled: bool, + heartbeat_enabled: bool, last_heartbeat: Mutex, } @@ -26,20 +27,25 @@ pub(crate) struct ProgressSnapshot<'a> { impl VerboseProgress { pub(crate) fn new(format: OutputFormat, verbose: u8) -> Self { + let text_output = matches!(format, OutputFormat::Text); Self { - enabled: verbose > 0 && matches!(format, OutputFormat::Text), + detail_enabled: verbose > 0 && text_output, + // A normal human-readable build still needs a sign of life during + // module discovery/lowering. Keep the per-module event stream + // behind `-v`, but emit the throttled heartbeat by default. + heartbeat_enabled: text_output, last_heartbeat: Mutex::new(Instant::now()), } } pub(crate) fn record(&self, snapshot: ProgressSnapshot<'_>) { - if self.enabled { + if self.detail_enabled { eprintln!("{}", format_progress_line(&snapshot, false)); } } pub(crate) fn heartbeat(&self, snapshot: ProgressSnapshot<'_>) { - if !self.enabled { + if !self.heartbeat_enabled { return; } @@ -135,4 +141,15 @@ mod tests { "[progress] heartbeat stage=lower module=/repo/src/main.ts api=WebAssembly.instantiate visited=3" ); } + + #[test] + fn text_progress_heartbeats_without_verbose_detail() { + let progress = VerboseProgress::new(OutputFormat::Text, 0); + assert!(!progress.detail_enabled); + assert!(progress.heartbeat_enabled); + + let json_progress = VerboseProgress::new(OutputFormat::Json, 1); + assert!(!json_progress.detail_enabled); + assert!(!json_progress.heartbeat_enabled); + } } diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 94210ef46c..368530b4a7 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2001 entries across 122 modules +// Coverage: 2006 entries across 122 modules type PerryU32 = number & { readonly __perryU32?: never }; type PerryU64 = number & { readonly __perryU64?: never }; @@ -3153,8 +3153,16 @@ declare module "perry/updater" { /** stdlib */ export function computeFileSha256(...args: any[]): any; /** stdlib */ + export function embeddedCheckHeaders(...args: any[]): any; + /** stdlib */ + export function embeddedCheckUrl(...args: any[]): any; + /** stdlib */ + export function embeddedRefreshDue(...args: any[]): any; + /** stdlib */ export function getBackupPath(...args: any[]): any; /** stdlib */ + export function getEmbeddedConfig(...args: any[]): any; + /** stdlib */ export function getExePath(...args: any[]): any; /** stdlib */ export function getSentinelPath(...args: any[]): any; @@ -3165,6 +3173,8 @@ declare module "perry/updater" { /** stdlib */ export function readSentinel(...args: any[]): any; /** stdlib */ + export function recordEmbeddedResponse(...args: any[]): any; + /** stdlib */ export function relaunch(...args: any[]): any; /** stdlib */ export function verifyHash(...args: any[]): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index bc7e973861..a3dc0bf8e3 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 2924 entries across 124 modules. +Total: 2929 entries across 124 modules. ## Modules @@ -2802,12 +2802,17 @@ Total: 2924 entries across 124 modules. - `clearSentinel` — module - `compareVersions` — module - `computeFileSha256` — module +- `embeddedCheckHeaders` — module +- `embeddedCheckUrl` — module +- `embeddedRefreshDue` — module - `getBackupPath` — module +- `getEmbeddedConfig` — module - `getExePath` — module - `getSentinelPath` — module - `installUpdate` — module - `performRollback` — module - `readSentinel` — module +- `recordEmbeddedResponse` — module - `relaunch` — module - `verifyHash` — module - `verifySignature` — module diff --git a/run_parity_tests.sh b/run_parity_tests.sh index 027b6158fe..90a03a0912 100755 --- a/run_parity_tests.sh +++ b/run_parity_tests.sh @@ -40,13 +40,17 @@ fi mkdir -p "$TEMP_ROOT" PYTHON_CMD="" -if command -v python3 &>/dev/null; then - PYTHON_CMD="python3" -elif command -v python &>/dev/null; then - # GitHub's Windows image exposes the setup-python shim as `python` on - # some revisions and `python3` on others. - PYTHON_CMD="python" -fi +# `command -v` alone is insufficient on Windows: the Microsoft Store app- +# execution alias can expose a `python3.exe` that only prints an installation +# prompt and exits nonzero. Probe the interpreter before selecting it. +for candidate in python3 python; do + if command -v "$candidate" &>/dev/null \ + && "$candidate" -c 'import sys; raise SystemExit(sys.version_info.major != 3)' \ + &>/dev/null; then + PYTHON_CMD="$candidate" + break + fi +done if [[ -z "$PYTHON_CMD" ]]; then echo "Python 3 is required by the parity output normalizer" >&2 exit 1 @@ -532,7 +536,7 @@ for raw in sys.stdin: } normalize_failure_output() { - printf '%s' "$1" | python3 -c ' + printf '%s' "$1" | "$PYTHON_CMD" -c ' import re import sys @@ -899,6 +903,13 @@ import json import os import sys +# Native Windows Python translates stdout newlines to CRLF. Values from this +# helper are consumed by Bash arithmetic and line-based resume logic, where the +# retained `\r` corrupts counts such as `1` into `1\r`. +if os.name == "nt": + sys.stdout.reconfigure(newline="\n") + sys.stderr.reconfigure(newline="\n") + cmd = sys.argv[1] diff --git a/scripts/gc_gate_wiring_check.py b/scripts/gc_gate_wiring_check.py index bbec208220..6419a6180c 100644 --- a/scripts/gc_gate_wiring_check.py +++ b/scripts/gc_gate_wiring_check.py @@ -562,7 +562,7 @@ def main() -> int: if not path.exists(): problems.append(f"{wf}: missing — a GC gate workflow was deleted") continue - problems.extend(check_gate(path.read_text(), job, wf)) + problems.extend(check_gate(path.read_text(encoding="utf-8"), job, wf)) # The constant-group hazard is not specific to the GC gates -- it hits any # scheduled workflow, and it took out `gate-freshness` (the alarm) too. So @@ -571,7 +571,7 @@ def main() -> int: scanned = 0 for path in sorted(wf_dir.glob("*.yml")): scanned += 1 - problems.extend(check_schedule_group(path.read_text(), path.name)) + problems.extend(check_schedule_group(path.read_text(encoding="utf-8"), path.name)) if problems: print("GC GATE WIRING: one or more gates cannot fail where it matters.\n", file=sys.stderr) diff --git a/scripts/gc_pin_sites.py b/scripts/gc_pin_sites.py index 4d3e61d3fa..2115d0c4e0 100644 --- a/scripts/gc_pin_sites.py +++ b/scripts/gc_pin_sites.py @@ -216,7 +216,7 @@ def scan(root: Path) -> tuple[list[tuple[str, int, str]], int]: for path in sorted(crates.rglob("*.rs")): if "/target/" in str(path): continue - rel = str(path.relative_to(root)) + rel = path.relative_to(root).as_posix() try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: diff --git a/scripts/global_sink_isolation.py b/scripts/global_sink_isolation.py index 0289c39f97..307ef0d0c5 100644 --- a/scripts/global_sink_isolation.py +++ b/scripts/global_sink_isolation.py @@ -141,7 +141,7 @@ def clear_helpers(body: str) -> list: def rust_sources() -> dict: - return {p: p.read_text() for p in RUNTIME_SRC.rglob("*.rs")} + return {p: p.read_text(encoding="utf-8") for p in RUNTIME_SRC.rglob("*.rs")} def find_fn_body(sources: dict, name: str): @@ -392,7 +392,7 @@ def self_test() -> int: # 5. The parsers must survive the REAL tree, or the gate is vacuous. try: - real_support = SUPPORT.read_text() + real_support = SUPPORT.read_text(encoding="utf-8") helpers = clear_helpers(reset_body(real_support)) if len(helpers) < 10: failures.append("only %d clear helpers parsed from the real %s" % (len(helpers), SUPPORT.name)) @@ -423,7 +423,12 @@ def main() -> int: return self_test() try: - violations = audit(rust_sources(), SUPPORT.read_text(), ALLOWLIST, floor=CLASSIFIED_FLOOR) + violations = audit( + rust_sources(), + SUPPORT.read_text(encoding="utf-8"), + ALLOWLIST, + floor=CLASSIFIED_FLOOR, + ) except Violation as exc: print("ERROR: %s" % exc, file=sys.stderr) return 1 diff --git a/scripts/raw_handle_debt.py b/scripts/raw_handle_debt.py index e868329eed..1d494b5a0c 100755 --- a/scripts/raw_handle_debt.py +++ b/scripts/raw_handle_debt.py @@ -50,7 +50,7 @@ def count(): total, per_file = 0, {} for f in sorted(SRC.rglob("*.rs")): - rel = str(f.relative_to(ROOT)) + rel = f.relative_to(ROOT).as_posix() if rel in EXCLUDE: continue n = len(PAT.findall(f.read_text(encoding="utf-8", errors="replace"))) diff --git a/scripts/regen_api_docs.sh b/scripts/regen_api_docs.sh index c4aa96e325..9314fc42cc 100755 --- a/scripts/regen_api_docs.sh +++ b/scripts/regen_api_docs.sh @@ -16,6 +16,9 @@ echo "==> Building perry (release)…" cargo build --release -p perry PERRY="$ROOT/target/release/perry" +if [[ -f "${PERRY}.exe" ]]; then + PERRY="${PERRY}.exe" +fi mkdir -p "$ROOT/docs/src/api" "$ROOT/docs/api" diff --git a/test-files/test_issue_3987_string_tail.ts b/test-files/test_issue_3987_string_tail.ts index d591cd5743..10fcab9c36 100644 --- a/test-files/test_issue_3987_string_tail.ts +++ b/test-files/test_issue_3987_string_tail.ts @@ -34,4 +34,21 @@ check("charAt ignores extra args", s.charAt(0, extra(), extra(), extra()) === "g check("charCodeAt ignores extra args", s.charCodeAt(0, extra(), extra(), extra()) === 103); check("ignored extra args are evaluated", sideEffects === 6); +const matchAllExtra = [..."a1".matchAll(/([a-z])(\d)/g, extra())]; +check("matchAll ignores extra args", matchAllExtra[0][0] === "a1"); +check("matchAll extra args are evaluated", sideEffects === 7); + +check("search ignores extra args", "a1".search(/\d/, extra()) === 1); +check("match ignores extra args", "a1".match(/\d/, extra())![0] === "1"); +check("regex method extra args are evaluated", sideEffects === 9); + +check("replace missing replacement", "a1".replace("1") === "aundefined"); +check("replace ignores extra args", "a1".replace("1", "x", extra()) === "ax"); +check("replace extra args are evaluated", sideEffects === 10); + +check("slice ignores extra args", s.slice(1, 4, extra()) === "lob"); +check("substring ignores extra args", s.substring(1, 4, extra()) === "lob"); +check("indexOf ignores extra args", s.indexOf("g", 1, extra()) === 4); +check("two-arg string extras are evaluated", sideEffects === 13); + console.log("string-tail-3987 ok"); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 850dd77493..d011fdc7b7 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -765,3 +765,11 @@ test_gap_repsel_element_shape_param_binding # value here is byte-parity under every arm plus raw-f64 loads on proven # fields whenever relocation does land. test_gap_repsel_element_group_numeric + +# #7949/#7962 and #7978: runtime-held JS values and the full +# Object.defineProperty receiver/key/descriptor chain must remain rooted while +# allocating helpers run. These witnesses are specifically intended for the +# moving-GC matrix, where a stale raw copy becomes observable. +test_gap_gc_container_value_rooting +test_gap_gc_define_properties_key_rooting +test_gap_gc_define_property_descriptor_rooting diff --git a/tests/test_benchmark_peer_fallback.sh b/tests/test_benchmark_peer_fallback.sh index 6710917176..d09a6c22a1 100755 --- a/tests/test_benchmark_peer_fallback.sh +++ b/tests/test_benchmark_peer_fallback.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +case "$(uname -s)" in + Linux|Darwin) ;; + *) + echo "benchmark Bun-absent fallback: skipped (RSS collection requires Linux or macOS)" + exit 0 + ;; +esac + ROOT="$(cd "$(dirname "$0")/.." && pwd)" NODE_REAL="$(command -v node)" TMP="$(mktemp -d)" diff --git a/tests/test_public_baseline.py b/tests/test_public_baseline.py index bd177786b3..41503ffbcf 100644 --- a/tests/test_public_baseline.py +++ b/tests/test_public_baseline.py @@ -13,6 +13,8 @@ SOURCE_PATHS, _CARGO_VERSION_RE, _cargo_profile_tables, + _is_resolved_path, + _normalize_checkout_newlines, _replace_block, _validate_component_measurement_config, _validate_suite, @@ -159,6 +161,17 @@ def normalize(data): self.assertEqual(normalize(base), normalize(dependency_change)) self.assertNotEqual(normalize(base), normalize(profile_change)) + def test_fingerprint_input_is_checkout_line_ending_independent(self): + self.assertEqual( + _normalize_checkout_newlines(b"alpha\r\nbeta\r\n"), + _normalize_checkout_newlines(b"alpha\nbeta\n"), + ) + + def test_resolved_paths_are_host_independent(self): + self.assertTrue(_is_resolved_path("target/release/perry")) + self.assertTrue(_is_resolved_path(r"target\release\perry.exe")) + self.assertFalse(_is_resolved_path("perry")) + def test_measurement_config_is_the_fingerprinted_protocol(self): config = load_measurement_config() self.assertEqual(config["components"]["suite"]["measured_runs"], 5) diff --git a/tests/test_typed_feedback_runtime_evidence.py b/tests/test_typed_feedback_runtime_evidence.py index 214d474261..eaffa5d69d 100644 --- a/tests/test_typed_feedback_runtime_evidence.py +++ b/tests/test_typed_feedback_runtime_evidence.py @@ -49,7 +49,8 @@ def test_compiled_program_links_and_runs_typed_feedback_helpers(self) -> None: perry = resolve_perry() with tempfile.TemporaryDirectory() as temp: temp_path = Path(temp) - binary = temp_path / "typed-feedback-runtime-evidence" + executable_suffix = ".exe" if os.name == "nt" else "" + binary = temp_path / f"typed-feedback-runtime-evidence{executable_suffix}" trace_path = temp_path / "nested" / "typed-feedback-trace.json" compile_env = {**os.environ, "PERRY_NO_CACHE": "1", "PERRY_TYPED_FEEDBACK": "1"}