Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions benchmark/min-max/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions benchmark/min-max/benchmark.php
Original file line number Diff line number Diff line change
@@ -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";
}
5 changes: 5 additions & 0 deletions benchmark/min-max/project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: min-max-benchmark
build-mode: bin
optimize: 2
sources:
- benchmark.php
35 changes: 35 additions & 0 deletions benchmark/min-max/results-arm64.json
Original file line number Diff line number Diff line change
@@ -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
]
}
}
5 changes: 5 additions & 0 deletions benchmark/min-max/run.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

require __DIR__ . '/benchmark.php';

main($argc, $argv);
29 changes: 29 additions & 0 deletions phpunit/code/min-max-integer.php
Original file line number Diff line number Diff line change
@@ -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);
}
32 changes: 32 additions & 0 deletions phpunit/src/MinMaxIntegerTest.php
Original file line number Diff line number Diff line change
@@ -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)'));
}
}
58 changes: 58 additions & 0 deletions src/Optimizer/FuncCallOptimizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions tests/compiler/functions/min-max-cast-once.phpt
Original file line number Diff line number Diff line change
@@ -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)
66 changes: 66 additions & 0 deletions tests/compiler/functions/min-max-integer.phpt
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 32 additions & 0 deletions tests/compiler/functions/min-max-nullable.phpt
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions tests/compiler/functions/min-max-varint.phpt
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading