JIT: head/tail merge can collapse distinct cast helper calls, losing PGO type profile
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
`GenTreeCall::Equals` does not compare `gtCastHelperILOffset` for helper calls. As a result, `fgHeadTailMerge` can merge two structurally-identical CORINFO_HELP_CHKCASTCLASS (or CORINFO_HELP_ISINSTANCEOF*) calls in different return blocks, even when they originate from distinct IL offsets with distinct dynamic class profiles. The merged call retains only one IL offset, so cast helper expansion (pickGDV in helperexpansion.cpp) consults only one site's class profile. Calls whose runtime class matches the other site's profile take the slow helper path instead of the inlined GDV fast path.
This is the same family of bug as #128631 (which was for VSD gtStubCallStubAddr), but for cast helpers.
Repro
```C#
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
public class Foo { public virtual int Tag => 0; }
public class Bar : Foo { public override int Tag => 1; }
public class Baz : Foo { public override int Tag => 2; }
public class Bench
{
[MethodImpl(MethodImplOptions.NoInlining)]
public static Foo Dispatch(int tag, object o)
{
// Side effect prevents Roslyn from deduping the two casts at IL level.
if (tag == 0) { GC.KeepAlive(o); return (Foo)o; }
return (Foo)o;
}
public static int Main()
{
var bar = new Bar();
var baz = new Baz();
long sum = 0;
for (int i = 0; i < 200_000; i++)
{
sum += Dispatch(0, bar).Tag;
sum += Dispatch(1, baz).Tag;
}
System.Threading.Thread.Sleep(500);
const int N = 200_000_000;
var sw = Stopwatch.StartNew();
for (int i = 0; i < N; i++)
{
Foo f1 = Dispatch(0, bar);
Foo f2 = Dispatch(1, baz);
sum += f1.Tag + f2.Tag;
}
sw.Stop();
Console.WriteLine($"sum={sum} elapsed={sw.Elapsed.TotalMilliseconds:F1}ms per-iter={sw.Elapsed.TotalNanoseconds / (double)N:F2}ns");
return 100;
}
}
```
The IL contains two castclass instructions (offsets 0x00A and 0x011). JitDump=Dispatch shows the merge firing:
```
*************** Starting PHASE Head and tail merge
A set of 2 return blocks end with the same tree
... CALL help ref CORINFO_HELP_CHKCASTCLASS (100% likely 'Bar')
Will cross-jump to BB03
unlinking ... from BB02
```
After merge, only BB03's IL offset survives; the GDV expansion fast-paths only Bar. The tag=1 path (which only ever sees Baz) loses its fast path.
Measurement (Windows x64, Checked clrjit)
┌────────────────────────────────────────────┬─────────────────────────┐
│ JIT │ Median ns/iter (5 runs) │
├────────────────────────────────────────────┼─────────────────────────┤
│ Current (merges casts) │ 12.3 │
├────────────────────────────────────────────┼─────────────────────────┤
│ Local fix gating on gtCastHelperILOffset │ 9.9 │
└────────────────────────────────────────────┴─────────────────────────┘
≈20% regression caused by the merge in this workload.
Suggested fix
```
In GenTreeCall::Equals, after the existing helper checks, add:
if (c1->IsHelperCall() && (c1->gtCastHelperILOffset != c2->gtCastHelperILOffset))
{
return false;
}
```
(Or scope more narrowly to the actual cast helpers.) Equivalent to the fix in #128654, but for the cast-helper case.
Notes
- Reachability is gated by Roslyn's IL-level dedup: most natural source patterns produce only one castclass IL instruction over a given local, so head/tail merge can't trigger. A side effect (or other control-flow shape) between the two casts is enough to defeat the dedup.
- Related fields in the same union (gtStubCallStubAddr already covered by #128654; gtInitClsHnd is also carried as an argument so it's caught by the existing args-equality loop).
Contributor guide
Assessment
This issue has not been assessed yet.