From 984430c97dcdd2ee314915deec4843a355b3d364 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:30 -0500 Subject: [PATCH] chore: add typecheck profiling (#35622) --- .gitignore | 1 + package.json | 4 +- script/profile-typecheck-packages.ts | 66 ++++++++++ script/profile-typecheck.ts | 172 +++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 script/profile-typecheck-packages.ts create mode 100644 script/profile-typecheck.ts diff --git a/.gitignore b/.gitignore index 006cab8c27..ba80d79b90 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ tmp dist ts-dist .turbo +.typecheck-profiles **/.serena .serena/ **/.omo diff --git a/package.json b/package.json index 8ffb18491e..e427ceb127 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "lint": "oxlint", "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src", "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", - "typecheck": "bun turbo typecheck", + "typecheck": "bun turbo typecheck --concurrency=3", + "typecheck:profile": "bun script/profile-typecheck.ts", + "typecheck:profile:packages": "bun script/profile-typecheck-packages.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", "prepare": "husky", diff --git a/script/profile-typecheck-packages.ts b/script/profile-typecheck-packages.ts new file mode 100644 index 0000000000..0d03b1c1bd --- /dev/null +++ b/script/profile-typecheck-packages.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env bun + +import path from "path" + +const root = path.resolve(import.meta.dir, "..") +const proc = Bun.spawn( + [ + "bun", + "turbo", + "typecheck", + "--concurrency=1", + "--force", + "--continue=always", + "--summarize", + "--output-logs=errors-only", + ], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }, +) +const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) +const output = stdout + stderr +if (exitCode !== 0) { + process.stdout.write(stdout) + process.stderr.write(stderr) + process.exit(exitCode) +} + +const summary = output.match(/Summary:\s+(.+\.json)/)?.[1]?.trim() +if (!summary) { + process.stdout.write(stdout) + process.stderr.write(stderr) + throw new Error("Turbo did not report a run summary") +} + +const report = (await Bun.file(summary).json()) as { + tasks: Array<{ + taskId: string + execution: { startTime: number; endTime: number; exitCode: number } | null + }> +} +const tasks = report.tasks + .flatMap((task) => + task.execution + ? [ + { + task: task.taskId.replace(/#typecheck$/, ""), + durationMs: task.execution.endTime - task.execution.startTime, + }, + ] + : [], + ) + .sort((a, b) => b.durationMs - a.durationMs) +const total = tasks.reduce((duration, task) => duration + task.durationMs, 0) +const width = Math.max(...tasks.map((task) => task.task.length), "Package".length) + +console.log(`Package${" ".repeat(width - "Package".length)} Time Share`) +tasks.forEach((task) => { + const duration = `${(task.durationMs / 1000).toFixed(2)}s`.padStart(7) + const share = `${((task.durationMs / total) * 100).toFixed(1)}%`.padStart(6) + console.log(`${task.task.padEnd(width)} ${duration} ${share}`) +}) +console.log(`\nTotal serial task time: ${(total / 1000).toFixed(2)}s`) +console.log(`Turbo summary: ${path.relative(root, summary)}`) diff --git a/script/profile-typecheck.ts b/script/profile-typecheck.ts new file mode 100644 index 0000000000..ac093847bc --- /dev/null +++ b/script/profile-typecheck.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun + +import { mkdir } from "fs/promises" +import path from "path" + +if (process.platform !== "darwin") throw new Error("System typecheck profiling currently supports macOS only") + +const root = path.resolve(import.meta.dir, "..") +const startedAt = new Date() +const args = Bun.argv.slice(2) +const command = [ + "bun", + "turbo", + "typecheck", + ...(args.some((arg) => arg.startsWith("--concurrency")) ? [] : ["--concurrency=3"]), + ...args, +] +const before = systemSnapshot() +const proc = Bun.spawn(command, { + cwd: root, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", +}) +const samples = [processTreeSnapshot(proc.pid, startedAt)] +const timer = setInterval(() => samples.push(processTreeSnapshot(proc.pid, startedAt)), 200) +const exitCode = await proc.exited +clearInterval(timer) +samples.push(processTreeSnapshot(proc.pid, startedAt)) + +const finishedAt = new Date() +const after = systemSnapshot() +const active = samples.filter((sample) => sample.processes > 0) +const report = { + command, + cwd: root, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationSeconds: (finishedAt.getTime() - startedAt.getTime()) / 1000, + exitCode, + summary: { + peakCpuPercent: Math.max(0, ...active.map((sample) => sample.cpuPercent)), + averageCpuPercent: average(active.map((sample) => sample.cpuPercent)), + peakAggregateRssMB: Math.max(0, ...active.map((sample) => sample.aggregateRssMB)), + peakProcesses: Math.max(0, ...active.map((sample) => sample.processes)), + peakTsgoRelatedProcesses: Math.max(0, ...active.map((sample) => sample.tsgoRelatedProcesses)), + swapDeltaMB: after.swapUsedMB - before.swapUsedMB, + compressedMemoryDeltaMB: after.compressedMemoryMB - before.compressedMemoryMB, + pageoutDelta: after.pageouts - before.pageouts, + }, + system: { before, after }, + samples, +} + +const directory = path.join(root, ".typecheck-profiles") +const file = path.join(directory, `${startedAt.toISOString().replaceAll(":", "-")}.json`) +await mkdir(directory, { recursive: true }) +await Bun.write(file, JSON.stringify(report, null, 2) + "\n") + +console.log(` +Typecheck profile + Duration: ${report.durationSeconds.toFixed(1)}s + Average CPU: ${report.summary.averageCpuPercent.toFixed(0)}% + Peak CPU: ${report.summary.peakCpuPercent.toFixed(0)}% + Aggregate RSS: ${report.summary.peakAggregateRssMB.toFixed(0)} MB + Peak processes: ${report.summary.peakProcesses} (${report.summary.peakTsgoRelatedProcesses} tsgo-related) + Swap delta: ${signed(report.summary.swapDeltaMB)} MB + Compressed: ${signed(report.summary.compressedMemoryDeltaMB)} MB + Pageouts: ${signed(report.summary.pageoutDelta)} + Report: ${path.relative(root, file)} +`) + +process.exit(exitCode) + +function processTreeSnapshot(rootPID: number, startedAt: Date) { + const processes = processList() + const pids = new Set([rootPID]) + const pending = [rootPID] + while (pending.length > 0) { + const parent = pending.shift() + processes + .filter((process) => process.ppid === parent && !pids.has(process.pid)) + .forEach((process) => { + pids.add(process.pid) + pending.push(process.pid) + }) + } + const tree = processes.filter((process) => pids.has(process.pid)) + return { + elapsedSeconds: (Date.now() - startedAt.getTime()) / 1000, + processes: tree.length, + tsgoRelatedProcesses: tree.filter((process) => /\btsgo\b/.test(process.command)).length, + cpuPercent: sum(tree.map((process) => process.cpuPercent)), + aggregateRssMB: sum(tree.map((process) => process.rssKB)) / 1024, + } +} + +function systemSnapshot() { + const vm = text(["vm_stat"]) + const pageSize = Number(vm.match(/page size of (\d+) bytes/)?.[1] ?? 4096) + const fields = Object.fromEntries( + vm + .split("\n") + .map((line) => line.match(/^([^:]+):\s+(\d+)\.?$/)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => [match[1], Number(match[2])]), + ) + const swap = text(["sysctl", "-n", "vm.swapusage"]) + return { + loadAverage: text(["sysctl", "-n", "vm.loadavg"]).trim(), + thermalState: text(["pmset", "-g", "therm"]).trim(), + swapUsedMB: Number(swap.match(/used = ([\d.]+)M/)?.[1] ?? 0), + freeMemoryMB: ((fields["Pages free"] ?? 0) * pageSize) / 1024 / 1024, + compressedMemoryMB: ((fields["Pages occupied by compressor"] ?? 0) * pageSize) / 1024 / 1024, + pageouts: fields.Pageouts ?? 0, + relevantProcesses: processList() + .filter((process) => + /opencode|tsgo|tsserver|vtsls|eslintServer|tailwindcss-language-server/.test(process.command), + ) + .sort((a, b) => b.rssKB - a.rssKB) + .map(processSummary), + topCpuProcesses: processList() + .sort((a, b) => b.cpuPercent - a.cpuPercent) + .slice(0, 15) + .map(processSummary), + topMemoryProcesses: processList() + .sort((a, b) => b.rssKB - a.rssKB) + .slice(0, 15) + .map(processSummary), + } +} + +function processSummary(process: ReturnType[number]) { + return { + pid: process.pid, + ppid: process.ppid, + rssMB: process.rssKB / 1024, + cpuPercent: process.cpuPercent, + command: process.command, + } +} + +function processList() { + return text(["ps", "-axo", "pid=,ppid=,rss=,%cpu=,command="]) + .split("\n") + .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => ({ + pid: Number(match[1]), + ppid: Number(match[2]), + rssKB: Number(match[3]), + cpuPercent: Number(match[4]), + command: match[5], + })) +} + +function text(command: string[]) { + return Bun.spawnSync(command).stdout.toString() +} + +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +function average(values: number[]) { + if (values.length === 0) return 0 + return sum(values) / values.length +} + +function signed(value: number) { + return `${value >= 0 ? "+" : ""}${value.toFixed(0)}` +}