Cython(6) コンパイルでできたCのコード


2022年 07月 26日

今回は、Cythonの短い関数がどのようなCのコードに変換されるのかチェックしてみる。
前回、円周率の計算で、実行時間がCythonでも、Cで直接書いても、誤差が測定誤差程度しか差がでなかったのだが、そのあたりも調べてみたい。

次の短いCythonコード(pi_leibniz.pyx)をコンパイルすると、Cのコード(pi_leibniz.c)に変換してくれる。

## [Cython] 円周率 マーダヴァ・ライプニッツの公式
def pi_leibniz(int n):
	cdef double sum = 0.0
	cdef int   k
    
	for k in range(1,n,4):
    	sum += 4/k - 4/(k+2)
	return sum

10行にも満たない短いCythonのコードが、5258行の長大なCのコードになり、あまりのも長いので、関数pi_leibniz(int n)に直接関係する部分だけを示す。
元のCythonのコードがコメントで残っているので、5258行あっても該当箇所を探すのは簡単だろう。行番号は、元のCの行番号である。

  1886  static PyObject *__pyx_pf_10pi_leibniz_pi_leibniz(CYTHON_UNUSED PyObject *__pyx_self, int __pyx_v_n) {
  1887	double __pyx_v_sum;
  1888	int __pyx_v_k;
  1889	PyObject *__pyx_r = NULL;
  1890	__Pyx_RefNannyDeclarations
  1891	int __pyx_t_1;
  1892	int __pyx_t_2;
  1893	int __pyx_t_3;
  1894	long __pyx_t_4;
  1895	PyObject *__pyx_t_5 = NULL;
  1896	int __pyx_lineno = 0;
  1897	const char *__pyx_filename = NULL;
  1898	int __pyx_clineno = 0;
  1899	__Pyx_RefNannySetupContext("pi_leibniz", 0);
  1900  
  1901	/* "pi_leibniz.pyx":3
  1902   * ## [Cython]
  1903   * def pi_leibniz(int n):
  1904   * 	cdef double sum = 0.0         	# <<<<<<<<<<<<<<
  1905   * 	cdef int   k
  1906   *
  1907   */
  1908	__pyx_v_sum = 0.0;
  1909  
  1910	/* "pi_leibniz.pyx":6
  1911   * 	cdef int   k
  1912   *
  1913   * 	for k in range(1,n,4):         	# <<<<<<<<<<<<<<
  1914   *     	sum += 4/k - 4/(k+2)
  1915   * 	return sum
  1916   */
  1917	__pyx_t_1 = __pyx_v_n;
  1918	__pyx_t_2 = __pyx_t_1;
  1919	for (__pyx_t_3 = 1; __pyx_t_3 < __pyx_t_2; __pyx_t_3+=4) {
  1920  	__pyx_v_k = __pyx_t_3;
  1921  
  1922  	/* "pi_leibniz.pyx":7
  1923   *
  1924   * 	for k in range(1,n,4):
  1925   *     	sum += 4/k - 4/(k+2)         	# <<<<<<<<<<<<<<
  1926   * 	return sum
  1927   *
  1928   */
  1929  	if (unlikely(__pyx_v_k == 0)) {
  1930    	PyErr_SetString(PyExc_ZeroDivisionError, "float division");
  1931    	__PYX_ERR(0, 7, __pyx_L1_error)
  1932  	}
  1933  	__pyx_t_4 = (__pyx_v_k + 2);
  1934  	if (unlikely(__pyx_t_4 == 0)) {
  1935    	PyErr_SetString(PyExc_ZeroDivisionError, "float division");
  1936    	__PYX_ERR(0, 7, __pyx_L1_error)
  1937  	}
  1938  	__pyx_v_sum = (__pyx_v_sum + ((4.0 / ((double)__pyx_v_k)) - (4.0 / ((double)__pyx_t_4))));
  1939	}
  1940  
  1941	/* "pi_leibniz.pyx":8
  1942   * 	for k in range(1,n,4):
  1943   *     	sum += 4/k - 4/(k+2)
  1944   * 	return sum         	# <<<<<<<<<<<<<<
  1945   *
  1946   */
  1947	__Pyx_XDECREF(__pyx_r);
  1948	__pyx_t_5 = PyFloat_FromDouble(__pyx_v_sum); if (unlikely(!__pyx_t_5)) __PYX_ERR(0, 8, __pyx_L1_error)
  1949	__Pyx_GOTREF(__pyx_t_5);
  1950	__pyx_r = __pyx_t_5;
  1951	__pyx_t_5 = 0;
  1952	goto __pyx_L0;
  1953  
  1954	/* "pi_leibniz.pyx":2
  1955   * ## [Cython]
  1956   * def pi_leibniz(int n):         	# <<<<<<<<<<<<<<
  1957   * 	cdef double sum = 0.0
  1958   * 	cdef int   k
  1959   */
  1960  
  1961	/* function exit code */
  1962	__pyx_L1_error:;
  1963	__Pyx_XDECREF(__pyx_t_5);
  1964	__Pyx_AddTraceback("pi_leibniz.pi_leibniz", __pyx_clineno, __pyx_lineno, __pyx_filename);
  1965	__pyx_r = NULL;
  1966	__pyx_L0:;
  1967	__Pyx_XGIVEREF(__pyx_r);
  1968	__Pyx_RefNannyFinishContext();
  1969	return __pyx_r;
  1970  }

このうち、特に重要なforループの部分だけを取り出してみた。除算があるので、分母が0でない保証をするための判定が含まれているが、見やすくするために取り除くと次のようになり、かなり分かりやすくなったかと思う。

  1908	__pyx_v_sum = 0.0;
  1909  
  1917	__pyx_t_1 = __pyx_v_n;
  1918	__pyx_t_2 = __pyx_t_1;
  1919	for (__pyx_t_3 = 1; __pyx_t_3 < __pyx_t_2; __pyx_t_3+=4) {
  1920  	__pyx_v_k = __pyx_t_3;
  1921  
  1938  	__pyx_v_sum = (__pyx_v_sum + ((4.0 / ((double)__pyx_v_k)) - (4.0 / ((double)__pyx_t_4))));
  1939	}

さらに分かりやすくするために、次の変更を加えた。
変数の先頭に共通して __pyx_があり、見難いので削ってみた。

  1908	__pyx_v_sum = 0.0;
  1909  
  1917	t_1 = v_n;
  1918	t_2 = t_1;
  1919	for (t_3 = 1; t_3 < t_2; t_3+=4) {
  1920  	v_k = t_3;
  1933  	t_4 = (v_k + 2); 
  1938  	v_sum = (v_sum + ((4.0 / ((double)v_k)) - (4.0 / ((double)t_4))));
  1939	}

こちらが、Cで直接関数を書いたコード。

double    pi_leibniz(int n) {
	double sum = 0.0;
	for( int k=1; k<n; k+=4 )
   	 sum += 4.0/k - 4.0/(k+2);
	return sum;
}

Cython→Cのコードでは、forの制御変数にt_1, t_2, t_3が利用されていて、そのうちでカウンタ変数はt_3である。

C版のforのカウンタ変数kは、級数計算内で直接使われているが、Cython→Cではforのカウンタ変数t_3がv_kに代入されてから計算に使われる。
また、C版の計算式の中で分母に(k+2)とあるが、この部分はt_4 = (v_k + 2); により2を足した変数を用意し、計算に使われるという面倒なことをしている。

Cython→Cのコードは、C版のコードに比較して冗長であり、時間がかかると思われるが、実行時間計測では差が出ていない。

これは、次のような原因ではないかと思われる。

Cython→Cのコードも、Cで直接書いたコードも、その後コンパイルしてから利用されている。コンパイル時には最適化が行われるので、Cのコードがそのままオブジェクトになるわけではなく、コンパイラが無駄なコードと判断すれば削除されたり、無駄な代入などあれば、無駄を少なく出来るように変更されることがある。Cython→Cのコードの無駄な部分はコンパイラに検出され、C版と同様のコードになり、その結果として実行速度がほぼ一致したのではないかと思われる。

Python(Cython)のforループ

CythonのforがCに変換されたとき、わざわざ面倒なことをしているように感じたであろう。
なぜ、こんな面倒なことをしているのであろうか。
それで、次のようなPythonのプログラムで、forの動きを調べてみた。

# for ループのカウンタ変数(制御変数)をいじってみる

for i in range(5):
	print("変更前:{}".format(i), end="")
	i += 10
	print("\t変更後:{}".format(i))

forの中で、カウンタ変数i の値を勝手に変更してみた。ここでは10加えているので、ループは1回で終わってしまいそうだが、実行すると次のようになった。

$ ipython3 for_loop.py
変更前:0    変更後:10
変更前:1    変更後:11
変更前:2    変更後:12
変更前:3    変更後:13
変更前:4    変更後:14

カウンタ変数をどう変更しようと、次のループが始まるときに、カウンタ変数には元々用意されていた次の値が入るようになっているようだ。つまり、ちゃんとしたイテレータとして動いている。

C言語の場合は、カウンタ変数は自由に変更できるし、さらには上限値も途中変更可能である。何でも好き勝手できる無法状態と言ってもも良い。だから、器用で高速なこともできるのだが、勝手なことは自己責任で、暴走に気をつけないといけない。