Skip to content

Conversation

@eerhardt
Copy link
Member

@eerhardt eerhardt commented Jan 18, 2021

  • Remove unnecessary allocs
  • Call the Invoke method directly from generated code

Benchmark Results

Using the following benchmark:

    [MemoryDiagnoser]
    public class DispatchProxyBenchmark
    {
        [Params(1, 100)]
        public int Count { get; set; }

        private IFoo _proxy;

        [GlobalSetup]
        public void GlobalSetup()
        {
            _proxy = CountingProxy.Wrap(new Foo());
        }

        [Benchmark]
        public void Invoke()
        {
            for (int i = 0; i < Count; i++)
            {
                _proxy.Property1 = 5;
                _proxy.Method1();
                _proxy.Method2();
            }
        }

        [Benchmark]
        public void InvokeGeneric()
        {
            for (int i = 0; i < Count; i++)
            {
                _proxy.Method3<int>(5);
                _proxy.Method3<double>(4.5);
            }
        }
    }

    interface IFoo
    {
        public int Property1 { get; set; }

        public void Method1();
        public void Method2();
        public void Method3<T>(T t);
    }

    class Foo : IFoo
    {
        public int Property1 { get; set; }

        public void Method1() { }
        public void Method2() { }
        public void Method3<T>(T t) { }
    }

    class CountingProxy : DispatchProxy
    {
        private IFoo _inner;
        public int InvocationCount { get; private set; }

        public static IFoo Wrap(IFoo inner)
        {
            IFoo wrapped = Create<IFoo, CountingProxy>();
            ((CountingProxy)wrapped)._inner = inner;
            return wrapped;
        }

        protected override object Invoke(MethodInfo targetMethod, object[] args)
        {
            InvocationCount++;

            return targetMethod.Invoke(_inner, args);
        }
    }

Before

Method Count Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
Invoke 1 1.134 us 0.0256 us 0.0295 us 0.1156 - - 736 B
InvokeGeneric 1 2.276 us 0.0370 us 0.0309 us 0.2273 - - 1456 B
Invoke 100 111.460 us 0.9926 us 0.8289 us 11.5248 - - 73600 B
InvokeGeneric 100 231.935 us 6.6885 us 7.1566 us 22.3214 - - 145600 B

After

Method Count Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
Invoke 1 493.1 ns 8.76 ns 7.77 ns 0.0215 - - 136 B
InvokeGeneric 1 1,882.9 ns 51.62 ns 57.37 ns 0.1668 - - 1056 B
Invoke 100 49,365.7 ns 934.74 ns 1,000.16 ns 2.1351 - - 13600 B
InvokeGeneric 100 177,488.8 ns 3,470.60 ns 3,246.40 ns 16.3352 - - 105600 B

Which means we are saving 200 bytes per method invocation of a DispatchProxy.

Generated Code Comparison

In order to easily understand this change, here are the before and after of an example generated class:

Before

public class generatedProxy_1 : TestDispatchProxy, TypeType_GenericMethod
{
	private Action<object[]> invoke;

	public generatedProxy_1(Action<object[]> P_0)
		: this()
	{
		invoke = P_0;
	}

	public override T Echo<T>(T P_0)
	{
		object[] array = new object[1]
		{
			P_0
		};
		object[] array2 = new object[6]
		{
			this,
			typeof(TypeType_GenericMethod),
			0,
			array,
			new global::System.Type[1]
			{
				typeof(T)
			},
			null
		};
		invoke.Invoke(array2);
		return (T)array2[5];
	}
}

After

public class generatedProxy_1 : TestDispatchProxy, TypeType_GenericMethod
{
	private MethodInfo[] _methodInfos;

	public generatedProxy_1(MethodInfo[] P_0)
		: this()
	{
		_methodInfos = P_0;
	}

	public override T Echo<T>(T P_0)
	{
		object[] args = new object[1]
		{
			P_0
		};
		MethodInfo methodInfo = _methodInfos[0];
		methodInfo = methodInfo.MakeGenericMethod(typeof(T));
		object obj = ((DispatchProxy)(object)this).Invoke(methodInfo, args);
		return (T)obj;
	}
}

* Remove unnecessary allocs
* Call the Invoke method directly from generated code
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.

LGTM. Thanks

Copy link
Member

@stephentoub stephentoub left a comment

Choose a reason for hiding this comment

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

Nice.

@stephentoub stephentoub merged commit 598070c into dotnet:master Jan 19, 2021
@reflectronic
Copy link
Contributor

reflectronic commented Jan 19, 2021

Are those benchmark numbers right? Is DispatchProxy.Invoke really 400 to 700 times slower now?

@stephentoub
Copy link
Member

@reflectronic, the units changed from us to ns between the before and after.

@reflectronic
Copy link
Contributor

Oh. That is embarrassing. That makes a lot more sense now, thanks.

private static readonly ProxyAssembly s_proxyAssembly = new ProxyAssembly();
private static readonly MethodInfo s_dispatchProxyInvokeMethod = typeof(DispatchProxy).GetTypeInfo().GetDeclaredMethod("Invoke")!;
private static readonly MethodInfo s_getTypeFromHandleMethod = typeof(Type).GetRuntimeMethod("GetTypeFromHandle", new Type[] { typeof(RuntimeTypeHandle) })!;
private static readonly MethodInfo s_makeGenericMethodMethod = typeof(MethodInfo).GetMethod("MakeGenericMethod", new Type[] { typeof(Type[]) })!;
Copy link
Member

@MichalStrehovsky MichalStrehovsky Jan 19, 2021

Choose a reason for hiding this comment

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

We need to make sure reflection-accessing reflection surface area results in a trimming warning. Invoking reflection APIs with parameters that illink didn't see is not safe.

Cc @vitek-karas I filed dotnet/linker#1764.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good call.

In this case, the usage is "safe" since the type arguments all come from the user's code. So any requirements on the types will be seen by the linker there.

Here was the original suppression:

[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2060:MakeGenericMethod",
Justification = "MakeGenericMethod is safe here because the user code invoking the generic method will reference " +
"the GenericTypes being used, which will guarantee the requirements of the generic method.")]
private static void Invoke(object?[] args)
{
PackedArgs packed = new PackedArgs(args);
MethodBase method = s_proxyAssembly.ResolveMethodToken(packed.DeclaringType, packed.MethodToken);
if (method.IsGenericMethodDefinition)
method = ((MethodInfo)method).MakeGenericMethod(packed.GenericTypes!);

Copy link
Member

Choose a reason for hiding this comment

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

Sigh, maybe DispatchProxy is not trimming safe after all.

interface IFoo
{
    [return: DynamicallyAccessedMembers(PublicConstructors)]
    Type GetTheType();
}

IFoo someFoo = GetItFromDispatchProxy();
Activator.CreateInstance(someFoo.GetTheType());

If we make a DispatchProxy for this, who is going to enforce that the Type returned from the implementation of GetTheType matches the requirements?

There was a discussion whether we need to mark the interface methods as reflected up here: #46715 (comment)

I think we have to. And if we do that and once dotnet/linker#1764 is fixed, this will generate a warning because we in fact cannot statically enforce that this will hold.

Copy link
Member

Choose a reason for hiding this comment

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

Sigh, maybe DispatchProxy is not trimming safe after all.

I meant to say "not trimming safe for all possible interfaces". This would only warn if the interface has the DynamicallyAccessed annotations.

Copy link
Member

Choose a reason for hiding this comment

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

We will probably have to take this into account - see dotnet/linker#1607 (comment). Originally I though that return value annotations are not affected, since they apply to the implementation not the caller, but with TypeBuilder I can provide implementation to annotated method without linker seeing it that way.

Copy link
Member

Choose a reason for hiding this comment

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

@MichalStrehovsky that would mean that we need to mark the interface type in the proxy with All as well - so that linker would "mark as reflection" all of the interfaces and all the members on them. With the fix in dotnet/linker#1764 that would lead to generating a warning in the above case.
If we don't mark it as All, we would not be able to catch this. Ideally we would need some annotation like "consider all used methods on this type/interface as accessed via reflection" - but that's way too specific annotation to create. So All is probably the best for now.

So to answer @eerhardt question:

  • We need an issue tracking that this scenario is actually property handled (all up) - not sure if that should live in runtime or linker
  • Annotate the interface type in dispatch proxy with All - I know we originally reverted that, but given the above, I don't see a better way currently. It's probably not a big size concern anyway, since it will only increase the size of the proxy itself, which is typically very small (each method's implementation is pretty trivial).

Copy link
Member Author

Choose a reason for hiding this comment

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

Annotate the interface type in dispatch proxy with All

This is the part I don't understand. If a method on an interface is marked as DynamicallyAccessed and isn't being used, why would we need to warn about it? In the scenario above, GetTheType() is called. So I guess I don't understand how annotating the interface as All should change anything here.

Copy link
Member

@MichalStrehovsky MichalStrehovsky Jan 26, 2021

Choose a reason for hiding this comment

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

Annotating it as All will trigger a warning at the callsite to DispatchProxy's Create once dotnet/linker#1764 is addressed and the interface has any methods that have annotations. The purpose is just to trigger the warning when creating a DispatchProxy for an annotated interface. It's not about preserving stuff.

If it's not annotated and we're suppressing the warning within the method body there's no place that would warn in such situation.

Linker needs to see that the interface method is potentially reflection-accessed.

Basically, the problem is that there's a suppression on the call to TypeBuilder.AddInterfaceImplementation within the DispatchProxy code. This will never be safe to suppress if the interface is user-provided.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks a lot @MichalStrehovsky for the explanation. Another way to look at it is: Each annotation has two sides to it. The code which assigns value to "it" and the code which uses the value from "it". Linker needs to validate both sides. In the case of an annotation on a return value, the code which assigns the value is the method's body, and the code which uses the value is the caller. In this case with dispatch proxy and the interface method, the caller is known (as you mention there will be call to this method somewhere in the app's code), but the method's body is not known to linker - it will be created at runtime via TypeBuilder. So linker can only validate one side - which is not enough.

Copy link
Member

Choose a reason for hiding this comment

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

Here's a sample I used to wrap my head around this:

using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

IFooer f = DispatchProxy.Create<IFooer, MyFooer>();
Activator.CreateInstance(f.MakeFoo());

class MyFooer : DispatchProxy
{
    protected override object Invoke(MethodInfo targetMethod, object[] args)
    {
        return typeof(Unused);
    }
}

class Unused
{
}

interface IFooer
{
    [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
    Type MakeFoo();
}

Linker will not keep the ctor of Unused but there's also no warning. So I was thinking "what's the spot that should have warned". The spot was the TypeBuilder.AddInterfaceImplementation that we suppressed - linker needs to see that we reflection-access the interface methods. Propagating that annotation out to the public API surface will make sure there is a warning for this case (once the above mentioned bug is fixed), because indeed illink cannot prove that this will work.

Suppressions need a lot of scrutiny :(.

@eerhardt eerhardt deleted the DispatchProxyFixup branch January 19, 2021 15:33
@ghost ghost locked as resolved and limited conversation to collaborators Feb 25, 2021
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.

6 participants