Какие уловки оптимизации низкоуровневого кода вам нравятся больше всего?

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

Например: разворачивание цикла .

Ответов (24)

Выбор степени двойки для фильтров, кольцевых буферов и т. Д.

Очень-очень удобно.

-Адам

gcc -O2

Компиляторы справляются с этим намного лучше, чем вы.

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

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

Но мне понравился синтаксис ... он читается как «приращение i», что является разумным порядком.

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

Например

for (i = 0;  i < n;  ++i)
    *p++ = ...; // some complicated expression

против.

for (i = 0;  i < n;  ++i)
    p[i] = ...; // some complicated expression

Тот из Ассемблера:

xor ax, ax

вместо того:

mov ax, 0

Классическая оптимизация под размер и производительность программы.

Оптимизация местоположения кеша - например, при умножении двух матриц, которые не помещаются в кеш.

В SQL, если вам нужно только знать, существуют ли какие-либо данные или нет, не беспокойтесь о COUNT(*) :

SELECT 1 FROM table WHERE some_primary_key = some_value

Если ваше WHERE предложение, скорее всего, вернет несколько строк, добавьте LIMIT 1 также.

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

Один из самых полезных в научном коде - заменить pow(x,4) на x*x*x*x . Pow почти всегда дороже умножения. Далее следует

  for(int i = 0; i < N; i++)
  {
    z += x/y;
  }

к

  double denom = 1/y;
  for(int i = 0; i < N; i++) 
  {
    z += x*denom;
  }

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

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

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

Да ну , конечно же!

  • Внезапное повторное использование указателя кадра
  • Соглашение о вызовах Паскаля
  • Перепишите оптимизацию хвостового вызова стека-кадра (хотя иногда это противоречит приведенному выше)
  • Использование vfork()вместо fork()доexec()
  • И я все еще ищу оправдание для использования: генерация кода на основе данных во время выполнения.

Выделение new в предварительно выделенном буфере с использованием C++ размещения new.

Отсчет петли. Дешевле сравнивать с 0, чем с N:

for (i = N; --i >= 0; ) ...

Сдвиг и маскирование степенями двойки дешевле, чем деление и остаток, / и%

#define WORD_LOG 5
#define SIZE (1 << WORD_LOG)
#define MASK (SIZE - 1)

uint32_t bits[K]

void set_bit(unsigned i)
{
    bits[i >> WORD_LOG] |= (1 << (i & MASK))
}

Редактировать

(i >> WORD_LOG) == (i / SIZE) and
(i & MASK) == (i % SIZE)

потому что РАЗМЕР 32 или 2 ^ 5.

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

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

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

  • Наличие кеша LRU запросов к базе данных (или любого другого запроса на основе IPC).
  • Запоминание последнего неудавшегося запроса к базе данных и возврат сбоя при повторном запросе в течение определенного периода времени.
  • Запоминание вашего местоположения в большой структуре данных, чтобы гарантировать, что если следующий запрос будет для того же узла, поиск будет бесплатным.
  • Кеширование результатов расчетов для предотвращения дублирования работы. Помимо более сложных сценариев, это часто встречается в операторах ifили for.

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

предварительный расчет значений.

Например, вместо sin (a) или cos (a), если вашему приложению не обязательно нужны точные углы, возможно, вы представляете углы в 1/256 окружности и создаете массивы чисел с плавающей запятой sine [] и косинус [], предварительно вычисляющий sin и cos этих углов.

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

Или, говоря в более общем смысле, поменяйте память на скорость.

Или, в более общем смысле, «Все программирование - это упражнение в кэшировании» - Терье Матисен.

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

    для (x = 0; x <maxx; x ++)
       для (y = 0; y <maxy; y ++)
          do_something (a [x, y]);

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

   для (y = 0; y <maxy; y ++)
       для (x = 0; x <maxx; x ++)
           do_something (a [x, y]);

или наоборот.

Не разворачивайте петли. Не делай устройства Даффа. Сделайте ваши циклы как можно меньше, все остальное снижает производительность x86 и производительность оптимизатора gcc.

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

И, особенно для x86, постарайтесь уменьшить количество одновременно используемых переменных. Трудно сказать, что компиляторы будут делать с подобными вещами, но обычно при меньшем количестве переменных итераций цикла / индексов массивов в конечном итоге вывод asm будет лучше.

Конечно, это для настольных процессоров; медленный процессор с быстрым доступом к памяти может вычислить намного больше, но в наши дни это может быть встроенная система с небольшим объемом памяти…

Книга Джона Бентли « Написание эффективных программ» - отличный источник техник низкого и высокого уровня - если вы можете найти копию.

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

Удаление ветвей (if / elses) с помощью логической математики:

if(x == 0)
    x = 5;

// becomes:

x += (x == 0) * 5;
// if '5' was a base 2 number, let's say 4:
x += (x == 0) << 2;

// divide by 2 if flag is set
sum >>= (blendMode == BLEND);

Это ДЕЙСТВИТЕЛЬНО ускоряет работу, особенно когда эти if находятся в цикле или где-то, что часто вызывается.

Закатываем петли.

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

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

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

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

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

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

В дополнение к комментарию Джошуа о генерации кода (большая победа) и другим хорошим предложениям ...

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

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

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

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

Я был поражен ускорением, которое я получил, заменив цикл for на добавление чисел в структуры:

const unsigned long SIZE = 100000000;

typedef struct {
    int a;
    int b;
    int result;
} addition;

addition *sum;

void start() {
    unsigned int byte_count = SIZE * sizeof(addition);

    sum = malloc(byte_count);
    unsigned int i = 0;

    if (i < SIZE) {
        do {
            sum[i].a = i;
            sum[i].b = i;
            i++;
        } while (i < SIZE);
    }    
}

void test_func() {
    unsigned int i = 0;

    if (i < SIZE) { // this is about 30% faster than the more obvious for loop, even with O3
        do {
            addition *s1 = &sum[i];
            s1->result = s1->b + s1->a;
            i++;
        } while ( i<SIZE );
    }
}

void finish() {
    free(sum);
}

Почему gcc не оптимизирует циклы для этого? Или я что-то упустил? Какой-то эффект кеширования?