在一个开关vs字典中的Func值,这更快,为什么?

时间:2012-07-23 17:04:03

标签: c# dictionary clr switch-statement cyclomatic-complexity

假设有以下代码:

private static int DoSwitch(string arg)
{
    switch (arg)
    {
        case "a": return 0;
        case "b": return 1;
        case "c": return 2;
        case "d": return 3;
    }
    return -1;
}

private static Dictionary<string, Func<int>> dict = new Dictionary<string, Func<int>>
    {
        {"a", () => 0 },
        {"b", () => 1 },
        {"c", () => 2 },
        {"d", () => 3 },
    };

private static int DoDictionary(string arg)
{
    return dict[arg]();
}

通过迭代这两种方法并进行比较,我发现字典稍快一些,即使“a”,“b”,“c”,“d”被扩展为包含更多键。为什么会这样?

这与圈复杂度有关吗?是因为抖动只将字典中的return语句编译为本机代码一次?是因为字典的查找是O(1),may not be the case for a switch statement? (这些只是猜测)

4 个答案:

答案 0 :(得分:45)

简短的回答是switch语句以线性方式执行,而字典则以对数方式执行。

在IL级别,一个小的switch语句通常被实现为一系列if-elseif语句,比较切换变量和每种情况的相等性。因此,此语句将在与myVar的有效选项数成线性比例的时间内执行;这些案例将按照它们出现的顺序进行比较,最坏的情况是所有的比较都经过了尝试,最后一个比较或者没有比较。因此,有32个选项,最糟糕的情况是它们都不是,并且代码将进行32次比较以确定这一点。

另一方面,字典使用索引优化集合来存储值。在.NET中,Dictionary基于Hashtable,它具有有效的持续访问时间(缺点是空间效率极差)。通常用于“映射”集合(如字典)的其他选项包括平衡树结构,如红黑树,它们提供对数访问(和线性空间效率)。这些中的任何一个都允许代码找到与集合中正确的“case”相对应的密钥(或确定它不存在),这比switch语句可以做得更快。

编辑:其他答案和评论者已经提到了这一点,所以为了完整起见,我也会这样做。 Microsoft编译器总是编译切换到if / elseif,因为我最初推断。它通常使用少量案例和/或“稀疏”案例(非增量值,如1,200,4000)。对于较大的相邻情况集,编译器将使用CIL语句将开关转换为“跳转表”。对于大量稀疏情况,编译器可以实现二进制搜索以缩小字段,然后“通过”少量稀疏情况或实现相邻情况的跳转表。

但是,编译器通常会选择性能和空间效率最佳折衷的实现,因此它只会对大量密集包装的情况使用跳转表。这是因为跳转表需要在内存空间中按照它必须覆盖的情况范围的顺序,这对于稀疏情况来说在内存方面是非常低效的。通过在源代码中使用Dictionary,你基本上强制编译器的手;它会以你的方式做到,而不是在性能上妥协以获得内存效率。

因此,我希望大多数情况下可以在源代码中使用switch语句或Dictionary来使用Dictionary时表现更好。无论如何都要避免使用switch语句中的大量案例,因为它们的可维护性较差。

答案 1 :(得分:36)

这是微观基准可能误导的一个很好的例子。 C#编译器根据交换机/机箱的大小生成不同的IL。所以打开像这样的字符串

switch (text) 
{
     case "a": Console.WriteLine("A"); break;
     case "b": Console.WriteLine("B"); break;
     case "c": Console.WriteLine("C"); break;
     case "d": Console.WriteLine("D"); break;
     default: Console.WriteLine("def"); break;
}

产生IL,基本上对每种情况都做以下事情:

L_0009: ldloc.1 
L_000a: ldstr "a"
L_000f: call bool [mscorlib]System.String::op_Equality(string, string)
L_0014: brtrue.s L_003f

以后

L_003f: ldstr "A"
L_0044: call void [mscorlib]System.Console::WriteLine(string)
L_0049: ret 

即。这是一系列的比较。所以运行时间是线性的。

但是,添加其他案例,例如要包含a-z中的所有字母,请将IL生成的内容更改为以下内容:

L_0020: ldstr "a"
L_0025: ldc.i4.0 
L_0026: call instance void [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)

L_0176: ldloc.1 
L_0177: ldloca.s CS$0$0001
L_0179: call instance bool [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::TryGetValue(!0, !1&)
L_017e: brfalse L_0314

最后

L_01f6: ldstr "A"
L_01fb: call void [mscorlib]System.Console::WriteLine(string)
L_0200: ret 

即。它现在使用字典而不是一系列字符串比较,从而获得字典的性能。

换句话说,为这些生成的IL代码是不同的,这只是在IL级别。 JIT编译器可以进一步优化。

TL; DR :故事的士气是查看真实数据和个人资料,而不是尝试根据微基准进行优化。

答案 2 :(得分:1)

默认情况下,字符串上的开关实现为if / else / if / else结构。 正如Brian所建议的,当编译器变大时,编译器会将开关转换为哈希表。 Bart de Smet显示了in this channel9 video,(转换在13:50讨论)

编译器没有为4个项目执行此操作,因为它是保守的,以防止优化的成本超过好处。构建哈希表会花费一点时间和内存。

答案 3 :(得分:1)

与涉及编译器代码生成决策的许多问题一样,答案是“它取决于”。

在许多情况下,构建自己的哈希表可能比编译器生成的代码运行得更快,因为编译器有其他成本指标,它试图平衡你不是:主要是内存消耗。

哈希表将使用比少数if-then-else IL指令更多的内存。如果编译器为程序中的每个switch语句吐出一个哈希表,内存使用就会爆炸。

随着switch语句中case块的数量增加,您可能会看到编译器生成不同的代码。对于更多的情况,编译器有更大的理由放弃小而简单的if-then-else模式,而选择更快但更胖的替代方案。

我不知道C#或JIT编译器是否执行此特定优化,但是当case选择器很多并且大部分是顺序时,switch语句的常见编译器技巧是计算跳转向量。这需要更多的内存(以编译器生成的代码流中嵌入的跳转表的形式),但需要在恒定时间内执行。减去arg - “a”,使用结果作为跳转表的索引跳转到适当的case块。繁荣,你做完了,无论是否有20或2000例。

当切换选择器类型为char或int或enum 时,编译器更有可能转换为跳转表模式,大小写选择器的值大多是顺序的(“密集”),因为这些可以轻松减去类型以创建偏移量或索引。字符串选择器有点困难。

字符串选择器由C#编译器“实现”,这意味着编译器将字符串选择器值添加到唯一字符串的内部池中。可以将实习字符串的地址或标记用作其标识,从而在比较实际字符串以进行标识/字节方式相等时允许类似int的优化。使用足够的大小写选择器,C#编译器将生成IL代码,该代码查找arg字符串的内部等效(哈希表查找),然后将实习的标记与预先计算的大小写选择器标记进行比较(或跳转表)。

如果你可以哄骗编译器在char / int / enum选择器的情况下生成跳转表代码,这可以比使用你自己的哈希表更快地执行。

对于字符串选择器的情况,IL代码仍然需要进行哈希查找,因此与使用自己的哈希表的任何性能差异都可能是一个冲击。

但是,一般来说,在编写应用程序代码时,不应过多地关注这些编译器的细微差别。与函数指针的哈希表相比,Switch语句通常更容易阅读和理解。切换语句足够大,可以将编译器推入跳转表模式,这些语句通常太大而无法读取。

如果您发现switch语句位于代码的性能热点中,并且您已经使用分析器测量它会对性能产生切实影响,那么更改代码以使用您自己的字典是获得性能增益的合理权衡。

编写代码以从一开始就使用哈希表,没有性能测量来证明这一选择是合理的,过度工程将导致不可思议的代码,而且维护成本会不必要地增加。保持简单。