Огромная разница в производительности (на 26 раз быстрее) при компиляции для 32 и 64 бит

Я пытался измерить разницу при использовании for и foreach при доступе к спискам типов значений и ссылочным типам.

Я использовал следующий класс для профилирования.

public static class Benchmarker
{
    public static void Profile(string description, int iterations, Action func)
    {
        Console.Write(description);

        // Warm up
        func();

        Stopwatch watch = new Stopwatch();

        // Clean up
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        watch.Start();
        for (int i = 0; i < iterations; i++)
        {
            func();
        }
        watch.Stop();

        Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
    }
}

Я использовал double для моего типа значения. И я создал этот "поддельный класс" для тестирования ссылочных типов:

class DoubleWrapper
{
    public double Value { get; set; }

    public DoubleWrapper(double value)
    {
        Value = value;
    }
}

Наконец, я запустил этот код и сравнил разницу во времени.

static void Main(string[] args)
{
    int size = 1000000;
    int iterationCount = 100;

    var valueList = new List<double>(size);
    for (int i = 0; i < size; i++) 
        valueList.Add(i);

    var refList = new List<DoubleWrapper>(size);
    for (int i = 0; i < size; i++) 
        refList.Add(new DoubleWrapper(i));

    double dummy;

    Benchmarker.Profile("valueList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < valueList.Count; i++)
        {
             unchecked
             {
                 var temp = valueList[i];
                 result *= temp;
                 result += temp;
                 result /= temp;
                 result -= temp;
             }
        }
        dummy = result;
    });

    Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in valueList)
        {
            var temp = v;
            result *= temp;
            result += temp;
            result /= temp;
            result -= temp;
        }
        dummy = result;
    });

    Benchmarker.Profile("refList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < refList.Count; i++)
        {
            unchecked
            {
                var temp = refList[i].Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }
        dummy = result;
    });

    Benchmarker.Profile("refList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in refList)
        {
            unchecked
            {
                var temp = v.Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }

        dummy = result;
    });

    SafeExit();
}

Я выбрал опции Release и Any CPU, запустил программу и получил следующие моменты:

valueList for:  average time: 483,967938 ms
valueList foreach:  average time: 477,873079 ms
refList for:  average time: 490,524197 ms
refList foreach:  average time: 485,659557 ms
Done!

Затем я выбрал опции Release и x64, запустил программу и получил следующие моменты:

valueList for:  average time: 16,720209 ms
valueList foreach:  average time: 15,953483 ms
refList for:  average time: 19,381077 ms
refList foreach:  average time: 18,636781 ms
Done!

Почему x64-разрядная версия намного быстрее? Я ожидал некоторой разницы, но не что-то такое большое.

У меня нет доступа к другим компьютерам. Не могли бы вы запустить это на своих машинах и сообщить мне результаты? Я использую Visual Studio 2015, и у меня есть Intel Core i7 930.

Здесь используется метод SafeExit(), поэтому вы можете самостоятельно компилировать/запускать:

private static void SafeExit()
{
    Console.WriteLine("Done!");
    Console.ReadLine();
    System.Environment.Exit(1);
}

В соответствии с запросом, используя double? вместо моего DoubleWrapper:

Любой процессор

valueList for:  average time: 482,98116 ms
valueList foreach:  average time: 478,837701 ms
refList for:  average time: 491,075915 ms
refList foreach:  average time: 483,206072 ms
Done!

64

valueList for:  average time: 16,393947 ms
valueList foreach:  average time: 15,87007 ms
refList for:  average time: 18,267736 ms
refList foreach:  average time: 16,496038 ms
Done!

Последнее, но не менее важное: создание профиля x86 дает мне почти те же результаты использования Any CPU.

Ответ 1

Я могу воспроизвести это на 4.5.2. Здесь нет RyuJIT. Оба x86 и x64 разборки выглядят разумно. Проверка диапазона и т.д. Одинаковы. Такая же базовая структура. Нет разворачивания цикла.

x86 использует другой набор поплавковых инструкций. Производительность этих инструкций кажется сопоставимой с инструкциями x64 , за исключением разделения:

Операция деления делает 32-разрядную версию чрезвычайно медленной. Раскомментирование деления в значительной степени выравнивает производительность (32 бит от 430 мс до 3,25 мс).

Питер Кордес указывает, что латентности команд двух единиц с плавающей запятой не отличаются друг от друга. Возможно, некоторые из промежуточных результатов являются денормализованными числами или NaN. Это может вызвать медленный путь в одном из блоков. Или, может быть, значения расходятся между двумя реализациями из-за 10 байт против 8-байтовой точности поплавка.

Peter Cordes также указывает, что все промежуточные результаты - NaN... Удаление этой проблемы (valueList.Add(i + 1), так что ни один делитель не равен нулю) в основном выравнивает результаты. По-видимому, 32-битный код вообще не похож на операнды NaN. Пусть напечатайте некоторые промежуточные значения: if (i % 1000 == 0) Console.WriteLine(result);. Это подтверждает, что данные теперь нормальные.

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

Попробуйте просто суммировать числа, чтобы получить лучший результат.

Разделение и модуляция всегда очень медленные. Если вы измените код BCL Dictionary, чтобы просто не использовать оператор modulo для вычисления показателя производительности индекса ковша, он улучшается. Именно так происходит медленное деление.

Здесь 32-битный код:

введите описание изображения здесь

64-битный код (та же структура, быстрое деление):

введите описание изображения здесь

Это не векторизация, несмотря на использование инструкций SSE.

Ответ 2

valueList[i] = i, начиная с i=0, поэтому первая итерация цикла имеет 0.0 / 0.0. Таким образом, каждая операция во всем тесте выполняется с помощью NaN s.

Как @usr, показанный на выходе дизассемблирования, в 32-битной версии используется x87 с плавающей запятой, а 64-разрядная - с плавающей запятой SSE.

Я не специалист по производительности с NaN s, или разница между x87 и SSE для этого, но я думаю, что это объясняет разницу в 26x perf. Бьюсь об заклад, ваши результаты будут намного ближе между 32 и 64 бит, если вы инициализируете valueList[i] = i+1. (обновление: usr подтвердило, что это привело к тому, что производительность 32 и 64 бит довольно близка.)

Подразделение работает очень медленно по сравнению с другими операциями. См. Мои комментарии по адресу @usr. Также см. http://agner.org/optimize/ для тонких статей об оборудовании и оптимизации asm и C/С++, некоторые из которых относятся к С#. У него есть таблицы команд задержки и пропускной способности для большинства инструкций для всех последних процессоров x86.

Однако 10B x87 fdiv не намного медленнее, чем двойная точность SSE2 8B divsd, для нормальных значений. IDK о первичном различии с NaN, бесконечностями или денормалами.

У них разные элементы управления для того, что происходит с NaN и другими исключениями FPU. x87 управляющее слово FPU отделено от регистра SSE округления/исключения (MXCSR). Если x87 получает исключение CPU для каждого деления, но SSE нет, это легко объясняет коэффициент 26. Или, может быть, просто разница в производительности, большая при обработке NaN. Аппаратное обеспечение не оптимизировано для опрокидывания через NaN после NaN.

IDK, если контроль SSE для предотвращения замеров с денормалами будет входить в игру здесь, так как я считаю, что result будет NaN все время. IDK, если С# устанавливает флаг denormals-are-zero в MXCSR или флаг flush-to-zero (который в первую очередь записывает нули, вместо того, чтобы рассматривать денормалы как ноль при чтении).

Я нашел статью Intel об элементах с плавающей запятой SSE, сравнив их с управляющим словом x87 FPU. Однако нечего сказать о NaN. Это заканчивается следующим:

Заключение

Чтобы избежать проблем с сериализацией и производительностью из-за денонсаций и номера нижнего потока, используйте инструкции SSE и SSE2 для установки Сброс к нулю и Denormals-Are-Zero в аппаратном обеспечении обеспечивают максимальную производительность для приложений с плавающей запятой.

IDK, если это помогает любому с делением на ноль.

для ve foreach

Возможно, было бы интересно проверить тело цикла, которое ограничено пропускной способностью, а не просто одной цепью зависимостей, связанной с циклом. Как бы то ни было, вся работа зависит от предыдущих результатов; для CPU нечего делать параллельно (кроме границ - проверьте загрузку следующего массива, пока выполняется цепочка mul/div).

Вы можете увидеть больше различий между методами, если "реальная работа" заняла больше ресурсов выполнения ЦП. Кроме того, на pre-Sandybridge Intel существует большая разница между фитингом цикла в буфере цикла 28uop или нет. Вы получаете узкие места декодирования команд, если нет, особенно. когда средняя длина инструкции больше (что происходит с SSE). Инструкции, которые декодируются более чем на один uop, также ограничивают пропускную способность декодера, если они не попадают в шаблон, который хорош для декодеров (например, 2-1-1). Таким образом, цикл с дополнительными инструкциями накладных расходов цикла может сделать разницу между привязкой цикла в 28-ти точечном кэше или нет, что является большой проблемой для Nehalem, а иногда и полезно для Sandybridge и позже.

Ответ 3

У нас есть наблюдение, что 99,9% всех операций с плавающей запятой будут включать в себя NaN, что, по крайней мере, очень необычно (сначала Питер Кордес). У нас есть еще один эксперимент usr, который обнаружил, что удаление инструкций деления почти полностью исчезает.

Однако факт состоит в том, что NaN генерируются только потому, что самое первое деление вычисляет 0.0/0.0, которое дает начальное NaN. Если деления не выполняются, результат всегда будет 0.0, и мы всегда будем рассчитывать 0.0 * temp → 0.0, 0.0 + temp → temp, temp - temp = 0.0. Таким образом, удаление подразделения не только удалило деления, но и удалило NaNs. Я бы ожидал, что NaN на самом деле проблема, и что одна реализация обрабатывает NaN очень медленно, в то время как другая не имеет проблемы.

Было бы полезно начать цикл с я = 1 и снова измерить. Четыре результата операции * temp, + temp, temp, temp эффективно добавляют (1 - temp), поэтому для большинства операций мы не будем иметь никаких необычных чисел (0, бесконечность, NaN).

Единственная проблема может заключаться в том, что деление всегда дает целочисленный результат, а в некоторых реализациях разделов есть ярлыки, когда правильный результат не использует много бит. Например, деление 310.0/31.0 дает 10.0 в качестве первых четырех бит с остатком 0.0, а некоторые реализации могут прекратить оценивать оставшиеся 50 или около того битов, в то время как другие не могут. Если есть значительная разница, то запуск цикла с результатом = 1.0/3.0 будет иметь значение.

Ответ 4

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

Архитектура процессора:

Архитектура процессора Intel была чисто 64-битной. Чтобы выполнить 32-битный код, 32-битные команды должны были быть преобразованы (внутри ЦП) на 64-битные команды перед выполнением.

Архитектура AMD CPU должна была построить 64-битную версию поверх своей 32-битной архитектуры; то есть, по существу, это была 32-битная архитектура с 64-разрядными расширениями - процесс преобразования кода не выполнялся.

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

.NET JIT

Он утверждал, что .NET(и другие управляемые языки, такие как Java) способны превосходить языки, такие как С++, из-за того, как компилятор JIT может оптимизировать ваш код в соответствии с вашей процессорной архитектурой. В этом отношении вы можете обнаружить, что компилятор JIT использует что-то в архитектуре с 64-битной архитектурой, которая, возможно, недоступна или требуется обходной путь при выполнении в 32-разрядной версии.

Примечание:

Вместо использования DoubleWrapper вы рассмотрели использование Nullable<double> или сокращенного синтаксиса: double? - мне было бы интересно узнать, не влияет ли это на ваши тесты.

Примечание 2: Некоторые люди, похоже, объединяют мои комментарии о 64-битной архитектуре с IA-64. Чтобы уточнить, в моем ответе 64-битный относится к x86-64 и 32 бит относится к x86-32. Ничто здесь не ссылалось на IA-64!