dotnet / dotnet/runtime

Checked JIT assert 'treeWithCall == call' in fgMorphPotentialTailCall on a struct-returning implicit fast tail call

Open
#129,648 2 comments 0 reactions 1 assignee Claimed by @jakobbotsch View on GitHub
area-CodeGen-coreclr
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.