Skip to content

feat: accelerator support — device translation units, the accelerator dimension of artifact identity, and the host-compiler pairing - #559

Merged
Sunrisepeak merged 19 commits into
mainfrom
feat/accelerator-support
Sep 4, 2026
Merged

feat: accelerator support — device translation units, the accelerator dimension of artifact identity, and the host-compiler pairing#559
Sunrisepeak merged 19 commits into
mainfrom
feat/accelerator-support

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

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.md
Survey the design argues from: .agents/docs/2026-09-04-ai-accelerator-toolchain-ecosystem-survey.md

The 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. .cu and .hip are device translation units: never
scanned for imports, never producing a BMI, because no device compiler accepts
C++20 modules. .cuh and .hiph are headers, so editing one still invalidates
the 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 device
source 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_check
compares 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.

accelerator as a multi-valued cfg layer. One build can enable several
backends 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 would
make 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, and
mcpp'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 --target already do.
--no-accel is an explicit request for none, which is how a CPU-only variant
of a package that also publishes device builds gets selected.
[package] accelerators mirrors platforms and is deliberately a different
field from an artifact's accel: a declaration is written by hand, an
artifact'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-kernel builds a device island through a build-rule package
and runs it:

$ mcpp run
     Running `target/.../bin/cuda-saxpy`
12 24 36 48

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):

example.rules.cuda: nvcc /usr/bin/nvcc with -ccbin /usr/bin/clang++-14

mcpp self doctor reports the same pairing independently:

    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.

And the refusal the dimension exists to produce:

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}
  fix: build for an architecture the package carries (--accel), or take
       a variant that carries no device code (--no-accel), or ask the
       publisher for one covering yours.

The generic advice to pin [toolchain] is withheld here because it is
misleading 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 accel constrains nothing, so
existing 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
accel ignores it. The packed mcpp.toml reader uses a closed whitelist and
refuses 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/600 covers
variant selection, the refusal's content, and the CPU-first ordering rule.

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.
@Sunrisepeak
Sunrisepeak merged commit 960f5b4 into main Sep 4, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants