golang

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

  • суббота, 12 сентября 2026 г. в 00:00:18
https://habr.com/ru/companies/ruvds/articles/1080832/
Работа с гигантской памятью из Go на выделенном сервере
Работа с гигантской памятью из Go на выделенном сервере

Массив на 160 ГиБ

Для одной научно-исследовательской задачи мне понадобился массив на 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 не происходит.

Проверим текущие настройки THP на сервере:

cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never

На стенде стоит madvise, а в тестовом процессе я дополнительно отключаю THP через PR_SET_THP_DISABLE для воспроизводимости теста на других машинах, где он может быть включён. Это делается перед выделением памяти.

  const prSetTHPDisable = 41

  _, _, errno := syscall.Syscall6(
        syscall.SYS_PRCTL,
        prSetTHPDisable,
        1, // отключить THP
        0, 0, 0, 0,
  )
  if errno != 0 {
        return fmt.Errorf("disable THP: %w", errno)
  }

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

Размер обычной страницы на стенде — 4 КиБ, это подтвердил getconf PAGESIZE.

Посмотрим, что говорит система о Huge Pages (до манипуляций с Grub):

cat /proc/meminfo
...
HugePages_Total:       0
HugePages_Free:        0
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB
Hugetlb:               0 kB
...

Строка Hugepagesize: 2048 kB может сбить с толку: она сообщает, что ядро умеет создавать пул HugeTLB со страницами по 2 МиБ, а не об их реальном использовании. Нули в других строках говорят о том, что ни явные 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

Когда страница памяти не выделена возникает ошибка типа «page faults» и ядро выделяет страницу.

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

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

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

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

GOEXPERIMENT=greenteagc go build -o membench-green main.go
GOEXPERIMENT=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

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

Сборщик

Первое заполнение 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 секунды. В однопоточном сценарии с 4КиБ страницами время заполнения увеличилось примерно на треть. Это время ушло на то, чтобы ядро по одной выделило процессу 42 миллиона страниц.

Счётчики процессорного времени показывают ту же картину. Заполнение подготовленного массива добавило 0,35 секунды системного времени, нового — 59,45 секунды.

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

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

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

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

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

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

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

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

Однопоточный проход содержит 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. Это может показаться странным. Но при чтении процессор должен дождаться готовности кеш-линии. Запись же попадает в буфер, который работает по принципу «fire and forget» и цикл может продолжаться, пока кеш и контроллер памяти завершают её в фоне.

На 120 горутинах соотношение изменилось: множество независимых чтений скрывает задержки.

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

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

Нужно различать размер настроенной подкачки и число байтов, которые система действительно туда выгрузила. Как уже было сказано память реально выделяется только при записи в неё, но успех mmap зависит от суммы реальной памяти и свата, а также от настроек политики ядра overcommit.

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

Посмотреть можно так cat /proc/meminfo|grep Commit.

  • CommitLimit — сколько памяти ядро в принципе готово пообещать;

  • Committed_AS — сколько уже обещано процессам.

Сколько страниц у массива на 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, с возвратом страниц в пул

Размер страницы задаю явно: к 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))

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

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

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

Что такое GOMEMLIMIT

GOMEMLIMIT — мягкий лимит памяти, которой управляет среда выполнения Go. Значение задаётся в байтах, удобнее с суффиксом:

  GOMEMLIMIT=8GiB ./program
  GOMEMLIMIT=200GiB ./program

При приближении к лимиту Go чаще запускает сборщик мусора и активнее возвращает память ОС. Это не жёсткий предел: программа может его временно превысить.

Для отключения лимита:

  GOMEMLIMIT=off

160 ГиБ для нашего массива, полученные через syscall.Mmap, в GOMEMLIMIT не входят.

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

Итоговая программа — отдельный файл с той же обёрткой массива, из которой убран режим 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

Массив 160 ГиБ на обычных страницах я тестировал до резервирования пула. После перезагрузки загрузки с подключением HugeTLB для обычных процессов остаётся около 78 ГиБ доступной RAM.

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

sudo ./bigmem-matrix -mode=huge

Программа печатает время каждого этапа, 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 раза, а случайная запись — почти втрое.

Самого сильного эффекта стоит ждать от однопоточных программ. В одном потоке каждый промах 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 ООО «МТ ФИНАНС»