Repository navigation
Description
Compiling the same file twice with the same compiler and the same flags produces different LLVM IR, and therefore different object files and binaries.
Reproduce
$ for i in 1 2 3 4; do
mkdir -p run$i && cp batch.ts run$i/
(cd run$i && perry batch.ts -o o --trace llvm >/dev/null 2>&1)
done
$ md5 -q run*/.perry-trace/llvm/batch_ts.ll | sort | uniq -c
1 c8f51e1f7ca5ba71e6cd785827e0e976
1 d5f7462479bf33726126117e038f8126
1 d67dc9790fa27305ab027a73285e67df
1 f34c01e832bac8092790c95e90945c96Four runs, four distinct outputs. (benchmarks/app-patterns/kernels/batch.ts, added in #7037, is a convenient repro — any file with several closures works.)
What varies
Only the numbering assigned to retained closure-source string constants, and the order of the matching js_register_function_source calls in __perry_init_strings_<prefix>:
-@.str.10 = private unnamed_addr constant [292 x i8] c"(acc: Summary[], r: Row) => {...}"
-@.str.11 = private unnamed_addr constant [160 x i8] c"(a, b) => {...}"
-@.str.12 = private unnamed_addr constant [130 x i8] c"(r) => ({...})"
+@.str.10 = private unnamed_addr constant [160 x i8] c"(a, b) => {...}"
+@.str.11 = private unnamed_addr constant [130 x i8] c"(r) => ({...})"
+@.str.12 = private unnamed_addr constant [292 x i8] c"(acc: Summary[], r: Row) => {...}"Canonicalising @.str.N by content makes all runs hash identically, so it is a pure permutation — semantics are unaffected and fn.toString() still resolves correctly.
Root cause
crates/perry-codegen/src/codegen/artifacts.rs:1861
for (func_id, src) in &hir.closure_source_text {Module::closure_source_text is a std::collections::HashMap<FuncId, String>
(crates/perry-hir/src/ir/module.rs:132). Iterating it directly yields
RandomState-seeded order, which differs per process. The sibling loop just
above it walks hir.functions (a Vec) and is deterministic; only this second
loop, which handles inline closures, is order-dependent.
Why it matters
- Reproducible builds. Two builds of the same source at the same commit are not bit-identical, so a binary cannot be verified by rebuilding it. This interacts with
perry verify --attest(security: reproducible builds + binary attestation #504), which attests a SHA-256 of the binary. - A/B testing the compiler is unsound by default. Any "did this change alter codegen?" comparison — the exact check a reviewer runs on a flag that claims to be observational — can show a spurious diff, or hide a real one behind an accidental match. feat(cli): --opt-report — surface which values could not be statically typed, why, and whether the developer can fix it #7037 hit both: an early two-run comparison matched by luck and looked like proof, and a later one diffed for reasons that had nothing to do with the change.
- Cache-key reasoning assumes emitted bytes are a function of the inputs.
Fix
Sort by FuncId before emitting (or make the field a BTreeMap):
let mut closure_sources: Vec<_> = hir.closure_source_text.iter().collect();
closure_sources.sort_by_key(|(id, _)| **id);
for (func_id, src) in closure_sources {Worth auditing for sibling cases: any other for … in &some_hashmap whose body
emits IR in loop order.
Acceptance
N compiles of the same input at the same commit produce byte-identical .ll
and .o. A regression test that compiles a multi-closure fixture twice in one
cargo test process would not catch it (the RandomState seed is per process,
not per HashMap instantiation — actually it is per-HashMap, so it may; verify
before relying on it), so an in-CI check should compile in two separate
processes and diff.
Activity
fix(codegen): sort closure-source emission so the same input compiles…