мьютекс vs семафор
#новичкам
Мьютексы довольно часто используют при разработке потокобезопасных модулей. Хоть их использование в высокопроизводительных приложениях нежелательно, они все равно являются базовым выбором для реализации наивной потокобезопасности. Более менее все про них знают.
А вот про семафоры - не все. В стандарт их добавили только в С++20 в виде std::counting_semaphore и std::binary_semaphore. Да и в принципе они не так часто используются.
Однако мьютексы и семафоры довольно похожи по внутренней реализации, хоть и довольно сильно отличаются по кейсам применения. Поэтому есть смысл их сравнить side-by-side, чтобы наглядно видеть все похожести и отличия.
Аналогия
Мьютекс - это дверь в очень маленькую туалетную комнату. Когда она свободна, любой может в нее войти. Любой, но только один. Как только кто-то вошел, все остальные начинают выстраиваться в очередь и ждать освобождения комнаты. А освободить комнату может только тот, кто в нее вошел.
Семафор - это турникет для автопарковки. У парковки есть определенное количество машин, которое на ней может разместиться. Больше не получится - места не будет, поэтому и турникет не пропустит. И машины опять выстраиваются в очередь. И только когда одна машина освободила место с парковки, одна новая может на нее заехать.
Для чего используется
Мьютекс - судя из названия mutual exclusion - взаимное исключение. Применяется, когда только один поток в один момент времени может получить доступ к разделяемому ресурсу.
Семафор же просто контролирует количество ресурсов и не дает уйти в минуса.
Низкоуровневое представление
Можно представить мьютекс как атомарный флаг, к которому прикручен механизм ожидания и очереди. Флаг можно выставить вверх и тогда попытка снова выставить его вверх карается блокировкой этого потока. Флаг можно опустить и тогда его может снова поднять любой желающий поток, либо тот, кто был первый в очереди на ожидание.
Семафор же - атомарный счетчик с таким же механизмом ожидания и очереди. Счетчик инициализируется каким-то числом и его можно инкрементировать и декрементировать. При попытке декремента нуля поток уходит в ожидание. Если какой-то другой поток снова накрутил единичку на семафоре - поток просыпается и делает таки свой декремент.
Владелец
У мьютекса есть владелец - тот, кто захватил замок, должен его отпустить. Если каким-то образом в коде это условие нарушается - сразу ub.
У семафора же нет никаких ограничений - любой поток может накручивать и скручивать счетчик.
Примеры
Мьютекс нужен для ограничения доступа потоков к критической секции. Например у вас есть какой-то кэш и вы хотите его потокобезопасно обновить:
Это все конечно нужно обернуть в класс, но суть понятна и так.
Семафор же нужен для ограничения количество одновременно используемых ресурсов.
Например, ограниченная потокобезопасная очередь:
Здесь семафоры нужны для того, чтобы в очередь не положили больше элементов, чем нужно, и не забрали больше, чем в очереди может потенциально быть. Обратите внимание на порядок инкремента и декремента.
Лайк, если понравилось. Да и если не понравилось, тоже ставьте.
Compare things. Stay cool.
#concurrency #cpp20 #cpp11
#новичкам
Мьютексы довольно часто используют при разработке потокобезопасных модулей. Хоть их использование в высокопроизводительных приложениях нежелательно, они все равно являются базовым выбором для реализации наивной потокобезопасности. Более менее все про них знают.
А вот про семафоры - не все. В стандарт их добавили только в С++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 у вас могут быть проблемы.
Этот код не скомпилируется. Все потому что для класса std::make_unique - это внешний код и ей нужен публичный конструктор для работы.
Это можно обойти, просто использовав явный вызов new:
Но это же явный вызов new! Из всех утюгов твердят, что сырые указатели - наши враги и с ними надо вести ожесточенную войну!
Есть один вариант, как этого можно избежать(если сырые указатели вызывают у вас диарею). Давайте сделаем публичный конструктор, но сделаем один из его параметров приватным типом класса. Сделаем у подтипа приватный конструктор и добавим Type в друзья. Тогда создавать объект по-прежнему можно будет только с помощью фабричного статического метода, но разблокируется возможность использовать std::make_unique:
По сути, это та же идиома passkey из этого поста. Только здесь она раскрывается с еще одной стороны.
Спасибо комментаторам из поста про запрет создания объектов на стеке за идею для публикации!
Know your enemies. Stay cool.
#cppcore #design #memory
#новичкам
Если вы хотите как-то по-особенному контролировать время жизни объекта, то с функциями 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🔥9❤7😁1
WAT
#новичкам
Спасибо, ₿ Satoshic, за любезно предоставленный примерчик в рамках рубрики #ЧЗХ.
Посмотрите на этот код и скажите, что выведется на экран:
Складывается довольно уверенное ощущение, что мы берем первые 10 символов строки str и после этого отрезаем от этой подстроки первые 5 символов. И в итоге выведется "super".
Однако ваш компилятор думает иначе и выведется на самом деле вот что:
ЧЗХ? str_view же содержит обезанную строку! Откуда там изначальная последовательность символов?
Дело в том, что str_view конечно не содержит никакую строку. Этот объект грубо говоря лишь ссылается на оригинальную строку с ограничениями на длину, которую мы задали в конструкторе.
И конечно вполне естественно на первый взгляд подумать, что
Конструктор string_view от одного аргумента формирует вьюху от переданного итератора на начало строки и идет дальше прям до символа конца строки. str_view.begin() и str.begin() ничем не отличаются, это фактически тот же самый указатель на начало супер длинной строки. Поэтому и остановится конструктор в конце этой строки и на консоль выведется "super mega long string".
Поэтому если вы создаете std::string_view не от строкового литерала, то указывайте в конструкторе либо длину, либо итератор на конец последовательности.
Specify your boundaries. Stay cool.
#cpp17
#новичкам
Спасибо, ₿ 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
1❤26👍11🔥5🥱4❤🔥3⚡1
Loop unrolling. Мотивация.
#новичкам
Современные компиляторы - это чудо-расчудесное. В целом, это такая шайтан-машина, в которую запихивается С++ код, который зачастую пахнет как коровья лепеха, а на выходе получается пушка-бомба-ракета. Базово ракета будет лететь довольно быстро. Иногда она может взрываться по середине пути, но не суть.
Компилятор позволяет нам думать о логике, в то время как сам заботится о перформансе.
На каком-то уровне можно без серьезных последствий поддерживать в голове образ шайтан-машины. Но чтобы расти дальше и выше, нужно понимать по крайней мере некоторые механики его работы и оптимизации, которые он совершает с нашим кодом.
Сегодня и дальше мы поговорим про одну из таких оптимизаций - loop unrolling или развертывание цикла.
В чём проблема обычного цикла?
Возьмём простейшую задачу: просуммировать элементы массива.
На каждый проход цикла процессор делает:
1️⃣ Загружает
2️⃣ Прибавляет к
3️⃣ Увеличивает
4️⃣ Сравнивает
5️⃣ Если не конец — прыгает обратно
Можно было просуммировать элементы вот так:
Но мы так не делаем, у нас есть цикл, чтобы писать короче. Получается, что цикл - это в некотором роде абстракция.
Так вот шаги 3–5 — это чистые накладные расходы (overhead) на использование этой абстракции. На 1024 итерациях мы теряем 1024 сравнения и 1024 условных перехода. Все это вносит свой, да мизерный, но вклад, в просадку перфа.
Было бы прикольно совместить два подхода, чтобы и абстракцию использовать и не терять тики процессора на ветвление.
Полностью получить все плюшки и убрать все минусюшки не получится. Но получится смешать два подхода. Будем делать цикл не по каждой итерации
Теперь на 256 итерациях мы делаем те же 1024 сложения, но сравнений и переходов в 4 раза меньше.
Круто? Круто. Это и называется loop unrolling.
Плюсы:
- быстрее исполнение за счет уменьшения оверхэда
Минусы:
- Теряется красота и лаконичность кода
- Увеличивается размер бинарника за счет большего количества инструкций
Получается такой трейдоф: ускоряем код за счет красоты кода и увеличения бинаря. Классический баланс используемой памяти и скорости кода.
Хорошие новости в том, что скорее все такую оптимизацию может провернуть компилятор за вас без вашего участия. Но это уже детали, которые будем обсуждать в следующих постах серии.
Optimize your performance. Stay cool.
#compiler #performance
#новичкам
Современные компиляторы - это чудо-расчудесное. В целом, это такая шайтан-машина, в которую запихивается С++ код, который зачастую пахнет как коровья лепеха, а на выходе получается пушка-бомба-ракета. Базово ракета будет лететь довольно быстро. Иногда она может взрываться по середине пути, но не суть.
Компилятор позволяет нам думать о логике, в то время как сам заботится о перформансе.
На каком-то уровне можно без серьезных последствий поддерживать в голове образ шайтан-машины. Но чтобы расти дальше и выше, нужно понимать по крайней мере некоторые механики его работы и оптимизации, которые он совершает с нашим кодом.
Сегодня и дальше мы поговорим про одну из таких оптимизаций - loop unrolling или развертывание цикла.
В чём проблема обычного цикла?
Возьмём простейшую задачу: просуммировать элементы массива.
int sum = 0;
for (int i = 0; i < 1024; ++i) {
sum += arr[i];
}
На каждый проход цикла процессор делает:
1️⃣ Загружает
arr[i]2️⃣ Прибавляет к
sum3️⃣ Увеличивает
i4️⃣ Сравнивает
i с 10245️⃣ Если не конец — прыгает обратно
Можно было просуммировать элементы вот так:
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
👍33❤19🔥6🥱2
Ты такой стараешься, оптимизируешь код, а компилятор просто плюет на него и делает то, что ему вздумается..
Чертова железяка!
Чертова железяка!
❤46😁40🔥8👍3
Особый день
Сегодня очень важный и теплый для нашей страны праздник - День Победы.
К нему можно по-разному относиться, непростая ситуация сейчас в мире. Но, на наш взгляд, это не имеет значения.
Значение имеет то, что конкретно наши с вами предки, конкретные люди сделали очень много для того, чтобы мы с вами просто жили.
Любой знаковый день - повод сделать что-то. Накидываем беспроигрышный вариант.
Давайте же просто сегодня вспомним своих бабушек и дедушек и искренне поблагодарим их за то, что мы живы. Они заслужили.
С праздником, дорогие подписчики! Благодарность свернет горы.
Tip your hat to your ancestors. Stay cool.
Сегодня очень важный и теплый для нашей страны праздник - День Победы.
К нему можно по-разному относиться, непростая ситуация сейчас в мире. Но, на наш взгляд, это не имеет значения.
Значение имеет то, что конкретно наши с вами предки, конкретные люди сделали очень много для того, чтобы мы с вами просто жили.
Любой знаковый день - повод сделать что-то. Накидываем беспроигрышный вариант.
Давайте же просто сегодня вспомним своих бабушек и дедушек и искренне поблагодарим их за то, что мы живы. Они заслужили.
С праздником, дорогие подписчики! Благодарность свернет горы.
Tip your hat to your ancestors. Stay cool.
1❤114❤🔥31🔥17👎7🤪2
joinable
#опытным
Не часто мы в коде сами создаем потоки. Но когда это происходит, то начинаются интересности буквально везде и повсюду. Опустим гонки данных и прочие проблемы и обсудим вот что.
В деструкторах классов, которые в качестве поля содержат поток, часто можно найти такую конструкцию:
Полноценно это может выглядеть так:
Зачем нужен этот joinable? Как это поток может не присоединиться? К чему может привести присоединение не joinable потока? На все эти вопросы сейчас ответим.
Проверка
Какой поток нельзя присоединять?
1️⃣ Созданный дефолтным конструктором.
2️⃣ Перемещенный объект. В ту же степь. Перемещенный объект std::thread не связан больше ни с каким потоком, поэтому и находится в неприсоединяемом состоянии.
3️⃣ Ранее присоединенный объект. Если тебя уже присоединили, то ты уже не можешь бы joinable.
4️⃣ Отсоединенный. Если у объекта вызвали метод detach() и отсоединили поток исполнения от этого объекта, то присоединить его совсем не получится.
По идее, все эти пункты мы контролируем в коде. Если мы явно вызываем конструктор потока с функцией, не отсоединяем поток, не присоединяем его по середине кода и не перемещаем, то и joinable казалось бы не нужен.
Но все-таки это идиоматическая конструкция, которую просто надо запомнить и использовать "на всякий случай".
В будущем вы скорее всего будете менять этот класс. И, например, добавите в апи метод Wait(), который явно дожидается завершения потока. Или вообще отсоедините поток.
Никто не застрахован от изменений в будущем, поэтому уже заранее нужно заложить безопасное разрушение объекта.
Ну или используйте C++20 std::jthread и забудьте про эту конструкцию)
Be safe. Stay cool.
#concurrency
#опытным
Не часто мы в коде сами создаем потоки. Но когда это происходит, то начинаются интересности буквально везде и повсюду. Опустим гонки данных и прочие проблемы и обсудим вот что.
В деструкторах классов, которые в качестве поля содержат поток, часто можно найти такую конструкцию:
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
🔥22❤13👍7
Одна из проблем решения задачек с литкода – отсутствие учителя
Либо ты гений, нарешавший кучу задач, и сам придумываешь решение, либо застреваешь часами в попытке выработать оптимальный алгоритм, либо просто смотришь готовое решение. Не самые завидные варианты.
Но не одним литкодом едины. В свое время Яндекс создал для своих разработчиков онлайн-тренажер, а потом открыл доступ для всех желающих попрактиковаться в решении задач. Недавно в CodeRun появилась новая фича в виде AI-помощника на базе SourceCraft – Кодерун AI.
Он не напишет код за вас, но проведёт от намека к инсайту, не лишая права на ошибку. Вместо готового решения он поможет разобраться с примерами из условия, даст прогрессивные подсказки и тесты для корнеркейсов. Работает почти на всех языках.
Чтобы попробовать, заходите в задачи на CodeRun и открывайте вкладку «Кодерун AI». Пока фича в бета-режиме, нужна авторизация, а лимит — 20 запросов в сутки.
Либо ты гений, нарешавший кучу задач, и сам придумываешь решение, либо застреваешь часами в попытке выработать оптимальный алгоритм, либо просто смотришь готовое решение. Не самые завидные варианты.
Но не одним литкодом едины. В свое время Яндекс создал для своих разработчиков онлайн-тренажер, а потом открыл доступ для всех желающих попрактиковаться в решении задач. Недавно в CodeRun появилась новая фича в виде AI-помощника на базе SourceCraft – Кодерун AI.
Он не напишет код за вас, но проведёт от намека к инсайту, не лишая права на ошибку. Вместо готового решения он поможет разобраться с примерами из условия, даст прогрессивные подсказки и тесты для корнеркейсов. Работает почти на всех языках.
Чтобы попробовать, заходите в задачи на CodeRun и открывайте вкладку «Кодерун AI». Пока фича в бета-режиме, нужна авторизация, а лимит — 20 запросов в сутки.
👍17🔥7❤5👎4
std::terminate
#опытным
Эта функция даже звучит страшно. Как будто бы даже есть немного опасений, что придет Шварц и заберет мою одежду, если в программе она вызовется.
Большинство из вас скорее всего знает, что вызов std::terminate приводит к завершению программы из-за какой-то ошибки.
Но как именно приводит? И почему это очень плохо? - На эти вопросы постараемся сегодня ответить.
Термин "ошибка" - слишком неоднозначный. На самом деле есть как минимум 2 категории ошибок: от которых можно восстановиться и от которых нельзя.
К первой категории можно отнести ситуации, от которых можно восстановиться. Когда системные вызовы возвращают отрицательное значение. Обычно это значит, что что-то пошло не так. Но жизнь программы продолжается: мы можем сделать ретрай или вообще прекратить исполнение задачи.
Туда же можно отнести выброс исключения. Ну ничего страшного: поймаем исключение, раскрутим стек, разрушим локальные объекты и продолжим исполнение дальше.
Ситуации же из второй категории намного серьезней. Вы в принципе нарушаете правила исполнения программы. Выход за границы массива - сегфолт. Слишком глубокая рекурсия - переполнение стека.
Таких ситуаций можно придумать много, и в каком-то небольшом их подмножестве вызывается функция std::terminate.
Эта функция, которая не может бросать исключений и не возвращает никакого значения. Непонятно, как можно обработать ошибку при аварийном завершении программы. И так, как программа завершается при вызове terminate, никакой другой код не должен продолжится после этого вызова.
Внутренний механизм вызова std::terminate() достаточно общий для такого рода функций. std::terminate() не выполняет всю работу сама. Вместо этого она вызывает обработчик события "уничтожения программы". Обработчик настраивается через функцию std::set_terminate и по умолчанию там стоит вызов std::abort. А это уже функция которая немедленно посылает сигнал
В чем проблемы такого завершения программы? Ну помимо того, что мы уже довольно сильно накосячили, что вызвался std::terminate.
Не раскручивается стек и не вызываются деструкторы ни локальных, ни глобальных объектов. Не вызываются никакие cleanup операции. Так или иначе при завершении программы ОС все равно заберет себе все ресурсы, но никакие процессы, происходящие в программе, грамотно не завершаются. Не флашатся потоки ввода-вывода и буферы, не возвращается нормальный ответ на запрос, не дообрабатываются уже готовые к обработке события. Батя ушел за хлебом и не вернулся... Ни свидетелей, ни весточки.
Поэтому очевидно, что просто не надо допускать ситуаций, которые приводят к вызову std::teminate. Их конечно много, но они вполне четко определены и их довольно просто избегать. Об этих ситуациях мы и поговорим в следующем посте.
Recover from your mistakes. Stay cool.
#cppcore
#опытным
Эта функция даже звучит страшно. Как будто бы даже есть немного опасений, что придет Шварц и заберет мою одежду, если в программе она вызовется.
Большинство из вас скорее всего знает, что вызов 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
👍27❤13🔥5🤣1
std::terminate. Когда вызывается
#опытным
Давайте посмотрим на список ситуаций, при которых вызывается std::terminate. Некоторые из них вполне мемные и заслуживают отдельных взвизгов, что так вообще можно в языке. Будем рассматривать более-менее понятные примеры, без малоизвестных динозавров. Погнали:
🔞 Вы бросили исключение, но не перехватили его нигде по стеку вызовов:
🔞 Исключение покидает noexcept функцию:
Частый вопрос на собесах: что будет если в деструкторе кинуть исключение? Тут без всяких раскруток стеков будет сразу terminate, потому что все деструкторы по умолчанию noexcept.
🔞 Деструктор бросает исключение во время раскрутки стека. Если вы уже такие смелые, что отменили noexcept спецификацию для деструкторов, то будьте готовы к аварийному завершению приложения. Если деструктор такого объекта вызовется во время раскрутки стека, ждите гитлер капут:
🔞 Из конструктора статического объекта вылетает исключение. В языке нет средств отловить такие исключения, поэтому сразу ломаемся:
🔞 В ту же песню залетает исключение из деструктора статических объектов. При нормальном завершении программы вызываются деструкторы глобальных объектов и если оттуда вылетит исключение, его никто не сможет поймать:
🔞 Из функции, зарегистрированной через atexit, вылетает исключение. Если вы хотите, чтобы при штатном завершении программы выполнилась функция, бросающая исключение, то у вас специфические вкусы. Такое исключение невозможно перехватить, поэтому тут же упадем:
🔞 Пустой throw без активного на данный момент исключения. Что вы хотели этим сказать - непонятно, зачем разрешать такое - тоже.
🔞 Из копирующего конструктора исключения вылетает исключение. Что-то такое:
Тут объект исключения после создания копируется во внутренний объект исключения, который будет переноситься механизмом раскрутки стека. Еще до раскрутки стека и вызова обработчика. В этом случае как бы произошла ошибка при выбросе исключения и это вполне аварийная ситуация, заканчивающаяся terminate. Ситуация странная, последствия вполне понятны.
🔞 Потоковая функция завершается исключением. Если исключение вылетает из последнего фрейма стека любого потока, вызывается std::terminate.
🔞 Вызвался деструктор или приваивание перемещением у joinable потока. Его не присоединили и не отсоединили и хотят уничтожить. А он уничтожил их:
🔞 Вы мануально вызвали std::terminate. Хотите явно аварийно грохнуть программу и не плясать с бубнами, вам это дозволено:
Есть еще пару кейсов с потоковыми токенами остановки и с новой библиотекой std::execution, но это экзотика.
Видно, что большинство кейсов завязано на исключениях и сводятся к тому, что вы хотите сообщить об ошибке не из того места. А ошибка при попытке сообщить об ошибке легально попадает в список аварийных ситуаций.
Terminate your enemies. Stay cool.
#опытным
Давайте посмотрим на список ситуаций, при которых вызывается 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.
👍20❤12🔥6👏1
WAT
#опытным
Сколько будет х/х?
Как ни странно, на этот вопрос есть множество ответов.
Математический ответ: при x != 0 выражение вырождается в единицу. Если же x = 0, то значение выражения неопределено. x=0 приводит к неопределенности вида 0/0, у которого нет корректного значения.
Какой же ответ у С++?
Ответ будет 1!
На те вот ссылочку на годболт, если не верите.
#ЧЗХ? Компьютер считать чтоли не умеет?
По ссылочке на самом деле только вывод gcc показан. И он действительно выдает 1.
Но на кланге при запуске программа получает сигнал SIGFPE, а на msvc выдается вот такая цифирь 3221225620.
Понятное дело, что деление на 0 - это UB, и это явно видно по результатам запуска на разных компиляторах.
Если вы думали, что проблема проявляется только оптимизациях - нет. Даже на -O0 все равно gcc единичку выдает.
Тогда как могла появиться единичка?
С какой-то стороны этот вопрос не имеет смысла, потому что при UB компилятор волен поступать, как ему вздумается. Но этот конкретный пример показывает одну из популярных моделей поведения компилятора при UB.
С точки зрения разработчика С++ UB - это скорее плохо. Некоторые из нас идут на риск, когда понимают реальный эффект на конкретном компиляторе и железе, но в большинстве кейсов мы стараемся избегать UB.
И компилятор это учитывает. Если UB - это плохо и программист старается его не допускать в своем коде, значит можно считать, что UB в коде не будет. И на основе этого знания компилировать программу.
UB в данном случае произойдет только при x=0. Тогда компилятор просто выкидывает этот вариант из анализа, притворяется, что такого не может быть. Тогда выражение x/x действительно становится равным единице.
Компилятор применил константную свёртку (constant folding) — одно из базовых преобразований, которое работает даже на нулевом уровне оптимизации. Он увидел выражение
Чтобы компилятор поступал максимально честно и ничего не смог сделать с выражением, надо пометить переменную volatile.
Это ключевое слово запрещает компилятору делать предположения о значении переменной и заставляет чего честно читать переменную из памяти.
Do not trust programmers. Stay cool.
#compiler
#опытным
Сколько будет х/х?
Как ни странно, на этот вопрос есть множество ответов.
Математический ответ: при 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
🔥24❤14👍9
Loop unrolling. Хвосты
#новичкам
Предыдущий пост тут.
Следующие пару постов могут быть немного скучными и замудренными, но темы по смыслу именно так бьются. Разговор о хвостах нужен, чтобы правильно понять одну технику(спойлер: Duff device)
Удобненько получилось, что в прошлом посте число итераций цикла делилось на 4. 1024 % 4 = 0.
Но как разворачивать циклы, если число итераций не делится нацело на фактор развертывания(количество повторов операции в развертке)?
Да тем же циклом. Обрабатываем целую часть развернутым циклом и потом дорабатываем остаток:
Последний цикл - это и есть дообработка хвоста.
И обработка хвостов - это важная штука, которую нельзя забывать хоть при разворачивании цикла, хоть при векторизации вычислений, хоть при распараллеливании вычислений.
Чем вообще плох хвост? Мы заранее не знаем его размер и поэтому вынуждены использовать привычный неразвернутый цикл для его обработки. Это опять же приносит с собой недостатки обычных циклов.
Если бы мы только знали, сколько элементов в хвосте коллекции, то смогли бы и этот последний цикл развернуть просто в набор операций.
И есть несколько техник, которые позволяют сэмулировать нечто подобное. О них мы поговорим в следующие разы, они отдельных постов заслуживают.
See the job through. Stay cool.
#performance
#новичкам
Предыдущий пост тут.
Следующие пару постов могут быть немного скучными и замудренными, но темы по смыслу именно так бьются. Разговор о хвостах нужен, чтобы правильно понять одну технику(спойлер: 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
👍20❤9🔥6
Loop unrolling. Быстрая обработка хвостов
#новичкам
В прошлый раз мы пришли к важной мысли: нам нужно обрабатывать хвосты развернутых циклов и делать это быстро.
В С++ коде пока непонятно, как это сделать. Но в ассемблере есть довольно старая техника для этого.
Возьмем С++ код:
В целом, мы хотим, чтобы хвосты обрабатывались примерно так:
Только без кучи повторяющегося кода, количество которого будет только увеличиваться с увеличением фактора разворачивания цикла.
Идея: на месте пролога напишем 4 инструкции присваивания, а между ними поставим метки. В начале мы узнаем остаток и прыгаем на нужную метку. После чего раз за разом проваливаемся в следующую метку и в итоге в развернутый цикл. То есть в начале обрабатываем хвост правильного размера с помощью меток, а потом уже попадаем в основной цикл. В переводе на С++ это выглядит так:
Выглядит адски: свитч, какие-то метки, на которых одинаковый код, goto. За такое сразу бы запретили человеку коммитить код, только документацию писать отныне и навсегда. Поэтому никто так не пишет.
Зато эту лапшу можно спрятать в скомпилированном ассемблере, который вряд ли кто-то будет читать. Компилятор превращает исходный С++ код из начала поста в ассемблер примерно такого же вида, как последний сниппет. Вот примерчик на годболте как это выглядит в оригинале.
Hide your impurities deep. Stay cool.
#performance #compiler
#новичкам
В прошлый раз мы пришли к важной мысли: нам нужно обрабатывать хвосты развернутых циклов и делать это быстро.
В С++ коде пока непонятно, как это сделать. Но в ассемблере есть довольно старая техника для этого.
Возьмем С++ код:
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
🔥12❤9👍7😁2🤯1
Loop unrolling. Duff's device
#опытным
Если у вас чуть подвытекали глаза, когда вы увидели в прошлом посте эту схему со свитчами и гоуту, то сегодня они вытекут окончательно.
Если приглядеться в механику этого кода
То можно заметить одну вещь. Мы используем возможность беспретятственного прохождения исполнения сквозь метки при этом имеем возможность воткнуться в любой момент времени, перейдя по нужной метке.
Но эта механика же полностью закрывается функциональностью switch. Код может проходить от кейса к кейсу, пока не встретит break и изначально можно перейти в любой из кейсов.
Давайте сделаем трах-тебедох и представим код в другом виде:
Как это работает
👉🏿
👉🏿
👉🏿 Тело цикла содержит четыре одинаковых операции (вычисление квадрата и сдвиг индекса), размеченных метками
👉🏿 Цикл продолжается, пока
Первым такую особенность заметил мисье Том Дафф. В тот момент он работал в Lucasfilm и пытался оптимизировать копирование данных в memory-mapped регистр для анимации реального времени. Ему нужно было развернуть цикл для скорости, но была проблема — количество итераций не всегда кратно размеру развёртки.
Он вдохновился приемами из ассемблера и написал на языке K&R С(одна из ранних версий С, до стандартизации) вот такую конструкцию:
Функция send - это такой аналог memcpy, до внедрения в стандарт этой функции. Идея такая же: прыгаем в середину свитча для обработки остатка, а дальше крутимся в развернутом while, пока не скопируем все элементы.
С тех пор код такого вида называется Duff's device или устройство Даффа.
В 80-е техника давала буст производительности хоть и вызывала вопросы к читаемости кода и в целом к средствам языка, которые предоставляют такие возможности.
Но в современном мире не стоит даже и пытаться нечто такое написать в проде.Компиляторы cами отлично разворачивают циклы, часто лучше, чем ручная оптимизация.
И вообще перед применением любой оптимизации кода следует выполнить его бенчмаркинг или изучить сгенерированный компилятором вывод, чтобы убедиться, что код работает ожидаемым образом на целевой архитектуре, уровне оптимизации и компиляторе.
Тем не менее Duff's device остался в истории как пример вырвиглазной эксплуатации тонкостей языка в гонке за производительностью.
Be relevant. Stay cool.
#compiler #optimization #performance
#опытным
Если у вас чуть подвытекали глаза, когда вы увидели в прошлом посте эту схему со свитчами и гоуту, то сегодня они вытекут окончательно.
Если приглядеться в механику этого кода
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 > 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) или количество итераций цикла неизвестно.
Если же вы хотите вручную контролировать развертку, но не писать код развертки руками - для вас есть один вариант. Это
Правда, есть загвоздка. Стандартного синтаксиса этой директивы не существует, для каждого компилятора чуть свой синтаксис:
-👉🏿GCC и Clang: Используется
👉🏿 MSVC (Microsoft Visual C++): здесь все, как не у всех: в msvc нет прямого аналога директивы
👉🏿 Intel и NVIDIA компиляторы: Используют
👉🏿 ARM компилятор: Поддерживает
Раз у всех все свое, то в примерах будет использовать некую усредненную версию.
По сути есть 2 возможных варианта использования директивы:
🙈 Полная развертка
В этом случае компилятор обязан знать число итераций цикла, чтобы полностью его развернуть.
Плюс чем больше итераций, тем сильнее распухает код бинаря.
Поэтому в данном виде директива встречается не так часто.
☃️ Частичная развертка
Это именно та история, которую мы обсуждаем всю серию статей.
Как прагма работает?
1️⃣ Препроцессор оставляет
2️⃣ На этапе семантического анализа компилятор встречает
3️⃣ Специальная сущность в компиляторе, отвечающая за развертку циклов, слушает и повинуется прагме и делает столько операций в цикле, сколько сказал разработчик. Плюс добавляет обработку хвоста.
Здесь можно посмотреть примерчики с gcc-шной версией прагмы.
Напомню, что компилятор сам хорошо умеет оптимизировать циклы, особенно на O3. Используйте прагму только тогда, когда вы сами ручками проверили, что компилятор в вашем случае бессилен или делает что-то неправильно.
Плюс обязательно делайте перфоманс тестирование, чтобы выявить действительно лучший по производительности вариант.
Measure your performance. Stay cool.
#compiler #optimization #performance
#опытным
Компилятор конечно умный и сам умеет разворачивать циклы. Но делает он это не во всех случаях. Когда-то уровень оптимизации не позволяет(агрессивная развертка делается на 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
Сколько раз можно разыменовать лямбду?
#опытным
Давайте для начала поговорим о том, сколько раз можно разыменовать указатель?
Зависит от того, какой указатель мы имеем ввиду.
Тип
Но мы конечно же можем определить указатель на указатель и добиваться очень глубоких уровней индирекции. Однако мы все равно сможем разыменовать такой указатель ровно по количеству этих уровней, не больше:
Сколько же раз можно разыменовывать лямбду?
Так стоп. Как в лямбде можно применять оператор?
Лямбда без захвата неявно кастится к указателю на функцию. А указатель можно разыменовывать.
Так сколько?
Бесконечно
Чисто технически мы конечно ограничены количеством атомов во вселенной или, что более реально, возможностями компилятора обрабатывать длиннющие тексты программ. Но формальных ограничений нет.
#ЧЗХ?
Давайте подумаем, что происходит при разыменовании лямбды. Она приводится к указателю на функцию, оператор применяется и результатом мы получаем lvalue функции(саму функцию, а не указатель). Тип этого выражения –
А функции у нас что любят делать? Правильно, неявно преобразовываться к указателю на саму себя.
Поэтому можем применить оператор еще разик.
А потом еще и еще. И еще, и еще, и еще, и еще... Ну вы поняли.
В любом случае ret по итогам того же неявного преобразования будет иметь тип указателя на фукнцию:
По тем же рассуждениям, кстати, любой указатель на функцию тоже можно бесконечно разыменовать.
Спасибо, @Ivaneo, за любезно предоставленный примерчик.
Be limited only by your imagination. Stay cool.
#cppcore
#опытным
Давайте для начала поговорим о том, сколько раз можно разыменовать указатель?
Зависит от того, какой указатель мы имеем ввиду.
Тип
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