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 |
|---|---|---|---|
boundary/ |
none — a C island | none | the boundary alone: a generated module the consumer imports, with no seam and no header anywhere in the project |
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 boundary/, which needs no device and isolates the interface
between an island and the C++ side. Then cuda/, which adds the device compiler
and a seam over that boundary. The four beside cuda/ assume it, and
multi-backend/ assumes two of them.
boundary/ and the five model directories 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
each of the five model directories — turns that C interface back into a C++ one.
boundary/ is the one that stops before this step, so the two can be read side
by side. 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: every island and every CPU fallback here produces 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 boundary, generated or written. The extern "C" declarations under the
seam are each entry point's signature stated a second time, at the one place a
disagreement is invisible: C language linkage does not mangle, and an island and
its host fallback are never in one link. mcpp.tools.island reads the marked
declarations out of both implementations and writes that header and a module
over it, so the signatures exist once. boundary/, cuda/ and sycl/ take
that route; hip/, vulkan/ and cann/ keep the header written by hand, so
the two can be read side by side. boundary/ states what each
rung costs.
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.4.0", features = ["rules-cuda", "tools-island"], 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/04-mcpp-toml.md.
A device build reaches hardware through four layers, and each of the model directories 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/42-heterogeneous-builds.md
states the distinction and why it is a property of the model rather than a gap
in the tool.