Освобождение объекта в коде приложения или закрытие временного потока не всегда возвращает физическую ОЗУ операционной системе. Специальные нативные распределители, среды выполнения со сборкой мусора и хеш-таблицы с открытой адресацией часто сохраняют области виртуальной памяти, которые остаются подкрепленными физическими страницами ещё долго после того, как логические данные внутри них исчезнут. Аналогично, чтение или сопоставление памяти больших архивов объектов заполняет кеш страниц ядра, который остается в группе управления памятью (memcg) вашего приложения.
В этой статье рассказывается, как с помощью системных вызовов madvise и posix_fadvise возвращать неиспользуемые анонимные и связанные с файлами страницы в ОС, не затрагивая резервирование виртуальных адресов и дескрипторы файлов, а также как проверять результат в adb shell и Perfetto.
Почему при логическом освобождении физическая оперативная память сохраняется
Ядро Linux отслеживает физическую память на уровне аппаратных страниц, размер которых на устройствах Android составляет 4 КБ или 16 КБ. Когда процесс резервирует область виртуального адресного пространства с помощью mmap, ядро создает область виртуальной памяти (VMA), не выделяя физическую оперативную память. Ядро выделяет и учитывает физическую страницу, только когда процесс впервые обращается к ней. Для анонимной памяти первое чтение сопоставляет общую нулевую страницу, поэтому ОЗУ выделяется только при записи.
После того как страница загружена в ОЗУ, ядро не знает, представляют ли байты внутри этой страницы живые объекты в среде выполнения вашего приложения. Есть два распространенных сценария, когда физические страницы остаются резидентными после того, как приложение перестает их использовать:
- Анонимные арены и пулы. Пользовательские нативные распределители (например, распределители slab, пула или арены фреймов), управляемые среды выполнения с неподвижными пространствами кучи и большие хеш-таблицы резервируют непрерывные диапазоны виртуальных адресов с помощью
mmap, используяMAP_PRIVATE | MAP_ANONYMOUS. Когда приложение сбрасывает арену, освобождает блок обратно в пользовательский пул или очищает хеш-таблицу, обнуляя ее метаданные, базовые страницы остаются измененными. Без явного указания ядру эти страницы продолжают учитываться в анонимном резидентном наборе (RSS) и пропорциональном наборе (PSS) вашего процесса и его нагрузке на cgroupmemory.current. При нехватке памяти ядро сжимает их в ZRAM, где они по-прежнему занимают swap-пространство, относящееся кmemory.swap.current. - Кэш страниц, связанных с файлами, из потоковой передачи объектов. Когда приложение считывает большие наборы объектов, архивы уровней, медиаконтейнеры или модели машинного обучения с помощью
read,preadили связанных с файламиmmap, ядро кэширует каждую прочитанную страницу в кэше страниц и добавляет эти страницы в счетчикfileв файлеmemory.statгруппы cgroup вашего процесса. Если приложение распаковывает или копирует этот объект в анонимный буфер среды выполнения или загружает его в графический процессор, то сохранение страниц необработанного файла в кеше страниц дублирует объект в ОЗУ.
Опубликовать анонимные страницы
Системный вызов 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:
- Вызовите
madvise(addr, length, MADV_DONTNEED)для сопоставленного диапазона виртуальных адресов, чтобы отменить сопоставление этих страниц с таблицами страниц вашего процесса (RssFiledrops). - Вызовите
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_cacheftrace, когдаposix_fadviseсPOSIX_FADV_DONTNEEDвытесняет чистые страницы из кеша страниц.mem.swap– снижается, когдаMADV_DONTNEEDудаляет анонимные страницы, которые ранее были сжаты в ZRAM.
На рисунке 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 | ↑ Наверх | Оптимизация фоновых процессов →