GPUを使ってマンデルブロ集合の描画を3桁速くする方法


描画が3桁速くなると、マンデルブロ集合をインタラクティブに楽しめるようになります。

はじめに

以前、Mathematicaでマンデルブロ集合を描く方法を紹介しました(「追悼、マンデルブロ博士」)。

Clear@f;
f[c_] := f[c, 0., 0]
f[_, _, 100] = -1;
f[c_, z_, i_] := With[{x = z^2 + c}, If[2 < Abs@x, i, f[c, x, i + 1]]]
DensityPlot[f[x + y I], {x, -2, 1}, {y, -1.5, 1.5}, PlotPoints -> 100]

描くのは確かに簡単だったのですが、「ここをもうちょっと拡大したい」という要望に応えるためのインタラクティブな仕掛けは、遅すぎて使えませんでした。「追悼記事なのに・・・」という思いがあったので、ちょっと改善することにしました。

目的

計算を高速化して、インタラクティブなマンデルブロ集合描画システムを作ります。

手法

(1)計算自体(上の例のf)高速化と(2)並列化で高速化します。(1)のために、再帰を反復に書き換えます。(2)のために、CUDAを使います。マンデルブロ集合の計算は、いわゆる「embarrassingly parallel」というやつなので、CUDAの練習に向いていると思います。

この記事のようにインタラクティブなものではありませんが、Mathematicaのマニュアルにもマンデルブロ集合の例が掲載されています。

条件

時間を計る条件は次のようにします。「有理数だと遅い」と思われるかもしれませんが、浮動小数点数を使うと、TableとParallelTableの結果が違ってしまう危険があることへの対応です。ちなみに、これはバグだと思うのですが、Wolfram社の回答は「バグでは無い」でした。

xMin = -2;
xMax = 1;
yMin = -3/2;
yMax = 3/2;
dx = (xMax - xMin)/300;
dy = (yMax - yMin)/300;

計算機環境

  • CPU:Core i7 930(HTオフ)
  • 主記憶:6GB
  • GPU:NVIDIA GeForce GTX 465
  • OS:Windows 7 64 bit
  • Mathematica 8.0.1

結果

比較の基準

最初のバージョンをCUDAを使うバージョンで、計算時間を比較してみましょう。

最初のバージョンの実行時間を計ります。

time0 = AbsoluteTiming[
   result = Table[ f[x + y I],
      {y, yMin, yMax, dy}, {x, xMin, xMax, dx}];][[1]]

23.5279876

CUDA

CUDAの準備をします(MathematicaからCUDAを使う時、書かなければいけないCのコードはこの部分だけなので簡単です)。

Needs["CUDALink`"]

kernelCode = "
  __device__ int check(Real_t x, Real_t y, int maxSteps) {
    Real_t re = 0, im = 0;
    int i = 0;
    while (i++ < maxSteps) {
      Real_t newRe = re * re - im * im + x;
      Real_t newIm = 2 * re * im + y;
      if (4 < newRe * newRe + newIm * newIm) return i;
      re = newRe;
      im = newIm;
    }
    return -1;
  }

  __global__ void mandelbrot(int* result, int maxSteps,
      int width, int height,
      Real_t xStart, Real_t xEnd, Real_t yStart, Real_t yEnd) {
    int xIndex = threadIdx.x + blockIdx.x * blockDim.x;
    int yIndex = threadIdx.y + blockIdx.y * blockDim.y;
    if (xIndex < width && yIndex < height) {
      Real_t x = xStart + (xEnd - xStart) * xIndex / width;
      Real_t y = yStart + (yEnd - yStart) * yIndex / height;
      result[xIndex + yIndex * width] = check(x, y, maxSteps);
    }
  }
  ";

kernel = CUDAFunctionLoad[kernelCode, "mandelbrot",
   {{_Integer, _, "Output"}, _Integer, _Integer, _Integer,
    _Real, _Real, _Real, _Real}, {16, 16}];

これがうまくいかないときは、CUDAのための環境がちゃんとあることを確認してください。私は、Visual Studio 2008 Professionalのデフォルトではx64のコンパイラがインストールされないために少しはまりました。本稿執筆時点でMathematicaが使うデフォルトのCUDA SDKは3.1で、これがサポートするのはVisual Studio 9 (2008)までだということにも注意が必要です(カスタマイズすればいいのでしょうが)。追記:2011年8月にCUDA SDK 4に対応したので、Visual Studio 2010がそのまま使えるようになりました。

実行してみると、4000倍くらい速くなることがわかります。

time = AbsoluteTiming[
    width = 300; height = 300;
    buffer = CUDAMemoryAllocate[Integer, {height, width}];
    kernel[buffer, 100, width, height, N@xMin, N@xMax, N@yMin, N@yMax];
    result = CUDAMemoryGet[buffer];][[1]];

CUDAMemoryUnload[buffer];

time0/time

3920.8

まあ、GPU無しの比較対象は最初に思いつくものですから、「まずGPU無しでどこまで速くなるのか追求してから」と思う方は、下の「おまけ」を読んでください。GPU無しでも最初のものより80倍くらいは速くできるので、それと比べれば、「GPUの効果は50倍くらい」というのが妥当なところでしょう。

リアルタイム描画

描画には、ArrayPlotを使うことにします。ListDensityPlotのほうが高度ですが、少し遅いのと、座標の扱いに問題があったため使えませんでした。Imageは速そうですが、やはり座標の扱いに問題がありました。

ユーザインターフェースはMathematicaのManipulateで構築します。

width = 300; height = 300;
buffer = CUDAMemoryAllocate[Integer, {height, width}];

Manipulate[With[{
   xMin = center[[1]] - 10^scale, xMax = center[[1]] + 10^scale,
   yMin = center[[2]] - 10^scale, yMax = center[[2]] + 10^scale},
  kernel[buffer, IntegerPart[10^maxSteps], width, height, xMin, xMax,
   yMin, yMax];
  ArrayPlot[CUDAMemoryGet[buffer],
   DataRange -> {{xMin, xMax}, {yMin, yMax}},
   DataReversed -> True, ColorFunction -> "Rainbow"]],
 {{center, {-0.5, 0}}, Locator},
 {{scale, 0, "Scale"}, -13, 1},
 {{maxSteps, 2, "Log[maxSteps]"}, 1, 3}]

realtime

画像上をクリックすると、その場所を中心にして再描画します。Scaleで描画領域の広さを、Log[maxSteps]で反復の上限を変更できます。

ぬるぬる動くというわけにはいきませんが、「ここをもうちょっと拡大したい」という要望には十分応えられると思います。もっと速くしたい場合は、ArrayPlotをどうにかするということになるのでしょうが、もう少しコードを書かなければならないでしょう。

ところで、さすがにこれってCDFにしてもだめなんですよねえ。

おまけ(細かいことなので、これ以降は読まなくていいです)

追悼記事がダメだった理由

追悼記事がダメだった最大の要因は、DensityPlotにあったようです。DensityPlotは、下に示すように場所ごとにメッシュの細かさを自動調整してくれる便利な関数なのですが、この内部が並列化されていないため、あまり速くならないのです。

densityplot

CUDAなしでどこまで行くか

コンパイル+並列化

Mathematicaでは、機械精度の数を使うことに限定すれば、ユーザ定義関数をコンパイルすることができます。最初のバージョンをコンパイルし、並列化して実行すると30倍くらい速くなります。

fc = Compile[{{c, _Complex}, {z, _Complex}, {i, _Integer}},
   If[i == 100, -1,
    With[{x = z^2 + c}, If[2 < Abs@x, i, fc[c, x, i + 1]]]],
   CompilationTarget -> "C"];
DistributeDefinitions[fc];

time = AbsoluteTiming[
   result = ParallelTable[ fc[x + y I, 0, 0],
      {y, yMin, yMax, dy}, {x, xMin, xMax, dx}];][[1]];

time0/time

28.44619
反復

再帰を反復に書き直すともっと速く、80倍くらい速くなります。

hc = Compile[{{z, _Complex}},
   Module[{c = 0. + 0. I, i = 0},
    While[i++ < 100,
     c = c^2 + z;
     If[2 < Abs@c, Return@i]];
    Return@-1]];
DistributeDefinitions[hc];

time = AbsoluteTiming[
   result = ParallelTable[ hc[N@x + N@y I],
      {y, yMin, yMax, dy}, {x, xMin, xMax, dx}];][[1]];

time0/time

77.13116

ちなみに、この例ではコンパイル時に「CompilationTarget -> “C”」を付けてもあまり効果は無いようです。

CUDA無しでのリアルタイム描画

この方法を使ってリアルタイムに描画してみます。スケールや反復の上限もリアルタイムに変更できるように、少し修正します。

hc2 = Compile[{{xIndex, _Integer}, {yIndex, _Integer},
    {maxSteps, _Integer},
    {width, _Integer}, { height, _Integer},
    {xMin, _Real}, {xMax, _Real},
    {yMin, _Real}, {yMax, _Real}},
   With[{z = xMin + (xMax - xMin) xIndex/width
       + (yMin + (yMax - yMin) yIndex/height) I},
    Module[{c = 0. + 0. I, i = 0},
     While[i++ < maxSteps,
      c = c^2 + z;
      If[4 < Abs@c, Return@i]];
     Return@-1]],
   CompilationTarget -> "C"];
DistributeDefinitions[hc2];

width = 300; height = 300;
Manipulate[
 With[{
   xMin = center[[1]] - 10^scale, xMax = center[[1]] + 10^scale,
   yMin = center[[2]] - 10^scale, yMax = center[[2]] + 10^scale,
   maxSteps = IntegerPart[10^logMaxSteps]},
  result = ParallelTable[
    hc2[xIndex, yIndex, maxSteps, width, height, xMin, xMax, yMin, yMax],
    {yIndex, 1, height}, {xIndex, 1, width}];
  ArrayPlot[result, DataRange -> {{xMin, xMax}, {yMin, yMax}},
   DataReversed -> True, ColorFunction -> "Rainbow"]],
 {{center, {-0.5, 0}}, Locator},
 {{scale, 0, "Scale"}, -13, 1},
 {{logMaxSteps, 2, "Log[maxSteps]"}, 1, 3}]

実行結果は上に掲載したものと同じです。速いCPUがある場合はこれで十分なのかもしれませんが、私の環境では、「一応動くがストレスフル」という感じでした。

数独の平凡な解法(C言語)


数独の解き方をいくつか紹介したことがあります。

困ったことに、「どれも変態でわかりにくい、ふざけんな」という批判を受けたので、みんな大好きC言語で、平凡に書いてみました(実行結果)。

#include <stdio.h>

#define N 9
#define B 3 //square root of N

int canBePlaced(int board[], int pos, int x)
{
  int row=pos/N;
  int col=pos%N;
  int i, j, topLeft;
  for (i=0; i<N; ++i) {
    if (board[row*N+i]==x) return 0;
    if (board[col+i*N]==x) return 0;
  }
  topLeft=N*(row/B)*B+(col/B)*B;
  for (i=0; i<B; ++i) {
    for (j=0; j<B; ++j) {
      if (board[topLeft+i*N+j]==x) return 0;
    }
  }
  return 1;
}

void check(int board[], int pos)
{
  int i, x, newPos;

  //Solution is found.
  if (pos==N*N) {
    for (i=0; i<N*N; ++i) {
      printf("%d ", board[i]);
      if (i%N==N-1) printf("\n");
    }
    printf("\n");
    return;
  }

  //Find a blank.
  for (newPos=pos; newPos<N*N; ++newPos) {
    if (board[newPos]==0) break;
  }

  //Check recursively.
  for (x=1; x<=N; ++x) {
    if (canBePlaced(board, newPos, x)) {
      board[newPos]=x;
      check(board, newPos+1);
      board[newPos]=0;//backtracking
    }
  }
}

int main()
{
  int board[]={
    1,0,0,0,0,7,0,9,0,
    0,3,0,0,2,0,0,0,8,
    0,0,9,6,0,0,5,0,0,
    0,0,5,3,0,0,9,0,0,
    0,1,0,0,8,0,0,0,2,
    6,0,0,0,0,4,0,0,0,
    3,0,0,0,0,0,0,1,0,
    0,4,0,0,0,0,0,0,7,
    0,0,7,0,0,0,3,0,0};
  check(board, 0);
  return 0;
}

数独の平凡な解法(Mathematica)

Excelを使っていると採点ミスで謝罪することになる?


事例が見つかるたびに騒ぎになっているようです。今回は、「8.2 – 7.2」の結果が1より小さくなってしまうというものです。最初に非難されたのは例によってExcelのようですが、他の環境でもこういう問題は起こります(C言語の例Rubyの例)。

#include <stdio.h>

int main() {
  double x = 8.2 - 7.2;
  if (x >= 1) printf("x >= 1");
  else printf("x < 1");
  return 0;
}

「コンピュータは数値を2進数としてしか表せなくて・・・」という説明をよく見ますが、この説明は間違いです。これでは、「コンピュータは正しい計算ができない」という誤解を生んでしまいます。

10進数の1, 2, 3, …を2進数の1, 10, 11, …で表すという具合に、数を2進数で直接表現しようとするとこういう問題が起きますが、別の表現方法を用いれば、このような問題は起きません。実際、Mathematicaは正しく計算します。UMMでも確かめられます。)

With[{x = 8.2 - 7.2}, If[x >= 1, "x >= 1", "x < 1"]]

x >= 1

コンパイルしても大丈夫です。(UMMでは動かないようです。)

f = Compile[{a, b}, With[{x = a - b}, Print[If[x >= 1, "x >= 1", "x < 1"]]]];
f[8.2, 7.2]

x >= 1

C言語やRubyではどうすればいいのかというと、許容可能な誤差を決めておいて、その範囲内はよしとするのです。

さらに、この許容可能な誤差は、扱う数によって変えなければならないことに注意してください。以下の例では、許容可能な誤差を1より大きくしなければなりません(実行結果)。

#include <stdio.h>

int main() {
  double a = 9007199254740992;
  double b = a + 1;
  if (a == b) printf("a==b");
  return 0;
}

4891006269こういうことは、『Microsoft Visual C++入門』の3.2.11項「浮動小数点についての注意」にちゃんと書いてあったりします。ですから、入門を終えたプログラマはこういうことは知っています。

Excelのような、こうことは知らないであろう人が使うソフトウェアでは、こういう工夫をしなくても正しく計算できるようにしておいてほしいものです。フェルマーの最終定理の「反例」のような冗談を言っているうちはいいのですが、「x >= 0.6 なら合格」というようなことって、よくやられているのではないでしょうか(ボーダーの学生が間違って不合格になる危険があります)。間違いが公になるときには、「採点ミス」になるのでしょうか。

世界一難しい数独


解けたら天才! フィンランドの数学者が作った「世界一難しい数独」というのがあるらしい。

こちらの問題、実はフィンランドの数学者Arto Inkala氏が作成した問題で、曰く「世界でもっとも難しい数独」とのこと

1847534511この問題の作者であるArto Inkalaさんは、これまで「史上最難」と言われいた問題「AI Escargot」の作者でもある。その彼が言うのだから、間違いないと言いたいところだが、そもそも、数独の難しさに客観的な指標なんてないはず。合理的なものに限っても、たまたま知っていた定石で一発、なんてこともあり得る。だから、「ある解き方においては最も難しい」という限定的なことしか言えないのだ。彼自身、著書“AI Escargot”の中で次のように述べている。

it is often impossible to clearly identify the clearly most difficult puzzle in the world.

それにも拘わらず「世界でもっとも難しい」なんて言うことの大人の事情は放っておいて、せっかくだから手元にあるアルゴリズム(単純なしらみつぶし)でどうなるかを見てみよう。解を見つけるまでにどれだけの候補をチェックしなければならないかを調べる。Mathematica版なら全体をTraceで囲んで、結果の長さを見ればよい(UMMでも動く)。

problem={
  {0, 0, 5, 3, 0, 0, 0, 0, 0},
  {8, 0, 0, 0, 0, 0, 0, 2, 0},
  {0, 7, 0, 0, 1, 0, 5, 0, 0},
  {4, 0, 0, 0, 0, 5, 3, 0, 0},
  {0, 1, 0, 0, 7, 0, 0, 0, 6},
  {0, 0, 3, 2, 0, 0, 0, 8, 0},
  {0, 6, 0, 5, 0, 0, 0, 0, 9},
  {0, 0, 4, 0, 0, 0, 0, 3, 0},
  {0, 0, 0, 0, 0, 9, 7, 0, 0}};

Reap@Length[Trace[search[{problem}, Join[#2, #1] &, False]]]

{80070, {{1 4 5 3 2 7 6 9 8}}}
          8 3 9 6 5 4 1 2 7
          6 7 2 9 1 8 5 4 3
          4 9 6 1 8 5 3 7 2
          2 1 8 4 7 3 9 5 6
          7 5 3 2 9 6 4 8 1
          3 6 7 5 4 2 8 1 9
          9 8 4 7 6 1 2 3 5
          5 2 1 8 3 9 7 6 4

となるので、この問題をMathematicaで解くときの、Traceの長さは80070。

一方、AI Escargotはと言うと、

problem={
  {1, 0, 0, 0, 0, 7, 0, 9, 0},
  {0, 3, 0, 0, 2, 0, 0, 0, 8},
  {0, 0, 9, 6, 0, 0, 5, 0, 0},
  {0, 0, 5, 3, 0, 0, 9, 0, 0},
  {0, 1, 0, 0, 8, 0, 0, 0, 2},
  {6, 0, 0, 0, 0, 4, 0, 0, 0},
  {3, 0, 0, 0, 0, 0, 0, 1, 0},
  {0, 4, 0, 0, 0, 0, 0, 0, 7},
  {0, 0, 7, 0, 0, 0, 3, 0, 0}};

Reap@Length[Trace[search[{problem}, Join[#2, #1] &, False]]]

{71766, {{1 6 2 8 5 7 4 9 3}}}
          5 3 4 1 2 9 6 7 8
          7 8 9 6 4 3 5 2 1
          4 7 5 3 1 2 9 8 6
          9 1 3 5 8 6 7 4 2
          6 2 8 7 9 4 1 3 5
          3 5 6 4 7 8 2 1 9
          2 4 1 9 3 5 8 6 7
          8 9 7 2 6 1 3 5 4

となる。解くときのTraceの長さ71766は、先の値80070より小さい。ということは、ここで採用しているアルゴリズムを基準にすれば、今回発表された問題は、確かにAI Escargotより難しい。

しかし、このアルゴリズムを基準にするなら、もっと難しい問題はある(Mathematicaでは時間がかかるからC++版で試す。オプション「-pg」を付けてコンパイルして実行し、gprofでプロファイルを見ればよい)。MathematicaでもC++でも、わざわざコードを書き換えたりしないのがポイント。

要するに、どの隅から解いていくかという話なのだが、ここで使っている方法の場合、どちらの問題も、右下から左下に向かって解くときに最も難しくなる。再帰的な関数searchが、今回発表された問題180度回転したものの場合は110874回、AI Escargot180度回転したものの場合は1502267回呼ばれる(どちらもCodepadでは解けない)。

つまり、ここで採用した基準では、世界一難しいのは今回発表された問題ではなく、依然としてAI Escargot(を180度回転したもの)だということになる。

追記:どちらも自力で解けた友人によると、AI Escargotの方が難しかったそうです。もう一つの方は30分ほどだったとか。

早まった最適化


B003LMJGUY日経ソフトウエア 2010年 07月号を見ていたら、こんな記事がありました。

このプログラムを速くせよ!(p.114)

問2

int isPrimeNumber(int n) {
  int i, ans;

  ans = -1;
  for (i = 2; i <= (int) sqrt(n); i++) {
    if (n % i == 0) {
      ans = 0;
      break;
    }
  }
  return ans;
}

解答は想像通りのものでしたが、本当にそういう修正が必要なのかどうかは、(規格はともかく)私たちがコンパイラに何を求めるかによると思います。

コンパイラが、「errnoのチェックはしてないみたいですね。じゃあ、for文の評価でsqrtを何度も呼ぶのはやめましょう」と勝手に判断して、gccのオプション「-fno-math-errno」に相当する最適化をすることを私は期待します。

などということを考えさせるいい問題です。実は、素数だったら-1を返すという仕様のほうが気になるのですが、結果を数字と比較するわけではないのでまあいいでしょう。

問3も同じ感じですね。

問4のような関数を学生が書いてきたら、解答例のように書き直すのではなく、Lispを紹介ししたくなります。

4874084141問5の解答は、「よい子はまねをしないでね」というやつですね。高速化云々よりも、2次方程式を題材にして、数値アルゴリズムを紹介した本(例:奥村晴彦『C言語による最新アルゴリズム事典』)にはたいてい載っているような、誤差を少なくするための工夫をあえてしないところがにくいです。

問6も同様で、どうやって速くするかよりも、「(a+ b) / 2」を「a / 2 + b / 2」にすることをまず考えさせる良問です。

「早まった最適化は諸悪の根源だ」とまでは言いませんが、「最適化の前にやるべきことがある」ということは体験できるでしょう。

このほかにも、日経ソフトウェア7月号には、勉強になりそうな記事がいろいろありそうです。

特集「PHP5とSymfony1.4で作るTwitterアプリ」で紹介されているTwitterアプリは、6/30を過ぎたら動かくなるという厳しいタイマーが付いています。7月号でそれはちょっと厳しいんじゃないかと思いますが、動くように修正するのは、よいトレーニングになるでしょう。

「再入門!いまどきのJavaScript」ではGoogle Maps API バージョン2を使っています。1年くらい前に出たバージョン3ではなく、あえてバージョン2を使うあたりが教育的です。バージョン3を使おうと思ったら、自分で、ネットで、英語で勉強しなければならないわけですが、これもまたよいトレーニングになるでしょう。

広く知られているinsertion sortのコードは駄目すぎる?


やねうらおさんによると、「広く知られているinsertion sortのコードは駄目すぎる」らしい。Wikipediaに載っているコード(2009.08.06版)を、

どこの馬鹿なアルゴリズムの教科書から引用してきたのかは知らないが、こんなものをサンプルとして掲載しないでもらいたい。

というのだから穏やかではない。

私の座右の書『コルメン』も、

一連の劣悪なコードはこいつが犯人かも知れない。

と非難されてしまっている。

実際のところ、どのくらいの性能差になるのか、実験してみた。

こんな感じ(codepad)。

C++の標準ライブラリ(std)のsortとWikipedia版、やねうらお版の比較。単位は秒。挿入ソートの2つ(つまりWikipedia版とやねうらお版)は、実行時間の比も計算した(1より小さい値はやねうらお版が速いことを意味する)。

size	repetition	std	wiki	yane	y/w
4	1048576		0.3512	0.1576	0.1333	0.8459
8	233016		0.1148	0.06757	0.07265	1.075
16	65536		0.111	0.02996	0.1191	3.976
32	20971		0.1388	0.1141	0.09537	0.836

Timeout

codepadはすぐにタイムアウトしてしまうから、ベンチマークにはちょっと使えない(そもそも、ほかにどんなプロセスが動いているかわからないし)。

手元のCore i7 940 2.93GHz、主記憶9Gのマシンで実行してみる。

GCC 4.4.2(64ビット)の場合(コンパイルオプションは「-O3」のみ)

size    repetition      std     wiki    yane    y/w
4       6291456         0.3001  0.1896  0.1957  1.032
8       1398099         0.1766  0.1374  0.1427  1.039
16      393216          0.1201  0.09756 0.09972 1.022
32      125829          0.1045  0.08267 0.08463 1.024
64      43689           0.08399 0.07897 0.08637 1.094
128     16047           0.07033 0.09022 0.1061  1.175
256     6144            0.06053 0.118   0.1466  1.243
512     2427            0.05325 0.1704  0.2192  1.287
1024    981             0.04746 0.263   0.3447  1.311
2048    405             0.04273 0.4241  0.5613  1.323
4096    168             0.03847 0.697   0.9245  1.326
8192    72              0.0356  1.187   1.582   1.333
16384   30              0.03148 1.978   2.623   1.326
32768   12              0.02696 3.157   4.208   1.333
65536   6               0.02857 6.37    8.425   1.323

配列のサイズが64くらいまでなら、std::sortを使うよりも挿入ソートのほうが速いようだ。その範囲では、Wikipedia版とやねうらお版にあまり差はない(Wikipedia版が若干速いか)。

インテルC++コンパイラ 11.0(64ビット)の場合(コンパイルオプションは「-fast」のみ)

size    repetition      std     wiki    yane    y/w
4       6291456         0.2103  0.2164  0.2285  1.056
8       1398099         0.1464  0.1455  0.1447  0.9947
16      393216          0.107   0.106   0.1072  1.011
32      125829          0.1034  0.08955 0.09303 1.039
64      43689           0.08693 0.08898 0.09369 1.053
128     16047           0.07405 0.1059  0.1113  1.051
256     6144            0.06446 0.1456  0.1503  1.032
512     2427            0.05672 0.218   0.2219  1.018
1024    981             0.05066 0.3439  0.3462  1.007
2048    405             0.04571 0.5608  0.5618  1.002
4096    168             0.04118 0.9253  0.9255  1
8192    72              0.03799 1.578   1.579   1
16384   30              0.03406 2.628   2.629   1.001
32768   12              0.02887 4.197   4.208   1.003
65536   6               0.0307  8.464   8.46    0.9995

GCCのときよりstd::sortが速くなるのが早い。サイズ64ならもうstd::sortでよいようだ。GCCの場合より、std::sortが有利になるのが少し早い。Wikipedia版とやねうらお版にあまり差はない(やはり、Wikipedia版が若干速いか)。

Visual Studio 2008(32ビット)の場合(Releaseモード。オプションはデフォルトのまま)

size    repetition      std     wiki    yane    y/w
4       6291456         0.329   0.184   0.171   0.9293
8       1398099         0.209   0.14    0.142   1.014
16      393216          0.136   0.114   0.111   0.9737
32      125829          0.105   0.1     0.098   0.98
64      43689           0.103   0.099   0.097   0.9798
128     16047           0.093   0.113   0.115   1.018
256     6144            0.083   0.141   0.155   1.099
512     2427            0.075   0.195   0.226   1.159
1024    981             0.066   0.295   0.35    1.186
2048    405             0.061   0.469   0.568   1.211
4096    168             0.057   0.77    0.937   1.217
8192    72              0.051   1.313   1.617   1.232
16384   30              0.045   2.207   2.72    1.232
32768   12              0.038   3.57    4.398   1.232
65536   6               0.04    6.966   8.513   1.222

配列サイズ64くらいまでは、挿入ソートがstd::sortより速い。その範囲では、Wikipedia版よりやねうらお版のほうが若干速いか。

いずれにしても、配列サイズが小さいなら、挿入ソートを試す価値はありそうだ。しかし、GNUやインテルのコンパイラをよく使う私には、挿入ソートの細かいチューニングは早すぎる最適化になりそうだ。

小さい配列限定のすごく速いソートなら、ソーティングネットワークはどうだろう。サイズ7の場合はこんな感じ(codepad)。batcherがソーティングネットワーク。単位は秒。codepadだと遅いか。

GCC 4.4.2(64ビット)では、ソーティングネットワークが速い。

std::sort: 0.411432
wikipedia: 0.3258
yaneurao: 0.331935
batcher: 0.28106

インテルC++コンパイラ 11.0(64ビット)の場合

std::sort: 0.324813
wikipedia: 0.331058
yaneurao: 0.298131
batcher: 0.228164

ここで使っている関数bathcersortは、CPANにあるAlgorithm::Networkで作った。次のようなコードでASCIIアートを描ける。

use Algorithm::Networksort qw(:all);
$inputs=7;
my @network = nw_comparators($inputs, algorithm => 'batcher');
print nw_graph(\@network, $inputs), "\n";
o--^--------^-----^-----------------o
   |        |     |
o--|--^-----|--^--v--------^--^-----o
   |  |     |  |           |  |
o--|--|--^--v--|--^-----^--|--v-----o
   |  |  |     |  |     |  |
o--|--|--|-----v--|--^--v--|--^--^--o
   |  |  |        |  |     |  |  |
o--v--|--|--^-----v--|--^--v--|--v--o
      |  |  |        |  |     |
o-----v--|--|--------v--v-----|--^--o
         |  |                 |  |
o--------v--v-----------------v--v--o

仕組みを知りたいという向きにはKnuthの第3巻が、「サイズ7限定じゃ意味ないし、任意のサイズでできるようにしたらコンパイルできないじゃん」という向きには『LET OVER LAMBDA Edition 1.0』がおすすめ。

47561461474434133632

分散コンパイルのためのdistcc


とある事情でMySQLを何度もビルドしなければならなくなりました(『MySQLデータベース構築バイブル』を読んだからではありませんが、そういうことにしておいてもらってもいいです)。

利用できる最速のマシン(Core i7 GHz)でMySQL 5.4.3-betaをビルドするのにかかる時間は1分40秒ほどです。1回だけならいいのですが、何回もすることを考えると、少しでも早くビルドできる環境を用意しておきたくなります。

Windowsの場合は知りませんが、GNU/LinuxやMac OS Xでは、C言語とC++の分散コンパイルのためのdistccが簡単に利用できるので試してみました。

手順は以下の通りです。

  1. すべてのマシンで同じコンパイラを利用できるようにする
  2. distccのインストール
  3. 環境変数CC、CXXの設定
  4. ./configure
  5. 環境変数DISTCC_HOSTSの設定
  6. make -jジョブ数

もう少し詳しく説明します。

利用したマシンは以下の通りです(A, D, Eのネットワークは1G、それ以外は100M)。

  • A: Core i7 940 2.93GHz
  • B: Core i5 750 2.67GHz
  • C: Core2 Quad Q6600 2.4GHz
  • D: Core2 4300 1.8GHz
  • E: Athlon 64 X2 5400+
  • F: Pentium D 3GHz

クライアント側で必要なのはdistcc、サーバ側で必要なのはdistcc-serverだけなのですが、マシンをどのように利用するかが確定しているわけではないので、まとめて入れておきます。

yum install distcc*

本稿執筆時点では、パッケージで配布されているdistccのバージョンは2.18でした。Googleによって改良されたバージョン3系列のほうが、パフォーマンスはいいはずなのですが、私の環境ではそれを確認することはできませんでした。

今回利用したGCCのバージョンは4.4.2です。すべてのマシンで、同じフルパスで同じバージョンのgcc, g++を利用できるようにしておきます(binutilsも?)。その上で、クライアントで次のように環境変数を設定します。

export CC="distcc gccのフルパス"
export CXX="distcc g++のフルパス"

さらに、利用するサーバとジョブの数を設定します(クライアントはIPではなくlocalhostと書くべきです)。ジョブ数のデフォルトはIPに対しては4、localhostに対しては2なので、その場合は「/ジョブ数」は省略することができます。「IPアドレス/ジョブ数,lzo」と書くとデータを圧縮するようになりますが、私の環境ではその効果は確認できませんでした。

#distcc Ver. 2の場合
export DISTCC_HOSTS="サーバ1のIP/ジョブ数 サーバ2のIP/ジョブ数 ... localhost/ジョブ数"
#distcc Ver. 3の場合
export DISTCC_POTENTIAL_HOSTS="サーバ1のIP/ジョブ数 サーバ2のIP/ジョブ数 ... localhost/ジョブ数"

すべてのサーバ用マシンで、次のようにしてサーバを起動します(受け入れるジョブの最大値を指定することもできますが、この例のように省略すると、CPU数+2になります)。サブネットは192.168.0.0/24のように指定します(毎回これを行うのが面倒なら、distccdが自動的に起動されように設定しておいてもいいでしょう)。このサーバがポート3632を利用できるようにファイアウォールを設定しておいてください(利用するポートを指定することもできます)。

distccd --daemon --allow サブネット --log-stderr

マシンBをクライアントにして、DISTCC_HOSTSを「AのIP/6 EのIP CのIP localhost」にしたときのビルド時間は1分17秒でした。マシンA単体でのビルド時間1分41秒、マシンD単体でのビルド時間6分54秒と比べると、「速くなってよかったね」という感じです。

投入するジョブの数は微妙な調整が必要です。デフォルトの「CPU数+2」が最適というわけではありません。また、明らかに遅いマシン(ここではマシンF)は入れない方がよさそうです。

Macでも試してみました。Macの開発環境にはdistccがはじめから含まれているので、準備の手間が一つ省けます。

手元のMacBook Pro (Core2 Duo 2.16GHz)単体では、ビルドに7分57秒かかりました(GCCのバージョンは4.2.1)。

MacPortsという常にソースからビルドするシステムがデファクトになっているのでとても困ったことなのですが、私のMacのビルドは遅いです(MacPortsの待ち時間は大嫌いです)。たとえば、CPUはこのMacBook Proより遅いはずのマシンD(上述)のビルド時間は6分54秒でした(ディスクの書き込み速度の差はあまり関係ないでしょう)。

このMacBook Proをクライアントに、Core2 Duo 2GHzのMacBookとMacMiniをサーバにして(DISTCC_HOSTSは「MacBookのIP MacMiniのIP」)ビルド(make -j8)した結果は5分53秒でした(localhostをDISTCC_HOSTSに追加したら遅くなりました)。

確かに速くはなりましたが、「MacPortsがすごく速くなるかも」という期待は裏切られた感じです。

もちろん、GNU/Linuxマシン上にクロスコンパイル環境を構築してサーバにすればよいというのはわかっているのですが、Appleが開発環境を微妙にいじっているせいで、クロスコンパイル環境の構築はちょっとやっかいだったりするのです(そのためのプロジェクトがあるくらいに)。

『ジェネレーティブプログラミング』『C++ Template Metaprogramming』で紹介されているテンプレート/メタプログラミングのコードを使えば、分散コンパイルのインフラ上で並列計算ができたりするわけですが、そういうネタはまた別の機会に。

追記:Cプリプロセッサメタプログラミングで、文字列系泥沼関数型プログラミングというのもすごいですね。

コールグラフを描く2つのツール(EgyptとCodeViz)


休日(でなくてもいいけれど)の読書の題材としてコンピュータ・プログラムを選んだとき、単にコードを読むだけでなく、何か図形的な補佐が欲しいと思うことがある。

オブジェクト指向の言語だとUML図が便利だが、別のパラダイムではあまり役に立たない。

たとえばC言語では、プログラムの構成要素である関数(というか手続き)の呼び出し合う関係を視覚化できると少しうれしい(「すごくうれしい」とまではいかない)。

次のようなコードがあったとする。

#include <stdio.h>

int fib(int n)
{
  if (n<3) return 1;
  return fib(n-2)+fib(n-1);
}

int main()
{
  int i;
  for (i=1; i<=10; ++i) {
    printf("fibonacci(%d) = %d\n", i, fib(i));
  }
  return 0;
}

2つの関数fibとmainの呼び出し関係は次のようになっている。

  • main→fib
  • fib→fib

このような、有向グラフをコールグラフと呼ぶ。

GNU Binutilsに含まれるgprofを使えばコールグラフを生成できるのだが、そのためには一度プログラムを実行しなければならず、得られるコールグラフも、実行時に呼び出された関数のみのものだという欠点がある。

ついでに、グラフがあればそれを描画したいというのが人情だから(グラフが大きいとあまり意味がないのだが)、下のような絵が描けるとうれしい。

というわけで、コールグラフを描画するためのツールであるEgyptとCodeVizを試す。

  • Egypt: Cのコードを解析してコールグラフを生成
  • CodeViz: CとC++のコードを解析してコールグラフを生成。パッチを当てたGCCを用意するため、導入に少し時間がかかる

準備

想定する環境はUbuntu。

共通の準備

2つのツールともグラフの描画まではやってくれない。どちらも描画はGraphVizに任せるようになっているため、まずはGraphVizをインストールする。

apt-get install graphviz

Egypt

Egyptのインストール手順は以下の通り。

wget http://www.gson.org/egypt/download/egypt-1.6.tar.gz
tar zxf egypt-1.6.tar.gz
cd egypt-1.6
perl Makefile.PL
make
sudo make install

Cygwinでは最後の「make install」で失敗した(というわけで想定環境はUbuntu)。

CodeViz

CodeVizのインストール手順は以下の通り。

wget http://www.csn.ul.ie/~mel/projects/codeviz/codeviz-1.0.11.tar.gz
tar zxf codeviz-1.0.11.tar.gz
cd codeviz-1.0.11/compilers
wget http://ftp.yz.yamagata-u.ac.jp/pub/GNU/gcc/gcc-3.4.6/gcc-3.4.6.tar.gz
cd ..
./configure --prefix=インストール先
make
sudo make install
export PATH=インストール先/gccgraph/bin:$PATH

使い方

Egypt

Egyptの使い方は以下の通り(詳しい使い方は「man egypt」)。

gcc -dr fib.c #fib.c.131r.expandができる
egypt fib.c.131r.expand > egypt.dot
dot -Tgif -Grankdir=LR egypt.dot -o egypt.gif

「-dr」はRTLを生成するためのオプション。「-Grankdir=LR」は階層を左から右に向かって描くためのオプション(デフォルトは上から下)。最後に紙に出力したいなら、「-Tps」としてPostscriptにするといいだろう。

CodeViz

CodeVizの使い方は以下の通り。

gcc fib.c #fib.c.cdepnができる
genfull #full.graphができる。詳しい使い方は「genfull --man」
dot -Tgif -Grankdir=LR full.graph -o full.gif

今読んでいるコード中にあるものだけに限定したい場合は、gengraphを使う(-fは最上位の関数を指定、–plainはdotファイルを出力するための、–no-externは外部のファイルを無視するためのオプション。詳しい使い方は「gengraph –man」)

gengraph -f main --plain --no-extern #main.plainができる。
dot -Tgif -Grankdir=LR main.plain -o main.gif

Startrek

グラフが少しでも大きくなると実用性に疑問が生じてくる。

Chris NystromさんのSuper Star Trek Classicコードで試す。このコードはBASICをそのままCに翻訳したものであるため、今日の基準では、読みやすいとは言えないだろう。それでも読みたいというときに、コールグラフが役立つはず。(Startrek自体に興味がある向きはWikipediaの記事を参照)

Egypt

CodeViz

Code Reading―オープンソースから学ぶソフトウェア開発技法 (単行本)コールグラフは扱われていないが、休日の読書のガイドにはDiomidis Spinellis『Code Reading』(毎日コミュニケーションズ, 2004)がおすすめ。

プログラマの道具箱(深さ優先探索と幅優先探索) C++編


参考:数独の平凡な解法(C言語Mathematica

Mathematicaで実装した深さ優先探索と幅優先探索をC++に移植してみましょう。

深さ優先探索と幅優先探索

探索木のノードを表現する型をSTATUS、そのリストをFRINGE、FRINGEにノードを追加するメンバ関数をCOMBINERとします。

データ構造はツリーではなくリストを使います。候補ノードを、スタックで管理すれば深さ優先探索、キューで管理すれば幅優先探索になるわけですが、std::stackとstd::queueにおいて、要素を取り出すメソッド名が同じではないため多態性を利用できません。ですから、ノードはstd::listで管理し、要素はメンバ関数pop_front()で常に先頭から取り出すことにします。

要素を追加する際に、メンバ関数push_front()を使えば深さ優先探索に、push_back()を使えば幅優先探索になります。探索に使う関数search()は、引数としてこれらのメンバ関数へのポインタを取るようにしましょう。

道具箱には次のようなコードを入れておけばいいでしょう。

#include <iostream>
#include <list>

using namespace std;

typedef ... STATUS; //ノードのための型
typedef list<STATUS*> FRINGE; //ノードを管理するオブジェクトの型
typedef void (FRINGE::*COMBINER)(FRINGE::const_reference); //メンバ関数へのポインタ

//解を報告する手続き
void report(STATUS* s)
{
  ...
}

//解かどうかを判定する述語
bool goal(STATUS* s)
{
  ...
}

//探索を深める
void expand(FRINGE* fringe, STATUS* s, COMBINER combiner)
{
  ...
  STATUS* newStatus=new STATUS...
  ...
  (fringe->*combiner)(newStatus);
}

//探索の本体
void search(FRINGE* fringe, COMBINER combiner, bool findAll)
{
  if (fringe->size()!=0) {
    STATUS* s=fringe->front();
    fringe->pop_front();
    if (goal(s)) {
      report(s);
      if (!findAll) {
        while (!fringe->empty()) {
          STATUS* tmp=fringe->front();
          fringe->pop_front();
          delete tmp;
        }
      }
    }
    else expand(fringe, s, combiner);
    delete s;
    search(fringe, combiner, findAll);
  }
}

int main ()
{
  STATUS* s=new STATUS...
  ...
  FRINGE* fringeP=new FRINGE();
  fringeP->push_back(s);
  search(fringeP, &FRINGE::push_front, true); //depth-first search
  //search(fringeP, &FRINGE::push_back, true); //breadth-first search
  return 0;
}

数独を解く場合には、typedef int STATUS;でいいでしょう。関数については、実行結果を参照してください(実行する場合には、スタックサイズを大きくする必要があるかもしれません)。

コメントと空行を取り除くと、(Mathematicaの時は含めなかった)実行部分を含めて約100行です。

C++に関する必要な知識の大部分は、拙著『Microsoft Visual C++入門』にまとめてありますが、深さ優先・幅優先を指定するために必要なメンバ関数へのポインタの使い方は確認しておきましょう。

クラスFooのオブジェクトのメンバ関数へのポインタには、次のように名前(ここではBAR)を付けておくのが簡単です。

typedef 戻り値の型 (Foo::*BAR)(パラメータリスト)

Fooののメンバ関数funcへのポインタbarは、次のように取得できます。

BAR bar=&Foo::func;

次のようにして、クラスFooのオブジェクトfooのメンバ関数を、ポインタbarを使って呼び出します。

(foo->*bar)(引数リスト)

参考:プログラマの道具箱(深さ優先探索と幅優先探索) Mathematica編

関数printf()内の%lf


学生が書くC言語のプログラムに、「printf(“%lf”, x)」のような断片を見つけることがあります(ここでxはdoubleの変数)。

私:なんで%lfなの?
学生:doubleだからです。

確かにscanf系の関数では、代入先がfloatなら%f、doubleなら%lfを使います。しかし、printf系の関数の場合はそうではありません。対象がfloatでもdoubleでも%fを使います(正確に言えば、doubleには%fを使います。floatのための変換指定子はありませんが、%fを使えばfloatがdoubleにキャストされます)。

新・C言語入門 シニア編 (C言語実用マスターシリーズ) (単行本)

関数printf()の変換修飾子「l」の意味は次の通りです。

変換指定子がd、i、o、u、x、Xのとき対応する引数がlong型またはunsigned long型であることを示す。変換指定子がnのとき対応する引数がlong型へのポインタであることを示す。(『新訂新C言語入門シニア編』p.321)

変換指定子「f」に変換修飾子「l」は付かないのです。関数printf()内の%lfは、C90では未定義です。

間違って書く人が多かったためか、C99では%lfを使うことが許容されました。%fとして扱うというだけのことですが。

単純に「floatは%f、doubleは%lf」と憶えておけばよいことになったのですから、学生にとってはいい話かもしれません。しかし、こんな間違った憶え方をする前に、「どのように動いているのか」を考える癖をつけたほうがいいのではないでしょうか。

Javaでも1.5からprintfが使えるようになりましたが、”%lf”と書くと例外が発生します。

年配の先生から「使い分けなければならないハードもかつてはあった」ということを教えてもらいました。