昨天发布了 SimdPaddleOCR,评论区和群里最多的一类问题是:纯 C# 写的推理,凭什么比纯 C 还快? 有人觉得肯定是偷偷调了 ONNX Runtime,有人觉得是 C 那边写得太烂,还有人直接说"C# 就不该谈性能"。
这篇不谈 OCR,谈一个更朴素的问题:C# 到底有没有能力写出和 C 一样贴近硬件的代码? 我选了一个所有人都用过、算法又足够简单的例子------Base64 编码------把它用 C# 和 C 各写三遍(标量 / 128 位 / 256 位),在同一台机器、同一套方法下跑一遍,然后拿数据说话。
先给结论,后面全是过程:
- 同指令集、同算法、逐行对应的手写内核:C# 的 AVX2 版比 C 慢 4~6%,128 位版反而比 C 快 6~7%。天花板是同一量级的。
- 不写 intrinsics 会怎样 :C 的标量循环被 MSVC 拒绝自动向量化(
/O2 /arch:AVX2全开也不行);C# 直接调Base64.EncodeToUtf8一行代码,吞吐是它的 12.9 倍。 - 真正的差距不在天花板,在覆盖面:.NET 基础库一份源码覆盖 AVX-512 / AVX2 / SSSE3 / ARM NEON / WASM,C 每多支持一个指令集就多一份源码。
一个你可能没注意过的事实
先跑一段代码。它对 48 KB 的随机数据调 System.Buffers.Text.Base64.EncodeToUtf8,然后打印当前 .NET 运行时实际启用的指令集和吞吐:
powershell
$exe = ".\Base64Bench.exe" # 源代码链接在最后
& $exe isa
$env:DOTNET_EnableAVX2=0; & $exe isa; Remove-Item Env:\DOTNET_EnableAVX2
$env:DOTNET_EnableHWIntrinsic=0; & $exe isa; Remove-Item Env:\DOTNET_EnableHWIntrinsic
isa=avx2 avx2=True ssse3=True v128hw=True | 1.7 us | 27995 MB/s
isa=ssse3 avx2=False ssse3=True v128hw=True | 3.0 us | 15683 MB/s
isa=scalar avx2=False ssse3=False v128hw=False | 21.1 us | 2216 MB/s
(输出略去了 AVX-512 相关字段,这台机器上全是 False。)
同一个 exe,一行代码没改,只是通过环境变量告诉运行时"别用硬件加速",Base64 编码的吞吐掉了 12.6 倍。
也就是说,你每次调 Convert.ToBase64String,底下跑的都是一段手写的 SIMD 代码。它在 x64 上会按 AVX-512 VBMI → AVX2 → SSSE3 的顺序挑最好的一档,在 ARM64 上走 NEON,在 WebAssembly 上走 PackedSimd,全部塞在 dotnet/runtime 的一个文件里。
那这份代码是什么样的?我能不能自己写一份?写出来和 C 比又如何?这就是下面要做的事。
Base64 在算什么
Base64 把每 3 个字节(24 位)切成 4 个 6 位的数(0~63),再查表映射到 A-Z a-z 0-9 + / 这 64 个字符。最直白的写法长这样:
csharp
private static ReadOnlySpan<byte> Map =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"u8;
while (i < source.Length - 2)
{
uint t = (uint)((source[i] << 16) | (source[i + 1] << 8) | source[i + 2]);
destination[o] = Map[(int)(t >> 18)];
destination[o + 1] = Map[(int)((t >> 12) & 0x3F)];
destination[o + 2] = Map[(int)((t >> 6) & 0x3F)];
destination[o + 3] = Map[(int)(t & 0x3F)];
i += 3;
o += 4;
}
// 剩下的 1 或 2 个字节补 '=',略
每次循环吃 3 字节、吐 4 字节。这份代码 C 和 C# 几乎可以逐字翻译,后面它就是两边的"标量基线"。
怎么把它向量化
SIMD 的思路是一次处理 16 字节(SSE)或 32 字节(AVX2)。但 Base64 有个天然的别扭:输入步长是 3,输出步长是 4,不是简单的"一个输入槽对应一个输出槽"。所以第一步要先把字节重排:
- 加载 16 字节,只用前 12 字节(4 组 × 3 字节);
- 用一条 shuffle (
pshufb)把每 3 字节组"摊"进一个 4 字节槽里; - 用两次掩码 + 两次乘法(等价于移位)把 4 个 6 位数各自挪到自己那个字节的低 6 位;
- 6 位数 → 字符不是查 64 项的表(一条 shuffle 只能查 16 项),而是按范围加偏移:
A-Z加 65,a-z加 71,0-9减 4,+减 19,/减 16。这个"范围 → 偏移"的表只有 13 项,正好一条 shuffle 查完。
微软在源码里画了每一步的位布局,比我用文字讲清楚得多(大写字母是原字节的高位,小写是低位,一行是一个 4 字节槽):
shuffle 之后 & 0x0fc0fc00 再乘法右移 & 0x003f03f0 再乘法左移
b c a b -> 00000000 00bbbbCC 00000000 00AAAAAA | 00cccccc 00000000 00aaBBBB 00000000
两者按位或: 00cccccc 00bbbbCC 00aaBBBB 00AAAAAA
^ 4 个字节,每个字节的低 6 位就是一个 0..63 的索引
然后是查偏移表:
# From To Abs Index Characters
0 [0..25] [65..90] +65 0 ABCDEFGHIJKLMNOPQRSTUVWXYZ
1 [26..51] [97..122] +71 1 abcdefghijklmnopqrstuvwxyz
2 [52..61] [48..57] -4 [2..11] 0123456789
3 [62] [43] -19 12 +
4 [63] [47] -16 13 /
Index 列怎么算:SubtractSaturate(x, 51) 让 0~51 全变 0、52~63 变 1~12;再用 x > 25 的比较结果(是 -1)减回去,就把 26~63 整体加 1。两条指令,5 个范围全部落到 0~13 的索引上。
这个算法来自 aklomp/base64,.NET 基础库的 SSSE3 / AVX2 路径就是它的 C# 移植。我下面写的内核也是同一个算法------两边算法完全相同,才有资格比语言。
C# 写 SIMD 的三层 API
System.Runtime.Intrinsics 给了三层选择,从可移植到贴硬件:
| 层 | 类型 | 特点 |
|---|---|---|
| 可移植 | Vector<T> |
宽度由运行时决定(AVX2 机器上 Vector<float>.Count == 8),API 是加减乘除、比较、Min/Max 这类通用操作。没有 Shuffle,所以 Base64 用不上它。 |
| 固定宽度、跨平台 | Vector128<T> / Vector256<T> / Vector512<T> |
宽度固定,API 在 x86 / ARM / WASM 上同一套(Vector128.Create、LoadUnsafe、GreaterThan、& ` |
| 平台专属 | Ssse3.Shuffle、Avx2.MultiplyHigh、AdvSimd.Arm64.VectorTableLookup... |
和 C 的 _mm_shuffle_epi8 一一对应,每个方法就是一条指令。 |
三层可以混着用:一个 Vector128<byte> 变量既能传给 Ssse3.Shuffle,也能用 Vector128.GreaterThan 做跨平台比较,也能直接 &。这一点后面会很重要。
C# 的 128 位内核
csharp
public static int EncodeVector128(ReadOnlySpan<byte> source, Span<byte> destination)
{
if (!Ssse3.IsSupported)
return EncodeScalar(source, destination);
Vector128<byte> shuffleVec = Vector128.Create(0x01020001, 0x04050304, 0x07080607, 0x0A0B090A).AsByte();
Vector128<byte> lut = Vector128.Create(
(sbyte)65, 71, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -19, -16, 0, 0).AsByte();
Vector128<byte> maskAC = Vector128.Create(0x0fc0fc00).AsByte();
Vector128<byte> maskBB = Vector128.Create(0x003f03f0).AsByte();
Vector128<ushort> shiftAC = Vector128.Create(0x04000040).AsUInt16();
Vector128<short> shiftBB = Vector128.Create(0x01000010).AsInt16();
Vector128<byte> const51 = Vector128.Create((byte)51);
Vector128<sbyte> const25 = Vector128.Create((sbyte)25);
ref byte src = ref MemoryMarshal.GetReference(source);
ref byte dst = ref MemoryMarshal.GetReference(destination);
int i = 0, o = 0;
// 每次读 16 字节、用 12 字节、写 16 字节,所以提前 16 字节停下
while (i + 16 <= source.Length)
{
Vector128<byte> str = Vector128.LoadUnsafe(ref Unsafe.Add(ref src, i));
str = Ssse3.Shuffle(str, shuffleVec);
Vector128<byte> t0 = str & maskAC;
Vector128<byte> t2 = str & maskBB;
Vector128<ushort> t1 = Sse2.MultiplyHigh(t0.AsUInt16(), shiftAC);
Vector128<short> t3 = t2.AsInt16() * shiftBB;
str = t1.AsByte() | t3.AsByte();
Vector128<byte> indices = Sse2.SubtractSaturate(str, const51);
Vector128<sbyte> mask = Vector128.GreaterThan(str.AsSByte(), const25);
Vector128<sbyte> tmp = indices.AsSByte() - mask;
str += Ssse3.Shuffle(lut, tmp.AsByte());
str.StoreUnsafe(ref Unsafe.Add(ref dst, o));
i += 12;
o += 16;
}
return EncodeScalarCore(source, destination, i, o); // 尾巴交给标量
}
主循环 12 条向量操作,没有一个 unsafe、没有一个指针------ref byte + Unsafe.Add 就够了。
256 位版把所有类型换成 Vector256、Ssse3. 换成 Avx2.,每次吃 24 字节吐 32 字节。唯一多出来的心眼是加载方式:AVX2 的 Shuffle 只能在 128 位半区内重排,所以我用两次 128 位加载(偏移 i 和 i+12)拼成一个 256 位向量,让两个半区看到的数据形状和 128 位版一模一样。这样 shuffle 常量可以直接复用。
C 侧:同一个算法,同一组指令
C 的 SSSE3 内核,和上面几乎可以逐行对照:
c
size_t b64_encode_ssse3(const uint8_t *source, size_t source_length, uint8_t *destination)
{
const __m128i shuffle_vec = _mm_setr_epi32(0x01020001, 0x04050304, 0x07080607, 0x0A0B090A);
const __m128i lut = _mm_setr_epi8(65, 71, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -19, -16, 0, 0);
const __m128i mask_ac = _mm_set1_epi32(0x0fc0fc00);
const __m128i mask_bb = _mm_set1_epi32(0x003f03f0);
const __m128i shift_ac = _mm_set1_epi32(0x04000040);
const __m128i shift_bb = _mm_set1_epi32(0x01000010);
const __m128i const51 = _mm_set1_epi8(51);
const __m128i const25 = _mm_set1_epi8(25);
size_t i = 0, o = 0;
while (i + 16 <= source_length)
{
__m128i str = _mm_loadu_si128((const __m128i *)(source + i));
str = _mm_shuffle_epi8(str, shuffle_vec);
__m128i t0 = _mm_and_si128(str, mask_ac);
__m128i t2 = _mm_and_si128(str, mask_bb);
__m128i t1 = _mm_mulhi_epu16(t0, shift_ac);
__m128i t3 = _mm_mullo_epi16(t2, shift_bb);
str = _mm_or_si128(t1, t3);
__m128i indices = _mm_subs_epu8(str, const51);
__m128i mask = _mm_cmpgt_epi8(str, const25);
__m128i tmp = _mm_sub_epi8(indices, mask);
str = _mm_add_epi8(str, _mm_shuffle_epi8(lut, tmp));
_mm_storeu_si128((__m128i *)(destination + o), str);
i += 12;
o += 16;
}
return b64_encode_scalar_from(source, source_length, destination, i, o);
}
_mm_shuffle_epi8 ↔ Ssse3.Shuffle,_mm_mulhi_epu16 ↔ Sse2.MultiplyHigh,_mm_subs_epu8 ↔ Sse2.SubtractSaturate......一一对应。AVX2 版同理,加载方式也和 C# 版保持一致(两次 _mm_loadu_si128 + _mm256_set_m128i)。
C 的一个硬约束:每个指令集一个文件
编译 C 侧的脚本是这样的:
bat
rem 标量也给 /arch:AVX2,让自动向量化拿到一切便利
cl %COMMON% /arch:AVX2 /c b64_scalar.c
rem SSSE3 intrinsics 在 x64 上不需要 /arch(SSE2 是基线)
cl %COMMON% /c b64_ssse3.c
cl %COMMON% /arch:AVX2 /c b64_avx2.c
cl %COMMON% /c main.c
为什么要拆成三个 .c?因为 /arch:AVX2(GCC/Clang 对应 -mavx2)是按翻译单元生效的。开了它,编译器可以在这个文件的任何地方(包括你以为是标量的代码)生成 AVX2 指令;而没开它的文件里又不该出现 AVX2 内核。所以运行时分发逻辑必须放在一个"干净"的文件里,各指令集的内核分别放在各自的文件里。
这不是我为了演示故意拆的。去看 lw.PPOCR.C(SimdPaddleOCR 的 C 参照实现)的 src/simd/ 目录,就是 sse2_*.c / avx2_*.c / neon_*.c / lsx_*.c / wasm128_*.c 这样一排排铺开的。C 写多指令集 SIMD,就是这个形状。
正确性
性能之前先说正确性,因为 SIMD 代码最容易错的是边界:
- 7 个实现(C# 标量 /
Vector128/Vector256/Base64.EncodeToUtf8,C 标量 / SSSE3 / AVX2)全部逐字节 等于Convert.ToBase64String; - 输入长度 0~300 穷举(覆盖所有
mod 3尾部情形和向量 → 标量的交接点),外加 1023 / 1024 / 1025 / 4096 / 49152 / 100000; - 目标缓冲区后面放
0xCC哨兵,检查越界写; - C# 和 C 各自对同一个 LCG 生成的输入算 FNV-1a 哈希,4 种尺寸下 7 个实现哈希完全一致。
同指令集对拼:天花板在哪
测试机是 AMD Ryzen 7 5800X(Zen 3,没有 AVX-512),.NET 10,MSVC 19.51。每个(实现,尺寸)组合独立进程跑,C#/C 交替,5 轮取中位数,整个矩阵再跑 5 遍取中位数。单位纳秒,越小越好:
| 输入 | C# Vector256 |
C 手写 AVX2 | C#/C | C# Vector128 |
C 手写 SSSE3 | C#/C |
|---|---|---|---|---|---|---|
| 64 B | 9.71 | 7.49 | 1.30× | 11.17 | 8.51 | 1.31× |
| 4 KB | 169.68 | 160.20 | 1.06× | 261.09 | 277.18 | 0.94× |
| 48 KB | 1954.18 | 1881.37 | 1.04× | 3072.86 | 3255.12 | 0.94× |
| 1 MB | 43139.83 | 41094.57 | 1.05× | 65517.12 | 70363.03 | 0.93× |
读法:
- AVX2 档,C# 比 C 慢 4~6%。 这是同一算法、同一组指令、逐行对应的情况下,RyuJIT 和 MSVC 后端之间的差距。不是零,但也就这么多。
- 128 位档,C# 反而快 6~7%。 我没去逐条比对汇编找原因,只是照实写。它说明的是:同一份算法两边谁快,已经进入"看编译器当天心情"的区间。
- 64 B 那一行 C# 慢三成,但这一档测的不是内核。 64 字节只够 AVX2 循环转两圈,时间被调用开销、
Span构造和尾部处理主导。如果你的场景是海量小字符串,这个开销真实存在;如果是编码文件或图片,它就无关紧要了。
所以第一个问题的答案:C# 手写 intrinsics 的天花板和 C 在同一量级,同指令集下差距在 ±6% 以内。 "C# 不该谈性能"这句话,至少在这个层面是不成立的。
不写 intrinsics 会怎样:覆盖面
天花板一样高,那 SimdPaddleOCR 凭什么快过 C?看下一张表。1 MB 输入,单位 MB/s:
| 实现 | 吞吐 | 相对 C 标量 |
|---|---|---|
| C# 标量 | 1565 | 0.74× |
C 标量(/O2 /arch:AVX2) |
2111 | 1.00× |
| C 手写 SSSE3 | 14212 | 6.7× |
| C 手写 AVX2 | 24334 | 11.5× |
C# 手写 Vector256 |
23180 | 11.0× |
C# 调 Base64.EncodeToUtf8(一行代码) |
27318 | 12.9× |
几个事实:
1. 标量档 C# 比 C 慢 26%。 Span 索引有边界检查,C 的裸指针没有。这是 C# 的另一个真实处境,不用回避。
2. C 的标量循环,编译器拒绝向量化。 我给了它 /O2 /arch:AVX2,然后打开 /Qvec-report:2 看它为什么没做:
b64_scalar.c(15) : info C5002: 由于"502",循环未向量化
原因码 502 是"归纳变量不是按 +1 步进"------i += 3; o += 4 这种步长错配,加上逐字节查表,自动向量化器直接放弃。这不是我把 C 写烂了,是这类算法本来就不在自动向量化的能力范围内。 你不手写 intrinsics,C 就只能拿到 2 GB/s。
3. C# 基础库那一行,是微软替你写好的。 Base64.EncodeToUtf8 一行调用,吞吐比我手写的 Vector256 还高一截(一个可能的原因:它的 AVX2 路径每轮只做一次 256 位加载,靠首次 PermuteVar8x32 错位 4 字节来对齐半区,省掉了我那两次 128 位加载和一次拼接)。这一行我不拿来排名次------BenchmarkDotNet 和我的计时器在这一项上有系统性分歧(分歧来自动态 PGO 对调用点的特化程度,见文末的复现仓库),只把它当"同一量级的参照"。
一份源码 vs 二十六份
真正拉开差距的是这个。同样是"多指令集 Base64 / 卷积内核",两边的源码形状:
| 文件数 | 行数 | 覆盖的指令集 | |
|---|---|---|---|
.NET Base64EncoderHelper.cs |
1 | 926 | AVX-512 VBMI / AVX2 / ARM64 NEON / SSSE3 / WASM PackedSimd |
C 的 src/simd/*.c |
26 | 3726 | sse2(9 个文件)/ avx2(11)/ neon(3)/ lsx(1)/ wasm128(1) |
C# 为什么能一份源码搞定?因为 IsSupported 是 JIT 时常量 。这是基础库里那个 SimdShuffle 的原文:
csharp
internal static Vector128<byte> SimdShuffle(Vector128<byte> left, Vector128<byte> right, Vector128<byte> mask8F)
{
if (Ssse3.IsSupported)
return Ssse3.Shuffle(left, right);
else if (PackedSimd.IsSupported)
return PackedSimd.Swizzle(left, right & mask8F);
else
return AdvSimd.Arm64.VectorTableLookup(left, right & mask8F);
}
三个分支写在一个方法里,JIT 在 x64 上只会生成第一个分支的那一条 pshufb,其余两个分支连代码都不存在;在 ARM64 上只剩 tbl。上一节 C# 的 128 位内核只要把 Ssse3.Shuffle 换成这个 SimdShuffle、Sse2.SubtractSaturate 换成对应的三分支版本,就同时是 x64 / ARM64 / WASM 三个平台的内核了------不需要 #ifdef,不需要拆文件,不需要 3 套构建配置。
而 C 那边 _mm_shuffle_epi8 和 vqtbl1q_u8 是两个头文件里的两个函数,运行时分发要自己写函数指针表,每加一个平台就再复制一份 300 行的内核出来。这不是 lw 大佬写得不好,这是 C 的地形。
Vector<T>:不写一行 intrinsics 白送的性能
前面说 Vector<T> 没有 Shuffle,Base64 用不上。但对卷积、矩阵乘这种"一堆 float 加加乘乘"的算子,它是最省事的一层。SimdPaddleOCR 里没有专门写 AVX 内核的算子,走的就是 Vector<float>,它的价值可以从 CI 报告里直接读出来(GitHub Actions win-x64,AMD EPYC 7763,tiny 模型 4 worker,同一台机器同一批图):
| 配置 | 生效路径 | 中位数 | 相对默认 |
|---|---|---|---|
| 默认 | 手写 AVX2 / FMA 内核 | 229.9 ms | 1.00× |
DOTNET_EnableAVX2=0 |
手写 AVX 内核(256 位,无 FMA) | 353.6 ms | 1.51× |
DOTNET_EnableAVX=0 |
只剩 Vector<float> 可移植路径 |
553.6 ms | 2.34× |
DOTNET_EnableHWIntrinsic=0 |
纯标量 | 1457.7 ms | 6.22× |
这个库的内核分发是 Avx512F → Avx → Vector.IsHardwareAccelerated → 标量,没有单独写 SSE 内核。所以第三行的意思是:把我手写的所有平台专属 intrinsics 全部关掉,光靠 Vector<float> 这种"写起来像普通数学"的 API(此时它是 128 位宽),就把 6.22× 的坑填到 2.34×。这一层在 C 里没有对应物------C 只有"手写 intrinsics"和"祈祷自动向量化"两个选项。
回到那个问题:SimdPaddleOCR 凭什么比 C 快
把上面三件事放到一起,答案就出来了。
同一台 CI 机器(win-x64,AMD EPYC 9V74),同一批 99 张图,tiny 模型:
| workers | C# 中位数 | C 中位数 | C 相对 C# |
|---|---|---|---|
| 1 | 285.6 ms | 519.4 ms | 慢 1.76× |
| 4 | 234.4 ms | 357.1 ms | 慢 1.55× |
这 1.76× 不是"C# 的 AVX2 指令比 C 的 AVX2 指令快"------同指令集对拼我们已经看到了,C 还领先 5%。它来自覆盖面:
- 端到端的算子耗时统计里,Conv 占 350 ms,其中 222 ms 是 1×1 卷积(本质上是 GEMM)。这一块两边都是手写 AVX2,比的是谁的分块、预取、寄存器分配调得更细,是纯工程时间的比拼;
- 剩下几十个算子,C# 那边要么走
Vector<float>、要么一份Vector256源码通吃,C 那边每个算子 × 每个指令集都是一个独立的.c文件------26 个文件 3700 行。同样的人日,C 要铺到 26 个地方,C# 只需要铺到一个地方。能铺得更深,算子就能调得更细。
至于内存,C 版 1 worker 工作集只涨 72.6 MB,C# 涨 322.5 MB,这一点 C 赢得干干净净,后面会单独写一篇。
小结
回到标题的问题:C# 手写向量化能追上 C 吗?
能。同算法、同指令集、逐行对应的情况下,差距在 ±6%。这是"天花板"。
但更值得记住的是另外两个数字:12.9× 和 1 vs 26 。前者是"不写 intrinsics 时两边差多少"------C 的自动向量化在这类算法上是零;后者是"想覆盖五个指令集要写几份源码"。一个 C# 开发者花一份精力写出的 Vector128 内核,会自动变成 x64 / ARM64 / WASM 三份;一个 C 开发者花同样的精力,只能得到其中一份。
这就是为什么一个纯 C# 的 OCR 引擎能比它的 C 参照跑得快------不是语言更快,是同样的工程时间,铺到了更大的面上。
完整的 C# / C 源码、构建脚本、计时器和原始数据都在这里,欢迎复现和挑错:
https://github.com/sdcb/blog-data/tree/master/2026/20260908-csharp-simd-intro
如果你对 .NET 高性能编程、SIMD 和这类"C# 到底行不行"的实测有兴趣,欢迎关注我的微信公众号 .NET骚操作,明天继续写 SimdPaddleOCR 的下一篇。
也欢迎加入 SimdPaddleOCR 微信交流群:
微信群二维码过期的话,可以加 .NET骚操作 QQ群:495782587,一起探讨更多硬核玩法。