Skip to content

Commit f3ebfad

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 f3ebfad

9 files changed

Lines changed: 570 additions & 0 deletions

tests/mustachebenchmark.php

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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+
function buildData(): array
119+
{
120+
$sortKey = 'date';
121+
$sortDir = 'asc';
122+
$sortBaseUrl = '/orders';
123+
124+
$columns = [
125+
['key' => 'id', 'label' => '#', 'sortable' => true],
126+
['key' => 'name', 'label' => 'Customer', 'sortable' => false],
127+
['key' => 'created', 'label' => 'Date', 'sortable' => true],
128+
['key' => 'total', 'label' => 'Total', 'sortable' => false],
129+
['key' => 'active', 'label' => 'Active', 'sortable' => false],
130+
];
131+
132+
foreach ($columns as &$col) {
133+
$isSortActive = $col['key'] === $sortKey;
134+
$thClass = $col['sortable'] ? 'sortable' : '';
135+
if ($isSortActive) {
136+
$thClass .= ($thClass ? ' ' : '') . 'sort-' . $sortDir;
137+
}
138+
$col['thClass'] = $thClass;
139+
if ($col['sortable']) {
140+
$newDir = ($isSortActive && $sortDir === 'asc') ? 'desc' : 'asc';
141+
$col['sortUrl'] = "{$sortBaseUrl}?sort={$col['key']}&dir={$newDir}";
142+
}
143+
}
144+
unset($col);
145+
146+
$selectedIndex = 2;
147+
$currency = 'USD';
148+
149+
$rawItems = array_map(fn($i) => [
150+
'id' => (string) $i,
151+
'name' => "Customer $i",
152+
'created' => date('Y-m-d', mktime(0, 0, 0, (int) ceil($i / 28), (($i - 1) % 28) + 1, 2024) ?: 0),
153+
'total' => 100.0 * $i,
154+
'active' => (bool) ($i % 2),
155+
'deleted' => false,
156+
], range(1, 100));
157+
158+
$rows = array_map(function (array $item, int $idx) use ($selectedIndex, $columns): array {
159+
$classes = [];
160+
if ($item['deleted']) {
161+
$classes[] = 'deleted';
162+
}
163+
if ($idx === $selectedIndex) {
164+
$classes[] = 'selected';
165+
}
166+
167+
$cells = array_map(fn(array $col) => [
168+
'cellClass' => 'col-' . $col['key'],
169+
'content' => match ($col['key']) {
170+
'name' => '<a href="/c/' . htmlspecialchars($item['id']) . '">' . htmlspecialchars($item['name']) . '</a>',
171+
'created' => '<time datetime="' . htmlspecialchars($item['created']) . '">' . date('M j, Y', strtotime($item['created'])) . '</time>',
172+
'total' => 'USD ' . number_format($item['total'], 2),
173+
'active' => $item['active'] ? '<i class="icon-check text-success"></i>' : '<i class="icon-times text-muted"></i>',
174+
default => htmlspecialchars((string) ($item[$col['key']] ?? '')),
175+
},
176+
], $columns);
177+
178+
return [
179+
'id' => $item['id'],
180+
'rowClass' => implode(' ', $classes),
181+
'hasActions' => true,
182+
'cells' => $cells,
183+
'actions' => [
184+
[
185+
'url' => '/orders/' . $item['id'] . '/edit',
186+
'style' => 'secondary',
187+
'label' => 'Edit',
188+
'icon' => 'edit',
189+
'confirm' => false,
190+
],
191+
[
192+
'url' => '/orders/' . $item['id'],
193+
'style' => 'danger',
194+
'label' => 'Delete',
195+
'icon' => 'trash',
196+
'confirm' => true,
197+
'confirmMessage' => 'Are you sure you want to delete this?',
198+
],
199+
],
200+
];
201+
}, $rawItems, array_keys($rawItems));
202+
203+
return [
204+
'lang' => 'en',
205+
'pageTitle' => 'Dashboard | MyApp',
206+
'siteName' => 'MyApp',
207+
'stylesheets' => [
208+
['url' => '/css/app.css'],
209+
['url' => '/css/print.css', 'media' => 'print'],
210+
],
211+
'bodyClass' => 'page-dashboard is-admin',
212+
'sticky' => true,
213+
'rootUrl' => '/',
214+
'logoHtml' => '<img src="/logo.svg" alt="">',
215+
// Labels at root — accessed via compat/stack-lookup from within {{#user}}
216+
'loginLabel' => 'Log In',
217+
'profileLabel' => 'Profile',
218+
'settingsLabel' => 'Settings',
219+
'adminLabel' => 'Admin',
220+
'logoutLabel' => 'Log Out',
221+
'user' => [
222+
'id' => 1,
223+
'name' => 'Alice',
224+
'avatar' => '/avatars/alice.jpg',
225+
'isAdmin' => true,
226+
],
227+
'navItems' => [
228+
['label' => 'Home', 'url' => '/', 'active' => true, 'hasChildren' => false],
229+
['label' => 'Reports', 'url' => '/reports', 'active' => false, 'badge' => '3', 'hasChildren' => false],
230+
['label' => 'More', 'url' => '#', 'active' => false, 'icon' => 'chevron', 'hasChildren' => true, 'children' => [
231+
['label' => 'Sub A', 'url' => '/a'],
232+
['label' => 'Sub B', 'url' => '/b'],
233+
]],
234+
],
235+
'alerts' => [
236+
['type' => 'success', 'message' => 'Saved successfully!', 'dismissible' => true, 'icon' => 'check'],
237+
],
238+
'hasBreadcrumbs' => true,
239+
'breadcrumbs' => [
240+
['label' => 'Home', 'url' => '/', 'isLink' => true, 'active' => false],
241+
['label' => 'Orders', 'url' => '/orders', 'isLink' => true, 'active' => false],
242+
['label' => 'List', 'url' => '', 'isLink' => false, 'active' => true],
243+
],
244+
'heading' => 'Orders',
245+
'headingBadge' => ['type' => 'primary', 'text' => 'Live'],
246+
'subheading' => 'All orders',
247+
'hasPageActions' => true,
248+
'pageActions' => [
249+
['label' => 'New Order', 'url' => '/orders/new', 'style' => 'primary', 'icon' => 'plus'],
250+
],
251+
'hasItems' => true,
252+
'tableClass' => 'table table-striped table-hover',
253+
'columns' => $columns,
254+
'showActions' => true,
255+
'actionsLabel' => 'Actions',
256+
'currency' => $currency, // accessed via compat/stack-lookup from within {{#rows}}
257+
'rows' => $rows,
258+
'showTotals' => true,
259+
'totalCells' => [
260+
['content' => ''],
261+
['content' => ''],
262+
['content' => ''],
263+
['content' => 'USD ' . number_format(5500.00, 2)],
264+
['content' => ''],
265+
],
266+
'pagination' => [
267+
'label' => 'Page navigation',
268+
'hasPrev' => false,
269+
'hasNext' => true,
270+
'prevUrl' => '#',
271+
'prevLabel' => 'Previous',
272+
'nextUrl' => '/orders?page=2',
273+
'nextLabel' => 'Next',
274+
'showingText' => 'Showing 1–10 of 42',
275+
'pages' => [
276+
['active' => true, 'ellipsis' => false, 'number' => 1, 'url' => '/orders'],
277+
['active' => false, 'ellipsis' => false, 'number' => 2, 'url' => '/orders?page=2'],
278+
['active' => false, 'ellipsis' => true, 'number' => null, 'url' => ''],
279+
['active' => false, 'ellipsis' => false, 'number' => 5, 'url' => '/orders?page=5'],
280+
],
281+
],
282+
'sidePanels' => [
283+
[
284+
'id' => 'summary',
285+
'title' => 'Summary',
286+
'isStats' => true,
287+
'isLinks' => false,
288+
'isHtml' => false,
289+
'collapsible' => true,
290+
'collapsed' => false,
291+
'ariaExpanded' => 'true',
292+
'stats' => [
293+
['label' => 'Total Orders', 'value' => 42, 'trend' => 'up', 'hasDelta' => true, 'delta' => 5],
294+
['label' => 'Revenue', 'value' => '$5,500', 'unit' => 'USD', 'hasDelta' => false, 'delta' => 0],
295+
],
296+
],
297+
],
298+
'footerColumns' => [
299+
['heading' => 'Product', 'links' => [
300+
['label' => 'Features', 'url' => '/features'],
301+
['label' => 'Pricing', 'url' => '/pricing'],
302+
]],
303+
['heading' => 'Legal', 'links' => [
304+
['label' => 'Privacy', 'url' => '/privacy'],
305+
['label' => 'Terms', 'url' => '/terms'],
306+
]],
307+
],
308+
'copyright' => '©',
309+
'currentYear' => 2024,
310+
'socialLinks' => [
311+
'items' => [
312+
['name' => 'GitHub', 'url' => 'https://github.com/myapp', 'icon' => 'github'],
313+
['name' => 'Twitter', 'url' => 'https://twitter.com/myapp', 'icon' => 'twitter'],
314+
],
315+
],
316+
'scripts' => [
317+
['url' => '/js/vendor.js'],
318+
['url' => '/js/app.js', 'defer' => true],
319+
],
320+
'emptyHeading' => 'No orders found',
321+
'emptyMessage' => 'Try adjusting your filters.',
322+
];
323+
}
324+
325+
function loadMustacheTemplate(string $name): string
326+
{
327+
$filename = __DIR__ . "/templates/{$name}.mustache";
328+
$template = file_get_contents($filename);
329+
330+
if ($template === false) {
331+
exit("Failed to open {$filename}");
332+
}
333+
334+
return $template;
335+
}
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)