WAF 机器学习推理从 1519μs 压到 275μs,Cloudflare 是怎么做到的

每秒 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 请求经过五个阶段:

  1. 原始输入:取出请求的 URI、请求体、User-Agent 等字段
  2. 归一化和清洗:标准化内容,替换特定字符,去重
  3. 特征提取:对处理后的内容做 tokenize,产出一个固定大小的浮点张量
  4. 模型推理:把张量送入预训练的 TensorFlow Lite 模型
  5. 输出评分: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 对分支预测失败的惩罚很重------猜错一次要丢弃已执行的指令,重新从正确路径取指。

能否完全消灭分支?答案是:预计算偏移查找表。

设计思路

  1. 预计算一个三维的字节偏移表 NGRAM_OFFSETS[3][256],存每个字节在其位置上的偏移量
  2. ngram 的索引直接由三个字节各自的偏移值相加得出,没有任何比较
  3. 再用索引在 NGRAM_TENSOR_IDX 数组里取出张量维度下标
  4. 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 编译成了跳转表,这个细节是手工优化很难意识到的。

相关推荐
Moment2 小时前
2026年,为什么NestJS + Monorepo越来越流行了 ❓❓❓
前端·后端·面试
IT果果日记2 小时前
人大金仓使用Flink-CDC
大数据·数据库·后端
Gopher_HBo2 小时前
阻塞队列之SynchronousQueue
后端
understandme2 小时前
30 毫秒教会你怎么在 TKE 搭建 Istio
后端
神奇小汤圆2 小时前
Spring Boot:别再重复造轮子,这些内置功能香麻了
后端
tonydf2 小时前
快速上手AI网关——LiteLLM
后端·aiops
Pkmer2 小时前
类的封装性: 让门面设计模式来打开这扇门
后端·设计模式
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第35题:怎样声明一个类不会被继承?什么场景下会用
java·开发语言·后端·面试
无限进步_2 小时前
【C++】AVL树完全解析:从平衡因子到四种旋转
c语言·开发语言·数据结构·c++·后端·算法·github