Skip to content

Commit feca41c

Browse files
authored
[TRTLLMINF-103][feat] Keep SLURM timeouts non-retryable (#15183)
Signed-off-by: Derek Pitman <dpitman@nvidia.com>
1 parent 7cefb4a commit feca41c

1 file changed

Lines changed: 120 additions & 10 deletions

File tree

jenkins/L0_Test.groovy

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ SLURM_INFRA_RETRY_MAX = 1
133133
// to avoid nesting with the inner SLURM retry.
134134
K8S_INFRA_RETRY_MAX = 1
135135

136+
// Fallback discriminator for SLURM timeouts.
137+
// If we can't reach the SLURM node for an authoritative reason,
138+
// we apply a heuristic: if the job needed more than this of its budget
139+
// to fail, we treat it as a timeout.
140+
SLURM_TIMEOUT_RETRY_FRACTION = 0.9
141+
136142
// Typed-exception hierarchy and FailureClassifier (PATTERN_CATALOG, classify(),
137143
// flattenThrowable) live in trtllm-jenkins-shared-lib under src/trtllm/. They
138144
// were originally inline here, but the Jenkins script-security sandbox
@@ -585,6 +591,39 @@ def cleanUpNodeResources(def pipeline, SlurmCluster cluster, String clusterName,
585591
}
586592
}
587593

594+
// Authoritative timeout signal: ask the SLURM controller (via sacct on the
595+
// cluster login node) for a job's terminal state. Returns the uppercased
596+
// primary state token -- e.g. "TIMEOUT", "COMPLETED", "FAILED", "NODE_FAIL",
597+
// "OUT_OF_MEMORY", "CANCELLED" -- or null when it can't be determined.
598+
//
599+
// Best-effort: any SSH/sacct error returns null so callers fall back to the
600+
// duration heuristic rather than failing the stage.
601+
def querySlurmJobState(def pipeline, SlurmCluster cluster, String clusterName, String slurmJobID) {
602+
if (!slurmJobID || !slurmJobID.toString().isNumber()) {
603+
return null
604+
}
605+
String state = null
606+
try {
607+
CloudManager.withSlurmSshCredentials(pipeline, clusterName, cluster) { remote ->
608+
// -X: allocation row only (skip .batch/.extern steps). -Pn:
609+
// parsable, no header. First line's first token is the job state;
610+
// SLURM renders cancellations as "CANCELLED by <uid>", so we keep
611+
// only the leading token.
612+
def out = Utils.exec(
613+
pipeline,
614+
script: Utils.sshUserCmd(remote, "\"sacct -j ${slurmJobID} --format=State -Pn -X || true\""),
615+
returnStdout: true,
616+
)?.trim()
617+
if (out) {
618+
state = out.readLines()[0]?.trim()?.tokenize(' ')?.getAt(0)?.toUpperCase(java.util.Locale.ROOT)
619+
}
620+
}
621+
} catch (Exception e) {
622+
pipeline.echo("[INFRA-RETRY] Could not query SLURM job ${slurmJobID} state via sacct: ${e.message}")
623+
}
624+
return state
625+
}
626+
588627
def runLLMTestlistWithAgent(pipeline, platform, testList, config=VANILLA_CONFIG, perfMode=false, stageName="Undefined", splitId=1, splits=1, gpuCount=1, skipInstallWheel=false, cpver="cp312", String postTag="", boolean useClusterDurations=false)
589628
{
590629
SlurmPartition partition = SlurmConfig.resolvePlatform(platform)
@@ -653,6 +692,12 @@ def runLLMTestlistWithAgent(pipeline, platform, testList, config=VANILLA_CONFIG,
653692
}
654693
}
655694

