Skip to content

Feat/rpoornac/partition mode sweep - #1299

Open
rpoornac wants to merge 3 commits into
mainfrom
feat/rpoornac/partition-mode-sweep
Open

Feat/rpoornac/partition mode sweep#1299
rpoornac wants to merge 3 commits into
mainfrom
feat/rpoornac/partition-mode-sweep

Conversation

@rpoornac

Copy link
Copy Markdown
Collaborator

Base this PR on feat/rpoornac/compute-partition-lever (#1288), not main. It
imports hyperloom.common.gpu_partition, which #1288 adds. Reviewing it against
main will show that module as missing.

What this is

#1288 made the partition mode a fixed session property: the optimizer reads it,
records it, refuses a session that cannot work in that shape, and never sets it.
That is the right split, but it leaves the obvious question unanswered — which
shape does this workload want? — and answering it by hand is four sessions and a
spreadsheet.

This is the other half of that boundary. It sets each mode on one card in turn,
runs the same benchmark on every partition the mode creates, sums the throughput,
prints the comparison, and restores the card's entry mode on the way out. The
privileged amd-smi set lives here and nowhere else: between benchmarks, in a
script an operator ran deliberately, rather than inside a loop that also runs
agent-authored code. optimize still never changes a mode.

mode   parts    CU    throughput  vs entry   worst lat
------------------------------------------------------------------------------
SPX        1   256        2560.0     1.00x   1000.0 ms
DPX        2   128        2560.0     1.00x   2000.0 ms
QPX        4    64        2560.0     1.00x   4000.0 ms
CPX        -     -             -         -           -   2 x 20.7 GiB will not fit a 36.0 GiB partition

The fan-out is the substance, not a detail

A benchmark that loads one partition and ignores the other seven measures an
eighth of the card. A sweep built that way would report CPX as catastrophically
slow while looking exactly like a partitioning comparison — which is the
misleading number #1288 promised not to produce, and the reason it shipped
without a sweep. So every partition is launched before any is waited on, and a
mode is reported only when all of its partitions came back. Six of eight summed
understates by a quarter while still looking like a result, so that is
"unmeasured", not "slow".

The table above is the validation. The synthetic workload's throughput scales
with CU count, so a correct fan-out shows a constant aggregate across modes —
SPX 1×2560, DPX 2×1280, QPX 4×640 — while a broken one would show DPX at half
and QPX at a quarter. Run on an 8-card MI355X node. The latency column moving
1000 → 2000 → 4000 ms is the cost being paid for it.

The enumeration rule, now measured rather than argued

#1288 justified publishing the shape instead of a device list by asserting that
HIP enumerates whole cards before partitions. That is now verified on hardware,
and the two tools genuinely disagree. With card 0 of 8 in CPX:

partitions of card 0 whole cards
amd-smi (orders by PCI address) devices 0-7 devices 8-14
HSA/HIP (rocminfo) devices 7-14 devices 0-6

So a device list computed with one tool and handed to the other is wrong, and
wrong invisibly — the benchmark runs to completion, on the wrong silicon. Under
DPX the partitions are HIP devices 7 and 8, exactly as
partition_device_predicate's docstring predicted.

That also settles the loose end #1288 disclosed: partition_device_predicate had
no caller in the tree. It has one now, and this script is the fan-out consumer
the published env contract was written for. It sets the same
HYPERLOOM_PARTITION_* variables a session sets, so an entrypoint written
against that contract behaves identically under either.

Selection matches CU count within the swept card's PCI bus. Both halves
matter: the CU match distinguishes a partition from a whole card, and the bus
match is what stops SPX — whose "partition" is a whole card — from collecting
all eight cards on the node.

Refusals instead of failures

The launch-time feasibility check from #1288 is reused per mode, so a mode whose
partitions provably cannot hold the configured streams is skipped with the
arithmetic shown rather than run into an out-of-memory failure. On MI355X that
correctly refuses CPX for a 20.7 GiB stream at two per partition: 36 GiB does
not hold 41.4.

The card is restored on every exit path, including a failure mid-sweep and a
Ctrl-C, and a failure to restore is its own exit code (3) rather than a line in
the log — a card left in a shape nobody asked for silently mislabels whatever
runs next. The sweep also refuses to start when processes hold GPU contexts,
since repartitioning evicts them; --allow-busy is there for when they are
yours.

Three traps found on the hardware

All three were real failures during bring-up, and each is now a named test.

  • rocminfo puts BDFID before Compute Unit in each agent block, so a
    line-at-a-time parser that emits on BDFID attributes every agent the
    previous one's CU count. It turned the first CPX partition into a 256-CU card
    and would have pointed the benchmark at whole silicon while labelling it CPX.
  • amd-smi reports an idle card's process_list as the string
    "No running processes detected"
    , not an empty list, so a length check finds
    one process on every idle card and the sweep refuses to start on a free node.
  • An inherited HIP_VISIBLE_DEVICES alongside the ROCR mask applies as a
    second mask indexing into the first
    , quietly redirecting every partition's
    work onto one device — every process succeeds and the mode reads as uniformly
    slow. Both it and CUDA_VISIBLE_DEVICES are removed rather than set.

Scope and shape

  • scripts/partition_mode_sweep.py, plus scripts/tests/ — 88 tests, none
    needing a GPU. Follows the platform_audit.py conventions: argparse, Usage::
    docstring, raise SystemExit(main()), tests loaded via importlib.
  • The mode-control machinery (readback verification, busy/drain retry, the
    profile query) is recovered from ff451d840, the revision of feat(platform): record, check and publish the compute-partition shape #1288 that had it
    before the mutation surface was removed from the optimizer. Its principle
    survives intact: a set is not a set until it reads back. amd-smi set
    reports success for a staged change and exits zero on some permission
    failures, so trusting the exit code means benchmarking the old topology under
    the new mode's name.
  • --dry-run prints the plan and touches nothing.
  • Takes either --benchmark-config (the in-tree Magpie invocation) or
    --benchmark-command with {device} / {output_dir} placeholders, substituted
    per already-split token so no shell is involved and a path with a space stays
    one argument.

What it does not do

It chooses a mode; it does not tune one. The intended sequence is: sweep to pick
the shape, then run optimize --compute-partition-mode <winner> so the session
refuses to start if the card is not actually in it. The report says so in as many
words, because a throughput table is easy to mistake for a tuning result.

@rpoornac
rpoornac requested a review from a team as a code owner August 27, 2026 00:47
@rpoornac
rpoornac requested a review from ZhengGong-amd August 27, 2026 00:48
@rpoornac
rpoornac changed the base branch from main to feat/rpoornac/compute-partition-lever August 27, 2026 00:50
Comment thread scripts/partition_mode_sweep.py Fixed
Comment thread scripts/partition_mode_sweep.py Fixed
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

CI E2E report — ✅ Succeeded

item value
result ✅ Succeeded
model Qwen/Qwen3-0.6B (dense)
resources 1× GPU, TP=1
PR branch feat/rpoornac/partition-mode-sweep
commit 49528a0ec351972a6e3b2ff900c3e84f88ab5a1b
session_id b3f758a4-7abf-471b-bac7-c2ce7485f344
queue → dispatch 0s
run time 167m 8s
total 167m 8s

details

@rpoornac

Copy link
Copy Markdown
Collaborator Author

/retest

@rpoornac
rpoornac force-pushed the feat/rpoornac/compute-partition-lever branch from 647cfc8 to ff6ff66 Compare August 27, 2026 16:41
@rpoornac
rpoornac force-pushed the feat/rpoornac/partition-mode-sweep branch from 5371e66 to 4041b23 Compare August 27, 2026 16:46
@ZhengGong-amd
ZhengGong-amd force-pushed the feat/rpoornac/compute-partition-lever branch from 0986b14 to 3044f81 Compare August 28, 2026 08:31
Base automatically changed from feat/rpoornac/compute-partition-lever to main August 28, 2026 08:41
rpoornac and others added 2 commits August 28, 2026 18:07
Choosing a partition mode was left to judgement. The optimizer treats the
mode as a fixed session property and asserts it, which is right -- a
card-wide privileged mutation does not belong in a loop that also runs
agent-authored code -- but it leaves the question of which shape a
workload actually wants unanswered, and answering it by hand means four
sessions and a spreadsheet.

This is the other half of that boundary. It sets each mode on one card in
turn, runs the same benchmark on every partition the mode creates, sums
the throughput, and restores the card's entry mode on the way out --
after a failure or a Ctrl-C included. The optimizer still never sets a
mode; the privileged amd-smi set lives here and nowhere else, in a script
an operator ran on purpose, between benchmarks rather than inside one.

The fan-out is the substance rather than a detail. A benchmark that loads
one partition and ignores the other seven measures an eighth of the card,
so a sweep without it would report CPX as catastrophically slow while
looking like a partitioning comparison -- the misleading number that kept
this out of the previous branch. Every partition is launched before any is
waited on, and a mode is reported only when all of its partitions came
back: six of eight summed understates by a quarter while still looking
like a result. Validated on an 8-card MI355X node with a synthetic
workload whose throughput scales with CU, where a correct fan-out is
visible as a constant aggregate across modes -- SPX 1x2560, DPX 2x1280,
QPX 4x640 -- and a broken one would show DPX at half.

Partitions are selected by matching CU count within the swept card's PCI
bus, never by device index, and the reason is now measured rather than
argued: on that node with card 0 in CPX, amd-smi orders by PCI address and
calls the partitions devices 0-7, while HSA/HIP enumerates whole cards
first and calls them 7-14. Under DPX the partitions are HIP devices 7 and
8, exactly as partition_device_predicate's docstring predicted, which also
gives that helper its first caller. The bus half of the match matters
separately: SPX's "partition" is a whole card, so CU alone would collect
all eight of them.

Reuses the launch-time feasibility check to skip modes whose partitions
provably cannot hold the configured streams, showing the arithmetic
instead of running into an out-of-memory failure -- on MI355X that
correctly refuses CPX for a 20.7 GiB stream at two per partition, since
36 GiB does not hold 41.4.

Three traps found on the hardware and encoded in the tests: rocminfo puts
BDFID before Compute Unit in each agent block, so a line-at-a-time parser
attributes every agent the previous one's CU count and silently turns the
first partition into a whole card; amd-smi reports an idle card's
process_list as the string "No running processes detected" rather than an
empty list, so a naive length check finds a process on every free node and
refuses to start; and an inherited HIP_VISIBLE_DEVICES alongside the
ROCR mask applies as a second mask indexing into the first, quietly
redirecting every partition's work onto one device.

88 tests, none needing a GPU.

Co-authored-by: Cursor <cursoragent@cursor.com>
CodeQL flagged the NaN guard in the sweep's local float coercion
(alert 2628, "comparison of identical values"): out == out is the classic
idiom but it reads as a tautology, and the scanner is right that
math.isnan states the intent.

The better fix is to delete the helper rather than repair it.
hyperloom.common.coerce.to_float already is this function -- rejecting
bool, rejecting non-finite, stripping strings, falling back to a default
-- and its docstring calls itself the single home for the idiom, with zero
first-party imports precisely so anything may depend on it. The local copy
was a reimplementation of a documented primitive that happened to also
carry a lint finding.

Behaviour is unchanged except that a numeric string with surrounding
whitespace now parses, which the tests already asserted should work.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rpoornac
rpoornac force-pushed the feat/rpoornac/partition-mode-sweep branch from 4041b23 to 56d0b43 Compare August 28, 2026 18:08
@rpoornac

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (d5f3561d4), no content change.

#1288 was squash-merged, so the commit this branch was stacked on
(ff6ff66f0) is no longer an ancestor of main. GitHub retargeted the base
here automatically, which left the PR showing 18 files and +4218 — all of
#1288 re-presented on top of merge conflicts. Dropping the stale commit and
replaying the sweep's own two onto main puts it back to what it should be:

CHANGELOG.md                               |   21 +
README.md                                  |    6 +
docs/reference/environment-variables.md    |   36 +
scripts/partition_mode_sweep.py            | 1123 +
scripts/tests/test_partition_mode_sweep.py |  609 +
5 files changed, 1795 insertions(+)

88 sweep tests pass against the merged tree, ruff check and ruff format
clean. Worth noting the one thing that could have broken: #1288's last review
round trimmed __all__ in common/gpu_partition.py, and this script imports
MODE_PARTITION_COUNTS, layout_for and partition_device_predicate from
there. Explicit imports are unaffected by __all__, and the tests confirm it.

The two earlier CodeQL notes on _as_float are stale — that helper is gone,
replaced by the shared hyperloom.common.coerce.to_float, which uses
math.isfinite.

@ZhengGong-amd

Copy link
Copy Markdown
Collaborator

Re-reviewed the current head (56d0b4382). The three "must-fix" items from the earlier pass are still present — the only new commit since then (use the canonical to_float) is unrelated.

  1. resident_processes fails open on schema drift. rows = payload if isinstance(payload, list) else [], and per-row key mismatches just continue. A wrapped/renamed amd-smi process payload silently becomes "no busy GPUs" and the sweep proceeds to amd-smi set, which evicts real processes. This guards a destructive op — it should raise SweepError (exit 2) on unexpected shape, not degrade to empty.

  2. Busy check is node-wide, but set only touches --gpu. occupied = {gpu: n for gpu, n in busy.items() if n} isn't filtered to args.gpu. On a shared node, any card being busy blocks scanning an idle target card, and the only escape (--allow-busy) also disables protection for the target card itself. Filter to {args.gpu}.

  3. An unexpected exception mid-sweep skips reporting and breaks the exit-code contract. Only (SweepError, PartitionError) are caught inside the loop; anything else (verified with a KeyError from set_mode) propagates out of main() past the finally. Restore is attempted, but render()/sweep_summary.json never run, and if restore itself also fails, the process exits with Python's default 1 instead of the documented 3 for "could not restore". Results from already-completed modes are lost.

Would like these addressed before merge.

Three fail-open paths around the one destructive operation in this script,
all found in review.

The busy check that stands in front of amd-smi set read any payload it did
not recognise as an idle node. rows = payload if isinstance(payload, list)
else [] answered {} for a wrapped or renamed process listing, and per-row
key mismatches were skipped, so the sweep proceeded to evict whatever was
running. Measured against the old parser: a dict-wrapped payload gave {},
and a process entry without the process_info wrapper gave zero live
processes on a card that had one. Both now raise SweepError, which is exit
2 before anything is set. The same strictness catches the inverse, which
was also live: a process_list arriving as the bare idle string was iterated
character by character and counted as 29 processes, refusing the sweep on a
free node -- the exact failure the sentinel constant exists to prevent,
reintroduced one level up.

The check was also node-wide while the set only ever touches --gpu. Any
busy card on a shared node blocked sweeping an idle target, and the only
escape, --allow-busy, gave up the protection on the target card as well.
It is now scoped to the swept card, and a target absent from the listing is
a refusal rather than an assumed-idle card. --allow-busy and --dry-run skip
the check entirely, since neither has a set for it to guard -- which also
makes the documented "--dry-run touches nothing" true when the node is busy.

An exception that was not a SweepError or a PartitionError escaped the
per-mode loop, past the finally, and out of main(): the card was restored,
but the table and sweep_summary.json never ran, the modes already measured
were lost, and a restore that failed on the way out exited 1 instead of the
documented 3. Verified with a KeyError from set_mode. Unexpected exceptions
now stop the sweep by breaking rather than propagating, so the restore and
the report both still happen, and they exit 4 -- a new code, because
reporting a crashed sweep as 0 because some mode measured would be a lie,
and 1 already means "ran, measured nothing". The restore is a helper that
raises nothing, attempts the set even when the read-back that would have
proved it necessary is what broke, and 3 outranks 4: a card left in the
wrong shape mislabels whatever runs on the node next.

37 tests added, 125 total, including the first that drive main() end to end
against a fake node -- the control flow above cannot be reached any other
way. 29 of the new tests fail against the previous revision.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants