暮に講習会に出たのだけれど、そこで久しぶりに思い出すことになって、 しばし再考して楽しんだ(?)話。
結局は、過去に書いたこと (「行列はどうする」 特に 「二つの方法の優劣」, 「行列・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
|
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++ の利用を勧めることがありうる。
桂田 祐史