Skip to content

feat: hook_hs_extension v2 with per-request HS attribution Replace fl…#249

Merged
smirnovlad merged 10 commits into
mainfrom
feat/hook-hs-v2-per-request
May 4, 2026
Merged

feat: hook_hs_extension v2 with per-request HS attribution Replace fl…#249
smirnovlad merged 10 commits into
mainfrom
feat/hook-hs-v2-per-request

Conversation

@rvz16

@rvz16 rvz16 commented Apr 10, 2026

Copy link
Copy Markdown
Collaborator

Add capturing hidden states without using second forward pass (getting id of requests)

…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).
@smirnovlad

Copy link
Copy Markdown
Collaborator

Wait until IINemo/lm-polygraph#452 is merged

rvz16 and others added 6 commits April 13, 2026 16:35
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.
smirnovlad added 3 commits May 4, 2026 14:11
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.
@smirnovlad
smirnovlad merged commit 6f22f5e into main May 4, 2026
3 checks passed
@smirnovlad

Copy link
Copy Markdown
Collaborator

Post-merge end-to-end smoke test ✅

Ran a real-GPU smoke against the merged code (6f22f5e on main) to confirm everything works in vivo, not just in unit tests.

Setup

  • GPU: single RTX 5880 Ada (49 GiB), CUDA_VISIBLE_DEVICES=1
  • Model: Qwen/Qwen2.5-1.5B-Instruct (28 layers × 1536 hidden)
  • vLLM: 0.12.0 with enforce_eager=True, prefix caching off
  • Hooked layers: [0, 14, 27] (first / middle / last)
  • Protocol: canonical _setup_hidden_states_capture_reset_capturegenerate_get_captured_states_get_capture_metadata

Results

vLLM injection log confirms the worker extension is wired correctly:

Injected <class 'utils.hook_hs_extension.HookHiddenStatesExtension'>
into <class 'vllm.v1.worker.gpu_worker.Worker'> for extended collective_rpc calls
['_get_base_model', '_get_capture_metadata', '_get_captured_states',
 '_get_request_segmentation', '_reset_capture',
 '_setup_hidden_states_capture', '_track_prefill_chunk']

Captured states for two prompts of different lengths:

=== generate() complete ===
  req 0: prompt=11 tokens, gen=8 tokens, text=' 4\nExplain how you used'
  req 1: prompt=14 tokens, gen=8 tokens, text=' Red, green, and blue.\n\nQ'

=== Verification ===
[OK] States have shape {layer_id: {req_id: bytes}}
[INFO] Captured 2 requests at layer 14: ['0', '1']
  layer 14 req 0: shape=(18, 1536), dtype=float32
  layer 14 req 1: shape=(21, 1536), dtype=float32
[INFO] Metadata for 2 requests:
  req 0: {'total_computed': 18, 'prefill_tokens': 11}
  req 1: {'total_computed': 21, 'prefill_tokens': 14}
[OK] No multiplier-bug signal in total_computed / prefill_tokens
[OK] Last-token L2 distance between req 0 and req 1: 43.179 (good if > 1)
=== All smoke checks passed ===

What this proves

Check Pass
Worker extension injected into vLLM v1
Canonical 5-call protocol works end-to-end
Nested return contract {layer_id: {req_id: bytes}}
Decoded ndarray shape (num_tokens, hidden_size) ✓ — (18, 1536) and (21, 1536) against expected hidden_size=1536
Multiplier-bug regression (3 layers hooked, metadata must NOT triple) ✓ — total_computed matches real token counts (18, 21), not 54/63
prefill_tokens matches prompt length ✓ — 11 / 14
Per-request attribution (distinct prompts → distinct hidden states) ✓ — last-token L2 = 43.18 between req 0 and req 1
Multi-layer consistency ([0, 14, 27] all return same shape contract)

The merged PR works as intended on real hardware. No regressions, no multiplier bug, per-request slicing intact, multi-layer capture consistent.

@smirnovlad

Copy link
Copy Markdown
Collaborator

Smoke test code

For reproducibility, the exact driver used in the comment above (drop into <repo-root>/smoke_test_hook_hs.py on main and run CUDA_VISIBLE_DEVICES=<free-gpu> python smoke_test_hook_hs.py):

"""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.py

Adjust CUDA_VISIBLE_DEVICES to whichever GPU is free; lower gpu_memory_utilization if you have less VRAM. Tested on a 49 GiB RTX 5880 Ada with vLLM 0.12.0 + Qwen2.5-1.5B-Instruct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants