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

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

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

предположим:

  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);
}

7 ответов


Я вижу три переменные (даже в кажущемся простым куске кода):

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

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


измерить-это знать.


интуитивно один цикл лучше: вы увеличиваете i миллион меньше времен и все другие отсчеты деятельности остают этими же.

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

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


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

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

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

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

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


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

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

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 миллион раз, потому что кэш недостаточно велик для хранения всех этих данных.

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


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


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

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