Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: benchmark

# Manual-only: BenchmarkDotNet runs are too slow and CPU-noisy to gate every PR on. Trigger this on a
# branch to capture serialization numbers; results are printed in the job log.
on:
workflow_dispatch:
inputs:
filter:
description: "BenchmarkDotNet filter (e.g. '*Serialize*')"
required: false
default: "*"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
benchmark:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
actions: read
id-token: write
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
# Bind the workflow input to an env var so it is never interpolated into the run script text
# (avoids shell injection); the run steps reference it as a normal, quoted shell variable.
FILTER: ${{ inputs.filter }}

steps:
- name: CargoWall
uses: code-cargo/cargowall-action@c0bd989036354f795a5150d478fce5bd510586af # v1.3.2-rc.3
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Setup dotnet
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
cache: true
cache-dependency-path: '**/packages.linux-x64.lock.json'
dotnet-version: |
8.x
10.x

- name: Serialized size report
run: dotnet run -c Release --project util/Benchmarks -f net10.0 -- --sizes

- name: Benchmarks (net10.0)
run: dotnet run -c Release --project util/Benchmarks -f net10.0 -- --filter "${FILTER:-*}"

- name: Benchmarks (net8.0)
run: dotnet run -c Release --project util/Benchmarks -f net8.0 -- --filter "${FILTER:-*}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
[Bb]in/
[Oo]bj/

# BenchmarkDotNet run output
BenchmarkDotNet.Artifacts/

# User settings
*.DotSettings.user
*.sln.DotSettings
Expand Down
1 change: 1 addition & 0 deletions NatsDistributedCache.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<Project Path="test/UnitTests/UnitTests.csproj" />
</Folder>
<Folder Name="/util/">
<Project Path="util/Benchmarks/Benchmarks.csproj" />
<Project Path="util/NatsAppHost/NatsAppHost.csproj" />
<Project Path="util/PerfTest/PerfTest.csproj" />
<Project Path="util/ReadmeExample/ReadmeExample.csproj" />
Expand Down
46 changes: 46 additions & 0 deletions util/Benchmarks/BenchmarkData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace CodeCargo.Nats.DistributedCache.Benchmarks;

/// <summary>
/// Shared, deterministic sample data for the serialization benchmarks and the size report.
/// </summary>
internal static class BenchmarkData
{
/// <summary>
/// Gets a fixed absolute-expiration instant. A constant is used so the benchmark is reproducible and
/// does not depend on the wall clock.
/// </summary>
public static DateTimeOffset AbsoluteExpiration { get; } = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero);

/// <summary>
/// Gets the sliding-expiration window expressed in ticks.
/// </summary>
public static long SlidingExpirationTicks { get; } = TimeSpan.FromMinutes(10).Ticks;

/// <summary>
/// Builds a deterministic payload of the requested size.
/// </summary>
/// <param name="size">Payload size in bytes.</param>
/// <returns>The payload bytes.</returns>
public static byte[] CreatePayload(int size)
{
var payload = new byte[size];
for (var i = 0; i < payload.Length; i++)
{
payload[i] = (byte)(i & 0xFF);
}

return payload;
}

/// <summary>
/// Builds a JSON envelope with a payload of the requested size.
/// </summary>
/// <param name="size">Payload size in bytes.</param>
/// <returns>The populated <see cref="JsonCacheEntry"/>.</returns>
public static JsonCacheEntry CreateJsonEntry(int size) => new()
{
AbsoluteExpiration = AbsoluteExpiration,
SlidingExpirationTicks = SlidingExpirationTicks,
Data = CreatePayload(size),
};
}
17 changes: 17 additions & 0 deletions util/Benchmarks/Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<RootNamespace>CodeCargo.Nats.DistributedCache.Benchmarks</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\NatsDistributedCache\NatsDistributedCache.csproj" />
</ItemGroup>

</Project>
65 changes: 65 additions & 0 deletions util/Benchmarks/CacheEntrySerializationBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Buffers;
using BenchmarkDotNet.Attributes;
using NATS.Client.Core;

namespace CodeCargo.Nats.DistributedCache.Benchmarks;

/// <summary>
/// Baseline serialization benchmarks for the cache envelope. This measures the current
/// JSON+base64 envelope; issue #37 adds a compact binary format and the head-to-head comparison.
/// <c>MemoryDiagnoser</c> reports per-operation allocations; the <c>--sizes</c> report
/// (see <see cref="SizeReport"/>) shows the stored byte counts.
/// </summary>
[MemoryDiagnoser]
public class CacheEntrySerializationBenchmarks
{
private static readonly NatsJsonContextSerializer<JsonCacheEntry> JsonEnvelopeSerializer =
new(BenchmarkJsonContext.Default);

private readonly ArrayBufferWriter<byte> _writer = new();

private JsonCacheEntry _jsonEntry = null!;
private ReadOnlySequence<byte> _jsonBytes;

/// <summary>
/// Gets or sets the size, in bytes, of the cached payload under test.
/// </summary>
[Params(128, 1024, 8192, 65536, 262144)]
public int PayloadSize { get; set; }

/// <summary>
/// Builds the sample entry and pre-serialized buffer used by the benchmarks.
/// </summary>
[GlobalSetup]
public void Setup()
{
_jsonEntry = BenchmarkData.CreateJsonEntry(PayloadSize);
_jsonBytes = ToSequence(JsonEnvelopeSerializer, _jsonEntry);
}

/// <summary>
/// Serializes the entry using the JSON envelope.
/// </summary>
/// <returns>The number of bytes written.</returns>
[Benchmark]
public int Json_Serialize()
{
_writer.Clear();
JsonEnvelopeSerializer.Serialize(_writer, _jsonEntry);
return _writer.WrittenCount;
}

/// <summary>
/// Deserializes a JSON envelope.
/// </summary>
/// <returns>The decoded entry.</returns>
[Benchmark]
public JsonCacheEntry? Json_Deserialize() => JsonEnvelopeSerializer.Deserialize(_jsonBytes);

private static ReadOnlySequence<byte> ToSequence<T>(INatsSerialize<T> serializer, T value)
{
var writer = new ArrayBufferWriter<byte>();
serializer.Serialize(writer, value);
return new ReadOnlySequence<byte>(writer.WrittenMemory.ToArray());
}
}
28 changes: 28 additions & 0 deletions util/Benchmarks/JsonCacheEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;

