Repository navigation
JIT: optimizations for multi-use boxes #9118
Description
A fairly common pattern (especially after inlining) is to see a box that feeds an isinst and if that succeeds, an unbox.any. For example:
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
internal class ObjectEqualityComparer<T> : EqualityComparer<T>
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Equals(T x, T y)
{
if (x != null)
{
if (y != null) return x.Equals(y);
return false;
}
if (y != null) return false;
return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override int GetHashCode(T obj) => obj?.GetHashCode() ?? 0;
// Equals method for the comparer itself.
public override bool Equals(Object obj) =>
obj != null && GetType() == obj.GetType();
public override int GetHashCode() =>
GetType().GetHashCode();
}
class C
{
public static int Main()
{
var comp = new ObjectEqualityComparer<int>();
bool result = comp.Equals(3, 4);
return result ? 0 : 100;
}
}We get pretty far when optimizing Main here -- we can devirtualize the call to Equals, inline it and remove the null checks since we have a value type, then inline the inner call to Equals. But along the way we have to box y and the inner Equals has the following IL:
IL_0000 03 ldarg.1
IL_0001 75 f1 00 00 02 isinst 0x20000F1
IL_0006 2d 02 brtrue.s 2 (IL_000a)
IL_0008 16 ldc.i4.0
IL_0009 2a ret
IL_000a 02 ldarg.0
IL_000b 4a ldind.i4
IL_000c 03 ldarg.1
IL_000d a5 f1 00 00 02 unbox.any 0x20000F1
IL_0012 fe 01 ceq
IL_0014 2a ret With the advent of dotnet/coreclr#14420 the jit will now optimize away the isinst, but the box cleanup opts for unbox.any don't fire because there is usually a temp in the way, and so we generate the following code for Main:
G_M4930_IG01:
57 push rdi
56 push rsi
4883EC28 sub rsp, 40
G_M4930_IG02:
; ** BOX (y) **
48B9086014E2FA7F0000 mov rcx, 0x7FFAE2146008
E8BB3C835F call CORINFO_HELP_NEWSFAST
C7400804000000 mov dword ptr [rax+8], 4
488BF0 mov rsi, rax
4885F6 test rsi, rsi ; gratuitous null check ?
7504 jne SHORT G_M4930_IG03
33FF xor edi, edi
EB2D jmp SHORT G_M4930_IG05
G_M4930_IG03:
; * UNBOX.ANY type check
48BA086014E2FA7F0000 mov rdx, 0x7FFAE2146008
483916 cmp qword ptr [rsi], rdx
7412 je SHORT G_M4930_IG04
; * call helper if type check fails (which it won't)
488BD6 mov rdx, rsi
48B9086014E2FA7F0000 mov rcx, 0x7FFAE2146008
E8470B395F call CORINFO_HELP_UNBOX
G_M4930_IG04:
837E0803 cmp dword ptr [rsi+8], 3
400F94C7 sete dil
400FB6FF movzx rdi, dil
G_M4930_IG05:
85FF test edi, edi
750C jne SHORT G_M4930_IG07
B864000000 mov eax, 100
G_M4930_IG06:
4883C428 add rsp, 40
5E pop rsi
5F pop rdi
C3 ret
G_M4930_IG07:
33C0 xor eax, eax
G_M4930_IG08:
4883C428 add rsp, 40
5E pop rsi
5F pop rdi
C3 retIf when optimizing a successful cast we copy the result to a new more strongly typed temp (see #9117) we might be able to optimize away the type equality check in the downstream unbox.any (see dotnet/coreclr#14473). And perhaps if we are lucky and the box is simple we might be able to propagate the value to be boxed through the box/unbox to the ultimate use, and so not need the unbox. But the box would remain as it is difficult to remove unless it is known to be dead and whatever transformation makes it dead explicitly cleans it up.
A couple of ways we could approach this:
- The optimizer should be able to reason about and propagate boxes and perhaps trigger the box/unbox.any peephole, and turn the result into a simple copy.
BOXis just an expression "wrapper" in the spirit of JIT: some ideas on high-level representation of runtime operations in IR #9056. So we could allow the inliner to giveBOX(y)the same treatment asyand duplicate it within the inlinee body (essentially, generalize the logic inimpInlineFetchArgthat begins withelse if (argInfo.argIsLclVar && !argCanBeModified)to also apply toBOX(y)). If we added suitable "reference counting" to boxes to track the duplicates then optimizing away the last use of the box could trigger the box cleanup. We have this today but the reference count is implicit and always = 1 since we don't duplicate the boxed values.
If all this kicked in, the code for Main above would collapse to simply returning a constant.
category:cq
theme:importer
skill-level:expert
cost:medium
Activity
AndyAyersMS commentedon Oct 27, 2017
Considering for 2.1.
mikedn commentedon Jan 1, 2018
Yes. Can be removed by proper value numbering of
GT_BOX. See dotnet/coreclr#15666.I think that the subsequent unbox can be removed too - we need a proper VN for
IND(JIT_New)and we should be able to removeCORINFO_HELP_UNBOXinoptAssertionProp_Call.But that still leaves us with the box:
It would be nice if the optimizer could handle this but I don't think there's any mechanism in the JIT that would allow the elimination of useless stores. So even with the fixes I mentioned above it would still be preferable to get rid of the box sooner.
Another related example:
Testgenerates:The null check can be eliminated but we're again stuck with the box. And this time it's completely useless, the code doesn't even attempt to read from the allocated memory.
It's not clear to me why the importer is so eager to expand
box. It seems that it would be easier if the importer generates a singleGT_BOXnode that's then expanded during global morph.AndyAyersMS commentedon Jan 23, 2018
Expansion of some runtime constructs like
boxcan cause inlining to fail. So this creates tension in the jit: expanding too early reveals ugly details that inhibit or complicates high-level optimizations; expanding too late means the jit can't easily undo inlines that are now invalid.Hence the wrapper idea from #9056 where we try and have our cake and eat it too. We at least need to do enough sanity checking in importation that we can spot the cases where inlining will fail.
AndyAyersMS commentedon Jan 24, 2018
Looked a bit at the reference counting idea. For instance
genTreeBoxnodes that share the same assign and copy could be linked in a circular list that is updated bygtCloneand by deletion of box nodes.Having IR cross links is obviously questionable but we already have these in
genTreeBoxand are potentially skating on thin ice because we don't have any ref counting.At any rate, assuming we can somehow track how many uses there are,
gtTryRemoveBoxUpstreamEffectsshould probably fail if there is more than one use. However, callers cannot tell why this method fails. Failing because the box is multi-use may still allow the optimization the caller has in mind to go forward, then allow them to remove their use of the box, and then potentially allow full cleanup to be triggered elsewhere. Hmm.AndyAyersMS commentedon Jan 30, 2018
In the RGB example the jit doesn't try optimizing the box early as it is in a return tree. The importer currently only calls
gtFoldExpron trees feeding conditional branches.We could presumably invoke the expression folder more often during importation and catch more cases. I suppose the question then is "how much more often" -- likely whether the size reduction wins counteract the extra cost of searching for foldable patterns. I'll play around with this and see how likely more eager folding is to lead to changes.
We also try to fold the box tree again during morph but the struct assignment to copy the box payload has been expanded into an exception-flagged comma laden tree that the box cleanup utility does not expect, so it bails out.
In this case the exception flags on the morphed copy tree are bogus as we're copying from a local struct to the box payload. The copy tree doesn't have an exception flag initially but
fgMorphCopyBlockthrows one in for some reason.AndyAyersMS commentedon Jan 30, 2018
Looked at the flags issue a bit without gaining much insight. The GTF_EXCEPT flag is flipped on for GT_BLK during morph regardless of whether it was set before. So any upstream knowledge about potentially safe memory operations is seemingly just tossed out. And if we don't set the flag then
fgDebugCheckFlagsgets cranky.mikedn commentedon Jan 30, 2018
Aha! So the trick I used for
string.IsNullOrEmptyworks here as well but for completely different reasons:generates
Funny.
mikedn commentedon Jan 30, 2018
Or in this
List<T>code where I extracted the example from:https://github.com/dotnet/coreclr/blob/8a9c59260b8cc04d266e021229f4b044bf40ad52/src/mscorlib/shared/System/Collections/Generic/List.cs#L193-L198
generates
While not ideal at least it no longer does boxing.
AndyAyersMS commentedon Jan 30, 2018
Seems like importer folding at relational ops is pretty cheap and effective. Will send a PR.
JIT: add a bit more importer folding
AndyAyersMS commentedon Jan 31, 2018
Think the more general opt needs more conceptual bake time, so moving out of 2.1.
JIT: add a bit more importer folding (#16111)
AndyAyersMS commentedon May 4, 2018
Though I like this proposal I'm still not quite sure how to handle the case where there are multiple uses and the uses have different demands on the box tree. Say for some uses are type tests (and so just want the type handle) and some others are feeding an unboxed entry point (and so just want a local copy of the payload) and some others feed a null test.
Say we refcount and there are N uses. Until we understand all those use contexts we can't decide what shape the box tree should take on. So in addition to the reference count we need to track the kinds of references (basically the
BR_xxxvalues that the uses would request if they were to optimize). And if any context needs the full box, then we can only satisfy the non-destructive uses (type tests and null tests).Also since optimizations like the
Enum.HasFlagcouple the fates of two boxes together, box optimizations can potentially be mutually dependent. Though perhaps we can ignore this dependence as the current cases like this are single-use and so can just be handled directly.zpodlovics commentedon May 30, 2018
@AndyAyersMS Is it possible to also recognize + optimize box/unbox sequence? It seems this IL sequence is common in F#.
dotnet/fsharp#5019 (comment)
AndyAyersMS commentedon May 30, 2018
Adjacent
box / unbox.anyis handled via a peephole optimization in the jit importer (look at importer.cpp around line 14890).If there is IL in between or a
dupof the box the jit may not optimize.5 remaining items
AndyAyersMS commentedon Apr 14, 2024
Looks like a longshot for .NET 9 now too, so moving to future.
Stack allocate unescaped boxes (#103361)
AndyAyersMS commentedon Jul 1, 2024
Fixed by #103361