Может ли x86 выполнять операции FPU независимо или параллельно?

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

float a = 3.14;
float b = 5.12;
float c;
float d = 3.02;
float e = 2.52;
float f;
c = a + b;
f = e + d;

Итак, как я слышал, 2 операции добавления выше будут выполняться быстрее, чем:

float a = 3.14;
float b = 5.12;
float c;
float d = 3.02;
float e = 2.52;
float f;
c = a + b;
f = c + d;

потому что процессор должен ждать, пока c становится вычисляемым.

Я хотел проверить это, поэтому я написал функцию, которая делает вторую вещь, и она измеряет время, проверяя счетчик времени:

flds    h # st(7)
flds    g # st(6)
flds    f # st(5)
flds    e # st(4)
flds    d # st(3)
flds    c # st(2)
flds    b # st(1)
flds    a # st(0)
fadd    %st, %st(1) # i = a + b
fmul    %st, %st(2) # j = i * c
fadd    %st, %st(3) # k = j + d
fmul    %st, %st(4) # l = k + e
fadd    %st, %st(5) # m = l + f
fmul    %st, %st(6) # n = m * g
fadd    %st, %st(7) # o = n + h

те не являются независимыми. Теперь я пытаюсь писать независимые. Но проблема в том, что независимо от того, что я на самом деле делаю, значение всегда сохраняется в ST(0) (независимо от того, какую инструкцию я использую), при желании ее можно вытащить, но это все равно означает, что мы должны подождать до вычисления.

Я посмотрел на код, генерируемый компилятором (gcc -S). Он просто не работает так на st регистры. Для каждого числа он делает:

flds number
fstps -some_value(%ebp)

а затем (например, для а и Б, где -4(%ebp) это, -8(%ebp) is b):

flds    -4(%ebp)
fadds   -8(%ebp) # i = a + b
fstps   -32(%ebp)

поэтому он сначала загружается в FPU и возвращается к обычному стеку. Затем появляется одно значение (to st(0)), добавляет к этому значению, и результат возвращается. Так что это все еще не независимо, потому что мы должны ждать, пока st(0) освобождается.

1 ответов


в стиле PolitiFact, я бы оценил утверждение вашего учителя о том, что" процессор иногда может выполнять операции FPU параллельно "как"полуправда". В некоторых смыслах и при определенных условиях это совершенно верно; в других смыслах это вовсе не так. Таким образом, общее заявление вводит в заблуждение и может быть неверно истолковано.

теперь, скорее всего, ваш учитель сказал это в очень конкретном контексте, сделав некоторые предположения о том, что (Ы) он уже говорил вам ранее, и вы не включили все это в вопрос, поэтому я не буду обвинять их в преднамеренном вводе в заблуждение. Вместо этого я попытаюсь прояснить это общее утверждение, указав некоторые способы, в которых оно истинно, и другие способы, в которых оно ложно.

большая точка прилипания-это именно то, что подразумевается под "операциями FPU". Классически процессоры x86 выполняли операции FPU на отдельном сопроцессоре с плавающей запятой (известном как блок с плавающей запятой, или ФПУ), в x87, так. До процессора 80486 это был отдельный чип, установленный на основной плате. Начиная с 80486DX, x87 FPU был интегрирован непосредственно в тот же кремний, что и основной процессор, и поэтому был доступен во всех системах, а не только в тех, в которых был установлен специализированный x87 FPU. Это остается верным сегодня-все процессоры x86 имеют встроенный x87-совместимый FPU, и это, как правило, то, что люди ссылаются, когда они говорят "FPU" в контексте x86 микроархитектура.

однако FPU x87 редко используется для операций с плавающей запятой. Хотя он все еще существует, он был эффективно заменен блоком SIMD, который легче программировать и (в целом) более эффективен.

AMD была первой, кто представил такую специализированную векторную единицу со своим 3DNow! технология в микропроцессоре К6-2 (около 1998). По различным техническим и маркетинговым причинам это действительно не использовалось, за исключением некоторые игры и другие специализированные приложения так и не попали в индустрию (AMD с тех пор постепенно отказалась от современных процессоров), но она поддерживала арифметические операции с упакованными значениями с плавающей запятой с одной точностью.

SIMD действительно начал цепляться, когда Intel выпустила расширение SSE с процессором Pentium III. SSE был похож на 3DNow!, в том, что он поддерживал векторные операции над значениями с плавающей запятой с одной точностью, но был несовместим с ним и поддерживается несколько больший диапазон операций. AMD быстро добавила поддержку SSE для своих процессоров. Действительно хорошая вещь о SSE по сравнению с 3DNow! было то, что он использовал совершенно отдельный набор регистров, что сделало Программирование намного проще. С Pentium 4 Intel выпустила SSE2, который был расширением SSE, что добавило поддержку значений с плавающей запятой двойной точности. SSE2 поддерживается на все процессоры, поддерживающие 64-разрядные расширения длинного режима (AMD64), которые все процессоры сделаны сегодня, поэтому 64-битный код практически всегда использует инструкции SSE2 для управления значениями с плавающей запятой, а не инструкции x87. Даже в 32-битном коде инструкции SSE2 сегодня широко используются, поскольку все процессоры, начиная с Pentium 4, поддерживают их.

помимо поддержки устаревших процессоров, есть только одна причина использовать инструкции x87 сегодня, и это то, что x87 FPU поддерживал специальный" длинный двойной " формат с 80 немного точности. SSE поддерживает только одну точность (32-разрядную), в то время как SSE2 добавил поддержку значений двойной точности (64-разрядной). Если вам абсолютно нужна расширенная точность, то x87-ваш лучший вариант. (На уровне отдельных инструкций он сопоставим по скорости с блоками SIMD, работающими на скалярных значениях.) В противном случае вы предпочитаете SSE/SSE2 (и более поздние расширения SIMD для набора инструкций, такие как AVX и т. д.) И, конечно, когда я говорю "Вы", я имею в виду не только ассемблер программисты; я также имею в виду компиляторы. Например, Visual Studio 2010 была последней основной версией, которая по умолчанию выдавала код x87 для 32-разрядных сборок. Во всех более поздних версиях инструкции SSE2 генерируются, если их специально не отключить (/arch:IA32).

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

но, как я уже говорил, Причина, по которой я называю это утверждение вводящим в заблуждение, заключается в том, что когда кто-то говорит "FPU", как правило, понимается как x87 FPU, и в этом случае варианты независимого, параллельного выполнения являются существенно более ограничены. инструкции x87 FPU-это все те, чья мнемоника начинается с f, включая FADD, FMUL, FDIV, FLD, FSTP, etc. Эти инструкции не может пара* и поэтому никогда не может быть выполнен по-настоящему независимо.

существует только одно специальное исключение правило, что инструкции x87 FPU не могут сопрягаться, и это FXCH инструкция (обмен с плавающей запятой). FXCH можете пара, когда это происходит, как вторая инструкция в паре, пока первая инструкция в паре либо FLD, FADD, FSUB, FMUL, FDIV, FCOM, FCHS или FABS, и следующая инструкция После FXCHG также инструкция с плавающей запятой. Таким образом, это охватывает наиболее распространенные случаи, когда вы бы использовали FXCHG. As Iwillnotexist Idonotexist упоминается в комментарии, эта магия реализуется внутри через переименование регистра:FXCH инструкция фактически не меняет содержимое двух регистров, как вы можете себе представить; она только меняет имена регистров. На процессорах Pentium и более поздних процессорах регистры могут быть переименованы во время их использования и даже могут быть переименованы более одного раза за сутки без каких-либо сбоев. Эта функция на самом деле очень важно поддерживать максимальную производительность в коде x87. Почему? Ну, x87 необычен тем, что он имеет интерфейс на основе стека. Его "регистры" (st0 через st7) реализованы в виде стека, и несколько инструкций с плавающей запятой работают только со значением в верхней части стека (st0). Но функция, которая позволяет использовать интерфейс на основе стека FPU достаточно эффективно, вряд ли считается "независимым" выполнением.

, это правда, что многие операции x87 FPU могут перекрытие. Это работает так же, как и любой другой тип инструкции: начиная с Pentium, процессоры x86 были pipelined, что фактически означает, что инструкции выполняются на разных этапах. (Чем длиннее конвейер, тем больше этапов выполнения, что означает, что больше инструкций процессор может работать одновременно, что также обычно означает, что быстрее процессор может быть синхронизирован. Однако, оно имеет другое недостатки, как высшее наказание за ветки mispredicted, но я отвлекся.) Таким образом, хотя каждая инструкция по-прежнему требует фиксированного числа циклов для завершения, возможно, что инструкция начнет выполняться до завершения предыдущей. Например:
fadd  st(1), st(0)    ; clock cycles 1 through 3
fadd  st(2), st(0)    ; clock cycles 2 through 4
fadd  st(3), st(0)    ; clock cycles 3 through 5
fadd  st(4), st(0)    ; clock cycles 4 through 6

на FADD инструкция занимает 3 такта для выполнения, но мы можем начать новый FADD на каждом такте. Как вы можете видеть, это можно сделать до 4 FADD операции только в 6 тактах, которые в два раза быстрее, чем 12 тактовых циклов, которые это возьмет на себя непроверенный FPU.

естественно, как вы говорите в вопросе, это перекрытие требует, чтобы между двумя инструкциями не было зависимостей. Другими словами, две инструкции не могут перекрываться, если вторая требует результата первой. На практике это, к сожалению, означает, что выгоды от этого конвейеризации ограничены. Из-за стековой архитектуры FPU, о которой я упоминал ранее, и тот факт, что большинство инструкций с плавающей запятой включают значение в верхней части стека (st(0)), существует чрезвычайно мало случаев, когда инструкция может быть независимой от результата предыдущей инструкции.

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

fld  [a1]   ; cycle 1
fadd [a2]   ; cycles 2-4
fld  [b1]   ; cycle 3
fadd [b2]   ; cycles 4-6
fld  [c1]   ; cycle 5
fadd [c2]   ; cycles 6-8
fxch st(2)  ; cycle 6 (pairs with previous instruction)
fadd [a3]   ; cycles 7-9
fxch st(1)  ; cycle 7 (pairs with previous instruction)
fadd [b3]   ; cycles 8-10
fxch st(2)  ; cycle 8 (pairs with previous instruction)
fadd [c3]   ; cycles 9-11
fxch st(1)  ; cycle 9 (pairs with previous instruction)
fadd [a4]   ; cycles 10-12
fxch st(2)  ; cycle 10 (pairs with previous instruction)
fadd [b4]   ; cycles 11-13
fxch st(1)  ; cycle 11 (pairs with previous instruction)
fadd [c4]   ; cycles 12-14
fxch st(2)  ; cycle 12 (pairs with previous instruction)

в этом коде были чередованы три независимых вычисления: (a1 + a2 + a3 + a4), (b1 + b2 + b3 + b4) и (c1 + c2 + c3 + c4). С каждого FADD занимает 3 такта, после того, как мы пнуть a вычисление, у нас есть два "свободных" цикла для запуска двух новых FADD инструкция b и c вычисления перед возвращением в a расчет. Каждый третий FADD инструкция возвращается к исходному вычислению, следуя регулярному шаблону. Между FXCH инструкции используются, чтобы сделать верхнюю часть стека (st(0)) содержит значение, принадлежащее соответствующему вычислению. Эквивалентный код может быть написан для FSUB, FMUL и FILD, так как все три принимают 3 такта и могут перекрываться. (Ну, кроме этого, по крайней мере, на Pentium-я не уверен, что это справедливо для более поздних процессоров, так как я больше не использую x87 -FMUL инструкция не идеально конвейерная, поэтому вы не можете запустить FMUL один такт за другим FMUL. У вас либо есть стойло, либо вы должны бросить другую инструкцию между ними.)

я полагаю, что это то, что имел в виду ваш учитель. На практике, однако, даже с помощью магии FXCHG инструкция, это довольно сложно написать код, который действительно достигает значительных уровней параллелизма. Вам нужно иметь несколько независимых вычислений, которые вы можете чередовать, но во многих случаях вы просто вычисляете одну большую формулу. Иногда есть способы вычислить части формулы независимо, параллельно, а затем объединить их в конце, но у вас неизбежно будут киоски, которые уменьшают общую производительность, и не все инструкции с плавающей запятой могут перекрываться. Как вы могли представьте себе, это так трудно достичь, что компиляторы редко (в значительных масштабах). Для этого требуется человек с решимостью и стойкостью для ручной оптимизации кода, ручного планирования и чередования инструкций.

