Stabilize flaky CodSpeed benchmarks and speed up container type checks
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:
| Benchmark | Flagged on | Values |
|---|---|---|
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:
Benchmark Runs on Repeats decode_event_headers[4/16/64]every socket connect 200 state_update_dataclass_serializationevery update 1000 from_event_typehandlers returning events 500 emit_updateevery update 50 memory modify/read_only_modifyevery event ITERATIONSscalar mutation, dependency fan-out every event 50, one shared helper small wire payloads, edge-case payload every update 10–100 state_collection_assignment[10/1000/100000]events setting a list 100k items per call, as 100000 // sizeassignmentsdecode_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_datacovers 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_getduplicatestest_get_state[memory], which already batches the same call.burst_independent_tokens[1]andburst_same_token[1]are the same single warm event astest_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:_isinstancenow 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_isinstancechecks a LiteralVar by the value it wraps. The remaining generator expressions are now loops, so_isinstancehas no cell variables. - Effect under callgrind:
state_collection_assignment[100000]andisinstance_container[list_int]run about 36x fewer instructions; wall time for the first went from 50 ms to 1.6 ms. The otherisinstancebenchmarks 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) andstate_access[read_cached_computed](−10% and −15% on 2 PRs) also look noisy. Worth revisiting if they keep flipping after this.- CodSpeed's
exclude-allocationsaction 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_itemscovers 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.