Интерпретация абсурдно-низкой измеренной латентности в тщательном профиле (эффекты суперскалярности?) - PullRequest
0 голосов
/ 02 марта 2019

Я написал некоторый код для профилирования небольших функций.На высоком уровне:

  1. Устанавливает привязку потока только к одному ядру, а приоритет потока - к максимальному.
  2. Вычисляет статистику, выполняя следующие 100 раз:

    1. Оцените задержку функции, которая ничего не делает.
    2. Оцените задержку тестовой функции.
    3. Вычтите первое из второго, чтобы убрать стоимость выполнения функции-затраты на вызов, тем самым приблизительно получая стоимость содержимого тестовой функции.

Чтобы оценить задержку функции, она:

  1. Invalidatesкэши (на самом деле это трудно сделать в пользовательском режиме, но я выделяю и записываю в память буфер размером L3, что, возможно, должно помочь).
  2. Возвращает поток, так что цикл профиля имеетпереключение контекста как можно меньше.
  3. Получает текущее время из std::chrono::high_resolution_clock (который, кажется, компилируется в system_clock, но).
  4. Запускает цикл профиля 100 000 000 развызывая проверенную функциюion в пределах.
  5. Получает текущее время из std::chrono::high_resolution_clock и вычитает, чтобы получить задержку.

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


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

Я ищу объяснение, объясняющее это поведение.Почему мои профилированные функции занимают так мало времени?


Давайте рассмотрим пример вычисления квадратного корня для float.

Сигнатура функции float(*)(float),и пустая функция тривиальна:

empty_function(float):
    ret

Давайте вычислим квадратный корень с помощью инструкции sqrtss и путем умножения на умножение на обратный квадратный корень.То есть, проверенные функции:

sqrt_sseinstr(float):
    sqrtss  xmm0, xmm0
    ret
sqrt_rcpsseinstr(float):
    movaps  xmm1, xmm0
    rsqrtss xmm1, xmm0
    mulss   xmm0, xmm1
    ret

Вот цикл профиля.Опять же, этот же код вызывается с пустой функцией и с тестовыми функциями:

double profile(float):
    ...

    mov    rbp,rdi

    push   rbx
    mov    ebx, 0x5f5e100

    call   1c20 <invalidate_caches()>
    call   1110 <sched_yield()>

    call   1050 <std::chrono::high_resolution_clock::now()>

    mov    r12, rax
    xchg   ax,  ax

    15b0:
    movss  xmm0,DWORD PTR [rip+0xba4]
    call   rbp
    sub    rbx, 0x1
    jne    15b0 <double profile(float)+0x20>

    call   1050 <std::chrono::high_resolution_clock::now()>

    ...

Результат синхронизации для sqrt_sseinstr(float) на моем Intel 990X составляет 3,60 ± 0,13 наносекунды.При этом процессоре с рейтингом 3,46 ГГц это составляет 12,45 ± 0,44 такта.Это кажется довольно точным, учитывая, что в документах говорится, что задержка sqrtss составляет около 13 циклов (она не указана для архитектуры Nehalem этого процессора, но, вероятно, она также составляет около 13 циклов).

результат синхронизации для sqrt_rcpsseinstr(float) является странным: 0,01 ± 0,07 наносекунд (или 0,02 ± 0,24 цикла).Это абсолютно неправдоподобно, если только не произойдет другой эффект.

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

(Примечание: я очистил некоторые обозначения сборки для вашего удобстваНеотредактированная objdump всей программы, которая включает в себя несколько других вариантов, - здесь , и я временно размещаю двоичный файл здесь (x86-64 SSE2 +, Linux).)


Снова вопрос: Почему некоторые профилированные функции выдают невероятно малые значения?Если это эффект высшего порядка, объясните?

Ответы [ 2 ]

0 голосов
/ 03 марта 2019

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

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

Встроенное предположение состоит в том, что стоимость вызова функции равна X, а если задержка выполнения работы в функции равна Y, то общая стоимость будетчто-то вроде X + Y.

Это обычно не верно для любых двух блоков работы и особенно не верно, когда один из них "вызывает функцию".Более сложный взгляд будет состоять в том, что общее время будет где-то между min(X, Y) и X + Y - но даже это часто неправильно в зависимости от деталей.Тем не менее, достаточно уточнения, чтобы объяснить, что здесь происходит: стоимость функции не складывается с работой, выполняемой в функции: они происходят параллельно .

.Стоимость пустого вызова функции в современном Intel составляет от 4 до 5 циклов, вероятно, в узких местах по пропускной способности внешнего интерфейса для двух взятых ветвей, и возможно в зависимости от задержки предсказания ветвления и возврата.

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

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

Ваша первая функция занимает достаточно времени, чтобы реальная работа доминировала над временем выполнения.Вторая функция, однако, намного быстрее, что позволяет ей «прятаться» под существующим временем, затрачиваемым механиками call / ret.

Решение простое: продублируйте работу внутри функции N раз, чтобыработа всегда доминирует.N = 10 или N = 50 или что-то в этом роде.Вы должны решить, хотите ли вы проверить задержку, и в этом случае выходные данные одной копии работы должны поступать в следующую, или пропускную способность, в этом случае это не должно быть.

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


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

0 голосов
/ 02 марта 2019

Ваш подход к сравнительному анализу в корне неверен, и ваш "осторожный код" является поддельным.

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

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

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

Вы ожидаете loop_time_gross/loop_count быть временем, затрачиваемым на каждую итерацию цикла. Это неправильно. Современные процессоры не выполняют команды последовательно, последовательно. Современные процессоры выполняют конвейеризацию, предсказывают переходы, выполняют несколько команд параллельно и (достаточно быстрые процессоры) выходят из строя.

Так что послеПервая горстка итераций цикла бенчмаркинга, все ветви идеально предсказаны для следующих почти 100000000 итераций.Это позволяет процессору спекулировать .Фактически, условная ветвь в цикле сравнительного анализа исчезает, как и большая часть стоимости косвенного вызова.Фактически, процессор может развернуть цикл:

movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
...

или, для другого цикла

movss  xmm0, number
sqrtss  xmm0, xmm0
movss  xmm0, number
sqrtss  xmm0, xmm0
movss  xmm0, number
sqrtss  xmm0, xmm0
...

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

Если честно,

call   rbp
sub    rbx, 0x1
jne    15b0 <double profile(float)+0x20>

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

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

Небольшой взгляд на https://agner.org/optimize/instruction_tables.pdf показывает, почему это параллельное выполнение не работает для sqrtss на Nehalem:

instruction: SQRTSS/PS
latency: 7-18
reciprocal throughput: 7-18

, т. Е. Инструкция не может быть передана по конвейеру и выполняется только наодин порт исполнения.Напротив, для movaps, rsqrtss, mulss:

instruction: MOVAPS/D
latency: 1
reciprocal throughput: 1

instruction: RSQRTSS
latency: 3
reciprocal throughput: 2

instruction: MULSS
latency: 4
reciprocal throughput: 1

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

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

float x = INITIAL_VALUE;
for (i = 0; i < 100000000; i++)
    x = benchmarked_function(x);

Очевидно, что вы не будете тестировать один и тот же вход таким образом, если INITIAL_VALUE не является фиксированной точкой benchmarked_function().Однако вы можете организовать , чтобы она была фиксированной точкой расширенной функции, вычислив float diff = INITIAL_VALUE - benchmarked_function(INITIAL_VALUE); и затем сделав цикл

float x = INITIAL_VALUE;
for (i = 0; i < 100000000; i++)
    x = diff + benchmarked_function(x);

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

...