Анализ различных задержек для более осознанного проектирования
Много JavaScript-фреймворков назад, в 2009 году, Джеффри Дин, будучи инженером в Google, представил знаменитые “числа, которые должен знать каждый программист”. Этот список выглядел примерно так:

Эти числа завоевали популярность в инженерном сообществе, поскольку они эффективно демонстрировали разницу в задержках между различными типами операций. Питер Норвиг также упоминает эти цифры в своем эссе “Научитесь программировать за десять лет”.
Хоть эти цифры по-прежнему широко обсуждаются в инженерных статьях и сообществах, более глубокое понимание того, как различные задержки могут влиять на разрабатываемые системы и проектные решения, на основе которых они принимаются, встречается не так часто. В этой статье мы попытаемся разобраться в этих вопросах.
Понимание масштабов
Когда речь заходит о величинах, которые далеки от нашего повседневного опыта, осознать истинное значение разницы может быть непросто. Одно дело — знать эти цифры на практике, и совсем другое — добиться интуитивного понимания их значимости. Один из способов упростить понимание — изменить масштаб операций для более наглядного сравнения. Например, рассмотрим операцию "Fetch from L1 cache memory", которая занимает всего 0.5 наносекунды. Если мы увеличим время выполнения этой операции до 1 секунды для удобства сравнения, относительные различия между другими операциями будут выглядеть следующим образом:

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

Кэш 1-го уровня (L1):
Расположение: Находится ближе всего к ядру процессора, будучи интегрированным напрямую.
Скорость: Самый быстрый, но и самый маленький (16-128 КБ на ядро).
Назначение: Хранит часто используемые данные и инструкции в отдельных кэшах (L1-D для данных, L1-I для инструкций), чтобы минимизировать задержку (~ 0.5 нс).
Доступ: У каждого ядра свой собственный L1-кэш.
Кэш 2-го уровня (L2):
Расположение: На кристалле процессора, чуть дальше от ядра.
Скорость: Медленнее L1-кэша, но больше по объему (256 КБ–1 МБ на ядро).
Назначение: Используется, когда объема L1-кэша недостаточно, т.к. вмещает больше данных и инструкций (задержка ~ 3-10 нс).
Доступ: Обычно у каждого ядра свой собственный L2-кэш, но бывают архитектуры и с общим.
Кэш 3-го уровня (L3, отсутствует в первоначальном списке “чисел, которые должен знать каждый программист”):
Расположение: Дальше всех от ядра, общий для всего CPU.
Скорость: медленнее, чем L2-кэш, но быстрее, чем основная память (2-64 МБ).
Назначение: Общее хранилище для координации многоядерных операций (задержка ~ 10-30 нс).
Доступ: Общий для всех ядер.
Временная и пространственная локальность
На первый взгляд может показаться нелогичным, что процессоры используют несколько уровней кэша, ведь извлечение данных и заполнение кэша увеличивают накладные расходы на выполнение операций. Однако полезность кэшей обусловлена двумя важными явлениями, которые можно наблюдать при анализе того, как программы обращаются к памяти: временная и пространственная локальность.
Временная локальность: Код и данные, к которым уже обращались, с большой вероятностью понадобятся снова.
Пространственная локальность: Области памяти рядом с кодом и данными, к которым обращались недавно, вероятно, также понадобятся в ближайшее время.
Эти принципы позволяют процессорам делать обоснованные прогнозы относительно того, какие данные понадобятся дальше. Чтобы использовать пространственную локальность, центральный процессор извлекает данные из основной памяти в виде небольших блоков, называемыми строками кэша. Строка кэша — это блок памяти фиксированного размера (обычно 64 байта), который представляет собой наименьшую единицу передачи данных между кэшем процессора и основной памятью. Даже если центральный процессор запрашивает всего один байт данных, в кэш загружается целая строка кэша, содержащая этот байт и соседние с ним данные. Это позволяет обеспечить быстрый доступ к близлежащим данным в случае необходимости.
Для временной локальности центральный процессор использует сложные стратегии замены кэша, сохраняя часто и недавно использованные данные на более быстрых уровнях кэша, одновременно перемещая данные с менее частым доступом на более медленные уровни или в оперативную память. Это создает естественную иерархию, при которой наиболее часто используемые данные остаются ближе всего к центральному процессору.
Хотя в целом программа может нуждаться во всех своих данных и инструкциях, доступ к памяти можно представить как скользящее окно, которое перемещается по программе. Уровни кэша позволяют удерживать это "окно" на необходимых в данный момент данных и инструкциях как можно ближе к центральному процессору, что значительно сокращает время, затрачиваемое на обращение к более медленной основной памяти.
Промахи и попадания в кэш
Когда центральный процессор запрашивает данные, сначала он проверяет кэш. Для этого процессор идентифицирует строку в кэше, используя индекс, и сравнивает тег, чтобы убедиться, что запрошенные данные присутствуют. Если данные находятся в кэше, происходит попадание в кэш. Это позволяет центральному процессору быстро получить к ним доступ. Однако если данных нет в кэше, то происходит промах, и процессору необходимо извлекать их из более медленного источника, такого как основная память или следующий уровень кэша. Этот процесс может занимать сотни циклов работы с памятью, что делает промахи весьма дорогостоящими.
Прогнозирование ветвления
Современные процессоры используют прогнозирование ветвления для повышения производительности за счет угадывания результата условных операций (например, операторов if) до того, как станет известен фактический результат. Это необходимо, потому что процессоры выполняют инструкции в конвейерах, и ожидание определения направления ветвления (например, по какому пути идти) привело бы к простою конвейера и потере ценных циклов. Предиктор ветвления предвосхищает выполнение следующей команды, позволяя конвейеру оставаться загруженным. Если прогноз оказывается верным, выполнение программы проходит без задержек. В противном случае центральный процессор должен отбросить неправильно угаданные инструкции и получить правильные, что вызывает некоторую задержку.
Ложное разделение
Ложное разделение (false sharing) — это тонкая проблема, связанная с производительностью в многопоточных программах. Она возникает, когда разные ядра процессора записывают данные в переменные, которые, хотя и расположены в разных частях программы, находятся в одной строке кэша. Когда одно ядро изменяет свою переменную, вся строка кэша становится недействительной для всех остальных ядер, и им приходится перезагружать всю строку, даже если они обращаются к другим переменным. Например, если два потока часто обновляют разные счетчики, которые находятся рядом в памяти, каждое обновление делает недействительной строку кэша для другого потока. Это становится причиной излишнего трафик для согласований кэша и снижает производительность. Эта проблема может быть особенно коварной, поскольку переменные в коде кажутся независимыми, но их физическая близость в памяти создает конфликты. Обычно решение этой проблемы заключается в изменении структуры данных таким образом, чтобы часто используемые переменные размещались в разных строках кэша. Как правило, для этого их выравнивают по 64-байтовым границам.
SIMD (Single Instruction, Multiple Data)
SIMD (одиночный поток команд, множественный поток данных) — это технология параллельной обработки, применяемая в современных процессорах для одновременного выполнения одной и той же операции над несколькими фрагментами данных. В отличие от последовательной обработки, SIMD позволяет одной команде работать с несколькими элементами данных, которые хранятся в векторах или массивах. Это особенно полезно для задач, таких как рендеринг графики, обработка изображений и научные вычисления, где одна и та же операция (например, сложение или умножение) применяется к большим объемам данных. Используя SIMD, процессоры могут значительно повысить производительность для таких рабочих нагрузок. Современные процессоры включают наборы SIMD-команд, такие как AVX или NEON, что делает эту технологию важной функцией для оптимизации производительности в задачах, связанных с параллельной работой с данными.
Как это все перекладывается на проектирование систем?
Современные процессоры представляют собой невероятно сложные устройства, способные к мощной автоматической оптимизации. Вместо того чтобы пытаться вручную оптимизировать код, наша основная задача заключается в написании кода таким образом, чтобы процессоры могли эффективно выполнять эту задачу самостоятельно. Такой подход обычно обеспечивает наилучшую отдачу от наших усилий, позволяя достичь значительного повышения производительности за счет простых и удобных для процессора шаблонов. Только в том случае, когда автоматических оптимизаций оказывается недостаточно, мы должны рассмотреть возможность ручной оптимизации, тщательно взвешивая затраты на ее внедрение с преимуществами в производительности. Вот что это означает на практике:
Строки кэша
Современные процессоры считывают память в виде 64-байтовых фрагментов, называемых строками кэша. При обращении к любой ячейке памяти извлекается вся строка кэша. Это означает, что расположение данных в структуре имеет важное значение для производительности. Пересечение границ строк кэша или ложное разделение между потоками могут существенно снизить скорость работы.
# cpp
// Плохо: структура, вероятно, пересекает границы строки кэша
// (всего 76 байт)
struct DataPoint {
// 8 байт
double value;
// 60 байт
char metadata[60];
// 8 байт, вводит новую строку кэша
double timestamp;
}; // Будет занимать 2 строки кэша
// Лучше: структура, выровненная по кэшу
// (всего 64 байта)
struct alignas(64) DataPoint {
// 8 байт
double value;
// 8 байт
double timestamp;
// 48 байт для заполнения строки кэша
char metadata[48];
}; // Поместится ровно в 1 строку кэша
Шаблоны доступа к памяти и структуры данных
Современные процессоры обладают впечатляющей способностью прогнозировать и предварительно извлекать данные из последовательных обращений к памяти. При проектировании структур данных важно учитывать, как вы будете получать доступ к этим данным. Классический пример — это сравнение структуры массивов с массивом структур. Для операций с отдельными атрибутами структура массивов часто оказывается более эффективной:
# cpp
// Массив структур: неэффективное использование кэша для
/ операций над одним полем
struct Particle {
// Координаты
float x, y, z;
// Скорость
float vx, vy, vz;
};
std::array<Particle, 1000> particles;
// Структура массивов: более эффективное использование кэша для
// обработки отдельных атрибутов
struct ParticleSystem {
// Координаты
std::array<float, 1000> x, y, z;
// Скорости
std::array<float, 1000> vx, vy, vz;
};
Связанные списки, несмотря на их гибкость в вставке и удалении элементов, часто оказываются неэффективными на современном оборудовании из-за их распределенной памяти:
# cpp
// Связанные списки: неэффективное использование кэша
// из-за хаотичного распределения памяти.
// Каждый узел может находиться в любом месте в памяти.
// Отсутствует последовательный шаблон доступа для
// предварительного извлечения данных.
// Каждый доступ может привести к необходимости
// загрузки новой строки кэша.
struct Node {
int data;
// Указатель на следующий элемент, который может находится где угодно в памяти.
Node* next;
};
Предсказание ветвления и простои
Современные процессоры оснащены сложными конвейерами, состоящими из 15-20 этапов. Они могут с высокой точностью предсказывать результаты ветвления. Однако неправильно предсказанные переходы могут привести к простою всего конвейера. Когда прогноз оказывается неверным, центральный процессор вынужден очищать свой конвейер: он отбрасывает всю работу, выполненную по неправильно предсказанному пути, и перезагружает правильные инструкции. Это может занять 10-20 циклов или даже больше. Подобные остановки возникают, когда зависимость от данных мешает предварительному выполнению. Например, при обходе связанного списка центральный процессор не может предварительно выбрать данные следующего узла, пока не загрузит указатель на "next" текущего узла . Это создает цепочку зависимых загрузок из памяти, которая останавливает конвейер. Когда производительность имеет критическое значение, стоит подумать о том, чтобы сделать шаблоны переходов предсказуемыми или полностью исключить их:
# cpp
// Предсказуемый шаблон: процессор может его распознать
for(int i = 0; i < n; i++) {
if(i % 2 == 0) { ... }
}
// Непредсказуемый шаблон: процессор не может его распознать
// сетевой ответ может поступить в любое время
for(int i = 0; i < n; i++) {
// Зависит от внешних факторов
if(check_network_status()) {
process_data(i);
}
}
// Остановка конвейера из-за зависимости от данных
while(current != nullptr) {
// Процессор должен ожидать каждой загрузки "next"
process(current->data);
// Следующий адрес неизвестен до тех пор, пока не будет загружен
current = current->next;
}
// Альтернатива без ветвлений для простых операций
int max(int a, int b) {
int diff = a - b;
// Все единицы, если отрицательные, все 0, если положительные
int mask = diff >> 31;
return b + (diff & ~mask);
}
Автоматическая векторизация SIMD
Современные процессоры обладают уникальной способностью автоматически распараллеливать операции с помощью SIMD-инструкций. Однако это возможно только в том случае, если код следует определенным шаблонам. Чтобы обеспечить автоматический распараллеливание, важно писать простые и предсказуемые циклы без сложных ветвлений или зависимостей от данных. Рассмотрим два примера:
# cpp
// Процессор может автоматически векторизовать этот цикл
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
// Сложное ветвление предотвращает автоматическую векторизацию
for (int i = 0; i < n; i++) {
if (a[i] > 0)
c[i] = a[i] + b[i];
else
c[i] = a[i] - b[i];
}
Синхронизация и конфликты при блокировке
При написании параллельного кода старайтесь минимизировать накладные расходы на синхронизацию, сокращая критические секции. Хотя операции с мьютексами выполняются быстро (примерно 25 наносекунд), конфликты при блокировках могут существенно снизить производительность, поскольку потоки могут тратить циклы впустую на ожидание или вообще отменяться, что приводит к дополнительным накладным расходам из-за аннулирования кэша и переключения контекста. Для работы, завязанной на процессоре, количество потоков должно соответствовать количеству ядер процессора. Если работа связана с вводом-выводом, это соотношение должно быть выше. Рассмотрите альтернативные подходы: lock-free структуры с атомарной схемой для повышенной пропускной способности, блокировки чтения-записи или RCU для больших рабочих нагрузок, связанных с чтением, а также более точные блокировки и распределение данных для уменьшения конфликтов.
Системы, привязанные к вводу/выводу (I/O)
Различные программные системы сталкиваются с уникальными проблемами производительности. В то время как научные вычисления и графические движки стремятся к повышению эффективности вычислений, большинство повседневных приложений ограничены скоростью передачи данных, а не вычислительной мощностью. Веб-серверы, обработчики логирования, конвейеры данных и другие подобные системы часто завязаны на вводе/выводе, где узкими местами могут стать доступ к диску, задержка в сети или запросы к базе данных. Когда система зависит от ввода/вывода, основное внимание уделяется минимизации и оптимизации перемещения данных. Это достигается за счет таких методов, как кэширование часто используемых данных, объединение нескольких операций в одну и использование асинхронных операций для обхода задержек.
Обзор операций ввода/вывода
Ввод/вывод хранилища:
Носители: Доступ к данным осуществляется через физическое хранилище, такое как твердотельные накопители (SSD) и жесткие диски (HDD).
-
Скорость:
Твердотельные накопители NVMe: задержка ~ 10-20 мкс, пропускная способность 3-7 ГБ/с.
Твердотельные накопители SATA: задержка ~ 50-100 мкс, пропускная способность 550 МБ/с.
Жесткие диски (7200 об/мин): задержка ~ 9-10 мс, пропускная способность 150-200 МБ/с.
Жесткие диски (5400 об/мин): задержка ~ 12-15 мс, пропускная способность 100-150 МБ/с.
Назначение: Постоянное хранение и извлечение данных.
Характеристики: Последовательное чтение/запись выполняется намного быстрее, чем произвольный доступ.
Сетевой ввод/вывод:
Среда: передача данных по сетям (локальная сеть, интернет, облако).
-
Скорость:
Локальная сеть (LAN): ~0.5-5 мс.
Интернет/глобальная сеть (WAN): ~10-200 мс.
Межконтинентальная коммуникация: ~200+ мс.
Назначение: Распределенная передача данных.
Характеристики: Производительность сильно варьируется в зависимости от условий сети и расстояния.
Буферизация и пакетирование
Эффективность систем, ограниченных вводом/выводом, в значительной степени зависит от двух ключевых принципов: минимизации количества операций и максимального увеличения размера каждой из них. Именно поэтому буферизация и пакетирование являются основополагающими для оптимизации ввода/вывода:
Влияние размера буфера:
Небольшие буферы приводят ко множеству небольших операций ввода/вывода.
Большие буферы, напротив, сокращают количество операций, но повышают использование памяти.
Оптимальный размер буфера обычно соответствует базовым системным блокам (например, 4 КБ или 8 КБ).
Влияние пакетной обработки:
Амортизирует накладные расходы по каждой операции.
Позволяет лучше использовать ресурсы.
Обеспечивает объединение операций.
Как это перекладывается на проектирование систем?
В веб-приложениях операции ввода/вывода часто становятся узким местом. Это касается как сетевых вызовов, так и запросов к базам данных. Ниже мы обсудим практические решения, которые помогут избежать распространенных проблем, связанных с операциями ввода/вывода.
Как это перекладывается на проектирование систем?
Современные файловые системы оптимизированы для последовательного доступа и работы с большими блоками данных. Если файлы читаются построчно или небольшими порциями, каждая операция связана с дополнительными затратами и может привести к запуску новой операции с диском. Например, при обработке журнала построчное чтение файла может потребовать от системы выполнения тысяч небольших операций ввода/вывода вместо меньшего количества более крупных. Лучший подход заключается в использовании буферизованных операций чтения с большими фрагментами, соответствующими размеру страницы системы.
Твердотельные накопители (если они доступны)
Хотя буферизованные операции чтения по-прежнему очень важны, твердотельные накопители коренным образом меняют некоторые традиционные представления о файловой системе. В отличие от жестких дисков, которые испытывают серьезные задержки при произвольном доступе из-за времени механического поиска, SSD могут выполнять произвольное чтение почти так же быстро, как и последовательное. Это делает такие методы, как memory mapping, особенно эффективными на SSD. Однако у SSD есть свои особенности. Они имеют ограниченное количество циклов записи и работают лучше всего при выровненной записи, соответствующей размеру их внутренней страницы, обычно 4 КБ или 8 КБ. Для систем, выполняющих интенсивные операции ввода/вывода, использование твердотельных накопителей может значительно повысить производительность — в 10-20 раз. Это особенно важно для приложений, где схемы доступа к данным менее предсказуемы, а задержки играют решающую роль.
Пакетное выполнение запросов к базе данных и предотвращение проблемы N + 1
Проблема N+1 запросов — это классический пример неэффективных шаблонов ввода/вывода в работе с базами данных. Например, при получении списка пользователей и их заказов, непродуманный код может запрашивать данные сначала для пользователей, а затем для каждого пользователя отдельно — его заказы. Это приводит к N+1 обходам базы данных. Чтобы избежать этой проблемы, при проектировании запросов следует использовать объединения и массовую загрузку. Вот как это можно реализовать:
# python
# Проблема N+1: один запрос на пользователя
users = db.query("SELECT * FROM users")
for user in users:
# Дополнительный запрос для каждого пользователя
orders = db.query(
f"SELECT * FROM orders WHERE user_id = {user.id}"
)
...
# Эффективное решение: одиночный запрос с объединением
users_with_orders = db.query("""
SELECT users.*, orders.*
FROM users
LEFT JOIN orders ON users.id = orders.user_id
WHERE users.id IN (...)
""")
Влияние антипаттерна N+1 на производительность может показаться обманчивым. Даже при быстром подключении к сети, составляющем всего 5 миллисекунд в пределах одного центра обработки данных, накладные расходы быстро возрастают. Для приложения с 1000 пользователями это означает 5 секунд чистой задержки в сети. Хуже того, эти накладные расходы растут линейно с увеличением вашей пользовательской базы — удваивайте пользователей, удваивайте задержку. Это кошмарная перспектива.
Устранение избыточного ввода/вывода
В системах машинного обучения одной из распространенных ошибок является сохранение данных, которые необходимы лишь для временной обработки. Например, при разработке сервисов прогностических моделей разработчики часто допускают ошибку, сохраняя файлы на диск, даже если данные требуются только на короткий промежуток времени. Рассмотрим следующий шаблон для облачного хранилища:
# python
# Плохо: две операции ввода/вывода
def process_image(gcs_path):
# Первая операция: загрузка из сети на медленный диск
local_path = "/tmp/image.jpg"
gcs.download_to_file(gcs_path, local_path)
# Вторая операция: чтение с диска в память процесса
image = load_image(local_path)
return make_predictions(image)
# Лучше: одна операция ввода/вывода
def process_image(gcs_path):
# Загрузка непосредственно из сети в память процесса,
# поскольку она нужна нам только временно
image_bytes = gcs.download_as_bytes(gcs_path)
image = load_image_from_bytes(image_bytes)
return make_predictions(image)
Этот шаблон приводит к ненужным и медленным операциям с диском, а также к задержкам. Вместо того чтобы загружать данные непосредственно в память, он добавляет дополнительный цикл загрузки на диск, временно сохраняя и затем повторно читая файл. Всегда учитывайте, действительно ли необходимо промежуточное хранилище, поскольку устранение избыточных операций ввода/вывода может значительно повысить производительность.
Параллельное выполнение / Минимизация обходов сети
Сетевые операции становятся более сложными, когда код обрабатывает удаленные вызовы как локальные функции. Каждый HTTP-запрос или RPC-вызов связан с заметной задержкой, и выполнение их последовательно только увеличивает ее. Современные системы используют пакетирование и асинхронные операции, чтобы минимизировать обходы и скрыть задержку:
# python
# Плохо: последовательные сетевые запросы
for user_id in user_ids:
# Отдельный сетевой вызов
user = await api.get_user(user_id)
process_user(user)
# Лучше: Либо...
# Вариант 1: Одиночный пакетный сетевой запрос
# Единый сетевой вызов для всех пользователей
# если позволяет API
users = await api.get_users(user_ids)
for user in users:
process_user(user)
# Вариант 2: Одновременные сетевые запросы
# Одновременное выполнение запросов
users = await asyncio.gather(*[
api.get_user(id) for id in user_ids
])
for user in users:
process_user(user)
Последовательные удаленные вызовы могут стать кошмаром с точки зрения производительности, точно так же, как проблема N+1. Вместо того чтобы обрабатывать запросы одновременно, что значительно сокращает время ожидания системы, мы выполняем их один за другим, заставляя нашу систему без необходимости простаивать. Решение поставляется в двух формах: объединение нескольких запросов в один сетевой вызов (когда API это поддерживает) или одновременное выполнение нескольких отдельных запросов (когда пакетное выполнение недоступно). Оба подхода могут значительно сократить общее время ожидания системы по сравнению с последовательным выполнением.
Memory Mapping для эффективного доступа к файлам
Когда мы работаем с большими файлами, традиционные операции чтения требуют нескольких копий данных: с диска в буфер ядра и затем в буфер пользовательского пространства. Однако memory mapping позволяет избежать этих лишних копий, отображая содержимое файла непосредственно в пространство памяти. Это особенно полезно для больших файлов, которые считываются несколько раз или к которым осуществляется случайный доступ.
# python
# Традиционный подход: несколько копий
with open('large_file.dat', 'rb') as f:
# Копирует данные в пространство пользователя
data = f.read()
# Memory mapping: никакого копирования
with mmap.mmap('large_file.dat', 0, access=mmap.ACCESS_READ) as mm:
# Прямой доступ к памяти
data = mm[offset:offset+length]
Кэширование
Кэширование играет ключевую роль в оптимизации производительности, выступая в качестве уровня памяти для хранения часто используемых данных. Оно значительно уменьшает задержки и снижает нагрузку на сервер. Однако реализация кэширования должна быть тщательно продумана. Прежде чем создавать собственные системы кэширования, рекомендуется рассмотреть возможность использования проверенных решений, таких как Redis или Memcached, или встроенных механизмов кэширования вашего фреймворка. Эти системы могут справиться со сложными сценариями, такими как время аннулирования и согласованность данных, которые часто становятся серьезными проблемами в пользовательских реализациях.
Заключение
Современные процессоры превосходно оптимизируют код, но для этого им нужны предсказуемые шаблоны. Ключ к высокой производительности лежит в написании простого и понятного кода, который соответствует принципам работы процессоров. Это подразумевает использование структур данных, которые хорошо вписываются в строки кэша процессора и соответствуют естественным шаблонам доступа к памяти. Когда вы организуете свой код таким образом — подход, известный как дизайн, ориентированный на данные, — центральный процессор может автоматически оптимизировать большую часть ваших программ. Этот метод стал особенно популярным в разработке игр и высокопроизводительных вычислениях. Если вы хотите более подробно изучить дизайн, ориентированный на данные, я рекомендую прочитать книгу Ричарда Фабиана "Data-Oriented Design”.
Оптимизация систем, связанных с вводом/выводом, подразумевает минимизацию перемещения данных и сокращение задержек за счет стратегических решений. Это достигается благодаря использованию memory mapping’а для больших файлов, пакетных операций, когда это возможно, правильному выбору носителей и устранению ненужных копий данных. Методы кэширования и параллельного выполнения могут существенно помочь в этом процессе, однако основная цель заключается в уменьшении общего количества и стоимости операций ввода/вывода. Такие распространенные недоработки, как проблема N+1 запросов и последовательные вызовы API, настолько противоречат этим принципам, что любой инженер, использующий их, должен быть готов обслуживать устаревшие системы COBOL до тех пор, пока не осознает ошибочность своих подходов.
Основные принципы остаются неизменными, независимо от того, идет ли речь об оптимизации производительности процессора или скорости ввода/вывода. Прежде всего, необходимо понять, на что способно ваше оборудование, а затем структурировать код таким образом, чтобы он эффективно использовал эти возможности, а не пытался их преодолеть.
Практические навыки проектирования систем высокой сложности можно прокачать на курсе System Design — на нем вы научитесь применять современные подходы к проектированию систем, учитывая реальные ограничения железа и сетевой инфраструктуры.
В заключение приглашаем ознакомиться с нашим Календарем открытых уроков — регулярные встречи с экспертами помогут вам расширять знания и оставаться в курсе трендов.
Также заглядывайте в Каталог курсов, где собраны курсы по архитектуре, разработке и многому другому.