Разработчики на C++ с самого начала изучают догмы о производительности: быстрый обратный квадратный корень, XOR‑обмен, ручное развертывание циклов, метод Даффа, медлительность исключений/виртуальных вызовов/деления и безоговорочную победу табличного поиска над чистой математикой. Даже если когда‑то все это было чистой правдой, развитие железа, софта и компиляторных технологий настолько перекроило ландшафт, что большинство этих правил давно превратились в мифы.

Те компьютеры DEC Alpha и Pentium Pro, на которых творил Джон Кармак, бесконечно далеки от современного ядра Zen 5 архитектуры x86_64. В этом крошечном кристалле кремния один лишь блок предсказания переходов содержит больше транзисторов, чем все рабочие станции в офисе id Software тех лет. К тому же компиляторы вроде LLVM Clang и GNU GCC научились генерировать совершенный машинный код из самого простого, бесхитростного исходника. Например, у обоих компиляторов есть этапы оптимизации, которые распознают завуалированный подсчет единичных битов (popcount) и превращают его в одну инструкцию.

Более того, попытка написать «умный» код часто обращается медвежьей услугой: заумные конструкции скрывают от оптимизатора ваши истинные намерения, из‑за чего компилятор может отказаться от векторизации, инлайнинга функций или низкоуровневых преобразований под целевую архитектуру.

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

  • Ubuntu 24.04 LTS (с обновленным ядром Linux 6.18.1)

  • AMD Ryzen 9 9950X (16 ядер / 32 потока)

  • 128 ГБ ОЗУ DDR5-3600

  • Clang 21.1.1 с флагами -O3 -ffast-math -mtune=native

❯ Содержание

1. Часть 1. Известные трюки на новый лад

2. Часть 2. Стандартная библиотека

3. Заключение

❯ Часть 1. Известные трюки на новый лад

❯ Быстрый обратный квадратный корень

Знаменитая функция Q_rsqrt из Quake III выглядит следующим образом:

float Q_rsqrt( float number )
{
	long i;
	float x2, y;
	const float threehalfs = 1.5F;

	x2 = number * 0.5F;
	y  = number;
	i  = * ( long * ) &y;
	i  = 0x5f3759df - ( i >> 1 );
	y  = * ( float * ) &i;
	y  = y * ( threehalfs - ( x2 * y * y ) );
  return y;
}

В сети полно подробных разборов этой магии, поэтому опустим теорию.

Если вкратце: блоки вычислений с плавающей запятой (FPU) в процессорах конца 90-х и близко не стояли с современными. При разработке Quake 3 Arena ребята из id Software заметили, что львиная доля процессорного времени уходит на вычисление нормалей вершин для новой модели освещения. И самое ее узкое горлышко умещалось всего в одну операцию — нахождение обратного квадратного корня.

Разработчики решили пожертвовать идеальной точностью ради скорости и применили схему «грубая оценка с последующим уточнением». Такой подход обходил наивный вариант 1.0f / sqrtf(x) с колоссальным отрывом: инструкции FSQRT и FDIV на сопроцессоре 8087 могли выполняться более 500 тактов, тогда как метод id Software обходился дешевыми целочисленными операциями и всего одной итерацией метода Ньютона.

Современные процессоры

С приходом SSE корпорация Intel добавила в архитектуру x86 инструкции rsqrtss и rsqrtps. Позже, в наборе AVX, появились векторные аналоги vrsqrtss и vrsqrtps для более широких регистров SIMD. Эти инструкции мгновенно вычисляют приблизительный обратный квадратный корень с гарантированной погрешностью и несравнимо меньшей задержкой, чем древняя fsqrt на x87. В архитектуре ARMv8/AArch64 также есть аналогичные инструкции для приблизительного вычисления:

Архитектура

Скалярные

Пакетные (SIMD)

Ширина (векторизация)

x86-64 SSE

rsqrtss

rsqrtps

4 × f32

x86-64 AVX

vrsqrtss

vrsqrtps

8 × f32

ARMv8 NEON

frsqrte (scalar)

frsqrte

4 × f32

Почему это важно?

На современном C++ этот трюк можно переписать примерно так:

constexpr float Q_rsqrt(float number) noexcept {
  static_assert(sizeof(float) == sizeof(std::uint32_t));
  auto i = std::bit_cast<std::uint32_t>(number);
  auto magic = 0x5f3759dfu - (i >> 1);
  auto y = std::bit_cast<float>(magic);
  return y * (1.5f - (number * 0.5f * y * y));
}

Сравним его с классическим наивным вариантом:

constexpr float naive_rsqrt(float x) noexcept {
    return 1.0f / std::sqrt(x);
}

Посмотрим на ассемблерный код, сгенерированный с флагами -std=c++23 -O3 -ffast-math -march=znver4 в Compiler Explorer на Clang 21.1.0. Сами тесты измерялись с опцией -march=native, но для наглядности листинга мы выбрали конкретную архитектуру znver4. Здесь принципиально важен флаг -ffast-math: он разрешает компилятору применять агрессивные оптимизации вещественной арифметики, которые могут приводить к потере точности. В данном случае компилятор считает аргументы заведомо положительными и конечными числами, пренебрегая специфическими краевыми случаями стандарта IEEE.

Q_rsqrt(float):
        movd    eax, xmm0
        sar     eax
        mov     ecx, 1597463007
        sub     ecx, eax
        mulss   xmm0, dword ptr [rip + .LCPI0_0]
        movd    xmm1, ecx
        movdqa  xmm2, xmm1
        mulss   xmm2, xmm1
        mulss   xmm0, xmm2
        addss   xmm0, dword ptr [rip + .LCPI0_1]
        mulss   xmm0, xmm1
        ret

naive_rsqrt(float):
        vrsqrtss        xmm1, xmm0, xmm0
        vmulss  xmm0, xmm0, xmm1
        vfmadd213ss     xmm0, xmm1, dword ptr [rip + .LCPI1_0]
        vmulss  xmm1, xmm1, dword ptr [rip + .LCPI1_1]
        vmulss  xmm0, xmm1, xmm0
        ret

Результаты тестирования

В теории все выглядит красиво, но что покажет практика?

Тест

Время (скалярно)

Время (n=1024)

Время (n=65536)

Q_sqrt

380 нс

24,5 нс

1865 нс

naive_rsqrt

373 нс

25,0 нс

2161 нс

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

Итог

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

❯ Подсчет единичных битов и битовые манипуляции

Стандарт C++20 принес нам замечательный заголовочный файл <bit>, подарив std::popcount, std::countl_zero, std::countr_zero, std::bit_width, std::has_single_bit и семейство сопутствующих функций. При поддержке процессором нужного набора инструкций на архитектуре x86 большинство из них компилируются в одну‑единственную команду процессора:

Функция

x86-64 (BMI/POPCNT)

ARMv8

std::popcount

popcnt

cnt + addv

std::countl_zero

lzcnt

clz

std::countr_zero

tzcnt

rbit + clz

Сравним три реализации:

constexpr int modern(std::uint64_t x) noexcept {
  return std::popcount(x);
}

constexpr int kernighan(std::uint64_t x) noexcept {
  int c = 0;
  while (x != 0U) {
    x &= x - 1U;
    ++c;
  }
  return c;
}

