Оптимизация решета Эратосфена. 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*}{1} x = 30k + 0 & \quad \text{// делится на 2, 3, 5} \\ x = 30k + 1 & \quad \text{// не делится на 2, 3, 5} \\ x = 30k + 2 & \quad \text{// делится на 2} \\ x = 30k + 3 & \quad \text{// делится на 3} \\ x = 30k + 4 & \quad \text{// делится на 2} \\ x = 30k + 5 & \quad \text{// делится на 5} \\ x = 30k + 6 & \quad \text{// делится на 2, 3} \\ x = 30k + 7 & \quad \text{// не делится на 2, 3, 5} \\ & \ldots \\ x = 30k + 28 & \quad \text{// делится на 2} \\ 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$ ячеек. Памяти под решето понадобится в $3.75$ раза меньше ($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). Однако даже такое улучшение не позволяет разместить решето целиком в кэше процессора, нам придется обращаться к данным в оперативной памяти для каждого малого простого снова и снова. Сегментация здесь должна помочь.
В версии с wheel factorization мы в $1$ байте храним информацию о $30$ числах, поэтому в сегменте размером $100\ 000$ байт будет храниться информация о $3\ 000\ 000$ чисел.
Разделим наше решето, как и ранее, на кусочки размером $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 |
Что дальше
В следующей части мы, наконец, сравним наш код с оригинальным решением и проведем параллели. Дополнительная оптимизация позволит почти сравняться с ним по скорости.