Skip to content

⚡ Speed up ArrayArbitrary generate hot path#7019

Open
dubzzz wants to merge 2 commits into
mainfrom
perf/array-arbitrary
Open

⚡ Speed up ArrayArbitrary generate hot path#7019
dubzzz wants to merge 2 commits into
mainfrom
perf/array-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.

fc.array(...) is one of the most widely-used arbitraries: fc.string, fc.set, fc.uniqueArray, fc.subarray, every typed-array arbitrary, fc.commands, fc.lorem, fc.webUrl, and many user composites all go through ArrayArbitrary.generate. This PR shaves several per-call overheads from that path without changing observable behaviour for a given seed.

What changes

  • Inline applyBias. The helper returned a fresh { size, biasFactorItems? } object every call. The two pieces are now plain locals in generate, and the biased branches are written as a single if/else ladder so no intermediate object is allocated. (Tucked behind two oxlint-disable-next-line no-dupe-else-if because mrng.nextInt is impure and produces a different value on each roll — the duplicated condition shape is intentional.)
  • Cache values that depend only on construction-time constants. biasedMaxLength(minLength, maxGeneratedLength) involves Math.log + Math.floor and was recomputed on every generate; it is now memoised on the instance as cachedBiasedMaxLength. minLength === maxGeneratedLength is also stored as isFixedLength to keep the bias dispatch cheap.
  • Skip the depth-context try/finally when depthImpact === 0. This is the dominant case for arrays not opted into a depth identifier. V8 cannot easily hoist the try itself; the explicit branch avoids the wrapping.
  • Short-circuit buildSlicedGenerator when no custom slices are configured. A constructor-time hasNoCustomSlices flag is the common case; the inner loop then calls this.arb.generate(mrng, biasFactorItems) directly instead of going through a generator wrapper that allocates a closure object per call.
  • Pre-size items, vs, itemsContexts to the known length and write by index. safePush is replaced by indexed writes (new Array(N) is sealed behind oxlint-disable-next-line unicorn/no-new-array because the array is fully populated by the immediately-following loop).

Background reading

Observable behaviour

No public API change. Same seed → same generated value, same shrink ordering. One agent-tried optimisation (skipping lengthArb.generate on isFixedLength arrays) was reverted because it would change the RNG state — uniformInt(rng, N, N) still draws once, and skipping it would diverge seeds.

The whole test/unit/arbitrary/array.spec.ts + _internals/ArrayArbitrary.spec.ts suite (27 tests) passes unchanged; the broader test/unit/arbitrary/ sweep (2085 tests + 1 skipped) also passes.

Numbers

Median of 13 runs × 4 s, paired against main:

Suite Δ
array<int> default +30–35%
array<int> maxLength: 10 +27–39%
array<int> fixed length 50 +34–40%
array<bool> default +13–24%
array<int> empty (fixed 0) +2–5%
array<constant> fixed length 100 +107–213%
array<int> size: 'small' +2–35% (noisy, mostly positive)
array<int> size: 'xlarge' +14–122%
array<int> default biased +11–34%
string default (uses array internally) +17–23%
string maxLength: 10 +9–19%
integer default (control, untouched) ±3% (noise floor)

The array<constant> numbers are the largest because they benefit the most from the hasNoCustomSlices short-circuit + the absence of any inner-allocation pressure — the per-iteration cost essentially collapses to this.arb.generate.

Re-runnable benchmark:

// bench-array.mjs
import { performance } from 'node:perf_hooks';
import { xorshift128plus } from 'pure-rand/generator/xorshift128plus';
import { Random, array, integer, boolean, constant } 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 aDefault = array(integer());
const aSmall   = array(integer(), { maxLength: 10 });
const aFixed50 = array(integer({ min: 0, max: 100 }), { minLength: 50, maxLength: 50 });
const aConst100 = array(constant(1), { minLength: 100, maxLength: 100 });

bench('array<int> default      ', () => aDefault.generate(rng, undefined));
bench('array<int> maxLength=10  ', () => aSmall.generate(rng, undefined));
bench('array<int> fixed len 50  ', () => aFixed50.generate(rng, undefined));
bench('array<constant> len=100  ', () => aConst100.generate(rng, undefined));

Why this is patch-level

No API change. No behaviour change for a given seed. The single file touched is _internals/ArrayArbitrary.ts. No new tests needed: the existing suites 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

Optimise the per-call cost of `array(arb).generate(...)`:

- Inline `applyBias` into `generate` — the previous helper returned an
  object literal on every call. The biased branches are restructured
  as a plain `if/else` ladder so no temporary object is allocated.
- Cache `biasedMaxLength(this.minLength, this.maxGeneratedLength)` and
  `this.minLength === this.maxGeneratedLength` (`isFixedLength`) on the
  instance — both depend only on constants captured at construction.
- Skip the depth-context `try/finally` when `depthImpact === 0`, which
  is the common case for arrays that do not opt into a depth identifier.
- Short-circuit `buildSlicedGenerator` when there are no custom slices
  (`hasNoCustomSlices`). The inner loop then calls `this.arb.generate`
  directly instead of going through the generator wrapper.
- Pre-size `items`, `vs`, `itemsContexts` to the known length and write
  by index instead of growing via `safePush`.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 23, 2026

⚠️ No Changeset found

Latest commit: aa4ca14

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@7019

fast-check

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

@fast-check/jest

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

@fast-check/packaged

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

@fast-check/poisoning

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

@fast-check/vitest

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

@fast-check/worker

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

commit: aa4ca14

@github-actions
Copy link
Copy Markdown
Contributor

⏱️ Benchmark Results

Click to expand

 RUN  v4.1.5 /home/runner/work/fast-check/fast-check


 ✓  fast-check  test/bench/arbitraries/integer.bench.ts > integer() > generate 4720ms
     name               hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · current  7,629,759.11  0.0001  0.1801  0.0001  0.0002  0.0002  0.0003  0.0019  ±0.19%  3814880
   · main     7,052,112.82  0.0001  0.1579  0.0001  0.0002  0.0002  0.0003  0.0004  ±0.13%  3526057

 BENCH  Summary

   fast-check  current - test/bench/arbitraries/integer.bench.ts > integer() > generate
    1.08x faster than main


Commit: aa4ca14 (merge: df5869d)

dubzzz added a commit that referenced this pull request May 29, 2026
Cover the arbitraries targeted by the in-flight ⚡ performance PRs with a
single key case each, mirroring the existing integer benchmark:
- array(integer()) generate (#7019)
- tuple(integer(), integer()) generate (#7018)
- constantFrom(...) generate (#7020)
- string() generate (#7021)
- integer().chain(.) generate (#7025)
- integer().filter(.) shrink (#7024, shrink-only hot path)

https://claude.ai/code/session_01892QKEatye539h87ym1bFp

---------

Co-authored-by: Claude <noreply@anthropic.com>
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