Skip to content

configure: disable Apple clang size optimizations that hurt runtime speed - #23080

Open
staabm wants to merge 1 commit into
php:masterfrom
staabm:apple
Open

configure: disable Apple clang size optimizations that hurt runtime speed#23080
staabm wants to merge 1 commit into
php:masterfrom
staabm:apple

Conversation

@staabm

@staabm staabm commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

disclaimer: this change was generated by claude opus. I have little experience with php-src development


Apple clang enables the AArch64 machine outliner and hot/cold code splitting by default at -O2. Both trade speed for size: the outliner inserts extra bl/ret pairs into hot paths (measured inside the inlined zval destructor loops of zend_array_destroy, among others), and cold-split fragments (4700+ in a default cli build) are compiled for size, which mispredicted branch hints turn into slow hot code.

Disabling both speeds up a CPU-bound static analysis workload (PHPStan analysing its own codebase, no opcache) by ~4% wall time on an Apple M-series machine. The flags are added only when the compiler accepts them (-Werror guards against clang's unknown -m flag warnings), so non-Apple toolchains are unaffected.


benchmark (3 runs after 2 warmup runs):

run /Users/staabm/workspace/php-src/sapi/cli/php bin/phpstan clear-result-cache -q && /Users/staabm/workspace/php-src/sapi/cli/php -d memory_limit=450M bin/phpstan -v after checking out phpstan/phpstan-src#5942 from the git root folder.
(on first time phpstan-src checkout you need composer install and make to prepare the codebase)

before this PR:
14,47s
14,87s
14,60s

after this PR:
13,91s
13,68s
13,86s

on M4-Pro with macOS 26.6 (25G72)

…peed

Apple clang enables the AArch64 machine outliner and hot/cold code
splitting by default at -O2. Both trade speed for size: the outliner
inserts extra bl/ret pairs into hot paths (measured inside the inlined
zval destructor loops of zend_array_destroy, among others), and
cold-split fragments (4700+ in a default cli build) are compiled for
size, which mispredicted branch hints turn into slow hot code.

Disabling both speeds up a CPU-bound static analysis workload (PHPStan
analysing its own codebase, no opcache) by ~4% wall time on an Apple
M-series machine. The flags are added only when the compiler accepts
them (-Werror guards against clang's unknown -m flag warnings), so
non-Apple toolchains are unaffected.
@realFlowControl

Copy link
Copy Markdown
Contributor

I played with this PR locally on my M4 Max and can confirm the speedup:

Metric Baseline (master) PR (-mno-outline -Xclang -fno-split-cold-code)
Mean 8.485s 8.194s
Median 8.446s 8.213s
Stdev 0.059s 0.058s
Speedup 3.4%

With this setup:

  • Compiler: Apple clang 21.0.0 (clang-2100.1.1.101)
  • PHP: 8.6.0-dev, built from same commit (e9deb0a), default ./configure
  • PHPStan 2.2.5, level 6, analysing Sylius src/ (v2.2.7-dev)
  • 2 warmup runs + 5 alternating timed runs, result cache cleared before each run

One interesting caveat: When compiling PHP with clang from Homebrew I would not see any difference because:

  • -mno-outline is accepted but is a no-op: upstream LLVM (Homebrew) does not enable the AArch64 machine outliner by default at -O2, unlike Apple clang.
  • -Xclang -fno-split-cold-code is specific to Apple clang and the Homebrew version rejects those flags.

@devnexen
devnexen requested a review from arnaud-lb August 6, 2026 21:33
@arnaud-lb

Copy link
Copy Markdown
Member

@staabm @realFlowControl if you have experience with profiling on MacOS, are you able to see if the speed up is localized to some functions, or is this a more general speed up? It would be interesting to find which code paths are mislabeled as cold, too. If we can identify those we could make adjustment to the code.

I would assume that at least -Xclang -fno-split-cold-code doesn't have as much impact on PGO builds?

@staabm

staabm commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

I have no experience with profilling at the php-src level.

Just today I was talking with ondrej, and he showed me the impact of pessimized PHPStan performance because of the way how PGO is trained in the homebrew-php, see shivammathur/homebrew-php#5605 (maybe there is the information you just asked for)

@ondrejmirtes

Copy link
Copy Markdown
Contributor

This isn't related to PGO, if you build PHP manually on macOS it's not PGO-optimized, you need some extra steps for that.

@realFlowControl

Copy link
Copy Markdown
Contributor

@staabm @realFlowControl if you have experience with profiling on MacOS, are you able to see if the speed up is localized to some functions, or is this a more general speed up? It would be interesting to find which code paths are mislabeled as cold, too. If we can identify those we could make adjustment to the code.

That's a good idea, I may be able to do a run tonight to find where the speed up is coming from

@realFlowControl

realFlowControl commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

So few things., and as @ondrejmirtes meant, this has nothing to do with PGO, these are "optimizations" Apple Clang is using by default.

On -fno-split-cold-code

I could find that Apple clang (without -fno-split-cold-code) moves some parts of zend_array_destroy() into .cold.* functions, for example this part into a symbol zend_array_destroy.cold.3():

php-src/Zend/zend_hash.c

Lines 1845 to 1847 in 0380f25

do {
i_zval_ptr_dtor(zv);
} while (++zv != end);

Which means: whenever we take this branch, it has an extra function call/return and it looks like PHPStan analyzing the Sylius code base goes this branch often enough for sample to see this zend_array_destroy.cold.3() function a lot.

On -mno-outline

Another thing (and that brings the biggest part of the speed up) is removing the outliners with -mno-outline: In the zend_array_destroy.cold.3() function (see above) Apple clang finds repeated instruction sequences in that loop and moves them into OUTLINED_FUNCTION_* helpers.

In the baseline, the loop contains:

  ldr x0, [x19]                     ; load the refcounted value
  bl  OUTLINED_FUNCTION_7
  str w8, [x0]                      ; store the decremented refcount

The generated helper is:

  OUTLINED_FUNCTION_7:
      ldr  w8, [x0]                 ; load refcount
      subs w8, w8, #1               ; decrement it and set condition flags
      ret

With -mno-outline those instructions remain in the loop:

  ldr  x0, [x19]
  ldr  w8, [x0]
  subs w8, w8, #1
  str  w8, [x0]

Another example is gc_check_possible_root(), where

if (EXPECTED(GC_TYPE_INFO(ref) == GC_REFERENCE)) {
is extracted into an outline helper that has a call and a ret:

  OUTLINED_FUNCTION_449:
      ldr w8, [x0, #4]              ; load GC type information
      cmp w8, #0x1a                 ; compare with GC_REFERENCE
      ret

This is not limited to zend_array_destroy()/gc_check_possible_root(). The default Apple clang build contained 3,546 OUTLINED_FUNCTION_* symbols, including helpers used by VM opcode handlers, hash-table operations, object handlers ...

@arnaud-lb

Copy link
Copy Markdown
Member

Thank you!

About PGO: I know that we are not talking about a PGO build, but I was implying that in a PGO build the compiler could possibly make better decisions regarding what is hot or cold code, which relates directly to -fsplit-cold-code. However shivammathur/homebrew-php#5605 shows that producing a universal PGO build is not trivial.

I could find that Apple clang (without -fno-split-cold-code) moves some parts of zend_array_destroy() into .cold.* functions, for example this part into a symbol zend_array_destroy.cold.3():

Interesting. I was half expecting that EXPECTED() / UNEXPECTED() would be the culprit, but this doesn't appear to be the case, at least in this example.

Given that these flags impact only Apple Clang, and may become obsolete if these Clang optimizations become profitable in the future, it may be more relevant to apply them only in shivammathur/homebrew-php? WDYT?

cc @shivammathur

@realFlowControl

Copy link
Copy Markdown
Contributor

Interesting. I was half expecting that EXPECTED() / UNEXPECTED() would be the culprit, but this doesn't appear to be the case, at least in this example.

Yeah I though that too, but playing with it showed that outliner generation is completely ignoring those.

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.

4 participants