Coverage for transformer_lens/model_bridge/sources/vllm/worker_extension.py: 94%

161 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-21 19:27 +0000

1"""Worker extension exposed to collective_rpc for capture reads and 

2intervention writes. 

3 

4Hook *installation* lives in :mod:`plugin` (must happen pre-compile). This class 

5only exposes the read/write surface. State is per-Worker so concurrent 

6``boot_vllm`` calls don't collide. All methods prefixed ``tl_`` to avoid 

7colliding with vLLM ``Worker`` attributes. 

8 

9Two capture modes, selected at boot: 

10 * Compiled (default): per-hook GPU buffers + affine scale/bias swap. Single 

11 prompt. ``tl_read_captures`` / ``tl_set_interventions``. 

12 * Batched (``enable_batching=True``, eager): per-(req_id, hook) CPU 

13 accumulators filled in the hook via query_start_loc segmentation; arbitrary 

14 batch + chunked prefill. ``tl_read_batched_captures`` / 

15 ``tl_set_batched_interventions`` / ``tl_reset_accumulators``. 

16""" 

17from __future__ import annotations 

18 

19from typing import Any, Dict, List, Optional, Set 

20 

21import torch 

22 

23from .intervention_specs import SUPPORTED_OPS, validate_spec 

24 

25# Explicit tensor wire format for collective_rpc returns. vLLM's multiproc RPC 

26# cannot round-trip raw tensors from extension methods — its tensor-aware msgpack 

27# encoder's header (['float32', [rows, cols], buf_idx]) leaks through undecoded on 

28# the response path (GPU-verified on vllm 0.20.2). dtype/shape/bytes are all 

29# msgpack-native, so this survives any executor topology; the in-process executor 

30# pays one extra CPU copy. 

31_TL_TENSOR_KEY = "__tl_tensor__" 

32 

33 

34def dtype_name(dtype: torch.dtype) -> str: 

35 """``torch.float32`` → ``"float32"`` — the one spelling for every dtype-string site.""" 

36 return str(dtype).removeprefix("torch.") 

37 

38 

39def is_pp_missing_layer(module: Any) -> bool: 

40 """vLLM PP keeps full-length module lists on every rank, filling non-owned slots 

41 (layers, embed_tokens, norm, lm_head) with PPMissingLayer identity stubs that the 

42 forward never calls — a hook installed there would serve its dead zero buffer as 

43 a real capture. Matched by name so non-vllm test doubles work; the pinned band's 

44 class is named exactly this, and a rename fails loud via the rank-layout check.""" 

45 return type(module).__name__ == "PPMissingLayer" 

46 

47 

48def resolve_dot_path(root: Any, dot_path: str) -> Any: 

49 """Walk a dot-path; ``None`` when any segment is missing or the target is a 

50 PPMissingLayer stub. Per-rank absence is legal under pipeline parallelism (each 

51 rank owns a layer subset) — the boot site verifies every hook landed on at least 

52 one rank.""" 

53 target = root 

54 for seg in dot_path.split("."): 

55 if seg.isdigit(): 

56 try: 

57 target = target[int(seg)] 

58 except (IndexError, KeyError, TypeError): 

59 return None 

60 else: 

61 target = getattr(target, seg, None) 

62 if target is None: 

63 return None 

64 return None if is_pp_missing_layer(target) else target 

65 

66 

67def encode_tensor(t: torch.Tensor) -> Dict[str, Any]: 

68 """Encode a CPU tensor for the RPC wire. bf16 rides as fp32 bytes (exact).""" 

69 t = t.detach().cpu().contiguous() 

70 name = dtype_name(t.dtype) 

71 if t.dtype == torch.bfloat16: 

72 t = t.to(torch.float32) 

73 return { 

74 _TL_TENSOR_KEY: True, 

75 "dtype": name, 

76 "shape": list(t.shape), 

77 "data": t.numpy().tobytes(), 

78 } 

79 

80 

81def decode_tensor(payload: Dict[str, Any]) -> torch.Tensor: 

82 """Inverse of :func:`encode_tensor`; used driver-side.""" 

83 import numpy as np 

84 

85 dtype = getattr(torch, payload["dtype"]) 

86 wire_dtype = torch.float32 if dtype == torch.bfloat16 else dtype 

87 np_dtype = torch.empty(0, dtype=wire_dtype).numpy().dtype 

88 array = np.frombuffer(payload["data"], dtype=np_dtype).copy() 

89 tensor = torch.from_numpy(array).reshape(payload["shape"]) 

90 return tensor.to(torch.bfloat16) if dtype == torch.bfloat16 else tensor 

91 

92 

93class TLWorkerExtension: 

94 """Mixed into vLLM's ``Worker`` via ``worker_extension_cls``.""" 

95 

96 _tl_hook_handles: list 

97 _tl_buffers: Dict[str, torch.Tensor] 

98 _tl_scale_buffers: Dict[str, torch.Tensor] 

99 _tl_bias_buffers: Dict[str, torch.Tensor] 

100 # Per-hook first-write-wins gates (compiled mode); see plugin._gated_capture. 

101 _tl_capture_flags: Dict[str, torch.Tensor] 

102 _tl_fire_counter: torch.Tensor 

103 # Batched-mode state (eager). 

104 _tl_accum: Dict[tuple, List[torch.Tensor]] 

105 _tl_intervention_specs: Dict[str, Dict[str, Any]] 

106 # Specs whose module doesn't exist on this rank (PP layer shards). 

107 _tl_absent_hooks: set 

108 

109 def tl_absent_hooks(self) -> Optional[List[str]]: 

110 """Spec names that installed no hook on this rank — the boot site verifies 

111 their union across ranks covers every spec. ``None`` means installation 

112 never ran at all (plugin patch absent or spec channel empty), which the 

113 coverage check must treat as fatal rather than vacuously complete.""" 

114 if not hasattr(self, "_tl_absent_hooks"): 

115 return None 

116 return sorted(self._tl_absent_hooks) 

117 

118 def tl_read_captures( 

119 self, prompt_lens: List[int], names: Optional[List[str]] = None 

120 ) -> Dict[str, Dict[str, Any]]: 

121 """Slice each capture buffer to ``sum(prompt_lens)`` rows; wire-encoded CPU copies. 

122 

123 ``names`` restricts the read (``None`` = all) — this is the only GPU→CPU 

124 crossing, so it's where a names_filtered run saves bandwidth. Caller gates 

125 ``sum(prompt_lens) <= max_num_batched_tokens``, so ``total`` is in bounds. 

126 """ 

127 total = sum(prompt_lens) 

128 buffers: Dict[str, torch.Tensor] = getattr(self, "_tl_buffers", {}) 

129 # Ownership is dynamic, not structural: a hook can be installed on a module 

130 # this rank holds but never runs (PP stages share tied-embedding aliases and 

131 # some archs instantiate norm on every rank), so a still-open first-write 

132 # flag means the buffer holds no data from this forward — don't serve it. 

133 flags: Dict[str, torch.Tensor] = getattr(self, "_tl_capture_flags", {}) 

134 wanted = buffers.keys() if names is None else [n for n in names if n in buffers] 

135 return { 

136 name: encode_tensor(buffers[name][:total]) 

137 for name in wanted 

138 if name not in flags or bool(flags[name].item()) 

139 } 

140 

141 def tl_set_interventions(self, specs: Dict[str, Dict[str, Any]]) -> None: 

142 """Reset all affine buffers to identity, then apply each spec. 

143 

144 Driver pushes the full spec set every forward (or ``{}`` to reset). 

145 Spec format: ``{hook_name: {"op": <op>, ...op-specific params>}}``. 

146 Supported ops: suppress, scale (``factor``: float), add and set 

147 (``value``: scalar broadcast across width, or 1-D shape ``(width,)``). 

148 """ 

149 scale_bufs: Dict[str, torch.Tensor] = getattr(self, "_tl_scale_buffers", {}) 

150 bias_bufs: Dict[str, torch.Tensor] = getattr(self, "_tl_bias_buffers", {}) 

151 # Reset every hook to identity first — clears any stale state from 

152 # the previous forward. 

153 for name, sb in scale_bufs.items(): 

154 sb.fill_(1.0) 

155 bias_bufs[name].zero_() 

156 absent: Set[str] = getattr(self, "_tl_absent_hooks", set()) 

157 for hook_name, spec in specs.items(): 

158 if hook_name in absent: 

159 # Another PP stage owns this hook; its rank applies the spec. The 

160 # driver's supported_hook_points check already caught real typos. 

161 continue 

162 if hook_name not in scale_bufs: 162 ↛ 166line 162 didn't jump to line 166 because the condition on line 162 was always true

163 raise KeyError(f"Unknown hook for intervention: {hook_name!r}") 

164 # Authoritative validation: producers that bypass VLLMDriver (the Inspect 

165 # vLLM provider pushes specs over this same RPC) get identical rejection. 

166 validate_spec(hook_name, spec, width=scale_bufs[hook_name].shape[-1]) 

167 _apply_intervention(scale_bufs[hook_name], bias_bufs[hook_name], spec) 

168 

169 def tl_get_param(self, dotted_name: str) -> Optional[Dict[str, Any]]: 

170 """Read a named model tensor (e.g. ``model.norm.weight``) as a wire-encoded 

171 CPU clone. 

172 

173 ``None`` if the path doesn't resolve to a tensor. The bridge has no general 

174 weight surface, so this is how callers reach e.g. the ln_final weight. 

175 """ 

176 target = resolve_dot_path(getattr(self, "model_runner").model, dotted_name) 

177 return encode_tensor(target) if isinstance(target, torch.Tensor) else None 

178 

179 def tl_reset_counter(self) -> None: 

180 """Zero the shared hook-fire counter before a forward.""" 

181 counter = getattr(self, "_tl_fire_counter", None) 

182 if counter is not None: 

183 counter.zero_() 

184 

185 def tl_reset_capture_flags(self) -> None: 

186 """Open every per-hook capture gate so the next forward writes to the buffers. 

187 

188 First-write-wins gating means decode-step forwards self-copy and never overwrite 

189 prefill activations — the driver calls this once before any capture-needing 

190 generate (single-forward or multi-token eval) and ``tl_read_captures`` afterward. 

191 """ 

192 flags: Dict[str, torch.Tensor] = getattr(self, "_tl_capture_flags", {}) 

193 for flag in flags.values(): 

194 flag.zero_() 

195 

196 def tl_read_counter(self) -> int: 

197 """Total hook fires since the last reset.""" 

198 counter = getattr(self, "_tl_fire_counter", None) 

199 return int(counter.item()) if counter is not None else 0 

200 

201 def tl_reset_accumulators(self) -> None: 

202 """Clear capture chunks before each generate, else prior chunks leak into the cat.""" 

203 self._tl_accum = {} 

204 

205 def tl_read_batched_captures( 

206 self, names: Optional[List[str]] = None 

207 ) -> Dict[str, Dict[str, torch.Tensor]]: 

208 """Cat per-request chunks into ``{req_id: {hook: (seq, width)}}`` (token-order). 

209 

210 ``names`` restricts to those hooks (``None`` = all). Note the per-chunk 

211 GPU→CPU copy already happened in the hook, so this only saves the cat. 

212 """ 

213 accum: Dict[tuple, List[torch.Tensor]] = getattr(self, "_tl_accum", {}) 

214 nameset = None if names is None else set(names) 

215 out: Dict[str, Dict[str, Any]] = {} 

216 for (req_id, name), chunks in accum.items(): 

217 if nameset is not None and name not in nameset: 

218 continue 

219 out.setdefault(req_id, {})[name] = encode_tensor(torch.cat(chunks, dim=0)) 

220 return out 

221 

222 def tl_set_batched_interventions(self, specs: Dict[str, Dict[str, Any]]) -> None: 

223 """Store the global spec dict the eager hook reads; ``{}`` clears.""" 

224 for hook_name, spec in specs.items(): 

225 validate_spec(hook_name, spec) 

226 self._tl_intervention_specs = dict(specs) 

227 

228 def tl_remove_hooks(self) -> None: 

229 """Detach all capture hooks and drop buffer references. Idempotent.""" 

230 for handle in getattr(self, "_tl_hook_handles", []): 230 ↛ 231line 230 didn't jump to line 231 because the loop on line 230 never started

231 handle.remove() 

232 self._tl_hook_handles = [] 

233 self._tl_buffers = {} 

234 self._tl_scale_buffers = {} 

235 self._tl_bias_buffers = {} 

236 self._tl_capture_flags = {} 

237 self._tl_accum = {} 

238 self._tl_intervention_specs = {} 

239 self._tl_absent_hooks = set() 

240 

241 

242def _apply_op(t: torch.Tensor, spec: Dict[str, Any]) -> torch.Tensor: 

243 """Apply a spec to a tensor in-line (eager path; no GPU buffer to swap).""" 

244 op = spec.get("op") 

245 if op not in SUPPORTED_OPS: 

