Skip to content

Drop the dead default-initializer on a definitely-assigned local#611

Merged
richlander merged 1 commit into
mainfrom
feature/dead-initializer
Jun 18, 2026
Merged

Drop the dead default-initializer on a definitely-assigned local#611
richlander merged 1 commit into
mainfrom
feature/dead-initializer

Conversation

@richlander

Copy link
Copy Markdown
Owner

Found by the --compile-back oracle (#604).

A local whose defining store is not its first entry-block reference declares up front, and the emitter always spelled that int V_0 = default;. When the local is in fact assigned on every path before each read — the common shape of a value computed across switch sections or try/catch arms — that initializer is a dead store the original IL never had (locals lean on .locals init). Recompiling emits an extra ldc.i4.0; stloc and diverges from the source opcode stream:

orig : ldarg switch br ldc.i4 stloc ... ldloc ret
recmp: ldc.i4 stloc ldarg switch br ldc.i4 stloc ... ldloc ret   (leading dead store)

This declares such a local bare. A new conservative structured definite-assignment walk (ComputeReadBeforeAssign) decides: it threads an assigned set through if/switch/try-catch/try-finally/loops, joining on the paths that reach each merge, and keeps = default for any local read while not provably assigned. It under-claims assignment wherever unsure and bails outright — keeping every = default — on control flow it does not fully model (gotos/labels, leave, lock). So a bare declaration is emitted only when CS0165 cannot arise; the change can only remove a redundant initializer, never a required one.

Soundness: faithful. Dropping the initializer leaves the local to .locals init zero-initialization, exactly as the original relied on; the value is unchanged and the dead store disappears from the recompiled IL.

Measurements:

  • Compile-back fixture corpus: exact 133 → 139 (+6), docket 20 → 14 (−6), recompile-fail unchanged at 498 (no CS0165 introduced — the soundness check).
  • Compile-check arbiter: unchanged (semantic 316, Full malformed 1206 — no method-level regression).
  • Tests +2 (428): PowerOfTwo/CatchEverything/TryFinallyAdd now declare bare; ParseOrZero (reached via a by-ref out-argument) still keeps = default.

A local whose defining store is not its first entry-block reference declares
up front, and the emitter always spelled that `int V_0 = default;`. When the
local is in fact assigned on every path before each read — the common shape of
a value computed across switch sections or try/catch arms — that initializer
is a dead store the original IL never had (locals lean on `.locals init`), so
recompiling the body emits an extra `ldc.i4.0; stloc` and diverges from the
source opcode stream. The new --compile-back oracle flags exactly this.

Declare such a local bare. A new conservative structured definite-assignment
walk (ComputeReadBeforeAssign) decides: it threads an assigned set through
if/switch/try-catch/try-finally/loops, joining on the paths that reach each
merge, and keeps `= default` for any local read while not provably assigned.
It under-claims assignment wherever it is unsure and bails outright — keeping
every `= default` — on control flow it does not fully model (gotos/labels,
leave, lock). So a bare declaration is emitted only when CS0165 cannot arise;
the change can only remove a redundant initializer, never a required one.

Soundness: faithful. Dropping the initializer leaves the local to `.locals
init` zero-initialization, exactly as the original relied on; the value is
unchanged and the dead store disappears from the recompiled IL.

Corpus: compile-back fixture exact 133 -> 139 (+6), docket 20 -> 14 (-6),
recompile-fail unchanged at 498 (no CS0165 introduced). Compile-check arbiter
unchanged (semantic 316, Full malformed 1206 — no method-level regression).
Tests +2 (428): the three dead-init shapes now declare bare; ParseOrZero
(reached via a by-ref out-argument) still keeps `= default`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@richlander richlander merged commit 288c228 into main Jun 18, 2026
10 checks passed
@richlander richlander deleted the feature/dead-initializer branch June 18, 2026 12:20
richlander added a commit that referenced this pull request Jun 18, 2026
Cross-reference the printer's definite-assignment dead-init drop (#611) as the
conservative fallback for accumulators this pass leaves standing, so the
two-layer division of labor is discoverable from the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
richlander added a commit that referenced this pull request Jun 18, 2026
Cross-reference the printer's definite-assignment dead-init drop (#611) as the
conservative fallback for accumulators this pass leaves standing, so the
two-layer division of labor is discoverable from the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
richlander added a commit that referenced this pull request Jun 18, 2026
Cross-reference the printer's definite-assignment dead-init drop (#611) as the
conservative fallback for accumulators this pass leaves standing, so the
two-layer division of labor is discoverable from the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
richlander added a commit that referenced this pull request Jun 18, 2026
* Eliminate return-accumulator temps in EH/lock by sinking returns

When a method's result is computed inside a try, catch, finally-guarded, or
lock body and the ret sits after the region closes, the importer reconstructs
a synthetic accumulator local: try { V = x + 1; } finally { ... } return V;
The original source was try { return x + 1; } finally { ... }.

bare, which recompiles opcode-exact for the simple shapes its DA walk can
prove. It deliberately bails on leave and lock, so the two-return try/finally
(TryFinallyTwoReturns) and the lock body (ClassicLock) keep diverging, and even
the shapes it fixes still render the unnatural temp form rather than the source.

ReturnSinkingPass rewrites the accumulator back to source form: it sinks each
feeding store into a return in place — adjacent StoreLocal V; return V; pairs,
and the terminal fall-through stores of a try-body, catch arm, lock body, or
if/else arm a trailing return V follows. It is sound by construction: it acts
only on a local that is never address-taken and whose every load is a return's
operand, and applies a candidate only when every store and every load is
consumed by the plan (all-or-nothing). Switch is excluded: a switch expression
is lowered through its own result accumulator, so per-arm returns would diverge.

CfgSampleClass compile-back: exact 93 -> 95 (TryFinallyTwoReturns, ClassicLock
now exact); the EH/lock return-accumulators render try { return e; }. Compile-
check arbiter unchanged across 35k+ BCL methods (no new malformed or binding
errors). Gate updated: the five EH/lock shapes pinned exact; KnownDiffs trimmed
to the residual docket.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Document the #611 division of labor in ReturnSinkingPass header

Cross-reference the printer's definite-assignment dead-init drop (#611) as the
conservative fallback for accumulators this pass leaves standing, so the
two-layer division of labor is discoverable from the code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

1 participant