轻量化动态编译库 Natasha v8.0 正式发布!
.NET8.0 与 动态编译
Hello 各位小伙伴,我于 2024年1月10日 发布了 Natasha 一个全新的里程碑版本 v8.0,对于老用户而言,此次发布版本号跨度较大,是因为我决定使用新的版本号计划,主版本号将随 Runtime 版本号的增加而增加。
浅谈 .NET8.0
在 .NET8.0 Runtime 方向的深度解析文章出来之前,八卦了一些新闻,例如前阶段的文档收费风波,就那一段时间我觉得粥里的乌江榨菜都不香了;又目睹马某某说微软不开源,这损犊子玩意,以马祭码吧。
.NET8.0 更新的东西真的太多了,仅作为八卦聊聊,比如略低分配的异步状态机,ConfigureAwait 的改进,Win 线程池与托管线程池的切换,减少了 GCHandle 滥用的 Socket,向量运算提升字符操作的性能等,这么串起来,整个网络通信技术栈均有受益,照这么下去,.NET10 时 Asp.net Core 的相关性能测试跑第一也不是不可能的了。话说回来,其中官方比较看重的一项提升 “SearchValues”。官方引入 "SearchValues.Create()" API 根据要查找的字符来返回不同的策略实现,本质上是为了缓解 O(M*N) 的性能问题,定位算法策略多达 10 种,包括简单的"四八取值定位",纯 Ascii 字符定位,范围定位,向量运算定位,位图网格定位等(算法名都没找到,字面意思帮助理解), 除了向量算法属于普通应用盲区,其他算法都蛮好理解的,向量算法可以参考时总写过的一些博客。
动态编译 与 Natasha
在介绍新版之前,必须让新来者了解动态编译相关的知识,动态编译在 .NET 生态中一直扮演着重要角色,例如 Dapper , Json.net , AutoMapper , EFCore , 动态编译版 Razor ,
Orleans 等类库中或多或少都存在动态编译相关的代码,在 Source Generators 出现之前 [运行时动态] 一直是建设 .NET 生态的重要技能,但繁重的 IL 以及 Expression 代码无疑不给开发者带来巨大的维护和升级成本,不仅如此,在执行性能上,Emit 方法的执行性性能只能趋近于原生编译,并不能超过(这里纠正一下看到某篇文章提到 emit 要比原代码编译执行快的观点), 然而 SG 以及 AOT 兼容性方案的出现不仅解决了一些动态代码性能上的问题,还让 .NET 生态顺利开展出另个分支,即 AOT 生态。
说到 AOT, 在启动耗时过长,内存拮据,服务端对发布包大小有严格限制这三类场景中,AOT 如今已经成为开发界所热衷的方案。.NET8.0 中更加全面的支持了 AOT,Asp.net Core 推出了 WebApplication.CreateSlimBuilder() 作为 Web 的 AOT 方案,在 .NET8.0 发布后,除了官方类库,应该属老叶的 FreeSql 在动静兼容上做的是又快又稳了。在 .NET8.0 之前更早实现兼容方案的,不得不提九哥的 WebApiClient。即使有这些前车之鉴,Natasha 也无法参考。 Natasha 作为动态编译类库不得不站在 .NET8.0 的另一个重大技术特性之上,即 .NET8.0 默认开启的动态 PGO 优化。AOT 并不能作为最佳的性能选择方案,相反运行时的最简动态策略以及对机器码的动态优化更加合适性能敏感场景。在此之前我也曾仔细想过 [编译时动态] 的适用场景有多么广泛,能否取代 [运行时动态],结论是不管 [编译时动态] 有多么优秀也无法取代 [运行时动态],彻底放弃 [运行时动态] 的做法也是十分欠考虑的,而且在纯动态业务的场景中 SG 以及 AOT 方案是十分无力的,此时需要一个 [运行时动态] 方案来达到业务目标,这里推荐使用 Natasha.
那么使用新版 Natasha 来完成 [运行时动态] 的相关功能有什么好处呢?
答案是高效快速、轻量方便,智能省心.
Natasha 是基于 Roslyn 开发的,它允许将 C# 脚本代码转换成程序集,并享受编译优化、运行时优化带来的性能提升;在易用性上新版 Natasha 组件层次分明,API 规范可查,在保证灵活性的同时还封装了很多细节;在扩展性上 Natasha 每次更新都会尽量挖掘 Roslyn 的隐藏功能给大家使用;在封装粒度上,Natasha 自有一套减少用户编译成本的方案,让更多的细节变得透明,接下来可以看一看新版 Natasha 都有哪些变化。
Natasha 项目地址:https://github.com/dotnetcore/Natasha
Natasha8.0 的新颜
开发相关
Natasha 应用用了前一篇文章提到的 CI/CD Runner,并加以实战改造,在 PR 管理,ISSUE 管理等管道功能上得到了便利的支持。
此版本我们有三个大方向上的编码任务,分别是功能性上的轻量化路线,扩展性上的动态方法使用率统计,以及兼容性上对 standard2.0 的支持。
Natasha 从本次更新起,停止了对非 LTS .NET 版本进行 UT 测试,在开发者使用非 LTS 版本 Runtime 时小概率可会出现意外情况,若遇到可提交 issue。
一. API 命名规范
-
With 系列 API: 带有关闭、排除、枚举附加值等条件状态开关时使用的 API。 例如:
WithCombineUsingCode
和WithoutCombineUsingCode
,WithHighVersionDependency
、WithLowVersionDependency
、WithDebugCompile
、WithReleaseCompile
、WithFileOutput
等,又例如编译选项的 API 都是作为附加条件赋给选项的,因此都由 With 开头(注:与 Roslyn 风格不同,With 方法不返回新对象). -
Set 系列 API: 属单向赋值类 API, 例如:
SetDllFilePath
、SetReferencesFilter
等. -
Config 系列 API: 具有对主类中,某重要组件的额外配置,通常是各类 options 操作的 API, 例如:
ConfigCompilerOption
、ConfigSyntaxOptions
等. -
特殊功能 API: 此类 API 需要非常独立且明确的功能,常用而显眼,例如
UseRandomDomain
、UseSmartMode
、OutputAsFullAssembly
、GetAssembly
等显眼包 API.
二. 性能提升
新版 Natasha 使用并发的方式将两种预热方法(引用/实现程序集预热)的执行时间从 .NET8.0 实验环境的 2-4s 降低到了 0.700 -1 s 左右;预热的内存涨幅从 60-70M 降到 30-40M。
新版 Natasha 允许开发者灵活管理元数据覆盖策略,比如
- 合并共享域及当前域的元数据.
- 仅使用当前域元数据.
- 仅使用指定的元数据等.
这使得 Natasha 可以支持自定义轻量化编译,在实验案例中轻量化编译比预热编译节省了约 15M 左右的内存。
以下是引用程序集与实现程序集的预热耗时统计截图
引用程序集预热
实现程序集预热
以下是引用程序集与实现程序集的预热内存统计截图
引用程序集预热
实现程序集预热
三. Standard2.0 兼容方案
新版编译单元的依赖项变为了 Standard2.0
, 编译单元项目移除了 System.Reflection.MetadataLoadContext
依赖包, Natasha 将直接从文件中提取元数据,避免一些繁琐的加载操作,另外我们还移除了对 DotNetCore.Natasha.Domain
的依赖,尽管域对于 Natasha 来说十分重要,域作为 Runtime 的重要特性,它严重牵制着 Natasha 的兼容性,为此我对 Natasha 的框架进行了重新设计,将域以及一些运行时方法交由第三方去实现,而 Natasha 只保留和调用 Standard2.0 的接口,这两个接口为 DotNetCore.Natasha.DynamicLoad.Base
包中的 INatashaDynamicLoadContextBase
和 INatashaDynamicLoadContextCreator
,开发者可以根据两个接口自行实现域功能,但这里 Core3.0 以上版本我推荐使用 DotNetCore.Natasha.CSharp.Compiler.Domain
Natasha 官方实现的域功能,该包继承自 DotNetCore.Natasha.Domain
, 这是一个功能强大且稳定的 .NET 域实现包。
当然 Natasha 的使用方式也发生了一些变化:
//首先向 Natasha 加载上下文中注入域创建者实现类 NatashaDomainCreator
//NatashaDomainCreator 来自包 DotNetCore.Natasha.CSharp.Compiler.Domain,实现了 INatashaDynamicLoadContextCreator 接口
NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
//若需要预热,也可以直接使用泛型预热,泛型预热将自动调用 NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
NatashaManagement.Preheating<NatashaDomainCreator>(false, false);
与此同时,新版 Natasha 解耦了编译单元及模板,部分开发者在使用 Natasha 时习惯自己构建脚本代码,而不需要 Natasha 本身模板的参与,为此我们解耦了模板与编译单元的相关代码,现在您可以引用 DotNetCore.Natasha.CSharp.Template.Core
来使用模板的相关功能,或者单独引用 DotNetCore.Natasha.CSharp.Compiler
仅使用编译单元的功能。
对于运行时目前区分了 "Core" 和 "Framework" 版本,"Core" 相关的代码将继续维护着,而与 "Framework" 相关的代码任务已经停止,从去年年底我已无精力去做 Framework 的兼容工作,经济来源对于 2024 年的我来说是个巨大难题,更多的思考与尝试都将围绕着如何维持生活来展开,但是 Natasha 会接受 PR,接受开源贡献者的代码。如果您不想使用上一版本的 Framework 实现,不介意您联系我进行有偿定制,这里也希望诸各位的公司项目早日脱离 Framework 苦海。
四. 域的改进
提到动态编译不得不说的一个前提就是“域”,再次强调这里所说的域是 .NETCore3.0 + 版本的 ALC (程序集加载上下文),Natasha 对 ALC 进行了较全面的封装,您可以单独引用 Natasha.Domain
以便进行插件加载等操作,
本次更新我对域操作进行了修正与补充:
- Natasha 实现的 ALC 将避开依赖程序集的重复加载。
- 我发现之前的代码中,在共享域加载为主的逻辑中,ALC 默认将程序集交由共享域处理,共享域处理不过接由当前域处理,新版本在确定共享域存在程序集的情况下,将直接返回共享域的程序集,无需另外处理。
- 在依赖程序集被排除的情况下,如果该程序集在共享域中存在,将返回共享域的程序集。
新增 Natasha.CSharp.Compiler.Domain
项目继承 Natasha.Domain 项目并实现基础编译接口。
使用域加载插件
domain1.LoadPluginXXX(file)
在 Natasha 中使用加载插件,并加载插件元数据及 Using Code.
var loadContext = DomainManagement.Random();
//或
var loadContext = (new AssemblyCSharpBuilder().UseRandomDomain()).LoadContext;
var domain = (NatashaDomain)(loadContext.Domain);
//排除基类接口,否则你反射出来的方法只能在当前域编码使用(更多详情请学习微软官方关于插件的相关知识)。
Func<AssemblyName, bool>? excludeInterfaceBase= item => item.Name!.Contains("IPluginBase");
//获取插件程序集.
var assembly = domain.LoadPluginWithHighDependency(file, excludeInterfaceBase);
//添加元数据以及 using code.
loadContext.AddReferenceAndUsingCode(assembly, excludeInterfaceBase);
五. 元数据管理优化
元数据以及 using code 对于 Roslyn 编译来说属于重点依赖对象,新版 Natasha 增加了 NatashaLoadContext 来管理元数据,在 vs 开发过程中,由于动态脚本没有智能提示和隐式 using 覆盖,因此早期 Natasha 推出了透明模式,让元数据管理变得透明不可见,预热过程将缓存元数据和 using code,使用时自动覆盖元数据引用以及 using code。对于 using code 的全覆盖,类似于近期 vs 推出的隐式 usings 的功能,Natasha 还为编译单元增加了语义过滤器的支持,以便自动处理编译诊断。
同时 NatashaLoadContext 还支持解析实现程序集和引用程序集,早期 Natasha 仅在预热时会缓存引用程序集的元数据,而如今,Natasha 不仅支持两种程序集的预热还支持在不预热的情况下允许开发者自管理元数据。
/// <summary>
/// 预热方法,调用此方法之前需要调用 RegistDomainCreator<TCreatorT> 确保域的创建
/// </summary>
/// <param name="excludeReferencesFunc"></param>
/// <param name="useRuntimeUsing">是否使用实现程序集的 using code</param>
/// <param name="useRuntimeReference">是否使用实现程序集的元数据</param>
/// <param name="useFileCache">是否使用 using 缓存</param>
public static void Preheating(
Func<AssemblyName?, string?, bool>? excludeReferencesFunc,
bool useRuntimeUsing = false,
bool useRuntimeReference = false,
bool useFileCache = false);
预热案例1: 自动注入域实现,从内存中的 [实现程序集] 中提取元数据和 using code.
NatashaManagement.Preheating<NatashaDomainCreator>(true, true);
预热案例2: 手动注入域实现, 从 refs 文件夹下的 [引用程序集] 中提取元数据和 using code. (需提前引入 DotNetCore.Compile.Environment 包).
NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
NatashaManagement.Preheating(false, false);
预热案例3: 自动注入域实现,从 refs 文件夹下的 [引用程序集] 中提取 using code. (需提前引入 DotNetCore.Compile.Environment 包),从内存中的[实现程序集]中提取元数据, 此种方法一旦运行过一次,就会产生 using 缓存文件,此时即使删除 refs 文件夹程序仍会正常工作.
NatashaManagement.Preheating<NatashaDomainCreator>(false, true, true);
六. 多种编译模式
1. 智能编译模式
使用智能编译模式,编译单元 AssemblyCSharpBuilder 将默认合并 共享加载上下文(NatashaLoadContext.DefaultContext) 和 当前上下文(builder.LoadContext) 的元数据以及 using code,并自动开启语义过滤,如下是较完整的使用代码:
1.若不使用内存程序集,则需要引入 DotNetCore.Compile.Environment
来输出引用程序集。
2.预热并注册域实现。
3.启用智能模式编码。
NatashaManagement.Preheating<NatashaDomainCreator>();
AssemblyCSharpBuilder builder = new();
var myAssembly = builder
.UseRandomDomain()
.UseSmartMode() //启用智能模式
.Add("public class A{ }")
.GetAssembly();
2. 轻便编译模式
新版 Natasha 允许开发者使用编译单元进行轻量级编译,如果您只是想创建一个计算表达式或者一个简单逻辑的映射,建议您使用编译单元的轻便模式进行动态编译。轻便模式不会合并主域的元数据和 using 代码,只会使用当前域的,并且不会触发语义过滤。
AssemblyCSharpBuilder builder = new();
builder
.UseRandomDomain()
.UseSimpleMode() //启用轻便模式
.ConfigLoadContext(ldc=> ldc
.AddReferenceAndUsingCode(typeof(Math).Assembly)
.AddReferenceAndUsingCode(typeof(MathF))
.AddReferenceAndUsingCode(typeof(object)))
.Add("public static class A{ public static int Test(int a, int b){ return a+b; } }");
var func = builder
.GetAssembly()
.GetDelegateFromShortName<Func<int,int,int>>("A", "Test");
func(1,2);
3. 自定义编译模式
AssemblyCSharpBuilder builder = new();
builder
.UseRandomDomain()
.WithSpecifiedReferences(元数据集合)
.WithoutCombineUsingCode()
.WithReleaseCompile()
.Add("using System.Math; using System; public static class A{ public static int Test(int a, int b){ return a+b; } }");
其中 WithSpecifiedReferences
方法允许您传入引用集合,例如 Roslyn 成员提供的Basic.Reference.Assemblies
引用程序集包。由于案例中指定了 WithoutCombineUsingCode
方法,该方法将不会自动覆盖 using code, 因此脚本中需要手动添加 using code例如 using System;
。
七. 动态调试
新版本 Natasha 允许在编译单元在指定 Debug 编译模式后,使用 VS 进入到方法内进行调试.
同时这里介绍一种隐藏的 Release 模式,该模式允许在生成程序集时携带有 Debug 相关的信息,之前被定义为 Debug 的 Plus 版本/可调试的 Release 模式,还可以增加您反编译时的可读性(这个功能 Roslyn 随后几个版本可能会加入到优化级别的枚举中暴露给开发者)。
也许我们已经在 VS 中体验过了?这个功能后续我会继续跟进测试研究。
//调试信息写入文件,原始的写入方式,对 Win 平台支持良好
builder.WithDebugCompile(item=>item.WriteToFileOriginal())
//调试信息写入文件,兼容性写入方式
builder.WithDebugCompile(item=>item.WriteToFile())
//调试信息整合到程序集中
builder.WithDebugCompile(item=>item.WriteToAssembly())
//Release 发布无法进行调试
builder.WithReleaseCompile()
//Release 模式将携带 debugInfo 一起输出
builder.WithFullReleaseCompile()
案例
AssemblyCSharpBuilder builder = new();
builder
.UseRandomDomain()
.UseSimpleMode()
.WithDebugCompile(item => item.WriteToAssembly())
.ConfigLoadContext(ldc=> ldc
.AddReferenceAndUsingCode(typeof(object).Assembly)
.AddReferenceAndUsingCode(typeof(Math).Assembly)
.AddReferenceAndUsingCode(typeof(MathF).Assembly));
builder.Add(@"
namespace MyNamespace{
public class A{
public static int N1 = 10;
public static float N2 = 1.2F;
public static double N3 = 3.44;
private static short N4 = 0;
public static object Invoke(){
int[] a = [1,2,3];
return N1 + MathF.Log10((float)Math.Sqrt(MathF.Sqrt(N2) + Math.Tan(N3)));
}
}
}
");
var method = builder
.GetAssembly()
.GetDelegateFromShortName<Func<object>>("A", "Invoke");
//断点调试此行代码
var result = method();
八. 程序集输出
Natasha 8.0 版本允许您在动态编译完成后输出完整程序集或引用程序集,注意这里并没有进行什么智能判断,需要您手动控制行为,域加载引用程序集会引发异常。请看以下例子来达到仅输出的目的。
//编译结果为引用程序集,且写入文件,且不会加载到域。
builder
.OutputAsRefAssembly();
.WithFileOutput()
.WithoutInjectToDomain();
注: 如果您希望把 Natasha 作为一个插件生产器,那么很遗憾,目前它并不能像 VS 编辑器那样输出完整的依赖以及依赖文件。
九. 输出文件
Natasha 支持 dll/pdb/xml 文件输出,其中 xml 存储了程序集注释相关的信息。参考 API
//该方法将使程序集输出到默认文件夹下的 dll/pdb/xml 文件中
//可传入一个文件夹路径
//可以传入三个文件的路径
builder.WithFileOutput(string dllFilePath, string? pdbFilePath = null, string? commentFilePath = null)
builder.WithFileOutput(string? folder = null);
//分离的 API
builder.SetDllFilePath/SetPdbFilePath/SetCommentFilePath();
周边扩展
一. 动态程序集方法使用率统计
众所周知,单元测试中测试方法覆盖率统计通常使用 VS 自带的工具进行静态统计,还有 CLI 工具,这里 Natasha 推出一种新的扩展,允许开发者动态的统计[由 Natasha 生成的动态程序集]中的[方法]使用情况,目前已通过测试,并发布了第一个扩展包。此项技术还需要搜集需求和建议,因此我们的 ISSUE 被设置为 phase-done,欢迎大家留言提需求和建议。
使用方法:
- 引入
DotNetCore.Natasha.CSharp.Extension.Codecov
扩展包。 - 编码并获取结果。
builder.WithCodecov();
Assembly asm = builder.GetAssembly();
List<(string MethodName, bool[] Usage)>? list = asm.GetCodecovCollection();
情景假设: A 类中有方法 Method , Method 方法体共 6 行代码逻辑,在执行过程中仅执行了前4行。
result 集合如下:
"MyNamespace.A.Method":
[0] = true,
[1] = true,
[2] = true,
[3] = true,
[4] = false,
[5] = false,
二. 动态只读字典
目前该库还是维护状态,因为它是仅 Natasha 关键项目之外的最重要项目,但目前没有随着 Natasha 发布新版。
动态只读字典通过正向特征树算法,计算最小查找次数(权值)来动态构建一段查找代码,并交由 Natasha 编译,并提供 GetValue 、 TryGetValue 、Change 、索引操作。
我对 .NET8.0 推出的冻结字典进行了性能对比,对比环境 .NET8.0, 字典类型 FrozenDictionary<string, string>, 对比结果:
冻结字典除非后续在 JIT 动态优化出更简洁高效的代码,否则它无法在这个场景中超越动态字典,主打性能的类库越精细越不好优化,特征算法目前来讲十分复杂且构建低效,在特征过多时构建延迟十分明显,代码上需要进行优化与重构,Swifter.Json 作者提出了差异算法,且经过案例推演也证实差异算法在某些场景中可以取得更小的权值,因此我们需要引入差异算法来与特征算法形成竞争,对于代码脚本来说,下一步我将使用更高效的 Runtime API 来提高代码执行性能,争取在下一个版本呢取得更好的性能,后续我们还将横向对比 Indexof / SearchValue 等高性能查找算法,以确定在特殊情况下是否能够借鉴 Runtime 中的算法来提升性能。
在性能过剩的今天,ConcurrentDictionay 已经满足大部分人的需求了,这个类库没有带给我任何金钱收益和荣誉成就,甚至至今为止也未受到过任何需求,因此此库优先级对我来说很低,对一个初级算法都不到的人来说,这库挺令我头疼,也许最好的走向是让一个英语好的,头脑思路清晰的小伙子把算法思路提交给官方,让官方动态优化冻结字典。
结尾
即便 Roslyn 版的 Natasha 已经发布几年时间,但我对 Roslyn 仍然有一种陌生且无力的感觉,Roslyn 文档少的可怜,更多的功能还需要自己去研究挖掘,我会将一些提上日程的重要开发计划发布到 issue 中并征集意见,例如:https://github.com/dotnetcore/Natasha/issues/240 , 开发不易,求个 Star。
Natasha 项目地址:https://github.com/dotnetcore/Natasha
Natasha 文档地址: https://natasha.dotnetcore.xyz/zh-Hans/docs/ (文档需要改善可在 https://github.com/dotnetcore/Natasha.Docs 中提交 issue)