94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
1578 lines
54 KiB
Python
1578 lines
54 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["msgspec", "requests", "plotly", "kaleido", "numpy"]
|
|
# ///
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import math
|
|
import random
|
|
import statistics
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import msgspec
|
|
import numpy as np
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _load_module_by_path(name: str, path: Path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
try:
|
|
from sglang.benchmark.one_batch_server import (
|
|
DEFAULT_TIMEOUT,
|
|
should_skip_due_to_max_running_requests,
|
|
should_skip_due_to_token_capacity,
|
|
)
|
|
from sglang.benchmark.utils import get_tokenizer
|
|
from sglang.srt.speculative.dspark_components.dspark_sps import (
|
|
SpsAdditiveCostTable,
|
|
load_sps_table_from_path,
|
|
profile_sps_table,
|
|
)
|
|
except ImportError as exc:
|
|
logger.warning(
|
|
"Full sglang runtime unavailable (%s); using a torch-free fallback import. "
|
|
"The 'fit' subcommand works; 'run' requires the full sglang install.",
|
|
exc,
|
|
)
|
|
_benchmark_dir = Path(__file__).resolve().parent
|
|
_table_module = _load_module_by_path(
|
|
"dspark_sps",
|
|
_benchmark_dir.parent / "srt/speculative/dspark_components/dspark_sps.py",
|
|
)
|
|
SpsAdditiveCostTable = _table_module.SpsAdditiveCostTable
|
|
load_sps_table_from_path = _table_module.load_sps_table_from_path
|
|
profile_sps_table = _table_module.profile_sps_table
|
|
DEFAULT_TIMEOUT = 60
|
|
get_tokenizer = None
|
|
should_skip_due_to_max_running_requests = None
|
|
should_skip_due_to_token_capacity = None
|
|
|
|
DEFAULT_OUT = "dspark_sps.json"
|
|
DEFAULT_MAX_BATCH_SIZE = 256
|
|
DEFAULT_INPUT_LEN = 16
|
|
DEFAULT_TEMPERATURE = 1.0
|
|
DEFAULT_MIN_STEADY_STEPS = 32
|
|
DEFAULT_MIN_STEADY_SECONDS = 10.0
|
|
DEFAULT_ROUND_TIMEOUT_SECONDS = 300.0
|
|
ROUND_WARMUP_STEPS = 8
|
|
ROUND_STEP_SLACK = 64
|
|
STEP_TIME_FLOOR_SECONDS = 0.02
|
|
WARMUP_ROUND_STEADY_STEPS = 16
|
|
POLL_INTERVAL_SECONDS = 2.0
|
|
LOAD_JOIN_TIMEOUT_SECONDS = 60.0
|
|
MATCH_FRACTION_WARN = 0.9
|
|
MATCH_FRACTION_ERROR = 0.5
|
|
PROFILE_SEED = 42
|
|
REQUIRED_SIMULATE_ACC_LEN = 1.0
|
|
RANDOM_TOKEN_LOW = 1000
|
|
RANDOM_TOKEN_HIGH_MARGIN = 1000
|
|
|
|
STATIC_CONDITIONING_CAVEAT = (
|
|
"Profiled with SGLANG_RAGGED_VERIFY_MODE=static: a verify step of B tokens "
|
|
"comes from B/(gamma+1) requests, the fewest possible for that B. A "
|
|
"compact-mode step with the same B usually spans more requests and reads "
|
|
"more KV history, so the table slightly over-estimates steps_per_sec and "
|
|
"the scheduler may admit slightly more than optimal. Much smaller bias "
|
|
"than the retired non-spec decode proxy, and in the opposite direction."
|
|
)
|
|
CONVERSION_FORMULA = (
|
|
"batch_tokens = num_running_reqs_per_rank * verify_num_draft_tokens; "
|
|
"steps_per_sec = 1 / median(server-side step_time over aligned steady steps)"
|
|
)
|
|
|
|
|
|
class SpsRow(msgspec.Struct, frozen=True):
|
|
forward_ct: int
|
|
num_running_reqs: int
|
|
num_verify_tokens: int
|
|
step_time: float
|
|
|
|
|
|
# Server-side per-step record feed: the DsparkInfoDumper 'core' +
|
|
# 'step_cpu_time' components exported on /server_info.
|
|
INFO_RECORD_PAYLOAD_KEY = "dspark_info_record"
|
|
INFO_RECORD_ENABLE_HINT = (
|
|
"SGLANG_DSPARK_ENABLE_SPS_RECORD=1 (or SGLANG_DSPARK_DEBUG_DUMP="
|
|
"core,step_cpu_time) and SGLANG_RAGGED_VERIFY_MODE=static"
|
|
)
|
|
|
|
|
|
class ServerContext(msgspec.Struct, frozen=True):
|
|
base_url: str
|
|
tokenizer_path: str
|
|
tp_size: int
|
|
dp_size: int
|
|
verify_num_draft_tokens: int
|
|
simulate_acc_len: float
|
|
cuda_graph_max_bs: Optional[int]
|
|
skip_max_running_requests_threshold: float
|
|
skip_token_capacity_threshold: float
|
|
|
|
|
|
class RoundSettings(msgspec.Struct, frozen=True):
|
|
input_len: int
|
|
temperature: float
|
|
min_steady_steps: int
|
|
min_steady_seconds: float
|
|
round_timeout_seconds: float
|
|
ramp_token_slack: int = 0
|
|
|
|
|
|
class LoadInfo(msgspec.Struct, frozen=True):
|
|
num_requests: int
|
|
max_new_tokens: int
|
|
wall_seconds: float
|
|
reached_target: bool
|
|
|
|
|
|
class RoundOutcome(msgspec.Struct, frozen=True):
|
|
batch_size: int
|
|
batch_size_per_rank: int
|
|
batch_tokens: int
|
|
steps_per_sec: float
|
|
num_steady_steps: int
|
|
match_fraction: float
|
|
per_rank_median_step_time: list[float]
|
|
rank_rows: list[list[SpsRow]]
|
|
load_info: LoadInfo
|
|
frac: Optional[float] = None
|
|
|
|
|
|
def out_paths(*, out: str) -> dict[str, Path]:
|
|
out_path = Path(out).expanduser()
|
|
return {
|
|
"table": out_path,
|
|
"records": out_path.with_name(out_path.stem + ".records.jsonl"),
|
|
"rounds": out_path.with_name(out_path.stem + ".rounds.jsonl"),
|
|
"manifest": out_path.with_name(out_path.name + ".manifest.json"),
|
|
"plot": out_path.with_name(out_path.stem + ".plot.png"),
|
|
}
|
|
|
|
|
|
def run_profile(
|
|
*,
|
|
base_url: str,
|
|
batch_sizes: list[int],
|
|
settings: RoundSettings,
|
|
out: str,
|
|
repeats: int,
|
|
local_tokenizer_path: Optional[str],
|
|
fracs: Optional[list[float]],
|
|
) -> None:
|
|
if get_tokenizer is None:
|
|
raise RuntimeError(
|
|
"'run' needs the full sglang runtime (torch, tokenizers, ...), but this "
|
|
"process loaded the torch-free fallback. Run 'run' where sglang is "
|
|
"installed; 'fit' works in either environment."
|
|
)
|
|
if not base_url:
|
|
raise ValueError(
|
|
"dspark_sps_profiler connects to an already-running DSpark server "
|
|
"(SGLANG_RAGGED_VERIFY_MODE=static, SGLANG_DSPARK_ENABLE_SPS_RECORD=1); "
|
|
"pass --base-url <url> (it never launches a server)."
|
|
)
|
|
|
|
offdiag = fracs is not None
|
|
if offdiag:
|
|
for frac in fracs:
|
|
if not 0.0 < frac <= 1.0:
|
|
raise ValueError(
|
|
f"--fracs values must be in (0, 1], got {frac}. The off-diagonal "
|
|
"budget pin runs frac * full verify, so frac <= 1.0 keeps M below "
|
|
"the full uniform tier and inside the captured cuda graphs."
|
|
)
|
|
|
|
paths = out_paths(out=out)
|
|
paths["table"].parent.mkdir(parents=True, exist_ok=True)
|
|
for path in (paths["records"], paths["rounds"]):
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
context = fetch_server_context(
|
|
base_url=base_url,
|
|
local_tokenizer_path=local_tokenizer_path,
|
|
allowed_modes=("compact", "cap-accept") if offdiag else ("static",),
|
|
)
|
|
vocab_size = len(get_tokenizer(context.tokenizer_path))
|
|
batch_sizes = sorted(set(batch_sizes))
|
|
validate_sweep_against_server(context=context, batch_sizes=batch_sizes)
|
|
rng = random.Random(PROFILE_SEED)
|
|
|
|
frac_sweep: list[Optional[float]] = sorted(fracs) if offdiag else [None]
|
|
|
|
run_warmup_round(
|
|
context=context,
|
|
vocab_size=vocab_size,
|
|
batch_sizes=batch_sizes,
|
|
settings=settings,
|
|
rng=rng,
|
|
frac=frac_sweep[-1],
|
|
)
|
|
|
|
rounds: list[RoundOutcome] = []
|
|
for repeat in range(max(1, repeats)):
|
|
for batch_size_per_rank in batch_sizes:
|
|
for frac in frac_sweep:
|
|
outcome = run_one_round(
|
|
context=context,
|
|
vocab_size=vocab_size,
|
|
batch_size_per_rank=batch_size_per_rank,
|
|
settings=settings,
|
|
rng=rng,
|
|
frac=frac,
|
|
)
|
|
if outcome is None:
|
|
continue
|
|
logger.info(
|
|
"Round bs=%s (per-rank %s, frac=%s, batch_tokens=%s) "
|
|
"repeat=%s/%s: steps_per_sec=%.3f over %s steady steps "
|
|
"(match_fraction=%.2f, wall=%.1fs, per-rank median "
|
|
"step_time=%s)",
|
|
outcome.batch_size,
|
|
outcome.batch_size_per_rank,
|
|
outcome.frac,
|
|
outcome.batch_tokens,
|
|
repeat + 1,
|
|
max(1, repeats),
|
|
outcome.steps_per_sec,
|
|
outcome.num_steady_steps,
|
|
outcome.match_fraction,
|
|
outcome.load_info.wall_seconds,
|
|
["%.4f" % value for value in outcome.per_rank_median_step_time],
|
|
)
|
|
append_round_files(
|
|
records_path=paths["records"],
|
|
rounds_path=paths["rounds"],
|
|
outcome=outcome,
|
|
repeat=repeat,
|
|
)
|
|
rounds.append(outcome)
|
|
|
|
if not rounds:
|
|
raise RuntimeError(
|
|
"No usable rounds (all were skipped by capacity guards or failed); "
|
|
"check the batch-size sweep against the server's "
|
|
"max_running_requests / KV capacity."
|
|
)
|
|
|
|
write_manifest(
|
|
manifest_path=paths["manifest"],
|
|
records_path=paths["records"],
|
|
rounds_path=paths["rounds"],
|
|
context=context,
|
|
batch_sizes=batch_sizes,
|
|
settings=settings,
|
|
repeats=repeats,
|
|
rounds=rounds,
|
|
fracs=fracs,
|
|
)
|
|
logger.info(
|
|
"Collected %s rounds; wrote %s, %s and %s",
|
|
len(rounds),
|
|
paths["rounds"].name,
|
|
paths["records"].name,
|
|
paths["manifest"].name,
|
|
)
|
|
|
|
|
|
def fit_profile(
|
|
*,
|
|
out: str,
|
|
max_batch_tokens: Optional[int],
|
|
self_check: bool,
|
|
plot: bool,
|
|
) -> None:
|
|
paths = out_paths(out=out)
|
|
if not paths["rounds"].exists():
|
|
raise FileNotFoundError(
|
|
f"No rounds file at {paths['rounds']}; run the 'run' subcommand first "
|
|
"(or point --out at a prior run's table path)."
|
|
)
|
|
|
|
summaries = load_round_summaries(rounds_path=paths["rounds"])
|
|
if not summaries:
|
|
raise RuntimeError(f"{paths['rounds']} has no rounds to fit.")
|
|
offdiag = any(summary.get("frac") is not None for summary in summaries)
|
|
|
|
table = build_table_from_summaries(
|
|
summaries=summaries, max_batch_tokens=max_batch_tokens, offdiag=offdiag
|
|
)
|
|
paths["table"].write_text(table.to_json(), encoding="utf-8")
|
|
if offdiag:
|
|
logger.info(
|
|
"Fit SpsAdditiveCostTable (%s bs probes x %s M probes) -> %s",
|
|
len(table.bs_probes),
|
|
len(table.m_probes),
|
|
paths["table"],
|
|
)
|
|
else:
|
|
logger.info(
|
|
"Fit SpsCostTable (%s probes) -> %s",
|
|
len(table.sample_batch_tokens),
|
|
paths["table"],
|
|
)
|
|
|
|
if plot:
|
|
plot_fit(
|
|
cells=summaries_to_cells(summaries=summaries),
|
|
table=table,
|
|
plot_path=paths["plot"],
|
|
)
|
|
|
|
if self_check:
|
|
run_self_check(out_path=paths["table"], offdiag=offdiag)
|
|
|
|
|
|
def profile_all(
|
|
*,
|
|
base_url: str,
|
|
batch_sizes: list[int],
|
|
settings: RoundSettings,
|
|
out: str,
|
|
max_batch_tokens: Optional[int],
|
|
repeats: int,
|
|
self_check: bool,
|
|
local_tokenizer_path: Optional[str],
|
|
fracs: Optional[list[float]],
|
|
plot: bool,
|
|
) -> None:
|
|
run_profile(
|
|
base_url=base_url,
|
|
batch_sizes=batch_sizes,
|
|
settings=settings,
|
|
out=out,
|
|
repeats=repeats,
|
|
local_tokenizer_path=local_tokenizer_path,
|
|
fracs=fracs,
|
|
)
|
|
fit_profile(
|
|
out=out,
|
|
max_batch_tokens=max_batch_tokens,
|
|
self_check=self_check,
|
|
plot=plot,
|
|
)
|
|
|
|
|
|
def load_round_summaries(*, rounds_path: Path) -> list[dict]:
|
|
return [
|
|
json.loads(line)
|
|
for line in rounds_path.read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
|
|
|
|
def summaries_to_cells(*, summaries: list[dict]) -> list[dict]:
|
|
return [
|
|
{
|
|
"bs": summary["batch_size_per_rank"],
|
|
"M": summary["batch_tokens"],
|
|
"T": 1.0 / summary["steps_per_sec"],
|
|
"frac": summary.get("frac"),
|
|
}
|
|
for summary in summaries
|
|
]
|
|
|
|
|
|
def build_table_from_summaries(
|
|
*, summaries: list[dict], max_batch_tokens: Optional[int], offdiag: bool
|
|
):
|
|
if offdiag:
|
|
return build_additive_table_from_cells(
|
|
cells=summaries_to_cells(summaries=summaries)
|
|
)
|
|
|
|
by_batch_tokens: dict[int, list[float]] = {}
|
|
for summary in summaries:
|
|
by_batch_tokens.setdefault(summary["batch_tokens"], []).append(
|
|
summary["steps_per_sec"]
|
|
)
|
|
probes = [
|
|
(batch_tokens, statistics.median(values))
|
|
for batch_tokens, values in sorted(by_batch_tokens.items())
|
|
]
|
|
return profile_sps_table(probes=probes, max_batch_tokens=max_batch_tokens)
|
|
|
|
|
|
def fetch_server_context(
|
|
*,
|
|
base_url: str,
|
|
local_tokenizer_path: Optional[str],
|
|
allowed_modes: tuple[str, ...] = ("static",),
|
|
) -> ServerContext:
|
|
response = requests.get(base_url + "/server_info", timeout=DEFAULT_TIMEOUT)
|
|
response.raise_for_status()
|
|
info = response.json()
|
|
|
|
speculative_algorithm = info.get("speculative_algorithm")
|
|
if speculative_algorithm != "DSPARK":
|
|
raise ValueError(
|
|
f"Profile against a DSpark server: {base_url} reports "
|
|
f"speculative_algorithm={speculative_algorithm!r}. The SPS table is "
|
|
"measured from real static-mode DSpark verify steps; relaunch with "
|
|
"--speculative-algorithm DSPARK and SGLANG_RAGGED_VERIFY_MODE=static."
|
|
)
|
|
if info.get("disable_cuda_graph"):
|
|
raise ValueError(
|
|
"The server runs with --disable-cuda-graph; an SPS table measured "
|
|
"without cuda graphs is uselessly slow. Relaunch with cuda graphs "
|
|
"enabled."
|
|
)
|
|
|
|
internal_states = info.get("internal_states") or []
|
|
if not internal_states:
|
|
raise RuntimeError(f"{base_url}/server_info returned no internal_states.")
|
|
sps_payloads = [state.get(INFO_RECORD_PAYLOAD_KEY) for state in internal_states]
|
|
for rank_index, payload in enumerate(sps_payloads):
|
|
if payload is None:
|
|
raise ValueError(
|
|
f"DP rank {rank_index} reports no {INFO_RECORD_PAYLOAD_KEY}; "
|
|
f"launch the server with {INFO_RECORD_ENABLE_HINT}."
|
|
)
|
|
if payload.get("mode") not in allowed_modes:
|
|
raise ValueError(
|
|
f"{INFO_RECORD_PAYLOAD_KEY}.mode must be one of {allowed_modes}, "
|
|
f"got {payload.get('mode')!r} on DP rank {rank_index}."
|
|
)
|
|
components = payload.get("components") or []
|
|
missing = {"core", "step_cpu_time"} - set(components)
|
|
if missing:
|
|
raise ValueError(
|
|
f"DP rank {rank_index} {INFO_RECORD_PAYLOAD_KEY} is missing "
|
|
f"component(s) {sorted(missing)}; launch with "
|
|
f"{INFO_RECORD_ENABLE_HINT}."
|
|
)
|
|
if payload.get("simulate_acc_len") != REQUIRED_SIMULATE_ACC_LEN:
|
|
raise ValueError(
|
|
f"DP rank {rank_index} reports simulate_acc_len="
|
|
f"{payload.get('simulate_acc_len')!r}, but SPS profiling "
|
|
f"requires exactly SGLANG_SIMULATE_ACC_LEN="
|
|
f"{REQUIRED_SIMULATE_ACC_LEN} (spec fully ineffective: every "
|
|
"step advances every request by exactly the bonus token, so "
|
|
"the per-step KV conditioning is deterministic instead of "
|
|
"drifting with the model's accept behavior)."
|
|
)
|
|
verify_num_draft_tokens = {
|
|
int(payload["verify_num_draft_tokens"]) for payload in sps_payloads
|
|
}
|
|
if len(verify_num_draft_tokens) != 1:
|
|
raise RuntimeError(
|
|
"DP ranks disagree on verify_num_draft_tokens: "
|
|
f"{sorted(verify_num_draft_tokens)}."
|
|
)
|
|
|
|
tokenizer_path = local_tokenizer_path or info.get("tokenizer_path")
|
|
if not tokenizer_path:
|
|
raise RuntimeError(
|
|
"Could not resolve a tokenizer path from /server_info; pass "
|
|
"--local-tokenizer-path explicitly."
|
|
)
|
|
|
|
internal_state = internal_states[0]
|
|
dp_size = int(internal_state.get("dp_size") or 1)
|
|
cuda_graph_max_bs = resolve_cuda_graph_max_bs(internal_state=internal_state)
|
|
max_running_per_dp = internal_state.get("effective_max_running_requests_per_dp", -1)
|
|
if max_running_per_dp and max_running_per_dp > 0:
|
|
skip_max_running = float(max_running_per_dp * dp_size)
|
|
else:
|
|
logger.warning(
|
|
"Server did not report effective_max_running_requests_per_dp (%s); "
|
|
"not clamping the batch-size sweep against the running cap.",
|
|
max_running_per_dp,
|
|
)
|
|
skip_max_running = float("inf")
|
|
|
|
skip_token_capacity = 0.0
|
|
for state in internal_states:
|
|
skip_token_capacity += state.get("memory_usage", {}).get(
|
|
"token_capacity", 1_000_000_000
|
|
)
|
|
|
|
return ServerContext(
|
|
base_url=base_url,
|
|
tokenizer_path=tokenizer_path,
|
|
tp_size=int(info.get("tp_size", 1) or 1),
|
|
dp_size=dp_size,
|
|
verify_num_draft_tokens=verify_num_draft_tokens.pop(),
|
|
simulate_acc_len=REQUIRED_SIMULATE_ACC_LEN,
|
|
cuda_graph_max_bs=cuda_graph_max_bs,
|
|
skip_max_running_requests_threshold=skip_max_running,
|
|
skip_token_capacity_threshold=skip_token_capacity,
|
|
)
|
|
|
|
|
|
def resolve_cuda_graph_max_bs(*, internal_state: dict) -> Optional[int]:
|
|
cuda_graph_config = internal_state.get("cuda_graph_config")
|
|
if not isinstance(cuda_graph_config, dict):
|
|
return None
|
|
decode_config = cuda_graph_config.get("decode")
|
|
if not isinstance(decode_config, dict):
|
|
return None
|
|
captured_bs = decode_config.get("bs")
|
|
if isinstance(captured_bs, list) and captured_bs:
|
|
return int(max(captured_bs))
|
|
max_bs = decode_config.get("max_bs")
|
|
if max_bs is not None:
|
|
return int(max_bs)
|
|
return None
|
|
|
|
|
|
def validate_sweep_against_server(
|
|
*, context: ServerContext, batch_sizes: list[int]
|
|
) -> None:
|
|
if context.cuda_graph_max_bs is None:
|
|
raise ValueError(
|
|
"Could not resolve the server's captured cuda-graph max batch size "
|
|
"from /server_info, so the sweep cannot be confirmed to stay inside "
|
|
"the captured decode graphs. Steps that fall back to eager silently "
|
|
"poison the table with a different perf regime. Relaunch the server "
|
|
"so it reports cuda_graph_config in /server_info, or -- if you really "
|
|
"want to profile anyway -- delete this raise, but be careful."
|
|
)
|
|
max_per_rank = max(batch_sizes)
|
|
if max_per_rank > context.cuda_graph_max_bs:
|
|
raise ValueError(
|
|
f"The sweep reaches {max_per_rank} running requests per DP rank but "
|
|
"the server captured decode cuda graphs only up to bs="
|
|
f"{context.cuda_graph_max_bs}; steps beyond it run eager and poison "
|
|
"the table. Relaunch the server with a larger --cuda-graph-max-bs-decode "
|
|
"or shrink --max-batch-size. If you really want to profile anyway, "
|
|
"delete this raise, but be careful."
|
|
)
|
|
|
|
|
|
def build_request_count_sweep(max_num_reqs: int) -> list[int]:
|
|
if max_num_reqs < 1:
|
|
raise ValueError(f"max_num_reqs must be >= 1, got {max_num_reqs}.")
|
|
raw = [
|
|
1,
|
|
2,
|
|
4,
|
|
8,
|
|
*range(16, 64, 8),
|
|
*range(64, 128, 16),
|
|
*range(128, 256, 32),
|
|
*range(256, max_num_reqs + 1, 64),
|
|
]
|
|
sweep = sorted({value for value in raw if 1 <= value <= max_num_reqs})
|
|
if sweep[-1] != max_num_reqs:
|
|
sweep.append(max_num_reqs)
|
|
return sweep
|
|
|
|
|
|
def round_max_new_tokens(*, settings: RoundSettings) -> int:
|
|
# simulate_acc_len is pinned to exactly 1.0 (enforced in
|
|
# fetch_server_context), so every step commits exactly the bonus token
|
|
# per request.
|
|
commit_tokens_per_step = 1
|
|
steady_steps_budget = max(
|
|
settings.min_steady_steps,
|
|
math.ceil(settings.min_steady_seconds / STEP_TIME_FLOOR_SECONDS),
|
|
)
|
|
total_steps = ROUND_WARMUP_STEPS + steady_steps_budget + ROUND_STEP_SLACK
|
|
return total_steps * commit_tokens_per_step + settings.ramp_token_slack
|
|
|
|
|
|
def run_warmup_round(
|
|
*,
|
|
context: ServerContext,
|
|
vocab_size: int,
|
|
batch_sizes: list[int],
|
|
settings: RoundSettings,
|
|
rng: random.Random,
|
|
frac: Optional[float] = None,
|
|
) -> None:
|
|
warmup_settings = RoundSettings(
|
|
input_len=settings.input_len,
|
|
temperature=settings.temperature,
|
|
min_steady_steps=WARMUP_ROUND_STEADY_STEPS,
|
|
min_steady_seconds=0.0,
|
|
round_timeout_seconds=settings.round_timeout_seconds,
|
|
ramp_token_slack=settings.ramp_token_slack,
|
|
)
|
|
try:
|
|
run_one_round(
|
|
context=context,
|
|
vocab_size=vocab_size,
|
|
batch_size_per_rank=min(8, max(batch_sizes)),
|
|
settings=warmup_settings,
|
|
rng=rng,
|
|
frac=frac,
|
|
)
|
|
except Exception:
|
|
logger.warning("Warmup round failed; continuing.", exc_info=True)
|
|
|
|
|
|
def run_one_round(
|
|
*,
|
|
context: ServerContext,
|
|
vocab_size: int,
|
|
batch_size_per_rank: int,
|
|
settings: RoundSettings,
|
|
rng: random.Random,
|
|
frac: Optional[float] = None,
|
|
) -> Optional[RoundOutcome]:
|
|
batch_size = batch_size_per_rank * context.dp_size
|
|
max_new_tokens = round_max_new_tokens(settings=settings)
|
|
if should_skip_due_to_max_running_requests(
|
|
batch_size, context.skip_max_running_requests_threshold
|
|
) or should_skip_due_to_token_capacity(
|
|
batch_size,
|
|
settings.input_len,
|
|
max_new_tokens,
|
|
context.skip_token_capacity_threshold,
|
|
):
|
|
return None
|
|
|
|
if frac is not None:
|
|
set_forced_budget_frac(base_url=context.base_url, frac=frac)
|
|
|
|
flush_cache(base_url=context.base_url)
|
|
watermarks = [
|
|
max((row.forward_ct for row in rows), default=-1)
|
|
for rows in fetch_rank_rows(base_url=context.base_url)
|
|
]
|
|
|
|
start_time = time.monotonic()
|
|
load_thread = start_load(
|
|
base_url=context.base_url,
|
|
num_requests=batch_size,
|
|
input_len=settings.input_len,
|
|
max_new_tokens=max_new_tokens,
|
|
temperature=settings.temperature,
|
|
vocab_size=vocab_size,
|
|
rng=rng,
|
|
)
|
|
reached_target = wait_for_aligned_steps(
|
|
context=context,
|
|
watermarks=watermarks,
|
|
batch_size_per_rank=batch_size_per_rank,
|
|
min_steady_steps=settings.min_steady_steps,
|
|
min_steady_seconds=settings.min_steady_seconds,
|
|
timeout_seconds=settings.round_timeout_seconds,
|
|
)
|
|
abort_all_requests(base_url=context.base_url)
|
|
load_thread.join(timeout=LOAD_JOIN_TIMEOUT_SECONDS)
|
|
if load_thread.is_alive():
|
|
logger.warning(
|
|
"Load batch for bs=%s did not return within %.0fs after abort; "
|
|
"continuing with the collected records.",
|
|
batch_size,
|
|
LOAD_JOIN_TIMEOUT_SECONDS,
|
|
)
|
|
wall_seconds = time.monotonic() - start_time
|
|
if not reached_target:
|
|
logger.warning(
|
|
"Round bs=%s hit the %.0fs timeout before both gates (>=%s steady "
|
|
"steps and >=%.1fs) were met; proceeding with what was collected.",
|
|
batch_size,
|
|
settings.round_timeout_seconds,
|
|
settings.min_steady_steps,
|
|
settings.min_steady_seconds,
|
|
)
|
|
|
|
rank_rows = fetch_rank_rows(base_url=context.base_url)
|
|
if len(rank_rows) != len(watermarks):
|
|
raise RuntimeError(
|
|
f"DP rank count changed mid-profile: {len(watermarks)} -> "
|
|
f"{len(rank_rows)}."
|
|
)
|
|
new_rank_rows = [
|
|
[row for row in rows if row.forward_ct > watermark]
|
|
for rows, watermark in zip(rank_rows, watermarks)
|
|
]
|
|
return postprocess_round(
|
|
rank_rows=new_rank_rows,
|
|
batch_size_per_rank=batch_size_per_rank,
|
|
dp_size=context.dp_size,
|
|
verify_num_draft_tokens=context.verify_num_draft_tokens,
|
|
min_steady_steps=settings.min_steady_steps,
|
|
load_info=LoadInfo(
|
|
num_requests=batch_size,
|
|
max_new_tokens=max_new_tokens,
|
|
wall_seconds=round(wall_seconds, 3),
|
|
reached_target=reached_target,
|
|
),
|
|
frac=frac,
|
|
)
|
|
|
|
|
|
def start_load(
|
|
*,
|
|
base_url: str,
|
|
num_requests: int,
|
|
input_len: int,
|
|
max_new_tokens: int,
|
|
temperature: float,
|
|
vocab_size: int,
|
|
rng: random.Random,
|
|
) -> threading.Thread:
|
|
token_high = vocab_size - RANDOM_TOKEN_HIGH_MARGIN
|
|
if token_high <= RANDOM_TOKEN_LOW:
|
|
raise ValueError(f"vocab_size={vocab_size} too small for random prompts.")
|
|
input_ids = [
|
|
[rng.randrange(RANDOM_TOKEN_LOW, token_high) for _ in range(input_len)]
|
|
for _ in range(num_requests)
|
|
]
|
|
payload = {
|
|
"input_ids": input_ids,
|
|
"sampling_params": {
|
|
"temperature": temperature,
|
|
"max_new_tokens": max_new_tokens,
|
|
"ignore_eos": True,
|
|
},
|
|
"stream": False,
|
|
}
|
|
|
|
def _post() -> None:
|
|
try:
|
|
requests.post(base_url + "/generate", json=payload, timeout=DEFAULT_TIMEOUT)
|
|
except Exception:
|
|
logger.warning(
|
|
"Load batch POST /generate failed (expected on abort for some "
|
|
"server versions).",
|
|
exc_info=True,
|
|
)
|
|
|
|
thread = threading.Thread(target=_post, daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
def wait_for_aligned_steps(
|
|
*,
|
|
context: ServerContext,
|
|
watermarks: list[int],
|
|
batch_size_per_rank: int,
|
|
min_steady_steps: int,
|
|
min_steady_seconds: float,
|
|
timeout_seconds: float,
|
|
) -> bool:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
steady_start: Optional[float] = None
|
|
while time.monotonic() < deadline:
|
|
time.sleep(POLL_INTERVAL_SECONDS)
|
|
try:
|
|
rank_rows = fetch_rank_rows(base_url=context.base_url)
|
|
except Exception:
|
|
logger.warning("Polling /server_info failed; retrying.", exc_info=True)
|
|
continue
|
|
new_rank_rows = [
|
|
[row for row in rows if row.forward_ct > watermark]
|
|
for rows, watermark in zip(rank_rows, watermarks)
|
|
]
|
|
if len(new_rank_rows) != len(watermarks):
|
|
continue
|
|
aligned = count_aligned_steps(
|
|
rank_rows=new_rank_rows, batch_size_per_rank=batch_size_per_rank
|
|
)
|
|
if aligned >= ROUND_WARMUP_STEPS and steady_start is None:
|
|
steady_start = time.monotonic()
|
|
steady_steps = max(0, aligned - ROUND_WARMUP_STEPS)
|
|
steady_seconds = (
|
|
time.monotonic() - steady_start if steady_start is not None else 0.0
|
|
)
|
|
logger.debug(
|
|
"Aligned-step poll: %d aligned (%d/%d steady steps, %.1f/%.1fs)",
|
|
aligned,
|
|
steady_steps,
|
|
min_steady_steps,
|
|
steady_seconds,
|
|
min_steady_seconds,
|
|
)
|
|
if steady_steps >= min_steady_steps and steady_seconds >= min_steady_seconds:
|
|
return True
|
|
return False
|
|
|
|
|
|
def count_aligned_steps(
|
|
*, rank_rows: list[list[SpsRow]], batch_size_per_rank: int
|
|
) -> int:
|
|
if any(not rows for rows in rank_rows):
|
|
return 0
|
|
by_ct_per_rank = [{row.forward_ct: row for row in rows} for rows in rank_rows]
|
|
common_cts = set(by_ct_per_rank[0])
|
|
for by_ct in by_ct_per_rank[1:]:
|
|
common_cts &= set(by_ct)
|
|
return sum(
|
|
1
|
|
for ct in common_cts
|
|
if all(
|
|
by_ct[ct].num_running_reqs == batch_size_per_rank
|
|
for by_ct in by_ct_per_rank
|
|
)
|
|
)
|
|
|
|
|
|
def abort_all_requests(*, base_url: str) -> None:
|
|
response = requests.post(
|
|
base_url + "/abort_request",
|
|
json={"abort_all": True},
|
|
timeout=DEFAULT_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
|
|
|
|
def flush_cache(*, base_url: str) -> None:
|
|
try:
|
|
requests.post(base_url + "/flush_cache", timeout=DEFAULT_TIMEOUT)
|
|
except Exception:
|
|
logger.warning("POST /flush_cache failed; continuing.", exc_info=True)
|
|
|
|
|
|
def set_forced_budget_frac(*, base_url: str, frac: Optional[float]) -> None:
|
|
response = requests.post(
|
|
base_url + "/set_internal_state",
|
|
json={"server_args": {"dspark_force_budget_frac": frac}},
|
|
timeout=DEFAULT_TIMEOUT,
|
|
).json()
|
|
outs = response if isinstance(response, list) else [response]
|
|
|
|
def _ok(out) -> bool:
|
|
return bool(out.get("updated")) if isinstance(out, dict) else bool(out)
|
|
|
|
if not outs or not all(_ok(out) for out in outs):
|
|
raise RuntimeError(
|
|
f"set dspark_force_budget_frac={frac} rejected by server: {response}"
|
|
)
|
|
|
|
|
|
def fetch_rank_rows(*, base_url: str) -> list[list[SpsRow]]:
|
|
response = requests.get(base_url + "/server_info", timeout=DEFAULT_TIMEOUT)
|
|
response.raise_for_status()
|
|
internal_states = response.json().get("internal_states") or []
|
|
rank_rows: list[list[SpsRow]] = []
|
|
for state in internal_states:
|
|
payload = state.get(INFO_RECORD_PAYLOAD_KEY) or {}
|
|
rows: list[SpsRow] = []
|
|
for record in payload.get("records", []):
|
|
step_cpu_ms = record.get("step_cpu_ms")
|
|
if step_cpu_ms is None:
|
|
continue
|
|
rows.append(
|
|
SpsRow(
|
|
forward_ct=int(record["forward_ct"]),
|
|
num_running_reqs=int(record["num_running_reqs"]),
|
|
num_verify_tokens=int(record["num_verify_tokens"]),
|
|
step_time=float(step_cpu_ms) / 1000.0,
|
|
)
|
|
)
|
|
rank_rows.append(rows)
|
|
return rank_rows
|
|
|
|
|
|
def postprocess_round(
|
|
*,
|
|
rank_rows: list[list[SpsRow]],
|
|
batch_size_per_rank: int,
|
|
dp_size: int,
|
|
verify_num_draft_tokens: int,
|
|
min_steady_steps: int,
|
|
load_info: LoadInfo,
|
|
frac: Optional[float] = None,
|
|
) -> RoundOutcome:
|
|
offdiag = frac is not None
|
|
batch_size = batch_size_per_rank * dp_size
|
|
expected_tokens = batch_size_per_rank * verify_num_draft_tokens
|
|
|
|
if len(rank_rows) != dp_size:
|
|
raise RuntimeError(
|
|
f"Expected records from {dp_size} DP ranks, got {len(rank_rows)}."
|
|
)
|
|
|
|
by_ct_per_rank: list[dict[int, SpsRow]] = []
|
|
for rank_index, rows in enumerate(rank_rows):
|
|
if not rows:
|
|
raise RuntimeError(
|
|
f"DP rank {rank_index} produced no new decode-step records this "
|
|
"round; the load generator did not reach it (DP imbalance or "
|
|
"the round was too short)."
|
|
)
|
|
by_ct_per_rank.append({row.forward_ct: row for row in rows})
|
|
|
|
common_cts = set(by_ct_per_rank[0])
|
|
for by_ct in by_ct_per_rank[1:]:
|
|
common_cts &= set(by_ct)
|
|
if not common_cts:
|
|
raise RuntimeError(
|
|
"DP ranks share no common forward_ct in this round; their step "
|
|
"counters are misaligned, so per-step cross-rank checks are "
|
|
"impossible. This breaks the uniformity assumption of the table."
|
|
)
|
|
|
|
aligned_cts: list[int] = []
|
|
aligned_verify_tokens: set[int] = set()
|
|
for ct in sorted(common_cts):
|
|
rows_at_ct = [by_ct[ct] for by_ct in by_ct_per_rank]
|
|
if all(row.num_running_reqs == batch_size_per_rank for row in rows_at_ct):
|
|
for rank_index, row in enumerate(rows_at_ct):
|
|
if not offdiag and row.num_verify_tokens < expected_tokens:
|
|
raise RuntimeError(
|
|
f"DP rank {rank_index} at forward_ct={ct} reports "
|
|
f"num_verify_tokens={row.num_verify_tokens}, expected at "
|
|
f"least {expected_tokens} (= {batch_size_per_rank} reqs x "
|
|
f"{verify_num_draft_tokens}); ranks are not running the "
|
|
"uniform static verify the table assumes. The recorded "
|
|
"count is the replayed graph tier, which may exceed the "
|
|
"candidate count when a bs is not an exact capture tier."
|
|
)
|
|
aligned_verify_tokens.add(row.num_verify_tokens)
|
|
aligned_cts.append(ct)
|
|
|
|
if len(aligned_cts) < ROUND_WARMUP_STEPS + min_steady_steps:
|
|
raise RuntimeError(
|
|
f"Round bs={batch_size} never stabilized: only {len(aligned_cts)} "
|
|
f"of {len(common_cts)} common decode steps had every rank at the "
|
|
f"target {batch_size_per_rank} requests (need at least "
|
|
f"{ROUND_WARMUP_STEPS + min_steady_steps}). Increase "
|
|
"--round-timeout / --target-steady-steps, or inspect the raw "
|
|
"records for retraction / DP imbalance."
|
|
)
|
|
|
|
window_cts = [
|
|
ct for ct in sorted(common_cts) if aligned_cts[0] <= ct <= aligned_cts[-1]
|
|
]
|
|
match_fraction = len(aligned_cts) / len(window_cts)
|
|
if match_fraction < MATCH_FRACTION_ERROR:
|
|
raise RuntimeError(
|
|
f"Round bs={batch_size} is unstable mid-round: only "
|
|
f"{match_fraction:.0%} of the {len(window_cts)} decode steps inside "
|
|
"the steady window ran at the target per-rank batch (retraction or "
|
|
"DP imbalance, not just ramp-in/drain). Inspect the raw records."
|
|
)
|
|
if match_fraction < MATCH_FRACTION_WARN:
|
|
logger.warning(
|
|
"Round bs=%s: %.0f%% of %s steady-window decode steps ran at the "
|
|
"target per-rank batch; treat this probe with suspicion.",
|
|
batch_size,
|
|
match_fraction * 100.0,
|
|
len(window_cts),
|
|
)
|
|
|
|
steady_cts = aligned_cts[ROUND_WARMUP_STEPS:]
|
|
per_ct_step_times = [
|
|
statistics.fmean(by_ct[ct].step_time for by_ct in by_ct_per_rank)
|
|
for ct in steady_cts
|
|
]
|
|
per_rank_median_step_time = [
|
|
statistics.median(by_ct[ct].step_time for ct in steady_cts)
|
|
for by_ct in by_ct_per_rank
|
|
]
|
|
median_step_time = statistics.median(per_ct_step_times)
|
|
|
|
if offdiag:
|
|
if len(aligned_verify_tokens) != 1:
|
|
raise RuntimeError(
|
|
f"Round bs={batch_size} frac={frac}: aligned steps ran at "
|
|
f"differing num_verify_tokens {sorted(aligned_verify_tokens)}; the "
|
|
"budget pin did not hold a single graph tier across all "
|
|
"ranks/steps, so the measurement is ambiguous. Inspect the raw "
|
|
"records."
|
|
)
|
|
graph_tier = aligned_verify_tokens.pop()
|
|
budget = int(frac * batch_size_per_rank * (verify_num_draft_tokens - 1))
|
|
batch_tokens = batch_size_per_rank + budget
|
|
if graph_tier < batch_tokens:
|
|
raise RuntimeError(
|
|
f"Round bs={batch_size} frac={frac}: replayed graph tier "
|
|
f"{graph_tier} is smaller than the pinned M={batch_tokens} "
|
|
f"(= {batch_size_per_rank} + int({frac} * {batch_size_per_rank} "
|
|
f"* {verify_num_draft_tokens - 1})); the budget pin did not take."
|
|
)
|
|
else:
|
|
batch_tokens = expected_tokens
|
|
|
|
return RoundOutcome(
|
|
batch_size=batch_size,
|
|
batch_size_per_rank=batch_size_per_rank,
|
|
batch_tokens=batch_tokens,
|
|
steps_per_sec=1.0 / median_step_time,
|
|
num_steady_steps=len(steady_cts),
|
|
match_fraction=match_fraction,
|
|
per_rank_median_step_time=per_rank_median_step_time,
|
|
rank_rows=rank_rows,
|
|
load_info=load_info,
|
|
frac=frac,
|
|
)
|
|
|
|
|
|
def fitted_step_time(*, table, bs: int, m: int) -> float:
|
|
if isinstance(table, SpsAdditiveCostTable):
|
|
return table.step_time(num_reqs=bs, budget=max(0, m - bs))
|
|
return 1.0 / table.lookup(m)
|
|
|
|
|
|
def bs_color_map(*, batch_sizes: list[int]) -> dict[int, str]:
|
|
span = max(len(batch_sizes) - 1, 1)
|
|
colors = {}
|
|
for index, bs in enumerate(batch_sizes):
|
|
hue = 240.0 * (1.0 - index / span)
|
|
colors[bs] = f"hsl({hue:.0f}, 70%, 50%)"
|
|
return colors
|
|
|
|
|
|
def plot_fit(*, cells: list[dict], table, plot_path: Path) -> None:
|
|
try:
|
|
import plotly.graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
except ImportError:
|
|
logger.warning(
|
|
"plotly not installed; skipping the fit plot. Install plotly + "
|
|
"kaleido to render %s.",
|
|
plot_path.name,
|
|
)
|
|
return
|
|
|
|
batch_sizes = sorted({cell["bs"] for cell in cells})
|
|
color_of = bs_color_map(batch_sizes=batch_sizes)
|
|
fig = make_subplots(
|
|
rows=1,
|
|
cols=3,
|
|
horizontal_spacing=0.07,
|
|
subplot_titles=(
|
|
"step time",
|
|
"throughput = M / T",
|
|
"raw (circle) vs fit (square)",
|
|
),
|
|
)
|
|
for bs in batch_sizes:
|
|
points = sorted(
|
|
(cell for cell in cells if cell["bs"] == bs), key=lambda c: c["M"]
|
|
)
|
|
m_values = [cell["M"] for cell in points]
|
|
t_ms = [cell["T"] * 1e3 for cell in points]
|
|
color = color_of[bs]
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=m_values,
|
|
y=t_ms,
|
|
mode="markers+lines",
|
|
name=f"bs={bs}",
|
|
legendgroup=f"bs={bs}",
|
|
marker=dict(color=color, size=7),
|
|
line=dict(color=color, width=1),
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=m_values,
|
|
y=[cell["M"] / cell["T"] for cell in points],
|
|
mode="markers+lines",
|
|
legendgroup=f"bs={bs}",
|
|
showlegend=False,
|
|
marker=dict(color=color, size=7),
|
|
line=dict(color=color, width=1),
|
|
),
|
|
row=1,
|
|
col=2,
|
|
)
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=m_values,
|
|
y=t_ms,
|
|
mode="markers",
|
|
legendgroup=f"bs={bs}",
|
|
showlegend=False,
|
|
marker=dict(color=color, size=8, symbol="circle"),
|
|
),
|
|
row=1,
|
|
col=3,
|
|
)
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=m_values,
|
|
y=[
|
|
fitted_step_time(table=table, bs=bs, m=cell["M"]) * 1e3
|
|
for cell in points
|
|
],
|
|
mode="markers",
|
|
legendgroup=f"bs={bs}",
|
|
showlegend=False,
|
|
marker=dict(
|
|
color=color, size=9, symbol="square-open", line=dict(width=2)
|
|
),
|
|
),
|
|
row=1,
|
|
col=3,
|
|
)
|
|
fig.update_xaxes(
|
|
title_text="M = num total verify tokens", rangemode="tozero", row=1, col=1
|
|
)
|
|
fig.update_xaxes(
|
|
title_text="M = num total verify tokens", rangemode="tozero", row=1, col=2
|
|
)
|
|
fig.update_xaxes(
|
|
title_text="M = num total verify tokens", rangemode="tozero", row=1, col=3
|
|
)
|
|
fig.update_yaxes(title_text="T = step time (ms)", rangemode="tozero", row=1, col=1)
|
|
fig.update_yaxes(
|
|
title_text="throughput (tokens/s)", rangemode="tozero", row=1, col=2
|
|
)
|
|
fig.update_yaxes(title_text="T = step time (ms)", rangemode="tozero", row=1, col=3)
|
|
fig.update_layout(
|
|
title="DSpark SPS profiler: raw cells vs additive fit",
|
|
legend_title="batch size",
|
|
template="plotly_white",
|
|
width=1700,
|
|
height=640,
|
|
)
|
|
try:
|
|
fig.write_image(str(plot_path), scale=2)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to render %s (kaleido missing?); skipping plot.",
|
|
plot_path.name,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
logger.info("Wrote fit plot to %s", plot_path)
|
|
|
|
|
|
def build_additive_table_from_cells(*, cells: list[dict]) -> SpsAdditiveCostTable:
|
|
if len(cells) < 4:
|
|
raise RuntimeError(
|
|
f"Off-diagonal fit needs at least 4 cells, got {len(cells)}."
|
|
)
|
|
bias, alpha, theta, _rel, _stats = ols_resid_backfit(cells)
|
|
bs_probes = sorted(alpha)
|
|
m_probes = sorted(theta)
|
|
return SpsAdditiveCostTable(
|
|
bias_seconds=bias,
|
|
bs_probes=bs_probes,
|
|
alpha_seconds=[alpha[b] for b in bs_probes],
|
|
m_probes=m_probes,
|
|
theta_seconds=[theta[m] for m in m_probes],
|
|
)
|
|
|
|
|
|
def ols_resid_backfit(cells: list, mbin_w: int = 64):
|
|
def mbin(m):
|
|
return round(m / mbin_w) * mbin_w
|
|
|
|
bslist = sorted({c["bs"] for c in cells})
|
|
mbins = sorted({mbin(c["M"]) for c in cells})
|
|
bs_col = {b: i for i, b in enumerate(bslist[1:])}
|
|
m_col = {m: i for i, m in enumerate(mbins[1:])}
|
|
num_cols = 1 + len(bs_col) + len(m_col)
|
|
|
|
design = np.zeros((len(cells), num_cols))
|
|
target = np.array([c["T"] for c in cells], dtype=float)
|
|
for row, c in enumerate(cells):
|
|
design[row, 0] = 1.0
|
|
if c["bs"] in bs_col:
|
|
design[row, 1 + bs_col[c["bs"]]] = 1.0
|
|
if mbin(c["M"]) in m_col:
|
|
design[row, 1 + len(bs_col) + m_col[mbin(c["M"])]] = 1.0
|
|
|
|
beta, _, _, _ = np.linalg.lstsq(design, target, rcond=None)
|
|
bias = float(beta[0])
|
|
alpha = {bslist[0]: 0.0}
|
|
for b in bslist[1:]:
|
|
alpha[b] = float(beta[1 + bs_col[b]])
|
|
theta = {mbins[0]: 0.0}
|
|
for m in mbins[1:]:
|
|
theta[m] = float(beta[1 + len(bs_col) + m_col[m]])
|
|
|
|
resid = [c["T"] - (bias + alpha[c["bs"]] + theta[mbin(c["M"])]) for c in cells]
|
|
rel = [abs(r) / c["T"] * 100 for r, c in zip(resid, cells)]
|
|
rms = (sum(r * r for r in resid) / len(resid)) ** 0.5
|
|
tbar = statistics.fmean(c["T"] for c in cells)
|
|
ss_tot = sum((c["T"] - tbar) ** 2 for c in cells)
|
|
r2 = 1.0 - sum(r * r for r in resid) / ss_tot if ss_tot > 0 else float("nan")
|
|
|
|
def probe_se(pred):
|
|
se = {}
|
|
for key in sorted({pred(c) for c in cells}):
|
|
rs = [r for r, c in zip(resid, cells) if pred(c) == key]
|
|
se[key] = (statistics.pstdev(rs) / (len(rs) ** 0.5)) if len(rs) > 1 else 0.0
|
|
return se
|
|
|
|
stats = {
|
|
"rms_ms": rms * 1e3,
|
|
"r2": r2,
|
|
"n": len(cells),
|
|
"alpha_se": probe_se(lambda c: c["bs"]),
|
|
"theta_se": probe_se(lambda c: mbin(c["M"])),
|
|
}
|
|
return bias, alpha, theta, rel, stats
|
|
|
|
|
|
def round_summary_dict(*, outcome: RoundOutcome, repeat: int) -> dict:
|
|
return {
|
|
"repeat": repeat,
|
|
"batch_size": outcome.batch_size,
|
|
"batch_size_per_rank": outcome.batch_size_per_rank,
|
|
"frac": outcome.frac,
|
|
"batch_tokens": outcome.batch_tokens,
|
|
"steps_per_sec": outcome.steps_per_sec,
|
|
"num_steady_steps": outcome.num_steady_steps,
|
|
"match_fraction": outcome.match_fraction,
|
|
"per_rank_median_step_time": outcome.per_rank_median_step_time,
|
|
"load_info": msgspec.to_builtins(outcome.load_info),
|
|
}
|
|
|
|
|
|
def append_round_files(
|
|
*,
|
|
records_path: Path,
|
|
rounds_path: Path,
|
|
outcome: RoundOutcome,
|
|
repeat: int,
|
|
) -> None:
|
|
with records_path.open("a", encoding="utf-8") as fout:
|
|
for rank_index, rows in enumerate(outcome.rank_rows):
|
|
for row in rows:
|
|
fout.write(
|
|
json.dumps(
|
|
{
|
|
"repeat": repeat,
|
|
"batch_size": outcome.batch_size,
|
|
"batch_size_per_rank": outcome.batch_size_per_rank,
|
|
"dp_rank": rank_index,
|
|
"forward_ct": row.forward_ct,
|
|
"num_running_reqs": row.num_running_reqs,
|
|
"num_verify_tokens": row.num_verify_tokens,
|
|
"step_time": row.step_time,
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
with rounds_path.open("a", encoding="utf-8") as fout:
|
|
fout.write(
|
|
json.dumps(round_summary_dict(outcome=outcome, repeat=repeat)) + "\n"
|
|
)
|
|
|
|
|
|
def write_manifest(
|
|
*,
|
|
manifest_path: Path,
|
|
records_path: Path,
|
|
rounds_path: Path,
|
|
context: ServerContext,
|
|
batch_sizes: list[int],
|
|
settings: RoundSettings,
|
|
repeats: int,
|
|
rounds: list[RoundOutcome],
|
|
fracs: Optional[list[float]],
|
|
) -> None:
|
|
manifest = {
|
|
"base_url": context.base_url,
|
|
"tp_size": context.tp_size,
|
|
"dp_size": context.dp_size,
|
|
"verify_num_draft_tokens": context.verify_num_draft_tokens,
|
|
"simulate_acc_len": context.simulate_acc_len,
|
|
"batch_size_per_rank_sweep": batch_sizes,
|
|
"fracs": fracs,
|
|
"settings": msgspec.to_builtins(settings),
|
|
"repeats": repeats,
|
|
"seed": PROFILE_SEED,
|
|
"timestamp": time.time(),
|
|
"timestamp_iso": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
|
|
"conversion_formula": CONVERSION_FORMULA,
|
|
"static_conditioning_caveat": STATIC_CONDITIONING_CAVEAT,
|
|
"records_jsonl": records_path.name,
|
|
"rounds_jsonl": rounds_path.name,
|
|
"round_summaries": [
|
|
round_summary_dict(outcome=outcome, repeat=0) for outcome in rounds
|
|
],
|
|
}
|
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def run_self_check(*, out_path: Path, offdiag: bool) -> None:
|
|
table = load_sps_table_from_path(str(out_path))
|
|
if offdiag:
|
|
run_additive_self_check(table=table)
|
|
return
|
|
if len(table.sample_batch_tokens) != len(table.sample_steps_per_sec):
|
|
raise RuntimeError("Reloaded table has mismatched probe / SPS lengths.")
|
|
|
|
previous_sps: Optional[float] = None
|
|
for batch_tokens in table.sample_batch_tokens:
|
|
looked_up = table.lookup(batch_tokens)
|
|
if looked_up <= 0:
|
|
raise RuntimeError(
|
|
f"Reloaded table lookup at batch_tokens={batch_tokens} returned "
|
|
f"non-positive SPS {looked_up}."
|
|
)
|
|
if previous_sps is not None and looked_up > previous_sps * 1.10:
|
|
logger.warning(
|
|
"Non-monotone SPS across probes: batch_tokens=%s SPS=%.3f rose "
|
|
"above the previous probe's SPS=%.3f by >10%%; verify the server "
|
|
"was at steady state (no co-tenants, steady clocks).",
|
|
batch_tokens,
|
|
looked_up,
|
|
previous_sps,
|
|
)
|
|
previous_sps = looked_up
|
|
|
|
below_floor = table.lookup(table.sample_batch_tokens[0] - 1)
|
|
if below_floor != table.sample_steps_per_sec[0]:
|
|
raise RuntimeError(
|
|
"Reloaded table lookup below the smallest probe did not clamp to the "
|
|
f"first SPS ({below_floor} != {table.sample_steps_per_sec[0]})."
|
|
)
|
|
logger.info(
|
|
"Self-check passed: reloaded %s probes, all lookups positive and "
|
|
"below-floor clamp holds.",
|
|
len(table.sample_batch_tokens),
|
|
)
|
|
|
|
|
|
def run_additive_self_check(*, table: SpsAdditiveCostTable) -> None:
|
|
for num_reqs in table.bs_probes:
|
|
for budget in (0, max(table.m_probes) - table.bs_probes[0]):
|
|
value = table.step_time(num_reqs=num_reqs, budget=max(0, budget))
|
|
if not value > 0:
|
|
raise RuntimeError(
|
|
f"Reloaded additive table step_time(num_reqs={num_reqs}, "
|
|
f"budget={budget}) is non-positive ({value})."
|
|
)
|
|
logger.info(
|
|
"Self-check passed: reloaded additive table (%s bs probes x %s M "
|
|
"probes), all step_time lookups positive.",
|
|
len(table.bs_probes),
|
|
len(table.m_probes),
|
|
)
|
|
|
|
|
|
def add_out_arg(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"--out",
|
|
type=str,
|
|
default=DEFAULT_OUT,
|
|
help="Output JSON path for the SPS table. Raw per-step records land next "
|
|
"to it as <stem>.records.jsonl, one line per (bs, frac) cell as "
|
|
"<stem>.rounds.jsonl, <out>.manifest.json ties everything together, and "
|
|
"the fit plot as <stem>.plot.png.",
|
|
)
|
|
parser.add_argument(
|
|
"--log-level",
|
|
type=str,
|
|
default="info",
|
|
help="Python logging level for the profiler.",
|
|
)
|
|
|
|
|
|
def add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"--base-url",
|
|
type=str,
|
|
default="",
|
|
help="Base URL of the already-running DSpark server, e.g. "
|
|
"http://localhost:30000. The profiler never launches a server.",
|
|
)
|
|
parser.add_argument(
|
|
"--fracs",
|
|
type=float,
|
|
nargs="+",
|
|
default=None,
|
|
help="Off-diagonal K-fraction sweep in (0, 1]. When given, the server "
|
|
"must run SGLANG_RAGGED_VERIFY_MODE=compact and each (bs, frac) cell "
|
|
"pins dspark_force_budget_frac to profile T(bs, M); the fit is a 2D "
|
|
"SpsAdditiveCostTable. When omitted, the diagonal static sweep runs and "
|
|
"the fit is a 1D SpsCostTable.",
|
|
)
|
|
parser.add_argument(
|
|
"--batch-size",
|
|
type=int,
|
|
nargs="+",
|
|
default=None,
|
|
help="Explicit PER-DP-RANK running-request counts to sweep; the load "
|
|
"generator sends value * dp_size requests so every rank (GPU group) "
|
|
"sits at the given batch. Overrides --max-batch-size when given.",
|
|
)
|
|
parser.add_argument(
|
|
"--max-batch-size",
|
|
type=int,
|
|
default=DEFAULT_MAX_BATCH_SIZE,
|
|
help="Upper bound of the auto-generated tapered PER-DP-RANK "
|
|
"request-count sweep (used only when --batch-size is not given), so "
|
|
"per-rank token coverage is identical for any dp_size.",
|
|
)
|
|
parser.add_argument(
|
|
"--input-len",
|
|
type=int,
|
|
default=DEFAULT_INPUT_LEN,
|
|
help="Prompt length per request. Short: the table is conditioned on the "
|
|
"decode-heavy regime.",
|
|
)
|
|
parser.add_argument(
|
|
"--temperature",
|
|
type=float,
|
|
default=DEFAULT_TEMPERATURE,
|
|
help="Sampling temperature for the load requests; default 1.0 to hit "
|
|
"the same accept/sampling kernels as real serving.",
|
|
)
|
|
parser.add_argument(
|
|
"--min-steady-steps",
|
|
type=int,
|
|
default=DEFAULT_MIN_STEADY_STEPS,
|
|
help="Stop a round only after at least this many aligned steady steps "
|
|
"(and --min-steady-seconds) have been collected, then abort the load "
|
|
"batch. Bounds cheap small-batch rounds; the batch is held at exactly "
|
|
"the target running-request count the whole time (no drain tail).",
|
|
)
|
|
parser.add_argument(
|
|
"--min-steady-seconds",
|
|
type=float,
|
|
default=DEFAULT_MIN_STEADY_SECONDS,
|
|
help="Stop a round only after at least this much steady-state wall time "
|
|
"(and --min-steady-steps) has elapsed. Bounds expensive large-batch "
|
|
"rounds, where a fixed step count would run many slow steps.",
|
|
)
|
|
parser.add_argument(
|
|
"--round-timeout",
|
|
type=float,
|
|
default=DEFAULT_ROUND_TIMEOUT_SECONDS,
|
|
help="Per-round wall-clock budget in seconds to collect the target "
|
|
"steps before giving up and using what was collected.",
|
|
)
|
|
parser.add_argument(
|
|
"--ramp-token-slack",
|
|
type=int,
|
|
default=0,
|
|
help="Extra per-request tokens on top of the step budget so requests "
|
|
"outlive the whole-batch prefill ramp. Required for long --input-len "
|
|
"at high batch sizes, where the ramp exceeds the request lifetime and "
|
|
"full-batch alignment becomes unreachable; size it as roughly "
|
|
"ramp_seconds / step_time.",
|
|
)
|
|
parser.add_argument(
|
|
"--repeats",
|
|
type=int,
|
|
default=1,
|
|
help="Times to repeat the whole sweep; per batch_tokens the median "
|
|
"steps_per_sec across repeats is taken.",
|
|
)
|
|
parser.add_argument(
|
|
"--local-tokenizer-path",
|
|
type=str,
|
|
default=None,
|
|
help="Override the tokenizer path (defaults to the one reported by "
|
|
"/server_info).",
|
|
)
|
|
|
|
|
|
def add_fit_args(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"--max-batch-tokens",
|
|
type=int,
|
|
default=None,
|
|
help="Override the diagonal table's max_batch_tokens metadata (defaults "
|
|
"to the largest probed batch_tokens). Ignored for off-diagonal fits.",
|
|
)
|
|
parser.add_argument(
|
|
"--no-self-check",
|
|
dest="self_check",
|
|
action="store_false",
|
|
help="Skip the read-back + lookup self-check of the written table.",
|
|
)
|
|
parser.add_argument(
|
|
"--no-plot",
|
|
dest="plot",
|
|
action="store_false",
|
|
help="Skip the <stem>.plot.png fit plot (needs plotly + kaleido).",
|
|
)
|
|
|
|
|
|
def run_settings(*, args: argparse.Namespace) -> RoundSettings:
|
|
return RoundSettings(
|
|
input_len=args.input_len,
|
|
temperature=args.temperature,
|
|
min_steady_steps=args.min_steady_steps,
|
|
min_steady_seconds=args.min_steady_seconds,
|
|
round_timeout_seconds=args.round_timeout,
|
|
ramp_token_slack=args.ramp_token_slack,
|
|
)
|
|
|
|
|
|
def run_batch_sizes(*, args: argparse.Namespace) -> list[int]:
|
|
if args.batch_size is not None:
|
|
return args.batch_size
|
|
return build_request_count_sweep(args.max_batch_size)
|
|
|
|
|
|
def cli_main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Profile a DSpark SPS cost table from an already-running DSpark "
|
|
"server. Subcommands: 'run' collects raw per-cell data, 'fit' builds "
|
|
"the table (and plot) from that data, 'all' does both."
|
|
)
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
run_parser = subparsers.add_parser(
|
|
"run", help="Collect raw sweep data (append one line per cell to jsonl)."
|
|
)
|
|
add_out_arg(run_parser)
|
|
add_run_args(run_parser)
|
|
|
|
fit_parser = subparsers.add_parser(
|
|
"fit", help="Fit the table and render the plot from a prior run's jsonl."
|
|
)
|
|
add_out_arg(fit_parser)
|
|
add_fit_args(fit_parser)
|
|
|
|
all_parser = subparsers.add_parser("all", help="Run then fit in one shot.")
|
|
add_out_arg(all_parser)
|
|
add_run_args(all_parser)
|
|
add_fit_args(all_parser)
|
|
|
|
args = parser.parse_args()
|
|
logging.basicConfig(
|
|
level=getattr(logging, args.log_level.upper()),
|
|
format="%(message)s",
|
|
)
|
|
|
|
if args.command == "run":
|
|
run_profile(
|
|
base_url=args.base_url,
|
|
batch_sizes=run_batch_sizes(args=args),
|
|
settings=run_settings(args=args),
|
|
out=args.out,
|
|
repeats=args.repeats,
|
|
local_tokenizer_path=args.local_tokenizer_path,
|
|
fracs=args.fracs,
|
|
)
|
|
elif args.command == "fit":
|
|
fit_profile(
|
|
out=args.out,
|
|
max_batch_tokens=args.max_batch_tokens,
|
|
self_check=args.self_check,
|
|
plot=args.plot,
|
|
)
|
|
else:
|
|
profile_all(
|
|
base_url=args.base_url,
|
|
batch_sizes=run_batch_sizes(args=args),
|
|
settings=run_settings(args=args),
|
|
out=args.out,
|
|
max_batch_tokens=args.max_batch_tokens,
|
|
repeats=args.repeats,
|
|
self_check=args.self_check,
|
|
local_tokenizer_path=args.local_tokenizer_path,
|
|
fracs=args.fracs,
|
|
plot=args.plot,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli_main()
|