| 1 | import assert from "node:assert/strict" |
| 2 | import type { Profiler, Runtime } from "node:inspector" |
| 3 | import { percentile } from "../../stats.mts" |
| 4 | |
| 5 | export const categories = [ |
| 6 | "type-projection", |
| 7 | "ast", |
| 8 | "schema-object", |
| 9 | "make-sync", |
| 10 | "make-effect", |
| 11 | "make-option", |
| 12 | "schema-api", |
| 13 | "fixture", |
| 14 | "gc", |
| 15 | "runtime" |
| 16 | ] as const |
| 17 | type Category = typeof categories[number] |
| 18 | type Frame = Runtime.CallFrame |
| 19 | |
| 20 | const inFile = (frame: Frame, suffix: string) => frame.url.endsWith(suffix) |
| 21 | const parserFunction = (frame: Frame, name: string) => |
| 22 | inFile(frame, "/src/SchemaParser.ts") && frame.functionName === name |
| 23 | |
| 24 | // Innermost first. Each sample belongs to exactly one category; parents are |
| 25 | // never added to child costs. The Option adapter includes its own makeEffect. |
| 26 | export function classify(stack: ReadonlyArray<Frame>): Category { |
| 27 | if (stack[0]?.functionName === "(garbage collector)") return "gc" |
| 28 | const sync = stack.findIndex((frame) => parserFunction(frame, "make")) |
| 29 | if (sync >= 0) { |
| 30 | if (stack.slice(0, sync).some((frame) => parserFunction(frame, "makeConstructorSync"))) return "make-sync" |
| 31 | return sync > 0 ? "type-projection" : "make-sync" |
| 32 | } |
| 33 | if (stack.some((frame) => parserFunction(frame, "makeOption"))) return "make-option" |
| 34 | if (stack.some((frame) => parserFunction(frame, "makeEffect"))) return "make-effect" |
| 35 | const centralMake = stack.findIndex((frame) => |
| 36 | inFile(frame, "/src/internal/schema/make.ts") && frame.functionName === "make" |
| 37 | ) |
| 38 | // V8 can inline SchemaParser.make and omit its frame. In these fixtures, |
| 39 | // central make has no getter-bearing options: its only eager AST/Function |
| 40 | // subtree is toType, including the WeakMap memoizer in Function.ts. |
| 41 | if ( |
| 42 | centralMake > 0 && |
| 43 | stack.slice(0, centralMake).some((frame) => inFile(frame, "/src/SchemaAST.ts") || inFile(frame, "/src/Function.ts")) |
| 44 | ) return "type-projection" |
| 45 | if (stack.some((frame) => inFile(frame, "/src/SchemaAST.ts"))) return "ast" |
| 46 | if (centralMake >= 0) { |
| 47 | return "schema-object" |
| 48 | } |
| 49 | if (stack.some((frame) => frame.url.includes("/packages/effect/src/"))) return "schema-api" |
| 50 | if ( |
| 51 | stack.some((frame) => inFile(frame, "/schema-construction/fixtures.mts") || frame.functionName === "constructBatch") |
| 52 | ) { |
| 53 | return "fixture" |
| 54 | } |
| 55 | return "runtime" |
| 56 | } |
| 57 | |
| 58 | export function analyzeProfile(profile: Profiler.Profile) { |
| 59 | const { nodes, samples, timeDeltas } = profile |
| 60 | assert.ok(samples && timeDeltas && samples.length === timeDeltas.length) |
| 61 | const byId = new Map(nodes.map((node) => [node.id, node])) |
| 62 | const parents = new Map<number, number>() |
| 63 | for (const node of nodes) for (const child of node.children ?? []) parents.set(child, node.id) |
| 64 | const stacks = new Map<number, Array<Frame>>() |
| 65 | for (const node of nodes) { |
| 66 | const frames: Array<Frame> = [] |
| 67 | const seen = new Set<number>() |
| 68 | let id: number | undefined = node.id |
| 69 | while (id !== undefined) { |
| 70 | assert.ok(!seen.has(id), "Cycle in CPU profile") |
| 71 | seen.add(id) |
| 72 | const current = byId.get(id) |
| 73 | assert.ok(current, "Missing profile node") |
| 74 | frames.push(current.callFrame) |
| 75 | id = parents.get(id) |
| 76 | } |
| 77 | stacks.set(node.id, frames) |
| 78 | } |
| 79 | const inBatch = samples.map((id) => |
| 80 | stacks.get(id)!.some((frame) => |
| 81 | frame.functionName === "constructBatch" && inFile(frame, "/schema-construction/worker.mts") |
| 82 | ) |
| 83 | ) |
| 84 | const first = inBatch.indexOf(true) |
| 85 | const last = inBatch.lastIndexOf(true) |
| 86 | const counts = Object.fromEntries(categories.map((category) => [category, 0])) as Record<Category, number> |
| 87 | const sites = new Map<string, { category: Category; frame: Frame; samples: number }>() |
| 88 | const lineTicks = new Map<string, { category: Category; url: string; line: number; ticks: number }>() |
| 89 | const sampledNodes = new Set<number>() |
| 90 | if (first < 0) { |
| 91 | return { counts, totalSamples: 0, spanUs: 0, excludedSamples: samples.length, sites: [], lineTicks: [] } |
| 92 | } |
| 93 | let spanUs = 0 |
| 94 | for (let index = first; index <= last; index++) { |
| 95 | const stack = stacks.get(samples[index])! |
| 96 | const category = classify(stack) |
| 97 | sampledNodes.add(samples[index]) |
| 98 | counts[category]++ |
| 99 | if (index > first) spanUs += timeDeltas[index] |
| 100 | const frame = stack[0] |
| 101 | const key = `${category}:${frame.url}:${frame.lineNumber}:${frame.columnNumber}:${frame.functionName}` |
| 102 | const site = sites.get(key) ?? { category, frame, samples: 0 } |
| 103 | site.samples++ |
| 104 | sites.set(key, site) |
| 105 | } |
| 106 | for (const id of sampledNodes) { |
| 107 | const node = byId.get(id)! |
| 108 | const stack = stacks.get(id)! |
| 109 | if (!stack.some((frame) => frame.functionName === "constructBatch")) continue |
| 110 | for (const position of node.positionTicks ?? []) { |
| 111 | const category = classify(stack) |
| 112 | const url = node.callFrame.url |
| 113 | const key = `${category}:${url}:${position.line}` |
| 114 | const entry = lineTicks.get(key) ?? { category, url, line: position.line, ticks: 0 } |
| 115 | entry.ticks += position.ticks |
| 116 | lineTicks.set(key, entry) |
| 117 | } |
| 118 | } |
| 119 | return { |
| 120 | counts, |
| 121 | totalSamples: last - first + 1, |
| 122 | spanUs, |
| 123 | excludedSamples: samples.length - (last - first + 1), |
| 124 | sites: [...sites.values()].sort((a, b) => b.samples - a.samples), |
| 125 | lineTicks: [...lineTicks.values()].sort((a, b) => b.ticks - a.ticks) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // Resample whole fresh processes, preserving correlation among their samples. |
| 130 | // The point estimate is the mean process share, giving each process equal weight. |
| 131 | export function shareInterval(values: ReadonlyArray<number>, iterations = 10_000) { |
| 132 | assert.ok(values.length > 0 && values.every((value) => Number.isFinite(value) && value >= 0 && value <= 1)) |
| 133 | const mean = values.reduce((a, b) => a + b, 0) / values.length |
| 134 | let state = 0x5eed1234 |
| 135 | const draws = new Array(iterations) |
| 136 | for (let iteration = 0; iteration < iterations; iteration++) { |
| 137 | let sum = 0 |
| 138 | for (let index = 0; index < values.length; index++) { |
| 139 | state = (Math.imul(state, 1664525) + 1013904223) >>> 0 |
| 140 | sum += values[Math.floor(state / 0x100000000 * values.length)] |
| 141 | } |
| 142 | draws[iteration] = sum / values.length |
| 143 | } |
| 144 | return { mean, low: percentile(draws, 0.025), high: percentile(draws, 0.975) } |
| 145 | } |