2018/07/16

AVアンプを捨てた

暑い



マシンよりも人間の方が先にだめになる。

ところでこいつ、とりあえずこの状況でも、コア温度が50度そこそこだから、ファンレスでも結構頑張って放熱してくれているらしい。





ところで、買ったものの記録を残すのを忘れていたから、暇つぶしに書いておく。


(1)まず、AVアンプを捨てた。YAMAHA AX1400が常時50Wくらい電気を喰っていたから、やめることにした。その代わりに、以下の組み合わせで代用することにした。

a.光デジタルオーディオの切り替え機




b.光デジタルオーディオを音声に変えるDAC



c.学習リモコン



結局、AVアンプを何に使っているかといえば、リモコンで入力ソースを切り替えることと、光デジタルオーディオを音声に変換してヘッドホンに出力することだけだ。そう割り切れば、これで用を足すことができるはず。

ポイントは、リモコンで操作できることだ。

あと、消費電力量だが、稼働中でも0.6W程度、元のAVアンプと比較して約100分の1だ。素晴らしい。





(2)色々思うところがあって、HDMIの映像をキャプチャしたくて、こんなん買ってみた。

a.HDMIスプリッター



タテマエとしての機能は、HDMI信号を2つに分離するというものだ。だが、表立って記載されていない機能として、HDCPを解除することが可能だという物がある。

出力するHDMI信号のHDCPを解除できない機器でも、こいつを通してやると、HDCPが解除できるという便利な一品だ。


b.HDMIキャプチャ



PCがなくても、USBメモリにHDMIの信号を動画として保存できるという機械だ。

PCにつながなくても使えるのだから、当然PCに余計なソフトを入れなくてもいいということだ。リアルタイムに配信したいのではない人間にとっては、きわめて好都合な代物だ。


これを使って、今となってはだいぶ古くなったSONYのBDZ-T90で再生した映像をきっちり取り込むことができた。

ついでに言うと、HDMIスプリッターは、稼働中は結構温度が上がる。普段から刺しっぱなしにしておこうかとも思ったが、熱で壊れるという噂もあるようだし、やめておいた方がいいようだ。

使う時だけ接続する。これが正解のようだ。




(3)今は、OPNSenseをベアメタルで入れて動かしているPCだが、こいつの能力の有効活用を図るために、Windows 10を入れたいと考えている。

ということで、PCの補強パーツとして以下を購入した。

a.mSATAのSSD(120GB)



選定理由は安かったから。

色々と日本語は怪しいが、機能には問題ない。性能はそれほど気にしていない。SSDだし。というか、もとよりCPUがCeleron J1900だ。SSDの性能が問題になることは無かろう。


b.USB Audioのアンプ


こいつはどうも工業用というか、産業用を想定しているらしく、音声の出力がない。当然、光デジタルオーディオの出力もない。それだと、最終的には俺の頭の上に乗っているヘッドホンに出力してやらねばならないのだが、それができない。ということで、USB接続のオーディオ出力を行う機械を買ってやった。

これも見た目が怪しい雰囲気を感じるが、機能・性能に問題はなかった。とりあえずPCに繋いだら、何も考えなくても認識されて、何も考えなくても光デジタルオーディオで出力できた。当然、アナログでも再生できる。

しいて言うと、SONYのMDR-1RNCを直接接続すると、結構音が大きい。かなり絞ってやる必要がある。

2018/07/07

Kettop MI19W-S1をもうちょっとばらしてみた

これ



今はOPNSenseを入れて使っているが、もうちょっと有効活用しようということで改造を予定している。

その前に少し調べてみた。

まず気になるところで、CPUのヒートシンクは筐体に接しているのか、ちゃんと放熱されているのかどうか。



どうやら筐体に接しており、ちゃんと筐体表面から放熱する仕様になっているらしい。



手前にLANポートが2つ見えており、その奥にヒートシンクがある。画面下部に波打っているのが筐体の上面で、おそらく熱伝導シートだと思うが、何かを挟んで接していることがわかる。

梅雨も明けて室温も常時30度越えの状態になったわけだが、その状態でアイドル中のCPUの温度を確認すると、概ね50度を超えている。中々にして熱いわけだが、その分筐体の温度も高い。



※絵を張っているが、これをキャプチャしたときの室温は24度だった。

証拠がなくて申し訳ないが、USB扇風機で筐体外部から風を送ってやると、50度程度の状態から40度程度まではCPUの温度が下がることから見ても、これ見よがしに波打った筐体のデザインはただのこけおどしという程度以上の効果はあるようだ。



それと、使用しているメモリはなんだかよく分からないが、おそらくDDR3Lの奴だ。メモリのソケットにはDDR3とあるが多分違う気がする。



メモリを交換する際には注意した方がいい。

2018/05/05

ワットメーターを買ってみた

電気代は罪である。そんなものを払うのは人倫にもとる悪逆非道の行いである。

だから少しでも電気代をケチるべく、ワットメータを買って調べてみた。

買ったのはこれ。



節電対策といえばこれだろうという定番品だ。

いくつか調べてみた。

項番項目消費電力(使用中)消費電力(停止中)
1PC60w(アイドル中)2w
2ディスプレイ60w0w
3テレビ(Pioneer KRP-500A)200w30w
4Blu-rayディスクレコーダー(SONY BDZ-T90)30w30w
5AVアンプ(YAMAHA DSP-AX1400)50w0w
6ルータ5w-
7Raspberry pi 35w-
8フレッツ光用のモデム4w-
9Surface RT15w10w

PCとディスプレイの消費電力が大きいのは承知しているが、これについては当面はどうしようもない。

許せないのはこいつらだ。

・AVアンプ
・テレビ
・Surface RT

テレビの待機電力が30wとはどういうことだ。買ったのが10年前だから、1KWh当たり25円換算で10年×365日×24時間×30w×25円/1000で65,700円も使っていることになる。待機電力だけでだ。