695+
// Wall-clock at which the SLURM job was first observed RUNNING.
696+
// Captured locally so it remains usable even when the controller
697+
// is later unreachable (the case the fallback exists for). Null until
698+
// Phase 1 confirms RUNNING; callers fall back to executeStartMs.
699+
def jobRunningStartMs = null
700+
656701
stage('Check If Node Is Online') {
657702
CloudManager.withSlurmSshCredentials(pipeline, partition.clusterName, cluster) { remote ->
658703
// Check the SLURM job once; if it is no longer active, raise a typed
@@ -723,6 +768,10 @@ def runLLMTestlistWithAgent(pipeline, platform, testList, config=VANILLA_CONFIG,
723768
// Ocean, and probe job status every ~3 min (every 6th iter) to fail fast if the
724769
// job dies during bring-up. 120 * 30s = 1h.
725770
if (waitRc == 0) {
771+
// Job is RUNNING: stamp the walltime-budget origin for the
772+
// timeout duration fallback (within Phase 1's ~3min poll
773+
// granularity of the true RUNNING transition).
774+
jobRunningStartMs = System.currentTimeMillis()
726775
def onlineCounter = 0
727776
while (!CloudManager.isNodeOnline(nodeName) && onlineCounter < 120) {
728777
Thread.sleep(30L * 1000L)
@@ -804,7 +853,43 @@ def runLLMTestlistWithAgent(pipeline, platform, testList, config=VANILLA_CONFIG,
804853
} else {
805854
throw new Exception("Unsupported container runtime: ${cluster.containerRuntime}")
806855
}
807-
executeLLMTestOnSlurm(pipeline, platform, testList, config, perfMode, stageName, splitId, splits, skipInstallWheel, cpver, slurmRunner, postTag, useClusterDurations)
856+
long executeStartMs = System.currentTimeMillis()
857+
try {
858+
executeLLMTestOnSlurm(pipeline, platform, testList, config, perfMode, stageName, splitId, splits, skipInstallWheel, cpver, slurmRunner, postTag, useClusterDurations)
859+
} catch (InterruptedException e) {
860+
throw e
861+
} catch (Exception e) {
862+
// Decide whether this failure is a SLURM timeout (the test ran to
863+
// its partition walltime) rather than a transient infra blip.
864+
// Measure elapsed from when the job was first observed RUNNING.
865+
// Fall back to executeStartMs if the RUNNING stamp was never set.
866+
long timeoutBaselineMs = (jobRunningStartMs ?: executeStartMs) as long
867+
long elapsedMin = Math.floorDiv(System.currentTimeMillis() - timeoutBaselineMs, 60000L)
868+
Integer walltimeMin = (partition?.time ?: SlurmConfig.DEFAULT_TIMEOUT_SHORT) as Integer
869+
def slurmState = querySlurmJobState(pipeline, cluster, partition.clusterName, slurmJobID)
870+
871+
if (slurmState == "TIMEOUT") {
872+
throw new UserFailure(
873+
"SLURM job ${slurmJobID} for ${stageName} ended in state TIMEOUT " +
874+
"(hit partition walltime ${walltimeMin}min); treating as a test timeout, not retrying. " +
875+
"Original failure: ${e.message}",
876+
e)
877+
}
878+
879+
if (slurmState == null && walltimeMin != null
880+
&& elapsedMin >= (long)(SLURM_TIMEOUT_RETRY_FRACTION * walltimeMin)) {
881+
throw new UserFailure(
882+
"SLURM job ${slurmJobID} for ${stageName} ran ${elapsedMin}min " +
883+
"(>= ${(int)(SLURM_TIMEOUT_RETRY_FRACTION * 100)}% of the ${walltimeMin}min walltime) and " +
884+
"the SLURM controller was unreachable for an authoritative state; treating as a likely " +
885+
"timeout, not retrying. Original failure: ${e.message}",
886+
e)
887+
}
888+
889+
echo "[INFRA-RETRY] ${stageName}: SLURM job ${slurmJobID} terminal state=${slurmState ?: 'unknown'}, " +
890+
"ran ${elapsedMin}min of ${walltimeMin}min walltime; deferring to failure classifier."
891+
throw e
892+
}
808893
} finally {
809894
stage("Clean Up Slurm Resource") {
810895
// Workaround to handle the interruption during clean up SLURM resources
@@ -1460,15 +1545,40 @@ def runLLMTestlistWithSbatch(pipeline, platform, testList, config=VANILLA_CONFIG
14601545
)
14611546

14621547
// Track the Slurm job
1463-
Utils.exec(
1464-
pipeline,
1465-
timeout: false,
1466-
script: Utils.sshUserCmd(
1467-
remote,
1468-
scriptTrackPathNode
1469-
),
1470-
numRetries: 3
1471-
)
1548+
try {
1549+
Utils.exec(
1550+
pipeline,
1551+
timeout: false,
1552+
script: Utils.sshUserCmd(
1553+
remote,
1554+
scriptTrackPathNode
1555+
),
1556+
numRetries: 3
1557+
)
1558+
} catch (InterruptedException e) {
1559+
throw e
1560+
} catch (Exception e) {
1561+
// The track script squashes the job's terminal SLURM state to
1562+
// exit 0/1, so a walltime kill is indistinguishable here from a
1563+
// real test failure. Re-query sacct for the allocation-level
1564+
// state (SLURM already aggregates it across nodes; TIMEOUT if
1565+
// any node hit the walltime) and, when it is TIMEOUT, raise a
1566+
// typed UserFailure so neither the SLURM retry loop nor the
1567+
// outer K8s pod retry re-runs a job that would just time out
1568+
// again. srun --kill-on-bad-exit=1 means a genuine test failure
1569+
// surfaces as FAILED, not TIMEOUT, so this stays unambiguous.
1570+
def slurmState = querySlurmJobState(pipeline, cluster, partition.clusterName, slurmJobId)
1571+
if (slurmState == "TIMEOUT") {
1572+
throw new UserFailure(
1573+
"SLURM job ${slurmJobId} for ${stageName} ended in state TIMEOUT " +
1574+
"(hit partition walltime ${partition?.time}min); treating as a test timeout, not retrying. " +
1575+
"Original failure: ${e.message}",
1576+
e)
1577+
}
1578+
echo "[INFRA-RETRY] ${stageName}: SLURM job ${slurmJobId} terminal state=${slurmState ?: 'unknown'}; " +
1579+
"deferring to failure classifier."
1580+
throw e
1581+
}
14721582
}
14731583
echo "Finished test stage execution."
14741584
} // end CloudManager.withSlurmSshCredentials

0 commit comments

Comments
 (0)