Skip to content

Commit 9e1e5ea

Browse files
committed
feat(perf): add no-device startup matrix simulation flow
1 parent afdb5c8 commit 9e1e5ea

6 files changed

Lines changed: 323 additions & 0 deletions

File tree

docs/diataxis/en/explanation/startup-node-update-acceleration-plan.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,14 @@ npm run perf:startup:matrix -- --root tmp/startup-logs --single-platform-label w
172172

173173
- The current Windows pilot sample clears the v1 gate and is ready for broader cohort capture on macOS/Android/iOS.
174174
- Because sample size is still small, collect at least `>=10` complete startup sessions per platform before release-go decisions.
175+
176+
## 12) Continuous Progress Without Multi-Device Hardware (March 31, 2026)
177+
178+
- Goal: keep automation and gate pipelines moving when macOS/Android/iOS physical logs are not yet available.
179+
- Execution:
180+
- `npm run perf:startup:matrix:simulate -- --seed-root tmp/startup-logs --out-root tmp/startup-logs-simulated`
181+
- `npm run perf:startup:matrix -- --root tmp/startup-logs-simulated --out tmp/startup-logs-simulated/report-platform-matrix.md`
182+
- `npm run perf:startup:matrix:watch -- --root tmp/startup-logs-simulated --out tmp/startup-logs-simulated/report-platform-matrix.md --strict`
183+
- Data boundary:
184+
- `tmp/startup-logs-simulated` is synthetic and only valid for pipeline/gate/report flow verification.
185+
- Release-go performance decisions must be based on real same-device baseline/pilot cohorts.

docs/diataxis/en/reference/interfaces-and-runtime.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ This reference tracks canonical API/runtime contracts.
6969
- `<root>/android/pilot/*.log`
7070
- `<root>/ios/baseline/*.log`
7171
- `<root>/ios/pilot/*.log`
72+
- Fallback flow without multi-device hardware (pipeline validation only):
73+
- `npm run perf:startup:matrix:simulate -- --seed-root tmp/startup-logs --out-root tmp/startup-logs-simulated`
74+
- `npm run perf:startup:matrix -- --root tmp/startup-logs-simulated --out tmp/startup-logs-simulated/report-platform-matrix.md`
75+
- Note: `tmp/startup-logs-simulated` is synthetic data and must not be used for release-go performance decisions.
7276

7377
## Mermaid Canonical Baseline (Obsidian)
7478

docs/diataxis/zh/explanation/startup-node-update-acceleration-plan.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,14 @@ npm run perf:startup:matrix -- --root tmp/startup-logs --single-platform-label w
172172

173173
- 当前 Windows 试点样本满足 v1 门禁,具备继续扩展到 macOS/Android/iOS 分组采集的条件。
174174
- 由于样本量仍偏小,发布级决策前建议每个平台至少采集 `>=10` 个完整启动会话。
175+
176+
## 12) 无多端设备时的连续推进策略(2026-03-31)
177+
178+
- 目的:在缺少 macOS/Android/iOS 实机日志时,继续推进自动化链路与门禁流程,避免开发停滞。
179+
- 执行步骤:
180+
- `npm run perf:startup:matrix:simulate -- --seed-root tmp/startup-logs --out-root tmp/startup-logs-simulated`
181+
- `npm run perf:startup:matrix -- --root tmp/startup-logs-simulated --out tmp/startup-logs-simulated/report-platform-matrix.md`
182+
- `npm run perf:startup:matrix:watch -- --root tmp/startup-logs-simulated --out tmp/startup-logs-simulated/report-platform-matrix.md --strict`
183+
- 数据边界:
184+
- `tmp/startup-logs-simulated` 全部为模拟数据,仅用于脚本链路、门禁规则与报表产物验证。
185+
- 发布级性能结论必须基于真实设备同设备双阶段样本(baseline/pilot)。

