Skip to content

perf(cli): one-shot runs boot the CLI twice and skip the compile cache #12598

Closed

Description

@yiliang114

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

  1. A no-op self-relaunch. After loading settings, main() always calls relaunchAppInChildProcess(memoryArgs, …) outside a sandbox. Since perf(cli): optimize one-shot headless execution #12340, a one-shot prompt does this with process.execve. But memoryArgs is only non-empty when advanced.autoConfigureMemory is on, and that setting defaults to false (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 plus QWEN_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's NODE_DEBUG=child_process trace shows ps … ×2 and git rev-parse ×2.
  2. The compile cache is off on the spawn path. scripts/cli-entry.js calls module.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_CERTS from a home .env) changed after startup, still execve so the new value takes effect.
  • Have the launcher's spawn path pass NODE_COMPILE_CACHE to the child. This matches what the serve fast path already publishes, and it respects NODE_DISABLE_COMPILE_CACHE and any inherited value.
中文说明

概述

#12405 的后续(实测评论)。显式 one-shot 运行(qwen -p …)会把整个 CLI 启动两遍,而且两遍都没用上 Node 编译缓存。在 harness-perf-benchmark 的负载里,one-shot 的启动时间、CPU 和内存开销,这两点占了最大头。

根因

  1. 一次什么都不改变的自我 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,也是这个原因。
  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

  1. doudouOUC commented on Sep 24, 2026

    @doudouOUC
    Collaborator

    Confirmed against main ca8146aa. Independently re-derived the full evidence chain for both root-cause claims.

    1. No-op self-relaunch. main() reaches relaunchAppInChildProcess(memoryArgs, [], {...}) in the non-sandboxed block — packages/cli/src/llm.tsx:811 (the else branch when no sandbox is configured; when sandboxConfig is truthy, start_sandbox(...) is taken at :796 instead — a minor nuance the issue's "always calls" phrasing glosses over, but it doesn't affect the one-shot case which has no sandbox). memoryArgs is settings.merged.advanced?.autoConfigureMemory ? getNodeMemoryArgs(isDebugMode) : [] (llm.tsx:632-634), and autoConfigureMemory defaults to false (settingsSchema.ts:3494). Under --bare, createMinimalSettings() is used instead of loadSettings() (llm.tsx:530-532; settings.ts:916-952 returns settings with no advanced block), so memoryArgs is always []. relaunch.ts:52-53 guards on QWEN_CODE_NO_RELAUNCH; :58-62 rebuilds nodeArgs from process.execArgv + additionalNodeArgs + script + additionalScriptArgs + scriptArgs (all empty/additive when memoryArgs is empty); :65-70 sets QWEN_CODE_NO_RELAUNCH: 'true' in the child env; :82-92 performs process.execve when replaceProcess is true, and replaceProcess is Boolean(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-53 isInProcessFastPath() returns true only for serve/mcp/--help/--version; enableCompileCache?.() is called at :295-307 only inside that fast-path block; the else/spawn branch (:308-358) neither calls enableCompileCache nor passes NODE_COMPILE_CACHE to the child. Every spawned one-shot therefore recompiles the bundle from scratch, while serve/mcp already publish the cache (cli-entry.js:297-304).

    The proposed direction is well-targeted on all three points: (1) skip execve when additionalNodeArgs and additionalScriptArgs are both empty — eliminates the no-op re-exec; (2) guard the skip on NODE_*/UV_* boot-time env changes (e.g. NODE_EXTRA_CA_CERTS from a home .env) so a late-introduced var still takes effect; (3) pass NODE_COMPILE_CACHE to spawned children, respecting NODE_DISABLE_COMPILE_CACHE and any inherited value — matches what the serve fast path already does. The QWEN_CODE_NO_RELAUNCH guard at relaunch.ts:52 correctly 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions