hotwire-vllm 0.0.2__tar.gz
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.
- hotwire_vllm-0.0.2/.github/workflows/test.yml +22 -0
- hotwire_vllm-0.0.2/.gitignore +11 -0
- hotwire_vllm-0.0.2/AGENTS.md +42 -0
- hotwire_vllm-0.0.2/LICENSE +21 -0
- hotwire_vllm-0.0.2/PKG-INFO +267 -0
- hotwire_vllm-0.0.2/README.md +255 -0
- hotwire_vllm-0.0.2/benchmarks/bench_decode.py +79 -0
- hotwire_vllm-0.0.2/hotwire/AGENTS.md +42 -0
- hotwire_vllm-0.0.2/hotwire/__init__.py +26 -0
- hotwire_vllm-0.0.2/hotwire/_bank.py +46 -0
- hotwire_vllm-0.0.2/hotwire/_kernel.py +54 -0
- hotwire_vllm-0.0.2/hotwire/_patch.py +369 -0
- hotwire_vllm-0.0.2/hotwire/_state.py +92 -0
- hotwire_vllm-0.0.2/hotwire/verify.py +116 -0
- hotwire_vllm-0.0.2/hotwire/wire.py +41 -0
- hotwire_vllm-0.0.2/pyproject.toml +33 -0
- hotwire_vllm-0.0.2/tests/test_bank.py +49 -0
- hotwire_vllm-0.0.2/tests/test_fill.py +129 -0
- hotwire_vllm-0.0.2/tests/test_integration_vllm.py +84 -0
- hotwire_vllm-0.0.2/tests/test_kernel.py +67 -0
- hotwire_vllm-0.0.2/tests/test_state.py +48 -0
- hotwire_vllm-0.0.2/tests/test_wire.py +30 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
unit:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- name: Install (CPU torch + triton)
|
|
17
|
+
run: |
|
|
18
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
19
|
+
pip install triton pytest
|
|
20
|
+
pip install -e .
|
|
21
|
+
- name: Unit tests (GPU + integration tests auto-skip)
|
|
22
|
+
run: pytest -q
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Using hotwire-vllm (guide for coding agents)
|
|
2
|
+
|
|
3
|
+
You are helping someone add per-request activation steering to a vLLM server
|
|
4
|
+
with `hotwire-vllm`. It is a vLLM plugin — small surface, but the details
|
|
5
|
+
below are the ones agents get wrong.
|
|
6
|
+
|
|
7
|
+
## Install and load
|
|
8
|
+
|
|
9
|
+
pip install hotwire-vllm
|
|
10
|
+
|
|
11
|
+
It auto-registers via the `vllm.general_plugins` entry point when vLLM
|
|
12
|
+
starts — nothing to import in user code. If steering silently does nothing,
|
|
13
|
+
the plugin did not load: confirm vLLM sees the entry point.
|
|
14
|
+
|
|
15
|
+
## Steer one request
|
|
16
|
+
|
|
17
|
+
Steering rides on each request as a `vllm_xargs` field. The `hotwire` value is
|
|
18
|
+
a JSON **string** (stringified spec), not a nested object:
|
|
19
|
+
|
|
20
|
+
{"messages": [...],
|
|
21
|
+
"vllm_xargs": {"hotwire": "{\"id\": \"NAME\", \"layer\": 20, \"scale\": 1.5}"}}
|
|
22
|
+
|
|
23
|
+
Multiple layers: pass a JSON list of such specs. Add `"decode_only": true` to
|
|
24
|
+
steer only generated tokens.
|
|
25
|
+
|
|
26
|
+
## The gotchas that actually bite
|
|
27
|
+
|
|
28
|
+
- **Slots are a fixed budget.** Each distinct steering config lives in a
|
|
29
|
+
fixed-size GPU slot bank; distinct (layer, scale) combos each consume one
|
|
30
|
+
persistent slot. Don't sweep hundreds of combos on a live server — you will
|
|
31
|
+
exhaust the bank. Reuse configs; size the sweep to the slot budget.
|
|
32
|
+
- **Vectors must be registered** with the server (wire format is JSON /
|
|
33
|
+
safetensors, no pickle). An unknown id degrades to *unsteered*, never a
|
|
34
|
+
failed request — so "no effect" usually means "id not registered" or
|
|
35
|
+
"plugin not loaded", not "steering broke".
|
|
36
|
+
- **Malformed specs degrade to unsteered**, silently. Validate your spec JSON.
|
|
37
|
+
- Unsteered requests (including batchmates of steered ones) are untouched, and
|
|
38
|
+
idle steering is zero-cost — CUDA graphs stay intact.
|
|
39
|
+
|
|
40
|
+
Calibrate the vector first with
|
|
41
|
+
[hidden-directions](https://github.com/moudrkat/hidden-directions) (it produces
|
|
42
|
+
the `{id, layer, scale, decode_only}` spec you paste here).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Katerina Fajmanova
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotwire-vllm
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: CUDA-graph-safe activation steering plugin for vLLM
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: numpy
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# hotwire
|
|
14
|
+
|
|
15
|
+
[](https://pypi.org/project/hotwire-vllm/)
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pip install hotwire-vllm
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Activation steering for vLLM that doesn't turn off the engine.**
|
|
22
|
+
|
|
23
|
+
For teams running vLLM in production who need per-request activation
|
|
24
|
+
steering at native speed — CUDA graphs and torch.compile intact.
|
|
25
|
+
|
|
26
|
+
Every existing steering tool for vLLM ([vllm-lens](https://github.com/UKGovernmentBEIS/vllm-lens),
|
|
27
|
+
[EasySteer](https://arxiv.org/abs/2509.25175), IBM's vLLM Hook) forces
|
|
28
|
+
`enforce_eager=True`: PyTorch forward hooks don't survive CUDA graph capture, so
|
|
29
|
+
they disable CUDA graphs and torch.compile for the whole server — every request
|
|
30
|
+
pays, steered or not. Fine for research, a non-starter for production.
|
|
31
|
+
|
|
32
|
+
hotwire keeps the graphs. The steering addition is a custom torch op (Triton
|
|
33
|
+
kernel) that gets baked *into* the captured graph; per-request routing happens
|
|
34
|
+
by updating the contents of persistent GPU buffers between graph replays —
|
|
35
|
+
the graph reads fresh data at the same addresses.
|
|
36
|
+
|
|
37
|
+
The technique was proven viable in [RhizoNymph's vLLM fork](https://github.com/RhizoNymph/vllm)
|
|
38
|
+
(see [RFC #36998](https://github.com/vllm-project/vllm/issues/36998), where
|
|
39
|
+
in-flight steering is explicitly deferred to "Phase 2"). hotwire packages it as
|
|
40
|
+
an out-of-tree plugin: `pip install`, no fork, registered via vLLM's official
|
|
41
|
+
`general_plugins` entry point.
|
|
42
|
+
|
|
43
|
+
## ⚡ Run in 30 s
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e . # registers the vllm.general_plugins entry point
|
|
47
|
+
export HOTWIRE_VECTORS=/path/to/vectors # dir of .pt files, (n_layers, hidden) each
|
|
48
|
+
vllm serve Qwen/Qwen3-4B-Instruct-2507 # CUDA graphs stay ON
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Steer any request by id + layer + scale:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# offline
|
|
55
|
+
SamplingParams(extra_args={"hotwire": '{"id": "tesla_car", "layer": 20, "scale": 1.5}'})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Optional per-entry flag `"decode_only": true` steers generated tokens only,
|
|
59
|
+
never the prompt — use it for vectors calibrated on generation-only steering
|
|
60
|
+
(research rigs typically don't steer the prefill; applying such a vector to a
|
|
61
|
+
long prompt as well multiplies the effective dose and can wreck coherence).
|
|
62
|
+
|
|
63
|
+
Lab twin: [brainscope](https://github.com/moudrkat/brainscope) accepts this
|
|
64
|
+
exact spec and wire format — calibrate a vector under its lenses (its
|
|
65
|
+
`export_hotwire` ships `.pt` files with a regime passport), deploy it here
|
|
66
|
+
unchanged, and replay production conversations back under the lens when a
|
|
67
|
+
vector misbehaves.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# OpenAI API
|
|
71
|
+
curl .../v1/chat/completions -d '{..., "vllm_xargs":
|
|
72
|
+
{"hotwire": "{\"id\": \"tesla_car\", \"layer\": 20, \"scale\": 1.5}"}}'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Unsteered requests — including batchmates of steered ones — are untouched.
|
|
76
|
+
Malformed specs and unknown vector ids degrade to "unsteered", never to a
|
|
77
|
+
failed request.
|
|
78
|
+
|
|
79
|
+
## Design
|
|
80
|
+
|
|
81
|
+
Three persistent GPU tensors, allocated at model-load time:
|
|
82
|
+
|
|
83
|
+
| buffer | shape | role |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `bank` | `(n_slots, hidden)` | steering vectors, one per active slot |
|
|
86
|
+
| `scales` | `(n_slots,)` | per-slot multiplier |
|
|
87
|
+
| `slot_map` | `(n_layers, max_tokens)` | token → slot per layer, `-1` = untouched |
|
|
88
|
+
|
|
89
|
+
The op, called at the end of each decoder layer's forward:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
hidden[tok] += scales[slot] * bank[slot] where slot = slot_map[layer, tok] >= 0
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **Graph-safe:** the op is `torch.library.custom_op` with a fake impl —
|
|
96
|
+
opaque to torch.compile, captured into CUDA graphs as a fixed kernel on
|
|
97
|
+
fixed addresses. Steering on/off/vector changes are buffer *content*
|
|
98
|
+
updates between replays (host-side copy), never a re-capture.
|
|
99
|
+
- **Per-request:** a pre-forward hook reads `forward_context`
|
|
100
|
+
(`query_start_loc` + `req_ids`, same bookkeeping vllm-lens validated)
|
|
101
|
+
and fills `slot_map` for the step.
|
|
102
|
+
- **No pickle:** vectors enter as safetensors files or base64 JSON via a
|
|
103
|
+
registration endpoint (`POST /steer/vectors`), requests reference them by
|
|
104
|
+
id + scale in `vllm_xargs`. Nothing executable crosses the wire.
|
|
105
|
+
- **Zero cost when idle:** `slot_map` all `-1` → kernel early-exits per token.
|
|
106
|
+
(Benchmark target: unmeasurable vs baseline; RhizoNymph reported minimal
|
|
107
|
+
overhead on H100.)
|
|
108
|
+
|
|
109
|
+
## Layout
|
|
110
|
+
|
|
111
|
+
- `hotwire/_kernel.py` — Triton kernel + `hotwire::steer` custom op (working)
|
|
112
|
+
- `hotwire/_bank.py` — slot allocation, vector registration (working)
|
|
113
|
+
- `hotwire/_patch.py` — decoder-layer wrapping + pre-forward slot fill (WIP:
|
|
114
|
+
integration points against vLLM 0.25.x)
|
|
115
|
+
- `hotwire/wire.py` — JSON/safetensors vector wire format, no pickle (working)
|
|
116
|
+
|
|
117
|
+
## Verify on your hardware
|
|
118
|
+
|
|
119
|
+
Two commands, ~3 minutes on any CUDA box with vLLM installed:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install git+https://github.com/moudrkat/hotwire-vllm
|
|
123
|
+
python -m hotwire.verify --model Qwen/Qwen3-0.6B # any HF model id works
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
It generates a throwaway steering vector for the model, checks that steering
|
|
127
|
+
fires, that unsteered requests (including batchmates) are untouched, and
|
|
128
|
+
compares decode cost idle vs all-steered — then prints a report block.
|
|
129
|
+
**Please paste the report into an issue**, especially from hardware, model
|
|
130
|
+
families, or configs (TP > 1, 7B+, H100s) the tables below don't cover yet —
|
|
131
|
+
that's currently the most useful contribution this project can receive.
|
|
132
|
+
|
|
133
|
+
## Status
|
|
134
|
+
|
|
135
|
+
Working end-to-end on vLLM 0.25.1, **both model runners** (the classic
|
|
136
|
+
`GPUModelRunner` and the new V2 runner that 0.25.1 selects by default for
|
|
137
|
+
dense generate models), with CUDA graphs captured (PIECEWISE + FULL) and
|
|
138
|
+
torch.compile on. Verified on Qwen3-0.6B / Qwen3-4B on a single 16 GB GPU:
|
|
139
|
+
solo steering, mixed batches, decode-phase graph replays.
|
|
140
|
+
|
|
141
|
+
hotwire also salts vLLM's torch.compile/AOT cache key (`VllmConfig.compute_hash`)
|
|
142
|
+
— the op is traced into the compiled model, and vLLM's cache doesn't know about
|
|
143
|
+
plugins, so without the salt a stale cache silently serves a model with no
|
|
144
|
+
steering op in it.
|
|
145
|
+
|
|
146
|
+
Tests: `pytest` (unit, CPU-safe), `pytest -m integration` (real engine, GPU).
|
|
147
|
+
|
|
148
|
+
Verified architectures (chaos-vector A/B + batchmate-isolation check, both
|
|
149
|
+
model runners exercised):
|
|
150
|
+
|
|
151
|
+
| model | steering works | unsteered untouched | TPOT idle → all-steered |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| Qwen3-14B-AWQ (4-bit) | ✓ | ✓ | 1.91 → 1.91 ms/tok |
|
|
154
|
+
| Llama-3.1-8B-Instruct-AWQ | ✓ | ✓ | 1.10 → 1.10 ms/tok |
|
|
155
|
+
| Qwen3-8B-FP8 | ✓ | ✓ | 2.27 → 2.28 ms/tok |
|
|
156
|
+
| Mistral-7B-Instruct-v0.2-AWQ | ✓ | ✓ | 0.94 → 0.94 ms/tok |
|
|
157
|
+
| Qwen3-4B-Instruct-2507 | ✓ | ✓ | 1.78 → 1.78 ms/tok |
|
|
158
|
+
| Qwen3-0.6B | ✓ | ✓ | — |
|
|
159
|
+
| Qwen2.5-1.5B-Instruct | ✓ | ✓ | 0.77 → 0.77 ms/tok |
|
|
160
|
+
| Phi-3.5-mini-instruct | ✓ | ✓ | 1.73 → 1.73 ms/tok |
|
|
161
|
+
| tiny-aya-water (Cohere) | ✓ | ✓ | 1.54 → 1.54 ms/tok |
|
|
162
|
+
|
|
163
|
+
Quantized checkpoints work — steering touches the residual stream, not the
|
|
164
|
+
weights, and the AWQ / FP8 rows above confirm it end-to-end, CUDA graphs
|
|
165
|
+
captured (PIECEWISE + FULL).
|
|
166
|
+
|
|
167
|
+
Models that still OOM on the 16 GB test GPU before the plugin engages:
|
|
168
|
+
OLMo-2-7B, command-r7b, Qwen3.5-4B, gpt-oss-20b (13.8 GiB weight load
|
|
169
|
+
succeeds, engine init doesn't), gemma-4-E4B-it (vision tower). No
|
|
170
|
+
architecture failure observed yet; reports from bigger cards welcome. The
|
|
171
|
+
layer patch targets any `*DecoderLayer` module with the standard
|
|
172
|
+
`(positions, hidden_states, residual)` signature.
|
|
173
|
+
|
|
174
|
+
## Numbers
|
|
175
|
+
|
|
176
|
+
Qwen3-4B-Instruct-2507, bf16, RTX 4070 Ti SUPER 16 GB, 8 concurrent requests,
|
|
177
|
+
256 decode tokens each, medians of 3 (`benchmarks/bench_decode.py`):
|
|
178
|
+
|
|
179
|
+
| condition | TTFT | decode TPOT |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| vanilla vLLM (plugin not installed) | 4.9 ms | 1.78 ms/tok |
|
|
182
|
+
| hotwire installed, no request steered | 4.9 ms | 1.78 ms/tok |
|
|
183
|
+
| hotwire, **all 8 requests steered** | 4.6 ms | 1.78 ms/tok |
|
|
184
|
+
| vLLM `enforce_eager` (no plugin) | 5.0 ms | 1.88 ms/tok |
|
|
185
|
+
|
|
186
|
+
Idle and fully-steered are both within noise of vanilla. The eager row is what
|
|
187
|
+
hook-based steering tools pay *before* their Python hooks even run.
|
|
188
|
+
|
|
189
|
+
Batch sweep (same model/GPU): the eager tax grows with batch pressure —
|
|
190
|
+
+2.3% at 1 request (batch-1 decode is weight-streaming-bound, which hides
|
|
191
|
+
launch overhead), +4.7% at 2, +5.6% at 8. hotwire's idle == steered holds at
|
|
192
|
+
every batch size, to the second decimal.
|
|
193
|
+
|
|
194
|
+
| batch | graphs idle | graphs all-steered | eager |
|
|
195
|
+
|---|---|---|---|
|
|
196
|
+
| 1 | 13.69 ms/tok | 13.69 | 14.00 |
|
|
197
|
+
| 2 | 6.97 ms/tok | 6.97 | 7.30 |
|
|
198
|
+
| 8 | 1.78 ms/tok | 1.78 | 1.88 |
|
|
199
|
+
|
|
200
|
+
Untested configurations (no known issues, but nobody has run them — treat as
|
|
201
|
+
unsupported until someone does): tensor parallel > 1, pipeline parallel,
|
|
202
|
+
speculative decoding, LoRA, GPTQ and MXFP4 quantization (AWQ and FP8 are
|
|
203
|
+
verified — see the table). Issues welcome.
|
|
204
|
+
|
|
205
|
+
Known limitation: one vector per (layer, token) — multiple spec entries
|
|
206
|
+
targeting the **same layer** don't stack; the last one wins. Different layers
|
|
207
|
+
compose fine. Workaround: pre-combine same-layer vectors into one .pt
|
|
208
|
+
(`a*v1 + b*v2`) and register the combo; native stacking is on the roadmap.
|
|
209
|
+
|
|
210
|
+
Known limitation: the slot budget. Steering configs live in a fixed-size GPU
|
|
211
|
+
table allocated before graph capture — CUDA graphs read fixed addresses, so
|
|
212
|
+
it can never grow at runtime. Size it with `HOTWIRE_SLOTS` (default 16;
|
|
213
|
+
a slot is one vector row, ~5 KB on a 4B model, so 256 costs ~1.3 MB and
|
|
214
|
+
nothing per token). Each distinct **(vector, layer, scale)** combo occupies
|
|
215
|
+
one slot **permanently** — nothing frees slots when requests finish. A fixed
|
|
216
|
+
catalog of vectors at fixed scales therefore runs forever, but continuously
|
|
217
|
+
varying scales (0.80, 0.83, 0.87, …) mint a fresh slot each and exhaust the
|
|
218
|
+
table; once full, requests with an unregistrable combo run unsteered (logged)
|
|
219
|
+
while already-registered combos keep working, batchmates included.
|
|
220
|
+
Workaround today: round scales to a small fixed
|
|
221
|
+
palette and set `HOTWIRE_SLOTS` generously at startup. The real fixes are on
|
|
222
|
+
the roadmap below — slots *can* recycle (the scale isn't baked into the
|
|
223
|
+
stored vector; the kernel reads it separately at replay), it's bookkeeping,
|
|
224
|
+
not graph physics.
|
|
225
|
+
|
|
226
|
+
Roadmap:
|
|
227
|
+
- HTTP vector registration at runtime (via `vllm.endpoint_plugins`), replacing
|
|
228
|
+
startup-only `$HOTWIRE_VECTORS`.
|
|
229
|
+
- Slot eviction: refcount slots per in-flight request and `release()` when the
|
|
230
|
+
last user of a combo finishes, so the table recycles instead of filling.
|
|
231
|
+
- Per-token scales: key slots by (vector, layer) only and move scale into a
|
|
232
|
+
per-token buffer — continuous intensities without minting new slots.
|
|
233
|
+
- Norm-matched and position-targeted steering modes.
|
|
234
|
+
- Tracking the RFC vllm-project/vllm#36998 Phase 2 interface as it lands.
|
|
235
|
+
## Where this sits in the lab
|
|
236
|
+
|
|
237
|
+
```mermaid
|
|
238
|
+
flowchart LR
|
|
239
|
+
hd["🧭 hidden-directions<br/>behavior → vector"]
|
|
240
|
+
bs(["🧠 brainscope<br/>watch the model think"])
|
|
241
|
+
hw["🔥 hotwire-vllm<br/>steering in production"]
|
|
242
|
+
st["🕹️ steeropathy<br/>agents talk via activations"]
|
|
243
|
+
tm["⚖️ in-two-minds<br/>agent hesitating between tools"]
|
|
244
|
+
sm["🧪 steering-mechanics<br/>how steering actually works"]
|
|
245
|
+
|
|
246
|
+
hd -->|vectors| bs
|
|
247
|
+
hd -->|vector + passport| hw
|
|
248
|
+
bs --> st
|
|
249
|
+
bs --> tm
|
|
250
|
+
bs -->|causal replay| sm
|
|
251
|
+
hw -.->|vector under study| sm
|
|
252
|
+
|
|
253
|
+
click hd "https://github.com/moudrkat/hidden-directions"
|
|
254
|
+
click bs "https://github.com/moudrkat/brainscope"
|
|
255
|
+
click hw "https://github.com/moudrkat/hotwire-vllm"
|
|
256
|
+
click st "https://github.com/moudrkat/steeropathy"
|
|
257
|
+
click tm "https://github.com/moudrkat/in-two-minds"
|
|
258
|
+
click sm "https://github.com/moudrkat/steering-mechanics"
|
|
259
|
+
|
|
260
|
+
classDef dim fill:#f6f8fa,stroke:#d0d7de,color:#57606a;
|
|
261
|
+
classDef here fill:#8957e5,stroke:#6e40c9,color:#ffffff;
|
|
262
|
+
class hd,bs,hw,st,tm,sm dim;
|
|
263
|
+
class hw here;
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
*Highlighted = this repo. The full lab map (with the two other repos' stories) lives on [moudrkat](https://github.com/moudrkat).*
|
|
267
|
+
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# hotwire
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/hotwire-vllm/)
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install hotwire-vllm
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**Activation steering for vLLM that doesn't turn off the engine.**
|
|
10
|
+
|
|
11
|
+
For teams running vLLM in production who need per-request activation
|
|
12
|
+
steering at native speed — CUDA graphs and torch.compile intact.
|
|
13
|
+
|
|
14
|
+
Every existing steering tool for vLLM ([vllm-lens](https://github.com/UKGovernmentBEIS/vllm-lens),
|
|
15
|
+
[EasySteer](https://arxiv.org/abs/2509.25175), IBM's vLLM Hook) forces
|
|
16
|
+
`enforce_eager=True`: PyTorch forward hooks don't survive CUDA graph capture, so
|
|
17
|
+
they disable CUDA graphs and torch.compile for the whole server — every request
|
|
18
|
+
pays, steered or not. Fine for research, a non-starter for production.
|
|
19
|
+
|
|
20
|
+
hotwire keeps the graphs. The steering addition is a custom torch op (Triton
|
|
21
|
+
kernel) that gets baked *into* the captured graph; per-request routing happens
|
|
22
|
+
by updating the contents of persistent GPU buffers between graph replays —
|
|
23
|
+
the graph reads fresh data at the same addresses.
|
|
24
|
+
|
|
25
|
+
The technique was proven viable in [RhizoNymph's vLLM fork](https://github.com/RhizoNymph/vllm)
|
|
26
|
+
(see [RFC #36998](https://github.com/vllm-project/vllm/issues/36998), where
|
|
27
|
+
in-flight steering is explicitly deferred to "Phase 2"). hotwire packages it as
|
|
28
|
+
an out-of-tree plugin: `pip install`, no fork, registered via vLLM's official
|
|
29
|
+
`general_plugins` entry point.
|
|
30
|
+
|
|
31
|
+
## ⚡ Run in 30 s
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e . # registers the vllm.general_plugins entry point
|
|
35
|
+
export HOTWIRE_VECTORS=/path/to/vectors # dir of .pt files, (n_layers, hidden) each
|
|
36
|
+
vllm serve Qwen/Qwen3-4B-Instruct-2507 # CUDA graphs stay ON
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Steer any request by id + layer + scale:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# offline
|
|
43
|
+
SamplingParams(extra_args={"hotwire": '{"id": "tesla_car", "layer": 20, "scale": 1.5}'})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Optional per-entry flag `"decode_only": true` steers generated tokens only,
|
|
47
|
+
never the prompt — use it for vectors calibrated on generation-only steering
|
|
48
|
+
(research rigs typically don't steer the prefill; applying such a vector to a
|
|
49
|
+
long prompt as well multiplies the effective dose and can wreck coherence).
|
|
50
|
+
|
|
51
|
+
Lab twin: [brainscope](https://github.com/moudrkat/brainscope) accepts this
|
|
52
|
+
exact spec and wire format — calibrate a vector under its lenses (its
|
|
53
|
+
`export_hotwire` ships `.pt` files with a regime passport), deploy it here
|
|
54
|
+
unchanged, and replay production conversations back under the lens when a
|
|
55
|
+
vector misbehaves.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# OpenAI API
|
|
59
|
+
curl .../v1/chat/completions -d '{..., "vllm_xargs":
|
|
60
|
+
{"hotwire": "{\"id\": \"tesla_car\", \"layer\": 20, \"scale\": 1.5}"}}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Unsteered requests — including batchmates of steered ones — are untouched.
|
|
64
|
+
Malformed specs and unknown vector ids degrade to "unsteered", never to a
|
|
65
|
+
failed request.
|
|
66
|
+
|
|
67
|
+
## Design
|
|
68
|
+
|
|
69
|
+
Three persistent GPU tensors, allocated at model-load time:
|
|
70
|
+
|
|
71
|
+
| buffer | shape | role |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `bank` | `(n_slots, hidden)` | steering vectors, one per active slot |
|
|
74
|
+
| `scales` | `(n_slots,)` | per-slot multiplier |
|
|
75
|
+
| `slot_map` | `(n_layers, max_tokens)` | token → slot per layer, `-1` = untouched |
|
|
76
|
+
|
|
77
|
+
The op, called at the end of each decoder layer's forward:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
hidden[tok] += scales[slot] * bank[slot] where slot = slot_map[layer, tok] >= 0
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- **Graph-safe:** the op is `torch.library.custom_op` with a fake impl —
|
|
84
|
+
opaque to torch.compile, captured into CUDA graphs as a fixed kernel on
|
|
85
|
+
fixed addresses. Steering on/off/vector changes are buffer *content*
|
|
86
|
+
updates between replays (host-side copy), never a re-capture.
|
|
87
|
+
- **Per-request:** a pre-forward hook reads `forward_context`
|
|
88
|
+
(`query_start_loc` + `req_ids`, same bookkeeping vllm-lens validated)
|
|
89
|
+
and fills `slot_map` for the step.
|
|
90
|
+
- **No pickle:** vectors enter as safetensors files or base64 JSON via a
|
|
91
|
+
registration endpoint (`POST /steer/vectors`), requests reference them by
|
|
92
|
+
id + scale in `vllm_xargs`. Nothing executable crosses the wire.
|
|
93
|
+
- **Zero cost when idle:** `slot_map` all `-1` → kernel early-exits per token.
|
|
94
|
+
(Benchmark target: unmeasurable vs baseline; RhizoNymph reported minimal
|
|
95
|
+
overhead on H100.)
|
|
96
|
+
|
|
97
|
+
## Layout
|
|
98
|
+
|
|
99
|
+
- `hotwire/_kernel.py` — Triton kernel + `hotwire::steer` custom op (working)
|
|
100
|
+
- `hotwire/_bank.py` — slot allocation, vector registration (working)
|
|
101
|
+
- `hotwire/_patch.py` — decoder-layer wrapping + pre-forward slot fill (WIP:
|
|
102
|
+
integration points against vLLM 0.25.x)
|
|
103
|
+
- `hotwire/wire.py` — JSON/safetensors vector wire format, no pickle (working)
|
|
104
|
+
|
|
105
|
+
## Verify on your hardware
|
|
106
|
+
|
|
107
|
+
Two commands, ~3 minutes on any CUDA box with vLLM installed:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pip install git+https://github.com/moudrkat/hotwire-vllm
|
|
111
|
+
python -m hotwire.verify --model Qwen/Qwen3-0.6B # any HF model id works
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
It generates a throwaway steering vector for the model, checks that steering
|
|
115
|
+
fires, that unsteered requests (including batchmates) are untouched, and
|
|
116
|
+
compares decode cost idle vs all-steered — then prints a report block.
|
|
117
|
+
**Please paste the report into an issue**, especially from hardware, model
|
|
118
|
+
families, or configs (TP > 1, 7B+, H100s) the tables below don't cover yet —
|
|
119
|
+
that's currently the most useful contribution this project can receive.
|
|
120
|
+
|
|
121
|
+
## Status
|
|
122
|
+
|
|
123
|
+
Working end-to-end on vLLM 0.25.1, **both model runners** (the classic
|
|
124
|
+
`GPUModelRunner` and the new V2 runner that 0.25.1 selects by default for
|
|
125
|
+
dense generate models), with CUDA graphs captured (PIECEWISE + FULL) and
|
|
126
|
+
torch.compile on. Verified on Qwen3-0.6B / Qwen3-4B on a single 16 GB GPU:
|
|
127
|
+
solo steering, mixed batches, decode-phase graph replays.
|
|
128
|
+
|
|
129
|
+
hotwire also salts vLLM's torch.compile/AOT cache key (`VllmConfig.compute_hash`)
|
|
130
|
+
— the op is traced into the compiled model, and vLLM's cache doesn't know about
|
|
131
|
+
plugins, so without the salt a stale cache silently serves a model with no
|
|
132
|
+
steering op in it.
|
|
133
|
+
|
|
134
|
+
Tests: `pytest` (unit, CPU-safe), `pytest -m integration` (real engine, GPU).
|
|
135
|
+
|
|
136
|
+
Verified architectures (chaos-vector A/B + batchmate-isolation check, both
|
|
137
|
+
model runners exercised):
|
|
138
|
+
|
|
139
|
+
| model | steering works | unsteered untouched | TPOT idle → all-steered |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| Qwen3-14B-AWQ (4-bit) | ✓ | ✓ | 1.91 → 1.91 ms/tok |
|
|
142
|
+
| Llama-3.1-8B-Instruct-AWQ | ✓ | ✓ | 1.10 → 1.10 ms/tok |
|
|
143
|
+
| Qwen3-8B-FP8 | ✓ | ✓ | 2.27 → 2.28 ms/tok |
|
|
144
|
+
| Mistral-7B-Instruct-v0.2-AWQ | ✓ | ✓ | 0.94 → 0.94 ms/tok |
|
|
145
|
+
| Qwen3-4B-Instruct-2507 | ✓ | ✓ | 1.78 → 1.78 ms/tok |
|
|
146
|
+
| Qwen3-0.6B | ✓ | ✓ | — |
|
|
147
|
+
| Qwen2.5-1.5B-Instruct | ✓ | ✓ | 0.77 → 0.77 ms/tok |
|
|
148
|
+
| Phi-3.5-mini-instruct | ✓ | ✓ | 1.73 → 1.73 ms/tok |
|
|
149
|
+
| tiny-aya-water (Cohere) | ✓ | ✓ | 1.54 → 1.54 ms/tok |
|
|
150
|
+
|
|
151
|
+
Quantized checkpoints work — steering touches the residual stream, not the
|
|
152
|
+
weights, and the AWQ / FP8 rows above confirm it end-to-end, CUDA graphs
|
|
153
|
+
captured (PIECEWISE + FULL).
|
|
154
|
+
|
|
155
|
+
Models that still OOM on the 16 GB test GPU before the plugin engages:
|
|
156
|
+
OLMo-2-7B, command-r7b, Qwen3.5-4B, gpt-oss-20b (13.8 GiB weight load
|
|
157
|
+
succeeds, engine init doesn't), gemma-4-E4B-it (vision tower). No
|
|
158
|
+
architecture failure observed yet; reports from bigger cards welcome. The
|
|
159
|
+
layer patch targets any `*DecoderLayer` module with the standard
|
|
160
|
+
`(positions, hidden_states, residual)` signature.
|
|
161
|
+
|
|
162
|
+
## Numbers
|
|
163
|
+
|
|
164
|
+
Qwen3-4B-Instruct-2507, bf16, RTX 4070 Ti SUPER 16 GB, 8 concurrent requests,
|
|
165
|
+
256 decode tokens each, medians of 3 (`benchmarks/bench_decode.py`):
|
|
166
|
+
|
|
167
|
+
| condition | TTFT | decode TPOT |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| vanilla vLLM (plugin not installed) | 4.9 ms | 1.78 ms/tok |
|
|
170
|
+
| hotwire installed, no request steered | 4.9 ms | 1.78 ms/tok |
|
|
171
|
+
| hotwire, **all 8 requests steered** | 4.6 ms | 1.78 ms/tok |
|
|
172
|
+
| vLLM `enforce_eager` (no plugin) | 5.0 ms | 1.88 ms/tok |
|
|
173
|
+
|
|
174
|
+
Idle and fully-steered are both within noise of vanilla. The eager row is what
|
|
175
|
+
hook-based steering tools pay *before* their Python hooks even run.
|
|
176
|
+
|
|
177
|
+
Batch sweep (same model/GPU): the eager tax grows with batch pressure —
|
|
178
|
+
+2.3% at 1 request (batch-1 decode is weight-streaming-bound, which hides
|
|
179
|
+
launch overhead), +4.7% at 2, +5.6% at 8. hotwire's idle == steered holds at
|
|
180
|
+
every batch size, to the second decimal.
|
|
181
|
+
|
|
182
|
+
| batch | graphs idle | graphs all-steered | eager |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| 1 | 13.69 ms/tok | 13.69 | 14.00 |
|
|
185
|
+
| 2 | 6.97 ms/tok | 6.97 | 7.30 |
|
|
186
|
+
| 8 | 1.78 ms/tok | 1.78 | 1.88 |
|
|
187
|
+
|
|
188
|
+
Untested configurations (no known issues, but nobody has run them — treat as
|
|
189
|
+
unsupported until someone does): tensor parallel > 1, pipeline parallel,
|
|
190
|
+
speculative decoding, LoRA, GPTQ and MXFP4 quantization (AWQ and FP8 are
|
|
191
|
+
verified — see the table). Issues welcome.
|
|
192
|
+
|
|
193
|
+
Known limitation: one vector per (layer, token) — multiple spec entries
|
|
194
|
+
targeting the **same layer** don't stack; the last one wins. Different layers
|
|
195
|
+
compose fine. Workaround: pre-combine same-layer vectors into one .pt
|
|
196
|
+
(`a*v1 + b*v2`) and register the combo; native stacking is on the roadmap.
|
|
197
|
+
|
|
198
|
+
Known limitation: the slot budget. Steering configs live in a fixed-size GPU
|
|
199
|
+
table allocated before graph capture — CUDA graphs read fixed addresses, so
|
|
200
|
+
it can never grow at runtime. Size it with `HOTWIRE_SLOTS` (default 16;
|
|
201
|
+
a slot is one vector row, ~5 KB on a 4B model, so 256 costs ~1.3 MB and
|
|
202
|
+
nothing per token). Each distinct **(vector, layer, scale)** combo occupies
|
|
203
|
+
one slot **permanently** — nothing frees slots when requests finish. A fixed
|
|
204
|
+
catalog of vectors at fixed scales therefore runs forever, but continuously
|
|
205
|
+
varying scales (0.80, 0.83, 0.87, …) mint a fresh slot each and exhaust the
|
|
206
|
+
table; once full, requests with an unregistrable combo run unsteered (logged)
|
|
207
|
+
while already-registered combos keep working, batchmates included.
|
|
208
|
+
Workaround today: round scales to a small fixed
|
|
209
|
+
palette and set `HOTWIRE_SLOTS` generously at startup. The real fixes are on
|
|
210
|
+
the roadmap below — slots *can* recycle (the scale isn't baked into the
|
|
211
|
+
stored vector; the kernel reads it separately at replay), it's bookkeeping,
|
|
212
|
+
not graph physics.
|
|
213
|
+
|
|
214
|
+
Roadmap:
|
|
215
|
+
- HTTP vector registration at runtime (via `vllm.endpoint_plugins`), replacing
|
|
216
|
+
startup-only `$HOTWIRE_VECTORS`.
|
|
217
|
+
- Slot eviction: refcount slots per in-flight request and `release()` when the
|
|
218
|
+
last user of a combo finishes, so the table recycles instead of filling.
|
|
219
|
+
- Per-token scales: key slots by (vector, layer) only and move scale into a
|
|
220
|
+
per-token buffer — continuous intensities without minting new slots.
|
|
221
|
+
- Norm-matched and position-targeted steering modes.
|
|
222
|
+
- Tracking the RFC vllm-project/vllm#36998 Phase 2 interface as it lands.
|
|
223
|
+
## Where this sits in the lab
|
|
224
|
+
|
|
225
|
+
```mermaid
|
|
226
|
+
flowchart LR
|
|
227
|
+
hd["🧭 hidden-directions<br/>behavior → vector"]
|
|
228
|
+
bs(["🧠 brainscope<br/>watch the model think"])
|
|
229
|
+
hw["🔥 hotwire-vllm<br/>steering in production"]
|
|
230
|
+
st["🕹️ steeropathy<br/>agents talk via activations"]
|
|
231
|
+
tm["⚖️ in-two-minds<br/>agent hesitating between tools"]
|
|
232
|
+
sm["🧪 steering-mechanics<br/>how steering actually works"]
|
|
233
|
+
|
|
234
|
+
hd -->|vectors| bs
|
|
235
|
+
hd -->|vector + passport| hw
|
|
236
|
+
bs --> st
|
|
237
|
+
bs --> tm
|
|
238
|
+
bs -->|causal replay| sm
|
|
239
|
+
hw -.->|vector under study| sm
|
|
240
|
+
|
|
241
|
+
click hd "https://github.com/moudrkat/hidden-directions"
|
|
242
|
+
click bs "https://github.com/moudrkat/brainscope"
|
|
243
|
+
click hw "https://github.com/moudrkat/hotwire-vllm"
|
|
244
|
+
click st "https://github.com/moudrkat/steeropathy"
|
|
245
|
+
click tm "https://github.com/moudrkat/in-two-minds"
|
|
246
|
+
click sm "https://github.com/moudrkat/steering-mechanics"
|
|
247
|
+
|
|
248
|
+
classDef dim fill:#f6f8fa,stroke:#d0d7de,color:#57606a;
|
|
249
|
+
classDef here fill:#8957e5,stroke:#6e40c9,color:#ffffff;
|
|
250
|
+
class hd,bs,hw,st,tm,sm dim;
|
|
251
|
+
class hw here;
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
*Highlighted = this repo. The full lab map (with the two other repos' stories) lives on [moudrkat](https://github.com/moudrkat).*
|
|
255
|
+
|