Performance and IL2CPP
Use this page to understand what Onity does on the paths that run every frame, what the measured comparisons show and what they do not, and what to check before shipping on IL2CPP. One package runs on JIT runtimes (the Unity Editor and Mono players) and ahead-of-time runtimes (IL2CPP, console AOT) without a code change; the paths differ, the results do not. The numbers below each name their record, their conditions and their limits; the records are the authority.
Contents: Three activation strategies, one container, Hot-path design, What the numbers show, Known slower cases, Onity.Reactive on IL2CPP, Onity.Messaging on IL2CPP, OnityTask on IL2CPP, IL2CPP checklist, What remains.
Three activation strategies, one container
The DI layer constructs instances and injects members through the fastest safe strategy for the selected constructor:
- Generated (AOT and JIT):
Onity.SourceGenemits a directnew T(...)delegate for every type marked[OnityGenerateActivator]and registers it inOnity.DI.Internal.GeneratedActivators. A registered activator is used first on every runtime, including IL2CPP. - Compiled (JIT runtimes): without a generated activator, constructor activators and member setters are built with
Expression.Compile, once per constructor for the life of the process, across every containerBuild(). - Reflection (AOT, IL2CPP or restricted runtimes): runtime expression compilation can be unavailable, interpreter-backed or target-dependent, so a one-time probe compiles and invokes a representative lambda, because some AOT runtimes let
Compile()succeed and throw only when the delegate first runs. When the probe fails, or one constructor fails to compile (for example a type the linker stripped), that constructor uses cached reflection: slower per call, allocation-comparable, and it runs instead of crashing the container.
All three produce identical graphs; only the per-constructor delegate differs. You can read which path the probe selected:
using Onity.DI; // OnityContainer
// True when the runtime probe accepted Expression.Compile. Generated activators are used
// whether this is true or false; it is a diagnostic, not a "fast path active" flag.
bool compiled = OnityContainer.IsCompiledActivationSupported;
Hot-path design
- Resolve: construction plans are cached per type, constructor arguments travel through
[ThreadStatic]pooled arrays, a genericResolve<T>()of an explicit local binding reads a dense type-id provider slot instead of a dictionary, andBuild()bakes flat lifetime and singleton slots for the explicit bindings (the parity suite checks that the baked and standard lanes produce identical results, including for registrations made afterBuild()). DynamicResolve(Type), misses and parent fallback use the general provider map. A transient resolve still allocates the instance it returns, and a deep graph allocates one object per constructed node. - Reactive: a subscription is one object, an internal node that is both the entry the source keeps and the
IDisposablethe subscriber receives.Subject<T>andReactiveProperty<T>keep their nodes in an array-backed list, remove a node in constant time outside a notification and compact after the outermost notification when nodes were removed during it, deliver each value by invoking the node’s callback directly, and run one exception region per notification pass. The synchronous operators are sink nodes, withWherefollowed bySelectfused into one sink. Steady-stateOnNextandSetValueallocate nothing. - Messaging: a subscription is one object that is both the slot entry the channel keeps and the
IDisposablethe subscriber receives, and it knows its slot, so a dispose is a constant-time slot removal.MessageChannel<T>.Publishwalks an array of slots, allocates nothing in steady state and returns at once without subscribers.AsyncMessageChannel<T>.PublishAsyncruns synchronously completing handlers inline without a state machine, moves to an awaiting continuation only at the first pending handler, and copies its handler array only when a subscription is disposed during a pass. The broker locks its channel table, never the publish path. - Frame streams:
EveryUpdate()and the other frame sources share one pump and deliver without allocating. - Async: a suspended
async OnityTaskmethod binds a pooled runner that holds the state machine by value; cancelable waits, timed waits and Unity operations are pooled single-consumer sources; in Play, token-less frame waits are stateless.OnityTaskhas no static constructor, and context flow is off by default.
What is not claimed: a verified zero-allocation resolve for every scenario. The accurate statement is that the machinery avoids per-call managed allocation beyond the objects a call must create, and the raw Profiler passes below measured where that holds.
What the numbers show
Every ratio is Onity’s time divided by the other library’s time for the same workload in the same process; below 1 means Onity took less time. The Comparisons pages hold the full tables.
- Dependency injection. In the Windows IL2CPP release Player run of 2026-10-02 (Unity 2022.3.62f2, the published 0.6.0 package whose DI code is unchanged through 0.8.2, 19 generated activators, three processes, 512 warmups and 8 samples of 10,000 operations), Onity Baked was fastest in all seven scenarios in every process, with Onity / VContainer per-process ratios of 0.21 to 0.59 (record). The 2026-10-01 re-measurement found 0.23 to 0.57 on IL2CPP, Onity Baked faster than both VContainer and Zenject in all seven Editor/Mono scenarios, and the same
GC.Allocevent counts as the September raw Profiler pass: 0 for singleton, keyed and scoped resolves in every container, 1 event for Onity and VContainer and 4 for Zenject on transient and combined resolves, 24 and 96 on the complex graph (record). The September 2026 raw Profiler passes measured 0, 16, 16 and 384 B per operation for Onity Baked and VContainer on singleton, transient, combined and complex resolves, on Editor/Mono and in a Windows IL2CPP Development Player (summary). A release Player reports no allocation; the comparison there is timing only. - Pooling. Against Zenject’s
MemoryPool, paired within one process: one reused item in the checked Editor/Mono test, Onity checked / Zenject 0.590x and 0.579x in two runs afterOnityObjectPool<T>moved to its own array stack (1.483x before it, the same day); the 32-item two-parameter burst in a Windows IL2CPP Release Player, factory / Zenject 0.738x and 0.729x in an old/new/new/old sequence (record), and 0.809 and 0.801 in the 2026-10-01 re-measurement of the earlier source, with zero warmedGC.Allocevents in Development runs (record). Prefab pools, VContainer pool adapters and other workloads were not ranked. - Async. In the 2026-10-02 Release Player gate (Unity 2022.3.62f2, Windows x64, UniTask 2.5.11, three processes per backend and suite, context flow off, pool retention matched for the 1,024- and 4,096-operation bursts), OnityTask took less time than UniTask in all 29 gated IL2CPP rows, median ratios 0.085 to 0.808, worst process 0.906 (record). Mono is reported, not gated: faster in 25 of 29 rows.
- Reactive. In the 2026-10-02 Release Player comparison (Unity 2022.3.62f2, Windows x64, R3 1.3.0 and UniRx 7.1.0, three processes per backend, source
36c68c5), Onity.Reactive took less time than R3 in all nine IL2CPP rows, median ratios 0.145 to 0.805 and worst process 0.811, and less time than UniRx in all nine rows, 0.129 to 0.872 and worst process 0.874 (record). Mono is reported, not gated: faster than R3 in seven rows, on par in one, slower inCombineLatest; faster than UniRx in five rows, on par in two, slower in single-subscriber publish andCombineLatest. - Messaging. In the 2026-10-02 Release Player comparison (Unity 2022.3.62f2, Windows x64, MessagePipe 1.8.1 as the Unity package compiled against UniTask 2.5.10, three processes per backend, source
52e209a, which is the shipped runtime4cc5763plus the harness), Onity.Messaging took less time than MessagePipe in all nine IL2CPP rows, median ratios 0.150 to 0.846 and worst process 0.895, and in all nine Mono rows, 0.280 to 0.756 and worst process 0.862 (record). Against MessagePipe’s .NET build (the NuGet netstandard2.0 dll) the same runtime took less time in all nine rows on both backends (IL2CPP medians 0.197 to 0.807, worst process 0.974). Allocation was not measured; the subscribe-and-dispose rows ran 90 to 93 gen-0 collections per row in the Unity-package run (92 to 93 in the NuGet run) against 426 to 430 for the 0.7.0 runtime (IL2CPP, sum over three processes).
Known slower cases
- OnityTask on Mono: the four synchronous completed-result rows are 1.09x to 1.92x slower than UniTask; the other 25 rows are faster.
- OnityTask with opt-in
AsyncLocalflow (FlowExecutionContext = true): a complete method lifecycle with four suspensions is 1.5x to 1.6x slower than UniTask on IL2CPP and 2.6x to 2.7x on Mono. UniTask has no flow; the row prices a feature it does not offer. - OnityTask at default pool retention: 4,096-call bursts of one async method are up to 2.6x slower than UniTask, because every call above the 128-runner cap allocates. Raise
OnityTask.RunnerPoolCapacityandSourcePoolCapacitybefore such bursts; the gate rows were measured that way. - Onity.Reactive on Mono:
CombineLatestpublish is 1.378x slower than R3 and 1.527x slower than UniRx (it was not redesigned), single-subscriber publish is 1.098x slower than UniRx, and the same-value property set is on par with both (1.040 and 1.001). On IL2CPP every row is faster than both libraries. - DI: the standard (non-baked) lane’s scoped-singleton resolve on IL2CPP (233.4 ns) is slower than VContainer’s (112.9 ns); the baked graph, the default after
Build(), measured 53.5 ns. The prepare-and-register scenario is the closest ratio, 0.52 to 0.59. - Pooling before the own-stack change: the single reused item was 1.48x slower than Zenject in the checked Editor/Mono test. That is the superseded state, kept in the records.
Onity.Reactive on IL2CPP
The reactive core carries internal copies of the Unity.IL2CPP.CompilerServices attributes (OnityIl2CppCompilerServices.cs), which IL2CPP recognizes by their full names, so the engine-free assembly steers code generation without a dependency on UnityEngine. [Il2CppSetOption(Option.NullChecks, false)] and [Il2CppSetOption(Option.ArrayBoundsChecks, false)] are applied to the node list, the nodes, the operator observables and their sinks, where every receiver is non-null by construction and the public entry points validate their arguments. Node delivery is an instance method on purpose: under IL2CPP a generic call made from a static or struct method re-checks class initialization on every call, and this runs once per delivered value. While exactly one live subscriber is registered, Subject<T>.OnNext and ReactiveProperty<T>.SetValue deliver to it through a direct reference the node list keeps, in their own exception region and without the general loop; a subscribe, unsubscribe or dispose inside that callback falls back to the general pass. The gate results above were measured on this build.
Onity.Messaging on IL2CPP
Onity.Messaging carries the same internal copies of the attributes (OnityMessagingIl2CppCompilerServices.cs; the engine-free CI build compiles the reactive copy for both assemblies). [Il2CppSetOption(Option.NullChecks, false)] and [Il2CppSetOption(Option.ArrayBoundsChecks, false)] are applied to MessageChannel<T> and AsyncMessageChannel<T>, and null-check removal alone to their subscription objects, where every index stays inside the tracked count and the public entry points validate their arguments. The messaging comparison above was measured on this build.
OnityTask on IL2CPP
What the measured Player build relies on:
- Code generation options. Onity carries internal copies of the
Unity.IL2CPP.CompilerServicesattributes for the async layer as well.[Il2CppSetOption(Option.NullChecks, false)]removes null checks from the task awaiters, method builders, pooled source bases, async-method runners and their pool, the PlayerLoop owner and the yield awaiter.[Il2CppEagerStaticClassConstruction]on the settings holder and the PlayerLoop owner runs their static constructors at startup, so reads of their static fields carry no class-initialization check.OnityTaskitself has no static constructor, so its inlined members have none either; the generated C++ of the measured Player confirms both. - Immediate runner return. A suspended async method’s runner goes back to its pool as soon as its task is consumed. This is safe because the runner calls the state machine’s
MoveNextby address, with no copy back, which the generated C++ also confirms. - Context flow. The results are measured at the default
FlowExecutionContext = false, which is UniTask-equivalent; enabling flow adds one execution-context capture and restore per suspension. - Pool retention. Onity keeps at most 128 runners per async method (
OnityTask.RunnerPoolCapacity) and 256 sources per source type (OnityTask.SourcePoolCapacity); UniTask keeps everything. Above the cap each extra operation allocates. Raise both caps at startup for methods that run in large bursts when the retained memory is acceptable.
IL2CPP checklist
- Mark hot DI-managed implementation types with
[OnityGenerateActivator].Onity.SourceGen.dllships in the package’sAnalyzers/folder, so IL2CPP uses directnew T(...)delegates for those types instead ofConstructorInfo.Invoke. - No setup is required for correctness. Without a generated activator the runtime probe selects the compiled path only when it can compile and invoke safely; otherwise the reflection path engages. The same bindings run in the Editor, in Mono players and in IL2CPP players.
- Keep the core engine-free.
Onity.Core,Onity.DI,Onity.Reactive,Onity.Messaging,Onity.FactoryandOnity.Compositionhave noUnityEnginedependency, which keeps them simple to strip and test. Onity has no non-Unity third-party runtime dependency. - Closed generics only. Open generic definitions are bound (
Bind(typeof(IRepository<>))), but each closed form is what resolves and is built on first use; make sure the closed types you resolve are reachable so the linker preserves them. - No reflection-only members the linker can drop silently. Constructors and injected members the container needs must survive stripping; reference them so they are not removed.
- Pre-flight the fallback in the Editor. The parity tests exercise the reflection path through an internal switch, so the AOT fallback behavior is covered by tests rather than discovered on device.
- Re-run the Player benchmarks for your target platform before treating the published Windows numbers as target-platform results; the records cover one Windows PC.
What remains
The generator is explicit: it emits activators for types marked [OnityGenerateActivator]. Future work can improve discovery, generate member setters and add device coverage. The reactive and messaging comparisons measured no allocation on either backend, and CombineLatest was not redesigned; the DI allocation figures come from a Development Player and the Editor, not from the release Players that supply the timing.
See also
- Dependency Injection: the binding and resolve surface the fast path serves.
- Reactive and Events and Messaging: the emit paths.
- Async with OnityTask: pool retention and context flow.
- Comparisons: the four measured comparisons and how to read them.
- Architecture: the assembly boundaries and the node design.