The Art of Debugging 读书笔记(逐日展开版)
原书地址:https://github.com/stas00/the-art-of-debugging
作者:Stas Bekman(1995 年起从事软件开发)
许可:CC BY-SA 4.0这是一本关于调试方法论和工具集的开放书籍。本文按”先学方法论 → 再练工具 → 最后实战”的路径逐日展开,每天配一个可复现的调试案例。
写在前面
调试是工程能力的核心
编程时间分配:
- 写代码:20%
- 读代码:30%
- 调试:40%
- 开会:10%
如果你每天工作 8 小时,超过 3 小时在调试。把调试效率提高一倍,等于每天多出 1.5 小时。
你的环境
这本书的所有工具和方法都在你的 RTX 5060 + AMD 9600X 上完全可复现。不需要大模型,不需要多卡。
第一部分:调试方法论(Day 1-5)
Day 1:调试的两个核心需求
- 阅读本书 “Fast Debugging Methodology” 章节
- 理解两个核心需求:
- 快速迭代:从重启到关键断点的等待时间应控制在几秒
- 小数据:用最小数据复现问题
- 本地实验:故意写一个有 bug 的程序,分别用大模型和小模型来调试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17# 有 bug 的函数:应该返回 a + b,但写成了 a * b
def add(a, b):
return a * b # 故意写错
# 大数据调试:痛苦
import torch
big_a = torch.randn(10000, 10000)
big_b = torch.randn(10000, 10000)
result = add(big_a, big_b) # 等 30 秒,结果还是错的,不知道哪里错了
# 小数据调试:清晰
small_a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
small_b = torch.tensor([[1.0, 1.0], [1.0, 1.0]])
result = add(small_a, small_b)
print(result)
# 预期:[[2, 3], [4, 5]]
# 实际:[[1, 2], [3, 4]] # 明显是乘法不是加法! - 对比两种调试方式的效率差异
做完之后能了解:
- 为什么 “用大数据调试” 是痛苦且低效的
- “小数据 + 快速迭代” 是调试的万能钥匙
Day 2:数据选择策略
- 阅读 “Real data vs. random data vs. synthetic data” 章节
- 理解三种数据的选择策略:
- Random data:程序崩溃时用(不需要真实数据)
- Synthetic data:需要跟踪数值时用(如
[[1.0, 2.0], [3.0, 4.0]]) - Real data:程序能跑但结果不对时用(需要真实数据验证质量)
- 本地实验:用 synthetic data 调试一个数值计算 bug
1
2
3
4
5
6
7
8
9
10
11
12
13# 假设你在实现一个矩阵乘法
def my_matmul(a, b):
# 故意有一个 bug:忘记转置
return a @ b.T # 应该是 a @ b
# 用 synthetic data:容易发现错误
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
b = torch.tensor([[1.0, 0.0], [0.0, 1.0]]) # 单位矩阵
result = my_matmul(a, b)
print(result)
# 预期:a @ I = a = [[1, 2], [3, 4]]
# 实际:因为 .T 的存在,结果是 [[1, 3], [2, 4]]
# 一眼看出错误! - 记录:你过去一周调试时,用的是哪种数据?有没有可以改进的地方?
做完之后能了解:
- 不同调试阶段需要不同类型的数据
- synthetic data 的选择技巧:用容易心算的数字、用 stand-out 的值(如
DEADBEEF)
Day 3:调试流程标准化
- 阅读 “Fast Debugging Methodology” 中关于调试流程的部分
- 理解标准调试流程:
1
2
3
4
5
61. 复现问题 → 确保能稳定复现
2. 最小化 → 用最少的代码和数据复现
3. 假设 → 提出可能的原因
4. 验证 → 设计实验验证假设
5. 修复 → 确认根因后修复
6. 回归测试 → 确保修复没有引入新问题 - 本地实验:用标准流程解决一个 bug
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# 步骤 1:复现
def buggy_function(x):
return x / (x - 5) # 当 x=5 时崩溃
buggy_function(5) # ZeroDivisionError
# 步骤 2:最小化
# 已经是最小化了,一行代码
# 步骤 3:假设
# 假设:分母为 0 时没处理
# 步骤 4:验证
# 验证:x=5 时,x-5=0,确实会 ZeroDivisionError
# 步骤 5:修复
def fixed_function(x):
if x == 5:
return float('inf')
return x / (x - 5)
# 步骤 6:回归测试
assert fixed_function(5) == float('inf')
assert fixed_function(6) == 6.0
assert fixed_function(4) == -4.0
print("All tests passed!")
做完之后能了解:
- 调试不是”碰运气”,而是可以系统化的六步法
- 为什么 “先最小化再调试” 能节省 80% 的时间
Day 4:常见调试陷阱
- 阅读书中关于调试陷阱的部分
- 列出 5 个常见陷阱:
- 确认偏误:只看支持自己假设的证据
- 过度复杂化:把简单问题想复杂
- 忽略日志:不仔细读错误信息就急着改代码
- 没有版本控制:改了代码后忘记原来是什么样子
- 假设环境没问题:”在我机器上能跑” 的陷阱
- 本地实验:故意犯一个确认偏误的错误
1
2
3# 假设你认为 bug 是因为学习率太高
# 于是你只调学习率,但问题其实是梯度爆炸
# 你应该同时检查:学习率、梯度范数、loss 曲线、权重分布 - 理解:为什么 “看错误信息” 能解决 50% 的 bug(大多数错误信息已经告诉你问题在哪)
做完之后能了解:
- 调试中最常见的认知偏误
- 如何避免 “在错误的方向上越走越远”
Day 5:方法论周复盘
- 回答:
- 调试的两个核心需求是什么?
- 什么时候用 random data?什么时候用 synthetic data?什么时候用 real data?
- 标准调试流程的 6 个步骤是什么?
- 你最容易犯哪个调试陷阱?
- 记录:过去一周的调试经历,用标准流程重新分析一次
第二部分:Python 调试工具(Day 6-10)
Day 6:q —— 快速打印追踪
- 阅读 “Debugging Python” 章节的
q工具部分 - 安装
q:pip install q - 本地实验:用
q追踪一个函数的输入输出1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import q
def add(a, b):
return a + b
def multiply(a, b):
return a * b
result = add(1, 2)
result = multiply(result, 3)
# 查看 /tmp/q 的输出
# cat /tmp/q
# 输出:
# 0.0s add(1, 2)
# 0.0s -> 3
# 0.0s multiply(3, 3)
# 0.0s -> 9 - 理解
q的优势:- 输出到
/tmp/q,不干扰正常日志 - 自动打印参数和返回值
- 自带粗略的时间戳
- 输出到
做完之后能了解:
- 为什么
q比print更适合调试(不污染 stdout,自动格式化) - 在已有大量日志输出的程序中,如何快速定位自己的调试信息
Day 7:pdb 与 ipdb —— 断点调试
- 阅读书中关于 Python 调试的部分
- 掌握
pdb的基本命令:b(reak) line_no:设置断点c(ontinue):继续执行n(ext):执行下一行s(tep):进入函数r(eturn):执行到函数返回p(rint) var:打印变量l(ist):显示当前代码q(uit):退出
- 本地实验:用
pdb调试一个循环1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import pdb
def find_max(numbers):
max_val = numbers[0]
for i, num in enumerate(numbers):
if num > max_val:
max_val = num
if i == 3: # 在第四次迭代时打断点
pdb.set_trace()
return max_val
result = find_max([3, 1, 4, 1, 5, 9, 2, 6])
print(result)
# 在 pdb 中输入:
# p max_val # 查看当前最大值
# p i # 查看当前索引
# p num # 查看当前数字
# n # 执行下一行
# c # 继续执行 - 对比
pdb和ipdb(ipdb支持语法高亮和 tab 补全)
做完之后能了解:
- 断点调试比 “加 print” 更高效(可以查看任意变量、任意时刻的状态)
pdb是 Python 标准库的调试器,无需安装
Day 8:py-spy —— 无侵入式性能分析
- 阅读 “Debugging Python” 章节的
py-spy部分 - 安装
py-spy:pip install py-spy - 本地实验:用
py-spy分析一个慢程序1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18# slow_program.py
import time
import torch
def slow_function():
time.sleep(1) # 模拟慢操作
x = torch.randn(1000, 1000)
y = torch.matmul(x, x) # 计算密集型
return y
def fast_function():
return 42
for i in range(10):
if i % 2 == 0:
slow_function()
else:
fast_function()1
2
3
4
5
6
7
8
9# 终端 1:运行程序
python slow_program.py
# 终端 2:用 py-spy 分析
py-spy top --pid <pid>
# 录制火焰图
py-spy record -o profile.svg --pid <pid>
# 用浏览器打开 profile.svg,观察时间分布 - 观察火焰图,确认
slow_function占用了大部分时间
做完之后能了解:
py-spy不需要修改代码、不需要重启程序- 火焰图能直观显示时间消耗在哪些函数上
- 为什么 “无侵入式” 很重要(生产环境不能改代码)
Day 9:objprint 与打印对象结构
- 阅读 “Printing Object Variables” 部分
- 理解
__repr__的作用:让对象打印更友好 - 本地实验:对比有
__repr__和没有的类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 没有 __repr__
class BadModel:
def __init__(self):
self.layers = [1, 2, 3]
self.activation = 'relu'
# 有 __repr__
class GoodModel:
def __init__(self):
self.layers = [1, 2, 3]
self.activation = 'relu'
def __repr__(self):
return f"GoodModel(layers={self.layers}, activation='{self.activation}')"
print(BadModel()) # <__main__.BadModel object at 0x...>
print(GoodModel()) # GoodModel(layers=[1, 2, 3], activation='relu') - 安装
objprint:pip install objprint - 用
objprint打印复杂对象的结构1
2
3
4
5from objprint import objprint
import torch
model = torch.nn.Linear(10, 10)
objprint(model) # 打印模型的完整结构
做完之后能了解:
- 为什么调试时 “看到对象的内部结构” 很重要
__repr__和__str__的区别(__repr__是给开发者看的,__str__是给用户看的)
Day 10:Python 调试周复盘
- 回答:
q和print的区别是什么?pdb的n和s有什么区别?py-spy为什么被称为 “无侵入式”?- 什么时候需要自定义
__repr__?
- 记录:过去一周的 Python 调试中,你最常用的工具是什么?有没有可以改进的地方?
第三部分:PyTorch 调试(Day 11-15)
Day 11:检测 NaN/Inf
- 阅读 “Debugging PyTorch” 章节
- 掌握检测方法:
torch.isnan(tensor).any():检测 NaNtorch.isinf(tensor).any():检测 Inftorch.autograd.set_detect_anomaly(True):自动检测
- 本地实验:故意制造 NaN,然后定位来源
1
2
3
4
5
6
7
8
9
10
11
12
13
14import torch
# 开启异常检测
torch.autograd.set_detect_anomaly(True)
x = torch.tensor([1.0], requires_grad=True)
y = x.log() # log(1) = 0,正常
z = y / 0 # 除以 0!
try:
z.backward()
except RuntimeError as e:
print(f"Caught error: {e}")
# 会打印出 forward stack trace,告诉你哪一步出了问题 - 理解
detect_anomaly的工作原理:在反向传播时检查梯度是否为 NaN/Inf
做完之后能了解:
- PyTorch 的
detect_anomaly是定位 NaN 来源的神器 - 为什么 NaN 一旦产生就会传播(任何运算 involving NaN 都是 NaN)
Day 12:显存分析
- 阅读书中关于显存调试的部分
- 掌握显存分析工具:
1
2
3
4
5
6
7
8
9
10
11
12import torch
# 查看当前显存占用
print(f"Allocated: {torch.cuda.memory_allocated() / 1e9:.2f} GB")
print(f"Reserved: {torch.cuda.memory_reserved() / 1e9:.2f} GB")
print(f"Max allocated: {torch.cuda.max_memory_allocated() / 1e9:.2f} GB")
# 重置峰值统计
torch.cuda.reset_peak_memory_stats()
# 显存摘要
print(torch.cuda.memory_summary()) - 本地实验:追踪一次 forward 的显存变化
1
2
3
4
5
6
7
8
9
10
11
12import torch
torch.cuda.reset_peak_memory_stats()
before = torch.cuda.memory_allocated()
model = torch.nn.Linear(4096, 4096).cuda()
x = torch.randn(256, 4096, device='cuda')
y = model(x)
after = torch.cuda.memory_allocated()
print(f"Model: {(after - before) / 1024**2:.2f} MB")
print(f"Peak: {torch.cuda.max_memory_allocated() / 1024**2:.2f} MB") - 理解
memory_allocated和memory_reserved的区别:allocated:PyTorch 实际使用的显存reserved:CUDA 缓存池中的显存(包括已分配和未分配的空闲块)
做完之后能了解:
- 如何系统性地追踪显存泄漏
- 为什么
reserved > allocated(CUDA 内存池机制)
Day 13:PyTorch Profiler
- 阅读书中关于 PyTorch Profiler 的部分
- 本地实验:用 PyTorch Profiler 分析一个模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import torch
from torch.profiler import profile, record_function, ProfilerActivity
model = torch.nn.TransformerEncoder(
torch.nn.TransformerEncoderLayer(d_model=512, nhead=8),
num_layers=6
).cuda()
x = torch.randn(32, 100, 512, device='cuda')
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
with_stack=True
) as prof:
with record_function("model_forward"):
y = model(x)
# 打印结果
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
# 导出 Chrome trace
prof.export_chrome_trace("trace.json")
# 打开 chrome://tracing,加载 trace.json - 观察 Chrome trace,找到耗时最长的 kernel
做完之后能了解:
- PyTorch Profiler 是定位性能瓶颈的标准工具
- Chrome trace 能直观显示 CPU 和 GPU 的时间线
Day 14:梯度检查
- 理解梯度检查的重要性
- 本地实验:检查一个模型的梯度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import torch
model = torch.nn.Sequential(
torch.nn.Linear(10, 10),
torch.nn.ReLU(),
torch.nn.Linear(10, 1)
).cuda()
x = torch.randn(32, 10, device='cuda')
y = torch.randn(32, 1, device='cuda')
loss_fn = torch.nn.MSELoss()
pred = model(x)
loss = loss_fn(pred, y)
loss.backward()
# 检查每层梯度
for name, param in model.named_parameters():
if param.grad is not None:
grad_norm = param.grad.norm().item()
print(f"{name}: grad_norm={grad_norm:.4f}")
if torch.isnan(param.grad).any():
print(f" WARNING: NaN in gradient!")
if grad_norm < 1e-6:
print(f" WARNING: Vanishing gradient!") - 理解梯度消失和梯度爆炸的表现
做完之后能了解:
- 为什么 “模型不收敛” 时首先要检查梯度
- 梯度范数的正常范围(通常 0.1-10,太小说明梯度消失,太大说明梯度爆炸)
Day 15:PyTorch 调试周复盘
- 回答:
torch.autograd.set_detect_anomaly(True)的原理是什么?memory_allocated和memory_reserved的区别?- PyTorch Profiler 的 Chrome trace 怎么看?
- 梯度范数多少算正常?多少算异常?
- 记录:过去一周的 PyTorch 调试中,哪个工具帮你解决了实际问题?
第四部分:Unix 调试工具(Day 16-19)
Day 16:strace —— 系统调用追踪
- 阅读 “Unix Tools For Debugging” 章节的
strace部分 - 本地实验:用
strace追踪一个 Python 程序的系统调用1
2
3
4
5
6
7
8# 追踪所有系统调用
strace -e trace=file python -c "import torch; torch.randn(10)" 2>&1 | head -20
# 只看文件操作
strace -e trace=file,open,read,write python your_script.py 2>&1 | grep -E "open|read|write"
# 统计每种系统调用的次数
strace -c python your_script.py 2>&1 - 理解输出格式:
1
2
3open("/path/to/file", O_RDONLY) = 3 # 打开文件,返回文件描述符 3
read(3, "data...", 1024) = 1024 # 从 fd 3 读取 1024 字节
close(3) = 0 # 关闭 fd 3
做完之后能了解:
strace是诊断 “程序为什么慢” 的利器(可能是卡在 I/O)- 如何区分 “CPU 计算慢” 和 “I/O 等待慢”
Day 17:htop / nvtop —— 资源监控
- 阅读书中关于系统监控的部分
- 本地实验:同时运行
htop和nvtop,观察资源使用1
2
3
4
5
6
7
8# 终端 1
htop
# 终端 2
nvtop
# 终端 3:跑一个压力测试
python -c "import torch; a=torch.randn(10000,10000,device='cuda'); [torch.matmul(a,a) for _ in range(100)]" - 观察:
- CPU 利用率是否 100%?(如果是,可能是 DataLoader 瓶颈)
- GPU 利用率是否 100%?(如果不是,可能是 CPU 等待或 I/O 瓶颈)
- 显存占用是否稳步上升?(可能是泄漏)
做完之后能了解:
htop看 CPU/内存,nvtop看 GPU/显存- “GPU 利用率 100%” 不一定代表高效(可能是在等内存)
Day 18:tmux + nohup —— 后台与持久化
- 阅读书中关于进程管理的部分
- 本地实验:
1
2
3
4
5
6
7
8
9
10
11
12
13# 用 tmux 创建会话
tmux new -s train
# 在 tmux 中运行训练
python train.py
# 按 Ctrl+B 然后 D 分离会话
# 重新连接
tmux attach -t train
# 用 nohup 后台运行
nohup python train.py > train.log 2>&1 &
# 查看输出
tail -f train.log - 理解
tmux和nohup的适用场景:tmux:需要交互式操作(随时 attach 进去看)nohup:纯后台运行(适合长时间训练)
做完之后能了解:
- 为什么 “SSH 断开训练不中断” 是刚需
tmux是远程服务器上最实用的工具之一
Day 19:Unix 调试周复盘 + 全书总结
- 回答:
strace能解决什么问题?不能解决什么问题?htop中 “CPU 利用率 100% 但程序很慢” 可能是什么原因?tmux和nohup各适用于什么场景?- 调试方法论的两个核心需求是什么?
- Python 调试的四个工具(q/pdb/py-spy/objprint)各适用于什么场景?
- PyTorch 调试的四个工具(detect_anomaly/profiler/memory/gradient)各适用于什么场景?
- 记录:这本书对你最有价值的 3 个知识点
- 记录:你还想深入了解但没覆盖到的 3 个主题
附录:调试工具速查表
| 工具 | 用途 | 安装 | 适用场景 |
|---|---|---|---|
q |
快速打印追踪 | pip install q |
不污染 stdout 的调试 |
pdb |
断点调试 | 内置 | 逐行调试 |
ipdb |
增强版 pdb | pip install ipdb |
需要语法高亮和补全 |
py-spy |
性能分析 | pip install py-spy |
无侵入式 profiling |
objprint |
打印对象结构 | pip install objprint |
查看复杂对象 |
strace |
系统调用追踪 | 系统自带 | 诊断 I/O 问题 |
htop |
CPU/内存监控 | apt install htop |
资源使用监控 |
nvtop |
GPU 监控 | apt install nvtop |
显存/GPU 监控 |
tmux |
会话管理 | apt install tmux |
持久化终端会话 |
nohup |
后台运行 | 内置 | 长时间后台任务 |
附录:调试决策树
1 | 程序崩溃了? |
附录:本地可复现调试案例
| 案例 | 现象 | 诊断 | 解决 |
|---|---|---|---|
| NaN Loss | loss 变成 nan | detect_anomaly + 梯度检查 |
梯度裁剪、降低学习率 |
| OOM | CUDA out of memory | memory_summary() |
减小 batch、用 gradient checkpoint |
| 慢训练 | 100% GPU 但吞吐低 | py-spy + Profiler |
检查 DataLoader、启用 compile |
| 死锁 | 程序卡住不动 | strace -p <pid> |
检查分布式通信 |
| 结果不一致 | 每次运行结果不同 | 检查随机种子 | 设置 torch.manual_seed |