одно и чаще всего возможно чередование инструкций с плавающей запятой и целых чисел. Инструкции, как FDIV медленны (~39 циклов на Pentium) и не перекрываются с другими инструкциями с плавающей запятой; однако он может перекрываться целочисленными инструкциями на всех, кроме первого такта. (Всегда есть предостережения, и это не исключение: деление с плавающей запятой не может перекрываться целочисленным делением, потому что они обрабатываются одной и той же единицей выполнения почти на всех процессорах.) Что-то подобное можно было бы сделать с FSQRT. Компиляторы С несколько большей вероятностью будут выполнять эти типы оптимизаций, предполагая, что вы написали код, в котором целочисленные операции чередуются операции с плавающей запятой (встраивание помогает значительно с этим), но все же во многих случаях, когда вы делаете расширенные вычисления с плавающей запятой, у вас есть небольшая целочисленная работа, которую нужно сделать.


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

(кстати, это большой стратегия и один из основных способов, которым я научился писать и оптимизировать сборочный код. И основываясь на выводе компилятора, я все еще начинаю, когда хочу оптимизировать конкретный фрагмент кода.)

как я уже упоминал выше, современные компиляторы не генерируют инструкции x87 FPU. Они!--90-->никогда do для 64-битных сборок, поэтому вы должны начать с компиляции в 32-битном режиме. Затем обычно необходимо указать переключатель компилятора, который указывает ему не использовать инструкции SSE. В MSVC это /arch:IA32. В компиляторах Gnu-стиля, таких как GCC и Clang, это -mfpmath=387 и/или -mno-sse.

есть еще один маленький ниггль, который объясняет, что вы на самом деле видели. Код C, который вы писали, использовал float type, который является типом с одной точностью (32-бит). Как вы узнали выше, x87 FPU использует специальную 80-битную" расширенную " точность внутренне. Это несоответствие точности может повлиять на вывод операций с плавающей запятой, поэтому для строгого соблюдения стандартов IEEE-754 и языковых стандартов компиляторы по умолчанию используют "строгий" или "точный" режим при использовании FPU x87, где они сбрасывают точность каждой промежуточной операции до 32-разрядной. Вот почему вы видите узор, который вы видите:

flds    -4(%ebp)
fadds   -8(%ebp) # i = a + b
fstps   -32(%ebp)

он загружает значение с одной точностью в верхней части стека FPU, неявно расширяя это значение до 80-битного точность. Это FLDS инструкция. Затем FADDS инструкция делает комбинацию load-and-add: она сначала загружает значение с одной точностью, неявно расширяя его до 80-битной точности и добавляет Это к значению в верхней части стека FPU. Наконец, он выводит результат во временное место в памяти, смывая его до 32-разрядного значения с одной точностью.

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

если вы хотите предотвратить это и получить самый быстрый код с плавающей запятой, даже за счет корректности, вам нужно передать флаг, чтобы указать это компилятору. На MSVC это /fp:fast. На компиляторах в стиле Gnu, таких как GCC и Clang, это -ffast-math.

пара советов:

  • когда вы анализируете компилятором разборки, всегда убедитесь, что вы смотрите на оптимизированный код. Не беспокойтесь о неоптимизированном коде; он очень шумный, просто запутает вас и не соответствует тому, что написал бы настоящий программист сборки. Для MSVC используйте /O2 переключатель; для GCC / Clang используйте -O2 или -O3 переключатели.

  • Если вам просто не нравится синтаксис AT&T, настройте компилятор Gnu или дизассемблер для создания синтаксических списков Intel-format. Это гарантирует, что вывод будет выглядеть как код, который вы увидите в руководствах Intel или других книгах по программированию на ассемблере. Для компилятора используйте параметры -S -masm=intel. Для objdump используйте параметры -d -M intel. Это не обязательно с компилятором Microsoft, так как он никогда не использует AT&T синтаксис.


* начиная с процессора Pentium (около 1993 года), целочисленные инструкции, выполняемые на основной части процессора, могут быть "сопряжены". Это было сделано процессором, фактически имеющим два в основном независимых блока выполнения, известных как "U" - канал и "V" - канал. Естественно, были некоторые оговорки к этому сопряжению-труба" V "была более ограничена в инструкциях, которые она могла выполнить, чем труба" U, таким образом, определенные инструкции и комбинации инструкций были непарабельны, но в целом эта возможность сопряжения удвоила эффективную полосу пропускания Pentium, сделав его значительно быстрее, чем его предшественник (486) на коде, который был написан соответственно. Я говорю здесь о том, что, в отличие от основной целочисленной стороны процессора, x87 FPU did не поддержка этого типа сопряжения.