В коммутаторе и словаре для значения 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? (Это просто догадки)

Ответ 1

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

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

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

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

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

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

Ответ 2

Это хороший пример того, почему микро-тесты могут вводить в заблуждение. Компилятор С# генерирует разные ИЛ в зависимости от размера переключателя/случая. Итак, включите строку, подобную этой

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, изменяет IL, сгенерированный таким образом:

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 CS$0$0001
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: Таким образом, моральный дух истории - это просмотр реальных данных и профиля, а не попытка оптимизации на основе микро-тестов.

Ответ 3

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

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

Ответ 4

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

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

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

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

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

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

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

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

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

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

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

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