Event driven programming should be fast. Especially in video games, where every CPU cycle and byte of memory counts. Sappy helps you achieve this.
There are actually two problems with regular C# events in Unity.
- Every time you pass a method group as a lambda, a new delegate instance is allocated. This creates garbage.
- Under the hood, C# holds an array for each event. Every time you subscribe or unsubscribe (
OnEvent += Callback;), a new array is created and the previous content is copied. This not only creates garbage but it's also really slow.
In Sappy, we apply a tree analogy to event handling: we have Sap (event data) delivered to Sap Targets (event listeners) by Sap Stems (event handlers).
Sappy generates cached delegates for your methods so that you can use them instead of passing a method group directly as a lambda (and avoid creating garbage). To achieve this, you just need to add the [SapTarget] attribute to your method.
[SapTarget]
private void MyMethod()
{
// ...
}
private void OtherMethod(Action callback)
{
// ...
}
// βββ Before Sappy πβ βββ
private void SadInit()
{
InterestingObject.OnInterestingEvent += MyMethod;
OtherMethod(MyMethod);
}
// βββ Before Sappy πβ βββ
// βββ After Sappy πβοΈ βββ
private void HappyInit()
{
InterestingObject.OnInterestingEvent += Sappy.MyMethod; // Garbage avoided! β
OtherMethod(Sappy.MyMethod); // Garbage avoided! β
}
// βββ After Sappy πβοΈ βββBut what is Sappy.MyMethod? We haven't defined it anywhere in our class. No, Sappy did. Sappy requires the enclosing class to be partial so it can generate a new source file for the same class, adding the cached delegate.
// βββ User Code βββ
public partial class MyClass
{
public MyClass()
{
InterestingObject.OnInterestingEvent += Sappy.MyMethod;
}
[SapTarget]
private void MyMethod() {
// ...
}
}
// βββ User Code βββ
// βββ Sappy Generated βββ
// (Simplified for illustrative purposes, real generated code defines a SapTarget<Action>)
public partial class MyClass
{
private SappyGroup _sappyGroup;
private SappyGroup SappyGroup => _sappyGroup ??= new SappyGroup(this);
private class SappyGroup
{
private MyClass Instance { get; }
public SappyGroup(MyClass instance)
{
Instance = instance;
}
private Action _MyMethod;
public Action MyMethod => _MyMethod ??= Instance.MyMethod;
}
}
// βββ Sappy Generated βββBy default, Sappy creates Action and Func delegates. You can define a different delegate type if necessary.
[SapTarget(typeof(MyCustomDelegateType))]
private void MyMethod()
{
// ...
}SapTargets are generated as part of a group. You can override the default "Sappy" name.
public partial class MyClass
{
public MyClass()
{
InterestingObject.OnInterestingEvent += CachedDelegates.MyMethod;
}
[SapTarget(groupName: "CachedDelegates")]
private void MyMethod() {
// ...
}
}You can also avoid the nested class for your group and instead prefix the group name. This is useful if you want to have access to the SapTarget from outside the class (the generated delegate will have the same accessibility modifier as the method).
public partial class MyClass
{
public MyClass()
{
InterestingObject.OnInterestingEvent += SappyMyMethod;
}
[SapTarget(nested: false)]
public void MyMethod() {
// ...
}
}As we mentioned before, C# creates and copies arrays every single time we subscribe to or unsubscribe from an event. This may sound like madness. Because it is.
Of course there are workarounds and things you can do when performance is critical (like creating a MulticastDelegate with Delegate.Combine to skip a lot of the Array.Copy calls). Well, with Sappy, none of that is necessary.
You can instead create a SapStem to handle your event, which internally uses a resizable list of delegates (and can also lazy initialize a MulticastDelegate if necessary).
// βββ Before Sappy πβ βββ
public partial class MySadClass
{
public event Action<int> OnInterestingEvent;
// ...
private void DoTheThing()
{
// ...
OnInterestingEvent?.Invoke(5);
}
}
public partial class MyOtherSadClass
{
public MyOtherSadClass(MySadClass interestingObject)
{
interestingObject.OnInterestingEvent += DoUpdate;
}
private void DoUpdate()
{
// ...
}
}
// βββ Before Sappy πβ βββ
// βββ After Sappy πβοΈ βββ
public partial class MyHappyClass
{
private SapStem<int> OnInterestingEventStem { get; } = new();
public SapTargets<int> OnInterestingEvent => OnInterestingEventStem.Targets;
// ...
private void DoTheThing()
{
// ...
OnInterestingEventStem.Send(5);
}
}
public partial class MyOtherHappyClass
{
public MyOtherHappyClass(MyHappyClass interestingObject)
{
interestingObject.OnInterestingEvent.Register(Sappy.DoUpdate); // Garbage and Array.Copy avoided! β
}
[SapTarget]
private void DoUpdate()
{
// ...
}
}
// βββ After Sappy πβοΈ βββTip
If you're using a third party library, you may have no control over the event handling but you can still create and use SapTargets to avoid some garbage creation.
The result of using SapStem is much, much faster subscriptions, virtually free unsubscriptions and resubscriptions and much less garbage created.
The trade off might be negligible slower event invocations. If you notice your event invocations to be underperforming in a meaningful way, you can opt to internally use a MulticastDelegate instead.
private SapStem<int> OnEventStem { get; } = new(createMulticastDelegate: true);When a SapStem creates a MulticastDelegate, the first invocation after the listeners have changed will always recompute the MulticastDelegate and take some extra time... but with all the time you saved in subscriptions and unsubscriptions, you have plenty to spare! Well, maybe not, but rest assure that all the following invocations (as long as the listeners don't change) will perform exactly the same as its native C# counterpart (because they're effectively doing the same).
Sap is tacky and sticky and in Sappy, everything sticks around!
We also tried to pick a name that would cause no naming conflicts in your classes. We felt pretty safe about "Sappy".
We created a benchmark that
- Subscribes 1000 different listeners.
- Triggers the event 50 times.
- Unsubscribes all 1000 listeners.
- Repeats steps 1-3 30 times.
We run this benchmark 500 times. These are the results:
| Plain C# (Editor) | Sappy (Editor) | Plain C# (IL2CPP Build) | Sappy (IL2CPP Build) | |
|---|---|---|---|---|
| 1. Subscription | 1.50ms | 0.27ms (5.5x faster) | 1.41ms | 0.27ms (5.2x faster) |
| 2. Resubscription | 1.46ms | 0.05ms (29.2x faster) | 1.40ms | 0.06ms (23.3x faster) |
| 3. Invocation | 0.02ms | 0.02ms | 0.01ms | 0.01ms |
| 4. Unsubscription | 10.62ms | 0.04ms (265.5x faster) | 4.95ms | 0.07ms (70.7x faster) |
On top of this, plain C# created a lot more garbage.
You can find the Benchmark Scene in Samples and play with it.
Installing Sappy is simple. You can add the package via the Unity Package Manager using the Git URL, or by modifying your manifest.json file directly.
Add the following package URL: https://github.com/clockworklabs/SappyEvents#[target-version] (latest is 1.0.0).
- Open the Package Manager.
- Select the Sappy package.
- Go to the Samples tab and import Source Generator.
- In your project assets, locate the imported
SappyGenerator.dll. - Crucial Step: In the Inspector for the DLL:
- Ensure the Select platforms for plugin list is empty (no platforms selected).
- Add (if not present already) the
RoslynAnalyzerlabel.