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

80

Я пытался измерить разницу между использованием a forи a 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!

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

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

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

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

По запросу, используя double?вместо my 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!

x64

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 .

Trauer
источник
14
«Любой CPU»! = «32Bits»! Если вы скомпилировали "Any CPU", ваше приложение должно работать как 64-битный процесс в вашей 64-битной системе. Также я бы удалил код, который возился с GC. На самом деле это не помогает.
Торстен Диттмар
9
@ThorstenDittmar вызовы GC выполняются перед измерением, а не в измеряемом коде. Это достаточно разумный поступок, чтобы уменьшить степень, в которой удача выбора времени GC может повлиять на такое измерение. Кроме того, между сборками есть фактор «предпочтение 32-разрядной версии» и «предпочтение 64-разрядной версии».
Джон Ханна,
1
@ThorstenDittmar Но я запускаю окончательную версию (вне Visual Studio), и диспетчер задач говорит, что это 32-битное приложение (при компиляции на любой процессор). Также. Как сказал Джон Ханна, вызов GC полезен.
Trauer
2
Какую версию среды выполнения вы используете? Новый RyuJIT в 4.6 намного быстрее, но даже для более ранних версий компилятор x64 и JITer были новее и более продвинутыми, чем версии x32. Они могут выполнять гораздо более агрессивную оптимизацию, чем версии x86.
Панайотис Канавос,
2
Замечу, что задействованный тип не имеет никакого эффекта; измените doubleна float, longили, intи вы получите аналогичные результаты.
Джон Ханна,

Ответы:

87

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

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

  1. 32-битные инструкции с плавающей запятой x87 используют внутреннюю точность 10 байт.
  2. Деление с повышенной точностью происходит очень медленно.

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

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

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

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

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

Деление и модуль всегда очень медленные. Если вы измените Dictionaryкод BCL, чтобы просто не использовать оператор по модулю для вычисления измеримого индекса корзины, производительность улучшится. Вот насколько медленное деление.

Вот 32-битный код:

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

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

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

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

usr
источник
11
«Кто бы мог подумать, что ни в чем не повинное подразделение может испортить ваш тест?» Я сделал это сразу, как только увидел разделение во внутреннем цикле, особенно. как часть цепочки зависимостей. Деление невинно только тогда, когда это целочисленное деление на степень 2. Из таблиц agner.org/optimize insn: Nehalem fdiv- задержка 7–27 циклов (и такая же обратная пропускная способность). divsdсоставляет 7-22 цикла. addsdпри задержке 3c, пропускной способности 1 / c. Division - единственная исполнительная единица без конвейерной обработки в процессорах Intel / AMD. C # JIT не векторизует цикл для x86-64 (с divPd).
Питер Кордес,
1
Кроме того, нормально ли для 32b C # не использовать математику SSE? Разве невозможно использовать функции текущей машинной части точки JIT? Таким образом, в Haswell и более поздних версиях он мог автоматически векторизовать целочисленные циклы с 256b AVX2, а не только с SSE. Чтобы получить векторизацию циклов FP, я думаю, вам придется записать их с такими вещами, как 4 аккумулятора параллельно, поскольку математика FP не ассоциативна. Но в любом случае использование SSE в 32-битном режиме быстрее, потому что у вас меньше инструкций для выполнения той же скалярной работы, когда вам не нужно манипулировать стеком x87 FP.
Питер Кордес,
4
В любом случае, div очень медленный, но 10B x87 fdiv не намного медленнее, чем 8B SSE2, так что это не объясняет разницу между x86 и x86-64. Что может объяснить, что это исключения FPU или замедления с денормалями / бесконечностями. Управляющее слово x87 FPU отделено от регистра управления округлением / исключениями SSE ( MXCSR). NaNМогу я подумать, что разная обработка денормальных чисел может объяснить фактор 26 перф. Разницы. C # может устанавливать значение denormals-are-zero в MXCSR.
Питер Кордес,
2
@Trauer и usr: Я только что заметил, что valueList[i] = i, начиная с i=0, значит первая итерация цикла 0.0 / 0.0. Таким образом, каждая операция во всем вашем тесте выполняется с помощью NaNs. Это разделение выглядит все менее и менее невинным! Я не эксперт по производительности с NaNs или разнице между x87 и SSE, но я думаю, что это объясняет разницу в 26x производительности. Бьюсь об заклад, ваши результаты будут намного ближе между 32 и 64 битами, если вы инициализируете valueList[i] = i+1.
Питер Кордес,
1
Что касается сброса до нуля, мне не очень нравится 64-битное двойное, но когда 80-битное расширенное и 64-битное двойное используются вместе, ситуации, когда 80-битное значение может быть недостаточным, а затем достаточно масштабируется получить значение, которое можно было бы представить как 64-битное, doubleбыло бы довольно редко. Один из основных шаблонов использования 80-битного типа заключался в том, чтобы позволить суммировать несколько чисел без необходимости полностью округлять результаты до самого конца. В соответствии с этим шаблоном переполнение просто не проблема.
supercat
31

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

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

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

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

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

Однако у них есть разные средства управления тем, что происходит с NaN и другими исключениями FPU. X87 управление ФПОМ слово отделено от регистра управления округлением / исключений SSE (MXCSR). Если x87 получает исключение ЦП для каждого подразделения, а SSE - нет, это легко объясняет множитель 26. Или, может быть, есть просто разница в производительности при обработке NaN. Оборудование не оптимизировано для работы NaNпосле NaN.

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

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

Заключение

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

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

для vs. foreach

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

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

Питер Кордес
источник
У меня никогда не было случая, чтобы я наблюдал какую-либо разницу в производительности в зависимости от того, были ли NaN в моем потоке данных, но наличие денормализованных чисел может иметь огромное значение в производительности. В данном примере это не похоже, но об этом следует помнить.
Джейсон Р.
@JasonR: Это потому, что NaNs действительно редко встречаются на практике? Я оставил все материалы о денормальных величинах и ссылку на материалы Intel, в основном для удобства читателей, а не потому, что я думал, что это действительно сильно повлияет на этот конкретный случай.
Питер Кордес,
В большинстве приложений они встречаются редко. Однако при разработке нового программного обеспечения, использующего числа с плавающей запятой, ошибки реализации нередко приводят к потокам NaN вместо желаемых результатов! Это приходило мне в голову много раз, и я не помню заметного снижения производительности при появлении NaN. Я наблюдал обратное, если делаю что-то, что вызывает появление денормалей; что обычно приводит к заметному падению производительности. Обратите внимание, что они основаны только на моем анекдотическом опыте; возможно некоторое падение производительности с NaN, которого я просто не заметил.
Джейсон Р.
@JasonR: IDK, возможно, NaN не намного медленнее с SSE. Ясно, что это большая проблема для x87. Семантика SSE FP была разработана Intel в дни PII / PIII. Под капотом этих процессоров находится такое же вышедшее из строя оборудование, что и в текущих проектах, поэтому, по-видимому, при разработке SSE они имели в виду высокую производительность для P6. (Да, Skylake основан на микроархитектуре P6. Некоторые вещи изменились, но он по-прежнему декодирует ошибки и распределяет их на порты выполнения с буфером переупорядочения.) Семантика x87 была разработана для дополнительного внешнего сопроцессорного чипа для скалярный процессор в порядке.
Питер Кордес,
@PeterCordes Назвать Skylake чипом на базе P6 - слишком большая натяжка. 1) FPU был (почти) полностью переработан в эпоху Sandy Bridge, поэтому старый FPU P6 практически ушел в прошлое; 2) x86 для декодирования uop имел критическую модификацию в эпоху Core2: в то время как предыдущие разработки декодировали инструкции вычислений и памяти как отдельные операторы памяти, чип Core2 + имел операторы, состоящие из инструкции вычисления и оператора памяти. Это привело к значительному увеличению производительности и энергоэффективности за счет более сложной конструкции и потенциально более низкой пиковой частоты.
shodanshok
1

У нас есть наблюдение, что 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. Таким образом, удаление деления не только удалило деления, но и удалило NaN. Я ожидал, что NaN на самом деле являются проблемой, и что одна реализация обрабатывает NaN очень медленно, в то время как другая не имеет проблемы.

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

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

скряга729
источник
-2

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

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

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

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

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

.NET JIT

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

Заметка:

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

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

Мэтью Лейтон
источник
4
Итак, почему это в 26 раз быстрее? Не могу найти в ответе.
usr
2
Я предполагаю, что это разница в джиттере, но не более чем догадки.
Джон Ханна,
2
@seriesOne: Я думаю, MSalters пытается сказать, что вы смешиваете IA-64 с x86-64. (Intel также использует IA-32e для x86-64 в своих руководствах). У всех настольные процессоры x86-64. Itanic затонул несколько лет назад и, я думаю, в основном использовался на серверах, а не на рабочих станциях. Core2 (первый процессор семейства P6, поддерживающий длинный режим x86-64) на самом деле имеет некоторые ограничения в 64-битном режиме. например, uop macro-fusion работает только в 32-битном режиме. Intel и AMD сделали то же самое: расширили свои 32-битные конструкции до 64-битных.
Питер Кордес,
1
@PeterCordes где я упомянул IA-64? Я знаю, что процессоры Itanium имели совершенно другой дизайн и набор инструкций; ранние модели, помеченные как EPIC или явно параллельные вычисления с инструкциями. Я думаю, что MSalters объединяет 64-битную версию и IA-64. Мой ответ верен для архитектуры x86-64 - там не было ничего, ссылающегося на семейство процессоров Itanium
Мэтью Лейтон,
2
@ series0ne: Хорошо, тогда ваш абзац о том, что процессоры Intel «чисто 64-битные» - это полная чушь. Я предположил, что вы думаете об IA-64, потому что тогда вы не ошиблись бы полностью. Для запуска 32-битного кода никогда не было дополнительного этапа перевода. Декодеры x86-> uop имеют два похожих режима: x86 и x86-64. Intel построила 64-битный P4 поверх P4. 64-битный Core2 поставлялся со многими другими архитектурными улучшениями по сравнению с Core и Pentium M, но такие вещи, как макро-слияние, работающее только в 32-битном режиме, показывают, что 64-битный был установлен. (довольно рано в процессе проектирования, но все же.)
Питер Кордес,