Как оптимизировать цикл C for?

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

Профилирование проблемы показывает, что программа тратит много времени просто на увеличение обоих счетчиков циклов (++) и проверку завершения (i/j ‹ 8).

Наблюдая за выводом сборки, я вижу, что оба счетчика не получают регистры, и доступ к ним стоит много циклов. Использование ключевого слова «register» не убеждает компилятор фактически помещать их в регистры. можно ли что-то сделать, чтобы оптимизировать время доступа к счетчикам?

Вот результат сборки. Исходный код C представляет собой простой вложенный цикл со счетчиками i/j.

  2738  0.2479  2459  0.1707   :    1e6c:   jne    1dd1 <process_hooks+0x121>
  1041  0.0942  1120  0.0778   :    1e72:   addl   $0x1,0xffffffd4(%ebp)
  2130  0.1928  2102  0.1459   :    1e76:   cmpl   $0x8,0xffffffd4(%ebp)
  2654  0.2403  2337  0.1622   :    1e7a:   jne    1da0 <process_hooks+0xf0>
  809   0.0732   814  0.0565   :    1e80:   jmp    1ce2 <process_hooks+0x32>

Как и просили, вот код C. Компилятор gcc кстати:

for (byte_index=0; byte_index < MASK_SIZE / NBBY; byte_index++)
{
    if (check_byte(mask,byte_index))
    {
        for (bit_index=0; bit_index < NBBY; bit_index++)
        {
            condition_index = byte_index*NBBY + bit_index;
            if (check_bit(condition_mask,condition_index))
            {
                .
                .
                .
            }
        }
    }
}

Спасибо


person Nir    schedule 30.07.2009    source источник
comment
Можете ли вы также опубликовать код на C, чтобы здравомыслящие программисты могли понять, что не так?   -  person the_drow    schedule 30.07.2009
comment
Действительно, код может содержать что-то, что делает вполне разумным не назначать регистры для счетчиков.   -  person sharptooth    schedule 30.07.2009
comment
не могли бы вы опубликовать исходный код C?   -  person peterchen    schedule 30.07.2009
comment
Кроме того, вы создаете конфигурацию выпуска с полной оптимизацией?   -  person peterchen    schedule 30.07.2009
comment
Хорошо, код C опубликован. Я использую флаг оптимизации (-O2 в gcc) при компиляции.   -  person Nir    schedule 30.07.2009
comment
Кроме того, я прекрасно понимаю, что компилятор может лучше меня знать, какие переменные должны получить регистры, и форсирование регистровых переменных здесь может быть не подходящим способом.   -  person Nir    schedule 30.07.2009
comment
См. также: stackoverflow.com/questions/535039. Может быть, эти два вопроса можно объединить?   -  person Paul Biggar    schedule 30.07.2009
comment
Почему только -О2? Используйте -O3 -Wall -Wextra :)   -  person Christoffer    schedule 30.07.2009
comment
вы указываете процессор в параметрах gcc (-mtune=cpu-type -march=cpu-type)? может быть, у вас совершенно новый процессор с большим количеством регистров, но gcc воздерживается от использования их всех, чтобы сделать исполняемый файл более переносимым со старым оборудованием...   -  person fortran    schedule 30.07.2009
comment
В качестве общего совета, если это вообще возможно, попробуйте выполнить цикл от N до 0, а не от 0 до N. Цикл с уменьшением счетчика до 0 часто имеет специальную инструкцию (например, LOOP в x86).   -  person Pavel Minaev    schedule 30.07.2009
comment
Как определяются MASK_SIZE и NBBY?   -  person Jim Buck    schedule 30.07.2009
comment
Как сказал Кристоффер, почему только -O2? -O2 не включает оптимизации, которые могут повлиять на размер кода, такие как развертывание цикла, что действительно поможет вам, если вы обнаружите, что узким местом является увеличение счетчика.   -  person Todd Gamblin    schedule 31.07.2009
comment
почему ни один из ответов не принят? Я думаю, что между ответами есть несколько довольно хороших советов. Достойно только наградить одного из них за приложенные усилия.   -  person Toad    schedule 08.08.2009
comment
Павел, а какой компилятор вам не помешает? И +1 за вопрос о производительности, которая действительно имеет реальные данные.   -  person GManNickG    schedule 18.10.2009
comment
Я знаю, что спецификация не требует, чтобы компилятор слушал вас, но изменяет ли использование ключевого слова register для объявления byte_index испускаемый asm?   -  person KitsuneYMG    schedule 19.10.2009


Ответы (16)


Есть две возможные причины, по которым он не попадает в реестр:

Переменная должна храниться в памяти

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

gcc плохо справляется с распределением регистров.

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

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

Вы также можете попробовать попросить gcc приложить больше усилий с помощью горячих атрибут.

Наконец, вы также можете настраивать вещи, используя флаги --param gcc. Они раскрывают внутренние настройки компилятора, поэтому к этому, вероятно, не следует относиться легкомысленно.

person Paul Biggar    schedule 30.07.2009
comment
›› gcc плохо справляется с распределением регистров. Или, возможно, он хорошо справляется с распределением регистров и вместо этого использует регистры для других целей/ - person jcoder; 31.07.2009
comment
Может быть - трудно сказать. Но так как gcc имеет очень плохой распределитель регистров, это не было бы моим первым предположением. - person Paul Biggar; 31.07.2009

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

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

person Laserallan    schedule 30.07.2009
comment
развертывание циклов слишком далеко на самом деле плохо для производительности. Так как это может привести к тому, что весь цикл (или другие биты) больше не будет помещаться в кеш. - person Toad; 30.07.2009
comment
Обычно компилятор пытается развернуть цикл, если считает, что это улучшит производительность. - person Geerad; 30.07.2009
comment
Если это может привести к тому, что цикл не помещается в кеш, то это может плохо сказаться на производительности. «на самом деле плохо» — это преувеличение. Измерьте, прежде чем оптимизировать. - person Tadeusz A. Kadłubowski; 30.07.2009
comment
Компиляторы C иногда не могут развернуться так агрессивно, как должны, из-за псевдонимов указателей. Это означает, что некоторая ручная работа иногда может иметь значение. - person Laserallan; 30.07.2009
comment
ОП говорит выше в комментариях, что он использует только -O2, поэтому цикл просто не разворачивается. Используйте -O3, чтобы развернуться. - person Todd Gamblin; 31.07.2009

Наилучшие результаты (по скорости) я получаю при использовании компилятора Intel.

Вы правы, говоря, что ключевое слово «register» предназначено просто как подсказка для компилятора (так же, как встроенный).

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

вы даже можете #ifdef весь бит с исходным кодом C для обеспечения переносимости

person Toad    schedule 30.07.2009

    for (bit_index=0; bit_index < NBBY; bit_index++)
    {
        condition_index = byte_index*NBBY + bit_index;
        if (check_bit(condition_mask,condition_index))
        {
            .
            .
            .
        }
    }

может так же легко быть;

    condition_index = byte_index * NBBY;
    for (bit_index=0; bit_index < NBBY; bit_index++, condition_index++)
    {
        if (check_bit(condition_mask,condition_index))
        {
            .
            .
            .
        }
    }

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

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

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

person Dan Olson    schedule 30.07.2009
comment
Не могли бы вы объяснить, как это поможет оптимизировать его код? Насколько я могу судить, он фактически займет регистр extra, если только компилятор не вернет его обратно (что противоречит цели). - person Paul Biggar; 30.07.2009
comment
Предполагая, что компилятор не может сделать это за вас (а он должен), второй пример кода избегает пересчета byte_index*NBBY на каждой итерации цикла. - person Matt J; 30.07.2009
comment
@Matt J: Но было установлено, что это не узкое место. - person Paul Biggar; 30.07.2009
comment
Почему дополнительный регистр — это плохо? Возможно, это не узкое место, но сложно сказать, что делает компилятор. А перевод вычислений в более логическую область упрощает развертывание цикла. Я просто стреляю в темноте, разбрасываясь советами, и все это можно принять индивидуально, как оно того стоит. - person Dan Olson; 30.07.2009
comment
Он сказал это в вопросе. Цитата: Глядя на вывод сборки, я вижу, что оба счетчика не получают регистры и доступ к ним стоит много циклов. Использование ключевого слова register не убеждает компилятор помещать их в регистры. можно ли что-то сделать, чтобы оптимизировать время доступа к счетчикам? - person Paul Biggar; 31.07.2009

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

person jcoder    schedule 30.07.2009

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

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

person anthony    schedule 30.07.2009
comment
Все известные мне современные компиляторы C++ полностью игнорируют register. Учитывая трюки, которые они проделывают с распределением регистров, это вполне понятно. - person Pavel Minaev; 30.07.2009

Это может показаться незначительным моментом, но вместо использования формы: index++ используйте ++index;

Причина в том, что index++ требует кэширования текущего rvalue перед увеличением, тогда как ++index возвращает только что вычисленное rvalue, которое уже должно быть кэшировано, таким образом сохраняя ссылку.

Конечно, хороший компилятор оптимизирует это, так что, вероятно, это не проблема.

person dar7yl    schedule 18.10.2009

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

если компилятор не встраивает их, вставьте их в цикл самостоятельно.

person Toad    schedule 30.07.2009
comment
Они встроены. это простые побитовые операции, и они не используют локальные переменные. Я вызываю функцию внутри цикла, которую не могу встроить, потому что она слишком тяжелая. Я проверил накладные расходы, и они были незначительными по сравнению со счетчиками циклов. - person Nir; 30.07.2009
comment
Вызов функции намного тяжелее любого счетчика циклов. Почему? Он должен сохранить переменные в стеке, (возможно) сломать конвейер, (возможно) выйти из кеша и снова восстановить переменные. Проблема счетчика - просто арахис по сравнению с этим. - person Toad; 30.07.2009
comment
Кроме того, попробуйте проверить, транслируется ли компилятор умножения в битовый сдвиг. Поскольку NBBY, вероятно, 8, 16 или 32, это возможно. То же самое касается дивизии. - person Toad; 30.07.2009
comment
И последнее, но не менее важное... вместо постоянного вычисления 'condition_index'. Просто увеличивайте его каждый цикл. - person Toad; 30.07.2009
comment
или еще лучше ... поскольку bit_check, вероятно, проверяет только 1 бит (как следует из названия), предварительно вычисляйте маску проверки и сдвигайте ее каждый раз влево для каждой итерации. Итак: битиндексмаск = 1; и для каждого цикла выполните: bitIndexMask ‹‹=1; тогда ваш if становится: if (condition_mask & bitIndexMask), что также намного быстрее. - person Toad; 30.07.2009
comment
@reiner: вы можете отредактировать свой ответ вместо того, чтобы комментировать его, это сделает его более приятным для чтения .. :) - person falstro; 30.07.2009

Вы можете попробовать реорганизовать его до одного индекса и посмотреть, изменит ли это мнение компилятора:

for (condition_index = 0; condition_index < MASK_SIZE;)
{
    if (check_byte(mask, condition_index / NBBY))
    {
        for (bound = condition_index + NBBY; condition_index < bound; condition_index++)
        {
            if (check_bit(condition_mask, condition_index))
            {
                /* stuff */
            }
        }
    }
    else
    {
        condition_index += NBBY;
    }
}

(Надеюсь, NBBY является степенью двойки, поэтому деление будет реализовано как сдвиг)

person caf    schedule 30.07.2009
comment
Пробовал. Счетчик по-прежнему использует базовый указатель, а не регистр, но на самом деле это немного улучшает ситуацию. (5% лучше). Не знаю почему, но с результатами не поспоришь... Так что спасибо! - person Nir; 30.07.2009
comment
Хм, должно быть, это вызов функции внутри /* stuff */. Если это происходит не очень часто, вы можете попробовать обернуть check_bit() как __builtin_expect(check_bit(...), 0), чтобы указать компилятору, что обычно это ложь. - person caf; 31.07.2009

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

РЕДАКТИРОВАТЬ: Еще одна вещь, которую следует учитывать, если вы действительно хотите сделать часть кода быстрее, вы можете использовать специальные инструкции ЦП, ваш компилятор, вероятно, не будет знать, когда их использовать. Например, в Intel есть много инструкций, которые можно использовать, вплоть до SSE4 и более, именно здесь вы действительно можете работать лучше, чем ваш компилятор, поскольку у него нет возможности узнать, чего вы хотите достичь на уровне алгоритма. Подробности см. в Руководстве разработчика программного обеспечения для архитектур Intel(R) 64 и IA-32. Также на этом уровне вы можете извлечь выгоду из лучшего контроля над конвейером.

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

Относительно проверки того, включен ли бит или нет: Не уверен, что вы хотите сделать, если бит включен, но (при условии, что ваши биты выровнены по байтам):

Предположим, вы получили байт 0110 0110 на X. Вы захотите сделать кое-что, например, напечатать сообщение типа «Биты 1,2,5,6 включены». Вы можете создать 256 функций, каждая будет делать что-то вроде отображения такого рода массажа. Как узнать, какой из них активировать? Номер функции должен точно соответствовать значению полученного байта, поэтому вы можете просто использовать оператор [], чтобы перейти туда. однако это будет таблица указателей на функции. Это должно выглядеть примерно так:

//define the functions
void func0()
{
   printf("No Bits are on.");
}

void func1()
{
   printf("Bit 0 is on.");
}
.
.
.

//create the table
void  (*table[256])();
table[0] = &func0;
table[1] = &func1;
.
.
.

//the for loop
void  (*pointer_to_func)();
for...
{
   X = getByte();
   pointer_to_func = table[X]; //table shell contain 256 function pointers.
   pointer_to_func(); //call the function
}

это должно вызвать функцию в позиции X и выполнить ее, я предполагаю, что функция в позиции X == 102 (десятичное число 0110 0110) будет примерно таким:

printf("Биты 1,2,5,6 включены");

См. учебники по указателям функций , в частности это.

person Liran Orevi    schedule 30.07.2009
comment
Почему это поможет? Это не изменит количество чеков, которые у меня будут. Кроме того, это будет использовать ценное пространство стека. - person Nir; 30.07.2009
comment
Это должно сократить 8 проверок битов, которые являются действительно дорогостоящими проверками байтов, манипулирующими битами, до одной проверки байтов. проблема здесь возникает непосредственно из-за того, что большинство современных процессоров плохо работают на битовом уровне, поэтому вы всегда должны стремиться работать хотя бы на уровне байтов, если не с большими объектами данных. Буду рад помочь вам, если вы опишете Bit-Check. (Ваш ЦП не поддерживает прямую манипуляцию с битами, верно?, потому что если это так, то мое предложение будет гораздо менее полезным.) - person Liran Orevi; 30.07.2009
comment
bit_check просто проверяет, включен ли данный бит. что-то вроде n & (1 ‹‹ i). вместо этого у меня было бы n > 1. В любом случае, мне пришлось бы зацикливаться одинаковое количество раз (в моем случае 8 * 8), и, как я уже сказал, это кажется узким местом на выходе профилировщика. - person Nir; 30.07.2009

Если правда, что /* материал */ код во внутреннем if() выполняется очень редко (и существует априорно известное ограничение на количество раз, которое это может произойти, или, по крайней мере, разумный предел), он вполне может повышение производительности при переходе на двухпроходное решение. Это устранит давление регистра во вложенных циклах. Следующее основано на моем предыдущем ответе с одним индексом:

for (n = 0, condition_index = 0; condition_index < MASK_SIZE;)
{
    if (check_byte(mask, condition_index / NBBY))
    {
        for (bound = condition_index + NBBY; condition_index < bound; condition_index++)
        {
            if (check_bit(condition_mask, condition_index))
            {
                condition_true[n++] = condition_index;
            }
        }
    }
    else
    {
        condition_index += NBBY;
    }
}

do {
    condition_index = condition_true[--n];
    /* Stuff */
} while (n > 0);
person caf    schedule 30.07.2009

Можно попробовать развернуть цикл. Компилятор может сделать это за вас, но если нет и вам действительно нужна производительность, сделайте это сами. Я предполагаю, что вы делаете что-то вроде вызова function(.., i, j, ..) на каждой итерации, поэтому просто замените циклы на:

function(.., 0, 0, ..)
...
function(.., 0, 7, ..)
function(.., 1, 0, ..)
...
function(.., 7, 7, ..)

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

person Matt J    schedule 30.07.2009
comment
вызов функции внутри цикла - это плохая производительность. Если можете, поместите содержимое этой функции внутрь цикла. Это сэкономит огромное количество времени (больше, чем развертывание цикла, которое, как я уже говорил ранее, действительно может быть ужасным для производительности из-за промахов кеша) - person Toad; 30.07.2009
comment
правда, встраивание и развертывание + встраивание также являются жизнеспособными решениями. по моему опыту, кажется, что компилятор с большей вероятностью выполнит за вас встраивание, чем развертывание, поэтому развертывание в вашем коде C с умеренной вероятностью приведет к развертыванию + встраиванию в скомпилированном коде, если предположить, что функция достаточно мала, чтобы не вызывать ужасные количество промахов кеша. - person Matt J; 30.07.2009

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

person groovingandi    schedule 30.07.2009

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

for (byte_index = 0; byte_index < MASK_SIZE / NBBY; )
{
    if (check_byte(mask,byte_index++))
    {
        condition_index = byte_index*NBBY;
        for (bit_index=0; bit_index < NBBY; )
        {
            if (check_bit(condition_mask,condition_index + bit_index++))
            {
                ...
            }
        }
    }
}

(вышеупомянутый фрагмент не будет работать по понятным причинам, но вы должны понять идею :)

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

for (byte_index = 0; byte_index < MASK_SIZE / NBBY; byte_index++)
{
    if (check_byte(mask,byte_index))
    {
        const char masks[] = { 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 };
        for (mask_index=0; mask_index < sizeof(masks) / sizeof(masks[0]); mask_index++)
        {
            if (check_bit(masks[mask_index], byte_index))
            {
                ...
            }
        }
    }
}

... который у компилятора может быть больше шансов правильно оптимизировать/развернуть.

person Christoffer    schedule 30.07.2009
comment
битовый сдвиг начальной маски выполняется намного быстрее, чем каждый раз дорогостоящий поиск в массиве. - person Toad; 30.07.2009
comment
Да, конечно. Но я не раз видел, что простой код дает компилятору лучшую основу для оптимизации, особенно когда речь идет о циклах — вы не выполняете векторизацию вручную в коде C. - person Christoffer; 30.07.2009

Не видя, что находится во внутреннем цикле, нет смысла пытаться оптимизировать циклы. Похоже, что код сгенерирован для x86 32bit. Если для вычисления в цикле требуется несколько регистров, то компилятору нет смысла держать счетчик цикла в регистрах, так как он все равно должен будет сбросить их в стек. Затем, в зависимости от инструкций, используемых во внутреннем цикле, могут возникнуть некоторые проблемы с распределением регистров. Сдвиги используют регистр ECX только в качестве подсчета, умножение и деление имеют ограничения в отношении используемых регистров, строковые команды используют ESI и EDI в качестве регистров, что снижает возможность компилятора хранить в них значения. И, как уже говорили другие, вызов в середине цикла тоже не помогает, так как регистры все равно нужно сохранять.

person Patrick Schlüter    schedule 30.07.2009

Я бы попытался посмотреть на проблему по-другому.

Что именно делает код, возможно, для объяснения того, что он делает, можно использовать другой, более эффективный алгоритм?

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

person KPexEA    schedule 04.08.2009