メモリページを OS に返す

アプリのコードでオブジェクトを解放したり、一時ストリームを閉じたりしても、物理 RAM がオペレーティング システムに返されるとは限りません。カスタム ネイティブ アロケータ、ガベージ コレクション ランタイム、オープン アドレス ハッシュ テーブルは、内部の論理データが失われた後も、物理ページにバックアップされた仮想メモリアリーナを保持することがよくあります。同様に、大きなアセット アーカイブを読み取るかメモリマッピングすると、カーネル ページ キャッシュが入力され、アプリのメモリ コントロール グループ(memcg)に課金されます。

このページでは、madvise システムコールと posix_fadvise システムコールを使用して、仮想アドレスの予約とファイル記述子をそのまま維持しながら、未使用の匿名ページとファイルバック ページを OS に返す方法と、adb shell と Perfetto で影響を確認する方法について説明します。

論理的な割り当て解除で物理 RAM が保持される理由

Linux カーネルは、ハードウェア ページの粒度で物理メモリを追跡します。Android デバイスでは、4 KB または 16 KB です。プロセスが mmap を使用して仮想アドレス空間の領域を予約すると、カーネルは物理 RAM を割り当てずに仮想メモリ領域(VMA)を作成します。カーネルは、プロセスが最初にそのページにアクセスしたときにのみ、物理ページを割り当てて課金します。匿名メモリの場合、最初の読み取りで共有ゼロページがマッピングされるため、書き込みのみで RAM が割り当てられます。

ページが RAM にフォールトされると、カーネルは、そのページ内のバイトがアプリのランタイムでライブ オブジェクトを表しているかどうかを認識できません。アプリが物理ページの使用を終了した後も物理ページが常駐する一般的なパターンは 2 つあります。

  • 匿名のアリーナとプール: カスタム ネイティブ アロケータ(スラブ、プール、フレームアリーナ アロケータなど)、移動しないヒープスペースを持つマネージド ランタイム、大規模なハッシュ テーブルは、MAP_PRIVATE | MAP_ANONYMOUS を使用して mmap で連続した仮想アドレス範囲を予約します。アプリがアリーナをリセットしたり、ブロックをカスタムプールに解放したり、メタデータをゼロにしてハッシュ テーブルをクリアしたりしても、基盤となるページはダーティのままです。カーネルに明示的なヒントがない場合、これらのページはプロセスの匿名 Resident Set Size(RSS)と Proportional Set Size(PSS)、および memory.current cgroup チャージに引き続きカウントされます。メモリ不足の状況では、カーネルはそれらを ZRAM に圧縮します。ZRAM では、それらは memory.swap.current に課金されるスワップ領域を占有します。
  • アセット ストリーミングのファイル格納型ページ キャッシュ: アプリが read、pread、またはファイル格納型 mmap を使用して、大規模なアセット バンドル、レベル アーカイブ、メディア コンテナ、機械学習モデルを読み取ると、カーネルは読み取ったすべてのページをページ キャッシュにキャッシュし、それらのページをプロセスの cgroup の memory.stat ファイル内の file カウンタに課金します。アプリがそのアセットを解凍またはコピーして匿名のランタイム バッファに格納するか、GPU にアップロードする場合、未加工のファイルページをページ キャッシュに保持すると、RAM 内でアセットが重複します。
ダーティ アリーナ ページは madvise で解放され、キャッシュに保存されたアセットページは posix_fadvise で強制排除されます。仮想範囲とファイル記述子はそのまま残ります。
図 1. 仮想アリーナとページ キャッシュから物理ページを再利用する。

匿名ページを解放する

madvise システムコールは、プロセスが仮想アドレス範囲をどのように使用するかについて、カーネルにアドバイスを提供します。プライベート匿名マッピング(MAP_PRIVATE | MAP_ANONYMOUS)の場合、MADV_DONTNEED を渡すと、指定されたページアライメント範囲をバッキングする物理ページとスワップされた ZRAM エントリを直ちに破棄するようカーネルに指示します。

madvise と MADV_DONTNEED の戻り値:

  • 仮想アドレス範囲は、プロセスの仮想アドレス空間で予約されたまま有効です(VmSize は変更されません)。そのため、munmap や mmap を呼び出さなくても、ポインタとアリーナの境界はそのまま維持されます。
  • 物理ページは直ちにマッピング解除されて解放され、RssAnon、Pss、memory.stat の anon カウンタ、ZRAM にあったページの memory.swap.current が減少します。
  • その範囲内のアドレスに対する後続の読み取りまたは書き込みは、オンデマンドで新しいゼロ埋めページにフォールトします。
アドバイス フラグ ターゲット マッピング 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 KB と 16 KB の両方のページデバイスをサポートするには、常に 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 を使用してカスタムの匿名マッピングを作成する場合は、PR_SET_VMA_ANON_NAME を使用して VMA に prctl というラベルを付けます。これにより、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: 指定されたバイト範囲に、近い将来アクセスされないことをカーネルに指示します。カーネルは、そのページアライメントされた範囲内のクリーンでマッピングされていないキャッシュ ページをページ キャッシュから直ちに強制排除し、memory.stat の file カウンタと cgroup の memory.current チャージを減らします。
  • POSIX_FADV_SEQUENTIAL: ファイル記述子のカーネルの先読みウィンドウを 2 倍にし、大きなアーカイブを最初から最後までストリーミングする際のスループットを向上させます。これと POSIX_FADV_DONTNEED を組み合わせて、後続のチャンクを消費する際に使用することで、ページ キャッシュのフットプリントを小さなスライディング ウィンドウに制限できます。
  • POSIX_FADV_RANDOM: ファイル記述子で投機的なカーネルの先読みを無効にします。これは、大きなアーカイブ ファイルまたはデータベース ファイルへのバイト オフセットによって、スパースで連続していないルックアップを実行する場合に使用します。これにより、小さな読み取りごとに、隣接する未使用のページがページ キャッシュにプルされることがなくなります。メモリマップド ファイルの場合は、代わりにマッピングで MADV_RANDOM を指定して madvise を呼び出します。

次の例では、大きなアセット アーカイブをチャンク単位でストリーミングし、事前に順次先読みを有効にして、処理されたページアライン スパンをページ キャッシュから削除します。

#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 領域を再利用する

アプリが pread で読み取る代わりに mmap で大きなアセット ファイルをメモリマッピングする場合、POSIX_FADV_DONTNEED のみで posix_fadvise を呼び出しても、プロセスのページテーブルにマッピングされたままのページは削除されません。VMA 全体のマッピングを解除せずに、読み取り専用ファイル マッピングの使用済みスパンをドロップするには:

  1. マッピングされた仮想アドレス範囲で madvise(addr, length, MADV_DONTNEED) を呼び出して、プロセスのページテーブルからそれらのページのマッピングを解除します(RssFile がドロップされます)。
  2. 対応するファイル オフセットで posix_fadvise(fd, offset, length, POSIX_FADV_DONTNEED) を呼び出し、カーネルがマッピングされていないクリーンなページをページ キャッシュから強制排除するようにします(memory.stat の file がドロップされます)。

ページのアドバイスのガイドラインに準拠する

madvise と posix_fadvise をアロケータ、アセット ローダ、ライフサイクル ハンドラに統合する場合は、以下の方法を実践してください。

  • 粗いリージョンへのバッチ呼び出し: 各 madvise 呼び出しは、プロセスのメモリマップ ロック(mmap_lock)を取得し、ページテーブルを走査して、他の CPU コアの一致する変換ルックアサイド バッファ(TLB)エントリを無効にします(Arm ではブロードキャスト TLB メンテナンス命令、x86 ではプロセッサ間割り込みを使用)。小さな割り当て解除ごとに madvise を呼び出さないでください。空きスパンを結合し、64 KB ~ 1 MB 以上のチャンクでカーネルに通知するか、フレーム境界とバックグラウンド遷移(onStop と TRIM_MEMORY_UI_HIDDEN)中に空きスパンをスイープします。
  • ハッシュ テーブルとプールの空き容量を再利用する: シーンの遷移やデータの同期後に、大きなハッシュ テーブル、オブジェクト プール、ステージング バッファをクリアする場合は、バッキング割り当てを縮小するか、クリアされたバッキング配列全体に MADV_DONTNEED を発行します。tombstone が割り当てられた 32 MB のオープン アドレッシング テーブルを保持すると、32 MB の物理ページすべてがプロセスに課金されます。
  • ダーティ ファイル データを強制排除する前にフラッシュする: POSIX_FADV_DONTNEED は、クリーンなページ キャッシュ ページのみを強制排除します。アプリが、すぐに読み戻さない大きな一時ファイルまたはキャッシュ ファイルを書き込む場合は、posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) の前に fdatasync を呼び出して、ダーティページがストレージに書き込まれ、RAM からドロップされるようにします。
  • ホットな共有コードやアセットを強制排除しない: 繰り返しアクセスされる DEX、ODEX、VDEX、共有ライブラリ(.so)、UI アセットに対して POSIX_FADV_DONTNEED を呼び出さないでください。アクティブなコードページやアセットページを強制排除すると、同期ストレージの再フォールトが強制的に発生し、スレッドが停止して I/O 負荷が高まります。これは、圧力停止情報(PSI)によって報告されます。

シェルでページ再利用を確認する

madvise と posix_fadvise が、showmap とアプリの cgroup v2 アカウンティング ファイルを使用して物理 RAM を再利用していることを確認します。

showmap を使用して仮想サイズと物理サイズを調べる

PR_SET_VMA_ANON_NAME を使用して匿名アリーナに名前を付けると、showmap は仮想予約(virtual size)と常駐物理ページ(RSS と PSS)の両方を表示します。次のサンプル出力は、AOSP の MemoryLab サンプルアプリからのもので、アリーナに memorylab-arena という名前を付けています。com.example.app と memorylab-arena は、パッケージ名とアリーナ名に置き換えます。タッチされた 64 MB アリーナで MADV_DONTNEED を指定して madvise を呼び出す前に、次のコマンドを実行します。

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 KB(64 MB)のままで、仮想アドレス範囲がまだ予約されていることを確認できます。一方、RSS、PSS、private dirty は 0 KB に減少しています。

cgroup の memory.current と memory.stat を調べる

匿名ページのリクラメーションと、マッピングされていないページ キャッシュの強制排除(pread で読み取った場合にプロセス ページ テーブルに表示されない)の両方を確認するには、プロセスの cgroup v2 ディレクトリを調べます。Android では、各アプリプロセスに独自の cgroup があります(デバイスに応じて /sys/fs/cgroup/apps/uid_<UID>/pid_<PID>/ など)。次のコマンドは、/proc/<PID>/cgroup の 0:: 行からディレクトリを検索し、カウンタを出力します。

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 MB の匿名アリーナを解放し、128 MB のメモリマップド アセット ファイルをページ キャッシュから強制排除する前に、

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

アリーナとアセット ファイル マッピングで MADV_DONTNEED を指定して madvise を呼び出し、アセット ファイル記述子で POSIX_FADV_DONTNEED を指定して posix_fadvise を呼び出した直後:

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

anon が 64 MB 減少し、file が 128 MB 減少します。これにより、ファイル記述子を閉じたり、アリーナの仮想アドレス空間のマッピングを解除したりすることなく、memory.current が約 192 MB 減少します。memory.swap.current はほとんど変化しません。これは、アリーナページが ZRAM にほとんどスワップされていないためです。値は、デバイスと、カーネルがすでに独自にエビクションしたファイルの量によって異なります。カーネルが cgroup ごとにメモリを課金して再利用する方法については、メモリの再利用: エビクションとスワップをご覧ください。

Perfetto でページ再利用を分析する

Perfetto を使用すると、madvise 呼び出しと posix_fadvise 呼び出しを、カーネルのページテーブル カウンタとページキャッシュ カウンタに、統合されたタイムライン上で関連付けることができます。

トレースを記録する

定期的なプロセス カウンタには linux.process_stats、リアルタイムの RSS イベントとページキャッシュ イベントには linux.ftrace を指定してトレースをキャプチャし、パソコンに pull します。

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)が変更されていないことを確認します。
  • ダーティな匿名アリーナまたはヒープ領域で、アプリが MADV_DONTNEED を指定して madvise を呼び出す正確なタイムスタンプで急激に低下します。mem.rss.anon
  • mem.rss.file: アプリがファイルベースの mmap 領域で MADV_DONTNEED を指定して madvise を呼び出すと、ステップダウンします。POSIX_FADV_DONTNEED を指定した posix_fadvise がページ キャッシュからクリーンページを削除すると、mm_filemap_delete_from_page_cache ftrace イベントがバーストします。
  • mem.swap: MADV_DONTNEED が、以前に ZRAM に圧縮された匿名ページを破棄すると減少します。
1 つのプロセスの Perfetto カウンタ トラック。アリーナとアセット ファイルが入力されると mem.rss.anon と mem.rss.file が増加し、madvise と posix_fadvise の後に減少します。一方、mem.swap と mem.virt は変化しません。
図 2. ページ アドバイス コールの後に RSS が低下する。

図 2 は、MemoryLab サンプルアプリのこれらのトラックを示しています。同じビューを調べるには、Perfetto UI でサンプル トレースを開きます。

PerfettoSQL でピークメモリと最終メモリをクエリする

Perfetto UI([Query (SQL)] タブ)で次の PerfettoSQL クエリを実行して、ターゲット プロセスの 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 MB 減少し、mem.rss.file はピークから 128 MB 減少します。一方、mem.virt は仮想予約が変更されていないため、ピークで終了します。

強制排除されたページをファイルに解決する

mm_filemap_add_to_page_cache イベントと mm_filemap_delete_from_page_cache イベントは、パスではなく inode 番号(i_ino)でファイルを識別します。強制排除されたページが、アドバイスしたファイルに属していることを確認するには、i ノードあたりの強制排除数をカウントします。

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 をパスに解決します。i ノード番号は 1 つのファイル システム内でのみ一意であるため、アプリのデータを保持するファイル システムに検索を制限します。

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 は同じアプリデータ ディレクトリの 2 つの名前であるため、どちらのパスも同じファイルを参照します。アセット ファイルの強制排除数(35,781 ページ)は、ファイル自体(4 KB で 32,768 ページ)よりも大きくなっています。これは、アプリがファイルを読み戻して posix_fadvise を呼び出す前に、kswapd がメモリ不足によりファイルの一部をすでに強制排除していたためです。

複数の inode を一度に解決するスクリプトについては、inode の障害を解決してからファイルの障害を解決するをご覧ください。


← kswapd と lmkd の相互作用 | ↑ 上へ