[Перевод] Как одной строкой сделать 24-ядерный сервер медленнее ноутбука
Лучше учиться на чужих ошибках, поэтому мы в М.Видео-Эльдорадо стремимся изучать зарубежный опыт. Предлагаем и вам посмотреть перевод статьи Петра Колачковского, получившего черный пояс по прокачке производительности своего железа.
Представьте, что вы написали программу для решения удобно распараллеливаемой задачи, в которой каждый поток выполняет собственный независимый фрагмент работы, и потокам не нужно координироваться, за исключением объединения результатов в конце. Очевидно, вы ожидаете, что чем больше ядер используется для выполнения, тем быстрее будет работать программа. Сначала вы проводите бенчмарк на ноутбуке и действительно выясняете, что она отлично масштабируется на все 4 доступных ядра. Потом вы запускаете её на большой, сложной, многопроцессорной машине, ожидая ещё большей производительности, но оказывается, что на самом деле она работает медленнее, чем на ноутбуке, сколько бы ядер вы ей не выделили. Ой-ёй. Именно так недавно произошло у меня.
Недавно я работал над инструментом бенчмаркинга Cassandra под названием Latte — пожалуй, самого эффективного инструмента бенчмаркинга Cassandra, с точки зрения использования как ЦП, так и памяти. В целом его идея очень проста: небольшой фрагмент кода генерирует данные и набор асинхронных операторов CQL для Cassandra. Latte вызывает этот код в цикле и фиксирует, сколько времени занимает каждая итерация. В конце он выполняет статистический анализ и отображает его в различных видах.
Кажется, что бенчмаркинг — это очень удобная для распараллеливания задача. Если подвергаемый бенчмаркингу код не хранит состояния, его достаточно тривиально можно вызывать из нескольких потоков. Я уже писал посты о том, как этого можно добиться на Rust: здесь и
здесь.
Однако когда я писал эти посты, способности задания нагрузки в Latte были довольно ограниченными. В нём было всего две заранее заданных нагрузки, одна для чтения, другая для записи. Параметризировать можно было очень немногие аспекты, такие как количество или размеры столбцов таблиц, но ничего более сложного. Не было никаких вторичных индексов. Никаких собственных операторов фильтрации. Никакого контроля за текстом CQL. Практически ничего. То есть в целом Latte в то время был больше доказательством концепции, чем универсальным инструментом для выполнения реальной работы. Разумеется, можно было форкнуть его и написать новую нагрузку на Rust, а затем скомпилировать всё из исходников, но кому захочется тратить время на изучение внутренностей нишевого инструмента бенчмаркинга?
Скриптинг на Rune
Поэтому в прошлом году, чтобы иметь возможность измерять производительность storage attached indexes в Cassandra, я решил интегрировать Latte с движком скриптинга, позволившим бы мне легко задавать нагрузки без перекомпиляции всей программы. Немного поэкспериментировав с встраиванием операторов CQL в файлы конфигураций TOML (этот процесс оказался одновременно запутанным и ограниченным в возможностях), получив удовольствие от встраивания Lua (который, вероятно, отлично работает в мире C, но с Rust оказался не таким удобным, как я ожидал), я в результате выбрал схему, схожую с используемой в sysbench, но со встроенным интерпретатором Rune вместо Lua.
Основные преимущества Rune для меня — это беспроблемная интеграция с Rust и поддержка асинхронного кода. Благодаря поддержке асинхронности пользователь может исполнять операторы CQL непосредственно в скриптах нагрузок, пользуясь асинхронной природой драйвера Cassandra. Кроме того, команда разработчиков Rune оказалась невероятно полезной и практически мгновенно удалила всё мешавшее мне.
Вот пример полной нагрузки, измеряющей производительность выбора строк по случайным ключам:
const ROW_COUNT = latte::param!("rows", 100000);
const KEYSPACE = "latte";
const TABLE = "basic";
pub async fn schema(ctx) {
ctx.execute(`CREATE KEYSPACE IF NOT EXISTS ${KEYSPACE} \
WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }`).await?;
ctx.execute(`CREATE TABLE IF NOT EXISTS ${KEYSPACE}.${TABLE}(id bigint PRIMARY KEY)`).await?;
}
pub async fn erase(ctx) {
ctx.execute(`TRUNCATE TABLE ${KEYSPACE}.${TABLE}`).await?;
}
pub async fn prepare(ctx) {
ctx.load_cycle_count = ROW_COUNT;
ctx.prepare("insert", `INSERT INTO ${KEYSPACE}.${TABLE}(id) VALUES (:id)`).await?;
ctx.prepare("select", `SELECT * FROM ${KEYSPACE}.${TABLE} WHERE id = :id`).await?;
}
pub async fn load(ctx, i) {
ctx.execute_prepared("insert", [i]).await?;
}
pub async fn run(ctx, i) {
ctx.execute_prepared("select", [latte::hash(i) % ROW_COUNT]).await?;
}
Подробнее о том, как писать такие скрипты, можно прочитать в README.
Бенчмаркинг программы для бенчмаркинга
Хотя скрипты пока не JIT-компилируются в нативный код, они приемлемо быстры и благодаря ограниченному объёму кода в них они обычно не являются основной нагрузкой. Эмпирически я выяснил, что дополнительная нагрузка от Rust-Rune FFI была ниже, чем от Rust-Lua, реализованных с помощью mlua, вероятно из-за используемых mlua проверок безопасности.
Изначально для оценки производительности цикла бенчмаркинга я создал пустой скрипт:
pub async fn run(ctx, i) {
}
Хотя тела функции здесь нет, программе бенчмаркинга для выполнения этого кода нужно проделать определённую работу:
- Запланировать N параллельных асинхронных вызовов с помощью
buffer_unordered
- Подготовить свежее локальное состояние (например, стек) для Rune VM
- Вызвать функцию Rune, передав ей параметры со стороны Rust
- Замерить время, потребовавшееся для завершения каждого возвращённого future
- Собрать логи, обновить гистограммы HDR и вычислить другую статистику
- И выполнить это всё в M потоках при помощи потокового планировщика Tokio
Результаты на моём старом 4-ядерном ноутбуке с Intel Xeon E3–1505M v6, залоченным на 3 Ггц, выглядели очень многообещающе:
Так как ядер всего четыре, производительность линейно увеличивается до 4 потоков. Затем она увеличивается чуть больше до 8 потоков благодаря hyper-threading, выжимающему чуть больше производительности из каждого ядра. Очевидно, при дальнейшем увеличении количества потоков производительность не растёт, потому что все ресурсы ЦП к этому моменту уже заняты.
Мне понравились и абсолютные полученные значения. Несколько миллионов пустых вызовов в секунду на ноутбуке — похоже, цикл бенчмаркинга достаточно лёгок для того, чтобы не вызывать существенную дополнительную нагрузку в реальных измерениях. Локальный сервер Cassandra, запущенный на том же ноутбуке, при полной нагрузке может выполнять примерно 200 тысяч запросов в секунду, да и то только если эти запросы тривиальны и все данные помещаются в памяти.
Кстати, добавление в тело реального кода для генерации данных, не выполняющего вызовов базы данных, как и ожидалось, пропорционально замедляет работу, но не более чем в два раза. Порядок производительности остаётся на уровне миллионов операций в секунду.
Это было просто. Я мог бы остановиться на этом и заявить о своей победе. Однако мне было любопытно, насколько быстро программа сможет работать, если я запущу её на мощной машине с большим количеством ядер.
Запуск пустого цикла на 24 ядрах
Очевидно, что сервер с двумя процессорами Intel Xeon CPU E5–2650L v3, каждый из которых имеет 12 ядер с частотой 1,8 Ггц, должен быть гораздо быстрее, чем старый 4-ядерный ноутбук, не так ли? Ну, вероятно, с одним потоком он будет медленнее из-за более низкой частоты ЦП (3 ГГц против 1,8 ГГц), но он должен это компенсировать благодаря большему количеству ядер.
Пусть цифры говорят сами за себя:
Согласитесь, здесь что-то не так. Два потока лучше, чем один…, но на этом всё. Я не смог выжать больше производительности, чем примерно 2 миллиона вызовов в секунду, что было примерно в 4 хуже, чем производительность на ноутбуке. Или сервер был слаб, или моя программа имела серьёзную проблему масштабируемости.
Расследование
Когда находишь проблему производительности, то самый распространённый способ её изучения заключается в запуске кода под профилировщиком. В Rust очень легко генерировать flamegraphs с помощью cargo flamegraph
. Давайте сравним flamegraphs, полученные при запуске бенчмарка с 1 потоком и с 12 потоками:
Я ожидал, что найду какое-то одно узкое место, например, конфликтующий мьютекс или что-то подобное, но, к моему удивлению, ничего очевидного заметно не было. Не было даже одного узкого места! Похоже, код VM::run
Rune занимал примерно ⅓ времени, но остальное было просто занято опросом future, с большой вероятностью виновник просто оказался встроен и пропал из профиля.
Как бы то ни было, из-за VM::run
и из-за пути rune::shared::assert_send::AssertSend
, тоже ведущего к Rune, я решил отключить код, отвечающий за вызов функции Rune, и начал эксперимент заново с одним циклом, запускающим пустой future, хотя код таймингов и статистики по-прежнему были включены:
// Executes a single iteration of a workload.
// This should be idempotent –
// the generated action should be a function of the iteration number.
// Returns the end time of the query.
pub async fn run(&self, iteration: i64) -> Result {
let start_time = Instant::now();
let session = SessionRef::new(&self.session);
// let result = self
// .program
// .async_call(self.function, (session, iteration))
// .await
// .map(|_| ()); // erase Value, because Value is !Send
let end_time = Instant::now();
let mut state = self.state.try_lock().unwrap();
state.fn_stats.operation_completed(end_time - start_time);
// ...
Ok(end_time)
}
Код хорошо масштабировался до 100 миллионов вызовов в секунду на 48 потоках! То есть проблема должна находиться где-то в функции Program::async_call
:
// Compiled workload program
pub struct Program {
sources: Sources,
context: Arc,
unit: Arc,
}
// Executes given async function with args.
// If execution fails, emits diagnostic messages, e.g. stacktrace to standard error stream.
// Also signals an error if the function execution succeeds, but the function returns
// an error value.
pub async fn async_call(
&self,
fun: FnRef,
args: impl Args + Send,
) -> Result {
let handle_err = |e: VmError| {
let mut out = StandardStream::stderr(ColorChoice::Auto);
let _ = e.emit(&mut out, &self.sources);
LatteError::ScriptExecError(fun.name, e)
};
let execution = self.vm().send_execute(fun.hash, args).map_err(handle_err)?;
let result = execution.async_complete().await.map_err(handle_err)?;
self.convert_error(fun.name, result)
}
// Initializes a fresh virtual machine needed to execute this program.
// This is extremely lightweight.
fn vm(&self) -> Vm {
Vm::new(self.context.clone(), self.unit.clone())
}
Функция async_call
выполняет несколько действий:
- Подготавливает свежую Rune VM — это должна быть очень простая операций, по сути, подготавливающая свежий стек; VM не разделяются между вызовами и потоками, чтобы они могли выполняться полностью независимо
- Вызывает функцию, передавая её идентификатор и параметры
- В конце она получает результат и преобразует ошибки; мы можем уверенно предположить, что в пустом бенчмарке это эквивалентно no-op
Далее я подумал, что можно просто удалить вызовы send_execute
и async_complete
, оставив только подготовку VM. То есть, по сути, я хотел выполнить бенчмарк этой строки:
Vm::new(self.context.clone(), self.unit.clone())
Код выглядит довольно невинно. Никаких блокировок, мьютексов, системных вызовов, общих изменяемых данных. В нём есть несколько структур context
и unit
только для чтения, общих в Arc
, но разделение только для чтения не должно быть проблемой.
VM::new
тоже тривиален:
impl Vm {
// Construct a new virtual machine.
pub const fn new(context: Arc, unit: Arc) -> Self {
Self::with_stack(context, unit, Stack::new())
}
// Construct a new virtual machine with a custom stack.
pub const fn with_stack(context: Arc, unit: Arc, stack: Stack) -> Self {
Self {
context,
unit,
ip: 0,
stack,
call_frames: vec::Vec::new(),
}
}
Однако как бы невинно ни выглядел код, я хотел убедиться в своих предположениях. Я запустил его с разным количеством потоков и хотя он теперь был быстрее, чем раньше, он снова не масштабировался — утыкался в потолок производительности примерно 4 миллиона вызовов в секунду!
Проблема
Хотя поначалу не похоже, что в показанном выше коде есть разделение изменяемых данных, на самом деле в нём скрыто нечто разделяемое и изменяемое: сам подсчёт ссылок Arc
. Этот подсчёт является общим для всех вызовов, выполняемых из множества потоков, и он становится здесь причиной торможения.
Кто-то может сказать, что это атомарное увеличение или уменьшение общего атомарного счётчика не должно быть проблемой, потому что это операции без блокировки. Они даже транслируются в одиночные ассемблерные команды (например, lock xadd
)! Если что-то является одиночной ассемблерной командой, это не может быть медленным, ведь так? К сожалению, это утверждение ошибочно.
Корень проблемы заключается не в вычислениях, а в затратах на хранение общего состояния.
На количество времени, требуемого для считывания или записи данных, в основном влияет то, насколько далеко ядру ЦП нужно «тянуться» к данным. Вот стандартные задержки процессоров Intel Haswell Xeon (данные с этого сайта):
- Кэш L1: 4 такта
- Кэш L2: 12 тактов
- Кэш L3: 43 такта
- ОЗУ: 62 такта + 100 нс
Кэши L1 и L2 обычно локально располагаются в ядре (L2 может быть общим для двух ядер). Кэш L3 является общим для всех ядер ЦП. Также для управления согласованностью кэшей L3 существует прямая связь между кэшами L3 разных процессоров на материнской плате, так что L3 является логически общим для всех процессоров.
Если мы не обновляем строку кэша и только считываем её из разных потоков, строка будет загружаться несколькими ядрами и будет помечена как общая. Есть вероятность, что частые операции доступа к таким данным будут обрабатываться из кэша L1, который очень быстр. Следовательно, разделение данных только для чтения вполне приемлемо и хорошо масштабируется. Даже использование atomics только для чтения в этом случае будет достаточно быстрым.
Однако если добавить обновления общей строки кэша, всё усложняется. Архитектура x86-amd64 имеет согласованные кэши данных. По сути, это значит, что при записи на одном ядре можно выполнить считывание на другом. Невозможно хранить строку кэша с конфликтующими данными в нескольких ядрах. Когда поток решает обновить общую строку кэша, эта строка инвалидируется на всех остальных ядрах, поэтому последующие загрузки на этих ядрах должны будут получать данные как минимум из L3. Очевидно, что это гораздо медленнее, и ещё медленнее, если на материнской плате установлено больше одного процессора.
Дополнительная проблема заключается в том, что счётчики ссылок атомарны, что ещё сильнее усложняет ситуацию для процессора. Хотя эти атомарные команды часто называют «lockless-программированием», это немного неверно — на самом деле на аппаратном уровне атомарным операциям требуется блокировка. Эта блокировка очень мелка и малозатратна, если нет торможений, однако, как это обычно бывает с блокировками, стоит ожидать очень низкой производительности, если множество элементов начинает одновременно конкурировать за одну и ту же блокировку. И, разумеется, всё гораздо хуже, если этими «элементами» являются целые процессоры, а не отдельные ядра, расположенные рядом друг с другом.
Решение проблемы
Очевидное решение заключается в том, чтобы избегать разделения подсчёта ссылок. Latte имеет очень простую иерархическую структуру жизненного цикла, поэтому все эти Arc
казались мне перебором и их, вероятно, можно заменить более простыми ссылками и жизненными циклами Rust. Однако это проще сказать, чем сделать. К сожалению, Rune требует, чтобы ссылки на Unit
и RuntimeContext
передавались в обёртке из Arc
для управления жизненным циклом (вероятно, в более сложных ситуациях), а также использует в рамках этих структур значения в обёртке из Arc
. Переписывать Rune просто ради моего частного мелкого случая никто не будет.
Следовательно Arc
должен остаться. Вместо одного значения Arc
можно использовать по одному Arc
на поток. Для этого также нужно отделить значения Unit
и RuntimeContext
, чтобы каждый поток получал свои собственные. Также это гарантирует отсутствие общего использования, то есть даже если Rune клонирует Arc
, хранящийся внутри как часть этих значений, эта проблема тоже будет устранена. Недостаток такого решения заключается в увеличении используемой памяти. К счастью, скрипты нагрузок Latte обычно имеют крошечный размер, поэтому увеличенный объём памяти, скорее всего, не вызовет серьёзных проблем.
Чтобы иметь возможность использования отдельных Unit
и RuntimeContext
, я отправил патч команде Rune, чтобы они могли выполнить Clone
. После этого на стороне Latte решение проблемы заключалось в добавлении новой функции для «глубокого» клонирования структуры Program
и предоставления каждому потоку собственной копии:
// Makes a deep copy of context and unit.
// Calling this method instead of `clone` ensures that Rune runtime structures
// are separate and can be moved to different CPU cores efficiently without accidental
// sharing of Arc references.
fn unshare(&self) -> Program {
Program {
sources: self.sources.clone(),
context: Arc::new(self.context.as_ref().clone()), // clones the value under Arc and wraps it in a new counter
unit: Arc::new(self.unit.as_ref().clone()), // clones the value under Arc and wraps it in a new counter
}
}
Кстати, поле sources
не используется во время выполнения, за исключением отправки диагностики, поэтому его можно оставить общим.
Обратите внимание, что исходная строка, в которой я изначально выявил замедление, не требовала никаких изменений!
Vm::new(self.context.clone(), self.unit.clone())
Так получилось потому, что self.context
и self.unit
больше не являются общими для этих потоков. Атомарные обновления необщих счётчиков, к счастью, очень быстры.
Результаты
Теперь, как и ожидалось, производительность линейно масштабируется до 24 потоков:
Выводы
- Затраты на общий
Arc
в некоторых конфигурациях оборудования могут быть абсурдно высоки, если он часто обновляется во множестве потоков. - Не стоит автоматически допускать, что одиночная ассемблерная команда не может стать проблемой для производительности.
- Не стоит автоматически допускать, что если что-то хорошо масштабируется на однопроцессорном компьютере, то это обязательно хорошо масштабируется на многопроцессорном компьютере.