В процессе разработки критичных по времени алгоритмов на платформе 1С:Предприятие программисты часто сталкиваются с необходимостью выжать максимум производительности из каждого оператора. Одним из классических вопросов микрооптимизации является замена стандартных ветвлений Если...Тогда...КонецЕсли на лаконичные логические выражения.
В этой статье мы подробно разберем, как работает оптимизация логических условий на уровне платформы 1С, выясним физику процессов «под капотом» виртуальной машины 1С, детально изучим механизм ленивых вычислений и ответим на вопрос, стоит ли превращать многострочный код в однострочные логические цепочки.
Представим сценарий, в котором выполняется обработка большого массива данных в оперативной памяти (без обращения к базе данных). Автор темы предложил заменить привычную конструкцию ветвления на вычисление логического выражения с функцией. Рассмотрим оба варианта кода.
Вариант 1. Классическое ветвление с оператором Если:
Для Каждого Элемент Из Элементы Цикл
// ... какие-то предварительные расчеты
Если Ответ Тогда
ПосчитатьЭлемент(Элемент);
КонецЕсли;
КонецЦикла;
Вариант 2. Логическое выражение с функцией:
Для Каждого Элемент Из Элементы Цикл
// ... какие-то предварительные расчеты
Результат = Ответ И ПосчитатьЭлемент(Элемент);
КонецЦикла;
Автор выдвинул гипотезу, что второй вариант за счет исключения явного ветвления может выполняться быстрее благодаря конвейеризации инструкций на уровне центрального процессора (CPU pipelining) и снижению затрат на предсказание переходов. Давайте разберемся, так ли это на самом деле.
Прежде чем оценивать производительность на уровне процессора, нам необходимо выяснить, являются ли эти две конструкции эквивалентными с точки зрения логики исполнения. Будет ли функция ПосчитатьЭлемент() вызываться всегда во втором варианте, даже если переменная Ответ имеет значение Ложь?
Платформа 1С:Предприятие 8 использует сокращенный (ленивый) цикл вычисления логических выражений. Это означает, что вычисление выражения происходит слева направо, и как только конечный результат становится очевиден, вычисление оставшейся части прекращается:
И: если первый операнд равен Ложь, то результат всего выражения гарантированно будет равен Ложь. В этом случае платформа игнорирует правую часть выражения и вообще не вызывает функцию, стоящую справа.ИЛИ: если первый операнд равен Истина, результат всего выражения гарантированно будет равен Истина, и вычисление правой части также опускается.Давайте убедимся в этом на наглядном практическом примере. Проанализируем тестовый код, который наглядно демонстрирует поведение платформы:
Процедура ВыполнитьТест()
Сообщить("Начало теста");
Индекс = 0;
Пока Индекс < 5 Цикл
// Функция ПоказатьСообщение вызовется только при Индекс = 3
Результат = (Индекс = 3) И ПоказатьСообщение(Индекс);
Сообщить("Итерация " + Индекс + ", Результат выражения: " + Результат);
Индекс = Индекс + 1;
КонецЦикла;
Сообщить("Конец теста");
КонецПроцедуры
Функция ПоказатьСообщение(НомерЦикла)
Сообщить("=== Внутри функции! Итерация номер: " + НомерЦикла);
Возврат Истина;
КонецФункции
Если мы запустим этот код, в окно сообщений будет выведен следующий результат:
Начало теста
Итерация 0, Результат выражения: Ложь
Итерация 1, Результат выражения: Ложь
Итерация 2, Результат выражения: Ложь
=== Внутри функции! Итерация номер: 3
Итерация 3, Результат выражения: Истина
Итерация 4, Результат выражения: Ложь
Конец теста
Этот эксперимент наглядно доказывает, что функция ПоказатьСообщение() была вызвана ровно один раз — когда переменная Индекс стала равна 3, и левая часть логического выражения вернула Истина. Во всех остальных случаях платформа пропустила вызов функции.
Классическим примером практического использования этого свойства является безопасная проверка объектов на равенство Неопределено перед обращением к их реквизитам в рамках одного условия:
Если ТекущиеДанные <> Неопределено И ТекущиеДанные.Количество > 0 Тогда
// Данный код никогда не вызовет ошибку обращения к методу Неопределено,
// так как если ТекущиеДанные равны Неопределено, проверка свойства .Количество не начнется.
КонецЕсли;
Вернемся к вопросу автора о конвейеризации процессора (CPU pipelining). В компилируемых языках программирования высокого уровня (таких как C, C++, Rust) разработчики иногда применяют приемы безветвленного программирования (branchless programming). Они заменяют условные операторы арифметическими или логическими операциями, чтобы центральный процессор не тратил такты на сброс конвейера при неверном предсказании перехода (branch misprediction).
Однако в контексте встроенного языка 1С эта логика абсолютно неприменима по следующим причинам:
В обсуждении участники затронули еще один интересный аспект: «код, записанный в одну строку, выполняется быстрее». Давайте разберем физику этого процесса.
При компиляции исходного текста модуля в байт-код платформа 1С для каждой физической строки кода генерирует специальную служебную инструкцию перехода на новую строку (условный опкод LineNum). Эта инструкция несет в себе информацию о номере строки исходного кода и необходима для:
Рассмотрим, как ведут себя многострочный и однострочный варианты кода при разных режимах работы платформы.
Если к рабочему сеансу подключен отладчик или запущен замер производительности, виртуальная машина при обработке каждого опкода LineNum вынуждена делать паузу, синхронизировать состояние, проверять наличие точек останова (breakpoint) и фиксировать метки времени.
Если внутри тяжелого цикла на миллион итераций у вас написано ветвление на 5-10 строк, платформа выполнит миллиарды проверок для отладчика. Если же весь этот цикл сжать в одну физическую строку, служебный опкод сгенерируется всего один раз на всю сложную конструкцию. В режиме отладки разница в скорости выполнения между многострочным и однострочным кодом может достигать 10–50 раз!
На рабочем сервере с полностью выключенной отладкой накладные расходы на обработку переходов строк значительно ниже. Тем не менее виртуальная машина все равно тратит микросекунды на пропуск или обработку этих системных инструкций. Экспериментальные замеры на «чистых» математических вычислениях в памяти показывают, что однострочный код может выигрывать у многострочного порядка 15–30% по времени исполнения.
Однако стоит ли использовать этот выигрыш на практике? Категорически нет. И вот почему:
Если перед вами стоит задача ускорить критичный по времени участок кода, обрабатывающий данные в памяти, вместо сомнительных синтаксических уловок следует использовать проверенные архитектурные паттерны.
Основная потеря производительности в циклах происходит из-за накладных расходов на вызовы процедур и функций. Вместо того чтобы передавать элементы поочередно в цикле, перепишите логику на групповую обработку.
Передайте в функцию массив или таблицу значений целиком, обработайте данные одним пакетом и верните результат в виде коллекции. Это радикально сократит количество контекстных вызовов внутри платформы.
Создание новых объектов (Новый Структура, Новый Соответствие, Новый Массив) внутри итератора — крайне затратная операция по памяти и процессорному времени. Инициализируйте вспомогательные объекты до начала цикла и очищайте их внутри цикла, избегая повторного выделения памяти.
Если данные изначально хранятся в базе данных, любые расчеты на встроенном языке будут медленнее, чем аналогичные вычисления силами СУБД. Максимально переносите математические расчеты, группировки и условные выборки в язык запросов 1С.
Если вам действительно необходимы сложные математические расчеты, работа с хэш-функциями или обработка больших массивов бинарных данных в реальном времени, напишите внешнюю компоненту на компилируемом языке программирования (C++, Rust или C#). Платформа 1С позволяет бесшовно подключать их, выполняя тяжелые вычисления на уровне «чистого железа» с максимальной скоростью.