diff --git a/benchmark/declaration-headers/README.md b/benchmark/declaration-headers/README.md new file mode 100644 index 00000000..a52d86fc --- /dev/null +++ b/benchmark/declaration-headers/README.md @@ -0,0 +1,55 @@ +# Declaration header generation benchmark + +Creates 300 PHP source files with 6,001 functions, each ordinary function +having a default parameter. It prepares and converts the project, then measures +six calls to the real declaration-header generator and discards the first as +warm-up. The timed region includes rendering and writing headers; source +preparation, conversion, and C++ compilation are excluded. This measures one +compiler phase, not total build time. + +Run from a checkout with Composer development dependencies installed: + +```sh +project_dir=$(mktemp -d) +php benchmark/declaration-headers/run.php "$project_dir" > /tmp/declaration-candidate.log +tail -1 /tmp/declaration-candidate.log +``` + +The script writes generated sources and build outputs into the supplied +benchmark directory. Reuse the same directory for comparisons: absolute source +paths contribute to header names and therefore the combined content digest. + +The baseline does not contain this new benchmark. From the candidate checkout, +these commands create a separate baseline and run the same source in both. The +shared vendor directory is supported by `phpunit/bootstrap.php`'s checkout-local +source loader. + +```sh +baseline_dir=$(mktemp -d) +project_dir=$(mktemp -d) +git archive b3898c32 | tar -x -C "$baseline_dir" +ln -s "$(pwd)/vendor" "$baseline_dir/vendor" +mkdir -p "$baseline_dir/benchmark/declaration-headers" +cp benchmark/declaration-headers/run.php "$baseline_dir/benchmark/declaration-headers/run.php" +php "$baseline_dir/benchmark/declaration-headers/run.php" "$project_dir" > /tmp/declaration-baseline.log +php benchmark/declaration-headers/run.php "$project_dir" > /tmp/declaration-candidate.log +tail -1 /tmp/declaration-baseline.log +tail -1 /tmp/declaration-candidate.log +``` + +Compare median `ms` values and require identical `headers` counts and `sha256` +digests. The digest covers the contents and names of every generated declaration +header, including runtime and aggregate headers. + +## Sample result + +Linux ARM64 Docker, PHP 8.5.10 ZTS, CLI opcache disabled; baseline `b3898c32`: + +| Revision | Median (ms) | Headers | +| --- | ---: | ---: | +| Baseline | 88.001 | 302 | +| Grouped functions | 13.087 | 302 | + +The measured phase is about 6.72x faster, with identical output digests. Raw +samples are in `results-arm64.json`. This workload isolates function-heavy +projects; class and constant declaration scans are not optimized by this change. diff --git a/benchmark/declaration-headers/results-arm64.json b/benchmark/declaration-headers/results-arm64.json new file mode 100644 index 00000000..358210c4 --- /dev/null +++ b/benchmark/declaration-headers/results-arm64.json @@ -0,0 +1,31 @@ +{ + "environment": "Linux ARM64 Docker, PHP 8.5.10 ZTS, no CLI opcache", + "baseline_commit": "b3898c32", + "files": 300, + "functions": 6001, + "baseline": { + "ms": [ + 88.000792, + 87.998334, + 90.341888, + 90.412138, + 87.537832 + ], + "headers": 302, + "sha256": "bfad6f04ca81c591734aeeaf223cc32ef7064768ca12faba8ac41ef053444f37", + "median_ms": 88.000792 + }, + "candidate": { + "ms": [ + 13.015777, + 13.490487, + 12.763525, + 13.087318, + 13.106777 + ], + "headers": 302, + "sha256": "bfad6f04ca81c591734aeeaf223cc32ef7064768ca12faba8ac41ef053444f37", + "median_ms": 13.087318 + }, + "speedup": 6.7241272810823425 +} diff --git a/benchmark/declaration-headers/run.php b/benchmark/declaration-headers/run.php new file mode 100644 index 00000000..ee4ea44d --- /dev/null +++ b/benchmark/declaration-headers/run.php @@ -0,0 +1,43 @@ +\n"); + exit(1); +} +$root = realpath($argv[1]); +$files = []; +for ($i = 0; $i < 300; ++$i) { + $source = "setTargetName('declaration_benchmark'); +(new ReflectionMethod($compiler, 'setBuildDir'))->invoke($compiler, "$root/build"); +$compiler->addFiles($files); +foreach ($files as $file) { + $compiler->prepareFile($file); +} +$compiler->convert($files); +$generate = new ReflectionMethod($compiler, 'genDeclarationHeaders'); +$samples = []; +for ($i = 0; $i < 6; ++$i) { + $start = hrtime(true); + $generate->invoke($compiler, $files); + if ($i > 0) { + $samples[] = (hrtime(true) - $start) / 1e6; + } +} +$hashes = []; +foreach (glob("$root/build/include/*decl.h") as $file) { + $hashes[basename($file)] = hash_file('sha256', $file); +} +ksort($hashes); +echo "BENCHMARK_RESULT=" . json_encode(['ms' => $samples, 'headers' => count($hashes), 'sha256' => hash('sha256', json_encode($hashes))]) . "\n"; diff --git a/phpunit/src/Build/IncrementalDeclarationTest.php b/phpunit/src/Build/IncrementalDeclarationTest.php index 0f646e0e..fedc6318 100644 --- a/phpunit/src/Build/IncrementalDeclarationTest.php +++ b/phpunit/src/Build/IncrementalDeclarationTest.php @@ -122,6 +122,42 @@ PHP); self::assertSame($this->provider, $symbols['constant:Incremental\\LIMIT']); } + public function testGroupedDeclarationsPreserveHelpersAndFilesWithoutFunctions(): void + { + file_put_contents($this->provider, <<<'PHP' +consumer, "independent, "convertProject(); + foreach ([$this->provider, $this->consumer, $this->independent] as $file) { + $expected = $this->invoke($compiler, 'renderFunctionDeclarations', $file) + . $this->invoke($compiler, 'renderDataDeclarations', $file, false, false); + self::assertSame($expected, file_get_contents($compiler->getDeclarationHeaderFile($file))); + } + $provider = file_get_contents($compiler->getDeclarationHeaderFile($this->provider)); + self::assertStringContainsString('php_incremental__answer_arg_0_default_value();', $provider); + self::assertStringContainsString('php_incremental__answer_arg_1_default_value();', $provider); + self::assertStringContainsString('php_incremental__base__amount_arg_0_default_value();', $provider); + self::assertStringNotContainsString('php_main(', $provider); + $independent = file_get_contents($compiler->getDeclarationHeaderFile($this->independent)); + self::assertStringNotContainsString('php_incremental__answer', $independent); + self::assertStringNotContainsString('php_main(', $independent); + self::assertStringNotContainsString('_default_value', $independent); + + $aggregate = $this->invoke($compiler, 'renderFunctionDeclarations'); + self::assertStringContainsString('php_incremental__answer(', $aggregate); + self::assertStringContainsString('php_main(', $aggregate); + self::assertStringContainsString('php_incremental__base__amount_arg_0_default_value();', $aggregate); + } + public function testSuperglobalsAreDeclaredInEachUsingSource(): void { file_put_contents($this->provider, <<<'PHP' diff --git a/src/Generator/DefaultArgumentGenerator.php b/src/Generator/DefaultArgumentGenerator.php index 89662068..357ac581 100644 --- a/src/Generator/DefaultArgumentGenerator.php +++ b/src/Generator/DefaultArgumentGenerator.php @@ -11,6 +11,7 @@ use TypePhp\Type; use TypePhp\Entity\ArgInfo; use TypePhp\Entity\ArrayInitPlan; +use TypePhp\Entity\FunctionDef; trait DefaultArgumentGenerator { @@ -43,13 +44,11 @@ trait DefaultArgumentGenerator return "do {\n" . $plan->init . $body . $plan->clean . "} while (0);\n"; } - protected function genDefaultArgumentHelperDeclarations(?string $sourceFile = null): string + /** @param array $functions */ + protected function genDefaultArgumentHelperDeclarations(array $functions): string { $code = ''; - foreach ($this->symbols->functions() as $nativeName => $func) { - if ($sourceFile !== null && $func->sourceFile !== $sourceFile) { - continue; - } + foreach ($functions as $nativeName => $func) { foreach ($func->argInfoList as $argumentIndex => $argInfo) { if (!$this->shouldGenerateDefaultArgumentHelper($argInfo)) { continue; diff --git a/src/Translator.php b/src/Translator.php index 24bc5b06..b9e82f58 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -2841,8 +2841,15 @@ CODE; $this->writeFile($file, $this->renderFunctionDeclarations()); } - protected function renderFunctionDeclarations(?string $sourceFile = null): string + /** @param array|null $functions */ + protected function renderFunctionDeclarations(?string $sourceFile = null, ?array $functions = null): string { + $functions ??= $sourceFile === null + ? $this->symbols->functions() + : array_filter( + $this->symbols->functions(), + static fn (FunctionDef $function): bool => $function->sourceFile === $sourceFile, + ); $code = '#pragma once' . PHP_EOL . PHP_EOL; $code .= '#include ' . PHP_EOL; $code .= '#include ' . PHP_EOL; @@ -2861,10 +2868,7 @@ CODE; $code .= $this->genLibraryApiMacro($this->targetName); } $importLibraries = []; - foreach ($this->symbols->functions() as $function) { - if ($sourceFile !== null && $function->sourceFile !== $sourceFile) { - continue; - } + foreach ($functions as $function) { if ($this->isImportedFunction($function)) { $importLibraries[$function->importLibrary] = true; } @@ -2873,12 +2877,9 @@ CODE; $code .= $this->genLibraryImportMacro($library); } - $code .= $this->genDefaultArgumentHelperDeclarations($sourceFile); + $code .= $this->genDefaultArgumentHelperDeclarations($functions); - foreach ($this->symbols->functions() as $name => $func) { - if ($sourceFile !== null && $func->sourceFile !== $sourceFile) { - continue; - } + foreach ($functions as $name => $func) { if ($func->abstractMethod) { continue; } @@ -3008,11 +3009,20 @@ CODE; $this->writeFile($runtimeHeader, '#pragma once' . PHP_EOL . PHP_EOL . $this->renderDataDeclarations(null, true, false) . $this->genNativeObjectForwardDeclarations()); + $functionsBySource = null; foreach ($this->declarationHeaderFiles as $file => $header) { if (!$this->shouldRegeneratePhpFile($file)) { continue; } - $code = $this->renderFunctionDeclarations($file); + // Group once, only if a header needs regeneration. Keep this local + // so later passes see symbols finalized by their own conversion. + if ($functionsBySource === null) { + $functionsBySource = []; + foreach ($this->symbols->functions() as $name => $function) { + $functionsBySource[$function->sourceFile][$name] = $function; + } + } + $code = $this->renderFunctionDeclarations($file, $functionsBySource[$file] ?? []); $code .= $this->renderDataDeclarations($file, false, false); $this->writeFile( $this->getIncludeDir() . '/' . $header,