> For the complete documentation index, see [llms.txt](https://postica.gitbook.io/binding-system-3/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://postica.gitbook.io/binding-system-3/project-tools/performance/benchmarks.md).

# Benchmarks

The numbers, and how to produce your own.

## How these numbers were produced

Medians from the package's own performance suite, in **nanoseconds per call**, from two builds of the same project on the same machine: one on **IL2CPP**, one on **Mono**. Each cell gives IL2CPP first and Mono under it. One machine and one run apiece, so they are orders of magnitude rather than specifications. [Run the suite yourself](#running-them-yourself) before quoting a number.

## Against plain reflection

The common assumption about a binding system is that it calls `FieldInfo.GetValue` on every read. This one does not: reflection is used once, when the path is first resolved, and never again while the game runs. Every test in the suite measures both, on the same object in the same run.

| Access                                                               | Through `Bind<T>` | Plain reflection | Faster by       |
| -------------------------------------------------------------------- | ----------------- | ---------------- | --------------- |
| A **field**, read                                                    | 8.0 ns            | 98 ns            | **12x**         |
| A **field**, written                                                 | 7.6 ns            | 75 ns            | **10x**         |
| A **property**, read                                                 | 8.4 ns            | 191 ns           | **23x**         |
| A **property**, written                                              | 9.2 ns            | 178 ns           | **19x**         |
| `SubStruct.vec2.x`, read                                             | 32 ns             | 464 ns           | **15x**         |
| `SubStruct.vec2.x`, written                                          | 39 ns             | 798 ns           | **20x**         |
| A field through the [field layer](#the-field-layer-on-its-own) alone | 1.4 to 2.5 ns     | 62 to 256 ns     | **25x to 180x** |

IL2CPP figures. On Mono the ratios are the same or larger: a property write is 30x, a struct-property chain 63x.

Two notes on the table. Reflection also **boxes** every value type it carries, which costs a frame rather than a microbenchmark, and the ratio does not show that. The two multi-hop rows are measured with [IL2CPP Specializations](/binding-system-3/project-tools/performance/il2cpp-specializations.md) on; with every optimization off they are 8x and 13x rather than 15x and 20x.

## One member

| Path                    | Direct C#                          | The accessor                       | Through `Bind<T>`                   | Plain reflection                   |
| ----------------------- | ---------------------------------- | ---------------------------------- | ----------------------------------- | ---------------------------------- |
| A **field**, read       | <p>1.0 ns<br><em>Mono 1.5</em></p> | <p>3.7 ns<br><em>Mono 4.0</em></p> | <p>8.0 ns<br><em>Mono 9.8</em></p>  | <p>98 ns<br><em>Mono 137</em></p>  |
| A **field**, written    | <p>1.0 ns<br><em>Mono 1.4</em></p> | <p>3.2 ns<br><em>Mono 6.3</em></p> | <p>7.6 ns<br><em>Mono 9.2</em></p>  | <p>75 ns<br><em>Mono 125</em></p>  |
| A **property**, read    | <p>1.1 ns<br><em>Mono 2.0</em></p> | <p>7.0 ns<br><em>Mono 8.3</em></p> | <p>8.4 ns<br><em>Mono 13.3</em></p> | <p>191 ns<br><em>Mono 52</em></p>  |
| A **property**, written | <p>1.0 ns<br><em>Mono 2.4</em></p> | <p>5.4 ns<br><em>Mono 7.3</em></p> | <p>9.2 ns<br><em>Mono 11.9</em></p> | <p>178 ns<br><em>Mono 354</em></p> |

A bound field read is **eight nanoseconds**. Updated every frame on a thousand objects, that is well under a tenth of a millisecond.

The accessor column is the resolved member access. `Bind<T>` adds **1.5 to 4.5 ns** on top of it: the bound-source check, the mode check and the pipeline entry. That delta is flat, so it matters less the more expensive the member is.

## Several hops

A path through properties that return structs is the expensive shape: the value comes back, is walked into, and on a write is written back through the property it came from.

These figures are with [IL2CPP Specializations](/binding-system-3/project-tools/performance/il2cpp-specializations.md) at **All**, which is the setting this shape needs on IL2CPP.

| Path                                                                                   | Direct C#                            | The accessor                      | Through `Bind<T>`                | Plain reflection                    |
| -------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------- | -------------------------------- | ----------------------------------- |
| <p><code>SubStruct.vec2.x</code>, read<br><em>property, property, field</em></p>       | <p>0.9 ns<br><em>Mono 4.1</em></p>   | <p>28 ns<br><em>Mono 7.2</em></p> | <p>32 ns<br><em>Mono 19</em></p> | <p>464 ns<br><em>Mono 619</em></p>  |
| `SubStruct.vec2.x`, written                                                            | <p>2.1 ns<br><em>Mono 9.6</em></p>   | <p>36 ns<br><em>Mono 17</em></p>  | <p>39 ns<br><em>Mono 22</em></p> | <p>798 ns<br><em>Mono 1395</em></p> |
| <p><code>Transform.localScale.x</code>, read<br><em>property, property, field</em></p> | <p>13.3 ns<br><em>Mono 14.8</em></p> | <p>22 ns<br><em>Mono 22</em></p>  | <p>26 ns<br><em>Mono 41</em></p> | <p>429 ns<br><em>Mono 234</em></p>  |

The third row has a different baseline: `Transform.localScale.x` in plain C# is already 13 ns, because it is an engine call rather than a memory read. Against that, the binding is 2x on IL2CPP.

### The same paths with every optimization off

<table><thead><tr><th width="270">Path</th><th width="140">Accessor, default</th><th width="140">Accessor, specialized</th><th>Gain</th></tr></thead><tbody><tr><td><code>SubStruct.vec2.x</code>, read</td><td>59 ns</td><td><strong>28 ns</strong></td><td>2.1x</td></tr><tr><td><code>SubStruct.vec2.x</code>, written</td><td>58 ns</td><td><strong>36 ns</strong></td><td>1.6x</td></tr><tr><td><code>Transform.localScale.x</code>, read</td><td>75 ns</td><td><strong>22 ns</strong></td><td><strong>3.3x</strong></td></tr></tbody></table>

Through `Bind<T>` the same rows go 61 to 32, 63 to 39 and 78 to 26 ns. On Mono nothing changes, because Mono has no such default cost.

The reason is IL2CPP's handling of generics. A chain of up to four members is one compiled delegate calling each hop's compiled getter, and the getter of a property on a struct is a delegate typed on that struct, created at runtime for whatever types the path names. Mono compiles those for the exact types on first call. IL2CPP compiles ahead of time, has no code for an instantiation it never saw in the source, and falls back to a **shared** body that routes every value-type call through a runtime adapter. [IL2CPP Specializations](/binding-system-3/project-tools/performance/il2cpp-specializations.md) name those instantiations at build time, so IL2CPP compiles them specialised. On `Transform.localScale.x` that brings the accessor to 22 ns against Mono's 22: the backend difference on that path is gone.

Single-member paths are unaffected by any of this, because `Bind<float>` and its accessors are written out in ordinary code and were never shared.

{% hint style="success" %}
**Two settings address deep paths, and they are not alternatives.** [Generated accessors](/binding-system-3/project-tools/performance/optimized-accessors.md) replace a qualifying path at build time with one generated static method that does the whole walk inline. [IL2CPP Specializations](/binding-system-3/project-tools/performance/il2cpp-specializations.md) leave the pipeline in place and have IL2CPP compile it for the exact types; that reaches every binding, including the ones generated accessors refuse. For a hot binding on a deep path through struct properties, turn on both.
{% endhint %}

## The field layer on its own

Underneath the accessor, a field is reached by **pointer arithmetic**: take the object's address, add a byte offset fixed when the path was resolved, read. No `FieldInfo`, no delegate, no dispatch per segment. Measured on its own, without the accessor interface or the wrapper around it:

<table><thead><tr><th width="250">Field</th><th width="130">This layer</th><th width="120">Direct C#</th><th width="110">Ratio</th><th>Plain reflection</th></tr></thead><tbody><tr><td>A <code>Vector3</code>, read</td><td>1.7 ns<br><em>Mono 4.0</em></td><td>1.1 ns<br><em>Mono 2.3</em></td><td><strong>1.6x</strong><br><em>Mono 1.7x</em></td><td>100 ns<br><em>Mono 143</em></td></tr><tr><td>A <code>Vector3</code>, written</td><td>2.0 ns<br><em>Mono 3.8</em></td><td>1.4 ns<br><em>Mono 2.5</em></td><td><strong>1.4x</strong><br><em>Mono 1.6x</em></td><td>77 ns<br><em>Mono 133</em></td></tr><tr><td>A <code>Color</code>, read</td><td>1.7 ns<br><em>Mono 4.5</em></td><td>0.9 ns<br><em>Mono 2.5</em></td><td>1.9x<br><em>Mono 1.8x</em></td><td>106 ns<br><em>Mono 153</em></td></tr><tr><td>A <code>Color</code>, written</td><td>1.6 ns<br><em>Mono 10.5</em></td><td>0.9 ns<br><em>Mono 8.0</em></td><td>1.7x<br><em>Mono 1.3x</em></td><td>77 ns<br><em>Mono 135</em></td></tr><tr><td>A class reference, read</td><td>2.5 ns<br><em>Mono 7.5</em></td><td>2.2 ns<br><em>Mono 7.1</em></td><td><strong>1.1x</strong><br><em>Mono 1.1x</em></td><td>62 ns<br><em>Mono 83</em></td></tr><tr><td>A class reference, written</td><td>2.2 ns<br><em>Mono 8.0</em></td><td>2.1 ns<br><em>Mono 7.0</em></td><td><strong>1.1x</strong><br><em>Mono 1.1x</em></td><td>73 ns<br><em>Mono 101</em></td></tr><tr><td><code>publicVector.y</code>, read</td><td>1.7 ns<br><em>Mono 2.4</em></td><td>0.9 ns<br><em>Mono 1.6</em></td><td>1.9x<br><em>Mono 1.5x</em></td><td>190 ns<br><em>Mono 303</em></td></tr><tr><td><code>publicVector.y</code>, written</td><td>1.4 ns<br><em>Mono 2.5</em></td><td>0.9 ns<br><em>Mono 1.4</em></td><td>1.6x<br><em>Mono 1.8x</em></td><td>256 ns<br><em>Mono 409</em></td></tr></tbody></table>

Within a factor of two of the compiler's own field access on every row, and within ten percent for references, where there is nothing to copy. The last two rows are a **two-field chain** and cost no more than one field: consecutive value-type fields are collapsed into a **single** offset when the path is resolved, so `a.b.c` where all three are fields is one addition and one read. The suite measures that path written as one string and as separate segments, and both cost the same.

Reflection cannot do that. Reading `publicVector.y` through `FieldInfo` means fetching the whole `Vector3`, boxing it, reading `y` out of the box, and on a write putting the box back, which is why those two rows are the dearest in the reflection column.

{% hint style="info" %}
**Allocation is the other axis, and it is the flat one.** A bound read or write of a value type allocates **nothing**. `ModifyDelegate<T>` takes its value by `in`, so nothing is boxed on the way through.

The suite checks this rather than asserting it: every measurement in `BindOverheadPerformanceTests` counts allocated memory alongside the time. On both runs above the median allocation of every row is **zero**, for the direct call, the accessor and `Bind<T>` alike, over thirty thousand calls apiece. Each row records one measurement of under half a kilobyte, and the direct C# call records the same one, which places it in the harness rather than in the binding.
{% endhint %}

## What each mechanism is worth

<table><thead><tr><th width="290">Feature</th><th>Result</th></tr></thead><tbody><tr><td><a href="/binding-system-3/project-tools/performance/phased-bindings.md">Phased bindings</a></td><td>Measured on the same field through the same chain. With <strong>four</strong> static modifiers and an unchanged source, a read drops from 31 ns to 12 ns on IL2CPP and from 112 ns to 20 ns on Mono. With one modifier it is a wash on IL2CPP, and with none there is nothing to pay. On an update where the source <strong>did</strong> change, the phased read costs the fixed one plus about 10 ns for the compare and the cache write on a four-stage chain, and a chain led by a dynamic modifier costs the same as fixed. <a href="/binding-system-3/project-tools/performance/phased-bindings.md#what-it-is-worth">The measured table</a>.</td></tr><tr><td><a href="/binding-system-3/project-tools/performance/optimized-accessors.md">Generated accessors</a></td><td>Removes the per-segment walk entirely for qualifying paths. This is the one that addresses the <a href="#several-hops">multi-hop cost</a>, which is the largest single number on this page.</td></tr><tr><td><a href="/binding-system-3/project-tools/performance/il2cpp-specializations.md">IL2CPP specializations</a></td><td>On IL2CPP only, and on <a href="#several-hops">multi-hop paths</a> only: measured at <strong>1.6x to 3.3x</strong> off the accessor, and on <code>Transform.localScale.x</code> it closes the gap against Mono entirely. Single-member paths do not move. More code and memory in exchange.</td></tr><tr><td><a href="/binding-system-3/overview/modes-and-updates.md#optimized-update">Optimized Update</a></td><td>Proportional to how often the value is unchanged. On a stable value, close to free.</td></tr><tr><td><a href="/binding-system-3/overview/modes-and-updates.md#intervals">Intervals</a></td><td>Exactly linear. A 6-frame interval is a sixth of the work.</td></tr></tbody></table>

## Running them yourself

Ratios from someone else's machine are a starting point, not an answer. The package ships its performance suite.

1. Install **Unity Performance Testing** (`com.unity.test-framework.performance`) via the Package Manager. That is the only step: the test assembly turns `ENABLE_PERFORMANCE_TESTS` on by itself through a version define, so there is nothing to add to the Player settings.
2. Open **Window ▸ General ▸ Test Runner**, **PlayMode** tab.
3. Run the classes below, under `Postica.BindingSystem.Tests`.

<table><thead><tr><th width="330">Test class</th><th>What it measures</th></tr></thead><tbody><tr><td><code>BindOverheadPerformanceTests</code></td><td>The per-call overhead of the <code>Bind&#x3C;T></code> family over the accessor it delegates to. Four timed sample groups per test, on the same object and the same accessor instance: <strong>Direct</strong>, <strong>Reflection</strong>, <strong>Accessor</strong> and <strong>Bind</strong>, plus <strong>AllocatedKB</strong>.</td></tr><tr><td><code>AccessorsFactoryTests</code></td><td>Accessor construction and throughput across short and long paths.</td></tr><tr><td><code>ReflectTests</code></td><td>The fast field-access layer, against plain reflection and against direct C#. This is where the <a href="#the-field-layer-on-its-own">field layer</a> numbers come from. Three sample groups per test: <strong>FastAccessor</strong>, <strong>Reflection</strong>, <strong>Direct</strong>.</td></tr><tr><td><code>RegisteredAccessorPerfTests</code></td><td>What a <a href="/binding-system-3/project-tools/performance/optimized-accessors.md">generated accessor</a> is worth against the resolved one it replaces.</td></tr><tr><td><code>PhasedPipelinePerformanceTests</code></td><td><a href="/binding-system-3/project-tools/performance/phased-bindings.md">Phased</a> against fixed, over the same field with the same modifier chain: zero, one, two and four static modifiers on a source that never changes, zero and four on one that changes every read, and a chain led by a dynamic modifier. Two sample groups: <strong>Fixed</strong> and <strong>Phased</strong>.</td></tr></tbody></table>

The Bind versus Accessor delta is the wrapper overhead, and it is the number the package is written to keep small.

{% hint style="warning" %}
Measure in a **build**, not in the editor, and on the platform you care about. Editor numbers include editor-only work, and the tables above show that IL2CPP and Mono disagree by up to eight times on the same path, in both directions.
{% endhint %}

### Reading the exported results

Each row is one sample group of one test, and the aggregate columns describe that test alone. Divide by the iteration count, 1000 for the bind tests and 100000 for the field-layer ones, to get nanoseconds per call.

{% hint style="warning" %}
**Results exported before 3.0.2 need care.** Until then the sample groups were static and shared across every test in a class, so each row carried its own samples *plus* every earlier test's, and the Median, Average and Standard Deviation columns described a mixture. The tell is a standard deviation larger than the median. In such a file only the per-sample **Values** column is sound, and one test's own figures are the **last thirty values** of its row.
{% endhint %}

## Measuring your own scene

The suite measures the package. For your project, the useful tool is the [Bindings Monitor](/binding-system-3/project-tools/diagnostics/bindings-monitor.md):

1. Enter play mode, let the scene settle, press **Reset**.
2. Play normally for a while.
3. Read **Total Exec Time** in the footer. That is your whole binding budget per update.
4. Sort by **Measurements** and see which rows own most of it.

If the total is a fraction of a millisecond, you are done. If it is not, the [tuning order](/binding-system-3/project-tools/performance.md#a-tuning-order-that-works) is on the overview page.

## Build size

<table><thead><tr><th width="290">Feature</th><th>Result</th></tr></thead><tbody><tr><td>Base package</td><td>The runtime assembly plus Mono.Cecil. The editor assemblies are not included in a build.</td></tr><tr><td><a href="/binding-system-3/project-tools/performance/phased-bindings.md">Phased bindings</a></td><td>Slightly larger, for the phase machinery.</td></tr><tr><td><a href="/binding-system-3/project-tools/performance/optimized-accessors.md">Generated accessors</a></td><td>One generated method per optimized path, present only during the build.</td></tr><tr><td><a href="/binding-system-3/pipeline/standard-modifiers.md">Standard modifiers</a></td><td>One generated class per modifier kind per enabled type. This is why it is opt in.</td></tr></tbody></table>
