Во что компилятор C++ превращает код, или ускоряем код безопасно

от автора

Привет, Хабр! Меня зовут Алексей Салтыков, я инженер-программист в команде КОМПАС-3D. Решил поделиться соображениями насчет оптимизаций в С++ глазами обычного разработчика. Хочется сразу предупредить, что статья никого ни к чему не призывает, цель – наглядно показать, как незначительные трансформации кода могут помочь компилятору лучше оптимизировать код и насколько это вообще эффективно.

Дисклеймер:

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

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

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

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

  • Будет много ассемблера (куда же без него?).

  • Для получения результатов использовался clang 18.1 с флагами -std=c++20 -O3

Нет информации — нет оптимизаций

Рассмотрим простой пример. Мы написали функцию, которая на вход принимает две точки. Находим среднюю точку и, если обе ее координаты не равны нулю, возвращаем ее, иначе – точку с координатами (-1, -1). Код будет выглядеть примерно так:

#include "Point.hpp" #include "GeometryAlgorithm.hpp"  Point GetSomePoint(Point p1, Point p2) {   Point p = Middle(p1, p2);    return p.GetX() != 0 and p.GetY() != 0 ? p : Point{-1, -1}; }

Если вставить содержимое #include, то получим следующее:

class Point { private:   double m_x;   double m_y;  public:   Point(double x, double y);    double GetX() const;   double GetY() const; };  Point Middle(Point p1, Point p2);  Point GetSomePoint(Point p1, Point p2) {   Point p = Middle(p1, p2);    return p.GetX() != 0 and p.GetY() != 0         ? p         : Point{-1, -1}; }

Именно так компилятор и будет видеть наш файл, препроцессор при виде директивы #include фактически просто копирует содержимое указанного файла. После компиляции (и дизассемблирования) в объектном файле будет следующий код:

.LCPI0_0:   .quad 0xbff0000000000000 .LCPI0_1:   .quad 0x0000000000000000 GetSomePoint(Point, Point):   sub rsp, 40   call Middle(Point, Point)@PLT   movsd qword ptr [rsp], xmm0   movsd qword ptr [rsp + 8], xmm1   mov rdi, rsp   call Point::GetX() const@PLT   xorpd xmm1, xmm1   ucomisd xmm0, xmm1   jne .LBB0_1   jnp .LBB0_3 .LBB0_1:   mov rdi, rsp   call Point::GetY() const@PLT   ucomisd xmm0, qword ptr [rip + .LCPI0_1]   jne .LBB0_2   jnp .LBB0_3 .LBB0_2:   movups xmm0, xmmword ptr [rsp]   movaps xmmword ptr [rsp + 16], xmm0   movsd xmm0, qword ptr [rsp + 16]   movsd xmm1, qword ptr [rsp + 24]   add rsp, 40   ret .LBB0_3:   lea rdi, [rsp + 16]   movsd xmm0, qword ptr [rip + .LCPI0_0]   movaps xmm1, xmm0   call Point::Point(double, double)@PLT   movsd xmm0, qword ptr [rsp + 16]   movsd xmm1, qword ptr [rsp + 24]   add rsp, 40   ret

В ассемблерном коде можно явно найти выделение памяти на стеке для промежуточного результата (переменная p), вызовы функций Point::GetX(), Point::GetY(), Middle и конструктора.

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

Point GetSomePoint(Point p1, Point p2) {   Point p = Middle(p2, p1);

Тогда получим следующее:

.LCPI0_0:   .quad 0xbff0000000000000 .LCPI0_1:   .quad 0x0000000000000000 GetSomePoint(Point, Point):   sub rsp, 40   movaps xmm4, xmm1   movaps xmm5, xmm0   movapd xmm0, xmm2   movapd xmm1, xmm3   movaps xmm2, xmm5   movaps xmm3, xmm4   call Middle(Point, Point)@PLT   movsd qword ptr [rsp], xmm0   movsd qword ptr [rsp + 8], xmm1   mov rdi, rsp   call Point::GetX() const@PLT   xorpd xmm1, xmm1   ucomisd xmm0, xmm1   jne .LBB0_1   jnp .LBB0_3 .LBB0_1:   mov rdi, rsp   call Point::GetY() const@PLT   ucomisd xmm0, qword ptr [rip + .LCPI0_1]   jne .LBB0_2   jnp .LBB0_3 .LBB0_2:   movups xmm0, xmmword ptr [rsp]   movaps xmmword ptr [rsp + 16], xmm0   movsd xmm0, qword ptr [rsp + 16]   movsd xmm1, qword ptr [rsp + 24]   add rsp, 40   ret .LBB0_3:   lea rdi, [rsp + 16]   movsd xmm0, qword ptr [rip + .LCPI0_0]   movaps xmm1, xmm0   call Point::Point(double, double)@PLT   movsd xmm0, qword ptr [rsp + 16]   movsd xmm1, qword ptr [rsp + 24]   add rsp, 40   ret

Стало только хуже :D. Теперь можно и приступить к оптимизациям.

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

Рассмотрим происходящее с точки зрения предметной области (пусть и скудной). Что делают функции GetX() и GetY() нам очевидно. То же касается и порядка аргументов функции Middle() – он не важен, так как результат останется такой же. Это очевидно нам, но не компилятору.

Довольно распространенная практика на языках С и С++ — разделять файлы на заголовочные и реализацию. В данном случае так и происходит. Реализация методов класса Point и функции Middle находятся в других файлах. В данной единице трансляции нет никакой информации о том, что эти функции делают. Это значительно ограничивает компилятор в оптимизациях.

Такой подход оправдан (я его не критикую), это как минимум полезно для скорости компиляции. Тем не менее, давайте попробуем дать компилятору информацию о тривиальных функциях. Для этого перенесем реализацию в заголовочный файл и добавим ключевое слово inline для соблюдения ODR — one definition rule. То же самое сделаем и для конструктора.

class Point { private:   double m_x;   double m_y;  public:   inline Point(double x, double y)     : m_x(x)     , m_y(y)   {}    inline double GetX() const { return m_x; }   inline double GetY() const { return m_y; } };

Результат впечатляет:

.LCPI0_0:   .quad 0xbff0000000000000 GetSomePoint(Point, Point):   push rax   movaps xmm4, xmm1   movapd xmm5, xmm0   movapd xmm0, xmm2   movapd xmm1, xmm3   movapd xmm2, xmm5   movaps xmm3, xmm4   call Middle(Point, Point)@PLT   xorpd xmm2, xmm2   movapd xmm3, xmm1   cmpneqpd xmm3, xmm2   cmpneqpd xmm2, xmm0   andpd xmm2, xmm3   movd eax, xmm2   test al, 1   jne .LBB0_2   movsd xmm0, qword ptr [rip + .LCPI0_0]   movapd xmm1, xmm0 .LBB0_2:   pop rax   ret

Не будем останавливаться и сделаем то же для функции `Middle`:

inline Point Middle(Point p1, Point p2) {   double x = p1.GetX() / 2 + p2.GetX() / 2;   double y = p1.GetY() / 2 + p2.GetY() / 2;   return Point{x, y}; }

Итого получаем следующее:

.LCPI0_0:   .quad 0x3fe0000000000000   .quad 0x3fe0000000000000 .LCPI0_1:   .quad 0xbff0000000000000   .quad 0xbff0000000000000 GetSomePoint(Point, Point):   unpcklpd xmm2, xmm3   movapd xmm3, xmmword ptr [rip + .LCPI0_0]   mulpd xmm2, xmm3   unpcklpd xmm0, xmm1   mulpd xmm0, xmm3   addpd xmm0, xmm2   xorpd xmm1, xmm1   movapd xmm2, xmm0   cmpneqpd xmm2, xmm1   movapd xmm3, xmm0   unpckhpd xmm3, xmm0   cmpneqpd xmm3, xmm1   andpd xmm3, xmm2   pshufd xmm2, xmm3, 68   andpd xmm0, xmm2   andnpd xmm2, xmmword ptr [rip + .LCPI0_1]   orpd xmm2, xmm0   pshufd xmm1, xmm2, 238   movapd xmm0, xmm2   ret

Результат стал не таким тривиальным, как был до этого. Но даже при беглом осмотре становится понятно, что работать это будет значительно быстрее. Главное, на что стоит обратить внимание, это прямое обращение к полям класса без вызова getter-ов.

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

Point GetSomePoint(Point p1, Point p2) {   Point p = Middle(p2, p1);    return p.GetX() != 0 and p.GetY() != 0 ? p : Point{-1, -1}; }

Стоит указать на некоторые важные вещи:

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

  • Link time optimization не рассматриваем специально, речь в статье идет именно о преобразовании исходного кода.

  • Существует мнение, что inline расставлять не стоит, компилятор лучше знает, где его надо использовать. Это правда, но лишь отчасти. Компилятор и правда лучше знает, но фактически ключевое слово inline используется только для ODR. Большинство современных компиляторов его игнорируют (уже достаточно давно) и строят оптимизации по своим правилам.

virtual

Избитый всеми (и мной тоже) пример с фигурами. У нас есть фигура, и в очередной раз надо вычислять ее площадь. Будем смотреть только на квадрат.

class Figure { public:   virtual ~Figure() = default;    virtual double Area() const = 0; };  class Square : public Figure { private:   double m_size;  public:   virtual double Area() const override { return m_size * m_size; } };

Напишем элементарную функцию:

double CalculateSomeArea(const Square* s) {   return s != nullptr ? s->Area() * 2 : 0; }

Получаем следующее:

CalculateSomeArea(Square const*):   test rdi, rdi   je .LBB0_1   push rax   mov rax, qword ptr [rdi]   call qword ptr [rax + 16]   addsd xmm0, xmm0   add rsp, 8   ret .LBB0_1:   xorps xmm0, xmm0   ret

Стоит обратить внимание на:

call qword ptr [rax + 16]

Это вызов виртуальной функции. В данном случае нам не поможет inline, даже несмотря на то, что тип объекта мы знаем (аргумент функции Square). Причина в том, что компилятор делает предположение, что может быть какой-то наследник класса Square, который эту функцию переопределяет.

Его нет и быть не может. Скажем об этом прямо:

class Square final : public Figure //           ~~~~~

Если же есть, например, квадрат с именем, который не переопределяет метод `Area()`, то можно сделать замену `override -> final`:

virtual double Area() const final { return m_size * m_size; }

Оба способа дают одинаковый результат:

CalculateSomeArea(Square const*):   test rdi, rdi   je .LBB0_1   movsd xmm0, qword ptr [rdi + 8]   mulsd xmm0, xmm0   addsd xmm0, xmm0   ret .LBB0_1:   xorps xmm0, xmm0   ret

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

Отсюда получается довольно интересный эффект:

class Figure { public:   virtual ~Figure() = default;    virtual double Area() const = 0; };  class Square : public Figure { private:   double m_size;  public:   Square(double size)     : m_size(size)   {   }    virtual double Area() const override    {      return m_size * m_size;    } };  void SetSomeNumber(double);  inline double FigureArea(const Figure* f) {   return f != nullptr ? f->Area() : 0; }  void SomeCode(double value) {   Square s(value);   double num = FigureArea(&s);   SetSomeNumber(num); }

Весь код сверху превращается в это:

SomeCode(double):   mulsd xmm0, xmm0   jmp SetSomeNumber(double)@PLT

Красиво, не правда ли? Мы сохранили абстракции, но при этом получили максимально производительный код.

Может произойти что угодно

class Bar { private:   int m_int;   double m_double;  public:   Bar();   ~Bar();    int foo() const; };  int SomeCode() {   Bar bar;   return bar.foo(); }

Обычный код. Есть какой-то класс, у него есть какой-то метод. Мы его вызываем. Сразу скажу, inline тут не причем. Давайте посмотрим, что может пойти не так:

SomeCode():   push rbx   sub rsp, 16   mov rbx, rsp   mov rdi, rbx   call Bar::Bar()@PLT   mov rdi, rbx   call Bar::foo() const@PLT   mov ebx, eax   mov rdi, rsp   call Bar::~Bar()@PLT   mov eax, ebx   add rsp, 16   pop rbx   ret   mov rbx, rax   mov rdi, rsp   call Bar::~Bar()@PLT   mov rdi, rbx   call _Unwind_Resume@PLT  DW.ref.__gxx_personality_v0:   .quad __gxx_personality_v0

Все было бы хорошо, если бы не одна деталь — код после ret. В нашем коде мы исключениями не пользуемся. Глазами разработчика, если ничего не намекает на исключения, их скорее всего нет. Со стороны компилятора — наоборот. Не написали явно, значит, исключения могут быть. По понятным причинам, компилятор обязан встроить дополнительный код для их обработки. Добавим то, чего не хватает – noexcept.

class Bar {   // ...   Bar() noexcept;   ~Bar();    int foo() const noexcept;

И все встает на свои места:

SomeCode():   push rbp   push rbx   sub rsp, 24   lea rbx, [rsp + 8]   mov rdi, rbx   call Bar::Bar()@PLT   mov rdi, rbx   call Bar::foo() const@PLT   mov ebp, eax   mov rdi, rbx   call Bar::~Bar()@PLT   mov eax, ebp   add rsp, 24   pop rbx   pop rbp   ret

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

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

Эксперименты

Все мы любим эксперименты. И чтобы не быть голословным, напишем код и проверим. Будем писать игру, а точнее ее малую часть. Есть игровое поле, представленное в виде ячеек. Ячеек бывает два вида — море и суша. Напишем:

// Position.hpp #pragma once  #include <cstddef> #include <functional>  class Position { public:   Position(int x, int y);    int GetX() const;   int GetY() const;    bool operator==(const Position&) const = default;   bool operator!=(const Position&) const = default;  private:   int m_x;   int m_y; };  namespace std {   template <>   struct hash<Position>   {     size_t operator()(const Position& pos) const     {       return (static_cast<size_t>(pos.GetX()) << 32) + static_cast<size_t>(pos.GetY());     }   }; } // namespace std
// Cell.hpp #pragma once  enum class CellType {   Ground,   Sea };  class Cell { public:   Cell(CellType type);    CellType GetType() const;  private:   CellType m_type; };
// Map.hpp #pragma once  #include <memory> #include <vector>  class Cell; class Position;  class Map { public:   Map();    std::shared_ptr<Cell> GetCellAt(const Position& pos) const;    int GetWidth() const;   int GetHeight() const;  private:   std::vector<std::shared_ptr<Cell>> m_cells;   int m_width;   int m_height; };

Что-то похожее на правду получилось. Ячейки специально разместил в виде указателей. Предполагаем, что это какой-то сложный объект, и по значению мы его хранить не можем. Реализацию методов выкладывать не вижу смысла – они тривиальные. Теперь напишем алгоритм. У нас на карте есть суша и море. Мы хотим написать следующую функцию:

Для определенной позиции вычисляем размер острова (количество ячеек земли), на котором расположена указанная позиция. Если позиция указывает на море, возвращаем 0.

В качестве основы для алгоритма используем что-то похожее на поиск в глубину. Чтобы избежать переполнения стека, рекурсию использовать не будем. Получается следующий код:

#include "cell.hpp" #include "map.hpp" #include "position.hpp"  int GetGroundAreaAt(const Map& map, const Position& initialPos) {   int result = 0;   std::unordered_set<Position> visited;   std::stack<Position> toVisit;    toVisit.emplace(initialPos);    while (not toVisit.empty())   {     Position pos = toVisit.top();     toVisit.pop();      if (pos.GetX() < 0 or pos.GetY() < 0 or pos.GetX() >= map.GetWidth() or pos.GetY() >= map.GetHeight())     {       continue;     }      auto insertRes = visited.insert(pos);     if (not insertRes.second)     {       continue;     }      auto c = map.GetCellAt(pos);     if (c->GetType() != CellType::Ground)     {       continue;     }      ++result;      toVisit.emplace(pos.GetX() + 1, pos.GetY());     toVisit.emplace(pos.GetX() - 1, pos.GetY());     toVisit.emplace(pos.GetX(), pos.GetY() + 1);     toVisit.emplace(pos.GetX(), pos.GetY() - 1);   }    return result; }

Проверять будем на карте размером 1000х1000. Заполняем случайно, с шансом 70% будет земля, так мы получим огромные острова (и долгое время выполнения 😀). При проверке будем вызывать функцию для 100 случайных позиций. Сид для генерации устанавливаем фиксированный – 0. В результате время выполнения составляет 10.3 секунды. Кстати, максимальная площадь острова составила 700183 клетки (материк с лужами, а не острова в море), что объясняет такое время выполнения.

Теперь переделаем все методы классов Cell, Position и Map, они будут inline. Работа очевидная и в примерах кода не нуждается. Результат порадовал, время выполнения стало 9.2 секунды.

Не так впечатляет, как снижение сложности алгоритма или выполнение в несколько потоков, но тоже приятно – 10% без особых усилий. Как и говорилось в начале, подход не дает большого преимущества, но изменения в коде тривиальные, мы не рискуем что-то сломать. Разумеется, представленный алгоритм далек от идеала, но наша цель состояла в том, чтобы ускорить его без изменений. Если изменить алгоритм, время выполнения, естественно, будет ниже, но преимущество от описанных оптимизаций никуда не денется.

Бонус — range vs for

В качестве бонуса хочется поговорить про новинку С++20 (новинке уже 4 года…) – ranges. Давайте посмотрим, как их видит компилятор. Для начала сравним обычные алгоритмы и их аналоги в namespace std::ranges

bool HasZero(const std::vector<int>& vi) {   return std::any_of(vi.begin(),                       vi.end(),                       [](int x) { return x == 0; }); }

Тривиально, а в результате получаем это:

Скрытый текст
HasZero(std::vector<int, std::allocator<int>> const&):   mov rdx, qword ptr [rdi]   mov rax, qword ptr [rdi + 8]   mov rdi, rax   sub rdi, rdx   mov rsi, rdi   sar rsi, 4   test rsi, rsi   jle .LBB0_8   and rdi, -16   mov rcx, rdi   add rcx, rdx   inc rsi   add rdx, 8 .LBB0_2:   cmp dword ptr [rdx - 8], 0   je .LBB0_17   cmp dword ptr [rdx - 4], 0   je .LBB0_18   cmp dword ptr [rdx], 0   je .LBB0_16   cmp dword ptr [rdx + 4], 0   je .LBB0_19   dec rsi   add rdx, 16   cmp rsi, 1   jg .LBB0_2   mov rdi, rax   sub rdi, rcx   sar rdi, 2   cmp rdi, 1   jne .LBB0_9   jmp .LBB0_15 .LBB0_8:   mov rcx, rdx   sar rdi, 2   cmp rdi, 1   je .LBB0_15 .LBB0_9:   cmp rdi, 2   je .LBB0_13   mov rdx, rax   cmp rdi, 3   jne .LBB0_16   cmp dword ptr [rcx], 0   je .LBB0_21   add rcx, 4 .LBB0_13:   cmp dword ptr [rcx], 0   je .LBB0_21   add rcx, 4 .LBB0_15:   cmp dword ptr [rcx], 0   cmovne rcx, rax   mov rdx, rcx .LBB0_16:   cmp rdx, rax   setne al   ret .LBB0_17:   add rdx, -8   cmp rdx, rax   setne al   ret .LBB0_18:   add rdx, -4   cmp rdx, rax   setne al   ret .LBB0_21:   mov rdx, rcx   cmp rdx, rax   setne al   ret .LBB0_19:   add rdx, 4   cmp rdx, rax   setne al   ret

Выглядит не очень, попробуем аналог:

bool HasZero(const std::vector<int>& vi) {   return std::ranges::any_of(vi, [](int x) { return x == 0; }); }
HasZero(std::vector<int, std::allocator<int>> const&):   mov rdx, qword ptr [rdi]   mov rcx, qword ptr [rdi + 8]   cmp rdx, rcx   je .LBB0_1   add rdx, 4 .LBB0_4:   cmp dword ptr [rdx - 4], 0   sete al   je .LBB0_2   lea rsi, [rdx + 4]   cmp rdx, rcx   mov rdx, rsi   jne .LBB0_4 .LBB0_2:   ret .LBB0_1:   xor eax, eax   ret

Очевидно, где лучше. Посмотрим кое-что интересней:

int OddSum(const std::vector<int>& vi) {   int res = 0;    auto&& range = vi                 | std::views::filter([](int x) { return x % 2 == 0; });    for (int i : range)   {     res += i;   }    return res; }

Стоит сразу оговориться — accumulate я специально не использовал, важно показать влияние от применения фильтрации.

Посмотрим, что получилось:

OddSum(std::vector<int, std::allocator<int>> const&):   mov rcx, qword ptr [rdi]   mov rdx, qword ptr [rdi + 8]   cmp rcx, rdx   je .LBB0_4 .LBB0_1:   test byte ptr [rcx], 1   je .LBB0_4   add rcx, 4   cmp rcx, rdx   jne .LBB0_1   xor eax, eax   ret .LBB0_4:   xor eax, eax .LBB0_5:   cmp rcx, rdx   je .LBB0_9   add eax, dword ptr [rcx] .LBB0_8:   add rcx, 4   cmp rcx, rdx   je .LBB0_9   test byte ptr [rcx], 1   jne .LBB0_8   jmp .LBB0_5 .LBB0_9:   ret

Прекрасный код. Ничего лишнего, именно то, что я и написал. «Доделали!» – сказал я себе, радуясь, что новинка не только удобная, но и работать будет быстро.

«Ага, ЩАС!». Поверили? В статье про оптимизации в коде нет хитростей? Давайте проверим старый добрый цикл for, его точно доделали:

int OddSum(const std::vector<int>& vi) {   int res = 0;    for (int i : vi)   {     if (i % 2 == 0)     {       res += i;     }   }    return res; }
Скрытый текст
.LCPI0_0:   .long 1   .long 1   .long 1   .long 1 OddSum(std::vector<int, std::allocator<int>> const&):   mov r8, qword ptr [rdi]   mov rcx, qword ptr [rdi + 8]   cmp r8, rcx   je .LBB0_1   mov rdi, rcx   sub rdi, r8   add rdi, -4   xor edx, edx   cmp rdi, 28   jae .LBB0_5   xor eax, eax   mov rsi, r8   jmp .LBB0_8 .LBB0_1:   xor eax, eax   ret .LBB0_5:   shr rdi, 2   inc rdi   mov r9, rdi   and r9, -8   lea rsi, [r8 + 4*r9]   pxor xmm0, xmm0   xor eax, eax   movdqa xmm3, xmmword ptr [rip + .LCPI0_0]   pxor xmm2, xmm2   pxor xmm1, xmm1 .LBB0_6:   movdqu xmm4, xmmword ptr [r8 + 4*rax]   movdqu xmm5, xmmword ptr [r8 + 4*rax + 16]   movdqa xmm6, xmm4   pand xmm6, xmm3   movdqa xmm7, xmm5   pand xmm7, xmm3   pcmpeqd xmm6, xmm0   pand xmm6, xmm4   paddd xmm2, xmm6   pcmpeqd xmm7, xmm0   pand xmm7, xmm5   paddd xmm1, xmm7   add rax, 8   cmp r9, rax   jne .LBB0_6   paddd xmm1, xmm2   pshufd xmm0, xmm1, 238   paddd xmm0, xmm1   pshufd xmm1, xmm0, 85   paddd xmm1, xmm0   movd eax, xmm1   cmp rdi, r9   je .LBB0_2 .LBB0_8:   mov edi, dword ptr [rsi]   test dil, 1   cmovne edi, edx   add eax, edi   add rsi, 4   cmp rsi, rcx   jne .LBB0_8 .LBB0_2:   ret

Выглядит страшно. Но этот код работает примерно в 12 раз быстрее за счет векторизации. Да, элементарный код будет лучше, мнимая красота того не стоит. Однако в защиту стоит сказать, где применение ranges реально может ускорить быстродействие:

inline auto Document::GetFiguresInRect(const Rect& rect) const {   return std::views::all(m_figures)        | std::views::filter([&rect](const Figure* fig) { return fig->CollidesRect(rect); })        | std::views::transform([](auto&& i) -> const Figure* { return i; }); }

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

Примечание: из-за своей специфики подобный подход опасен висячими ссылками, применять с осторожностью. Берегите свои ноги!

Итоги

  • Для простых функций применение inline может быть оправдано, например:

    • Тривиальные get/set

    • Простые функции

    • Не сложные вычисления

  • Для оптимизации работы виртуальных функций inline не всегда достаточно. Тем не менее, поставить у класса-наследника final может помочь компилятору лучше оптимизировать.

  • Если необходимо применить функцию STL на весь контейнер, есть смысл выбрать версию из namespace std::ranges.

  • Использование std::views вместо цикла for может быть оправдано только в тех случаях, когда мы избегаем создания нового контейнера. В остальных случаях обычный цикл будет как минимум не хуже.

  • Исключения достаточно хорошо оптимизированы, но не бесплатны. Если их нет и не будет — не лишним будет добавить noexcept.


ссылка на оригинал статьи https://habr.com/ru/articles/850406/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *