AppleScript で高速データ処理

Table of Contents

Introduction

AppleScript の高速化の要点として、他のアプリケーションとの通信を避ける。AppleScript のそもそもの目的が他のアプリケーションとの通信ですが、多くの場合、実行速度のボトルネックになります。不用意な通信は避け、AppleScript 内で閉じることができれば、それに越したことはないです。

そうすると、次は AppleScript 内でのデータ処理を高速化しよう、ということになります。何も考えないとAppleScript の大きなデータの処理がとにかく遅いです。それを高速化しようという話をします。

例えば、次のようなスクリプトを考えてみましょう。大きなリストデータ(要素の数が 10000)にアクセスするスクリプトです。

なお、実行時間計測のためにライブラリ LapTime.scptd を使っています。予めインストールして実行してください。

use LapTime : script "LapTime"

set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat

set tm to LapTime's start_timer()

-- Case 1 : 2718.9 ms
repeat with i from 1 to length of a_list
get item i of a_list
end repeat
tm's lap:"Case 1"

-- Case 2 : 2173.6 ms
repeat with an_item in a_list
get contents of an_item
end repeat
tm's lap:"Case 2"

tm's lap_times()
(*[Lap Times]
Case 1 2718.85299682617 [ms]
Case 2 2173.60603809357 [ms]*)

set tm to missing value

二つのやり方で、すべての要素にアクセスする時間を計測してみました。たかが 10000 点ぐらいのデータをループさせるのに僕のマシン(MacBook Pro 2.5GHz)で 2 秒以上かかります。非常に遅いといえるでしょう。

しかし、いくつかのコツを覚えるだけで驚くほど高速化することができます。

リスト処理の高速化

リストの要素へのアクセスは、リストデータそのものにアクセスするのではなく、リストのリファレンスにアクセスすることでかなり高速化できます。

use LapTime : script "LapTime"

set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat

set list_ref to a reference to a_list
set tm to LapTime's start_timer()

(* Case 1: 52.9 ms *)
repeat with i from 1 to length of list_ref
get item i of list_ref
end repeat
tm's lap:"Case 1"

(* Case 2: 48.2 ms *)
repeat with an_item in list_ref
get contents of an_item
end repeat
tm's lap:"Case 2"

tm's lap_times()
(*[Lap Times]
Case 1 52.892923355102 [ms]
Case 2 48.191070556641 [ms]*)

set tm to missing value

このように、数十倍の高速化ができます。ちなみに 数 msec の違いは誤差とお考えください。その程度は実行の度に変わってきます。

リスト処理の高速化2

さて、a reference to 演算子によってリストのリファレンスを作るというテクニックはいつでも使える訳ではありません。というのも、リファレンスは、

にしか作れないという制限があります。すなわち、任意のハンドラ内のローカル変数に対してはリファレンスを作ることができません。sample2 の a_list はトップレベルの run ハンドラ内の変数だから a reference to 演算子を使うことができたのです。この問題を回避して、任意のローカル変数のリストをリファレンスとしてアクセスするトリックが存在します。次のように、ハンドラ内でスクリプトオブジェクトを定義し、その property に一度格納するのです。

use LapTime : script "LapTime"

on main()
set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat

script list_wrapper
property cnts : a_list
end script

set tm to LapTime's start_timer()

-- Case 1: 7.4 ms
repeat with i from 1 to length of a_list
get item i of cnts of list_wrapper
end repeat
tm's lap:"Case 1"

-- Case 2 : 6.8 ms
repeat with an_item in cnts of list_wrapper
get contents of an_item
end repeat
tm's lap:"Case 2"

tm's lap_times()
(*[Lap Times]
Case 1 7.420063018799 [ms]
Case 2 6.798028945923 [ms]*)
end main

main()

おまけとして、さらに数倍、速くなるようです。リストのリファレンスにアクセスするより、スクリプトオブジェクトの property のリストにアクセスするほうが速いようです。sample1 と比べれば、数百倍の差です。なぜ速くなるかは謎ですが(誰か教えてください)、このテクニックを知っていると知らないでは、できるとことできないことに違いが生まれるほどの差であると思います。

