Checked JIT assert 'treeWithCall == call' in fgMorphPotentialTailCall on a struct-returning implicit fast tail call
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
> [!NOTE]
> This issue description was drafted with the help of GitHub Copilot
The checked JIT asserts in `Compiler::fgMorphPotentialTailCall` (`src/coreclr/jit/morph.cpp`) when it forms an **implicit fast tail call** from a thin forwarder that **returns a struct** (`Memory`). The release JIT compiles the same method correctly, so this is a JIT bug rather than a program/IL problem.
It was hit while running `jit-diff` / PMI (at FullOpts) over the [`Nerdbank.Streams`](https://www.nuget.org/packages/Nerdbank.Streams) NuGet package — the base (checked-JIT) pass aborts. The offending method is `HalfDuplexStream`'s explicit interface implementation, a one-line forwarder:
```csharp
Memory IBufferWriter.GetMemory(int sizeHint) => this.pipe.Writer.GetMemory(sizeHint);
```
## Assert
```
Assertion failed 'treeWithCall == call' in
'Nerdbank.Streams.HalfDuplexStream:System.Buffers.IBufferWriter.GetMemory(int):System.Memory`1[byte]:this'
during 'Morph - Global' (IL size 18; hash 0xdcc7702d; FullOpts)
```
## Minimal repro
```csharp
using System;
using System.Buffers;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
// Mirrors Nerdbank.Streams.HalfDuplexStream's explicit interface implementation verbatim.
public sealed class HalfDuplex : IBufferWriter
{
private readonly Pipe pipe = new Pipe();
void IBufferWriter.Advance(int count) => this.pipe.Writer.Advance(count);
Memory IBufferWriter.GetMemory(int sizeHint) => this.pipe.Writer.GetMemory(sizeHint);
Span IBufferWriter.GetSpan(int sizeHint) => this.pipe.Writer.GetSpan(sizeHint);
}
public static class Program
{
[MethodImpl(MethodImplOptions.NoInlining)]
private static int CallGetMemory(IBufferWriter writer) => writer.GetMemory(1).Length;
public static int Main()
{
IBufferWriter writer = new HalfDuplex();
Console.WriteLine(CallGetMemory(writer));
return 100;
}
}
```
```bash
dotnet build -c Release -o out
# The implicit fast tail call is only formed at FullOpts, so disable tiered compilation
# (this is how jit-diff/PMI compile).
DOTNET_TieredCompilation=0 ./corerun out/repro.dll # aborts (rc=134) with the assert
DOTNET_TieredCompilation=0 DOTNET_FastTailCalls=0 ./corerun out/repro.dll # prints 4096, exits 100
```
The minimal repro produces the identical assert (same method signature, phase and IL size 18; only
the type-name-dependent hash differs):
```
Assertion failed 'treeWithCall == call' in
'HalfDuplex:System.Buffers.IBufferWriter.GetMemory(int):System.Memory`1[byte]:this'
during 'Morph - Global' (IL size 18; hash 0x271e3b05; FullOpts)
```
## Analysis
After the call is marked as a tail call, `fgMorphPotentialTailCall` has a `#ifdef DEBUG` check that expects the statement to be `GT_CALL`, `GT_RETURN(GT_CALL/GT_CAST...)`, `var = GT_CALL`, or `GT_COMMA(GT_CALL, GT_NOP)`. For this struct-returning implicit tail call the statement is instead:
```
RETURN struct
\--* COMMA struct
+--* STORE_LCL_VAR struct V05 tmp // op1: store the call result into a temp
| \--* CALL nullcheck struct System.IO.Pipelines.Pipe:GetMemory(int):System.Memory`1[byte]
\--* LCL_VAR struct V05 tmp // op2: read the temp back
```
i.e. `GT_RETURN( GT_COMMA( GT_STORE_LCL_VAR(GT_CALL), GT_LCL_VAR ) )`. The accepted COMMA form has the call as op1 with a `GT_NOP` op2; here the call is buried inside op1's `STORE_LCL_VAR` and op2 is the temp read, so `treeWithCall` (the COMMA) `!= call` and the assert fires.
The temp (`V05`) is introduced when the JIT devirtualizes + inlines the intermediate `PipeWriter.GetMemory` forwarder (`pipe -> Pipe.Writer -> DefaultPipeWriter.GetMemory -> Pipe.GetMemory`) and spills the inlinee's struct return value to a local before the outer `RETURN`.
The assert is a DEBUG-only IR-shape sanity check; release codegen handles the shape and emits a correct implicit fast tail call:
```asm
mov rdi, gword ptr [rdi+0x10] ; walk the wrapper field chain
mov rdi, gword ptr [rdi+0x10]
mov rdi, gword ptr [rdi+0x10]
tail.jmp [rax] System.IO.Pipelines.Pipe:GetMemory(int):System.Memory`1[byte]
```
> PMI / `jit-diff` only surface this with tiered compilation disabled. With default tiering the
> method is first compiled at Tier-0 (MinOpts), which does not form implicit tail calls.
## Expected behavior
The checked JIT compiles the method without asserting (matching release).
## Workaround
`DOTNET_FastTailCalls=0` avoids the bad tail-call morphing and compiles cleanly.
## Configuration
- OS: Linux, x64
- .NET 10 / .NET 11 `main` (checked CoreCLR), x64
- Only the fast tail call path is affected; only the checked/debug JIT asserts (release is a silent, correct compile).
Contributor guide
Assessment
This issue has not been assessed yet.