今回は、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版と同様のコードになり、その結果として実行速度がほぼ一致したのではないかと思われる。
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言語の場合は、カウンタ変数は自由に変更できるし、さらには上限値も途中変更可能である。何でも好き勝手できる無法状態と言ってもも良い。だから、器用で高速なこともできるのだが、勝手なことは自己責任で、暴走に気をつけないといけない。