docs/diataxis/zh/reference/interfaces-and-runtime.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@
6969
- `<root>/android/pilot/*.log`
7070
- `<root>/ios/baseline/*.log`
7171
- `<root>/ios/pilot/*.log`
72+
- 无多端设备条件下的替代链路(仅链路验证):
73+
- `npm run perf:startup:matrix:simulate -- --seed-root tmp/startup-logs --out-root tmp/startup-logs-simulated`
74+
- `npm run perf:startup:matrix -- --root tmp/startup-logs-simulated --out tmp/startup-logs-simulated/report-platform-matrix.md`
75+
- 注意:`tmp/startup-logs-simulated` 为模拟数据,禁止用于 release-go 性能结论,仅用于脚本/门禁流程演练。
7276

7377
## Mermaid 标准兼容基线(Obsidian)
7478

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"perf:startup:compare": "node scripts/compare-startup-perf.js",
6161
"perf:startup:matrix": "node scripts/compare-startup-perf-matrix.js",
6262
"perf:startup:matrix:watch": "node scripts/watch-startup-perf-matrix.js",
63+
"perf:startup:matrix:simulate": "node scripts/simulate-startup-perf-platform-logs.js",
6364
"verify:pathbridge:strict": "node scripts/verify-pathbridge-strict-schema.js",
6465
"generate:sbom": "node scripts/generate-sbom.js",
6566
"generate:sbom:attestation": "node scripts/generate-sbom-attestation.js",
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
const CHECKPOINT_ORDER = [
7+
'T0 app_boot',
8+
'T1 graph_preprocessed',
9+
'T2 worker_init_sent',
10+
'T3 first_tick_received',
11+
'T4 first_interactive_render',
12+
'T5 stable_layout',
13+
];
14+
15+
const DEFAULT_PLATFORMS = [
16+
{ name: 'macos', baselineFactor: 1.08, pilotFactor: 0.82 },
17+
{ name: 'android', baselineFactor: 1.35, pilotFactor: 0.96 },
18+
{ name: 'ios', baselineFactor: 1.42, pilotFactor: 1.01 },
19+
];
20+
21+
function fail(message) {
22+
console.error(`[startup-perf-sim] FAIL ${message}`);
23+
process.exit(1);
24+
}
25+
26+
function warn(message) {
27+
console.warn(`[startup-perf-sim] WARN ${message}`);
28+
}
29+
30+
function parseArgs(argv) {
31+
const args = {
32+
seedRoot: 'tmp/startup-logs',
33+
outRoot: 'tmp/startup-logs-simulated',
34+
sessionsPerPlatform: 12,
35+
noisePct: 0.05,
36+
seed: 20260331,
37+
};
38+
39+
for (let i = 0; i < argv.length; i += 1) {
40+
const token = argv[i];
41+
const next = argv[i + 1];
42+
43+
if (token === '--seed-root' && next) {
44+
args.seedRoot = String(next).trim();
45+
i += 1;
46+
continue;
47+
}
48+
if (token.startsWith('--seed-root=')) {
49+
args.seedRoot = String(token.slice('--seed-root='.length)).trim();
50+
continue;
51+
}
52+
53+
if (token === '--out-root' && next) {
54+
args.outRoot = String(next).trim();
55+
i += 1;
56+
continue;
57+
}
58+
if (token.startsWith('--out-root=')) {
59+
args.outRoot = String(token.slice('--out-root='.length)).trim();
60+
continue;
61+
}
62+
63+
if (token === '--sessions-per-platform' && next) {
64+
args.sessionsPerPlatform = Number(next);
65+
i += 1;
66+
continue;
67+
}
68+
if (token.startsWith('--sessions-per-platform=')) {
69+
args.sessionsPerPlatform = Number(token.slice('--sessions-per-platform='.length));
70+
continue;
71+
}
72+
73+
if (token === '--noise-pct' && next) {
74+
args.noisePct = Number(next);
75+
i += 1;
76+
continue;
77+
}
78+
if (token.startsWith('--noise-pct=')) {
79+
args.noisePct = Number(token.slice('--noise-pct='.length));
80+
continue;
81+
}
82+
83+
if (token === '--seed' && next) {
84+
args.seed = Number(next);
85+
i += 1;
86+
continue;
87+
}
88+
if (token.startsWith('--seed=')) {
89+
args.seed = Number(token.slice('--seed='.length));
90+
continue;
91+
}
92+
}
93+
94+
return args;
95+
}
96+
97+
function makeRng(seedValue) {
98+
let seed = (Number(seedValue) >>> 0) || 1;
99+
return () => {
100+
seed = (seed * 1664525 + 1013904223) >>> 0;
101+
return seed / 4294967296;
102+
};
103+
}
104+
105+
function normalizeCheckpointLabel(rawLabel) {
106+
const text = String(rawLabel || '').trim();
107+
const tagMatch = text.match(/^(T[0-5])\b/i);
108+
if (!tagMatch) {
109+
return null;
110+
}
111+
const tag = tagMatch[1].toUpperCase();
112+
return CHECKPOINT_ORDER.find((label) => label.startsWith(`${tag} `)) || null;
113+
}
114+
115+
function extractSessionsFromText(content, sourceFile) {
116+
const lines = String(content || '').split(/\r?\n/);
117+
const sessions = [];
118+
let current = null;
119+
120+
const startupLinePattern = /\[Startup Perf\]\s*(T[0-5][^\+]*)\+([0-9]+(?:\.[0-9]+)?)ms/i;
121+
for (const line of lines) {
122+
const match = line.match(startupLinePattern);
123+
if (!match) {
124+
continue;
125+
}
126+
const checkpoint = normalizeCheckpointLabel(match[1]);
127+
const ms = Number(match[2]);
128+
if (!checkpoint || !Number.isFinite(ms)) {
129+
continue;
130+
}
131+
132+
if (checkpoint === 'T0 app_boot') {
133+
if (current && Object.keys(current.checkpoints).length > 0) {
134+
sessions.push(current);
135+
}
136+
current = { sourceFile, checkpoints: {} };
137+
}
138+
139+
if (!current) {
140+
current = { sourceFile, checkpoints: {} };
141+
}
142+
if (!Object.prototype.hasOwnProperty.call(current.checkpoints, checkpoint)) {
143+
current.checkpoints[checkpoint] = ms;
144+
}
145+
}
146+
147+
if (current && Object.keys(current.checkpoints).length > 0) {
148+
sessions.push(current);
149+
}
150+
151+
return sessions;
152+
}
153+
154+
function parseSessionsFromFile(filePath) {
155+
if (!fs.existsSync(filePath)) {
156+
fail(`Seed file not found: ${filePath}`);
157+
}
158+
const text = fs.readFileSync(filePath, 'utf8');
159+
const sessions = extractSessionsFromText(text, filePath);
160+
const complete = sessions.filter((session) =>
161+
CHECKPOINT_ORDER.every((key) => Number.isFinite(session.checkpoints[key]))
162+
);
163+
if (complete.length === 0) {
164+
fail(`No complete startup sessions found in seed file: ${filePath}`);
165+
}
166+
return complete;
167+
}
168+
169+
function round2(value) {
170+
return Math.round(value * 100) / 100;
171+
}
172+
173+
function mutateSession(baseSession, factor, noisePct, random01) {
174+
const next = {};
175+
let previous = null;
176+
177+
for (const key of CHECKPOINT_ORDER) {
178+
const baseValue = Number(baseSession.checkpoints[key]);
179+
const noise = (random01() * 2 - 1) * noisePct;
180+
const scaled = baseValue * factor * (1 + noise);
181+
let value = round2(Math.max(0, scaled));
182+
if (previous !== null && value <= previous) {
183+
value = round2(previous + 1 + random01() * 4);
184+
}
185+
next[key] = value;
186+
previous = value;
187+
}
188+
return next;
189+
}
190+
191+
function ensureDir(targetPath) {
192+
fs.mkdirSync(targetPath, { recursive: true });
193+
}
194+
195+
function writeSessionLog(filePath, platformName, groupName, sessions, metadata) {
196+
const lines = [];
197+
lines.push(`# Simulated startup perf log`);
198+
lines.push(`# platform=${platformName}`);
199+
lines.push(`# cohort=${groupName}`);
200+
lines.push(`# note=SIMULATED_DATA_FOR_PIPELINE_VALIDATION_ONLY`);
201+
lines.push(`# seed=${metadata.seed}`);
202+
lines.push(`# noisePct=${metadata.noisePct}`);
203+
lines.push('');
204+
205+
for (let i = 0; i < sessions.length; i += 1) {
206+
const session = sessions[i];
207+
lines.push(`[Startup Perf] Session#${i + 1} platform=${platformName} cohort=${groupName}`);
208+
for (const checkpoint of CHECKPOINT_ORDER) {
209+
const value = Number(session[checkpoint]);
210+
lines.push(`[Startup Perf] ${checkpoint} +${value.toFixed(2)}ms`);
211+
}
212+
lines.push('');
213+
}
214+
215+
fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
216+
}
217+
218+
function main() {
219+
const args = parseArgs(process.argv.slice(2));
220+
if (!Number.isFinite(args.sessionsPerPlatform) || args.sessionsPerPlatform <= 0) {
221+
fail('sessions-per-platform must be a positive integer.');
222+
}
223+
if (!Number.isFinite(args.noisePct) || args.noisePct < 0 || args.noisePct > 0.5) {
224+
fail('noise-pct must be in range [0, 0.5].');
225+
}
226+
227+
const seedRoot = path.resolve(process.cwd(), args.seedRoot);
228+
const outRoot = path.resolve(process.cwd(), args.outRoot);
229+
const baselineSeedPath = path.join(seedRoot, 'baseline', 'session.log');
230+
const pilotSeedPath = path.join(seedRoot, 'pilot', 'session.log');
231+
232+
const baselineSeedSessions = parseSessionsFromFile(baselineSeedPath);
233+
const pilotSeedSessions = parseSessionsFromFile(pilotSeedPath);
234+
const random01 = makeRng(args.seed);
235+
236+
ensureDir(outRoot);
237+
const indexLines = [];
238+
indexLines.push('# Simulated Startup Logs Index');
239+
indexLines.push('');
240+
indexLines.push('This dataset is generated from Windows seed logs and is only for pipeline verification.');
241+
indexLines.push('Do not use this file set for release-go performance decisions.');
242+
indexLines.push('');
243+
244+
for (const platform of DEFAULT_PLATFORMS) {
245+
const platformRoot = path.join(outRoot, platform.name);
246+
const baselineDir = path.join(platformRoot, 'baseline');
247+
const pilotDir = path.join(platformRoot, 'pilot');
248+
ensureDir(baselineDir);
249+
ensureDir(pilotDir);
250+
251+
const generatedBaselineSessions = [];
252+
const generatedPilotSessions = [];
253+
for (let i = 0; i < args.sessionsPerPlatform; i += 1) {
254+
const baselineSeed = baselineSeedSessions[i % baselineSeedSessions.length];
255+
const pilotSeed = pilotSeedSessions[i % pilotSeedSessions.length];
256+
generatedBaselineSessions.push(
257+
mutateSession(baselineSeed, platform.baselineFactor, args.noisePct, random01)
258+
);
259+
generatedPilotSessions.push(
260+
mutateSession(pilotSeed, platform.pilotFactor, args.noisePct, random01)
261+
);
262+
}
263+
264+
writeSessionLog(
265+
path.join(baselineDir, 'session.log'),
266+
platform.name,
267+
'baseline',
268+
generatedBaselineSessions,
269+
{ seed: args.seed, noisePct: args.noisePct }
270+
);
271+
writeSessionLog(
272+
path.join(pilotDir, 'session.log'),
273+
platform.name,
274+
'pilot',
275+
generatedPilotSessions,
276+
{ seed: args.seed, noisePct: args.noisePct }
277+
);
278+
279+
indexLines.push(
280+
`- ${platform.name}: baselineFactor=${platform.baselineFactor}, pilotFactor=${platform.pilotFactor}, sessions=${args.sessionsPerPlatform}`
281+
);
282+
}
283+
284+
fs.writeFileSync(path.join(outRoot, 'README.simulated.md'), `${indexLines.join('\n')}\n`, 'utf8');
285+
286+
console.log(`[startup-perf-sim] Generated simulated cohorts at: ${outRoot}`);
287+
console.log(
288+
`[startup-perf-sim] Platforms=${DEFAULT_PLATFORMS.length}, sessionsPerPlatform=${args.sessionsPerPlatform}, seed=${args.seed}`
289+
);
290+
}
291+
292+
main();

0 commit comments

Comments
 (0)