このBlogは移転しました。今後は aish.dev を御覧ください。

Python/CFFIでCUDAしてみる

ふと思い立って、CUDAでCPUからGPUにデータを転送する時の速度を測ってみた。

普通にCUDA SDKのサンプルで測定しても良いが、PythonCFFI で実行してみよう。

コードはこんな感じで書ける。

import time
import sys
from cffi import FFI
ffi=FFI()

ffi.cdef(r"""
typedef unsigned int cudaError_t;
cudaError_t cudaGetLastError();
char* cudaGetErrorString(cudaError_t error);

cudaError_t cudaDeviceSynchronize();
cudaError_t cudaMalloc(void **p, size_t s);
cudaError_t cudaFree(void *p);
cudaError_t cudaMallocHost(void **p, size_t s);
cudaError_t cudaFreeHost(void *p);
cudaError_t cudaMemcpy(void *dst, const void *src, size_t count, unsigned int kind);

""")

cudaMemcpyHostToHost          =   0
cudaMemcpyHostToDevice        =   1
cudaMemcpyDeviceToHost        =   2
cudaMemcpyDeviceToDevice      =   3
cudaMemcpyDefault             =   4

cuda = ffi.dlopen("/usr/local/cuda/lib64/libcudart.so")

def check():
    err = cuda.cudaGetLastError()
    if err:
        print(ffi.string(cuda.cudaGetErrorString(ret)))
    assert err == 0

def run(host, dev, n, size):
    for i in range(n):
        ret = cuda.cudaMemcpy(dev[0], host[0], size, cudaMemcpyHostToDevice)
        check()

size = int(sys.argv[1])

pinned = ffi.new("void **")
cuda.cudaMallocHost(pinned, size)
check()

ffi.memmove(pinned[0], b'a'*size, size)

dev = ffi.new("void **")
cuda.cudaMalloc(dev, size)
check()

run(pinned, dev, 1, 1) # warm up

buf = ffi.new("char []", b'a'*size)
host = ffi.new("char **", buf)

N = 1000
for s in range(1, size, size//20):
    f = time.time()
    run(pinned, dev, N, s)
    p = time.time()-f

    f = time.time()
    run(host, dev, N, s)
    n= time.time()-f

    print(f'{s}, {p:0.5f}, {n:0.5f}')

AWSのp2.xlargeとp3.2xlargeで実行してみた。

p2.xlarge
Tesla K80
E5-2686 v4 @ 2.30GHz
p3.2xlarge   
Tesla V100-SXM2-16GB
E5-2686 v4 @ 2.30GHz

f:id:atsuoishimoto:20180130100853p:plain

ざっくり、Pinnedメモリのほうが15%くらい速い。また、転送速度に関してはp2.xlargeでもp3.2xlargeでも大差はないようだ。

ちなみに、手元にあったi7-6700 CPU @ 3.40GHz/GeForce GTX 1070 と E5-1650 v4 @ 3.60GHz/GeForce GTX 1080 Ti のデスクトップは、EC2のインスタンスより2割程度高速だった。

また、~100KBぐらいまでの転送量を見てみると、50KBぐらいまではPinnedメモリより普通のメモリのほうが速いようだ。

f:id:atsuoishimoto:20180130100921p:plain

これは、データの転送よりも、転送の前処理・後処理に時間がかかってしまっているのかもしれない。ストリームを使って並列化するなどすればまた違ってくるかもしれないので、あとで実験してみたい。

クラスブロックのひみつ

さて、 リスト内包のひみつ - atsuoishimoto's diary で、Python3では、リスト内包式は関数呼び出しとなることを説明した。

>>> a = [i*2 for i in range(3)]

というスクリプトは、次のように展開される。

>>> def _listcomp(_it):
...     ret = []
...     for i in it:
...         ret.append(i*2)
...     return ret
...
>>> _it = range(3)
>>> a = _listcomp(it)

通常、この点はあまり気にする必要はないが、問題となるケースもなくはない。

クラスブロックのリスト内包

クラスブロックで次の処理を実行してみよう。

class Foo:
    NUMS = [i*2 for i in range(3)]

まあ、これは当然動作する。Foo.NUMS の値は、[0,2,4] となる。

では、これをちょっと直してみよう。

class Foo:
    N = 3
    NUMS = [i*2 for i in range(N)]

これも問題ない。Foo.NUMS の値は、同じく [0,2,4] となる。

もうちょっと変えてみよう。

class Foo:
    N = 3
    F = 2
    NUMS = [i*F for i in range(N)]

一見、これも問題なさそうだが、実行するとエラーとなってしまう。

>>> class Foo:
...     N = 3
...     F = 2
...     NUMS = [i*F for i in range(N)]
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in Foo
  File "<stdin>", line 4, in <listcomp>
NameError: name 'F' is not defined

なんで N は良くて、 F はダメなのだろう。

Pythonの名前解決ルール

Pythonspam*2 のような式の値を計算する時、Pythonは変数 spam の値を、次の順序で検索する。

  1. 式のスコープ。関数内で定義された入れ子の関数で実行していれば、親関数のスコープも検索する。

  2. 式のモジュールのグローバル変数

  3. 組み込み関数のモジュール __builtins__

例えば、

def foo():
    return 2 * spam

という関数を実行すると、Pythonは変数 spam

  1. 式を実行している foo() のローカル変数。
  2. foo() が所属するモジュールのグローバル変数
  3. __builtins__

の順番で検索し、見つからなければ NameError 例外が発生する。

この名前の解決ルールは、Pythonプログラミングにおいて非常に重要なルールだ。それほど難しいルールではないので、確実に頭に叩き込んでおこう。

Pythonのプログラムを読んでいて、どこで定義されているかわからない変数や関数などがあっても、「ローカル変数かグローバル変数__builtins__ のいずれかに必ず存在する」 ということも覚えておくと良いだろう。

クラスブロック

Pythonの名前解決ルールを頭に叩き込んだら、次のコードを見てみよう。

class Foo:
    N = 3
    M = 2*N

当然ながら、Foo.M の値は 2*3=6 となる。2*N という式は N という変数を参照しているが、Pythonは同じブロックで定義されている変数 N を見つけ、値を取得する。

では、このコードをちょっと修正しよう。

class Foo:
    N = 3
    def f():
        return 2*N
    M = f()

このコードを実行すると、次のようなエラーが発生する。

>>> class Foo:
...     N = 3
...     def f():
...         return 2*N
...     M = f()
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in Foo
  File "<stdin>", line 4, in f
NameError: name 'N' is not defined

関数 f() 内で、変数 N が見つからないと言っている。

しかし、

N = 3
def f():
    return 2*N
M = f()

この処理は、どう見ても問題のないコードだ。実際、クラス定義以外で実行すると、正常に動作する。

次の例では、関数 f() の式 2*N は、グローバル変数 N を参照して値を評価する。

>>> N = 3
>>> def f():
...     return 2*N
...
>>> M = f()
>>> M
6

また、次の例では、入れ子の関数 f() は、親関数 bar() のローカル変数 N を参照する。

>>> def bar():
...     N = 3
...     def f():
...         return N*2
...     print(f())
...
>>> bar()
6

では、さきほどの例をもう一度見てみよう。

>>> class Foo:
...     N = 3
...     def f():
...         return 2*N
...     M = f()
...

Foo.f() は変数 N を参照しているが、この N はどこで見つかるだろうか?

関数 Foo.f() のローカル変数ではない。また、Foo.f()入れ子の関数ではないので、親となる関数も存在しない。

Foo.f() のモジュールにも、N は存在しない。

__builtins__ モジュールにも、当然 N は存在しない。

「でも、Foo.N があるじゃないか!」と言うかもしれない。しかし、

  • Foo.N は、Foo.f() のローカル変数ではなく
  • Foo.N は、Foo.f() のモジュールのグローバル変数ではなく
  • Foo.N は、__builtins__ モジュールの値でもない

つまり、Pythonの名前解決ルールのどの項目でも、Foo.f() では、 N という名前を解決することはできないのである。

リスト内包

さて、冒頭のコードをもう一度見てみよう。

class Foo:
    N = 3
    F = 2
    NUMS = [i*F for i in range(N)]

このコードは、次のように実行される。

class Foo:
    N = 3
    F = 2

    def _f(_iter):
        ret = []
        for i in _iter:
            ret.append(i*F)

    _iter = range(N)
    NUMS = _f(_iter)

リスト内包式が関数に変換されるのは、リスト内包式の [] 内のすべてではなく、in 以下のイテレータを指定する式は関数内では実行されない。

したがって、

class Foo:
    N = 3
    F = 2
    NUMS = [i*F for i in range(N)]

の、 range(N) の部分は、そのままクラスブロックで実行されるため NameError とはならない。

しかし、i*F の部分は関数内で実行されるため、クラス変数 F を参照できず、NameError とはなってしまうのである。

これはリスト内包だけではなく、ジェネレータ式や辞書内包、集合内包でも同様にエラーとなる。

対処方法

実のところ、クラスブロックに直接リスト内包を記述する場合は、うまい回避方法は思いつかない。

障害報告も出ているが、どうも治らなそうだ

リスト内包を避けて通常のループを使用するか、または

class Foo:
    N = 3
    F = 2
    def _init(N, F):
        return [i*F for i in range(N)]
    NUMS = _init(N, F)

のように、関数でラップするのが無難だろう。

まとめ

  • クラスブロックの中に定義した関数は、クラスブロックの変数を参照できない。
  • クラスブロック内のリスト内包などでクラス変数を参照すると、エラーとなる場合がある。ならない場合もある。
  • 対処方法: うーん。。。

リスト内包のひみつ

こちらのTweetが Python.jp slack でちょっと話題になっていた。

次のようなコードだ

>>> a = [lambda: print(i) for i in range(3)]
>>> for i in a: i()
2
2
2

結論としては cocoatomo さんの書かれているように、変数の評価タイミングの問題で、

対処としては、次のように、出力する値を、関数の実行時ではなく、関数の作成時に決定する必要がある。

>>> a = [lambda x=i: print(x) for i in range(3)]
>>> for i in a: i()
0
1
2

もともとの問題はこれで解決するのだが、このコード、単純だがPython2とPython3では動作が異なっている。

Pythonの動作を理解するのに良い教材だと思うので、ちょっと解説してみよう。

Python2の場合

Python2では、先程のコードは、次のようなループとして実行される。

>>> from __future__ import print_function
>>> a=[]
>>> for i in range(3):
...     f = lambda : print(i)
...     a.append(f)
...
>>> for i in a: i()
2
2
2

リスト内包式で使われているループ変数 i は、グローバル変数 i として、リスト内包式を終了した後も参照できる。

i の値は、必ず 2 となる。

>>> a = [lambda: print(i) for i in range(3)]
>>> i
2

この場合、lambda 関数の print(i) という式は、グローバル変数i を参照して、出力している。

したがって、 lambda 関数を実行する前に i の値を変更すると、出力も変化する。

>>> a = [lambda: print(i) for i in range(3)]
>>> i = 10000000
>>> for u in a:u()
...
10000000
10000000
10000000

Python3の場合

Python3では、リスト内包は次のように展開され、関数呼び出しとして実行される。

>>> _it = range(3)
>>> def _listcomp(_it):
...     ret = []
...     for i in it:
...         ret.append(lambda : print(i))
...     return ret
...
>>> a = _listcomp(_it)
>>> for i in a: i()
2
2
2

Python2と違って、Python3ではループ変数 i は外部からは参照できない。リスト内包は関数内で実行され、 iグローバル変数ではなくローカル変数となるためだ。

>>> a = [lambda: print(i) for i in range(3)]
>>> i
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'i' is not defined

Python2 では lambda 関数の print(i) は、グローバル変数 i を参照するが、Python3 では、lambda 式はローカル変数 i を参照するクロージャとなる。

クロージャとは、Pythonの関数のなかに別の関数を作成した時、親となる関数のローカル変数を子となる関数が参照する仕組みのことだ。

クロージャの仕組みは、以前

atsuoishimoto.hatenablog.com

に書いた。

したがって、Python2のように、後から i の値を変更することはできない。

>>> a = [lambda: print(i) for i in range(3)]
>>> i = 10000000
>>> for u in a:u()
...
2
2
2

Python2/3の違い

Python2 と Python3 では、リスト内包の実現方法に上記のような違いがある。これは、Python2 では

>>> i = 10000
>>> a = [lambda: print(i) for i in range(3)]
>>> print(i)
3

のように、リスト内包式があると他のローカル変数を上書きしてしまい、わかりにくいという問題を解決するためだ。

この経緯は

python-history-jp.blogspot.jp

でも解説されているので、参照されたい

Python文法詳解(二刷) 正誤表

以下の正誤表は、二刷のものです。一刷の正誤表は Python文法詳解(一刷) 正誤表 - atsuoishimoto's diary を参照してください。

第3章

P.50

>>> 1 + 1j  # 整数 + 複素数
(1+2j) # 結果は複素数となる
>>> 1 + 1j  # 整数 + 複素数
(1+1j) # 結果は複素数となる

第4章

P.113

文字列が空でなく、全て数を表す文字なら

文字列が空でなく、全ての文字が数を表す文字なら

P.118

>>> ' スパムハム '.find(' ハム ', 4)
-1
>>> ' スパムハム '.rfind(' ハム ', 4)
-1

検索失敗がエラーとなるよ うなケースでは index() を使い

検索失敗がエラーとなるよ うなケースでは rindex() を使い

第6章

P.201

スクリプトファイルやディレクトリを格納した zip ファイル

スクリプトファイルやディレクトリを格納した Zip ファイル

P.206

$ zip -r spam.zip __main__.py
$ zip  spam.zip __main__.py

P.210

図中

Func_spam関数

func_spam関数

第7章

P.240

x | ファイルを新しくファイルを作成し......

x+ | ファイルを新しくファイルを作成し......

x | 新しくファイルを作成し......

x+ | 新しくファイルを作成し......

P.261

イテレータオブジェクトから値を取得すると

このように、ジェネレータが作成したイテレータオブジェクトから値を取得すると

P.262

>>> spam.send('One')    # 次の yield 式まで実行
One
2
>>> spam.send('Two')   # 最後まで実行
Two
>>> gen.send('One')    # 次の yield 式まで実行
One
2
>>> gen.send('Two')   # 最後まで実行
Two

P.267

インスタンスから属性の値を取得・設定するとき、クラスの属性としてディスクリプタという種 類のオブジェクトを登録すると、

クラスの属性としてディスクリプタという種 類のオブジェクトを登録すると、インスタンスから属性の値を取得・設定するとき、

索引

P.309

(追加)

dict.values() -------------------- 159

Pythonのfor文は遅い?

bicycle1885.hatenablog.com

こちらの記事を拝見していて、ちょっと気になったので注釈。

PythonやRを使っている人で、ある程度重い計算をする人達には半ば常識になっていることとして、いわゆる「for文を使ってはいけない。ベクトル化*1しろ。」という助言があります。 これは、PythonやRのようなインタープリター方式の処理系をもつ言語では、極めてfor文が遅いため、C言語Fortranで実装されたベクトル化計算を使うほうが速いという意味です。

昔からよくこういう言い方がよくされるが、本当にPythonのfor文は遅いのだろうか。

聞くところによるとRのfor文はガチで遅いそうだが、Pythonの計算が遅いのはインタープリタ方式だからでも、for文が遅いからでもない。もちろん、Pythonインタープリタなので遅いし、for文だって極めて遅い。しかし、これはPythonの計算が遅い要因の一部でしかない。

まずは手元の環境(Macbook Air 2015, Python 3.6)で速度を測ってみよう。以下のコードはすべて Jupyter Notebookで実行している。

import numpy as np
a = np.ones(100000)
b=np.ones(100000 )

def dot(a, b):
    s = 0
    for i in range(len(a)):
        s += a[i] * b[i]
    return s

timeit dot(a, b)
41.6 ms ± 1.51 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

timeit np.dot(a, b)
62 µs ± 5.6 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Pythonのループを使った演算と、numpyを使った演算ではパフォーマンスに大きな差がある。これは for文が遅いから なのだろうか?

試しに、演算をせずにforループだけを実行してみよう。

def loop(a, b):
    s = 0
    for i in range(len(a)):
        pass
    return s

timeit loop(a, b)
3.44 ms ± 129 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

演算を行わず、forループを実行するだけなら、全体の10%以下しかかかっていない。まあ、forループ遅いが、全体の遅さの主犯ではないようだ。

では、Pythonの遅さの残り9割はどこからくるのだろう?

ここからは、Cythonを使って原因を探っていこう。

まず、Cythonで dot()C言語に変換する。

%%cython
def dot_cython(a,b):
    s = 0
    for i in range(len(a)):
        s += a[i] * b[i]
    return s

timeit dot_cython(a,b)
21.9 ms ± 717 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

単純にC言語に変換しただけでは、それほど変化はない。

ここでは、dot_cython()for ループは、CythonによってC言語のループに展開されており、Pythonのようなループによるオーバーヘッドはなくなっている。

また、Pythonバイトコードを経由せずに実行しているため、ループ以外のPythonインタープリタのオーバヘッドもなくなっている。処理時間が 41.6 ms -> 21.9 ms と約半分になっているが、これはほぼインタープリタのオーバヘッドが解消したためだ。

ここでわかるのは、単純にPythonと同じ処理をC言語で書き直すだけでは、numpyの62µsという圧倒的な速度には遠くおよばない、ということだ。 Pythonインタープリタである、というのも、Pythonの遅さの原因の一部でしかないのである。

インタープリタ言語というと、何もかもがコンパイル型言語より数百倍遅くなるようなイメージがあるかもしれないが、それほど極端な差はつかないものだ。

Pythonの遅さの別の原因として、Pythonが静的な型定義を持たない、という点がある。

例えば、dot() では s += a[i] * b[i]という式を実行しているが、この中の X*Y のような乗算処理では、次のような処理が行われる。

  1. X が乗算をサポートしているかチェックする
  2. X の乗算関数を取得する
  3. Y が被乗数として適切なデータかチェックする
  4. X の値と Y の値を取り出し、乗算する
  5. 乗算の結果から新しい浮動小数点数オブジェクトを作成する

しかし、C や Javaのような、静的な型定義をもつプログラミング言語では、そもそも乗算を行えないような処理はコンパイルエラーとなるため、 上記の1. 〜 3. の処理の必要がなく、さまざまな最適化を行って処理を高速化できる。

Cythonでは、明示的にC言語のデータ型を指定して値を変換できる。まず、数値演算処理の部分にデータ型を宣言し、高速化してみよう。

%%cython
def dot_typed(a,b):
    cdef double s = 0.0
    for i from 0 <= i < len(a):
        s += <double>(a[i]) * <double>(b[i])
    return s

timeit dot_typed(a,b)
11.8 ms ± 189 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

この宣言により、次のような高速化が行われる。

  1. 上記の1. 〜 3. の処理は必要ない。
  2. 上記の 4. の演算処理は、Pythonの加算処理と乗算処理ではなく、CPUの機能で高速に演算されるようになる。
  3. 上記の 5. の浮動小数点数オブジェクトを生成せず、ハードウェアがサポートしている浮動小数点数が作成される。

だいぶ速くなったが、やはりまだ Numpy には及ばない。

dot_typed() では、演算中に <double>(a[i]) のようにして、Numpyの配列から要素を取得して、C言語double 型に変換している。実は、これもかなり複雑な処理なのだ。

  1. a が添字によるインデックスをサポートしているかチェックする
  2. a のインデックス関数を取得する
  3. 添字 i が添字として適切かチェックする
  4. i の整数値を取得する
  5. a から i 番目の値を取得する
  6. 取得した値で、numpy.float64 オブジェクトを作成する
  7. numpy.float64 オブジェクトを double 型に変換可能かチェックする
  8. numpy.float64 オブジェクトの変換関数を取得する
  9. double 型に変換する

この処理をスキップして、Numpy配列からデータを直接取得してみよう。

実は、Numpy配列には double 型のデータが格納されており、適切なデータ型を指定して直接参照してしまえば、変換は一切必要なくなってしまう。Numpy内部では、このような形式で要素を参照して効率的に処理を行えるようになっている。

Cythonには、Numpyなどのバッファを直接参照する、Typed Memoryview 型が備わっている。この機能で、単なる double 型データの入った配列としてアクセスできるようにしてみよう。

%%cython
def dot_view(double[:] a, double[:] b):
    cdef double s = 0.0
    for i from 0 <= i < len(a):
        s += a[i] * b[i]
    return s

timeit dot_view(a,b)
152 µs ± 6.32 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

おお。速くなった。データをPythonプロトコルを使って取り出す部分が最後のボトルネックだったわけで、この部分を最適化することで、Numpyの4割ぐらいのパフォーマンスまで迫ることができた。

のこりの速度差は、おそらくNumpy内部の内積処理の、高度な最適化によるものだと思う。もう一段階せまってみようかと思ったが、面倒なのでやめた。

つまり

  • Pythonを使った処理は遅くなるが、インタープリタだから、というのは実はそれほど大きな要因ではない。

  • Pythonの演算が遅い最大の要因は、Pythonが静的な型宣言を行わない言語で、型推論JITもなく、常に動的にオブジェクトの演算を行う、という点にある場合がほとんどだ。

  • Numpyでは、配列をすべて同じデータ型しか格納できない、 Homogeneous なコンテナとすることで、効率的に計算を行えるようにしている。

  • Numpy 「は?型推論?ぜんぶfloatにすればよくね?」

ついでに

Pythonが遅い原因として、GIL(Global Interpreter Lock) によってマルチコアをうまく使えないから、と言われることもある。

これもまあ、なくはない。

しかし、仮にGILがなくとも、Pythonの演算はせいぜいCPU数分しか速くならない。CPUが16個あってもたかだか16倍になるにすぎない。これではとてもNumpyには対抗できないのである。

おまけに

この根本的な原因は、今のJulia(v0.3.7)には破壊的な演算子が無いため、いつでも新しい配列を確保してしまう点にあります。

Pythonには、破壊的な代入演算子( += など) がある。

石本敦夫氏に聞く、Pythonの歴史とこれから〜Pythonエンジニア列伝 Vol.3 - Python学習チャンネル by PyQ でも話したが、Pythonにこの種の代入演算子が導入されたのは、実はNumpyで使用するためだった。

Python1.5までは、+= の導入には否定的な意見が多かった。これは、

X = [1,2,3]
X += [4,5]

のように、リストなどの更新可能なオブジェクトなら、リストオブジェクト X に新しく要素を追加すればよい。

しかし、同じようなスクリプトでも、

X = (1,2,3)
X += (4,5)

では、Xは更新不可能なタプルオブジェクトなので、要素を追加できない。この場合は、X に要素が追加されるのではなく、新しく (1,2,3,4,5) というタプルオブジェクトが、X に代入されることになる

このような判りにくさから、+= は導入されないという判断がくだされていた。

しかし、Numpyで大きな配列を効率的に演算するため、ということで必要性を認められ、導入されたのである。

GitLab PagesのDeployが失敗する場合の対処方法

GitLab Pages にドキュメント生成するたびに同じ落とし穴にハマるので、ここに記しておく。

GitLab Pagesにドキュメントを生成すると、ビルドが成功しても、その後のDeployが失敗する場合がある。

この問題は次のチケットでGitLabに報告済みで、もうすでにCloseされているが、まだ発生するようだ。

gitlab.com

回避方法は以下の通り。

  • artifacts: に指定するドキュメントの path: は、かならず public という名前のディレクトリとする。
  • public ディレクトリは、かならず新規に作成する。

Sphinxをつかってpdfやドキュメントを生成するときは、次のように public ディレクトリを新規作成し、生成したファイルを public ディレクトリに移動するようにする。

.gitlab-ci.yml の例を以下に示す。

# Sphinxによるドキュメント生成の例

image: atsuoishimoto/ubuntu-latex

pages:
  script:
    - make latexpdf
    - make html
    - mkdir public
    - mv _build/latex/*.pdf public/
    - mv _build/html/* public/
  artifacts:
    paths:
    - public
  only:
    - master

ダイクストラおばちゃん

最近、偶然プログラミング初心者に接する機会が続いた。初心者にもいろいろあるが、中でも印象深い女性のことを思い出したので書いておきたい。

大昔、ちょっとした業務改善のシステムを開発することになって、実際にその業務を行っている事務や経理の方々に話を伺ったことがある。

この時お会いした年配の女性が、すさまじいほどのExcelのエキスパートだった。当時のPC環境はまだまだ原始的で、動作も不安定だったが、彼女は独学でExcelマクロを開発し、かなりの業務の自動化に成功していた。

話を聞いてみると、とくにプログラミングの勉強をしたことはなく、本を数冊読んだ程度で、あとはExcelのヘルプだけを頼りにマクロを組み上げたらしい。まだインターネットもさほど普及していない時代だ。ほぼ自分の頭だけで考えて、ここまでたどり着いたのだろう。素晴らしい出来栄えだった。

一番驚いたのは、彼女が重要なソフトウェア工学の原則を独力で再発見していたことだ。「処理は機能ごとの関数に分割するとわかりやすいんですよ」とか、「意味のある名前を付けてあげるのが一番大事なんです」とか、自分の発見を体系化し、明確なルールとして運用していたのだ。私は戦慄した。なんてことだ、このおばちゃんダイクストラやんけ!

この時点で、彼女がExcelを使い始めてからまだ数年。どこまで成長するのだろうか。私は背筋に冷たいものを感じながら彼女に礼を言い、インタビューを終了したのだった。