ci: dynamic partition + LPT from live sglang-ci-stats model (#25263)

This commit is contained in:
Liangsheng Yin
2026-05-14 02:35:59 -07:00
committed by GitHub
parent 373a22c225
commit 4593bbdf31
7 changed files with 233 additions and 36 deletions
@@ -34,6 +34,8 @@ on:
value: ${{ jobs.run.outputs.multimodal_gen }} value: ${{ jobs.run.outputs.multimodal_gen }}
partitions: partitions:
value: ${{ jobs.run.outputs.partitions }} value: ${{ jobs.run.outputs.partitions }}
partition_model_sha:
value: ${{ jobs.run.outputs.partition_model_sha }}
b200_runner: b200_runner:
value: ${{ jobs.run.outputs.b200_runner }} value: ${{ jobs.run.outputs.b200_runner }}
enable_retry: enable_retry:
@@ -60,6 +62,7 @@ jobs:
jit_kernel: ${{ steps.filter-api.outputs.jit_kernel || steps.filter.outputs.jit_kernel || steps.run-mode.outputs.run_all_tests }} jit_kernel: ${{ steps.filter-api.outputs.jit_kernel || steps.filter.outputs.jit_kernel || steps.run-mode.outputs.run_all_tests }}
multimodal_gen: ${{ steps.filter-api.outputs.multimodal_gen || steps.filter.outputs.multimodal_gen || steps.run-mode.outputs.run_all_tests }} multimodal_gen: ${{ steps.filter-api.outputs.multimodal_gen || steps.filter.outputs.multimodal_gen || steps.run-mode.outputs.run_all_tests }}
partitions: ${{ steps.partitions.outputs.partitions }} partitions: ${{ steps.partitions.outputs.partitions }}
partition_model_sha: ${{ steps.partition-model-sha.outputs.sha }}
b200_runner: ${{ steps.set-runner.outputs.b200_runner }} b200_runner: ${{ steps.set-runner.outputs.b200_runner }}
enable_retry: ${{ steps.set-retry.outputs.enable_retry }} enable_retry: ${{ steps.set-retry.outputs.enable_retry }}
continue_on_error: ${{ steps.set-continue-on-error.outputs.continue_on_error }} continue_on_error: ${{ steps.set-continue-on-error.outputs.continue_on_error }}
@@ -233,6 +236,33 @@ jobs:
fi fi
echo "full=$FULL" >> "$GITHUB_OUTPUT" echo "full=$FULL" >> "$GITHUB_OUTPUT"
- name: Resolve sglang-ci-stats SHA
id: partition-model-sha
env:
GH_TOKEN: ${{ github.token }}
run: |
# Pin all shards to one immutable commit so dispatch and every
# runtime LPT use the same model snapshot. Soft fail -> static.
SHA=$(gh api repos/sgl-project/sglang-ci-stats/commits/main --jq '.sha' 2>/dev/null || true)
if [[ -n "$SHA" ]]; then
echo "Pinned sglang-ci-stats@$SHA"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
else
echo "::warning::Could not resolve sglang-ci-stats SHA; using in-source est_time"
echo "sha=" >> "$GITHUB_OUTPUT"
fi
- name: Fetch live partition model
if: steps.partition-model-sha.outputs.sha != ''
run: |
# SHA resolved -> require fetch (curl --retry). Soft fallback
# would risk cross-shard LPT divergence on transient curl flake.
rm -f /tmp/partition-model.json
URL="https://raw.githubusercontent.com/sgl-project/sglang-ci-stats/${{ steps.partition-model-sha.outputs.sha }}/model.json"
curl --fail --silent --show-error --max-time 15 --retry 3 --retry-delay 2 \
"$URL" -o /tmp/partition-model.json
echo "Fetched partition-model.json ($(wc -c < /tmp/partition-model.json) bytes)"
- name: Compute partitions - name: Compute partitions
id: partitions id: partitions
run: | run: |
@@ -243,6 +273,7 @@ jobs:
# See scripts/ci/utils/compute_partitions.py. # See scripts/ci/utils/compute_partitions.py.
python3 scripts/ci/utils/compute_partitions.py \ python3 scripts/ci/utils/compute_partitions.py \
--full-parallel ${{ steps.parallel-mode.outputs.full }} \ --full-parallel ${{ steps.parallel-mode.outputs.full }} \
--partition-model-file /tmp/partition-model.json \
>> "$GITHUB_OUTPUT" >> "$GITHUB_OUTPUT"
- name: Set B200 runner tag - name: Set B200 runner tag
+13 -2
View File
@@ -35,9 +35,9 @@ on:
type: string type: string
required: true required: true
run_timeout_minutes: run_timeout_minutes:
description: 'timeout-minutes for the Run test step.' description: 'timeout-minutes for the Run test step. Required so compute_partitions.py can read it from pr-test.yml without a duplicated default constant.'
type: string type: string
default: '30' required: true
timeout_per_file: timeout_per_file:
description: 'run_suite.py --timeout-per-file value (empty = unset).' description: 'run_suite.py --timeout-per-file value (empty = unset).'
type: string type: string
@@ -147,6 +147,16 @@ jobs:
[ -f "${SGLANG_CI_VENV_PATH:-/dev/null}/env.sh" ] && source "${SGLANG_CI_VENV_PATH}/env.sh" [ -f "${SGLANG_CI_VENV_PATH:-/dev/null}/env.sh" ] && source "${SGLANG_CI_VENV_PATH}/env.sh"
python3 scripts/ci/cuda/warmup_server.py ${{ inputs.warmup_server_models }} python3 scripts/ci/cuda/warmup_server.py ${{ inputs.warmup_server_models }}
- name: Fetch live partition model
if: fromJson(inputs.check_changes).partition_model_sha != ''
run: |
# SHA resolved by check-changes -> require fetch (curl --retry)
# so all shards stay on the same snapshot.
rm -f /tmp/partition-model.json
URL="https://raw.githubusercontent.com/sgl-project/sglang-ci-stats/${{ fromJson(inputs.check_changes).partition_model_sha }}/model.json"
curl --fail --silent --show-error --max-time 15 --retry 3 --retry-delay 2 \
"$URL" -o /tmp/partition-model.json
- name: Run test - name: Run test
timeout-minutes: ${{ fromJson(inputs.run_timeout_minutes) }} timeout-minutes: ${{ fromJson(inputs.run_timeout_minutes) }}
env: env:
@@ -156,6 +166,7 @@ jobs:
python3 run_suite.py --hw cuda --suite ${{ inputs.self_name }} \ python3 run_suite.py --hw cuda --suite ${{ inputs.self_name }} \
--auto-partition-id ${{ matrix.partition }} \ --auto-partition-id ${{ matrix.partition }} \
--auto-partition-size ${{ fromJson(inputs.partitions)[inputs.self_name].size }} \ --auto-partition-size ${{ fromJson(inputs.partitions)[inputs.self_name].size }} \
--partition-model-file /tmp/partition-model.json \
${{ inputs.timeout_per_file && format('--timeout-per-file {0}', inputs.timeout_per_file) || '' }} \ ${{ inputs.timeout_per_file && format('--timeout-per-file {0}', inputs.timeout_per_file) || '' }} \
$CONTINUE_ON_ERROR_FLAG $CONTINUE_ON_ERROR_FLAG
+10
View File
@@ -372,6 +372,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
secrets: inherit secrets: inherit
# Runs on H100 (80GB, SM90) - tests that don't pass on 5090 (FA3, FP8, high VRAM, etc.) # Runs on H100 (80GB, SM90) - tests that don't pass on 5090 (FA3, FP8, high VRAM, etc.)
@@ -386,6 +387,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
timeout_per_file: '1800' timeout_per_file: '1800'
secrets: inherit secrets: inherit
@@ -400,6 +402,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
secrets: inherit secrets: inherit
stage-b-test-4-gpu-b200: stage-b-test-4-gpu-b200:
@@ -461,6 +464,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
secrets: inherit secrets: inherit
stage-c-test-8-gpu-h200: stage-c-test-8-gpu-h200:
@@ -474,6 +478,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
# Per-model TP must match the test's launch in test/registered/ -- see # Per-model TP must match the test's launch in test/registered/ -- see
# FALLBACK_ARGS in scripts/ci/cuda/warmup_deep_gemm.py for extra dp/ep # FALLBACK_ARGS in scripts/ci/cuda/warmup_deep_gemm.py for extra dp/ep
# flags. Only models that actually invoke DeepGEMM kernels at runtime # flags. Only models that actually invoke DeepGEMM kernels at runtime
@@ -494,6 +499,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
secrets: inherit secrets: inherit
stage-c-test-deepep-4-gpu-h100: stage-c-test-deepep-4-gpu-h100:
@@ -507,6 +513,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
warmup_deep_gemm_models: 'lmsys/sglang-ci-dsv3-test:4' warmup_deep_gemm_models: 'lmsys/sglang-ci-dsv3-test:4'
warmup_server_models: 'lmsys/sglang-ci-dsv3-test:4' warmup_server_models: 'lmsys/sglang-ci-dsv3-test:4'
secrets: inherit secrets: inherit
@@ -538,6 +545,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
timeout_per_file: '1800' timeout_per_file: '1800'
secrets: inherit secrets: inherit
@@ -552,6 +560,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '45'
timeout_per_file: '1800' timeout_per_file: '1800'
secrets: inherit secrets: inherit
@@ -566,6 +575,7 @@ jobs:
check_changes: ${{ toJson(needs.check-changes.outputs) }} check_changes: ${{ toJson(needs.check-changes.outputs) }}
caller_inputs: ${{ toJson(inputs) }} caller_inputs: ${{ toJson(inputs) }}
partitions: ${{ needs.check-changes.outputs.partitions }} partitions: ${{ needs.check-changes.outputs.partitions }}
run_timeout_minutes: '30'
timeout_per_file: '1800' timeout_per_file: '1800'
secrets: inherit secrets: inherit
+16 -3
View File
@@ -287,17 +287,30 @@ def ut_parse_one_file(filename: str) -> Tuple[List[CIRegistry], bool]:
return visitor.registries, visitor.has_main_entry return visitor.registries, visitor.has_main_entry
def auto_partition(files: List[CIRegistry], rank: int, size: int) -> List[CIRegistry]: def auto_partition(
files: List[CIRegistry],
rank: int,
size: int,
live_est: Optional[dict] = None,
) -> List[CIRegistry]:
"""Partition files into `size` sublists with approximately equal sums of """Partition files into `size` sublists with approximately equal sums of
estimated times using a greedy algorithm (LPT heuristic), and return the estimated times using a greedy algorithm (LPT heuristic), and return the
partition for the specified rank. partition for the specified rank.
`live_est`: optional `filename -> est seconds` overrides; missing
files fall back to in-source `est_time`.
""" """
if not files or size <= 0: if not files or size <= 0:
return [] return []
def est_of(f: CIRegistry) -> float:
if live_est is not None and f.filename in live_est:
return live_est[f.filename]
return f.est_time
# Sort by estimated_time descending; filename as tie-breaker for # Sort by estimated_time descending; filename as tie-breaker for
# deterministic partitioning regardless of glob ordering. # deterministic partitioning regardless of glob ordering.
sorted_files = sorted(files, key=lambda f: (-f.est_time, f.filename)) sorted_files = sorted(files, key=lambda f: (-est_of(f), f.filename))
partitions: List[List[CIRegistry]] = [[] for _ in range(size)] partitions: List[List[CIRegistry]] = [[] for _ in range(size)]
partition_sums = [0.0] * size partition_sums = [0.0] * size
@@ -306,7 +319,7 @@ def auto_partition(files: List[CIRegistry], rank: int, size: int) -> List[CIRegi
for file in sorted_files: for file in sorted_files:
min_sum_idx = min(range(size), key=partition_sums.__getitem__) min_sum_idx = min(range(size), key=partition_sums.__getitem__)
partitions[min_sum_idx].append(file) partitions[min_sum_idx].append(file)
partition_sums[min_sum_idx] += file.est_time partition_sums[min_sum_idx] += est_of(file)
if rank < size: if rank < size:
return partitions[rank] return partitions[rank]
+116 -27
View File
@@ -1,4 +1,4 @@
"""Sum CIRegistry est_time per per-commit suite and emit one $GITHUB_OUTPUT line """Sum est_time per per-commit suite and emit one $GITHUB_OUTPUT line
keyed by suite name. Consumed by pr-test.yml stage jobs as keyed by suite name. Consumed by pr-test.yml stage jobs as
`fromJson(needs.check-changes.outputs.partitions)['<suite>']`. `fromJson(needs.check-changes.outputs.partitions)['<suite>']`.
@@ -13,6 +13,8 @@ import math
import os import os
from collections import defaultdict from collections import defaultdict
import yaml # PyYAML; preinstalled on ubuntu-latest GHA runners.
REPO_ROOT = os.path.dirname( REPO_ROOT = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
) )
@@ -39,16 +41,36 @@ _STAGE_A_OVERRIDES = {
"stage-a-test-1-gpu-small": 1, "stage-a-test-1-gpu-small": 1,
} }
# Per-partition wall-clock target. ~20 min avg naive; worst-case LPT 4/3 _REUSABLE_STAGE_USES = "./.github/workflows/_pr-test-stage.yml"
# imbalance is ~27 min, still below the 30-min job-level timeout that acts
# as the real safety net. No LPT slop applied — we lean on the runtime
# timeout + the explicit MAX_PARTITION_SECONDS sanity check rather than
# padding partition count.
TARGET_SECONDS = 20 * 60
# Hard ceiling. Exceeded → raise, forcing the maintainer to split a slow file
# or bump TARGET_SECONDS deliberately. def load_run_timeouts(pr_test_yml_path: str) -> dict:
MAX_PARTITION_SECONDS = 30 * 60 """Map `self_name -> run_timeout_minutes` from pr-test.yml. The input
is required in `_pr-test-stage.yml` -- KeyError surfaces missing.
Inline stage-a-test-cpu is skipped (uses `_STAGE_A_OVERRIDES`)."""
with open(pr_test_yml_path) as f:
wf = yaml.safe_load(f)
timeouts = {}
for job_id, job in (wf.get("jobs") or {}).items():
if not isinstance(job, dict) or job.get("uses") != _REUSABLE_STAGE_USES:
continue
with_ = job.get("with") or {}
suite = with_.get("self_name", job_id)
timeouts[suite] = int(with_["run_timeout_minutes"])
if not timeouts:
raise RuntimeError(
f"load_run_timeouts: no jobs matched uses={_REUSABLE_STAGE_USES!r} "
f"in {pr_test_yml_path}. The reusable workflow path likely "
"changed -- update _REUSABLE_STAGE_USES."
)
return timeouts
def per_shard_target_seconds(suite: str, run_timeouts: dict) -> float:
"""Per-shard wall budget = 0.75 * stage timeout. 0.75 is the inverse
of LPT's 4/3 worst-case approximation ratio, so the most imbalanced
LPT shard fills exactly the timeout."""
return 0.75 * run_timeouts[suite] * 60
def discover_files(repo_root: str) -> list[str]: def discover_files(repo_root: str) -> list[str]:
@@ -70,43 +92,85 @@ def discover_files(repo_root: str) -> list[str]:
return files return files
def load_partition_model(path):
"""Read sglang-ci-stats' model.json; None on missing/unparsable.
Cross-repo schema -- guard against non-dict top-level."""
if not path or not os.path.exists(path):
return None
try:
with open(path) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def compute_max_parallel(size: int) -> int: def compute_max_parallel(size: int) -> int:
return max(size // 4, 1) return max(size // 3, 1)
def compute_partitions(tests, full_parallel=False): def compute_partitions(
tests, repo_root, run_timeouts, partition_model=None, full_parallel=False
):
"""Group per-commit tests by suite and emit partition metadata. """Group per-commit tests by suite and emit partition metadata.
`full_parallel=True` (scheduled cron or `high priority` PR) sets `run_timeouts`: `suite -> minutes` from `load_run_timeouts`.
max_parallel = size, lifting the matrix-fanout throttle. `partition_model`: optional sglang-ci-stats `model.json`; per-file
`est` and per-suite `(coeff, bias)` each fall back independently to
in-source `est_time` / `(1.0, 0.0)`.
`full_parallel=True` lifts the matrix-fanout throttle.
""" """
# Allowlist: stages pr-test.yml dispatches. Stress / weekly /
# nightly-* live in test/registered/ but pr-test doesn't run them.
dispatched_suites = set(run_timeouts) | set(_STAGE_A_OVERRIDES)
suite_tests = defaultdict(list) suite_tests = defaultdict(list)
for t in tests: for t in tests:
if t.backend not in _TARGET_BACKENDS: if t.backend not in _TARGET_BACKENDS:
continue continue
if t.nightly or t.disabled is not None: if t.nightly or t.disabled is not None:
continue continue
if t.effective_suite not in dispatched_suites:
continue
suite_tests[t.effective_suite].append(t) suite_tests[t.effective_suite].append(t)
est_table = (partition_model or {}).get("est", {})
fit_table = (partition_model or {}).get("fit", {})
result = {} result = {}
for suite, group in suite_tests.items(): for suite, group in suite_tests.items():
total = sum(t.est_time for t in group) live_est = est_table.get(suite, {})
total = 0.0
for t in group:
relpath = os.path.relpath(t.filename, repo_root)
total += live_est.get(relpath, t.est_time)
fit = fit_table.get(suite) or {}
coeff = fit.get("coeff", 1.0)
bias = fit.get("bias", 0.0)
# Each shard pays `bias` once, so size >= coeff*total / (target-bias).
if suite in _STAGE_A_OVERRIDES: if suite in _STAGE_A_OVERRIDES:
size = _STAGE_A_OVERRIDES[suite] size = _STAGE_A_OVERRIDES[suite]
max_parallel = size max_parallel = size
else: else:
size = max(1, math.ceil(total / TARGET_SECONDS)) target = per_shard_target_seconds(suite, run_timeouts)
max_parallel = size if full_parallel else compute_max_parallel(size) budget = target - bias
# Check naive average (total/size). LPT can be ~4/3 of that in if budget <= 0:
# worst case; the 30-min job timeout enforces the real ceiling at
# runtime. This build-time check fails fast on egregious misconfigs.
if total / size > MAX_PARTITION_SECONDS:
raise RuntimeError( raise RuntimeError(
f"Suite {suite!r}: total est_time {total:.0f}s / size {size} " f"Suite {suite!r}: fit bias={bias}s >= target={target}s. "
f"= {total / size:.0f}s exceeds MAX_PARTITION_SECONDS " "Investigate the fit or raise the stage's run_timeout_minutes."
f"({MAX_PARTITION_SECONDS}s). Split a slow file or raise "
f"TARGET_SECONDS deliberately."
) )
ideal_size = math.ceil(coeff * total / budget)
# ideal_size > len(group) -> slowest single file alone exceeds
# the per-shard budget; surface via raise instead of empty shard.
if ideal_size > len(group):
raise RuntimeError(
f"Suite {suite!r}: needs {ideal_size} shards but has only "
f"{len(group)} test file(s). target={target:.0f}s, "
f"coeff={coeff}, bias={bias}s, total_est={total:.0f}s."
)
size = max(1, ideal_size)
max_parallel = size if full_parallel else compute_max_parallel(size)
result[suite] = { result[suite] = {
"size": size, "size": size,
"arr": list(range(size)), "arr": list(range(size)),
@@ -130,14 +194,32 @@ def main():
default="false", default="false",
help="Lift the max_parallel throttle (set by schedule / `high priority`)", help="Lift the max_parallel throttle (set by schedule / `high priority`)",
) )
parser.add_argument(
"--partition-model-file",
default=None,
help="Path to sglang-ci-stats model.json (omit/missing -> static fallback)",
)
parser.add_argument(
"--pr-test-yml",
default=os.path.join(REPO_ROOT, ".github", "workflows", "pr-test.yml"),
help="Path to pr-test.yml; per-stage `run_timeout_minutes` is read from here.",
)
args = parser.parse_args() args = parser.parse_args()
files = discover_files(args.repo_root) files = discover_files(args.repo_root)
# Warn-not-fail on unregistered files: run_suite.py catches this at # Warn-not-fail on unregistered files: run_suite.py catches this at
# test-execution time with sanity_check=True; dispatch should keep going. # test-execution time with sanity_check=True; dispatch should keep going.
all_tests = collect_tests(files, sanity_check=False) all_tests = collect_tests(files, sanity_check=False)
partition_model = load_partition_model(args.partition_model_file)
run_timeouts = load_run_timeouts(args.pr_test_yml)
result = compute_partitions(all_tests, full_parallel=(args.full_parallel == "true")) result = compute_partitions(
all_tests,
repo_root=args.repo_root,
run_timeouts=run_timeouts,
partition_model=partition_model,
full_parallel=(args.full_parallel == "true"),
)
payload = json.dumps(result, separators=(",", ":"), sort_keys=True) payload = json.dumps(result, separators=(",", ":"), sort_keys=True)
if args.output_format == "gha": if args.output_format == "gha":
print(f"partitions={payload}") print(f"partitions={payload}")
@@ -148,9 +230,16 @@ def main():
if summary_path: if summary_path:
with open(summary_path, "a") as f: with open(summary_path, "a") as f:
f.write("## Partitions\n\n") f.write("## Partitions\n\n")
if partition_model is None:
src_note = "no live model -- static est_time + (coeff=1, bias=0)"
else:
src_note = (
f"live model `data_as_of={partition_model.get('data_as_of')}`, "
f"`n_runs={partition_model.get('n_runs')}`"
)
f.write( f.write(
f"`full_parallel={args.full_parallel}` " f"`full_parallel={args.full_parallel}` "
f"(`size//4` throttle is lifted when true)\n\n" f"(`size//3` throttle is lifted when true); {src_note}\n\n"
) )
f.write("| Suite | size | max_parallel |\n") f.write("| Suite | size | max_parallel |\n")
f.write("|---|---:|---:|\n") f.write("|---|---:|---:|\n")
@@ -21,7 +21,7 @@ from sglang.test.test_utils import (
try_cached_model, try_cached_model,
) )
register_cuda_ci(est_time=1800, suite="stage-c-test-dsv4-4-gpu-b200") register_cuda_ci(est_time=900, suite="stage-c-test-dsv4-4-gpu-b200")
MODEL = "deepseek-ai/DeepSeek-V4-Flash" MODEL = "deepseek-ai/DeepSeek-V4-Flash"
SERVER_LAUNCH_TIMEOUT = 3600 SERVER_LAUNCH_TIMEOUT = 3600
+45 -2
View File
@@ -1,8 +1,9 @@
import argparse import argparse
import glob import glob
import json
import os import os
import sys import sys
from typing import List from typing import Dict, List, Optional
import tabulate import tabulate
@@ -222,6 +223,29 @@ def pretty_print_tests(
print(msg, flush=True) print(msg, flush=True)
def load_live_est(
partition_model_file: Optional[str], suite: str, repo_root: str
) -> Optional[Dict[str, float]]:
"""`CIRegistry.filename -> est seconds` from `model.json est[suite]`;
None on any miss (caller falls back to in-source `est_time`)."""
if not partition_model_file or not os.path.exists(partition_model_file):
return None
try:
with open(partition_model_file) as f:
partition_model = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(partition_model, dict):
return None
suite_est = partition_model.get("est", {}).get(suite)
if not isinstance(suite_est, dict) or not suite_est:
return None
return {
os.path.join(repo_root, relpath): float(elapsed)
for relpath, elapsed in suite_est.items()
}
def run_a_suite(args): def run_a_suite(args):
hw = HW_MAPPING[args.hw] hw = HW_MAPPING[args.hw]
suite = args.suite suite = args.suite
@@ -261,7 +285,20 @@ def run_a_suite(args):
ci_tests, skipped_tests = filter_tests(all_tests, hw, suite, nightly) ci_tests, skipped_tests = filter_tests(all_tests, hw, suite, nightly)
if auto_partition_size: if auto_partition_size:
ci_tests = auto_partition(ci_tests, auto_partition_id, auto_partition_size) live_est = load_live_est(args.partition_model_file, suite, repo_root)
if live_est is not None:
print(
f"LPT: {len(live_est)} live est entries from {args.partition_model_file}",
flush=True,
)
else:
print(
f"LPT: no live est ({args.partition_model_file!r}); using in-source est_time",
flush=True,
)
ci_tests = auto_partition(
ci_tests, auto_partition_id, auto_partition_size, live_est=live_est
)
pretty_print_tests(args, ci_tests, skipped_tests) pretty_print_tests(args, ci_tests, skipped_tests)
@@ -343,6 +380,12 @@ def main():
default=600, default=600,
help="Additional timeout in seconds when retry is enabled (default: 600)", help="Additional timeout in seconds when retry is enabled (default: 600)",
) )
parser.add_argument(
"--partition-model-file",
type=str,
default=None,
help="Path to sglang-ci-stats model.json for live LPT est; missing/malformed -> in-source est_time fallback.",
)
args = parser.parse_args() args = parser.parse_args()
# Validate auto-partition arguments # Validate auto-partition arguments