Repository navigation
Stabilize flaky CodSpeed benchmarks and speed up container type checks - #7878
Conversation
test_route_matching measured three lru_cache hits: get_router memoizes each path and CodSpeed calls the benchmark once before measuring it. The measured call was about 8k instructions with ~360 cold cache misses, so the route table size did not register and the result flipped by about 5.5% between commits. It now calls the wrapped scan for 100 lookups that cover the index, a dynamic route, the catch-all, static routes spread over the table and misses, so the cost grows with the table (1.3M, 11M and 111M instructions under callgrind, stable to 0.13% across runs). The cached_rxconfig fixture is removed: route matching no longer reads the config. test_process_event_burst_independent_tokens[1] ran the same single warm-token event as test_process_event_warm. Claude-Session: https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX
A measured call of a few microseconds is mostly the fixed cost of entering it and cold cache misses, so its CodSpeed result moves with unrelated changes. Benchmarks of code that does not run per event are removed: - test_decode_event_headers: runs once per socket connection. - test_route_argument_extraction: runs once per page, at add_page. - test_var_arithmetic_chain, test_var_to_dispatch, test_var_guess_type_dispatch: compile time, and the operation families in the same module cover them with sized bodies. - test_state_update_dataclass_serialization: serialize_state_update is the StateUpdate serializer, already measured by every wire benchmark. - test_state_manager_memory_warm_get: duplicate of test_get_state[memory], which batches the same call. Hot-path benchmarks now repeat their operation per measured call: from_event_type (500), emit_update (50), memory modify and read-only modify (ITERATIONS), scalar mutation and dependency fan-out (50, through a shared helper), small wire payloads and the edge-case payload (10-100 encodes). Every remaining benchmark takes at least ~30 us uninstrumented, the batched ones at least ~110 us. Claude-Session: https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX
Validating a list[int] assigned to a state var called _isinstance once per item. Each call allocated five closure cells, because generator expressions in the function captured its arguments, and looked up __origin__ on the item type. The cost of both depends on interpreter state left by earlier code (pymalloc pools, the type attribute cache), which is why state_collection_assignment flipped by 5-14% on CodSpeed for changes that did not touch it. The container branches now check items through _all_isinstance, which accepts an item whose type is exactly the item class without a call, and falls back to _isinstance otherwise. Var classes are excluded, since _isinstance checks a LiteralVar by the value it wraps. The remaining generator expressions are loops, so _isinstance has no cell variables. Under callgrind, state_collection_assignment[100000] and isinstance_container[list_int] run about 36x fewer instructions, and list_optional, list_typeddict and isinstance_scalar 1.2-1.5x fewer. state_collection_assignment keeps only its 100000 case: the smaller ones now measure little beyond the fixed cost of one assignment. Claude-Session: https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX
It ran the same single event on one warm token as test_process_event_warm. Claude-Session: https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX
[Medium impact] The reviewed changes appear safe to merge. SummaryThis PR makes small benchmarks do more work per measured call and speeds up checks of container items.
No actionable new issues were found in the changes since the previous review. Reviews (2) · Last reviewed commit: "Restore the hot-path benchmarks removed ..." · Reviewed by Greptile |
Merging this PR will degrade performance by 89.71%1 warning
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
Header decoding runs on every socket connect (page load, new tab, reconnect), serialize_state_update on every update, and small list assignments on every event that sets a list var, so their benchmarks stay, repeating the operation per measured call instead: - test_decode_event_headers: 200 decodes per call. The 0-header case is dropped: it measured only the call. The docstring said per-event; the decoded headers are cached per socket. - test_state_update_dataclass_serialization: 1000 conversions per call. - test_state_collection_assignment[10] and [1000] are back. Every size validates 100,000 items per call, as 100,000 // size assignments, so the sizes split the same work between per-assignment and per-item cost. Under callgrind the restored benchmarks drift 0.0-0.7% between a run alone and one after another module, except assignment[10] at 1.4%, which is simulated cache misses on perf trampoline addresses. Before, a header decode call was 44k-204k estimated cycles and drifted up to 2%. The README now says how to size a benchmark and how to check it locally. Claude-Session: https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX
All Submissions:
Type of change
reflex-base: container type validationDescribe 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:
route_matching[1000]state_collection_assignment[1000]state_collection_assignment[100000]process_event_burst_independent_tokens[1]1.
test_route_matchingmeasured three cache hits.get_routermemoizes each path withlru_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. Thecached_rxconfigfixture 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 ignorespedanticrounds/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:
decode_event_headers[4/16/64]state_update_dataclass_serializationfrom_event_typeemit_updatemodify/read_only_modifyITERATIONSstate_collection_assignment[10/1000/100000]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 runsgc.collect()andgc.disable()around setup and the measured call. Under callgrind the run-to-run difference sat entirely in_Py_dict_lookup(type-attribute cache misses ongetattr(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.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.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.mdnow documents the command and the sizing rules.decode_event_headers[64]went from 1.98% to 0.28%.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.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.New Feature Submission:
Changes To Core Features:
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.tests/units/reflex_bench/drivers/test_browser.py, because the local container lacks the matching Chromium headless shell.https://claude.ai/code/session_01LwJUCWYQL5BocrPjnH5hFX