From 90fb89e79feb82541e506c7824f5fbf66d1a7153 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:22:59 +1000 Subject: [PATCH] chore(desktop): attribute renderer CPU profiles to source files --- packages/desktop/scripts/profile-by-source.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/desktop/scripts/profile-by-source.ts diff --git a/packages/desktop/scripts/profile-by-source.ts b/packages/desktop/scripts/profile-by-source.ts new file mode 100644 index 000000000000..9f0194097970 --- /dev/null +++ b/packages/desktop/scripts/profile-by-source.ts @@ -0,0 +1,94 @@ +// Attribute a renderer .cpuprofile's self time to original source files through the build's +// source maps. Run with: bun scripts/profile-by-source.ts [out/renderer/assets] +import { readFileSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { TraceMap, originalPositionFor } from "C:/Users/Lukem/.local/share/opencode/worktree/6c1049/quiet-wolf-2/node_modules/.bun/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs" + +const profilePath = process.argv[2]! +const assets = process.argv[3] ?? "out/renderer/assets" +const profile = JSON.parse(readFileSync(profilePath, "utf8")) +const maps = new Map() +for (const name of readdirSync(assets).filter((f) => f.endsWith(".js.map"))) { + maps.set(name.slice(0, -4), new TraceMap(JSON.parse(readFileSync(join(assets, name), "utf8")))) +} + +const nodes = new Map() +for (const n of profile.nodes) nodes.set(n.id, n) +const self = new Map() +const byPkg = new Map() +const group = (source: string) => { + const n = source.replace(/\\/g, "/") + const nm = n.match(/node_modules\/(?:\.bun\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)(?:\/dist\/([^/]+))?/) + if (nm) return nm[1] === "effect" ? `effect/${(nm[2] ?? "").replace(/\.js$/, "")}` : nm[1] + const pk = n.match(/packages\/([^/]+)\/src\/(.+)$/) + return pk ? `${pk[1]}/${pk[2]}` : n.slice(-50) +} +const parent = new Map() +for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id) +const resolve = (frame: any) => { + const name = frame.functionName + if (name === "(program)" || name === "(garbage collector)") return { label: name, fn: name } + const file = frame.url.split("/").pop() + const map = maps.get(file) + if (!map) return { label: `(no map) ${file}`, fn: name } + const pos = originalPositionFor(map, { line: frame.lineNumber + 1, column: frame.columnNumber }) + return { label: pos.source ? group(pos.source) : `(unmapped) ${file}`, fn: pos.name ?? name } +} +const labelOf = (frame: any) => resolve(frame).label +// A sample belongs to the render phase once Solid's root is on the stack; everything before that is +// module evaluation, everything after the first render is later work (hydration, effects, timers). +const stackHas = (id: number, test: (label: string, fn: string) => boolean) => { + for (let cur: number | undefined = id; cur !== undefined; cur = parent.get(cur)) { + const resolved = resolve(nodes.get(cur).callFrame) + if (test(resolved.label, resolved.fn)) return true + } + return false +} +const phases = { evaluate: new Map(), render: new Map(), later: new Map() } +let phase: keyof typeof phases = "evaluate" +let t = 0 +let total = 0 +for (let i = 0; i < profile.samples.length; i++) { + const dt = (profile.timeDeltas[i] ?? 0) / 1000 + t += dt + const node = nodes.get(profile.samples[i]) + if (node.callFrame.functionName === "(idle)") { + if (phase === "render" && dt > 5) phase = "later" + continue + } + total += dt + if (phase === "evaluate" && stackHas(node.id, (label, fn) => label === "solid-js" && (fn === "render" || fn === "createRoot"))) + phase = "render" + const label = labelOf(node.callFrame) + const bucket = phases[phase] + bucket.set(label, (bucket.get(label) ?? 0) + dt) + self.set(label, (self.get(label) ?? 0) + dt) + const pkg = label.split("/").slice(0, label.startsWith("effect/") || label.startsWith("@") ? 2 : 1).join("/") + byPkg.set(pkg, (byPkg.get(pkg) ?? 0) + dt) +} +console.log(`busy ${total.toFixed(0)} ms over ${t.toFixed(0)} ms`) +void phases +// Timeline: 25 ms buckets with the top sources, so module evaluation, render and hydration show as bands. +const buckets = new Map>() +t = 0 +for (let i = 0; i < profile.samples.length; i++) { + const dt = (profile.timeDeltas[i] ?? 0) / 1000 + t += dt + const node = nodes.get(profile.samples[i]) + if (node.callFrame.functionName === "(idle)") continue + const b = Math.floor(t / 25) * 25 + const m = buckets.get(b) ?? new Map() + const label = labelOf(node.callFrame).replace(/^(\.\.\/)+/, "") + m.set(label, (m.get(label) ?? 0) + dt) + buckets.set(b, m) +} +console.log("\n== timeline (25 ms buckets) ==") +for (const [b, m] of [...buckets].sort((a, c) => a[0] - c[0])) { + const busy = [...m.values()].reduce((a, c) => a + c, 0) + if (busy < 1) continue + const top = [...m].sort((a, c) => c[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" | ") + console.log(String(b).padStart(5), busy.toFixed(0).padStart(3), top) +} +console.log("\n== by package (all) ==") +for (const [k, v] of [...byPkg].sort((a, b) => b[1] - a[1]).slice(0, 20)) console.log(v.toFixed(1).padStart(7), k) +