Skip to content

Commit 20efc14

Browse files
Add opt-in fast render context (REACTON_FAST) + benchmark harness
The default renderer is unchanged: _RenderContext keeps the original tree-walking (_render/_reconsolidate/_remove_element/_visit_children*), byte-for-byte. A _RenderContextFast(_RenderContext) subclass overrides only those methods with a reimplementation that does work proportional to what changed instead of a full tree walk: - state setters mark needs_render_descendant up the parent chain, so a render pass only descends into subtrees that can contain work; an identical, fully-reconciled, exception-free subtree is skipped in both phases (clean_subtree) and keeps its widgets; - force_update/update/first render walk fully (rc._walk_all); - stale-element removal runs once per context instead of an O(n) set-diff per element; - widget updates are skipped when an identical element reconciles to identical child widgets. Selected with REACTON_FAST=1 (via _render_context_class()); off by default. Side-effect (Layout/Style) widget tracking also moved to ipywidgets' on_widget_constructed hook (shared by both renderers) instead of a per-creation global-dict diff that was O(live widgets). benchmarks/ holds the harness, baseline-vs-fast result JSONs, and README.md documenting the design, the renderer contract both implementations satisfy, the deliberate test-invisible deltas, and known issues found during the work. On a 300-row tree the fast renderer takes a single-leaf update from 13.7ms to 0.62ms (22x) and a memoized-subtree skip from 14.4ms to 1.0ms (14x), behavior-identical to the default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a1f62a commit 20efc14

7 files changed

Lines changed: 1698 additions & 22 deletions

File tree

benchmarks/README.md

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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

Comments
 (0)