-
Notifications
You must be signed in to change notification settings - Fork 107
Expand file tree
/
Copy pathJenkinsfile
More file actions
310 lines (291 loc) · 13.2 KB
/
Copy pathJenkinsfile
File metadata and controls
310 lines (291 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
properties([
// abortPrevious, so a new push kills the superseded run instead of queueing
// behind it: these builds are long and the old commit's result is not wanted.
disableConcurrentBuilds(abortPrevious: true),
buildDiscarder(logRotator(numToKeepStr: '8', daysToKeepStr: '20'))
])
// One entry per supported CUDA version. gcc is a host gcc-toolset this nvcc
// accepts. gpuType pins the card where the card is the point of the config, so
// a run always covers sm_80 and sm_120; the arch itself is read off the card
// rather than written down, and archs is what this nvcc can target - a pod that
// lands outside it skips instead of failing (sm_120 needs CUDA >= 12.8; 13.x
// dropped sm_70).
// One card of each kind, so no run duplicates an arch: 13.x cannot go on the
// V100 at all, which leaves 12.6 as the only entry that can still cover sm_70.
// The 11.8 entry is the unpinned one: it takes any GPU node and is the only one
// asking for two GPUs, since cufinufft_multigpu does not care which cards it
// gets (it SKIPs anyway unless they are full cards, as CUDA enumerates one MIG
// instance per process).
// torch names the PyTorch wheel index. It must match the toolkit, because the
// image puts /usr/local/cuda/lib64 on LD_LIBRARY_PATH, which outranks torch's
// RUNPATH: a newer bundled cudart is shadowed by the older system one and every
// import dies on a missing symbol. It also decides which architectures the wheel
// carries, and cu126 is the last index building for sm_70 - hence 12.6 for the
// V100 rather than 12.4, whose index stopped resolving when the cudnn its every
// torch pins was pruned from download.pytorch.org and pypi.nvidia.com alike.
// Each pod needs a host driver its CUDA accepts (13.0 wants r580+); cuda-compat
// does not bridge a major version, so the first sh block prints nvidia-smi.
// A perf pod takes the card rather than the node: only one node carries an
// rtx6000, so a request only that node could satisfy sits Pending until the
// timeout whenever anything else is on it.
//
// The rtx6000 and a100 nodes hand out MIG slices, and a slice shares its L2,
// its TLB and its link with the other slices, which is exactly what a timing
// must not do. The V100 is the highest capability this cluster hands over
// whole, so the perf pods take it and the preflight fails the stage if a device
// ever comes back named MIG.
//
// A whole-node pod is `exclusive: true`: a co-tenant pollutes a CPU measurement
// and pinning does not isolate it. The count has to stay next to it, because a
// cpu request still sets the pod's cpuset and runPod defaults to 4 - measured on
// build 50, whose exclusive pod got 4 of the node's 72 processors. 64 is the
// most the current exclusive node (66 allocatable) can give while the jnlp
// sidecar keeps 1, and an even count means whole cores rather than SMT halves.
// The harness sizes every case from the affinity mask it ends up with.
def PERF_CPU_CORES = 64
// gh is in the image; the credential is an environment variable of the step
// and never lives on disk. Publishing runs in the main container.
def withGh(Closure body) {
container('main') {
withCredentials([usernamePassword(credentialsId: 'github-jenkins',
usernameVariable: 'GH_USER',
passwordVariable: 'GH_TOKEN')]) {
body()
}
}
}
// compute_cap is "8.9" for sm_89 and "12.0" for sm_120.
def gpuArch() {
sh(returnStdout: true, script:
'nvidia-smi --query-gpu=compute_cap --format=csv,noheader | head -1 | tr -d .').trim()
}
def configs = [
[cuda: '11.8', gcc: 11, torch: 'cu118', archs: ['70', '75', '80', '86', '89', '90'], gpus: 2],
[cuda: '12.6', gcc: 13, torch: 'cu126', gpuType: 'v100', archs: ['70', '75', '80', '86', '89', '90']],
[cuda: '12.8', gcc: 13, torch: 'cu128', gpuType: 'rtx6000', archs: ['70', '75', '80', '86', '89', '90', '100', '120']],
[cuda: '13.0', gcc: 14, torch: 'cu130', gpuType: 'a100', archs: ['75', '80', '86', '89', '90', '100', '120']],
]
catchError {
timeout(time: 3, unit: 'HOURS') {
buildImages(configs.collect { cfg ->
[ context: 'tools/cufinufft/docker', dockerfile: 'Dockerfile-x86_64',
tag: "cuda${cfg.cuda}",
buildArgs: "--build-arg CUDA_VERSION=${cfg.cuda} --build-arg GCC_TOOLSET=${cfg.gcc}" +
" --build-arg TORCH_INDEX=${cfg.torch}"
]
}, checkout: true)
def jobs = configs.collectEntries { cfg -> ['cuda-' + cfg.cuda, {
runPod(tag: "cuda${cfg.cuda}", cpus: 8, memory: '32Gi',
gpus: cfg.gpus ?: 1, gpuType: cfg.gpuType) {
stage("cuda ${cfg.cuda}") {
def arch = gpuArch()
if (!arch) {
error "nvidia-smi did not report compute_cap - the whole matrix would skip silently"
}
if (!cfg.archs.contains(arch)) {
echo "pod has sm_${arch}, which CUDA ${cfg.cuda} cannot target - skipping"
return
}
withEnv([
"HOME=$WORKSPACE",
"CUDA=${cfg.cuda}",
"CUDA_ARCH=${arch}",
"LIBRARY_PATH=$WORKSPACE/build:/usr/local/cuda/lib64/stubs",
"LD_LIBRARY_PATH=$WORKSPACE/build:/usr/local/cuda/lib64"
]) {
sh 'tools/ci/cuda-build-test.sh'
sh 'tools/ci/cuda-wheel.sh'
// catchError so a wheel that fails its tests still gets archived below.
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE') {
sh 'tools/ci/cuda-wheel-test.sh'
}
archiveArtifacts artifacts: 'wheelhouse/cuda*/cufinufft-*.whl', fingerprint: true
}
}
}
}] }
// The perftest comment, PR builds only: CHANGE_ID is unset on branch builds.
// Each half writes its own section in its own pod - the CPU half wants cores
// and no card, the GPU half wants the card - so the two run in parallel and
// the post step concatenates the sections into one comment.
//
// Beside the validation matrix, not after it, so neither waits. A validation
// pod may therefore land next to a perf pod; the interleaved rounds and the
// per-arm median are what keep a noisy neighbour out of the ratio.
//
// UNSTABLE rather than failed, and catchInterruptions because the timeout is
// the case to catch: a busy cluster must not mark the PR red.
def perfCpu = {
runPod(tag: 'cuda12.8', cpus: PERF_CPU_CORES, memory: '32Gi', exclusive: true) {
stage('perf cpu') {
withEnv([
"HOME=$WORKSPACE",
"CPM_SOURCE_CACHE=$WORKSPACE/.cpm",
"PARALLEL=16"
]) {
sh 'tools/ci/perf-cpu.sh'
}
stash name: 'perf-cpu', includes: 'cpu_perf.svg,cpu_perf.md'
}
}
}
// v100 for the card MIG cannot slice: the rtx6000 and a100 nodes run with MIG
// on, and a slice shares its last-level TLB and PCIe with its neighbours.
def perfGpu = {
runPod(tag: 'cuda12.8', cpus: 32, memory: '32Gi', gpus: 1, gpuType: 'v100') {
stage('perf gpu') {
// Before the measuring, so a build that cannot post says so early.
withGh { sh 'gh --version && gh auth status' }
def arch = gpuArch()
withEnv([
"HOME=$WORKSPACE",
"CUDA_ARCH=${arch}",
"CPM_SOURCE_CACHE=$WORKSPACE/.cpm",
"LIBRARY_PATH=/usr/local/cuda/lib64/stubs",
"PARALLEL=12"
]) {
sh 'tools/ci/perf-gpu.sh'
}
stash name: 'perf-gpu', includes: 'gpu_perf.svg,gpu_perf.md'
}
}
}
// Posting waits for both halves, so it is its own step, in a pod that needs
// neither a card nor cores.
def perfPost = {
runPod(tag: 'cuda12.8', cpus: 2, memory: '8Gi') {
stage('perf comment') {
withGh {
unstash 'perf-cpu'
unstash 'perf-gpu'
sh 'cat cpu_perf.md gpu_perf.md > perf_body.md'
sh 'tools/ci/perf-plots-push.sh'
sh 'gh pr comment "$CHANGE_ID" --repo flatironinstitute/finufft' +
' --edit-last --create-if-none --body-file perf_body.md'
sh 'tools/ci/perf-refs-sweep.sh'
}
}
}
}
def perfJob = {
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE',
catchInterruptions: true) {
timeout(time: 75, unit: 'MINUTES') {
parallel 'perf cpu': perfCpu, 'perf gpu': perfGpu
perfPost()
}
}
}
// The perftest report page behind the RTD performance section. master
// publishes it, and a PR has nothing to publish. A PR whose title carries
// [perf page] rehearses the same job instead: everything runs and the
// publish becomes an artifact, so a change to the page machinery is
// provable on the PR rather than on the first master build after it lands.
// Another branch of the same parallel, so it does not wait for the matrix.
//
// Two pods: one holds a whole node for both CPU backends in turn, one holds
// the card. A post step assembles the page from the three stashes. UNSTABLE
// rather than failed, since republishing a docs page says nothing about
// library code.
def pagePublishes = env.BRANCH_NAME == 'master'
def PAGE_VERSIONS = 'v2.2.0 v2.3.1 v2.4.1 v2.5.1'
// One backend, measured on a node this pod holds alone.
def pageBackend = { backend, ducc ->
stage('page ' + backend) {
withEnv([
"HOME=$WORKSPACE",
"CPM_SOURCE_CACHE=$WORKSPACE/.cpm",
"PARALLEL=16",
"VERSIONS=${PAGE_VERSIONS}",
"BACKEND=${backend}",
"DUCC=${ducc}"
]) {
sh 'tools/ci/page-worktrees.sh'
sh 'tools/ci/page-cpu.sh'
}
stash name: 'page-' + backend, includes: 'outputs/**'
}
}
// Both backends share one pod, one after the other. This cluster does not
// hand out three whole nodes at once, so a pod per backend waits for a node
// instead of running: slower, not faster. The pod still holds its node
// alone, which is what the numbers need, and the two backends never appear
// on one plot, so their order is free.
def pageCpu = {
runPod(tag: 'cuda12.8', cpus: PERF_CPU_CORES, memory: '32Gi', exclusive: true) {
pageBackend('fftw', 'OFF')
pageBackend('ducc', 'ON')
}
}
// v100 for the card MIG cannot slice, as in the comment's GPU half. One
// build per release, so the pod spends most of its time in nvcc.
def pageGpu = {
runPod(tag: 'cuda12.8', cpus: 32, memory: '32Gi', gpus: 1, gpuType: 'v100') {
stage('page gpu') {
def arch = gpuArch()
withEnv([
"HOME=$WORKSPACE",
"CUDA_ARCH=${arch}",
"CPM_SOURCE_CACHE=$WORKSPACE/.cpm",
"LIBRARY_PATH=/usr/local/cuda/lib64/stubs",
"PARALLEL=12",
"VERSIONS=${PAGE_VERSIONS}"
]) {
sh 'tools/ci/page-worktrees.sh'
sh 'tools/ci/page-gpu.sh'
}
stash name: 'page-gpu', includes: 'outputs/**'
}
}
}
def pagePost = {
runPod(tag: 'cuda12.8', cpus: 2, memory: '8Gi') {
stage('perftest page') {
// Fail on the credential before the publish, not after.
// A rehearsal publishes nothing, so it asks for no credential.
if (pagePublishes) withGh { sh 'gh --version && gh auth status' }
// A half that failed leaves its section out; the others still
// publish. unstash of a missing stash is what would fail the step.
for (half in ['page-fftw', 'page-ducc', 'page-gpu']) {
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE') {
unstash half
}
}
sh 'tools/ci/page-assemble.sh'
if (pagePublishes) withGh {
// gh's credential helper writes the global gitconfig, and this
// pod sets no home of its own: HOME is / there, and the build user
// cannot write it.
withEnv(["HOME=$WORKSPACE"]) {
sh 'tools/ci/page-publish.sh'
}
} else {
// A rehearsal stops here: the page and its figures ride out as
// build artifacts, and the branch readthedocs fetches is untouched.
archiveArtifacts artifacts: 'docs/performance_change_summary.rst,docs/pics/perftestci_*.svg'
}
}
}
}
def pageJob = {
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE',
catchInterruptions: true) {
// Covers a wait for a free node, then both CPU backends in turn.
timeout(time: 180, unit: 'MINUTES') {
parallel 'page cpu': pageCpu,
'page gpu': pageGpu
pagePost()
}
}
}
// Each half of either job holds a whole node or a whole card, so both at
// once ask for two of each and the second whole-node pod stays
// Unschedulable through its 300s launch timeout. In sequence instead.
def measures = []
if (env.CHANGE_ID) measures << perfJob
if (pagePublishes || (env.CHANGE_TITLE ?: '').contains('[perf page]')) measures << pageJob
if (measures) jobs['measure'] = { for (measure in measures) measure() }
parallel jobs
}
}
emailFailure()