MEM_DECOMMIT, MADV_FREE, mono_mprotect

さて,だいぶ地ならしができたので,いよいよ本題でも.
アプリケーションサイドでヒープマネジメントを行う場合,回収された使用済み (アクセス済み) メモリ領域がページアウトされるのを防ぎたい,という話があります.

たとえば、一時的に、300MBぐらいメモリを使って、そのあとGCが動いて、使ってるメモリが15MBぐらいになったとする。

このとき、使わない285MBは、必要の無い領域になる、が、しかし、この領域には一旦書き込んでしまってるので、ページが割り当てられているわけだ。そうなると、他のプロセスがメモリを必要としたときに、この領域がスワップアウトしてしまう。

つまり、無駄なスワップが発生してしまうわけだ。PC使ってる時のストレスの80%ぐらいはスワップが原因(多分)なので、無駄なスワップが発生するのは心苦しい。

これは CLR の GC ヒープに限らず,Visual C++ に付属する std::vector<T> のように内部でバッファ管理を行うコンテナクラスについても当てはまります.

std::vector<int> data;
data.resize( 1024 * 1024 * 64 ); // 256 MB のゼロクリア
data.resize( 1024 * 1024 * 1 );  

例えばこんなコードを書くと,252 MB のゼロ領域が無駄にページアウトされることになります.このような未使用領域を VirtualAlloc 直後の状態に "リセット" することができれば,252 MB 分の 0 を追跡するための物理メモリやページアウト/インのコストを削減できるはずです.

VirtualAlloc のおさらい

ここで VirtualAlloc についておさらいしておきましょう.
VirtualAlloc によるメモリアクセス権の確保は,予約→コミットの順に行われます.

VOID* ptr = VirtualAlloc(desired_addr, size, MEM_RESERVE, PAGE_READWRITE);
if( ptr ) ptr = VirtualAlloc(ptr, size, MEM_COMMIT, PAGE_READWRITE);

ここで開始アドレスは 64 kbyte 境界に丸められ,サイズはページサイズの倍数に切り上げられます.上のコードでは読み書きアクセス権を要求しているので,コミットに成功すれば,指定したアドレス領域に自由に読み書きできるようになります.
コミットされた直後のメモリは 0 クリアされていることになっています.実際にはコミット直後には物理メモリは割り当てられておらず,初回アクセス時に zero page (ゼロクリア済みメモリページ) から物理メモリが割り当てられます (demand zero fault; soft page fault の一種) *1
予約とコミットを同時に行うこともできます.

VOID* ptr = VirtualAlloc(desired_addr, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

希望する開始アドレスがないときは,NULL を指定することでシステムが適切なアドレスを決めてくれます.

VOID* ptr = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

なお,試してみるとこのパターンでは MEM_RESERVE フラグを省略することもできるようですが,実際には MEM_RESERVE を指定したものと等価になります.

VOID* ptr = VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_READWRITE);

//実験によれば,上のコードは次のコードと等価に振る舞う模様
VOID* ptr = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

このように VirtualAlloc の第一引数は,MEM_COMMIT と MEM_RESERVE の時で意味が変わります.

MEM_RESERVE 使用時の第一引数の意味
希望する開始アドレス
MEM_COMMIT 使用時の第一引数の意味
予約済み領域の,コミットを開始するアドレス

ただし,第一引数が NULL のときは MEM_COMMIT を単独で使用しても害がないためか,見かけ上予約を省略したかのようなコードを書くことができるようです.この場合にも,実際に予約とコミットが行われているようです.

VirtualFree による partial decommit

MEM_RESERVE によって予約された領域は,VirtualFree によって部分的にデコミットすることができます.

VirtualFree(ptr + offset, decommit_size, MEM_DECOMMIT);

指定される区間には,既にデコミットされている領域 (つまり,予約直後の領域) を含んでいても問題ありません.
デコミット実行後に,その領域にアクセスすると,例外が発生します *2

partial decommit と partial commit による dirty page の破棄

ここまでの話から,あるコミット済みページの内容が必要なくなったときには,そのページをデコミットし,コミットし直せばよいことが分かります.

// 指定した区間の内容追跡を "リセット" する
VirtualFree(ptr + offset, region_size, MEM_DECOMMIT);
VirtualAlloc(ptr + offset, region_size, MEM_COMMIT, PAGE_READWRITE);


mono_mprotect

参考として,CLI 実装の一種である mono が使用している,メモリ管理ユーティリティー関数 mono_mprotect を見てみましょう.(mono-1.2.5.1.tar.bz2)
まずは Windows 環境で用いられる mono_mprotect の実装.

int
mono_mprotect (void *addr, size_t length, int flags)
{
	DWORD oldprot;
	int prot = prot_from_flags (flags);

	if (flags & MONO_MMAP_DISCARD) {
		VirtualFree (addr, length, MEM_DECOMMIT);
		VirtualAlloc (addr, length, MEM_COMMIT, prot);
		return 0;
	}
	return VirtualProtect (addr, length, prot, &oldprot) == 0;
}

MONO_MMAP_DISCARD フラグが指定されているときに,指定された区間を "リセット" できるようになっていることが分かります.
次にその他の環境で使用される mono_mprotect.

/**
 * mono_mprotect:
 * @addr: memory address
 * @length: size of memory area
 * @flags: new protection flags
 *
 * Change the protection for the memory area at @addr for @length bytes
 * to matche the supplied @flags.
 * If @flags includes MON_MMAP_DISCARD the pages are discarded from memory
 * and the area is cleared to zero.
 * @addr must be aligned to the page size.
 * @length must be a multiple of the page size.
 *
 * Returns: 0 on success.
 */
int
mono_mprotect (void *addr, size_t length, int flags)
{
	int prot = prot_from_flags (flags);

	if (flags & MONO_MMAP_DISCARD) {
		/* on non-linux the pages are not guaranteed to be zeroed (*bsd, osx at least) */
#ifdef __linux__
		if (madvise (addr, length, MADV_DONTNEED))
			memset (addr, 0, length);
#else
		memset (addr, 0, length);
		madvise (addr, length, MADV_DONTNEED);
		madvise (addr, length, MADV_FREE);
#endif
	}
	return mprotect (addr, length, prot);
}

「ゼロクリアする」という Windows のセマンティクスと合わせるために,memset が使用されています.
Unix 環境では MADV_DONTNEED と MADV_FREE がこの処理の鍵であるようです.

まとめ

  • アプリケーションサイドで巨大なヒープ管理を行う場合,内容に関心が無い領域をいったんデコミットし,コミットし直すことで,ゴミデータがページアウトされるのを防ぐことができる
  • Windows 以外の環境で同様のことを行う場合,madvise と MADV_DONTNEED, MADV_FREE といったフラグを活用する.ただし zero page が割り当てられない OS には注意する.

*1:.NET は,多くの場面で,初期値 0 を使用しますが,これは特に初回アクセスで zero page が割り当てられる Windows で有利に働くと考えられます

*2:この例外をトラップして,オンデマンドにコミットを行うというテクニックもあります.ただし,事前にコミットを行う方がスループット上は有利でしょう