譯者 | 劉汪洋
審校 | 重樓
如果在閱讀這篇文章之前,你還不了解“十億行挑戰”( The One Billion Row Challenge,1brc ),我推薦你訪問 Gunnar Morling 的 1brc Github 代碼倉庫了解更多詳情。
我有兩位同事已經參與這項挑戰并成功上榜,因此我也選擇加入。
雖然 php 的執行速度并不出名,但我正開發一個 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分鐘。
很明顯,我需要對這段代碼進行優化,并對其進行性能分析:
通過可視化的時間線,我們可以分析出腳本運行明顯受到 CPU 限制,腳本開始時的文件編譯時間可以忽略不計,且幾乎沒有垃圾收集事件發生。
火焰圖清晰地顯示出,fgetcsv()函數占據了約 46% 的 CPU 時間。
使用 fgets() 替代 fgetcsv()
為了提升性能,我決定用fgets()替換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,以增強代碼的可讀性。
這個簡單的修改使得腳本運行時間大幅減少到 19 分鐘 49 秒,雖然時間仍然較長,但相比之前已經減少了 21%。
通過火焰圖的比較,可以看到在替換后 CPU 的時間利用率發生了變化,詳細的根幀分析也揭示了具體的性能瓶頸位置:
在腳本的第 18 行和第 23 行花費了大約 38% 的CPU時間。
18 | $stations[$city][3]++;
| // ...
23 | if ($temp > $stations[$city][1]) {
第 18 行是數組$stations的首次訪問和增量操作,而第 23 行進行了一次看似不那么耗時的比較操作。盡管如此,進一步優化有助于揭示這些操作中潛在的性能開銷。
盡可能使用引用
為了提高性能,我決定在處理數組時使用引用,以避免每次訪問數組時都對$stations數組中的鍵進行搜索。這相當于為數組中的"當前"站點設置了一個緩存。
代碼如下:
$station = &$stations[$city];
$station[3]++;
$station[2] += $temp;
// 替代原有的
$stations[$city][3]++;
$stations[$city][2] += $temp;
這一改變實際上大大減少了執行時間,將其縮短到 17 分鐘 48 秒,進一步減少了 **10% **的運行時間。
條件判斷優化
在審查代碼的過程中,我注意到了以下片段:
if ($temp < $station[0]) {
$station[0] = $temp;
} elseif ($temp > $station[1]) {
$station[1] = $temp;
}
考慮到一個溫度值如果低于最小值,則不可能同時高于最大值,因此我使用elseif來優化條件判斷,這可能會節省一些 CPU 周期。
需要指出的是,由于我不知道measurements.txt中溫度值的排列順序,根據這個順序,首先檢查最小值還是最大值可能會有所不同。
這次優化將時間進一步縮短到 17 分鐘 30 秒,節省了大約 2% 的時間,雖然這個提升并不是非常顯著。
執行類型轉換
PHP是一種動態類型語言,我在編程初期非常欣賞它這一特點,因為它簡化了許多問題。然而,另一方面,明確變量類型能幫助解釋引擎更高效地執行代碼。
$temp = (float)substr($data, $pos + 1, -1);
令人驚訝的是,這個簡單的類型轉換把腳本執行時間縮短至 13 分鐘 32 秒,性能提升達到了驚人的 **21% **!
18 | $station = &$stations[$city];
| // ...
23 | } elseif ($temp > $station[1]) {
在優化后,第 18 行顯示數組訪問的 CPU 時間消耗從 11% 減少,這是因為減少了在 PHP 的哈希映射(關聯數組的底層數據結構)中搜索鍵的次數。
第 23 行的 CPU 時間從約 32% 減少到約 15%。這是因為避免了類型轉換的開銷。在優化之前,$temp、$station[0]和$station[1]是字符串類型,因此 PHP 在每次比較時必須將它們轉換為浮點數。
引入 JIT
在優化過程中,我還嘗試啟用了 PHP 的 JIT(即時編譯器),它是 OPCache 的一部分。默認情況下,OPCache 在 CLI(命令行界面)模式下被禁用,因此需通過將opcache.enable_cli 設置為 on來啟用。此外,雖然JIT默認為開啟狀態,但由于緩沖區大小默認設置為0,實際上處于禁用狀態。通過將opcache.jit-buffer-size設置為10M,我有效地啟用了 JIT。
啟用 JIT 后,腳本執行時間驚人地縮減至 7 分鐘 19 秒,速度提升了 45.9%。
進一步優化
通過這系列優化,我將腳本的執行時間從最初的 25 分鐘大幅降低到了約 7 分鐘。在這個過程中,我注意到使用fgets()讀取一個 13GB 的文件時,竟然分配了大約 56GiB 每分鐘的 RAM,這顯然是不合理的。經過調查,我發現省略fgets()的長度參數可以大量減少內存分配:
while ($data = fgets($fp)) {
// 替代之前的
while ($data = fgets($fp, 999)) {
這個簡單變化雖然只使性能提高了約 1%,但將內存分配從每分鐘 56GiB 降至每分鐘 6GiB,顯著減少了內存占用。這一改進雖然對執行時間影響不大,但減少內存消耗對于大規模數據處理仍然是一個重要的優化方向。
以上優化展示了在 PHP 性能調優中考慮各種因素的重要性,包括代碼邏輯優化、類型明確、JIT編譯以及內存管理等,共同作用下可以顯著提升應用性能。
還能更快嗎?
到目前為止,我使用的單線程方法,與許多PHP程序默認的單線程方式相符,但通過使用parallel 擴展,PHP 實際上能在用戶空間內實現多線程操作。
性能分析明確指出,在 PHP 中進行數據讀取成為了性能瓶頸。雖然從 fgetcsv() 切換到 fgets() 并手動進行字符串分割有所改進,但這種方式仍舊相對耗時。因此,我們考慮采用多線程的方式來并行地讀取和處理數據,并在之后將各個工作線程的中間結果合并起來。
<?php
$file = 'measurements.txt';
$threads_cnt = 16;
/**
* 計算并返回每個線程應處理的文件塊的起始和結束位置。
* 這些位置將基于 n 字符進行對齊,因為我們使用 `fgets()` 進行讀取,
* 它會讀取直到遇到 n 字符為止。
*
* @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); // 將文件指針移動到下一個 n 字符
$chunk_end = ftell($fp);
}
$chunks[] = [
$chunk_start,
$chunk_end
];
$chunk_start = $chunk_end;
}
fclose($fp);
return $chunks;
}
/**
* 該函數負責打開指定的 `$file` 文件,并從 `$chunk_start` 開始讀取處理數據,
* 直到達到 `$chunk_end`。
*
* 返回的結果數組以城市名作為鍵,其值為一個數組,包含最低溫度(鍵 0)、最高溫度(鍵 1)、
* 溫度總和(鍵 2)及溫度計數(鍵 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 parallelRuntime();
$futures[$i] = $runtime->run(
$process_chunk,
[
$file,
$chunks[$i][0],
$chunks[$i][1]
]
);
}
$results = [];
for ($i = 0; $i < $threads_cnt; $i++) {
// 等待線程結果,主線程在此處阻塞直至獲取結果
$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 個工作線程,它們分別打開相同的文件并跳轉到分配給它們的塊的起始位置,繼續讀取并處理數據直到塊結束,返回中間結果。最后,在主線程中合并、排序并輸出這些結果。
利用多線程處理,這個過程只需: