Есть число, которое не меняется от смены знака. Минус перед ним ничего не делает, а модуля у него не существует: Math.Abs бросает исключение.
Это int.MinValue. Компилятор такую проверку не соберёт, но int.MinValue == unchecked(-int.MinValue) возвращает true.
|
Машина |
Процессор |
Система |
|
Комп 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 |
Все машины x64
Рантаймы .NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8. Числа в тексте с AMD Ryzen 9 5950X, таблицы по всем четырём машинам.
1. Число, у которого нет модуля
Исходник
Отрицательных чисел в int на одно больше, чем положительных: от −2 147 483 648 до 2 147 483 647. У нижней границы нет положительной пары, поэтому смена знака её не меняет.
int min = int.MinValue; Console.WriteLine(min); // -2147483648Console.WriteLine(-min); // -2147483648Console.WriteLine(min == -min); // True
Библиотечный модуль об этом знает. Вот как он написан:
public static int Abs(int value){ if (value < 0) { value = -value; if (value < 0) { ThrowNegateTwosCompOverflow(); } } return value;}
Число меняет знак, и результат проверяется повторно. Если после смены знака результат остался отрицательным, модуля не существует и метод бросает исключение.
Что выводит отчёт
тип самое маленькое -x равен x модуль x / -1 sbyte -128 да исключение -128 short -32768 да исключение -32768 int -2147483648 да исключение исключение long -9223372036854775808 да исключение исключение nint -9223372036854775808 да исключение исключение
Одинаково на четырёх машинах и трёх рантаймах.
Причина
Отрицательные числа хранятся в дополнительном коде. Старший бит означает знак, а смена знака — это инверсия всех битов плюс единица.
У нижней границы все биты, кроме старшего, нулевые. Инверсия ставит единицы во всех разрядах, прибавление единицы переносит разряды до конца, и получается исходное число.
Где легко ошибиться
Написать свой модуль и решить, что он лучше библиотечного:
int Abs(int value) => value < 0 ? -value : value;
Тут исключения нет, зато на нижней границе результат получается отрицательным. Модуль числа отрицателен, и с этим значением работает весь остальной код.
На sbyte и short ошибиться ещё легче. Смена знака ведёт себя там точно так же, но заметить это труднее: перед вычислением оба типа расширяются до int, где промежуточный результат укладывается в диапазон.
2. Деление на минус единицу
Исходник
Обычное деление, ничего особенного:
int left = int.MinValue;int right = -1; int result = left / right; // System.OverflowException
Что выводит отчёт
Последний столбец той же таблицы:
тип самое маленькое -x равен x модуль x / -1 sbyte -128 да исключение -128 short -32768 да исключение -32768 int -2147483648 да исключение исключение long -9223372036854775808 да исключение исключение nint -9223372036854775808 да исключение исключение
У int, long и nint — исключение. У sbyte и short деление проходит, и результат ожидаемый.
Причина
Правильный ответ равен 2 147 483 648, а такого числа в int нет. Деление в этом смысле ничем не отличается от смены знака.
Проверку делает не библиотека, а процессор. Инструкция целочисленного деления на x86 сама сообщает об ошибке, когда частное не помещается в регистр. Рантайм превращает это в OverflowException.
Именно поэтому sbyte и short не падают: перед делением они расширяются до int, где 128 и 32 768 укладываются в диапазон свободно.
Где легко ошибиться
Считать, что деление не бросает исключений, раз делитель не нулевой. Проверка if (right != 0) от этого случая не спасает.
Остаток ведёт себя так же: int.MinValue % -1 тоже бросает исключение, хотя математически остаток тут равен нулю.
3. Два миллиарда плюс два миллиарда
Исходник
int big = 2_000_000_000;int sum = big + big; Console.WriteLine(sum); // -294967296
Ни исключения, ни предупреждения. По умолчанию C# работает в непроверяемом контексте, поэтому старшие биты отбрасываются.
А то же самое на константах компилятор не пропустит:
const int big = 2_000_000_000;int sum = checked(big + big); // error CS0220: The operation overflows at compile time in checked mode
Значения констант известны при сборке, поэтому компилятор вычисляет результат заранее. Это делается с проверкой на переполнение, потому и падает ошибка.
С переменными такое невозможно: их значения появятся только при выполнении. Поэтому код собирается, а переполнение остаётся незамеченным.
Что показывает замер
Цикл складывает 10 000 чисел. Набор подобран так, чтобы сумма укладывалась в диапазон: иначе вариант с проверкой бросает исключение, и замер показывает не работу проверки, а работу обработчика ошибки.
|
Способ |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
без проверки |
2,521 |
2,599 |
2,928 |
4,749 |
|
с проверкой |
2,600 |
2,409 |
3,057 |
4,951 |
|
в длинном типе |
2,488 |
2,407 |
2,946 |
4,762 |
Микросекунды, .NET 10
Разница от 3 до 4 процентов, а на Ryzen 9 5950X версия с проверкой оказалась быстрее. То есть на таком коде проверка теряется в шуме.
Причина
Процессор получает признак переполнения при сложении, отдельных вычислений для этого не нужно. Проверяемый контекст добавляет к сложению один переход по этому признаку. Переполнения не происходит ни разу, поэтому предсказатель ветвлений не ошибается, и на времени такой переход не сказывается.
Где легко ошибиться
Решить, что раз проверка ничего не стоит, её можно включить на весь проект и забыть. Ключ CheckForOverflowUnderflow в файле проекта включает её везде, и любое место, где переполнение задумано специально, начнёт падать. Хеш-функции, счётчики и работа с битами обычно рассчитывают на отбрасывание старших разрядов.
4. Двоичный поиск с отрицательным индексом
Исходник
Привычная запись середины отрезка:
int mid = (low + high) / 2;
Пока границы небольшие, всё правильно. Когда сумма перестаёт помещаться в int, середина становится отрицательной.
Что выводит отчёт
low high привычно разность беззнаково 0 10 5 5 5 0 2000000000 1000000000 1000000000 1000000000 1000000000 2000000000 -647483648 1500000000 1500000000 1500000000 2000000000 -397483648 1750000000 1750000000 2000000000 2147483647 -73741824 2073741823 2073741823 2147483646 2147483647 -1 2147483646 2147483646 Поиск числа 2000000000 на отрезке от 0 до 2147483647: привычно IndexOutOfRangeException: номер -536870912 разность найдено, шагов: 31 беззнаково найдено, шагов: 31
Причина
В .NET такого не допускают, и делают это двумя разными способами. В ArraySortHelper сначала вычитают нижнюю границу из верхней:
int i = lo + ((hi - lo) >> 1);
А в SpanHelpers — беззнаковое сложение, и рядом комментарий, зачем так:
// PERF: `lo` or `hi` will never be negative inside the loop,// so computing median using uints is safe since we know// `length <= int.MaxValue`, and indices are >= 0// and thus cannot overflow an uint.// Saves one subtraction per loop compared to// `int i = lo + ((hi - lo) >> 1);`int i = (int)(((uint)hi + (uint)lo) >> 1);
Оба индекса неотрицательны, поэтому их сумма всегда помещается в uint. А ещё такая запись экономит одно вычитание на каждой итерации.
Что показывает замер
|
Способ |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
привычная запись |
5,165 |
5,891 |
6,122 |
7,964 |
|
через разность |
5,217 |
5,280 |
6,070 |
7,645 |
|
беззнаково |
5,167 |
5,288 |
6,049 |
7,627 |
Наносекунды на четыре отрезка, .NET 10
Обещанной экономии в замере не видно: разница между вторым и третьим способом от 0,3 до 0,4 процента, а на Intel Core i9-10900KF её нет. Инструкция действительно исчезает, но на времени это не сказывается.
Где легко ошибиться
Решить, что случай надуманный, раз массивов на два миллиарда элементов не бывает. Границы берутся не только из длины массива: это могут быть идентификаторы записей, отметки времени или номера страниц, а там такие значения встречаются постоянно.
Границы замеров
Все замеры сняты на x64 под Windows, на .NET 8, 9 и 10.
Числа для замера берутся из массива, а не пишутся в коде: константные выражения компилятор вычисляет при сборке, и замерять было бы нечего. У всех измеряемых методов запрещено встраивание.
Двоичный поиск в отчёте идёт по массиву, которого нет: значение элемента равно его номеру, поэтому обращение к памяти заменено вычислением. Проверяется только арифметика границ.
В 32-битной сборке nint займёт четыре байта, и его строка в отчёте будет другой.
Код из статьи
-
OverflowProof — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1080958/