На собеседованиях мы слышим или говорим сами, что поиск в массиве медленнее, чем в хеш-таблице. Кто-то даже вспоминает, что поиск в массиве имеет линейную сложность или O(n), а в хеш-таблице — константную O(1). Но работает ли это на практике? Что, если есть ситуации, когда поиск в массиве оказывается быстрее? Давайте не будем торопиться с выводами.

Итак, сравним поиск в массиве и HashSet на малых коллекциях, возьмём разные типы — примитивный int и ссылочный string.

Полный код бенчмарка. Нажмите, чтобы развернуть.
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace Benchmarks
{
    [ShortRunJob(RuntimeMoniker.Net60)]
    [ShortRunJob(RuntimeMoniker.Net80)]
    [ShortRunJob(RuntimeMoniker.Net10_0)]
    [HideColumns("Job", "Error", "StdDev", "Gen0", "Median")]
    [DisassemblyDiagnoser(printSource: true, maxDepth: 2)]
    public class HashSetContainsBenchmark
    {
        [Params(1, 10, 100, 1000)]
        public int Size { get; set; }

        private int[] _arrayInt;
        private HashSet<int> _hashSetInt;
        private int _missingInt;

        private string[] _arrayString;
        private HashSet<string> _hashSetString;
        private string _missingString;

        [GlobalSetup]
        public void Setup()
        {
            // --- Int Setup ---
            var intData = Enumerable.Range(0, Size).ToArray();
            _arrayInt = intData;
            _hashSetInt = new HashSet<int>(intData);
            _missingInt = Size + 1;

            // --- String Setup ---
            var stringData = Enumerable.Range(0, Size).Select(i => $"value-{i}").ToArray();
            _arrayString = stringData;
            _hashSetString = new HashSet<string>(stringData);
            _missingString = "value-a";
        }

        // --- Int Benchmarks ---
        [Benchmark(Description = "Array.Contains (Int)")]
        public bool Int_Array() => _arrayInt.Contains(_missingInt);

        [Benchmark(Description = "HashSet.Contains (Int)")]
        public bool Int_HashSet() => _hashSetInt.Contains(_missingInt);

        // --- String Benchmarks ---
        [Benchmark(Description = "Array.Contains (String)")]
        public bool String_Array() => _arrayString.Contains(_missingString);

        [Benchmark(Description = "HashSet.Contains (String)")]
        public bool String_HashSet() => _hashSetString.Contains(_missingString);
    }
}
| Method                        | Runtime   | Size | Mean        | Code Size |
|------------------------------ |---------- |----- |------------:|----------:|
| 'Array.Contains (Int)'        | .NET 6.0  | 1    |   3.443 ns  |      50 B |
| 'HashSet.Contains (Int)'      | .NET 6.0  | 1    |   2.052 ns  |     447 B |
| 'Array.Contains (String)'     | .NET 6.0  | 1    |   8.621 ns  |      57 B |
| 'HashSet.Contains (String)'   | .NET 6.0  | 1    |   4.741 ns  |     737 B |
| 'Array.Contains (Int)'        | .NET 8.0  | 1    |   2.676 ns  |      40 B |
| 'HashSet.Contains (Int)'      | .NET 8.0  | 1    |   1.954 ns  |     385 B |
| 'Array.Contains (String)'     | .NET 8.0  | 1    |   9.639 ns  |      51 B |
| 'HashSet.Contains (String)'   | .NET 8.0  | 1    |   4.599 ns  |     351 B |
| 'Array.Contains (Int)'        | .NET 10.0 | 1    |   1.193 ns  |     669 B |
| 'HashSet.Contains (Int)'      | .NET 10.0 | 1    |   1.666 ns  |     377 B |
| 'Array.Contains (String)'     | .NET 10.0 | 1    |   2.285 ns  |     548 B |
| 'HashSet.Contains (String)'   | .NET 10.0 | 1    |   3.698 ns  |     233 B |

Интуитивно кажется, что перебрать 1 элемент из массива должно быть быстрее, чем более сложный механизм с поиском по хэшу. Как видим, даже при поиске в коллекции из одного int массив проигрывает HashSet'у до .NET 10:

Время поиска в ns, size = 1.
Время поиска в ns, size = 1.

И то, преимущество совсем небольшое.

Для строк наблюдается такая же картина:

Время поиска в ns, size = 1.
Время поиска в ns, size = 1.

Давайте запомним это на будущее.

Ну ладно, коллекции из 1 элемента — это странные коллекции. Давайте всё-таки возьмём побольше — 10 элементов. Там-то преимущество HashSet должно стать более очевидным:

| Method                        | Runtime   | Size | Mean        | Code Size |
|------------------------------ |---------- |----- |------------:|----------:|
| 'Array.Contains (Int)'        | .NET 6.0  | 10   |   4.059 ns  |      50 B |
| 'HashSet.Contains (Int)'      | .NET 6.0  | 10   |   2.901 ns  |     447 B |
| 'Array.Contains (String)'     | .NET 6.0  | 10   |  33.465 ns  |      57 B |
| 'HashSet.Contains (String)'   | .NET 6.0  | 10   |   5.807 ns  |     737 B |
| 'Array.Contains (Int)'        | .NET 8.0  | 10   |   3.137 ns  |      40 B |
| 'HashSet.Contains (Int)'      | .NET 8.0  | 10   |   2.497 ns  |     397 B |
| 'Array.Contains (String)'     | .NET 8.0  | 10   |  31.164 ns  |      51 B |
| 'HashSet.Contains (String)'   | .NET 8.0  | 10   |   5.937 ns  |     377 B |
| 'Array.Contains (Int)'        | .NET 10.0 | 10   |   1.639 ns  |     729 B |
| 'HashSet.Contains (Int)'      | .NET 10.0 | 10   |   2.230 ns  |     377 B |
| 'Array.Contains (String)'     | .NET 10.0 | 10   |  18.063 ns  |     548 B |
| 'HashSet.Contains (String)'   | .NET 10.0 | 10   |   4.583 ns  |     233 B |

Уже видите странности? Вот они:

Время поиска в ns, size = 10.
Время поиска в ns, size = 10.

Время поиска в HashSet остаётся примерно одинаковым независимо от фреймворка, а вот поиск в массиве всё улучшается и улучшается. Более того, в .NET 10 массив обгоняет HashSet! Как так? Неужели поиск в массиве тоже теперь работает за константное время? Кажется, мы на пороге грандиозного открытия. Давайте проверим нашу гипотезу на коллекциях из 100 элементов:

| Method                        | Runtime   | Size | Mean        | Code Size |
|------------------------------ |---------- |----- |------------:|----------:|
| 'Array.Contains (Int)'        | .NET 6.0  | 100  |  13.073 ns  |      50 B |
| 'HashSet.Contains (Int)'      | .NET 6.0  | 100  |   2.116 ns  |     447 B |
| 'Array.Contains (String)'     | .NET 6.0  | 100  | 168.924 ns  |      57 B |
| 'HashSet.Contains (String)'   | .NET 6.0  | 100  |   8.306 ns  |     737 B |
| 'Array.Contains (Int)'        | .NET 8.0  | 100  |   4.381 ns  |      40 B |
| 'HashSet.Contains (Int)'      | .NET 8.0  | 100  |   1.857 ns  |     385 B |
| 'Array.Contains (String)'     | .NET 8.0  | 100  |  93.214 ns  |      51 B |
| 'HashSet.Contains (String)'   | .NET 8.0  | 100  |   7.938 ns  |     377 B |
| 'Array.Contains (Int)'        | .NET 10.0 | 100  |   3.401 ns  |     736 B |
| 'HashSet.Contains (Int)'      | .NET 10.0 | 100  |   1.774 ns  |     377 B |
| 'Array.Contains (String)'     | .NET 10.0 | 100  |  71.121 ns  |     562 B |
| 'HashSet.Contains (String)'   | .NET 10.0 | 100  |   6.806 ns  |     233 B |

К сожалению, чуда не случилось. Теперь массив стабильно проигрывает в поиске HashSet'-у. Но разница для типа int всё ещё не такая существенная:

Время поиска в ns, size = 100.
Время поиска в ns, size = 100.

Давайте посмотрим на рост времени поиска в массиве относительно его размера. Для наглядности добавим ещё массив в 1000 элементов:

Время поиска в ns.
Время поиска в ns.

Как видим, только в .NET 6 начинаем наблюдать линейный рост после 100 элементов. А в 8-й и 10-й версиях рост если не логарифмический, то сильно сглажен.

Пора погружаться глубже, а именно в то, как именно осуществляется поиск по массиву. Вызов метода Contains для массива в конечном итоге приводит к вызову статического метода Array.IndexOf. А вот дальше начинаются различия.

.NET 6

Используя некоторые оптимизации по раскрутке цикла и быстрому доступу к элементам массива .NET проверяет каждый элемент на равенство:

...
while (length >= 8)
{
    length -= 8;
 
    if (value.Equals(Unsafe.Add(ref searchSpace, index)))
        goto Found;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 1)))
        goto Found1;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 2)))
        goto Found2;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 3)))
        goto Found3;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 4)))
        goto Found4;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 5)))
        goto Found5;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 6)))
        goto Found6;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 7)))
        goto Found7;
 
    index += 8;
}
// далее проверяется остаток массива меньше 8 элементов

Как видите, мы последовательно проверяем каждый элемент и получаем наши честные O(N) трудоёмкость. Пока ничего необычного.

.NET 8

С этой версии ситуация меняется кардинально. В дело вступает метод SpanHelpers.NonPackedIndexOfValueType, который помимо раскрутки цикла содержит ещё мощную аппаратную оптимизацию — векторизацию:

if (Vector512.IsHardwareAccelerated && length >= Vector512<TValue>.Count)
{
  Vector512<TValue> left = Vector512.Create<TValue>(value);
  //...
}
else if (Vector256.IsHardwareAccelerated && length >= Vector256<TValue>.Count)
{
  Vector256<TValue> left = Vector256.Create<TValue>(value);
  //...
}
else
{
  Vector128<TValue> left = Vector128.Create<TValue>(value);
  //...
}

Мы видим использование новых типов Vector128Vector256Vector512 — коллекции для работы с векторными данными. И обратите внимание на свойство IsHardwareAccelerated, которое проверяется в каждом условии — в этом и есть вся магия. Оно определяет, поддерживает ли ваш процессор работу с векторами длиной 128, 256 и 512 бит соответственно.

Использование класса Vector512 позволяет оперировать сразу 16 элементами массива типа int. 512 бит = 64 байта, значит, в вектор помещается 16 значений типа int по 4 байта.

К сожалению, теперь придётся погрузиться в ассемблер, чтобы лучше понять, как это работает:

cmp          r8d,4              
jl           near ptr M01_L03    ; Если элементов < 4, то обрабатываем поштучно
cmp          r8d,10
jl           near ptr M01_L18    ; Если элементов < 16, то переходим обработку с помощью 256-битного регистра (ymm)
vpbroadcastd zmm0,edx            ; В 512-битный регистр zmm0 загружаем искомое значение edx. Теперь zmm0 выглядит как [edx, edx, edx, ..., edx]
...
vmovups      zmm1,[rcx]          ; Загружаем первые 16 элементов из памяти в 512-битный регистр zmm1
vpcmpeqd     k1,zmm1,zmm0        ; Сравниваем zmm1 и zmm0 на равенство
kortestw     k1,k1               ; Проверяем, есть ли хотя бы одно совпадение
...
  • vpcmpeqd — инструкция, которая сравнивает 2 вектора поэлементно и сохраняет результат в специальный регистр k1. Если 5-й элемент совпал, то 5-й бит в k1станет равен 1.

  • kortestw проверяет эту битовую маску. Если она равна 0 — значит, в первых 16 элементах искомого числа нет.

На первый взгляд выглядит сложно, но, по сути — это продолжение концепции раскрутки цикла, но уже на уровне «железа». А новые версии фреймворка скрывают от нас всю сложность таких оптимизаций. В итоге, никакой магии и поиск по массиву всё ещё O(N). Даже если это O(N/16).

Эта магия работает только на современных процессорах, поддерживающих инструкции AVX-512 (или AVX-2 для 256 бит). Если вы запустите код на старом железе, .NET плавно откатится до Vector128 или обычного цикла.

.NET 10

Напомню, что в этой версии результаты для массива длиной 10 стали еще лучше и даже обгоняют HashSet. При этом, сам алгоритм поиска не поменялся, код тот же самый. Регистров более 512 пока не изобрели. В чём же дело? Предположу, что сработал PGO и компилятор решил, что чаще всего поиск будет в средних массивах (8-15 элементов), поменял блоки сравнения местами, чтобы не делать лишних прыжков и сэкономить ещё пару тактов процессору.

А помните, мы там вначале запоминали, почему поиск по массиву строк стал так хорош в .NET 10? Настало время разобраться и с этим.

Давайте ещё раз посмотрим на бенчмарк:

| Method                      | Runtime   | Size | Mean       | Code Size |
|---------------------------- |---------- |----- |-----------:|----------:|
| 'Array.Contains (String)'   | .NET 8.0  | 1    |  9.639 ns  |      51 B |
| 'Array.Contains (String)'   | .NET 10.0 | 1    |  2.285 ns  |     548 B |

Помимо почти четырёхкратной разницы в скорости мы видим кратную разницу в размере кода (Code Size). Для .NET 8 компилятор долго не думал и вызов метода Contains перенаправил в generic-метод Array.IndexOf, который будет для каждого элемента массива вызывать виртуальный метод bool Equals(object obj). Надёжно, но не быстро.

А вот в .NET 10 компилятор применил новую технику под названием «девиртуализация», если точнее, то это Guarded Devirtualization. Он понял, что наша коллекция — это массив строк и только строк. А значит, можно пропустить все эти generic-вызовы и прыжки по таблице виртуальных методов и просто заинлайнить весь цикл сравнения строк. Отсюда мы и получаем 548 байт кода против 51. Но! Мы получаем специфичный код поиска строки в массиве, который работает намного быстрее. Чтобы это увидеть опять же обратимся к ассемблерному коду:

...
mov       r8,offset MT_System.String 
cmp       [rcx],r8                   ; Проверяем, что у нас точно строка. Поэтому и Guarded Devirtualization.
jne       short M00_L08              ; Иначе сравниваем через классический Equals
...
mov       r8d,[rcx+8]                ; Читаем длину текущей строки
cmp       r8d,[rsi+8]                ; Сравниваем с длиной искомой строки
je        short M00_L07              ; Если длины совпали - побайтово сравниваем сами строки
...
M00_L07:
lea       rax,[rcx+0C]
...
lea       rdx,[rsi+0C]                ; Получаем указатель на массив байт, в которых хранятся строки
mov       rcx,rax
call      qword ptr [7FFD2A46C330]    ; System.SpanHelpers.SequenceEqual - вызываем метод, который сравниваем 2 массива байт
...

При этом, SequenceEqual так же поддерживает векторные операции и аппаратное ускорение. Ух! Вот это мощь! Я даже не догадывался, какая огромная работа скрывается за простым поиском строки в массиве.

Выводы

Опять напрашивается очевидный вывод — простое обновление .NET позволит получить прирост производительности (а значит, и экономии на ресурсах). Ну и никакой магии не существует, O(N) — это всё еще O(N), что прекрасно видно на больших значениях. А вот на малых (5-10 элементов) всё может быть не так однозначно.

А можно ли лучше? Чтобы и не O(N), хоть и с аппаратным ускорением, но и не тратиться постоянно на хеширование? Посмотрим…

Анализатор

В свой анализатор я добавил правило, которое подскажет вам, что можно заменить массив (или список) на HashSet, если на нём вызывается Contains. Это точно будет полезно для версий .NET 8 или ниже. Для более тонкой настройки можно отрегулировать порог срабатывания в editor.config, например, только от 5 элементов:

dotnet_diagnostic.CI0008.min_items_count = 5