大家好,我是yangyang.又来转译一篇外网一位大佬的博客(https://dev.to/realflowcontrol/processing-one-billion-rows-in-php-3eg0).我看完之后,至心以为:盛行的措辞存在即伟大.

起因

您可能听说过“十亿行寻衅”(1brc),如果您没有听说过,请查看Gunnar Morlings 的 1brc 仓库(https://github.com/gunnarmorling/1brc)。
大佬之以是被吸引,是由于我的同事参加了比赛并进入了排行榜。
PHP 并不以其速率而有名,但昔时夜佬正在研究 PHP 剖析器时,大佬想他该当考试测验一下,看看它能有多快。

第一种稚子的方法

我克隆了存储库并在measurements.txt。
之后,我开始构建我的第一个天真正实现的,可以办理这个寻衅:

php朋友圈PHP挑衅处置10亿行数据 Vue.js

该文本文件包含一系列气候站的温度值。
每一行都是格式为 的一个丈量值<string: station name>;<double: measurement>,丈量值恰好有一个小数位。
以下以十行为例

任务是编写一个 程序,该程序读取文件,打算每个气候站的最低、均匀和最高温度值,并在标准输出上发出结果,如下所示(即按气候站名称的字母顺序排序,每个气候站的结果值在格式<min>/<mean>/<max>,四舍五入到一位小数):

{Abha=-23.0/18.0/59.2, Abidjan=-16.2/26.0/67.3, Abéché=-10.0/29.4/69.0, Accra=-10.1/26.4/66.4, Addis Ababa=-23.7/16.0/67.0, Adelaide=-27.8/17.3/58.5, ...}

数据格式如图

<?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()。
这是由于 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%!

火焰图反响了这一变革,切换到按行显示 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 数组中的键,将其视为用于访问数组中“当前”$station的缓存。

它实际上很有帮助,运行这个只须要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%!

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

第 18 行仍旧显示出 11% 的 CPU 韶光花费,这是对数组的访问(在哈希映命中查找键,哈希映射是 PHP 中用于关联数组的底层数据构造)。

第 23 行的 CPU 韶光从 ~32% 低落到 ~15%。
这是由于 PHP 不再进行类型处理。
在类型转换之前,$temp// were ,因此 PHP 必须将它们转换为以便在每次比较时对它们进行比较$station[0]。
$station[1]stringsfloat

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(),看起来我可以通过省略参数来节省大量len分配fgets():

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

比较变动前后的配置文件可以得出以下结果:

您可能认为这带来了很大的性能提升,但实际上只有大约 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 秒

这便是结局?

不,当然不是。
这个办理方案至少还有两件事:

我在 Apple Silicon 硬件上的 MacOS 上运行此代码,在 PHP 的 ZTS 版本中利用 JIT 时会崩溃,因此 1m 35s 结果是没有 JIT 的,如果我可以利用它,它可能会更快我意识到我正在运行一个 PHP 版本,该版本是CFLAGS="-g -O0 ..."根据我 日常事情的须要而编译的

我该当在一开始就检讨一下这一点,以是我利用重新编译了 PHP 8.3,CLFAGS="-Os ..."我的终极数量(16 个线程)是:

27.7 秒

这个数字绝对无法与您在原始寻衅的排行榜中看到的数字相媲美,这是由于我在完备不同的硬件上运行了这段代码。

这是一个有 10 个线程的韶光线视图:

最底层的线程是主线程,等待事情线程的结果。
一旦这些事情职员返回了中间结果,就可以看到主线程正在对所有内容进行组合和排序。
我们也可以清楚地看到,主线程绝不是瓶颈。
如果您想考试测验进一步优化,请专注于事情线程。

我在路上学到了什么?

每个抽象层只是用可用性/集成来换取 CPU 周期或内存。
fgetcsv()超级随意马虎利用并且隐蔽了很多东西,但是这是有代价的。
乃至fgets()向我们隐蔽了一些东西,但使读取数据变得超级方便。

在代码中添加类型将有助于措辞优化实行或停滞类型杂耍(这是您看不到的东西,但仍旧须要付出 CPU 周期的代价)。

JIT 非常棒,尤其是在处理 CPU 密集型问题时!

这绝对不是大多数 PHP 软件的实质,但由于并行化(利用ext-parallel),我们可以显著降落数字。

结束

借用朋友圈大佬的一句话,不是php的问题,而是写php代码人的问题