Можно ли написать программу на PL/I и получить скорость выполнения, сравнимую со скоростью аналогичной программы на C или быстрее? Именно это мы попытались реализовать в нашем компиляторе pli-llvm.
Мы сравнивали 21 небольшую программу - от сортировки и математических вычислений до работы со строками и файлами. Для каждой задачи были реализации на PL/I и C. Итоговая оценка была посчитана как геометрическое среднее отношений времени выполнения PL/I к C на -O2: 1,0x означает паритет, меньше 1,0x - PL/I выполнился быстрее. Результат на свежих сборках и медиане семи запусков - 0,8x на -O2. До начала этой работы компилятор уже примерно сравнялся с C: результат был около 0,9x. Задача состояла в том, чтобы пройти путь от паритета к устойчивому преимуществу - и выяснить, за счёт чего оно достигается.
Почему компилятор PL/I может быть быстрее C
Оптимизатору проще работать, когда он знает больше о программе: какие значения может принимать переменная, меняется ли счётчик цикла и могут ли две области памяти пересекаться. PL/I в ряде случаев задаёт такие правила явно. Но сами по себе возможности языка ускорения не гарантируют: компилятор должен сохранить эту информацию и передать её LLVM в безопасной форме.
Например, обычные локальные переменные не перекрываются, если программа не декларирует особые способы доступа, такие как base или defined. Это позволяет компилятору понять, что запись в одну переменную не меняет другую. В C при работе с указателями компилятор, наоборот, по-умолчанию должен учитывать возможность пересечения памяти, если программист явно не заявляет о непересечении через restrict. Другой пример - цикл do. В PL/I управляющую переменную такого цикла нельзя менять из его тела, что даёт оптимизатору более надёжное описание цикла.
К другим особенностям языка PL/I, позволяющим проводить улучшенную оптимизацию, относятся явное задание размерностей строк и числовых переменных, а также - использование встроенных функций, вызовы которых компилятор может оптимизировать, исходя из семантики и контекста.
Результаты: где удалось выиграть
В таблице приведены значения относительного времени прогона бенчмарков на -O2 для шести целевых тестов - результат менее единицы означает, что программа на PL/I выполнялась быстрее C.
Бенчмарк | До | После |
Строковые операции (string_ops.pli) | 6,8x | 0,3x |
Вызовы процедур (proc_calls) | 2,8x | 1,0x |
Стек с динамическим выделением памяти | 1,9x | 0,9x |
Вывод (io_throughput.pli) | 1,5x | 0,8x |
Десятичная арифметика (bcd_decimal.pli) | 2,2x | 2,2x |
Обработчики условий (on_conditions.pli) | 1,5x | 1,5x |
Среднее для этой выборки из шести тестов составило 0,9x на -O2 и 0,8x - на -O3. По всем 21 бенчмаркам результат составил 0,8x при оптимизации -O2. Исходный код бенчмарков можно найти в папке benchmarks/.
Не каждый тест стал быстрее. Например, десятичные бенчмарки сравнивают разные вычисления: PL/I сохраняет точность фиксированной десятичной арифметики, а версия на C использует double. Поэтому в некоторых таких тестах разница отражает не только качество компиляции, но и различие в семантике и объёме работы.
Что изменили в компиляторе
Меньше лишних переходов в runtime
Вызов runtime-функции в горячем цикле - цикле, который выполняется много раз, - может добавлять заметные накладные расходы. Для некоторых случаев операции mod (нахождения остатка от деления) компилятор теперь генерирует короткую последовательность LLVM IR вместо более общего пути: например, srem, а при делителе-степени двойки и доказанном неотрицательном значении - операцию and. Также была исправлена форма генерируемых циклов и вычислений проверок границ. Если проверка отключена, компилятор теперь старается убрать не только переход, но и ненужную арифметику для этой проверки.
Сведения о PL/I для LLVM
Для обычных локальных переменных компилятор передаёт LLVM информацию о непересечении памяти, а для индексов и диапазонов - соответствующие ограничения. Это помогает оптимизировать загрузки и записи, а также распознавать возможности для векторизации.
Здесь важна осторожность: такие утверждения корректны не для всех переменных и не при любых проверках времени выполнения. Например, для указателей и переменных, использующих общую области памяти (объявленных как defined), компилятор не должен делать предположение о непересечении. Были добавлены тесты не только на случаи, когда оптимизация должна сработать, но и на случаи, где её применять нельзя.
Передача параметров по значению
Параметр, передаваемый по ссылке, потенциально может измениться через указатель. Из-за этого оптимизатору сложнее рассуждать о его значении. Для встроенных функций компилятор теперь может передавать скалярный параметр по значению, если анализ подтверждает, что функции не нужны его адрес и запись через ссылку. Это улучшило отношение с 2,7x до 1,0x на -O2. Неопределённые случаи по-прежнему используют передачу по ссылке: быстрый путь включается только тогда, когда компилятор может доказать, что он безопасен.
Конкатенация строк без постоянных копий
При наивной конкатенации в цикле на каждой итерации нужно выделять новый буфер и заново копировать всю уже накопленную строку. Чем длиннее строка, тем больше лишней работы.
Для подходящих случаев result = result || temp компилятор теперь добавляет новый фрагмент в конец существующего буфера. Если доказать безопасность такого пути нельзя, остаётся общий вариант. В отдельном тесте для переменной конкатенации отношение улучшилось с 7,3x до 0,2x; в итоговой таблице для бенчмарка операций над строками оно составляет 0,3x.
Отдельно была исправлена утечка временных буферов, создававшихся при общей конкатенации: для одного теста потребление физической памяти процесса на -O0 снизилось примерно со 109 МБ до 1 МБ. На -O2 скорость выполнения при этом не изменилась.
SIMD и поиск подстроки
В одном отдельном тесте поиск подстроки с постоянным образцом ускорился примерно в 14 раз относительно C-версии с strstr. Наша реализация использует поиск первого байта и проверку совпадения, на ARM64 также применяется обработка блоками с NEON.
Это результат конкретного сценария: первый символ искомой строки отсутствует в повторяющемся буфере, поэтому поиск быстро проходит его. Его не следует воспринимать как универсальное ускорение любого поиска подстроки. Тест меняет содержимое буфера внутри цикла, чтобы измерять именно поиск, а не только способность оптимизатора вынести неизменную операцию за пределы цикла.
Что осталось медленнее
Условие «ни один бенчмарк не медленнее C более чем на 15%» выполнить пока не удалось. Среди оставшихся отстающих тестов:
десятичная арифметика (bcd_decimal.pli и decimal_ops.pli): PL/I реализует точную десятичную арифметику, C - приближенный результат типа double
ввод и вывод (io_throughput.pli и file_io.pli): ограничены накладными расходами форматирования и реализацией runtime-а
сигналы (on_conditions.pli) зависят от реализации обработчиков условий
алгоритм Мандельброта (mandelbrot.pli) чувствителен к шуму измерений и содержит цикл с ранним выходом, который трудно векторизовать.
Как понять, что оптимизация действительно помогла
Многие полезные изменения начинались с инспектирования сгенерированного LLVM IR, по которому видно, что горячий цикл вызывает runtime-функцию, выполняет ненужную арифметику проверки или не распознаётся LLVM как подходящий для векторизации.
Длина строк переменной длины, масштаб целочисленных данных, поиск подстроки, локальность переменных и правила динамического выделения памяти могут помочь выбрать более эффективное представление - если эта информация доходит до оптимизатора. Изменения в атрибутах функций и инлайнинг заметно влияют на отдельные бенчмарки.
Особенности методики измерений - такие, как повторные запуски, прогрев, проверка вывода, а также метаданные компилятора помогают отделить реальные изменения от шума.
Воспроизвести результаты самостоятельно можно с помощью команды:
cmake --build build/cmake -j && python3 benchmarks/run_benchmarks.py

