sGPU Test Scheduling: Global Work Queue - #696
Open
VeeraRajasekhar wants to merge 11 commits into
Open
Conversation
VeeraRajasekhar
force-pushed
the
veergopu/ci_test_optim
branch
5 times, most recently
from
August 13, 2026 18:19
adcc813 to
c440e8b
Compare
VeeraRajasekhar
force-pushed
the
veergopu/ci_test_optim
branch
from
August 14, 2026 16:27
c440e8b to
ac62b9a
Compare
VeeraRajasekhar
marked this pull request as ready for review
August 14, 2026 21:15
VeeraRajasekhar
requested review from
ipanfilo,
wangye805 and
wenchenvincent
as code owners
August 14, 2026 21:15
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces a self-calibrating, LPT-ordered global queue for distributing sGPU tests dynamically across available GPUs.
Changes:
- Adds test enumeration, filtered dispatch, and setup-hoisting hooks.
- Adds queue execution, weight learning, and scheduling reports.
- Integrates cached weights and queue artifacts into ROCm CI.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
.gitignore |
Ignores queue outputs and weights. |
.github/workflows/rocm-ci.yml |
Integrates queue execution and caching. |
.github/scripts/run_queue_sgpu.sh |
Implements the global worker queue. |
.github/scripts/ci_sgpu_queue.conf |
Defines queued suites and modes. |
.github/scripts/scheduler/build_weights.py |
Orders items and updates weights. |
.github/scripts/scheduler/queue_files.py |
Parses timing records. |
.github/scripts/scheduler/schedule_report.py |
Generates scheduling reports. |
ci/_utils.sh |
Adds scheduler hooks and tag validation. |
ci/pytorch.sh |
Supports PyTorch item listing and dispatch. |
ci/jax.sh |
Supports JAX setup hoisting and unique tags. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # Example usage: | ||
| # TEST_LEVEL=1 .github/scripts/run_queue_sgpu.sh | ||
| # HIP_VISIBLE_DEVICES=0,1 TEST_LEVEL=1 .github/scripts/run_queue_sgpu.sh | ||
| set -u |
Comment on lines
+176
to
+177
| rm -rf "${REPO_ROOT}/test-results" | ||
| rm -rf "$ITEM_LOG_DIR" "$SUITE_LOG_DIR" |
Comment on lines
+331
to
+333
| line=$(sed -n "${i}p" "$QUEUE_FILE") | ||
| [[ -z "$line" ]] && break | ||
| IFS=$'\t' read -r weight label cmd tag rest <<< "$line" |
Comment on lines
+422
to
+444
| for i in "${!SUITE_LABELS[@]}"; do | ||
| label="${SUITE_LABELS[$i]}" | ||
| # Phase 1 fails a suite that expands to nothing, so this should never skip. | ||
| # It stays because the alternative to skipping is an empty suite log and | ||
| # rc=0, which reads as "passed" to the workflow's gate. | ||
| awk -F'\t' -v l="$label" '$2==l {found=1} END {exit !found}' "$QUEUE_FILE" || continue | ||
| suite_log="$SUITE_LOG_DIR/${SUITE_LOGFILES[$i]}" | ||
| : > "$suite_log" | ||
| worst=0 | ||
| for itemlog in "$ITEM_LOG_DIR/${label}."*.log; do | ||
| [[ -e "$itemlog" ]] || continue | ||
| rc=$(cat "${itemlog}.rc" 2>/dev/null || echo 1) | ||
| iname=$(basename "$itemlog" .log) | ||
| printf '%-4s rc=%-4s items/%s\n' \ | ||
| "$([[ "$rc" == "0" ]] && echo ok || echo FAIL)" "$rc" \ | ||
| "${iname}.log" >> "$suite_log" | ||
| [[ "$rc" == "0" ]] && continue | ||
| worst=$rc | ||
| FAILED_ITEMS+=( "${itemlog#"${REPO_ROOT}/"}" ) | ||
| done | ||
| echo "$worst" > "${suite_log}.rc" | ||
| [[ "$worst" != "0" ]] && OVERALL_RC=$worst | ||
| done |
Comment on lines
+150
to
+153
| f"{new:.0f}s" if new is not None else "unknown", | ||
| "unknown" if not known else f"{row.est}s", | ||
| f"{row.secs}s" + (" (cut)" if row.incomplete == 1 else ""), | ||
| f"{(row.secs - row.est) * 100 / row.est:+.0f}%" if known else "n/a", |
Comment on lines
+2
to
+4
| # Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved. | ||
| # | ||
| # See LICENSE for license information. |
Comment on lines
+2
to
+4
| # Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved. | ||
| # | ||
| # See LICENSE for license information. |
Comment on lines
+2
to
+4
| # Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved. | ||
| # | ||
| # See LICENSE for license information. |
Comment on lines
+2
to
+4
| # Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved. | ||
| # | ||
| # See LICENSE for license information. |
Comment on lines
+1
to
+3
| # Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved. | ||
| # | ||
| # See LICENSE for license information. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
sGPU Test Scheduling — Global Work Queue
Design document for
.github/scripts/run_queue_sgpu.shand the scheduler package under.github/scripts/scheduler/, which replace the static one-suite-per-GPU sGPU runner with a single LPT-ordered, self-calibrating work queue.Contents
1. Analysis
Three ways to spread the same body of sGPU work across 4 GPUs. Measured on gfx950 at
TEST_LEVEL=3: 10497s of total work, 80 work items.Refer: https://github.com/ROCm/TransformerEngine/actions/runs/31151497276
2. Problem
run_parallel_sgpu.shassigns one suite per GPU, statically, by config-file line order (ci_sgpu_jobs.confline i → GPU i). The suites are very unevenly sized, so most GPUs finish early and idle.Measured on gfx950 at
TEST_LEVEL=3:Wall clock equals the largest suite (6935s), so 4 GPUs deliver 10497 / (6935 × 4) = ~38% utilisation. Two structural causes:
The imbalance is self-worsening: it reflects whichever suite grew last, and every new test file makes it more permanent.
Goals
rocm-ci.ymlneeds a one-line change.3. Design
One global queue; N workers, each pinned to one GPU, each pulling the next item when it goes idle. A GPU stops working only when the queue is empty.
The queue itself is easy. The hard parts are (a) obtaining a correct item list without duplicating suite logic, and (b) dispatching an item without losing its configuration. Three mechanisms address them. All three are env-var hooks inside
ci/_utils.sh, so the suite scripts pick them up without knowing a scheduler exists — and each is explained in the phase that uses it rather than in the abstract:TE_CI_LIST_ITEMS=1TE_CI_SETUP_ONLY=1,TE_CI_SKIP_SETUP=1TEST_FILTER=<tag>4. Flow chart
Rounded boxes are steps, cylinders are files. Each file says what is in it.
flowchart TD A[("<b>the config</b><br/><i>which suites exist, and<br/>whether each one can be split</i>")] --> P0 subgraph SETUP [" "] P0["<b>Phase 0</b> — read the config<br/><i>load the suites; stop now if any<br/>label is missing or repeated</i>"] S["<b>Run setup</b><br/><i>which GPUs, which chip,<br/>where the logs go</i>"] P0 --> S end S --> P1 P1["<b>Phase 1</b> — expand<br/><i>ask each suite to list its own<br/>work items instead of running them</i>"] P1 -->|"what this machine will run"| Q0[("<b>the queue, unsorted</b><br/><i>one line per item:<br/>suite, command, tag</i>")] P1 -->|"what exists at this level"| IT[("<b>the item census</b><br/><i>every item that still exists,<br/>even ones skipped here</i>")] W0[("<b>the weight table</b><br/><i>how many seconds each item<br/>took the last few runs</i>")] subgraph PH2 ["<b>Phase 2</b> — weight and order"] P2A["<b>load the cached weights</b><br/><i>look up a duration for every queued item;<br/>anything never seen before counts as huge</i>"] P2B["<b>order the queue — longest first (LPT)</b><br/><i>big items go out early, so only short ones<br/>are left to fill the end of the run</i>"] P2A --> P2B end W0 -.Update the cached weights.-> P2A Q0 --> P2B P2B --> Q1[("<b>the run plan</b><br/><i>the same items, now in<br/>the order they get handed out</i>")] Q1 --> P3 P3["<b>Phase 3</b> — set up once<br/><i>pip install and CK-JIT build,<br/>one time per suite, not per item</i>"] P3 --> P4 P4["<b>Phase 4</b> — run the queue<br/><i>one worker per GPU; a worker takes<br/>new work only when it is free</i>"] P4 --> W1["worker on gpu0"] P4 --> W2["worker on gpu1"] P4 --> W3["worker on gpuN"] W1 & W2 & W3 -->|"a lock, so two workers<br/>never take the same item"| TK{{"take the next item<br/>off the plan"}} TK --> RUN["<b>re-enter the suite's own script</b><br/>ci/pytorch.sh, ci/jax.sh, …<br/>TEST_FILTER=tag<br/>TE_CI_SKIP_SETUP=1<br/>HIP_VISIBLE_DEVICES=gpu<br/><i>the script replays just that one<br/>call line, in its original config</i>"] RUN --> LOGS[("<b>per-item logs</b><br/><i>what each item printed,<br/>and whether it passed</i>")] RUN --> TIM[("<b>timings</b><br/><i>per item: which GPU, how long,<br/>pass/fail, was it cut off</i>")] RUN --> XML[("<b>JUnit XML</b><br/><i>the test results the CI<br/>report is built from</i>")] LOGS --> P5 P5["<b>Phase 5</b> — per-suite verdict<br/><i>did this suite pass? the worst<br/>item decides</i>"] P5 --> SL[("<b>per-suite result</b><br/><i>one exit code, plus an index<br/>of that suite's items</i>")] TIM --> P6 IT -.what to keep.-> P6 P6["<b>Phase 6</b> — learn<br/><i>blend today's durations into the<br/>weights; drop items that are gone</i>"] P6 --> W0 TIM --> P7 W0 -.read back.-> P7 P7["<b>Phase 7</b> — report<br/><i>how well the GPUs were packed,<br/>estimate vs actual per item</i>"] P7 --> RM[("<b>schedule report</b><br/><i>posted to the CI job summary</i>")] P5 --> P8["<b>Phase 8</b> — failure summary<br/><i>print the failed items,<br/>so the terminal ends useful</i>"] P8 --> EX["exit with the worst<br/>exit code seen"]Same flow in a terminal, with the real file names — the layout of each is in §6:
Reading the dispatch step (Phase 4). A worker never invokes pytest itself. It runs the suite's own entry script again —
ci/pytorch.sh,ci/jax.sh, whatever the config named — with three variables set, and that script replays exactly one of its own call lines:TEST_FILTER=<tag>NVTE_*prefix, pytest args and backend flags, because the line that runs it is unchangedTE_CI_SKIP_SETUP=1HIP_VISIBLE_DEVICES=<gpu>Full detail in Phase 4.
5. Phase
Phase 1 — expand every suite into work items
Each suite is
list(expandable) oropaque(a single item —coreandexampleshave no per-item filter mechanism).A list suite enumerates itself. The suite scripts encode substantial policy:
TEST_LEVELgating, the fused-attn backend matrix, runtime capability probes (check_supported mxfp8,check_supported flash_attn), and per-item env prefixes. Reimplementing that here would guarantee drift, so the scheduler asks the script instead.TE_CI_LIST_ITEMS=1makespytest_runprint the item's tag and return rather than run it — placed after the existing level, filter and uniqueness checks, so every gate still applies (ci/_utils.sh:416):TE_CI_LIST_ITEMS=1 ci/pytorch.shtherefore walks the real schedule and prints exactly what it would have executed, and the tags it prints are the same ones Phase 4 dispatches by.It is listed twice, because "will run" and "exists" are different questions:
TE_CI_LIST_ITEMS=1queue.tsv.raw+ TE_CI_SKIP_CHECK_SUPPORTED=1TEST_LEVELitems.tsvpass2 − pass1is what this host skipped — no flash-attn, say — and those tests do still exist. Only a tag in neither list is gone for good, and that is what Phase 6 prunes on (§7.3).Phase 2 — weight and order
build_weights.py orderjoins the raw queue against the weight table and sorts descending by weight, emittingweight ⇥ label ⇥ cmd ⇥ tag ⇥ rest.Longest-processing-time-first is the standard makespan heuristic: dispatch the long items first and what is left to fill the tail is short, so no GPU is still starting a big item once the others have gone idle.
An item the table has never seen takes
DEFAULT_WEIGHT=999999and therefore sorts first. unknown-first costs at most one item's runtime of slightly-wrong ordering; unknown-last makes the new item the tail, idling N−1 GPUs behind it.The phase prints the full dispatch plan before anything runs, ending with the lower bound
total_work / N— which is what the wall clock is later compared against.Phase 4 — run the queue
One background
workerper GPU. Workers share a single index file guarded byflock:Each worker loops: take an index,
sed -n "${i}p"the queue, dispatch, record.Dispatch is a re-entry of the suite script, not a pytest invocation. The obvious alternative — serialise each item (path,
-kexpression,NVTE_*vars) and have the scheduler call pytest itself — is fragile. Items such asNVTE_USE_ATOMIC_AMAX=1 NVTE_USE_CAST_TRANSPOSE_TRITON=1 \ run_default_fa_lbl "amax+triton" 3 test_numerics.pycarry state that is easy to drop, and dropping it means the test silently runs in the wrong configuration — a failure mode that produces green runs with no coverage. So the scheduler reconstructs nothing. It runs the suite's own command again with three variables set:
TEST_FILTERtest_numerics.ck.amax+tritoncheck_test_filterreturns early on every call line but the one owning that tag, which then runs with its original env prefix, level and arguments — unmodifiedTE_CI_SKIP_SETUP1check_setup_neededreports nothing to do, so pip and CK-JIT are not repeated; Phase 3 did them onceHIP_VISIBLE_DEVICES"$cmd"is the suite's own entry point from the config (ci/pytorch.sh,ci/jax.sh, …), so a dispatched item is the same invocation the suite would have made unattended. The tag is the only thing that crosses the scheduler boundary, andcheck_test_tag_unique(ci/_utils.sh:408) guarantees it selects exactly one call line. That round trip — a tag printed by Phase 1 selecting the identical item in Phase 4 — is the load-bearing assumption of the design, so it was verified directly: 123 tags round-tripped, 0 mismatches.An opaque item has an empty tag and takes the
elsebranch: noTEST_FILTER, noTE_CI_SKIP_SETUP, so the script runs end to end exactly as it does today.Pull-on-completion is what makes the system robust to bad weights. A poor ordering costs a little tail latency; it can never cost correctness or balance. The bootstrap weight table was once wrong by 16x on one file and the run still hit 99% utilisation.
Per item the worker writes four things:
items/<label>.<tag>.log— the test output;items/<label>.<tag>.log.rc— the exit code, which is what makes selective per-item reporting possible later;timings.tsv, appended as the item ends, so a killed run still records everything that had finished;[HH:MM:SS] gpu1 t+1204s 260s rc=0 est=245s torch test_numerics.ck.amax+triton.It also decides the
incompleteflag. Ate_ci_result_sink.partialsidecar that outlived the process means pytest never reached its end-of-session write, so the duration is where the item was cut off, not what it costs. The check happens here because this is the only moment it is unambiguous — see §7.2.No scheduler-imposed deadline is applied: the suite scripts' own
PYTEST_TIMEOUTand the workflow'stimeout-minutesremain the only limits, exactly as outside the queue.6. The weight table Storage and update rule
Storage is the GHA cache alone, keyed
te-sgpu-weights-<arch>-l<level>-<run_id>with a prefixrestore-keysfallback — the standard append-only idiom, since cache entries are immutable and a fresh key per run is how the table gets updated at all. There is deliberately no committed seed table: it would go stale silently and show up in every CI diff, whereas one unordered run per cache eviction is self-healing (that run writes the table the next one reads).The update is an asymmetric EWMA, per key:
Rises in ~2 runs, decays over ~10. The asymmetry follows from LPT's error profile: over-estimating an item only starts it earlier than necessary, while under-estimating puts a long item late and creates the tail. So react fast to growth, treat a single fast run as probably noise.
7. Usage
Inside the dev container, the whole sGPU set at level 1:
That is the complete command. The arch comes from
rocminfo, the config defaults toci_sgpu_queue.conf, logs go totest-results/logs/, and the weight table toci-weights/test_weights.<arch>.l1.txt.The queue uses every GPU it can see; restrict it as you would any ROCm program:
The whole surface is
--log-dir DIRplus an optional list of config files, taken positionally: