Skip to content

fix(codegen): make emitted IR run-to-run deterministic (#7622) - #7625

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7622-codegen-determinism
Aug 8, 2026
Merged

fix(codegen): make emitted IR run-to-run deterministic (#7622)#7625
proggeramlug merged 4 commits into
mainfrom
fix/7622-codegen-determinism

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7622.

Compiling one source twice with the same perry binary produced different LLVM IR. Both shapes the issue names are std::collections hash-map iteration order reaching the emitter — Rust's default hasher is seeded per RandomState, so the order is a fresh permutation every process.

Variance taxonomy

I compiled the issue's two named sources six times each with one baseline binary and diffed pairwise. 5 of 5 runs differed from run 1 for both. The entire diff is two shapes; nothing else in the .ll moves.

shape what varies diff size
1. Function-name registration the @.str.N numbering of display-name constants, and the order of the matching js_register_function_name calls in __perry_init_strings_* 24 lines
2. Dispatch-tower arms which perry_method_* symbol each icmp-guarded case block names, and the class ids in the matching icmp eq i32 chain 32 lines

Nothing else was found: no register renumbering, no block reordering, no metadata churn, no Instant/address-derived value, and no rayon-completion effectctx.native_modules is a BTreeMap<PathBuf, HirModule> (compile/types.rs:557), so #7303's suspected mechanism is not what is happening. Both shapes are HashMap, as #7622 suspected.

Shape 2's diff is diagnostic on its own: the case blocks keep their register numbers and their arguments and only the callee symbol and the compared class id move — arms emitted in a permuted order, not different arms.

<   %r2492 = call double @perry_method_..._DerB__promise(double %r2956)
>   %r2492 = call double @perry_method_..._DerA__promise(double %r2956)
...
<   %r2488 = icmp eq i32 %r2487, 7          >   %r2488 = icmp eq i32 %r2487, 2
<   %r2489 = icmp eq i32 %r2487, 2          >   %r2489 = icmp eq i32 %r2487, 9

Root causes

1. crates/perry-codegen/src/codegen/artifacts.rs:1836for (func_id, display) in &hir.closure_display_names (a HashMap<FuncId, String>). Each entry mints a rodata constant through add_string_constant, whose @.str.N counter numbers in first-use order, and emits one js_register_function_name call. So the map's order set both.

This is the same defect #7038 fixed 36 lines below, in the closure_source_text loop, and left standing here. Its own comment explains exactly why it matters.

2. crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs:241for (start_cls, &start_cid) in ctx.class_ids.iter() (a HashMap<String, u32>) builds implementors, which is walked by index to emit one case block per implementing class. Map order was arm order.

Two mechanical siblings found by a systematic sweep of all four compiler crates rather than by following the diff, and fixed in the same commit:

3. dynamic_dispatch.rs:708 — the virtual-override tower (vdispatch.*) reads the same map the same way.
4. codegen/method_registry.rs:42for c in class_table.values(), whose body writes into the (class, method) -> symbol registry both insert (last writer wins) and entry().or_insert_with (first writer wins). class_table mixes local classes, their alias keys and imported stubs, so two distinct &Class can contend for one key, and that registry is what every call site consults to pick its callee.

The fix, and its ordering guarantee

Sorting at the point of iteration, not serializing the compiler. Rayon parallel module codegen is untouched — it was never the cause.

site order now
artifacts.rs ascending FuncId (total: filtered to unique, materialized ids)
dynamic_dispatch.rs interface tower ascending (class_id, class_name) (total: class_name is the map key, so unique)
dynamic_dispatch.rs virtual tower same
method_registry.rs ascending class-table key (total: it is the map key)

Every key is total, so each is a single fixed permutation, not merely "more stable".

One behaviour change, deliberate. max_explicit_arity's scan (dynamic_dispatch.rs:794) stopped at the first name carrying a class id, and class ids are not unique over class_ids keys — a class-expression self-binding alias (var X = class _X) and an imported class registered under both its own and its local-alias name each map two names to one id. So it was a hash-order tie-break, and the loser's arity decides how many TAG_UNDEFINED padding args every emitted call in the tower carries. It now takes the max over all names sharing the id, which is what the variable already means and is the safe direction: under-padding is the #235 garbage-argument bug, over-padding just lets a default-param desugaring fire.

Is the object cache implicated? Yes — say it plainly

compute_object_cache_key (compile/object_cache.rs:253) is a function of CompileOptions, the post-transform HIR fingerprint, the perry version, a hash of the perry binary, and the codegen env vars. All deterministic. The emitted IR was not. So identical inputs produced an identical key over different .o bytes: a cache hit and a cold rebuild could legitimately hold different code.

For the shapes actually observed that difference is semantically neutral — the name registry is keyed on distinct function pointers, and the tower's arms are keyed on distinct class ids, so first-match picks the same arm whatever the order. But it is not neutral by construction: seen_pairs dedups on (class_id, fname), which admits two arms sharing one class id with different symbols, and there the emission order is the behaviour. Same for method_registry's two tie-breaks. So this was a latent correctness hazard on the cache path, not only an audit-fidelity one, and it is now closed.

Evidence (all local — CI backlog is deep, so this is the evidence)

  • Repro, baseline binary: test_gap_object_create_method_this 5/5 runs differ; test_gap_class_expr_dynamic_parent_ctor 5/5 differ.
  • Same probe, fixed binary: 0/5 and 0/5.
  • Corpus sweep, 42 sampled test_gap_* compiled 3x each with the fixed binary: 0 nondeterministic / 41 compared. The 42nd, test_gap_http_overloads_3226plus, failed to link during that batch — the host was at 99% disk; on a clean re-run it links under both arms and aborts identically at runtime, see below.
  • Behavioural A/B, 126 sampled test_gap_* compiled with BOTH binaries and the produced executables run and compared: 123 byte-identical, 3 flagged, and all 3 investigated to a non-cause.
    • test_gap_http_overloads_3226plus aborts identically on both arms (exit 134, panic at crates/perry-ext-http/src/server/server.rs:911); the entire stdout difference is the thread id inside the panic message.
    • test_gap_proto_write_local_shadows_class and test_gap_typeof_instanceof were SIGKILLed (137) on the fixed arm during the concurrent batch, on a host that was at 99% disk and load ~20. Re-run serially on an idle host, 3x per arm: both arms exit 0 with byte-identical output, 3/3 each. Not reproducible.
    • So: zero behavioural differences over 126 programs.
  • Sabotage: reverting the artifacts.rs sort alone turns exactly the two shape-1 tests red and leaves shape-2 green; reverting the dynamic_dispatch.rs sort alone does the mirror image. Verified both directions.
  • Compile-time impact: none measurable, by user+sys CPU best-of-N (wall clock is unusable on this host — it runs other agents' builds at load 15-27, and an early wall-clock read of "+13%" did not survive a CPU-time re-measure).
    • Worst case for the change (40 classes x 40 dynamic call sites = 1,600 tower arms, sized to stay under the oversized-module -Os threshold so LLVM does not bury the signal), best-of-7: ratio 1.008 (median ratio 0.983). Within-arm spread is 3.56-4.25s, an order of magnitude larger than the gap.
    • 14 real class-bearing test_gap_* programs, total CPU, best-of-3: ratio 0.958.
    • Expected: the added work is one sort of (class_id, &name) pairs per dynamic-dispatch site, in a loop that already clones a String per class per site.
  • cargo test -p perry-codegen --lib: 695 passed.
  • cargo test -p perry-runtime --lib --no-fail-fast: 1885 passed, 1 failed — promise::keyed_table::tests::settling_many_keys_is_not_quadratic, a timing test that passes on rerun and lives in a crate this diff does not touch.
  • gc-root-dominance corpus, both gated modes: green. 149 files; mode 1 caught 40/40 seeded violations (so the checker was live); mode 2 reports 0 unrooted-alloca violations over 7,863 gc-capable allocas.
  • Full lint job step list from .github/workflows/test.yml, run individually: all green except Public benchmark evidence freshness, which fails on main for reasons this diff cannot reach (it touches only crates/perry-codegen/) and which the workflow's own inline comment records as failing on every main run since 2026-07-29.
  • cargo clippy -p perry --bins: exit 0.

#7303 is the same defect and is also closed

#7303 reports shape 1 exactly — js_register_function_name order flipping, with the @.str.N numbering following it — and attributes it to rayon closure-codegen completion order. It is the hir.closure_display_names HashMap. On its own two repro files, with the same binaries used above:

source baseline fixed
test_gap_1840_class_iterator_for_of_spread 2 of 5 runs differ 0 of 5
test_gap_yield_star_iterable 2 of 5 runs differ 0 of 5

That 2-in-5 is #7303's own "~1-in-3 runs", and it is also where its reasoning went wrong: it ruled out RandomState because "that would flip near-every run". With only two registration entries a hash order agrees with itself about half the time, so ~1-in-3 is exactly what a two-element HashMap looks like. The frequency argument needs the entry count, and both of those files have two.

Tests

Four --lib unit tests in crates/perry-codegen/src/codegen/emission_order_tests.rs, so they run on every PR that touches perry-codegen rather than only in the tag-gated integration tier.

They are built not to be vacuous, both ways:

  • Each builds its Module fresh for every compile. The offending maps live in the HIR, so compiling one long-lived Module twice would re-iterate the very same RandomState and pass unconditionally. With N = 16 entries, a chance-ordered agreement is a 1-in-16! event.
  • Each asserts its subject was live before judging order — the registration count and the tower arm count — so a fixture that stopped emitting the construct cannot go green having proven nothing.

What is deliberately not tested, and why. The virtual-override tower and the method_registry walk are sorted with no test. vdispatch blocks appear in zero of the 41 sampled programs — every receiver-typed call measured is claimed earlier by method_override.rs's method_direct shape guard — and I could not construct a fixture that reaches it. A green test over a fixture emitting no arms asserts nothing, which is worse than no test. They are sorted anyway because they are mechanical siblings of the defects that are covered, and #7622 exists precisely because #7038 fixed one such loop and left its neighbour. The test module says so in prose.

Out of scope

The same sweep found three hash-order reads that do not reach emitted IR and are left alone: dialect/mod.rs:1556 (which undefined register an error message names), compile/host_config.rs:841 (which offending package a bail! reports), and commands/fix_applier.rs:93/128 (the ordering of the perry fix report). Worth a follow-up; not this PR, whose diff should stay auditable.

Compiling one source twice with the same perry binary produced different
LLVM IR. Two emission sites read their order straight out of a
std::collections hash map, whose RandomState is seeded per process:

* codegen/artifacts.rs - the `hir.closure_display_names` walk. Each entry
  mints a rodata constant via `add_string_constant` (whose `@.str.N`
  counter numbers in first-use order) and emits one
  `js_register_function_name` call, so both permuted every run. #7038
  fixed the identical defect in the `closure_source_text` loop directly
  below it and left this one standing.

* lower_call/property_get/dynamic_dispatch.rs - the `ctx.class_ids` walk
  that builds the interface dispatch tower. Each surviving entry is one
  icmp-guarded case block, so the map order WAS the arm order: the same
  three call sites named different `perry_method_*` callees run to run.

Both reproduce 5/5 on the issue's own test files; both are byte-stable
after the fix, and a 41-program `test_gap_*` sweep is 0/41 nondeterministic.

Two mechanical siblings are sorted without a fixture and labelled as such
in the test module: the virtual-override tower over the same map (emits
zero arms across all 41 sampled programs), and method_registry.rs's
`class_table` walk, whose insert/or_insert_with tie-breaks only diverge
when two `&Class` contend for one registry key. The `max_explicit_arity`
scan there also stops taking the first name that carries a class id -
ids are not unique over `class_ids` keys - and takes the max instead,
which is what the variable already means.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The code generator now sorts closure registrations, method registry entries, and dispatch class roots. Virtual dispatch computes maximum override arity across matching class names. Regression tests verify stable ordering and identical LLVM IR across compilations.

Changes

Deterministic code generation

Layer / File(s) Summary
Stabilize registration ordering
crates/perry-codegen/src/codegen/artifacts.rs, crates/perry-codegen/src/codegen/method_registry.rs
Closure display-name registrations are sorted by FuncId. Method registry generation processes class entries sorted by name.
Stabilize dispatch emission
crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs
Dynamic and virtual dispatch sort class roots by class ID and name. Override arity analysis checks all matching class names and selects the maximum parameter count.
Add emission determinism tests
crates/perry-codegen/src/codegen/emission_order_tests.rs, crates/perry-codegen/src/codegen/mod.rs, changelog.d/7625-codegen-determinism.md
Tests verify registration order, dispatch-arm order, liveness, and byte-identical LLVM IR across fresh compilations. The test module is wired into codegen, and the changelog records the changes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7039 — Directly relates to sorted closure registration in artifacts.rs.
  • PerryTS/perry#7135 — Relates to deterministic LLVM output, although it changes linker temporary-file naming and hashing logic.

Sequence Diagram(s)

sequenceDiagram
  participant Codegen as Code generator
  participant Dispatch as Dynamic dispatch lowering
  participant IR as LLVM IR emitter
  Codegen->>Codegen: Sort closure and method registry entries
  Dispatch->>Dispatch: Sort class roots and compute maximum override arity
  Codegen->>IR: Emit registrations and dispatch arms
  IR-->>Codegen: Produce deterministic LLVM IR
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #7622 by sorting affected hash-map iterations and adding deterministic-emission regression tests.
Out of Scope Changes check ✅ Passed The code and tests remain focused on deterministic codegen and the linked issue objectives; no unrelated implementation changes are evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly and concisely describes the primary change: making emitted code-generation IR deterministic.
Description check ✅ Passed The description provides a detailed summary, changes, linked issue, test plan, validation results, and scope, although it omits the template headings and checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7622-codegen-determinism

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/codegen/emission_order_tests.rs`:
- Around line 402-408: Update the emission-order fixture around arms and its
class-ID assignments so class names receive IDs in reverse lexical order.
Replace the self-derived sorted comparison with an explicit expected class-name
sequence representing ascending class IDs, ensuring a name-only sort would fail
while validating the dispatch-tower emission order.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f3edf346-77ac-4d66-9382-46199cdfab9a

📥 Commits

Reviewing files that changed from the base of the PR and between 38ff7ec and 644c144.

📒 Files selected for processing (6)
  • changelog.d/7625-codegen-determinism.md
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/emission_order_tests.rs
  • crates/perry-codegen/src/codegen/method_registry.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs

Comment thread crates/perry-codegen/src/codegen/emission_order_tests.rs Outdated
Ralph Küpper added 2 commits August 8, 2026 09:06
CodeRabbit review: the fixture gave class C00..C15 ids 1..16, so class-id
order and name order coincided and a name-keyed sort would have satisfied
the assertion just as well. Ids now run opposite to names, and the expected
arm sequence is spelled out rather than derived by sorting the observed
list against itself.
@proggeramlug
proggeramlug merged commit c413af0 into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the fix/7622-codegen-determinism branch August 8, 2026 07:40
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1356

Reproduced the bug and the fix on my own probe (3 classes with a shared
method name + 2 named functions, five compiles per arm, same binary each time):

arm identical to run 1
main 1/4 — 3 runs differed
this PR 4/4

And main's diff is exactly shape 2 as described — perry_method_…__B__foo and
__C__foo trading positions in the tower, registers and arguments holding
still. The taxonomy is right.

Sabotage re-verified: commenting out the dispatch_roots sort turns exactly
the two tower tests red (dispatch_tower_arms_are_emitted_in_class_id_order,
dispatch_tower_emission_is_run_to_run_deterministic) and leaves the two
closure-name tests green — the per-shape isolation the report claims.

Gates re-run: codegen 694/0, runtime 1886/0, all six lint scripts + file-size +
fmt clean.

Why this one mattered more than its diff size

Every Layer-1 slice audit — mine and the agents' — rests on "emitted IR
byte-identical"
, and slice 1b had 3 of 9 apparent diffs contaminated by this.
Slice 2 (#7627, in review now) had to run a double-compile control first and
still found 5 ordering-only permutations in its corpus. With this merged, the
remaining ~85 modules get a clean instrument.

Three findings I want on the record:

  1. The object cache was implicated, and that is a correctness argument, not
    an audit-fidelity one.
    compute_object_cache_key is deterministic while
    the IR was not, so identical inputs keyed identical caches over differing
    .o bytes. Benign for the observed shapes — but seen_pairs dedups on
    (class_id, fname), which admits two arms sharing one class id with
    different symbols, and there emission order is behaviour. Latent, now
    closed.
  2. Emitted-IR nondeterminism: js_register_function_name order flips run-to-run (rayon completion order) #7303 closed as the same defect, and its stated reasoning refuted. That
    issue argued "not RandomState, that would flip near-every run" — but its
    two files have exactly two registration entries, and a two-element hash
    order agrees with itself about half the time, which is precisely its
    observed ~1-in-3. Correcting a wrong premise in a closed issue is worth as
    much as the fix.
  3. The tests were nearly vacuous twice and both were caught — once by the
    agent (a reused Module re-iterates the same RandomState and would pass
    unconditionally, so each test builds fresh), once by CodeRabbit (the tower
    fixture gave ids in name order, so a name-keyed sort would also have
    passed; ids now run opposite to names). And two sorts ship explicitly
    labelled untested
    because no fixture reaches them — a green test over a
    fixture that emits nothing is worse than none.

Also noted: the "+13% compile time" the agent first saw did not survive CPU
re-measurement (best-of-7 ratio 1.008) — correct handling on a host running
other agents at load 15–27.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codegen is not run-to-run deterministic: string-constant numbering, function-name registration order, and method-callee selection permute

1 participant