从 51% CPU 占用到 SIMD 加速:Cloudflare 防火墙引擎的性能优化实录
Cloudflare 的防火墙规则产品(Firewall Rules)背后是一个叫做 Wirefilter 的表达式匹配引擎。用户写下类似这样的规则: 每一条进入 Cloudflare 网络的 HTTP 请求,都要被拿来和这些规则做匹配,判断是否需要拦截、放行或者做其他处理。 Wirefilter 最初只为防火墙规则服务。但随着 WAF(Web 应用防火墙)等更多产品计划接入同一套引擎,它的 CPU 使用量将在不久后占到整个边缘节点相当可观的份额。 在安全产品里做性能优化,是一件需要格外谨慎的事。本文记录的正是 Cloudflare 的工程师们在这个约束下所做的一系列工作。 原文地址:https://blog.cloudflare.com/building-even-faster-interpreters... 在动手优化之前,需要一套可靠的测量方法。 最直觉的指标是时间,但时间在分布式环境下太不稳定——同样的代码跑两次可能差出 20%。Cloudflare 的工程师转向了硬件计数器,其中指令数(instruction count)被证明是最稳定的一个指标,而 CPU 周期数则波动太大,不适合做对比。 测量工具选用了 Linux 内核提供的 还有一个容易被忽视的工程细节:Cloudflare 常规的 CI 节点使用了虚拟化和沙箱技术,这让访问硬件计数器几乎不可能。他们最终在专用的裸机节点上运行基准测试,才拿到了可复现的结果。 这套框架还被嵌入到发布流程里:每次发新版本之前,都会跑一次完整的基准测试,用于检测性能回退。 有了测量框架之后,第一个实际问题出现了:基准测试太慢了。 Cloudflare 存储了大量真实客户的防火墙规则,如果把所有规则都跑一遍,需要好几个小时,这完全不现实。工程师们用了三个办法把这个时间压到 20 分钟以内: 去重:检查后发现,大约三分之二的规则在结构上和其他规则是重复的。Wirefilter 可以把规则序列化为 JSON,结构相同的规则序列化结果也相同,去掉这些重复的规则之后,测试集直接缩减到三分之一。 采样:去重之后规则量仍然很大,随机采样是简单有效的解法。关键是要保证每次采样的结果相同,否则不同时间的跑分结果就没有可比性。 分区:直接采样有一个风险,可能某些重要的语言特性恰好没被采到。解决方案是先按 Wirefilter 的语言特性对规则分区,再在每个分区内采样。这样不仅保证了覆盖面,还让测试结果能按特性拆开来看,更容易定位优化效果来自哪里。 有了基准测试框架,接下来是找优化点。 最直觉的猜测是:Wirefilter 是一个解释器,动态分派(dynamic dispatch)应该是性能瓶颈——每次执行操作都要通过虚函数表查找,有开销。JIT 编译、选择性内联、复制等解释器优化技术都在考虑范围内。 但 profiler 的数据打破了这个预期: 动态分派的开销不是主要矛盾。真正的瓶颈是 优化方向从此清晰了:先搞定 Wirefilter 最初用的是 但有工程师注意到一个奇怪的现象:把 正则表达式比 顺着这个线索查下去,发现了原因:Rust 的 regex crate 内部对它认为"简单"的模式做了特殊处理,会自动分派到专门的 SIMD 加速路径,而不是走通用的正则匹配逻辑。 既然 regex crate 能做到,为什么不直接用呢?问题在于 Wirefilter 是一个解释器,使用了动态分派机制。如果把 工程师们找到了 Wojciech Muła 此前开源的 sse4-strstr 库,把它接入了 Wirefilter。结果令人满意: 这个算法的核心思想是用 SIMD 指令做批量过滤,把大量不可能匹配的位置快速排除掉,只对少数候选位置做精确比较。 以在一段文本里搜索 第一步:把 needle 的第一个字节( 第二步:把 haystack 的前 32 个字节加载进另一个寄存器,与第一个寄存器做按位相等比较。结果中,不等于 第三步:对 needle 的最后一个字节( 第四步:把两次比较的结果按位 AND,进一步缩小候选集合。 第五步:对剩余的候选位置,用 第六步:如果没找到,移动到 haystack 的下一段,重复直到搜索完毕。 这个算法快的本质是:SIMD 寄存器一次比较 32 个字节,而且绝大多数位置在前两步就被过滤掉了,真正需要 原始的 sse4-strstr 有一个问题:当 haystack 的长度不是 32 字节的整数倍时,最后一批数据装不满一个寄存器,算法会继续读取 haystack 末尾之后的内存,直到填满寄存器为止,然后用位掩码忽略越界部分。 这种行为在 Cloudflare 的环境里是不可接受的。读取越界内存属于未定义行为,在安全产品里有不可预测的风险。 Cloudflare 的工程师把 sse4-strstr 移植到了 Rust,并引入了"重叠寄存器"技巧:当最后一批数据装不满时,把寄存器往前移,让它和前一个寄存器有所重叠,用已经读过的合法内存来填满寄存器,而不是越界读取。重复的字节不会影响最终的匹配结果,修改是安全的。 当 haystack 本身就小于一个寄存器宽度时,重叠技巧无法使用,这时会降级到更小的指令集(AVX2 → SSE2 → SWAR),直到能容纳 haystack 为止。再小的情况则使用 Rabin-Karp 算法。 这个移植版本以 选用 needle 的首字节和末字节作为过滤条件是个聪明的设计,但也引入了一个可被利用的弱点。 考虑这条规则: needle 是 对于首末字节的选择,无论选哪两个字节,攻击者都可以针对性地构造最坏情况。 解决方案是引入随机性:第一个比较字节仍然固定选 needle 的首字节,但第二个比较字节每次随机选取 needle 中的某个位置。攻击者无法预知这个随机选择,因此无法稳定地构造最坏情况。 代价是性能有所损失——指令数提升从 72.3% 回落到 49.1%,但这仍然是对原方案的巨大改进,而且不以牺牲安全性为代价。 性能测量先于性能优化。这篇文章花了相当篇幅在讲测量框架的建设,不是凑字数,而是因为没有可靠的测量,所有优化都是在猜。用时间作指标、在虚拟化环境里跑基准——这两个常见做法在 Cloudflare 的实践里都被证明是不够的。 Profiler 的结果经常打破直觉。动态分派被认为是解释器的主要开销,但实测下来只占一小部分,真正的瓶颈在一个具体的字符串操作上。在数据面前,预设的性能模型需要修正。 安全边界不能因为性能压力而放松。原始的 sse4-strstr 在边界处理上走了捷径,这在通用工具里或许可以接受,但在防火墙引擎里不行。额外的移植工作是必要成本。 随机化是对抗最坏情况攻击的有效手段。当算法的确定性行为可以被预测和利用时,引入随机性能以较低的性能代价换来对攻击者的不可预测性。为什么防火墙引擎的性能变得紧迫
ip.geoip.country eq "CN" and http.request.uri.path contains "/wp-admin/"先解决"怎么量"的问题
perf_event_open 系统调用,直接在 Wirefilter 的二进制内部插桩,分阶段采集数据:解析(parsing)、编译(compilation)、分析(analysis)、执行(execution)各自单独记录,互不干扰。这个思路来源于 Rust 编译器的自剖析机制。让基准测试本身跑得起来
优化从哪里入手:先看数据
操作 CPU 时间占比 matches 操作符0.6% in 操作符1.1% eq 操作符11.8% contains 操作符51.5% 其他 35.0% contains 这一个操作符,独占了超过一半的 CPU 时间。contains。一次意外发现引出的优化路径
contains 是字符串子串搜索:给定一段文本(haystack),判断其中是否包含某个子串(needle)。memmem crate,它实现了经典的两路(two-way)子串搜索算法,在理论上是高效的。contains 改写成等价的正则表达式,速度反而更快。# 原写法
http.host contains "example"
# 改成正则表达式后,莫名其妙更快
http.host matches "example"contains 功能强大得多,在简单场景里不应该更快。contains 的匹配逻辑塞进 regex crate 深处的某个 enum 分支里,额外的调度开销会抵消 SIMD 带来的收益。更理想的方案是:在 contains 表达式被调用时,直接分派到一个专用的 SIMD 实现,不走任何中间层。基准 指令数改善 使用 contains 的表达式72.3% 简单表达式(无 contains)0.0% 所有表达式综合 31.6% SIMD 子串搜索:算法是怎么工作的
"example" 为例,步骤如下:'e')填满一个 SIMD 寄存器,比如 AVX2 的 32 字节寄存器里放 32 个 'e'。'e' 的位置全部置 0——这些位置不可能是匹配的起点,直接排除。'e')重复同样的操作,但把 haystack 偏移 needle 长度减一的距离。memcmp 做完整比较。memcmp 的候选极少。一个必须解决的安全问题
sliceslice 为名开源在了 crates.io 上。还有一个最坏情况问题
http.request.uri.path contains "/wp-admin/"/wp-admin/,首字节和末字节都是 /。如果攻击者构造一个全是 / 的超长请求路径,每个位置都会通过前两步的过滤,导致每个位置都要做 memcmp,整个算法退化为暴力搜索。基准 sse4-strstr(原版) sliceslice(Cloudflare 版) 使用 contains 的表达式72.3% 49.1% 简单表达式 0.0% 0.1% 所有表达式综合 31.6% 24.0% 几点值得记住的东西