Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
633 changes: 633 additions & 0 deletions .agents/docs/2026-08-15-issues-426-427-analysis.md

Large diffs are not rendered by default.

22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,21 @@ data: [`standard-20260814-linux-x86_64`](bench/results/standard-20260814-linux-x
skips the cascade when the interface is unchanged. This is default behaviour
and requires no configuration. The `mcpp (old)` column measures the previous
release at 81.72s, level with cmake, so the effect is new in this revision.
* **`edit-body` is the control case**, and it is the row where the cascade is
genuinely owed: mcpp is 1.1x rather than 200x, and an engine faster here would
be omitting work. `+opt` does not omit it either — it performs the same work
2.9x faster. Worth stating precisely: the perturbation **inserts a line**, and
under GCC that shifts the recorded source location of every declaration after
it, which changes the BMI. The cascade follows from the changed BMI, not from
the edited body — measured in
* **`edit-body` measures the case where the cascade is genuinely owed** — and
whether an edit owes one depends on where the body lives:

| the function body is in… | editing it | this row |
|---|---|---|
| a `.cppm`, and the edit **moves lines** | GCC records declaration positions, so the BMI changes → cascade owed | **what is measured: 1.1x, and 2.9x with `+opt`** |
| a `.cppm`, edited **in place** (same line count) | GCC does not serialise non-template bodies → BMI unchanged → no cascade | ~200x, like `touch-hub` |
| a separate `.cpp` implementation unit | that file has no BMI at all → no cascade, on every compiler | ~200x |

The perturbation here inserts a statement, so it takes the first row: every
engine has to rebuild the importers, and one that did not would be skipping
work. `+opt` does not skip it either — it does the same work 2.9x faster.
Splitting interface from implementation is the sturdiest of the three, because
it does not depend on GCC's body handling or on avoiding line shifts.
Measured in
[`.agents/docs/2026-08-15-module-edit-granularity.md`](.agents/docs/2026-08-15-module-edit-granularity.md).
* **`bmi_schedule` is opt-in and disabled by default** (`auto` resolves to off).
It moves code generation off the critical path, so it helps only where a
Expand Down
16 changes: 12 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,18 @@ cmake 4.4.2 / xmake 3.1.0 · `-` 表示未测,本表没有 ·
cmake 与 xmake 按时间戳判断,重编全部下游单元;mcpp 将编译器刚产出的 BMI 与上
一份比较,接口未变则不触发级联。这是默认行为,无需任何配置。`mcpp (旧版)` 一列
测得上一个发布版为 81.72s,与 cmake 同量级,因此该效果在本版本中才生效。
* **`edit-body` 为对照组**,也是级联确实欠着的那一行:mcpp 为 1.1x 而非 200x,
在这一行更快的引擎意味着省略了应做的工作。`+优化` 同样不省略,只是把同一份工作
加快 2.9 倍。有一点需要说准:该扰动是**插入一行**,而 GCC 会因此移动其后所有声明
的源码位置记录,BMI 随之改变。级联来自变化后的 BMI,而非被编辑的函数体 —— 实测见
* **`edit-body` 量的是级联确实欠着的那种改动** —— 一次改动欠不欠级联,取决于函数体
写在哪里:

| 函数体所在 | 改动它 | 对应本行 |
|---|---|---|
| `.cppm`,且改动**移动了行号** | GCC 在 BMI 里记录声明位置,BMI 随之改变 → 欠级联 | **本行所测:1.1x,`+优化` 2.9x** |
| `.cppm`,**原地等长**修改 | GCC 不序列化非模板函数体 → BMI 不变 → 不级联 | 约 200x,与 `touch-hub` 同档 |
| 独立的 `.cpp` 实现单元 | 该文件根本不产生 BMI → 不级联,且跨编译器成立 | 约 200x |

这里的扰动插入一条语句,因此落在第一行:所有引擎都必须重建导入者,更快只能意味着
漏做。`+优化` 也不漏做,只是把同一份工作加快 2.9 倍。三者中**接口与实现分离最稳**,
因为它既不依赖 GCC 对函数体的处理方式,也不依赖你避免行号移动。实测见
[`.agents/docs/2026-08-15-module-edit-granularity.md`](.agents/docs/2026-08-15-module-edit-granularity.md)。
* **`bmi_schedule` 为 opt-in,默认关闭**(`auto` 解析为 off)。它将代码生成移出关键
路径,因此仅在级联必需时有效:`cold` 86.69s → 35.73s、`edit-body` 80.87s →
Expand Down
11 changes: 7 additions & 4 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ than what it said.
| bazel | **9.2.0** | `matrix.json` → `tools` |
| gcc | **16.1.0** | `bench/src/toolchain.cppm` |
| clang / libc++ | **22.1.8** (Windows: 20.1.7) | `bench/src/toolchain.cppm` |
| reference mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp` |
| reference mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp`; the run records which release it actually resolved in `meta.json`, and the report names it in the column header |
| mcpp (the workload) | **2026.8.11.3** — `a749e9f` | submodule `projects/mcpp/mcpp-2026.8.11.3` |
| xlings (combined style) | **2026.8.11.2** — `b1563fe` | submodule `projects/xlings/xlings-2026.8.11.2` |
| xlings (split style) | **2026.8.13.1** — `f072075` | submodule `projects/xlings/xlings-2026.8.13.1` |
Expand Down Expand Up @@ -325,9 +325,12 @@ Four things this says, and the fixture can say none of them:
touching a hub interface costs cmake and xmake a full 83-second rebuild
because they decide by timestamp, and 0.40s for an engine that compares the
BMI it just produced against the previous one.
4. **`edit-body` is the control.** mcpp is deliberately *not* fast there (0.89x):
the interface genuinely changed, so the cascade is owed. An engine that were
fast on that row would have skipped work it owed.
4. **`edit-body` is the control.** mcpp is deliberately *not* fast there
(0.89x): the perturbation inserts a statement into an interface unit, which
moves the source position GCC records for every declaration after it, so the
BMI changes and the cascade is owed. An engine that were fast on that row
would have skipped work it owed. A body edit that does NOT move lines, or one
in a separate `.cpp`, owes no cascade and mcpp skips it — see §7.

> **The xmake column is from a SEPARATE run.** Its numbers in the original
> five-arm run were invalid — xmake normalises `--buildir` to a path relative to
Expand Down
7 changes: 5 additions & 2 deletions bench/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ __format/format_functions.h:99:30: error: call to implicitly-deleted default
| bazel | **9.2.0** | `matrix.json` → `tools` |
| gcc | **16.1.0** | `bench/src/toolchain.cppm` |
| clang / libc++ | **22.1.8**(Windows:20.1.7) | `bench/src/toolchain.cppm` |
| 参照 mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp` |
| 参照 mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp`;每次跑把实际解析到的版本写进 `meta.json`,报告的表头直接写出它 |
| mcpp(被测工作负载) | **2026.8.11.3** — `a749e9f` | 子模块 `projects/mcpp/mcpp-2026.8.11.3` |
| xlings(合并风格) | **2026.8.11.2** — `b1563fe` | 子模块 `projects/xlings/xlings-2026.8.11.2` |
| xlings(分离风格) | **2026.8.13.1** — `f072075` | 子模块 `projects/xlings/xlings-2026.8.13.1` |
Expand Down Expand Up @@ -278,12 +278,15 @@ SNAPSHOT」。
| `noop` | 什么都不动 | 「已经是最新」有多便宜 |
| `touch-hub` | 给被大量 import 的单元改 mtime,**内容不变** | 引擎能不能证明接口没变? |
| `edit-comment` | 往同一个单元里插一条注释 | 字节**确实**变了但接口没变 —— 只有比较产出 BMI 的引擎能止住级联 |
| `edit-body` | 函数体内部一处真实语义修改 | 日常循环。接口单元里的内联函数体,BMI 合理地变了,级联是**对的** |
| `edit-body` | 函数体内部一处真实语义修改(插入一条语句) | 日常循环。改动移动了行号,接口单元的 BMI 因此改变,级联是**对的** |
| `touch-leaf` | 给没人 import 的单元改 mtime | 重编 1 个 + 链接 |

`edit-comment` 与 `edit-body` 是**刻意分开**的:不分开的话,一个能跳过纯注释重建
的引擎就可以宣传成「改代码快 12 倍」,而那实际上是一句关于注释的话。
`edit-body` 是反方向的对照 —— 那里没有引擎应该快,快了就是漏了该做的活。
一次函数体修改欠不欠级联,取决于**函数体写在哪里**、以及改动是否移动行号:
`.cppm` 里移动行号 ⇒ 欠;`.cppm` 里原地等长修改 ⇒ 不欠;独立 `.cpp` ⇒ 不欠,
且跨编译器成立。详见 `SPEC.md`。

---

Expand Down
64 changes: 50 additions & 14 deletions bench/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ table that was measuring something other than what it said:
| the workloads | git submodules under `bench/projects/` | xlings was cloned from its default branch at run time (`--hub src/xlings.cppm` named a file that had stopped existing); **mcpp's own sources were the checkout**, so every commit on a branch changed the thing being measured |
| the reference mcpp | `matrix.json.reference_mcpp` | a report said how fast this branch is, never whether it got faster |

> The reference pin is **not** required to equal the `.xlings.json` workspace
> pin. A guard once required that, on the theory that the reference arm is
> whatever CI bootstraps; neither half holds (the standard set runs on a
> developer box, and the bootstrap pin is a self-hosting floor that may lag a
> release), and bumping the pin after a release turned every e2e shard red on
> `main`. `run-standard.sh` resolves the arm by exact version and requires the
> binary to report that version itself, so a mismatch drops the column with a
> note instead of measuring the wrong release.

`--compiler payload:gcc` / `payload:clang` is the spelling that delivers the
second row: it resolves to the driver **inside mcpp's own registry**, so every
engine is handed the same binary. That is the suite's fairness rule
Expand Down Expand Up @@ -233,7 +242,7 @@ job: the cell still runs, and its note says what to distrust.
| `noop` | nothing | how cheap is "already up to date" |
| `touch-hub` | mtime bump on a widely-imported unit, **content unchanged** | can the engine prove the interface did not change? |
| `edit-comment` | a comment inserted into that same unit | the bytes *did* change but the interface did not — only an engine that compares the produced BMI avoids the cascade |
| `edit-body` | a real semantic edit inside a function body | the everyday loop — and whether a cascade is owed depends on **where the body lives**, not on the edit. See below. |
| `edit-body` | a real semantic edit inside a function body | the everyday loop — and whether a cascade is owed depends on **where the body lives and whether the edit moves lines**, not on what the body now does. See below. |
| `touch-leaf` | mtime bump on a unit nobody imports | recompile 1 + link |

#### ⚠️ `edit-body` perturbs a DIFFERENT FILE in each variant, and the two ask
Expand Down Expand Up @@ -301,25 +310,52 @@ Measured directly, GCC 16.1, comparing the BMI before and after:

| what is edited | BMI | cascade |
|---|---|---|
| a free exported function's body, in the `.cppm` | **byte-identical** | not owed |
| a **member function of an exported class**, inline in the `.cppm` | **differs** | **owed** |
| a body in a separate `.cpp` implementation unit | **byte-identical** | **not owed** |

A class's member function bodies are part of the class definition, which every
importer has to see, so they are serialised into the BMI. A free function's body
is not, and nothing in an implementation unit is.

So "editing one function rebuilt forty modules" is not inherent to named modules
— it is a consequence of where the body was written. `mcpp`'s own
`src/version_req.cppm` is the first case (the perturbation lands in
`Version::str()`, a member of an exported class), which is why its `edit-body`
row is a near-full rebuild and why that is correct.
| a body in a `.cppm`, edit **moves lines** (inserts or deletes one) | **differs** | **owed** |
| a body in a `.cppm`, edited **in place** (same line count) | **byte-identical** | not owed |
| a body in a separate `.cpp` implementation unit | **no BMI exists** | not owed |

GCC 16.1 does not serialise non-template function bodies, so changing what a
body *does* is invisible to importers. What it does serialise is the source
position of each declaration — so inserting a line moves every declaration
after it and the BMI changes for that reason alone.

⚠️ **An earlier version of this section said the deciding factor was whether the
body belonged to an exported class.** That was reasoning, and the measurement
refuted it: editing `Version::str()` — a member of an exported class — in place
rebuilt its object and left the BMI byte-identical, so no importer was touched.
The deciding factor is line movement, not class membership.

Two consequences:

* "editing one function rebuilt forty modules" is not inherent to named modules.
It follows from the edit moving lines in an interface unit.
* the third row is the sturdiest, because it holds for **every** compiler and
for every edit: a `.cpp` implementation unit produces no BMI, so nothing
downstream can depend on its contents. Clang, whose BMI carries more than
GCC's, cascades on an in-place body edit in a `.cppm` but not on a `.cpp`.

**This is what the two xlings pins measure.** Moving the implementations out of
the interface units takes `edit-body` from 88.33s to **1.77s** on the same
project — ~50x, the largest single effect anywhere in this suite, and a code
style rather than an engine feature.

#### KNOWN GAP: there is no scenario for an in-place body edit

The three rows in the table above are not equally covered. `edit-body` inserts a
statement, so only the **first** row is ever measured; the second — a semantic
edit that keeps the line count — has no scenario at all.

That is the everyday case, and it is the only one that would show cascade
suppression on a *real code change* rather than on a timestamp (`touch-hub`) or
a comment (`edit-comment`). Its absence makes the published tables read as
though the effect applies only when the code does not change, which understates
it.

Closing it is a `replace_in_first_body` beside `insert_into_first_body` (an
equal-length substitution, e.g. one integer literal for another of the same
width) plus a scenario token — and a re-run of the standard set, which is why it
is recorded here rather than half-added with no data behind it.

Real projects run five of the six: `touch-leaf` needs a unit nobody imports
*and* a stable name for it, which a generated fixture has by construction and a
real tree does not.
Expand Down
26 changes: 26 additions & 0 deletions bench/run-standard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,32 @@ echo "standard set: $(printf '%s\n' "$PLAN" | wc -l) cells, ${RUNS} run(s) each,
echo "output : ${OUT#"$ROOT"/}"
echo

# WHICH RELEASE THE `mcpp (old)` COLUMN ACTUALLY MEASURED, recorded rather than
# asserted. `matrix.json` states a REQUEST; this file states the OUTCOME — the
# path that was resolved and the version that binary reported about ITSELF (the
# loop above refuses any binary whose `--version` disagrees).
#
# This replaces a guard that required `reference_mcpp` to equal the `.xlings.json`
# bootstrap pin. Making the report self-describing removes the drift instead of
# policing it: a reader never has to hold two files in their head to know what
# the "old" column is, and the two pins are free to be what they each are.
mkdir -p "$OUT"
python3 - "$OUT/meta.json" "$REFERENCE_MCPP" "$REF_BIN" "$UNDER_TEST" <<'PY'
import json, sys
out, requested, path, under_test = sys.argv[1:5]
json.dump({
"schema": 1,
"reference_mcpp": {
"requested": requested or None,
# null ⇒ not resolved on this machine; the old-vs-new column is absent
# from the report rather than silently filled by another release.
"measured": (requested if path else None),
"binary": path or None,
},
"under_test": under_test or None,
}, open(out, "w", encoding="utf-8"), indent=2)
PY

# ⚠️ NOT `printf ... | while`. A pipeline runs its right-hand side in a SUBSHELL,
# so a failure counter incremented inside the loop does not survive it and the
# script exits 0 no matter what happened. Verified with a stub engine that exits
Expand Down
10 changes: 9 additions & 1 deletion bench/tools/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,15 @@ def short_name(engine, newest, lang="en"):
return "mcpp +优化" if lang == "zh" else "mcpp +opt"
if base == newest:
return "mcpp"
return "mcpp (旧版)" if lang == "zh" else "mcpp (old)"
# ⚠️ NAME THE RELEASE, do not write "old". The version is already in the
# engine key — it is the version that binary reported about ITSELF — so a
# header that hides it is throwing away the one fact that makes the column
# checkable. A guard used to enforce this from outside, by requiring
# `matrix.json`'s `reference_mcpp` to equal the `.xlings.json` bootstrap
# pin; that coupled a bench knob to the release pipeline and turned `main`
# red when the pin moved. Printing what was measured removes the drift
# instead of policing it.
return "mcpp " + base[len("mcpp@"):]


def columns_legend(short, engines, lang):
Expand Down
7 changes: 7 additions & 0 deletions docs/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,13 @@ The **compile** phase is not bounded, only the build *program*. See
runs it. It is a **distribution** property, not a build one — it describes the
runtime dependency set, and the flags that deliver it differ per platform.

> **A target with no C++ in it has no C++ runtime contract to honour.** mcpp
> links such a target with the C driver and leaves the C++ runtime flags off the
> line entirely, so a pure-C shared library does not acquire a dependency on
> `libstdc++`/`libc++` it has no use for. One C++ translation unit anywhere in
> the target puts the whole target back on the C++ driver. This is derived from
> the sources — there is no key for it.

```toml
[build]
cxx_runtime = "self-contained" # applies to every target (the default)
Expand Down
5 changes: 5 additions & 0 deletions docs/zh/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@ MCPP_BUILD_PROGRAM_TIMEOUT=<秒> 本次调用(最高)
`cxx_runtime` 声明的是**产物对运行它的机器做出的承诺**。它是**分发**属性而非
构建属性 —— 它描述的是运行期依赖集,而兑现它的 flag 逐平台不同。

> **不含 C++ 的目标没有 C++ 运行时契约需要兑现。** mcpp 用 C 驱动链接它,
> 并且完全不发 C++ 运行时相关的 flag,因此一个纯 C 的共享库不会平白拿到
> `libstdc++` / `libc++` 依赖。目标里只要有一个 C++ 翻译单元,整个目标就回到
> C++ 驱动。这一判定由源码推导,没有对应的配置键。

```toml
[build]
cxx_runtime = "self-contained" # 作用于所有目标(默认值)
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "2026.8.15.1"
version = "2026.8.15.2"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
Loading
Loading