Skip to content

Design Proposal: Reducing the reflection dependency #1

@matt-andrews

Description

@matt-andrews

Hello stranger! I ran across your reddit post for this project, to be completely honest I don't have a lot of use for desktop applications these days, but I've identified a potential solution to your reflection problem and thought I'd share (if you want). This might be considered a micro-optimization, so feel free to tell me to go to hell.

Summary

Reflection is expensive. In an application such as this, efficient interaction between the framework and the users code is critical for widespread adoption. I will spend the following wall of text explaining how we can accomplish this.

Solution

Source Generators! tl;dr source generators are a roslyn analyzer feature that when implemented, generates code based on code the user enters. We can then use the generated code to interface with user code instead of relying on reflection.

The following rough examples are in the context of registering a component and invoking the methods of a component.

Imagine a new interface:

public interface IIpcComponent
{
    object? Invoke(string target, object?[]? args = null);
}

And imagine that your IpcContainer looks more like this:

public class IpcContainer
{
    private readonly Dictionary<string, IIpcComponent> _instanceCache = [];
    public IpcContainer Register<T>(string instanceName, T target)
        where T : notnull, IIpcComponent
    {
        //we could even get rid of the instanceName param and use typeof(T).GetName() instead
        //for an even better user experience!
        if(!_instanceCache.TryAdd(instanceName, target))
        {
            throw new InvalidOperationException("This instance name already exists in the container");
        }

        return this;
    }

    public IIpcComponent GetInstance(string instanceName)
    {
        if (!_instanceCache.TryGetValue(instanceName, out var instance))
        {
            throw new ArgumentException($"Instance '{instanceName}' not found.");
        }

        return instance;
    }
}

Now the changes above are already a huge performance boost in regards to registering a new component - since all were doing is managing a dictionary. So how do we make this work with the rest of the framework, you might ask? This is where source generation comes in. Imagine a simple component:

public partial class TestComponent
{
    [IpcExpose]
    public int Add(int a, int b)
    {
        return a + b;
    }
}

Using a simple source generator we can very easily generate the following, triggered by the attribute:

// <auto-generated/>
using System;

#nullable enable
public partial class TestComponent : IIpcComponent
{
    public object? Invoke(string target, object?[]? args = null)
    {
        switch(target)
        {
            case nameof(Add): 
            {
                //ArgsBuilder is a simple helper class to cast the positional args
                ArgsBuilder a = new(args); 
		return Add(a.MoveNext<Int32>(), a.MoveNext<Int32>());
            }
            default:
                throw new Exception($"Target {target} not found in available methods");
        }
    }
}

With the generated partial class, the TestComponent class is now of type IIpcComponent and can be registered with the IpcContainer, leading us to the ability to invoke methods dynamically without the cost of reflection.

Note

I'm not including the actual source generation code here because it would bloat this proposal even more. Suffice to say the above code is 100% generated lol

Benchmarks

These benchmarks probably aren't super accurate since I threw them together on short notice, but the numbers are interesting to consider:

Component Registration

BenchmarkDotNet v0.14.0, Windows 11 (10.0.22621.1702/22H2/2022Update/SunValley2)
12th Gen Intel Core i5-12600K, 1 CPU, 16 logical and 10 physical cores
.NET SDK 8.0.400
[Host] : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2

Method Count Mean Error StdDev Ratio RatioSD Gen0 Gen1 Gen2 Allocated Alloc Ratio
Reflection 10 14,013.8 ns 127.22 ns 119.00 ns 1.00 0.01 1.7395 0.0610 - 17.97 KB 1.00
SourceGen 10 619.0 ns 7.67 ns 7.17 ns 0.04 0.00 0.7248 0.0191 - 7.41 KB 0.41
Reflection 100 162,327.8 ns 1,865.01 ns 1,744.53 ns 1.00 0.01 17.0898 4.3945 - 178 KB 1.00
SourceGen 100 6,827.7 ns 136.42 ns 204.19 ns 0.04 0.00 7.2479 1.5793 - 74.05 KB 0.42
Reflection 1000 4,472,908.4 ns 44,627.46 ns 41,744.56 ns 1.00 0.01 171.8750 109.3750 - 1796.05 KB 1.00
SourceGen 1000 94,303.8 ns 1,048.52 ns 980.78 ns 0.02 0.00 74.5850 44.9219 - 762.35 KB 0.42
Reflection 10000 349,939,866.7 ns 5,731,331.73 ns 5,361,091.26 ns 1.00 0.02 1000.0000 - - 18140.77 KB 1.00
SourceGen 10000 3,894,223.1 ns 51,385.16 ns 40,118.15 ns 0.01 0.00 710.9375 671.8750 312.5000 7629.44 KB 0.42

These metrics are not surprising at all, since the V2 version of the IpcContainer is basically just a wrapper over a dictionary - but it shows what kind of penalty we're paying with the original implementation.

Component Invocation

Method Count Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
Reflection 10 794.8 ns 6.47 ns 6.05 ns 1.00 0.1984 2.03 KB 1.00
SourceGen 10 288.9 ns 1.99 ns 1.77 ns 0.36 0.1683 1.72 KB 0.85
Reflection 100 7,382.9 ns 68.46 ns 60.69 ns 1.00 1.9836 20.31 KB 1.00
SourceGen 100 2,893.0 ns 13.37 ns 11.85 ns 0.39 1.6823 17.19 KB 0.85
Reflection 1000 78,986.5 ns 592.94 ns 554.63 ns 1.00 21.9727 225 KB 1.00
SourceGen 1000 35,216.4 ns 502.42 ns 469.96 ns 0.45 18.9209 193.75 KB 0.86
Reflection 10000 1,156,346.8 ns 11,557.47 ns 10,810.86 ns 1.00 228.5156 2334.38 KB 1.00
SourceGen 10000 457,363.7 ns 8,492.86 ns 7,944.23 ns 0.40 197.7539 2021.88 KB 0.87

These numbers are a little more interesting, this is calculated by getting a component from the container and invoking it with _container.Invoke("Add", [10, 5]); and compared to a slimmed down version of the original. As the metrics show, we can save a significant amount of time on the invocation by using source generators instead of reflection.

Cons

So we've talked about all the pros for source generators, what are some of the cons?

  • Increased code complexity: Source generators make the underlying code much more complicated to maintain and understand - sometimes it feels like magic (not the good kind!). Any experienced dev can look at GetType().GetMethods() and know that reflection is being used to get a types methods; but magic attributes that make a type suddenly have an interface and a method that gets other methods (???)
  • This would be a breaking change: Since the added requirement that classes that have the IpcExposeAttribute or have methods that use IpcExposeAttribute must be partial, this would be a breaking change and require a major version bump :(
  • Not all reflection can be (or should be) replaced with source generators. Obvious hot paths such as registration/invocation, but I haven't looked hard enough to determine if anywhere else should be.
  • Its a lot of work to perform this kind of migration, so you must ask yourself "is it worth it?"

Conclusion

Thinking back I'd probably use an abstract class instead of an interface for IIpcComponent - it kind of feels more natural, and frees us up to pass in cool things during the component lifecycle if we wanted in the future.

I don't really have much else to add tbh, I couldn't sleep last night so this is what I did instead lol. Anyways, this was a fun thought experiment since I've never actually created a source generator before today.

If I don't hear from you again - good luck!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions