Skip to content

Instantly share code, notes, and snippets.

@sybrew
Created July 24, 2026 13:57
Show Gist options
  • Select an option

  • Save sybrew/36a011735aa10ecfe98b30495f9c091e to your computer and use it in GitHub Desktop.

Select an option

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