2018/02/15

.NETのAppDomainと共有メモリ

この後書く記事の前哨戦として、.NETのアプリケーションドメインと共有メモリの関係について整理しておく。

まず、あるアプリケーションドメイン内で共有メモリを確保したとする。この共有メモリに他のアプリケーションドメインからアクセスすることは可能か否か。



当然できるだろう。どうせ同じプロセス内なのだし。

と、思うところだが、実は簡単ではない。

コードで書くならこうなる。
open System
open System.IO.MemoryMappedFiles
open System.Reflection

type CMemory() =
    inherit MarshalByRefObject()

    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 4096L );

    // アクセサを取得する
    member this.GetView() =
        mmapFile.CreateViewAccessor()

[<EntryPoint>]
let main argv = 
    // アプリケーションドメインを構築する
    let ad = AppDomain.CreateDomain( "dom1" );

    // dom1のアプリケーションドメインで、
    // CMemoryクラスのインスタンスを生成する
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    // アクセサを取得し、値を更新する
    let acc = tbl.GetView()
    acc.Write( 0L, 0L )

    0

F#だが、細かいことは気にするな。

上記のコードはコンパイルすることは可能である。だが、実行すると「tbl.GetView()」の箇所で失敗する。



理由は上の図にある通り、System.IO.MemoryMappedFiles.MemoryMappedViewAccessorクラスMarshalByRefObjectを継承しているわけでも、Serializableとして宣言されているわけでもないからである。

.NETで素直に共有メモリにアクセスするためにはMemoryMappedFileクラスからMemoryMappedViewAccessorクラスないしMemoryMappedViewStreamクラスのインスタンスを取得しなければならない。

そのうち、MemoryMappedViewStreamクラスはシリアル化可能ではないが、MarshalByRefObjectを継承しているため、アプリケーションドメインを跨いで使用することが可能である。

コードにすればこうなる。
open System
open System.IO.MemoryMappedFiles
open System.Reflection

type CMemory() =
    inherit MarshalByRefObject()

    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 4096L );

    // アクセサを取得する
    member this.GetView() =
        mmapFile.CreateViewStream()

[<EntryPoint>]
let main argv = 
    // アプリケーションドメインを構築する
    let ad = AppDomain.CreateDomain( "dom1" );

    // dom1のアプリケーションドメインで、
    // CMemoryクラスのインスタンスを生成する
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    // アクセサを取得し、値を更新する
    let acc = tbl.GetView();
    acc.Write( Array.zeroCreate( 8 ), 0, 8 ) |> ignore

    0

太字のところが変わっただけである。

上記は、正しくコンパイルも通るし、実行もできる。素晴らしい。これでよいではないか。

と、いう気もするのだ、1つ罠がある。性能だ。

繰り返しになるが、CreateViewStreamメソッドで返されるMemoryMappedViewStreamクラスMarshalByRefObjectを継承している。そのため、MemoryMappedViewStreamクラスのインスタンスそのものは、共有メモリを構築したアプリケーションドメイン(上記のコード例でいうのならdom1)にインスタンスが存在し、mainからはプロキシを通じてアクセスされることになる。



上記のコード例のように、8バイトのデータを書き込んでいるだけであれば、多少コピーが発生しようが気にする必要もないだろう。だが、データ量が増えると極端に性能が劣化する。

結局のところ、呼び元と呼び先のアプリケーションドメインにおいてマネージ領域に大きなバイト配列が作られるし、それらの間でデータのコピーを行わなければならないし、かなりのCPU負荷になるのだろう。

では、この状況下で性能を改善するにはどうすればよいか。

案1.アクセスしたいアプリケーションドメインで同名の共有メモリを作る。

共有メモリにアプリケーションドメインを跨ってアクセスするのではなく、そもそも関係するすべてのアプリケーションドメインで共有メモリを作ってやればよい、という発想である。

open System
open System.IO.MemoryMappedFiles
open System.Reflection

type CMemory() =
    inherit MarshalByRefObject()

    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 4096L );

    // 確認する
    member __.Check() =
        let acc = mmapFile.CreateViewAccessor()
        printf "Check = %d\n" ( acc.ReadInt64( 0L ) )

[<EntryPoint>]
let main argv = 
    // アプリケーションドメインを構築する
    let ad = AppDomain.CreateDomain( "dom1" );

    // dom1のアプリケーションドメインで、
    // CMemoryクラスのインスタンスを生成する
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 4096L );

    // 共有メモリに書き込む
    let acc = mmapFile.CreateViewAccessor()
    acc.Write( 0L, 123L )

    // 確認する
    tbl.Check()

    0

図にすればこうなる。



しかしこの方法だと、アドレス空間を無駄に使うことになる。つまり、共有メモリのサイズ×アクセスするアプリケーションドメインの個数分アドレスを使用する。

64bitプロセスであればあまり気にしなくてもいいのかもしれない。だが、32bitプロセスだと、実質1GB程度しかアドレス空間を使うことができないため、関与するアプリケーションドメインの個数が増えれば、それだけ使える共有メモリのサイズが小さくなってしまう。

もしデータ量が小さくてよいのであれば、そもそもこんなことを考える必要もない。

案2.アドレス値を取得して、直接書き込む。

そもそも、共有メモリはアンマネージドなリソースであり、.NETとかアプリケーションドメインとかの管理対象外の存在である。アプリケーションドメインの境界云々を気にするのは.NETのお作法の問題に過ぎない。だから、紳士の仮面をかなぐり捨てる勇気を持てば、高速化が可能である。

open System
open System.IO.MemoryMappedFiles
open System.Reflection
open Microsoft.FSharp.NativeInterop

type CMemory() =
    inherit MarshalByRefObject()

    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 4096L );

    // ハンドル
    let m_Handle = mmapFile.CreateViewAccessor().SafeMemoryMappedViewHandle

    // アドレス
    let m_Address =
        let mutable p : nativeptr<byte> = NativePtr.ofNativeInt( nativeint 0 )
        m_Handle.AcquirePointer( &p )
        ( NativePtr.toNativeInt p ).ToPointer()

    // ファイナライザ
    override this.Finalize() =
        m_Handle.ReleasePointer()

    // アドレスを取得する
    member __.GetAddress() =
        m_Address

    // 確認する
    member __.Check() =
        let acc = mmapFile.CreateViewAccessor()
        printf "Check = %d\n" ( acc.ReadInt64( 0L ) )

[<EntryPoint>]
let main argv = 
    // アプリケーションドメインを構築する
    let ad = AppDomain.CreateDomain( "dom1" );

    // dom1のアプリケーションドメインで、
    // CMemoryクラスのインスタンスを生成する
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    // アドレスを取得する
    let adr = tbl.GetAddress()

    // 共有メモリに書き込む
    let v = [| 123L |]
    Buffer.MemoryCopy(
        ( NativePtr.toNativeInt( &&v.[0] ) ).ToPointer(),
        adr,
        8L,
        8L
    )

    // 確認する
    tbl.Check()

    0

上記をコンパイルすると、「このコンストラクトを使用すると、検証できない .NET IL コードが生成される可能性があります。この警告を無効にするには、'--nowarn:9' または '#nowarn "9"' を使用してください。」という警告が3つぐらい表示される。

心が痛むがそれを無視して実行してやると、確かにprintf文で123という数字が表示され、共有メモリに値が書き込まれていることが確認できる。



小官が愚考するに、この案2に示した方法が最速なはずである。




実際に測定してみる。


■案1に示した方法。すなわち、アプリケーションドメインごとに同名の共有メモリを確保する方法。
open System
open System.IO
open System.IO.MemoryMappedFiles
open System.Reflection

type CMemory() =
    inherit MarshalByRefObject()
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 8192L );

[<EntryPoint>]
let main argv = 
    let ad = AppDomain.CreateDomain( "dom1" );
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 8192L );
    let acc =  mmapFile.CreateViewStream()
    let v : byte[] = Array.zeroCreate( 8192 );

    // 開始時間を取得する
    let startTime = DateTime.Now

    // 繰り返し共有メモリに書き込む
    let rec loop cnt =
        // 8192バイト書き込む
        acc.Seek( 0L, SeekOrigin.Begin ) |> ignore
        acc.Write( v, 0, 8192 ) |> ignore
        // 時々経過時間を確認する
        if cnt % 10000 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    // 秒間の繰り返し回数を算出する
    let timeSpan = DateTime.Now - startTime
    printf "count = %d / s\n" <| int( ( float count ) * 1000.0 / timeSpan.TotalMilliseconds )

    0

1回目:4,218,799回/秒
2回目:4,207,377回/秒
3回目:4,204,798回/秒

およそ420万回×8KB、32GB/秒程度か。


■案2に示した方法。すなわち、変態紳士になる方法。
open System
open System.IO.MemoryMappedFiles
open System.Reflection
open Microsoft.FSharp.NativeInterop

type CMemory() =
    inherit MarshalByRefObject()

    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 8192L );
    let m_Handle = mmapFile.CreateViewAccessor().SafeMemoryMappedViewHandle
    let m_Address =
    let mutable p : nativeptr<byte> = NativePtr.ofNativeInt( nativeint 0 )
        m_Handle.AcquirePointer( &p )
        ( NativePtr.toNativeInt p ).ToPointer()

    override this.Finalize() =
        m_Handle.ReleasePointer()

    member __.GetAddress() =
        m_Address

[<EntryPoint>]
let main argv = 
    let ad = AppDomain.CreateDomain( "dom1" );
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    let adr = tbl.GetAddress()
    let v : byte[] = Array.zeroCreate( 8192 );

    // 開始時間を取得する
    let startTime = DateTime.Now

    // 繰り返し共有メモリに書き込む
    let rec loop cnt =
        // 8192バイト書き込む
        Buffer.MemoryCopy(
            ( NativePtr.toNativeInt( &&v.[0] ) ).ToPointer(),
            adr,
            8192L,
            8192L
        )

        // 時々経過時間を確認する
        if cnt % 10000 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    // 秒間の繰り返し回数を算出する
    let timeSpan = DateTime.Now - startTime
    printf "count = %d / s\n" <| int( ( float count ) * 1000.0 / timeSpan.TotalMilliseconds )

    0

1回目:8,325,773回/秒
2回目:8,168,611回/秒
3回目:8,223,587回/秒

およそ820万回×8KB、62GB/秒程度。


比較のために、いくつか他の方法も試してみた。


■2番目に示した、MemoryMappedViewStreamクラスを用いた方法

再掲になるが、図にするとこうなる奴だ。



open System
open System.IO
open System.IO.MemoryMappedFiles
open System.Reflection

type CMemory() =
    inherit MarshalByRefObject()
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 8192L );
    member this.GetView() =
        mmapFile.CreateViewStream()

[<EntryPoint>]
let main argv = 
    let ad = AppDomain.CreateDomain( "dom1" );
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    // アクセサを取得する
    let acc = tbl.GetView();
    let v : byte[] = Array.zeroCreate( 8192 );

    // 開始時間を取得する
    let startTime = DateTime.Now

    // 繰り返し共有メモリに書き込む
    let rec loop cnt =
        // 8192バイト書き込む
        acc.Seek( 0L, SeekOrigin.Begin ) |> ignore
        acc.Write( v, 0, 8192 ) |> ignore
        // 時々経過時間を確認する
        if cnt % 10000 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    // 秒間の繰り返し回数を算出する
    let timeSpan = DateTime.Now - startTime
    printf "count = %d / s\n" <| int( ( float count ) * 1000.0 / timeSpan.TotalMilliseconds )

    0

1回目:192,646回/秒
2回目:192,662回/秒
3回目:192,201回/秒

およそ19万2千回×8KB、1.4GB/秒程度。遅い。


■同一アプリケーションドメインに存在する共有メモリに書き込む場合(MemoryMappedViewStreamクラスを使う)

これだと、そもそもお題に掲げた要件を満たさないが、性能比較のためにやってみた。

これを、上と同じ様に図にするのならこうなる。



open System
open System.IO
open System.IO.MemoryMappedFiles

[<EntryPoint>]
let main argv = 
    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc2", 8192L );
    let acc =  mmapFile.CreateViewStream()
    let v : byte[] = Array.zeroCreate( 8192 );

    // 開始時間を取得する
    let startTime = DateTime.Now

    // 繰り返し共有メモリに書き込む
    let rec loop cnt =
        // 8192バイト書き込む
        acc.Seek( 0L, SeekOrigin.Begin ) |> ignore
        acc.Write( v, 0, 8192 ) |> ignore
        // 時々経過時間を確認する
        if cnt % 10000 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    // 秒間の繰り返し回数を算出する
    let timeSpan = DateTime.Now - startTime
    printf "count = %d / s\n" <| int( ( float count ) * 1000.0 / timeSpan.TotalMilliseconds )

    0

1回目:4,619,800回/秒
2回目:4,605,824回/秒
3回目:4,601,801回/秒

およそ460万回×8KB、35GB/秒程度。

案1に示した方法とトントンだが、ちょっと早い気がする。同名の共有メモリを複数確保していると、何かオーバーヘッドでも生じるのか?

■同一アプリケーションドメインに存在する共有メモリに書き込む場合(MemoryMappedViewAccessorクラスを使う)

やっていることは上と同じだが、書き込みに使うアクセサをMemoryMappedViewAccessorクラスにする。
open System
open System.IO
open System.IO.MemoryMappedFiles

[<EntryPoint>]
let main argv = 
    // 共有メモリを確保する
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc2", 8192L );
    let acc =  mmapFile.CreateViewAccessor()
    let v : byte[] = Array.zeroCreate( 8192 );

    // 開始時間を取得する
    let startTime = DateTime.Now

    // 繰り返し共有メモリに書き込む
    let rec loop cnt =
        // 8192バイト書き込む
        acc.WriteArray( 0L, v, 0, 8192 )
        // 時々経過時間を確認する
        if cnt % 10000 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    // 秒間の繰り返し回数を算出する
    let timeSpan = DateTime.Now - startTime
    printf "count = %d / s\n" <| int( ( float count ) * 1000.0 / timeSpan.TotalMilliseconds )

    0

1回目:8,482回/秒
2回目:8,507回/秒
3回目:8,505回/秒

およそ8千500回×8KB、66MB/秒程度。

この数値は間違いではない。驚くほどに遅い。何となくストリームを使うよりもオーバーヘッドは少なそうな気がしていたのだが、そうではないらしい。

正直に言って、MemoryMappedViewAccessorクラスはさして便利な機能があるわけでもないし、もはや使用禁止だと言っていいレベルだ。


■共有メモリがあるアプリケーションドメインに配列を渡してから書き込む方法

多分、設計上はこれが一番素直な気もするが、直感的に性能上のペナルティが大きいのではないか?

同様に、図にするのならこうなる。



open System
open System.IO
open System.IO.MemoryMappedFiles
open System.Reflection

type CMemory() =
    inherit MarshalByRefObject()
    let mmapFile = MemoryMappedFile.CreateOrOpen( "abc", 8192L );
    let acc =  mmapFile.CreateViewStream()

    // 書き込む
    member __.write( v : byte[] ) =
        acc.Seek( 0L, SeekOrigin.Begin ) |> ignore
        acc.Write( v, 0, 8192 ) |> ignore

[<EntryPoint>]
let main argv = 
    let ad = AppDomain.CreateDomain( "dom1" );
    let tbl =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< CMemory >.FullName
        ) :?> CMemory

    let v : byte[] = Array.zeroCreate( 8192 );

    // 開始時間を取得する
    let startTime = DateTime.Now

    // 繰り返し共有メモリに書き込む
    let rec loop cnt =
        // 8192バイト書き込む
        tbl.write v
        // 時々経過時間を確認する
        if cnt % 10000 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    // 秒間の繰り返し回数を算出する
    let timeSpan = DateTime.Now - startTime
    printf "count = %d / s\n" <| int( ( float count ) * 1000.0 / timeSpan.TotalMilliseconds )

    0

1回目:377,092回/秒
2回目:375,640回/秒
3回目:376,830回/秒

およそ37万6千回×8KB、2.8GB/秒程度。

意外なことに、MemoryMappedViewStreamクラス(と、そのプロキシクラス)を通じて書き込むより早い。ほぼ2倍の性能と考えると、引数を呼び元に書き戻していたりとか? いずれにせよ、性能が悪いようだ。



結論をまとめるとこうなる。

・性能を追求するのなら、生のアドレスを取得して、Buffer.MemoryCopyでイチモツをねじ込むのが良い。

・美しいマネージドな世界で逝きたいのであれば、共有メモリへのアクセスを自前のメソッドで隠蔽して、共有メモリと同じアプリケーションドメインにあるメソッドからMemoryMappedViewStreamクラスで書き込むのが良い。

・アプリケーションドメインごとに共有メモリを確保する方法は貴重なアドレス空間を食いつぶすから、個人的にお勧めできない。

0 件のコメント: