Skip to content

Commit ad167f4

Browse files
committed
feat(doctor): read the device toolkit from a payload before the host, 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.
1 parent 1e2137b commit ad167f4

6 files changed

Lines changed: 286 additions & 1 deletion

File tree

‎docs/20-accelerators.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ The bound is read from the toolkit rather than tabulated in mcpp, so a toolkit
8585
mcpp has never seen still answers, and a header mcpp cannot parse yields no
8686
bound and therefore no claim.
8787

88+
**A payload is read before the host.** A toolkit installed through xlings is the
89+
one a build will use, and it is usually the newer one: a 12.9 payload states
90+
`gcc <= 14` and a 13.3 payload `gcc <= 15`, where a distribution's CUDA 12.0
91+
states `gcc <= 12`. Both package stores are searched — mcpp's own and the one
92+
`xlings install` writes to — and the host's locations remain, last, because a
93+
machine with a distribution toolkit and no payload is a real configuration.
94+
95+
**What is not checked here, and why.** A device runtime must not be newer than
96+
the driver it runs against; when it is, the build compiles and links cleanly and
97+
fails at the first allocation with *"CUDA driver version is insufficient for
98+
CUDA runtime version"*. mcpp knows the relation — `driver_accepts_toolkit`
99+
states when one version may meet another, including that minor-version
100+
compatibility means a 12.9 runtime is fine on a driver serving 12.4 — but it
101+
does not ask the machine which driver it has, because asking means running a
102+
vendor's tool and the engine owns no vendor probes. Those numbers reach the
103+
report as declarations instead: a toolkit payload states the driver it needs,
104+
and the package that owns the host driver states what the host has.
105+
88106
This is reported rather than enforced: a project that compiles no device code
89107
is unaffected by an incompatible pair.
90108

‎docs/zh/20-accelerators.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ warning: cuda will refuse this host compiler: gcc 13 exceeds the bound of 12
6767
上界是从工具包读出的,不是抄在 mcpp 里的表,因此一个 mcpp 从未见过的工具包同样能作答;
6868
而一个 mcpp 无法解析的头文件产生不出上界,也就不产生任何断言。
6969

70+
**载荷先于 host 被读取。** 经 xlings 装的工具包才是构建会用的那个,而且通常更新:
71+
12.9 载荷声明 `gcc <= 14`、13.3 载荷声明 `gcc <= 15`,而发行版的 CUDA 12.0 声明
72+
`gcc <= 12`。两个包 store 都会搜 —— mcpp 自己的,以及 `xlings install` 写入的那个;
73+
host 的位置保留在最后,因为「有发行版工具包、没有载荷」是一种真实配置。
74+
75+
**这里不检查什么,以及为什么。** 设备运行时不得比它将运行于其上的驱动更新;
76+
更新时构建**干净地编译并链接**,却在第一次分配时失败,报
77+
*"CUDA driver version is insufficient for CUDA runtime version"*。
78+
mcpp 知道这个关系 —— `driver_accepts_toolkit` 陈述一个版本何时可以遇上另一个,
79+
包括「小版本兼容」意味着 12.9 的运行时在只服务到 12.4 的驱动上没问题 ——
80+
但它**不去问机器装的是哪个驱动**,因为问就意味着运行一个厂商的工具,而引擎不持有
81+
任何厂商探针。那两个数字改以**声明**的形式抵达:工具包载荷声明它需要的驱动,
82+
持有宿主驱动的那个包声明宿主有什么。
83+
7084
这是报告而非强制:一个不编译任何设备代码的工程,不受不兼容配对的影响。
7185

7286
## 设备编译器能否够到自己的后端

‎src/doctor.cppm‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,8 +655,55 @@ export int doctor_report() {
655655
// code is unaffected by an incompatible pair, and refusing its build would
656656
// be a false alarm.
657657
if (!mcpp::platform::is_windows) {
658+
// ⭐ PAYLOADS BEFORE THE HOST, AND THE ORDER IS THE POINT.
659+
//
660+
// A toolkit installed through xlings is the one a build will use, and
661+
// it is also the newer one: measured on this machine, the payload's own
662+
// `crt/host_config.h` states gcc<=15 where the distribution's CUDA 12.0
663+
// states gcc<=12. Reporting the host's bound while the build uses the
664+
// payload's would answer a question nobody asked.
665+
//
666+
// The host entries stay, last, because a machine with a distribution
667+
// toolkit and no payload is a real configuration and reporting nothing
668+
// there would be worse than reporting its bound.
669+
auto payload_roots = [] {
670+
std::vector<std::filesystem::path> out;
671+
std::error_code ec;
672+
// `cuda-crt` carries the header in 13.x; in 12.x `cuda-nvcc`
673+
// carries it. Both are scanned, newest version directory first --
674+
// string order is right here because upstream pads nothing.
675+
// ⚠️ TWO STORES, AND A PAYLOAD MAY BE IN EITHER. mcpp keeps its own
676+
// under `<mcpp home>/registry/data/xpkgs`; a package installed with
677+
// `xlings install` lands in `<xlings home>/data/xpkgs`. Measured on
678+
// this machine: 191 packages in one and 211 in the other, with the
679+
// CUDA components only in the second. Scanning one of them reports
680+
// the host's toolkit while the build uses the payload's.
681+
std::vector<std::filesystem::path> stores{
682+
mcpp::home::root() / "registry" / "data" / "xpkgs"};
683+
{
684+
std::string xhome;
685+
if (const char* p = std::getenv("XLINGS_HOME")) xhome = p;
686+
else if (const char* h = std::getenv("HOME"))
687+
xhome = std::string(h) + "/.xlings";
688+
if (!xhome.empty())
689+
stores.push_back(std::filesystem::path(xhome) / "data" / "xpkgs");
690+
}
691+
for (auto const& store : stores)
692+
for (auto const* pkg : {"xim-x-cuda-crt", "xim-x-cuda-nvcc",
693+
"local-x-cuda-crt", "local-x-cuda-nvcc"}) {
694+
auto dir = store / pkg;
695+
if (!std::filesystem::is_directory(dir, ec)) continue;
696+
std::vector<std::filesystem::path> versions;
697+
for (auto& v : std::filesystem::directory_iterator(dir, ec))
698+
if (v.is_directory(ec)) versions.push_back(v.path());
699+
std::ranges::sort(versions, std::ranges::greater{});
700+
for (auto& v : versions) out.push_back(v);
701+
}
702+
return out;
703+
}();
704+
658705
auto header = [&]() -> std::optional<std::filesystem::path> {
659-
std::vector<std::filesystem::path> roots;
706+
std::vector<std::filesystem::path> roots = payload_roots;
660707
if (const char* p = std::getenv("CUDA_PATH")) roots.emplace_back(p);
661708
if (const char* p = std::getenv("CUDA_HOME")) roots.emplace_back(p);
662709
roots.emplace_back("/usr/local/cuda");
@@ -728,6 +775,34 @@ export int doctor_report() {
728775
//
729776
// Asked rather than assumed: `--dryrun` prints the plan without
730777
// running it, so the answer is nvcc's own.
778+
// ── The driver this toolkit will meet ──────────────────────
779+
//
780+
// A device runtime must not be newer than the driver it runs
781+
// against, and the failure when it is comes at the FIRST
782+
// ALLOCATION, after a clean compile and a clean link. Measured
783+
// 2026-09-05 on a driver serving CUDA 12.4: the 13.3 payload builds
784+
// cleanly and then reports "CUDA driver version is insufficient for
785+
// CUDA runtime version", while the 12.9 payload prints the right
786+
// answer.
787+
//
788+
// ⚠️ THE RELATION IS HERE; THE ACQUISITION IS NOT, AND THAT IS
789+
// DELIBERATE. `mcpp::toolchain::driver_accepts_toolkit` states when
790+
// one version may meet another and is unit-tested. Asking a machine
791+
// which driver it has means running a vendor's tool, and
792+
// `tests/unit/test_runtime_contract.cpp` refuses exactly that in
793+
// `src/` -- it caught the first revision of this check, which
794+
// launched one. The rule is the repository's, it predates this
795+
// work, and it is right: a core that learns to run one vendor's
796+
// probe learns to run four.
797+
//
798+
// The numbers reach this report through declarations instead: a
799+
// toolkit payload states the driver it needs, and the package that
800+
// owns the host driver states what the host has. Both are ordinary
801+
// manifest data. Wiring that is the rule-package channel, and until
802+
// it exists this section reports the pairing it can already read --
803+
// the host compiler bound above -- and says nothing about a driver
804+
// rather than guessing at one.
805+
731806
if (auto missing = unreachable_device_stage(); missing) {
732807
warn(std::format(
733808
"nvcc cannot reach its own back-end: it invokes '{}' by "

‎src/toolchain/devicehost.cppm‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,51 @@ DeviceDryRun parse_dryrun(std::string_view text);
7373
bool host_compiler_accepted(const HostCompilerBounds& b,
7474
std::string_view family, int major);
7575

76+
// ── The driver a device runtime will meet ──────────────────────────────────
77+
//
78+
// A device runtime must not be NEWER than the driver it runs against, and the
79+
// driver is the one component that cannot be redistributed: it is in ABI
80+
// lockstep with a kernel module. So the toolkit version a build uses is bounded
81+
// by a fact about the machine, and that fact is knowable before anything is
82+
// compiled.
83+
//
84+
// ⚠️⚠️ MEASURED, 2026-09-05, on a host whose driver reports CUDA 12.4: a
85+
// binary built with the 13.3 payload COMPILES AND LINKS CLEANLY and then fails
86+
// at the first allocation with
87+
//
88+
// cudaMalloc: CUDA driver version is insufficient for CUDA runtime version
89+
//
90+
// while the same source built with the 12.9 payload prints the right answer.
91+
// Everything that could have caught it earlier was silent -- which is the whole
92+
// reason this is checked rather than left to happen.
93+
//
94+
// ⭐ The comparison is separated from the acquisition on purpose. Which
95+
// function asks the driver its version is a vendor's business and belongs to a
96+
// rule package; whether one version may meet another is a relation, and that
97+
// is what lives here.
98+
99+
// A dotted version reduced to (major, minor). Absent or unreadable parts are
100+
// zero, which makes an unreadable version compare as older rather than as a
101+
// refusal.
102+
struct DeviceVersion {
103+
int major = 0;
104+
int minor = 0;
105+
bool known() const { return major != 0; }
106+
};
107+
108+
DeviceVersion parse_device_version(std::string_view text);
109+
110+
// May a runtime built against `toolkit` run on a machine whose driver supports
111+
// up to `driver`?
112+
//
113+
// The rule is minor-version compatibility, which is the vendor's and not
114+
// invented here: within one major version an application built against any
115+
// minor runs on a driver supporting that major. Across majors it does not.
116+
//
117+
// ⚠️ Either side unknown yields TRUE. A check that cannot reach an answer must
118+
// not manufacture a refusal -- the same rule the host-compiler bound follows.
119+
bool driver_accepts_toolkit(DeviceVersion toolkit, DeviceVersion driver);
120+
76121
} // namespace mcpp::toolchain
77122

78123
namespace mcpp::toolchain {
@@ -161,6 +206,35 @@ DeviceDryRun parse_dryrun(std::string_view text) {
161206
return plan;
162207
}
163208

209+
DeviceVersion parse_device_version(std::string_view text) {
210+
DeviceVersion v;
211+
std::size_t i = 0;
212+
while (i < text.size() && !std::isdigit(static_cast<unsigned char>(text[i]))) ++i;
213+
int acc = 0;
214+
bool any = false;
215+
for (; i < text.size() && std::isdigit(static_cast<unsigned char>(text[i])); ++i) {
216+
acc = acc * 10 + (text[i] - '0');
217+
any = true;
218+
}
219+
if (!any) return v;
220+
v.major = acc;
221+
if (i < text.size() && text[i] == '.') {
222+
++i;
223+
acc = 0;
224+
for (; i < text.size() && std::isdigit(static_cast<unsigned char>(text[i])); ++i)
225+
acc = acc * 10 + (text[i] - '0');
226+
v.minor = acc;
227+
}
228+
return v;
229+
}
230+
231+
bool driver_accepts_toolkit(DeviceVersion toolkit, DeviceVersion driver) {
232+
if (!toolkit.known() || !driver.known()) return true; // no claim
233+
if (toolkit.major != driver.major) return toolkit.major < driver.major;
234+
// Same major: minor-version compatibility covers it.
235+
return true;
236+
}
237+
164238
bool host_compiler_accepted(const HostCompilerBounds& b,
165239
std::string_view family, int major)
166240
{
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# The device-toolkit report reads a PAYLOAD before it reads the host.
3+
#
4+
# A toolkit installed through xlings is the one a build will use, and it is
5+
# also the newer one -- measured on the development machine, a payload states
6+
# `gcc <= 15` where the distribution's CUDA 12.0 states `gcc <= 12`. Reporting
7+
# the host's bound while the build uses the payload's answers a question nobody
8+
# asked, and the two answers differ by three major compiler versions.
9+
#
10+
# No CUDA is required to assert the ordering: a fabricated `crt/host_config.h`
11+
# in a fabricated payload directory is enough, because what is under test is
12+
# which of two files the report reads.
13+
set -e
14+
15+
TMP=$(mktemp -d)
16+
trap "rm -rf $TMP" EXIT
17+
18+
# A payload store laid out the way xlings lays one out, holding a header whose
19+
# bound is one nothing on a real machine would state.
20+
PAYLOAD="$TMP/xlings/data/xpkgs/local-x-cuda-crt/99.9.99/include/crt"
21+
mkdir -p "$PAYLOAD"
22+
cat > "$PAYLOAD/host_config.h" <<'HDR'
23+
#if __GNUC__ > 41
24+
#error -- unsupported GNU version! gcc versions later than 41 are not supported!
25+
#endif
26+
HDR
27+
28+
out="$TMP/doctor.log"
29+
XLINGS_HOME="$TMP/xlings" "$MCPP" self doctor > "$out" 2>&1 || true
30+
31+
grep -q "device toolkit" "$out" || { cat "$out"; echo "FAIL: no device toolkit section"; exit 1; }
32+
33+
# The bound reported must be the payload's 41, whatever the host has. On a
34+
# machine with no CUDA at all this is also the only way the section appears.
35+
if ! grep -qE "<= ?41|bound of 41" "$out"; then
36+
grep -A3 "device toolkit" "$out"
37+
echo "FAIL: the report did not read the payload's host_config.h"
38+
exit 1
39+
fi
40+
echo "PASS: the payload's bound is the one reported"
41+
42+
# The control. Without the payload store, the same command must NOT report 41 --
43+
# otherwise the assertion above would pass against a doctor that hardcodes it.
44+
out2="$TMP/doctor2.log"
45+
XLINGS_HOME="$TMP/empty" "$MCPP" self doctor > "$out2" 2>&1 || true
46+
if grep -qE "<= ?41|bound of 41" "$out2"; then
47+
echo "FAIL: 41 is reported with no payload present; the test measures nothing"
48+
exit 1
49+
fi
50+
echo "PASS: without the payload the bound is not 41"
51+
52+
echo "PASS: device toolkit payload-first"

‎tests/unit/test_devicehost.cpp‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,55 @@ TEST(DeviceDryRun, TextThatIsNotAPlanYieldsNoStages) {
147147
EXPECT_TRUE(plan.programs.empty());
148148
EXPECT_TRUE(plan.searchPath.empty());
149149
}
150+
151+
// ── The driver a device runtime will meet ──────────────────────────────────
152+
//
153+
// Measured 2026-09-05 on a host whose driver reports CUDA 12.4: a binary built
154+
// with the 13.3 payload compiles and links cleanly and then fails at the first
155+
// allocation; the same source built with the 12.9 payload prints the right
156+
// answer. These assert the relation that turns that into a message before
157+
// anything is compiled.
158+
159+
using mcpp::toolchain::parse_device_version;
160+
using mcpp::toolchain::driver_accepts_toolkit;
161+
162+
TEST(DeviceDriver, ReadsAVersionOutOfSurroundingText) {
163+
// The two real shapes: `nvcc --version` ends with "release 12.9, V12.9.86",
164+
// and `nvidia-smi`'s header carries "CUDA Version: 12.4".
165+
auto a = parse_device_version("12.9, V12.9.86");
166+
EXPECT_EQ(a.major, 12);
167+
EXPECT_EQ(a.minor, 9);
168+
auto b = parse_device_version(" 12.4 |");
169+
EXPECT_EQ(b.major, 12);
170+
EXPECT_EQ(b.minor, 4);
171+
}
172+
173+
TEST(DeviceDriver, MinorVersionCompatibilityHolds) {
174+
// Within one major, any minor runs. This is the vendor's rule, and it is
175+
// why the 12.9 payload works against a driver that serves 12.4 -- the case
176+
// a naive "toolkit must be <= driver" check would have refused.
177+
EXPECT_TRUE(driver_accepts_toolkit(parse_device_version("12.9"),
178+
parse_device_version("12.4")));
179+
EXPECT_TRUE(driver_accepts_toolkit(parse_device_version("12.0"),
180+
parse_device_version("12.4")));
181+
}
182+
183+
TEST(DeviceDriver, ANewerMajorIsRefused) {
184+
EXPECT_FALSE(driver_accepts_toolkit(parse_device_version("13.3"),
185+
parse_device_version("12.4")));
186+
}
187+
188+
TEST(DeviceDriver, AnOlderMajorIsAccepted) {
189+
EXPECT_TRUE(driver_accepts_toolkit(parse_device_version("11.8"),
190+
parse_device_version("12.4")));
191+
}
192+
193+
TEST(DeviceDriver, EitherSideUnknownMakesNoClaim) {
194+
// The same rule the host-compiler bound follows: a check that cannot reach
195+
// an answer must not manufacture a refusal. A machine with no driver, or a
196+
// toolkit whose version could not be read, is not a machine with a defect.
197+
EXPECT_TRUE(driver_accepts_toolkit(parse_device_version("13.3"),
198+
parse_device_version("no gpu here")));
199+
EXPECT_TRUE(driver_accepts_toolkit(parse_device_version(""),
200+
parse_device_version("12.4")));
201+
}

0 commit comments

Comments
 (0)