-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathch9-agile.typ
More file actions
113 lines (70 loc) · 11.3 KB
/
Copy pathch9-agile.typ
File metadata and controls
113 lines (70 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// 第九章:敏捷开发与性能观测 —— 由 main.typ 在 `= 敏捷开发与性能观测` 之后 #include。
前面的章节描述了 CosmOS 的主要系统结构:任务调度定义执行流如何获得 CPU,进程管理定义资源如何继承与回收,内存、文件、信号、网络与 HAL 则分别支撑用户态语义和底层平台差异。本章讨论另一条同样重要的主线:这些机制如何在持续迭代中被验证、修正和优化。
操作系统开发的困难不只在于写出一个看起来正确的设计,还在于面对复杂负载时能够快速回答三个问题:慢在哪里,为什么慢,改动之后是否真的更好。若没有观测工具,性能优化很容易变成凭直觉改代码;若观测工具本身太重,又会把被测路径彻底扰乱。CosmOS 因此把性能观测纳入开发流程,用轻量、可开关、可复位的内核侧工具支撑小步迭代。
本章重点介绍两类互补工具:`/proc/io_perf` 是长期事件计数器,回答“某类事件发生了多少次”;`perf_probe` 是短期命名耗时探针,回答“某段代码在这一轮实验里花了多少时间”。前者像仪表盘,适合长期保留并解释子系统行为;后者像显微镜,适合围绕一个假设临时下钻,定位完成后清理。
== 从大步重构到小步验证
CosmOS 的性能开发采用一种偏敏捷的闭环:每次只围绕一个可复现现象提出假设,插入最少必要的观测点,运行同一个负载,读出数据,再决定保留、拒绝、细化或回退。这个过程强调“短周期”和“可撤销”:探针可以临时添加,实验可以被拒绝,优化必须回到无探针构建下确认。
#figure(image("assets/perf_probe_agile_loop.svg", width: 96%), caption: [基于 perf_probe 的敏捷性能开发闭环])
这张图中的闭环有两层含义。第一,观测不是最终目的,而是下一次决策的输入。一次读数可能证明某个假设成立,也可能证明真正热点在别处;两者都能推进下一轮。第二,诊断构建和正式构建必须分离。带探针内核会读时钟、维护原子计数,并可能改变局部代码布局;它适合定位热点,但不适合进入最终性能对比表。最终是否保留某个优化,仍要用 `PERF_PROBE=OFF` 的普通构建跑正式 benchmark 或回归测试。
这种流程和传统“大步重构后再看结果”的差别很明显。大步改动常常把多个因素混在一起:缓存策略、锁粒度、系统调用入口和页表翻译同时变化,结果变好或变坏都难以归因。小步验证则要求每轮只回答一个问题,例如“4 KB 随机读慢在用户缓冲翻译还是 page cache 查找”、“cyclictest 长尾来自 timer heap 扫描还是 trap 周期处理”、“fd 表锁是否仍是小 I/O 的固定成本”。问题越具体,观测点就越少,结论也越可靠。
== 观测工具分层
CosmOS 目前使用两种内核侧观测方式。它们都通过 procfs 暴露给用户态,但职责不同。
#figure(
table(
columns: (1.1fr, 1.7fr, 1.7fr),
[维度], [`/proc/io_perf` 长期计数器], [`perf_probe` 命名耗时探针],
[回答的问题], [某类语义事件发生了多少次], [某段代码一共花了多少时间],
[典型指标], [命中、未命中、淘汰、I/O 次数], [`calls / total_ns / avg_ns / max_ns`],
[生命周期], [可长期保留,作为子系统自省接口], [短期插桩,定位完成后清理],
[开销模型], [热路径一次原子自增], [启用后读时钟并在 guard drop 时累计],
[使用方式], [解释系统行为,交叉验证 benchmark], [围绕当前假设继续向内拆热点],
),
caption: [两类性能观测工具的分工],
)
两者经常配合使用。以文件系统优化为例,`/proc/io_perf` 可以先告诉我们 page cache 是否命中、block cache 是否被击穿、dentry cache 是否正在发挥作用;如果最终吞吐仍不理想,再用 `perf_probe` 拆开命中路径,判断耗时究竟落在 fd 查找、用户缓冲翻译、BTreeMap 查找还是 CachePage 拷贝上。换言之,`io_perf` 给方向,`perf_probe` 给切口。
== io_perf:稳定的事件仪表盘
`/proc/io_perf` 最初服务于第五章的文件系统实验。那些实验需要把性能收益逐层归因:page cache、block cache、dentry cache、stat cache 各自命中了多少次,未命中了多少次,是否发生了淘汰。单看 iozone 的 KB/s 只能知道“变快了”或“变慢了”,却无法说明原因;吞吐上升可能来自 page cache 命中,也可能来自系统调用入口变短,还可能只是运行波动。
在内核热路径里直接打印日志并不可行。串口输出是阻塞式的,在 115200 波特率下每个字节约 87 µs,一条几十字符的日志就可能消耗数毫秒;而一次 block cache 命中或 page cache 装页本身通常只有亚微秒到微秒量级。若在这种路径里 `println!`,观测行为本身会比被测操作贵几个数量级,得到的不是系统真实性能,而是串口性能。
`io_perf` 的设计原则是把“计数”和“渲染”彻底分开。热路径只做一次静态原子自增:
```text
AtomicUsize::fetch_add(_, Ordering::Relaxed)
```
这条路径无锁、无格式化、无 I/O。真正的文本生成只发生在用户读取 `/proc/io_perf` 时:procfs 节点把 VFS、block cache、磁盘后端、块设备驱动、page cache 等子系统的 `render_perf_counters()` 拼成一段可读文本。写入该节点则执行 reset,便于两次 benchmark 之间复位。
`/proc/io_perf` 聚合的是文件栈中的语义事件:VFS 层的 dentry/stat 命中与未命中,block cache 的命中、未命中、淘汰和平均查找步数,ext4 与块设备的访问统计,page cache 的页装入、写映射和回写等。这些计数能直接支撑第五章的解释:重复目录遍历变快时,dentry/inode 和 block cache 命中应当上升;关闭 page cache 后,page cache 命中应当消失;热态 `tree` 的数量级提升,必须能在 block cache 命中上找到对应证据。
整套机制由 `io_perf_counters` feature 控制。关闭时,`#[cfg]` 会抹去静态计数器和每一个 `perf_inc` 调用点,做到真正的零开销;开启时,它以很低扰动提供比日志更稳定、也更适合自动化收集的观测能力。这也是它能长期存在于文件系统设计中的原因:它不是临时实验痕迹,而是子系统行为的可解释接口。
== perf_probe:临时的命名耗时探针
`perf_probe` 面向另一类问题:我们已经知道某个方向可疑,但还不知道具体哪一段代码最贵。它提供一个块式宏:
```rust
crate::probe!({
add_timer_inner();
}, "timer.add");
```
这段宏表达的是“测量这个代码块,并把它命名为 `timer.add`”。它的边界由开发者选择,可以包住 timer 插入、运行队列选择、用户缓冲翻译、page cache 查找、网络 poll gate 或任意一段短路径。宏保持原代码块语义,块的最后一个表达式仍然作为返回值;即使中间出现 `?`、`return` 或 panic 展开,RAII guard 的 `Drop` 也会记录已经经过的时间。
关闭 `perf_probe` feature 时,宏只保留原始代码块,不读时钟、不注册名字、不维护统计。只有通过 `PERF_PROBE=1` 构建时,`os/Makefile` 才会启用 `perf_probe` feature。顶层 Makefile 还把 `PERF_PROBE` 写入 kernel config stamp,使探针构建和普通构建之间切换会触发内核重编,避免误用带探针的 kernel 作为正式性能结果。
启用后,探针也尽量减少注册成本。每个调用点有一个静态 `AtomicUsize` 作为槽位缓存;第一次启用并命中时,内核按字符串名字注册到全局槽表,之后同一调用点直接使用缓存槽位。当前最多注册 64 个名字,这个上限是刻意的:它提醒开发者只围绕当前假设插入少量高信号探针,而不是把整个内核变成永久 profiler。
用户态通过两个 procfs 节点控制探针:
```bash
echo reset > /proc/perf_probe
echo 1 > /proc/perf_probe_enable
# run workload
cat /proc/perf_probe
echo 0 > /proc/perf_probe_enable
```
`/proc/perf_probe_enable` 是运行期开关,写入 `1` 开始计时,写入 `0` 停止计时。`/proc/perf_probe` 是统计表,读取时渲染所有已注册名字;写入任意内容、truncate 或写入 `reset` 都会清空统计值。重置只清空 calls、total 和 max,不删除名字,因此多轮实验读出的表格顺序稳定。
输出格式保持朴素:
```text
enabled 1
name calls total_ns avg_ns max_ns
timer.interrupt 108 7108000 65814 407200
timer.check_expired 108 968800 8970 339200
```
读表时不能只看单列。`calls` 说明路径热不热,`total_ns` 说明它对整轮负载的总贡献,`avg_ns` 说明单次成本,`max_ns` 暴露长尾。一个平均耗时高但调用次数很少的路径,不一定值得优先优化;一个平均耗时只有几百纳秒但调用数几十万的路径,可能正是总耗时来源;一个 `max_ns` 远高于平均值的路径,则可能提示锁竞争、缺页、I/O 或调度抖动。
== 使用纪律
性能观测工具必须有纪律,否则很容易从“帮助定位”变成“制造噪声”。CosmOS 对 `perf_probe` 的使用主要遵循四条规则。
第一,探针放在明确边界上。适合的位置包括锁内扫描、timer 插入/删除、唤醒决策、调度选择、page cache 查询、用户缓冲翻译、网络 poll gate 等。不适合的位置是跨越主动阻塞或上下文切换的大范围路径,因为那会把等待时间和执行时间混在一起,`max_ns` 也失去解释力。
第二,命名要稳定而具体。推荐使用 `模块.动作`,例如 `timer.add`、`sched.pick_next`、`page.opened_hit.lookup`。需要追溯某轮实验时,可以临时加上实验编号;但最终提交优化时,应删除临时编号和临时探针,避免把一次实验的历史痕迹固化进长期代码。
第三,探针结果只用于定位,不用于判分。带探针内核会改变微观时序,尤其是纳秒级热点、锁竞争路径和中断尾部路径。优化是否保留,必须回到无探针构建下跑正式 benchmark、功能测试和多轮确认。
第四,探针应当小步添加、小步删除。一次插入几十个探针通常会让数据难以解释,也会提高扰动。更有效的做法是先用粗粒度探针定位大块热点,再逐轮向内细分:例如从 `sys_read` 到 fd 查找和用户缓冲,再到 page cache lookup 和拷贝。每轮只回答一个问题。
== 小结
本章讨论的不是某一个子系统,而是 CosmOS 的开发方法。`io_perf` 提供长期、语义化、低扰动的事件计数,让我们能解释文件系统等子系统的行为;`perf_probe` 提供短期、命名、可开关的耗时探针,让我们能围绕一个假设快速向内定位热点。两者共同把性能开发从“凭感觉改代码”推进到“用数据驱动小步迭代”。
这套方法的核心并不复杂:明确问题,少量插桩,复现负载,读数决策,清理临时观测,再用正式构建确认结果。对于一个仍在快速演进的操作系统来说,这种闭环比单次优化本身更重要,因为它让每一次改动都能被解释、被比较,也能在无效时被果断撤回。