Four subdirectories compute the same thing — 2.0 * [1,2,3,4] + [10,20,30,40],
printed as 12 24 36 48 — on a device, and the same thing on the CPU when
there is no device. They are not four lessons. They are one structure
instantiated four times, and reading any two of them shows which parts belong
to the structure and which to a vendor.
| directory | model | device compiler | what it adds to the structure |
|---|---|---|---|
cuda/ |
CUDA | the project's own clang (-x cuda), or nvcc |
the driver relation stated as a fact and a floor, and two pairings nvcc cannot have |
vulkan/ |
Vulkan compute | glslang or shaderc, to SPIR-V | a device output that is a header rather than an object, and one artifact that runs on three devices |
sycl/ |
SYCL | the dpcpp payload's clang | a second compiler with its own standard library, and a chained action for the device link |
hip/ |
HIP | the project's own clang, NVIDIA platform | a programming model that is a header layer over another model's runtime |
Start with cuda/. The other three assume it.
An island. The device translation units are compiled by a compiler that
does not accept C++20 modules, so the engine classifies them as device sources:
never scanned for imports, never producing a BMI, never offered to the C++
compiler. SourceKind::Device is a role in the graph, and the criterion for it
is which compiler consumes the file, not which dialect the file is written in.
A seam. The island's interface is extern "C" and free of standard-library
types, because the two sides do not share a C++ ABI. A module — app.saxpy in
all four — turns that C interface back into a C++ one. The seam is the single
place where the implementation underneath becomes a different model or a CPU
loop, and the single place a cfg(accelerator = ...) section has to apply.
Without it, every importer would be backend-specific.
The seam carries one more entry point, saxpy_device_name(), for a reason
worth stating: all four islands and all four CPU fallbacks produce the same
four numbers. Without a name in the output, a run that silently fell back to
the CPU is indistinguishable from a run on a device -- in a set of examples
whose whole subject is which device ran the computation. Each backend fills it
in with the device it used, the CPU file fills it in with cpu, and both do so
only after a successful call: a device run that did not happen has no device to
report. main prints it after the result, never before.
A constrained glob. The device axis is written once, in the manifest, and the source set follows it:
[build]
accel = "cuda12.9+{sm_89} ptx>=89"
sources = [
"src/*.cppm",
"src/*.cpp",
{ glob = "src/kernels/**/*.cu", accel = "cuda12.9+{sm_89}" },
]
[target.'cfg(not(accelerator = "cuda"))'.build]
sources = ["src/cpu/*.cpp"]mcpp build compiles the device unit; mcpp build --no-accel compiles the CPU
file instead. Neither is excluded by a hand-written condition: the glob states
the constraint, and an --accel that does not cover it is refused before
anything is compiled. The two variants land in different artifact directories
because the device axis is part of the build's identity, so alternating between
them does not rebuild from scratch.
A rule package. Every vendor spelling — --cuda-gpu-arch, -gencode,
--target-env, -fsycl-targets, -fsycl-link — lives in mcpp:plugins, a
package the project depends on and selects features from. The engine owns the
graph, the artifact's identity and the accelerator axis; it holds no vendor
name. A unit test refuses vendor probes in src/.
A device build reaches hardware through four layers, and each of the four examples uses all of them. Confusing two of them is the most common way a working build stops working on another machine.
| layer | owns | example |
|---|---|---|
| engine | the graph, the identity, the axis | mcpp itself |
| rule package | the spelling of one model | mcpp.rules.cuda in mcpp:plugins |
| payload | the binaries, versioned by the project | xim:cuda-nvcc, xim:dpcpp, xim:glslang |
| adapter | a built artifact's reach to something the host owns | compat:cuda-driver, compat:vulkan-runtime, compat:sycl-runtime |
The adapter layer exists for one reason. An mcpp-built program runs under
mcpp's own loader, which does not consult /usr/lib, so a bare-soname dlopen
from inside the program finds nothing. Anything the host must supply — the
NVIDIA driver, a Vulkan ICD — is reached by an index package that puts a
directory on the artifact's runtime search path. A project declares it as an
ordinary dependency and does not otherwise think about it.
Everything else is a payload, so the version is the project's choice rather than the machine's, and no example here contains an absolute path or reads the host's toolkit.
Each subdirectory's app/ is a project:
cd examples/09-heterogeneous/cuda/app
mcpp run # on the device
mcpp run --no-accel # the same numbers, on the CPU
The CPU variant needs no device and no driver, and is what a machine without
hardware runs. vulkan/ additionally runs on a CPU device — Mesa's lavapipe,
delivered as a payload — which is a third case and is the one CI exercises.
The examples show the shape mcpp implements: device code in separate
translation units, compiled by a separate compiler, linked into an ordinary
program. Models that compile a whole target with an offloading compiler —
OpenMP target, C++ parallel algorithms with a device backend — have no
separable island and are not reached this way. docs/20-heterogeneous-builds.md
states the distinction and why it is a property of the model rather than a gap
in the tool.