Skip to content

Commit f38299f

Browse files
feat(cuda): selectable compilation target via -K cuda_arch, traced by Lake
Device code is compiled *for* a GPU architecture, but the native kernels were compiled with no -arch/-gencode at all, leaving nvcc on its built-in default. That default is a property of the toolkit, not of the machine: CUDA 13.0 emits sm_75 SASS plus compute_75 PTX. On any other GPU the kernels then run through forward PTX JIT rather than native SASS, or on an older architecture do not load at all, and nothing about the build says so. Add -K cuda_arch=<spec>, with TORCHLEAN_CUDA_ARCH as a fallback so a container image or CI job can select a target without rewriting its build command. A bare spec becomes -arch=<spec>, which covers sm_86, compute_86, native, all, and all-major; a spec starting with `-` is passed to nvcc verbatim, which is how a multi-architecture binary is requested and which keeps this package free of any policy about which architecture carries the PTX. Selecting a target is only meaningful if changing it rebuilds, so also split buildNativeBackendLib's compiler arguments the way buildO intends: include paths stay in weakArgs, where a moved checkout does not invalidate every object, and the flags that change what the compiler emits move to traceArgs, which buildO hashes. Previously every argument sat in weakArgs, so a changed optimization level or compilation target left the existing objects looking current. nvcc's own NVCC_APPEND_FLAGS has the same hole and Lake cannot close it — the option and the environment variable added here both participate in the trace, and the documentation says which to prefer. scripts/checks/cuda_arch_target.sh asserts the whole surface — flags reaching nvcc, recompilation on a changed target, no recompilation on an unchanged one, the environment fallback, its precedence, and verbatim pass-through — against a recording stand-in for nvcc, so it needs neither a CUDA toolkit nor a GPU and runs in CI. One consequence worth expecting: moving the flags into the trace changes the trace of every object built by buildNativeBackendLib, so the first build after this lands recompiles those four objects once — the four .cu kernels under CUDA, or the four C stubs on a CPU-only machine.
1 parent b2a2184 commit f38299f

6 files changed

Lines changed: 252 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ jobs:
4747
- name: Run curated test suite
4848
run: ~/.elan/bin/lake test
4949

50+
# Runs against a recording stand-in for nvcc, so this needs no CUDA toolkit and no GPU.
51+
- name: Check the CUDA compilation target
52+
run: LAKE=~/.elan/bin/lake scripts/checks/cuda_arch_target.sh
53+
5054
slow_proofs:
5155
name: CI (slow proofs)
5256
runs-on: ubuntu-latest

home_page/cuda/index.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,38 @@ Run the CUDA sanitizer suite when changing native kernels:
4747
scripts/checks/cuda_sanitize_tests.sh --all-tools
4848
```
4949

50+
## Compilation Target
51+
52+
`-K cuda=true` says to compile the native kernels; it does not say which GPU to compile them
53+
*for*. That second choice belongs to `nvcc`, which without instruction applies a built-in default
54+
that changes with the toolkit version — CUDA 13.0, for instance, emits `sm_75` machine code plus
55+
`compute_75` PTX. Such a binary runs at full speed on that architecture, reaches a newer GPU only
56+
through forward PTX just-in-time compilation, and does not load on an older one at all. Name the
57+
target when the deployment GPU is known:
58+
59+
```bash
60+
lake -R -K cuda=true -K cuda_arch=sm_86 build # an A10G or an RTX A4500
61+
lake -R -K cuda=true -K cuda_arch=native build # whatever GPU this machine has
62+
```
63+
64+
A value starting with `-` is passed to `nvcc` verbatim, which is how one binary is compiled for
65+
several architectures at once:
66+
67+
```bash
68+
lake -R -K cuda=true \
69+
-K cuda_arch="-gencode arch=compute_75,code=sm_75 \
70+
-gencode arch=compute_90,code=[sm_90,compute_90]" build
71+
```
72+
73+
`TORCHLEAN_CUDA_ARCH` carries the same value when the Lake option is absent, so a container image
74+
or CI job can select a target without rewriting its build command. Both spellings participate in
75+
Lake's build trace: changing the target recompiles the kernels. `nvcc`'s own `NVCC_APPEND_FLAGS`
76+
does not — Lake cannot see it, judges the existing objects current, and links kernels compiled for
77+
the previous target, which is visible only as unexplained throughput. Prefer the option, and delete
78+
`.lake/build/torchlean_*.o` if a target was ever set that way.
79+
80+
`scripts/checks/cuda_arch_target.sh` asserts this behavior, and needs neither a toolkit nor a GPU.
81+
5082
## What CUDA Covers
5183

5284
The CUDA path is used for supported Float32 tensor operations: elementwise arithmetic, reductions,

home_page/installation/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ The two CUDA choices happen at different times. `-K cuda=true` tells Lake to com
101101
native CUDA implementation. `--device cuda` asks the executable to use it. A CPU-linked executable
102102
rejects `--device cuda` instead of silently moving the run back to the CPU.
103103

104+
`nvcc` compiles the kernels for its own default architecture unless told otherwise. Add
105+
`-K cuda_arch=sm_86` (or `native`, or a verbatim `-gencode` list) to compile for the GPU you deploy
106+
on; the [CUDA guide]({{ '/cuda/' | relative_url }}) explains what the default costs you.
107+
104108
Use `-R` whenever you switch between CPU and CUDA configurations; it forces Lake to recompute the
105109
build description. The CUDA regression suite is:
106110

lakefile.lean

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,52 @@ private def cudaHome : String :=
3131
| some p => cleanCudaHome p
3232
| none => "/usr/local/cuda"
3333

34+
/-- CUDA compilation target for the native kernels, from `-K cuda_arch=...`.
35+
36+
Device code is compiled *for* an architecture. With no target, `nvcc` applies its own built-in
37+
default, which changes with the toolkit version (CUDA 13.0 emits `sm_75` SASS plus `compute_75`
38+
PTX). A binary then runs natively on that architecture only, reaching newer GPUs through forward
39+
PTX JIT and older ones not at all, so the default is a portability choice rather than a
40+
performance one.
41+
42+
Accepted values:
43+
* a bare architecture, such as `sm_86`, `compute_86`, `native`, `all`, or `all-major`, passed as
44+
`-arch=<value>` — `nvcc` shorthand for that architecture's SASS *and* its PTX;
45+
* a value starting with `-`, such as `-gencode arch=compute_86,code=[sm_86,compute_86]`, split on
46+
spaces and passed verbatim, which is how a multi-architecture binary is requested.
47+
48+
The `TORCHLEAN_CUDA_ARCH` environment variable supplies the same value when the Lake option is
49+
absent, so a container or CI job can select the target without rewriting its `lake` invocation.
50+
Prefer either spelling over `nvcc`'s own `NVCC_APPEND_FLAGS`: both reach `buildO`'s traced
51+
arguments below, whereas a variable Lake never reads leaves the existing objects looking current,
52+
and the build then links kernels compiled for the previous architecture. -/
53+
private def cudaArchConfig : Option String :=
54+
match get_config? cuda_arch with
55+
| some v =>
56+
let t := v.trimAscii.toString
57+
if t.isEmpty then none else some t
58+
| none => none
59+
60+
/-- Resolve the CUDA compilation target: the `cuda_arch` Lake option, else `TORCHLEAN_CUDA_ARCH`,
61+
else none (leaving `nvcc` on its built-in default). -/
62+
private def resolveCudaArch : SpawnM (Option String) := do
63+
match cudaArchConfig with
64+
| some spec => return some spec
65+
| none =>
66+
let env ← IO.getEnv "TORCHLEAN_CUDA_ARCH"
67+
return env.bind fun v =>
68+
let t := v.trimAscii.toString
69+
if t.isEmpty then none else some t
70+
71+
/-- `nvcc` flags for a resolved CUDA compilation target; empty when no target was requested. -/
72+
private def cudaArchArgs : Option String → Array String
73+
| none => #[]
74+
| some spec =>
75+
if spec.startsWith "-" then
76+
((spec.splitOn " ").filter (!·.isEmpty)).toArray
77+
else
78+
#[s!"-arch={spec}"]
79+
3480
/-- Optional explicit LibTorch root from `-K libtorch_home=...`. -/
3581
private def libtorchHomeConfig : Option String :=
3682
match get_config? libtorch_home with
@@ -240,22 +286,26 @@ private def buildNativeBackendLib (pkg : Package) (spec : NativeBackendLib) := d
240286
let headerDeps ← nativeHeaderDeps pkg
241287
let includeArgs := nativeIncludeArgs pkg
242288
let libFile := pkg.buildDir / nameToStaticLib spec.stem
289+
-- Include paths stay in `weakArgs`, where a moved checkout does not invalidate every object.
290+
-- The flags that change what the compiler emits belong in `traceArgs`: `buildO` hashes those,
291+
-- so switching optimization level or CUDA compilation target rebuilds instead of silently
292+
-- reusing objects built for the previous one.
243293
if cudaEnabled then
244294
let srcJob ← inputFile (pkg.dir / spec.cudaSrc) false
245295
let oFile := pkg.buildDir / s!"{spec.stem}.o"
296+
let archArgs := cudaArchArgs (← resolveCudaArch)
246297
let oJob ← buildO oFile srcJob
247-
(#[
248-
"-I", lean.includeDir.toString,
249-
"-I", s!"{cudaHome}/include",
250-
"-c", "--std=c++17", "-O2", "-Xcompiler", "-fPIC"
251-
] ++ includeArgs) #[] "nvcc" (pure headerDeps.getTrace)
298+
(weakArgs := #["-I", lean.includeDir.toString, "-I", s!"{cudaHome}/include"] ++ includeArgs)
299+
(traceArgs := #["-c", "--std=c++17", "-O2", "-Xcompiler", "-fPIC"] ++ archArgs)
300+
(compiler := "nvcc") (extraDepTrace := pure headerDeps.getTrace)
252301
buildStaticLib libFile #[oJob]
253302
else
254303
let srcJob ← inputFile (pkg.dir / spec.stubSrc) false
255304
let oFile := pkg.buildDir / s!"{spec.stem}_stub.o"
256305
let oJob ← buildO oFile srcJob
257-
(#["-I", lean.includeDir.toString] ++ includeArgs ++ #["-O2", "-fPIC"])
258-
#[] "cc" (pure headerDeps.getTrace)
306+
(weakArgs := #["-I", lean.includeDir.toString] ++ includeArgs)
307+
(traceArgs := #["-O2", "-fPIC"])
308+
(compiler := "cc") (extraDepTrace := pure headerDeps.getTrace)
259309
buildStaticLib libFile #[oJob]
260310

261311
/-- Native backend for `torchlean_dgemm_cuda`: CUDA+cuBLAS when `-K cuda=true`, else C stub. -/

scripts/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ These scripts are used by the build, documentation, and local verification paths
2525
- `checks/example_regression.sh`
2626
- `checks/cuda_sanitize_tests.sh`
2727
- `checks/cuda_profile_tests.sh`
28+
- `checks/cuda_arch_target.sh`
2829
- `checks/repo_lint.py`
2930
- `checks/TorchLeanLint.lean`
3031
- `checks/dependency_audit.py`
@@ -104,6 +105,10 @@ Generated locally:
104105
- `checks/cuda_sanitize_tests.sh`: CUDA sanitizer runner for the CUDA runtime test suite.
105106
- `checks/cuda_profile_tests.sh`: optional Nsight Systems / Nsight Compute wrapper for CUDA
106107
performance reports.
108+
- `checks/cuda_arch_target.sh`: checks that the `cuda_arch` Lake option and the
109+
`TORCHLEAN_CUDA_ARCH` environment fallback reach `nvcc` and that changing the target
110+
recompiles the kernels. A recording stand-in for `nvcc` supplies the evidence, so neither a
111+
CUDA toolkit nor a GPU is needed.
107112
- `checks/repo_lint.py`: repository lint used by `lake lint`. It checks source hygiene, public API
108113
boundaries, import-only aggregators, fixed-rank names in public tensor/model APIs,
109114
trusted-axiom quarantine, public-example spellings, DocGen/Verso math markup, and module

scripts/checks/cuda_arch_target.sh

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env bash
2+
# Check that the CUDA compilation target reaches nvcc and invalidates stale objects.
3+
set -euo pipefail
4+
5+
usage() {
6+
cat <<'EOF'
7+
Usage: scripts/checks/cuda_arch_target.sh [options]
8+
9+
Verify the `cuda_arch` Lake option end to end without a CUDA toolkit or a GPU.
10+
11+
A recording stand-in for nvcc is placed first on PATH; it logs its argument vector and emits an
12+
empty object file. One extern library is then built repeatedly under different targets, and the
13+
log is asserted against what each build should have compiled:
14+
15+
no target no -arch flag; nvcc keeps its built-in default
16+
-K cuda_arch=sm_86 -arch=sm_86
17+
-K cuda_arch=sm_86 (repeated) no recompilation
18+
-K cuda_arch=sm_89 recompiled, because the target is a traced argument
19+
TORCHLEAN_CUDA_ARCH=sm_90 -arch=sm_90, the environment fallback
20+
both, option and environment the option wins
21+
-K cuda_arch=-gencode ... passed through verbatim, for multi-architecture binaries
22+
23+
The stand-in's objects are removed afterwards, so a later real CUDA build cannot mistake them
24+
for its own. Any genuine object for that library goes with them, which costs one recompilation
25+
on the next real CUDA build. The stored build configuration is returned to its default, since
26+
every build here enables CUDA.
27+
28+
Options:
29+
--target LIB Extern library to build. Default: torchlean_dgemm_cuda.
30+
-h, --help Show this help message.
31+
32+
Environment:
33+
LAKE Lake executable to use (default: lake).
34+
35+
Examples:
36+
scripts/checks/cuda_arch_target.sh
37+
LAKE=~/.elan/bin/lake scripts/checks/cuda_arch_target.sh
38+
EOF
39+
}
40+
41+
LAKE="${LAKE:-lake}"
42+
lib="torchlean_dgemm_cuda"
43+
44+
while [[ $# -gt 0 ]]; do
45+
case "$1" in
46+
--target) [[ $# -ge 2 ]] || { echo "--target needs a value" >&2; exit 2; }; lib="$2"; shift 2 ;;
47+
--target=*) lib="${1#--target=}"; shift ;;
48+
-h|--help) usage; exit 0 ;;
49+
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
50+
esac
51+
done
52+
53+
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
54+
cd "$repo"
55+
56+
work="$(mktemp -d)"
57+
build="$repo/.lake/build"
58+
59+
# The stand-in writes into the normal build directory, so its objects have to go whether the
60+
# check passes or fails: Lake would otherwise record them as up to date and a real CUDA build
61+
# would archive an empty object instead of recompiling.
62+
cleanup() {
63+
rm -f "$build/$lib.o" "$build/$lib.o.trace" "$build/lib$lib.a" "$build/lib$lib.a.trace"
64+
rm -rf "$work"
65+
# Lake remembers the last build configuration, and every build below sets `cuda=true`. Put the
66+
# default back, or the next plain `lake build` in this checkout reaches for a CUDA toolkit that
67+
# the developer running this check need not have.
68+
"$LAKE" -R check-build >/dev/null 2>&1 || true
69+
}
70+
trap cleanup EXIT
71+
72+
mkdir -p "$work/bin"
73+
cat > "$work/bin/nvcc" <<'STUB'
74+
#!/usr/bin/env bash
75+
# Recording stand-in for nvcc: log the argument vector, emit an empty object at -o.
76+
printf '%s\n' "$*" >> "${NVCC_LOG:?}"
77+
out=""; prev=""
78+
for a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done
79+
[ -n "$out" ] || { echo "recording nvcc: no -o in argument vector" >&2; exit 1; }
80+
printf 'int torchlean_recording_nvcc_probe;\n' | cc -x c -c -o "$out" -
81+
STUB
82+
chmod +x "$work/bin/nvcc"
83+
84+
export PATH="$work/bin:$PATH"
85+
export NVCC_LOG="$work/nvcc.log"
86+
: > "$NVCC_LOG"
87+
88+
# Start from no object at all, so the first build below is guaranteed to compile.
89+
rm -f "$build/$lib.o" "$build/$lib.o.trace" "$build/lib$lib.a" "$build/lib$lib.a.trace"
90+
91+
failures=0
92+
93+
# Build once and report how many compilations that build triggered.
94+
compilations_for() {
95+
local before after
96+
before="$(wc -l < "$NVCC_LOG")"
97+
"$@" >/dev/null 2>&1 || { echo " build failed: $*" >&2; return 1; }
98+
after="$(wc -l < "$NVCC_LOG")"
99+
echo "$((after - before))"
100+
}
101+
102+
check() {
103+
local name="$1" expect_compilations="$2" expect_flags="$3"; shift 3
104+
local n last
105+
n="$(compilations_for "$@")" || { failures=$((failures + 1)); return; }
106+
last="$(tail -n 1 "$NVCC_LOG")"
107+
if [[ "$n" != "$expect_compilations" ]]; then
108+
echo "FAIL $name: expected $expect_compilations compilation(s), saw $n" >&2
109+
failures=$((failures + 1))
110+
return
111+
fi
112+
if [[ -n "$expect_flags" && "$last" != *"$expect_flags"* ]]; then
113+
echo "FAIL $name: expected argument vector to contain '$expect_flags'" >&2
114+
echo " saw: $last" >&2
115+
failures=$((failures + 1))
116+
return
117+
fi
118+
if [[ -z "$expect_flags" && "$n" != "0" && "$last" == *"-arch"* ]]; then
119+
echo "FAIL $name: expected no -arch flag" >&2
120+
echo " saw: $last" >&2
121+
failures=$((failures + 1))
122+
return
123+
fi
124+
echo "ok $name"
125+
}
126+
127+
echo "Checking the CUDA compilation target through a recording nvcc ($lib)"
128+
129+
check "no target leaves nvcc on its default" 1 "" \
130+
"$LAKE" -R -K cuda=true build "$lib"
131+
check "-K cuda_arch=sm_86 compiles for sm_86" 1 "-arch=sm_86" \
132+
"$LAKE" -R -K cuda=true -K cuda_arch=sm_86 build "$lib"
133+
check "an unchanged target does not recompile" 0 "" \
134+
"$LAKE" -R -K cuda=true -K cuda_arch=sm_86 build "$lib"
135+
check "a changed target recompiles" 1 "-arch=sm_89" \
136+
"$LAKE" -R -K cuda=true -K cuda_arch=sm_89 build "$lib"
137+
check "TORCHLEAN_CUDA_ARCH is the fallback" 1 "-arch=sm_90" \
138+
env TORCHLEAN_CUDA_ARCH=sm_90 "$LAKE" -R -K cuda=true build "$lib"
139+
check "the option outranks the environment" 1 "-arch=sm_75" \
140+
env TORCHLEAN_CUDA_ARCH=sm_90 "$LAKE" -R -K cuda=true -K cuda_arch=sm_75 build "$lib"
141+
check "an explicit flag list passes through" 1 "-gencode arch=compute_120,code=[sm_120,compute_120]" \
142+
"$LAKE" -R -K cuda=true \
143+
-K cuda_arch="-gencode arch=compute_75,code=sm_75 -gencode arch=compute_120,code=[sm_120,compute_120]" \
144+
build "$lib"
145+
146+
if [[ "$failures" -ne 0 ]]; then
147+
echo "$failures check(s) failed" >&2
148+
exit 1
149+
fi
150+
echo "All CUDA compilation target checks passed."

0 commit comments

Comments
 (0)