Skip to content

Commit 76a0752

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 76a0752

2 files changed

Lines changed: 249 additions & 15 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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 os
16+
import re
17+
import statistics
18+
import sys
19+
20+
HEADER_RE = re.compile(r"^\+([A-Za-z0-9_]+)")
21+
FLOAT_RE = re.compile(r"[-+]?\d*\.?\d+")
22+
23+
24+
def read_test_names(config_path):
25+
names = []
26+
with open(config_path) as f:
27+
for line in f:
28+
line = line.strip()
29+
if not line or line.startswith("#"):
30+
continue
31+
names.append(line.split()[0])
32+
return names
33+
34+
35+
def _metric_from_floats(name, floats):
36+
"""Return (bandwidth_MBs, latency_us) for one result row.
37+
38+
Multi-thread final row: iters, lat(us), bw(MB/s), msgrate (4 cols)
39+
Single-thread final row: iters, lat_pctl, lat_mom, lat_total,
40+
bw_mom, bw_total, mr_mom, mr_total (8 cols)
41+
"""
42+
if len(floats) >= 8:
43+
return floats[5], floats[3]
44+
if len(floats) >= 4:
45+
return floats[2], floats[1]
46+
return None, None
47+
48+
49+
def parse_file(path, names):
50+
"""Return {test_name: value} using the metric implied by the name.
51+
52+
Each batch test prints a "+<name>----" header then a "Final:" result line.
53+
"""
54+
name_set = set(names)
55+
results = {}
56+
current = None
57+
if not os.path.isfile(path):
58+
return results
59+
with open(path) as f:
60+
for line in f:
61+
m = HEADER_RE.match(line)
62+
if m and m.group(1) in name_set:
63+
current = m.group(1)
64+
elif current is not None and line.startswith("Final:"):
65+
floats = [float(x) for x in FLOAT_RE.findall(line)]
66+
bw, lat = _metric_from_floats(current, floats)
67+
val = bw if "_bw_" in current else lat
68+
if val is not None:
69+
results[current] = val
70+
current = None
71+
return results
72+
73+
74+
def median_by_test(paths, names):
75+
samples = {}
76+
for p in paths:
77+
for name, val in parse_file(p, names).items():
78+
samples.setdefault(name, []).append(val)
79+
return {name: statistics.median(vals) for name, vals in samples.items()}
80+
81+
82+
def regression_pct(name, base, head):
83+
"""Positive value == head is worse than base."""
84+
if base == 0:
85+
return 0.0
86+
if "_bw_" in name: # bandwidth: higher is better
87+
return (base - head) / base * 100.0
88+
return (head - base) / base * 100.0 # latency: lower is better
89+
90+
91+
def main():
92+
ap = argparse.ArgumentParser()
93+
ap.add_argument("--names", required=True,
94+
help="batch config file (test_types_ucp_device_cuda)")
95+
ap.add_argument("--threshold", type=float, default=15.0,
96+
help="max tolerated regression %% (default 15)")
97+
ap.add_argument("--base", nargs="+", required=True)
98+
ap.add_argument("--head", nargs="+", required=True)
99+
args = ap.parse_args()
100+
101+
names = read_test_names(args.names)
102+
base = median_by_test(args.base, names)
103+
head = median_by_test(args.head, names)
104+
105+
if not base or not head:
106+
print("ERROR: no parseable result files (base=%d, head=%d). "
107+
"Did the perftest runs produce output?" % (len(base), len(head)),
108+
file=sys.stderr)
109+
return 1
110+
111+
regressed = []
112+
print("%-34s %12s %12s %9s" % ("test", "base", "head", "regr%"))
113+
for name in names:
114+
b = base.get(name)
115+
h = head.get(name)
116+
if b is not None and h is None:
117+
# Ran on base but produced no result on head - a hang/crash is the
118+
# loudest regression, so fail rather than silently skip.
119+
print("%-34s %12.2f %12s MISSING ON HEAD <== REGRESSION" %
120+
(name, b, "-"))
121+
regressed.append((name, float("inf")))
122+
continue
123+
if b is None or h is None:
124+
print("%-34s %12s %12s (no baseline)" %
125+
(name, "-" if b is None else b, "-" if h is None else h))
126+
continue
127+
pct = regression_pct(name, b, h)
128+
flag = " <== REGRESSION" if pct > args.threshold else ""
129+
print("%-34s %12.2f %12.2f %8.1f%%%s" % (name, b, h, pct, flag))
130+
if pct > args.threshold:
131+
regressed.append((name, pct))
132+
133+
if regressed:
134+
msg = "device perftest regression > %.0f%%: %s" % (
135+
args.threshold,
136+
", ".join("%s %.0f%%" % (n, p) for n, p in regressed))
137+
# Loud, build-failing error (Azure annotation + non-zero exit).
138+
print("##vso[task.logissue type=error]" + msg)
139+
print("FAIL: " + msg, file=sys.stderr)
140+
return 1
141+
142+
print("No device perftest regression above %.0f%% threshold." %
143+
args.threshold)
144+
return 0
145+
146+
147+
if __name__ == "__main__":
148+
sys.exit(main())

contrib/test_jenkins.sh

Lines changed: 101 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,53 @@ 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), used to
669+
# produce the "before" binaries for perf regression compare. Built in devel
670+
# mode to match the head leg's devel build (line ~1335); --enable-gtest is
671+
# omitted on purpose - it adds the gtest suite but does not change the
672+
# ucx_perftest / libucp / libuct codegen, so the comparison stays fair.
673+
#
674+
build_ucx_at_commit() {
675+
local sha=$1
676+
local base_src="${WORKSPACE}/base-src"
677+
base_inst="${WORKSPACE}/install-base"
678+
679+
(cd "${WORKSPACE}" && git worktree add -f --detach "${base_src}" "${sha}") \
680+
|| return 1
681+
682+
local rc=0
683+
(
684+
WORKSPACE="${base_src}"
685+
ucx_inst="${base_inst}"
686+
prepare
687+
build devel --without-valgrind
688+
) || rc=1
689+
690+
(cd "${WORKSPACE}" && git worktree remove --force "${base_src}") || true
691+
return ${rc}
692+
}
693+
694+
#
695+
# Run UCX performance cuda device test, and (on a PR build) compare the
696+
# bandwidth/latency against the PR base to catch device-API regressions.
638697
#
639698
run_ucx_perftest_cuda_device() {
640699
if [ "X$have_cuda" == "Xno" ]; then
@@ -652,23 +711,50 @@ run_ucx_perftest_cuda_device() {
652711
return 0
653712
fi
654713

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

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"
733+
local base_sha
734+
base_sha=$(cd "${WORKSPACE}" && git rev-parse HEAD^1)
735+
echo "==== Building base ${base_sha} for device perftest comparison ===="
736+
if ! build_ucx_at_commit "${base_sha}"
737+
then
738+
echo "==== Base build failed; running device perftest without comparison ===="
739+
run_device_perftest_at "${ucx_inst}" ""
740+
return 0
741+
fi
664742

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

674760
#

0 commit comments

Comments
 (0)