每秒 950 万个请求,每个请求都要跑一次机器学习推理,打出一个安全评分,决定这个请求是攻击流量还是正常用户。
这是 Cloudflare WAF Attack Score 系统每天面对的现实。在这个规模下,单次推理时间哪怕节省 1 毫秒,乘以请求量后都是天文数字。
2024 年,Cloudflare 工程团队把这套系统的端到端执行时间从 1519 微秒 压到了 275 微秒,整体快了 5.5 倍。
这篇文章把他们的优化过程从头到尾拆开来讲。
原文链接:blog.cloudflare.com/making-waf-...
WAF Attack Score 是什么
WAF Attack Score 是架在 Cloudflare WAF 之上的一个机器学习层。传统 WAF 靠规则匹配工作------有规则的攻击能拦,没规则的绕过去了。Attack Score 的目标是识别那些绕过了现有规则的攻击,覆盖 SQLi(SQL 注入)、XSS(跨站脚本)、RCE(远程代码执行)等主要攻击类型。
每个 HTTP 请求经过五个阶段:
- 原始输入:取出请求的 URI、请求体、User-Agent 等字段
- 归一化和清洗:标准化内容,替换特定字符,去重
- 特征提取:对处理后的内容做 tokenize,产出一个固定大小的浮点张量
- 模型推理:把张量送入预训练的 TensorFlow Lite 模型
- 输出评分:1 到 99 分,1 接近恶意,99 接近正常
其中步骤 3 和步骤 4 是延迟的大头,也是这次优化的核心战场。
特征提取:四轮迭代,从 248μs 到 21μs
基线:HashMap 吃掉了 62% 的时间
特征提取的核心操作是 ngram 查找:用一个滑动窗口每次取 3 个字节,查找这个三元组在词汇表中的位置,然后对应的张量维度加一。
原始实现用 Rust 的 HashMap 来做这个查找。火焰图清楚地显示了瓶颈所在:
| 操作 | 时间占比 |
|---|---|
| HashMap::get | 61.8% |
| Regex::replace_all | 18.5% |
两个问题一起存在:查哈希表太慢,正则替换也很贵。
基线数字:9482 字节的输入需要 248μs ,1000 字节的输入需要 28μs。
第一轮:用 Aho-Corasick DFA 替换 HashMap
Aho-Corasick 算法构建一个有限状态机,在线性时间内完成多模式匹配,比 HashMap 的随机访问更缓存友好。开启 DFA 模式后性能最优:
rust
static ref NORM_VOCAB_AC: AhoCorasick =
AhoCorasick::builder()
.kind(Some(AhoCorasickKind::DFA))
.build(&["abc", "def", "wuq", ...])
.unwrap();
结果:长输入快了 1.64 倍 ,中等输入快了 1.71 倍。有改善,但还不够。
第二轮:直接用 Rust match,让编译器做优化
这个反直觉的发现是整篇文章里最有意思的地方之一:把 Aho-Corasick 扔掉,改用 Rust 的 match 语句,让编译器自己决定怎么生成代码。
rust
#[inline]
const fn norm_vocab_lookup(ngram: &[u8; 3]) -> usize {
match ngram {
b"abc" => 1,
b"def" => 2,
b"wuq" => 3,
// ... 数千个 ngram
_ => 0,
}
}
编译器会把这个 match 编译成一个跳转表(jump table),配合字节级比较,速度快过 Aho-Corasick DFA 和所有测试过的完美哈希方案(phf、ph、quickphf)。
结果:长输入快了 2.2 倍 ,中等输入快了 2.15 倍。
第三轮:用 WindowedReplacer 替换 Regex
回头看另一个瓶颈:Regex::replace_all 占用了 18.5% 的时间。它的作用其实很简单------把所有连续小写字母序列替换成 #,然后对替换后的字节做滑动窗口迭代。
团队自己实现了一个零分配的单次遍历迭代器 WindowedReplacer:
rust
pub struct WindowedReplacer<'a> {
window: Window,
input_iter: Iter<'a>,
}
核心思路:不先分配缓冲区存替换结果,而是在迭代时实时做替换,替换结果直接流向 ngram 查找,全程没有额外的内存分配。
结果:长输入比基线快了 4.87 倍 ,中等输入快了 5.09 倍------比上一轮又翻了一倍多。
第四轮:无分支查找,彻底消灭条件跳转
看汇编代码会发现,match 语句最终还是包含大量 cmp 比较指令,产生很多分支。现代 CPU 对分支预测失败的惩罚很重------猜错一次要丢弃已执行的指令,重新从正确路径取指。
能否完全消灭分支?答案是:预计算偏移查找表。
设计思路:
- 预计算一个三维的字节偏移表
NGRAM_OFFSETS[3][256],存每个字节在其位置上的偏移量 - ngram 的索引直接由三个字节各自的偏移值相加得出,没有任何比较
- 再用索引在
NGRAM_TENSOR_IDX数组里取出张量维度下标 - 用
get_unchecked_mut跳过边界检查,消除最后一个分支来源
rust
#[inline]
const fn ngram_index(ngram: [u8; 3]) -> usize {
(NGRAM_OFFSETS[0][ngram[0] as usize]
+ NGRAM_OFFSETS[1][ngram[1] as usize]
+ NGRAM_OFFSETS[2][ngram[2] as usize]) as usize
}
整个查找路径是完全无分支的,全是直接内存访问。查找表总大小约 500KB,轻松放入 L2/L3 缓存,缓存命中率极高。
配合每次处理 6 个 ngram 的循环展开,编译器可以对内层循环做自动向量化:
rust
const CHUNK_SIZE: usize = 6;
for i in (0..chunks_max_offset).step_by(CHUNK_SIZE) {
for ngram in input[i..i + CHUNK_SIZE + 2].windows(3) {
update_tensor_with_ngram(tensor, ngram.try_into().unwrap());
}
}
最终结果:
| 输入 | 基线 | 最终 | 提升 |
|---|---|---|---|
| 9482 字节 | 248μs | 21.5μs | 11.5 倍 |
| 1000 字节 | 28.2μs | 2.3μs | 12.1 倍 |
| 44 字节 URL | 1.45μs | 0.26μs | 5.7 倍 |
| 91 字节 UA | 2.87μs | 0.43μs | 6.6 倍 |
输入越长,收益越大,因为无分支设计在长循环里的优势会被充分放大。
模型推理:从 246μs 到 56μs
特征提取优化完,推理成了新的瓶颈。推理时间和输入长度无关(输入已被转换成固定大小的张量),稳定在 246μs 左右。
火焰图显示,最贵的操作是矩阵乘法,占 42%,对应 TFLite 里的三重循环朴素实现:
c
for (int b = 0; b < n_batch; b++) {
for (int r = 0; r < m_rows; r++) {
float dot_prod = 0.0f;
for (int c = 0; c < m_cols; c++) {
dot_prod += *matrix_ptr++ * *vector_in_batch++;
}
}
}
第一层:开启 AVX2 SIMD
TFLite 内置了 AVX2 加速的矩阵乘法,只需要改编译参数:
bash
arguments+=("--copt=-march=x86-64-v3")
开启后,矩阵乘法从朴素循环切换到 8x8 块的 AVX2 向量化实现。推理时间从 246μs 降到 130μs ,快了 1.89 倍。
第二层:升级 TFLite + 开启 XNNPACK
XNNPACK 是专门为神经网络推理设计的高度优化计算库,针对不同硬件架构做了深度调优。
TFLite 官方基准测试工具的数据如下:
| 配置 | 推理时间 |
|---|---|
| TFLite 2.6.0,无 SIMD | 105.6μs |
| TFLite 2.16.1,SIMD | 115.2μs |
| TFLite 2.16.1,SIMD + XNNPACK | 49.1μs |
XNNPACK 相比基础 SIMD 实现再降低 57%。
综合升级 TFLite 版本、SIMD 和 XNNPACK,推理时间从 246μs 降到 56μs ,整体快了 4.38 倍。
缓存:让代码根本不需要跑
所有代码层面的优化做完之后,团队想到了一个更根本的问题:能不能让推理完全不执行?
答案来自 Zipf 定律:互联网流量天然是长尾分布的------少数请求极其频繁,大量请求只出现一次。URL、HTTP 头、请求体都符合这个分布规律。
基于这个观察,把高频出现的输入和对应的推理结果缓存起来,下次相同输入直接返回缓存结果,完全跳过预处理和推理。
实现方案是 lua-resty-mlcache,基于 LRU(最近最少使用)策略,通过 Nginx 共享内存字典在多个 Worker 进程之间共享缓存状态。
实测缓存命中率约 70%------超过三分之二的请求直接从缓存拿结果,预处理和推理完全不执行。
三阶段上线,每天节省 32 年
为了保证系统稳定性,优化分三个阶段分批上线:
| 阶段 | 改动 | 执行时间 | 降幅 |
|---|---|---|---|
| 阶段一 | TFLite 开启 SIMD | 1519 → 884μs | -41.8% |
| 阶段二 | 升级 TFLite + XNNPACK + 预处理优化 | 884 → 552μs | -40.8% |
| 阶段三 | LRU 缓存 | 552 → 275μs | -50.2% |
| 总计 | 1519 → 275μs | -81.9%,快 5.5 倍 |
用一个更直观的换算来说明这个改善的量级:
Cloudflare WAF ML 平均每秒处理 950 万个请求,每个请求节省 1244 微秒,等价于每天节省约 32 年的处理时间。
这还不包括去年 Bot Management 优化带来的每天节省 65 年------两者加起来,每天节省近 100 年的处理时间。
优化路径的启示
这篇文章描述的不只是一次性能优化,而是一套在真实生产场景里行之有效的方法论。
第一,用 profiler 说话,不用直觉猜。 火焰图显示 HashMap 占了 62%,才知道从哪里下手;矩阵乘法占 42%,才知道推理侧的核心问题在哪里。
第二,优化有层次,每层逻辑不同。 无分支查找是 CPU 微架构层面的优化,SIMD 是指令集层面的优化,XNNPACK 是算法层面的优化,LRU 缓存是系统设计层面的优化------四个层次叠加才得到 5.5 倍的整体提升。
第三,有时候"不执行"比"执行得更快"更有效。 Amdahl 定律告诉我们:优化部分代码的收益是有上限的。LRU 缓存让 70% 的请求完全绕过了所有计算路径,这比任何代码层面的优化都更彻底。
第四,编译器有时比你聪明。 用 match 替换精心调优的 Aho-Corasick DFA,结果反而更快------因为编译器把 match 编译成了跳转表,这个细节是手工优化很难意识到的。