Wado

WEP: Optimizer Remarks for Missed Optimizations

Context

Wado source — including this compiler — is increasingly written and repaired by AI coding agents. WEP: Diagnostic Reason Chains acted on this for correctness feedback: it adopted the finding (Krishnamurthi and Flatt, "Type-Error Ablation and AI Coding Agents", arXiv:2606.01522) that detailed, causal, source-located error messages measurably raise an agent's first-try fix rate, and made type/trait diagnostics richer by default.

A second paper extends the same lesson to a different compiler subsystem. "AI Coding Agents Need Better Compiler Remarks" (arXiv:2604.13927, 2026) studies optimization remarks — the compiler's feedback about a desired optimization — rather than hard errors. Driving an agent to restructure C loops for auto-vectorization on the TSVC suite, it found:

This is the same meta-principle as the Diagnostic Reason Chains WEP — precise, causal, prescriptive feedback by default; vague feedback is actively harmful — applied to a subsystem that WEP does not touch. Type/trait diagnostics report errors: the program is rejected. Optimizer remarks report costs: the program is correct but pays a runtime price the source structure could avoid. Different audience need (performance, not correctness), different subsystem (the optimizer, not the type checker), so a separate WEP.

What transfers, and the design principle it forces

The paper's literal task does not transfer. It coaxes a compiler into auto-vectorizing; Wado's optimizer philosophy is to leave low-level work like SIMD to the runtime JIT (see optimizer.md). What transfers is the remark mechanism, and choosing where to point it surfaces a sharp design constraint, because Wado's optimizer is still developing and remarks must not be written to fit its current internals.

Inlining was the obvious first candidate and is rejected. It fails three tests a remark must pass:

Inlining misses all three. The principle that survives the bar:

A remark should bind to the language's semantic cost model, not to an optimizer heuristic, and it should fire from a fact observable in the final IR, not from a pass's internal decision.

Binding to the final IR is what decouples the remark from the immature optimizer. The remark machinery reads the optimizer's output — a stable interface — never its pass internals. As the optimizer improves and removes a cost, the fact disappears from the final IR and the remark self-retires. That is the correct behavior, and it directly answers "don't write code to fit the current optimizer."

The flagship costs: residual aggregates

Value semantics is Wado's defining feature: assignment, parameter passing, and return all deep-copy the value, and aggregates (structs, tuples, List<T>) are GC-managed heap objects (spec.md). Two of the optimizer's heaviest jobs exist to remove the hidden cost of that model — redundant deep copies, and the allocations themselves — and most of the time they succeed. The cost that remains is invisible at the source: f(x) looks free, and a struct literal looks like a register's worth of work, whether or not either survives as a runtime copy or heap allocation.

Both costs share the structure the principle wants: the optimizer represents each as a concrete construct in the IR and tries to remove it, so a survivor is an observable fact in the final NIR, rare because the easy cases are already gone, and self-retiring as the optimizer matures. They become the first two remark kinds.

Surviving value copies

A deep copy is a call to a synthesized $value_copy$T helper (FunctionKind::ValueCopy). It is inserted only where the lower-phase ownership analysis (lower::plan::value_copy) could not prove the copy unnecessary — a move, a share, or a fresh value — so every wrapper reaching the optimizer is already needed; there is no elision pass. But a copy is not always left intact: value_copy_demote rewrites a deep copy whose elements are never mutated into a shallow spine copy, lowered as a builtin::array_clone / array_clone_shallow call on the backing array. So a value copy that survives optimization appears in the final NIR as one of: a remaining $value_copy$T(...) call, or an array_clone / array_clone_shallow / copy_value call. The remark collects all of these in entry-module functions. (array_copy is excluded — it is bulk buffer movement inside stdlib helpers like String::push, not a value-semantic copy.) Example output (--log-level info), where b is a List<i32> copied then mutated:

src.wado:5:5: info: remark: a copy of `List<i32>` survives optimization

Why the final NIR and not the final WIR: the WIR is the last IR before codegen and would be the most authoritative "this copy executes" signal, but WirInstr carries no per-instruction span (only function-level WirMeta does), so a WIR-sourced remark could not point at the source. The optimized NIR is the last IR with per-expression spans, and wir_build lowers these copies one-to-one, so NIR is both span-accurate and faithful. Even so the synthesized copy nodes (array_clone, demoted spine copies) carry placeholder spans, as do the inner statements of the blocks that SROA reconstruction wraps them in, so the remark anchors to the enclosing real statement — the let mut b = a; that performs the copy — rather than descending into those synthesized inner statements. The reference escape hatch (&T / &mut T) is the only construct that shares rather than copies (spec.md); naming it as a suggestion is deferred to the read-only-vs-required classification below.

Surviving aggregate allocations (failed SROA)

Not yet implemented — design for the next remark kind; see the Consequences checklist.

Scalar Replacement of Aggregates (sroa, container_sroa, field_scalarize, sroa_param) dissolves a struct or tuple into individual scalar locals, removing the GC heap allocation entirely — by the pass's own note, "the single most impactful optimization for WasmGC-targeting compilers." It is gated by escape analysis: an aggregate used only for field access is scalarized; one with soft escapes (call argument, return, nested literal) is scalarized with reconstruction at the escape site; one with a hard escape (address taken, closure capture, bare local assignment, reference stored) is left as a heap allocation.

The observable survivor is an aggregate allocation that remains in the final IR. The remark reports it with the hard-escape reason the pass already classified — so the why is the analysis fact, not a guess:

remark: `Point` stays heap-allocated here; SROA could not scalarize it [path.wado:30:13]
  note: the value escapes by being captured in a closure at path.wado:33:20
  suggestion: pass the fields the closure needs instead of capturing `Point`

Because SROA already distinguishes hard from soft escapes, the remark can name the precise escape that blocked it, and can stay silent on soft escapes that were scalarized anyway.

Decision

Introduce optimizer remarks, with residual value-copy and failed-SROA remarks as the first two kinds.

MVP scope

Shipped: surviving value copies. remarks::collect_value_copy_remarks walks the optimized NIR's entry-module functions, collects residual $value_copy$T calls and array_clone / array_clone_shallow / copy_value calls, and emits one info-level remark per survivor anchored to the enclosing statement. logger.remark carries the span and the entry filename; tests (wado-compiler/tests/remarks.rs) pin the behavior at -O2 — a List<i32> copied then mutated fires one remark on the copy line; a Point that SROA scalarizes fires none. No size threshold yet: a synthesized copy helper only exists for non-trivial aggregates, so survivors are already non-trivial.

Next: failed-SROA remarks, reusing the SROA passes' existing hard-escape classification for the why and the escape site for a second span.

Reusing existing infrastructure

Remarks are diagnostics with spans, so they reuse the rendering path the Diagnostic Reason Chains WEP already exercises (headline + indented note: / suggestion: lines). That WEP's open follow-up — structured, independently- spanned notes on Diagnostic — is a shared dependency: a failed-SROA remark naming both the allocation and its escape site needs two spans, the same capability the type/trait reason chains want. Building it once serves both.

Consequences

Shipped:

Next:

Trade-offs and boundaries: