Explorar o código

chore: add typecheck profiling (#35622)

Aiden Cline hai 1 mes
pai
achega
984430c97d
Modificáronse 4 ficheiros con 242 adicións e 1 borrados
  1. 1 0
      .gitignore
  2. 3 1
      package.json
  3. 66 0
      script/profile-typecheck-packages.ts
  4. 172 0
      script/profile-typecheck.ts

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ tmp
 dist
 ts-dist
 .turbo
+.typecheck-profiles
 **/.serena
 .serena/
 **/.omo

+ 3 - 1
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",

+ 66 - 0
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)}`)

+ 172 - 0
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<typeof processList>[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)}`
+}