探针并行化:从 157s 到 50s(3.11×),且零假红
2026-09-23 · 工具:web/tools/run_parallel.cjs 目标:这台机器 10 核,串行跑探针时只有 1 个核在干活。
一、结论先行
8 个定点探针(--suite core)实测全表:
| 渲染 | 并发 | 墙钟 | 相对串行 | 未过清单 | 假红 |
|---|---|---|---|---|---|
| 软 | 1(串行) | 157.1 s | 1.00× | 基准 4 项 | 无 |
| 软 | 2 | 110.8 s | 1.42× | 相同 | 无 |
| 软 | 4 | 193.6 s | 0.81×(比串行还慢) | +probe_log_dock | ❌ 有 |
| 硬件 | 2 | 81.0 s | 1.94× | 相同 | 无 |
| 硬件 | 4(默认) | 50.5 s | 3.11× | 相同 | ✅ 无 |
| 硬件 | 8 | 41.7 s | 3.77× | 相同 | 无 |
一句话:
- 只调并发(软渲染):2 是甜点(1.42×),4 反而更慢还引入假红。
- 并发 + 硬件渲染一起上:4 路 → 50.5 s(3.11×)且零假红 —— 这才是正解。
# 推荐用法(实测最快且无假红)
PROBE_RENDER=d3d11 node web/tools/run_parallel.cjs --suite core
📌 这条结论有个弯路:我最初以为"换硬件渲染能提速",用两个软/硬混跑的对照实验 测得耗时几乎相同(147 vs 146 s),一度判定假设不成立。 真正的原因是那次实验混着软渲染的探针——软渲染的探针仍在抢 CPU,把整体拖住, 连累了硬渲染的同伴。全部换成硬件渲染后,CPU 不再争抢,收益才显现(193.6 → 50.5 s)。 ⇒ 做对照实验时,"混合编组"会把两类效应搅在一起 —— 要测 A 的效果,就得让整组都是 A。
二、为什么软渲染下 4 路会「反向变慢」
每个探针的 Chromium 走软渲染(SwiftShader,纯 CPU)。4 路并发 = 4 个 Chromium 抢 CPU:
| 探针 | 串行 | 软·jobs 4 | 硬·jobs 4 |
|---|---|---|---|
probe_task_cards | 38 s | 184 s | 28 s |
probe_log_dock | 21 s | 175 s | 21 s |
probe_members | 54 s | 160 s | 34 s |
软渲染下单探针耗时暴涨 ⇒ 探针内部依赖轮询/超时的断言来不及满足 ⇒ 走到超时上限 ⇒ 把原本通过的判成失败。
铁证:probe_log_dock 串行 47 过 / 0 败(OK),软渲染 jobs 4 下变 45 过 / 2 败, 失败的正是依赖 90 s waitGameReady 超时的两条:
[FAIL] 刷新后作品重新可玩
[FAIL] ★ 刷新后未用图标带与刷新前一致(没有"复活")
同样的探针、同样的并发,换成硬件渲染后回到 47 过 / 0 败。
📌 这正是项目一贯的告诫:"假红比慢更费人" —— 一个把通过的压成失败的加速, 比不加速更坏:它会让后续排查全都建在错误的前提上。
三、严格对照(8 个探针,--suite core)
| 探针 | 串行(软) | 软·j2 | 软·j4 | 硬·j4 | 硬·j8 |
|---|---|---|---|---|---|
| probe_camera_front | 5s OK | 7s OK | 9s OK | 8s OK | 13s OK |
| probe_world_dims | 6s OK | 7s OK | 9s OK | 8s OK | 14s OK |
| probe_taijie | 8s · 11过/6败 | 同 | 同 | 10s · 同 | 15s · 同 |
| probe_sidebar_removed | 13s · 78过/2败 | 同 | 同 | 16s · 同 | 20s · 同 |
| probe_build_dock | 12s OK | 13s OK | 17s OK | 14s OK | 20s OK |
| probe_task_cards | 38s · 32过/2败 | 37s · 同 | 184s 同 | 28s · 同 | 34s · 同 |
| probe_log_dock | 21s · 47过/0败 | 同 | 175s · 45过/2败 ❌ | 21s · 47过/0败 ✅ | 28s · 47过/0败 ✅ |
| probe_members | 54s · 23过/1败 | 同 | 160s · 22过/0败 | 34s · 同 | 41s · 同 |
| 未过清单 | 4 项 | 完全相同 | +log_dock ❌ | 完全相同 ✅ | 完全相同 ✅ |
| 墙钟 | 157.1 s | 110.8 s | 193.6 s | 50.5 s | 41.7 s |
(注:probe_taijie / probe_sidebar_removed / probe_task_cards / probe_members 的失败是既有状态 —— 五种配置下逐条一致,与并行无关。详见各探针的文件头。)
读数要点:
- 硬件·j4 的单探针耗时 ≈ 串行水平(task_cards 28s vs 38s、members 34s vs 54s、
log_dock 21s vs 21s)⇒ 说明 CPU 不再是瓶颈,并行几乎无损耗。
- 软·j4 的
probe_log_dock从47过/0败掉到45过/2败,硬·j4 回到47过/0败
⇒ 同一探针、同一并发,只换渲染后端,假红就消失了。
- 硬·j8(全并行)还能再快 18%,但单探针耗时涨约 40%(8→13s、28→34s)
⇒ 收益递减 ⇒ 默认取 4。
四、工具用法
# ★ 推荐:硬件渲染 + 4 路(实测 50.5s / 零假红)
PROBE_RENDER=d3d11 node web/tools/run_parallel.cjs --suite core
# 保守(无 GPU 或想完全复现历史行为):软渲染 + 2 路
node web/tools/run_parallel.cjs --suite core
# 串行对照(量化收益时用)
node web/tools/run_parallel.cjs --jobs 1 --suite core
# 换 URL / 调超时
node web/tools/run_parallel.cjs --url http://127.0.0.1:8000/ --timeout 120000 probe_a probe_b
| 选项 | 默认 | 说明 |
|---|---|---|
--jobs / -j | 按渲染后端自动选:PROBE_RENDER=d3d11 ⇒ 4;否则 2 | 实测值,见第一节 |
PROBE_RENDER | (不设) | 环境变量,透传给每个探针。d3d11 = 硬件 GPU |
--url | http://127.0.0.1:8080/ | 探针目标地址 |
--timeout | 300000 | 单探针上限(毫秒),超过则杀掉 |
--suite | — | core(8 个定点探针)/ speed(4 个轻量探针) |
安全网:软渲染下若显式把 --jobs 设到 >2,runner 会打印警告并给出推荐命令 (因为实测那正是假红重灾区)。
Sign out码:全部通过 = 0,任一失败 = 1 ⇒ 可直接当门禁。
输出:
- 汇总表(各探针耗时 / 结果 / 断言数 / 未过清单)
- 每个探针的完整输出落盘到
_tmp/parallel/<探针名>.log - 失败探针额外回显末尾 60 行(仅提示,完整输出看文件)
🔴 完整输出落盘是必须的,不是锦上添花 —— 开发这个 runner 时踩到: 起初只在失败时回显末尾 25 行,probe_taijie的 6 条[FAIL]里前 2 条正好落在窗口外 ⇒ 看到 4 条却报「6 失败」⇒ 当场误以为是自己的改动改坏了两条, 白查一轮。回显窗口只是提示,不是证据。
四之二、渲染后端开关(本轮新增)
62 个探针原先把软渲染参数写死在 args 里,且有三种写法:
| 写法 | 数量 | 样子 |
|---|---|---|
| A | 56 | ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', ...] |
| A' | 4 | ['--use-angle=swiftshader', ...] |
| B | 2 | ['--disable-gpu', '--use-gl=swiftshader', ...] |
已全部统一到 _auth.cjs#mergeRenderArgs(全项目唯一真相源):
const { mergeRenderArgs } = require('./_auth.cjs');
browser = await chromium.launch({
args: mergeRenderArgs(['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox']),
});
语义:只替换渲染相关参数,其余(--no-sandbox / --mute-audio 等)原样保留。
PROBE_RENDER | 效果 |
|---|---|
| (不设) | 原样返回入参 ⇒ 行为与历史严格一致(已实测:返回数组与入参逐项相同) |
d3d11 | 换硬件 GPU 参数 |
swiftshader | 换显式软渲染(对原本走硬件的探针有用) |
🔴 默认"原样返回"是刻意设计:改造 62 个探针时,不设环境变量的行为一字不变, 不引入任何回归。已用 probe_task_cards 验证:
| 耗时 | 断言 | |
|---|---|---|
| 默认(软渲染) | 35987 ms | 32 过 / 2 败 |
PROBE_RENDER=d3d11 | 27022 ms(-24.9%) | 32 过 / 2 败(逐条相同) |
⇒ 单跑时硬件渲染另省约 25%(不同探针收益 12%~25% 不等)。
⚠️ 一个被推翻的假说(值得记下)
我最初以为"换硬件渲染能提速",于是做对照实验:2 个软渲染 + 2 个硬件渲染混跑 jobs 4。
结果耗时几乎相同(probe_task_cards 软 147s / 硬 146s),我据此判定假设不成立。
但那是实验设计的错:混着软渲染的探针 ⇒ 它们仍在抢 CPU ⇒ 把整组都拖住, 连累了硬渲染的同伴。
整组换成硬件渲染后,收益才显现:软 jobs4 = 193.6s ⇒ 硬 jobs4 = 50.5s(快 3.8 倍)。
📌 教训:做对照实验时,"混合编组"会把两类效应搅在一起。 要测 A 的效果,就让整组都是 A —— 否则你测到的是它们互相干扰的合成结果。
五、安全性(为什么可以并行)
| 隔离面 | 机制 |
|---|---|
| 账号 | 每个探针走 _auth.cjs#bootstrap 自己注册新号 ⇒ 现场互不干扰 |
| 进程 | 每个探针独立 node + 独立 Chromium ⇒ 崩溃不互相拖累 |
| 存档 | 各写各的账号存档,不共享可变状态 |
⚠️ 唯一共享面是后端与 CPU/GPU —— 这正是 jobs 数不能高的原因。
六、下一步选项
让更多探针支持硬件渲染✅ 已完成(见第四之二节)
62 个探针统一到 mergeRenderArgs,默认行为严格不变。 结论修正:硬件渲染本身不提高并发上限(CPU 争抢只是表象之一, 真正卡住的是探针内部的超时断言);它的价值是消除高并发下的假红, 从而让 --jobs 4 从"不可用"变成"可用"(193.6s → 50.5s)。
- 按探针重量分组
轻量探针(<15 s)可以更高并发;重探针(含大量轮询/超时)分开跑。 硬·j8 的实测说明上限还没到,但收益已递减(j4→j8 只快 18%,单探针耗时涨 40%)。
- 复用浏览器进程与登录态
现在每个探针都要冷启动 Chromium + 注册账号(约 1~2 s/个)。合并成一个浏览器 上下文跑一批断言,能省掉这部分固定开销 —— 但会改变探针的隔离性,需权衡。
- 扩大
--suite core覆盖面
目前只挑了 8 个定点探针。全项目 140+ 个,可以按模块分组出更多 suite (如 suite scene / suite ui / suite combat),按改动范围选择跑哪些。
七、实测复现
cd D:/3d-game
# 串行基线(157.1s)
node web/tools/run_parallel.cjs --jobs 1 --suite core
# 软渲染 + 2 路(110.8s,断言与串行一致)
node web/tools/run_parallel.cjs --suite core
# 软渲染 + 4 路(193.6s,且把 probe_log_dock 压成假红)
node web/tools/run_parallel.cjs --jobs 4 --suite core
# ★ 硬件渲染 + 4 路(50.5s,零假红)
PROBE_RENDER=d3d11 node web/tools/run_parallel.cjs --suite core
# 硬件渲染 + 8 路(41.7s,全并行)
PROBE_RENDER=d3d11 node web/tools/run_parallel.cjs --jobs 8 --suite core
# 单探针软/硬对照
node web/tools/probe_task_cards.cjs http://127.0.0.1:8080/
PROBE_RENDER=d3d11 node web/tools/probe_task_cards.cjs http://127.0.0.1:8080/