Skip to content

Commit 6452168

Browse files
committed
feat(doctor): report when nvcc cannot reach its own back-end
Verifying the release inside a sandbox found the CUDA example failing where it succeeds on the host. The difference is one line: nvcc runs cicc, cudafe++, ptxas and fatbinary as bare names on a PATH it prepends itself from an `nvcc.profile` beside its binary, Debian-family packaging makes that profile a symlink into /etc, and a sandbox that replaces /etc removes it. nvcc then keeps the ambient PATH and reports `sh: 1: cicc: not found`, which names neither nvcc nor the profile. Nothing about the toolkit is missing, so every obvious check passes. The plan is asked for rather than assumed. `nvcc --dryrun` prints the stages and the PATH nvcc will use without compiling anything; parse_dryrun reads that plan, and the doctor resolves each bare name against the path nvcc states, reporting the first that does not resolve. A dryrun that yields no plan -- there is no nvcc, or the output is not one -- produces no finding, on the same principle as the host-compiler bound: a probe that reaches no answer must not invent one. Measured on both sides, because a check that is always silent and a check that is always loud look alike from one run. With a working nvcc the warning does not appear; with nvcc copied to a directory holding no profile it appears and names `cicc` -- the same stage the real failure names, because Debian ships cudafe++, ptxas and fatbinary in /usr/bin and cicc only in the toolkit's own directory. No e2e accompanies this. It would need nvcc, which CI does not have, and a `# requires: nvcc` script skips and exits 0 on every shard. The coverage is five unit tests over two real dryrun transcripts differing in exactly that PATH line, plus the two-sided control recorded in the design's section 15.7.
1 parent 2f5c125 commit 6452168

7 files changed

Lines changed: 376 additions & 0 deletions

File tree

.agents/docs/2026-09-05-accelerator-support-design.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,3 +1280,54 @@ xim:mcpp@2026.9.5.1 installed, but 'mcpp' still resolves to 2026.9.4.3
12801280
「引擎侧的新键要等 #559 发布之后,索引里的包才能使用」—— 2026.9.5.1 已发布,
12811281
`accel``[package] accelerators``cfg(accelerator=…)` 现在都可以出现在索引包里。
12821282
下一个用到它们的包需要在描述符里声明 mcpp 版本下限。
1283+
1284+
### 15.7 ⭐⭐ 又一条判据:从**发布的源码 tarball**跑示例,它红了
1285+
1286+
前六项用的是发布的引擎,但用的是工作树里的示例。补一条:
1287+
`mcpp-2026.9.5.1.tar.gz` 解出 `examples/09-cuda-kernel`,用发布的引擎构建。
1288+
**在沙箱里失败**:
1289+
1290+
```
1291+
sh: 1: cicc: not found
1292+
```
1293+
1294+
而同一个示例在宿主上输出 `12 24 36 48`。单变量隔离(同一条 nvcc 命令,只改一个东西):
1295+
1296+
| 环境 | 结果 |
1297+
|---|---|
1298+
| 沙箱默认 | `sh: 1: cicc: not found` |
1299+
| 沙箱 + `PATH=/usr/lib/nvidia-cuda-toolkit/bin:$PATH` | 产出 `.o` |
1300+
| 沙箱 + PATH + `NVVMIR_LIBRARY_DIR` | 产出 `.o` |
1301+
1302+
**唯一缺的是一条 PATH**。它来自 `/etc/nvcc.profile`,而 Debian 系把
1303+
`/usr/lib/nvidia-cuda-toolkit/bin/nvcc.profile` 做成指向 `/etc` 的符号链接 ——
1304+
subos 沙箱替换了 `/etc`,那条链接因此断开。
1305+
1306+
⚠️ **这不是 mcpp 的缺陷,但 mcpp 的诊断对它一言不发。** 工具包完整、nvcc 在
1307+
`PATH` 上、`crt/host_config.h` 读得到、`self doctor` 那一节照常打印,
1308+
**每一个显而易见的检查都通过**,而用户拿到的是一条既不提 nvcc 也不提 profile
1309+
的消息。这与 §5.2 的宿主编译器上界是同一个类别:失败很晚,消息指向一个用户
1310+
没有选择过的东西。
1311+
1312+
**已实现的检查**(`mcpp.toolchain.devicehost``parse_dryrun` + doctor 的
1313+
`unreachable_device_stage`):`nvcc --dryrun` 打印它将要运行的阶段与它将要使用的
1314+
`PATH` 而不编译任何东西;mcpp 解析这份计划,逐个解析其中的裸名,报出第一个解析
1315+
不到的。**判据取自 nvcc 自己,不是抄一张目录表** —— 与 §14.1 第 5 行读
1316+
`crt/host_config.h` 而非内置版本表是同一条原则。
1317+
1318+
**两侧对照都跑了**(否则这条判据可能永远沉默或永远报警):
1319+
1320+
| 对照 | `cannot reach its own back-end` |
1321+
|---|---|
1322+
| 真 nvcc(profile 可读) | 0 次 |
1323+
| 把 nvcc 复制到无 profile 的目录后放在 PATH 前 | 1 次,点名 `cicc` |
1324+
1325+
点名的是 `cicc` 而不是计划里更靠前的 `cudafe++`,因为 Debian 把
1326+
`cudafe++`/`ptxas`/`fatbinary` 装进了 `/usr/bin``cicc` 只在工具包目录里 ——
1327+
**与真实失败点名的那一个一致**
1328+
1329+
⚠️ **没有为它写 e2e。** 它需要 nvcc,而 CI 没有;一条 `# requires: nvcc`
1330+
e2e 会在两个 shard 上都跳过并退 0,那是一条永远不跑的判据(memory
1331+
`e2e-requires-llvm-never-runs-on-shards` 记的就是这个)。覆盖是:解析器的五条
1332+
单测(夹具是两份**真实**的 dryrun 输出,差别恰好是那一行 PATH),加上上表那次
1333+
手工两侧对照。

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [Unreleased]
7+
8+
### `mcpp self doctor` 报出设备编译器够不到自己的后端
9+
10+
一个 CUDA 工具包可以安装完整、就在 `PATH` 上,而设备代码仍然在第一个阶段失败。
11+
nvcc 以裸名调用 `cicc``cudafe++``ptxas``fatbinary`,依赖的是它自己从紧邻
12+
其二进制的 `nvcc.profile` 前置进来的一条 `PATH`。Debian 系打包把那个 profile
13+
做成指向 `/etc` 的符号链接,于是任何替换了 `/etc` 的容器或沙箱都会移除它,
14+
nvcc 沿用环境原有的 `PATH` 并报出 `sh: 1: cicc: not found` —— 这条消息既不提
15+
nvcc 也不提 profile,而工具包一样不缺,所以显而易见的检查全部通过。
16+
17+
判据取自 nvcc 自己:`--dryrun` 打印它将要运行的各个阶段与它将要使用的 `PATH`,
18+
而不编译任何东西。mcpp 解析这份计划,逐个解析其中的裸名,报出第一个解析不到的。
19+
一次没有产生计划的 dryrun(没有 nvcc,或输出不是一份计划)不产生任何结论 ——
20+
与宿主编译器上界那条检查同一条原则:一个够不到答案的探测不应当发明一个。
21+
22+
这是本轮把发布物放进沙箱验证时暴露出来的:示例在宿主上跑通,在 `--sandbox`
23+
里失败,而唯一的差别就是那条 `PATH`
24+
625
## [2026.9.5.1] — 2026-09-05
726

827
### ⭐⭐ 加速器支持:设备编译单元、产物身份的加速器维、以及没人做的宿主编译器配对

docs/20-accelerators.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,35 @@ bound and therefore no claim.
8888
This is reported rather than enforced: a project that compiles no device code
8989
is unaffected by an incompatible pair.
9090

91+
## Whether the device compiler can reach its own back-end
92+
93+
A toolkit can be installed, complete and on `PATH` and still fail at its first
94+
stage. nvcc runs `cicc`, `cudafe++`, `ptxas` and `fatbinary` as bare names, on
95+
a `PATH` it prepends itself from an `nvcc.profile` beside its own binary. On
96+
Debian-family packaging that profile is a symlink into `/etc`, so a container
97+
or sandbox that replaces `/etc` removes it. nvcc then keeps the ambient `PATH`
98+
and reports:
99+
100+
```
101+
sh: 1: cicc: not found
102+
```
103+
104+
The message names neither nvcc nor the profile, and nothing about the toolkit
105+
is missing, so the obvious checks all pass. `mcpp self doctor` asks nvcc for
106+
its plan instead of assuming one:
107+
108+
```
109+
$ mcpp self doctor
110+
Checking device toolkit
111+
warning: nvcc cannot reach its own back-end: it invokes 'cicc' by name, and
112+
that name does not resolve on the search path it states.
113+
```
114+
115+
The plan comes from `nvcc --dryrun`, which prints the stages and the `PATH`
116+
nvcc will use without compiling anything. A dryrun that produces no plan --
117+
there is no nvcc, or the output is not one -- yields no finding, because a
118+
probe that reaches no answer must not invent one.
119+
91120
## Declaring what a build targets
92121

93122
```toml

docs/zh/20-accelerators.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,33 @@ warning: cuda will refuse this host compiler: gcc 13 exceeds the bound of 12
6969

7070
这是报告而非强制:一个不编译任何设备代码的工程,不受不兼容配对的影响。
7171

72+
## 设备编译器能否够到自己的后端
73+
74+
一个工具包可以安装完整、就在 `PATH` 上,却仍然在第一个阶段失败。
75+
nvcc 以裸名调用 `cicc``cudafe++``ptxas``fatbinary`,依赖的是它自己
76+
从紧邻其二进制的 `nvcc.profile` 前置进来的一条 `PATH`。在 Debian 系的打包里,
77+
那个 profile 是指向 `/etc` 的符号链接,于是任何替换了 `/etc` 的容器或沙箱都会移除它。
78+
nvcc 随即沿用环境里原有的 `PATH`,并报出:
79+
80+
```
81+
sh: 1: cicc: not found
82+
```
83+
84+
这条消息既没有提到 nvcc,也没有提到 profile,而工具包本身一样不缺,
85+
于是所有显而易见的检查都会通过。`mcpp self doctor` 因此去问 nvcc 要它的计划,
86+
而不是假设一份:
87+
88+
```
89+
$ mcpp self doctor
90+
Checking device toolkit
91+
warning: nvcc cannot reach its own back-end: it invokes 'cicc' by name, and
92+
that name does not resolve on the search path it states.
93+
```
94+
95+
计划来自 `nvcc --dryrun` —— 它打印各个阶段与 nvcc 将要使用的 `PATH`,
96+
而不编译任何东西。一次没有产生计划的 dryrun(没有 nvcc,或输出不是一份计划)
97+
不产生任何结论:一个够不到答案的探测不应当发明一个。
98+
7299
## 声明一次构建的目标
73100

74101
```toml

src/doctor.cppm

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,64 @@ export int env_report() {
102102
}
103103

104104
// `mcpp self doctor`.
105+
// Which back-end stage nvcc names but cannot resolve, if any.
106+
//
107+
// Nothing is compiled: `--dryrun` prints the plan and stops. std::nullopt
108+
// covers three unlike situations on purpose -- there is no nvcc, the dryrun
109+
// produced no plan, and every stage in the plan resolves -- because only a
110+
// named unresolvable stage is a finding. A probe that cannot reach an answer
111+
// must not manufacture one.
112+
std::optional<std::string> unreachable_device_stage() {
113+
namespace fs = std::filesystem;
114+
std::error_code ec;
115+
116+
const auto probe = fs::temp_directory_path(ec) / "mcpp-nvcc-dryrun";
117+
if (ec) return std::nullopt;
118+
fs::remove_all(probe, ec);
119+
ec.clear();
120+
fs::create_directories(probe, ec);
121+
if (ec) return std::nullopt;
122+
struct Cleanup {
123+
fs::path dir;
124+
~Cleanup() { std::error_code e; fs::remove_all(dir, e); }
125+
} const cleanup{probe};
126+
127+
const auto source = probe / "empty.cu";
128+
{ std::ofstream out(source); if (!out) return std::nullopt; }
129+
130+
// A spawn that fails because there is no nvcc yields text with no `#$`
131+
// lines, hence an empty plan, hence no finding. No separate check needed.
132+
const auto run = mcpp::platform::process::capture_exec(
133+
{"nvcc", "--dryrun", "-c", source.string(),
134+
"-o", (probe / "empty.o").string()});
135+
136+
const auto plan = mcpp::toolchain::parse_dryrun(run.output);
137+
if (plan.programs.empty()) return std::nullopt;
138+
139+
std::string search = plan.searchPath;
140+
if (search.empty())
141+
if (const char* p = std::getenv("PATH"); p) search = p;
142+
if (search.empty()) return std::nullopt;
143+
144+
std::vector<fs::path> dirs;
145+
for (std::size_t pos = 0; pos <= search.size(); ) {
146+
const auto sep = search.find(':', pos);
147+
const auto stop = sep == std::string::npos ? search.size() : sep;
148+
if (stop > pos) dirs.emplace_back(search.substr(pos, stop - pos));
149+
pos = stop + 1;
150+
}
151+
152+
for (auto const& program : plan.programs) {
153+
bool found = false;
154+
for (auto const& dir : dirs) {
155+
if (fs::exists(dir / program, ec)) { found = true; break; }
156+
ec.clear();
157+
}
158+
if (!found) return program;
159+
}
160+
return std::nullopt;
161+
}
162+
105163
export int doctor_report() {
106164
int warns = 0, errors = 0;
107165
auto ok = [](std::string_view m) { mcpp::ui::status("ok", m); };
@@ -650,6 +708,33 @@ export int doctor_report() {
650708
header->string()));
651709
}
652710
}
711+
712+
// WHETHER nvcc CAN REACH ITS OWN BACK-END
713+
//
714+
// A toolkit can be present, complete and on PATH and still fail
715+
// at the first stage, because nvcc resolves cicc, ptxas and
716+
// fatbinary as bare names on a PATH it prepends from an
717+
// `nvcc.profile` beside its binary. A container or sandbox that
718+
// replaces /etc removes that profile -- it is a symlink into it
719+
// on Debian-family packaging -- and nvcc then states no PATH and
720+
// reports `sh: 1: cicc: not found`. The message names neither
721+
// nvcc nor the profile, and nothing about the toolkit is missing,
722+
// so the user has nowhere to look.
723+
//
724+
// Asked rather than assumed: `--dryrun` prints the plan without
725+
// running it, so the answer is nvcc's own.
726+
if (auto missing = unreachable_device_stage(); missing) {
727+
warn(std::format(
728+
"nvcc cannot reach its own back-end: it invokes '{}' by "
729+
"name, and that name does not resolve on the search path "
730+
"it states.\n"
731+
" The toolkit is installed; what is missing is the "
732+
"`nvcc.profile` that prepends the toolkit's own bin "
733+
"directory. This is what a container or sandbox that "
734+
"replaces /etc removes. Device code will fail to compile "
735+
"with a message naming only '{}'.",
736+
*missing, *missing));
737+
}
653738
}
654739
}
655740

