変数を作成し、それをゼロで初期化し、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#のDateTime
をStopWatch
に置き換えました。結果は同じです
これは、次の式を使用した結果として直接発生していることは間違いありません。
_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
関数が呼び出されることがわかります。
注:すべてのタイミングはリリースモードで実行されます(末尾呼び出し:オン、最適化:オン)。
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#の機能は次のとおりです。
MoveNext
部分への分岐(1回のみ)Current
プロパティを取得しますそしてそれを破棄します0
MoveNext
を呼び出しますtrue
の[1]に戻るか、false
のループを終了しますF#ループは次のことを行います。
MoveNext
を呼び出しますfalse
のままにしますCurrent
プロパティを取得しますそしてその値をローカルに格納します0
nop
(sic)で休憩してくださいしたがって、ここには2つの違いがあります。
Current
プロパティの値を破棄し、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 #nop
ILオペコードはJITによって最適化されています。 x64コードにはnop
がありますが、それはafterループであり、位置合わせのためにここにあります。
次に、コードの構造は少し異なりますが、2つのケースでコードが非常に似ていることがわかります。同じ関数を呼び出し、奇妙なことは何もしません。
そうです、あなたが見ているパフォーマンスの違いは、F#がループメカニズム自体ではなく、シーケンスを構築する方法によって確かに説明されています。
これらの部分についてF#コンパイラーを掘り下げた人として、私はおそらくF#コンパイラー内で何が起こっているかについていくつかの光を共有できると思いました。
多くの人が指摘しているように、for i in seq{1..N}
はIEnumerable<>
の範囲で1..N
を作成します。 IEnumerable<>
の反復は、Current
とMoveNext
への仮想呼び出しのために少し遅いです。原則として、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 ++と)ほぼ同等だと思いますが、はるかに高速です