Skip to content

Reserve CBOR string buffer capacity in a single call - #131051

Draft
artl93 wants to merge 1 commit into
dotnet:mainfrom
artl93:artl93-cbor-string-capacity
Draft

Reserve CBOR string buffer capacity in a single call#131051
artl93 wants to merge 1 commit into
dotnet:mainfrom
artl93:artl93-cbor-string-capacity

Conversation

@artl93

@artl93 artl93 commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

CborWriter.WriteByteString and CborWriter.WriteTextString currently reserve
buffer space in two steps: WriteUnsignedInteger reserves the length prefix, then a
second EnsureWriteCapacity(length) reserves the payload. When the internal buffer
has 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 to
1024 bytes for the prefix, then grown again for a larger payload).

This change adds a WriteUnsignedInteger(type, value, additionalCapacity) overload that
folds 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 final
reserved capacity is identical to before and the encoded output is byte-for-byte identical.

Details

  • New private overload reserves 1 + <intBytes> + additionalCapacity up front, with
    checked(...) guarding against int overflow for pathological lengths.
  • WriteByteString(ReadOnlySpan<byte>) and WriteTextString(ReadOnlySpan<char>) now
    pass the payload length as additionalCapacity and drop their separate
    EnsureWriteCapacity call.
  • No public API change.

Correctness

  • Final required capacity is unchanged: offset + 1 + intBytes + length.
  • EnsureWriteCapacity preserves already-written bytes on growth, and the single
    reservation now runs before the header is written, so the header lands in the grown
    buffer.
  • The text path copies via _buffer.AsSpan(_offset, length), a bounds-checked slice
    that would throw on any under-reservation — so the passing test suite directly
    validates the new arithmetic.
  • Indefinite-length chunk range bookkeeping is unchanged.
  • Full System.Formats.Cbor test 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 writer
x byte / ASCII text / Unicode text (é, 2 UTF-8 bytes/char), medium (256 B) and large
(64 KiB) payloads, plus an Int64 primitive control that exercises the unchanged
WriteUnsignedInteger path.

The deterministic win is one fewer buffer allocation on a fresh writer once the
payload exceeds the initial 1024-byte growth:

Scenario (fresh writer) Size Allocated before Allocated after Delta
WriteByteString 64 KiB 66,752 B 65,704 B -1,048 B
WriteTextString (ASCII) 64 KiB 66,752 B 65,704 B -1,048 B
WriteTextString (Unicode) 64 KiB 132,412 B 131,364 B -1,048 B
WriteByteString / text 256 B 1,184 B 1,184 B no change
  • The 64 KiB fresh writes drop exactly the intermediate ~1 KiB array (two allocations
    -> one), with a matching reduction in Gen0 rate (e.g. FreshByteString 7.93 -> 7.81
    Gen0/1k op).
  • At 256 B the payload already fits inside the first growth, so both versions allocate
    once — no regression, no change.
  • Reused-writer path and the Int64 control allocate zero in both versions and are
    unchanged (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.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d9c75d62-dda8-4115-be1b-ef2f101a6d61
Copilot AI review requested due to automatic review settings July 20, 2026 00:48
@artl93

artl93 commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

@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 Int64 control on the unchanged integer path):

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

Copy link
Copy Markdown
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>) and WriteTextString(ReadOnlySpan<char>) to use the new overload and remove the separate EnsureWriteCapacity(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

Comment on lines +121 to +127
private void WriteUnsignedInteger(CborMajorType type, ulong value, int additionalCapacity)
{
if (value < (byte)CborAdditionalInfo.Additional8BitData)
{
EnsureWriteCapacity(checked(1 + additionalCapacity));
WriteInitialByte(new CborInitialByte(type, (CborAdditionalInfo)value));
}
Comment on lines +121 to +122
private void WriteUnsignedInteger(CborMajorType type, ulong value, int additionalCapacity)
{
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-formats-cbor, @bartonjs, @vcsjones
See info in area-owners.md if you want to be subscribed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants