View Issue Details

IDProjectCategoryView StatusLast Update
0002440XdebugCode Coveragepublic2026-10-07 14:31
ReporterDevArchitectMaster Assigned To 
PriorityhighSeveritymajorReproducibilityalways
Status newResolutionopen 
Summary0002440: With UNUSED | DEAD_CODE | BRANCH_CHECK together, each call costs more the more calls the process has already made; stopping cove
Description

Xdebug version: 3.5.3, which is not yet listed under Product Version. Also reproduced on 3.4.0.

When code coverage is started with XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK, every call to a measured function costs more the more such calls the process has already made. xdebug_stop_code_coverage() (default cleanup) does not reset this, and neither does starting coverage again.

The three flags have to be used together. BRANCH_CHECK on its own, and every pair of the three flags, stays flat (see Additional Information).

Expected result:
The time per round of the script below stays roughly the same over all rounds, as it does with any two of the three flags. Branch checking may make each round slower, but a round should not become slower because earlier rounds ran.

Actual result (Xdebug 3.5.3, ms per round, every round does the same work):

repro.php lines (UNUSED | DEAD_CODE):
round 50: 3.06 ms
round 100: 3.55 ms
round 150: 3.47 ms
round 200: 3.53 ms

repro.php (UNUSED | DEAD_CODE | BRANCH_CHECK):
round 50: 47.15 ms
round 100: 163.77 ms
round 150: 387.12 ms
round 200: 643.29 ms

A second run gave 67.85 / 167.60 / 378.58 / 586.65 ms. Xdebug 3.4.0 on the same machine gives 35.76 / 129.18 / 359.04 / 599.47 ms.

Steps To Reproduce
  1. Save this as repro.php (also attached):

<?php
// XDEBUG_MODE=coverage php repro.php (branch check)
// XDEBUG_MODE=coverage php repro.php lines (line coverage only, for comparison)
function f(int $i): int { if ($i % 3 === 0) { return 1; } return $i > 10 ? 2 : 3; }

xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [FILE]);
$flags = XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | (($argv[1] ?? '') === 'lines' ? 0 : XDEBUG_CC_BRANCH_CHECK);

for ($round = 1, $t = 0; $round <= 200; $round++) {
xdebug_start_code_coverage($flags);
$s = hrtime(true);
for ($k = 0; $k < 2000; $k++) { f($k); } // the same 2000 calls in every round
$t += hrtime(true) - $s;
xdebug_get_code_coverage();
xdebug_stop_code_coverage();
if ($round % 50 === 0) { printf("round %3d: %8.2f ms per round\n", $round, $t / 50 / 1e6); $t = 0; }
}

  1. Run it without php.ini, once without and once with the branch check:

XDEBUG_MODE=coverage php -n -d zend_extension=<path to Xdebug> repro.php lines
XDEBUG_MODE=coverage php -n -d zend_extension=<path to Xdebug> repro.php

  1. Compare the time per round. Every round makes the same 2000 calls of f(), with coverage started before and stopped after.
Additional Information

php -v:

PHP 8.2.12 (cli) (built: Oct 24 2023 21:15:15) (ZTS Visual C++ 2019 x64)
Copyright (c) The PHP Group
Zend Engine v4.2.12, Copyright (c) Zend Technologies
with Xdebug v3.5.3, Copyright (c) 2002-2026, by Derick Rethans

Relevant settings: none besides XDEBUG_MODE=coverage. The script was run with php -n, so no php.ini was loaded, and Xdebug was loaded with -d zend_extension=... (official php_xdebug-3.5.3-8.2-ts-vs16-x86_64.dll).

It takes all three flags. Only the $flags line of the script was changed; Xdebug 3.5.3, ms per round at rounds 50 / 100 / 150 / 200:

BRANCH_CHECK 2.98 / 3.49 / 3.35 / 3.41
BRANCH_CHECK | UNUSED 3.18 / 3.58 / 3.84 / 3.88
BRANCH_CHECK | DEAD_CODE 2.93 / 3.34 / 3.44 / 3.54
UNUSED | DEAD_CODE 3.13 / 3.59 / 3.66 / 3.67
UNUSED | DEAD_CODE | BRANCH_CHECK 37.20 / 141.07 / 346.13 / 568.90

BRANCH_CHECK on its own also stays flat on Xdebug 3.4.0 (1.55 / 1.56 / 1.75 / 1.72 ms).

All three flags together is exactly the set php-code-coverage passes when branch or path coverage is asked for, so every PHPUnit run with branch coverage is affected.

How it shows up in practice: PHPUnit runs a whole test suite in one process and starts and stops coverage around every test, which is the loop above. Because each test makes all later tests slower, the cost of a coverage run grows with the square of the suite. In our suite of 2,680 tests:

  • Without coverage the suite takes 9 s. With line coverage only it takes 15 s. With branch and path coverage it takes 9.5 minutes.
  • The same 263 tests took 7.7 s when run on their own and 62.7 s at the end of the full run.
  • Disabling the garbage collector (zend.enable_gc=0) did not change this.
  • It did not change either when php-code-coverage discarded the collected data right after each xdebug_get_code_coverage().
  • No function in the suite has more than 112 paths.

We saw the same in CI on GitHub Actions (Ubuntu 24.04, Xdebug 3.5.3 via shivammathur/setup-php). There we observed it through the test suite only and did not run the script above.

We now work around it by splitting the suite into several PHP processes and merging the coverage reports afterwards.

TagsNo tags attached.
Operating SystemMicrosoft Windows 11 Pro 10.0.26200 (x64), official PHP Windows build 8.2.12 ZTS VS16
PHP Version8.2.10-8.2.19

Activities

There are no notes attached to this issue.