Task Parallelism in Polaris
Creation date: 2026/08/23
Contributors:
Xylar Asay-Davis
Claude
Summary
Polaris runs one step at a time. This document describes adding the ability to run independent steps at the same time, and maps out four phases of work to get there.
“Task parallelism” is the historical name for this effort. The unit Polaris
actually schedules is a Step, not a Task, and steps that run together may
come from one task or from several tasks in a suite.
Two workloads, not one
Two quite different kinds of work motivate this, and keeping them distinct is what makes the phasing sensible.
Regression suites such as omega_pr are dominated by many small MPI
model runs. On a recent omega_pr run on Chrysalis, 79% of the time in
steps was MPI forward runs and 21% was everything else. The whole suite took
12:26 while its longest single task took 106 s, so the work is there: it is
simply run one step after another. Running independent steps together should
give roughly a 2.5-3x improvement on the same three-node allocation, limited
by the total core-seconds of MPI work rather than by dependencies.
Analysis is the larger long-term need. Polaris is to gain analysis
capability equivalent to MPAS-Analysis, for Omega and possibly MPAS-Ocean.
That workload is heavily Python. MPAS-Analysis already has task parallelism,
but it is built on multiprocessing, which cannot span nodes, and that
single-node ceiling is a known choke point at high resolution – exactly
Omega’s target. What this workload needs is not just concurrency but
concurrency across nodes.
Neither is optional, and the phases below deliver them in an order chosen for what each one unblocks rather than for which matters more.
What we already established
Before designing anything, we measured whether the machines Polaris targets can actually run several placed pieces of work at once inside a single allocation. They can, all five of them. The measurements were made in August 2026 with throwaway scripts; the results are recorded here because this document is where they need to survive.
In each test, four pieces of work were launched at once inside one allocation, each asking to be confined to its own cores and, where relevant, its own GPUs. Each reported back which cores and GPUs it could actually see and when it ran, so that genuine overlap could be told from work that merely queued.
machine |
batch system |
what confines a launch |
cores |
GPUs |
|---|---|---|---|---|
Chrysalis |
Slurm 20.02 |
explicit CPU binding |
disjoint |
n/a |
Perlmutter CPU |
Slurm 25.11 |
resource request |
disjoint |
n/a |
Perlmutter GPU |
Slurm 25.11 |
request + GPU total |
disjoint |
disjoint |
Frontier |
Slurm 25.11 |
request + GPU total |
disjoint |
disjoint |
Aurora |
PBS with PALS |
host list + core list |
disjoint |
disjoint |
All five ran all four launches concurrently.
The GPU machines were also tested at MPI width – four concurrent two-rank MPI launches – and partitioned cleanly there too. On Frontier the four launches received GPUs 4,5 / 6,7 / 0,1 / 2,3.
What the rendered commands did
The table above was produced with commands written by hand. That establishes
what the machines can do, not that the commands Polaris and mache build
between them do it, which is a different claim and the one that gates the
mache change. It was checked separately, in August 2026, on all five
machines, by constructing disjoint placements, rendering each through
mache and launching them together:
machine |
placement |
GPUs |
note |
|---|---|---|---|
Chrysalis |
exact cores |
n/a |
clean first time |
Frontier |
honored |
disjoint, 0,1 / 2,3 / 4,5 / 6,7 |
clean first time |
Perlmutter GPU |
honored |
disjoint, 0 / 1 / 2 / 3 |
clean after a fix |
Aurora |
exact cores |
disjoint masks |
clean after a fix |
Perlmutter CPU |
honored |
n/a |
clean first time |
Two defects were found this way and both were fixed in mache rather than
worked around. On Aurora, --env-remove is not an option the PALS
mpiexec accepts, and it rejected the whole command, so every placed launch
failed to start. On Perlmutter GPU, a gpu_bind of none carried from the
machine config alongside an explicit GPU request, and under concurrency
three of four launches silently received no GPU at all and exited zero –
the same class of failure as silently losing placement, and the reason this
verification was worth doing separately from the hand-written one.
One limit of the Aurora result had to be carried with it, and asking about it
turned up a real defect. On Slurm the GPUs a launch can see are read from the
scheduler’s own variables, so the verdict is independent evidence. On PALS
nothing assigns GPUs, so mache renders the indices the caller chose and the
check reads that same value back: it confirms the plumbing, not that the
runtime honors it. A clean Aurora run therefore did not establish that a
launch asking for no GPUs got none.
It has since been asked properly, by enumerating what the runtime can see
rather than reading back what it was told, and the answer is that an empty
ZE_AFFINITY_MASK hides nothing. Level Zero reads an empty value as “no
mask”, which means every device: six cards visible with the variable unset,
and the same six with it set and empty. So the explicit “no GPUs” mache
renders on PALS is a no-op.
Two controls make that a finding rather than a guess, and both were necessary. The variable arrived at the process set and empty rather than dropped, which separates Level Zero’s semantics from a plumbing bug; and a mask naming a subset, in the same launch style, did confine the launch, which rules out the mask simply being inert there. Two enumerators agreed, one of them reaching Level Zero with no SYCL layer in between.
The consequence is small now and not small later. Nothing on PALS reserves GPUs, so a step seeing hardware it declined costs only tidiness, and neither Phase A nor Phase B is affected. It becomes an isolation question in Phase C, where it is recorded.
Where measurements live, and what does not
Both sets of results above were produced by harnesses written to answer a question. Neither harness is part of what Polaris ships, and the findings are recorded in this document rather than in the directories that produced them. This is deliberate and has already been learned once: an earlier version of these documents pointed at a README on a branch that was never going to merge, so the reference would have dangled and the evidence would have been lost with it.
The rule worth stating, so that it does not have to be learned a third time: a harness built to answer a question is not part of the deliverable, its findings belong in the design document, and it does not merge. Anything from such a harness that deserves to keep running – a regression test, a standing check – has to be moved somewhere permanent before the harness goes, not after.
The conclusions that shape this design are:
Launching work is cheap. Measured sequentially: roughly 60 per minute on Perlmutter GPU and Aurora, 150 on Perlmutter CPU, 500-600 on Frontier and Chrysalis. A long-standing suspicion that Perlmutter throttles launches to about one a minute turned out to describe a different problem – concurrent launches queueing, not launches being rate limited.
Two caveats worth carrying. The tail is heavy: Frontier showed two of ten launches near two seconds against a median of 0.11 s on an otherwise idle system. And all of this was measured off-peak, so a busy weekday has not been ruled out. If a future run finds launching unexpectedly slow, this is the first thing to re-measure rather than the last.
The scheduler will keep concurrent work apart, if asked properly. Each piece of work gets its own cores, and its own GPUs, enforced by the batch system rather than by Polaris. This is a stronger guarantee than we expected to get.
Silence about GPUs is not neutral at the launcher. Polaris steps use no GPUs unless they say so, and that stays true. But if Polaris passes that silence on, the batch system reads it as “give this work the node’s GPUs” and reserves all of them, which is what stopped concurrency on the GPU machines – not memory or CPU contention. Polaris must therefore state a step’s GPU need explicitly in every case, including when it is zero. Since most Polaris steps use no GPUs at all, saying “none” is the common path, not the exception.
When a step does want GPUs, it must ask for a total, not a count per MPI rank. The per-rank form does not confine a step at all. This is a real constraint on how Polaris describes step resources, and it differs from how CPU resources are described today.
The batch system will not schedule memory for us, but on newer Slurm it will enforce it. These are different things and the difference decides how memory is handled. Asking a launch for a share of the node’s memory did not fix the serialization – silence about GPUs did that – and it reserved nothing that any measurement could see. But a later measurement on the same machines showed a memory request is not inert: a launch allowed 1024 MB and told to take 4 GB is killed at 960 MB on Perlmutter GPU and on Frontier, and runs to completion on Chrysalis, whose Slurm predates the 20.11 change.
So the launcher will not tell Polaris what fits, and deciding that remains a budget Polaris keeps itself, in the scheduler in Phase B. What the launcher will do, on some machines, is hold a launch to a number it was given. That makes a memory figure passed to a launcher a cap rather than a reservation, which is a thing worth doing deliberately for a step that stated its own number and not worth doing to a step the framework guessed at.
One worry this raised does not materialize. Silence about memory does not repeat the trap that silence about GPUs sets: four concurrent launches that said nothing about memory all started within 40 ms of each other and ran their full duration on both machines that enforce. An unstated memory requirement is not read as a claim on the node’s memory. Aurora and Perlmutter CPU are unmeasured on this point.
One more result is worth recording because it cost a round of testing: on
CUDA machines the visible-device variable is renumbered for each launch, so
four launches on four different GPUs all report device 0. Anything
verifying GPU placement must use the scheduler’s global identifiers
instead.
Two approaches were tried and rejected. Allowing launches to overlap without constraining them gives concurrency but every launch then shares every core and GPU, which is oversubscription rather than scheduling. Constraining a launch and also pinning it with an explicit CPU mask fails outright on newer Slurm, because the two contradict each other; explicit masks remain the only mechanism on Slurm older than 20.11, where they work.
The practical consequence is that no new dependency is needed. We considered adopting Flux, a nested scheduler that would sidestep the batch system entirely, and it is not necessary.
The phases
Each phase is a separate design document and is intended to be independently useful.
Phase A – Placement
Task Parallelism Phase A: Placement
Give Polaris the ability to run a step on a named part of its allocation rather than on all of it, and give steps a way to say what they need, including GPUs as a per-step total and memory. Still one step at a time. Memory is declared here and shown to the step, but it is not something the launcher is asked to enforce; deciding what fits is Phase B’s job.
This is a prerequisite for everything else, and most of the work is in
mache, which owns how Polaris launches parallel work. That mache change
is merged and released as mache 3.12.0, which Polaris requires; while it
was unreleased Phase A was developed against the branch.
Phase B – Concurrency
Task Parallelism Phase B: Concurrency
Build the dependency graph, the resource pool and an executor that runs each step in its own process. Run independent steps at the same time – MPI and non-MPI alike, since with Phase A in place there is no reason to stage one behind the other.
This is where the regression-suite speedup lands.
Phase C – Python worker pool
Task Parallelism Phase C: Python Worker Pool
Add a second executor: a pool of workers spread across the allocation’s nodes, for Python work that is too fine-grained to give a process of its own. This is what lifts the single-node ceiling that constrains MPAS-Analysis today, and it is the phase the analysis capability depends on.
Measurement has since confirmed the premise: a high-resolution analysis run is fine-grained, with a median task of about five seconds and half of them shorter. Phase C records what else that run showed, including two of its headline numbers that do not mean what they appear to. It is worth stating here that analysis is the pool’s first demanding customer and not its specification – most Polaris workflows share no single large input, and a pool built around one would serve the general case badly.
Phase D – Coexistence and elasticity
Task Parallelism Phase D: Coexistence and Elasticity
Let the worker pool and ordinary steps share an allocation, and let the pool grow and shrink as the amount of ready Python work changes, so that it does not hold nodes idle once that work drains.
Scope and non-goals
polaris serialstays, unchanged, as the compatibility baseline. The new capability arrives on a separate path. There is no plan in these documents to removepolaris serial; that can be revisited once the new path has been used in anger.Polaris will not request larger allocations by default just because it can now use them. Allocation sizing stays as it is; users asking for more nodes is a deliberate act.
These documents do not cover writing the analysis capability itself. The properties an analysis step needs in order to be safely run concurrently are a separate document, Task-Parallel-Safe Analysis Steps in Polaris, which is worth adopting before analysis steps are written rather than after. Its rules are also written up for step authors in the developer guide, under Writing task-parallel-safe steps.
What success looks like
Polaris can run a suite’s independent steps at the same time, on any supported machine, with the batch system keeping them from treading on each other, producing the same results as running them one at a time; and Python analysis work can spread across more than one node.