В 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 находится в точке доступа производительности вашего кода, и вы измерили с помощью профилировщика, что он оказывает ощутимое влияние на производительность, то изменение кода для использования собственного словаря является разумным компромиссом для повышения производительности.
написание кода для использования хэш-таблицы с самого начала, без измерения производительности, чтобы оправдать этот выбор, является чрезмерной инженерией, которая приведет к непостижимому коду с излишне высоким стоимость обслуживания. Сохранить Его Простым.