Skip to content

Commit 80e5e83

Browse files
committed
Benchmark compilation and rendering speed
| Library | Compile time | Runtime | Total time | Peak memory usage | |--------------------|--------------|---------|------------|-------------------| | LightnCandy 1.2.6 | 5.2 ms | 2.8 ms | 8.0 ms | 5.3 MB | | PHP Handlebars 0.9 | 5.4 ms | 2.5 ms | 7.9 ms | 5.1 MB | | PHP Handlebars 1.0 | 3.5 ms | 1.6 ms | 5.1 ms | 3.6 MB | Parses, compiles, and executes complex Handlebars templates ~40% faster than LightnCandy.
1 parent 6448719 commit 80e5e83

9 files changed

Lines changed: 582 additions & 0 deletions

File tree

tests/benchmark.php

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
<?php
2+
3+
/**
4+
* Compiler and runtime benchmark script. Default iterations: 1000.
5+
*
6+
* Usage: php -d opcache.enable_cli=1 -d opcache.jit=tracing tests/benchmark.php
7+
*/
8+
9+
use DevTheorem\Handlebars\Handlebars;
10+
use DevTheorem\Handlebars\HelperOptions;
11+
use DevTheorem\Handlebars\Options;
12+
13+
require __DIR__ . '/../vendor/autoload.php';
14+
15+
$iterations = (int) ($argv[1] ?? 1000);
16+
17+
// A large, complex template exercising as many syntax features as possible.
18+
$template = loadTemplate('large-page');
19+
$partialNames = ['alert', 'breadcrumbs', 'footer-col', 'nav-item', 'page-header', 'pagination', 'side-panel'];
20+
$partialTemplates = [];
21+
22+
foreach ($partialNames as $name) {
23+
$partialTemplates[$name] = loadTemplate($name);
24+
}
25+
26+
$translations = [
27+
'nav.profile' => 'Profile',
28+
'nav.settings' => 'Settings',
29+
'nav.admin' => 'Admin',
30+
'nav.logout' => 'Log Out',
31+
'nav.login' => 'Log In',
32+
'table.actions' => 'Actions',
33+
'table.empty' => 'No records found.',
34+
'pagination.label' => 'Page navigation',
35+
'pagination.prev' => 'Previous',
36+
'pagination.next' => 'Next',
37+
'pagination.showing' => 'Showing {start}–{end} of {total}',
38+
'edit' => 'Edit',
39+
'delete' => 'Delete',
40+
'confirm_delete' => 'Are you sure you want to delete this?',
41+
];
42+
43+
$helpers = [
44+
't' => function (string $key, HelperOptions $options) use ($translations) {
45+
$str = $translations[$key] ?? $key;
46+
foreach ($options->hash as $k => $v) {
47+
// for pagination.showing
48+
$str = str_replace('{' . $k . '}', (string) $v, $str);
49+
}
50+
return $str;
51+
},
52+
'formatDate' => function (mixed $value, string $format) {
53+
return date($format, strtotime($value));
54+
},
55+
'formatCurrency' => function (mixed $value, ?string $format) {
56+
return ($format ? "$format " : '') . number_format($value, 2);
57+
},
58+
'replace' => function (string $subject, string $search, ?string $replace) {
59+
return str_replace($search, $replace ?? '', $subject);
60+
},
61+
'eq' => function (mixed $a, mixed $b) {
62+
if ($a === null || $b === null) {
63+
// in JS, null is not equal to blank string or false or zero
64+
return $a === $b;
65+
}
66+
67+
return $a == $b;
68+
},
69+
'and' => fn(mixed $a, mixed $b) => $a && $b,
70+
'not' => fn(mixed $a) => !$a,
71+
'gt' => fn(mixed $a, mixed $b) => $a > $b,
72+
];
73+
74+
if (isset($argv[2]) && $argv[2] == 1) {
75+
$knownHelpers = array_fill_keys(array_keys($helpers), true);
76+
$knownHelpersOnly = true;
77+
} else {
78+
$knownHelpers = [];
79+
$knownHelpersOnly = false;
80+
}
81+
82+
$options = new Options(
83+
knownHelpers: $knownHelpers,
84+
knownHelpersOnly: $knownHelpersOnly,
85+
);
86+
87+
// Warm up: give the JIT a chance to compile hot paths before we measure.
88+
for ($i = 0; $i < 50; $i++) {
89+
Handlebars::precompile($template, $options);
90+
foreach ($partialTemplates as $src) {
91+
Handlebars::precompile($src, $options);
92+
}
93+
}
94+
95+
memory_reset_peak_usage();
96+
$start = hrtime(true);
97+
98+
for ($i = 0; $i < $iterations; $i++) {
99+
Handlebars::precompile($template, $options);
100+
foreach ($partialTemplates as $src) {
101+
Handlebars::precompile($src, $options);
102+
}
103+
}
104+
105+
$elapsed = (hrtime(true) - $start) / 1e9;
106+
$compilePeakMB = memory_get_peak_usage() / 1024 / 1024;
107+
$perParse = $elapsed / $iterations * 1000;
108+
$php = Handlebars::precompile($template, $options);
109+
$codeBytes = strlen($php);
110+
$partials = [];
111+
112+
foreach ($partialTemplates as $name => $src) {
113+
$code = Handlebars::precompile($src, $options);
114+
$codeBytes += strlen($code);
115+
$partials[$name] = Handlebars::template($code);
116+
}
117+
118+
printf(
119+
"Compiled %d times | %.2f ms/compile | %6.1f KB code | %.1f MB peak\n",
120+
$iterations,
121+
$perParse,
122+
$codeBytes / 1024,
123+
$compilePeakMB,
124+
);
125+
126+
$data = [
127+
'lang' => 'en',
128+
'pageTitle' => 'Dashboard',
129+
'siteName' => 'MyApp',
130+
'stylesheets' => [
131+
['url' => '/css/app.css'],
132+
['url' => '/css/print.css', 'media' => 'print'],
133+
],
134+
'bodyClass' => 'page-dashboard',
135+
'sticky' => true,
136+
'rootUrl' => '/',
137+
'logoHtml' => '<img src="/logo.svg" alt="">',
138+
'user' => [
139+
'id' => 1,
140+
'name' => 'Alice',
141+
'avatar' => '/avatars/alice.jpg',
142+
'isAdmin' => true,
143+
'verified' => true,
144+
],
145+
'navItems' => [
146+
['label' => 'Home', 'url' => '/', 'active' => true],
147+
['label' => 'Reports', 'url' => '/reports', 'badge' => '3'],
148+
['label' => 'More', 'url' => '#', 'icon' => 'chevron', 'children' => [
149+
['label' => 'Sub A', 'url' => '/a'],
150+
['label' => 'Sub B', 'url' => '/b'],
151+
]],
152+
],
153+
'alerts' => [
154+
['type' => 'success', 'message' => 'Saved!', 'dismissible' => true, 'icon' => 'check'],
155+
],
156+
'breadcrumbs' => [
157+
['label' => 'Home', 'url' => '/'],
158+
['label' => 'Orders', 'url' => '/orders'],
159+
['label' => 'List', 'url' => '/orders/list'],
160+
],
161+
'heading' => 'Orders',
162+
'headingBadge' => ['type' => 'primary', 'text' => 'Live'],
163+
'subheading' => 'All orders',
164+
'actions' => [
165+
['label' => 'New', 'url' => '/orders/new', 'primary' => true, 'icon' => 'plus'],
166+
],
167+
'hoverable' => true,
168+
'bordered' => false,
169+
'sortBaseUrl' => '/orders',
170+
'currentSort' => ['key' => 'date', 'dir' => 'asc'],
171+
'showActions' => true,
172+
'selectedIndex' => 2,
173+
'columnCount' => 5,
174+
'currency' => 'USD',
175+
'columns' => [
176+
['key' => 'id', 'label' => '#', 'sortable' => true, 'type' => 'text'],
177+
['key' => 'name', 'label' => 'Customer', 'type' => 'link', 'linkTemplate' => '/c/{id}'],
178+
['key' => 'created', 'label' => 'Date', 'sortable' => true, 'type' => 'date', 'format' => 'M j, Y'],
179+
['key' => 'total', 'label' => 'Total', 'type' => 'currency', 'showTotal' => true],
180+
['key' => 'active', 'label' => 'Active', 'type' => 'boolean'],
181+
],
182+
'items' => array_map(fn($i) => [
183+
'id' => (string) $i,
184+
'name' => "Customer $i",
185+
'created' => date('Y-m-d', mktime(0, 0, 0, (int) ceil($i / 28), (($i - 1) % 28) + 1, 2024) ?: null),
186+
'total' => 100.0 * $i,
187+
'active' => (bool) ($i % 2),
188+
'deleted' => false,
189+
'currency' => 'USD',
190+
], range(1, 100)),
191+
'rowActions' => [
192+
['icon' => 'edit', 'style' => 'secondary', 'labelKey' => 'edit', 'urlTemplate' => '/orders/{id}/edit', 'requiresAdmin' => false],
193+
['icon' => 'trash', 'style' => 'danger', 'labelKey' => 'delete', 'urlTemplate' => '/orders/{id}', 'confirm' => true, 'confirmKey' => 'confirm_delete', 'requiresAdmin' => true],
194+
],
195+
'showTotals' => true,
196+
'totals' => ['total' => 5500.00],
197+
'pagination' => [
198+
'hasPrev' => false,
199+
'hasNext' => true,
200+
'prevUrl' => '#',
201+
'nextUrl' => '/orders?page=2',
202+
'start' => 1,
203+
'end' => 10,
204+
'total' => 42,
205+
'pages' => [
206+
['active' => true, 'number' => 1, 'url' => '/orders'],
207+
['active' => false, 'number' => 2, 'url' => '/orders?page=2'],
208+
['ellipsis' => true, 'number' => null, 'url' => ''],
209+
['active' => false, 'number' => 5, 'url' => '/orders?page=5'],
210+
],
211+
],
212+
'sidePanels' => [
213+
[
214+
'id' => 'summary',
215+
'title' => 'Summary',
216+
'type' => 'stats',
217+
'collapsible' => true,
218+
'collapsed' => false,
219+
'stats' => [
220+
['label' => 'Total Orders', 'value' => 42, 'trend' => 'up', 'delta' => 5],
221+
['label' => 'Revenue', 'value' => '$5,500', 'unit' => 'USD', 'delta' => 0],
222+
],
223+
],
224+
],
225+
'footerColumns' => [
226+
['heading' => 'Product', 'links' => [
227+
['label' => 'Features', 'url' => '/features'],
228+
['label' => 'Pricing', 'url' => '/pricing'],
229+
]],
230+
['heading' => 'Legal', 'links' => [
231+
['label' => 'Privacy', 'url' => '/privacy'],
232+
['label' => 'Terms', 'url' => '/terms'],
233+
]],
234+
],
235+
'copyright' => '©',
236+
'showYear' => true,
237+
'currentYear' => 2024,
238+
'social' => [
239+
['name' => 'GitHub', 'url' => 'https://github.com/myapp', 'icon' => 'github'],
240+
],
241+
'scripts' => [
242+
['url' => '/js/vendor.js'],
243+
['url' => '/js/app.js', 'defer' => true],
244+
],
245+
];
246+
247+
$renderer = Handlebars::template($php);
248+
249+
// Warm up
250+
for ($i = 0; $i < 50; $i++) {
251+
$renderer($data, ['helpers' => $helpers, 'partials' => $partials]);
252+
}
253+
254+
memory_reset_peak_usage();
255+
$start = hrtime(true);
256+
257+
for ($i = 0; $i < $iterations; $i++) {
258+
$renderer($data, ['helpers' => $helpers, 'partials' => $partials]);
259+
}
260+
261+
$elapsed = (hrtime(true) - $start) / 1e9;
262+
$renderPeakMB = memory_get_peak_usage() / 1024 / 1024;
263+
$perRun = $elapsed / $iterations * 1000;
264+
$outputBytes = strlen($renderer($data, ['helpers' => $helpers, 'partials' => $partials]));
265+
266+
printf(
267+
"Executed %d times | %.2f ms/render | %6.1f KB output | %.1f MB peak\n",
268+
$iterations,
269+
$perRun,
270+
$outputBytes / 1024,
271+
$renderPeakMB,
272+
);
273+
274+
if (isset($argv[2])) {
275+
echo "<?php\n", $php, "\n";
276+
}
277+
278+
function loadTemplate(string $name): string
279+
{
280+
$filename = __DIR__ . "/templates/$name.hbs";
281+
$template = file_get_contents($filename);
282+
283+
if ($template === false) {
284+
exit("Failed to open $filename");
285+
}
286+
287+
return $template;
288+
}

tests/templates/alert.hbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div class="alert alert-{{type}} {{#if dismissible}}alert-dismissible{{/if}}" role="alert">
2+
{{#if icon}}<i class="icon-{{icon}}"></i>{{/if}}
3+
{{{message}}}
4+
{{#if dismissible}}<button type="button" class="close" data-dismiss="alert">&times;</button>{{/if}}
5+
</div>

tests/templates/breadcrumbs.hbs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{{#if breadcrumbs}}
2+
<nav aria-label="breadcrumb">
3+
<ol class="breadcrumb">
4+
{{#each breadcrumbs as |crumb idx|}}
5+
<li class="breadcrumb-item{{#if @last}} active{{/if}}">
6+
{{#if @last}}
7+
{{crumb.label}}
8+
{{else}}
9+
<a href="{{crumb.url}}">{{crumb.label}}</a>
10+
{{/if}}
11+
</li>
12+
{{/each}}
13+
</ol>
14+
</nav>
15+
{{/if}}

tests/templates/footer-col.hbs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div class="footer-col">
2+
{{#if heading}}<h5>{{heading}}</h5>{{/if}}
3+
<ul>
4+
{{#each links}}
5+
<li><a href="{{url}}"{{#if external}} target="_blank" rel="noopener noreferrer"{{/if}}>{{label}}</a></li>
6+
{{/each}}
7+
</ul>
8+
</div>

0 commit comments

Comments
 (0)