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