From e44007846bb3490236dcd617f3825539c64cfdc0 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 28 Aug 2026 12:34:22 +0800 Subject: [PATCH] perf: use native property handlers for ordinary classes --- README-CN.md | 6 +- README.md | 5 +- benchmark/README.md | 17 +++ {examples => benchmark}/bench.php | 0 {examples => benchmark}/micro_bench.php | 0 benchmark/property-access/.gitignore | 3 + benchmark/property-access/README.md | 15 +++ benchmark/property-access/benchmark.php | 154 ++++++++++++++++++++++++ benchmark/property-access/project.yml | 7 ++ benchmark/property-access/run.php | 101 ++++++++++++++++ phpunit/code/new-object-codegen.php | 7 ++ phpunit/src/NewObjectCodegenTest.php | 34 ++++++ src/Translator.php | 26 ++++ 13 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 benchmark/README.md rename {examples => benchmark}/bench.php (100%) rename {examples => benchmark}/micro_bench.php (100%) create mode 100644 benchmark/property-access/.gitignore create mode 100644 benchmark/property-access/README.md create mode 100644 benchmark/property-access/benchmark.php create mode 100644 benchmark/property-access/project.yml create mode 100644 benchmark/property-access/run.php diff --git a/README-CN.md b/README-CN.md index ee1ca502..03092f6a 100644 --- a/README-CN.md +++ b/README-CN.md @@ -524,9 +524,9 @@ TypePHP 使用 `-O3` 运行 PHP 源码树自带的官方 `bench.php` 与 | `micro_bench.php`(总计) | 13.045 秒 | **2.021 秒** | 约 6.5× | 两项基准覆盖 PHP 语言核心性能——函数调用、对象属性访问、数组/哈希访问、 -字符串处理、控制流等。仓库内的测试源码为 -[`examples/bench.php`](examples/bench.php)和 -[`examples/micro_bench.php`](examples/micro_bench.php)。 +字符串处理、控制流等。测试代码见 [`benchmark/bench.php`](benchmark/bench.php) 和 +[`benchmark/micro_bench.php`](benchmark/micro_bench.php)。其他专项性能回归测试 +统一放置在 [`benchmark/`](benchmark/) 目录中。 这些数字是项目测量快照,不是性能保证。PHP 版本、编译器、CPU、优化参数和已启用 扩展都会影响结果;在用于部署决策前,应在同一机器上使用相同 workload 自行对比。 diff --git a/README.md b/README.md index d836a386..7fe575b2 100644 --- a/README.md +++ b/README.md @@ -565,8 +565,9 @@ benchmarks that ship with the PHP source tree, compiled with `-O3`: Both benchmarks measure core PHP language performance — function calls, object property access, array/hash access, string handling, control flow, and more. -The checked-in workloads are [`examples/bench.php`](examples/bench.php) and -[`examples/micro_bench.php`](examples/micro_bench.php). +The checked-in workloads are [`benchmark/bench.php`](benchmark/bench.php) and +[`benchmark/micro_bench.php`](benchmark/micro_bench.php). Additional focused +performance regressions live in the same [`benchmark/`](benchmark/) directory. These numbers are a project measurement snapshot, not a performance guarantee. PHP version, compiler, CPU, optimization flags, and enabled extensions can all diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..a06ac89f --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,17 @@ +# TypePHP benchmarks + +This directory contains repeatable performance workloads used to guide and +verify compiler/runtime optimizations. Benchmark results depend on the CPU, +PHP build, compiler, and system load, so compare PHP and TypePHP on the same +machine instead of committing absolute timing expectations. + +- `bench.php` and `micro_bench.php` are the original general workloads moved + from `examples/`. +- `property-access/` builds and compares dynamic/static property access under + Zend PHP and TypePHP. + +Run the property benchmark from the repository root: + +```bash +php benchmark/property-access/run.php +``` diff --git a/examples/bench.php b/benchmark/bench.php similarity index 100% rename from examples/bench.php rename to benchmark/bench.php diff --git a/examples/micro_bench.php b/benchmark/micro_bench.php similarity index 100% rename from examples/micro_bench.php rename to benchmark/micro_bench.php diff --git a/benchmark/property-access/.gitignore b/benchmark/property-access/.gitignore new file mode 100644 index 00000000..ccb81649 --- /dev/null +++ b/benchmark/property-access/.gitignore @@ -0,0 +1,3 @@ +/build/ +/property_access +/*.rsp diff --git a/benchmark/property-access/README.md b/benchmark/property-access/README.md new file mode 100644 index 00000000..64e26f2e --- /dev/null +++ b/benchmark/property-access/README.md @@ -0,0 +1,15 @@ +# Dynamic property benchmark + +This benchmark compares the same dynamic and static property operations under +Zend PHP and a TypePHP `-O2` binary. Each metric is the best of seven rounds +after three warm-up rounds and is reported in nanoseconds per property access. + +Run it from the repository root: + +```bash +php benchmark/property-access/run.php +``` + +To reuse an existing binary, add `--skip-build`. For local regression checks, +`--max-ratio=1.5` exits unsuccessfully when a dynamic read or write takes more +than 1.5 times the corresponding Zend PHP result. diff --git a/benchmark/property-access/benchmark.php b/benchmark/property-access/benchmark.php new file mode 100644 index 00000000..8c774c53 --- /dev/null +++ b/benchmark/property-access/benchmark.php @@ -0,0 +1,154 @@ + $value) { + $this->$property = $value; + } + } + + public function sum(array $properties): int + { + $sum = 0; + foreach ($properties as $property) { + $sum += $this->$property; + } + return $sum; + } +} + +final class StaticPropertyEntity +{ + public int $first = 0; + public int $second = 0; + public int $third = 0; + public int $fourth = 0; + public int $fifth = 0; + + public function hydrate(array $data): void + { + $this->first = $data['first']; + $this->second = $data['second']; + $this->third = $data['third']; + $this->fourth = $data['fourth']; + $this->fifth = $data['fifth']; + } + + public function sum(): int + { + return $this->first + $this->second + $this->third + $this->fourth + $this->fifth; + } +} + +function runDynamicWrite(DynamicPropertyEntity $entity, array $data, int $iterations): int +{ + for ($i = 0; $i < $iterations; $i++) { + $entity->hydrate($data); + } + return $entity->first; +} + +function runStaticWrite(StaticPropertyEntity $entity, array $data, int $iterations): int +{ + for ($i = 0; $i < $iterations; $i++) { + $entity->hydrate($data); + } + return $entity->first; +} + +function runDynamicRead(DynamicPropertyEntity $entity, array $properties, int $iterations): int +{ + $sum = 0; + for ($i = 0; $i < $iterations; $i++) { + $sum += $entity->sum($properties); + } + return $sum; +} + +function runStaticRead(StaticPropertyEntity $entity, int $iterations): int +{ + $sum = 0; + for ($i = 0; $i < $iterations; $i++) { + $sum += $entity->sum(); + } + return $sum; +} + +function measure(callable $callback, int $operations): float +{ + global $benchmarkSink; + for ($warmup = 0; $warmup < 3; $warmup++) { + $benchmarkSink += $callback(); + } + + $best = 1.0e30; + for ($round = 0; $round < 7; $round++) { + $start = hrtime(true); + $result = $callback(); + $elapsed = hrtime(true) - $start; + $benchmarkSink += $result; + if ($elapsed < $best) { + $best = $elapsed; + } + } + return $best / $operations; +} + +function main(): void +{ + global $benchmarkSink; + $benchmarkSink = 0; + $iterations = 200000; + $data = [ + 'first' => 1, + 'second' => 2, + 'third' => 3, + 'fourth' => 4, + 'fifth' => 5, + ]; + $properties = ['first', 'second', 'third', 'fourth', 'fifth']; + $dynamic = new DynamicPropertyEntity(); + $static = new StaticPropertyEntity(); + $operations = $iterations * 5; + + $dynamicWrite = measure( + function () use ($dynamic, $data, $iterations): int { + return runDynamicWrite($dynamic, $data, $iterations); + }, + $operations, + ); + $staticWrite = measure( + function () use ($static, $data, $iterations): int { + return runStaticWrite($static, $data, $iterations); + }, + $operations, + ); + $dynamicRead = measure( + function () use ($dynamic, $properties, $iterations): int { + return runDynamicRead($dynamic, $properties, $iterations); + }, + $operations, + ); + $staticRead = measure( + function () use ($static, $iterations): int { + return runStaticRead($static, $iterations); + }, + $operations, + ); + + printf("dynamic_write_ns=%.3f\n", $dynamicWrite); + printf("static_write_ns=%.3f\n", $staticWrite); + printf("dynamic_read_ns=%.3f\n", $dynamicRead); + printf("static_read_ns=%.3f\n", $staticRead); + echo 'checksum=', $benchmarkSink + $dynamic->sum($properties) + $static->sum(), "\n"; +} diff --git a/benchmark/property-access/project.yml b/benchmark/property-access/project.yml new file mode 100644 index 00000000..04ced7aa --- /dev/null +++ b/benchmark/property-access/project.yml @@ -0,0 +1,7 @@ +name: property_access_benchmark +mode: bin +optimize: 2 +build-dir: build +output: property_access +sources: + - benchmark.php diff --git a/benchmark/property-access/run.php b/benchmark/property-access/run.php new file mode 100644 index 00000000..ce08f0e1 --- /dev/null +++ b/benchmark/property-access/run.php @@ -0,0 +1,101 @@ + $command */ +function runCommand(array $command, string $cwd, bool $capture): string +{ + $stdout = $capture ? ['pipe', 'w'] : STDOUT; + $stderr = $capture ? ['pipe', 'w'] : STDERR; + $process = proc_open($command, [STDIN, $stdout, $stderr], $pipes, $cwd, null, ['bypass_shell' => true]); + if (!is_resource($process)) { + throw new RuntimeException('Failed to start: ' . implode(' ', $command)); + } + + $output = ''; + $error = ''; + if ($capture) { + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + } + $status = proc_close($process); + if ($status !== 0) { + throw new RuntimeException( + 'Command failed (' . $status . '): ' . implode(' ', $command) . "\n" . $output . $error, + ); + } + return $output; +} + +/** @return array */ +function parseResults(string $output): array +{ + $results = []; + foreach (explode("\n", trim($output)) as $line) { + if (!str_contains($line, '=')) { + continue; + } + [$name, $value] = explode('=', $line, 2); + if ($name !== 'checksum') { + $results[$name] = (float) $value; + } + } + return $results; +} + +if (!$skipBuild) { + runCommand([ + PHP_BINARY, + $root . '/bin/tpc.php', + $project, + '-j', + '8', + '--no-color', + '--no-progress', + ], $root, false); +} +if (!is_file($binary)) { + throw new RuntimeException('Benchmark binary does not exist: ' . $binary); +} + +$php = parseResults(runCommand([ + PHP_BINARY, + '-d', + 'opcache.enable_cli=0', + '-r', + 'require ' . var_export($source, true) . '; main();', +], $root, true)); +$typephp = parseResults(runCommand([$binary], $root, true)); + +echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n"; +echo "------------------------------------------------------------\n"; +$failed = false; +foreach (['dynamic_write_ns', 'dynamic_read_ns', 'static_write_ns', 'static_read_ns'] as $metric) { + if (!isset($php[$metric], $typephp[$metric])) { + throw new RuntimeException('Missing benchmark metric: ' . $metric); + } + $ratio = $typephp[$metric] / $php[$metric]; + printf("%-22s %10.2f %14.2f %12.2fx\n", $metric, $php[$metric], $typephp[$metric], $ratio); + if ($maximumRatio !== null && str_starts_with($metric, 'dynamic_') && $ratio > $maximumRatio) { + $failed = true; + } +} + +if ($failed) { + fwrite(STDERR, "Dynamic property ratio exceeded --max-ratio={$maximumRatio}\n"); + exit(1); +} diff --git a/phpunit/code/new-object-codegen.php b/phpunit/code/new-object-codegen.php index c1954618..ef6decd4 100644 --- a/phpunit/code/new-object-codegen.php +++ b/phpunit/code/new-object-codegen.php @@ -48,6 +48,13 @@ class HookOnlyDefaultCodegen } } +class GetterOnlyDefaultCodegen +{ + public string $value { + get => 'computed'; + } +} + class AsymmetricOnlyDefaultCodegen { public private(set) int $value = 0; diff --git a/phpunit/src/NewObjectCodegenTest.php b/phpunit/src/NewObjectCodegenTest.php index 1435c2f2..3289db0e 100644 --- a/phpunit/src/NewObjectCodegenTest.php +++ b/phpunit/src/NewObjectCodegenTest.php @@ -119,6 +119,40 @@ final class NewObjectCodegenTest extends \BaseTest ); } + public function testOrdinaryClassesKeepNativeReadAndWritePropertyHandlers(): void + { + [, $extension] = $this->compileFixtureAndExtension(); + + self::assertStringContainsString( + 'property_handlers_KnownNewObjectCodegen.read_property = ' + . 'base_property_handlers_KnownNewObjectCodegen->read_property;', + $extension, + ); + self::assertStringContainsString( + 'property_handlers_KnownNewObjectCodegen.write_property = ' + . 'base_property_handlers_KnownNewObjectCodegen->write_property;', + $extension, + ); + } + + public function testHookAndAsymmetricClassesKeepOnlyRequiredDispatchHandlers(): void + { + [, $extension] = $this->compileFixtureAndExtension(); + + self::assertStringNotContainsString('base_property_handlers_HookOnlyDefaultCodegen', $extension); + self::assertStringNotContainsString('base_property_handlers_GetterOnlyDefaultCodegen', $extension); + self::assertStringContainsString( + 'property_handlers_AsymmetricOnlyDefaultCodegen.read_property = ' + . 'base_property_handlers_AsymmetricOnlyDefaultCodegen->read_property;', + $extension, + ); + self::assertStringNotContainsString( + 'property_handlers_AsymmetricOnlyDefaultCodegen.write_property = ' + . 'base_property_handlers_AsymmetricOnlyDefaultCodegen->write_property;', + $extension, + ); + } + private function compileFixture(): string { return $this->compileFixtureAndExtension()[0]; diff --git a/src/Translator.php b/src/Translator.php index 17f830b6..0e807067 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -2274,7 +2274,33 @@ CODE; return $body . "return obj;\n"; }; + $needsHookReadHandler = false; + $needsHookWriteHandler = false; + foreach ($classDef->properties as $property) { + if ($property->getter !== null) { + $needsHookReadHandler = true; + $needsHookWriteHandler = true; + } + if ($property->setter !== null || $property->isPrivateSet() || $property->isProtectedSet()) { + $needsHookWriteHandler = true; + } + } + if (!$needsHookReadHandler || !$needsHookWriteHandler) { + $baseHandlers = "base_property_handlers_{$className}"; + // Keep the inherited handlers before PHPX installs the + // TypePHP unset/clone table. Most classes have no hooks; + // routing every ordinary property access through the hook + // name lookup makes dynamic properties several times + // slower even though the lookup can never succeed. + $code .= "const auto *{$baseHandlers} = {$ce}->default_object_handlers;\n"; + } $code .= "typephp_install_property_handlers({$ce}, &{$handlers});\n"; + if (!$needsHookReadHandler) { + $code .= "{$handlers}.read_property = {$baseHandlers}->read_property;\n"; + } + if (!$needsHookWriteHandler) { + $code .= "{$handlers}.write_property = {$baseHandlers}->write_property;\n"; + } if ($classDef->requireCtor) { $code .= "create_object_{$className} = php::getCreateObjectFn({$ce});\n"; $code .= "{$ce}->create_object = [](zend_class_entry *class_type) -> zend_object* {\n";