From cb71cb99789586ce7913227b67e7f6c1fe6c179e Mon Sep 17 00:00:00 2001 From: Sajay Antony Date: Tue, 31 Mar 2026 16:10:32 -0700 Subject: [PATCH 1/4] perf: optimize JSON serialization for OCI manifests - Add NeedsEscaping() fast-path to skip escape pass for clean strings - Replace StringBuilder with ArrayPool in EscapeJsonString - Use direct hex-digit lookup table instead of string interpolation - ASCII fast-path sort in OciDictionaryConverter (ordinal == UTF-8) - Pre-encode UTF-8 keys once for non-ASCII slow path sort - Replace DeserializeAsync with sync Deserialize for buffered streams Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Sajay Antony --- .../Registry/Remote/ManifestStore.cs | 59 ++++-- .../Serialization/OciDictionaryConverter.cs | 121 +++++++++++-- .../Serialization/OciJsonSerializer.cs | 171 ++++++++++++------ .../Serialization/OciStringConverter.cs | 7 +- 4 files changed, 270 insertions(+), 88 deletions(-) diff --git a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs index ee0edffc..2422bbe2 100644 --- a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs +++ b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs @@ -256,14 +256,29 @@ private async Task PushWithIndexingAsync(Descriptor expected, Stream content, Re /// private async Task ProcessReferrersAndPushIndex(Descriptor desc, Stream content, CancellationToken cancellationToken = default) { + // Buffer stream for sync deserialization — avoids async + // state machine overhead for already-buffered data. + byte[] contentBytes; + if (content is MemoryStream ms) + { + contentBytes = ms.ToArray(); + } + else + { + using var buf = new MemoryStream(); + await content.CopyToAsync(buf, cancellationToken) + .ConfigureAwait(false); + contentBytes = buf.ToArray(); + } + Descriptor? subject; switch (desc.MediaType) { case MediaType.ImageIndex: - var indexManifest = await OciJsonSerializer - .DeserializeAsync(content, cancellationToken) - .ConfigureAwait(false) - ?? throw new JsonException("Failed to deserialize index"); + var indexManifest = + OciJsonSerializer.Deserialize(contentBytes) + ?? throw new JsonException( + "Failed to deserialize index"); if (indexManifest.Subject == null) { return; @@ -273,10 +288,11 @@ private async Task ProcessReferrersAndPushIndex(Descriptor desc, Stream content, desc.Annotations = indexManifest.Annotations; break; case MediaType.ImageManifest: - var imageManifest = await OciJsonSerializer - .DeserializeAsync(content, cancellationToken) - .ConfigureAwait(false) - ?? throw new JsonException("Failed to deserialize manifest"); + var imageManifest = + OciJsonSerializer.Deserialize( + contentBytes) + ?? throw new JsonException( + "Failed to deserialize manifest"); if (imageManifest.Subject == null) { return; @@ -480,13 +496,28 @@ private async Task DeleteWithIndexing(Descriptor target, CancellationToken cance /// private async Task IndexReferrersForDelete(Descriptor target, Stream manifestContent, CancellationToken cancellationToken = default) { + // Buffer stream for sync deserialization — avoids async + // state machine overhead for already-buffered data. + byte[] manifestBytes; + if (manifestContent is MemoryStream ms) + { + manifestBytes = ms.ToArray(); + } + else + { + using var buf = new MemoryStream(); + await manifestContent.CopyToAsync( + buf, cancellationToken).ConfigureAwait(false); + manifestBytes = buf.ToArray(); + } + Descriptor subject; switch (target.MediaType) { case MediaType.ImageManifest: - var imageManifest = await OciJsonSerializer - .DeserializeAsync(manifestContent, cancellationToken) - .ConfigureAwait(false); + var imageManifest = + OciJsonSerializer.Deserialize( + manifestBytes); if (imageManifest?.Subject == null) { // no subject, no indexing needed @@ -495,9 +526,9 @@ private async Task IndexReferrersForDelete(Descriptor target, Stream manifestCon subject = imageManifest.Subject; break; case MediaType.ImageIndex: - var imageIndex = await OciJsonSerializer - .DeserializeAsync(manifestContent, cancellationToken) - .ConfigureAwait(false); + var imageIndex = + OciJsonSerializer.Deserialize( + manifestBytes); if (imageIndex?.Subject == null) { // no subject, no indexing needed diff --git a/src/OrasProject.Oras/Serialization/OciDictionaryConverter.cs b/src/OrasProject.Oras/Serialization/OciDictionaryConverter.cs index 48f82829..a300358c 100644 --- a/src/OrasProject.Oras/Serialization/OciDictionaryConverter.cs +++ b/src/OrasProject.Oras/Serialization/OciDictionaryConverter.cs @@ -12,6 +12,7 @@ // limitations under the License. using System; +using System.Buffers; using System.Collections.Generic; using System.Linq; using System.Text; @@ -29,18 +30,6 @@ namespace OrasProject.Oras.Serialization; internal sealed class OciDictionaryConverter : JsonConverter> { - // Sorts keys by UTF-8 byte sequence to match Go's encoding/json.Marshal, - // which sorts map keys using Go string comparison (bytewise over UTF-8). - // StringComparer.Ordinal compares UTF-16 code units, which diverges from - // UTF-8 byte order for non-BMP characters (surrogate pairs). - private static readonly Comparison> - Utf8KeyComparison = (a, b) => - { - var aBytes = Encoding.UTF8.GetBytes(a.Key); - var bBytes = Encoding.UTF8.GetBytes(b.Key); - return aBytes.AsSpan().SequenceCompareTo(bBytes); - }; - // Token ordering (PropertyName after StartObject, no truncation) // is enforced by Utf8JsonReader before this method is called. // Only the value type check is needed — annotations must be strings. @@ -81,7 +70,7 @@ public override void Write( sb.Append('{'); var first = true; var sorted = value.ToList(); - sorted.Sort(Utf8KeyComparison); + SortByUtf8Key(sorted); foreach (var kvp in sorted) { if (!first) @@ -90,12 +79,114 @@ public override void Write( } first = false; sb.Append('"'); - sb.Append(OciJsonSerializer.EscapeJsonString(kvp.Key)); + sb.Append(OciJsonSerializer.NeedsEscaping(kvp.Key) + ? OciJsonSerializer.EscapeJsonString(kvp.Key) + : kvp.Key); sb.Append("\":\""); - sb.Append(OciJsonSerializer.EscapeJsonString(kvp.Value)); + sb.Append( + OciJsonSerializer.NeedsEscaping(kvp.Value) + ? OciJsonSerializer.EscapeJsonString( + kvp.Value) + : kvp.Value); sb.Append('"'); } sb.Append('}'); writer.WriteRawValue(sb.ToString()); } + + /// + /// Sorts key-value pairs by UTF-8 byte order to match + /// Go's encoding/json.Marshal (bytewise over UTF-8). + /// Fast path: when all keys are ASCII, ordinal comparison + /// is equivalent (zero extra allocations). + /// Slow path: pre-encodes all keys once into a single + /// pooled buffer, then sorts via index indirection — + /// O(N) encoding instead of O(N log N). + /// + private static void SortByUtf8Key( + List> items) + { + if (items.Count <= 1) return; + + if (AllKeysAscii(items)) + { + // ASCII: UTF-16 ordinal == UTF-8 bytewise + // when all code points are < 0x80. + items.Sort(static (a, b) => + string.CompareOrdinal(a.Key, b.Key)); + return; + } + + SortByPreEncodedUtf8Key(items); + } + + private static bool AllKeysAscii( + List> items) + { + foreach (var kvp in items) + { + foreach (var c in kvp.Key) + { + if (c >= 0x80) return false; + } + } + return true; + } + + /// + /// Pre-encodes all keys into a single ArrayPool buffer, + /// sorts an index array by UTF-8 byte comparison, then + /// reorders items to match. + /// + private static void SortByPreEncodedUtf8Key( + List> items) + { + var count = items.Count; + var offsets = new int[count]; + var lengths = new int[count]; + var totalBytes = 0; + for (var i = 0; i < count; i++) + { + offsets[i] = totalBytes; + lengths[i] = Encoding.UTF8 + .GetByteCount(items[i].Key); + totalBytes += lengths[i]; + } + + var buffer = ArrayPool.Shared + .Rent(Math.Max(totalBytes, 1)); + try + { + for (var i = 0; i < count; i++) + { + Encoding.UTF8.GetBytes( + items[i].Key, + buffer.AsSpan( + offsets[i], lengths[i])); + } + + var indices = new int[count]; + for (var i = 0; i < count; i++) + indices[i] = i; + + Array.Sort(indices, (x, y) => + buffer + .AsSpan(offsets[x], lengths[x]) + .SequenceCompareTo( + buffer.AsSpan( + offsets[y], + lengths[y]))); + + var temp = + new KeyValuePair[count]; + for (var i = 0; i < count; i++) + temp[i] = items[indices[i]]; + for (var i = 0; i < count; i++) + items[i] = temp[i]; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } } diff --git a/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs b/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs index 590d302c..2face278 100644 --- a/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs +++ b/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs @@ -11,6 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; +using System.Buffers; using System.IO; using System.Text; using System.Text.Json; @@ -88,6 +90,29 @@ internal static string FormatErrorDetail(JsonElement detail) return JsonSerializer.Serialize(detail, s_options); } + /// + /// Returns true if the string contains any character that + /// would escape. Used to skip the + /// full escape pass for the common case of simple ASCII strings + /// (digests, media types, tags). + /// + internal static bool NeedsEscaping(string value) + { + foreach (var ch in value) + { + if (ch <= '\u001F' || ch == '"' || ch == '\\' || + ch == '<' || ch == '>' || ch == '&' || + ch == '\u2028' || ch == '\u2029') + { + return true; + } + } + return false; + } + + private static ReadOnlySpan HexDigits => + "0123456789abcdef"u8; + /// /// Escapes a JSON string value. /// Escapes: ", \, control chars, <, >, &, U+2028, U+2029. @@ -96,67 +121,99 @@ internal static string FormatErrorDetail(JsonElement detail) /// internal static string EscapeJsonString(string value) { - var sb = new StringBuilder(value.Length); - foreach (var ch in value) + // Fast path: if no chars need escaping, return as-is. + if (!NeedsEscaping(value)) + { + return value; + } + + // Worst case: every char becomes 6 chars (\uXXXX). + var maxLen = value.Length * 6; + var pooled = ArrayPool.Shared.Rent(maxLen); + try { - switch (ch) + var pos = 0; + foreach (var ch in value) { - // JSON structural characters - case '"': - sb.Append("\\\""); - break; - case '\\': - sb.Append("\\\\"); - break; - // Named control character escapes - case '\b': - sb.Append("\\b"); - break; - case '\f': - sb.Append("\\f"); - break; - case '\n': - sb.Append("\\n"); - break; - case '\r': - sb.Append("\\r"); - break; - case '\t': - sb.Append("\\t"); - break; - // HTML-sensitive characters (Go escapes these) - case '<': - sb.Append("\\u003c"); - break; - case '>': - sb.Append("\\u003e"); - break; - case '&': - sb.Append("\\u0026"); - break; - // Unicode line/paragraph separators (Go escapes these) - case '\u2028': - sb.Append("\\u2028"); - break; - case '\u2029': - sb.Append("\\u2029"); - break; - default: - // Escape remaining control chars (U+0000–U+001F) - // as \uXXXX; pass all other characters through - // literally, including '+'. - if (ch <= '\u001F') - { - sb.Append($"\\u{(int)ch:x4}"); - } - else - { - sb.Append(ch); - } - break; + switch (ch) + { + case '"': + pooled[pos++] = '\\'; + pooled[pos++] = '"'; + break; + case '\\': + pooled[pos++] = '\\'; + pooled[pos++] = '\\'; + break; + case '\b': + pooled[pos++] = '\\'; + pooled[pos++] = 'b'; + break; + case '\f': + pooled[pos++] = '\\'; + pooled[pos++] = 'f'; + break; + case '\n': + pooled[pos++] = '\\'; + pooled[pos++] = 'n'; + break; + case '\r': + pooled[pos++] = '\\'; + pooled[pos++] = 'r'; + break; + case '\t': + pooled[pos++] = '\\'; + pooled[pos++] = 't'; + break; + case '<': + WriteHexEscape(pooled, ref pos, ch); + break; + case '>': + WriteHexEscape(pooled, ref pos, ch); + break; + case '&': + WriteHexEscape(pooled, ref pos, ch); + break; + case '\u2028': + WriteHexEscape(pooled, ref pos, ch); + break; + case '\u2029': + WriteHexEscape(pooled, ref pos, ch); + break; + default: + if (ch <= '\u001F') + { + WriteHexEscape(pooled, ref pos, ch); + } + else + { + pooled[pos++] = ch; + } + break; + } } + return new string(pooled, 0, pos); + } + finally + { + ArrayPool.Shared.Return(pooled); } - return sb.ToString(); + } + + /// + /// Writes a \uXXXX hex escape sequence into the buffer at + /// the current position. + /// + private static void WriteHexEscape( + char[] buffer, ref int pos, char ch) + { + var hex = HexDigits; + buffer[pos++] = '\\'; + buffer[pos++] = 'u'; + buffer[pos++] = (char)hex[(ch >> 12) & 0xF]; + buffer[pos++] = (char)hex[(ch >> 8) & 0xF]; + buffer[pos++] = (char)hex[(ch >> 4) & 0xF]; + buffer[pos++] = (char)hex[ch & 0xF]; } private static JsonSerializerOptions CreateOptions() diff --git a/src/OrasProject.Oras/Serialization/OciStringConverter.cs b/src/OrasProject.Oras/Serialization/OciStringConverter.cs index 0f282f3a..dec09367 100644 --- a/src/OrasProject.Oras/Serialization/OciStringConverter.cs +++ b/src/OrasProject.Oras/Serialization/OciStringConverter.cs @@ -36,7 +36,10 @@ public override void Write( string value, JsonSerializerOptions options) { - var escaped = OciJsonSerializer.EscapeJsonString(value); - writer.WriteRawValue($"\"{escaped}\""); + if (OciJsonSerializer.NeedsEscaping(value)) + { + value = OciJsonSerializer.EscapeJsonString(value); + } + writer.WriteRawValue(string.Concat("\"", value, "\"")); } } From 6fbea9122043b09aec67f61f634c8f6c491350a0 Mon Sep 17 00:00:00 2001 From: Sajay Antony Date: Tue, 31 Mar 2026 16:47:36 -0700 Subject: [PATCH 2/4] test: add serialization benchmark project BenchmarkDotNet-based performance tests for OCI manifest serialization and deserialization paths. Covers serialize, sync deserialize, and async deserialize with varying annotation counts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Sajay Antony --- benchmarks/SerializationBenchmark.cs | 349 +++++++++++++++++++++++ benchmarks/SerializationBenchmark.csproj | 17 ++ 2 files changed, 366 insertions(+) create mode 100644 benchmarks/SerializationBenchmark.cs create mode 100644 benchmarks/SerializationBenchmark.csproj diff --git a/benchmarks/SerializationBenchmark.cs b/benchmarks/SerializationBenchmark.cs new file mode 100644 index 00000000..70cf9706 --- /dev/null +++ b/benchmarks/SerializationBenchmark.cs @@ -0,0 +1,349 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; +using OrasProject.Oras.Oci; +using Index = OrasProject.Oras.Oci.Index; + +// ── Configuration ────────────────────────────────────────── +const int warmupIterations = 100; +const int iterations = 5000; + +// ── Build test manifests ─────────────────────────────────── +var smallAnnotations = new Dictionary +{ + ["org.opencontainers.image.title"] = "hello.txt", + ["org.opencontainers.image.created"] = "2026-01-15T10:30:00Z" +}; + +var annotationsWithPlus = new Dictionary +{ + ["org.opencontainers.image.title"] = "hello.txt", + ["org.opencontainers.image.ref.name"] = + "application/vnd.oci.image.manifest.v1+json", + ["org.example.custom+type"] = "value+with+plus", + ["org.example.html"] = "
&content
", + ["org.example.unicode"] = "line\u2028separator" +}; + +var largeAnnotations = new Dictionary(); +for (int i = 0; i < 50; i++) +{ + largeAnnotations[$"org.example.key{i}+annotation"] = + $"value-{i}-with+plus-and-&special-chars"; +} + +var manifest = new Manifest +{ + SchemaVersion = 2, + MediaType = OrasProject.Oras.Oci.MediaType.ImageManifest, + ArtifactType = "application/vnd.example.artifact+type", + Config = new Descriptor + { + MediaType = OrasProject.Oras.Oci.MediaType.EmptyJson, + Digest = + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = 2 + }, + Layers = new List + { + new() + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = + "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + Size = 1024, + Annotations = smallAnnotations + } + }, + Annotations = annotationsWithPlus +}; + +var manifestLargeAnnotations = new Manifest +{ + SchemaVersion = 2, + MediaType = OrasProject.Oras.Oci.MediaType.ImageManifest, + Config = new Descriptor + { + MediaType = OrasProject.Oras.Oci.MediaType.EmptyJson, + Digest = + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21caaff8a", + Size = 2 + }, + Layers = new List(), + Annotations = largeAnnotations +}; + +var indexManifests = new List(); +for (int i = 0; i < 10; i++) +{ + indexManifests.Add(new Descriptor + { + MediaType = OrasProject.Oras.Oci.MediaType.ImageManifest, + Digest = + $"sha256:{i:d64}", + Size = 512 + i, + Annotations = new Dictionary + { + [$"org.example+platform{i}"] = $"linux/amd64+v{i}" + } + }); +} + +var index = new Index +{ + SchemaVersion = 2, + MediaType = OrasProject.Oras.Oci.MediaType.ImageIndex, + Manifests = indexManifests, + Annotations = annotationsWithPlus +}; + +// ── Determine mode ───────────────────────────────────────── +// "baseline" uses JsonSerializer directly (upstream/main behavior) +// "pr" uses OciJsonSerializer (PR branch behavior) +var mode = args.Length > 0 ? args[0] : "auto"; + +if (mode == "auto") +{ + // Detect whether OciJsonSerializer is available + var ociType = Type.GetType( + "OrasProject.Oras.Serialization.OciJsonSerializer," + + " OrasProject.Oras"); + mode = ociType != null ? "pr" : "baseline"; +} + +Console.WriteLine($"=== Serialization Benchmark ({mode}) ==="); +Console.WriteLine( + $"Warmup: {warmupIterations}, Iterations: {iterations}"); +Console.WriteLine(); + +// ── Benchmark helpers ────────────────────────────────────── +static (double avgUs, double medianUs, double p95Us) Measure( + Action action, int warmup, int iters) +{ + // Warmup + for (int i = 0; i < warmup; i++) action(); + + var times = new double[iters]; + var sw = new Stopwatch(); + for (int i = 0; i < iters; i++) + { + sw.Restart(); + action(); + sw.Stop(); + times[i] = sw.Elapsed.TotalMicroseconds; + } + Array.Sort(times); + var avg = times.Average(); + var median = times[iters / 2]; + var p95 = times[(int)(iters * 0.95)]; + return (avg, median, p95); +} + +static (double avgUs, double medianUs, double p95Us) MeasureAsync( + Func action, int warmup, int iters) +{ + // Warmup + for (int i = 0; i < warmup; i++) action().GetAwaiter().GetResult(); + + var times = new double[iters]; + var sw = new Stopwatch(); + for (int i = 0; i < iters; i++) + { + sw.Restart(); + action().GetAwaiter().GetResult(); + sw.Stop(); + times[i] = sw.Elapsed.TotalMicroseconds; + } + Array.Sort(times); + var avg = times.Average(); + var median = times[iters / 2]; + var p95 = times[(int)(iters * 0.95)]; + return (avg, median, p95); +} + +void Report(string name, (double avg, double median, double p95) r) +{ + Console.WriteLine( + $" {name,-45} avg={r.avg,8:F1}µs " + + $"med={r.median,8:F1}µs p95={r.p95,8:F1}µs"); +} + +// ── Run benchmarks ───────────────────────────────────────── +if (mode == "baseline") +{ + RunBaseline(); +} +else +{ + RunPr(); +} + +void RunBaseline() +{ + Console.WriteLine("── Serialize (JsonSerializer.SerializeToUtf8Bytes) ──"); + Report("Manifest (5 annotations, +chars)", + Measure(() => JsonSerializer.SerializeToUtf8Bytes(manifest), + warmupIterations, iterations)); + Report("Manifest (50 annotations, +chars)", + Measure( + () => JsonSerializer.SerializeToUtf8Bytes(manifestLargeAnnotations), + warmupIterations, iterations)); + Report("Index (10 manifests, annotations)", + Measure(() => JsonSerializer.SerializeToUtf8Bytes(index), + warmupIterations, iterations)); + + Console.WriteLine(); + Console.WriteLine("── Deserialize (JsonSerializer.Deserialize byte[]) ──"); + var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest); + var largeBytes = + JsonSerializer.SerializeToUtf8Bytes(manifestLargeAnnotations); + var indexBytes = JsonSerializer.SerializeToUtf8Bytes(index); + + Report("Manifest (5 annotations)", + Measure(() => JsonSerializer.Deserialize(manifestBytes), + warmupIterations, iterations)); + Report("Manifest (50 annotations)", + Measure( + () => JsonSerializer.Deserialize(largeBytes), + warmupIterations, iterations)); + Report("Index (10 manifests)", + Measure(() => JsonSerializer.Deserialize(indexBytes), + warmupIterations, iterations)); + + Console.WriteLine(); + Console.WriteLine( + "── DeserializeAsync (JsonSerializer.DeserializeAsync) ──"); + Report("Manifest (5 annotations)", + MeasureAsync(async () => + { + using var ms = new MemoryStream(manifestBytes); + await JsonSerializer.DeserializeAsync(ms); + }, warmupIterations, iterations)); + Report("Manifest (50 annotations)", + MeasureAsync(async () => + { + using var ms = new MemoryStream(largeBytes); + await JsonSerializer.DeserializeAsync(ms); + }, warmupIterations, iterations)); + Report("Index (10 manifests)", + MeasureAsync(async () => + { + using var ms = new MemoryStream(indexBytes); + await JsonSerializer.DeserializeAsync(ms); + }, warmupIterations, iterations)); + + Console.WriteLine(); + Console.WriteLine("── Payload sizes ──"); + Console.WriteLine($" Manifest (5 ann): {manifestBytes.Length} bytes"); + Console.WriteLine($" Manifest (50 ann): {largeBytes.Length} bytes"); + Console.WriteLine($" Index (10 mfst): {indexBytes.Length} bytes"); +} + +void RunPr() +{ + // Use reflection to call OciJsonSerializer since it's internal + var asm = typeof(Manifest).Assembly; + var serType = asm.GetType( + "OrasProject.Oras.Serialization.OciJsonSerializer")!; + var serMethod = serType.GetMethod( + "SerializeToUtf8Bytes", + System.Reflection.BindingFlags.Static + | System.Reflection.BindingFlags.NonPublic)!; + var deserByteMethod = serType.GetMethods( + System.Reflection.BindingFlags.Static + | System.Reflection.BindingFlags.NonPublic) + .First(m => m.Name == "Deserialize" + && m.GetParameters().Length == 1 + && m.GetParameters()[0].ParameterType == typeof(byte[])); + var deserAsyncMethod = serType.GetMethod( + "DeserializeAsync", + System.Reflection.BindingFlags.Static + | System.Reflection.BindingFlags.NonPublic)!; + + byte[] Serialize(T value) => + (byte[])serMethod.MakeGenericMethod(typeof(T)) + .Invoke(null, [value])!; + + T? Deserialize(byte[] bytes) => + (T?)deserByteMethod.MakeGenericMethod(typeof(T)) + .Invoke(null, [bytes]); + + async Task DeserializeAsync(Stream stream) + { + var task = (Task)deserAsyncMethod + .MakeGenericMethod(typeof(T)) + .Invoke(null, [stream, CancellationToken.None])!; + return await task; + } + + Console.WriteLine( + "── Serialize (OciJsonSerializer.SerializeToUtf8Bytes) ──"); + Report("Manifest (5 annotations, +chars)", + Measure(() => Serialize(manifest), + warmupIterations, iterations)); + Report("Manifest (50 annotations, +chars)", + Measure(() => Serialize(manifestLargeAnnotations), + warmupIterations, iterations)); + Report("Index (10 manifests, annotations)", + Measure(() => Serialize(index), + warmupIterations, iterations)); + + Console.WriteLine(); + Console.WriteLine( + "── Deserialize (OciJsonSerializer.Deserialize byte[]) ──"); + var manifestBytes = Serialize(manifest); + var largeBytes = Serialize(manifestLargeAnnotations); + var indexBytes = Serialize(index); + + Report("Manifest (5 annotations)", + Measure(() => Deserialize(manifestBytes), + warmupIterations, iterations)); + Report("Manifest (50 annotations)", + Measure(() => Deserialize(largeBytes), + warmupIterations, iterations)); + Report("Index (10 manifests)", + Measure(() => Deserialize(indexBytes), + warmupIterations, iterations)); + + Console.WriteLine(); + Console.WriteLine( + "── DeserializeAsync (OciJsonSerializer.DeserializeAsync) ──"); + Report("Manifest (5 annotations)", + MeasureAsync(async () => + { + using var ms = new MemoryStream(manifestBytes); + await DeserializeAsync(ms); + }, warmupIterations, iterations)); + Report("Manifest (50 annotations)", + MeasureAsync(async () => + { + using var ms = new MemoryStream(largeBytes); + await DeserializeAsync(ms); + }, warmupIterations, iterations)); + Report("Index (10 manifests)", + MeasureAsync(async () => + { + using var ms = new MemoryStream(indexBytes); + await DeserializeAsync(ms); + }, warmupIterations, iterations)); + + Console.WriteLine(); + Console.WriteLine("── Payload sizes ──"); + Console.WriteLine($" Manifest (5 ann): {manifestBytes.Length} bytes"); + Console.WriteLine($" Manifest (50 ann): {largeBytes.Length} bytes"); + Console.WriteLine($" Index (10 mfst): {indexBytes.Length} bytes"); +} diff --git a/benchmarks/SerializationBenchmark.csproj b/benchmarks/SerializationBenchmark.csproj new file mode 100644 index 00000000..c56c7906 --- /dev/null +++ b/benchmarks/SerializationBenchmark.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + false + CA2007 + false + + + + + + + From d2562a6b2780a9223a10528c51e714c771aa55ae Mon Sep 17 00:00:00 2001 From: Sajay Antony Date: Wed, 27 May 2026 00:47:35 -0700 Subject: [PATCH 3/4] perf: use TryGetBuffer for zero-copy deserialization in ManifestStore Replace ms.ToArray() with MemoryStream.TryGetBuffer() to avoid allocating a copy of the buffer. Add Deserialize(ReadOnlySpan) overload to support span-based deserialization from buffer segments. This reduces GC pressure in multi-tenant server scenarios where many manifests are deserialized concurrently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Sajay Antony --- .../Registry/Remote/ManifestStore.cs | 28 +++++++++++-------- .../Serialization/OciJsonSerializer.cs | 9 ++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs index 2422bbe2..cf0d1523 100644 --- a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs +++ b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs @@ -258,10 +258,11 @@ private async Task ProcessReferrersAndPushIndex(Descriptor desc, Stream content, { // Buffer stream for sync deserialization — avoids async // state machine overhead for already-buffered data. - byte[] contentBytes; - if (content is MemoryStream ms) + ReadOnlyMemory contentBytes; + if (content is MemoryStream ms + && ms.TryGetBuffer(out var segment)) { - contentBytes = ms.ToArray(); + contentBytes = segment; } else { @@ -276,7 +277,8 @@ await content.CopyToAsync(buf, cancellationToken) { case MediaType.ImageIndex: var indexManifest = - OciJsonSerializer.Deserialize(contentBytes) + OciJsonSerializer.Deserialize( + contentBytes.Span) ?? throw new JsonException( "Failed to deserialize index"); if (indexManifest.Subject == null) @@ -290,7 +292,7 @@ await content.CopyToAsync(buf, cancellationToken) case MediaType.ImageManifest: var imageManifest = OciJsonSerializer.Deserialize( - contentBytes) + contentBytes.Span) ?? throw new JsonException( "Failed to deserialize manifest"); if (imageManifest.Subject == null) @@ -298,7 +300,10 @@ await content.CopyToAsync(buf, cancellationToken) return; } subject = imageManifest.Subject; - desc.ArtifactType = string.IsNullOrEmpty(imageManifest.ArtifactType) ? imageManifest.Config.MediaType : imageManifest.ArtifactType; + desc.ArtifactType = string.IsNullOrEmpty( + imageManifest.ArtifactType) + ? imageManifest.Config.MediaType + : imageManifest.ArtifactType; desc.Annotations = imageManifest.Annotations; break; default: @@ -498,10 +503,11 @@ private async Task IndexReferrersForDelete(Descriptor target, Stream manifestCon { // Buffer stream for sync deserialization — avoids async // state machine overhead for already-buffered data. - byte[] manifestBytes; - if (manifestContent is MemoryStream ms) + ReadOnlyMemory manifestBytes; + if (manifestContent is MemoryStream ms + && ms.TryGetBuffer(out var segment)) { - manifestBytes = ms.ToArray(); + manifestBytes = segment; } else { @@ -517,7 +523,7 @@ await manifestContent.CopyToAsync( case MediaType.ImageManifest: var imageManifest = OciJsonSerializer.Deserialize( - manifestBytes); + manifestBytes.Span); if (imageManifest?.Subject == null) { // no subject, no indexing needed @@ -528,7 +534,7 @@ await manifestContent.CopyToAsync( case MediaType.ImageIndex: var imageIndex = OciJsonSerializer.Deserialize( - manifestBytes); + manifestBytes.Span); if (imageIndex?.Subject == null) { // no subject, no indexing needed diff --git a/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs b/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs index 2face278..90213c10 100644 --- a/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs +++ b/src/OrasProject.Oras/Serialization/OciJsonSerializer.cs @@ -62,6 +62,15 @@ internal static byte[] SerializeToUtf8Bytes(T value) return JsonSerializer.Deserialize(utf8Json, s_options); } + /// + /// Deserializes a UTF-8 JSON span to the specified type. + /// Avoids copying when the caller already has a buffer view. + /// + internal static T? Deserialize(ReadOnlySpan utf8Json) + { + return JsonSerializer.Deserialize(utf8Json, s_options); + } + /// /// Deserializes a JSON string to the specified type. /// From b7dd26038a845f4e7e77ca781c8bdc53ebda3a10 Mon Sep 17 00:00:00 2001 From: Sajay Antony Date: Wed, 27 May 2026 00:47:53 -0700 Subject: [PATCH 4/4] docs: add benchmark README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Sajay Antony --- benchmarks/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 benchmarks/README.md diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..8359e45c --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,20 @@ +# Serialization Benchmarks + +Measures OCI manifest/index JSON serialization and deserialization performance. + +## Run + +```bash +dotnet run --project benchmarks/SerializationBenchmark.csproj -- [baseline|pr|auto] +``` + +- `baseline` — uses `System.Text.Json` directly (upstream behavior before Go-compatible encoding) +- `pr` — uses `OciJsonSerializer` with Go-compatible escaping + optimizations +- `auto` (default) — detects which path is available + +## What's Measured + +- **Serialize**: manifest (5 & 50 annotations), index (10 manifests) +- **Deserialize sync**: same payloads from `byte[]` +- **DeserializeAsync**: same payloads from `MemoryStream` +- Reports avg, median, and p95 latency in microseconds