Skip to content

Performance ​

What Hindsight adds to a SaveChanges, measured with BenchmarkDotNet (benchmarks/Hindsight.Benchmarks). One PostgreSQL 17 container serves the whole run; every case uses its own database and measures one SaveChanges per iteration, re-seeding between iterations. None is plain EF Core without Hindsight, the baseline.

Measured on an AMD Ryzen 7 7800X3D, Windows 11, .NET SDK 10.0.400, PostgreSQL 17 in Docker. Your numbers will differ: compare the columns with each other, not with your hardware. To re-run:

bash
dotnet run -c Release --project benchmarks/Hindsight.Benchmarks -- --filter '*'

Insert, update, delete ​

Mean time of one SaveChanges over N temporal entities:

operationrowsNoneInterceptorTrigger
insert11.0 ms3.4 ms1.8 ms
insert1009.3 ms16 ms12 ms
insert100063 ms106 ms83 ms
update10.7 ms3.1 ms1.0 ms
update1007.5 ms23 ms13 ms
delete10.7 ms3.0 ms1.0 ms
delete1006.0 ms22 ms12 ms

Managed allocations for the same calls:

operationrowsNoneInterceptorTrigger
insert100855 KB1580 KB960 KB
insert10008166 KB14930 KB9213 KB
update100499 KB1367 KB592 KB
delete100373 KB1228 KB478 KB
  • Trigger stays within about 1.2–2× plain EF Core. The database writes history inside the same statement; the application pays only for the change-context push, if any, and the transaction around it.
  • Interceptor sends all history statements of a SaveChanges in one batched round trip, split every 512 rows. What remains is executing them: about 1.8× the baseline for a 100-row insert, about 3× for a 100-row update or delete (two statements per row), and about 1.7× at 1000 rows.
  • Single-row figures sit close to the round-trip noise floor; read the 100- and 1000-row rows for the trend.

Change context ​

The same 100-row update, with and without an IChangeContextProvider (a fixed provider with no I/O, so this is the plumbing only):

writerwithoutwithdifference
Interceptor23.0 ms23.8 mswithin noise
Trigger12.7 ms15.7 ms+3.0 ms, +24 KB

For the trigger writer, the difference is the one set_config round trip per SaveChanges. For the interceptor writer, the provider call and the five extra columns fold into the round trip it already makes.

Tracked but unchanged entities ​

A context that tracks many entities but changes one of them still pays for change detection over the whole graph. ChangeTrackerOverheadBenchmarks measures one SaveChanges that changes one Policy while TrackedUnchangedCount other policies sit unchanged in the same context. Wall-clock time at this scale is mostly noise, so allocations are the signal:

TrackedUnchangedCountNoneInterceptorTrigger
09.09 KB26.51 KB10.61 KB
10059.88 KB134.55 KB115.53 KB
1,000516.75 KB1,102.20 KB1,056.81 KB
10,0005,085.98 KB10,805.23 KB10,478.28 KB

The remaining overhead, about 2× at 10,000 tracked entities, is one extra change-detection pass that Hindsight runs before the writer, so it can reject a save of a historical snapshot before anything is written. To re-run only this benchmark:

bash
dotnet run -c Release --project benchmarks/Hindsight.Benchmarks -- --filter '*ChangeTrackerOverheadBenchmarks*'

Released under the MIT License.