Грокаем C++
9.37K subscribers
46 photos
1 video
3 files
655 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам (+ реклама) @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
​​мьютекс vs семафор
#новичкам

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

А вот про семафоры - не все. В стандарт их добавили только в С++20 в виде std::counting_semaphore и std::binary_semaphore. Да и в принципе они не так часто используются.

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

Аналогия

Мьютекс - это дверь в очень маленькую туалетную комнату. Когда она свободна, любой может в нее войти. Любой, но только один. Как только кто-то вошел, все остальные начинают выстраиваться в очередь и ждать освобождения комнаты. А освободить комнату может только тот, кто в нее вошел.

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

Для чего используется

Мьютекс - судя из названия mutual exclusion - взаимное исключение. Применяется, когда только один поток в один момент времени может получить доступ к разделяемому ресурсу.

Семафор же просто контролирует количество ресурсов и не дает уйти в минуса.

Низкоуровневое представление

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

Семафор же - атомарный счетчик с таким же механизмом ожидания и очереди. Счетчик инициализируется каким-то числом и его можно инкрементировать и декрементировать. При попытке декремента нуля поток уходит в ожидание. Если какой-то другой поток снова накрутил единичку на семафоре - поток просыпается и делает таки свой декремент.

Владелец

У мьютекса есть владелец - тот, кто захватил замок, должен его отпустить. Если каким-то образом в коде это условие нарушается - сразу ub.

У семафора же нет никаких ограничений - любой поток может накручивать и скручивать счетчик.

Примеры

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

std::mutex mtx;
std::map<std::string, int> cache;
void update_cache(const std::string& key, int value) {
std::lock_guard<std::mutex> lock(mtx);
cache[key] = value;
}


Это все конечно нужно обернуть в класс, но суть понятна и так.

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

template<typename T, size_t N>
class BoundedQueue {
std::queue<T> queue_;
std::counting_semaphore<N> empty_slots_{N};
std::counting_semaphore<N> filled_slots_{0};
std::mutex mtx_;
public:
void push(T value) {
empty_slots_.acquire();
{
std::lock_guard<std::mutex> lock(mtx_);
queue_.push(std::move(value));
}
filled_slots_.release();
}
T pop() {
filled_slots_.acquire();
T value;
{
std::lock_guard<std::mutex> lock(mtx_);
value = std::move(queue_.front());
queue_.pop();
}
empty_slots_.release();
return value;
}
};


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

Лайк, если понравилось. Да и если не понравилось, тоже ставьте.

Compare things. Stay cool.

#concurrency #cpp20 #cpp11
50👍32🔥7😁3
make_unique и приватный конструктор
#новичкам

Если вы хотите как-то по-особенному контролировать время жизни объекта, то с функциями std::make_unique и std::make_shared у вас могут быть проблемы.

struct Type {
static std::unique_ptr<Type> Create() {
return std::make_unique<Type>();
}
private:
Type() = default;
};

int main()
{
auto obj = Type::Create();
}


Этот код не скомпилируется. Все потому что для класса std::make_unique - это внешний код и ей нужен публичный конструктор для работы.

Это можно обойти, просто использовав явный вызов new:

struct Type {
static std::unique_ptr<Type> Create() {
return std::unique_ptr<Type>(new Type);
}
private:
Type() = default;
};

int main()
{
auto obj = Type::Create();
}


Но это же явный вызов new! Из всех утюгов твердят, что сырые указатели - наши враги и с ними надо вести ожесточенную войну!

Есть один вариант, как этого можно избежать(если сырые указатели вызывают у вас диарею). Давайте сделаем публичный конструктор, но сделаем один из его параметров приватным типом класса. Сделаем у подтипа приватный конструктор и добавим Type в друзья. Тогда создавать объект по-прежнему можно будет только с помощью фабричного статического метода, но разблокируется возможность использовать std::make_unique:

class Type {
class PrivateKey { // private struct of Type
PrivateKey() = default;
friend Type;
};
public:
Type(PrivateKey) {}

static std::unique_ptr<Type> Create() {
return std::make_unique<Type>(PrivateKey{});
}
};

int main() {
auto obj = Type::Create(); // OK

auto obj2 = Type(PrivateKey{}); // ERROR: PrivateKey is private
}


По сути, это та же идиома passkey из этого поста. Только здесь она раскрывается с еще одной стороны.

Спасибо комментаторам из поста про запрет создания объектов на стеке за идею для публикации!

Know your enemies. Stay cool.

#cppcore #design #memory
👍22🔥97😁1
​​WAT
#новичкам

Спасибо, ₿ Satoshic, за любезно предоставленный примерчик в рамках рубрики #ЧЗХ.

Посмотрите на этот код и скажите, что выведется на экран:

std::string_view crop_string_view(std::string_view str_view)
{
return std::string_view{str_view.begin() + 5};
}

int main()
{
const char* str = "some super mega long string";
std::string_view str_view = {str, 10};
std::cout << crop_string_view(str_view);
}


Складывается довольно уверенное ощущение, что мы берем первые 10 символов строки str и после этого отрезаем от этой подстроки первые 5 символов. И в итоге выведется "super".

Однако ваш компилятор думает иначе и выведется на самом деле вот что:

super mega long string

ЧЗХ? str_view же содержит обезанную строку! Откуда там изначальная последовательность символов?

Дело в том, что str_view конечно не содержит никакую строку. Этот объект грубо говоря лишь ссылается на оригинальную строку с ограничениями на длину, которую мы задали в конструкторе.

И конечно вполне естественно на первый взгляд подумать, что std::string_view{str_view.begin() + 5} здесь обрезается сама подстрока. Но это не так.

Конструктор string_view от одного аргумента формирует вьюху от переданного итератора на начало строки и идет дальше прям до символа конца строки. str_view.begin() и str.begin() ничем не отличаются, это фактически тот же самый указатель на начало супер длинной строки. Поэтому и остановится конструктор в конце этой строки и на консоль выведется "super mega long string".

Поэтому если вы создаете std::string_view не от строкового литерала, то указывайте в конструкторе либо длину, либо итератор на конец последовательности.

Specify your boundaries. Stay cool.

#cpp17
126👍11🔥5🥱4❤‍🔥31
​​Loop unrolling. Мотивация.
#новичкам

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

Компилятор позволяет нам думать о логике, в то время как сам заботится о перформансе.

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

Сегодня и дальше мы поговорим про одну из таких оптимизаций - loop unrolling или развертывание цикла.

В чём проблема обычного цикла?

Возьмём простейшую задачу: просуммировать элементы массива.

int sum = 0;
for (int i = 0; i < 1024; ++i) {
sum += arr[i];
}


На каждый проход цикла процессор делает:

1️⃣ Загружает arr[i]
2️⃣ Прибавляет к sum
3️⃣ Увеличивает i
4️⃣ Сравнивает i с 1024
5️⃣ Если не конец — прыгает обратно

Можно было просуммировать элементы вот так:

int sum = 0;
sum += arr[0];
sum += arr[1];
sum += arr[2];
...
sum += arr[1023];


Но мы так не делаем, у нас есть цикл, чтобы писать короче. Получается, что цикл - это в некотором роде абстракция.

Так вот шаги 3–5 — это чистые накладные расходы (overhead) на использование этой абстракции. На 1024 итерациях мы теряем 1024 сравнения и 1024 условных перехода. Все это вносит свой, да мизерный, но вклад, в просадку перфа.

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

Полностью получить все плюшки и убрать все минусюшки не получится. Но получится смешать два подхода. Будем делать цикл не по каждой итерации i, а через 4 числа:

int sum = 0;
for (int i = 0; i < 1024; i += 4) {
sum += arr[i];
sum += arr[i+1];
sum += arr[i+2];
sum += arr[i+3];
}


Теперь на 256 итерациях мы делаем те же 1024 сложения, но сравнений и переходов в 4 раза меньше.

Круто? Круто. Это и называется loop unrolling.

Плюсы:
- быстрее исполнение за счет уменьшения оверхэда
Минусы:
- Теряется красота и лаконичность кода
- Увеличивается размер бинарника за счет большего количества инструкций

Получается такой трейдоф: ускоряем код за счет красоты кода и увеличения бинаря. Классический баланс используемой памяти и скорости кода.

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

Optimize your performance. Stay cool.

#compiler #performance
👍3319🔥6🥱2
Ты такой стараешься, оптимизируешь код, а компилятор просто плюет на него и делает то, что ему вздумается..
Чертова железяка!
46😁40🔥8👍3
Особый день

Сегодня очень важный и теплый для нашей страны праздник - День Победы.

К нему можно по-разному относиться, непростая ситуация сейчас в мире. Но, на наш взгляд, это не имеет значения.

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

Любой знаковый день - повод сделать что-то. Накидываем беспроигрышный вариант.

Давайте же просто сегодня вспомним своих бабушек и дедушек и искренне поблагодарим их за то, что мы живы. Они заслужили.

С праздником, дорогие подписчики! Благодарность свернет горы.

Tip your hat to your ancestors. Stay cool.
1114❤‍🔥31🔥17👎7🤪2
​​joinable
#опытным

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

В деструкторах классов, которые в качестве поля содержат поток, часто можно найти такую конструкцию:

if (worker_.joinable())
worker_.join();


Полноценно это может выглядеть так:

class BackgroundWorker {
std::atomic<bool> stop_{false};
std::thread worker_;
public:
BackgroundWorker() {
worker_ = std::thread([this] {
while (!stop_.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// do something important
}
});
}
~BackgroundWorker() {
stop_ = true;
if (worker_.joinable())
worker_.join();
}
BackgroundWorker(const BackgroundWorker&) = delete;
BackgroundWorker& operator=(const BackgroundWorker&) = delete;
};


Зачем нужен этот joinable? Как это поток может не присоединиться? К чему может привести присоединение не joinable потока? На все эти вопросы сейчас ответим.

Проверка joinable() перед вызовом join() в деструкторе класса - это защита от ошибок, связанных с некорректным состоянием потока. Если вызвать join() для не joinable потока, будет сгенерировано исключение std::system_error, чего нам явно не хочется получить в такой важный момент.

Какой поток нельзя присоединять?


1️⃣ Созданный дефолтным конструктором. std::thread thd; - объект потока конечно есть, но никакого реального потока запущено не было. Поток должен быть успешно запущен, чтобы быть в состоянии joinable.

2️⃣ Перемещенный объект. В ту же степь. Перемещенный объект std::thread не связан больше ни с каким потоком, поэтому и находится в неприсоединяемом состоянии.

3️⃣ Ранее присоединенный объект. Если тебя уже присоединили, то ты уже не можешь бы joinable.

4️⃣ Отсоединенный. Если у объекта вызвали метод detach() и отсоединили поток исполнения от этого объекта, то присоединить его совсем не получится.

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

Но все-таки это идиоматическая конструкция, которую просто надо запомнить и использовать "на всякий случай".

В будущем вы скорее всего будете менять этот класс. И, например, добавите в апи метод Wait(), который явно дожидается завершения потока. Или вообще отсоедините поток.

Никто не застрахован от изменений в будущем, поэтому уже заранее нужно заложить безопасное разрушение объекта.

Ну или используйте C++20 std::jthread и забудьте про эту конструкцию)

Be safe. Stay cool.

#concurrency
🔥2213👍7
Одна из проблем решения задачек с литкода – отсутствие учителя

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

Но не одним литкодом едины. В свое время Яндекс создал для своих разработчиков онлайн-тренажер, а потом открыл доступ для всех желающих попрактиковаться в решении задач. Недавно в CodeRun появилась новая фича в виде AI-помощника на базе SourceCraft – Кодерун AI.

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

Чтобы попробовать, заходите в задачи на CodeRun и открывайте вкладку «Кодерун AI». Пока фича в бета-режиме, нужна авторизация, а лимит — 20 запросов в сутки.
👍17🔥75👎4
​​std::terminate
#опытным

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

Большинство из вас скорее всего знает, что вызов std::terminate приводит к завершению программы из-за какой-то ошибки.

Но как именно приводит? И почему это очень плохо? - На эти вопросы постараемся сегодня ответить.

Термин "ошибка" - слишком неоднозначный. На самом деле есть как минимум 2 категории ошибок: от которых можно восстановиться и от которых нельзя.

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

Ситуации же из второй категории намного серьезней. Вы в принципе нарушаете правила исполнения программы. Выход за границы массива - сегфолт. Слишком глубокая рекурсия - переполнение стека.

Таких ситуаций можно придумать много, и в каком-то небольшом их подмножестве вызывается функция std::terminate.

[[noreturn]] void terminate() noexcept;


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

Внутренний механизм вызова std::terminate() достаточно общий для такого рода функций. std::terminate() не выполняет всю работу сама. Вместо этого она вызывает обработчик события "уничтожения программы". Обработчик настраивается через функцию std::set_terminate и по умолчанию там стоит вызов std::abort. А это уже функция которая немедленно посылает сигнал SIGABRT текущему процессу. Стандартная реакция на этот сигнал — аварийное завершение программы.

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

Не раскручивается стек и не вызываются деструкторы ни локальных, ни глобальных объектов. Не вызываются никакие cleanup операции. Так или иначе при завершении программы ОС все равно заберет себе все ресурсы, но никакие процессы, происходящие в программе, грамотно не завершаются. Не флашатся потоки ввода-вывода и буферы, не возвращается нормальный ответ на запрос, не дообрабатываются уже готовые к обработке события. Батя ушел за хлебом и не вернулся... Ни свидетелей, ни весточки.

Поэтому очевидно, что просто не надо допускать ситуаций, которые приводят к вызову std::teminate. Их конечно много, но они вполне четко определены и их довольно просто избегать. Об этих ситуациях мы и поговорим в следующем посте.

Recover from your mistakes. Stay cool.

#cppcore
👍2713🔥5🤣1
​​std::terminate. Когда вызывается
#опытным

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

🔞 Вы бросили исключение, но не перехватили его нигде по стеку вызовов:
int main() {
throw 42; // нет catch → terminate
}


🔞 Исключение покидает noexcept функцию:
void foo() noexcept {
throw 42;
}

Частый вопрос на собесах: что будет если в деструкторе кинуть исключение? Тут без всяких раскруток стеков будет сразу terminate, потому что все деструкторы по умолчанию noexcept.

🔞 Деструктор бросает исключение во время раскрутки стека. Если вы уже такие смелые, что отменили noexcept спецификацию для деструкторов, то будьте готовы к аварийному завершению приложения. Если деструктор такого объекта вызовется во время раскрутки стека, ждите гитлер капут:
struct A {
~A() noexcept(false) { throw 1; }
};
int main() {
try {
A a;
throw 2;
} catch(...) {}
}


🔞 Из конструктора статического объекта вылетает исключение. В языке нет средств отловить такие исключения, поэтому сразу ломаемся:
struct A {
A() { throw 1; }
};
static A a;
int main() {}


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

struct A {
~A() noexcept(false) { throw 1; }
};
static A a;
int main() {}


🔞 Из функции, зарегистрированной через atexit, вылетает исключение. Если вы хотите, чтобы при штатном завершении программы выполнилась функция, бросающая исключение, то у вас специфические вкусы. Такое исключение невозможно перехватить, поэтому тут же упадем:
void boom() { throw 1; }
int main() {
std::atexit(boom); // при выходе из main вызовется boom → terminate
}


🔞 Пустой throw без активного на данный момент исключения. Что вы хотели этим сказать - непонятно, зачем разрешать такое - тоже.
int main() {
throw; // нет текущего исключения → terminate
}


🔞 Из копирующего конструктора исключения вылетает исключение. Что-то такое:
struct MyExcept {
MyExcept() = default;
MyExcept(const MyExcept&) { throw 1; }
};

int main() {
try {
MyExcept e;
throw e;
} catch (MyExcept) {
}
}


Тут объект исключения после создания копируется во внутренний объект исключения, который будет переноситься механизмом раскрутки стека. Еще до раскрутки стека и вызова обработчика. В этом случае как бы произошла ошибка при выбросе исключения и это вполне аварийная ситуация, заканчивающаяся terminate. Ситуация странная, последствия вполне понятны.

🔞 Потоковая функция завершается исключением. Если исключение вылетает из последнего фрейма стека любого потока, вызывается std::terminate.
void thread_func() { throw 42; }

int main() {
std::thread t(thread_func);
t.join();
}

🔞 Вызвался деструктор или приваивание перемещением у joinable потока. Его не присоединили и не отсоединили и хотят уничтожить. А он уничтожил их:
int main() {
std::thread t([]{});
// деструктор t вызывается без join/detach → terminate
}


🔞 Вы мануально вызвали std::terminate. Хотите явно аварийно грохнуть программу и не плясать с бубнами, вам это дозволено:
int main() {
std::terminate();
}


Есть еще пару кейсов с потоковыми токенами остановки и с новой библиотекой std::execution, но это экзотика.

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

Terminate your enemies. Stay cool.
👍2012🔥6👏1
​​WAT
#опытным

Сколько будет х/х?

Как ни странно, на этот вопрос есть множество ответов.

Математический ответ: при x != 0 выражение вырождается в единицу. Если же x = 0, то значение выражения неопределено. x=0 приводит к неопределенности вида 0/0, у которого нет корректного значения.

Какой же ответ у С++?

#include <iostream>

int main() {
int x = 0;
std::cout << x/x << std::endl;
}


Ответ будет 1!

На те вот ссылочку на годболт, если не верите.

#ЧЗХ? Компьютер считать чтоли не умеет?

По ссылочке на самом деле только вывод gcc показан. И он действительно выдает 1.

Но на кланге при запуске программа получает сигнал SIGFPE, а на msvc выдается вот такая цифирь 3221225620.

Понятное дело, что деление на 0 - это UB, и это явно видно по результатам запуска на разных компиляторах.

Если вы думали, что проблема проявляется только оптимизациях - нет. Даже на -O0 все равно gcc единичку выдает.

Тогда как могла появиться единичка?

С какой-то стороны этот вопрос не имеет смысла, потому что при UB компилятор волен поступать, как ему вздумается. Но этот конкретный пример показывает одну из популярных моделей поведения компилятора при UB.

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

И компилятор это учитывает. Если UB - это плохо и программист старается его не допускать в своем коде, значит можно считать, что UB в коде не будет. И на основе этого знания компилировать программу.

UB в данном случае произойдет только при x=0. Тогда компилятор просто выкидывает этот вариант из анализа, притворяется, что такого не может быть. Тогда выражение x/x действительно становится равным единице.

Компилятор применил константную свёртку (constant folding) — одно из базовых преобразований, которое работает даже на нулевом уровне оптимизации. Он увидел выражение x / x, где x — целочисленная переменная, и, как мы все делали в школе, сократил числитель и знаменатель. В итоге на консоль вывелась единичка.

Чтобы компилятор поступал максимально честно и ничего не смог сделать с выражением, надо пометить переменную volatile.

volatile int x = 0;
std::cout << x/x << std::endl;


Это ключевое слово запрещает компилятору делать предположения о значении переменной и заставляет чего честно читать переменную из памяти.

Do not trust programmers. Stay cool.

#compiler
🔥2414👍9
​​Loop unrolling. Хвосты
#новичкам

Предыдущий пост тут.

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

Удобненько получилось, что в прошлом посте число итераций цикла делилось на 4. 1024 % 4 = 0.

int sum = 0;
for (int i = 0; i < 1024; i += 4) {
sum += arr[i];
sum += arr[i+1];
sum += arr[i+2];
sum += arr[i+3];
}


Но как разворачивать циклы, если число итераций не делится нацело на фактор развертывания(количество повторов операции в развертке)?

Да тем же циклом. Обрабатываем целую часть развернутым циклом и потом дорабатываем остаток:

int sum = 0;
int i = 0;
int unroll_factor = 4;

int main_limit = n - n % unroll_factor;
for (; i < main_limit; i += unroll_factor) {
sum += arr[i];
sum += arr[i + 1];
sum += arr[i + 2];
sum += arr[i + 3];
}
// HERE
for (; i < n; ++i) {
sum += arr[i];
}


Последний цикл - это и есть дообработка хвоста.

И обработка хвостов - это важная штука, которую нельзя забывать хоть при разворачивании цикла, хоть при векторизации вычислений, хоть при распараллеливании вычислений.

Чем вообще плох хвост? Мы заранее не знаем его размер и поэтому вынуждены использовать привычный неразвернутый цикл для его обработки. Это опять же приносит с собой недостатки обычных циклов.

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

И есть несколько техник, которые позволяют сэмулировать нечто подобное. О них мы поговорим в следующие разы, они отдельных постов заслуживают.

See the job through. Stay cool.

#performance
👍209🔥6
​​Loop unrolling. Быстрая обработка хвостов
#новичкам

В прошлый раз мы пришли к важной мысли: нам нужно обрабатывать хвосты развернутых циклов и делать это быстро.

В С++ коде пока непонятно, как это сделать. Но в ассемблере есть довольно старая техника для этого.

Возьмем С++ код:

void output_squares(size_t count, size_t *output) {
for (size_t i = 0; i < count; ++i) {
*output++ = i * i;
}
}


В целом, мы хотим, чтобы хвосты обрабатывались примерно так:

#include <cstddef>

void output_squares(size_t count, size_t *output) {
size_t i = 0;
size_t remainder = count % 4;

// Эта часть назвается пролог
if (remainder == 3) {
output[2] = 2 * 2;
output[1] = 1 * 1;
output[0] = 0 * 0;
i = 3;
} else if (remainder == 2) {
output[1] = 1 * 1;
output[0] = 0 * 0;
i = 2;
} else if (remainder == 1) {
output[0] = 0 * 0;
i = 1;
}

for (; i < count; i += 4) {
output[i] = i * i;
output[i + 1] = (i + 1) * (i + 1);
output[i + 2] = (i + 2) * (i + 2);
output[i + 3] = (i + 3) * (i + 3);
}
}


Только без кучи повторяющегося кода, количество которого будет только увеличиваться с увеличением фактора разворачивания цикла.

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

void output_squares(size_t count, size_t *output) {
size_t i = 0;
size_t r = count % 4;

switch (r) {
case 3: goto rest3;
case 2: goto rest2;
case 1: goto rest1;
default: goto main_loop;
}

rest3:
output[i] = i * i; ++i;
rest2:
output[i] = i * i; ++i;
rest1:
output[i] = i * i; ++i;

main_loop:
for (; i < count; i += 4) {
output[i] = i * i;
output[i + 1] = (i + 1) * (i + 1);
output[i + 2] = (i + 2) * (i + 2);
output[i + 3] = (i + 3) * (i + 3);
}
}


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

