
Немного предыстории
Те, кто прочитал наш первый пост в блоге, знают, что в Oxla мы стремимся выявлять и устранять даже самые незначительные недостатки для повышения производительности. Недавно мы профилировали холодный запуск запроса и обнаружили неожиданную проблему — вызовы memset потребляют значительную часть процессорного времени.
Прежде чем рассказать больше, мы должны углубиться во внутреннюю работу Oxla, чтобы помочь вам понять причину этого наблюдения. Oxla использует распределенное объектное хранилище, такое как S3, для хранения данных. В результате мы не можем полагаться на кэширование файловой системы, обеспечиваемое операционной системой, если сначала не скопируем данные в локальное хранилище.
Мы решили не делать локальные копии файлов, потому что:
- Это добавляет дополнительную задержку между загрузкой данных и их предоставлением.
- Требуется локальное хранилище, которое не так уж и дешево: Elastic Block Storage примерно в 3 раза дороже, чем S3 на AWS.
- Требуется копирование данных между пространством ядра и пространством пользователя.
Что произошло
Итак, возвращаясь к нашему профилированию, вот что мы заметили:

Это привело к ужасному использованию процессора:

Быстрый анализ привел к выводу, что код вызова memset находится здесь:
auto line = std::make_shared<Line>();
А теперь давайте проверим определение структуры Line:
static constexpr std::size_t kLineSize = (1 << 23); // 8MB
struct Line {
char _data[kLineSize];
std::mutex _mtx;
std::atomic<std::size_t> _size = 0;
};
Это простая структура без явного конструктора. Поле _data не инициализируется при выделении: оно заполняется позже, после того как мы получим данные из хранилища. _mtx и _size не используют memset в своих конструкторах. Следуя древней интернет-мудрости «Google это», я решил сделать именно это. Вот что я обнаружил: std::make_shared устанавливает для всей структуры значение 0, если не указан конструктор по умолчанию.
После добавления в структуру следующего конструктора:
Line() {}
Результаты профилирования оказались совершенно разными:

И здесь мы имеем гораздо лучшее использование процессора:

Кажется, проблема решена в C++20 с помощью функции std::make_shared_for_overwrite.
Но важно ли это для вас?
Скорее всего, это не так. Обычно объекты, выделяемые таким образом, небольшие: десятки, максимум сотни байт. Кроме того, типично, что объект используется сразу после выделения. Самая трудоемкая часть инициализации объекта обычно не вычисляет значения для хранения, а извлекает существующее содержимое строк кэша, к которым он принадлежит, из ОЗУ в кэш ЦП. После этого последующие записи выполняются намного быстрее — при условии, что объект хорошо помещается в кеш.
Это означает, что memset выполняет самую сложную часть работы: загружает данные из оперативной памяти в кеш. Последующая фактическая инициализация происходит намного быстрее.
Давайте измерим это. Начнем с определения следующих простых типов:
template <size_t N>
struct Object {
char data[N];
};
template <size_t N>
struct ObjectWithConstructor {
char data[N];
ObjectWithConstructor() {}
};
Некоторые константы:
constexpr size_t total_size = 500000000; // total size of allocations: 0.5GB
constexpr size_t size_small = 100; // small allocation size, simulated typical allocation
constexpr size_t small_allocations_num = total_size / size_small;
constexpr size_t size_large = 100000000; // large allocation, that does not fit into cache, 100MB
constexpr size_t large_allocations_num = total_size / size_large;
Давайте представим, что он выделяет объект с помощью make_shared, не используя его:
template <typename T>
void testWithoutInit(const size_t repeats) {
std::vector<std::shared_ptr<T>>& objs =
*(new std::vector<std::shared_ptr<T>>); // There is memory leak here but we are sure that we get fresh part of
// memory
objs.reserve(repeats);
for (size_t i = 0; i < repeats; ++i) {
auto obj = std::make_shared<T>();
objs.template emplace_back(std::move(obj));
}
}
Вызов testWithoutInit<Object<size_small>>(small_allocations_num); занимает 0,452447 с
Вызов testWithoutInit<Object<size_large>>(large_allocations_num); занимает 0,104688 с
Очевидно, что время выполнения в основном зависит не от объема очищенных данных, а от количества выделений в случае небольших объектов.
Что, если есть конструктор?
Вызов testWithoutInit<ObjectWithConstructor<size_small>>(small_allocations_num); занимает 0,444047 с
Вызов testWithoutInit<ObjectWithConstructor<size_large>>(large_allocations_num); занимает 1,051e-05 с
Для небольших объектов мало что изменилось. С другой стороны, вызов его для большого объекта теперь происходит очень быстро: этот код фактически вызывает malloc 100 раз.
Теперь рассмотрим более реалистичный случай: сначала мы выделяем объект, а затем что-то в него пишем:
template <typename T>
void testWithInit(const size_t repeats, const size_t size) {
std::vector<std::shared_ptr<T>>& objs = *(new std::vector<std::shared_ptr<T>>);
objs.reserve(repeats);
for (size_t i = 0; i < repeats; ++i) {
auto obj = std::make_shared<T>();
char* data = obj->data;
memset(data, 0, size);
objs.template emplace_back(std::move(obj));
}
}

Для небольших объектов, имеющих или не имеющих конструктор, особого значения не имеет: вариант с конструктором работает на ~7% быстрее, а для больших объектов разница составляет ~19%.
Но это не все. Давайте создадим многопоточную версию нашего теста с инициализацией:
template <typename T>
std::chrono::duration<double> testWithInitMulti(const size_t repeats, const size_t size) {
std::condition_variable_any cv;
std::vector<std::thread> threads;
std::shared_mutex mtx;
for (size_t i = 0; i < std::thread::hardware_concurrency(); ++i) {
threads.emplace_back([&cv, &mtx, repeats, size]() {
std::shared_lock lock(mtx);
cv.wait(lock, []() { return true; });
testWithInit<T>(repeats, size);
});
}
const std::chrono::time_point<std::chrono::high_resolution_clock> start;
cv.notify_all();
for (auto& thread : threads) thread.join();
auto finish = std::chrono::high_resolution_clock::now();
return finish - start;
}
Время для варианта без конструктора: 0,702222с (testWithInitMulti<Object<size_large>>(large_allocations_num, size_large);)
Время для варианта с конструктором: 0,419979с (testWithInitMulti<ObjectWithConstructor<size_large>>(large_allocations_num, size_large);)
Вариант с конструктором быстрее на ~40%!
Что здесь произошло? Разве он не должен работать с той же скоростью, что и однопоточный вариант? Между потоками нет связи; они даже не используют совместно память только для чтения.
К сожалению, есть ресурс, которым пользуются все ядра процессора: каналы памяти.
На протяжении десятилетий скорость процессора росла быстрее, чем пропускная способность передачи данных между процессором и оперативной памятью. Это означает, что во многих случаях, когда данные обрабатываются параллельно, мы будем наблюдать, что скорость обработки не зависит линейно от количества используемых ядер.
Из-за этих ограничений мы очень осторожно относимся к передаче памяти в Oxla, чтобы обеспечить нашу производительность. Если вы хотите дать нам шанс, теперь вы можете развернуть один узел кластера всего за 2 минуты. Это бесплатно, так что получайте удовольствие и делитесь с нами своими мыслями по адресу [email protected]!