Skip to content

⚡ Speed up MapArbitrary generate hot path#7023

Open
dubzzz wants to merge 1 commit into
mainfrom
perf/map-arbitrary
Open

⚡ Speed up MapArbitrary generate hot path#7023
dubzzz wants to merge 1 commit into
mainfrom
perf/map-arbitrary

Conversation

@dubzzz
Copy link
Copy Markdown
Owner

@dubzzz dubzzz commented May 23, 2026

Description

AI-agent disclosure: this PR was authored by an automated agent (Claude Code, Opus 4.7) and has not been line-by-line reviewed by a human before submission.

Arbitrary.prototype.map(mapper, unmapper) (backed by MapArbitrary) is one of the most heavily-used composition primitives in fast-check. fc.string, fc.json, fc.uuid, fc.emailAddress, fc.webAuthority, fc.mixedCase, plus most user-built arbitraries derived via .map(...) all funnel through MapArbitrary.generate. Anything we shave there is paid back many times over by everything downstream.

What changes

  • Drop the [mappedValue, sourceValue] tuple in mapperWithCloneIfNeeded. The helper used to return a 2-element array so that the cloneable wiring could re-run mapper(sourceValue) on subsequent reads. The only call site (valueMapper) already holds the source Value<T> (the variable v) and can produce the source value itself, so the tuple is pure per-call allocation pressure. The helper now returns U directly.
  • Inline the non-cloneable fast path into generate(). Most mapper outputs are primitives or plain objects with no cloneMethod symbol; the cloneable wiring is dead code for them. We branch once on mappedValue (and on the source hasToBeCloned) and skip the helper-call frame plus the cloneable branches entirely. The general path is preserved for cloneable values and for shrink-stream consumers via a small bindValueMapper helper.

Background reading

Observable behaviour

No public API change. Same seed → same generated value, same shrink ordering. canShrinkWithoutContext returns the same booleans (the unmapper path is untouched).

Tests pass unchanged:

  • test/unit/check/arbitrary/definition/Arbitrary.utest.spec.ts
  • test/unit/check/arbitrary/definition/Arbitrary.itest.spec.ts

Total: 29/29 (the broader test/unit/check/ sweep — 268/268 — also passes).

Numbers

Median of 5–7 runs × 2 s, paired against main. The wins are largest for trivial mappers (where the per-call overhead dominates the mapper cost) and steadily shrink as the mapper itself becomes more expensive — exactly the expected shape:

Suite Δ
integer().map(x => x + 1) +40–63%
integer().map(String) +15–25%
integer().map(x => x + 1, x => x - 1) (with unmapper) +35–52%
nat().map(x => x*2).map(x => x+1) (chained) +3–11%
array(integer()).map(arr => arr.join(',')) shrink first 10 +9–18%

Object-out mappers (tuple(int,int).map(([a,b]) => ({a,b}))) and large-array string mappers come out as flat-to-mildly-positive within the run-to-run noise floor (≈ ±5%), with no observed regression.

Re-runnable benchmark:

// bench-map.mjs
import { performance } from 'node:perf_hooks';
import { xorshift128plus } from 'pure-rand/generator/xorshift128plus';
import { Random, integer, nat, array } from 'fast-check';

function bench(name, fn, ms = 2000) {
  for (let i = 0; i < 10_000; ++i) fn();
  const end = performance.now() + ms;
  let n = 0;
  while (performance.now() < end) {
    for (let i = 0; i < 256; ++i) fn();
    n += 256;
  }
  const elapsed = (performance.now() - (end - ms)) / 1000;
  console.log(`${name}: ${(n / elapsed / 1e6).toFixed(2)} Mop/s`);
}

const rng = new Random(xorshift128plus(42));
const mInc     = integer().map((x) => x + 1);
const mString  = integer().map(String);
const mUn      = integer().map((x) => x + 1, (x) => /** @type {number} */ (x) - 1);
const mChain   = nat().map((x) => x * 2).map((x) => x + 1);
const mJoinArr = array(integer()).map((a) => a.join(','));

bench('int.map(x=>x+1)         ', () => mInc.generate(rng, undefined));
bench('int.map(String)         ', () => mString.generate(rng, undefined));
bench('int.map(+1, -1)         ', () => mUn.generate(rng, undefined));
bench('nat.map.map(chained)    ', () => mChain.generate(rng, undefined));
bench('array(int).map(join)    ', () => mJoinArr.generate(rng, undefined));

Ideas considered but dropped

The agent also tried:

  • reordering canShrinkWithoutContext cheap tests,
  • simplifying isSafeContext,
  • fusing .map(f).map(g) at construction,
  • hoisting bindValueMapper,

all of which were either <3% wins, not on the hot generate path, or risked behaviour change. They were reverted before commit.

Why this is patch-level

No API change. No behaviour change for a given seed. One file touched (Arbitrary.ts, only the MapArbitrary class section). The existing 29 tests already pin the contract this PR preserves.

Checklist

Don't delete this checklist and make sure you do the following before opening the PR

  • I have a full understanding of every line in this PR — whether the code was hand-written, AI-generated, copied from external sources or produced by any other tool
  • I flagged the impact of my change (minor / patch / major) either by running pnpm run bump or by following the instructions from the changeset bot
  • I kept this PR focused on a single concern and did not bundle unrelated changes
  • I followed the gitmoji specification for the name of the PR, including the package scope (e.g. 🐛(vitest) Something...) when the change targets a package other than fast-check
  • I added relevant tests and they would have failed without my PR (when applicable)

Generated by Claude Code

Two focused changes on `MapArbitrary.generate`:

- Drop the `[mappedValue, sourceValue]` tuple previously created by
  `mapperWithCloneIfNeeded`. The only consumer (`valueMapper`) already
  has access to `v` for the source value, so the tuple is pure
  per-call allocation pressure and is removed. `mapperWithCloneIfNeeded`
  now returns `U` directly.
- Inline the non-cloneable fast path into `generate()`. Most mapper
  outputs are primitive (string, number, plain object) and have no
  `cloneMethod` symbol; we no longer go through the helper-call frame
  or the cloneable-wiring branches in that case. The general path is
  preserved for cloneable values and for shrink-stream consumers via a
  small `bindValueMapper` helper.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 23, 2026

⚠️ No Changeset found

Latest commit: 48c7d98

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 23, 2026

@fast-check/ava

npm i https://pkg.pr.new/@fast-check/ava@7023

fast-check

npm i https://pkg.pr.new/fast-check@7023

@fast-check/jest

npm i https://pkg.pr.new/@fast-check/jest@7023

@fast-check/packaged

npm i https://pkg.pr.new/@fast-check/packaged@7023

@fast-check/poisoning

npm i https://pkg.pr.new/@fast-check/poisoning@7023

@fast-check/vitest

npm i https://pkg.pr.new/@fast-check/vitest@7023

@fast-check/worker

npm i https://pkg.pr.new/@fast-check/worker@7023

commit: 48c7d98

@codecov
Copy link
Copy Markdown

codecov Bot commented May 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.83%. Comparing base (a7a73f1) to head (48c7d98).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #7023   +/-   ##
=======================================
  Coverage   94.82%   94.83%           
=======================================
  Files         212      212           
  Lines        5876     5885    +9     
  Branches     1541     1543    +2     
=======================================
+ Hits         5572     5581    +9     
  Misses        296      296           
  Partials        8        8           
Flag Coverage Δ
tests 94.83% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

2 participants