Skip to content

codegen: same input compiles to different LLVM IR run-to-run — HashMap iteration order in closure-source registration breaks reproducible builds #7038

Description

@proggeramlug

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 f34c01e832bac8092790c95e90945c96

Four 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions