Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions builder/bench.cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")}`)
Expand Down Expand Up @@ -125,10 +146,15 @@ async function main(): Promise<void> {
["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}`)
Expand All @@ -138,6 +164,9 @@ async function main(): Promise<void> {
// 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: []})
Expand All @@ -148,7 +177,8 @@ async function main(): Promise<void> {
}

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)

Expand Down
7 changes: 7 additions & 0 deletions builder/node-url.shim.ts
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions builder/rollup-bench.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
],
Expand Down
1 change: 1 addition & 0 deletions builder/rollup-browser-test.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
Expand Down
68 changes: 60 additions & 8 deletions test/utils/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
data: T;
expect: string;
Expand All @@ -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<void> {
}

hash(_data: string | Uint8Array | ArrayBufferView): string {
throw new Error("hash() not supported")
}
Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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<void> {
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
*/
Expand Down