Refactor test output in arena_list.c to remove redundant print statements

- Removed unnecessary print statements for arena sum and number of elements in the test_arena_list_add_del function.
- Simplified output to focus on essential test results, improving clarity and conciseness of the test logs.
This commit is contained in:
yunwei37
2025-10-06 06:24:08 +08:00
parent 6042594b8c
commit f3c4a3ee64
11 changed files with 1329 additions and 17 deletions

View File

@@ -178,7 +178,7 @@ This attempts a single packet injection. If the kernel lacks support (Linux <5.1
Navigate to the tutorial directory and build the project:
```bash
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/46-xdp-test
cd bpf-developer-tutorial/src/46-xdp-test
make build
```

View File

@@ -178,7 +178,7 @@ static int probe_kernel_support(int run_prog_fd)
导航到教程目录并构建项目
```bash
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/46-xdp-test
cd bpf-developer-tutorial/src/46-xdp-test
make build
```

View File

@@ -241,8 +241,6 @@ static void test_arena_list_add_del(int cnt)
}
sum = list_sum(skel->bss->list_head);
printf("Sum of elements: %d (expected: %d)\n", sum, expected_sum);
printf("Arena sum: %ld (expected: %d)\n", skel->bss->arena_sum, expected_sum);
printf("Number of elements: %d (expected: %d)\n", skel->data->test_val, cnt + 1);
ret = bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arena_list_del), &opts);
if (ret != 0) {
@@ -252,7 +250,6 @@ static void test_arena_list_add_del(int cnt)
sum = list_sum(skel->bss->list_head);
printf("Sum after deletion: %d (expected: 0)\n", sum);
printf("Sum computed by BPF: %d (expected: %d)\n", skel->bss->list_sum, expected_sum);
printf("Arena sum after deletion: %ld (expected: %d)\n", skel->bss->arena_sum, expected_sum);
printf("\nTest passed!\n");
out:
@@ -303,7 +300,7 @@ This allocator design avoids locks by using per-CPU state. Since BPF programs ru
Navigate to the bpf_arena directory and build the example:
```bash
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/features/bpf_arena
cd bpf-developer-tutorial/src/features/bpf_arena
make
```
@@ -320,11 +317,8 @@ Expected output:
```
Testing arena list with 10 elements
Sum of elements: 45 (expected: 45)
Arena sum: 45 (expected: 45)
Number of elements: 11 (expected: 11)
Sum after deletion: 0 (expected: 0)
Sum computed by BPF: 45 (expected: 45)
Arena sum after deletion: 45 (expected: 45)
Test passed!
```

View File

@@ -0,0 +1,360 @@
# eBPF 实例教程BPF Arena 零拷贝共享内存
你是否曾经尝试在 eBPF 中构建链表,却不得不使用笨拙的整数索引而不是真正的指针?或者需要在内核 BPF 程序和用户空间之间共享大量数据,却受困于昂贵的系统调用?传统的 BPF map 强制你绕过指针限制,并且每次访问都需要系统调用。如果你可以使用普通的 C 指针,并在内核和用户空间之间实现直接内存访问会怎样?
这正是 **BPF Arena** 要解决的问题。由 Alexei Starovoitov 在 2024 年创建arena 提供了一个稀疏共享内存区域BPF 程序可以使用真正的指针来构建链表、树和图等复杂数据结构,而用户空间可以零拷贝直接访问相同的内存。在本教程中,我们将在 arena 内存中构建一个链表,并展示内核和用户空间如何使用标准指针操作来操作它。
## BPF Arena 简介:突破 Map 的限制
### 问题:当 BPF Maps 不够用时
传统的 BPF map 非常适合简单的键值存储,但当你需要复杂的数据结构或大规模数据共享时,它们存在根本性的限制。让我们看看在 arena 出现之前开发者面临的问题。
**环形缓冲区**只能单向工作 - BPF 可以向用户空间发送数据,但用户空间无法写回。它们仅支持流式传输,没有随机访问。**哈希和数组 map** 从用户空间的每次访问都需要 `bpf_map_lookup_elem()` 等系统调用。数组 map 预先分配所有内存,如果你只使用一小部分条目就会浪费空间。最关键的是,**你不能使用真正的指针** - 你被迫使用整数索引来链接数据结构。
用旧方法构建链表看起来像这样混乱:
```c
struct node {
int next_idx; // 不能使用指针,必须使用索引!
int data;
};
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 10000);
__type(value, struct node);
} nodes_map SEC(".maps");
// 遍历需要重复的 map 查找
int idx = head_idx;
while (idx != -1) {
struct node *n = bpf_map_lookup_elem(&nodes_map, &idx);
if (!n) break;
process(n->data);
idx = n->next_idx; // 不能跟随指针!
}
```
每个节点访问都需要一次 map 查找。你不能像普通 C 代码那样跟随指针。验证器不允许你在不同的 map 条目之间使用指针。这使得实现树、图或任何基于指针的结构变得非常笨拙和缓慢。
### 解决方案:具有真实指针的稀疏共享内存
2024 年,来自 Linux 内核团队的 Alexei Starovoitov 引入了 BPF arena 来解决这些限制。Arena 在 BPF 程序和用户空间之间提供了一个**稀疏共享内存区域**,支持高达 4GB 的地址空间。内存页按需分配,因此不会浪费空间。内核 BPF 代码和用户空间程序都可以映射相同的 arena 并直接访问它。
改变游戏规则的是:你可以在针对 arena 内存的 BPF 程序中使用**真正的 C 指针**。`__arena` 注解告诉验证器这些指针引用 arena 空间,特殊的地址空间转换(`cast_kern()``cast_user()`)让你安全地在内核和用户空间视图之间转换相同的内存。用户空间通过 `mmap()` 获得零拷贝访问 - 无需系统调用即可读取或写入 arena 数据。
使用 arena 的相同链表如下所示:
```c
struct node __arena {
struct node __arena *next; // 真正的指针!
int data;
};
struct node __arena *head;
// 使用普通指针跟随进行遍历
struct node __arena *n = head;
while (n) {
process(n->data);
n = n->next; // 只需跟随指针!
}
```
简洁、简单,完全像你在普通 C 中编写的那样。验证器理解 arena 指针并允许你安全地解引用它们。
### 为什么这很重要
Arena 的灵感来自研究,这些研究展示了 BPF 中复杂数据结构的潜力。在 arena 之前,开发者使用巨大的 BPF 数组 map 和整数索引而不是指针来构建哈希表、队列和树。它可以工作但代码丑陋且缓慢。Arena 解锁了几个强大的用例。
**内核数据结构**变得实用。你可以实现带有碰撞链接的自定义哈希表、用于排序数据的 AVL 或红黑树、用于网络拓扑映射的图,所有这些都使用普通的指针操作。**键值存储加速器**可以在内核中运行以获得最大性能,用户空间无需系统调用开销即可直接访问数据结构。**双向通信**自然工作 - 内核和用户空间都可以使用无锁算法修改共享数据结构。**大数据聚合**可扩展到 4GB而不是受限于典型的 map 大小约束。
## 实现:在 Arena 内存中构建链表
让我们构建一个完整的示例来展示 arena 的强大功能。我们将创建一个链表,其中 BPF 程序使用真实指针添加和删除元素,而用户空间直接访问列表来计算总和,无需任何系统调用。
### 完整的 BPF 程序arena_list.bpf.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Copyright (c) 2024 Meta Platforms, Inc. and affiliates. */
#define BPF_NO_KFUNC_PROTOTYPES
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "bpf_experimental.h"
struct {
__uint(type, BPF_MAP_TYPE_ARENA);
__uint(map_flags, BPF_F_MMAPABLE);
__uint(max_entries, 100); /* number of pages */
#ifdef __TARGET_ARCH_arm64
__ulong(map_extra, 0x1ull << 32); /* start of mmap() region */
#else
__ulong(map_extra, 0x1ull << 44); /* start of mmap() region */
#endif
} arena SEC(".maps");
#include "bpf_arena_alloc.h"
#include "bpf_arena_list.h"
struct elem {
struct arena_list_node node;
__u64 value;
};
struct arena_list_head __arena *list_head;
int list_sum;
int cnt;
bool skip = false;
#ifdef __BPF_FEATURE_ADDR_SPACE_CAST
long __arena arena_sum;
int __arena test_val = 1;
struct arena_list_head __arena global_head;
#else
long arena_sum SEC(".addr_space.1");
int test_val SEC(".addr_space.1");
#endif
int zero;
SEC("syscall")
int arena_list_add(void *ctx)
{
#ifdef __BPF_FEATURE_ADDR_SPACE_CAST
__u64 i;
list_head = &global_head;
for (i = zero; i < cnt && can_loop; i++) {
struct elem __arena *n = bpf_alloc(sizeof(*n));
test_val++;
n->value = i;
arena_sum += i;
list_add_head(&n->node, list_head);
}
#else
skip = true;
#endif
return 0;
}
SEC("syscall")
int arena_list_del(void *ctx)
{
#ifdef __BPF_FEATURE_ADDR_SPACE_CAST
struct elem __arena *n;
int sum = 0;
arena_sum = 0;
list_for_each_entry(n, list_head, node) {
sum += n->value;
arena_sum += n->value;
list_del(&n->node);
bpf_free(n);
}
list_sum = sum;
#else
skip = true;
#endif
return 0;
}
char _license[] SEC("license") = "GPL";
```
### 理解 BPF 代码
程序首先定义 arena map 本身。`BPF_MAP_TYPE_ARENA` 告诉内核这是 arena 内存,`BPF_F_MMAPABLE` 使其可以从用户空间通过 `mmap()` 访问。`max_entries` 字段指定 arena 可以容纳多少页(通常每页 4KB- 这里我们允许最多 100 页,约 400KB。`map_extra` 字段设置 arena 在虚拟地址空间中的映射位置,为 ARM64 和 x86-64 使用不同的地址以避免与现有映射冲突。
定义 map 后,我们包含 arena 辅助函数。`bpf_arena_alloc.h` 文件提供 `bpf_alloc()``bpf_free()` 函数 - 一个与 arena 页一起工作的简单内存分配器,类似于 `malloc()``free()`,但专门用于 arena 内存。`bpf_arena_list.h` 文件使用 arena 指针实现双向链表操作,包括 `list_add_head()` 用于前置节点,`list_for_each_entry()` 用于安全迭代。
我们的 `elem` 结构包含实际数据。`arena_list_node` 成员提供用于链接节点的 `next``pprev` 指针 - 这些是用 `__arena` 标记的 arena 指针。`value` 字段保存我们的有效载荷数据。注意 `list_head` 上的 `__arena` 注解 - 这告诉验证器该指针引用 arena 内存,而不是普通内核内存。
`arena_list_add()` 函数创建列表元素。它标记为 `SEC("syscall")`,因为用户空间将使用 `bpf_prog_test_run()` 触发它。循环使用 `bpf_alloc(sizeof(*n))` 分配新元素,它返回一个 arena 指针。然后我们可以直接解引用 `n->value` - 验证器允许这样做,因为 `n` 是一个 arena 指针。`list_add_head()` 调用使用普通指针操作将新节点前置到列表,所有这些都发生在 arena 内存中。`can_loop` 检查满足验证器的有界循环要求。
`arena_list_del()` 函数演示了迭代和清理。`list_for_each_entry()` 宏沿着 arena 指针遍历列表。在循环内部,我们计算值的总和并删除节点。`bpf_free(n)` 调用将内存返回给 arena 分配器,减少引用计数,当计数降至零时可能释放页面。
地址空间转换功能至关重要。一些编译器支持 `__BPF_FEATURE_ADDR_SPACE_CAST`,它使 `__arena` 注解作为编译器地址空间工作。如果没有此支持,我们将退回到使用显式节注解,如 `SEC(".addr_space.1")`。代码检查此功能,如果不可用则跳过执行,防止运行时错误。
### 完整的用户空间程序arena_list.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Copyright (c) 2024 Meta Platforms, Inc. and affiliates. */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <stdint.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include "bpf_arena_list.h"
#include "arena_list.skel.h"
struct elem {
struct arena_list_node node;
uint64_t value;
};
static int list_sum(struct arena_list_head *head)
{
struct elem __arena *n;
int sum = 0;
list_for_each_entry(n, head, node)
sum += n->value;
return sum;
}
static void test_arena_list_add_del(int cnt)
{
LIBBPF_OPTS(bpf_test_run_opts, opts);
struct arena_list_bpf *skel;
int expected_sum = (u_int64_t)cnt * (cnt - 1) / 2;
int ret, sum;
skel = arena_list_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return;
}
skel->bss->cnt = cnt;
ret = bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arena_list_add), &opts);
if (ret != 0) {
fprintf(stderr, "Failed to run arena_list_add: %d\n", ret);
goto out;
}
if (opts.retval != 0) {
fprintf(stderr, "arena_list_add returned %d\n", opts.retval);
goto out;
}
if (skel->bss->skip) {
printf("SKIP: compiler doesn't support arena_cast\n");
goto out;
}
sum = list_sum(skel->bss->list_head);
printf("Sum of elements: %d (expected: %d)\n", sum, expected_sum);
ret = bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arena_list_del), &opts);
if (ret != 0) {
fprintf(stderr, "Failed to run arena_list_del: %d\n", ret);
goto out;
}
sum = list_sum(skel->bss->list_head);
printf("Sum after deletion: %d (expected: 0)\n", sum);
printf("Sum computed by BPF: %d (expected: %d)\n", skel->bss->list_sum, expected_sum);
printf("\nTest passed!\n");
out:
arena_list_bpf__destroy(skel);
}
int main(int argc, char **argv)
{
int cnt = 10;
if (argc > 1) {
cnt = atoi(argv[1]);
if (cnt <= 0) {
fprintf(stderr, "Invalid count: %s\n", argv[1]);
return 1;
}
}
printf("Testing arena list with %d elements\n", cnt);
test_arena_list_add_del(cnt);
return 0;
}
```
### 理解用户空间代码
用户空间程序演示了对 arena 内存的零拷贝访问。当我们使用 `arena_list_bpf__open_and_load()` 加载 BPF 骨架时libbpf 自动将 arena `mmap()` 到用户空间。指针 `skel->bss->list_head` 直接指向这个映射的 arena 内存。
`list_sum()` 函数从用户空间遍历链表。注意我们使用与 BPF 代码相同的 `list_for_each_entry()` 宏。列表在 arena 内存中,在内核和用户空间之间共享。用户空间可以直接解引用 arena 指针以访问节点值并跟随 `next` 指针 - 无需系统调用。这就是零拷贝的好处:用户空间直接从映射区域读取内存。
测试流程编排演示。首先,我们设置 `skel->bss->cnt` 来指定要创建多少个列表元素。然后 `bpf_prog_test_run_opts()` 执行 `arena_list_add` BPF 程序,它在 arena 内存中构建列表。一旦返回,用户空间立即调用 `list_sum()` 通过直接从用户空间遍历它来验证列表 - 无需系统调用,只是直接内存访问。预期总和计算为 0+1+2+...+(cnt-1),等于 cnt*(cnt-1)/2。
验证列表后,我们运行 `arena_list_del` 来删除所有元素。这个 BPF 程序遍历列表,计算自己的总和,并对每个节点调用 `bpf_free()`。然后用户空间通过再次调用 `list_sum()` 来验证列表是否为空,应该返回 0。我们还检查 `skel->bss->list_sum` 是否与我们的预期值匹配,确认 BPF 程序在删除节点之前计算了正确的总和。
## 理解 Arena 内存分配
arena 分配器值得仔细研究,因为它展示了 BPF 程序如何在 arena 空间中实现复杂的内存管理。`bpf_arena_alloc.h` 中的分配器使用每 CPU 页片段方法来避免锁定。
每个 CPU 维护自己的当前页和偏移量。当你调用 `bpf_alloc(size)` 时,它首先将大小向上舍入到 8 字节对齐。如果当前页在当前偏移量处有足够的空间,它只需递减偏移量并返回指针即可从那里分配。如果剩余空间不足,它使用 `bpf_arena_alloc_pages()` 分配新页,这是一个内核辅助函数,从内核的页分配器获取 arena 页。每个页在其最后 8 个字节中维护引用计数,跟踪有多少分配的对象指向该页。
`bpf_free(addr)` 函数实现引用计数释放。它将地址向下舍入到页边界,找到引用计数,并递减它。当计数达到零时 - 意味着从该页分配的所有对象都已被释放 - 它使用 `bpf_arena_free_pages()` 将整个页返回给内核。这种页级引用计数意味着单个 `bpf_free()` 调用很快,并且只有在适当的时候才将内存返回给系统。
这种分配器设计通过使用每 CPU 状态来避免锁定。由于 BPF 程序在禁用抢占的单个 CPU 上运行,当前 CPU 的页片段可以在没有同步的情况下访问。这使得 `bpf_alloc()` 极快 - 通常只需几条指令即可从当前页分配。
## 编译和执行
导航到 bpf_arena 目录并构建示例:
```bash
cd bpf-developer-tutorial/src/features/bpf_arena
make
```
Makefile 使用 `-D__BPF_FEATURE_ADDR_SPACE_CAST` 编译 BPF 程序以启用 arena 指针支持。它使用 `bpftool gen object` 处理编译的 BPF 对象并生成用户空间可以包含的骨架头。
使用 10 个元素运行 arena 列表测试:
```bash
sudo ./arena_list 10
```
预期输出:
```
Testing arena list with 10 elements
Sum of elements: 45 (expected: 45)
Sum after deletion: 0 (expected: 0)
Sum computed by BPF: 45 (expected: 45)
Test passed!
```
尝试使用更多元素来查看 arena 的扩展性:
```bash
sudo ./arena_list 100
```
总和应该是 4950 (100*99/2)。注意用户空间可以通过直接访问 arena 内存来验证列表,无需任何系统调用。这种零拷贝访问正是使 arena 对大型数据结构强大的原因。
## 何时使用 Arena 与其他 BPF Maps
选择正确的 BPF map 类型取决于你的访问模式和数据结构需求。**使用常规 BPF maps**(哈希、数组等)当你需要简单的键值存储、适合 map 的小型数据结构、标准 map 操作(如原子更新)或没有复杂链接的每 CPU 统计信息时。Maps 在使用内核提供的操作的直接用例中表现出色。
**使用 BPF Arena** 当你需要复杂的链接结构(如列表、树或图)、超过典型 map 大小的大型共享内存、零拷贝用户空间访问以避免系统调用开销,或超出 map 提供的自定义内存管理时。Arena 在指针操作自然的复杂数据结构方面表现出色。
**使用环形缓冲区**当你需要从 BPF 到用户空间的单向流式传输、事件日志或跟踪数据,或顺序处理的数据而无需随机访问时。环形缓冲区针对高吞吐量事件流进行了优化,但不支持双向访问或复杂的数据结构。
arena 与 map 的权衡基本上归结为指针和访问模式。如果你发现自己在 BPF map 中编码索引来模拟指针arena 可能是更好的选择。如果你需要从内核和用户空间都可访问的大规模数据结构arena 的零拷贝共享内存模型难以超越。
## 总结和下一步
BPF Arena 通过提供稀疏共享内存解决了传统 BPF map 的根本限制,你可以在其中使用真正的 C 指针来构建复杂的数据结构。由 Alexei Starovoitov 在 2024 年创建arena 使用普通指针操作而不是笨拙的整数索引实现链表、树、图和自定义分配器。内核 BPF 程序和用户空间都可以映射相同的 arena 以进行零拷贝双向访问,消除系统调用开销。
我们的链表示例演示了核心 arena 概念:定义 arena map、使用 `__arena` 注解用于指针类型、使用 `bpf_alloc()` 分配内存,以及从内核和用户空间访问相同的数据结构。每 CPU 页片段分配器展示了 BPF 程序如何在 arena 空间中实现复杂的内存管理。Arena 为内核数据结构、键值存储加速器和高达 4GB 的大规模数据聚合解锁了新的可能性。
> 如果你想深入了解 eBPF请查看我们的教程仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- **原始 Arena 补丁:** <https://lwn.net/Articles/961594/>
- **Meta 的 Arena 示例:** Linux 内核树 `samples/bpf/arena_*.c`
- **教程仓库:** <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/features/bpf_arena>
- **Linux 内核源码:** `kernel/bpf/arena.c` - Arena 实现
- **LLVM 地址空间:** 关于 `__arena` 编译器支持的文档
此示例改编自 Linux 内核示例中 Meta 的 arena_list.c并增加了教育性增强。需要 Linux 内核 6.10+ 并启用 `CONFIG_BPF_ARENA=y`。完整源代码可在教程仓库中获得。

View File

@@ -55,8 +55,6 @@ static void test_arena_list_add_del(int cnt)
}
sum = list_sum(skel->bss->list_head);
printf("Sum of elements: %d (expected: %d)\n", sum, expected_sum);
printf("Arena sum: %ld (expected: %d)\n", skel->bss->arena_sum, expected_sum);
printf("Number of elements: %d (expected: %d)\n", skel->data->test_val, cnt + 1);
ret = bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arena_list_del), &opts);
if (ret != 0) {
@@ -66,7 +64,6 @@ static void test_arena_list_add_del(int cnt)
sum = list_sum(skel->bss->list_head);
printf("Sum after deletion: %d (expected: 0)\n", sum);
printf("Sum computed by BPF: %d (expected: %d)\n", skel->bss->list_sum, expected_sum);
printf("Arena sum after deletion: %ld (expected: %d)\n", skel->bss->arena_sum, expected_sum);
printf("\nTest passed!\n");
out:

View File

@@ -1,4 +1,4 @@
# eBPF Tutorial by Example: BPF Iterators for Kernel Data Export
# eBPF Tutorial: BPF Iterators for Kernel Data Export
Ever tried monitoring hundreds of processes and ended up parsing thousands of `/proc` files just to find the few you care about? Or needed custom formatted kernel data but didn't want to modify the kernel itself? Traditional `/proc` filesystem access is slow, inflexible, and forces you to process tons of data in userspace even when you only need a small filtered subset.
@@ -294,7 +294,7 @@ After loading, we select which iterator to run based on the `--files` flag. Both
Navigate to the bpf_iters directory and build:
```bash
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/features/bpf_iters
cd bpf-developer-tutorial/src/features/bpf_iters
make
```

View File

@@ -0,0 +1,382 @@
# eBPF 教程BPF 迭代器用于内核数据导出
你是否曾经尝试监控数百个进程,却不得不解析数千个 `/proc` 文件,只为找到你关心的几个进程?或者需要自定义格式的内核数据,但不想修改内核本身?传统的 `/proc` 文件系统访问速度慢、不灵活,即使你只需要一小部分过滤后的数据,也会强制你在用户空间处理大量数据。
这正是 **BPF 迭代器**要解决的问题。在 Linux 内核 5.8 中引入,迭代器让你可以直接从 BPF 程序遍历内核数据结构,在内核中应用过滤器,并以你想要的任何格式输出你需要的确切数据。在本教程中,我们将构建一个双模式迭代器,它显示进程的内核堆栈跟踪和打开的文件描述符,并通过进程名称进行内核内过滤 - 比解析 `/proc` 快得多。
## BPF 迭代器简介:/proc 的替代品
### 问题:/proc 缓慢且僵化
传统的 Linux 监控围绕着 `/proc` 文件系统展开。需要查看进程在做什么?读取 `/proc/*/stack`。想要打开的文件?解析 `/proc/*/fd/*`。这样可以工作,但当你在大规模监控系统或需要内核数据的特定过滤视图时,效率非常低下。
性能问题是系统性的。每次 `/proc` 访问都需要一个系统调用、内核模式转换、文本格式化、数据复制到用户空间,然后你将文本解析回结构。如果你想要 1000 个进程中所有 "bash" 进程的堆栈跟踪,你仍然需要读取所有 1000 个 `/proc/*/stack` 文件并在用户空间过滤。这就是 1000 次系统调用、1000 次文本解析操作,以及传输的数兆字节数据,只是为了找到少数几个匹配项。
格式不灵活性加剧了问题。内核选择显示什么数据以及如何格式化。想要带有自定义注释的堆栈跟踪?抱歉,你只能得到内核的固定格式。需要跨进程聚合数据?在用户空间解析所有内容。`/proc` 接口是为人类使用而设计的,而不是为程序化过滤和分析设计的。
传统监控是这样的:
```bash
# 查找所有 bash 进程的堆栈跟踪
for pid in $(pgrep bash); do
echo "=== PID $pid ==="
cat /proc/$pid/stack
done
```
这会生成 `pgrep` 作为子进程,对每个匹配的 PID 进行一次系统调用以读取堆栈文件,解析文本输出,并在用户空间进行所有过滤。编写简单,但性能糟糕。
### 解决方案:可编程的内核内迭代
BPF 迭代器翻转了这个模型。与其将所有数据拉到用户空间进行处理,不如将处理逻辑推送到数据所在的内核中。迭代器是一个附加到内核数据结构遍历的 BPF 程序,它会为每个元素调用。内核遍历任务、文件或套接字,用每个元素的上下文调用你的 BPF 程序,你的代码决定输出什么以及如何格式化。
架构很优雅。你编写一个标记为 `SEC("iter/task")``SEC("iter/task_file")` 的 BPF 程序,在迭代期间接收每个任务或文件。在这个程序中,你可以直接访问内核结构字段,可以使用普通的 C 逻辑根据任何条件进行过滤,并使用 `BPF_SEQ_PRINTF()` 按需格式化输出。内核处理迭代机制,而你的代码纯粹专注于过滤和格式化。
当用户空间从迭代器文件描述符读取时,魔法完全发生在内核中。内核遍历任务列表,为每个任务调用你的 BPF 程序并传递 task_struct 指针。你的程序检查任务名称是否匹配你的过滤器 - 如果不匹配,它立即返回 0 且不输出。如果匹配,你的程序提取堆栈跟踪并将其格式化到 seq_file。所有这些都发生在内核上下文中然后数据才会跨越到用户空间。
好处是变革性的。**内核内过滤**意味着只有相关数据跨越内核边界,消除了浪费的工作。**自定义格式**让你可以输出二进制、JSON、CSV无论你的工具需要什么。**单次读取操作**取代了数千次单独的 `/proc` 文件访问。**零解析**,因为你在内核中正确格式化了数据。**可组合性**与标准 Unix 工具配合使用,因为迭代器输出通过普通文件描述符传递。
### 迭代器类型和能力
内核为许多子系统提供迭代器。**任务迭代器**`iter/task`)遍历所有任务,让你访问进程状态、凭据、资源使用和父子关系。**文件迭代器**`iter/task_file`)遍历打开的文件描述符,显示文件、套接字、管道和其他 fd 类型。**网络迭代器**`iter/tcp``iter/udp`)遍历活动网络连接及完整的套接字状态。**BPF 对象迭代器**`iter/bpf_map``iter/bpf_prog`)枚举已加载的 BPF 程序和 map 以进行内省。
我们的教程专注于任务和 task_file 迭代器,因为它们解决了常见的监控需求,并展示了适用于所有迭代器类型的核心概念。
## 实现:双模式任务迭代器
让我们构建一个完整的示例,在一个工具中演示两种迭代器类型。我们将创建一个程序,可以显示进程的内核堆栈跟踪或打开的文件描述符,并可选择按进程名称进行过滤。
### 完整的 BPF 程序task_stack.bpf.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Kernel task stack and file descriptor iterator */
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
char _license[] SEC("license") = "GPL";
#define MAX_STACK_TRACE_DEPTH 64
unsigned long entries[MAX_STACK_TRACE_DEPTH] = {};
#define SIZE_OF_ULONG (sizeof(unsigned long))
/* Filter: only show stacks for tasks with this name (empty = show all) */
char target_comm[16] = "";
__u32 stacks_shown = 0;
__u32 files_shown = 0;
/* Task stack iterator */
SEC("iter/task")
int dump_task_stack(struct bpf_iter__task *ctx)
{
struct seq_file *seq = ctx->meta->seq;
struct task_struct *task = ctx->task;
long i, retlen;
int match = 1;
if (task == (void *)0) {
/* End of iteration - print summary */
if (stacks_shown > 0) {
BPF_SEQ_PRINTF(seq, "\n=== Summary: %u task stacks shown ===\n",
stacks_shown);
}
return 0;
}
/* Filter by task name if specified */
if (target_comm[0] != '\0') {
match = 0;
for (i = 0; i < 16; i++) {
if (task->comm[i] != target_comm[i])
break;
if (task->comm[i] == '\0') {
match = 1;
break;
}
}
if (!match)
return 0;
}
/* Get kernel stack trace for this task */
retlen = bpf_get_task_stack(task, entries,
MAX_STACK_TRACE_DEPTH * SIZE_OF_ULONG, 0);
if (retlen < 0)
return 0;
stacks_shown++;
/* Print task info and stack trace */
BPF_SEQ_PRINTF(seq, "=== Task: %s (pid=%u, tgid=%u) ===\n",
task->comm, task->pid, task->tgid);
BPF_SEQ_PRINTF(seq, "Stack depth: %u frames\n", retlen / SIZE_OF_ULONG);
for (i = 0; i < MAX_STACK_TRACE_DEPTH; i++) {
if (retlen > i * SIZE_OF_ULONG)
BPF_SEQ_PRINTF(seq, " [%2ld] %pB\n", i, (void *)entries[i]);
}
BPF_SEQ_PRINTF(seq, "\n");
return 0;
}
/* Task file descriptor iterator */
SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
struct seq_file *seq = ctx->meta->seq;
struct task_struct *task = ctx->task;
struct file *file = ctx->file;
__u32 fd = ctx->fd;
long i;
int match = 1;
if (task == (void *)0 || file == (void *)0) {
if (files_shown > 0 && ctx->meta->seq_num > 0) {
BPF_SEQ_PRINTF(seq, "\n=== Summary: %u file descriptors shown ===\n",
files_shown);
}
return 0;
}
/* Filter by task name if specified */
if (target_comm[0] != '\0') {
match = 0;
for (i = 0; i < 16; i++) {
if (task->comm[i] != target_comm[i])
break;
if (task->comm[i] == '\0') {
match = 1;
break;
}
}
if (!match)
return 0;
}
if (ctx->meta->seq_num == 0) {
BPF_SEQ_PRINTF(seq, "%-16s %8s %8s %6s %s\n",
"COMM", "TGID", "PID", "FD", "FILE_OPS");
}
files_shown++;
BPF_SEQ_PRINTF(seq, "%-16s %8d %8d %6d 0x%lx\n",
task->comm, task->tgid, task->pid, fd,
(long)file->f_op);
return 0;
}
```
### 理解 BPF 代码
程序实现了两个共享通用过滤逻辑的独立迭代器。`SEC("iter/task")` 注解将 `dump_task_stack` 注册为任务迭代器 - 内核将为系统中的每个任务调用此函数一次。上下文结构 `bpf_iter__task` 提供三个关键部分:包含迭代元数据和用于输出的 seq_file 的 `meta` 字段,指向当前 task_struct 的 `task` 指针,以及当迭代结束时为 NULL 的任务指针,以便你可以打印摘要。
任务堆栈迭代器展示了内核内过滤的实际应用。当 `task` 为 NULL 时,我们已到达迭代的结尾,可以打印摘要统计信息,显示有多少任务与我们的过滤器匹配。对于每个任务,我们首先通过将 `task->comm`(进程名称)与 `target_comm` 进行比较来应用过滤。我们不能在 BPF 中使用像 `strcmp()` 这样的标准库函数,所以我们手动循环遍历字符逐字节比较。如果名称不匹配且启用了过滤,我们立即返回 0 且不输出 - 这个任务在内核中完全被跳过,不会跨越到用户空间。
一旦任务通过过滤,我们使用 `bpf_get_task_stack()` 提取其内核堆栈跟踪。这个 BPF 辅助函数将最多 64 个堆栈帧捕获到我们的 `entries` 数组中,返回写入的字节数。我们使用 `BPF_SEQ_PRINTF()` 格式化输出,它写入内核的 seq_file 基础设施。特殊的 `%pB` 格式说明符将内核地址符号化,将原始指针转换为人类可读的函数名称,如 `schedule+0x42/0x100`。这使得堆栈跟踪立即可用于调试。
文件描述符迭代器演示了不同的迭代器类型。`SEC("iter/task_file")` 告诉内核为所有任务的每个打开的文件描述符调用此函数。上下文提供 `task``file`(内核的 struct file 指针)和 `fd`(数字文件描述符)。我们应用相同的任务名称过滤,然后将输出格式化为表格。使用 `ctx->meta->seq_num` 检测第一次输出让我们可以只打印一次列标题。
注意过滤如何在任何昂贵的操作之前发生。我们首先检查任务名称,只有在匹配时才提取堆栈跟踪或格式化文件信息。这最小化了内核快速路径中的工作 - 不匹配的任务只需进行字符串比较就被拒绝,没有内存分配、没有格式化、没有输出。
### 完整的用户空间程序task_stack.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Userspace program for task stack and file iterator */
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include "task_stack.skel.h"
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}
static void run_iterator(const char *name, struct bpf_program *prog)
{
struct bpf_link *link;
int iter_fd, len;
char buf[8192];
link = bpf_program__attach_iter(prog, NULL);
if (!link) {
fprintf(stderr, "Failed to attach %s iterator\n", name);
return;
}
iter_fd = bpf_iter_create(bpf_link__fd(link));
if (iter_fd < 0) {
fprintf(stderr, "Failed to create %s iterator: %d\n", name, iter_fd);
bpf_link__destroy(link);
return;
}
while ((len = read(iter_fd, buf, sizeof(buf) - 1)) > 0) {
buf[len] = '\0';
printf("%s", buf);
}
close(iter_fd);
bpf_link__destroy(link);
}
int main(int argc, char **argv)
{
struct task_stack_bpf *skel;
int err;
int show_files = 0;
libbpf_set_print(libbpf_print_fn);
/* Parse arguments */
if (argc > 1 && strcmp(argv[1], "--files") == 0) {
show_files = 1;
argc--;
argv++;
}
/* Open BPF application */
skel = task_stack_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
/* Configure filter before loading */
if (argc > 1) {
strncpy(skel->bss->target_comm, argv[1], sizeof(skel->bss->target_comm) - 1);
printf("Filtering for tasks matching: %s\n\n", argv[1]);
} else {
printf("Usage: %s [--files] [comm]\n", argv[0]);
printf(" --files Show open file descriptors instead of stacks\n");
printf(" comm Filter by process name\n\n");
}
/* Load BPF program */
err = task_stack_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load BPF skeleton\n");
goto cleanup;
}
if (show_files) {
printf("=== BPF Task File Descriptor Iterator ===\n\n");
run_iterator("task_file", skel->progs.dump_task_file);
} else {
printf("=== BPF Task Stack Iterator ===\n\n");
run_iterator("task", skel->progs.dump_task_stack);
}
cleanup:
task_stack_bpf__destroy(skel);
return err;
}
```
### 理解用户空间代码
用户空间程序展示了一旦你理解了模式,迭代器的使用是多么简单。`run_iterator()` 函数封装了三步迭代器生命周期。首先,`bpf_program__attach_iter()` 将 BPF 程序附加到迭代器基础设施,注册它以在迭代期间被调用。其次,`bpf_iter_create()` 创建表示迭代器实例的文件描述符。第三,简单的 `read()` 调用使用迭代器输出。
这就是使其强大的原因:当你从迭代器 fd 读取时,内核透明地开始遍历任务或文件。对于每个元素,它调用你的 BPF 程序并传递元素的上下文。你的 BPF 代码过滤并格式化输出到 seq_file 缓冲区。内核累积此输出并通过 read() 调用返回它。从用户空间的角度来看,它只是在读取文件 - 所有迭代、过滤和格式化的复杂性都隐藏在内核中。
main 函数处理模式选择和配置。我们解析命令行参数以确定是显示堆栈还是文件,以及要过滤的进程名称。至关重要的是,我们在加载 BPF 程序之前设置 `skel->bss->target_comm`。这将过滤字符串写入 BPF 程序的全局数据节,使其在程序运行时对内核代码可见。这就是我们如何在没有复杂通信通道的情况下将配置从用户空间传递到内核的方法。
加载后,我们根据 `--files` 标志选择要运行哪个迭代器。两个迭代器使用相同的过滤逻辑,但产生不同的输出 - 一个显示堆栈跟踪,另一个显示文件描述符。共享的过滤代码展示了 BPF 程序如何在不同的迭代器类型之间实现可重用的逻辑。
## 编译和执行
导航到 bpf_iters 目录并构建:
```bash
cd bpf-developer-tutorial/src/features/bpf_iters
make
```
Makefile 使用 BTF 支持编译 BPF 程序,并生成包含嵌入在 C 结构中的编译字节码的骨架头。这个骨架 API 使 BPF 程序加载变得简单。
显示所有 systemd 进程的内核堆栈跟踪:
```bash
sudo ./task_stack systemd
```
预期输出:
```
Filtering for tasks matching: systemd
=== BPF Task Stack Iterator ===
=== Task: systemd (pid=1, tgid=1) ===
Stack depth: 6 frames
[ 0] ep_poll+0x447/0x460
[ 1] do_epoll_wait+0xc3/0xe0
[ 2] __x64_sys_epoll_wait+0x6d/0x110
[ 3] x64_sys_call+0x19b1/0x2310
[ 4] do_syscall_64+0x7e/0x170
[ 5] entry_SYSCALL_64_after_hwframe+0x76/0x7e
=== Summary: 1 task stacks shown ===
```
显示 bash 进程的打开文件描述符:
```bash
sudo ./task_stack --files bash
```
预期输出:
```
Filtering for tasks matching: bash
=== BPF Task File Descriptor Iterator ===
COMM TGID PID FD FILE_OPS
bash 12345 12345 0 0xffffffff81e3c6e0
bash 12345 12345 1 0xffffffff81e3c6e0
bash 12345 12345 2 0xffffffff81e3c6e0
bash 12345 12345 255 0xffffffff82145dc0
=== Summary: 4 file descriptors shown ===
```
不带过滤运行以查看所有任务:
```bash
sudo ./task_stack
```
这显示了系统中每个任务的堆栈。在典型的桌面上,这可能会显示数百个任务。注意它与为所有进程解析 `/proc/*/stack` 相比运行速度有多快 - 迭代器效率更高。
## 何时使用 BPF 迭代器与 /proc
选择 **BPF 迭代器**当你需要过滤的内核数据而不需要用户空间处理开销、不匹配 `/proc` 文本的自定义输出格式、频繁运行的性能关键监控,或与基于 BPF 的可观测性基础设施集成时。当你监控许多实体但只关心一个子集,或者当你需要在内核中聚合和转换数据时,迭代器表现出色。
选择 **/proc** 当你需要简单的一次性查询、调试或原型设计(开发速度比运行时性能更重要)、希望在内核版本之间获得最大可移植性(迭代器需要相对较新的内核),或在无法加载 BPF 程序的受限环境中运行时。
基本权衡是处理位置。迭代器将过滤和格式化推入内核以提高效率和灵活性,而 `/proc` 保持内核简单并在用户空间进行所有处理。对于复杂系统的生产监控,迭代器通常因其性能优势和编程灵活性而获胜。
## 总结和下一步
BPF 迭代器通过直接从 BPF 代码启用可编程、过滤的迭代,彻底改变了我们导出内核数据的方式。与其重复读取和解析 `/proc` 文件,你编写一个 BPF 程序,在内核内迭代内核结构,在源头应用过滤,并完全按需格式化输出。这消除了来自系统调用、模式转换和用户空间解析的大量开销,同时在输出格式方面提供了完全的灵活性。
我们的双模式迭代器演示了任务和文件迭代,展示了一个 BPF 程序如何使用共享过滤逻辑导出内核数据的多个视图。内核处理复杂的迭代机制,而你的 BPF 代码纯粹专注于过滤和格式化。迭代器通过其文件描述符接口与标准 Unix 工具无缝集成,使它们成为复杂监控管道的可组合构建块。
> 如果你想深入了解 eBPF请查看我们的教程仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- **BPF 迭代器文档:** <https://docs.kernel.org/bpf/bpf_iterators.html>
- **内核迭代器自测:** Linux 内核树 `tools/testing/selftests/bpf/*iter*.c`
- **教程仓库:** <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/features/bpf_iters>
- **libbpf 迭代器 API** <https://github.com/libbpf/libbpf>
- **BPF 辅助函数手册:** <https://man7.org/linux/man-pages/man7/bpf-helpers.7.html>
示例改编自 Linux 内核 BPF 自测,并增加了教育性增强。需要 Linux 内核 5.8+ 以获得迭代器支持、启用 BTF 和 libbpf。完整源代码可在教程仓库中获得。

View File

@@ -1,4 +1,4 @@
# eBPF Tutorial by Example: BPF Workqueues for Asynchronous Sleepable Tasks
# eBPF Tutorial: BPF Workqueues for Asynchronous Sleepable Tasks
Ever needed your eBPF program to sleep, allocate memory, or wait for device I/O? Traditional eBPF programs run in restricted contexts where blocking operations crash the system. But what if your HID device needs timing delays between injected key events, or your cleanup routine needs to sleep while freeing resources?
@@ -236,7 +236,7 @@ The fundamental trade-off is latency vs capability. Timers have lower latency bu
Navigate to the bpf_wq directory and build:
```bash
cd /home/yunwei37/workspace/bpf-developer-tutorial/src/features/bpf_wq
cd bpf-developer-tutorial/src/features/bpf_wq
make
```

View File

@@ -0,0 +1,287 @@
# eBPF 教程BPF 工作队列用于异步可睡眠任务
你是否曾经需要你的 eBPF 程序睡眠、分配内存或等待设备 I/O传统的 eBPF 程序在受限的上下文中运行,阻塞操作会导致系统崩溃。但是,如果你的 HID 设备需要在注入的按键事件之间进行时序延迟,或者你的清理例程需要在释放资源时睡眠怎么办?
这就是 **BPF 工作队列**所实现的功能。由 Red Hat 的 Benjamin Tissoires 于 2024 年为 HID-BPF 设备处理而创建,工作队列让你可以调度在进程上下文中运行的异步工作,在那里允许睡眠和阻塞操作。在本教程中,我们将探讨为什么创建工作队列、它们与定时器有何不同,并构建一个演示异步回调执行的完整示例。
## BPF 工作队列简介:解决睡眠问题
### 问题:当 eBPF 无法睡眠时
在 BPF 工作队列出现之前,开发者有 `bpf_timer` 用于延迟执行。定时器非常适合在延迟后调度回调,非常适合更新计数器或触发周期性事件。但有一个根本性的限制使得定时器对某些关键用例不可用:**bpf_timer 在 softirq软件中断上下文中运行**。
Softirq 上下文有内核强制执行的严格规则。你不能睡眠或等待 I/O - 任何这样做的尝试都会导致内核恐慌或死锁。你不能使用 `GFP_KERNEL` 标志的 `kzalloc()` 分配内存,因为内存分配可能需要等待页面。你不能与需要等待响应的硬件设备通信。本质上,你不能执行任何可能导致 CPU 等待的阻塞操作。
这个限制在 Red Hat 的 Benjamin Tissoires 于 2023 年开发 HID-BPF 时成为一个真正的问题。HID 设备(键盘、鼠标、平板电脑、游戏控制器)经常需要定时器根本无法处理的操作。想象一下实现键盘宏功能,按下 F1 输入 "hello" - 你需要在每次按键之间延迟 10ms以便系统正确处理事件。或者考虑一个固件有问题的设备在系统唤醒后需要重新初始化 - 你必须发送命令并等待硬件响应。softirq 上下文中的定时器回调无法做到这一切。
正如 Benjamin Tissoires 在他的内核补丁中解释的那样:"我需要类似于 bpf_timers 的东西,但不在软 IRQ 上下文中……bpf_timer 功能会阻止我 kzalloc 并等待设备。"
### 解决方案:进程上下文执行
2024 年初Benjamin 提出并开发了 **bpf_wq** - 本质上是"在进程上下文而不是 softirq 中的 bpf_timer"。内核社区在 2024 年 4 月将其合并到 Linux v6.10+ 中。关键见解简单但强大通过在进程上下文中运行回调通过内核的工作队列基础设施BPF 程序可以访问全套内核操作。
以下是进程上下文的变化:
| 功能 | bpf_timer (softirq) | bpf_wq (进程) |
|---------|---------------------|------------------|
| **可以睡眠?** | ❌ 否 - 会崩溃 | ✅ 是 - 安全睡眠 |
| **内存分配** | ❌ 仅限有限标志 | ✅ 完整 `kzalloc()` 支持 |
| **设备 I/O** | ❌ 不能等待 | ✅ 可以等待响应 |
| **阻塞操作** | ❌ 禁止 | ✅ 完全支持 |
| **延迟** | 非常低(微秒) | 较高(毫秒) |
| **用例** | 时间关键快速路径 | 可睡眠慢速路径 |
工作队列启用了经典的"快速路径 + 慢速路径"模式。你的 eBPF 程序在快速路径中立即处理性能关键操作,然后调度昂贵的清理或 I/O 操作在慢速路径中异步运行。快速路径保持响应性,而慢速路径获得所需的能力。
### 实际应用
应用跨越多个领域。**HID 设备处理**是最初的动机 - 注入带时序延迟的键盘宏、无需内核驱动程序即可动态修复损坏的设备固件、从睡眠中唤醒后重新初始化设备、即时转换输入事件。所有这些都需要只有工作队列才能提供的可睡眠操作。
**网络数据包处理**受益于异步清理模式。你的 XDP 程序在快速路径中执行速率限制并丢弃数据包(非阻塞),而工作队列在后台清理过时的跟踪条目。这可以防止内存泄漏而不影响数据包处理性能。
**安全监控**可以立即应用快速规则,然后使用工作队列查询信誉数据库或外部威胁情报服务。快速路径做出即时决策,而慢速路径根据复杂分析更新策略。
**资源清理**推迟昂贵的操作。与其在释放内存、关闭连接或压缩数据结构时阻塞主代码路径,不如调度工作队列在后台处理清理。
## 实现:简单的工作队列测试
让我们构建一个演示工作队列生命周期的完整示例。我们将创建一个在 `unlink` 系统调用上触发、调度异步工作并验证主路径和工作队列回调都正确执行的程序。
### 完整的 BPF 程序wq_simple.bpf.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Simple BPF workqueue example */
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include "bpf_experimental.h"
char LICENSE[] SEC("license") = "GPL";
/* Element with embedded workqueue */
struct elem {
int value;
struct bpf_wq work;
};
/* Array to store our element */
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, int);
__type(value, struct elem);
} array SEC(".maps");
/* Result variables */
__u32 wq_executed = 0;
__u32 main_executed = 0;
/* Workqueue callback - runs asynchronously in workqueue context */
static int wq_callback(void *map, int *key, void *value)
{
struct elem *val = value;
/* This runs later in workqueue context */
wq_executed = 1;
val->value = 42; /* Modify the value asynchronously */
return 0;
}
/* Main program - schedules work */
SEC("fentry/do_unlinkat")
int test_workqueue(void *ctx)
{
struct elem init = {.value = 0}, *val;
struct bpf_wq *wq;
int key = 0;
main_executed = 1;
/* Initialize element in map */
bpf_map_update_elem(&array, &key, &init, 0);
/* Get element from map */
val = bpf_map_lookup_elem(&array, &key);
if (!val)
return 0;
/* Initialize workqueue */
wq = &val->work;
if (bpf_wq_init(wq, &array, 0) != 0)
return 0;
/* Set callback function */
if (bpf_wq_set_callback(wq, wq_callback, 0))
return 0;
/* Schedule work to run asynchronously */
if (bpf_wq_start(wq, 0))
return 0;
return 0;
}
```
### 理解 BPF 代码
程序演示了从初始化到异步执行的完整工作队列工作流程。我们首先定义一个嵌入工作队列的结构。`struct elem` 包含应用数据(`value`)和工作队列句柄(`struct bpf_wq work`)。这种嵌入模式至关重要 - 工作队列基础设施需要知道哪个 map 包含工作队列结构,将其嵌入到 map 值中建立了这种关系。
我们的 map 是一个只有一个条目的简单数组,为了本例的简单性而选择。在生产代码中,你通常会使用哈希 map 来跟踪多个实体,每个实体都有自己的嵌入式工作队列。全局变量 `wq_executed``main_executed` 作为测试工具,让用户空间验证两个代码路径都运行了。
工作队列回调显示了所有工作队列回调必须遵循的签名:`int callback(void *map, int *key, void *value)`。内核在进程上下文中异步调用此函数,传递包含工作队列的 map、条目的键和指向值的指针。这个签名为回调提供了关于哪个元素触发它的完整上下文以及对元素数据的访问。我们的回调设置 `wq_executed = 1` 来证明它运行了,并修改 `val->value = 42` 来演示异步修改在 map 中持久化。
附加到 `fentry/do_unlinkat` 的主程序在 `unlink` 系统调用执行时触发。这为我们提供了一种简单的方法来激活程序 - 用户空间只需删除一个文件。我们立即设置 `main_executed = 1` 来标记同步路径。然后我们初始化一个元素并使用 `bpf_map_update_elem()` 将其存储在 map 中。这是必要的,因为工作队列必须嵌入在 map 条目中。
工作队列初始化遵循三步序列。首先,`bpf_wq_init(wq, &array, 0)` 初始化工作队列句柄,传递包含它的 map。验证器使用此信息来验证工作队列及其容器是否正确相关。其次`bpf_wq_set_callback(wq, wq_callback, 0)` 注册我们的回调函数。验证器在加载时检查此签名,并将拒绝签名不匹配的程序。第三,`bpf_wq_start(wq, 0)` 调度工作队列异步执行。此调用立即返回 - 主程序继续同步执行,而内核将工作排队以便稍后在进程上下文中执行。
所有三个函数中的 flags 参数都保留供将来使用,在当前内核中应为 0。该模式允许将来扩展而不破坏 API 兼容性。
### 完整的用户空间程序wq_simple.c
```c
// SPDX-License-Identifier: GPL-2.0
/* Userspace test for BPF workqueue */
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "wq_simple.skel.h"
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}
int main(int argc, char **argv)
{
struct wq_simple_bpf *skel;
int err, fd;
libbpf_set_print(libbpf_print_fn);
/* Open and load BPF application */
skel = wq_simple_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return 1;
}
/* Attach tracepoint handler */
err = wq_simple_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}
printf("BPF workqueue program attached. Triggering unlink syscall...\n");
/* Create a temporary file to trigger do_unlinkat */
fd = open("/tmp/wq_test_file", O_CREAT | O_WRONLY, 0644);
if (fd >= 0) {
close(fd);
unlink("/tmp/wq_test_file");
}
/* Give workqueue time to execute */
sleep(1);
/* Check results */
printf("\nResults:\n");
printf(" main_executed = %u (expected: 1)\n", skel->bss->main_executed);
printf(" wq_executed = %u (expected: 1)\n", skel->bss->wq_executed);
if (skel->bss->main_executed == 1 && skel->bss->wq_executed == 1) {
printf("\n✓ Test PASSED!\n");
} else {
printf("\n✗ Test FAILED!\n");
err = 1;
}
cleanup:
wq_simple_bpf__destroy(skel);
return err;
}
```
### 理解用户空间代码
用户空间程序编排测试并验证结果。我们使用 libbpf 的骨架 API它将编译的 BPF 字节码嵌入到 C 结构中,使加载变得简单。`wq_simple_bpf__open_and_load()` 调用编译(如果需要)、将 BPF 程序加载到内核中,并在一次操作中创建所有 map。
加载后,`wq_simple_bpf__attach()` 将 fentry 程序附加到 `do_unlinkat`。从这一点开始,任何 unlink 系统调用都会触发我们的 BPF 程序。我们通过创建并立即删除临时文件来故意触发这一点。`open()` 创建 `/tmp/wq_test_file`,我们关闭 fd然后 `unlink()` 删除它。此删除进入内核的 `do_unlinkat` 函数,触发我们的 fentry 探针。
以下是关键的时序方面:工作队列执行是异步的。我们的主 BPF 程序调度工作并立即返回。内核将回调排队以供内核工作线程稍后执行。这就是为什么我们 `sleep(1)` - 在检查结果之前给工作队列时间执行。在生产代码中你会使用更复杂的同步但对于简单的测试sleep 就足够了。
sleep 后,我们从 BPF 程序的 `.bss` 节读取全局变量。骨架通过 `skel->bss->main_executed``skel->bss->wq_executed` 提供便捷访问。如果两者都为 1我们知道同步路径fentry和异步路径工作队列回调都成功执行了。
## 理解工作队列 API
工作队列 API 由管理生命周期的三个基本函数组成。**`bpf_wq_init(wq, map, flags)`** 初始化工作队列句柄,建立工作队列与其包含 map 之间的关系。map 参数至关重要 - 它告诉验证器哪个 map 包含带有嵌入式 `bpf_wq` 结构的值。验证器使用此信息来确保跨异步执行的内存安全。在当前内核中,标志应为 0。
**`bpf_wq_set_callback(wq, callback_fn, flags)`** 注册要异步执行的函数。回调必须具有签名 `int callback(void *map, int *key, void *value)`。验证器在加载时检查此签名,并将拒绝签名不匹配的程序。这种类型安全防止了常见的异步编程错误。标志应为 0。
**`bpf_wq_start(wq, flags)`** 调度工作队列运行。这会立即返回 - 你的 BPF 程序继续同步执行。内核将回调排队以供工作线程在进程上下文中在将来某个时间点执行。回调可能在微秒或毫秒后运行,具体取决于系统负载。标志应为 0。
回调签名值得注意。与接收 `(void *map, __u32 *key, void *value)``bpf_timer` 回调不同,工作队列回调接收 `(void *map, int *key, void *value)`。注意键类型差异 - `int *``__u32 *`。这反映了 API 的演变,必须完全匹配,否则验证器会拒绝你的程序。回调在进程上下文中运行,因此它可以安全地执行在 softirq 上下文中会崩溃的操作。
## 何时使用工作队列与定时器
选择 **bpf_timer** 当你需要微秒精度的定时、操作快速且非阻塞、你正在更新计数器或简单状态,或实现周期性快速路径操作(如统计收集或数据包调度)时。定时器在必须以最小延迟执行的时间关键任务方面表现出色。
选择 **bpf_wq** 当你需要睡眠或等待、使用 `kzalloc()` 分配内存、执行设备或网络 I/O或推迟可以稍后发生的清理操作时。工作队列非常适合"快速路径 + 慢速路径"模式,其中关键操作立即发生,而昂贵的处理异步运行而不阻塞。示例包括 HID 设备 I/O带延迟的键盘宏注入、异步 map 清理(防止内存泄漏)、安全策略更新(查询外部数据库)和后台处理(压缩、加密、聚合)。
基本权衡是延迟与能力。定时器具有较低的延迟但受限的能力。工作队列具有较高的延迟但完整的进程上下文能力,包括睡眠和阻塞 I/O。
## 编译和执行
导航到 bpf_wq 目录并构建:
```bash
cd bpf-developer-tutorial/src/features/bpf_wq
make
```
Makefile 使用启用的实验性工作队列功能编译 BPF 程序并生成骨架头。
运行简单的工作队列测试:
```bash
sudo ./wq_simple
```
预期输出:
```
BPF workqueue program attached. Triggering unlink syscall...
Results:
main_executed = 1 (expected: 1)
wq_executed = 1 (expected: 1)
✓ Test PASSED!
```
测试验证同步 fentry 探针和异步工作队列回调都成功执行。如果工作队列回调没有运行,`wq_executed` 将为 0测试将失败。
## 历史时间线和背景
理解工作队列如何产生有助于欣赏它们的设计。2022 年Benjamin Tissoires 开始研究 HID-BPF旨在让用户在没有内核驱动程序的情况下修复损坏的 HID 设备。到 2023 年,他意识到 `bpf_timer` 的限制使 HID 设备 I/O 变得不可能 - 你不能在 softirq 上下文中等待硬件响应。2024 年初,他提出 `bpf_wq` 作为"进程上下文中的 bpf_timer",与 BPF 社区合作设计。内核在 2024 年 4 月将工作队列作为 Linux v6.10 的一部分合并。从那时起,它们已被用于 HID 怪癖、速率限制、异步清理和其他可睡眠操作。
Benjamin 的补丁中的关键引用完美地捕捉了动机:"我需要类似于 bpf_timers 的东西,但不在软 IRQ 上下文中……bpf_timer 功能会阻止我 kzalloc 并等待设备。"
这种现实世界的需求推动了设计。工作队列的存在是因为设备处理和资源管理需要定时器根本无法提供的可睡眠、阻塞操作。
## 总结和下一步
BPF 工作队列通过在进程上下文中启用可睡眠、阻塞操作解决了 eBPF 的根本限制。专门为支持 HID 设备处理而创建,其中时序延迟和设备 I/O 至关重要,工作队列为 eBPF 程序解锁了强大的新功能。它们启用了"快速路径 + 慢速路径"模式,其中性能关键操作立即执行,而昂贵的清理和 I/O 异步发生而不阻塞。
我们的简单示例演示了核心工作队列生命周期:在 map 值中嵌入 `bpf_wq`、初始化和配置它、调度异步执行,以及验证回调在进程上下文中运行。相同的模式可以扩展到生产用例,如带异步清理的网络速率限制、带外部服务查询的安全监控,以及带 I/O 操作的设备处理。
> 如果你想深入了解 eBPF请查看我们的教程仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- **原始内核补丁:** Benjamin Tissoires 的 HID-BPF 和 bpf_wq 补丁2023-2024
- **Linux 内核源码:** `kernel/bpf/helpers.c` - 工作队列实现
- **教程仓库:** <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/features/bpf_wq>
示例改编自 Linux 内核 BPF 自测,并增加了教育性增强。需要 Linux 内核 6.10+ 以获得工作队列支持。完整源代码可在教程仓库中获得。

View File

@@ -1,4 +1,4 @@
# eBPF Tutorial by Example: Monitoring GPU Activity with Kernel Tracepoints
# eBPF Tutorial: Monitoring GPU Driver Activity with Kernel Tracepoints
Ever wondered what your GPU is really doing under the hood? When games stutter, ML training slows down, or video encoding freezes, the answers lie deep inside the kernel's GPU driver. Traditional debugging relies on guesswork and vendor-specific tools, but there's a better way. Linux kernel GPU tracepoints expose real-time insights into job scheduling, memory allocation, and command submission - and eBPF lets you analyze this data with minimal overhead.

View File

@@ -0,0 +1,292 @@
# eBPF 教程:使用内核跟踪点监控 GPU 驱动活动
你是否曾经想知道你的 GPU 在底层到底在做什么?当游戏卡顿、机器学习训练变慢或视频编码冻结时,答案就隐藏在内核 GPU 驱动的深处。传统调试依赖于猜测和供应商特定的工具但有更好的方法。Linux 内核 GPU 跟踪点暴露了作业调度、内存分配和命令提交的实时洞察 - 而 eBPF 让你可以以最小的开销分析这些数据。
在本教程中,我们将探索跨 DRM 调度器、Intel i915 和 AMD AMDGPU 驱动的 GPU 内核跟踪点。我们将编写 bpftrace 脚本来监控实时 GPU 活动、跟踪内存压力、测量作业延迟并诊断性能瓶颈。最后,你将拥有生产就绪的监控工具以及对 GPU 如何与内核交互的深入了解。
## 理解 GPU 内核跟踪点
GPU 跟踪点是直接内置在内核的直接渲染管理器DRM子系统中的仪器点。当你的 GPU 调度作业、分配内存或发出栅栏信号时,这些跟踪点会触发 - 捕获精确的时序、资源标识符和驱动状态。与周期性采样并错过事件的用户空间分析工具不同,内核跟踪点以纳秒级时间戳捕获每一个操作。
### 为什么内核跟踪点对 GPU 监控很重要
想想当你启动 GPU 工作负载时会发生什么。你的应用通过图形 APIVulkan、OpenGL、CUDA提交命令。用户空间驱动将这些转换为硬件特定的命令缓冲区。内核驱动接收 ioctl验证工作分配 GPU 内存,将资源绑定到 GPU 地址空间,在硬件环上调度作业,并等待完成。传统分析看到开始和结束 - 内核跟踪点看到每一步。
性能影响是显著的。基于轮询的监控每 100ms 检查一次 GPU 状态,每次检查都会消耗 CPU 周期。跟踪点仅在事件发生时激活,每个事件仅添加纳秒级的开销,并捕获 100% 的活动,包括微秒级持续时间的作业。对于 Kubernetes GPU 工作负载的生产监控或调试 ML 训练性能,这种差异至关重要。
### DRM 跟踪点生态系统
GPU 跟踪点跨越图形堆栈的三层。**DRM 调度器跟踪点**gpu_scheduler 事件组)被标记为稳定的 uAPI - 它们的格式永远不会改变。这些在 Intel、AMD 和 Nouveau 驱动上工作完全相同,使它们成为供应商中立监控的完美选择。它们跟踪作业提交(`drm_run_job`)、完成(`drm_sched_process_job`)和依赖等待(`drm_sched_job_wait_dep`)。
**供应商特定跟踪点**暴露驱动内部。Intel i915 跟踪点跟踪 GEM 对象创建(`i915_gem_object_create`、VMA 绑定到 GPU 地址空间(`i915_vma_bind`)、内存压力事件(`i915_gem_shrink`)和页面故障(`i915_gem_object_fault`。AMD AMDGPU 跟踪点监控缓冲对象生命周期(`amdgpu_bo_create`)、从用户空间提交命令(`amdgpu_cs_ioctl`)、调度器执行(`amdgpu_sched_run_job`)和 GPU 中断(`amdgpu_iv`)。注意 Intel 低级跟踪点需要在内核配置中启用 `CONFIG_DRM_I915_LOW_LEVEL_TRACEPOINTS=y`
**通用 DRM 跟踪点**通过 vblank 事件处理显示同步 - 对于诊断丢帧和合成器延迟至关重要。事件包括 vblank 发生(`drm_vblank_event`)、用户空间排队(`drm_vblank_event_queued`)和传递(`drm_vblank_event_delivered`)。
### 实际应用场景
GPU 跟踪点解决了传统工具无法触及的问题。**诊断游戏卡顿**你注意到每隔几秒就会丢帧。Vblank 跟踪点揭示了错过的垂直消隐。作业调度跟踪显示命令提交中的 CPU 端延迟。内存跟踪点暴露在关键帧期间触发驱逐的分配。几分钟内你就能识别出纹理上传正在阻塞渲染管道。
**优化 ML 训练性能**:你的 PyTorch 训练比预期慢 40%。AMDGPU 命令提交跟踪揭示了过度同步 - CPU 过于频繁地等待 GPU 完成。作业依赖跟踪点显示独立操作之间不必要的栅栏。内存跟踪暴露了 VRAM 和系统 RAM 之间的抖动。你重新组织批处理以消除停顿。
**云 GPU 计费准确性**多租户系统需要公平的能源和资源核算。DRM 调度器跟踪点将确切的 GPU 时间归因于每个容器。内存跟踪点跟踪每个工作负载的分配。这些数据馈送到基于实际资源消耗而非基于时间估计收费的准确计费系统。
**热节流调查**GPU 性能在负载下降级。中断跟踪显示来自 GPU 的热事件。作业调度跟踪揭示影响执行时间的频率缩放。内存迁移跟踪显示驱动将工作负载移动到更冷的 GPU 芯片。你调整功率限制并改善气流。
## 跟踪点参考指南
让我们详细检查每个跟踪点类别,了解它们暴露的数据以及如何解释它。
### DRM 调度器跟踪点:通用 GPU 监视器
DRM 调度器提供 GPU 作业管理的供应商中立视图。无论你运行的是 Intel 集成显卡、AMD 独立 GPU 还是 NVIDIA 硬件上的 Nouveau这些跟踪点的工作方式都完全相同。
#### drm_run_jobGPU 工作开始执行时
当调度器将作业分配给 GPU 硬件时,`drm_run_job` 触发。这标志着从"在软件中排队"到"在硅上主动运行"的转换。跟踪点捕获作业 ID关联的唯一标识符、环名称哪个执行引擎图形、计算、视频解码、队列深度有多少作业在等待和硬件作业计数当前在 GPU 上执行的作业)。
格式看起来像:`entity=0xffff888... id=12345 fence=0xffff888... ring=gfx job count:5 hw job count:2`。这告诉你图形环上的作业 12345 开始执行。五个作业在它后面排队,两个作业当前在硬件上运行(多引擎 GPU 可以并行运行作业)。
使用此来测量作业调度延迟。记录用户空间提交工作时的时间戳(使用命令提交跟踪点),然后测量到 `drm_run_job` 触发的时间。超过 1ms 的延迟表示 CPU 端调度延迟。每个环的统计数据揭示特定引擎(视频编码、计算)是否存在瓶颈。
#### drm_sched_process_job作业完成信号
当 GPU 硬件完成作业并发出其栅栏信号时,此跟踪点触发。栅栏指针标识已完成的作业 - 将其与 `drm_run_job` 关联以计算 GPU 执行时间。格式:`fence=0xffff888... signaled`
`drm_run_job` 时间戳结合以计算作业执行时间:`completion_time - run_time = GPU_execution_duration`。如果应该需要 5ms 的作业需要 50ms你就发现了 GPU 性能问题。吞吐量指标(每秒完成的作业)表示总体 GPU 利用率。
#### drm_sched_job_wait_dep依赖停顿
在作业可以执行之前,其依赖项(它等待的先前作业)必须完成。此跟踪点在作业阻塞等待栅栏时触发。格式:`job ring=gfx id=12345 depends fence=0xffff888... context=1234 seq=567`
这揭示了管道停顿。如果计算作业不断等待图形作业,你就没有利用并行性。如果等待时间很长,依赖链太深 - 考虑批处理独立工作。过度的依赖表示 CPU 端调度效率低下。
### Intel i915 跟踪点:内存和 I/O 深入分析
Intel 的 i915 驱动暴露了内存管理和数据传输的详细跟踪点。这些需要 `CONFIG_DRM_I915_LOW_LEVEL_TRACEPOINTS=y` - 使用 `grep CONFIG_DRM_I915_LOW_LEVEL_TRACEPOINTS /boot/config-$(uname -r)` 检查。
#### i915_gem_object_createGPU 内存分配
当驱动分配 GEM图形执行管理器对象 - GPU 可访问内存的基本单位时,此触发。格式:`obj=0xffff888... size=0x100000` 表示分配 1MB 对象。
随时间跟踪总分配内存以检测泄漏。性能下降前的突然分配峰值表示内存压力。将对象指针与后续绑定/故障事件关联以了解对象生命周期。高频率小分配表示低效批处理。
#### i915_vma_bind将内存映射到 GPU 地址空间
分配内存还不够 - 它必须映射(绑定)到 GPU 地址空间。此跟踪点在 VMA虚拟内存区域绑定时触发。格式`obj=0xffff888... offset=0x0000100000 size=0x10000 mappable vm=0xffff888...` 显示在 GPU 虚拟地址 0x100000 处绑定的 64KB。
绑定开销影响性能。频繁的重新绑定表示内存抖动 - 驱动在压力下驱逐和重新绑定对象。GPU 页面故障通常与绑定操作相关 - CPU 在 GPU 访问之前绑定内存。像 `PIN_MAPPABLE` 这样的标志表示 CPU 和 GPU 都可以访问的内存。
#### i915_gem_shrink内存压力响应
在内存压力下,驱动回收 GPU 内存。格式:`dev=0 target=0x1000000 flags=0x3` 意味着驱动尝试回收 16MB。高收缩活动表示工作负载的 GPU 内存过小。
与性能下降关联 - 如果在帧渲染期间发生收缩,会导致卡顿。标志表示收缩的激进程度。反复收缩小目标表示内存碎片。将目标与实际释放量(跟踪对象销毁)进行比较以测量回收效率。
#### i915_gem_object_faultGPU 页面故障
当 CPU 或 GPU 访问未映射的内存时,会发生故障。格式:`obj=0xffff888... GTT index=128 writable` 表示图形转换表页 128 上的写故障。故障代价昂贵 - 它们在内核解决缺失映射时停止执行。
过度的故障会降低性能。写故障比读故障更昂贵需要使缓存失效。GTT 故障GPU 访问未映射的内存表示作业提交前资源绑定不完整。CPU 故障表示低效的 CPU/GPU 同步 - CPU 在 GPU 使用对象时访问它们。
### AMD AMDGPU 跟踪点:命令流和中断
AMD 的 AMDGPU 驱动提供命令提交和硬件中断的全面跟踪。
#### amdgpu_cs_ioctl用户空间命令提交
当应用通过 ioctl 提交 GPU 工作时,此捕获请求。格式:`sched_job=12345 timeline=gfx context=1000 seqno=567 ring_name=gfx_0.0.0 num_ibs=2` 显示提交到图形环的作业 12345 有 2 个间接缓冲区。
这标志着用户空间将工作交给内核的时间。记录时间戳以在与 `amdgpu_sched_run_job` 结合时测量提交到执行的延迟。高频率表示小批次 - 更好批处理的潜力。每个环的分布显示跨引擎的工作负载平衡。
#### amdgpu_sched_run_job内核调度作业
内核调度器开始执行先前提交的作业。将时间戳与 `amdgpu_cs_ioctl` 比较可揭示提交延迟。格式包括作业 ID 和用于关联的环。
超过 100μs 的提交延迟表示内核调度延迟。每个环的延迟显示特定引擎是否受调度限制。与 CPU 调度器跟踪关联以识别内核线程是否被抢占。
#### amdgpu_bo_create缓冲对象分配
AMD 的 i915 GEM 对象等价物。格式:`bo=0xffff888... pages=256 type=2 preferred=4 allowed=7 visible=1` 分配 1MB256 页)。类型表示 VRAM 与 GTTGPU 可访问的系统内存)。首选/允许域显示放置策略。
跟踪 VRAM 分配以监控内存使用。类型不匹配(请求 VRAM 但回退到 GTT表示 VRAM 耗尽。可见标志表示 CPU 可访问的内存 - 昂贵,谨慎使用。
#### amdgpu_bo_move内存迁移
当缓冲对象在 VRAM 和 GTT 之间迁移时,此触发。迁移代价昂贵(需要通过 PCIe 复制数据)。过度的移动表示内存抖动 - 工作集超过 VRAM 容量。
测量移动频率和大小以量化 PCIe 带宽消耗。与性能下降关联 - 迁移停止 GPU 执行。通过减少工作集或使用更智能的放置策略(将频繁访问的数据保留在 VRAM 中)进行优化。
#### amdgpu_ivGPU 中断
GPU 为完成的工作、错误和事件发出中断信号。格式:`ih:0 client_id:1 src_id:42 ring:0 vmid:5 timestamp:1234567890 pasid:100 src_data: 00000001...` 捕获中断详细信息。
源 ID 表示中断类型(完成、故障、热)。高中断率影响 CPU 性能。意外中断表示硬件错误。VMID 和 PASID 识别哪个进程/VM 触发了中断 - 对于多租户调试至关重要。
### DRM Vblank 跟踪点:显示同步
Vblank垂直消隐事件将渲染与显示刷新同步。错过 vblank 会导致丢帧和卡顿。
#### drm_vblank_event垂直消隐发生
当显示进入垂直消隐期时,此触发。格式:`crtc=0 seq=12345 time=1234567890 high-prec=true` 表示显示控制器 0 上的 vblank序列号 12345。
跟踪 vblank 频率以验证刷新率60Hz = 60 vblanks/秒)。错过的序列表示丢帧。高精度时间戳启用亚毫秒帧时序分析。多显示器设置的每 CRTC 跟踪。
#### drm_vblank_event_queued 和 drm_vblank_event_delivered
这些跟踪 vblank 事件传递到用户空间。排队延迟队列到传递测量内核调度延迟。总延迟vblank 到传递)包括内核和驱动处理。
超过 1ms 的延迟表示合成器问题。跨 CRTC 比较以识别有问题的显示。与用户可见的丢帧关联 - 延迟传递的事件意味着错过的帧。
## 使用 Bpftrace 脚本监控
我们为生产监控创建了供应商特定的 bpftrace 脚本。每个脚本专注于其 GPU 供应商的特定跟踪点,同时共享通用输出格式。
### DRM 调度器监视器:通用 GPU 跟踪
`drm_scheduler.bt` 脚本在**所有 GPU 驱动**上工作,因为它使用稳定的 uAPI 跟踪点。它跟踪所有环上的作业,测量完成率,并识别依赖停顿。
脚本附加到 `gpu_scheduler:drm_run_job``gpu_scheduler:drm_sched_process_job``gpu_scheduler:drm_sched_job_wait_dep`。在作业开始时,它在按作业 ID 键控的 map 中记录时间戳以供以后计算延迟。它递增每个环的计数器以显示工作负载分布。在完成时,它打印栅栏信息。在依赖等待时,它显示哪个作业阻塞哪个栅栏。
输出显示时间戳、事件类型RUN/COMPLETE/WAIT_DEP、作业 ID、环名称和队列深度。程序结束时统计数据总结每个环的作业和依赖等待计数。这揭示了特定环是否饱和、作业是否被依赖阻塞以及总体 GPU 利用率模式。
### Intel i915 监视器:内存和 I/O 分析
`intel_i915.bt` 脚本跟踪 Intel GPU 内存操作、I/O 传输和页面故障。它需要 `CONFIG_DRM_I915_LOW_LEVEL_TRACEPOINTS=y`
`i915_gem_object_create`它累积总分配内存并存储每个对象的大小。VMA 绑定/解绑事件跟踪 GPU 地址空间更改。收缩事件测量内存压力。Pwrite/pread 跟踪 CPU-GPU 数据传输。故障按类型分类GTT 与 CPU读与写
输出报告分配大小和以 MB 为单位的运行总计。绑定操作显示 GPU 虚拟地址和标志。I/O 操作跟踪偏移量和长度。故障指示类型以及它们是读还是写。结束统计汇总总分配、VMA 操作、内存压力收缩操作和回收字节、I/O 量(读/写计数和大小)以及故障分析(总故障,写与读)。
这揭示了内存泄漏没有相应释放的分配、绑定开销频繁的重新绑定表示抖动、内存压力时序将收缩与性能下降关联、I/O 模式(大传输与许多小传输)和故障热点(要优化的昂贵操作)。
### AMD AMDGPU 监视器:命令提交分析
`amd_amdgpu.bt` 脚本专注于 AMD 的命令提交管道,测量从 ioctl 到执行的延迟。
`amdgpu_cs_ioctl` 上,它记录按作业 ID 键控的提交时间戳。当 `amdgpu_sched_run_job` 触发时,它计算延迟:`(current_time - submit_time)`。缓冲对象创建/移动事件跟踪内存。中断事件按源 ID 计数。虚拟内存操作(刷新、映射、取消映射)测量 TLB 活动。
输出显示时间戳、事件类型、作业 ID、环名称和以微秒为单位的计算延迟。结束统计包括内存分配总计、每个环的命令提交计数、提交延迟的平均值和分布直方图显示有多少作业经历了不同的延迟桶、按源的中断计数以及虚拟内存操作计数。
延迟直方图至关重要 - 大多数作业应该有 <50μs 的延迟高延迟作业的尾部表示调度问题每个环的统计显示计算工作负载是否具有与图形不同的延迟内存迁移跟踪有助于诊断 VRAM 压力
### 显示 Vblank 监视器:帧时序分析
`drm_vblank.bt` 脚本跟踪显示同步以诊断丢帧
`drm_vblank_event` 它记录按 CRTC 和序列键控的时间戳 `drm_vblank_event_queued` 触发时它时间戳队列时间 `drm_vblank_event_delivered` 它计算队列到传递延迟和总 vblank 到传递延迟
输出显示 vblank 事件排队事件和带时间戳的传递事件结束统计包括每个 CRTC 的总 vblank 计数事件传递计数平均传递延迟延迟分布直方图以及总事件延迟vblank 发生到用户空间传递)。
超过 1ms 的传递延迟表示合成器调度问题总延迟揭示应用可见的端到端延迟 CRTC 统计显示特定显示器是否有问题延迟直方图暴露导致可见卡顿的异常值
## 运行监视器
让我们跟踪实时 GPU 活动导航到脚本目录并使用 bpftrace 运行任何监视器DRM 调度器监视器在所有 GPU 上工作
```bash
cd bpf-developer-tutorial/src/xpu/gpu-kernel-driver/scripts
sudo bpftrace drm_scheduler.bt
```
你将看到如下输出
```
Tracing DRM GPU scheduler... Hit Ctrl-C to end.
TIME(ms) EVENT JOB_ID RING QUEUED DETAILS
296119090 RUN 12345 gfx 5 hw=2
296120190 COMPLETE 0xffff888...
=== DRM Scheduler Statistics ===
Jobs per ring:
@jobs_per_ring[gfx]: 1523
@jobs_per_ring[compute]: 89
Waits per ring:
@waits_per_ring[gfx]: 12
```
这显示图形作业主导工作负载1523 89 个计算作业)。很少的依赖等待12表示良好的管道并行性
对于 Intel GPU运行 i915 监视器
```bash
sudo bpftrace intel_i915.bt
```
对于 AMD GPU
```bash
sudo bpftrace amd_amdgpu.bt
```
对于显示时序
```bash
sudo bpftrace drm_vblank.bt
```
每个脚本都输出实时事件和运行结束统计 GPU 工作负载游戏ML 训练视频编码期间运行它们以捕获特征模式
## 验证跟踪点可用性
在运行脚本之前验证你的系统上存在跟踪点我们包含了一个测试脚本
```bash
cd bpf-developer-tutorial/src/xpu/gpu-kernel-driver/tests
sudo ./test_basic_tracing.sh
```
这检查 gpu_schedulerdrmi915 amdgpu 事件组它报告哪些跟踪点可用并为你的硬件推荐适当的监控脚本对于 Intel 系统它验证内核配置中是否启用了低级跟踪点
你还可以手动检查可用的跟踪点
```bash
# 所有 GPU 跟踪点
sudo cat /sys/kernel/debug/tracing/available_events | grep -E '(gpu_scheduler|i915|amdgpu|^drm:)'
# DRM 调度器(稳定,所有供应商)
sudo cat /sys/kernel/debug/tracing/available_events | grep gpu_scheduler
# Intel i915
sudo cat /sys/kernel/debug/tracing/available_events | grep i915
# AMD AMDGPU
sudo cat /sys/kernel/debug/tracing/available_events | grep amdgpu
```
要手动启用跟踪点并查看原始输出
```bash
# 启用 drm_run_job
echo 1 | sudo tee /sys/kernel/debug/tracing/events/gpu_scheduler/drm_run_job/enable
# 查看跟踪输出
sudo cat /sys/kernel/debug/tracing/trace
# 完成后禁用
echo 0 | sudo tee /sys/kernel/debug/tracing/events/gpu_scheduler/drm_run_job/enable
```
## 总结和下一步
GPU 内核跟踪点提供对图形驱动行为的前所未有的可见性DRM 调度器的稳定 uAPI 跟踪点在所有供应商上工作使它们成为生产监控的完美选择来自 Intel i915 AMD AMDGPU 的供应商特定跟踪点暴露详细的内存管理命令提交管道和硬件中断模式
我们的 bpftrace 脚本演示了实际监控测量作业调度延迟跟踪内存压力分析命令提交瓶颈以及诊断丢帧这些技术直接应用于实际问题 - 优化 ML 训练性能调试游戏卡顿在云环境中实现公平的 GPU 资源核算以及调查热节流
与传统工具相比关键优势是完整性和开销内核跟踪点以纳秒级精度捕获每个事件成本可忽略不计没有轮询没有采样间隙没有错过的短期作业这些数据馈送生产监控系统Prometheus 导出器读取 bpftrace 输出)、临时性能调试用户报告问题时运行脚本和自动化优化基于延迟阈值触发工作负载重新平衡)。
> 如果你想深入了解 eBPF请查看我们的教程仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
## 参考资料
- **Linux 内核源码**: `/drivers/gpu/drm/`
- **DRM 调度器**: `/drivers/gpu/drm/scheduler/gpu_scheduler_trace.h`
- **Intel i915**: `/drivers/gpu/drm/i915/i915_trace.h`
- **AMD AMDGPU**: `/drivers/gpu/drm/amd/amdgpu/amdgpu_trace.h`
- **通用 DRM**: `/drivers/gpu/drm/drm_trace.h`
- **内核跟踪点文档**: `Documentation/trace/tracepoints.rst`
- **教程仓库**: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/xpu/gpu-kernel-driver>
完整的源代码包括所有 bpftrace 脚本和测试用例可在教程仓库中获得。欢迎贡献和问题报告!