Возвращать страницы памяти операционной системе

Освобождение объекта в коде приложения или закрытие временного потока не всегда возвращает физическую ОЗУ операционной системе. Специальные нативные распределители, среды выполнения со сборкой мусора и хеш-таблицы с открытой адресацией часто сохраняют области виртуальной памяти, которые остаются подкрепленными физическими страницами ещё долго после того, как логические данные внутри них исчезнут. Аналогично, чтение или сопоставление памяти больших архивов объектов заполняет кеш страниц ядра, который остается в группе управления памятью (memcg) вашего приложения.

В этой статье рассказывается, как с помощью системных вызовов madvise и posix_fadvise возвращать неиспользуемые анонимные и связанные с файлами страницы в ОС, не затрагивая резервирование виртуальных адресов и дескрипторы файлов, а также как проверять результат в adb shell и Perfetto.

Почему при логическом освобождении физическая оперативная память сохраняется

Ядро Linux отслеживает физическую память на уровне аппаратных страниц, размер которых на устройствах Android составляет 4 КБ или 16 КБ. Когда процесс резервирует область виртуального адресного пространства с помощью mmap, ядро создает область виртуальной памяти (VMA), не выделяя физическую оперативную память. Ядро выделяет и учитывает физическую страницу, только когда процесс впервые обращается к ней. Для анонимной памяти первое чтение сопоставляет общую нулевую страницу, поэтому ОЗУ выделяется только при записи.

После того как страница загружена в ОЗУ, ядро не знает, представляют ли байты внутри этой страницы живые объекты в среде выполнения вашего приложения. Есть два распространенных сценария, когда физические страницы остаются резидентными после того, как приложение перестает их использовать:

  • Анонимные арены и пулы. Пользовательские нативные распределители (например, распределители slab, пула или арены фреймов), управляемые среды выполнения с неподвижными пространствами кучи и большие хеш-таблицы резервируют непрерывные диапазоны виртуальных адресов с помощью mmap, используя MAP_PRIVATE | MAP_ANONYMOUS. Когда приложение сбрасывает арену, освобождает блок обратно в пользовательский пул или очищает хеш-таблицу, обнуляя ее метаданные, базовые страницы остаются измененными. Без явного указания ядру эти страницы продолжают учитываться в анонимном резидентном наборе (RSS) и пропорциональном наборе (PSS) вашего процесса и его нагрузке на cgroup memory.current. При нехватке памяти ядро сжимает их в ZRAM, где они по-прежнему занимают swap-пространство, относящееся к memory.swap.current.
  • Кэш страниц, связанных с файлами, из потоковой передачи объектов. Когда приложение считывает большие наборы объектов, архивы уровней, медиаконтейнеры или модели машинного обучения с помощью read, pread или связанных с файлами mmap, ядро кэширует каждую прочитанную страницу в кэше страниц и добавляет эти страницы в счетчик file в файле memory.stat группы cgroup вашего процесса. Если приложение распаковывает или копирует этот объект в анонимный буфер среды выполнения или загружает его в графический процессор, то сохранение страниц необработанного файла в кеше страниц дублирует объект в ОЗУ.
Страницы Dirty Arena освобождаются с помощью madvise, а кэшированные страницы объектов вытесняются с помощью posix_fadvise, при этом виртуальный диапазон и дескриптор файла остаются нетронутыми.
Рисунок 1. Освобождение физических страниц из виртуальных арен и кеша страниц.

Опубликовать анонимные страницы

Системный вызов madvise сообщает ядру, как процесс планирует использовать диапазон виртуальных адресов. Для частных анонимных сопоставлений (MAP_PRIVATE | MAP_ANONYMOUS) передача MADV_DONTNEED указывает ядру немедленно удалить физические страницы и все записи ZRAM в файле подкачки, которые поддерживают указанный диапазон, выровненный по страницам.

После madvise с MADV_DONTNEED возвращает:

  • Диапазон виртуальных адресов остается зарезервированным и действительным в адресном пространстве процесса (VmSize не меняется), поэтому указатели и границы арены остаются нетронутыми без вызова munmap или mmap.
  • Физические страницы сразу же отключаются и освобождаются, что уменьшает RssAnon, Pss, счетчик anon в memory.stat и memory.swap.current для всех страниц, которые были в ZRAM.
  • Любое последующее чтение или запись по адресу в этом диапазоне приводит к ошибке на новой странице, заполненной нулями, по запросу.
Флаг совета Сопоставление целей Поведение ядра в Android
MADV_DONTNEED Частный анонимный (MAP_PRIVATE | MAP_ANONYMOUS) Немедленно удаляет физические страницы и слоты подкачки ZRAM. При последующих обращениях к памяти будут возвращаться страницы, заполненные нулями. Детерминированное снижение в RssAnon и memory.current.
MADV_DONTNEED Файлы (MAP_PRIVATE или MAP_SHARED) Отменяет сопоставление страниц с таблицами страниц процесса (RssFile удалений). Частные страницы с копированием при записи в сопоставлении MAP_PRIVATE удаляются, поэтому изменения теряются, а при следующем доступе файл считывается заново. Измененные страницы MAP_SHARED записываются в хранилище позже. Чистые страницы остаются в кеше, пока не будут вытеснены.
MADV_FREE Частный анонимный (MAP_PRIVATE | MAP_ANONYMOUS) Помечает страницы как доступные для повторного использования. Ядро сохраняет страницы в RssAnon, если нет нехватки памяти, и освобождает их, только когда запускается глобальное освобождение или освобождение memcg.
MADV_WILLNEED Анонимная память, связанная с файлом или файлом подкачки Запускает асинхронное упреждающее чтение для сопоставлений, поддерживаемых файлами, или заранее считывает замененные анонимные страницы из ZRAM обратно в кеш подкачки.

Выравнивание внутренних диапазонов по размеру страницы среды выполнения

madvise работает со страницами целиком. Требуется начальный адрес, выровненный по странице, и округление длины до следующей границы страницы. Если вы освободите диапазон внутри общей арены, где соседние байты по-прежнему содержат активные объекты, округление диапазона наружу может обнулить активные распределения, которые совместно используют граничные страницы.

Чтобы обеспечить поддержку устройств со страницами размером 4 и 16 КБ, всегда запрашивайте размер страницы во время выполнения с помощью sysconf(_SC_PAGESIZE). Округляйте начальный адрес до следующей границы страницы, а конечный – до предыдущей. Подробнее о том, как оптимизировать распределители памяти, не зависящие от размера страницы, рассказывается в этой статье.

#include <sys/mman.h>
#include <unistd.h>
#include <cstdint>

// Reclaims physical pages strictly inside [region_start, region_start + length)
// without disturbing live objects on partially occupied boundary pages.
void ReleaseUnusedArenaSpan(void* region_start, size_t length) {
  const uintptr_t page_size = static_cast<uintptr_t>(sysconf(_SC_PAGESIZE));
  const uintptr_t page_mask = ~(page_size - 1);

  const uintptr_t raw_start = reinterpret_cast<uintptr_t>(region_start);
  const uintptr_t raw_end = raw_start + length;

  // Round start UP and end DOWN so only fully unused pages are discarded.
  const uintptr_t aligned_start = (raw_start + page_size - 1) & page_mask;
  const uintptr_t aligned_end = raw_end & page_mask;

  if (aligned_end > aligned_start) {
    madvise(reinterpret_cast<void*>(aligned_start),
            aligned_end - aligned_start,
            MADV_DONTNEED);
  }
}

При создании пользовательских анонимных сопоставлений с помощью mmap пометьте VMA как prctl, используя PR_SET_VMA_ANON_NAME, чтобы вы могли отслеживать его резидентность по имени в showmap и /proc/<pid>/smaps. dumpsys meminfo подсчитывает анонимные регионы с пользовательскими названиями в Unknown. Некоторые старые ядра сохраняют указатель на имя, а не копируют его, поэтому передайте строку, которая остается действительной в течение всего времени существования сопоставления, например строковый литерал:

#include <sys/mman.h>
#include <sys/prctl.h>

#ifndef PR_SET_VMA
#define PR_SET_VMA 0x53564d41
#define PR_SET_VMA_ANON_NAME 0
#endif

void* ReserveNamedArena(size_t capacity_bytes, const char* arena_name) {
  void* ptr = mmap(nullptr, capacity_bytes, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (ptr != MAP_FAILED) {
    prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME,
          reinterpret_cast<unsigned long>(ptr), capacity_bytes,
          reinterpret_cast<unsigned long>(arena_name));
  }
  return ptr;
}

Вытеснение страниц кеша файлов

Когда приложение считывает данные из дескриптора файла, ядро кеширует страницы файла в кеше страниц, чтобы ускорить последующие операции чтения. Это ускоряет повторный доступ к небольшим файлам конфигурации или общим библиотекам, но замедляет потоковую передачу больших архивов объектов, пакетов текстур или фрагментов аудио и видео, которые анализируются один раз и хранятся в декодированном виде в другом месте.

Системный вызов posix_fadvise позволяет приложению сообщить ядру об ожидаемом шаблоне доступа к файлу в открытом дескрипторе файла.

  • POSIX_FADV_DONTNEED – сообщает ядру, что указанный диапазон байтов не будет использоваться в ближайшем будущем. Ядро немедленно удаляет чистые, несопоставленные кешированные страницы в этом диапазоне, выровненном по страницам, из кеша страниц, уменьшая счетчик file в memory.stat и плату memory.current вашей cgroup.
  • POSIX_FADV_SEQUENTIAL – удваивает окно упреждающего чтения ядра для дескриптора файла, повышая пропускную способность при потоковой передаче большого архива от начала до конца. Используйте этот метод вместе с POSIX_FADV_DONTNEED для последующих фрагментов по мере их обработки, чтобы объем кеша страницы не превышал размер небольшого скользящего окна.
  • POSIX_FADV_RANDOM: отключает упреждающее чтение ядра для дескриптора файла. Используйте этот вариант, когда выполняете разреженный поиск по байтовому смещению в большом архиве или файле базы данных, чтобы при каждом небольшом считывании в кеш не попадали соседние неиспользуемые страницы. Для файлов, сопоставленных с памятью, вызовите madvise с MADV_RANDOM.

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

#include <fcntl.h>
#include <unistd.h>
#include <cstdint>
#include <vector>

void StreamAssetArchive(int fd, size_t total_size) {
  const size_t page_size = static_cast<size_t>(sysconf(_SC_PAGESIZE));
  constexpr size_t kChunkSize = 4 * 1024 * 1024;  // 4 MB streaming window
  std::vector<uint8_t> buffer(kChunkSize);

  // Hint sequential access so the kernel prefetches upcoming chunks.
  posix_fadvise(fd, 0, total_size, POSIX_FADV_SEQUENTIAL);

  off_t offset = 0;
  while (offset < static_cast<off_t>(total_size)) {
    ssize_t bytes_read = pread(fd, buffer.data(), buffer.size(), offset);
    if (bytes_read <= 0) break;

    // Decode or upload buffer[0 .. bytes_read) into runtime structures here.

    // Evict full pages that have been completely consumed.
    const size_t aligned_len =
        (static_cast<size_t>(bytes_read) / page_size) * page_size;
    if (aligned_len > 0) {
      posix_fadvise(fd, offset, static_cast<off_t>(aligned_len),
                    POSIX_FADV_DONTNEED);
    }
    offset += bytes_read;
  }
}

Освобождение областей mmap, связанных с файлами

Если ваше приложение сопоставляет большой файл объекта с памятью с помощью mmap вместо того, чтобы читать его с помощью pread, вызов posix_fadvise только с POSIX_FADV_DONTNEED не вытесняет страницы, которые все ещё сопоставлены с таблицами страниц вашего процесса. Чтобы удалить использованный диапазон сопоставления файлов только для чтения, не отменяя сопоставление всей VMA:

  1. Вызовите madvise(addr, length, MADV_DONTNEED) для сопоставленного диапазона виртуальных адресов, чтобы отменить сопоставление этих страниц с таблицами страниц вашего процесса (RssFile drops).
  2. Вызовите posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED) для соответствующего смещения файла, чтобы ядро также удалило несопоставленные чистые страницы из кеша страниц (file в memory.stat).

Следуйте рекомендациям по страницам

При интеграции madvise и posix_fadvise в распределители, загрузчики объектов и обработчики жизненного цикла следуйте приведенным ниже рекомендациям.

  • Пакетные вызовы в грубых регионах. Каждый вызов madvise блокирует карту памяти процесса (mmap_lock), обходит таблицы страниц и делает недействительными соответствующие записи буфера ассоциативной трансляции (TLB) на других ядрах ЦП (с инструкциями по обслуживанию широковещательного TLB на Arm и межпроцессорными прерываниями на x86). Не вызывайте madvise при каждом небольшом освобождении памяти. Объединяйте свободные диапазоны и передавайте ядру фрагменты размером от 64 КБ до 1 МБ или больше. Также можно очищать свободные диапазоны во время границ кадров и фоновых переходов (onStop и TRIM_MEMORY_UI_HIDDEN).
  • Освобождайте неиспользуемую память в хеш-таблицах и пулах. Если после перехода между сценами или синхронизации данных очищается большая хеш-таблица, пул объектов или промежуточный буфер, уменьшите объем выделенной памяти или вызовите MADV_DONTNEED для очищенного массива. Если у вас есть таблица с открытой адресацией размером 32 МБ, в которой хранятся надгробные записи, то все 32 МБ физических страниц будут закреплены за вашим процессом.
  • Сбрасывать данные измененных файлов перед их удалением. POSIX_FADV_DONTNEED удаляет только чистые страницы кеша. Если приложение записывает большой временный файл или файл кеша, который не будет прочитан сразу, вызовите fdatasync перед posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED), чтобы измененные страницы были записаны в хранилище, а затем удалены из ОЗУ.
  • Не вытесняйте часто используемый общий код или объекты. Не вызывайте POSIX_FADV_DONTNEED для DEX, ODEX, VDEX, общих библиотек (.so) или объектов интерфейса, к которым часто обращаются. Вытеснение активного кода или страниц объектов приводит к синхронным ошибкам хранилища, которые останавливают потоки и повышают нагрузку на систему I/O, о чем сообщает информация о задержках из-за нагрузки (PSI).

Как проверить освобождение страниц в оболочке

Убедитесь, что madvise и posix_fadvise освобождают физическую ОЗУ, используя showmap и файлы учета cgroup v2 вашего приложения.

Как проверить виртуальный и физический размер с помощью showmap

Если вы присвоите анонимной арене имя PR_SET_VMA_ANON_NAME, showmap покажет как ее виртуальное резервирование (virtual size), так и ее резидентные физические страницы (RSS и PSS). Ниже приведен пример выходных данных из примера приложения MemoryLab в AOSP, которое называет свою арену memorylab-arena. Замените com.example.app и memorylab-arena на название пакета и название арены. Перед вызовом madvise с MADV_DONTNEED в затронутой области памяти размером 64 МБ выполните следующую команду:

adb shell "showmap \$(pidof com.example.app) | grep -E '^ +virtual|^ +size|memorylab-arena' | head -3"
 virtual                     shared   shared  private  private                   Anon      Shmem     File      Shared   Private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS HugePages PmdMapped PmdMapped Hugetlb  Hugetlb    Locked    # object
   65536    65536    65536        0        0        0    65536        0        0         0         0         0        0        0        0    1 [anon:memorylab-arena]

После вызова madvise(arena_ptr, 64 * 1024 * 1024, MADV_DONTNEED) снова запустите showmap:

 virtual                     shared   shared  private  private                   Anon      Shmem     File      Shared   Private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS HugePages PmdMapped PmdMapped Hugetlb  Hugetlb    Locked    # object
   65536        0        0        0        0        0        0        0        0         0         0         0        0        0        0    1 [anon:memorylab-arena]

Значение virtual size остается равным 65 536 КБ (64 МБ), что подтверждает, что диапазон виртуальных адресов по-прежнему зарезервирован, а значения RSS, PSS и private dirty падают до 0 КБ.

Проверка cgroup memory.current и memory.stat

Чтобы проверить, как работает возврат анонимных страниц и вытеснение кэша несопоставленных страниц (не отображается в таблицах страниц процесса, если считывается с помощью pread), изучите каталог cgroup v2 процесса. На Android у каждого процесса приложения есть своя контрольная группа (например, /sys/fs/cgroup/apps/uid_<UID>/pid_<PID>/, в зависимости от устройства). Следующая команда находит каталог из строки 0:: в /proc/<PID>/cgroup и распечатывает счетчики:

adb shell '
  PID=$(pidof com.example.app)
  CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/$PID/cgroup)
  echo "memory.current: $(cat $CG/memory.current)"
  echo "memory.swap.current: $(cat $CG/memory.swap.current)"
  grep -E "^(anon|file) " $CG/memory.stat
'

Перед освобождением анонимной арены размером 64 МБ и удалением из кеша страниц файла ресурса размером 128 МБ, сопоставленного с памятью:

memory.current: 242962432
memory.swap.current: 16633856
anon 100671488
file 140009472

Сразу после вызова madvise с MADV_DONTNEED на арене и в сопоставлении файлов объектов, а затем posix_fadvise с POSIX_FADV_DONTNEED в дескрипторе файла объекта:

memory.current: 41766912
memory.swap.current: 16502784
anon 33722368
file 5914624

anon уменьшается на 64 МБ, а file – на 128 МБ. В результате memory.current уменьшается примерно на 192 МБ без закрытия дескриптора файла или отмены сопоставления виртуального адресного пространства арены. memory.swap.current почти не меняется, потому что почти ни одна из страниц арены не была перенесена в ZRAM. Значения зависят от устройства и от того, какую часть файла ядро уже вытеснило самостоятельно. Подробнее о том, как ядро выделяет и освобождает память для каждой группы управления, рассказывается в разделе Освобождение памяти: вытеснение и подкачка.

Как анализировать освобождение страниц в Perfetto

Perfetto позволяет сопоставлять вызовы madvise и posix_fadvise со счетчиками таблицы страниц ядра и кеша страниц на единой временной шкале.

Как записать трассировку

Запишите трассировку с помощью linux.process_stats для периодических счетчиков процессов и linux.ftrace для событий RSS и кеша страниц в реальном времени, а затем перенесите ее на компьютер:

adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/page_advice.perfetto-trace <<EOF
buffers: {
    size_kb: 65536
    fill_policy: RING_BUFFER
}
data_sources: {
    config {
        name: "linux.process_stats"
        process_stats_config {
            scan_all_processes_on_start: true
            proc_stats_poll_ms: 100
        }
    }
}
data_sources: {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "kmem/rss_stat"
            ftrace_events: "filemap/mm_filemap_add_to_page_cache"
            ftrace_events: "filemap/mm_filemap_delete_from_page_cache"
        }
    }
}
duration_ms: 20000
EOF
adb pull /data/misc/perfetto-traces/page_advice.perfetto-trace

Проверьте дорожки в Perfetto UI

Откройте трассировку в Perfetto UI, разверните группу процессов приложения и закрепите следующие счетчики, чтобы они оставались вверху временной шкалы:

  • mem.virt: остается неизменным для MADV_DONTNEED вызовов, подтверждая, что резервирование виртуального адреса (mmap) не изменилось.
  • mem.rss.anon резко снижается в момент, когда приложение вызывает madvise с MADV_DONTNEED в измененных анонимных аренах или областях кучи.
  • mem.rss.file: снижается, когда приложение вызывает madvise с MADV_DONTNEED в области mmap, поддерживаемой файлом, и сопровождается всплеском событий mm_filemap_delete_from_page_cache ftrace, когда posix_fadvise с POSIX_FADV_DONTNEED вытесняет чистые страницы из кеша страниц.
  • mem.swap – снижается, когда MADV_DONTNEED удаляет анонимные страницы, которые ранее были сжаты в ZRAM.
