Created
July 24, 2026 13:57
-
-
Save sybrew/36a011735aa10ecfe98b30495f9c091e to your computer and use it in GitHub Desktop.
Benchmark: cost of strict parameter/return typing vs. loose typing + manual cast/sanitization. This should be run in an opcache-enabled and opcache-disabled run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Benchmark: cost of strict parameter/return typing vs. loose typing + manual | |
| * cast/sanitization. This should be run in an opcache-enabled and opcache-disabled run. | |
| * | |
| * Improvements over the original script: | |
| * - Randomizes test order every round to cancel out positional/thermal bias | |
| * (CPU frequency scaling, branch predictor & instruction-cache warm-up). | |
| * - Runs many rounds and reports the median (robust against GC pauses/noise) | |
| * instead of a single sample per "test" loop. | |
| * - Reports a signed % diff of every variant vs. the `test_not_strict` | |
| * baseline, and vs. the fastest variant. | |
| * | |
| * Run: | |
| * php bench-strict-types.php # no opcache | |
| * php -d zend_extension=php_opcache.dll -d opcache.enable_cli=1 bench... # opcache, no JIT | |
| * php -d zend_extension=php_opcache.dll -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=1255 bench... # opcache + JIT | |
| * | |
| * Tested on: PHP 8.4.4 (cli) (built: Feb 11 2025 16:25:02) (ZTS Visual C++ 2022 x64), Zend Engine v4.4.4 | |
| * | |
| * Results (test_2x_strict_param_strict_return / test_1x_strict_param_strict_return vs. test_not_strict baseline): | |
| * - No OPcache (4 runs): +20.6% to +21.0% / +19.8% to +20.8% (avg ~20.8% / ~20.4%) | |
| * - OPcache, no JIT (1 run): +21.2% / +22.2% | |
| * - OPcache + JIT (3 runs): +10.4% to +13.4% (still slower every run, but roughly half the non-JIT penalty) | |
| * | |
| * Takeaways: | |
| * - Plain OPcache does NOT reduce the type-check overhead — it only caches compiled bytecode between | |
| * requests, it doesn't remove the per-call ZEND_VERIFY_ARG_TYPE / ZEND_VERIFY_RETURN_TYPE opcodes. | |
| * - OPcache's JIT partially mitigates (~20% -> ~10-13%) but does not eliminate the penalty. | |
| * - The return-type check is the dominant cost; param-only strict typing was only +4-6% vs baseline. | |
| * - Manual `is_string($val) ? $val : ''` validation cost +16-17%, nearly as much as the automatic | |
| * return-type check — an unconditional `(string)` cast (no real validation) was the only cheap option (+2-4%). | |
| * - Anomaly: test_strict_return (loose params, strict return only) was the single fastest function in | |
| * every run, even beating the fully-untyped baseline. No confirmed mechanical explanation for this yet. | |
| */ | |
| $its = 2e5; | |
| $rounds = 15; // odd, so the median is a single real sample, not an average of two. | |
| function test_2x_strict_param( string $val, string $val2 ) { | |
| return $val; | |
| } | |
| function test_1x_strict_param( string $val, $val2 ) { | |
| return $val; | |
| } | |
| function test_2x_strict_param_strict_return( string $val, string $val2 ): string { | |
| return $val; | |
| } | |
| function test_1x_strict_param_strict_return( string $val, $val2 ): string { | |
| return $val; | |
| } | |
| function test_strict_return( $val, $val2 ): string { | |
| return $val; | |
| } | |
| function test_is_string_return( $val, $val2 ) { | |
| return is_string( $val ) ? $val : ''; | |
| } | |
| function test_typecast_return( $val, $val2 ) { | |
| return (string) $val; | |
| } | |
| function test_not_strict( $val, $val2 ) { | |
| return $val; | |
| } | |
| $fns = [ | |
| 'test_2x_strict_param', | |
| 'test_1x_strict_param', | |
| 'test_2x_strict_param_strict_return', | |
| 'test_1x_strict_param_strict_return', | |
| 'test_strict_return', | |
| 'test_is_string_return', | |
| 'test_typecast_return', | |
| 'test_not_strict', | |
| ]; | |
| // Prime (autoloader/opcache warm-up, branch predictor, etc.), discarded. | |
| foreach ( $fns as $fn ) { | |
| $fn( 'hello', 'world' ); | |
| } | |
| $results = array_fill_keys( $fns, [] ); | |
| for ( $r = 0; $r < $rounds; $r++ ) { | |
| // Randomize order each round so no single function is always | |
| // first/last (which would otherwise skew results systematically). | |
| $order = $fns; | |
| shuffle( $order ); | |
| foreach ( $order as $fn ) { | |
| $t = hrtime( true ); | |
| for ( $i = $its; $i--; ) { | |
| $fn( 'hello', 'world' ); | |
| } | |
| $results[ $fn ][] = ( hrtime( true ) - $t ) / 1e9; | |
| } | |
| } | |
| function median( array $values ) { | |
| sort( $values ); | |
| $count = count( $values ); | |
| $mid = (int) ( $count / 2 ); | |
| return $count % 2 | |
| ? $values[ $mid ] | |
| : ( $values[ $mid - 1 ] + $values[ $mid ] ) / 2; | |
| } | |
| $medians = []; | |
| foreach ( $results as $fn => $times ) { | |
| $medians[ $fn ] = median( $times ); | |
| } | |
| $baseline = $medians['test_not_strict']; | |
| $fastest = min( $medians ); | |
| $name_len = max( array_map( 'strlen', $fns ) ); | |
| echo str_pad( 'function', $name_len ), ' ', str_pad( 'median', 9 ), ' ', str_pad( 'vs not_strict', 15 ), ' vs fastest', PHP_EOL; | |
| echo str_repeat( '-', $name_len + 45 ), PHP_EOL; | |
| foreach ( $medians as $fn => $median_time ) { | |
| $diff_baseline = ( $median_time - $baseline ) / $baseline * 100; | |
| $diff_fastest = ( $median_time - $fastest ) / $fastest * 100; | |
| $marker = $median_time === $fastest ? ' *' : ''; | |
| printf( | |
| "%-{$name_len}s %7.5fs %+13.2f%% %+9.2f%%%s\n", | |
| $fn, | |
| $median_time, | |
| $diff_baseline, | |
| $diff_fastest, | |
| $marker | |
| ); | |
| } | |
| echo PHP_EOL, 'Rounds: ', $rounds, ', iterations/round: ', number_format( $its ), ', opcache: ', | |
| ( function_exists( 'opcache_get_status' ) && opcache_get_status() !== false ? 'ON' : 'OFF' ), PHP_EOL; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment