---
title: Profiling
source: 'https://academia.sh/en/courses/php-production/profiling'
course: 'Quality, Performance and Deployment in PHP'
language: en
updated: '2026-10-21T05:03:36+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explain which question profiling answers and how it finds the hot path'
    - 'Show by measurement that the same job gives the same call count and the same memory difference in two profiles'
    - 'Interpret why putting counter code inside assert() changes the result depending on the profile'
---

# Profiling

Measuring in two profiles how many times a job calls each function and how much memory it holds: the counts stay the same, but the counting code can change with the profile.

When an application looks slow or bloated, the first reaction is to guess. Profiling replaces the guess with a measurement: it records how many times the job calls each function and how much memory it holds while running. The path that is called the most, or that holds the most memory, is called the hot path, and improvement starts there. In this lesson we do not measure time. The numbers are call counts and memory differences, because these do not change from machine to machine; what is measured is the behavior of the job.

The measuring question is the shared question of this course: does this behavior reach production? `side.php` is below verbatim, and in this lesson too it is only written to disk.

```php
<?php
// side.php — does this behavior reach production?
declare(strict_types=1);

// The two configuration files PHP ships (php.ini-development, php.ini-production) differ
// in seven directives; all seven are copied here verbatim. The last two production lines
// are comments in the shipped file: code cache on for the command line too, file checks off.
const PROFILES = [
    'development' => [
        'display_errors' => '1',
        'display_startup_errors' => '1',
        'error_reporting' => '32767',
        'mysqlnd.collect_memory_statistics' => '1',
        'zend.assertions' => '1',
        'zend.exception_ignore_args' => '0',
        'zend.exception_string_param_max_len' => '15',
    ],
    'production' => [
        'display_errors' => '0',
        'display_startup_errors' => '0',
        'error_reporting' => '24575',
        'mysqlnd.collect_memory_statistics' => '0',
        'zend.assertions' => '-1',
        'zend.exception_ignore_args' => '1',
        'zend.exception_string_param_max_len' => '0',
        'opcache.enable_cli' => '1',
        'opcache.validate_timestamps' => '0',
    ],
];

/** Runs the script in a separate process with no configuration file, only the profile's directives. */
function run(string $script, array $directives): bool {
    $command = ['php', '-n'];
    foreach ($directives as $name => $value) {
        array_push($command, '-d', "$name=$value");
    }
    $command[] = $script;
    $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    proc_close($process);
    $lines = array_values(array_filter(array_map('trim', explode("\n", $output))));
    return end($lines) === 'yes';
}

/** Derives the side from whether the behavior was observed in the two runs. */
function side(bool $inDevelopment, bool $inProduction): string {
    return match (true) {
        $inDevelopment && $inProduction => 'both sides',
        $inDevelopment => 'development only',
        $inProduction => 'production only',
        default => 'not in the run',
    };
}

function sideReport(string $name, string $script): string {
    $inDevelopment = run($script, PROFILES['development']);
    $inProduction = run($script, PROFILES['production']);
    $finalSide = side($inDevelopment, $inProduction);
    echo "$name → $finalSide\n    development ", $inDevelopment ? 'yes' : 'no',
        ' · production ', $inProduction ? 'yes' : 'no', "\n";
    return $finalSide;
}
```

Before measuring, we limit what we measure. There are two quantities in this lesson: the call count of a function, and the difference a job leaves in memory while running. Both are numbers, and both come out the same when you run the same code with the same input. Time does not have this property: the same job takes one time on one machine and another on another, and it varies even between two runs on the same machine. Building the comparison on numbers therefore makes the measurement repeatable. The two blocks below are written with this logic; the first defines the measured job and the counter, and the second runs that job in the two profiles and compares the results.

First, we write the job we will measure and the counter class. The `twice()` function is called 500 times and counts itself on each call; then an array of 10000 elements is built and the memory difference is taken. The file has no output; the probes load it.

```php
<?php
// counter.php — a small profiler that measures call counts and memory difference
declare(strict_types=1);

final class Counter {
    private static array $counts = [];

    public static function count(string $name): void {
        self::$counts[$name] = (self::$counts[$name] ?? 0) + 1;
    }

    public static function read(string $name): int {
        return self::$counts[$name] ?? 0;
    }
}

function twice(int $n): int {
    Counter::count('twice');
    return $n * 2;
}

/** @return array{calls: int, total: int, memoryDifference: int} */
function doWork(): array {
    $before = memory_get_usage();
    $total = 0;
    for ($i = 0; $i < 500; $i++) {
        $total += twice($i);
    }
    $numbers = range(1, 10000);
    $difference = memory_get_usage() - $before;
    return ['calls' => Counter::read('twice'), 'total' => $total, 'memoryDifference' => $difference];
}
```

