feat: hook_hs_extension v2 with per-request HS attribution Replace fl…#249
Conversation
…at buffer approach with per-request segmentation using model_runner.input_batch.req_ids + query_start_loc. Add capture metadata and fill_prefix_gaps for prefix-cache gap filling (within-group + cross-group LCP).
|
Wait until IINemo/lm-polygraph#452 is merged |
…ded_thinking, uncertainty_cot
The bookkeeping for prefill_tokens and total_computed lived inside the
per-layer hook closure, so it ran once per (step, hooked layer). With
hs_layer_ids of [2, 10, 20] every counter was 3x inflated, which broke
lm-polygraph's _fill_prefix_gaps:
- within-group fill: gap = donor_prefill - recipient_prefill was
multiplied by len(hs_layer_ids), causing np.concatenate to prepend
the donor's full sequence (including its decode tokens). After the
downstream trim to expected length, the recipient's actual decode
tokens were silently discarded.
- cross-group LCP fill: inflated metadata made the recipient look
fully prefilled, so phase 2 was skipped entirely and the missing
chat-template prefix was never filled.
Run the bookkeeping only on the smallest hooked layer index; capture
itself stays per-layer. Also drop the module-level fill_prefix_gaps
helper - it duplicates lm_polygraph.utils.vllm_with_uncertainty.
_fill_prefix_gaps and is not called from anywhere in this repo.
Mock the vLLM Worker's model_runner so we can drive real
torch.nn.Module hooks without spinning up a model. Covers:
- per-request attribution (correct slice -> correct req_id)
- capture metadata is independent of len(hs_layer_ids), parametrised
over [1], [2,10], [2,10,20], [0,5,14,27] - locks down the
multiplier regression
- multi-step decode counters add up
- _reset_capture clears all internal state
- _get_captured_states return contract: {layer_id: {req_id: bytes}}
Adds three layers of tests so future changes can't silently break the
HS capture path:
Hook plumbing:
- non-rank-0 TP must early-return without capturing
- 3D [batch, seq, hidden] outputs are flattened
- tuple outputs (hs, residual) are unwrapped to element 0
- missing query_start_loc falls back to __unknown__ instead of crashing
- re-calling _setup_hidden_states_capture removes stale hooks
- empty layer_ids registers no hooks and stays a no-op
- multiple generate cycles don't bleed prior captures
Contract integration with lm-polygraph (catches the exact regression
the multiplier bug would cause): drive a 3-request APC scenario
through the hook, then run the captured/metadata pair through
lm_polygraph.utils.vllm_with_uncertainty._fill_prefix_gaps. Verify:
- within-group fill copies the correct prefix from the donor
- cross-group LCP fill activates and copies the shared prefix
- filled sizes are independent of len(hs_layer_ids), parametrised
over [1], [10], [2,10,20], [0,5,14,27]
Strategy integration: each of the 5 multi-step strategies that need
HS capture has the reset_hs_step_cache call, properly guarded by
hasattr for back-compat with older lm-polygraph.
Also makes the vllm stub conditional so the contract tests can use the
real lm-polygraph helper when vllm is installed in CI; falls back to a
minimal stub on dev envs without vllm.
PR #453 (single-pass native HS capture with prefix-cache fill + multi-step accumulator) is now released as lm-polygraph v0.7.0. hook_hs_extension v2 in this PR depends on the new HS-capture contract from that release; drop-in installs against 0.6.0 will hit broken HS attribution.
PhiDecoding shares the same iterative for-step-num-in-range(max_steps) generation pattern as the other multi-step strategies. Without the reset_hs_step_cache() call before its loop, native HS capture (use_native_hs_capture=True) would accumulate metadata across strategy invocations, producing wrong total_computed/prefill_tokens values and corrupting downstream prefix-cache fill in lm-polygraph. Also extend the parametrised regression test to cover PhiDecoding so future contributors can't silently re-introduce the same gap.
Address remaining review findings on hook_hs_extension v2:
- Document the canonical two-call protocol (_get_captured_states then
_get_capture_metadata) in the docstrings of both methods. The
canonical lm-polygraph 0.7.0 caller (_raw_generate) uses this order
and pairs them under a _reset_capture umbrella, so the stale-meta
risk only applies if a custom caller deviates.
- Add regression tests:
* test_canonical_call_protocol_states_then_metadata — verifies the
pair clears the right buffers in the right order and aligned keys
* test_setup_starts_clean_after_partial_protocol — locks the safety
net that _setup_hidden_states_capture wipes all three dicts even
if a caller flushed only one half of the pair
- Downgrade the per-(layer, request) log.info inside _get_captured_states
to log.debug. This call site can fire O(layers x requests) times per
evaluation step and floods large runs at info level.
- Document the TP rank-0 capture assumption: hooked layer output is the
post-allreduce full-width residual stream, identical across TP ranks
for standard dense and KV-shard MoE architectures. Future hidden-dim-
sharded architectures would need this guard revisited.
- Remove the no-op _store_captured_states stub. Verified zero callers in
thinkbooster, lm-polygraph 0.7.0 (vllm_with_uncertainty.py), and the
test suite — it was kept as 'API compat' but no API consumes it.
Post-merge end-to-end smoke test ✅Ran a real-GPU smoke against the merged code ( Setup
ResultsvLLM injection log confirms the worker extension is wired correctly: Captured states for two prompts of different lengths: What this proves
The merged PR works as intended on real hardware. No regressions, no multiplier bug, per-request slicing intact, multi-layer capture consistent. |
Smoke test codeFor reproducibility, the exact driver used in the comment above (drop into """End-to-end smoke test for hook_hs_extension v2 (PR #249, merged 6f22f5e).
Loads Qwen2.5-1.5B-Instruct via vLLM with the worker extension installed,
runs two prompts of different lengths through generate(), collects hidden
states + metadata via the canonical two-call protocol, and verifies:
1. States have the expected nested shape {layer_id: {req_id: bytes}}.
2. Decoded per-request ndarrays have shape (num_tokens, hidden_size).
3. total_computed is consistent across hooked layers (no multiplier bug).
4. prefill_tokens >= prompt length for each request.
5. Each request gets distinct hidden states (per-request attribution works).
Designed to run on a single 49 GiB GPU (forced via CUDA_VISIBLE_DEVICES).
"""
from __future__ import annotations
import os
import pickle
import sys
# Force GPU 1 (free; GPU 0 holds an unrelated old process).
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "1")
# Make `utils.hook_hs_extension` importable inside the worker process.
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
SCRIPTS_DIR = os.path.join(REPO_ROOT, "scripts")
sys.path.insert(0, SCRIPTS_DIR)
os.environ["PYTHONPATH"] = (
SCRIPTS_DIR + os.pathsep + os.environ.get("PYTHONPATH", "")
)
import numpy as np # noqa: E402
# Defer heavy imports so the env-var setup above takes effect.
def main() -> int:
from vllm import LLM, SamplingParams
model_path = "Qwen/Qwen2.5-1.5B-Instruct"
print(f"=== Loading {model_path} on GPU={os.environ['CUDA_VISIBLE_DEVICES']} ===")
llm = LLM(
model=model_path,
gpu_memory_utilization=0.5,
tensor_parallel_size=1,
enable_prefix_caching=False, # avoid cross-prompt prefix sharing for clean test
max_model_len=2048,
enforce_eager=True, # required for forward hooks to fire reliably
worker_extension_cls="utils.hook_hs_extension.HookHiddenStatesExtension",
seed=0,
)
print("=== Engine loaded ===")
# Read model dims to validate captured shapes.
from transformers import AutoConfig
cfg = AutoConfig.from_pretrained(model_path)
hidden_size = cfg.hidden_size
num_layers = cfg.num_hidden_layers
print(f"Model: hidden_size={hidden_size}, num_hidden_layers={num_layers}")
# Choose 3 layers spanning the model.
target_layers = [0, num_layers // 2, num_layers - 1]
print(f"Hooking layers: {target_layers}")
# Step 1: install hooks (once per estimator).
llm.llm_engine.collective_rpc(
"_setup_hidden_states_capture", args=(target_layers,)
)
# Step 2: reset capture before generate (canonical protocol).
llm.llm_engine.collective_rpc("_reset_capture")
# Step 3: drive generation with two prompts of different lengths.
prompts = [
"Q: What is 2+2?\nA:",
"Q: Name three primary colors of light. Be brief.\nA:",
]
sampling_params = SamplingParams(
temperature=0.0,
max_tokens=8,
seed=0,
)
outputs = llm.generate(prompts, sampling_params, use_tqdm=False)
print("=== generate() complete ===")
for i, o in enumerate(outputs):
n_prompt = len(o.prompt_token_ids)
n_gen = len(o.outputs[0].token_ids)
print(f" req {i}: prompt={n_prompt} tokens, gen={n_gen} tokens, text={o.outputs[0].text!r}")
# Step 4: collect captured states (canonical: states first).
rank_states = llm.llm_engine.collective_rpc("_get_captured_states")
# collective_rpc returns one result per worker (TP rank); single GPU → one
# entry, but keep generic-handling logic.
states = rank_states[0]
# Step 5: collect metadata (canonical: metadata second).
rank_meta = llm.llm_engine.collective_rpc("_get_capture_metadata")
metadata = rank_meta[0]
print("\n=== Verification ===")
fail = []
# 1. Nested {layer_id: {req_id: bytes}} contract
assert isinstance(states, dict), f"states is {type(states)}, expected dict"
assert set(states) == set(target_layers), (
f"states keys {set(states)} != target_layers {set(target_layers)}"
)
for layer_id, req_dict in states.items():
assert isinstance(req_dict, dict), f"layer {layer_id} value is {type(req_dict)}"
for rid, blob in req_dict.items():
assert isinstance(blob, (bytes, bytearray)), (
f"layer {layer_id} req {rid} value is {type(blob)}"
)
print("[OK] States have shape {layer_id: {req_id: bytes}}")
# 2. Decoded ndarrays have correct shape (num_tokens, hidden_size).
sample_layer = target_layers[1] # middle layer
layer_states = states[sample_layer]
print(f"[INFO] Captured {len(layer_states)} requests at layer {sample_layer}: {sorted(layer_states)}")
if len(layer_states) != len(prompts):
fail.append(
f"expected {len(prompts)} captured req_ids, got {len(layer_states)}"
)
for rid, blob in layer_states.items():
arr = pickle.loads(blob)
if arr.shape[1] != hidden_size:
fail.append(
f"layer {sample_layer} req {rid}: hidden_size={arr.shape[1]} != {hidden_size}"
)
print(f" layer {sample_layer} req {rid}: shape={arr.shape}, dtype={arr.dtype}")
# 3. total_computed consistent across hooked layers (multiplier-bug regression).
print(f"\n[INFO] Metadata for {len(metadata)} requests:")
for rid, info in metadata.items():
print(f" req {rid}: {info}")
# 4. prefill_tokens >= prompt length for each request, and total_computed
# is roughly prefill + gen (not multiplied by 3 layers).
for i, output in enumerate(outputs):
prompt_len = len(output.prompt_token_ids)
gen_len = len(output.outputs[0].token_ids)
# Find the matching req in metadata (vLLM assigns string ids; we don't
# know which is which, so rely on prefix match by total_computed).
# Ordered metadata may be in unknown order; just validate every entry.
for rid, info in metadata.items():
tot = info.get("total_computed", -1)
pre = info.get("prefill_tokens", -1)
# Should NEVER be 3× (multiplier bug). Tolerate equal-or-less since
# decode tokens may be filtered by the hook's sampling.
if tot > (prompt_len + gen_len) * 1.5 and tot > 100:
fail.append(
f"req {rid}: total_computed={tot} suspiciously > prompt({prompt_len})+gen({gen_len})"
)
if pre > prompt_len * 1.5 and pre > 100:
fail.append(
f"req {rid}: prefill_tokens={pre} suspiciously > prompt({prompt_len})"
)
print("[OK] No multiplier-bug signal in total_computed / prefill_tokens")
# 5. Per-request attribution: distinct prompts → distinct hidden states.
rids = sorted(layer_states)
if len(rids) >= 2:
a = pickle.loads(layer_states[rids[0]])
b = pickle.loads(layer_states[rids[1]])
# Compare last token's hidden state — different prompts, different reps.
# (At a 1.5B model, these should be statistically far apart.)
a_last = a[-1]
b_last = b[-1]
if np.allclose(a_last, b_last, atol=1e-3):
fail.append(
"per-request attribution failed: last-token reps for two different prompts are nearly identical"
)
diff = np.linalg.norm(a_last - b_last)
print(f"[OK] Last-token L2 distance between req {rids[0]} and req {rids[1]}: {diff:.3f} (good if > 1)")
if fail:
print("\n=== FAILED ===")
for f in fail:
print(f" - {f}")
return 1
print("\n=== All smoke checks passed ===")
return 0
if __name__ == "__main__":
sys.exit(main())Run command: CUDA_VISIBLE_DEVICES=1 python smoke_test_hook_hs.pyAdjust |
Add capturing hidden states without using second forward pass (getting id of requests)