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.