From bfc8ca4452cf531f5d2b7b698d762bfc72119c8b Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Sat, 22 Aug 2026 00:29:01 -0400 Subject: [PATCH] feat(compile): perry compile --report-size, a native binary size report Attribute the final linked binary's size to the crates that produced it, read directly from its own symbol table (object + rustc-demangle) instead of requiring a cargo build rebuild. Investigated using cargo-bsize (https://github.com/boshen/cargo-bsize) first, against a throwaway crate linking perry-runtime/perry-stdlib directly: their FFI symbols get dead-code-eliminated under a plain cargo build (they only survive via the perry-runtime-static/perry-stdlib-static staticlib wrappers' #[used] anchors), so cargo-bsize -- which only knows how to drive a cargo build/cdylib rebuild -- has no hook into Perry's actual two-stage build (static archives, then a raw cc/ld link with LLVM-emitted object code). --report-size reads the real shipped artifact instead. Reuses the existing PERRY_KEEP_SYMBOLS strip-skip knob so there's a symbol table to attribute, without paying for -g DWARF the way --debug-symbols does. On a trivial console.log program it correctly surfaces perry_runtime as the single largest attributable crate (~3.8 MiB of an 8.9 MiB binary in one measurement). --- CLAUDE.md | 2 +- Cargo.lock | 164 ++++---- Cargo.toml | 2 +- changelog.d/8576-report-size.md | 3 + crates/perry/Cargo.toml | 4 + crates/perry/src/commands/compile.rs | 2 + .../src/commands/compile/run_pipeline.rs | 10 + .../perry/src/commands/compile/size_report.rs | 383 ++++++++++++++++++ crates/perry/src/commands/compile/types.rs | 13 + crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + 11 files changed, 505 insertions(+), 80 deletions(-) create mode 100644 changelog.d/8576-report-size.md create mode 100644 crates/perry/src/commands/compile/size_report.rs diff --git a/CLAUDE.md b/CLAUDE.md index a40fd31d53..e530b1ad06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1517 +**Current Version:** 0.5.1518 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 5f58538fb1..4bb0a9fc2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5598,7 +5598,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "base64 0.22.1", @@ -5619,6 +5619,7 @@ dependencies = [ "libc", "log", "notify", + "object", "perry-api-manifest", "perry-codegen", "perry-codegen-arkts", @@ -5637,6 +5638,7 @@ dependencies = [ "rayon", "regex", "reqwest", + "rustc-demangle", "semver", "serde", "serde_json", @@ -5658,7 +5660,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-dispatch", "serde", @@ -5666,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "cc", "libc", @@ -5675,7 +5677,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "inkwell", @@ -5692,7 +5694,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-hir", @@ -5700,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-hir", @@ -5708,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-dispatch", @@ -5717,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-hir", @@ -5725,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "base64 0.22.1", @@ -5737,7 +5739,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-hir", @@ -5745,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "async-trait", @@ -5774,14 +5776,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "serde", "serde_json", @@ -5789,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1517" +version = "0.5.1518" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5800,7 +5802,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "clap", @@ -5815,7 +5817,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "block2", "objc2", @@ -5825,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "argon2", "perry-ffi", @@ -5834,7 +5836,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "reqwest", @@ -5843,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "bcrypt", "perry-ffi", @@ -5851,7 +5853,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "rusqlite", @@ -5859,7 +5861,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "scraper", @@ -5867,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "perry-runtime", @@ -5875,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "chrono", "cron", @@ -5885,7 +5887,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "chrono", "perry-ffi", @@ -5893,7 +5895,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "rust_decimal", @@ -5901,7 +5903,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "serde_json", @@ -5909,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5917,7 +5919,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "perry-runtime", @@ -5925,14 +5927,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "bytes", "http-body-util", @@ -5950,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "bytes", "lazy_static", @@ -5963,7 +5965,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "bytes", @@ -5988,7 +5990,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "lazy_static", "perry-ffi", @@ -5998,7 +6000,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6009,7 +6011,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "lru", "perry-ffi", @@ -6018,7 +6020,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "chrono", "perry-ffi", @@ -6026,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "bson", "futures-util", @@ -6038,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "chrono", "perry-ffi", @@ -6048,7 +6050,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "nanoid", "perry-ffi", @@ -6057,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "bytes", "perry-ffi", @@ -6070,7 +6072,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6089,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "lettre", "perry-ffi", @@ -6099,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "fancy-regex", "notify", @@ -6111,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "printpdf", @@ -6119,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "sqlx", @@ -6128,7 +6130,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "governor", "perry-ffi", @@ -6136,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "fast_image_resize", "image", @@ -6146,14 +6148,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "lazy_static", "perry-ffi", @@ -6162,7 +6164,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "serde", @@ -6178,7 +6180,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "perry-runtime", @@ -6187,7 +6189,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "uuid", @@ -6195,7 +6197,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ffi", "regex", @@ -6205,7 +6207,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "futures-util", "lazy_static", @@ -6218,7 +6220,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "brotli", "flate2", @@ -6228,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "dashmap", "once_cell", @@ -6237,7 +6239,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-api-manifest", @@ -6255,7 +6257,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-diagnostics", @@ -6267,7 +6269,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "base64 0.22.1", @@ -6309,14 +6311,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6411,14 +6413,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "perry-hir", @@ -6427,14 +6429,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "itoa", @@ -6452,7 +6454,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "rand 0.10.1", "serde", @@ -6462,7 +6464,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.0", @@ -6485,7 +6487,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "block2", @@ -6502,7 +6504,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "block2", @@ -6518,7 +6520,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1517" +version = "0.5.1518" [[package]] name = "perry-ui-test" @@ -6529,11 +6531,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1517" +version = "0.5.1518" [[package]] name = "perry-ui-tvos" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "block2", @@ -6550,7 +6552,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "block2", @@ -6567,7 +6569,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "block2", "libc", @@ -6581,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "base64 0.22.1", "libc", @@ -6600,14 +6602,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "anyhow", "base64 0.22.1", @@ -6623,7 +6625,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1517" +version = "0.5.1518" dependencies = [ "wasmi", ] @@ -7687,6 +7689,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9c19fff8ba..df7c7c652e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -317,7 +317,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1517" +version = "0.5.1518" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/8576-report-size.md b/changelog.d/8576-report-size.md new file mode 100644 index 0000000000..356439f52f --- /dev/null +++ b/changelog.d/8576-report-size.md @@ -0,0 +1,3 @@ +### Added + +- `perry compile --report-size` writes `.size-report.md`: a per-crate and per-symbol size breakdown of the final linked binary, read directly from its own symbol table (`object` + `rustc-demangle`) rather than requiring a `cargo build` rebuild. Investigated using [cargo-bsize](https://github.com/boshen/cargo-bsize) (which drives a `cargo build`/`cdylib` rebuild) against a throwaway crate linking `perry-runtime`/`perry-stdlib` directly — its FFI symbols get dead-code-eliminated under a plain `cargo build` (they only survive via the `perry-runtime-static`/`perry-stdlib-static` staticlib wrappers' `#[used]` anchors), so cargo-bsize has no hook into Perry's actual two-stage build (static archives, then a raw `cc`/`ld` link with LLVM-emitted object code). `--report-size` reads the real shipped artifact instead, reusing the existing `PERRY_KEEP_SYMBOLS` strip-skip knob so there's a symbol table to attribute. On a trivial `console.log` program it correctly surfaces `perry_runtime` as the single largest attributable crate (~3.8 MiB of an 8.9 MiB binary in one measurement). diff --git a/crates/perry/Cargo.toml b/crates/perry/Cargo.toml index c3331a23d9..d5ce3e4e0f 100644 --- a/crates/perry/Cargo.toml +++ b/crates/perry/Cargo.toml @@ -69,6 +69,10 @@ jsonwebtoken.workspace = true semver.workspace = true dotenvy = "0.15" rayon = "1.10" +# `--report-size`: read the final linked binary's own symbol table (ELF/Mach-O/PE +# via `object`, demangled via `rustc-demangle`) to attribute size by crate. +object = { version = "0.37", default-features = false, features = ["read", "std"] } +rustc-demangle = "0.1" regex.workspace = true # Cross-platform file locking (Unix flock + Windows LockFileEx) — used by diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 14c1737691..9eeff0f508 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -46,6 +46,7 @@ mod parse_cache; mod post_link; mod precompile_capture; mod reachability; +mod size_report; mod update_config; mod windows_target; // pub(crate): commands/deps.rs (the `check --check-deps` dependency checker) @@ -111,6 +112,7 @@ use resolve::{ is_recognized_text_asset, parse_native_library_manifest, parse_package_specifier, resolve_import, }; +use size_report::emit_size_report; use strip_dedup::{ dedup_native_lib_for_tier3, dedup_runtime_for_tier3, dedup_stdlib_for_tier3, dedup_ui_lib_against_linked_libs, localize_stdlib_stub_symbols, diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 1ff3dc3840..ff6046272c 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -345,6 +345,14 @@ pub fn run_with_parse_cache( std::env::set_var("PERRY_DEBUG_SYMBOLS", "1"); } + // `--report-size` needs a symbol table to attribute size by crate, but not + // full DWARF — reuse the lighter `PERRY_KEEP_SYMBOLS` strip-skip knob + // rather than `PERRY_DEBUG_SYMBOLS`, so asking for a size report doesn't + // also pay for `-g` debug info it has no use for. + if args.report_size && std::env::var_os("PERRY_KEEP_SYMBOLS").is_none() { + std::env::set_var("PERRY_KEEP_SYMBOLS", "1"); + } + // #6125: resolve the CPU-baseline knob (`--march` / env / perry.toml // `[build] march` / `[build] native_tuning`) into the canonical // PERRY_TARGET_CPU env var, exactly like `--debug-symbols` above. @@ -6630,6 +6638,8 @@ pub fn run_with_parse_cache( print_binary_size(format, &exe_path); + emit_size_report(format, &exe_path, args.report_size); + cleanup_intermediates(args.keep_intermediates, &obj_cleanup_paths); // #5206 / #5230: visible end-of-compile notice listing every diff --git a/crates/perry/src/commands/compile/size_report.rs b/crates/perry/src/commands/compile/size_report.rs new file mode 100644 index 0000000000..56552a084c --- /dev/null +++ b/crates/perry/src/commands/compile/size_report.rs @@ -0,0 +1,383 @@ +//! `--report-size`: attribute the final linked binary's size to the crates +//! that produced it. +//! +//! Same core technique as `cargo-bsize` (see `../../../cargo-bsize/src/symbols.rs` +//! for the reference implementation this borrows from), applied directly to +//! the binary Perry actually ships instead of a `cargo build` rebuild: +//! `object` reads the symbol table out of the already-linked executable, +//! `rustc-demangle` recovers the Rust path, and the path's first `::` +//! segment is the attributed crate. ELF carries a real per-symbol size; +//! Mach-O does not, so its sizes come from sorting symbols by address +//! within a section and taking the distance to the next one (an upper +//! bound — it also counts any anonymous padding between them). +//! +//! Deliberately does not attempt cargo-bsize's monomorphization/trait/LTO +//! provenance analysis: that requires driving a `cargo build` with +//! instrumentation flags, which Perry's own two-stage build (static-archive +//! compile, then a raw `cc`/`ld` link of those archives plus LLVM-emitted +//! object code) has no hook for. This is a symbol-table-only view of +//! whatever made it into the final link. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use object::{Object, ObjectSection, ObjectSymbol, SectionKind, SymbolKind}; + +use crate::OutputFormat; + +const REPORT_TOP_CRATES: usize = 20; +const REPORT_TOP_SYMBOLS: usize = 30; + +#[derive(Default)] +struct CrateTotals { + code_bytes: u64, + data_bytes: u64, + symbol_count: usize, +} + +struct RankedSymbol { + demangled: String, + crate_name: String, + size: u64, + exact: bool, +} + +struct SizeReport { + file_bytes: u64, + code_section_bytes: u64, + data_section_bytes: u64, + code_attributed_bytes: u64, + data_attributed_bytes: u64, + symbol_count: usize, + by_crate: BTreeMap, + largest: Vec, +} + +/// Write `.size-report.md`. Best-effort and silent unless +/// `--report-size` was actually passed: a read/parse failure here must +/// never fail the build, the report is a diagnostic extra, not a build +/// product. +pub(super) fn emit_size_report(format: OutputFormat, exe_path: &Path, requested: bool) { + if !requested { + return; + } + let report = match build_report(exe_path) { + Ok(report) => report, + Err(e) => { + if let OutputFormat::Text = format { + eprintln!("warning: failed to build size report: {e}"); + } + return; + } + }; + let report_path = report_path_for(exe_path); + let markdown = render_markdown(exe_path, &report); + if let Err(e) = fs::write(&report_path, markdown) { + if let OutputFormat::Text = format { + eprintln!("warning: failed to write size report: {e}"); + } + return; + } + if let OutputFormat::Text = format { + let unattributed = (report.code_section_bytes + report.data_section_bytes) + .saturating_sub(report.code_attributed_bytes + report.data_attributed_bytes); + println!("Wrote size report: {}", report_path.display()); + println!( + " {} attributed across {} symbols, {} unattributed (inlined/no-symbol bytes)", + human_bytes(report.code_attributed_bytes + report.data_attributed_bytes), + report.symbol_count, + human_bytes(unattributed), + ); + } +} + +fn report_path_for(exe_path: &Path) -> PathBuf { + let mut s = exe_path.as_os_str().to_owned(); + s.push(".size-report.md"); + PathBuf::from(s) +} + +fn build_report(exe_path: &Path) -> anyhow::Result { + let data = fs::read(exe_path)?; + let file = object::File::parse(&*data)?; + let file_bytes = data.len() as u64; + + let mut code_section_bytes = 0u64; + let mut data_section_bytes = 0u64; + let mut code_syms: Vec<(u64, u64, &str)> = Vec::new(); // (section_index, address, name) + let mut data_syms: Vec<(u64, u64, &str)> = Vec::new(); + let mut sizes: BTreeMap<(u64, u64), u64> = BTreeMap::new(); // (section_index, address) -> real size + + for section in file.sections() { + let size = section.size(); + match section.kind() { + SectionKind::Text => code_section_bytes += size, + SectionKind::Data | SectionKind::ReadOnlyData | SectionKind::UninitializedData => { + data_section_bytes += size + } + _ => {} + } + } + + for symbol in file.symbols() { + let (Some(name), Ok(section_index)) = ( + symbol.name().ok().filter(|n| !n.is_empty()), + symbol.section().index().ok_or(()), + ) else { + continue; + }; + let kind = symbol.kind(); + let bucket = match kind { + SymbolKind::Text => &mut code_syms, + SymbolKind::Data => &mut data_syms, + _ => continue, + }; + bucket.push((section_index.0 as u64, symbol.address(), name)); + if symbol.size() != 0 { + sizes.insert((section_index.0 as u64, symbol.address()), symbol.size()); + } + } + + // Mach-O symbols carry no size: sort by (section, address) and take the + // distance to the next symbol in the same section as an upper-bound size + // for any address that didn't already get a real ELF size above. + let section_end: BTreeMap = file + .sections() + .map(|s| (s.index().0 as u64, s.address() + s.size())) + .collect(); + let mut ranked = Vec::new(); + for syms in [&mut code_syms, &mut data_syms] { + syms.sort_by_key(|&(section, address, _)| (section, address)); + for i in 0..syms.len() { + let (section, address, name) = syms[i]; + let exact = sizes.contains_key(&(section, address)); + let size = sizes.get(&(section, address)).copied().unwrap_or_else(|| { + let next_addr = syms + .get(i + 1) + .filter(|&&(next_section, ..)| next_section == section) + .map(|&(_, addr, _)| addr) + .or_else(|| section_end.get(§ion).copied()) + .unwrap_or(address); + next_addr.saturating_sub(address) + }); + if size == 0 { + continue; + } + ranked.push((section, name, size, exact)); + } + } + + let mut by_crate: BTreeMap = BTreeMap::new(); + let mut largest: Vec = Vec::new(); + let mut code_attributed_bytes = 0u64; + let mut data_attributed_bytes = 0u64; + let code_section_indices: std::collections::HashSet = file + .sections() + .filter(|s| s.kind() == SectionKind::Text) + .map(|s| s.index().0 as u64) + .collect(); + + for (section, name, size, exact) in ranked { + let demangled = demangle(name); + let crate_name = crate_of(&demangled); + let totals = by_crate.entry(crate_name.clone()).or_default(); + totals.symbol_count += 1; + if code_section_indices.contains(§ion) { + totals.code_bytes += size; + code_attributed_bytes += size; + } else { + totals.data_bytes += size; + data_attributed_bytes += size; + } + largest.push(RankedSymbol { + demangled, + crate_name, + size, + exact, + }); + } + + largest.sort_by(|a, b| b.size.cmp(&a.size)); + largest.truncate(REPORT_TOP_SYMBOLS); + let symbol_count: usize = by_crate.values().map(|t| t.symbol_count).sum(); + + Ok(SizeReport { + file_bytes, + code_section_bytes, + data_section_bytes, + code_attributed_bytes, + data_attributed_bytes, + symbol_count, + by_crate, + largest, + }) +} + +/// Demangle a Rust symbol name. `rustc_demangle` returns non-Rust input +/// unchanged — the normal case for libc/system symbols — and `crate_of` +/// below buckets those as `native/other`. +fn demangle(name: &str) -> String { + rustc_demangle::demangle(name).to_string() +} + +/// The crate a demangled Rust path belongs to — its first `::`-delimited +/// segment, with the compiler's disambiguating hash suffix (`::h<16 hex>`) +/// dropped. Non-Rust names (no `::`, or containing characters a Rust path +/// segment can't) bucket as `native/other`. +fn crate_of(demangled: &str) -> String { + // `::method` / `::method` associated-fn forms put + // the crate name one level in; strip the leading `<` before reading it. + let trimmed = demangled.trim_start_matches('<'); + let ident_end = trimmed + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(trimmed.len()); + let candidate = &trimmed[..ident_end]; + let starts_like_ident = candidate + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_'); + // v0 mangling embeds the crate's disambiguating hash right after its + // name as `crate_name[16 hex digits]`; legacy mangling has none and + // goes straight to `::`. Either is proof `candidate` is a crate name + // rather than an identifier that merely starts the same way a mangled + // name would (a bare C symbol, `main`, ``-as-printed generic self + // types with no path after them, …). + let rest = &trimmed[ident_end..]; + if starts_like_ident && (rest.starts_with('[') || rest.starts_with("::")) { + candidate.to_string() + } else { + "native/other".to_string() + } +} + +fn render_markdown(exe_path: &Path, report: &SizeReport) -> String { + let mut out = String::new(); + out.push_str(&format!( + "# Size report: {}\n\n", + exe_path.file_name().map_or_else( + || exe_path.display().to_string(), + |n| n.to_string_lossy().into_owned() + ) + )); + out.push_str(&format!( + "- Total file size: {}\n", + human_bytes(report.file_bytes) + )); + out.push_str(&format!( + "- Code sections: {} ({} attributed to {} symbols)\n", + human_bytes(report.code_section_bytes), + human_bytes(report.code_attributed_bytes), + report.symbol_count, + )); + out.push_str(&format!( + "- Data sections: {} ({} attributed)\n\n", + human_bytes(report.data_section_bytes), + human_bytes(report.data_attributed_bytes), + )); + out.push_str( + "Built from the linked binary's own symbol table (`object` + `rustc-demangle`), \ + the same technique [cargo-bsize](https://github.com/boshen/cargo-bsize) uses on a \ + `cargo build` rebuild — applied here directly to what Perry actually links, since \ + Perry's static-archive-then-`cc`/`ld` build has no `cargo build` for cargo-bsize to \ + drive. Sizes for symbols without a real size (Mach-O) are an upper bound: the \ + distance to the next symbol in the same section, which also counts any anonymous \ + padding between them.\n\n", + ); + + out.push_str("## By crate\n\n"); + out.push_str("| Code | Data | Symbols | Crate |\n|---|---|---|---|\n"); + let mut crates: Vec<(&String, &CrateTotals)> = report.by_crate.iter().collect(); + crates + .sort_by(|a, b| (b.1.code_bytes + b.1.data_bytes).cmp(&(a.1.code_bytes + a.1.data_bytes))); + for (name, totals) in crates.into_iter().take(REPORT_TOP_CRATES) { + out.push_str(&format!( + "| {} | {} | {} | `{}` |\n", + human_bytes(totals.code_bytes), + human_bytes(totals.data_bytes), + totals.symbol_count, + name, + )); + } + + out.push_str("\n## Largest symbols\n\n"); + out.push_str("| Size | Crate | Symbol |\n|---|---|---|\n"); + for sym in &report.largest { + out.push_str(&format!( + "| {}{} | `{}` | `{}` |\n", + human_bytes(sym.size), + if sym.exact { "" } else { " (≤)" }, + sym.crate_name, + sym.demangled, + )); + } + + out +} + +fn human_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + let bytes_f = bytes as f64; + if bytes_f >= MIB { + format!("{:.1} MiB", bytes_f / MIB) + } else if bytes_f >= KIB { + format!("{:.1} KiB", bytes_f / KIB) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crate_of_extracts_the_first_path_segment() { + assert_eq!( + crate_of("perry_runtime::gc::copying::run_copied_minor_attempt"), + "perry_runtime" + ); + assert_eq!(crate_of("core::ptr::drop_in_place"), "core"); + } + + #[test] + fn crate_of_strips_the_v0_disambiguator_hash() { + // v0 mangling's real shape: `crate_name[16-hex-digit-hash]::path`. + assert_eq!( + crate_of("perry_runtime[bf1fb6611b4368e2]::object::class_meta_registry::PARENT_DENSE"), + "perry_runtime" + ); + } + + #[test] + fn crate_of_reads_the_crate_out_of_an_associated_fn_receiver() { + // `::method` — the crate name sits one level inside the `<`. + assert_eq!( + crate_of("::step"), + "perry_runtime" + ); + } + + #[test] + fn crate_of_buckets_non_rust_names_as_native_other() { + assert_eq!(crate_of("_CCRandomGenerateBytes"), "native/other"); + assert_eq!(crate_of("__NSGetArgc"), "native/other"); + assert_eq!(crate_of("main"), "native/other"); + } + + #[test] + fn human_bytes_picks_the_right_unit() { + assert_eq!(human_bytes(512), "512 B"); + assert_eq!(human_bytes(2048), "2.0 KiB"); + assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MiB"); + } + + #[test] + fn report_path_appends_the_suffix() { + assert_eq!( + report_path_for(Path::new("/tmp/hello")), + PathBuf::from("/tmp/hello.size-report.md") + ); + } +} diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 0c40dd0a80..2c2e756791 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -234,6 +234,19 @@ pub struct CompileArgs { #[arg(long)] pub debug_symbols: bool, + /// Write `.size-report.md`: a per-crate/per-function size + /// breakdown of the final linked binary, attributed from its own + /// symbol table (real ELF sizes, or address-delta-within-section on + /// Mach-O, which carries no symbol size) via `object` + demangled + /// with `rustc-demangle`. Promotes to `PERRY_KEEP_SYMBOLS` (the same + /// strip-skip knob `--debug-symbols` uses) so there is a symbol + /// table to read; unlike `--debug-symbols` this does not also request + /// `-g` DWARF, so the binary grows only by the symbol table, not by + /// full debug info. Off by default; the report itself and the + /// resulting unstripped binary are the tradeoff for asking. + #[arg(long)] + pub report_size: bool, + /// Disable the per-module object cache. /// By default Perry caches each module's object bytes keyed by a /// hash of the source plus every `CompileOptions` field that can diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 8c76453ba5..ba1b63dcb8 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -304,6 +304,7 @@ fn build_once( minimal_stdlib: false, no_auto_optimize: false, debug_symbols: false, + report_size: false, no_cache: false, // `perry dev` has no `--cache-dir` flag of its own; the resolver // still honors `PERRY_CACHE_DIR` / perry.toml `[perry] cacheDir` / diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index 8043074d55..a7e420f56e 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -216,6 +216,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> minimal_stdlib: false, no_auto_optimize: false, debug_symbols: false, + report_size: false, no_cache: false, // `perry run` has no `--cache-dir` flag; the resolver still honors // `PERRY_CACHE_DIR` / perry.toml `[perry] cacheDir` / package.json