Просто не копируй это
То, о чем я собираюсь рассказать в статье настолько тривиально, что любой, даже начинающий, разработчик уже это знает — я правда очень на это надеюсь. Тем не менее, приходящий не ревью код, показывает, что люди как делали, так и продолжают делать что-то подобное:
bool LoadAnimation(str::string filename);
void DrawLines(std::vector path);
Matrix RotateObject(Matrix m, Angle angle);
int DrawSprite(Sprite sprite);
Что общего у этих функций? Аргумент по значению. И каждый раз, когда вызывается подобный функционал в коде, создается копия входных данных в своем временном контексте и передается внутрь функции. И можно еще простить редко вызываемый код, вроде загрузки анимаций или понадеяться на компилятор, что сработают оптимизации и он уничтожит копирование данных, но как назло, чаще всего такой подход к разработке уничтожает только перформанс и фпсы.
К любым оптимизациям надо подходить только! после анализа их в профайлере, копии как оказалось могут и не быть дорогой операцией. Это например зависит от размера объекта, так компилятор отлично справляется с передачей по значению объектов до 32 байт, расходы конечно есть, но они очень незначительные и не ловятся на бенчмарках. Вендор может накрутить «чего-то такого в платформу и компилятор», что копирование 32 кб из специальных облатей памяти будет быстрее чем сложение пары чисел. А в самой игре оптимизация «горячего кода», будем говорить честно, часто является не самой большой проблемой общей производительности. Но вот динамическое выделении памяти может преподнести немало сюрпризов, особенно при бездумном применении.
Но даже если накладные расходы небольшие, есть ли смысл тратить процессорные циклы, когда можно этого избежать? Вот эти «потерянные 2–3%» размазанного перфа, которые даже в профайлере не светятся, очень трудно потом ловить, и еще труднее чинить.
Скрытая алокация на строках
#include
#include
size_t PassStringByValueImpl(std::string str) {
return std::accumulate(str.begin(), str.end(), 0, [] (size_t v, char a) {
return (v += (a == ' ') ? 1 : 0);
});
}
size_t PassStringByRefImpl(const std::string& str) {
return std::accumulate(str.begin(), str.end(), 0, [] (size_t v, char a) {
return (v += (a == ' ') ? 1 : 0);
});
}
const std::string LONG_STR("a long string that can't use Small String Optimization");
void PassStringByValue(benchmark::State& state) {
for (auto _ : state) {
size_t n = PassStringByValueImpl(LONG_STR);
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(PassStringByValue);
void PassStringByRef(benchmark::State& state) {
for (auto _ : state) {
size_t n = PassStringByRefImpl(LONG_STR);
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(PassStringByRef);
void PassStringByNone(benchmark::State& state) {
for (auto _ : state) {
size_t n = 0;
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(PassStringByNone);
QuickBench: https://quick-bench.com/q/f6sBUE7FdwLdsU47G26yPOnnViY
Скрытая алокация на массивах
size_t SumValueImpl(std::vector vect)
{
size_t sum = 0;
for(unsigned val: vect) { sum += val; }
return sum;
}
size_t SumRefImpl(const std::vector& vect)
{
size_t sum = 0;
for(unsigned val: vect) { sum += val; }
return sum;
}
const std::vector vect_in = { 1, 2, 3, 4, 5 };
void PassVectorByValue(benchmark::State& state) {
for (auto _ : state) {
size_t n = SumValueImpl(vect_in);
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(PassVectorByValue);
void PassVectorByRef(benchmark::State& state) {
for (auto _ : state) {
size_t n = SumRefImpl(vect_in);
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(PassVectorByRef);
void PassVectorByNone(benchmark::State& state) {
for (auto _ : state) {
size_t n = 0;
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(PassVectorByNone);
QuickBench: https://quick-bench.com/q/GU68xgT0r97eYaCKxMzm9bXJei4
Компилятор все равно умнее
В случаях если копируемый объект, занимает размер меньше пары тройки десятков байт мы вообще не заметим никакой разницы от передачи его по ссылке, вплоть до того, что сгенерированный ассемблерный код будет «почти одинаковым».
Копирование есть, но не влияет
struct Vector{
double x;
double y;
double z;
};
double DotProductObjectImpl(Vector a, Vector p){
return (a.x*p.x + a.y*p.y + a.z*p.z);
}
double DotProductRefImpl(const Vector& a, const Vector& p){
return (a.x*p.x + a.y*p.y + a.z*p.z);
}
void DotProductObject(benchmark::State& state) {
Vector in = {1,2,3};
for (auto _ : state) {
double dp = DotProductObjectImpl(in, in);
benchmark::DoNotOptimize(dp);
}
}
BENCHMARK(DotProductObject);
void DotProductRef(benchmark::State& state) {
Vector in = {1,2,3};
for (auto _ : state) {
double dp = DotProductObjectImpl(in, in);
benchmark::DoNotOptimize(dp);
}
}
BENCHMARK(DotProductRef);
void DotProductNone(benchmark::State& state) {
for (auto _ : state) {
size_t n = 0;
benchmark::DoNotOptimize(n);
}
}
BENCHMARK(DotProductNone);
QuickBench: https://quick-bench.com/q/drlH-a9o4ejvWP87neq7KAyyA8o
В этом примере нам конечно известен размер структуры, да и пример очень простой. С другой стороны, если передача по ссылке явно работает не медленнее таковой по значению, использование const&
будет «such best as we can». А передача примитивных типов по const&
и вообще ни на что не влияет при компиляции с флагом /Ox
А раз нет никаких преимуществ, писать что-то подобное const int &i
лишено смысла, но некоторые всеже пишут.
Reserve my vector
Массивы имеют огромное преимущество по сравнению с другими структурами данных, которое зачастую перекрывает любые удобства других контейнеров: их элементы следуют в памяти один за другим. Можно долго обсуждать влияние применяемого алгоритма на время работы и как это может повлиять на производительность, но ничего быстрее кешлиний процессора у нас нет, и чем больше элементов лежит в кеше тем быстрее будет работать самый банальный перебор. Любое обращение за пределы L1 кеша сразу кратно увеличивает время работы.
Но работая с векторами (динамическими массивами) многие забывают, или не помнят, что там под капотом. А там если закончилось выделенное место, а оно было например выделено для 1 (одного) элемента, то:
Выделяется новый блок памяти, который больше.
Копируются все элементы, которые были сохранены в новый блок.
Удаляется старый блок памяти
Все эти операции затратные, очень быстрые, но все равно затратные. И происходят они под капотом и не видны:
— если не лазить смотреть код стандартной библиотеки
— не смотреть профайлером алокации
— доверять коду, который написан вендором (хотя тут придется принять вещи как они есть)
Use reserve, Luke
static void NoReserve(benchmark::State& state)
{
for (auto _ : state) {
// create a vector and add 100 elements
std::vector v;
for(size_t i=0; i<100; i++){ v.push_back(i); }
benchmark::DoNotOptimize(v);
}
}
BENCHMARK(NoReserve);
static void WithReserve(benchmark::State& state)
{
for (auto _ : state) {
// create a vector and add 100 elements, but reserve first
std::vector v;
v.reserve(100);
for(size_t i=0; i<100; i++){ v.push_back(i); }
benchmark::DoNotOptimize(v);
}
}
BENCHMARK(WithReserve);
static void CycleNone(benchmark::State& state) {
// create the vector only once
std::vector v;
for (auto _ : state) {
benchmark::DoNotOptimize(v);
}
}
BENCHMARK(CycleNone);
QuickBench: https://quick-bench.com/q/OuiFp3VOZKNKaAZgM_0DkJxRock
Ну и напоследок пример того, как можно убить перф и получить просадку до 10 FPS на ровном месте, просто при движении мышкой по игровому полю. Движок называть не буду, баг уже пофиксили. Найдете ошибку, пишите в коменты :)
bool findPath(Vector2 start, Vector2 finish) {
...
while (toVisit.empty() == false)
{
...
if (result == OBSTACLE_OBJECT_IN_WAY)
{
...
const std::vector directions{ {1.f, 0.f}, {-1.f, 0.f}, {0.f, 1.f}, {0.f, -1.f} };
for (const auto& dir : directions)
{
auto nextPos = currentPosition + dir;
if (visited.find(nextPos) == visited.end())
{
toVisit.push({ nextPos, Vector2::DistanceSq(center, nextPos) });
}
}
}
}
}