HyMT2Sharp 2.0:纯 C# 的混元翻译,现在能跑在显卡上了

一个月前我发布了 HyMT2Sharp 1.0,一个纯 C# 写的 腾讯混元翻译模型 Hy-MT2 推理库。当时我自己在文章里泼了两盆冷水:只支持 x86 CPU(AVX2 / AVX-VNNI),只测过三种量化。

2.0 主要就是把这两盆冷水收回来:

  • 加了 Vulkan 和 Metal 两个 GPU 后端。RTX 3080 Ti 上 prefill 512 从 CPU 的约 500 tok/s 到了约 1.58 万 tok/s,比同机 llama.cpp CUDA 还快 4%~18%。
  • CPU 加了 AVX-512 和 ARM64 NEON / SDOT 两档,没有 AVX2 的机器也有 Vector<T> 兜底。
  • 腾讯发布的五种 GGUF 量化(1.25Bit / 2Bit / Q4_K_M / Q6_K / Q8_0)在 CPU 和 GPU 上都能跑,也都验证过。
  • 三个 NuGet 包合并成一个 Sdcb.HyMT2Sharp,模型权重也放上了 NuGet,引用一个包就能直接跑,不用再自己去 Hugging Face 下载 GGUF。

GPU 部分和 1.0 守同一条规矩:Vulkan / Metal 的互操作、调度、内存管理全部是 C#,仓库语言统计仍然是 100% C#。从 1.0 到 2.0 一共 116 个提交、31 个 PR。

先看一张图,这是仓库里新加的 WinForms 示例,引擎选"自动",在我的 3080 Ti 上自动用了 Vulkan:

状态栏右下角是这次翻译的实际速度:83 个 token 的 prompt,decode 60 个 token 用了 211 ms,约 284 tok/s。模型加载 2.73 秒,用的是 2Bit 权重包。

权重也上了 NuGet

1.0 的时候要先去 Hugging Face 下一份 GGUF,再把路径传进去。2.0 我把五种量化都打成了 NuGet 包(Sdcb.GgufWeights),引用后直接从程序集里读出 GGUF 流:

powershell 复制代码
dotnet add package Sdcb.HyMT2Sharp
dotnet add package Sdcb.GgufWeights.Hy-MT2-1.8B-2Bit
csharp 复制代码
using Sdcb.GgufWeights.Hy_MT2_1_8B_2Bit;
using Sdcb.HyMT2Sharp.Backends;
using Sdcb.HyMT2Sharp.Model;

using HunyuanDenseModel model = new(Weights.Model(), backend: BackendFactory.Create());
量化 NuGet 权重包 分片数 总大小
1.25-bit STQ1_0 Sdcb.GgufWeights.Hy-MT2-1.8B-1.25Bit 2 约 440 MB
Q2_0C Sdcb.GgufWeights.Hy-MT2-1.8B-2Bit 3 约 573 MB
Q4_K_M Sdcb.GgufWeights.Hy-MT2-1.8B-Q4_K_M 5 约 1.1 GB
Q6_K Sdcb.GgufWeights.Hy-MT2-1.8B-Q6_K 7 约 1.4 GB
Q8_0 Sdcb.GgufWeights.Hy-MT2-1.8B-Q8_0 8 约 1.8 GB

NuGet 单包有大小限制,所以模型被切成了多个分片包,只需要引用顶层那个,.Part2、.Part3... 会作为依赖自动还原。加载时几个分片被串成一个可 seek 的流,模型读完就释放,不需要先拼成一个文件。

说实话这个方案也有代价:包很大,第一次还原 2Bit 在我这里花了七八分钟;输出目录也会多出对应的体积。不想要这些,new HunyuanDenseModel(@"D:\models\xxx.gguf") 传文件路径的老写法照样能用。

我推荐 2Bit:体积只有 Q4_K_M 的一半多一点,CPU 和 GPU 上 decode 都是几种量化里最快或接近最快的。仓库里的 CLI、Server、WinForms 示例默认都引用它。

GPU:Vulkan 和 Metal

BackendFactory.Create() 按 Metal → Vulkan → CPU 的顺序自动选,也可以传 "cpu" / "vulkan" / "metal" 指定,或者用环境变量 HYMT_BACKEND。返回 null 就是纯 CPU,已有代码不传 backend 行为和 1.0 一样。

RTX 3080 Ti(Vulkan)

同一台机器:Ryzen 7 5800X + RTX 3080 Ti,驱动 581.80,prefill 512 / decode 128:

量化 HyMT2Sharp Vulkan llama.cpp CUDA prefill 比 decode 比 同机 HyMT2Sharp CPU
Q4_K_M 15,834 / 277 14,378 / 336 1.10× 0.82× 424 / 26.6
Q6_K 15,830 / 260 13,421 / 278 1.18× 0.94× 403 / 23.0
Q8_0 15,504 / 229 14,928 / 255 1.04× 0.90× 320 / 16.6
1.25Bit STQ1_0 15,997 / 185 加载不了 --- --- 565 / 47.8
2Bit Q2_0C 15,801 / 299 加载不了 --- --- 489 / 47.5

(单位 tok/s,prefill / decode)

prefill 用的是 VK_KHR_cooperative_matrix(也就是张量核),在 NVIDIA 上有一套 subgroup 32 专用的 GEMM 和融合 flash attention,最大的 GEMM 单步大约 63 TFLOPS。最早的版本 pp512 只有 2.9k 左右,#25 之后到了 15.8k。

decode 我们输了。llama.cpp 的 CUDA int8 点积 GEMV 更贴近显存带宽上限,Q4_K_M 差了 18%。这是下一步要磨的地方。

另外 3080 Ti 的 GPU 频率会在 1695--1980 MHz 之间飘,我今晚写文章时复测 Q4_K_M 是 14,597 / 262,比上表低一点,属于这块卡的正常波动。

1.25Bit 和 2Bit 是腾讯自己的量化格式,llama.cpp 连 CPU 都跑不了,更别说 GPU。HyMT2Sharp 的 Vulkan 后端目前是这两个模型唯一能用的 GPU 路径。2Bit 的权重只有 0.62 GB 左右,decode 299 tok/s 是五种量化里最快的。

其它显卡

  • Intel Arc B580:2Bit 11,039 / 243 tok/s;Q4_K_M 10,620 / 208,llama.cpp Vulkan 是 7,345 / 194。
  • AMD RDNA:#29 给 AMD 加了默认的 tile 调优。
  • Apple Silicon(Metal):在 M4 虚拟机上 prefill 约 1,300 tok/s、decode 约 70 tok/s,比同机 CPU SDOT 档 prefill 快 6~9 倍、decode 快约 1.5~2 倍。只在虚拟机上测过,真机应该会更好。

GPU 后端在加载时会把量化权重额外解成一份 fp16 给 prefill GEMM 用,所以显存占用比 GGUF 文件大得多,3080 Ti 上一个模型实例大约占 4.3 GB。这是拿显存换 prefill 速度,后面考虑做反量化融合进 GEMM 省掉这份副本。

CPU 也没闲着

1.0 只有 AVX2 / AVX-VNNI,2.0 现在的分派链是:

复制代码
AVX-512 → AVX-VNNI → AVX2 → ARM64 SDOT → Vector<T>
  • AVX-512(#5):在 Xeon Platinum 8559C 上,2Bit / Q4_K_M 的 prefill 比 AVX2 档快约 23%。
  • ARM64 NEON / SDOT(#1):M4 虚拟机上 Q4_K_M prefill 147.7 tok/s,同机 llama.cpp 84.6,约 1.75 倍;decode 40.8 vs 34.8。
  • Vector<T> 兜底:给没有 AVX2 也不是 ARM64 的机器用,prefill 用的是 BLIS 风格的 GEMM。
  • 线程:启动时自动校准线程数,主线程也参与计算(#2);默认只绑物理 P-core,不占 SMT 和 E-core(#16);Linux 上支持 CPU 亲和性(#3)。

还有一个值得单独说的修复(#8)。GGUF 量化块里的 d / dmin 缩放因子是 fp16,而真实权重里这些值几乎全是 fp16 的次正规数(subnormal):Q4_K_M 的 token_embd 有 966,544 个块,其中 966,543 个是。旧的 fp16 → fp32 转换写法中间会产生一个 fp32 次正规数,每次转换都触发约 150 个周期的微码辅助。llama.cpp 用 F16C 的 vcvtph2ps,硬件直接处理,没有这个问题。

麻烦在于用随机字节造的合成权重几乎不会落进次正规区间,所以微基准一直看不出来,只有拿真实权重跑才慢。改成在整数尾数上算之后结果逐位一致,Xeon 8559C 上 decode:Q6_K 17.7 → 30.0、Q4_K_M 26.4 → 32.6、2Bit 52.1 → 63.5 tok/s。

5800X 上的 CPU 数字和 1.0 差不多(prefill 2Bit 489、1.25Bit 565 tok/s),1.0 没测过的 Q6_K / Q8_0 也补上了,prefill 是同机 llama.cpp CPU 的 1.7~2.9 倍。完整表格在仓库的 docs/perf.md。

推理 API 的变化

  • KV 缓存策略 KvCacheConfig:None(每次请求独立,默认)、Memory(保留上一轮,多轮对话直接接着算)、BlockPolicy()(前缀块池,多个请求共享相同前缀时命中)。Server 用 --kv-cache 选。
  • KV cache 改成 bf16 存储,比 fp32 省一半内存;prefill 改为分块(#7)。
  • Sampler (#10):temperature / top-k / top-p / min-p / 重复惩罚,低分配。1.0 时我在文章里自己写了个 ArgMax,现在直接 Sampler.ArgMax(logits)。
  • 可以从任意可 seek 的 Stream 加载 GGUF(#31),NuGet 权重包就是靠这个实现的。

示例

CLI

不传 --model 就用内置的 2Bit 权重:

powershell 复制代码
dotnet run --project src/HyMT2Sharp.Cli -c Release -- --prompt "Translate the following text into Chinese. ..."
复制代码
HyMT2Sharp  model=Hy-MT2-1.8B-2Bit (embedded)
threads=auto  avx2=True  vnni=False
backend=vulkan
threads=8 (physical P-cores)  arch=hunyuan-dense layers=32 hidden=2048 heads=16/4 vocab=120818
缓存已热,GPU处于空闲状态,翻译已完成。
prefill tokens=41 (cached=0)  45.3 ms  905.25 tok/s  |  decode tokens=12  37.1 ms  323.70 tok/s

Server

OpenAI 兼容的 /v1/chat/completions,带 SSE 流式和一个聊天页,同样默认 2Bit、自动选 GPU:

WinForms

就是文章开头那张图。左边输入右边输出,可以选引擎(自动 / CPU / Vulkan)、KV 缓存策略和目标语言,切换引擎时会重新加载模型。支持高 DPI(PerMonitorV2),在 150% 缩放下也不会糊。

LINQPad

Sdcb.HyMT2Sharp 包里带了三个 LINQPad 脚本:hello-world、cli(交互式,/to 日语 切换目标语言)和 minimal-api(ASP.NET Core minimal API 加流式接口)。在 LINQPad 里引用这个包后,Samples 面板里就能看到。hello-world 的注释里放了模型卡上推荐的几种 prompt(术语表、风格、个性化、结构化数据),取消注释就能切换:

两个坑

流式输出里的 �。 用 2Bit 翻译日文时,流式输出偶尔会冒出一个 �,最后的完整结果又是对的,4Bit 没这个问题。原因是 2Bit 模型更倾向于把一个多字节字符拆成几个 byte token 输出,比如"ざ"会被拆成两个 token,第一个 token 单独解码出来就是半个 UTF-8 序列。修法很简单:解码结果末尾是 U+FFFD 就先扣住,等下一个 token 来了再一起吐。Server、CLI、README 示例和 LINQPad 脚本都改了,自己写 Generate 的话建议也照着加一行 .TrimEnd('\uFFFD')。

NVIDIA 上 Vulkan 不还显存。 写 WinForms 示例时来回切引擎,切了几次就 ErrorOutOfDeviceMemory。原来的 Dispose 只调了 vkDestroyDevice,我以为销毁设备会顺带回收它名下的所有资源,在 NVIDIA 驱动上并不会,每次重新加载漏约 4.3 GB。现在 buffer、pipeline、shader module、fence、descriptor pool、command pool 全部单独跟踪、逐个释放,反复加载 6 次显存稳定在基线。服务端程序一般只加载一次,不容易撞到;桌面程序切模型就很明显。

从 1.0 升级

三个旧包 Sdcb.HyMT2Sharp.Gguf / .Kernels / .Model 合并成了一个,删掉旧引用换成:

powershell 复制代码
dotnet add package Sdcb.HyMT2Sharp

命名空间还是 Sdcb.HyMT2Sharp.Model,new HunyuanDenseModel(path) 和 Forward 的用法没变。旧的三个包会在 NuGet 上标记为弃用。

已知限制

  • Vulkan decode 比 llama.cpp CUDA 慢 6%~18%。
  • GPU 显存占用偏大(fp16 权重副本)。
  • Metal 只在虚拟机上测过。
  • HunyuanDenseModel 不是线程安全的,并发请求请排队或各开一个实例。

开源与交流

仓库:https://github.com/sdcb/HyMT2Sharp ,Apache-2.0,觉得有用的话欢迎点个 Star。

大家可以扫我的微信加群:

如果群满了或者不方便加微信,也可以加入 .NET骚操作 QQ 群:495782587。