.NETアプリでマルチコアCPUを活かす

F#でレイトレーサーを作っていてArray.Parallelモジュールを使って処理を並列化したら、CPU使用率が40~50%くらいまでしか上がらない現象が発生しました。原因を調べたら.NETランタイムのGCの設定を変更することで解消できたので記事にまとめます。

現象

以下が並列化している個所のコードで、640 x 480の解像度の画像を生成する場合、あるピクセルの色を計算するrenderPixel関数が640 x 480 = 307,200回呼び出されます。また、renderPixel関数では最大1万回シーンオブジェクト(球など)との衝突判定と色の計算が行われ、そのときにベクトルの計算(和や内積など)を大量に行います。

let render scene (width : int) (height : int) =
    let coords =
        seq {
            for y in 0..(height - 1) do
                for x in 0..(width - 1) do
                    yield { X = x; Y = y }
        }
        |> Seq.toArray
    let data =
        coords
        |> Array.Parallel.map (fun coord -> renderPixel scene width height coord)
    { Width = width; Height = height; Data = data }

f:id:LocaQ:20180624013300p:plain

この状態だと私のPCでは1枚の画像を生成するのに約550秒(9分10秒)かかりました。

CPUを100%まで使い切れればより速く画像を生成できるので原因を調べることにしました。あと、せっかくマルチコアのCPUを使っているのに活かし切れていないのはもったいないと思うので。(正直こっちの理由の方が大きいです。)

また、F#のArray.Paralle.mapは内部でSystem.Threading.Tasks.Parallel.Forを使っています。なのでこの現象はF#固有の現象ではないです。(調べたら.NETのランタイムが原因でした。)

Parallel.map<'T,'U> Function (F#)

解決策

解決方法を先に書くと、.NETのGCのモードをサーバーGCというものに変更すると解決しました。

ガベージ コレクションの基礎 | Microsoft Docs

.NET Frameworkの場合

App.configgcServerという要素を追加してenabled属性の値をtrueにします。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup> 
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
  </startup>
  <runtime>
    <gcServer enabled="true"/>
  </runtime>
</configuration>

<gcServer>要素 | Microsoft Docs

gcServerはデフォルトでfalseワークステーションGC)です。

.NET Coreの場合

プロジェクトファイルにServerGarbageCollectionを追加して値をtrueにします。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.0</TargetFramework>
    <ServerGarbageCollection>true</ServerGarbageCollection>
  </PropertyGroup>

  ...

</Project>

project.json と csproj の比較 - .NET Core | Microsoft Docs

この設定をするとCPU使用率が100%まで上がるようになり、約202秒(3分22秒)、変更前より約2.7倍速く画像を生成できるようになりました。

f:id:LocaQ:20180624013320p:plain

改善した理由

ガベージ コレクションの基礎 | Microsoft Docs

このページの「ワークステーションとサーバーのガベージ コレクションの比較」に、

ワークステーションのガベージ コレクションにおける、スレッド処理とパフォーマンスについての注意点を次に示します。

・コレクションは、ガベージ コレクションをトリガーしたユーザー スレッドで、それと同じ優先順位で実行されます。 ユーザー スレッドは一般に通常の優先順位で実行されるため、その場合 (通常の優先順位のスレッドで実行された場合)、ガベージ コレクターの CPU 時間が他のスレッドと競合します。

...

サーバーのガベージ コレクションにおける、スレッド処理とパフォーマンスについての注意点を次に示します。

・コレクションは、 THREAD_PRIORITY_HIGHEST の優先順位で実行される複数の専用スレッドで実行されます。

・ヒープおよびガベージ コレクションを実行するための専用スレッドは CPU ごとに 1 つずつ用意され、複数のヒープのコレクションが同時に行われます。 各ヒープには小さなオブジェクト ヒープと大きなオブジェクト ヒープがあり、どのヒープもユーザー コードからアクセスできます。 異なるヒープのオブジェクトを相互に参照できます。

・複数のガベージ コレクション スレッドが連携して処理を行うため、同じサイズのヒープを処理した場合、サーバーのガベージ コレクションの方がワークステーションのガベージ コレクションよりも高速です。

...

と書かれていました。

レイトレーサーではベクトルの計算を大量に行うため、有効期間が短い(ジェネレーション0)ベクトルクラスのオブジェクトが大量に生成されます。(これはVisual Studioのプロファイラを使って確認しました。)

そのためgcServerを有効にする前はGCを1スレッドで行っていたためボトルネックになりCPU使用率が上がらず、gcServerを有効にした後は論理CPUの数(私のPCの場合は12)だけGCのスレッドが用意されてボトルネックが解消されてCPU使用率が上がったのだと思います。

その他

今回の現象について調べてる最中に知ったことが他にもあるので、せっかくなので書いておきます。

CPUグループ

CPUグループは64個の論理CPUをまとめたもので、1つのシステムで64個より多い論理CPUがある場合は複数のCPUグループが存在するみたいです。

Processor Groups (Windows)

また、How to Get Started with Multi-Core: Parallel Processing You Can Use – US ISV Evangelismに、

CLR only uses processor group 0 and doesn’t call any of the new Windows NUMA APIs. If you are a C# or VB developer, you’ll be able to use up to 64 processors. This provides plenty of processing power for the foreseeable future on commodity hardware. But rather than embed NUMA into your code, .NET Framework 4 provides some a new namespace that lets you take advantage of parallel processing power in your PC.

と書かれていました。CLR(共通言語ランタイム)はデフォルトで1つのCPUグループしか使わないようです。

なので、もし64個より多い論理CPUを持つシステムで.NETアプリを動かす場合、CLRで全てのCPUグループを使うようにする設定が必要みたいです。

.NET Framework の場合

c# - Unable to use all processors in .NET on AWS c5.18xlarge 72 vpu - Stack Overflow の回答によると、App.configThread_UseAllCpuGroups, GCCpuGroup, gcServerを有効にする設定をするらしいです。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
  </startup>
  <runtime>
    <Thread_UseAllCpuGroups enabled="true"/>
    <GCCpuGroup enabled="true"/>
    <gcServer enabled="true"/>
  </runtime>
</configuration>

<Thread_UseAllCpuGroups>要素 | Microsoft Docs

<GCCpuGroup>要素 | Microsoft Docs

.NET Core の場合

.NET CoreではServerGarbageCollectiontrueにして、さらに以下の2つの環境変数を追加すると同等の設定ができるようです。

coreclr/clr-configuration-knobs.md at master · dotnet/coreclr