Skip to content

核实并修复优化分析报告中的问题:流式解析、Serialization 写入、编码器并发与正确性 - #11

Merged
vnixx merged 16 commits into
mainfrom
fix/deepseek-harness-findings
Sep 25, 2026
Merged

vnixx merged 16 commits into
mainfrom
fix/deepseek-harness-findings

Conversation

@vnixx

@vnixx vnixx commented Sep 24, 2026

Copy link
Copy Markdown
Member

概述

逐条核实了 DeepSeek harness 生成的《ReerJSON 优化分析报告》。每一项都先写测试确认问题是否真实存在,确认后再修复,一个修复一个提交;每个提交都附带修复前失败、修复后通过的回归测试(集中在 Tests/ReerJSONTests/RegressionTests.swift),以及修复前后的 A/B 性能数据。

结论:

  • 报告中确认并修复 11 项:C-1、P0-1、P0-2(含 C-6)、C-13、P2、C-2、C-4(含 C-3)、P1、C-5、C-9、C-10,另外 C-12 做了重构。
  • 核实过程中额外发现并修复 2 个正确性 bug:.custom 键策略收到的 codingPath 不完整;-9223372036854775809 会被静默解码成 Int64.min。
  • 核实后不修 5 项:C-7、C-8、C-11、C-14、C-15,理由见文末。
  • 没有 API 变更。全部测试通过:XCTest 778 个,Swift Testing 143 个(原 120 个 + 新增 23 个),debug 和 release 两种模式都跑过。另外用 Linux CI 同款镜像 swiftlang/swift:nightly-6.4.x-noble 验证了 swift build,新增的回归测试在 Linux 上也全部通过。

整体效果(main 对比本分支,交替运行 3 轮,取最小值)

场景 main 本分支 提升
JSONStreamParser 16000 条 NDJSON 一次喂入 111.6 ms 1.92 ms 58x
StreamingJSONLinesDecoder 16000 条 117.6 ms 11.2 ms 10.5x
ReerJSONSerialization 写 1000 键字典 ×10 30.07 ms 0.79 ms 38x(Foundation 1.22 ms)
ReerJSONSerialization 写 5000 行 × 6 键 85.3 ms 5.2 ms 16x(Foundation 7.45 ms)
ReerJSONSerialization 5000 行 sortedKeys 89.9 ms 6.5 ms 14x(Foundation 11.7 ms)
编码 2000 键的 keyed container ×10 53.2 ms 1.82 ms 29x
编码 1000 个嵌套 container 的宽对象 ×8 72.2 ms 4.4 ms 16x
snake_case 解码 5000 行(每行 68 键,只读 4 个) 347.5 ms 48.3 ms 7.2x(Foundation 371 ms)
JSONValue.description(638 KB) 8.55 ms 2.01 ms 4.3x
解码 [Int] 200k 6.44 ms 5.85 ms 1.10x
解码 [String] 100k × 8B 1.34 ms 1.00 ms 1.34x
解码 Payload(5000 行 × 7 字段)×10 60.0 ms 56.2 ms 1.07x
编码 Payload ×10 / 窄对象 / [String: Int] — — 持平(0.98~1.05x)

验证方法

  • 环境:Apple Swift 6.4,arm64 macOS,Release(-O)。
  • 确认问题:正确性问题写成和 Foundation 输出对比的测试;性能问题写成复杂度测试(数据量扩大 8 倍,线性实现约 8 倍,平方级约 64 倍,断言比值 < 24)。所有新测试都在修复前的代码上跑过,确认会失败。
  • 性能:在仓库外建了一个独立的基准包(通过 path 依赖本仓库,源码附在文末)。每个修复用 git worktree 构建"修复前的提交"和"修复后"两个二进制,交替运行 3~7 轮,每个用例取所有轮次中的最小值,以消除约 ±5% 的运行间抖动。
  • 并发问题用 release 构建的独立复现程序确认(见 C-1)。

逐项结论

报告编号 结论 处理 提交
C-1 编码器读取 options 未加锁 属实,而且会崩溃 已修复 a620811
P0-1 流式解析 O(K²) 属实 已修复 aaf9695
P0-2 Serialization 写路径慢 属实,而且比报告说的更严重(窄字典也慢 11 倍) 已修复 caddf5f
C-6 校验与转换两次遍历 属实 随 P0-2 一起修复 caddf5f
C-13 Package.swift 3 条警告 属实 已修复 efa7385
P2 编码器 obj_put O(n²) 属实 已修复,保留 last-wins 语义 a3e08a1
C-2 wrapStringKeyedDictValue 的 codingPath 不恢复 属实,报告说"影响≈0"不对:用户用 try? 吞掉错误后,后续错误的路径是错的 已修复 073e395
C-4 nestedUnkeyedContainer 不复用已有数组 属实,会丢数据 已修复 1447757
C-3 绕过 _key() 快路径 只有写法问题成立;".custom 下多一次分配"不成立,两条路径分配相同 随 C-4 统一写法 1447757
(新发现).custom 键策略的 codingPath 不完整 属实 已修复 7ea4e78
(新发现)超出 64 位的整数被静默截断 属实 已修复 5c9d699
P1 NUMBER_AS_RAW 二次解析 属实,但收益比报告估计的小 修复整数路径;浮点路径受 Decimal 精度约束,保持不变 8b78b8c
C-5 snake_case 急切构建整个字典 开销属实;报告建议的"按需反查"不可行 改为优化转换本身 45aa0c5
C-9 JSON.string 的 memchr 属实,并且确认这个分支在语义上多余 已修复 9d2d283
C-10 description 系列 属实,而且问题更多(字符串没转义、NUL 截断、uint 输出为负数、raw 数字输出为 null) 已修复 8971da7
C-12 DocumentStorage/DocumentRef 代码重复 属实 已重构 47b7f9c
C-7 IncrementalParser O(N·K) 属实,但代码和文档都已写明是有意的取舍 不修 —
C-8 .sortedKeys 整树深拷贝 属实,但代价可以接受 不修 —
C-11 unboxInteger 错误路径重复解析 属实,只发生在抛错路径上 不修 —
C-14 swift-tools-version 6.4 有意为之 不需要改 —
C-15 employee.bin / .DS_Store 误报:两者都被 .gitignore 忽略,从未进入 git 不需要改 —

各修复详情

1. C-1 编码器读取 options 未加锁(a620811)

确认:一个线程循环修改 userInfo/keyEncodingStrategy,另一个线程循环调用 encode。独立复现程序在 release 构建下 5 次运行全部 SIGSEGV(exit 139),swift test -c release 下测试进程 signal 11 崩溃。原因是 encode 无锁拷贝 options,而 setter 在锁内替换 userInfo 字典,读线程用到了已经释放的旧字典。(TSan 在这个场景下没有报告,所以最终以崩溃复现为准。)

修复:与 ReerJSONDecoder 保持一致,新增 optionsSnapshot(),在 encode 开头加锁取一次快照,并直接交给 JSONEncoderImpl,outputFormatting 也从同一份快照读取(顺带把每次 encode 的加锁次数从 3 次减到 1 次)。

效果:复现程序 5/5 正常退出,测试 3/3 通过。性能持平:

用例 修复前 修复后 变化
encode WideObject(8) ×2500 1.930 1.922 1.00x
encode WideObject(128) ×156 3.674 3.713 0.99x
encode Payload ×10 45.015 45.399 0.99x

2. P0-1 流式解析 O(K²)(aaf9695)

确认:复杂度测试中,条目数扩大 8 倍,耗时扩大 128~137 倍(jsonLines 和 jsonArray 两种模式都是)。

修复:没有采用报告建议的"切片 + INSITU"方案,而是用了更简单、语义完全不变的自适应窗口:每次只把 max(256, 上一条长度 × 2) 字节交给 yyjson;如果窗口内无法确定结果(数据不完整、值恰好结束在窗口末尾,或者解析出错),就把窗口翻倍重试。当窗口覆盖整个剩余缓冲区时,行为与原实现完全一致,所以边界延迟产出、错误位置和错误信息都不变。新增了混合大小条目、分块喂入、数字恰好在缓冲区末尾、有效条目之后出现错误等测试。

效果(单位 ms):

用例 修复前 修复后 变化
jsonLines 1000 条 0.449 0.106 4.2x
jsonLines 4000 条 7.173 0.442 16.2x
jsonLines 16000 条(946 KB) 120.547 1.975 61.0x
jsonArray 16000 条 112.546 2.296 49.0x
jsonLines 16000 条,64 KB 分块 9.078 1.870 4.9x
jsonLines 16000 条,4 KB 分块 2.424 1.844 1.3x
StreamingJSONLinesDecoder 16000 条 124.165 11.389 10.9x

3. P0-2 + C-6 ReerJSONSerialization 写路径(caddf5f)

确认:宽字典复杂度测试中,键数扩大 8 倍,耗时扩大 44 倍。实测问题比报告说的严重:6 键的普通行数据也比 Foundation 慢 11.6 倍(85 ms 对 7.3 ms)。用 sample 采样后发现,主要热点不在 yyjson,而在 Swift 运行时对 Any 的动态类型转换(swift_conformsToProtocol、tryCast、dyld 协议一致性查找),以及 NSString 桥接成 String 时的拷贝。

修复:

  • obj_put 改为 obj_add(NSDictionary 的键天然唯一)。
  • 校验合并进转换过程,删掉单独的 isValidJSONObject 预遍历;错误仍然是 JSONError.invalidData("Invalid JSON object"),fragment 的 NaN 错误信息也保持原样。公开的 isValidJSONObject(_:) 行为不变。
  • Darwin 上:把值统一转成 AnyObject,用 ObjC 类检查分派类型;容器用 CFDictionaryGetKeysAndValues/CFArrayGetValues 一次性取出全部元素;字符串优先走 CFStringGetCStringPtr(仅 ASCII 存储时成功),否则用 CFStringGetBytes 写入栈上临时缓冲区;未配对代理项仍然退回到原来的 String 桥接,保证输出一致。Linux 上保留原来基于 Any 的分派逻辑。
  • 新增测试:与 JSONSerialization 的输出逐字节对比(包含非 ASCII、emoji、NSMutableString、嵌套、UInt64.max 等)、各类非法对象的错误信息、未配对代理项。

效果(单位 ms,×10 次):

用例 修复前 修复后 变化 Foundation
NSDictionary(100) 1.373 0.095 14.5x —
NSDictionary(1000) 30.291 0.799 37.9x 1.195
NSDictionary(2000) 77.381 1.621 47.7x —
5000 行 × 6 键 85.187 5.024 17.0x 7.179
5000 行 sortedKeys 89.249 6.581 13.6x 11.630

现在所有场景都快过 Foundation。另外,仓库自带的 SerializationMicroBenchmarkTests 让 XCTest 总耗时从 11.3 秒降到 2.6 秒。

4. C-13 Package.swift 警告(efa7385)

确认:每次构建都会输出 found 3 file(s) which are unhandled。这 3 个文件(Info.plist、instruments.json、json_to_swift.rb)都没有被任何测试引用。

修复:加到 exclude 里,而不是作为资源打包进测试 bundle。修复后构建没有这条警告。

5. P2 编码器 keyed container 用 obj_put,O(n²)(a3e08a1)

确认:复杂度测试中,键数扩大 8 倍,耗时扩大 59 倍。

修复:对象键数小于 32 时仍用 obj_put;达到 32 键后,为该对象懒建一个 Set<String> 键索引:新键走 obj_add,只有真正重复时才回退到 obj_put 原地替换。这样保留了"后写覆盖、位置不变"的语义,也就是 skill 记录里提到的 keyStrategyDuplicateKeys 等 3 个测试依赖的行为。索引按需创建,窄对象不产生额外分配;referencing encoder(superEncoder)共享同一个索引。新增了 5/31/32/33/200 键下的重复键、superEncoder、"字典作为 single value 后再用 keyed container 追加"等测试。

效果:

用例 修复前 修复后 变化
WideObject(8) ×2500 1.925 1.950 0.99x
WideObject(32) ×625 1.574 1.590 0.99x
WideObject(128) ×156 3.708 1.730 2.1x
WideObject(512) ×39 14.218 1.812 7.9x
WideObject(2000) ×10 52.565 1.862 28.2x
Payload ×10 45.912 46.262 0.99x

6. C-2 字典值抛错后 codingPath 残留(073e395)

确认:encode(to:) 里先 try? container.encode(["inner": Thrower()], forKey: .dict),再编码一个会抛错的值。ReerJSON 报告的错误路径是 ["dict", "value"],Foundation 是 ["value"]。残留的路径还会传给 .custom 键策略。

修复:改用 defer 恢复路径,同时省掉一次数组拷贝。性能持平([String: Int](1000) 0.97x、[[String: String]] 1.06x,都在噪声范围内)。

7. C-4 + C-3 nestedUnkeyedContainer 不复用已有数组(1447757)

确认:对同一个 key 两次调用 nestedUnkeyedContainer 并各写入一个元素,ReerJSON 输出 {"list":[2]},第一个数组的内容丢失;Foundation 输出 {"list":[1,2]}。

修复:与 nestedContainer 一致,查找并复用同名的已有数组。另外,原来的"查找已有值"用的是 yyjson_mut_obj_getn 线性扫描,宽对象会退化成 O(n²)(测试中比值为 30);现在对象达到 32 键后会先查键索引。C-3 提到的几处直接调用 impl.convertedKey 的地方也统一改为走 _key()。

用例 修复前 修复后 变化
WideNestedObject(8) ×1000 3.645 3.597 1.01x
WideNestedObject(64) ×125 5.609 4.363 1.3x
WideNestedObject(1000) ×8 27.463 4.362 6.3x
Payload ×10 46.450 46.560 1.00x

8. 新发现:.custom 键策略收到的 codingPath 不完整(7ea4e78)

确认:嵌套 container 里的键,Foundation 传给转换闭包的路径是 outer.inner.leaf、outer.leaf.0.leaf,ReerJSON 传入的只是 leaf。原因是嵌套 container 不会把自己的 key 压入 encoder 的路径,而转换时用的是 encoder 的路径。按路径做键映射的用户会得到错误的结果。

修复:改用 container 自己的 codingPath。开销不变(Payload .custom ×10:95.0 → 93.8 ms)。

9. 新发现:超出 64 位的整数被静默截断(5c9d699)

确认:在写 P1 的整数对照测试时发现,-9223372036854775809 解码为 Int/Int64 时没有报错,而是静默得到 Int64.min;Foundation 会抛错。原因是 yyjson 把超出范围的整数读成 double(-2^63),而 T(exactly:) 恰好能精确表示这个 double。

修复:原文是纯整数字面量(没有 ./e/E)时,不接受 double 结果,改用 T(String) 精确解析(Int128 也走这条路径)。这个分支不在整数解码的热路径上。

10. P1 整数二次解析(8b78b8c)

确认:确认每次整数解码都会通过 yyjson_read_number 重新解析原文。新增的对照测试覆盖 10 种整数类型 × 38 个边界字面量,逐一与 Foundation 对比,另外单独测试了 JSON5 的 +1、0x1F。

修复:原文形如 -?[0-9]{1,18} 时直接手写解析(18 位十进制数不可能溢出 Int64),其他情况照旧走原路径。浮点路径没有改:去掉 NUMBER_AS_RAW 会让 Decimal 丢失精度,报告自己也指出了这一点。

用例 修复前 修复后 变化
decode [Int] 200k 6.283 5.615 1.12x
decode [IntRow] 50k 23.104 22.532 1.03x
decode Payload ×10 59.499 56.965 1.04x
decode [Double] 111k 3.714 3.780 0.98x(不在优化范围内)

11. C-5 snake_case 键转换(45aa0c5)

确认:每行多 64 个不参与解码的键,耗时从 13.4 ms 涨到 352 ms,折合每个多余键约 1µs,主要花在 Foundation 算法的 split + capitalized + joined 上。

为什么不按报告建议改成按需查找:convertFromSnakeCase 不是双射(a_b 和 aB 都会映射到 aB),当前"转换后首个出现者胜出"的语义依赖完整的映射表,把请求的键反推成 snake_case 做不到可靠。

修复:保留急切构建,只优化转换本身:没有内部下划线的键直接返回原串;纯 ASCII、且每一段全是字母或全是数字的键,用字节操作完成转换;其他情况回退到原算法(保留为 convertFromSnakeCaseReference)。新增测试包括:与 Foundation 对比 34 个刁钻键名的 allKeys,以及 2 万个随机键的快路径和原算法逐一比对。

用例 修复前 修复后 变化 Foundation
5000 行 × 4 键 13.448 3.974 3.4x 14.875
5000 行 × 20 键 96.397 14.850 6.5x 103.503
5000 行 × 68 键 351.929 47.685 7.4x 371.404

12. C-9 JSON.string 的 memchr(9d2d283)

确认:两个分支分别用 String(bytes:encoding:) 和 String(decoding:as:),对合法 UTF-8 的结果完全相同(包括内嵌 NUL);解码器不会启用 allowInvalidUnicode,所以这个扫描是多余的。含 \u0000 的对照测试在修复前后都与 Foundation 一致。stringWhenJSON5 遇到 NUL 返回 nil 是有意为之的行为,保持不变。

用例 修复前 修复后 变化
decode [String] 100k × 8B 1.299 0.996 1.30x
decode [String] 100k × 64B 5.222 4.811 1.09x
decode [String] 100k × 512B 16.805 15.944 1.05x

13. C-10 JSONValue.description(8971da7)

确认:报告说的 inf/nan 只算半成立,因为 yyjson 在 allowInfAndNaN 下接受 inf/nan。但实测发现了更严重的问题,修复前 4 处断言失败:

  • 字符串和键没有转义,含 " 或换行的内容会生成无法解析的输出;
  • 用 String(cString:) 读取字符串,"a\u0000b" 被截断成 "a";
  • 超过 Int64.max 的无符号整数输出为负数(18446744073709551615 → -1);
  • numberAsRaw/bigNumberAsRaw 解析出的数字输出为 null。

修复:直接遍历 yyjson 值,写入同一个字节缓冲区;正确转义字符串;非有限浮点数输出 Infinity/-Infinity/NaN(与库里 writer 一致)。输出格式(": "、", ")保持不变,原有的 description 测试全部通过。

用例 修复前 修复后 变化
description 638 KB 文档 8.103 2.036 4.0x
description 深度 200 ×100 12.236 1.326 9.2x

14. C-12 文档解析代码重复(47b7f9c)

把拷贝解析和就地解析(INSITU + padding)抽成两个私有函数,DocumentStorage/DocumentRef 只保留各自的所有权差异;同时删除了没有调用者的 Span 版 streamParse。纯重构,流式解析基准全部持平(0.96~1.03x)。

另外还有一个只改测试的提交(e29084a),把混合大小流式测试的分块大小从 7 调到 997 字节,测试耗时从约 8 秒降到 0.1 秒。


不修的项

  • C-7 IncrementalParser.read() 是 O(N·K):代码注释和 JSONIncrementalReader 的文档都已经写明这是有意的取舍,而且 P0-1 的窗口方案不适用于单个大文档。
  • C-8 .sortedKeys 整树深拷贝:实测 638 KB 文档普通写出 0.42 ms,sortedKeys 写出 1.29 ms,Foundation 12.96 ms。排序必须在可变 DOM 上进行,要去掉拷贝就得自己写一个 writer,投入产出不划算。
  • C-11 unboxInteger 失败时重复解析:只发生在抛错路径上,开销是微秒级。
  • C-14 swift-tools-version 6.4:库用到了 Span、~Copyable 等新特性,这是有意为之。
  • C-15 employee.bin / .DS_Store:git check-ignore 显示两者分别被 .gitignore 的 *.bin 和 .DS_Store 规则忽略,从未提交过。

报告第 3 节中已被报告自己实测证伪的两个想法(解码容器 class → struct、withCString → yyObjGet)没有做。

复现基准

基准包放在仓库之外(../ReerJSONPerf),通过 path 依赖本仓库,没有提交到本 PR。复现方式:./ab.sh <修复前的提交> <分组> [轮数],其中分组可选 stream、serial、numbers、wide、snake、strings、desc、sorted。

Package.swift / ab.sh / compare.py
// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "ReerJSONPerf",
    platforms: [.macOS(.v15)],
    dependencies: [
        .package(path: Context.environment["REER_PATH"] ?? "../ReerJSON"),
    ],
    targets: [
        .executableTarget(
            name: "PerfCheck",
            dependencies: [.product(name: "ReerJSON", package: "ReerJSON")],
            swiftSettings: [.unsafeFlags(["-O"])]
        ),
        .executableTarget(
            name: "RaceRepro",
            dependencies: [.product(name: "ReerJSON", package: "ReerJSON")]
        ),
    ]
)
#!/bin/bash
# ab.sh <before-rev> <group> [rounds]  — compares <before-rev> against the ReerJSON working tree
set -e
cd "$(dirname "$0")"
REV=$1; GROUP=$2; ROUNDS=${3:-5}
git -C ../ReerJSONWT/base/ReerJSON checkout -q --detach "$REV"
REER_PATH=../ReerJSONWT/base/ReerJSON swift build -c release --product PerfCheck --scratch-path .build-base 2>&1 | grep -E "error" || true
cp .build-base/release/PerfCheck bin/before
swift build -c release --product PerfCheck 2>&1 | grep -E "error" || true
cp .build/release/PerfCheck bin/after
python3 compare.py bin/before bin/after "$GROUP" "$ROUNDS"
#!/usr/bin/env python3
"""Interleaved A/B benchmark: compare.py <binA> <binB> <group> [rounds]"""
import re
import subprocess
import sys

a, b, group = sys.argv[1], sys.argv[2], sys.argv[3]
rounds = int(sys.argv[4]) if len(sys.argv) > 4 else 3
line_re = re.compile(r"^(.*?)\s+min\s+([\d.]+) ms\s+median\s+([\d.]+) ms")


def run(binary):
    out = subprocess.run([binary, group], capture_output=True, text=True, check=True).stdout
    res = {}
    for line in out.splitlines():
        m = line_re.match(line)
        if m:
            res[m.group(1).strip()] = float(m.group(2))
    return res


best = {a: {}, b: {}}
order = []
for _ in range(rounds):
    for binary in (a, b):
        for name, v in run(binary).items():
            if name not in order:
                order.append(name)
            best[binary][name] = min(v, best[binary].get(name, float("inf")))

na, nb = a.split("/")[-1], b.split("/")[-1]
print(f"| 用例 | {na} (ms) | {nb} (ms) | 变化 |")
print("|---|---:|---:|---:|")
for name in order:
    va, vb = best[a].get(name), best[b].get(name)
    if va is None or vb is None:
        continue
    ratio = va / vb if vb else float("inf")
    print(f"| {name} | {va:.3f} | {vb:.3f} | {ratio:.2f}x |")
Sources/PerfCheck/main.swift
import Foundation
import ReerJSON

// MARK: - Harness

@inline(never)
func blackHole<T>(_ x: T) { withExtendedLifetime(x) {} }

func measure(_ name: String, iters: Int = 7, _ body: () throws -> Void) rethrows {
    try body() // warm-up
    var samples: [Double] = []
    for _ in 0..<iters {
        let t0 = DispatchTime.now().uptimeNanoseconds
        try body()
        samples.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6)
    }
    samples.sort()
    let padded = name.padding(toLength: 52, withPad: " ", startingAt: 0)
    print(String(format: "%@ min %9.3f ms   median %9.3f ms", padded, samples[0], samples[samples.count / 2]))
}

let groups = Set(CommandLine.arguments.dropFirst())
func enabled(_ g: String) -> Bool { groups.isEmpty || groups.contains(g) }

// MARK: - Payloads

struct Nested: Codable { var a: Int; var b: String }
struct Row: Codable {
    var id: Int; var name: String; var score: Double; var active: Bool
    var nickname: String?; var nested: Nested; var tags: [String]
}
struct Payload: Codable { var rows: [Row] }

let payload = Payload(rows: (0..<5000).map { i in
    Row(id: i, name: "name_\(i)_\(i % 97)", score: Double(i) * 1.5 + 0.25,
        active: i % 2 == 0, nickname: i % 3 == 0 ? nil : "nick_\(i)",
        nested: Nested(a: i % 1000, b: "b_\(i % 31)"), tags: ["t\(i % 7)", "u\(i % 11)"])
})
let payloadData = try! JSONEncoder().encode(payload)

// MARK: - stream (P0-1)

if enabled("stream") {
    print("=== stream: JSONStreamParser, whole buffer fed at once ===")
    for k in [1000, 2000, 4000, 8000, 16000] {
        var nd = ""
        for i in 0..<k {
            nd += "{\"id\":\(i),\"name\":\"item_\(i)\",\"value\":\(Double(i) * 1.5),\"flag\":true}\n"
        }
        let ndData = Data(nd.utf8)
        try measure("jsonLines \(k) items (\(ndData.count) B)", iters: 5) {
            var parser = JSONStreamParser(mode: .jsonLines)
            let a = try parser.parse(ndData)
            let b = try parser.finalize()
            precondition(a.count + b.count == k)
        }
    }
    for k in [1000, 2000, 4000, 8000, 16000] {
        var s = "["
        for i in 0..<k {
            if i > 0 { s += "," }
            s += "{\"id\":\(i),\"name\":\"item_\(i)\",\"value\":\(Double(i) * 1.5),\"flag\":true}"
        }
        s += "]"
        let data = Data(s.utf8)
        try measure("jsonArray \(k) items (\(data.count) B)", iters: 5) {
            var parser = JSONStreamParser(mode: .jsonArray)
            let a = try parser.parse(data)
            let b = try parser.finalize()
            precondition(a.count + b.count == k)
        }
    }
    do {
        var nd = ""
        for i in 0..<16000 {
            nd += "{\"id\":\(i),\"name\":\"item_\(i)\",\"value\":\(Double(i) * 1.5),\"flag\":true}\n"
        }
        let ndData = Data(nd.utf8)
        for chunk in [4096, 65536] {
            try measure("jsonLines 16000 items, \(chunk) B chunks", iters: 5) {
                var parser = JSONStreamParser(mode: .jsonLines)
                var n = 0
                var off = 0
                while off < ndData.count {
                    let end = min(off + chunk, ndData.count)
                    n += try parser.parse(ndData.subdata(in: off..<end)).count
                    off = end
                }
                n += try parser.finalize().count
                precondition(n == 16000)
            }
        }
        try measure("StreamingJSONLinesDecoder 16000 items", iters: 5) {
            struct Item: Decodable { let id: Int; let name: String; let value: Double; let flag: Bool }
            var d = StreamingJSONLinesDecoder(Item.self)
            let a = try d.parseBuffer(ndData)
            let b = try d.finalize()
            precondition(a.count + b.count == 16000)
        }
    }
}

// MARK: - serial (P0-2)

if enabled("serial") {
    print("=== serial: ReerJSONSerialization.data(withJSONObject:) x10 ===")
    for n in [100, 250, 500, 1000, 2000] {
        let dict = NSMutableDictionary()
        for i in 0..<n { dict["key_\(i)"] = i }
        try measure("ReerJSONSerialization NSDictionary(\(n))") {
            for _ in 0..<10 { blackHole(try ReerJSONSerialization.data(withJSONObject: dict)) }
        }
        if n == 1000 {
            try measure("JSONSerialization     NSDictionary(\(n))") {
                for _ in 0..<10 { blackHole(try JSONSerialization.data(withJSONObject: dict)) }
            }
        }
    }
    let rows: [[String: Any]] = (0..<5000).map { i in
        ["id": i, "name": "name_\(i)", "score": Double(i) * 1.5 + 0.25, "active": i % 2 == 0,
         "tags": ["a", "b"], "nested": ["a": i, "b": "x"]]
    }
    let arr = rows as NSArray
    try measure("ReerJSONSerialization 5000 rows x6 keys") { blackHole(try ReerJSONSerialization.data(withJSONObject: arr)) }
    try measure("JSONSerialization     5000 rows x6 keys") { blackHole(try JSONSerialization.data(withJSONObject: arr)) }
    try measure("ReerJSONSerialization 5000 rows sortedKeys") { blackHole(try ReerJSONSerialization.data(withJSONObject: arr, options: .sortedKeys)) }
    try measure("JSONSerialization     5000 rows sortedKeys") { blackHole(try JSONSerialization.data(withJSONObject: arr, options: .sortedKeys)) }
}

// MARK: - numbers (P1)

struct DoublesPayload: Codable { var values: [Double] }
struct IntsPayload: Codable { var values: [Int] }
struct IntRow: Codable { var a: Int; var b: Int; var c: Int; var d: Int64; var e: UInt32; var f: Int }

if enabled("numbers") {
    print("=== numbers: decode ===")
    let doubles = DoublesPayload(values: (0..<111_080).map { Double($0) * 0.731 + 0.123 })
    let doublesData = try JSONEncoder().encode(doubles)
    let ints = IntsPayload(values: (0..<200_000).map { $0 &* 7919 % 1_000_003 - 500_000 })
    let intsData = try JSONEncoder().encode(ints)
    let intRows = (0..<50_000).map { i in IntRow(a: i, b: -i, c: i * 31, d: Int64(i) * 1_000_000_007, e: UInt32(i), f: 42) }
    let intRowsData = try JSONEncoder().encode(intRows)
    let r = ReerJSONDecoder(), f = JSONDecoder()
    try measure("decode [Double] 111k  ReerJSON") { blackHole(try r.decode(DoublesPayload.self, from: doublesData)) }
    try measure("decode [Double] 111k  Foundation") { blackHole(try f.decode(DoublesPayload.self, from: doublesData)) }
    try measure("decode [Int] 200k     ReerJSON") { blackHole(try r.decode(IntsPayload.self, from: intsData)) }
    try measure("decode [Int] 200k     Foundation") { blackHole(try f.decode(IntsPayload.self, from: intsData)) }
    try measure("decode [IntRow] 50k   ReerJSON") { blackHole(try r.decode([IntRow].self, from: intRowsData)) }
    try measure("decode [IntRow] 50k   Foundation") { blackHole(try f.decode([IntRow].self, from: intRowsData)) }
    try measure("decode Payload x10    ReerJSON") { for _ in 0..<10 { blackHole(try r.decode(Payload.self, from: payloadData)) } }
}

// MARK: - wide (P2)

struct WideObject: Encodable {
    let keys: [String]
    struct K: CodingKey {
        var stringValue: String; var intValue: Int? { nil }
        init(stringValue: String) { self.stringValue = stringValue }
        init?(intValue: Int) { nil }
    }
    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: K.self)
        for (i, k) in keys.enumerated() { try c.encode(i, forKey: K(stringValue: k)) }
    }
}

struct WideNestedObject: Encodable {
    let count: Int
    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: WideObject.K.self)
        for i in 0..<count {
            var list = c.nestedUnkeyedContainer(forKey: .init(stringValue: "list_\(i)"))
            try list.encode(i)
            var obj = c.nestedContainer(keyedBy: WideObject.K.self, forKey: .init(stringValue: "obj_\(i)"))
            try obj.encode(i, forKey: .init(stringValue: "v"))
        }
    }
}

if enabled("wide") {
    print("=== wide: encode keyed container with N keys ===")
    let e = ReerJSONEncoder()
    for n in [8, 32, 128, 512, 2000] {
        let obj = WideObject(keys: (0..<n).map { "key_\($0)" })
        let reps = max(1, 20_000 / n)
        try measure("encode WideObject(\(n)) x\(reps)") {
            for _ in 0..<reps { blackHole(try e.encode(obj)) }
        }
    }
    for n in [8, 64, 1000] {
        let obj = WideNestedObject(count: n)
        let reps = max(1, 8_000 / n)
        try measure("encode WideNestedObject(\(n)) x\(reps)") { for _ in 0..<reps { blackHole(try e.encode(obj)) } }
    }
    try measure("encode Payload x10 ReerJSON") { for _ in 0..<10 { blackHole(try e.encode(payload)) } }
    let custom = ReerJSONEncoder()
    custom.keyEncodingStrategy = .custom { $0.last! }
    try measure("encode Payload x10 .custom keys") { for _ in 0..<10 { blackHole(try custom.encode(payload)) } }
    let dict = Dictionary(uniqueKeysWithValues: (0..<1000).map { ("key_\($0)", $0) })
    try measure("encode [String: Int](1000) x20") { for _ in 0..<20 { blackHole(try e.encode(dict)) } }
    let rowsOfDicts = (0..<2000).map { i in ["id": "\(i)", "name": "n\(i)", "kind": "k"] }
    try measure("encode [[String: String]] 2000 rows x10") { for _ in 0..<10 { blackHole(try e.encode(rowsOfDicts)) } }
}

// MARK: - snake (C-5)

struct SnakeRow: Codable {
    var userId: Int; var userName: String; var createdAt: String; var isActive: Bool
}

if enabled("snake") {
    print("=== snake: convertFromSnakeCase decode ===")
    func makeRows(extraKeys: Int) -> Data {
        var s = "["
        for i in 0..<5000 {
            if i > 0 { s += "," }
            s += "{\"user_id\":\(i),\"user_name\":\"n\(i)\",\"created_at\":\"2020\",\"is_active\":true"
            for j in 0..<extraKeys { s += ",\"extra_field_number_\(j)\":\(j)" }
            s += "}"
        }
        return Data((s + "]").utf8)
    }
    for extra in [0, 16, 64] {
        let data = makeRows(extraKeys: extra)
        let r = ReerJSONDecoder(); r.keyDecodingStrategy = .convertFromSnakeCase
        let f = JSONDecoder(); f.keyDecodingStrategy = .convertFromSnakeCase
        try measure("snake 5000 rows, \(4 + extra) keys ReerJSON") { blackHole(try r.decode([SnakeRow].self, from: data)) }
        try measure("snake 5000 rows, \(4 + extra) keys Foundation") { blackHole(try f.decode([SnakeRow].self, from: data)) }
    }
}

// MARK: - strings (C-9)

if enabled("strings") {
    print("=== strings: decode [String] ===")
    let r = ReerJSONDecoder()
    for len in [8, 64, 512] {
        let strings = (0..<100_000).map { i in String(repeating: "a", count: len - 6) + String(format: "%06d", i) }
        let data = try JSONEncoder().encode(strings)
        try measure("decode [String] 100k x \(len)B") { blackHole(try r.decode([String].self, from: data)) }
    }
}

// MARK: - desc (C-10)

if enabled("desc") {
    print("=== desc: JSONValue.description ===")
    let value = try JSONValue(data: payloadData)
    try measure("JSONValue.description Payload (638KB)") { blackHole(value.description) }
    var deep = ""
    for _ in 0..<200 { deep += "{\"k\":[1,\"s\"," }
    deep += "0"
    for _ in 0..<200 { deep += "]}" }
    let deepValue = try JSONValue(data: Data(deep.utf8))
    try measure("JSONValue.description depth 200 x100") { for _ in 0..<100 { blackHole(deepValue.description) } }
}



// MARK: - sorted (C-8)

if enabled("sorted") {
    print("=== sorted: JSONValue.data ===")
    let value = try JSONValue(data: payloadData)
    try measure("JSONValue.data() Payload") { blackHole(try value.data()) }
    try measure("JSONValue.data(.sortedKeys) Payload") { blackHole(try value.data(options: .sortedKeys)) }
    let obj = try JSONSerialization.jsonObject(with: payloadData)
    try measure("JSONSerialization sortedKeys (Foundation)") { blackHole(try JSONSerialization.data(withJSONObject: obj, options: .sortedKeys)) }
}
C-1 并发复现程序(RaceRepro)
import Foundation
import ReerJSON

let encoder = ReerJSONEncoder()
nonisolated(unsafe) let e = encoder
let key = CodingUserInfoKey(rawValue: "k")!
let group = DispatchGroup()
group.enter(); group.enter()
Thread {
    for i in 0..<20_000 {
        e.userInfo = [key: i]
        e.keyEncodingStrategy = i % 2 == 0 ? .convertToSnakeCase : .useDefaultKeys
    }
    group.leave()
}.start()
Thread {
    for i in 0..<20_000 { _ = try? e.encode(["fooBar": i]) }
    group.leave()
}.start()
group.wait()
print("done")

vnixx and others added 16 commits September 24, 2026 19:24
ReerJSONEncoder.encode read the stored `options` without taking
optionsLock, while every setter mutates it under the lock. Concurrent
configuration changes could therefore race with encode and crash with a
use-after-free of the userInfo dictionary. Take one locked snapshot at
the start of encode (matching ReerJSONDecoder) and use it for both the
encoder implementation and the output formatting flags.

Co-authored-by: Cursor <cursoragent@cursor.com>
JSONStreamParser handed yyjson every byte from the read offset to the
end of the buffer for each item. In non-INSITU mode yyjson copies its
whole input, so K buffered items cost O(K * N) bytes of memcpy and the
parse time grew quadratically (16000 NDJSON items: 120 ms).

Parse a window sized from the previous item (at least 256 bytes) and
double it only when the window cannot decide the value by itself:
incomplete input, a value ending exactly at the window end, or an error.
Once the window covers all available bytes the parser behaves exactly as
before, so boundary deferral and error reporting are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
ReerJSONSerialization.data(withJSONObject:) was 11-60x slower than
JSONSerialization. Profiling showed three causes:

- yyjson_mut_obj_put scans the whole object on every insert, making wide
  dictionaries O(n^2). NSDictionary keys are unique, so obj_add is safe.
- isValidJSONObject walked the full object graph before conversion
  walked it again. Validation now happens during conversion and still
  throws the same "Invalid JSON object" error.
- Most of the remaining time was Swift dynamic casts from Any and
  NSString → String bridging copies. On Darwin, values are now dispatched
  with class checks on AnyObject, containers are read with
  CFDictionaryGetKeysAndValues / CFArrayGetValues, and strings are copied
  straight from CFString storage. Linux keeps the Any-based dispatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
SwiftPM warned about Info.plist, Models/instruments.json and
Models/json_to_swift.rb on every build. None of them are loaded by the
tests, so exclude them instead of bundling them as resources.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keyed containers insert with yyjson_mut_obj_put so that re-encoding a key
replaces its value in place, but obj_put scans the whole object on every
insert, making objects with many keys O(n^2) (2000 keys: 2.6 µs/key).

Objects below 32 keys keep using obj_put. Once an object reaches 32 keys
the encoder lazily builds a key set for it; new keys are appended with
obj_add and only genuine duplicates fall back to obj_put, preserving the
last-value-wins, original-position semantics. The index is shared with
referencing encoders so superEncoder writes stay in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
wrapStringKeyedDictValue restored codingPath only on the success path.
If a value threw and the caller recovered (e.g. `try? container.encode`),
the dictionary key stayed on the path, so later errors and .custom key
strategies saw a wrong coding path ([dict, value] instead of [value],
unlike Foundation). Pop the key in a defer.

Co-authored-by: Cursor <cursoragent@cursor.com>
nestedContainer(keyedBy:forKey:) reused an existing object for the key,
but nestedUnkeyedContainer(forKey:) always inserted a new array, so a
second call for the same key replaced the first array and dropped its
elements ({"list":[2]} instead of Foundation's {"list":[1,2]}).
Look up and reuse an existing array like Foundation does.

The existing-value lookup scanned the object linearly, which made wide
objects built from nested containers O(n^2); objects with 32+ keys now
consult the key index first. nestedContainer and superEncoder also go
through the container's _key() helper instead of calling convertedKey
directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
…rategy

The .custom key encoding strategy was called with the encoder's current
path plus the key, but nested keyed containers do not push their key onto
the encoder, so converters saw ["leaf"] where Foundation passes
["outer", "inner", "leaf"]. Convert keys with the owning container's
codingPath instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
yyjson reads an integer literal outside the Int64/UInt64 range as a
double. integer() accepted any double exactly representable in T, so
-9223372036854775809 rounded to -2^63 and silently decoded as Int64.min,
while Foundation throws. Only accept the double for literals with a
fraction or exponent; plain integer literals are parsed exactly from the
raw text (which still covers Int128/UInt128).

Co-authored-by: Cursor <cursoragent@cursor.com>
The decoder reads numbers as raw text (needed for Decimal, Int128 and
big integers), so every integer decode re-parsed its literal through the
general yyjson_read_number. Literals of the form -?[0-9]{1,18} (which
cannot overflow Int64) are now converted directly; everything else still
takes the existing path. decode [Int] 200k: 6.28 → 5.62 ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
With .convertFromSnakeCase every keyed container converts all keys of
the JSON object, including keys the type never reads. The Foundation
algorithm used for this (split + lowercased + capitalized + joined) cost
about 1 µs per key, so wide objects dominated decode time.

Keys without inner underscores are now returned as is, and ASCII keys
whose components are all letters or all digits are converted with byte
operations. Other keys still use the original algorithm, which is kept
as the reference and fuzz-tested against the fast path. The eager table
is kept because snake-case conversion is not invertible, so requested
keys cannot be mapped back reliably.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every decoded string was scanned for NUL bytes to choose between
String(bytes:encoding:) and String(decoding:as:). For the valid UTF-8
yyjson hands back both produce the same String, embedded NULs included,
so the scan only added an extra pass (decode [String] 100k x 8 B:
1.30 → 1.00 ms). stringWhenJSON5, which deliberately rejects NULs, is
unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
JSONValue/JSONObject/JSONArray.description produced JSON-looking text
that did not parse back and lost data:

- strings and keys were not escaped (a quote or newline broke the output)
  and were read with String(cString:), truncating at embedded NULs;
- unsigned integers above Int64.max printed as negative numbers;
- raw numbers (numberAsRaw / bigNumberAsRaw) printed as null;
- non-finite doubles printed as inf/nan instead of Infinity/NaN.

Render directly from the yyjson value into one byte buffer, keeping the
existing ": " / ", " layout. This also removes the per-level [String]
arrays and joins (638 KB document: 8.1 → 2.0 ms).

Co-authored-by: Cursor <cursoragent@cursor.com>
…ocumentRef

DocumentStorage and DocumentRef each carried a verbatim copy of the
copying and in-place (INSITU + padding) parse logic, so a fix to one could
silently miss the other. Move both into private helpers and keep only the
ownership difference in the two types. Also drop the unused Span overload
of DocumentRef.streamParse. No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
Feeding a 300 KB item 7 bytes at a time re-parses it from the start on
every call and took ~8 s; 997-byte chunks exercise the same boundaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
a3e08a1 routed every keyed-container insert through putKey, which reads
yyjson_mut_obj_size on each call. That check is itself cheap, but on the
common narrow-object path it is pure overhead: a fresh object never
reaches the index, so every insert pays a size read and a branch for
nothing. Re-encoding Twitter (its user objects carry ~40 keys, so they
sit just above the old threshold of 32) regressed ~22%: 0.70 ms -> 0.87 ms.

Count inserts in the container instead. Narrow objects stay on the plain
yyjson_mut_obj_put fast path and never call putKey; only once a container
has written threshold keys does it hand over to the shared index, which
discovers the earlier keys lazily. Raise the threshold to 256 so that
payloads in the tens-of-keys range never build a key set at all.

Verified: Twitter re-encode back to 0.706/0.707/0.708 ms over three
alternating rounds (main 0.701/0.720/0.712, pr-head 0.863/0.865/0.872).
All 143 tests pass.
@vnixx
vnixx merged commit 9b38f12 into main Sep 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant