lews397715 发表于 2025-3-28 15:00:42

使用 C# 进行AI工程开发-基础篇(二):NativeAOT

NativeAOT 是 dotnet 新增加的运行模式。其中,AOT是 Ahead-Of-Time 的缩写,和 JIT 边运行边编译不同,NativeAOT 直接将 IL 代码编译为目标平台的机器码发布,它的文件大小、启动时间和内存占用均比 JIT 低。
对 dotnet 程序员来说,Native AOT 是个新事物,这里单独拆一篇,补充介绍 NativeAOT。
dotnet6 下的 Native AOT 开发体验还不行。dotnet7 的 NativeAOT 的开发体验非常不错了。本文默认的dotnet版本为 dotnet7。
一、Hello World!NativeAOT

先看一个 Hello World 级别的例子。新建名叫“HelloWorldNativeAOT”的控制台项目,程序代码非常简单,就一句话:
Console.WriteLine("Hello, World!");程序的开发、测试、调试均是在 JIT 模式下。只有当发布时,才会生成二进制程序。比如运行:
dotnet publish -r win-x64 -c Release -p:PublishAot=true在 bin/Release/net7.0/win-x64/publish 目录下可以看到:
Mode               LastWriteTime         Length Name----               -------------         ------ -----a---         2023/6/22   6:44      2997760 HelloWorldNativeAOT.exe-a---         2023/6/22   6:44       12210176 HelloWorldNativeAOT.pdb已经生成了 exe 程序和 pdb 调试文件。exe 程序接近 3M,可以独立发布运行。在 powershell 下,可以通过 Measure-Command 来测量程序的运行时间(linux 下通过 time 来测量),运行:
Measure-Command{.\HelloWorldNativeAOT.exe}------------------------------------------......TotalMilliseconds : 25.8797运行时间为 25.9 ms。下面,我们来比较下 JIT 模式下生成文件的大小和运行时间:
dotnet publish -r win-x64 --self-contained -c Release -p:PublishAot=false -p:PublishSingleFile=true在 bin/Release/net7.0/win-x64/publish 目录下可以看到:
Mode               LastWriteTime         Length Name----               -------------         ------ -----a---         2023/6/22   7:02       66841392 HelloWorldNativeAOT.exe-a---         2023/6/22   7:02          10636 HelloWorldNativeAOT.pdb带运行时的 Hello World 大小为 66.8M。测量运行时间:
Measure-Command{.\HelloWorldNativeAOT.exe}------------------------------------------......TotalMilliseconds : 81.5271进行 trim 和压缩:
dotnet publish -r win-x64 --self-contained -c Release -p:PublishAot=false -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:PublishTrimmed=true在 bin/Release/net7.0/win-x64/publish 目录下可以看到:
Mode               LastWriteTime         Length Name----               -------------         ------ -----a---         2023/6/22   7:06       10304405 HelloWorldNativeAOT.exe-a---         2023/6/22   7:06          10284 HelloWorldNativeAOT.pdb尺寸仍然比 NativeAOT 下大很多。测量运行时间:
Measure-Command{.\HelloWorldNativeAOT.exe}------------------------------------------......TotalMilliseconds : 127.4261通过 Nuget 引入 “System.Diagnostics.PerformanceCounter”,修改程序代码,除了打印 "Hello, World!" 外,也打印程序所占内存:
using System.Diagnostics;Console.WriteLine("Hello, World!");var name = Process.GetCurrentProcess().ProcessName;var ramCounter = new PerformanceCounter("Process", "Working Set", name);float ram = ramCounter.NextValue()/(1024*1024);Console.WriteLine($"所耗内存: {ram.ToString("0.00")} MBytes");测试下上面三个程序的内存占用,分别为:22.86 MBytes、35.86 MBytes和32.20 MBytes。
汇总比较,列表如下:
对比项NativeAOTJITJIT(Trim&Compress)文件大小3M66.8M10.3M运行时间25.9ms81.5ms127.4ms内存占用22.86 M35.86 M32.20 M由此可见,NativtAOT 相对于 JIT 运行,主要有下面的优势:

[*] 文件尺寸小:这个是最显著的,相比较 JIT 模式,AOT 下文件尺寸小了很多。
[*] 启动速度快:由于测试程序非常简单,只有一行,这里的程序运行时间,反映的是程序启动时间。JIT 模式下,由于需要即时编译,启动时间比 AOT 下慢多了。
[*] 内存占用低:AOT 下程序的内存占用要低于JIT。
NativeAOT 也可以开发动态链接库。
新建名为 HelloNativeLibrary 的项目,在项目中添加如下代码:
using System.Runtime.InteropServices;namespace HelloNativeLibrary{    public class Class1    {            public static void HelloWorld()      {            Console.WriteLine("Hello World!");      }    }}运行:
dotnet publish -r win-x64 -c Release -p:PublishAot=true在 bin/Release/net7.0/win-x64/publish 目录下可以看到:
Mode               LastWriteTime         Length Name----               -------------         ------ -----a---         2023/6/22   7:21      2989056 HelloNativeLibrary.dll-a---         2023/6/22   7:21       12120064 HelloNativeLibrary.pdb这个动态链接库,可以供其它语言调用。
二、NativeAOT 的使用场景

根据我这几个月使用经验来看,NativeAOT 主要适用于这些场景:

[*] 云原生:Serverless 下压榨资源
[*] 增加反编译的难度:jit 模式下,dotnet 的程序跟裸奔差不多,即使代码混淆了,也很容易破解和反编译
[*] SDK 开发:可以采用 csharp 开发动态链接库,给其它语言服务,以前,这是 c/cpp 的领地,现在,csharp 也能干了。
[*] 满足特殊场景的需求,比如,iOS 下不允许 JIT,以及 wasm 环境下,减少程序尺寸,提高运行速度。
下面一一解说。
(1)云原生
这是微软搞 NativeAOT 的主要目的。云计算是微软非常重要的业务,云原生、Serverless 计算是云计算的未来。
什么是 Serverless 计算呢?如其名,就是不需要服务器了。当有计算需求时,我们不需要租赁单独的服务器或云服务器。实时的向云厂商发一个请求,拖一个镜像,创建容器,启动程序,开始计算,计算完毕后,释放容器。用户只需要对所使用的CPU资源、内存资源等计算资源付费。运行花费了1秒,就只支付1秒的钱。
在这个背景之下,一个程序的尺寸、内存占用、启动时间就变得非常重要了。
NativeAOT,就是来解决这些问题的。可以大大减小程序的尺寸,减小内存的占用,减少启动时间,进而减少开支。
AI是计算密集型应用,传统租赁服务器的方式,需要预先支付大量的费用。而基于 Serverless,只需要使用时付费,还支持实时动态扩容,无论是成本还是便捷性上,都要优于传统云计算。当然,也有一些限制,这些在后面的应用篇会详细举例演示。
(2)增加反编译的难度
这是国内 dotnet 程序员的刚需。java 程序大部分是服务器端的,服务器端的不怕破解。而dotnet程序,很多是客户端的,客户端的就很怕反编译及破解了。
JIT 下,dotnet 程序和裸奔差不多,即使混淆了,也很容易反编译及破解。混淆一下,再NativeAOT,一定程度上增加破解的难度,同时大幅度增加了反编译的难度。
(3)SDK 开发
这是一个新增的应用场景。JIT 下,没法开发动态链接库给其它语言调用。有了 NativeAOT,我们可以冒充 cpp 程序员,开发 SDK 给其它程序直接调用了。于JIT 模式下快速开发,快速编译,于NativeAOT 下打包发布,快哉快哉!很爽很爽!
Mode               LastWriteTime         Length Name----               -------------         ------ -----a---         2023/6/12    14:47      8439296 xxxxxocr.dll-a---         2023/2/25    15:05      9271704 onnxruntime.dll上面是我们开发的 OCR SDK,8M 多的一个 dll,封装了图像编码解码、图像处理、 文字检测、版面分析、表格识别、鉴权、多语言、多inference engine支持等功能,带上一个 onnxruntime.dll 就能跑了,带上 paddle_inference_c.dll 就能在 cuda 卡下跑。所有代码都是 csharp 写的,开发速度老快了。提供二进制 API,什么 java,delphi,python,csharp,cpp 都可以直接调用。
dotnet publish -r linux-x64 -c Release 一下,就变成了 so 文件了。dotnet publish -r linux-arm64 -c Release 一下,就能在飞腾、鲲鹏等机器上跑。等 dotnet 8 正式发布后,dotnet publish -r osx-x64 -c Release 一下,就能在 mac osx 下跑了,dotnet publish -r linux-boinc-arm64 -c Release 一下,就能在安卓下跑了。
这可比用 cpp 苦哈哈的开发,配各种编译环境爽多了。
(4)特殊场景:iOS 和 wasm
这是两个特殊场景。iOS 不允许 JIT,需要 AOT。而 wasm 下,程序通常是通过web传输的,文件尺寸和运行速度都非常重要。blazor wasm 通过解释器运行 csharp 程序,那速度比 js 还慢,通过 AOT 编译,运行速度才超越了 js。
三、NativeAOT 的局限和限制

尽管有这么多的好处,NativeAOT 仍然有很多限制,下面是官方文档列的 NativeAOT 的局限:
- No dynamic loading (for example, Assembly.LoadFile).- No run-time code generation (for example, System.Reflection.Emit).- No C++/CLI.- No built-in COM (only applies to Windows).- Requires trimming, which has limitations.- Implies compilation into a single file, which has known incompatibilities.- Apps include required runtime libraries (just like self-contained apps, increasing their size as compared to framework-dependent apps).- System.Linq.Expressions always use their interpreted form, which is slower than run-time generated compiled code.其中,最大的限制是对反射不友好,对于大量使用反射的程序,通常需要配置 RD.xml 文件才能完整的编译。RD.xml 文件,太他娘的难用了。
微软搞了 Source Generator(源生成器),一定程度上解决了反射问题,典型如下面两个场景:
(1)json 的序列化和反序列化。
这是非常常见的功能,json 的序列化和反序列化需要大量的使用反射,对 AOT 极度不友好。System.Text.Json 库的 Source Generator 可以自动生成对象的序列化和反序列化。使用 NativeAOT 时,这样进行 json 的序列化和反序列化可以避免反射调用:
public class WeatherForecast{    public DateTime Date { get; set; }    public int TemperatureCelsius { get; set; }    public string? Summary { get; set; }}internal partial class SourceGenerationContext : JsonSerializerContext{}...// 序列化var jsonString = JsonSerializer.Serialize(    weatherForecast!, SourceGenerationContext.Default.WeatherForecast);// 反序列化var weatherForecast = JsonSerializer.Deserialize(    jsonString, SourceGenerationContext.Default.WeatherForecast);(2)LibraryImport
LibraryImport 是 dotnet7 引入的调用第三方库的方式。它和 DllImport 不同的是,DllImport 里有部分 marshalling code 是运行时生成的,而 LibraryImport 则直接生成全部 marshalling code ,对 NativeAOT 更友好。


除了对反射不友好之外,NativeAOT 还有其它的限制:

[*] 支持的平台有限。官方文档中,支持的平台包括 windows(x64/arm64),linux(x64/arm64) 和 mac osx(windows(x64/arm64)。不支持32位程序。
[*] 无法交叉编译。到目前为止,NativeAOT下,还无法交叉编译。也就是说,你在 x64 的 windows 环境下,只能编译 windows x64 的程序。如果需要编译 Linux 下的程序,则需要对应的环境。
注:评论区网友提醒,NativeAOT 目前可以进行跨架构编译,不能进行跨操作系统编译。查了下官方文档,也是这样说的。但是测试时发现,dotnet7中,x64的Windows下,VS2022 安装 vc arm64 编译工具后,可以编译到 arm64,但是编译 x64 会出错。Linux下的交叉编译也需要设置。参考文档:cross-architecture-compilation
四、一些经验总结

这里介绍下,我使用NativeAOT这段时间来,摸索出来的一些提高生产力的经验。
通用 API 框架

搞 API 开发,特头疼的一件事,就是写文档。
Restful API 还好,有了 swagger 了,直接扔个 swagger 的地址过去就行了。二进制的就惨了,比如,上面提到的 OCR 的 API,要基于它做上层应用开发,上层应用开发语言是 csharp 的,要提供 API 文档和 csharp 代码示例。有用 Java 以及 delphi 开发的,得给他提供 API 文档和 java 以及 delphi 的开发示例。…… 增加一个 API 接口,就要提供一系列语言的开发示例,这就头大了。
于是,俺捣鼓了个名叫 CommonApi 的 NativeAOT 的 API 框架,一劳永逸的解决这个问题。
详细参见使用 DotNet7 NativeAOT 进行SDK开发 - CommonApi。引入 web 开发的路由概念,json 传值进行输入和输出。这样,对于每个语言,只需要写一个实例就够了。新增一个接口,也就新增传入传出 json 文件的示例即可。
通过 json 进行调用,代价蛮大的。好在,AI 应用的调用粒度也比较大,json 系列化和反系列化的代价相对来说,可以忽略。
JIT/AOT 双层架构



JIT/AOT 双层架构这个是我摸索出来的非常好使的产品技术架构:不要全部 AOT,一部分使用 AOT,一部分使用 JIT,双方共同引用的实例模型提取出来,通过 CommonApi,进行交互。
具体分层策略是:将偏底层、偏核心逻辑的,不怎么依赖反射的,放在 AOT 层,封装成 SDK。UI 层可以不 AOT,直接各种反射用起来,骚操作用得飞起来。UI部分和SDK部分,可以分工到不同人头上,分别更新。如果全部AOT,单那个编译速度就受不了 ……
产品技术架构用这套技术架构开发产品,特爽!如上图所示:

[*] 核心部分放在 SDK 里面,通过 CommonApi提供服务。
[*] SDK 可以单独销售给第三方,集成到对方产品里。
[*] 可以用 WebAPI/Blazor Server 封装成 API 服务器产品。
[*] 可以用 Avalonia UI 封装成桌面产品,
[*] 可以用 MAUI 或 Avalonia UI 封装成移动端产品,
[*] 可以用 Blazor Hybrid 封装成全栈产品。
csharp 代码一套全搞定了!
NativeAOT 下的山寨版 AOP 编程

AOP 编程用的好,能够极大的提高开发效率。对于 AI 应用来说,有许多需要 AOP 的地方。下面举几个实际例子:
(1)要捕获各种异常,将异常信息以 errMessage 返回给用户。
(2)测量每个接口的运行时间。
(3)根据运行环境,决定每个AI调用是直接在本线程调用,还是需要发送到单独线程运行,再轮询获取运行结果?为什么有这个问题呢?CPU 程序一般没这个问题。而有的 CUDA 程序,跨线程调用,会概率性出错,需要发送到创建 CUDA 上下文的线程执行。
对于(1)、(2),相关代码示例如下:
public class BaseResult{    public Error code { get; set; }    public string? message { get; set; }}public class EngineBaseResult:BaseResult{    public long elapsedMilliseconds { get; set; }}public class EngineImageOcrResult : EngineBaseResult{    ...}internal static T CatchTo(Func func) where T : EngineBaseResult, new(){    T rtn = null;    String msg = null;    Stopwatch sw = new Stopwatch();    sw.Start();    try    {      rtn = func();    }    catch(Exception ex)    {      if(rtn == null)      {            rtn = new T() { code = Error.InternalError, message = ex.Message };      }      }    sw.Stop();    if (rtn != null) rtn.elapsedMilliseconds = sw.ElapsedMilliseconds;    return rtn;}public EngineDocOcrResult OcrDoc(IFormFile file, String lang = "en", String token = "yourtoken"){    return CatchTo(() => {      if (Utils.CheckAuthToken(token) == false) throw new HttpRequestException("invalid token");      byte[] imageData = ConvertToInputData(file);      var rtn = OcrDoc(lang, imageData);      return rtn;    });}对于(3),示例代码如下:
interface IBackgroundAction{    public bool Finished { get; set; }    public void Run();    public Exception Exception { get; set; }}class BackgroundAction : IBackgroundAction{    public bool Finished { get; set; }    public void Run()    {      if (Action == null) return;      Result = Action();    }    public Func Action { get; set; }    public T Result { get; set; }    public Exception Exception { get; set; }}private Queue BackgroundWorkActions { get; set; } = new Queue();private T Run(Func action){    if (action == null || Runtime == null) return default(T);    if(CanInvokeCrossThreads())    {      var rtn = action();      return rtn;    }    if (Stopped == true) throw new InvalidOperationException("Session has stopped");    // 不能跨线程调用,则在后台调用    BackgroundAction ba = new BackgroundAction() { Action = action };    lock (BackgroundWorkActions)      BackgroundWorkActions.Enqueue(ba);    // AI 线程会从中不断取出任务执行    while (ba.Finished == false)      Thread.Sleep(100);    if (ba.Exception != null) throw ba.Exception;    return ba.Result;}public OcrResult RunOcr(String key, ImageBgr24 image, bool parseLayout = true){    return Run(() => RunOcrSync(key,image,parseLayout));}private OcrResult RunOcrSync(String key, ImageBgr24 image, bool parseLayout = true){    ......    // return a ocr result}总结一下,可以通过定义下面格式的AOP函数,封装 Aspect 部分的逻辑:
T AOPFunc(Func func){    // Aspect 部分}对于需要AOP的函数,假设原函数内容为:
T1 Foo(TArg1 arg1, TArg2 arg2){    var t1 = new T1();    t1.Arg1 = arg1;    t1.Arg2 = arg2;    return t1; }AOP后代码如下:
T1 Foo(TArg1 arg1, TArg2 arg2){    return AOPFunc(()=>{      // 原函数复制进来      var t1 = new T1();      t1.Arg1 = arg1;      t1.Arg2 = arg2;      return t1;   });}不知道能不能通过 source generator 来通过标注属性解决上面的 Aspect 问题。有兴趣的童靴可以试试。
编译策略及相关 Docker 文件

经过长时间的摸索,我摸索出了这样的编译策略(针对开发机是Windows的):

[*] Windows 下开发,直接编译 win-x64 的程序;
[*] 使用 docker,创建 linux-x64 容器,将程序代码映射进去,编译 linux-x64 的程序;
[*] 在云平台上租赁 linux arm64 的机器(不上云,也可以搞个树莓派这种Linux arm64的系统),在机器里创建 linux-arm64 的容器,在容器里编译 linux-arm64 的程序(国产化场景!)
也封装了几个 Docker:
(1)ubuntu 18.04 下 dotnet7 NativeAOT/x64 编译环境
FROM ubuntu:18.04RUN apt-get update && apt-get install -y clang zlib1g-dev && apt-get install -y wget && \    wget https://packages.microsoft.com/config/ubuntu/18.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb && \    dpkg -i packages-microsoft-prod.deb && \    rm packages-microsoft-prod.deb && \    apt-get update && apt-get install -y dotnet-sdk-7.0 && \    rm -rf /var/lib/apt/lists/*可以直接 docker pull:
docker pull hufei96/ubuntu18.04:dotnet7-aot-sdk(2)dotnet7 NativeAOT/linux-arm64v8 编译环境
FROM mcr.microsoft.com/dotnet/sdk:7.0-jammy-arm64v8RUN sed -i s@/ports.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.listRUN apt-get update && \    apt-get install -y vim clang zlib1g-dev && \    rm -rf /var/lib/apt/lists/*可以直接 docker pull:
docker pull hufei96/dotnet7aotsdk:linux-arm64v8五、其它资源

zerosharp: 这是个非常好的 C# 系统编程示例项目。介绍了怎么使用 C# 开发有/无GC,有/无运行时的程序,无GC、无运行时,最小可以做到 8K 的尺寸。
bflat: 这是基于 dotnet 开发的 NativeAOT 编译工具,支持交叉编译,可以用来学习了解 NativeAOT 的编译过程。
How to use source generation in System.Text.Json: 介绍了 source generation 在 json 序列化和反系列化里的应用
Source generation for platform invokes: 介绍了 source generation 在 p/invoke 里的应用


(1万多字,5点写到11点,用了6个小时,坚持更新......)

asdf 发表于 2023-6-22 12:05:34

[赞][赞][赞][赞] 大佬们的分享会让国内 .net 越来越好。

雪落 发表于 2023-6-22 12:49:39

除了人才储备在国内实在不足,和大型框架的欠缺,c#哪哪不比java强(更不要提golang了

kingwa5 发表于 2024-3-3 05:58:39

以前c#裸奔 转到C++,看来有这个可以回归了

zzzss654321 发表于 2023-6-22 15:41:49

bflat 玩过,除了大点,不错。

少看知乎多读书 发表于 2023-12-4 15:26:29

.net8出了,还是没支持安卓和ios

彭小鲜 发表于 2023-10-20 08:19:03

跟着大佬学习

lifesinger 发表于 2023-10-1 22:11:00

[图片]

厍康复 发表于 2023-9-16 14:30:40

感动,在linux环境下编译出一个so文件,拿去给java调用了。
但是要注意,不能跨平台AOT编译!必须去linux-arm64环境下才能编译出so

sjorz 发表于 2023-7-12 09:19:50

请问 安卓 ios现在使用aot发布的动态链有官方支持了吗
页: [1] 2
查看完整版本: 使用 C# 进行AI工程开发-基础篇(二):NativeAOT