Skip to content

Commit 705f3ba

Browse files
committed
TEST/PERF: Compare device perftest BW/Lat against the PR base
The GPU device-API (GDA / gdaki) perftests run in CI but only ever ran once on the PR, with no comparison, so a bandwidth/latency regression went unnoticed (e.g. the ~34% GDA bandwidth drop in #11424). The OSU perf pipeline can't cover this: OSU is host-initiated and never enters the GPU-kernel-initiated path, and its perf nodes lack GDA hardware. Add a before/after check inside the existing GPU leg: build the PR base (HEAD^1 of the merge ref) and run test_types_ucp_device_cuda on both the base and head builds, interleaving the runs so both see the same node load, then compare per-test BW/Lat. A regression above the threshold (default 15%, looser than the OSU 5% since the GPU CI nodes are shared and noisier) fails the job. master / non-PR builds (no HEAD^2) just run once for coverage, unchanged.
1 parent 95beefc commit 705f3ba

2 files changed

Lines changed: 240 additions & 15 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) NVIDIA CORPORATION & AFFILIATES, 2026. ALL RIGHTS RESERVED.
4+
# See file LICENSE for terms.
5+
#
6+
# Compare ucx_perftest device-CUDA results between a base build and a head
7+
# build and flag bandwidth/latency regressions. Tolerates noisy shared CI
8+
# nodes by taking the median across repeated runs and using a loose threshold.
9+
#
10+
# Input files are raw stdout of `ucx_perftest -b <batch>` (one file per run).
11+
# A batch test prints a "+<name>----" header line followed by a "Final:" line
12+
# with the result columns (see src/tools/perf/perftest_run.c).
13+
14+
import argparse
15+
import re
16+
import statistics
17+
import sys
18+
19+
HEADER_RE = re.compile(r"^\+([A-Za-z0-9_]+)")
20+
# Direct final line in final-only (`-f`) mode: "<name> <floats...>"
21+
DIRECT_RE = re.compile(r"^([A-Za-z0-9_]+)\s+([\d.\s]+)$")
22+
FLOAT_RE = re.compile(r"[-+]?\d*\.?\d+")
23+
24+
25+
def read_test_names(config_path):
26+
names = []
27+
with open(config_path) as f:
28+
for line in f:
29+
line = line.strip()
30+
if not line or line.startswith("#"):
31+
continue
32+
names.append(line.split()[0])
33+
return names
34+
35+
36+
def _metric_from_floats(name, floats):
37+
"""Return (bandwidth_MBs, latency_us) for one result row.
38+
39+
Multi-thread final row: iters, lat(us), bw(MB/s), msgrate (4 cols)
40+
Single-thread final row: iters, lat_pctl, lat_mom, lat_total,
41+
bw_mom, bw_total, mr_mom, mr_total (8 cols)
42+
"""
43+
if len(floats) >= 8:
44+
return floats[5], floats[3]
45+
if len(floats) >= 4:
46+
return floats[2], floats[1]
47+
return None, None
48+
49+
50+
def parse_file(path, names):
51+
"""Return {test_name: value} using the metric implied by the name."""
52+
name_set = set(names)
53+
results = {}
54+
current = None
55+
with open(path) as f:
56+
for line in f:
57+
m = HEADER_RE.match(line)
58+
if m and m.group(1) in name_set:
59+
current = m.group(1)
60+
continue
61+
target = None
62+
if line.startswith("Final:") and current is not None:
63+
target = current
64+
else:
65+
dm = DIRECT_RE.match(line)
66+
if dm and dm.group(1) in name_set:
67+
target = dm.group(1)
68+
if target is None:
69+
continue
70+
floats = [float(x) for x in FLOAT_RE.findall(line)]
71+
bw, lat = _metric_from_floats(target, floats)
72+
val = bw if "_bw_" in target else lat
73+
if val is not None:
74+
results[target] = val
75+
current = None
76+
return results
77+
78+
79+
def median_by_test(paths, names):
80+
samples = {}
81+
for p in paths:
82+
for name, val in parse_file(p, names).items():
83+
samples.setdefault(name, []).append(val)
84+
return {name: statistics.median(vals) for name, vals in samples.items()}
85+
86+
87+
def regression_pct(name, base, head):
88+
"""Positive value == head is worse than base."""
89+
if base == 0:
90+
return 0.0
91+
if "_bw_" in name: # bandwidth: higher is better
92+
return (base - head) / base * 100.0
93+
return (head - base) / base * 100.0 # latency: lower is better
94+
95+
96+
def main():
97+
ap = argparse.ArgumentParser()
98+
ap.add_argument("--names", required=True,
99+
help="batch config file (test_types_ucp_device_cuda)")
100+
ap.add_argument("--threshold", type=float, default=15.0,
101+
help="max tolerated regression %% (default 15)")
102+
ap.add_argument("--base", nargs="+", required=True)
103+
ap.add_argument("--head", nargs="+", required=True)
104+
args = ap.parse_args()
105+
106+
names = read_test_names(args.names)
107+
base = median_by_test(args.base, names)
108+
head = median_by_test(args.head, names)
109+
110+
worst = 0.0
111+
regressed = []
112+
print("%-34s %12s %12s %9s" % ("test", "base", "head", "regr%"))
113+
for name in names:
114+
if name not in base or name not in head:
115+
print("%-34s %12s %12s (missing)" % (name, base.get(name, "-"),
116+
head.get(name, "-")))
117+
continue
118+
pct = regression_pct(name, base[name], head[name])
119+
worst = max(worst, pct)
120+
flag = " <== REGRESSION" if pct > args.threshold else ""
121+
print("%-34s %12.2f %12.2f %8.1f%%%s" %
122+
(name, base[name], head[name], pct, flag))
123+
if pct > args.threshold:
124+
regressed.append((name, pct))
125+
126+
if regressed:
127+
msg = "device perftest regression > %.0f%%: %s" % (
128+
args.threshold,
129+
", ".join("%s %.0f%%" % (n, p) for n, p in regressed))
130+
# Loud, build-failing error (Azure annotation + non-zero exit).
131+
print("##vso[task.logissue type=error]" + msg)
132+
print("FAIL: " + msg, file=sys.stderr)
133+
return 1
134+
135+
print("No device perftest regression above %.0f%% threshold." %
136+
args.threshold)
137+
return 0
138+
139+
140+
if __name__ == "__main__":
141+
sys.exit(main())