namespace CodeCargo.Nats.DistributedCache.Benchmarks;

/// <summary>
/// Mirror of the legacy JSON cache envelope — property names and types identical to the pre-#37
/// <c>CacheEntry</c>. It lives in the benchmark project so the JSON baseline stays frozen regardless of
/// later changes to the production type.
/// </summary>
public sealed class JsonCacheEntry
{
[JsonPropertyName("absexp")]
public DateTimeOffset? AbsoluteExpiration { get; set; }

[JsonPropertyName("sldexp")]
public long? SlidingExpirationTicks { get; set; }

[JsonPropertyName("data")]
public byte[]? Data { get; set; }
}

/// <summary>
/// Source-generated JSON context for <see cref="JsonCacheEntry"/>, matching the production serializer setup.
/// </summary>
[JsonSerializable(typeof(JsonCacheEntry))]
public partial class BenchmarkJsonContext : JsonSerializerContext
{
}
12 changes: 12 additions & 0 deletions util/Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using BenchmarkDotNet.Running;
using CodeCargo.Nats.DistributedCache.Benchmarks;

// `--sizes` prints the serialized-size comparison table and exits; anything else is handed to
// BenchmarkDotNet (e.g. `--filter *Serialize*`).
if (args.Contains("--sizes"))
{
SizeReport.Print();
return;
}

BenchmarkSwitcher.FromAssembly(typeof(CacheEntrySerializationBenchmarks).Assembly).Run(args);
43 changes: 43 additions & 0 deletions util/Benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Benchmarks

Micro-benchmarks for the `NatsCache` value envelope — the framing that wraps every cached payload
together with its expiration metadata before it is stored in NATS KV.

Today this measures the **JSON** envelope (payload base64-encoded inside a JSON object), establishing a
baseline. Issue #37 replaces it with a compact binary framing and extends these benchmarks with a
`Binary` variant for a side-by-side comparison.

These are in-memory serialize/deserialize benchmarks — **no NATS server or Docker required**.

## Run

Serialized-size comparison (fast, deterministic):

```bash
dotnet run -c Release --project util/Benchmarks -f net10.0 -- --sizes
```

Timing + allocation benchmarks (BenchmarkDotNet, `MemoryDiagnoser`):

```bash
# net10.0
dotnet run -c Release --project util/Benchmarks -f net10.0 -- --filter '*'

# net8.0
dotnet run -c Release --project util/Benchmarks -f net8.0 -- --filter '*'
```

Filter to a subset, e.g. only serialize benchmarks:

```bash
dotnet run -c Release --project util/Benchmarks -f net10.0 -- --filter '*_Serialize'
```

## What is measured

`CacheEntrySerializationBenchmarks` runs `Json_Serialize` / `Json_Deserialize` across
`[Params(128, 1024, 8192, 65536, 262144)]` payload sizes (128 B up to 256 KiB — the practical range
for a NATS-backed cache, whose default `max_payload` is 1 MB). `MemoryDiagnoser` reports `Allocated`
and `Gen0`; the `--sizes` report shows the stored byte counts.

The JSON baseline is defined locally (`JsonCacheEntry`) so it stays fixed as the library evolves.
39 changes: 39 additions & 0 deletions util/Benchmarks/SizeReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Buffers;
using NATS.Client.Core;

namespace CodeCargo.Nats.DistributedCache.Benchmarks;

/// <summary>
/// Prints the serialized envelope size (in bytes) of the JSON format for each payload size, so the
/// stored size can be inspected without running the full timing benchmarks. Issue #37 extends this
/// with the binary format for a side-by-side comparison.
/// </summary>
internal static class SizeReport
{
private static readonly int[] PayloadSizes = [128, 1024, 8192, 65536, 262144];

/// <summary>
/// Writes the size table to the console.
/// </summary>
public static void Print()
{
var jsonSerializer = new NatsJsonContextSerializer<JsonCacheEntry>(BenchmarkJsonContext.Default);

Console.WriteLine("Stored envelope size (bytes) — absolute + sliding expiration set");
Console.WriteLine($"{"Payload",10} {"JSON",10}");
Console.WriteLine(new string('-', 22));

foreach (var size in PayloadSizes)
{
var jsonSize = Measure(jsonSerializer, BenchmarkData.CreateJsonEntry(size));
Console.WriteLine($"{size,10} {jsonSize,10}");
}
}

private static int Measure<T>(INatsSerialize<T> serializer, T value)
{
var writer = new ArrayBufferWriter<byte>();
serializer.Serialize(writer, value);
return writer.WrittenCount;
}
}
Loading
Loading