Skip to content

Commit 866909a

Browse files
committed
feat: a CUDA kernel behind a seam module, and the package-level declaration
The example is the design's form A end to end: a device translation unit that never enters the module graph, an extern "C" interface free of standard-library types, a seam module that turns that interface back into a C++ one, and a build-rule package that owns every fact about nvcc's spelling. The seam is the part worth reading. Its reason for existing is not that nvcc rejects modules; it is that this is the one place a backend can be exchanged without any consumer changing, and the one place a cfg(accelerator = ...) section has somewhere to apply. The rule package reads the toolkit's own host-compiler bound and selects a compiler that satisfies it, because mcpp's payload is routinely newer than what a given toolkit accepts. On the machine this was verified on the payload is gcc 16.1.0 and the toolkit refuses anything above gcc 12, so the rule selects clang++-14 and says so. Verified on an RTX 4080 with CUDA 12.0 and driver 550.144.03: mcpp run prints 12 24 36 48, which is the saxpy the kernel computed on the device. [package] accelerators is declared alongside, mirroring platforms: a statement of intent and a CI-matrix hint. It is deliberately a different field from an artifact's accel, because a declaration is written by hand and can be aspirational while the artifact field is measured from the build and is what a consumer is refused against. The example's README records the one hop that is not closed: [xlings] deps provisions a payload and puts its bin/ on PATH, which is what a payload providing a program needs, while a payload providing a library also needs its lib/ on the artifact's runtime search path. Until an mcpp-index package declares that directory for the driver sentinel, the run needs it supplied.
1 parent 3738506 commit 866909a

12 files changed

Lines changed: 512 additions & 0 deletions

File tree

examples/09-cuda-kernel/README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# 09 — A CUDA kernel behind a seam module
2+
3+
What this example demonstrates, and what it does not.
4+
5+
## The shape
6+
7+
```
8+
app/
9+
src/kernels/saxpy.cu the island: compiled by nvcc, never scanned, no BMI
10+
include/saxpy/saxpy.h the island's interface: extern "C", no std types
11+
src/app.cppm the seam: a module that turns the C interface back
12+
into a C++ one
13+
src/main.cpp an ordinary consumer, which imports the seam and
14+
never sees the header
15+
build.mcpp names the sources and the architectures
16+
rules-cuda/ a build-rule package that knows how to run nvcc
17+
```
18+
19+
Three properties are load-bearing.
20+
21+
**The island is not in the module graph.** No device compiler accepts C++20
22+
modules, so `.cu` is classified as a device translation unit: never scanned for
23+
imports, never producing a BMI. Its header is classified as a header, so
24+
editing one still invalidates the fast path.
25+
26+
**The island's interface is `extern "C"` and free of standard-library types.**
27+
nvcc drives a host compiler that mcpp did not choose, so the two sides do not
28+
share a C++ ABI and must not exchange anything that depends on one. The island
29+
also uses no standard library itself, which keeps it from linking a second copy
30+
of the C++ runtime into a program whose own copy came from mcpp's toolchain.
31+
32+
**The seam exists for backend substitution, not for the module boundary.** It
33+
is the one place where the island underneath could become HIP or a CPU
34+
fallback without any consumer of `app.saxpy` changing, and the one place a
35+
`cfg(accelerator = ...)` section has to apply. Remove it and every importer
36+
becomes backend-specific.
37+
38+
## The rule package, and why nvcc's host compiler is its problem
39+
40+
nvcc refuses host compilers newer than a bound it states in its own
41+
`crt/host_config.h`, and mcpp's toolchain payload is routinely newer than that
42+
bound. The rule reads the bound, selects a host compiler that satisfies it, and
43+
says which one it chose:
44+
45+
```
46+
example.rules.cuda: nvcc /usr/bin/nvcc with -ccbin /usr/bin/clang++-14
47+
```
48+
49+
On the machine this example was verified on, the toolkit is CUDA 12.0
50+
(`__GNUC__ > 12` is refused, clang must be below 15) and mcpp's payload is gcc
51+
16.1.0, so passing mcpp's own compiler through would fail. `mcpp self doctor`
52+
reports the same pairing independently.
53+
54+
Everything about nvcc's spelling lives in the rule package. The engine owns the
55+
graph, the artifact's identity and the architecture set; it does not own
56+
`-gencode`.
57+
58+
## Verified
59+
60+
On an NVIDIA RTX 4080 (compute capability 8.9) with CUDA 12.0 and driver
61+
550.144.03:
62+
63+
```
64+
$ mcpp run
65+
Running `target/.../bin/cuda-saxpy`
66+
12 24 36 48
67+
```
68+
69+
which is `2.0 * [1,2,3,4] + [10,20,30,40]` computed on the device.
70+
71+
## The one thing that is not closed yet
72+
73+
The CUDA runtime is linked statically, so the artifact carries every
74+
redistributable component. That leaves exactly one host dependency,
75+
`libcuda.so.1` — the driver's userspace library, which NVIDIA's licence forbids
76+
redistributing and which is in ABI lockstep with the kernel module.
77+
78+
xim already has the right shape for this: `libcuda-host-link` is a sentinel
79+
package that installs a symlink to whatever the host has, so every GPU consumer
80+
reads one path instead of reimplementing an `ldconfig` probe. This example
81+
declares it under `[xlings] deps`.
82+
83+
What is missing is the last hop. `[xlings] deps` provisions the payload and
84+
puts its `bin/` on `PATH`, which is what a payload providing a *program* needs.
85+
A payload providing a *library* also needs its `lib/` on the artifact's runtime
86+
search path, and mcpp's private loader does not consult `/usr/lib`, so without
87+
that hop the statically linked CUDA runtime cannot `dlopen` the driver and
88+
reports it as "driver version is insufficient". Until an mcpp-index package
89+
declares that directory, the run needs it supplied:
90+
91+
```
92+
$ LD_LIBRARY_PATH=$(xlings pkginfo libcuda-host-link)/lib mcpp run
93+
```
94+
95+
This is an ecosystem gap, not a defect in the design: the durable fix is a
96+
`compat.cuda-driver` package in mcpp-index that wraps the sentinel and declares
97+
its library directory, which is the same shape every other runtime provider in
98+
that index already has.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import std;
2+
import mcpp;
3+
import example.rules.cuda;
4+
5+
int main() {
6+
mcpp::rerun_if_changed_glob("src/kernels/**/*.cu");
7+
mcpp::rerun_if_changed_glob("include/**/*.h");
8+
9+
example::rules::cuda::options opt;
10+
// RTX 4080 is compute capability 8.9. Named rather than detected: what a
11+
// build compiles for is a decision, and a machine's own hardware is a poor
12+
// default for it — the artifact would run here and nowhere else.
13+
opt.archs = { "sm_89" };
14+
// Embed the portable form as well, so the same object runs on hardware
15+
// newer than this one.
16+
opt.ptx = "89";
17+
opt.includes = { "include" };
18+
const std::vector<std::string> sources{ "src/kernels/saxpy.cu" };
19+
return example::rules::cuda::compile(sources, opt) ? 0 : 1;
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// The device island's interface.
2+
//
3+
// `extern "C"` and free of standard-library types, on purpose. The island is
4+
// compiled by nvcc driving a host compiler that mcpp did not choose, so the two
5+
// sides do not share a C++ ABI and must not exchange anything that depends on
6+
// one. Keeping the boundary this narrow is also what lets the island publish a
7+
// C-surface compatibility tag.
8+
#ifndef MCPP_EXAMPLE_SAXPY_H
9+
#define MCPP_EXAMPLE_SAXPY_H
10+
11+
#ifdef __cplusplus
12+
extern "C" {
13+
#endif
14+
15+
// out[i] = a * x[i] + y[i], computed on the device. Returns 0 on success.
16+
int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n);
17+
18+
#ifdef __cplusplus
19+
}
20+
#endif
21+
#endif
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[package]
2+
name = "cuda-saxpy"
3+
namespace = "example"
4+
version = "0.1.0"
5+
description = "A CUDA kernel behind a seam module"
6+
accelerators = ["cuda"]
7+
8+
[language]
9+
standard = "c++23"
10+
modules = true
11+
import_std = true
12+
13+
[dependencies]
14+
rules-cuda = { path = "../rules-cuda", host-module = true }
15+
16+
# The driver's userspace library, reached through the sentinel package.
17+
#
18+
# It is the one CUDA component that cannot be an ordinary payload: NVIDIA's
19+
# driver licence forbids redistributing it, and it is in ABI lockstep with the
20+
# kernel module, so a version of it is meaningless outside the machine it came
21+
# from. The sentinel installs a symlink to whatever the host has, which gives
22+
# mcpp a path it can put on the artifact's runtime search path — mcpp's private
23+
# loader does not consult /usr/lib, so without this the statically linked CUDA
24+
# runtime cannot dlopen the driver and reports it as missing.
25+
[xlings]
26+
deps = [{ linux = "libcuda-host-link" }]
27+
28+
[build]
29+
# The CUDA runtime is linked STATICALLY. mcpp refuses a dynamic link against
30+
# the host's libcudart because its private loader does not consult /usr/lib,
31+
# and it is right to: such an artifact is not self-contained. Linking the
32+
# redistributable half in leaves exactly one host dependency, libcuda.so.1,
33+
# which is the driver and genuinely cannot be redistributed — that is what the
34+
# libcuda-host-link sentinel package in xim exists for.
35+
accel = "cuda12.0+{sm_89} ptx>=89"
36+
include_dirs = ["include"]
37+
ldflags = ["-L/usr/local/cuda/lib64", "-L/usr/lib/x86_64-linux-gnu",
38+
"-lcudart_static", "-lrt", "-lpthread", "-ldl"]
39+
40+
[targets.cuda-saxpy]
41+
kind = "bin"
42+
main = "src/main.cpp"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// The seam.
2+
//
3+
// Its reason for existing is not that nvcc rejects modules. It is that this is
4+
// the one place a backend can be exchanged: the island underneath can become
5+
// HIP or a CPU fallback without a single consumer of this module changing, and
6+
// a `cfg(accelerator = ...)` section has somewhere to apply. Remove the seam
7+
// and every importer becomes backend-specific.
8+
module;
9+
#include "saxpy/saxpy.h"
10+
export module app.saxpy;
11+
import std;
12+
13+
export namespace app {
14+
15+
// The device interface is raw pointers and a count because it has to be. The
16+
// seam is where that becomes a C++ interface again.
17+
std::optional<std::vector<float>>
18+
saxpy(float a, std::span<const float> x, std::span<const float> y) {
19+
if (x.size() != y.size()) return std::nullopt;
20+
std::vector<float> out(x.size());
21+
if (saxpy_device(a, x.data(), y.data(), out.data(),
22+
static_cast<unsigned>(x.size())) != 0)
23+
return std::nullopt;
24+
return out;
25+
}
26+
27+
} // namespace app
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// The island. Nothing here is visible to the module graph: nvcc does not
2+
// accept C++20 modules, so this translation unit is never scanned and never
3+
// produces a BMI.
4+
//
5+
// It uses no C++ standard library. That is a deliberate property rather than
6+
// an accident of a small example: an island that pulls in libstdc++ links a
7+
// second copy of the C++ runtime into a program whose own copy came from
8+
// mcpp's toolchain, which is the failure where one is linked and the other is
9+
// loaded.
10+
#include "saxpy/saxpy.h"
11+
#include <cuda_runtime.h>
12+
#include <cstdio>
13+
14+
namespace {
15+
16+
__global__ void saxpy_kernel(float a, const float* x, const float* y,
17+
float* out, unsigned n) {
18+
unsigned i = blockIdx.x * blockDim.x + threadIdx.x;
19+
if (i < n) out[i] = a * x[i] + y[i];
20+
}
21+
22+
} // namespace
23+
24+
extern "C" int saxpy_device(float a, const float* x, const float* y,
25+
float* out, unsigned n) {
26+
float *dx = nullptr, *dy = nullptr, *dout = nullptr;
27+
const size_t bytes = static_cast<size_t>(n) * sizeof(float);
28+
int rc = -1;
29+
30+
if (cudaError_t e = cudaMalloc(&dx, bytes); e != cudaSuccess) {
31+
std::fprintf(stderr, "cudaMalloc: %s\n", cudaGetErrorString(e));
32+
goto done;
33+
}
34+
if (cudaMalloc(&dy, bytes) != cudaSuccess) goto done;
35+
if (cudaMalloc(&dout, bytes) != cudaSuccess) goto done;
36+
if (cudaMemcpy(dx, x, bytes, cudaMemcpyHostToDevice) != cudaSuccess) goto done;
37+
if (cudaMemcpy(dy, y, bytes, cudaMemcpyHostToDevice) != cudaSuccess) goto done;
38+
39+
saxpy_kernel<<<(n + 255) / 256, 256>>>(a, dx, dy, dout, n);
40+
// The launch is asynchronous, so its own return value reports only whether
41+
// the launch was accepted. A kernel compiled for an architecture this
42+
// device does not have fails HERE, with `no kernel image is available for
43+
// execution on the device` — which is the runtime failure the accelerator
44+
// dimension of an artifact's identity exists to turn into a build-time one.
45+
if (cudaError_t e = cudaGetLastError(); e != cudaSuccess) {
46+
std::fprintf(stderr, "launch: %s\n", cudaGetErrorString(e));
47+
goto done;
48+
}
49+
if (cudaDeviceSynchronize() != cudaSuccess) goto done;
50+
if (cudaMemcpy(out, dout, bytes, cudaMemcpyDeviceToHost) != cudaSuccess) goto done;
51+
rc = 0;
52+
53+
done:
54+
cudaFree(dx); cudaFree(dy); cudaFree(dout);
55+
return rc;
56+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import std;
2+
import app.saxpy;
3+
4+
int main() {
5+
const std::vector<float> x{1, 2, 3, 4}, y{10, 20, 30, 40};
6+
auto out = app::saxpy(2.0f, x, y);
7+
if (!out) { std::println("device unavailable"); return 1; }
8+
for (auto v : *out) std::print("{} ", v);
9+
std::println("");
10+
const std::vector<float> want{12, 24, 36, 48};
11+
return *out == want ? 0 : 1;
12+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[package]
2+
name = "rules-cuda"
3+
namespace = "example"
4+
version = "0.1.0"
5+
description = "Compile CUDA device translation units with nvcc (role = object)"
6+
license = "Apache-2.0"

0 commit comments

Comments
 (0)