Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions configs/nvidia-master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9064,6 +9064,97 @@ dsv4-fp4-gb200-dynamo-vllm:
# MTP2 variant of dsv4-fp4-gb200-dynamo-vllm. Uses the vLLM 0.20.1 image
# and hand-picked 8k/1k Pareto points mirrored from NVIDIA/srt-slurm.
dsv4-fp4-gb200-dynamo-vllm-mtp2:
image: vllm/vllm-openai:v0.20.1-ubuntu2404

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 BLOCKING: Master config files were modified but perf-changelog.yaml was not updated. When changing configs/amd-master.yaml or configs/nvidia-master.yaml, you must add a corresponding entry to perf-changelog.yaml documenting the changes.

Why it matters: The perf changelog is how config-driven performance changes are tracked chronologically; this PR changes what dsv4-fp4-gb200-dynamo-vllm-mtp2 measures (synthetic acceptance, AL=2.27) and adds a new -nosynthetic baseline key.

Fix: Append an entry to the end of perf-changelog.yaml (the file is read chronologically, oldest at top) documenting the synthetic-acceptance enablement and the new baseline key, with a link to this PR.

model: deepseek-ai/DeepSeek-V4-Pro
model-prefix: dsv4
runner: gb200
precision: fp4
framework: dynamo-vllm
multinode: true
disagg: true
scenarios:
fixed-seq-len:
- isl: 8192
osl: 1024
search-space:
# Aggregate low latency: TP=8, max-num-seqs=4.
- conc-list: [1]
spec-decoding: mtp
prefill:
num-worker: 1
tp: 8
ep: 1
dp-attn: false
additional-settings:
- "SYNTHETIC_ACCEPTANCE=true"
- "SYNTHETIC_ACCEPTANCE_LENGTH=2.27"
- "CONFIG_FILE=recipes/vllm/deepseek-v4/8k1k/agg-gb200-low-latency-mtp2.yaml"
decode:
num-worker: 0
tp: 8
ep: 1
dp-attn: false

# Low-latency bridge: 1 prefill (DEP=8) + 4 decode (TP=8), no offload.
- conc-list: [16, 32, 64]
spec-decoding: mtp
prefill:
num-worker: 1
tp: 8
ep: 8
dp-attn: true
additional-settings:
- "SYNTHETIC_ACCEPTANCE=true"
- "SYNTHETIC_ACCEPTANCE_LENGTH=2.27"
- "CONFIG_FILE=recipes/vllm/deepseek-v4/8k1k/disagg-gb200-low-latency-mtp2.yaml"
decode:
num-worker: 4
tp: 8
ep: 1
dp-attn: false

# MegaMOE mid curve: 1 prefill (DEP=8) + 1 decode (DEP=8).
# 5 nodes total with a dedicated NATS/etcd infra node.
- conc-list: [128, 256, 512, 1024]
spec-decoding: mtp
prefill:
num-worker: 1
tp: 8
ep: 8
dp-attn: true
additional-settings:
- "SYNTHETIC_ACCEPTANCE=true"
- "SYNTHETIC_ACCEPTANCE_LENGTH=2.27"
- "CONFIG_FILE=recipes/vllm/deepseek-v4/8k1k/disagg-gb200-mid-curve-megamoe-mtp2.yaml"
decode:
num-worker: 1
tp: 8
ep: 8
dp-attn: true

# MegaMOE high throughput: 2 prefill (DEP=8 each) + 1 decode (DEP=8).
# 7 nodes total with a dedicated NATS/etcd infra node.
- conc-list: [1024]
spec-decoding: mtp
prefill:
num-worker: 2
tp: 8
ep: 8
dp-attn: true
additional-settings:
- "SYNTHETIC_ACCEPTANCE=true"
- "SYNTHETIC_ACCEPTANCE_LENGTH=2.27"
- "CONFIG_FILE=recipes/vllm/deepseek-v4/8k1k/disagg-gb200-high-tpt-megamoe-mtp2.yaml"
decode:
num-worker: 1
tp: 8
ep: 8
dp-attn: true

# Baseline (no synthetic acceptance) variant of dsv4-fp4-gb200-dynamo-vllm-mtp2
# for before/after Pareto comparison. Identical topology and recipes, only the
# SYNTHETIC_ACCEPTANCE envs are removed.
dsv4-fp4-gb200-dynamo-vllm-mtp2-nosynthetic:
image: vllm/vllm-openai:v0.20.1-ubuntu2404
model: deepseek-ai/DeepSeek-V4-Pro
model-prefix: dsv4
Expand Down
164 changes: 164 additions & 0 deletions runners/inject_synthetic_acceptance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Inject synthetic acceptance parameters into an srt-slurm recipe (generic driver).

This is the framework-agnostic half of the synthetic-acceptance mechanism. It
decides *whether* to inject (the ``SYNTHETIC_ACCEPTANCE`` flag) and *what* mean
acceptance length to inject, then delegates the actual recipe rewrite to a
framework-specific backend (see ``runners/synthetic_injectors/``).

The script is a no-op (exit 0, file untouched) when:
- SYNTHETIC_ACCEPTANCE is unset/false,
so existing callers that do not opt in get exactly the previous behavior. When
enabled it requires a backend registered for the given framework; the vLLM
backend is added in a follow-up framework-support change.

Environment variables:
SYNTHETIC_ACCEPTANCE "true" to enable (default: "false")
SYNTHETIC_ACCEPTANCE_LENGTH target mean acceptance length; if unset, it is
auto-resolved from the reference AL YAML using
MODEL_PREFIX (+ NUM_SPEC_TOKENS / THINKING_MODE)
NUM_SPEC_TOKENS number of speculative tokens (for auto-lookup;
falls back to the value parsed from the recipe)
MODEL_PREFIX model prefix key in the reference YAML (e.g. "dsv4")
THINKING_MODE "thinking_on" / "thinking_off" — only used when the
reference YAML is in the thinking matrix form
(default: "thinking_on")
FRAMEWORK framework key selecting the backend (e.g.
"dynamo-vllm"); may also be passed as argv[2].

Usage (from a runner; use an absolute path since runners cd into the srt-slurm
clone before invoking this):
python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "${CONFIG_FILE%%:*}" "$FRAMEWORK"
"""

import os
import sys

from synthetic_injectors import get_injector

# MODEL_PREFIX -> top-level key in speedbench-reference-al.yaml.
MODEL_PREFIX_TO_YAML_KEY = {
"dsv4": "deepseek-v4-pro",
"dsr1": "deepseek-r1",
}


def _log(msg):
print(f"[Synthetic AR] {msg}")


def _yaml_key(model_prefix):
return MODEL_PREFIX_TO_YAML_KEY.get(model_prefix, model_prefix)


def _lookup_al(model_block, num_spec_tokens):
"""Resolve AL for num_spec_tokens from either reference-YAML shape.

Flat list form: [ {1: 1.90}, {2: 2.60}, ... ]
Thinking matrix: { thinking_on: {1: ...}, thinking_off: {1: ...} }
"""
# Flat list form (each item is a single-key {level: al} mapping).
if isinstance(model_block, list):
for item in model_block:
if num_spec_tokens in item:
return item[num_spec_tokens]
return None

if isinstance(model_block, dict):
# Thinking matrix form: pick the requested mode, then index by level.
if any(str(k).startswith("thinking") for k in model_block):
mode = os.environ.get("THINKING_MODE", "thinking_on").strip() or "thinking_on"
mode_block = model_block.get(mode)
if mode_block is None:
sys.exit(
f"ERROR: THINKING_MODE='{mode}' not found in reference YAML "
f"(available: {sorted(model_block)})"
)
return mode_block.get(num_spec_tokens)
# Plain {level: al} mapping.
return model_block.get(num_spec_tokens)

return None


def _resolve_al(config_text, injector, ref_yaml):
explicit = os.environ.get("SYNTHETIC_ACCEPTANCE_LENGTH", "").strip()
if explicit:
return float(explicit)

if not os.path.isfile(ref_yaml):
sys.exit(
"ERROR: SYNTHETIC_ACCEPTANCE_LENGTH not set and reference YAML not "
f"found: {ref_yaml}"
)

import yaml # local import: only needed on the auto-lookup path

with open(ref_yaml) as f:
data = yaml.safe_load(f)

key = _yaml_key(os.environ.get("MODEL_PREFIX", ""))
model_block = data.get(key)
if model_block is None:
sys.exit(f'ERROR: model key "{key}" not found in {ref_yaml}')

nst_env = os.environ.get("NUM_SPEC_TOKENS", "").strip()
if nst_env:
num_spec_tokens = int(nst_env)
else:
num_spec_tokens = injector.spec_tokens_from_recipe(config_text) or 2

al = _lookup_al(model_block, num_spec_tokens)
if al is None:
sys.exit(f"ERROR: num_spec_tokens={num_spec_tokens} not found for {key} in {ref_yaml}")

_log(
f"Auto-resolved AL={al} from {ref_yaml} "
f"(model={key}, num_spec_tokens={num_spec_tokens})"
)
return float(al)


def inject(config_file, framework):
if os.environ.get("SYNTHETIC_ACCEPTANCE", "false") != "true":
return 0

injector = get_injector(framework)
if injector is None:
sys.exit(
"ERROR: SYNTHETIC_ACCEPTANCE=true but no synthetic-acceptance "
f"injector is registered for FRAMEWORK='{framework}'"
)

with open(config_file) as f:
content = f.read()

al = _resolve_al(
content,
injector,
os.path.join(os.path.dirname(__file__), "..", "benchmarks", "speedbench-reference-al.yaml"),
)

_log(f"Injecting synthetic acceptance (length={al}) into {config_file}")

new_content, count = injector.rewrite(content, al, _log)

if count == 0:
_log("WARNING: No speculative-config entries found to modify; leaving recipe unchanged")
return 0
Comment on lines +146 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 WARNING: When SYNTHETIC_ACCEPTANCE=true but the recipe contains no matching speculative-config: '<json>' line (e.g. the recipe uses double quotes, block style, or drops the field), this logs a warning and returns 0 — the sweep continues and publishes real-MTP numbers under the synthetic-acceptance config key.

Why it matters: The caller explicitly opted in, so "nothing injected" means the recipe and the config key have drifted apart; the resulting data is silently mislabeled rather than the job failing.

Fix: Treat this as an error when injection was explicitly requested:

Suggested change
if count == 0:
_log("WARNING: No speculative-config entries found to modify; leaving recipe unchanged")
return 0
if count == 0:
sys.exit(
"ERROR: SYNTHETIC_ACCEPTANCE=true but no speculative-config "
f"entries were found to modify in {config_file}"
)


with open(config_file, "w") as f:
f.write(new_content)
_log(f"Modified {count} speculative-config entries")
return 0


def main(argv):
if len(argv) not in (2, 3):
sys.exit("Usage: inject_synthetic_acceptance.py CONFIG_FILE [FRAMEWORK]")
framework = argv[2] if len(argv) == 3 else os.environ.get("FRAMEWORK", "")
return inject(argv[1], framework)


if __name__ == "__main__":
sys.exit(main(sys.argv))
25 changes: 19 additions & 6 deletions runners/launch_gb200-nv.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@
export MODEL_PATH="/mnt/lustre01/models/kimi-k2.5-nvfp4"
export SRT_SLURM_MODEL_PREFIX="kimi-k2.5-nvfp4"
elif [[ $MODEL_PREFIX == "dsv4" && $PRECISION == "fp4" ]]; then
# The FP4 checkpoint is staged on compute-visible Lustre. The former
# /mnt/numa1 path is no longer present on watchtower compute nodes;
# the lowercase Lustre sibling is the FP8 checkpoint, so keep the
# NVFP4 path explicit here.
export MODEL_PATH="/mnt/lustre01/models/DeepSeek-V4-Pro-NVFP4/"
# FP4 checkpoint on compute-visible Lustre (the /mnt/numa1 path is gone
# on watchtower compute nodes). Use the base DeepSeek-V4-Pro checkpoint,
# NOT the -NVFP4 re-quant: the recipe's served identity is plain
# deepseek-ai/DeepSeek-V4-Pro and the pinned v0.20.1 container's
# deepseek_v4 loader doesn't define the NVFP4 export's extra quant
# params (e.g. ffn.experts.w13_input_scale), which KeyErrors at load.
# The lowercase Lustre sibling is the FP8 checkpoint, so name the
# CamelCase FP4 path explicitly (Linux is case-sensitive).
export MODEL_PATH="/mnt/lustre01/models/DeepSeek-V4-Pro"
export SRT_SLURM_MODEL_PREFIX="deepseek-v4-pro"
elif [[ $MODEL_PREFIX == "minimaxm2.5" && $PRECISION == "fp4" ]]; then
export MODEL_PATH="/mnt/lustre01/models/MiniMax-M2.5-NVFP4"
Expand Down Expand Up @@ -84,8 +88,12 @@
uses_watchtower_shared_fs() {
case "$MODEL_PREFIX" in
minimaxm2.5|minimaxm3|kimik2.5) return 0 ;;
*) return 1 ;;
esac
# dsv4 multinode runs only under dynamo-vllm on watchtower, which likewise
# needs the srt-slurm workspace/outputs on a compute-visible shared FS
# (the runner home is not cross-mounted to compute nodes).
[[ "$FRAMEWORK" == "dynamo-vllm" && "$MODEL_PREFIX" == "dsv4" ]] && return 0
return 1
}

# === Cluster diagnostic probe for watchtower-hosted sweeps ===
Expand Down Expand Up @@ -462,6 +470,11 @@
# Keep the Slurm job name aligned with the GitHub runner name.
sed -i "s/^name:.*/name: \"${RUNNER_NAME}\"/" "$CONFIG_PATH"

# Optionally inject synthetic acceptance into the recipe's speculative-config
# when SYNTHETIC_ACCEPTANCE=true (no-op otherwise). Must run after the name
# override and before srtctl apply so the rendered job picks it up.
python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK"

Check warning on line 476 in runners/launch_gb200-nv.sh

View check run for this annotation

Claude / Claude Code Review

Synthetic-acceptance injection silently no-ops on failure

Two-layer silent failure in the new synthetic-acceptance injection. `launch_gb200-nv.sh:476` invokes `inject_synthetic_acceptance.py` without `|| exit 1`, and the script has only `set -x` (no `set -e`), so any `sys.exit(1)` from the injector is swallowed and `srtctl apply` proceeds with the unmodified recipe. Compounding this, `inject_synthetic_acceptance.py:146-148` returns 0 with only a WARNING when `count == 0` (e.g. the vLLM backend's single-quoted-JSON regex fails against a future block-sca

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 WARNING: The injector's exit status is ignored — this script runs under set -x only (no set -e), so if inject_synthetic_acceptance.py fails (sys.exit("ERROR: ...") on unregistered framework, bad SYNTHETIC_ACCEPTANCE_LENGTH, or an uncaught traceback), the sweep proceeds with the unmodified recipe.

Why it matters: The benchmark then runs real MTP acceptance but the results are recorded and published under the synthetic-acceptance config key — silently wrong data, which is worse than a failed job.

Fix:

Suggested change
python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK"
python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK" || { echo "Error: synthetic-acceptance injection failed" >&2; exit 1; }

Comment on lines +473 to +476

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Two-layer silent failure in the new synthetic-acceptance injection. launch_gb200-nv.sh:476 invokes inject_synthetic_acceptance.py without || exit 1, and the script has only set -x (no set -e), so any sys.exit(1) from the injector is swallowed and srtctl apply proceeds with the unmodified recipe. Compounding this, inject_synthetic_acceptance.py:146-148 returns 0 with only a WARNING when count == 0 (e.g. the vLLM backend's single-quoted-JSON regex fails against a future block-scalar/double-quoted recipe). When SYNTHETIC_ACCEPTANCE=true is set on the mtp2 configs, either path silently produces a run without synthetic acceptance whose results are then attributed to the synthetic-acceptance side of the -nosynthetic Pareto comparison this PR is built to enable. Neither path fires for the currently-shipped recipes — fix is trivial defense-in-depth: add || exit 1 on line 476, and have the injector sys.exit non-zero on count == 0 when SYNTHETIC_ACCEPTANCE=true was explicitly requested.

Extended reasoning...

What the bug is

This PR wires a new synthetic-acceptance injection step into launch_gb200-nv.sh and adds a dsv4-fp4-gb200-dynamo-vllm-mtp2-nosynthetic baseline so the sweep can Pareto-compare synthetic-AL results against a real-draft baseline. There are two independent silent-failure paths in that new plumbing:

Layer 1 — launcher swallows non-zero exits. runners/launch_gb200-nv.sh:5 has only set -x (no set -e), and line 476 invokes the injector as:

python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK"

with no || exit 1 trailer. The injector has five sys.exit("ERROR: ...") paths (no injector registered for framework, missing reference YAML when SYNTHETIC_ACCEPTANCE_LENGTH is unset, missing model key, missing num_spec_tokens, missing THINKING_MODE). Any of these produce exit code 1 — but the launcher just continues to srtctl apply with the unmodified recipe. Every other critical step in this same file (mkdir/rsync/git clone/make setup at 448, 455, and the entire clone-branch block) uses || exit 1, so this omission is out of local convention.

Layer 2 — injector silently no-ops on zero matches. runners/inject_synthetic_acceptance.py:146-148:

if count == 0:
    _log("WARNING: No speculative-config entries found to modify; leaving recipe unchanged")
    return 0

returns success even when SYNTHETIC_ACCEPTANCE=true was explicitly requested. The vLLM backend regex speculative-config:\s*'([^']+)' in runners/synthetic_injectors/vllm.py:22 only matches single-quoted JSON on a single line — a recipe converted to double-quoted style, block scalar (speculative-config: |), or bare flow style would silently yield count=0 and the injector would return 0, hiding the mismatch from the launcher entirely (even after fixing Layer 1).

Step-by-step proof (Layer 1)

  1. A future config sets SYNTHETIC_ACCEPTANCE=true and FRAMEWORK=dynamo-sglang (or any framework not in _INJECTORS).
  2. inject_synthetic_acceptance.py's inject() hits get_injector("dynamo-sglang")None.
  3. It calls sys.exit("ERROR: SYNTHETIC_ACCEPTANCE=true but no synthetic-acceptance injector is registered for FRAMEWORK='dynamo-sglang'") — process exits 1.
  4. Bash continues, because there's no set -e and no || exit 1. set -x echoes the exit; nothing stops the pipeline.
  5. srtctl apply runs with the unmodified recipe (real draft model MTP2, not synthetic AL=2.27).
  6. The benchmark completes and the JSON is saved under the config key. Downstream perf-changelog / the -nosynthetic Pareto comparison treats these numbers as the synthetic-acceptance side of the sweep — silently poisoned data.

Step-by-step proof (Layer 2)

  1. A future maintainer reformats agg-gb200-low-latency-mtp2.yaml to use block scalars for the speculative-config JSON (or double quotes — a stylistically valid change).
  2. _SPEC_CONFIG_RE.finditer(text) yields zero matches.
  3. injector.rewrite() returns ("<unmodified>", 0).
  4. The count == 0 branch logs a WARNING and returns 0.
  5. Launcher accepts exit 0 (even after fixing Layer 1) and proceeds to srtctl apply with the unmodified recipe.
  6. Same downstream mis-attribution as above.

Why this is nit-severity for THIS PR

I verified that none of the currently-shipped recipes trigger either failure mode:

  • All four mtp2 recipes referenced by dsv4-fp4-gb200-dynamo-vllm-mtp2 (agg-gb200-low-latency-mtp2.yaml, disagg-gb200-{low-latency,mid-curve-megamoe,high-tpt-megamoe}-mtp2.yaml) use the exact single-quoted JSON form speculative-config: '{"method":"mtp","num_speculative_tokens":2}' that the regex expects.
  • The config sets SYNTHETIC_ACCEPTANCE_LENGTH=2.27 explicitly on every scenario, so the reference-YAML lookup path (which has three of the five sys.exit calls) is skipped entirely.
  • FRAMEWORK=dynamo-vllm is registered by runners/synthetic_injectors/vllm.py:69, so the "no injector for framework" path doesn't fire.

So the immediate mtp2 vs mtp2-nosynthetic Pareto comparison this PR is enabling should be correct. This is defense-in-depth for the scaffolding that's explicitly designed to be reused across future frameworks (sglang, trtllm — see runners/synthetic_injectors/__init__.py's module docstring) and models.

How to fix

Two one-liners:

-python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK"
+python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK" || exit 1

and in inject_synthetic_acceptance.py:

     if count == 0:
-        _log("WARNING: No speculative-config entries found to modify; leaving recipe unchanged")
-        return 0
+        sys.exit(
+            "ERROR: SYNTHETIC_ACCEPTANCE=true was requested but no speculative-config "
+            f"entries matched in {config_file}; refusing to run an un-injected sweep"
+        )

Both changes match the fail-fast pattern used throughout the launcher (mkdir/rsync/git-clone/make setup all use || exit 1).


# Don't leak the login-node venv to the compute-node orchestrator. sbatch's
# default --export=ALL propagates VIRTUAL_ENV (set by `source
# .venv/bin/activate` above) into job_script_minimal.j2, whose
Expand Down
38 changes: 38 additions & 0 deletions runners/synthetic_injectors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Framework-specific synthetic-acceptance injectors.

The generic driver (``inject_synthetic_acceptance.py``) resolves *whether* to
inject and *what* acceptance length to use, then hands the recipe text to a
framework-specific injector registered here. This keeps the driver
framework-agnostic: adding a new backend (e.g. sglang, trtllm) means dropping a
module in this package and registering it, without touching the driver.

A backend is any object exposing:

rewrite(content: str, al: float, log) -> tuple[str, int]
Return the recipe text with the synthetic acceptance parameters applied
and the number of entries modified. ``log`` is a ``callable(str)`` used
for human-readable progress output.

spec_tokens_from_recipe(content: str) -> int | None
Best-effort extraction of the speculative-token count from the recipe,
used only for reference-AL auto-lookup. May return ``None``.
"""

# framework value passed by the runner (e.g. "dynamo-vllm") -> backend module.
# Populated by backend modules registering themselves at import time (see the
# `from . import` block below).
_INJECTORS = {}


def register(framework, backend):
_INJECTORS[framework] = backend


def get_injector(framework):
"""Return the backend for ``framework`` (as passed by the runner), or None."""
return _INJECTORS.get(framework)


# Import backends after register/get_injector are defined so each module can
# call register() at import time. Add new frameworks (sglang, trtllm, ...) here.
from . import vllm # noqa: E402,F401
Loading
Loading