1import assert from "node:assert/strict"
2import type { Profiler, Runtime } from "node:inspector"
3import { percentile } from "../../stats.mts"
4
5export 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
17type Category = typeof categories[number]
18type Frame = Runtime.CallFrame
19
20const inFile = (frame: Frame, suffix: string) => frame.url.endsWith(suffix)
21const 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.
26export 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
58export 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.
131export 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}