Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimizing Xdebug Performance When Debugging is Inactive #4

Closed
wants to merge 1 commit into from

Conversation

carlos-granados
Copy link
Owner

@carlos-granados carlos-granados commented Feb 10, 2025

When using Xdebug, enabling debug mode significantly impacts execution speed, often doubling the run time of code compared to when Xdebug is not loaded. This performance penalty occurs even if the debugger is not actively used —i.e., when no client connects or no breakpoints are set. As a result, developers often find themselves frequently enabling and disabling Xdebug to avoid noticeable slowdowns.

Cause of the Slowdown

This slowdown happens because Xdebug injects additional processing at two critical points:

  1. On each function call – It records stack traces, including function calls and variable values.
  2. On each statement execution – It checks whether execution should pause at a breakpoint.

These operations are essential when debugging but become unnecessary overhead when the debugger is inactive.

What This PR Changes

This PR introduces optimizations that dynamically disable unnecessary debugging operations when:

  • Xdebug is running only in debug mode (i.e., without code coverage, profiling, tracing, etc...).
  • The debugger is not activated, meaning:
    • The debugger is not started,
    • Or no client is connected,
    • Or no breakpoints are set.

By skipping function call logging and statement checks in these cases, we can greatly reduce execution overhead while maintaining full debugging capabilities when needed.

Handling Just-In-Time (JIT) Debugging

One challenge is JIT debugging, where the debugger is automatically started when:

  • An exception or error occurs.
  • xdebug_break() is called.
  • xdebug_connect_to_client() is used.

In these cases, some debugging functionality must remain available to ensure Xdebug can correctly attach to the process and capture execution state. As a result, the optimizations in this PR do not entirely eliminate Xdebug’s overhead but significantly reduce it compared to the current implementation.

Challenges

A key challenge is that JIT debugging sessions will not have a pre-recorded stack trace. However, this is an issue thar can be overcome because:

  • The stack trace can be reconstructed when a debugger client connects.
  • While reconstructing the stack trace incurs some performance cost, it happens only once during reconnection, rather than continuously during execution.

This is a reasonable compromise to ensure that Xdebug remains performant when debugging is not actively in use.

Performance Improvements

To illustrate the benefits of this approach, we measured execution times for two common real-world use cases: Running RectorPHP and PHPStan on a codebase.

Operation Without Xdebug (s) With Current Xdebug (s) Slowdown (%) With Optimized Xdebug (s) Slowdown (%)
Running RectorPHP 42 92 +119% 52 +23%
Running PHPStan 51 108 +112% 62 +22%

These tests were conducted with Xdebug enabled, a debugger client connected, and no breakpoints set. As the results show, the optimized implementation runs 3–4× faster than the current version when debugging is not actively used.

Goal of This Change

The ultimate goal of this improvement is to make Xdebug’s performance impact minimal when debugging is inactive. This means developers no longer need to constantly toggle Xdebug on and off. Instead, they can leave it enabled at all times with:

xdebug.mode=debug
xdebug.start_with_request=yes

This allows seamless debugging when needed, without causing unnecessary slowdowns during regular execution.

Additional improvements

This PR also includes a few optimizations that enhance the execution speed of the debugger, regardless of whether it is actively used or not. These improvements further reduce the overall overhead of Xdebug, making debugging more efficient while maintaining full functionality.

Support This Work

If you or your company use Xdebug, please consider supporting my open-source work by sponsoring me on GitHub. Your support helps fund further improvements, and I have several ideas for enhancing Xdebug even further.

@carlos-granados carlos-granados force-pushed the fast-debugger-when-not-active branch 3 times, most recently from f6b2e5a to 1444aa9 Compare February 11, 2025 22:36
@@ -608,23 +612,30 @@ static void collect_params(function_stack_entry *fse, zend_execute_data *zdata,
#endif
}

function_stack_entry *xdebug_add_stack_frame(zend_execute_data *zdata, zend_op_array *op_array, int type)
function_stack_entry *xdebug_add_stack_frame(zend_execute_data *zdata, zend_op_array *op_array, int type, int use_current_execute_data)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is reused to reconstruct the stack when jit debugging is started. We will call it successively with the execute data for each stack frame. To be able to do this, we add a new parameter that tells it if it needs to use the current_execute_data or the passed zdata to record the data for that frame

src/base/base.c Outdated
@@ -874,18 +885,57 @@ static bool should_run_user_handler_wrapper(zend_execute_data *execute_data)
#endif
}

static zend_extension *xdebug_find_xdebug_extension()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use this function to find the current extension struct data so that we can set or unset the statement_handler function

src/base/base.c Outdated
return NULL;
}

static void disable_statement_extension_handler()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two functions are used to enable or disable the statement handler. Disable needs to be called before we can call enable because we need to record the existing handler

/* We still need this to do "include", "require", and "eval" */
static void xdebug_execute_ex(zend_execute_data *execute_data)
{
bool run_user_handler = should_run_user_handler_wrapper(execute_data);

if (run_user_handler) {
if (run_user_handler && (XG_DBG(debugger_disabled) == 0 || execute_data->func->type == ZEND_EVAL_CODE)) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only call the xdebug_execute_user_code_begin() and xdebug_execute_user_code_end() functions if the debugger has not been disabled. Notice that we cannot just use a single if statement for both functions because the status of the debugger_disabled flag can change during the call to the xdebug_old_execute_ex() function

We also need to call the begin function if we are entering into some eval code because we want to record this eval code so that it can be used later

@@ -1011,7 +1061,7 @@ static void xdebug_execute_internal(zend_execute_data *current_execute_data, zva
{
bool run_internal_handler = should_run_internal_handler(current_execute_data);

if (run_internal_handler) {
if (run_internal_handler && XG_DBG(debugger_disabled) == 0) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We proceed in a similar fashion in the internal execute handler

@@ -112,4 +112,11 @@ static inline void xdebug_vector_destroy(xdebug_vector *v)
xdfree(v);
}

static inline void xdebug_vector_empty(xdebug_vector *v)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New utility function to empty the stack

@@ -8,12 +8,14 @@ xdebug.start_with_request=yes
xdebug.client_discovery_header=I_LIKE_COOKIES
xdebug.discover_client_host=1
xdebug.client_port=9999
xdebug.log=
xdebug.log={TMP}/{RUNID}{TEST_PHP_WORKER}bug01656.txt
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that now we are not printing the failed connection attempt in the std output, we need to read it from the log to confirm that it contains the expected data

@@ -30,4 +30,4 @@ unlink (sys_get_temp_dir() . '/' . getenv('UNIQ_RUN_ID') . getenv('TEST_PHP_WORK
[%d] [Step Debug] WARN: Invalid remote address provided containing URI spec 'unix:///tmp/haxx0r.sock'.
[%d] [Step Debug] WARN: Could not discover client host through HTTP headers, connecting to configured address/port: unix:///tmp/xdbg.sock:0.
[%d] [Step Debug] WARN: Creating socket for 'unix:///tmp/xdbg.sock', connect: No such file or directory.
[%d] [Step Debug] ERR: Could not connect to debugging client. Tried: unix:///tmp/xdbg.sock:0 (fallback through xdebug.client_host/xdebug.client_port).
[%d] [Step Debug] WARN: Could not connect to debugging client. Tried: unix:///tmp/xdbg.sock:0 (fallback through xdebug.client_host/xdebug.client_port).
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some other cases where we were already checking the log, we just need to change the severity in the test

@@ -569,7 +569,10 @@ PHP_MINIT_FUNCTION(xdebug)
return SUCCESS;
}

xdebug_library_minit();
if (XDEBUG_MODE_IS(XDEBUG_MODE_TRACING) || XDEBUG_MODE_IS(XDEBUG_MODE_COVERAGE)) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This xdebug_library_minit function overrides a number of opcode handlers but the vast majority of them are only used during tracing or code coverage. The only one that is used during debugging is the INCLUDE_OR_EVAL code. So we only call this function if we are tracing or doing coverage. The INCLUDE_OR_EVAL opcode will be overridden separately later for debugging

@@ -823,7 +829,7 @@ ZEND_DLEXPORT void xdebug_zend_shutdown(zend_extension *extension)

ZEND_DLEXPORT void xdebug_init_oparray(zend_op_array *op_array)
{
if (XDEBUG_MODE_IS_OFF()) {
if (!XDEBUG_MODE_IS(XDEBUG_MODE_COVERAGE)) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code in this handler is only useful if we are doing code coverage, so we return early in other cases

@carlos-granados carlos-granados force-pushed the fast-debugger-when-not-active branch from 1444aa9 to 800db5b Compare February 12, 2025 07:48
add_stack_frame_recursively(EG(current_execute_data));
}

void xdebug_enable_debugger_if_disabled()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These public functions are used to enable and disable the debugger and to rebuild the stack as needed

@@ -447,6 +453,11 @@ void xdebug_debugger_error_cb(zend_string *error_filename, int error_lineno, int
xdebug_hash_find(XG_DBG(context).exception_breakpoints, error_type_str, strlen(error_type_str), (void *) &extra_brk_info) ||
xdebug_hash_find(XG_DBG(context).exception_breakpoints, "*", 1, (void *) &extra_brk_info)
) {
if (stack_rebuilt) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are stopping on a pseudo exception we need to enable the debugger and rebuild the stack if needed. We use this boolean flag because the stack may have already been rebuilt if we have notified about this error

xdebug_debug_init_if_requested_on_xdebug_break();

if (!xdebug_is_debug_connection_active()) {
RETURN_FALSE;
}

xdebug_enable_debugger_if_disabled();
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then if the connection is active, we need to re-enable the debugger

@carlos-granados carlos-granados force-pushed the fast-debugger-when-not-active branch from 800db5b to 8438742 Compare February 12, 2025 08:29
@carlos-granados carlos-granados changed the title Fast debugger when not active Optimizing Xdebug Performance When Debugging is Inactive Feb 12, 2025
@@ -874,18 +884,36 @@ static bool should_run_user_handler_wrapper(zend_execute_data *execute_data)
#endif
}

void xdebug_save_statement_handler(zend_extension *extension, statement_handler_func_t statement_handler)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is used to save a pointer to the xdebug zend_extension and to the xdebug statement handler so that we can use this data to enable or disable this handler

}


static void xdebug_disable_statement_extension_handler()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two functions enable or disable xdebug's statement handler

@@ -787,6 +793,7 @@ ZEND_DLEXPORT void xdebug_statement_call(zend_execute_data *frame)

ZEND_DLEXPORT int xdebug_zend_startup(zend_extension *extension)
{
xdebug_save_statement_handler(extension, xdebug_statement_call);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When starting the extension, we save a pointer to it and to the statement handler function

@@ -1067,6 +1101,83 @@ static zend_observer_fcall_handlers xdebug_observer_init(zend_execute_data *exec
return (zend_observer_fcall_handlers){xdebug_execute_begin, xdebug_execute_end};
}
#endif

static void xdebug_enable_debugger_handlers()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is called when we want to enable the debugger after having been disabled. We restore the function handler and the statement handler. Restoring the function handler in PHP 8.0 is not needed because we won't be disabling it as in PHP 8.0 it is called instead of the Observer API functions

xdebug_enable_statement_extension_handler();
}

static void xdebug_disable_debugger_handlers()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is called when we want to disable the debugger. We remove the normal function handler if using PHP 8.1+ as all the functionality we need in this mode can be handled by the handlers called by the Observer API. If it is PHP 8.0 we need to leave it in place.

@carlos-granados carlos-granados marked this pull request as draft February 13, 2025 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant