diff --git a/src/NatsDistributedCache/CacheEntryBinarySerializer.cs b/src/NatsDistributedCache/CacheEntryBinarySerializer.cs new file mode 100644 index 0000000..d7f526b --- /dev/null +++ b/src/NatsDistributedCache/CacheEntryBinarySerializer.cs @@ -0,0 +1,134 @@ +using System.Buffers; +using System.Buffers.Binary; +using NATS.Client.Core; + +namespace CodeCargo.Nats.DistributedCache; + +/// +/// Serializes and deserializes using a compact binary framing that avoids the +/// base64 inflation and JSON overhead of the previous envelope. +/// +/// +/// Wire format (little-endian): +/// +/// [version:1][flags:1][absExpTicks:8?][sldExpTicks:8?][payload...] +/// +/// The 8-byte expiration fields are only present when their corresponding flag bit is set, so an entry +/// without expiration carries just a 2-byte header. Any buffer whose leading version byte does not match +/// — including pre-existing JSON entries, whose first byte is '{' (0x7B) — +/// deserializes to null and is treated as a cache miss. +/// +internal sealed class CacheEntryBinarySerializer : INatsSerialize, INatsDeserialize +{ + /// + /// Current on-wire format version. Bump when the framing changes so that entries written by an older + /// version are treated as cache misses instead of being misinterpreted. + /// + internal const byte FormatVersion = 1; + + private const byte HasAbsoluteExpiration = 0b0000_0001; + private const byte HasSlidingExpiration = 0b0000_0010; + private const byte KnownFlags = HasAbsoluteExpiration | HasSlidingExpiration; + + /// + /// Gets the shared, stateless serializer instance. + /// + public static CacheEntryBinarySerializer Default { get; } = new(); + + /// + public void Serialize(IBufferWriter bufferWriter, CacheEntry value) + { + var hasAbsolute = value.AbsoluteExpiration.HasValue; + var hasSliding = value.SlidingExpirationTicks.HasValue; + + var flags = (byte)0; + if (hasAbsolute) + { + flags |= HasAbsoluteExpiration; + } + + if (hasSliding) + { + flags |= HasSlidingExpiration; + } + + var headerLength = 2 + (hasAbsolute ? 8 : 0) + (hasSliding ? 8 : 0); + var header = bufferWriter.GetSpan(headerLength); + header[0] = FormatVersion; + header[1] = flags; + + var offset = 2; + if (hasAbsolute) + { + BinaryPrimitives.WriteInt64LittleEndian(header.Slice(offset), value.AbsoluteExpiration!.Value.UtcTicks); + offset += 8; + } + + if (hasSliding) + { + BinaryPrimitives.WriteInt64LittleEndian(header.Slice(offset), value.SlidingExpirationTicks!.Value); + offset += 8; + } + + bufferWriter.Advance(offset); + + if (value.Data is { Length: > 0 } data) + { + bufferWriter.Write(data); + } + } + + /// + public CacheEntry? Deserialize(in ReadOnlySequence buffer) + { + var reader = new SequenceReader(buffer); + + if (!reader.TryRead(out var version) || version != FormatVersion) + { + // Unknown or legacy (e.g. JSON) entry: treat as a cache miss. + return null; + } + + if (!reader.TryRead(out var flags) || (flags & ~KnownFlags) != 0) + { + // Unknown flag bits: corrupt data, or a future format that mistakenly reused this version. + // Fail closed rather than misinterpreting the remaining bytes. + return null; + } + + DateTimeOffset? absoluteExpiration = null; + if ((flags & HasAbsoluteExpiration) != 0) + { + if (!reader.TryReadLittleEndian(out long absoluteTicks) || + absoluteTicks < DateTimeOffset.MinValue.Ticks || + absoluteTicks > DateTimeOffset.MaxValue.Ticks) + { + // Missing or out-of-range ticks (corrupt entry): treat as a miss instead of throwing. + return null; + } + + absoluteExpiration = new DateTimeOffset(absoluteTicks, TimeSpan.Zero); + } + + long? slidingExpirationTicks = null; + if ((flags & HasSlidingExpiration) != 0) + { + if (!reader.TryReadLittleEndian(out long slidingTicks)) + { + return null; + } + + slidingExpirationTicks = slidingTicks; + } + + var remaining = reader.UnreadSequence; + var data = remaining.IsEmpty ? Array.Empty() : remaining.ToArray(); + + return new CacheEntry + { + AbsoluteExpiration = absoluteExpiration, + SlidingExpirationTicks = slidingExpirationTicks, + Data = data, + }; + } +} diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 348d305..beb86d1 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -1,5 +1,4 @@ using System.Buffers; -using System.Text.Json.Serialization; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -15,32 +14,20 @@ namespace CodeCargo.Nats.DistributedCache; /// public class CacheEntry { - [JsonPropertyName("absexp")] public DateTimeOffset? AbsoluteExpiration { get; set; } - [JsonPropertyName("sldexp")] public long? SlidingExpirationTicks { get; set; } - [JsonPropertyName("data")] public byte[]? Data { get; set; } } -/// -/// JsonSerializerContext for CacheEntry -/// -[JsonSerializable(typeof(CacheEntry))] -public partial class CacheEntryJsonContext : JsonSerializerContext -{ -} - /// /// Distributed cache implementation using NATS Key-Value Store. /// public partial class NatsCache : IBufferDistributedCache { - // Static JSON serializer for CacheEntry - private static readonly NatsJsonContextSerializer CacheEntrySerializer = - new(CacheEntryJsonContext.Default); + // Compact binary serializer for the CacheEntry envelope (replaces the previous JSON+base64 format). + private static readonly CacheEntryBinarySerializer CacheEntrySerializer = CacheEntryBinarySerializer.Default; private readonly string _bucketName; private readonly INatsCacheKeyEncoder _keyEncoder; diff --git a/src/NatsDistributedCache/NatsDistributedCache.csproj b/src/NatsDistributedCache/NatsDistributedCache.csproj index c2af08b..02772af 100644 --- a/src/NatsDistributedCache/NatsDistributedCache.csproj +++ b/src/NatsDistributedCache/NatsDistributedCache.csproj @@ -47,6 +47,7 @@ .snk into keys/ locally flips the build to signed and will break the UnitTests compile. --> + diff --git a/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs b/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs index b3dd429..b1f4e9f 100644 --- a/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs +++ b/test/IntegrationTests/Cache/HybridCacheSetAndRemoveTests.cs @@ -70,12 +70,13 @@ public async Task HybridCacheSerializesDateTime() var serializedDateString = Encoding.ASCII.GetString(writer.WrittenSpan.ToArray()); var kvStore = await NatsConnection.CreateKeyValueStoreContext().GetStoreAsync("cache"); - NatsJsonContextSerializer cacheEntrySerializer = new(CacheEntryJsonContext.Default); - var kvEntry = await kvStore.GetEntryAsync(key, serializer: cacheEntrySerializer); - Assert.NotNull(kvEntry.Value?.Data); - var storedDateString = Encoding.ASCII.GetString(kvEntry.Value.Data); + var kvEntry = await kvStore.GetEntryAsync(key); + Assert.NotNull(kvEntry.Value); + var storedDateString = Encoding.ASCII.GetString(kvEntry.Value); - // HybridCache adds additional data to the front of the serialized value, so we're matching only the relevant data + // The compact binary envelope stores the payload raw (no base64/JSON), so the HybridCache-serialized + // value appears verbatim in the stored bytes. HybridCache also adds framing of its own to the front, + // so we match only the relevant portion. Assert.Contains(serializedDateString, storedDateString); // Assert - date is deserialized as expected diff --git a/test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs b/test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs new file mode 100644 index 0000000..13022a4 --- /dev/null +++ b/test/UnitTests/Serialization/CacheEntryBinarySerializerTests.cs @@ -0,0 +1,226 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Text; + +namespace CodeCargo.Nats.DistributedCache.UnitTests.Serialization; + +/// +/// Tests for the compact binary serializer. +/// +public class CacheEntryBinarySerializerTests +{ + private static readonly DateTimeOffset SampleAbsolute = + new(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + + private static readonly long SampleSlidingTicks = TimeSpan.FromMinutes(10).Ticks; + + private readonly CacheEntryBinarySerializer _serializer = CacheEntryBinarySerializer.Default; + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void RoundTrips_AllExpirationCombinations(bool hasAbsolute, bool hasSliding) + { + var entry = new CacheEntry + { + AbsoluteExpiration = hasAbsolute ? SampleAbsolute : null, + SlidingExpirationTicks = hasSliding ? SampleSlidingTicks : null, + Data = [1, 2, 3, 4, 5], + }; + + var result = Deserialize(Serialize(entry)); + + Assert.NotNull(result); + Assert.Equal(entry.AbsoluteExpiration, result.AbsoluteExpiration); + Assert.Equal(entry.SlidingExpirationTicks, result.SlidingExpirationTicks); + Assert.Equal(entry.Data, result.Data); + } + + [Fact] + public void RoundTrips_EmptyData() + { + var entry = new CacheEntry { Data = [] }; + + var result = Deserialize(Serialize(entry)); + + Assert.NotNull(result); + Assert.NotNull(result.Data); + Assert.Empty(result.Data); + } + + [Fact] + public void RoundTrips_LargePayload() + { + var payload = new byte[64 * 1024]; + for (var i = 0; i < payload.Length; i++) + { + payload[i] = (byte)(i & 0xFF); + } + + var entry = new CacheEntry + { + AbsoluteExpiration = SampleAbsolute, + SlidingExpirationTicks = SampleSlidingTicks, + Data = payload, + }; + + var result = Deserialize(Serialize(entry)); + + Assert.NotNull(result); + Assert.Equal(payload, result.Data); + } + + [Fact] + public void Serialize_WritesVersionedHeaderAndRawPayload() + { + var entry = new CacheEntry + { + AbsoluteExpiration = SampleAbsolute, + SlidingExpirationTicks = SampleSlidingTicks, + Data = [10, 20, 30], + }; + + var bytes = Serialize(entry); + + // [version:1][flags:1][absTicks:8][sldTicks:8][payload:3] + Assert.Equal(2 + 8 + 8 + 3, bytes.Length); + Assert.Equal(CacheEntryBinarySerializer.FormatVersion, bytes[0]); + Assert.Equal(0b0000_0011, bytes[1]); + Assert.Equal(SampleAbsolute.UtcTicks, BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(2, 8))); + Assert.Equal(SampleSlidingTicks, BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(10, 8))); + Assert.Equal(new byte[] { 10, 20, 30 }, bytes[18..]); + } + + [Fact] + public void Serialize_WithoutExpiration_WritesTwoByteHeader() + { + var entry = new CacheEntry { Data = [42] }; + + var bytes = Serialize(entry); + + Assert.Equal(3, bytes.Length); + Assert.Equal(CacheEntryBinarySerializer.FormatVersion, bytes[0]); + Assert.Equal(0, bytes[1]); + Assert.Equal(42, bytes[2]); + } + + [Fact] + public void Deserialize_WrongVersion_ReturnsNull() + { + var bytes = Serialize(new CacheEntry { Data = [1, 2, 3] }); + bytes[0] = (byte)(CacheEntryBinarySerializer.FormatVersion + 1); + + Assert.Null(Deserialize(bytes)); + } + + [Fact] + public void Deserialize_LegacyJsonBytes_ReturnsNull() + { + // Old envelope format: a JSON object whose first byte is '{' (0x7B). + var legacy = Encoding.UTF8.GetBytes("{\"absexp\":null,\"sldexp\":null,\"data\":\"AQID\"}"); + + Assert.Null(Deserialize(legacy)); + } + + [Fact] + public void Deserialize_EmptyBuffer_ReturnsNull() => + Assert.Null(Deserialize([])); + + [Fact] + public void Deserialize_TruncatedExpirationField_ReturnsNull() + { + // Header claims an absolute expiration but only supplies 4 of the required 8 bytes. + byte[] truncated = [CacheEntryBinarySerializer.FormatVersion, 0b0000_0001, 1, 2, 3, 4]; + + Assert.Null(Deserialize(truncated)); + } + + [Fact] + public void Deserialize_UnknownFlagBits_ReturnsNull() + { + // A v1 entry with an undefined flag bit set (0b100) must fail closed rather than treat the + // remaining bytes as payload. + byte[] bytes = [CacheEntryBinarySerializer.FormatVersion, 0b0000_0100, 1, 2, 3]; + + Assert.Null(Deserialize(bytes)); + } + + [Fact] + public void Deserialize_OutOfRangeAbsoluteTicks_ReturnsNull() + { + // flags = HasAbsoluteExpiration, followed by a tick value beyond DateTimeOffset's range. + var bytes = new byte[2 + 8]; + bytes[0] = CacheEntryBinarySerializer.FormatVersion; + bytes[1] = 0b0000_0001; + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(2), long.MaxValue); + + Assert.Null(Deserialize(bytes)); + } + + [Fact] + public void Deserialize_MultiSegmentSequence_RoundTrips() + { + var entry = new CacheEntry + { + AbsoluteExpiration = SampleAbsolute, + SlidingExpirationTicks = SampleSlidingTicks, + Data = [7, 8, 9, 10], + }; + var bytes = Serialize(entry); + + // Split mid-header so the reader must cross a segment boundary. + var sequence = CreateSegmented(bytes, splitAt: 5); + var result = _serializer.Deserialize(sequence); + + Assert.NotNull(result); + Assert.Equal(entry.AbsoluteExpiration, result.AbsoluteExpiration); + Assert.Equal(entry.SlidingExpirationTicks, result.SlidingExpirationTicks); + Assert.Equal(entry.Data, result.Data); + } + + [Fact] + public void AbsoluteExpiration_RoundTripsAsUtcInstant() + { + // A non-UTC offset must survive as the same instant, normalized to UTC. + var local = new DateTimeOffset(2030, 6, 1, 12, 0, 0, TimeSpan.FromHours(-5)); + var entry = new CacheEntry { AbsoluteExpiration = local, Data = [1] }; + + var result = Deserialize(Serialize(entry)); + + Assert.NotNull(result); + Assert.Equal(local.UtcTicks, result.AbsoluteExpiration!.Value.UtcTicks); + Assert.Equal(TimeSpan.Zero, result.AbsoluteExpiration.Value.Offset); + Assert.Equal(local.UtcDateTime, result.AbsoluteExpiration.Value.UtcDateTime); + } + + private static ReadOnlySequence CreateSegmented(byte[] data, int splitAt) + { + var first = new BufferSegment(data.AsMemory(0, splitAt)); + var second = first.Append(data.AsMemory(splitAt)); + return new ReadOnlySequence(first, 0, second, second.Memory.Length); + } + + private byte[] Serialize(CacheEntry entry) + { + var writer = new ArrayBufferWriter(); + _serializer.Serialize(writer, entry); + return writer.WrittenMemory.ToArray(); + } + + private CacheEntry? Deserialize(byte[] bytes) => + _serializer.Deserialize(new ReadOnlySequence(bytes)); + + private sealed class BufferSegment : ReadOnlySequenceSegment + { + public BufferSegment(ReadOnlyMemory memory) => Memory = memory; + + public BufferSegment Append(ReadOnlyMemory memory) + { + var segment = new BufferSegment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = segment; + return segment; + } + } +} diff --git a/util/Benchmarks/BenchmarkData.cs b/util/Benchmarks/BenchmarkData.cs index 251012a..be834cc 100644 --- a/util/Benchmarks/BenchmarkData.cs +++ b/util/Benchmarks/BenchmarkData.cs @@ -33,7 +33,7 @@ public static byte[] CreatePayload(int size) } /// - /// Builds a JSON envelope with a payload of the requested size. + /// Builds a legacy JSON envelope with a payload of the requested size. /// /// Payload size in bytes. /// The populated . @@ -43,4 +43,16 @@ public static byte[] CreatePayload(int size) SlidingExpirationTicks = SlidingExpirationTicks, Data = CreatePayload(size), }; + + /// + /// Builds a binary-envelope with a payload of the requested size. + /// + /// Payload size in bytes. + /// The populated . + public static CacheEntry CreateBinaryEntry(int size) => new() + { + AbsoluteExpiration = AbsoluteExpiration, + SlidingExpirationTicks = SlidingExpirationTicks, + Data = CreatePayload(size), + }; } diff --git a/util/Benchmarks/CacheEntrySerializationBenchmarks.cs b/util/Benchmarks/CacheEntrySerializationBenchmarks.cs index 2c5db6e..405a9be 100644 --- a/util/Benchmarks/CacheEntrySerializationBenchmarks.cs +++ b/util/Benchmarks/CacheEntrySerializationBenchmarks.cs @@ -5,10 +5,9 @@ namespace CodeCargo.Nats.DistributedCache.Benchmarks; /// -/// 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. -/// MemoryDiagnoser reports per-operation allocations; the --sizes report -/// (see ) shows the stored byte counts. +/// Compares the legacy JSON+base64 envelope against the compact binary framing for serializing and +/// deserializing a . MemoryDiagnoser reports the per-operation +/// allocations; the --sizes report (see ) shows the stored byte counts. /// [MemoryDiagnoser] public class CacheEntrySerializationBenchmarks @@ -16,10 +15,14 @@ public class CacheEntrySerializationBenchmarks private static readonly NatsJsonContextSerializer JsonEnvelopeSerializer = new(BenchmarkJsonContext.Default); + private static readonly CacheEntryBinarySerializer BinaryEnvelopeSerializer = CacheEntryBinarySerializer.Default; + private readonly ArrayBufferWriter _writer = new(); private JsonCacheEntry _jsonEntry = null!; + private CacheEntry _binaryEntry = null!; private ReadOnlySequence _jsonBytes; + private ReadOnlySequence _binaryBytes; /// /// Gets or sets the size, in bytes, of the cached payload under test. @@ -28,20 +31,22 @@ public class CacheEntrySerializationBenchmarks public int PayloadSize { get; set; } /// - /// Builds the sample entry and pre-serialized buffer used by the benchmarks. + /// Builds the sample entries and pre-serialized buffers used by the benchmarks. /// [GlobalSetup] public void Setup() { _jsonEntry = BenchmarkData.CreateJsonEntry(PayloadSize); + _binaryEntry = BenchmarkData.CreateBinaryEntry(PayloadSize); _jsonBytes = ToSequence(JsonEnvelopeSerializer, _jsonEntry); + _binaryBytes = ToSequence(BinaryEnvelopeSerializer, _binaryEntry); } /// - /// Serializes the entry using the JSON envelope. + /// Serializes the entry using the legacy JSON envelope (baseline). /// /// The number of bytes written. - [Benchmark] + [Benchmark(Baseline = true)] public int Json_Serialize() { _writer.Clear(); @@ -50,12 +55,31 @@ public int Json_Serialize() } /// - /// Deserializes a JSON envelope. + /// Serializes the entry using the compact binary framing. + /// + /// The number of bytes written. + [Benchmark] + public int Binary_Serialize() + { + _writer.Clear(); + BinaryEnvelopeSerializer.Serialize(_writer, _binaryEntry); + return _writer.WrittenCount; + } + + /// + /// Deserializes a JSON envelope (baseline read path). /// /// The decoded entry. [Benchmark] public JsonCacheEntry? Json_Deserialize() => JsonEnvelopeSerializer.Deserialize(_jsonBytes); + /// + /// Deserializes a binary envelope. + /// + /// The decoded entry. + [Benchmark] + public CacheEntry? Binary_Deserialize() => BinaryEnvelopeSerializer.Deserialize(_binaryBytes); + private static ReadOnlySequence ToSequence(INatsSerialize serializer, T value) { var writer = new ArrayBufferWriter(); diff --git a/util/Benchmarks/README.md b/util/Benchmarks/README.md index 5e9ce0e..16396af 100644 --- a/util/Benchmarks/README.md +++ b/util/Benchmarks/README.md @@ -1,11 +1,10 @@ # 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. +Micro-benchmarks comparing the cache envelope formats used by `NatsCache`: -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. +- **JSON** — the legacy `System.Text.Json` envelope (payload base64-encoded inside a JSON object). +- **Binary** — the compact binary framing introduced in issue #37 + (`[version:1][flags:1][absExpTicks:8?][sldExpTicks:8?][payload...]`). These are in-memory serialize/deserialize benchmarks — **no NATS server or Docker required**. @@ -35,9 +34,11 @@ dotnet run -c Release --project util/Benchmarks -f net10.0 -- --filter '*_Serial ## 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. +`CacheEntrySerializationBenchmarks` runs `Json_Serialize` / `Binary_Serialize` and +`Json_Deserialize` / `Binary_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), with `Json_Serialize` as the baseline. `MemoryDiagnoser` reports `Allocated` and `Gen0`; the +`--sizes` report shows the stored byte counts and the binary/JSON ratio. -The JSON baseline is defined locally (`JsonCacheEntry`) so it stays fixed as the library evolves. +The JSON baseline is defined locally (`JsonCacheEntry`) so it stays fixed even though the library no +longer serializes with JSON. diff --git a/util/Benchmarks/SizeReport.cs b/util/Benchmarks/SizeReport.cs index ade64e4..5211c4c 100644 --- a/util/Benchmarks/SizeReport.cs +++ b/util/Benchmarks/SizeReport.cs @@ -4,29 +4,31 @@ namespace CodeCargo.Nats.DistributedCache.Benchmarks; /// -/// 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. +/// Prints the serialized envelope size (in bytes) of the JSON and binary formats for each payload size, +/// so the byte-size reduction can be inspected without running the full timing benchmarks. /// internal static class SizeReport { private static readonly int[] PayloadSizes = [128, 1024, 8192, 65536, 262144]; /// - /// Writes the size table to the console. + /// Writes the size-comparison table to the console. /// public static void Print() { var jsonSerializer = new NatsJsonContextSerializer(BenchmarkJsonContext.Default); + var binarySerializer = CacheEntryBinarySerializer.Default; Console.WriteLine("Stored envelope size (bytes) — absolute + sliding expiration set"); - Console.WriteLine($"{"Payload",10} {"JSON",10}"); - Console.WriteLine(new string('-', 22)); + Console.WriteLine($"{"Payload",10} {"JSON",10} {"Binary",10} {"Saved",10} {"Binary/JSON",12}"); + Console.WriteLine(new string('-', 56)); foreach (var size in PayloadSizes) { var jsonSize = Measure(jsonSerializer, BenchmarkData.CreateJsonEntry(size)); - Console.WriteLine($"{size,10} {jsonSize,10}"); + var binarySize = Measure(binarySerializer, BenchmarkData.CreateBinaryEntry(size)); + var ratio = (double)binarySize / jsonSize; + Console.WriteLine($"{size,10} {jsonSize,10} {binarySize,10} {jsonSize - binarySize,10} {ratio,12:P1}"); } }