From 72f53f8d7655b0b27e11ce3af6a2ebf50a69fbd0 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 19 Aug 2026 21:41:13 +0200 Subject: [PATCH 1/5] perf(compile): reduce generated module bloat --- .../src/commands/compile/collect_modules.rs | 21 +- .../compile/collect_modules/json_module.rs | 64 ++++ .../src/commands/compile/run_pipeline.rs | 304 +----------------- .../tests/source_graph_export_regressions.rs | 57 ++++ 4 files changed, 130 insertions(+), 316 deletions(-) create mode 100644 crates/perry/src/commands/compile/collect_modules/json_module.rs diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 821cab6d78..4e07034d55 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -33,6 +33,7 @@ mod dynamic_glob; mod eval_worker; mod feature_detect; mod import_helpers; +mod json_module; mod native_addon; mod parse_error; mod script_string; @@ -51,6 +52,7 @@ pub(super) use import_helpers::known_node_submodule_key; use import_helpers::{ cached_resolve_import_with_lexical_base, collect_js_module_imports, env_defines_for_lowering, }; +use json_module::synthesize_json_module; pub(super) use native_addon::package_has_unsupported_node_addon; use native_addon::{refuse_compile_package_native_addon, refuse_node_addon_binary}; use parse_error::annotate_parse_error; @@ -346,24 +348,9 @@ fn collect_module_one( .map_err(|e| anyhow!("Failed to read {}: {}", canonical.display(), e))? }; // JSON module import: turn the data file into a native ESM module whose - // default export is the parsed value. JSON is a syntactic subset of a JS - // expression, so `export default ;` parses and lowers like any other - // module. Validate as JSON first so a malformed file yields a clear error - // rather than a confusing TS parse failure on the synthesized source. + // default export is the parsed value. let raw_source = if is_json { - if let Err(e) = serde_json::from_str::(&raw_source) { - return Err(anyhow!( - "Failed to parse JSON module {}: {}", - canonical.display(), - e - )); - } - let json_value = "__perry_json_default"; - format!( - "function __perry_json_factory() {{ return {}; }}\nconst {json_value} = __perry_json_factory();\nconst __perry_json_module = {{ __perry_cjs_record: true, __perry_cjs_factory: __perry_json_factory, exports: {json_value}, loaded: false }};\n__perry_register_path_module({:?}, __perry_json_module);\nexport default {json_value};\n", - raw_source.trim(), - canonical.to_string_lossy(), - ) + synthesize_json_module(&raw_source, &canonical)? } else if is_text_asset { // #5223: text-asset import. The file's contents are exposed verbatim as // the module's default export (a JS string). We never TS-parse the raw diff --git a/crates/perry/src/commands/compile/collect_modules/json_module.rs b/crates/perry/src/commands/compile/collect_modules/json_module.rs new file mode 100644 index 0000000000..f0a1919214 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/json_module.rs @@ -0,0 +1,64 @@ +//! JSON-module source synthesis. +//! +//! Keep JSON as a serialized string until runtime. Lowering a large JSON object +//! literal emits an allocation and property-store sequence for every member; +//! data sets such as `mime-db` consequently produced megabytes of machine code. + +use anyhow::{anyhow, Result}; +use std::path::Path; + +pub(super) fn synthesize_json_module(raw: &str, canonical: &Path) -> Result { + serde_json::from_str::(raw).map_err(|error| { + anyhow!( + "Failed to parse JSON module {}: {}", + canonical.display(), + error + ) + })?; + + // serde_json's string serializer produces a valid JavaScript literal and + // escapes control characters, quotes, backslashes, and line separators. + let serialized = serde_json::to_string(raw.trim()).map_err(|error| { + anyhow!( + "Failed to encode JSON module {}: {}", + canonical.display(), + error + ) + })?; + let path = canonical.to_string_lossy(); + + Ok(format!( + "function __perry_json_factory() {{ return JSON.parse({serialized}); }}\n\ + const __perry_json_default = __perry_json_factory();\n\ + const __perry_json_module = {{ __perry_cjs_record: true, __perry_cjs_factory: __perry_json_factory, exports: __perry_json_default, loaded: false }};\n\ + __perry_register_path_module({path:?}, __perry_json_module);\n\ + export default __perry_json_default;\n" + )) +} + +#[cfg(test)] +mod tests { + use super::synthesize_json_module; + use std::path::Path; + + #[test] + fn keeps_json_serialized_instead_of_lowering_an_object_literal() { + let source = synthesize_json_module( + r#"{"items":[1,true,null],"nested":{"message":"hello"}}"#, + Path::new("data.json"), + ) + .expect("synthesize JSON module"); + + assert!(source.contains("return JSON.parse(")); + assert!(!source.contains("return {\"items\"")); + assert!(source.contains("export default __perry_json_default")); + } + + #[test] + fn rejects_invalid_json_before_typescript_parsing() { + let error = synthesize_json_module("{broken", Path::new("broken.json")) + .expect_err("invalid JSON should fail"); + assert!(error.to_string().contains("Failed to parse JSON module")); + assert!(error.to_string().contains("broken.json")); + } +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 14d66a00d7..3d48f0c155 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -869,45 +869,6 @@ pub fn run_with_parse_cache( } } - // Set of every type name (class, interface, enum, type alias) that - // exists *anywhere* in the program's HIR — across every native - // module. The per-module polymorphic-receiver augmentation pass - // (issue #240) consults this when scanning function/class type - // annotations: any `Named(X)` reference whose X is NOT in this set - // and NOT a builtin TS/runtime type name signals an interface that - // came from a type-only import (i.e. `import type { Driver } from - // "./driver"` — the source module never enters `native_modules` at - // all because it has no value-side exports). When such an - // unresolved reference appears, the consumer module needs full - // visibility into every program-wide class so the dispatch tower - // at `crates/perry-codegen/src/lower_call.rs::needs_dynamic_dispatch` - // can resolve `obj.method()` against any implementer at runtime. - // - // Without this, `function consume(d: Driver) { d.findOne(...) }` - // compiled in a module that only type-imports `Driver` produces a - // dispatch-tower implementor list of size 0, and the call falls - // through to a generic property-get closure call that resolves to - // `undefined` — silently dropping every method invocation through - // the interface. Type-only imports are stripped at HIR lowering - // (`crates/perry-hir/src/lower.rs:2777`), so the consumer's - // `hir_module.imports` doesn't even mention the source module. - let mut all_program_type_names: std::collections::HashSet = - std::collections::HashSet::new(); - for hir_module in ctx.native_modules.values() { - for class in &hir_module.classes { - all_program_type_names.insert(class.name.clone()); - } - for iface in &hir_module.interfaces { - all_program_type_names.insert(iface.name.clone()); - } - for en in &hir_module.enums { - all_program_type_names.insert(en.name.clone()); - } - for ta in &hir_module.type_aliases { - all_program_type_names.insert(ta.name.clone()); - } - } - // Build a map of all exported classes from all modules // Key: (resolved_path, class_name) -> Class reference let mut exported_classes: BTreeMap<(String, String), &perry_hir::Class> = BTreeMap::new(); @@ -4267,266 +4228,11 @@ pub fn run_with_parse_cache( } } - // Polymorphic-receiver augmentation (issue #240): when this - // module references a type name that doesn't resolve to any - // class, interface, enum, or type alias in the program's - // HIR — and isn't a TS/runtime builtin — the most likely - // explanation is that the name names an interface in a - // module that was reached only via a type-only import. - // `import type { Driver } from "./driver.ts"` is stripped - // at HIR lowering (`crates/perry-hir/src/lower.rs:2777`), - // so `driver.ts` never enters `ctx.native_modules`, and - // `Driver` becomes invisible to the rest of the program. - // The consumer's HIR still has `Named("Driver")` on the - // function param — it just doesn't resolve. - // - // When such an unresolved reference appears, this module's - // dispatch tower (`crates/perry-codegen/src/lower_call.rs`) - // would otherwise see an empty `implementors` list at - // `obj.method()` call sites and the call would fall through - // to a generic property-get closure call that resolves to - // `undefined` — silently dropping the call. The fix is to - // pull every program-wide exported class into - // `imported_classes` so the dispatch tower can resolve the - // call against any class that has the called method. The - // dispatch tower at the call site filters per-method-name, - // so IR size is bounded by the number of implementing - // classes, not the total class count. - // - // Without `implements`-clause tracking we can't be more - // surgical (e.g. pull only classes that satisfy a specific - // interface). The conservative "pull everything" matches - // the existing precedent for namespace imports (line ~1810 - // above), which already pulls every class in the source - // module on `import * as ns`. - fn is_builtin_type_name(name: &str) -> bool { - matches!( - name, - // Primitive aliases sometimes carried as Named - "Number" | "String" | "Boolean" | "BigInt" | "Symbol" - | "Object" | "Function" - // Built-in JS objects - | "Array" | "ReadonlyArray" | "Tuple" - | "Map" | "Set" | "WeakMap" | "WeakSet" | "WeakRef" - | "Date" | "RegExp" | "Promise" - | "Error" | "TypeError" | "RangeError" | "SyntaxError" - | "ReferenceError" | "EvalError" | "URIError" - | "AggregateError" | "InternalError" | "SuppressedError" - // TypedArrays / buffers - | "Buffer" | "ArrayBuffer" | "SharedArrayBuffer" | "DataView" - | "Uint8Array" | "Uint8ClampedArray" - | "Int8Array" | "Int16Array" | "Uint16Array" - | "Int32Array" | "Uint32Array" - | "Float32Array" | "Float64Array" - | "BigInt64Array" | "BigUint64Array" - // Iterables / generators - | "Iterable" | "Iterator" | "IteratorResult" - | "AsyncIterable" | "AsyncIterator" | "AsyncIteratorResult" - | "Generator" | "AsyncGenerator" - | "GeneratorFunction" | "AsyncGeneratorFunction" - // Common stdlib utility types - | "Partial" | "Required" | "Readonly" | "Record" | "Pick" - | "Omit" | "Exclude" | "Extract" | "NonNullable" - | "ReturnType" | "InstanceType" | "Awaited" - | "Parameters" | "ConstructorParameters" - | "ThisParameterType" | "OmitThisParameter" - | "ThisType" | "Capitalize" | "Uncapitalize" - | "Uppercase" | "Lowercase" - // Globals sometimes referenced as types - | "console" | "JSON" | "Math" | "Reflect" | "Proxy" - | "globalThis" | "this" - // Perry runtime / UI / system primitives - | "Widget" | "Color" | "Font" | "Image" - // Perry native-memory marker types - | "NativeArena" | "NativeArenaOwner" - | "PerryPod" | "PerryPodView" - | "PerryU32" | "PerryU64" | "PerryUSize" - | "PerryF32" | "PerryF64" | "PerryI32" | "PerryI64" - | "PerryBufferLen" | "PerryHandleId" - ) - } - let mut local_known: std::collections::HashSet = - std::collections::HashSet::new(); - for class in &hir_module.classes { - local_known.insert(class.name.clone()); - } - for iface in &hir_module.interfaces { - local_known.insert(iface.name.clone()); - } - for en in &hir_module.enums { - local_known.insert(en.name.clone()); - } - for ta in &hir_module.type_aliases { - local_known.insert(ta.name.clone()); - } - for ic in &imported_classes { - local_known.insert(ic.name.clone()); - if let Some(alias) = &ic.local_alias { - local_known.insert(alias.clone()); - } - } - for (n, _) in &imported_enums { - local_known.insert(n.clone()); - } - let is_unresolved_name = |name: &str| -> bool { - !local_known.contains(name) - && !all_program_type_names.contains(name) - && !is_builtin_type_name(name) - }; - fn type_has_unresolved bool>(ty: &perry_hir::types::Type, check: &F) -> bool { - use perry_hir::types::Type; - match ty { - Type::Named(name) => check(name), - Type::Generic { base, type_args } => { - check(base) || type_args.iter().any(|t| type_has_unresolved(t, check)) - } - Type::Array(elem) => type_has_unresolved(elem, check), - Type::Promise(inner) => type_has_unresolved(inner, check), - Type::Union(variants) => variants.iter().any(|v| type_has_unresolved(v, check)), - Type::Tuple(items) => items.iter().any(|v| type_has_unresolved(v, check)), - Type::Function(ft) => { - ft.params - .iter() - .any(|(_, t, _)| type_has_unresolved(t, check)) - || type_has_unresolved(&ft.return_type, check) - } - _ => false, - } - } - fn stmts_have_unresolved bool>( - stmts: &[perry_hir::Stmt], - check: &F, - ) -> bool { - stmts.iter().any(|s| stmt_has_unresolved(s, check)) - } - fn stmt_has_unresolved bool>(stmt: &perry_hir::Stmt, check: &F) -> bool { - match stmt { - perry_hir::Stmt::Let { ty, .. } => type_has_unresolved(ty, check), - perry_hir::Stmt::If { - then_branch, - else_branch, - .. - } => { - stmts_have_unresolved(then_branch, check) - || else_branch - .as_ref() - .map(|a| stmts_have_unresolved(a, check)) - .unwrap_or(false) - } - perry_hir::Stmt::While { body, .. } | perry_hir::Stmt::DoWhile { body, .. } => { - stmts_have_unresolved(body, check) - } - perry_hir::Stmt::For { init, body, .. } => { - let init_hit = init - .as_ref() - .map(|s| stmt_has_unresolved(s.as_ref(), check)) - .unwrap_or(false); - init_hit || stmts_have_unresolved(body, check) - } - perry_hir::Stmt::Labeled { body, .. } => { - stmt_has_unresolved(body.as_ref(), check) - } - perry_hir::Stmt::Try { - body, - catch, - finally, - } => { - if stmts_have_unresolved(body, check) { - return true; - } - if let Some(c) = catch { - if stmts_have_unresolved(&c.body, check) { - return true; - } - } - if let Some(f) = finally { - if stmts_have_unresolved(f, check) { - return true; - } - } - false - } - perry_hir::Stmt::Switch { cases, .. } => cases - .iter() - .any(|case| stmts_have_unresolved(&case.body, check)), - _ => false, - } - } - fn fn_has_unresolved bool>(f: &perry_hir::Function, check: &F) -> bool { - f.params.iter().any(|p| type_has_unresolved(&p.ty, check)) - || type_has_unresolved(&f.return_type, check) - || stmts_have_unresolved(&f.body, check) - } - let mut references_interface = false; - 'outer: for func in &hir_module.functions { - if fn_has_unresolved(func, &is_unresolved_name) { - references_interface = true; - break 'outer; - } - } - if !references_interface { - 'outer: for class in &hir_module.classes { - for field in &class.fields { - if type_has_unresolved(&field.ty, &is_unresolved_name) { - references_interface = true; - break 'outer; - } - } - if let Some(ctor) = &class.constructor { - if fn_has_unresolved(ctor, &is_unresolved_name) { - references_interface = true; - break 'outer; - } - } - for m in class - .methods - .iter() - .chain(class.static_methods.iter()) - .chain(class.getters.iter().map(|(_, g)| g)) - .chain(class.setters.iter().map(|(_, s)| s)) - { - if fn_has_unresolved(m, &is_unresolved_name) { - references_interface = true; - break 'outer; - } - } - } - } - if !references_interface && stmts_have_unresolved(&hir_module.init, &is_unresolved_name) - { - references_interface = true; - } - if references_interface { - for (src_pathbuf, src_hir) in &ctx.native_modules { - // #8036: this augmentation supplies FOREIGN implementors - // to a consumer's polymorphic dispatch tower. Feeding the - // module its own exported classes back through - // `imported_classes` creates a second, imported-constructor - // identity for each local class. `lower_new` then treats a - // local class as cross-module and calls its standalone - // constructor metadata instead of the local implicit-super - // path. That is observably wrong for native-backed derived - // classes such as Next's ReadonlyURLSearchParams: the local - // path installs the URLSearchParams backing, while the - // accidental self-import path loses it. Local classes are - // already present in codegen's class table, so they must - // never be added by this foreign-class fallback. - if src_pathbuf == path { - continue; - } - let src_path = src_pathbuf.to_string_lossy().to_string(); - for class in &src_hir.classes { - if !class.is_exported { - continue; - } - if imported_classes.iter().any(|c| c.name == class.name) { - continue; - } - let class_prefix = compute_module_prefix(&src_path, &ctx.project_root); - imported_classes.push(imported_class_from_hir(class, class_prefix, None)); - } - } - } + // Do not augment type-only interface consumers with every exported + // class in the program (the old issue #240 fallback). Dynamic method + // calls and method-as-value reads now resolve through the runtime's + // class-vtable registry; copying all class metadata here multiplied + // unrelated consumer IR and object size by the whole program. // Transitive class closure: pull in classes referenced by // field types of already-imported classes. Without this, a diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 44b2c647bf..c74fb994ea 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -133,6 +133,63 @@ fn whole_type_only_import_does_not_wrap_same_named_runtime_builtin() { assert_eq!(compile_and_run(dir.path(), "main.ts"), "true\n"); } +#[test] +fn type_only_interface_dispatch_uses_runtime_class_registry() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "driver.ts", + "export interface Driver { greet(name: string): string; }\n", + ); + write( + dir.path(), + "consumer.ts", + "import type { Driver } from './driver';\n\ + export function consume(driver: Driver) {\n\ + const greet = driver.greet;\n\ + return driver.greet('world') + '|' + typeof greet + '|' + greet('friend');\n\ + }\n", + ); + write( + dir.path(), + "implementation.ts", + "export class Hello { greet(name: string) { return 'hello ' + name; } }\n", + ); + write( + dir.path(), + "main.ts", + "import { consume } from './consumer';\n\ + import { Hello } from './implementation';\n\ + console.log(consume(new Hello()));\n", + ); + + assert_eq!( + compile_and_run(dir.path(), "main.ts"), + "hello world|function|hello friend\n" + ); +} + +#[test] +fn json_module_parses_embedded_serialized_data() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "data.json", + r#"{"name":"Perry","items":[1,true,null],"nested":{"message":"Grüße"}}"#, + ); + write( + dir.path(), + "main.ts", + "import data from './data.json';\n\ + console.log(data.name, data.items.length, data.items[1], data.nested.message);\n", + ); + + assert_eq!( + compile_and_run(dir.path(), "main.ts"), + "Perry 3 true Grüße\n" + ); +} + #[test] fn renamed_export_exposes_raw_local_getter_spelling() { let dir = tempfile::tempdir().expect("tempdir"); From 59ac610779bbba21991d5d89cef7ed03cb2f4de6 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 19 Aug 2026 21:41:13 +0200 Subject: [PATCH 2/5] perf(codegen): size-optimize around giant functions --- crates/perry-codegen/src/inprocess.rs | 135 +++++++++++++++++- crates/perry-codegen/src/linker.rs | 23 ++- crates/perry-codegen/src/linker_tests.rs | 15 +- .../perry/src/commands/compile/build_cache.rs | 4 + .../src/commands/compile/object_cache.rs | 10 ++ .../object_cache/object_cache_tests.rs | 2 + 6 files changed, 180 insertions(+), 9 deletions(-) diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index ee8e097417..972a48eb42 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -8,7 +8,7 @@ //! //! Decision parity by construction: this module does not re-derive optimization //! or CPU tuning. It interprets the *same* argv `build_clang_compile_plan` -//! produces for clang (`-O3`, `-mcpu=native`, `-mllvm +//! produces for clang (`-O3`/explicit `-Os`, `-mcpu=native`, `-mllvm //! -inlinehint-threshold=N`, `-target `), so the two backends cannot //! drift on a decision without drifting on the plan — which the plan's own //! tests pin. @@ -21,6 +21,7 @@ use std::ffi::CString; use std::sync::Once; use anyhow::{anyhow, Result}; +use inkwell::attributes::{Attribute, AttributeLoc}; use inkwell::context::Context; use inkwell::memory_buffer::MemoryBuffer; use inkwell::passes::PassBuilderOptions; @@ -327,6 +328,91 @@ pub(crate) fn optimize_and_emit_module( ) } +/// Optional pre-optimization escape hatch for unusually large generated +/// functions. +/// +/// Dense generated bundles often contain one parser/table initializer that is +/// large enough to make the `-O1+` middle-end super-linear, alongside hundreds +/// of ordinary functions that benefit substantially from `-Os`. Routing the +/// whole codegen unit to `-O0` keeps compilation bounded but also bloats every +/// ordinary sibling. When this cap is non-zero, only functions above it are +/// stamped `optnone`+`noinline` before the module pipeline runs. This makes +/// `PERRY_LL_SIZE_OPT=1` a practical hybrid mode instead of an all-or-nothing +/// gamble on the largest function in each unit. +/// +/// Disabled by default while the threshold is calibrated across the bundle +/// corpus. `PERRY_LL_PREOPT_OPTNONE_INSTRS=N` enables it; `0` disables it. +const DEFAULT_PREOPT_OPTNONE_INSTRS: usize = 0; + +fn preopt_optnone_instr_cap() -> usize { + std::env::var("PERRY_LL_PREOPT_OPTNONE_INSTRS") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(DEFAULT_PREOPT_OPTNONE_INSTRS) +} + +fn stamp_function_optnone(function: inkwell::values::FunctionValue<'_>) { + let context = function.get_type().get_context(); + let optnone_kind = Attribute::get_named_enum_kind_id("optnone"); + let noinline_kind = Attribute::get_named_enum_kind_id("noinline"); + // `alwaysinline` and `noinline` are verifier-incompatible. Generated + // functions do not normally carry it, but the opt-in must remain safe for + // imported/generated IR that does. + function.remove_enum_attribute( + AttributeLoc::Function, + Attribute::get_named_enum_kind_id("alwaysinline"), + ); + function.remove_enum_attribute( + AttributeLoc::Function, + Attribute::get_named_enum_kind_id("inlinehint"), + ); + function.add_attribute( + AttributeLoc::Function, + context.create_enum_attribute(optnone_kind, 0), + ); + function.add_attribute( + AttributeLoc::Function, + context.create_enum_attribute(noinline_kind, 0), + ); +} + +fn function_instruction_count(function: inkwell::values::FunctionValue<'_>, cap: usize) -> usize { + let mut instrs = 0usize; + 'body: for bb in function.get_basic_blocks() { + let mut inst = bb.get_first_instruction(); + while let Some(i) = inst { + instrs += 1; + if instrs > cap { + break 'body; + } + inst = i.get_next_instruction(); + } + } + instrs +} + +/// Demote large functions before the ordinary optimization pipeline while +/// leaving every smaller sibling eligible for the unit's requested opt level. +fn demote_preoptimization_bloated_functions(module: &inkwell::module::Module<'_>, cap: usize) { + if cap == 0 { + return; + } + let mut function = module.get_first_function(); + while let Some(f) = function { + if function_instruction_count(f, cap) > cap { + stamp_function_optnone(f); + eprintln!( + "perry: `{}` exceeds {} pre-optimization instructions; compiling only this \ + function unoptimized (optnone) while its siblings keep the module's size \ + optimization. Override with PERRY_LL_PREOPT_OPTNONE_INSTRS.", + f.get_name().to_string_lossy(), + cap, + ); + } + function = f.get_next_function(); + } +} + fn optimize_and_emit( module: &inkwell::module::Module<'_>, effective_target: &str, @@ -387,6 +473,12 @@ fn optimize_and_emit( module.set_triple(&triple); module.set_data_layout(&tm.get_target_data().get_data_layout()); + // Opt-in hybrid size optimization for generated bundles: protect only the + // pathological bodies before entering the requested module pipeline. + if opt != '0' { + demote_preoptimization_bloated_functions(module, preopt_optnone_instr_cap()); + } + // RS4GC must run BEFORE the optimization pipeline, and — critically — in // this process, against this LLVM. // @@ -515,6 +607,47 @@ mod tests { ); } + #[test] + fn preoptimization_bloated_function_is_demoted_without_demoting_its_sibling() { + global_init(&[]); + let context = Context::create(); + let ir = "define i64 @big(i64 %a) {\n\ + entry:\n\ + \x20 %x1 = add i64 %a, 1\n\ + \x20 %x2 = add i64 %x1, 1\n\ + \x20 %x3 = add i64 %x2, 1\n\ + \x20 %x4 = add i64 %x3, 1\n\ + \x20 %x5 = add i64 %x4, 1\n\ + \x20 ret i64 %x5\n\ + }\n\ + define i64 @small(i64 %a) {\n\ + entry:\n\ + \x20 %x1 = add i64 %a, 1\n\ + \x20 ret i64 %x1\n\ + }\n"; + let module = + parse_ir_text(&context, ir, "preopt_optnone_demotion").expect("fixture parses"); + demote_preoptimization_bloated_functions(&module, 4); + + let optnone_kind = Attribute::get_named_enum_kind_id("optnone"); + let big = module.get_function("big").expect("big exists"); + let small = module.get_function("small").expect("small exists"); + assert!( + big.get_enum_attribute(AttributeLoc::Function, optnone_kind) + .is_some(), + "a function past the pre-optimization cap must be stamped optnone" + ); + assert!( + small + .get_enum_attribute(AttributeLoc::Function, optnone_kind) + .is_none(), + "an ordinary sibling must keep the module optimization pipeline" + ); + module + .verify() + .expect("optnone+noinline must remain verifier-valid"); + } + fn constant_fold_order_fixture(folded: bool) -> String { let mut ir = String::from( "declare i64 @may_collect()\n\ndefine i64 @f(i64 %d0, i64 %d1, i64 %d2, i64 %d3, i64 %d4, i64 %d5, i64 %d6, i64 %d7) gc \"statepoint-example\" {\nentry:\n", diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index 31a0a526fd..ab5f1089a8 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -307,6 +307,13 @@ fn cpu_tuning_arg_for( } } +fn size_optimization_requested(value: Option<&str>) -> bool { + value + .map(str::trim) + .map(str::to_ascii_lowercase) + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "on" | "yes")) +} + fn build_clang_compile_plan( clang: PathBuf, ll_path: PathBuf, @@ -323,11 +330,15 @@ fn build_clang_compile_plan( cpu_tuning_arg_for(requested_cpu.as_deref(), target_triple, &effective_target); let stderr_remarks_path = PathBuf::from(format!("{}.clang-stderr", obj_path.display())); - // Perry promises speed-optimized native output. Large modules/functions - // stay at -O3 too; compile-time scalability belongs in codegen-unit - // partitioning and structured function outlining, not in a silent change - // to the runtime optimization policy. - let opt_flag = "-O3"; + // Perry defaults to speed-optimized native output. Generated-bundle users + // can explicitly trade runtime speed for artifact size with + // PERRY_LL_SIZE_OPT; there is no module-size-driven policy change. + let size_opt = env::var("PERRY_LL_SIZE_OPT").ok(); + let opt_flag = if size_optimization_requested(size_opt.as_deref()) { + "-Os" + } else { + "-O3" + }; // Compacting the stack map means going through assembly, because that is // where LLVM prints the map's function addresses as symbol *names* — the @@ -372,7 +383,7 @@ fn build_clang_compile_plan( clang_args.push("-target".to_string()); clang_args.push(effective_target.clone()); - let mut analysis_clang_args = vec!["-O3".to_string(), "-fno-math-errno".to_string()]; + let mut analysis_clang_args = vec![opt_flag.to_string(), "-fno-math-errno".to_string()]; if let Some(arg) = &native_tuning_arg { analysis_clang_args.push(arg.clone()); } diff --git a/crates/perry-codegen/src/linker_tests.rs b/crates/perry-codegen/src/linker_tests.rs index decc37b2ab..e4021bafe1 100644 --- a/crates/perry-codegen/src/linker_tests.rs +++ b/crates/perry-codegen/src/linker_tests.rs @@ -167,7 +167,7 @@ fn compile_plan_records_effective_target_and_native_tuning() { false, ); assert!(plan.clang_args.contains(&"-fno-math-errno".to_string())); - // Native compilation is always speed-optimized at -O3. + // Native compilation defaults to speed-optimized -O3. assert!(plan.clang_args.contains(&"-O3".to_string())); assert!(plan.clang_args.contains(&"-target".to_string())); assert!(plan.analysis_clang_args.contains(&"-target".to_string())); @@ -185,7 +185,7 @@ fn compile_plan_records_effective_target_and_native_tuning() { } #[test] -fn compile_plan_always_uses_o3() { +fn compile_plan_defaults_to_o3() { // Module size is deliberately absent from the compile plan: Perry's // runtime optimization contract does not change for large generated IR. // Scalability is handled by codegen-unit partitioning and structured @@ -203,6 +203,17 @@ fn compile_plan_always_uses_o3() { assert!(!plan.clang_args.contains(&"-O0".to_string())); } +#[test] +fn size_optimization_flag_is_explicit_and_truthy() { + for enabled in ["1", "true", "TRUE", " on ", "yes"] { + assert!(size_optimization_requested(Some(enabled)), "{enabled}"); + } + for disabled in ["", "0", "false", "off", "no", "anything-else"] { + assert!(!size_optimization_requested(Some(disabled)), "{disabled}"); + } + assert!(!size_optimization_requested(None)); +} + #[test] fn compile_plan_skips_native_tuning_for_explicit_target() { let plan = build_clang_compile_plan( diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 3e97d5231c..810416ae39 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -39,6 +39,10 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_WRITE_BARRIERS", "PERRY_SHADOW_STACK", "PERRY_RS4GC", + // Explicit hybrid size mode changes both the module optimization policy + // and which unusually large functions skip the middle-end. + "PERRY_LL_SIZE_OPT", + "PERRY_LL_PREOPT_OPTNONE_INSTRS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", "PERRY_DISABLE_BUFFER_FAST_PATH", diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index f286a85a49..c886e1131e 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -830,6 +830,16 @@ fn compute_object_cache_key_with_env( env_var("PERRY_SHADOW_STACK").as_deref().unwrap_or(""), ); h.field("env_rs4gc", env_var("PERRY_RS4GC").as_deref().unwrap_or("")); + h.field( + "env_ll_size_opt", + env_var("PERRY_LL_SIZE_OPT").as_deref().unwrap_or(""), + ); + h.field( + "env_ll_preopt_optnone_instrs", + env_var("PERRY_LL_PREOPT_OPTNONE_INSTRS") + .as_deref() + .unwrap_or(""), + ); // Explicit-safepoint contract: flips audited AllocNoReentry helpers // between statepoint and plain call. Two arms sharing a cached object // would make the contract's metadata reduction unmeasurable. 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 5085f5013a..9fbd3db19b 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 @@ -608,6 +608,8 @@ fn key_changes_with_codegen_env_vars() { "PERRY_WRITE_BARRIERS", "PERRY_SHADOW_STACK", "PERRY_RS4GC", + "PERRY_LL_SIZE_OPT", + "PERRY_LL_PREOPT_OPTNONE_INSTRS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_DISABLE_BUFFER_FAST_PATH", "PERRY_VERIFY_NATIVE_REGIONS", From 12a29aef5e0cc5032176e3b8e575cd22de963319 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Wed, 19 Aug 2026 21:42:18 +0200 Subject: [PATCH 3/5] docs(changelog): note generated bundle size improvements --- changelog.d/8418-generated-bundle-size.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/8418-generated-bundle-size.md diff --git a/changelog.d/8418-generated-bundle-size.md b/changelog.d/8418-generated-bundle-size.md new file mode 100644 index 0000000000..55cfdbda4b --- /dev/null +++ b/changelog.d/8418-generated-bundle-size.md @@ -0,0 +1,11 @@ +### Improved + +- Reduced native output for large dependency graphs by removing obsolete + program-wide class metadata augmentation and by keeping imported JSON + serialized until `JSON.parse` runs at startup. On the 4,743-module OpenCode + source build this cut the Windows executable from 1,685.4 MiB to 1,143.7 MiB + and reduced module-codegen time by 12.8%. +- Added opt-in hybrid size optimization with + `PERRY_LL_PREOPT_OPTNONE_INSTRS`: generated functions above the configured + instruction cap skip the LLVM middle-end while ordinary siblings in the same + codegen unit remain eligible for `-Os`. From aacafd5f84a04fdb9ed1a79c81a2682b1885ecd3 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 20 Aug 2026 00:10:31 +0200 Subject: [PATCH 4/5] docs(changelog): record full hybrid build results --- changelog.d/8418-generated-bundle-size.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.d/8418-generated-bundle-size.md b/changelog.d/8418-generated-bundle-size.md index 55cfdbda4b..93ea1744e7 100644 --- a/changelog.d/8418-generated-bundle-size.md +++ b/changelog.d/8418-generated-bundle-size.md @@ -8,4 +8,6 @@ - Added opt-in hybrid size optimization with `PERRY_LL_PREOPT_OPTNONE_INSTRS`: generated functions above the configured instruction cap skip the LLVM middle-end while ordinary siblings in the same - codegen unit remain eligible for `-Os`. + codegen unit remain eligible for `-Os`. With an 8,192-instruction cap, the + same OpenCode executable shrank further to 801.5 MiB (52.4% below baseline); + module codegen took 92.0 minutes versus the 73.2-minute baseline. From d6d03edf94e2deabcc81c094d2a24004a302503c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 20 Aug 2026 03:24:25 +0200 Subject: [PATCH 5/5] docs(changelog): refresh OpenCode hybrid metrics --- changelog.d/8418-generated-bundle-size.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/8418-generated-bundle-size.md b/changelog.d/8418-generated-bundle-size.md index 93ea1744e7..f60c47746a 100644 --- a/changelog.d/8418-generated-bundle-size.md +++ b/changelog.d/8418-generated-bundle-size.md @@ -9,5 +9,5 @@ `PERRY_LL_PREOPT_OPTNONE_INSTRS`: generated functions above the configured instruction cap skip the LLVM middle-end while ordinary siblings in the same codegen unit remain eligible for `-Os`. With an 8,192-instruction cap, the - same OpenCode executable shrank further to 801.5 MiB (52.4% below baseline); - module codegen took 92.0 minutes versus the 73.2-minute baseline. + same OpenCode executable shrank further to 797.0 MiB (52.7% below baseline); + module codegen took 88.8 minutes versus the 73.2-minute baseline.