t-hom’s diary

主にVBAネタを扱っているブログ…とも言えなくなってきたこの頃。

VBA プロシージャ呼び出しのオーバーヘッドについて

今回はVBAでプロシージャ呼び出しにかかるオーバーヘッド時間を計測してみた。

プロシージャ呼び出しのオーバーヘッド改善はコンパイラの領分

一般的に、プログラミングではプロシージャの呼び出しにはオーバーヘッドが発生すると信じている方もいるようだけれど、言語によっては必ずしもそうでもない。
(VBAは残念ながらオーバーヘッドが発生するのだけれど。)

コンパイラの実装次第では、コンパイル時の最適化で関数(VBAでいうプロシージャ)の呼び出しをインライン展開するものがある。

どういうことかというと、
たとえばこのようなコードがあったとする。

Sub Main()
    For i = 1 To 100
        Call hoge()
    Next
End Sub

Sub hoge()
    Debug.Print "hoge"
End Sub

これをコンパイル時に以下のように自動的にインライン処理に置き換えてプロシージャ呼び出しを消してしまうのだ。

Sub Main()
    For i = 1 To 100
        Debug.Print "hoge"
    Next
End Sub

上記はあくまでイメージであって、コード自体は変更されるわけではない。コンパイルされた実行ファイルが、インラインで書かれたものと同等になるということだ。つまり、処理速度は同等になり、プロシージャ呼び出しのオーバーヘッドはゼロである。
(VBAは残念ながら、そうはならないのだけれど。)

ここで言いたいのは、プロシージャ呼び出しによってオーバーヘッドが発生するのはそういう実装になっているVBAコンパイラの問題であって、これを回避するためだけにインライン処理を選択するのは一般的なプログラミングテクニックとしては推奨できないということだ。

こちらのページの10条目に、まさに私が言いたいことが書かれてたので引用する。
プログラミング中級者に読んでほしい良いコードを書くための20箇条 | anopara

そもそも「メソッドをいっぱい呼んだらパフォーマンスが悪くなる」とすれば、それはコーディングで解決すべき問題ではなくコンパイラが解決すべき問題である。自分のやるべきでないことに手を出す必要はない。

時代が進めばコンピューターは高速になり、コンパイラはより賢くなる。先の時代を見据えるなら、コードのわかりやすさを犠牲にしてまでコンパイラの領分に手を出し、微々たる高速化を得るというのは割に合わないと思う。

実際のプロシージャ呼び出しのオーバーヘッドはどれくらいなのか

とはいえ、データ量やアルゴリズムによっては何万回とプロシージャ呼び出しを行うケースが出てくるかもしれないし、いくらオーバーヘッドが小さいとはいえ無視できないと考える方もいるだろう。

ここではプロシージャ呼び出しにかかるオーバーヘッドがいかに小さいものかを示し、コードの可読性を犠牲にしてまでインライン処理を選択する価値があるのかどうかを再考する材料として頂くべく、実際に計測してみた。
(もちろん、インラインのほうが可読性が高い場合は別の話。)

さて、計測用のコードはこちら。
※数分かかるので注意。途中で止めるにはCtrl+Pauseキー

Dim x As Double

Sub Main()
    Dim t As Double, inlineTime As Double, procTime As Double, L As Long, i As Long
    '通常私は変数を利用の直前で宣言するが、今回は厳密にタイムを計るため、
    'あらかじめ宣言しておいた。
    For L = 1 To 9
        'インライン処理
        x = 0
        t = Timer
        For i = 1 To 10 ^ L
            x = x + Rnd
        Next
        inlineTime = Timer - t
        
        '関数呼び出し
        x = 0
        t = Timer
        For i = 1 To 10 ^ L
            Call proc
        Next
        procTime = Timer - t
        Debug.Print procTime - inlineTime
    Next
End Sub

Sub proc()
    x = x + Rnd
End Sub

インライン処理と関数呼び出しでそれぞれタイムを計測し、その差分をイミディエイトウインドウに出力するプログラムだ。
ループ回数は10のL乗で表され、それぞれ10の1乗(十)から10の9乗(十億)まで増えていく。

実行の結果はこのようになった。

十回 … 0 秒 (小さすぎて計測不能)
百回 … 0 秒 (小さすぎて計測不能)
千回 … 0 秒 (小さすぎて計測不能)
一万回 … 0.001953125 秒
十万回 … 0.0078125 秒
百万回 … 0.083984375 秒
一千万回 … 0.810546875 秒
一億回 … 8.193359375 秒
十億回 … 81.8203125 秒

十回から千回では呼び出しにかかるオーバーヘッドは0秒と出た。真の0秒ではないが、小さすぎてDouble型では表せない。
一万回まではまだ小さすぎて相当の誤差が出ているが、十万回から十億回にかけては、おおよそ10倍ずつになっている。ループ回数、つまりプロシージャ呼び出し回数が10倍になれば、かかるオーバーヘッドも当然10倍になる。

1億回のプロシージャ呼び出しで約8秒のオーバーヘッドということは、1回のプロシージャ呼び出しにかかるオーバーヘッドは1億分の8秒である。そしてこれを実行したのは8年前に発売のノートPC。CPUはIntel Core 2 Duo P8600 2.4GHz×2コアだ。最新機ならさらにオーバーヘッドは小さくなっているはずだ。

それでも無視できないほど呼び出し回数が多いのであれば、ロジックに問題があるか、もはやそれはVBAで扱うべき領域を超えているような気がする。

プロシージャは積極的に分割して良い

ひとつのプロシージャに書いたほうが速いというのは、嘘ではないけれど、語弊がある。
プロシージャ呼び出しにかかるオーバーヘッドは極めて小さく、ほとんどのケースでは無視してよい。

プロシージャを分割したほうがコードがわかりやすくなると思ったら、迷わずそうするべきだ。

プロシージャを分けるのはコードの共通部分をまとめるためだと信じられているが、コードをまとめるのはプロシージャを分割するメリットのひとつにすぎない。他にも、ローカル変数のスコープが狭まり変更に強くなる、プロシージャに適切な名前を付けることで処理がわかりやすくなるなどのメリットがある。

また、ある程度まとまった処理をプロシージャにすべきだとの思い込みもあるかもしれない。
そうではなく、単純な処理でもコードの可読性が上がるならプロシージャ化して良い。

たとえば次のコードで説明する。これがプロシージャ分割前。

Sub バブルソート()
    Dim arr(1 To 10)
    Dim i
    Debug.Print "ソート前"
    For i = LBound(arr) To UBound(arr)
        arr(i) = WorksheetFunction.RandBetween(1, 100)
        Debug.Print arr(i)
    Next
    
    Debug.Print "ソート後"
    Dim j, k
    For j = LBound(arr) To UBound(arr) - 1
        For k = j + 1 To UBound(arr)
            If arr(j) > arr(k) Then
                tmp = arr(j)
                arr(j) = arr(k)
                arr(k) = tmp
            End If
        Next
    Next

    Dim l
    For l = LBound(arr) To UBound(arr)
        Debug.Print arr(l)
    Next
End Sub

これがプロシージャ分割後

Sub メイン()
    Dim arr(1 To 10)
    Call ランダム値セット(arr)
    
    Debug.Print "ソート前"
    Call 表示(arr)
    
    Call バブルソート(arr)
    
    Debug.Print "ソート後"
    Call 表示(arr)
End Sub

Sub バブルソート(arr)
    Dim i, j
    For i = LBound(arr) To UBound(arr) - 1
        For j = i + 1 To UBound(arr)
            If arr(i) > arr(j) Then
                Call 交換(arr(i), arr(j))
            End If
        Next
    Next
End Sub

Sub ランダム値セット(arr)
    Dim i
    For i = LBound(arr) To UBound(arr)
        arr(i) = WorksheetFunction.RandBetween(1, 100)
    Next
End Sub

Sub 表示(arr)
    Dim i
    For i = LBound(arr) To UBound(arr)
        Debug.Print arr(i)
    Next
End Sub

Sub 交換(a, b)
    Dim tmp
    tmp = a
    a = b
    b = tmp
End Sub

コードのトータル行数は長くなっているが、ひとつのプロシージャでひとつの処理を行っているのでメイン処理はスッキリしてわかりやすく、プロシージャ単位でみたときに処理内容が理解しやすくなっている。

また、ローカル変数のスコープはプロシージャ内部に限られるので他に影響を与えることなくメンテナンスできる。

まとめ

何がわかりやすいコードかというのは人の主観も入るのでここでは問題にしていない。
この記事で言いたかったのは、単にプロシージャ呼び出しのほうが遅いと盲信するのは良くないということ。
わかりやすさを犠牲にするためにプロシージャ分割を避けても、得られる速度は微々たるものだ。

インラインのほうがわかりやすいと思えばインラインで書けばいいし、プロシージャ分割したほうがわかりやすいと思えばプロシージャ分割すればよい。一般的には1プロシージャは1画面に収まる程度にしておくと見通しが良いのでメンテナンスが楽であるが、これもケースバイケースなので一概には言えない。

また、速度に関しても一概に無視できるとは言えない。確かに塵も積もれば山となる。
だからオーバーヘッドによる遅さを疑うのであればTimer関数などで実測してみて、やはり実務上無視できないと確認したうえでインラインで書き直せば良い。

問題は、オーバーヘッドがどの程度なのか調べずに遅いと決めつけてしまうことだ。
どの程度遅いのか、それは実務上影響が出るのか、本当にオーバーヘッドによるものなのか、それを調べたうえで、コードの可読性と天秤にかけて決めるとよいと思う。

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。