← К результатам

Методика измерений

Бенчмарки библиотек легко сделать нечестными, поэтому здесь описано всё, что влияет на цифры. Код сценариев лежит в 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 процесса
пустой PHP0,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.modeoff
opcache.enable_cli0
error_reporting22527
display_errors0
log_errors0
memory_limit2048M (эталон), 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 прогонов с одного адреса в час.