AVアンプは待機電力は微々たるものだが、使用中の消費電力量が大きい。だが、現状ではヘッドフォンしかつないでいないのだから、もっと賢いやり方がある気がしてならない。いい加減古いし、リプレースするべきか。

でもって、最悪に許せないのがSurface RTの端末だ。全く役に立たないゴミであることを承知の上で買って、下馬評通りクソの役にも立たず、電源だけつないだ状態で転がしてあったのだが、スリープしている状態であるにもかかわらず常時10wも喰っていやがる。

Core i7-5930KのデスクトップのPCでも、スリープ中の消費電力量は6w程度だった。なのにだ、このゴミクズ野郎は何もしないで、何らの有効成分もないくせに、月額で180円、年額で2,160円もドブに捨てていたのだ。まさに極悪非道、鬼畜生の行い、言語道断許すまじ。

叩き割って捨てるしかない。

2018/03/21

共有メモリにInterlocked.Increment

共有メモリとして確保した領域に対して、Interlocked.Incrementで値を加算したい。

まず、共有メモリに対する値の読み書きは、MemoryMappedViewAccessorクラスMemoryMappedViewStreamクラスを用いてアトミックでないアクセスを行うか、全て自己責任で生のアドレスを使ってどうにかするしか方法は無い。

無論、アトミックでない方法を用いることはできない。

open System.Threading
open System.IO.MemoryMappedFiles

[<EntryPoint>]
let main args =
    // 共有メモリ
    let mmapFile : MemoryMappedFile =
        MemoryMappedFile.CreateOrOpen( "abc", 4L )
    let acc =
        mmapFile.CreateViewAccessor()
    
    // 値を取得して
    let mutable v = acc.ReadInt32( 0L )

    // アトミックに加算する!
    Interlocked.Increment( &v ) |> ignore
    
    // 書き込む
    acc.Write( 0L, v )

    0

ジョークにもならない。

だが、Interlocked.Incrementが取る引数はbyrefのintである。C#ならば「public static int Increment( ref int location )」、F#ならば「static member Increment : location:int byref -> int」だ。通常、共有メモリ上の領域を直接Interlocked.Incrementの引数として与える方法がない。

ならば、Win32APIにあるInterlockedIncrement関数を使えばよいではないか。環境依存ではないかという良心の呵責も聞こえてくるが、もとより.NETだと思えばもはやどうでもよろしい。

open System.Runtime.InteropServices
open Microsoft.FSharp.NativeInterop
open System.Threading
open System.IO.MemoryMappedFiles

[<DllImport("Kernel32")>] extern int32 InterlockedIncrement( nativeint p );

[<EntryPoint>]
let main args =
    // 共有メモリ
    let mmapFile : MemoryMappedFile =
        MemoryMappedFile.CreateOrOpen( "abc", 4L )
    let handle =
        mmapFile.CreateViewAccessor().SafeMemoryMappedViewHandle

    // 共有メモリ領域のアドレスを取得する
    let mutable p : nativeptr<byte> =
        NativePtr.ofNativeInt( nativeint 0 )
    handle.AcquirePointer( &p )

    // アドレスを指定して、アトミックに加算する
    InterlockedIncrement( NativePtr.toNativeInt p ) |> ignore

    handle.ReleasePointer()

    0

このコードは、一応想定通りに動作する。はずだ。InterlockedIncrementで加算している個所を複数のプロセスやスレッドでゴリゴリ実行しても、おかしなことにはならないはずだ。

だが環境依存ということを除いても致命的な欠陥がある。

32bitのコードでしか動作しないのだ。

64bit版でビルドすると、Kernel32.dllにInterlockedIncrementというエントリがない、と言って怒られる。



どういうことなのか、アンマネージドなC++のコードで試してみる。



こんなものをビルドしてアセンブラを確認してみると、



直接マシン語命令に展開されている匂いがする。

というか、こんなページもあった。

https://msdn.microsoft.com/ja-jp/library/2ddez55b.aspx

結局、32bit版では(互換性のためか?)Kernel32.dllにInterlockedIncrement関数(本物の関数だ)が用意されているが、今では呼び出し元に直接マシン語コードを生成するようになっているから、64bit版ではそんなものは提供されないのではないか。

となると、P/Invokeで逃げるという手段も塞がれる。.NETでは、共有メモリを使ってプロセス間でInterlockedIncrementはできないのだろうか。


そう思っていたら、なんとも都合の良いことに、Visual Studio 2017がこの間アップデートされて15.6が公開されたのだが、その中にこんな記述を見つけた

「NativePtr.ByRef のサポートの追加」

リンク先を辿っていくと、IntPtrの値を Interlocked.CompareExchangeに渡したいとか何とか、書いてあるように見える。

ということで、やってみた。

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

[<EntryPoint>]
let main args =
    // 共有メモリ
    let mmapFile : MemoryMappedFile =
        MemoryMappedFile.CreateOrOpen( "abc", 4L );
    let handle =
        mmapFile.CreateViewAccessor().SafeMemoryMappedViewHandle

    // 共有メモリ領域のアドレスを取得する
    let mutable p : nativeptr<byte> =
        NativePtr.ofNativeInt( nativeint 0 )
    handle.AcquirePointer( &p )

    // 型を変える
    let pint : nativeptr<int> = NativePtr.ofNativeInt( NativePtr.toNativeInt( p ) )

    // アドレスを指定して、アトミックに加算する
    Interlocked.Increment( NativePtr.toByRef pint )

    handle.ReleasePointer()

    0


ここで、プロジェクトの設定を変えて、ターゲットF#ランタイムをFSharp.Core 4.4.3.0に変えてやらなければならない。



そうすると、NativePtr.toByRefが使えるようになる。

どうもこいつは、「nativeptr<'T> -> byref<'T>」なる関数の様で、Interlocked.Incrementの引数がbyref intであるため、指定するアドレスとしてはnativeptrでなければならない。なので、著しく見苦しいが事前に共有メモリ領域のアドレスとして取得したnativeptrの値をnativeptrにキャストしている。

恐ろしくばっちいが、コンパイラを騙しているだけで実行時に負荷にはならないと思えば、まぁいいか。とりあえず、これで32bit版でも64bit版でも動作するし、P/Invokeにも頼らずに、目的を達することができるようになった。




2018/02/19

AppDomain境界を跨ぐデータ授受を行う一手法

こんなことをやりたい。



ネットワークから大きなデータを受信して、複数のアプリケーションドメインを経由して、最終的にはどこかのアプリケーションドメインで消費される。あるいはその逆の過程を辿ってネットワークにデータを送信する。

そこで気になるのはアプリケーションドメインを跨いでデータを受け渡す方法だ。

標準的な方法では、受信側でMarshalByRefObjectを継承したクラスを作り、送信側でそのクラスのメソッドを呼び出すということになる。

標準的手法を用いると以下のようなコードになる。というか、いつもいつも似たようなコードで申し訳ない。

open System
open System.Reflection

// 受信側になるアプリケーションドメイン
type Receiver() =
    inherit MarshalByRefObject()

    // データを受信する
    member __.receive ( v : byte[] ) =
        //printf "length = %d\n" ( v.Length )
        ()

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

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

    // 送信すべきデータを受信する
    let send_data : byte[]  = Array.zeroCreate( 128 * 1024 );

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

    // 一定時間繰り返す
    let rec loop cnt =
        // データを渡す
        receiver.receive send_data
        // 時々経過時間を確認する
        if ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

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

だがこの方法には問題がある。

データが受け渡される都度(上記ではreceiveメソッドが呼ばれる都度)、送信側アプリケーションドメインにあるデータが、受信側アプリケーションドメインのヒープに複製されるのである。ということは、データが受け渡されるアプリケーションドメインの個数分だけ複製が作られてメモリを無駄遣いするし、その分GCの負荷も大きくなるということを意味している。

しかも、やってみればわかるが、このアプリケーションドメインを跨ぐデータの複製は、単なるメモリコピーと比較しても時間がかかる。しかも、データ量に比例して時間がかかる。

だから、性能向上を図ろうと思うのなら、アプリケーションドメイン境界を跨ぐデータ授受について、頻度もしくはデータ量を削減しなければならない。

前に、アプリケーションドメイン境界を跨いで共有メモリにアクセスする手法について記載したが、それをここで使う。

やることは単純である。送信元で共有メモリ空間(あるいはどこかの領域)にデータを書き込んで、その後書き込んだ事実を送信先に通知、送信先で共有メモリの内容を読み込めばよい。そうすれば、少なくともマネージドな空間の間でやり取りされる情報はデータ送受信の通知に関する分だけとなり、データ本体は繰り返しコピーする必要がなくなる。

※余談だが、所詮プロセス内でデータのコピーを行っているだけなのだから、「本物の」共有メモリを使用する必然性はどこにもない。GC対象外の領域であればそれが何であっても実のところ問題ないのだが、ここでは単に便利だからという理由だけで、MemoryMappedFileクラスを用いている。

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

let DataLength = 128 * 1024

// 受信側になるアプリケーションドメイン
type Receiver( argAdr : nativeint ) =
    inherit MarshalByRefObject()

    // 受信用のバッファを用意する
    let receive_data : byte[] = Array.zeroCreate( DataLength );

    // 共有メモリの開始アドレス
    let adr = argAdr

    // データを受信する
    member __.receive () =
        // (3)データをバッファにコピーする
        Buffer.MemoryCopy(
            adr.ToPointer(),
            ( NativePtr.toNativeInt( &&receive_data.[0] ) ).ToPointer(),
            int64 DataLength,
            int64 DataLength
        )

// 送信側のアプリケーションドメイン
[<EntryPoint>]
let main argv = 
    // 共有メモリ
    let mmapFile : MemoryMappedFile =
        MemoryMappedFile.CreateOrOpen( "abc", int64 DataLength );
    let handle =
        mmapFile.CreateViewAccessor().SafeMemoryMappedViewHandle
    let mutable p : nativeptr<byte> =
        NativePtr.ofNativeInt( nativeint 0 )
    handle.AcquirePointer( &p )

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

    // dom1のアプリケーションドメインで、
    // Receiverクラスのインスタンスを生成する
    let receiver =
        ad.CreateInstanceAndUnwrap(
            Assembly.GetEntryAssembly().FullName,
            typeof< Receiver >.FullName,
            false,
            BindingFlags.Default,
            null,
            [| NativePtr.toNativeInt p |],
            null,
            null
        ) :?> Receiver

    // 送信すべきデータを用意する
    let send_data : byte[] = Array.zeroCreate( DataLength );

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

    // 一定時間繰り返す
    let rec loop cnt =
        // (1)データを書き込む
        Buffer.MemoryCopy(
            ( NativePtr.toNativeInt( &&send_data.[0] ) ).ToPointer(),
            ( NativePtr.toNativeInt p ).ToPointer(),
            int64 DataLength,
            int64 DataLength
        )
        // (2)書き込んだことを通知する
        receiver.receive ()
        // 時々経過時間を確認する
        if cnt % 100 <> 0 || ( DateTime.Now - startTime ).TotalSeconds < 10.0 then
            loop ( cnt + 1 )
        else
            cnt
    let count = loop 0

    handle.ReleasePointer()

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

ここまでに示したプログラムを単純に実行すると、5倍弱程度の性能差があることが確認できる。

ところで、先のプログラムでは送信側も受信側も共有メモリ領域を完全に占有することを前提としていたことに注意してほしい。しかし、それでは俺のやりたいことは実現できない。ソケットからデータは連続して受信し続けたいし、各アプリケーションドメイン内の処理は非同期的に動作できるようにしなければならない。

なので、共有メモリ空間を単一のデータで占有できないので、メモリ空間を切り出して個別に割り当て・解放を管理しなければならない。

ということで、まずは、共有メモリ領域を固定長のページ単位に区切る。その上で、ページごとに使用中か否かの管理を行うものとする。

共有メモリに書き込む前に領域の割り当てを行い、割り当てられたページ番号を取得する。データの受信側、すなわち共有メモリから読み込む処理に対しては、割り当てられたページ番号を通知し、そのページ番号を元にデータの参照を行う。



さらに、非同期的に動作するということは、メモリ領域(もしくはページ)を確保した処理のすぐ後に解放する処理を入れるようなコーディングスタイルは取れない。

C言語的に書くならこうだ。

void Procedure( …… )
{
    // メモリ領域を確保する
    unsigned char *p = (unsigned char*)malloc( いっぱい );

    // データを設定する
    p[ ??? ] = ……;

    // 重要な処理を行う
    jyuuyou_na_syori( p );// ←これが非同期的に実行され、即座に制御を返すとしたらどうなる?

    // 解放する
    free( p );
}

だから、ページ単位に参照カウンタを設けて、参照する処理がなくなったところでページの開放が行われるような方法を考えてみる。



ページ割り当てを行ったときに参照カウンタを1に設定し、その後、アプリケーションドメイン境界を跨いでページ番号が通知されたら参照カウンタを加算する。データを使い終わるか、他の処理に丸投げるかして参照を外す場合には参照カウンタを減算する。やっていることは純朴な参照カウンタによるガベージコレクタの実装そのものだ。

後は、加減算さえ間違えなければよいのだが、それをどう実現するか。

無論1つの方法として、呼び出された箇所や参照が増えた所に加算処理を記述し、参照が不要になる箇所に減算処理をその都度記述するという方法もある。だが、それを間違いなく行うのは至難の業だ。しかも、例外が発生したり、アプリケーションドメインがアンロードされたような場合にも、確実に参照カウンタが減算されるようにしたい。そうでないと、解放されないページが残存して、いつかはメモリ領域が枯渇してしまうことになる。

なので、ページ番号を保持するクラスを作り、そのクラスのコンストラクタで参照カウンタの加算を行い、ファイナライザで減算させるようにする。そうすれば管理が簡単になって、間違いも減るだろう。(なお、アプリケーションドメインがアンロードされる時にも、ファイナライザは実行される)

だがここで疑問になるのが、アプリケーションドメイン境界を跨いでページ番号を渡す時のことだ。シリアライズ可能なクラスを作って、引数に指定してメソッドを呼び出したとして、その時コンストラクタは実行されるのか否か。実行されるとしたら、それはどれなのか?

open System
open System.Reflection

// ページ番号を保持するクラス
[<Serializable>]
type Descriptor( argPages : int[] ) =
    let m_Pages = argPages
    do
        printf "コンストラクタが動いたぜ!\n"

    override __.Finalize() =
        printf "ファイナライザが動いたぜ!\n"

// 受信側になるアプリケーションドメイン
type Receiver() =
    inherit MarshalByRefObject()

    // データを受信する
    member __.receive ( pages : Descriptor ) =
        ()

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

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

    let pages = new Descriptor( [| 0; 1; 2; |] )
    receiver.receive pages

    0

結論からすれば、この場合、コンストラクタは実行されない。



ファイナライザは想定通り2回動作しているが、コンストラクタは最初の1回しか実行されていない。

どうするのか。

この場合、以下にあるカスタムシリアル化という処理が役に立つ。

https://msdn.microsoft.com/ja-jp/library/ms973893.aspx#objserializ_topic6

結局、シリアル化を解除されるタイミングで参照カウンタの加算を行いたいのだから、そのタイミングを捕まえてやればいいのである。

open System
open System.Reflection
open System.Runtime.Serialization

// ページ番号を保持するクラス
[<Serializable>]
type Descriptor( argPages : int[] ) =
    let m_Pages = argPages
    do
        printf "オブジェクトが作られたぜ!\n"

    override __.Finalize() =
        printf "ファイナライザが動いたぜ!\n"

    interface ISerializable with
        // ここでシリアル化する
        member __.GetObjectData( info : SerializationInfo, context : StreamingContext ) =
            info.AddValue( "a", m_Pages, typeof< int[] > )
        
    // ここでシリアル化を解除する
    new( info : SerializationInfo, context : StreamingContext ) =
        let wv = info.GetValue( "a", typeof< int[] > ) :?> int[]
        // デフォルトのコンストラクタを呼ぶ
        new Descriptor( wv )

// 受信側になるアプリケーションドメイン
type Receiver() =
    inherit MarshalByRefObject()

    // データを受信する
    member __.receive ( pages : Descriptor ) =
        ()

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

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

    let pages = new Descriptor( [| 0; 1; 2; |] )
    receiver.receive pages

    0

実行するとこうなる。



無事に、コンストラクタが2回と、ファイナライザが2回呼ばれていることが確認できる。

これで後は、割り当てられたページ番号として常にDescriptorクラスを使うように気を付けてあげれば、加減算で間違いはないはずである。

ここで、ページの割り当て・解放を管理するクラスの実装を示しておく。

open System
open System.Reflection
open System.Runtime.Serialization
open System.Threading

// ページ番号を保持するクラス
// pos : 割り当てられたページのページ番号(連続するページの開始ページ番号)
// len : 割り当てられたページのページ数(何ページ分連続しているか)
// argTable : ページ管理用クラスの参照
// alloced : 新規に割り当てられたのか否か
[<Serializable>]
type Descriptor( pos : uint16[], len : byte[], argTable : MMapTable, alloced : bool ) =

    let m_PageNums = pos    // 割り当てられたページ番号
    let m_PageCount = len   // 割り当てられたページ数
    let m_Table = argTable  // メモリ管理用のクラス
    let mutable m_Released : bool = false   // 解放済みか否か

    do
        if not alloced then
            // 新規に割り当てられたのではない場合は、参照カウンタを加算する
            // ※新規に割り当てられた場合は、その時点で参照カウンタが1に設定されるため、
            //   ここで再度参照カウンタを加算してはならない。
            m_Table.AddRef m_PageNums m_PageCount

    interface ISerializable with
        // ここでシリアル化する
        member __.GetObjectData( info : SerializationInfo, context : StreamingContext ) =
            // 意外なことに、AddValueを3回呼ぶよりもレコード型にして
            // 1回で登録してしまった方が早い。
            info.AddValue( "a", ( m_PageNums, m_PageCount, m_Table ), typeof< ( uint16[] * byte[] * MMapTable ) > )
        
    // ここでシリアル化を解除する
    new( info : SerializationInfo, context : StreamingContext ) =
        let wpos, wlen, wtbl =
            info.GetValue( "a", typeof< ( uint16[] * byte[] * MMapTable ) > ) :?> ( uint16[] * byte[] * MMapTable )
        // デフォルトのコンストラクタを呼ぶ
        new Descriptor( wpos, wlen, wtbl, false )

    // ファイナライザ
    override __.Finalize() =
        // (Disposeで)参照カウンタの減算が既に行われているのなら、
        // ここでは何も行わない。
        if not m_Released then
            m_Table.Release m_PageNums m_PageCount
            m_Released <- true

    // 明示的なオブジェクトの開放
    interface IDisposable with
        member __.Dispose () =
            // 明らかに不要とわかるのなら、GCの実行を待たずにページを解放する
            m_Table.Release m_PageNums m_PageCount
            // 実は、ここのタイミングで例外が発生すると、参照カウンタを減算しすぎることになる。
            m_Released <- true
            // 逆に、Releaseメソッドの実行前にm_Released <- trueを行うと、
            // その間で例外が発生した場合に、参照カウンタの減算が不足することになる。
            // しかし、可能性として著しく低いものと判断し、単純に無視する。
            // できるのであれば、誰か対処するべし。

// ページの割り当て状況を管理するクラス
and MMapTable( argPageSize : uint32, argPageCount : uint16 ) =
    inherit MarshalByRefObject()

    let m_PageSize = argPageSize    // 1ページのバイト長
    let m_PageCount = argPageCount  // ページ数
    let m_PageStatus : int[] =      // ページごとの参照カウンタの配列
        Array.zeroCreate( int( m_PageCount ) )

    // メモリ領域の割り当てを行う
    // size : 要求するデータ量(バイト数)
    // 戻り値:割り当てられたページのページ番号(連続するページの開始ページ番号)と
    //         割り当てられたページのページ数(何ページ分連続しているか)のレコード。
    //         割り当てに失敗した場合はNoneを返す。
    member this.Allocate ( size : uint32 ) : ( uint16[] * byte[] ) option =
        // 必要になるページ数を求める
        let needPageCount32 =
            if size % m_PageSize > 0u then
                ( size / m_PageSize ) + 1u
            else
                size / m_PageSize

        // 要求ページ数が総ページ数を超える場合や、0ページの場合は何もしない
        if needPageCount32 > uint32 m_PageCount || needPageCount32 = 0u then
            None
        else
            let needPageCount = int( needPageCount32 )
            // メモリ領域の割り当てを試みる
            let rec loop
                ( idx : int )       // 次に確認すべきページ番号
                ( cnt : int )       // 今までに割り当てたページ数
                ( hold : bool )     // curIdx,curCntに有効な値を保持しているか否か
                ( curIdx : int )    // 連続して確保したページの開始ページ番号
                ( curCnt : int )    // 連続して確保したページのページ数
                ( cont : ( ( int * int ) list * bool ) -> ( ( int * int ) list * bool ) ) = // 継続
                
                if cnt >= needPageCount then
                    // 必要なページ数分割り当てた場合
                    if hold then
                        // 最後に割り当てた連続領域も含めて、戻り値のリストを構築する
                        cont ( [ ( curIdx, curCnt ) ], true )
                    else
                        // ここに来ることは無いはずだが
                        cont ( [], true )
                    
                elif idx >= int ( m_PageCount ) then
                    // 要求された分を割り当てられなかった場合
                    // 今まで割り当ててしまった分のリストを構築して返す
                    cont ( [], false )
                
                elif Interlocked.CompareExchange( &m_PageStatus.[idx], 1, 0 ) = 0 then
                    // ページの割り当てに成功した場合

                    if not hold then
                        // 新規に連続領域が開始する場合
                        loop ( idx + 1 ) ( cnt + 1 ) true idx 1 cont
                    elif curCnt >= 255 then
                        // 連続領域のページ数が既に255を超える場合は、別の連続領域とする
                        loop ( idx + 1 ) ( cnt + 1 ) true idx 1 ( fun ( arg, flg ) -> cont ( ( ( curIdx, curCnt ) :: arg ), flg ) )
                    else
                        // 連続領域のページ数のみを加算する
                        loop ( idx + 1 ) ( cnt + 1 ) true curIdx ( curCnt + 1 ) cont
                else
                    // ページの割り当てができなかった場合

                    if not hold then
                        // 割り当てられない領域が継続している場合
                        loop ( idx + 1 ) cnt false 0 0 cont
                    else
                        // 直前までページが割り当てられていた場合
                        loop ( idx + 1 ) cnt false 0 0 ( fun ( arg, flg ) -> cont ( ( ( curIdx, curCnt ) :: arg ), flg ) )

            // 割り当てる
            let rList, rFlg = loop 0 0 false 0 0 ( fun arg -> arg )
            
            if not rFlg then
                // 必要なページ数分割り当てることができなかった場合
                List.iter
                    ( fun ( pos, cnt ) ->
                        // 割り当ててしまったページを解放する
                        for i = 0 to cnt - 1 do
                            this.ReleasePos <| uint16( pos + i )
                    ) rList
                // 失敗したのでNoneを返す
                None
            else
                // 必要なページの割り当てに成功した場合

                let vPos : uint16[] = Array.zeroCreate( rList.Length )
                let vCnt : byte[] = Array.zeroCreate( rList.Length )

                let rec loop ( idx : int ) list =
                    match list with
                    | ( pos, cnt ) :: tail ->
                        vPos.[idx] <- uint16 pos
                        vCnt.[idx] <- byte cnt
                        loop ( idx + 1 ) tail
                    | [] -> ()
                loop 0 rList

                Some( vPos, vCnt )
         
        // 参照カウンタを加算する
        member __.AddRef ( pos : uint16[] ) ( cnt : byte[] ) =
            for i = 0 to pos.Length - 1 do
                for j = 0 to int( cnt.[i] ) - 1 do
                    let idx = int( pos.[i] ) + j
                    Interlocked.Increment ( &( m_PageStatus.[idx] ) ) |> ignore
        
        // 参照カウンタを減算する
        member __.Release( pos : uint16[] ) ( cnt : byte[] ) =
            for i = 0 to pos.Length - 1 do
                for j = 0 to int( cnt.[i] ) - 1 do
                    let idx = int( pos.[i] ) + j
                    if Interlocked.Decrement( &( m_PageStatus.[idx] ) ) < 0 then
                        // 可能性として、減算しすぎる場合も起こりうる。
                        // 無駄な努力だが、一応加算する処理だけ記述しておく
                        Interlocked.Increment ( &( m_PageStatus.[idx] ) ) |> ignore
                        // あるいはこの場合にはプロセスを落としてしまった方がいいかもしれない

        // 参照カウンタを減算する
        member __.ReleasePos( pos : uint16 ) =
            let idx = int pos
            if Interlocked.Decrement( &( m_PageStatus.[idx] ) ) < 0 then
                Interlocked.Increment ( &( m_PageStatus.[idx] ) ) |> ignore

著しく分かりにくい処理だが、まぁがんばれ。


Descriptorクラスは、Disposeメソッドを追加しているだけで、さっきと変わっていない。参照カウンタの加減算も、さして難しいことは行っていない。

分かりにくいのは、MMapTableテーブルクラスのAllocateメソッドである。



ここでは、参照カウンタの配列を先頭から虱潰しに検索して、0の箇所が存在したらInterlocked.CompareExchangeメソッドでアトミックに1を設定している。それにより確保できたページについて、連続して確保したページの開始ページ番号と、連続して確保したページ数を配列に格納して処理結果として返している。

安直に考えるのなら、確保したページのページ番号だけを配列に入れて返せばいい気もするが、ページ番号の配列はアプリケーションドメイン境界を越えて送受信されることになるため、少しでもデータ量を削減するためにこのような面倒なことを行っている。

最も、1ページごと細切れに確保してしまうような最悪の事態が発生した場合には、最大で1.5倍(データ型がuint16とbyteであるため)になってしまうが、そのようなことは多分発生しない。なぜならば、毎回配列を先頭から検索しているためである。

ここも試案のしどころで、ランダムな位置から開始するとか、前回検索を終了した位置から継続するとか、いろいろ試したが、結局これが一番最速であるとの結論に達した。(特に問題になるのが、ランダムな要素が入ると、空き領域があるはずなのに確保できないという問題が発生する可能性があり、その可能性を排除できなかったからだ)

後は、上記プログラムのAddRefとReleaseのメソッドを呼び出す都度、アプリケーションドメイン境界を越えた呼び出しになる問題だけだ。これも、あと少し工夫すれば排除できる目途があるから、気が向いたらまたここに書き散らす。

2018/02/17

Buffer.MemoryCopyで任意の位置に書き込む方法

前回、アプリケーションドメインの境界を跨いで共有メモリにアクセスする方法を記載したが、書き込んでいた位置が常にメモリ領域の先頭だった。例で挙げたプログラムだと、メモリ領域の任意の位置に書き込もうと思うとちょっと考えなければならないことが生じるから、一応対応方法を記録しておく。

まず、データのコピーで使っているBuffer.MemoryCopyだが、これが癖があって使いにくい。

大体にして、F#でVoid*が何を意味するのか、それをどうやって得るのか説明がない。

ということで、それを実現したコードを示す。

前回同様、マネージドな配列から共有メモリへのコピーを題材にしている。

open System
open System.IO.MemoryMappedFiles
open Microsoft.FSharp.NativeInterop
open Microsoft.Win32.SafeHandles

[<EntryPoint>]
let main argv = 

    // 共有メモリ
    let mmapFile : MemoryMappedFile =
        MemoryMappedFile.CreateOrOpen( "abc", 8192L );

    // 共有メモリのハンドルを得る
    let handle : SafeMemoryMappedViewHandle =
        mmapFile.CreateViewAccessor().SafeMemoryMappedViewHandle

    // いったんNULL値を保持するポインタを作る(nativeptr<byte>型)
    let mutable p : nativeptr<byte> =
        NativePtr.ofNativeInt( nativeint 0 )

    // メモリブロックのアドレスを取得する
    handle.AcquirePointer( &p )

    // 型を変える
    let destAddress =                // nativeptr<unit>型(型宣言が書けない)
        (
            NativePtr.toNativeInt p  // nativeptr<byte>型をnativeint型に変換する
        ).ToPointer()                // nativeptr<unit>型にする

    // マネージドな配列
    let sourceArray : byte[] =
        Array.zeroCreate( 8192 );

    // コピー元領域のアドレス値を得る
    let sourceAddress =
        (
            NativePtr.toNativeInt(
                &&sourceArray.[0]   // 配列の先頭要素のアドレス(nativeptr<byte>型)を得る
            )                       // nativeint型に変換する
        ).ToPointer()               // nativeptr<unit>型にする

    // コピー元とコピー先を指定して、コピーする
    Buffer.MemoryCopy(
        sourceAddress,
        destAddress,
        8192L,
        8192L
    )

    // 共有メモリを解放できるようにする
    handle.ReleasePointer()

    0

まずは共有メモリの場合から説明する。

共有メモリ領域の先頭アドレスは、SafeMemoryMappedViewHandleクラスAcquirePointerメソッドで取得できる。引数として、NULLで初期化されたnativeptr<byte>型変数のアドレスを指定する必要がある。

nativeptr<byte>型の値は普通に定義できる。

let mutable p : nativeptr<byte> = ……

NULLで初期化するためには、数値の0をnativeptr<byte>型にキャストする必要があるが、それにはNativePtrモジュールofNativeIntメソッドを用いる。こいつの引数はnativeint型であるが、これはSystem.IntPtr型の別名で、単にアドレス幅と同じビット長を持つ整数だと言っているに過ぎない。

だから、nativeint 0をNativePtr.ofNativeIntでnativeptr<byte>にしてやればよいということになるので、こうなる。

let mutable p : nativeptr<byte> = NativePtr.ofNativeInt( nativeint 0 )

このNativePtr.ofNativeIntメソッドは便利で、好きなnativeint型の整数を、任意の型のネイティブポインタに変換することができる。

ここまでで、NULL値を保持するnativeptr<byte>型の値が得られた。後はAcquirePointerメソッドを呼んであればよいのだが、こいつの引数がbyrefになっているので、引数の前に&を付けてやる必要がある。なおかつ、この時に指定する変数はmutableでなければならない。

handle.AcquirePointer( &p )

ここで、pは上記の通りnativeptr<byte>型である。しかし、本当に欲しいものは、MSDNにはVoid*型とか、nativeptr<void>と記述されている型であり、Visual Studioの画面ではnativeptr<unit>と表記される、得体のしれないデータ型である。



これを得るためには、まず一度NativePtrモジュールtoNativeIntメソッドを用いて、nativeptr<byte>型をnativeint型に変換してやる。

そこからIntPtr.ToPointerメソッド を使って目的のVoid*だかnativeptr<void>だかnativeptr<unit>だかという型の値を得る。

それをまとめると下記になる。

let destAddress =
    ( NativePtr.toNativeInt p ).ToPointer()

上記のdestAddressがVoid*(またはnativeptr<void>とかnativeptr<unit>)であるため、このままBuffer.MemoryCopyメソッドの引数に指定できる。


では次に、マネージドな領域に対するコピーを考える。

これはさっきよりも簡単だ。F#の&&演算子(単項の演算子だ)を使うと、任意の型のnativeptrが得られる。後は、上記と同じ議論でnativeptrをNativePtr.toNativeIntメソッドによりnativeint型にして、ToPointerメソッド で目的の型の値が得られる。

let sourceAddress =
    ( NativePtr.toNativeInt( &&sourceArray.[0] ) ).ToPointer()


これでようやく、Buffer.MemoryCopyメソッドを呼ぶことができる。


では、タイトルにある通り、アドレスの任意の場所にアクセスするにはどうすればよいか。

大きくいって、方法は2つある。

1つはNativePtr.addを用いる方法で、もう1つはnativeint型で計算してしまう方法だ。

NativePtr.addは型の指定があり、指定された型のバイト幅を考慮してアドレス値を加算してくれる。これを使うのなら、下記のようになる。

let mutable p : nativeptr<byte> = 
    NativePtr.ofNativeInt( nativeint 0 )

handle.AcquirePointer( &p )

// アクセスする先を決定する
let p2 = NativePtr.add p 4096

// 型を変える
let destAddress =
    ( NativePtr.toNativeInt p2 ).ToPointer()

上記ではpはnativeptr<byte>型であり、たまたまbyteが1バイトであるため、4096大きいアドレス値が得られる。


nativeintで直に値を加算するのならこうなる。

let mutable p : nativeptr<byte> = 
    NativePtr.ofNativeInt( nativeint 0 )

handle.AcquirePointer( &p )

// 型を変える
let destAddress =
    (
        ( NativePtr.toNativeInt p ) + nativeint 4096
     ).ToPointer()

結局、nativeint型はただの整数なのだから、四則演算は自由にできる。ならば、バイト数を自前で計算して演算を行うのも自由にできる。

C言語での配列やポインタの計算よりもさらに野蛮になっているが、もはやネイティブなアドレスを直接操作する以上、マネージ言語の精神も型の安全性も全て踏みにじっているのだから、今更取り繕ったところで始まらない。正直言って、どっちでもいいんじゃないかと思う。


あ、あと、Void*とかnativeptr<void>とかnativeptr<unit>と言われる型の値は四則演算ができないし、NativePtr.addを使っても計算できない。だから、アドレス値の計算を行う場合はVoid*に変換する前に事を済ませなければならない。

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クラスで書き込むのが良い。

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

2018/01/31

OPNsenseで自作ルータにする


LANポートが4つあるPCを手に入れたから、それにソフトウェアルータを入れて自分の気に入るように構成してやろうと思う。

ソフトウェアルータ、いろいろあるが何がいいのか。

VyOS(https://vyos.io/)
SEIL/x86(http://www.seil.jp/product/seil-x86.html)
OPNsense(https://opnsense.org/)
pfSense(https://www.pfsense.org/)

VyOSでは、IPv6だけブリッジで接続してIPv4はNATにするとかいうことが難しそうだった。それに、ブリッジにしたときのファイアウォールの設定に難がある。宛先や送信元のポートで絞ることができず、IPアドレスでフィルタを設定しなければならない。しかし当然だが、IPアドレスは何が割り当てられるのかも、いつ変わるのかもわからないので、設定しようがない。

SEIL/x86でもよさそうだったのだが、俺みたいな情弱には使い方がいまいちわからなかった。

ということで、OPNsenseでやってみることにした。

1.インストール

ダウンロードのページに行くと、nano、dvd、vga、serialの4種類の中から選択を求められる。

どうも、vgaという奴がUSBメモリからブートする奴らしいので、それを選んでやってみた。だが、インストール後にUSBメモリを引っこ抜いて再起動すると、Windows 7が起動してくる。これはどうやら、常時USBメモリを指しておかなければいけないらしい。俺が求めているのはこれではない。

dvdというのも、同じようなものらしい。ということで、nanoというものをダウンロードする。でもってそれを、内蔵のSSDに書き込んでやる。

書き込むと簡単に言うが、これが俺みたいな情弱にはかなりの難物だった。

まず、USBメモリに書き込むのであれば、USBWriterというソフトが便利である。だが、これはUSBメモリ以外には書き込めないよう制限がかかっているらしく、対象としてSSDが選択できなかった。

なので、DD for Windowsを使った。

まず、内蔵されているmSATAのSSDを取り出して、それをmSATA-SATA変換アダプタとSATA-USB変換アダプタでUSB接続に変えてやって、USBでPCに接続する。(USBにすればUSBWriterが使えるかと思ったのだが、だめだった)。

その状態で、ダウンロードしたOPNsenseのディスクイメージをDD for WindowsでSSDに書き込む。

それを、元通りに本体に戻して電源を入れてやると、無事にOPNsenseが起動した。

後はコマンドラインからIPアドレスを設定して、その設定したアドレスにWebブラウザで接続してやれば、他の設定をいじることができるようになる。なお、デフォルトのユーザ名はrootで、パスワードはopnsenseである。


2.設定内容

設定内容を手順を追って書くのが良いとは思うのだが、実のところ、かなり試行錯誤した結果であるため、何をどういう順番でこうしたのか、もはや全く覚えていない。

だから結果だけ書く。

2.1 インタフェースの割り当て



とりあえず、ポートが4つあるのだから、4ポート分インタフェースの割り当てを行ってやる。そのうち、ポート1をWAN用に使うことにして、残りのポート2~4とWiFiはLAN用の使う。

ポート2と4の設定は下記のようになっている。



ポート3には管理用のIPアドレスを割り当てるため、設定がちょっと違う。



WiFiのポートは、基本的にポート2と4と同じだが、WiFiのアクセスポイントにする設定項目がある。



WAN用に使うポート1は、ポート2や4と同じだ。



ここで、PPPoEの設定を追加してやる。ポイントツーポイントのデバイスという項目を選択してやる。



上はすでに登録した後の絵だ。最初は何もない。そこで追加ボタンを押して、PPPoEの設定を行う。



確か、PPPoEのポートを追加したら、それをインタフェースとして追加してやらなければいけなかったはず。上の絵を見ると、一覧に「v4WAN_PORT1」というのがあるが、それである。

中の設定はこんな感じ。



それと、ブリッジの設定をしてやらなければならない。その他のタイプでブリッジを選択して、追加ボタンを押下する。



中はほとんど設定すべきことは無い。ブリッジに含めるポートを選ぶだけだ。



ここではIPv6用とIPv4用に2つブリッジを設定している。V6WAN_PORT1というのが含まれているのがIPv6用である。IPv6の場合はWAN用のポート1を含めてそれ以外全部(無論PPPoEのものは除いて)をブリッジでつないでやるのだ。それに対してIPv4の場合は、IPv4のパケットがWANポートに出られては困るので、WAN用のポート1を除いてブリッジを設定する。

ブリッジを追加したら、これもインタフェースに追加する。インタフェースの一覧に見えているv6BRIDGEというものとv4BRIDGEというものがそれである。

ブリッジについてインタフェースとして設定すべきことはほとんどない。


次はファイアウォールの設定である。

LAN用に使うポート2・4・WiFiは同じで、IPv4とIPv6を全開にしてやる。デフォルトですべてドロップされるため、これをやらないと何も通信ができない。



ここで、送信元が*になっていることに注意されたい。ルールを追加するとき、送信元のポートをインタフェースの名称で指定することができるのだが、これは結局、そのインタフェースに設定されているIPアドレスによりフィルタされるものであるようだ。だから、ポート2やポート4のようにIPアドレスを設定していないポートを送信元として条件に加えると、すべてのパケットがフィルタされることになってしまう。

ポート3については、デフォルトで80番に対する接続を受け入れる設定が入っているが、それ以外は似たようなものである。



PPPoEのポート(つまり、IPv4用のWANポート)は、デフォルトで設定されていたものをそのまま残している。



ポート1、すなわちIPv6用のWANポートについては、送信元が547番で送信先が546番のUDPだけを許可してやっている。これは、DHCPv6のサーバからクライアントへの通信である。これがないと、IPv6のDNSサーバのアドレスが取得できない。(なお、フレッツ光のIPoEだと、IPアドレスとサブネットマスク、デフォルトゲートウェイはルータ広告で設定され、DNSサーバのアドレスだけはDHCPv6で決定されるらしい)


あと、ブリッジにもフィルタを設定する。IPv4用に作ったブリッジとか言ったところで、それは俺の思い込みに過ぎず、何もしなければすべてのパケットが転送されてしまうので、v4用のブリッジにはv4しか通さない設定を行う。



IPv6についても同様。


IPv4についてはブリッジでつないだ中のネットワークから、PPPoEによる外のネットワークに対してNATを行ってやらなければならないはずだが、その設定は勝手に定義が登録されるようだ。ここは実は何も触っていない。



ゲートウェイの設定を追加してやらなければならない。IPv4のゲートウェイとIPv6のゲートウェイのそれぞれが必要になる。



設定はデフォルト的な感じになっている。



IPv6も同様。


あと、デフォルトではブリッジに設定したフィルタルールは適用されないようになっている。なので、それを有効にしてやらなければならない。システムの調整パラメータというところを選択して、その中にある「Set to 1 to enable filtering on the bridge interface」を1に設定してやる。



ここまでやってやれば、とりあえず、IPv4とIPv6で通信できるようになるはずである。



あと、ここまで書いておいてなんだが、上記を記載通りの順番で設定しておくと、ブリッジを2つ有効にした時点でブロードキャストストームが発生してろくでもないことになるはずである。当然だ。

最終的な構成としてはフィルタによってIPv4とIPv6が分離されてループはしないはずではあるが、それでも何となく不安な気がしてきた。

結局、ブリッジは1つにしてブリッジに対するフィルタはなしにしてしまった。