feat(perf): parallel async settings loading + initializeApp parallelization - #4054
Conversation
…zation Builds on top of PR-A (#3994) the second leg of the startup main path optimization series: the synchronous portion of `Config.initialize()` is now narrower because settings I/O and i18n + auth + IDE init run in parallel where there's no data dependency. ## Two coupled changes **1. `loadSettingsAsync(workspaceDir)`** — async parallel reader: - Concurrent `fs.promises.readFile` for the 4 well-known settings JSON paths (system / system-defaults / user / workspace). ENOENT silently treated as missing. - Defers to the existing synchronous `loadSettings` for parse → migration → trust check → `loadEnvironment` → merge, feeding the prefetched content in via a new optional `prefetchedFileContents` Map parameter. Sync `loadSettings` API is unchanged for the dozens of call sites that consume it (commands, settings dialog, tests). - Used only on the cli startup main path in `gemini.tsx`. Every other call site keeps the existing synchronous behavior. **2. `initializeApp` parallelization**: - New exported `initializeI18nFromSettings(settings)` — pure function of `settings`, no `Config` dependency, so the cli main path can fire it in parallel with `loadCliConfig`. `initializeApp` accepts a new `skipI18n: true` option to avoid re-initializing. - Post-config substeps (auth + IDE connect) now run via `Promise.allSettled` instead of serial awaits. An IDE-connect failure cannot short-circuit auth, and vice versa — each error is surfaced through its own channel (`authError` in `InitializationResult` for auth; debug log for IDE). - `gemini.tsx` main path: - `await Promise.all([loadCliConfig(...), initializeI18nFromSettings(settings)])` - then `await initializeApp(config, settings, { skipI18n: true })` ## Expected impact On a warm filesystem cache (typical interactive launch), `after_load_settings` goes from ~9 ms to ~3-5 ms (parallel I/O dominated by parse + migration, not raw read). Cold-start improvement is larger (50-200 ms recovered from serial I/O). `before_render` shrinks by ~50-100 ms from concurrent i18n + config and concurrent auth + IDE — the size depends on whether IDE mode is on (IDE connect is the longest tail). Bundle size / dynamic import of interactive UI is **deferred to a follow-up PR** — it requires switching esbuild from `outfile: dist/cli.js` to `outdir` + `splitting: true`, which changes the shipped artifact shape (the `bin: dist/cli.js` contract in package.json) and is too invasive to ship in the same change. ## Compat - `loadSettings()` signature unchanged (added optional `prefetchedFileContents`). All existing call sites still work. - `initializeApp` signature gains a third optional `options` parameter defaulting to `{ skipI18n: false }` so the legacy behavior is the default. - New `Promise.allSettled` flow preserves `InitializationResult` shape. ## Tests - 5663 cli tests passing (up from 5657 — 2 new settings parity tests, 4 new initializer parallelism tests). - `tsc --noEmit` clean for both packages. - `eslint` clean on touched files. Stacked on #3994 — base branch is `feat/first-screen-performance-optimization`. Will retarget to `main` once PR-A merges. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Closing after real-measurement interleaved A/B (3×10 blocks per branch, n=30) showed zero perceivable benefit vs PR-A baseline on a warm-cache, no-MCP, non-IDE-mode startup:
The 50-100 ms savings projected from i18n / loadCliConfig parallelization and the 3-5 ms from Reviewing 830 lines of diff with a behavioral semantic change ( Refocusing effort on PR-A (#3994) — the real win — and on properly scoping the bundle-split work (esbuild |
Why
PR-A (#3994) made the cli no longer block on slow MCP servers. This PR is the second leg of the same startup-perf series: shrinking the synchronous portion of
Config.initialize()itself by running settings I/O and i18n + auth + IDE init in parallel wherever there's no data dependency.What changes
1.
loadSettingsAsync(workspaceDir)— async parallel settings readerThe synchronous
loadSettings()reads the 4 well-known settings JSON paths serially. On a warm cache that's ~9 ms (mostly parse + migration, not raw I/O); on a cold cache or networked filesystem it can be 50-200 ms.The new async path:
fs.promises.readFilefor the 4 paths (system / system-defaults / user / workspace).ENOENTis silently treated as a missing file (existing behavior).loadSettingsfor the rest (parse → migration → trust check →loadEnvironment→ merge), feeding prefetched content in via a new optionalprefetchedFileContents: Map<string, string>parameter so the sync path doesn't re-read.Used only on the cli startup main path (
gemini.tsx). Every other call site (commands, settings dialog, ~12 test files) keeps usingloadSettings— verified by a new parity test that the two APIs produce identicalLoadedSettings.2.
initializeAppparallelizationinitializeI18nFromSettings(settings)— pure function ofsettings, noConfigdependency. The cli main path fires it in parallel withloadCliConfig.initializeAppaccepts a newoptions.skipI18n?: booleanso the main path can skip the second initialization without breaking other callers.await … await …toPromise.allSettled([auth, ideConnect]). An IDE-connect failure can no longer short-circuit auth, and vice versa — each error is surfaced through its own channel (authErrorinInitializationResultfor auth; debug log for IDE, same as the legacy contract).gemini.tsxmain path becomes:Expected impact
All paths inherit the larger TTI improvements PR-A (#3994) already shipped — this stacks on top.
Why this PR doesn't include the bundle-split / dynamic-import work
The original plan called for moving
AppContainer+ Ink imports behindawait import('./interactiveCli.js')so headless / non-interactive bundles wouldn't pay V8 module-eval cost for UI code. Real bundle-size savings (~200-500 KB) require switching esbuild fromoutfile: dist/cli.jstooutdir+splitting: true, which changes the shipped artifact shape:package.jsonbin: dist/cli.jswould become a chunk-loading entrydist/cli.jsbeing a single file would need to be auditedThat's a meaningful contract change. Deferring to a dedicated bundle-restructuring PR so reviewers can audit it on its own merits without the noise of these unrelated parallelization changes.
In source-level only (without
splitting: true), dynamic imports would give a small wall-time savings (deferred module-eval) but zero bundle-size savings — the cost/benefit doesn't justify the refactor in this PR.Behavioral / compat
loadSettings()signature unchanged for existing callers (added an optionalprefetchedFileContentsparameter that defaults toundefined).initializeAppsignature gains a third optionaloptionsparameter; default{ skipI18n: false }preserves the legacy serial behavior. All ~10 existing call sites still work without modification.Promise.allSettledpreserves theInitializationResultshape exactly. Tests added to verify auth/IDE failure isolation.Test plan
packages/cli/src/config/settings.test.ts— 2 new tests (loadSettingsAsyncproduces identicalLoadedSettingsas sync path; ENOENT is silent)packages/cli/src/core/initializer.test.ts— 4 new tests (skipI18n: trueskips i18n;initializeI18nFromSettingsstandalone; auth failure ↛ IDE init; IDE failure ↛ auth)packages/cli/src/gemini.test.tsx— adjusted mocks; 14 tests passingtsc --noEmitclean for bothpackages/coreandpackages/clieslintclean on touched filesHow to validate locally
Same instrumentation infrastructure as PR-A —
QWEN_CODE_PROFILE_STARTUP=1reveals the timing:Stacked PR
🔗 Base branch: `feat/first-screen-performance-optimization` (PR #3994). Will auto-retarget to
mainwhen PR-A merges; the diff shown here will then be just the parallelization changes.Out of scope (future PR)
splitting: true+outdir. Will drop Ink + AppContainer + themes from the non-interactive / headless / ACP / subcommand entry chunks. Estimated additional savings: ~200-500 KB bundle, ~50-150 ms V8 module-eval on cold start.🤖 Generated with Qwen Code