Optimizer: prefer no inlining in debug builds#19548
Optimizer: prefer no inlining in debug builds#19548auduchinok wants to merge 35 commits intodotnet:mainfrom
Conversation
❗ Release notes required
|
9377496 to
257c702
Compare
|
re: Debugging I would rely on people choosing to do "Just My Code" or not, in general. I would assume most of the "ugly to debug" fslib code uses statically optimized switches and is forced to be inlined even after this feature? |
Yes, IDE settings is going to be the primary way to control it. The problem is when an SRTP function from another assembly gets a specialized definition, the resulting method becomes a part of the user assembly. That's the reason I'm considering adding an attribute like
I'm considering keeping inlining for functions in these modules:
That would allow to workaround the issue with |
|
I've checked all |
|
Ok, how do you want to whitelist those? I agree with the selection, there is nothing surprising inside of those module's functions. For the specialized SRTP definition, wouldn't |
I'm going to try the second approach first, so it could work with older FSharp.Core too. |
aefc4ce to
11f5c62
Compare
5dd26ae to
a8c0602
Compare
26ac104 to
31ee9f0
Compare
It's done on purpose, since stepping through the code reliably seems more important in debug builds.
It's done on purpose. If there're issues this can be changed in a future PR.
That's a good one. Is there some existing approach in the compiler that could be used here?
Thanks! This was deleted accidentally. |
T-Gro
left a comment
There was a problem hiding this comment.
Review: Optimizer — prefer no inlining in debug builds
This is a well-structured PR with excellent test coverage (~40 new test cases). The goal of making inline functions debuggable is highly valuable. However, there are several issues that should be addressed before merge.
🔴 CRITICAL: State Machine Compilation Regression in Debug Builds
The PR disables InlineIfLambda inlining in debug mode (inlineIfLambda && cenv.settings.alwaysInline). This breaks state machine compilation for task { }, async { }, and all user-defined computation expressions in debug builds.
Evidence:
- Removed test:
StateMachineTests.fsdeletes"Debug-mode: mixing resumable and standard computation expressions compiles" tests/fsharp/tests.fs:asyncStackTracesandstate-machines-non-optimizedtests changed from--optimize-to--optimize+— these were specifically testing non-optimized behaviorTaskGeneratedCodebaselines now showcallvirt ... TaskBuilder::Run<int32>instead of the state machineMoveNextmethod
Root cause: Task/async builder methods (TaskBuilder.Run, TaskBuilder.Bind, etc.) are inline with InlineIfLambda parameters but:
- They're NOT in
fslibForceInlineModules(only operators and intrinsics are) - They don't have
NoDynamicInvocation - Therefore
shouldForceInlineInDebugreturns false - Without inlining,
if __useResumableCode thenevaluates to false at runtime, falling through to the dynamic path
Impact: Every F# user's debug build would:
- Allocate closures and heap objects where state machines previously avoided them
- Produce different async stack traces
- Have degraded debug-mode performance for
task { }andasync { }
Recommendation: At minimum, shouldForceInlineInDebug should also force-inline vals whose return type involves ResumableCode<_,_> or recognize InlineIfLambda-bearing builder methods. Alternatively, add the FSharp.Core task builder modules to fslibForceInlineModules.
🟠 HIGH: ValInline.InlinedDefinition Uses the Zero Bit Pattern
InlinedDefinition maps to bit pattern 0b00 in ValFlags. Previously, 0b00 was unused (the InlineInfo getter would failwith "unreachable" for it). Zero is the default value for uninitialized data — if any code path creates ValFlags with default zero bits in those positions, the inline info will silently read as InlinedDefinition rather than being caught by an assertion.
While PickledBits correctly remaps it to Always before serialization, the zero-default concern remains for in-memory paths.
Recommendation: Use a different representation (e.g., a separate flag bit) or ensure the zero value remains unused.
🟠 HIGH: taccessPublic on Specialized Debug Vals
sharp let debugVal = Construct.NewVal(debugValName, m, None, debugValTy, Immutable, true, valReprInfo, taccessPublic, ...)
All specialized debug vals are created with taccessPublic regardless of the original function's accessibility. An internal inline function produces a public debug specialization. While these are local let bindings, IlxGen may emit them as public static methods.
Recommendation: Propagate the original val's accessibility: vref.Accessibility.
🟡 MEDIUM: No LanguageFeature Guard for Default-On Behavior Change
This changes default behavior for every Debug build (debug info on + optimizations off = standard dotnet build Debug). There's no gradual opt-in path — projects upgrading to this compiler silently get different debug-build behavior. Consider shipping behind a LanguageFeature flag (off-by-default in preview) initially, or at minimum require explicit MSBuild opt-in for the first release.
🟡 MEDIUM: CheckInlineValueIsComplete Disabled in Debug
When alwaysInline=false, incomplete inline values don't produce errors. A consumer compiling in release mode against a debug-compiled library might encounter errors that weren't reported when the library was built, creating confusing cross-compilation-mode failures.
🟡 MEDIUM: Closure Name Range Manipulation
The closure name collision fix creates synthetic ranges for cross-file copied expressions:
sharp Range.mkFileIndexRange eenv.cloc.Range.FileIndex expr.Range.Start expr.Range.End
This could confuse PDB/debugging (wrong file attribution). Consider using a counter that's keyed by the enclosing type instead.
✅ Positive Observations
- Excellent test coverage with ~40 cases covering direct calls, SRTP specialization, cross-assembly, byrefs, accessibility, members, operators, recursive functions
- Sound recursion prevention via
dontInlinemap for both concrete and non-concrete type specializations - Correct byref handling — falls back when closure path can't handle byrefs
- Cross-assembly design is correct — release consumers of debug-compiled libraries inline normally
- Binary compatibility maintained via
PickledBitsremapping
Summary
The implementation is architecturally sound but needs to address the state machine regression before shipping. The removed/changed state machine tests should be restored, and the InlineIfLambda path needs to remain functional for resumable code patterns in debug builds.
inlinefunctions are fully inlined on the call sites by F# compiler. This means the source information and actual calls are lost, so it's very hard to reliably debug code using such functions.This PR prevents inlining
inlinefunctions in debug builds. This improves debugging experience by allowing setting breakpoints inside such functions and allowing stepping into them and checking the function arguments. It also allows IDEs to analyze the produced IL more reliably during debug.The implementation handles two distinct cases. When possible, a normal call to the existing method is emitted. When a function contains SRTPs and no callable method is produced, a specialized method is created for each type instantiation.
An example
inlinefunction that can be called directly:The existing method is called:

An
inlinefunction with SRTPs:A specialized method is produced and called:

Open questions:
DebuggerNonUserCode?Implements fsharp/fslang-suggestions#824 for debug builds.
Fixes #9555.