Однажды я готовился к Ludum Dare и сделал простую игру, где использовал пиксельные шейдеры (других в движок Phaser не завезли).  Шейдеры — это программы на си-подобном языке GLSL, которые выполняются на видеокарте. Есть два вида шейдеров, в этой статье речь идет про пиксельные (они же “фрагментные”, fragment shaders), которые очень грубо можно представить в таком виде: Т.е. шейдер выполняется для каждого пикселя выводимого изображения, определяя или уточняя его цвет.
Вводную можно почитать на другой статье на хабре — https://habr.com/post/333002/  Потестировав, кинул ссылку другу, и получил от него вот такой скриншот с вопросом "а это нормально?" Нет, это было ненормально. Посмотрев внимательно код шейдера, я обнаружил ошибку в вычислениях: Т.к. константа R1 была меньше чем M, то в некоторых случаях в первом аргументе pow получалось число меньше нуля. Квадратный корень из отрицательного числа — штука загадочная, по крайней мере для стандарта GLSL. Мою видеокарту ничего не смутило, и она как-то выпуталась из этого положения (похоже, вернув из pow 0), а вот у друга она оказалась более разборчивой. И тут я задумался: а могу ли я избежать таких проблем в будущем? От ошибок никто не застрахован, особенно таких, которые не воспроизводятся локально. Юнит-тесты на GLSL не напишешь. В то же время преобразования внутри шейдера довольно простые — умножения, деления, синусы, косинусы… Неужели нельзя отследить значения каждой переменной и убедиться, что ни при каких условиях не происходит выхода за допустимые границы значений? Так я решил попробовать сделать статический анализ для GLSL. Что из этого получилось — можно прочитать под катом.  Сразу предупрежу: какого-то законченного продукта получить не удалось, только учебный прототип. Немного проштудировав существующие статьи на эту тему (и попутно выяснив, что тема называется Value Range Analysis), я порадовался тому, что у меня именно GLSL, а не какой-то другой язык. Посудите сами: В общем, с учетом ограничений GLSL задача выглядит решаемой. Основной алгоритм сводится к следующему: Здесь у меня был долгий и мучительный выбор. С одной стороны мой основной скоуп — это проверка WebGL-ных шейдеров, поэтому почему бы и не javascript — чтобы запускать все в браузере при разработке. С другой стороны, я давно планирую слезть с Phaser-а и попробовать другой движок типа Unity или LibGDX. Там тоже будут шейдеры, а вот javascript-а уже не будет. А с третьей стороны задача делалась в первую очередь для развлечения. А лучшее на свете развлечение — это зоопарк. Поэтому: Все, теперь можно ехать. Анализатор держит в памяти список переменных, которые встречаются в шейдере, и для каждого хранит текущий возможный диапазон значений (типа  или [1,∞) ). Анализатор принимает на вход поток операций типа такой: Тут у нас вызывается функция sin, на вход ей подаются переменные с id = 3 и 4, а результат записывается в переменные 1 и 2. Этот вызов соответствует GLSL-ному: Обратите внимание на пустые аргументы (помечены как "-"). В GLSL почти все встроенные функции перегружены для разных наборов входных типов, т.е. существуют sin(float), sin(vec2), sin(vec3), sin(vec4). Для удобства я привожу все перегруженные версии к одному виду — в данном случае sin(vec4). На выход анализатор выдает список изменений для каждой переменной, вроде Что означает "переменная 2 в строке 10 в ветке 1 имеет диапазон от -1 до 1 включительно" (что такое ветка (branch) мы поговорим чуть позже). Теперь можно красиво подсвечивать диапазоны значений в исходном коде. Когда AST-дерево уже начало худо-бедно превращаться в список команд, настала пора реализовывать стандартные функции и методы. Их довольно много (и еще у них до кучи перегрузок, как я писал выше), но в целом у них предсказуемые преобразования диапазонов. Скажем, вот для такого примера все довольно очевидно получается: Красный канал выходного цвета у нас в допустимом диапазоне, ошибок нет. Если покрыть побольше встроенных функций, то для половины шейдеров и такого анализа хватит. Но что насчет второй половины — с условиями, циклами и функциями? Возьмем для примера такой шейдер. Переменная a у нас берется из текстуры, и поэтому значение этой переменной лежит от 0 до 1. А вот какие значения может принимать k? Можно пойти по простому пути и "обьединить ветки" — посчитать диапазон в каждом из случаев и выдать общее. Для ветки if получаем k = [0,2], а для ветки else k = [0,1]. Если объединить, получается , и надо выдавать ошибку, т.к. в выходной цвет gl_FragColor попадают значения больше 1. Однако это явный false alarm, а для статического анализатора нет ничего хуже ложных срабатываний — если его не отключат после первого крика "волки", то после десятого точно. Значит, нам нужно обрабатывать обе ветки порознь, причем в обеих ветках мы должны уточнить диапазон переменной a (хотя формально его никто не менял). Вот как это может выглядеть: Ветка 1: Ветка 2: Таким образом, когда анализатор встречает некое условие, которое по-разному себя вести в зависимости от диапазона, то он создает ветки (branches — бранчи) для каждого из случаев. В каждом случае он уточняет диапазон исходной переменной и движется дальше по списку команд.  Стоит уточнить, что ветки в данном случае не связаны с конструкцией if-else. Ветки создаются при разбиении диапазона какой-то переменной на под-диапазоны, и причиной может стать необязательно условный оператор. Например, функция step тоже создает ветки. Следующий GLSL-шейдер делает то же самое, что предыдущий, только не использует ветвление (что, кстати, лучше в плане производительности). Функция step должна вернуть 0 если a < 0.5 и 1 в противном случае. Поэтому здесь тоже будут созданы ветки — аналогичные предыдущему примеру. Рассмотрим чуть видоизмененный предыдущий пример: Здесь нюанс в следующем: ветвление происходит по переменной b, а вычисления происходят с переменной a. То есть внутри каждой ветки будет корректное значение диапазона b, но совершенно ненужное, и оригинальное значение диапазона a, совершенно некорректное. Однако анализатор видел, что диапазон b был получен вычислением из a. Если запомнить эту информацию, то при ветвлении анализатор может пройтись по всем исходным переменным и уточнить их диапазон, выполнив обратное вычисление. В GLSL нет виртуальных методов, указателей на функции и даже рекурсивных вызовов, так что каждый вызов функции однозначен. Поэтому проще всего вставить тело функции в месте вызова (заинлайнить, иначе говоря). Это будет полностью соответствовать последовательности выполнения команд. С циклами все посложнее, т.к. формально GLSL полностью поддерживает си-подобный цикл for. Однако чаще всего циклы используются в простейшем варианте, вот таком: Такие циклы несложно "развернуть", т.е. вставить тело цикла 12 раз друг за другом. В итоге, подумав, я решил пока что поддержать только такой вариант. Плюсом этого подхода является то, что команды можно выдавать потоком в анализатор, не требуя от него запоминания каких-то фрагментов (типа тел функций или циклов) для дальнейшего переиспользования. Выше мы рассматривали случаи, когда при уточнения значения по одной переменной мы делали выводы о значениях другой переменной. И эта задача решаема, когда участвуют операции типа сложения/вычитания. Но, скажем, как быть с тригонометрией? Например такое условие: Как посчитать диапазон a внутри if? Получается бесконечный набор диапазонов с шагом пи, с которым потом работать будет очень неудобно. А еще может быть такая ситуация: Уточнить диапазоны a и b в общем случае будет нереально. А, значит, возможны ложные срабатывания. Рассмотрим такой пример: Для начала анализатор считает диапазон переменной val2 — и он ожидаемо оказывается  Однако затем, считая value - val2, анализатор не учитывает, что val2 был получен из value, и работает с диапазонами так, будто бы они независимы друг от друга. Получает , и рапортует об ошибке. Хотя в реальности он должен был получить константу 1. Возможное решение: хранить для каждой переменную не просто историю диапазонов, но и всю "родословную" — от каких переменных была зависимость, какие операции, и так далее. Другое дело, что "разворачивать" эту родословную будет делом непростым. Вот пример: Здесь анализатор предположит, что диапазон k = [-1,1] + [-1,1] = [-2,2]. Что неверно, т.к. sin(a) + cos(a) для любых a лежит в диапазоне .  Результат вычисления sin(a) формально никак не зависит от результата вычисления cos(a). Однако они зависят от одного и того же диапазона a.  Как оказалось, сделать value range analysis даже для такого простого и узкоспециализированного языка, как GLSL — задача непростая. Покрытие фич языка еще можно усилить: поддержка массивов, матриц и всех встроенных операций — задача чисто техническая, просто требующая затрат по времени. А вот как решать ситуации с зависимостями между переменными — вопрос пока для меня неясный. Без решения этих проблем неизбежны ложные срабатывания, шум от которых может в итоге перевесить пользу от статического анализа. С учетом того, с чем я столкнулся, я не особо удивлен отсутствию каких-то известных инструментов для value range analysis в других языках — в них проблем явно больше, чем в относительно простом GLSL. При этом на других языках можно хотя бы юнит-тесты писать, а тут — никак. Альтернативным решением может стать компиляция из других языков в GLSL — вот тут недавно была статья про компиляцию из kotlin. Тогда можно писать юнит-тесты на исходный код и покрывать все граничные условия. Или сделать “динамический анализатор”, который будет прогонять те же данные, что идут в шейдер, через оригинальный код на kotlin и предупреждать о возможных проблемах. Так что на этом этапе я остановился. Библиотеки увы не получилось, но может быть кому-то этот прототип пригодится. Репозиторий на github, для ознакомления:  Попробовать:  Изначально я делал анализатор без использования stdlib — по старинке, с массивами и указателями. На тот момент я сильно переживал за размер выходного wasm-файла, хотелось чтобы он был маленьким. Но начиная с некоторого момента я начал испытывать дискомфорт и потому решил перевести все на stdlib — умные указатели, нормальные коллекции, вот это все. Соответственно я получил возможность сравнить результаты сборки двух версий библиотеки — с stdlib и без нее. Ну и еще посмотреть, насколько хорошо/плохо cheerp (и используемый им clang) оптимизирует код. Поэтому я скомпилировал обе версии с разными наборами флагов оптимизации (-O0, -O1, -O2, -O3, -Os и -Oz), и для некоторых из этих версий сделал замеры скорости анализа 3000 операций с 1000 ветвлениями. Согласен, не самый большой пример, но для сравнительного анализа имхо достаточно. Что получилось по размеру wasm файла: Удивительно, но по размеру вариант с "нулевой" оптимизацией получается лучше, чем почти все остальные. Я предположу, что в O3 имеет место агрессивный инлайн всего на свете, что и раздувает бинарник. Ожидаемо версия без stdlib получилась покомпактнее, но не настолько, чтобы терпеть такие унижения лишать себя удовольствия работы с удобными коллекциями. По скорости выполнения: Теперь мне видно, что -O3 не зря ест свой хлеб, если сравнивать с -O0. При этом разница между версиями с stdlib и без нее практически отсутствует (я делал по 10 замеров, думаю на большем числе разница вообще исчезла бы). Стоит отметить 2 момента: Всем спасибо за внимание.
Пусть значения ваших переменных никогда не выходят за отведенные рамки.
А вот вы — выходите. Разработчик широкого профиля