譯者 | 劉汪洋
審校 | 重樓
如果在閱讀這篇文章之前,你還不了解“十億行挑戰(zhàn)”( The One Billion Row Challenge,1brc ),我推薦你訪問 Gunnar Morling 的 1brc GitHub 代碼倉庫了解更多詳情。
我有兩位同事已經(jīng)參與這項(xiàng)挑戰(zhàn)并成功上榜,因此我也選擇加入。
雖然 PHP 的執(zhí)行速度并不出名,但我正開發(fā)一個(gè) PHP 分析器,因此我想親自測試一下 PHP的處理速度。
我首先克隆了挑戰(zhàn)的代碼倉庫,并生成了一個(gè)包含十億行數(shù)據(jù)的文件measurements.txt。接下來,我開始嘗試第一個(gè)解決方案:
<?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()讀取數(shù)據(jù)。若之前未記錄過該站點(diǎn),則創(chuàng)建一個(gè)新條目;否則,進(jìn)行計(jì)數(shù)器增加、溫度累加,并檢查當(dāng)前溫度是否刷新了最低或最高記錄,如是,則進(jìn)行更新。
處理完所有數(shù)據(jù)后,我使用ksort()對(duì)數(shù)組$stations進(jìn)行排序,并輸出每個(gè)站點(diǎn)的最低溫度、平均溫度(總溫度/記錄數(shù))和最高溫度。
令我驚訝的是,在我的筆記本電腦上運(yùn)行這段簡單腳本竟然耗時(shí)達(dá)到了25分鐘。
很明顯,我需要對(duì)這段代碼進(jìn)行優(yōu)化,并對(duì)其進(jìn)行性能分析:
通過可視化的時(shí)間線,我們可以分析出腳本運(yùn)行明顯受到 CPU 限制,腳本開始時(shí)的文件編譯時(shí)間可以忽略不計(jì),且?guī)缀鯖]有垃圾收集事件發(fā)生。
火焰圖清晰地顯示出,fgetcsv()函數(shù)占據(jù)了約 46% 的 CPU 時(shí)間。
為了提升性能,我決定用fgets()替換fgetcsv()函數(shù)來逐行讀取數(shù)據(jù),并手動(dòng)按;字符進(jìn)行分割。
// ...while ($data = fgets($fp, 999)) { $pos = strpos($data, ';'); $city = substr($data, 0, $pos); $temp = substr($data, $pos + 1, -1);// ...
同時(shí),我還把代碼中的$data[0]重命名為$city,$data[1]重命名為$temp,以增強(qiáng)代碼的可讀性。
這個(gè)簡單的修改使得腳本運(yùn)行時(shí)間大幅減少到 19 分鐘 49 秒,雖然時(shí)間仍然較長,但相比之前已經(jīng)減少了 21%。
通過火焰圖的比較,可以看到在替換后 CPU 的時(shí)間利用率發(fā)生了變化,詳細(xì)的根幀分析也揭示了具體的性能瓶頸位置:
在腳本的第 18 行和第 23 行花費(fèi)了大約 38% 的CPU時(shí)間。
18 | $stations[$city][3]++; | // ...23 | if ($temp > $stations[$city][1]) {
第 18 行是數(shù)組$stations的首次訪問和增量操作,而第 23 行進(jìn)行了一次看似不那么耗時(shí)的比較操作。盡管如此,進(jìn)一步優(yōu)化有助于揭示這些操作中潛在的性能開銷。
為了提高性能,我決定在處理數(shù)組時(shí)使用引用,以避免每次訪問數(shù)組時(shí)都對(duì)$stations數(shù)組中的鍵進(jìn)行搜索。這相當(dāng)于為數(shù)組中的"當(dāng)前"站點(diǎn)設(shè)置了一個(gè)緩存。
代碼如下:
$station = &$stations[$city];$station[3]++;$station[2] += $temp;// 替代原有的$stations[$city][3]++;$stations[$city][2] += $temp;
這一改變實(shí)際上大大減少了執(zhí)行時(shí)間,將其縮短到 17 分鐘 48 秒,進(jìn)一步減少了 **10% **的運(yùn)行時(shí)間。
在審查代碼的過程中,我注意到了以下片段:
if ($temp < $station[0]) { $station[0] = $temp;} elseif ($temp > $station[1]) { $station[1] = $temp;}
考慮到一個(gè)溫度值如果低于最小值,則不可能同時(shí)高于最大值,因此我使用elseif來優(yōu)化條件判斷,這可能會(huì)節(jié)省一些 CPU 周期。
需要指出的是,由于我不知道m(xù)easurements.txt中溫度值的排列順序,根據(jù)這個(gè)順序,首先檢查最小值還是最大值可能會(huì)有所不同。
這次優(yōu)化將時(shí)間進(jìn)一步縮短到 17 分鐘 30 秒,節(jié)省了大約 2% 的時(shí)間,雖然這個(gè)提升并不是非常顯著。
PHP是一種動(dòng)態(tài)類型語言,我在編程初期非常欣賞它這一特點(diǎn),因?yàn)樗喕嗽S多問題。然而,另一方面,明確變量類型能幫助解釋引擎更高效地執(zhí)行代碼。
$temp = (float)substr($data, $pos + 1, -1);
令人驚訝的是,這個(gè)簡單的類型轉(zhuǎn)換把腳本執(zhí)行時(shí)間縮短至 13 分鐘 32 秒,性能提升達(dá)到了驚人的 **21% **!
18 | $station = &$stations[$city]; | // ...23 | } elseif ($temp > $station[1]) {
在優(yōu)化后,第 18 行顯示數(shù)組訪問的 CPU 時(shí)間消耗從 11% 減少,這是因?yàn)闇p少了在 PHP 的哈希映射(關(guān)聯(lián)數(shù)組的底層數(shù)據(jù)結(jié)構(gòu))中搜索鍵的次數(shù)。
第 23 行的 CPU 時(shí)間從約 32% 減少到約 15%。這是因?yàn)楸苊饬祟愋娃D(zhuǎn)換的開銷。在優(yōu)化之前,$temp、$station[0]和$station[1]是字符串類型,因此 PHP 在每次比較時(shí)必須將它們轉(zhuǎn)換為浮點(diǎn)數(shù)。
在優(yōu)化過程中,我還嘗試啟用了 PHP 的 JIT(即時(shí)編譯器),它是 OPCache 的一部分。默認(rèn)情況下,OPCache 在 CLI(命令行界面)模式下被禁用,因此需通過將opcache.enable_cli 設(shè)置為 on來啟用。此外,雖然JIT默認(rèn)為開啟狀態(tài),但由于緩沖區(qū)大小默認(rèn)設(shè)置為0,實(shí)際上處于禁用狀態(tài)。通過將opcache.jit-buffer-size設(shè)置為10M,我有效地啟用了 JIT。
啟用 JIT 后,腳本執(zhí)行時(shí)間驚人地縮減至 7 分鐘 19 秒,速度提升了 45.9%。
通過這系列優(yōu)化,我將腳本的執(zhí)行時(shí)間從最初的 25 分鐘大幅降低到了約 7 分鐘。在這個(gè)過程中,我注意到使用fgets()讀取一個(gè) 13GB 的文件時(shí),竟然分配了大約 56GiB 每分鐘的 RAM,這顯然是不合理的。經(jīng)過調(diào)查,我發(fā)現(xiàn)省略fgets()的長度參數(shù)可以大量減少內(nèi)存分配:
while ($data = fgets($fp)) {// 替代之前的while ($data = fgets($fp, 999)) {
這個(gè)簡單變化雖然只使性能提高了約 1%,但將內(nèi)存分配從每分鐘 56GiB 降至每分鐘 6GiB,顯著減少了內(nèi)存占用。這一改進(jìn)雖然對(duì)執(zhí)行時(shí)間影響不大,但減少內(nèi)存消耗對(duì)于大規(guī)模數(shù)據(jù)處理仍然是一個(gè)重要的優(yōu)化方向。
以上優(yōu)化展示了在 PHP 性能調(diào)優(yōu)中考慮各種因素的重要性,包括代碼邏輯優(yōu)化、類型明確、JIT編譯以及內(nèi)存管理等,共同作用下可以顯著提升應(yīng)用性能。
到目前為止,我使用的單線程方法,與許多PHP程序默認(rèn)的單線程方式相符,但通過使用parallel 擴(kuò)展,PHP 實(shí)際上能在用戶空間內(nèi)實(shí)現(xiàn)多線程操作。
性能分析明確指出,在 PHP 中進(jìn)行數(shù)據(jù)讀取成為了性能瓶頸。雖然從 fgetcsv() 切換到 fgets() 并手動(dòng)進(jìn)行字符串分割有所改進(jìn),但這種方式仍舊相對(duì)耗時(shí)。因此,我們考慮采用多線程的方式來并行地讀取和處理數(shù)據(jù),并在之后將各個(gè)工作線程的中間結(jié)果合并起來。
<?php$file = 'measurements.txt';$threads_cnt = 16;/** * 計(jì)算并返回每個(gè)線程應(yīng)處理的文件塊的起始和結(jié)束位置。 * 這些位置將基于 /n 字符進(jìn)行對(duì)齊,因?yàn)槲覀兪褂?`fgets()` 進(jìn)行讀取, * 它會(huì)讀取直到遇到 /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); // 將文件指針移動(dòng)到下一個(gè) /n 字符 $chunk_end = ftell($fp); } $chunks[] = [ $chunk_start, $chunk_end ]; $chunk_start = $chunk_end; } fclose($fp); return $chunks;}/** * 該函數(shù)負(fù)責(zé)打開指定的 `$file` 文件,并從 `$chunk_start` 開始讀取處理數(shù)據(jù), * 直到達(dá)到 `$chunk_end`。 * * 返回的結(jié)果數(shù)組以城市名作為鍵,其值為一個(gè)數(shù)組,包含最低溫度(鍵 0)、最高溫度(鍵 1)、 * 溫度總和(鍵 2)及溫度計(jì)數(shù)(鍵 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++) { // 等待線程結(jié)果,主線程在此處阻塞直至獲取結(jié)果 $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;
該段代碼主要執(zhí)行以下操作:首先,它掃描文件并將其分割成以 /n 為界的塊(利用 fgets() 進(jìn)行讀取)。準(zhǔn)備好這些塊后,我啟動(dòng)了 $threads_cnt 個(gè)工作線程,它們分別打開相同的文件并跳轉(zhuǎn)到分配給它們的塊的起始位置,繼續(xù)讀取并處理數(shù)據(jù)直到塊結(jié)束,返回中間結(jié)果。最后,在主線程中合并、排序并輸出這些結(jié)果。
利用多線程處理,這個(gè)過程只需:
本文鏈接:http://www.tebozhan.com/showinfo-26-84720-0.html使用 PHP 處理十億行數(shù)據(jù),如何極致提升處理速度?
聲明:本網(wǎng)頁內(nèi)容旨在傳播知識(shí),若有侵權(quán)等問題請(qǐng)及時(shí)與本網(wǎng)聯(lián)系,我們將在第一時(shí)間刪除處理。郵件:2376512515@qq.com
上一篇: 如何編寫可讀性高的 C/C++代碼?
下一篇: 探秘Python神器:eli5模塊如何解讀機(jī)器學(xué)習(xí)模型的預(yù)測結(jié)果?