源码:https://github.com/Martin1994/JsonJitSerializer
NuGet:https://www.nuget.org/packages/MartinCl2.Text.Json.Serialization/
简介:Just-in-time 编译的 JSON 序列化,基于 System.Text.Json
.NET Core 3.0 即将正式发布,其中一项令人振奋的功能是 corefx 集成了一个 JSON 库用来替代 JSON.NET,目前我按照 namespace 称这套库为 System.Text.Json。
这一套 JSON 库吸取了一部分 JSON.NET
了解到这一点后我意识到可以用这套底层 API(具体来说是 Utf8JsonWriter
)来实现一个 just-in-time 编译(本质上其实是 IL generation)的 JSON 序列化库。
为何 JSON 序列化可以从 JIT 中受益呢?
System.Text.Json
实现 JSON 序列化的步骤是:
- 利用反射读出需要序列化的 class 的结构;
- 缓存每个需要序列化的 property,包括其名字(用 UTF-8 存储)、getter method 以及对应的 converter;
- 每次需要序列化的时候逐条读取这个结构化的缓存并利用
Utf8JsonWriter
序列化为 JSON stream。
可以注意到步骤 2 到 3 其实有点类似于解释执行的脚本语言。既然是解释执行,那自然可以有其对应的 JIT 优化,将解释的内容直接编译成可执行的代码。这样可以省去一些存取的开销和动态类型检查的开销。具体可以减小多少开销可以参照 benchmark 的结果:
System.Text.Json_Async | Mean - 592.6 ns | Allocated Memory/Op - 304 B
MartinCl2.Text.Json_Async | Mean - 346.0 ns | Allocated Memory/Op - 152 B
其中第二行是这个库的数值。取自 Json_ToStream_LoginViewModel_
测试。完整数据请见附录。
此外,这个序列化库的所有 public API 的签名及行为我尽可能的保持与 System.Text.Json
保持一致,因此采用了 System.Text.Json
的代码应该可以几乎无缝地与这个 JIT 序列化库来回切换。
System.Text.Json
:
static async Task CompileAndSerializeAsync<T>(Stream stream, T obj)
{
JsonSerializerOptions options = new JsonSerializerOptions()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
await JsonSerializer.SerializeAsync(stream, obj, options);
}
JsonJitSerializer
:
static async Task CompileAndSerializeAsync<T>(Stream stream, T obj)
{
JsonJitSerializer<T> serializer = JsonJitSerializer<T>.Compile(new JsonSerializerOptions()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await serializer.SerializeAsync(stream, obj);
}
JIT 与 code generator 相比的优劣
类似的优化完全可以通过 code generator 实现,区别在于 code generator 在编译前生成 C# code,而 JIT 在运行时生成 IL code。
最显著的区别显然是 JIT 是在运行时才知道需要序列化的 class 的结构,而 code generator 必须事先知道。但其实需要运行时才知道结构的情况非常少见,所以这一点上区别不大。
但是从功能上来看,运行时才知道结构的 JIT 显然就更自由了。比方说 System.Text.Json
提供了自定义的 converter,而具体的 converter 是要到运行时才知道的,code generator 对此只能将 converter 当作抽象的接口来处理,而 JIT 却可以直接精细到具体的 class 甚至是 instance。
从启动时间上来看 code generator 或许会占有一定优势,因为 JIT 的运行时编译,包括其中 reflection 的消耗,多少会占用一定的资源。这一点在需要经常冷启动的 serverless 架构上或许会更明显。
从抠墙缝级别的优化来看两者各有千秋。JIT 由于是直接生成 IL,跳过了 C# 的抽象,可以做一些很极端的优化,例如跳过类型检查、激进的 devirtualization 等。但反过来说,code generator 由于最终还是由 C# 编译器编译,因此可以享受到很多编译器带来的优化,例如函数内联。我在实现这个 JIT serializer 的时候必须重新手动实现很多本来是编译器负责的优化,例如 foreach
中,数组(例如 T[]
)、value type enumerator(例如 List<T>
) 和 reference type enumerator(例如 IEnumerable<T>
)出于性能目的是会分别编译成不同的 IL 的,而不是简单的 IEnumerator
那几个函数的语法糖(详见下文「GC-free C# 编程」的最后)。
从实现难度上来看,也各有各的难处。从生成代码的可读性和调试难度上来看,code generator 更好,毕竟最终就是 C# 代码。从读取 class 结构的难度上来看 JIT 简直是白送的,因为可以直接使用 C# 的 reflection。不过考虑到 C# 的编译器 Roslyn 的口碑,说不定从源码读 class 结构这件事没有预想中那么难。
而对我来说很重要的一点是与开发环境的集成性。在用户使用 JIT 方案的时候对开发环境的侵入性是 0,换句话说不需要任何其他的工具链就能直接无缝使用。而 code generator 方案则有大大小小的侵入,这一点在高度依赖 code generator 的 Java 开发中有非常明显的体现,因为其本质类似于 MACRO。例如 IDE 几乎必须要依赖专门编写的插件才能在编译前就获得 generated code 中的 symbol。Java 的 lombok 是一个具体的例子:若是没有 lombok 插件, IntelliJ Idea 完全无法知道有自动生成的 getter 和 setter 的存在。另一个侵入的例子是:由于 code generator 在编译过程中需要提前生成代码,那或多或少就需要自定义编译链。举例来说有很多上古时代的 C 项目中有 generated C code,而我做 Windows 移植的时候必须先想办法让 20 年前的 code generator 跑起来之后才能编译,而调用这个 code generator 的 bash script 中又会出现各式目瞪口呆的不兼容。此外在自定义编译链的情况下,CI/CD 也需要额外的配置。如果编译链中有多个 code generator,甚至还有先后顺序的依赖,项目一大就会产生巨大的痛苦。
编写这套库时的一些心得及随想
Generated IL 的调试
有一些 Visual Studio 的插件可以看到生成的 IL,但我没有找到 VSCode 上的实现,因此我是完全靠肉眼定位 bug 的。
首先,使用 Emit
生成的 IL 会有少量的 validation。例如 .NET 会帮你确认栈是不是平衡的(MSIL 是基于栈的虚拟机)。如果碰到有 runtime exception 说什么 invalid IL,那多半是少压了一个栈。
但是多数情况下静态分析不能找出太多错误。例如栈压反了的时候只会有 runtime exception,有时甚至连 runtime exception 都不会有。这是因为 MSIL 其实是没有自动类型检查的,只有少数几个指令自带了类型检查。比方说你可以压一个 DateTime
在在栈上,然后调用 String.Length
,多半是会给出一个乱七八糟的数而不是报错。调试这种问题就需要一些奇技淫巧了。
在 .NET Framework 的时代,Emit
生成的 IL 是可以加 debugging symbol 的,也就是 IL 到 C# 源代码的 mapping。但 .NET Core 中的这个 API 被砍掉了(估计被 cut scope 了吧),因此不能用 IDE 在生成的 IL 里面加断点,也不能在 exception 的 stack trace 里面看到行号。这就导致一旦 generated code 半当中 throw exception,就只能靠二分法来定位行号。
而具体二分法的操作方法是在代码中插入断点,这有点类似于 JavaScript 的 debugger
语句,执行到的时候会自动将程序暂停。在 C# 中 debugger
语句对应的方法是 System.Diagnostics.Debugger.Break()
。当然,对于动态生成的 IL 来说这也是要 emit 的而不是直接调用,因此实际上的代码是:ilg.Emit(OpCodes.Call, typeof(System.Diagnostics.Debugger).GetMethod("Break"));
在找到问题行了之后就要确认问题所在了,然而这也不是一个直观的过程。在 debug 普通 C# code 的时候,一般的做法是在问题行加断点之后,看一下 local variable 之类的是否正常。但对于动态生成的代码,debugger 并不能读出的 local variable。更何况对于 MSIL 而言大多数时候我想知道的是虚拟机栈上的内容,甚至都不在 local variable 里。因此这个时候就要借助祖传的 print 大法了(从学编程的第一天开始可以一路用到带进棺材,真香)。ILGenerator
有一个很方便的 EmitWriteLine
方法,可以直接 print 一个 local variable。我一般的做法是先把我要看的栈顶 dup
一下,然后 GetType
,把 Type
放进一个 local variable,最后打印。确定了类型无误之后再去慢慢 print 里面的值,看是否正常。
对象「常量」
MSIL 提供了直接在 IL 中载入数值或字符串常量的功能。唯一一个可以在编译期就确定的对象(引用类型)「常量」是各个 typeof(T)
。那有什么办法可以在代码中使用对象「常量」呢?static field 嘛……static field 其实就是 C# 的 global variable。加个 readonly 加个 initializer,就和使用常量无异了。
这对于动态生成的代码来说其实是更常见的一个需求。例如这个 JSON 序列化库中会用到各式各样的 converter,而每一个 property 用到的 converter 其实是固定死的,因此完全可以写死在生成的代码里。而写死的方法与之前提到的正常写的时候用的方法无异——在动态生成的 class 里加 static field,然后生成的代码直接载入这个 static field。
唯一需要注意的是,在动态生成的 class 定型(调用 CreateType()
)前是不能往 static field 里面写东西的,因此生成 IL 的时候必须先创建好 field 并且记录下来每个 field 需要写入的值,待 class 定型之后再一并通过 reflection 写入。这导致了一个缺陷:static field 不能是只读的,因此理论上并不是个常量。不过考虑到动态生成的 class 必须要通过 reflection 才能写 static field,也没必要对这一点吹毛求疵……
GC-free C# 编程
要说 C# 的高性能编程,和 C++ 比到底差在哪?C# 有丰富的多线程原语、有栈上分配、有可控的 struct layout、有 unsafe 指针操作、有开箱即用的 native call、现在甚至还有 hardware intrinsics 做 SIMD。到底有什么地方离底层语言仍有差距?
就我目前的感觉来看,差距最大的是 allocate-free(自然地也是 GC-free)的能力。虽说 C# 有 value type,可以栈上分配,但这仅仅停留在理论,实际操作有非常多的阻碍,并不像 C++ 那样如吃饭喝水般自然。举例来说:
假设现在栈上有一个 B
的实例和一个 C
的实例,我要对其中的 A
进行某种通用的处理 ProcessA
。对于 C++ 来说,在没有内存拷贝的情况下仅仅通过引用传递来做到这一点是家常便饭,但 C# 则不一定。
对于现版本的 C# 而言,B
可以比较自然的做到,因为在 B
中 A
是一个 field:
而 C
则完全无法做到,因为在 C
中 A
是一个 property。虽说平常写的是 c.A
,但实际上编译出来的是 c.get_A()
。而其中 C.get_A()
的返回值是 A
而不是 ref A
,这个返回值可不是能在外部被 caller 控制的。要想让 property 返回一个引用,就必须将 property 的类型设置成 ref T
,此外这个 property 也不能有 setter。
更雪上加霜的是,C# 的 best practice 是只暴露 public property 而不是 public field。你可以翻翻看 MSDN 上 corefx 各大 class 的 public API,根本找不到任何一个 public field。就连 KeyValuePair<TKey, TValue>
都是通过 property 获取的 key value。这意味着什么呢?在 C# 中,instance method 在编译后的第一个参数是 this
,而对于 struct 来说是 ref this
。说如果我要写诸如 kvp.Value.SomeMethod()
的代码,那编译器就必须先将 kvp.Value
的值复制到一个 local variable 里,再对这个 local variable 取 ref。
可能有人会觉得,那就井水不犯河水,哪怕 corefx 都是用的 public property,只要自己的高性能代码用 public field 就好了嘛。但很多时候这是很局限的。难道自己的代码就完全不用 Dictionary<TKey, TValue>
、不用 Stream
、不用 Task<T>
了吗?这是不现实的。因此只有 corefx 全方位改动了之后才会出现更多的 C# 高性能编程。
而提升 struct 利用率这一点其实最近一直在进行(不过无关 public field,这是 one-way door,估计已经改不回来了),像是 ref return、Span<T>
、ValueTask<T>
都是为了减少内存分配或者内存拷贝作出的系统性改进。而实际上,利用了这些的新 C# code 可以有非常小的 GC 压力。各位可以在附录的 benchmark 中关注一下内存分配这一栏:Utf8Json
是早些年以接近 0 内存分配为目标而实现的 JSON 序列化库,在有些测试中,内存上的表现其实是不如更重、功能更多、但是享受了最新的这些优化的这个库的。不过往远处想的话,如果 C# 的 ref 和 struct 还要在此之上获得更进一层的表达力,我估计就要引入类似 Rust 将生命周期作为参数传递的机制了。
在 C# 尽可能利用 struct 的种种优化中值得一提的是 struct enumerator。众所周知 C# 的 iterator 是通过 IEnumerable<T>
来实现的。但 IEnumerable.GetEnumerator()
的返回值是 IEnumerator
,是个接口,也就是说重载的 method 无法返回一个不装箱的 struct。但是——谁说一定要重载了?很多人认为 C# 的 foreach
只是简单的翻译成几个 IEnumerable<T>
的函数调用,但实际上这两者是独立的。foreach
实际上会直接调用当前类型的 GetEnumerator()
(不是接口调用),也就是说你可以人为定义一个返回 struct 的 GetEnumerator()
。而事实上 corefx 里的那些你所知道的 collection class 已经在大量使用这个方法了。
void*(?
在直接写 IL 的情况下,Object
其实是可以当作类似 C++ 的 void*
用的:只要是任意的 reference type,就可以自由地存入一个 Object
field 或 local variable 然后取出,其中不涉及到任何 casting 和类型检查。
而实际上哪怕是 value type 也是可以这么干的,只要确保底层的数据长度不大于 field 的长度。当然,这已经属于 undefined behaviour 了。
这样做的目的是将运行时的内存消耗降低到与数据结构的深度线性相关,而不是深度的 2 的幂。换句话说,应该用栈的方式使用内存。序列化一个嵌套结构和遍历一棵树的逻辑是相似的。比方说,如果一个结构内含有子结构 A 和 B,序列化完 A 之后 B 应该能重用序列化 A 时的空间。最原生的以栈的方式利用内存的办法是调用函数。但由于需要支持异步调用,函数需要能够重入(从上次退出的地方继续执行,详见下文「函数重入」章节)。函数重入对于 stackful coroutine 来说是原生的,但对于 C# 的 stackless coroutine 来说就需要额外的工作了。异步栈其实是分配在堆上的,而且每次新的异步调用都会分配新的内存而不是一次性分配全部,这不满足 GC-free 的前提。因此最佳的方案只能是将一系列的 Object
类型的 field 当作栈来用。
而对于不定长度的 value type,目前还没有比较好的办法。或许用 unsafe + pointer casting 可行。
函数重入
由于 JSON 的使用场景多半会和文件操作或网络操作共存,JSON 序列化需要支持异步调用(async/await),也就是说通过 IL 生成的函数需要支持重入。
先简单说明一下背景。目前 coroutine 有两大派系:stackful coroutine 和 stackless coroutine。前者的代表是 Golang,而 C# 是后者。他们的区别正如其名:coroutine 有没有自己的栈。stackful coroutine 其实很像操作系统级别的线程,context switch 之后会完整保留栈。其好处是原生的代码可以直接无缝接入 coroutine,编译器也无需做额外的处理,而坏处是需要和操作系统级的线程一样与分配栈内存,实现上要为每个平台单独处理 context switch,这其中还包含了 memory barrier 之类的处理。而 stackless coroutine 则直接按需将栈分配在堆上,其本质是 generator(yield return
)与 callback 对接。其好处是内存粒度小,实现相对简单(因为平台无关),但对应地其坏处是小粒度的异步调用性能低下,以及需要编译器的支持(意味着动态代码生成很难做)。
在这个序列化库中,我选择的重入方案是将整个序列化编译成一个巨大的函数,然后在需要重入的地方插入 label,最外面套一个巨大的 switch 语句。不过整个过程需要编译两次,因为 emit switch 语句的时候必须预先知道有多少个 case。但幸运的是由于同步和异步的序列化本身出于性能考量就会生成两个独立的函数,所以只要先生成同步函数,生成的过程中记录一些必要信息(case 的数量),再用这些信息去生成异步函数就可以了。
编译成一个巨大函数的代价是重入点必须在这个函数上,因此不能很好地通过函数调用利用栈空间(见上文「void*」)。
Pooled array buffer
如果仔细阅读一下 benchmark 的话,可以注意到无论是这个利用 JIT 的序列化库还是 corefx 自带的 System.Text.Json
,居然都是异步方法的内存分配(GC 压力)比同步方法更少,甚至有一些测式中异步方法的耗时都能比同步方法更少!而这显然是不科学的,因为相比同步操作,异步操作需要额外的堆分配(Task
),并且还有额外的重入开销。
导致这一现象的原因其实(我认为)是一个实现上的失误。.NET Core 3.0 的这套 JSON API 其实并不会直接向 Stream 中写数据,而是通过了一层 array buffer 来做中转。这层 array buffer 的长度是可变的,因此不能在堆上作分配(C# 现在是可以堆分配类似数组的 Span<T>
的,详情请搜索 stackalloc
)。出于性能考虑,在 System.Text.Json
内部实现了一个基于 array pool 的 array buffer:PooledByteBufferWriter。然而,在通过 Stream
创建 Utf8JsonWriter
的时候ef="github.com/dotnet/coref">用的却是 ArrayBufferWriter<byte>。这导致了同步方法每次调用都会有数组分配,反而异步方法在并发不变的情况下基本不会有新的分配。
事实上使用这套 PooledByteBufferWriter
的效果非常不错。具体来说,有一些粒度非常小的同步操作其实并不适合直接改成异步,例如每次只往 Stream
里写一个字节对于同步操作来说是可接受的,但对于 C# 的异步操作来说额外的开销非常大(见「函数重入」中关于 coroutine 的讨论)。这本是 stackful coroutine 的一个优势,但 stackless coroutine 在利用类似 PooledByteBufferWriter
机制的情况下也能做到很不错的效果。
附录:Benchmark
代码: 定制的 dotnet/performance/micro (从 dotnet/performance 的一个分支修改而来)
命令: dotnet run -c Release -f netcoreapp3.0 --filter *Json_ToStream*