web-dev-qa-db-ja.com

この単純なF#コードがC#/ C ++バージョンより36倍遅いのはなぜですか?

変数を作成し、それをゼロで初期化し、100000000回インクリメントする簡単なテストを作成しました。

C++は0.36秒でそれを行います。 0.33秒の元のC#バージョン0.8秒の新しい12秒のF#。

私は関数を使用しないので、問題はデフォルトではジェネリックにありません

F#コード

_open System
open System.Diagnostics
// Learn more about F# at http://fsharp.org
// See the 'F# Tutorial' project for more help.
[<EntryPoint>]
let main argv = 
    let N = 100000000
    let mutable x = 0
    let watch = new Stopwatch();
    watch.Start();
    for i in seq{1..N} do
        x <- (x+1)
    printfn "%A" x
    printfn "%A" watch.Elapsed
    Console.ReadLine()
        |> ignore
    0 // return an integer exit code
_

C++コード

_#include<stdio.h>
#include<string.h>
#include<vector>
#include<iostream>
#include<time.h>
using namespace std;
int main()
{
    const int N = 100000000;
    int x = 0;
    double start = clock();
    for(int i=0;i<N;++i)
    {
        x = x + 1;
    }
    printf("%d\n",x);
    printf("%.4lf\n",(clock() - start)/CLOCKS_PER_SEC);
    return 0;
}
_

C#コード

_using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;

namespace SpeedTestCSharp
{
    class Program
    {
        static void Main(string[] args)
        {
            const int N = 100000000;
            int x = 0;
            Stopwatch watch = new Stopwatch();
            watch.Start();

            foreach(int i in Enumerable.Range(0,N))
            //Originally it was for(int i=0;i<N;++i)
            {
                x = x + 1;
            }
            Console.WriteLine(x);
            Console.WriteLine(watch.Elapsed);
            Console.ReadLine();
        }
    }
}
_

編集

for (int i = 0; i < N; ++i)foreach(int i in Enumerable.Range(0,N))に置き換えると、C#プログラムは約0.8秒で実行されますが、それでもf#よりもはるかに高速です。

編集

F#/ C#のDateTimeStopWatchに置き換えました。結果は同じです

25
user2136963

これは、次の式を使用した結果として直接発生していることは間違いありません。

_for i in seq{1..N} do
_

私のマシンでは、これにより次の結果が得られます。

100000000

00:00:09.1500924

ループを次のように変更した場合:

_for i in 1..N do
_

結果は劇的に変化します。

100000000

00:00:00.1001864

なぜですか?

これら2つのアプローチによって生成されるILはまったく異なります。 2番目のケースでは、_1..N_構文を使用すると、C#for(int i=1; i<N+1; ++i)ループと同じ方法でコンパイルされます。

最初のケースはまったく異なり、このバージョンは完全なシーケンスを生成し、それがforeachループによって列挙されます。

IEnumerablesを使用するC#バージョンとF#バージョンは、異なる範囲関数を使用して生成するという点で異なります。

C#バージョンは_System.Linq.Enumerable.RangeIterator_を使用して値の範囲を生成し、F#バージョンは_Microsoft.FSharp.Core.Operators.OperatorIntrinsics.RangeInt32_を使用します。この特定のケースでC#バージョンとF#バージョンの間に見られるパフォーマンスの違いは、これら2つの機能のパフォーマンス特性の結果であると考えるのが安全だと思います。

svickは、彼のコメントで、_+_演算子が実際にはintegralRangeStep関数の引数として渡されていることを指摘しています。

_n <> m_の重要なケースの場合、F#コンパイラはProperIntegralRangeEnumeratorを使用し、実装は次のようになります: https://github.com/Microsoft/visualfsharp/blob/ master/src/fsharp/FSharp.Core/prim-types.fs#L646

_let inline integralRangeStepEnumerator (zero,add,n,step,m,f) : IEnumerator<_> =
    // Generates sequence z_i where z_i = f (n + i.step) while n + i.step is in region (n,m)
    if n = m then
        new SingletonEnumerator<_> (f n) |> enumerator 
    else
        let up = (n < m)
        let canStart = not (if up then step < zero else step > zero) // check for interval increasing, step decreasing 
        // generate proper increasing sequence
        { new ProperIntegralRangeEnumerator<_,_>(n,m) with 
                member x.CanStart = canStart
                member x.Before a b = if up then (a < b) else (a > b)
                member x.Equal a b = (a = b)
                member x.Step a = add a step
                member x.Result a = f a } |> enumerator
_

列挙子をステップスルーすると、より単純で直接的な加算ではなく、提供されたadd関数が呼び出されることがわかります。

注:すべてのタイミングはリリースモードで実行されます(末尾呼び出し:オン、最適化:オン)。

34
TheInnerLight

F#についてはよくわからないので、F#が生成するコードを確認したいと思いました。結果は次のとおりです。 TheInnerLightの答えを確認するだけです。

まず、C++はforループを最適化できるはずです。ゼロ(またはほぼゼロ)の時間が得られます。 .NETコンパイラとJITは現在この最適化を実行していないので、それらを比較してみましょう。

C#ループのILは次のとおりです。

// [21 28 - 21 58]
IL_000e: ldc.i4.0     
IL_000f: ldc.i4       100000000
IL_0014: call         class [mscorlib]System.Collections.Generic.IEnumerable`1<int32> [System.Core]System.Linq.Enumerable::Range(int32, int32)
IL_0019: callvirt     instance class [mscorlib]System.Collections.Generic.IEnumerator`1<!0/*int32*/> class [mscorlib]System.Collections.Generic.IEnumerable`1<int32>::GetEnumerator()
IL_001e: stloc.2      // V_2
.try
{

  IL_001f: br.s         IL_002c

// [21 16 - 21 24]
  IL_0021: ldloc.2      // V_2
  IL_0022: callvirt     instance !0/*int32*/ class [mscorlib]System.Collections.Generic.IEnumerator`1<int32>::get_Current()
  IL_0027: pop          

// [22 9 - 22 15]
  IL_0028: ldloc.0      // num1
  IL_0029: ldc.i4.1     
  IL_002a: add          
  IL_002b: stloc.0      // num1

  IL_002c: ldloc.2      // V_2
  IL_002d: callvirt     instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
  IL_0032: brtrue.s     IL_0021
  IL_0034: leave.s      IL_0040
} // end of .try
finally
{
  IL_0036: ldloc.2      // V_2
  IL_0037: brfalse.s    IL_003f
  IL_0039: ldloc.2      // V_2
  IL_003a: callvirt     instance void [mscorlib]System.IDisposable::Dispose()
  IL_003f: endfinally   
} // end of finally

そして、これがF#ループのILです。

// [23 5 - 23 138]
IL_000f: ldc.i4.1     
IL_0010: ldc.i4.1     
IL_0011: ldc.i4       100000000
IL_0016: call         class [mscorlib]System.Collections.Generic.IEnumerable`1<int32> [FSharp.Core]Microsoft.FSharp.Core.Operators/OperatorIntrinsics::RangeInt32(int32, int32, int32)
IL_001b: call         class [mscorlib]System.Collections.Generic.IEnumerable`1<!!0/*int32*/> [FSharp.Core]Microsoft.FSharp.Core.Operators::CreateSequence<int32>(class [mscorlib]System.Collections.Generic.IEnumerable`1<!!0/*int32*/>)
IL_0020: stloc.2      // V_2
IL_0021: ldloc.2      // V_2
IL_0022: callvirt     instance class [mscorlib]System.Collections.Generic.IEnumerator`1<!0/*int32*/> class [mscorlib]System.Collections.Generic.IEnumerable`1<int32>::GetEnumerator()
IL_0027: stloc.3      // enumerator
.try
{

// [26 7 - 26 36]
  IL_0028: ldloc.3      // enumerator
  IL_0029: callvirt     instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
  IL_002e: brfalse.s    IL_003f

// [28 9 - 28 41]
  IL_0030: ldloc.3      // enumerator
  IL_0031: callvirt     instance !0/*int32*/ class [mscorlib]System.Collections.Generic.IEnumerator`1<int32>::get_Current()
  IL_0036: stloc.s      current

// [29 9 - 29 15]
  IL_0038: ldloc.0      // func
  IL_0039: ldc.i4.1     
  IL_003a: add          
  IL_003b: stloc.0      // func
  IL_003c: nop          

  IL_003d: br.s         IL_0028
  IL_003f: ldnull       
  IL_0040: stloc.s      V_4
  IL_0042: leave.s      IL_005d
} // end of .try
finally
{

// [34 7 - 34 57]
  IL_0044: ldloc.3      // enumerator
  IL_0045: isinst       [mscorlib]System.IDisposable
  IL_004a: stloc.s      disposable

// [35 7 - 35 30]
  IL_004c: ldloc.s      disposable
  IL_004e: brfalse.s    IL_005a

// [36 9 - 36 29]
  IL_0050: ldloc.s      disposable
  IL_0052: callvirt     instance void [mscorlib]System.IDisposable::Dispose()

  IL_0057: ldnull       
  IL_0058: pop          
  IL_0059: endfinally   
  IL_005a: ldnull       
  IL_005b: pop          
  IL_005c: endfinally   
} // end of finally
IL_005d: ldloc.s      V_4
IL_005f: pop          

したがって、ループは少し異なりますが、主に同じことを行います。

C#の機能は次のとおりです。

  • [0] MoveNext部分への分岐(1回のみ)
  • [1]列挙可能なオブジェクトのCurrentプロパティを取得しますそしてそれを破棄します
  • [2]ローカルに1を追加します0
  • [3] MoveNextを呼び出します
  • [4] trueの[1]に戻るか、falseのループを終了します

F#ループは次のことを行います。

  • [0] MoveNextを呼び出します
  • [1]ループをfalseのままにします
  • [2]列挙可能なオブジェクトのCurrentプロパティを取得しますそしてその値をローカルに格納します
  • [3]ローカルに1を追加します0
  • [4] nop(sic)で休憩してください
  • [5] [0]に分岐します

したがって、ここには2つの違いがあります。

  • C#はCurrentプロパティの値を破棄し、F#はそれをローカルに格納します
  • F#には、私を超えた何らかの理由でループ内にnop(何もしない)命令があります(はい、これはリリースモードです)。

しかし、これらの違いだけでは、パフォーマンスへの大きな影響を説明することはできません。 JITがこれを使って何をするのか見てみましょう。

注:rcxは、使用されるx64呼び出し規約の最初の引数であり、インスタンスメソッド呼び出しのthis暗黙パラメーターに対応します。

C#、x64:

            foreach (int i in Enumerable.Range(0, N))
00007FFCF2B94514  xor         ecx,ecx  
00007FFCF2B94516  mov         edx,5F5E100h  
00007FFCF2B9451B  call        00007FFD50EF08F0          // Call Enumerable.Range
00007FFCF2B94520  mov         rcx,rax  
00007FFCF2B94523  mov         r11,7FFCF2A80040h
00007FFCF2B9452D  cmp         dword ptr [rcx],ecx  
00007FFCF2B9452F  call        qword ptr [r11]           // Call GetEnumerator
00007FFCF2B94532  mov         qword ptr [rbp-20h],rax  
00007FFCF2B94536  mov         rcx,qword ptr [rbp-20h]   // Store the IEnumerator in rcx
00007FFCF2B9453A  mov         r11,7FFCF2A80048h        
00007FFCF2B94544  cmp         dword ptr [rcx],ecx  
00007FFCF2B94546  call        qword ptr [r11]           // Call MoveNext
00007FFCF2B94549  test        al,al  
00007FFCF2B9454B  je          00007FFCF2B9457F          // Skip the loop
00007FFCF2B9454D  mov         rcx,qword ptr [rbp-20h]   // Store the IEnumerator in rcx
00007FFCF2B94551  mov         r11,7FFCF2A80050h  
00007FFCF2B9455B  cmp         dword ptr [rcx],ecx  
00007FFCF2B9455D  call        qword ptr [r11]           // Call get_Current
            {
                x = x + 1;
00007FFCF2B94560  mov         ecx,dword ptr [rbp-0Ch]  
00007FFCF2B94563  inc         ecx                       
00007FFCF2B94565  mov         dword ptr [rbp-0Ch],ecx  
            foreach (int i in Enumerable.Range(0, N))
00007FFCF2B94568  mov         rcx,qword ptr [rbp-20h]   // Store the IEnumerator in rcx
00007FFCF2B9456C  mov         r11,7FFCF2A80048h  
00007FFCF2B94576  cmp         dword ptr [rcx],ecx  
00007FFCF2B94578  call        qword ptr [r11]           // Call MoveNext
00007FFCF2B9457B  test        al,al  
00007FFCF2B9457D  jne         00007FFCF2B9454D  
00007FFCF2B9457F  mov         rcx,qword ptr [rsp+20h]  
00007FFCF2B94584  call        00007FFCF2B945C6  
00007FFCF2B94589  nop  
            }

F#、x64:

    for i in seq{1..N} do
00007FFCF2B904F4  mov         ecx,1  
00007FFCF2B904F9  mov         edx,1  
00007FFCF2B904FE  mov         r8d,5F5E100h  
00007FFCF2B90504  call        00007FFD42AA2B80          // Create the sequence
00007FFCF2B90509  mov         rcx,rax  
00007FFCF2B9050C  mov         r11,7FFCF2A90020h  
00007FFCF2B90516  cmp         dword ptr [rcx],ecx  
00007FFCF2B90518  call        qword ptr [r11]           // Call GetEnumerator
00007FFCF2B9051B  mov         qword ptr [rbp-20h],rax  
00007FFCF2B9051F  mov         rcx,qword ptr [rbp-20h]   // Store the IEnumerator in rcx
00007FFCF2B90523  mov         r11,7FFCF2A90028h  
00007FFCF2B9052D  cmp         dword ptr [rcx],ecx  
00007FFCF2B9052F  call        qword ptr [r11]           // Call MoveNext  
00007FFCF2B90532  test        al,al  
00007FFCF2B90534  je          00007FFCF2B90553          // Exit the loop?
        x <- (x+1)
00007FFCF2B90536  mov         rcx,qword ptr [rbp-20h]  
00007FFCF2B9053A  mov         r11,7FFCF2A90030h  
00007FFCF2B90544  cmp         dword ptr [rcx],ecx  
00007FFCF2B90546  call        qword ptr [r11]           // Call get_Current
00007FFCF2B90549  mov         edx,dword ptr [rbp-0Ch]  
00007FFCF2B9054C  inc         edx  
00007FFCF2B9054E  mov         dword ptr [rbp-0Ch],edx  
00007FFCF2B90551  jmp         00007FFCF2B9051F          // Loop
00007FFCF2B90553  mov         rcx,qword ptr [rsp+20h]  
00007FFCF2B90558  call        00007FFCF2B9061C  
00007FFCF2B9055D  nop   

まず、C#stillは、結果を破棄してもCurrentを呼び出すことに気付きます。これは仮想通話であり、最適化されていません。

ああ、そのF #nopILオペコードはJITによって最適化されています。 x64コードにはnopがありますが、それはafterループであり、位置合わせのためにここにあります。

次に、コードの構造は少し異なりますが、2つのケースでコードが非常に似ていることがわかります。同じ関数を呼び出し、奇妙なことは何もしません。

そうです、あなたが見ているパフォーマンスの違いは、F#がループメカニズム自体ではなく、シーケンスを構築する方法によって確かに説明されています。

15

これらの部分についてF#コンパイラーを掘り下げた人として、私はおそらくF#コンパイラー内で何が起こっているかについていくつかの光を共有できると思いました。

多くの人が指摘しているように、for i in seq{1..N}IEnumerable<>の範囲で1..Nを作成します。 IEnumerable<>の反復は、CurrentMoveNextへの仮想呼び出しのために少し遅いです。原則として、F#がこのパターンを検出して最適化することは可能ですが、現在F#はそうではありません。

パターンfor i in 1..Nを使用することをお勧めします。これにより、パフォーマンスが大幅に向上し、GC圧力が低下します。

読む前の読者への質問は、式からどのようなパフォーマンスが期待できるかということです。

  • for i in 1L..int64 N
  • for i in 1..2..N

F# タイプチェッカーfor-each expressionを検出すると、ILコードに簡単に変換できるよりプリミティブな式に変換します。フォールバックケースは、for-each expressionを次のようなものに変換することです。

// body is the body of the for_each expression, enumerable is what we iterate over
let for_each (body : 'T -> unit) (enumerable : IEnumerable<'T>) : unit =
  let e = enumerable.GetEnumerator ()
  try
    while e.MoveNext () do
      body e.Current
  finally
    e.Dispose ()

これは関数TcForEachExprで発生します。好奇心旺盛な読者は、この関数のこの行に気づきます。

// optimize 'for i in n .. m do' 
| Expr.App(Expr.Val(vf,_,_),_,[tyarg],[startExpr;finishExpr],_) 
    when valRefEq cenv.g vf cenv.g.range_op_vref && typeEquiv cenv.g tyarg cenv.g.int_ty -> 
        (cenv.g.int32_ty, (fun _ x -> x), id, Choice1Of3 (startExpr,finishExpr))

タイプチェッカーは、実際にはfor-each expressionの形状のfor i in lowerint32..upperinter32の最適化を実行しています。より自然な場所は オプティマイザー でこれを行うことだと思うでしょう。これは、F#がすべての新しい最適化をオプティマイザーに入力する必要があるほど成熟していなかったため、レガシーな理由によるものと思われます。残念ながら、この最適化をオプティマイザーに移動するのは簡単ではありません。これにより、<@ for i in 0..100 @>の式ツリーの形状が変更され、多くのユーザーコードコードが破損する可能性があります。同じ理由で、タイプチェッカーにこれ以上最適化を追加することはできません。これは、下位互換性を維持することの喜びと課題です。

最適化コードを使用すると、前の質問に答えることもできます。

  • for i in 1L..int64 N-int32が必要なため、最適化は適用されません
  • for i in 1..2..N- range_step_op_vrefのケースがないため、最適化は適用されません

フォールバックケースが行うことは、範囲式の周りにseqオブジェクトを作成し、.Current/.MoveNextを使用してそれを反復することです。動作しますが、パフォーマンスが低下します。

配列を反復処理するための最適化もあります。

// optimize 'for i in arr do' 
| _ when isArray1DTy cenv.g enumExprTy  -> 
    let arrVar,arrExpr = mkCompGenLocal m "arr" enumExprTy
    let idxVar,idxExpr = mkCompGenLocal m "idx" cenv.g.int32_ty
    let elemTy = destArrayTy cenv.g enumExprTy

したがって、配列の反復処理は(C#の場合と同じように)高速ですが、文字列(C#の場合は高速)やその他のデータ構造についてはどうでしょうか。

オプティマイザーには、文字列、fsharpリスト、および1と-1の増分でforループの反復を検出し、それらを効率的なfor loopsに変換するケースが多くあることがわかりました(ほとんどはDetectAndOptimizeForExpressionで発生します)。

いくつかの最適化または最適化の機会を逃したことを示すコード

open System.Collections.Generic

let total = 10000000
let outer = 10
let inner = total / outer

let stopWatch = 
  let sw = System.Diagnostics.Stopwatch ()
  sw.Start ()
  sw

let timeIt (name : string) (a : unit -> 'T) : unit = // ' 
  let t = stopWatch.ElapsedMilliseconds
  let v = a ()
  for i = 1 to (outer - 1) do
    a () |> ignore
  let d = stopWatch.ElapsedMilliseconds - t
  printfn "%s, elapsed %d ms, result %A" name d v

let case1 () = 
  // Slow because it fallbacks into slow but safe code pattern
  let mutable x = 0
  for i in seq{1..inner} do
    x <- x+1
  x

let case2 () = 
  // Fast because the optimization in TypeChecker.fs matches
  let mutable x = 0
  for i in 1..inner do
    x <- x+1
  x

let case3 () = 
  // Slow because the optimization in TypeChecker.fs requires int32
  let mutable x = 0
  for i in 1L..int64 inner do
    x <- x+1
  x

let case4 () = 
  // Slow because the optimization in TypeChecker.fs doesn't recognize b..inc..e patterns
  let mutable x = 0
  for i in 1..2..inner do
    x <- x+1
  x

let case5 () = 
  // Fast because Optimizer.fs recognizes this pattern
  let mutable x = 0
  for i in 1..1..inner do
    x <- x+1
  x

let case6 () = 
  // Fast because Optimizer.fs recognizes this pattern
  let mutable x = 0
  for i in inner..(-1)..1 do
    x <- x+1
  x


[<EntryPoint>]
let main argv =
  timeIt "case1" case1
  timeIt "case2" case2
  timeIt "case3" case3
  timeIt "case4" case4
  timeIt "case5" case5
  timeIt "case6" case6

  0

F#オプティマイザーに価値のある改善があると思う人は誰でも、F#コードをダウンロードして適用してみることをお勧めします。よくできた最適化は、ほとんどの場合大歓迎です。

これが誰かにとって面白かったことを願っています

何が起こっているのかというと、余分なseqがいくつかの最適化を妨げていると思います。

に変更した場合

for i in 1..N 

これは(少なくともc ++と)ほぼ同等だと思いますが、はるかに高速です

6
John Palmer