10兆までの素数リスト

プログラミングコンテストチャレンジブックを読んでいたら、素数リストの作り方が出てきた。
そういえば、10兆までの素数リストを作ってみる、なんて記事があったな。
これだ

http://itpro.nikkeibp.co.jp/article/Watcher/20100519/348242/?ST=develop

読み返しているうちに、ついつい挑戦してみる気になってしまった。


色々試して意外だったのが、10億までの素数をリストアップした場合でも「試し割り」より「エラトステネスの篩」の方が100倍以上も高速だったことだ。リストが大きくなればその差はますます開いていく。
メモリ上の広い範囲をまんべんなくアクセスするエラトステネスの篩は、実際には効率が悪いのではないかと思ったりしたのだが、見当が外れてしまった。
考察してみるに

  • 試し割りは意外と無駄が多い。まず素数の密度だがn/log(n)に従うらしい。100億あたりだとおよそ5%だった。大雑把にlog(n)の項を無視して「ほぼnに比例する」と考える。そして試し割りだが、これまた大雑把にいえば、3について無駄になる確率は2/3, 5については(2/3)*(4/5)、7は(2/3)*(4/5)*(6/7)、11は(2/3)*(4/5)*(6/7)*(10/11)だ。√nまで続くから、nまで探索したときの試し割りの回数はO(n*√n)で抑えられると言えそうだ。一方、エラトステネスの篩は3についてはn/3回、5についてはn/5回といった処理回数が必要だから、全体ではn*{(1/3)+(1/5)+(1/7)+...}回になる。これは少なくともO(n*log(n))で抑えられる。正確なことはよく分からないが要するに計算オーダーが違うようだ。
  • 64bit整数の除算が遅い。エラトステネスの篩では基本的に除算がない。(ただし探索区間を分割した際に手間を惜しんで使用している)
  • 一度に篩にかける範囲を500KByte程度に制限することで、CPUのキャッシュの恩恵を受けることができた

といった理由が考えられるだろうか。
「試し割り一部→ミラー・ラビン→試し割り完全」も試してみたが100億程度の数では効果は微かで、エラトステネスの篩には全く敵わなかった。ミラー・ラビンは単一の数を素数判定するには有効だが、リストを作るのには向かないということだろうか。
(ミラー・ラビンの実装は http://d.hatena.ne.jp/kazu-yamamoto/20100131/1264921432 を参考にした)


そんなわけで「エラトステネスの篩」を基本に素数リスト生成プログラムを作成した。以下概要。

  • まずふるい落とすべき素数のリストを「試し割り」で作成する。10兆まで欲しいならば、10兆^(1/2)、つまり316万程度までのリストを用意すればよい(合成数であれば、必ず√n以下の素因数をもつ)
  • CPUのキャッシュ(実験環境ではそれぞれのコアにL2キャッシュ=512KB)を考慮して、一度に篩にかける範囲を50万以下とし、これを繰り返した。
  • 実験環境は4コアのCPU(AMD Phenom II X4 945, 3.0GHz)だったので、マルチスレッドによる分散処理を行った。スレッドを3つ作成し、それぞれのスレッドは探索ブロックを3つおきに処理する。ブロックが小さいため、これらをバラバラのファイルに出力するのはよくない。そこで同期処理し各スレッドが担当したブロックを順番に出力するようにした。幸い、各スレッドの処理時間はほぼ等しいため、同期してもCPU使用率は300%に近い値になった。高速化の効果は2.1倍くらいになった。スレッドを4つではなく3つにしたのは、出力結果をパイプで受け取り圧縮するプロセスが1つあることに配慮したものだ。4つスレッドをたてた場合は、プロセスの切り替えが多発するためか、かえって性能が悪化してしまった。
  • 2*3*5*7*11*13*17=510,510周期の「17までふるい落し済みリスト」を使い回す方針も試したが100億まででさえ10%程度の高速化の効果しか得られず、それ以上になればさらに効果が薄れること、実装が多少複雑になることから最終的には採用しなかった。


さて問題は出力だが、あいにく余り容量の大きいディスクを持っていない。
差分をVariableByte形式にして、さらにgzipで圧縮してみたものの、それでも10兆までのリストを保持するには約230GBが必要という見積もりになった。
3以降の素数素数の差分は必ず偶数で2以上なので、「2で割って1を引く」という処理を加えてみたが、当然、雀の涙の効果しかなかった。


どうしたらいい…買うか?いやいや、そんな。


そうだ…保存しなければいいんだ!


目的は処理時間、およびファイルサイズを知ることだ。その結果230GBのファイルを保存できたとしても、すぐに消去することになるだろう。それならば最初から保存しなければよい。

time ./prime | gzip -c > primes.dat

time ./prime | gzip -c | wc -c

を比較して、処理時間が殆ど同じことを確かめた。ディスクIOはボトルネックではない。


そんな訳で、めでたく10兆までの素数リストを作成するのに必要な時間とデータサイズが見積もれた。

どこまでか処理時間サイズ(gzip圧縮)
10億まで3秒45約30.8MB
100億まで28秒89約287MB
1000億まで4分19秒約2.68GB
1兆まで51分48秒約25.1GB
10兆まで13時間27分約236GB

プログラム

#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <cmath>
#include <algorithm>
#include <pthread.h>

uint64_t g_check_max = 1000ULL*1000*1000*1000*10;
uint64_t g_search_width = 1000ULL*500;
uint64_t* g_primes;
int g_thread_count = 3;
pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
volatile uint64_t g_seq_number = 0;
volatile uint64_t g_last_out = 3;

struct ThreadInfo{
  pthread_t thread;
  int thread_id;
  uint64_t from;
};

void out_prime(uint64_t v){
  if(v <= g_last_out) return;
  uint64_t d = (v - g_last_out) / 2 - 1;
  g_last_out = v;
  while(d > 0x80){
    fputc(0x80 | (d & 0x7f), stdout);
    d >>= 7;
  }
  putc(d, stdout);
}

bool isPrime(uint64_t v){
  if(v <= 1) return false;
  if(v == 2) return true;
  if((v&1) == 0) return false;
  for(uint64_t i = 3; i * i <= v; i+=2){
    if(v % i == 0) return false;
  }
  return true;
}

void sift_prime(uint64_t block_number, char* ar, uint64_t from, uint64_t to){
  uint64_t size = to - from;
  for(uint64_t i = 1; g_primes[i]*g_primes[i] < to; i++){
    uint64_t p = g_primes[i];
    for(uint64_t j = from + p - 1 - ((from + p - 1) % p); j < to; j += p){
      ar[j-from] = 0;
    }
  }
  while(true){
    pthread_mutex_lock(&g_mutex);
    if(g_seq_number == block_number) break;
    pthread_mutex_unlock(&g_mutex);
    usleep(100);
  }
  for(uint64_t i = 1 - (from & 1); i < size; i+=2){
    if(ar[i]) out_prime(from+i);
  }
  g_seq_number++;
  pthread_mutex_unlock(&g_mutex);
}

void search_range(ThreadInfo* info){
  uint64_t width = g_search_width;
  char* ar = new char[width];
  uint64_t block_number = info->thread_id;
  for(uint64_t from = info->from + width * info->thread_id; from < g_check_max; from += width * g_thread_count){
    uint64_t to = std::min(g_check_max, from + width);
    memset(ar, 1, width);
    sift_prime(block_number, ar, from, to);
    if(block_number / g_thread_count % 100 == 0){
      fprintf(stderr, "thread:%d, %lld : %lld\n", info->thread_id, from, to);
    }
    block_number += g_thread_count;
  }
}

int main(){
  uint64_t total = 0;
  uint64_t check_max_r = (uint64_t)sqrt(g_check_max) + 1;
  g_primes = new uint64_t[check_max_r];
  g_primes[0] = 2;
  uint64_t primes_i = 1;
  uint64_t i;
  for(i = 3; true; i += 2){
    if(isPrime(i)){
      out_prime(i);
      g_primes[primes_i] = i;
      primes_i++;
      if(i >= check_max_r){
        i += 2;
        break;
      }
    }
  }

  ThreadInfo* threads = new ThreadInfo[g_thread_count];
  for(int th = 0; th < g_thread_count; th++){
    threads[th].thread_id = th;
    threads[th].from = i;
    pthread_create(&(threads[th].thread), NULL, (void*(*)(void*))search_range, &threads[th]);
  }
  for(int th = 0; th < g_thread_count; th++){
    pthread_join(threads[th].thread, NULL);
    fprintf(stderr, "thread end:%d\n", th);
  }

  return 0;
}

付録*
■シングルスレッドとマルチスレッドの比較
100億まで
コマンドは time ./prime | gzip | wc -c

シングルスレッド3スレッド4スレッド
65秒5128秒8938秒01


■一度に処理する探索範囲の大きさの比較
100億まで

スレッド範囲処理時間
シングルスレッド5万個60秒77
シングルスレッド50万個65秒51
シングルスレッド500万個129秒74
3スレッド5万個161秒67
3スレッド50万個28秒89
3スレッド500万個87秒16
シングルスレッドでは5万個の場合が最もよいが、3スレッドでは50万個が最もよいという結果になった。一度の処理が小さすぎると、スレッドの同期処理が負担になるからだと考えられる。


■結果を保存するかどうかの比較
100億まで、3スレッド、VariableByte

コマンド処理時間
time ./prime | gzip -c > primes.dat29秒37
time ./prime | gzip -c > /dev/null28秒56
time ./prime | gzip -c | wc -c29秒18
time ./prime > /dev/null23秒64


■出力データの大きさの比較
100億まで、3スレッド、ファイルに保存する










方式処理時間サイズ(Byte)
テキスト93秒564,948,214,537
テキスト+gzip4分45秒1,160,824,945
差分テキスト60秒021,241,646,236
差分テキスト+gzip3分28秒353,280,158
差分VariableByte24秒05455,615,134
差分VariableByte+gzip33秒12287,681,964
差分VariableByte(x/2-1)24秒49455,052,953
差分VariableByte(x/2-1)+gzip29秒21286,813,303
テキスト形式の場合、HDDがボトルネックになったようだ。
テキスト+gzipの場合、gzipボトルネックになったようだ。