暮に講習会に出たのだけれど、そこで久しぶりに思い出すことになって、 しばし再考して楽しんだ(?)話。
結局は、過去に書いたこと (「行列はどうする」 特に 「二つの方法の優劣」, 「行列・2次元格子上の数値データを C のプログラムでどう扱うべきか」) を思い出しただけのような気もするが。
微分方程式のシミュレーションをしていると、 行列 や2次元格子上で定義された関数値 を記録するために二重添字を持つ数列を扱う必要が生じる。 Fortran でプログラムを書く場合は、 二次元配列で扱って特に問題は生じないことが多いのだが、 C 言語で書く場合はいくつか問題が生じる。
整合配列を使いたいが…
Fortran には、古くから整合配列 (conformant array) の機能があるため、 副プログラムとの間のデータのやりとりに特に問題は生じないが、 C言語の場合は、そうではない。
GCC や LLVM では、可変長配列の機能は残っているので、 それを使えば良いのかもしれないが… 個人的にはかなり気持ちが悪い (規格は尊重すべき)。
私のコンピューターの師匠は、ポインターへのポインターを使いなさい、 少し余分なメモリーが必要になるけれど問題にはならない、 という意見だった。
そういうわけで、個人的には次のようなコードを使うことにしていた。
#include <stdlib.h> typedef double *vector; typedef vector *matrix; matrix newmatrix(int m, int n) { int i; vector ap; matrix a; if ((a = malloc(m * sizeof(vector))) == NULL) return NULL; if ((ap = malloc(m * n * sizeof(double))) == NULL) { free(a); return NULL; } for (i = 0; i < m; i++) a[i] = ap + (i * n); return a; } |
(malloc() はメモリーを動的に確保する関数である。 void *malloc(size_t) というプロトタイプ宣言が、 stdlib.h に入っている。)
このやり方は確かに少し余分のメモリーが必要になるが、 評価できるところは多い。
/* 素朴なやり方で行列の掛け算をする */ void mulmatrix(matrix c, matrix a, matrix b, int l, int m, int n) { int i, j, k; double s; for (i = 0; i < l; i++) for (j = 0; j < n; j++) { s = 0.0; for (k = 0; k < m; k++) s += a[i][k] * b[k][j]; c[i][j] = s; } } |
row-major order と column-major order の違いの問題
(これはC言語に限った話ではないけれど…)
Fortran で
real a(3,2) |
ところが C で
double a[3][2]; |
いわゆる線形演算をする場合、 LAPACK のような Fortran で書かれたサブルーチン・ライブライリィの 利用を検討したくなる。 その場合に、FORTRAN と C の2次元配列で、 order が異なっていることが問題となる。
同様に、2変数関数のグラフの鳥瞰図などを描く関数を呼ぶ場合も、 それが問題となる。
個人的には、C や C++ でプログラムを書いていて、 そこから Fortran で書かれたサブルーチンを呼び出す場合が多かったが、 しばしば column-major で記憶するようにしたりした。
例えば column-major order の言語で書かれた関数 bird_view() を、 C で書いたプログラムから呼び出すために次のようなコード (2つ示す) を用いる。
方法1 を u[][] に記憶する |
double u[ny+1][nx+1]; // ここの不自然な感じと ... for (i = 0; i <= nx; i++) { x = a + i * dx; for (j = 0; j <= ny; j++) { y = c + j * dy; u[j][i] = f(x, y); // ここの不自然な感じは目をつぶる } } bird_view(u, nx, ny); |
方法2 マクロを使って column-major order でアクセス |
// nx, ny がマクロ定義の場合でも正しく動くように (nx), (ny) と書く #define u(i,j) u[(i)+((nx)+1)*(j)] // column-major order int nx, ny; double *u; ... u = malloc(((nx)+1)*((ny)+1)); if (u == NULL) ... for (i = 0; i <= nx; i++) { x = a + i * dx; for (j = 0; j <= ny; j++) { y = c + j * dy; u(i,j) = f(x, y); } } bird_view(u, nx, ny); |
それから、これは馬鹿馬鹿しいようだが、 bird_view() のような関数を呼ぶときに、 転置した内容の変数を用意する、ということも考えられる。
小さなスタックをどうするかの問題
ところで件の講習会では、スタックに大きなデータを記憶するのが難しい、 という問題を取り上げていた。 例えば
#define Nx (1000) #define Ny (2000) int main(void) { double u[Nx][Ny]; // 1000*2000*8=16MB ... } |
今の macOS で普通にやるとスタックサイズがわずか 8MB ということだけれど、 私がプログラミングを始めた頃は、 Cの某処理系のスタック・サイズのデフォールト・サイズは 2KB だったりしたのだ (完全に昔話モード)。
数値計算でその手の大きな変数は、 1つのプログラムでたくさん出てくるものではないので、 auto はやめて、static にするという手もある。
#define Nx (1000) #define Ny (2000) double u[Nx][Ny]; // 関数の外に出してしまう int main(void) { ... } |
#define Nx (1000) #define Ny (2000) int main(void) { static double u[Nx][Ny]; // static と指定する ... } |
もっとも、 この方法は、Nx, Ny が変数の場合には使えないので (可変長配列は static にできない)、 あまり良いやり方ではない。
私なりの結論
結局は、以前からの結論を再確認することになりそう。
次が考えられる選択肢となる。
(a)と(b)を比較すると、メモリー効率は (b) が優れているが、 計算速度はケース・バイ・ケースのようである。
最近の私は (c) に傾きつつある。
個人的には、C はやはり設計の古い言語で、 昔から言われて来たように高級なアセンブラに近いと思う。 特にマクロとか使い出すと… (a) も (b) も理解できるけれど、人に強く勧める気にはなれない。 (アセンブラは重要で、必要性はなくなるはずがないと思うけれど、 第一の選択肢にするものではない。)
C++ ならば、色々なクラス・ライブラリィが使いやすい。 場合によっては、 それが一番大きな理由となって、C++ の利用を勧めることがありうる。
桂田 祐史