Почему этот цикл задержки начинает работать быстрее после нескольких итераций без сна?

Рассмотрим:

#include <time.h>
#include <unistd.h>
#include <iostream>
using namespace std;

const int times = 1000;
const int N = 100000;

void run() {
  for (int j = 0; j < N; j++) {
  }
}

int main() {
  clock_t main_start = clock();
  for (int i = 0; i < times; i++) {
    clock_t start = clock();
    run();
    cout << "cost: " << (clock() - start) / 1000.0 << " ms." << endl;
    //usleep(1000);
  }
  cout << "total cost: " << (clock() - main_start) / 1000.0 << " ms." << endl;
}

Вот пример кода. В первых 26 итерациях цикла синхронизации функция run стоит около 0,4 мкс, но тогда стоимость сокращается до 0,2 мкс.

Когда usleep раскоментирован, цикл задержки принимает 0,4 мск для всех прогонов, никогда не ускоряясь. Почему?

Код скомпилирован с помощью g++ -O0 (без оптимизации), поэтому цикл задержки не оптимизирован. Он работает на Intel (R) Core (TM) i3-3220 CPU @3,30 GHz, с 3.13.0-32-generic Ubuntu 14.04.1 LTS (Trusty Tahr).

Ответ 1

После 26 итераций Linux наращивает процессор до максимальной тактовой частоты, так как ваш процесс несколько раз использует свой полный фрагмент времени строка.

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

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

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


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



комментарии к другим теориям:

re: Теория Дэвида о том, что потенциальный переключение контекста из usleep может загрязнять кеширование: это не плохая идея в целом, но это не помогает объяснить это код.

Загрязнение кэш /TLB вообще не важно для этого эксперимента. Внутри временного окна практически нет ничего, что касается памяти, отличной от конца стека. Большая часть времени проводится в крошечном цикле (1 строка кэша команд), который касается только одной int стековой памяти. Любое потенциальное загрязнение кэша во время usleep составляет крошечную долю времени для этого кода (реальный код будет другим)!

Подробнее о x86:

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

Функция run может находиться в другой строке кэша от main (поскольку gcc отмечает main как "холодный", поэтому он меньше оптимизируется и помещается в другие холодные функции/данные). Мы можем ожидать, что один или два пропуска кэша команд. Вероятно, они все еще находятся на одной странице 4k, поэтому main запускает потенциальную пропуску TLB перед входом в временную область программы.

gcc -O0 скомпилирует OP-код в что-то вроде этого (Godbolt Compiler explorer): сохранение счетчика циклов в памяти в стеке.

Пустой цикл хранит счетчик циклов в стеке памяти, поэтому на типичном Intel x86 CPU цикл работает на одной итерации на ~ 6 циклов на процессоре OP IvyBridge, благодаря задержке хранения, которая является частью add с местом назначения памяти (read-modify-write). 100k iterations * 6 cycles/iteration - это 600 тыс. циклов, что доминирует в вкладе не более нескольких промахов в кэше (~ 200 циклов каждый для пропусков с ошибками кода, которые препятствуют выдаче дополнительных инструкций, пока они не будут разрешены).

Выполнение вне очереди и пересылка хранилища должны в основном скрывать потенциальную промаху кэша при доступе к стеку (как часть инструкции call).

Даже если счетчик циклов хранился в регистре, много циклов 100k.

Ответ 2

Вызов usleep может или не может привести к переключению контекста. Если это произойдет, это займет больше времени, чем если бы оно не было.