C # Как базовое время работы зависит от размера чисел? - PullRequest
0 голосов
/ 14 марта 2019

Контекст этой функции - функция, которая должна запускаться практически один раз за кадр, и поэтому очень критична для производительности. Эта функция содержит цикл и операции внутри него.

private int MyFunction(int number)
{
    // Code
    for (int i = 0; i <= 10000; i++)
    {
        var value = i * number
        var valuePow2 = value * value;

        // Some code which uses valuePow2 several times
    }
    return 0; // Not actual line
}

Теперь, благодаря математическим свойствам, мы знаем, что (a * b) ² равно a² * b²

Итак, можно было бы сделать мою функцию такой:

private int MyFunction(int number)
{
    // Code
    var numberPow2 = number * number;
    for (int i = 0; i <= 10000; i++)
    {
        var iPow2 = i * i
        var valuePow2 = numberPow2 * iPow2;

        // Some code which uses valuePow2 several times
    }
    return 0; // Not actual line
}

Интуитивно понятно, что это должно быть быстрее, так как число² не меняется, и теперь вычисляется только один раз за пределами цикла. По крайней мере, это будет намного быстрее для человека, потому что операция x² выполняется во время цикла с гораздо меньшим числом.

Что мне интересно, так это в C #, когда вы используете такие типы, как int, будет ли умножение на самом деле быстрее с меньшими числами?

Например, будет ли 5 ​​* 5 выполняться быстрее, чем 5000 * 5000?

Если так, то вторая версия лучше, хотя и с небольшим отрывом, из-за этого.

Но если для данного типа данных время постоянное, тогда первая версия функции лучше, потому что половина вычислений будет выполняться на меньших числах, потому что я делаю то же количество умножения в цикле оба раза, но во второй версии перед началом я делаю одно дополнительное умножение.

Мне известно, что по всем причинам и целям разница в производительности незначительна. Мне предложили вторую версию в Code Review, потому что эта функция важна, и я не могу найти документацию для поддержки любого из представлений.

Ответы [ 2 ]

2 голосов
/ 15 марта 2019

Например, будет ли 5 ​​* 5 выполняться быстрее, чем 5000 * 5000?

Для констант времени компиляции 5 * x дешевле, чем 5000 * x, потому что первое можно сделатьс lea eax, [rdi + rdi*4].

Но для переменных времени выполнения единственной целочисленной инструкцией с зависимой от данных производительностью является деление. Это применимо к любому основному ЦП: конвейерная обработка настолько важна, что даже в некоторых случаяхмогут работать с меньшей задержкой, обычно они этого не делают, потому что это усложняет планирование.(Нельзя, чтобы один и тот же исполнительный модуль выдавал 2 результата в одном и том же цикле; вместо этого ЦП просто хочет знать, что ввод входных данных в один цикл обязательно приведет к тому, что ответ выйдет через 3 цикла.)

(Для FP опять-таки только деление и sqrt имеют производительность, зависящую от данных на обычных процессорах.)

Код с использованием целых чисел или FP, который имеет любое зависящее от данных ветвление, может быть намного медленнее, если ветви идут другим путем.(например, предсказание ветвления «обучается» на одной последовательности переходов для двоичного поиска; поиск с другим ключом будет медленнее, потому что он будет неверно предсказан хотя бы один раз.)

И для записи, предложения по использованию Math.Pow вместо целого числа * безумны.Простое преобразование целого числа в double и обратно медленнее, чем умножение самого себя на умножение на целое число.


Ответ Адама связывает эталон, который зацикливается на большом массиве, с возможностью автоматической векторизации.SSE / AVX2 имеет только 32-битное целочисленное умножение.И 64-битная занимает больше пропускной способности памяти.Именно поэтому он показывает ускорения для 16 и 8-битных целых чисел.Таким образом, он обнаруживает, что c=a*b работает на половинной скорости на процессоре Haswell, но это не применимо к случаю цикла.

В скалярном коде imul r64, r64 имеет производительность, идентичную imul r32, r32 на основных процессорах Intel (начиная с по крайней мере Nehalem) и на Ryzen (https://agner.org/optimize/). Обе: 1 мегапиксель, 3 такта, пропускная способность 1 / такт.

Это только семейство AMD Bulldozer и AMDAtom и Silvermont, где 64-разрядное скалярное умножение медленнее (конечно, в 64-разрядном режиме! В 32-разрядном режиме работа с 64-разрядными целыми числами медленнее.)


Оптимизация цикла

Для фиксированного значения number вместо пересчета i*number компиляторы могут и оптимизируют его до inum += number. Это называется оптимизация снижения прочности , потому что сложение является «более слабой» (немного более дешевой) операцией, чем умножение.

for(...) {
    var value = i * number
    var valuePow2 = value * value;
}

может быть скомпилировано в asm, что-то вроде

var value = 0;
for(...) {
    var valuePow2 = value * value;

    ...

    value += number;
}

Вы можете попробовать написатьтаким образом, на случай, если компилятор этого не сделаетили вы.

Но целочисленное умножение очень дешево и особенно конвейерно на современных процессорах, особенно.Он имеет немного большую задержку, чем add, и может работать на меньшем количестве портов (обычно только 1 на тактовую пропускную способность вместо 4 для add), но вы говорите, что выполняете значительную работу с valuePow2.Это должно позволить внеочередному выполнению скрыть задержку.


Если вы проверяете asm и компилятор использует отдельный счетчик цикла, увеличивающийся на 1, вы также можете попытаться удержать компилятор вручнуюв оптимизации цикла для использования value в качестве счетчика цикла.


var maxval = number * 10000;
for (var value = 0; i <= maxval; value += number) {
    var valuePow2 = value * value;

    ...
}

Будьте осторожны, если number*10000 может переполниться, если вам нужно правильно обернуть.В этом случае этот цикл будет выполнять гораздо меньше итераций.(Если number не будет настолько большим, что value += number также закутывается ...)

1 голос
/ 14 марта 2019

Для типичного процессора умножение двух 32-разрядных целых чисел займет одинаковое количество циклов независимо от данных в этих целых числах. Большинству современных процессоров для умножения 64-разрядных целых чисел требуется почти вдвое больше, чем умножению 32-разрядных целых чисел.

Я заметил проблему в обоих ваших кодах. Когда вы умножаете два целых числа, он возвращает тип int. Тип var установит тип возвращаемого значения. Это означает, что valuePow2 будет int. Поскольку ваш цикл увеличивается до 10000, если число равно 5 или больше, вы переполните valuePow2.

Если вы не хотите переполнять свой int, вы можете изменить код на

private int MyFunction(int number)
{
    // Code
    for (int i = 0; i <= 10000; i++)
    {
        long value = i * number;        //64bit multiplication          
        long valuePow2 = value * value; //64bit multiplication

        // Some code which uses valuePow2 several times
    }
    return 0; // Not actual line
}

модифицированный код должен быть быстрее, потому что вы можете изменить 64-битное умножение на 32-битное умножение

private int MyFunction(int number)
{
    // Code
    long numberPow2 = number * number; //64bit multiplication
    for (int i = 0; i <= 10000; i++)
    {
        int iPow2 = i * i;                      //32bit multiplication
        long valuePow2 = numberPow2 * iPow2;    //64bit multiplication

        // Some code which uses valuePow2 several times
    }
    return 0; // Not actual line
}

Но схема в ЦП и оптимизация компилятора могут изменить количество циклов, которые он завершает. В конце дня ты сказал это лучше всего:

Мне известно, что по всем причинам и целям разница в производительности незначительна.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...