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

179 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-10 05:10 +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 

19import math 

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

21 

22import torch 

23 

24from .intervention_specs import SUPPORTED_OPS, validate_spec 

25 

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

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

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

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

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

31# pays one extra CPU copy. 

32_TL_TENSOR_KEY = "__tl_tensor__" 

33 

34 

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

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

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

38 

39 

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

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

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

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

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

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

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

47 

48 

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

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

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

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

53 one rank.""" 

54 target = root 

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

56 if seg.isdigit(): 

57 try: 

58 target = target[int(seg)] 

59 except (IndexError, KeyError, TypeError): 

60 return None 

61 else: 

62 target = getattr(target, seg, None) 

63 if target is None: 

64 return None 

65 return None if is_pp_missing_layer(target) else target 

66 

67 

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

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

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

71 name = dtype_name(t.dtype) 

72 if t.dtype == torch.bfloat16: 

73 t = t.to(torch.float32) 

74 return { 

75 _TL_TENSOR_KEY: True, 

76 "dtype": name, 

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

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

79 } 

80 

81 

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

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

84 import numpy as np 

85 

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

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

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

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

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

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

92 

93 

94class TLWorkerExtension: 

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

96 

97 model_runner: Any 

98 

99 _tl_hook_handles: list 

100 _tl_buffers: Dict[str, torch.Tensor] 

101 _tl_scale_buffers: Dict[str, torch.Tensor] 

102 _tl_bias_buffers: Dict[str, torch.Tensor] 

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

104 _tl_capture_flags: Dict[str, torch.Tensor] 

105 _tl_fire_counter: torch.Tensor 

106 # Batched-mode state (eager). 

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

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

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

110 _tl_absent_hooks: set 

111 

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

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

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

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

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

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

118 return None 

119 return sorted(self._tl_absent_hooks) 

120 

121 def tl_read_captures( 

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

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

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

125 

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

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

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

129 """ 

130 total = sum(prompt_lens) 

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

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

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

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

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

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

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

138 return { 

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

140 for name in wanted 

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

142 } 

143 

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

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

146 

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

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

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

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

151 """ 

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

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

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

155 # the previous forward. 

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

157 sb.fill_(1.0) 

158 bias_bufs[name].zero_() 

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

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

161 if hook_name in absent: 

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

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

164 continue 

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

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

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

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

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

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

171 

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

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

174 CPU clone. 

175 

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

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

178 """ 

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

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

181 

182 def tl_get_logits_processor(self) -> Optional[Dict[str, Any]]: 

183 """Read the engine's post-unembedding transform; absent on non-final PP stages.""" 

184 processor = resolve_dot_path(self.model_runner.model, "logits_processor") 

185 if processor is None: 

186 return None 

187 if not hasattr(processor, "scale") or not hasattr(processor, "soft_cap"): 

188 raise NotImplementedError( 

189 "Unsupported vLLM logits processor: missing scale or soft_cap." 

190 ) 

191 if bool(getattr(processor, "logits_as_input", False)): 

192 raise NotImplementedError("Cannot reconstruct logits_as_input processors from lm_head.") 

193 try: 

194 scale = float(processor.scale) 

195 cap = float(processor.soft_cap) if processor.soft_cap is not None else None 

196 except (TypeError, ValueError, OverflowError) as exc: 

197 raise RuntimeError("vLLM logits processor has an invalid scale or soft_cap.") from exc 

198 if not math.isfinite(scale) or (cap is not None and (not math.isfinite(cap) or cap <= 0)): 

199 raise RuntimeError("vLLM logits processor has an invalid scale or soft_cap.") 

200 return {"scale": scale, "soft_cap": cap} 

201 

202 def tl_reset_counter(self) -> None: 

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

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

205 if counter is not None: 

206 counter.zero_() 

207 

208 def tl_reset_capture_flags(self) -> None: 

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

210 

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

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

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

214 """ 

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

216 for flag in flags.values(): 

217 flag.zero_() 

218 

219 def tl_read_counter(self) -> int: 

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

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

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

223 

224 def tl_reset_accumulators(self) -> None: 

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

226 self._tl_accum = {} 

227 

228 def tl_read_batched_captures( 

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

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

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

232 

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

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

235 """ 

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

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

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

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

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

241 continue 

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

243 return out 

244 

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

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

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

248 validate_spec(hook_name, spec) 

249 self._tl_intervention_specs = dict(specs) 

250 

251 def tl_remove_hooks(self) -> None: 

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

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

254 handle.remove() 

255 self._tl_hook_handles = [] 

256 self._tl_buffers = {} 

257 self._tl_scale_buffers = {} 

258 self._tl_bias_buffers = {} 

259 self._tl_capture_flags = {} 

260 self._tl_accum = {} 

261 self._tl_intervention_specs = {} 

262 self._tl_absent_hooks = set() 

263 

264 

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

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

267 op = spec.get("op") 

268 if op not in SUPPORTED_OPS: 

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

270 if op == "suppress": 

271 return torch.zeros_like(t) 

272 if op == "scale": 

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

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

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

276 raise ValueError( 

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

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

279 ) 

280 if op == "add": 

281 return t + value 

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

283 

284 

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

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

287 

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

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

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

291 """ 

292 if idx is None: 

293 if isinstance(value, torch.Tensor): 

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

295 else: 

296 buf.fill_(value) 

297 else: 

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

299 

300 

301def _apply_intervention( 

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

303) -> None: 

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

305 

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

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

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

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

310 untouched. 

311 """ 

312 op = spec.get("op") 

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

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

315 pos = spec.get("pos") 

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

317 if pos is not None: 

318 if scale_buf.ndim != 2: 

319 raise ValueError( 

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

321 "enable_position_interventions=True." 

322 ) 

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

324 max_n = scale_buf.shape[0] 

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

326 if bad: 

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

328 

329 if op == "suppress": 

330 _write_rows(scale_buf, idx, 0.0) 

331 _write_rows(bias_buf, idx, 0.0) 

332 return 

333 if op == "scale": 

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

335 _write_rows(bias_buf, idx, 0.0) 

336 return 

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

338 width = bias_buf.shape[-1] 

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

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

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

342 raise ValueError( 

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

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

345 ) 

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

347 if op == "add": 

348 _write_rows(scale_buf, idx, 1.0) 

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

350 _write_rows(scale_buf, idx, 0.0) 

351 else: 

352 raise RuntimeError( 

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

354 )