Skip to content

Non-optimized inlining regression #20063

Closed

Description

@kerams

Since #19548 inline functions are not inlined in debug builds, including those where inlining is not negotiable.

Repro steps

open System
open FSharp.NativeInterop
#nowarn 9

// without inlining the returned Span points at a stack position that is no longer valid
let inline stackalloc<'a when 'a: unmanaged> length =
    Span<'a> (NativePtr.stackalloc<'a> length |> NativePtr.toVoidPtr, length)

let rec clobber n =
    let mutable a = int64 n
    let mutable b = a * 2L
    let mutable c = b * 2L
    let mutable d = c * 2L
    if n > 0 then clobber (n - 1) + int (a + b + c + d) else 0

let demo () =
    let b = stackalloc<char> 3
    b.[0] <- 'a'
    b.[1] <- 'b'
    b.[2] <- 'c'
    clobber 64 |> ignore
    String b

let r = demo ()
printfn "expected: \"abc\" [97; 98; 99]"
printfn "actual:   %A %A" r (r |> Seq.map int |> Seq.toList)

Expected behavior

> dotnet fsi .\stackalloc-inline-repro.fsx
expected: "abc" [97; 98; 99]
actual:   "abc" [97; 98; 99]
> dotnet fsi --optimize- .\stackalloc-inline-repro.fsx
expected: "abc" [97; 98; 99]
actual:   "abc" [97; 98; 99]

Actual behavior

> dotnet fsi .\stackalloc-inline-repro.fsx
expected: "abc" [97; 98; 99]
actual:   "abc" [97; 98; 99]
> dotnet fsi --optimize- .\stackalloc-inline-repro.fsx
expected: "abc" [97; 98; 99]
actual:   "?" [63; 0; 0]

Known workarounds

Optimize debug builds.

Related information

SDK 11.0.100-preview.6.26359.118

Activity

  1. added theissue type on Jul 14, 2026
  2. added this to the Backlog milestone on Jul 14, 2026
  3. T-Gro commented on Jul 22, 2026

    @T-Gro
    Member

    @auduchinok : Can you take a look please?

  4. auduchinok commented on Jul 22, 2026

    @auduchinok
    Member

    Yes, thanks, it's on my list 🙂

  5. smoothdeveloper commented on Jul 26, 2026

    @smoothdeveloper
    Contributor

    Also hitting this with recent release of VS2026 insider. What is the plan to identify functions that can't be inlined?

    In my case it is calling SRTP in a stack of inline functions; which provokes a runtime failure.

  6. auduchinok commented on Jul 26, 2026

    @auduchinok
    Member

    In my case it is calling SRTP in a stack of inline functions; which provokes a runtime failure.

    Could you share a repro look into, please?

  7. smoothdeveloper commented on Jul 26, 2026

    @smoothdeveloper
    Contributor
    type LoadMechanism =
        | NoSource
        | BytesSource of byte[]
    
    let loadResource () =
        let mechanisms = [ NoSource; BytesSource [| 1uy; 2uy; 3uy |] ]
        // Closes over `mechanisms` from the enclosing scope instead of taking it as a parameter --
        let inline makeTypedCsvLoader loadStream mkResult =
            mechanisms
            |> Seq.choose (fun m ->
                let loadStream = loadStream >> Array.map int
                match m with
                | NoSource -> None
                | BytesSource bytes -> Some(loadStream bytes))
            |> Seq.tryHead
            |> Option.map mkResult
        makeTypedCsvLoader id id
    
    [<EntryPoint>]
    let main _ =
        let expected = Some [| 1; 2; 3 |]
        let actual = loadResource ()
        printfn "expected: %A" expected
        printfn "actual:   %A" actual
        if actual = expected then
            printfn "OK"
            0
        else
            printfn "MISMATCH (unexpected on this compiler -- bug may be fixed)"
            1

    With current VS2026 insider compiler, targetting net48, in debug, mechanisms end up being null rather than closing over the outer scope value.

    Unhandled Exception: System.ArgumentNullException: Value cannot be null.
    Parameter name: source
    at Microsoft.FSharp.Collections.SeqModule.Choose[T,TResult](FSharpFunc2 chooser, IEnumerable1 source) in D:\a_work\1\s\src\FSharp.Core\seq.fs:line 777
    at Program.__debug@69(FSharpFunc2 loadStream, FSharpFunc2 mkResult)

    in release it works properly.

  8. auduchinok commented on Jul 28, 2026

    @auduchinok
    Member

    A smaller repro @kerams's case is this:

    let inline stackalloc n =
        Span<char>(NativePtr.stackalloc<char> n |> NativePtr.toVoidPtr, n)
    
    let f () =
        let b = stackalloc 1
        b.[0] <- 'a'
        String b

    In addition to enabling the optimizations, here are two more workarounds:

    • use --always-inline+ in the project
    • mark the function with NoDynamicInvocation

    There are several ways to tackle this. One way is to propagate force-inlining when an inline function uses any functions marked with NoDynamicInvocation. This would fix this case, but it would silently break the debugger improvements from #19548 which is not good.

    Another way is to ask the users to mark such functions with NoDynamicInvocation explicitly: this way adds churn to the user codebases, but it makes the intent clear and communicates the debugging expectations to both users and IDEs. This can be done by adding a new compiler warning when a function not marked with NoDynamicInvocation uses other such functions.

    There's a trade off in either option, so I'd like to hear more opinions.

  9. auduchinok commented on Jul 28, 2026

    @auduchinok
    Member

    @smoothdeveloper Thanks! It looks like a genuine bug. I'm looking into it.

  10. smoothdeveloper commented on Jul 31, 2026

    @smoothdeveloper
    Contributor

    @auduchinok, I’m concerned about the proposed solutions.

    They require users to determine which methods need annotation and identify when runtime behavior is incorrect, making it difficult to determine when an attribute needs to be added.

    This breaks backward compatibility of perfectly fine code and makes debug compiled code buggy.

    My suggestions:

    • if there is a way to have a proper heuristic, the NoDynamicInvocation attribute would be added implicitly
    • when specialist users (those who know how to use inline as it were before this change) want their inline functions to be debuggable, and are aware of potential side-effects, they could annotate the specific functions that won't be inlined in debug builds, with an opt-in attribute

    I feel this would be better user experience than hard to identify regressions in debug builds, however I understand that stepping into inline function, not working at all out of the box, is a concern; maybe the initial approach was too optimistic?

  11. auduchinok commented on Jul 31, 2026

    @auduchinok
    Member

    @smoothdeveloper These are valid concerns, thank you. These suggestions apply to relatively rare unverifiable code only, like the stackalloc example above, and all the rest inline cases should just work without any changes. The first option suggests to propagate this automatically indeed, so there would be no regression and no user action would be required. The second option suggests producing a warning, so users still wouldn't have to determine anything themselves. We could try to do both: propagate the attribute implicitly and suggest adding it via a warning.

  12. brianrourkeboll commented on Jul 31, 2026

    @brianrourkeboll
    Contributor

    It is annoying that you couldn't add the attribute to a local inline function though — you'd need to pull the function out into class/module scope in order to add the attribute.

  13. smoothdeveloper commented on Aug 1, 2026

    @smoothdeveloper
    Contributor

    you'd need to pull the function out into class/module scope in order to add the attribute.

    In my usage of inline, I often use it for local functions, and they do close over symbols; sometimes to hide another function and supplying some of the parameters.

    The first option suggests to propagate this automatically indeed, so there would be no regression and no user action would be required.

    Sorry, I misunderstood that it would happen automatically. I'm for the implicit solution with no added warnings.

  14. T-Gro commented on Aug 3, 2026

    @T-Gro
    Member

    @auduchinok :

    Could some of the byref analysis that we have in postinferencechecks be moved+used to build up the heuristics around implicitly adding NoDynamicInvocation ?

    It would for sure help if more distinct examples are collected here 👍 .

    I think we cannot accept the pls add an attribute if you see it resolution here - the original repro from @kerams might be very difficult to spot in a larger program, since it does not end with any program error. It just produces incorrect data.

  15. auduchinok commented on Aug 3, 2026

    @auduchinok
    Member

    Could some of the byref analysis that we have in postinferencechecks be moved+used to build up the heuristics around implicitly adding NoDynamicInvocation ?

    @T-Gro Yes, this is what I have in mind. I'm on vacation right now, so will look into it next week 🙂

  16. christian-clausen commented on Aug 21, 2026

    @christian-clausen

    We independently hit this exact regression in a real production codebase, via the
    same stackalloc helper shape (inline wrapper around NativePtr.stackalloc +
    manual Span construction). Flagging because our environment differs from the
    original report in a way that may raise the urgency: we saw this on SDK 10.0.400,
    a shipped GA servicing release installed via plain winget install Microsoft.DotNet.SDK.10 -v 10.0.400 — not the 11.0.100-preview channel. Since
    #20242 merged to main on Aug 11 and 10.0.400 was already out, it looks like this
    regression already reached real users on stable .NET 10, and the fix likely hasn't
    been serviced back into the 10.0.4xx branch yet.

    Confirmed side by side on the same machine (same underlying runtime,
    .NET 10.0.11, in every case — only the SDK-bundled compiler differs):

    SDK fsc.dll ProductVersion Debug Release
    10.0.111 (not captured) correct correct
    10.0.201 (not captured) correct correct
    10.0.303 15.2.303-servicing.26377.103+e730f1db756d11c93f246830ba7b94ee6fcf4b94 correct correct
    10.0.400 15.2.400-servicing.26379.115+14fbf8d5271c98133561eb55185fdb05b286f578 wrong correct

    Also worth noting: our repro doesn't need the clobber() recursive-call trick from
    the original report at all. A nested closure captured in the while loop's own
    condition (capturing and pattern-matching an option-typed outer parameter), with
    the stackalloc call wrapped in if ... then try ... finally, is sufficient on its
    own to trigger it:

    open System
    open FSharp.NativeInterop
    #nowarn "9"
    
    let inline stackalloc<'a when 'a: unmanaged> (length: int) : Span<'a> =
        let p = NativePtr.stackalloc<'a> length |> NativePtr.toVoidPtr
        Span<'a>(p, length)
    
    let data : byte[] = Array.zeroCreate 60
    data[40] <- 80uy
    data[41] <- 75uy
    data[42] <- 3uy
    data[43] <- 4uy
    
    let findMarker (data: byte[], maxOffset: int option) : int option =
        let mutable result = None
        let mutable i = 0
    
        let beforeMaxOffset () =
            match maxOffset with
            | None -> true
            | Some offset -> i < offset + 4
    
        if data.Length > 4 then
            try
                let bytes: Span<byte> = stackalloc 4
                while i < data.Length && result.IsNone && beforeMaxOffset () do
                    bytes[0] <- bytes[1]
                    bytes[1] <- bytes[2]
                    bytes[2] <- bytes[3]
                    bytes[3] <- data[i]
                    if bytes[0] = 80uy && bytes[1] = 75uy && bytes[2] = 3uy && bytes[3] = 4uy then
                        result <- Some(i - 3)
                    i <- i + 1
            finally
                ()
        result
    
    [<EntryPoint>]
    let main _ =
        printfn "Runtime: %s" System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription
        match findMarker (data, Some 60) with
        | Some pos -> printfn "FOUND at offset %d (expected 40)" pos
        | None -> printfn "NOT FOUND (expected offset 40)"
        0

    Expected/actual: FOUND at offset 40 on every SDK above except 10.0.400 Debug,
    which prints NOT FOUND — the marker genuinely is at data[40..43], so this is
    silent data corruption, not a crash.

    Real-world impact for us: this broke a ZIP-header-scanning routine (locating
    PK\x03\x04 inside a proprietary container format) with no exception — just a
    wrong answer — after picking up 10.0.400 on a build agent. Given this shape (no
    diagnostic, Debug-only, silent), we'd treat this as a correctness issue worth
    tracking until it's confirmed fixed on the 10.0.4xx servicing train specifically,
    not just on main. Happy to help verify against a servicing build once one is
    available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions