Когда сборка происходит быстрее, чем C? - PullRequest
440 голосов
/ 23 февраля 2009

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

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

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

Ответы [ 39 ]

255 голосов
/ 23 февраля 2009

Вот пример из реальной жизни: умножение с фиксированной запятой на старых компиляторах.

Они не только пригодятся на устройствах без плавающей запятой, они сияют, когда дело доходит до точности, так как они дают вам 32 бита с предсказуемой ошибкой (у плавающего есть только 23 бита, и труднее предсказать потерю точности). то есть равномерная абсолютная точность во всем диапазоне вместо почти равномерной относительной точности (float).


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


C не имеет оператора полного умножения (2N-битный результат от N-битных входов). Обычный способ выразить это в C - привести входные данные к более широкому типу и надеяться, что компилятор распознает, что старшие биты входных данных не интересны:

// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
  long long a_long = a; // cast to 64 bit.

  long long product = a_long * b; // perform multiplication

  return (int) (product >> 16);  // shift by the fixed point bias
}

Проблема с этим кодом заключается в том, что мы делаем то, что не может быть прямо выражено на языке Си. Мы хотим умножить два 32-битных числа и получить 64-битный результат, из которого мы возвращаем средний 32-битный. Однако в C это умножение не существует. Все, что вы можете сделать, это повысить целые числа до 64 бит и сделать умножение 64 * 64 = 64.

Однако

x86 (и ARM, MIPS и другие) могут выполнять умножение в одной инструкции. Некоторые компиляторы игнорировали этот факт и генерировали код, который вызывает функцию библиотеки времени выполнения для выполнения умножения. Сдвиг на 16 также часто выполняется библиотечной подпрограммой (такой же сдвиг может выполнять и x86).

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

Если вы переписываете тот же код на (встроенном) ассемблере, вы можете значительно увеличить скорость.

В дополнение к этому: использование ASM - не лучший способ решения проблемы. Большинство компиляторов позволяют вам использовать некоторые ассемблерные инструкции во внутренней форме, если вы не можете выразить их в C. Например, компилятор VS.NET2008 выставляет 32 * 32 = 64-битное значение mul как __emul, а 64-битное смещение как __ll_rshift.

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

Для справки: конечный результат для мульта с фиксированной запятой для компилятора VS.NET:

int inline FixedPointMul (int a, int b)
{
    return (int) __ll_rshift(__emul(a,b),16);
}

Разница в производительности делителей с фиксированной точкой еще больше. У меня были улучшения до коэффициента 10 для тяжелого кода с фиксированной точкой, написав пару asm-строк.


Использование Visual C ++ 2013 дает одинаковый код ассемблера для обоих способов.

gcc4.1 2007 года также прекрасно оптимизирует версию на чистом C. (В проводнике компилятора Godbolt не было установлено более ранних версий gcc, но, вероятно, даже более старые версии GCC могут делать это без встроенных функций.)

См. Source + asm для x86 (32-разрядная версия) и ARM для проводника компилятора Godbolt . (К сожалению, у него нет достаточно старых компиляторов для генерации плохого кода из простой версии на чистом C).


Современные процессоры могут делать то, что C не имеет операторов для вообще , например popcnt или битовое сканирование, чтобы найти первый или последний установленный бит . (POSIX имеет функцию ffs(), но его семантика не соответствует x86 bsf / bsr. См. https://en.wikipedia.org/wiki/Find_first_set).

Некоторые компиляторы могут иногда распознавать цикл, который подсчитывает количество установленных битов в целом числе и компилировать его в инструкцию popcnt (если она включена во время компиляции), но гораздо надежнее использовать __builtin_popcnt в GNU C или на x86, если вы ориентируетесь только на оборудование с SSE4.2: _mm_popcnt_u32 с <immintrin.h>.

Или в C ++ присвойте std::bitset<32> и используйте .count(). (Это тот случай, когда язык нашел способ портативного представления оптимизированной реализации popcount через стандартную библиотеку, таким образом, который всегда будет компилироваться во что-то правильное и может использовать все, что поддерживает цель.) Смотрите также https://en.wikipedia.org/wiki/Hamming_weight#Language_support.

Аналогично, ntohl может компилироваться в bswap (32-битный байт подкачки x86 для преобразования в порядковый номер) в некоторых реализациях C, которые его имеют.


Другая важная область для встроенной или рукописной ассм - это ручная векторизация с инструкциями SIMD. Компиляторы неплохи с простыми циклами, такими как dst[i] += src[i] * 10.0;, но часто работают плохо или вообще не векторизуются, когда все становится сложнее. Например, вы вряд ли получите что-то вроде Как реализовать atoi с использованием SIMD? автоматически генерируется компилятором из скалярного кода.

131 голосов
/ 23 февраля 2009

Много лет назад я учил кого-то программировать на C. Упражнение состояло в том, чтобы повернуть изображение на 90 градусов. Он вернулся с решением, которое заняло несколько минут, в основном потому, что он использовал умножения и деления и т. Д.

Я показал ему, как исправить проблему, используя сдвиги битов, и время обработки сократилось до 30 секунд на неоптимизирующем компиляторе, который у него был.

Я только что получил оптимизирующий компилятор, и тот же код поворачивал графику за <5 секунд. Я посмотрел на ассемблерный код, который генерировал компилятор, и из того, что я увидел, решил тут же, что мои дни написания ассемблера закончились. </p>

59 голосов
/ 23 февраля 2009

Практически каждый раз, когда компилятор видит код с плавающей запятой, рукописная версия будет быстрее. Основная причина заключается в том, что компилятор не может выполнять какие-либо робастные оптимизации. См. Эту статью из MSDN для обсуждения этой темы. Вот пример, где версия сборки в два раза быстрее, чем версия C (скомпилирована с VS2K5):

#include "stdafx.h"
#include <windows.h>

float KahanSum
(
  const float *data,
  int n
)
{
   float
     sum = 0.0f,
     C = 0.0f,
     Y,
     T;

   for (int i = 0 ; i < n ; ++i)
   {
      Y = *data++ - C;
      T = sum + Y;
      C = T - sum - Y;
      sum = T;
   }

   return sum;
}

float AsmSum
(
  const float *data,
  int n
)
{
  float
    result = 0.0f;

  _asm
  {
    mov esi,data
    mov ecx,n
    fldz
    fldz
l1:
    fsubr [esi]
    add esi,4
    fld st(0)
    fadd st(0),st(2)
    fld st(0)
    fsub st(0),st(3)
    fsub st(0),st(2)
    fstp st(2)
    fstp st(2)
    loop l1
    fstp result
    fstp result
  }

  return result;
}

int main (int, char **)
{
  int
    count = 1000000;

  float
    *source = new float [count];

  for (int i = 0 ; i < count ; ++i)
  {
    source [i] = static_cast <float> (rand ()) / static_cast <float> (RAND_MAX);
  }

  LARGE_INTEGER
    start,
    mid,
    end;

  float
    sum1 = 0.0f,
    sum2 = 0.0f;

  QueryPerformanceCounter (&start);

  sum1 = KahanSum (source, count);

  QueryPerformanceCounter (&mid);

  sum2 = AsmSum (source, count);

  QueryPerformanceCounter (&end);

  cout << "  C code: " << sum1 << " in " << (mid.QuadPart - start.QuadPart) << endl;
  cout << "asm code: " << sum2 << " in " << (end.QuadPart - mid.QuadPart) << endl;

  return 0;
}

И некоторые цифры с моего компьютера, на котором установлена ​​стандартная версия выпуска *:

  C code: 500137 in 103884668
asm code: 500137 in 52129147

Из интереса я поменял цикл с помощью dec / jnz, и это не имело никакого значения для времени - иногда быстрее, иногда медленнее. Я полагаю, что аспект с ограниченной памятью гномит другие оптимизации.

Ой, я запустил немного другую версию кода, и она вывела числа в неправильном направлении (то есть C был быстрее!). Исправлены и обновлены результаты.

55 голосов
/ 23 февраля 2009

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

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

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

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

45 голосов
/ 23 февраля 2009

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

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

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

    for (int y=0; y < imageHeight; y++) {
        for (int x=0; x < imageWidth; x++) {
           // do something
        }
    }
    

    «сделать что-то» обычно происходит порядка нескольких миллионов раз (т. Е. От 3 до 30). Соскребая циклы в этой фазе «сделать что-то», выигрыш в производительности значительно увеличивается. Я обычно не начинаю там - я обычно начинаю с того, что сначала пишу код для работы, а затем делаю все возможное, чтобы реорганизовать C, чтобы он был естественно лучше (лучший алгоритм, меньшая нагрузка в цикле и т. Д.). Мне обычно нужно читать ассемблер, чтобы увидеть, что происходит, и редко нужно его писать. Я делаю это, может быть, каждые два или три месяца.

  3. делать что-то, что язык не позволит мне. К ним относятся - получение архитектуры процессора и конкретных функций процессора, доступ к флагам, не входящим в процессор (мужик, я действительно хотел бы, чтобы C предоставил вам доступ к флагу переноса) и т. Д. Я делаю это, возможно, раз в год или два года.

40 голосов
/ 23 февраля 2009

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

Чтобы максимизировать вычислительную мощность современного ЦП с несколькими конвейерами и прогнозирующим ветвлением, вам необходимо структурировать программу сборки так, чтобы человек а) практически не мог писать, б) еще более невозможно поддерживать. *

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

38 голосов
/ 23 февраля 2009

Хотя C "близок" к низкоуровневой манипуляции с 8-битными, 16-битными, 32-битными, 64-битными данными, есть несколько математических операций, не поддерживаемых C, которые часто могут выполняться элегантно в определенные наборы инструкций по сборке:

  1. Умножение с фиксированной запятой: произведение двух 16-разрядных чисел представляет собой 32-разрядное число. Но правила в Си говорят, что произведение двух 16-битных чисел - это 16-битное число, а произведение двух 32-битных чисел - это 32-битное число - нижняя половина в обоих случаях. Если вы хотите, чтобы top половина умножения 16x16 или умножения 32x32, вы должны играть в игры с компилятором. Общий метод заключается в приведении к битовой ширине, которая больше необходимой, умножении, сдвиге вниз и приведении назад:

    int16_t x, y;
    // int16_t is a typedef for "short"
    // set x and y to something
    int16_t prod = (int16_t)(((int32_t)x*y)>>16);`
    

    В этом случае компилятор может быть достаточно умен, чтобы знать, что вы на самом деле просто пытаетесь получить верхнюю половину умножения 16x16 и делать правильные вещи с собственным умножением 16x16. Или это может быть глупо и требовать от библиотеки вызова для умножения 32x32, что является излишним, потому что вам нужно только 16 бит продукта - но стандарт C не дает вам никакого способа выразить себя.

  2. Некоторые операции сдвига битов (ротация / переносы):

    // 256-bit array shifted right in its entirety:
    uint8_t x[32];
    for (int i = 32; --i > 0; )
    {
       x[i] = (x[i] >> 1) | (x[i-1] << 7);
    }
    x[0] >>= 1;
    

    Это не слишком не элегантно в C, но, опять же, если компилятор не достаточно умен, чтобы понять, что вы делаете, он выполнит много "ненужной" работы. Многие наборы инструкций по сборке позволяют вращать или сдвигать влево / вправо с результатом в регистре переноса, поэтому вы можете выполнить вышеизложенное в 34 инструкциях: загрузить указатель на начало массива, очистить перенос и выполнить 32. сдвиг вправо по битам, используя автоинкремент по указателю.

    В другом примере есть регистры сдвига с линейной обратной связью (LFSR), которые элегантно выполняются при сборке: возьмите блок из N битов (8, 16, 32, 64, 128 и т. Д.), Сдвиг все это верно на 1 (см. алгоритм выше), тогда, если результирующий перенос равен 1, тогда вы XOR в битовой комбинации, которая представляет полином.

Сказав это, я бы не прибегал к этим методам, если у меня не было серьезных ограничений производительности. Как уже говорили другие, сборка намного сложнее документировать / отлаживать / тестировать / поддерживать, чем код C: выигрыш в производительности сопряжен с серьезными затратами.

edit: 3. Обнаружение переполнения возможно в сборке (на самом деле это невозможно сделать в C), это делает некоторые алгоритмы намного проще.

23 голосов
/ 23 февраля 2009

Краткий ответ? Иногда.

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

Язык программирования C - A язык, который сочетает в себе Гибкость языка ассемблера с сила ассемблера.

Забавно, потому что это правда: C похож на переносимый язык ассемблера.

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

Когда появился gcc, одна из вещей, которые сделали его настолько популярным, это то, что он часто был намного лучше, чем компиляторы C, которые поставлялись со многими коммерческими разновидностями UNIX. Мало того, что это был ANSI C (ни один из этих мусоров K & R C), он был более надежным и, как правило, создавал лучший (более быстрый) код. Не всегда, но часто.

Я говорю вам все это, потому что нет общего правила относительно скорости C и ассемблера, потому что нет никакого объективного стандарта для C.

Аналогично, ассемблер сильно различается в зависимости от того, какой процессор вы используете, какие у вас системные характеристики, какой набор инструкций вы используете и так далее. Исторически существовало два семейства процессорных архитектур: CISC и RISC. Самым крупным игроком в CISC была и остается архитектура Intel x86 (и набор инструкций). RISC доминировал в мире UNIX (MIPS6000, Alpha, Sparc и т. Д.). CISC выиграл битву за сердца и умы.

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

Наборы инструкций являются важным моментом даже в одном семействе процессоров. Некоторые процессоры Intel имеют такие расширения, как SSE - SSE4. У AMD были свои собственные инструкции SIMD. Преимущество такого языка программирования, как C, заключается в том, что кто-то может написать свою библиотеку, чтобы она была оптимизирована для любого процессора, на котором вы работали. Это была тяжелая работа на ассемблере.

В ассемблере все еще есть возможности оптимизации, которые не может сделать ни один компилятор, и хорошо написанный алгоритм ассемблера будет работать быстрее или быстрее, чем его эквивалент в Си. Главный вопрос: стоит ли это того?

В конечном счете, ассемблер был продуктом своего времени и был более популярен в то время, когда циклы ЦП были дорогими. В настоящее время процессор, стоимость которого составляет 5-10 долларов (Intel Atom), может делать практически все, что угодно. Единственная реальная причина для написания ассемблера в наши дни - это низкоуровневые вещи, такие как некоторые части операционной системы (несмотря на то, что подавляющее большинство ядра Linux написано на C), драйверы устройств, возможно встроенные устройства (хотя C имеет тенденцию доминировать там). тоже) и тд. Или просто для ударов (что несколько мазохистски).

15 голосов
/ 23 февраля 2009

Вариант использования, который может больше не применяться, но для вашего удовольствия: на Amiga ЦП и графические / аудиочипы будут бороться за доступ к определенной области ОЗУ (первые 2 МБ ОЗУ будут специфическими). Поэтому, когда у вас было только 2 МБ ОЗУ (или меньше), отображение сложной графики и воспроизведение звука снизили бы производительность ЦП.

В ассемблере вы можете чередовать свой код таким умным способом, что ЦП будет пытаться получить доступ к ОЗУ только тогда, когда графические / аудиочипы заняты внутри (то есть, когда шина была свободна). Таким образом, переупорядочивая ваши инструкции, умело используя кэш ЦП, синхронизацию шины, вы могли достичь некоторых эффектов, которые были просто невозможны при использовании любого языка более высокого уровня, потому что вам приходилось синхронизировать каждую команду, даже вставлять NOP здесь и там, чтобы сохранить различные чипы друг от друга радар.

Это еще одна причина, по которой инструкция ЦПУ NOP (Без операции - ничего не делать) может на самом деле ускорить работу всего приложения.

[РЕДАКТИРОВАТЬ] Конечно, техника зависит от конкретной настройки оборудования. Именно поэтому многие игры Amiga не справлялись с более быстрыми процессорами: время выполнения инструкций было выключено.

15 голосов
/ 23 февраля 2009

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

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

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

Мой друг работает над микроконтроллерами, в настоящее время чипами для управления маленькими электродвигателями. Он работает в комбинации низкого уровня c и Assembly. Однажды он рассказал мне о хорошем дне на работе, когда он сократил основной цикл с 48 инструкций до 43. Он также столкнулся с выбором, например, когда код расширился, чтобы заполнить чип 256 КБ, и бизнес хочет новую функцию, не так ли

  1. Удалить существующую функцию
  2. Уменьшите размер некоторых или всех существующих функций, возможно, за счет производительности.
  3. Пропагандируйте переход на более крупную микросхему с более высокой стоимостью, более высоким энергопотреблением и большим форм-фактором.

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

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

так что давайте попробуем еще раз Вы должны думать о сборке

  • работает на низкоуровневой функции операционной системы
  • Работа над компилятором.
  • Работа на чрезвычайно ограниченном чипе, встроенной системе и т. Д.

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

Дэвид.

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