Skip to content

⚡️ Speed up assert/property hot path ~5x#7017

Open
dubzzz wants to merge 4 commits into
mainfrom
claude/fast-check-perf-optimize-eTrdi
Open

⚡️ Speed up assert/property hot path ~5x#7017
dubzzz wants to merge 4 commits into
mainfrom
claude/fast-check-perf-optimize-eTrdi

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 / claude-opus-4-7) and has not been line-by-line reviewed by a human before submission.

For trivial properties — anything dominated by fast-check's per-iteration plumbing rather than user predicates — assert(property(...)) is now ~5× faster on Node 22. On the minimal benchmark assert(property(constant(1), () => {})) the call goes from ~81 µs to ~15 µs, and per-iteration steady-state cost drops from ~820 ns to ~140 ns.

This matters for users who write many small property tests in their suite: today the runner overhead is comparable to or larger than the actual user predicate for cheap properties, and that overhead is fixed cost paid on every assert(). For larger CI suites with thousands of property tests, this can shave seconds off wall-clock time. Heavier predicates see proportionally smaller wins (their own work dominates), but they still benefit from the cheaper jump and lower per-iteration allocation pressure.

No observable behavior change. The default rng still produces the exact same value stream for any given seed, so existing tests (counterexamples, seeds, replay paths) reproduce bit-for-bit. The full test suite (2505 tests) passes, including the Poisoning suite (which verifies the framework still works under hostile global-prototype tampering) and the NoRegression snapshot suite. The only user-observable difference: params.randomType === xorshift128plus identity checks no longer match the default — code using the 'xorshift128plus' string or default behavior is unaffected, but code that compares against the imported xorshift128plus function will see our fastXorshift128plus instead. Stack-trace snapshots also lose one (now-removed) Property.predicate frame.

What changed

  1. FastXorshift128Plus — drop-in replacement for pure-rand's xorshift128plus (same next() and same jump() end-state) whose jump() is implemented as a precomputed four-Russians lookup table (16 byte lookups + 16 four-word XORs) instead of 128 calls to next(). Wired in as the default and as the 'xorshift128plus' string randomType. Standalone jump() benchmark: ~21× faster (846 ns → 40 ns); this alone accounts for most of the end-to-end win, since jump() was ~50% of CPU.
  2. runIdToFrequency — replaced ~~(Math.log(runId + 1) * 1/log(10)) with an integer digit-count ladder. Same numeric output, but stays on the Smi path so V8 no longer emits a per-iteration DoubleToI.
  3. Tosser — replaced the generator with a TossIterator class that reuses one Random wrapper across iterations. When both pool and work rng are FastXorshift128Plus (the default case), state is synced with four field writes per iteration instead of allocating a fresh clone().
  4. SourceValuesIterator / RunnerIterator — added a pullNext() hot path returning the value (or a SENTINEL_DONE symbol) directly, so the runner loop no longer allocates an { value, done } IteratorResult per iteration. The shrink-stream slot is held as a monomorphic PullableIterator to keep V8's IC happy; the dynamic capability probe is done once at construction (and explicitly avoids Function.prototype.bind so the Poisoning suite still passes).
  5. Property arity dispatch — moved the (t) => p(...t) spread out of the per-property() closure and into a switch inside Property.run keyed on a stored arity. The old wrapper allocated a fresh closure per call, giving each call site its own feedback cell and triggering repeated "wrong feedback cell" deopts; the new dispatch is a single stable call site shared across every property() call.
  6. Property.generate / AlwaysShrinkableArbitrary.generate — inlined the early-return for the common case where value.context !== undefined, skipping the noUndefinedAsContext function call entirely (it was a no-op for any tuple-wrapped arbitrary, which covers everything built via property(...)).

How to reproduce the benchmark

The repo ships perf-bench/bench-suite.mjs (a self-contained Node script). For a quick standalone check:

// save as bench.mjs, then: node bench.mjs
import { assert, property, constant } from 'fast-check';

const prop = property(constant(1), () => {});
const N = 5000, NUM_RUNS = 100;

// warmup
for (let i = 0; i < 1000; ++i) assert(prop, { numRuns: NUM_RUNS });

const samples = [];
for (let s = 0; s < 9; ++s) {
  const t0 = process.hrtime.bigint();
  for (let i = 0; i < N; ++i) assert(prop, { numRuns: NUM_RUNS });
  samples.push(Number(process.hrtime.bigint() - t0));
}
samples.sort((a, b) => a - b);
const median = samples[samples.length >> 1];
console.log(`median: ${(median / 1e6).toFixed(1)} ms — ${(median / N).toFixed(0)} ns/assert — ${(median / N / NUM_RUNS).toFixed(0)} ns/iter`);

Indicative numbers on Node 22.22 (5000 asserts × 100 numRuns each, median of 9 samples):

Scenario Baseline This PR Speedup
assert(property(constant(1), () => {})) ~81 µs / call ~15 µs / call 5.5×
Pre-built property, numRuns=1000 (per-iter) ~820 ns/iter ~140 ns/iter 5.9×
Pre-built property, numRuns=1 (setup-bound) ~1100 ns ~430 ns 2.6×
property(integer(), () => {}) ~101 µs ~29 µs 3.5×
property(int, int, int) -> noop ~147 µs ~59 µs 2.5×
`property(integer(), (a) => a + 1 > a ...)` ~105 µs

Design notes / trade-offs

  • Why not upstream the fast jump() to pure-rand? That's the right long-term home, but shipping a vendored implementation here unblocks the win without coupling fast-check releases to a pure-rand release. The 64 KB jump table is built lazily at module import (~1 ms one-shot) and is shared across all FastXorshift128Plus instances.
  • Why not specialize Property for 1-arg properties (skip the tuple)? Considered, but it would change the Ts typing contract (property(arb, p) would no longer be Property<[T]> internally) and bleed into shrink semantics. The cheaper arity-dispatch + tuple wrapping already captures most of the win without breaking layering.
  • Predicate-spread regression on stack traces. Removing the (t) => p(...t) wrapper drops one frame from error stacks (Property.predicate no longer appears). This is captured in the updated NoRegressionStack snapshot — the new stack is strictly shorter, which is arguably an improvement for users debugging failures.

Impact

Patch-level under the changeset definition: no API additions, no breaking behavior, identical value streams. The one observable change is the default rng's function identity, which is plausibly an implementation detail that nobody asserts against — but flagging it here so the maintainer can decide whether it warrants a minor bump instead.

Tests

  • All 2505 existing tests pass (unit + e2e + Poisoning).
  • The NoRegressionStack snapshot is updated to reflect the now-shorter stack (one fewer wrapper frame).
  • The QualifiedParameters test that asserted the default rng function identity is updated to reference fastXorshift128plus.
  • No new dedicated perf test was added (the existing suite covers correctness end-to-end via assert(property(...)); perf is verified via the perf-bench/ scripts shipped in this PR, not run in CI).

Fixes #issue-number

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

claude added 2 commits May 23, 2026 00:22
For `assert(property(constant(1), () => {}))` (and similarly trivial
workloads dominated by framework overhead), the per-iteration cost drops
from ~820 ns to ~140 ns on Node 22 — about 5.5–6× faster end-to-end.

Concrete changes:

- Introduce `FastXorshift128Plus`, a drop-in replacement for pure-rand's
  `xorshift128plus` that produces the identical value stream but runs
  `jump()` via a precomputed 64 KB four-Russians table (16 byte-lookups
  + 16 four-word XORs) instead of 128 calls to `next()`. Wire it in as
  the default and the `'xorshift128plus'` string randomType.
- Replace `runIdToFrequency`'s `Math.log` + `~~` with an integer digit
  ladder. Stays on the Smi path and eliminates per-iter `DoubleToI`.
- Refactor `Tosser` from a generator into a `TossIterator` class that
  reuses one `Random` wrapper across iterations, and, when the rng is
  `FastXorshift128Plus`, syncs the work rng's state with four field
  writes instead of allocating a fresh clone per call.
- Add a `pullNext` hot path to `SourceValuesIterator` and
  `RunnerIterator` that returns the value (or `SENTINEL_DONE`) directly,
  avoiding the `{value, done}` IteratorResult allocations the `for-of`
  protocol would force on every iteration.
- Move arity-based predicate dispatch into `Property.run` itself. The
  former `(t) => p(...t)` wrapper allocated a fresh closure per
  `property()` call, giving each call site its own feedback cell and
  causing repeated "wrong feedback cell" deopts on the hot path. The
  dispatched call site is now stable across all `property()` calls.
- Inline the `noUndefinedAsContext` hot path in `Property.generate` and
  `AlwaysShrinkableArbitrary.generate`: skip the helper call entirely
  when `value.context !== undefined` (the common case).

Verified: the full fast-check test suite (2505 tests) still passes,
including the Poisoning suite (no reliance on `.bind` / direct
prototype-method calls on user buffers) and the NoRegression snapshot
suite (same value stream as before). `NoRegressionStack` snapshots are
updated to reflect the removal of the now-unused `Property.predicate`
wrapper frame.
- Drop now-unused `xorshift128plus` import from QualifiedParameters
  (the default and the 'xorshift128plus' string both resolve to
  fastXorshift128plus, which has the same value stream).
- Widen `TossIterator.pullNext` return type to `Value<Ts> | SENTINEL_DONE`
  to satisfy the `PullableIterator` contract (the runtime never returns
  SENTINEL_DONE — this iterator is unbounded once examples are exhausted).
- Extend the `fakeRandom` test helper's Omit to also exclude the new
  `unsafeReplaceInternal` / `unsafeGetInternal` internal methods.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 23, 2026

⚠️ No Changeset found

Latest commit: f33b961

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

fast-check

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

@fast-check/jest

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

@fast-check/packaged

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

@fast-check/poisoning

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

@fast-check/vitest

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

@fast-check/worker

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

commit: f33b961

@codecov
Copy link
Copy Markdown

codecov Bot commented May 23, 2026

Codecov Report

❌ Patch coverage is 92.06349% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.65%. Comparing base (b9334a6) to head (f33b961).

Files with missing lines Patch % Lines
.../fast-check/src/check/property/Property.generic.ts 68.00% 8 Missing ⚠️
packages/fast-check/src/check/runner/Tosser.ts 87.03% 7 Missing ⚠️
...ages/fast-check/src/check/property/IRawProperty.ts 54.54% 1 Missing and 4 partials ⚠️
...ages/fast-check/src/check/runner/RunnerIterator.ts 72.72% 2 Missing and 1 partial ⚠️
...-check/src/random/generator/FastXorshift128Plus.ts 98.87% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7017      +/-   ##
==========================================
- Coverage   94.87%   94.65%   -0.22%     
==========================================
  Files         212      213       +1     
  Lines        5888     6173     +285     
  Branches     1545     1587      +42     
==========================================
+ Hits         5586     5843     +257     
- Misses        294      317      +23     
- Partials        8       13       +5     
Flag Coverage Δ
tests 94.65% <92.06%> (-0.22%) ⬇️

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.

claude added 2 commits May 28, 2026 22:03
Mirrors the harness from #7027: two minimal benches that run the full
`assert(property(...))` flow with an empty always-successful predicate.
Lets the CI bench job show the runner-overhead win this PR brings
against `main` side-by-side.
@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/check/property/AsyncProperty.bench.ts > asyncProperty() > assert (always successful, empty predicate) 1213ms
     name            hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · current  20,031.51  0.0443  0.3067  0.0499  0.0469  0.1197  0.1261  0.1905  ±0.59%    10016
   · main      8,478.70  0.1130  0.3412  0.1179  0.1153  0.1997  0.2110  0.2604  ±0.36%     4240

 ✓  fast-check  test/bench/check/property/Property.bench.ts > property() > assert (always successful, empty predicate) 1239ms
     name            hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · current  83,365.77  0.0101  1.0706  0.0120  0.0114  0.0445  0.0774  0.0927  ±0.73%    41683
   · main     12,459.47  0.0778  0.3390  0.0803  0.0788  0.1020  0.1679  0.2026  ±0.32%     6230

 ✓  fast-check  test/bench/arbitraries/integer.bench.ts > integer() > generate 4707ms
     name               hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · current  7,529,689.66  0.0001  0.1917  0.0001  0.0002  0.0002  0.0003  0.0020  ±0.30%  3764864
   · main     7,007,119.19  0.0001  0.1695  0.0001  0.0002  0.0002  0.0003  0.0003  ±0.16%  3503822

 BENCH  Summary

   fast-check  current - test/bench/check/property/AsyncProperty.bench.ts > asyncProperty() > assert (always successful, empty predicate)
    2.36x faster than main

   fast-check  current - test/bench/check/property/Property.bench.ts > property() > assert (always successful, empty predicate)
    6.69x faster than main

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


Commit: f33b961 (merge: 8ed2394)

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