Оперативная память
Предпосылки: бит, байт, когерентность кешей (cache line, MESI, false sharing), иерархия памяти (L1/L2/L3 кеши, cache line, кеш-промахи).
← Когерентность кешей | Хранилище →
Программа копирует 1 ГБ данных — массив, который гарантированно не помещается ни в какой кеш. Последовательное копирование (memcpy по непрерывному блоку) даёт пропускную способность около 40 ГБ/с. Случайное чтение тех же данных (по произвольным адресам) — около 0.8 ГБ/с. Разница в 50 раз. Кеш-промахи объясняют часть этого разрыва, но не весь: даже если каждый доступ идёт в RAM, последовательное чтение всё равно на порядок быстрее случайного.
Когерентность решила проблему согласованности данных между ядрами. Та же RAM, где физически лежат общие данные, прячет за своим «~100 нс» ещё одну историю: ~100 нс — средняя цифра, и в 50 раз отличающиеся скорости двух обходов объясняются именно устройством DRAM.
DRAM-ячейка: один конденсатор — один бит
В основе DRAM — простейшая ячейка: крошечный конденсатор (хранит заряд — это бит «1», разряжен — бит «0») и один транзистор (ключ, открывающий доступ к конденсатору). Одна ячейка — один бит. Это радикально проще, чем SRAM (static RAM — статическая память), где один бит хранится в защёлке из 6 транзисторов. Именно поэтому DRAM настолько плотнее и дешевле: на том же кристалле помещается в разы больше бит.
За простоту приходится платить двумя вещами.
Чтение разрушает данные. Чтобы прочитать бит, нужно «выпустить» заряд из конденсатора на линию данных и измерить напряжение. Конденсатор при этом разряжается — заряд перетекает и рассеивается. Аналогия: чтобы узнать, есть ли вода в стакане, нужно его перевернуть. После каждого чтения ячейку нужно перезаписать — восстановить заряд. Это называется restore (восстановление) и занимает дополнительное время. Restore происходит автоматически как часть цикла чтения, но удлиняет его.
Заряд утекает. Конденсатор теряет заряд за миллисекунды из-за токов утечки. Если не обновлять — данные пропадут. Поэтому контроллер памяти периодически обновляет все строки: посылает 8192 команды refresh (по стандарту JEDEC), каждая из которых регенерирует группу строк. Окно регенерации — ~64 мс в DDR4 и ~32 мс в DDR5 (с ростом плотности строк их нужно обновлять чаще). Эта операция называется refresh (регенерация). Во время refresh затронутые строки недоступны для чтения и записи. Суммарная потеря составляет примерно 1-5% всего времени — небольшая, но постоянная и неустранимая. С ростом плотности чипов (больше строк) доля refresh растёт — это одна из проблем масштабирования DRAM.
Матрица: строки и столбцы
Ячейка и refresh объясняют, почему DRAM в принципе медленнее SRAM. Конкретные ~60 нс — результат другой конструктивной задачи: как адресовать миллиарды ячеек, не протягивая миллиард проводов.
Ячейки DRAM организованы в двумерную матрицу — строки (rows) и столбцы (columns). Типичная матрица: 65 536 строк x 8 192 столбца = полмиллиарда бит (около 64 МБ на одну матрицу, до масштабирования чипами). Такая организация не случайна: она позволяет адресовать огромное количество ячеек, используя всего два координатных провода — номер строки и номер столбца. Для 65 536 строк нужно 16 бит адреса, для 8 192 столбцов — 13 бит. Итого 29 бит вместо миллиарда отдельных проводов. Без матричной структуры адресация такого количества ячеек была бы физически невозможна.
Чтение происходит в два этапа.
Row activation (активация строки). Контроллер памяти (аппаратный блок внутри CPU) посылает номер строки (RAS — row address strobe, сигнал выбора строки). Вся строка — тысячи бит — копируется из матрицы конденсаторов в специальный буфер: row buffer (строковый буфер). Row buffer — быстрый буфер, хранящий копию одной строки. Этот этап медленный: ~13 нс (tRCD — row-to-column delay, задержка от строки до столбца; типичное значение для DDR4). Именно здесь происходит физический процесс считывания заряда с конденсаторов.
Column read (чтение столбца). Контроллер посылает номер столбца (CAS — column address strobe, сигнал выбора столбца). Из row buffer выбираются нужные биты. Это быстрее: ~13 нс (CL — CAS latency, задержка строба столбца; типичное значение для DDR4). Row buffer уже содержит усиленные данные, и чтение из него намного проще, чем из конденсаторов.
Суммарная задержка первого чтения: tRCD + CL + накладные расходы на передачу составляют примерно 40-60 нс только на уровне микросхемы. Плюс задержка передачи по шине данных от модуля до контроллера памяти CPU — итого те самые ~60-100 нс. Для DDR5-5600 типичный CAS latency — 40 тактов шины. DDR5-5600 означает 5600 МТ/с (мегатрансферов в секунду); поскольку DDR передаёт данные на обоих фронтах такта (rising и falling edge тактового сигнала), шина работает на половинной частоте: 5600 / 2 = 2800 МГц. Тогда 40 тактов × (1 / 2800 × 10^6) ≈ 14.3 нс. Суммарная задержка (tRCD + CAS + контроллер) — ~70-90 нс. Заряд конденсатора крохотный — его нужно усилить специальной схемой (sense amplifier, усилитель считывания), которая определяет «0» или «1». Именно sense amplifier формирует данные в row buffer, и его работа — основной вклад в tRCD.
Контроллер DRAM-чип
памяти
| ┌─────────────────────────────┐
|--- RAS --------->│ Активация строки #4712 │
| │ (вся строка -> row buffer) │ ~13 нс (tRCD)
| │ │
|--- CAS --------->│ Чтение столбца #256 │
| │ (из row buffer) │ ~13 нс (CL)
|<-- данные -------│ │
| └─────────────────────────────┘Row buffer hit vs miss: источник 50-кратного разрыва
После row activation строка уже скопирована в row buffer. Если следующее чтение попадает в ту же строку, активация не нужна — достаточно только column read (~13 нс). Это row buffer hit (попадание в строковый буфер).
Если следующее чтение попадает в другую строку, нужно сначала закрыть текущую — записать содержимое row buffer обратно в конденсаторы (напомним: при активации конденсаторы разрядились). Эта операция называется precharge (предзарядка — восстановление строки матрицы и сброс row buffer в исходное состояние), занимает ~13 нс (tRP — row precharge time, время предзарядки строки). Только после precharge можно активировать новую строку. Это row buffer miss — полный цикл из трёх шагов:
Row buffer miss (другая строка):
precharge row activation column read
текущей строки новой строки из row buffer
~13 нс + ~13 нс + ~13 нс = ~40 нс на чипе
Row buffer hit (та же строка):
column read
из row buffer
~13 нсflowchart TB REQ["Адрес чтения"] --> OPEN{"Уже открыта<br>строка?"} OPEN -->|нет| ACT["RAS / activate:<br>скопировать строку в row buffer"] OPEN -->|да, та же строка| CAS_H["CAS:<br>выбрать столбец"] OPEN -->|да, другая строка| PRE["Precharge:<br>закрыть текущую строку"] PRE --> ACT ACT --> CAS_M["CAS:<br>выбрать столбец"] CAS_H --> HIT["Попадание в row buffer:<br>~13 нс"] CAS_M --> MISS["Промах row buffer:<br>~40 нс на чипе"]
Разница — тройная. И именно она объясняет эксперимент из начала.
Row buffer hit выигрывает потому, что row buffer уже держит нужную строку. При miss приходится либо открыть строку с нуля, либо сначала закрыть чужую, а потом открыть нужную — именно эти дополнительные шаги и съедают десятки наносекунд.
Последовательное чтение vs случайное: разбор по шагам
В матрице выше одна строка — 8 192 столбца = 8 192 бита ≈ 1 КБ. При чтении подряд идущих адресов контроллер может сделать одну row activation, а затем десятки column read из той же строки без единого precharge — вся строка обслуживается из row buffer. Последовательное чтение большого массива проходит по памяти подряд — десятки обращений подряд попадают в одну строку. Каждое из них стоит только CL (~13 нс), без precharge и без activate. Row buffer hits доминируют, и узкое место смещается с латентности отдельного запроса на пропускную способность шины. Результат: 30-50 ГБ/с — близко к теоретическому максимуму канала.
Случайное чтение — противоположная картина. Каждое обращение с высокой вероятностью попадает в другую строку. Каждый запрос проходит полный цикл: precharge + activate + column read. На уровне чипа это ~40 нс, плюс передача по шине — итого ~80-100 нс на каждый запрос. Шина передаёт 64 байта (одну cache line) за несколько наносекунд, а потом простаивает ~80 нс в ожидании следующего ответа.
Проверим числами. При случайном доступе ~100 нс на запрос, каждый запрос приносит 64 байта (одну cache line). 10^9 нс / 100 нс = 10^7 запросов в секунду. 10^7 x 64 байта = 640 МБ/с. Канал способен на ~50 ГБ/с — используется около 1% пропускной способности. В реальности параллелизм внутри чипа и prefetch (аппаратное опережающее чтение) немного улучшают картину (до 1-3 ГБ/с при случайном доступе), но порядок величин остаётся тем же: десятки процентов утилизации для последовательного доступа и единицы процентов для случайного.
Вернёмся к эксперименту из начала. Последовательный memcpy 1 ГБ при ~40 ГБ/с завершается за ~25 мс. Случайное чтение 1 ГБ при ~640 МБ/с — за ~1.5 с. Разница в 60 раз, и бо́льшая часть этого разрыва — row buffer misses в DRAM, а не кеш-промахи.
Последовательный доступ упирается в bandwidth (пропускную способность). Случайный — в latency (задержку). Это фундаментальный паттерн, который повторяется не только в RAM, но и в SSD (Solid State Drive — твердотельный накопитель), и в HDD (Hard Disk Drive — жёсткий диск).
Три уровня параллелизма: банки, ранги, каналы
Одна строка занимает row buffer на ~40 нс, и пока она занята — другие запросы ждут. Одной матрицей 50 ГБ/с не выдать: даже если шина способна, матрица не успевает генерировать данные. Решение — параллелизм, и он устроен тремя независимыми уровнями.
- Банки делят матрицу одного чипа на 16-32 независимые подматрицы со своими row buffer. Один чип может одновременно держать разные строки открытыми в разных банках.
- Ранги объединяют 8 узких чипов в одну группу, чтобы собрать 64-битное слово на общей шине.
- Каналы — это целиком отдельные шины между контроллером и модулями. Два канала = две параллельных передачи за такт.
Каждый из трёх уровней решает свою задачу: банки прячут внутренние задержки одной матрицы, ранги набирают ширину шины из узких чипов, каналы удваивают число самих шин. Разберём их по порядку.
Banks: параллелизм внутри одного чипа
Если бы вся DRAM была одной матрицей, она могла бы обслуживать только один запрос за раз: пока одна строка активирована, другие запросы ждут. Один bank (банк — независимая матрица со своим row buffer) обслуживает один запрос за раз. При латентности ~40 нс на запрос и одном bank максимальная частота запросов — 25 миллионов в секунду, независимо от ширины шины. Даже если шина способна передавать 50 ГБ/с, одна матрица не может генерировать данные с такой скоростью — она «занята» подготовкой очередного чтения.
Решение: чип DRAM разделён на несколько банков (bank, буквально «отдел», «группа» — независимая матрица со своим row buffer). Типичное число — 16 банков на чип (DDR4), в DDR5 — 32. Каждый банк — отдельная матрица со своим row buffer. Банки работают параллельно: пока банк 0 делает row activation, банк 1 может выполнять column read, банк 2 — precharge. Контроллер памяти чередует запросы по банкам, скрывая задержки одного банка за работой другого.
Время: t0 t1 t2 t3 t4 t5
Bank 0: [activ] [ CAS ] [данные]
Bank 1: [activ] [ CAS ] [данные]
Bank 2: [activ] [ CAS ] [данные]
Bank 3: [activ] [ CAS ] [данные]
^
шина не простаивает —
каждый такт кто-то отдаёт данныеЭто bank-level parallelism (параллелизм на уровне банков) — одна из причин, почему реальная пропускная способность RAM намного выше, чем можно было бы ожидать от латентности одного запроса.
Обратная сторона: если несколько запросов подряд попадают в один и тот же банк (разные строки одного банка), параллелизм не работает — запросы сериализуются. Это bank conflict (конфликт банков). Контроллер памяти выбирает банк по нескольким битам физического адреса. Если шаг обхода кратен «банковой полосе» — расстоянию, после которого адреса возвращаются в тот же банк, — эти биты не меняются, и все обращения сериализуются в одном банке. Конкретный размер полосы зависит от реализации контроллера и, как правило, составляет единицы или десятки килобайт. На практике это редкий, но болезненный случай: пропускная способность падает в разы при определённых шагах обхода, и причина не очевидна без знания банковой структуры.
Ranks: набрать ширину шины
Один DRAM-чип имеет вывод данных шириной 8 бит (у него физически столько выводов — ног микросхемы). А шина памяти между контроллером и модулем — 64 бита. Один чип не может заполнить 64-битную шину за один трансфер.
Решение: 8 чипов объединяют в rank (ранг, буквально «ряд» — группа чипов, отвечающих на запрос одновременно; типичная x8-конфигурация, где каждый чип имеет 8-битный вывод). Контроллер посылает один запрос (номер строки, номер столбца), и все 8 чипов отвечают параллельно, каждый выдаёт свои 8 бит. Вместе — 64 бита за один трансфер.
Rank — это не уровень иерархии для хранения данных. Это способ набрать ширину шины из узких чипов. Данные не дублируются, а распределяются: каждый чип в rank хранит свою «полоску» каждого 64-битного слова.
64-битное слово в памяти: [байт0][байт1][байт2]...[байт7]
| | | |
чип0 чип1 чип2 ... чип7
<--------- один rank ---------->При записи 64-битного числа каждый чип получает свои 8 бит; при чтении контроллер собирает 8 ответов обратно в одно слово. Контроллер отправляет один запрос (номер строки, номер столбца), и все 8 чипов rank выполняют его параллельно — каждый в одном и том же банке, на одной и той же строке, в одном и том же столбце своей матрицы.
Размер rank объясняет, насколько велик «выигрыш» от одной row activation при последовательном чтении. На уровне rank одна строка покрывает ~8 КБ (8 чипов × 1 КБ на чип). Каждый column read приносит 64 байта — один cache line (8 чипов × 8 байт). Одна row activation без precharge обслуживает до 128 column read (8 КБ / 64 байта). Поэтому «десятки обращений» из предыдущей секции на практике дают до 8 КБ последовательных данных за одну активацию без лишних задержек.
Важно не путать чипы внутри rank с банками. Чипы в rank — 8 штук, работают параллельно над одним запросом, каждый отдаёт свои 8 бит. Банки внутри одного чипа — 8-32 штуки, каждый хранит разные данные (разные адреса) и может обрабатывать свой запрос независимо. Банки решают проблему параллелизма запросов. Rank решает проблему узкого вывода чипа.
Модуль памяти (DIMM, Dual Inline Memory Module — та планка, которую вставляют в материнскую плату) обычно содержит 1 или 2 rank. Два rank на одном модуле хранят разные данные по разным адресам — не дублируют и не нарезают. Rank 0 хранит одну часть адресного пространства, rank 1 — другую. Контроллер памяти знает, какой адрес в каком rank, и отправляет запрос нужному. В каждый момент времени на шине отвечает только один rank — шина одна, и два rank не могут передавать данные одновременно. Выигрыш в другом: пока один rank занят внутренней работой (precharge, activate — операции, не требующие шины), второй может передавать данные. Контроллер чередует запросы между ними, прячет внутренние задержки одного за ответом другого.
Время: t0 t1 t2 t3 t4 t5
Rank 0: [activ] [ CAS ] [шина] [precharge] [activ] ...
Rank 1: [activ] [ CAS ] [шина] ...
^
пока rank 0 занят
precharge, rank 1
использует шинуRank решил проблему узкого вывода — 8 чипов набирают ширину шины. Но пропускная способность по-прежнему ограничена одной 64-битной шиной: все rank на одном канале делят её по очереди.
Channels: отдельная шина — удвоение пропускной способности
Channel (канал) — это отдельная 64-битная шина данных от контроллера памяти к модулям. У каждого канала свои провода, свой независимый путь передачи данных. Два канала — двойная пропускная способность, потому что данные передаются параллельно по двум физически независимым путям.
Вот почему «в какой слот вставить память» имеет значение. На десктопной материнской плате с двухканальным контроллером слоты памяти окрашены в два цвета (или промаркированы A1/A2, B1/B2). Два модуля в слотах одного канала делят одну шину — чередуются, но полоса та же. Два модуля в слотах разных каналов — пропускная способность удваивается. На практике это разница между ~25 ГБ/с (одноканальный режим) и ~50 ГБ/с (двухканальный) для DDR4-3200.
Контроллер памяти (внутри CPU)
/ \
Канал A (64 бит) Канал B (64 бит)
| |
[DIMM A1] [DIMM B1]
[DIMM A2] [DIMM B2]
Один канал: 25.6 ГБ/с (DDR4-3200)
Два канала: 51.2 ГБ/сКонтроллер памяти современного CPU обычно поддерживает 2 канала (десктоп) или 4-8 каналов (серверный). Серверный Intel Xeon с 8 каналами DDR5-4800 имеет теоретическую полосу 8 x 38.4 = 307 ГБ/с — этого достаточно, чтобы кормить десятки ядер одновременно.
Двухканальная конфигурация удваивает полосу: те же 1 ГБ memcpy получают ~50 ГБ/с вместо ~25 ГБ/с. Случайное чтение почти не выигрывает — оно ограничено латентностью каждого запроса, а не шириной шины.
Разница между банками, rank и каналами — в уровне параллелизма. Банки внутри одного чипа обрабатывают разные запросы независимо, но делят внутренние ресурсы чипа. Rank объединяет чипы для набора ширины шины. Каналы — полностью независимые шины. Все три уровня работают вместе: контроллер памяти одновременно чередует запросы между каналами, rank внутри канала и банками внутри чипов, выжимая максимум параллелизма из физически медленных DRAM-ячеек.
flowchart TB MC["Контроллер памяти"] --> CHA["Канал A<br>отдельная 64-битная шина"] MC --> CHB["Канал B<br>отдельная 64-битная шина"] CHA --> RA["Типичный rank<br>8 чипов = 64 бита"] CHB --> RB["Типичный rank<br>параллельная передача"] RA --> CHIP["Один DRAM-чип<br>выдаёт 8 бит"] CHIP --> BANKS["Банки 0..31<br>независимые матрицы<br>со своими row buffer"]
Контроллер памяти: кто управляет оркестром
Три уровня параллелизма — банки, rank, каналы — требуют оркестрации. Кто решает, в какой банк направить запрос и когда сделать refresh?
Без явной координации параллелизм трёх уровней превращается в хаос: банки простаивают, шина перегружена в один момент и пустует в другой, refresh падает в самый неудачный момент. Контроллер памяти (memory controller) — аппаратный блок внутри CPU — решает именно это: принимает запросы от ядер и кешей и превращает их в упорядоченные последовательности команд для DRAM-модулей (activate, read, write, precharge, refresh). Программист не видит контроллер напрямую, но именно его решения определяют, будет ли конкретный паттерн доступа быстрым или медленным.
Контроллер распределяет адреса по каналам, rank и банкам — от выбора схемы адресации зависит, как часто возникают bank conflict. Он переупорядочивает запросы в очереди: если параллельно стоят запросы к разным строкам и запрос к той строке, что уже активирована, — контроллер пропускает «попадание» вперёд, чтобы получить row buffer hit. Refresh он планирует так, чтобы тот совпадал с моментами низкой нагрузки.
В результате реальная производительность RAM зависит не только от характеристик чипов (tRCD, CL, частота), но и от качества контроллера памяти. Один и тот же модуль DDR5 может показывать разную пропускную способность на разных процессорах — потому что контроллеры по-разному переупорядочивают запросы и распределяют адреса по банкам.
DDR: поколения и их компромиссы
За три поколения пропускная способность RAM выросла в 3.5 раза — но латентность почти не изменилась.
DDR расшифровывается как double data rate (удвоенная скорость передачи): данные передаются и по нарастающему, и по спадающему фронту тактового сигнала (rising и falling edge), то есть дважды за такт. До DDR (просто SDRAM — Synchronous DRAM, синхронная динамическая память) данные передавались только по одному фронту — DDR удвоила пропускную способность без удвоения частоты. Каждое следующее поколение увеличивает частоту и ширину prefetch — сколько бит за раз забирается из матрицы во внутренний буфер чипа.
DDR4-3200 работает на частоте шины 1600 МГц. Два трансфера за такт дают 3200 МТ/с (мегатрансферов в секунду). При ширине шины 8 байт: 3200 x 8 = 25.6 ГБ/с на канал.
DDR5-5600 — 5600 МТ/с, ~44.8 ГБ/с на канал. DDR5 также удвоила число банков (32 вместо 16) и разделила каждый модуль на два независимых подканала (sub-channel) по 32 бита каждый — больше параллелизма при той же ширине модуля. Два подканала обслуживают запросы независимо, что особенно помогает при случайном доступе: два разных адреса могут обрабатываться параллельно даже в пределах одного DIMM.
Поколение МТ/с ГБ/с/канал Банков/чип Латентность
──────────────────────────────────────────────────────────────
DDR3-1600 1600 12.8 8 ~50-70 нс
DDR4-3200 3200 25.6 16 ~60-80 нс
DDR5-5600 5600 44.8 32 ~70-90 нс
──────────────────────────────────────────────────────────────
Пропускная способность: x3.5 за два поколения
Латентность: практически без измененийDDR3, DDR4, DDR5 — все дают примерно 60-100 нс до первого байта. Причина фундаментальна: латентность определяется физикой конденсаторов — временем, за которое заряд перетекает на линию данных, усиливается sense amplifier и фиксируется в row buffer. Эти физические процессы не ускоряются от повышения тактовой частоты шины. Bandwidth увеличивается за счёт параллелизма (больше банков, подканалов) и частоты передачи, но latency остаётся на том же уровне.
Вот почему кеш остаётся критически важным вне зависимости от поколения DDR: он прячет латентность, которую RAM не может уменьшить. Без L1/L2/L3 процессор простаивал бы ~100 нс на каждом обращении к данным — при такте в 0.3 нс это 300 тактов впустую. DDR3 выпущена в 2007, DDR5 — в 2020. За 13 лет bandwidth вырос в 3.5 раза, латентность не изменилась. Процессоры за то же время увеличили число ядер с 4 до 32 и частоту с 3 до 5 ГГц. Разрыв между скоростью процессора и задержкой памяти продолжает расти — и кеш остаётся единственным способом его компенсировать.
Три явления, где внутреннее устройство RAM проступает наружу
Механика DRAM редко видна программисту напрямую — но она проявляется в трёх конкретных местах, и в каждом случае правильная реакция требует понимания причины, а не чутья.
Последовательный vs случайный доступ — разрыв в 50 раз. Структуры данных и алгоритмы с последовательным обходом выигрывают не только за счёт кеша, но и за счёт row buffer: последовательные адреса попадают в одну DRAM-строку, и десятки чтений обходятся без precharge. Связный список, хэш-таблица с открытой адресацией и бинарное дерево дают случайный доступ — каждый переход по указателю с высокой вероятностью row buffer miss.
Фиксированный шаг обхода — возможный bank conflict. Шаг, кратный размеру банковой «полосы», приводит к тому, что все обращения попадают в один банк. Профилировщик видит повышенную латентность, но не указывает на банки — причина становится очевидна только при знании схемы адресации. Совет: нестандартно медленный цикл с конкретным шагом — повод поэкспериментировать с соседними размерами шага.
Слоты памяти на материнской плате. Два модуля в один канал чередуются, но полоса не удваивается. Для двухканального режима нужны слоты разных каналов (обычно DIMM1 и DIMM3, или A1 и B1). Это единственный случай, где программист влияет на hardware-конфигурацию памяти напрямую.
NUMA: когда у каждого процессора «своя» RAM
До сих пор подразумевалось, что контроллер памяти один. На однопроцессорной системе так и есть — но серверы часто имеют два и более процессоров, каждый со своим контроллером и своей RAM.
На однопроцессорной системе (один физический CPU, пусть и с 8-16 ядрами) контроллер памяти встроен в CPU и подключён ко всей RAM напрямую. Все ядра имеют одинаковое время доступа к любому адресу — это UMA (uniform memory access — однородный доступ к памяти).
На сервере с двумя или более физическими процессорами картина меняется. У каждого CPU свой контроллер памяти и своя «локальная» RAM. Если ядру на CPU 0 нужны данные из RAM, подключённой к CPU 1, запрос идёт через межпроцессорную шину (Intel UPI — Ultra Path Interconnect, или аналогичный канал у AMD). Это дополнительные ~30-60 нс поверх обычной латентности.
flowchart LR CPU0["CPU 0<br>ядра 0-7"] -- "~80 нс" --> RAM0["RAM 0<br>128 ГБ"] CPU1["CPU 1<br>ядра 8-15"] -- "~80 нс" --> RAM1["RAM 1<br>128 ГБ"] CPU0 <-- "межпроцессорная шина<br>(UPI и аналоги)" --> CPU1 CPU0 -. "~110-140 нс" .-> RAM1 CPU1 -. "~110-140 нс" .-> RAM0
Это NUMA (non-uniform memory access — неоднородный доступ к памяти). Время доступа зависит от того, к чьей RAM обращается ядро. ОС (операционная система) и рантаймы (среды исполнения языков) стараются размещать данные потока в «локальной» памяти того процессора, на котором поток выполняется. По умолчанию Linux применяет first-touch policy (политику первого касания): физическая страница выделяется на том NUMA-узле, где работает поток, первым обратившийся к виртуальному адресу. Если этого не делать — программа может работать вдвое медленнее из-за удалённых обращений, хотя по метрикам «память не занята» и «кеш-промахов немного».
На практике NUMA проявляется неожиданно. База данных (PostgreSQL, MySQL) на двухсокетном сервере показывает нестабильные задержки: одни запросы выполняются за 2 мс, другие — за 5 мс. Профилирование показывает одинаковое число кеш-промахов, одинаковую нагрузку на CPU. Причина: планировщик ОС иногда мигрирует поток на другой сокет, и данные, выделенные при старте процесса в локальной памяти CPU 0, оказываются «удалёнными» для CPU 1. Каждое обращение к этим данным стоит ~140 нс вместо ~80 нс — на 75% дольше. При тысячах обращений на запрос разница накапливается.
Ещё один типичный случай: сервер с 256 ГБ RAM (128 ГБ на каждом сокете). Приложение выделяет большой буфер при старте. ОС по умолчанию может разместить его целиком в памяти одного сокета. Потоки, работающие на другом сокете, получают удалённый доступ ко всему буферу. Результат: половина потоков работает вдвое медленнее.
Инструменты вроде numactl (привязка процесса к конкретному NUMA-узлу), системные вызовы mbind и set_mempolicy позволяют контролировать размещение памяти. Команда numactl --hardware показывает топологию NUMA-узлов и расстояния между ними. Команда numastat -p <pid> показывает, сколько памяти процесса размещено на каждом узле — локально и удалённо. Подробнее о NUMA-политиках и инструментах — в управлении памятью в Linux.
NUMA — это ещё одно проявление принципа «расстояние стоит времени», который проходит через всю иерархию памяти. Регистр быстрый потому, что он физически внутри ядра. L1 — на том же кристалле, но дальше. L3 — общий для всех ядер, ещё дальше. RAM — на отдельном модуле за шиной данных. Удалённая RAM на другом сокете — ещё один переход через межпроцессорную связь, ещё одна задержка. Каждый уровень удалённости добавляет десятки наносекунд.
Эффективная пропускная способность: seq vs random
Одна и та же RAM выдаёт 25-50 ГБ/с или 0.5-3 ГБ/с в зависимости от порядка обращений.
Паттерн доступа Латентность Эффективная пропускная способность
─────────────────────────────────────────────────────────────────────
RAM (seq) ~60-100 нс ~25-50 ГБ/с
RAM (random) ~60-100 нс ~0.5-3 ГБ/с*
─────────────────────────────────────────────────────────────────────
* ограничена латентностью, а не шинойЛатентность до первого байта одинакова. Различается то, что происходит дальше: при последовательном доступе row buffer hits позволяют шине работать почти на полной скорости; при случайном — каждый запрос проходит полный цикл precharge + activate + CAS, и шина простаивает ~80 нс между трансферами.
Задача: массив 1 ГБ, не помещается в кеш. Последовательный обход vs случайный — где узкое место в каждом случае?
Частая ошибка: «последовательный — латентность, случайный — пропускная способность, потому что много запросов создают нагрузку на шину».
Ошибка в том, что при последовательном доступе латентность отдельного запроса скрыта за непрерывным потоком данных, а при случайном — именно латентность определяет скорость, потому что шина простаивает между редкими ответами.
Правильный вариант:
Последовательный обход. Row buffer hits — почти каждое обращение попадает в уже активированную строку. Данные льются потоком, CPU и контроллер выставляют запросы быстрее, чем шина передаёт данные. Узкое место — bandwidth шины (~25-50 ГБ/с).
Случайный обход. Каждое обращение — новая строка, полный цикл ~80-100 нс. Шина передаёт 64 байта за несколько наносекунд, потом простаивает ~80 нс. Узкое место — latency каждого отдельного запроса. Bandwidth используется на ~1%.
Практическое следствие: структуры данных и алгоритмы с последовательным обходом памяти выигрывают не только из-за кеша, но и из-за row buffer в DRAM. Массив быстрее связного списка не только потому, что элементы массива попадают в одну cache line — они ещё и попадают в одну строку DRAM. Даже при промахе кеша массив даёт row buffer hit, связный список — row buffer miss на каждом переходе по указателю. B-tree предпочтительнее бинарного дерева по той же причине: узел B-tree хранит десятки ключей подряд, и поиск внутри узла — row buffer hit’ы; бинарное дерево — один ключ на узел, каждый переход потенциально новая строка.
Разделение на bandwidth-bound (последовательный) и latency-bound (случайный) — не специфика RAM. В SSD и HDD разрыв ещё больше, и там же добавляется третий компонент — пропажа данных при выключении питания. RAM хранит заряд, пока идёт питание и работает refresh; как только пропадает то или другое, конденсаторы разряжаются. Для персистентного хранения нужен другой уровень иерархии, где данные переживают выключение — в следующей заметке.
Sources
- John L. Hennessy, David A. Patterson, 2017, Computer Architecture: A Quantitative Approach — 6th edition, Chapter 2: Memory Hierarchy Design (DRAM timing, banks, channels): https://www.elsevier.com/books/computer-architecture/hennessy/978-0-12-811905-1
- Bruce Jacob, Spencer Ng, David Wang, 2007, Memory Systems: Cache, DRAM, Disk — Morgan Kaufmann, Chapter 3: DRAM (cell, row buffer, banks, ranks), Chapter 8: Advanced DRAM Topics (DDR generations): https://www.elsevier.com/books/memory-systems/jacob/978-0-12-379751-3
- Ulrich Drepper, 2007, What Every Programmer Should Know About Memory — Section 2: Commodity Hardware Today (DRAM internals, NUMA topology): https://people.freebsd.org/~lstewart/articles/cpumemory.pdf
← Когерентность кешей | Хранилище →