F#与C#的性能比较——附带示例代码

7
这个话题已经有很多讨论了,但我喜欢打死马也要再鞭一下,特别是当我发现它们可能还在呼吸时。
我正在解析不寻常和奇异的CSV文件格式,并且为了好玩,我决定对比我所知道的两种.NET语言C#和F#的性能。
结果令人不安。F#赢了,而且优势很大,是2倍或更多(实际上我认为更接近于0.5n,但由于我正在测试硬件IO,所以很难得到真正的基准)。
在像读取CSV这样常见的事情中出现不同的性能特征对我来说很惊讶(请注意,系数意味着C#在非常小的文件上胜出。我做的测试越多,就越感觉C#的扩展性更差,这既让人惊讶又让人担忧,因为这可能意味着我做错了些什么)。
一些注释:Core 2 duo笔记本电脑,80GB磁盘,3GB DDR 800内存,Windows 7 64位版,.Net 4,没有开启电源选项。
30000行、5列、1个短语、每个短语10个字符或更少的数据,在第一次运行后,使用尾调用递归可以使性能提高3倍(它似乎缓存了文件)。
300000行相同的数据重复出现,尾调用递归的性能优势是2倍,F#的可变实现略微胜出,但性能特征表明我正在访问磁盘而不是将整个文件放在RAM磁盘中,这会导致半随机的性能峰值。
F#代码
//Module used to import data from an arbitrary CSV source
module CSVImport
open System.IO

//imports the data froma path into a list of strings and an associated value
let ImportData (path:string) : List<string []> = 

    //recursively rips through the file grabbing a line and adding it to the 
    let rec readline (reader:StreamReader) (lines:List<string []>) : List<string []> =
        let line = reader.ReadLine()
        match line with
        | null -> lines
        | _ -> readline reader  (line.Split(',')::lines)

    //grab a file and open it, then return the parsed data
    use chaosfile = new StreamReader(path)
    readline chaosfile []

//a recreation of the above function using a while loop
let ImportDataWhile (path:string) : list<string []> =
    use chaosfile = new StreamReader(path)
    //values ina loop construct must be mutable
    let mutable retval = []
    //loop
    while chaosfile.EndOfStream <> true do
        retval <- chaosfile.ReadLine().Split(',')::retval 
    //return retval by just declaring it
    retval

let CSVlines (path:string) : string seq= 
    seq { use streamreader = new StreamReader(path)
          while not streamreader.EndOfStream do
            yield streamreader.ReadLine() }

let ImportDataSeq (path:string) : string [] list =
    let mutable retval = []
    let sequencer = CSVlines path
    for line in sequencer do
        retval <- line.Split()::retval
    retval

C# 代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Text;

namespace CSVparse
{
    public class CSVprocess
    {
        public static List<string[]> ImportDataC(string path)
        {
            List<string[]> retval = new List<string[]>();
            using(StreamReader readfile = new StreamReader(path))
            {
                string line = readfile.ReadLine();
                while (line != null)
                {
                    retval.Add(line.Split());
                    line = readfile.ReadLine();
                }
            } 

           return retval;
        }

        public static List<string[]> ImportDataReadLines(string path)
        {
            List<string[]> retval = new List<string[]>();
            IEnumerable<string> toparse = File.ReadLines(path);

            foreach (string split in toparse)
            {
                retval.Add(split.Split());
            }
            return retval;
        }
    }

}

请注意这里有许多种实现方式。使用迭代器、使用序列、使用尾调用优化,在两种语言中使用while循环...
一个主要的问题是我正在访问磁盘,因此一些特殊性质可以通过这个来解释。我打算重写这段代码以从内存流中读取(假设我不开始交换),这样应该更加一致。
但是我所学到/阅读到的所有内容都说while循环/for循环比尾调用优化/递归更快,而我运行的每个实际基准测试都表明相反。
那么我的问题是,我应该质疑传统智慧吗?
在.net生态系统中,尾调用递归真的比while循环更好吗?
在Mono上如何运行?

1
我自己尝试了一下,但无法复现你的结果。我的测试结果表明C#更快,比你的结果快50%。请看我的回复... - MartinStettner
结果表明,基准测试查找的位置不正确,这是在.split上受限于CPU而非I/O。所有实现读取数据的时间大约为3.5秒,除了.ReadLines大约需要10秒。.Split的时间有所不同,但通常在同一范围内(大约20秒左右)。 - Snark
3个回答

5
我认为差异可能来自F#和C#中不同的List。F#使用单向链表(请参见http://msdn.microsoft.com/en-us/library/dd233224.aspx),而在C#中使用基于数组的System.Collections.Generic.List。
对于单向链表,连接速度要快得多,特别是当您解析大文件时(需要不时地分配/复制整个数组列表)。
尝试在C#代码中使用LinkedList,我很想知道结果:)...
PS:此外,这将是使用分析器的好例子。您可以轻松找到C#代码的“热点”...
编辑
所以,我亲自尝试了一下:我使用了两个相同的文件,以防止缓存效应。文件有3,000,000行,每行有10次'abcdef',用逗号分隔。
主程序如下:
static void Main(string[] args) {
   var dt = DateTime.Now;
   CSVprocess.ImportDataC("test.csv"); // C# implementation
   System.Console.WriteLine("Time {0}", DateTime.Now - dt);
   dt = DateTime.Now;
   CSVImport.ImportData("test1.csv"); // F# implementation
   System.Console.WriteLine("Time {0}", DateTime.Now - dt);
}

我还尝试了先执行F#实现,然后再执行C#实现...

结果如下:

  • C#: 3.7秒
  • F#: 7.6秒

在F#解决方案之后运行C#解决方案会产生相同的性能(对于F#版本),但是对于C#会产生4.7秒的时间(我认为这是由于F#解决方案的大量内存分配造成的)。单独运行每个解决方案不会改变上述结果。

使用拥有600万行的文件,C#解决方案需要约7秒钟,而F#解决方案会产生OutOfMemoryException(我在一台拥有12GB RAM的机器上运行...)

因此,对我来说,传统的“智慧”是正确的,使用简单循环的C#对于这种任务更快...


1
我有一个分析器正在运行,因此我知道实际的性能特征。如果有一种方法可以确定“list.add”所花费的时间量,那将非常好,但我实际上不知道如何做到这一点,因为它不在我的代码中。此外,这也不是热点问题,我可以确定这一点。这只是让我感到困惑。 - Snark
1
在 F# 代码中使用 System.Generic.Collections.List 导致了约 10% 的性能损失,而在 C# 代码中使用链表则导致了约 5% 的性能损失。 - Snark
好的,这很有趣。我想我要自己试试。 - MartinStettner
关于你最后一句话,如果性能差异与递归和循环有关,我会非常惊讶。由于此示例中的递归函数是尾递归的,F#编译器应在编译期间将其转换为循环。 - kvb
@kvb 最后一句话有误导性,我也不认为差异是由于循环与尾递归。我只是想说,在C#中使用简单的方法似乎比在F#中(同样使用简单的方法)更容易实现良好的性能。等我有时间了,我会尝试查看IL代码,找出差异可能在哪里... - MartinStettner

5

你真的,真的真的真的不应该从这些结果中读取任何东西-要么将整个系统作为系统测试的形式进行基准测试,要么从基准测试中删除磁盘I / O。这只会使事情更加混乱。在实践中,最好使用TextReader参数而不是物理路径来避免将实现链接到物理文件。

此外,作为微基准测试,您的测试还有一些其他缺陷:

  • 您定义了许多未在基准测试期间调用的函数。您是在测试ImportDataC还是ImportDataReadLines?为了清晰起见,请选择并选择-在实际应用中,请不要复制实现,而是将相似之处因素化,并根据另一个定义一个。
  • 您在F#中调用.Split(',') ,但在C#中调用.Split()-您是打算在逗号上拆分还是在空格上拆分?
  • 您正在重新发明轮子-至少将您的实现与使用高阶函数(也称为LINQ)的更短版本进行比较。

2

我注意到你的F#使用了F#列表,而C#使用了.NET列表。建议尝试更改F#使用其他列表类型以处理更多数据。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接