fredag 12. april 2013 C# F# Ytelse
Dette er en oppfølger til blogposten Litt F#-kode for å måle kjøretid, hvor jeg blant annet målte tiden på ulike løsninger på Euler1-problemet. Det kom som en stor overraskelse at den rekursive varianten var såpass mye raskere enn å bruke løkker og muterbare variabler.
Spørsmålet jeg da stod igjen med var hvor rask den rekursive F#-varianten var i forhold til en løsning i C# – det har jeg nå undersøkt, og det er disse resultatene jeg skal presentere her. Og mot slutten kommer det en overraskelse!
Jeg valgte å lage et testprogram i C#. Programmet inneholder tre ulike varianter av Euler1: En som bruker en for-løkke, en som bruker Linq, og en crazy variant som bruker GOTO. I Main-metoden kjører jeg disse tre samt de fire orginale variantene fra F#-bloggposten.
public class Program
{
public static void CSharpTest1()
{
long result = 0;
for (int i = 0; i < 1000000; i++)
if (i % 3 == 0 || i % 5 == 0)
result += i;
Console.WriteLine("\nResult by for loop (C#): "
+ result);
}
public static void CSharpTest2()
{
Console.WriteLine("\nResult by Enumerable (C#): "
+ Enumerable.Range(1, 1000000)
.Where(n => n % 3 == 0 || n % 5 == 0)
.Select(Convert.ToInt64)
.Sum());
}
public static void CSharpTest3()
{
int i = 0;
long result = 0;
Loop:
if (++i > 1000000)
goto End;
if (i % 3 == 0 || i % 5 == 0)
result += i;
goto Loop;
End:
Console.WriteLine("\nResult by goto (C#): "
+ result);
}
static void Main(string[] args)
{
Benchmark.Measure("C# for loop ",
Program.CSharpTest1);
Benchmark.Measure("C# sequence ",
Program.CSharpTest2);
Benchmark.Measure("C# goto ",
Program.CSharpTest3);
Benchmark.Measure("F# mutable ",
MyFSharpProject.Program.test1);
Benchmark.Measure("F# sequence ",
MyFSharpProject.Program.test2);
Benchmark.Measure("F# ref cell ",
MyFSharpProject.Program.test3);
Benchmark.Measure("F# recursion",
MyFSharpProject.Program.test4);
Console.ReadLine();
}
}
Og nå er du vel spent? Kjøringen av dette testprogrammet gav følgende output, presentert som en graf:

Resultatet er nedslående. Det er forsåvidt greit nok – og ikke uventet – at en rekursiv løsning er treigere enn en for-løkke i C#, selv om F# har tail call optimization. Men hvorfor er sekvens-løsningen i F# så veldig mye mere tidkrevende enn sekvens-løsningen i C#?
For sikkerhets skyld forsøkte jeg også å kjøre alle variantene fra en F# executable (man vet jo aldri), men det gav akkurat det samme resultatet.
Og det var da det slo meg: Jeg kompilerer testene mine i DEBUG modus! Kan det hende kjøretiden vil ble bedre i RELEASE? Jeg er ikke vandt til at det har så mye å si i C#, men kanskje det er anderledes i F#?
Så jeg endret prosjektene til å kompilere i RELEASE-modus, og forsøkte C#-testprogrammet på nytt. Ingen merkbar endring!
Ok – hva om jeg tester fra F# igjen da? Og da ble resultatet anderledes!
I RELEASE-modus på F# runtime ble plutselig den rekursive algoritmen nøyaktig like rask som for-løkken i C#. Kompilatoren har altså sansynligvis vært i stand til å transformere rekursjonen til mer eller mindre den samme IL-koden som for-løkken. De andre variantene ble også betydelig raskere, selv om de fortsatt er et stykke fra den optimale løsningen.
Så det er på tide med en Note To Self: Mål alltid performance i RELEASE. Kompileringsmodus har mye å si for optimalisering av F#-kode.