CborWriter: reduce per-call overhead of map and array bookkeeping

オープン
#137,111 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
45/100
issue の種類
リファクタリング
明瞭さ
おおむね明確
活発さ
活発
技術スタック
csharp

調査の方向性

Start by reading CborWriter.cs, CborWriter.Map.cs, CborWriter.Array.cs and CborWriter.String.cs; the issue describes five proposed changes across those files and reports benchmark results. Review the existing writer state management and the relevant benchmark and behavior checks described in the issue. Done means a proposed change preserves the documented behavior while reducing per-call bookkeeping overhead; the issue asks whether the author should open a PR, so implementation scope and review direction still need maintainer agreement.

索引モデルが issue の本文から書いたものです。

説明

area-System.Formats.Cbor tenet-performance untriaged
Terms
  • main: the unmodified CborWriter source from dotnet/runtime main.
  • Changed writer: the same source with the five changes listed under Description.
  • Minimal writer: a small CBOR writer used only as a reference point, not proposed for anything. It writes headers and UTF-8 bytes straight into an IBufferWriter<byte> and tracks only which open containers are indefinite-length. Apart from rejecting an end call with no open container, it does no structural validation or conformance checks.
  • Bookkeeping: the state CborWriter keeps to validate structure (current container type, definite length, items written, key and value offsets) and saves and restores for each map or array.
  • Conversion: ConvertIndefiniteLengthEncodings = true. Maps and arrays started with WriteStartMap(null) or WriteStartArray(null) are rewritten as definite-length when they close.
  • Differential fuzz: random sequences of writer calls, including deliberate misuse, run on both main and the changed writer. After every call, the two must agree on success or exception (type and message) and on writer state; at the end, on the encoded bytes. Any difference counts as a behavior change.
  • Boundary test: a fixed, non-random set of documents with container sizes and string lengths around the points where the CBOR length header grows (24 and 256), compared the same way.
  • Benchmark names (Text, Pairs, Map1Indef, Map1Def, Doc, DocStrict) are defined in the Data table.
  • "Nx faster" / "Nx slower": the ratio of the two times being compared, larger over smaller, both measured in the same session.
Description

In map-heavy writes, most of CborWriter's extra cost over a minimal writer is bookkeeping for each map and array. Writing the one-pair map {"S": v} takes 39.6 ns, while the minimal writer produces the same bytes in 21.6 ns.

I have a set of internal changes, with the same behavior and no public API changes, that make a small nested document 1.48x faster in Lax mode and 1.09x faster in Strict mode. I'd like to open a PR if you're open to it.

This follows up #136164, where I proposed an IBufferWriter<byte> output mode to avoid the Encode() copy. As I noted there, the copy turned out to be negligible: on the Doc benchmark's document (9.0.5 package), Encode(Span<byte>) added 2.5 ns to 1,536 ns, within measurement error. The bookkeeping below accounts for most of the difference from the minimal writer.

For context, I ran into this in the AWS SDK for .NET, which writes Smithy RPC v2 CBOR request bodies with CborWriter. Its CBOR request benchmarks (12 cases) are 1.30x slower than the same requests in JSON with Utf8JsonWriter (default options). Swapping in the changed writer, with nothing else changed, brings that to 1.02x slower, with identical output. Those are geometric means; per case it ranges from 1.13x slower to 1.42x faster. The existing options don't close the gap: on a document with 255 "attrN": {"S": "valueN"} entries, the best combination I found (definite lengths, conversion off) was 1.06x faster than Lax with indefinite-length maps and conversion (11.0 package, Stopwatch timing, best of 9 rounds).

What the changes target. For every map or array, CborWriter saves the current context into a 9-field StackFrame on a Stack<StackFrame> and restores it on close. Four of the context fields are Nullable<T> (CborMajorType? and three int?), so the checks on every item use lifted nullable operations. In Lax mode, the writer also saves, restores and updates state that only the key checks in Strict and the canonical modes read.

Changes and impact. All changes are in CborWriter.cs, CborWriter.Map.cs, CborWriter.Array.cs and CborWriter.String.cs. Speedups are cumulative vs main; each row was measured in its own session, so treat differences of a few percent as noise.

# Change Map1Indef Doc
1 Replace Stack<StackFrame> with a StackFrame[] and a depth counter; push and pop through ref 1.09x faster 1.10x faster
2 Use sentinel values instead of nullable state fields 1.20x faster 1.22x faster
3 Move the key-sorting flag and the two key-range collections, used only by modes that check keys, to a separate array touched only in those modes 1.28x faster 1.30x faster
4 When closing an indefinite-length map or array with conversion and fewer than 24 elements or pairs, write the initial byte directly instead of rewinding and calling WriteUnsignedInteger 1.31x faster 1.33x faster
5 In Lax mode, skip key and value offset tracking, its save and restore, and CompleteMapWrite; cache RequiresUniqueKeys, RequiresSortedKeys, RequiresDefiniteLengthItems and GetUtf8Encoding in readonly fields 1.57x faster 1.51x faster

Change 1 alone made Text 1.02x slower; change 2 more than made up for it. DocStrict measured 1.09x faster after change 4 and 1.07x after change 5 (it wasn't run for changes 1 to 3).

Configuration
BenchmarkDotNet v0.15.2, macOS 26.7.2 (25G332) [Darwin 25.6.0]
Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
.NET 10.0.12 (10.0.1226.42308), Arm64 RyuJIT AdvSIMD
WarmupCount=5, IterationCount=15

Both writers are built from main source in the same benchmark project. The only differences are the namespace and small adaptations to build against the 9.0.5 package's public types (resource strings, enums, and WriteEncodedValue validation, which the benchmarks don't call). The source was taken at 38f5cfdad059; the four files are identical in main as of 8cdbec121572 and in release/11.0. The writer is CborConformanceMode.Lax (Strict for DocStrict) with conversion on, reused through Reset(). I've only measured Arm64 on macOS.

Regression?

Not that I know of. In the same session, the 9.0.5 package and the unmodified main source were within about 3% of each other on every benchmark below.

Data

Mean ns, all from one session:

Benchmark main changed changed vs main minimal writer
Text: 64 short strings in an array, per string 11.2 10.0 1.12x faster 10.5
Pairs: 32 short string pairs in a map, per string 12.5 10.0 1.25x faster 10.4
Map1Indef: 64 maps {"S": v}, indefinite length, per map 44.2 28.1 1.57x faster
Map1Def: same, definite length, per map 39.6 24.8 1.60x faster 21.6
Doc: nested document, see below 1,578 1,070 1.48x faster 1,022
DocStrict: Doc with unique keys, Strict mode 2,152 1,978 1.09x faster

The minimal writer writes definite lengths directly here and has no Encode step.

Doc is {"TableName": "my-table", "Item": {...}}, where Item has 20 short-key entries like k: {"S": v} or k: {"N": v} plus "nested": {"M": {...}} with 4 more k: {"S": v} entries. That's 28 maps, all started with WriteStartMap(null) and converted on close, and 77 short strings: 389 bytes, then Encode(Span<byte>) into a preallocated buffer. Five of Doc's Item keys are "S", which Lax allows; DocStrict uses 20 unique keys.

Allocations don't change: 0 B for Text, Pairs and Map1, and 128 B for Doc and DocStrict with both writers (the four "a" + i keys the benchmark builds).

The Map1Def loop:

// values: 64 ASCII strings of 1 to 9 characters. Reported per map (OperationsPerInvoke = 64).
var writer = new CborWriter(CborConformanceMode.Lax, convertIndefiniteLengthEncodings: true); // created once

writer.Reset();
writer.WriteStartArray(64);
for (int i = 0; i < 64; i++)
{
    writer.WriteStartMap(1);           // Map1Indef: WriteStartMap(null)
    writer.WriteTextString("S");
    writer.WriteTextString(values[i]);
    writer.WriteEndMap();
}
writer.WriteEndArray();
Analysis

To check that behavior doesn't change, I compared the changed writer with main two ways. Both found no differences.

  • Boundary test: maps and arrays (definite and indefinite) with 0 to 30 and 250 to 260 entries, in every conformance mode with conversion on and off, plus text and byte strings of the same lengths. That's 1,512 documents. It caught an off-by-one I introduced on purpose in change 4.
  • Differential fuzz: random sequences of the write APIs, with misuse mixed in, run on all four conformance modes with conversion and AllowMultipleRootLevelValues on and off, using both a reused writer and a new writer per case. After every call it compares success or exception (type and message), CurrentDepth, BytesWritten and IsWriteCompleted, and at the end the Encode() bytes. In total: 10,000 cases per configuration and 121,581,530 calls per comparison, about 29% of them throwing.
    • APIs: text and byte strings (including indefinite-length), WriteInt64 (including extremes), WriteUInt64, double, half, bool, null, simple values, tags, and maps and arrays with duplicate, out-of-order and non-text keys. Sizes are mostly small, sometimes around the 24 and 256 header boundaries.
    • Misuse: extra items, dropped calls, wrong or extra end calls, random extra calls.
主要言語
C#
スター
18.4k
フォーク
5.8k
平均マージ
2日 14時間
マージ済み PR(30日)
585

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

dotnet/runtime のほかの issue

dotnet/runtime の issue をすべて見る

似ている issue

C# の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。