11

私は、大量の同時ユーザーを同時にメモリに表示できるシステムを設計しようとしています。このシステムの設計に着手したとき、私はすぐに、Erlang に似た何らかのアクター ベースのソリューションを思いつきました。

システムは .NET で実行する必要があるため、MailboxProcessor を使用して F# でプロトタイプの作業を開始しましたが、重大なパフォーマンスの問題に遭遇しました。私の最初のアイデアは、ユーザーごとに 1 つのアクター (MailboxProcessor) を使用して、1 人のユーザーの通信をシリアル化することでした。

私が見ている問題を再現する小さなコードを分離しました:

open System.Threading;
open System.Diagnostics;

type Inc() =

    let mutable n = 0;
    let sw = new Stopwatch()

    member x.Start() =
        sw.Start()

    member x.Increment() =
        if Interlocked.Increment(&n) >= 100000 then
            printf "UpdateName Time %A" sw.ElapsedMilliseconds

type Message
    = UpdateName of int * string

type User = {
    Id : int
    Name : string
}

[<EntryPoint>]
let main argv = 

    let sw = Stopwatch.StartNew()
    let incr = new Inc()
    let mb = 

        Seq.initInfinite(fun id -> 
            MailboxProcessor<Message>.Start(fun inbox -> 

                let rec loop user =
                    async {
                        let! m = inbox.Receive()

                        match m with
                        | UpdateName(id, newName) ->
                            let user = {user with Name = newName};
                            incr.Increment()
                            do! loop user
                    }

                loop {Id = id; Name = sprintf "User%i" id}
            )
        ) 
        |> Seq.take 100000
        |> Array.ofSeq

    printf "Create Time %i\n" sw.ElapsedMilliseconds
    incr.Start()

    for i in 0 .. 99999 do
        mb.[i % mb.Length].Post(UpdateName(i, sprintf "User%i-UpdateName" i));

    System.Console.ReadLine() |> ignore

    0

100k アクターを作成するだけで、私のクアッド コア i7 で約 800 ミリ秒かかります。次に、UpdateName各アクターにメッセージを送信し、完了するまで約 1.8 秒かかります。

ここで、すべてのキューからのオーバーヘッドがあることに気付きました: ThreadPool での処理、AutoResetEvents の設定/リセットなど、MailboxProcessor の内部で。しかし、これは本当に期待通りのパフォーマンスでしょうか? MSDN と MailboxProcessor に関するさまざまなブログの両方を読んで、私はそれが erlang アクターに似ているという考えを得ましたが、ひどいパフォーマンスから、これは実際には当てはまらないようです?

8 つの MailboxProcessors を使用し、それぞれMap<int, User>が ID でユーザーを検索するために使用されるマップを保持するコードの修正バージョンも試してみました。これにより、UpdateName 操作の合計時間が 1.2 秒に短縮されるいくつかの改善が得られました。しかし、それでも非常に遅いように感じます。変更されたコードは次のとおりです。

open System.Threading;
open System.Diagnostics;

type Inc() =

    let mutable n = 0;
    let sw = new Stopwatch()

    member x.Start() =
        sw.Start()

    member x.Increment() =
        if Interlocked.Increment(&n) >= 100000 then
            printf "UpdateName Time %A" sw.ElapsedMilliseconds

type Message
    = CreateUser of int * string
    | UpdateName of int * string

type User = {
    Id : int
    Name : string
}

[<EntryPoint>]
let main argv = 

    let sw = Stopwatch.StartNew()
    let incr = new Inc()
    let mb = 

        Seq.initInfinite(fun id -> 
            MailboxProcessor<Message>.Start(fun inbox -> 

                let rec loop users =
                    async {
                        let! m = inbox.Receive()

                        match m with
                        | CreateUser(id, name) ->
                            do! loop (Map.add id {Id=id; Name=name} users)

                        | UpdateName(id, newName) ->
                            match Map.tryFind id users with
                            | None -> 
                                do! loop users

                            | Some(user) ->
                                incr.Increment()
                                do! loop (Map.add id {user with Name = newName} users)
                    }

                loop Map.empty
            )
        ) 
        |> Seq.take 8
        |> Array.ofSeq

    printf "Create Time %i\n" sw.ElapsedMilliseconds

    for i in 0 .. 99999 do
        mb.[i % mb.Length].Post(CreateUser(i, sprintf "User%i-UpdateName" i));

    incr.Start()

    for i in 0 .. 99999 do
        mb.[i % mb.Length].Post(UpdateName(i, sprintf "User%i-UpdateName" i));

    System.Console.ReadLine() |> ignore

    0

だから私の質問はここにあります、私は何か間違ったことをしていますか? MailboxProcessor の使用方法を誤解していませんか? それとも、このパフォーマンスは期待されているものですか。

アップデート:

そこで私は ##fsharp @ irc.freenode.net で何人かの人に連絡を取り、sprintf の使用が非常に遅いことを知らせてくれました。しかし、上記の sprintf 操作を削除し、すべてのユーザーに同じ名前を使用するだけでも、操作を実行するのに約 400 ミリ秒かかり、非常に遅く感じます。

4

2 に答える 2

18

ここで、すべてのキューからのオーバーヘッドがあることに気付きました: ThreadPool での処理、AutoResetEvents の設定/リセットなど、MailboxProcessor の内部で。

そしてprintf、、そしてあなたMapSeqグローバルミュータブルをめぐって争っていますInc。そして、ヒープに割り当てられたスタック フレームをリークしています。実際、ベンチマークの実行にかかった時間のうち、MailboxProcessor.

しかし、これは本当に期待通りのパフォーマンスでしょうか?

あなたのプログラムのパフォーマンスには驚きませんが、 のパフォーマンスについてはあまり語っていませんMailboxProcessor

MSDN と MailboxProcessor に関するさまざまなブログの両方を読んで、私はそれが erlang アクターに似ているという考えを得ましたが、ひどいパフォーマンスから、これは実際には当てはまらないようです?

MailboxProcessor、Erlang の一部と概念的に似ています。あなたが目にしている最悪のパフォーマンスは、さまざまなことが原因であり、そのうちのいくつかは非常に微妙で、そのようなプログラムに影響を与えます.

だから私の質問はここにあります、私は何か間違ったことをしていますか?

あなたはいくつか間違ったことをしていると思います。まず、解決しようとしている問題が明確でないため、これはXY 問題の質問のように聞こえます。第二に、あなたは間違ったことをベンチマークしようとしています (たとえば、作成に必要なマイクロ秒の時間について不平を言っていますが、MailboxProcessor数桁の時間がかかる TCP 接続が確立された場合にのみそうするつもりかもしれません)。第 3 に、いくつかのパフォーマンスを測定するベンチマーク プログラムを作成しましたが、観察結果はまったく異なるものに起因していました。

ベンチマーク プログラムを詳しく見てみましょう。他のことをする前に、いくつかのバグを修正しましょう。sw.Elapsed.TotalSecondsより正確であるため、常に時間を測定するために使用する必要があります。return!and notを使用して非同期ワークフローで常に繰り返す必要があります。そうしないと、do!スタック フレームがリークします。

私の最初のタイミングは次のとおりです。

Creation stage: 0.858s
Post stage: 1.18s

次に、プロファイルを実行して、プログラムが実際にほとんどの時間を F# のスラッシングに費やしていることを確認しましょうMailboxProcessor

77%    Microsoft.FSharp.Core.PrintfImpl.gprintf(...)
 4.4%  Microsoft.FSharp.Control.MailboxProcessor`1.Post(!0)

明らかに、私たちが望んでいたものではありません。より抽象的に考えると、私たちは多くのデータを生成sprintfして適用していますが、生成と適用を一緒に行っています。初期化コードを分離しましょう。

let ids = Array.init 100000 (fun id -> {Id = id; Name = sprintf "User%i" id})
...
    ids
    |> Array.map (fun id ->
        MailboxProcessor<Message>.Start(fun inbox -> 
...
            loop id
...
    printf "Create Time %fs\n" sw.Elapsed.TotalSeconds
    let fxs =
      [|for i in 0 .. 99999 ->
          mb.[i % mb.Length].Post, UpdateName(i, sprintf "User%i-UpdateName" i)|]
    incr.Start()
    for f, x in fxs do
      f x
...

今、私たちは得ます:

Creation stage: 0.538s
Post stage: 0.265s

つまり、作成は 60% 速くなり、投稿は 4.5 倍速くなります。

ベンチマークを完全に書き直してみましょう。

do
  for nAgents in [1; 10; 100; 1000; 10000; 100000] do
    let timer = System.Diagnostics.Stopwatch.StartNew()
    use barrier = new System.Threading.Barrier(2)
    let nMsgs = 1000000 / nAgents
    let nAgentsFinished = ref 0
    let makeAgent _ =
      new MailboxProcessor<_>(fun inbox ->
        let rec loop n =
          async { let! () = inbox.Receive()
                  let n = n+1
                  if n=nMsgs then
                    let n = System.Threading.Interlocked.Increment nAgentsFinished
                    if n = nAgents then
                      barrier.SignalAndWait()
                  else
                    return! loop n }
        loop 0)
    let agents = Array.init nAgents makeAgent
    for agent in agents do
      agent.Start()
    printfn "%fs to create %d agents" timer.Elapsed.TotalSeconds nAgents
    timer.Restart()
    for _ in 1..nMsgs do
      for agent in agents do
        agent.Post()
    barrier.SignalAndWait()
    printfn "%fs to post %d msgs" timer.Elapsed.TotalSeconds (nMsgs * nAgents)
    timer.Restart()
    for agent in agents do
      use agent = agent
      ()
    printfn "%fs to dispose of %d agents\n" timer.Elapsed.TotalSeconds nAgents

このバージョンではnMsgs、エージェントが共有カウンターをインクリメントする前に各エージェントを想定しているため、その共有カウンターのパフォーマンスへの影響が大幅に軽減されます。このプログラムでは、さまざまな数のエージェントでのパフォーマンスも調べます。このマシンで私は得る:

Agents  M msgs/s
     1    2.24
    10    6.67
   100    7.58
  1000    5.15
 10000    1.15
100000    0.36

したがって、メッセージ/秒の速度が遅い理由の一部は、エージェントの数が異常に多い (100,000) ことのようです。エージェントが 10 ~ 1,000 の場合、F# の実装は、エージェントが 100,000 の場合よりも 10 倍以上高速です。

したがって、この種のパフォーマンスでやり遂げることができる場合は、アプリケーション全体を F# で記述できるはずですが、さらにパフォーマンスを向上させる必要がある場合は、別のアプローチを使用することをお勧めします。Disruptor のような設計を採用することで、F# の使用を犠牲にする必要さえなくなるかもしれません (そして、プロトタイピングに確実に使用できます)。実際には、.NET でのシリアル化に費やす時間は、F# async および .NET で費やす時間よりもはるかに長くなる傾向があることがわかりましたMailboxProcessor

于 2013-07-01T09:25:13.307 に答える