src/toolchain/devicehost.cppm

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ struct HostCompilerBounds {
3939
// Parse the two guards out of a `crt/host_config.h`.
4040
HostCompilerBounds parse_host_config(std::string_view header);
4141

42+
// The plan nvcc states for one compilation: the search path it will use, and
43+
// the programs it will invoke by bare name.
44+
//
45+
// WHY THE PLAN IS ASKED FOR RATHER THAN ASSUMED
46+
//
47+
// nvcc runs its back-end stages -- cicc, ptxas, fatbinary, nvlink -- as bare
48+
// names, on a PATH it prepends itself from an `nvcc.profile` beside its own
49+
// binary. Where those stages live is therefore not a property of the toolkit
50+
// layout that mcpp could tabulate: it is whatever that profile says. When the
51+
// profile is unreachable -- a container or sandbox that replaces /etc, where
52+
// the profile is a symlink into it -- nvcc states no PATH, keeps the ambient
53+
// one, and fails at the first stage with `sh: 1: cicc: not found`. That
54+
// message names neither nvcc nor the profile, and the toolkit is present and
55+
// intact, so every obvious check passes. `--dryrun` prints the same plan
56+
// without running it, which is how the question is answered before a
57+
// compilation is attempted.
58+
struct DeviceDryRun {
59+
// The PATH nvcc assigns itself. Empty means it assigned none, in which
60+
// case the ambient PATH is what its stages will be resolved against.
61+
std::string searchPath;
62+
// Stages invoked by bare name, in first-appearance order, deduplicated.
63+
// A stage named by an absolute or relative path resolves on its own and
64+
// is not collected.
65+
std::vector<std::string> programs;
66+
};
67+
68+
// Parse the `#$` lines of `nvcc --dryrun` output.
69+
DeviceDryRun parse_dryrun(std::string_view text);
70+
4271
// Is `major` of `family` ("gcc" | "clang") within the bounds? A family the
4372
// header said nothing about is accepted: silence is not a refusal.
4473
bool host_compiler_accepted(const HostCompilerBounds& b,
@@ -87,6 +116,51 @@ HostCompilerBounds parse_host_config(std::string_view header) {
87116
return b;
88117
}
89118

119+
DeviceDryRun parse_dryrun(std::string_view text) {
120+
DeviceDryRun plan;
121+
122+
for (std::size_t pos = 0; pos <= text.size(); ) {
123+
const auto eol = text.find('\n', pos);
124+
const auto stop = eol == std::string_view::npos ? text.size() : eol;
125+
std::string_view line = text.substr(pos, stop - pos);
126+
pos = stop + 1; // past the end after the last line: the loop stops
127+
128+
// Every line nvcc contributes is prefixed; anything else is a
129+
// diagnostic and says nothing about the plan.
130+
constexpr std::string_view kPrefix = "#$ ";
131+
if (!line.starts_with(kPrefix)) continue;
132+
line.remove_prefix(kPrefix.size());
133+
while (!line.empty() && (line.front() == ' ' || line.front() == '\t'))
134+
line.remove_prefix(1);
135+
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
136+
line.remove_suffix(1);
137+
if (line.empty()) continue;
138+
139+
// The first token ends at whitespace or at the `=` of an assignment,
140+
// whichever comes first. An assignment is `NAME=value` with no space
141+
// before the `=`; a command is anything else.
142+
std::size_t end = 0;
143+
while (end < line.size() && line[end] != ' ' && line[end] != '\t'
144+
&& line[end] != '=')
145+
++end;
146+
147+
if (end < line.size() && line[end] == '=') {
148+
if (line.substr(0, end) == "PATH")
149+
plan.searchPath = std::string(line.substr(end + 1));
150+
continue;
151+
}
152+
153+
std::string_view program = line.substr(0, end);
154+
// A stage nvcc names by path resolves without the search path.
155+
if (program.find('/') != std::string_view::npos
156+
|| program.find('\\') != std::string_view::npos
157+
|| program.starts_with("\"")) continue;
158+
if (std::ranges::find(plan.programs, program) == plan.programs.end())
159+
plan.programs.emplace_back(program);
160+
}
161+
return plan;
162+
}
163+
90164
bool host_compiler_accepted(const HostCompilerBounds& b,
91165
std::string_view family, int major)
92166
{

0 commit comments

Comments
 (0)