Skip to content
Merged
93 changes: 93 additions & 0 deletions configs/nvidia-master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5220,6 +5220,99 @@ dsv4-fp4-gb200-dynamo-vllm-mtp2:
kv-p2p-transfer: nixl
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
runner: gb200
precision: fp4
framework: dynamo-vllm
router: { name: dynamo-router, version: "1.2.0.dev20260426" }
multinode: true
disagg: true
kv-p2p-transfer: nixl
scenarios:
fixed-seq-len:
- isl: 8192
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 @@ -156,11 +156,15 @@ elif [[ $FRAMEWORK == "dynamo-vllm" ]]; then
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 All @@ -184,8 +188,12 @@ NGINX_IMAGE="nginx:1.27.4"
uses_watchtower_shared_fs() {
case "$MODEL_PREFIX" in
minimaxm2.5|minimaxm3|kimik2.5|qwen3.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
}

SQUASH_FILE="${SQUASH_DIR}/$(echo "$IMAGE" | sed 's/[\/:@#]/_/g').sqsh"
Expand Down Expand Up @@ -561,6 +569,11 @@ fi
# 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"

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 +572 to +575

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