Reserve CBOR string buffer capacity in a single call - #131051
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d9c75d62-dda8-4115-be1b-ef2f101a6d61
|
@EgorBot -amd -osx_arm64 Cross-platform A/B for the "reserve string capacity once" change (fresh vs. reused writer x byte / ASCII / Unicode text, medium + large payloads, plus an using System;
using System.Formats.Cbor;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CborStringCapacity
{
[Params(256, 65536)]
public int Size;
private byte[] _bytes = Array.Empty<byte>();
private string _ascii = "";
private string _unicode = "";
private CborWriter _reusedByte = null!, _reusedAscii = null!, _reusedUnicode = null!, _reusedInt = null!;
[GlobalSetup]
public void Setup()
{
_bytes = new byte[Size];
new Random(42).NextBytes(_bytes);
_ascii = new string('a', Size); // 1 UTF-8 byte/char
_unicode = new string('\u00e9', Size); // 'é' => 2 UTF-8 bytes/char
_reusedByte = Prime(w => w.WriteByteString(_bytes));
_reusedAscii = Prime(w => w.WriteTextString(_ascii));
_reusedUnicode = Prime(w => w.WriteTextString(_unicode));
_reusedInt = Prime(w => w.WriteInt64(long.MaxValue));
}
private static CborWriter Prime(Action<CborWriter> write)
{
var w = new CborWriter();
write(w);
w.Reset();
return w;
}
[Benchmark]
public int FreshByteString() { var w = new CborWriter(); w.WriteByteString(_bytes); return w.BytesWritten; }
[Benchmark]
public int FreshAsciiText() { var w = new CborWriter(); w.WriteTextString(_ascii); return w.BytesWritten; }
[Benchmark]
public int FreshUnicodeText() { var w = new CborWriter(); w.WriteTextString(_unicode); return w.BytesWritten; }
[Benchmark]
public int ReusedByteString() { _reusedByte.Reset(); _reusedByte.WriteByteString(_bytes); return _reusedByte.BytesWritten; }
[Benchmark]
public int ReusedAsciiText() { _reusedAscii.Reset(); _reusedAscii.WriteTextString(_ascii); return _reusedAscii.BytesWritten; }
[Benchmark]
public int ReusedUnicodeText() { _reusedUnicode.Reset(); _reusedUnicode.WriteTextString(_unicode); return _reusedUnicode.BytesWritten; }
[Benchmark]
public int ReusedInt64() { _reusedInt.Reset(); _reusedInt.WriteInt64(long.MaxValue); return _reusedInt.BytesWritten; }
} |
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
This PR optimizes CborWriter string writes by reserving buffer capacity for the CBOR length prefix and payload in a single step, avoiding a potential second allocation/copy when the buffer needs to grow.
Changes:
- Added a private
WriteUnsignedInteger(type, value, additionalCapacity)overload to reserve header + payload capacity up front. - Updated
WriteByteString(ReadOnlySpan<byte>)andWriteTextString(ReadOnlySpan<char>)to use the new overload and remove the separateEnsureWriteCapacity(payloadLength)call.
Show a summary per file
| File | Description |
|---|---|
src/libraries/System.Formats.Cbor/src/System/Formats/Cbor/Writer/CborWriter.String.cs |
Switches string write paths to a single capacity reservation using the new overload. |
src/libraries/System.Formats.Cbor/src/System/Formats/Cbor/Writer/CborWriter.Integer.cs |
Introduces the new WriteUnsignedInteger overload that reserves prefix + additional payload capacity. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 2
| private void WriteUnsignedInteger(CborMajorType type, ulong value, int additionalCapacity) | ||
| { | ||
| if (value < (byte)CborAdditionalInfo.Additional8BitData) | ||
| { | ||
| EnsureWriteCapacity(checked(1 + additionalCapacity)); | ||
| WriteInitialByte(new CborInitialByte(type, (CborAdditionalInfo)value)); | ||
| } |
| private void WriteUnsignedInteger(CborMajorType type, ulong value, int additionalCapacity) | ||
| { |
|
Tagging subscribers to this area: @dotnet/area-system-formats-cbor, @bartonjs, @vcsjones |
Summary
CborWriter.WriteByteStringandCborWriter.WriteTextStringcurrently reservebuffer space in two steps:
WriteUnsignedIntegerreserves the length prefix, then asecond
EnsureWriteCapacity(length)reserves the payload. When the internal bufferhas to grow, this can trigger two allocations and copies for a single string write
(e.g. the first write to a fresh
CborWriter, whose buffer starts empty, is grown to1024 bytes for the prefix, then grown again for a larger payload).
This change adds a
WriteUnsignedInteger(type, value, additionalCapacity)overload thatfolds the payload size into the length-prefix reservation, so the writer performs at
most one
EnsureWriteCapacity(hence at most one growth) per string write. The finalreserved capacity is identical to before and the encoded output is byte-for-byte identical.
Details
1 + <intBytes> + additionalCapacityup front, withchecked(...)guarding againstintoverflow for pathological lengths.WriteByteString(ReadOnlySpan<byte>)andWriteTextString(ReadOnlySpan<char>)nowpass the payload length as
additionalCapacityand drop their separateEnsureWriteCapacitycall.Correctness
offset + 1 + intBytes + length.EnsureWriteCapacitypreserves already-written bytes on growth, and the singlereservation now runs before the header is written, so the header lands in the grown
buffer.
_buffer.AsSpan(_offset, length), a bounds-checked slicethat would throw on any under-reservation — so the passing test suite directly
validates the new arithmetic.
System.Formats.Cbortest suite passes locally (1921 tests, 0 failed, 0 skipped).Performance
Local A/B micro-benchmark (BenchmarkDotNet,
[MemoryDiagnoser], in-process; baseline =main, candidate = this change; both compiled Release). Matrix: fresh vs. reused writerx byte / ASCII text / Unicode text (
é, 2 UTF-8 bytes/char), medium (256 B) and large(64 KiB) payloads, plus an
Int64primitive control that exercises the unchangedWriteUnsignedIntegerpath.The deterministic win is one fewer buffer allocation on a fresh writer once the
payload exceeds the initial 1024-byte growth:
-> one), with a matching reduction in Gen0 rate (e.g. FreshByteString 7.93 -> 7.81
Gen0/1k op).
once — no regression, no change.
Int64control allocate zero in both versions and areunchanged (within run-to-run noise).
Local timings are on an Apple M5 Pro (arm64) and are within measurement noise for the
compute-bound cases; the meaningful, deterministic signal here is the allocation
reduction. Cross-platform Release before/after via EgorBot below.
Note
This pull request was authored with the assistance of GitHub Copilot.