テキスト処理への応用

さて、テキストデータの処理で行単位で処理を行うというのは非常によくある状況だと思います。その場合、やり方としては以下の二つが考えられます。

use LapTime : script "LapTime"

on main()
set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat
set text item delimiters to {return}
set a_text to a_list as text


set tm to LapTime's start_timer()

-- Case 1 : 1943.8 ms
repeat with i from 1 to count paragraph of a_text
get paragraph i of a_text
end repeat
tm's lap:"Case 1"

-- Case 2 : 2451.9 ms
set a_list to every paragraph of a_text
repeat with a_paragraph in a_list
get contents of a_paragraph
end repeat

tm's lap:"Case 2"

tm's lap_times()
(*[Lap Times]
Case 1 1943.84300708771 [ms]
Case 2 2451.92408561707 [ms]*)
end main

main()

リストに変換してからループするケースの方が遅くなります。しかし、sample3 のリストデータの高速処理のトリックを使えば、リストに変換してから処理した方が早くなります。

use LapTime : script "LapTime"

on main()
set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat
set AppleScript's text item delimiters to {return}
set a_text to a_list as string

script list_wrapper
property cnts : paragraphs of a_text
end script

set tm to LapTime's start_timer()

repeat with an_item in cnts of list_wrapper
get contents of an_item
end repeat
tm's duration()
(*9.414076805115 [ms]*)
end main

main()

sample4 と比較して約 200 倍も高速です。このように、リスト処理の高速化テクニックを使えば、テキスト処理の高速化も行えます。

リストの高速書き換え

ところで、次のように repeat with ~ in ~ ループの中でリストの要素を書き換えることができることをご存知でしょうか?

repeat with an_item in a_list
set contents of an_item to new_value
end repeat

変数 an_item にはリストの要素へのリファレンスが渡されるので、リストの要素を書き換えることができます。リストの内容を書き換えるだけだと、そんなに遅くないのですが、普通全部のデータを同じ値に書き換えることはしないと思います。ある条件にマッチした値だけを書き換えるのが現実的でしょう。

ですから、a_list の部分はスクリプトオブジェクト内のリストかリストの参照に置き換えないとめちゃくちゃ遅くなります。

しかし、どういう訳か、次のコードは動作しません。

use LapTime : script "LapTime"

on main()
set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat

set tm to LapTime's start_timer()
script list_wrapper
property cnts : a_list
end script

repeat with an_item in cnts of list_wrapper
set contents of an_item to 5000 -- error of number -10006
end repeat
tm's duration()
end main

main()

しかし、どういう訳か、a referenc to をつけるとリストの要素の書き換えができます。そして、パフォーマンスも悪くないです。

use LapTime : script "LapTime"

on main()
set a_list to {}
repeat with i from 1 to 10000
set end of a_list to i
end repeat

set tm to LapTime's start_timer()
script list_wrapper
property cnts : a_list
end script

repeat with an_item in (a reference to cnts of list_wrapper)
set contents of an_item to 5000
end repeat
tm's duration()
(*11.500000953674 [ms]*)
end main

main()

なぜこうなるかは激しく謎です。誰か教えてください。

Summary

AppleScript で大きなデータ処理を行う場合の高速化テクニックをご紹介しました。

このテクニックは、2000年代前半に Serge さんという方が発見され、Serge Metod と呼ばれたりします。

できれば、このような謎のトリックを使わずとも、常に最適化された処理を実行して欲しいと思いますが、どういうわけか改善される気配はありません。しかし、上記のようなことをいちいち覚えておくのはしんどいですね。そこで、上記の高速化のテクニックを含めてリストを扱うのに便利なライブラリを作って使っています。

XList -- Iterator, Queue, Stack として使えるリストのラッパーオブジェクトを提供する AppleScript のモジュール。AppleScript のリストはリファレンスを経由して要素を参照しないと速度が大きく低下するという性質があります。XList は内部で、常にリファレンスを経由してリストにアクセスする為、常に良好な動作速度が得られます。