A profiler is most useful when it proves your first idea wrong.
I recently went back to a small PHP example that normalizes a large list of email addresses. The first version of this article had a neat story: replace array_unique(), remove repeated string operations, switch from filter_var() to a regular expression, and watch execution time fall from 12.8 seconds to 2.1 seconds.
There was one problem: the repository did not support that story.
The baseline already called trim() and strtolower() only once. The generated addresses were all unique, which made the deduplication comparison weak. The repository contained no Cachegrind output from which the quoted function percentages could be reproduced. Its peak-memory number also included the fixture built before the measured function ran.
So I rebuilt the example and measured it again. The result is less dramatic, but much more useful.
What are we trying to improve?
The example does three things:
- normalize each address with
trim()andstrtolower(); - validate it according to a declared rule;
- return unique normalized strings in first-seen order.
In simplified form, the reference implementation collects accepted addresses and deduplicates them at the end:
function normalizeWithArrayUnique(array $rows, callable $validator): array
{
$emails = [];
foreach ($rows as $row) {
$email = strtolower(trim($row['email']));
if ($email !== '' && $validator($email)) {
$emails[] = $email;
}
}
return array_values(array_unique($emails, SORT_STRING));
}
The alternative uses normalized addresses as keys and therefore deduplicates while iterating:
function normalizeWithSet(array $rows, callable $validator): array
{
$set = [];
foreach ($rows as $row) {
$email = strtolower(trim($row['email']));
if ($email !== '' && $validator($email)) {
$set[$email] = true;
}
}
return array_keys($set);
}
That looks promising, but appearance is not evidence. We need a profile to find expensive work and a benchmark to determine whether a change actually helps.
Profile one case deliberately
For CLI profiling with Xdebug 3, I use a trigger instead of profiling every PHP command:
xdebug.mode=profile
xdebug.start_with_request=trigger
xdebug.output_dir=/tmp/xdebug
The output directory must exist and be writable. I can then profile one implementation:
XDEBUG_TRIGGER=1 php examples/emails/run.php baseline
Xdebug writes a cachegrind.out.* file that can be opened in a Cachegrind-compatible viewer. Self time shows work done inside a function. Inclusive time also includes its callees.
A profile is excellent for forming a hypothesis, but it is a poor place to take final wall-clock numbers. Instrumentation adds overhead, so the benchmark runs separately with Xdebug disabled:
XDEBUG_MODE=off composer bench:record
The Xdebug profiler documentation describes trigger values and output-name settings if several profiles need to be kept apart.
A fixture that exercises the code
The revised fixture is deterministic and contains 200,000 interleaved rows:
| Category | Rows | Share |
|---|---|---|
| Unique valid addresses | 100,000 | 50% |
| Duplicate valid addresses | 40,000 | 20% |
| Empty values | 20,000 | 10% |
| Invalid values | 20,000 | 10% |
| Accepted only by the simple shape check | 20,000 | 10% |
This is still synthetic data. It does not claim to represent every import. It simply makes validation, rejection, and deduplication observable instead of filling the input with unique valid addresses only.
The last category is there for a specific reason: it exposes a behavior change that a timing table could otherwise hide.
Benchmark protocol
The runner uses one warm-up and ten measured runs for each implementation. Every sample gets a fresh PHP process, and the order of the implementations rotates between iterations.
Fixture generation happens before the timer starts. On PHP 8.2 or newer, peak accounting is reset immediately before the measured code:
$rows = EmailFixture::generate(200_000);
gc_collect_cycles();
memory_reset_peak_usage();
$baselineMemory = memory_get_usage(false);
$startedAt = hrtime(true);
$result = normalize($rows);
$elapsedSeconds = (hrtime(true) - $startedAt) / 1_000_000_000;
$peakDelta = memory_get_peak_usage(false) - $baselineMemory;
hrtime(true) is monotonic, so a system clock adjustment cannot distort the duration. Resetting the peak keeps fixture construction out of the incremental memory figure.
The runner also checks the output. The two strict implementations must have the same row count and SHA-256 checksum on every run. If they differ, the benchmark stops instead of comparing non-equivalent code.
Results
I recorded these results on PHP 8.5.9 CLI, Darwin arm64, with a 512 MiB memory limit. OPcache for CLI was off, JIT was disabled, and Xdebug was not loaded.
| Implementation | Median time | Time range | Median incremental peak | Output rows |
|---|---|---|---|---|
array_unique() + FILTER_VALIDATE_EMAIL
|
0.178650 s | 0.169635–0.183735 s | 24.45 MiB | 100,000 |
Set + FILTER_VALIDATE_EMAIL
|
0.183060 s | 0.178105–0.189811 s | 11.60 MiB | 100,000 |
| Set + simple shape check | 0.039487 s | 0.037182–0.043289 s | 11.60 MiB | 120,000 |
The raw samples and environment metadata are committed in results/benchmark.json. These numbers describe this machine, PHP build, fixture, and protocol. They are not universal constants.
The recorded output is rendered directly from the committed benchmark result. The editable SVG source is stored next to the PNG in the repository.
What did the set actually improve?
Replacing array_unique() with a set reduced the median incremental peak from 24.45 MiB to 11.60 MiB. The output count and checksum matched the baseline.
It did not make this run faster. Its median was slightly higher, and the measured ranges overlap. With ten small samples, the responsible conclusion is not that the set is categorically slower. It is that this benchmark shows a clear memory improvement and no demonstrated time improvement.
That distinction matters. If memory pressure is the problem, the set is useful here. If latency is the problem, this change has not solved it.
The result may change with a different duplicate ratio, input order, PHP version, or allocator behavior. That is why the fixture and environment belong next to the numbers.
The fast regex is not the same validator
The third implementation uses:
function hasSimpleEmailShape(string $email): bool
{
return preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $email) === 1;
}
It is much faster in this fixture, but it returns 120,000 rows rather than 100,000. It is not a drop-in optimization for FILTER_VALIDATE_EMAIL.
For example, the simple expression accepts values that PHP's validator rejects:
a..b@example.com
.alice@example.com
alice@example..com
alice@-example.com
The regex only checks for a broad something@something.something shape. That may be a valid product rule. A signup form that sends a verification message may deliberately prefer a permissive initial check. Another system may require a stricter local policy.
But that is a requirements decision, not a free performance win. A profiler can show the cost of the current validator; it cannot decide which input the application is allowed to accept. No syntax validator proves that a mailbox exists, either.
The same care applies to lowercasing the full address. It can be a reasonable application identity rule, but it should be documented as behavior rather than presented as a performance detail.
Why the original progression was misleading
One of the old steps split:
$email = strtolower(trim($row['email']));
into multiple assignments. Both forms still invoked trim() and strtolower() once for non-empty input. There was no repeated normalization to remove, so the supposed optimization did not correspond to the committed baseline.
The previous memory figures had a different problem. Calling memory_get_peak_usage() after building the entire fixture reports the highest point reached anywhere in the process. Without resetting peak accounting, that number cannot be described as the normalization function's incremental peak.
Finally, function percentages from a profiler need the corresponding profile, environment, and workload. Without that evidence, exact values such as “42% self time” look precise but cannot be checked. I removed them rather than inventing a reconstruction.
How to reproduce it
The companion repository contains the fixture, implementations, tests, runner, and recorded raw results:
github.com/phpner/php-profiling-example
composer install
composer test
XDEBUG_MODE=off composer bench
The tests verify fixture composition, first-seen order, equality of the strict implementations, and the intentional difference in the shape-check policy.
For a production import, I would investigate one more thing before micro-optimizing this loop: whether the input can be streamed. This example materializes all 200,000 rows before normalization. Reading a CSV or database cursor incrementally can remove a much larger memory cost, although the deduplication set will still grow with the number of unique addresses.
Conclusion
The corrected example does not produce a smooth sequence of wins:
- the set saves incremental peak memory but does not demonstrate a speedup;
- rearranging the same string calls is not an optimization;
- the simple regex is faster because it implements a different acceptance policy.
That is what honest profiling often looks like. Use the profile to choose a question. Change one thing. Verify the output. Benchmark outside the profiler. If behavior changes, describe it as a tradeoff rather than hiding it behind a faster number.

Top comments (2)
What made you think the script was fast?
The first thing that needs to happen is to remove data you don't need. That are the two first improvements.
With the set like code you assume the first entry is the most accurate. What if it is the last entry?
While i think using metrics is a good way to look for performance gains. Common programming patterns help you without profiling.
You’re right cleaning data and using common patterns is the first step.
In this article I kept the dataset raw to focus on profiling.
Patterns help in general, but profiling shows where they give the biggest impact.