2021/08/05(木)調和級数の部分和で遊んでみた

はじめに

次のようなツイートが話題になったことがありました。
調和級数
H_n = \sum_{k=1}^n \frac{1}{k} = 1 + \frac{1}{2} + \frac{1}{3} + \cdots + \frac{1}{n}
が、n \to \inftyのとき無限に発散することはよく知られています。この発散は非常に遅く、30を超える最小のnはかなり大きな数になります。これを計算で求めようと思うと、計算時間の問題もありますが、何よりも丸め誤差の蓄積の影響が馬鹿にならず、正しいnを求めるのは案外大変です。

以下、xに対してH_{n-1}<xだがH_n \ge xとなるようなnF(x)と書くことにします。つまり、F(30)を計算したい

精度保証せずに普通に計算

次の簡単なプログラムで、自然数なxに対してF(x)を計算できます。
#include <iostream>
#include <cmath>

int main()
{
	double s, tmp;
	long long int i;
	std::cout.precision(17);

	s = 0;
	i = 1;
	while (true) {
		tmp = s + 1.0 / i;
		if (floor(tmp) != floor(s)) {
			std::cout << i-1 << ":" << s << "\n";
			std::cout << i << ":" << tmp << "\n";
		}
		s = tmp;
		i++;
	}
}
計算結果は、
xF(x)
24
311
431
583
6227
7616
81674
94550
1012367
1133617
1291380
13248397
14675214
151835421
164989191
1713562027
1836865412
19100210581
20272400600
21740461601
222012783315
235471312310
2414872568832
2540427833562
26109894245237
27298723533129
28812014582525
292207275035877
306000125006292
となりました。果たしてこれは正しいでしょうか?

区間演算してみる

kvライブラリを使って、区間演算でこれが正しいかチェックしてみます。部分和を区間演算して、xを「またいで」いないかどうかチェックします。
#include <kv/interval.hpp>
#include <kv/rdouble.hpp>
#include <kv/dd.hpp>
#include <kv/rdd.hpp>

typedef kv::interval<double> itv;
// typedef kv::interval< kv::dd > itv;

int main()
{
	itv s, tmp;
	long long int i;
	std::cout.precision(17);

	s = 0;
	i = 1;
	while (true) {
		tmp = s + 1.0 / itv(i);
		if (floor(tmp.lower()) != floor(tmp.upper())) {
			std::cout << "warning\n";
			std::cout << i-1 << ":" << s << "\n";
			std::cout << i << ":" << tmp << "\n";
			break;
		} else if (floor(tmp.lower()) != floor(s.lower())) {
			std::cout << i-1 << ":" << s << "\n";
			std::cout << i << ":" << tmp << "\n";
		}
		s = tmp;
		i++;
	}
}
これを実行すると、x=16のときは
\begin{eqnarray*} && H_{4989190} \in [15.999999890595899,15.999999899456953] \\ && H_{4989191} \in [16.000000091029196,16.000000099890251] \end{eqnarray*}
となって間違いなくF(16)=4989191と証明されました。しかし、x=17のときは、
\begin{eqnarray*} && H_{13562026} \in [16.999999921467317,16.999999960785197] \\ && H_{13562027} \in [16.999999995202607,17.00000003452049] \end{eqnarray*}
となってしまい、F(17)=13562027とは断定できないことが分かりました。

なお、このプログラムは単に和を左から足していますが、すべて正のたくさんの数を足すときは、小さい順に足した方が丸め誤差が小さくなり、区間演算の場合は区間の幅が小さくなることが知られています。大体のnが分かっているならば、逆順に足すことによってもう少し頑張れて、F(17)=13562027F(18)=36865412まで証明することができます。

double-double区間演算で頑張る

倍精度ではF(16)まで、逆順に足す工夫をしてもF(18)までしか証明できませんでした。kvライブラリはdouble型を2つ束ねた疑似4倍精度で区間演算ができるので、それを使って頑張ってみました。上に挙げたプログラムの最初の方のtypedef行を変更するだけです。これで計算すると、x=30
\begin{eqnarray*} && H_{6000022499692} \in [29.999999999999855,29.999999999999856] \\ && H_{6000022499693} \in [30.000000000000021,30.000000000000022] \end{eqnarray*}
となり、F(30)=6000022499693と判明しました。最初のtweetを見て計算してみた人は何人かいたようですが、やはり丸め誤差まみれになってしまってぴったりの値を計算するのは難しかったようです。ただ、この計算にはOpenMPを使って並列化しても55時間ほどかかりました(汗)。

計算結果をまとめておきます。
xF(x)
24
311
431
583
6227
7616
81674
94550
1012367
1133617
1291380
13248397
14675214
151835421
164989191
1713562027
1836865412
19100210581
20272400600
21740461601
222012783315
235471312310
2414872568831
2540427833596
26109894245429
27298723530401
28812014744422
292207284924203
306000022499693
こうして見ると、精度保証しない倍精度での計算結果は、F(23)までは(偶然)正しく、F(24)以降は間違っていたことが分かりました。下の赤字のところが間違っています。
xF(x)
235471312310
2414872568832
2540427833562
26109894245237
27298723533129
28812014582525
292207275035877
306000125006292

もうちょっと数学っぽく

有名なOEISを調べてみると、この数列はA002387に見つかりました。そこにはF(28)=812014744422まで載っていました。また、そのページからリンクされたここにはF(2303)まで(!)載っていますが、これが証明されたものかどうかは分かりません。自然数n>1に対して
F(n) = \lfloor \exp(n - \gamma) + \frac{1}{2} \rfloor
(\lfloor\rfloorはガウス記号、\gammaはEuler's gamma)という予想があるようなので、これで計算したものなのかも。

また、ガンマ関数の対数微分であるディガンマ関数
\mathrm{digamma}(x) = \frac{d}{dx}\log\Gamma(x) = \frac{\Gamma'(x)}{\Gamma(x)}
は、漸化式
\mathrm{digamma}(x+1) = \mathrm{digamma}(x) + \frac{1}{x}
を満たすことが知られており、これから直ちに、
H_n = \sum_{k=1}^n \frac{1}{k} = \mathrm{digamma}(n+1) - \mathrm{digamma}(1)
が従います。よって、与えられたxに対して、
n = \lfloor \exp(x - \gamma) + \frac{1}{2} \rfloor
で予測して、H_{n-1}H_nを精度保証付きのディガンマ関数で計算し、
H_{n-1} < x \le H_n
が満たされていればF(x)=nが証明されたことになります。

幸いなことにkvライブラリには精度保証付きのディガンマ関数があるので、これを実装してみたところ、まったく精度が出ず話になりませんでした。原因はちょっと考えたらすぐ分かって、そもそもkvのディガンマ関数は
\mathrm{digamma}(x+1) = \mathrm{digamma}(x) + \frac{1}{x}
の漸化式を用いて1 \le x < 2の範囲に引き戻して計算する実装になっていて、何のことはない、ただ調和級数の部分和を計算するだけになっていたのでした。そこで、kv-0.4.52ではもうちょっと真面目にディガンマ関数を実装してみたという話でした。詳細はガンマ関数の精度保証付き計算メモの3章に書きましたが、簡単に言えば、xを引き戻した上で
\mathrm{digamma}(x) = \int_0^\infty \left( \frac{e^{-t}}{t} - \frac{e^{-xt}}{1-e^{-t}} \right) dt
を精度保証付き数値積分で計算していたのを、引き戻しを行わずに、
\mathrm{digamma}(x) = \log x - \frac{1}{2x} - 2 \int_0^\infty \frac{t}{(t^2+x^2)(e^{2 \pi t}-1)}dt
を使って計算するようにしました。
#include <iostream>
#include <kv/interval.hpp>
#ifdef DD
#include <kv/dd.hpp>
#include <kv/rdd.hpp>
#endif
#include <kv/gamma.hpp>

#ifdef DD
typedef kv::interval<kv::dd> itv;
#define MAX 66
#else
typedef kv::interval<double> itv;
#define MAX 32
#endif

int main() {
	#ifdef DD
	std::cout.precision(34);
	#else
	std::cout.precision(17);
	#endif

	itv gamma, x, y;
	int i;

	gamma = - kv::digamma(itv(1.));

	for (i=2; i<=MAX; i++) {
		std::cout << i << "\n";

		x = i;
		y = floor(mid(exp(x - gamma) + 0.5));
		std::cout << y.lower() << "\n";
		std::cout <<  kv::digamma(y) + gamma << "\n";;
		std::cout <<  kv::digamma(y + 1) + gamma << "\n";;
	}
}
新しいディガンマ関数を用いて計算すると、倍精度で
\begin{eqnarray*} && H_{16309752131261} \in [30.99999999999995,30.999999999999997] \\ && H_{16309752131262} \in [31.000000000000014,31.000000000000061] \end{eqnarray*}
となってF(31)=16309752131262が、double-doubleを使うと、
\begin{eqnarray*} && H_{9516116398696941841501814186} \in [64.99999999999999999999999999997317,64.99999999999999999999999999999567] \\ && H_{9516116398696941841501814187} \in [65.00000000000000000000000000007849,65.00000000000000000000000000010098] \end{eqnarray*}
となってF(65)=9516116398696941841501814187まで証明できました。

数学としては何の意味もない問題でしょうけど、こういうのは楽しいですね。

2021/08/05(木)kv-0.4.52

kvライブラリを0.4.52にアップデートしました。

変更点は以下の通りです。
  • test-rounding.ccで、非正規化数を強制的に0にする"flush to zero"モードになっていないかどうかをチェックするようにした。
  • iccでコンパイルできるように修正。
  • optimize.hppで、「端にない」区間で傾きが単調ならばそこに最小値は存在しない というチェックを入れる改良を行った。
  • defint-newtoncotes.hpp, double-newtoncotes.hppで、最適な分割数の 推定がうまくできていなかったバグを修正。
  • defint.hpp, defint-singular.hppで、急減少関数などの特定の被積分関数 に対してstep sizeがうまく計算できていなかった問題を修正。
  • psa.hppの中のinv関数を、係数のオーバーフローが発生しづらいように改良。
  • gamma.hppの中のdigamma関数の計算アルゴリズムをより高精度なものに変更。
  • その他細かい修正。
iccはデフォルトで非正規化数を強制的に0にする"flush to zero"になっているので、そのままだと区間演算の結果が異常になってしまいます。例えば、上向き丸めモードで2^{-1022}/2を計算すると、0になってしまいます。すなわち、IEEE754に従っていません。オプション-no-ftz (windowsなら/Qftz-)を付けるとこの問題は回避できるようです。iccを使うときは、test-rounding.ccをコンパイル、実行してチェックしてみて下さい。

また、digammaを高精度化したのは、ある問題を解くのに必要になったためです。忘れないうちにその話も記事にするつもりです。

2021/03/20(土)kv-0.4.51

ものすごく久しぶりに、kvライブラリを0.4.51にアップデートしました。

変更点は以下の通りです。
  • 区間演算を行うinterval.hppにfloorとceilを追加。
  • 区間演算と領域の再帰的分割による最適化を行うoptimize.hppが場合によって正しく終了しなかったバグの修正。
  • 高階微分を計算するhighderiv.hppが定数関数を渡されたとき動作しなかったバグの修正。
  • 昨年11月のNVR2020の発表で作成した、複合Newton-Cotes公式を用いた数値積分のための関数群を追加。
大した変更や追加はないのですが、コロナ対応での多忙で時間が取れず、3月になってようやく少し時間が出来たので思い出しながらファイルを整理し、公開できる状態までもっていきました。数値積分のところのドキュメントに手間取りました。

highderiv.hppとoptimize.hppはともに数値積分の誤差項の計算に使われており、昨年11月の発表の際にいろいろ使ってみて不具合が発覚しています。やはり使われて鍛えられないとライブラリの完成度は上がらないので、ご利用下さると大変嬉しいです。

2020/06/21(日)Ubuntu 20.04のBLAS (dgemm) をベンチマーク

Ubuntu 20.04で、aptでインストール出来るBLASの速度を、dgemm (倍精度の行列積) でテストしてみたので、記録を残しておきます。

BLASとは

BLAS (Basic Linear Algebra Subprograms) は、行列とベクトルの単純な操作を行うプログラム群です。有名な数値線形代数のプログラム集である LAPACK (Linear Algebra PACKage) の内部で使われています。行列とベクトルの操作は、特に最近のキャッシュメモリに依存したアーキテクチャのCPUではCPU毎に最適な書き方が異なるので、LAPACK本体から切り離して自由に差し替えられるようにしておき、ユーザがLAPACKを動かすときにその計算機に最適なBLASに差し替えることによってLAPACKは最高の性能を発揮するようにデザインされています。

Ubuntu 20.04で使えるBLAS

Ubuntu 20.04で探してみたところ、aptで簡単にインストールできるBLASが5種類ありました。それぞれの特徴と、インストールコマンドを書いておきます。
  • Reference BLAS
sudo apt install libblas-dev
LAPACKと一緒に配布されているBLAS。入出力インターフェースのReferenceを与える、という意味合いと思われ、中身は単なるfor文で高速化を考慮したものではありません。
  • ATLAS (Automatically Tuned Linear Algebra Software)
sudo apt install libatlas-base-dev
CPUの特性に合わせて自動的に各種パラメータをチューニングして性能を引き出すBLASです。make時に何通りもの大きさの計算を繰り返して最適な大きさを探るため、makeにはものすごく時間がかかります。実行するマシン上でmakeしないと意味がないので、aptによるbinary配布では本来の性能は発揮できないはずです。
sudo apt install libblis-dev
Ubuntu 20.04で使えるBLASを探していて、今回初めて知ったBLAS実装です。最近できたものでしょうか。AOCL (AMD Optimizing CPU Libraries) で採用されていたので、AMDのCPUに強いのかも知れません。
sudo apt install libopenblas-base
sudo apt install libopenblas-dev
有名なオープンソース実装のBLAS。かつて、GotoBLASというテキサス大学の後藤和茂氏による高速で有名なBLAS実装があって、その後藤氏のIntelへの移籍で開発が中断したとき、有志がそのソースコードを引き継いで発展させたものです。その後藤氏は現在Intelで後述のMKLに関わっているそうです。
sudo apt install intel-mkl
Intelが提供している、BLASを含む数学用ソフトウェアパッケージで、オープンソースではありません。最近、aptで簡単にインストールできるようになりました。極めて高速とされています。

さて、これらは同じBLASの機能を提供するshared libraryです。同時にインストールすると上書きされてしまうのでしょうか? Ubuntuではそのような場合にsymbolic linkを利用してファイルを切り替える機能を提供しています。上記の5つのライブラリをインストールした状態で、
sudo update-alternatives --config libblas.so-x86_64-linux-gnu
とすると、
There are 5 choices for the alternative libblas.so-x86_64-linux-gnu (providing /usr/lib/x86_64-linux-gnu/libblas.so).

  Selection    Path                                                   Priority   Status
------------------------------------------------------------
  0            /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so   100       auto mode
  1            /usr/lib/x86_64-linux-gnu/atlas/libblas.so              35        manual mode
* 2            /usr/lib/x86_64-linux-gnu/blas/libblas.so               10        manual mode
  3            /usr/lib/x86_64-linux-gnu/blis-openmp/libblas.so        80        manual mode
  4            /usr/lib/x86_64-linux-gnu/libmkl_rt.so                  1         manual mode
  5            /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so   100       manual mode

Press <enter> to keep the current choice[*], or type selection number:
のように表示され、「*」の印がついたライブラリが現在使われているもので、番号を入れるとそのライブラリに切り替えることができます。これはlibblas.soを切り替えるものですが、libblas.so.3も同様にupdate-alternativesの管理下にあるようで、念の為そちらも同じように切り替えて実験しました。
sudo update-alternatives --config libblas.so.3-x86_64-linux-gnu
There are 5 choices for the alternative libblas.so.3-x86_64-linux-gnu (providing /usr/lib/x86_64-linux-gnu/libblas.so.3).

  Selection    Path                                                     Priority   Status
------------------------------------------------------------
  0            /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3   100       auto mode
  1            /usr/lib/x86_64-linux-gnu/atlas/libblas.so.3              35        manual mode
* 2            /usr/lib/x86_64-linux-gnu/blas/libblas.so.3               10        manual mode
  3            /usr/lib/x86_64-linux-gnu/blis-openmp/libblas.so.3        80        manual mode
  4            /usr/lib/x86_64-linux-gnu/libmkl_rt.so                    1         manual mode
  5            /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3   100       manual mode

Press <enter> to keep the current choice[*], or type selection number:

ベンチマーク

これらの5つのライブラリについて、DGEMM (倍精度行列積) の速度を計測してみました。使ったプログラムは、次のようなものです。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

// prototype declaration
void dgemm_(char *transA, char *transB, int *m, int *n, int *k, double *alpha, double *A, int *ldA, double *B, int *ldB, double *beta, double *C, int *ldC);

int main(int argc, char **argv)
{
	int i, j;
	int m, n, k;
	int size;
	double *a, *b, *c;
	double alpha, beta;
	int lda, ldb, ldc;
	struct timespec ts1, ts2;

	size = atoi(argv[1]);

	m = size;
	n = size;
	k = size;

	a = (double *)malloc(sizeof(double) * m * k); // m x k matrix
	b = (double *)malloc(sizeof(double) * k * n); // k x n matrix
	c = (double *)malloc(sizeof(double) * m * n); // m x n matrix

	for (i=0; i<m; i++) {
		for (j=0; j<k; j++) {
			a[i + m * j] = rand() / (1.0 + RAND_MAX);
		}
	}

	for (i=0; i<k; i++) {
		for (j=0; j<n; j++) {
			b[i + k * j] = rand() / (1.0 + RAND_MAX);
		}
	}

	for (i=0; i<m; i++) {
		for (j=0; j<n; j++) {
			c[i + m * j] = 0;
		}
	}

	alpha = 1.;
	beta = 0.;
	lda = m; 
	ldb = k; 
	ldc = m; 

	// dgemm_(TransA, TransB, M, N, K, alpha, A, LDA, B, LDB, beta, C, LDC)
	// C = alpha * A * B + beta * C
	// A=M*K, B=K*N, N=M*N
	// Trans: "N"/"T"/"C"
	// LDA = number of row of A

	clock_gettime(CLOCK_REALTIME, &ts1);
	dgemm_("N", "N", &m, &n, &k, &alpha, a, &lda, b, &ldb, &beta, c, &ldc);
	clock_gettime(CLOCK_REALTIME, &ts2);

	printf("%g\n", (ts2.tv_sec - ts1.tv_sec) + (ts2.tv_nsec - ts1.tv_nsec) / 1e9);

	free(a);
	free(b);
	free(c);

	return 0;
}
BLASはFortranで書かれていて、C言語から呼びやすくしたcblasというインターフェースもあるのですが、ここでは直接BLASを呼び出しています。n×n行列を2つ乱数で初期化し、その積を求めています。また、参考のために単なるfor文のi-j-k loopで書いた行列積のプログラムとも比較してみました。そのプログラムは以下の通り。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

double **alloc_matrix(int n, int m)
{
	double **a;
	int i;

	a = (double **)malloc(sizeof(double *) * n);
	a[0] = (double *)malloc(sizeof(double) * n * m);
	for (i=1; i<n; i++) {
		a[i] = a[0] + i * m;
	}

	return a;
}

void free_matrix(double **a)
{
	free(a[0]);
	free(a);
}

// a: n x m, b: m x s, c: n x s
void m_m_mul(double **a, double **b, double **c, int n, int m, int s)
{
	int i, j, k;
	int i1, j1, k1;

	for (i=0; i<n; i++) {
		for (j=0; j<s; j++) {
			c[i][j] = 0.0;
		}
	}

	for (i=0; i<n; i++) {
		for (j=0; j<s; j++) {
			for (k=0; k<m; k++) {
				c[i][j] += a[i][k] * b[k][j];
			}
		}
	}
}

int main(int argc, char **argv)
{
	int i, j, n;
	double **a;
	double **b;
	double **c;

	struct timespec ts1, ts2;

	n = atoi(argv[1]);

	a = alloc_matrix(n,n);
	b = alloc_matrix(n,n);
	c = alloc_matrix(n,n);

	for (i=0; i<n; i++) {
		for (j=0; j<n; j++) {
			a[i][j] = rand()/(1.0 + RAND_MAX);
			b[i][j] = rand()/(1.0 + RAND_MAX);
		}
	}

	clock_gettime(CLOCK_REALTIME, &ts1);
	m_m_mul(a, b, c, n, n, n);
	clock_gettime(CLOCK_REALTIME, &ts2);

	printf("%g\n", (ts2.tv_sec - ts1.tv_sec) + (ts2.tv_nsec - ts1.tv_nsec) / 1e9);

	free_matrix(a);
	free_matrix(b);
	free_matrix(c);

	return 0;
}
これらを、
cc -O3 dgemm.c -lblas
cc -O3 forloop.c
のようにコンパイルしました。

環境は、core i7 9700、Ubuntu 20.04で、dockerの中で計算しました。ただ、core i7 9700は8コアですが、どういうわけか8コアで計算させると非常に不安定で、BIOSで1コア殺して7コア生かすという特殊な環境で計算させました。

結果は、次のようになりました。

result.png


横軸は行列サイズ、縦軸は計算時間です。なお、すべて一回しか計測していないため、特に小さいサイズの行列で計算時間が短いあたりは信頼性が低いです。参考程度に見て下さい。

MKL、OpenBLASは、自動的にマルチコアを使って計算してくれました。BLISは、
OMP_NUM_THREADS=8 ./a.out
のように環境変数をつけてあげるとその数のコアを使って計算しますが、何も付けないとシングルコアになりました。ATLAS、Reference BLASはシングルコア計算でした (ATLASはこのマシンでmakeすればマルチコアを使うはずです、念の為)。

マルチコアを使い、また十分チューニングされていると思われる3つは、非常に速いです。特にBLISは知らなかったのですが、十分完成度は高そうです。Reference BLASはこんなもんでしょう。自作i-j-k loopは更に非常に遅いですが、例えばi-j-kをi-k-jにするだけで結構違うはずです。

このグラフを見ると、MKLの小サイズ行列が妙に遅いのが気になります。巨大行列でガンガン計算する分には気にならないでしょうが、用途によってはこれでは困りそう。何か使い方を間違っているのかな?

2020/04/21(火)Ubuntu 20.04 インストール (リンク集)

OK キャンセル 確認 その他