Now we write the probe that runs the same job in the two profiles. The probe recognizes the profile from the value of `zend.assertions` (`1` in development, `-1` in production) and writes the result to a file specific to the profile. The driver reads the two files and compares them. For memory, the question is whether the difference is equal in the two profiles.

```php
<?php
// profile.php — does the same job give the same number in two profiles?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('measure.php', <<<'PHP'
<?php
// measure.php — runs the job, writes the result to the profile-specific file
require __DIR__ . '/counter.php';
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
$result = doWork();
file_put_contents("measure-$profile.json", json_encode($result));
echo $result['calls'] === 500 && $result['total'] === 249500 ? "yes\n" : "no\n";
PHP);

$finalSide = sideReport('a job of 500 calls gives the same count', 'measure.php');

$development = json_decode(file_get_contents('measure-development.json'), true);
$production = json_decode(file_get_contents('measure-production.json'), true);
echo 'call count: ', $development['calls'], ' / ', $production['calls'], "\n";
echo 'total: ', $development['total'], ' / ', $production['total'], "\n";
echo 'memory differences equal: ', $development['memoryDifference'] === $production['memoryDifference'] ? 'yes' : 'no', "\n";
```

```
a job of 500 calls gives the same count → both sides
    development yes · production yes
call count: 500 / 500
total: 249500 / 249500
memory differences equal: yes
```

In both profiles the call count came out 500 and the total 249500, and the memory differences stayed equal. The question a profile answers is "how many times and how much memory", and this question gets the same answer on both sides. So the hot path you find in development is also the hot path in production. (**PO2.** The comparison was made in a single process and a single run; the memory difference depends on the job itself, not on the settings of the process the job lives in, and this lesson did not vary those settings separately.)

## Finding the Hot Path

So far we have counted a single function. A real job is a chain of functions calling one another, and the hot path is the most-called link of that chain. The probe below builds a chain of three functions: `aggregate()` calls `convert()` in a loop, and `convert()` calls `read()` twice on every call. `aggregate(40)` runs five times. The call counts can be worked out beforehand: `aggregate()` 5, `convert()` 40 times 5, that is 200, and `read()` should be 400, since it is called twice in each of the 200 calls. The script raises a counter at the entry of each function, sorts the counts from largest to smallest, and says whether the name in first place is `read`. The distribution is written to a separate file in each profile; the driver compares the two files and prints the sorted distribution. It matters that the numbers can be worked out by hand: if the measurement agrees with our expectation, we also learn that the counter works correctly.

```php
<?php
// hotpath.php — is the call distribution the same in two profiles, which is the hot path?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('hot.php', <<<'PHP'
<?php
// hot.php — extracts the call distribution, finds the most-called function
$counts = [];
function record(string $name): void {
    global $counts;
    $counts[$name] = ($counts[$name] ?? 0) + 1;
}
function read(int $i): int {
    record('read');
    return $i % 7;
}
function convert(int $i): int {
    record('convert');
    return read($i) * 3 + read($i + 1);
}
function aggregate(int $n): int {
    record('aggregate');
    $total = 0;
    for ($i = 0; $i < $n; $i++) {
        $total += convert($i);
    }
    return $total;
}
for ($k = 0; $k < 5; $k++) {
    aggregate(40);
}
arsort($counts);
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
file_put_contents("hot-$profile.json", json_encode($counts));
echo array_key_first($counts) === 'read' ? "yes\n" : "no\n";
PHP);

sideReport('the hot path is the read function', 'hot.php');

$development = file_get_contents('hot-development.json');
$production = file_get_contents('hot-production.json');
foreach (json_decode($production, true) as $name => $count) {
    echo $name, ': ', $count, "\n";
}
echo 'distributions equal: ', $development === $production ? 'yes' : 'no', "\n";
```

```
the hot path is the read function → both sides
    development yes · production yes
read: 400
convert: 200
aggregate: 5
distributions equal: yes
```

We read the output line by line. The first two lines are the standard result of the probe: the claim `the hot path is the read function` gave `yes` in both profiles, meaning the function we expected is at the head of the ordering; the side is **both sides**. The next three lines are the sorted distribution: `read` 400, `convert` 200, `aggregate` 5. These numbers agree exactly with the calculation we made by hand. The agreement is the real sign that the measurement is reliable: had the counter given something different from the expected, we would have questioned first the counter and then the code. The last line says the distribution files of the two profiles are identical byte for byte. Comparing the files as a whole, rather than number by number, confirms in one step that both the numbers and the ordering are the same.

How to interpret this distribution is the real work of profiling. `aggregate()` is the outermost function and is called only five times; someone reading the code might think it important and turn to optimizing it. The distribution shows instead that the load of the job is gathered in the lowest function, `read()`: four hundred calls, eighty times the outer function. The optimization is sought inside this function or in the way it is called, for example caching the result, moving the call out of the loop, or reducing two calls to one. Since we do not measure time in this lesson, we do not say "how long the four hundred calls took"; the call count decides which function to start examining, that is, the order; the decision to improve is made when it is read together with time.

We also say why the side is **both sides**. The counter is an ordinary function call; it uses no path that depends on profile differences such as `zend.assertions` or `error_reporting`. The same chain runs in the same order in both profiles and produces the same numbers. In a real production this is useful in the following way: you can find the hot path on your development machine and trust that the same path is hot in production, but only as long as your counting code does not depend on a profile difference. The next section shows why this condition matters.


## The Bounding Measurement

The code that does the profiling is itself code, and it may not behave the same in the two profiles. When the value of `zend.assertions` is `-1`, the expression inside an `assert()` call is never evaluated. If we raise the counter inside an `assert()`, do the two profiles give the same call count?

```php
<?php
// bound.php — does a counter inside assert() give the same number in two profiles?
declare(strict_types=1);
require __DIR__ . '/side.php';

file_put_contents('asserted.php', <<<'PHP'
<?php
// asserted.php — raises the counter inside assert()
$counter = 0;
function tick(): bool {
    global $counter;
    $counter++;
    return true;
}
for ($i = 0; $i < 500; $i++) {
    assert(tick());
}
$profile = ini_get('zend.assertions') === '1' ? 'development' : 'production';
file_put_contents("asserted-$profile.txt", (string) $counter);
echo $counter === 500 ? "yes\n" : "no\n";
PHP);

sideReport('the counter inside assert() rises', 'asserted.php');
echo 'development: ', file_get_contents('asserted-development.txt'), "\n";
echo 'production: ', file_get_contents('asserted-production.txt'), "\n";
```

```
the counter inside assert() rises → development only
    development yes · production no
development: 500
production: 0
```

The counter came out 500 in development and 0 in production; the side was read as **development only**. In the production profile the `assert()` call is not compiled, so the `tick()` inside it never ran. The meaning is clear: if the code holding the measurement is a call that changes state, do not put it inside `assert()`. When you measure the same job in two profiles, the counter must be kept either in an expression that runs in both profiles or outside `assert()`. When profiling, it matters in which profile you profile; measuring in the production profile and interpreting with development assumptions can lead to a wrong number for this reason.

Reading the two measurements side by side shows the division of labor. The counter class counts with an ordinary function call and gives the same result in both profiles; when the counting is pushed into `assert()`, the behavior becomes tied to the profile. Profiler tools work by the same rule: the code that keeps a record runs with the settings of the code it measures. After finding the hot path, verifying the improvement with the same measurement, that is, comparing the same number before and after changing the job, is a sound method.

This measurement also carries a warning. The person who finds the hot path thinks the path found should be improved; yet a function with a high call count may also be cheap. A function of four hundred calls that does a single operation per call may cause far less trouble than a function of five calls. The sound use of profiling is to read the call count together with the memory difference and with time measurement (which this lesson leaves outside). The two measurements of this lesson, the call distribution and the memory difference, are not enough on their own to make a decision, but they are enough to set the order of improvement.

In a real project you do not do this counting by hand; a profiler extension records the calls. But these extensions are themselves loaded into the process, and some are off in the production profile or run without being loaded. Counting by hand is therefore a teaching tool: proof that a piece of counting code gives the same result in two profiles is not proof that the tool doing the measurement gives the same result. Before you run your tool on the production side, verify, as in this lesson, on a small job that it gives the same numbers on both sides.

There is a distinction behind the bounding measurement: treating the counting code itself as part of the job being measured. `assert()` is where this distinction breaks, because its real purpose is to check assumptions during development, and it is ignored in production. The person who puts the counter there sees the expected number in development, sees zero in production, and takes the two for the same code. The fix is short: counting is done in an expression outside `assert()`; `assert()` is used only for correctness checks. This is hard to see in code, because neither profile raises an error; only the number is silently different. The only way to catch silent differences of this kind is to run the measurement on both sides and compare the results.

## Summary

- Profiling answers the question "which function was called how many times and how much memory was held" with a measurement; the most-called path is the hot path.
- The side of this lesson is **both sides**: a job of 500 calls gave the same call count (500), the same total (249500) and the same memory difference in the development and production profiles.
- Call counts and equality comparisons were used instead of time and byte counts; these are independent of the machine.
- If the counter is raised inside `assert()`, the result is tied to the profile: it comes out 500 in development and 0 in production, because in production the expression inside `assert()` is not evaluated.
- If the code holding the measurement is a call that changes state, it is not put inside `assert()`.
- In the chain of three functions the call distribution came out `read` 400, `convert` 200, `aggregate` 5 and stayed identical byte for byte in both profiles; the hot path was the lowest function, not the outermost (side: both sides).

## Next Step

The call count and the memory difference show what the job does; they do not show what happens when the memory limit is exceeded. The next lesson measures how a request stops when `memory_limit` is exceeded, how connection objects are closed at the end of the request, and when a circular reference delays the destructor.
