Skip to content

Commit 2af208b

Browse files
committed
Add strtr blind-reduction experiment
strtr iterate-to-stable is ~2650x slower than hand RD: it scans the whole table per call. Dead end.
1 parent 38868e0 commit 2af208b

2 files changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# strtr blind reduction
2+
3+
**Origin:** ephemeral exploration (rebuilt fresh, toy grammar). No PR/commit.
4+
5+
**Idea:** build a `strtr` translation table whose keys are reducible right-hand-side
6+
sequences and values are non-terminal placeholders, then iterate `strtr` to a fixed
7+
point. `strtr` with an array does parallel substitution in C, which is fast in
8+
principle.
9+
10+
**Run:** `php -d ...jit... strtr-bench.php` (toy expression grammar, ~79K-entry table).
11+
12+
**Result (ops/sec, warm JIT):** hand-written recursive descent ~7.0M;
13+
preg_match validate-only ~23.6M; preg_replace_callback shift-reduce ~1.8M;
14+
`strtr` iterate-to-stable ~2,600 — roughly 2,650× slower than hand RD.
15+
Throughput is ∝ 1/table-size and independent of input length.
16+
17+
**Verdict:** Dead end. `strtr` scans its entire translation table on every call
18+
regardless of input, so a grammar-sized table dominates per call. The native
19+
function is fast; the per-call whole-table scan is not. (Blind parallel
20+
substitution also can't model ordered shift-reduce — it needs a confluent rule set.)
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<?php
2+
/**
3+
* strtr blind reduction (toy expression grammar).
4+
*
5+
* Grammar:
6+
* E -> E ('+'|'*') T | T
7+
* T -> num | '(' E ')'
8+
*
9+
* Four recognizers over a stream of TOY TOKENS (single-char symbols):
10+
* n = num literal, + * ( ) as themselves.
11+
*
12+
* (a) hand-written recursive descent (validating)
13+
* (b) preg_match validate-only (regex for the toy language via recursion (?R))
14+
* (c) preg_replace_callback shift-reduce (iterate reducing an RHS -> placeholder)
15+
* (d) strtr iterate-to-stable with a LARGE (~79K-entry) translation table,
16+
* padded with synthetic non-matching keys to reproduce the table-scan cost.
17+
*
18+
* We measure QPS = recognitions per second, warm JIT, best-of-N.
19+
*/
20+
21+
// ---------------------------------------------------------------------------
22+
// Token alphabet used by all recognizers:
23+
// 'n' = number, '+','*','(',')'
24+
// Reducers (c) and (d) work on these single-char symbols; non-terminals are
25+
// encoded as single bytes too so RHS sequences are fixed strings.
26+
// 'E' and 'T' are the non-terminals.
27+
// ---------------------------------------------------------------------------
28+
29+
/** (a) Hand-written recursive descent. Returns true if the token string is a valid E. */
30+
function rd_parse(string $s): bool {
31+
$pos = 0;
32+
$len = strlen($s);
33+
$ok = rd_E($s, $pos, $len);
34+
return $ok && $pos === $len;
35+
}
36+
function rd_E(string $s, int &$pos, int $len): bool {
37+
if (!rd_T($s, $pos, $len)) return false;
38+
while ($pos < $len && ($s[$pos] === '+' || $s[$pos] === '*')) {
39+
$pos++;
40+
if (!rd_T($s, $pos, $len)) return false;
41+
}
42+
return true;
43+
}
44+
function rd_T(string $s, int &$pos, int $len): bool {
45+
if ($pos >= $len) return false;
46+
$c = $s[$pos];
47+
if ($c === 'n') { $pos++; return true; }
48+
if ($c === '(') {
49+
$pos++;
50+
if (!rd_E($s, $pos, $len)) return false;
51+
if ($pos >= $len || $s[$pos] !== ')') return false;
52+
$pos++;
53+
return true;
54+
}
55+
return false;
56+
}
57+
58+
/** (b) preg_match validate-only. PCRE recursive subpattern for the toy language. */
59+
$GLOBALS['toy_re'] = '/^(?<E>(?<T>n|\((?&E)\))(?:[+*](?&T))*)$/';
60+
function pcre_validate(string $s): bool {
61+
return (bool) preg_match($GLOBALS['toy_re'], $s);
62+
}
63+
64+
/** (c) preg_replace_callback shift-reduce.
65+
* Repeatedly reduce reducible RHS sequences to a single non-terminal until
66+
* the string is exactly "E" (accept) or no further reduction applies (reject).
67+
* RHS handled: T->n , T->(E) , E->T , E->E[+*]T
68+
*/
69+
function prc_reduce(string $s): bool {
70+
// Same confluent rule set as the strtr reducer, but driven by a regex that
71+
// matches any reducible RHS; iterate to a fixed point. Each call rewrites all
72+
// non-overlapping matches found in the current string (callback re-derives the
73+
// replacement), and we loop until the string stops changing.
74+
// n -> E ; (E) -> E ; E+E -> E ; E*E -> E
75+
$re = '/\(E\)|E[+*]E|n/';
76+
$guard = 0;
77+
while (true) {
78+
$s = preg_replace_callback($re, static function () { return 'E'; }, $s, -1, $count);
79+
if ($count === 0) break;
80+
if (++$guard > 100000) break;
81+
}
82+
return $s === 'E';
83+
}
84+
85+
/** (d) strtr iterate-to-stable with a LARGE padded table.
86+
* The reduction rules are the same RHS->placeholder rewrites, but applied via
87+
* strtr() against a table padded to ~PAD_TARGET entries with synthetic
88+
* non-matching keys, to reproduce the "whole-table scan per call" cost.
89+
*/
90+
$GLOBALS['strtr_table'] = null;
91+
function build_strtr_table(int $pad_target): array {
92+
// Real reduction rules (longest-key-first is handled by strtr automatically:
93+
// strtr prefers the longest matching key).
94+
// strtr does a SINGLE left-to-right non-overlapping pass per call, preferring
95+
// the longest matching key, and applies all rules SIMULTANEOUSLY (no re-scan of
96+
// already-substituted output within the same call). So the rule set must be
97+
// CONFLUENT under that semantics. We encode every value as the single non-terminal
98+
// 'E' and collapse binary forms, which converges to 'E' for any valid expression:
99+
// n -> E (atom)
100+
// (E) -> E (parenthesised)
101+
// E+E -> E (binary)
102+
// E*E -> E (binary)
103+
$table = [
104+
'(E)' => 'E',
105+
'E+E' => 'E',
106+
'E*E' => 'E',
107+
'n' => 'E',
108+
];
109+
// Pad with synthetic non-matching keys. Use a byte range that never appears
110+
// in our token strings (uppercase hex of a counter prefixed with '#').
111+
$i = 0;
112+
while (count($table) < $pad_target) {
113+
$key = '#' . dechex($i); // '#0','#1',... never present in token input
114+
$table[$key] = '~'; // arbitrary non-terminal-ish value, never used
115+
$i++;
116+
}
117+
return $table;
118+
}
119+
function strtr_reduce(string $s): bool {
120+
$table =& $GLOBALS['strtr_table'];
121+
$guard = 0;
122+
while (true) {
123+
$next = strtr($s, $table);
124+
if ($next === $s) break; // fixed point
125+
$s = $next;
126+
if (++$guard > 100000) break;
127+
}
128+
return $s === 'E';
129+
}
130+
131+
// ---------------------------------------------------------------------------
132+
// Representative toy input set (token strings). All are VALID expressions.
133+
// ---------------------------------------------------------------------------
134+
function make_inputs(): array {
135+
return [
136+
'n',
137+
'n+n',
138+
'n*n',
139+
'n+n*n',
140+
'(n)',
141+
'(n+n)',
142+
'(n+n)*n',
143+
'n+(n*n)+n',
144+
'((n))',
145+
'(n+n)*(n+n)',
146+
'n+n+n+n+n',
147+
'n*n*n*n*n',
148+
'(n+n*n)+(n*n+n)',
149+
'((n+n)*(n+n))+n',
150+
'n+n*(n+n)*n+n',
151+
];
152+
}
153+
154+
// ---------------------------------------------------------------------------
155+
// Sanity check: all four agree on the input set (and on a few invalids).
156+
// ---------------------------------------------------------------------------
157+
function sanity(): void {
158+
$valids = make_inputs();
159+
$invalids = ['', 'n+', '+n', '(n', 'n)', 'n++n', '()', 'nn', '(n+)'];
160+
foreach (['rd_parse','pcre_validate','prc_reduce','strtr_reduce'] as $fn) {
161+
foreach ($valids as $v) {
162+
if ($fn($v) !== true) { fwrite(STDERR, "SANITY FAIL: $fn rejected valid '$v'\n"); exit(1); }
163+
}
164+
foreach ($invalids as $iv) {
165+
if ($fn($iv) !== false) { fwrite(STDERR, "SANITY FAIL: $fn accepted invalid '$iv'\n"); exit(1); }
166+
}
167+
}
168+
fwrite(STDERR, "sanity OK (all 4 agree on " . count($valids) . " valid + " . count($invalids) . " invalid)\n");
169+
}
170+
171+
// ---------------------------------------------------------------------------
172+
// Benchmark harness: QPS = total recognitions / elapsed, best-of-N runs.
173+
// ---------------------------------------------------------------------------
174+
function bench(callable $fn, array $inputs, int $iters): float {
175+
// returns ops/sec for one run
176+
$t0 = hrtime(true);
177+
$acc = 0;
178+
for ($i = 0; $i < $iters; $i++) {
179+
foreach ($inputs as $in) {
180+
$acc += $fn($in) ? 1 : 0;
181+
}
182+
}
183+
$dt = (hrtime(true) - $t0) / 1e9;
184+
$ops = $iters * count($inputs);
185+
if ($acc < 0) echo $acc; // prevent DCE
186+
return $ops / $dt;
187+
}
188+
189+
$pad = (int) ($argv[1] ?? 79000);
190+
$GLOBALS['strtr_table'] = build_strtr_table($pad);
191+
fwrite(STDERR, "strtr table entries: " . count($GLOBALS['strtr_table']) . "\n");
192+
193+
sanity();
194+
195+
$inputs = make_inputs();
196+
197+
$cfgs = [
198+
'hand RD ' => ['fn' => 'rd_parse', 'iters' => 200000],
199+
'preg_match validate ' => ['fn' => 'pcre_validate', 'iters' => 200000],
200+
'preg_replace_callback ' => ['fn' => 'prc_reduce', 'iters' => 20000],
201+
'strtr iterate-to-stable' => ['fn' => 'strtr_reduce', 'iters' => 2000],
202+
];
203+
204+
$N = (int) ($argv[2] ?? 7);
205+
$warmup = 2;
206+
207+
$results = [];
208+
foreach ($cfgs as $label => $c) {
209+
$fn = $c['fn']; $iters = $c['iters'];
210+
for ($w = 0; $w < $warmup; $w++) bench($fn, $inputs, max(1, (int)($iters/4)));
211+
$best = 0.0;
212+
for ($r = 0; $r < $N; $r++) {
213+
$qps = bench($fn, $inputs, $iters);
214+
if ($qps > $best) $best = $qps;
215+
}
216+
$results[$label] = $best;
217+
}
218+
219+
echo "\n=== QPS (best-of-$N, warm) — strtr pad=$pad ===\n";
220+
$rd = $results['hand RD '];
221+
foreach ($results as $label => $qps) {
222+
printf("%-25s %12s QPS (%.1fx vs hand RD)\n",
223+
$label, number_format($qps, 0), $qps / $rd);
224+
}
225+
printf("\nstrtr is %.0fx slower than hand RD\n", $rd / $results['strtr iterate-to-stable']);

0 commit comments

Comments
 (0)