bpf: Optimize string kfuncs

eBPF · 字符串 kfunc 热路径 · word-at-a-time(寄存器内 SIMD / SWAR)优化

💡 一句话总结

在 eBPF 字符串 kfunc(bpf_strnchr / bpf_strcmp / bpf_strcspn / bpf_strnstr 等)的扫描热路径上,原实现逐字节 nofault 加载使长串搜索的开销随字符串长度线性放大;本系列借用内核 word-at-a-time(寄存器内 SIMD / SWAR)技巧,把扫描从"每 1 字节 1 次加载 + 1 次比较"改为"每 8 字节 1 次对齐字加载 + 位掩码并行判定",作者自报在 512–2048 字节长串上提速 2.03x–11.30x、bpf_strnstr 整体约 10x(作者自报,未独立验证);但 Andrii Nakryiko 评审认为"代码与复杂度不值",作者同意并撤回系列、未合入(本地 7.2-rc6 内核确认无此改动)。

📋 补丁基本信息

项目内容
补丁类型优化(性能 / hot-path)
状态Rejected / 作者撤回(RFC v1,评审后主动放弃;本地 7.2-rc6 无此改动)
当前版本RFC v1 1/6(唯一版本) · scan 补丁 lore 链接
版本演进首版(2026-07-28,[RFC PATCH bpf-next 0/6],6 补丁)。作者在评审后同意撤回,无后续版本。
作者机构Leon Hwang(linux.dev,Isovalent 系 BPF 开发者)
提交日期2026-07-28
改动范围系列 6 补丁:kernel/bpf/helpers.c 557 行变更 + selftests 400 行新增;4 个核心优化补丁 = scan +141/-39、compare +57/-15、span +121/-54、substring +111/-31
核心函数bpf_str_for_each_word() / bpf_str_find() / __bpf_strncasecmp() / __bpf_strspn() / __bpf_strnstr()
原始链接cover letter · scan 1/6 · compare 2/6 · span 3/6 · substring 4/6

注:4 个补丁均含 Assisted-by: Codex:gpt-5.6-sol,为 LLM 辅助开发(cover letter 明言 "with LLM assistance, I optimized all string kfuncs")。

📊 速览卡片

核心机制
寄存器内SIMD
优化目标
减加载次数
适用场景
长串搜索
实测提升
11.3x 长串

🎯 解决什么问题

背景 / 原始动机
cover letter 原话:作者的朋友 Gray 报告 bpf_strnstr() kfunc 的性能不如他自己用 SWAR(SIMD Within A Register,寄存器内 SIMD)思路手写的纯 BPF 实现(bpf_swar_benchmark)。作者搭了同一套 microbench 复现:在 16 核 16GiB QEMU 虚拟机里跑 "BPF HTTP Host search"(在 HTTP 头里搜 Host 字段场景),基线 kfunc 比逐字节实现快 1.43x–2.69x,但 SWAR 手写实现更快(2.00x–4.36x)——内核自带 kfunc 反而慢于用户手写。这暴露了"字符串扫描类 kfunc 是热路径但从未被优化"的痛点。
系统层面:逐字节 nofault 加载 + 无跳过大块能力
原实现(本地 7.2-rc6 内核 kernel/bpf/helpers.c 中仍是原版,见下)对每个字节执行一次 __get_kernel_nofault()——该宏要处理页边界/异常表等安全开销,一次调用比普通内存读贵;再逐字节比较、指针自增、循环回边。对长度为 N 的字符串,内存访问次数 = N,且无法跳过任何无关字节。这是典型的"能工作但热路径次优"实现。
场景层面:BPF 程序里的字符串解析/匹配是高频操作
BPF 程序(特别是网络/追踪类)常做字符串处理:HTTP 头解析(找 Host:)、bpf_strnstr 在数据包里搜子串、bpf_strcspn/bpf_strspn 做分隔符/字符集扫描、bpf_strnlen 量长度。这类负载的特征是单次处理的数据可达数百到数千字节(如完整 HTTP 请求头),且每个包/事件都执行一次——扫描越长,逐字节开销越被放大。作者基准里的 "middle/late/absent" 场景(512/1536/2048 字节、目标在尾部或缺失)正是这类负载的形状。
受影响负载:HTTP Host 搜索、子串匹配、分隔符扫描等字符串密集 BPF 负载 · 为什么该场景遇到缺陷:目标越靠后、字符串越长,逐字节 N 次加载的开销越线性放大

🧩 核心机制

一句话:把"每字节一次 nofault 加载 + 比较"改成"每次一个对齐 8 字节字加载,再用位掩码技巧在寄存器内同时判定 8 个字节是否为目标字符或 NUL"。这正是 SWAR 思路——不依赖 SIMD 指令集,只用普通整数运算模拟"一次处理多个字节"。

从系统层面看(四个补丁共用一套地基)
补丁改了什么为什么快
1/6 scan新增 bpf_str_for_each_word() 展开宏(对齐→字循环→字节回退三阶段)+ 共享查找器 bpf_str_find(),改造 bpf_strnchr/bpf_strchrnul/bpf_strrchr/bpf_strnlenstrchr/strlen 家族从 N 次加载降到 N/8 次
2/6 compare__bpf_strncasecmp() 在两个操作数相对对齐相同时走字比较:word1 == word2 直接整字跳过前缀相同场景跳过整字,只在失配字节处展开比较
3/6 spanbpf_strspn/bpf_strcspn 共用 __bpf_strspn(),用懒加载的 256-bit 位图缓存字符集成员;发现"单一拒绝字符集"时改走 bpf_str_find() 快速路径每个 accept/reject 字符只 nofault 加载一次,源串按字扫描
4/6 substring__bpf_strnstr() 先用 bpf_str_find() 定位 needle 首字节,再在候选点用 bpf_str_match_at() 字匹配验证haystack 中不可能开头的区域被整字跳过,只验证真正候选点
BPF 字符串 kfunc 扫描:逐字节 vs word-at-a-time 三阶段 before/after
图 1:改前逐字节扫描 vs 改后 word-at-a-time 三阶段(对齐头部 → 对齐字循环 → 异常字节回退)。
来源:基于 lore 真实补丁 diff(kernel/bpf/helpers.c)与 asm/word-at-a-time.h 语义绘制
word-at-a-time 位掩码怎么"同时判 8 字节"(简化理解 + 精确语义)
简化理解:把 8 字节拼成一个整数,用减法和按位与一次性"问"整字:有没有字节是 0?有没有字节等于某个字符 c?(等价于 8 个字节并行比较)。
精确语义(x86 arch/x86/include/asm/word-at-a-time.h):
① has_zero(word):((word - 0x0101…01) & ~word) & 0x8080…80——对每个字节,若该字节为 0,则从 0x01 的借位会让结果的最高位(0x80)置位,从而得到"哪些字节是 NUL"的位掩码;
② has_zero(word ^ REPEAT_BYTE(c)):先用 REPEAT_BYTE(c) 把 c 铺满整字,异或后"等于 c 的字节"变 0,再套用 ① 即得"哪些字节等于 c";
③ find_zero(bits):__ffs(bits) >> 3 把首个置位 bit 换算成字节下标。
一次字加载 + 3 条整数运算,就完成了原本 8 次逐字节加载 + 8 次分支比较的工作。
为什么要三阶段(对齐→字循环→字节回退)
对齐头部:__get_kernel_nofault 的字加载要求地址按 sizeof(unsigned long) 对齐,所以先逐字节走到对齐边界(最多 7 字节),一次完成;
对齐字循环:主体——每次整字加载 + 位掩码判定,无命中则整字跳过;
字节回退:字加载一旦跨页且下一页未映射会触发异常(__get_kernel_nofault 通过异常表返回错误),此时从同一地址回退到逐字节,保证语义与安全不变;KMSAN 下也直接走字节路径,避免读到终止符之后的未初始化内存。

🔬 关键代码

diff 逐字取自 lore patch 字段(scan 1/6 为 +141/-39,本段摘取最能体现机制的核心片段;其余三补丁摘代表性片段)。

核心逻辑点 1:三阶段扫描宏(scan 1/6)——对齐一次、整字跳过、异常回退

+/* Abstract the common unaligned-byte, aligned-word, and byte-fallback scan. */
+#define bpf_str_for_each_word(s, limit, pos, word, byte, byte_label,	\
+			      byte_action, word_action, err_label)	\
+do {								\
+	__label__ byte_label;					\
+	size_t __word_end;					\
+								\
+	if (IS_ENABLED(CONFIG_KMSAN))				\
+		goto byte_label;				\
+								\
+	for (; (pos) < (limit) &&				\
+	       !IS_ALIGNED((unsigned long)((s) + (pos)), sizeof(word));	\
+	     (pos)++) {						\
+		__get_kernel_nofault(&(byte), (s) + (pos),	\
+				     unsigned char, err_label);	\
+		byte_action;					\
+	}							\
+								\
+	__word_end = (pos) + round_down((limit) - (pos), sizeof(word));	\
+	for (; (pos) < __word_end; (pos) += sizeof(word)) {	\
+		__get_kernel_nofault(&(word), (s) + (pos),	\
+				     unsigned long, byte_label);	\
+		word_action;					\
+	}							\
+								\
+byte_label:							\
+	for (; (pos) < (limit); (pos)++) {			\
+		__get_kernel_nofault(&(byte), (s) + (pos),	\
+				     unsigned char, err_label);	\
+		byte_action;					\
+	}							\
+} while (0)

▲ 核心是"一段宏 + 在调用点展开动作":byte_action/word_action 由各 kfunc 现场展开,避免为每个算法单独写对齐/边界/回退三遍。注意字加载的出错目标是 byte_label(回退到逐字节),而逐字节加载出错才是 err_label(返回 -EFAULT)——保证"字加载异常不丢结果、只降级"。

核心逻辑点 2:共享查找器 bpf_str_find(scan 1/6)——一次字加载判 8 字节

+	zero_at = has_zero(word, &zero_data, &constants);
+	char_at = has_zero(word ^ repeated_c, &char_data, &constants);
+	alt_at = has_alt ? has_zero(word ^ repeated_alt, &alt_data, &constants) : 0;
+	if (!zero_at && !char_at && !alt_at)
+		continue;
+
+	if (zero_at) {
+		zero_data = prep_zero_mask(word, zero_data, &constants);
+		zero_at = find_zero(create_zero_mask(zero_data));
+	} else {
+		zero_at = sizeof(word);
+	}
+	if (char_at) {
+		char_data = prep_zero_mask(word ^ repeated_c, char_data, &constants);
+		char_at = find_zero(create_zero_mask(char_data));
+	} else {
+		char_at = sizeof(word);
+	}
+	if (alt_at) {
+		alt_data = prep_zero_mask(word ^ repeated_alt, alt_data, &constants);
+		alt_at = find_zero(create_zero_mask(alt_data));
+		char_at = min(char_at, alt_at);
+	}
+
+	if (char_at <= zero_at)
+		return pos + char_at;
+	return nul_is_match ? pos + zero_at : -ENOENT;

▲ 这是"一次字加载顶 8 次比较"的心脏:has_zero(word) 查 NUL,has_zero(word ^ REPEAT_BYTE(c)) 查目标字符 c(alt_c 让大小写不敏感子串查找能同时匹配大小写变体)。若整字既无 NUL 也无目标字符,continue 整字跳过;有命中再用 find_zero 定位到字节。关键:char_at <= zero_at 决定返回目标字符位置还是 NUL 位置——保持与原实现一致的"NUL 也算字符串一部分"语义。

核心逻辑点 3:compare 2/6——同相对对齐才走字比较

+	if (!IS_ALIGNED((unsigned long)s1 ^ (unsigned long)s2, sizeof(word1))) {
+		for (; pos < limit; pos++) {
+			__get_kernel_nofault(&byte1, s1 + pos, unsigned char, err_out);
+			__get_kernel_nofault(&byte2, s2 + pos, unsigned char, err_out);
+			if (bpf_str_cmp_byte(byte1, byte2, ignore_case, &ret))
+				return ret;
+		}
+		return pos == XATTR_SIZE_MAX ? -E2BIG : 0;
+	}
+
+	bpf_str_for_each_word(s1, limit, pos, word1, byte1, byte_at_a_time, ({
+		__get_kernel_nofault(&byte2, s2 + pos, unsigned char, err_out);
+		if (bpf_str_cmp_byte(byte1, byte2, ignore_case, &ret))
+			return ret;
+	}), ({
+		__get_kernel_nofault(&word2, s2 + pos, unsigned long, byte_at_a_time);
+		if (word1 == word2) {
+			if (has_zero(word1, &data, &constants))
+				return 0;
+			continue;
+		}

▲ 关键一行:IS_ALIGNED(s1 ^ s2, sizeof(word1)) 判断两个串的相对对齐是否一致——一致时才能用同一个偏移做对齐字加载;否则回退逐字节(避免引入未对齐字加载的架构依赖)。字相等且无 NUL 时 continue 整字跳过;大小写折叠只在真正失配字节处做 tolower,避免每字节都折叠。

核心逻辑点 4:span 3/6——256-bit 位图缓存字符集 + 单字符拒绝快速路径

+static __always_inline int
+bpf_str_set_lookup(const char *set, unsigned long *set_bits, size_t *set_pos, bool *set_complete,
+		   unsigned char *set_first, unsigned char c)
+{
+	unsigned char set_c;
+
+	if (bpf_str_set_contains(set_bits, c))
+		return 1;
+	if (*set_complete)
+		return 0;
+
+	while (*set_pos < XATTR_SIZE_MAX) {
+		__get_kernel_nofault(&set_c, set + *set_pos, unsigned char, err_out);
+		if (set_c == '\0') {
+			*set_complete = true;
+			return 0;
+		}
+		if (*set_pos == 0)
+			*set_first = set_c;
+		set_bits[set_c / BITS_PER_LONG] |= BIT(set_c % BITS_PER_LONG);
+		(*set_pos)++;
+		if (set_c == c)
+			return 1;
+	}
+	return -E2BIG;

▲ 原 bpf_strcspn 对源串每个字节都要把 reject 串从头扫到尾(最坏 O(N×M));这里用 set_bits[256/64] 位图做成员缓存——每个 accept/reject 字符只 nofault 加载一次,之后 O(1) 查位。另加"单字符 reject 集"快速路径:发现 reject 串只有一个字符就转调 bpf_str_find() 整字扫描(常见分隔符场景)。

核心逻辑点 5:substring 4/6——先用查找器跳过不可能区域,再在候选点验证

+	while (pos < XATTR_SIZE_MAX && pos < len) {
+		limit = min_t(size_t, len - pos, XATTR_SIZE_MAX - pos);
+		offset = bpf_str_find(s1 + pos, limit, first, ignore_case, true);
+		if (offset < 0) {
+			if (offset == -ENOENT && limit == XATTR_SIZE_MAX - pos)
+				return -E2BIG;
+			return offset;
+		}
+		pos += offset;
+
+		/* bpf_str_find() also returns the position of a terminating
+		 * NUL. Distinguish it from a first-character match. */
+		__get_kernel_nofault(&candidate, s1 + pos, unsigned char, err_out);
+		if (candidate == '\0')
+			return -ENOENT;
+
+		limit = min_t(size_t, len - pos, XATTR_SIZE_MAX);
+		ret = bpf_str_match_at(s1 + pos, s2, limit, ignore_case);
+		if (ret > 0)
+			return pos;
+		if (ret < 0)
+			return ret;
+		pos++;
+	}

▲ 原 bpf_strnstr 是双重循环(对每个 haystack 偏移试全 needle,最坏 O(N×M))。新逻辑先用 bpf_str_find() 快速定位 needle 首字节的下一个出现位置——中间所有不可能开头的 haystack 区域都被整字跳过;找到候选点后才用 bpf_str_match_at() 做整字匹配验证。注:bpf_str_find() 会把 NUL 也当"命中",所以候选点要再读一字节区分"首字符命中"与"NUL",这是原实现语义的保留。

📈 性能影响

数据全部来自 cover letter 作者自报(未独立验证)。两个基准:① Gray 的 bpf_swar_benchmark(HTTP Host 搜索,验证 bpf_strnstr);② 作者为 4 类 kfunc 新增的 selftests 基准 bench_bpf_str_kfuncs。

基准 ②:4 类 kfunc 对比(x86_64,repeat=100000 trials=9 warmup=1000,单位 ns/op)
kfunc场景长度改前 ns/op改后 ns/op加速
scan
(bpf_strnchr)
first64B174.0179.00.97x(略回退)
middle512B489.0241.02.03x
late1536B1706.0506.03.37x
absent2048B2781.0727.03.83x
comparison
(bpf_strcmp)
first64B186.0189.00.98x(略回退)
middle512B614.0238.02.58x
late1536B2234.0458.04.88x
equal2048B3663.0634.05.78x
span
(bpf_strcspn)
first64B179.0195.00.92x(回退)
middle512B1047.0266.03.94x
late1536B4489.0482.09.31x
absent2048B7468.0661.011.30x
substring
(bpf_strnstr)
first64B277.0218.01.27x
middle512B1115.0296.03.77x
late1536B4427.0613.07.22x
absent2048B7261.0854.08.50x
运行环境:baseline kernel=7.2.0-rc4-00661-g4748a67f7111,current kernel=7.2.0-rc4-00671-g239632cc49ee,arch=x86_64。作者总结:"The optimized kfuncs win most of the benchmarks."
基准 ①:HTTP Host 搜索(bpf_swar_benchmark,16 核 16GiB QEMU VM,amd64)
场景长度改前 kfunc改后 kfunckfunc 加速
first64B345.0261.01.87x
middle512B1193.0352.08.03x
late1536B4469.0708.017.13x
absent2048B7389.0982.020.84x
作者结论:"The optimized bpf_strnstr() kfunc was 10x faster than the original bpf_strnstr() kfunc."(改后 kfunc 在 late/absent 场景甚至反超 Gray 的纯 BPF SWAR 实现:SWAR 2822/4548 ns vs kfunc 708/982 ns)
on-CPU / off-CPU 视角
本优化纯属 on-CPU(在 CPU 上执行的计算量) 收益:减少内存加载次数与分支,不涉及锁/IO/网络等待。延迟 = on-CPU 计算 + off-CPU 等待,此处等待项不变。短串场景(first,64B)有轻微回退(0.92x–0.98x),说明对齐判断 + 宏展开的固定开销在小数据上盖过了收益——这是典型的"固定开销换线性收益"权衡(解读(AI 分析))。补丁未提供 off-CPU 数据,此项为逻辑分析。

🔄 方案演进 + 讨论焦点

版本演进
RFC v1(cover letter,2026-07-28,[RFC PATCH bpf-next 0/6]):6 补丁首发,含 4 个核心优化 + 2 个 selftests(exercise + benchmark)。无 v2——作者在评审后主动撤回。cover letter 自述已知问题:checkpatch 报 WARNING: Macros with flow control statements should be avoided(指两个带 return/goto 的宏),作者称"将在下一版处理",但下一版未出现。
💬 讨论焦点(评审驱动撤回)
  • 评审核心质疑(Andrii Nakryiko)(Message-ID,2026-07-30):针对 1/6 scan 的 bpf_str_find() 代码,Andrii 直言:"I'd say it's just not worth it. Too much code and complexity, IMO. For the absolute majority of BPF programs this small speed up won't matter, while for those BPF programs where doing tons of bpf_strstr-like operations is the essence of those programs and has a huge impact on the performance, they can basically implement and maintain this complexity in their own code base."——即:代码量与复杂度不划算;绝大多数 BPF 程序用不上这点提速;真正把字符串操作当核心的少数程序,可以把这套复杂度留在自己代码里。
  • 作者回应(Leon Hwang)(Message-ID,2026-07-31):"Agreed on the complexity concern. Let's leave the str kfuncs as-is."——完全同意复杂度顾虑,决定不推进,系列就此终止。
  • 无其他维护者参与:对 compare/span/substring/selftests 各补丁均无公开回复;只有 scan 补丁收到 Andrii 一条评审。
要点:这不是"方案被证明有缺陷"而撤回,而是"维护者认为复杂度/收益比不值得进内核"而放弃——性能数据本身未被质疑(解读(AI 分析))。

⚠️ 风险与局限

潜在回归 / 并发 / 边界
短串回退:基准中 first(64B)场景 0.92x–0.98x 轻微变慢,对齐判断 + 展开宏的固定开销在小串上占主导(解读(AI 分析))。
架构依赖:asm/word-at-a-time.h 是架构相关实现,x86 用乘减位掩码,其他架构(如某些 32 位/有特殊字节序的架构)行为可能不同;IS_ALIGNED(s1 ^ s2) 的相对对齐判断也隐含字大小一致假设(解读(AI 分析))。
宏复杂度:带 goto/return 的展开宏(checkpatch 已警告)使控制流隐藏在调用点,后续维护容易出错;这也是评审反对的主因之一。
KMSAN 交互:强制走字节路径避免读越界未初始化内存,但会失去优化收益;KMSAN 使能时行为正确但性能回归是预期内的。
错误语义保持:作者声称保留 EFAULT/E2BIG/ERANGE 顺序,但该声明在 RFC 阶段未附独立测试证明(作者补了 exercise/failure 测试,评审未质疑,也未合入验证)。
性能类别定位:纯 on-CPU 热路径优化,无锁/并发改动,多核扩展性不受影响。
严重度:MAJOR(维护性/复杂度) · review 质疑:Andrii "not worth it, too much code and complexity"(已链接来源) · 结局:作者接受并撤回

🔗 交叉引用

📌 关联工作

✅ 关键洞察

  • 发现:BPF 字符串 kfunc 长期是"能工作但次优"的逐字节实现,在字符串密集负载(HTTP 头解析、子串搜索)上是真实可测的热路径;作者用内核已有的 word-at-a-time 位掩码技巧,把扫描从 O(N) 次加载降到 O(N/8),长串提速 2.03x–11.30x(作者自报)。
  • 证据:最强数据是 HTTP Host 搜索 absent 场景 kfunc 从 7389→982 ns/op(20.84x)、以及 4-kfunc 基准中 span absent 场景 11.30x——但全部为作者自报、未独立验证,且系列已撤回。
  • 边界:收益只出现在"目标靠后/缺失的长串";64B 短串反而略回退;架构依赖 asm/word-at-a-time.h,KMSAN 下自动降级。
  • 风险 / 建议:评审(Andrii)判"复杂度不值",作者接受并撤回。对本日报的价值不只是性能数字,更是维护哲学案例:性能优化要过"复杂度/收益比"这道关——纯性能、大规模但复杂的内核改动,即使基准漂亮也可能因维护成本被拒。若后续想推进,建议走"只优化最高频的 bpf_strnstr + 简化宏"的缩小版,或把 SWAR 复杂度留在 BPF 库/用户程序侧(解读(AI 分析))。
⚠️ 免责声明

本站内容均由 AI 基于公开知识辅助生成,仅供学习参考,请勿直接引用作为依据。作者不对信息的准确性、完整性及适用性作保证,亦不对因使用本站内容产生的任何损失承担责任。