Два тела петли или одно (результат идентичен)

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

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

Предполагая:

  1. Стоимость f и g каждого незначительна по сравнению со стоимостью завершения всего цикла, содержащего каждый
  2. f и g используют большую часть кеша каждый сам по себе, и поэтому кеш будет аннулирован, если один будет вызываться после другого (что было бы в случае с версией с одним циклом).
  3. ЦП Intel Core Duo
  4. Исходный код языка C
  5. gcc компилятор, без переключателей

Итерируемый набор является математическим набором, а не контейнером чисел в памяти, таким как вектор или список. См. пример ниже.

Пожалуйста, не отвечайте на вопрос "преждевременная оптимизация - это зло" :-)

Пример версии с двумя петлями, за которую я выступаю:

int j = 0, k = 0;

for(int i = 0; i < 1000000; i++)
{
    j += f(i);
}

for(int i = 0; i < 1000000; i++)
{
    k += g(i);
}

person amn    schedule 23.07.2010    source источник


Ответы (7)


Я вижу три переменные (даже в, казалось бы, простом фрагменте кода):

  • Что делают f() и g()? Может ли один из них сделать недействительными все строки кэша инструкций (фактически вытеснив другой)? Может ли это произойти и в кэше инструкций L2 (маловероятно)? Тогда сохранение только одного из них может быть полезным. Примечание. Обратное не означает "иметь один цикл", потому что:
  • Согласно i, f() и g() работают с большими объемами данных? Затем было бы неплохо узнать, работают ли они с одним и тем же набором данных — опять же, вы должны учитывать, не испортит ли вас работа с двумя разными наборами из-за промахов кеша.
  • Если f() и g() действительно настолько примитивны, как вы сначала утверждаете, и я предполагаю, что это касается как размера кода, так и времени выполнения и сложности кода, проблемы локальности кеша не возникнут в таких небольших фрагментах кода, как этот. быть, если какой-то другой процесс был запланирован с фактической работой и сделал недействительными все кеши до тех пор, пока не наступит очередь вашего процесса.

Последняя мысль: учитывая, что такие процессы, как выше, могут быть редким явлением в вашей системе (и я довольно широко использую слово «редкий»), вы можете подумать о том, чтобы сделать обе ваши функции встроенными, и позволить компилятору развернуть цикл. Это связано с тем, что для кэша инструкций возврат к L2 не имеет большого значения, и вероятность того, что единственная строка кэша, содержащая i, j, k, будет признана недействительной в этом цикле, не выглядит такой ужасной. Однако, если это не так, было бы полезно узнать некоторые подробности.

person Michael Foukarakis    schedule 24.07.2010
comment
Учитывая, что вопрос был действительно слишком расплывчатым, я думаю, что ваш ответ - это ответ здесь. Спасибо. - person amn; 26.07.2010

Измерить — значит знать.

person Jim Lewis    schedule 23.07.2010

Интуитивно один цикл лучше: вы увеличиваете i в миллион раз меньше, а все остальные счетчики операций остаются прежними.

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

Как вы говорите: это зависит.

person CB Bailey    schedule 23.07.2010
comment
Именно поэтому мне было любопытно - я думаю, что когда f и g достаточно сложны, чтобы каждый из них нуждался в большей части кеша для себя, вызов обоих один за другим в одном теле цикла будет иметь пагубное влияние на производительность, абсолютно. Но это мое необразованное мнение, конечно. - person amn; 26.07.2010

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

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

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

Настройка кэша в лучшем случае сложна. Я регулярно демонстрирую, что даже со встроенной системой, без прерываний, с одной задачей, с одним и тем же исходным кодом. Время выполнения/производительность могут сильно различаться, просто изменяя параметры оптимизации компилятора, меняя компиляторы, обе марки компиляторов или версии компиляторов, gcc 2.x, 3.x и 4.x (кстати, gcc не обязательно производит более быстрый код с более новыми версиями). ) (и компилятор, который довольно хорош для многих целей, не очень хорош для какой-то одной конкретной цели). Один и тот же код с разными компиляторами или опциями может изменить время выполнения в несколько раз, в 3 раза быстрее, в 10 раз быстрее и т. д. Как только вы приступите к тестированию с кешем или без него, становится еще интереснее. Добавьте один nop в свой код запуска, чтобы вся ваша программа перемещала одну инструкцию в памяти, а ваши строки кэша теперь попадали в разные места. Тот же код компилятора. Повторите это с двумя nops, тремя nops и т. д. Тот же компилятор, тот же код, вы можете увидеть десятки процентов (для тестов, которые я провел в тот день на этой цели с этим компилятором) различий лучше и хуже. Это не означает, что вы не можете настроить кэш, это просто означает, что попытка выяснить, помогает ли ваша настройка или вредит, может быть трудной. Обычный ответ - просто "рассчитайте и посмотрите", но это больше не работает, и вы можете получить отличные результаты на своем компьютере в тот же день с этой программой с этим компилятором. Но завтра на своем компьютере или в любой другой день на чьем-либо компьютере вы можете делать все медленнее, а не быстрее. Вам нужно понять, почему то или иное изменение сделало его быстрее, возможно, это не было связано с вашим кодом, ваша почтовая программа могла загружать много почты в фоновом режиме во время одного теста, а не во время другого.

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

person old_timer    schedule 24.07.2010
comment
@amn Набор живет в памяти или регистрах или где? - person old_timer; 26.07.2010
comment
@amn, что именно находится в кеше/памяти, которую вы пытаетесь оптимизировать? - person old_timer; 26.07.2010
comment
Привет. Ну и вопрос возник из общего любопытства. Иногда, и не только при использовании C, я оказываюсь в похожей ситуации с реальным кодом. Набор нигде не живет, потому что цикл for(int i = 0; i < 1000000; i++) и поэтому только i, скорее всего, будет постоянно находиться в памяти и различных кешах, в зависимости от того, насколько умен компилятор. Я пытаюсь оптимизировать работу, выполняемую каждым циклом, но, поскольку и f, и g являются гипотетическими, я допускаю, что это может быть слишком расплывчато для определенного ответа. - person amn; 26.07.2010
comment
в зависимости от цикла и количества кода в цикле и т. д. я могу находиться в регистре и никогда не переходить в память (даже если для него зарезервировано место). Что убивает вас, если вы смотрите только на переменную цикла, так это ветвь к началу цикла, очищающая канал, как и любая ветвь, не такая плохая, как вызов функции, который должен установить аргументы, но это вызывает промывка трубопровода. - person old_timer; 26.07.2010
comment
если бы я был в памяти (стек, если локальная переменная, основная память, если глобальная), то он был бы кэширован. может быть, случайный удар из кеша, но в следующий раз через петлю обратно в кеш. два цикла будут означать в два раза больше чтений из кеша, что по-прежнему будет стоить вам, поэтому два цикла медленнее с кешем или без него по мере того, как идет переменная цикла i. - person old_timer; 26.07.2010

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

Из вашего примера:

int j = 0, k = 0;

for(int i = 0; i < 1000000; i++)
{
    j += f(i);
}

for(int i = 0; i < 1000000; i++)
{
    k += g(i);
}

Я бы либо объединил две петли в одну петлю следующим образом:

int j = 0, k = 0;

for(int i = 0; i < 1000000; i++)
{
    j += f(i);
    k += g(i);
}

Если это невозможно, выполните оптимизацию под названием Loop-Tiling:

#define TILE_SIZE 1000 /* or whatever you like - pick a number that keeps   */
                       /* the working-set below your first level cache size */

int i=0; 
int elements = 100000;

do {
  int n = i+TILE_SIZE; 
  if (n > elements) n = elements;

  // perform loop A
  for (int a=i; a<n; a++)
  {
    j += f(i);
  }

  // perform loop B
  for (int a=i; a<n; a++)
  {
    k += g(i);
  }

  i += n
} while (i != elements)

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

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

person Nils Pipenbrinck    schedule 25.07.2010

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

person Vasiliy Sharapov    schedule 23.07.2010
comment
Отключение оптимизации, как правило, не очень хорошая идея: вы будете тестировать что-то совершенно иное, чем то, что вы действительно получили бы при использовании кода. Вы должны проводить тесты с той же оптимизацией, что и для реальной программы, иначе она не будет отражать фактическое время выполнения. - person sth; 24.07.2010
comment
@sth: я имел в виду, что если бы он хотел увидеть, какой метод был быстрее в вычислительном отношении, он мог бы отключить оптимизацию, чтобы получить тот же эффект, что и подсчет часов вручную. - person Vasiliy Sharapov; 24.07.2010

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

Но если бы я наткнулся на версию с двумя циклами вместе с комментарием типа «Я использую два цикла, потому что он работает на X% быстрее в кеше на процессоре Y», по крайней мере, я бы больше не был озадачен кодом, хотя Я бы все еще сомневался, было ли это правдой и применимо ли к другим машинам.

person joe snyder    schedule 24.07.2010
comment
@amn: нет, вы упомянули только оптимизацию, а не качество. и ваше желание утверждать, что все остальные равны для двух неравных фрагментов кода, сомнительно. выбор того, какие факторы разрешено учитывать для определения того, какой код быстрее, - это просто искусственная головоломка, которая, я думаю, скорее приведет к плохим привычкам, чем к хорошему программированию. - person joe snyder; 26.07.2010