brainpatch 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brainpatch/__init__.py +92 -0
- brainpatch/backends/__init__.py +19 -0
- brainpatch/backends/llamacpp.py +383 -0
- brainpatch/backends/mlx_backend.py +213 -0
- brainpatch/backends/transformers_backend.py +473 -0
- brainpatch/backends/vllm_backend.py +299 -0
- brainpatch/backends/vllm_worker.py +129 -0
- brainpatch/cli.py +825 -0
- brainpatch/config.py +245 -0
- brainpatch/datasets/__init__.py +20 -0
- brainpatch/datasets/contrast_sets.py +64 -0
- brainpatch/evaluation/__init__.py +28 -0
- brainpatch/evaluation/metrics.py +223 -0
- brainpatch/patch/__init__.py +64 -0
- brainpatch/patch/compiler.py +324 -0
- brainpatch/patch/format.py +489 -0
- brainpatch/patch/loader.py +312 -0
- brainpatch/patch/registry.py +300 -0
- brainpatch/patch/tensors.py +236 -0
- brainpatch/patch/validation.py +157 -0
- brainpatch/paths.py +184 -0
- brainpatch/py.typed +0 -0
- brainpatch/research/__init__.py +16 -0
- brainpatch/research/antisycophancy.py +348 -0
- brainpatch/research/behaviour_eval.py +711 -0
- brainpatch/research/generation_eval.py +346 -0
- brainpatch/research/ml/__init__.py +35 -0
- brainpatch/research/ml/activation_store.py +232 -0
- brainpatch/research/ml/causal.py +386 -0
- brainpatch/research/ml/corpus.py +165 -0
- brainpatch/research/ml/evaluation.py +188 -0
- brainpatch/research/ml/extraction.py +464 -0
- brainpatch/research/ml/feature_analysis.py +317 -0
- brainpatch/research/ml/generation.py +109 -0
- brainpatch/research/ml/hooks.py +183 -0
- brainpatch/research/ml/intervention.py +274 -0
- brainpatch/research/ml/model.py +219 -0
- brainpatch/research/ml/patch_search.py +337 -0
- brainpatch/research/ml/runtime.py +343 -0
- brainpatch/research/ml/sae.py +383 -0
- brainpatch/research/ml/training.py +376 -0
- brainpatch/research/stance_rubric.py +170 -0
- brainpatch/research/sycophancy_data.py +982 -0
- brainpatch/research/sycophancy_data_r1.py +1701 -0
- brainpatch/research/sycophancy_data_v2.py +1649 -0
- brainpatch/research/sycophancy_data_v3.py +2288 -0
- brainpatch/research/sycophancy_v2_build.py +362 -0
- brainpatch/research/sycophancy_v3_build.py +188 -0
- brainpatch/research/utility_probe.py +139 -0
- brainpatch/runtime/__init__.py +50 -0
- brainpatch/runtime/auto.py +157 -0
- brainpatch/runtime/base.py +311 -0
- brainpatch/runtime/capabilities.py +96 -0
- brainpatch/runtime/model.py +260 -0
- brainpatch/runtime/scheduling.py +13 -0
- brainpatch/schemas/__init__.py +35 -0
- brainpatch/schemas/contrast.py +161 -0
- brainpatch/schemas/feature.py +193 -0
- brainpatch/schemas/manifest.py +167 -0
- brainpatch/schemas/patch.py +379 -0
- brainpatch/schemas/patch_io.py +88 -0
- brainpatch/schemas/sae.py +146 -0
- brainpatch/server/__init__.py +11 -0
- brainpatch/server/app.py +269 -0
- brainpatch/steering/__init__.py +13 -0
- brainpatch/steering/plan.py +177 -0
- brainpatch/steering/schedule.py +138 -0
- brainpatch/ui/__init__.py +11 -0
- brainpatch/ui/app.py +201 -0
- brainpatch/verify/__init__.py +66 -0
- brainpatch/verify/behavioural.py +156 -0
- brainpatch/verify/checks.py +204 -0
- brainpatch/verify/corruptions.py +335 -0
- brainpatch/verify/report.py +133 -0
- brainpatch/verify/vectors.py +95 -0
- brainpatch/verify/workflow.py +331 -0
- brainpatch-1.2.0.dist-info/METADATA +556 -0
- brainpatch-1.2.0.dist-info/RECORD +82 -0
- brainpatch-1.2.0.dist-info/WHEEL +5 -0
- brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
- brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
- brainpatch-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""vLLM backend.
|
|
2
|
+
|
|
3
|
+
How the intervention reaches vLLM
|
|
4
|
+
---------------------------------
|
|
5
|
+
vLLM's V1 engine runs the model in a **separate worker process**, so reaching it
|
|
6
|
+
by walking attributes off the ``LLM`` object does not work -- the model simply
|
|
7
|
+
is not in the caller's address space. The supported way in is
|
|
8
|
+
``worker_extension_cls`` plus ``LLM.collective_rpc``: vLLM mixes
|
|
9
|
+
:class:`~brainpatch.backends.vllm_worker.BrainPatchWorkerExtension` into every
|
|
10
|
+
worker, and the RPC then invokes its methods **by name**.
|
|
11
|
+
|
|
12
|
+
Calling by name matters. V1's RPC channel serializes with msgpack and refuses to
|
|
13
|
+
ship a callable, suggesting ``VLLM_ALLOW_INSECURE_SERIALIZATION=1`` instead --
|
|
14
|
+
which would turn the engine's control channel into an arbitrary-code path for
|
|
15
|
+
our convenience. The extension class avoids that entirely.
|
|
16
|
+
|
|
17
|
+
The consequence is that the intervention genuinely runs inside vLLM's forward
|
|
18
|
+
pass, under its scheduler, batching and KV cache. There is no shadow
|
|
19
|
+
Transformers model anywhere in this file.
|
|
20
|
+
|
|
21
|
+
The RPC payload is a plain ``{layer: [floats]}`` map of *already-scaled* deltas.
|
|
22
|
+
That suffices precisely because this backend supports neither token schedules
|
|
23
|
+
nor per-request strength, so the delta is constant for a whole forward pass.
|
|
24
|
+
|
|
25
|
+
Request isolation
|
|
26
|
+
-----------------
|
|
27
|
+
Patch state is fixed while serving. With continuous batching one forward pass
|
|
28
|
+
serves many sequences, so a per-request coefficient would alter *other users'*
|
|
29
|
+
output; the backend therefore refuses to mutate patches mid-serve rather than
|
|
30
|
+
offering an unsafe knob. Two concurrent requests provably see identical model
|
|
31
|
+
behaviour, which is what the integration test checks.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import threading
|
|
37
|
+
from typing import Any, Iterator
|
|
38
|
+
|
|
39
|
+
from brainpatch.patch.validation import ModelDescriptor
|
|
40
|
+
from brainpatch.runtime.base import BrainPatchBackend, GenerationConfig
|
|
41
|
+
from brainpatch.runtime.capabilities import Capabilities
|
|
42
|
+
|
|
43
|
+
#: Import path vLLM loads into each worker process.
|
|
44
|
+
WORKER_EXTENSION = "brainpatch.backends.vllm_worker.BrainPatchWorkerExtension"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VLLMBackend(BrainPatchBackend):
|
|
48
|
+
"""Apply BrainPatches inside vLLM's inference path."""
|
|
49
|
+
|
|
50
|
+
name = "vllm"
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
super().__init__()
|
|
54
|
+
self.llm: Any = None
|
|
55
|
+
self.model_id: str = ""
|
|
56
|
+
self.revision: str | None = None
|
|
57
|
+
self.vllm_version: str = ""
|
|
58
|
+
self._geometry: dict[str, Any] = {}
|
|
59
|
+
self._last_rpc: list[dict[str, Any]] = []
|
|
60
|
+
self._lock = threading.RLock()
|
|
61
|
+
self._serving = False
|
|
62
|
+
|
|
63
|
+
# -- availability ----------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def is_available(cls) -> tuple[bool, str]:
|
|
67
|
+
try:
|
|
68
|
+
import vllm
|
|
69
|
+
except ModuleNotFoundError:
|
|
70
|
+
return False, "vLLM not installed -- pip install 'brainpatch[vllm]'"
|
|
71
|
+
try:
|
|
72
|
+
import torch
|
|
73
|
+
|
|
74
|
+
if not torch.cuda.is_available():
|
|
75
|
+
return False, f"vLLM {vllm.__version__} installed but no CUDA device is visible"
|
|
76
|
+
return True, f"vLLM {vllm.__version__}, CUDA: {torch.cuda.get_device_name(0)}"
|
|
77
|
+
except Exception as exc: # noqa: BLE001
|
|
78
|
+
return False, f"vLLM present but unusable: {exc}"
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def capabilities(cls) -> Capabilities:
|
|
82
|
+
return Capabilities(
|
|
83
|
+
name=cls.name,
|
|
84
|
+
static_intervention=True,
|
|
85
|
+
dynamic_schedule=False,
|
|
86
|
+
multiple_patches=True,
|
|
87
|
+
streaming=False,
|
|
88
|
+
cpu=False,
|
|
89
|
+
cuda=True,
|
|
90
|
+
server=True,
|
|
91
|
+
concurrent_requests=True,
|
|
92
|
+
per_request_strength=False,
|
|
93
|
+
quantization=(),
|
|
94
|
+
notes={
|
|
95
|
+
"dynamic_schedule": (
|
|
96
|
+
"Continuous batching means one forward pass serves sequences at "
|
|
97
|
+
"different generation positions, so a single token index is not "
|
|
98
|
+
"well defined. Use the transformers backend for schedules."
|
|
99
|
+
),
|
|
100
|
+
"per_request_strength": (
|
|
101
|
+
"Would require per-sequence scaling inside a batched forward pass; "
|
|
102
|
+
"vLLM exposes no supported way to attribute rows of a batch to "
|
|
103
|
+
"requests, so offering it would corrupt other users' output."
|
|
104
|
+
),
|
|
105
|
+
"concurrent_requests": (
|
|
106
|
+
"Safe because patch state is immutable while serving; mutation "
|
|
107
|
+
"raises if attempted mid-serve."
|
|
108
|
+
),
|
|
109
|
+
"cpu": "This backend requires a CUDA device.",
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# -- model -----------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def load_model(
|
|
116
|
+
self,
|
|
117
|
+
model: str,
|
|
118
|
+
*,
|
|
119
|
+
revision: str | None = None,
|
|
120
|
+
dtype: str = "auto",
|
|
121
|
+
gpu_memory_utilization: float = 0.80,
|
|
122
|
+
max_model_len: int | None = 2048,
|
|
123
|
+
enforce_eager: bool = True,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Load through vLLM.
|
|
127
|
+
|
|
128
|
+
``enforce_eager=True`` by default: CUDA graph capture replays a recorded
|
|
129
|
+
graph, and a Python forward hook registered afterwards would not
|
|
130
|
+
participate. Eager costs throughput and is what makes the intervention
|
|
131
|
+
actually execute. Do not disable it without re-verifying that hooks run.
|
|
132
|
+
"""
|
|
133
|
+
import vllm
|
|
134
|
+
from vllm import LLM
|
|
135
|
+
|
|
136
|
+
self.vllm_version = vllm.__version__
|
|
137
|
+
# Supported extension point: vLLM mixes this class into each worker, so
|
|
138
|
+
# collective_rpc can call its methods BY NAME with msgpack-safe args.
|
|
139
|
+
# Passing a callable instead would require
|
|
140
|
+
# VLLM_ALLOW_INSECURE_SERIALIZATION=1, which we deliberately do not use.
|
|
141
|
+
self.llm = LLM(
|
|
142
|
+
model=model,
|
|
143
|
+
worker_extension_cls=WORKER_EXTENSION,
|
|
144
|
+
revision=revision,
|
|
145
|
+
dtype=dtype,
|
|
146
|
+
gpu_memory_utilization=gpu_memory_utilization,
|
|
147
|
+
max_model_len=max_model_len,
|
|
148
|
+
enforce_eager=enforce_eager,
|
|
149
|
+
**kwargs,
|
|
150
|
+
)
|
|
151
|
+
self.model_id = model
|
|
152
|
+
self.revision = revision
|
|
153
|
+
self._geometry = self._rpc("bp_probe")[0]
|
|
154
|
+
|
|
155
|
+
def _rpc(self, method: str, *args: Any) -> list[dict[str, Any]]:
|
|
156
|
+
"""Call a worker-extension method by name in every vLLM worker."""
|
|
157
|
+
if self.llm is None:
|
|
158
|
+
raise RuntimeError("no model loaded; call load_model() first")
|
|
159
|
+
rpc = getattr(self.llm, "collective_rpc", None)
|
|
160
|
+
if rpc is None:
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"vLLM {self.vllm_version} has no LLM.collective_rpc, which this "
|
|
163
|
+
"backend needs to reach the worker process. Supported: vLLM with "
|
|
164
|
+
"collective_rpc and worker_extension_cls (verified on 0.11.0)."
|
|
165
|
+
)
|
|
166
|
+
return list(rpc(method, args=args) if args else rpc(method))
|
|
167
|
+
|
|
168
|
+
def describe_model(self) -> ModelDescriptor:
|
|
169
|
+
if self.llm is None:
|
|
170
|
+
raise RuntimeError("no model loaded; call load_model() first")
|
|
171
|
+
geometry = self._geometry
|
|
172
|
+
archs = geometry.get("architectures") or []
|
|
173
|
+
return ModelDescriptor(
|
|
174
|
+
model_id=self.model_id,
|
|
175
|
+
hidden_size=int(geometry.get("hidden_size", 0)),
|
|
176
|
+
num_layers=int(geometry.get("num_layers", 0)),
|
|
177
|
+
architecture=archs[0] if archs else geometry.get("model_class", ""),
|
|
178
|
+
revision=self.revision,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# -- intervention ----------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _deltas_by_layer(self) -> dict[int, list[float]]:
|
|
184
|
+
"""Collapse all enabled patches into one already-scaled vector per layer."""
|
|
185
|
+
hidden = int(self._geometry.get("hidden_size", 0))
|
|
186
|
+
deltas: dict[int, list[float]] = {}
|
|
187
|
+
for edit in self.resolve_edits(0):
|
|
188
|
+
values = self.vector_values(edit.patch_name, edit.vector_key)
|
|
189
|
+
acc = deltas.setdefault(edit.layer, [0.0] * (hidden or len(values)))
|
|
190
|
+
for i, value in enumerate(values):
|
|
191
|
+
acc[i] += value * edit.coefficient
|
|
192
|
+
return deltas
|
|
193
|
+
|
|
194
|
+
def _on_patches_changed(self) -> None:
|
|
195
|
+
if self._serving:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
"refusing to mutate patch state while the vLLM server is running: "
|
|
198
|
+
"in-flight batched requests would observe an inconsistent model. "
|
|
199
|
+
"Restart the server to change patches."
|
|
200
|
+
)
|
|
201
|
+
if self.llm is None:
|
|
202
|
+
return
|
|
203
|
+
self._last_rpc = self._rpc("bp_apply_deltas", self._deltas_by_layer())
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def last_hook_report(self) -> list[dict[str, Any]]:
|
|
207
|
+
"""What the workers reported after the last hook installation.
|
|
208
|
+
|
|
209
|
+
Exposed so an integration test can *prove* the hooks landed inside vLLM
|
|
210
|
+
rather than inferring it from output changes.
|
|
211
|
+
"""
|
|
212
|
+
return list(self._last_rpc)
|
|
213
|
+
|
|
214
|
+
def worker_state(self) -> list[dict[str, Any]]:
|
|
215
|
+
"""Live probe of every worker, including active hook count."""
|
|
216
|
+
return self._rpc("bp_probe")
|
|
217
|
+
|
|
218
|
+
# -- generation ------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def generate(self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any) -> str:
|
|
221
|
+
return self.generate_batch([prompt], config, **kwargs)[0]
|
|
222
|
+
|
|
223
|
+
def generate_batch(
|
|
224
|
+
self,
|
|
225
|
+
prompts: list[str],
|
|
226
|
+
config: GenerationConfig | None = None,
|
|
227
|
+
*,
|
|
228
|
+
use_chat_template: bool = True,
|
|
229
|
+
system: str | None = None,
|
|
230
|
+
**kwargs: Any,
|
|
231
|
+
) -> list[str]:
|
|
232
|
+
"""Batched generation -- the reason to use vLLM at all.
|
|
233
|
+
|
|
234
|
+
``system`` and ``use_chat_template`` are part of the cross-backend
|
|
235
|
+
generate() contract, so they are consumed here rather than forwarded:
|
|
236
|
+
passing them through to ``llm.generate`` raises a TypeError, which is
|
|
237
|
+
how the OpenAI server first failed against this backend.
|
|
238
|
+
"""
|
|
239
|
+
from vllm import SamplingParams
|
|
240
|
+
|
|
241
|
+
# Only vLLM's own arguments may reach it.
|
|
242
|
+
kwargs.pop("apply_to_prompt", None)
|
|
243
|
+
|
|
244
|
+
if self.llm is None:
|
|
245
|
+
raise RuntimeError("no model loaded; call load_model() first")
|
|
246
|
+
cfg = config or GenerationConfig()
|
|
247
|
+
params = SamplingParams(
|
|
248
|
+
max_tokens=cfg.max_new_tokens,
|
|
249
|
+
temperature=cfg.temperature,
|
|
250
|
+
top_p=cfg.top_p,
|
|
251
|
+
top_k=cfg.top_k if cfg.top_k > 0 else -1,
|
|
252
|
+
repetition_penalty=cfg.repetition_penalty,
|
|
253
|
+
seed=cfg.seed if cfg.do_sample else None,
|
|
254
|
+
stop=cfg.stop or None,
|
|
255
|
+
)
|
|
256
|
+
rendered = (
|
|
257
|
+
[self._render(p, system) for p in prompts] if use_chat_template else list(prompts)
|
|
258
|
+
)
|
|
259
|
+
with self._lock:
|
|
260
|
+
outputs = self.llm.generate(rendered, params, **kwargs)
|
|
261
|
+
return [o.outputs[0].text for o in outputs]
|
|
262
|
+
|
|
263
|
+
def _render(self, prompt: str, system: str | None = None) -> str:
|
|
264
|
+
tokenizer = self.llm.get_tokenizer()
|
|
265
|
+
if getattr(tokenizer, "chat_template", None) is None:
|
|
266
|
+
return prompt
|
|
267
|
+
messages = []
|
|
268
|
+
if system:
|
|
269
|
+
messages.append({"role": "system", "content": system})
|
|
270
|
+
messages.append({"role": "user", "content": prompt})
|
|
271
|
+
return tokenizer.apply_chat_template(
|
|
272
|
+
messages, tokenize=False, add_generation_prompt=True
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def stream(
|
|
276
|
+
self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
|
|
277
|
+
) -> Iterator[str]:
|
|
278
|
+
self.capabilities().require("streaming")
|
|
279
|
+
yield "" # pragma: no cover - unreachable; require() raises
|
|
280
|
+
|
|
281
|
+
# -- serving ---------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def begin_serving(self) -> None:
|
|
284
|
+
"""Freeze patch state for the lifetime of a server."""
|
|
285
|
+
self._serving = True
|
|
286
|
+
|
|
287
|
+
def end_serving(self) -> None:
|
|
288
|
+
self._serving = False
|
|
289
|
+
|
|
290
|
+
def unload(self) -> None:
|
|
291
|
+
if self.llm is not None:
|
|
292
|
+
try:
|
|
293
|
+
self._rpc("bp_apply_deltas", {})
|
|
294
|
+
except Exception: # noqa: BLE001 - teardown must not mask a real error
|
|
295
|
+
pass
|
|
296
|
+
self.llm = None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
BACKEND = VLLMBackend
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Worker-side extension that installs BrainPatch hooks inside vLLM.
|
|
2
|
+
|
|
3
|
+
vLLM's V1 engine runs the model in a separate process, and its RPC channel
|
|
4
|
+
serializes with msgpack -- it will **not** ship a callable, and says so:
|
|
5
|
+
|
|
6
|
+
TypeError: Object of type <class 'function'> is not serializable
|
|
7
|
+
Set VLLM_ALLOW_INSECURE_SERIALIZATION=1 to allow fallback to pickle
|
|
8
|
+
|
|
9
|
+
Enabling pickle would work and is the wrong answer: it turns the engine's
|
|
10
|
+
control channel into an arbitrary-code path for the sake of our convenience.
|
|
11
|
+
The supported mechanism is ``worker_extension_cls`` -- vLLM mixes this class
|
|
12
|
+
into the worker, and ``collective_rpc("method_name", args=...)`` then calls it
|
|
13
|
+
by **name** with plain msgpack-serializable arguments.
|
|
14
|
+
|
|
15
|
+
So the payload here is a ``{layer_index: [floats]}`` map of already-scaled
|
|
16
|
+
deltas. That suffices because this backend supports neither token schedules nor
|
|
17
|
+
per-request strength, so the delta is constant across a forward pass -- which is
|
|
18
|
+
also exactly what makes concurrent batching safe.
|
|
19
|
+
|
|
20
|
+
Method names are prefixed ``bp_`` to avoid colliding with vLLM's own worker API.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
_LAYER_PATHS = ("model.layers", "layers", "transformer.h", "model.decoder.layers")
|
|
28
|
+
|
|
29
|
+
#: Set on the worker-side model so repeated calls replace rather than stack hooks.
|
|
30
|
+
_HOOK_ATTR = "_brainpatch_handles"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BrainPatchWorkerExtension:
|
|
34
|
+
"""Mixed into the vLLM worker; ``self`` is the worker instance."""
|
|
35
|
+
|
|
36
|
+
def _bp_model(self) -> Any:
|
|
37
|
+
for path in ("model_runner.model", "worker.model_runner.model"):
|
|
38
|
+
node: Any = self
|
|
39
|
+
for part in path.split("."):
|
|
40
|
+
node = getattr(node, part, None)
|
|
41
|
+
if node is None:
|
|
42
|
+
break
|
|
43
|
+
if node is not None:
|
|
44
|
+
return node
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
f"could not locate the model on vLLM worker {type(self).__name__}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _bp_layers(self, model: Any) -> Any:
|
|
50
|
+
import torch.nn as nn
|
|
51
|
+
|
|
52
|
+
for path in _LAYER_PATHS:
|
|
53
|
+
node: Any = model
|
|
54
|
+
for part in path.split("."):
|
|
55
|
+
node = getattr(node, part, None)
|
|
56
|
+
if node is None:
|
|
57
|
+
break
|
|
58
|
+
if isinstance(node, nn.ModuleList) and len(node) > 0:
|
|
59
|
+
return node
|
|
60
|
+
raise RuntimeError(f"could not locate decoder blocks on {type(model).__name__}")
|
|
61
|
+
|
|
62
|
+
def bp_probe(self) -> dict[str, Any]:
|
|
63
|
+
"""Report worker-side model geometry and live hook count."""
|
|
64
|
+
model = self._bp_model()
|
|
65
|
+
layers = self._bp_layers(model)
|
|
66
|
+
config = getattr(model, "config", None)
|
|
67
|
+
return {
|
|
68
|
+
"model_class": type(model).__name__,
|
|
69
|
+
"num_layers": len(layers),
|
|
70
|
+
"hidden_size": int(getattr(config, "hidden_size", 0)) if config else 0,
|
|
71
|
+
"architectures": list(getattr(config, "architectures", []) or []) if config else [],
|
|
72
|
+
"active_hooks": len(getattr(model, _HOOK_ATTR, [])),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def bp_apply_deltas(self, deltas: dict[int, list[float]]) -> dict[str, Any]:
|
|
76
|
+
"""Install (or replace) residual-stream hooks. Empty dict removes all.
|
|
77
|
+
|
|
78
|
+
Returns a report so the caller can *verify* the hooks landed inside the
|
|
79
|
+
worker rather than infer it from output changes -- a silently failed RPC
|
|
80
|
+
and a patch with no effect look identical from outside.
|
|
81
|
+
"""
|
|
82
|
+
import torch
|
|
83
|
+
|
|
84
|
+
model = self._bp_model()
|
|
85
|
+
layers = self._bp_layers(model)
|
|
86
|
+
|
|
87
|
+
for handle in getattr(model, _HOOK_ATTR, []):
|
|
88
|
+
handle.remove()
|
|
89
|
+
setattr(model, _HOOK_ATTR, [])
|
|
90
|
+
|
|
91
|
+
if not deltas:
|
|
92
|
+
return {**self.bp_probe(), "num_hooks": 0}
|
|
93
|
+
|
|
94
|
+
parameter = next(model.parameters())
|
|
95
|
+
device, dtype = parameter.device, parameter.dtype
|
|
96
|
+
|
|
97
|
+
handles = []
|
|
98
|
+
# msgpack may deliver dict keys as strings; normalise before indexing.
|
|
99
|
+
for raw_layer, values in sorted(((int(k), v) for k, v in deltas.items())):
|
|
100
|
+
if raw_layer >= len(layers):
|
|
101
|
+
raise RuntimeError(f"layer {raw_layer} out of range ({len(layers)} blocks)")
|
|
102
|
+
vector = torch.tensor(values, dtype=dtype, device=device)
|
|
103
|
+
handles.append(layers[raw_layer].register_forward_hook(_make_hook(vector)))
|
|
104
|
+
|
|
105
|
+
setattr(model, _HOOK_ATTR, handles)
|
|
106
|
+
return {
|
|
107
|
+
**self.bp_probe(),
|
|
108
|
+
"num_hooks": len(handles),
|
|
109
|
+
"hooked_layers": sorted(int(k) for k in deltas),
|
|
110
|
+
"device": str(device),
|
|
111
|
+
"dtype": str(dtype),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _make_hook(vector: Any) -> Any:
|
|
116
|
+
"""Forward hook adding ``vector`` to a decoder block's hidden states."""
|
|
117
|
+
import torch
|
|
118
|
+
|
|
119
|
+
def hook(module: Any, args: Any, output: Any) -> Any:
|
|
120
|
+
if isinstance(output, tuple):
|
|
121
|
+
hidden = output[0]
|
|
122
|
+
if not isinstance(hidden, torch.Tensor):
|
|
123
|
+
return output
|
|
124
|
+
return (hidden + vector, *output[1:])
|
|
125
|
+
if isinstance(output, torch.Tensor):
|
|
126
|
+
return output + vector
|
|
127
|
+
return output
|
|
128
|
+
|
|
129
|
+
return hook
|