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

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" стоит ставить явно при создании таблицы. Плюс к скорости получается устойчивость к смене библиотеки под ногами.
vvdev
По методике вопрос.
Не лучше ли создать один инстанс массива/списка/чего-ещё и в начале каждой бенчмарки вместо клона делать КопиТу?
Так расход памяти был бы точнее, ловились бы и индивидуальные аллокации типа печально известного делегата Comparison.
Geronom Автор
Да, вполне оптимально хоть крайне несущественно. В дальнейшем подправлю