Skip to content

Commit 744c11d

Browse files
serpentbladeclaude
andcommitted
refactor(livewire): decompose the mount delta — it's the model trait
The 50% on a "simple" Livewire mount looked too good, so decompose it across four corners (vanilla / +model only / +foundation only / full). The result: the model trait alone is ~-45 to -50%; the container/view/event tiers add ~0-5% on a single-component mount. The win isn't "the stack" — it's HasGrease. Why one trait halves a mount: the model tier kills per-instance overhead (reflection, class-attribute resolution, initialize* booters) — a fixed cost per `new Model()` that dominates for small rows — and a `with('posts')` mount hydrates nine models (user + 8 posts), so it's paid nine times. Checked against the macOS-distortion reflex: this reproduces at the SAME magnitude on Linux+JIT via benchmarks/docker (x86_64 image), so it is not a macOS artifact — the earlier "Linux will deflate it" hunch was wrong. - livewire_ab.php: four-corner decomposition + per-tier attribution in the report; parity gate now asserts both greased arms match the vanilla baseline. - docs/guide/livewire.md: replace the full-stack headline with the decomposition table; land the "just put HasGrease on your models" conclusion the data backs. - CLAUDE.md: record the decomposition + the corrected macOS-vs-Linux finding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09af8d6 commit 744c11d

2 files changed

Lines changed: 83 additions & 45 deletions

File tree

benchmarks/livewire_ab.php

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
* a stack of exactly the tiers Grease accelerates — hydration, casting, date serialization,
99
* `toArray()`, and the Blade compiler — fired on every round-trip rather than once per request.
1010
*
11-
* This sizes what the full greased stack buys on a Livewire initial render: a fully-configured
12-
* Testbench app boots vanilla vs greased (greased container + view + event providers + a greased
13-
* model), and `Livewire::mount()` is timed — the path that hydrates the model from the DB,
14-
* renders the component, dehydrates the `toArray()` payload (ISO dates, the loaded relation),
15-
* generates the checksum, and embeds it. The component is the UserCard fixture, whose state
16-
* is a serialized model — so the date/`toArray` tiers actually land in the measured work.
11+
* This sizes — and *attributes* — what Grease buys on a Livewire initial render. A fully-
12+
* configured Testbench app times `Livewire::mount()` (hydrate the model from the DB, render the
13+
* component, dehydrate the `toArray()` payload — ISO dates, the loaded relation — to a checksummed
14+
* snapshot) across four corners: vanilla, +model trait only, +foundation tiers only, and the full
15+
* stack. The decomposition is the point: it shows the win is almost entirely the model trait, not
16+
* the container/view/event tiers (a single component resolve is a thin slice of a request). The
17+
* component is the UserCard fixture, whose state is a serialized model — so the date/`toArray`
18+
* tiers land squarely in the measured work.
1719
*
1820
* Each arm runs in its own subprocess (two full apps in one process collide on facade / static
1921
* state, and a greased-container arm needs a different Application class — same reason as
@@ -176,26 +178,47 @@ function snapshot_data(string $html): string
176178
return ['us' => $us[intdiv(count($us), 2)], 'data' => $data];
177179
};
178180

179-
echo "Booting Testbench + Livewire, vanilla vs greased ($rounds rounds × $iterations mounts)...\n\n";
180-
181-
$vanilla = $runArm(VanillaLivewireResolver::class, VanillaUserCard::class);
182-
$greased = $runArm(GreasedLivewireResolver::class, GreasedUserCard::class);
183-
184-
// ---- Parity gate: the dehydrated snapshot must be byte-identical ----------------
185-
186-
if ($vanilla['data'] !== $greased['data']) {
187-
echo "PARITY FAILED — dehydrated snapshot data differs:\n vanilla: {$vanilla['data']}\n greased: {$greased['data']}\n";
188-
exit(1);
181+
echo "Booting Testbench + Livewire, four corners ($rounds rounds × $iterations mounts each)...\n\n";
182+
183+
// The resolver controls the foundation tiers (container/view/events); the component class
184+
// controls the model tier (Greased vs Plain). The four corners isolate which tier the win
185+
// actually comes from — the surprise being that on a single-component mount it's almost all
186+
// the model trait, with the foundation tiers a thin slice on top (one component resolve is a
187+
// small fraction of a request — see the container guide).
188+
$baseline = $runArm(VanillaLivewireResolver::class, VanillaUserCard::class); // nothing greased
189+
$model = $runArm(VanillaLivewireResolver::class, GreasedUserCard::class); // + model trait only
190+
$foundation = $runArm(GreasedLivewireResolver::class, VanillaUserCard::class); // + container/view/events only
191+
$full = $runArm(GreasedLivewireResolver::class, GreasedUserCard::class); // everything
192+
193+
// ---- Parity gate: the greased snapshot must be byte-identical to vanilla ---------
194+
// This is the contract: a greased model serialized through Livewire must dehydrate exactly
195+
// like a vanilla one (same ISO dates, same decimal string, same relation), or the checksum
196+
// breaks. The model-only and full arms both carry the greased model, so both must match the
197+
// vanilla baseline byte-for-byte.
198+
199+
foreach (['model' => $model, 'full' => $full] as $label => $arm) {
200+
if ($baseline['data'] !== $arm['data']) {
201+
echo "PARITY FAILED ($label) — dehydrated snapshot data differs from vanilla:\n";
202+
echo " vanilla: {$baseline['data']}\n greased: {$arm['data']}\n";
203+
exit(1);
204+
}
189205
}
190-
echo "Parity: OK (dehydrated snapshot data byte-identical)\n\n";
206+
echo "Parity: OK (greased snapshot data byte-identical to vanilla)\n\n";
207+
208+
// ---- Report: each tier's contribution, baseline-relative ------------------------
191209

192-
// ---- Report --------------------------------------------------------------------
210+
$delta = fn (array $arm) => ($arm['us'] - $baseline['us']) / $baseline['us'] * 100;
193211

194-
$delta = ($greased['us'] - $vanilla['us']) / $vanilla['us'] * 100;
195-
printf("%-28s %12s %12s %8s\n", '', 'vanilla', 'greased', 'delta');
196-
printf("%-28s %10.2f µs %10.2f µs %+7.1f%%\n", 'Livewire::mount (render)', $vanilla['us'], $greased['us'], $delta);
212+
printf("%-34s %12s %8s\n", 'Livewire::mount', 'median', 'vs base');
213+
printf("%-34s %10.2f µs %8s\n", 'vanilla (baseline)', $baseline['us'], '');
214+
printf("%-34s %10.2f µs %+7.1f%%\n", '+ HasGrease on the model only', $model['us'], $delta($model));
215+
printf("%-34s %10.2f µs %+7.1f%%\n", '+ container/view/events only', $foundation['us'], $delta($foundation));
216+
printf("%-34s %10.2f µs %+7.1f%%\n", 'full greased stack', $full['us'], $delta($full));
197217

198-
echo "\nThe initial mount hydrates the model, renders the component, and dehydrates its\n";
199-
echo "toArray() payload into a checksummed snapshot — the model, view, and serialization\n";
200-
echo "tiers stacked. A subsequent update re-runs the same path, so this delta recurs on\n";
201-
echo "every interaction, not just first paint. macOS figures — confirm on Linux/docker.\n";
218+
echo "\nThe mount hydrates the model (+ its loaded relation) and dehydrates the toArray()\n";
219+
echo "payload into a checksummed snapshot — and the model trait alone carries nearly the\n";
220+
echo "whole delta, because Grease's per-instance overhead kill (reflection, class-attribute\n";
221+
echo "resolution, the initialize* booters) is a FIXED cost per `new Model()` that dominates\n";
222+
echo "for small rows. A Livewire update re-runs this path every interaction, so the delta\n";
223+
echo "recurs — not just on first paint. Confirm on your own stack (NOTES: macOS distorts;\n";
224+
echo "this also reproduces on Linux+JIT via benchmarks/docker).\n";

docs/guide/livewire.md

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,34 +58,49 @@ checks, across mount → action → update:
5858
Because the snapshots are identical, a mixed greased/vanilla fleet is safe: there's no
5959
boundary for a checksum to straddle.
6060

61-
## What it's worth
61+
## What it's worth — and where the win actually is
6262

6363
The same `benchmarks/livewire_ab.php` that gates the parity above then times a
64-
`Livewire::mount()` — the path that hydrates the model, renders the component, and
65-
dehydrates its `toArray()` payload into a checksummed snapshot — on the full greased stack
66-
(greased [container](/guide/container), [view](/guide/blade), and
67-
[event](/guide/events) tiers plus a greased model) versus vanilla.
68-
69-
On a Mac that lands around **−48%** on the mount path. Trust the direction, not the digits:
70-
as everywhere in these docs, [macOS distorts the magnitude](/guide/benchmarks) — reproduce
71-
it on your own stack:
64+
`Livewire::mount()` — hydrate the model, render the component, dehydrate its `toArray()`
65+
payload into a checksummed snapshot — across four corners, so you can see *which* tier the
66+
saving comes from rather than just a headline:
67+
68+
| | vs vanilla |
69+
|---|---|
70+
| `+ HasGrease` on the model **only** | **≈ −45 to −50%** |
71+
| `+` container / view / event tiers only | ≈ −0 to −5% |
72+
| full greased stack | ≈ −48 to −50% |
73+
74+
The surprise is that **the model trait alone carries nearly the whole delta** — the
75+
foundation tiers are a thin slice on top, because a single component resolve is a small
76+
fraction of the work (the [container tier](/guide/container) shaves a request elsewhere,
77+
not here). Why is one trait worth half a mount? Because Grease's model tier kills
78+
*per-instance* overhead — reflection, the [class-attribute resolution](/guide/how-it-works),
79+
the `initialize*` booters — which is a **fixed cost per `new Model()`** that doesn't shrink
80+
when the row is small. A Livewire mount hydrates the component's model **and its loaded
81+
relations** (the bench's fixture is one user + eight posts — nine models), so that fixed
82+
overhead is paid nine times and greasing it lands hard. It's the same mechanism behind the
83+
[`index_users` macro](/guide/benchmarks).
84+
85+
Trust the direction, not the digits. This reproduces at the same magnitude on both macOS and
86+
Linux+JIT (`benchmarks/docker`), but — in the spirit of the
87+
[Octane page](/guide/octane#why-there-s-no-benchmark-table-on-this-page) — the honest number
88+
is the one you measure on your own components and database:
7289

7390
```bash
7491
php benchmarks/livewire_ab.php
7592
```
7693

77-
And, in the spirit of the [Octane page](/guide/octane#why-there-s-no-benchmark-table-on-this-page):
78-
the honest number is the one you measure on your own components, against your own database,
79-
with your own worker model. A Livewire update re-runs this whole path on every interaction,
80-
so whatever delta you measure on mount recurs for the life of the page — not just on first
81-
paint.
94+
A Livewire update re-runs this whole path on every interaction, so whatever delta you measure
95+
on mount recurs for the life of the page — not just on first paint.
8296

8397
## Getting started
8498

8599
There's nothing Livewire-specific to install or configure. Add `HasGrease` to the
86-
[Eloquent models](/guide/getting-started) your components use, and — if you want the view
87-
and foundation tiers too — opt into the [Blade](/guide/blade),
88-
[container](/guide/container), and [event](/guide/events) tiers exactly as you would for a
89-
classic app. Livewire renders Blade and resolves through the container like everything else,
90-
so those tiers apply unchanged. The model trait alone is enough to justify the experiment on
91-
a component-heavy page; the rest is compounding upside.
100+
[Eloquent models](/guide/getting-started) your components use — that's it. The
101+
[Blade](/guide/blade), [container](/guide/container), and [event](/guide/events) tiers all
102+
apply unchanged (Livewire renders Blade and resolves through the container like everything
103+
else), and they're worth turning on — but the four-corner number above is unambiguous about
104+
the priority: **if you take nothing else from this package, put `HasGrease` on the models your
105+
components touch.** On a component-heavy page that single trait is where the request goes; the
106+
rest is compounding upside.

0 commit comments

Comments
 (0)