Skip to content

perf: hoist per-call regular expressions in hot validation paths - #569

Merged
gakonst merged 1 commit into
mainfrom
perf/hot-path-cleanups
Nov 3, 2026
Merged

gakonst merged 1 commit into
mainfrom
perf/hot-path-cleanups

Conversation

@gakonst

@gakonst gakonst commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

A regular expression literal inside a function body creates a new RegExp object every time it is evaluated. This PR moves the remaining per-call literals on validation paths to module scope, the same change internal/abiItem.ts and internal/abiParameters.ts already made for their regexes. All of the moved expressions are non-global and non-sticky. test, match and replace never write lastIndex on them, so sharing one instance carries no state between calls.

Changes

  • Hex.assert (strict mode, also used by Hash.validate, Frame, FrameSignature, TxEnvelopeEip8141): hoisted the hex character regex.
  • Value.format / Value.from: hoisted the decimal-number and trailing-zeros regexes.
  • TypedData: hoisted the array-suffix regex (findTypeDependencies, validateField) and the struct-name regexes (validateReference).
  • Siwe.isUri (also used by Siwe.createMessage for uri and each resource): hoisted its 9 regexes, including the ones in isAuthority and splitUri. Siwe's exported regexes are unchanged, and the new constants are not exported.
  • internal/frameNonce.fromRpc: hoisted the canonical quantity regex (evaluated once per nonce key).
  • ox/zod: hoisted the refine regexes in Hex (every hex field of every decoded RPC object), Address, the FrameRequest quantity, and the Solidity type schemas and isType.
  • internal/abiItem.isArgOfType: reads the integer bit size from the integerTypeRegex match it already runs, instead of a second replace(/^u?int/, '') regex.
  • Added isArgOfType cases for the bare uint/int bounds, which exercise the default-256 branch of that change.
  • Added a patch changeset.

No public API or type changes.

Measurements

  • Microbenchmark (Node 22, CPU time, min of 7 runs, 14M calls per case): literal vs hoisted was 28.8–30.4 vs 26.3–27.7 ns for the hex regex, 40.5–41.7 vs 37.8–38.2 ns for replace(/(0+)$/), and 42.1–44.2 vs 38.6–39.5 ns for replace(/(\[[0-9]*\])+$/). GC scavenges during each run dropped from 95–766 to 0–21. So each call saves one allocation and about 3 ns.
  • End to end (Hash.validate, Value.format, TypedData.encodeType/validate, Siwe.createMessage, and ox/zod Hex/Address arrays of 64): base and patched results were within noise of each other. The machine running them had a load average of 80–115, so the change is a per-call allocation removal, not a measurable speedup of any single function.

Verification

  • pnpm check:types (tsc -b) passed.
  • vp check (format and type-aware lint) passed on all changed files.
  • SKIP_GLOBAL_SETUP=1 pnpm test --project core on the touched modules passed: Hex, Value, TypedData, Siwe, AbiItem, Hash, Frame, FrameRpc, FrameRequest, FrameSignature and all of src/zod. That is 62 files and 922 tests.
  • Without Anvil, the network-backed cases in AbiEvent and TransactionRequest failed to connect (ECONNREFUSED 127.0.0.1:8545). The tmp_frames project needs its frames RPC node. CI runs both with those services.
  • The pre-commit vp check --fix over the whole repo did not finish within its time limit on the loaded machine, so I ran the same check on the changed files and committed with the hook skipped.

Considered and not changed

  • AbiItem.fromAbi with a selector hashes the signature of every unprepared ABI item on each call. Caching hashes per ABI item object (for example in a WeakMap) would be a much larger win for decoding against large ABIs. It would also add a new global cache, which goes against the Caches/Engine registry rules, and it would return stale results if an ABI item is mutated. That is left for maintainers to decide.
  • virtualMasterPool.resolveNode: the two dynamic imports run once and one is a Node builtin, so overlapping them saves nothing.
  • internal/ens.ts (a /gm regex) and tempo/Tick.ts: these are not hot, so I left them as they are.

Regex literals inside a function body allocate a fresh RegExp object on
every evaluation. Move the ones on hot validation paths to module scope:
`Hex.assert` (strict), `Value.format`/`Value.from`, `TypedData`
type dependency and field validation, `Siwe.isUri`, frame nonce RPC
decoding, and the `ox/zod` Hex, Address, quantity and Solidity type
schemas. All of them are non-global and non-sticky, so sharing them
holds no `lastIndex` state.

`isArgOfType` also reads the integer bit size from the
`integerTypeRegex` capture instead of running a second `replace`
regex.
@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ox Error Error Nov 3, 2026 2:00am UTC

Request Review

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Published ox@0.0.0-pr-569-0b24e69 under the pr-569 dist-tag, which follows this pull request until it closes.

pnpm add ox@pr-569
pnpm add ox@0.0.0-pr-569-0b24e69

@gakonst
gakonst merged commit 7a564af into main Nov 3, 2026
15 of 16 checks passed
@gakonst
gakonst deleted the perf/hot-path-cleanups branch November 3, 2026 02:04
@github-actions github-actions Bot mentioned this pull request Nov 3, 2026
gakonst added a commit that referenced this pull request Nov 3, 2026
Backports #569 and #570 from main.

#569: Regex literals inside a function body allocate a fresh RegExp on
every evaluation. Move the ones on hot validation paths to module scope:
`Hex.assert` (strict), `Value.format`/`Value.from`, `TypedData` type
dependency and field validation, `Siwe.isUri`, frame nonce RPC
decoding, and the integer and fixed-bytes checks in ABI overload
resolution. All of them are non-global and non-sticky, so sharing them
holds no `lastIndex` state. `isArgOfType` also reads the integer bit
size from the integer type regex capture instead of running a second
`replace` regex. 0.x has no `ox/zod` entrypoint, and keeps its own
`Value.from` decimal pattern, so behavior is unchanged.

#570: `AbiItem.getSignatureHash` keccak-hashed the signature on every
call for ABI items without a precomputed `hash`. Memoize the hash in a
bounded (8192-entry) LRU map keyed by the normalized signature string,
next to the checksum cache in a new `internal/caches.ts`, so
`Caches.clear()` resets it. Only `Caches.checksum` stays public. 0.x
has no `Engine`, so keccak256 cannot be swapped and the engine test is
not ported.

This branch had an error being deployed

1 failed deployment
Preview — 0b24e69d Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant