I have faced this issue recently to test that some functions are memory-efficient in Unit tests.
It requires just the Xdebug extension, as it uses Xdebug Profiler under the hood, generating and parsing the same trace files that Kachegrind would. It uses Xdebug functions to programmatically start a Profile on-demand against the function under test, then it parses the trace file, finds the peak memory execution inside that function and return it.
/**
* Returns the peak memory usage in bytes during the execution of a given function.
*
* @param callable $callback A callable to the function to check the memory usage.
* @param mixed ...$parameters The arguments to the callable function.
*
* @throws RuntimeException When Xdebug is not available, or
*
* @return int The peak memory usage in bytes that this function consumed during execution.
*/
protected function get_function_memory_usage( callable $callback, ...$parameters ) {
if ( ! function_exists( 'xdebug_stop_trace' ) ) {
throw new RuntimeException('Xdebug is required for this test.');
}
$trace_file = xdebug_start_trace( null, XDEBUG_TRACE_COMPUTERIZED );
call_user_func_array( $callback, $parameters );
xdebug_stop_trace();
$trace_file = new SplFileObject( $trace_file );
$start_context_memory_usage = 0;
$highest_memory_usage = 0;
/*
* A small Xdebug Tracefile analyser that looks for the highest memory allocation
* during the execution of the function
*
* @link https://github.com/xdebug/xdebug/blob/master/contrib/tracefile-analyser.php
*/
while ( $trace_file->valid() ) {
$line = $trace_file->fgets();
if (
preg_match( '@^Version: (.*)@', $line, $matches ) ||
preg_match( '@^File format: (.*)@', $line, $matches ) ||
preg_match( '@^TRACE.*@', $line, $matches )
) {
continue;
}
$trace_entry = explode( "\t", $line );
if ( count( $trace_entry ) < 5 ) {
continue;
}
$memory = (int) $trace_entry[4];
if ( $memory > $highest_memory_usage ) {
$highest_memory_usage = $memory;
}
if ( empty( $start_context_memory_usage ) ) {
$start_context_memory_usage = $memory;
}
}
$memory_used = $highest_memory_usage - $start_context_memory_usage;
return $memory_used;
}
Example results:
$function_uses_memory = static function( $mbs ) {
$a = str_repeat('a', 1024 * 1024 * $mbs);
};
$memory_usage_5 = get_function_memory_usage( $function_uses_memory, 5 );
$memory_usage_40 = get_function_memory_usage( $function_uses_memory, 40 );
$memory_usage_62 = get_function_memory_usage( $function_uses_memory, 62 );
$memory_usage_30 = get_function_memory_usage( $function_uses_memory, 30 );
// Result:
(
[$memory_usage_5] => 5247000
[$memory_usage_40] => 41947160
[$memory_usage_62] => 65015832
[$memory_usage_30] => 31461400
)
Optionally, you can return the memory used and the result, to assert on both, by changing just two lines:
// Store the result after calling the callback
$result = call_user_func_array( $callback, $parameters );
// Return the result along with the memory usage
return [ $result, $memory_used ];
This way you can assert the memory usage is expected, while giving the expected result.
If you are going to use this in tests, you can also add this helpful method to the memory trait:
protected function assertMemoryUsedWithinTolerance( $expected, $equal, $tolerance_percentage ) {
$is_within_tolerance = static function ( $expected, $actual ) use ( $tolerance_percentage ) {
$upper_limit = $expected + ( ( $expected / 100 ) * $tolerance_percentage );
return $actual <= $upper_limit;
};
$failure_message = static function ( $expected, $actual ) use ( $tolerance_percentage ) {
return sprintf( 'Failed to assert that a function uses up to %s bytes (%s) of memory (+%s%%). Actual usage: %s bytes (%s).', $expected, size_format( $expected, 4 ), $tolerance_percentage, $actual, size_format( $actual, 4 ) );
};
$this->assertTrue( $is_within_tolerance( $expected, $equal ), $failure_message( $expected, $equal ) );
}
Example usage:
public function test_memory_usage_parsing_long_string() {
$long_input = str_repeat('a', 1024 * 1024); // 1mb string
[ $result, $memory_used ] = $this->get_function_memory_usage( [$this, 'parse_long_string'], $long_input );
// If it exceeds 2x the size of the input it should fail.
$expected_memory_peak = 1024 * 1024 * 2; // 2mb
$this->assertEquals('foo', $result);
$this->assertMemoryUsedWithinTolerance($expected_memory_peak, $memory_used, 1); // 1% tolerance
}
Why not register_ticks_function
?
After I wrote this answer, I also tested the register_ticks_function
mentioned by another answer to this question, however, I found out it requires declare(ticks=1)
on the file where the function under test is, and all files where the function under test uses code from, otherwise the tick won't be triggered. The Xdebug approach works without the declare, so it works with all files and tracks memory usage nested deep into the function under test calls.