Почему медленнее сравнивать тип значения с нулевым значением в универсальном методе без ограничений?

#c# #performance #compiler-construction #nullable #jit

#c# #Производительность #построение компилятора #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

Как вы можете видеть, сравнение типа значения с нулевым значением в null в 234 раза медленнее, чем сравнение int или строки. Если я добавлю вторую перегрузку с правильными ограничениями, результаты резко изменятся:

 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 оптимизирует это, и это не так (я работаю с оптимизациями).

Комментарии:

1. С такими результатами — менее 0,2 секунды для 1 МЛН итераций в худшем случае, имеет ли это значение?

2. Да, это происходит, если вы делаете это 1 м в секунду. Я делаю.

3. Не забывайте, что вы измеряете сумму затрат на миллион итераций и затрат на jitting кода при первом вызове . Если код действительно дешевый, как этот код, стоимость jit, которая выполняется только один раз, может фактически превышать среднее значение. Может быть интересно дважды запустить тест в одной и той же программе, чтобы во второй раз код был «горячим».

4. Спасибо, Эрик, в следующий раз я буду следовать этим рекомендациям!

Ответ №1:

Вот что вам следует сделать, чтобы исследовать это.

Начните с переписывания программы так, чтобы она делала все дважды. Поместите окно сообщения между двумя итерациями. Скомпилируйте программу с включенной оптимизацией и запустите программу не в отладчике. Это гарантирует, что jitter генерирует наиболее оптимальный код, который он может. Дрожание знает, когда подключен отладчик, и может сгенерировать код хуже, чтобы упростить отладку, если он думает, что это то, что вы делаете.

Когда появится окно сообщения, подключите отладчик, а затем выполните трассировку на уровне ассемблерного кода для трех разных версий кода, если на самом деле существует три разные версии. Я был бы готов поспорить на доллар, что для первого кода вообще не генерируется никакого кода, потому что jitter знает, что все это может быть оптимизировано для «return false», и тогда это значение return false может быть встроено, и, возможно, даже цикл может быть удален.

(В будущем вам, вероятно, следует учитывать это при написании тестов производительности. Помните, что если вы не используете результат, то jitter может полностью оптимизировать все, что приводит к этому результату, при условии, что это не имеет побочного эффекта.)

Как только вы сможете взглянуть на ассемблерный код, вы увидите, что происходит.

Я лично не исследовал это, но велика вероятность, что происходит следующее:

  • в кодовом пути int дрожание осознает, что вставленный в коробку int никогда не равен null, и превращает метод в «return false»

  • в string codepath дрожание осознает, что проверка строки на нулевое значение эквивалентна проверке того, равен ли управляемый указатель на строку нулю, поэтому он генерирует единственную инструкцию, которая проверяет, равен ли регистр нулю.

  • в int? путь к коду, вероятно, джиттер понимает, что тестирует int? для nullity может быть выполнено путем упаковки int? — поскольку вставленный в коробку null int является нулевой ссылкой, это сводится к более ранней проблеме проверки управляемого указателя на соответствие нулю. Но вы берете на себя стоимость упаковки.

Если это так, то дрожание могло бы быть более сложным здесь и реализовать, что тестирование int? для null может быть выполнено путем возврата значения, обратного значению bool внутри int?.

Но, как я уже сказал, это всего лишь предположение. Сгенерируйте код самостоятельно и посмотрите, что он делает, если вам интересно.

Ответ №2:

Если вы сравните 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 
}
  

Во втором случае компилятор знает, что тип имеет значение Null, поэтому он может оптимизировать для этого. В первом случае он должен обрабатывать любой тип, как ссылочный, так и тип значения. Поэтому ему приходится перепрыгивать через несколько дополнительных обручей.

Что касается того, почему int быстрее, чем int? , Я бы предположил, что здесь задействованы некоторые оптимизации JIT.

Ответ №3:

Упаковка и распаковка там происходят без вашего ведома, а операции упаковки, как известно, медленные. Это потому, что вы в фоновом режиме преобразуете ссылочные типы с нулевым значением в типы значений.

Комментарии:

1. Я в курсе этого. Но тогда, не должна ли производительность сравнения int с null быть эквивалентной сравнению null-типа <int> с null?

2. Это так или, по крайней мере, так должно быть. Но ваш тест включает операции боксирования. Попробуйте другой метод с циклом, который сравнивает переменную типа int? для null без метода 1 000 000 раз, затем сравните это с тем же значением int по сравнению с null. Однако я не знаю, будет ли это выполнять боксирование (поскольку int тип, возможно, придется сначала привести к object ) в любом случае.

3. @Diego: При сравнении int с null мы имеем дело непосредственно с операторами преобразования. Сравнение вызывает Equals(int?, int?) метод, в котором значения int и null преобразуются неявно; в этом вызове нет бокса. (см. blogs.msdn.com/b/kirillosenkov/archive/2008/09/08 /… )

4. int? x = null; IsNull(x); Выдает исключение? (На самом деле я не могу протестировать это прямо сейчас.)

5. @Steve: Ваши рассуждения надуманны. Если компилятор знает, что код сравнивает два целых числа с нулевыми значениями, то, конечно, он может сравнить их как целые числа с нулевыми значениями. Но здесь компилятор знает, что он сравнивает null с произвольным T , и поэтому должен сгенерировать код, который выполняет произвольные сравнения. Помните, что универсальный код является универсальным ; это не похоже на код шаблона C , который перекомпилируется для каждой конструкции.