源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Renderer performance probes
|
||||
|
||||
Measuring what a session switch costs in the running Desktop app, over CDP. The
|
||||
findings in #4109 were produced with these; they live here so the next
|
||||
measurement is a command rather than a rebuild.
|
||||
|
||||
They are ad-hoc tools, not a benchmark suite: they attach to a dev app you
|
||||
started, and they answer "how much" only for the interaction they drive.
|
||||
|
||||
## Running
|
||||
|
||||
Start the dev app with the debugger open:
|
||||
|
||||
```bash
|
||||
npm run dev -w @maka/desktop -- --remote-debugging-port=9334
|
||||
```
|
||||
|
||||
Then, with a populated sidebar (about 30 rows is what the #4109 numbers used):
|
||||
|
||||
```bash
|
||||
node scripts/perf/session-switch-commits.mjs before
|
||||
node scripts/perf/session-switch-busy-js.mjs
|
||||
```
|
||||
|
||||
- `session-switch-commits.mjs` — React commits per switch, how many are
|
||||
full-tree renders, and how many fibers actually re-rendered. Reloads the page
|
||||
first, so `react-commit-probe.js` is in place before React boots.
|
||||
- `session-switch-busy-js.mjs` — renderer busy JS per switch, with the top
|
||||
self-time entries. It reports the running build, one configuration. A
|
||||
before/after needs both configurations in one instance; see below.
|
||||
- `react-commit-probe.js` — the in-page half: a minimal
|
||||
`__REACT_DEVTOOLS_GLOBAL_HOOK__` plus a `Function.prototype.bind` wrapper that
|
||||
catches React creating a `dispatchSetState`, so a commit can be attributed to
|
||||
the `setState` that caused it.
|
||||
- `cdp-client.mjs` — the protocol client and the row-clicking helper.
|
||||
|
||||
## The one rule
|
||||
|
||||
**Never compare numbers from two app launches.** Restarting the app shifts these
|
||||
metrics by orders of magnitude, while the spread inside a single running
|
||||
instance is small. An A/B means: one instance, a `globalThis` switch to select
|
||||
the configuration, at least three repetitions per configuration, alternating,
|
||||
compared pairwise. Two runs of "before" and "after" against two launches will
|
||||
produce a confident number that means nothing.
|
||||
|
||||
The switch is a throwaway, not a shipped affordance. Branch a worktree, put a
|
||||
`globalThis` flag in the one place that reads it — one that restores the old
|
||||
behaviour when set, so the regressed configuration is reachable without
|
||||
rebuilding the defect by hand — alternate the flag inside the running instance,
|
||||
then delete the worktree. Measurement discipline is ours; product code carries
|
||||
none of it. A flag that reaches `main` is a branch nothing executes and a
|
||||
defect replica that has to be maintained against the code it replicates.
|
||||
|
||||
Two smaller ones that cost time to rediscover:
|
||||
|
||||
- Clicks must be `Input.dispatchMouseEvent`. A synthesised `element.click()`
|
||||
does not activate an Astryx `SideNavItem`, so the app does nothing and the
|
||||
measurement describes it.
|
||||
- Disarm the commit probe (`__MAKA_PROBE__.arm = false`) before sampling. Its
|
||||
fiber walk otherwise shows up in the profile as the app's own work;
|
||||
`session-switch-busy-js.mjs` does this for you.
|
||||
|
||||
## What these do not tell you
|
||||
|
||||
The commit count is unweighted, and busy JS on this workload is spread across
|
||||
about a thousand cheap fibers rather than concentrated in one component. A
|
||||
change that lowers the commit count has not necessarily lowered the time, and a
|
||||
change that moves the top self-time entry has not necessarily lowered anything.
|
||||
Read both, and read the medians.
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Chrome DevTools Protocol client for the running dev app (#4109).
|
||||
*
|
||||
* Small on purpose: `/json/list` to find the renderer target, one WebSocket,
|
||||
* `Runtime.evaluate` and `Input.dispatchMouseEvent`. Nothing here is specific
|
||||
* to a measurement — the probes in this directory are.
|
||||
*
|
||||
* Clicks must go through `Input.dispatchMouseEvent`. A synthesised
|
||||
* `element.click()` does not produce the event sequence Astryx's SideNavItem
|
||||
* listens for, so the row never activates and the measurement silently
|
||||
* describes an app that did nothing.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const WebSocket = require('ws');
|
||||
|
||||
export const DEFAULT_PORT = 9334;
|
||||
|
||||
export async function targets(port = DEFAULT_PORT) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function connectRenderer(port = DEFAULT_PORT, match = /index\.html|maka/i) {
|
||||
const list = await targets(port);
|
||||
const page = list.find(
|
||||
(target) => target.type === 'page' && (match.test(target.url) || match.test(target.title)),
|
||||
);
|
||||
if (!page) {
|
||||
const seen = list.map((target) => `${target.type} ${target.title} ${target.url}`).join('\n ');
|
||||
throw new Error(`no renderer target on port ${port}. Saw:\n ${seen}`);
|
||||
}
|
||||
return connect(page.webSocketDebuggerUrl);
|
||||
}
|
||||
|
||||
export function connect(url) {
|
||||
const socket = new WebSocket(url, { perMessageDeflate: false, maxPayload: 512 * 1024 * 1024 });
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
const listeners = [];
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
socket.once('open', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
socket.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.id === undefined) {
|
||||
for (const listener of listeners) listener(message);
|
||||
return;
|
||||
}
|
||||
const waiter = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (!waiter) return;
|
||||
if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
|
||||
else waiter.resolve(message.result);
|
||||
});
|
||||
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
|
||||
const evaluate = async (expression, options = {}) => {
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
...options,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(
|
||||
JSON.stringify(result.exceptionDetails.exception?.description ?? result.exceptionDetails),
|
||||
);
|
||||
}
|
||||
return result.result.value;
|
||||
};
|
||||
|
||||
return {
|
||||
socket,
|
||||
send,
|
||||
evaluate,
|
||||
on: (listener) => listeners.push(listener),
|
||||
ready,
|
||||
close: () => socket.close(),
|
||||
};
|
||||
}
|
||||
|
||||
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Click the nth Session row by real input events, at the centre of its box.
|
||||
*
|
||||
* Scoped to the rail and refused when the row is not on screen. A click at
|
||||
* coordinates outside the viewport lands on whatever is there instead — one of
|
||||
* them opened Settings mid-run and the measurement that followed described an
|
||||
* app that was not showing the rail at all.
|
||||
*/
|
||||
export async function clickSessionRow(client, index) {
|
||||
const box = JSON.parse(
|
||||
await client.evaluate(
|
||||
`(() => {
|
||||
const rail = document.querySelector('nav.maka-session-panel');
|
||||
if (!rail) throw new Error('the session rail is not mounted');
|
||||
const row = [...rail.querySelectorAll('[data-session-id]')][${index}];
|
||||
if (!row) throw new Error('no session row at index ${index}');
|
||||
const b = row.getBoundingClientRect();
|
||||
const x = b.x + b.width / 2;
|
||||
const y = b.y + b.height / 2;
|
||||
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) {
|
||||
throw new Error('session row ${index} is off screen (' + x + ',' + y + ')');
|
||||
}
|
||||
return JSON.stringify({ x, y });
|
||||
})()`,
|
||||
),
|
||||
);
|
||||
const point = {
|
||||
x: Math.round(box.x),
|
||||
y: Math.round(box.y),
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
buttons: 1,
|
||||
};
|
||||
await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point, buttons: 0 });
|
||||
await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point });
|
||||
await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, buttons: 0 });
|
||||
}
|
||||
|
||||
export function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
Vendored
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Counts React commits, rendered fibers, and the `setState` calls that caused
|
||||
* them, from inside the renderer (#4109).
|
||||
*
|
||||
* Injected with `Page.addScriptToEvaluateOnNewDocument` before the app loads,
|
||||
* because both halves have to be in place first:
|
||||
*
|
||||
* 1. `__REACT_DEVTOOLS_GLOBAL_HOOK__` must exist before `react-dom` looks for
|
||||
* it, or no commit is ever reported. This is a minimal stand-in for the
|
||||
* DevTools hook — enough of the shape that react-dom injects into it.
|
||||
*
|
||||
* 2. `Function.prototype.bind` is wrapped to catch React creating a state
|
||||
* dispatcher. React binds `dispatchSetState` as `(null, fiber, queue)`,
|
||||
* and the queue is the only object in the runtime carrying
|
||||
* `lastRenderedReducer`, so that triple identifies it with no version
|
||||
* coupling to react-dom's internals.
|
||||
*
|
||||
* A fiber counts as rendered when it carries `Placement` (`flags & 1`) and its
|
||||
* alternate was last seen in the previous commit. That is identity-insensitive:
|
||||
* it reports what React actually re-rendered, not what a `memo` comparator was
|
||||
* asked about.
|
||||
*
|
||||
* `arm` gates recording so a measurement covers one interaction rather than the
|
||||
* app's whole life. Set `__MAKA_PROBE__.arm = false` before profiling with the
|
||||
* sampler, or the probe's own walk lands in the profile.
|
||||
*/
|
||||
(() => {
|
||||
if (globalThis.__MAKA_PROBE__) return 'already';
|
||||
const probe = {
|
||||
commits: [],
|
||||
dispatches: [],
|
||||
arm: false,
|
||||
roots: new Set(),
|
||||
rootIdx: new WeakMap(),
|
||||
qid: 0,
|
||||
qlabel: new Map(),
|
||||
};
|
||||
globalThis.__MAKA_PROBE__ = probe;
|
||||
|
||||
const originalBind = Function.prototype.bind;
|
||||
Function.prototype.bind = function bind(...args) {
|
||||
const bound = originalBind.apply(this, args);
|
||||
try {
|
||||
const queue = args[2];
|
||||
if (
|
||||
args.length === 3 &&
|
||||
args[0] === null &&
|
||||
queue &&
|
||||
typeof queue === 'object' &&
|
||||
'lastRenderedReducer' in queue &&
|
||||
!queue.__qid
|
||||
) {
|
||||
queue.__qid = ++probe.qid;
|
||||
const qid = queue.__qid;
|
||||
return function dispatchWithRecord(action) {
|
||||
if (probe.arm && probe.dispatches.length < 500) {
|
||||
probe.dispatches.push({
|
||||
t: performance.now(),
|
||||
qid,
|
||||
prev: brief(queue.lastRenderedState),
|
||||
action: brief(action),
|
||||
st: (new Error().stack || '')
|
||||
.split('\n')
|
||||
.slice(2, 8)
|
||||
.map((line) => line.trim().slice(0, 130)),
|
||||
});
|
||||
}
|
||||
return bound.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A bind we cannot inspect is a bind we do not care about.
|
||||
}
|
||||
return bound;
|
||||
};
|
||||
|
||||
function brief(value) {
|
||||
try {
|
||||
if (value == null) return String(value);
|
||||
if (typeof value === 'function') return 'fn';
|
||||
if (typeof value !== 'object') return String(value).slice(0, 40);
|
||||
if (Array.isArray(value)) return `Arr${value.length}`;
|
||||
return `{${Object.keys(value).slice(0, 6)}}`;
|
||||
} catch {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
const nameOf = (fiber) => {
|
||||
const type = fiber.type ?? fiber.elementType;
|
||||
if (typeof type === 'string') return type;
|
||||
if (typeof type === 'function') return type.displayName || type.name || 'anon';
|
||||
if (type && typeof type === 'object') {
|
||||
const inner = type.type ?? type.render;
|
||||
return (
|
||||
type.displayName ||
|
||||
(typeof inner === 'function' ? inner.displayName || inner.name : null) ||
|
||||
'obj'
|
||||
);
|
||||
}
|
||||
return `tag${fiber.tag}`;
|
||||
};
|
||||
|
||||
const hook = {
|
||||
renderers: new Map(),
|
||||
supportsFiber: true,
|
||||
_nextId: 1,
|
||||
inject(renderer) {
|
||||
const id = hook._nextId++;
|
||||
hook.renderers.set(id, renderer);
|
||||
return id;
|
||||
},
|
||||
checkDCE() {},
|
||||
onScheduleFiberRoot() {},
|
||||
onCommitFiberUnmount() {},
|
||||
onPostCommitFiberRoot() {},
|
||||
setStrictMode() {},
|
||||
getFiberRoots() {
|
||||
return probe.roots;
|
||||
},
|
||||
on() {},
|
||||
off() {},
|
||||
sub() {
|
||||
return () => {};
|
||||
},
|
||||
emit() {},
|
||||
onCommitFiberRoot(_id, root) {
|
||||
probe.roots.add(root);
|
||||
const idx = (probe.rootIdx.get(root) || 0) + 1;
|
||||
probe.rootIdx.set(root, idx);
|
||||
const key = '__pc';
|
||||
let total = 0;
|
||||
let visited = 0;
|
||||
const renderRoots = [];
|
||||
const stack = [[root.current, 0, false]];
|
||||
while (stack.length) {
|
||||
const [fiber, depth, hasRenderedAncestor] = stack.pop();
|
||||
if (!fiber || visited++ > 90000) break;
|
||||
const alternate = fiber.alternate;
|
||||
const rendered = fiber.flags & 1 && alternate && alternate[key] === idx - 1;
|
||||
if (rendered) total++;
|
||||
let ancestor = hasRenderedAncestor;
|
||||
if (rendered && !hasRenderedAncestor && probe.arm) {
|
||||
ancestor = true;
|
||||
renderRoots.push({ d: depth, name: nameOf(fiber) });
|
||||
}
|
||||
let state = fiber.memoizedState;
|
||||
let i = 0;
|
||||
while (state && i < 1200) {
|
||||
if (state.queue?.__qid && !probe.qlabel.has(state.queue.__qid)) {
|
||||
probe.qlabel.set(state.queue.__qid, `${nameOf(fiber)}@d${depth}#h${i}`);
|
||||
}
|
||||
state = state.next;
|
||||
i++;
|
||||
}
|
||||
fiber[key] = idx;
|
||||
if (fiber.child) stack.push([fiber.child, depth + 1, ancestor]);
|
||||
if (fiber.sibling) stack.push([fiber.sibling, depth, hasRenderedAncestor]);
|
||||
}
|
||||
if (probe.arm && probe.commits.length < 300) {
|
||||
renderRoots.sort((a, b) => a.d - b.d);
|
||||
probe.commits.push({ t: performance.now(), idx, total, rr: renderRoots.slice(0, 3) });
|
||||
}
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, '__REACT_DEVTOOLS_GLOBAL_HOOK__', {
|
||||
value: hook,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
return 'installed';
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renderer busy JS per session switch, with the top self-time entries (#4109).
|
||||
*
|
||||
* Usage, against a running dev app with `--remote-debugging-port=9334`:
|
||||
*
|
||||
* node scripts/perf/session-switch-busy-js.mjs [port] [trials]
|
||||
*
|
||||
* This reports one configuration: whatever the running build does. A before/
|
||||
* after needs both configurations reachable from ONE running instance — see the
|
||||
* README for how to get that from a throwaway worktree instead of from a switch
|
||||
* carried in product code.
|
||||
*
|
||||
* Sampling at 100µs. Busy time is every sample that is not `(idle)` or
|
||||
* `(program)`; the top-8 self-time list is there to show WHERE the time is, and
|
||||
* on this workload it is deliberately flat — about a thousand cheap fibers, no
|
||||
* hot component. A change that only moves the top entry around has not moved
|
||||
* the total.
|
||||
*
|
||||
* The same discipline as the commit counter: alternate configurations inside
|
||||
* ONE running instance and compare paired trials. Numbers from two launches are
|
||||
* not comparable.
|
||||
*/
|
||||
|
||||
import { clickSessionRow, connectRenderer, median, sleep, DEFAULT_PORT } from './cdp-client.mjs';
|
||||
|
||||
const positional = process.argv.slice(2).filter((arg) => !arg.startsWith('--'));
|
||||
const port = Number(positional[0] ?? DEFAULT_PORT);
|
||||
const trials = Number(positional[1] ?? 5);
|
||||
const SETTLE_MS = 2000;
|
||||
|
||||
const client = await connectRenderer(port);
|
||||
await client.ready;
|
||||
await client.send('Profiler.enable');
|
||||
|
||||
// The commit probe walks the fiber tree on every commit. Left armed it lands in
|
||||
// the profile as the app's own work.
|
||||
await client.evaluate('globalThis.__MAKA_PROBE__ && (__MAKA_PROBE__.arm = false)').catch(() => {});
|
||||
|
||||
async function measureSwitch(row) {
|
||||
await client.send('Profiler.setSamplingInterval', { interval: 100 });
|
||||
await client.send('Profiler.start');
|
||||
await clickSessionRow(client, row);
|
||||
await sleep(SETTLE_MS);
|
||||
const { profile } = await client.send('Profiler.stop');
|
||||
|
||||
const byId = new Map(profile.nodes.map((node) => [node.id, node]));
|
||||
const selfTime = new Map();
|
||||
const deltas = profile.timeDeltas ?? [];
|
||||
let busy = 0;
|
||||
let idle = 0;
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const node = byId.get(profile.samples[i]);
|
||||
const dt = (deltas[i] ?? 0) / 1000;
|
||||
const name = node?.callFrame?.functionName || '(anonymous)';
|
||||
if (name === '(idle)' || name === '(program)') {
|
||||
idle += dt;
|
||||
continue;
|
||||
}
|
||||
busy += dt;
|
||||
const file = (node?.callFrame?.url || '').split('/').pop() || '';
|
||||
const key = `${name} @${file}:${node?.callFrame?.lineNumber}`;
|
||||
selfTime.set(key, (selfTime.get(key) ?? 0) + dt);
|
||||
}
|
||||
return { busy, idle, selfTime };
|
||||
}
|
||||
|
||||
function printTop(selfTime) {
|
||||
for (const [key, value] of [...selfTime.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)) {
|
||||
console.log(` ${value.toFixed(0)}ms ${key.slice(0, 110)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const busyPerSwitch = [];
|
||||
for (let trial = 0; trial < trials; trial++) {
|
||||
const row = 2 + trial;
|
||||
const { busy, idle, selfTime } = await measureSwitch(row);
|
||||
busyPerSwitch.push(busy);
|
||||
console.log(`row ${row}: busyJS=${busy.toFixed(0)}ms idle=${idle.toFixed(0)}ms`);
|
||||
printTop(selfTime);
|
||||
}
|
||||
console.log(`\nmedian busy JS per switch = ${median(busyPerSwitch).toFixed(0)}ms`);
|
||||
|
||||
client.close();
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How many React commits one session switch costs, and how much of each is a
|
||||
* full-tree render (#4109).
|
||||
*
|
||||
* Usage, against a running dev app with `--remote-debugging-port=9334`:
|
||||
*
|
||||
* node scripts/perf/session-switch-commits.mjs <label> [port]
|
||||
*
|
||||
* Reloads the page to install `react-commit-probe.js` before React boots, then
|
||||
* switches sessions several times and reports the median. Read the medians, not
|
||||
* a single row: the first switch after a reload pays for caches the rest do
|
||||
* not, which is why row 1 is a warm-up and is excluded.
|
||||
*
|
||||
* NEVER compare two numbers from two app launches. Restarting the app moves
|
||||
* these metrics by an order of magnitude while the spread inside one instance
|
||||
* is small; an A/B has to alternate configurations inside one running instance
|
||||
* and compare paired trials.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { clickSessionRow, connectRenderer, median, sleep, DEFAULT_PORT } from './cdp-client.mjs';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
const label = process.argv[2] ?? 'run';
|
||||
const port = Number(process.argv[3] ?? DEFAULT_PORT);
|
||||
|
||||
/** A full-tree render: React re-rendered essentially everything under the root. */
|
||||
const FULL_TREE_FIBERS = 1000;
|
||||
const SETTLE_MS = 2500;
|
||||
const BOOT_MS = 9000;
|
||||
const ROWS = [2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
const client = await connectRenderer(port);
|
||||
await client.ready;
|
||||
await client.send('Page.enable');
|
||||
await client.send('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: readFileSync(join(here, 'react-commit-probe.js'), 'utf8'),
|
||||
});
|
||||
await client.send('Page.reload');
|
||||
await sleep(BOOT_MS);
|
||||
|
||||
await clickSessionRow(client, 1);
|
||||
await sleep(3000);
|
||||
|
||||
const results = [];
|
||||
for (const row of ROWS) {
|
||||
await client.evaluate(
|
||||
'__MAKA_PROBE__.commits.length = 0; __MAKA_PROBE__.dispatches.length = 0; __MAKA_PROBE__.arm = true',
|
||||
);
|
||||
await clickSessionRow(client, row);
|
||||
await sleep(SETTLE_MS);
|
||||
await client.evaluate('__MAKA_PROBE__.arm = false');
|
||||
const { commits } = JSON.parse(
|
||||
await client.evaluate('JSON.stringify({ commits: __MAKA_PROBE__.commits })'),
|
||||
);
|
||||
const fullTree = commits.filter((commit) => commit.total > FULL_TREE_FIBERS);
|
||||
const renderRoots = {};
|
||||
for (const commit of fullTree) {
|
||||
const name = commit.rr[0]?.name ?? '?';
|
||||
renderRoots[name] = (renderRoots[name] ?? 0) + 1;
|
||||
}
|
||||
const rendered = commits.reduce((sum, commit) => sum + commit.total, 0);
|
||||
results.push({ row, commits: commits.length, fullTree: fullTree.length, renderRoots, rendered });
|
||||
console.log(
|
||||
`row ${row}: commits=${commits.length} fullTree=${fullTree.length} renderedFibers=${rendered} roots=${JSON.stringify(renderRoots)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n[${label}] median commits=${median(results.map((r) => r.commits))} ` +
|
||||
`fullTree=${median(results.map((r) => r.fullTree))} ` +
|
||||
`renderedFibers=${median(results.map((r) => r.rendered))}`,
|
||||
);
|
||||
writeFileSync(`session-switch-commits-${label}.json`, `${JSON.stringify(results, null, 1)}\n`);
|
||||
client.close();
|
||||
Reference in New Issue
Block a user