|
| 1 | +# Reacton render benchmarks & the opt-in fast reconciler |
| 2 | + |
| 3 | +This directory holds the performance harness for a faster reimplementation of |
| 4 | +the renderer (the tree-walking downstream of `render()`), plus its design notes. |
| 5 | + |
| 6 | +The fast renderer is **opt-in and off by default.** The default `_RenderContext` |
| 7 | +is the original, unchanged implementation; the rewrite lives in a |
| 8 | +`_RenderContextFast(_RenderContext)` subclass that overrides only the |
| 9 | +tree-walking methods. Set `REACTON_FAST=1` to select it. CI runs the full test |
| 10 | +suite against both (a `reacton-fast: ["0", "1"]` matrix dimension), so the two |
| 11 | +stay behavior-identical. |
| 12 | + |
| 13 | +## Running the benchmarks |
| 14 | + |
| 15 | +```bash |
| 16 | +# default renderer, then the fast one: |
| 17 | +python benchmarks/bench.py --label baseline |
| 18 | +REACTON_FAST=1 python benchmarks/bench.py --label fast |
| 19 | +python benchmarks/compare.py baseline fast |
| 20 | +``` |
| 21 | + |
| 22 | +`bench.py` writes `benchmarks/results/<label>.json`. `--scenario <name>` runs a |
| 23 | +subset; `--profile` also writes a cProfile `.prof` per scenario. Scenarios cover |
| 24 | +initial render (wide/deep), single-leaf update, root update, memoized-subtree |
| 25 | +skip, keyed list reorder, burst updates, full `force_update`, and teardown — so |
| 26 | +a change can be checked against both the headline case (one leaf updating in a |
| 27 | +large tree) and the no-op/full-walk cases. |
| 28 | + |
| 29 | +Use the cheapest widgets possible in scenarios (bare `Button`/`Label`): real |
| 30 | +ipywidget construction cost can dominate and mask reconciler cost, so confirm |
| 31 | +with a profile that you are measuring tree-walking, not traitlets. |
| 32 | + |
| 33 | +## What the fast renderer changes |
| 34 | + |
| 35 | +Goal: an update should cost work proportional to what actually changed, not a |
| 36 | +full tree walk. With the default renderer, a single-leaf state change, a |
| 37 | +memoized-subtree "skip", and a full `force_update` all cost the same (~14ms on a |
| 38 | +300-row tree), and stale-element removal is accidentally quadratic. The fast |
| 39 | +renderer (`_RenderContextFast`, `REACTON_FAST=1`) addresses both: |
| 40 | + |
| 41 | +- **Dirty-subtree skipping.** State setters mark `needs_render_descendant` up |
| 42 | + the parent chain, so a render pass only descends into subtrees that can |
| 43 | + contain work. A component subtree whose element is identical to the previous |
| 44 | + render (`el is el_prev`), is fully reconciled, and has no dirty/excepted |
| 45 | + contexts is skipped in *both* phases and keeps its previous widgets |
| 46 | + (`clean_subtree`). |
| 47 | +- **Forced full walks** (`force_update()`, `update()`, the first render) set |
| 48 | + `rc._walk_all`, disabling skipping for that pass — faithful to the old |
| 49 | + behavior. |
| 50 | +- **Stale-element removal** runs once per component context (and once for the |
| 51 | + root context in `render()`) instead of an O(elements) set-difference per |
| 52 | + element. |
| 53 | +- **Widget updates** are skipped when an identical element reconciles to |
| 54 | + identical child widget objects (`_values_identical`), avoiding pointless |
| 55 | + traitlets assignments. |
| 56 | +- **Side-effect ("orphan") widgets** (Layout/Style created during construction) |
| 57 | + are tracked via ipywidgets' `on_widget_constructed` hook instead of diffing |
| 58 | + the global widgets dict per creation — the old diff was O(live widgets) per |
| 59 | + widget, so it degraded as an app grew. |
| 60 | + |
| 61 | +## Renderer contract |
| 62 | + |
| 63 | +What both renderers must preserve (derived from `core.py` + the test suite). |
| 64 | +The fast renderer overrides only `_render`, `_reconsolidate`, `_remove_element`, |
| 65 | +`_visit_children`, `_visit_children_values`; everything else (Element widget |
| 66 | +create/update/close, hooks storage, `ComponentContext`, exception plumbing, the |
| 67 | +render loop) is shared with the default renderer. |
| 68 | + |
| 69 | +**Phases** (inside one `rc.render()` call, under `thread_lock`, with `local.rc` set): |
| 70 | + |
| 71 | +1. *Render phase*: execute component functions depth-first, build |
| 72 | + `elements_next`/`children_next`. Repeat while `_rerender_needed` (max 50, |
| 73 | + then `RuntimeError`) and no exception has bubbled up. |
| 74 | +2. *Reconsolidate phase*: create/update/close widgets, run effects, move |
| 75 | + `*_next` → current. Loop back to phase 1 if state was set during reconcile |
| 76 | + (e.g. in an effect). `render()` returns the root widget. |
| 77 | + |
| 78 | +**Keys.** `el._key` or a positional default. A context's root key is `"/"`. |
| 79 | +Children of an element with key `K`: list → `f"{K}{i}/"`, dict → `f"{K}{k}/"`. |
| 80 | +Component child contexts live in `context.children[key]`. A duplicate key in one |
| 81 | +context raises `KeyError`. `el._key_frozen` is set once an element is rendered. |
| 82 | + |
| 83 | +**Render phase, per element:** |
| 84 | +- `el._render_count += 1` (a shared element is visited once; a non-shared one |
| 85 | + per use — see `test_render_count_element`). |
| 86 | +- Element arguments (args/kwargs) are walked only for `ComponentWidget` |
| 87 | + elements or shared elements; a `ComponentFunction` element does not walk its |
| 88 | + arguments. |
| 89 | +- `ComponentFunction`: reuse the `ComponentContext` when |
| 90 | + `same_component(invoke_element.component, el.component)`, or when a context |
| 91 | + exists with no `root_element` (pre-created by `state_set`). A different |
| 92 | + component → fresh context, old one removed during reconcile. |
| 93 | +- `needs_render` = `context.needs_render` (set by setters/force) OR |
| 94 | + `el._arguments_changed(el_prev)` OR `context.exceptions_children`. If false, |
| 95 | + the body is *not* executed (component `render_count` stays put), the previous |
| 96 | + `root_element` is reused, but it is still walked. |
| 97 | +- Body execution resets `state_index`/`effect_index`/`memo_index`, |
| 98 | + `user_contexts={}`, `exception_handler=False`, `needs_render=False` before the |
| 99 | + call; wraps in `context_managers` (the solara `ContextManager` hook) and the |
| 100 | + `_default_container` handling (implicit containers via |
| 101 | + `ContainerAdder`/`el.__enter__`). |
| 102 | +- Body exceptions → `context.exceptions_self`, set `_rerender_needed`; a |
| 103 | + hook-count mismatch is itself an error (fewer calls only allowed if an |
| 104 | + exception interrupted the body); a component returning `None` → `ValueError`. |
| 105 | +- After: `parent.children_next[key] = context`; prune `children_next`/ |
| 106 | + `elements_next` to `used_keys`; `user_contexts_prev = user_contexts`. |
| 107 | + Exceptions bubble to `parent.exceptions_children` unless `exception_handler` |
| 108 | + (set by `use_exception` during the body). |
| 109 | + |
| 110 | +**Reconsolidate phase, per element** (depth-first over the element tree, |
| 111 | +entering child contexts): |
| 112 | +- *Widget element*: visit children → new kwargs (Elements replaced by widgets; |
| 113 | + `FragmentWidget` children flattened into lists); then create / update-in-place |
| 114 | + (same component) / replace (remove old first). Created via `el._create_widget` |
| 115 | + (orphan side-effect widgets tracked into `rc._orphans[widget.model_id]`); |
| 116 | + updated via `el._update_widget` (dropped kwargs restored to trait defaults, |
| 117 | + listeners removed) under `hold_sync` + `suppress_events`. |
| 118 | +- *Component element*: recurse into the child context root, then process |
| 119 | + effects: an `effect.next` with equal deps drops `next`; otherwise clean up the |
| 120 | + old effect and run the next; never-executed effects run now; all are skipped |
| 121 | + (cleanups still run) if the context has exceptions. |
| 122 | +- `el.meta` / root-widget meta merge → `widget._react_meta`. |
| 123 | +- Bookkeeping: `elements_next[key]` → `elements[key]`; `children_next[key]` → |
| 124 | + `children[key]`; shared elements move `_shared_elements_next` → |
| 125 | + `_shared_elements` with the widget in `rc._shared_widgets` (one widget per rc); |
| 126 | + non-shared widget in `context.widgets[key]`; `context.element_to_widget[el]` |
| 127 | + set (used by `get_widget`). |
| 128 | +- Stale keys (old elements whose key is not in `used_keys`) → `_remove_element`, |
| 129 | + in sorted order for reproducibility. |
| 130 | +- After reconciling a context, `elements_next` must be empty (shared leftovers |
| 131 | + are moved), else `RuntimeError`. `root_element = root_element_next`; |
| 132 | + `root_element_next = None` (asserted by `test_internals`). |
| 133 | + |
| 134 | +**Removal.** |
| 135 | +- *Component element*: clean up all effects, recurse into `root_element`, assert |
| 136 | + the context is fully empty, `del parent.children[key]`; cleanup exceptions |
| 137 | + bubble (`close()` raises the first). |
| 138 | +- *Widget element*: recurse children first, close orphans, |
| 139 | + `el._cleanup_callbacks(widget)`, `el._close_widget(widget)`. Shared widgets are |
| 140 | + removed only on the last reference. |
| 141 | +- Removing an already-removed key is a no-op (`test_remove_element_twice`). |
| 142 | + |
| 143 | +`close()`: `_remove_element(root)`, container closed (+layout), asserts |
| 144 | +`_shared_elements`/`_orphans` empty, raises any pending exception. |
| 145 | + |
| 146 | +**Other pinned behavior:** `rc.render_count` increments per `render()` call; |
| 147 | +root widget type change without a container → `ValueError`; container gets |
| 148 | +`.children = [widget]` (`[]` on error). `handle_error=True` renders an HTML |
| 149 | +traceback widget instead of raising. Batching: `with rc:` defers re-render, and |
| 150 | +`hold_trait_notifications` is wrapped so frontend-triggered trait updates batch |
| 151 | +into a single render. Setters use `eq`/`utils.equals`, warn on mutated |
| 152 | +list/dict/DataFrame state, are ignored while `_closing`, and trigger a render |
| 153 | +unless already rendering or batching. `use_context`/`provide` walk parent |
| 154 | +contexts; `provide` notifies listeners only when the value changed. |
| 155 | + |
| 156 | +## Deliberate behavior deltas (not observable by the test suite) |
| 157 | + |
| 158 | +- Stale elements are now closed *after* the parent widget gets its new children |
| 159 | + assigned (the old code closed them mid-walk, so a closed widget could briefly |
| 160 | + remain in a container's `children`). |
| 161 | +- Per-element debug logging in the hot paths was dropped. |
| 162 | + |
| 163 | +## Known issues worth revisiting (found during the rewrite, not fixed here) |
| 164 | + |
| 165 | +- **Effect/cleanup exceptions** are recorded on the *parent* context's |
| 166 | + `exceptions_self`, while render exceptions go to the component's own context. |
| 167 | + So an effect exception is caught by `use_exception` one level higher than a |
| 168 | + render exception would be. Behavior preserved from the old code; looks |
| 169 | + unintentional. |
| 170 | +- **pandas 3.0**: setting state to an equal DataFrame triggers a re-render and |
| 171 | + leaks widgets (`test_set_state_with_dataframe`). The reacton equality logic |
| 172 | + likely needs a pandas-3 fix; the test environment pins `pandas<3` for now. |
| 173 | +- `ComponentContext.owns` is dead — never written, only asserted empty in |
| 174 | + `_remove_element`. |
| 175 | + |
| 176 | +## Where initial-render time goes |
| 177 | + |
| 178 | +After the rewrite, initial render is dominated by ipywidget *construction*, not |
| 179 | +reconciler tree-walking — most of it is traitlets default materialization and |
| 180 | +`get_state` during `Widget.open()`. Reacton-side reconciliation of a large tree |
| 181 | +is now a small fraction of that. Future initial-render wins are mostly in |
| 182 | +ipywidgets/traitlets, not here. |
0 commit comments