C
C#15mo ago
Bambosa

❔ How can I provide a safe zero cost abstraction over passing managed function pointers over FFI

I have the following:
public sealed unsafe partial class Test : IDisposable
{
public delegate void IntVoid(int _);
public static void Callback(int number, IntVoid fn) => callback(number, (delegate* managed<int, void>)Marshal.GetFunctionPointerForDelegate(fn));
public static void Callback2(int number, IntVoid fn) => callback(number, fn);

public static void Callback(int number, delegate* managed<int, void> fn) => callback(number, fn);
[LibraryImport(Native.Unnamed)] private static partial void callback(int number, delegate* managed<int, void> fn);
[LibraryImport(Native.Unnamed)] private static partial void callback(int number, IntVoid fn);
}
public sealed unsafe partial class Test : IDisposable
{
public delegate void IntVoid(int _);
public static void Callback(int number, IntVoid fn) => callback(number, (delegate* managed<int, void>)Marshal.GetFunctionPointerForDelegate(fn));
public static void Callback2(int number, IntVoid fn) => callback(number, fn);

public static void Callback(int number, delegate* managed<int, void> fn) => callback(number, fn);
[LibraryImport(Native.Unnamed)] private static partial void callback(int number, delegate* managed<int, void> fn);
[LibraryImport(Native.Unnamed)] private static partial void callback(int number, IntVoid fn);
}
Benchmark:
| Method | Iterations | Mean | Error | StdDev | Allocated |
|--------------- |----------- |--------------:|-------------:|-------------:|----------:|
| CallbackSafe | 10 | 286.22 ns | 3.481 ns | 2.907 ns | - |
| CallbackSafe2 | 10 | 308.83 ns | 1.978 ns | 1.850 ns | - |
| CallbackUnsafe | 10 | 29.99 ns | 0.624 ns | 0.833 ns | - |
| CallbackSafe | 100 | 2,897.19 ns | 46.334 ns | 43.341 ns | - |
| CallbackSafe2 | 100 | 2,894.44 ns | 57.380 ns | 98.978 ns | - |
| CallbackUnsafe | 100 | 289.64 ns | 5.811 ns | 8.697 ns | - |
| CallbackSafe | 1000 | 28,992.22 ns | 575.695 ns | 807.043 ns | - |
| CallbackSafe2 | 1000 | 28,616.33 ns | 522.664 ns | 488.900 ns | - |
| CallbackUnsafe | 1000 | 2,817.02 ns | 54.650 ns | 81.797 ns | - |
| CallbackSafe | 10000 | 287,244.93 ns | 4,654.431 ns | 4,779.758 ns | - |
| CallbackSafe2 | 10000 | 279,869.47 ns | 5,454.815 ns | 4,258.761 ns | - |
| CallbackUnsafe | 10000 | 26,898.52 ns | 80.543 ns | 71.399 ns | - |
| Method | Iterations | Mean | Error | StdDev | Allocated |
|--------------- |----------- |--------------:|-------------:|-------------:|----------:|
| CallbackSafe | 10 | 286.22 ns | 3.481 ns | 2.907 ns | - |
| CallbackSafe2 | 10 | 308.83 ns | 1.978 ns | 1.850 ns | - |
| CallbackUnsafe | 10 | 29.99 ns | 0.624 ns | 0.833 ns | - |
| CallbackSafe | 100 | 2,897.19 ns | 46.334 ns | 43.341 ns | - |
| CallbackSafe2 | 100 | 2,894.44 ns | 57.380 ns | 98.978 ns | - |
| CallbackUnsafe | 100 | 289.64 ns | 5.811 ns | 8.697 ns | - |
| CallbackSafe | 1000 | 28,992.22 ns | 575.695 ns | 807.043 ns | - |
| CallbackSafe2 | 1000 | 28,616.33 ns | 522.664 ns | 488.900 ns | - |
| CallbackUnsafe | 1000 | 2,817.02 ns | 54.650 ns | 81.797 ns | - |
| CallbackSafe | 10000 | 287,244.93 ns | 4,654.431 ns | 4,779.758 ns | - |
| CallbackSafe2 | 10000 | 279,869.47 ns | 5,454.815 ns | 4,258.761 ns | - |
| CallbackUnsafe | 10000 | 26,898.52 ns | 80.543 ns | 71.399 ns | - |
25 Replies
Bambosa
BambosaOP15mo ago
How can I get the same performance as passing in a delegate* managed<int, void> without requiring consumers to be unsafe?
Kai
Kai15mo ago
Got redirected here from silk - "zero cost" basically comes down to JIT eliminating whatever extra code you've written - not super sure what exactly you're asking, also not super sure what you're asking. Not even super sure how those benchmarks relate to the code given tbh
Bambosa
BambosaOP15mo ago
I want to be able to pass a callback function from a safe managed context over FFI without these insane overheads, here is the benchmark I used:
[MemoryDiagnoser(false)]
public class Benchmarks
{
[Params(10, 100, 1000, 10000)] public int Iterations { get; set; }

[Benchmark]
public void CallbackSafe()
{
for (var i = 0; i < Iterations; i++)
Test.Callback(1234, Callback);
}

[Benchmark]
public void CallbackSafe2()
{
for (var i = 0; i < Iterations; i++)
Test.Callback2(1234, Callback);
}

[Benchmark]
public unsafe void CallbackUnsafe()
{
for (var i = 0; i < Iterations; i++)
Test.Callback(1234, &Callback);
}

private static void Callback(int i){ }
}
[MemoryDiagnoser(false)]
public class Benchmarks
{
[Params(10, 100, 1000, 10000)] public int Iterations { get; set; }

[Benchmark]
public void CallbackSafe()
{
for (var i = 0; i < Iterations; i++)
Test.Callback(1234, Callback);
}

[Benchmark]
public void CallbackSafe2()
{
for (var i = 0; i < Iterations; i++)
Test.Callback2(1234, Callback);
}

[Benchmark]
public unsafe void CallbackUnsafe()
{
for (var i = 0; i < Iterations; i++)
Test.Callback(1234, &Callback);
}

private static void Callback(int i){ }
}
Test.Callback(1234, &Callback); is perfect other than the fact the user consuming this static method has to be an unsafe context to give me a delegate* managed<int, void> is there a way to take a managed delegate and convert it to a function pointer without the huge overheads?
TechPizza
TechPizza15mo ago
i don't think it will be safe to send a managed function pointer away unless you only intend to invoke it in a managed context later but regardless
Bambosa
BambosaOP15mo ago
with the delegate* managed<int, void> it only works against static methods which makes it safe to execute from native code
TechPizza
TechPizza15mo ago
it can only be executed with the managed calling convention
Bambosa
BambosaOP15mo ago
I just wish there was an easy way to get the same benefit without the consumer having to dip into unsafe code
TechPizza
TechPizza15mo ago
which you would have to match in native
Bambosa
BambosaOP15mo ago
it currently works with this rust code and executes fine:
#[no_mangle]
pub extern "C" fn callback(number: i32, callback: unsafe extern "C" fn(i32)) {
(|n| unsafe { callback(n) })(number);
}
#[no_mangle]
pub extern "C" fn callback(number: i32, callback: unsafe extern "C" fn(i32)) {
(|n| unsafe { callback(n) })(number);
}
TechPizza
TechPizza15mo ago
because the runtime probably uses the same calling convention as your rust code that's not always a given that's why it's safer to force it to something like Cdecl
Bambosa
BambosaOP15mo ago
so I should pass it as delegate* unmanaged[Cdecl]<int, void>?
TechPizza
TechPizza15mo ago
yes the problem is that then the Callback has to be annotated with [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] Marshal.GetFunctionPointerForDelegate creates a stub for you
Bambosa
BambosaOP15mo ago
yeah but there seems to be a pretty beefy overhead to using it also I thought the default calling convention for unmanaged callers only was cdecl? is it not?
TechPizza
TechPizza15mo ago
may be ok, it is not: If omitted, the runtime will use the default platform calling convention.
Bambosa
BambosaOP15mo ago
No description
Bambosa
BambosaOP15mo ago
yeah just checked
TechPizza
TechPizza15mo ago
so you either use Marshal to get a stub, or you use unsafe and function pointers
Bambosa
BambosaOP15mo ago
I guess I will have to use unsafe then as the overhead for the stub is too big for the use case (interacting with an ECS)
reflectronic
reflectronic15mo ago
do you have to create the callback every time? because your benchmark measures the cost of calling Marshal.GetFunctionPointerForDelegate, not just calling the function pointer it returns
Bambosa
BambosaOP15mo ago
yeah I was thinking if I went down that route I would just have some lookup to get the function pointer for the given function some things to think about, thanks for the info guys
reflectronic
reflectronic15mo ago
the most efficient way is to take an unmanaged function pointer and for the caller to use UnmanagedCallersOnly
Bambosa
BambosaOP15mo ago
yeah I think this is the route I will go down a shame the UnmanagedCallersAttribute is sealed, would have liked to just have a CdeclAttribute to default the calling convention
TechPizza
TechPizza15mo ago
attribute and polymorphism are a bit wack though
Bambosa
BambosaOP15mo ago
especially when they probably work with source generators these were added for AOT I think so I assume these are compile time
Accord
Accord15mo ago
Was this issue resolved? If so, run /close - otherwise I will mark this as stale and this post will be archived until there is new activity.
Want results from more Discord servers?
Add your server