HardТеория13 min

Профилирование и оптимизация

Xdebug profiler, Blackfire, SPX, оптимизация кода, PHP-FPM tuning, бенчмарки

Профилирование и оптимизация PHP

Правило оптимизации

"Premature optimization is the root of all evil" — Donald Knuth

Никогда не оптимизируйте без профилирования. Сначала измерьте, потом оптимизируйте.

Правильный порядок:
1. Написать работающий код
2. Написать тесты
3. Профилировать (найти bottleneck)
4. Оптимизировать ТОЛЬКО bottleneck
5. Измерить снова (подтвердить улучшение)
6. Повторить при необходимости

Ручное измерение

microtime и hrtime

<?php
declare(strict_types=1);

// microtime(true) — float seconds (microsecond precision)
$start = microtime(true);
// ... code to measure ...
$elapsed = microtime(true) - $start;
echo "Time: " . number_format($elapsed * 1000, 2) . " ms\n";

// hrtime(true) — nanoseconds (most precise, monotonic clock)
$start = hrtime(true);
// ... code to measure ...
$elapsed = hrtime(true) - $start;
echo "Time: " . number_format($elapsed / 1_000_000, 2) . " ms\n";

// hrtime() without true — [seconds, nanoseconds]
$start = hrtime();
// ... code ...
$end = hrtime();
$nanos = ($end[0] - $start[0]) * 1_000_000_000 + ($end[1] - $start[1]);

Почему hrtime лучше microtime: hrtime() использует монотонные часы, которые не зависят от изменений системного времени (NTP sync, DST). Для бенчмарков всегда предпочитайте hrtime(true).

Профилирование памяти

<?php
declare(strict_types=1);

// Memory profiling
function profileMemory(string $label, Closure $callback): void
{
    gc_collect_cycles();
    $memBefore = memory_get_usage();
    $peakBefore = memory_get_peak_usage();

    $result = $callback();

    gc_collect_cycles();
    $memAfter = memory_get_usage();
    $peakAfter = memory_get_peak_usage();

    printf(
        "[%s] Memory: %+.2f KB, Peak: %+.2f KB\n",
        $label,
        ($memAfter - $memBefore) / 1024,
        ($peakAfter - $peakBefore) / 1024,
    );

    return $result;
}

// Usage
profileMemory('Array creation', function (): void {
    $data = range(1, 100_000);
    // Memory: ~3200 KB
});

profileMemory('Generator', function (): void {
    $gen = (function () {
        for ($i = 1; $i <= 100_000; $i++) {
            yield $i;
        }
    })();

    foreach ($gen as $val) {
        // Process
    }
    // Memory: ~0.5 KB
});

Простой profiler

<?php
declare(strict_types=1);

/**
 * Simple inline profiler for development.
 */
final class Profiler
{
    /** @var array<string, array{start: int, memory: int}> */
    private static array $timers = [];

    /** @var array<string, array{time: float, memory: int, calls: int}> */
    private static array $results = [];

    public static function start(string $label): void
    {
        self::$timers[$label] = [
            'start' => hrtime(true),
            'memory' => memory_get_usage(),
        ];
    }

    public static function stop(string $label): void
    {
        if (!isset(self::$timers[$label])) {
            return;
        }

        $elapsed = hrtime(true) - self::$timers[$label]['start'];
        $memoryDelta = memory_get_usage() - self::$timers[$label]['memory'];

        if (!isset(self::$results[$label])) {
            self::$results[$label] = ['time' => 0, 'memory' => 0, 'calls' => 0];
        }

        self::$results[$label]['time'] += $elapsed / 1_000_000;  // ms
        self::$results[$label]['memory'] += $memoryDelta;
        self::$results[$label]['calls']++;

        unset(self::$timers[$label]);
    }

    /**
     * Profile a closure and return its result.
     *
     * @template T
     * @param string $label
     * @param Closure(): T $callback
     * @return T
     */
    public static function measure(string $label, Closure $callback): mixed
    {
        self::start($label);
        try {
            return $callback();
        } finally {
            self::stop($label);
        }
    }

    public static function report(): void
    {
        echo "\n=== Profiler Report ===\n";
        echo str_pad('Label', 30) . str_pad('Time (ms)', 15)
            . str_pad('Memory', 15) . "Calls\n";
        echo str_repeat('-', 75) . "\n";

        // Sort by time descending
        uasort(self::$results, fn ($a, $b) => $b['time'] <=> $a['time']);

        foreach (self::$results as $label => $data) {
            printf(
                "%s%s%s%d\n",
                str_pad($label, 30),
                str_pad(number_format($data['time'], 2), 15),
                str_pad(self::formatBytes($data['memory']), 15),
                $data['calls'],
            );
        }
    }

    private static function formatBytes(int $bytes): string
    {
        $sign = $bytes < 0 ? '-' : '+';
        $bytes = abs($bytes);

        if ($bytes >= 1024 * 1024) {
            return $sign . number_format($bytes / 1024 / 1024, 2) . ' MB';
        }
        if ($bytes >= 1024) {
            return $sign . number_format($bytes / 1024, 2) . ' KB';
        }
        return $sign . $bytes . ' B';
    }
}

// Usage
Profiler::start('total');

$data = Profiler::measure('generate data', function (): array {
    return range(1, 50_000);
});

Profiler::start('sort');
sort($data);
Profiler::stop('sort');

Profiler::measure('serialize', function () use ($data): string {
    return json_encode($data);
});

Profiler::stop('total');
Profiler::report();

Xdebug Profiler

Xdebug может создавать файлы профилирования в формате Cachegrind, которые анализируются инструментами визуализации.

; php.ini — Xdebug profiling configuration
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug-profiles
xdebug.profiler_output_name=cachegrind.out.%p.%t

; Trigger profiling only with parameter (not every request)
xdebug.start_with_request=trigger
; Then: http://example.com/?XDEBUG_PROFILE=1
; Or header: Cookie: XDEBUG_PROFILE=1
# Analyze cachegrind files:

# KCachegrind (Linux/macOS)
kcachegrind /tmp/xdebug-profiles/cachegrind.out.12345

# QCachegrind (macOS via Homebrew)
brew install qcachegrind
qcachegrind /tmp/xdebug-profiles/cachegrind.out.12345

# webgrind (web-based, PHP)
# https://github.com/jokkedk/webgrind

Что даёт Xdebug profiler

Cachegrind output содержит:
├── Дерево вызовов (call tree)
├── Время каждой функции (self time / inclusive time)
├── Количество вызовов каждой функции
├── Процент общего времени
└── Вызывающая → вызываемая связь

Что искать:
1. Функции с наибольшим inclusive time (bottleneck)
2. Функции вызванные слишком много раз
3. Неожиданно медленные операции
4. N+1 проблемы (много одинаковых SQL-запросов)

Blackfire.io

Blackfire — коммерческий профилировщик с минимальным overhead. В отличие от Xdebug, может использоваться в production.

# Install Blackfire
# 1. Install probe (PHP extension)
# 2. Install agent (daemon)
# 3. Install CLI client

# Profile from CLI
blackfire run php script.php

# Profile HTTP request
blackfire curl http://localhost/api/users

# Continuous profiling in production
# Configure in blackfire.yaml
<?php
declare(strict_types=1);

// Blackfire SDK — manual instrumentation
use Blackfire\Client;
use Blackfire\Profile\Configuration;

$blackfire = new Client();

$config = new Configuration();
$config->setTitle('User API Profile');
$config->assert('metrics.sql.queries.count < 10', 'Too many queries');
$config->assert('main.wall_time < 200ms', 'Too slow');

$probe = $blackfire->createProbe($config);

// ... code to profile ...

$profile = $blackfire->endProbe($probe);
echo "Profile URL: {$profile->getUrl()}\n";

Blackfire assertions (CI/CD)

# .blackfire.yaml — performance tests
tests:
  "Homepage should be fast":
    path: /
    assertions:
      - "metrics.http.requests.count < 5"
      - "main.wall_time < 100ms"
      - "main.peak_memory < 20mb"
      - "metrics.sql.queries.count < 3"

  "API Users Endpoint":
    path: /api/v1/users
    assertions:
      - "main.wall_time < 50ms"
      - "metrics.sql.queries.count == 1"

SPX (Simple Profiling Extension)

SPX — легковесный профилировщик с встроенным веб-интерфейсом. Бесплатный и с минимальным overhead.

# Install SPX
git clone https://github.com/NoiseByNorthworst/php-spx.git
cd php-spx
phpize && ./configure && make && make install
; php.ini
extension=spx.so
spx.http_enabled=1
spx.http_key=MySecretKey
spx.http_ip_whitelist="127.0.0.1"

; Access UI: http://localhost/?SPX_KEY=MySecretKey&SPX_UI_URI=/
; Profile request: http://localhost/page?SPX_KEY=MySecretKey&SPX_ENABLED=1

Оптимизация кода

Сравнение строк

<?php
declare(strict_types=1);

$string = 'hello world';
$iterations = 1_000_000;

// === faster than == (no type juggling)
// Benchmark:
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $result = $string === 'hello world';  // Strict
}
$strict = hrtime(true) - $start;

$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $result = $string == 'hello world';   // Loose
}
$loose = hrtime(true) - $start;

printf("=== : %.2f ms\n", $strict / 1_000_000);
printf("==  : %.2f ms\n", $loose / 1_000_000);
// === is typically 10-20% faster

isset() vs array_key_exists()

<?php
declare(strict_types=1);

$data = ['key' => null, 'other' => 'value'];
$iterations = 1_000_000;

// isset() — faster, but returns false for null values
// array_key_exists() — slower, but handles null correctly

$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $exists = isset($data['key']);  // false (key exists but value is null!)
}
$issetTime = hrtime(true) - $start;

$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $exists = array_key_exists('key', $data);  // true
}
$akeTime = hrtime(true) - $start;

printf("isset():             %.2f ms\n", $issetTime / 1_000_000);
printf("array_key_exists():  %.2f ms\n", $akeTime / 1_000_000);
// isset() is 2-3x faster

// Use isset() when:
// - You don't need to distinguish null from missing key
// - Performance matters (hot paths)

// Use array_key_exists() when:
// - null is a valid value
// - Correctness more important than speed

foreach vs array_map vs for

<?php
declare(strict_types=1);

$data = range(1, 100_000);
$iterations = 10;

// foreach — usually fastest for simple transformations
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $result = [];
    foreach ($data as $val) {
        $result[] = $val * 2;
    }
}
$foreachTime = hrtime(true) - $start;

// array_map — cleaner syntax, slight overhead
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $result = array_map(fn (int $val) => $val * 2, $data);
}
$mapTime = hrtime(true) - $start;

// for loop with index
$start = hrtime(true);
$count = count($data);
for ($i = 0; $i < $iterations; $i++) {
    $result = [];
    for ($j = 0; $j < $count; $j++) {
        $result[] = $data[$j] * 2;
    }
}
$forTime = hrtime(true) - $start;

printf("foreach:    %.2f ms\n", $foreachTime / 1_000_000);
printf("array_map:  %.2f ms\n", $mapTime / 1_000_000);
printf("for:        %.2f ms\n", $forTime / 1_000_000);
// foreach ≈ for > array_map (map has closure call overhead)

Generators для экономии памяти

<?php
declare(strict_types=1);

// Processing large CSV: array vs generator

// BAD: loads entire file into memory
function readCsvAll(string $path): array
{
    $rows = [];
    $handle = fopen($path, 'r');
    while (($row = fgetcsv($handle)) !== false) {
        $rows[] = $row;
    }
    fclose($handle);
    return $rows;
}
// For 1 million rows: ~500 MB memory!

// GOOD: generator — one row at a time
function readCsvStream(string $path): Generator
{
    $handle = fopen($path, 'r');
    while (($row = fgetcsv($handle)) !== false) {
        yield $row;
    }
    fclose($handle);
}
// For 1 million rows: ~0.5 KB memory!

// Usage is identical:
foreach (readCsvStream('/data/large.csv') as $row) {
    processRow($row);
}

function processRow(array $row): void {}

SplFixedArray vs Array

<?php
declare(strict_types=1);

// Regular array: hash table, flexible, more memory
// SplFixedArray: C-like array, fixed size, less memory

$size = 1_000_000;

$memBefore = memory_get_usage();
$arr = [];
for ($i = 0; $i < $size; $i++) {
    $arr[$i] = $i;
}
$arrMemory = memory_get_usage() - $memBefore;

unset($arr);
gc_collect_cycles();

$memBefore = memory_get_usage();
$fixed = new SplFixedArray($size);
for ($i = 0; $i < $size; $i++) {
    $fixed[$i] = $i;
}
$fixedMemory = memory_get_usage() - $memBefore;

printf("Regular array:  %s\n", number_format($arrMemory / 1024 / 1024, 2) . ' MB');
printf("SplFixedArray:  %s\n", number_format($fixedMemory / 1024 / 1024, 2) . ' MB');
// SplFixedArray uses ~40-50% less memory for large numeric arrays

Предвычисления и кеширование

<?php
declare(strict_types=1);

// BAD: recalculate on every iteration
function processItemsBad(array $items, array $config): void
{
    foreach ($items as $item) {
        // strlen, strtolower called N times with same value
        if (strlen($config['prefix']) > 0) {
            $name = strtolower($config['prefix']) . '_' . $item;
        }
    }
}

// GOOD: precompute outside loop
function processItemsGood(array $items, array $config): void
{
    $hasPrefix = strlen($config['prefix']) > 0;
    $prefix = $hasPrefix ? strtolower($config['prefix']) . '_' : '';

    foreach ($items as $item) {
        $name = $prefix . $item;
    }
}

// BAD: count() in loop condition (recalculated each iteration for some types)
for ($i = 0; $i < count($array); $i++) {
    // count() called each iteration
}

// GOOD: precompute count
$count = count($array);
for ($i = 0; $i < $count; $i++) {
    // Direct comparison with cached value
}

Строки: конкатенация vs implode

<?php
declare(strict_types=1);

$parts = array_fill(0, 10_000, 'segment');

// Concatenation in loop: O(n²) memory copies
$start = hrtime(true);
$result = '';
foreach ($parts as $part) {
    $result .= $part . ',';  // Each .= copies entire string
}
$concatTime = hrtime(true) - $start;

// implode: single allocation
$start = hrtime(true);
$result = implode(',', $parts);
$implodeTime = hrtime(true) - $start;

printf("Concat loop:  %.2f ms\n", $concatTime / 1_000_000);
printf("implode():    %.2f ms\n", $implodeTime / 1_000_000);
// implode is 5-20x faster for large arrays

// Alternative: collect in array, then implode
$start = hrtime(true);
$buffer = [];
foreach ($parts as $part) {
    $buffer[] = $part;  // Array append is fast
}
$result = implode(',', $buffer);
$bufferTime = hrtime(true) - $start;
printf("Buffer+join:  %.2f ms\n", $bufferTime / 1_000_000);
// Almost as fast as direct implode

Типизация улучшает JIT

<?php
declare(strict_types=1);

// Type hints help JIT optimize better

// Without types — JIT can't optimize well
function addUntyped($a, $b)
{
    return $a + $b;  // JIT doesn't know types → generic path
}

// With types — JIT optimizes to native CPU instructions
function addTyped(int $a, int $b): int
{
    return $a + $b;  // JIT knows both are int → native integer add
}

// This matters most in tight loops
function sumUntyped(array $data)
{
    $sum = 0;
    foreach ($data as $val) {
        $sum += $val;
    }
    return $sum;
}

function sumTyped(array $data): int
{
    $sum = 0;
    foreach ($data as $val) {
        $sum += (int) $val;
    }
    return $sum;
}

Оптимизация SQL-запросов

N+1 проблема

<?php
declare(strict_types=1);

// N+1 problem — the most common performance killer

// BAD: N+1 queries
// 1 query for posts + N queries for authors = N+1 total
$posts = $pdo->query("SELECT * FROM posts LIMIT 100")->fetchAll();
foreach ($posts as $post) {
    // This executes 100 separate queries!
    $author = $pdo->prepare("SELECT * FROM users WHERE id = ?");
    $author->execute([$post['author_id']]);
    $post['author'] = $author->fetch();
}
// Total: 101 queries

// GOOD: Eager loading with JOIN (1 query)
$stmt = $pdo->query("
    SELECT p.*, u.name as author_name, u.email as author_email
    FROM posts p
    JOIN users u ON u.id = p.author_id
    LIMIT 100
");
$posts = $stmt->fetchAll();
// Total: 1 query

// GOOD: Batch loading with IN (2 queries)
$posts = $pdo->query("SELECT * FROM posts LIMIT 100")->fetchAll();
$authorIds = array_unique(array_column($posts, 'author_id'));

if (!empty($authorIds)) {
    $placeholders = implode(',', array_fill(0, count($authorIds), '?'));
    $stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ({$placeholders})");
    $stmt->execute($authorIds);
    $authors = $stmt->fetchAll(PDO::FETCH_UNIQUE);  // Indexed by id

    foreach ($posts as &$post) {
        $post['author'] = $authors[$post['author_id']] ?? null;
    }
}
// Total: 2 queries (regardless of number of posts)

Индексы

<?php
declare(strict_types=1);

// ALWAYS check query plans with EXPLAIN

// Without index:
// SELECT * FROM orders WHERE user_id = 123;
// → Seq Scan on orders (cost=0.00..1234.00 rows=1000000)
// Scans ALL rows!

// With index:
// CREATE INDEX idx_orders_user_id ON orders(user_id);
// SELECT * FROM orders WHERE user_id = 123;
// → Index Scan using idx_orders_user_id (cost=0.00..8.00 rows=10)
// Scans only matching rows

// Composite index for common queries:
// SELECT * FROM orders WHERE user_id = 123 AND status = 'active'
// ORDER BY created_at DESC;
// → CREATE INDEX idx_orders_user_status_created
//   ON orders(user_id, status, created_at DESC);

// Check missing indexes (PostgreSQL):
// SELECT schemaname, relname, seq_scan, idx_scan
// FROM pg_stat_user_tables
// WHERE seq_scan > idx_scan AND seq_scan > 1000;

Пагинация: keyset vs OFFSET

<?php
declare(strict_types=1);

// BAD: OFFSET pagination — gets slower with each page
// Page 1000: database reads and discards 999,000 rows!
$page = 1000;
$perPage = 20;
$offset = ($page - 1) * $perPage;
$pdo->query("SELECT * FROM articles ORDER BY id DESC LIMIT {$perPage} OFFSET {$offset}");
// Slow: O(offset + limit)

// GOOD: Keyset (cursor) pagination — constant speed
$lastId = 12345;  // Last ID from previous page
$pdo->prepare("
    SELECT * FROM articles
    WHERE id < ?
    ORDER BY id DESC
    LIMIT 20
");
$stmt->execute([$lastId]);
// Fast: O(limit) — always scans only 20 rows

PHP-FPM Tuning

<?php
declare(strict_types=1);

// Calculate optimal PHP-FPM settings

// Step 1: Measure average worker memory
// Run: ps -eo pid,rss,command | grep php-fpm | awk '{sum+=$2; n++} END {print sum/n/1024 " MB"}'
$avgWorkerMemoryMb = 40;  // Example: 40 MB per worker

// Step 2: Available RAM for PHP
$totalRamMb = 4096;  // 4 GB server
$reservedMb = 1024;  // RAM for OS, nginx, PostgreSQL, etc.
$phpRamMb = $totalRamMb - $reservedMb;  // 3072 MB for PHP

// Step 3: Calculate max_children
$maxChildren = (int) floor($phpRamMb / $avgWorkerMemoryMb);
echo "Recommended pm.max_children = {$maxChildren}\n";  // 76

// Step 4: Dynamic pool settings
$startServers = (int) ($maxChildren * 0.25);      // 25% at startup
$minSpare = (int) ($maxChildren * 0.25);           // Min idle
$maxSpare = (int) ($maxChildren * 0.75);           // Max idle

echo <<<CONFIG
    [www]
    pm = dynamic
    pm.max_children = {$maxChildren}
    pm.start_servers = {$startServers}
    pm.min_spare_servers = {$minSpare}
    pm.max_spare_servers = {$maxSpare}
    pm.max_requests = 1000

    ; Slow log (find slow requests)
    request_slowlog_timeout = 5s
    slowlog = /var/log/php-fpm/slow.log

    ; Hard timeout
    request_terminate_timeout = 30s
    CONFIG;

Чеклист оптимизации

<?php
declare(strict_types=1);

/**
 * Performance optimization checklist for PHP applications.
 */

// === INFRASTRUCTURE (biggest impact) ===
// [ ] OPcache enabled with proper settings
// [ ] JIT enabled (tracing mode)
// [ ] Preloading configured
// [ ] PHP-FPM tuned (pm.max_children)
// [ ] Nginx configured (gzip, keepalive, static files)

// === DATABASE (usually the bottleneck) ===
// [ ] N+1 queries eliminated
// [ ] Indexes on filtered/sorted columns
// [ ] Keyset pagination (no OFFSET)
// [ ] Connection pooling (PgBouncer)
// [ ] Query caching (Redis/APCu)
// [ ] EXPLAIN on slow queries

// === APPLICATION CODE ===
// [ ] Lazy loading (load data only when needed)
// [ ] Generators for large datasets
// [ ] APCu for frequently accessed data
// [ ] Redis for shared cache
// [ ] Strict types + type hints (helps JIT)
// [ ] Avoid unnecessary object creation in loops
// [ ] Precompute loop-invariant values

// === MONITORING ===
// [ ] Slow query log enabled (PostgreSQL)
// [ ] PHP-FPM slow log enabled
// [ ] APM tool (Blackfire, New Relic, Datadog)
// [ ] Memory monitoring for workers
// [ ] Response time tracking

Практический пример: оптимизация эндпоинта

<?php
declare(strict_types=1);

// BEFORE optimization: 850ms, 45MB memory

class OrderController
{
    public function listOrders(int $userId): array
    {
        // Problem 1: No pagination
        $orders = $this->orderRepository->findByUser($userId);

        // Problem 2: N+1 for products
        foreach ($orders as &$order) {
            $order['products'] = $this->productRepository
                ->findByOrderId($order['id']);

            // Problem 3: N+1 for product images
            foreach ($order['products'] as &$product) {
                $product['image'] = $this->imageService
                    ->getUrl($product['image_id']);
            }
        }

        // Problem 4: No caching
        return $orders;
    }
}

// AFTER optimization: 35ms, 2MB memory

class OptimizedOrderController
{
    public function __construct(
        private readonly OrderRepository $orderRepository,
        private readonly AppCache $cache,
    ) {}

    public function listOrders(int $userId, ?int $lastId = null): array
    {
        $cacheKey = "orders:{$userId}:{$lastId}";

        // Fix 4: Cache responses
        return $this->cache->remember($cacheKey, function () use ($userId, $lastId): array {
            // Fix 1: Keyset pagination
            // Fix 2: JOIN to eliminate N+1
            // Fix 3: Single query with all data
            return $this->orderRepository->findByUserWithProducts(
                userId: $userId,
                afterId: $lastId,
                limit: 20,
            );
        }, ttl: 60);
    }
}

// SQL: Single query with JOINs
// SELECT o.*, p.name, p.price, i.url as image_url
// FROM orders o
// JOIN order_items oi ON oi.order_id = o.id
// JOIN products p ON p.id = oi.product_id
// LEFT JOIN images i ON i.id = p.image_id
// WHERE o.user_id = ? AND o.id < ?
// ORDER BY o.id DESC
// LIMIT 20

Проверь себя

5 из 11

Первое правило оптимизации?

Как SplFixedArray экономит память?

Как правильно рассчитать pm.max_children для PHP-FPM?

Какой инструмент можно использовать для профилирования PHP в production?

Как Generators помогают с производительностью?