24 KiB
Agent Note: 不改变产物的构建提速
Status: implemented
English | 中文
问题
冷启动构建中有三处与产物内容无关的固定开销:
- Client 测试聚合程序的 JSX 签名。
tsc -p tsconfig.client.json的 Check 阶段为 59.1 s,其中一半以上用于为每个导入 React 的 JSX 文件计算@types/react的 module specifier。TypeScript 的computeModuleSpecifiers遍历该声明文件的每个符号链接位置,并在每个位置扫描全部 491 条 tsconfigpaths。pnpm 为每个自行声明@types/react的 workspace 包创建一个node_modules/@types/react链接;66 个 manifest 声明了它,Client 测试聚合程序因此看到 65 个位置。计数器测得 88 个 JSX 文件 × 2 个模块(index.d.ts、jsx-runtime.d.ts)× 65 个位置,共 11,457 次tryGetModuleNameFromPaths调用、5,623,782 次条目扫描,合计 32.6 s。按可见位置数 N 截断的剂量实验在五个截断级别(N 从 1 到 65,Check 从 26.92 s 到 58.40 s)上拟合出 Check ≈ 26.9 + 0.494·N s。 - Host tsdown 阶段中的 Typert。 Host tsdown 阶段约 21.6–22.6 s。Typert 在第一个
writeBundle中同步发现并生成产物,CPU profile 显示它阻塞 tsdown 主线程约 12.4 s,期间所有包构建都在等待。发现阶段占 8.06 s:它解析两个 face 聚合配置所引用的 362 个包 tsconfig(约 3.8 s),每次解析 Host 聚合配置都展开 2,064 条字面include(1.6 s,分析器从不读取这些文件名),并对 322 个注册项做公开导出表面遍历(2.7–3.2 s);最终只有 28 个声明 Typert 导出的包产生 Host 产物。 - Client tsdown 阶段的 manifest 查找。 Client tsdown 阶段约 11.7 s,主线程饱和(空闲仅 7.1%),其中 34.5%(3.95 s)在
workspaceManifest:每个包配置按包名globSync('packages/*/*/package.json')并逐个解析 manifest,直到名字匹配。模块级缓存无效,因为 tsdown 的 native 配置加载器通过import-without-cache为每个包配置创建新的模块实例;每次 glob 耗时 40–125 ms,且发生在tsdown:deps的neverBundle/alwaysBundle回调中,阻塞全部构建。
#5849 已合并 Host fixture 类型收窄与 Typert memoization;本 Note 的基线是其后的 master。
决策
以下八项提速不改变 lib/、apps/web/dist 的产物字节,也不改变仓库构建报告的类型诊断集合。唯一的诊断归属变化来自第 4 项:Typert 不校验它不分析的包 tsconfig,这些错误只由 tsc -b 报告。命令拆分与并发调度由构建拆分 Note记录。
| # | 决策 | 受影响阶段 | 实测中位数 | 产物与诊断 | 有意的行为差异 |
|---|---|---|---|---|---|
| 1 | React 类型包只由根 manifest 声明 | Client 类型检查 | 测试聚合 Check 59.1 → 26.3 s | 程序文件数与诊断不变 | 包 manifest 不列出 React 类型包 |
| 2 | Typert 先按 manifest 选择包 | Host tsdown | 22.62 → 19.94 s | 逐字节一致 | 无 |
| 3 | Typert 不展开聚合配置的 include |
Host tsdown | 19.94 → 16.99 s | 逐字节一致;注册清单一致 | 无 |
| 4 | Typert 惰性解析包 tsconfig | Host tsdown | 16.99 → 16.25 s(噪声内);Typert 发现 5.9–7.1 → 0.36–0.40 s | 逐字节一致;注册清单一致 | 未分析包的 tsconfig 错误只由 tsc -b 报告 |
| 5 | Typert 工作区根目录同时要求 packages/ |
Host tsdown | 噪声内 | 逐字节一致 | package 模式下双 face 包获得产物 |
| 6 | Typert 在 Worker Thread 中生成 | Host tsdown | 15.39 → 10.25 s | 逐字节一致;失败仍使 tsdown 失败 | Host tsdown 最大 RSS 约 +0.6 GiB |
| 7 | Typert transform 钩子按模块 id 过滤 |
Host tsdown | 噪声内;钩子调用 1,566 → 195 | 逐字节一致 | 无 |
| 8 | Client tsdown 预设按包目录读取 manifest | Client tsdown | 12.78 → 8.73 s | 逐字节一致;external 判定一致 | 预设第一个参数是包目录 |
第 5–7 项改变了发布的 ./tsdown 导出 typertPlugin()(transform 为 { filter, handler }、writeBundle 为异步、工作区根目录必须包含 packages/);rolldown 与 tsdown 的使用方接受两种钩子形式,分析器本来就要求 <root>/packages,因此此前可用的外部工作区都不会失效。
测量条件
所有数字取自同一台共享 Linux 主机(256 vCPU、4 TB 内存,运行期间 load average 23–111),Node v24.18.0、TypeScript 6.0.3、tsdown 0.22.2(rolldown 1.1.1)。
- Host tsdown:
node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host,每次运行前删除全部lib/typert.*;各变体交替顺序运行,每组 3–4 个样本。第 2–4 项取自第一轮测量(起点为基线),第 5–7 项取自第二轮测量(起点为第 2–4 项合入后的代码)。产物比较覆盖packages/*/*/lib、apps/*/lib、vendor/*/lib顶层的 743 个文件(含 140 个 Typert 产物)的 SHA-256。相同代码的两组中位数相差 0–0.11 s,组内样本跨度最大 2.5 s。 - Client 测试聚合: references 预先构建后运行
tsc -p tsconfig.client.json --extendedDiagnostics,使用全新的 tsBuildInfo 文件;相同代码 6 个样本的 Check 跨度为 3.8 s(约 6%)。 - Client tsdown: 已构建的树上运行
node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client。第 8 项的原型 3 轮轮换顺序,基线跨度 1.56 s;最终实现与原实现交替运行,各 3 个样本。产物比较覆盖{packages,apps,vendor,native}/**/lib/**(排除*.tsbuildinfo)的全部文件。
1. React 类型包只由根 manifest 声明
-
目的: 把 Client 测试聚合程序看到的
@types/react位置数 N 从 65 降到 1,消除随 N 线性增长的 module specifier 计算。 -
改动: 根
package.json的devDependencies声明@types/react~18.3.1与@types/react-dom~18.3.0,沿用各包原先统一的版本范围;apps/*、packages/*/*与vendor/*的 manifest 在任何依赖区段都不声明它们。TypeScript 从任一包向上查找时都解析到根node_modules/@types/react。runtimereact与react-dom仍按 Client 依赖声明规则由各包声明;只通过import type引用 React 的packages/client/ui-slots在devDependencies中声明react。pnpmdedupeDirectDeps保持关闭。lockfile 的变化集中在 importers 区段(另有一条 integrity 相同的papaparse条目重排)。 -
防回退:
verify-package-dependencies在hygiene与 CI 中要求根 manifest 的devDependencies声明这两个包,并拒绝apps/*、packages/*/*、vendor/*中任一 manifest 在dependencies、devDependencies、optionalDependencies或peerDependencies中声明它们;诊断指明要删除的条目,并链接 Client 依赖声明规则第 8 条。规则只匹配这两个精确包名,其他@types/*包仍按 DefinitelyTyped 类型提供方处理。依赖区段推导不把这两个包视为react/react-dom的类型提供方:包缺少react声明时,验证器报告react缺失,--fix拒绝修复并要求先声明版本范围;--fix也不写回或删除这两个包。verify-package-dependencies.spec.ts覆盖各依赖区段的拒绝、根 manifest 缺失声明、被接受的布局,以及类型提供方排除。 -
性能:
测量项 各包自声明(N = 65) 根 manifest 声明(N = 1) Client 测试聚合 Check 59.08 s(3 个样本的中位数) 26.28 s(5 个样本的中位数),−56% Client 测试聚合 wall 68–71 s 33.6–34.9 s Client 测试聚合 user CPU 79–83 s 44.5–45.3 s 76 个 client-only 包项目的 Check 总和 34.6 s(2 个样本的中位数) 26.5 s(2 个样本的中位数),−23% 76 个 client-only 包项目的 wall 55.7 s(2 个样本的中位数) 44.5 s(2 个样本的中位数) -
无损依据: 66 个包级链接与根链接指向同一个 realpath(
.pnpm/@[email protected]),因此程序读取的声明内容相同;程序文件数保持 2,613,tsc -b --force tsconfig.client.json以 0 退出且无诊断。devDependencies不随已发布的包安装,发布依赖面中独立 TypeScript 消费者自行安装所引用类型包的结论不变;runtime 解析不读取@types/*。 -
取舍: 包 manifest 不列出自己的 React 类型依赖,所有 workspace 包共用同一个 React 类型版本;某个包需要不同版本时,需要新的 Agent Note 推翻本项。检查只覆盖
apps/*、packages/*/*与vendor/*;website、benchmarks、native/system与python/sdk-runtime等其他 pnpm 成员不在范围内,目前它们都不声明 React 类型包。
2. Typert 先按 manifest 选择包
- 目的: workspace 模式只为声明 Typert 导出的包生成产物,避免对其余候选包做表面遍历。
- 改动:
WorkspaceAnalyzer.discoverPackages(includeManifest?)与WorkspaceTypertGenerator.discover(faces?, includeManifest?)接受可选的package.json谓词,在表面遍历之前应用;被拒绝的包既不读取源码,也不解析项目配置。WorkspaceTypertGenerator.generateContributors(faces?)以hasTypertExport(exports含./typert、./client/typert或./remote)为谓词。不传谓词的调用方(Cordis 目录、不指定包的generate())行为不变。 - 性能: Host tsdown 22.62 s → 19.94 s(各 3 个样本的中位数)。
- 无损依据: 谓词就是原先在发现之后应用的同一过滤条件,被它拒绝的包本来就不产生产物;743 个产物文件逐字节一致(Typert generator 自身的
lib/index.js除外,其源码即本项改动)。
3. Typert 不展开聚合配置的 include
- 目的: 分析器只读取聚合配置的编译选项与 project references,展开
include只为填充从不使用的fileNames。 - 改动:
WorkspaceCaches.aggregate(path)返回并缓存AggregateConfig { path, options, projectReferences }。解析自身声明references的聚合配置时,使用readDirectory返回空列表的ParseConfigHost;不声明references的聚合配置照常展开。 - 性能: Host tsdown 19.94 s → 16.99 s(各 3 个样本的中位数)。
- 无损依据: TypeScript 只对既无
files也无references的配置报告 TS18003(无输入),因此声明references的聚合配置在不展开时得到相同的诊断、选项与 references。强制读取每个惰性配置后导出的注册清单(408 个注册项,含fileNames与选项,以及聚合配置的选项和 references)与基线逐字节一致;产物逐字节一致。测试reads aggregate options and references without expanding include patterns与reports an aggregate without references or inputs固定这两种配置的行为。
4. Typert 惰性解析包 tsconfig
- 目的: 只解析被选中分析的包的 tsconfig,而不是聚合配置引用的全部包。
- 改动:
PackageRegistration.config是caches.config(configPath)的 getter,首次读取时解析。注册清单的顺序、face、包名、根目录与导出子路径不变。 - 性能: Host tsdown 16.99 s → 16.25 s(各 3 个样本的中位数),在 2.5 s 的组内跨度以内。第 2–4 项合计在隔离基准中把 Typert 发现从 5.9–7.1 s 降到 0.36–0.40 s,tsconfig 解析从 362 次降到 28 次,表面遍历从 322 次降到 28 次,Typert 总耗时从 9.7–11.8 s 降到 4.4–4.5 s。
- 无损依据: 注册清单比较与第 3 项相同(逐字节一致),产物逐字节一致。测试
reads only the project configs of manifests selected for discovery固定只解析所选包的配置。 - 取舍(非无损): Typert 不解析它不分析的包的 tsconfig,因此这类 tsconfig 的语法或配置错误不会使 Typert 失败。同一构建中,先于 Host bundle 运行的 Host 包编译
tsc -b与 Client 包编译tsc -b解析每个被引用的 tsconfig,并拒绝其中的错误;PackageRegistration.config的 JSDoc 与 Typert generator README 写明这一归属。单独调用 Typert 而不运行tsc -b的使用方不会得到这些诊断。
5. Typert 工作区根目录同时要求 packages/
- 目的: 修正工作区根目录的判定。24 个双 face 包带有自己的
tsconfig.host.json叶子配置;只按tsconfig.host.json向上查找时,每个这样的包都被当作根目录,workspace 模式每次构建为它们各做一次空发现,package模式下双 face 包得不到产物且没有报错。 - 改动: 工作区根目录是打包输出最近的、同时包含
tsconfig.host.json与packages/的祖先目录。 - 性能: Host tsdown 14.82 s → 15.39 s(各 3 个样本的中位数),在噪声内。
- 无损依据: workspace 模式的比较中,743 个产物文件逐字节一致;先生成顺序负责当前根构建的生成。测试
resolves the workspace root above a dual-face package leaf固定该判定。 - 行为差异:
package模式的使用方构建双 face 包时得到该包的 Typert 产物;仓库构建不使用package模式。
6. Typert 在 Worker Thread 中生成
- 目的: 让 Typert 分析与 tsdown 主线程上的打包重叠,而不是在第一个
writeBundle中阻塞全部包构建。 - 改动:
src/worker.ts是 Worker Thread 入口,运行generateContributors(faces)并回传一条artifacts或failure消息。workspace 模式下,本次构建的第一个buildStart以options.cwd定位工作区根目录并启动生成;writeBundle是异步的,第一个完成的打包认领写入并等待结果,之后的打包立即返回。分析错误、worker 崩溃或提前退出、格式错误或无法反序列化的响应、worker 无法终止,都以指明工作区与原因的TypertWorkerError使认领写入的打包失败,从而使整个 tsdown 阶段失败;结果敲定前 worker 已终止,未被认领的结果不会成为未处理的 rejection。构建产物从./worker.js启动 worker;源码运行(测试)通过tsx/esm/api加载worker.ts。package模式仍在主线程中生成。 - 性能: Host tsdown 15.39 s → 10.25 s(各 3 个样本的中位数)。最终配置(第 2–7 项)的 profile:主线程 10.1 s,第一个
buildStart出现在 4.18 s(tsdown 配置解析),主线程上的writeBundle只占 0.37 s;worker 5.0 s(模块加载约 0.4 s、发现 0.28 s、生成 3.88 s,其中createProgram2.21 s),约在 9.3 s 结束。Host tsdown 的 wall 约为 max(打包, 4.2 s + Typert)。 - 无损依据: 第二轮的 54 次运行全部以 0 退出,每次的 743 个产物文件均与基线一致(generator 自身的
lib/index.js除外)。测试writes the artifacts in-process generation produces比较 worker 与进程内生成的产物;在临时工作区中把一个包的files改坏后,tsdown 在 2.4 s 内以 1 退出,报告TypertWorkerError及 worker 内的TypertAnalysisError,不写入任何产物。各失败路径由typertPlugin workspace mode测试组覆盖。 - 取舍: worker 堆约 300–420 MiB;最终配置的 Host tsdown 最大 RSS 中位数为 6,296–6,408 MiB,基线为 5,676 MiB。共享主机高负载时 worker 可能落在关键路径上:一次样本为 21.91 s,拥有产物的包构建等待 worker 约 14 s,其后 10 个样本未复现;load average 约 100 时两次运行为 17.86 s 与 14.3 s。worker 在 1,024 MiB 堆上限下也能完成,因此不是 GC 压力;归因于主机争用尚未证实。
7. Typert transform 钩子按模块 id 过滤
- 目的: 避免每个模块都从 rolldown 进入 JavaScript
transform钩子,只为被扩展名检查拒绝。 - 改动:
transform声明filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ },处理函数仍以同一正则检查 id。 - 性能: 每次 Host 构建的钩子调用从 1,566 次降到 195 次(195 个 TypeScript 模块,其中 4 个含装饰器)。wall 中位数为 11.07 s(3 个样本)与 9.98 s(4 个样本),第 6 项为 10.25 s,相同代码的对照组为 11.07 s 与 9.87 s,差异在噪声内。
- 无损依据: 该正则只含 ASCII 构造,在 rolldown 的 Rust 正则与 JavaScript 中匹配相同的 id 集合;处理函数应用同一检查,因此不读取过滤器的宿主行为相同。产物逐字节一致;测试
filters transform calls to TypeScript module ids固定过滤器。
8. Client tsdown 预设按包目录读取 manifest
- 目的: 每个包配置只读取自己的
package.json,消除按包名遍历全部 manifest 的 glob。 - 改动:
packages/client/tsdown.client.ts的clientBundle、staticLinked与clientLibrary的第一个参数是包目录(通常为配置文件的import.meta.dirname),预设读取一次<packageDir>/package.json,以其中的name作为插件 id 与诊断名称;该 manifest 缺失、不可读或缺少name时,配置加载以指明路径与原因的错误失败。82 个包配置调用这些预设。预设不保留模块级 manifest 缓存;Node 半边的生产依赖 external 列表在首次 resolve 时由该 manifest 计算,并缓存在该配置的闭包中。 - 性能: Client tsdown 从 12.78 s 降到 8.73 s(各 3 个样本的中位数),−4.05 s(−31.7%),两组样本区间不重叠;user CPU 从 26.1 s 降到 25.2 s,sys 从 5.9 s 降到 4.9 s,因此收益主要来自解除主线程阻塞,而不是减少 CPU 总量。原型(一次 glob 建立按包名索引)为 11.72 s → 7.46 s(−36%)。Host tsdown 中只有 5 个
hostPhase包经过该查找,一对样本为 11.92 s → 10.71 s,置信度低。 - 无损依据: 11 次运行的 11,566 个 lib 文件逐字节一致。82 个调用处改为传入包目录后,没有一个解析出的包名与原先手写的包名不同。进程内比较 82 个配置 × 3 个 face × 1,600 个 specifier 的 external 判定,结果与原实现完全一致;负对照(多加一个 external)使 75 个判定不同。
client-bundle-purity.spec.ts覆盖这些 manifest 错误。 - 行为差异: 预设的调用约定从包名改为包目录。
考虑过的替代方案
- 开启 pnpm
dedupeDirectDeps(第 1 项): 作用于所有直接依赖,改变每个 workspace 包的链接布局,副作用是全局的。 - 给 TypeScript 打补丁(第 1 项): 上游式补丁(预先计算候选扩展名并缓存规范化的
paths模式)使 Check 降低 31.9 s,与提升相当;在已提升的树上不再带来收益(refs Check 25.76 s 对 26.47 s),且需随每次 TypeScript 升级维护。 - 在 tsconfig
paths中为react加别名(第 1 项): tsx、vitest 与 vite 也读取paths,别名会改变它们对 runtimereact的解析。 - 把
paths收窄为各 face 的 reference closure(第 1 项): 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。 - 拆分 Client 测试聚合程序(第 1 项): 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。
- 缓存解析结果并把发现队列改为 O(1)(Typert): 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。
- 在产物构建中把 Typert 移到独立构建步骤(Typert): 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤(本地门禁提速)。 先生成顺序决策为了编译依赖顺序而选择独立步骤,并记录无改动构建的代价;公开 worker 插件仍可使用。
- 按代码内容过滤
transform(第 7 项): rolldown 以 Rust 正则语义解释^与\s,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。 - 在
globalThis上建立进程级 manifest 索引(第 8 项): 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反包规则中的共享状态归属要求。 - 关闭 tsdown 的 gzip 报告(第 8 项):
--no-report使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。 - 为 DSH 的
resolveId/load钩子加 rolldown 过滤器(第 8 项): 降低 1.16 s,在噪声内。 - 按模块 id 缓存输入隔离检查(第 8 项): 降低 0.93 s,在噪声内;叠加在 manifest 索引之上只再降低 0.18 s。
后果
Client 类型检查的 CPU 与 wall 时间、Host tsdown 与 Client tsdown 的 wall 时间都明显下降,且不改变 runtime 解析、发布安装语义或任何产物字节。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;串行各段合计在 master 上为 308.5 s,在本分支上为 254.9 s(见构建拆分 Note)。Host tsdown 位于 build 与 build:artifacts 的关键路径上,Client tsdown 位于 build:artifacts 的关键路径上,Client 聚合检查是 build 关键路径的最后一步。
代价:所有 workspace 包共用一个 React 类型版本;Typert 不校验它不分析的包 tsconfig,这些错误由 tsc -b 负责;Host tsdown 的最大 RSS 约增加 0.6 GiB,共享主机高负载时 worker 可能成为 Host tsdown 的关键路径。
重新评估条件:迁移到 TypeScript 7 时重新测量第 1 项,若位置数不再影响 Check 时间,可以重新评估包级声明。tsdown 的配置加载本身每次 Client 构建仍占 1.8–2.3 s(每个配置一个无缓存的模块实例),需要上游改动。第一个 buildStart 在 Host 构建开始约 4.2 s 后才出现;在插件构造时以显式根目录启动 worker 可以进一步提前 Typert。
验证:Typert generator 的 tsdown-plugin.spec.ts、tsdown-plugin-worker.spec.ts 与 type-model.spec.ts 固定第 2–7 项的行为;generator 测试 209 个通过、28 个跳过,tsc -b tsconfig.host.json 以 0 退出。仓库覆盖率 gate 不包含 packages/typert/*/src/**,在临时覆盖率配置下 tsdown-plugin.ts 的 statements、branches、functions 与 lines 均为 100%。五个负对照(旧根目录规则、立即解析包配置、展开聚合配置、不认领写入、跳过 worker 终止)各自使守护它的测试失败;真实 tsdown 失败路径在 2.4 s 内以 1 退出并报告 TypertWorkerError。第 1 项:verify-package-dependencies 以 0 退出(74 个包),其 spec 47/47 通过,verify-client-packages 以 0 退出,tsc -b tsconfig.client.json 无诊断,Client 聚合 Check 为 26.48 s。第 8 项:72 个相关 spec 全部通过。