feat(compiler): add property access caching mechanism for Zend integration

- Introduce PropertyCacheId enum and get_property_cache() runtime helper
- Add per-generated-access-site Zend object-handler cache slots in CompilerBase
- Implement direct magic property access resolution for exact compiled classes
- Enhance property access methods with optional cache parameter support
- Generate cached property read/write operations for named static properties
- Add thread-local property cache map initialization and reset handling
- Create comprehensive benchmark suite for bridge operations including property access
- Add test coverage for property cache site generation and behavior validation
master
韩天峰 7 hours ago
parent 1061848f97
commit 7768ce177e
  1. 2
      benchmark/README.md
  2. 4
      benchmark/bridge/.gitignore
  3. 19
      benchmark/bridge/README.md
  4. 183
      benchmark/bridge/benchmark.php
  5. 8
      benchmark/bridge/project.yml
  6. 126
      benchmark/bridge/run.php
  7. 40
      phpunit/code/property-cache-sites.php
  8. 13
      phpunit/src/CompilerBaseApiTest.php
  9. 27
      phpunit/src/PropertyCacheCodegenTest.php
  10. 13
      src/CompilerBase.php
  11. 224
      src/Parser/PropertyAccessTrait.php
  12. 16
      src/Translator.php
  13. 195
      tests/compiler/magic_methods/named-property-cache.phpt

@ -7,6 +7,8 @@ machine instead of committing absolute timing expectations.
- `bench.php` and `micro_bench.php` are the original general workloads moved
from `examples/`.
- `bridge/` measures calls, property operations, and container operations that
cross the generated-code/PHPX/Zend boundary.
- `property-access/` builds and compares dynamic/static property access under
Zend PHP and TypePHP.

@ -0,0 +1,4 @@
/build/
/bridge_benchmark
/bridge_benchmark.exe
/*.rsp

@ -0,0 +1,19 @@
# PHP bridge benchmark
This benchmark measures the cost of common operations that cross between
generated TypePHP code and PHPX/Zend. It is the maintained version of the
original `debug/bridge-bench` reproducer.
Run it from the repository root:
```bash
PHPX_HOME=../phpx PHP_BIN=/opt/php-8.5-nts/bin/php php benchmark/bridge/run.php
```
The TypePHP binary is built with `-O3` and LTO. `PHP_BIN` selects the Zend PHP
binary used for the baseline; `TPC_PHP_BIN` can independently select the PHP
binary that runs `bin/tpc.php`. Add `--skip-build` to reuse an existing binary.
Use `--case=magic_property` to run only one workload while profiling.
Results are the best of five rounds after warm-up. Always compare PHP and
TypePHP in the same run on an otherwise idle machine.

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
const BRIDGE_ITERATIONS = 10_000_000;
const BRIDGE_CONTAINER_ITERATIONS = 1_000_000;
const BRIDGE_ROUNDS = 5;
function bridgePureInt(int $iterations): int
{
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += $i * 2 + 1;
}
return $sum;
}
function bridgeAddOne(int $value): int
{
return $value + 1;
}
function bridgeFunctionCall(int $iterations): int
{
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += bridgeAddOne($i);
}
return $sum;
}
final class BridgeCalculator
{
public function hit(int $value): int
{
return $value + 1;
}
}
function bridgeMethodCall(int $iterations): int
{
$calculator = new BridgeCalculator();
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += $calculator->hit($i);
}
return $sum;
}
final class BridgeCounter
{
public int $value = 0;
}
function bridgePropertyAccess(int $iterations): int
{
$counter = new BridgeCounter();
for ($i = 0; $i < $iterations; $i++) {
$counter->value++;
}
return $counter->value;
}
final class BridgeMagicCall
{
public function __call(string $name, array $arguments): int
{
return $arguments[0] + 1;
}
}
function bridgeMagicCall(int $iterations): int
{
$object = new BridgeMagicCall();
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$sum += $object->hit($i);
}
return $sum;
}
final class BridgeMagicProperty
{
private array $data = ['value' => 0];
public function __get(string $name): int
{
return $this->data[$name];
}
public function __set(string $name, mixed $value): void
{
$this->data[$name] = $value;
}
}
function bridgeMagicProperty(int $iterations): int
{
$object = new BridgeMagicProperty();
for ($i = 0; $i < $iterations; $i++) {
$object->value = $object->value + 1;
}
return $object->value;
}
function bridgeArrayAppend(int $iterations): int
{
$values = [];
for ($i = 0; $i < $iterations; $i++) {
$values[] = $i;
}
return count($values);
}
function bridgeStringConcat(int $iterations): string
{
$value = '';
for ($i = 0; $i < $iterations; $i++) {
$value .= 'x';
}
return $value;
}
function measureBridgeCase(string $case): array
{
$iterations = match ($case) {
'array_append', 'string_concat' => BRIDGE_CONTAINER_ITERATIONS,
default => BRIDGE_ITERATIONS,
};
$best = 0;
$bestResult = null;
for ($round = 0; $round < BRIDGE_ROUNDS; $round++) {
$start = hrtime(true);
$result = match ($case) {
'pure_int' => bridgePureInt($iterations),
'function_call' => bridgeFunctionCall($iterations),
'method_call' => bridgeMethodCall($iterations),
'property_access' => bridgePropertyAccess($iterations),
'magic_call' => bridgeMagicCall($iterations),
'magic_property' => bridgeMagicProperty($iterations),
'array_append' => bridgeArrayAppend($iterations),
'string_concat' => bridgeStringConcat($iterations),
default => throw new RuntimeException("Unknown benchmark case: {$case}"),
};
$elapsed = hrtime(true) - $start;
if ($round === 0 || $elapsed < $best) {
$best = $elapsed;
$bestResult = $result;
}
}
return [$best / $iterations, $bestResult];
}
function main(): void
{
bridgePureInt(1000);
bridgeFunctionCall(1000);
bridgeMethodCall(1000);
bridgePropertyAccess(1000);
bridgeMagicCall(1000);
bridgeMagicProperty(1000);
bridgeArrayAppend(1000);
bridgeStringConcat(1000);
$selectedCase = getenv('BRIDGE_CASE');
foreach ([
'pure_int',
'function_call',
'method_call',
'property_access',
'magic_call',
'magic_property',
'array_append',
'string_concat',
] as $case) {
if (is_string($selectedCase) && $selectedCase !== '' && $case !== $selectedCase) {
continue;
}
[$nanoseconds, $result] = measureBridgeCase($case);
printf("%s_ns=%.3f\n", $case, $nanoseconds);
printf("checksum_%s=%s\n", $case, is_string($result) ? strlen($result) : $result);
}
}

@ -0,0 +1,8 @@
name: bridge_benchmark
mode: bin
optimize: 3
lto: true
build-dir: build
output: bridge_benchmark
sources:
- benchmark.php

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__, 2);
$source = __DIR__ . '/benchmark.php';
$project = __DIR__ . '/project.yml';
$binary = __DIR__ . '/bridge_benchmark' . (PHP_OS_FAMILY === 'Windows' ? '.exe' : '');
$skipBuild = in_array('--skip-build', $argv, true);
$selectedCase = null;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--case=')) {
$selectedCase = substr($argument, strlen('--case='));
}
}
/** @param list<string> $command */
function runBridgeCommand(array $command, string $cwd, ?array $environment = null): string
{
$process = proc_open(
$command,
[STDIN, ['pipe', 'w'], ['pipe', 'w']],
$pipes,
$cwd,
$environment,
['bypass_shell' => true],
);
if (!is_resource($process)) {
throw new RuntimeException('Failed to start: ' . implode(' ', $command));
}
$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<string, float> */
function parseBridgeResults(string $output): array
{
$results = [];
foreach (explode("\n", trim($output)) as $line) {
if (!preg_match('/^([a-z_]+)_ns=([0-9.]+)$/', $line, $matches)) {
continue;
}
$results[$matches[1]] = (float) $matches[2];
}
return $results;
}
$compilerPhp = getenv('TPC_PHP_BIN') ?: PHP_BINARY;
$baselinePhp = getenv('PHP_BIN') ?: PHP_BINARY;
if (!$skipBuild) {
echo "Building TypePHP benchmark (-O3 + LTO)...\n";
echo runBridgeCommand([
$compilerPhp,
$root . '/bin/tpc.php',
$project,
'-j',
'8',
'--no-color',
'--no-progress',
], $root);
}
if (!is_file($binary)) {
throw new RuntimeException('Benchmark binary does not exist: ' . $binary);
}
$benchmarkEnvironment = getenv();
if ($selectedCase !== null && $selectedCase !== '') {
$benchmarkEnvironment['BRIDGE_CASE'] = $selectedCase;
}
$php = parseBridgeResults(runBridgeCommand([
$baselinePhp,
'-n',
'-d',
'opcache.enable_cli=0',
'-d',
'opcache.jit=0',
'-r',
'require ' . var_export($source, true) . '; main();',
], $root, $benchmarkEnvironment));
$environment = $benchmarkEnvironment;
if (PHP_OS_FAMILY !== 'Windows') {
$phpxHome = getenv('PHPX_HOME') ?: dirname($root) . '/phpx';
$loaderVariable = PHP_OS_FAMILY === 'Darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
$existing = $environment[$loaderVariable] ?? '';
$environment[$loaderVariable] = $phpxHome . '/lib'
. ($existing === '' ? '' : PATH_SEPARATOR . $existing);
}
$typephp = parseBridgeResults(runBridgeCommand([$binary], $root, $environment));
echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n";
echo "------------------------------------------------------------\n";
$metrics = [
'pure_int',
'function_call',
'method_call',
'property_access',
'magic_call',
'magic_property',
'array_append',
'string_concat',
];
if ($selectedCase !== null && $selectedCase !== '') {
$metrics = [$selectedCase];
}
foreach ($metrics as $metric) {
if (!isset($php[$metric], $typephp[$metric])) {
throw new RuntimeException("Missing benchmark metric: {$metric}");
}
printf(
"%-22s %10.2f %14.2f %12.2fx\n",
$metric,
$php[$metric],
$typephp[$metric],
$typephp[$metric] / $php[$metric],
);
}

@ -0,0 +1,40 @@
<?php
function propertyCacheReceiver(object $object): object
{
return $object;
}
function propertyCacheSites(object $object, string $name, mixed $value): mixed
{
$first = $object->named;
$object->named = $value;
propertyCacheReceiver($object)->other = $value;
$dynamic = $object->{$name};
$object->{$name} = $value;
return [$first, $dynamic];
}
final class DirectMagicPropertySites
{
private array $values = [];
public function __get(string $name): mixed
{
return $this->values[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->values[$name] = $value;
}
}
function directMagicPropertySites(mixed $value): mixed
{
$object = new DirectMagicPropertySites();
$current = $object->missing;
$object->missing = $value;
return $current;
}

@ -1276,6 +1276,19 @@ YAML);
$this->assertStringContainsString('zend_class_entry *get_class(', $extension, $mode);
$this->assertStringContainsString('static void module_init()', $extension, $mode);
$this->assertStringContainsString('static void module_clean()', $extension, $mode);
$moduleInitStart = strpos($extension, 'static void module_init()');
$moduleCleanStart = strpos($extension, 'static void module_clean()');
$this->assertIsInt($moduleInitStart, $mode);
$this->assertIsInt($moduleCleanStart, $mode);
$moduleInit = substr($extension, $moduleInitStart, $moduleCleanStart - $moduleInitStart);
$this->assertStringNotContainsString('slot.reset()', $moduleInit, $mode);
$this->assertMatchesRegularExpression(
'/PHP_RSHUTDOWN_FUNCTION\([^)]*\)\s*\{\s*'
. 'for \(auto &slot : php_property_cache_map\) \{\s*slot\.reset\(\);\s*\}\s*'
. 'php::request_shutdown\(\);/s',
$extension,
$mode,
);
$this->assertStringContainsString('typephp_register_fiber_generator_class();', $extension, $mode);
$this->assertStringContainsString('typephp_unregister_fiber_generator_class();', $extension, $mode);
$this->assertStringNotContainsString('php_app_init', $extension, $mode);

@ -0,0 +1,27 @@
<?php
use TypePhp\CompilerTest;
final class PropertyCacheCodegenTest extends BaseTest
{
public function testOnlyStaticallyNamedPropertySitesReceiveZendCacheSlots(): void
{
global $translator;
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$translator = $compiler;
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/property-cache-sites.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$generated = $compiler->convertFile($source);
$code = file_get_contents($generated);
self::assertIsString($code);
self::assertSame(1, substr_count($code, 'typephp_read_property_cached('));
self::assertSame(2, substr_count($code, 'typephp_write_property_cached('));
self::assertStringContainsString('.attr(name, php::AttrMode::Get)', $code);
self::assertStringContainsString('typephp_write_property_scoped(object, name, value', $code);
self::assertSame(1, substr_count($code, 'typephp_read_magic_property_direct('));
self::assertSame(1, substr_count($code, 'typephp_write_magic_property_direct('));
}
}

@ -331,6 +331,12 @@ class CompilerBase implements PropertyAccessContext
*/
protected array $persistentPropMap = [];
protected int $persistentPropIndex = 0;
/**
* Per-generated-access-site Zend object-handler cache slots. Unlike the
* declared-property offset cache above, these are request-local and may
* cache a runtime class together with a dynamic/hooked-property sentinel.
*/
protected int $propertyAccessCacheIndex = 0;
/** @var array<string, array<Node\Stmt>> Prepared declaration ASTs keyed by real path. */
protected array $preparedFileAsts = [];
protected bool $declarationExpressionsFinalized = false;
@ -1289,6 +1295,13 @@ class CompilerBase implements PropertyAccessContext
return $id;
}
protected function getPropertyAccessCache(): string
{
$this->assertCompilerPhase(self::PHASE_CONVERT, 'property access cache ID allocation');
$id = $this->propertyAccessCacheIndex++;
return 'get_property_cache(PropertyCacheId{' . $id . '})';
}
protected function getClassEntryPtr(string $className): string
{
$id = $this->getClassId($className);

@ -23,41 +23,142 @@ use TypePhp\Generator\Symbol;
trait PropertyAccessTrait
{
/**
* Resolve a direct TypePHP magic-property body only for an exact, simple
* compiled class. The runtime helper still rechecks handlers, lazy state,
* declared/dynamic properties, and Zend's recursion guard before calling
* this function; otherwise it falls back to the standard handler.
*
* @return array{function: string, classEntry: string}|null
*/
private function resolveDirectMagicPropertyAccess(
Expr\PropertyFetch $expr,
string $object,
string $magicMethod,
): ?array {
if (!$this->isIdExpr($expr->name)
|| !$this->isVarExpr($expr->var)
|| $this->getVarType($object) !== Type::OBJECT
) {
return null;
}
$class = $this->detectClassOfExpr($expr->var);
if ($class === '' || !$this->hasClass($class)) {
return null;
}
$exactClass = null;
if ($object === 'this_' && $this->isCurrentClassFinal()) {
$exactClass = $this->getFullClassName();
} elseif (isset($this->context->exactObjects[$object])) {
$exactClass = $this->context->exactObjects[$object];
} elseif ($this->isFinalClass($class)) {
$exactClass = $class;
}
if ($exactClass === null
|| strcasecmp(ltrim($exactClass, '\\'), ltrim($class, '\\')) !== 0
|| !$this->hasClass($exactClass)
) {
return null;
}
$classDef = $this->getClass($exactClass);
$property = $this->parseIdentifier($expr->name);
// Keep the first implementation deliberately narrow. An internal or
// compiled parent may install custom object handlers; inherited magic
// methods also need a different generated receiver ABI.
if ($classDef->extends !== ''
|| $classDef->nativeObject
|| $classDef->trait
|| $classDef->hasProperty($property)
|| !$classDef->hasMethod($magicMethod)
) {
return null;
}
$method = $classDef->getMethod($magicMethod);
if ($method->functionDef === null
|| ($magicMethod === '__get' && $method->functionDef->returnsByRef)
) {
return null;
}
$nativeFunction = $this->getNativeName(
$magicMethod,
$classDef->namespace,
$classDef->name,
);
if (!$this->hasFunction($nativeFunction)) {
return null;
}
return [
'function' => self::PREFIX . $nativeFunction,
'classEntry' => $this->getLocalClassEntryPtr($exactClass),
];
}
protected function usesTraitPropertyScope(string $object): bool
{
return $this->classDef?->trait && $object === 'this_';
}
protected function emitDynamicPropertyRead(string $object, string $property): string
protected function emitDynamicPropertyRead(string $object, string $property, ?string $cache = null): string
{
if ($this->usesTraitPropertyScope($object)) {
return 'typephp_read_property_scoped('
. $object . ', ' . $property . ', php::FakeScopeGuard::current(), php::AttrMode::Get)';
}
if ($cache !== null) {
return 'typephp_read_property_cached('
. $object . ', ' . $property . ', php::AttrMode::Get, ' . $cache . ')';
}
return "{$object}.getProperty({$property})";
}
protected function emitDynamicPropertyWrite(string $object, string $property, string $value): string
protected function emitDynamicPropertyWrite(
string $object,
string $property,
string $value,
?string $cache = null,
): string
{
$scope = $this->usesTraitPropertyScope($object)
? 'php::FakeScopeGuard::current()'
: ($this->class ? $this->getLocalClassEntryPtr($this->getFullClassName()) : 'nullptr');
if ($cache !== null && !$this->usesTraitPropertyScope($object)) {
return 'typephp_write_property_cached('
. $object . ', ' . $property . ', ' . $value . ', ' . $scope . ', ' . $cache . ')';
}
return 'typephp_write_property_scoped('
. $object . ', ' . $property . ', ' . $value . ', ' . $scope . ')';
}
protected function emitDynamicPropertyTargetRead(PropertyWriteTarget $target): string
protected function emitDynamicPropertyTargetRead(PropertyWriteTarget $target, ?string $cache = null): string
{
$this->assertDynamicPropertyTarget($target);
return $this->emitDynamicPropertyRead($target->getDynamicObjectExpr(), $target->getDynamicPropertyExpr());
return $this->emitDynamicPropertyRead(
$target->getDynamicObjectExpr(),
$target->getDynamicPropertyExpr(),
$cache,
);
}
protected function emitDynamicPropertyTargetWrite(PropertyWriteTarget $target, string $value): string
protected function emitDynamicPropertyTargetWrite(
PropertyWriteTarget $target,
string $value,
?string $cache = null,
): string
{
$this->assertDynamicPropertyTarget($target);
return $this->emitDynamicPropertyWrite($target->getDynamicObjectExpr(), $target->getDynamicPropertyExpr(), $value);
return $this->emitDynamicPropertyWrite(
$target->getDynamicObjectExpr(),
$target->getDynamicPropertyExpr(),
$value,
$cache,
);
}
protected function emitDynamicPropertyTargetUnset(PropertyWriteTarget $target): string
@ -74,18 +175,28 @@ trait PropertyAccessTrait
return $target->getDynamicObjectExpr() . '.attrRef(' . $target->getDynamicPropertyExpr() . ')';
}
protected function emitDynamicPropertyTargetAppendArray(PropertyWriteTarget $target, string $value): string
protected function emitDynamicPropertyTargetAppendArray(
PropertyWriteTarget $target,
string $value,
?string $cache = null,
): string
{
$this->assertDynamicPropertyTarget($target);
return $this->emitDynamicPropertyAppendArray(
$target->getDynamicObjectExpr(),
$target->getDynamicPropertyExpr(),
$value
$value,
$cache,
);
}
protected function emitDynamicPropertyTargetUpdateArray(PropertyWriteTarget $target, string $dim, string $value): string
protected function emitDynamicPropertyTargetUpdateArray(
PropertyWriteTarget $target,
string $dim,
string $value,
?string $cache = null,
): string
{
$this->assertDynamicPropertyTarget($target);
@ -93,7 +204,8 @@ trait PropertyAccessTrait
$target->getDynamicObjectExpr(),
$target->getDynamicPropertyExpr(),
$dim,
$value
$value,
$cache,
);
}
@ -104,26 +216,50 @@ trait PropertyAccessTrait
protected function emitDynamicPropertyFetchRead(Expr\PropertyFetch $expr, ?PropertyWriteTarget $target = null): string
{
$cache = $this->isIdExpr($expr->name) && !$this->isNativePropertyAccess($expr)
? $this->getPropertyAccessCache()
: null;
if ($this->canEmitDynamicPropertyTarget($target)) {
return $this->emitDynamicPropertyTargetRead($target);
return $this->emitDynamicPropertyTargetRead($target, $cache);
}
return $this->emitDynamicPropertyRead(
$this->parseIdentifier($expr->var),
$this->propertyNameToStr($expr->name, literal: true)
$this->propertyNameToStr($expr->name, literal: true),
$cache,
);
}
protected function emitDynamicPropertyFetchWrite(Expr\PropertyFetch $expr, string $value, ?PropertyWriteTarget $target = null): string
{
$cache = $this->isIdExpr($expr->name) && !$this->isNativePropertyAccess($expr)
? $this->getPropertyAccessCache()
: null;
$object = $this->canEmitDynamicPropertyTarget($target)
? $target->getDynamicObjectExpr()
: $this->parseIdentifier($expr->var);
$direct = $cache !== null && $this->hasVar($value) && $this->getVarType($value) === Type::VAR
? $this->resolveDirectMagicPropertyAccess($expr, $object, '__set')
: null;
if ($direct !== null) {
$property = $this->propertyNameToStr($expr->name, literal: true);
$scope = $this->class
? $this->getLocalClassEntryPtr($this->getFullClassName())
: 'nullptr';
return 'typephp_write_magic_property_direct('
. $object . ', ' . $property . ', ' . $value . ', ' . $scope . ', '
. $direct['classEntry'] . ', ' . $cache . ', [&]() {'
. $direct['function'] . '(' . $object . ', ' . $property . ', ' . $value . '); })';
}
if ($this->canEmitDynamicPropertyTarget($target)) {
return $this->emitDynamicPropertyTargetWrite($target, $value);
return $this->emitDynamicPropertyTargetWrite($target, $value, $cache);
}
return $this->emitDynamicPropertyWrite(
$this->parseIdentifier($expr->var),
$object,
$this->propertyNameToStr($expr->name, literal: true),
$value
$value,
$cache,
);
}
@ -151,13 +287,18 @@ trait PropertyAccessTrait
return $this->parseWritableIdentifier($expr) . ".newItem() = {$value}";
}
if ($this->canEmitDynamicPropertyTarget($target)) {
return $this->emitDynamicPropertyTargetAppendArray($target, $value);
return $this->emitDynamicPropertyTargetAppendArray(
$target,
$value,
$this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null,
);
}
return $this->emitDynamicPropertyAppendArray(
$this->parseIdentifier($expr->var),
$this->propertyNameToStr($expr->name, literal: true),
$value
$value,
$this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null,
);
}
@ -167,34 +308,61 @@ trait PropertyAccessTrait
return $this->parseWritableIdentifier($expr) . ".item({$dim}, true) = {$value}";
}
if ($this->canEmitDynamicPropertyTarget($target)) {
return $this->emitDynamicPropertyTargetUpdateArray($target, $dim, $value);
return $this->emitDynamicPropertyTargetUpdateArray(
$target,
$dim,
$value,
$this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null,
);
}
return $this->emitDynamicPropertyUpdateArray(
$this->parseIdentifier($expr->var),
$this->propertyNameToStr($expr->name, literal: true),
$dim,
$value
$value,
$this->isIdExpr($expr->name) ? $this->getPropertyAccessCache() : null,
);
}
protected function emitDynamicPropertyAppendArray(string $object, string $property, string $value): string
protected function emitDynamicPropertyAppendArray(
string $object,
string $property,
string $value,
?string $cache = null,
): string
{
if ($this->usesTraitPropertyScope($object)) {
return 'typephp_read_property_scoped('
. $object . ', ' . $property . ', php::FakeScopeGuard::current(), php::AttrMode::Update)'
. ".newItem() = {$value}";
}
if ($cache !== null) {
return 'typephp_read_property_cached('
. $object . ', ' . $property . ', php::AttrMode::Update, ' . $cache . ')'
. ".newItem() = {$value}";
}
return "{$object}.attr({$property}, php::AttrMode::Update).newItem() = {$value}";
}
protected function emitDynamicPropertyUpdateArray(string $object, string $property, string $dim, string $value): string
protected function emitDynamicPropertyUpdateArray(
string $object,
string $property,
string $dim,
string $value,
?string $cache = null,
): string
{
if ($this->usesTraitPropertyScope($object)) {
return 'typephp_read_property_scoped('
. $object . ', ' . $property . ', php::FakeScopeGuard::current(), php::AttrMode::Update)'
. ".item({$dim}, true) = {$value}";
}
if ($cache !== null) {
return 'typephp_read_property_cached('
. $object . ', ' . $property . ', php::AttrMode::Update, ' . $cache . ')'
. ".item({$dim}, true) = {$value}";
}
return "{$object}.attr({$property}, php::AttrMode::Update).item({$dim}, true) = {$value}";
}
@ -1003,9 +1171,21 @@ trait PropertyAccessTrait
. $this->getNativeObjectPropertyCppName($resolution->propertyDef, $resolution->classDef);
}
$objectVar = $objectName;
if ($this->usesTraitPropertyScope($objectVar)) {
$directMagic = !$update && !$this->isNativePropertyAccess($expr)
? $this->resolveDirectMagicPropertyAccess($expr, $objectVar, '__get')
: null;
if ($directMagic !== null) {
$getProperty = 'typephp_read_magic_property_direct('
. $objectVar . ', ' . $id . ', ' . $directMagic['classEntry'] . ', '
. $this->getPropertyAccessCache() . ', [&]() {'
. ' return ' . $directMagic['function'] . '(' . $objectVar . ', ' . $id . '); })';
} elseif ($this->usesTraitPropertyScope($objectVar)) {
$getProperty = 'typephp_read_property_scoped('
. $objectVar . ', ' . $id . ', php::FakeScopeGuard::current(), ' . $this->escapeAttrMode($update) . ')';
} elseif ($this->isIdExpr($property) && !$this->isNativePropertyAccess($expr)) {
$getProperty = 'typephp_read_property_cached('
. $objectVar . ', ' . $id . ', ' . $this->escapeAttrMode($update) . ', '
. $this->getPropertyAccessCache() . ')';
} else {
$getProperty = $objectVar . '.attr(' . $id . ', ' . $this->escapeAttrMode($update) . ')';
}

@ -819,7 +819,8 @@ class Translator extends Preprocessor
$lines[] = 'enum class PersistentClassId : uint32_t {};';
$lines[] = 'enum class RequestFuncId : uint32_t {};';
$lines[] = 'enum class PersistentFuncId : uint32_t {};';
$lines[] = 'enum class PersistentPropertyId : uint32_t {};' . PHP_EOL;
$lines[] = 'enum class PersistentPropertyId : uint32_t {};';
$lines[] = 'enum class PropertyCacheId : uint32_t {};' . PHP_EOL;
$lines[] = 'zend_class_entry *get_class(RequestClassId class_id, const php::Str &class_name);';
$lines[] = 'zend_function *get_func(RequestFuncId func_id, const php::Str &func_name);';
@ -828,6 +829,7 @@ class Translator extends Preprocessor
$lines[] = 'zend_function *get_persistent_func(PersistentFuncId func_id, const php::Str &func_name);';
$lines[] = 'zend_function *get_persistent_method(PersistentFuncId func_id, const php::Str &method_name, PersistentClassId class_id, const php::Str &class_name);';
$lines[] = 'uint32_t get_persistent_prop(PersistentPropertyId prop_id, const php::Str &prop_name, const php::Str &class_name);' . PHP_EOL;
$lines[] = 'php::PropertyCacheSlot &get_property_cache(PropertyCacheId cache_id);' . PHP_EOL;
foreach ($this->getClassLikesWithConstants() as $classDef) {
foreach ($classDef->constants as $constant) {
@ -927,6 +929,11 @@ class Translator extends Preprocessor
// No dynamic propMap: the property offset cache only covers declared
// properties of compiled/built-in classes (see getPropertyId).
$code .= 'static php::PersistentCacheSlot<uint32_t> ' . self::PREFIX . self::PERSISTENT_PROP_MAP . '[' . max(1, count($this->persistentPropMap)) . ']{};' . PHP_EOL;
// Zend's object handlers use three adjacent pointers as one cache
// entry. Keep these slots request-local: a named access may receive a
// class provided by an ordinary PHP script whose CE is not persistent.
$code .= 'static THREAD_LOCAL php::PropertyCacheSlot ' . self::PREFIX . 'property_cache_map['
. max(1, $this->propertyAccessCacheIndex) . ']{};' . PHP_EOL;
$code .= "// functions \n";
@ -985,6 +992,10 @@ uint32_t get_persistent_prop(PersistentPropertyId prop_id, const php::Str &prop_
});
return value - 1024;
}
php::PropertyCacheSlot &get_property_cache(PropertyCacheId cache_id) {
return php_property_cache_map[static_cast<uint32_t>(cache_id)];
}
CODE;
$code .= "\n\n";
@ -1300,6 +1311,9 @@ CODE;
$code .= <<<CODE
PHP_RSHUTDOWN_FUNCTION({$moduleName}) {
for (auto &slot : php_property_cache_map) {
slot.reset();
}
php::request_shutdown();
module_clean();
return SUCCESS;

@ -0,0 +1,195 @@
--TEST--
Named magic property cache preserves polymorphism, dynamic names, and receiver evaluation
--FILE--
<?php
declare(strict_types=1);
class FirstMagicProperty
{
private array $values = [];
public function __get(string $name): mixed
{
echo "first:get:$name\n";
return $this->values[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
echo "first:set:$name=$value\n";
$this->values[$name] = $value;
}
}
class SecondMagicProperty
{
private array $values = [];
public function __get(string $name): mixed
{
echo "second:get:$name\n";
return $this->values[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
echo "second:set:$name=$value\n";
$this->values[$name] = $value;
}
}
#[AllowDynamicProperties]
class MaterializingMagicProperty
{
public int $setCalls = 0;
public function __set(string $name, mixed $value): void
{
$this->setCalls++;
$this->{$name} = $value;
}
}
final class RecursiveMagicProperty
{
public int $getCalls = 0;
public function __get(string $name): mixed
{
$this->getCalls++;
return @$this->{$name};
}
}
final class ThrowingMagicProperty
{
public int $getCalls = 0;
public int $setCalls = 0;
public function __get(string $name): mixed
{
$this->getCalls++;
if ($this->getCalls === 1) {
throw new RuntimeException('get failed');
}
return 77;
}
public function __set(string $name, mixed $value): void
{
$this->setCalls++;
if ($this->setCalls === 1) {
throw new RuntimeException('set failed');
}
}
}
function readNamedProperty(object $object): mixed
{
return $object->value;
}
function writeNamedProperty(object $object, mixed $value): void
{
$object->value = $value;
}
function readDynamicProperty(object $object, string $name): mixed
{
return $object->{$name};
}
function writeDynamicProperty(object $object, string $name, mixed $value): void
{
$object->{$name} = $value;
}
function namedPropertyReceiver(object $object, int &$calls): object
{
$calls++;
return $object;
}
function main(): void
{
$first = new FirstMagicProperty();
$second = new SecondMagicProperty();
// One generated access site sees different runtime class entries. Zend
// must invalidate and refill the polymorphic cache rather than reusing the
// first class's magic-property result.
writeNamedProperty($first, 10);
writeNamedProperty($second, 20);
writeNamedProperty($first, 30);
var_dump(readNamedProperty($first));
var_dump(readNamedProperty($second));
var_dump(readNamedProperty($first));
// A dynamic property-name expression deliberately remains uncached.
writeDynamicProperty($second, 'other', 40);
var_dump(readDynamicProperty($second, 'other'));
// __set() materializes a real dynamic property on its first invocation.
// The cached dynamic-property sentinel must continue to dispatch through
// Zend so the second write reaches the newly created property directly.
$materialized = new MaterializingMagicProperty();
writeNamedProperty($materialized, 1);
writeNamedProperty($materialized, 2);
var_dump($materialized->setCalls, readNamedProperty($materialized));
// The direct TypePHP path owns the same per-name Zend recursion guard.
// Re-reading the same missing property from __get() must not recurse.
$recursive = new RecursiveMagicProperty();
var_dump(readNamedProperty($recursive), $recursive->getCalls);
// A direct magic method may throw a C++ exception. Its RAII guard must be
// released before the next access, just as Zend clears its guard after a
// VM-level exception.
$throwing = new ThrowingMagicProperty();
try {
writeNamedProperty($throwing, 1);
} catch (RuntimeException $e) {
echo "set caught\n";
}
writeNamedProperty($throwing, 2);
var_dump($throwing->setCalls);
try {
readNamedProperty($throwing);
} catch (RuntimeException $e) {
echo "get caught\n";
}
var_dump(readNamedProperty($throwing), $throwing->getCalls);
// A parenthesized receiver is cacheable, but is still evaluated once.
$calls = 0;
namedPropertyReceiver($first, $calls)->value = 50;
var_dump($calls, namedPropertyReceiver($first, $calls)->value, $calls);
}
?>
--EXPECT--
first:set:value=10
second:set:value=20
first:set:value=30
first:get:value
int(30)
second:get:value
int(20)
first:get:value
int(30)
second:set:other=40
second:get:other
int(40)
int(1)
int(2)
NULL
int(1)
set caught
int(2)
get caught
int(77)
int(2)
first:set:value=50
first:get:value
int(1)
int(50)
int(2)
Loading…
Cancel
Save