Retornar páginas de memória para o SO

Liberar um objeto no código do app ou fechar um stream temporário nem sempre retorna a RAM física ao sistema operacional. Alocadores nativos personalizados, ambientes de execução com coleta de lixo e tabelas de hash de endereçamento aberto frequentemente retêm arenas de memória virtual que permanecem apoiadas por páginas físicas muito depois que os dados lógicos dentro delas desaparecem. Da mesma forma, ler ou mapear na memória grandes arquivos de recursos preenche o cache de páginas do kernel, que permanece carregado no grupo de controle de memória (memcg, na sigla em inglês) do app.

Nesta página, explicamos como usar as chamadas do sistema madvise e posix_fadvise para retornar páginas anônimas e com suporte de arquivos não usadas ao SO, mantendo as reservas de endereços virtuais e os descritores de arquivos intactos, e como verificar o impacto no adb shell e no Perfetto.

Por que a desalocação lógica retém a RAM física

O kernel do Linux rastreia a memória física na granularidade das páginas de hardware, que são de 4 KB ou 16 KB em dispositivos Android. Quando um processo reserva uma região de espaço de endereço virtual com mmap, o kernel cria uma área de memória virtual (VMA, na sigla em inglês) sem alocar RAM física. O kernel aloca e cobra uma página física somente quando o processo toca essa página pela primeira vez. Para memória anônima, uma primeira leitura mapeia uma página zero compartilhada, de modo que apenas uma gravação aloca RAM.

Depois que uma página é carregada na RAM, o kernel não sabe se os bytes dentro dela ainda representam objetos ativos no ambiente de execução do app. Dois padrões comuns mantêm as páginas físicas residentes depois que o app termina de usá-las:

  • Arenas e pools anônimos: alocadores nativos personalizados (como alocadores de slab, pool ou arena de frame), ambientes de execução gerenciados com espaços de heap não móveis e tabelas de hash grandes reservam intervalos de endereços virtuais contíguos com mmap usando MAP_PRIVATE | MAP_ANONYMOUS. Quando o app redefine uma arena, libera um bloco de volta para um pool personalizado ou limpa uma tabela de hash zerando os metadados, as páginas subjacentes permanecem sujas. Sem uma dica explícita para o kernel, essas páginas continuam a contar para o tamanho do conjunto residente (RSS) e o tamanho do conjunto proporcional (PSS) anônimos do processo e para a cobrança do cgroup memory.current. Sob pressão de memória, o kernel as compacta em ZRAM, onde elas ainda ocupam espaço de troca cobrado de memory.swap.current.
  • Cache de página com suporte a arquivos do streaming de recursos: quando o app lê grandes pacotes de recursos, arquivos de nível, contêineres de mídia ou modelos de machine learning usando read, pread ou mmap com suporte a arquivos, o kernel armazena em cache todas as páginas lidas no cache de página e cobra essas páginas do contador file no arquivo memory.stat do cgroup do processo. Se o app descompactar ou copiar esse recurso para um buffer de ambiente de execução anônimo ou fizer upload dele para a GPU, manter as páginas de arquivos brutos no cache de páginas vai duplicar o recurso na RAM.
Páginas de arena sujas liberadas com madvise e páginas de recursos em cache
       removidas com posix_fadvise, enquanto o intervalo virtual e o descritor
       de arquivo permanecem intactos.
Figura 1. Recuperação de páginas físicas de arenas virtuais e do cache de páginas.

Liberar páginas anônimas

A chamada do sistema madvise dá ao kernel conselhos sobre como o processo espera usar um intervalo de endereços virtuais. Para mapeamentos anônimos particulares (MAP_PRIVATE | MAP_ANONYMOUS), transmitir MADV_DONTNEED instrui o kernel a descartar imediatamente as páginas físicas e todas as entradas de ZRAM trocadas que dão suporte ao intervalo especificado alinhado à página.

Depois que madvise com MADV_DONTNEED retornar:

  • O intervalo de endereços virtuais permanece reservado e válido no espaço de endereço do processo (VmSize não muda), então os ponteiros e os limites da arena permanecem intactos sem chamar munmap ou mmap.
  • As páginas físicas são imediatamente desmapeadas e liberadas, reduzindo RssAnon, Pss, o contador anon em memory.stat e memory.swap.current para todas as páginas que estavam na ZRAM.
  • Qualquer leitura ou gravação subsequente em um endereço nesse intervalo falha em uma nova página preenchida com zeros sob demanda.
Sinalização de conselho Mapeamento de destino Comportamento do kernel no Android
MADV_DONTNEED Particular anônimo (MAP_PRIVATE | MAP_ANONYMOUS) Descarta imediatamente páginas físicas e slots de troca de ZRAM. Os acessos subsequentes falham em páginas preenchidas com zeros. Substituição determinística em RssAnon e memory.current.
MADV_DONTNEED Com suporte de arquivo (MAP_PRIVATE ou MAP_SHARED) Desmapeia páginas das tabelas de páginas do processo (RssFile drops). As páginas particulares de cópia em gravação em um mapeamento MAP_PRIVATE são descartadas, de modo que as modificações são perdidas e o próximo acesso lê novamente o arquivo. As páginas MAP_SHARED sujas são gravadas de volta no armazenamento mais tarde. As páginas limpas permanecem no cache até serem removidas.
MADV_FREE Particular anônimo (MAP_PRIVATE | MAP_ANONYMOUS) Marca as páginas como reutilizáveis de forma lenta. O kernel retém as páginas em RssAnon se não houver pressão de memória e as recupera apenas quando a recuperação global ou memcg é executada.
MADV_WILLNEED Anônimo com backup de arquivo ou trocado Inicia a leitura antecipada assíncrona para mapeamentos com backup de arquivos ou lê páginas anônimas trocadas da ZRAM de volta para o cache de troca antes do tempo.

Alinhar intervalos internos ao tamanho da página do ambiente de execução

O madvise opera em páginas inteiras. Ele requer um endereço inicial alinhado à página e arredonda o comprimento até o próximo limite de página. Se você liberar um período dentro de uma arena compartilhada em que os bytes vizinhos ainda contêm objetos ativos, arredondar o intervalo para fora pode zerar as alocações ativas que compartilham as páginas de limite.

Sempre consulte o tamanho da página de execução com sysconf(_SC_PAGESIZE) para oferecer suporte a dispositivos de página de 4 KB e 16 KB e arredonde o endereço de início interno até o próximo limite de página e o endereço de término interno até o limite de página anterior. Para mais orientações sobre alocadores independentes de tamanho de página, consulte Otimizar alocadores de memória personalizados.

#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);
  }
}

Ao criar mapeamentos anônimos personalizados com mmap, rotule a VMA com prctl usando PR_SET_VMA_ANON_NAME para rastrear a residência dela por nome em showmap e /proc/<pid>/smaps. dumpsys meminfo conta as regiões anônimas com nomes personalizados em Unknown. Alguns kernels mais antigos mantêm um ponteiro para o nome em vez de copiá-lo. Portanto, transmita uma string que permaneça válida durante o ciclo de vida do mapeamento, como uma string literal:

#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;
}

Remover páginas de cache de arquivos

Quando o app lê dados de um descritor de arquivo, o kernel armazena em cache as páginas do arquivo no cache de páginas para leituras futuras. Embora isso acelere os acessos repetidos a pequenos arquivos de configuração ou bibliotecas compartilhadas, ele funciona contra você ao transmitir grandes arquivos de recursos, pacotes de textura ou blocos de áudio e vídeo que são analisados uma vez e armazenados em formato decodificado em outro lugar.

A chamada do sistema posix_fadvise permite que o app informe ao kernel sobre o padrão de acesso esperado em um descritor do arquivo aberto:

  • POSIX_FADV_DONTNEED: instrui o kernel de que o intervalo de bytes especificado não será acessado em um futuro próximo. O kernel remove imediatamente as páginas em cache limpas e não mapeadas nesse intervalo alinhado à página do cache de páginas, reduzindo o contador file em memory.stat e a cobrança de memory.current do seu cgroup.
  • POSIX_FADV_SEQUENTIAL: dobra a janela de leitura antecipada do kernel para o descritor do arquivo, melhorando a capacidade de processamento ao transmitir um arquivo grande do início ao fim. Combine isso com POSIX_FADV_DONTNEED em blocos finais à medida que você os consome para que o consumo de cache da página permaneça limitado a uma pequena janela deslizante.
  • POSIX_FADV_RANDOM: desativa a leitura antecipada especulativa do kernel no descritor do arquivo. Use isso ao realizar pesquisas esparsas e não contíguas por deslocamento de bytes em um arquivo grande ou arquivo de banco de dados para que cada leitura pequena não puxe páginas adjacentes não utilizadas para o cache de páginas. Para arquivos mapeados em memória, chame madvise com MADV_RANDOM no mapeamento.

O exemplo a seguir faz streaming de um grande arquivo de recursos em blocos, permitindo a leitura antecipada sequencial e a remoção de cada período alinhado à página processado do cache de página:

#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;
  }
}

Recuperar regiões mmap com suporte de arquivo

Se o app mapear na memória um arquivo de recurso grande com mmap em vez de ler com pread, chamar posix_fadvise apenas com POSIX_FADV_DONTNEED não vai remover as páginas que ainda estão mapeadas nas tabelas de páginas do processo. Para descartar um período consumido de um mapeamento de arquivo somente leitura sem desmapear toda a VMA:

  1. Chame madvise(addr, length, MADV_DONTNEED) no intervalo de endereços virtuais mapeados para desmapear essas páginas das tabelas de páginas do processo (RssFile cai).
  2. Chame posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED) no deslocamento de arquivo correspondente para que o kernel também remova as páginas limpas não mapeadas do cache de páginas (file em memory.stat drops).

Siga as diretrizes de conselhos para páginas

Siga estas práticas ao integrar madvise e posix_fadvise a alocadores, carregadores de recursos e gerenciadores de ciclo de vida:

  • Agrupar chamadas em regiões aproximadas: cada chamada madvise usa o bloqueio do mapa de memória do processo (mmap_lock), percorre as tabelas de páginas e invalida as entradas correspondentes do buffer de consulta de tradução (TLB, na sigla em inglês) em outros núcleos de CPU (com instruções de manutenção de TLB de transmissão no Arm e interrupções entre processadores no x86). Não chame madvise em todas as pequenas desalocações. Agrupe os intervalos livres e informe o kernel em blocos de 64 KB a 1 MB ou mais, ou limpe os intervalos livres durante os limites de frame e as transições em segundo plano (onStop e TRIM_MEMORY_UI_HIDDEN).
  • Recuperar capacidade vazia em tabelas de hash e pools: quando uma tabela de hash, um pool de objetos ou um buffer de preparo grande é limpo após uma transição de cena ou sincronização de dados, reduza a alocação de apoio ou emita MADV_DONTNEED na matriz de apoio limpa. Manter uma tabela de endereçamento aberto de 32 MB alocada com marcadores de exclusão mantém todos os 32 MB de páginas físicas cobrados do seu processo.
  • Limpar dados de arquivos sujos antes de removê-los: o POSIX_FADV_DONTNEED só remove páginas limpas do cache de páginas. Se o app gravar um arquivo temporário ou de cache grande que não será lido imediatamente, chame fdatasync antes de posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) para que as páginas sujas sejam gravadas no armazenamento e, em seguida, descartadas da RAM.
  • Não remova códigos ou recursos compartilhados ativos: evite chamar POSIX_FADV_DONTNEED em DEX, ODEX, VDEX, bibliotecas compartilhadas (.so) ou recursos de interface que são acessados repetidamente. A remoção de páginas de código ou recursos ativos força falhas de armazenamento síncronas que travam as linhas de execução e aumentam a pressão de E/S, conforme relatado pelas informações de travamento por pressão (PSI, na sigla em inglês).

Verificar a recuperação de páginas no shell

Verifique se madvise e posix_fadvise recuperam a RAM física usando showmap e os arquivos de contabilização do cgroup v2 do app.

Inspecionar o tamanho virtual em comparação com o físico com o showmap

Quando você nomeia uma arena anônima com PR_SET_VMA_ANON_NAME, o showmap mostra a reserva virtual (virtual size) e as páginas físicas residentes (RSS e PSS). O exemplo de saída abaixo vem do app de exemplo MemoryLab no AOSP, que nomeia a arena como memorylab-arena. Substitua com.example.app e memorylab-arena pelo nome do pacote e da arena. Antes de chamar madvise com MADV_DONTNEED em uma arena de 64 MB tocada, execute:

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]

Depois de chamar madvise(arena_ptr, 64 * 1024 * 1024, MADV_DONTNEED), execute showmap novamente:

 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]

O virtual size permanece em 65536 KB (64 MB), confirmando que o intervalo de endereços virtuais ainda está reservado, enquanto RSS, PSS e private dirty caem para 0 KB.

Inspecionar memory.current e memory.stat do cgroup

Para verificar a recuperação de páginas anônimas e a remoção do cache de páginas não mapeadas (que não aparecem nas tabelas de páginas do processo se lidas com pread), inspecione o diretório cgroup v2 do processo. No Android, cada processo de app tem o próprio cgroup (como /sys/fs/cgroup/apps/uid_<UID>/pid_<PID>/, dependendo do dispositivo). O comando a seguir encontra o diretório na linha 0:: de /proc/<PID>/cgroup e imprime os contadores:

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
'

Antes de liberar uma arena anônima de 64 MB e remover um arquivo de recurso mapeado em memória de 128 MB do cache de página:

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

Imediatamente após chamar madvise com MADV_DONTNEED na arena e no mapeamento de arquivos de recursos e, em seguida, posix_fadvise com POSIX_FADV_DONTNEED no descritor do arquivo de recursos:

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

anon cai em 64 MB e file cai em 128 MB, reduzindo memory.current em cerca de 192 MB sem fechar o descritor do arquivo ou remover o mapeamento do espaço de endereço virtual da arena. O memory.swap.current quase não muda, porque quase nenhuma das páginas da arena foi trocada para a ZRAM. Os valores dependem do dispositivo e de quanto do arquivo o kernel já removeu por conta própria. Para saber mais sobre como o kernel cobra e recupera memória por cgroup, consulte Recuperação de memória: remoção e troca.

Analisar a recuperação de páginas no Perfetto

O Perfetto permite correlacionar chamadas madvise e posix_fadvise com contadores de tabela de páginas e cache de páginas do kernel em uma linha do tempo unificada.

Gravar um rastro

Capture um rastro com linux.process_stats para contadores de processos periódicos e linux.ftrace para eventos de RSS e cache de página em tempo real e, em seguida, extraia-o para seu computador:

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

Inspecionar faixas na interface do Perfetto

Abra o rastro na interface do Perfetto, expanda o grupo de processos do app e fixe as seguintes faixas de contador para que elas fiquem na parte de cima da linha do tempo:

  • mem.virt: permanece estável em todas as chamadas de MADV_DONTNEED, confirmando que a reserva de endereço virtual (mmap) não mudou.
  • mem.rss.anon: diminui drasticamente no carimbo de data/hora exato em que o app chama madvise com MADV_DONTNEED em arenas anônimas sujas ou regiões de heap.
  • mem.rss.file: diminui quando o app chama madvise com MADV_DONTNEED em uma região mmap com suporte de arquivo, acompanhada por uma explosão de eventos ftrace mm_filemap_delete_from_page_cache quando posix_fadvise com POSIX_FADV_DONTNEED remove as páginas limpas do cache de páginas.
  • mem.swap: cai quando o MADV_DONTNEED descarta páginas anônimas que foram compactadas anteriormente na ZRAM.
Faixas de contador do Perfetto para um processo. mem.rss.anon e
       mem.rss.file aumentam quando uma arena e um arquivo de recurso são preenchidos e
       diminuem após madvise e posix_fadvise, enquanto mem.swap e
       mem.virt permanecem estáveis.
Figura 2. O RSS cai após as chamadas de aviso de página.

A Figura 2 mostra essas faixas para o app de exemplo MemoryLab. Para conferir a mesma visualização, abra o exemplo de rastro na interface do Perfetto.

Consultar o pico e a memória final com o PerfettoSQL

Execute a consulta PerfettoSQL abaixo na interface do Perfetto (guia Query (SQL)) para comparar os valores de pico e finais de mem.virt, mem.rss.anon, mem.rss.file e mem.swap para o processo de destino:

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;

Exemplo de saída para o trace na Figura 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 cai em 63 MB e mem.rss.file cai em 128 MB em relação aos picos, enquanto mem.virt termina no pico porque as reservas virtuais não mudam.

Resolver páginas removidas para arquivos

Os eventos mm_filemap_add_to_page_cache e mm_filemap_delete_from_page_cache identificam arquivos pelo número do inode (i_ino), e não pelo caminho. Para confirmar que as páginas removidas pertencem ao arquivo que você recomendou, conte as remoções por 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;

Exemplo de saída para o trace na Figura 2:

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

Enquanto o dispositivo que gravou o trace ainda estiver conectado, resolva cada inode para um caminho. Os números de inode são exclusivos apenas em um sistema de arquivos. Portanto, limite a pesquisa ao sistema de arquivos que contém os dados do app:

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

Exemplo de saída para o inode 9761:

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

Os dois caminhos se referem ao mesmo arquivo, porque /data/user/0 e /data/data são dois nomes para o mesmo diretório de dados do app. A contagem de remoção do arquivo de recurso (35.781 páginas) é maior que o próprio arquivo (32.768 páginas de 4 KB), porque o kswapd já havia removido parte do arquivo sob pressão de memória antes que o app o lesse novamente e chamasse posix_fadvise.

Para um script que resolve muitos inodes de uma só vez, consulte Como resolver falhas em inodes e, em seguida, em arquivos.


← Interação entre kswapd e lmkd | ↑ Para cima | Processo em segundo plano enxuto →