Skip to content

Repository files navigation

DivRemProof

Замер вокруг одной строки: когда рядом стоят 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

Ссылки

About

Деление и остаток от одной константы: на int компилятор считает деление один раз, на uint дважды. Замеры на трёх машинах, .NET 8, 9 и 10, с листингами машинного кода

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages