From 59db1e9036c64bd123b33de8617300d26081bdb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 01:53:56 +0200 Subject: [PATCH] fix(opt-report): report spec_no_call_sites instead of silence when every call was inlined away (#7111) --- changelog.d/7818-opt-report-no-call-sites.md | 18 ++++++++++++++++++ crates/perry-codegen/src/codegen/mod.rs | 17 ++++++++++++++--- crates/perry-codegen/src/codegen/typed_abi.rs | 12 ++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 changelog.d/7818-opt-report-no-call-sites.md diff --git a/changelog.d/7818-opt-report-no-call-sites.md b/changelog.d/7818-opt-report-no-call-sites.md new file mode 100644 index 0000000000..f460d7f7cd --- /dev/null +++ b/changelog.d/7818-opt-report-no-call-sites.md @@ -0,0 +1,18 @@ +**`--opt-report` now says `spec_no_call_sites` where it used to say nothing** (#7111). + +A function whose call sites were all removed by an earlier pass — the inliner, then `unroll_static_loops` constant-folding what it produced — has no entry in `spec_facts.call_sites`, which is built by walking `hir.init` and every body for direct `Call` expressions. The spec-ABI decision loop `continue`d on that **before** constructing any `TypedCloneRejectionReason`, so nothing reached `record_typed_clone_rejection` and nothing reached `opt_report::deny_named`. + +The result was silence, and silence is the one answer this report cannot afford: it is indistinguishable from "not analysed" and from "analysed and denied". A reader now sees that the loop was reached and had nothing to decide. + +Measured on a four-line fixture whose only call to `tiny()` is inlined and folded away: + +| | entries | rules | +|---|--:|---| +| before | 1 | `not_index_used_or_bounded` | +| after | 2 | `not_index_used_or_bounded`, **`spec_no_call_sites`** | + +The new `TypedCloneRejectionReason::SpecNoCallSites` is deliberately not framed as a denial — its doc says so — because there is nothing to specialise *for*; it is reported so the absence is legible rather than inferred. + +Worth recording for the next person: `--opt-report` writes to **stderr**, deliberately, so it cannot contaminate a `--format json` stdout payload or piped program output. Three of my attempts to observe it came back empty because they were running under `2>/dev/null`. That is also the likeliest reason a report looks like it "did not render". + +`cargo test -p perry-codegen --lib` 851 passed / 0 failed; fmt and file-size clean. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index e8d17bbfa0..6f3c37a6d6 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -2183,9 +2183,6 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let spec_budget = spec_abi::spec_abi_max(); let mut spec_emitted = 0usize; for f in &hir.functions { - let Some(sites) = spec_facts.call_sites.get(&f.id) else { - continue; - }; let reject = |reason: typed_abi::TypedCloneRejectionReason, records: &mut Vec| { @@ -2201,6 +2198,20 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> ], ); }; + // #7111: a function whose call sites were all inlined away — and + // then constant-folded by `unroll_static_loops` — has no entry in + // `spec_facts.call_sites`, which is built by walking `hir.init` and + // every body for direct `Call` expressions. This used to `continue` + // BEFORE any rejection was constructed, so `--opt-report` said + // nothing at all about the function: indistinguishable from "not + // analysed" and from "analysed and denied". Say "moot" instead. + let Some(sites) = spec_facts.call_sites.get(&f.id) else { + reject( + typed_abi::TypedCloneRejectionReason::SpecNoCallSites, + &mut typed_clone_rejection_records, + ); + continue; + }; if f.is_async || f.is_generator || f.was_plain_async { reject( typed_abi::TypedCloneRejectionReason::AsyncOrGenerator, diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index c983af0322..06488ac802 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -245,6 +245,17 @@ pub(crate) enum TypedCloneRejectionReason { ReceiverFieldNotOwn, ReceiverFieldNotF64, ThisEscape, + /// #7111 — Spec-ABI: the function has NO direct call sites left to analyse, + /// because an earlier pass removed them all (the inliner, then + /// `unroll_static_loops` constant-folding what it produced). + /// + /// This is not a denial in the usual sense: there is nothing to specialise + /// FOR. It is reported anyway because the alternative is silence, and + /// silence here is indistinguishable from "not analysed" and from "analysed + /// and denied" — which is the whole complaint the opt-report exists to + /// answer. A reader seeing `spec_no_call_sites` knows the decision loop was + /// reached and had nothing to decide. + SpecNoCallSites, /// Spec-ABI (Phase 2): no call site proved a viable representation tuple. SpecTupleUnproven, /// Spec-ABI: the function already carries a typed_abi clone family — @@ -292,6 +303,7 @@ impl TypedCloneRejectionReason { Self::ReceiverFieldNotOwn => "receiver_field_not_own", Self::ReceiverFieldNotF64 => "receiver_field_not_f64", Self::ThisEscape => "this_escape", + Self::SpecNoCallSites => "spec_no_call_sites", Self::SpecTupleUnproven => "spec_tuple_unproven", Self::SpecTypedCloneOverlap => "spec_typed_clone_overlap", Self::SpecBudgetExceeded => "spec_budget_exceeded",