perf: startup optimizations — pre-compiled extensions, compile cache, batch discovery (#2125)
Skip jiti JIT compilation for bundled extensions that have pre-compiled .js siblings, enable V8 bytecode caching on Node 22+, and batch directory discovery to reduce syscalls during resource loading. Fixes #2108 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c75f69610f
commit
f4ee51017a
4 changed files with 295 additions and 59 deletions
|
|
@ -569,6 +569,24 @@ function createExtensionAPI(
|
|||
}
|
||||
|
||||
async function loadExtensionModule(extensionPath: string) {
|
||||
// Pre-compiled extension loading: if the source is .ts and a sibling .js
|
||||
// file exists with matching or newer mtime, use native import() to skip
|
||||
// jiti JIT compilation entirely. This is the biggest startup win for
|
||||
// bundled extensions that have already been built.
|
||||
if (extensionPath.endsWith(".ts")) {
|
||||
const jsPath = extensionPath.replace(/\.ts$/, ".js");
|
||||
try {
|
||||
const [tsStat, jsStat] = [fs.statSync(extensionPath), fs.statSync(jsPath)];
|
||||
if (jsStat.mtimeMs >= tsStat.mtimeMs) {
|
||||
const module = await import(jsPath);
|
||||
const factory = (module.default ?? module) as ExtensionFactory;
|
||||
return typeof factory !== "function" ? undefined : factory;
|
||||
}
|
||||
} catch {
|
||||
// .js file doesn't exist or stat failed — fall through to jiti
|
||||
}
|
||||
}
|
||||
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
moduleCache: false,
|
||||
...getJitiOptions(),
|
||||
|
|
|
|||
|
|
@ -1562,6 +1562,26 @@ export class DefaultPackageManager implements PackageManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-discover which resource subdirectories exist under a parent dir.
|
||||
* A single readdirSync replaces 4 separate existsSync probes, reducing
|
||||
* syscalls during startup.
|
||||
*/
|
||||
private discoverResourceSubdirs(baseDir: string): Set<string> {
|
||||
try {
|
||||
const entries = readdirSync(baseDir, { withFileTypes: true });
|
||||
const names = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() || e.isSymbolicLink()) {
|
||||
names.add(e.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
private addAutoDiscoveredResources(
|
||||
accumulator: ResourceAccumulator,
|
||||
globalSettings: ReturnType<SettingsManager["getGlobalSettings"]>,
|
||||
|
|
@ -1595,6 +1615,11 @@ export class DefaultPackageManager implements PackageManager {
|
|||
themes: (projectSettings.themes ?? []) as string[],
|
||||
};
|
||||
|
||||
// Batch directory discovery: one readdir of each parent replaces up to
|
||||
// 4 separate existsSync calls per base directory, cutting syscalls.
|
||||
const projectSubdirs = this.discoverResourceSubdirs(projectBaseDir);
|
||||
const userSubdirs = this.discoverResourceSubdirs(globalBaseDir);
|
||||
|
||||
const userDirs = {
|
||||
extensions: join(globalBaseDir, "extensions"),
|
||||
skills: join(globalBaseDir, "skills"),
|
||||
|
|
@ -1626,66 +1651,82 @@ export class DefaultPackageManager implements PackageManager {
|
|||
}
|
||||
};
|
||||
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(projectDirs.extensions),
|
||||
projectMetadata,
|
||||
projectOverrides.extensions,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"skills",
|
||||
[
|
||||
...collectAutoSkillEntries(projectDirs.skills),
|
||||
// Project resources — skip collect calls when the parent readdir shows
|
||||
// the subdirectory doesn't exist (avoids redundant existsSync + readdirSync).
|
||||
if (projectSubdirs.has("extensions")) {
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(projectDirs.extensions),
|
||||
projectMetadata,
|
||||
projectOverrides.extensions,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
{
|
||||
const skillEntries = [
|
||||
...(projectSubdirs.has("skills") ? collectAutoSkillEntries(projectDirs.skills) : []),
|
||||
...projectAgentsSkillDirs.flatMap((dir) => collectAutoSkillEntries(dir)),
|
||||
],
|
||||
projectMetadata,
|
||||
projectOverrides.skills,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(projectDirs.prompts),
|
||||
projectMetadata,
|
||||
projectOverrides.prompts,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(projectDirs.themes),
|
||||
projectMetadata,
|
||||
projectOverrides.themes,
|
||||
projectBaseDir,
|
||||
);
|
||||
];
|
||||
if (skillEntries.length > 0) {
|
||||
addResources("skills", skillEntries, projectMetadata, projectOverrides.skills, projectBaseDir);
|
||||
}
|
||||
}
|
||||
if (projectSubdirs.has("prompts")) {
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(projectDirs.prompts),
|
||||
projectMetadata,
|
||||
projectOverrides.prompts,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
if (projectSubdirs.has("themes")) {
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(projectDirs.themes),
|
||||
projectMetadata,
|
||||
projectOverrides.themes,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(userDirs.extensions),
|
||||
userMetadata,
|
||||
userOverrides.extensions,
|
||||
globalBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"skills",
|
||||
[...collectAutoSkillEntries(userDirs.skills), ...collectAutoSkillEntries(userAgentsSkillsDir)],
|
||||
userMetadata,
|
||||
userOverrides.skills,
|
||||
globalBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(userDirs.prompts),
|
||||
userMetadata,
|
||||
userOverrides.prompts,
|
||||
globalBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(userDirs.themes),
|
||||
userMetadata,
|
||||
userOverrides.themes,
|
||||
globalBaseDir,
|
||||
);
|
||||
// User (global) resources
|
||||
if (userSubdirs.has("extensions")) {
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(userDirs.extensions),
|
||||
userMetadata,
|
||||
userOverrides.extensions,
|
||||
globalBaseDir,
|
||||
);
|
||||
}
|
||||
{
|
||||
const skillEntries = [
|
||||
...(userSubdirs.has("skills") ? collectAutoSkillEntries(userDirs.skills) : []),
|
||||
...collectAutoSkillEntries(userAgentsSkillsDir),
|
||||
];
|
||||
if (skillEntries.length > 0) {
|
||||
addResources("skills", skillEntries, userMetadata, userOverrides.skills, globalBaseDir);
|
||||
}
|
||||
}
|
||||
if (userSubdirs.has("prompts")) {
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(userDirs.prompts),
|
||||
userMetadata,
|
||||
userOverrides.prompts,
|
||||
globalBaseDir,
|
||||
);
|
||||
}
|
||||
if (userSubdirs.has("themes")) {
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(userDirs.themes),
|
||||
userMetadata,
|
||||
userOverrides.themes,
|
||||
globalBaseDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] {
|
||||
|
|
|
|||
19
src/cli.ts
19
src/cli.ts
|
|
@ -29,6 +29,15 @@ import { stopWebMode } from './web-mode.js'
|
|||
import { getProjectSessionsDir } from './project-sessions.js'
|
||||
import { markStartup, printStartupTimings } from './startup-timings.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V8 compile cache — Node 22+ can cache compiled bytecode across runs,
|
||||
// eliminating repeated parse/compile overhead for unchanged modules.
|
||||
// Must be set early so dynamic imports (extensions, lazy subcommands) benefit.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (parseInt(process.versions.node) >= 22) {
|
||||
process.env.NODE_COMPILE_CACHE ??= join(agentDir, '.compile-cache')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal CLI arg parser — detects print/subagent mode flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -538,8 +547,16 @@ const sessionManager = cliFlags._selectedSessionPath
|
|||
exitIfManagedResourcesAreNewer(agentDir)
|
||||
initResources(agentDir)
|
||||
markStartup('initResources')
|
||||
|
||||
// Overlap resource loading with session manager setup — both are independent.
|
||||
// resourceLoader.reload() is the most expensive step (jiti compilation), so
|
||||
// starting it early shaves ~50-200ms off interactive startup.
|
||||
const resourceLoader = buildResourceLoader(agentDir)
|
||||
await resourceLoader.reload()
|
||||
const resourceLoadPromise = resourceLoader.reload()
|
||||
|
||||
// While resources load, let session manager finish any async I/O it needs.
|
||||
// Then await the resource promise before creating the agent session.
|
||||
await resourceLoadPromise
|
||||
markStartup('resourceLoader.reload')
|
||||
|
||||
const { session, extensionsResult } = await createAgentSession({
|
||||
|
|
|
|||
160
src/tests/startup-perf.test.ts
Normal file
160
src/tests/startup-perf.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
// ─── Pre-compiled extension loading ──────────────────────────────────────────
|
||||
|
||||
describe("pre-compiled extension loading", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "precompiled-ext-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3 });
|
||||
} catch {
|
||||
// Ignore cleanup errors on Windows
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers .js sibling over .ts when .js is newer", async () => {
|
||||
// Create a .ts file
|
||||
const tsPath = path.join(tmpDir, "ext.ts");
|
||||
fs.writeFileSync(tsPath, `export default function ext() { return "ts"; }`);
|
||||
|
||||
// Create a .js file with a newer mtime
|
||||
const jsPath = path.join(tmpDir, "ext.js");
|
||||
fs.writeFileSync(jsPath, `export default function ext() { return "js"; }`);
|
||||
|
||||
// Make .js newer than .ts
|
||||
const now = new Date();
|
||||
const past = new Date(now.getTime() - 10_000);
|
||||
fs.utimesSync(tsPath, past, past);
|
||||
fs.utimesSync(jsPath, now, now);
|
||||
|
||||
const tsStat = fs.statSync(tsPath);
|
||||
const jsStat = fs.statSync(jsPath);
|
||||
assert.ok(jsStat.mtimeMs >= tsStat.mtimeMs, ".js should have matching or newer mtime");
|
||||
});
|
||||
|
||||
it("falls back to .ts when no .js sibling exists", () => {
|
||||
const tsPath = path.join(tmpDir, "ext.ts");
|
||||
fs.writeFileSync(tsPath, `export default function ext() { return "ts"; }`);
|
||||
|
||||
const jsPath = path.join(tmpDir, "ext.js");
|
||||
assert.ok(!fs.existsSync(jsPath), ".js should not exist");
|
||||
});
|
||||
|
||||
it("falls back to .ts when .js is older", () => {
|
||||
const tsPath = path.join(tmpDir, "ext.ts");
|
||||
fs.writeFileSync(tsPath, `export default function ext() { return "ts"; }`);
|
||||
|
||||
const jsPath = path.join(tmpDir, "ext.js");
|
||||
fs.writeFileSync(jsPath, `export default function ext() { return "js-stale"; }`);
|
||||
|
||||
// Make .ts newer
|
||||
const now = new Date();
|
||||
const past = new Date(now.getTime() - 10_000);
|
||||
fs.utimesSync(jsPath, past, past);
|
||||
fs.utimesSync(tsPath, now, now);
|
||||
|
||||
const tsStat = fs.statSync(tsPath);
|
||||
const jsStat = fs.statSync(jsPath);
|
||||
assert.ok(jsStat.mtimeMs < tsStat.mtimeMs, ".js should be older than .ts");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Batch directory discovery ───────────────────────────────────────────────
|
||||
|
||||
describe("batch directory discovery", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "batch-discover-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3 });
|
||||
} catch {
|
||||
// Ignore cleanup errors on Windows
|
||||
}
|
||||
});
|
||||
|
||||
it("single readdir discovers existing subdirectories", () => {
|
||||
// Create some resource subdirectories
|
||||
fs.mkdirSync(path.join(tmpDir, "extensions"));
|
||||
fs.mkdirSync(path.join(tmpDir, "skills"));
|
||||
// prompts and themes do NOT exist
|
||||
|
||||
const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
|
||||
const subdirs = new Set(
|
||||
entries.filter((e) => e.isDirectory()).map((e) => e.name),
|
||||
);
|
||||
|
||||
assert.ok(subdirs.has("extensions"));
|
||||
assert.ok(subdirs.has("skills"));
|
||||
assert.ok(!subdirs.has("prompts"));
|
||||
assert.ok(!subdirs.has("themes"));
|
||||
});
|
||||
|
||||
it("returns empty set for non-existent parent directory", () => {
|
||||
const missing = path.join(tmpDir, "does-not-exist");
|
||||
let subdirs = new Set<string>();
|
||||
try {
|
||||
const entries = fs.readdirSync(missing, { withFileTypes: true });
|
||||
subdirs = new Set(
|
||||
entries.filter((e) => e.isDirectory()).map((e) => e.name),
|
||||
);
|
||||
} catch {
|
||||
subdirs = new Set();
|
||||
}
|
||||
|
||||
assert.equal(subdirs.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Node.js compile cache ──────────────────────────────────────────────────
|
||||
|
||||
describe("Node.js compile cache env setup", () => {
|
||||
it("NODE_COMPILE_CACHE is settable on Node 22+", () => {
|
||||
const nodeVersion = parseInt(process.versions.node);
|
||||
if (nodeVersion >= 22) {
|
||||
// Verify the env var mechanism works (does not throw)
|
||||
const original = process.env.NODE_COMPILE_CACHE;
|
||||
try {
|
||||
process.env.NODE_COMPILE_CACHE = path.join(os.tmpdir(), ".test-compile-cache");
|
||||
assert.equal(
|
||||
process.env.NODE_COMPILE_CACHE,
|
||||
path.join(os.tmpdir(), ".test-compile-cache"),
|
||||
);
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.NODE_COMPILE_CACHE;
|
||||
} else {
|
||||
process.env.NODE_COMPILE_CACHE = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not overwrite existing NODE_COMPILE_CACHE", () => {
|
||||
const original = process.env.NODE_COMPILE_CACHE;
|
||||
try {
|
||||
process.env.NODE_COMPILE_CACHE = "/custom/cache";
|
||||
// Simulate the ??= behavior from cli.ts
|
||||
process.env.NODE_COMPILE_CACHE ??= "/should-not-overwrite";
|
||||
assert.equal(process.env.NODE_COMPILE_CACHE, "/custom/cache");
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.NODE_COMPILE_CACHE;
|
||||
} else {
|
||||
process.env.NODE_COMPILE_CACHE = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue