Skip to content

0.4.0: every payload a compile reads is in the build graph - #12

Merged
speak-agent merged 3 commits into
mainfrom
fix/tracked-payload-dependencies
Sep 8, 2026
Merged

0.4.0: every payload a compile reads is in the build graph#12
speak-agent merged 3 commits into
mainfrom
fix/tracked-payload-dependencies

Conversation

@speak-agent

Copy link
Copy Markdown
Member

Every payload a compile reads is in the build graph

Two mechanisms carry that, and which one applies is decided by when the thing is known.

Discovered while the compiler runs: a shader's #include

a.input() is fixed when build.mcpp runs, before the compiler has read a line. A shader or kernel that includes another file therefore had no edge to it: editing that file rebuilt nothing and the build stayed green over a stale artifact. grep depfile rules/ returned nothing across all six rules.

Every spelling was measured against the tool, not read from its help text:

rule driver flag what it actually wrote
spirv glslangValidator --depfile <f> out.spv: scale.comp ./common.glsl
spirv glslc -MD -MF <f> out2.spv: scale.comp common.glsl
slang slangc -depfile <f> out.spv: <entry>.slang <included>.slang
cuda nvcc -MMD -MF <f> n.o : k.cu \ common.cuh
hip / sycl clang++ -MMD -MF <f> c.o: k.cpp common.cuh
ascendc bisheng -MMD -MF <f> k2.o: k.asc inc.h

ascendc is the one lane with no CI consumer — there is no Ascend runner and the toolkit is a 4 GB download — so "bisheng is clang-derived, so it should support it" would have shipped an unverified flag into the only rule nothing checks. The toolkit is on this machine, so it was asked.

-MMD and not -MD, matching what mcpp passes for its own C and GAS units: user includes only. -MD on BiSheng was measured pulling in fifty host headers under /usr/include, which makes an object depend on absolute host paths a shared build directory must not carry.

Discovered by nobody: .incbin

The assembler opens the embedded file at assembly time, and the generated .S's own text does not change when the payload does, so the object is assembled once. Reproduced against the published 0.3.0, in a sandbox, from packages resolved through the index:

before: bytes=1480
after:  bytes=1480      and `Finished dev in 0.06s` — nothing rebuilt at all

Asking the assembler does not fix it, and that was measured: the compiler driver's -MD is a preprocessor channel that never sees .incbin; GNU as names it in its own --MD; clang's integrated assembler has no dependency output of any kind (-Wa,-MD, -Wa,--dependency-file and clang -cc1as -dependency-file are all rejected). Tracking it that way would work under GCC and fail silently under Clang — worse than failing under both.

The fix is not a new channel. It is to stop writing the file at the wrong time.

An mcpp::action declares its inputs. Make the generation an action and the payloads its declared inputs, and the edge is an ordinary one, expressed with the graph primitive the engine already has. Prototyped before this was written: BYTES=64 -> 192, with no engine change at all.

Traced file by file: the payload's mtime moves, the .S's does not (its content is unchanged, so write_if_different leaves it), and the object's does — ninja reruns the action because a declared input changed, and without restat its outputs count as new. Remove the payload inputs and the object stops moving and the byte count stands still. Both directions measured.

What that required, and why each part is right on its own terms

1. mcpp.plugins.surface imports only std. It used to read mcpp::target_os(), mcpp::compiler() and mcpp::package_name(); those are parameters now. A generator that takes its inputs rather than reading its environment is the same code in a build program and in a program — and an action's command has to be a program. Measured before this: an ordinary build of this package failed with mcpp: failed to read compiled module.

2. mcpp-embed is built from this package, through tools = ["mcpp-embed"] on the same dependency edge that brings the rules in. Not published as a payload: docs/05 §2.14 gives the reason and it is not convenience — "the tool's version IS the dependency's version, so a protoc that does not match its runtime is not expressible. This is the problem with packaging the tool separately, and it is the failure mode that bites at run time rather than compile time." The generator and the declarations it writes are one decision.

3. src/declare.cppm is a second unit, carrying the build-program half: mcpp::action, mcpp::dep_bin, and the decision of which route a storage takes. It needs mcpp 2026.9.8.1, in which a package's host modules are ordered by their import graph rather than by their paths — before that, rules/ sorted before src/ and importing it failed. (mcpp-community/mcpp#589 is that fix, and its own account notes that this package's 700-line lib root was adopted because that sort was misread.)

Two actions rather than one, and that is a fact about inputs: the interface is a function of the item list, the body of the item list and the payloads. One action would rewrite the interface whenever a payload changed and rebuild every BMI importing it. (mcpp::action::provides is also per-action rather than per-output, so one action with three outputs and one provides makes the scanner report "already provided by" — measured.)

Only object needs any of it. header reaches the artifact through generated data headers the payload's own compiler already writes as action outputs; sidecar is never compiled at all — both checked rather than assumed. The default path builds no tool, and a consumer that never opts into object storage writes nothing extra.

Criteria

Four CI steps, all with the criterion on the artifact's bytes — not the exit code (the defective build exits 0) and not a log line (mcpp build passes ninja --quiet on a successful non-verbose build, so grepping for an edge's description is vacuous either way).

  1. An edited shader reaches the artifact under every storage. The denominator is the point: header storage was already correct, so a run where only object fails names the mechanism, and a run where all three fail names the harness. The three storage steps that already existed assert structure — is there an .incbin, is the section aligned — and all three passed against 0.3.0 while the artifact was stale.
  2. An edited include reaches the artifact, on the header fixture. Measured: run against object storage it fails when either mechanism is missing, so a red run would not say which.
  3. Every rule declares a depfile, denominator from ls rules/*.cppm, so a seventh rule is counted the day it is added.
  4. Object storage without the tool is refused, naming the fix. The tool is default-off, so the message is a contract: asserted on the three strings a reader needs — the tool, the key that supplies it, and the storage that needs none.

All four were run verbatim under bash -e with $GITHUB_WORKSPACE set, because this workflow cannot run until mcpp 2026.9.8.1 is published and a shell bug would otherwise cost a release cycle.

Also

  • options::store changed shape, so this is a breaking release: 0.3.x → 0.4.0.
  • storage::object requires item::payload_path, and now refuses without it. The constraint had been written in a comment with nothing enforcing it; that it holds today is a coincidence, not a guarantee.
  • rules/spirv.cppm's comment on options::module_name said the default derives from the package directory, while the function it calls opens with "THE PACKAGE'S NAME. NOT ITS DIRECTORY'S."

Depends on mcpp-community/mcpp#589 being released: this CI downloads a released mcpp and pins MCPP_VERSION: 2026.9.8.1.

Every payload a compile reads is now in the build graph. Two mechanisms carry
that, and which one applies is decided by WHEN the thing is known.

DISCOVERED WHILE THE COMPILER RUNS: a shader's `#include`
--------------------------------------------------------

`a.input()` is fixed when `build.mcpp` runs, before the compiler has read a
line, so a shader or kernel that includes another file had no edge to it:
editing that file rebuilt nothing and the build stayed green over a stale
artifact. `grep depfile rules/` returned nothing across all six rules.

每一种拼法都用真实输出验过,不是照着文档写的:

  glslangValidator --depfile  ->  out.spv: scale.comp ./common.glsl
  glslc -MD -MF               ->  out2.spv: scale.comp common.glsl
  slangc -depfile             ->  out.spv: <entry>.slang <included>.slang
  nvcc -MMD -MF               ->  n.o : k.cu \ common.cuh
  clang++ -MMD -MF            ->  c.o: k.cpp common.cuh
  bisheng -MMD -MF            ->  k2.o: k.asc inc.h

ascendc 是唯一没有 CI 消费者的 lane,「bisheng 是 clang 血统所以应该支持」这种理由
会把未验证的旗标发进唯一没人检查的规则里。工具包在本机,于是问了它。`-MMD` 而不是
`-MD`:后者在 BiSheng 上实测拉进五十个 `/usr/include` 宿主头,让目标文件依赖共享构建
目录不该携带的绝对路径。

DISCOVERED BY NOBODY: `.incbin`
-------------------------------

汇编器在汇编期打开被内嵌的文件,而生成的 `.S` 自己的文本并不随载荷改变,于是目标文件
只被汇编一次。在已发布的 0.3.0 上、在沙箱里、对着索引解析出来的包实测:改一个 shader,
程序打印的还是上一版载荷的字节数(1480 -> 1480),而 header 存储是 1480 -> 1776。

问汇编器不行,这是实测的:编译器驱动的 `-MD` 是预处理器通道看不见 `.incbin`;GNU as 的
`--MD` 报得出来;clang 的集成汇编器根本没有依赖输出(三种拼法全被拒)。采信它会让这条
依赖在 GCC 上被跟踪、在 Clang 上静默缺失 —— 比两边都缺失更坏。

**修法不是给引擎加一条通道,而是不要在错误的时刻写那个文件。** 一条 `mcpp::action`
声明它的输入;把生成变成 action、载荷作它的声明输入,这条边就是普通的边,用的是引擎
已有的唯一图原语。原型先证后写:`BYTES=64 -> 192`,零引擎改动。

机制逐文件追踪过:载荷 mtime 前移、`.S` 不动(内容没变)、`.o` 前移 —— ninja 因 action
的声明输入变化而重跑它,无 `restat` 于是其 output 视为新的,汇编边随之重跑。去掉载荷
输入,`.o` 不动、字节停住。两个方向都量过。

这要求三件事,而每一件本身都是对的
--------------------------------

1. **`mcpp.plugins.surface` 只 import `std`。** 它原先读 `mcpp::target_os()`、
   `mcpp::compiler()`、`mcpp::package_name()`;现在这些是参数。一个取输入而不读环境的
   生成器,在构建程序里和在普通程序里是同一份代码 —— 而 action 的命令必须是程序。
   实测:此前这个包**不能**普通构建,`mcpp: failed to read compiled module`。

2. **`mcpp-embed` 由 mcpp 从本包源码造**,走 `tools = ["mcpp-embed"]`,与规则同一条
   依赖边。不单独发包:`docs/05` §2.14 写明代价 —— 「工具的版本**就是**依赖的版本,
   所以 protoc 与它的 runtime 不匹配这件事不可表达」。生成器与它写下的声明是一个决定。

3. **`src/declare.cppm` 是第二个单元**,承载构建程序侧(`mcpp::action` / `dep_bin` /
   按存储分派)。它需要 mcpp 2026.9.8.1:此前一个包的 host module 按**路径**排序,
   `rules/` 在 `src/` 之前,import 它会失败。

两条 action 而不是一条,这是关于输入的事实而非限制:接口是条目表的函数,实现体还是
载荷的函数。合成一条会让改一个载荷去重写接口、重建每个 import 它的 BMI。
(`mcpp::action::provides` 也是 action 级的,一条 action 三个 output 一个 provides
会让扫描器报 "already provided by" —— 实测。)

只有 `object` 需要这一切。`header` 的字节经由载荷编译器已经写出的数据头(本就是 action
的 output)到达产物,`sidecar` 根本不进编译 —— 都检查过而不是假定。所以默认路径不造
任何工具,不选 object 存储的消费者一个字都不用多写。

其它
----

`options::store` 改变了形状,所以这是破坏性发布:0.3.x -> 0.4.0。

`storage::object` 要求 `item::payload_path`,现在它拒绝 —— 这条约束一直写在注释里而
没有任何东西执行它。今天只有 spirv 能走到这条路且只有它设这个字段,那是巧合不是保证。

`rules/spirv.cppm` 里 `options::module_name` 的注释说默认值取自「包目录」,而它调用的
函数开头就写着「THE PACKAGE'S NAME. NOT ITS DIRECTORY'S.」。

CI 四条步骤,判据都落在产物的字节上,不落退出码也不落日志行;第四条断言「没要工具就
用 object 存储」被拒绝且点名了要加的键 —— 消息是契约。
这一步比较包里每一个 `[features.<x>]` 与夹具激活的那一列,好让第七个成员不能被悄悄漏出
「every rule module compiles for this host」。`[features.surface]` 让它红了,而且红得对:
那是一个夹具没点名的新 feature。

但它不是成员。它承载 surface 的构建程序那一半,每个成员都 implies 它,消费者永远不写它。
把它加进夹具是错的修法 —— 夹具那一列的含义是「消费者能激活的成员」,为了让检查变绿而往里
填东西,只会让检查的含义变少。

所以分母减去「被别的 feature implies 的」。这条规则从 manifest 推出来而不是列在这里,正是
这个检查原本就有的性质:第七个**成员**仍会被抓到,因为没有东西 implies 它。

两条腿都验过:修正后两列一致;临时加一个新成员,它出现在分母里并会让这一步失败。
@Sunrisepeak
Sunrisepeak force-pushed the fix/tracked-payload-dependencies branch from b15a424 to f90b771 Compare September 8, 2026 02:00
「every rule module compiles for this host」这一步在 `consumers` 与
`rules-cross-platform` 里各有一份。上一次只改了第一份 —— 而那个 job 本来就是绿的;
红的是另一个。

**做这次替换的脚本在锚点「存在」时就放行了。存在不等于唯一,而这个差别就是缺陷本身:**
它验证了自己将要改的东西在,没验证它是唯一的一处。现在按**出现次数**断言,并在写回前
再数一次两个 job 是否都拿到了修好的形状。

顺带把 `<(...)` 换成临时文件:这一步也在 windows-2022 的 Git Bash 上跑,进程替换在那里
是模拟的、不可依赖 —— 而「一个在三台宿主上行为不同的检查」正是这个 job 存在的理由所要
抓的东西,它自己不能引入一个。
@speak-agent
speak-agent merged commit 9249553 into main Sep 8, 2026
3 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