|
| 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