feat(accel): version floors, a probe channel, constrained device globs, and a CUDA example that takes two routes - #563
Merged
Merged
Conversation
… and state the driver relation without probing for it
## The report was answering about the wrong toolkit
`mcpp self doctor` located `crt/host_config.h` through `CUDA_PATH`, `CUDA_HOME`,
`/usr/local/cuda` and `/usr/include` — host locations only. A toolkit installed
through xlings is the one a build will use, and it is usually the newer one:
measured here, a 12.9 payload states `gcc <= 14` and a 13.3 payload `gcc <= 15`,
where this machine's distribution CUDA 12.0 states `gcc <= 12`. The report said
`gcc 13 exceeds the bound of 12` about a toolkit the build was not using.
Payload stores are now searched first. Both of them: mcpp keeps its own under
`<mcpp home>/registry/data/xpkgs`, and `xlings install` writes to
`<xlings home>/data/xpkgs` — 191 packages in one and 211 in the other on this
machine, with the CUDA components only in the second. The host locations remain,
last, because a machine with a distribution toolkit and no payload is real.
Reading changes accordingly:
before warning: cuda will refuse this host compiler: gcc 13 exceeds
the bound of 12 stated in /usr/include/crt/host_config.h
after ok cuda accepts this host compiler (gcc 13 <= 15)
## The driver relation, and why the acquisition is not here
A device runtime must not be newer than the driver it runs against. Measured on
a driver serving CUDA 12.4: the 13.3 payload compiles and links cleanly and then
fails at the first allocation with "CUDA driver version is insufficient for CUDA
runtime version", while the 12.9 payload prints the right answer.
`mcpp::toolchain::driver_accepts_toolkit` states when one version may meet
another, including that minor-version compatibility makes 12.9 fine against a
driver serving 12.4 — the case a naive "toolkit <= driver" check would have
refused. Five unit tests, including that either side unknown makes no claim.
The acquisition is NOT here. Asking a machine which driver it has means running
a vendor's tool, and `tests/unit/test_runtime_contract.cpp` refuses exactly that
in `src/`. It caught the first revision of this change, which launched one. The
rule predates this work and it is right: a core that learns to run one vendor's
probe learns to run four. Those numbers will reach the report as declarations —
a toolkit payload stating the driver it needs, and the package that owns the
host driver stating what the host has — which is the rule-package channel.
## Verified
`tests/e2e/602_device_toolkit_payload_first.sh` fabricates a payload store whose
header states `gcc <= 41`, a bound nothing real would state, and asserts the
report reads it. The control is the half that matters: without the payload store
the same command must not report 41, or the assertion would pass against a
doctor that hardcoded it.
100 test binaries pass, including the contract test that rejected the earlier
revision.
… from the host
The example shipped in 2026.9.5.1 wrote `-L/usr/local/cuda/lib64` in its
manifest and probed `/usr/local/cuda/bin/nvcc` and `/usr/include/crt/host_config.h`
in its rule package. It was the project's own demonstration of how to do this,
and what it demonstrated was reaching for the host.
The project now names the toolkit:
[xlings.workspace]
"xim:cuda-nvcc" = "12.9.86"
"xim:cuda-cudart" = "12.9.79"
and the rule resolves it with `mcpp::xpkg_dir`, building the whole invocation
from what it finds — compiler, include directories, and library search paths
through `mcpp::link_search`. The manifest names libraries and no locations.
Host paths remain in the rule as a last fallback so a machine with only a
distribution toolkit still builds.
⚠️ THE PAYLOAD'S HEADERS HAVE TO BE NAMED. nvcc adds
`<its own directory>/../include` by itself, and on the 12.x line that holds
`crt/` but not `cuda_runtime.h` — which is in the `cuda-cudart` component. The
first revision of this change left it out: nvcc resolved `cuda_runtime.h` from
/usr/include and then read the HOST's `crt/host_config.h` beside it, and the
build failed with the host toolkit's complaint (`clang version must be less than
15`) while using the payload's compiler. Naming the payload include directories
is what makes "uses the payload" true rather than nearly true.
Verified: `mcpp run` prints `12 24 36 48`, and
$ mcpp build -v | grep -c '/usr/local/cuda\|/usr/bin/nvcc|-I/usr/include'
0
nvcc and both include directories resolve under
`registry/data/xpkgs/xim-x-cuda-*`. 100 test binaries pass.
…nything is compiled
Some facts about a machine bound what may be built for it, and the failure when
they are ignored arrives late. Measured on a driver serving CUDA 12.4: a binary
built with the 13.3 toolkit compiles cleanly, links cleanly, and fails at the
first allocation with "CUDA driver version is insufficient for CUDA runtime
version" — a message naming neither the toolkit nor the driver.
Both numbers are knowable beforehand. What mcpp must not do is go and ask a
vendor's tool for them, and `tests/unit/test_runtime_contract.cpp` refuses
exactly that in `src/`. So the numbers arrive as declarations.
A package states what it needs:
[[runtime.requirements]]
kind = "version-floor"
value = "cuda.driver >= 12.0"
A package that established a fact about this machine — at install time, which
is where probing belongs — states it:
[runtime]
provides = ["cuda.driver=12.4"]
They are compared when capabilities are bound, and a short machine is refused
before anything is compiled, reporting `version-floor-unmet`.
## No vendor vocabulary reaches the engine
`src/build/version_floor.cppm` reads a name, a relation and a version and knows
nothing about what any of them mean. `cuda.driver` is data passing through, no
new manifest keys were needed — `kind` was already a free string — and a
backend mcpp has never heard of compares the same way. That is asserted with a
name no backend uses.
## A floor nobody answered is silent
A machine that never declared what it has is not a machine that fails the floor;
it is one nobody asked. Turning "we do not know" into "no" is the failure mode
this exists to avoid, so it is the third case in the e2e rather than a comment.
Without that control the first case would also pass against an engine that
refused every version-floor requirement.
## Verified
`tests/e2e/603_version_floor.sh`, three halves:
PASS: refused, naming the requirement, both versions and who stated the fact
PASS: a met floor builds
PASS: a floor with no stated fact is silent, for a name no backend uses
Eight unit tests over the parsing and the comparison, including that `>=` is
never read as `=` — both spellings live in string lists, and reading a floor as
a fact would turn a requirement into its own satisfaction. 101 test binaries
pass.
… report exists Reaching "five consecutive short failures" costs at least 1.25 s (four restart delays of 250 ms and five 50 ms polls). A two-second pause left 150 ms per spawn, and a loaded macOS runner exceeded it twice in a row on main while the same code passed twice on this branch. The pause is now five seconds, so the property is decided by the code. 602 declared no platform, and on Windows the doctor does not emit the device-toolkit section at all: the payloads are Linux builds and the bound a Windows toolkit states is an _MSC_VER range the report does not read. It also ran the doctor in a fresh home, which provisioned a full toolchain into the temporary directory: 229 s and 1.4 GB per run. It now requires unix-shell and runs under MCPP_OFFLINE.
--offline promises never to touch the network. In a home that had never been used, load_or_init cloned the package index through `xlings self init` and installed ninja and patchelf through `xlings install` before any command ran: measured, 26 s and 126 MB under MCPP_OFFLINE=1. The three network-bound steps are now skipped under offline mode, the skip is announced once per process, and the completion markers are still written from what is on disk, so a bootstrapped home loses nothing. The commands that need the tools report their absence through check_base_init. e2e 604 asserts the fresh-home behaviour and, as its control, that a bootstrapped home announces nothing.
…m sees it A rule package is the thing that knows how to ask a machine what it has, and the engine is the thing that must not. Two directives close that gap: `mcpp:fact=<name>=<version>` states what the program established about the machine, `mcpp:floor=<name> >= <version>` what the package needs of it. Both fold into the runtime declarations a manifest could have carried itself, so the existing version-floor check reads one list; it now runs a second time after the root's build program, which is where a rule package speaks from. Protocol 7. MCPP_ACCEL carries the resolved device axis (`--accel` / `--no-accel` over `[build] accel`) to build programs, so an architecture set is written once, in the manifest. The same value now feeds the `cfg(accelerator = "...")` layer key, a field that was declared, documented and never written. The axis also enters the fingerprint and disqualifies the fast path: measured before, `mcpp build --no-accel` after a device build finished in 0.00 s and handed back the device build.
…ld narrows to it
`[build] sources` accepts `{ glob = "...", accel = "..." }` entries. The glob
joins the plain list, so every reader keeps working; the constraint is
resolved in prepare after feature application. A constrained glob that
matches nothing is refused, naming it. Under a build that asks for no
accelerator the glob is excluded through the same `!` mechanism feature gates
use, which is how one project yields its CPU-only variant. Under a build
that targets something the constraint is not within, the build is refused
naming the glob and both sides (`accel-mismatch`). Device-kind files the
effective set matches reach the build program as MCPP_DEVICE_SOURCES; the
engine has no compile rule for them and the rule package turns each into an
action. Unit tests cover the table form and its refusals; e2e 606 measures
the four outcomes with a backend nothing knows.
… and test
Three gaps a rule package that drives a compiler mcpp did not resolve fell
into, each measured on the CUDA example.
**The flags mcpp passes to its own compiler.** `MCPP_TOOLCHAIN_SYSROOT` and
`MCPP_TOOLCHAIN_BINUTILS_DIR` state the `--sysroot` and the `-B` directory,
empty when mcpp passes none. They are not `MCPP_TARGET_SYSROOT`, which is a
tier fact and empty on a hosted target: under a sub-OS the C library is not at
`/usr/include` and the assembler is not at `/usr/bin`, so a compiler that mcpp
did not place fails at the first `#include`:
crt/host_config.h:218: fatal error: features.h: No such file or directory
`hipcc`, `-fsycl-host-compiler` and any generator that compiles what it emits
have the same gap, so the answer belongs to the engine. Both read from the
single producer that already decides them for the engine's own command lines;
`gcc::binutils_prefix_dir` now states the `-B` guard once, where three copies
of it stood and one said so in a comment.
**A dynamic build-program helper on Linux gets `DT_RPATH`.** The driver's
default is the new tag, and a runpath is consulted only for the helper's own
needed libraries. A build program that opens a host library at run time then
fails one hop later: measured, `dlopen("<sentinel>/lib/libcuda.so.1")`
answered `libdl.so.2: cannot open shared object file` while the very
directories that hold it sat in the helper's RUNPATH. The artifacts mcpp links
already carry DT_RPATH for this reason. The link policy is part of the helper's
cache identity, so an older helper is rebuilt rather than replayed.
**`--accel` / `--no-accel` on `run` and `test`.** The axis existed on `build`
only, so a project could build its CPU-only variant and not run it. Both verbs
take the same two flags, with `--no-accel` travelling as the same explicit
sentinel, and both bypass their fast path when either is given — a cached
artefact was built for whatever axis the last build used.
e2e 607 covers the chained-action shape a device link needs: an artifact-role
action's output consumed by an object-role action, with the intermediate
absent from the link line.
…at owns the tool `mcpp self doctor` grew a CUDA section: it located a toolkit payload, read NVIDIA's `crt/host_config.h` for the host-compiler bound, and parsed `nvcc --dryrun` to name a back-end stage the tool could not reach. Every one of those readings was correct, and none of them belonged to the engine. The repository already states the rule as an invariant. `test_runtime_contract` refuses a vendor name beside a probe launch in `src/`, and the reason is that an engine which learns to run one vendor's tool learns to run four: the second backend arrives as a second section, the third as a third, and the engine acquires a table of tools it must keep current with releases it does not control. The same answers are now produced where the tool is known — the rule package the project imports — and reach mcpp as declarations through the build program's channel, which the engine compares without knowing what any of the names mean. Nothing is lost: the CUDA example reports the driver relation, the host-compiler bound and the unreachable stage before its first compile, and the version floor refuses a build the machine cannot run. `test_core_vendor_probes` states the property the removal establishes, over comment-stripped sources so that recording a vendor's name in a comment stays possible. It carries its own denominator: an enumeration that found fewer than a hundred files is a broken scan rather than a clean result. e2e 602 is removed with the section it tested. Its subject was the doctor report, not a build, and the same preference — payload before host — is what the example's rule package now exercises end to end.
…efuses The example took one route — nvcc driving the project's own compiler — and that route has two constraints neither the engine nor the project controls. Both now produce a sentence before anything is compiled, and a second route exists that has neither. **clang is the primary route.** `[toolchain] default = "llvm@22.1.8"` and the device unit is compiled by the same compiler as the rest of the project (`-x cuda --cuda-path=<payload>`). No second host compiler, no host-compiler bound, no CUDA host header in the way. `MCPP_EXAMPLE_CUDA_ROUTE` selects the other route, and the rule declares `rerun_if_env_changed` for it. **nvcc is the alternate, and it refuses two pairings by name.** The host compiler must satisfy the bound nvcc states in its own `crt/host_config.h`: the rule uses the project's toolchain when it fits, otherwise a `xim:gcc` payload the project declared, otherwise a refusal naming the declaration to add. Measured — GCC 16 under nvcc 12.9 fails inside GCC's own `<type_traits>` even with `-allow-unsupported-compiler`, which admits a compiler one step past the bound and not a standard library two majors newer. The second pairing is a toolkit older than the C library. Toolkit 12.9's `crt/math_functions.h` redeclares the C23 functions `cospi`, `sinpi` and `rsqrt` for the host without `noexcept`; glibc 2.41 and later declare them with it, and since C++17 that is part of the function type. The compile stops with six `exception specification is incompatible` errors naming a glibc header and a CUDA header, and no decision. The rule reads the C library's `bits/mathcalls.h` through `mcpp::toolchain_sysroot()` and states the pair it cannot have, naming the 13.x toolkit as the way out. **A CPU implementation behind the same seam.** `src/cpu/saxpy.cpp` is selected by `cfg(not(accelerator = "cuda"))` while the `.cu` carries the accel its glob is for, so `mcpp build --no-accel` compiles one and `mcpp build` the other, with no hand-written condition on either side. Measured on an RTX 4080, driver 550.144.03 reporting CUDA 12.4: `mcpp run` and `mcpp run --no-accel` both print `12 24 36 48`, from different artifact directories, and the second contains no `cudaMalloc`. The nvcc route is not exercisable there — 12.9 meets the driver and not the C library, 13.3 meets the C library and not the driver — and both refusals are the ones above.
…re name Two packages in one graph may share a bare name and differ only by namespace — that is what namespaces are for — and the map that carries device sources to a build program was keyed by the bare name at both its write site and its two read sites. A collision would hand one package's device sources to the other's build program, and nothing would report it: the receiving program would emit actions for files it does not own, and the owner would emit none. Keyed by the package root instead, which is unique by construction. Found by re-reading the diff before merge; no test covered it, and a test that did would need two same-named packages in one graph, which is worth adding when a second consumer of this map appears.
The version annotations in the manual said 2026.9.6 while the release lands today, so they name the version that actually carries them. `mcpp.toml` and `MCPP_VERSION` move together, as `01_help_and_version.sh` requires. The CHANGELOG's Unreleased section described the doctor's `nvcc --dryrun` check as a shipped feature. That check left the engine in this same batch — the reading was right and the place was wrong — so the entry is rewritten to say where it went rather than to announce something the release does not contain.
…lling `accel_str` prints `(none)` for an empty set so an ABI tag reads as a sentence. `resolvedAccel` handed that spelling on as a value, and two readers were wrong at once: - A build program saw `MCPP_ACCEL=(none)` while the manual promised an empty string, so a rule package asking whether there is an accelerator got a yes and a backend named `(none)`. This reached every project that never mentioned an accelerator, not only those passing `--no-accel`. - The fingerprint's own guard, `if (!accel.empty())`, was true for every project, appending `#accel=(none)` to builds that had asked for nothing — the opposite of what the comment beside it says. Measured with a build program that wrote the value to a file, which is the only way to see it: mcpp shows a build program's stdout only when it exits non-zero. e2e 605's fourth section was titled "--no-accel empties both the variable and the layer" and measured only the layer, which is how this passed a suite it was meant to be covered by. It now asserts both, and the fixture prints the value delimited — an empty warning is indistinguishable from an absent one.
The adapters and the rename are in mcpp-index#347; the plan table says so and records the three findings that came out of measuring them — the display spelling that leaked into a value, the two upstream couplings the CUDA recipes now carry, and the path-index namespace behaviour that made local verification look like a resolution failure. The verification script travels with the plan rather than living only in a scratch directory: it is the thing that found the `(none)` defect, and it is what the release is checked with inside a sandbox.
…space The rule is content this ecosystem wrote rather than anyone's upstream, so `mcpplibs` is its namespace by the same rule the rest of the index follows, and the module follows the namespace: `mcpplibs.rules.cuda`. It also makes the package publishable. The index entry for it points at this same directory inside an mcpp release tarball — the shape `grpcgen` already uses — and a descriptor's identity has to match the manifest it points at, so a package that stays in an `example` namespace can only ever be copied into the index rather than referenced there. Measured: `mcpp run` and `mcpp run --no-accel` both print 12 24 36 48 after a clean rebuild.
… left Five places the plan was wrong and two defects the implementation created and fixed, each with the reading that settled it. Also what a consumer writes after this batch, and the list of what remains with the reason each item is not done rather than a silence that reads as done.
…nly a linked image
A package whose device code is its point declares `kind = "lib"`. Until this,
the actions its build program emitted were dropped — with a warning, and with
the archive coming out containing none of them:
build.mcpp action 'cuda:wkv' has role = "object" but this build produces
no executable, shared library or test binary to link its outputs into
Measured on llama.cpp's CUDA backend, which is 305 `.cu` files behind exactly
such a target: every action reported that line and the build succeeded, having
produced a library with no device code in it.
The archive rule already consumes `lu.objects`; a static library was simply
absent from the predicate that decides which link units an object action
attaches to. The objects an action produced belong there for the same reason a
compiled `.cpp`'s do — a target's content is what it was told to contain.
e2e 608 asserts the member list of the archive rather than the exit status: an
`ar` handed nothing still writes a well-formed archive and reports success.
This is the engine half of the multi-device design's C-6.
The framework batch's first item found the engine gap C-6 named and nothing had measured, and it got as far as compiling before stopping on a payload version rather than on anything about the design. Both readings are recorded with what would close them.
A device unit that includes <cuda_runtime.h> stops on
crt/host_defines.h:67: error: "libc++ is not supported on x86 system"
whenever the toolchain is LLVM, which is the toolchain the clang route exists
for. The guard is `#if defined(__CUDACC__) && … && defined(_LIBCPP_VERSION)`,
and clang defines `__CUDACC__` when it compiles CUDA itself, so the refusal —
which is about nvcc's host pass — lands on a compiler it was not written about.
The escape hatch is upstream's own and is passed only on the clang route: nvcc
really does break against libc++, and nothing here weakens that refusal.
Found on ggml's CUDA backend. The example's own kernel never showed it because
a bare kernel includes no toolkit header at all — which is worth knowing about
the example: it exercises the plumbing, not the headers.
… on the way The chain the design is about — axis, narrowed glob, device source list, rule package, actions, static archive, link — runs end to end and produced 48 device objects. What stops it is a four-way payload matrix that has no satisfiable point on this machine, and none of its four sides is mcpp's.
… not a library A `kind = "lib"` target used to serve as "nothing that takes an object action's outputs", because a static library did not take them. It does now, so that fixture stopped exercising the case and the suite reported it — which is the whole value of running it. The one shape that still leaves an object action with nowhere to go is a target behind an inactive `required_features`: it is absent from the build entirely. A control runs the same project with the feature on and asserts the diagnostic is gone, so the assertion cannot pass on a build that stopped emitting it for everyone.
T5.1 did what a gate is for: it found the engine gap C-6 named and nothing had exercised, and it carried the whole chain to 48 device objects before stopping on a four-way payload matrix with no satisfiable point on this machine. The matrix is written out with the reading for each side, and the three general findings it produced are recorded with where they went.
…uld say which The README claimed "no CUDA host header in the way", which stopped being true the moment a device unit includes <cuda_runtime.h>. It says what the flag is, why the guard fires on a compiler it was not written about, and why this example's own kernel never showed it.
… for free Rehearsed inside a sandbox against the previous release — the one that still had the doctor's toolkit reader — and section E reported ok. It was watching for a section `self doctor` only prints when a CUDA toolkit is installed, and a fresh sandbox has none, so the assertion could not fail there. It now reads the engine binary for the names the removed code carried. `crt/host_config.h` appears twice in 2026.9.5.1 and not at all in 2026.9.5.2, so the check discriminates: the control run against the older binary fails with that exact count. The source-level property stays where it belongs, in `test_core_vendor_probes`. Also fixed while writing it: `grep -c` prints 0 and exits 1 when it matches nothing, so `|| echo 0` appended a second line, `[` said "integer expression expected", and the section reported ok having skipped every comparison.
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.
Three things, all from the multi-device design. Companion to
openxlings/xim-pkgindex#759 (merged), which put the toolkit into the ecosystem.
1. The device-toolkit report was answering about the wrong toolkit
mcpp self doctorlocatedcrt/host_config.hthrough host locations only. Atoolkit installed through xlings is the one a build will use, and it is usually
the newer one:
gcc <= 14gcc <= 15gcc <= 12Both stores are searched — mcpp's own and the one
xlings installwritesto. 191 packages in one and 211 in the other here, with the CUDA components only
in the second.
2. Version floors
A device runtime must not be newer than the driver it meets. Measured on a
driver serving CUDA 12.4:
cudaMalloc: CUDA driver version is insufficientresult: 12 24 36 48Both numbers are knowable beforehand, and mcpp must not run a vendor's tool to
get them —
test_runtime_contractrefuses that insrc/, and it caught thefirst revision of this PR, which launched
nvidia-smi. So they arrive asdeclarations:
No vendor vocabulary reaches the engine.
version_floor.cppmreads a name,a relation and a version. No new manifest keys were needed —
kindwas alreadya free string.
has is not one that fails the floor; it is one nobody asked. That is the third
case in the e2e, not a comment — without it the first case would also pass
against an engine that refused every floor.
3. The example stops reaching for the host
The example shipped in 2026.9.5.1 wrote
-L/usr/local/cuda/lib64and probed/usr/bin/nvcc. It was the project's own demonstration, and what itdemonstrated was reaching for the host. It now names the toolkit:
<its own directory>/../include, and on the 12.x line that holdscrt/but notcuda_runtime.h— which is incuda-cudart. The first revision left it out:nvcc resolved
cuda_runtime.hfrom/usr/includeand read the host'scrt/host_config.hbeside it, so the build failed with the host toolkit'scomplaint while using the payload's compiler. "Uses the payload" and "uses only
the payload" are different, and the first looks like the second.
Verified
602_device_toolkit_payload_first.shgcc <= 41; the report reads it. Control: without the store, 41 is not reported603_version_floor.sh601(from #562)101 test binaries pass, including the contract test that rejected the earlier
revision of §2.