Skip to content

Commit 3ef2163

Browse files
Add benchmark suite and per-PR benchmark CI workflow (#185)
* Add pytest-benchmark suite and per-PR benchmark CI workflow Adds a bench/ suite (element creation, component mount depth, attribute update at depth, keyed/unkeyed v-for reconciliation, and unkeyed list grow/shrink) using pytest-benchmark with DictRenderer and synchronous event loop, mirroring the setup in the sibling observ repo. The new benchmark workflow runs the suite twice on every PR - once against the master version of collagraph/ and once against the PR version - and fails when mean time regresses more than 5%. The CI test job is scoped to the tests directory so the matrix does not execute benchmarks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Disable GC during timed list-benchmark calls to reduce variance The allocation burst of list reconciliation triggers collection pauses in some rounds, which inflated stddev on the grow benchmarks to ~40% of the mean - far too noisy for the 5% regression gate in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7ac00c1 commit 3ef2163

11 files changed

Lines changed: 518 additions & 1 deletion

.github/workflows/benchmark.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Benchmarks
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- master
7+
8+
jobs:
9+
benchmark:
10+
name: Benchmarks
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 20
13+
steps:
14+
- uses: actions/checkout@v5
15+
16+
- name: Install uv
17+
uses: astral-sh/setup-uv@v6
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version-file: "pyproject.toml"
23+
24+
- name: Install dependencies
25+
run: uv sync
26+
27+
# On PRs: run benchmarks twice (PR code vs master code) and compare
28+
- name: Run benchmarks
29+
run: |
30+
# Checkout master version of collagraph directory
31+
git fetch origin master
32+
git checkout origin/master -- collagraph/
33+
34+
# Run benchmarks with master code as baseline
35+
uv run --no-sync pytest bench \
36+
--benchmark-only \
37+
--benchmark-save=master \
38+
--benchmark-sort=mean || true
39+
40+
# Restore PR code
41+
git checkout HEAD -- collagraph/
42+
43+
# Run benchmarks on PR code and compare
44+
uv run --no-sync pytest bench \
45+
--benchmark-only \
46+
--benchmark-compare \
47+
--benchmark-compare-fail=mean:5% \
48+
--benchmark-save=branch \
49+
--benchmark-sort=mean
50+
51+
- name: Upload benchmarks
52+
if: always()
53+
uses: actions/upload-artifact@v4
54+
with:
55+
name: Benchmarks
56+
path: .benchmarks/
57+
include-hidden-files: true

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
- name: Install dependencies
6262
run: uv sync --all-groups
6363
- name: Test
64-
run: uv run pytest -v --cov=collagraph --cov-report=term-missing
64+
run: uv run pytest tests -v --cov=collagraph --cov-report=term-missing
6565
env:
6666
QT_QPA_PLATFORM: offscreen
6767

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ build
77
*.spec
88
uv.lock
99
.venv
10+
.benchmarks

bench/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Reuse existing test fixtures/helpers for collagraph benchmarks."""
2+
3+
from tests.conftest import ( # noqa: F401
4+
CustomElement,
5+
CustomElementRenderer,
6+
TrackingRenderer,
7+
cleanup,
8+
parse_source,
9+
process_events,
10+
)

bench/test_component_mount.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Benchmarks for mounting a chain of nested components.
3+
4+
Each level is its own ComponentFragment, so this stresses per-component
5+
mount overhead: the weak()-wrapped ref/bind watchers set up in
6+
ComponentFragment.create(), and the root-element lookup at the end of
7+
ComponentFragment.mount() (self.first()).
8+
"""
9+
10+
import pytest
11+
12+
from collagraph import Collagraph, EventLoopType
13+
from collagraph.renderers import DictRenderer
14+
15+
DEPTHS = [10, 50, 200]
16+
17+
18+
def _build_nested_component(parse_source, depth):
19+
Leaf, _ = parse_source(
20+
"""
21+
<leaf />
22+
23+
<script>
24+
import collagraph as cg
25+
26+
class Leaf(cg.Component):
27+
pass
28+
</script>
29+
"""
30+
)
31+
32+
prev = Leaf
33+
prev_name = "Leaf"
34+
for i in range(depth):
35+
name = f"Wrapper{i}"
36+
prev, _ = parse_source(
37+
f"""
38+
<div>
39+
<{prev_name} />
40+
</div>
41+
42+
<script>
43+
import collagraph as cg
44+
45+
class {name}(cg.Component):
46+
pass
47+
</script>
48+
""",
49+
namespace={prev_name: prev},
50+
)
51+
prev_name = name
52+
53+
return prev
54+
55+
56+
@pytest.mark.timeout(timeout=0)
57+
@pytest.mark.benchmark(group="mount_nested_components")
58+
@pytest.mark.parametrize("depth", DEPTHS, ids=[str(d) for d in DEPTHS])
59+
def test_mount_nested_components(benchmark, parse_source, depth):
60+
App = _build_nested_component(parse_source, depth)
61+
62+
def mount():
63+
gui = Collagraph(renderer=DictRenderer(), event_loop_type=EventLoopType.SYNC)
64+
gui.render(App, {"type": "root"})
65+
66+
benchmark(mount)

bench/test_creation.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Benchmarks for the cost of mounting a fragment tree from scratch.
3+
4+
Mounting stresses Fragment.create()/mount(), and for elements with a
5+
dynamic bind, also the watcher-setup path (`_watch_bind`) which relies
6+
on the `weak()` decorator for every callback registration.
7+
"""
8+
9+
import pytest
10+
from observ import reactive
11+
12+
from collagraph import Collagraph, EventLoopType
13+
from collagraph.renderers import DictRenderer
14+
15+
SIZES = [10, 100, 1_000]
16+
SIZE_IDS = ["10", "100", "1k"]
17+
18+
19+
@pytest.mark.timeout(timeout=0)
20+
@pytest.mark.benchmark(group="mount_plain_elements")
21+
@pytest.mark.parametrize("n", SIZES, ids=SIZE_IDS)
22+
def test_mount_plain_elements(benchmark, parse_source, n):
23+
children = "\n".join(" <item />" for _ in range(n))
24+
App, _ = parse_source(
25+
f"""
26+
<root>
27+
{children}
28+
</root>
29+
30+
<script>
31+
import collagraph as cg
32+
33+
class App(cg.Component):
34+
pass
35+
</script>
36+
"""
37+
)
38+
39+
def mount():
40+
gui = Collagraph(renderer=DictRenderer(), event_loop_type=EventLoopType.SYNC)
41+
gui.render(App, {"type": "root"})
42+
43+
benchmark(mount)
44+
45+
46+
@pytest.mark.timeout(timeout=0)
47+
@pytest.mark.benchmark(group="mount_bound_elements")
48+
@pytest.mark.parametrize("n", SIZES, ids=SIZE_IDS)
49+
def test_mount_bound_elements(benchmark, parse_source, n):
50+
children = "\n".join(f' <item :value="v{i}" />' for i in range(n))
51+
App, _ = parse_source(
52+
f"""
53+
<root>
54+
{children}
55+
</root>
56+
57+
<script>
58+
import collagraph as cg
59+
60+
class App(cg.Component):
61+
pass
62+
</script>
63+
"""
64+
)
65+
state = reactive({f"v{i}": i for i in range(n)})
66+
67+
def mount():
68+
gui = Collagraph(renderer=DictRenderer(), event_loop_type=EventLoopType.SYNC)
69+
gui.render(App, {"type": "root"}, state=state)
70+
71+
benchmark(mount)

bench/test_keyed_list.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Benchmarks for keyed v-for reconciliation.
3+
4+
Stresses ListFragment's keyed reconciliation path: the LIS computation
5+
and Fragment.anchor() lookups used to reposition/insert fragments.
6+
7+
Each round replaces the whole `items` list in one reactive assignment
8+
(the idiomatic way to reorder/replace a collection), rather than doing
9+
many individual in-place mutations, which would each trigger their own
10+
full reconciliation pass and dominate the measurement with unrelated
11+
per-mutation dependency-tracking cost.
12+
"""
13+
14+
import gc
15+
import random
16+
17+
import pytest
18+
from observ import reactive
19+
20+
from collagraph import Collagraph, EventLoopType
21+
from collagraph.renderers import DictRenderer
22+
23+
SIZES = [10, 100, 1_000]
24+
SIZE_IDS = ["10", "100", "1k"]
25+
PATTERNS = ["append", "prepend", "reverse", "shuffle"]
26+
27+
28+
def _new_items(items, pattern):
29+
if pattern == "append":
30+
return [*items, {"id": len(items), "text": "x"}]
31+
if pattern == "prepend":
32+
return [{"id": len(items), "text": "x"}, *items]
33+
if pattern == "reverse":
34+
return list(reversed(items))
35+
if pattern == "shuffle":
36+
shuffled = list(items)
37+
random.Random(0).shuffle(shuffled)
38+
return shuffled
39+
raise ValueError(pattern)
40+
41+
42+
def _apply(gui, state, items):
43+
# `gui` is unused but must be kept as a live argument: it holds the
44+
# only strong reference to the mounted fragment tree, so passing it
45+
# through keeps that tree alive for the duration of the timed call.
46+
# GC is disabled during the timed call so that collection pauses
47+
# (triggered by the allocation burst) don't dominate the variance.
48+
gc.disable()
49+
try:
50+
state["items"] = items
51+
finally:
52+
gc.enable()
53+
54+
55+
@pytest.mark.timeout(timeout=0)
56+
@pytest.mark.benchmark(group="keyed_list_reconcile")
57+
@pytest.mark.parametrize("n", SIZES, ids=SIZE_IDS)
58+
@pytest.mark.parametrize("pattern", PATTERNS)
59+
def test_keyed_list_reconcile(benchmark, parse_source, pattern, n):
60+
App, _ = parse_source(
61+
"""
62+
<node v-for="item in items" :key="item['id']" :text="item['text']" />
63+
64+
<script>
65+
import collagraph as cg
66+
67+
class App(cg.Component):
68+
pass
69+
</script>
70+
"""
71+
)
72+
73+
def setup():
74+
initial = [{"id": i, "text": str(i)} for i in range(n)]
75+
state = reactive({"items": list(initial)})
76+
gui = Collagraph(renderer=DictRenderer(), event_loop_type=EventLoopType.SYNC)
77+
gui.render(App, {"type": "root"}, state=state)
78+
new_items = _new_items(initial, pattern)
79+
return (gui, state, new_items), {}
80+
81+
benchmark.pedantic(_apply, setup=setup, warmup_rounds=1, rounds=30)

0 commit comments

Comments
 (0)