Skip to content

JIT: Add DebugInfo to ret expr statement when doing GDV - #135209

Open
anderspedersen wants to merge 6 commits into
dotnet:mainfrom
anderspedersen:gdv-debuginfo-to-ret-expression
Open

anderspedersen wants to merge 6 commits into
dotnet:mainfrom
anderspedersen:gdv-debuginfo-to-ret-expression

Conversation

@anderspedersen

@anderspedersen anderspedersen commented Oct 5, 2026 •

Copy link
Copy Markdown

Fixes #134523

When doing GDV of a method with an return value, the compiler inserts a statement for the call with DebugInfo and a statement for the return expression without DebugInfo. Later, more logic from the devirtualized call is merged into the return expression statement, so we have a statement with logic from the devirtualized call, but with no IP mapping, so stack traces will point to first line of method, rather than the line containing the devirtualized call.

This PR fixes this by adding DebugInfor to the return expression also.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 5, 2026
@github-actions github-actions Bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Oct 5, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.

@pcshrosbree

Copy link
Copy Markdown
Contributor

Thanks for chasing this down. I measured before/after against the same tree with the line reverted, on Checked builds of the PR head for linux-x64, linux-arm64, win-x64, win-arm64 and osx-arm64, plus a linux-x64 Release build (without native PGO/LTO).

  • The issue's repro shape (warmed with Data always null) reports the method's first line before this change and the right line after.
  • The same line also fixes delegate GDV, chained GDV and boxed-struct GDV. In the chained case the second call was attributed to the first call's line, since the unmapped code inherits whatever mapping precedes it.
  • SPMI asmdiffs over the 12 linux-x64 collections (~3.78M contexts): +0 code bytes; 38,746 contexts differ at the same size, and every one I sampled fails only SuperPMI's IL-to-native boundary comparison (see notes).
  • Shipped 8.0 and 10.0 show the same wrong rows as main on linux-x64, linux-arm64, osx-arm64, win-x64 and win-arm64 (all but the non-inlined row, which they don't guard).

The other arm of that if in DevirtualizeCall (!inlineInfo->isInlineable || unboxedEntryMismatch) has the same omission: fgNewStmtAtEnd(block, store) without m_stmt->GetDebugInfo(), so with this PR a non-inlineable target whose result is used still reports IL 0. That half became reachable by default with #132375 (not in release/10.0 or release/11.0). The patch below adds that line plus a regression test with three cases: an inlined target, a non-inlineable target, and the issue's own shape (a delegate-returned holder whose inlined getter's null result is dereferenced, warmed through the throwing path). It fails all three on main and the non-inlined one on this PR, and passes with both lines, on all five platforms.

Happy to push it here or separately.

Measurements

Caller frame after Tier1 GDV (StackFrame.GetILOffset() / PDB line). ✗ = differs from Tier0.

case main (PR line reverted) this PR PR + extra line
interface, inlined target ✗ IL 0 ✓ ✓
interface, non-inlineable target ✗ IL 0 ✗ IL 0 ✓
same, result unused (control) ✓ ✓ ✓
delegate, inlined target ✗ IL 0 ✓ ✓
chained GDV, 2nd call ✗ previous call's line ✓ ✓
boxed struct ✗ IL 0 ✓ ✓
#134523 as reported (Data null during warm-up) ✗ first line ✓ ✓

Same rows on all five platforms (Checked), on linux-x64 Release, and, on linux-x64 Checked, with the probe precompiled by crossgen2 (R2R; there the reference frame is the R2R code's, not Tier0's). Tier1 IP map for the guarded block of the first row (the test's CallInlined, same IL shape):

main:    IL offs NO_MAP : 0x00000025
this PR: IL offs 0x0006 : 0x00000025

Notes:

  • SPMI: every diffing collection is +0 bytes with all diffs at the same size (3,572 contexts were missing data and not compared). The 28 diffing contexts I replayed verbosely (the first four per collection) pass the code, EH and GC-info comparisons and fail only the IL-to-native boundary comparison. The near-differ checks only the size of the read-only data and the count of variable-location records, not their contents, so those are not covered. The extra line: +0 bytes, 1,957 contexts, same pattern.
  • The unboxedEntryMismatch half of that arm is older and has shipped, but it changed no SPMI context: a JIT adding the DebugInfo only for that half gives 0 diffs, and the complementary half reproduces all of them.
  • On linux-x64 Checked, the test keeps the same main/PR failures under fullpgo_random_gdv, R2R and GCStress 0x3/0xC, and passes with both lines there. Under JitStress=1/2 the non-inlined case goes vacuous (Tier1 doesn't guard that call there), while the inlined and issue-shaped cases still catch the PR's line. The warm-up is timed, so a slow machine should only make it vacuously green; I saw no false red in any run. It takes about 2.5 s on x64 and 4 s on a Snapdragon X laptop (3–8 s under GCStress on linux-x64), so it does not opt out of GCStress.
  • On NativeAOT (linux-x64, ILC's exact-class GDV, no profile), the interface and boxed-struct shapes already reported the right lines before this change, in stack traces and DWARF. ILC's setBoundaries drops NO_MAPPING rows, so the unmapped range continues the guard's row for the same statement. The IL-0 symptom is CoreCLR's stack walker mapping NO_MAPPING to 0.
Patch: the extra line + regression test (against 5abd6a0)
diff --git a/src/coreclr/jit/indirectcalltransformer.cpp b/src/coreclr/jit/indirectcalltransformer.cpp
index 635c0f7ea6f..2b2805c4dfa 100644
--- a/src/coreclr/jit/indirectcalltransformer.cpp
+++ b/src/coreclr/jit/indirectcalltransformer.cpp
@@ -1033,7 +1033,7 @@ private:
                 if (m_returnTemp != BAD_VAR_NUM)
                 {
                     GenTree* const store = m_compiler->gtNewTempStore(m_returnTemp, call);
-                    m_compiler->fgNewStmtAtEnd(block, store);
+                    m_compiler->fgNewStmtAtEnd(block, store, m_stmt->GetDebugInfo());
                 }
                 else
                 {
diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_134523/Runtime_134523.cs b/src/tests/JIT/Regression/JitBlue/Runtime_134523/Runtime_134523.cs
new file mode 100644
index 00000000000..b6636232f97
--- /dev/null
+++ b/src/tests/JIT/Regression/JitBlue/Runtime_134523/Runtime_134523.cs
@@ -0,0 +1,248 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using Xunit;
+
+// Guarded devirtualization must not drop the IL offset of the call it guards.
+// Each caller makes a single interface call whose result is used, so the
+// devirtualized call needs a return temp. Once Tier1 has guarded-devirtualized
+// the call site (the profile only ever sees Impl), the stack frame for the
+// caller must still report an IL offset inside the call's statement, as it
+// does at Tier0.
+public class Runtime_134523
+{
+    public interface IValue
+    {
+        int Inlined(int x);
+        int NotInlined(int x);
+    }
+
+    public sealed class Impl : IValue
+    {
+        // Inlineable: after inlining, the call to Check ends up in the
+        // statement created for the GDV return expression.
+        public int Inlined(int x) => Check(x);
+
+        // Not inlineable: GDV still devirtualizes the call and keeps it as a
+        // direct call stored to the return temp.
+        [MethodImpl(MethodImplOptions.NoInlining)]
+        public int NotInlined(int x) => Check(x);
+    }
+
+    // The shape reported in the issue: a delegate returns a holder, and the
+    // result of an inlined getter is dereferenced, so a null Data faults in
+    // this frame (hardware NullReferenceException).
+    public interface IHolder
+    {
+        object Data { get; }
+    }
+
+    public sealed class HolderImpl : IHolder
+    {
+        public object Data { get; set; }
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private static bool CallIssue(Func<IHolder> holderFactory, bool flag)
+    {
+        var holder = holderFactory();
+        return flag && holder.Data.ToString() != null;
+    }
+
+    // IL range of CallIssue's 'return flag && ...' statement: everything after the
+    // first statement (ldarg.0; callvirt Invoke; stloc.0).
+    private static (int Lo, int Hi) IssueStatementRange()
+    {
+        MethodInfo method = typeof(Runtime_134523).GetMethod(nameof(CallIssue), BindingFlags.NonPublic | BindingFlags.Static);
+        byte[] il = method.GetMethodBody().GetILAsByteArray();
+        Assert.True(il.Length > 7 && il[0] == 0x02 && il[1] == 0x6F && il[6] == 0x0A, "unexpected IL shape in CallIssue");
+        return (7, il.Length - 1);
+    }
+
+    private static int IssueFrameILOffset(Func<IHolder> holderFactory)
+    {
+        try
+        {
+            CallIssue(holderFactory, true);
+        }
+        catch (NullReferenceException ex)
+        {
+            foreach (StackFrame frame in new StackTrace(ex, false).GetFrames())
+            {
+                if (frame.GetMethod()?.Name == nameof(CallIssue))
+                {
+                    return frame.GetILOffset();
+                }
+            }
+        }
+
+        Assert.Fail("expected a NullReferenceException from CallIssue");
+        return -1;
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private static int Check(int x)
+    {
+        if (x < 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(x));
+        }
+
+        return x;
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private static void Consume(int x) { }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private static int CallInlined(IValue v, int x)
+    {
+        Consume(x);
+        return v.Inlined(x) + 1;
+    }
+
+    [MethodImpl(MethodImplOptions.NoInlining)]
+    private static int CallNotInlined(IValue v, int x)
+    {
+        Consume(x);
+        return v.NotInlined(x) + 1;
+    }
+
+    // IL range of the statement 'return v.X(x) + 1;': from just after the
+    // 'call Consume' instruction up to and including the 'callvirt'.
+    private static (int Lo, int Hi) CallStatementRange(string methodName)
+    {
+        MethodInfo method = typeof(Runtime_134523).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);
+        byte[] il = method.GetMethodBody().GetILAsByteArray();
+        int lo = -1;
+        int hi = -1;
+        int i = 0;
+        while (i < il.Length)
+        {
+            switch (il[i])
+            {
+                case 0x28: // call <token>
+                    if (lo < 0)
+                    {
+                        lo = i + 5;
+                    }
+                    i += 5;
+                    break;
+                case 0x6F: // callvirt <token>
+                    hi = i;
+                    i += 5;
+                    break;
+                default:
+                    // Only one-byte opcodes without operands (ldarg.N, ldc.i4.1,
+                    // add, ret, nop) occur otherwise in these two methods.
+                    i += 1;
+                    break;
+            }
+        }
+
+        Assert.True(lo > 0 && hi >= lo, $"unexpected IL shape in {methodName}");
+        return (lo, hi);
+    }
+
+    private static int ThrowingFrameILOffset(Func<IValue, int, int> caller, IValue v, string callerName)
+    {
+        try
+        {
+            caller(v, -1);
+        }
+        catch (ArgumentOutOfRangeException ex)
+        {
+            StackTrace trace = new StackTrace(ex, false);
+            foreach (StackFrame frame in trace.GetFrames())
+            {
+                if (frame.GetMethod()?.Name == callerName)
+                {
+                    return frame.GetILOffset();
+                }
+            }
+
+            Assert.Fail($"no frame for {callerName} in:{Environment.NewLine}{trace}");
+        }
+
+        Assert.Fail("expected ArgumentOutOfRangeException");
+        return -1;
+    }
+
+    [Fact]
+    public static void TestEntryPoint()
+    {
+        IValue v = new Impl();
+        (int Lo, int Hi) inlinedRange = CallStatementRange(nameof(CallInlined));
+        (int Lo, int Hi) notInlinedRange = CallStatementRange(nameof(CallNotInlined));
+
+        // Tier0 reference: the frame lands inside the call statement.
+        Assert.InRange(ThrowingFrameILOffset(CallInlined, v, nameof(CallInlined)), inlinedRange.Lo, inlinedRange.Hi);
+        Assert.InRange(ThrowingFrameILOffset(CallNotInlined, v, nameof(CallNotInlined)), notInlinedRange.Lo, notInlinedRange.Hi);
+
+        // The issue's shape faults in CallIssue itself, in its second statement.
+        Func<IHolder> holderFactory = () => new HolderImpl();
+        (int Lo, int Hi) issueRange = IssueStatementRange();
+        Assert.InRange(IssueFrameILOffset(holderFactory), issueRange.Lo, issueRange.Hi);
+
+        // Drive the callers through instrumented Tier0 to Tier1 with a
+        // monomorphic class profile, so Tier1 guarded-devirtualizes the calls.
+        // If tier-up has not finished by the checks below, they run against
+        // Tier0 code and pass, so a slow machine can only make this vacuous.
+        for (int i = 0; i < 100; i++)
+        {
+            for (int j = 0; j < 1000; j++)
+            {
+                CallInlined(v, j);
+                CallNotInlined(v, j);
+            }
+
+            // As in the issue, warm CallIssue through the throwing path.
+            for (int j = 0; j < 100; j++)
+            {
+                try
+                {
+                    CallIssue(holderFactory, true);
+                }
+                catch (NullReferenceException)
+                {
+                }
+            }
+
+            Thread.Sleep(20);
+        }
+
+        Thread.Sleep(200);
+
+        // Check every caller before asserting, so one failure does not hide another.
+        string failures = "";
+        for (int i = 0; i < 20; i++)
+        {
+            failures += CheckCaller(CallInlined, v, nameof(CallInlined), inlinedRange);
+            failures += CheckCaller(CallNotInlined, v, nameof(CallNotInlined), notInlinedRange);
+
+            int issueOffset = IssueFrameILOffset(holderFactory);
+            if ((issueOffset < issueRange.Lo) || (issueOffset > issueRange.Hi))
+            {
+                failures += $"{nameof(CallIssue)}: frame IL offset 0x{issueOffset:X} outside statement [0x{issueRange.Lo:X}, 0x{issueRange.Hi:X}]{Environment.NewLine}";
+            }
+        }
+
+        Assert.True(failures.Length == 0, failures);
+    }
+
+    private static string CheckCaller(Func<IValue, int, int> caller, IValue v, string callerName, (int Lo, int Hi) range)
+    {
+        int offset = ThrowingFrameILOffset(caller, v, callerName);
+        if ((offset >= range.Lo) && (offset <= range.Hi))
+        {
+            return "";
+        }
+
+        return $"{callerName}: frame IL offset 0x{offset:X} outside call statement [0x{range.Lo:X}, 0x{range.Hi:X}]{Environment.NewLine}";
+    }
+}
diff --git a/src/tests/JIT/Regression/JitBlue/Runtime_134523/Runtime_134523.csproj b/src/tests/JIT/Regression/JitBlue/Runtime_134523/Runtime_134523.csproj
new file mode 100644
index 00000000000..99b6b9b01e6
--- /dev/null
+++ b/src/tests/JIT/Regression/JitBlue/Runtime_134523/Runtime_134523.csproj
@@ -0,0 +1,22 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <Optimize>True</Optimize>
+    <!-- Needed for CLRTestEnvironmentVariable -->
+    <RequiresProcessIsolation>true</RequiresProcessIsolation>
+    <!-- Tiered compilation is disabled in interpreter mode. -->
+    <InterpreterIncompatible>true</InterpreterIncompatible>
+    <!-- Relies on Tiered PGO and on reading IL via MethodBody. -->
+    <NativeAotIncompatible>true</NativeAotIncompatible>
+  </PropertyGroup>
+  <ItemGroup>
+    <Compile Include="$(MSBuildProjectName).cs" />
+
+    <!-- GDV needs a class profile, so let Tiered PGO collect one quickly. -->
+    <CLRTestEnvironmentVariable Include="DOTNET_TieredCompilation" Value="1" />
+    <CLRTestEnvironmentVariable Include="DOTNET_TieredPGO" Value="1" />
+    <CLRTestEnvironmentVariable Include="DOTNET_TC_CallCountThreshold" Value="1" />
+    <CLRTestEnvironmentVariable Include="DOTNET_TC_CallCountingDelayMs" Value="0" />
+    <!-- A MinOpts stress leg would make Tier1 MinOpts (no GDV) and the test vacuous. -->
+    <CLRTestEnvironmentVariable Include="DOTNET_JITMinOpts" Value="0" />
+  </ItemGroup>
+</Project>

@anderspedersen

Copy link
Copy Markdown
Author

@pcshrosbree
Thanks for looking into this and finding and fixing the related issue!

Happy to push it here or separately.

Either option works for me. Let me know if there's anything you need me to do.

@pcshrosbree

Copy link
Copy Markdown
Contributor

@anderspedersen Thanks. One PR is simplest, since the test only passes with both lines in place and I can't push to your branch. If you apply the patch from my comment above on top of gdv-debuginfo-to-ret-expression (git apply applies cleanly at 5abd6a0) and push, that keeps the review in one place and the test will cover both sites. If you'd rather I open a PR against your fork's branch instead, say so and I'll do that.

@anderspedersen

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

@anderspedersen

Copy link
Copy Markdown
Author

@pcshrosbree I have added your changes to my branch

@pcshrosbree

Copy link
Copy Markdown
Contributor

Thanks — confirmed a93e567 matches the patch exactly. Nothing further from me; over to the reviewers.

This branch has not been deployed

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

Labels

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stack trace line numbers wrong after tier-1. Possible related to PGO/GDV

2 participants