Skip to content

Commit e595b60

Browse files
committed
ci: add manual speed-matrix workflow for hot-path A/B benchmarks
Run from the Actions tab, or: gh workflow run speed-matrix.yml. Never runs on push. - Benchmarks 17 optimization candidates for the SmartString output path: encode fast paths, SmartArray __get shapes, property typing, output idioms - Covers PHP 8.1-8.5 on Linux x64/ARM, Windows x64, macOS Intel/ARM (~25 cells) - Reports B-vs-A ratios from interleaved same-process runs, so noisy shared runners still compare fairly; within 5% reads as a tie - Refuses to time any encoder that isn't byte-identical to htmlspecialchars across a 106k-string corpus (invalid UTF-8, noncharacters, every 2-byte string) - Summary job merges all cells into one grid on the run page - Inputs narrow the run: os, php, jit, iteration scale, test ids - No Windows ARM cell: php.net ships no ARM64 Windows builds
1 parent 6ffeba3 commit e595b60

5 files changed

Lines changed: 1006 additions & 0 deletions

File tree

.github/scripts/speed-corpus.php

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Correctness corpus for encoding fast-path candidates.
6+
*
7+
* Any alternative encoder must produce byte-identical output to
8+
* htmlspecialchars($s, ENT_QUOTES|ENT_SUBSTITUTE|ENT_DISALLOWED|ENT_HTML5, 'UTF-8')
9+
* for every string this corpus generates. speed-probe.php runs this gate before
10+
* timing anything; a future PHPUnit test can wrap it when a fast path ships.
11+
*
12+
* Corpus derived from the adversarial verifier run of 2026-07-18 (~91k entries):
13+
* empty string, all 256 single bytes, ALL 65,536 two-byte strings, every byte at
14+
* head/mid/tail of clean ASCII, valid multibyte incl. emoji and combining marks,
15+
* all 66 Unicode noncharacters, overlongs, surrogate halves, truncated sequences,
16+
* stray continuation bytes, the five specials in context, long strings with a bad
17+
* byte at head/middle/tail, whitespace combos, and 40,000 seeded fuzz strings.
18+
*
19+
* No extension requirements: UTF-8 is encoded by hand (mbstring may be absent),
20+
* fuzz uses a fixed mt_srand seed so every run tests the identical corpus.
21+
*/
22+
23+
const SPEED_CORPUS_FLAGS = ENT_QUOTES | ENT_SUBSTITUTE | ENT_DISALLOWED | ENT_HTML5;
24+
25+
/**
26+
* Encode a code point to UTF-8 bytes without mbstring.
27+
*/
28+
function speed_corpus_u8(int $cp): string
29+
{
30+
if ($cp < 0x80) {
31+
return chr($cp);
32+
}
33+
if ($cp < 0x800) {
34+
return chr(0xC0 | $cp >> 6) . chr(0x80 | $cp & 0x3F);
35+
}
36+
if ($cp < 0x10000) {
37+
return chr(0xE0 | $cp >> 12) . chr(0x80 | ($cp >> 6) & 0x3F) . chr(0x80 | $cp & 0x3F);
38+
}
39+
return chr(0xF0 | $cp >> 18) . chr(0x80 | ($cp >> 12) & 0x3F) . chr(0x80 | ($cp >> 6) & 0x3F) . chr(0x80 | $cp & 0x3F);
40+
}
41+
42+
/**
43+
* Build the full corpus. ~91k strings, a few MB; generate once per process.
44+
*
45+
* @return string[]
46+
*/
47+
function speed_corpus(): array
48+
{
49+
$corpus = [''];
50+
51+
// All 256 single bytes
52+
for ($b = 0; $b <= 0xFF; $b++) {
53+
$corpus[] = chr($b);
54+
}
55+
56+
// ALL 65,536 two-byte strings (partial multibyte, C1-as-latin1, etc.)
57+
for ($a = 0; $a <= 0xFF; $a++) {
58+
$ca = chr($a);
59+
for ($b = 0; $b <= 0xFF; $b++) {
60+
$corpus[] = $ca . chr($b);
61+
}
62+
}
63+
64+
// Each byte at head / mid / tail of clean ASCII (position sensitivity)
65+
for ($b = 0; $b <= 0xFF; $b++) {
66+
$corpus[] = "Hello " . chr($b) . " World";
67+
$corpus[] = chr($b) . " leading";
68+
$corpus[] = "trailing " . chr($b);
69+
}
70+
71+
// Valid multibyte UTF-8: 2/3/4-byte sequences, emoji, combining marks, BOM, zero-width
72+
$mb = ["caf\u{E9}", "\u{4E2D}\u{6587}", "\u{1F600}\u{1F4A9}", "e\u{0301}", "\u{FEFF}",
73+
"\u{00A0}", "\u{2028}\u{2029}", "\u{FFFD}", "\u{10FFFF}", "\u{E000}", "\u{200B}"];
74+
foreach ($mb as $s) {
75+
$corpus[] = $s;
76+
$corpus[] = "pre $s post";
77+
}
78+
79+
// Noncharacters: U+FDD0..U+FDEF, and U+xFFFE/U+xFFFF in every plane
80+
for ($cp = 0xFDD0; $cp <= 0xFDEF; $cp++) {
81+
$corpus[] = speed_corpus_u8($cp) . " tail";
82+
}
83+
for ($plane = 0; $plane <= 0x10; $plane++) {
84+
$corpus[] = "a" . speed_corpus_u8($plane * 0x10000 + 0xFFFE);
85+
$corpus[] = "a" . speed_corpus_u8($plane * 0x10000 + 0xFFFF);
86+
}
87+
88+
// Invalid UTF-8: overlongs, surrogate halves (CESU-8), truncations, stray continuations
89+
$invalid = [
90+
"\xC0\xAF", "\xC1\xBF", // overlong '/'
91+
"\xE0\x80\xAF", "\xF0\x80\x80\xAF", // more overlongs
92+
"\xED\xA0\x80", "\xED\xBF\xBF", // surrogate halves
93+
"\xF4\x90\x80\x80", // > U+10FFFF
94+
"\xC3", "\xE2\x82", "\xF0\x9F\x98", // truncated sequences
95+
"\x80", "\xBF", "\x80\x80\x80", // stray continuations
96+
"\xFE", "\xFF", "\xFF\xFE\xFD",
97+
"ok\xC3\x28bad", // invalid continuation
98+
];
99+
foreach ($invalid as $s) {
100+
$corpus[] = $s;
101+
$corpus[] = "text $s text";
102+
}
103+
104+
// The five specials in various contexts
105+
foreach (['&', '<', '>', '"', "'"] as $sp) {
106+
$corpus[] = $sp;
107+
$corpus[] = "a{$sp}b";
108+
$corpus[] = str_repeat($sp, 50);
109+
}
110+
$corpus[] = '<script>alert("x&y\'z")</script>';
111+
$corpus[] = '&amp; already encoded &#39;';
112+
113+
// Long strings, bad byte at head / middle / tail (gate must not miss by position)
114+
$clean1k = str_repeat('The quick brown fox jumps over the lazy dog. ', 23);
115+
$corpus[] = $clean1k;
116+
$corpus[] = "<" . $clean1k;
117+
$corpus[] = substr($clean1k, 0, 500) . "\x00" . substr($clean1k, 500);
118+
$corpus[] = $clean1k . "\xE9";
119+
$corpus[] = $clean1k . speed_corpus_u8(0xFDD0);
120+
121+
// Whitespace combos: \t \n \v \f \r
122+
$corpus[] = "a\tb\nc\x0Bd\x0Ce\rf";
123+
$corpus[] = "\t\n\x0C\r";
124+
$corpus[] = "\x0B";
125+
126+
// Fuzz: 20,000 random-byte strings + 20,000 ASCII-biased (fast path's home turf).
127+
// Fixed seed: every run, every platform, tests the identical corpus.
128+
mt_srand(20260718);
129+
for ($i = 0; $i < 20000; $i++) {
130+
$len = mt_rand(0, 64);
131+
$s = '';
132+
for ($j = 0; $j < $len; $j++) {
133+
$s .= chr(mt_rand(0, 255));
134+
}
135+
$corpus[] = $s;
136+
}
137+
for ($i = 0; $i < 20000; $i++) {
138+
$len = mt_rand(0, 64);
139+
$s = '';
140+
for ($j = 0; $j < $len; $j++) {
141+
$s .= mt_rand(0, 30) === 0 ? chr(mt_rand(0, 255)) : chr(mt_rand(0x20, 0x7E));
142+
}
143+
$corpus[] = $s;
144+
}
145+
146+
return $corpus;
147+
}
148+
149+
/**
150+
* Assert an encoder is byte-identical to the reference over the whole corpus.
151+
*
152+
* @param callable $encoder fn(string): string
153+
* @param string[] $corpus result of speed_corpus() (pass in to reuse across encoders)
154+
* @return array{count: int, fail: int, samples: string[]} samples = first 5 mismatches as hex
155+
*/
156+
function speed_corpus_assert(callable $encoder, array $corpus): array
157+
{
158+
$fail = 0;
159+
$samples = [];
160+
foreach ($corpus as $s) {
161+
$want = htmlspecialchars($s, SPEED_CORPUS_FLAGS, 'UTF-8');
162+
$got = $encoder($s);
163+
if ($got !== $want) {
164+
$fail++;
165+
if (count($samples) < 5) {
166+
$samples[] = sprintf('input=%s want=%s got=%s', bin2hex($s), bin2hex($want), bin2hex($got));
167+
}
168+
}
169+
}
170+
return ['count' => count($corpus), 'fail' => $fail, 'samples' => $samples];
171+
}
172+
173+
// Standalone self-check: the reference must agree with itself, and the corpus
174+
// must be a sane size. Run: php speed-corpus.php
175+
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
176+
$corpus = speed_corpus();
177+
$result = speed_corpus_assert(static fn(string $s): string => htmlspecialchars($s, SPEED_CORPUS_FLAGS, 'UTF-8'), $corpus);
178+
printf("PHP %s | corpus=%d fail=%d (self-check)\n", PHP_VERSION, $result['count'], $result['fail']);
179+
exit($result['fail'] === 0 && $result['count'] > 90000 ? 0 : 1);
180+
}

.github/scripts/speed-merge.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Merge per-cell speed-probe JSON artifacts into one comparison grid.
6+
*
7+
* php speed-merge.php probes/*.json >> "$GITHUB_STEP_SUMMARY"
8+
*
9+
* Rows = tests, columns = cells (os/arch php), values = "B vs A" ratio.
10+
* Ratio > 1.00 means the B side (the candidate) is faster; >= 1.05 bold,
11+
* <= 0.95 flagged as a regression. CORPUS_FAIL and missing cells are called out.
12+
*/
13+
14+
$files = array_slice($argv, 1);
15+
if ($files === []) {
16+
fwrite(STDERR, "Usage: php speed-merge.php probe-*.json\n");
17+
exit(1);
18+
}
19+
20+
$cells = []; // label => decoded json
21+
foreach ($files as $file) {
22+
$data = json_decode((string)file_get_contents($file), true);
23+
if (!is_array($data) || !isset($data['php'], $data['os'], $data['tests'])) {
24+
fwrite(STDERR, "skipping unreadable probe file: $file\n");
25+
continue;
26+
}
27+
$phpShort = preg_replace('/^(\d+\.\d+)\..*$/', '$1', $data['php']);
28+
$arch = str_contains((string)$data['arch'], 'arm') || str_contains((string)$data['arch'], 'aarch') ? 'arm' : 'x64';
29+
$label = strtolower($data['os']) . "-$arch $phpShort";
30+
$cells[$label] = $data;
31+
}
32+
if ($cells === []) {
33+
fwrite(STDERR, "no valid probe files\n");
34+
exit(1);
35+
}
36+
ksort($cells);
37+
38+
// Collect the union of test ids in first-seen order, and A/B labels for the legend
39+
$testIds = [];
40+
$legend = [];
41+
foreach ($cells as $data) {
42+
foreach ($data['tests'] as $id => $t) {
43+
if (!in_array($id, $testIds, true)) {
44+
$testIds[] = $id;
45+
$legend[$id] = [$t['a_label'] ?? '?', $t['b_label'] ?? '?'];
46+
}
47+
}
48+
}
49+
50+
echo "## Speed matrix: B-vs-A ratios (>1.00 = candidate faster)\n\n";
51+
52+
// Corpus status line: any failure anywhere is a headline, not a footnote
53+
$corpusBad = [];
54+
foreach ($cells as $label => $data) {
55+
foreach (($data['corpus']['encoders'] ?? []) as $fn => $ok) {
56+
if (!$ok) {
57+
$corpusBad[] = "$label:$fn";
58+
}
59+
}
60+
}
61+
echo $corpusBad === []
62+
? "Correctness: every encoder byte-identical on every cell.\n\n"
63+
: "**CORRECTNESS FAILURES: " . implode(', ', $corpusBad) . "** - affected timings withheld.\n\n";
64+
65+
// Grid
66+
echo '| test |';
67+
foreach (array_keys($cells) as $label) {
68+
echo " $label |";
69+
}
70+
echo "\n|---|" . str_repeat('---|', count($cells)) . "\n";
71+
foreach ($testIds as $id) {
72+
echo "| $id |";
73+
foreach ($cells as $data) {
74+
$t = $data['tests'][$id] ?? null;
75+
if ($t === null) {
76+
echo ' - |';
77+
} elseif (($t['verdict'] ?? '') === 'CORPUS_FAIL') {
78+
echo ' **FAIL** |';
79+
} else {
80+
$r = (float)$t['ratio'];
81+
$text = sprintf('%.2fx', $r);
82+
echo ' ' . ($r >= 1.05 ? "**$text**" : ($r <= 0.95 ? "$text (slower)" : $text)) . ' |';
83+
}
84+
}
85+
echo "\n";
86+
}
87+
88+
// Legend
89+
echo "\n<details><summary>Test legend (A vs B)</summary>\n\n";
90+
foreach ($legend as $id => [$a, $b]) {
91+
echo "- **$id**: $a vs $b\n";
92+
}
93+
echo "\n</details>\n";

0 commit comments

Comments
 (0)