Привет, Хабр! Меня зовут Алексей Салтыков, я инженер-программист в команде КОМПАС-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% будет земля, так мы получим огромные острова (и долгое время выполнения :D). При проверке будем вызывать функцию для 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.

Комментарии (8)


  1. JordanCpp
    17.10.2024 08:32

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


  1. max-daniels
    17.10.2024 08:32

    Код

    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};
    }

    можно еще оптимизировать:

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

    ибо процессор любит больше умножение чем деление


    1. domix32
      17.10.2024 08:32

      Замеряли?


    1. Mmv85
      17.10.2024 08:32

      Компилятор сам так сделал. 0xbff0000000000000 это 0.5


    1. Shaman_Ist
      17.10.2024 08:32

      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};
      }

      можно еще оптимизировать:

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

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

      double x = p1.GetX() + p2.GetX() / 2;

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


  1. Viilture
    17.10.2024 08:32

    Вопрос, а нельзя ли как нибудь отфильтровать Хабр на предмет таких статей? А то поиск как выборочно работает, статей явно больше.


  1. simplepersonru
    17.10.2024 08:32

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

    Методы классов/структур inline по умолчанию, если объявление и определение это одно и то же (в определении класса, которое, как правило, в заголовочнике).

    Можно конечно написать, но это ничего не делает


  1. Tausinov
    17.10.2024 08:32

    ЕМНИП вешать inline на методы определенные в объявлении класса - избыточная история, они уже неявно inline