十分に物理メモリを搭載しているにもかかわらずスワップアウトが発生する理由

多くの人々は,Windows OS でのスワップアウトを「メモリが足りなくなったときの緊急回避」と考えているようです.実際,緊急退避的なスワップアウトも存在しますが,PC の搭載メモリが増えてきた現在では,しかし,もっと別の形でのスワップアウトの方が頻繁に起きるようになってきています.ここでは,より現実的なスワップアウトの姿を見てみることにしましょう.

ある想像上のプログラムを考えます.そのプログラムは,300 MB のヒープを確保し,同時に 300 MB 程度のファイルのあちこちにランダムアクセスを繰り返すとします.
Windows は,このアプリケーションを円滑に動作させるために,どのように物理メモリを提供するのでしょうか?

アプリケーションが,ヒープに対するランダムアクセスのみを行い,ファイルアクセスはほとんど行わない場合

この場合,300 MB の物理メモリをヒープのために維持し続け,アクセスが少ないファイルキャッシュは徐々に縮小されていきます(もちろんメモリが余っていれば使用頻度が低くても生き残る可能性はあります).
このような環境では,そのアプリケーション用として物理メモリに 300 MB の余裕ができるまではメモリ投資効果は高いでしょうが,それ以上に物理メモリに投資してもスループットは改善しないでしょう.

アプリケーションが,ファイルアクセスを頻繁に行い,ヒープに対するアクセスをほとんど行わない場合

この場合,300 MB の物理メモリをディスクキャッシュのために維持し続け,アクセスが少ないヒープは徐々にページファイルに退避されていきます(もちろんメモリが余っていれば使用頻度が低くても生き残る可能性はあります).
この退避作業は,順を追って行われます.
まず,物理メモリ上に存在するヒープの内容をページファイルに書き写します.この作業は,実際にメモリが逼迫する以前に“投機的に”行われることに意味があります.書き写しが完了すると,物理メモリとページファイル両方に,同じヒープ内容が存在することになります.これにより,物理メモリ上に存在するヒープの内容は,任意のタイミングで破棄できるようになります.この段階で,物理メモリ上のヒープは,ページファイル内容の“ファイルキャッシュ”と等価です.
後は他のファイルキャッシュと同じです.もっと別の内容を物理メモリ上にキャッシュした方が良いと OS が判断するタイミングで,ヒープを“キャッシュ”していた物理メモリは,別の用途に転用されます.
この時点で,いわゆる“スワップアウト”が完了します.
また,このような環境では,そのアプリケーション用として物理メモリに 300 MB の余裕ができるまではメモリ投資効果は高いでしょうが,それ以上に物理メモリに投資してもスループットは改善しないでしょう.
十分なサイズのディスクキャッシュが確保できる環境では,定常状態において,HDD のアクセスの影響を取り除くことができます.

アプリケーションが,ヒープに対するランダムアクセスも,ファイルアクセスも,どちらも頻繁に行う場合

この場合,300 MB の物理メモリをヒープのために維持し続け,同時に 300 MB の物理メモリをディスクキャッシュのために維持し続けるのがベストです(もちろんメモリが足りなければ,この限りではありません).
このような環境では,そのアプリケーション用として物理メモリに 600 MB の余裕ができるまではメモリ投資効果は高いでしょうが,それ以上に物理メモリに投資してもスループットは改善しないでしょう.

まとめ 1 : 十分に物理メモリを搭載しているにもかかわらずスワップアウトが発生する理由

Windows OS では,「ファイルキャッシュから使用頻度の低いキャッシュ内容を削除」するのと同じ感覚で,「プログラムが確保したメモリの内容をスワップアウト」しています.より正確に言えば,両者を共通のアルゴリズムで扱えるよう,意図的にメモリマネージャが実装されています.
別の言い方をしてみましょう.
我々が C/C++/C# などの言語からアクセスしていると思っているところの物理メモリとは,実は,仮想的なメモリ空間の一部が物理メモリにキャッシュされたもの,と考えることもできます.つまり,最近アクセスしたアドレス領域は物理メモリにキャッシュされていますし,しばらくアクセスしないアドレス領域は OS が不要と判断してキャッシュから削除して (ページファイルに書き戻して) しまうわけです.
そしてこれが,Windows で,十分に物理メモリを搭載しているにもかかわらず,なぜかヒープのスワップアウトが発生する,という理由でもあります.
端的に言えば,malloc で確保したメモリは,物理メモリ上の存在意義をかけ,ディスクキャッシュと常に戦っています.そして,その戦いに敗れれば,ページファイルに送られてしまうというわけです.十分に物理メモリを搭載したはずなのに,プログラムがスワップアウトされていたときには,ヒープの方が「ヒット率」が低いというのが OS 判断だったとお考えください.

まとめ 2 : 搭載メモリ量を超えるようなファイルマッピングが邪悪な理由 (あるいは 64 bit OS に対する一部の幻想に対する回答)

物理メモリにキャッシュ可能なサイズは,当たり前ですが,PC に搭載された物理メモリのサイズを超えることができません.
確かに 64 bit OS では,巨大なファイルをまるごと仮想アドレス空間にマップすることは可能です.しかし,数十 GB のファイルをメモリマップしても,同時にキャッシュ可能なサイズは搭載メモリ量を超えられないことに違いはありません.搭載メモリ量を超えるような巨大なファイルをメモリマップして,そのあちこちにランダムアクセスするようなプログラムは,非常にパフォーマンスの悪いものになるでしょう.
64 bit OS の導入は,目前に迫ったアドレス空間の上限と,搭載可能メモリ量の上限の問題に対する解決策にはなりますが,搭載メモリ量を超えるランダムアクセスを実用的にするような魔法の薬ではないのです.

アプリケーション終了時の大量のページイン

Adobe ReaderVisual Studio 2005 などでよく感じるのですが,アプリケーション終了時に無駄にスワップインが発生しているようなのはなんとかならないものかと.
よくあるパターンはこんな感じ.

  1. Adobe ReaderVisual Studio 2005 でそれなりに大きな作業をする (数百 MB 単位のメモリがコミットされる)
  2. 最小化してしばらく忘れる
  3. タスクバーが手狭になってきたので,タスクバーから右クリック→閉じるを選ぶ
  4. 終了処理が数十秒続く

この手の大型アプリケーションの開発元には,終了時のメモリアクセスを極力避けるよう努力していただけるとありがたいところ.まあ,後からそういう修正を入れるのが難しいというのもよく分かるんですけどね.

FILE_FLAG_SEQUENTIAL_SCAN フラグを使用しなかったため,意図せず「メモリの掃除」をしてしまった事例集

シーケンシャルファイルアクセスで,一度しかファイルの内容を使用しないにもかかわらず,FILE_FLAG_SEQUENTIAL_SCAN フラグを使用しなかったため,プログラム本体やヒープがスワップアウトされてしまった事例集.

プロセス終了時のページイン: 実験

アプリケーション終了時の大量のページインをちょっと実験してみました.

std::vector<T> の場合

#define ZEROCLEAR_IN_DESTRUCTOR
struct DummyData
{
public:
    int m_data;
    DummyData() : m_data(rand())
    {

    }
#ifdef ZEROCLEAR_IN_DESTRUCTOR
    ~DummyData()
    {
        m_data = 0;
    }
#endif
};

class CMyWindow : public CWindowImpl<CMyWindow>
{
public:
    std::vector<DummyData> m_data;
    CMyWindow()
    {
        // 64 MB ほど確保
        m_data.resize( 1024 * 1024 * 16 );
    }
    ......

こんな感じで 64 MB 程度メモリを消費するプログラムを用意して,外部から SetProcessWorkingsetSize + 「メモリの掃除屋さん」で無理矢理ページアウトさせ,その後プロセスを終了させた時にどういう挙動を示すか調べてみました.コンパイラは Visual C++ 2005 のデフォルト設定,実行環境は Windows Vista 32 bit 版,搭載メモリは 2GB です.また,実験用に SuperFetch と ReadyBoost サービスはオフにしてあります.
十分ページアウトさせてからアプリケーションを終了させると

デストラクタを定義しない場合
ページインは無視できる範囲内.
デストラクタを定義してメモリアクセスを行った場合
64 MB 程度のページインが発生.

というわけで,vector の場合,デストラクタで何か作業をしなければ,中身がページアウトしていても問題なさそうでした.

std::list<T> の場合

struct DummyData
{
public:
    int m_data;
    DummyData() : m_data(rand())
    {

    }
};

class CMyWindow : public CWindowImpl<CMyWindow>
{
public:
    std::list<DummyData> m_data;
    CMyWindow()
    {
        m_data.resize( 1024 * 1024 * 16 );
    }
    ......

リンクリストなので vector に比べて要素ごとに余分なメモリオーバーヘッドがあるのと,一要素ごとにメモリを確保するのとで,1600 万要素でもコミットサイズは 400 MB 程度になります.

デストラクタを定義しない場合
プロセス終了時に 20 秒程度のディスクアクセス.

このように,list の場合は,たとえデストラクタが存在しなくてもページインが発生しています.懐かしの「malloc and free」論争ではありませんが,この場合はまさに,メモリを解放するためわざわざページインを起こしているわけで,やはり少し気になる動作と言えます.
ちなみに,無理矢理ページアウトさせているときのパフォーマンスカウンタの変化の様子も記録してみました.

外部からこのプロセスのワーキングセットの縮小を図ったタイミングで,PC 全体の Total Working Set が 400 MB 程度縮小し,代わりに Modified Page List が増加しています.その後「メモリの掃除屋さん」を実行すると,一時的に大量のワーキングセットが消費され,この後 Modified Page List の減少と Standby Cache Normal Priority Byte の増加が続いています.
ここで,各データの意味を簡単に説明.

Total Working Set
PC 全体で物理メモリに「キャッシュ」されているデータのうち,最近アクセスされたとみなされる部分のデータ量
Modified Page List
PC 全体で物理メモリに「キャッシュ」されているデータから Total Working Set に含まれる部分をのぞいた上で,「キャッシュ」の内容の方が新しく,ファイル (またはページファイル) に書き戻す必要があるデータ量
Standby Cache
PC 全体で物理メモリに「キャッシュ」されているデータから Total Working Set に含まれる部分をのぞいた上で,「キャッシュ」の内容と,ファイル (またはページファイル) の内容が同一なデータ量

Modified Page List の減少と,対応する Standby Cache Normal Priority Byte の増加こそが,先ほどの「メモリ内容の書き出し」に相当しています.「メモリの掃除屋さん」実行中の書き出しは急激に行われていますが,「メモリの掃除屋さん」の実行終了後の HDD のアクセスの仕方は,全力というほどではなく,1 MB/sec 程度におちつきます.メモリ圧力が消えたことで,書き出しもゆっくりとしたモードに変化したようです.
さて,この時点ではタスクマネージャからは十分にメモリが空いているように見えます.こうやってパフォーマンスカウンタを見ていないと,定期的に点滅するアクセスランプを見て,「また Vista のバックグラウンドサービスが何か余計な動作をしているのかな?」と勘違いしてしまうかもしれませんね.
なお,Standby Cache に変化しても,ページファイルの「キャッシュ」として機能しているので,他に大きなメモリを確保したり,スタンバイモードに変更したりしながら,がんばって「キャッシュ」を追い出して今回の実験を行っています.

.NET の LinkedList<MyData>

public partial class Form1 : Form
{
    public struct MyData
    {
        public int Data;
    }
    public readonly LinkedList<MyData> dataList;
    public Form1()
    {
        Random rand = new Random ();
        dataList = new LinkedList<MyData>();
        for (int i = 0; i < 1024 * 1024 * 16; ++i)
        {
            MyData data = new MyData();
            data.Data = rand.Next();
            dataList.AddLast(data);
        }

最後に .NET の LinkedList<MyData> の場合です.コミットサイズはほぼ同じ 400 MB になりました..NET の場合,Gen 2 GC が発生すると GC ヒープ中のランダムアクセスが多発し,せっかくワーキングセットから追い出したメモリがワーキングセットに戻ってきやすいので注意が必要です.色々苦労しながら実験してみましたが,結果.

データの大半がページファイルにあると思われる状況でのプロセス終了
ほぼノーコスト

というわけで,CLR の場合,プロセス終了時にも特にページインは発生しなさそうという感じでした.ただ,実験状況がネイティブアプリケーションほど安定しないので,この結果はもうちょっと検証した方が良さそうです.これが真実なら,CLR はアプリケーション終了時と通常の GC 発生時で動作を変えていることになり,調べてみると中々おもしろい部分ではないでしょうか?

Windows VistaWindows XP の違い

さて,先ほどの説明で,「ワーキングセットの縮小を行う」という部分がありましたが,ページアウトの発端はこのワーキングセットの縮小にあるとも言えます.実は,Windows XP では,アプリケーションのウィンドウを最小化したときに OS が「ワーキングセットの縮小を行う」という仕様がありました.Windows XP でウィンドウを頻繁に最小化する使い方をしていると,ページアウトが発生しやすくなりるのはこのためです.
この動作は,Windows Vista で変更され,ウィンドウ最小化によるワーキングセットの縮小は発生しなくなっています.Windows XP 時代に,大量のメモリを使用するアプリケーションが頻繁にページアウトしすぎてあちこちで問題になったため,Vista で動作が変更されたものと思われます.この辺の歴史的経緯については,以下をどうぞ.