perf(optimizer): lower simple integer min/max calls (#96)

* perf(optimizer): lower simple integer min and max calls

* docs(benchmark): focus min/max results on integer performance
master
yavon007 1 month ago committed by GitHub
parent 393ea1d369
commit 692841a60b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 35
      benchmark/min-max/README.md
  2. 15
      benchmark/min-max/benchmark.php
  3. 5
      benchmark/min-max/project.yml
  4. 35
      benchmark/min-max/results-arm64.json
  5. 5
      benchmark/min-max/run.php
  6. 29
      phpunit/code/min-max-integer.php
  7. 32
      phpunit/src/MinMaxIntegerTest.php
  8. 58
      src/Optimizer/FuncCallOptimizer.php
  9. 25
      tests/compiler/functions/min-max-cast-once.phpt
  10. 66
      tests/compiler/functions/min-max-integer.phpt
  11. 32
      tests/compiler/functions/min-max-nullable.phpt
  12. 20
      tests/compiler/functions/min-max-varint.phpt
  13. 28
      tests/compiler/namespace/min-max-integer-resolution.phpt

@ -0,0 +1,35 @@
# Integer min/max benchmark
This benchmark repeatedly clamps integer values and accumulates a checksum. It
isolates two-argument integer `min()` / `max()` calls; it is not representative
of every PHP workload. It emits one line after the loop, so terminal output is
not part of the hot path.
From this directory, with PHP 8.5 and a matching PHPX/embed installation:
```sh
php run.php 10000000
php ../../bin/tpc.php project.yml --no-progress -o min_max_benchmark
./min_max_benchmark 10000000
```
The PHP and AOT checksums must match. Compile baseline and candidate revisions
into different build directories and binary paths, then alternate their
execution order. Exclude compilation time, discard a warm-up pair, and compare
medians over multiple runs. Keep PHPX, PHP, compiler flags, and machine fixed.
## Sample result
Linux ARM64 in Docker, PHP 8.5.10 ZTS, PHPX `6a68f38`, GCC `-O2`;
baseline TypePHP `72b7ce9b` versus this integer min/max lowering change.
Nine measured runs per binary after one discarded pair, 10,000,000 iterations:
| Build | Median | Range |
| --- | ---: | ---: |
| Baseline | 482.56 ms | 456.41–497.78 ms |
| Integer lowering | 48.01 ms | 44.10–57.38 ms |
This is a 10.05x speedup for the isolated integer min/max workload. Timings
come from a shared development machine, not a dedicated benchmark host. Raw
samples are in `results-arm64.json`; times include process startup and exclude
compilation.

@ -0,0 +1,15 @@
<?php
function main(int $argc, array $argv): void
{
$iterations = $argc > 1 ? (int) $argv[1] : 10000000;
$checksum = 0;
for ($i = 0; $i < $iterations; ++$i) {
$damage = ($i % 101) - 20;
$hp = 100 - ($i % 100);
$damage = max(0, $damage);
$remaining = min($hp, $damage);
$checksum += $remaining;
}
echo $checksum, "\n";
}

@ -0,0 +1,5 @@
name: min-max-benchmark
build-mode: bin
optimize: 2
sources:
- benchmark.php

@ -0,0 +1,35 @@
{
"baseline": "72b7ce9b",
"candidate": "simple integer min/max lowering",
"php": "8.5.10 ZTS",
"phpx": "6a68f38",
"platform": "Linux ARM64 Docker",
"optimization": "O2",
"method": "alternate order; one discarded pair; nine measured pairs; wall time including process startup; seconds",
"count": 10000000,
"checksum": "236310950",
"samples": {
"candidate": [
0.057375312,
0.045864502,
0.048057849,
0.045604484,
0.048013895,
0.045256348,
0.04409891,
0.048323617,
0.049788737
],
"baseline": [
0.482051066,
0.482560355,
0.478532421,
0.497780501,
0.484743119,
0.456406973,
0.472020001,
0.483321787,
0.489281674
]
}
}

@ -0,0 +1,5 @@
<?php
require __DIR__ . '/benchmark.php';
main($argc, $argv);

@ -0,0 +1,29 @@
<?php
function minMaxIntegers(int $a, int $b): array
{
return [min($a, $b), max($a, $b)];
}
function minMaxFallbacks(mixed $a, mixed $b): array
{
return [min($a, $b), max($a, $b), min(1.5, 2.5), max(1, 2.5), min([3, 1]), max(1, 2, 3), min(...[3, 1])];
}
function minMaxUnproven(mixed $a): array
{
return [min($a + 1, 2), max($a + 1, 2)];
}
class MinMaxNullableProperty
{
public ?int $value = null;
}
function minMaxNullable(?int $value, MinMaxNullableProperty $object): array
{
return [min($value, 0), max($value, 0), min($object->value, 0), max($object->value, 0)];
}
function minMaxCasts(mixed $a): array
{
return [min((int) $a, 2), max((int) $a, 2)];
}
function main(): void
{
minMaxIntegers(3, 1);
}

@ -0,0 +1,32 @@
<?php
use TypePhp\CompilerTest;
final class MinMaxIntegerTest extends BaseTest
{
public function testOnlyTwoProvenIntegerArgumentsBypassZend(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/min-max-integer.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$code = file_get_contents($compiler->convertFile($source));
self::assertIsString($code);
$start = strpos($code, 'php::Array php_minmaxintegers(');
self::assertNotFalse($start);
$end = strpos($code, 'php::Array php_minmaxfallbacks(', $start);
self::assertNotFalse($end);
$integerBody = substr($code, $start, $end - $start);
self::assertStringNotContainsString('php::call(', $integerBody);
self::assertSame(13, substr_count($code, 'php::call('));
$castStart = strpos($code, 'php::Array php_minmaxcasts(');
self::assertNotFalse($castStart);
$castEnd = strpos($code, 'void php_main(', $castStart);
self::assertNotFalse($castEnd);
self::assertSame(2, substr_count(substr($code, $castStart, $castEnd - $castStart), 'php::toInt(a)'));
}
}

@ -102,6 +102,9 @@ trait FuncCallOptimizer
];
$extra = [
'min' => ['handler' => 'genIntegerMinMax'],
'max' => ['handler' => 'genIntegerMinMax'],
// Aliases (PHP function name → C++ target name)
'join' => 'implode',
'stristr' => 'stristr',
@ -1181,6 +1184,61 @@ trait FuncCallOptimizer
return $array . '.offsetExists(' . $key . ')';
}
protected function genIntegerMinMax(string $name, Node\Expr\FuncCall $expr, array $config): string|false
{
// PHP also accepts arrays, mixed types and variadic arguments. Only
// two proven integers have the same comparison and result semantics
// as a native scalar selection; leave every other form to Zend.
if (count($expr->args) !== 2) {
return false;
}
foreach ($expr->args as $arg) {
if (!$this->isExactIntegerMinMaxOperand($arg->value)) {
return false;
}
}
// Reuse ordinary call operand lowering: materialize side effects once,
// but preserve PHP's deferred reads of simple variable arguments.
// Casts may warn or invoke an object conversion even without nested
// calls, so snapshot them before repeating operands in the selection.
$left = $expr->args[0]->value instanceof Node\Expr\Cast\Int_
? $this->parseOrderedOperand($expr->args[0]->value, false, true)
: $this->getArg($expr, 0);
$right = $expr->args[1]->value instanceof Node\Expr\Cast\Int_
? $this->parseOrderedOperand($expr->args[1]->value, false, true)
: $this->getArg($expr, 1);
$operator = $name === 'min' ? '<' : '>';
return '(' . $left . ' ' . $operator . ' ' . $right . ' ? ' . $left . ' : ' . $right . ')';
}
protected function isExactIntegerMinMaxOperand(Node\Expr $expr): bool
{
if (!$this->usesNativeScalarStorage(Type::INT)
|| $this->detectTypeOfExpr($expr) !== Type::INT
) {
return false;
}
if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) {
// Require actual native storage, not a flow-sensitive approximation
// of the value held by a mixed/overflow-capable variable.
return $this->getVarType($this->parseIdentifier($expr)) === Type::INT;
}
if ($expr instanceof Node\Expr\PropertyFetch && $expr->name instanceof Node\Identifier) {
$class = $this->resolveObjectClassDef($expr->var);
if ($class !== null && $class->hasProperty($expr->name->toString())) {
$property = $class->getProperty($expr->name->toString());
return $property->type === Type::INT && !$property->nullable;
}
return false;
}
// Arithmetic inference can report INT for mixed + int, even though
// the value may be a float. Keep computations, calls and unresolved
// property/constant reads on Zend's path.
return $expr instanceof Node\Scalar\Int_
|| $expr instanceof Node\Expr\Cast\Int_;
}
protected function genRound(string $n, Node\Expr\FuncCall $e, array $c): string|false
{
// An unpacked or named argument is a single Node\Arg whatever its

@ -0,0 +1,25 @@
--TEST--
Integer min/max evaluates integer casts exactly once per argument
--FILE--
<?php
class MinMaxWarnings
{
public int $count = 0;
}
function main(): void
{
$warnings = new MinMaxWarnings();
set_error_handler(static function (int $severity, string $message, string $file, int $line) use ($warnings): bool {
++$warnings->count;
return true;
});
$object = new stdClass();
var_dump(min((int) $object, 2), max((int) $object, 0));
restore_error_handler();
var_dump($warnings->count);
}
?>
--EXPECT--
int(1)
int(1)
int(2)

@ -0,0 +1,66 @@
--TEST--
Integer min/max optimization preserves values, evaluation order, and dynamic fallback
--FILE--
<?php
function nextMinMaxValue(int &$value): int
{
++$value;
return $value;
}
function compareMinMax(int $a, int $b): void
{
var_dump(min($a, $b), max($a, $b));
}
function dynamicMinMax(mixed $a, mixed $b): void
{
var_dump(min($a, $b), max($a, $b));
}
function main(): void
{
compareMinMax(8, -3);
compareMinMax(4, 4);
compareMinMax(PHP_INT_MIN, PHP_INT_MAX);
$value = 2;
var_dump(min($value, (int) nextMinMaxValue($value)), $value);
var_dump(max(nextMinMaxValue($value), nextMinMaxValue($value)), $value);
var_dump(min(nextMinMaxValue($value), $value), $value);
$counter = 0;
var_dump(false && min(++$counter, ++$counter), $counter);
var_dump(true ? max(++$counter, ++$counter) : min(++$counter, ++$counter), $counter);
dynamicMinMax('20', 3);
dynamicMinMax(false, 2);
var_dump(min(1, 1.5), max(1, 1.5), min(-2.5, -1.0), max(-1.0, -2.5));
var_dump(min([4, 2]), max(...[1, 7, 3]), min(3, 2, 1));
$callable = min(...);
var_dump($callable(4, 2));
}
?>
--EXPECT--
int(-3)
int(8)
int(4)
int(4)
int(-9223372036854775808)
int(9223372036854775807)
int(3)
int(3)
int(5)
int(5)
int(6)
int(6)
bool(false)
int(0)
int(2)
int(2)
int(3)
string(2) "20"
bool(false)
int(2)
int(1)
float(1.5)
float(-2.5)
float(-1)
int(2)
int(7)
int(1)
int(2)

@ -0,0 +1,32 @@
--TEST--
min/max preserve null arguments and nullable property values
--FILE--
<?php
class MinMaxNullable
{
public ?int $value = null;
public static ?int $staticValue = null;
}
function nullableMinMax(?int $value): void
{
var_dump(min($value, 0), max($value, 0));
}
function main(): void
{
$object = new MinMaxNullable();
nullableMinMax(null);
var_dump(min($object->value, 0), max($object->value, 0));
var_dump(min(MinMaxNullable::$staticValue, 0), max(MinMaxNullable::$staticValue, 0));
$object->value = 3;
var_dump(min($object->value, 0), max($object->value, 0));
}
?>
--EXPECT--
NULL
NULL
NULL
NULL
NULL
NULL
int(0)
int(3)

@ -0,0 +1,20 @@
--TEST--
min/max keep overflow-capable varint values on the Zend path
--FILE--
<?php
use varint_types;
function main(): void
{
$value = PHP_INT_MAX;
++$value;
var_dump(min($value, 2), is_float(max($value, 2)));
$value = 1;
$value += 0.5;
var_dump(min($value, 2), max($value, 2));
}
?>
--EXPECT--
int(2)
bool(true)
float(1.5)
int(2)

@ -0,0 +1,28 @@
--TEST--
Integer min/max optimization respects local functions and imported builtin aliases
--FILE--
<?php
namespace MinMaxGame {
use function min as builtinMin;
use function max as builtinMax;
function min(int $a, int $b): int { return 99; }
function max(int $a, int $b): int { return -99; }
function compare(int $a, int $b): void
{
var_dump(min($a, $b), max($a, $b));
var_dump(builtinMin($a, $b), builtinMax($a, $b));
var_dump(\min($a, $b), \max($a, $b));
}
}
namespace {
function main(): void { \MinMaxGame\compare(3, 7); }
}
?>
--EXPECT--
int(99)
int(-99)
int(3)
int(7)
int(3)
int(7)
Loading…
Cancel
Save