Сравниваем c# операторы ?: vs if-else vs switch

    Сегодня на ревью прилетела очередная фабрика животных:

    public static class AnimalsFactory
    {
        public static Animal CreateAnimalByTernaryOperator(bool isCat)
        {
            return isCat ? (Animal)new Cat() : new Dog();
        }
    }
    

    В который раз расстроился, что C# заставляет делать каст объекта Cat к Animal. Но пусть лучше будет каст, ведь через if-else оператор код получается ещё длиннее:
    public static class AnimalsFactory
    {
        public static Animal CreateAnimalByIfElseOperator(bool isCat)
        {
            if (isCat)
                return new Cat();
    
            return new Dog();
        }
    }
    

    Отвлечёмся на минуту от ревью и попробуем разобраться:

    • будет ли отличаться IL-код в этих примерах?
    • будет ли один из примеров в выигрыше по производительности?


    Ответ на первый вопрос — да, IL-код отличается, ниже покажу чем.

    Перейдём к вопросу о производительности. Качаем nuget-пакет для бенчмарков BenchmarkDotNet и пишем тест:

    public class AnimalFactoryPerformanceTests
    {
        [ParamsAllValues]
        public bool IsCat { get; set; }
    
        [Benchmark]
        public void CreateAnimalByTernaryOperator() =>
            AnimalsFactory.CreateAnimalByTernaryOperator(IsCat);
    
        [Benchmark]
        public void CreateAnimalByIfElseOperator() =>
            AnimalsFactory.CreateAnimalByIfElseOperator(IsCat);
    }
    

    Результаты бенчмарков:

    |                         Метод | IsCat |    Время |
    |------------------------------ |------ |---------:|
    | CreateAnimalByTernaryOperator | False | 1.357 ns |
    | CreateAnimalByTernaryOperator |  True | 1.655 ns |
    |------------------------------ |------ |---------:|
    |  CreateAnimalByIfElseOperator | False | 1.636 ns |
    |  CreateAnimalByIfElseOperator |  True | 1.360 ns |
    

    Удивительно, что «для собаки» быстрее работает тернарный, а «для кота» — if-else оператор.
    Смотрим на IL-код метода с тернарным оператором:

    CreateAnimalByTernaryOperator(bool isCat)
    {
        IL_0000: ldarg.0      // isCat
        IL_0001: brtrue.s     IL_0009
        IL_0003: newobj       instance void AnimalPerformance.Dog::.ctor()
        IL_0008: ret
        IL_0009: newobj       instance void AnimalPerformance.Cat::.ctor()
        IL_000e: ret
    }
    

    При создании объекта Dog последовательно выполнятся команды IL_0000 — IL_0008, в то время как при создании объекта Cat происходит условный переход (IL_0001: brtrue.s IL_0009).

    Как можно догадаться, для if-else оператора генерируется IL-код, который не требует условных переходов для создания объекта Cat. В то время как объект Dog создаётся через условный переход:

    CreateAnimalByIfElseOperator(bool isCat)
    {
        IL_0000: ldarg.0      // isCat
        IL_0001: brfalse.s    IL_0009
        IL_0003: newobj       instance void AnimalPerformance.Cat::.ctor()
        IL_0008: ret
        IL_0009: newobj       instance void AnimalPerformance.Dog::.ctor()
        IL_000e: ret
    }
    

    Добавим в фабрику создание «Попугая» и новый метод с оператором switch:

    public static class AnimalFactory
    {
        public static Animal CreateAnimalByTernaryOperator(AnimalType animalType)
        {
            return animalType == AnimalType.Cat
                ? new Cat()
                : animalType == AnimalType.Dog
                    ? (Animal)new Dog()
                    : new Parrot();
        }
    
        public static Animal CreateAnimalByIfElseOperator(AnimalType animalType)
        {
            if (animalType == AnimalType.Cat)
                return new Cat();
    
            if (animalType == AnimalType.Dog)
                return new Dog();
    
            return new Parrot();
        }
    
        public static Animal CreateAnimalBySwitchOperator(AnimalType animalType)
        {
            switch (animalType)
            {
                case AnimalType.Cat:
                    return new Cat();
                case AnimalType.Dog:
                    return new Dog();
                case AnimalType.Parrot:
                    return new Parrot();
                default:
                    throw new InvalidOperationException();
            }
        }
    }
    

    Какой из методов окажется быстрее?

    Результаты бенчмарков
    |                         Метод | AnimalType |    Время |
    |------------------------------ |----------- |---------:|
    | CreateAnimalByTernaryOperator |        Cat | 2.490 ns |
    | CreateAnimalByTernaryOperator |        Dog | 2.515 ns |
    | CreateAnimalByTernaryOperator |     Parrot | 2.333 ns |
    |------------------------------ |----------- |---------:|
    |  CreateAnimalByIfElseOperator |        Cat | 2.368 ns |
    |  CreateAnimalByIfElseOperator |        Dog | 2.545 ns |
    |  CreateAnimalByIfElseOperator |     Parrot | 2.735 ns |
    |------------------------------ |----------- |---------:|
    |  CreateAnimalBySwitchOperator |        Cat | 2.747 ns |
    |  CreateAnimalBySwitchOperator |        Dog | 2.730 ns |
    |  CreateAnimalBySwitchOperator |     Parrot | 2.722 ns |
    


    IL-код
    CreateAnimalByTernaryOperator(AnimalsFactory.AnimalType animalType)
    {
        IL_0000: ldarg.0      // animalType
        IL_0001: brfalse.s    IL_0013
        IL_0003: ldarg.0      // animalType
        IL_0004: ldc.i4.1
        IL_0005: beq.s        IL_000d
        IL_0007: newobj       instance void AnimalsFactory.Parrot::.ctor()
        IL_000c: ret
        IL_000d: newobj       instance void AnimalsFactory.Dog::.ctor()
        IL_0012: ret
        IL_0013: newobj       instance void AnimalsFactory.Cat::.ctor()
        IL_0018: ret
    }
    
    CreateAnimalByIfElseOperator(AnimalsFactory.AnimalType animalType)
    {
        IL_0000: ldarg.0      // animalType
        IL_0001: brtrue.s     IL_0009
        IL_0003: newobj       instance void AnimalsFactory.Cat::.ctor()
        IL_0008: ret
        IL_0009: ldarg.0      // animalType
        IL_000a: ldc.i4.1
        IL_000b: bne.un.s     IL_0013
        IL_000d: newobj       instance void AnimalsFactory.Dog::.ctor()
        IL_0012: ret
        IL_0013: newobj       instance void AnimalsFactory.Parrot::.ctor()
        IL_0018: ret
    
    }
    
    CreateOtherAnimalBySwitchOperator(AnimalsFactory.AnimalType animalType)
    {
        IL_0000: ldarg.0      // animalType
        IL_0001: switch       (IL_0014, IL_001a, IL_0020)
        IL_0012: br.s         IL_0026
        IL_0014: newobj       instance void AnimalsFactory.Cat::.ctor()
        IL_0019: ret
        IL_001a: newobj       instance void AnimalsFactory.Dog::.ctor()
        IL_001f: ret
        IL_0020: newobj       instance void AnimalsFactory.Parrot::.ctor()
        IL_0025: ret
        IL_0026: newobj       instance void System.InvalidOperationException::.ctor()
        IL_002b: throw
    }
    


    Вывод 1: Для тернарного и if-else операторов время работы напрямую зависит от количества условных переходов, которые прозошли в потоке выполнения.

    Вывод 2: C# оператор switch преобразуется в IL-коде в инструкцию switch и в среднем работает немного дольше, чем обычные операторы ветвления.

    Вывод 3: Всё, что выше, актуально только для .NET Framework v.4.8. Прогнав те же самые тесты на .NetCore получились совсем другие результаты, которые ещё предстоит как-то интерпретировать.

    Результаты бенчмарков .NetCore 3.0
    |                         Метод | AnimalType |    Время |
    |------------------------------ |----------- |---------:|
    | CreateAnimalByTernaryOperator |        Cat | 3.046 ns |
    | CreateAnimalByTernaryOperator |        Dog | 2.984 ns |
    | CreateAnimalByTernaryOperator |     Parrot | 3.019 ns |
    |------------------------------ |----------- |---------:|
    |  CreateAnimalByIfElseOperator |        Cat | 2.980 ns |
    |  CreateAnimalByIfElseOperator |        Dog | 2.977 ns |
    |  CreateAnimalByIfElseOperator |     Parrot | 3.103 ns |
    |------------------------------ |----------- |---------:|
    |  CreateAnimalBySwitchOperator |        Cat | 3.519 ns |
    |  CreateAnimalBySwitchOperator |        Dog | 3.533 ns |
    |  CreateAnimalBySwitchOperator |     Parrot | 3.312 ns |
    


    Процессор: Intel® Core(TM) i7-7700K

    Исходники
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 14

      +1
      Мне каждый раз сложно читать результаты бенчмарков, потому что это просто туча цифр и какие то названия рядом.
      if else быстрее всех в обоих рантаймах, я правильно понял?
        0
        Можно сказать, что if-else и ?: одинаково быстрые в обоих рантаймах. Но в результате разной трансляции в IL-код могут иметь разную скорость в зависимости от условия.
        +2

        У вас не совсем одинаковые методы. В случае с ветвлением у вас попугай — это по сути дефолтная ветка (вы не делаете проверку на попугая), а вот в switch блоке делается дополнительная проверка и есть дополнительный дефолтный кейс с исключением. Ожидаемо switch медленнее.
        Еще было бы интересно померить switch-expression. Если сделать попугая дефолтной веткой везде, switch генерируют похожий код (хотя expression имеет дополнительную локальную переменную), см. SharpLab.


        UPD: В примере я явно указал базовый тип перечисления как byte, что привело к возникновению разных неоптимальных asm-инструкций (что странно). Оставив тип перечисления по умолчанию (int32), asm-код для обоих switch оказался идентичным (как и ожидалось). Ссылка заменена.

          +3

          Могу сразу сказать, что если у вас где-то встретилось


          public static Animal CreateAnimalByTernaryOperator(bool isCat)
          {
              return isCat ? (Animal)new Cat() : new Dog();
          }

          То где-то вы свернули не туда. В очень редких случаях такое бывает нужно, но тогда нужно заводить нормальный энум enum AnimalType { Cat, Dog } с самого начала, а не ждать пока появятся попугаи.


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

            0
            Ну как — наверняка есть наиболее вероятный исход, который можно поставить по более быстрой ветке, но надо учитывать, что в тернарном операторе это false ветка, а в if-else, наоборот :)
              0

              Какая-то уж совсем преждевременная оптимизация получается.
              На фоне выделений памяти и прочего.

                0
                На то и смайлик. Но чем чёрт не шутит ;)
                0

                Во-первых, есть бранч предиктор, который снизит стоимость джампа почти до нуля (если мы предполагаем. что true/false приходит достаточно стабильно, если нет, то какая ветка дефолтная уже не важно).
                Во-вторых если мне будет жалко одного такта на джамп то я скорее поменяю ветки if/else (есть автоматический рефакторинг в студии и райдере), чем буду тернарник делать. Тем более, что он не умеет во что-то чуть более сложное чем 2 экспрешна.
                Ну а в-третьих в языке с ГЦ и миллионом ненужных аллокаций это всё курам на смех. LINQ в 10 раз медленнее циклов, но все пишут на них (кроме избранных, которые и верстку рендерят на стрингбилдерах).


                Есть некоторая грань, до которой перфоманс можно получать бесплатно, просто грамотно продумывая архитектуру, а за ней начинается безумие, когда лучше взять какой нибудь раст и иметь 1500% буст перфоманса без пляски над тем что джит выплюнул. Тем более, что это нигде не документировано, так что вчера там был brtrue, а завтра — brfalse. Да еще и tiered JIT есть, который может увидеть паттерн и спокойно перекомпилировать, поменяв ветки местами.

              0
              не уверен насчет шарпа. Но в Си switch на большом кол-ве условий будет быстрей. Так как это по сути превращается в таблицу безусловных переходов
                +1
                Что вы такое пишете что дошли до измерения производительности основных конструкций языка? По моему это бесполезные измерения… Premature optimization во всей красе…
                  0
                  Лично у меня был исключительно академический интерес, который возник с вопроса: «Равнозначен ли будет код через ?: и if-else?» В реальных проектах, безусловно, эти оптимизации не имеют никакого значения. Как писали выше, бранч предиктор всё равно сведёт выигрыш от них до нуля.
                  0

                  .NetCore 3.0, выходит, на ~25% медленней?

                    0
                    Я думаю, что делать такой вывод будет не совсем корректно. Возможно, что BenchmarkDotNet в .NetCore делает замеры немного иначе, чем в фреймворке.

                    Так как IL-код для .NetCore и .NET Framework одинаковый, надо посмотреть, какие команды процессору получаются в результате JIT-компиляции. Но для этого нужно читать исходники JIT'а, что, скорее всего, не самое простое занятие.
                      0
                      IL-код не имеет никакого значения при замере быстродействия

                  Only users with full accounts can post comments. Log in, please.