feat: accelerator support — device translation units, the accelerator dimension of artifact identity, and the host-compiler pairing - #559
Merged
Conversation
A device translation unit is compiled by a vendor device compiler that mcpp does not drive directly, and no such compiler accepts C++20 modules. The new kind states the graph role — never scanned for imports, never produces a BMI — and says nothing about the language, which is what lets one kind cover CUDA C++, HIP, and device dialects that are not C++ at all. Device headers are classified as headers rather than device units because their role is the header role: they are not compiled, and editing one can change what the graph should be. Leaving them in Other is how a project would observe an edited kernel header producing no rebuild. The default source glob is deliberately unchanged. Widening it would make a published package that vendors a device source it builds elsewhere start compiling it on the next mcpp upgrade, which its author cannot fix because that version's tarball has already shipped.
One build can enable several accelerator backends at once, which is what an inference framework shipping CUDA and ROCm device code in one artifact requires, so this layer holds a set rather than the single answer the other five hold. The comparison is membership, and it is membership everywhere rather than only inside any(...). Letting a combinator change the meaning of its operand would make all(accelerator = "cuda", accelerator = "rocm") unsatisfiable rather than "both backends are enabled". Membership everywhere keeps any, all and not pure boolean combinators, and a single-backend build still answers its own backend true and every other backend false. The multi-valued case differs in exactly one function, Ctx::layer_matches, so adding a second such layer later is a row rather than a second code path.
The open-source intel/llvm DPC++ compiler is clang: one driver, GNU-style flags, the same BMI format. Its NVIDIA and AMD backends are configured into the compiler itself through buildbot/configure.py --cuda and --hip, so the Codeplay plugins the v4 text referred to are a property of Intel's binary distribution rather than of DPC++. It therefore attaches as a payload variant of the existing LLVM family plus a provider capability, and requires no new value in the closed Family enumeration. nvc++ attaches as a device rule compiler rather than a toolchain family. The HPC SDK user guide states C++17 with GNU compatibility as its standard, and a compiler without C++20 modules cannot host projects that import std.
… already provides The governing rule needs no accelerator-specific extension. A toolchain is mcpp's contract and must come from a payload; a library the program links is the program's own business. Applied here, nvcc and the CUDA runtime libraries are payload material because NVIDIA's redistributable manifests permit it, while libcuda.so.1 cannot be: the driver EULA forbids third-party redistribution and the userspace library is in ABI lockstep with the kernel module. The lower half of that axis already exists in xim as libcuda-host-link, a sentinel package that installs one symlink to the host library and gives every GPU consumer a single source of truth instead of a reimplemented ldconfig probe. The upper half does not: there is no CUDA toolchain package. Compiling needs only the nvcc and cudart components rather than the whole toolkit, which is the same per-component selection the hermetic Bazel CUDA rules make.
An artifact that carries device code constrains which devices can run it, and until now nothing recorded that. Two builds differing only in the compute capability they targeted produced identical tags, linked cleanly, and failed at the first kernel launch. The dimension is a parallel structured field rather than a tag segment. The tag is dash-joined and parsed from the end, and an architecture list is a set; joining it in would put separators inside a segment of a string whose triple already carries a variable number of dashes. tag_check compares it all the same, so there is one comparator and two storage locations rather than a second comparator. Comparison is membership widened two ways, each matching a mechanism that exists in the hardware rather than a convenience. A family target covers its own major with an equal-or-higher minor, which is what makes publishing one artifact per generation viable instead of one per chip. An embedded portable form covers everything at or above its floor, which is how NVIDIA's own guidance for a distributed binary works. AMD has neither a portable form nor a reason for one, leaves the floor empty, and obtains the same reach through family targets on the architecture side; an empty floor therefore widens nothing.
…of tabulating it nvcc refuses host compilers newer than a bound that moves with every CUDA release, and the bound is not documentation: it is a preprocessor guard in the toolkit's own crt/host_config.h. A table transcribed into mcpp would be a copy of one release, correct until the next and silently wrong afterwards. Reading the guard means a toolkit mcpp has never heard of still answers. mcpp supplies the host compiler, so it is in a position to know both sides of this pairing before either compiler runs. The value is the earlier refusal: forwarding -ccbin and letting nvcc fail produces a diagnostic naming a compiler the user did not choose and a bound they cannot see. The parse is narrow on purpose. A header it cannot make sense of yields no bound, and no bound means no check, because a refusal invented from an unreadable file is worse than the failure it would have prevented. Verified against the CUDA 12.0 header on the development machine: the guard reads __GNUC__ > 12 and the clang bound is stated as less than 15, which the parse reports as 12 and 14.
…toolkit
nvcc refuses host compilers newer than a bound the toolkit states in its own
crt/host_config.h. The failure is late, the message names a compiler the user
did not choose, and the bound is invisible. Every other build system forwards
-ccbin and lets nvcc discover this; mcpp supplies the host compiler and can
answer before anything is compiled.
Reported rather than enforced. A project that compiles no device code is
unaffected by an incompatible pair, and refusing its build would be a false
alarm.
Observed on the development machine, where the toolkit is CUDA 12.0 and the
resolved host compiler is gcc 13:
Checking device toolkit
warning: cuda will refuse this host compiler: gcc 13 exceeds the bound of
12 stated in /usr/include/crt/host_config.h.
An engine capability with no packages behind it does not exist for a developer. The design and the plan now name what has to land in xim and mcpp-index, in dependency order, with what each package validates. Two entries carry most of the weight. AdaptiveCpp comes first among the payloads because its OpenMP backend runs SYCL kernels on the CPU, which makes the whole of form B verifiable end to end on a machine with no GPU. llama.cpp-m comes first among the libraries because one package exercises three of the seven user roles at once, it has no Python packaging layer to confuse the result, and it already exists in the ecosystem, so the step is adding a backend rather than adopting a large project. The two header-only libraries, CCCL and CUTLASS, are listed before anything with a binary artifact: they carry no size or variant-matrix problem and are therefore the first real libraries reachable by the source-distribution path.
The comparison added earlier had no reader: nothing populated the field, so it was empty everywhere and accepted everything. This connects both ends. An artifact declares what device code it carries in a separate descriptor field, in one wire form that is also the diagnostic form, so what a publisher writes is what a refusal prints back. A build declares what it targets through [build] accel, overridden for one build by --accel, which is the relationship --target already has with [toolchain]. --no-accel is not the absence of --accel: it is an explicit request for none, which is what a user needs in order to take the CPU-only variant of a package that also publishes device builds. A build that asks for no accelerator is satisfied by every artifact vacuously. That is correct rather than permissive, and it is why a descriptor lists its CPU-only variant first: the first accepted artifact wins, so an older mcpp that does not know this field at all still selects the variant that runs anywhere.
…target
This is the failure the whole dimension exists to move. A library built for one
compute capability and consumed by a build targeting another linked cleanly and
failed at the first kernel launch, with a runtime message that names neither
the package nor the architecture either side expected.
The refusal now names both, and the remedy is the one that can work. The
generic advice to pin [toolchain] is actively misleading on this axis, because
no toolchain pin changes which GPU architecture a build targets; the device
case offers --accel and --no-accel instead. The published listing carries the
dimension too, since it travels beside the tag rather than inside it and a
listing showing only tags would print two indistinguishable lines for a package
publishing a CPU and a device build of one triple.
Observed:
error: mcpplibs.gpuonly@0.1.0: no prebuilt artifact matches this toolchain.
your toolchain : x86_64-linux-gnu-gcc16-libstdcxx16-c++23 accel=cuda12.8+{sm_86}
published tags :
x86_64-linux-gnu accel=cuda12.8+{sm_90f}
closest is x86_64-linux-gnu, and it differs on:
accel needs cuda12.8+{sm_90f}, this build has cuda12.8+{sm_86}
The accompanying end-to-end test also covers the ordering rule: a descriptor
listing its CPU-only artifact first lets a build targeting an architecture no
device variant carries fall back to the portable one, which is the same
ordering that keeps an mcpp predating this field selecting a usable artifact.
…ration 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.
Chapter 20 states the two shapes an accelerator toolchain takes, says which one mcpp implements, and gives the reasoning for the three decisions a reader will otherwise find arbitrary: why device sources are excluded from the default glob, why the seam module is an architectural unit rather than a workaround for nvcc's lack of module support, and why the accelerator dimension travels beside the compatibility tag instead of inside it. The manifest reference gains [build] accel and [package] accelerators, kept apart on purpose: one is measured from a build, the other is written by hand.
…enforced Five design statements did not survive contact with the code. The cfg semantics were wrong in a way that made the design more complex than it needed to be: letting any(...) change the meaning of its operand would have made a conjunction of two backends unsatisfiable rather than "both are enabled", and membership everywhere is both correct and one rule fewer for a user to learn. The compatibility risk was real but on a different path than the one checked. Form B needs no new toolchain family. nvc++ belongs to form A. And the host compiler bound should be read from the vendor's header rather than tabulated. The ecosystem refused three shortcuts on its own: mcpp's runtime closure check rejected linking the host CUDA runtime, allow_host_libs was not enough to get past it, and the index already documented that [xlings] deps materialises for the root project only. Every one of those constraints was already there and every one was right. Two mistakes were mine and both are shapes this document warns about. A first draft of the index package reimplemented a host probe that the sentinel's own recipe forbids reimplementing, and which xim's hostlib module records as having been got wrong three times out of four. The same draft carried an extra library pattern justified by a reason written from memory; measurement showed PTX JIT works without it.
… var The example previously needed LD_LIBRARY_PATH pointing at the xim sentinel, because nothing declared a runtime search directory for the driver's userspace library. compat.cuda-runtime now does, so the dependency is an ordinary one and mcpp run works unaided. The two packages divide the question by layer. libcuda-host-link in xim owns where the host's copy is, so no consumer reimplements an ldconfig probe. compat.cuda-runtime in mcpp-index owns how a built artifact reaches it, which needs a directory on the runtime search path and is therefore something only an mcpp package can declare. Verified end to end on an RTX 4080: mcpp run prints 12 24 36 48 with no environment variables set.
The design states that the ordering between repositories is hard. This records what walking it actually cost. The index package used only keys the current floor supports, so it did not have to wait for the engine to ship and could land independently. What it did have to wait for was not the merge but the artifact publish: xlings consumes the index as artifact:<sha>, so there is a workflow between "merged" and "resolvable". The example's dependency resolves only after that workflow is green on the merged commit. What remains genuinely blocked is the other direction: an index package that uses one of the new engine keys cannot land until the engine ships. This one was lucky in needing none of them.
…s gaps --accel is a build option alongside --static and --toolchain, not repeated on run, test or pack. Those read the manifest key like any other build input. mcpp pack does not emit the accel field, and the reason is the point rather than an omission. The field states what an artifact carries, and mcpp does not yet compile device code itself, so it has nothing to measure. Writing the declared value into a field whose meaning is measured would make the identity lie in precisely the way the dimension exists to prevent.
Section 14.1 recorded that the original three-line semantics were overturned during implementation, while section 3.4 still stated them. A correction that lives only in a later section is the shape where one layer covers another and a reader takes the first one they reach. The implemented rule is one line: membership, everywhere. The combinators do not change the meaning of their operand, which is what makes a conjunction of two backends mean "both are enabled" rather than being unsatisfiable.
Two of the decisions taken at design time did not land, and a staged plan that does not say so lets a reader assume otherwise. Decision 4 turned out not to be implementable as stated. Refusing whenever one capability has several providers in the graph would also refuse the OpenBLAS and MKL case, which the manual documents as normal use; refusing only when their symbols overlap requires reading object files that do not exist yet at binding time. What is missing is a declaration that a capability's providers are mutually exclusive, which only the capability's definer knows. That is a new manifest key and a design decision rather than an implementation, so it is recorded as such instead of being quietly skipped. Decision 3 depends on the device-target primitive, and this round compiles device code through a build-rule package, which the staged plan already placed before that primitive. The completion table states the evidence for every stage that did ship.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Accelerator support: device translation units, the accelerator dimension of a
prebuilt artifact's identity, and the host-compiler pairing no other build
system reports.
Design:
.agents/docs/2026-09-05-accelerator-support-design.mdSurvey the design argues from:
.agents/docs/2026-09-04-ai-accelerator-toolchain-ecosystem-survey.mdThe failure this moves
A library built for one compute capability and consumed by a build targeting
another links cleanly and fails at the first kernel launch, with a runtime
message naming neither the package nor the architecture either side expected.
Nothing in the C++ build ecosystem records the architecture a binary was
compiled for as part of that binary's identity — this is a gap across the whole
category, not a mcpp-specific one.
What lands
SourceKind::Device..cuand.hipare device translation units: neverscanned for imports, never producing a BMI, because no device compiler accepts
C++20 modules.
.cuhand.hiphare headers, so editing one still invalidatesthe fast path. Device extensions are deliberately absent from the default
source glob, on the argument that already keeps the built-in module-extension
table at
.cppm: widening it makes a published package that vendors a devicesource start compiling it on the next upgrade, which its author cannot fix.
The accelerator dimension of an artifact's identity. An artifact records
what device code it carries beside its compatibility tag, and
tag_checkcompares it. Membership is widened two ways, each matching a mechanism in the
hardware rather than a convenience: a family target covers its own major with
an equal-or-higher minor, and an embedded portable form covers everything at or
above its floor. AMD has neither a portable form nor a reason for one and
obtains the same reach through family targets instead, so an empty floor widens
nothing.
The field is separate from the tag rather than a segment of it because an
architecture list is a set and the tag is dash-joined over a triple that
already carries a variable number of dashes. One comparator, two storage
locations.
acceleratoras a multi-valued cfg layer. One build can enable severalbackends at once, which an inference framework shipping CUDA and ROCm in one
artifact requires. The comparison is membership everywhere rather than only
inside
any(...): letting a combinator change the meaning of its operand wouldmake
all(accelerator = "cuda", accelerator = "rocm")unsatisfiable rather than"both are enabled".
The host-compiler bound, read rather than tabulated. nvcc refuses host
compilers newer than a bound it states in its own
crt/host_config.h, andmcpp's payload is routinely newer. Because mcpp supplies the host compiler it
can answer before anything is compiled. The bound is read from the toolkit, so
one mcpp has never seen still answers; a header it cannot parse yields no bound
and therefore no claim.
[build] accel/--accel/--no-accel, and[package] accelerators.The first three stand to each other as
[toolchain]and--targetalready do.--no-accelis an explicit request for none, which is how a CPU-only variantof a package that also publishes device builds gets selected.
[package] acceleratorsmirrorsplatformsand is deliberately a differentfield from an artifact's
accel: a declaration is written by hand, anartifact's field is measured from the build.
Verified on hardware
An RTX 4080 (compute capability 8.9), CUDA 12.0, driver 550.144.03.
examples/09-cuda-kernelbuilds a device island through a build-rule packageand runs it:
The rule package reads the toolkit's bound and selects a host compiler that
satisfies it, because mcpp's payload (gcc 16.1.0) exceeds what CUDA 12.0
accepts (gcc 12):
mcpp self doctorreports the same pairing independently:And the refusal the dimension exists to produce:
The generic advice to pin
[toolchain]is withheld here because it ismisleading on this axis: no toolchain pin changes which GPU architecture a
build targets.
Compatibility
Additive throughout. A project without device sources is unaffected; the
default source glob does not change; an absent
accelconstrains nothing, soexisting artifacts are accepted exactly as before.
One asymmetry is worth stating. The published xpkg descriptor's reader collects
and skips an unrecognised key, so an older mcpp reading a descriptor carrying
accelignores it. The packedmcpp.tomlreader uses a closed whitelist andrefuses one, so adding the key there is a compatibility event — and the reason
a descriptor should list its CPU-only artifact first: the first accepted
artifact wins, so a client that ignores the field still selects one that runs
anywhere.
Not in this change
The whole-target shape (SYCL, OpenMP offload, stdpar), device linking for
relocatable device code, static libraries containing device code, and
accelerator payloads supplied through xim. Each is staged in the design with
its own criterion; none is blocked by anything here.
Tests
100 unit tests pass. New coverage: device classification and the default-glob
exclusion; the accelerator comparison including family targets, portable-form
floors, toolkit major versions and multi-backend artifacts; the wire form's
round trip; the cfg layer's membership semantics and combinator purity; the
host-config parse against the CUDA 12.0 header verbatim.
tests/e2e/600coversvariant selection, the refusal's content, and the CPU-first ordering rule.