Отслеживание счетчиков Perfetto для одного процесса. Значения mem.rss.anon и mem.rss.file увеличиваются, когда заполняются арена и файл ассета, и уменьшаются после madvise и posix_fadvise, а mem.swap и mem.virt остаются неизменными.
Рисунок 2. RSS-фид падает после вызовов советов по страницам.

На рисунке 2 показаны эти дорожки для примера приложения MemoryLab. Чтобы посмотреть их, откройте пример трассировки в Perfetto UI.

Как узнать пиковый и конечный объем памяти с помощью PerfettoSQL

Выполните следующий запрос PerfettoSQL в интерфейсе Perfetto (вкладка Query (SQL)), чтобы сравнить пиковые и конечные значения mem.virt, mem.rss.anon, mem.rss.file и mem.swap для целевого процесса:

WITH counters AS (
  SELECT t.name AS counter_name, c.ts, c.value
  FROM counter c
  JOIN process_counter_track t ON c.track_id = t.id
  JOIN process p USING (upid)
  WHERE p.name = 'com.example.app'
    AND t.name IN ('mem.virt', 'mem.rss.anon', 'mem.rss.file', 'mem.swap')
)
SELECT
  counter_name,
  CAST(MAX(value) / 1048576 AS INT) AS peak_mb,
  CAST((
    SELECT value FROM counters AS last
    WHERE last.counter_name = counters.counter_name
    ORDER BY ts DESC LIMIT 1
  ) / 1048576 AS INT) AS final_mb
FROM counters
GROUP BY counter_name
ORDER BY counter_name;

Пример выходных данных для трассировки на рисунке 2:

counter_name  peak_mb  final_mb
mem.rss.anon      111        48
mem.rss.file      212        84
mem.swap           36        36
mem.virt        13952     13952

mem.rss.anon снижается на 63 МБ, а mem.rss.file – на 128 МБ от пиковых значений, в то время как mem.virt остается на пиковом значении, поскольку виртуальные резервирования не изменяются.

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

События mm_filemap_add_to_page_cache и mm_filemap_delete_from_page_cache идентифицируют файлы по номеру inode (i_ino), а не по пути. Чтобы убедиться, что вытесненные страницы принадлежат указанному вами файлу, подсчитайте количество вытеснений на inode:

SELECT
  EXTRACT_ARG(arg_set_id, 'i_ino') AS inode,
  COUNT(*) AS evicted_pages
FROM ftrace_event
WHERE name = 'mm_filemap_delete_from_page_cache'
GROUP BY inode
ORDER BY evicted_pages DESC
LIMIT 5;

Пример выходных данных для трассировки на рисунке 2:

inode     evicted_pages
9761              35781
76792192            767
126                 668
0                   612
11587712            514

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

INODE=<inode from the query>
adb shell "find /data -xdev -inum $INODE 2>/dev/null"

Пример выходных данных для inode 9761:

/data/user/0/com.android.memorylab/files/asset_pack.bin
/data/data/com.android.memorylab/files/asset_pack.bin

Оба пути ведут к одному и тому же файлу, поскольку /data/user/0 и /data/data – это два названия одного каталога с данными приложения. Количество вытеснений для файла объекта (35 781 страница) больше,чем размер самого файла (32 768 страниц по 4 КБ), поскольку kswapd уже вытеснил часть файла из-за нехватки памяти до того, как приложение прочитало его и вызвало posix_fadvise.

Скрипт, который устраняет сразу несколько ошибок inode, можно найти в разделе Устранение ошибок inode, а затем ошибок файлов.


← Взаимодействие kswapd и lmkd | ↑ Наверх | Оптимизация фоновых процессов →