Skip to content

feat(perf): parallel async settings loading + initializeApp parallelization - #4054

Closed
chiga0 wants to merge 1 commit into
feat/first-screen-performance-optimizationfrom
feat/startup-main-path-optimization
Closed

feat(perf): parallel async settings loading + initializeApp parallelization#4054
chiga0 wants to merge 1 commit into
feat/first-screen-performance-optimizationfrom
feat/startup-main-path-optimization

Conversation

@chiga0

@chiga0 chiga0 commented May 11, 2026

Copy link
Copy Markdown
Collaborator

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 reader

The 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:

  • Concurrent fs.promises.readFile for the 4 paths (system / system-defaults / user / workspace). ENOENT is silently treated as a missing file (existing behavior).
  • Defers to the existing synchronous loadSettings for the rest (parse → migration → trust check → loadEnvironment → merge), feeding prefetched content in via a new optional prefetchedFileContents: 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 using loadSettings — verified by a new parity test that the two APIs produce identical LoadedSettings.

2. initializeApp parallelization

  • New exported initializeI18nFromSettings(settings) — pure function of settings, no Config dependency. The cli main path fires it in parallel with loadCliConfig.
  • initializeApp accepts a new options.skipI18n?: boolean so the main path can skip the second initialization without breaking other callers.
  • Post-config substeps (auth + IDE connect) move from serial await … await … to Promise.allSettled([auth, ideConnect]). An IDE-connect failure can no longer short-circuit auth, and vice versa — each error is surfaced through its own channel (authError in InitializationResult for auth; debug log for IDE, same as the legacy contract).

gemini.tsx main path becomes:

const settings = await loadSettingsAsync();   // parallel file reads
// ...
const [config] = await Promise.all([
  loadCliConfig(settings.merged, argv, ),
  initializeI18nFromSettings(settings),
]);
const result = await initializeApp(config, settings, { skipI18n: true });

Expected impact

Scenario Δ before_render (p50) Δ after_load_settings (p50)
Warm cache / no IDE -30 to -60 ms (i18n+config) -3 to -5 ms
Warm cache / IDE on -60 to -120 ms (auth+IDE) same
Cold cache / slow disk same -50 to -200 ms (4× parallel I/O)

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 behind await 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 from outfile: dist/cli.js to outdir + splitting: true, which changes the shipped artifact shape:

  • package.json bin: dist/cli.js would become a chunk-loading entry
  • The SEA (single-executable-archive) builder and any third-party installers that depend on dist/cli.js being a single file would need to be audited

That'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 optional prefetchedFileContents parameter that defaults to undefined).
  • initializeApp signature gains a third optional options parameter; default { skipI18n: false } preserves the legacy serial behavior. All ~10 existing call sites still work without modification.
  • Promise.allSettled preserves the InitializationResult shape exactly. Tests added to verify auth/IDE failure isolation.

Test plan

  • packages/cli/src/config/settings.test.ts2 new tests (loadSettingsAsync produces identical LoadedSettings as sync path; ENOENT is silent)
  • packages/cli/src/core/initializer.test.ts4 new tests (skipI18n: true skips i18n; initializeI18nFromSettings standalone; auth failure ↛ IDE init; IDE failure ↛ auth)
  • packages/cli/src/gemini.test.tsx — adjusted mocks; 14 tests passing
  • 5663 cli tests passing across 349 test files
  • tsc --noEmit clean for both packages/core and packages/cli
  • eslint clean on touched files

How to validate locally

Same instrumentation infrastructure as PR-A — QWEN_CODE_PROFILE_STARTUP=1 reveals the timing:

git checkout main && npm run bundle
QWEN_CODE_PROFILE_STARTUP=1 SANDBOX=1 \
  QWEN_HOME=/tmp/q/.qwen HOME=/tmp/q node dist/cli.js   # type, Ctrl+C
mv /tmp/q/.qwen/startup-perf/*.json /tmp/before.json

git checkout feat/startup-main-path-optimization && npm run bundle
rm -f /tmp/q/.qwen/startup-perf/*.json
QWEN_CODE_PROFILE_STARTUP=1 SANDBOX=1 \
  QWEN_HOME=/tmp/q/.qwen HOME=/tmp/q node dist/cli.js
mv /tmp/q/.qwen/startup-perf/*.json /tmp/after.json

jq -r '.derivedPhases | "pre_render=\(.pre_render)ms  settings=\(.settings_time)ms"' /tmp/before.json /tmp/after.json

Stacked PR

🔗 Base branch: `feat/first-screen-performance-optimization` (PR #3994). Will auto-retarget to main when PR-A merges; the diff shown here will then be just the parallelization changes.

Out of scope (future PR)

  • Bundle restructuring with esbuild 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.
  • Module-eval-time prefetch hooks (e.g., git branch detect). Modest win (~10-30 ms); needs a clear safe-candidate audit before adding.

🤖 Generated with Qwen Code

…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>
@chiga0

chiga0 commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

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:

Metric PR-A only (p50, σ) PR-A+B (p50, σ) Δ
processUptimeAtT0Ms 506.8 ms (σ=34.9) 509.8 ms (σ=12.1) +0.6%
before_render 107.5 ms (σ=9.4) 107.3 ms (σ=3.5) -0.2%
first_paint 413.0 ms (σ=11.6) 412.8 ms (σ=5.3) -0.0%
input_enabled 461.1 ms (σ=13.3) 461.6 ms (σ=6.3) +0.1%
config_initialize_dur 58.7 ms 58.9 ms +0.3%

The 50-100 ms savings projected from i18n / loadCliConfig parallelization and the 3-5 ms from loadSettingsAsync (4× parallel fs.promises.readFile) all fell inside measurement noise. The theoretical wins exist only on cold caches / IDE-mode / slow-disk scenarios, none of which are the primary user experience.

Reviewing 830 lines of diff with a behavioral semantic change (Promise.allSettled for auth+IDE error isolation) in exchange for zero measurable user benefit is a poor trade. The loadSettingsAsync API can be reintroduced as a focused ~80-line follow-up if/when a concrete cold-start scenario justifies it.

Refocusing effort on PR-A (#3994) — the real win — and on properly scoping the bundle-split work (esbuild splitting: true + outdir, which changes the bin: dist/cli.js contract and needs its own audit window).

@chiga0 chiga0 closed this May 11, 2026
@chiga0
chiga0 deleted the feat/startup-main-path-optimization branch May 11, 2026 11:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant