InquirerSara Duterte says to avail all remedies amid grave threats rapsPunchWhy AI cannot replace actors — FilmmakerDaily MaverickESCAPE: How a humble Soweto parkrun cultivates joy and community in a neglected parkThe Jerusalem PostIsrael warned of potential Hamas kidnapping operation years before Oct. 7, IDF col. claims - reportCNN TürkOpenAI ve Samsung arasındaki yakınlaşmaRTP DesportoJames Rodríguez regressa ao futebol colombianoBollywood HungamaBipasha Basu seeks Tukaram Mundhe’s help after she finds worms in protein powder: “You are our only hope”Inquirer EntertainmentAi-Ai delas Alas says ‘not affected’ by ex Gerald Sibayan’s new marriageוואלהצה"ל ושב"כ: חיסלנו את מפקד חטיבת חאן יונס בזרוע הצבאית של חמאסWirtualna PolskaWojna o Trybunał. Czarzasty odmawia Nawrockiemu i żąda ślubowania BerkaDaily MailMy husband will leave our home to his two children, can I carry on living here if he dies first?ColliderOnly 10 Anime Series From the 1990s Are True Masterpieces
The Daily Newsstand · Free, Always
Friday, September 11, 2026

Работа с гигантской памятью из Go на выделенном сервере

Translate

Для одной научно-исследовательской задачи мне понадобился массив на 160 ГиБ. Требования к нему простые: максимально быстрый случайный доступ к элементам, быстрый захват памяти при создании и быстрое освобождение при уничтожении. Последние два пункта важны, потому что содержимое массива подбирается итерациями, и на каждой он создаётся заново.

Выходом стал выделенный сервер и Huge Pages размером 1 ГиБ. Но прежде чем их использовать, я провёл тесты из Go и померил скорость работы моего массива на обычных 4K-страницах и на гигабайтовых.

Сразу договоримся о единицах: в коде и замерах ГиБ — это 2³⁰ байт. Мне требовался массив ровно из 2³⁵ элементов, а значение помещалось в 40 бит. Массив []uint64 занял бы 256 ГиБ. Плотная упаковка по 5 байт уменьшила его до 160 ГиБ и сэкономила 96 ГиБ RAM.

Где запускать

Я использовал выделенный сервер на AMD EPYC 7713 (Zen 3, Milan): 64 физических ядра, 128 логических ядер, 256GB DDR4-3200.

В 2026 году серверная DRAM подорожала примерно на 90 % за один квартал. Из-за этого такой сервер может оказаться выгоднее взять в аренду, чем купить. В особенности это касается проектов, ограниченных во времени или организаций, которые принципиально не держат собственное железо, чтобы не замораживать деньги в капитальных затратах и не содержать администраторов.

У RUVDS есть выделенные серверы четырёх классов: «Старт» на Xeon D/E5, «Стандарт» с памятью до 256 ГБ, «Мощный» на Xeon Gold с памятью до 1 ТБ и «Эпик» на AMD EPYC с памятью до 2,3 ТБ. В линейке AMD EPYC есть и 7713.

Такой сервер прекрасно подойдёт и для собственной базы данных, и для готовой СУБД. В нашем эксперименте нужен контроль над загрузкой ядра: гигабайтовые страницы резервируются через GRUB. На обычном VPS, как правило, это недоступно для настройки, на выделенном сервере мы настроим такой пул сами.

Памяти для массива на 160 ГиБ нужно больше, так как нужен ещё запас RAM для ОС и остальных функций приложения.

Сначала тест массива на обычных страницах памяти

Я опасался, что срезы Go окажутся узким местом: они динамически создаются через make, растут через append, и хотелось понимать, не платим ли мы за эту гибкость при каждом обращении к массиву. Поэтому первый опыт сравнивает make с голым mmap.

Самый очевидный вариант на Go:

data := make([]byte, uint64(160)<<30)

Но время выполнения этой строки и реальное время выделения страниц для записи — разные вещи. Срез вернулся заполненным нулями, а физические страницы под него ядро выделит только при первом обращении. Go тут ни при чём — так устроен Linux.

Для второго варианта я использую анонимный mmap. Слово «отображение» здесь не означает файл на диске: передаём MAP_ANONYMOUS, файловый дескриптор не нужен. Сами числа живут в оперативной памяти.

const size = 160 << 30 // 160 ГиБ

data, err := syscall.Mmap(
     -1, // файловый дескриптор не нужен
     0,
     size,
     syscall.PROT_READ|syscall.PROT_WRITE,
     syscall.MAP_PRIVATE|syscall.MAP_ANON,
)
if err != nil {
    panic(fmt.Errorf("mmap 160 ГиБ: %w", err))
}
defer syscall.Munmap(data)

Возвращаемый data имеет тип []byte — обычный байтовый срез Go.

В обёртке из моего рабочего кода вариант на обычных страницах использовал MAP_POPULATE, а вариант с Huge Pages — нет, поэтому время их вызовов напрямую не сопоставить: MAP_POPULATE просит ядро заранее подготовить таблицы страниц. В примере оба режима mmap работают без него, а первое обращение измеряется отдельно. Поведение флагов описано в документации mmap.

Мой порядок измерений:

  1. Создать массив и засечь время возврата из конструктора.

  2. Записать один байт через каждые 4096 байт, чтобы обратиться с записью к каждой обычной странице.

  3. Повторить такой же проход по уже подготовленной памяти.

Второй пункт заставляет ядро выделить память под весь диапазон. Полную запись 160 ГиБ данных я измерю ниже.

Перед стартом на машине было около 234 ГиБ доступной RAM и 200 ГиБ незанятого swap. Подкачка находится на отдельном разделе. Ядро — 6.18.31-calculate, компилятор — go1.26.2-X:nodwarf5 linux/amd64. Зарезервированных Huge Pages пока нет. Отдельно надо сказать про THP, Transparent Huge Pages. Это второй механизм больших страниц в Linux: ядро само, без пула и без участия программы, склеивает обычные страницы в страницы по 2 МиБ и при нехватке памяти разбирает их обратно. В режиме always это происходит для всей анонимной памяти, в режиме madvise только для участков, которые программа пометила через madvise, в режиме never не происходит. На стенде стоит madvise, а в тестовом процессе я дополнительно отключаю THP через PR_SET_THP_DISABLE, чтобы обычные страницы оставались обычными и сравнение шло между честными 4 КиБ и явными 1 ГиБ. Настройки других процессов это не меняет.

Сколько дали бы THP на этой задаче, я не измерял. Ожидать можно часть выигрыша: страница 2 МиБ убирает один уровень обхода таблиц, но 160 ГиБ — это 81 920 таких страниц, а TLB держит порядка тысяч записей, так что промахи при случайном доступе остаются. С 1 ГиБ массив покрывают 160 записей. Плюс THP ядро может разобрать обратно при нехватке памяти или не собрать из-за фрагментации, а пул HugeTLB гарантирован с загрузки. Полагаться на такое поведение я не стал и взял 160 Huge Pages по 1 ГиБ.

Размер обычной страницы на стенде — 4 КиБ, это подтвердил getconf PAGESIZE. Строка Hugepagesize: 2048 kB в /proc/meminfo может сбить с толку: она сообщает, что ядро умеет создавать пул HugeTLB со страницами по 2 МиБ по умолчанию, а не доказывает использование таких страниц. Рядом стояли HugePages_Total: 0, Hugetlb: 0 kB и AnonHugePages: 0 kB. Значит, ни явные HugeTLB, ни прозрачные THP в нашем процессе не использовались: весь массив состоял из обычных страниц по 4 КиБ.

Начал с 55 ГиБ. Каждая строка таблицы — отдельный запуск нового процесса.

Способ

Создание массива, с

Первый проход, с

Повторный проход, с

Занято swap после прохода

make([]byte, …), запуск 1

0,049223

23,879522

0,130221

0

make([]byte, …), запуск 2

0,067221

23,885488

0,129323

0

Обычный mmap, запуск 1

0,000017

23,889367

0,126846

0

Обычный mmap, запуск 2

0,000010

23,996322

0,126147

0

Обычный mmap вернулся за микросекунды, а потом почти 24 секунды ушло на первое обращение к памяти. У make та же картина, и опасение насчёт срезов не подтвердилось: срез представляет собой структуру из трёх полей: длины, ёмкости и указателя на обычный массив в памяти, поэтому обращение по индексу стоит столько же, сколько через голый указатель, плюс проверка границ. Для увеличения скорости потом мы откажемся от проверки границ компилятором. После первого прохода с выделением памяти через mmap процесс занимал около 55 ГиБ RAM, а VmPTE показывал примерно 110 МиБ. VmPTE — строка из /proc/<pid>/status, объём памяти, который занимают таблицы страниц процесса. Ядру нужно знать каждую подключённую страницу к процессу, поэтому по этой строке видно, во что обходится учёт миллионов обычных страниц. Я снимаю её на каждом этапе.

Заполнение массива делалось однопоточно с отключённой THP. Сравнение с Huge Pages будет ниже.

Заполняем каждый элемент

Тот же постраничный проход по 160 ГиБ занял 69,04 секунды. На промежуточных объёмах от 80 до 200 ГиБ время росло пропорционально размеру, подкачка не использовалась.

Постраничный проход записи хорошо показывает стоимость первого обращения, но обычное приложение записывает полезные данные во все элементы. Поэтому следующий тест выполняет Set для каждого элемента, записывая все пять его байтов:

for i := uint64(0); i < a.size; i++ {
    a.Set(i, (i+1)&((1<<40)-1))
}

Маска в этой строке досталась от рабочего кода, где размер массива мог быть другим; для 2³⁵ элементов счётчик и так укладывается в 40 бит. Одна инструкция AND на элемент против стоимости записи в память — шум, поэтому я оставил её как есть. В повторном проходе записываю другую последовательность. В затраты входят и обработка страниц, и сам цикл с проверками границ и упаковкой чисел, поэтому цену страниц даёт разница между первым и повторным заполнением.

В программе этому соответствует -init=full. Проверка ресурсов вынесена за внутренний цикл, на границы блоков по 80 МиБ. Для длинных полных проходов временное ограничение увеличено до 300 секунд.

Размер, ГиБ

Первое полное заполнение, с

Повторное заполнение, с

Занято swap процессом

55

56,015

32,156

0

160

162,224

92,553

0

В первом проходе по 160 ГиБ было 42 006 767 малых page faults. После него VmPTE составлял 328 908 КиБ. Под «малым» здесь понимается fault без чтения с диска: ядру всё равно приходится предоставить физическую страницу и добавить запись в таблицу страниц. Больших page faults, связанных с вводом-выводом, не было.

Первое заполнение заняло на 69,67 секунды больше повторного. Это совпадает с постраничным проходом, где первое обращение к 160 ГиБ стоило 69,04 секунды. Минута с лишним на 160 ГиБ — цена обычных страниц сама по себе, без всякой подкачки.

Проверяем Green Tea GC

В Go 1.26 сборщик Green Tea уже включён по умолчанию. Исходные тесты выполнялись с ним. Для сравнения нужно пересобрать программу с GOEXPERIMENT=greenteagc и с GOEXPERIMENT=nogreenteagc. Эта переменная действует при сборке; присваивать её перед запуском готового бинарника недостаточно.

В моей установке Go также задан nodwarf5. Сохраняю его в обеих сборках, чтобы менять только сборщик:

GOEXPERIMENT=nodwarf5,greenteagc go build -o membench-green main.go
GOEXPERIMENT=nodwarf5,nogreenteagc go build -o membench-classic main.go
./membench-green -mode=heap -gib=160 -init=full -probe
./membench-classic -mode=heap -gib=160 -init=full -probe

Бинарники сами выводят версию Go и параметры сборки. Вместе со временем я снимаю число завершённых циклов GC и суммарное время его пауз через runtime.ReadMemStats. Паузы не охватывают всю работу сборщика, часть идёт параллельно приложению. Один большой массив байтов ничего не говорит и о работе GC на множестве объектов со ссылками.

Сборщик

Первое заполнение 160 ГиБ, с

Повторное заполнение, с

Циклы GC

Суммарные паузы GC, с

Green Tea

162,224

92,553

3

0,001845

Классический

161,312

91,586

3

0,002873

Разница первого прохода — 0,57 %, повторного — 1,06 %, на уровне шума. Важно другое: минуты не ушли в stop-the-world-паузы GC, сборщик здесь вообще ни при чём.

Заполняем из 120 горутин

Следом я заполнил весь массив псевдослучайными байтами через rand.Read. Сначала это делала одна горутина, затем я поделил массив на 120 непересекающихся участков и дал каждой горутине отдельный rand.Source.

Однопоточное заполнение 160 ГиБ заняло 261,49 секунды. В многопоточном варианте первый проход занял 7,15 секунды, второй — 4,92 секунды. Во время первого многопоточного прохода потоки накопили около 316 секунд системного процессорного времени: обработка миллионов faults хорошо распараллелилась, но сервер несколько секунд занимался почти исключительно раздачей страниц. Во втором проходе добавилось около 0,33 секунды системного времени.

Ускорение относительно одного потока получилось примерно в 36,6 раза. До 120 далеко: генерация упирается в ядра и подсистему памяти, а 120 горутин делят 64 физических ядра. Зато четыре с лишним минуты превратились в несколько секунд.

Чтобы понять, сколько времени уходит на сам генератор чисел, а сколько на работу с памятью, я добавил два контрольных опыта в одной горутине: генерация тех же 160 ГиБ в маленький буфер и генерация в заранее размещённый в памяти массив:

Что делаем одной горутиной

Время, с

Генерируем 160 ГиБ, перезаписывая один буфер

173,21

Генерируем и пишем в уже подготовленный массив 160 ГиБ

175,18

Генерируем и пишем в новый массив 160 ГиБ

261,49

Запись по уже существующей памяти добавила к работе генератора 1,97 секунды. Первичное создание и подключение 41 943 040 страниц добавило ещё 86,31 секунды. В однопоточном сценарии обычные страницы увеличили время заполнения примерно на треть. Треть времени уходит не на данные, а на то, чтобы ядро по одной выдало 42 миллиона страниц.

Счётчики процессорного времени показывают ту же картину. Заполнение подготовленного массива добавило 0,35 секунды системного времени, нового — 59,45 секунды. Разница реального времени больше разницы системного: обработка page faults идёт вперемешку с генерацией и обращениями к памяти, поэтому простым вычитанием её не разложить.

Случайное чтение и запись

Для моей научно-исследовательской задачи важнейший параметр — скорость произвольного доступа к ячейкам на чтение. Размер страницы влияет на кеш трансляций TLB: чем крупнее страница, тем реже приходится обходить многоуровневые таблицы страниц.

Создание, полное заполнение и освобождение массива тоже важны: на стадии эксперимента я подбираю его содержимое, поэтому массив приходится многократно создавать, перестраивать и уничтожать. Время, потерянное на трансляциях адресов при миллионах обычных страниц, сильно замедляет исследование.

Чтение и запись измеряю раздельно: в реальной программе одно из них почти всегда преобладает, а произвольно выбранное соотношение в смешанном тесте только скрыло бы разницу между Get и Set.

В однопоточном проходе случайный индекс выбирается по всему массиву. В многопоточном я делю массив на 120 непересекающихся участков и запускаю по одной горутине на участок. Внутри своего участка каждая горутина обращается к псевдослучайным индексам. Благодаря этому случайные Set не создают гонку за одну ячейку, но в совокупности обращения покрывают все 160 ГиБ.

В моей задаче преобладает чтение. В исходной обёртке для него был отдельный GetFast: он загружает восемь байтов через unsafe.Pointer и оставляет маской младшие 40 бит. В скоростном тесте генератор не выдаёт последний индекс, поскольку за последним элементом нет трёх запасных байтов. Для него остаётся безопасный Get. GetFast применяется в фазе без одновременной записи в соседние элементы.

Я проверил оба варианта в общем цикле. Безопасный Get, записанный через encoding/binary, компилятор Go встроил и превратил в одну 32-битную и одну байтовую загрузку. GetFast оставил одну 64-битную загрузку и маску. В одном потоке скорость выросла с 20,48 до 23,85 миллиона операций в секунду. На 120 горутинах оба варианта дали около 1,07 миллиарда операций в секунду: подсистема памяти уже была насыщена. В итоговый тест я взял GetFast, а для записи оставил безопасный Set. Восьмибайтовый SetFast из исходного кода оказался медленнее из-за операции чтение–изменение–запись.

В итоговой программе, её код ниже в разделе «Как повторить итоговый замер», чтение и запись находятся в разных горячих циклах, а вид операции проверяется до старта счётчика, а не миллиарды раз внутри. Поэтому итоговая скорость выше предварительного сравнения методов.

Однопоточный проход содержит 400 миллионов операций, многопоточный — 20 миллиардов. Тест скорости для каждого режима выполняется один раз и занимает от 10 до 24 секунд. Генерация индекса и упаковка значения входят в измеряемое время. В цикле GetFast проверки границ нет.

Этап на обычных страницах 4 КиБ

Горутины

Результат

Создание mmap

1

0,000013 с

Полное детерминированное заполнение 160 ГиБ

120

6,461 с

Случайный GetFast

1

31,38 млн операций/с

Случайный Set

1

38,30 млн операций/с

Случайный GetFast

120

1 398,76 млн операций/с

Случайный Set

120

838,50 млн операций/с

Освобождение через munmap

1

10,213 с

Во время заполнения возникло 41 946 582 малых page faults, а таблицы страниц выросли примерно до 321 МиБ. Подкачка процесса осталась нулевой. Микросекунды на создание — это получение диапазона виртуальных адресов; физическую память ядро подключило во время заполнения. Освобождение заняло больше десяти секунд. Десять секунд на то, чтобы отдать память обратно! В моём пайплайне оптимизации содержимого массива эта пауза повторяется на каждой итерации, и мириться с ней я не хотел.

В одном потоке Set обогнал даже GetFast. При чтении процессор должен дождаться кеш-линии; загруженное значение участвует в контрольной сумме. Запись попадает в буфер, и цикл может продолжаться, пока кеш и контроллер памяти завершают её в фоне. На 120 горутинах соотношение изменилось: множество независимых чтений скрывает задержки, а случайной записи требуется получить кеш-линию во владение и позднее вытеснить её. Аппаратные счётчики кеша на стенде недоступны, поэтому объяснение опирается на устройство процессора. В сравнительном запуске порядок тестов, генератор индексов и число горутин те же.

Откуда берётся гигантская подкачка

При неоптимизированном сохранении такого массива на диск стандартными средствами Go, которые создают под него огромные буферы, нужно использовать гигантский файл или раздел подкачки: в исходном приложении при первых опытах мне пришлось довести её примерно до 460 ГБ.

Нужно различать размер настроенной подкачки и число байтов, которые система действительно туда выгрузила. Большой swap может влиять на разрешение запросов памяти, даже когда дискового обмена почти нет. В Linux это связано, в частности, с политикой overcommit. При строгом режиме vm.overcommit_memory=2 лимит учитывает swap и заданную долю обычной RAM; значение Committed_AS отражает учтённые обязательства по памяти. На моей машине во время теста установлен режим 0. Подробности — в документации Linux об overcommit.

Чтобы объяснить старый запуск, нужны его настройки и показатели процесса: сколько памяти он отображал, сколько копий данных держал одновременно, были ли другие крупные потребители. Сам размер страницы не объясняет сотни гигабайт дополнительной подкачки.

Сколько страниц у массива на 160 ГиБ

Размер страницы

Число страниц на 160 ГиБ

4 КиБ

41 943 040

2 МиБ

81 920

1 ГиБ

160

Для обычных страниц одни восьмибайтовые записи нижнего уровня таблицы займут 320 МиБ. Добавятся верхние уровни и прочие накладные расходы, но из этой арифметики не получается 460 ГБ.

Чем крупнее страница, тем больший участок памяти покрывает одна запись трансляции адреса и тем меньше нагрузка на таблицы страниц и TLB. Само по себе соотношение числа страниц не обещает такого же ускорения программы.

Резервируем 160 гигабайтовых страниц

Для опыта я использую Calculate Linux. В /etc/default/grub к существующим параметрам ядра добавляются:

default_hugepagesz=1G hugepagesz=1G hugepages=160

Моя полная строка выглядит так:

GRUB_CMDLINE_LINUX="video=1920x1200 resume=UUID=e5f1a2ef-6f1c-40c2-9630-161a096f8752 rd.retry=40 calculate=video:amdgpu dolvm lsm=landlock,apparmor default_hugepagesz=1G hugepagesz=1G hugepages=160"

Параметры видео, UUID подкачки и остальные настройки относятся к моей машине. На другой машине сохраняем её параметры и добавляем три настройки Huge Pages.

hugepagesz=1G hugepages=160 запрашивает пул из 160 страниц по 1 ГиБ. default_hugepagesz=1G выбирает размер Huge Pages по умолчанию; обычные страницы всех процессов от этого гигабайтовыми не становятся. Резервировать такой пул при загрузке надёжнее, пока физическая память ещё не фрагментирована. Это описано в документации HugeTLB.

В системе с конфигурацией GRUB по пути /boot/grub/grub.cfg после редактирования выполняется:

sudo grub-mkconfig -o /boot/grub/grub.cfg

Это генерация конфигурации GRUB, за которой следует перезагрузка. До неё нужно убедиться, что изменяется конфигурация используемого загрузчика.

После загрузки проверяем:

cat /proc/cmdline
grep -E 'Huge|MemTotal|MemAvailable|Swap' /proc/meminfo
cat /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages
cat /sys/kernel/mm/hugepages/hugepages-1048576kB/free_hugepages

Ожидаем 160 в nr_hugepages. Если пул пока никто не использует, свободных страниц тоже должно быть 160. Строка в GRUB сама по себе ничего не гарантирует: пул мог и не собраться.

Эти 160 ГиБ недоступны для обычного выделения через make или malloc: ОС и остальным программам остаётся память вне пула. После освобождения нашим процессом страницы возвращаются в пул. В swap страницы HugeTLB не выгружаются.

Какие требования для использования Huge Pages

Нужна поддержка страниц 1 ГиБ процессором и ядром. При анонимном MAP_HUGETLB процессу также нужны соответствующие права: CAP_IPC_LOCK либо членство в группе, заданной vm.hugetlb_shm_group. Ошибка EPERM требует проверки прав, а ENOMEM — размера и доступности пула.

Почему сразу 1 ГиБ, если на x86-64 доступны и страницы по 2 МиБ? Для массива на 160 ГиБ это 160 записей верхнего уровня вместо 81 920 страниц по 2 МиБ и 41 943 040 страниц по 4 КиБ. Гигабайтовый вариант даёт ядру и TLB минимум служебной работы, а объём массива делится на него без остатка. Плата за это — грубое выделение целыми гигабайтами и заранее отнятый у обычных процессов пул. Страницы по 2 МиБ гибче и тоже могут дать заметный выигрыш; размер надо выбирать под структуру данных и измерения.

Обёртка массива

Из рабочего кода я взял упаковку числа в пять байт и методы доступа. Для статьи оставил три явно выбираемых режима:

Режим

Как выделяется память

Кто освобождает

heap

make([]byte, …)

Среда выполнения Go после потери ссылок

mmap

Анонимное отображение обычных страниц

Наш вызов Munmap

huge

Анонимное отображение HugeTLB, страницы 1 ГиБ

Наш вызов Munmap, с возвратом страниц в пул

В исходном конструкторе после неудачной попытки Huge Pages был переход на обычную память. Это удобно для приложения, которому важнее продолжить работу, но мешает эксперименту. Здесь режим huge завершается с ошибкой, если запрос выполнить нельзя.

Размер страницы задаю явно: к MAP_HUGETLB добавляю 30 << 26, соответствующее MAP_HUGE_1GB в Linux. Поэтому пример не зависит от системного размера Huge Pages по умолчанию. Длину отображения округляю вверх до целой гигабайтовой страницы и сохраняю полный срез для освобождения. При размере 160 ГиБ округление ничего не добавляет.

В примере использован стандартный пакет syscall, чтобы файл собирался без внешних зависимостей. Исходная обёртка использовала golang.org/x/sys/unix. Код ниже предназначен для Linux/amd64.

Полный код обёртки и первой программы измерений

Сохраните как main.go в отдельном каталоге.

//go:build linux && amd64

package main

import (
	"encoding/binary"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"runtime"
	"runtime/debug"
	"strconv"
	"strings"
	"syscall"
	"time"
	"unsafe"
)

const gib = uint64(1 << 30)

// Bit40Array owns its storage. Do not copy it or use it after Close.
// Concurrent access to the same element requires external synchronization.
type Bit40Array struct {
	data   []byte
	size   uint64
	mapped bool
}

func NewBit40Array(size uint64, mode string) (*Bit40Array, error) {
	if size == 0 || size > (uint64(^uint(0)>>1)-gib+1)/5 {
		return nil, fmt.Errorf("invalid size: %d", size)
	}
	n := size * 5
	if mode == "heap" {
		return &Bit40Array{data: make([]byte, int(n)), size: size}, nil
	}
	flags := syscall.MAP_PRIVATE | syscall.MAP_ANON
	switch mode {
	case "mmap":
	case "huge":
		const mapHuge1GB = 30 << 26
		flags |= syscall.MAP_HUGETLB | mapHuge1GB
		n = (n + gib - 1) &^ (gib - 1)
	default:
		return nil, fmt.Errorf("unknown mode: %s", mode)
	}
	data, err := syscall.Mmap(-1, 0, int(n), syscall.PROT_READ|syscall.PROT_WRITE, flags)
	if err != nil {
		return nil, fmt.Errorf("mmap (%s): %w", mode, err)
	}
	return &Bit40Array{data: data, size: size, mapped: true}, nil
}

func (a *Bit40Array) Set(index, value uint64) {
	if index >= a.size || value >= 1<<40 {
		panic("invalid index or value")
	}
	p := index * 5
	d := a.data[p : p+5]
	binary.LittleEndian.PutUint32(d, uint32(value))
	d[4] = byte(value >> 32)
}

func (a *Bit40Array) Get(index uint64) uint64 {
	if index >= a.size {
		panic("invalid index")
	}
	p := index * 5
	d := a.data[p : p+5]
	return uint64(binary.LittleEndian.Uint32(d)) | uint64(d[4])<<32
}

func (a *Bit40Array) GetFast(index uint64) uint64 {
	ptr := unsafe.Add(unsafe.Pointer(unsafe.SliceData(a.data)), uintptr(index*5))
	return *(*uint64)(ptr) & ((1 << 40) - 1)
}

func (a *Bit40Array) Close() error {
	if a.mapped && a.data != nil {
		if err := syscall.Munmap(a.data); err != nil {
			return err
		}
	}
	a.data, a.size = nil, 0
	return nil
}

func fields(path string) map[string]uint64 {
	b, err := os.ReadFile(path)
	if err != nil {
		panic(err)
	}
	out := make(map[string]uint64)
	for _, line := range strings.Split(string(b), "\n") {
		f := strings.Fields(line)
		if len(f) < 2 {
			continue
		}
		v, err := strconv.ParseUint(f[1], 10, 64)
		if err == nil {
			out[strings.TrimSuffix(f[0], ":")] = v
		}
	}
	return out
}

func snapshot(stage string, elapsed time.Duration) {
	s := fields("/proc/self/status")
	m := fields("/proc/meminfo")
	var r syscall.Rusage
	var gc runtime.MemStats
	runtime.ReadMemStats(&gc)
	if err := syscall.Getrusage(syscall.RUSAGE_SELF, &r); err != nil {
		panic(err)
	}
	v := map[string]any{"stage": stage, "seconds": elapsed.Seconds(),
		"rss_kib": s["VmRSS"], "pte_kib": s["VmPTE"], "process_swap_kib": s["VmSwap"],
		"hugetlb_kib": s["HugetlbPages"], "system_swap_used_kib": m["SwapTotal"] - m["SwapFree"],
		"minor_faults": r.Minflt, "major_faults": r.Majflt,
		"gc_cycles": gc.NumGC, "gc_pause_seconds": float64(gc.PauseTotalNs) / 1e9,
		"heap_alloc_bytes": gc.HeapAlloc, "gc_cpu_fraction": gc.GCCPUFraction}
	if err := json.NewEncoder(os.Stdout).Encode(v); err != nil {
		panic(err)
	}
}

// initialize writes either all five bytes of every element, or one byte per page.
func initialize(a *Bit40Array, full, probe bool, pass uint64, initialSwap uint64) error {
	started := time.Now()
	check := func(touched uint64) error {
		if !probe {
			return nil
		}
		m := fields("/proc/meminfo")
		swap := m["SwapTotal"] - m["SwapFree"]
		if m["MemAvailable"] < 12*gib/1024 || swap > initialSwap+64*1024 || time.Since(started) > 300*time.Second {
			snapshot("stopped_partial_initialization", time.Since(started))
			fmt.Printf("initialized_gib=%.4f available_gib=%.4f swap_used_kib=%d\n", float64(touched)/float64(gib), float64(m["MemAvailable"])/float64(gib/1024), swap)
			return fmt.Errorf("probe stopped at resource/time guard; this is not an allocation failure")
		}
		return nil
	}
	if full {
		// Each block contains 2^24 elements (80 MiB); no monitoring inside the inner loop.
		for start := uint64(0); start < a.size; {
			if err := check(start * 5); err != nil {
				return err
			}
			end := min(start+(1<<24), a.size)
			for i := start; i < end; i++ {
				a.Set(i, (i+pass)&((1<<40)-1))
			}
			start = end
		}
	} else {
		for start := 0; start < len(a.data); {
			if err := check(uint64(start)); err != nil {
				return err
			}
			end := min(start+(64<<20), len(a.data))
			for i := start; i < end; i += os.Getpagesize() {
				a.data[i] = byte(pass)
			}
			start = end
		}
	}
	return nil
}

func run() error {
	mode := flag.String("mode", "mmap", "heap, mmap, huge")
	volume := flag.Uint64("gib", 55, "mapping size in GiB, divisible by 5")
	probe := flag.Bool("probe", false, "probe larger mappings; stop at 12 GiB available, 64 MiB new swap, or 300 seconds")
	initMode := flag.String("init", "pages", "pages: one byte per page; full: Set every element")
	flag.Parse()
	if *volume == 0 || *volume > 1024 || *volume%5 != 0 {
		return fmt.Errorf("gib must be 5..1020 and divisible by 5")
	}
	if *mode != "heap" && *mode != "mmap" && *mode != "huge" {
		return fmt.Errorf("invalid mode")
	}
	if *initMode != "pages" && *initMode != "full" {
		return fmt.Errorf("invalid init mode")
	}
	if !*probe && *mode != "huge" && fields("/proc/meminfo")["MemAvailable"]*1024 < (*volume+16)*gib {
		return fmt.Errorf("insufficient available RAM: keep at least 16 GiB spare")
	}
	// Disable THP only for this process, including runtime-managed memory.
	const prSetTHPDisable = 41
	_, _, errno := syscall.Syscall6(syscall.SYS_PRCTL, prSetTHPDisable, 1, 0, 0, 0, 0)
	if errno != 0 {
		return fmt.Errorf("disable THP: %w", errno)
	}
	fmt.Printf("mode=%s init=%s gib=%d go=%s pid=%d THP=disabled\n", *mode, *initMode, *volume, runtime.Version(), os.Getpid())
	if info, ok := debug.ReadBuildInfo(); ok {
		for _, setting := range info.Settings {
			if setting.Key == "GOEXPERIMENT" {
				fmt.Printf("build_GOEXPERIMENT=%s\n", setting.Value)
			}
		}
	}
	snapshot("before", 0)
	t := time.Now()
	a, err := NewBit40Array(*volume*gib/5, *mode)
	if err != nil {
		return err
	}
	alloc := time.Since(t)
	snapshot("allocate", alloc)
	initialMemory := fields("/proc/meminfo")
	initialSwap := initialMemory["SwapTotal"] - initialMemory["SwapFree"]
	t = time.Now()
	if err := initialize(a, *initMode == "full", *probe, 1, initialSwap); err != nil {
		if closeErr := a.Close(); closeErr != nil {
			return closeErr
		}
		return err
	}
	touch := time.Since(t)
	snapshot("first_touch", touch)
	t = time.Now()
	if err := initialize(a, *initMode == "full", *probe, 2, initialSwap); err != nil {
		if closeErr := a.Close(); closeErr != nil {
			return closeErr
		}
		return err
	}
	snapshot("second_touch", time.Since(t))
	if *initMode == "full" {
		for _, i := range []uint64{0, a.size / 2, a.size - 1} {
			if a.Get(i) != (i+2)&((1<<40)-1) {
				return fmt.Errorf("initialization mismatch at %d", i)
			}
		}
	}
	a.Set(0, 123456789)
	a.Set(a.size-1, (1<<40)-1)
	if a.Get(0) != 123456789 || a.Get(a.size-1) != (1<<40)-1 {
		return fmt.Errorf("Set/Get mismatch")
	}
	fmt.Printf("allocate_plus_first_touch_seconds=%.9f\n", (alloc + touch).Seconds())
	runtime.KeepAlive(a)
	if err := a.Close(); err != nil {
		return err
	}
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Работа с массивом выглядит так; этот фрагмент можно использовать внутри функции, возвращающей error:

a, err := NewBit40Array(uint64(1)<<35, "huge")
if err != nil {
    return err
}
defer a.Close()

a.Set(123, 987654321)
fmt.Println(a.Get(123))

Владелец массива должен дождаться завершения всех обращений перед Close. После него массив использовать нельзя. Параллельные обращения к одному элементу требуют синхронизации: пятибайтовая запись не является атомарной. Мой основной сценарий — чтение, поэтому синхронизация не нужна.

Для собственной базы данных отдельно понадобятся хранение на диске, восстановление и прочие вещи, которые одним размером страницы не решаются.

Память внешнего mmap не находится под управлением сборщика мусора Go. Нельзя рассчитывать, что он сам вызовет Munmap, или хранить там единственную ссылку на объект Go: сборщик её не увидит. У нас в массиве лежат только числа. Лимит GOMEMLIMIT также не учитывает память, отображённую приложением самостоятельно; это оговорено в руководстве по сборщику мусора Go.

Как повторить итоговый замер

Итоговая программа — отдельный файл с той же обёрткой массива, из которой убран режим heap. Она создаёт отображение, заполняет массив из 120 горутин, измеряет случайные GetFast и Set в одной горутине и в 120, затем освобождает память. Перед большим запуском встроенная самопроверка записывает и читает контрольные значения на нулевом, первом, среднем, предпоследнем и последнем индексах. GetFast проверяется везде, кроме последней ячейки.

Полный код итоговой программы bigmem-matrix

Сохраните как main.go в отдельном каталоге.

//go:build linux && amd64

// bigmem-matrix сравнивает обычный mmap и HugeTLB 1 ГиБ.
// Флаг -self-test проверяет упаковку 40-битных значений через Set/Get на
// нулевом, первом, среднем, предпоследнем и последнем индексах. На всех
// индексах, кроме последнего, результат также сверяется через GetFast.
// Самопроверка завершается до выделения большого массива и замеров скорости.

package main

import (
	"encoding/binary"
	"encoding/json"
	"flag"
	"fmt"
	"math/bits"
	"os"
	"runtime"
	"runtime/debug"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"
	"unsafe"
)

const (
	gib       = uint64(1 << 30)
	valueMask = uint64(1<<40 - 1)
)

type Bit40Array struct {
	data []byte
	size uint64
}

func NewBit40Array(size uint64, mode string) (*Bit40Array, error) {
	if size == 0 || size > (uint64(^uint(0)>>1)-gib+1)/5 {
		return nil, fmt.Errorf("invalid size: %d", size)
	}
	n := size * 5
	flags := syscall.MAP_PRIVATE | syscall.MAP_ANON
	switch mode {
	case "mmap":
	case "huge":
		const mapHuge1GB = 30 << 26
		flags |= syscall.MAP_HUGETLB | mapHuge1GB
		n = (n + gib - 1) &^ (gib - 1)
	default:
		return nil, fmt.Errorf("mode must be mmap or huge")
	}
	data, err := syscall.Mmap(-1, 0, int(n), syscall.PROT_READ|syscall.PROT_WRITE, flags)
	if err != nil {
		return nil, fmt.Errorf("mmap (%s): %w", mode, err)
	}
	return &Bit40Array{data: data, size: size}, nil
}

func (a *Bit40Array) Set(index, value uint64) {
	if index >= a.size || value >= 1<<40 {
		panic("invalid index or value")
	}
	p := index * 5
	d := a.data[p : p+5]
	binary.LittleEndian.PutUint32(d, uint32(value))
	d[4] = byte(value >> 32)
}

func (a *Bit40Array) Get(index uint64) uint64 {
	if index >= a.size {
		panic("invalid index")
	}
	p := index * 5
	d := a.data[p : p+5]
	return uint64(binary.LittleEndian.Uint32(d)) | uint64(d[4])<<32
}

// GetFast читает восемь байтов и отбрасывает старшие 24 бита. Вызывающая
// сторона обязана исключить последний индекс и одновременную запись в соседей.
func (a *Bit40Array) GetFast(index uint64) uint64 {
	return getFast(unsafe.Pointer(unsafe.SliceData(a.data)), index)
}

func getFast(base unsafe.Pointer, index uint64) uint64 {
	ptr := unsafe.Add(base, uintptr(index*5))
	return *(*uint64)(ptr) & valueMask
}

func (a *Bit40Array) Close() error {
	if a.data == nil {
		return nil
	}
	err := syscall.Munmap(a.data)
	a.data = nil
	a.size = 0
	return err
}

func procFields(path string) map[string]uint64 {
	b, err := os.ReadFile(path)
	if err != nil {
		panic(err)
	}
	out := make(map[string]uint64)
	for _, line := range strings.Split(string(b), "\n") {
		f := strings.Fields(line)
		if len(f) < 2 {
			continue
		}
		v, err := strconv.ParseUint(f[1], 10, 64)
		if err == nil {
			out[strings.TrimSuffix(f[0], ":")] = v
		}
	}
	return out
}

func snapshot(stage string, elapsed time.Duration) {
	status := procFields("/proc/self/status")
	memory := procFields("/proc/meminfo")
	var usage syscall.Rusage
	if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil {
		panic(err)
	}
	v := map[string]any{
		"stage": stage, "seconds": elapsed.Seconds(),
		"rss_kib": status["VmRSS"], "pte_kib": status["VmPTE"],
		"process_swap_kib": status["VmSwap"], "hugetlb_kib": status["HugetlbPages"],
		"hugepages_total": memory["HugePages_Total"], "hugepages_free": memory["HugePages_Free"],
		"hugepages_reserved": memory["HugePages_Rsvd"], "hugepages_surplus": memory["HugePages_Surp"],
		"hugepage_size_kib": memory["Hugepagesize"], "anon_hugepages_kib": memory["AnonHugePages"],
		"system_swap_used_kib": memory["SwapTotal"] - memory["SwapFree"],
		"minor_faults":         usage.Minflt, "major_faults": usage.Majflt,
		"user_seconds":   float64(usage.Utime.Sec) + float64(usage.Utime.Usec)/1e6,
		"system_seconds": float64(usage.Stime.Sec) + float64(usage.Stime.Usec)/1e6,
	}
	if err := json.NewEncoder(os.Stdout).Encode(v); err != nil {
		panic(err)
	}
}

func parallel(workers int, work func(worker int, start, end uint64)) {
	ready := sync.WaitGroup{}
	ready.Add(workers)
	start := make(chan struct{})
	done := sync.WaitGroup{}
	done.Add(workers)
	for worker := 0; worker < workers; worker++ {
		lo := uint64(worker)
		hi := uint64(worker + 1)
		go func(worker int) {
			defer done.Done()
			ready.Done()
			<-start
			work(worker, lo, hi)
		}(worker)
	}
	ready.Wait()
	close(start)
	done.Wait()
}

func fill(a *Bit40Array, workers int) {
	parallel(workers, func(_ int, lo, hi uint64) {
		start := a.size * lo / uint64(workers)
		end := a.size * hi / uint64(workers)
		for i := start; i < end; i++ {
			a.Set(i, (i+1)&valueMask)
		}
	})
}

// splitmix64 даёт дешёвую воспроизводимую последовательность индексов.
func splitmix64(x uint64) uint64 {
	x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
	x = (x ^ (x >> 27)) * 0x94d049bb133111eb
	return x ^ (x >> 31)
}

type result struct {
	sum uint64
	_   [120]byte
}

func sumResults(results []result) uint64 {
	var sum uint64
	for i := range results {
		sum += results[i].sum
	}
	return sum
}

func randomGets(a *Bit40Array, operations uint64, workers int) uint64 {
	results := make([]result, workers)
	base := unsafe.Pointer(unsafe.SliceData(a.data))
	readableSize := a.size - 1 // GetFast читает 8 байт; последний индекс исключён.
	parallel(workers, func(worker int, _, _ uint64) {
		shardStart := readableSize * uint64(worker) / uint64(workers)
		shardEnd := readableSize * uint64(worker+1) / uint64(workers)
		shardSize := shardEnd - shardStart
		start := operations * uint64(worker) / uint64(workers)
		end := operations * uint64(worker+1) / uint64(workers)
		x := uint64(worker+1) * 0x9e3779b97f4a7c15
		var sum uint64
		for i := start; i < end; i++ {
			x += 0x9e3779b97f4a7c15
			z := splitmix64(x)
			offset, _ := bits.Mul64(z, shardSize)
			index := shardStart + offset
			sum += getFast(base, index)
		}
		results[worker].sum = sum
	})
	return sumResults(results)
}

func randomSets(a *Bit40Array, operations uint64, workers int) uint64 {
	results := make([]result, workers)
	parallel(workers, func(worker int, _, _ uint64) {
		// Непересекающиеся участки исключают гонки между горутинами.
		shardStart := a.size * uint64(worker) / uint64(workers)
		shardEnd := a.size * uint64(worker+1) / uint64(workers)
		shardSize := shardEnd - shardStart
		start := operations * uint64(worker) / uint64(workers)
		end := operations * uint64(worker+1) / uint64(workers)
		x := uint64(worker+1) * 0x9e3779b97f4a7c15
		var sum uint64
		for i := start; i < end; i++ {
			x += 0x9e3779b97f4a7c15
			z := splitmix64(x)
			offset, _ := bits.Mul64(z, shardSize)
			index := shardStart + offset
			a.Set(index, z&valueMask)
			sum += index
		}
		results[worker].sum = sum
	})
	return sumResults(results)
}

func measureAccess(a *Bit40Array, operations uint64, workers, repeats int, write bool) {
	name := "get"
	if write {
		name = "set"
	}
	for pass := 1; pass <= repeats; pass++ {
		started := time.Now()
		var sum uint64
		if write {
			sum = randomSets(a, operations, workers)
		} else {
			sum = randomGets(a, operations, workers)
		}
		elapsed := time.Since(started)
		fmt.Printf("operation=%s workers=%d pass=%d operations=%d seconds=%.9f million_ops_per_second=%.3f checksum=%d\n",
			name, workers, pass, operations, elapsed.Seconds(), float64(operations)/elapsed.Seconds()/1e6, sum)
	}
}

func checkAccess() error {
	a, err := NewBit40Array(1024, "mmap")
	if err != nil {
		return err
	}
	defer a.Close()
	tests := []struct {
		index uint64
		value uint64
	}{
		{0, 0}, {1, 1}, {511, 0x123456789a},
		{1022, valueMask - 1}, {1023, valueMask},
	}
	for _, test := range tests {
		a.Set(test.index, test.value)
		if got := a.Get(test.index); got != test.value {
			return fmt.Errorf("Get(%d)=%x, want %x", test.index, got, test.value)
		}
		if test.index < a.size-1 {
			if got := a.GetFast(test.index); got != test.value {
				return fmt.Errorf("GetFast(%d)=%x, want %x", test.index, got, test.value)
			}
		}
	}
	fmt.Println("self_test=passed")
	return nil
}

func run() error {
	selfTest := flag.Bool("self-test", false, "check boundary values and exit")
	mode := flag.String("mode", "mmap", "mmap or huge")
	volume := flag.Uint64("gib", 160, "array size in GiB")
	fillWorkers := flag.Int("fill-workers", 120, "workers for full initialization")
	parallelWorkers := flag.Int("parallel-workers", 120, "workers for parallel random access")
	singleOps := flag.Uint64("single-ops", 400_000_000, "operations in each one-thread measurement")
	parallelOps := flag.Uint64("parallel-ops", 20_000_000_000, "operations in each parallel measurement")
	repeats := flag.Int("repeats", 1, "measurements of each access mode")
	flag.Parse()
	if *selfTest {
		return checkAccess()
	}
	if *volume == 0 || *volume > 1024 || (*volume*gib)%5 != 0 {
		return fmt.Errorf("gib must be positive and byte size must be divisible by 5")
	}
	if *fillWorkers < 1 || *fillWorkers > 128 || *parallelWorkers < 1 || *parallelWorkers > 128 || *singleOps == 0 || *parallelOps == 0 || *repeats < 1 || *repeats > 10 {
		return fmt.Errorf("invalid benchmark parameters")
	}
	if *mode != "mmap" && *mode != "huge" {
		return fmt.Errorf("mode must be mmap or huge")
	}
	if *mode == "mmap" && procFields("/proc/meminfo")["MemAvailable"]*1024 < (*volume+16)*gib {
		return fmt.Errorf("need array size plus 16 GiB available RAM")
	}
	const prSetTHPDisable = 41
	_, _, errno := syscall.Syscall6(syscall.SYS_PRCTL, prSetTHPDisable, 1, 0, 0, 0, 0)
	if errno != 0 {
		return fmt.Errorf("disable THP: %w", errno)
	}
	fmt.Printf("go=%s mode=%s gib=%d gomaxprocs=%d fill_workers=%d parallel_workers=%d THP=disabled\n",
		runtime.Version(), *mode, *volume, runtime.GOMAXPROCS(0), *fillWorkers, *parallelWorkers)
	if info, ok := debug.ReadBuildInfo(); ok {
		for _, setting := range info.Settings {
			if setting.Key == "GOEXPERIMENT" {
				fmt.Printf("build_GOEXPERIMENT=%s\n", setting.Value)
			}
		}
	}
	snapshot("before", 0)
	started := time.Now()
	a, err := NewBit40Array(*volume*gib/5, *mode)
	if err != nil {
		return err
	}
	snapshot("create", time.Since(started))

	started = time.Now()
	fill(a, *fillWorkers)
	snapshot("fill", time.Since(started))

	measureAccess(a, *singleOps, 1, *repeats, false)
	measureAccess(a, *singleOps, 1, *repeats, true)
	measureAccess(a, *parallelOps, *parallelWorkers, *repeats, false)
	measureAccess(a, *parallelOps, *parallelWorkers, *repeats, true)

	runtime.KeepAlive(a)
	started = time.Now()
	if err := a.Close(); err != nil {
		return err
	}
	snapshot("close", time.Since(started))
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
go build -o bigmem-matrix main.go
./bigmem-matrix -self-test
./bigmem-matrix -mode=mmap

Режим обычных страниц я запустил до резервирования пула. После загрузки с HugeTLB для обычных процессов осталось около 78 ГиБ доступной RAM, поэтому второй массив на 160 ГиБ туда уже не помещается.

Для Huge Pages команда отличается одним аргументом. Анонимному MAP_HUGETLB нужны права, о которых шла речь выше, поэтому в простейшем случае запускаем через sudo:

sudo ./bigmem-matrix -mode=huge

Программа не переходит на обычные страницы при ошибке HugeTLB. Она печатает время каждого этапа, VmPTE, page faults, подкачку, HugetlbPages процесса и состояние глобального пула.

Что дали страницы по 1 ГиБ

После перезагрузки командная строка ядра содержала default_hugepagesz=1G hugepagesz=1G hugepages=160. В пуле находились 160 свободных страниц:

HugePages_Total:     160
HugePages_Free:      160
HugePages_Rsvd:        0
Hugepagesize:    1048576 kB
Hugetlb:        167772160 kB

Обычный размер страницы остался равен 4 КиБ. До запуска массива free показывал около 78 ГиБ доступной обычной RAM и 200 ГиБ свободной подкачки. Зарезервированные 160 ГиБ уже не входили в доступную обычным процессам память.

Каждый скоростной тест выполнялся один раз. Перед сравнением программа полностью заполняла массив из 120 горутин. Результаты получились такими:

Этап

Обычные страницы 4 КиБ

HugeTLB 1 ГиБ

Изменение

Создание mmap

12,830 мкс

31,222 мкс

+18,392 мкс

Полное заполнение 160 ГиБ

6,461 с

5,122 с

в 1,26 раза быстрее

GetFast, 1 горутина

31,38 млн/с

56,55 млн/с

в 1,80 раза быстрее

Set, 1 горутина

38,30 млн/с

113,79 млн/с

в 2,97 раза быстрее

GetFast, 120 горутин

1 398,76 млн/с

1 982,31 млн/с

в 1,42 раза быстрее

Set, 120 горутин

838,50 млн/с

855,39 млн/с

на 2,0 % быстрее

Освобождение через munmap

10,213 с

0,000135 с

примерно в 75 520 раз быстрее

Главный для моей задачи результат — ускорение параллельного чтения в 1,42 раза (120 горутин, 1 982 миллиона операций в секунду вместо 1 399). В одном потоке чтение ускорилось в 1,80 раза. Гигабайтовые страницы увеличили участок памяти, покрываемый одной записью TLB, и сократили число обходов таблиц страниц.

Параллельная запись прибавила всего 2 %: разница несущественная, на уровне шума. Однопоточный Set при этом ускорился почти втрое. Причина в том, что в одном потоке каждый промах TLB и обход таблиц страниц лежит на критическом пути: пока трансляция адреса не готова, следующая операция ждёт. При 120 горутинах независимые обращения перекрывают задержки друг друга, и ограничением становится пропускная способность памяти.

Полное заполнение стало быстрее на 1,34 секунды. После него процесс показывал 3 607 малых page faults вместо 41 946 582 и VmPTE размером 260 КиБ вместо 328 584 КиБ. В 260 КиБ входят и стек, и среда выполнения Go, и другие отображения процесса.

Создание обоих отображений заняло десятки микросекунд. Счётчики HugeTLB подтвердили, что массив действительно лёг на гигабайтовые страницы: после mmap счётчик HugePages_Rsvd стал равен 160, во время первого заполнения HugePages_Free уменьшился до нуля, а HugetlbPages процесса вырос до 160 ГиБ.

Освобождение обычного массива потребовало разобрать десятки миллионов записей таблиц страниц и заняло 10,21 секунды. Для HugeTLB ядро вернуло 160 страниц в пул за 135 микросекунд. Сразу после munmap счётчик HugePages_Free снова показывал 160. Подкачка в обоих тестах не использовалась.

Какой софт умеет работать с большими страницами

Наличие сотен гигабайт RAM само по себе не включает Huge Pages ни в СУБД, ни в чём-либо ещё. Нужны поддержка со стороны программы и её настройка. Начну с баз данных.

СУБД

Поддержка и ограничения

PostgreSQL

Явное использование через huge_pages для основной общей области памяти. Параметр huge_page_size позволяет выбрать размер, в том числе 1 ГиБ на подходящей Linux-системе. См. документацию PostgreSQL.

MySQL / InnoDB

Поддерживает большие страницы для буферного пула, включая Linux HugeTLB. Допустимые размеры страниц зависят от версии. См. документацию MySQL 8.4.

Oracle Database

Использует большие страницы для SGA; поведение задаёт USE_LARGE_PAGES. См. документацию Oracle.

Отдельная история — рекомендации отключать THP. Например, документация Redis предупреждает о задержках при сохранении через fork с включёнными THP. THP и явно зарезервированный пул HugeTLB — разные механизмы, и к нашему пулу эта рекомендация не относится.

За пределами СУБД явный пул HugeTLB чаще всего встречается там, где программа сама держит большой непрерывный кусок памяти и сама им управляет:

Софт

Как использует большие страницы

DPDK

Пул HugeTLB обязателен: из него выделяются буферы пакетов. Для 64-битных приложений документация рекомендует страницы по 1 ГиБ, резервировать их советует при загрузке теми же параметрами ядра, что и у нас.

SPDK

Тот же подход для работы с NVMe из пространства пользователя: перед запуском нужно выделить пул, скрипт установки по умолчанию берёт 2 ГиБ.

QEMU/KVM

Память виртуальной машины целиком ложится на HugeTLB через memoryBacking в libvirt. Об этом ниже.

JVM

-XX:+UseLargePages включает явные большие страницы для кучи, -XX:LargePageSizeInBytes=1g задаёт размер. Отдельный -XX:+UseTransparentHugePages работает через THP, и документация Java прямо предупреждает о возможных проблемах с ним.

memcached

Ключ -L просит большие страницы и ради этого выделяет весь кеш одним куском.

mimalloc

Аллокатор от Microsoft: MIMALLOC_ALLOW_LARGE_OS_PAGES=1 включает страницы по 2 МиБ, а MIMALLOC_RESERVE_HUGE_OS_PAGES=N резервирует N страниц по 1 ГиБ при старте. Так гигабайтовые страницы получает любая программа на C или C++ без правки кода.

jemalloc

Только THP: параметры opt.thp и opt.metadata_thp управляют madvise, явный пул HugeTLB аллокатор не использует.

libhugetlbfs

Библиотека для тех, кто хочет посадить на HugeTLB немодифицированную программу: через LD_PRELOAD и HUGETLB_MORECORE она подменяет источник памяти для malloc.

Kubernetes

Пул HugeTLB с узла выдаётся подам как ресурс hugepages-2Mi или hugepages-1Gi, без переподписки. Резервировать страницы на узле всё равно приходится через GRUB.

Среда выполнения Go в этот список не входит: сама она пул HugeTLB не использует, поэтому массив и пришлось отображать через mmap вручную.

Включать гигабайтовые страницы для всего подряд я бы не стал. Для нашего массива способ выделения контролируется в нескольких строках; для готовой программы ориентируемся на её документацию и собственные измерения.

Иные применения Huge Pages

С гигантскими страницами я сталкиваюсь не впервые. В статье «Ускорение дисковой подсистемы Qemu KVM в Linux» я резервировал 64 ГиБ через GRUB и подключал этот пул к виртуальной машине с помощью memoryBacking в libvirt:

<memoryBacking>
  <hugepages/>
</memoryBacking>

Там Huge Pages были одним из нескольких изменений вместе с LVM, отдельным потоком ввода-вывода и настройками VirtIO. После тюнинга всех настроек, включая активацию Huge Pages, запись ускорилась в среднем на 58 %, чтение — на 25 %.

Связь с нынешним опытом прямая: QEMU тоже управляет большим непрерывным диапазоном памяти, а хосту приходится поддерживать его трансляцию.

Выводы

Гигабайтовые страницы дали колоссальный эффект на итоговую производительность случайного чтения. Главный результат — ускорение параллельного чтения в 1,42 раза. В одном потоке чтение ускорилось в 1,80 раза, а случайная запись — почти втрое. Параллельная запись прибавила 2 %, это на уровне шума: при 120 пишущих горутинах тест упирается в пропускную способность памяти.

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

Подготовка массива тоже стала быстрее: 5,12 против 6,46 секунды. Сильнее всего изменилось освобождение памяти: 135 микросекунд вместо 10,21 секунды. Десятисекундная пауза на каждой итерации подбора исчезла. Для моих экспериментов это существенно, поскольку содержимое массива приходится подбирать, после чего создавать его заново. Для конвейеров обработки данных, в которых большие объёмы памяти многократно выделяются и освобождаются, разница может оказаться принципиальной: десять секунд на каждое освобождение быстро складываются в минуты и часы.

Изолированный тест уточнил и моё первоначальное объяснение старой проблемы: супермедленной записи готового массива на диск, при которой подкачка разрасталась до сотен гигабайт. Сохранение делалось стандартными средствами Go, и они создавали под него огромные буферы. Сам массив на обычных страницах по 4 КиБ не потребовал swap даже при полном заполнении. Значит, подкачка относилась не к массиву, а ко всей рабочей комбинации: остальным данным процесса и самой процедуре сохранения результата. Массив на HugeTLB в swap не попадает: такие страницы ядро не выгружает.

Плата за всё это — пул из 160 ГиБ, который исчезает из распоряжения обычных процессов сразу после загрузки. В моей научно-исследовательской задаче обмен оправдан: я получил более быстрый произвольный доступ и моментальное освобождение памяти.

Для проекта, ограниченного во времени, сервер с сотнями гигабайт RAM разумно арендовать, особенно при нынешней стоимости оперативной памяти. У RUVDS такие машины есть в линейке выделенных серверов на AMD EPYC: от 7713 с 512 ГБ RAM до конфигураций с 2,3 ТБ. Остальные классы выделенных серверов собраны в общем конфигураторе.

© 2026 ООО «МТ ФИНАНС»

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.