Skip to content

Commit 2b2621d

Browse files
committed
Benchmark LightnCandy
1 parent 410d84e commit 2b2621d

File tree

4 files changed

+346
-12
lines changed

4 files changed

+346
-12
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
],
2020
"require": {
2121
"php": ">=8.2",
22-
"devtheorem/php-handlebars-parser": "^1.1.0"
22+
"devtheorem/php-handlebars-parser": "^1.1.0",
23+
"zordius/lightncandy": "^1.2"
2324
},
2425
"require-dev": {
2526
"friendsofphp/php-cs-fixer": "^3.94",

composer.lock

Lines changed: 57 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/lncbenchmark.php

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

tests/templates/large-page.hbs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,28 +78,28 @@
7878
</tr>
7979
</thead>
8080
<tbody>
81-
{{#each items as |item i|}}
82-
<tr class="{{#if item.deleted}}deleted{{/if}} {{#if (eq @index ../selectedIndex)}}selected{{/if}}" data-id="{{item.id}}">
81+
{{#each items}}
82+
<tr class="{{#if deleted}}deleted{{/if}} {{#if (eq @index ../selectedIndex)}}selected{{/if}}" data-id="{{id}}">
8383
{{#each ../columns}}
84-
<td class="col-{{key}}{{#if item.highlighted}} highlighted{{/if}}">
84+
<td class="col-{{key}}{{#if ../highlighted}} highlighted{{/if}}">
8585
{{#if (eq type "boolean")}}
86-
{{#if (lookup item key)}}<i class="icon-check text-success"></i>{{else}}<i class="icon-times text-muted"></i>{{/if}}
86+
{{#if (lookup .. key)}}<i class="icon-check text-success"></i>{{else}}<i class="icon-times text-muted"></i>{{/if}}
8787
{{else if (eq type "date")}}
88-
<time datetime="{{formatDate (lookup item key) "Y-m-d"}}">{{formatDate (lookup item key) format}}</time>
88+
<time datetime="{{formatDate (lookup .. key) "Y-m-d"}}">{{formatDate (lookup .. key) format}}</time>
8989
{{else if (eq type "currency")}}
90-
{{formatCurrency (lookup item key) ../currency}}
90+
{{formatCurrency (lookup .. key) ../../currency}}
9191
{{else if (eq type "link")}}
92-
<a href="{{replace linkTemplate "{id}" item.id}}">{{lookup item key}}</a>
92+
<a href="{{replace linkTemplate "{id}" ../id}}">{{lookup .. key}}</a>
9393
{{else}}
94-
{{lookup item key}}
94+
{{lookup .. key}}
9595
{{!--- avoid extra whitespace ---}}{{/if}}
9696
</td>
9797
{{/each}}
9898
{{#if ../showActions}}
9999
<td class="actions">
100100
{{#each ../rowActions}}
101-
{{#unless (and requiresAdmin (not item.isAdmin))}}
102-
<a href="{{replace urlTemplate "{id}" item.id}}"
101+
{{#unless (and requiresAdmin (not ../isAdmin))}}
102+
<a href="{{replace urlTemplate "{id}" ../id}}"
103103
class="btn btn-sm btn-{{style}}"
104104
{{#if confirm}}data-confirm="{{t confirmKey}}"{{/if}}
105105
title="{{t labelKey}}">

0 commit comments

Comments
 (0)