#cpp
Давайте обманем атомик. Вот две функции - можно ли используя только их увидеть некорректное состояние атомика?
Пишем тест:
- 1-ый тред упорно пишет -1 в атомик (то есть все биты равны единице)
- 2-ой тред пишет 2 (то есть ровно 1 бит - второй младший равен единице)
- 3-ий тред читает и стопает программу, если прочитает нечётное отрицательное число
И, с помощью легко пропускаемой ошибки, эта программа у меня стабильно падает где-то за 7-10млн итераций чтения
и вот оно - давайте посмотрим как мы обманули атомик
Если не совершать ошибку (запустится с любым аргументом) то промежуточного стейта мы не увидим
Думаю уже понятно что происходит - мы располагаем наш атомик на 8 байт по середине двух страниц.
В частности - мы располагаем старшие и младшие 4 биты в разных кеш-линиях
А алгоритмы синхронизации в процессоре между ядрами работают только в рамках кеш-линий.
Что же конкретное мы нарушили? Мы нарушили выравнивание объекта.
Как же посадить такую ошибку? Я точно видел такую ошибку в районе попытки реалзивоывать что-то вроде арен или memory-pool-ов.
Выделяют память, выдают её кусками, а про выравнивание при выделении забыли (это встроенные new сделают как надо, ибо знают тип, а malloc или самописные функции получения буфера из мемори-пула, этого не делают)
Что ещё интересного можно заметить? Давайте поштырим в godbold
https://godbolt.org/z/j5jP8ozEb
1. сами операции чтения и записи не требуют выравнивания (мы не получаем sigill, хотя например с simd инструкциями это легко случается даже на x86)
2. изменение memory-order ничего не меняет (видимо, можно попытаться заявить, что в случае x86 memory-order аргументы прежде всего описывают ограничений перестановки инструкций компилятором, но не порождают дополнительных явных инструкций (сброса буферов чтения/записи например))
3. компиляция функции про чтение вообще ничего про атомарность не содержит, а вот запись использует спец функцию xchg, про которую и написано что оно будет реализовывать внутрипроцессорный алгоритм блокировок
Давайте обманем атомик. Вот две функции - можно ли используя только их увидеть некорректное состояние атомика?
void write(std::atomic<int64_t>& x, int64_t v) {
x.store(v, std::memory_order_seq_cst);
}
int64_t read(std::atomic<int64_t>& x) {
return x.load(std::memory_order_seq_cst);
}Пишем тест:
- 1-ый тред упорно пишет -1 в атомик (то есть все биты равны единице)
- 2-ой тред пишет 2 (то есть ровно 1 бит - второй младший равен единице)
- 3-ий тред читает и стопает программу, если прочитает нечётное отрицательное число
void test(std::atomic<int64_t>& x) {
constexpr size_t ITERS = 100'000'000;
std::thread writePart1([&x]() {
for(size_t i = 0; i < ITERS; ++i) {
write(x, -1);
}
});
std::thread writePart2([&x]() {
for(size_t i = 0; i < ITERS; ++i) {
write(x, 2);
}
});
std::thread reader([&x]() {
for(size_t i = 0; i < ITERS; ++i) {
int64_t r = read(x);
if ((r & 1) == 0 && r < 0) {
std::cout << "found non atomic behaviour, iter=" << i/1e6 << "m" << std::endl;
exit(0);
return;
}
}
});
writePart1.join();
writePart2.join();
reader.join();
std::cout << "finish, not found non atomic behaviour" << std::endl;
}И, с помощью легко пропускаемой ошибки, эта программа у меня стабильно падает где-то за 7-10млн итераций чтения
+ clang++ -std=c++20 main.cpp -o nonatomic.exe -Wall -O2 -DNDEBUG
+ ./nonatomic.exe
found non atomic behaviour, iter=8.15546m
и вот оно - давайте посмотрим как мы обманули атомик
int main(int argc, const char* _ []) {
constexpr size_t alignment = 4096;
char* buf [alignment * 3];
size_t ptr = size_t(buf) / alignment * alignment + alignment - 4;
std::atomic<int64_t>* nonAligned = new((void*)ptr) std::atomic<int64_t>(0);
std::atomic<int64_t> aligned;
std::atomic<int64_t>& x = (argc == 1) ? *nonAligned : aligned;
test(x);
return 0;
}Если не совершать ошибку (запустится с любым аргументом) то промежуточного стейта мы не увидим
+ ./nonatomic.exe aligned
finish, not found non atomic behaviour
Думаю уже понятно что происходит - мы располагаем наш атомик на 8 байт по середине двух страниц.
В частности - мы располагаем старшие и младшие 4 биты в разных кеш-линиях
А алгоритмы синхронизации в процессоре между ядрами работают только в рамках кеш-линий.
Что же конкретное мы нарушили? Мы нарушили выравнивание объекта.
alignof(std::atomic<int64_t>) == 8, а нарушение алайнмента - UB.Как же посадить такую ошибку? Я точно видел такую ошибку в районе попытки реалзивоывать что-то вроде арен или memory-pool-ов.
Выделяют память, выдают её кусками, а про выравнивание при выделении забыли (это встроенные new сделают как надо, ибо знают тип, а malloc или самописные функции получения буфера из мемори-пула, этого не делают)
Что ещё интересного можно заметить? Давайте поштырим в godbold
https://godbolt.org/z/j5jP8ozEb
1. сами операции чтения и записи не требуют выравнивания (мы не получаем sigill, хотя например с simd инструкциями это легко случается даже на x86)
2. изменение memory-order ничего не меняет (видимо, можно попытаться заявить, что в случае x86 memory-order аргументы прежде всего описывают ограничений перестановки инструкций компилятором, но не порождают дополнительных явных инструкций (сброса буферов чтения/записи например))
3. компиляция функции про чтение вообще ничего про атомарность не содержит, а вот запись использует спец функцию xchg, про которую и написано что оно будет реализовывать внутрипроцессорный алгоритм блокировок
godbolt.org
Compiler Explorer - C++ (x86-64 clang (trunk))
void write(std::atomic<int64_t>& x, int64_t v) {
x.store(v, std::memory_order_seq_cst);
}
void write2(int64_t& x, int64_t v) {
x = v;
}
void read(const std::atomic<int64_t>& x, int64_t& v) {
v = x.load(std::memory_order_seq_cst);
}
void read2(const…
x.store(v, std::memory_order_seq_cst);
}
void write2(int64_t& x, int64_t v) {
x = v;
}
void read(const std::atomic<int64_t>& x, int64_t& v) {
v = x.load(std::memory_order_seq_cst);
}
void read2(const…
👍2
Микро #incident был: подселял коллега очередную модель в очередной сервис, для аб. Ну подселяет и подселяет - порадуемся что сам справляется, тестики, проверочки подсказывают что сделать, действие в целом шаблонное. Но вот чего-то начинают подспамливать алёртики, мол не отвечает немножко (выглядит как единичные всплески неответов).
Вечером подумал - ну кажись опять какие-то настойки поехали - стандартная тема "фон неответов если выкатывается сервис", надо проверить настройки хеджей.
А на следующий день - оно продолжает фонить и подспамливать. Ну ладно - придётся посмотреть что там.
Оказалось что "советчик по настройкам сервиса" в пр подсказал:
"вот тут настроечка, она равна 5. А чего-это? По моим формулами лучше 4".
Что-то человек у себя внутри подумал-подумал и вкоммитил туда вообще два. Настройка эта - ограничитель infly на поде. И вот: теперь бывало что запрос не находил свободного пода (обходя все 5 подов локации). Ну что скажешь - 1) надо лучше смотреть что выкатываешь, 2) а iac в себе таит возможность приезда немного неожиданных изменений.
К чему я подвожу то? #математика , как известно, программистам не нужна, но коль её завалялось без дела под стулом - можно побаловаться.
Дано: ~130 рпс входящего потока. Время 1 запроса от 7мс до 60мс (среднее - 22мс). Есть 5 подов с лимитом в "2 инфлай". Сколько будет ошибок шедулинга?
Сразу скажу имперический ответ - их было 0.8-0.1 рпс. То есть вероятность должна оказаться примерно 0.0007 (в 7 раз хуже 4х девяток).
Что является условием для ошибки шедулинга? Наличие 10 запросов infly (и приход 11-ого). В среднем, на наших вводных, у нас должно быть 130 * 0.022 = 2.86 infly.
Ну что, берём пуассоновское распределение с
Вечером подумал - ну кажись опять какие-то настойки поехали - стандартная тема "фон неответов если выкатывается сервис", надо проверить настройки хеджей.
А на следующий день - оно продолжает фонить и подспамливать. Ну ладно - придётся посмотреть что там.
Оказалось что "советчик по настройкам сервиса" в пр подсказал:
"вот тут настроечка, она равна 5. А чего-это? По моим формулами лучше 4".
Что-то человек у себя внутри подумал-подумал и вкоммитил туда вообще два. Настройка эта - ограничитель infly на поде. И вот: теперь бывало что запрос не находил свободного пода (обходя все 5 подов локации). Ну что скажешь - 1) надо лучше смотреть что выкатываешь, 2) а iac в себе таит возможность приезда немного неожиданных изменений.
К чему я подвожу то? #математика , как известно, программистам не нужна, но коль её завалялось без дела под стулом - можно побаловаться.
Дано: ~130 рпс входящего потока. Время 1 запроса от 7мс до 60мс (среднее - 22мс). Есть 5 подов с лимитом в "2 инфлай". Сколько будет ошибок шедулинга?
Сразу скажу имперический ответ - их было 0.8-0.1 рпс. То есть вероятность должна оказаться примерно 0.0007 (в 7 раз хуже 4х девяток).
Что является условием для ошибки шедулинга? Наличие 10 запросов infly (и приход 11-ого). В среднем, на наших вводных, у нас должно быть 130 * 0.022 = 2.86 infly.
Ну что, берём пуассоновское распределение с
\lambda = 2.86. Считаем sum([math.exp(-2.86) * (2.86 ** t) / math.factorial(t) for t in range(10, 100)]) = 0.00077... Неплохо попали (правда, если условие ставить 11 - уже в 3 раза меньше вероятность).🔥3
#cpp
ODR и weak-символы - источник внезапных и не приятных проблем. Например, можно посадить вот такую мину, при компиляции под разные наборы инструкций.
А также имеется два .cpp файла. Первый - tu_avx512.cpp например такой
(а второй - tu_common.cpp отличается только именем определяемой функции)
Компилируем каждый translation-unit таким способом
Вызываем правильную функцию (не avx512, оно кажется редкость на консьюмерских процах, на
и легко получаем sigill - в зависимости от того, как слинковали
А вот так - не получаем
Inline функция prepare при линковке была в первом случае выбрана из сборки с разрешёнными avx512f инструкциями.
Данный пример немного натянутый, но любое использование шаблонов порождает кучу инстанциаций inline различных методов, стоит хоть где-то не повезти с тем что не случился inline, а был использован call, и реализация была создана в TU со спец флагами - можно попасть на то, что именно это реализация будет выбрана где-то в совершенно другом месте.
ODR и weak-символы - источник внезапных и не приятных проблем. Например, можно посадить вот такую мину, при компиляции под разные наборы инструкций.
header.hpp:#include <iostream>
#include <array>
namespace NS {
using TData = std::array<float, 128>;
float func_in_tu_common(TData& z);
float func_in_tu_avx512(TData& z);
inline void prepare(TData& z) {
for(size_t i = 0; i < z.size(); ++i) {
z[i] = i;
}
}
inline float func_with_choose(bool haveAvx) {
TData z;
if (haveAvx) {
return func_in_tu_avx512(z);
}
return func_in_tu_common(z);
}
}
А также имеется два .cpp файла. Первый - tu_avx512.cpp например такой
#include "header.hpp"
#include <numeric>
float NS::func_in_tu_avx512(TData& z) {
prepare(z);
return std::accumulate(z.begin(), z.end(), 0);
}
(а второй - tu_common.cpp отличается только именем определяемой функции)
Компилируем каждый translation-unit таким способом
clang++ -std=c++20 -c tu_avx512.cpp -o tu_avx512.a -msse4.2 -mavx -mavx2 -mavx512f
clang++ -std=c++20 -c tu_common.cpp -o tu_common.a -msse4.2 -mavx -mavx2
Вызываем правильную функцию (не avx512, оно кажется редкость на консьюмерских процах, на
13th Gen Intel(R) Core(TM) i5-1340P например его нет)#include "header.hpp"
int main(int argc, const char* argv[]) {
std::cout << "start" << std::endl;
std::cout << NS::func_with_choose(false) << std::endl;
std::cout << "finish" << std::endl;
return 0;
}
и легко получаем sigill - в зависимости от того, как слинковали
clang++ -std=c++20 main.cpp tu_avx512.a tu_common.a -o weak_bomb.exe
./weak_bomb.exe
start
13073 Illegal instruction (core dumped) ./weak_bomb.exe
А вот так - не получаем
clang++ -std=c++20 main.cpp tu_common.a tu_avx512.a -o weak_bomb2.exe
./weak_bomb2.exe
start
8128
finish
Inline функция prepare при линковке была в первом случае выбрана из сборки с разрешёнными avx512f инструкциями.
Данный пример немного натянутый, но любое использование шаблонов порождает кучу инстанциаций inline различных методов, стоит хоть где-то не повезти с тем что не случился inline, а был использован call, и реализация была создана в TU со спец флагами - можно попасть на то, что именно это реализация будет выбрана где-то в совершенно другом месте.
👍3🤔2✍1
#cpp
На сколько сложные вещи могут понимать компиляторы?
Оказывается, очень легко найти примеры, когда компилятор не может опереться на казалось бы очевидные инварианты.
Часто бывает, что итерирование по коллекции оборачивают в if с проверкой пустоты.
Мне обычно не нравится такая проверка, но ведь компилятору всё равно? А вот нет. Посмотрим пример
Анализировать будем во что скомпилировалось
https://godbolt.org/z/jMaaso74a
Итераторы в хеш-мапе (ну как минимум в этой) - это указатель на ноду. И end() просто создаёт его через nullptr.
Вот эта конструкция - взятие begin, и сравнение на nullptr (то есть сравнение с end)
А вот
Таким образом - сравнение на пустоту таки стоит пару доп инструкций (конечно замерить это почти нереально).
У меня даже с помощью assume не получилось заставить компилятор переделать случай с проверкой на
Понятно, что взаимосвязь
На сколько сложные вещи могут понимать компиляторы?
Оказывается, очень легко найти примеры, когда компилятор не может опереться на казалось бы очевидные инварианты.
Часто бывает, что итерирование по коллекции оборачивают в if с проверкой пустоты.
Мне обычно не нравится такая проверка, но ведь компилятору всё равно? А вот нет. Посмотрим пример
#include <unordered_map>
void dosome(int, int);
void iterate_simple(std::unordered_map<int, int>& z) {
for(auto& x : z) {
dosome(x.first, x.second);
}
}
void iterate_with_check(std::unordered_map<int, int>& z) {
if (!z.empty()) {
for(auto& x : z) {
dosome(x.first, x.second);
}
}
}
Анализировать будем во что скомпилировалось
https://godbolt.org/z/jMaaso74a
Итераторы в хеш-мапе (ну как минимум в этой) - это указатель на ноду. И end() просто создаёт его через nullptr.
Вот эта конструкция - взятие begin, и сравнение на nullptr (то есть сравнение с end)
mov rbx, qword ptr [rdi + 16]
test rbx, rbx
je .Lxxx
А вот
cmp qword ptr [rdi + 24], 0 - сравнение .size() (который лежит следующим полем за begin()) с нулём. rdi - у нас указатель на первый аргумент функции, +x - смещение для выбора поля в байтах, je - делает переход если предыдущая операция выставила флаг нулевого результата, а test - побитовое AND.Таким образом - сравнение на пустоту таки стоит пару доп инструкций (конечно замерить это почти нереально).
У меня даже с помощью assume не получилось заставить компилятор переделать случай с проверкой на
do {} while() - чтобы сэкономить нам 1 сравнение и не делать его на первой итерации.Понятно, что взаимосвязь
.size() и количество раз сколько можно от begin пройти - почти невозможно автоматически вывести: для этого нужно проанализировать все возможные ситуации когда эти поля меняются, и доказать какую-то теорему (причём что-то должно сформулировать, что такой стейтмент нужно доказывать).godbolt.org
Compiler Explorer - C++
void dosome(int, int);
void iterate(std::unordered_map<int, int>& z) {
#ifdef check_empty
if (!z.empty()) {
#ifdef do_assume
[[assume(it != z.end())]];
#endif
#endif
for(auto& x : z) {
…
void iterate(std::unordered_map<int, int>& z) {
#ifdef check_empty
if (!z.empty()) {
#ifdef do_assume
[[assume(it != z.end())]];
#endif
#endif
for(auto& x : z) {
…
👍3🔥1
#cpp
После прошлого поста мне захотелось посмотреть ещё какие-нибудь примеры, где компилятор смог или не смог бы заметить инвариант.
Решил посмотреть на рефкаунт - сможет ли компилятор избавиться от атомарных += 1 и -=1, захочет ли.
Если кратко - то не смог (Но интереснее другое, но об этом чуть позже)
doInc2 - конских размеров, куча проверок, и вызов "виртуального деструктора" (
Свапы - уже получше, но не идеально
получилось
Что мы тут не видим? Не видим сравнений и чего-то странного, хотя
А теперь к тому что удивило - отсутствие проверок - не обнулили ли мы рефкаунт x в 1-ой и 3-ей функциях. И вот "в столько лет я узнал" - что по calling-convention деструктор передаваемых по значению аргументов вызывает вызывающая сторона.
Для проверки посмотрел что получается в таком случае
В createAndDo можно увидеть два запуска деструкторов. И если задуматься - такое решение достаточно логично.
Рассмотрим:
Если мы, например, уже посчитали calcParam1, а calcParam2 кинуло исключение - то надо бы первый аргумент уничтожить.
Получается, если мы не дошли до вызова функции - нам придётся вызывать уничтожение. Если за деструктор отвечает вызывающая сторона, то можно легко объединить основной случай и unhappy-path.
[годболт](https://godbolt.org/z/7EGjo54br)
После прошлого поста мне захотелось посмотреть ещё какие-нибудь примеры, где компилятор смог или не смог бы заметить инвариант.
Решил посмотреть на рефкаунт - сможет ли компилятор избавиться от атомарных += 1 и -=1, захочет ли.
Если кратко - то не смог (Но интереснее другое, но об этом чуть позже)
void doInc(std::shared_ptr<int> x) {
(*x) += 7;
}
void doInc2(std::shared_ptr<int> x) {
std::shared_ptr<int> y = x;
(*y) += 7;
}doInc2 - конских размеров, куча проверок, и вызов "виртуального деструктора" (
call qword ptr [rax + 16] - освобождение управляющего блока, видимо, там)Свапы - уже получше, но не идеально
void doInc3(std::shared_ptr<int> x) {
std::shared_ptr<int> y = std::move(x);
(*y) += 7;
x = std::move(y);
}получилось
doInc3(std::shared_ptr<int>):
movups xmm0, xmmword ptr [rdi]
mov rax, qword ptr [rdi]
xorps xmm1, xmm1
movups xmmword ptr [rdi], xmm1
add dword ptr [rax], 7
movups xmmword ptr [rdi], xmm0
ret
Что мы тут не видим? Не видим сравнений и чего-то странного, хотя
xorps xmm1, xmm1 (и следующий mov полученных 128 битного нуля) - говорит что обнуление из move-а было скомпилено.А теперь к тому что удивило - отсутствие проверок - не обнулили ли мы рефкаунт x в 1-ой и 3-ей функциях. И вот "в столько лет я узнал" - что по calling-convention деструктор передаваемых по значению аргументов вызывает вызывающая сторона.
Для проверки посмотрел что получается в таком случае
struct TWithDestruct {
struct TState;
std::unique_ptr<TState> State;
TWithDestruct();
TWithDestruct(const TWithDestruct&);
~TWithDestruct();
};
void someAction(TWithDestruct);
TWithDestruct create();
void createAndDo() {
TWithDestruct x = create();
someAction(x);
}В createAndDo можно увидеть два запуска деструкторов. И если задуматься - такое решение достаточно логично.
Рассмотрим:
call(calcParam1(), calcParam2())
Если мы, например, уже посчитали calcParam1, а calcParam2 кинуло исключение - то надо бы первый аргумент уничтожить.
Получается, если мы не дошли до вызова функции - нам придётся вызывать уничтожение. Если за деструктор отвечает вызывающая сторона, то можно легко объединить основной случай и unhappy-path.
[годболт](https://godbolt.org/z/7EGjo54br)
godbolt.org
Compiler Explorer - C++
void doInc(std::shared_ptr<int> x) {
(*x) += 7;
}
void doInc2(std::shared_ptr<int> x) {
std::shared_ptr<int> y = x;
(*y) += 7;
}
void doInc3(std::shared_ptr<int> x) {
std::shared_ptr<int> y = std::move(x);
(*y) += 7;
x = std::move(y);…
(*x) += 7;
}
void doInc2(std::shared_ptr<int> x) {
std::shared_ptr<int> y = x;
(*y) += 7;
}
void doInc3(std::shared_ptr<int> x) {
std::shared_ptr<int> y = std::move(x);
(*y) += 7;
x = std::move(y);…
👍3
#флуд
Забавно, как разные решения и сюжеты рикошетят в неожиданных местах.
Телеграмм на андроиде, например, (видимо, опасаясь что рустор и гугл-плей перестанут быть рабочими каналами) предлагает обновить приложку прямо из себя.
Контекст их решения понятен? - Понятен. Но последствия пугают.
Чтобы обновление случилось - надо разрешить "установку приложений из этого источника". Сколько интересно людей дойдут потом выключить обратно? А сколько сделают это во второй-третий раз? (а баннер то очень назойливый)
Ось в свою очередь, в этом случае уже не проверят что устанавливает, и кем.
В итоге: такое вот станет сильно более реальным вектором (а кто-то попадётся чисто на мисклике), для полноты картины - в этом apk должен быть собственно говоря сам телеграмм, только инфицированный.
Что можно было бы сделать на уровни оси? Например разрешение могло бы работать на 1 установку, или на пару минут. Хотя решение всё равно будет ограниченным - учить людей на автомате нажимать "подтверждаю" = слабо отличается от того, чтобы не иметь вовсе подтверждений.
А со стороны приложения, кажется, единственный вариант - начинать внутри себя разделять каналы для исполняемых файлов. (у меня сейчас уведомление на открытие apk, не являющегося апдейтом, такое же как при апдейте - предлагает разрешить установку приложений из тг)
Забавно, как разные решения и сюжеты рикошетят в неожиданных местах.
Телеграмм на андроиде, например, (видимо, опасаясь что рустор и гугл-плей перестанут быть рабочими каналами) предлагает обновить приложку прямо из себя.
Контекст их решения понятен? - Понятен. Но последствия пугают.
Чтобы обновление случилось - надо разрешить "установку приложений из этого источника". Сколько интересно людей дойдут потом выключить обратно? А сколько сделают это во второй-третий раз? (а баннер то очень назойливый)
Ось в свою очередь, в этом случае уже не проверят что устанавливает, и кем.
В итоге: такое вот станет сильно более реальным вектором (а кто-то попадётся чисто на мисклике), для полноты картины - в этом apk должен быть собственно говоря сам телеграмм, только инфицированный.
Что можно было бы сделать на уровни оси? Например разрешение могло бы работать на 1 установку, или на пару минут. Хотя решение всё равно будет ограниченным - учить людей на автомате нажимать "подтверждаю" = слабо отличается от того, чтобы не иметь вовсе подтверждений.
А со стороны приложения, кажется, единственный вариант - начинать внутри себя разделять каналы для исполняемых файлов. (у меня сейчас уведомление на открытие apk, не являющегося апдейтом, такое же как при апдейте - предлагает разрешить установку приложений из тг)
🌚2👍1
#cpp
Задался вопросом - как связаны порядки (в смысле перебора от begin к end) элементов в хеш-таблице, если её сначала сериализовать, а потом результат распарсить. Для простоты можно посмотреть на преобразование:
Оказалось, что для аркадийного THashMap - порядок очень похож на исходный: на 100к элементов вышло всего 746 отклонений. Но 0 отклонений простыми подготовками контейнера-приёмника не получил.
С unordered-map всё чуть интереснее вышло, и вариант с 0 отклонений тоже получить удалось. Так как текста вышло многовато - выложил на хабр https://habr.com/ru/articles/1010588/ , можно глянуть если любопытно
Задался вопросом - как связаны порядки (в смысле перебора от begin к end) элементов в хеш-таблице, если её сначала сериализовать, а потом результат распарсить. Для простоты можно посмотреть на преобразование:
template<class T>
T RefillSimple(const T& x) {
T res;
for(auto& p : x) {
res[p.first] = p.second;
}
return res;
}
Оказалось, что для аркадийного THashMap - порядок очень похож на исходный: на 100к элементов вышло всего 746 отклонений. Но 0 отклонений простыми подготовками контейнера-приёмника не получил.
С unordered-map всё чуть интереснее вышло, и вариант с 0 отклонений тоже получить удалось. Так как текста вышло многовато - выложил на хабр https://habr.com/ru/articles/1010588/ , можно глянуть если любопытно
Хабр
А давайте переложим данные из unordered_map в unordered_map
Наверное, многим знакома проблема с хеш-таблицами и детерминированностью их сериализации. Если менять что-нибудь в базовых либах, то быстро находятся тесты, которые дифуют из-за изменения порядка...
🔥3
Случился у нас #incident
Новый код (немного неожиданно сразу выехавший во включённом режиме) - имел две ветки. Вторая ветка срабатывала если число некоторых событий было достаточно велико. Оказалось, что в этой ветке банальный nullptr-dereference - ветка не сработала нигде в тестировании (*), да и под трафиком без проблем сборка жила несколько часов.
Лимит, разделявший две ветки, оказался таким, что при закрытии одного из дц - в остальных он был превышен - всё там стало падать.
Дц открывается - а тут засада: поды не поднимаются одновременно, и те кто первый стал доступен трафику - от этого же трафика тут же умирают (ибо трафик делится на слишком малое число подов - тех кто поднялся в это же время).
По этому случаю, задумался что надо ввести и оттренировать применение
Шаг 1: через service-discovery снимаем трафик со всех подов кроме 1 (ибо система умная - обнулить не даст)
Шаг 2: а когда всё кроме этого пода (который убивается трафлом) поднялось - разово открыться
Должно помогать выходить из клинча, когда руткоз в превышении событий (скоррелированных с трафиком) на под, и система выйдя из баланса не может в него вернуться сама. А то могут быть ситуации, когда стейта для отката нет под рукой. Например, если стрельнуло какое-то старое багло (просто не проявлявшееся), и варить хотфикс долго..
(*) тест у нас кстати с прошлого раза появился, который примерно такое и должен ловить. и он даже ловит, но ещё не был признан достаточно не шумным и стоял сбоку. теперь будет обязательным
Новый код (немного неожиданно сразу выехавший во включённом режиме) - имел две ветки. Вторая ветка срабатывала если число некоторых событий было достаточно велико. Оказалось, что в этой ветке банальный nullptr-dereference - ветка не сработала нигде в тестировании (*), да и под трафиком без проблем сборка жила несколько часов.
Лимит, разделявший две ветки, оказался таким, что при закрытии одного из дц - в остальных он был превышен - всё там стало падать.
Дц открывается - а тут засада: поды не поднимаются одновременно, и те кто первый стал доступен трафику - от этого же трафика тут же умирают (ибо трафик делится на слишком малое число подов - тех кто поднялся в это же время).
По этому случаю, задумался что надо ввести и оттренировать применение
манёвра трафиком "козёл отпущения": Шаг 1: через service-discovery снимаем трафик со всех подов кроме 1 (ибо система умная - обнулить не даст)
Шаг 2: а когда всё кроме этого пода (который убивается трафлом) поднялось - разово открыться
Должно помогать выходить из клинча, когда руткоз в превышении событий (скоррелированных с трафиком) на под, и система выйдя из баланса не может в него вернуться сама. А то могут быть ситуации, когда стейта для отката нет под рукой. Например, если стрельнуло какое-то старое багло (просто не проявлявшееся), и варить хотфикс долго..
(*) тест у нас кстати с прошлого раза появился, который примерно такое и должен ловить. и он даже ловит, но ещё не был признан достаточно не шумным и стоял сбоку. теперь будет обязательным
😱1
#cpp
Сравнивал разные методы сделать call-once (а точнее инициализацию синглтона) и решил оформить это в небольшую заметку
Итак, нам нужно создать какой-то объект, пусть даже что-то нетривиальное (для определённости можно представлять что мы хотим прочитать данные из файла/эмбеддет ресурсов и разложить эти данные в хеш-мапу для будущих чтений). Рассмотрим следующие варианты:
1. статичный глобальный объект, вычисление вызывается в конструкторе
2. статичный глобальный объект + std::call_once
3. статичный локальный объект в функции, вычисление вызывается в конструкторе
Чтобы в годболте видеть что и где вызывается - определяем тип имеющий только объявления методов (это заставит компилятор ставить явный
Особенности первого варианта:
- вычисление "до main"
- неконтролируемый порядок инициализации глобальных ответов => нельзя иметь зависимости между такими объектами
- вычисление только в рамках 1 потока
- уникальный плюс: нет проверок "на горячем цикле" - при обращении к объекту вызывающая функция может считать что данные уже готовы (но именно этот плюс стоит нам второго пункта)
В годболте можно посмотреть на func1 - видно что она скомпилилась просто в забор поинтера, и
Также видно что были порождены - указание что нужно разместить объект
А также
Второй вариант (более правдоподобно было бы внести call_once внутрь глобального объекта, но для простоты сравнения сделал как ниже)
- настоящая инициализация случается только в момент первого использования (ленивая инициализация). Это может быть как плюс (позволяет не тратиться на инициализацию если данные не будут использоваться), так и минус (первые запросы/итерации будут медленными)
- разные инициализаторы данных вполне могут работать одновременно из разных потоков
- имхо - дорогой основной цикл (очень много инструкций до
Третий вариант link
- в рантайме есть проверка - но это проверка и так нужного нам указателя на null
- если указатель не нулевой, то переход не будет выполнен и мы сразу переходим к
- в случае если указатель не готов, то подготовка случается под блокировкой (
- конкретно в годболте не видно, но создаются слоты для
Итого: ленивое вычисление, также возможна многопоточная инициализация, а также дешёвый success-path
Я бы назвал относительным неудобством третьего варианта, что указатель оказывается скрыт внутри функции, и надо сделать ещё доп действие чтобы использовать объект из нескольких функций. Пример обёртки из аркадии - Singleton<type>.
Сравнивал разные методы сделать call-once (а точнее инициализацию синглтона) и решил оформить это в небольшую заметку
Итак, нам нужно создать какой-то объект, пусть даже что-то нетривиальное (для определённости можно представлять что мы хотим прочитать данные из файла/эмбеддет ресурсов и разложить эти данные в хеш-мапу для будущих чтений). Рассмотрим следующие варианты:
1. статичный глобальный объект, вычисление вызывается в конструкторе
2. статичный глобальный объект + std::call_once
3. статичный локальный объект в функции, вычисление вызывается в конструкторе
Чтобы в годболте видеть что и где вызывается - определяем тип имеющий только объявления методов (это заставит компилятор ставить явный
call)struct TSomeExternal {
TSomeExternal(int);
~TSomeExternal();
int DoCalc();
char Data[60]; // просто чтобы было проще увидеть
};Особенности первого варианта:
- вычисление "до main"
- неконтролируемый порядок инициализации глобальных ответов => нельзя иметь зависимости между такими объектами
- вычисление только в рамках 1 потока
- уникальный плюс: нет проверок "на горячем цикле" - при обращении к объекту вызывающая функция может считать что данные уже готовы (но именно этот плюс стоит нам второго пункта)
В годболте можно посмотреть на func1 - видно что она скомпилилась просто в забор поинтера, и
callTSomeExternal GlobalValue(7);
int func1() {
int x = GlobalValue.DoCalc();
return x + 14;
}
Также видно что были порождены - указание что нужно разместить объект
GlobalValue:
.zero 60
А также
_GLOBAL__sub_I_example.cpp - там видно, что вызывается конструктор, и регистрируется указатель на деструктор в пост-колбеках (__cxa_atexit)Второй вариант (более правдоподобно было бы внести call_once внутрь глобального объекта, но для простоты сравнения сделал как ниже)
static std::once_flag flag;
static std::unique_ptr<TSomeExternal> ptr;
void init() {ptr = std::make_unique<TSomeExternal>(7);}
int func2() {
std::call_once(flag, init);
int x = ptr->DoCalc();
return x + 14;
}
- настоящая инициализация случается только в момент первого использования (ленивая инициализация). Это может быть как плюс (позволяет не тратиться на инициализацию если данные не будут использоваться), так и минус (первые запросы/итерации будут медленными)
- разные инициализаторы данных вполне могут работать одновременно из разных потоков
- имхо - дорогой основной цикл (очень много инструкций до
TSomeExternal::DoCalc) - это всё подготовка вызова call pthread_once, который будет выполнен каждый разТретий вариант link
int func3() {
static TSomeExternal prepared = {7};
int x = prepared.DoCalc();
return x + 14;
}- в рантайме есть проверка - но это проверка и так нужного нам указателя на null
push rbx
movzx eax, byte ptr [rip + guard variable for func3()::prepared]
test al, al
je .LBB0_1
- если указатель не нулевой, то переход не будет выполнен и мы сразу переходим к
lea rdi, [rip + func3()::prepared]
call TSomeExternal::DoCalc()@PLT
- в случае если указатель не готов, то подготовка случается под блокировкой (
__cxa_guard_acquire)- конкретно в годболте не видно, но создаются слоты для
guard variable for func3()::prepared и func3()::prepared (в этом смысле разницы со вторым вариантом мало)Итого: ленивое вычисление, также возможна многопоточная инициализация, а также дешёвый success-path
Я бы назвал относительным неудобством третьего варианта, что указатель оказывается скрыт внутри функции, и надо сделать ещё доп действие чтобы использовать объект из нескольких функций. Пример обёртки из аркадии - Singleton<type>.
godbolt.org
Compiler Explorer - C++ (x86-64 clang (trunk))
struct TSomeExternal {
TSomeExternal(int);
~TSomeExternal();
int DoCalc();
char Data[60];
};
TSomeExternal GlobalValue(7);
int func1() {
int x = GlobalValue.DoCalc();
return x + 14;
}
// int func2() {
// static TSomeExternal…
TSomeExternal(int);
~TSomeExternal();
int DoCalc();
char Data[60];
};
TSomeExternal GlobalValue(7);
int func1() {
int x = GlobalValue.DoCalc();
return x + 14;
}
// int func2() {
// static TSomeExternal…
🔥1
На скрине показано 40 минут графиков с балансировки некоторого эндпоинта. На выделенном участке видно 129.01 рпс успехов и 7.27 ошибок 4xx, которые являлись 429 от рпс-лимитера. Настройка рпс-лимитера находилась на уровне "не более 150 запросов с интервалом в 1 секунду". Не странно ли видеть такое уверенное и постоянное превышение лимита?
В прошлый раз мы уже замечали что пуассоновское распределение неплохо справляется с описанием поведения объёма потока в интервал времени
Во-первых давайте прикинем - какова вероятность что у нас на интервале в 1 секунду будет хотя бы 151 запрос:
То есть - где-то 11%, но это на 1-секундный интервал. Графики у нас 15-секундные, что даёт > 1 интервала с превышением в среднем на точке графика - уже неплохо, нам хотя бы не удивительно что график уверенно показывает положительное число 429.
Давайте попробуем теперь объяснить наблюдаемый рпс ошибок. Посчитаем условное мат-ожидание
Я стал искать причины смещений. Первое что я нашёл - скорее всего клиентская сторона делает безусловный 1 ретрай. То есть надо исходный рпс событий считать
Далее, порядок ошибки и проверка некоторых графиков заставили меня вспомнить что рпс-лимитер - это 6-подовый сервис. Распределённые рпс-лимитеры можно реализовывать по разному, а также можно сильно по разному делать логику раздачи разрешений (Leaky-Bucket, сброс в начале секунды и другие).
Давайте предположим, что рпс-лимитер работает так:
- каждый лимитер раз в икс времени узнаёт число живых соседей, и делит разрешённый лимит на найденное число соседей
- синхронизации числа не выданных разрешений не происходит
- сброс счётчика разрешённых запросов в начале периода
В такой ситуации - у нас 6 независимых точек принятия решения, и формулы выходят такими
Один лимитер при таких числах имеет ожидаемое число ошибок в 0.57, а общее число ошибок (умножением на 12) получается 6.85.
Кажется, на этом можно успокоиться и считать что поведение (на уровне принципов) объяснено, возможно лимитер действительно примерно предположенным образом и работает. В качестве выводов, думаю - видно как сильно дробление (шардирование) увеличивает ожидаемое число превышений - почти что линейно.
#math
В прошлый раз мы уже замечали что пуассоновское распределение неплохо справляется с описанием поведения объёма потока в интервал времени
(*). Давайте попробуем, используя его же, попробовать объяснить наблюдаемое.Во-первых давайте прикинем - какова вероятность что у нас на интервале в 1 секунду будет хотя бы 151 запрос:
def get_prob_at_least(border, lmbd):
sum = 1
mult = 1
for i in range(1, border):
mult = mult * lmbd / i
sum += mult
return 1 - sum * math.exp(-lmbd)
get_prob_at_least(151, 129.01 + 7.27)
0.1127
То есть - где-то 11%, но это на 1-секундный интервал. Графики у нас 15-секундные, что даёт > 1 интервала с превышением в среднем на точке графика - уже неплохо, нам хотя бы не удивительно что график уверенно показывает положительное число 429.
Давайте попробуем теперь объяснить наблюдаемый рпс ошибок. Посчитаем условное мат-ожидание
(**) числа запросов, при условии что у нас их пришло хотя бы 151:def get_expectation_via_conditional_at_least(border, lmbd):
sum_prob = 0
exp_sum = 0
mult = 1
for i in range(1, 10000):
mult = mult * lmbd / i
if (i >= border):
exp_sum += i * mult
sum_prob += mult
return exp_sum / sum_prob
def get_expected_errors_num(events, border):
return (get_expectation_via_conditional_at_least(border, events) - border) * get_prob_at_least(border, events)
get_expectation_via_conditional_at_least(151, 129.01 + 7.27) даёт 156.4, а ожидаемое число ошибок выходит всего лишь 0.6 рпс - существенная разница с 7.27, которые мы хотели бы объяснить.Я стал искать причины смещений. Первое что я нашёл - скорее всего клиентская сторона делает безусловный 1 ретрай. То есть надо исходный рпс событий считать
129.01 + 7.27/2, а результат числа ошибок удваивать. Эта поправка почти ничего не даёт - 0.72 рпс ошибок.Далее, порядок ошибки и проверка некоторых графиков заставили меня вспомнить что рпс-лимитер - это 6-подовый сервис. Распределённые рпс-лимитеры можно реализовывать по разному, а также можно сильно по разному делать логику раздачи разрешений (Leaky-Bucket, сброс в начале секунды и другие).
Давайте предположим, что рпс-лимитер работает так:
- каждый лимитер раз в икс времени узнаёт число живых соседей, и делит разрешённый лимит на найденное число соседей
- синхронизации числа не выданных разрешений не происходит
- сброс счётчика разрешённых запросов в начале периода
В такой ситуации - у нас 6 независимых точек принятия решения, и формулы выходят такими
limiters_num = 6
retry_num = 2
rps_one_limiter=(129.01 + 7.27 / 2)/limiters_num
border_one_limiter=int(150/limiters_num) + 1
one_limiter_erros = get_expected_errors_num(rps_one_limiter, border_one_limiter)
limited_num = one_limiter_erros * limiters_num * retry_num
Один лимитер при таких числах имеет ожидаемое число ошибок в 0.57, а общее число ошибок (умножением на 12) получается 6.85.
Кажется, на этом можно успокоиться и считать что поведение (на уровне принципов) объяснено, возможно лимитер действительно примерно предположенным образом и работает. В качестве выводов, думаю - видно как сильно дробление (шардирование) увеличивает ожидаемое число превышений - почти что линейно.
(*) Имхо, это работает потому что здесь идёт речь о достаточно чистом консьюмерском трафике (без роботного трафика) - действительно "около бесконечное число монеток" (юзеров) решают в каждый момент времени "воспользоваться ли сервисом", то есть примерно условия предельного перехода для пуассоновского распределения и получаются.(**) Мне кажется, это должно было быть для вас также неожиданно, как испанская инквизиция.#math
#cpp
https://en.cppreference.com/w/cpp/utility/source_location.html
Коллега показал прикольную штуку -
как легко догадаться (или посмотреть полный пример по ссылке) - дефолтное значение в функцию вычислится в точке вызова
> NOTE: это кстати вполне общая механика, компилятор не "предсоздаёт дефолтное значение функции", а добавляет его вычисление в точке вызова указанным в объявлении выражением. В примере можно увидеть два вызова конструкторов TSomeObject
Сделано оно конечно скучно -
Последнее что хочется добавить - сравнение со старым макросным аналогом -
https://en.cppreference.com/w/cpp/utility/source_location.html
Коллега показал прикольную штуку -
std::source_location (ещё в 20-ых плюсах добавили) - артефакт борьбы с макросами (с __FILE__ и т.п. ), позволяет в лог добавить файл, строку, имя функцииvoid log(const std::string_view message,
const std::source_location location =
std::source_location::current())
как легко догадаться (или посмотреть полный пример по ссылке) - дефолтное значение в функцию вычислится в точке вызова
> NOTE: это кстати вполне общая механика, компилятор не "предсоздаёт дефолтное значение функции", а добавляет его вычисление в точке вызова указанным в объявлении выражением. В примере можно увидеть два вызова конструкторов TSomeObject
Сделано оно конечно скучно -
__builtin_source_location, то есть нужна спец поддержка компилятора.Последнее что хочется добавить - сравнение со старым макросным аналогом -
TSourceLocation аркадийным. Тот хранит меньше инфы (не хранит десимволизированное имя функции и смещение в строке), что даже полезно - найти функцию по строке проблема не большая, а новые строки в бинарник добавляются (которые даже при strip не получится удалить). Более того - строки которые создаёт source_location - содержат подстановку шаблонных параметров (то есть полное имя инстанцированной шаблонной функции, если она шаблонная), что конечно может вызывать нежелательное раздутие бинаринка, если использовать повсеместно. К сожалению, не нашёл сходу как создать std::source_location, который бы не содержал часть полей (приватные поля нельзя поправить), но сделать свою обёртку вполне возможно - в примере с TSmallSourceLocation можно увидеть, что компилятор породил константу void useLog1(), а аналога для useLog2 в выхлоп не добавил, при этом в выхлопе строчки и файл верно подставилисьgodbolt.org
Compiler Explorer - C++ (x86-64 clang (trunk))
struct TSomeObject {
TSomeObject(int x);
~TSomeObject();
};
void call(TSomeObject x = TSomeObject(3));
void funс1()
{
call();
}
void funс2()
{
call();
}
TSomeObject(int x);
~TSomeObject();
};
void call(TSomeObject x = TSomeObject(3));
void funс1()
{
call();
}
void funс2()
{
call();
}
👍4
Какие-то непорядки с сетевыми походами - наверное основной поток проблем или #incident-ов. В конце концов, большая часть технических поджогов алёртов так или иначе будет связана с тем что какое-то ребро сетевого графа повело себя не ок. Несмотря на некоторую избитость основных сюжетов, всё таки во многих инцидентах даже с рутинными руткозами часто проявляется "кластерный" момент (есть ещё странное слово "эмерджентность"): 1) казалось бы логичное поведение каждого узла приводит не всегда к ожидаемым эффектам на уровне всего кластера 2) именно учёт поведения системы целиком является основным ориентиром.
Например: ну кого удивишь ретраем запроса, конечно почти всегда нужны ретраи. С другой стороны - удиви кого-нибудь тем, что систему завалило именно ретраями, когда в каком-то месте стало немного тонко. Поэтому, лучше иметь лимиты на долю ретраев (по крайней мере если и клиент и сервер находятся у вас в эксплуатации).
Была недавно пара как раз проблем в районе сетевых походов и балансировки. Первый инцидент - вскрыл багу (или точнее немного неожиданный дефолт), а второй (уже на другой день), как часто бывает, был результатом желания исправить/предотвратить рецидив (инциденты тоже любят возвращаться на место преступления).
Неочевидный дефолт был в том, что хеш балансировки вычислялся немного детерменированно. Логика даже понятна: где-то видимо хотелось переюзать локальные кеши эндпоинтов. В потоке (после роста на порядок, и ещё потом на треть из-за маневрирований трафиком) запросов оказалось достаточно много, чтобы повторные задачи забивали очередь подов, куда их балансирует (на этих подах рпс в несколько раз выше оказывается).
И о чудо - параметры сервиса даже удовлетворяли свойству, что (размер очереди) * (среднее время запроса) / (число воркеров) меньше клиентского таймаута, то есть клиент должен был бы получить нормальные отлупы.
Но наложилось дополнительно ещё то, что начанали тригерится дропы пакетов (кончилась сеть на эндпоинтах перегруженных), плюс важный момент - ретраи в нашем случае обходят эндпоинты тоже в детерменированном порядке.
В итоге: относительно небольшая доля (в районе 5-6%) подов не очень хорошо отрабатывала запросы, причём доля ошибок укладывалась в 6% в квадрате (в районе 70-100 ошибок на 20к рпс) - по всей видимости потому, что "невезло" тем у кого и запрос и хедж попали в плохие поды. И казалось бы - мелочь и рутина, 3 девятки на ребре - только очень чувствительные алёрты подожгутся.
Но вот если ребро критичное, и в графе их несколько (то есть нужно чтобы были успешны все походы), то вероятность обратно умножается (примерно на число таких рёбер в графе). В нашем случае - обратно до ~6% раскрыло.
Второй акт же случился от попытки что-то поменять в дефолтах (но точечно включить "обратный флаг" на те рёбра, которым действительно нужна детерменированность). И, конечно, вскрылись нюансы (иногда "детерменированность нужна, но лишь при других спец параметрах"), из-за которого детерменированный перебор эндпоинтов привёл к неответам (тоже критичного для успешности общего результата ребра).
Самый неприятный момент в этом всём - это очень тяжело ловится тестированием - в обоих случаях эффекты можно было проявить только на большом трафике.
Ну что ж, буду надеяться, что в следующей итерации починки/предотвращения не вскроются новые неучтённые случаи, на которые уже не явно завязалась система.
Например: ну кого удивишь ретраем запроса, конечно почти всегда нужны ретраи. С другой стороны - удиви кого-нибудь тем, что систему завалило именно ретраями, когда в каком-то месте стало немного тонко. Поэтому, лучше иметь лимиты на долю ретраев (по крайней мере если и клиент и сервер находятся у вас в эксплуатации).
Была недавно пара как раз проблем в районе сетевых походов и балансировки. Первый инцидент - вскрыл багу (или точнее немного неожиданный дефолт), а второй (уже на другой день), как часто бывает, был результатом желания исправить/предотвратить рецидив (инциденты тоже любят возвращаться на место преступления).
Неочевидный дефолт был в том, что хеш балансировки вычислялся немного детерменированно. Логика даже понятна: где-то видимо хотелось переюзать локальные кеши эндпоинтов. В потоке (после роста на порядок, и ещё потом на треть из-за маневрирований трафиком) запросов оказалось достаточно много, чтобы повторные задачи забивали очередь подов, куда их балансирует (на этих подах рпс в несколько раз выше оказывается).
И о чудо - параметры сервиса даже удовлетворяли свойству, что (размер очереди) * (среднее время запроса) / (число воркеров) меньше клиентского таймаута, то есть клиент должен был бы получить нормальные отлупы.
Но наложилось дополнительно ещё то, что начанали тригерится дропы пакетов (кончилась сеть на эндпоинтах перегруженных), плюс важный момент - ретраи в нашем случае обходят эндпоинты тоже в детерменированном порядке.
В итоге: относительно небольшая доля (в районе 5-6%) подов не очень хорошо отрабатывала запросы, причём доля ошибок укладывалась в 6% в квадрате (в районе 70-100 ошибок на 20к рпс) - по всей видимости потому, что "невезло" тем у кого и запрос и хедж попали в плохие поды. И казалось бы - мелочь и рутина, 3 девятки на ребре - только очень чувствительные алёрты подожгутся.
Но вот если ребро критичное, и в графе их несколько (то есть нужно чтобы были успешны все походы), то вероятность обратно умножается (примерно на число таких рёбер в графе). В нашем случае - обратно до ~6% раскрыло.
Второй акт же случился от попытки что-то поменять в дефолтах (но точечно включить "обратный флаг" на те рёбра, которым действительно нужна детерменированность). И, конечно, вскрылись нюансы (иногда "детерменированность нужна, но лишь при других спец параметрах"), из-за которого детерменированный перебор эндпоинтов привёл к неответам (тоже критичного для успешности общего результата ребра).
Самый неприятный момент в этом всём - это очень тяжело ловится тестированием - в обоих случаях эффекты можно было проявить только на большом трафике.
Ну что ж, буду надеяться, что в следующей итерации починки/предотвращения не вскроются новые неучтённые случаи, на которые уже не явно завязалась система.
Хотел прояснить себе некоторые вопросы про бранч-предиктор и всё такое.
Сначала хотел попытаться как-то по-обамнывать бранч-предиктор (условно сделать ветку которую мы берём в ~10% случаев, или что-то такое - увидеть что и бранч миссов примерно столько же).
1) Сходу у меня не получилось, простые кольцевые итерации ( a % b == c; a += d) упорно безошибочно (0.1% и меньше ошибок) угадывались.
2) у меня вообще не бились числа по абсолютам которые я видел
В итоге редуцировал всё до простейшей программы с 1 циклом (https://godbolt.org/z/s75WvqaGn) - там в принципе видно один повторяющийся бранч (
Получаю типично что-то вида
10млн итераций цикла - примерно 15млн бранчей в перф стат, 20млн -> 30 млн, 30 -> 45. И счётчик то называется
Подумал - может дело в виртуализации (но не целый коэффициент прямо очень странный). Проверил что добавление инструкций в тело цикла (то есть просто увеличение числа команд) по моим наблюдениям ничего не менял (какие-то способы либо найти множитель от виртуализации на кажду инструкцию, либо увеличить расстояние от старта цикла до джампа на проверку).
Потом ещё проверил команду в другом окружении (там вроде нет виртуализации, только контейнерная изоляция) - там вообще соотношение вышло 2 * iters + 3mln
Пока у меня нет ни каких ответов. Может быть кто-то из прочитавших подскажет куда покапать, что почитать (а то и просто знает - что же означает счётчик branches)?
Сначала хотел попытаться как-то по-обамнывать бранч-предиктор (условно сделать ветку которую мы берём в ~10% случаев, или что-то такое - увидеть что и бранч миссов примерно столько же).
1) Сходу у меня не получилось, простые кольцевые итерации ( a % b == c; a += d) упорно безошибочно (0.1% и меньше ошибок) угадывались.
2) у меня вообще не бились числа по абсолютам которые я видел
В итоге редуцировал всё до простейшей программы с 1 циклом (https://godbolt.org/z/s75WvqaGn) - там в принципе видно один повторяющийся бранч (
jge .LBB0_4). Собираю без оптимизаций и натравливаю perf stat.Получаю типично что-то вида
10млн итераций цикла - примерно 15млн бранчей в перф стат, 20млн -> 30 млн, 30 -> 45. И счётчик то называется
branches:u (то есть вроде как речь о юзер-спейсе должна идти).Подумал - может дело в виртуализации (но не целый коэффициент прямо очень странный). Проверил что добавление инструкций в тело цикла (то есть просто увеличение числа команд) по моим наблюдениям ничего не менял (какие-то способы либо найти множитель от виртуализации на кажду инструкцию, либо увеличить расстояние от старта цикла до джампа на проверку).
Потом ещё проверил команду в другом окружении (там вроде нет виртуализации, только контейнерная изоляция) - там вообще соотношение вышло 2 * iters + 3mln
Пока у меня нет ни каких ответов. Может быть кто-то из прочитавших подскажет куда покапать, что почитать (а то и просто знает - что же означает счётчик branches)?
godbolt.org
Compiler Explorer - C++ (x86-64 clang (trunk))
int main(int argc, const char* argv[]) {
assert(argc == 3);
int64_t iters = atoll(argv[1]);
int64_t step = atoll(argv[2]);
int64_t value = 0;
for(int64_t i = 0; i < iters; ++i) {
value += step;
}
std::cout << value <<…
assert(argc == 3);
int64_t iters = atoll(argv[1]);
int64_t step = atoll(argv[2]);
int64_t value = 0;
for(int64_t i = 0; i < iters; ++i) {
value += step;
}
std::cout << value <<…
#флуд #cpp
Плюсы всегда казались мне странным языком, со своим путём и очень странными решениями
Например, среди первого, что вас встречает при знакомстве:
По честному, можно ли, не зная, что из себя представляет vector, догадаться что это именно оно ?
Если пытаться угадать, какая была логика названий, то я бы предположил что
- array - занят си массивами
- а вектор (в линейной алгебре) - это что-то про набор однотипных значений
Других pro- я сходу не придумал, зато причин, почему название (предположительно апеллирующее к алгебраическому термину) неудачное хватает:
- часто вы видели вектора неизвестной размерности?
- часто в алгебраический вектор происходит "добавление размерности с расширением" (
- может быть у std::vector из коробки переопределены какие-нибудь по-элементные операции? (типа сложения векторов как поэлементное сложение)?
Вторая (наверное даже первая, ведь ещё в хелло ворлд оно будет) необычность (того, что не выпячивается в других языках), которая встретится новичку при знакомстве с плюсами - стримы ввода и вывода. Меня не покидает ощущение, что главная причина стримов выглядеть именно так - просто хоть где-то продемонстрировать перегрузку операторов (и хоть куда-то в stl навернуть иерархию наследования типов и виртуальных методов`(*)`). И получилось ведь так себе: перегружаются операторы выражений, и поменять приоритет разбора - нельзя. В итоге то тут то там можно неожиданно встрять, из-за того
Плюсы всегда казались мне странным языком, со своим путём и очень странными решениями
Например, среди первого, что вас встречает при знакомстве:
std::vector для работы с массивами динамической длины.По честному, можно ли, не зная, что из себя представляет vector, догадаться что это именно оно ?
Если пытаться угадать, какая была логика названий, то я бы предположил что
- array - занят си массивами
- а вектор (в линейной алгебре) - это что-то про набор однотипных значений
Других pro- я сходу не придумал, зато причин, почему название (предположительно апеллирующее к алгебраическому термину) неудачное хватает:
- часто вы видели вектора неизвестной размерности?
- часто в алгебраический вектор происходит "добавление размерности с расширением" (
push_back) ?- может быть у std::vector из коробки переопределены какие-нибудь по-элементные операции? (типа сложения векторов как поэлементное сложение)?
std::array<, const> в этом смысле намного больше похож на алгебраический вектор: у него только последний пункт из трёх contra- остаётся.Вторая (наверное даже первая, ведь ещё в хелло ворлд оно будет) необычность (того, что не выпячивается в других языках), которая встретится новичку при знакомстве с плюсами - стримы ввода и вывода. Меня не покидает ощущение, что главная причина стримов выглядеть именно так - просто хоть где-то продемонстрировать перегрузку операторов (и хоть куда-то в stl навернуть иерархию наследования типов и виртуальных методов`(*)`). И получилось ведь так себе: перегружаются операторы выражений, и поменять приоритет разбора - нельзя. В итоге то тут то там можно неожиданно встрять, из-за того
<< определился не как вывод, а как сдвиг куска выражения.(*) по датам вроде не бьётся, но необычность iostream, в сравнении с остальным stl, сложно не замечать💯5
#cpp
Прекрасное дополнение к лямбдам, появившееся вместе с ними в с++11, это
type-erasure обёртка, которая при конструировании запоминает конструктор, деструктор, копирование, ну и invoke.
Часто заменяет собой использование интерфейсов (виртуальных методов), особенно когда нужно было бы только только одну функцию в интерфейсе определять.
Вопрос - насколько компилятор хорошо справляется с разворачиванием
Рассмотрим функцию
(а также объявим без определения
Результат компиляции с лямбдой
оказался достаточно интересным. Если заменить
А с std::function сложнее: будет и сконструирован объект std::function, и будет вызван его деструктор
Конструирование - заполнение полей (указателей на функции)
Потом вызовы (+ прикапывание rax с результатом
И деструктор
Сохранение тройки - это как раз выбор деструктора [1] [2]
Далее ещё идёт блок для разворачивания стека на случай вызова исключений. Если
Итого: в принципе компилятор может справляться с разворачиванием (инлайнингом)
https://godbolt.org/z/zxEef3ch8
Прекрасное дополнение к лямбдам, появившееся вместе с ними в с++11, это
std::function:type-erasure обёртка, которая при конструировании запоминает конструктор, деструктор, копирование, ну и invoke.
Часто заменяет собой использование интерфейсов (виртуальных методов), особенно когда нужно было бы только только одну функцию в интерфейсе определять.
Вопрос - насколько компилятор хорошо справляется с разворачиванием
std::function?Рассмотрим функцию
int MakeAction(std::function<int(void)> f) {
MarkerStart();
int x = f();
MarkerEnd();
return x;
}(а также объявим без определения
MarkerStart, MarkerInnerActionInt, MarkerEnd чтобы по call директивам следить что думает компиляция)Результат компиляции с лямбдой
void MakeActionLambda() {
MakeAction([](){return MarkerInnerAction();});
}оказался достаточно интересным. Если заменить
int MakeAction(std::function<int(void)> f) на template<class F> int MakeAction(F&& f) то компилятор просто сделает 3 call-а, то есть ожидаемым образом всё подставит.А с std::function сложнее: будет и сконструирован объект std::function, и будет вызван его деструктор
Конструирование - заполнение полей (указателей на функции)
push rbx
sub rsp, 32
xorps xmm0, xmm0
movaps xmmword ptr [rsp], xmm0
lea rax, [rip + std::_Function_handler<int (), MakeActionLambda()::$_0>...]
mov qword ptr [rsp + 24], rax
lea rax, [rip + std::_Function_handler<int (), MakeActionLambda()::$_0>...]
mov qword ptr [rsp + 16], rax
Потом вызовы (+ прикапывание rax с результатом
MarkerInnerAction для будущего return)call MarkerStart()@PLT
call MarkerInnerAction()@PLT
call MarkerEnd()@PLT
mov rax, qword ptr [rsp + 16]
И деструктор
test rax, rax
je .LBB2_5
mov rdi, rsp
mov rsi, rdi
mov edx, 3
call rax
Сохранение тройки - это как раз выбор деструктора [1] [2]
Далее ещё идёт блок для разворачивания стека на случай вызова исключений. Если
MarkerStart и MarkerEnd указать как noexcept - то компилятор опять не будет "лишние" инструкции добавлять.Итого: в принципе компилятор может справляться с разворачиванием (инлайнингом)
std::function (если видит его конструирование), но это явно добавляет ему сложностей (можно представить что необходимость как-то аккуратнее сохранять стек для его раскрутки, заставляет создавать объект, но вызывать деструктор который заведомо ничего не делает - выглядит как недооптимизация).https://godbolt.org/z/zxEef3ch8
godbolt.org
Compiler Explorer - C++
void MarkerStart();
int MarkerAction();
int MarkerInnerAction();
int MarkerInnerActionInt(int);
void MarkerEnd();
#ifdef TEMPLATE
template<class F>
int MakeAction(F&& f) {
MarkerStart();
int x = f();
MarkerEnd();
return…
int MarkerAction();
int MarkerInnerAction();
int MarkerInnerActionInt(int);
void MarkerEnd();
#ifdef TEMPLATE
template<class F>
int MakeAction(F&& f) {
MarkerStart();
int x = f();
MarkerEnd();
return…
[info] Server started
#cpp Прекрасное дополнение к лямбдам, появившееся вместе с ними в с++11, это std::function: type-erasure обёртка, которая при конструировании запоминает конструктор, деструктор, копирование, ну и invoke. Часто заменяет собой использование интерфейсов (виртуальных…
Небольшое дополнение к предыдущему посту: посмотрим как себя компилятор ведёт с интерфейсами.
Объявим интерфейс, объявим его реализацию, которая умеет взять лямбду и форварднуть её, ну и тип MakeAction сменим
Примерно любые варианты определений (кстати, спасибо выводу шаблонных параметров классов из конструктора - выглядит это компактно)
Компилятор оптимизирует в одинаковый результат - без всяких лишних действий, и никакого влияния
https://godbolt.org/z/GPnYcKEMc
Объявим интерфейс, объявим его реализацию, которая умеет взять лямбду и форварднуть её, ну и тип MakeAction сменим
struct ICallable {
virtual ~ICallable() {}
virtual int operator()() = 0;
};
template<class F>
struct TLambdaCallable : public ICallable {
F Functor;
TLambdaCallable(F&& f) : Functor(std::move(f))
{}
int operator()() final {
return Functor();
}
};
int MakeAction(ICallable&& f) {
MarkerStart();
int x = f();
MarkerEnd();
return x;
}Примерно любые варианты определений (кстати, спасибо выводу шаблонных параметров классов из конструктора - выглядит это компактно)
void MakeActionFunc() {
MakeAction(TLambdaCallable{&MarkerInnerAction});
}
void MakeActionLambda() {
MakeAction(TLambdaCallable{[](){return MarkerInnerAction();}});
}Компилятор оптимизирует в одинаковый результат - без всяких лишних действий, и никакого влияния
noexcept тоже нет - хорошо и с ним и, без него. Видимо, стоит констатировать, что интерфейсы более понятная компилятору штука, чем std::function.MakeActionLambda():
push rax
call MarkerStart()@PLT
call MarkerInnerAction()@PLT
pop rax
jmp MarkerEnd()@PLT
https://godbolt.org/z/GPnYcKEMc
godbolt.org
Compiler Explorer - C++
#ifdef NOEXCEPT
#define NE noexcept(true)
#else
#define NE
#endif
void MarkerStart() NE;
int MarkerInnerAction();
int MarkerInnerActionInt(int);
void MarkerEnd() NE;
struct ICallable {
virtual ~ICallable() {}
virtual int operator()() = 0;
};
template<class…
#define NE noexcept(true)
#else
#define NE
#endif
void MarkerStart() NE;
int MarkerInnerAction();
int MarkerInnerActionInt(int);
void MarkerEnd() NE;
struct ICallable {
virtual ~ICallable() {}
virtual int operator()() = 0;
};
template<class…
#cpp
Коллега раздебажил и поделился очень интересным проездом.
Проезд случался при вызове
Собрал тут пример (или даже так), показывающий, что сортировка с рандомным компоратором легко выходит за пределы буфера
А вляпаться в такое удалось следующим образом
1. сортировка выполнялась через
2. взятие веса было примерно такого вида
3. это могло привести к нарушению детерменированности: один раз вызов отдал
Вместо итога: а вам никогда не казалось, что shuffle можно реализовать просто подав рандомный компоратор в сортировку? Как мы выше узнали - так легком может и не сработать, но вот если подавать рандомный вес (однозначно определённый/вычисленный/назначенный на элементы на время сортировки), то уже должно работать.
Коллега раздебажил и поделился очень интересным проездом.
Проезд случался при вызове
std::sort(vec.begin(), vec.end()) - сортировка просто выходила за пределы выделенного буфера. Вызов на совершенно корректном рейндже, и ни каких use-after-free с его элементами тоже во время сортировки не происходило.Собрал тут пример (или даже так), показывающий, что сортировка с рандомным компоратором легко выходит за пределы буфера
struct TComparator {
bool operator()(const int* a, const int* b) {
return std::experimental::randint(0, 10) < 5;
}
};А вляпаться в такое удалось следующим образом
1. сортировка выполнялась через
SortBy(container, weightFunction), где weightFunction: item->weight должен отдавать вес элемента, и уже из сравнения весов получаем обычный компоратор.2. взятие веса было примерно такого вида
item->LoadAvg.load() - то есть LoadAvg элементов мог быть изменён параллельно с работой сортировки3. это могло привести к нарушению детерменированности: один раз вызов отдал
a < b , а второй раз a >= b, это в свою очередь может оказаться причиной декремента итератора где-то в кишках сортировкиВместо итога: а вам никогда не казалось, что shuffle можно реализовать просто подав рандомный компоратор в сортировку? Как мы выше узнали - так легком может и не сработать, но вот если подавать рандомный вес (однозначно определённый/вычисленный/назначенный на элементы на время сортировки), то уже должно работать.
godbolt.org
Compiler Explorer - C++ (x86-64 clang (trunk))
struct TComparator {
int mode = 0;
bool operator()(const int* a, const int* b) {
// int64_t apos = &a - ptrsToSort.data();
// int64_t bpos = &b - ptrsToSort.data();
// std::cerr << "cmp " << *a << " vs " << *b << std::endl;…
int mode = 0;
bool operator()(const int* a, const int* b) {
// int64_t apos = &a - ptrsToSort.data();
// int64_t bpos = &b - ptrsToSort.data();
// std::cerr << "cmp " << *a << " vs " << *b << std::endl;…
👍4💊1
[info] Server started
#cpp Коллега раздебажил и поделился очень интересным проездом. Проезд случался при вызове std::sort(vec.begin(), vec.end()) - сортировка просто выходила за пределы выделенного буфера. Вызов на совершенно корректном рейндже, и ни каких use-after-free с его…
небольшое уточнение в догонку
подумал: а где же в обычных сортировках нам приходится дважды выполнять сравнения элементов, ещё и итерироваться от этого вниз ?
базовые алгоритмические подоходы:
- квик сорт таким не страдает
- мердж сорт не страдает
- даже хип сорт, вроде, сам по себе не сломается (да и повторные сравнения там типично не случается)
Значит у нас влияние комбинированных алгоритмов и каких-то оптимизаций.
Если глянуть в стек-трейсы, да и просто в код, то сортировка состоит из
А тут как раз и есть выделенность первого элемента: цикл идёт от
То есть мы в начале поняли, что новый элемент нарушает сортировку (видимо, инвариант что first у нас временно буфер для максимального элемента), тогда мы в рейндж [first + 1, current -1],
подумал: а где же в обычных сортировках нам приходится дважды выполнять сравнения элементов, ещё и итерироваться от этого вниз ?
базовые алгоритмические подоходы:
- квик сорт таким не страдает
- мердж сорт не страдает
- даже хип сорт, вроде, сам по себе не сломается (да и повторные сравнения там типично не случается)
Значит у нас влияние комбинированных алгоритмов и каких-то оптимизаций.
Если глянуть в стек-трейсы, да и просто в код, то сортировка состоит из
__introsort_loop и __final_insertion_sort, ошибка случается в последней.А тут как раз и есть выделенность первого элемента: цикл идёт от
_RandomAccessIterator __i = __first + _Dist(1) , при этом сравнения идут тоже с этим элементом if (__comp(*__i, *__first)). И если сравнение провалено - проваливаемся в __unguarded_linear_insert, где уже и находится итерирование назад --__next, с условием while (__comp(__val, *__next)).То есть мы в начале поняли, что новый элемент нарушает сортировку (видимо, инвариант что first у нас временно буфер для максимального элемента), тогда мы в рейндж [first + 1, current -1],
__unguarded_linear_insert сдвигает элементы вправо до тех пор, пока не найдёт правлиьное место для current. И тут мы вместо контроля итераторов просто опираемся на то, что в крайнем случае остановимся на first.GitHub
gcc/libstdc++-v3/include/bits/stl_algo.h at master · gcc-mirror/gcc
Contribute to gcc-mirror/gcc development by creating an account on GitHub.
👍2
Сначала воспоминание.
Много лун тому назад в ходе дебага непонятных фейлов вскрылось, что "коллеги из соседнего отдела" сначала внедрили сомнительную схему "чисто ради эксперимента", за год утрясок всяких продуктовых вопросов, раздебага метрик - о том, что конструкция на соплях, забыли (не знаю уж специально ли), и после согласования этих самых продуктовых вопросов и метрик - выкатили. На полном трафике то они и попались на приборы.
Суть схемы была даже красива своей экспериментальной дерзостью и скоростью: вместо того чтобы разбираться в стадиях и зависимостях - встроили натуральный mitm между бэком и server-side-rendering компонентами фронтенда. И модифицировали этот внутренний протокол (до этого они ещё явно его на глазок реверс-инженерили). На приборы они попали потому, что падение этого mitm стало дюже заметным - данные пользователю не доходили. При релизе толи железо не долили, то ли в принципе сервис и не пытались делать переживающим хотя бы свои релизы или переселения подов.
Я тогда удивлялся как такое число народу, вроде бы вовлечённых в "фичу", ни разу не задумались о технической стороне вопроса, не спросили как оно сделано, а вообще готово ли оно и всё такое.
Конец отступления: теперь к очередному любопытному #incident , уже свежачку.
Была/есть у нас компонента, которая очередные модельки считает. Запросы в неё (так исторически сложилось) шли по двум разным протоколам (подстраивали калькер под сервисы-рецепиенты данных).
Чтобы унифицировать эксплуатацию калькеров, решили мы убрать "особенность" присущую этой одной инсталяции - перевести всё на "общий протокол". Дело не быстрое - правятся схемы данных, их маршруты меняются, затронуто несколько разных компонент живщих в разных командах.
В общем не слишком форсированно двигали мы тему итерацию за итерацией сводя то одни дифы, то раздебаживая различные прокрасы метрик (либо убирая либо, либо объясняя тем, что баг какой-то пофиксили).
И вот кажется всё серое/объяснённое - релизим.
Первое что вскрылось - железо стали больше есть, при минус-дц фонит ошибками.
Быстро поняли - забыли включить накопление батчей (оно и не тригерилось бы в аб - поток там маленький, батчи бы не накопились).
Ладно - выкатываем с батчингом. И тут - пробиваем кеш компоненты которая "ниже по стеку" (использует данные сервиса).
Если не сталкивались - то в зависимости от размера батча (а иногда даже и от места в нём) - можно словить шум флотов. И вот мы его словили: вместо детерменированного вычисления стали получать небольшую примесь шумов в компонентах выходных векторов.
Почему раньше такого не было? А потому что был кеш вычислений, который мы тоже забыли включить. Включили - вроде пока полёт нормальный.
Что общего с первой историей? А ровно тоже самое - пока согласовывали метрики и сводили дифы, забыли о перфе.
Много лун тому назад в ходе дебага непонятных фейлов вскрылось, что "коллеги из соседнего отдела" сначала внедрили сомнительную схему "чисто ради эксперимента", за год утрясок всяких продуктовых вопросов, раздебага метрик - о том, что конструкция на соплях, забыли (не знаю уж специально ли), и после согласования этих самых продуктовых вопросов и метрик - выкатили. На полном трафике то они и попались на приборы.
Суть схемы была даже красива своей экспериментальной дерзостью и скоростью: вместо того чтобы разбираться в стадиях и зависимостях - встроили натуральный mitm между бэком и server-side-rendering компонентами фронтенда. И модифицировали этот внутренний протокол (до этого они ещё явно его на глазок реверс-инженерили). На приборы они попали потому, что падение этого mitm стало дюже заметным - данные пользователю не доходили. При релизе толи железо не долили, то ли в принципе сервис и не пытались делать переживающим хотя бы свои релизы или переселения подов.
Я тогда удивлялся как такое число народу, вроде бы вовлечённых в "фичу", ни разу не задумались о технической стороне вопроса, не спросили как оно сделано, а вообще готово ли оно и всё такое.
(*)Конец отступления: теперь к очередному любопытному #incident , уже свежачку.
Была/есть у нас компонента, которая очередные модельки считает. Запросы в неё (так исторически сложилось) шли по двум разным протоколам (подстраивали калькер под сервисы-рецепиенты данных).
Чтобы унифицировать эксплуатацию калькеров, решили мы убрать "особенность" присущую этой одной инсталяции - перевести всё на "общий протокол". Дело не быстрое - правятся схемы данных, их маршруты меняются, затронуто несколько разных компонент живщих в разных командах.
В общем не слишком форсированно двигали мы тему итерацию за итерацией сводя то одни дифы, то раздебаживая различные прокрасы метрик (либо убирая либо, либо объясняя тем, что баг какой-то пофиксили).
И вот кажется всё серое/объяснённое - релизим.
Первое что вскрылось - железо стали больше есть, при минус-дц фонит ошибками.
Быстро поняли - забыли включить накопление батчей (оно и не тригерилось бы в аб - поток там маленький, батчи бы не накопились).
Ладно - выкатываем с батчингом. И тут - пробиваем кеш компоненты которая "ниже по стеку" (использует данные сервиса).
Если не сталкивались - то в зависимости от размера батча (а иногда даже и от места в нём) - можно словить шум флотов. И вот мы его словили: вместо детерменированного вычисления стали получать небольшую примесь шумов в компонентах выходных векторов.
Почему раньше такого не было? А потому что был кеш вычислений, который мы тоже забыли включить. Включили - вроде пока полёт нормальный.
Что общего с первой историей? А ровно тоже самое - пока согласовывали метрики и сводили дифы, забыли о перфе.
(*) Удивлялся я в тот раз зря (доказано на себе) - забывать о технических деталях - скорее правило в любых продуктовых согласованиях😢7
Пока продолжаю пополнять посты с тегом #incident , ибо чтобы их написать не нужно особо стараться (нужно, например, что-то не сделать в прошлом), и фабула сама найдётся.
Вчера была очередная беготня и тушение, руткозы, наверное, слишком рутинны и скучны (основной сюжет кмк в давшних не крупных долгах и "сейчас сделаем так", необходимость "потом" про которые или не осознали, или забыли, или забили. и вот они вернулись взять свои проценты с нас нервами). Но был забавный момент, который перекидывает микро-мостик к тегу #math .
В общем: есть некий api "стартани задачу", железо для него, как обычно, разложено в 3 дц. Трафик в ручку приходит на запросы пользователя дважды: 1) из ajax браузера (основной режим) 2) при рендеринге исходного страницы (оптимизация для того чтобы задача раньше стартанула -> раньше завершилась). Если вторая (более ранняя в таймлайне запроса) ветка стригерилась, то основной поход "подконнектится". Почти весь трафик идёт по оптимизированному сценарию (летенси - важная продуктовая метрика).
Сломался сторадж, который обеспечивал этот реконнект, локально это вызывало двойные старты задач (с последующим перегрузом вычислительных мощностей) и некоторые другие неприятности. Пока разбирались со стораджем - отключили оптимизированный сценарий (и убрали фоны ошибок и перегрузы, но замедлили пользователям получение ответа).
Когда проблему купировали, пришло время включать оптимизированную ветку. Включение, как и полагается, покатили по одному дц. И при выкатке в первом получили откуда-то перегруз (хотя увеличенного потока из-за задвоения при ошибка сторадже не было больше). Потупив (сначала смотрели что где-то система определения перегруженности затупила что-то закешила и т.п.) заметили - трафик перетёк в этот самый дц, где включили ветку с более ранним стартом задачи. (неконтролируемые, нежданные перетоки трафика - это конечно страшно)
Разгадка - в независимой балансировке запросов на первой и второй итерациях.
Если у нас выключен "ранний" запрос, то весь трафик просто с вероятностью 1/3 попадает в какой-то из дц и ест его ресурсы.
Если мы включаем "ранний" запрос в одном из дц, то 1/3 запросов, которые пришли туда на первой итерации, там и останется. Остальные 2/3 запроса идут на вторую итерацию, и из них ещё 1/3 доля попадёт в первый дц. Итого 1/3 + 2/3 * 1/3 = 5/9 трафика приземлится в первом дц.
(Тут ещё стоит заметить что эффект и при отключение воспроизводится. Но там это дополнительно улучшало ситуацию в дц где отключали раннюю ветку, и осознан эффект не был, поэтому при откате получился сюрприз)
Пришлось катить включение сразу во все локации. При такой активации уже и трафик нормально себя вёл. (Синкнуть балансировку на итерациях скорее всего не выйдет, к сожалению, хотя было бы полезно - слишком это запутано организционно, маршруты итераций слишком разные)
Вчера была очередная беготня и тушение, руткозы, наверное, слишком рутинны и скучны (основной сюжет кмк в давшних не крупных долгах и "сейчас сделаем так", необходимость "потом" про которые или не осознали, или забыли, или забили. и вот они вернулись взять свои проценты с нас нервами). Но был забавный момент, который перекидывает микро-мостик к тегу #math .
В общем: есть некий api "стартани задачу", железо для него, как обычно, разложено в 3 дц. Трафик в ручку приходит на запросы пользователя дважды: 1) из ajax браузера (основной режим) 2) при рендеринге исходного страницы (оптимизация для того чтобы задача раньше стартанула -> раньше завершилась). Если вторая (более ранняя в таймлайне запроса) ветка стригерилась, то основной поход "подконнектится". Почти весь трафик идёт по оптимизированному сценарию (летенси - важная продуктовая метрика).
Сломался сторадж, который обеспечивал этот реконнект, локально это вызывало двойные старты задач (с последующим перегрузом вычислительных мощностей) и некоторые другие неприятности. Пока разбирались со стораджем - отключили оптимизированный сценарий (и убрали фоны ошибок и перегрузы, но замедлили пользователям получение ответа).
Когда проблему купировали, пришло время включать оптимизированную ветку. Включение, как и полагается, покатили по одному дц. И при выкатке в первом получили откуда-то перегруз (хотя увеличенного потока из-за задвоения при ошибка сторадже не было больше). Потупив (сначала смотрели что где-то система определения перегруженности затупила что-то закешила и т.п.) заметили - трафик перетёк в этот самый дц, где включили ветку с более ранним стартом задачи. (неконтролируемые, нежданные перетоки трафика - это конечно страшно)
Разгадка - в независимой балансировке запросов на первой и второй итерациях.
Если у нас выключен "ранний" запрос, то весь трафик просто с вероятностью 1/3 попадает в какой-то из дц и ест его ресурсы.
Если мы включаем "ранний" запрос в одном из дц, то 1/3 запросов, которые пришли туда на первой итерации, там и останется. Остальные 2/3 запроса идут на вторую итерацию, и из них ещё 1/3 доля попадёт в первый дц. Итого 1/3 + 2/3 * 1/3 = 5/9 трафика приземлится в первом дц.
(Тут ещё стоит заметить что эффект и при отключение воспроизводится. Но там это дополнительно улучшало ситуацию в дц где отключали раннюю ветку, и осознан эффект не был, поэтому при откате получился сюрприз)
Пришлось катить включение сразу во все локации. При такой активации уже и трафик нормально себя вёл. (Синкнуть балансировку на итерациях скорее всего не выйдет, к сожалению, хотя было бы полезно - слишком это запутано организционно, маршруты итераций слишком разные)
👍2