diff --git a/builder/bench.cli.ts b/builder/bench.cli.ts index 54d2b04..a772dae 100644 --- a/builder/bench.cli.ts +++ b/builder/bench.cli.ts @@ -18,8 +18,29 @@ const param = (key: string, def: string): string => { const DURATION = +param("DURATION", "500") const SETS = +param("SETS", "10") const TARGET = param("TARGET", "") +// Module paths given on the command line. Naming files is a more +// specific request than TARGET, so they replace it rather than filter +// it, turning the run into a comparison of this package's own builds. +const PATHS = ("object" === typeof location) ? [] : process.argv.slice(2) const SHUFFLE_SEED = 0x53484132 // ASCII "SHA2" +// Every other argument is a module to import, so a leading dash can only +// be a mistyped option — worth refusing rather than trying to load. +const USAGE = [ + "usage: bench.cli.ts [module.mjs ...]", + " DURATION=500 milliseconds each cell is calibrated towards", + " SETS=10 measured sets per cell", + " TARGET= comma-separated name substrings, unused when modules are named", +].join("\n") + +if (PATHS.includes("-h") || PATHS.includes("--help")) { + console.log(USAGE) + process.exit(0) +} + +const dashed = PATHS.find(path => path.startsWith("-")) +if (dashed) throw new Error(`unknown option: ${dashed}, try -h`) + // Garbage in, immediate stop: the caller chose the values. if (!(Number.isFinite(DURATION) && DURATION > 0 && Number.isInteger(SETS) && SETS > 0)) { throw new Error(`invalid DURATION=${param("DURATION", "")} SETS=${param("SETS", "")}`) @@ -125,10 +146,15 @@ async function main(): Promise { ["crypto.subtle.digest()", new A.SubtleCrypto()], ] - // TARGET picks modules by comma-separated substrings; - // the default (empty) measures everything. - const wants = TARGET.split(",").map(t => t.trim()).filter(Boolean) - const picked = ADAPTERS.filter(([name]) => !wants.length || wants.some(t => name.includes(t))) + // Named paths replace the comparison outright rather than filtering + // it, so every cell then measures a build of this package and only + // the code differs between them. + const named = PATHS.map((path): [string, A.Adapter] => [path, A.dynamicModule(path)]) + + // TARGET picks modules by comma-separated substrings; the default + // (empty) measures everything, and named paths skip it entirely. + const wants = named.length ? [] : TARGET.split(",").map(t => t.trim()).filter(Boolean) + const picked = named.length ? named : ADAPTERS.filter(([name]) => !wants.length || wants.some(t => name.includes(t))) if (wants.length && picked.length === 0) { throw new Error(`TARGET matched nothing: ${TARGET}`) @@ -138,6 +164,9 @@ async function main(): Promise { // the sync implementation when it has one, otherwise the async one // in the same rotation, and a cell-less adapter is simply skipped. const cells: Cell[] = [] + // Loading a module is a cost of the import, not of a digest, so an + // adapter that needs one gets it out of the way before any closure. + for (const [, adapter] of picked) await adapter.setup() for (const [name, adapter] of picked) { const s = adapter.makeStringBench(stringPairs) if (s) cells.push({name, input: "string", impl: "sync", fn: s, opsPerRepeat: stringPairs.length, repeat: 0, times: []}) @@ -148,7 +177,8 @@ async function main(): Promise { } const env = ("object" === typeof process && process.version) ? `node ${process.version}` : navigator.userAgent - out(`# ${env} DURATION=${DURATION} SETS=${SETS} TARGET=${TARGET || "(all)"}`) + const scope = named.length ? `FILES=${named.length}` : `TARGET=${TARGET || "(all)"}` + out(`# ${env} DURATION=${DURATION} SETS=${SETS} ${scope}`) const random = mulberry32(SHUFFLE_SEED) diff --git a/builder/node-url.shim.ts b/builder/node-url.shim.ts new file mode 100644 index 0000000..9986751 --- /dev/null +++ b/builder/node-url.shim.ts @@ -0,0 +1,7 @@ +// Browser stand-in for `node:url`, aliased in by the rollup configs. +// Only the CLI-only dynamic adapter reaches for it, so nothing here is +// ever meant to run; it exists to keep the browser bundle self-contained. + +export const pathToFileURL = (_path: string): never => { + throw new Error("pathToFileURL() not supported") +} diff --git a/builder/rollup-bench.config.ts b/builder/rollup-bench.config.ts index ccf6901..6127f5e 100644 --- a/builder/rollup-bench.config.ts +++ b/builder/rollup-bench.config.ts @@ -102,6 +102,7 @@ const browserConfig: RollupOptions = { entries: [ {find: "node:assert", replacement: here("./node-assert.shim.ts")}, {find: "node:crypto", replacement: here("./node-crypto.shim.ts")}, + {find: "node:url", replacement: here("./node-url.shim.ts")}, {find: "sha256-uint8array", replacement: here("../browser/import.js")}, {find: /^(\.\.\/)+lib\/sha256-uint8array\.ts$/, replacement: here("../browser/import.js")}, ], diff --git a/builder/rollup-browser-test.config.ts b/builder/rollup-browser-test.config.ts index 0a378aa..0713a8d 100644 --- a/builder/rollup-browser-test.config.ts +++ b/builder/rollup-browser-test.config.ts @@ -66,6 +66,7 @@ const rollupConfig: RollupOptions = { {find: "node:test", replacement: here("./node-test.shim.ts")}, {find: "node:assert", replacement: here("./node-assert.shim.ts")}, {find: "node:crypto", replacement: here("./node-crypto.shim.ts")}, + {find: "node:url", replacement: here("./node-url.shim.ts")}, {find: "sha256-uint8array", replacement: here("../browser/import.js")}, // The suites spell the entry as a relative path; same shim either way. {find: /^(\.\.\/)+lib\/sha256-uint8array\.ts$/, replacement: here("../browser/import.js")}, diff --git a/test/utils/adapters.ts b/test/utils/adapters.ts index 411e03b..552679a 100644 --- a/test/utils/adapters.ts +++ b/test/utils/adapters.ts @@ -13,10 +13,26 @@ import jsSha from "jssha/dist/sha256" import forgeSha from "node-forge/lib/sha256.js" import {strict as assert} from "node:assert" import * as nodeCrypto from "node:crypto" +import {pathToFileURL} from "node:url" import shaJs from "sha.js/sha256.js" import {createHash as ownCreateHash} from "../../lib/sha256-uint8array.ts" import {arrayToHex} from "./utils.ts" +// update() is published one input shape at a time, so a caller holding +// a union has to narrow it back down before every call — a test the +// implementation then repeats internally. Naming the union it already +// takes at run time lets the adapters hand theirs straight over: method +// parameters are bivariant, so the published overloads satisfy this on +// their own and the package keeps publishing exactly what it always has. +interface IHash { + update(data: string | Uint8Array | ArrayBufferView): IHash; + digest(encoding: string): string; +} + +type ICreateHash = (algorithm?: string) => IHash + +const hasCreateHash = (v: any): v is {createHash: ICreateHash} => ("function" === typeof v?.createHash) + export interface BenchPair { data: T; expect: string; @@ -35,6 +51,12 @@ export abstract class Adapter { declare noAsync?: boolean; declare noBench?: boolean; + // An adapter that has to load something before its first hash() + // overrides this. The runner awaits it once, before any measuring, + // so a module load never lands inside a timed window. + async setup(): Promise { + } + hash(_data: string | Uint8Array | ArrayBufferView): string { throw new Error("hash() not supported") } @@ -81,16 +103,10 @@ const hasSubtle = ("undefined" !== typeof crypto) && crypto.subtle && ("function */ export class SHA256Uint8Array extends Adapter { - private createHash = ownCreateHash; + private createHash: ICreateHash = ownCreateHash; hash(data: string | Uint8Array | ArrayBufferView): string { - const hash = this.createHash() - if ("string" === typeof data) { - hash.update(data) // same call either way: update() is overloaded, not union-typed - } else { - hash.update(data) - } - return hash.digest("hex") + return this.createHash().update(data).digest("hex") } } @@ -249,6 +265,42 @@ export class JsSha256 extends Adapter { } } +/** + * A module named on the command line rather than a package this file + * knows about. It exists to compare builds of this package with each + * other — a published dist/, a branch build, the working tree — where + * every cell runs the same implementation and only the code differs, + * so the numbers answer "did this change help?" directly. + * + * Each path gets a class of its own, for the same reason the benchmark + * closures above are built per adapter: one shared method would send + * every module's createHash, and the differently shaped hashes it + * returns, through the same call sites, so the compared builds would + * blend into each other's numbers instead of standing apart. + * + * Note: it expects the createHash() entry point this package documents, + * so it is not a general adapter for arbitrary modules. + */ + +export const dynamicModule = (path: string): Adapter => new class extends Adapter { + private loaded: ICreateHash | null = null; + + override async setup(): Promise { + let module = await import(pathToFileURL(path).href) + if (!hasCreateHash(module)) module = module?.default + if (!hasCreateHash(module)) { + throw new Error(`${path}: no createHash export`) + } + this.loaded = module.createHash + } + + hash(data: string | Uint8Array | ArrayBufferView): string { + const createHash = this.loaded + if (!createHash) throw new Error(`${path}: setup() not awaited`) + return createHash().update(data).digest("hex") + } +}() + /** * https://developer.mozilla.org/docs/Web/API/SubtleCrypto */