Walk-through: benchmarking PHP vs KPHP vs C++ · KPHP — a PHP compiler
- take a PHP code and measure its execution time
- compile with KPHP and compare
- rewrite in plain C++ and compare
- tweak PHP code to make KPHP run faster
- add some abstractions and see, how it affects performance on PHP/KPHP
We’ll do all our experiments on a quicksort algorithm from here (the last one).
You can oppose, that it has no real-life relation. Well, but no abstract piece of code has a real-life relation.
The main idea to assimilate will be seen in the end, when we add abstractions to this code.
PHP code: quicksort algorithm
function quickSort(array &$a, int $start = 0, int $last = null) { $wall = $start; $last = is_null($last) ? count($a) - 1 : $last; if ($last - $start < 1) return; switchValues($a, (int) (($start + $last) / 2), $last); for ($i = $start; $i < $last; $i++) if ($a[$i] < $a[$last]) { switchValues($a, $i, $wall); $wall++; } switchValues($a, $wall, $last); quickSort($a, $start, $wall - 1); quickSort($a, $wall + 1, $last); } function switchValues(array &$a, int $i1, int $i2) { if ($i1 !== $i2) { $temp = $a[$i1]; $a[$i1] = $a[$i2]; $a[$i2] = $temp; } } function generateArr(int $size): array { $arr = []; for ($i = 0; $i < $size; $i++) { $arr[] = (int) (rand() / (1000000000 / $size)); } return $arr; } $size = 1000000; $arr = generateArr($size); $start = microtime(true); quickSort($arr); $duration = round((microtime(true) - $start) * 1000 * 1000) / 1000; echo "Sorted $size elements in {$duration}ms\n";
Compare PHP vs KPHP
Let's launch it several times and take the average:
PHP 7.4: ~2100 ms
KPHP: ~480 ms (~4x faster)
Okay, can we do better in C++?
Let's do the same in C++ and compile with g++ with -Os optimization level (the same as used for KPHP):
#include <iostream> #include <cstdlib> #include <chrono> using std::chrono::steady_clock; void switchValues(int64_t *, int64_t, int64_t); void quickSort(int64_t *a, int64_t size, int64_t start = 0, int64_t last = -1) { int64_t wall = start; last = last == -1 ? size - 1 : last; if (last - start < 1) return; switchValues(a, (start+last)/2, last); for (int i = start; i < last; ++i) if (a[i] < a[last]) { switchValues(a, i, wall); wall++; } switchValues(a, wall, last); quickSort(a, size, start, wall-1); quickSort(a, size, wall+1, last); } void switchValues(int64_t *a, int64_t i1, int64_t i2) { if (i1 != i2) std::swap(a[i1], a[i2]); } int main() { int64_t size = 1000000; int64_t *a = new int64_t[size]; for (int64_t i = 0; i < size; ++i) a[i] = std::rand(); steady_clock::time_point begin = steady_clock::now(); quickSort(a, size); steady_clock::time_point end = steady_clock::now(); std::cout << std::chrono::duration_cast<std::chrono::milliseconds> (end - begin).count() << " ms\n"; return 0; }
Compare PHP vs KPHP vs C++
PHP 7.4: ~2100 ms
KPHP: ~480 ms
C++: ~220 ms
Well, C++ is almost 2x faster than KPHP-compiled. Why, and can we do better in KPHP?
Why is C++ faster than KPHP
Primarily it's because of the array. KPHP's array behaves like PHP, but in PHP it can be either a hashtable or a vector (with integer indexes 0, 1, 2, … — it's our case).
This means, that reading $a[$i] for numeric $i in KPHP works like this:
- $a is a wrapper with refcounter pointing to actual data, get it
- if $a is a hashtable, that perform bucket-accessing logic
- if $a is a vector, then check dimensions ($i>=0 && $i<size), and if satisfied, do a linear memory access
So, reason number one:
- In C++, we did just
int* aanda[i]— we did no checks for dimensions, and we know it's a vector - But in PHP,
array $aand$a[$i]leads to hashtable/vector checks and dimensions checks
Next, look at this code PHP / C++:
// PHP // C++ $temp = $a[$i1]; std::swap(a[i1], a[i2]); $a[$i1] = $a[$i2]; $a[$i2] = $temp;
In C++, we don't do any checks and just swap two 8-bytes memory pieces: we know, that they don't intersect in memory, that we won't access corrupted memory, etc.
In KPHP, $a[$i] = … may lead to memory reallocation. For instance, $a[100500] = 0 will convert a vector array to an associative array with 100500 index present, if its length was smaller. That's why $a[$i1] = $a[$i2] can potentially invoke memory reallocation, and in order to maintain pointers if it occurs, KPHP implicitly inserts an extra variable with explicit copying before assigning. Again, in C++ we just didn't take care of this, as while writing, we knew, that all dimensions are satisfied.
So, reason number two:
- In C++, we did just
swap(a[i1], a[i2])— direct swapping memory pieces - But in PHP, lines with $temp var not only lead to extra checks while accessing array by index, but also insert a special wrapper in case memory reallocation occurs (which actually doesn't occur here of course)
Can we do better in KPHP?
Let's start. We remember, that the bottleneck here is $a[$index] access.
First, take a look at this:
for ($i = $start; $i < $last; $i++) if ($a[$i] < $a[$last]) { /* ... */ }
We see, that $a[$last] is queried for every $i, but we are sure, that in our case $last-th element will remain the same. Let's extract it as a separate variable:
$a_last = $a[$last]; for ($i = $start; $i < $last; $i++) if ($a[$i] < $a_last) { /* ... */ }
This really speeds up PHP/KPHP, but the same change in C++ does almost nothing because of cheapness.
Second, let's use array_swap_int_keys() KPHP built-in function:
function switchValues(array &$a, int $i1, int $i2) { array_swap_int_keys($a, $i1, $i2); }
This function performs checks only once and performs in-memory swapping for POD types, avoiding checking on every index access and extra code surrounding potential reallocations, as mentioned.
To make it work on PHP, either write a polyfill manually:
#ifndef KPHP function array_swap_int_keys(array &$a, int $idx1, int $idx2): void { if ($idx1 != $idx2 && isset($a[$idx1]) && isset($a[$idx2])) { $tmp = $a[$idx1]; $a[$idx1] = $a[$idx2]; $a[$idx2] = $tmp; } } #endif
Or — better — use existing polyfills convering all KPHP built-ins.
Third, look at array generation:
for ($i = 0; $i < $size; $i++) $arr[] = (int) (rand() / (1000000000 / $size));
We know exactly, that $size elements would be inserted, and $arr would be a vector. It's a good idea to pre-reserve memory for $size-vector of integers, to avoid reallocations while inserting:
$arr = []; array_reserve($arr, $size, 0, true); /* ... */
Compare PHP vs KPHP vs C++ — number 2
Let's see what we have achieved by optimizations above:
PHP 7.4: ~2650 ms (was 2100)
KPHP: ~270 ms (was 480)
C++: ~220 ms
KPHP is now almost identical to C++! Wow.
But… PHP became slower?? Why?
In practice, if PHP becomes a bit slower, don’t pay attention to it: PHP for development, KPHP for production.
Fix that PHP became slower
Remember, that we started using array_swap_int_keys() from switchValues()?
function switchValues(array &$a, int $i1, int $i2) { array_swap_int_keys($a, $i1, $i2); }
This function is invoked many-many times, and in PHP function calls are quite expensive. If we directly call array_swap_int_keys() without calling switchValues(), it will speed up PHP:
if ($a[$i] < $a_last) { array_swap_int_keys($a, $i, $wall); // not switchValues() $wall++; }
In KPHP, this doesn't matter, as a simple function switchValues() is just inlined and has no overhead.
Compare PHP vs KPHP vs C++ — final results
PHP 7.4: ~1950 ms
KPHP: ~270 ms (~7x faster)
C++: ~220 ms
Can KPHP occasionally work slower?
KPHP works fast here, as it infers $arr to be an array of int. Let's say, generateArr() will append user input ($argv) to random numbers:
function generateArr(int $size): array { /* ... */ if (isset($argv[1])) $arr[] = $argv[1]; // suppose it's a numeric string }
Then, compile, run, and…
KPHP: ~940 ms (was 270)
What happened? Types have spoilt. $arr was int[], but now it's mixed[], because $argv[$i] is mixed. As a result, quickSort() accepts mixed[] (which holds integers or numeric strings at runtime), and all computations became significantly slower.
How to fix? Convert $argv[1] to integer:
if (isset($argv[1])) $arr[] = (int)$argv[1];
To prevent such accidents in the future, manually lock return type:
/** @return int[] */ function generateArr(int $size): array { /* ... */ }
If you occasionally spoil types, later on, KPHP will show you an error, thanks to @return.
Add abstractions to PHP code
In real life, you use OOP instead of operating data directly.
Let's extend our example. Instead of array $arr and $arr[$i] we'll use a wrapping class with getters:
class ArrayHolder { /** @var int[] */ private $arr; function __construct(array $arr) { $this->arr = $arr; } function getCount(): int { return count($this->arr); } function getElemAt(int $idx): int { return $this->arr[$idx]; } function switchValues(int $idx1, int $idx2) { array_swap_int_keys($this->arr, $idx1, $idx2); } };
Business logic doesn't change pretty much, it just uses another access to data:
function quickSort2(ArrayHolder $a, int $start = 0, int $last = null) { $wall = $start; $last = is_null($last) ? $a->getCount() - 1 : $last; if ($last - $start < 1) return; switchValues2($a, (int) (($start + $last) / 2), $last); $a_last = $a->getElemAt($last); for ($i = $start; $i < $last; $i++) if ($a->getElemAt($i) < $a_last) { switchValues2($a, $i, $wall); $wall++; } switchValues2($a, $wall, $last); quickSort2($a, $start, $wall - 1); quickSort2($a, $wall + 1, $last); } function switchValues2(ArrayHolder $a, int $i1, int $i2) { $a->switchValues($i1, $i2); }
Compare PHP vs KPHP with abstractions
Let's invoke the code above and compare it with accessing an array directly:
PHP 7.4: ~4600 ms (was 2650)
KPHP: ~320 ms (was 270)
Almost nothing changed, but PHP became about 2x slower. That's for the same reason: function calls are expensive in PHP, and fields are more expensive than local variables.
KPHP became a bit slower too. Why? Primarily, because KPHP doesn't track nullability for now, and $o->field is surrounded with checks that $o is not null. When KPHP is able to detect nullability, even this small performance degradation will vanish.
Conclusion
- when you use accurate types, KPHP is usually faster than PHP
- as your project grows, the gap increases, because simple functions have no overhead in KPHP