Skip to content

Infer static for closures#19941

Open
iluuu1994 wants to merge 4 commits intophp:masterfrom
iluuu1994:auto-static-closures
Open

Infer static for closures#19941
iluuu1994 wants to merge 4 commits intophp:masterfrom
iluuu1994:auto-static-closures

Conversation

@iluuu1994
Copy link
Copy Markdown
Member

@iluuu1994 iluuu1994 commented Sep 23, 2025

RFC: https://wiki.php.net/rfc/closure-optimizations

This PR implements static inference for closures, with some restrictions. The closure must not:

  1. Use $this. That's the obvious case.
  2. Use $$var, given $var could be 'this'.
  3. Use Foo::bar(), given this could be a hidden instance call to a (grand-)parent method.
  4. Use $f(), for the same reason as 3.
  5. Use call_user_func(), for the same reason as 3.
  6. Declare another non-static (explicit or inferred) closure, where $this flows from parent to child.
  7. Use require, include or eval, given the called code might do any of the above.

In a Symfony Demo run specifically, static inference works for 68/87 (~78%) closures that were explicitly marked as static by Symfony. That seems quite decent.

The PR also adds caching for static closures that don't have any bindings and don't declare static variables. Instances of such closures can be re-used almost without side-effects (except for object identity).

For Symfony Demo (with static removed from all closures), I measured an improvemend of ~0.1%, so definitely not very significant. Synthetic benchmarks can improve quite a bit, though this will also apply to real-world code that creates closures in loops.

function test() {
    $x = function () {};
}
for ($i = 0; $i < 10_000_000; $i++) {
    test();
}

improves by ~78% in my test runs.

If persistent objects are ever implemented, the instantiation could be done fully at compile-time.

@iluuu1994
Copy link
Copy Markdown
Member Author

@dktapps Ping. I haven't done any benchmarking yet.

@dktapps
Copy link
Copy Markdown
Contributor

dktapps commented Sep 23, 2025

This isn't caching static closures yet, right? So there should be no observable effect on performance

@iluuu1994
Copy link
Copy Markdown
Member Author

iluuu1994 commented Sep 23, 2025

This isn't caching static closures yet, right?

Right. IIRC static closures themselves have a small benefit, though I didn't see it in the CI benchmark. I'm not sure if maybe Symfony already uses linting to add static.

iluuu1994 added a commit to php/benchmarking-symfony-demo-2.2.3 that referenced this pull request Sep 24, 2025
@iluuu1994
Copy link
Copy Markdown
Member Author

Okay, testing Symfony Demo with all static closures turned into non-static ones shows virtually no improvement. Regardless, they should benefit if we can cache them. I'll try this in the same PR then, given this one isn't useful on its own.

@arnaud-lb
Copy link
Copy Markdown
Member

One drawback of non-static closures is that they retain $this, which can increase memory usage when the lifetime of $this is supposed to be shorter and references a large graph. I believe this is why coding guidelines and IDEs recommend to manually declare closure as static.

Big +1 on this.

@iluuu1994
Copy link
Copy Markdown
Member Author

iluuu1994 commented Sep 24, 2025

Quick test: In Symfony Demo with all static closures turned into non-static ones, this patch can turn 122/283 into static ones. That's ~43%, so not bad at all. How many of those can also be cached remains to be seen.

@dktapps
Copy link
Copy Markdown
Contributor

dktapps commented Sep 27, 2025

Some other weird cases that might need to be considered:

  • Variable variables
  • include,require et al
  • eval()

Source: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blame/b0a17bf1288a696cba2c8492126f0a485d5637f0/src/Fixer/FunctionNotation/StaticLambdaFixer.php#L110

@dktapps
Copy link
Copy Markdown
Contributor

dktapps commented Sep 27, 2025

To be honest I start to wonder if this actually makes sense considering the number of obscure conditions that might cause an unintended $this binding just for a potential usage. It'd avoid some unnecessary refs but there'd still be plenty of cases where static would be needed to avoid cycles and accidental refs. (I suppose it'd still benefit for common array_map() style cases where a throwaway closure is used, but 🤷 )

Has there been any discussion about a shorter syntax for static closures? e.g. something like sfn() (idk)

@iluuu1994
Copy link
Copy Markdown
Member Author

Var var is already handled in this patch. I forgot about eval, but that's quite rare as well, especially in closures.

Copy link
Copy Markdown
Member

@Girgias Girgias left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack for ext/spl test changes

@dktapps
Copy link
Copy Markdown
Contributor

dktapps commented Jan 5, 2026

Is there any movement on caching static stateless closures generally? I know the 1cc cache was merged, but there's plenty of cases where explicitly static closures could be cached too without the need for this inference. This inference would just be an added benefit to auto-static stuff in certain cases.

@iluuu1994
Copy link
Copy Markdown
Member Author

Ofc, explicitly static closures will be cached too, assuming they are stateless.

@dktapps
Copy link
Copy Markdown
Contributor

dktapps commented Jan 5, 2026

Ok, I hadn't realised caching was also in this PR. Good stuff

Copy link
Copy Markdown
Member

@arnaud-lb arnaud-lb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks right to me!

@github-actions
Copy link
Copy Markdown

AWS x86_64 (c7i.24xl)

Attribute Value
Environment aws
Runner host
Instance type c7i.metal-24xl (dedicated)
Architecture x86_64
CPU Intel(R) Xeon(R) Platinum 8488C, 48 cores @ 2400 MHz
CPU settings disabled deeper C-states, disabled turbo boost, disabled hyper-threading
RAM 188 GB
Kernel 6.1.158-178.288.amzn2023.x86_64
OS Amazon Linux 2023.9.20251117
GCC 14.2.1
Time 2026-01-13 15:22:54 UTC

Laravel 12.11.0 demo app - 100 consecutive runs, 50 warmups, 100 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@8c4f 0.45921 0.46365 0.00054 0.12% 0.45990 0.00% 0.45976 0.00% 3.659 0.999 26.93 MB
PHP - auto-static-closures 0.44028 0.44333 0.00038 0.09% 0.44077 -4.16% 0.44072 -4.14% 3.351 0.000 26.93 MB

Symfony 2.8.0 demo app - 100 consecutive runs, 50 warmups, 100 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@8c4f 0.77489 0.79602 0.00311 0.40% 0.78241 0.00% 0.78270 0.00% 0.509 0.999 26.96 MB
PHP - auto-static-closures 0.77087 0.78412 0.00246 0.32% 0.77873 -0.47% 0.77924 -0.44% -1.452 0.000 26.96 MB

Wordpress 6.9 main page - 100 consecutive runs, 20 warmups, 20 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@8c4f 0.66211 0.68058 0.00297 0.45% 0.66357 0.00% 0.66299 0.00% 5.430 0.999 26.98 MB
PHP - auto-static-closures 0.65689 0.65935 0.00042 0.06% 0.65784 -0.86% 0.65778 -0.79% 0.512 0.000 26.96 MB

bench.php - 100 consecutive runs, 10 warmups, 2 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@8c4f 0.43121 0.43999 0.00163 0.37% 0.43559 0.00% 0.43556 0.00% -0.041 0.999 7.95 MB
PHP - auto-static-closures 0.42158 0.42797 0.00136 0.32% 0.42483 -2.47% 0.42492 -2.44% -0.003 0.000 7.95 MB

@iluuu1994 iluuu1994 force-pushed the auto-static-closures branch from 165bbb9 to 43dc616 Compare January 19, 2026 13:49
@github-actions
Copy link
Copy Markdown

AWS x86_64 (c7i.24xl)

Attribute Value
Environment aws
Instance type c7i.metal-24xl (dedicated)
Architecture x86_64
CPU Intel(R) Xeon(R) Platinum 8488C, 48 cores @ 2400 MHz
CPU settings disabled deeper C-states, disabled turbo boost, disabled hyper-threading
RAM 188 GB
Kernel 6.1.158-178.288.amzn2023.x86_64
OS Amazon Linux 2023.9.20251117
GCC 14.2.1
Time 2026-01-19 15:00:50 UTC
Job details https://github.com/php/php-src/actions/runs/21142128198 (Artifacts)
Changeset https://github.com/php/php-src/compare/0caebc..96ca7f

Laravel 12.11.0 demo app - 100 consecutive runs, 50 warmups, 100 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0cae 0.45726 0.46371 0.00069 0.15% 0.45797 0.00% 0.45785 0.00% 6.056 0.999 27.03 MB
PHP - auto-static-closures 0.44219 0.44421 0.00043 0.10% 0.44275 -3.32% 0.44265 -3.32% 1.570 0.000 27.03 MB

Symfony 2.8.0 demo app - 100 consecutive runs, 50 warmups, 100 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0cae 0.75879 0.77172 0.00220 0.29% 0.76755 0.00% 0.76812 0.00% -1.834 0.999 27.06 MB
PHP - auto-static-closures 0.75747 0.77009 0.00249 0.33% 0.76599 -0.20% 0.76654 -0.21% -1.652 0.000 27.06 MB

Wordpress 6.9 main page - 100 consecutive runs, 20 warmups, 20 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0cae 0.66644 0.68717 0.00207 0.31% 0.66804 0.00% 0.66772 0.00% 8.183 0.999 27.06 MB
PHP - auto-static-closures 0.66228 0.68300 0.00216 0.33% 0.66365 -0.66% 0.66326 -0.67% 7.484 0.000 27.06 MB

bench.php - 100 consecutive runs, 10 warmups, 2 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0cae 0.42379 0.43149 0.00150 0.35% 0.42667 0.00% 0.42661 0.00% 0.470 0.999 7.94 MB
PHP - auto-static-closures 0.43164 0.43861 0.00134 0.31% 0.43417 1.76% 0.43395 1.72% 0.531 0.000 7.95 MB

@github-actions
Copy link
Copy Markdown

AWS x86_64 (c7i.24xl)

Attribute Value
Environment aws
Instance type c7i.metal-24xl (dedicated)
Architecture x86_64
CPU Intel(R) Xeon(R) Platinum 8488C, 48 cores @ 2400 MHz
CPU settings disabled deeper C-states, disabled turbo boost, disabled hyper-threading
RAM 188 GB
Kernel 6.1.158-178.288.amzn2023.x86_64
OS Amazon Linux 2023.9.20251117
GCC 14.2.1
Time 2026-01-19 23:24:29 UTC
Job details https://github.com/php/php-src/actions/runs/21154141728 (Artifacts)
Changeset https://github.com/php/php-src/compare/0caebcd196..96ca7fa073

Laravel 12.11.0 demo app - 100 consecutive runs, 50 warmups, 100 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0caebcd 0.44661 0.45030 0.00044 0.10% 0.44720 0.00% 0.44713 0.00% 3.736 0.999 27.00 MB
PHP - auto-static-closures 0.43096 0.43464 0.00046 0.11% 0.43155 -3.50% 0.43145 -3.51% 3.433 0.000 27.00 MB

Symfony 2.8.0 demo app - 100 consecutive runs, 50 warmups, 100 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0caebcd 0.76308 0.77568 0.00265 0.34% 0.77247 0.00% 0.77328 0.00% -1.988 0.999 27.01 MB
PHP - auto-static-closures 0.76260 0.77571 0.00284 0.37% 0.77207 -0.05% 0.77291 -0.05% -1.870 0.008 27.01 MB

Wordpress 6.9 main page - 100 consecutive runs, 20 warmups, 20 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0caebcd 0.66457 0.66720 0.00042 0.06% 0.66540 0.00% 0.66534 0.00% 1.027 0.999 27.01 MB
PHP - auto-static-closures 0.66029 0.68065 0.00302 0.46% 0.66168 -0.56% 0.66114 -0.63% 5.744 0.000 27.01 MB

bench.php - 100 consecutive runs, 10 warmups, 2 requests (sec)

PHP Min Max Std dev Rel std dev % Mean Mean diff % Median Median diff % Skew P-value Memory
PHP - baseline@0caebcd 0.42382 0.43063 0.00135 0.32% 0.42676 0.00% 0.42671 0.00% 0.064 0.999 27.01 MB
PHP - auto-static-closures 0.43190 0.45228 0.00284 0.65% 0.43486 1.90% 0.43450 1.83% 4.165 0.000 27.01 MB

@iluuu1994
Copy link
Copy Markdown
Member Author

iluuu1994 commented Jan 20, 2026

Pretty cool, I was able to confirm the ~3% improvement for the Laravel default page locally. They use a lot of closures, and this patch is able to avoid instantiation for 2384 out of 3637.

@iluuu1994 iluuu1994 added the RFC label Mar 4, 2026
@dragoonis

This comment was marked as off-topic.

@iluuu1994 iluuu1994 force-pushed the auto-static-closures branch from 96ca7fa to f9927b1 Compare March 13, 2026 16:04
iluuu1994 added a commit that referenced this pull request Mar 13, 2026
This field is still unused on master, but required for GH-19941.
@iluuu1994 iluuu1994 force-pushed the auto-static-closures branch from f9927b1 to 4451788 Compare March 13, 2026 19:29
@@ -0,0 +1,46 @@
--TEST--
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this tests a bunch of cases that are not inferred as static, can we also test a few that are inferred as static?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ext/reflection/tests/closures_005.phpt contains some rudimentary ones, but I'll add some more standard cases that can be inferred in a new test.


Deprecated: ArrayObject::__construct(): Using an object as a backing array for ArrayObject is deprecated, as it allows violating class constraints and invariants in %s on line %d
object(ArrayObject)#3 (1) {
object(ArrayObject)#%d (1) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the changes in this file to replace hard-coded numbers with %d are fine, but unrelated to the actual functionality change - can you send a separate PR if you want to adjust these? I'd be happy to review that, but it makes this PR bigger than it needs to be. Applies to a few other files too

Also not all of the object ids were updated with placeholders, just some

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the object IDs have changed as they are declared earlier - at compile time. I personally would like to keep hardcoded numbers to be able to review possible changes of the declaration order earlier in the future.

var_dump(
(new class {
function test(){
return (function(){ return compact('this'); })();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this maintain the non-static-ness of the function, given the access to $this via compact()? I'd say that any use of compact() should be treated the same as $$var - if we aren't going to analyze the possible values to make sure $this is not included, then we should be conservative and not infer static

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are much more cases. Like uasort($arr, 'Foo::bar'). IMO they are not worth to be pursued.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you're both correct. compact() needs to be excluded, and so do any calls to internal functions using Z_PARAM_FUNC(), i.e. those that accept callable, and are passed something that could be a string. I'll think about how to best address those cases.


class Test {
public function method() {
// It would be okay if this is NULL, but the rest should work
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment needs some update, now it is NULL

public $a;

public function x() {
$a = &$this;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be marked as static? Doesn't it give access to $this via the closure?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$this is not the scope, but a bound variable. This PR does two things, it "infer static" and "cache not bound closures". The case here allows the 1st one but not the 2nd one.

So I expect the l31 unchanges and prefer to keep it hardcoded.

array(1) {
[0]=>
object(Closure)#%d (4) {
object(Closure)#%d (%d) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as closure_20, don't these still need $this ?

zend_error(E_WARNING, "Cannot bind an instance to a static closure, this will be an error in PHP 9");
return false;
} else {
*newthis_ptr = NULL;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got confused reading this for the first time wondering why no warning was needed here - can I suggest adding some comment explaining this with a reference to the RFC, which specified that

Of note is that Closure::bind() and Closure::bindTo() usually throw when attempting to bind an object to a static closure. In this RFC, passing an object to these methods is explicitly allowed and discarded only for closures that are inferred as static, but not those that are explicitly static. The aim is to retain backward compatibility when a closure can suddenly be inferred as static due to seemingly unrelated changes, such as removing a static method call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants