Stabilize flaky CodSpeed benchmarks and speed up container type checks

Merged
FA
21h ago
+318-253

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue): CodSpeed benchmarks that flip on unrelated PRs
  • Performance improvement in reflex-base: container type validation

Describe your changes

CodSpeed has been flagging the same handful of benchmarks on PRs that don't touch the code they measure. A tally of the CodSpeed reports on the 50 most recently updated PRs showed them landing on the same two values each time:

BenchmarkFlagged onValues
route_matching[1000]9 PRs−5.5 to −5.9% and +5.4 to +5.8%
state_collection_assignment[1000]4 PRs−11 to −12% and +14%
state_collection_assignment[100000]3 PRs±5%
process_event_burst_independent_tokens[1]4 PRs−4.4 to −4.5% (one PR only changes release tests)

1. test_route_matching measured three cache hits. get_router memoizes each path with lru_cache, and CodSpeed calls a benchmark once before measuring it, so the measured call was three cache lookups. That's about 8k instructions plus ~360 cold cache misses, which dominated the estimate, so table size didn't register. It now calls the wrapped scan for 100 lookups covering the index, a dynamic route, the catch-all, static routes spread over the table and paths no route matches. Cost now grows with the table: 1.3M, 11M and 111M instructions under callgrind for 10, 100 and 1000 routes. The cached_rxconfig fixture is removed, because route matching no longer reads the config.

2. Microsecond benchmarks. A measured call of a few microseconds is mostly the fixed cost of entering it plus cold cache misses, which move with code and memory layout. CodSpeed's docs put cache-driven micro-benchmark changes at under 1 µs. pyperf handles this with hardcoded inner_loops (repeating the operation inside the benchmark) and calibrates to at least 100 ms per value. CodSpeed's simulation runs one measured call and ignores pedantic rounds/iterations, so the repetition has to live inside the measured function.

  • Hot path, repeated per measured call. Every one now does at least ~100 µs of uninstrumented work:

    BenchmarkRuns onRepeats
    decode_event_headers[4/16/64]every socket connect200
    state_update_dataclass_serializationevery update1000
    from_event_typehandlers returning events500
    emit_updateevery update50
    memory modify / read_only_modifyevery eventITERATIONS
    scalar mutation, dependency fan-outevery event50, one shared helper
    small wire payloads, edge-case payloadevery update10–100
    state_collection_assignment[10/1000/100000]events setting a list100k items per call, as 100000 // size assignments

    decode_event_headers[0] is dropped, because it measured only the call. The decoded headers are cached per socket (_static_router_data), so decoding happens once per connection; test_on_event_router_data covers the per-event merge.

  • Removed, compile time or once per page: route_argument_extraction, var_arithmetic_chain, var_to_dispatch, var_guess_type_dispatch. The page, compilation and sized var-operation benchmarks cover that code.

  • Removed, exact duplicates:

    • state_manager_memory_warm_get duplicates test_get_state[memory], which already batches the same call.
    • burst_independent_tokens[1] and burst_same_token[1] are the same single warm event as test_process_event_warm.

3. state_collection_assignment: cost depended on interpreter state. Garbage collection isn't the cause: pytest-codspeed runs gc.collect() and gc.disable() around setup and the measured call. Under callgrind the run-to-run difference sat entirely in _Py_dict_lookup (type-attribute cache misses on getattr(cls, "__origin__")) and pymalloc. Both happened once per item in _isinstance, which also allocated five closure cells per call, because generator expressions captured its arguments.

  • The fix, in reflex-base: _isinstance now checks container items through _all_isinstance, which accepts an item whose type is exactly the item class without a call. Var classes are excluded, because _isinstance checks a LiteralVar by the value it wraps. The remaining generator expressions are now loops, so _isinstance has no cell variables.
  • Effect under callgrind: state_collection_assignment[100000] and isinstance_container[list_int] run about 36x fewer instructions; wall time for the first went from 50 ms to 1.6 ms. The other isinstance benchmarks run 1.2–1.5x fewer.

How this was checked. pytest-codspeed's hooks reset and dump callgrind counters per benchmark under valgrind, so stock callgrind gives a close local approximation of CodSpeed's simulation. tests/benchmarks/README.md now documents the command and the sizing rules.

  • Method: each changed benchmark was run alone and again after another module.
  • Batched benchmarks: they now drift 0.0–0.7% between the two runs, down from up to ~2%. decode_event_headers[64] went from 1.98% to 0.28%.
  • The one exception: state_collection_assignment[10] drifts 1.4%. That comes from simulated cache misses on perf-trampoline addresses, the code-layout noise CodSpeed's docs describe; instructions differ by 0.03%.

This is a local approximation: CI on this PR is the real test.

Expected on CodSpeed for this PR: large changes on every benchmark that was batched, rewritten or sped up, and missing results for the removed ones. They need acknowledging once.

Not changed here:

  • wire_serialization[model_1000] (−4.4 to −6.0% on 3 PRs, one JS-only) and state_access[read_cached_computed] (−10% and −15% on 2 PRs) also look noisy. Worth revisiting if they keep flipping after this.
  • CodSpeed's exclude-allocations action input exists in the pinned v5.6.3. It is left off: allocation is a real part of Python hot-path cost, and it isn't documented whether CPython's own pymalloc counts as allocator time.
  • The regression threshold is a project setting outside this repo and is left as is.

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable? test_isinstance_checks_container_items covers 21 container cases with Vars treated both as their type and as values; it passes on the old and new _isinstance.
  • Have you successfully ran tests with your changes locally? Unit tests, ruff and pyright pass. The only failures are the 14 Playwright tests in tests/units/reflex_bench/drivers/test_browser.py, because the local container lacks the matching Chromium headless shell.

https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX