简易版 pdb watchpoint

本文最后更新于 2026年10月12日

在开发大型项目的时候,经常会遇到某个变量不知道在哪里就被莫名其妙改掉的情况。这时候如果有 gdb 的 watchpoint 那样的功能,在变量被写入时自动断下来打印调用栈,排查起来就会方便很多。

问题背景

以 vLLM(ac0048c0a)为例,FusedMoE 的 forward_native 开头有一段神秘的 padding 代码:

1
2
3
4
5
6
7
og_hidden_states = hidden_states.shape[-1]
if self.hidden_size != og_hidden_states:
# runtime behavior: 3072 != 2880
hidden_states = F.pad(hidden_states,
(0, self.hidden_size - og_hidden_states),
mode='constant',
value=0.0)

但 FusedMoE 初始化的时候明明是 self.hidden_size = 2880,运行时却变成了 3072。所以我们需要一个工具来追踪 self.hidden_size 到底是在哪里被改的。

vLLM 的多进程架构让直接使用 pdb 调试很困难,而且 pdb 似乎也没有像 gdb watchpoint 那样简单直接的功能。

用 property 实现 watchpoint

一个简单的办法是把要追踪的属性改成 property,在 setter 里打印调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import traceback

class MyClass:
def __init__(self):
self._value = None

@property
def value(self):
return self._value

@value.setter
def value(self, new_value):
print(f"{self._value} -> {new_value}")
traceback.print_stack() # or import pdb; pdb.set_trace()
self._value = new_value

a = MyClass()
a.value = 10

这样每次 value 被赋值时,都会打印出旧值、新值和完整的调用栈,一眼就能看到是谁改的。

setter 的原理

property 本质上是一个描述符(descriptor):a.value 会被翻译成 type(a).value.__get__(a),a.value = 10 会被翻译成 type(a).value.__set__(a, 10)。所以只要我们拦截了 __set__,就能拦截所有对这个属性的写入。

@property 装饰器创建一个只读的 property(只定义了 __get__),而 @value.setter 则是在原有 property 的基础上再包一层,把传入的函数注册为 __set__。读操作照常走 getter,写操作则先经过我们的 setter,打印完调用栈再真正写入 self._value。

有两个细节值得注意:

  • property 必须定义在类上才生效。直接 obj.value = property(...) 挂在实例上是没有用的,因为描述符协议只查类的 __dict__。
  • __init__ 里的初次赋值(self._value = None)如果写的是公开属性名,也会触发 setter。如果不想被初始化干扰,可以在 setter 里加个条件过滤掉。

封装成装饰器

每次手写一遍 property 还是有点啰嗦,可以把这套逻辑封装成一个类装饰器,一行代码就能给任意属性加上 watchpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import traceback

def watchpoint(attr):
"""类装饰器:给 cls 的 attr 属性加上写入追踪"""
def decorator(cls):
private = f"_watched_{attr}"

def getter(self):
return getattr(self, private, None)

def setter(self, new_value):
print(f"{type(self).__name__}.{attr}: "
f"{getattr(self, private, None)} -> {new_value}")
traceback.print_stack()
setattr(self, private, new_value)

setattr(cls, attr, property(getter, setter))
return cls
return decorator

使用方式:

1
2
3
4
5
6
7
@watchpoint("value")
class MyClass:
def __init__(self):
self.value = None # 这一行就会触发 watchpoint

a = MyClass()
a.value = 10

对于不方便改源码的第三方库,也可以手动调用装饰器来打补丁:

1
FusedMoE = watchpoint("hidden_size")(FusedMoE)

这里把真正的值存到了 _watched_ 前缀的属性里,避免和原属性名冲突,也防止 setter 里赋值时无限递归——如果 setter 里直接写 self.value = new_value,就会再次触发 setter 自身,直到栈溢出。