diff --git a/benchmark/min-max/README.md b/benchmark/min-max/README.md new file mode 100644 index 00000000..8136f444 --- /dev/null +++ b/benchmark/min-max/README.md @@ -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. diff --git a/benchmark/min-max/benchmark.php b/benchmark/min-max/benchmark.php new file mode 100644 index 00000000..cec23e89 --- /dev/null +++ b/benchmark/min-max/benchmark.php @@ -0,0 +1,15 @@ + 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"; +} diff --git a/benchmark/min-max/project.yml b/benchmark/min-max/project.yml new file mode 100644 index 00000000..071df09f --- /dev/null +++ b/benchmark/min-max/project.yml @@ -0,0 +1,5 @@ +name: min-max-benchmark +build-mode: bin +optimize: 2 +sources: + - benchmark.php diff --git a/benchmark/min-max/results-arm64.json b/benchmark/min-max/results-arm64.json new file mode 100644 index 00000000..f445bbe2 --- /dev/null +++ b/benchmark/min-max/results-arm64.json @@ -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 + ] + } +} diff --git a/benchmark/min-max/run.php b/benchmark/min-max/run.php new file mode 100644 index 00000000..2ffee2dc --- /dev/null +++ b/benchmark/min-max/run.php @@ -0,0 +1,5 @@ +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); +} diff --git a/phpunit/src/MinMaxIntegerTest.php b/phpunit/src/MinMaxIntegerTest.php new file mode 100644 index 00000000..5d3740eb --- /dev/null +++ b/phpunit/src/MinMaxIntegerTest.php @@ -0,0 +1,32 @@ +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)')); + } +} diff --git a/src/Optimizer/FuncCallOptimizer.php b/src/Optimizer/FuncCallOptimizer.php index 3b2c9d51..c3d2a2fe 100644 --- a/src/Optimizer/FuncCallOptimizer.php +++ b/src/Optimizer/FuncCallOptimizer.php @@ -102,6 +102,9 @@ protected function buildFuncCallConfig(): array ]; $extra = [ + 'min' => ['handler' => 'genIntegerMinMax'], + 'max' => ['handler' => 'genIntegerMinMax'], + // Aliases (PHP function name → C++ target name) 'join' => 'implode', 'stristr' => 'stristr', @@ -1181,6 +1184,61 @@ protected function genArrayKeyExists(string $n, Node\Expr\FuncCall $e, array $c) 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 diff --git a/tests/compiler/functions/min-max-cast-once.phpt b/tests/compiler/functions/min-max-cast-once.phpt new file mode 100644 index 00000000..c9f4ecfc --- /dev/null +++ b/tests/compiler/functions/min-max-cast-once.phpt @@ -0,0 +1,25 @@ +--TEST-- +Integer min/max evaluates integer casts exactly once per argument +--FILE-- +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) diff --git a/tests/compiler/functions/min-max-integer.phpt b/tests/compiler/functions/min-max-integer.phpt new file mode 100644 index 00000000..7727d912 --- /dev/null +++ b/tests/compiler/functions/min-max-integer.phpt @@ -0,0 +1,66 @@ +--TEST-- +Integer min/max optimization preserves values, evaluation order, and dynamic fallback +--FILE-- + +--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) diff --git a/tests/compiler/functions/min-max-nullable.phpt b/tests/compiler/functions/min-max-nullable.phpt new file mode 100644 index 00000000..6ca7bfb2 --- /dev/null +++ b/tests/compiler/functions/min-max-nullable.phpt @@ -0,0 +1,32 @@ +--TEST-- +min/max preserve null arguments and nullable property values +--FILE-- +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) diff --git a/tests/compiler/functions/min-max-varint.phpt b/tests/compiler/functions/min-max-varint.phpt new file mode 100644 index 00000000..377cf2b9 --- /dev/null +++ b/tests/compiler/functions/min-max-varint.phpt @@ -0,0 +1,20 @@ +--TEST-- +min/max keep overflow-capable varint values on the Zend path +--FILE-- + +--EXPECT-- +int(2) +bool(true) +float(1.5) +int(2) diff --git a/tests/compiler/namespace/min-max-integer-resolution.phpt b/tests/compiler/namespace/min-max-integer-resolution.phpt new file mode 100644 index 00000000..e050dc37 --- /dev/null +++ b/tests/compiler/namespace/min-max-integer-resolution.phpt @@ -0,0 +1,28 @@ +--TEST-- +Integer min/max optimization respects local functions and imported builtin aliases +--FILE-- + +--EXPECT-- +int(99) +int(-99) +int(3) +int(7) +int(3) +int(7)