Each subdirectory computes 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 several lessons. They are one structure
instantiated several 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 |
cann/ |
Ascend C | the toolkit's BiSheng (-x asc) |
a device object for hardware nobody in this repository has, and a host half that declines cleanly |
multi-backend/ |
CUDA and Vulkan | both of the above | the other shape: backends that are additive rather than a seam, chosen at run time |
Start with cuda/. The four beside it assume it, and multi-backend/ assumes
two of them.
The first five are one shape — a seam: exactly one implementation exists in
the artifact and the choice was made at build time. multi-backend/ is the
other — several implementations in one artifact, chosen when the program runs.
A program can take either; a library that is compiled once and consumed by
people whose machines differ can only take the second.
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, which brings its own environment. Every vendor spelling —
--cuda-gpu-arch, -gencode, --target-env, -fsycl-targets, -fsycl-link,
--cce-aicore-arch — 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/.
The rule also declares the payloads it needs, under the feature that
selects it and the accelerator it serves, so a project writes one edge and no
[xlings.workspace] block:
[build-dependencies.mcpp]
plugins = { version = "0.2.4", features = ["rules-cuda"], host-module = true }multi-backend/ is the one example here that also pins a version, and it does
so to demonstrate the override: the rule owns "which package, and no older than
what", the project owns "and exactly this one". One version is installed either
way — see One package, one version in docs/05-mcpp-toml.md.
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 rule and overridable 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. Which package and how old it may be belongs to
the rule; exactly which version is the project's to override and nobody's to
discover from the machine. No example here contains an absolute path or reads
the host's toolkit, and only multi-backend/ names a payload version at all.
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.