"Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook Air on a 144Hz screen. Measured on that machine with headed engines (process CPU plus the per-process GPU time macOS keeps): an idle Aero library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%, Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of WindowServer GPU. Priced one by one: - the gradient mask on .ice-floats re-rendered the animated layer offscreen on every frame (~75 points of Gecko CPU). The floats now rise through a plain rectangular band clear of the header, the bottom chrome and the rail, and fade in and out inside it, so the clip never cuts a visible one; - the overlay grain forced a backdrop readback per frame for a texture that measured a mean <=0.11/255 difference: removed; - the invisible blob drift made every frame a full-screen redraw: the blobs are painted once; - static glass (answer tiles, feedback panel, chips, inputs, dropzones, lists, segmented controls, Aero hint chips) re-blurred the moving scene every frame: only floating glass blurs now, and Aero's baked chat chrome drops a live blur that never showed under the bake; - the scene also parks while the window has no focus and under any dialog or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS animation instead of a framer-motion main-thread loop. After, focused and animating: LibreWolf library 109% -> 33% CPU, study 134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark. Unfocused or under a sheet: nothing. bubbles-test sweeps every float through 41 phases against the band and pins the parking and the cost contract; tests/heat-probe.mjs (macOS) repeats the measurement; the suites find Playwright's Chromium on macOS. All 15 suites pass, lint clean, the contrast scan lost a finding. Version 0.6.7, SW v89. Co-authored-by: Cursor <cursoragent@cursor.com>
138 lines
5.5 KiB
JavaScript
138 lines
5.5 KiB
JavaScript
/**
|
|
* Diagnostic, NOT part of `npm run test:e2e`, macOS only: what an idle
|
|
* Freezmo screen costs the machine on its real display. Headed WebKit and
|
|
* Chromium (Playwright's builds) open each screen and sit still while the
|
|
* probe reads the browser's own CPU time and the GPU time of its processes
|
|
* and of WindowServer (macOS composites every window there, and Core
|
|
* Animation runs WebKit's CSS animations there), against an about:blank
|
|
* baseline and with the scene parked.
|
|
*
|
|
* npx playwright-core install webkit chromium # once
|
|
* node tests/heat-probe.mjs # both engines
|
|
* node tests/heat-probe.mjs webkit # one engine
|
|
*
|
|
* Unlike tests/bubble-perf.mjs (frame times in a throttled software renderer)
|
|
* this measures the cost itself, at the display's own refresh rate: on a
|
|
* 144Hz screen every running animation is composited 144 times a second.
|
|
* Keep the window in front while it runs (other apps add noise to the
|
|
* WindowServer column). Always exits 0.
|
|
*/
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { chromium, webkit } from "playwright-core";
|
|
|
|
import { BASE, CHROME, DECK, seed } from "./lib.mjs";
|
|
|
|
if (process.platform !== "darwin") {
|
|
console.log("heat-probe: macOS only (it reads ps and the IOKit GPU counters).");
|
|
process.exit(0);
|
|
}
|
|
|
|
const run = promisify(execFile);
|
|
const SETTLE_MS = 2500;
|
|
const SAMPLE_MS = 8000;
|
|
const ENGINES = {
|
|
webkit: { launch: () => webkit.launch({ headless: false }), match: "/ms-playwright/webkit-" },
|
|
chromium: { launch: () => chromium.launch({ headless: false, executablePath: CHROME }), match: CHROME.split("/Contents/")[0] },
|
|
};
|
|
const SCREENS = [
|
|
["library", "/"],
|
|
["study", `/deck/${DECK.id}`],
|
|
["chat", "/chat"],
|
|
];
|
|
|
|
function seconds(text) {
|
|
const [days, clock] = text.includes("-") ? text.split("-") : ["0", text];
|
|
return clock.split(":").reduce((total, part) => total * 60 + Number(part), 0) + Number(days) * 86400;
|
|
}
|
|
|
|
async function processes() {
|
|
const { stdout } = await run("ps", ["-axo", "pid=,time=,command="], { maxBuffer: 64 << 20 });
|
|
const map = new Map();
|
|
for (const line of stdout.split("\n")) {
|
|
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
|
|
if (match) map.set(Number(match[1]), { cpu: seconds(match[2]), command: match[3] });
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** Accumulated GPU time per process (ns) — the number Activity Monitor shows. */
|
|
async function gpuTimes() {
|
|
const { stdout } = await run("ioreg", ["-r", "-c", "AGXDeviceUserClient", "-l", "-w", "0"], {
|
|
maxBuffer: 256 << 20,
|
|
encoding: "latin1",
|
|
});
|
|
const map = new Map();
|
|
for (const block of stdout.split("+-o AGXDeviceUserClient")) {
|
|
const creator = block.match(/"IOUserClientCreator" = "pid (\d+),/);
|
|
if (!creator) continue;
|
|
let total = 0;
|
|
for (const match of block.matchAll(/"accumulatedGPUTime"=(\d+)/g)) total += Number(match[1]);
|
|
map.set(Number(creator[1]), (map.get(Number(creator[1])) ?? 0) + total);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function measure(match) {
|
|
const [cpu0, gpu0] = [await processes(), await gpuTimes()];
|
|
const start = Date.now();
|
|
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS));
|
|
const [cpu1, gpu1] = [await processes(), await gpuTimes()];
|
|
const elapsed = (Date.now() - start) / 1000;
|
|
const owner = (pid) => {
|
|
const command = cpu1.get(pid)?.command ?? "";
|
|
return command.includes(match) ? "browser" : command.includes("WindowServer") ? "windowServer" : null;
|
|
};
|
|
const total = { browserCpu: 0, browserGpu: 0, windowServerGpu: 0 };
|
|
for (const [pid, after] of cpu1) {
|
|
const before = cpu0.get(pid);
|
|
if (before && owner(pid) === "browser") total.browserCpu += after.cpu - before.cpu;
|
|
}
|
|
for (const [pid, after] of gpu1) {
|
|
const before = gpu0.get(pid);
|
|
if (before === undefined) continue;
|
|
if (owner(pid) === "browser") total.browserGpu += (after - before) / 1e6;
|
|
if (owner(pid) === "windowServer") total.windowServerGpu += (after - before) / 1e6;
|
|
}
|
|
return {
|
|
cpu: (total.browserCpu / elapsed) * 100,
|
|
gpu: total.browserGpu / elapsed,
|
|
windowServer: total.windowServerGpu / elapsed,
|
|
};
|
|
}
|
|
|
|
const wanted = process.argv.slice(2);
|
|
for (const [name, engine] of Object.entries(ENGINES)) {
|
|
if (wanted.length && !wanted.includes(name)) continue;
|
|
const browser = await engine.launch();
|
|
const runs = [["baseline", null, null, false]];
|
|
for (const theme of ["aero", "dark"]) {
|
|
for (const [screen, path] of SCREENS) runs.push([`${screen} ${theme}`, path, theme, false]);
|
|
runs.push([`library ${theme}, parked`, "/", theme, true]);
|
|
}
|
|
console.log(`\n${name} — browser CPU %, browser GPU ms/s, WindowServer GPU ms/s (${SAMPLE_MS / 1000}s each)`);
|
|
for (const [label, path, theme, parked] of runs) {
|
|
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
|
|
const page = await context.newPage();
|
|
if (path) {
|
|
await seed(page, { decks: [DECK], theme });
|
|
await page.goto(BASE + path, { waitUntil: "load" });
|
|
} else {
|
|
await page.goto("about:blank");
|
|
}
|
|
await page.bringToFront();
|
|
if (parked) {
|
|
await page.evaluate(() => {
|
|
document.hasFocus = () => false;
|
|
window.dispatchEvent(new Event("blur"));
|
|
});
|
|
}
|
|
await page.waitForTimeout(SETTLE_MS);
|
|
const cost = await measure(engine.match);
|
|
console.log(
|
|
` ${label.padEnd(24)} CPU ${cost.cpu.toFixed(1).padStart(5)}% GPU ${cost.gpu.toFixed(0).padStart(4)} WindowServer ${cost.windowServer.toFixed(0).padStart(4)}`,
|
|
);
|
|
await context.close();
|
|
}
|
|
await browser.close();
|
|
}
|