В switch vs dictionary для значения Func, которое быстрее и почему?

допустим есть следующий код:

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), который не может быть случай для оператора switch? (Это только догадки)

4 ответов


короткий ответ заключается в том, что оператор switch выполняется линейно, в то время как словарь выполняет логарифмически.

на уровне IL небольшой оператор switch обычно реализуется как серия операторов if-elseif, сравнивающих равенство переключаемой переменной и каждого случая. Таким образом, этот оператор будет выполняться во времени, линейно пропорциональном количеству допустимых опций для myVar; случаи будут сравниваться в порядке их появления, и худший сценарий заключается в том, что все сравнения опробованы, и либо последний соответствует, либо нет. Итак, с 32 вариантами худший случай заключается в том, что это ни один из них, и код сделает 32 сравнения, чтобы определить это.

словарь, с другой стороны, использует оптимизированную по индексу коллекцию для хранения значений. В .NET словарь основан на хэш-таблице, которая имеет эффективно постоянное время доступа (недостатком является чрезвычайно низкая эффективность пространства). Другие параметры, обычно используемые для" сопоставления " коллекций подобные словари включают сбалансированные древовидные структуры, такие как красно-черные деревья, которые обеспечивают логарифмический доступ (и эффективность линейного пространства). Любой из них позволит коду найти ключ, соответствующий правильному "случаю" в коллекции (или определить, что он не существует), намного быстрее, чем оператор switch может сделать то же самое.

редактировать: другие ответы и комментаторы коснулись этого, поэтому в интересах полноты я также буду. Компилятор Майкрософт не всегда компилируйте переключатель в if / elseif, как я предполагал изначально. Он обычно делает это с небольшим количеством случаев и / или с" разреженными " случаями (не инкрементные значения, такие как 1, 200, 4000). При больших наборах смежных случаев компилятор преобразует коммутатор в" таблицу переходов " с помощью оператора CIL. С большими наборами разреженных случаев компилятор может реализовать двоичный поиск для сужения поля, а затем "провалить" небольшое количество разреженных случаев или реализовать прыжок таблица для смежных случаев.

однако компилятор обычно выбирает реализацию, которая является лучшим компромиссом производительности и эффективности пространства, поэтому он будет использовать только таблицу перехода для большого количества плотно упакованных случаев. Это связано с тем, что таблица переходов требует места в памяти в порядке диапазона случаев, которые она должна охватывать, что для разреженных случаев ужасно неэффективно с точки зрения памяти. Используя словарь в исходном коде, вы в основном заставляете компилятор; это будет делать это по-вашему, а не компрометировать производительность, чтобы получить эффективность памяти.

Итак, я ожидаю, что в большинстве случаев оператор switch или словарь могут использоваться в source для лучшей работы при использовании словаря. Большое количество случаев в операторах switch следует избегать в любом случае, поскольку они менее ремонтопригодны.


это хороший пример того, почему микро-бенчмарки могут вводить в заблуждение. Компилятор 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, изменения Ил генерируется что-то вроде этого для каждого:

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 CS01
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: Итак, моральный дух истории посмотреть на реальные данные и профиль вместо того, чтобы пытаться оптимизировать на основе микро-ориентиры.


по умолчанию переключатель строки реализуется как конструкция if / else / if / else. Как предложил Брайан, компилятор преобразует коммутатор в хэш-таблицу, когда он станет больше. Барт Де Смет показывает это в этом видео channel9, (переключатель обсуждается в 13: 50)

компилятор не делает этого для 4 элементов, потому что он консервативен, чтобы предотвратить, что стоимость оптимизации перевешивает преимущества. Создание hashtable стоит немного времени и память.


Как и во многих вопросах, связанных с решениями компилятора codegen, ответ "это зависит".

создание собственной хэш-таблицы, вероятно, будет работать быстрее, чем сгенерированный компилятором код во многих случаях, потому что компилятор имеет другие показатели затрат, которые он пытается сбалансировать, что вы не: в первую очередь, потребление памяти.

хэш-таблица будет использовать больше памяти, чем несколько инструкций if-then-else IL. Если компилятор выплевывает хэш-таблицу для каждого оператора switch в программе использование памяти взорвется.

по мере увеличения количества блоков case в инструкции switch вы, вероятно, увидите, что компилятор создает другой код. С большим количеством случаев компилятор имеет больше оснований отказаться от небольших и простых шаблонов if-then-else в пользу более быстрых, но более толстых альтернатив.

Я не знаю, выполняют ли компиляторы C# или JIT эту конкретную оптимизацию, но общий трюк компилятора для операторов switch, когда селекторы case много и в основном последовательны для вычисления вектора перехода. Для этого требуется больше памяти (в виде таблиц переходов, созданных компилятором, встроенных в поток кода), но выполняется в постоянное время. Вычесть arg - "a", использовать результат в качестве индекса в таблицу перехода, чтобы перейти к соответствующему блоку case. Бум, вы сделали, независимо от того, есть ли 20 или 2000 случаев.

компилятор с большей вероятностью перейдет в режим таблицы перехода, когда тип селектора переключателя-char или int или enum и значения селекторов регистров в основном последовательные ("плотные"), так как эти типы можно легко вычесть для создания смещения или индекса. Селекторы строк немного сложнее.

селекторы строк "интернируются" компилятором C#, что означает, что компилятор добавляет значения селекторов строк во внутренний пул уникальных строк. Адрес или токен интернированной строки можно использовать в качестве ее идентификатора, что позволяет оптимизировать int-like при сравнении интернированных строки для равенства identity / byte-wise. При наличии достаточного количества селекторов вариантов компилятор C# создаст код IL, который ищет интернированный эквивалент строки arg (поиск хэш-таблицы), а затем сравнивает (или таблицы переходов) интернированный маркер с предварительно вычисленными маркерами селектора вариантов.

Если вы можете уговорить компилятор создать код таблицы переходов в случае селектора char/int / enum, это может выполняться быстрее, чем с помощью вашей собственной хэш-таблицы.

для селектора строк в случае, код IL все еще должен выполнять поиск хэша, поэтому любая разница в производительности от использования вашей собственной хэш-таблицы, вероятно, стирается.

в целом, однако, вы не должны слишком подробно останавливаться на этих нюансах компилятора при написании кода приложения. Операторы Switch обычно намного легче читать и понимать, чем хэш-таблицу указателей функций. Операторы Switch, которые достаточно велики, чтобы перевести компилятор в режим таблицы перехода, часто слишком велики, чтобы быть человеческими читаемый.

Если вы обнаружите, что оператор switch находится в точке доступа производительности вашего кода, и вы измерили с помощью профилировщика, что он оказывает ощутимое влияние на производительность, то изменение кода для использования собственного словаря является разумным компромиссом для повышения производительности.

написание кода для использования хэш-таблицы с самого начала, без измерения производительности, чтобы оправдать этот выбор, является чрезмерной инженерией, которая приведет к непостижимому коду с излишне высоким стоимость обслуживания. Сохранить Его Простым.