Repository navigation
perf(cli): one-shot runs boot the CLI twice and skip the compile cache #12598
Description
Summary
Follow-up to #12405 (measurements comment). An explicit one-shot prompt (qwen -p …) boots the whole CLI twice, and neither boot uses the Node compile cache. Together these are the largest share of one-shot startup, CPU and memory cost in the harness-perf-benchmark workload.
Root cause
- A no-op self-relaunch. After loading settings,
main()always callsrelaunchAppInChildProcess(memoryArgs, …)outside a sandbox. Since perf(cli): optimize one-shot headless execution #12340, a one-shot prompt does this withprocess.execve. ButmemoryArgsis only non-empty whenadvanced.autoConfigureMemoryis on, and that setting defaults tofalse(under--bare, settings are never loaded, so it is always off). The new image gets the same Node arguments, the same script arguments and the same environment plusQWEN_CODE_NO_RELAUNCH. It is the same process booted a second time: all modules load again, settings load again, theme detection runs again, and so on. This is also why the comment'sNODE_DEBUG=child_processtrace showsps …×2 andgit rev-parse×2. - The compile cache is off on the spawn path.
scripts/cli-entry.jscallsmodule.enableCompileCache()only on the in-process fast path (serve/mcp/--help/--version). Every spawned run, including every one-shot-p, recompiles the bundle from scratch.
Evidence
Linux x64, 4 vCPU, Node 22.23.2, published @qwen-code/qwen-code@0.24.4, local OpenAI-compatible mock, --bare --safe-mode --no-telemetry --output-format json -p hi, mean of 3–4 warm runs. QWEN_CODE_NO_RELAUNCH=1 stands in for "skip the relaunch": a relaunched child already runs with that variable set, so the two follow the same path.
| variant | wall | CPU (core·s) | memory (GB·s) |
|---|---|---|---|
| 0.24.4 as shipped | 3.93 s | 5.08 | 0.84 |
| skip no-op relaunch | 2.44 s | 3.19 | 0.51 |
| compile cache only | 3.20 s | 4.40 | — |
| both | 2.04 s | 2.81 | 0.44 |
Time to the first mock request dropped from ~3.85 s to ~1.95 s. The tree RSS peak for this single-turn run stayed at ~270 MB: the double boot costs time, not peak. On this box module loading (main_entry) takes ~1.3 s, compared with ~0.32 s on the M4 Pro in #12405, so the compile-cache share will be smaller on fast machines.
Not reproduced here: the ~1.04 s zero-CPU wait inside Config.initialize() reported on macOS. On Linux the whole Config.initialize() takes 89 ms, and tool_registry_created → gemini_tools_updated takes 72 ms. That wait needs a macOS profile and is tracked separately.
Proposal
- Keep running in the current process when the replacement would add no Node or script arguments. Guard: if a Node boot-time variable (
NODE_*/UV_*, e.g.NODE_EXTRA_CA_CERTSfrom a home.env) changed after startup, stillexecveso the new value takes effect. - Have the launcher's spawn path pass
NODE_COMPILE_CACHEto the child. This matches what the serve fast path already publishes, and it respectsNODE_DISABLE_COMPILE_CACHEand any inherited value.
中文说明
概述
#12405 的后续(实测评论)。显式 one-shot 运行(qwen -p …)会把整个 CLI 启动两遍,而且两遍都没用上 Node 编译缓存。在 harness-perf-benchmark 的负载里,one-shot 的启动时间、CPU 和内存开销,这两点占了最大头。
根因
- 一次什么都不改变的自我 relaunch。 不在 sandbox 里时,
main()加载完 settings 后总会调用relaunchAppInChildProcess(memoryArgs, …),perf(cli): optimize one-shot headless execution #12340 之后 one-shot 改用process.execve执行这一步。但memoryArgs只有在advanced.autoConfigureMemory打开时才非空,而它默认是false(--bare不加载 settings,所以一定是关的)。新镜像拿到的 Node 参数、脚本参数和环境都跟原来一样,只多了QWEN_CODE_NO_RELAUNCH。也就是把同一个进程再启动一遍:模块重新加载,settings 重新读,主题探测等再跑一次。评论里NODE_DEBUG=child_process看到ps …×2、git rev-parse×2,也是这个原因。 - spawn 路径没开编译缓存。
scripts/cli-entry.js只在进程内 fast path(serve/mcp/--help/--version)调用module.enableCompileCache()。每次走 spawn 的运行(包括所有 one-shot-p)都要从头编译整个 bundle。
证据
Linux x64、4 vCPU、Node 22.23.2、已发布的 @qwen-code/qwen-code@0.24.4、本地 OpenAI 兼容 mock,参数 --bare --safe-mode --no-telemetry --output-format json -p hi,取 3–4 次热启动的平均值。表中用 QWEN_CODE_NO_RELAUNCH=1 代表“跳过 relaunch”:relaunch 出来的子进程本来就带着这个变量,两者走的是同一条路径。
| 变体 | 墙钟 | CPU (core·s) | 内存 (GB·s) |
|---|---|---|---|
| 0.24.4 原样 | 3.93 s | 5.08 | 0.84 |
| 跳过无效 relaunch | 2.44 s | 3.19 | 0.51 |
| 仅开编译缓存 | 3.20 s | 4.40 | — |
| 两者都做 | 2.04 s | 2.81 | 0.44 |
到第一个 mock 请求的时间从 ~3.85 s 降到 ~1.95 s。这个单轮运行的进程树 RSS 峰值一直在 ~270 MB 左右:启动两遍浪费的是时间,不抬高峰值。这台机器上模块加载(main_entry)要 ~1.3 s,#12405 里的 M4 Pro 只要 ~0.32 s,所以在快机器上编译缓存的收益会小一些。
这里没复现出来的:macOS 上报告的 Config.initialize() 内 ~1.04 s 零 CPU 等待。Linux 上整个 Config.initialize() 只花 89 ms,tool_registry_created → gemini_tools_updated 72 ms。这段等待要在 macOS 上 profile,单独跟踪。
方案
- 如果替换后的镜像不会多出任何 Node 参数或脚本参数,就在当前进程里继续运行。保护条件:启动后如果有 Node 启动期环境变量(
NODE_*/UV_*,比如 home.env里的NODE_EXTRA_CA_CERTS)变了,仍然execve,让新值生效。 - launcher 的 spawn 路径把
NODE_COMPILE_CACHE传给子进程。serve fast path 已经这么做了;同时尊重NODE_DISABLE_COMPILE_CACHE和继承下来的已有值。
Activity
doudouOUC commentedon Sep 24, 2026
Confirmed against main
ca8146aa. Independently re-derived the full evidence chain for both root-cause claims.1. No-op self-relaunch.
main()reachesrelaunchAppInChildProcess(memoryArgs, [], {...})in the non-sandboxed block —packages/cli/src/llm.tsx:811(the else branch when no sandbox is configured; whensandboxConfigis truthy,start_sandbox(...)is taken at:796instead — a minor nuance the issue's "always calls" phrasing glosses over, but it doesn't affect the one-shot case which has no sandbox).memoryArgsissettings.merged.advanced?.autoConfigureMemory ? getNodeMemoryArgs(isDebugMode) : [](llm.tsx:632-634), andautoConfigureMemorydefaults tofalse(settingsSchema.ts:3494). Under--bare,createMinimalSettings()is used instead ofloadSettings()(llm.tsx:530-532;settings.ts:916-952returns settings with noadvancedblock), somemoryArgsis always[].relaunch.ts:52-53guards onQWEN_CODE_NO_RELAUNCH;:58-62rebuildsnodeArgsfromprocess.execArgv + additionalNodeArgs + script + additionalScriptArgs + scriptArgs(all empty/additive whenmemoryArgsis empty);:65-70setsQWEN_CODE_NO_RELAUNCH: 'true'in the child env;:82-92performsprocess.execvewhenreplaceProcessis true, andreplaceProcessisBoolean(argv.prompt)for one-shots (llm.tsx:815-821). So the re-executed image is the same process booted a second time — modules reload, settings reload, theme detection reruns.2. Compile cache off on the spawn path.
scripts/cli-entry.js:44-53isInProcessFastPath()returns true only forserve/mcp/--help/--version;enableCompileCache?.()is called at:295-307only inside that fast-path block; the else/spawn branch (:308-358) neither callsenableCompileCachenor passesNODE_COMPILE_CACHEto the child. Every spawned one-shot therefore recompiles the bundle from scratch, whileserve/mcpalready publish the cache (cli-entry.js:297-304).The proposed direction is well-targeted on all three points: (1) skip
execvewhenadditionalNodeArgsandadditionalScriptArgsare both empty — eliminates the no-op re-exec; (2) guard the skip onNODE_*/UV_*boot-time env changes (e.g.NODE_EXTRA_CA_CERTSfrom a home.env) so a late-introduced var still takes effect; (3) passNODE_COMPILE_CACHEto spawned children, respectingNODE_DISABLE_COMPILE_CACHEand any inherited value — matches what the serve fast path already does. TheQWEN_CODE_NO_RELAUNCHguard atrelaunch.ts:52correctly prevents infinite relaunch, but it only stops the function from re-firing — the per-boot startup cost on the re-executed image is still paid in full, which is exactly what the measurements in the issue capture.