Skip to content

GPU fault classification: cover the child pods of distributed tasks - #7913

Draft
samhita-alla wants to merge 2 commits into
mainfrom
gpu-forward/child-pod-gpu-fault
Draft

GPU fault classification: cover the child pods of distributed tasks#7913
samhita-alla wants to merge 2 commits into
mainfrom
gpu-forward/child-pod-gpu-fault

Conversation

@samhita-alla

@samhita-alla samhita-alla commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Why

#7879 classifies GPU faults into the failure the user sees, but only for tasks whose tracked resource is the Pod. CRD-backed distributed tasks — RayJob, PyTorchJob, MPIJob, TFJob, and the clustered JobSet plugin — track the CRD while the fault event lands on a worker pod the plugin manager never looks at, so an Xid on a training worker still surfaces as a generic operator failure. This closes that gap, which #7879's description tracks as its known follow-up.

How

Nothing new is watched. The executor's event cache already holds the Xid events recorded on operator-created pods, and every plugin already merges the framework's run, action and attempt labels into the pod templates it builds. What was missing is the lookup key from a failing CRD to its pods.

  • k8s.ChildPodDiscovery, an optional interface next to PluginAbortOverride and in the same shape: a plugin whose tracked resource is not a Pod can return the label selector that names the pods of this attempt. A plugin that does not implement it behaves exactly as before.
  • flytek8s.AttemptPodSelector builds the shared half of that selector from the labels the framework stamps on every task (managed, run, action, attempt), and returns nil when the attempt cannot be identified, because a partial match would credit one action's fault to another. The label keys move from the executor into flytek8s so the stamping side and the selecting side cannot disagree; flytek8s.PreservedPodLabels re-forces the same keys last in template builders so a user-supplied pod label named run or attempt cannot break the join.
  • Each plugin ANDs that with one operator-native label actually applied to its pods, so a sanitization collision cannot pull in another job's pods: Ray uses ray.io/cluster from the RayJob status (falling back to the attempt selector alone before the operator reports the cluster name), the three Kubeflow plugins share training.kubeflow.org/job-name, and clustered uses jobset.sigs.k8s.io/jobset-name. Every value is verified against the vendored operator, and a conformance test per plugin asserts the pod templates the plugin itself builds satisfy its own selector, so label drift on either side fails a unit test.
  • On a CRD task's failure, the plugin manager lists the child pods from the cache it already maintains (one List, no API call, no new informer, no RBAC change) and runs the same per-pod fault filter as the Pod path against each: reason, UID, and the relevance window. Pods that succeeded are skipped, so a benign mid-run fault on a worker that exited 0 cannot flip an unrelated user failure to a system one.
  • The relevance window anchors on each pod's own termination (latest terminated workload container, then deletion timestamp, clamped to the attempt's failure time) rather than the reconcile time the CRD plugins stamp, since for a CRD the phase timestamp says when the operator noticed, not when the pod died.
  • Cross-pod faults are ordered by when each fault was first created, so the earliest fault of the winning severity is the one named; the last observation is used only to decide whether a fault is still relevant. When classification settles on a child pod's fault, the failure carries one additional reason naming that pod, because on a many-worker job the Xid sentence alone does not say which worker to look at.

Faults on a child pod that was deleted before the failing reconcile are lost rather than guessed at; discovery logs at debug when that happens. Dask and Spark are deliberately left out of this PR; both are mechanical follow-ups on the same interface.

Tests

  • go build ./... in both modules; 38 packages tested across flyteplugins and the executor.
  • golangci-lint (v2.12.0) diffed against the base on both configs: no new findings.
  • New tests: AttemptPodSelector label cases; per-plugin ChildPods behavior including Ray's empty cluster name; per-plugin conformance of built pod templates against the plugin's own selector across every replica type; plugin-manager cases for earliest-critical-wins across workers, aggregated versus one-shot event ordering, succeeded workers excluded, still-running wedged workers included, different-attempt pods excluded, wrong UID rejected, vanished pods contributing nothing, user label overrides not breaking the selector, and non-implementing plugins unchanged.
  • The behavioral fixes were mutation-tested: reversing the sort order, removing the succeeded-pod skip, re-adding init containers to the anchor, removing the clamp, and reverting the label re-forcing each fail exactly the test written for it.

Stack: #7878#7879 (base) → this.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

This PR was flagged by our automated quality checks. If you're a genuine
contributor, please reply here and a maintainer will review your PR.

We appreciate your contribution and apologize if this is a false positive!

@samhita-alla
samhita-alla marked this pull request as draft August 25, 2026 13:11
@samhita-alla
samhita-alla force-pushed the gpu-forward/gpu-failure-classification branch from c56770a to 54e3a5e Compare August 26, 2026 06:43
@samhita-alla
samhita-alla force-pushed the gpu-forward/gpu-failure-classification branch 2 times, most recently from 3176487 to f4abf56 Compare August 26, 2026 06:59
@samhita-alla
samhita-alla force-pushed the gpu-forward/child-pod-gpu-fault branch from 8ef1440 to e9767e0 Compare August 26, 2026 13:08
Base automatically changed from gpu-forward/gpu-failure-classification to main August 27, 2026 05:30
samhita-alla and others added 2 commits August 27, 2026 11:34
Classification so far covered tasks whose tracked resource is the Pod itself. A
RayJob, PyTorchJob, MPIJob or TFJob records the Xid on a worker pod while the
executor tracks the CRD, so the fault the node saw never reached the failure the
user read, and a GPU that fell off the bus during distributed training still
looked like the workload's own crash.

Nothing new has to be watched to fix that. The event informer is not scoped to
the tracked resource, so a worker pod's events are already cached, and the Pod
informer is already running and already selects on the managed label that every
plugin merges into the pod templates it builds. What was missing was a way to
name the pods: an operator's child pod names carry a random suffix, so the
executor cannot derive the keys the events are cached under.

ChildPodDiscovery is the optional interface that closes that, in the shape
PluginAbortOverride already established. A plugin returns the label selector that
picks out this attempt's pods; the manager lists them from the resource's own
namespace out of the cache it already holds, and reads each one's faults. Because
the pods arrive as objects, their UIDs are known, so the identity check that
already guarded the Pod path applies unchanged and never has to fall back to
matching on a name.

AttemptPodSelector builds the half of that selector which holds whichever
operator created the pod. The run, action and attempt labels move to flytek8s
next to the managed label, for the reason that one already lives there: the
plugins that build pod templates have to keep them intact. It refuses to build a
selector when any of them is missing, since run and action are dropped when
sanitization empties them and selecting on what is left would reach another
action's pods. PreservedPodLabels exposes the same key set for plugins to
re-apply after any labels a user supplied, so that the two halves cannot disagree
about which labels have to survive.

Which pods are consulted is narrower than everything the selector matches. A
worker that finished its work cannot be why the job failed, so a succeeded pod
contributes nothing even if it logged a fault mid-run; crediting that to a
launcher's user-code failure would turn a user error into a hardware one and stop
charging the retry. A pod still running is kept, because a worker wedged on a GPU
that fell off the bus is exactly the case this path exists for.

Relevance is anchored per pod on that pod's own container termination, because
every CRD plugin stamps the failure with the time of the reconcile that noticed
it rather than with anything the kubelet recorded, and anchoring on the reconcile
would age out a fault that killed a worker minutes earlier. Init containers are
not eligible anchors: they finish before the workload starts, and a native
sidecar declared among them is reaped after everything else. The anchor is never
later than the attempt's own failure, so a pod torn down long afterwards cannot
let faults recorded in the meantime explain it.

Which fault wins needs no new rule: ClassifyFailure already takes the worst
severity and the earliest within it, so the faults gathered across pods are only
sorted back into order before being handed over. That order is on when each fault
was first recorded rather than when it was last seen, because a fault that is
still repeating has a later last observation than a one-shot fault that followed
it, and ordering on that would let a downstream symptom outrank the root cause.
The last observation still decides relevance, which is all it was ever for.

The one pod whose fault settled the verdict is named as an additional reason,
because the fault sentence carries the Xid, the GPU and the node but not the pod,
which on a job with sixty-four workers is not enough to act on. Only that pod is
named: pointing at every pod that saw any fault would put a misleading reason on
a failure the faults did not explain, and would emit one cluster event per pod
when a whole node's worth of GPUs faults at once. The single pod path names
nothing, since the failure is already reported against that pod.

A gang-scheduled restart creates fresh pods under the same attempt and its faults
count, while an earlier Flyte attempt's pods are excluded by the attempt label. A
plugin that does not implement the interface contributes no pods and so no
faults, which is exactly what it did before.

A pod already torn down with its job is not recovered. The events are cached
under a name nothing can reconstruct, so discovery logs at debug and classifies
nothing rather than guessing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Samhita Alla <aallasamhita@gmail.com>
… task

The Ray, Kubeflow and clustered plugins track a CRD, so they implement
ChildPodDiscovery to tell the framework where the pods that actually ran the task
are.

All three build the same selector: the attempt's own labels, which the pod
templates they hand the operator already carry, narrowed by one label the
operator applies to the pods themselves. For Kubeflow that is
training.kubeflow.org/job-name, which is the CR's own name and so is known from
the moment the resource exists; one implementation in common covers PyTorchJob,
MPIJob and TFJob, because all three get their replica pods from the same
operator. For the clustered plugin it is jobset.sigs.k8s.io/jobset-name, exported
by the vendored JobSet API the plugin already imports. For Ray it is
ray.io/cluster, which KubeRay stamps on head and worker pods and overwrites on
the template, and whose value is only knowable from the RayJob's status because
KubeRay appends a random suffix to the cluster name.

Until that status is reported Ray falls back to the attempt labels alone. That is
not a wider search in any sense that matters, since run, action and attempt
already pin the selector to one attempt of one action; the most it can add is the
job submitter pod, which has no GPU and so no faults to contribute. The
alternative, declining until the operator catches up, would lose the fault on
exactly the runs where the cluster never came up healthy.

Ray also has to stop a task from taking its own pods out of reach. The head and
worker templates let a task supply k8s_pod metadata, and that metadata was
unioned after the execution labels with only the managed label re-forced
afterwards, so a user label named run, action or attempt would overwrite the
identity the selector looks a pod up by. All three templates now re-apply
flytek8s.PreservedPodLabels last, which is the same key set the selector is built
from. Dask and Spark are left for a follow-up.

The conformance tests are the point of the test additions. Each asserts that the
labels the plugin puts on the pod templates satisfy the selector the same plugin
hands the framework, for every replica type it builds. The two halves live far
apart and drift on either one would leave a GPU fault on a worker silently
unclassified, which is the failure mode this whole path exists to prevent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Samhita Alla <aallasamhita@gmail.com>
@samhita-alla
samhita-alla force-pushed the gpu-forward/child-pod-gpu-fault branch from e9767e0 to 23a0ece Compare August 27, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant