| 1 | /** |
| 2 | * Attribute TypeScript check time and type creation to their source — |
| 3 | * "which types are making the checker slow, and whose fault are they?" |
| 4 | * |
| 5 | * The workspace compiler is tsgo (TS 7), which does NOT support |
| 6 | * `--generateTrace`, so traces are produced with Strada (TS 5.x) used purely |
| 7 | * as a measurement instrument against the same project: |
| 8 | * |
| 9 | * ```sh |
| 10 | * # 1. generate a trace (~2.5 min, ~12GB RAM for packages/alchemy) |
| 11 | * bunx --package typescript@5.9 tsc \ |
| 12 | * -p packages/alchemy/tsconfig.json \ |
| 13 | * --noEmit --incremental false --composite false \ |
| 14 | * --generateTrace /tmp/tstrace |
| 15 | * |
| 16 | * # 2. attribute it |
| 17 | * bun scripts/analyze-type-trace.ts /tmp/tstrace |
| 18 | * ``` |
| 19 | * |
| 20 | * Reports, from `trace.json` + a streaming pass over `types.json`: |
| 21 | * - check self-time per bucket (src/AWS, src/Cloudflare, core, distilled |
| 22 | * lib, effect, node_modules, TS libs) and the top files |
| 23 | * - top-level `structuredTypeRelatedTo` relation pairs by time — this is |
| 24 | * what exposed the 28.4s `Worker<NormalizedBindings<...>>` vs |
| 25 | * `WorkerBindingResource` constraint proof (45% of all check time) |
| 26 | * - locationless union/intersection types attributed to their first |
| 27 | * member's origin, and the top symbols by types created |
| 28 | * |
| 29 | * NOTE: `npx @typescript/analyze-trace` is complementary (per-file hot-spot |
| 30 | * drill-downs from trace.json) but crashes with "Invalid string length" when |
| 31 | * given an Effect-scale types.json — this script streams it instead. |
| 32 | * types.json can exceed 1GB; the stream pass takes ~1 min. |
| 33 | */ |
| 34 | import * as fs from "node:fs"; |
| 35 | import * as path from "node:path"; |
| 36 | import * as readline from "node:readline"; |
| 37 | |
| 38 | const traceDir = process.argv[2]; |
| 39 | if (!traceDir) { |
| 40 | console.error("usage: bun scripts/analyze-type-trace.ts <traceDir>"); |
| 41 | process.exit(1); |
| 42 | } |
| 43 | |
| 44 | const BUCKETS = [ |
| 45 | "(unknown)", |
| 46 | "packages/alchemy/src/AWS", |
| 47 | "packages/alchemy/src/Cloudflare", |
| 48 | "packages/alchemy/src (core)", |
| 49 | "submodules/distilled/*/lib", |
| 50 | "node_modules/effect", |
| 51 | "other node_modules", |
| 52 | "typescript libs", |
| 53 | "other", |
| 54 | ] as const; |
| 55 | |
| 56 | // Match by path segment rather than a repo-root prefix so the script works no |
| 57 | // matter which checkout produced the trace. Strada lowercases paths on macOS. |
| 58 | function bucketIdOf(p: string | undefined): number { |
| 59 | if (!p) return 0; |
| 60 | const lower = p.toLowerCase(); |
| 61 | if (lower.includes("node_modules/effect/")) return 5; |
| 62 | if (lower.includes("node_modules/typescript/lib/")) return 7; |
| 63 | if (lower.includes("node_modules/")) return 6; |
| 64 | if (lower.includes("packages/alchemy/src/aws/")) return 1; |
| 65 | if (lower.includes("packages/alchemy/src/cloudflare/")) return 2; |
| 66 | if (lower.includes("packages/alchemy/src/")) return 3; |
| 67 | if (/distilled\/packages\/[^/]+\//.test(lower)) return 4; |
| 68 | return 8; |
| 69 | } |
| 70 | |
| 71 | // Strip the absolute prefix before the first recognizable repo-relative |
| 72 | // segment, purely for display. |
| 73 | const rel = (p: string | undefined) => { |
| 74 | if (!p) return "?"; |
| 75 | const m = p.match( |
| 76 | /(?:packages|submodules|distilled|node_modules|examples|scripts)\/.*$/, |
| 77 | ); |
| 78 | return m ? m[0] : p; |
| 79 | }; |
| 80 | |
| 81 | // ---------- trace.json: check self-time + top-level relation pairs ---------- |
| 82 | interface TraceEvent { |
| 83 | name?: string; |
| 84 | ph?: string; |
| 85 | ts: number; |
| 86 | dur?: number; |
| 87 | args?: { path?: string; sourceId?: number; targetId?: number }; |
| 88 | } |
| 89 | |
| 90 | const trace: TraceEvent[] = JSON.parse( |
| 91 | fs.readFileSync(path.join(traceDir, "trace.json"), "utf8"), |
| 92 | ); |
| 93 | |
| 94 | const stack: { p: string | undefined; start: number; child: number }[] = []; |
| 95 | const fileSelf = new Map<string, number>(); |
| 96 | let totalCheck = 0; |
| 97 | const relEvents: TraceEvent[] = []; |
| 98 | for (const ev of trace) { |
| 99 | if (!ev?.name) continue; |
| 100 | if (ev.name === "checkSourceFile") { |
| 101 | if (ev.ph === "B") { |
| 102 | stack.push({ p: ev.args?.path, start: ev.ts, child: 0 }); |
| 103 | } else if (ev.ph === "E" && stack.length) { |
| 104 | const f = stack.pop()!; |
| 105 | const dur = ev.ts - f.start; |
| 106 | const self = dur - f.child; |
| 107 | if (stack.length) stack[stack.length - 1].child += dur; |
| 108 | fileSelf.set(f.p ?? "?", (fileSelf.get(f.p ?? "?") ?? 0) + self); |
| 109 | totalCheck += self; |
| 110 | } |
| 111 | } else if (ev.name === "structuredTypeRelatedTo" && ev.ph === "X") { |
| 112 | relEvents.push(ev); |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // Keep only top-level relation intervals (not nested inside a prior one). |
| 117 | relEvents.sort((a, b) => a.ts - b.ts); |
| 118 | let coverEnd = -1; |
| 119 | const pairTime = new Map<string, number>(); |
| 120 | let topLevelRelTotal = 0; |
| 121 | const neededIds = new Set<number>(); |
| 122 | for (const ev of relEvents) { |
| 123 | if (ev.ts >= coverEnd) { |
| 124 | topLevelRelTotal += ev.dur ?? 0; |
| 125 | coverEnd = ev.ts + (ev.dur ?? 0); |
| 126 | const k = `${ev.args!.sourceId}->${ev.args!.targetId}`; |
| 127 | pairTime.set(k, (pairTime.get(k) ?? 0) + (ev.dur ?? 0)); |
| 128 | neededIds.add(ev.args!.sourceId!); |
| 129 | neededIds.add(ev.args!.targetId!); |
| 130 | } |
| 131 | } |
| 132 | const topPairs = [...pairTime.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25); |
| 133 | |
| 134 | const out: string[] = []; |
| 135 | out.push( |
| 136 | `== checkSourceFile self-time: ${(totalCheck / 1e6).toFixed(1)}s over ${fileSelf.size} files ==`, |
| 137 | "", |
| 138 | "== Check self-time by bucket ==", |
| 139 | ); |
| 140 | const bucketTime = new Map<string, number>(); |
| 141 | for (const [p, us] of fileSelf) { |
| 142 | const b = BUCKETS[bucketIdOf(p)]; |
| 143 | bucketTime.set(b, (bucketTime.get(b) ?? 0) + us); |
| 144 | } |
| 145 | for (const [b, us] of [...bucketTime.entries()].sort((a, b) => b[1] - a[1])) { |
| 146 | out.push( |
| 147 | `${(us / 1000).toFixed(0).padStart(9)} ms ${((us / totalCheck) * 100).toFixed(1).padStart(5)}% ${b}`, |
| 148 | ); |
| 149 | } |
| 150 | out.push("", "== Top 30 files by check self-time =="); |
| 151 | for (const [p, us] of [...fileSelf.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)) { |
| 152 | out.push(`${(us / 1000).toFixed(0).padStart(9)} ms ${rel(p)}`); |
| 153 | } |
| 154 | out.push( |
| 155 | "", |
| 156 | `== Top-level structuredTypeRelatedTo total: ${(topLevelRelTotal / 1e6).toFixed(1)}s ==`, |
| 157 | ); |
| 158 | |
| 159 | // ---------- types.json: streamed attribution ---------- |
| 160 | interface TypeDescriptor { |
| 161 | id: number; |
| 162 | symbolName?: string; |
| 163 | firstDeclaration?: { path?: string; start?: { line: number } }; |
| 164 | location?: { path?: string; line?: number }; |
| 165 | intersectionTypes?: number[]; |
| 166 | unionTypes?: number[]; |
| 167 | flags?: string[]; |
| 168 | } |
| 169 | |
| 170 | const typesPath = path.join(traceDir, "types.json"); |
| 171 | const rl = readline.createInterface({ |
| 172 | input: fs.createReadStream(typesPath, { encoding: "utf8", highWaterMark: 1 << 22 }), |
| 173 | crlfDelay: Infinity, |
| 174 | }); |
| 175 | |
| 176 | const MAX = 16_000_000; |
| 177 | const bucketById = new Uint8Array(MAX); |
| 178 | const idInfo = new Map< |
| 179 | number, |
| 180 | { sym?: string; path?: string; line?: number; flags?: string } |
| 181 | >(); |
| 182 | const compositeByBucket = new Map<string, number>(); |
| 183 | const symCount = new Map<string, number>(); |
| 184 | const locatedByBucket = new Map<string, number>(); |
| 185 | let total = 0; |
| 186 | |
| 187 | for await (let line of rl) { |
| 188 | line = line.trim(); |
| 189 | if (line === "[" || line === "]" || line === "") continue; |
| 190 | if (line.startsWith("[")) line = line.slice(1); |
| 191 | if (line.endsWith(",")) line = line.slice(0, -1); |
| 192 | if (line[0] !== "{") continue; |
| 193 | let t: TypeDescriptor; |
| 194 | try { |
| 195 | t = JSON.parse(line); |
| 196 | } catch { |
| 197 | continue; |
| 198 | } |
| 199 | total++; |
| 200 | const decl = t.firstDeclaration ?? t.location; |
| 201 | const p = decl && ("path" in decl ? decl.path : undefined); |
| 202 | const lineNo = |
| 203 | decl && ("start" in decl && decl.start ? decl.start.line : (decl as { line?: number }).line); |
| 204 | let b = 0; |
| 205 | if (p) { |
| 206 | b = bucketIdOf(p); |
| 207 | locatedByBucket.set(BUCKETS[b], (locatedByBucket.get(BUCKETS[b]) ?? 0) + 1); |
| 208 | if (t.symbolName) { |
| 209 | const key = `${t.symbolName} @ ${rel(p)}:${lineNo}`; |
| 210 | symCount.set(key, (symCount.get(key) ?? 0) + 1); |
| 211 | } |
| 212 | } else { |
| 213 | const members = t.intersectionTypes ?? t.unionTypes; |
| 214 | if (members) { |
| 215 | // Attribute a locationless composite to its first non-lib member. |
| 216 | for (const m of members) { |
| 217 | if (m > 0 && m < MAX && bucketById[m] !== 0 && bucketById[m] !== 7) { |
| 218 | b = bucketById[m]; |
| 219 | break; |
| 220 | } |
| 221 | } |
| 222 | if (b === 0) { |
| 223 | for (const m of members) { |
| 224 | if (m > 0 && m < MAX && bucketById[m] !== 0) { |
| 225 | b = bucketById[m]; |
| 226 | break; |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | const bn = BUCKETS[b] + (t.intersectionTypes ? " [intersection]" : " [union]"); |
| 231 | compositeByBucket.set(bn, (compositeByBucket.get(bn) ?? 0) + 1); |
| 232 | } |
| 233 | } |
| 234 | if (t.id < MAX) bucketById[t.id] = b; |
| 235 | if (neededIds.has(t.id)) { |
| 236 | idInfo.set(t.id, { |
| 237 | sym: t.symbolName, |
| 238 | path: p, |
| 239 | line: lineNo, |
| 240 | flags: t.flags?.join("|"), |
| 241 | }); |
| 242 | } |
| 243 | if (total % 1_000_000 === 0) process.stderr.write(` ...streamed ${total} types\n`); |
| 244 | } |
| 245 | |
| 246 | out.push("", `== ${total} types total; located types by bucket ==`); |
| 247 | for (const [b, c] of [...locatedByBucket.entries()].sort((a, b) => b[1] - a[1])) { |
| 248 | out.push(`${String(c).padStart(9)} ${b}`); |
| 249 | } |
| 250 | out.push("", "== Locationless union/intersection types by first member's bucket =="); |
| 251 | for (const [b, c] of [...compositeByBucket.entries()].sort((a, b) => b[1] - a[1])) { |
| 252 | out.push(`${String(c).padStart(9)} ${b}`); |
| 253 | } |
| 254 | out.push("", "== Top 30 symbols by types created =="); |
| 255 | for (const [k, c] of [...symCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)) { |
| 256 | out.push(`${String(c).padStart(9)} ${k}`); |
| 257 | } |
| 258 | out.push("", "== Top 25 top-level relation pairs by time =="); |
| 259 | const fmt = (id: number) => { |
| 260 | const i = idInfo.get(id); |
| 261 | if (!i) return `#${id}`; |
| 262 | const loc = i.path ? `${rel(i.path)}:${i.line}` : i.flags; |
| 263 | return `${i.sym ?? "(anon)"} [${i.flags}] ${loc}`; |
| 264 | }; |
| 265 | for (const [k, us] of topPairs) { |
| 266 | const [s, tg] = k.split("->").map(Number); |
| 267 | out.push(`${(us / 1000).toFixed(0).padStart(8)} ms ${fmt(s)}`, ` -> ${fmt(tg)}`); |
| 268 | } |
| 269 | |
| 270 | const outPath = path.join(traceDir, "attribution.txt"); |
| 271 | fs.writeFileSync(outPath, `${out.join("\n")}\n`); |
| 272 | console.log(out.join("\n")); |
| 273 | console.error(`\nwritten to ${outPath}`); |