chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# CPU Performance Integration Test Harness
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains performance/CPU integration tests for the Gemini CLI.
|
||||
These tests measure wall-clock time, CPU usage, and event loop responsiveness to
|
||||
detect regressions across key scenarios.
|
||||
|
||||
CPU performance is inherently noisy, especially in CI. The harness addresses
|
||||
this with:
|
||||
|
||||
- **IQR outlier filtering** — discards anomalous samples
|
||||
- **Median sampling** — takes N runs, reports the median after filtering
|
||||
- **Warmup runs** — discards the first run to mitigate JIT compilation noise
|
||||
- **15% default tolerance** — won't panic at slight regressions
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Run tests (compare against committed baselines)
|
||||
npm run test:perf
|
||||
|
||||
# Update baselines (after intentional changes)
|
||||
npm run test:perf:update-baselines
|
||||
|
||||
# Verbose output
|
||||
VERBOSE=true npm run test:perf
|
||||
|
||||
# Keep test artifacts for debugging
|
||||
KEEP_OUTPUT=true npm run test:perf
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Measurement Primitives
|
||||
|
||||
The `PerfTestHarness` class (in `packages/test-utils`) provides:
|
||||
|
||||
- **`performance.now()`** — high-resolution wall-clock timing
|
||||
- **`process.cpuUsage()`** — user + system CPU microseconds (delta between
|
||||
start/stop)
|
||||
- **`perf_hooks.monitorEventLoopDelay()`** — event loop delay histogram
|
||||
(p50/p95/p99/max)
|
||||
|
||||
### Noise Reduction
|
||||
|
||||
1. **Warmup**: First run is discarded to mitigate JIT compilation artifacts
|
||||
2. **Multiple samples**: Each scenario runs N times (default 5)
|
||||
3. **IQR filtering**: Samples outside Q1−1.5×IQR and Q3+1.5×IQR are discarded
|
||||
4. **Median**: The median of remaining samples is used for comparison
|
||||
|
||||
### Baseline Management
|
||||
|
||||
Baselines are stored in `baselines.json` in this directory. Each scenario has:
|
||||
|
||||
```json
|
||||
{
|
||||
"cold-startup-time": {
|
||||
"wallClockMs": 1234.5,
|
||||
"cpuTotalUs": 567890,
|
||||
"eventLoopDelayP99Ms": 12.3,
|
||||
"timestamp": "2026-04-08T..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tests fail if the measured value exceeds `baseline × 1.15` (15% tolerance).
|
||||
|
||||
To recalibrate after intentional changes:
|
||||
|
||||
```bash
|
||||
npm run test:perf:update-baselines
|
||||
# then commit baselines.json
|
||||
```
|
||||
|
||||
### Report Output
|
||||
|
||||
After all tests, the harness prints an ASCII summary:
|
||||
|
||||
```
|
||||
═══════════════════════════════════════════════════
|
||||
PERFORMANCE TEST REPORT
|
||||
═══════════════════════════════════════════════════
|
||||
|
||||
cold-startup-time: 1234.5 ms (Baseline: 1200.0 ms, Delta: +2.9%) ✅
|
||||
idle-cpu-usage: 2.1 % (Baseline: 2.0 %, Delta: +5.0%) ✅
|
||||
skill-loading-time: 1567.8 ms (Baseline: 1500.0 ms, Delta: +4.5%) ✅
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
perf-tests/
|
||||
├── README.md ← you are here
|
||||
├── baselines.json ← committed baseline values
|
||||
├── globalSetup.ts ← test environment setup
|
||||
├── perf-usage.test.ts ← test scenarios
|
||||
├── perf.*.responses ← fake API responses per scenario
|
||||
├── tsconfig.json ← TypeScript config
|
||||
└── vitest.config.ts ← vitest config (serial, isolated)
|
||||
|
||||
packages/test-utils/src/
|
||||
├── perf-test-harness.ts ← PerfTestHarness class
|
||||
└── index.ts ← re-exports
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
These tests are **excluded from `preflight`** and designed for nightly CI:
|
||||
|
||||
```yaml
|
||||
- name: Performance regression tests
|
||||
run: npm run test:perf
|
||||
```
|
||||
|
||||
## Adding a New Scenario
|
||||
|
||||
1. Add a fake response file: `perf.<scenario-name>.responses`
|
||||
2. Add a test case in `perf-usage.test.ts` using `harness.runScenario()`
|
||||
3. Run `npm run test:perf:update-baselines` to establish initial baseline
|
||||
4. Commit the updated `baselines.json`
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-14T14:04:02.662Z",
|
||||
"scenarios": {
|
||||
"cold-startup-time": {
|
||||
"wallClockMs": 927.6,
|
||||
"cpuTotalUs": 1470,
|
||||
"timestamp": "2026-04-08T22:27:54.871Z"
|
||||
},
|
||||
"idle-cpu-usage": {
|
||||
"wallClockMs": 5000.5,
|
||||
"cpuTotalUs": 12157,
|
||||
"timestamp": "2026-04-08T22:28:19.098Z"
|
||||
},
|
||||
"asian-language-conv": {
|
||||
"wallClockMs": 2315.1,
|
||||
"cpuTotalUs": 6283,
|
||||
"timestamp": "2026-04-14T15:22:56.133Z"
|
||||
},
|
||||
"skill-loading-time": {
|
||||
"wallClockMs": 930.1,
|
||||
"cpuTotalUs": 1323,
|
||||
"timestamp": "2026-04-08T22:28:23.290Z"
|
||||
},
|
||||
"high-volume-shell-output": {
|
||||
"wallClockMs": 1119.9,
|
||||
"cpuTotalUs": 2100,
|
||||
"timestamp": "2026-04-09T02:30:22.000Z"
|
||||
},
|
||||
"long-conversation-resume": {
|
||||
"wallClockMs": 4212.5,
|
||||
"cpuTotalUs": 351393,
|
||||
"timestamp": "2026-04-14T14:02:53.268Z"
|
||||
},
|
||||
"long-conversation-typing": {
|
||||
"wallClockMs": 113.7,
|
||||
"cpuTotalUs": 3304,
|
||||
"timestamp": "2026-04-14T14:03:12.525Z"
|
||||
},
|
||||
"long-conversation-execution": {
|
||||
"wallClockMs": 248.7,
|
||||
"cpuTotalUs": 3825,
|
||||
"timestamp": "2026-04-14T14:03:28.575Z"
|
||||
},
|
||||
"long-conversation-terminal-scrolling": {
|
||||
"wallClockMs": 362.4,
|
||||
"cpuTotalUs": 12755860,
|
||||
"timestamp": "2026-04-14T14:03:45.687Z"
|
||||
},
|
||||
"long-conversation-alternate-scrolling": {
|
||||
"wallClockMs": 362.4,
|
||||
"cpuTotalUs": 12755860,
|
||||
"timestamp": "2026-04-14T14:04:02.662Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { isolateTestEnv } from '../packages/test-utils/src/env-setup.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
const perfTestsDir = join(rootDir, '.perf-tests');
|
||||
const KEEP_RUNS_COUNT = 5;
|
||||
let runDir = '';
|
||||
|
||||
export async function setup() {
|
||||
runDir = join(perfTestsDir, `${Date.now()}`);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
|
||||
// Isolate environment variables
|
||||
isolateTestEnv(runDir);
|
||||
|
||||
// Download ripgrep to avoid race conditions
|
||||
const available = await canUseRipgrep();
|
||||
if (!available) {
|
||||
throw new Error('Failed to download ripgrep binary');
|
||||
}
|
||||
|
||||
// Clean up old test runs, keeping the latest few for debugging
|
||||
try {
|
||||
const testRuns = await readdir(perfTestsDir);
|
||||
if (testRuns.length > KEEP_RUNS_COUNT) {
|
||||
const oldRuns = testRuns
|
||||
.sort()
|
||||
.slice(0, testRuns.length - KEEP_RUNS_COUNT);
|
||||
await Promise.all(
|
||||
oldRuns.map((oldRun) =>
|
||||
rm(join(perfTestsDir, oldRun), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up old perf test runs:', e);
|
||||
}
|
||||
|
||||
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
|
||||
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
|
||||
|
||||
console.log(`\nPerf test output directory: ${runDir}`);
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
// Cleanup unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
|
||||
try {
|
||||
await rm(runDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn('Failed to clean up perf test directory:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
PerfTestHarness,
|
||||
type PerfSnapshot,
|
||||
} from '@google/gemini-cli-test-utils';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINES_PATH = join(__dirname, 'baselines.json');
|
||||
const UPDATE_BASELINES = process.env['UPDATE_PERF_BASELINES'] === 'true';
|
||||
const TOLERANCE_PERCENT = 15;
|
||||
|
||||
// Use fewer samples locally for faster iteration, more in CI
|
||||
const SAMPLE_COUNT = process.env['CI'] ? 5 : 3;
|
||||
const WARMUP_COUNT = 1;
|
||||
|
||||
describe('CPU Performance Tests', () => {
|
||||
let harness: PerfTestHarness;
|
||||
|
||||
beforeAll(() => {
|
||||
harness = new PerfTestHarness({
|
||||
baselinesPath: BASELINES_PATH,
|
||||
defaultTolerancePercent: TOLERANCE_PERCENT,
|
||||
sampleCount: SAMPLE_COUNT,
|
||||
warmupCount: WARMUP_COUNT,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Generate the summary report after all tests
|
||||
await harness.generateReport();
|
||||
}, 30000);
|
||||
|
||||
it('cold-startup-time: startup completes within baseline', async () => {
|
||||
const result = await harness.runScenario('cold-startup-time', async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-cold-startup', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.cold-startup.responses'),
|
||||
});
|
||||
|
||||
return await harness.measure('cold-startup', async () => {
|
||||
await rig.run({
|
||||
args: ['hello'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('idle-cpu-usage: CPU stays low when idle', async () => {
|
||||
const IDLE_OBSERVATION_MS = 5000;
|
||||
|
||||
const result = await harness.runScenario('idle-cpu-usage', async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-idle-cpu', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.idle-cpu.responses'),
|
||||
});
|
||||
|
||||
// First, run a prompt to get the CLI into idle state
|
||||
await rig.run({
|
||||
args: ['hello'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
|
||||
// Now measure CPU during idle period in the test process
|
||||
return await harness.measureWithEventLoop('idle-cpu', async () => {
|
||||
// Simulate idle period — just wait
|
||||
const { setTimeout: sleep } = await import('node:timers/promises');
|
||||
await sleep(IDLE_OBSERVATION_MS);
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('asian-language-conv: verify perf is acceptable ', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'asian-language-conv',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-asian-language', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'),
|
||||
});
|
||||
|
||||
return await harness.measure('asian-language', async () => {
|
||||
await rig.run({
|
||||
args: ['嗨'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('skill-loading-time: startup with many skills within baseline', async () => {
|
||||
const SKILL_COUNT = 20;
|
||||
|
||||
const result = await harness.runScenario('skill-loading-time', async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-skill-loading', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.skill-loading.responses'),
|
||||
});
|
||||
|
||||
// Create many skill directories with SKILL.md files
|
||||
for (let i = 0; i < SKILL_COUNT; i++) {
|
||||
const skillDir = `.gemini/skills/perf-skill-${i}`;
|
||||
rig.mkdir(skillDir);
|
||||
rig.createFile(
|
||||
`${skillDir}/SKILL.md`,
|
||||
[
|
||||
'---',
|
||||
`name: perf-skill-${i}`,
|
||||
`description: Performance test skill number ${i}`,
|
||||
`activation: manual`,
|
||||
'---',
|
||||
'',
|
||||
`# Performance Test Skill ${i}`,
|
||||
'',
|
||||
`This is a test skill for measuring skill loading performance.`,
|
||||
`It contains some content to simulate real-world skill files.`,
|
||||
'',
|
||||
`## Usage`,
|
||||
'',
|
||||
`Use this skill by activating it with @perf-skill-${i}.`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
return await harness.measure('skill-loading', async () => {
|
||||
await rig.run({
|
||||
args: ['hello'],
|
||||
timeout: 120000,
|
||||
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('high-volume-shell-output: handles large output efficiently', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'high-volume-shell-output',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
try {
|
||||
rig.setup('perf-high-volume-output', {
|
||||
fakeResponsesPath: join(__dirname, 'perf.high-volume.responses'),
|
||||
});
|
||||
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'high-volume-output',
|
||||
async () => {
|
||||
await rig.run({
|
||||
args: ['Generate 1M lines of output'],
|
||||
timeout: 120000,
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Query CLI's own performance metrics from telemetry logs
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
// Debug: Read and log the telemetry file content
|
||||
try {
|
||||
const logFilePath = join(rig.homeDir!, 'telemetry.log');
|
||||
if (existsSync(logFilePath)) {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
console.log(` Telemetry Log Content:\n`, content);
|
||||
} else {
|
||||
console.log(` Telemetry log file not found at: ${logFilePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` Failed to read telemetry log:`, e);
|
||||
}
|
||||
|
||||
const memoryMetric = rig.readMetric('memory.usage');
|
||||
const cpuMetric = rig.readMetric('cpu.usage');
|
||||
const toolLatencyMetric = rig.readMetric('tool.call.latency');
|
||||
const eventLoopMetric = rig.readMetric('event_loop.delay');
|
||||
|
||||
if (memoryMetric) {
|
||||
console.log(
|
||||
` CLI Memory Metric found:`,
|
||||
JSON.stringify(memoryMetric),
|
||||
);
|
||||
}
|
||||
if (cpuMetric) {
|
||||
console.log(` CLI CPU Metric found:`, JSON.stringify(cpuMetric));
|
||||
}
|
||||
if (toolLatencyMetric) {
|
||||
console.log(
|
||||
` CLI Tool Latency Metric found:`,
|
||||
JSON.stringify(toolLatencyMetric),
|
||||
);
|
||||
}
|
||||
const logs = rig.readTelemetryLogs();
|
||||
console.log(` Total telemetry log entries: ${logs.length}`);
|
||||
for (const logData of logs) {
|
||||
if (logData.scopeMetrics) {
|
||||
for (const scopeMetric of logData.scopeMetrics) {
|
||||
for (const metric of scopeMetric.metrics) {
|
||||
if (metric.descriptor.name.includes('event_loop')) {
|
||||
console.log(
|
||||
` Found event_loop metric in log:`,
|
||||
metric.descriptor.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventLoopMetric) {
|
||||
console.log(
|
||||
` CLI Event Loop Metric found:`,
|
||||
JSON.stringify(eventLoopMetric),
|
||||
);
|
||||
|
||||
const findValue = (percentile: string) => {
|
||||
const dp = eventLoopMetric.dataPoints.find(
|
||||
(p) => p.attributes?.['percentile'] === percentile,
|
||||
);
|
||||
return dp?.value?.min;
|
||||
};
|
||||
|
||||
snapshot.childEventLoopDelayP50Ms = findValue('p50');
|
||||
snapshot.childEventLoopDelayP95Ms = findValue('p95');
|
||||
snapshot.childEventLoopDelayMaxMs = findValue('max');
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
describe('long-conversation', () => {
|
||||
let rig: TestRig;
|
||||
const identifier = 'perf-long-conversation';
|
||||
const SESSION_ID =
|
||||
'anonymous_unique_id_577296e0eee5afecdcec05d11838e0cd1a851cd97a28119a4a876b11';
|
||||
const LARGE_CHAT_SOURCE = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'memory-tests',
|
||||
'large-chat-session.json',
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!existsSync(LARGE_CHAT_SOURCE)) {
|
||||
throw new Error(
|
||||
`Performance test fixture missing: ${LARGE_CHAT_SOURCE}.`,
|
||||
);
|
||||
}
|
||||
|
||||
rig = new TestRig();
|
||||
rig.setup(identifier, {
|
||||
fakeResponsesPath: join(__dirname, 'perf.long-chat.responses'),
|
||||
});
|
||||
|
||||
const geminiDir = join(rig.homeDir!, '.gemini');
|
||||
const projectTempDir = join(geminiDir, 'tmp', identifier);
|
||||
const targetChatsDir = join(projectTempDir, 'chats');
|
||||
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(geminiDir, 'projects.json'),
|
||||
JSON.stringify({
|
||||
projects: { [rig.testDir!]: identifier },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(projectTempDir, '.project_root'), rig.testDir!);
|
||||
copyFileSync(
|
||||
LARGE_CHAT_SOURCE,
|
||||
join(targetChatsDir, `session-${SESSION_ID}.json`),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('session-load: resume a 60MB chat history', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-resume',
|
||||
async () => {
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'resume',
|
||||
async () => {
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
await run.kill();
|
||||
},
|
||||
);
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('typing: latency when typing into a large session', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-typing',
|
||||
async () => {
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'typing',
|
||||
async () => {
|
||||
// On average, the expected latency per key is under 30ms.
|
||||
for (const char of 'Hello') {
|
||||
await run.type(char);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await run.kill();
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('execution: response latency for a simple shell command', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-execution',
|
||||
async () => {
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.expectText('Type your message');
|
||||
|
||||
const snapshot = await harness.measureWithEventLoop(
|
||||
'execution',
|
||||
async () => {
|
||||
await run.sendKeys('!echo hi\r');
|
||||
await run.expectText('hi');
|
||||
},
|
||||
);
|
||||
|
||||
await run.kill();
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('terminal-scrolling: latency when scrolling a large terminal buffer', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-terminal-scrolling',
|
||||
async () => {
|
||||
// Enable terminalBuffer to intentionally test CLI scrolling logic
|
||||
const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json');
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
security: { folderTrust: { enabled: false } },
|
||||
ui: { terminalBuffer: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.expectText('Type your message');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[5~'); // PageUp
|
||||
}
|
||||
|
||||
// Scroll to the very top
|
||||
await run.sendKeys('\u001b[H'); // Home
|
||||
// Verify top line of chat is visible.
|
||||
await run.expectText('Authenticated with');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[6~'); // PageDown
|
||||
}
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
await run.kill();
|
||||
|
||||
const eventLoopMetric = rig.readMetric('event_loop.delay');
|
||||
const cpuMetric = rig.readMetric('cpu.usage');
|
||||
|
||||
let p50Ms = 0;
|
||||
let p95Ms = 0;
|
||||
let maxMs = 0;
|
||||
if (eventLoopMetric) {
|
||||
const dataPoints = eventLoopMetric.dataPoints;
|
||||
const p50Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p50',
|
||||
);
|
||||
const p95Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p95',
|
||||
);
|
||||
const maxData = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'max',
|
||||
);
|
||||
|
||||
if (p50Data?.value?.sum) p50Ms = p50Data.value.sum;
|
||||
if (p95Data?.value?.sum) p95Ms = p95Data.value.sum;
|
||||
if (maxData?.value?.sum) maxMs = maxData.value.sum;
|
||||
}
|
||||
|
||||
let cpuTotalUs = 0;
|
||||
if (cpuMetric) {
|
||||
const dataPoints = cpuMetric.dataPoints;
|
||||
for (const dp of dataPoints) {
|
||||
if (dp.value?.sum && dp.value.sum > 0) {
|
||||
cpuTotalUs += dp.value.sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cpuUserUs = cpuTotalUs;
|
||||
const cpuSystemUs = 0;
|
||||
|
||||
const snapshot: PerfSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
label: 'scrolling',
|
||||
wallClockMs: Math.round(p50Ms * 10) / 10,
|
||||
cpuTotalUs,
|
||||
cpuUserUs,
|
||||
cpuSystemUs,
|
||||
eventLoopDelayP50Ms: p50Ms,
|
||||
eventLoopDelayP95Ms: p95Ms,
|
||||
eventLoopDelayMaxMs: maxMs,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('alternate-scrolling: latency when scrolling a large alternate buffer', async () => {
|
||||
const result = await harness.runScenario(
|
||||
'long-conversation-alternate-scrolling',
|
||||
async () => {
|
||||
// Enable useAlternateBuffer to intentionally test CLI scrolling logic
|
||||
const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json');
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
security: { folderTrust: { enabled: false } },
|
||||
ui: { useAlternateBuffer: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive({
|
||||
args: ['--resume', 'latest'],
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-perf-test-key',
|
||||
GEMINI_TELEMETRY_ENABLED: 'true',
|
||||
GEMINI_MEMORY_MONITOR_INTERVAL: '500',
|
||||
GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true',
|
||||
DEBUG: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
await run.expectText('Type your message');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[5~'); // PageUp
|
||||
}
|
||||
|
||||
// Scroll to the very top
|
||||
await run.sendKeys('\u001b[H'); // Home
|
||||
// Verify top line of chat is visible.
|
||||
await run.expectText('Authenticated with');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await run.sendKeys('\u001b[6~'); // PageDown
|
||||
}
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
await run.kill();
|
||||
|
||||
const eventLoopMetric = rig.readMetric('event_loop.delay');
|
||||
const cpuMetric = rig.readMetric('cpu.usage');
|
||||
|
||||
let p50Ms = 0;
|
||||
let p95Ms = 0;
|
||||
let maxMs = 0;
|
||||
if (eventLoopMetric) {
|
||||
const dataPoints = eventLoopMetric.dataPoints;
|
||||
const p50Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p50',
|
||||
);
|
||||
const p95Data = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'p95',
|
||||
);
|
||||
const maxData = dataPoints.find(
|
||||
(dp) => dp.attributes?.['percentile'] === 'max',
|
||||
);
|
||||
|
||||
if (p50Data?.value?.sum) p50Ms = p50Data.value.sum;
|
||||
if (p95Data?.value?.sum) p95Ms = p95Data.value.sum;
|
||||
if (maxData?.value?.sum) maxMs = maxData.value.sum;
|
||||
}
|
||||
|
||||
let cpuTotalUs = 0;
|
||||
if (cpuMetric) {
|
||||
const dataPoints = cpuMetric.dataPoints;
|
||||
for (const dp of dataPoints) {
|
||||
if (dp.value?.sum && dp.value.sum > 0) {
|
||||
cpuTotalUs += dp.value.sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cpuUserUs = cpuTotalUs;
|
||||
const cpuSystemUs = 0;
|
||||
|
||||
const snapshot: PerfSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
label: 'scrolling',
|
||||
wallClockMs: Math.round(p50Ms * 10) / 10,
|
||||
cpuTotalUs,
|
||||
cpuUserUs,
|
||||
cpuSystemUs,
|
||||
eventLoopDelayP50Ms: p50Ms,
|
||||
eventLoopDelayP95Ms: p95Ms,
|
||||
eventLoopDelayMaxMs: maxMs,
|
||||
};
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"你好!我是 Gemini CLI,你的 AI 编程助手"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. What would you like to work on?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":12,"totalTokenCount":17,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"yes | head -n 1000000"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have generated 1M lines of output."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":8,"totalTokenCount":13,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"complexity_reasoning\":\"simple\",\"complexity_score\":1}"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am a large conversation model response."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"candidatesTokenCount":10,"promptTokenCount":20,"totalTokenCount":30}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"originalSummary\":\"large chat summary\",\"events\":[]}"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"countTokens","response":{"totalTokens":100}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to assist you with your project."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":10,"totalTokenCount":15,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 600000, // 10 minutes — performance profiling needs time for multiple samples
|
||||
globalSetup: './globalSetup.ts',
|
||||
reporters: ['default'],
|
||||
include: ['**/*.test.ts'],
|
||||
retry: 0, // No retries — noise is handled by IQR filtering and tolerance
|
||||
fileParallelism: false, // Must run serially to avoid CPU contention
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true, // Single process for accurate per-test CPU readings
|
||||
},
|
||||
},
|
||||
env: {
|
||||
GEMINI_TEST_TYPE: 'perf',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user