Optimizer: treat EI_ilzero (Unchecked.defaultof) as effect-free to enable dead binding elimination#19758
Optimizer: treat EI_ilzero (Unchecked.defaultof) as effect-free to enable dead binding elimination#19758Copilot wants to merge 3 commits into
EI_ilzero (Unchecked.defaultof) as effect-free to enable dead binding elimination#19758Conversation
EI_ilzero (Unchecked.defaultof) as effect-free to enable dead binding elimination
❗ Release notes required
|
…for concrete types Unchecked.defaultof<'T> (compiled as EI_ilzero) was unconditionally treated as having an effect, preventing the optimizer from eliminating unused bindings like `let _ = Unchecked.defaultof<decimal>`. Fix: In ExprHasEffect and OptimizeExprOpFallback, EI_ilzero is now considered effect-free when all type args are concrete (not type parameters). When type variables are present (e.g. SRTP ^T), it is conservatively treated as having an effect to prevent orphaned type variables during IL generation (FS0073). Fixes #17775 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
8b99fdc to
7ad68b7
Compare
isTyparTy only detects direct type parameters (e.g. ^T) but not type variables nested inside constructed types (e.g. SomeType<^T>). Replace with freeInTypes CollectTyparsNoCaching which recursively checks for any free type parameters in the type structure. Also add release notes entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Broaden the type-variable check to cover ALL effect-free Expr.Op expressions, not just EI_ilzero. When any operation would be effect-free but its F# type args contain free type parameters, conservatively treat it as having an effect. This prevents dead binding/sequential elimination from orphaning type variables that are only referenced through the eliminated expression. Also add EI_ilzero to the instruction-level no-effect list (its safety is ensured by the tyargs check above it in the pipeline). Also add release notes entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
17e17e9 to
ccefa82
Compare
T-Gro
left a comment
There was a problem hiding this comment.
Review: Optimizer – treat EI_ilzero as effect-free
Overall: Solid fix. The root-cause analysis is correct, the code change is minimal, and both positive and negative test cases are included. Approve direction; a few observations below.
1. Breadth of the ExprHasEffect / OptimizeExprOpFallback type-var guard
The freeInTypes check is now applied to all Expr.Op nodes that are otherwise effect-free, not only TOp.ILAsm [EI_ilzero _].
This means dead-binding elimination is also blocked for unused tuples, union cases, struct records, etc. when their tyargs reference a free typar – even the function's own type parameter (which is safe to reference via the IL generic context).
Example that could be affected:
sharp let f<'T> () = let _ = Unchecked.defaultof<'T> // safe to eliminate, 'T is the function's own typar 42
In practice this is unlikely to matter (who writes unused defaultof with their own typar?), but it would be worth adding a comment explaining the trade-off: "we accept false positives for non-SRTP typars to keep the fix simple."
If you later want a tighter check, you could limit the guard to TOp.ILAsm containing EI_ilzero, since that's the only instruction that can appear with zero value-args in an expression whose sole purpose is providing an SRTP witness/dummy.
2. Duplicate logic – consider a helper
The same freeInTypes CollectTyparsNoCaching tyargs check appears verbatim at lines 1642 and 2663. A small helper like:
sharp let tyargsHaveFreeTypars tyargs = not (List.isEmpty tyargs) && not (Zset.isEmpty (freeInTypes CollectTyparsNoCaching tyargs).FreeTypars)
would reduce duplication and make intent clearer at both call sites.
3. Performance consideration
freeInTypes traverses the type list and allocates a FreeTyvars. It's now called on every Expr.Op with non-empty tyargs whose op+args are effect-free. In large optimized builds this could add up. Have you spot-checked compiler self-build time or BenchmarkDotNet traces to confirm it's negligible? The "NoCaching" variant means no memoization across calls.
4. Tests
Good coverage:
- Concrete type → eliminated ✓
- SRTP type var (including nested
Wrapper<^T>) → preserved ✓
Optional addition: a test with a non-SRTP generic function demonstrating the conservative "keep" behavior (to document expected behavior and guard against future over-optimization):
sharp let f<'T> () = let _ = Unchecked.defaultof<'T> 42
5. Minor: release-notes wording
"for concrete types"
The optimization applies whenever tyargs have no free typars, so Unchecked.defaultof<list<int>> is also eliminated – "concrete" is correct but might be clearer as "fully-resolved types" or "types without free type parameters."
Overall: the fix is correct and safe. The conservatism is acceptable. Nice work.
Unchecked.defaultof<T>unused bindings were not eliminated even with optimizations enabled, because the optimizer's effect analysis incorrectly classified the underlyingEI_ilzeroIL instruction as having side effects.Root cause
Unchecked.defaultof<T>inlines to(# "ilzero !0" type ('T) : 'T #), which the optimizer represents asTOp.ILAsm([EI_ilzero ty], ...). TheIlAssemblyCodeInstrHasEffectfunction inOptimizer.fsdid not recognizeEI_ilzeroas effect-free, so it fell through to the conservative_ -> truedefault, preventing dead binding elimination.Change
src/Compiler/Optimize/Optimizer.fs— addEI_ilzero _to the no-effect branch ofIlAssemblyCodeInstrHasEffect. The instruction only produces a zero/default value (mapping toldnull,ldc.i4.0, orinitobjdepending on type), with no observable side effects.This is particularly relevant for SRTP-based patterns (common in libraries like FSharpPlus) where
Unchecked.defaultof<'T>is used as a witness/dummy argument to drive overload resolution and ends up as unused bindings at call sites.