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

Đang mở
#137,111 2 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
45/100
Loại issue
Tái cấu trúc
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
csharp
Lĩnh vực
backend, performance

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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.
Ngôn ngữ chính
C#
Star
18.4k
Fork
5.8k
Merge trung bình
2 ngày 14 giờ
Pull request đã merge (30 ngày)
585

Chuẩn bị môi trường

Mở trong Codespaces

Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của dotnet/runtime

Tất cả issue của dotnet/runtime

Issue tương tự

Thêm issue về C#

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.