Привет, Хабр! Меня зовут Алексей Салтыков, я инженер-программист в команде КОМПАС-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)
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}; }
ибо процессор любит больше умножение чем деление
Shaman_Ist
17.10.2024 08:32inline 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;
Аналогично для второй координаты. А, иногда и третьей, и последующих, хотя с ними не встречался.
Viilture
17.10.2024 08:32Вопрос, а нельзя ли как нибудь отфильтровать Хабр на предмет таких статей? А то поиск как выборочно работает, статей явно больше.
simplepersonru
17.10.2024 08:32Для этого перенесем реализацию в заголовочный файл и добавим ключевое слово inline для соблюдения ODR — one definition rule. То же самое сделаем и для конструктора.
Методы классов/структур inline по умолчанию, если объявление и определение это одно и то же (в определении класса, которое, как правило, в заголовочнике).
Можно конечно написать, но это ничего не делает
Tausinov
17.10.2024 08:32ЕМНИП вешать inline на методы определенные в объявлении класса - избыточная история, они уже неявно inline
JordanCpp
Спасибо за статью. Не могли бы вы дополнить статью реальным кейсом? Оптимизировали горячий код и получили тонны профита? Уверен в таком специализированном софте, должно быть много таких участков кода.