Оптимизация решета Эратосфена. Wheel factorization


Предыдущая статья серии: Оптимизация решета Эратосфена. Сегментация.

Что было ранее

Мы написали наивную версию решета Эратосфена и при помощи сегментации значительно ее ускорили:

ВерсияВремяУскорениеОптимизации
naive12.400 сек1x-
segmented3.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$ разберемся, как использовать эту технику.

Почему выбираем p = 41?
---

Нам необходимо рассмотреть $8$ остатков $r$ после представления числа в виде $p = 30k + r$. Чтобы не загромождать статью вычислениями, я буду выбирать числа вида $p = 30k + 11$, то есть с остатком $r = 11$. Если же речь пойдет о конкретном простом, то пусть это будет $41$. Нам подошло бы любое простое, чей остаток от деления на $30$ совпадает с одним из $\{1, 7, 11, 13, 17, 19, 23, 29\}$.

Без wheel factorization нам надо было бы вычеркнуть:

Вычеркивание кратных для p = 41Вычеркивание кратных для p = 41

Я красным подсветил те числа, которые мы могли бы даже не рассматривать, так как они гарантированно являются составными. Это мы можем понять по остатку от деления второго множителя на $30$. Уберем красные строки, и останутся только те, которые нам необходимо обработать:

Вычеркивание кратных для p = 41 (после фильтрации)Вычеркивание кратных для p = 41 (после фильтрации)

Можно логику вычеркивания составных реализовать ровно так, как описано на картинке выше, но с точки зрения процессора умножение значительно более трудоемкое, чем сложение. Чтобы понять, как избежать лишних умножений, посмотрим на процесс вычеркивания кратных для простого $p = 41$. Помним, что в одном блоке хранится информация о $30$ числах:

Wheel factorizationWheel factorization
  • во-первых, можно заметить, что процесс циклический и повторяется через $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$, используем сдвиг влево: 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; // = 10000000

Чтобы пометить число как составное, мы устанавливаем нужный бит операцией “побитовое ИЛИ”:

sieve[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 -> 00100100

Тогда проверить, является ли число с остатком $11$ в этом блоке помеченным, можно так:

let res = sieve[b] & mask_r11; // = 00000100 - помеченное

А для числа с остатком $13$ результат проверки будет выглядеть так:

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;
                }
            }
        }
    }  

Исходный код можно посмотреть здесь . Добавим результаты запуска этой версии:

ВерсияВремяУскорениеОптимизации
naive12.400 сек1x-
segmented3.735 сек3.3xсегментация
wheel2353.238 сек3.8xWF

Промежуточные итоги

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

  • в версии с сегментацией мы говорим: “нам приходится обращаться к данным в оперативной памяти снова и снова, давайте использовать кэш процессора так, чтобы повторные обращения были более эффективными”.
  • в версии с 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);
    }
}

Исходный код данной версии можно посмотреть здесь . Добавим результаты запусков:

ВерсияВремяУскорениеОптимизации
naive12.400 сек1x-
segmented3.735 сек3.3xсегментация
wheel2353.238 сек3.8xWF
segmented_wheel2351.434 сек8.6xсегментация, WF

Что дальше

В следующей части мы, наконец, сравним наш код с оригинальным решением и проведем параллели. Дополнительная оптимизация позволит почти сравняться с ним по скорости.