CborWriter: reduce per-call overhead of map and array bookkeeping
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 45/100
- Issue 类型
- 重构
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- csharp
- 领域
- backend, performance
调研方向
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 内容生成。
描述
Terms
main: the unmodifiedCborWritersource from dotnet/runtimemain.- 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
CborWriterkeeps 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 withWriteStartMap(null)orWriteStartArray(null)are rewritten as definite-length when they close. - Differential fuzz: random sequences of writer calls, including deliberate misuse, run on both
mainand 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
AllowMultipleRootLevelValueson 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,BytesWrittenandIsWriteCompleted, and at the end theEncode()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.
- APIs: text and byte strings (including indefinite-length),
- 主要语言
- C#
- 星标
- 18.4k
- 派生
- 5.8k
- 平均合并
- 2 天 14 小时
- 30 天内合并 PR
- 585
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
dotnet/runtime 的其他 Issue
-
area-Host untriaged
难度 2/5 1-3 小时 新手友好度 68/100
dotnet/runtime#137187 · 3 条评论 ·
维护者通常 1 天内回复
-
ImmutableSortedSet<T>.Builder: enumerating through IEnumerable<T> doesn't throw on modification and can return wrong items可能已有人在做 @aw0lid 今天认领。 未关闭area-System.Collections
难度 2/5 1-3 小时 新手友好度 78/100
dotnet/runtime#137167 · 1 条评论 ·
维护者通常 1 天内回复
-
DbTransaction.SupportsSavepoints docs say Release/ReleaseAsync are expected to throw, but they are no-ops by design可能已有人在做 @alaricd 于 4 天前认领。 未关闭area-System.Transactions untriaged
难度 2/5 1-3 小时 新手友好度 72/100
dotnet/runtime#137040 · 1 条评论 ·
维护者通常 1 天内回复
-
FileSystemGlobbing: Matcher throws ArgumentOutOfRangeException when the rootDir is the same value as the file可能已有人在做 @makazeu 于 5 天前认领。 未关闭area-Extensions-FileSystem
难度 2/5 1-3 小时 新手友好度 78/100
dotnet/runtime#136989 · 1 条评论 ·
维护者通常 1 天内回复
-
Configuration binder source generator emits unqualified List<T> when binding arrays without implicit usings可能已有人在做 @makazeu 于 5 天前认领。 未关闭area-Extensions-Configuration bug source-generator
难度 2/5 1-3 小时 新手友好度 78/100
dotnet/runtime#136972 · 1 条评论 ·
维护者通常 1 天内回复
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
Issue-Bug Needs-Triage
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 2 天内回复
-
难度 2/5 1-3 小时 新手友好度 66/100
JiaFeiMiao-K-Cat/DiscImageStudio#281 ·
维护者通常 1 天内回复
-
bug triage
难度 2/5 1-3 小时 新手友好度 76/100
-
area/access-management Frontend onboarding candidate :handshake:
难度 2/5 1-3 小时 新手友好度 65/100
Altinn/altinn-auth#4941 ·
维护者通常 1 天内回复