freezmo/tests/heat-probe.mjs
pepaskopec-blip bfc0f806b9 The scene stops heating the MacBook
"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>
2026-10-15 19:42:29 +02:00

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();
}