Perf/gsd startup speed (#497)

* docs: add startup performance analysis and optimization plan

Profiled GSD CLI startup finding 2.2s for --version and ~3.8s for
interactive mode. Identified 5 root causes with measured timings and
created a phased optimization plan targeting <0.2s for --version
and ~0.8s for interactive startup.

* perf: speed up GSD startup with lazy loading and fast paths

- Fast-path --version/-v and --help/-h in loader.ts before importing
  any heavy dependencies (2.2s → 0.15s, 14x faster)
- Lazy-load undici (~200ms) only when HTTP_PROXY env vars are set
- Skip initResources cpSync when managed-resources.json version
  matches current GSD version (~128ms saved per launch)
- Lazy-load Mistral SDK (~369ms) on first API call instead of startup
- Lazy-load Google GenAI SDK (~186ms) on first API call instead of
  startup
- Parallelize extension loading with Promise.all() instead of
  sequential for-loop

---------

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
Flux Labs 2026-03-15 14:33:43 -05:00 committed by GitHub
parent ed47018496
commit e6d55f8aaf
6 changed files with 272 additions and 33 deletions

View file

@ -0,0 +1,157 @@
# GSD Startup Performance Analysis & Optimization Plan
## Measured Baseline (macOS, Node v25.6.1)
### `gsd --version` (simplest possible path): **2.2 seconds**
| Phase | Time | Notes |
|-------|------|-------|
| Node.js process startup | ~160ms | Unavoidable |
| loader.js top-level imports | ~13ms | fs, app-paths, logo |
| undici import + proxy setup | ~200ms | EnvHttpProxyAgent |
| **@gsd/pi-coding-agent barrel import** | **~970ms** | THE BOTTLENECK |
| cli.js other imports | ~3ms | resource-loader, wizard, etc. |
| Arg parsing + version print | ~0ms | |
| Measured wall time overhead | ~700ms | ESM resolution, gc, etc. |
### Full interactive startup: **~3.6 seconds** (post-node)
| Phase | Time | Notes |
|-------|------|-------|
| @gsd/pi-coding-agent import | ~750ms | (cached from loader measurement) |
| ensureManagedTools | ~0ms | No-op after first run |
| AuthStorage + env keys | ~3ms | |
| ModelRegistry | ~1ms | |
| SettingsManager | ~1ms | |
| **initResources (cpSync)** | **~128ms** | Copies all extensions/skills/agents on every launch |
| **resourceLoader.reload()** | **~2535ms** | jiti-compiles 17+ extensions from TypeScript |
### Inside @gsd/pi-coding-agent (barrel import breakdown)
| Sub-module | Time | Notes |
|------------|------|-------|
| Mistral SDK (@mistralai/mistralai) | 369ms | Loaded even if unused |
| Google GenAI SDK (@google/genai) | 186ms | Loaded even if unused |
| extensions/index.js (circular → index.js) | 497ms | Pulls in everything |
| tools/index.js | 124ms | Tool definitions |
| @sinclair/typebox | 64ms | Schema validation |
| OpenAI SDK | 52ms | |
| Anthropic SDK | 50ms | |
---
## Root Causes (Priority Order)
### 1. Extension JIT compilation via jiti (~2.5s)
Every launch compiles 17+ TypeScript extensions to JavaScript using jiti. No caching (`moduleCache: false` is explicitly set). This is the single largest cost.
### 2. Barrel import of @gsd/pi-coding-agent (~1s)
`cli.js` line 1 does a barrel import pulling in ALL exports including all LLM provider SDKs, TUI components, theme system, compaction, blob store, etc.
### 3. Eager LLM SDK loading (~660ms inside barrel)
All provider SDKs are imported at module evaluation time in `pi-ai/index.js`, even though only one provider is typically configured.
### 4. initResources copies files every launch (~128ms)
`cpSync` with `force: true` copies all bundled resources to `~/.gsd/agent/` on every startup, even when nothing changed.
### 5. undici import (~200ms)
Imported in loader.js for proxy support. Not needed for most users.
---
## Optimization Plan
### Phase 1: Quick Wins (est. save ~1-1.5s on --version, ~0.5s interactive)
#### 1A. Fast-path for `--version` and `--help`
Parse argv BEFORE importing cli.js. In loader.js, check for `--version`/`-v` and `--help`/`-h` and exit immediately without loading any dependencies.
**File**: `src/loader.ts`
**Change**: Add arg check before `await import('./cli.js')`
**Impact**: `gsd --version` goes from 2.2s → ~0.2s
#### 1B. Skip initResources when unchanged
Compare `managed-resources.json` version against current `GSD_VERSION`. If they match, skip the `cpSync` entirely.
**File**: `src/resource-loader.ts``initResources()`
**Change**: Early return if versions match
**Impact**: Save ~128ms per launch
#### 1C. Lazy-load undici
Only import undici when HTTP_PROXY/HTTPS_PROXY env vars are actually set.
**File**: `src/loader.ts`
**Change**: Wrap undici import in proxy env check
**Impact**: Save ~200ms for most users
### Phase 2: Lazy Provider Loading (est. save ~600ms interactive)
#### 2A. Lazy-load LLM provider SDKs
Instead of importing all providers at module level in `pi-ai/index.js`, use dynamic `import()` in the provider factory functions. Only load the SDK when a model from that provider is actually requested.
**Files**: `packages/pi-ai/src/providers/*.ts`
**Change**: Move `import { Anthropic } from '@anthropic-ai/sdk'` etc. to dynamic imports inside `complete()` / `stream()` functions
**Impact**: Save ~600ms (Mistral 369ms + Google 186ms + extras) for users who only use one provider
#### 2B. Selective re-exports in pi-ai barrel
Instead of `export * from "./providers/mistral.js"` etc., only export the registration function. Provider internals stay private.
**File**: `packages/pi-ai/src/index.ts`
### Phase 3: Extension Loading Optimization (est. save ~1.5-2s interactive)
#### 3A. Enable jiti module caching
Remove `moduleCache: false` from the jiti config, or use a persistent cache directory.
**File**: `packages/pi-coding-agent/src/core/extensions/loader.ts`
**Change**: Set `moduleCache: true` or configure `cacheDir`
**Impact**: Second+ launches save ~1-2s on extension loading
#### 3B. Pre-compile extensions at build time
Instead of JIT-compiling TypeScript extensions at runtime, compile them to JavaScript during `npm run build`. The runtime loader can then just `import()` the .js files directly without jiti.
**Files**: `package.json` build scripts, `src/resource-loader.ts`, extension loader
**Change**: Add build step to compile extensions; loader checks for .js first
**Impact**: Eliminate ~2.5s of jiti compilation entirely
**Complexity**: HIGH — requires careful handling of extension resolution paths
#### 3C. Parallel extension loading
Currently extensions load sequentially in a `for` loop. Load them in parallel with `Promise.all()`.
**File**: `packages/pi-coding-agent/src/core/extensions/loader.ts``loadExtensions()`
**Change**: `await Promise.all(paths.map(...))` instead of sequential for-loop
**Impact**: Wall time reduction depends on I/O overlap; est. 30-50% faster
### Phase 4: Bundle Optimization (est. save ~300-500ms)
#### 4A. Use esbuild/tsup for the main CLI bundle
Replace plain `tsc` with a bundler that does tree-shaking. A single-file bundle eliminates ESM resolution overhead and removes unused code.
**Impact**: Faster module resolution, smaller output, tree-shaking removes unused exports
**Complexity**: MEDIUM
#### 4B. Split pi-coding-agent into entry-point chunks
Instead of one barrel export, provide separate entry points for core, interactive, tools.
**Impact**: cli.js can import only what it needs for each code path
**Complexity**: HIGH — changes public API surface
---
## Recommended Implementation Order
1. **Phase 1A** — Fast-path --version/--help (trivial, huge UX impact)
2. **Phase 1C** — Lazy undici (easy, 200ms saved)
3. **Phase 1B** — Skip initResources (easy, 128ms saved)
4. **Phase 3C** — Parallel extension loading (moderate, ~1s saved)
5. **Phase 2A** — Lazy provider SDKs (moderate, ~600ms saved)
6. **Phase 3A** — jiti caching (easy, ~1s saved on repeat launches)
7. **Phase 3B** — Pre-compile extensions (hard, eliminates jiti entirely)
8. **Phase 4A** — Bundle with esbuild (medium, ~300-500ms)
### Expected Results
| Scenario | Before | After (Phase 1-3) | After (All) |
|----------|--------|-------------------|-------------|
| `gsd --version` | 2.2s | **~0.2s** | ~0.2s |
| Interactive startup | ~3.8s | **~1.5s** | **~0.8s** |

View file

@ -1,9 +1,20 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
// Lazy-loaded: Google GenAI SDK (~186ms) is imported on first use, not at startup.
// This avoids penalizing users who don't use Google models.
import type {
GenerateContentConfig,
GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
ThinkingConfig,
} from "@google/genai";
let _GoogleGenAIClass: typeof GoogleGenAI | undefined;
async function getGoogleGenAIClass(): Promise<typeof GoogleGenAI> {
if (!_GoogleGenAIClass) {
const mod = await import("@google/genai");
_GoogleGenAIClass = mod.GoogleGenAI;
}
return _GoogleGenAIClass;
}
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost } from "../models.js";
import type {
@ -73,7 +84,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const client = createClient(model, apiKey, options?.headers);
const client = await createClient(model, apiKey, options?.headers);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@ -308,11 +319,11 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt
} satisfies GoogleOptions);
};
function createClient(
async function createClient(
model: Model<"google-generative-ai">,
apiKey?: string,
optionsHeaders?: Record<string, string>,
): GoogleGenAI {
): Promise<GoogleGenAI> {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
if (model.baseUrl) {
httpOptions.baseUrl = model.baseUrl;
@ -322,7 +333,8 @@ function createClient(
httpOptions.headers = { ...model.headers, ...optionsHeaders };
}
return new GoogleGenAI({
const GoogleGenAIClass = await getGoogleGenAIClass();
return new GoogleGenAIClass({
apiKey,
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});

View file

@ -1,4 +1,6 @@
import { Mistral } from "@mistralai/mistralai";
// Lazy-loaded: Mistral SDK (~369ms) is imported on first use, not at startup.
// This avoids penalizing users who don't use Mistral models.
import type { Mistral } from "@mistralai/mistralai";
import type { RequestOptions } from "@mistralai/mistralai/lib/sdks.js";
import type {
ChatCompletionStreamRequest,
@ -7,6 +9,15 @@ import type {
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components/index.js";
let _MistralClass: typeof Mistral | undefined;
async function getMistralClass(): Promise<typeof Mistral> {
if (!_MistralClass) {
const mod = await import("@mistralai/mistralai");
_MistralClass = mod.Mistral;
}
return _MistralClass;
}
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost } from "../models.js";
import type {
@ -61,7 +72,8 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
}
// Intentionally per-request: avoids shared SDK mutable state across concurrent consumers.
const mistral = new Mistral({
const MistralSDK = await getMistralClass();
const mistral = new MistralSDK({
apiKey,
serverURL: model.baseUrl,
});

View file

@ -369,22 +369,26 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*
* Extensions are loaded in parallel to reduce wall-clock time (~30-50% faster
* than sequential loading for I/O-bound jiti compilation).
*/
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
const results = await Promise.all(
paths.map((extPath) => loadExtension(extPath, cwd, resolvedEventBus, runtime)),
);
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
for (let i = 0; i < results.length; i++) {
const { extension, error } = results[i];
if (error) {
errors.push({ path: extPath, error });
continue;
}
if (extension) {
errors.push({ path: paths[i], error });
} else if (extension) {
extensions.push(extension);
}
}

View file

@ -1,7 +1,52 @@
#!/usr/bin/env node
// GSD Startup Loader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { fileURLToPath } from 'url'
import { dirname, resolve, join, delimiter } from 'path'
import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync } from 'fs'
// Fast-path: handle --version/-v and --help/-h before importing any heavy
// dependencies. This avoids loading the entire pi-coding-agent barrel import
// (~1s) just to print a version string.
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2)
const firstArg = args[0]
if (firstArg === '--version' || firstArg === '-v') {
try {
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
process.stdout.write((pkg.version || '0.0.0') + '\n')
} catch {
process.stdout.write('0.0.0\n')
}
process.exit(0)
}
if (firstArg === '--help' || firstArg === '-h') {
let version = '0.0.0'
try {
const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8'))
version = pkg.version || version
} catch { /* ignore */ }
process.stdout.write(`GSD v${version} — Get Shit Done\n\n`)
process.stdout.write('Usage: gsd [options] [message...]\n\n')
process.stdout.write('Options:\n')
process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n')
process.stdout.write(' --print, -p Single-shot print mode\n')
process.stdout.write(' --continue, -c Resume the most recent session\n')
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
process.stdout.write(' --no-session Disable session persistence\n')
process.stdout.write(' --extension <path> Load additional extension\n')
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')
process.stdout.write(' --list-models [search] List available models and exit\n')
process.stdout.write(' --version, -v Print version and exit\n')
process.stdout.write(' --help, -h Print this help and exit\n')
process.stdout.write('\nSubcommands:\n')
process.stdout.write(' config Re-run the setup wizard\n')
process.stdout.write(' update Update GSD to the latest version\n')
process.exit(0)
}
import { agentDir, appRoot } from './app-paths.js'
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
import { renderLogo } from './logo.js'
@ -46,7 +91,6 @@ process.env.GSD_CODING_AGENT_DIR = agentDir
// Without this, extensions (e.g. browser-tools) can't resolve dependencies like
// `playwright` because jiti resolves modules from pi-coding-agent's location, not gsd's.
// Prepending gsd's node_modules to NODE_PATH fixes this for all extensions.
const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const gsdNodeModules = join(gsdRoot, 'node_modules')
process.env.NODE_PATH = [gsdNodeModules, process.env.NODE_PATH]
.filter(Boolean)
@ -72,9 +116,8 @@ process.env.GSD_BIN_PATH = process.argv[1]
// GSD_WORKFLOW_PATH — absolute path to bundled GSD-WORKFLOW.md, used by patched gsd extension
// when dispatching workflow prompts. Prefers dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree) — see resource-loader.ts for rationale.
const loaderPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const distRes = join(loaderPackageRoot, 'dist', 'resources')
const srcRes = join(loaderPackageRoot, 'src', 'resources')
const distRes = join(gsdRoot, 'dist', 'resources')
const srcRes = join(gsdRoot, 'src', 'resources')
const resourcesDir = existsSync(distRes) ? distRes : srcRes
process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md')
@ -116,8 +159,11 @@ process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discove
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests.
// pi-coding-agent's cli.ts sets this, but GSD bypasses that entry point — so we
// must set it here before any SDK clients are created.
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new EnvHttpProxyAgent())
// Lazy-load undici (~200ms) only when proxy env vars are actually set.
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) {
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici')
setGlobalDispatcher(new EnvHttpProxyAgent())
}
// Ensure workspace packages are linked before importing cli.js (which imports @gsd/*).
// npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.

View file

@ -126,21 +126,29 @@ export function getNewerManagedResourceVersion(agentDir: string, currentVersion:
/**
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
*
* - extensions/ ~/.gsd/agent/extensions/ (always overwrite ensures updates ship on next launch)
* - agents/ ~/.gsd/agent/agents/ (always overwrite)
* - skills/ ~/.gsd/agent/skills/ (always overwrite)
* - extensions/ ~/.gsd/agent/extensions/ (overwrite when version changes)
* - agents/ ~/.gsd/agent/agents/ (overwrite when version changes)
* - skills/ ~/.gsd/agent/skills/ (overwrite when version changes)
* - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
*
* Always-overwrite ensures `npm update -g @glittercowboy/gsd` takes effect immediately.
* User customizations should go in ~/.gsd/agent/extensions/ subdirs with unique names,
* not by editing the gsd-managed files.
* Skips the copy when the managed-resources.json version matches the current
* GSD version, avoiding ~128ms of synchronous cpSync on every startup.
* After `npm update -g @glittercowboy/gsd`, versions will differ and the
* copy runs once to land the new resources.
*
* Inspectable: `ls ~/.gsd/agent/extensions/`
*/
export function initResources(agentDir: string): void {
mkdirSync(agentDir, { recursive: true })
// Sync extensions — always overwrite so updates land on next launch
// Skip resource sync when versions match — saves ~128ms of cpSync per launch
const currentVersion = getBundledGsdVersion()
const managedVersion = readManagedResourceVersion(agentDir)
if (managedVersion && managedVersion === currentVersion) {
return
}
// Sync extensions — overwrite so updates land on next launch
const destExtensions = join(agentDir, 'extensions')
cpSync(bundledExtensionsDir, destExtensions, { recursive: true, force: true })
@ -151,7 +159,7 @@ export function initResources(agentDir: string): void {
cpSync(srcAgents, destAgents, { recursive: true, force: true })
}
// Sync skills — always overwrite so updates land on next launch
// Sync skills — overwrite so updates land on next launch
const destSkills = join(agentDir, 'skills')
const srcSkills = join(resourcesDir, 'skills')
if (existsSync(srcSkills)) {