您可能聽說過「十億行挑戰」(1brc),如果您沒有聽說過,請查看Gunnar Morlings 的 1brc 儲存庫

我之所以被吸引,是因為我的兩位同事參加了比賽並進入了排行榜

PHP 並不以其速度而聞名,但當我正在開發 PHP 分析器時,我想我應該嘗試它,看看它能達到多快。

第一種幼稚的方法

我克隆了儲存庫並在measurements.txt中建立了十億行資料集。之後,我開始建立我的第一個天真的實現,可以解決這個挑戰:

<?php

$stations = [];

$fp = fopen('measurements.txt', 'r');

while ($data = fgetcsv($fp, null, ';')) {
    if (!isset($stations[$data[0]])) {
        $stations[$data[0]] = [
            $data[1],
            $data[1],
            $data[1],
            1
        ];
    } else {
        $stations[$data[0]][3] ++;
        $stations[$data[0]][2] += $data[1];
        if ($data[1] < $stations[$data[0]][0]) {
            $stations[$data[0]][0] = $data[1];
        }
        if ($data[1] > $stations[$data[0]][1]) {
            $stations[$data[0]][1] = $data[1];
        }
    }
}

ksort($stations);

echo '{';
foreach($stations as $k=>&$station) {
    $station[2] = $station[2]/$station[3];
    echo $k, '=', $station[0], '/', $station[2], '/', $station[1], ', ';
}
echo '}';

這裡沒什麼瘋狂的,只要打開文件,使用fgetcsv()讀取資料即可。如果尚未找到該站,則建立它,否則增加計數器,對溫度求和,並查看當前溫度是否低於或高於最小值或最大值並相應更新。

一旦我把所有東西都放在一起,我使用ksort()$stations陣列按順序排列,然後回顯列表併計算平均溫度(總和/計數)。

在我的筆記型電腦上執行這個簡單的程式碼需要25 分鐘🤯

是時候優化並查看分析器了:

使用 Datadog PHP 分析器的時間軸視覺化顯示程式碼 100% CPU 限制

時間軸視覺化幫助我看到,這顯然是 CPU 限制的,腳本開頭的檔案編譯可以忽略不計,並且沒有垃圾收集事件。

火焰圖視圖顯示我們在 <code>fgetcsv()</code> 函數中花了 46% 的 CPU 時間。

火焰圖視圖也有助於顯示我在fgetcsv()上花費了 46% 的 CPU 時間。

fgets()而不是fgetcsv()

第一個最佳化是使用fgets()取得一行並在;上分割。手動輸入字元而不是依賴fgetcsv() 。這是因為fgetcsv()所做的事情遠遠超出了我的需要。

// ...
while ($data = fgets($fp, 999)) {
    $pos = strpos($data, ';');
    $city = substr($data, 0, $pos);
    $temp = substr($data, $pos+1, -1);
// ...

此外,我在各處將$data[0]重構為$city ,將$data[1]$temp

僅透過此變更再次執行腳本就已將執行時間降至19m 49s 。從絕對數字來看,這仍然是很多,而且:下降了 21%

火焰圖視圖現在顯示 <code>fgets()</code>、<code>substr()</code> 和 <code>strpos()</code> 組合使用與 <code>fgetcsv 幾乎相同數量的 CPU ( )</code> 之前,但仍然更快

火焰圖反映了這一變化,切換到按行顯示 CPU 時間也揭示了根框架中發生的情況:

火焰圖按行顯示 CPU 時間,表示我們在第 18 行和第 23 行花了 38%

我在第 18 行和第 23 行花費了約 38% 的 CPU 時間,它們是:

18 | $stations[$city][3] ++;
   | // ...
23 | if ($temp > $stations[$city][1]) {

第 18 行是循環中對$stations陣列的第一次存取,否則它只是一個增量,第23 行是一個比較,乍一看似乎沒什麼昂貴的,但讓我們再做一些優化,你就會看到發生了什麼時間在這裡。

盡可能使用參考

$station = &$stations[$city];
$station[3] ++;
$station[2] += $temp;
// instead of
$stations[$city][3] ++;
$stations[$city][2] += $data[1];

這應該有助於 PHP 在每次存取陣列時不必搜尋$stations陣列中的鍵,將其視為用於存取陣列中“當前”電台的快取。

它實際上很有幫助,執行這個只需要17m 48s ,又減少了 10%

只有一處比較

在查看程式碼時,我偶然發現了這段程式碼:

if ($temp < $station[0]) {
    $station[0] = $temp;
}
if ($temp > $station[1]) {
    $station[1] = $temp;
}

如果溫度低於最小值,它就不能再高於最大值,所以我將其設為elseif ,也許可以節省一些 CPU 週期。

順便說一句:我對measurements.txt中的溫度順序一無所知,但根據該順序,如果我先檢查一個或另一個,可能會產生影響。

新版本需要 17m 30s,又增加了約 2%。比僅僅抖動好,但也不是很多。

新增類型轉換

PHP被認為是一種動態語言,這是我剛開始寫軟體時非常重視的東西,少了一個需要關心的問題。但另一方面,了解類型有助於引擎在執行程式碼時做出更好的決策。

$temp = (float)substr($data, $pos+1, -1);

你猜怎麼了?這個簡單的轉換使腳本執行時間僅為13m 32s效能提升了 21%

火焰圖按行顯示 CPU 時間,表示我們在 23 中花了 15%

18 | $station = &$stations[$city];
   | // ...
23 | } elseif ($temp > $station[1]) {

第 18 行仍然顯示 11% 的 CPU 時間花費,這是對陣列的存取(在哈希映射中查找鍵,哈希映射是 PHP 中用於關聯陣列的底層資料結構)。

第 23 行的 CPU 時間從 ~32% 下降到 ~15%。這是因為 PHP 不再進行型別處理。在型別轉換之前, $temp / $station[0] / $station[1]strings ,因此 PHP 必須將它們轉換為float以便在每次比較時對它們進行比較。

JIT 怎麼樣?

PHP 中的 OPCache 在 CLI 中預設為停用狀態,需要將opcache.enable_cli設定設為on 。 JIT(作為 OPCache 的一部分)預設為啟用,但由於緩衝區大小設定為0而有效停用,因此我將opcache.jit-buffer-size設為某個值,我只是使用10M 。應用這些更改後,我使用 JIT 重新執行腳本並看到它完成:

7m 19s 🚀

花費的時間減少了45.9%

還有什麼?

我已經將執行時間從一開始的 25 分鐘縮短到了大約 7 分鐘。我發現絕對令人驚訝的一件事是fgets()分配 ~56 GiB/m 的 RAM 來讀取 13 GB 檔案。似乎有些不對勁,所以我檢查了fgets()的實現,看起來我可以透過省略fgets()len參數來節省大量分配:

while ($data = fgets($fp)) {
// instead of
while ($data = fgets($fp, 999)) {

比較更改前後的設定檔可以得出以下結果:

忽略 <code>fgets()</code> 的 len 參數之前和之後的設定檔比較視圖顯示,PHP 每分鐘僅分配 6BiG,而不是每分鐘 56 GiB。

您可能認為這帶來了很大的效能提升,但實際上只有大約 1%。這是因為這些是ZendMM 可以在 bin 中處理的小分配,而且速度非常快。

我們可以讓它更快嗎?

我們可以!到目前為止,我的方法是單線程,這是大多數 PHP 軟體的本質,但 PHP 確實透過並行擴展來支援使用者狀態中的線程。

正如分析器清楚地顯示的那樣,在 PHP 中讀取資料是一個瓶頸。從fgetcsv()切換到fgets()並手動拆分會有所幫助,但這仍然需要花費大量時間,因此讓我們使用線程並行讀取和處理資料,然後合併工作線程的中間結果。

<?php

$file = 'measurements.txt';

$threads_cnt = 16;

/**
 * Get the chunks that each thread needs to process with start and end position.
 * These positions are aligned to \n chars because we use `fgets()` to read
 * which itself reads till a \n character.
 *
 * @return array<int, array{0: int, 1: int}>
 */
function get_file_chunks(string $file, int $cpu_count): array {
    $size = filesize($file);

    if ($cpu_count == 1) {
        $chunk_size = $size;
    } else {
        $chunk_size = (int) ($size / $cpu_count);
    }

    $fp = fopen($file, 'rb');

    $chunks = [];
    $chunk_start = 0;
    while ($chunk_start < $size) {
        $chunk_end = min($size, $chunk_start + $chunk_size);

        if ($chunk_end < $size) {
            fseek($fp, $chunk_end);
            fgets($fp); // moves fp to next \n char
            $chunk_end = ftell($fp);
        }

        $chunks[] = [
            $chunk_start,
            $chunk_end
        ];

        $chunk_start = $chunk_end+1;
    }

    fclose($fp);
    return $chunks;
}

/**
 * This function will open the file passed in `$file` and read and process the
 * data from `$chunk_start` to `$chunk_end`.
 *
 * The returned array has the name of the city as the key and an array as the
 * value, containing the min temp in key 0, the max temp in key 1, the sum of
 * all temperatures in key 2 and count of temperatures in key 3.
 *
 * @return array<string, array{0: float, 1: float, 2: float, 3: int}>
 */ 
$process_chunk = function (string $file, int $chunk_start, int $chunk_end): array {
    $stations = [];
    $fp = fopen($file, 'rb');
    fseek($fp, $chunk_start);
    while ($data = fgets($fp)) {
        $chunk_start += strlen($data);
        if ($chunk_start > $chunk_end) {
            break;
        }
        $pos2 = strpos($data, ';');
        $city = substr($data, 0, $pos2);
        $temp = (float)substr($data, $pos2+1, -1);
        if (isset($stations[$city])) {
            $station = &$stations[$city];
            $station[3] ++;
            $station[2] += $temp;
            if ($temp < $station[0]) {
                $station[0] = $temp;
            } elseif ($temp > $station[1]) {
                $station[1] = $temp;
            }
        } else {
            $stations[$city] = [
                $temp,
                $temp,
                $temp,
                1
            ];
        }
    }
    return $stations;
};

$chunks = get_file_chunks($file, $threads_cnt);

$futures = [];

for ($i = 0; $i < $threads_cnt; $i++) {
    $runtime = new \parallel\Runtime();
    $futures[$i] = $runtime->run(
        $process_chunk,
        [
            $file,
            $chunks[$i][0],
            $chunks[$i][1]
        ]
    );
}

$results = [];

for ($i = 0; $i < $threads_cnt; $i++) {
    // `value()` blocks until a result is available, so the main thread waits
    // for the thread to finish
    $chunk_result = $futures[$i]->value();
    foreach ($chunk_result as $city => $measurement) {
        if (isset($results[$city])) {
            $result = &$results[$city];
            $result[2] += $measurement[2];
            $result[3] += $measurement[3];
            if ($measurement[0] < $result[0]) {
                $result[0] = $measurement[0];
            }
            if ($measurement[1] < $result[1]) {
                $result[1] = $measurement[1];
            }
        } else {
            $results[$city] = $measurement;
        }
    }
}

ksort($results);

echo '{', PHP_EOL;
foreach($results as $k=>&$station) {
    echo "\t", $k, '=', $station[0], '/', ($station[2]/$station[3]), '/', $station[1], ',', PHP_EOL;
}
echo '}', PHP_EOL;

這段程式碼做了一些事情,首先我掃描檔案並將其分成\n對齊的區塊(因為我稍後使用fgets() )。當我準備好區塊時,我啟動$threads_cnt工作線程,然後所有線程都打開同一個檔案並尋找分配的區塊開始並讀取和處理資料直到區塊結束,返回一個中間結果,然後將其組合、排序和列印在主線程中輸出。

這種多執行緒方法只需:

1 分 35 秒🚀

這就是結局?

不,當然不是。這個解決方案至少還有兩件事:

  1. 我在 Apple Silicon 硬體上的 MacOS 上執行此程式碼,在 PHP 的 ZTS 版本中使用 JIT 時會崩潰,因此 1m 35s 結果是沒有 JIT 的,如果我可以使用它,它可能會更快

  2. 我意識到我正在執行一個使用CFLAGS="-g -O0 ..."編譯的 PHP 版本,因為我的日常工作需要 🤦

我應該在一開始就檢查一下這一點,所以我使用CLFAGS="-Os ..."重新編譯了 PHP 8.3,我的最終數字(有 16 個線程)是:

🚀 27.7 秒🚀

這個數字絕對無法與您在原始挑戰的排行榜中看到的數字相媲美,這是因為我在完全不同的硬體上執行了這段程式碼。

這是一個有 10 個執行緒的時間軸視圖:

圖片描述

最底層的線程是主線程,等待工作線程的結果。一旦這些工作人員返回了中間結果,就可以看到主線程正在對所有內容進行組合和排序。我們也可以清楚看到,主線程絕對不是瓶頸。如果您想嘗試進一步優化,請專注於工作線程。

我在路上學到了什麼?

每個抽象層只是用可用性/整合來換取 CPU 週期或記憶體。 fgetcsv()非常容易使用,並且隱藏了很多東西,但是這是有代價的。即使fgets()也向我們隱藏了一些東西,但卻使讀取資料變得非常方便。

在程式碼中新增類型將有助於語言最佳化執行或停止類型雜耍(這是您看不到的東西,但仍然需要付出 CPU 週期的代價)。

JIT 非常棒,尤其是在處理 CPU 密集型問題時!

這絕對不是大多數 PHP 軟體的本質,但由於並行化(使用ext-parallel ),我們可以顯著降低數字。

結束

我希望您和我一樣從閱讀這篇文章中獲得樂趣。如果您想進一步優化程式碼,請隨時取得此程式碼並發表評論。


原文出處:https://dev.to/realflowcontrol/processing-one-billion-rows-in-php-3eg0


共有 0 則留言