В этой статье я хочу рассказать, сколько времени уходит на сортировку строк, когда компаратор не передан. Array.Sort(string[]) без компаратора сравнивает строки так, как они шли бы в словаре: с учётом языка, регистра и букв с надстрочными знаками. Это заметно дольше сравнения по кодам символов — на тысяче строк в 5,00–11,71 раза.

Будем замерять следующие варианты:

  • без компаратора — Array.Sort(a);

  • через StringComparer.CurrentCulture и StringComparer.InvariantCulture;

  • через StringComparer.Ordinal и StringComparer.OrdinalIgnoreCase;

  • сравнение по кодам символов через делегат — string.CompareOrdinal;

  • те же варианты у List.Sort, Span.Sort и OrderBy;

  • на 5-ти наборах строк: числа, слова из английских букв, строки с одинаковым началом, слова с надстрочными знаками и строки по 128 символов.

Будет 2 истории:

  • сколько занимает одно сравнение и почему дело не в сортировке;

  • что будет, если поменять набор строк или способ сортировки.

Машина

Процессор

Система

Комп 1

Intel Core i9-10900KF 3.70GHz, 10 ядер

Windows 10 22H2

Комп 2

AMD Ryzen 9 5950X 3.39GHz, 16 ядер

Windows 10 1809

Комп 3

Intel Xeon W-2255 3.70GHz, 10 ядер

Windows Server 2022

Комп 4

Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра

Windows Server 2022

Рантаймы: 8.0.11–8.0.30, 9.0.4–9.0.19, 10.0.1–10.0.11, 11.0.0 preview. BenchmarkDotNet 0.15.8

.NET 11 здесь — предварительная сборка. К релизу числа могут измениться.

История 1. Куда уходит время

Две строки ниже сортируют один и тот же массив. Разница между ними — один аргумент.

// сравнение по правилам языка
Array.Sort(names);

// сравнение по кодам символов
Array.Sort(names, StringComparer.Ordinal);

Массив из 10 000 английских слов, .NET 10. Первая строка таблицы показывает, сколько занимает копия массива. Копию делает каждый вариант, поэтому это время учтено и в остальных строках.

Чем задан порядок

Комп 1, мкс

Комп 2, мкс

Комп 3, мкс

Комп 4, мкс

Память

копия массива без сортировки

4,09

2,89

8,29

5,80

78,15 КБ

без компаратора

4 788,84

4 634,36

6 607,53

6 731,95

78,15 КБ

CurrentCulture

4 070,42

4 034,92

5 545,05

5 739,46

78,21 КБ

InvariantCulture

4 102,68

3 884,93

5 569,20

5 803,89

78,21 КБ

Ordinal

1 214,38

1 003,47

1 673,43

2 073,24

78,21 КБ

OrdinalIgnoreCase

1 424,79

1 277,66

2 001,62

2 422,15

78,21 КБ

string.CompareOrdinal

1 147,50

1 171,55

1 496,15

1 938,42

78,15 КБ

Сортировка 10 000 строк, .NET 10. Без компаратора она занимает в 3,25–4,62 раза больше времени, чем с Ordinal

Числа в тексте округлены так же, как в таблицах: до двух знаков после запятой.

Делегат и StringComparer.Ordinal работают одинаково: отношение 0,86–1,12, а на одной машине из четырёх делегат даже медленнее. Как именно передан порядок — делегатом или объектом — на время не влияет.

Первое, что стоит проверить, — одинаково ли работает сортировка. Может оказаться, что с одним компаратором она сравнивает элементы чаще, чем с другим, и тогда дело не в сравнении.

Для проверки компаратор оборачивается в счётчик, который считает свои вызовы. В обоих случаях сортировка обращается к компаратору через интерфейс IComparer<string>, поэтому путь до сравнения одинаковый и числа можно сравнивать между собой.

public sealed class CountingComparer(IComparer<string> inner) : IComparer<string>
{
    public int Compare(string? x, string? y)
    {
        CallCounter.Count();

        return inner.Compare(x, y);
    }
}

Число сравнений на 10 000 строк, .NET 10.

Набор строк

Без компаратора

Ordinal

Совпал с порядком по кодам

числа

144 238

144 238

да

английские слова

142 759

142 759

да

одинаковое начало

144 220

144 220

да

надстрочные знаки

148 652

152 004

нет

по 128 символов

142 216

142 216

да

Обращения к компаратору. Сортировка делает одну и ту же работу, различается только сравнение

Числа совпадают на четырёх наборах из пяти и на всех машинах и рантаймах. Сортировка делает столько же сравнений, а времени тратит больше — значит, каждое сравнение работает дольше. Пятый набор со словами с надстрочными знаками отличается: язык расставляет такие буквы иначе, поэтому и число сравнений другое.

Второе — сколько времени уходит на языковые правила. Для такого сравнения в .NET используется ICU, библиотека с таблицами языков. Её отключает переменная среды DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: с ней строки сравниваются по кодам символов, а код остаётся прежним. Вместе со временем меняется и результат сортировки, поэтому в рабочем приложении такая переменная опасна — здесь она нужна только для замера.

Как задан порядок

Обычно, мкс

Без ICU, мкс

Отношение

без компаратора

4 616,20 – 6 740,40

1 715,10 – 3 034,40

2,21 – 2,69

Ordinal

1 044,80 – 2 045,10

1 054,00 – 2 000,40

0,99 – 1,06

Массив из 10 000 строк, .NET 10, замер на Stopwatch. Без ICU сортировка без компаратора занимает столько же, сколько сортировка по кодам символов

Сортировка с Ordinal без ICU не меняется — 0,99–1,06. А сортировка без компаратора ускоряется в 2,21–2,69 раза и даёт такие же числа. Значит, всё лишнее время уходило на языковые правила.

Третье. Те же языковые правила, записанные явно, работают быстрее:

// сравнение по правилам языка, компаратор не передан
Array.Sort(names);

// те же правила, но компаратор указан
Array.Sort(names, StringComparer.CurrentCulture);

Обе строки сравнивают по одним правилам и дают один результат, но вторая быстрее: 4034,92–5739,46 микросекунды против 4634,36–6731,95, то есть в 1,15–1,25 раза.

Причина в том, что путь до сравнения разный. Без компаратора это GenericComparer<string> → string.CompareTo → CultureInfo.CurrentCulture.CompareInfo.Compare: на каждом вызове читается текущая культура потока, и уже через неё берётся CompareInfo. У StringComparer.CurrentCulture это CultureAwareComparer, у которого CompareInfo и параметры сравнения лежат в полях с момента создания, поэтому вызов идёт сразу в CompareInfo.Compare.

История 2. Дело не в строках и не в способе сортировки

Возьмём пять разных наборов по 10 000 строк: строки-числа, английские слова, строки с одинаковыми первыми 14 символами, слова с надстрочными знаками и строки по 128 символов.

Набор строк

Без компаратора, мкс

Ordinal, мкс

Отношение

числа

5 381,20 – 7 605,00

1 231,00 – 2 105,00

3,61 – 4,45

английские слова

4 610,00 – 6 816,00

1 021,40 – 2 033,00

3,32 – 4,64

одинаковое начало

6 005,70 – 9 160,00

1 076,10 – 2 113,00

3,88 – 5,58

надстрочные знаки

5 293,00 – 8 052,00

1 127,80 – 2 320,00

3,47 – 4,88

по 128 символов

4 692,60 – 6 738,00

928,40 – 1 805,00

3,61 – 5,05

Пять наборов строк, .NET 10. Отношение держится в пределах 3,32–5,58

На всех пяти наборах сортировка без компаратора работает медленнее. Больше всего на строках с одинаковым началом — 3,88–5,58 раза: у них сравнение доходит до конца строки, а не заканчивается на первом символе.

Теперь четыре способа отсортировать массив.

Способ

Без компаратора, мкс

Ordinal, мкс

Отношение

Память

Array.Sort

4 704,00 – 6 978,00

1 025,90 – 2 109,00

3,22 – 4,68

78,15 КБ

List.Sort

4 686,00 – 6 948,00

1 033,90 – 2 172,00

3,12 – 4,66

156,33 КБ

Span.Sort

4 843,60 – 7 055,00

1 057,00 – 2 102,00

3,24 – 4,58

78,15 КБ

OrderBy

4 385,00 – 6 324,00

1 500,50 – 2 857,00

2,21 – 2,92

273,77 КБ

Массив из 10 000 строк, .NET 10. Без компаратора медленнее работают все четыре, но у OrderBy разница меньше

У OrderBy отставание меньше — 2,21–2,92. Он копирует набор и создаёт массив индексов, отсюда и 273,77 килобайта против 78,15 у Array.Sort. Эти расходы одинаковы при любом компараторе, поэтому на их фоне разница между компараторами не так заметна.

Остался размер набора и версия рантайма.

Строк

.NET 8

.NET 9

.NET 10

.NET 11

1 000

5,36 – 9,95

5,00 – 8,46

5,07 – 10,23

5,66 – 11,71

10 000

3,95 – 5,41

3,64 – 4,90

3,25 – 4,62

3,46 – 4,49

100 000

3,59 – 4,61

3,57 – 4,41

3,21 – 4,25

2,99 – 3,68

Во сколько раз сортировка без компаратора медленнее сортировки по кодам символов. Отношение держится на всех четырёх рантаймах

На тысяче строк отставание больше, чем на ста тысячах: 5,00–11,71 против 2,99–4,61.

Выводы

По цифрам

  • Сортировка без компаратора медленнее сортировки по кодам символов в 5,00–11,71 раза на тысяче строк, в 3,25–5,41 на десяти тысячах и в 2,99–4,61 на ста тысячах.

  • На 10 000 строк это 4634,36–6731,95 микросекунды против 1003,47–2073,24.

  • Число сравнений у сортировки без компаратора и с Ordinal совпадает на четырёх наборах из пяти: 142 759 на словах, 144 220 на строках с одинаковым началом. Значит, разница во времени приходится на само сравнение.

  • Без ICU сортировка без компаратора ускоряется в 2,21–2,69 раза и даёт такие же числа, что и сортировка по кодам символов, а у самой сортировки по кодам символов без ICU ничего не меняется: 0,99–1,06.

  • Те же языковые правила, записанные явно через StringComparer.CurrentCulture, работают быстрее, чем без компаратора, в 1,15–1,25 раза.

  • На 5 наборах строк отставание держится в пределах 3,32–5,58, больше всего — на строках с одинаковым началом.

  • У Array.Sort, List.Sort и Span.Sort отставание 3,12–4,68, у OrderBy — 2,21–2,92.

  • Памяти на 10 000 строк уходит 78,15 килобайта у Array.Sort и Span.Sort, 156,33 у List.Sort и 273,77 у OrderBy.

  • На .NET 8, 9, 10 и 11 отставание держится в тех же пределах.

Что стоит помнить

  • Без компаратора строки сравниваются по правилам языка, настроенного на машине, — отсюда и лишнее время.

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

  • Переменная DOTNET_SYSTEM_GLOBALIZATION_INVARIANT меняет не только время, но и результат: с ней строки сравниваются по кодам символов.

  • Любой способ сортировки без компаратора работает медленнее: у Array.Sort, List.Sort и Span.Sort в 3,12–4,68 раза, у OrderBy — в 2,21–2,92, потому что OrderBy копирует набор и создаёт массив индексов.

Код из статьи

  • StringSortProof — бенчмарки, счётчик обращений к компаратору и прогоны на четырёх машинах

Ссылки

Всем удачи и до новых встреч!

Комментарии (3)


  1. vvdev
    27.08.2026 15:49

    По методике вопрос.

    Не лучше ли создать один инстанс массива/списка/чего-ещё и в начале каждой бенчмарки вместо клона делать КопиТу?

    Так расход памяти был бы точнее, ловились бы и индивидуальные аллокации типа печально известного делегата Comparison.


    1. Geronom Автор
      27.08.2026 15:49

      Да, вполне оптимально хоть крайне несущественно. В дальнейшем подправлю


  1. r_o_m_k_o_l_a
    27.08.2026 15:49

    Та же плата за языковые правила есть и на стороне СУБД, и там она обходится дороже, потому что оседает в индексах.

    В PostgreSQL сравнение текста по умолчанию идёт по локали, и COLLATE "C" — примерно то же самое, что ваш Ordinal: сравнение по кодам символов, без ICU. Разница видна на глаз даже на пяти строках, только что прогнал на 17.9. По локали получается _a, a b, ab, елка, Ёлка, а с COLLATE "C" — _a, a b, ab, Ёлка, елка.

    Прикладная сторона в том, что порядок в btree-индексе фиксируется тем же правилом сравнения. Если под базой сменилась версия glibc или ICU, порядок может поехать, а индекс об этом не знает и продолжает отвечать по старому — с пропусками строк, которые формально в диапазон попадают. Лечится REINDEX, но узнают об этом обычно постфактум.

    Поэтому для служебных колонок, где человеческий алфавитный порядок не нужен вообще — ключи, хеши, идентификаторы, поиск по префиксу, — COLLATE "C" стоит ставить явно при создании таблицы. Плюс к скорости получается устойчивость к смене библиотеки под ногами.