00:00:00 ディスクへのスピルについてのトークの紹介
00:00:34 小売業者のデータ処理とメモリの制限
00:02:13 永続的なストレージソリューションとコスト比較
00:04:07 ディスクとメモリの速度比較
00:05:10 パーティショニングとストリーミング技術の制限
00:06:16 順序付けられたデータの重要性と最適な読み取りサイズ
00:07:40 データ読み取りの最悪のケースシナリオ
00:08:57 マシンのメモリがプログラムの実行に与える影響
00:10:49 ディスクへのスピル技術とメモリ使用量
00:12:59 コードセクションの説明と.NETの実装
00:15:06 メモリ割り当ての制御とその結果
00:16:18 メモリマップページとメモリマッピングファイル
00:18:24 読み書き可能なメモリマップとシステムパフォーマンスツール
00:20:04 仮想メモリとメモリマップページの使用
00:22:08 大きなファイルと64ビットポインタの取り扱い
00:24:00 メモリマップメモリからロードするためのスパンの使用
00:26:03 データのコピーと整数の読み取りに構造体の使用
00:28:06 ポインタからスパンを作成し、メモリマネージャを使用する
00:30:27 メモリマネージャのインスタンスを作成する
00:31:05 ディスクへのスピルプログラムとメモリマッピングの実装
00:33:34 パフォーマンスのためにメモリマップバージョンが好ましい
00:35:22 ファイルストリームバッファリング戦略と制限
00:37:03 一つの大きなファイルをマッピングする戦略
00:39:30 複数の大きなファイルにメモリを分割する
00:40:21 結論と質問への招待

要約

メモリに収まらないほどのデータを処理するために、プログラムはそのデータの一部を、NVMeドライブのような遅いが大容量のストレージにスピルすることができます。これは、あまり知られていない.NETの2つの機能(メモリマップファイルとメモリマネージャ)の組み合わせにより、C#からほとんどパフォーマンスのオーバーヘッドなしに行うことができます。このトークは、Warsaw IT Days 2023で行われ、この仕組みがどのように動作するかの詳細に深く入り込み、オープンソースのNuGetパッケージLokad.ScratchSpaceが開発者からその詳細のほとんどを隠している方法について議論します。

拡張要約

包括的な講義で、LokadのCTOであるVictor Nicoletは、.NETでのディスクへのスピルの複雑さについて詳しく説明します。これは、一般的なコンピュータのメモリ容量を超える大量のデータセットを処理するための技術です。Nicoletは、Lokadでの量的供給チェーン最適化の分野での複雑なデータセットの取り扱いに関する豊富な経験を活用し、100箇所にわたる10万の製品を持つ小売業者の実例を提供します。これにより、3年間の日次データポイントを考慮すると、10億のエントリのデータセットが生じ、各エントリに対して1つの浮動小数点値を格納するためには37ギガバイトのメモリが必要となり、これは一般的なデスクトップコンピュータの容量をはるかに超えています。

Nicoletは、NVMe SSDストレージなどの永続的なストレージを、メモリのコスト効果的な代替手段として使用することを提案します。彼はメモリとSSDストレージのコストを比較し、18ギガバイトのメモリのコストで、1テラバイトのSSDストレージを購入できることを指摘します。また、ディスクからの読み取りがメモリからの読み取りよりも6倍遅いことを指摘し、パフォーマンスのトレードオフについても議論します。

彼は、メモリの代わりにディスクスペースを使用する技術として、パーティショニングとストリーミングを紹介します。パーティショニングは、メモリに収まるようにデータセットを小さなピースに処理することを可能にしますが、パーティション間での通信は許されません。一方、ストリーミングは、異なる部分の処理の間にある程度の状態を維持することを可能にしますが、最適なパフォーマンスを得るためには、ディスク上のデータが適切に順序付けられているか、または整列している必要があります。

Nicoletは次に、メモリに収まるアプローチの制限を解決するためのディスクへのスピル技術を紹介します。これらの技術は、データをメモリと永続的なストレージの間で動的に分散し、利用可能なときにはより多くのメモリを使用して高速に実行し、利用可能なメモリが少ないときには遅くなってメモリを少なく使用します。彼は、ディスクへのスピル技術は可能な限り多くのメモリを使用し、メモリが不足するとディスクにデータをダンプし始めると説明します。これにより、最初に予想したよりも多くまたは少ないメモリを持つことに対して、より良く対応することができます。

彼はさらに、ディスクへのスピル技術がデータセットを2つのセクションに分けることを説明します:常にメモリ内にあるホットセクションと、その内容の一部をいつでも永続的なストレージにスピルできるコールドセクションです。プログラムはホット-コールド転送を使用し、通常はNVMe帯域幅の使用を最大化するために大量のバッチを含みます。コールドセクションはこれらのアルゴリズムが可能な限り多くのメモリを使用することを可能にします。

Nicoletは次に、これを.NETでどのように実装するかについて議論します。ホットセクションには、通常の.NETオブジェクトが使用され、コールドセクションには、参照クラスが使用されます。このクラスは、コールドストレージに入れられる値への参照を保持し、この値はメモリにないときにnullに設定することができます。プログラムの中央システムはすべてのコールド参照を追跡し、新しいコールド参照が作成されるたびに、それがメモリのオーバーフローを引き起こすかどうかを判断し、コールドストレージの利用可能なメモリ予算内に収まるように、システム内の既存の1つ以上のコールド参照のスピル関数を呼び出します。

彼は次に、仮想メモリの概念を紹介します。ここでは、プログラムは物理メモリページに直接アクセスすることはできず、代わりに仮想メモリページにアクセスします。メモリマップページを作成することが可能で、これはプログラムとメモリマッピングファイルの間の通信を実装する一般的な方法です。メモリマッピングの主な目的は、各プログラムがメモリ内にDLLの自分のコピーを持つのを防ぐことです。なぜなら、それらのコピーはすべて同一だからです。

Nicoletは次に、システムパフォーマンスツールについて議論します。これは物理メモリの現在の使用状況を示します。緑色はプロセスに直接割り当てられたメモリを示し、青色はページキャッシュを示し、中央の変更されたページはディスクの正確なコピーであるべきですが、メモリ内に変更が含まれています。

彼は次に、仮想メモリを使用した2回目の試みについて議論します。ここでは、コールドセクションは完全にメモリマップページで構成されます。もしオペレーティングシステムが突然メモリを必要とした場合、どのページがメモリマップされていて安全に破棄できるかを知っています。

Nicoletは次に、.NETでメモリマップファイルを作成する基本的な手順を説明します。これらは、まずディスク上のファイルからメモリマップファイルを作成し、次にビューアクセッサを作成することです。これら二つは別々に保持されます。なぜなら、.NETは32ビットプロセスのケースを処理する必要があるからです。64ビットプロセスの場合、ファイル全体をロードするビューアクセッサを1つ作成することができます。

Nicoletは次に、5年前に導入されたメモリとスパンについて議論します。これらは、ポインタよりも安全な方法でメモリの範囲を表すために使用される型です。スパンとメモリの一般的な考え方は、ポインタとバイト数が与えられた場合、そのメモリの範囲を表す新しいスパンを作成できるということです。スパンが作成されると、スパン内のどこでも安全に読み取ることができ、境界を越えて読み取ろうとするとランタイムがそれをキャッチし、プロセスが終了するのではなく例外がスローされます。

Nicoletは次に、スパンを使用してメモリマップメモリから.NET管理メモリにロードする方法について議論します。例えば、読み取る必要がある文字列がある場合、スパンを中心にした多くのAPIを使用できます。Nicoletは、MemoryMarshal.Readなどのスパンを中心にしたAPIの使用について説明します。これはスパンの先頭から整数を読み取ることができます。また、Encoding.GetString関数についても触れています。これは、バイトのスパンから文字列にロードすることができます。

彼はさらに、これらの操作はスパン上で行われ、これはデータのセクションを表し、それがメモリ内ではなくディスク上にある可能性があると説明します。オペレーティングシステムは、初めてアクセスされたときにデータをメモリにロードする処理を行います。Nicoletは、浮動小数点値のシーケンスを浮動小数点値の配列にロードする必要がある例を提供します。彼は、サイズを読み取るためのMemoryMarshal.Readの使用、そのサイズの浮動小数点値の配列の割り当て、バイトのスパンを浮動小数点値のスパンに変換するためのMemoryMarshal.Castの使用について説明します。

彼はまた、スパンのCopyTo関数の使用について議論します。これは、メモリマップファイルから配列にデータを高性能でコピーする操作を行います。彼は、このプロセスが完全に新しいコピーを作成することで少し無駄になる可能性があると指摘します。Nicoletは、2つの整数値を内部に持つヘッダを表す構造体を作成することを提案します。これはMemoryMarshalによって読み取ることができます。彼はまた、データを解凍するための圧縮ライブラリの使用についても議論します。

Nicoletは、より長期間のデータ範囲を表すために異なるタイプ、Memoryの使用について議論します。彼は、ポインタからメモリを作成する方法についての文書化の欠如に言及し、GitHubのgistを最良の利用可能なリソースとして推奨します。彼は、MemoryManagerを作成する必要性を説明します。これは、Memoryが配列のセクションを指すだけよりも複雑なことをする必要があるときに、内部的に使用されます。

Nicoletは、メモリマッピングとFileStreamの使用について議論します。彼は、ディスク上のデータにアクセスするときにFileStreamが明らかな選択肢であり、その使用はよく文書化されていると指摘します。彼は、FileStreamのアプローチがスレッドセーフでなく、操作の周りにロックを必要とし、複数の場所から並行して読み取ることを防ぐと指摘します。Nicoletはまた、FileStreamのアプローチがメモリマップバージョンには存在しないいくつかのオーバーヘッドを導入するとも言及します。

彼は、代わりにメモリマップバージョンを使用するべきであると説明します。なぜなら、それは可能な限り多くのメモリを使用し、メモリが不足したときにはデータセットの一部をディスクに戻すことができるからです。Nicoletは、どのくらいのファイルを割り当てるべきか、それらはどのくらい大きくすべきか、メモリが割り当てられて解放されるときにそれらのファイルをどのように循環させるべきかという問題を提起します。

彼は、メモリをいくつかの大きなファイルに分割し、同じメモリに二度と書き込まない、そして可能な限り早くファイルを削除することを提案します。Nicoletは、Lokadのプロダクションでは、特定の設定でLokadスクラッチスペースを使用していることを共有します:ファイルはそれぞれ16ギガバイト、各ディスクには100のファイルがあり、各L32VMには4つのディスクがあり、これは各VMのスピルスペースとして6テラバイト以上を表しています。

全文書き起こし

スライド1

Victor Nicolet: こんにちは、.NETでのディスクへのスピルについてのこのトークへようこそ。

ディスクへのスピルは、メモリに収まらないデータセットを処理するための技術で、使用中でないデータセットの一部を代わりに永続的なストレージに保持します。

このトークは、私がLokadで働いている経験に基づいています。私たちは定量的な供給チェーン最適化を行っています。

定量的な部分とは、私たちは大規模なデータセットで作業を行い、供給チェーンの部分とは、それは現実世界の一部なので、それらは混乱しており、驚くべきことで、エッジケースの中にエッジケースが満載です。

したがって、私たちはかなり複雑な処理をたくさん行っています。

スライド4

典型的な例を見てみましょう。小売業者は、おおよそ十万の製品を持っているでしょう。

これらの製品は最大100の場所に存在します。これらは店舗であったり、倉庫であったり、eコマースに専用の倉庫のセクションでさえあります。

そして、これについて何か本当の分析を行いたいと思うなら、私たちは過去の行動、それらの製品と場所に何が起こったかを見る必要があります。

毎日1つのデータポイントだけを保持し、過去3年間だけを見ると仮定すると、これは約1000日を意味します。これらすべてを掛け合わせると、私たちのデータセットは100億のエントリーを持つことになります。

各エントリーについて浮動小数点値を1つだけ保持すると、データセットはすでに37ギガバイトのメモリフットプリントを取ります。これは、典型的なデスクトップコンピュータが持つものを超えています。

スライド10

そして、1つの浮動小数点値は、どんな種類の分析を行うにもほとんど十分ではありません。

より良い数値は20で、それでも私たちはフットプリントを小さく保つために非常に良い努力をしています。それでも、私たちは約745ギガバイトのメモリ使用量を見ています。

これは、それらが十分に大きければクラウドマシンに収まりますが、月額約7千ドルです。だから、それは手頃な価格ですが、それはまた無駄なものでもあります。

スライド11

このトークのタイトルから推測できるように、解決策は代わりに永続的なストレージを使用することです。これはメモリよりも遅いですが、安価です。

これらの日々、あなたは約5セント/ギガバイトでNVMe SSDストレージを購入することができます。NVMe SSDは、現在簡単に手に入る最速の永続的なストレージの一つです。

比較すると、1ギガバイトのRAMは275ドルです。これは約55倍の差です。

スライド14

これを別の視点から見ると、18ギガバイトのメモリを購入するための予算で、1テラバイトのSSDストレージの料金を支払うのに十分な予算があります。

スライド15

クラウドの提供はどうでしょうか? 例としてMicrosoftクラウドを取ると、左側はL32sで、ストレージに最適化された仮想マシンの一部です。

月額約2千ドルで、ほぼ8テラバイトの永続的なストレージを得ることができます。

右側はM32msで、メモリに最適化され、コストは2倍以上ですが、RAMは875ギガバイトしか得られません。

私のプログラムが左側のマシンで動作し、完了するのに2倍の時間がかかったとしても、コスト面ではまだ勝っています。

スライド16

パフォーマンスはどうでしょうか? メモリからの読み取りは約21ギガバイト/秒です。NVMe SSDからの読み取りは約3.5ギガバイト/秒です。

これは実際のベンチマークではありません。私はただ仮想マシンを作成し、これらの2つのコマンドを実行しました。これらの数字を増減させる方法はたくさんあります。

ここで重要なのは、2つの間の差の大きさの順序だけです。ディスクからの読み取りは、メモリからの読み取りよりも6倍遅いです。

したがって、ディスクはとても遅く、常にランダムなアクセスパターンでディスクから読み取りたいとは思わないでしょう。しかし、一方で、それは驚くほど速いです。あなたの処理が主にCPUに依存しているなら、あなたはディスクから読み取っていることに気づかないかもしれません。

スライド19

メモリの代わりにディスクスペースを使用するというかなりよく知られた技術はパーティショニングです。

パーティショニングの背後にある考え方は、データセットの次元の1つを選択し、データセットをより小さなピースに切り分けることです。各ピースはメモリに収まるほど小さくなければなりません。

処理はそれぞれのピースを個別にロードし、処理を行い、そのピースをディスクに保存し、次のピースをロードします。

私たちの例では、データセットを場所ごとに切り分けて、一度に一つの場所を処理すると、各場所はメモリを7.5ギガバイトしか使用しません。これはデスクトップコンピュータが処理できる範囲内です。

スライド21

しかし、パーティショニングでは、パーティション間での通信はありません。したがって、場所をまたいでデータを処理する必要がある場合、この技術は使用できません。

別の技術はストリーミングです。ストリーミングは、任意の時点でメモリに小さなデータのピースだけをロードするという点で、パーティショニングにかなり似ています。

パーティショニングとは異なり、異なる部分の処理の間に一部の状態を保持することが許されています。したがって、最初の場所を処理する間、私たちは初期状態を設定し、次に2つ目の場所を処理するとき、その時点で状態に存在したものを使用して、2つ目の場所の処理の終わりに新しい状態を作成することが許されます。

パーティショニングとは異なり、ストリーミングは並列実行には適していません。しかし、それはデータセット内のすべてのデータに対して何かを計算する問題を解決します。

しかし、ストリーミングには自身の制限があります。それがパフォーマンスを発揮するためには、ディスク上のデータは適切に順序付けられているか、または整列しているべきです。

スライド26

これらの要件を理解するためには、NVMeが半キロバイトのセクタでデータを読み書きし、以前のパフォーマンス値(例えば、1秒あたり3.5ギガバイト)は、セクタが全体として読み取られ、使用されることを前提としていることを知る必要があります。

セクタの一部だけを使用して全体のセクタを読み取る必要がある場合、帯域幅を無駄にしてパフォーマンスを大きく分割してしまいます。

スライド28

したがって、読み取るデータが半キロバイトの倍数であり、セクタの境界に整列している場合が最適です。

今では、回転ディスクを使用していないので、先に進んでセクタを読み取らないことは無駄がありません。

スライド30

セクタの境界にデータを整列することができない場合、別の方法は、それを順序通りにロードすることです。

これは、セクタが一度メモリにロードされると、セクタの2番目の部分を読み取るためにディスクから別のロードを必要としないからです。代わりに、オペレーティングシステムはまだ使用されていない残りのバイトを提供することができます。

したがって、データが連続してロードされると、帯域幅は無駄にならず、フルパフォーマンスを得ることができます。

スライド31

最悪の場合は、各セクタから1バイトまたは数バイトだけを読み取る場合です。例えば、各セクタから浮動小数点値を読み取ると、パフォーマンスを128で割ることになります。

スライド32

さらに悪いことに、セクタより上のデータグループ化の単位があり、それがオペレーティングシステムのページで、オペレーティングシステムは通常、約4キロバイトのページ全体をそのままロードします。

したがって、各ページから1つの浮動小数点値を読み取ると、パフォーマンスを1024で割ることになります。

このため、永続ストレージからのデータの読み取りは、大きな連続したバッチで行われることを確認することが非常に重要です。

スライド33

これらの技術を使用すると、プログラムをより少ないメモリに収めることが可能になります。これらの技術は、メモリとディスクを互いに独立した2つの別々のストレージスペースとして扱います。

したがって、データセットのメモリとディスク間の分布は、アルゴリズムとデータセットの構造に完全によって決定されます。

したがって、正確な量のメモリを持つマシンでプログラムを実行すると、プログラムはきちんと収まり、実行することができます。

必要なメモリ量よりも少ないマシンを提供すると、プログラムはメモリに収まらず、実行することができません。

最後に、必要なメモリ量以上のマシンを提供すると、プログラムは通常のプログラムが行うように、追加のメモリを使用せず、同じ速度で実行し続けます。

スライド38

利用可能なメモリに基づいて実行時間をグラフにプロットすると、このようになります。メモリフットプリント以下では実行がないため、処理時間はありません。フットプリント以上では、プログラムが追加のメモリを使用して速く実行することができないため、処理時間は一定です。

スライド39

また、データセットが大きくなるとどうなるでしょうか? どの次元によるかによりますが、データセットがパーティションの数を増やすように成長すると、メモリフットプリントは同じままで、パーティションが増えるだけです。

スライド41

一方、個々のパーティションが大きくなると、メモリフットプリントも大きくなり、プログラムが実行するために必要な最小限のメモリ量が増えます。

スライド42

言い換えれば、処理する必要がある大きなデータセットがある場合、それだけでなく、フットプリントも大きくなります。

これは、大きなデータセットを収めるためにはより多くのメモリを追加する必要があるが、より小さなデータセットについては何も改善しないという厄介な状況を生み出します。

スライド43

これは、データセットのメモリと永続ストレージ間の分布がデータセットの構造とアルゴリズム自体によって完全に決定される、メモリに収まるアプローチの制限です。

実際に利用可能なメモリの量を考慮に入れていません。ディスクへのスピル技術が行うことは、この分布を動的に行います。したがって、より多くのメモリが利用可能な場合、より多くのメモリを使用して速く実行します。

スライド46

逆に、利用可能なメモリが少ない場合、ある程度までは、メモリを少なく使用するために速度を落とすことができます。その場合、カーブははるかに良く見えます。最小のフットプリントは小さく、両方のデータセットに対して同じです。

スライド47

メモリが追加されると、すべてのケースでパフォーマンスが向上します。メモリにフィットする技術は、メモリフットプリントを減らすために予め一部のデータをディスクにダンプします。対照的に、ディスクへのスピル技術は可能な限り多くのメモリを使用し、メモリが不足したときに初めて一部のデータをディスクにダンプしてスペースを作ります。

これにより、最初に予想したよりも多かれ少なかれメモリを持つことに対して反応する能力が大幅に向上します。ディスクへのスピル技術は、データセットを2つのセクションに分割します。ホットセクションは常にメモリ内にあると仮定され、したがってランダムアクセスパターンでアクセスするのはパフォーマンス上常に安全です。もちろん、最大の予算があり、典型的なクラウドマシンではCPUあたり8ギガバイト程度かもしれません。

一方、コールドセクションは、その内容の一部を永続ストレージにスピルすることがいつでも許されています。最大の予算は利用可能な位置にしかありません。そしてもちろん、パフォーマンス上、コールドセクションから読み取ることは安全に可能ではありません。

したがって、プログラムはホット-コールド転送を使用します。これらは通常、NVMeの帯域幅の使用を最大化するために大量のバッチを含むことになります。そして、バッチがかなり大きいので、それらはかなり低い頻度で行われます。そして、それはコールドセクションがこれらのアルゴリズムが可能な限り多くのメモリを使用することを可能にします。

Slide 50

コールドセクションは利用可能なRAMを可能な限り多く埋め、残りを永続ストレージにスピルします。では、これを.NETでどのように実現することができるでしょうか?私がこれを最初の試みと呼んでいるので、それがうまくいかないことは予想できます。だから、問題が何になるかを事前に見つけてみてください。

slide 51

ホットセクションについては、通常の.NETオブジェクトを使用し、問題は通常の.NETプログラムを見てみます。コールドセクションについては、これを参照クラスと呼びます。このクラスは、コールドストレージに入れられる値への参照を保持し、この値はメモリ内になくなったときにnullに設定することができます。メモリから値を取り出してストレージに書き込み、参照をnullにするスピル関数があります。これにより、.NETのガベージコレクタが圧力を感じたときにそのメモリを回収できます。

最後に、値プロパティがあります。このプロパティにアクセスすると、メモリ内に存在する場合はその値を返し、存在しない場合はディスクからメモリに戻してからそれを返します。さて、私がプログラム内にすべてのコールド参照を追跡する中央システムを設定すると、新しいコールド参照が作成されるたびに、それがメモリをオーバーフローさせるかどうかを判断し、コールドストレージの利用可能なメモリ予算内に収まるように、システム内の既存のコールド参照の一つ以上のスピル関数を呼び出すことができます。

Slide 53

では、問題は何でしょうか?さて、私が私たちのプログラムを実行するマシンのメモリの内容を見ると、理想的な場合はこのようになります。左側に最初にあるのは、自身の目的のために使用するオペレーティングシステムのメモリです。次に、.NETがロードしたアセンブリやガベージコレクタのオーバーヘッドなどのために使用する内部メモリがあります。次にホットセクションからのメモリがあり、最後にすべてを占めるのがコールドセクションに割り当てられたメモリです。

ある程度の努力をすれば、右側のすべてを制御することができます。なぜなら、それは私たちが割り当て、ガベージコレクタが収集するために解放を選択するものだからです。しかし、左側にあるものは私たちの制御外です。そして、突然オペレーティングシステムが追加のメモリを必要とし、すべてが.NETプロセスが作成したものに占められていることを発見したらどうなるでしょうか?

Slide 56

さて、その場合のLinuxカーネルの典型的な反応は、最もメモリを使用しているプログラムを終了させることであり、カーネルにメモリを返すために十分に早く反応する方法はありません。では、解決策は何でしょうか?

Slide 57

現代のオペレーティングシステムには仮想メモリという概念があります。プログラムは物理メモリページに直接アクセスすることはありません。代わりに、仮想メモリページにアクセスし、それらのページと物理メモリ内の実際のページとの間にマッピングがあります。もし別のプログラムが同じコンピュータ上で実行されている場合、それは自身で最初のプログラムのページにアクセスすることはできません。ただし、共有する方法はあります。

メモリマップページを作成することが可能です。その場合、最初のプログラムが共有ページに書き込むものは、他の部分によってすぐに見えるようになります。これはプログラム間の通信を実装する一般的な方法ですが、その主な目的はファイルのメモリマッピングです。ここでは、オペレーティングシステムはこのページが永続ストレージ上のページの正確なコピーであることを知っています、通常は共有ライブラリファイルの一部です。

ここでの主な目的は、各プログラムがメモリ内にDLLの自身のコピーを持つのを防ぐことです。なぜなら、それらのコピーはすべて同一であるため、それらのコピーを保存するためにメモリを無駄にする理由はありません。ここでは例えば、物理メモリが3ページしかないのに対して、2つのプログラムが合計4ページのメモリを占めています。さて、最初のプログラムで1ページをさらに割り当てたいとしたらどうなるでしょうか?利用可能なスペースはありませんが、カーネルオペレーティングシステムは、メモリマップページを一時的に削除でき、必要に応じて永続ストレージから同一のものを再ロードできることを知っています。

Slide 63

そこで、それを行います。2つの共有ページは、メモリではなくディスクを指すようになります。メモリはオペレーティングシステムによってクリアされ、ゼロに設定され、その後、最初のプログラムがその3つ目の論理ページに使用するために与えられます。今、メモリは完全にいっぱいで、どちらのプログラムが共有ページにアクセスしようとも、それをメモリに再ロードするスペースはありません。なぜなら、プログラムに与えられたページはオペレーティングシステムによって再取得することはできないからです。

Slide 66

そこで、ここで起こることはメモリ不足のエラーです。プログラムの一つが終了し、メモリが解放され、それがメモリマップファイルを再度メモリにロードするために再利用されます。また、ほとんどのメモリマップは読み取り専用ですが、読み書き可能なものを作成することも可能です。

Slide 70

プログラムがマップされたページのメモリに変更を加えると、オペレーティングシステムは将来のある時点でそのページの内容をディスクに保存します。もちろん、Windowsのflushのような関数を使用して、これが特定の瞬間に行われるように要求することも可能です。システムパフォーマンスツールには、物理メモリの現在の使用状況を示す素晴らしいウィンドウがあります。

緑色はプロセスに直接割り当てられたメモリを示しています。これはプロセスを終了させない限り回収することはできません。青色はページキャッシュです。これらはディスク上のページの同一のコピーであることが分かっているページで、プロセスがディスクから既にキャッシュにあるページを読み取る必要がある場合、ディスクからの読み取りは行われず、値は直接メモリから返されます。

Slide 71

最後に、中央の変更されたページは、ディスクの正確なコピーであるべきですが、メモリに変更が含まれています。これらの変更はまだディスクに適用されていませんが、かなり短い時間で適用されるでしょう。Linuxでは、h-stopツールが同様のグラフを表示します。左側はプロセスに直接割り当てられ、それらを終了させない限り回収することができないページで、右側の黄色はページキャッシュです。

Slide 73

興味がある方は、Vyacheslav BiryukovによるLinuxページキャッシュの中で何が起こっているかについての優れたリソースがあります。仮想メモリを使用して、2回目の試みをしましょう。今回はうまくいくでしょうか?今、私たちは冷たいセクションを完全にメモリマップページで構成することに決めました。したがって、それらすべてが最初にディスク上に存在することが期待されます。

プログラムはもはやどのページがメモリに存在し、どのページがディスク上に存在するだけかを制御することはありません。オペレーティングシステムがそれを透明に行います。したがって、プログラムが、例えば、冷たいセクションの3ページ目にアクセスしようとすると、オペレーティングシステムはそれがメモリに存在しないことを検出し、既存のページのうちの1つ、例えば2ページ目をアンロードし、その後3ページ目をメモリにロードします。

Slide 76

プロセス自体の視点からすると、それは完全に透明でした。メモリからの読み取り待ち時間はいつもより少し長かっただけです。そして、オペレーティングシステムが突然自分自身のことをするためにメモリを必要としたらどうなるでしょうか?まあ、どのページがメモリマップされ、安全に破棄できるかを知っています。したがって、それはただ1つのページをドロップし、それを自分自身の目的のために使用し、それが終わったときにそれを戻します。

Slide 77

これらのすべての技術は.NETに適用され、Lokad Scratch Spaceオープンソースプロジェクトに存在します。そして、以下のほとんどのコードは、このNuGetパッケージがどのように動作するかに基づいています。

Slide 78

最初に、.NETでメモリマップファイルをどのように作成するかを考えてみましょうか?メモリマッピングは.NET Framework 4から存在しており、約13年前です。インターネット上でかなりよく文書化されており、ソースコードはGitHub上で完全に利用可能です。

Slide 80

基本的なステップは、まずディスク上のファイルからメモリマップファイルを作成し、次にビューアクセッサを作成することです。これら2つのタイプは、それぞれ別の意味を持つため、別々に保持されます。メモリマップファイルは、このファイルから、プロセスのメモリにマップされるセクションがいくつかあることをオペレーティングシステムに伝えるだけです。ビューアクセッサ自体は、それらのマッピングを表します。

これら2つは別々に保持されます。なぜなら、.NETは32ビットプロセスのケースを処理する必要があるからです。非常に大きなファイル、つまり4ギガバイトより大きなファイルは、32ビットプロセスのメモリ空間にマップすることはできません。それは大きすぎます。今、ポイントはそれを表現するのに十分大きくありません。したがって、代わりに、ファイルの小さなセクションだけを一度にマップし、それらを適合させる方法が可能です。

私たちの場合、64ビットポインターを使用して作業します。したがって、私たちはただ全体のファイルをロードするビューアクセッサを作成することができます。そして今、私はこのメモリマップ範囲のメモリの最初のバイトへのポインタを取得するためにAcquirePointerを使用します。ポインタを使い終わったら、それを解放するだけです。.NETでポインタを使うことは安全ではありません。それはあらゆる場所にunsafeキーワードを追加することを必要とし、許可された範囲の終わりを超えてメモリにアクセスしようとすると爆発する可能性があります。

Slide 81

幸いなことに、それを回避する方法があります。5年前、.NETはメモリとスパンを導入しました。これらは、ポインタよりも安全な方法でメモリの範囲を表すために使用されるタイプです。それはかなりよく文書化されており、コードのほとんどはGitHubのこの場所で見つけることができます。

Slide 83

スパンとメモリの背後にある一般的な考え方は、ポインタとバイト数が与えられると、そのメモリの範囲を表す新しいスパンを作成できるということです。

このスパンを持っていれば、スパン内のどこでも安全に読み取ることができ、境界を越えて読み取ろうとするとランタイムがそれをキャッチし、プロセスが終了する代わりに例外が発生します。

Slide 84

メモリマップされたメモリから.NET管理メモリにロードするために、どのようにスパンを使用できるかを見てみましょう。覚えておいてください、パフォーマンスの理由から、直接コールドセクションにアクセスしたくありません。代わりに、一度に大量のデータをロードするコールドからホットへの転送を行いたいのです。

例えば、読みたい文字列があるとしましょう。それはメモリマップされたファイルにサイズと続いてUTF-8でエンコードされたペイロードのバイトとして配置され、それから.NETの文字列をロードしたいと思います。

Slide 86

まあ、私たちが使えるスパンを中心にした多くのAPIがあります。例えば、MemoryMarshal.Readはスパンの先頭から整数を読み取ることができます。そして、このサイズを使って、Encoding.GetString関数にスパンのバイトから文字列にロードするように依頼することができます。

これらすべてがスパンで動作し、スパンがメモリ内ではなくディスク上に存在する可能性のあるデータのセクションを表していても、オペレーティングシステムは初めてアクセスされたときにデータを透明にメモリにロードします。

Slide 87

別の例として、浮動小数点の値のシーケンスを浮動小数点の配列にロードしたいと思います。

Slide 88

再び、MemoryMarshal.Readを使ってサイズを読み取ります。そのサイズの浮動小数点値の配列を割り当て、次にMemoryMarshal.Castを使ってバイトのスパンを浮動小数点値のスパンに変換します。これは本当に、スパンに存在するデータをバイトではなく浮動小数点値として再解釈するだけです。

最後に、スパンのCopyTo関数を使って、メモリマップされたファイルから配列自体にデータを高性能でコピーします。これはある意味で少し無駄です、全く新しいコピーを行っています。

Slide 89

それを避けることができるかもしれません。通常、ディスクに保存するのは生の浮動小数点値ではなく、それらの圧縮バージョンになります。ここでは、圧縮サイズを保存します。これは、読み取る必要があるバイト数を示します。私たちは目的地のサイズ、または解凍されたサイズを保存します。これは、管理メモリに割り当てる必要がある浮動小数点値の数を教えてくれます。最後に、圧縮されたペイロード自体を保存します。

Slide 90

これをロードするためには、2つの整数を読み取る代わりに、そのヘッダを表す構造を作成し、その中に2つの整数値を持つ方が良いでしょう。

Slide 91

MemoryMarshalはその構造のインスタンスを読み取ることができ、同時に2つのフィールドをロードします。浮動小数点値の配列を割り当て、次に圧縮ライブラリにはほぼ確実に、入力としてバイトの読み取り専用スパンを取り、出力としてバイトのスパンを取る解凍関数の一種があります。再びMemoryMarshal.Castを使用することができます。今回は、浮動小数点値の配列をバイトのスパンに変換して、目的地として使用します。

今、コピーは関与していません。代わりに、圧縮アルゴリズムは直接ディスクから、通常はページキャッシュを通じて、浮動小数点値の配列に読み込みます。

Slide 92

Spanには一つの大きな制限があり、それはクラスメンバーとして使用できないこと、そしてそれに伴い、非同期メソッドのローカル変数としても使用できないことです。

幸いにも、より長期間のデータ範囲を表すために使用すべき別の型、Memoryがあります。

Slide 94

残念ながら、これをどのように行うかについての文書化はほとんどありません。ポインタからスパンを作成するのは簡単ですが、ポインタからメモリを作成する方法は、最良の利用可能な文書化がGitHubのgistであるほど文書化されていません。私は本当にあなたがそれを読むことをお勧めします。

Slide 95

簡単に言えば、私たちがやるべきことはMemoryManagerを作成することです。MemoryManagerは、Memoryが配列のセクションを指すよりも複雑なことをする必要があるときに、内部的に使用されます。

私たちの場合、私たちは参照しているメモリマップビューアクセッサを参照する必要があります。私たちが見ることが許されている長さを知る必要があります。最後に、オフセットが必要です。これは、バイトのMemoryが設計上最大2ギガバイトしか表現できず、ファイル自体が2ギガバイト以上の長さになる可能性があるためです。したがって、オフセットはメモリが広範なビューアクセッサ内で開始する位置を私たちに示します。

Slide 97

クラスのコンストラクタはかなり直感的です。

Slide 98

私たちはただ、メモリ領域を表すセーフハンドルへの参照を追加する必要があります。この参照は、dispose関数で解放されます。

Slide 99

次に、別の乗り物ではなく、私たちが持っているのに便利なアドレスプロパティがあります。私たちはDangerousGetHandleを使用してポインタを取得し、アドレスが私たちがメモリで表現したい領域の最初のバイトを指すようにオフセットを追加します。

Slide 100

私たちはGetSpan関数をオーバーライドします。これはすべての魔法を行います。それはアドレスと長さを使用してスパンを作成します。

Slide 101

MemoryManagerには実装する必要がある他の2つのメソッドがあります。そのうちの一つはPinです。これは、メモリが短期間同じ場所に保持される必要がある場合に、ランタイムによって使用されます。私たちは参照を追加し、正しい場所を指し、現在のオブジェクトをピン可能として参照するMemoryHandleを返します。

Slide 102

これにより、ランタイムはメモリがアンピンされるとき、このオブジェクトのUnpinメソッドを呼び出し、再びセーフハンドルの解放を引き起こすことを知ることができます。

Slide 103

このクラスが作成されると、それをインスタンス化し、そのMemoryプロパティにアクセスするだけで十分です。これは、私たちが作成したばかりのMemoryManagerを内部的に参照するバイトのMemoryを返します。そして、あなたはメモリの一部を持つことになります。それに書き込むと、必要なスペースがあるときに自動的にディスクにアンロードされ、アクセスすると、必要なときに透明にディスクから再ロードされます。

Slide 104

これで、ディスクにスピルするプログラムを実装するのに十分です。もう一つの問題は、FileStreamを使用する代わりにメモリマッピングを使用する理由は何か?結局のところ、FileStreamはディスク上のデータにアクセスするときの明らかな選択肢であり、その使用方法はかなりよく文書化されています。例えば、浮動小数点の値の配列を読むには、FileStreamとBinaryReaderをFileStreamにラップする必要があります。データが存在するオフセットに位置を設定し、リーダーでInt32を読み、浮動小数点の配列を割り当て、それをMemoryMarshal.Castでバイトのスパンに変換します。

Slide 106

FileStream.Readには、宛先としてバイトのスパンを取るオーバーロードがあります。これは実際にはページキャッシュも使用します。プロセスのアドレス空間にそれらのページをマッピングする代わりに、オペレーティングシステムはそれらを保持し、値を読むためには、ディスクからメモリにロードし、そのページから提供した宛先スパンにコピーします。したがって、これはパフォーマンスと振る舞いの両方の観点から、メモリマップバージョンで起こったことと同等です。

しかし、2つの大きな違いがあります。まず、これはスレッドセーフではありません。一行で位置を設定し、別のステートメントでその位置がまだ同じであることに依存します。これは、この操作の周りにロックが必要であり、したがって、メモリマップファイルで可能なように、複数の場所から並行して読み取ることはできません。

もう一つの問題は、FileStreamが使用する戦略によっては、Int32の読み取りとスパンへの読み取りの2つの読み取りを行うことになります。一つの可能性は、それぞれが一つのシステムコールを行うことです。それはオペレーティングシステムを呼び出し、オペレーティングシステムは自身のメモリからプロセスメモリに一部のデータをコピーします。これにはオーバーヘッドがあります。もう一つの可能性は、ストリームがバッファリングされていることです。その場合、最初の4バイトを読むと、おそらく1ページのコピーが作成されます。そして、このコピーは、後でread関数によって行われる実際のコピーの上に発生します。したがって、メモリマップバージョンでは存在しないオーバーヘッドを導入します。

この理由から、パフォーマンスの観点からは、メモリマップバージョンを使用することが好ましいです。結局のところ、FileStreamはディスク上のデータにアクセスする明らかな選択肢であり、その使用方法は非常によく文書化されています。例えば、浮動小数点の値の配列を読むには、FileStreamとBinaryReaderが必要です。データがファイル内のどこに存在するかを示すオフセットにFileStreamの位置を設定し、Int32を読んでサイズを取得し、浮動小数点の配列を割り当て、それをMemoryMarshal.Castを使用してバイトのスパンに変換し、それを読み取りの宛先としてバイトのスパンを必要とするFileStream.Readのオーバーロードに渡します。そして、これもページキャッシュを使用します。ページがプロセスに関連付けられる代わりに、それらはオペレーティングシステム自体によって保持され、ディスクからページキャッシュにロードし、ページキャッシュからプロセスメモリにコピーします。これは、メモリマッピングバージョンで行ったのと同じように行います。

しかし、FileStreamのアプローチには2つの大きな欠点があります。最初の欠点は、このコードはマルチスレッドの使用に対して安全ではないということです。結局のところ、位置は一つのステートメントで設定され、その後のステートメントで使用されます。したがって、これらの読み取り操作の周囲にロックが必要です。メモリマップバージョンはロックを必要とせず、実際にはディスク上の複数の場所から並行してロードすることができます。SSDの場合、これによりキューの深さが増加し、パフォーマンスが向上し、通常は望ましいとされています。もう一つの問題は、FileStreamが2回の読み取りを行う必要があることです。

ストリームが内部的に使用する戦略によっては、オペレーティングシステムを起動する必要がある2つのシステムコールが発生する可能性があります。それは自身のメモリから一部のデータをプロセスメモリにコピーし、その後、すべてをクリアしてプロセスに制御を戻す必要があります。これには一部のオーバーヘッドがあります。もう一つの可能な戦略は、FileStreamがバッファリングされることです。その場合、システムコールは1回だけ行われますが、オペレーティングシステムのメモリからFileStreamの内部バッファへのコピーが発生し、その後、読み取りステートメントがFileStreamの内部バッファから浮動小数点配列へ再度コピーする必要があります。したがって、これはメモリマップバージョンでは存在しない無駄なコピーを作り出します。

ファイルストリームは、少し使いやすいですが、いくつかの制限があります。代わりにメモリマップバージョンを使用するべきです。したがって、今、私たちは可能な限り多くのメモリを使用し、メモリが不足したときにはデータセットの一部をディスクに戻すシステムを持つことになりました。このプロセスは完全に透明で、オペレーティングシステムと協力します。頻繁にアクセスされるデータセットの一部が常にメモリに保持されているため、最大のパフォーマンスで動作します。

しかし、最後に答えるべき質問が一つあります。結局のところ、メモリマップを行うとき、ディスクをメモリマップするのではなく、ディスク上のファイルをメモリマップします。では、私たちは何個のファイルを割り当てるつもりなのでしょうか?それらはどれくらい大きくなるのでしょうか?そして、私たちはどのようにしてそれらのファイルをメモリの割り当てと解放の際に循環させるのでしょうか?

明らかな選択肢は、一つの大きなファイルをマップし、プログラムの開始時にそれを行い、それを続けて実行することです。一部がもう使用されなくなったときには、それを上書きします。これは明らかであるため、それは間違っています。

Slide 111

このアプローチの最初の問題は、メモリのページを上書きするためには離散的なアルゴリズムが必要であるということです。

アルゴリズムは次のようになります:まず、ページを直ちにメモリにロードします。次に、メモリ内のページの内容を変更します。オペレーティングシステムは、ステップ2であなたがすべてを消去して置き換えるつもりであることを知る方法がないので、あなたが変更しない部分が同じままであるように、それでもページをロードする必要があります。最後に、あなたはページを将来のある時点でディスクに書き戻すようにスケジュールします。

さて、新しいファイルの特定のページに初めて書き込むとき、ロードするデータはありません。オペレーティングシステムはすべてのページがゼロであることを知っているので、ロードは無料です。それはただゼロページを取って使います。しかし、ページがすでに変更されてメモリにない場合、オペレーティングシステムはディスクから再度ロードする必要があります。

Slide 113

二つ目の問題は、ページキャッシュからのページは最近最も使用されていないものが追い出され、オペレーティングシステムはあなたのメモリの死んだセクションが二度と使用されないことを認識していないということです。したがって、それは必要ないデータセットの一部をメモリに保持し、必要な一部を追い出す可能性があります。死んだセクションを無視すべきだとオペレーティングシステムに伝える方法はありません。

Slide 114

三つ目の問題も関連しており、それはデータをディスクに書き込むのが常にメモリに書き込むのに遅れているということです。そして、ページがもう必要ないことを知っていて、それがまだディスクに書き込まれていない場合、まあ、オペレーティングシステムはそれを知りません。だから、それはまだ二度と使われることのないバイトをディスクに書き込む時間を費やし、すべてを遅くします。

Slide 115

代わりに、メモリをいくつかの大きなファイルに分割すべきです。私たちは同じメモリに二度書き込むことはありません。これにより、すべての書き込みがディスクからのロードを伴わない、オペレーティングシステムによってゼロであることがわかっているページにヒットすることが保証されます。そして、可能な限り早くファイルを削除します。これはオペレーティングシステムに、これはもう必要ない、ページキャッシュから削除できる、すでにディスクに書き込まれていなければディスクに書き込む必要はないと伝えます。

Slide 116

Lokadの本番環境では、典型的な本番VMでは、以下の設定でLokadスクラッチスペースを使用しています:各ファイルは16ギガバイト、各ディスクには100のファイルがあり、各L32VMには4つのディスクがあります。合計すると、これは各VMに対して6テラバイト以上のスピルスペースを表しています。

Slide 117

今日はこれまでです。ご質問やコメントがありましたら、お気軽にお問い合わせください。ご視聴いただきありがとうございます。