mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* perf(cli): keep running in place when env files only hold model credentials The env-file relaunch rule from #12602 fires for any value that `.env` files or `settings.env` inject, including the API key that `/auth` stores and the keys the docs recommend for `~/.qwen/.env`. Those users always paid the second boot. Providers read credentials and endpoints per request, never while a module loads, so `hasLoadedEnvironmentValues()` now ignores the documented per-auth-type variables and the `envKey` of every configured model provider. Any other injected value still forces a fresh image, and a provider entry can never exempt a loader or boot-time key. The DashScope proxy base URL was the one such variable read at module load; it is now read at call time. Reported by @wenshao in the #12622 review (F2). Claude-Session: https://claude.ai/code/session_01163b7X4Ntw1S2pr9VrtRrs * feat(scripts): let the startup benchmark load credentials from a file `--credentials settings|dotenv` moves the stub's key and URL from the shell into the settings file's `env` block or `~/.qwen/.env`, so the env-file relaunch rule can be measured. The default stays `shell`. Claude-Session: https://claude.ai/code/session_01163b7X4Ntw1S2pr9VrtRrs
469 lines
13 KiB
JavaScript
469 lines
13 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Shared pieces of the startup benchmark: a clean environment per launch, a
|
|
// loopback model server, a real terminal, and /proc and strace readers.
|
|
// See docs/design/2026-09-25-startup-benchmark-harness.md.
|
|
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { createServer } from 'node:http';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { performance } from 'node:perf_hooks';
|
|
import * as pty from '@lydell/node-pty';
|
|
import xtermHeadless from '@xterm/headless';
|
|
|
|
const { Terminal } = xtermHeadless;
|
|
|
|
export const PROMPT_TEXT = 'Type your message';
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
/** OpenAI-compatible stub that answers every request at once. */
|
|
export async function startModelServer() {
|
|
const requests = new Map();
|
|
const server = createServer((req, res) => {
|
|
const at = performance.now();
|
|
const match = /^\/(run-[^/]+)(\/.*)$/.exec(req.url ?? '');
|
|
const runId = match?.[1] ?? 'unknown';
|
|
if (!requests.has(runId)) requests.set(runId, []);
|
|
requests.get(runId).push(at);
|
|
let body = '';
|
|
req.on('data', (chunk) => (body += chunk));
|
|
req.on('end', () => {
|
|
let stream = false;
|
|
try {
|
|
stream = JSON.parse(body || '{}').stream === true;
|
|
} catch {
|
|
// Not JSON; answer with a plain completion.
|
|
}
|
|
const created = Math.floor(Date.now() / 1000);
|
|
const choice = (delta, finish) => ({
|
|
id: 'bench',
|
|
object: 'chat.completion.chunk',
|
|
created,
|
|
model: 'bench-model',
|
|
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
});
|
|
if (stream) {
|
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
res.write(
|
|
`data: ${JSON.stringify(choice({ role: 'assistant', content: 'OK' }, null))}\n\n`,
|
|
);
|
|
res.write(
|
|
`data: ${JSON.stringify({ ...choice({}, 'stop'), usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`,
|
|
);
|
|
res.end('data: [DONE]\n\n');
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
id: 'bench',
|
|
object: 'chat.completion',
|
|
created,
|
|
model: 'bench-model',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'OK' },
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
return {
|
|
baseUrl: (runId) => `http://127.0.0.1:${port}/${runId}/v1`,
|
|
firstRequestAt: (runId) => requests.get(runId)?.[0],
|
|
close: () => new Promise((resolve) => server.close(resolve)),
|
|
};
|
|
}
|
|
|
|
let runCounter = 0;
|
|
|
|
// Where a run's model credentials come from: exported in the shell, in the
|
|
// settings file's `env` block (where `/auth` stores them), or in
|
|
// `~/.qwen/.env` (where the docs recommend them). The two file sources
|
|
// exercise the env-file relaunch rule.
|
|
export const CREDENTIAL_SOURCES = ['shell', 'settings', 'dotenv'];
|
|
|
|
/**
|
|
* A fresh HOME, XDG and runtime directories, an empty git workspace, and a
|
|
* settings file that selects OpenAI auth. The environment is reduced to what
|
|
* a user's terminal would pass.
|
|
*/
|
|
export function makeRunEnvironment({
|
|
root,
|
|
modelServer,
|
|
tmpDir,
|
|
credentials = 'shell',
|
|
}) {
|
|
if (!CREDENTIAL_SOURCES.includes(credentials)) {
|
|
throw new Error(
|
|
`credentials must be one of ${CREDENTIAL_SOURCES.join(', ')}: ${credentials}`,
|
|
);
|
|
}
|
|
const runId = `run-${process.pid}-${++runCounter}`;
|
|
const dir = path.join(root, runId);
|
|
const home = path.join(dir, 'home');
|
|
const cwd = path.join(dir, 'workspace');
|
|
for (const d of [
|
|
path.join(home, '.qwen'),
|
|
cwd,
|
|
path.join(dir, 'xdg'),
|
|
path.join(dir, 'runtime'),
|
|
]) {
|
|
fs.mkdirSync(d, { recursive: true });
|
|
}
|
|
const modelEnv = {
|
|
OPENAI_API_KEY: 'bench-key',
|
|
OPENAI_BASE_URL: modelServer.baseUrl(runId),
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(home, '.qwen', 'settings.json'),
|
|
JSON.stringify({
|
|
security: { auth: { selectedType: 'openai' } },
|
|
model: { name: 'bench-model' },
|
|
...(credentials === 'settings' ? { env: modelEnv } : {}),
|
|
}),
|
|
);
|
|
if (credentials === 'dotenv') {
|
|
fs.writeFileSync(
|
|
path.join(home, '.qwen', '.env'),
|
|
Object.entries(modelEnv)
|
|
.map(([key, value]) => `${key}=${value}\n`)
|
|
.join(''),
|
|
);
|
|
}
|
|
execFileSync('git', ['init', '-q', cwd], {
|
|
env: { PATH: process.env.PATH, HOME: home },
|
|
});
|
|
const tmp = tmpDir ?? path.join(dir, 'tmp');
|
|
fs.mkdirSync(tmp, { recursive: true });
|
|
return {
|
|
runId,
|
|
dir,
|
|
cwd,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
LANG: 'C.UTF-8',
|
|
TERM: 'xterm-256color',
|
|
HOME: home,
|
|
QWEN_HOME: path.join(home, '.qwen'),
|
|
QWEN_RUNTIME_DIR: path.join(dir, 'runtime'),
|
|
XDG_CONFIG_HOME: path.join(dir, 'xdg', 'config'),
|
|
XDG_CACHE_HOME: path.join(dir, 'xdg', 'cache'),
|
|
XDG_DATA_HOME: path.join(dir, 'xdg', 'data'),
|
|
XDG_STATE_HOME: path.join(dir, 'xdg', 'state'),
|
|
TMPDIR: tmp,
|
|
...(credentials === 'shell' ? modelEnv : {}),
|
|
NO_PROXY: '127.0.0.1,localhost',
|
|
},
|
|
};
|
|
}
|
|
|
|
function readStat(pid) {
|
|
try {
|
|
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
const close = stat.lastIndexOf(')');
|
|
const fields = stat.slice(close + 2).split(' ');
|
|
return {
|
|
pid,
|
|
comm: stat.slice(stat.indexOf('(') + 1, close),
|
|
ppid: Number(fields[1]),
|
|
sid: Number(fields[3]),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Processes in the session led by `rootPid`, plus its descendants. */
|
|
export function processTree(rootPid) {
|
|
if (!fs.existsSync('/proc')) return [];
|
|
const all = [];
|
|
for (const entry of fs.readdirSync('/proc')) {
|
|
if (/^\d+$/.test(entry)) {
|
|
const stat = readStat(Number(entry));
|
|
if (stat) all.push(stat);
|
|
}
|
|
}
|
|
const tree = new Set([rootPid]);
|
|
for (let grew = true; grew; ) {
|
|
grew = false;
|
|
for (const p of all) {
|
|
if (!tree.has(p.pid) && tree.has(p.ppid)) {
|
|
tree.add(p.pid);
|
|
grew = true;
|
|
}
|
|
}
|
|
}
|
|
return all.filter((p) => p.sid === rootPid || tree.has(p.pid));
|
|
}
|
|
|
|
export function rssMb(pids) {
|
|
let kb = 0;
|
|
for (const pid of pids) {
|
|
try {
|
|
kb += Number(
|
|
/VmRSS:\s+(\d+)/.exec(
|
|
fs.readFileSync(`/proc/${pid}/status`, 'utf8'),
|
|
)?.[1] ?? 0,
|
|
);
|
|
} catch {
|
|
// The process exited between the listing and the read.
|
|
}
|
|
}
|
|
return kb / 1024;
|
|
}
|
|
|
|
function killTree(rootPid) {
|
|
for (const p of processTree(rootPid)) {
|
|
try {
|
|
process.kill(p.pid, 'SIGKILL');
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
}
|
|
|
|
function screenText(terminal) {
|
|
const buffer = terminal.buffer.active;
|
|
const lines = [];
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
lines.push(buffer.getLine(i)?.translateToString(true) ?? '');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// A change after the prompt counts only when text already on screen is
|
|
// replaced or cleared; rows filling in are the same frame still arriving.
|
|
function replacesText(before, after) {
|
|
const a = before.split('\n');
|
|
const b = after.split('\n');
|
|
return a.some((line, i) => line.trim() !== '' && line !== b[i]);
|
|
}
|
|
|
|
/**
|
|
* Launches the interactive CLI in a 120x40 terminal, types a key once the
|
|
* prompt shows, and reports when the key became visible. The session then
|
|
* quits with /quit so Node writes its compile cache, as it does for users.
|
|
*/
|
|
export async function runInteractive({
|
|
command,
|
|
args,
|
|
cwd,
|
|
env,
|
|
answerQueries = true,
|
|
observeMs = 4000,
|
|
timeoutMs = 60_000,
|
|
}) {
|
|
const terminal = new Terminal({
|
|
cols: 120,
|
|
rows: 40,
|
|
scrollback: 2000,
|
|
allowProposedApi: true,
|
|
});
|
|
const start = performance.now();
|
|
const child = pty.spawn(command, args, {
|
|
name: 'xterm-256color',
|
|
cols: 120,
|
|
rows: 40,
|
|
cwd,
|
|
env,
|
|
});
|
|
const result = {};
|
|
let exited = null;
|
|
let typedAt;
|
|
let keysBefore = 0;
|
|
let screenAtTti;
|
|
let tail = '';
|
|
let pending = Promise.resolve();
|
|
let observing = true;
|
|
|
|
if (answerQueries) terminal.onData((data) => child.write(data));
|
|
child.onExit((e) => (exited = e));
|
|
|
|
const evaluate = () => {
|
|
if (!observing) return;
|
|
const now = performance.now() - start;
|
|
const screen = screenText(terminal);
|
|
if (result.firstContentMs === undefined && /\S/.test(screen))
|
|
result.firstContentMs = now;
|
|
if (result.promptMs === undefined && screen.includes(PROMPT_TEXT)) {
|
|
result.promptMs = now;
|
|
keysBefore = (screen.match(/Z/g) ?? []).length;
|
|
typedAt = now;
|
|
child.write('Z');
|
|
} else if (typedAt !== undefined && result.ttiMs === undefined) {
|
|
if ((screen.match(/Z/g) ?? []).length > keysBefore) {
|
|
result.ttiMs = now;
|
|
result.ttiEpochMs = Date.now();
|
|
result.rssMb = rssMb(processTree(child.pid).map((p) => p.pid));
|
|
screenAtTti = screen;
|
|
} else if (now - typedAt > 1500) {
|
|
typedAt = now;
|
|
child.write('Z');
|
|
}
|
|
} else if (screenAtTti !== undefined && !result.screenChanged) {
|
|
result.screenChanged = replacesText(screenAtTti, screen);
|
|
}
|
|
};
|
|
|
|
child.onData((data) => {
|
|
tail = (tail + data).slice(-64);
|
|
// xterm answers DA1 itself but not the background-colour query.
|
|
if (answerQueries && tail.includes('\x1b]11;?')) {
|
|
tail = '';
|
|
child.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x07');
|
|
}
|
|
pending = pending.then(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
terminal.write(data, () => (evaluate(), resolve())),
|
|
),
|
|
);
|
|
});
|
|
|
|
while (
|
|
result.ttiMs === undefined &&
|
|
!exited &&
|
|
performance.now() - start < timeoutMs
|
|
) {
|
|
await sleep(20);
|
|
if (typedAt !== undefined) evaluate();
|
|
}
|
|
if (result.ttiMs === undefined) {
|
|
killTree(child.pid);
|
|
terminal.dispose();
|
|
throw new Error(
|
|
`no typeable prompt (${exited ? `exited ${exited.exitCode}` : 'timeout'}):\n${screenText(terminal).trim().slice(-1500)}`,
|
|
);
|
|
}
|
|
await sleep(observeMs);
|
|
await pending;
|
|
observing = false;
|
|
result.screenChanged ??= false;
|
|
|
|
child.write('\x7f');
|
|
await sleep(150);
|
|
for (const c of '/quit') {
|
|
child.write(c);
|
|
await sleep(25);
|
|
}
|
|
await sleep(250);
|
|
child.write('\r');
|
|
const quitStart = performance.now();
|
|
while (!exited && performance.now() - quitStart < 10_000) await sleep(50);
|
|
killTree(child.pid);
|
|
terminal.dispose();
|
|
return result;
|
|
}
|
|
|
|
/** Runs `qwen -p` against the stub and reports when its first request arrived. */
|
|
export async function runHeadless({
|
|
command,
|
|
args,
|
|
cwd,
|
|
env,
|
|
modelServer,
|
|
runId,
|
|
timeoutMs = 60_000,
|
|
}) {
|
|
const start = performance.now();
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
child.stdout.on('data', (d) => (stdout += d));
|
|
child.stderr.on('data', () => {});
|
|
let peakRssMb = 0;
|
|
const poll = setInterval(() => {
|
|
peakRssMb = Math.max(
|
|
peakRssMb,
|
|
rssMb(processTree(child.pid).map((p) => p.pid)),
|
|
);
|
|
}, 20);
|
|
const exitCode = await new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
killTree(child.pid);
|
|
resolve('timeout');
|
|
}, timeoutMs);
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(timer);
|
|
resolve(signal ?? code);
|
|
});
|
|
});
|
|
clearInterval(poll);
|
|
const firstRequest = modelServer.firstRequestAt(runId);
|
|
return {
|
|
exitCode,
|
|
stdout: stdout.trim(),
|
|
firstRequestMs:
|
|
firstRequest === undefined ? undefined : firstRequest - start,
|
|
totalMs: performance.now() - start,
|
|
peakRssMb,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Counts Node process images and the bytes of JavaScript they opened, from
|
|
* `strace -f -ttt -e trace=execve,openat` output, up to `cutoffEpochMs`.
|
|
* Each file counts once per process image.
|
|
*/
|
|
export function parseStrace(
|
|
text,
|
|
cutoffEpochMs = Infinity,
|
|
fileSize = (file) => fs.statSync(file).size,
|
|
) {
|
|
let nodeProcesses = 0;
|
|
let jsBytes = 0;
|
|
const opened = new Map();
|
|
for (const line of text.split('\n')) {
|
|
const match = /^(\d+)\s+(\d+\.\d+)\s+(.*)$/.exec(line);
|
|
if (!match || Number(match[2]) * 1000 > cutoffEpochMs) continue;
|
|
const pid = match[1];
|
|
const call = match[3];
|
|
const exec = /^execve\("([^"]+)",.* = 0$/.exec(call);
|
|
if (exec) {
|
|
opened.set(pid, new Set());
|
|
if (path.basename(exec[1]) === 'node') nodeProcesses++;
|
|
continue;
|
|
}
|
|
const open = /^openat\([^,]+, "([^"]+\.(?:m|c)?js)", [^)]*\) = \d+$/.exec(
|
|
call,
|
|
);
|
|
if (!open) continue;
|
|
if (!opened.has(pid)) opened.set(pid, new Set());
|
|
const files = opened.get(pid);
|
|
if (files.has(open[1])) continue;
|
|
files.add(open[1]);
|
|
try {
|
|
jsBytes += fileSize(open[1]);
|
|
} catch {
|
|
// A file that disappeared cannot be sized; it was a temporary one.
|
|
}
|
|
}
|
|
return { nodeProcesses, jsBytes };
|
|
}
|
|
|
|
/** p50 and p75 by linear interpolation. */
|
|
export function quantiles(values) {
|
|
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
|
const at = (q) => {
|
|
if (sorted.length === 0) return NaN;
|
|
const i = (sorted.length - 1) * q;
|
|
const lo = Math.floor(i);
|
|
return sorted[lo] + (sorted[Math.ceil(i)] - sorted[lo]) * (i - lo);
|
|
};
|
|
return { p50: at(0.5), p75: at(0.75) };
|
|
}
|