Обновить

Что именно делал компилятор: как ассемблер помогает разобраться в производительности кода на C++

Время на прочтение10 мин
Охват и читатели16K
Всего голосов 28: ↑28 и ↓0+41
Комментарии26

Комментарии 26

Вы использовали godbolt[dot]org . А пробовали ли дизассемблировать intel-овский или Visual C++ компилятор?

Это перевод.

Интеловский компилятор на годболте есть.

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

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

Я что-то не понял первый пример, как data может указывать на factor, если factor - локальная для этой функции переменная? Или компилятор исходит из того, что мы в вызывающей функции можем сформировать указатель на локальную переменную вызываемой?

Factor передается по значению, но ты мог передать в data указатель на адрес, где этот factor лежит на стеке вызывающей функции

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

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

Указатель на регистр сделать сложно. Даже если как то зафорсить размещение в стеке, сомневаюсь, что можно в вызывающей функции получить указатель на аргумент без UB.

да, пожалуй извне не выйдет...

А на практике это UB, которого пугаются сишники и хотят избавиться от самого понятия, хотя оптимизация в принципе возможна именно потому что он есть.

Не может, restrict работает только для поинтеров. Возможно это нейрослоп.

Тоже склоняюсь к этому варианту. Вроде по отдельности слова логичные, но смысл полный бред.

Да, там чушь написана, 1 вариант легко векторизуется (сам сходил проверил). factor передается по значению в регистре, он никак не может алиаситься с массивом.

В примере 4 никакого UB нет, там же знаковые инты, они могут быть меньше нуля и приводить к сумме меньше нуля безо всякого переполнения.

Автор там коммент в коде оставил специально про это

Я про этот коммент и пишу, он бессмысленный.

Векторизация ломается не только от ветвлений, иногда хватает просто неудачного выравнивания данных. SoA вместо AoS для геймдева база, но в обычном энтерпрайзе переписывать структуры ради simd мало кто даст

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

Не понятно, как в первом примере возможна векторизация, если размер данных не определен? Почему компилятор при оптимизации смело думает, что размерность кратна 8 foat?

Помню, приходилось в цикле раскладывать на 8 строк или с помощью pragma гарантировать, что размер (n) кратен 8.

Тут не весь код. Компилятор векторизует с шагом 8, а после сделает 0-7 итераций хвоста цикла.

В современных векторных ISA (AVX512, SVE, RVV) есть предикаты/маски, позволяющие естественным образом обработать часть массива, не полностью влезающую в вектор. Если этого нет - то, как уже написали, после основного цикла идёт явная обработка хвоста.

Вот Intel OneAPI со всеми настройками по умолчанию, в релиз на хасвелл, цикл скачет по восемь:

Можно и разворот добавить прагмой или опцией:

// Версия 1: безобидный цикл
GBDLL_API void scale(float* data, float factor, int n) {
#pragma unroll 4
    for (int i = 0; i < n; i++) {
        data[i] *= factor;
    }
}

Тогда будет так:

Версия с __restrict вообще отличий никаких не имеет, более того, если в файле и так и сяк, то компилятор фолдит обе в одну функцию, они для него строго одинаковы.

А вот студия 2026 предпоследняя, в релиз с О2, тут цикл сразу развёрнут

Годболт, кстати, имеет лёгкие отличия, если очень дотошно сравнить:

Какой компилятор выбирал автор оригинала и что у него там не очень поддаётся векторизации — не очень понятно.

НЛО прилетело и опубликовало эту надпись здесь

Чё-то мне кажется, что автор оригинала в первом примере напортачил, не разобрался или взял непонятно какой компилятор, всё там векторизуется и вроде ничего не мешает.

Вот примерно как выглядет минимальный пример, где компилятору надо помогать с restrict:

void saxpy(float* x, float* y, float a, int n) {
    for (int i = 0; i < n; i++) {
        y[i] = a * x[i] + y[i];
    }
}

void saxpy_r(float* __restrict x,
    float* __restrict y,
    float a, int n)
{
    for (int i = 0; i < n; i++) {
        y[i] = a * x[i] + y[i];
    }
}

Тогда да.

Было:

Стало с __restrict:

И, кстати, godbolt показывает это дело вот так:

Так что заметно проще и надёжнее анализировать ассемблерный листинг на местах, а не возиться с выставлением компилятора и всех опций в онлайн годболте.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации