Skip to content

[Bug]: Attention-DP KV metrics copy rank-0 values and mix per-rank counter history #21124

Closed

Description

@tanmayv25

System Info

  • GPUs: 4 × NVIDIA GB200, one node.
  • Model: TinyLlama/TinyLlama-1.1B-Chat-v1.0, BF16.
  • Official container: nvcr.io/nvidia/tensorrt-llm/release:1.4.0a0.dev202610180820.
  • TensorRT-LLM: 1.4.0a0.dev202610180820.
  • Attention DP enabled with tensor_parallel_size=4; KV cache manager v2, block reuse and iteration statistics enabled.
  • Measured using Slurm/Pyxis. No model or metrics fix was applied.
  • The image's ADP producer SHA256 is 816c440e0e95bafb159ad669c8e30ad17cf8c8d33dbe9052f544e64ea06c8efe, identical to the inspected producer source.

Information

Custom diagnostic scripts using the public LLM API. The observation hook records each rank's _update_iter_stats() result before fanout and returns the original object unchanged. No private model, data, infrastructure details, or access credentials are required.

Actual behavior and measured results

1. Live ADP fanout exports rank 0's KV values for other ranks.

Nine requests completed across all four ranks with explicit placement and uneven prompt lengths. At iteration 0:

Rank Locally measured used KV blocks Exported used KV blocks
0 3 3
1 4 3
2 6 3
3 8 3

After a repeated prompt on rank 1, the local reuse counts were [0, 32, 0, 0], while the exported KV rows reported zero reuse. This can lose real activity from nonzero ranks.

Both live runs passed all nine requests and observed all four ranks:

Statistics mode Captured exported rows Replicated rank-0 KV values reproduced
Default 1,669 Yes
TLLM_METRICS_ALL_RANKS=1 1,672 Yes

2. The native collector mixes independent rank histories.

Separately, replaying the 1,668 captured rank-local measurements through the unmodified native MetricsCollector, ordered by iteration and rank, produced:

Cumulative KV counter Per-rank reference Native collector
Reused blocks 32 6,144
Missed blocks 100 9,926

This second result is a controlled replay of actual GPU measurements, not the default live exporter's result. The default producer's copied inputs can mask cross-rank overcounting while losing other ranks' activity. Correcting only the collector labels would not make copied producer values truthful.

Reproduction

Use four available GPUs and the pinned public image. Cold startup downloads the public model weights and attention kernels. Exact iteration counts and row counts can vary; the important result is disagreement between a rank's local value and its exported value, and incorrect counter accumulation when replaying independent local measurements.

All four scripts are included below; no attachment, private filesystem, or external gist is needed.

1. Create the scripts in a fresh directory.

mkdir adp-metrics-repro
cd adp-metrics-repro
Copy-paste this block to create all four Python files
cat > sitecustomize.py <<'PY_OBSERVER'
"""Diagnostic observation only: retain rank-local values before ADP fanout."""
import json
import os
import time
from pathlib import Path

if os.environ.get("ADP_REPRO_DIR"):
    from tensorrt_llm._torch.pyexecutor.py_executor import PyExecutor

    original = PyExecutor._update_iter_stats
    counts = {}

    def observed(self, *args, **kwargs):
        stats = original(self, *args, **kwargs)
        rank = int(self.dist.tp_rank)
        count = counts.get(rank, 0)
        if stats is not None and count < 10000:
            counts[rank] = count + 1
            row = {
                "rank": rank,
                "time_ns": time.time_ns(),
                "stats": json.loads(stats.to_json_str()),
            }
            root = Path(os.environ["ADP_REPRO_DIR"])
            with (root / f"local-rank-{rank}.jsonl").open("a") as output:
                output.write(json.dumps(row) + "\n")
        return stats

    PyExecutor._update_iter_stats = observed
PY_OBSERVER

cat > repro.py <<'PY_WORKLOAD'
import asyncio
import hashlib
import json
import os
import time
from pathlib import Path

ROOT = Path(os.environ["ADP_REPRO_DIR"])


async def main():
    import tensorrt_llm
    from tensorrt_llm import LLM, SamplingParams
    from tensorrt_llm.llmapi.llm_args import KvCacheConfig
    from tensorrt_llm.scheduling_params import SchedulingParams
    from tensorrt_llm.metrics import MetricsCollector
    from prometheus_client import generate_latest
    import tensorrt_llm._torch.pyexecutor.adp_iter_stats as adp
    import tensorrt_llm.metrics.collector as collector_module

    manifest = {
        "runtime": tensorrt_llm.__version__,
        "model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
        "tp_size": 4,
        "attention_dp": True,
        "all_ranks": os.environ.get("TLLM_METRICS_ALL_RANKS", "0"),
        "source_hashes": {},
    }
    for label, module in (("adp_producer", adp), ("collector", collector_module)):
        manifest["source_hashes"][label] = hashlib.sha256(Path(module.__file__).read_bytes()).hexdigest()
    (ROOT / "manifest.json").write_text(json.dumps(manifest, indent=2))
    print("REPRO_MANIFEST", json.dumps(manifest), flush=True)
    llm = LLM(
        model="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
        backend="pytorch",
        dtype="bfloat16",
        tensor_parallel_size=4,
        enable_attention_dp=True,
        enable_chunked_prefill=True,
        enable_iter_perf_stats=True,
        return_perf_metrics=True,
        max_batch_size=8,
        max_num_tokens=2048,
        max_seq_len=2048,
        cuda_graph_config=None,
        kv_cache_config=KvCacheConfig(
            max_tokens=8192,
            enable_block_reuse=True,
            use_kv_cache_manager_v2=True,
        ),
    )
    collector = MetricsCollector({"model_name": "adp-repro", "engine_type": "trtllm"})
    manifest["workload_started_ns"] = time.time_ns()
    (ROOT / "manifest.json").write_text(json.dumps(manifest, indent=2))
    stopped = asyncio.Event()
    observed_ranks = set()
    row_count = 0

    async def poll():
        nonlocal row_count
        with (ROOT / "exported-rows.jsonl").open("w") as output:
            while not stopped.is_set():
                rows = await asyncio.to_thread(llm.get_stats, 0.2)
                for row in rows:
                    observed_ranks.add(int(row.get("attentionDpRank", row.get("rank", 0))))
                    output.write(json.dumps(row) + "\n")
                    row_count += 1
                    collector.log_iteration_stats(row)
                output.flush()
                await asyncio.sleep(0.02)

    async def one(rank, variant, length, output_tokens):
        prompt = [100 + rank * 31 + variant] * length
        handle = llm.generate_async(
            inputs=prompt,
            sampling_params=SamplingParams(max_tokens=output_tokens, temperature=0.0, ignore_eos=True),
            streaming=True,
            scheduling_params=SchedulingParams(attention_dp_rank=rank, attention_dp_relax=False),
            cache_salt=f"rank-{rank}-variant-{variant}",
        )
        terminal = None
        async for result in handle:
            if result.finished:
                terminal = result
        assert terminal is not None, (rank, variant, "missing terminal response")
        assert not terminal.error, str(terminal.error)
        print("REQUEST_COMPLETE", rank, variant, length, output_tokens, flush=True)

    task = asyncio.create_task(poll())
    success = False
    try:
        # Establish real work on every rank, with distinct prompt lengths.
        await asyncio.gather(*(one(rank, 0, 64 * (rank + 1), 32) for rank in range(4)))
        # Keep rank 0 busy while all other ranks are idle.
        await one(0, 1, 640, 128)
        # Different cache footprints and reuse activity on rank 1.
        await one(1, 2, 1024, 64)
        await one(1, 2, 1024, 64)
        # Simultaneously exercise distinct load and retained-cache sizes.
        await asyncio.gather(one(0, 3, 128, 128), one(2, 4, 768, 32))
        await asyncio.sleep(1)
        assert observed_ranks == {0, 1, 2, 3}, observed_ranks
        (ROOT / "metrics.prom").write_bytes(generate_latest())
        success = True
    finally:
        stopped.set()
        try:
            await asyncio.wait_for(task, 15)
        finally:
            await asyncio.to_thread(llm.shutdown)
            summary = {"workload_passed": success, "observed_ranks": sorted(observed_ranks), "rows": row_count}
            (ROOT / "workload-summary.json").write_text(json.dumps(summary, indent=2))
            print("WORKLOAD_SUMMARY", json.dumps(summary), flush=True)


if __name__ == "__main__":
    asyncio.run(main())
PY_WORKLOAD

cat > analyze.py <<'PY_ANALYZE'
"""Require real multi-rank evidence; do not infer correctness from identical rows."""
import json
import sys
from collections import defaultdict
from pathlib import Path

root = Path(sys.argv[1])
manifest = json.loads((root / "manifest.json").read_text())
workload = json.loads((root / "workload-summary.json").read_text())
assert workload["workload_passed"], workload
assert workload["observed_ranks"] == [0, 1, 2, 3], workload
assert manifest["source_hashes"]["adp_producer"] == "816c440e0e95bafb159ad669c8e30ad17cf8c8d33dbe9052f544e64ea06c8efe"

rows = [json.loads(line) for line in (root / "exported-rows.jsonl").read_text().splitlines()]
local = defaultdict(dict)
for rank in range(4):
    path = root / f"local-rank-{rank}.jsonl"
    assert path.exists(), f"Missing actual measurements for rank {rank}"
    for line in path.read_text().splitlines():
        measurement = json.loads(line)
        if measurement["time_ns"] < manifest["workload_started_ns"]:
            continue
        stats = measurement["stats"]
        assert "iter" in stats, stats.keys()
        local[rank][stats["iter"]] = stats
    assert local[rank], f"No workload measurements for rank {rank}"

evidence = []
matched = 0
for row in rows:
    rank = int(row.get("attentionDpRank", row.get("rank", 0)))
    iteration = row.get("iter")
    own = local[rank].get(iteration)
    zero = local[0].get(iteration)
    if rank == 0 or own is None or zero is None:
        continue
    if not all("kvCacheStats" in stats for stats in (row, own, zero)):
        continue
    matched += 1
    own_kv, zero_kv, exported_kv = (stats["kvCacheStats"] for stats in (own, zero, row))
    for field in ("usedNumBlocks", "freeNumBlocks", "reusedBlocks", "missedBlocks"):
        if field not in own_kv or field not in zero_kv or field not in exported_kv:
            continue
        if own_kv[field] != zero_kv[field] and exported_kv[field] == zero_kv[field]:
            evidence.append({
                "iter": iteration,
                "rank": rank,
                "field": field,
                "actual_rank_value": own_kv[field],
                "actual_rank0_value": zero_kv[field],
                "exported_rank_value": exported_kv[field],
            })

result = {
    "workload": workload,
    "matched_nonzero_rank_rows": matched,
    "rank0_copy_confirmed": bool(evidence),
    "mismatched_field_observations": len(evidence),
    "examples": evidence[:20],
    "limits": [
        "Direct LLM statistics plus native collector; not a full Dynamo/HTTP/OpenEngine serving comparison.",
        "Diagnostic observation adds local file writes; no performance claim.",
    ],
}
assert matched > 0, "No aligned local/exported observations; result is inconclusive"
(root / "analysis.json").write_text(json.dumps(result, indent=2))
print(json.dumps(result, indent=2))
PY_ANALYZE

cat > replay_collector.py <<'PY_REPLAY'
"""Replay real rank-local captures through the unmodified native collector.

This isolates collector behavior; it is not a claim that default fanout exports
these truthful local rows today.
"""
import importlib
import importlib.util
import json
import sys
import types
from pathlib import Path

from prometheus_client import REGISTRY

source = Path(importlib.util.find_spec("tensorrt_llm").submodule_search_locations[0]) / "metrics"
package = types.ModuleType("native_metrics_replay")
package.__path__ = [str(source)]
sys.modules[package.__name__] = package
module = importlib.import_module("native_metrics_replay.collector")
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/repro/output")
rows = []
for rank in range(4):
    for line in (root / f"local-rank-{rank}.jsonl").read_text().splitlines():
        capture = json.loads(line)
        row = capture["stats"]
        row["attentionDpRank"] = rank
        rows.append((row["iter"], rank, row))
rows.sort(key=lambda entry: (entry[0], entry[1]))
labels = {"model_name": "rank-local-replay", "engine_type": "trtllm"}
collector = module.MetricsCollector(labels)
previous = {"reusedBlocks": {}, "missedBlocks": {}}
reference = {"reusedBlocks": 0, "missedBlocks": 0}
for _, rank, row in rows:
    collector.log_iteration_stats(row)
    for field in reference:
        value = row.get("kvCacheStats", {}).get(field)
        if value is not None:
            reference[field] += max(0, value - previous[field].get(rank, 0))
            previous[field][rank] = value
result = {
    "input": "Real per-rank measurements captured before default ADP fanout",
    "order": "iteration then rank",
    "rows": len(rows),
    "reused_blocks": {
        "collector": REGISTRY.get_sample_value("trtllm_kv_cache_reused_blocks_total", labels),
        "per_rank_reference": reference["reusedBlocks"],
    },
    "missed_blocks": {
        "collector": REGISTRY.get_sample_value("trtllm_kv_cache_missed_blocks_total", labels),
        "per_rank_reference": reference["missedBlocks"],
    },
    "limit": "Controlled replay isolates collector rank mixing, not default live exporter output.",
}
assert result["reused_blocks"]["collector"] is not None
assert result["missed_blocks"]["collector"] is not None
print(json.dumps(result, indent=2))
PY_REPLAY

2. Run the live workload.

The measured run used one existing Slurm allocation exposing four GPUs. With SLURM_JOB_ID set, the following stages only these scripts into node-local temporary storage, runs each mode, and copies the captures back. Account and partition selection are site-specific and are intentionally not part of the reproducer.

IMAGE=nvcr.io#nvidia/tensorrt-llm/release:1.4.0a0.dev202610180820
REPRO_DIR="/tmp/adp-metrics-repro-${SLURM_JOB_ID}"

srun --jobid="$SLURM_JOB_ID" --nodes=1 --ntasks=1 mkdir -p "$REPRO_DIR"
tar -czf - sitecustomize.py repro.py analyze.py replay_collector.py | \
  srun --jobid="$SLURM_JOB_ID" --nodes=1 --ntasks=1 \
  tar -xzf - -C "$REPRO_DIR"

run_slurm_mode() {
  metrics_mode="$1"
  metrics_output="$2"
  srun --jobid="$SLURM_JOB_ID" --nodes=1 --ntasks=1 \
    mkdir "$REPRO_DIR/$metrics_output"
  srun --jobid="$SLURM_JOB_ID" --nodes=1 --ntasks=1 \
    --container-image="$IMAGE" \
    --container-mounts="$REPRO_DIR:/repro" \
    --container-workdir=/repro --container-writable --container-entrypoint \
    env PYTHONPATH=/repro ADP_REPRO_DIR="/repro/$metrics_output" \
    HF_HOME=/repro/hf HF_MODULES_CACHE=/repro/hf-modules \
    TLLM_METRICS_ALL_RANKS="$metrics_mode" python3 /repro/repro.py
}

run_slurm_mode 0 output
run_slurm_mode 1 output-control

srun --jobid="$SLURM_JOB_ID" --nodes=1 --ntasks=1 \
  tar -czf - -C "$REPRO_DIR" output output-control > captures.tar.gz
tar -xzf captures.tar.gz

The equivalent Docker launch is below for machines with four available GPUs. This launcher was not rerun end-to-end; the scripts/configuration above were measured with Slurm/Pyxis. Use this alternative instead of the Slurm block.

run_docker_mode() {
  metrics_mode="$1"
  metrics_output="$2"
  mkdir "$metrics_output"
  docker run --rm --gpus all --ipc=host \
    -v "$PWD:/repro" -w /repro \
    -e PYTHONPATH=/repro -e ADP_REPRO_DIR="/repro/$metrics_output" \
    -e HF_HOME=/repro/hf -e HF_MODULES_CACHE=/repro/hf-modules \
    -e TLLM_METRICS_ALL_RANKS="$metrics_mode" \
    nvcr.io/nvidia/tensorrt-llm/release:1.4.0a0.dev202610180820 \
    python3 /repro/repro.py
}

run_docker_mode 0 output
run_docker_mode 1 output-control

3. Analyze the live producer captures (Python 3.12; standard library only).

python3 analyze.py output
python3 analyze.py output-control

The analyzer requires a successful workload, all four rank captures, the expected producer hash, and aligned local/exported observations. rank0_copy_confirmed: true reports observed mismatches where another rank's exported value equals rank 0's value instead of its own. Missing evidence raises an error rather than passing.

4. Isolate the collector behavior using the captured local rows.

This CPU-only Docker command replays the actual pre-fanout measurements through the image's unmodified native collector, without importing the full inference engine. It does not fabricate counter values.

docker run --rm -v "$PWD:/data:ro" --entrypoint python3 \
  nvcr.io/nvidia/tensorrt-llm/release:1.4.0a0.dev202610180820 \
  /data/replay_collector.py /data/output

Alternatively, within the existing Slurm allocation/container:

srun --jobid="$SLURM_JOB_ID" --nodes=1 --ntasks=1 \
  --container-image="$IMAGE" --container-mounts="$REPRO_DIR:/repro" \
  --container-workdir=/repro --container-writable \
  python3 /repro/replay_collector.py /repro/output

Keep the same runtime when comparing the observed totals. The workload observation hook adds file writes, so this is a correctness diagnostic rather than a performance benchmark. Fresh output directories are required for each run because local observation files are append-only.

Expected behavior

  • Rank-labelled KV statistics reflect measurements from the corresponding rank.
  • Shared statistics have explicit ownership and are not double-counted.
  • Cumulative counter history is tracked independently per rank, including defined reset behavior.
  • The shared collector exposes correct values consistently to serving frontends; request metrics and configuration-info metrics retain their intended semantics.

Additional notes

  • ADPIterStatsManager._make_rank_iter_stats() copies rank-0 scalar and optional nested KV statistics into rank rows while replacing selected scheduling fields with rank-local payloads.
  • MetricsCollector.log_iteration_stats() ignores rank identity and uses shared _prev_reused_blocks / _prev_missed_blocks values.
  • This validation used the direct LLM API and native metrics collector. It did not run complete HTTP, OpenEngine, or other integration deployments, and makes no performance claim.
  • No product fix was applied. Private logs and original machine manifests are not attached.
  • Related: [#21032][feat] Expose OpenEngine Prometheus metrics #21034 (review)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Disaggregated serving<NV>Deploying with separated, distributed components (params, kv-cache, compute). Arch & perf.bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions