REDox内存分配为何降低约70%:惰性解码与按需物化原理详解
【免费下载链接】REDoxHigh-performance, token-based structured data engine for .NET. A core component of REX, the technology behind CAPCOM's next-generation game engine.项目地址: https://gitcode.com/gh_mirrors/redox/REDox
REDox 是面向 .NET 的高性能 token 化结构化数据引擎(CAPCOM 次世代游戏引擎 REX 技术的核心组件之一),支持 JSON、CBOR、MessagePack 等格式的反序列化与序列化。在 canada.json 基准测试中,REDox 反序列化仅分配约 2.56 MB 内存,而 System.Text.Json 需要约 8.53 MB——内存分配直接降低约 70%。本文带你拆解这背后的两个核心设计:惰性解码与按需物化。
📊 先看数据:内存分配降低约 70% 的基准结果
官方基准(BenchmarkDotNet,.NET 10,X64)中,REDox 对 canada.json 的分配量只有 System.Text.Json 的约三分之一:
| 数据集 | REDox 分配 | System.Text.Json 分配 | 降幅 |
|---|---|---|---|
canada.json(数值密集,约 2.2 MB) | 2,619.92 KB | 8,734.66 KB | ≈70% |
citm_catalog.json | 553.55 KB | 1,175.38 KB | ≈53% |
twitter.json | 504.91 KB | 557.09 KB | ≈9% |
💡 数值越密集、结构越规整的数据集(如 canada.json),收益越明显——因为惰性机制可以"跳过"大量未解码的值。
完整基准数据见 README.md,测试数据定义位于 Canada.cs。
原理一:64 位固定 token + 源偏移引用,解析阶段不拷贝数据
传统 JSON 解析器边解析边把每个字符串、数字构造成托管对象;而 REDox 的中间表示是一串固定 64 位的 token:
- 每个值只占一个
long(8 字节),整文档就是一块紧凑的 token 数组,缓存友好; - 字符串、数字等值的 token payload只保存原始缓冲区中的偏移量和长度(
length/offset),而不是值本身; - 顶层结构(对象、数组)直接内联存储元素计数,无需递归构建节点树。
核心结构定义在 DToken.cs,MakeArray/MakeMap等工厂方法展示了 token 如何用位运算编码类型与计数。
这样做的好处是:解析阶段只做结构索引,不做数据拷贝。文档层通过_source字段持有原始输入缓冲区(如 Json5Document.cs 中的ReadOnlyMemory<byte> _source),token 像"书签"一样指向它。
原理二:惰性解码(Lazy Decoding)——值在被请求时才转换
这是降低分配的第一块拼图。REDox 的 token 可以携带指向源缓冲区的偏移/长度,字符串、数字、时间戳、二进制只在被显式读取时才解码(官方文档原话见 README.md)。
具体体现在两层 API 上:
TryGetXxxExact:如果 token 内联了完整值(小整数、布尔等),直接读出,零分配;GetXxxValue:只有Exact失败、真正需要回源解码时,才按 token 里的偏移/长度从_source切片并转换。
访问入口在 DElement.cs:GetString()、TryGetInt32()等方法都先查 token 类型,能精确命中就返回,不命中才触发解码。
🎯 实际效果:如果你反序列化后只读取了文档中 10% 的字段,REDox 就只为这 10% 的值分配解码结果;而未解码的值始终"借用"原始缓冲区的切片,零额外分配。
原理三:按需物化(On-Demand Materialization)——轻量句柄替代对象树
第二块拼图在于"元素是什么"。REDox 中一个元素(DElement)只是三个字段:
DElement = { Document 引用, 第 Id 号 token, 版本号 }见 DElement.cs。它是一个结构体句柄,不是每个 JSON 值一个对象——对比传统 node DOM 中"每个对象/数组/字符串各一个堆对象"的做法,光句柄这一层就省掉了海量小对象。
真正需要独占数据时(比如编辑某个值、或值来自需要完整解码的场景),文档才把结果放入扩展数组_extends(定义于 Document.Internal.cs):
- 未编辑、未解码的 token 保持"源支撑"状态,复用原始切片;
- 只有被物化的值才在
_extends中产生一个托管对象; - 容器视图(
DObject/DArray/DMap)也是按需生成的,配合"提前按已知元素数预分配数组"(结构已完全可寻址,先知道计数再分配),避免了集合扩容造成的多次重新分配。
一句话总结:解析 = 建索引(8 字节/token);读取 = 按需用;编辑 = 按位置物化。这正是"惰性解码 + 按需物化"减少约 70% 分配的原因。
两种模式对比,一图看懂
| 维度 | 传统 node DOM / 即时解析 | REDox token DOM |
|---|---|---|
| 每个值的表示 | 堆对象(节点/字符串/数字) | 8 字节固定 token |
| 字符串数据 | 解析时立即构造 | 偏移+长度,回源解码 |
| 元素句柄 | 对象引用 | 轻量结构体(文档+Id) |
| 数组分配 | 边解析边扩容 | 已知计数,一次预分配 |
| 只读 10% 字段时的成本 | 100% 值全部构造 | 仅 10% 值被解码/物化 |
🔧 如何验证:在自己的项目中复现
- 克隆仓库并构建:
dotnet build REDox.slnx -c Release - 运行 JSON 基准:
dotnet run -c Release --project benchmarks/REDox.Json.Benchmarks - 关注输出中的Allocated列,对比不同数据集与目标类型的分配差异。
注意:基准结果受数据形态、目标类型与运行时影响,建议用自己的工作负载验证(见 REDox.Benchmarks 下的各基准类)。
总结:70% 从哪里省出来的?
- 固定 token:整个文档的中间表示只是紧凑的 64 位 token 数组,替代逐值构造的节点树;
- 源偏移引用:解析不拷贝数据,值以偏移/长度"引用"原始缓冲区;
- 惰性解码:字符串/数字/时间戳在被请求读取时才转换;
- 按需物化 + 预分配:
DElement是廉价句柄,扩展数据按需入_extends,容器按已知大小一次分配到位。
对于"读多写少"的反序列化场景——日志解析、配置加载、API 响应处理——这套机制让 REDox 在更快(最高约 1.8x)的同时,把 GC 压力也压低了约七成,这对高吞吐 .NET 服务与游戏运行时都意义重大。
【免费下载链接】REDoxHigh-performance, token-based structured data engine for .NET. A core component of REX, the technology behind CAPCOM's next-generation game engine.项目地址: https://gitcode.com/gh_mirrors/redox/REDox
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考