Skip to content

Commit 2692aff

Browse files
committed
Benchmark Mustache.php
| Library | Runtime | Runtime memory usage | |--------------------|---------|----------------------| | PHP Handlebars 2.0 | 1.0 ms | 2.0 MB | | Mustache.php 3.0 | 0.9 ms | 2.6 MB |
1 parent a48a254 commit 2692aff

9 files changed

Lines changed: 543 additions & 0 deletions

tests/mustachebenchmark.php

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
<?php
2+
3+
/**
4+
* Benchmark comparing PHP Handlebars (compat mode) with Mustache.php render performance.
5+
* Templates are precompiled once before measurement. Default iterations: 1000.
6+
*
7+
* Usage: php -d opcache.enable_cli=1 -d opcache.jit=tracing tests/mustachebenchmark.php
8+
*/
9+
10+
use DevTheorem\Handlebars\Handlebars;
11+
use DevTheorem\Handlebars\Options;
12+
use Mustache\Cache\NoopCache;
13+
use Mustache\Engine;
14+
15+
require __DIR__ . '/../vendor/autoload.php';
16+
17+
$iterations = (int) ($argv[1] ?? 1000);
18+
19+
$template = loadMustacheTemplate('mustache-page');
20+
$partialNames = [
21+
'mustache-nav-item',
22+
'mustache-alert',
23+
'mustache-breadcrumbs',
24+
'mustache-page-header',
25+
'mustache-pagination',
26+
'mustache-side-panel',
27+
'mustache-footer-col',
28+
];
29+
$partialTemplates = [];
30+
31+
foreach ($partialNames as $name) {
32+
$partialTemplates[$name] = loadMustacheTemplate($name);
33+
}
34+
35+
$data = buildData();
36+
37+
// ==================== PHP Handlebars (compat mode) ====================
38+
39+
echo "=== PHP Handlebars (compat mode) ===\n";
40+
41+
$options = new Options(compat: true);
42+
43+
$php = Handlebars::precompile($template, $options);
44+
$hbsPartials = [];
45+
46+
foreach ($partialTemplates as $name => $src) {
47+
$hbsPartials[$name] = Handlebars::compile($src, $options);
48+
}
49+
50+
$hbsRenderer = Handlebars::template($php);
51+
52+
// Warm up: give the JIT a chance to compile hot paths before we measure.
53+
for ($i = 0; $i < 50; $i++) {
54+
$hbsRenderer($data, ['partials' => $hbsPartials]);
55+
}
56+
57+
memory_reset_peak_usage();
58+
$start = hrtime(true);
59+
60+
for ($i = 0; $i < $iterations; $i++) {
61+
$hbsRenderer($data, ['partials' => $hbsPartials]);
62+
}
63+
64+
$elapsed = (hrtime(true) - $start) / 1e9;
65+
$renderPeakMB = memory_get_peak_usage() / 1024 / 1024;
66+
$perRender = $elapsed / $iterations * 1000;
67+
$outputBytes = strlen($hbsRenderer($data, ['partials' => $hbsPartials]));
68+
69+
printf(
70+
"Rendered %d times | %.2f ms/render | %6.1f KB output | %.1f MB peak\n",
71+
$iterations,
72+
$perRender,
73+
$outputBytes / 1024,
74+
$renderPeakMB,
75+
);
76+
77+
// ==================== Mustache.php ====================
78+
79+
echo "\n=== Mustache.php ===\n";
80+
81+
$mustache = new Engine([
82+
'cache' => new NoopCache(),
83+
'partials' => $partialTemplates,
84+
]);
85+
86+
$mustacheTpl = $mustache->loadTemplate($template);
87+
88+
// Warm up
89+
for ($i = 0; $i < 50; $i++) {
90+
$mustacheTpl->render($data);
91+
}
92+
93+
memory_reset_peak_usage();
94+
$start = hrtime(true);
95+
96+
for ($i = 0; $i < $iterations; $i++) {
97+
$mustacheTpl->render($data);
98+
}
99+
100+
$elapsed = (hrtime(true) - $start) / 1e9;
101+
$renderPeakMB = memory_get_peak_usage() / 1024 / 1024;
102+
$perRender = $elapsed / $iterations * 1000;
103+
$outputBytes = strlen($mustacheTpl->render($data));
104+
105+
printf(
106+
"Rendered %d times | %.2f ms/render | %6.1f KB output | %.1f MB peak\n",
107+
$iterations,
108+
$perRender,
109+
$outputBytes / 1024,
110+
$renderPeakMB,
111+
);
112+
113+
if (isset($argv[2])) {
114+
file_put_contents(__DIR__ . '/hbs_out.html', $hbsRenderer($data, ['partials' => $hbsPartials]));
115+
file_put_contents(__DIR__ . '/mus_out.html', $mustacheTpl->render($data));
116+
}
117+
118+
/**
119+
* @return array<mixed>
120+
*/
121+
function buildData(): array
122+
{
123+
$columns = [
124+
['key' => 'id', 'label' => '#', 'sortable' => true, 'thClass' => 'sortable', 'sortUrl' => '/orders?sort=id&dir=asc'],
125+
['key' => 'name', 'label' => 'Customer', 'sortable' => false, 'thClass' => ''],
126+
['key' => 'created', 'label' => 'Date', 'sortable' => true, 'thClass' => 'sortable sort-asc', 'sortUrl' => '/orders?sort=created&dir=desc'],
127+
['key' => 'total', 'label' => 'Total', 'sortable' => false, 'thClass' => ''],
128+
['key' => 'active', 'label' => 'Active', 'sortable' => false, 'thClass' => ''],
129+
];
130+
131+
$rawItems = array_map(fn($i) => [
132+
'id' => (string) $i,
133+
'name' => "Customer $i",
134+
'created' => date('Y-m-d', mktime(0, 0, 0, (int) ceil($i / 28), (($i - 1) % 28) + 1, 2024) ?: 0),
135+
'total' => 100.0 * $i,
136+
'active' => (bool) ($i % 2),
137+
], range(1, 100));
138+
139+
$rows = array_map(function (array $item, int $idx) use ($columns): array {
140+
$cells = array_map(fn(array $col) => [
141+
'cellClass' => 'col-' . $col['key'],
142+
'content' => match ($col['key']) {
143+
'name' => '<a href="/c/' . htmlspecialchars($item['id']) . '">' . htmlspecialchars($item['name']) . '</a>',
144+
'created' => '<time datetime="' . htmlspecialchars($item['created']) . '">' . date('M j, Y', strtotime($item['created']) ?: null) . '</time>',
145+
'total' => 'USD ' . number_format($item['total'], 2),
146+
'active' => $item['active'] ? '<i class="icon-check text-success"></i>' : '<i class="icon-times text-muted"></i>',
147+
default => htmlspecialchars((string) $item[$col['key']]),
148+
},
149+
], $columns);
150+
151+
return [
152+
'id' => $item['id'],
153+
'rowClass' => $idx === 2 ? 'selected' : '',
154+
'hasActions' => true,
155+
'cells' => $cells,
156+
'actions' => [
157+
[
158+
'url' => '/orders/' . $item['id'] . '/edit',
159+
'style' => 'secondary',
160+
'label' => 'Edit',
161+
'icon' => 'edit',
162+
'confirm' => false,
163+
],
164+
[
165+
'url' => '/orders/' . $item['id'],
166+
'style' => 'danger',
167+
'label' => 'Delete',
168+
'icon' => 'trash',
169+
'confirm' => true,
170+
'confirmMessage' => 'Are you sure you want to delete this?',
171+
],
172+
],
173+
];
174+
}, $rawItems, array_keys($rawItems));
175+
176+
return [
177+
'lang' => 'en',
178+
'pageTitle' => 'Dashboard | MyApp',
179+
'siteName' => 'MyApp',
180+
'stylesheets' => [
181+
['url' => '/css/app.css'],
182+
['url' => '/css/print.css', 'media' => 'print'],
183+
],
184+
'bodyClass' => 'page-dashboard is-admin',
185+
'sticky' => true,
186+
'rootUrl' => '/',
187+
'logoHtml' => '<img src="/logo.svg" alt="">',
188+
// Labels at root — accessed via compat/stack-lookup from within {{#user}}
189+
'loginLabel' => 'Log In',
190+
'profileLabel' => 'Profile',
191+
'settingsLabel' => 'Settings',
192+
'adminLabel' => 'Admin',
193+
'logoutLabel' => 'Log Out',
194+
'user' => [
195+
'id' => 1,
196+
'name' => 'Alice',
197+
'avatar' => '/avatars/alice.jpg',
198+
'isAdmin' => true,
199+
],
200+
'navItems' => [
201+
['label' => 'Home', 'url' => '/', 'active' => true, 'hasChildren' => false],
202+
['label' => 'Reports', 'url' => '/reports', 'active' => false, 'badge' => '3', 'hasChildren' => false],
203+
['label' => 'More', 'url' => '#', 'active' => false, 'icon' => 'chevron', 'hasChildren' => true, 'children' => [
204+
['label' => 'Sub A', 'url' => '/a'],
205+
['label' => 'Sub B', 'url' => '/b'],
206+
]],
207+
],
208+
'alerts' => [
209+
['type' => 'success', 'message' => 'Saved successfully!', 'dismissible' => true, 'icon' => 'check'],
210+
],
211+
'hasBreadcrumbs' => true,
212+
'breadcrumbs' => [
213+
['label' => 'Home', 'url' => '/', 'isLink' => true, 'active' => false],
214+
['label' => 'Orders', 'url' => '/orders', 'isLink' => true, 'active' => false],
215+
['label' => 'List', 'url' => '', 'isLink' => false, 'active' => true],
216+
],
217+
'heading' => 'Orders',
218+
'headingBadge' => ['type' => 'primary', 'text' => 'Live'],
219+
'subheading' => 'All orders',
220+
'hasPageActions' => true,
221+
'pageActions' => [
222+
['label' => 'New Order', 'url' => '/orders/new', 'style' => 'primary', 'icon' => 'plus'],
223+
],
224+
'hasItems' => true,
225+
'tableClass' => 'table table-striped table-hover',
226+
'columns' => $columns,
227+
'showActions' => true,
228+
'actionsLabel' => 'Actions',
229+
'currency' => 'USD', // accessed via compat/stack-lookup from within {{#rows}}
230+
'rows' => $rows,
231+
'showTotals' => true,
232+
'totalCells' => [
233+
['content' => ''],
234+
['content' => ''],
235+
['content' => ''],
236+
['content' => 'USD ' . number_format(5500.00, 2)],
237+
['content' => ''],
238+
],
239+
'pagination' => [
240+
'label' => 'Page navigation',
241+
'hasPrev' => false,
242+
'hasNext' => true,
243+
'prevUrl' => '#',
244+
'prevLabel' => 'Previous',
245+
'nextUrl' => '/orders?page=2',
246+
'nextLabel' => 'Next',
247+
'showingText' => 'Showing 1–10 of 42',
248+
'pages' => [
249+
['active' => true, 'ellipsis' => false, 'number' => 1, 'url' => '/orders'],
250+
['active' => false, 'ellipsis' => false, 'number' => 2, 'url' => '/orders?page=2'],
251+
['active' => false, 'ellipsis' => true, 'number' => null, 'url' => ''],
252+
['active' => false, 'ellipsis' => false, 'number' => 5, 'url' => '/orders?page=5'],
253+
],
254+
],
255+
'sidePanels' => [
256+
[
257+
'id' => 'summary',
258+
'title' => 'Summary',
259+
'isStats' => true,
260+
'isLinks' => false,
261+
'isHtml' => false,
262+
'collapsible' => true,
263+
'collapsed' => false,
264+
'ariaExpanded' => 'true',
265+
'stats' => [
266+
['label' => 'Total Orders', 'value' => 42, 'trend' => 'up', 'hasDelta' => true, 'delta' => 5],
267+
['label' => 'Revenue', 'value' => '$5,500', 'unit' => 'USD', 'hasDelta' => false, 'delta' => 0],
268+
],
269+
],
270+
],
271+
'footerColumns' => [
272+
['heading' => 'Product', 'links' => [
273+
['label' => 'Features', 'url' => '/features'],
274+
['label' => 'Pricing', 'url' => '/pricing'],
275+
]],
276+
['heading' => 'Legal', 'links' => [
277+
['label' => 'Privacy', 'url' => '/privacy'],
278+
['label' => 'Terms', 'url' => '/terms'],
279+
]],
280+
],
281+
'copyright' => '©',
282+
'currentYear' => 2024,
283+
'socialLinks' => [
284+
'items' => [
285+
['name' => 'GitHub', 'url' => 'https://github.com/myapp', 'icon' => 'github'],
286+
['name' => 'Twitter', 'url' => 'https://twitter.com/myapp', 'icon' => 'twitter'],
287+
],
288+
],
289+
'scripts' => [
290+
['url' => '/js/vendor.js'],
291+
['url' => '/js/app.js', 'defer' => true],
292+
],
293+
'emptyHeading' => 'No orders found',
294+
'emptyMessage' => 'Try adjusting your filters.',
295+
];
296+
}
297+
298+
function loadMustacheTemplate(string $name): string
299+
{
300+
$filename = __DIR__ . "/templates/{$name}.mustache";
301+
$template = file_get_contents($filename);
302+
303+
if ($template === false) {
304+
exit("Failed to open {$filename}");
305+
}
306+
307+
return $template;
308+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div class="alert alert-{{type}}{{#dismissible}} alert-dismissible fade show{{/dismissible}}" role="alert">
2+
{{#icon}}<i class="icon-{{icon}}"></i> {{/icon}}{{{message}}}
3+
{{#dismissible}}
4+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">&times;</button>
5+
{{/dismissible}}
6+
</div>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{{#hasBreadcrumbs}}
2+
<nav aria-label="breadcrumb">
3+
<ol class="breadcrumb">
4+
{{#breadcrumbs}}
5+
<li class="breadcrumb-item{{#active}} active{{/active}}">
6+
{{#isLink}}<a href="{{url}}">{{label}}</a>{{/isLink}}
7+
{{^isLink}}<span>{{label}}</span>{{/isLink}}
8+
</li>
9+
{{/breadcrumbs}}
10+
</ol>
11+
</nav>
12+
{{/hasBreadcrumbs}}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div class="footer-col">
2+
<h4>{{heading}}</h4>
3+
<ul>
4+
{{#links}}
5+
<li><a href="{{url}}">{{label}}</a></li>
6+
{{/links}}
7+
</ul>
8+
</div>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<li class="nav-item{{#active}} active{{/active}}">
2+
<a href="{{url}}">{{#icon}}<i class="icon-{{icon}}"></i> {{/icon}}{{label}}{{#badge}} <span class="badge">{{badge}}</span>{{/badge}}</a>
3+
{{#hasChildren}}
4+
<ul class="dropdown">
5+
{{#children}}
6+
<li><a href="{{url}}">{{label}}</a></li>
7+
{{/children}}
8+
</ul>
9+
{{/hasChildren}}
10+
</li>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div class="page-header">
2+
<div class="page-header-content">
3+
<h1>{{heading}}{{#headingBadge}} <span class="badge badge-{{type}}">{{text}}</span>{{/headingBadge}}</h1>
4+
{{#subheading}}<p class="text-muted">{{subheading}}</p>{{/subheading}}
5+
</div>
6+
{{#hasPageActions}}
7+
<div class="page-header-actions">
8+
{{#pageActions}}
9+
<a href="{{url}}" class="btn btn-{{style}}">
10+
{{#icon}}<i class="icon-{{icon}}"></i> {{/icon}}{{label}}
11+
</a>
12+
{{/pageActions}}
13+
</div>
14+
{{/hasPageActions}}
15+
</div>

0 commit comments

Comments
 (0)