Ускорение производительности с типами "как" и "с нулевыми значениями"

Я просто пересматриваю главу 4 С# в Depth, которая имеет дело с типами NULL, и я добавляю раздел об использовании оператора "as", который позволяет вам написать:

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    ... // Use x.Value in here
}

Я думал, что это было очень аккуратно, и что это может повысить производительность над эквивалентом С# 1, используя "is", за которым следует бросок. В конце концов, нам нужно только один раз запросить проверку динамического типа, а затем простая проверка значения.

Это, похоже, не так. Я включил тестовое приложение ниже, которое в основном суммирует все целые числа в массиве объектов, но массив содержит множество нулевых ссылок и ссылок на строки, а также целые числа в штучной упаковке. Эталонный показатель измеряет код, который вы должны использовать в С# 1, код с использованием оператора "as" и просто для решения LINQ. К моему удивлению, код С# 1 в этом случае в 20 раз быстрее - и даже код LINQ (который я ожидал бы медленнее, учитывая задействованные итераторы) бьет код "как".

Является ли реализация .NET isinst для типов с нулевым значением просто очень медленной? Является ли это дополнительным unbox.any, который вызывает проблему? Есть ли еще одно объяснение этому? На данный момент мне кажется, что мне придется включить предупреждение об использовании этого в ситуациях, чувствительных к производительности...

Результаты:

В ролях: 10000000: 121
Как: 10000000: 2211
LINQ: 10000000: 2143

код:

using System;
using System.Diagnostics;
using System.Linq;

class Test
{
    const int Size = 30000000;

    static void Main()
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i+1] = "";
            values[i+2] = 1;
        }

        FindSumWithCast(values);
        FindSumWithAs(values);
        FindSumWithLinq(values);
    }

    static void FindSumWithCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int) o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Cast: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithAs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithLinq(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = values.OfType<int>().Sum();
        sw.Stop();
        Console.WriteLine("LINQ: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }
}

Ответы

Ответ 1

Очевидно, что машинный код, который компилятор JIT может генерировать для первого случая, намного эффективнее. Одно правило, которое действительно помогает, состоит в том, что объект может быть только unboxed для переменной, которая имеет тот же тип, что и размер в штучной упаковке. Это позволяет компилятору JIT генерировать очень эффективный код, не нужно учитывать преобразования значений.

Операторский тест прост, просто проверьте, не является ли объект нулевым и имеет ожидаемый тип, берет только несколько инструкций машинного кода. Литье также легко, компилятор JIT знает расположение битов значения в объекте и использует их напрямую. Никакое копирование или преобразование не происходит, весь машинный код является встроенным и занимает всего около десятка инструкций. Это должно было быть действительно эффективным в .NET 1.0, когда бокс был обычным явлением.

Кастинг для int? занимает гораздо больше работы. Представление значения целого числа в штучной упаковке несовместимо с макетом памяти Nullable<int>. Требуется преобразование, и код является сложным из-за возможных типов переименованных коробок. Компилятор JIT генерирует вызов вспомогательной функции CLR с именем JIT_Unbox_Nullable, чтобы выполнить задание. Это функция общего назначения для любого типа значений, много кода для проверки типов. И значение копируется. Трудно оценить стоимость, так как этот код заблокирован внутри mscorwks.dll, но, вероятно, сотни инструкций машинного кода.

Метод расширения Linq OfType() также использует оператор is и cast. Это, однако, приведение к родовому типу. Компилятор JIT генерирует вызов вспомогательной функции JIT_Unbox(), которая может выполнять приведение к произвольному типу значений. У меня нет отличного объяснения, почему оно так же медленное, как приведение к Nullable<int>, учитывая, что нужно меньше работать. Я подозреваю, что ngen.exe может вызвать проблемы здесь.

Ответ 2

Мне кажется, что isinst просто слишком медленный для типов с нулевым значением. В методе FindSumWithCast я изменил

if (o is int)

к

if (o is int?)

что также значительно замедляет выполнение. Единственное отличие в IL, которое я вижу, состоит в том, что

isinst     [mscorlib]System.Int32

изменяется на

isinst     valuetype [mscorlib]System.Nullable`1<int32>

Ответ 3

Это изначально появилось в качестве комментария к замечательному ответу Ханса Пассана, но оно слишком долго, поэтому я хочу добавить несколько бит здесь:

Во-первых, оператор С# as выдает команду isinst IL (так же работает оператор is). (Еще одна интересная инструкция - castclass, которая выдается, когда вы выполняете прямой трансляции, и компилятор знает, что проверка выполнения не может быть исключена.)

Вот что isinst делает (ECMA 335 Partition III, 4.6):

Формат: isinst typeTok

typeTok - токен метаданных (a typeref, typedef или typespec), указывающий желаемый класс.

Если typeTok - тип значения, не допускающий нулевой или общий тип параметра, он интерпретируется как "boxed" typeTok.

Если типTok является типом с нулевым значением, Nullable<T>, он интерпретируется как "в штучной упаковке" T

Самое главное:

Если фактический тип (не отслеживаемый верификатором типа) объекта obj является присваивающим верификатору тип typeTok, то isinst завершается успешно, а obj (как результат) возвращается без изменений, а проверка отслеживает его тип как typeTok. В отличие от принуждений (§ 1.6) и преобразований (§3.27), isinst никогда не изменяет фактический тип объекта и не сохраняет идентичность объекта (см. раздел I).

Таким образом, убийца производительности в этом случае не isinst, а дополнительный unbox.any. Это не было ясно из ответа Ганса, поскольку он смотрел только на код JITed. В общем случае компилятор С# будет выдавать unbox.any после isinst T? (но опустит его, если вы сделаете isinst T, когда T является ссылочным типом).

Почему это так? isinst T? никогда не будет иметь эффекта, который был бы очевиден, т.е. вы вернете T?. Вместо этого все эти инструкции гарантируют, что у вас есть "boxed T", который можно распаковать на T?. Чтобы получить фактический T?, нам все равно нужно распаковать наш "boxed T" в T?, поэтому компилятор испускает unbox.any после isinst. Если вы думаете об этом, это имеет смысл, потому что "формат окна" для T? - это всего лишь "boxed T", а выполнение castclass и isinst выполнения unbox будет непоследовательным.

Резервное копирование поиска Ханса с помощью некоторой информации из стандарта, вот оно:

(ECMA 335, раздел III, 4.33): unbox.any

При применении к коробчатой ​​форме типа значения команда unbox.any извлекает значение, содержащееся в obj (типа O). (Это эквивалентно unbox, за которым следует ldobj.) При применении к ссылочному типу команда unbox.any имеет тот же эффект, что и castclass typeTok.

(ECMA 335, раздел III, 4.32): unbox

Как правило, unbox просто вычисляет адрес типа значения, который уже присутствует внутри объекта в штучной упаковке. Такой подход невозможен при распаковке типов значений NULL. Поскольку значения Nullable<T> преобразуются в коробку Ts во время операции с ящиком, реализация часто должна производить новый Nullable<T> в куче и вычислять адрес для вновь выделенного объекта.

Ответ 4

Интересно, что я передал обратную связь о поддержке оператора через dynamic на порядок меньше для Nullable<T> (аналогично этот ранний тест) - Я подозреваю по очень сходным причинам.

Надо любить Nullable<T>. Еще одно интересное: даже если JIT-пятна (и удаляет) null для непустых структур, он выдает его для Nullable<T>:

using System;
using System.Diagnostics;
static class Program {
    static void Main() { 
        // JIT
        TestUnrestricted<int>(1,5);
        TestUnrestricted<string>("abc",5);
        TestUnrestricted<int?>(1,5);
        TestNullable<int>(1, 5);

        const int LOOP = 100000000;
        Console.WriteLine(TestUnrestricted<int>(1, LOOP));
        Console.WriteLine(TestUnrestricted<string>("abc", LOOP));
        Console.WriteLine(TestUnrestricted<int?>(1, LOOP));
        Console.WriteLine(TestNullable<int>(1, LOOP));

    }
    static long TestUnrestricted<T>(T x, int loop) {
        Stopwatch watch = Stopwatch.StartNew();
        int count = 0;
        for (int i = 0; i < loop; i++) {
            if (x != null) count++;
        }
        watch.Stop();
        return watch.ElapsedMilliseconds;
    }
    static long TestNullable<T>(T? x, int loop) where T : struct {
        Stopwatch watch = Stopwatch.StartNew();
        int count = 0;
        for (int i = 0; i < loop; i++) {
            if (x != null) count++;
        }
        watch.Stop();
        return watch.ElapsedMilliseconds;
    }
}

Ответ 5

Это результат поиска FindSumWithAsAndHas выше: alt text

Это результат FindSumWithCast: alt text

Выводы:

  • Используя as, он сначала проверяет, является ли объект экземпляром Int32; под капотом он использует isinst Int32 (что аналогично рукописному коду: if (o is int)). И используя as, он также безоговорочно распаковывает объект. И это настоящий убийца производительности для вызова свойства (это все еще функция под капотом), IL_0027

  • Используя приведение, вы сначала проверяете, является ли объект int if (o is int); под капотом это использует isinst Int32. Если это экземпляр типа int, вы можете безопасно распаковать значение IL_002D

Проще говоря, это псевдокод использования подхода as:

int? x;

(x.HasValue, x.Value) = (o isinst Int32, o unbox Int32)

if (x.HasValue)
    sum += x.Value;    

И это псевдокод использования метода приведения:

if (o isinst Int32)
    sum += (o unbox Int32)

Так что приведение ((int)a[i], ну, синтаксис выглядит как приведение, но на самом деле распаковка, приведение и распаковка имеют одинаковый синтаксис, в следующий раз, когда я буду педантичен с правильной терминологией), подход действительно быстрее требуется только для распаковки значения, когда объект определенно является int. То же самое нельзя сказать об использовании подхода as.

Ответ 6

Профилирование далее:

using System;
using System.Diagnostics;

class Program
{
    const int Size = 30000000;

    static void Main(string[] args)
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i + 1] = "";
            values[i + 2] = 1;
        }

        FindSumWithIsThenCast(values);

        FindSumWithAsThenHasThenValue(values);
        FindSumWithAsThenHasThenCast(values);

        FindSumWithManualAs(values);
        FindSumWithAsThenManualHasThenValue(values);



        Console.ReadLine();
    }

    static void FindSumWithIsThenCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int)o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Is then Cast: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsThenHasThenValue(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;

            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As then Has then Value: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsThenHasThenCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;

            if (x.HasValue)
            {
                sum += (int)o;
            }
        }
        sw.Stop();
        Console.WriteLine("As then Has then Cast: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithManualAs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            bool hasValue = o is int;
            int x = hasValue ? (int)o : 0;

            if (hasValue)
            {
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Manual As: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsThenManualHasThenValue(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;

            if (o is int)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As then Manual Has then Value: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

}

Выход:

Is then Cast: 10000000 : 303
As then Has then Value: 10000000 : 3524
As then Has then Cast: 10000000 : 3272
Manual As: 10000000 : 395
As then Manual Has then Value: 10000000 : 3282

Что мы можем сделать из этих цифр?

  • Во-первых, подход, основанный на подходе, значительно быстрее, чем как. 303 против 3524
  • Во-вторых, значение будет немного медленнее, чем литье. 3524 против 3272
  • В-третьих,.HasValue немного медленнее, чем использование ручного (т.е. использование ). 3524 против 3282
  • В-четвертых, сравнение apple-to-apple (т.е. как назначение симулированного HasValue, так и преобразование моделируемого значения происходит вместе) между имитируемым как и реальным как, мы может видеть, что моделируется как по-прежнему значительно быстрее, чем реальный как. 395 против 3524
  • Наконец, основываясь на первом и четвертом выводах, что-то не так с как реализация ^ _ ^

Ответ 7

Чтобы этот ответ был актуальным, стоит упомянуть, что большая часть обсуждения на этой странице теперь спорна с С# 7.1 и .NET 4.7, которые поддерживают тонкий синтаксис, который также производит лучший код IL.

Оригинальный пример OP...

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    // ...use x.Value in here
}

становится просто...

if (o is int x)
{
    // ...use x in here
}

Я обнаружил, что одно из распространенных применений нового синтаксиса - это когда вы пишете .NET тип значения (т.е. struct в С#), который реализует IEquatable<MyStruct> ( как большинство должно). После реализации строго типизированного метода Equals(MyStruct other) вы можете теперь изящно перенаправить нетипизированное переопределение Equals(Object obj) (унаследованное от Object) следующим образом:

public override bool Equals(Object obj) => obj is MyStruct o && Equals(o);


Приложение: Код Release build IL для первых двух примеров функций, показанных выше в этом ответе (соответственно), приведен здесь. Несмотря на то, что код IL для нового синтаксиса действительно на 1 байт меньше, он в основном выигрывает, делая нулевые вызовы (против двух) и по возможности избегая операции unbox.

// static void test1(Object o, ref int y)
// {
//     int? x = o as int?;
//     if (x.HasValue)
//         y = x.Value;
// }

[0] valuetype [mscorlib]Nullable'1<int32> x
        ldarg.0
        isinst [mscorlib]Nullable'1<int32>
        unbox.any [mscorlib]Nullable'1<int32>
        stloc.0
        ldloca.s x
        call instance bool [mscorlib]Nullable'1<int32>::get_HasValue()
        brfalse.s L_001e
        ldarg.1
        ldloca.s x
        call instance !0 [mscorlib]Nullable'1<int32>::get_Value()
        stind.i4
L_001e: ret

// static void test2(Object o, ref int y)
// {
//     if (o is int x)
//         y = x;
// }

[0] int32 x,
[1] object obj2
        ldarg.0
        stloc.1
        ldloc.1
        isinst int32
        ldnull
        cgt.un
        dup
        brtrue.s L_0011
        ldc.i4.0
        br.s L_0017
L_0011: ldloc.1
        unbox.any int32
L_0017: stloc.0
        brfalse.s L_001d
        ldarg.1
        ldloc.0
        stind.i4
L_001d: ret

Для дальнейшего тестирования, подтверждающего мое замечание о производительности нового синтаксиса С# 7, превосходящего ранее доступные параметры, см. здесь (в частности, пример "D").

Ответ 8

У меня нет времени попробовать, но вы можете захотеть:

foreach (object o in values)
        {
            int? x = o as int?;

а

int? x;
foreach (object o in values)
        {
            x = o as int?;

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

Ответ 9

Я попытался построить конструкцию проверки типа

typeof(int) == item.GetType(), который работает так же быстро, как версия item is int, и всегда возвращает номер (выделение: даже если вы написали Nullable<int> в массив, вам нужно будет использовать typeof(int)). Вам также нужна дополнительная проверка null != item здесь.

Однако

typeof(int?) == item.GetType() остается быстрым (в отличие от item is int?), но всегда возвращает false.

Тип-конструкция в моих глазах - самый быстрый способ точной проверки типов, поскольку он использует RuntimeTypeHandle. Поскольку точные типы в этом случае не совпадают с нулевыми значениями, я полагаю, is/as должен выполнить дополнительную потерю внимания здесь, гарантируя, что это фактически экземпляр типа Nullable.

И честно: что ваш is Nullable<xxx> plus HasValue покупает вас? Ничего. Вы всегда можете перейти непосредственно к базовому (значению) типу (в данном случае). Вы либо получаете значение, либо "нет, а не экземпляр типа, о котором вы просите". Даже если вы написали (int?)null в массив, проверка типа вернет false.

Ответ 10

using System;
using System.Diagnostics;
using System.Linq;

class Test
{
    const int Size = 30000000;

    static void Main()
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i + 1] = "";
            values[i + 2] = 1;
        }

        FindSumWithCast(values);
        FindSumWithAsAndHas(values);
        FindSumWithAsAndIs(values);


        FindSumWithIsThenAs(values);
        FindSumWithIsThenConvert(values);

        FindSumWithLinq(values);



        Console.ReadLine();
    }

    static void FindSumWithCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int)o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Cast: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsAndHas(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As and Has: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }


    static void FindSumWithAsAndIs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (o is int)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As and Is: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }







    static void FindSumWithIsThenAs(object[] values)
    {
        // Apple-to-apple comparison with Cast routine above.
        // Using the similar steps in Cast routine above,
        // the AS here cannot be slower than Linq.



        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {

            if (o is int)
            {
                int? x = o as int?;
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("Is then As: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithIsThenConvert(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {            
            if (o is int)
            {
                int x = Convert.ToInt32(o);
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Is then Convert: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }



    static void FindSumWithLinq(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = values.OfType<int>().Sum();
        sw.Stop();
        Console.WriteLine("LINQ: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }
}

Выходы:

Cast: 10000000 : 456
As and Has: 10000000 : 2103
As and Is: 10000000 : 2029
Is then As: 10000000 : 1376
Is then Convert: 10000000 : 566
LINQ: 10000000 : 1811

[EDIT: 2010-06-19]

Примечание. Предыдущий тест был выполнен внутри VS, отладка конфигурации, используя VS2009, используя Core i7 (машина разработки компании).

Следующее было сделано на моей машине с использованием Core 2 Duo, используя VS2010

Inside VS, Configuration: Debug

Cast: 10000000 : 309
As and Has: 10000000 : 3322
As and Is: 10000000 : 3249
Is then As: 10000000 : 1926
Is then Convert: 10000000 : 410
LINQ: 10000000 : 2018




Outside VS, Configuration: Debug

Cast: 10000000 : 303
As and Has: 10000000 : 3314
As and Is: 10000000 : 3230
Is then As: 10000000 : 1942
Is then Convert: 10000000 : 418
LINQ: 10000000 : 1944




Inside VS, Configuration: Release

Cast: 10000000 : 305
As and Has: 10000000 : 3327
As and Is: 10000000 : 3265
Is then As: 10000000 : 1942
Is then Convert: 10000000 : 414
LINQ: 10000000 : 1932




Outside VS, Configuration: Release

Cast: 10000000 : 301
As and Has: 10000000 : 3274
As and Is: 10000000 : 3240
Is then As: 10000000 : 1904
Is then Convert: 10000000 : 414
LINQ: 10000000 : 1936