Оптимизация решета Эратосфена. Wheel factorization
Содержание
Предыдущая статья серии: Оптимизация решета Эратосфена. Сегментация.
Что было ранее
Мы написали наивную версию решета Эратосфена и при помощи сегментации значительно ее ускорили:
| Версия | Время | Ускорение | Оптимизации |
|---|---|---|---|
| naive | 12.400 сек | 1x | - |
| segmented | 3.735 сек | 3.3x | сегментация |
Сейчас мы разберемся, как сделать меньше работы и поэтому выполнить ее быстрее. Также мы значительно уменьшим расход памяти под решето.
Благодарности
Благодарности
Я очень признателен за помощь в рецензировании данной статьи следующим людям:
- Екатерина К.
Оптимизация wheel factorization
Помните, в наивной версии мы проверяли только нечетные числа, но хранили все. Мы легко могли бы сократить расход памяти вдвое, избавившись от четных. Давайте разовьем эту идею. Возьмем первые простые числа $2$, $3$ и $5$. Их наименьшее общее кратное:
$$ \operatorname{НОК}(2,3,5) = 2 \cdot 3 \cdot 5 = 30 $$
Теперь представим, что мы хотим быстро проверить, делится ли число $x$ на один из этих делителей, если знаем остаток от деления числа $x$ на $30$:
$$ \begin{alignat*}{3} x &= 0 \pmod {30} \implies x = 30k + 0 && \quad \text{// делится на 2} \\ x &= 1 \pmod {30} \implies x = 30k + 1 && \quad \text{// не делится на 2, 3, 5} \\ x &= 2 \pmod {30} \implies x = 30k + 2 && \quad \text{// делится на 2} \\ x &= 3 \pmod {30} \implies x = 30k + 3 && \quad \text{// делится на 3} \\ x &= 4 \pmod {30} \implies x = 30k + 4 && \quad \text{// делится на 2} \\ x &= 5 \pmod {30} \implies x = 30k + 5 && \quad \text{// делится на 5} \\ x &= 6 \pmod {30} \implies x = 30k + 6 && \quad \text{// делится на 2} \\ x &= 7 \pmod {30} \implies x = 30k + 7 && \quad \text{// не делится на 2, 3, 5} \\ & \phantom{{}= 1 \pmod {30} \implies} \ldots \\ x &= 28 \pmod {30} \implies x = 30k + 28 && \quad \text{// делится на 2} \\ x &= 29 \pmod {30} \implies x = 30k + 29 && \quad \text{// не делится на 2, 3, 5} \\ \end{alignat*} $$
Проверив все $30$ остатков, мы можем заключить, что если число при делении на $30$ дает остаток не из списка $\{1, 7, 11, 13, 17, 19, 23, 29\}$, то это точно составное число (за исключением $2$, $3$ и $5$). Что это означает для нас?
Во-первых, мы можем не хранить числа, заведомо являющиеся составными. Для каждого блока из $30$ чисел потребуется всего $8$ ячеек. Расход памяти уменьшится в $30 / 8 = 3.75$ раза. А если хранить значения в битах, а не в байтах, то расход памяти уменьшится уже в $30$ раз.
блок 0: 1 7 11 13 17 19 23 29
блок 1: 31 37 41 43 47 49 53 59
блок 2: 61 67 71 73 77 79 83 89
блок 3: 91 97 101 103 107 109 113 119
...Во-вторых, когда мы будем вычеркивать кратные $p$, то есть числа вида $p \cdot q$, будет достаточно рассматривать только те $q$, чей остаток от деления на $30$ совпадает с одним из $\{1, 7, 11, 13, 17, 19, 23, 29\}$.
Эта оптимизация называется wheel factorization (в сводной таблице с результатами буду сокращать до WF). Чтобы понять, откуда такое название, представьте себе колесо с $30$ спицами, где $22$ спицы заранее нам не подходят. Вы “крутите” колесо (wheel по-английски) и останавливаетесь только на $8$ подходящих позициях.
О других вариантах
Мы могли бы взять в качестве основания для wheel factorization простые числа $\{2, 3\}$, их наименьшее общее кратное
$$ \operatorname{НОК}(2,3) = 2 \cdot 3 = 6 $$
В таком случае мы рассматривали бы $6$ остатков $\{0, 1, 2, 3, 4, 5\}$, из которых нам подходили бы только $\{1, 5\}$, ведь для всех остальных заранее известно, что есть делители. Соответственно, нам необходимо было бы обрабатывать $2$ числа на каждые $6$, то есть количество вычеркиваний уменьшилось бы в $6 / 2 = 3$ раза.
В рассматриваемой в этой статье версии используется wheel factorization для первых трех простых чисел $\{2, 3, 5\}$. Это позволяет, как мы выяснили выше, обрабатывать по $8$ чисел на каждые $30$, то есть количество вычеркиваний уменьшается в $30 / 8 = 3.75$ раза.
Если же взять $2, 3, 5, 7$ в качестве основания для wheel factorization, то их наименьшее общее кратное
$$ \operatorname{НОК}(2,3,5,7) = 2 \cdot 3 \cdot 5 \cdot 7 = 210 $$
И на $210$ чисел будут “выживать” всего $48$ остатков. Количество вычеркиваний уменьшится в $210 / 48 = 4.375$ раза.
Так можно продолжать до бесконечности, но каждое новое простое в основании “колеса” будет давать все меньший вклад, а сложность написания программы вырастать. Мы выбрали основание $\{2, 3, 5\}$ в качестве такого, которое дает существенное ускорение, и не слишком усложняет нашу задачу.
Давайте на конкретном примере $p = 41$ разберемся, как использовать эту технику. Нам необходимо рассмотреть $8$ остатков $r$ после представления числа в виде $p = 30k + r$. Чтобы не загромождать статью вычислениями, я буду выбирать числа вида $p = 30k + 11$, то есть с остатком $r = 11$. Если же речь пойдет про конкретное простое, то пусть это будет число $41$. Нам подошло бы любое простое, чей остаток от деления на $30$ совпадает с одним из $\{1, 7, 11, 13, 17, 19, 23, 29\}$.Почему выбираем p = 41?
Без wheel factorization нам надо было бы вычеркнуть:
Я красным подсветил те числа, которые мы могли бы даже не рассматривать, так как они гарантированно являются составными. Это мы можем понять по остатку от деления второго множителя на $30$.
В итоге нам достаточно вычеркнуть только указанные ниже числа:
С точки зрения процессора умножение более трудоемкое, чем сложение. Давайте попытаемся понять, как уменьшить число умножений, чтобы наша программа выполнялась быстрее. Для этого посмотрите на то, как будет происходить вычеркивание кратных для простого $p = 41$:
- во-первых, тут видно, что информацию о каждых $30$ числах мы храним в блоках по $8$ чисел
- во-вторых, можно заметить, что процесс циклический и повторяется через $8$ шагов
- в-третьих, шаги внутри цикла разные, но мы можем их посчитать заранее, как и то, число на какой позиции вычеркиваем
Если хранить блок в $1$ байте, то кодом мы могли бы описать это так:
// вычеркивание составных для p = 41
let mask_r1 = 1 << 0; // первому числу соответствует 0-ой бит
let mask_r7 = 1 << 1; // второму числу соответствует 1-ый бит
let mask_r11 = 1 << 2; // ...
let mask_r13 = 1 << 3;
let mask_r17 = 1 << 4;
let mask_r19 = 1 << 5;
let mask_r23 = 1 << 6;
let mask_r29 = 1 << 7;
// p = 41
let mut b = p * p / 30;
loop {
sieve[b] |= mask_r1;
b += 2;
sieve[b] |= mask_r23;
b += 6;
sieve[b] |= mask_r7;
b += 2;
sieve[b] |= mask_r29;
b += 6;
sieve[b] |= mask_r13;
b += 8;
sieve[b] |= mask_r19;
b += 3;
sieve[b] |= mask_r11;
b += 8;
sieve[b] |= mask_r17;
b += 6;
}Как видно по коду, нам только для самого первого блока нужно произвести умножение ($b = p * p / 30$), номер каждого следующего блока мы получаем тривиальным сложением. Один байт — это $8$ бит, каждый из которых может быть $0$ или $1$. Нам как раз нужно хранить $8$ значений на блок. Упакуем их в один байт, пусть каждому остатку соответствует какой-нибудь бит, например: $$ \text{остатку} \; \phantom{0}1 - \text{бит} \; 0 \\ \text{остатку} \; \phantom{0}7 - \text{бит} \; 1 \\ \text{остатку} \; 11 - \text{бит} \; 2 \\ \text{остатку} \; 13 - \text{бит} \; 3 \\ \text{остатку} \; 17 - \text{бит} \; 4 \\ \text{остатку} \; 19 - \text{бит} \; 5 \\ \text{остатку} \; 23 - \text{бит} \; 6 \\ \text{остатку} \; 29 - \text{бит} \; 7 $$ Чтобы получить байт, в котором установлен только бит с номером $i$, используем сдвиг влево: Чтобы пометить число как составное, мы устанавливаем нужный бит операцией “побитовое ИЛИ”: Эта операция не трогает остальные биты в байте — меняется только тот, который соответствует нашему остатку. Чтобы проверить, помечено ли число, используем “побитовое И”: Если результат не равен нулю, значит, бит установлен и число составное. Давайте на примере посмотрим на какой-нибудь блок Тогда проверить, является ли число с остатком $11$ в этом блоке помеченным, можно так: А для числа с остатком $13$ результат проверки будет выглядеть так:Побитовые операции и маски
1 << i:mask_r1 = 1 << 0; // = 00000001 — в бинарной записи
mask_r7 = 1 << 1; // = 00000010
mask_r11 = 1 << 2; // = 00000100
mask_r13 = 1 << 3; // = 00001000
mask_r17 = 1 << 4; // = 00010000
mask_r19 = 1 << 5; // = 00100000
mask_r23 = 1 << 6; // = 01000000
mask_r29 = 1 << 7; // = 10000000sieve[b] |= mask;let res = sieve[b] & mask;b, представим, что в нем были помечены числа с остатками $11$ и $19$:mask_r11 = 1 << 2;
mask_r19 = 1 << 5;
sieve[b] |= mask_r11; // sieve[b]: 00000000 -> 00000100
sieve[b] |= mask_r19; // sieve[b]: 00000100 -> 00100100let res = sieve[b] & mask_r11; // = 00000100 - помеченноеlet res = sieve[b] & mask_r13; // = 00000000 - непомеченное
Но этот код подходит только для $p = 41$, а что если мы рассмотрим произвольное $p = 30k + 11$?
Для остальных остатков вычисления проводятся аналогично. В исходных кодах эти вычисления описаны .
Расчет переходов для r = 11
Начинаем вычеркивать мы с $d_{0} = p^2$.$$ d_{0} = (30k + 11)^2 = 30^{2}k^{2} + 2 \cdot 11 \cdot 30k + 121 = 30 \cdot b_{0} + 1 $$
Теперь давайте рассмотрим, насколько далеко отстоят друг от друга числа, которые мы собираемся вычеркивать:
$$ \begin{align*} d_{0} &= p \cdot (30k + 11) \\ d_{1} &= p \cdot (30k + 13) = d_{0} + 2p \\ d_{2} &= p \cdot (30k + 17) = d_{1} + 4p \\ d_{3} &= p \cdot (30k + 19) = d_{2} + 2p \\ d_{4} &= p \cdot (30k + 23) = d_{3} + 4p \\ d_{5} &= p \cdot (30k + 29) = d_{4} + 6p \\ d_{6} &= p \cdot (30k + 31) = d_{5} + 2p \\ d_{7} &= p \cdot (30k + 37) = d_{6} + 6p \\ d_{8} &= p \cdot (30k + 41) = d_{7} + 4p \end{align*} $$
Подставим $d_{0} = 30 \cdot b_{0} + 1$ и $p = 30k + 11$:
$$ \begin{align*} d_{1} &= d_{0} + 2p = 30 \cdot b_{0} + 1 \;+\; 2 \cdot (30k + 11) \\ &\phantom{{}= d_{0} + 2p} = 30 \cdot (b_{0} + 2k) + 23 \\ &\phantom{{}= d_{0} + 2p} = 30 \cdot b_{1} + 23 \\[0.5em] d_{2} &= d_{1} + 4p = 30 \cdot b_{1} + 23 \;+\; 4 \cdot (30k + 11) \\ &\phantom{{}= d_{1} + 4p} = 30 \cdot (b_{1} + 4k + 2) + 7 \\ &\phantom{{}= d_{1} + 4p} = 30 \cdot b_{2} + 7 \\[0.5em] d_{3} &= d_{2} + 2p = 30 \cdot b_{2} + 7 \;+\; 2 \cdot (30k + 11) \\ &\phantom{{}= d_{2} + 2p} = 30 \cdot (b_{2} + 2k) + 29 \\ &\phantom{{}= d_{2} + 2p} = 30 \cdot b_{3} + 29 \\[0.5em] d_{4} &= d_{3} + 4p = 30 \cdot b_{3} + 29 \;+\; 4 \cdot (30k + 11) \\ &\phantom{{}= d_{3} + 4p} = 30 \cdot (b_{3} + 4k + 2) + 13 \\ &\phantom{{}= d_{3} + 4p} = 30 \cdot b_{4} + 13 \\[0.5em] d_{5} &= d_{4} + 6p = 30 \cdot b_{4} + 13 \;+\; 6 \cdot (30k + 11) \\ &\phantom{{}= d_{4} + 6p} = 30 \cdot (b_{4} + 6k + 2) + 19 \\ &\phantom{{}= d_{4} + 6p} = 30 \cdot b_{5} + 19 \\[0.5em] d_{6} &= d_{5} + 2p = 30 \cdot b_{5} + 19 \;+\; 2 \cdot (30k + 11) \\ &\phantom{{}= d_{5} + 2p} = 30 \cdot (b_{5} + 2k + 1) + 11 \\ &\phantom{{}= d_{5} + 2p} = 30 \cdot b_{6} + 11 \\[0.5em] d_{7} &= d_{6} + 6p = 30 \cdot b_{6} + 11 \;+\; 6 \cdot (30k + 11) \\ &\phantom{{}= d_{6} + 6p} = 30 \cdot (b_{6} + 6k + 2) + 17 \\ &\phantom{{}= d_{6} + 6p} = 30 \cdot b_{7} + 17 \\[0.5em] d_{8} &= d_{7} + 4p = 30 \cdot b_{7} + 17 \;+\; 4 \cdot (30k + 11) \\ &\phantom{{}= d_{7} + 4p} = 30 \cdot (b_{7} + 4k + 2) + 1 \\ &\phantom{{}= d_{7} + 4p} = 30 \cdot b_{8} + 1 \end{align*} $$
Для удобства выпишем конечный результат:
$$ \begin{alignat*}{3} d_{0} &= 30 \cdot b_{0} &&+ \phantom{0}1 && \\ d_{1} &= 30 \cdot (b_{0} + 2k) &&+ 23 &&\quad\text{// } b_{1} = b_{0} + 2k \\ d_{2} &= 30 \cdot (b_{1} + 4k + 2) &&+ \phantom{0}7 &&\quad\text{// } b_{2} = b_{1} + 4k + 2 \\ d_{3} &= 30 \cdot (b_{2} + 2k) &&+ 29 &&\quad\text{// } b_{3} = b_{2} + 2k \\ d_{4} &= 30 \cdot (b_{3} + 4k + 2) &&+ 13 &&\quad\text{// } b_{4} = b_{3} + 4k + 2 \\ d_{5} &= 30 \cdot (b_{4} + 6k + 2) &&+ 19 &&\quad\text{// } b_{5} = b_{4} + 6k + 2 \\ d_{6} &= 30 \cdot (b_{5} + 2k + 1) &&+ 11 &&\quad\text{// } b_{6} = b_{5} + 2k + 1 \\ d_{7} &= 30 \cdot (b_{6} + 6k + 2) &&+ 17 &&\quad\text{// } b_{7} = b_{6} + 6k + 2 \\ d_{8} &= 30 \cdot (b_{7} + 4k + 2) &&+ \phantom{0}1 &&\quad\text{// } b_{8} = b_{7} + 4k + 2 \end{alignat*} $$
Эти переходы можно описать кодом:
// p = 30k + 11
let mut b = p * p / 30;
let k = p / 30;
loop {
sieve[b] |= mask[1];
b += 2*k;
sieve[b] |= mask[23];
b += 4*k + 2;
sieve[b] |= mask[7];
b += 2*k;
sieve[b] |= mask[29];
b += 4*k + 2;
sieve[b] |= mask[13];
b += 6*k + 2;
sieve[b] |= mask[19];
b += 2*k + 1;
sieve[b] |= mask[11];
b += 6*k + 2;
sieve[b] |= mask[17];
b += 4*k + 2;
}Давайте добавим в наш код проверку, не вышли ли мы за границу решета. А чтобы не дублировать однотипные прыжки и вычеркивания чисел, сохраним их в массиве cycle, обработчик напишем один раз:
const WHEEL_SIZE: usize = 30;
const RESIDUES_PER_WHEEL: usize = 8;
let mut bit_mask: [u8; WHEEL_SIZE] = [0; WHEEL_SIZE];
for (i, &r) in [1, 7, 11, 13, 17, 19, 23, 29].iter().enumerate() {
bit_mask[r] = 1 << i;
}
for p in small_primes {
let mut b = (p * p) / WHEEL_SIZE;
let r = p % WHEEL_SIZE;
let k = p / WHEEL_SIZE;
let cycle: [(u8, usize); RESIDUES_PER_WHEEL] = match r {
11 => {
[
(bit_mask[1], 2 * k),
(bit_mask[23], 4 * k + 2),
(bit_mask[7], 2 * k),
(bit_mask[29], 4 * k + 2),
(bit_mask[13], 6 * k + 2),
(bit_mask[19], 2 * k + 1),
(bit_mask[11], 6 * k + 2),
(bit_mask[17], 4 * k + 2),
]
}
...
};
let max_b = n / WHEEL_SIZE;
while b <= max_b {
for (bit, jump) in cycle {
sieve[b] |= bit;
b += jump;
if b > max_b {
break;
}
}
}
} Исходный код можно посмотреть здесь . Добавим результаты запуска этой версии:
| Версия | Время | Ускорение | Оптимизации |
|---|---|---|---|
| naive | 12.400 сек | 1x | - |
| segmented | 3.735 сек | 3.3x | сегментация |
| wheel235 | 3.238 сек | 3.8x | WF |
На данный момент мы разработали две разные версии программы, ускоряющие наивное решение. Каждая из этих версий использует свой независимый подход:
- в версии с сегментацией мы говорим: “нам приходится обращаться к данным в оперативной памяти снова и снова, давайте использовать кэш процессора так, чтобы повторные обращения были более эффективными”.
- в версии с wheel factorization идея следующая: “нам не надо обрабатывать весь объем данных, большую часть данных мы можем пропустить, таким образом нам потребуется выполнить значительно меньше операций, и всю работу мы сможем завершить существенно быстрее”. При этом в версии с wheel factorization мы будем “промахиваться” мимо кэша процессора больше, чем могли бы. И об этом поговорим дальше.
Хочу отметить, что так как теперь для блока из $30$ чисел используется $1$ байт вместо $30$, то и выигрыш по памяти соответствующий. Решето в наивной версии занимало $2048$ MiB, в этой версии занимает $2048 / 30 \approx 68$ MiB.
Сегментированное решето с wheel factorization
В первой части мы ввели понятие малые простые - простые числа до $46340$. Этих чисел достаточно, чтобы просеять решето до $2^{31}$ (предел по условию задачи).
Мы смогли существенно уменьшить размер выделяемой памяти для решета, применив wheel factorization (до $\approx 68$ MiB). Однако даже такое улучшение не позволяет разместить решето целиком в кэше процессора, нам придется обращаться к данным в оперативной памяти для каждого малого простого снова и снова. Сегментация здесь должна помочь.
Разделим наше решето, как и ранее, на кусочки размером $100\ 000$. В версии, где применялась сегментация, мы для каждого простого $p$ вычисляли, с какой именно позиции будем начинать в новом сегменте. Это было легко сделать, так как прыжки между вычеркиваемыми числами были одинаковыми. В версии с wheel factorization прыжки разные, напомню для $p = 30k + 11$:
$$ \begin{alignat*}{1} j0 &= 2k \\ j1 &= 4k + 2 \\ j2 &= 2k \\ j3 &= 4k + 2 \\ j4 &= 6k + 2 \\ j5 &= 2k + 1 \\ j6 &= 6k + 2 \\ j7 &= 4k + 2 \\ \end{alignat*} $$
Обратите внимание, что:
$$ j0 + j1 + … + j7 = 30k + 11 = p $$
Через $8$ шагов цикл повторяется (длина цикла $=p$), но внутри цикла шаги неодинаковые. Давайте делать так: если во время прыжка мы вышли за границу сегмента, то запомним блок, на который прыгнули, и номер прыжка внутри цикла. Тогда для каждого малого простого мы будем хранить позицию, с которой надо начинать в новом сегменте.
Так как мы начинаем вычеркивания с $p^2$ и в одном блоке храним $30$ чисел, то начинать будем с блока $\lfloor p^2 / 30 \rfloor$ и с $0$-го шага внутри цикла:
const WHEEL_SIZE: usize = 30;
let mut resume_state: Vec<_> =
small_primes
.iter()
.map(|&p| (p * p / WHEEL_SIZE, 0))
.collect();А далее для каждого сегмента:
const RESIDUES_PER_WHEEL: usize = 8;
let sieve_len = (n / WHEEL_SIZE) + 1;
for chunk_start in (0..sieve_len).step_by(CHUNK) {
let chunk_end = (chunk_start + CHUNK).min(sieve_len - 1);
for (i, &p) in small_primes.iter().enumerate() {
let r = p % WHEEL_SIZE;
let k = p / WHEEL_SIZE;
let cycle: [(u8, usize); RESIDUES_PER_WHEEL] = match r {
// 1 => { ... }
// 7 => { ... }
11 => {
[
(bit_mask[1], 2 * k),
(bit_mask[23], 4 * k + 2),
(bit_mask[7], 2 * k),
(bit_mask[29], 4 * k + 2),
(bit_mask[13], 6 * k + 2),
(bit_mask[19], 2 * k + 1),
(bit_mask[11], 6 * k + 2),
(bit_mask[17], 4 * k + 2),
]
}
// 13 => { ... }
// 17 => { ... }
// 19 => { ... }
// 23 => { ... }
// 29 => { ... }
};
let (mut b, mut s) = resume_state[i];
let mut next_s = s;
'outer: while b <= chunk_end {
for (j, &(bit, jump)) in cycle[s..].iter().enumerate() {
sieve[b] |= bit;
b += jump;
if b > chunk_end {
next_s = (s + j + 1) % RESIDUES_PER_WHEEL;
break 'outer;
}
}
s = 0;
next_s = 0;
}
resume_state[i] = (b, next_s);
}Исходный код данной версии можно посмотреть здесь . Добавим результаты запусков:
| Версия | Время | Ускорение | Оптимизации |
|---|---|---|---|
| naive | 12.400 сек | 1x | - |
| segmented | 3.735 сек | 3.3x | сегментация |
| wheel235 | 3.238 сек | 3.8x | WF |
| segmented_wheel235 | 1.434 сек | 8.6x | сегментация, WF |
Что дальше
В следующей части мы, наконец, сравним наш код с оригинальным решением и проведем параллели. Дополнительная оптимизация позволит почти уравнять две версии.