Методика измерений
Бенчмарки библиотек легко сделать нечестными, поэтому здесь описано всё, что влияет на цифры.
Код сценариев лежит в src/Bench/Adapter — его можно прочитать целиком за пять минут.
Каждый замер — отдельный процесс PHP
И пик кучи PHP, и пик RSS — величины на весь процесс. Если мерить несколько
библиотек подряд в одном процессе, вторая унаследует пик первой, а её классы уже будут
загружены автолоадером — то есть она получит незаслуженную фору. Поэтому каждая пара
«библиотека + сценарий» запускается в новом процессе через bin/worker.php,
который печатает единственный JSON с результатом.
Внутри воркера порядок действий одинаков для всех: прогрев файла чтением с диска
(чтобы первый замер не платил за холодный кэш ОС), gc_collect_cycles(),
memory_reset_peak_usage(), снятие базового уровня RSS, затем
hrtime() вокруг вызова адаптера.
В измеряемое время попадает и подгрузка классов самой библиотеки — это честно, в реальном
приложении за неё тоже платят.
Память меряется по RSS, а не по счётчику PHP
Это самое важное отличие от большинства PHP-бенчмарков, и без него сравнение получается
неверным. memory_get_peak_usage() считает только то, что выделено через
внутренний аллокатор PHP (emalloc). Но libxml — а значит
SimpleXMLElement, DOMDocument и XMLReader::expand() —
работает своим malloc'ом. Мимо счётчика и мимо memory_limit.
Насколько велика разница, видно на контрольном замере: лист на 12 МБ XML, три состояния одного и того же процесса.
| Состояние процесса | memory_get_peak_usage() | RSS процесса |
|---|---|---|
| пустой PHP | 0,4 МБ | 28 МБ |
| XML прочитан в строку | 14,4 МБ | 40 МБ |
+ simplexml_load_string() этой строки | 14,4 МБ | 354 МБ |
Счётчик PHP не сдвинулся ни на байт, а процесс вырос на 313 МБ. Библиотека, которая держит лист в DOM, по этому счётчику выглядит в разы легче, чем она есть — и в таблице результатов незаслуженно обходит потоковые. Поэтому основная метрика здесь — прирост резидентной памяти процесса (RSS) за время замера: пик RSS после вычета базового уровня, набранного интерпретатором, автолоадером и прогревом файла (около 30 МБ, одинаково для всех библиотек). Пик по процессу целиком и старый счётчик кучи остались в подробной таблице — расхождение между колонками и есть цена DOM.
Источник цифры зависит от платформы: на Linux это VmHWM из
/proc/self/status, иначе — getrusage()['ru_maxrss']. На этом стенде:
RSS процесса (/proc/self/status, VmHWM).
Если платформа пик RSS не отдаёт, бенчмарк откатывается на memory_get_peak_usage()
и пишет об этом прямо под графиком — молча занижать цифры он не будет.
Из того же факта следует практическое: memory_limit не защищает от библиотеки,
которая строит DOM листа. Процесс не упадёт с «Allowed memory size» — он просто съест
оперативную память сервера и попадёт под OOM-killer. Такой результат в отчёте выглядит как
«процесс упал», а не «не хватило памяти».
Чего RSS не покрывает: OpenSpout выносит словарь sharedStrings во временные файлы
на диск. По памяти он от этого выигрывает по-настоящему, но часть нагрузки переезжает на диск,
и в колонке памяти этого не видно.
Xdebug и OPcache выключены
Воркер запускается с параметрами:
| Директива | Значение |
|---|---|
xdebug.mode | off |
opcache.enable_cli | 0 |
error_reporting | 22527 |
display_errors | 0 |
log_errors | 0 |
memory_limit | 2048M (эталон),
1024M (загруженные файлы) |
Xdebug замедляет PHP в несколько раз и делает сравнение бессмысленным. OPcache выключен намеренно: каждый воркер — новый процесс, кэш опкодов ему всё равно не поможет, но зато в измеряемое время попадает реальная стоимость компиляции классов библиотеки. Библиотека из двух файлов и библиотека из четырёхсот здесь не равны — и это часть картины.
Повторы и медиана
Эталонный прогон — 5 повторов на замер, пользовательский — 3. Показывается медиана; минимум и максимум видны в подробной таблице под каждым набором. Замеры длиннее полутора секунд повторяются меньше раз: разброс на таких длительностях мал, а суммарное время прогона растёт линейно. Если библиотека не уложилась в 60 с или в лимит памяти, это попадает в отчёт как «таймаут» или «не хватило памяти» — такой результат тоже результат.
Три сценария
Чтение всех строк (read_all)
Основной сценарий: пройти по всем строкам первого листа и получить значения ячеек. Значения не накапливаются в памяти — там, где библиотека умеет отдавать строки потоком, используется поток.
Открытие + первая строка (first_row)
Сколько стоит просто открыть файл и получить первую строку. Показывает, обязана ли библиотека разобрать весь документ до того, как отдаст первые данные.
Весь лист в массив (to_array)
Весь лист загружается в один PHP-массив. Самый частый способ использования и самый тяжёлый по памяти.
Что именно вызывается
Для каждой библиотеки берётся самый экономичный штатный способ решить задачу, а не первый попавшийся из README. Никаких «удобных» обёрток, замедляющих одну из сторон:
| Библиотека | Версия | Как читается |
|---|---|---|
| FastExcelReader | v3.2.0 |
Потоковый XMLReader поверх ZIP; строки отдаются генератором nextRow(). |
| OpenSpout | v5.8.0 |
Наследник box/spout. Потоковые итераторы; shared strings кэшируются на диск при нехватке памяти. |
| PhpSpreadsheet | 5.9.0 |
Строит полную объектную модель книги. Замеряется в режиме setReadDataOnly(true) — без стилей и формул. |
| SimpleXLSX | 1.1.16 |
Один класс без зависимостей. readRows() — генератор, но XML листа целиком лежит в SimpleXMLElement. |
В частности, PhpSpreadsheet работает в режиме setReadDataOnly(true) и
setReadEmptyCells(false), а для сценария «первая строка» получает
IReadFilter, ограничивающий чтение первой строкой — то есть меряется
в своём лучшем, а не в самом наивном режиме.
Проверка, что все прочитали одно и то же
Каждый адаптер возвращает количество прочитанных строк, количество непустых ячеек и суммарную длину строковых представлений значений. Эти числа видны в подробной таблице: если библиотека «выиграла», прочитав вдвое меньше ячеек, это сразу заметно. На сгенерированных наборах все четыре библиотеки дают одинаковые счётчики.
Полного побайтового совпадения значений добиться нельзя: библиотеки по-разному приводят даты
(объект DateTime, строка, число Excel) и по-разному округляют дробные значения.
Поэтому сверяются счётчики, а не сами значения.
Наборы данных
Файлы генерируются нейтральной библиотекой (OpenSpout Writer), а не одной из сравниваемых по
чтению — чтобы структура файла не была подогнана под конкретный парсер. Данные детерминированы
(фиксированный seed), набор воспроизводится командой php bin/generate-fixtures.php.
| Набор | Строк | Колонок | Особенность | |
|---|---|---|---|---|
small-1k |
1 000 | 12 | Типичная выгрузка из админки: смешанные типы, немного данных. | скачать |
medium-20k |
20 000 | 15 | Рабочий объём для импорта заказов или товаров. | скачать |
large-100k |
100 000 | 10 | Миллион ячеек. Здесь разница между потоковым и объектным чтением становится решающей. | скачать |
wide-2k-x-150 |
2 000 | 150 | Мало строк, но очень много колонок — нагрузка на разбор адресов ячеек. | скачать |
strings-40k-shared |
40 000 | 8 | Повторяющийся текст вынесен в sharedStrings.xml, как это делает Excel. | скачать |
strings-40k-inline |
40 000 | 8 | Тот же контент без словаря строк — так пишут многие генераторы отчётов. | скачать |
numeric-40k |
40 000 | 10 | Почти нет строк: проверяем стоимость конвертации чисел и дат. | скачать |
Чего этот бенчмарк не показывает
- Запись файлов — сравниваются только читатели.
- Работу со стилями, формулами, изображениями, диаграммами и объединёнными ячейками. PhpSpreadsheet умеет несопоставимо больше остальных, и в задачах, где нужна полная модель документа, у него просто нет конкурентов в этом списке.
- Форматы, кроме XLSX/XLSM: CSV, ODS и старый XLS сюда не входят.
- Поведение под нагрузкой и в конкурентном режиме — все замеры последовательные.
Абсолютные числа зависят от процессора, диска и версии PHP. Осмысленно сравнивать библиотеки между собой в рамках одного прогона, а не значения с разных стендов.
Про загруженные файлы
Файл сохраняется вне веб-корня, обрабатывается фоновым процессом и удаляется сразу после завершения прогона. Содержимое ячеек нигде не выводится и не сохраняется: в отчёт попадают только размеры, счётчики и тайминги. Отчёты хранятся 24 ч, затем удаляются. Ограничения: до 25,0 МБ на файл и 10 прогонов с одного адреса в час.