246 raise ValueError(f"Unsupported intervention op: {op!r}. Supported: {sorted(SUPPORTED_OPS)}") 

247 if op == "suppress": 

248 return torch.zeros_like(t) 

249 if op == "scale": 

250 return t * float(spec["factor"]) 

251 value = torch.as_tensor(spec["value"], device=t.device, dtype=t.dtype) 

252 if value.ndim != 0 and value.shape != (t.shape[-1],): 

253 raise ValueError( 

254 f"Intervention 'value' must be a scalar or shape {(t.shape[-1],)}; " 

255 f"got shape {tuple(value.shape)}" 

256 ) 

257 if op == "add": 

258 return t + value 

259 return torch.zeros_like(t) + value # set 

260 

261 

262def _write_rows(buf: torch.Tensor, idx: Optional[List[int]], value: Any) -> None: 

263 """In-place write of ``value`` (Python scalar or ``(width,)`` tensor) into ``buf``. 

264 

265 ``idx is None`` writes the whole buffer — for a 2-D ``(max_n, width)`` buffer that 

266 broadcasts a ``(width,)`` value across every row. ``idx`` (a list of row indices) 

267 writes only those rows of a 2-D buffer. 

268 """ 

269 if idx is None: 

270 if isinstance(value, torch.Tensor): 

271 buf[...] = value # (width,) broadcasts across rows for a 2-D buffer 

272 else: 

273 buf.fill_(value) 

274 else: 

275 buf[idx] = value # advanced-index rows; scalar or (width,) broadcasts 

276 

277 

278def _apply_intervention( 

279 scale_buf: torch.Tensor, bias_buf: torch.Tensor, spec: Dict[str, Any] 

280) -> None: 

281 """Translate a spec dict to in-place buffer writes. 

282 

283 ``spec['pos']`` (int or list[int]) scopes the edit to those sequence rows and 

284 requires 2-D ``(max_n, width)`` affine buffers (position interventions enabled at 

285 boot). Absent ``pos`` writes the whole buffer, applying to every position. The 

286 caller resets both buffers to identity first, so a ``pos`` edit leaves other rows 

287 untouched. 

288 """ 

289 op = spec.get("op") 

290 if op not in SUPPORTED_OPS: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 raise ValueError(f"Unsupported intervention op: {op!r}. Supported: {sorted(SUPPORTED_OPS)}") 

292 pos = spec.get("pos") 

293 idx: Optional[List[int]] = None 

294 if pos is not None: 

295 if scale_buf.ndim != 2: 

296 raise ValueError( 

297 "Per-position 'pos' requires 2-D affine buffers; boot with " 

298 "enable_position_interventions=True." 

299 ) 

300 idx = [pos] if isinstance(pos, int) else list(pos) 

301 max_n = scale_buf.shape[0] 

302 bad = [p for p in idx if p < 0 or p >= max_n] 

303 if bad: 

304 raise ValueError(f"Intervention 'pos' {bad} out of range [0, {max_n}).") 

305 

306 if op == "suppress": 

307 _write_rows(scale_buf, idx, 0.0) 

308 _write_rows(bias_buf, idx, 0.0) 

309 return 

310 if op == "scale": 

311 _write_rows(scale_buf, idx, float(spec["factor"])) 

312 _write_rows(bias_buf, idx, 0.0) 

313 return 

314 value = torch.as_tensor(spec["value"], device=bias_buf.device, dtype=bias_buf.dtype) 

315 width = bias_buf.shape[-1] 

316 # 0-d broadcasts across width (e.g. "shift all dims by 0.5"); width-shaped 

317 # writes element-wise (e.g. SAE steering vector). Anything else is an error. 

318 if value.ndim != 0 and value.shape != (width,): 

319 raise ValueError( 

320 f"Intervention 'value' must be a scalar or shape {(width,)}; " 

321 f"got shape {tuple(value.shape)}" 

322 ) 

323 _write_rows(bias_buf, idx, value.item() if value.ndim == 0 else value) 

324 if op == "add": 

325 _write_rows(scale_buf, idx, 1.0) 

326 elif op == "set": 326 ↛ 329line 326 didn't jump to line 329 because the condition on line 326 was always true

327 _write_rows(scale_buf, idx, 0.0) 

328 else: 

329 raise RuntimeError( 

330 f"op {op!r} is in SUPPORTED_OPS but _apply_intervention has no branch for it." 

331 )