为什么在没有约束的泛型方法上将可空值类型与null进行比较会变慢?

时间:2011-04-18 19:26:45

标签: c# performance compiler-construction nullable jit

我遇到了一个非常有趣的情况,即在通用方法中将可空类型与null进行比较比比较值类型或引用类型慢234倍。代码如下:

static bool IsNull<T>(T instance)
{
    return instance == null;
}

执行代码是:

int? a = 0;
string b = "A";
int c = 0;

var watch = Stopwatch.StartNew();

for (int i = 0; i < 1000000; i++)
{
    var r1 = IsNull(a);
}

Console.WriteLine(watch.Elapsed.ToString());

watch.Restart();

for (int i = 0; i < 1000000; i++)
{
    var r2 = IsNull(b);
}

Console.WriteLine(watch.Elapsed.ToString());

watch.Restart();

for (int i = 0; i < 1000000; i++)
{
    var r3 = IsNull(c);
}

watch.Stop();

Console.WriteLine(watch.Elapsed.ToString());
Console.ReadKey();

上面代码的输出是:

00:00:00.1879827

00:00:00.0008779

00:00:00.0008532

如您所见,将nullable int与null进行比较比比较int或字符串慢234倍。如果我使用正确的约束添加第二个重载,结果会发生显着变化:

static bool IsNull<T>(T? instance) where T : struct
{
    return instance == null;
}

现在的结果是:

00:00:00.0006040

00:00:00.0006017

00:00:00.0006014

为什么?我没有检查字节代码,因为我不熟悉它,但即使字节代码有点不同,我希望JIT优化它,而不是(我正在运行优化)

3 个答案:

答案 0 :(得分:14)

以下是您应该采取的措施来调查此事。

首先重写程序,使其完成所有两次。在两次迭代之间放置一个消息框。通过优化编译程序,并运行程序而不是在调试器中。这可确保抖动生成最佳代码。抖动知道连接调试器的时间,并且如果它认为你正在做的事情,可以生成更糟糕的代码,以便更容易调试。

当弹出消息框时,附加调试器,然后在汇编代码级别跟踪代码的三个不同版本,如果实际上甚至有三个不同的版本。我愿意下注多达一美元,不会为第一个生成任何代码,因为抖动知道整个事情可以优化为“返回false”,然后返回false可以内联,甚至可以删除循环。

(将来,你应该在编写性能测试时考虑这一点。请记住,如果你没有使用结果那么抖动可以自由地完全优化所有东西产生该结果,只要它没有副作用。)

一旦你看到汇编代码,你就会看到发生了什么。

我个人并没有对此进行过调查,但是发生了什么事情的可能性很大:

  • 在int codepath中,抖动意识到boxed int永远不会为null并且将方法转换为“return false”

  • 在字符串代码路径中,抖动意识到测试字符串的无效等同于测试字符串的托管指针是否为零,因此它生成一条指令来测试寄存器是否为零。

  • int中的
  • ? codepath,可能是抖动实现了测试int?对于nullity可以通过装箱来完成吗? - 因为盒装的null int是一个空引用,然后减少到先前测试托管指针的问题。但你承担了拳击的费用。

如果是这种情况那么抖动可能在这里更复杂并且意识到测试int? for null可以通过在int?中返回HasValue bool的逆来完成。

但就像我说的那样,这只是猜测。如果您感兴趣,请自己生成代码,看看它在做什么。

答案 1 :(得分:5)

如果你比较两个重载产生的IL,你可以看到有拳击涉及:

第一个看起来像:

.method private hidebysig static bool IsNull<T>(!!T instance) cil managed
{
    .maxstack 2
    .locals init (
        [0] bool CS$1$0000)
    L_0000: nop 
    L_0001: ldarg.0 
    L_0002: box !!T
    L_0007: ldnull 
    L_0008: ceq 
    L_000a: stloc.0 
    L_000b: br.s L_000d
    L_000d: ldloc.0 
    L_000e: ret 
}

虽然第二个看起来像:

.method private hidebysig static bool IsNull<valuetype ([mscorlib]System.ValueType) .ctor T>(valuetype [mscorlib]System.Nullable`1<!!T> instance) cil managed
{
    .maxstack 2
    .locals init (
        [0] bool CS$1$0000)
    L_0000: nop 
    L_0001: ldarga.s instance
    L_0003: call instance bool [mscorlib]System.Nullable`1<!!T>::get_HasValue()
    L_0008: ldc.i4.0 
    L_0009: ceq 
    L_000b: stloc.0 
    L_000c: br.s L_000e
    L_000e: ldloc.0 
    L_000f: ret 
}

在第二种情况下,编译器知道类型是Nullable,因此可以对其进行优化。在第一种情况下,它必须处理任何类型,包括引用和值类型。所以它必须跳过一些额外的箍。

至于为什么int比int更快?我想象那里有一些JIT优化。

答案 2 :(得分:3)

在没有你知道的情况下,拳击和拆箱就在那里发生,拳击行动的速度非常慢。这是因为您在后台将可空引用类型转换为值类型。