Skip to content

Commit 37ba978

Browse files
authored
fix(validator): surface + re-read NCCL launcher log on bandwidth parse miss (NVIDIA#1691)
Signed-off-by: Nathan Hensley <nhensley@nvidia.com>
1 parent 22651e2 commit 37ba978

3 files changed

Lines changed: 222 additions & 2 deletions

File tree

pkg/defaults/timeouts.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,17 @@ const (
923923
// attempts while the Kubeflow Trainer validating webhook's informer cache
924924
// catches up to a freshly-created TrainingRuntime.
925925
TrainJobAdmissionRetryInterval = 500 * time.Millisecond
926+
927+
// NCCLLauncherLogReadInterval is the backoff between re-reads of a succeeded
928+
// NCCL launcher pod's log while waiting for the results table to be fully
929+
// captured. A pod that has just reached Succeeded can briefly serve an empty
930+
// or truncated log if its container is being torn down mid-read.
931+
NCCLLauncherLogReadInterval = 2 * time.Second
932+
933+
// NCCLLauncherLogReadAttempts bounds how many times the succeeded launcher
934+
// pod's log is re-read before giving up and returning the last read for
935+
// diagnosis (the parser then fails and the log is surfaced).
936+
NCCLLauncherLogReadAttempts = 5
926937
)
927938

928939
// Termination and truncation limits for validator output.

validators/performance/nccl_all_reduce_bw_constraint.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,16 @@ func validateNcclAllReduceBw(ctx *validators.Context, constraint recipe.Constrai
328328
// Parse bandwidth from logs (shared across all service types).
329329
bandwidth, err := parseBandwidthFromLogs(logs)
330330
if err != nil {
331+
// The launcher pod succeeded but its log yielded no parseable bandwidth
332+
// row. Surface the retrieved log into report.json the way the pod-failed
333+
// path does via emitDiagnosticBlock — without it, a succeeded-but-
334+
// unparseable run is a dead end: we cannot tell an empty/truncated log
335+
// capture from a benchmark that exited 0 without emitting the results
336+
// table. (The caller discards the returned logs string on error, so
337+
// logging is the only way this reaches the check's captured stdout.)
338+
slog.Error("NCCL launcher succeeded but bandwidth could not be parsed; dumping launcher log",
339+
"logBytes", len(logs))
340+
emitDiagnosticBlock("launcher log (bandwidth parse failed)", tailLines(strings.TrimSpace(logs), maxDiagLogLines))
331341
return logs, false, aicrErrors.Wrap(aicrErrors.ErrCodeInternal, "failed to parse bandwidth from logs", err)
332342
}
333343

@@ -1315,16 +1325,78 @@ func waitForLauncherPodAndGetLogs(ctx *validators.Context, podHelper *helper.Pod
13151325
return logs, aicrErrors.Wrap(aicrErrors.ErrCodeInternal, "pod failed to complete successfully", err)
13161326
}
13171327

1318-
// Get logs from completed pod using helper method
1328+
// Get logs from the completed pod. A pod that has just reached Succeeded can
1329+
// briefly serve an empty or truncated log if its container is being torn down
1330+
// mid-read, and the NCCL results table (which parseBandwidthFromLogs keys on)
1331+
// prints last — so re-read until the results are present before returning.
13191332
slog.Info("Retrieving logs from successful pod...")
1320-
logs, err := podHelper.GetPodLogs(ctx.Ctx, launcherPod)
1333+
logs, err := getCompleteLauncherLogs(ctx.Ctx, podHelper, launcherPod)
13211334
if err != nil {
13221335
return "", aicrErrors.Wrap(aicrErrors.ErrCodeInternal, "failed to get pod logs", err)
13231336
}
13241337

13251338
return logs, nil
13261339
}
13271340

1341+
// ncclLauncherLogComplete reports whether a launcher log contains the NCCL
1342+
// results parseBandwidthFromLogs needs. all_reduce_perf prints its "Avg bus
1343+
// bandwidth" summary line only after the full size sweep finishes, so its
1344+
// presence guarantees the trailing largest-message-size row — the row the parser
1345+
// keys on (last regexp match) — is already in the log. We deliberately do NOT
1346+
// accept a bare data-row match here: an early row can appear while the log is
1347+
// still streaming, and gating on it would let the retry loop short-circuit
1348+
// before the largest row lands, defeating the purpose (parseBandwidthFromLogs
1349+
// would then read a smaller-size row).
1350+
func ncclLauncherLogComplete(logs string) bool {
1351+
return strings.Contains(logs, "Avg bus bandwidth")
1352+
}
1353+
1354+
// getCompleteLauncherLogs retrieves the launcher pod's logs, re-reading until the
1355+
// NCCL results are present or the attempt budget is exhausted. A pod that has
1356+
// just reached Succeeded can serve an empty or truncated log if its container is
1357+
// torn down while we read; because the parser keys on the trailing
1358+
// largest-message-size row, a truncated read loses exactly that row and yields
1359+
// "could not find bandwidth value in logs".
1360+
func getCompleteLauncherLogs(ctx context.Context, podHelper *helper.PodLifecycle, pod *v1.Pod) (string, error) {
1361+
return readLauncherLogsUntilComplete(ctx,
1362+
func(c context.Context) (string, error) { return podHelper.GetPodLogs(c, pod) },
1363+
defaults.NCCLLauncherLogReadAttempts, defaults.NCCLLauncherLogReadInterval)
1364+
}
1365+
1366+
// readLauncherLogsUntilComplete re-reads via fetch until ncclLauncherLogComplete
1367+
// is satisfied or attempts is exhausted, sleeping interval between tries. It
1368+
// returns the last read even when still incomplete, so the caller's parse-failure
1369+
// path can surface it for diagnosis rather than discarding it. Split from
1370+
// getCompleteLauncherLogs so the retry logic is unit-testable without a cluster.
1371+
func readLauncherLogsUntilComplete(ctx context.Context, fetch func(context.Context) (string, error), attempts int, interval time.Duration) (string, error) {
1372+
var logs string
1373+
for attempt := 1; ; attempt++ {
1374+
var err error
1375+
logs, err = fetch(ctx)
1376+
if err != nil {
1377+
return "", err
1378+
}
1379+
if ncclLauncherLogComplete(logs) {
1380+
if attempt > 1 {
1381+
slog.Info("launcher log complete after re-read", "attempts", attempt, "logBytes", len(logs))
1382+
}
1383+
return logs, nil
1384+
}
1385+
if attempt >= attempts {
1386+
slog.Warn("launcher log still lacks NCCL results after re-reads; returning last read for diagnosis",
1387+
"attempts", attempt, "logBytes", len(logs))
1388+
return logs, nil
1389+
}
1390+
slog.Info("launcher log has no NCCL results yet; re-reading", "attempt", attempt, "logBytes", len(logs))
1391+
select {
1392+
case <-ctx.Done():
1393+
// Return what we have; the caller's parse path will surface it.
1394+
return logs, nil
1395+
case <-time.After(interval):
1396+
}
1397+
}
1398+
}
1399+
13281400
// maxDiagLogLines bounds how many trailing log lines are kept per worker
13291401
// container in the failure diagnostics. The fatal error is almost always near
13301402
// the end, so the tail is what matters; the cap keeps a verbose worker
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
stderrors "errors"
20+
"testing"
21+
"time"
22+
)
23+
24+
// A representative all_reduce_perf results block (one data row + summary trailer).
25+
const ncclCompleteLog = `# size count type redop root time algbw busbw #wrong
26+
# (B) (elements) (us) (GB/s) (GB/s)
27+
8589934592 2147483648 float sum -1 48298 177.85 333.47 0 48292 177.87 333.51 0
28+
# Out of bounds values : 0 OK
29+
# Avg bus bandwidth : 333.49
30+
`
31+
32+
func TestNcclLauncherLogComplete(t *testing.T) {
33+
tests := []struct {
34+
name string
35+
logs string
36+
want bool
37+
}{
38+
{"empty", "", false},
39+
{"header only (no data rows, no trailer)", "# size count type\n# (B)\n", false},
40+
{"init noise only", "NCCL INFO Bootstrap : Using eth0\nsome mpirun warmup line\n", false},
41+
// An early data row without the trailer must NOT count as complete: the
42+
// parser keys on the *last* row, so a log truncated before the largest
43+
// size would otherwise short-circuit the retry loop (CodeRabbit #1691).
44+
{"early data row but no trailer", "# size ...\n 1024 256 float sum -1 12 0.10 0.09 0 12 0.10 0.09 0\n", false},
45+
{"full sweep with trailer", ncclCompleteLog, true},
46+
{"summary trailer only", "# Avg bus bandwidth : 333.49\n", true},
47+
}
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
if got := ncclLauncherLogComplete(tt.logs); got != tt.want {
51+
t.Errorf("ncclLauncherLogComplete() = %v, want %v", got, tt.want)
52+
}
53+
})
54+
}
55+
}
56+
57+
func TestReadLauncherLogsUntilComplete_ReturnsWhenComplete(t *testing.T) {
58+
// First read is already complete — returns immediately, no retries.
59+
calls := 0
60+
fetch := func(context.Context) (string, error) { calls++; return ncclCompleteLog, nil }
61+
logs, err := readLauncherLogsUntilComplete(context.Background(), fetch, 5, time.Millisecond)
62+
if err != nil {
63+
t.Fatalf("unexpected error: %v", err)
64+
}
65+
if !ncclLauncherLogComplete(logs) {
66+
t.Errorf("expected complete logs, got %q", logs)
67+
}
68+
if calls != 1 {
69+
t.Errorf("expected 1 fetch, got %d", calls)
70+
}
71+
}
72+
73+
func TestReadLauncherLogsUntilComplete_RetriesUntilComplete(t *testing.T) {
74+
// Truncated (empty) reads twice, then the full results table lands.
75+
calls := 0
76+
fetch := func(context.Context) (string, error) {
77+
calls++
78+
if calls < 3 {
79+
return "", nil
80+
}
81+
return ncclCompleteLog, nil
82+
}
83+
logs, err := readLauncherLogsUntilComplete(context.Background(), fetch, 5, time.Millisecond)
84+
if err != nil {
85+
t.Fatalf("unexpected error: %v", err)
86+
}
87+
if !ncclLauncherLogComplete(logs) {
88+
t.Errorf("expected complete logs after retries, got %q", logs)
89+
}
90+
if calls != 3 {
91+
t.Errorf("expected 3 fetches, got %d", calls)
92+
}
93+
}
94+
95+
func TestReadLauncherLogsUntilComplete_ReturnsLastReadWhenNeverComplete(t *testing.T) {
96+
// Never completes: must return the last (incomplete) read after the budget,
97+
// not an error — so the caller's parse-failure path can surface it.
98+
calls := 0
99+
fetch := func(context.Context) (string, error) { calls++; return "partial noise, no table", nil }
100+
logs, err := readLauncherLogsUntilComplete(context.Background(), fetch, 3, time.Millisecond)
101+
if err != nil {
102+
t.Fatalf("expected no error (last read returned for diagnosis), got %v", err)
103+
}
104+
if ncclLauncherLogComplete(logs) {
105+
t.Errorf("expected incomplete logs to be returned as-is, got complete %q", logs)
106+
}
107+
if calls != 3 {
108+
t.Errorf("expected exactly the attempt budget (3) fetches, got %d", calls)
109+
}
110+
}
111+
112+
func TestReadLauncherLogsUntilComplete_PropagatesFetchError(t *testing.T) {
113+
sentinel := stderrors.New("logs api unavailable")
114+
fetch := func(context.Context) (string, error) { return "", sentinel }
115+
_, err := readLauncherLogsUntilComplete(context.Background(), fetch, 5, time.Millisecond)
116+
if !stderrors.Is(err, sentinel) {
117+
t.Errorf("expected the fetch error to propagate, got %v", err)
118+
}
119+
}
120+
121+
func TestReadLauncherLogsUntilComplete_ReturnsOnContextCancel(t *testing.T) {
122+
ctx, cancel := context.WithCancel(context.Background())
123+
cancel() // canceled before the retry sleep
124+
calls := 0
125+
fetch := func(context.Context) (string, error) { calls++; return "still no table", nil }
126+
logs, err := readLauncherLogsUntilComplete(ctx, fetch, 5, time.Hour)
127+
if err != nil {
128+
t.Fatalf("expected no error on cancel (last read returned), got %v", err)
129+
}
130+
if ncclLauncherLogComplete(logs) {
131+
t.Errorf("expected incomplete logs, got complete %q", logs)
132+
}
133+
// One fetch, then the canceled context short-circuits the sleep.
134+
if calls != 1 {
135+
t.Errorf("expected 1 fetch before cancel short-circuit, got %d", calls)
136+
}
137+
}

0 commit comments

Comments
 (0)