Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eliminate dead branches around typeof comparisons #102248

Merged
merged 4 commits into from
Jun 19, 2024

Conversation

MichalStrehovsky
Copy link
Member

RyuJIT will already do dead branch elimination for typeof(X) == typeof(Y) patterns, but we couldn't do elimination around foo == typeof(X). This fixes that using whole program knowledge - if we never saw a constructed MT for X, the comparison is not going to be true. Because it needs whole program, we still scan this dead branch so in the end this doesn't save much. We can eventually do better.

I'm doing this in SubstitutedILProvider instead of in RyuJIT: this is because we currently only reap a small benefit from this optimization due to it only happening during compilation phase. We need to do this during scanning as well. I think I can extend it to scannig. But the extension will require the optimization to 100% guaranteed happen during codegen. We cannot rely on whether RyuJIT will feel like it. SubstitutedILProvider is our way to ensure the optimization will happen no matter what - the IL from the branch will be gone and RyuJIT can at most remove the comparison (we don't mind much if it's left).

Cc @dotnet/ilc-contrib

RyuJIT will already do dead branch elimination for `typeof(X) == typeof(Y)` patterns, but we couldn't do elimination around `foo == typeof(X)`. This fixes that using whole program knowledge - if we never saw a constructed `MT` for `X`, the comparison is not going to be true. Because it needs whole program, we still scan this dead branch so in the end this doesn't save much. We can eventually do better.

I'm doing this in `SubstitutedILProvider` instead of in RyuJIT: this is because we currently only reap a small benefit from this optimization due to it only happening during compilation phase. We need to do this during scanning as well. I think I can extend it to scannig. But the extension will require the optimization to 100% guaranteed happen during codegen. We cannot rely on whether RyuJIT will feel like it. `SubstitutedILProvider` is our way to ensure the optimization will happen no matter what - the IL from the branch will be gone and RyuJIT can at most remove the comparison (we don't mind much if it's left).
Copy link
Contributor

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

@MichalStrehovsky
Copy link
Member Author

/azp run runtime-nativeaot-outerloop

Copy link

Azure Pipelines successfully started running 1 pipeline(s).


// We don't actually mind if this is not Object.GetType
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is an arbitrary call, can it return a type that happens to be equal to the other type?

Or is the idea that this case will fail the CanReferenceConstructedTypeOrCanonicalFormOfType check below? Ie the other argument can be anything. We are just skipping the specific common patterns here to keep things simple.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should be okay with any value loaded from a local or parameter. So also any value a method call could return.

We just don't have facilities to accept any value, so only a couple recognized patterns are allowed. Allowing any instance method call is less work than also checking if it's object.GetType.

if (knownType.IsCanonicalDefinitionType(CanonicalFormKind.Any))
return false;

if (_devirtualizationManager.CanReferenceConstructedTypeOrCanonicalFormOfType(knownType))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to call convert ConvertToCanonForm before calling CanReferenceConstructedTypeOrCanonicalFormOfType? Or is the type guaranteed to be normalized somehow?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should convert to canon. Good catch.

@jkotas
Copy link
Member

jkotas commented May 17, 2024

I have run this under debugger on this simple test:

using System;

static class Program
{
    static void Main(string[] args)
    {
        if (typeof(MyType) == args.GetType())
            Console.WriteLine(42);
    }
}

static class MyType
{
}

I would expect the substitution to trigger for it, but it is not happening. It never hits breakpoint at this line https://github.com/dotnet/runtime/pull/102248/files#diff-7c5a8ad684ce4e7f583b3bc392219bb15fc17e8400b172a2ae32d5301b7cdd0bR1027 . Is that expected?

@MichalStrehovsky
Copy link
Member Author

It never hits breakpoint at this line https://github.com/dotnet/runtime/pull/102248/files#diff-7c5a8ad684ce4e7f583b3bc392219bb15fc17e8400b172a2ae32d5301b7cdd0bR1027 . Is that expected?

Yes, you need to flip them to the more common pattern.

The problem is in the IL scanner. IL scanner only does the "downgrade result of typeof to necessary MethodTable" for a limited set of IL patterns as well and this one is not it. So we end up with "constructed MethodTable is needed" in the scanning phase, and this can no longer get optimized away.

// We expect pattern:
//
// ldtoken Foo
// call GetTypeFromHandle
// ldtoken Bar
// call GetTypeFromHandle
// call Equals
//
// We check for both ldtoken cases
if ((ILOpcode)_ilBytes[_currentOffset + 5] == ILOpcode.call)
{
methodToken = ReadILTokenAt(_currentOffset + 6);
method = (MethodDesc)_methodIL.GetObject(methodToken);
isTypeEquals = IsTypeEquals(method);
}
else if ((ILOpcode)_ilBytes[_currentOffset + 5] == ILOpcode.ldtoken
&& _basicBlocks[_currentOffset + 10] == null
&& (ILOpcode)_ilBytes[_currentOffset + 10] == ILOpcode.call
&& methodToken == ReadILTokenAt(_currentOffset + 11)
&& _basicBlocks[_currentOffset + 15] == null
&& (ILOpcode)_ilBytes[_currentOffset + 15] == ILOpcode.call)
{
methodToken = ReadILTokenAt(_currentOffset + 16);
method = (MethodDesc)_methodIL.GetObject(methodToken);
isTypeEquals = IsTypeEquals(method);
}

We really need some better facilities to analyze IL in C#, but also I don't know if I want us to build a "proper" IL importer in C#.

@MichalStrehovsky
Copy link
Member Author

(I plan to look into at least sharing this code between scanner and substitutions in some way.)

@jkotas
Copy link
Member

jkotas commented May 17, 2024

Could you please share a program that hits this line https://github.com/dotnet/runtime/pull/102248/files#diff-7c5a8ad684ce4e7f583b3bc392219bb15fc17e8400b172a2ae32d5301b7cdd0bR1027 in the compiler? Or is this WIP and this path is not reachable yet?

@MichalStrehovsky
Copy link
Member Author

Could you please share a program that hits this line https://github.com/dotnet/runtime/pull/102248/files#diff-7c5a8ad684ce4e7f583b3bc392219bb15fc17e8400b172a2ae32d5301b7cdd0bR1027 in the compiler? Or is this WIP and this path is not reachable yet?

It's the tests that are part of this PR. We also have hits in corelib, for example:

if (attributeType == typeof(DecimalConstantAttribute))
{
return GetRawDecimalConstant(attributeData);
}
else if (attributeType.IsSubclassOf(typeof(CustomConstantAttribute)))
{
if (attributeType == typeof(DateTimeConstantAttribute))
{
return GetRawDateTimeConstant(attributeData);
}
return GetRawConstant(attributeData);
}

(The above will also be a real saving once we can do this optimization in the scanner - this is the only places that boxes DateTime and Decimal and that's a 100 kB saving on an app that uses reflection. It doesn't kick in right now, because the scanner will see we box DateTime/decimal and that destroys our opportunity to get rid of it because DateTime/decimal is referenced in typeof comparisons in other spots.)

@jkotas
Copy link
Member

jkotas commented May 17, 2024

It's the tests that are part of this PR.

I have extracted the test into a small program:

using System;
using System.Runtime.CompilerServices;

static class Program
{
    static void Main(string[] args)
    {
        Type someType = GetTheType();
        if (someType == typeof(Never3))
        {
            Console.WriteLine(42);
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static Type GetTheType() => null;
}

class Never3
{
}

I have compiled the test in release mode (the test is under #if !DEBUG). Roslyn optimized out the someType local variable and the IL looks like this:

    IL_0000:  call       class [System.Runtime]System.Type Program::GetTheType()
    IL_0005:  ldtoken    MyType
    IL_000a:  call       class [System.Runtime]System.Type [System.Runtime]System.Type::GetTypeFromHandle(valuetype [System.Runtime]System.RuntimeTypeHandle)
    IL_000f:  call       bool [System.Runtime]System.Type::op_Equality(class [System.Runtime]System.Type,
                                                                       class [System.Runtime]System.Type)

It fails the pattern match in TryExpandTypeEquality_TokenOther very early since the ldloc that the pattern match is looking for is gone. What am I missing?

@MichalStrehovsky
Copy link
Member Author

Weird, I don't know how the test would pass without it. I've submitted #102374 with just the test because I don't want to switch branches locally right now.

@MichalStrehovsky
Copy link
Member Author

Weird, I don't know how the test would pass without it. I've submitted #102374 with just the test because I don't want to switch branches locally right now.

The tests are all failing in #102374 so the optimization here works. I agree that for the local case this is pretty fragile. This is another case where the expectation is that this will mostly come from a parameter in real world code. Loading it from a local was just equally cheap in the pattern match so I just allowed it. But parameter is the main use case.

@jkotas
Copy link
Member

jkotas commented May 17, 2024

I have figured out one of the mysteries:

The dotnet/runtime build sets DebugSymbols property to true globally. DebugSymbols does not actually do what its name suggests. The (portable) symbols are generated regardless of whether this property is true or false. What this property actually does is that it disables C# peephole IL optimizations. The C# peephole IL optimizations break the IL patterns used by the tests added in this PR. Setting the DebugSymbols to false makes the tests fail as demonstrated by #102391 . It would be nice to fix the pattern match and/or the test to work with DebugSymbols set to false.

The ordinary user projects out there do not set DebugSymbols property. I have done my quick ad-hoc test using an ordinary project and it is why it did not work for me. I will look into deleting the DebugSymbols setting so that we build and test our bits using the same settings as our users.

@jkotas
Copy link
Member

jkotas commented May 18, 2024

Yes, you need to flip them to the more common pattern.

Ok, this was the other part of the mystery. if (t == typeof(Never)) works as expected, if (typeof(Never) == t) does not work as expected. The code added in this PR handles it, but the pre-existing ldtoken handling in the scanner does not as you have pointed out.

{
Debug.Assert(type.NormalizeInstantiation() == type);
Debug.Assert(ConstructedEETypeNode.CreationAllowed(type));
return _constructedMethodTables.Contains(type);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also assert that we are only adding normalizations into _constructedMethodTables when it is populated?

Copy link
Member

@jkotas jkotas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

@MichalStrehovsky MichalStrehovsky merged commit e0bd776 into dotnet:main Jun 19, 2024
91 of 93 checks passed
@MichalStrehovsky MichalStrehovsky deleted the deadtypeofbranches branch June 19, 2024 14:21
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 19, 2024
This fixes the problem discussed at dotnet#102248 (comment). Now we call into the same code from both substitutions and scanner.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 24, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in dotnet#102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 28, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in dotnet#102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 28, 2024
This fixes the problem discussed at dotnet#102248 (comment). Now we call into the same code from both substitutions and scanner.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 28, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in dotnet#102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 28, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in dotnet#102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jun 28, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in dotnet#102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
MichalStrehovsky added a commit that referenced this pull request Jul 1, 2024
This fixes the problem discussed at #102248 (comment). Now we call into the same code from both substitutions and scanner.
MichalStrehovsky added a commit to MichalStrehovsky/runtime that referenced this pull request Jul 1, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in dotnet#102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
MichalStrehovsky added a commit that referenced this pull request Jul 18, 2024
Before this PR, we were somewhat able to eliminate dead typeof checks such as:

```csharp
if (someType == typeof(Foo)
{
    ExpensiveMethod();
}
```

This work was done in #102248.

However, the optimization only happened during codegen. This meant that when building the whole program view, we'd still look at `ExpensiveMethod` and whatever damage this caused to the whole program view was permanent.

With this PR, the scanner now becomes aware of the optimization we do during codegen and tries to defer injecting dependencies until we will need them.

With this change, we detect the conditional branch, and generate whatever dependencies from the basic block as conditional. That way scanning can fully skip scanning `ExpensiveMethod` and the subsequent optimization will ensure the missed scanning will not cause issues at codegen time.
@github-actions github-actions bot locked and limited conversation to collaborators Jul 20, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants