Я хочу реализовать целочисленное деление без знака a на произвольную степень двух, округляя вверх, эффективно. Итак, математически я хочу ceiling(p/q)
0. В C реализация соломона, которая не использует ограниченный домен q
, может быть похожа на следующую функцию 1:
/** q must be a power of 2, although this version works for any q */
uint64_t divide(uint64_t p, uint64_t q) {
uint64_t res = p / q;
return p % q == 0 ? res : res + 1;
}
... конечно, я действительно не хочу использовать разделение или мод на уровне машины, так как это занимает много циклов даже на современном оборудовании. Я ищу сокращение силы, которое использует сдвиги и/или некоторые другие дешевые операции (операции) - воспользовавшись тем, что q
является степенью 2.
Вы можете предположить, что у нас есть эффективная функция lg(unsigned int x)
, которая возвращает журнал base-2 x
, если x
является степенью двух.
Undefined поведение прекрасное, если q
равно нулю.
Обратите внимание, что простое решение: (p+q-1) >> lg(q)
не работает вообще - попробуйте его с помощью p == 2^64-100 and q == 256
2 например.
Сведения о платформе
Я интересуюсь решениями на C, которые, вероятно, будут хорошо работать на разных платформах, но ради конкретности, присуждая награду, и поскольку любое окончательное обсуждение производительности должно включать целевую архитектуру, Будем говорить о том, как я их протежу:
- Сервер Skylake
-
gcc 5.4.0
с флагами компиляции-O3 -march=haswell
Использование встроенных gcc (таких как встроенные функции bitcan/leading zero) отлично, и, в частности, я реализовал функцию lg()
, которую я сказал, был доступен следующим образом:
inline uint64_t lg(uint64_t x) {
return 63U - (uint64_t)__builtin_clzl(x);
}
inline uint32_t lg32(uint32_t x) {
return 31U - (uint32_t)__builtin_clz(x);
}
Я проверил, что они скомпилированы до одной инструкции bsr
, по крайней мере с -march=haswell
, несмотря на кажущееся участие вычитания. Вы, конечно, можете игнорировать их и использовать любые другие встроенные функции, которые вы хотите в своем решении.
Benchmark
Я написал критерий для существующих ответов и поделился и обновил результаты по мере внесения изменений.
Написание хорошего ориентира для небольшой, потенциально связанной операции довольно сложно. Когда код встроен в сайт вызова, большая часть работы функции может исчезнуть, особенно когда она находится в цикле 3.
Вы могли бы просто избежать всей проблемы вложения, гарантируя, что ваш код не вставлен: объявите его в другой блок компиляции. Я попытался сделать это с помощью bench
двоичного кода, но на самом деле результаты довольно бессмысленны. Почти все реализации привязаны к 4 или 5 циклам за вызов, но даже фиктивный метод, который ничего не делает, кроме return 0
, принимает одно и то же время. Таким образом, вы в основном просто измеряете накладные расходы call + ret
. Кроме того, вы почти никогда не будете использовать такие функции, как это, если вы не испортились, они будут доступны для вложения и что все это изменит.
Итак, два теста, на которых я буду больше всего фокусироваться, многократно вызывают тестируемый метод в цикле, позволяя встраивание, кросс-функциональную оптизацию, подъем петли и даже векторизации.
Существует два основных типа тестов: латентность и пропускная способность. Ключевое различие заключается в том, что в тесте латентности каждый вызов divide
зависит от предыдущего вызова, поэтому общие вызовы не могут быть легко перекрыты 4:
uint32_t bench_divide_latency(uint32_t p, uint32_t q) {
uint32_t total = p;
for (unsigned i=0; i < ITERS; i++) {
total += divide_algo(total, q);
q = rotl1(q);
}
return total;
}
Обратите внимание, что работающий total
зависит от вывода каждого деления, а также является вызовом вызова divide
.
Вариант пропускной способности, с другой стороны, не подает вывод одного деления на последующий. Это позволяет работать на одном вызове с последующим дублированием (как компилятором, так и главным образом процессором) и даже допускает векторизация:
uint32_t bench_divide_throughput(uint32_t p, uint32_t q) {
uint32_t total = p;
for (unsigned i=0; i < ITERS; i++) {
total += fname(i, q);
q = rotl1(q);
}
return total;
}
Обратите внимание, что здесь мы корнем в цикле счетчика как дивиденд - это переменная, но она не зависит от предыдущего деления.
Кроме того, каждый тест имеет три варианта поведения для делителя, q
:
- Постоянный делитель времени компиляции. Например, вызов
divide(p, 8)
. Это обычно на практике, и код может быть намного проще, когда делитель известен во время компиляции. - Инвариантный делитель. Здесь делитель не знает во время компиляции, но является постоянным для всего цикла бенчмаркинга. Это позволяет подмножество оптимизаций, которые выполняет константа времени компиляции.
- Переменный делитель. На каждой итерации цикла изменяется делитель. Контрольные функции выше показывают этот вариант, используя команду "rotate left 1" для изменения делителя.
Объединяя все, что вы получаете в общей сложности 6 различных тестов.
Результаты
В целом
В целях выбора общего наилучшего алгоритма я просмотрел каждый из 12 подмножеств для предложенных алгоритмов: (latency, throughput) x (constant a, invariant q, variable q) x (32-bit, 64-bit)
и присвоил оценку 2, 1 или 0 на каждый подтест следующим образом:
- Лучший алгоритм (с точностью до 5%) получает оценку 2.
- "Близкие" алгоритмы (не более 50% медленнее, чем лучшие) получают оценку 1.
- Остальные алгоритмы оценивают нуль.
Следовательно, максимальный общий балл равен 24, но алгоритм не достиг этого. Вот общие итоговые результаты:
╔═══════════════════════╦═══════╗
║ Algorithm ║ Score ║
╠═══════════════════════╬═══════╣
║ divide_user23_variant ║ 20 ║
║ divide_chux ║ 20 ║
║ divide_user23 ║ 15 ║
║ divide_peter ║ 14 ║
║ divide_chrisdodd ║ 12 ║
║ stoke32 ║ 11 ║
║ divide_chris ║ 0 ║
║ divide_weather ║ 0 ║
╚═══════════════════════╩═══════╝
Таким образом, для целей этого конкретного тестового кода, с этим конкретным компилятором и на этой платформе, user2357112 "вариант" (с ... + (p & mask) != 0
) лучше всего работает, связанный с предложением chux (что на самом деле является идентичным кодом).
Вот все подзадачи, которые суммируются с приведенным выше:
╔══════════════════════════╦═══════╦════╦════╦════╦════╦════╦════╗
║ ║ Total ║ LC ║ LI ║ LV ║ TC ║ TI ║ TV ║
╠══════════════════════════╬═══════╬════╬════╬════╬════╬════╬════╣
║ divide_peter ║ 6 ║ 1 ║ 1 ║ 1 ║ 1 ║ 1 ║ 1 ║
║ stoke32 ║ 6 ║ 1 ║ 1 ║ 2 ║ 0 ║ 0 ║ 2 ║
║ divide_chux ║ 10 ║ 2 ║ 2 ║ 2 ║ 1 ║ 2 ║ 1 ║
║ divide_user23 ║ 8 ║ 1 ║ 1 ║ 2 ║ 2 ║ 1 ║ 1 ║
║ divide_user23_variant ║ 10 ║ 2 ║ 2 ║ 2 ║ 1 ║ 2 ║ 1 ║
║ divide_chrisdodd ║ 6 ║ 1 ║ 1 ║ 2 ║ 0 ║ 0 ║ 2 ║
║ divide_chris ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║
║ divide_weather ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║
║ ║ ║ ║ ║ ║ ║ ║ ║
║ 64-bit Algorithm ║ ║ ║ ║ ║ ║ ║ ║
║ divide_peter_64 ║ 8 ║ 1 ║ 1 ║ 1 ║ 2 ║ 2 ║ 1 ║
║ div_stoke_64 ║ 5 ║ 1 ║ 1 ║ 2 ║ 0 ║ 0 ║ 1 ║
║ divide_chux_64 ║ 10 ║ 2 ║ 2 ║ 2 ║ 1 ║ 2 ║ 1 ║
║ divide_user23_64 ║ 7 ║ 1 ║ 1 ║ 2 ║ 1 ║ 1 ║ 1 ║
║ divide_user23_variant_64 ║ 10 ║ 2 ║ 2 ║ 2 ║ 1 ║ 2 ║ 1 ║
║ divide_chrisdodd_64 ║ 6 ║ 1 ║ 1 ║ 2 ║ 0 ║ 0 ║ 2 ║
║ divide_chris_64 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║
║ divide_weather_64 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║ 0 ║
╚══════════════════════════╩═══════╩════╩════╩════╩════╩════╩════╝
Здесь каждый тест называется XY, с X в {Latency, Throughput} и Y в {Constant Q, Invariant Q, Variable Q}. Так, например, LC - это "Тест латентности с константой q".
Анализ
На самом высоком уровне решения можно условно разделить на две категории: быстрые (верхние 6 финишеров) и медленные (нижние два). Разница больше: все быстрые алгоритмы были самыми быстрыми, по крайней мере, на двух подтестах, и вообще, когда они не закончили сначала, они попали в категорию "достаточно близко" (только исключения были неудачными векторизацией в случае stoke
и chrisdodd
). Однако медленные алгоритмы оценивали 0 (даже не закрывали) при каждом тесте. Таким образом, вы можете в основном устранить медленные алгоритмы из дальнейшего рассмотрения.
Автоматическая векторизация
Среди быстрых алгоритмов большой дифференциатор - это возможность авто-векторизация.
Ни один из алгоритмов не смог авто-векторизовать в тестах на задержку, что имеет смысл, поскольку тесты задержки Vec?, показывающим, включена ли функция или нет:
Size Vec? Name
045 N bench_c_div_stoke_64
049 N bench_c_divide_chrisdodd_64
059 N bench_c_stoke32_64
212 Y bench_c_divide_chux_64
227 Y bench_c_divide_peter_64
220 Y bench_c_divide_user23_64
212 Y bench_c_divide_user23_variant_64
Тенденция ясна - векторизованные функции занимают около 4x размер не-вексельных. Это связано с тем, что сами петли ядра больше (векторные инструкции имеют тенденцию быть больше и их больше), а потому, что настройка цикла и особенно код после цикла намного больше: например, для векторизованной версии требуется сокращение до суммируем все частичные суммы в векторе. Счетчик циклов фиксирован и кратен 8, поэтому код хвоста не генерируется - но если бы они были переменными, сгенерированный код был бы еще больше.
Кроме того, несмотря на значительное улучшение во время выполнения, векторизация gcc
фактически невелика. Здесь выдержка из векторизованной версии программы Питера:
on entry: ymm4 == all zeros
on entry: ymm5 == 0x00000001 0x00000001 0x00000001 ...
4007a4: c5 ed 76 c4 vpcmpeqd ymm0,ymm2,ymm4
4007ad: c5 fd df c5 vpandn ymm0,ymm0,ymm5
4007b1: c5 dd fa c0 vpsubd ymm0,ymm4,ymm0
4007b5: c5 f5 db c0 vpand ymm0,ymm1,ymm0
Этот фрагмент работает независимо от 8 DWORD
элементов, происходящих из ymm2
. Если мы возьмем x
как единственный элемент DWORD
ymm2
, а y
входящее значение ymm1
, эти команды foud соответствуют:
x == 0 x != 0
x = x ? 0 : -1; // -1 0
x = x & 1; // 1 0
x = 0 - x; // -1 0
x = y1 & x; // y1 0
Таким образом, первые три команды могут быть просто заменены первым, так как состояния в обоих случаях одинаковы. Так что два цикла добавлены к этой цепочке зависимостей (которая не переносится циклом) и два дополнительных uops. Очевидно, что фазы оптимизации gcc
как-то плохо взаимодействуют с кодом векторизации здесь, так как такие тривиальные оптимизации редко пропускаются в скалярном коде. Рассмотрение других векторизованных версий аналогичным образом показывает большую производительность, упавшую на пол.
Филиалы vs Без отраслевых
Почти все решения, скомпилированные для кода без ветвей, даже если код C имел условные выражения или явные ветки. Условные части были достаточно малы, чтобы компилятор вообще решил использовать условное перемещение или какой-то вариант. Одним из исключений является chrisdodd
, который скомпилирован с веткой (проверка if p == 0
) во всех тестах пропускной способности, но ни одна из латентных. Здесь типичный пример из теста пропускной способности константы q
:
0000000000400e60 <bench_c_divide_chrisdodd_32>:
400e60: 89 f8 mov eax,edi
400e62: ba 01 00 00 00 mov edx,0x1
400e67: eb 0a jmp 400e73 <bench_c_divide_chrisdodd_32+0x13>
400e69: 0f 1f 80 00 00 00 00 nop DWORD PTR [rax+0x0]
400e70: 83 c2 01 add edx,0x1
400e73: 83 fa 01 cmp edx,0x1
400e76: 74 f8 je 400e70 <bench_c_divide_chrisdodd_32+0x10>
400e78: 8d 4a fe lea ecx,[rdx-0x2]
400e7b: c1 e9 03 shr ecx,0x3
400e7e: 8d 44 08 01 lea eax,[rax+rcx*1+0x1]
400e82: 81 fa 00 ca 9a 3b cmp edx,0x3b9aca00
400e88: 75 e6 jne 400e70 <bench_c_divide_chrisdodd_32+0x10>
400e8a: c3 ret
400e8b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
В ветки 400e76
пропущен случай, когда p == 0
. Фактически, компилятор мог просто очистить первую итерацию (вычислять ее результат явно), а затем полностью избежать прыжка, так как после этого он может доказать, что p != 0
. В этих тестах ветвь совершенно предсказуема, что может дать преимущество коду, который фактически компилируется с использованием ветки (поскольку код сравнения и ветвления существенно не соответствует линии и близок к свободному), и это большая часть того, почему chrisdodd
выигрывает пропускная способность, переменный случай q.
Подробные результаты испытаний
Здесь вы можете найти некоторые подробные результаты тестов и некоторые детали самих тестов.
Задержка
Результаты ниже проверяют каждый алгоритм на протяжении 1е9 итераций. Циклы вычисляются просто путем умножения времени/вызова на тактовую частоту. Обычно можно предположить, что что-то вроде 4.01
совпадает с 4.00
, но большие отклонения, такие как 5.11
, кажутся реальными и воспроизводимыми.
Результаты для divide_plusq_32
используют (p + q - 1) >> lg(q)
, но показаны только для справки, так как эта функция не работает при больших p + q
. Результаты для dummy
являются очень простой функцией: return p + q
и позволяют оценить эксплуатационные издержки контрольного пакета 5 (само добавление должно пройти максимум не более).
==============================
Bench: Compile-time constant Q
==============================
Function ns/call cycles
divide_peter_32 2.19 5.67
divide_peter_64 2.18 5.64
stoke32_32 1.93 5.00
stoke32_64 1.97 5.09
stoke_mul_32 2.75 7.13
stoke_mul_64 2.34 6.06
div_stoke_32 1.94 5.03
div_stoke_64 1.94 5.03
divide_chux_32 1.55 4.01
divide_chux_64 1.55 4.01
divide_user23_32 1.97 5.11
divide_user23_64 1.93 5.00
divide_user23_variant_32 1.55 4.01
divide_user23_variant_64 1.55 4.01
divide_chrisdodd_32 1.95 5.04
divide_chrisdodd_64 1.93 5.00
divide_chris_32 4.63 11.99
divide_chris_64 4.52 11.72
divide_weather_32 2.72 7.04
divide_weather_64 2.78 7.20
divide_plusq_32 1.16 3.00
divide_plusq_64 1.16 3.00
divide_dummy_32 1.16 3.00
divide_dummy_64 1.16 3.00
==============================
Bench: Invariant Q
==============================
Function ns/call cycles
divide_peter_32 2.19 5.67
divide_peter_64 2.18 5.65
stoke32_32 1.93 5.00
stoke32_64 1.93 5.00
stoke_mul_32 2.73 7.08
stoke_mul_64 2.34 6.06
div_stoke_32 1.93 5.00
div_stoke_64 1.93 5.00
divide_chux_32 1.55 4.02
divide_chux_64 1.55 4.02
divide_user23_32 1.95 5.05
divide_user23_64 2.00 5.17
divide_user23_variant_32 1.55 4.02
divide_user23_variant_64 1.55 4.02
divide_chrisdodd_32 1.95 5.04
divide_chrisdodd_64 1.93 4.99
divide_chris_32 4.60 11.91
divide_chris_64 4.58 11.85
divide_weather_32 12.54 32.49
divide_weather_64 17.51 45.35
divide_plusq_32 1.16 3.00
divide_plusq_64 1.16 3.00
divide_dummy_32 0.39 1.00
divide_dummy_64 0.39 1.00
==============================
Bench: Variable Q
==============================
Function ns/call cycles
divide_peter_32 2.31 5.98
divide_peter_64 2.26 5.86
stoke32_32 2.06 5.33
stoke32_64 1.99 5.16
stoke_mul_32 2.73 7.06
stoke_mul_64 2.32 6.00
div_stoke_32 2.00 5.19
div_stoke_64 2.00 5.19
divide_chux_32 2.04 5.28
divide_chux_64 2.05 5.30
divide_user23_32 2.05 5.30
divide_user23_64 2.06 5.33
divide_user23_variant_32 2.04 5.29
divide_user23_variant_64 2.05 5.30
divide_chrisdodd_32 2.04 5.30
divide_chrisdodd_64 2.05 5.31
divide_chris_32 4.65 12.04
divide_chris_64 4.64 12.01
divide_weather_32 12.46 32.28
divide_weather_64 19.46 50.40
divide_plusq_32 1.93 5.00
divide_plusq_64 1.99 5.16
divide_dummy_32 0.40 1.05
divide_dummy_64 0.40 1.04
Пропускная способность
Вот результаты тестов пропускной способности. Обратите внимание, что многие из алгоритмов здесь были авто-векторизованы, поэтому производительность относительно хороша для тех, кто во многих случаях является частью цикла. Одним из результатов является то, что в отличие от большинства результатов латентности 64-битные функции значительно медленнее, поскольку векторизация более эффективна при меньших размерах элементов (хотя разрыв больше, чем я ожидал).
==============================
Bench: Compile-time constant Q
==============================
Function ns/call cycles
stoke32_32 0.39 1.00
divide_chux_32 0.15 0.39
divide_chux_64 0.53 1.37
divide_user23_32 0.14 0.36
divide_user23_64 0.53 1.37
divide_user23_variant_32 0.15 0.39
divide_user23_variant_64 0.53 1.37
divide_chrisdodd_32 1.16 3.00
divide_chrisdodd_64 1.16 3.00
divide_chris_32 4.34 11.23
divide_chris_64 4.34 11.24
divide_weather_32 1.35 3.50
divide_weather_64 1.35 3.50
divide_plusq_32 0.10 0.26
divide_plusq_64 0.39 1.00
divide_dummy_32 0.08 0.20
divide_dummy_64 0.39 1.00
==============================
Bench: Invariant Q
==============================
Function ns/call cycles
stoke32_32 0.48 1.25
divide_chux_32 0.15 0.39
divide_chux_64 0.48 1.25
divide_user23_32 0.17 0.43
divide_user23_64 0.58 1.50
divide_user23_variant_32 0.15 0.38
divide_user23_variant_64 0.48 1.25
divide_chrisdodd_32 1.16 3.00
divide_chrisdodd_64 1.16 3.00
divide_chris_32 4.35 11.26
divide_chris_64 4.36 11.28
divide_weather_32 5.79 14.99
divide_weather_64 17.00 44.02
divide_plusq_32 0.12 0.31
divide_plusq_64 0.48 1.25
divide_dummy_32 0.09 0.23
divide_dummy_64 0.09 0.23
==============================
Bench: Variable Q
==============================
Function ns/call cycles
stoke32_32 1.16 3.00
divide_chux_32 1.36 3.51
divide_chux_64 1.35 3.50
divide_user23_32 1.54 4.00
divide_user23_64 1.54 4.00
divide_user23_variant_32 1.36 3.51
divide_user23_variant_64 1.55 4.01
divide_chrisdodd_32 1.16 3.00
divide_chrisdodd_64 1.16 3.00
divide_chris_32 4.02 10.41
divide_chris_64 3.84 9.95
divide_weather_32 5.40 13.98
divide_weather_64 19.04 49.30
divide_plusq_32 1.03 2.66
divide_plusq_64 1.03 2.68
divide_dummy_32 0.63 1.63
divide_dummy_64 0.66 1.71
a По крайней мере, указав unsigned, мы избегаем целой банки червей, связанной с правым сдвигом поведения целых чисел со знаком в C и С++.
0 Конечно, эта нотация на самом деле не работает на C, где /
обрезает результат, поэтому ceiling
ничего не делает. Поэтому рассмотрим, что псевдо-нотация, а не прямая C.
1 Я также интересуюсь решениями, где все типы uint32_t
, а не uint64_t
.
2 В общем случае любые p
и q
, где p + q >= 2^64
вызывает проблему из-за переполнения.
3 Тем не менее, функция должна быть в цикле, потому что производительность микроскопической функции, которая занимает полдюжины циклов, действительно имеет значение, если она вызывается в довольно жесткой петле.
4 Это немного упрощает - только дивиденд p
зависит от вывода предыдущей итерации, поэтому некоторые работы, связанные с обработкой q
, все еще могут перекрываться.
5 Используйте такие оценки с осторожностью, однако накладные расходы не просто аддитивны. Если накладные расходы отображаются в 4 циклах, а какая-то функция f
занимает 5, вероятно, неточно сказать, что стоимость реальной работы в f
равна 5 - 4 == 1
из-за перекрытия выполнения.