Замер вокруг одной строки: когда рядом стоят x / 10 и x % 10, компилятор
может посчитать деление один раз и вывести из него остаток вычитанием,
а может посчитать дважды. Проверяется, зависит ли это от того, знаковый тип
или беззнаковый.
Повод — открытое обращение dotnet/runtime #119131.
BenchmarkDotNet 0.15.8, Release, .NET 8, .NET 9 и .NET 10.
На int деление и остаток от одной константы собираются в одно умножение.
На uint их три: деление считается заново после того, как из него уже
получили остаток. Ответ верный и там, и там, отличается только объём работы.
Отношение пары к одиночному делению своего типа, .NET 10, три машины:
1,32–1,41 у int и 1,84–2,56 у uint.
Возврат к одному умножению даёт Math.DivRem — быстрее пары в 1,13–1,76 раза,
или вычитание, записанное явно, — в 1,23–1,80 раза.
Три случая, где разницы между типами нет: делитель, равный степени двойки;
делитель в переменной; тип ulong на .NET 10.
На разборе числа по цифрам uint остаётся быстрее int — 0,81–0,86: лишнее
умножение забирает часть того, что беззнаковый тип даёт на делении, но не всё.
Полные отчёты в Results, листинги в Disasm/Listings_<машина>.
| Класс | Что сравнивает |
|---|---|
| DivRemBench | деление, остаток и оба действия рядом; int против uint, на трёх рантаймах |
| DivRemDivisorBench | те же два действия на делителях 3, 10, 100 и 16 |
| DivRemWidthBench | то же самое на 64 битах и с делителем в переменной, на трёх рантаймах |
| DigitsBench | разбор числа на десятичные цифры: int против uint |
Строки Div и Rem — нижние границы: одно действие на значение. Строка
DivRem делает оба над одним значением и одной константой. Отношение DivRem
к Div и отвечает на вопрос, считается деление один раз или дважды.
Остальные строки основного замера отвечают на встречные вопросы:
Split— те же действия отдельными операторами, а не одним выражением;RemDiv— те же действия в обратном порядке;MathDivRem— тот же результат черезMath.DivRem;ViaSigned— через приведение беззнакового значения к знаковому типу;ManualRem— остаток выведен вычитанием:value - value / 10 * 10.
Делитель 16 в DivRemDivisorBench — контрольный: он равен степени двойки,
деление сводится к сдвигу, остаток берётся из младших битов, и повторять
компилятору нечего. Делитель в переменной из DivRemWidthBench — второй
контрольный случай: замены умножением там нет, работает машинная команда
деления, а она выдаёт оба ответа разом.
Отчёт checks сверяет ответы: все способы на одних данных обязаны дать одно
число. Код возврата не нулевой, если хоть одна пара разошлась, и скрипт прогона
на этом останавливается.
| CPU | Ядра/потоки | ОС |
|---|---|---|
| AMD Ryzen 9 5950X | 16 / 32 | Windows 10 1809 |
| 2 × Intel Xeon Silver 4314 | 2×16 / 64 | Windows Server 2022 |
| Intel Xeon W-2255 | 10 / 20 | Windows Server 2022 |
Нужны SDK .NET 8, 9 и 10 — BenchmarkDotNet поднимает по процессу на каждый рантайм. Проверить, что все три на месте:
dotnet --list-sdks
Весь прогон одной командой, без аргументов:
all.bat
Скрипт собирает проект, сверяет ответы на трёх рантаймах, запускает замеры
и складывает всё в Results\<имя машины>.
Листинги машинного кода снимаются отдельно, около минуты:
Disasm\snap.bat
Скрипт складывает их в Disasm\Listings_<имя машины>, по файлу на рантайм.
Вручную, без скриптов:
dotnet run -c Release -f net10.0 -- checks
dotnet run -c Release -f net10.0
dotnet run -c Release -f net10.0 -- --filter *DivRemBench*
Все измеряемые методы лежат в Subjects.cs, у каждого способа свой метод
с NoInlining: иначе компилятор встроит его в тело замера, и метод не найти
в листинге.
Значения берутся из массива, а не из константы: на константе компилятор
посчитал бы всё заранее и замер показал бы пустой цикл. Значения во всех
наборах одинаковые и положительные — у деления со знаком отрицательные числа
идут по другой ветке машинного кода, и разница между int и uint тогда
объяснялась бы знаком, а не тем, что проверяется.
Делитель 10 взят потому, что он не равен степени двойки. Деление на такую константу компилятор заменяет умножением на подобранное число со сдвигом, и это умножение оказывается общим для деления и остатка — то самое повторяющееся вычисление, которое ищет CSE.
Замер DigitsBench показывает то же самое на разборе числа по цифрам — цикле,
ради которого деление и остаток и пишут рядом.
Отдельный проект Disasm собран без BenchmarkDotNet: иначе в листинги попали бы
его обёртки. Он прогревает те же методы и печатает их машинный код через
DOTNET_JitDisasm.
DivRemProof.csproj многоцелевой: net8.0, net9.0, net10.0
DivRemProof.slnx
Program.cs точка входа, разбор аргументов
Subjects.cs все измеряемые методы
README.md
all.bat весь прогон одной командой
Benchmarks/ четыре класса, по одному на файл
Types/
Datasets.cs наборы значений
Diagnostics/
Checks.cs сверка ответов, код возврата
Disasm/ снятие машинного кода, свой проект
snap.bat три рантайма
Listings_<машина>/ выгрузка snap.bat, по файлу на рантайм
Results/
<машина>/ выгрузки прогона
<машина>/bench/ отчёты BenchmarkDotNet
- issue #119131 — обращение, с которого начался замер
- Common Subexpression Elimination — проход, который склеивает повторяющиеся вычисления
- optcse.cpp — его код
- morph.cpp — превращение деления на константу в умножение
- Math.cs — реализация
Math.DivRem - Math.DivRem
- jitconfigvalues.h — переменные
DOTNET_JitDisasmиDOTNET_JitDisasmDiffable