昨天发布了 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 运行时实际启用的指令集和吞吐:

$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 个字符。最直白的写法长这样:

private static ReadOnlySpan 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,不是简单的"一个输入槽对应一个输出槽"。所以第一步要先把字节重排:

  1. 加载 16 字节,只用前 12 字节(4 组 × 3 字节);
  2. 用一条 shufflepshufb)把每 3 字节组"摊"进一个 4 字节槽里;
  3. 用两次掩码 + 两次乘法(等价于移位)把 4 个 6 位数各自挪到自己那个字节的低 6 位;
  4. 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 宽度由运行时决定(AVX2 机器上 Vector.Count == 8),API 是加减乘除、比较、Min/Max 这类通用操作。没有 Shuffle,所以 Base64 用不上它。
固定宽度、跨平台 Vector128 / Vector256 / Vector512 宽度固定,API 在 x86 / ARM / WASM 上同一套(Vector128.CreateLoadUnsafeGreaterThan& `
平台专属 Ssse3.ShuffleAvx2.MultiplyHighAdvSimd.Arm64.VectorTableLookup 和 C 的 _mm_shuffle_epi8 一一对应,每个方法就是一条指令。

三层可以混着用:一个 Vector128 变量既能传给 Ssse3.Shuffle,也能用 Vector128.GreaterThan 做跨平台比较,也能直接 &。这一点后面会很重要。

C# 的 128 位内核

public static int EncodeVector128(ReadOnlySpan source, Span destination)
{
    if (!Ssse3.IsSupported)
        return EncodeScalar(source, destination);

    Vector128 shuffleVec = Vector128.Create(0x01020001, 0x04050304, 0x07080607, 0x0A0B090A).AsByte();
    Vector128 lut = Vector128.Create(
        (sbyte)65, 71, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -19, -16, 0, 0).AsByte();
    Vector128 maskAC = Vector128.Create(0x0fc0fc00).AsByte();
    Vector128 maskBB = Vector128.Create(0x003f03f0).AsByte();
    Vector128 shiftAC = Vector128.Create(0x04000040).AsUInt16();
    Vector128 shiftBB = Vector128.Create(0x01000010).AsInt16();
    Vector128 const51 = Vector128.Create((byte)51);
    Vector128 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 str = Vector128.LoadUnsafe(ref Unsafe.Add(ref src, i));

        str = Ssse3.Shuffle(str, shuffleVec);

        Vector128 t0 = str & maskAC;
        Vector128 t2 = str & maskBB;
        Vector128 t1 = Sse2.MultiplyHigh(t0.AsUInt16(), shiftAC);
        Vector128 t3 = t2.AsInt16() * shiftBB;
        str = t1.AsByte() | t3.AsByte();

        Vector128 indices = Sse2.SubtractSaturate(str, const51);
        Vector128 mask = Vector128.GreaterThan(str.AsSByte(), const25);
        Vector128 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 位版把所有类型换成 Vector256Ssse3. 换成 Avx2.,每次吃 24 字节吐 32 字节。唯一多出来的心眼是加载方式:AVX2 的 Shuffle 只能在 128 位半区内重排,所以我用两次 128 位加载(偏移 ii+12)拼成一个 256 位向量,让两个半区看到的数据形状和 128 位版一模一样。这样 shuffle 常量可以直接复用。

C 侧:同一个算法,同一组指令

C 的 SSSE3 内核,和上面几乎可以逐行对照:

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_epi8Ssse3.Shuffle_mm_mulhi_epu16Sse2.MultiplyHigh_mm_subs_epu8Sse2.SubtractSaturate……一一对应。AVX2 版同理,加载方式也和 C# 版保持一致(两次 _mm_loadu_si128 + _mm256_set_m128i)。

C 的一个硬约束:每个指令集一个文件

编译 C 侧的脚本是这样的:

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
Csrc/simd/*.c 26 3726 sse2(9 个文件)/ avx2(11)/ neon(3)/ lsx(1)/ wasm128(1)

C# 为什么能一份源码搞定?因为 IsSupportedJIT 时常量。这是基础库里那个 SimdShuffle 的原文:

internal static Vector128 SimdShuffle(Vector128 left, Vector128 right, Vector128 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 换成这个 SimdShuffleSse2.SubtractSaturate 换成对应的三分支版本,就同时是 x64 / ARM64 / WASM 三个平台的内核了——不需要 #ifdef,不需要拆文件,不需要 3 套构建配置

而 C 那边 _mm_shuffle_epi8vqtbl1q_u8 是两个头文件里的两个函数,运行时分发要自己写函数指针表,每加一个平台就再复制一份 300 行的内核出来。这不是 lw 大佬写得不好,这是 C 的地形。

Vector:不写一行 intrinsics 白送的性能

前面说 Vector 没有 Shuffle,Base64 用不上。但对卷积、矩阵乘这种"一堆 float 加加乘乘"的算子,它是最省事的一层。SimdPaddleOCR 里没有专门写 AVX 内核的算子,走的就是 Vector,它的价值可以从 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 可移植路径 553.6 ms 2.34×
DOTNET_EnableHWIntrinsic=0 纯标量 1457.7 ms 6.22×

这个库的内核分发是 Avx512FAvxVector.IsHardwareAccelerated → 标量,没有单独写 SSE 内核。所以第三行的意思是:把我手写的所有平台专属 intrinsics 全部关掉,光靠 Vector 这种"写起来像普通数学"的 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、要么一份 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,一起探讨更多硬核玩法。


原文地址: https://www.cveoy.top/t/topic/qHvP 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录