フリーソフトの小道   

コンピュータ・プログラマー養成講座(入門編)

第12回 「素数発見」アルゴリズムの進化を解説する B
〜高速アルゴリズム「倍数除去」の限界に挑戦〜

 プログラミングコンテスト第1回「素数発見」競技の記録の伸びは飛躍的で、 コンテスト創設時に示した管理人の参考記録から10000倍の速度にまで達している。 同じコンピュータでありながらプログラムを変えただけで、 これだけの性能アップが実現された秘密はどこにあるのだろうか。 この秘密について詳しく調べてみよう。  アルゴリズムの解説だから、プログラミング言語にはとらわれないつもりだが、 一部にプログラミングの話が出てくることは避けられないのでお許しを得たい。

「倍数除去」 アルゴリズムの限界は、表に必要なメモリー領域の大きさ

 「倍数除去」アルゴリズムでの素数発見プログラムでは、 割り算、掛け算などの時間がかかる計算をいっさい使わない。 足し算だけで素数発見プログラムの中では計算らしい部分が無いのだ。 当然、処理速度そのものが非常に速くなる。 全てが上手く行くように感じられるが、そこに落とし穴が控えていた。 それは、表そのものが持つ問題である。表に書く数1つに変数を1つ割り当てていることだった。 このことがその重要な問題点として浮かび上がる。
 コンピュータが取り扱える変数は、コンピュータに装備されたメモリーという記憶場所に確保されている。 この記憶場所は、OS(Windows本体)が動くための領域として、 また、起動されるプログラムを格納するための場所として、 プログラムが使う変数が使う場所としてなど、いろいろなものの共用領域である。 しかし、そのメモリー領域には、ハードウエア上の限界がある。 例えば、パソコンに装備されたメモリーが256メガバイトであるとしよう。 1メガバイトとは、100万バイトのことであり非常に多いようにも思える。 しかし、OSが大半を使用してしまい、各プログラムで利用できるメモリー領域は想像するよりはるかに少ない。
 具体的に使われるメモリー領域の大きさを計算してみよう。 1つの変数でメモリーがどれくらい使うのかは、プログラミング言語や、変数の種類によって違ってくる。 普通の場合、文字型変数では1文字で1バイト、 整数変数では16ビット系では2バイト(32ビット系では4バイト)、 実数変数では4〜6バイト(8バイト)必要になる(倍精度変数ではそれぞれの2倍のサイズになる)。 したがって、使える全てのメモリー領域256メガバイトを変数に与えたとしても、 メモリーの総数の限界は1億個より小さいところになる。 したがって、表を使うアルゴリズムである「倍数除去」方式では、 表の仕組みに特別の工夫を取らない限り、1000万未満を越えるあたりから、 メモリーに関する障害が出てくる。 仮想記憶方式が取られるため、メモリー不足でプログラムが停止することはないが、 ハードディスク領域をメモリーとして利用し始める。 ハードディスクをメモリー領域として利用し始めると プログラムの実行速度が1000分の1以下まで大きく低下してしまう。 この現象を 「ページング」または、「スワッピング」 が始まったという言葉で表現する。
 上に示したように、素数の上限値が1000までのプログラムでは 特別の工夫をしなくてもスムーズにアルゴリズム通りの動きが実現できる。 しかし、素数の最大値がある値に達したとき、突然プログラムが動かなくなってしまう現象に遭遇する。 ここで行き詰まって、記録が伸びなくなってしまった経験を持つプログラマは多くいる。 しかし、これに対する解決策を見出すことはなかなか難しい。 アルゴリズムのチューニングで記録を僅かに改善できても、このメモリーサイズによるトラブルの解決にはならない。  この壁を乗り越えるためは新しいアルゴリズムの導入が必要になる。 表の利用効率を高めるアルゴリズムの導入である。

「ページング」(「スワッピング」)を防ぐために取られる対策は?

 表を表すメモリーの取り方に無駄はないのか? これについて見直してみるとその解決策はすぐに見つかる。 前述で例を示した1から1000までの場合、表を表す変数を1000個必要としている。 1変数を32ビットで表す場合、1変数が4バイトになる。 表に書く変数の数が1000個だから、表を表すのに4000バイトを使ってしまう。 この程度なら支障はまったく発生しない。 しかし、「1億未満」の素数発見の場合では、4億バイトになる。 CD1枚分に近い、400メガバイトの巨大なメモリー領域が、表のためだけに必要となってしまう。 これでは、プログラムが事実上動きづらくなってしまう(ページング、スワッピングの発生によるため)。
 ページング、スワッピングはメモリーを使いすぎるために発生するのだから、メモリー使用量を減らせばよい。 だれにでもわかる解決法だが、それを実現するアイデアはある。

表に使われるメモリー領域を節約する手法は 〜素数か非素数かは1ビットで表現可能〜

 その変数に記録される情報は、 素数であるか(値が1)、素数でないか(値が0)のどちらかという簡単な情報だけだ。 素数・非素数を表すには1ビットで十分だから、 文字変数が8ビットだから8つ分を1つの変数に入れることができる。 整数変数が16ビットから32ビットだから16から32個分を1つの変数に入れることができるのだ。 これで、メモリースワッピングの限界を約10倍に伸ばすことができる。 したがって、この表の表し方を「ビット単位」にする工夫により使用するメモリーが10分の1程度になり、 プログラム実行速度の低下を防ぐことが出来るようになる。 コンテストで言えば1000万未満までの記録は簡単なプログラムで記述できるが、 1億未満の部門になるとこのビット処理が無ければ「倍数除去」アルゴリズムでは難しくなる。

変数1つに8つの状態を1まとめで表す 〜ビット処理のアルゴリズムは2進数の知識〜

 2進数と10進数の変換が出来るなら、ビット処理のアルゴリズムは簡単に実現できる。 たとえば、1から10までの数が素数であるか無いかを表す表を考えてみよう。素数は2、3、5、7だから、 仮に素数を1、非素数を0と表し、右端から順番に1、2,3,..、10とすると、 「0001010110」と表すことが出来る。 表の状態から変数値に直すには、2進数表示を10進数に変換すると、2+4+16+64=86 になり、 1つの変数に86を覚えさせて置けばよいことになる。 逆に、変数の値から表の状態に戻すには、2で割った余りを順に並べればよい。 1回目の割り算では、商が43余りが0、2回目の割り算では商が21余りが1、 3回目の割り算では、商が10余りが1、4回目の割り算では商が5余りが0、 5回目の割り算では、商が2余りが1、6回目の割り算では商が1余りが0、 7回目の割り算では、商が0余りが1、8回目の割り算では商が01余りが0、 9回目の割り算では、商が0余りが0、10回目の割り算では、商が0余りが0である。 よって、順に右から並べると、「0001010110」 と元の表の状態を表現できるのだ。

ビット処理に必要なものは、任意のビットの「セット」と「クリア」と「チェック」の3つ

 変数の一部をどのように使い分けるか、プログラムでは、 サブプログラム(Cでは関数、Pascalでは手続、関数という)を使えばよい。 サブプログラムさえ出来れば、アルゴリズムの上ではビットの意識をせずにビット処理が行われる。
 まず、表を表すメモリー領域を確保する。 説明にはPascal言語を利用して考える(C言語でもほとんど同じである)。 表の領域を1億個の数表とすると、10000万ビットだから、 32ビット変数であれば、10000万÷32個の配列となる。


{ 表と、ビット操作の手続と関数の例    Pascal言語(Delphi)での記述 }

const
  TABLE_SIZE = 3125000;   { = 100000000/32  }   1億までの表を表す領域の定義(13メガバイト弱)

var
 t: array[1..TABLE_SIZE] of lontword;   1億の数が書き込める表を表す変数領域
 Dm: array[0..31] of longword;          ビットをセットするときに使うマスク配列
 Dmx: array[0..31] of longword;         ビットをクリアするときに使うマスク配列

{==========================================}
{    ビット操作のためのビットマスク作成    }
{==========================================}
procedure MakeBitMask;   {フラグ操作用のビットマスク生成}
var
  i: integer;
begin
  Dmx[0]:=1; Dm[0]:=(not Dmx[0]);   1ビット目の操作マスク作成
  for i:=1 to 31 do
  begin                             Dmx[0] shl 1 はDmx[0] を1ビット左にずらす操作
    Dmx[i]:=(Dmx[i-1] shl 1);       2から32ビット目のビットセットマスク作成
    Dm[i]:=(not Dmx[i]);            2から32ビット目のビットクリアマスク作成
  end;
end;


{==========================================}
{  素数フラグ領域のビット操作手続・関数群  }
{==========================================}
procedure BitSet(n: longword);  {フラグ・セット手続}   数nを表に書き込むことに相当
var
  n0,n1: longword;
begin
  n0:=n and $0000001f; n1:=n shr 5;   下5ビットと上27ビットに分離
  Dp^[n1]:=Dp^[n1] or Dmx[n0];        下5ビットが変数内のビット位置を表し
end;                                  上27ビットが配列内の位置を表す

procedure BitClr(n: longword);  {フラグ・リセット手続}   数nを表から消すことに相当
var
  n0,n1: longword;
begin
  n0:=n and $0000001f; n1:=n shr 5;
  Dp^[n1]:=Dp^[n1] and Dm[n0];
end;

function BitChk(n: longword): boolean;  {フラグチェック関数}   数nが表にあるかどうかを調べることに相当
var
  n0,n1: longword;
begin
  n0:=n and $0000001f; n1:=n shr 5;
  BitChk:=(Dp^[n1] and Dmx[n0])<>0;
end;

 プログラムの内容が、2進数の操作になるので、数学に弱い人には理解することが難しいかもしれない。 数学の教科書を見て、2進数と10進数の表現の違いについて書かれているところをよく勉強しておいて欲しい。 この手続きと関数さえあれば、アルゴリズムの主体であるプログラム本体には、 今までと同じようにプログラムを記述することが出来る。 プログラム本体の部分と階層化して、ビット操作を見えなくする(マスクする)ように、 プログラムを階層化して、何段階かに分離することで、 上位のプログラムに分かり難いビット操作を隠してしまう手法は常用される「階層化技術」という。 特に、オブジェクト指向プログラミング(OOP)で多用される手法である(オブジェクト内にビット操作 を閉じ込め、外のプログラムからは見ずに済むようにしてしまえる)。
 これで、5億未満から10億未満程度までの挑戦が可能になる。

「倍数除去」 アルゴリズムでも、「チューニング」 作業は大きく記録に影響する

 「倍数除去」 アルゴリズムでの処理速度改善では「 メモリースワップを興さないこと」 が一番大きい。 次に、「無駄な処理を見つけて取り除くこと」 が続く。 記録を狙うには、上記のようなメモリー節約の対策を第一に考え、 次に、「逐次余りチェック」アルゴリズムと同様の無駄を見つける地味なチューニングに努めればよい。
 「倍数除去」 アルゴリズムのプロトタイプ・プログラムにもたくさんの無駄がまだ含まれている。 チューニング作業だけで、2から3倍の速度改善はすぐに可能であるのが普通である。 アルゴリズムの改良はチューニングと異なり、10倍速のように桁違いに速くすることに成功する場合が多い。

「影武者」さんはどのようにして、10億の壁を突破できたのか

 表の変数をビット単位で扱う技を使って1億の壁は突破できた。 これで、10倍程度限界を伸ばすことが出来ることになった(正確には限界値が8倍伸びた)。 しかし、その次の壁となる「10億未満」の壁の突破はビット処理では実現できない。 表の使い方の新たなアルゴリズムの創造が必要である。
 「影武者」さんは、最新バージョン6で「100億未満」まで記録を伸ばした。 このときに使われたプログラムの全ソースリストを公開し、 表の使い方の「新しい技」の解説をする予定です。

 「第12回 「素数発見」アルゴリズムの進化を解説する C」 を 見る



2004/03/29  管理人(志)


感想・意見は、掲示板、または メールでどうぞ! --- 管理人(志)