Зато эту лапшу можно спрятать в скомпилированном ассемблере, который вряд ли кто-то будет читать. Компилятор превращает исходный С++ код из начала поста в ассемблер примерно такого же вида, как последний сниппет. Вот примерчик на годболте как это выглядит в оригинале.

Hide your impurities deep. Stay cool.

#performance #compiler
🔥129👍7😁2🤯1
​​Loop unrolling. Duff's device
#опытным

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

Если приглядеться в механику этого кода

switch (r) {
case 3: goto rest3;
case 2: goto rest2;
case 1: goto rest1;
default: goto main_loop;
}

rest3:
output[i] = i * i; ++i;
rest2:
output[i] = i * i; ++i;
rest1:
output[i] = i * i; ++i;

main_loop:
for (; i < count; i += 4) {
output[i] = i * i;
output[i + 1] = (i + 1) * (i + 1);
output[i + 2] = (i + 2) * (i + 2);
output[i + 3] = (i + 3) * (i + 3);
}


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

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

Давайте сделаем трах-тебедох и представим код в другом виде:

if (count == 0) return;
size_t i = 0;
size_t n = (count + 3) / 4;
switch (count % 4) {
case 0:
do {
output[i] = i * i; i++;
case 3:
output[i] = i * i; i++;
case 2:
output[i] = i * i; i++;
case 1:
output[i] = i * i; i++;
} while (--n > 0);
}


Как это работает

👉🏿 n – число полных итераций развёрнутого цикла, необходимых для обработки всех элементов (с учётом возможного неполного первого прохода).

👉🏿 switch (count % 4) выбирает точку входа в тело цикла do-while в зависимости от остатка. При остатке 0 начинаем с начала (полная итерация), при остатке 3 – с третьей операции и т.д.

👉🏿 Тело цикла содержит четыре одинаковых операции (вычисление квадрата и сдвиг индекса), размеченных метками case. За счёт проваливания (fall-through) первая (неполная) итерация выполняет ровно столько операций, сколько нужно для обработки хвоста, а все последующие итерации – по четыре операции.

👉🏿 Цикл продолжается, пока --n &gt; 0, что гарантирует обработку всех элементов.

Первым такую особенность заметил мисье Том Дафф. В тот момент он работал в Lucasfilm и пытался оптимизировать копирование данных в memory-mapped регистр для анимации реального времени. Ему нужно было развернуть цикл для скорости, но была проблема — количество итераций не всегда кратно размеру развёртки.

Он вдохновился приемами из ассемблера и написал на языке K&R С(одна из ранних версий С, до стандартизации) вот такую конструкцию:

send(to, from, count)
register short *to, *from;
register count;
{
// Begin of the function
register n = (count + 7) / 8;
switch (count % 8) {
case 0: do { *to = *from++;
case 7: *to = *from++;
case 6: *to = *from++;
case 5: *to = *from++;
case 4: *to = *from++;
case 3: *to = *from++;
case 2: *to = *from++;
case 1: *to = *from++;
} while (--n > 0);
}
}


Функция send - это такой аналог memcpy, до внедрения в стандарт этой функции. Идея такая же: прыгаем в середину свитча для обработки остатка, а дальше крутимся в развернутом while, пока не скопируем все элементы.

С тех пор код такого вида называется Duff's device или устройство Даффа.

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

Но в современном мире не стоит даже и пытаться нечто такое написать в проде.Компиляторы cами отлично разворачивают циклы, часто лучше, чем ручная оптимизация.

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

Тем не менее Duff's device остался в истории как пример вырвиглазной эксплуатации тонкостей языка в гонке за производительностью.

Be relevant. Stay cool.

#compiler #optimization #performance
24👍11🔥5🤯2❤‍🔥1👏1
​​Loop unrolling. pragma unroll
#опытным

Компилятор конечно умный и сам умеет разворачивать циклы. Но делает он это не во всех случаях. Когда-то уровень оптимизации не позволяет(агрессивная развертка делается на O3) или количество итераций цикла неизвестно.

Если же вы хотите вручную контролировать развертку, но не писать код развертки руками - для вас есть один вариант. Это #pragma unroll.

Правда, есть загвоздка. Стандартного синтаксиса этой директивы не существует, для каждого компилятора чуть свой синтаксис:

-👉🏿GCC и Clang: Используется #pragma GCC unroll Для Clang существует более детализированный синтаксис #pragma clang loop unroll.

👉🏿 MSVC (Microsoft Visual C++): здесь все, как не у всех: в msvc нет прямого аналога директивы #pragma unroll. Есть #pragma loop, но она контролирует скорее автовекторизацию, чем развертку.

👉🏿 Intel и NVIDIA компиляторы: Используют #pragma unroll.

👉🏿 ARM компилятор: Поддерживает #pragma unroll.

Раз у всех все свое, то в примерах будет использовать некую усредненную версию.

По сути есть 2 возможных варианта использования директивы:

🙈 Полная развертка

#pragma unroll
for (int i = 0; i < 12; ++i) {
p1[i] += p2[i] * 2;
}


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

Плюс чем больше итераций, тем сильнее распухает код бинаря.

Поэтому в данном виде директива встречается не так часто.

☃️ Частичная развертка

Это именно та история, которую мы обсуждаем всю серию статей. #pragma unroll N позволяет развернуть цикл так, чтобы за одну итерацию тело цикла выполнялось N раз.

#pragma GCC unroll 4
for (size_t i = 0; i < n; ++i) {
sum += arr[i];
}


Как прагма работает?

1️⃣ Препроцессор оставляет #pragma unroll после препроцессинга в единице трансляции для того, чтобы компилятор смог ее учесть.

2️⃣ На этапе семантического анализа компилятор встречает #pragma unroll и связывает его со следующим за ним циклом.

3️⃣ Специальная сущность в компиляторе, отвечающая за развертку циклов, слушает и повинуется прагме и делает столько операций в цикле, сколько сказал разработчик. Плюс добавляет обработку хвоста.

Здесь можно посмотреть примерчики с gcc-шной версией прагмы.

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

Плюс обязательно делайте перфоманс тестирование, чтобы выявить действительно лучший по производительности вариант.

Measure your performance. Stay cool.

#compiler #optimization #performance
21👍10🔥3😁2👏1
Сколько раз можно разыменовать лямбду?
#опытным

Давайте для начала поговорим о том, сколько раз можно разыменовать указатель?

Зависит от того, какой указатель мы имеем ввиду.

Тип int* можно разыменовать всего один раз. Получившийся объект после разыменования будет lvalue int, для которого отсутствует перегрузка operator*:

int i = 5;
int * p = &i;
std::cout << **p << std::endl; // error: indirection requires pointer operand ('int' invalid)


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

int i = 5;
int * p = &i;
int ** p1 = &p;
std::cout << **p1 << std::endl; // OK
std::cout << ***p1 << std::endl; // Error


Сколько же раз можно разыменовывать лямбду?

Так стоп. Как в лямбде можно применять оператор?

Лямбда без захвата неявно кастится к указателю на функцию. А указатель можно разыменовывать.

Так сколько?

Бесконечно

#include <iostream>

int main() {
auto ret = *****************************************[]{ return 23; };
std::cout << ret() << std::endl;
}


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

#ЧЗХ?

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

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

Поэтому можем применить оператор еще разик.

А потом еще и еще. И еще, и еще, и еще, и еще... Ну вы поняли.

В любом случае ret по итогам того же неявного преобразования будет иметь тип указателя на фукнцию:

static_assert(std::is_same_v<decltype(ret), int(*)()>);


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

Спасибо, @Ivaneo, за любезно предоставленный примерчик.

Be limited only by your imagination. Stay cool.

#cppcore
17👍12😁6🔥2🤯1