contrib/test_jenkins.sh

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ source $(dirname $0)/../buildlib/tools/common.sh
2929
WORKSPACE=${WORKSPACE:=$PWD}
3030
ucx_inst=${WORKSPACE}/install
3131

32+
# Absolute path to the source tree, captured before any 'cd' so it stays valid
33+
# regardless of the current directory at call time.
34+
ucx_src_dir=$(cd "$(dirname "$0")" && pwd)
35+
3236
if [ -z "$BUILD_NUMBER" ]; then
3337
echo "Running interactive"
3438
BUILD_NUMBER=1
@@ -178,6 +182,7 @@ run_client_server_app() {
178182
server_addr_arg=$3
179183
kill_server=$4
180184
error_emulation=$5
185+
out_file=${6:-}
181186

182187
server_port_arg="-p $server_port"
183188
step_server_port
@@ -195,11 +200,19 @@ run_client_server_app() {
195200
set +Ee
196201
fi
197202

198-
taskset -c $affinity_client ${test_exe} ${test_args} ${server_addr_arg} ${server_port_arg} &
203+
if [ -n "${out_file}" ]
204+
then
205+
taskset -c $affinity_client ${test_exe} ${test_args} ${server_addr_arg} ${server_port_arg} > "${out_file}" 2>&1 &
206+
else
207+
taskset -c $affinity_client ${test_exe} ${test_args} ${server_addr_arg} ${server_port_arg} &
208+
fi
199209
client_pid=$!
200210

201211
wait ${client_pid}
202212

213+
# Echo captured output so it still appears in the CI log.
214+
[ -n "${out_file}" ] && cat "${out_file}" || true
215+
203216
if [ $error_emulation -eq 1 ]
204217
then
205218
set -eE
@@ -634,7 +647,51 @@ run_ucx_perftest_with_daemon() {
634647
}
635648

636649
#
637-
# Run UCX performance cuda device test
650+
# Run the cuda device perftest against a given install prefix, capturing the
651+
# client output to a results file (when given).
652+
#
653+
run_device_perftest_at() {
654+
local inst=$1
655+
local out_file=${2:-}
656+
# TODO: Run on all GPUs & NICs combinations
657+
local ucp_test_args="-b ${inst}/share/ucx/perftest/test_types_ucp_device_cuda"
658+
local ucp_client_args="-a cuda:0 $(hostname)"
659+
660+
# TODO: Run with cuda_ipc_tls (cuda_copy,rc,cuda_ipc)
661+
export UCX_TLS="cuda_copy,rc,rc_gda"
662+
run_client_server_app "${inst}/bin/ucx_perftest" "${ucp_test_args}" \
663+
"${ucp_client_args}" 0 0 "${out_file}"
664+
unset UCX_TLS
665+
}
666+
667+
#
668+
# Build UCX at a specific commit into a separate prefix ($base_inst), reusing
669+
# the standard prepare()/build() with the same configuration as the current
670+
# build. Used to produce the "before" binaries for perf regression compare.
671+
#
672+
build_ucx_at_commit() {
673+
local sha=$1
674+
local base_src="${WORKSPACE}/base-src"
675+
base_inst="${WORKSPACE}/install-base"
676+
677+
(cd "${WORKSPACE}" && git worktree add -f --detach "${base_src}" "${sha}") \
678+
|| return 1
679+
680+
local rc=0
681+
(
682+
WORKSPACE="${base_src}"
683+
ucx_inst="${base_inst}"
684+
prepare
685+
build devel --without-valgrind
686+
) || rc=1
687+
688+
(cd "${WORKSPACE}" && git worktree remove --force "${base_src}") || true
689+
return ${rc}
690+
}
691+
692+
#
693+
# Run UCX performance cuda device test, and (on a PR build) compare the
694+
# bandwidth/latency against the PR base to catch device-API regressions.
638695
#
639696
run_ucx_perftest_cuda_device() {
640697
if [ "X$have_cuda" == "Xno" ]; then
@@ -652,23 +709,50 @@ run_ucx_perftest_cuda_device() {
652709
return 0
653710
fi
654711

655-
echo "==== Running ucx_perftest with cuda kernel ===="
656-
ucx_inst_ptest=$ucx_inst/share/ucx/perftest
657-
ucx_perftest="$ucx_inst/bin/ucx_perftest"
658-
ucp_test_args="-b $ucx_inst_ptest/test_types_ucp_device_cuda"
712+
echo "==== Running ucx_perftest with cuda kernel ===="
713+
local repeat="${UCX_PERFTEST_REPEAT:-3}"
714+
# OSU perf (dedicated nodes) uses 5%; device perf runs on shared, noisier
715+
# GPU CI nodes, so the default tolerance is higher.
716+
local threshold="${UCX_PERFTEST_REGRESSION_THRESHOLD:-15}"
717+
local res_dir="${WORKSPACE}/device_perf"
718+
rm -rf "${res_dir}"
719+
mkdir -p "${res_dir}"
720+
721+
# On a PR build the checkout is the merge ref: HEAD^1 is the target branch
722+
# tip (base) and HEAD^2 is the PR head. master / non-merge builds have no
723+
# HEAD^2, so there is nothing to compare against - run once for coverage.
724+
if ! (cd "${WORKSPACE}" && git rev-parse --verify -q HEAD^2 >/dev/null 2>&1)
725+
then
726+
echo "==== Not a PR merge build; running device perftest without comparison ===="
727+
run_device_perftest_at "${ucx_inst}" ""
728+
return 0
729+
fi
659730

660-
# TODO: Run on all GPUs & NICs combinations
661-
ucp_client_args="-a cuda:0 $(hostname)"
662-
gda_tls="cuda_copy,rc,rc_gda"
663-
cuda_ipc_tls="cuda_copy,rc,cuda_ipc"
731+
local base_sha
732+
base_sha=$(cd "${WORKSPACE}" && git rev-parse HEAD^1)
733+
echo "==== Building base ${base_sha} for device perftest comparison ===="
734+
if ! build_ucx_at_commit "${base_sha}"
735+
then
736+
echo "==== Base build failed; running device perftest without comparison ===="
737+
run_device_perftest_at "${ucx_inst}" ""
738+
return 0
739+
fi
664740

665-
# TODO: Run with cuda_ipc_tls
666-
for tls in "$gda_tls"
741+
# Interleave head/base runs so both see the same node-load window - on the
742+
# shared GPU CI nodes, measuring them far apart would skew the comparison.
743+
local i
744+
for i in $(seq 1 "${repeat}")
667745
do
668-
export UCX_TLS=${tls}
669-
run_client_server_app "$ucx_perftest" "$ucp_test_args" "$ucp_client_args" 0 0
746+
run_device_perftest_at "${ucx_inst}" "${res_dir}/head.${i}.txt"
747+
run_device_perftest_at "${base_inst}" "${res_dir}/base.${i}.txt"
670748
done
671-
unset UCX_TLS
749+
750+
echo "==== Comparing device perftest: base vs head ===="
751+
python3 "${ucx_src_dir}/../buildlib/tools/compare_ucx_perftest.py" \
752+
--names "${ucx_inst}/share/ucx/perftest/test_types_ucp_device_cuda" \
753+
--threshold "${threshold}" \
754+
--base "${res_dir}"/base.*.txt \
755+
--head "${res_dir}"/head.*.txt
672756
}
673757

674758
#

0 commit comments

Comments
 (0)