| 1 | import assert from "node:assert/strict" |
| 2 | import { execFileSync } from "node:child_process" |
| 3 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs" |
| 4 | import { join } from "node:path" |
| 5 | import { fileURLToPath, pathToFileURL } from "node:url" |
| 6 | import * as v8 from "node:v8" |
| 7 | |
| 8 | const implementations = [ |
| 9 | "effect-interpreted", |
| 10 | "effect-jit", |
| 11 | "effect-aot", |
| 12 | "valibot", |
| 13 | "zod-jitless", |
| 14 | "zod-compiled" |
| 15 | ] as const |
| 16 | type Implementation = typeof implementations[number] |
| 17 | |
| 18 | const cases = [ |
| 19 | "struct-decode", |
| 20 | "struct-invalid", |
| 21 | "struct-is", |
| 22 | "array-decode", |
| 23 | "union-decode", |
| 24 | "transform-decode", |
| 25 | "default-make" |
| 26 | ] as const |
| 27 | type CaseName = typeof cases[number] |
| 28 | |
| 29 | type BuiltCase = { |
| 30 | readonly schema: unknown |
| 31 | input: unknown |
| 32 | expected: unknown |
| 33 | readonly invalid?: boolean |
| 34 | readonly ast?: unknown |
| 35 | } |
| 36 | |
| 37 | type Parser = (input: unknown) => unknown |
| 38 | |
| 39 | const [command, root, implementationArgument, caseArgument, countArgument, output] = process.argv.slice(2) |
| 40 | const includes = <A extends string>(values: ReadonlyArray<A>, value: string | undefined): value is A => |
| 41 | value !== undefined && values.includes(value as A) |
| 42 | |
| 43 | if (command !== "measure" && command !== "generate") { |
| 44 | throw new Error("command must be measure or generate") |
| 45 | } |
| 46 | if (root === undefined) throw new Error("root is required") |
| 47 | if (!includes(implementations, implementationArgument)) { |
| 48 | throw new Error(`implementation must be one of: ${implementations.join(", ")}`) |
| 49 | } |
| 50 | if (!includes(cases, caseArgument)) { |
| 51 | throw new Error(`case must be one of: ${cases.join(", ")}`) |
| 52 | } |
| 53 | const implementation: Implementation = implementationArgument |
| 54 | const caseName: CaseName = caseArgument |
| 55 | const count = Number(countArgument) |
| 56 | if (!Number.isSafeInteger(count) || count <= 0) throw new Error("count must be a positive integer") |
| 57 | |
| 58 | const forceGc = () => { |
| 59 | assert.equal(typeof globalThis.gc, "function", "run this probe with --expose-gc") |
| 60 | for (let i = 0; i < 5; i++) globalThis.gc!() |
| 61 | } |
| 62 | |
| 63 | type CpuSample = { |
| 64 | readonly cpuMicros: number |
| 65 | readonly systemMicros: number |
| 66 | readonly userMicros: number |
| 67 | readonly wallNanos: number |
| 68 | } |
| 69 | |
| 70 | const measureCpu = async <A,>(f: () => A | Promise<A>): Promise<readonly [A, CpuSample]> => { |
| 71 | const cpuStart = process.cpuUsage() |
| 72 | const wallStart = process.hrtime.bigint() |
| 73 | const value = await f() |
| 74 | const wallNanos = Number(process.hrtime.bigint() - wallStart) |
| 75 | const cpu = process.cpuUsage(cpuStart) |
| 76 | return [value, { |
| 77 | cpuMicros: cpu.user + cpu.system, |
| 78 | systemMicros: cpu.system, |
| 79 | userMicros: cpu.user, |
| 80 | wallNanos |
| 81 | }] |
| 82 | } |
| 83 | |
| 84 | type MemorySample = { |
| 85 | readonly bytecodeBytes: number |
| 86 | readonly codeBytes: number |
| 87 | readonly externalSourceBytes: number |
| 88 | readonly heapBytes: number |
| 89 | readonly maxRssBytes: number |
| 90 | readonly rssBytes: number |
| 91 | } |
| 92 | |
| 93 | const memory = (): MemorySample => { |
| 94 | const usage = process.memoryUsage() |
| 95 | const code = v8.getHeapCodeStatistics() |
| 96 | return { |
| 97 | bytecodeBytes: code.bytecode_and_metadata_size, |
| 98 | codeBytes: code.code_and_metadata_size, |
| 99 | externalSourceBytes: code.external_script_source_size, |
| 100 | heapBytes: usage.heapUsed, |
| 101 | maxRssBytes: process.resourceUsage().maxRSS * 1024, |
| 102 | rssBytes: usage.rss |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | const delta = (after: MemorySample, before: MemorySample) => ({ |
| 107 | bytecodeBytes: after.bytecodeBytes - before.bytecodeBytes, |
| 108 | codeBytes: after.codeBytes - before.codeBytes, |
| 109 | externalSourceBytes: after.externalSourceBytes - before.externalSourceBytes, |
| 110 | heapBytes: after.heapBytes - before.heapBytes, |
| 111 | maxRssBytes: after.maxRssBytes - before.maxRssBytes, |
| 112 | rssBytes: after.rssBytes - before.rssBytes |
| 113 | }) |
| 114 | |
| 115 | const perSchema = (sample: ReturnType<typeof delta>) => ({ |
| 116 | bytecodeBytes: sample.bytecodeBytes / count, |
| 117 | codeBytes: sample.codeBytes / count, |
| 118 | externalSourceBytes: sample.externalSourceBytes / count, |
| 119 | heapBytes: sample.heapBytes / count, |
| 120 | maxRssBytes: sample.maxRssBytes / count, |
| 121 | rssBytes: sample.rssBytes / count |
| 122 | }) |
| 123 | |
| 124 | const loadEffectModule = (path: string) => |
| 125 | import(pathToFileURL(join(root, "packages/effect/src", `${path}.ts`)).href) |
| 126 | |
| 127 | const loadEffectSchemaModules = async () => ({ |
| 128 | Effect: await loadEffectModule("Effect"), |
| 129 | Schema: await loadEffectModule("Schema"), |
| 130 | SchemaAST: await loadEffectModule("SchemaAST") |
| 131 | }) |
| 132 | |
| 133 | type EffectSchemaModules = Awaited<ReturnType<typeof loadEffectSchemaModules>> |
| 134 | |
| 135 | const buildEffectCase = ({ Effect, Schema, SchemaAST }: EffectSchemaModules, index: number): BuiltCase => { |
| 136 | const suffix = String(index) |
| 137 | const name = `name${suffix}` |
| 138 | const age = `age${suffix}` |
| 139 | const active = `active${suffix}` |
| 140 | const person = () => Schema.Struct({ [name]: Schema.String, [age]: Schema.Number, [active]: Schema.Boolean }) |
| 141 | const value = { [name]: "Ada", [age]: 37, [active]: true } |
| 142 | switch (caseName) { |
| 143 | case "struct-decode": { |
| 144 | const schema = person() |
| 145 | return { schema, ast: schema.ast, input: value, expected: value } |
| 146 | } |
| 147 | case "struct-invalid": { |
| 148 | const schema = person() |
| 149 | return { schema, ast: schema.ast, input: { ...value, [age]: "bad" }, expected: true, invalid: true } |
| 150 | } |
| 151 | case "struct-is": { |
| 152 | const schema = person() |
| 153 | return { schema, ast: SchemaAST.toType(schema.ast), input: value, expected: true } |
| 154 | } |
| 155 | case "array-decode": { |
| 156 | const schema = Schema.Array(person()) |
| 157 | const input = Array.from({ length: 32 }, () => ({ ...value })) |
| 158 | return { schema, ast: schema.ast, input, expected: input } |
| 159 | } |
| 160 | case "union-decode": { |
| 161 | const tag = `tag${suffix}` |
| 162 | const schema = Schema.Union( |
| 163 | Array.from({ length: 8 }, (_, member) => |
| 164 | Schema.Struct({ [tag]: Schema.Literal(member), [`value${suffix}`]: Schema.Number })) |
| 165 | ) |
| 166 | const input = { [tag]: 7, [`value${suffix}`]: 1 } |
| 167 | return { schema, ast: schema.ast, input, expected: input } |
| 168 | } |
| 169 | case "transform-decode": { |
| 170 | const schema = Schema.Struct( |
| 171 | Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, Schema.NumberFromString])) |
| 172 | ) |
| 173 | const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) |
| 174 | const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) |
| 175 | return { schema, ast: schema.ast, input, expected } |
| 176 | } |
| 177 | case "default-make": { |
| 178 | const schema = Schema.Struct( |
| 179 | Object.fromEntries( |
| 180 | Array.from( |
| 181 | { length: 32 }, |
| 182 | (_, field) => [ |
| 183 | `v${suffix}_${field}`, |
| 184 | Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(field))) |
| 185 | ] |
| 186 | ) |
| 187 | ) |
| 188 | ) |
| 189 | const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) |
| 190 | return { schema, ast: SchemaAST.toType(schema.ast), input: {}, expected } |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | const loadEffect = async (jit: boolean) => { |
| 196 | const modules = await loadEffectSchemaModules() |
| 197 | const SchemaParser = await loadEffectModule("SchemaParser") |
| 198 | const enable = jit ? (await loadEffectModule("schema/SchemaJITCompiler")).enable : undefined |
| 199 | |
| 200 | const prepare = (built: ReadonlyArray<BuiltCase>): ReadonlyArray<Parser> => { |
| 201 | return built.map(({ ast, schema }) => { |
| 202 | if (enable !== undefined) enable(ast) |
| 203 | if (caseName === "struct-is") return SchemaParser.is(schema) |
| 204 | if (caseName === "default-make") return SchemaParser.make(schema) |
| 205 | return SchemaParser.decodeUnknownSync(schema) |
| 206 | }) |
| 207 | } |
| 208 | |
| 209 | return { build: (index: number) => buildEffectCase(modules, index), prepare } |
| 210 | } |
| 211 | |
| 212 | const loadZod = async (compiled: boolean) => { |
| 213 | const z = await import("zod/v4") |
| 214 | |
| 215 | const build = (index: number): BuiltCase => { |
| 216 | const suffix = String(index) |
| 217 | const name = `name${suffix}` |
| 218 | const age = `age${suffix}` |
| 219 | const active = `active${suffix}` |
| 220 | const person = () => z.object({ [name]: z.string(), [age]: z.number(), [active]: z.boolean() }) |
| 221 | const value = { [name]: "Ada", [age]: 37, [active]: true } |
| 222 | switch (caseName) { |
| 223 | case "struct-decode": |
| 224 | return { schema: person(), input: value, expected: value } |
| 225 | case "struct-invalid": |
| 226 | return { schema: person(), input: { ...value, [age]: "bad" }, expected: true, invalid: true } |
| 227 | case "struct-is": |
| 228 | return { schema: person(), input: value, expected: true } |
| 229 | case "array-decode": { |
| 230 | const input = Array.from({ length: 32 }, () => ({ ...value })) |
| 231 | return { schema: z.array(person()), input, expected: input } |
| 232 | } |
| 233 | case "union-decode": { |
| 234 | const tag = `tag${suffix}` |
| 235 | const schema = z.union( |
| 236 | Array.from({ length: 8 }, (_, member) => |
| 237 | z.object({ [tag]: z.literal(member), [`value${suffix}`]: z.number() })) as [ |
| 238 | ReturnType<typeof person>, |
| 239 | ReturnType<typeof person>, |
| 240 | ...Array<ReturnType<typeof person>> |
| 241 | ] |
| 242 | ) |
| 243 | const input = { [tag]: 7, [`value${suffix}`]: 1 } |
| 244 | return { schema, input, expected: input } |
| 245 | } |
| 246 | case "transform-decode": { |
| 247 | const schema = z.object( |
| 248 | Object.fromEntries( |
| 249 | Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, z.string().transform(Number)]) |
| 250 | ) |
| 251 | ) |
| 252 | const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) |
| 253 | const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) |
| 254 | return { schema, input, expected } |
| 255 | } |
| 256 | case "default-make": { |
| 257 | const schema = z.object( |
| 258 | Object.fromEntries( |
| 259 | Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, z.number().default(field)]) |
| 260 | ) |
| 261 | ) |
| 262 | const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) |
| 263 | return { schema, input: {}, expected } |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | const prepare = (built: ReadonlyArray<BuiltCase>): ReadonlyArray<Parser> => { |
| 269 | return built.map(({ schema }) => { |
| 270 | const target = compiled ? z.compile(schema, { strict: true }) : schema |
| 271 | if (caseName === "struct-is") { |
| 272 | return compiled |
| 273 | ? (input: unknown) => z.validate(target, input) |
| 274 | : (input: unknown) => z.validate(target, input, { jitless: true }) |
| 275 | } |
| 276 | return compiled |
| 277 | ? (input: unknown) => target.parse(input) |
| 278 | : (input: unknown) => target.parse(input, { jitless: true }) |
| 279 | }) |
| 280 | } |
| 281 | |
| 282 | return { build, prepare } |
| 283 | } |
| 284 | |
| 285 | const loadValibot = async () => { |
| 286 | const v = await import("valibot") |
| 287 | |
| 288 | const build = (index: number): BuiltCase => { |
| 289 | const suffix = String(index) |
| 290 | const name = `name${suffix}` |
| 291 | const age = `age${suffix}` |
| 292 | const active = `active${suffix}` |
| 293 | const person = () => v.object({ [name]: v.string(), [age]: v.number(), [active]: v.boolean() }) |
| 294 | const value = { [name]: "Ada", [age]: 37, [active]: true } |
| 295 | switch (caseName) { |
| 296 | case "struct-decode": |
| 297 | return { schema: person(), input: value, expected: value } |
| 298 | case "struct-invalid": |
| 299 | return { schema: person(), input: { ...value, [age]: "bad" }, expected: true, invalid: true } |
| 300 | case "struct-is": |
| 301 | return { schema: person(), input: value, expected: true } |
| 302 | case "array-decode": { |
| 303 | const input = Array.from({ length: 32 }, () => ({ ...value })) |
| 304 | return { schema: v.array(person()), input, expected: input } |
| 305 | } |
| 306 | case "union-decode": { |
| 307 | const tag = `tag${suffix}` |
| 308 | const schema = v.variant( |
| 309 | tag, |
| 310 | Array.from({ length: 8 }, (_, member) => |
| 311 | v.object({ [tag]: v.literal(member), [`value${suffix}`]: v.number() })) |
| 312 | ) |
| 313 | const input = { [tag]: 7, [`value${suffix}`]: 1 } |
| 314 | return { schema, input, expected: input } |
| 315 | } |
| 316 | case "transform-decode": { |
| 317 | const schema = v.object( |
| 318 | Object.fromEntries( |
| 319 | Array.from({ length: 32 }, (_, field) => [ |
| 320 | `v${suffix}_${field}`, |
| 321 | v.pipe(v.string(), v.transform(Number)) |
| 322 | ]) |
| 323 | ) |
| 324 | ) |
| 325 | const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) |
| 326 | const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) |
| 327 | return { schema, input, expected } |
| 328 | } |
| 329 | case "default-make": { |
| 330 | const schema = v.object( |
| 331 | Object.fromEntries( |
| 332 | Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, v.optional(v.number(), field)]) |
| 333 | ) |
| 334 | ) |
| 335 | const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) |
| 336 | return { schema, input: {}, expected } |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | const prepare = (built: ReadonlyArray<BuiltCase>): ReadonlyArray<Parser> => { |
| 342 | return built.map(({ schema }) => |
| 343 | caseName === "struct-is" |
| 344 | ? (input: unknown) => v.is(schema, input) |
| 345 | : (input: unknown) => v.parse(schema, input)) |
| 346 | } |
| 347 | |
| 348 | return { build, prepare } |
| 349 | } |
| 350 | |
| 351 | const runParser = (parser: Parser, built: BuiltCase): unknown => { |
| 352 | if (!built.invalid) return parser(built.input) |
| 353 | try { |
| 354 | parser(built.input) |
| 355 | return false |
| 356 | } catch { |
| 357 | return true |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | const validate = (actual: unknown, built: BuiltCase) => assert.deepEqual(actual, built.expected) |
| 362 | |
| 363 | if (command === "generate") { |
| 364 | if (output === undefined) throw new Error("output is required for generate") |
| 365 | const [modules, moduleCpu] = await measureCpu(async () => { |
| 366 | return { |
| 367 | ...await loadEffectSchemaModules(), |
| 368 | AOT: await loadEffectModule("schema/SchemaAOTCompiler") |
| 369 | } |
| 370 | }) |
| 371 | const [built, schemaCpu] = await measureCpu(() => |
| 372 | Array.from({ length: count }, (_, index) => buildEffectCase(modules, index).ast) |
| 373 | ) |
| 374 | const operation = caseName === "struct-is" ? "is" : caseName === "default-make" ? "make" : "decode" |
| 375 | const [source, generateCpu] = await measureCpu(() => |
| 376 | modules.AOT.compile(built.map((ast) => ({ ast, operations: [operation] })))) |
| 377 | writeFileSync(output, source) |
| 378 | process.stdout.write(JSON.stringify({ |
| 379 | generateCpu, |
| 380 | heapBytes: process.memoryUsage().heapUsed, |
| 381 | maxRssBytes: process.resourceUsage().maxRSS * 1024, |
| 382 | moduleCpu, |
| 383 | schemaCpu, |
| 384 | sourceBytes: Buffer.byteLength(source) |
| 385 | })) |
| 386 | } else { |
| 387 | forceGc() |
| 388 | const beforeModule = memory() |
| 389 | const [library, moduleCpu] = await measureCpu(() => { |
| 390 | switch (implementation) { |
| 391 | case "effect-interpreted": |
| 392 | return loadEffect(false) |
| 393 | case "effect-jit": |
| 394 | case "effect-aot": |
| 395 | return loadEffect(implementation === "effect-jit") |
| 396 | case "zod-jitless": |
| 397 | case "zod-compiled": |
| 398 | return loadZod(implementation === "zod-compiled") |
| 399 | case "valibot": |
| 400 | return loadValibot() |
| 401 | } |
| 402 | }) |
| 403 | forceGc() |
| 404 | const afterModule = memory() |
| 405 | |
| 406 | const [built, schemaCpu] = await measureCpu(() => Array.from({ length: count }, (_, index) => library.build(index))) |
| 407 | forceGc() |
| 408 | const afterSchemas = memory() |
| 409 | |
| 410 | let aotBuild: unknown |
| 411 | let aotModuleCpu: CpuSample | undefined |
| 412 | let directory: string | undefined |
| 413 | let parsers: ReadonlyArray<Parser> |
| 414 | let prepareCpu: CpuSample |
| 415 | try { |
| 416 | if (implementation === "effect-aot") { |
| 417 | directory = mkdtempSync(join(root, "packages/effect/.compiler-resources-")) |
| 418 | const generated = join(directory, "generated.mjs") |
| 419 | aotBuild = JSON.parse(execFileSync( |
| 420 | process.execPath, |
| 421 | ["--expose-gc", fileURLToPath(import.meta.url), "generate", root, implementation, caseName, String(count), generated], |
| 422 | { encoding: "utf8" } |
| 423 | )) |
| 424 | const [installed, measuredModuleCpu] = await measureCpu(async () => { |
| 425 | const generatedModule = await import(pathToFileURL(generated).href) |
| 426 | generatedModule.install(built.map((value) => value.ast)) |
| 427 | }) |
| 428 | void installed |
| 429 | aotModuleCpu = measuredModuleCpu |
| 430 | } |
| 431 | const prepared = await measureCpu(() => library.prepare(built)) |
| 432 | parsers = prepared[0] |
| 433 | prepareCpu = prepared[1] |
| 434 | forceGc() |
| 435 | const afterPrepare = memory() |
| 436 | |
| 437 | const [results, firstCallCpu] = await measureCpu(() => parsers.map((parser, index) => runParser(parser, built[index]))) |
| 438 | results.forEach((actual, index) => validate(actual, built[index])) |
| 439 | results.length = 0 |
| 440 | forceGc() |
| 441 | const afterFirstCall = memory() |
| 442 | |
| 443 | for (const value of built) { |
| 444 | value.input = undefined |
| 445 | value.expected = undefined |
| 446 | } |
| 447 | forceGc() |
| 448 | const afterRelease = memory() |
| 449 | |
| 450 | assert.equal(built.length, count) |
| 451 | assert.equal(parsers.length, count) |
| 452 | process.stdout.write(JSON.stringify({ |
| 453 | aotBuild, |
| 454 | case: caseName, |
| 455 | count, |
| 456 | cpu: { |
| 457 | aotModule: aotModuleCpu, |
| 458 | firstCall: firstCallCpu, |
| 459 | module: moduleCpu, |
| 460 | prepare: prepareCpu, |
| 461 | schema: schemaCpu |
| 462 | }, |
| 463 | implementation, |
| 464 | memory: { |
| 465 | compilerPerSchema: perSchema(delta(afterFirstCall, afterSchemas)), |
| 466 | firstCallPerSchema: perSchema(delta(afterFirstCall, afterPrepare)), |
| 467 | module: delta(afterModule, beforeModule), |
| 468 | preparePerSchema: perSchema(delta(afterPrepare, afterSchemas)), |
| 469 | retainedLibraryPerSchema: perSchema(delta(afterRelease, afterModule)), |
| 470 | schemaPerSchema: perSchema(delta(afterSchemas, afterModule)), |
| 471 | totalWithFixturePerSchema: perSchema(delta(afterFirstCall, afterModule)) |
| 472 | } |
| 473 | })) |
| 474 | } finally { |
| 475 | if (directory !== undefined) { |
| 476 | rmSync(directory, { recursive: true, force: true }) |
| 477 | } |
| 478 | } |
| 479 | } |