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

Предыстория

В 2008 году я участвовал в соревновании по программированию, и одна из задач выглядела так:

Описание задачи

В этой тренировочной задаче вам потребуется проверить на простоту как можно больше чисел. Чтобы не делать ее ориентированной на скорость ввода-вывода, числа будут следовать в следующем порядке: пусть первое число будет 1, а все следующие определяются рекуррентным соотношением:

$$a_i = (a_{i-1} + 1234567890) \mod 2^{31}$$

И будьте внимательны, не используйте для решения больше, чем 4096 байтов кода.

Выходные данные

Для каждого числа выведите на выход цифру “1”, если число простое, или цифру “0”, если составное.

Начисление очков

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

Пример

Выходные данные:

01000000000000000000000000001000010000000001100000

за этот вывод вы получите ровно 50 очков.

Для решения я использовал алгоритм Миллера — Рабина, и он показал достойный результат. Но наиболее эффективным оказалось решение от участника под ником Levin Aleksey. Автор строил решето Эратосфена, а затем для каждого числа из рекуррентной последовательности за константное время мог выяснить, является данное число простым или нет. Предлагаю познакомиться с оригинальной версией по ссылке . К ее кусочкам мы еще вернемся по ходу нашего повествования.

Об ограничениях

У программ были ограничения на размер исходника, время выполнения и объем потребляемой памяти. Так как цель этой серии статей образовательная, то мы будем игнорировать указанные лимиты для лучшей читаемости.

Что я ожидаю от читателя

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

Что нас ждет

  • в первой части мы напишем наивную версию решета Эратосфена и разберемся, что такое сегментация (эта техника позволит нам в $3.3$ раза ускорить выполнение программы)
  • во второй части мы применим оптимизацию wheel factorization. Этот математический трюк даст нам не только ускорение в $3.8$ раза, но и существенно сократит потребление памяти (в сочетании с побитовым хранением выигрыш по памяти для решета будет $30$-кратным). Объединив эти две техники, мы получим ускорение программы в $8.6$ раза и приблизимся к оригинальному решению
  • в третьей части мы сравним наше решение с оригинальным, дополнительная микрооптимизация практически уравняет две версии
  • в четвертой части мы поговорим о последнем существенном отличии между нашим и оригинальным решениями
  • в последующих частях мы найдем способы ускорить программу еще вдвое относительно оригинальной версии

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

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

Параметры запуска

Компилировал с такими опциями (-O3 для оригинального решения используется, чтобы уравнять с кодом на Rust):

gcc -O3 -march=native -fwrapv -pipe -o original/solution original/main.c

RUSTFLAGS="-C target-cpu=native" cargo build --release

Версии компиляторов:

gcc version 15.2.1 20260214 (Gentoo 15.2.1_p20260214 p5)
rustc 1.94.0 (4a4ef493e 2026-03-02)

Процессор:

Intel Core i7-6700K

Размер кэшей для одного ядра:

L1D — 32 KiB
L2 — 256 KiB
L3 — 8 MiB

Перед запуском я сконфигурировал процессор в производительный режим:

for i in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
do
    echo performance | sudo tee $i
done

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

$ hyperfine --warmup 3 --runs 20 'taskset -c 0 original/solution > /dev/null'

Benchmark 1: taskset -c 0 original/solution > /dev/null
Time (mean ± σ):      1.067 s ±  0.003 s    [User: 1.036 s, System: 0.029 s]
Range (min … max):    1.063 s …  1.074 s    20 runs

В репозитории также представлены результаты запусков программ при помощи утилиты perf и сводная таблица с результатами .

Там же можно увидеть результаты запусков на других платформах.

Благодарности

Благодарности

Я очень признателен за помощь в рецензировании данной статьи следующим людям:

  • Екатерина К.

Наивная версия решета

Решето Эратосфена позволяет найти все простые числа до некоторого предела $N$ следующим образом:

  • выпишем все числа от $2$ до $N$
  • найдем первое незачеркнутое число (в нашем случае $2$), вычеркнем из списка все числа, кратные ему: $4, 6, 8, …$
  • найдем следующее незачеркнутое число (теперь $3$), вычеркнем из списка все числа, кратные ему: $6, 9, 12, …$
  • найдем следующее незачеркнутое число (теперь $5$), вычеркнем из списка все числа, кратные ему: $10, 15, 20, …$

Отмечу, что:

  • если мы хотим вычеркнуть кратные $p$, то можно начинать не с $2p$, а с $p^2$, ведь мы вычеркнули ранее $2p,\,3p,\,…,\,(p-1)p$.
  • все простые — нечетные (для $p > 2$), значит $p^2$ тоже нечетное. При шаге $p$ мы бы чередовали четные и нечетные числа ($p^2,\,p^2 + p,\,p^2 + 2p,\,…$), но четные нас не интересуют — они и так заведомо составные. Поэтому вычеркиваем с шагом $2p$, оставаясь на нечетных числах (для $p = 7: 49, 63, 77, 91, …$)
Решето ЭратосфенаРешето Эратосфена

Программа, реализующая данный алгоритм, может выглядеть так:

fn naive_sieve(n: usize) -> impl Fn(usize) -> bool {
    // false - prime, true - composite
    let mut sieve: Vec<bool> = vec![false; n];

    sieve[1] = true;
    for p in (3..n).step_by(2) {
        if sieve[p] {
            continue;
        }

        let step = 2 * p;
        for i in (p * p..n).step_by(step) {
            sieve[i] = true;
        }
    }

    move |a| a == 2 || (a % 2 != 0 && !sieve[a])
}

В массиве sieve мы проставляем true в i-й позиции, если число не является простым.

Вся программа отработала на моем компьютере за 12.400 сек. Построить решето необходимо до $2^{31}$, каждый bool занимает $1$ байт, поэтому на массив sieve потребуется 2 GiB.

Сегментированное решето

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

Работает это следующим образом. Допустим, программе требуется прочитать данные из оперативной памяти. Процессор загружает их блоками по $64$ байта (размер cache line). Эти блоки “оседают” на разных уровнях кэшей (на каких именно — зависит от архитектуры процессора). Если мы обращаемся к данным по тому же адресу или в пределах загруженных $64$ байт, то повторное обращение к оперативной памяти не требуется.

Размеры кэшей процессора intel-i7-6700K
Размеры кэшей процессора intel-i7-6700K

Для тестов я использую компьютер с процессором intel-i7-6700K, вот какими характеристиками он обладает:

ТипРазмерСкорость доступа, в циклах процессора
Кэш L1D32 KiB4 — 5
Кэш L2256 KiB12
Кэш L38 MiB42
Оперативная памятьот 8 GiB≈ 227

Скорость доступа к оперативной памяти я измерял при помощи Intel Memory Latency Checker при частоте процессора $4.2$ GHz.

Чтобы получить время доступа в секундах, нужно разделить количество циклов на частоту процессора, например, до L2 мы будем обращаться за $t = 12 / (4.2 \cdot 10^9) \approx 2.86$ наносекунды.

Теперь давайте подумаем, что наша наивная версия делает:

  • для $p = 3$, каждое число из интервала $[9; 2^{31})$ с шагом $step = 2 \cdot 3$ помечаем как составное: $9, 15, 21, …$
  • для $p = 5$, каждое число из интервала $[25; 2^{31})$ с шагом $step = 2 \cdot 5$ помечаем как составное: $25, 35, 45, …$
  • для $p = 7$, каждое число из интервала $[49; 2^{31})$ с шагом $step = 2 \cdot 7$ помечаем как составное: $49, 63, 77, …$
Как используется кэш в наивной версииКак используется кэш в наивной версии

Для каждого простого $p$ мы заново читаем данные из оперативной памяти, ведь предыдущие sieve[0..63], sieve[64..127], … были вытеснены из кэша процессора, так как его размер гораздо меньше, чем объем загруженного. Было бы здорово использовать кэш более эффективно.

Для этого разделим все решето на кусочки размером $100\ 000$ байт и обработаем каждый кусочек отдельно:

Почему именно 100 000?
Почему именно 100 000?

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

  • для первого сегмента $[0; 100\ 000)$

    • вычеркнем все кратные $3$
    • вычеркнем все кратные $5$
    • вычеркнем все кратные $7$
    • вычеркнем все кратные $46337$
  • для второго сегмента $[100\ 000; 200\ 000)$

    • вычеркнем все кратные $3$
    • вычеркнем все кратные $5$
    • вычеркнем все кратные $7$
    • вычеркнем все кратные $46337$
  • для каждого следующего сегмента проделываем то же, что и выше

Почему вычеркиваем до 46337?
Почему вычеркиваем до 46337?

Почему достаточно вычеркивать кратные только простых до $\sqrt{N}$? Допустим, число $n \le N$ составное. Тогда $n = a \cdot b$, где $a, b > 1$. Оба множителя не могут одновременно быть больше $\sqrt{N}$, ведь тогда $a \cdot b >\sqrt{N} \cdot \sqrt{N} = N$. Значит, хотя бы одно из них $\le \sqrt{N}$. Раз у любого составного числа есть делитель $\le \sqrt{N}$, то нам достаточно просеять кратные всех простых до $\sqrt{N}$.

Для нашей задачи $\sqrt{2^{31}} \approx 46340.95$, так что нам нужны все простые до $46340$. Наибольшее из них — $46337$.

Рассмотрим на примере самого первого сегмента, как будет использоваться кэш процессора:

Как используется кэш в сегментированной версииКак используется кэш в сегментированной версии

В такой реализации после первого прохода ($p = 3$) данные сегмента будут в кэше, и каждый последующий проход не будет требовать повторного обращения к оперативной памяти.

Почему кэш процессора помогает?

Обратите внимание, наша программа на этапе просеивания решета преимущественно записывает данные, а не считывает. Как же так получается, что кэш процессора ее ускоряет? Дело в том, что процессор работает с блоками по $64$ байта, и для записи даже одного байта требуется прочитать весь блок, изменить этот байт и записать весь блок. То есть каждая операция записи требует неявного чтения.

Код сегментированной версии станет сложнее, ведь теперь нам нужно обработать все решето по кусочкам. Для каждого простого нужно будет посчитать, с какого именно числа мы начнем вычеркивать в определенном сегменте. Как это сделать, я проиллюстрировал ниже на примере простого $p = 41$:

Решето ЭратосфенаРешето Эратосфена

А так будет выглядеть эта логика в виде кода:

const CHUNK: usize = 100_000;

fn segmented_sieve(n: usize) -> impl Fn(usize) -> bool {
    let sqrt_n = (n as f64).sqrt() as usize + 1;
    let is_prime = naive_sieve(sqrt_n);
    let small_primes: Vec<usize> =
        (3..sqrt_n)
            .filter(|&i| is_prime(i))
            .collect();

    let mut sieve: Vec<bool> = vec![false; n];
    sieve[1] = true;

    for chunk_start in (0..n).step_by(CHUNK) {
        let chunk_end = (chunk_start + CHUNK).min(n);

        for &p in &small_primes {
            if p * p > chunk_end {
                break;
            }

            let r = chunk_start % p;
            let mut j = match r {
                0 => chunk_start,
                _ => chunk_start + p - r,
            };
            if j % 2 == 0 {
                j += p;
            }

            j = j.max(p * p);

            let step = 2 * p;
            for j in (j..chunk_end).step_by(step) {
                sieve[j] = true;
            }
        }
    }

    move |a| a == 2 || (a % 2 != 0 && !sieve[a])
}

Здесь мы сначала предвычисляем (при помощи наивной версии) все простые до $\lfloor \sqrt{2^{31}} \rfloor = 46340$ — назовем их малые простые. А дальше обходим каждый сегмент, вычеркивая кратные всех малых простых:

  • в первом сегменте:
    • для $p = 3$, каждое число из интервала $[9; 100\ 000)$ с шагом $2 \cdot 3$ помечаем как составное: $9, 15, 21, …$
    • для $p = 5$, каждое число из интервала $[25; 100\ 000)$ с шагом $2 \cdot 5$ помечаем как составное: $25, 35, 45, …$
    • для $p = 7$, каждое число из интервала $[49; 100\ 000)$ с шагом $2 \cdot 7$ помечаем как составное: $49, 63, 77, …$
  • во втором сегменте
    • для $p = 3$, каждое число из интервала $[100\ 000; 200\ 000)$ с шагом $2 \cdot 3$ помечаем как составное (сначала позиционируем на кратное $3$)
    • для $p = 5$, каждое число из интервала $[100\ 000; 200\ 000)$ с шагом $2 \cdot 5$ помечаем как составное (сначала позиционируем на кратное $5$)
    • для $p = 7$, каждое число из интервала $[100\ 000; 200\ 000)$ с шагом $2 \cdot 7$ помечаем как составное (сначала позиционируем на кратное $7$)

Вся программа отработала на моем компьютере за 3.735 сек. Потребление памяти под решето осталось таким же, как и в наивной версии. Исходный код можно посмотреть здесь .

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

Что дальше

В следующей части мы освоим оптимизацию wheel factorization. Объединив эту технику с представленной здесь сегментацией, мы ускорим программу в $8.6$ раза и уменьшим потребление памяти в $30$ раз.


Следующая статья серии: Оптимизация решета Эратосфена. Wheel factorization