Benchmarks
The numbers on this page are reference figures from a controlled BenchmarkDotNet run. Real-world performance depends on record size, schema complexity, serialization choice, index configuration, storage media, and hardware — treat these as relative indicators.
Test environment
| Item | Value |
|---|---|
| CPU | Intel Core i7-9750H, 2.60 GHz, 6 physical / 12 logical cores |
| OS | Windows 11 25H2 (10.0.26200.8457) |
| .NET runtime | .NET 9.0.16, X64 RyuJIT AVX2 |
| BenchmarkDotNet | v0.15.8 |
| Benchmark config | InvocationCount=1, UnrollFactor=1 |
| MemDb version | 6.1.0 |
Record schema
All benchmarks use the same record type:
public class BenchmarkRecord
{
public long Id { get; set; }
public string Name { get; set; } // ~16 chars
public string Category { get; set; } // ~8 chars
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public decimal Amount { get; set; }
public int Count { get; set; }
public bool IsActive { get; set; }
}The custom serializer writes a compact binary format using BinaryWriter (fixed-width fields; strings length-prefixed). The custom cloner copies all eight properties by value: the six value-type fields are bit-copied, and the two string fields are reference-copied — no new heap allocation occurs during cloning.
Workload
| Benchmark group | Operations per invocation | Pre-seeded dataset |
|---|---|---|
| Insert | 250,000 | 250,000 records |
| Update, delete | 2,500 | 100,000 records |
| Find | 2,500 | 250,000 records |
| Find (large volume) | 2,500 | 3,000,000 records |
Scenarios
Four configurations are benchmarked to isolate the impact of each optimization. Each row’s name shows what it builds on — note that scenarios 3 and 4 both branch from scenario 2, not from each other.
All four scenarios use durable storage: every insert and update is flushed to disk. MemDb also supports ephemeral-only modes that skip persistence entirely; write throughput in those configurations will be higher.
| # | Scenario | Serializer | Cloner | Index |
|---|---|---|---|---|
| 1 | Defaults | JSON (built-in) | JSON (round trip) | None |
| 2 | #1 + custom clone + serialize | IMemDbSerializer<T> | IMemDbCloner<T> | None |
| 3 | #2 + identity index | IMemDbSerializer<T> | IMemDbCloner<T> | Identity index |
| 4 | #2 + applied index | IMemDbSerializer<T> | IMemDbCloner<T> | Applied index |
Throughput
Single-threaded steady-state measurements.
Scenario 1 — Defaults
This is the zero-configuration baseline. JSON parse and serialize runs at every cache boundary — once on write, and again on every read through the round-trip cloner — and by-id operations also pay for a full sequential scan to locate the record.
| Operation | Mean | Op/s | Allocated |
|---|---|---|---|
| Insert (unencrypted) | 2.991 μs | 334,308 | 738 B |
| Insert (encrypted) | 4.229 μs | 236,479 | 1,345 B |
| Update by id | 236.329 μs | 4,231 | 866 B |
| Delete by id | 33.510 μs | 29,841 | 148 B |
| Find by id | 231.034 μs | 4,328 | 472 B |
| Find by predicate (full scan) | 478.521 μs | 2,089 | 448 B |
Scenario 2 — Custom clone + serialize
Replacing the JSON serializer with a compact binary format and the JSON round-trip cloner with a property-copy implementation removes parse and serialize cost from every operation. Update, delete, and find by id are still scan-based — the gain comes from eliminating JSON overhead, not the lookup.
| Operation | Mean | Op/s | Allocated |
|---|---|---|---|
| Insert (unencrypted) | 0.8663 μs | 1,154,324 | 770 B |
| Insert (encrypted) | 1.8730 μs | 533,917 | 1,266 B |
| Update by id | 160.492 μs | 6,231 | 898 B |
| Delete by id | 30.8396 μs | 32,425 | 148 B |
| Find by id | 161.433 μs | 6,195 | 168 B |
| Find by predicate (full scan) | 254.865 μs | 3,924 | 144 B |
Scenario 3 — Custom clone + serialize + identity index
The identity index’s impact on update, delete, and find by id is the most pronounced result on this page. Each of those operations must locate the record before acting on it, and the identity index reduces that step from a full sequential scan to a direct pointer lookup.
| Operation | Mean | Op/s | Allocated |
|---|---|---|---|
| Insert (unencrypted) | 0.8638 μs | 1,157,633 | 871 B |
| Insert (encrypted) | 1.8898 μs | 529,165 | 1,367 B |
| Update by id | 7.305 μs | 259,677 | 834 B |
| Delete by id | 4.664 μs | 343,464 | 84 B |
| Find by id | 0.048 μs | 20,800,550 | 104 B |
| Find by id (large volume) | 0.0464 μs | 20,341,066 | 104 B |
| Find by predicate (full scan) | 253.975 μs | 3,937 | 144 B |
Scenario 4 — Custom clone + serialize + applied index
The applied index does not accelerate update or delete by id — those remain scan-based — and adds maintenance overhead on every write, so insert, update, and delete throughput sits slightly below the no-index scenario.
| Operation | Mean | Op/s | Allocated |
|---|---|---|---|
| Insert (unencrypted) | 0.8599 μs | 1,162,863 | 810 B |
| Insert (encrypted) | 1.8166 μs | 550,481 | 1,306 B |
| Update by id (index assist) | 8.0782 μs | 123,789 | 1370 B |
| Delete by id (index assist) | 20.5449 μs | 48,674 | 620 B |
| Find by id (index assist) | 0.2061 μs | 4,852,571 | 736 B |
| Find large volume (index assist) | 0.1492 μs | 6,701,457 | 584 B |
| Find by predicate (full scan) | 269.765 μs | 1,543 | 144 B |
Memory footprint
Per-record overhead in the managed cache, measured as the delta in GC.GetTotalMemory(true) before and after inserting N records into an open database.
| Records | Cache delta | Per record |
|---|---|---|
| 250,000 | 48.2 MB | 192.77 B |
| 500,000 | 96.4 MB | 192.77 B |
| 1,000,000 | 192.8 MB | 192.78 B |
Per-record cost stabilizes at ~193 bytes for the benchmark record schema. The total process working set at 1,000,000 records was 263 MB, decomposing into ~193 MB of cached records plus ~70 MB of fixed overhead (CLR, JIT, MemDb internals, native buffers).
On-disk format
Pointer map overhead is exact and independent of record content:
| Component | Size |
|---|---|
.map file header | 12 bytes |
| Per-record pointer | 39 bytes |
.map total | 12 + (39 × record count) bytes |
Data file size depends on the encoding and whether encryption is enabled:
| Encoding | Avg record size | 1M records on disk |
|---|---|---|
| JSON (default) | 182 B | ~182 MB |
| JSON + AES-256 | 208 B | ~208 MB |
| Custom binary | 70 B | ~70 MB |
| Custom binary + AES-256 | 208 B | ~208 MB |
Cold start
Time from MemDb.Open to first successful read, measured with System.Diagnostics.Stopwatch.
| Records | Defaults | Custom clone + serialize | Custom clone + serialize + AES-256 |
|---|---|---|---|
| 25,000 | 73 ms | 21 ms | 66 ms |
| 100,000 | 231 ms | 110 ms | 291 ms |
| 500,000 | 870 ms | 379 ms | 958 ms |
| 1,000,000 | 1,799 ms | 786 ms | 1,869 ms |
| 10,000,000 | 18,391 ms | 8,105 ms | 19,976 ms |
The custom binary serializer roughly halves cold start time compared to the default JSON deserializer. Adding AES-256 decryption brings cold start back in line with the JSON baseline — every record must be decrypted on load, which offsets the gains from the faster binary format.
Reproducing these numbers
The benchmark harness lives in the MemDb repository, under benchmark/HatTrick.MemDb.Benchmarks. It’s a BenchmarkDotNet console project that consumes the HatTrick.MemDb NuGet package — no local build of MemDb is required.
git clone https://github.com/HatTrickLabs/mem-db.git
cd mem-db/benchmark/HatTrick.MemDb.Benchmarks
dotnet run -c ReleaseThe run executes, in order:
- Memory footprint — cache delta at increasing record counts
- Cold start —
MemDb.Opento first read, across record counts and serializer/encryption combinations - Throughput — the four scenarios above, via BenchmarkDotNet (
DefaultsThroughput,CustomCloneAndSerializeThroughput,CustomCloneAndSerializeIdentityIndexThroughput,CustomCloneAndSerializeAppliedIndexThroughput)
BenchmarkDotNet writes per-scenario reports (Markdown, HTML, CSV) to BenchmarkDotNet.Artifacts/results/. A full run — including the 10,000,000-record cold start passes — takes a while; expect it to run for several minutes.