profile-typecheck.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. #!/usr/bin/env bun
  2. import { mkdir } from "fs/promises"
  3. import path from "path"
  4. if (process.platform !== "darwin") throw new Error("System typecheck profiling currently supports macOS only")
  5. const root = path.resolve(import.meta.dir, "..")
  6. const startedAt = new Date()
  7. const args = Bun.argv.slice(2)
  8. const command = [
  9. "bun",
  10. "turbo",
  11. "typecheck",
  12. ...(args.some((arg) => arg.startsWith("--concurrency")) ? [] : ["--concurrency=3"]),
  13. ...args,
  14. ]
  15. const before = systemSnapshot()
  16. const proc = Bun.spawn(command, {
  17. cwd: root,
  18. stdin: "inherit",
  19. stdout: "inherit",
  20. stderr: "inherit",
  21. })
  22. const samples = [processTreeSnapshot(proc.pid, startedAt)]
  23. const timer = setInterval(() => samples.push(processTreeSnapshot(proc.pid, startedAt)), 200)
  24. const exitCode = await proc.exited
  25. clearInterval(timer)
  26. samples.push(processTreeSnapshot(proc.pid, startedAt))
  27. const finishedAt = new Date()
  28. const after = systemSnapshot()
  29. const active = samples.filter((sample) => sample.processes > 0)
  30. const report = {
  31. command,
  32. cwd: root,
  33. startedAt: startedAt.toISOString(),
  34. finishedAt: finishedAt.toISOString(),
  35. durationSeconds: (finishedAt.getTime() - startedAt.getTime()) / 1000,
  36. exitCode,
  37. summary: {
  38. peakCpuPercent: Math.max(0, ...active.map((sample) => sample.cpuPercent)),
  39. averageCpuPercent: average(active.map((sample) => sample.cpuPercent)),
  40. peakAggregateRssMB: Math.max(0, ...active.map((sample) => sample.aggregateRssMB)),
  41. peakProcesses: Math.max(0, ...active.map((sample) => sample.processes)),
  42. peakTsgoRelatedProcesses: Math.max(0, ...active.map((sample) => sample.tsgoRelatedProcesses)),
  43. swapDeltaMB: after.swapUsedMB - before.swapUsedMB,
  44. compressedMemoryDeltaMB: after.compressedMemoryMB - before.compressedMemoryMB,
  45. pageoutDelta: after.pageouts - before.pageouts,
  46. },
  47. system: { before, after },
  48. samples,
  49. }
  50. const directory = path.join(root, ".typecheck-profiles")
  51. const file = path.join(directory, `${startedAt.toISOString().replaceAll(":", "-")}.json`)
  52. await mkdir(directory, { recursive: true })
  53. await Bun.write(file, JSON.stringify(report, null, 2) + "\n")
  54. console.log(`
  55. Typecheck profile
  56. Duration: ${report.durationSeconds.toFixed(1)}s
  57. Average CPU: ${report.summary.averageCpuPercent.toFixed(0)}%
  58. Peak CPU: ${report.summary.peakCpuPercent.toFixed(0)}%
  59. Aggregate RSS: ${report.summary.peakAggregateRssMB.toFixed(0)} MB
  60. Peak processes: ${report.summary.peakProcesses} (${report.summary.peakTsgoRelatedProcesses} tsgo-related)
  61. Swap delta: ${signed(report.summary.swapDeltaMB)} MB
  62. Compressed: ${signed(report.summary.compressedMemoryDeltaMB)} MB
  63. Pageouts: ${signed(report.summary.pageoutDelta)}
  64. Report: ${path.relative(root, file)}
  65. `)
  66. process.exit(exitCode)
  67. function processTreeSnapshot(rootPID: number, startedAt: Date) {
  68. const processes = processList()
  69. const pids = new Set([rootPID])
  70. const pending = [rootPID]
  71. while (pending.length > 0) {
  72. const parent = pending.shift()
  73. processes
  74. .filter((process) => process.ppid === parent && !pids.has(process.pid))
  75. .forEach((process) => {
  76. pids.add(process.pid)
  77. pending.push(process.pid)
  78. })
  79. }
  80. const tree = processes.filter((process) => pids.has(process.pid))
  81. return {
  82. elapsedSeconds: (Date.now() - startedAt.getTime()) / 1000,
  83. processes: tree.length,
  84. tsgoRelatedProcesses: tree.filter((process) => /\btsgo\b/.test(process.command)).length,
  85. cpuPercent: sum(tree.map((process) => process.cpuPercent)),
  86. aggregateRssMB: sum(tree.map((process) => process.rssKB)) / 1024,
  87. }
  88. }
  89. function systemSnapshot() {
  90. const vm = text(["vm_stat"])
  91. const pageSize = Number(vm.match(/page size of (\d+) bytes/)?.[1] ?? 4096)
  92. const fields = Object.fromEntries(
  93. vm
  94. .split("\n")
  95. .map((line) => line.match(/^([^:]+):\s+(\d+)\.?$/))
  96. .filter((match): match is RegExpMatchArray => match !== null)
  97. .map((match) => [match[1], Number(match[2])]),
  98. )
  99. const swap = text(["sysctl", "-n", "vm.swapusage"])
  100. return {
  101. loadAverage: text(["sysctl", "-n", "vm.loadavg"]).trim(),
  102. thermalState: text(["pmset", "-g", "therm"]).trim(),
  103. swapUsedMB: Number(swap.match(/used = ([\d.]+)M/)?.[1] ?? 0),
  104. freeMemoryMB: ((fields["Pages free"] ?? 0) * pageSize) / 1024 / 1024,
  105. compressedMemoryMB: ((fields["Pages occupied by compressor"] ?? 0) * pageSize) / 1024 / 1024,
  106. pageouts: fields.Pageouts ?? 0,
  107. relevantProcesses: processList()
  108. .filter((process) =>
  109. /opencode|tsgo|tsserver|vtsls|eslintServer|tailwindcss-language-server/.test(process.command),
  110. )
  111. .sort((a, b) => b.rssKB - a.rssKB)
  112. .map(processSummary),
  113. topCpuProcesses: processList()
  114. .sort((a, b) => b.cpuPercent - a.cpuPercent)
  115. .slice(0, 15)
  116. .map(processSummary),
  117. topMemoryProcesses: processList()
  118. .sort((a, b) => b.rssKB - a.rssKB)
  119. .slice(0, 15)
  120. .map(processSummary),
  121. }
  122. }
  123. function processSummary(process: ReturnType<typeof processList>[number]) {
  124. return {
  125. pid: process.pid,
  126. ppid: process.ppid,
  127. rssMB: process.rssKB / 1024,
  128. cpuPercent: process.cpuPercent,
  129. command: process.command,
  130. }
  131. }
  132. function processList() {
  133. return text(["ps", "-axo", "pid=,ppid=,rss=,%cpu=,command="])
  134. .split("\n")
  135. .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/))
  136. .filter((match): match is RegExpMatchArray => match !== null)
  137. .map((match) => ({
  138. pid: Number(match[1]),
  139. ppid: Number(match[2]),
  140. rssKB: Number(match[3]),
  141. cpuPercent: Number(match[4]),
  142. command: match[5],
  143. }))
  144. }
  145. function text(command: string[]) {
  146. return Bun.spawnSync(command).stdout.toString()
  147. }
  148. function sum(values: number[]) {
  149. return values.reduce((total, value) => total + value, 0)
  150. }
  151. function average(values: number[]) {
  152. if (values.length === 0) return 0
  153. return sum(values) / values.length
  154. }
  155. function signed(value: number) {
  156. return `${value >= 0 ? "+" : ""}${value.toFixed(0)}`
  157. }