Skip to content
Go back

.NET 用 AVX-512 加速 IPv4 解析

在普通业务里,解析一个 IP 地址通常只需要调用 IPAddress.TryParse。当程序要持续处理日志、代理请求、网络包或访问控制规则时,解析器可能进入非常热的循环,几十纳秒的差异也会累积成可见成本。

Daniel Lemire 最近用 C# 和 .NET 10 做了一个很有代表性的实验:只针对最常见的 IPv4 点分十进制形式,利用 AVX-512 一次处理多个字符,再把无法覆盖的输入交回标准库。原文报告的完整包装路径从 45.3 ns/地址降到 14.1 ns/地址,约有 3 倍差距。本文拆解这条路径的关键想法,也说明它的适用边界。

先把问题边界说清楚

最稳妥的基线仍然是标准库:

if (IPAddress.TryParse(text, out var address))
{
    // 使用 address
}

IPAddress.TryParse 需要处理 IPv4、IPv6,以及一些不符合日常书写习惯的 IPv4 表达形式。高性能路径选择了更窄的目标:类似 192.168.0.112.121.244.111 的四段十进制文本,每段 1 到 3 位,中间有 3 个点。

这个限定很重要。只要输入超出快路径的约束,就交给标准库处理,整体 API 仍保持完整的 IP 地址语义。快路径服务的是常见数据,不承担重新实现全部解析规则的任务。

C# 的第一道难题:输入是 UTF-16

SIMD 优化常从“同时加载一批 ASCII 字节”开始。C# 的 char 默认是 UTF-16,一个看起来只有 15 个字符的 IPv4 字符串,在内存里可能占用 30 个字节。直接把它当成连续 ASCII 字节处理,会把数据布局弄错。

原文的思路分成两步:先用 AVX-512 的 masked load 只加载字符串实际存在的字符,再把 UTF-16 的低位压缩成一组 ASCII 字节。核心片段可以简化成这样:

unsafe
{
    Vector256<ushort> laneMask = Vector256.LessThan(
        CharLaneIndex,
        Vector256.Create((ushort)length));

    Vector256<ushort> chars = Avx512BW.VL.MaskLoad(
        (ushort*)pointer,
        laneMask,
        Vector256.Create((ushort)'0'));

    if (Avx512BW.VL
            .CompareGreaterThan(chars, Vector256.Create((ushort)0x7f))
            .ExtractMostSignificantBits() != 0)
    {
        return false;
    }

    Vector128<byte> ascii = Avx512BW.VL.ConvertToVector128Byte(chars);
}

laneMask 标记哪些字符位于输入范围内,其他位置不会从字符串边界外读取。随后检查每个 UTF-16 单元是否属于 ASCII 范围,再把数据收窄为适合后续处理的字节向量。

这类代码依赖指针和硬件指令,项目需要打开 AllowUnsafeBlocks,并以 net10.0 为目标框架。运行时还要检查 Avx512BW.VL.IsSupported,当前公开实现同时检查了 Ssse3.IsSupported

81 种点位布局是一个小空间

一个常见 IPv4 地址有四个数字段,每段长度只有 1、2、3 位三种选择。因此,三个点的位置一共有:

3 × 3 × 3 × 3 = 81

这让解析器可以把“点在哪里”变成一个很小的查表问题。算法大致按下面的顺序工作:

  1. 把点的位置提取成位掩码。
  2. 根据位掩码找到对应的布局编号和字节重排模式。
  3. 减去字符 '0',检查每个需要是数字的位置。
  4. 将四段数字排列到固定位置,用向量乘加得到四个数值。
  5. 检查每段是否超过 255、点的数量是否正确,以及是否出现非法字符。
  6. 把四个字节合成一个 uint,再构造 IPAddress

原文提到的 dot product 就发生在第 4 步。它把不同长度的数字段统一放进向量,通过预先准备的权重一次算出各段的十进制值。公开源码还提供了 table-free 变体,尝试用 vpcompressbvpermb 和掩码运算减少查表依赖;这些版本适合继续研究,普通应用没有必要直接照抄。

用一个回退入口保留兼容性

高性能解析器最实用的结构,是把硬件快路径放在标准库前面:

public static bool TryParse(
    ReadOnlySpan<char> text,
    out IPAddress? address)
{
    if (IsSupported && TryParseAvx512(text, out uint value))
    {
        address = new IPAddress(value);
        return true;
    }

    return IPAddress.TryParse(text, out address);
}

这里有两层保护:CPU 不支持所需指令集时直接回退,输入不符合严格点分十进制规则时也回退。IPv6、非典型 IPv4 写法和 SIMD 路径无法接受的字符串,都能继续由标准库处理。

这个设计还降低了部署风险。调用方只面对一个方法,机器能力探测和输入分类都藏在实现内部;新代码可以逐步替换热路径,其他地方继续使用熟悉的 BCL API。

基准数字应该怎样解读

原文的测试生成了 10,000 个随机 32 位地址,把文本重复解析 20,000,000 次,并在每次构造 IPAddress。测试机器是 Intel Xeon Gold 6548N,运行 .NET 10:

实现ns/地址每秒百万地址
IPAddress.TryParse45.322.1
AVX-512 + fallback14.171.1

原文因此给出了约 3 倍的加速结论。作者公开代码目录中的当前 results.txt 又记录了一组结果:IPAddress.TryParse 为 46.23 ns,Ipv4Parser.TryParse 为 16.52 ns,约为 2.80 倍;只测不分配 IPAddress 的核心函数,当前结果最低约 6.24 ns。

两组数字来自具体机器、运行时、代码版本和测量方式。它们可以说明优化方向的潜力,不能直接当作所有 CPU 的承诺。尤其要区分两种成本:完整 API 包含对象构造和包装开销,纯解析内核更接近 SIMD 算法本身的速度。

正确性要先于纳秒

这类优化最容易被忽略的部分是输入覆盖范围。建议至少保留下面几组验证:

公开仓库的当前结果记录了 300 万个随机字符串的差分 fuzz、1 万个随机 dotted-quad,以及 0 个失败样本。这样的测试记录比单个漂亮的吞吐数字更能说明实现是否可用。

还要注意 masked load 的边界语义。它解决的是“只读取有效字符”的问题,不能替应用程序解决指针生命周期、跨线程访问或输入缓冲区本身失效的问题。unsafe 代码需要和普通业务代码一样接受审查。

什么时候值得使用

这条路线适合以下场景:

配置文件、低频 API 参数和普通日志处理通常不需要这份复杂度。先使用 IPAddress.TryParse,只有基准证明解析本身值得优化时,再引入硬件专用路径。优化后还要重新测量完整业务链路,确认收益没有被对象分配、字符串获取或网络等待吞掉。

小结

这篇实验的价值在于展示了一条完整的性能优化链路:缩小输入范围、识别数据布局、用 masked load 避免越界读取、用少量布局表处理结构差异、用向量运算完成校验,最后保留标准库回退。

它也提醒我们,C# 性能代码的边界已经延伸到硬件指令集。代码可以很快,适用范围仍需要写清楚;基准可以很亮眼,正确性和部署条件仍决定它能否进入生产环境。

Aide Hub 会继续分享 AI 助手、开发工具和软件工程实践。

参考


Tags


Previous

Agent 成本高,先查 Harness

Next

.NET 数据格式与查询语言速查