Как я написал интро 4K на Rust — и оно победило

Original author: Jani Peltonen
  • Translation
Недавно я написал своё первое интро 4K на Rust и представил его на Nova 2020, где оно заняло первое место в конкурсе New School Intro Competition. Написать интро 4K довольно сложно. Это требует знания многих различных областей. Здесь я сосредоточусь на методах, как максимально сократить код Rust.


Можете просмотреть демо-версию на Youtube, скачать исполняемый файл на Pouet или получить исходный код с Github.

Интро 4K — это демо, в которой вся программа (включая любые данные) занимает 4096 байта или меньше, поэтому важно, чтобы код был максимально эффективным. Rust имеет некоторую репутацию создания раздутых исполняемых файлов, поэтому я хотел выяснить, можно ли написать на нём эффективный и лаконичный код.

Конфигурация


Всё интро написано на комбинации Rust и glsl. Glsl используется для рендеринга, но Rust делает всё остальное: создание мира, управление камерой и объектами, создание инструментов, воспроизведение музыки и т. д.

В коде есть зависимости от некоторых функций, которые ещё не включены в стабильный Rust, поэтому я использую набор инструментов Nightly Rust. Чтобы установить и использовать этот набор по умолчанию, нужно запустить следующие команды rustup:

rustup toolchain install nightly
rustup default nightly

Я использую crinkler для сжатия объектного файла, сгенерированного компилятором Rust.

Я также использовал shader minifier для препроцессинга шейдера glsl, чтобы сделать его меньше и удобнее для crinkler. Shader minifier не поддерживает вывод в .rs, так что я брал необработанную выдачу и вручную копировал её в свой файл shader.rs (задним умом ясно, что нужно было как-то автоматизировать этот этап. Или даже написать пул-реквест для shader minifier).

Отправной точкой послужило моё прошлое интро 4K на Rust, которое тогда мне казалось довольно лаконичным. В той статье также более подробная информация о настройке файла toml и о том, как использовать xargo для компиляции крошечного бинарника.

Оптимизация дизайна программы для уменьшения кода


Многие из наиболее эффективных оптимизаций размера никак не назовёшь умными хаками. Это результат переосмысления дизайна.

В моём первоначальном проекте одна часть кода создавала мир, включая размещение сфер, а другая часть отвечала за перемещение сфер. В какой-то момент я понял, что код размещения и код перемещения сферы делают очень похожие вещи, и можно объединить их в одну значительно более сложную функцию, которая делает и то, и другое. К сожалению, такие оптимизации делают код менее элегантным и читабельным.

Анализ ассемблерного кода


В какой-то момент придётся посмотреть на скомпилированный ассемблер и разобраться, во что компилируется код и какие оптимизации размера стоят того. В компиляторе Rust есть очень полезная опция --emit=asm для вывода ассемблерного кода. Следующая команда создаёт файл ассемблера .s:

xargo rustc --release --target i686-pc-windows-msvc -- --emit=asm

Не обязательно быть экспертом в ассемблере, чтобы извлечь выгоду из изучения выходных данных ассемблера, но определённо лучше иметь базовое понимание синтаксиса. Опция opt-level = "z заставляет компилятор максимально оптимизировать код для наименьшего размера. После этого немного сложнее выяснить, какая часть кода ассемблера соответствует какой части кода Rust.

Я обнаружил, что компилятор Rust может быть удивительно хорош в минимизации, удалении неиспользуемого кода и ненужных параметров. Он также делает некоторые странные вещи, поэтому очень важно время от времени изучать результат на ассемблере.

Дополнительные функции


Я работал с двумя версиями кода. Одна протоколирует процесс и позволяет зрителю манипулировать камерой для создания интересных траекторий. Rust позволяет определить функции для этих дополнительных действий. В файле toml есть раздел [features], который позволяет объявлять доступные функции и их зависимости. В toml моего интро 4K есть следующий раздел:

[features]
logger = []
fullscreen = []

Ни одна из дополнительных функций не имеет зависимостей, поэтому они эффективно работают как флаги условной компиляции. Условным блокам кода предшествует оператор #[cfg(feature)]. Использование функций само по себе не делает код меньше, но сильно упрощает процесс разработки, когда вы легко переключаетесь между различными наборами функций.

        #[cfg(feature = "fullscreen")]
        {
            // Этот код компилируется только в том случае, если выбран полноэкранный режим
        }

        #[cfg(not(feature = "fullscreen"))]
        {
            // Этот код компилируется только в том случае, если полноэкранный режим не выбран
        }

Изучив скомпилированный код, я уверен, что в него включены только выбранные функции.

Одним из основных применений функций было включение лога и проверки ошибок для отладочной сборки. Загрузка кода и компиляция шейдера glsl часто завершались неудачно, и без полезных сообщений об ошибках было бы чрезвычайно трудно находить проблемы.

Использование get_unchecked


При размещении кода внутри блока unsafe{} я вроде как предполагал, что все проверки безопасности будут отключены, но это не так. Там по-прежнему выполняются все обычные проверки, и они дорого обходятся.

По умолчанию range проверяет все обращения к массиву. Возьмите следующий код Rust:

    delay_counter = sequence[ play_pos ];

До поиска по таблице компилятор вставит код, который проверяет, что play_pos не индексируется после конца последовательности, и паникует, если это так. Это добавляет значительный размер коду, потому что таких функций может быть много.

Преобразуем код следующим образом:

    delay_counter = *sequence.get_unchecked( play_pos );

Это говорит компилятору, чтобы он не выполнял никаких проверок диапазона и просто искал по таблице. Это явно опасная операция и поэтому её можно выполнять только в пределах кода unsafe.

Более эффективные циклы


Изначально все мои циклы выполнялись идиоматически как положено в Rust, используя синтаксис for x in 0..10. Я предполагал, что он будет скомпилирован в максимально плотный цикл. Удивительно, но это не так. Простейший случай:

for x in 0..10 {
    // do code
}

будет скомпилирован в ассемблерный код, который делает следующее:

    setup loop variable
loop:
    проверить условие цикла    
    если цикл закончен, перейти в end
    // выполнить код внутри цикла
    безусловно перейти в loop
end:

тогда как следующий код

let x = 0;
loop{
    // do code
    x += 1;
    if x == 10 {
        break;
    }
}

непосредственно компилируется в:

    setup loop variable
loop:
    // выполнить код внутри цикла
    проверить условие цикла    
    если цикл не закончен, перейти в loop
end:

Обратите внимание, что условие проверяется в конце каждого цикла, что делает ненужным безусловный переход. Это небольшая экономия места для одного цикла, но они действительно в сумме дают неплохую экономию, когда в программе 30 циклов.

Другая, гораздо более трудная для понимания проблема с идиоматическим циклом Rust заключается в том, что в некоторых случаях компилятор добавлял некоторый дополнительный код настройки итератора, который действительно раздувал код. Я так и не понял, что вызывает эту дополнительную настройку итератора, поскольку всегда было тривиально заменить конструкции for {} конструкцией loop{}.

Использование векторных инструкций


Я много времени потратил на оптимизацию кода glsl, и одна из лучших оптимизаций (которая ещё обычно и ускоряет работу кода) состоит в одновременной работе со всем вектором, а не с каждым компонентом по очереди.

Например, код трассировки лучей использует быстрый алгоритм обхода сетки для проверки, какие части карты посещает каждый луч. Исходный алгоритм рассматривает каждую ось отдельно, но можно переписать его так, чтобы он рассматривал все оси одновременно и не нуждался в каких-либо ветвях. Rust на самом деле не имеет собственного векторного типа, такого как glsl, но вы можете использовать внутренние компоненты, чтобы указать использовать инструкции SIMD.

Чтобы использовать встроенные функции, я бы преобразовал следующий код

        global_spheres[ CAMERA_ROT_IDX ][ 0 ] += camera_rot_speed[ 0 ]*camera_speed;
        global_spheres[ CAMERA_ROT_IDX ][ 1 ] += camera_rot_speed[ 1 ]*camera_speed;
        global_spheres[ CAMERA_ROT_IDX ][ 2 ] += camera_rot_speed[ 2 ]*camera_speed;

в такое:

        let mut dst:x86::__m128 = core::arch::x86::_mm_load_ps(global_spheres[ CAMERA_ROT_IDX ].as_mut_ptr());
        let mut src:x86::__m128 = core::arch::x86::_mm_load_ps(camera_rot_speed.as_mut_ptr());
        dst = core::arch::x86::_mm_add_ps( dst, src);
        core::arch::x86::_mm_store_ss( (&mut global_spheres[ CAMERA_ROT_IDX ]).as_mut_ptr(), dst );

что будет немного меньше по размеру (и гораздо менее читабельно). К сожалению, по какой-то причине это сломало отладочную сборку, хотя прекрасно работало в релизной. Ясно, что здесь проблема с моим знанием внутренних средств Rust, а не с самим языком. На это стоит потратить больше времени при подготовке следующего 4K-интро, поскольку сокращение объёма кода было значительным.

Использование OpenGL


Есть множество стандартных крейтов Rust для загрузки функций OpenGL, но по умолчанию все они загружают очень большой набор функций. Каждая загруженная функция занимает некоторое пространство, потому что загрузчик должен знать её имя. Crinkler очень хорошо сжимает такого рода код, но он не в состоянии полностью избавиться от оверхеда, поэтому пришлось создать свою собственную версию gl.rs, включающую только нужные функции OpenGL.

Заключение


Главная цель состояла в том, чтобы написать конкурентоспособное корректное интро 4K и доказать, что язык Rust пригоден для демосцены и для сценариев, где каждый байт имеет значение и вам действительно нужен низкоуровневый контроль. Как правило, в этой области рассматривали только ассемблер и C. Дополнительная цель состояла в максимальном использовании идиоматического Rust.

Мне кажется, что я довольно успешно справился с первой задачей. Ни разу не возникало ощущения, что Rust каким-то образом сдерживает меня или что я жертвую производительностью или функциями, потому что использую Rust, а не C.

Со второй задачей я справился менее успешно. Слишком много небезопасного кода, который на самом деле не должен быть там. unsafe несёт разрушающий эффект; очень легко его использовать, чтобы быстро что-то выполнить (например, используя изменяемые статические переменные), но как только появляется небезопасный код, он порождает ещё более небезопасный код, и внезапно он оказывается повсюду. В будущем я буду гораздо осторожнее использовать unsafe только тогда, когда действительно нет альтернативы.

См. также:

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 27

    +1

    "Ни разу не возникало ощущения, что Rust каким-то образом сдерживает меня или что я жертвую производительностью или функциями, потому что использую Rust, а не C" — ну да, а те же приседания с get_unchecked в unsafe блоках — это не оно.

      +4

      И где же в использовании get_unchecked жертвование производительностью или возможностями (в оригинале capabilities)? Если бы было про краткость/лаконичность ещё можно было бы согласиться, но в текущем варианте, нет, не оно.

        0

        В самом факте, что приходится использовать его вместо стандартной [] нотации, которая как раз и вызывает проблемы с производительностью. Впрочем, вашего права иметь на этот счёт свое мнение я не оспариваю.

          +2

          Ну есть выбор — медленнее, но с проверками или быстрее, но без проверок. Вот если бы опции "быстрее, но без проверок" в расте не было, но он бы действительно "каким-то образом сдерживал".

            0
            А еще есть выбор «с отключаемыми проверками» — это когда не нужно жертвовать ничем, т. к. можно везде вставлять медленные проверки, которые потом автоматически будут убраны. Наверняка такое можно сделать в Расте, но стандартных средств автор не нашел.
              0

              Мне кажется, что, начиная с опредеённого размера кодовой базы, это скорее вредный подход. Обычно у нас есть критичные к производительности места, которые можно внимательно проанализировать и решить, что там проверки не нужны. Со всем кодом так обычно не поступают.


              У автора всё-таки особенный случай.

                0
                Вы, возможно, не совсем поняли о чем я говорю. Проверками в этом случае не надо жертвовать вообще — ставьте где хотите и сколько хотите, запускайте дебажную сборку и получайте панику по каждому чиху. А вот в релизной сборке эти проверки автоматически убираются, и получается максимальная производительность. При этом, конечно, можно реализовать и неотключаемые проверки, но, на мой взгляд, реальная потребность в них возникает довольно редко (если мы говорим о приложениях, в которых производительность важна).
                  0

                  Кажется, понял я как раз правильно и в том и проблема, что проверки отключаются сразу по всему проекту. И если отдельны части написаны аккурано и обложены тестами, но на весь код это не распространяется.


                  можно реализовать и неотключаемые проверки, но, на мой взгляд, реальная потребность в них возникает довольно редко (если мы говорим о приложениях, в которых производительность важна).

                  Давайте определимся, что значит "отключаем проверки"? Получаем UB, если вдруг что-то пошло не так? В таком случае, проверки очень даже нужны.


                  На примере get_unchecked из статьи: в идеальном случае мы можем передать компилятору знание о том, что проверка не нужна (например, если у нас есть итератор) — это будет ещё и безопасно. В некоторых случаях мы обладаем знанием, что операция безопасна, а компилятор нет. В этом случае используем "костыли" (get_unchecked) и обкладываем код тестами. Делать так везде скорее вредно: утверждения, которые мы в уме держим (тут массив точно больше десяти элементов!) могут быть ошибочны.


                  У автора весь смысл проекта сделать как можно быстрее и меньше. В других проектах обычно коректность и отсутствие порчи данных важны.

                    0
                    Отключаем — значит используем что-то вроде этого:
                    #ifdef NDEBUG
                    #define assert(expr)
                    #else
                    #define assert(expr) if (!(expr)) { <some logging and debug break>; }
                    #endif // #ifdef NDEBUG

                    Для релиза вместо no-op'а можно еще хинты для оптимизатора поставить, вроде как уже ввели стандартизированный способ.
                    Да, в релизе это может добавить UB, только вот на стадии тестирования подавляющая их часть будет отловлена. Если же по какой-то причине надо отдать в продакшен какую-то нетестированную часть проекта — просто переопределите макрос так, что бы в релизе было то же, что в дебаге, и получайте логи об ошибках.
                    В общем то я уверен, что аналогичные дела можно реализовать в Rust, но, насколько я понимаю, стандартные паники, на которых написана стандартная библиотека и, думаю, огромная часть нестандартных, таки не подлежат отключению (как минимум автор не нашел способа это сделать).
                      +1
                      Да, в релизе это может добавить UB, только вот на стадии тестирования подавляющая их часть будет отловлена.

                      Не будет.

                        –1
                        О, ну раз Вы сказали «не будет» — да, значит и впрямь не будет. /s
                        0

                        Так я о том и говорю, что с таким подходом можно или отключить все проверки сразу или включить. Можно, конечно, определять дефайны для отдельных частей проекта, но с таким сталкивался редко. Ну а дальше всё упирается в устраивает ли нас утверждение "(думаем, что) большая часть UB отловлена". Могу только повторить, что предпочитаю уменьшать объём кода надёжность которого гарантируется "вручную".


                        Раньше любил дебаг асертами (которые в расте тоже есть) код обкладывать, потом пришёл к тому, что это полумера и, зачастую, бессмысленная "оптимизация". Да, бывают исключения, но вообще оптимизировать лучше с профайлером и проверки редко бывают узким местом.

        +2
        > интро 4K
        > Rust

        Демосцена уже не та.
          0
          Меня больше смутило использование glsl… Всегда казалось, что тру — это только побайтовая запись в видеопамять.
            +3
            OpenGL давно уже используют, также как и шейдеры. Вся работа с фреймбуфером вынесена в шейдеры.
            +2

            Я считываю тон вашего комментария, но не улавливаю его смысл. Не могли бы вы раскрыть смысл?

              –1
              Sapienti sat.
                0

                Это-то понятно. А можно объяснение для не sapienti? Что, для демосцены обязательно нужно использовать ассемблер или один из самых старых и убогих языков программирования?

            +2
            Боюсь, что подобную демку можно было написать вообще одним шейдером. Ну а цикл вызова любым дефолтным языком с передачей времени и еще каких-либо опорных данных в шейдер.
              0
              Отлично получилось
              Меня очень возмутила сборка раста из коробки на 180 кб, сократить ее значительно оказалось возможно, но до 4 кб дойти выглядит подвигом.
              Хотя понятно, что стандартная библиотека.
                +1

                красивые кроссплатформенные паники и форматирование дорого стоят в плане объема. Плюс весь тот обвязочный код который намазывается компилятором без вашего ведома там же.

                +2
                Это 4k?
                Вот 10 место:
                www.youtube.com/watch?v=5k0OiT8X3z0
                Вы можете сказать, что компетишны разные. А какая разница?
                  0
                  Другая, гораздо более трудная для понимания проблема с идиоматическим циклом Rust заключается в том, что в некоторых случаях компилятор добавлял некоторый дополнительный код настройки итератора, который действительно раздувал код. Я так и не понял, что вызывает эту дополнительную настройку итератора

                  Дело в том, что подобный цикл


                  for x in 0..10 {
                      // do code
                  }

                  Развернется в следующий код:


                  match IntoIterator::into_iter(0..10) {
                      mut iter => loop {
                          let next;
                          match iter.next() {
                              Some(val) => next = val,
                              None => break,
                          };
                          let x = next;
                          let () = {
                              // do code
                          };
                      },
                  }
                    0
                    Это просто НЕРЕАЛЬНО ШИКАРНО!!!
                      +1
                      Minor, но нужно дополнительно mut
                      let mut x = 0;
                      loop {
                          // do code
                          x += 1;
                          if x == 10 {
                              break;
                          }
                      }
                        0
                        поясните, а чем это демо принципиально отличается от демо mars 1993года объёмом 3кБ?
                        ps: то есть это не совсем демо — это игра, по Марсу можно летать =)

                        cloud.mail.ru/public/4Taj/2zMm29QK9
                          0

                          Принципиально — ничем. Хотя это зависит от уровня вашей принципиальности )

                        Only users with full accounts can post comments. Log in, please.