constexpr int swar(std::uint64_t x) noexcept {
  x = x - ((x >> 1) & 0x5555'5555'5555'5555ULL);
  x = (x & 0x3333'3333'3333'3333ULL) + ((x >> 2) & 0x3333'3333'3333'3333ULL);
  x = (x + (x >> 4)) & 0x0f0f'0f0f'0f0f'0f0fULL;
  return static_cast<int>((x * 0x0101'0101'0101'0101ULL) >> 56);
}

При сборке с флагом -march=native (если процессор физически поддерживает команду popcnt) функция modern предсказуемо превращается в одну инструкцию. Но самое любопытное здесь то, насколько поумнели компиляторы: в этой конфигурации теста и старый алгоритм Кернигана, и замудренная версия SWAR тоже превратились в команду popcnt! Без аппаратной поддержки различия, конечно, вновь всплывут на поверхность: обход по Кернигану превратится в цикл, число итераций которого зависит от данных, а SWAR станет компактной цепочкой битовых сдвигов и масок. В любом случае выбор стандартного библиотечного варианта явно заявляет компилятору о ваших намерениях.

До эпохи C++20 приходилось вызывать непереносимые функции: компиляторный интринсик __builtin_popcountll в GCC/Clang или __popcnt64 в MSVC. Теперь появился std::popcount — кроссплатформенный вариант. Если целевое железо не поддерживает аппаратную инструкцию popcnt, стандартная библиотека автоматически предоставит максимально быстрый программный аналог.

Результаты замеров

Тест

Время

BM_popcount_modern

94,5 нс

BM_popcount_kernighan

94,5 нс

BM_popcount_swar

94,5 нс

Вывод

Смело используйте функции из заголовка <bit>, за исключением редких случаев разработки под специфические микроконтроллеры, не имеющих поддержки стандартной библиотеки.

❯ «Численные методы» и массивы указателей строк

Еще одна известная аксиома гласит: доступ к матрице через массив указателей на ее строки работает быстрее, чем прямое вычисление индекса по формуле i * cols + j. В это охотно верилось в те далекие времена, когда операция умножения стоила неприлично дорого, аппаратные блоки генерации адресов были примитивными, а компиляторы не умели так виртуозно оптимизировать доступ по индексам.

Свою роль в популяризации этого подхода сыграл и знаменитый фолиант в красной обложке — книга «Численные методы на языке Си» под авторством Уильяма Пресса и др. Местные вспомогательные методы наподобие dmatrix и nrutil вовсю использовали косвенную адресацию через строки, чтобы алгоритмы в коде записывались привычным математическим синтаксисом [i][j]. Это упрощало перенос формул напрямую из языка Fortran. И хотя такое решение выглядит вполне оправданным с точки зрения удобства интерфейса, само по себе оно не дает никакого выигрыша в скорости.

Для тех, кому любопытно заглянуть под капот, в код тестов включены три разные реализации класса матрицы:

  • flat_matrix (вычисление смещения через умножение, одномерный непрерывный буфер в памяти)

  • nr_matrix (доступ через разыменование указателя строки, но данные все равно лежат в памяти непрерывно)

  • scattered_matrix (аналог nr_matrix, но данные строк разбросаны по куче — между ними намеренно выделяется «мусорная» память)

В классах nr_matrix и scattered_matrix перегружен operator[], возвращающий указатель float *. Обе структуры хранят отдельный массив указателей, каждый из которых ссылается на начало соответствующей строки, а operator[] возвращает нужный адрес по индексу.

Результаты тестов

Мы запускаем два разных сценария обхода: суммирование элементов по строкам и по столбцам.

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

Непрерывный плоский буфер и представление в духе «Численных методов» идут практически ноздря в ноздрю. А вот фрагментированный вариант с разбросанными строками безнадежно отстает, особенно при росте объемов данных, когда кеш процессора перестает справляться.

Тест

Время (400×400)

Время (4000×4000)

Сценарий построчного обхода для flat_matrix

6062 нс

987 614 нс

Сценарий построчного обхода для nr_matrix

6065 нс

987 632 нс

Сценарий построчного обхода для scattered_matrix

6171 нс

2 323 394 нс

Сценарий столбцового обхода для flat_matrix

33 148 нс

11 258 036 нс

Сценарий столбцового обхода для nr_matrix

36 418 нс

11 450 827 нс

Сценарий столбцового обхода для scattered_matrix

39 054 нс

14 800 609 нс

Итог

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

И если есть возможность, не пишите собственные алгоритмы линейной алгебры с нуля: специализированные библиотеки — будь то Eigen, BLAS/LAPACK, GLM или оптимизированные решения от производителей процессоров — почти всегда справятся с задачей на порядок лучше.

❯ Повсеместный const& против правильной передачи параметров

Еще одна стародавняя привычка, въевшаяся в подсознание: «всегда передавай аргументы по const& — копирование обходится дорого».

Это правило все еще безупречно работает в сценариях, когда функция просто читает данные, не сохраняя их у себя:

double norm(std::vector<double> const& xs);

Однако в обертках и прокси‑функциях это превращается во вредную абстракцию. Если единственная задача метода — передать аргументы куда‑то дальше, константная ссылка буквально стирает информацию о категории значения. Временный объект (rvalue) насильно превращается в константную ссылку (const lvalue), из‑за чего полностью блокируется возможность перемещения данных (move). Правила разрешения перегрузок ломаются, а истинные намерения вызывающей стороны попросту теряются.

Взглянем на классический вариант обертки в стиле метода emplace:

template <class T>
class bag {
public:
  template <class... Args>
  T& emplace(Args const&... args) {
    return xs_.emplace_back(args...);
  }

private:
  std::vector<T> xs_;
};

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

b.emplace(std::string(1024, 'x'));

внутри emplace_bad (плохой реализации) временная строка принудительно преобразуется в std::string const&. Внутренний вектор не может переиспользовать ее память через перемещение — ему приходится выполнять дорогостоящее копирование.

Современный подход сохраняет исходную категорию переданного аргумента:

template <class T>
class bag {
public:
  template <class... Args>
  T& emplace(Args&&... args) {
    return xs_.emplace_back(std::forward<Args>(args)...);
  }

private:
  std::vector<T> xs_;
};

lvalue остаются ссылками на живые объекты, rvalues — на временные, а механизм разрешения перегрузки безошибочно понимает изначальный контекст.

Эта разница хорошо прослеживается в различных интерфейсах современного C++:

// Просто читаем данные: const& подходит идеально
void draw(widget const& w);

// Забираем или сохраняем данные у себя: передача по значению с последующим std::move
void set_name(std::string name) {
  name_ = std::move(name);
}

// Перенаправляем аргументы для конструирования: универсальные ссылки (forwarding references)
template <class... Args>
auto make_widget(Args&&... args) {
  return widget(std::forward<Args>(args)...);
}

Результаты измерений

Тест

Время (n=64)

Время (n=1024)

Время (n=4096)

С использованием const &

12,2 нс

18,6 нс

72,1 нс

С использованием идеальной передачи

6,55 нс

8,74 нс

31,0 нс

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

Вывод

Разумеется, никто не призывает вас полностью отказаться от const&. Правильнее сформулировать новые привычки следующим образом:
— Используйте T const& для простого чтения существующего объекта.
— Передавайте по значению (T), если функции в любом случае нужна собственная копия, которую она затем переместит внутрь себя.
— Выбирайте T&& для явных приемников данных («стоков»).
— Применяйте универсальные ссылки (forwarding references) для написания обобщенных функций‑оберток.

В венах прежнего C++ текла более скудная семантика, и разработчики использовали const& чисто рефлекторно, за неимением альтернатив. Современный C++ обладает куда более богатым словарем. Говорите с компилятором на равных и используйте возможности системы типов, чтобы донести свои намерения без искажений.


❯ Часть 2. Стандартная библиотека

Возможности стандартной библиотеки C++ за последние годы выросли колоссально. Наверняка вы сталкивались с проектами или целыми студиями, где STL находится в немилости: ее либо запрещают использовать вовсе, либо прячут за километрами самописных оберток. Похоже, пришло время пересмотреть эти устои. Современные стандартные алгоритмы и контейнеры не только повышают читаемость кода, но и безупречно протестированы, а главное — оптимизированы под капотом так, как мало кто сможет написать вручную.

❯ Диапазоны и алгоритмы

Представим простую задачу:

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

inline double raw_loop(std::span<double const> xs) noexcept {
  double sum = 0.0;

  for (double volts : xs) {
    auto mv  = calibrated_mv(volts);
    auto err = residual(mv);
    sum += weighted_square(err);
  }

  return sum;
}

inline double algorithm_call(std::span<double const> xs) noexcept {
  return std::accumulate(
    xs.begin(),
    xs.end(),
    0.0,
    [](double acc, double volts) {
      auto mv  = calibrated_mv(volts);
      auto err = residual(mv);
      return weighted_square(err) + acc;
    });
}

inline double ranges_pipeline(std::span<double const> xs) noexcept {
  auto costs = xs
    | std::views::transform(calibrated_mv)
    | std::views::transform(residual)
    | std::views::transform(weighted_square);

  return std::ranges::fold_left(costs, 0.0, std::plus<double>{});
}

Этот пример намеренно лаконичен. Он также представляет собой идеальное поле деятельности для компиляторных оптимизаций: функции преобразования просты, прозрачны и легко инлайнятся на месте вызова. Метод std::ranges::fold_left выполняет левую свертку по всей цепочке представлений — вычисляя выражение по цепочке f(f(f(f(init, x1), x2), ...), xn). В таких условиях компилятор полностью стирает накладные расходы на абстракцию.

Результаты тестов

Тест

Время (n=1024)

Время (n=65536)

Простой цикл (raw loop)

36,0 нс

2400 нс

С использованием <algorithm>

36,0 нс

2395 нс

С использованием <ranges>

37,9 нс

2417 нс

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

Итог

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

Но будьте начеку: автор статьи заметил просадки производительности при использовании std::views::filter на связке Clang 21.1.0 и libstdc++ времен GCC 13.2 (GLIBCXX_3.4.32). Разумеется, концептуально с фильтрацией все в порядке — дело в мелких шероховатостях реализации конкретной библиотеки, которые разработчики постоянно полируют и улучшают от релиза к релизу.

❯ Исключения, std::expected либо коды ошибок

Представленный в стандарте C++23 интерфейс std::expected<T, E> предлагает суммарный тип (sum type) для обработки операций, способных завершиться сбоем. Первопроходцы настойчиво твердят: «исключения — это медленно, возвращайте коды ошибок». В реальности дела обстоят тоньше и глубже.

Давайте сравним три разных подхода к обработке ошибок:

// Сценарий 1. Использование механизма исключений для сообщения об ошибке
int parse_throws(std::string_view s) {
  int v{};
  auto const* end = s.data() + s.size();
  auto [ptr, ec] = std::from_chars(s.data(), end, v);
  if (ec != std::errc{} || ptr != end) {
      throw std::runtime_error("bad parse");
  }
  return v;
}

// Сценарий 2. Возврат std::expected с явным кодированием успеха/неудачи
std::expected<int, std::errc> parse_expected(std::string_view s) noexcept {
  int v{};
  auto const* end = s.data() + s.size();
  auto [ptr, ec] = std::from_chars(s.data(), end, v);
  if (ec != std::errc{}) return std::unexpected(ec);
  if (ptr != end) return std::unexpected(std::errc::invalid_argument);
  return v;
}

// Сценарий 3. Возврат кода ошибки через выходной параметр
int parse_errc(std::string_view s, std::errc& err) noexcept {
  int v{};
  auto const* end = s.data() + s.size();
  auto [ptr, ec] = std::from_chars(s.data(), end, v);
  if (ec != std::errc{}) {
    err = ec;
  }
  else if (ptr != end) {
    err = std::errc::invalid_argument;
  } else {
    err = std::errc{};
  }
  return (err != std::errc{}) ? 0 : v;
}

Анализ кода

Mеханизм исключений:

Реализацию современного механизма обработки исключений (в рамках конвенции Itanium ABI) часто называют нулевыми накладными расходами (zero‑cost exceptions) — пока ничего не «брошено» (throw), оплата не производится.

Таблицы раскрутки стека (unwind tables) вынесены отдельно, благодаря чему в штатном режиме процессор не тратит время на лишние проверки. При удачном стечении обстоятельств вызов parse_throws в горячем цикле после встраивания может выглядеть в ассемблере точь‑в‑точь как ручной разбор ошибок parse_errc. Однако стоит коду попасть в зону видимости блоков try‑catch или пересечь границу оптимизации, за которую компилятор заглянуть не может, как бинарник тут же обрастает компенсационными блоками (landing pads) и метаданными раскрутки. А это мешает встраиванию функций и рубит на корню другие полезные преобразования.

Класс std::expected:

Возвращаемое значение концептуально представляет собой структуру типа {данные, флаг_успеха}. Самое важное преимущество std::expected — его дружелюбность к спецификатору noexcept, что максимально облегчает работу инлайнера на стыке контекстов. Кроме того, проверка условия has_value() прекрасно укладывается в логику предсказателя переходов процессора и хорошо дружит со спекулятивным выполнением инструкций.

Обычные коды ошибок:

Схожи по своей архитектуре с std::expected, но выглядят гораздо более громоздко и неуклюже. Им требуется больше усилий со стороны компилятора, ведь передаваемый выходной параметр втягивает оптимизатор в дебри сложного анализа указателей (aliasing analysis). Но что еще опаснее — подобный подход никак не обязывает программиста проверять результат вызова на уровне семантики типа.

Какова цена обработанной ошибки?

Если ошибки в программе возникают регулярно (к примеру, при парсинге входящего HTTP‑трафика), исключения быстро превращаются в кошмар для производительности: каждый вызов throw заставляет процессор тратить уйму времени на раскрутку стека. Напротив, std::expected работает в рамках штатного потока выполнения: обычная проверка тега объединения и стандартный условный переход.

Обратите внимание, что во всех трех примерах мы проверяем и код ошибки ec, и условие ptr == end. Это критически важно, так как функция std::from_chars может прервать работу сразу после первого валидного префикса. Проверяй мы только ec, функция радостно бы проглотила некорректные строки вида "123abc".

Результаты тестов

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

  • идеальные (0% ошибок),

  • с умеренным количеством сбоев (5%)

  • и со значительным объемом битых данных (30%).

Тест

Время (0% ошибок)

Время (5% ошибок)

Время (30% ошибок)

Сценарий с исключениями

3770 нс

26 971 нс

154 762 нс

Сценарий с std::expected

3553 нс

3403 нс

2338 нс

Сценарий с std::errc через выходной параметр

3608 нс

3430 нс

2347 нс

В данном бенчмарке ветки обработки ошибок без генерации исключений практически не выполняют полезной нагрузки при сбоях. Именно поэтому время работы версий с expected и errc даже снижается по мере роста дефектных входящих данных. Относитесь к этому как к неизбежной специфике синтетического теста.

Гораздо красноречивее ведет себя вариант с исключениями: бросание исключений — это классический редкий сценарий («холодный путь»), и он обходится безумно дорого, как только начинает происходить регулярно.

Накладные расходы на std::expected и коды ошибок настолько мизерны, что на их фоне становится заметен банальный эффект экономии времени при раннем выходе из процедуры разбора невалидной строки.

Итог

Даже при скромных 5% ошибок время работы кода с исключениями взлетает на порядок:
— Используйте std::expected для штатных, прогнозируемых сбоев: при парсинге данных, поиске в коллекциях, конвертации типов или валидации пользовательского ввода.
— Оставьте исключения исключительно для по‑настоящему аварийных ситуаций: нехватки памяти, неустранимых ошибок конфигурации при старте приложения и тому подобных фатальных инцидентов.

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

❯ Виртуальный и статический полиморфизм

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

  1. Считать указатель на таблицу виртуальных методов vptr из объекта (одно чтение из памяти, обычно оседает в кэше L1).

  2. Считать указатель на конкретную функцию из самой таблицы vtable (еще одно чтение из памяти, также обычно в L1).

  3. Выполнить косвенный вызов (в зависимости от успешности работы предсказателя переходов это может отнять от 1 до 20+ тактов процессора).

На каждом из этих этапов мы рискуем нарваться на кеш‑промах (а в худшем случае — на промах буфера ассоциативной трансляции адресов, TLB). К тому же, если компилятор не найдет лазейку для проведения девиртуализации, о встраивании тела функции в место вызова можно забыть. Это автоматически блокирует и целый ворох сопутствующих низкоуровневых оптимизаций.

Альтернативным решением мог бы послужить вездесущий паттерн (CRTP), реализующий так называемый статический полиморфизм, однако он применим далеко не везде — например, вы не сможете создать гетерогенный контейнер типа std::vector из базовых классов.

Но тут на сцену выходит std::variant — суммарный тип, появившийся еще в C++17. Конструкция std::visit для переменной типа std::variant<A, B, C> отлично компилируется в обычный оператор выбора switch по активному индексу типа. Каждый возможный исход становится полностью прозрачным для компилятора, диспетчеризация сводится к обыкновенному вызову функции, и все традиционные оптимизации снова вступают в игру.

Пример

Ниже представлена усеченная реализация индивидуальных структур из файла бенчмарка:

struct shape {
  virtual ~shape() = default;
  virtual double area() const = 0;
};

struct circle final : shape { ... };

struct square final : shape { ... };

struct circle_v { ... };
struct square_v { ... };

struct shape_v : std::variant<circle_v, square_v> {
  using variant::variant;

  double area() const {
    return std::visit([](auto &&x) {return x.area();}, *this);
  }
};

Результаты измерений

Контейнер бенчмарка наполняется объектами std::vector<std::unique_ptr<shape>> и std::vector<shape_v> соответственно. Тестовые проходы последовательно вызывают методы ->area() или .area() у каждого элемента группы.

Тест

Время (n=1024)

Время (n=1000000)

С вектором указателей типа unique_ptr

3824 нс

4 330 980 нс

С вектором структур типа std::variant

142 нс

149 928 нс

Справедливости ради, данный бенчмарк не измеряет чистые затраты на диспетчеризацию «в вакууме». Было бы некорректно интерпретировать эти цифры исключительно как лобовое столкновение «виртуальный вызов против std::visit». Здесь тестируется паттерн, максимально близкий к суровым реалиям продакшена: обход std::vector<std::unique_ptr<base>> в горячем цикле, по сравнению с ориентированным на значения представлением фиксированного набора типов в одной непрерывной структуре.

В классическом ООП‑варианте коллекция хранит кучу разрозненных указателей‑владельцев — процессору приходится постоянно прыгать по адресам в heap‑куче. Вариант на базе std::variant хранит значения объектов подряд в памяти, что превращает чтение данных для кеша процессора и блоков префетчера в легкую прогулку. Таким образом, тест оценивает общую картину принятых архитектурных решений: стратегию выделения памяти, взаимное расположение объектов, локальность данных, возможности инлайнинга и сам механизм вызова. В конкретно этом тесте удачная компоновка данных в памяти дает гораздо больший выигрыш, нежели сам способ диспетчеризации.

И это не баг нашего примера, а ключевая мысль. Жалобы в стиле «виртуальные функции тормозят» редко относятся к чистому расходу времени на вызов метода по таблице. На практике в реальном коде C++ за виртуальностью всегда тянется шлейф из засилья указателей, фрагментации памяти под кучу и непрозрачных для транслятора барьеров. Если в вашей архитектуре набор типов ограничен и фиксирован, замена std::vector<std::unique_ptr<base>> на std::vector<std::variant<...>> моментально поднимет локальность данных в памяти без всякой возни с кастомными аллокаторами или пулами объектов.

Выводы

Издержки от самого механизма перенаправления вызова — сущий пустяк по сравнению с (а) физическим размещением данных в памяти и (б) видимостью кода функции для оптимизатора в момент компиляции. Традиционный виртуальный полиморфизм безупречно решает архитектурные задачи на уровне «крупного помола»: например, на границе разделяемых библиотек при динамической загрузке плагинов через dlopen. Но для мелкозернистого полиморфизма внутри горячих циклов, работающего с конечным набором типов схожих размеров, выбор std::variant на современном C++ станет однозначным выигрышем во всех отношениях.

❯ Заключение

Через все наши примеры красной нитью проходит одна и та же истина: современный компилятор щедро поощряет прямоту намерений и жестко наказывает за чрезмерную заумь. Если дать оптимизатору некоторую свободу флагом -ffast-math, классическое выражение 1.0f / std::sqrt(x) легко превращается в быструю аппаратную инструкцию с парой уточняющих тактов. Стандартный std::popcount тут же сводится к ассемблерной команде popcnt, а изящная цепочка преобразований ranges‑диапазонов безболезненно трансформируется в лаконичный и плотный векторный цикл. Чем прозрачнее написан код и чем яснее он заявляет о конечной цели, тем больше козырей оказывается в рукаве у транслятора.

Отсюда вытекает важный вывод: ваша первая и главная оптимизация должна заключаться в безжалостном удалении хитроумного кода и переписывании программы самым простым и очевидным способом. Сначала профилируйте — и только потом оптимизируйте те крошечные участки кода, в которых действительно затаился затык по скорости. Не бойтесь использовать блага цивилизации: интерфейсы std::expected и std::variant, заголовочные файлы <bit> и std::bit_cast, механизмы диапазонов и проверенные стандартные алгоритмы. За последние двадцать лет компиляторы сделали гигантский шаг вперед. Современный стандарт C++ вкупе со своей библиотекой позволяет выразить любую инженерную мысль ёмко и прямолинейно.

Если вы изо дня в день пишете на свежем C++23 (и посматриваете в сторону грядущего C++26), рассуждения этой статьи вряд ли станут для вас откровением. Но если вы возвращаетесь к плюсам после долгой паузы или придерживайтесь предыдущих стандартов — вас приятно удивит то, насколько легко теперь выжать максимальную производительность из железа, просто создавая чистый C+±код.

Словом — доверьтесь компилятору!

❯ Что еще посмотреть

Может быть интересно:
Перейти ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале 

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


  1. Sazonov
    27.07.2026 13:43

    Небольшой вопрос, а почему std::bit_cast из цпп20 вместо std::start_lifetime_as из цпп23?