dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
14.97k stars 4.66k forks source link

Developers using reflection invoke should be able to use ref struct #45152

Open steveharter opened 3 years ago

steveharter commented 3 years ago

Support ref struct and "fast invoke" by adding new reflection APIs. The new APIs will be faster than the current object[] boxing approach by leveraging the existing System.TypedReference<T>.

TypedReference is a special type and is super-fast because since it is a ref struct with its own opcodes. By extending it with this feature, it provides alloc-free, stack-based “boxing” with support for all argument types (reference types, value types, pointers and ref structs [pending]) along with all modifiers (byval, in, out, ref, ref return). Currently reflection does not support passing or invoking a ref struct since it can’t be boxed to object; the new APIs are to support ref struct with new language features currently being investigated.

Example syntax (actual TBD):

MyClass obj= …; 
MethodInfo methodInfo = …;

int param1 = 42; // Pass an integer
Span<int> param2 = new Span<int>(new int[]{1, 2, 3}); // Pass a span (not possible with reflection today)

// Adding support for Span<TypedReference> requires the language and runtime asks below.
Span<TypedReference> parameters = stackalloc TypedReference[2];

// The 'byval', 'in', 'out' and 'ref' modifiers in the callee method's parameters all have the same caller syntax
parameters[0] = TypedReference.FromRef(ref param1);
parameters[1] = TypedReference.FromRef(ref param2);
methodInfo.GetInvoker().Invoke(returnValue: default, target: TypedReference.FromRef(ref obj), parameters);  
// The first call to this methodInfo will be slower; subsequent calls used cached IL when possible

// Shorter syntax using __makeref (C# specific)
parameters[0] = __makeref(param1);
parameters[1] = __makeref(param2);
methodInfo.Invoke(default, __makeref(obj), parameters));

Dependencies

The Roslyn and runtime dependencies below are required for the programming model above. These are listed in the order in which they need to be implemented.

In Scope 

APIs to invoke methods using TypedReference including passing a TypedReference collection. TypedReference must be treated as a normal ref struct (today it has nuances and special cases).

Support ref struct (passing and invoking).

Performance on par with existing ref emit scenarios:

To scope this feature, the minimum functionality that results in a win by allowing System.Text.Json to remove its dependency to System.Reflection.Emit for inbox scenarios.

Out of Scope 

This issue is an incremental improvement of reflection by adding new Invoke APIs and leveraging the existing TypedReference while requiring some runtime\Roslyn changes. Longer-term we should consider a more holistic runtime and Roslin support for reflection including JIT intrinsics and\or new "dynamic invoke" opcodes for performance along with perhaps C# auto-stack-boxing to\from a ref TypedReference.

Implementation

A design doc is forthcoming.

The implementation will likely cache the generated method on the corresponding MethodBase and MemberInfo objects.

100% backwards compat with the existing object[]-based Invoke APIs is not necessary but will be designed with laying in mind (e.g. parameter validation, special types like ReflectionPointer, the Binder pattern, CultureInfo for culture-aware methods) so that in theory the existing object[]-based Invoke APIs could layer on this new work.

This issue supersedes other reflection performance issues that overlap:

MichalPetryka commented 10 months ago

I feel like adding a property called Offset on RuntimeFieldHandle would make more sense here, the issue is though that the language doesn't expose fieldof today.

Actually, we could add UnsafeAccessor for Field and Method handles then instead to solve the lack of language features for fieldof and methodof and just expose the Offset as an intrinsic.

Sergio0694 commented 10 months ago

We were talking the other day about how IL allows overloading fields, and hence how you couldn't have an unsafe accessor returning eg. a ref readonly T to a type T having a base conversion from TField, because then you wouldn't be able to disambiguate which field exactly you're looking for. If you had a field accessor just returning a RuntimeFieldHandle, wouldn't you hit the same problem if multiple fields of different types with the same name were present in IL? 🤔

MichalPetryka commented 10 months ago

We were talking the other day about how IL allows overloading fields, and hence how you couldn't have an unsafe accessor returning eg. a ref readonly T to a type T having a base conversion from TField, because then you wouldn't be able to disambiguate which field exactly you're looking for. If you had a field accessor just returning a RuntimeFieldHandle, wouldn't you hit the same problem if multiple fields of different types with the same name were present in IL? 🤔

It could be solved by adding a fake parameter for return type just like there is one for declaring type.

jkotas commented 10 months ago

Shouldn't RuntimeHelpers.GetRawData be made public alongside the addition of UnsafeAccessorKind.FieldOffset? Otherwise there is no safe way to get the base reference for an arbitrary object. You'd have no way to actually use the offset in that scenario...

Yes, this would need to be thought through if the raw offsets are enabled for classes, and not just structs. Another problem with classes is that field offset is not always available. https://github.com/dotnet/runtime/issues/28001 has related discussion.

xoofx commented 10 months ago

Shouldn't RuntimeHelpers.GetRawData be made public alongside the addition of UnsafeAccessorKind.FieldOffset? Otherwise there is no safe way to get the base reference for an arbitrary object. You'd have no way to actually use the offset in that scenario...

Yes, this would need to be thought through if the raw offsets are enabled for classes, and not just structs. Another problem with classes is that field offset is not always available. #28001 has related discussion.

Couldn't we add instead a new intrinsic to Unsafe class that would make the usage of the offset quite more straightforward:

public static ref T AddByteOffset<T>(object obj, nint offset);
// ldarg.0, ldarg.1, add, ret
hamarb123 commented 10 months ago
public static ref T AddByteOffset<T>(object obj, nint offset);
// ldarg.0, ldarg.1, add, ret

I would think it couldn't be implemented like that, since it's invalid IL, but if there was a way to get the reference to field 0 from and object, and we did that after ldarg.0, it should work.

If we got this API (which I personally would like to get), it would be great if we also got the corresponding inverse API (should both of these be nuint? I think so personally, but it doesn't really matter to me - just makes sense since negative doesn't make sense for either of these):

public static object? GetObject<T>(ref T reference, out nuint offset);
xoofx commented 10 months ago

I would think it couldn't be implemented like that, since it's invalid IL,

Just tested and calling the following code is working with .NET 7 JIT, but I haven't checked the specs in a while for such operation. One unknown I have with CoreCLR JIT/GC is what is seen after the add: is it an object ref, or is it a ref T. If it is the former, that could create GC corruption.

  .method public hidebysig static !!T& AddByteOffset<T>(object source, native int byteOffset) cil managed aggressiveinlining
  {
        .custom instance void System.Runtime.Versioning.NonVersionableAttribute::.ctor() = ( 01 00 00 00 )
        .maxstack 2
        ldarg.0
        ldarg.1
        add
        ret
  } // end of method Unsafe::AddByteOffset
Sergio0694 commented 10 months ago

"One unknown I have with CoreCLR JIT/GC is what is seen after the add: is it an object ref, or is it a ref T."

As far as I know, the type you declare in C#/IL doesn't matter. As far as the GC is concerned, that is simply "some GC pointer". In this case, it'll fall inside the data of an object, hence it's an interior pointer, so during mark the GC will consider the entire object as reachable, that's it. Shouldn't really cause any problems. In fact, reinterpreting the type of interior pointers (or GC refs in general) is quite common for various reasons.

xoofx commented 10 months ago

Couldn't we add instead a new intrinsic to Unsafe class that would make the usage of the offset quite more straightforward

An additional benefit of providing ref T Unsafe.AddByteOffset<T>(object obj, nint offset); is that it makes it relatively similar when dealing with struct field offsetting, difference being that the struct field offsetting would require an additional ref cast.

jkotas commented 10 months ago

Just tested and calling the following code is working with .NET 7 JIT, but I haven't checked the specs in a while for such operation.

This is invalid IL. If you run it on checked JIT, you will see all sorts of asserts. The first one is:

Assertion failed 'genActualType(op1->TypeGet()) != TYP_REF && genActualType(op2->TypeGet()) != TYP_REF : Possibly bad IL with CEE_add at offset 0002h (op1=ref op2=long stkDepth=0)' in 'Test:AddByteOffset[ubyte](System.Object,long):byref' during 'Importation' (IL size 4; hash 0xcdd16edf; Tier0)

It is likely that that the JIT can either crash or produce bad code when this gets method inlined into a callsite of a particular shape.

xoofx commented 10 months ago

This is invalid IL. If you run it on checked JIT, you will see all sorts of asserts. The first one is:

Fair enough. We definitely don't want invalid IL 😅

So, exposing RuntimeHelpers.GetRawData would be the way and it would require to shift the offset with the method table in the UnsafeAccessor code in my PR, or could we provide a ref byte Unsafe.AsByteRef(object) { ldarg.0; ret; }?

I'm ok with RuntimeHelpers.GetRawData if it is the preferred way.

hamarb123 commented 10 months ago

My opinion is that the following set of APIs makes the most sense:

namespace System.Runtime.CompilerServices
{
    public static class RuntimeHelpers
    {
        public static ref byte GetRawData(object? o);
        public static object? GetObject(ref byte reference, out nuint offset);
    }
}

This provides the forward and reverse API to convert between byrefs and objects.

The lack of <T> reduces the number of generic instantiations we will get (if that still matters on some platforms? I can't recall).

Note object?: since we will need to check null anyway, I think it makes sense to check for null and give a null byref for GetRawData, instead of throwing, since it should be able to emit better code for that, and for GetObject it makes sense since reference could point to unmanaged/stack memory (in which case offset would equal the pointer).

When the documentation is written for these pair of APIs, we will need to consider their behaviour with strings and arrays - currently they would probably return a ref to the start of the length field - if this is how we want it to work (which probably makes sense), then we should document the offset from the length field to the first entry in these cases so people can use it correctly for these OR we could document that it's undefined to use these APIs and then try to determine/select what specific index it's at (this would allow us to change them to be 64 bit length in the future if needed), which could make sense since there are other APIs for working with these special cases in the "intended" way when indexing is desired.

steveharter commented 10 months ago

Linking Roslyn issue https://github.com/dotnet/roslyn/issues/68000 where you can't get an offset of a ref field.

steveharter commented 10 months ago

We now have the PR at https://github.com/dotnet/runtime/pull/93946 which adds UnsafeAccessorKind.FieldOffset (API issue pending).

But we need a champion to create the API issue for the RuntimeHelpers.GetRawData() and .GetObject() proposal above so we can move the discussion there.

MichalStrehovsky commented 10 months ago

But we need a champion to create the API issue for the RuntimeHelpers.GetRawData()

I simply reopened #28001 since it already has a bunch of discussion and it's exactly that proposal. I cleared the milestone and marked untriaged.