昔のアセンブラー・プログラムが最新のプロセッサーでは遅くなる!?

★★★!!この記事はz/OSのアセンブラー・プログラム、それもバッチ処理プログラムに関するものです。COBOLやPL/I等のアセンブラー以外の言語で書かれたものには記事の説明は該当しません!!★★★

「新しいプロセッサーにしたのに、いくつかのバッチ処理プログラムのCPU使用量が以前の機種より増えてしまった」ということが起きた場合、そのプログラムがアセンブラー言語で書かれたものであれば、プロセッサーのキャッシュ・ミスが影響しているかも知れません。

どのようなアセンブラー・プログラムが影響を受けるのか?

バッチ処理プログラムの場合、モジュール内に書き込み領域があって、その領域に書き込み処理を行う命令が256バイト以内で近接していれば、どのようなものであってもキャッシュ・ミスの発生は起き得ます。問題とすべきかどうかはそれが許容できる程度のものかどうかによります。1回だけの書き込みとか、数回程度の書き換えであれば無視していいでしょうが、ループ処理の命令部分で近接領域内に書き込みを行っているような場合は注意が必要でしょう。ループ回数が数万回を超えるような場合、言い換えるとループしながら近接領域内を数万回、数十万回と書き換えているようなプログラムはキャッシュ・ミスによる性能低下の影響を受け易いでしょう。
しかし、ループ処理の命令部分と書き込みされるデータ領域が離れているようなプログラム構造であれば、例え同じモジュール内であってもキャッシュ・ミス自体が起きません。また、マルチ・タスク処理や出口ルーチンで使われる再入可能構造のリエントラント・プログラムは、プログラム構造的に命令部分とデータ部分は完全に分離していますので、やはり影響を受けません。
プログラム・ロジックは様々ですが、キャッシュ・ミスによる性能低下を受けやすいプログラムの代表例を以下に1つ示します。

1つの例:順次データセットからレコードを読み込んで処理を行うプログラム

上記のプログラム例は、典型的なバッチ処理の1つです。ごく一般的に使われる移動モードのQSAMで、順次データセットをEODまで読み込みながら処理を繰り返します。EODまでGETしながらレコードの処理を繰り返すこと自体がループ処理ですし、移動モードのGETマクロでレコードを読み込むこと自体がメモリーへの書き込み処理になります。
アセンブラー言語でも、命令は命令部分にまとめモジュールの前方に記述し、データ領域は命令部分の後ろ側に配置するのが一般的なので、命令部分が前処理(初期処理)、メイン処理、後処理(終了処理)、内部サブルーチン群のように並んでいれば、GETしながらの処理部分とレコードの読み込み領域が256バイト以内で近接することは少ないでしょうが、レコードの読み込み処理を内部サブルーチン化しているような場合は意図しなくても近接してしまうかも知れません。もし、レコードの読み込み領域が近接していれば、GETマクロが実行される度に先読みしたキャッシュ内容が無効になりキャッシュの読み直しが発生し、データセット内のレコード量に比例してCPUコストが増加します。

問題の背景

最新のIBMメインフレーム・コンピューターの非常に高い性能を支えている要素の1つがプロセッサーのキャッシュ・メモリーです。zEC12(2012年)以降、L2キャッシュは命令とデータを別々にサポートするようになりました。
命令は設計上読み取り専用であるため、オペランドが示すデータも読み取り専用または共有であればよいのですが、命令とオペランド(命令のパラメーター)が示すデータのローカル・キャッシュ(L1とL2)の間でキャッシュ・ラインが移動するため、同じキャッシュ・ラインでプログラムの命令コードとオペランドが示すデータ領域が共有されるとCPU効率は劣ります。また、オペランドが示す場所への書き込みによって余分なキャッシュ・ミスが発生し遅延が発生します。命令コードの直後(同じキャッシュ・ライン内)に書き込み領域を持っているプログラムは特に影響を受けやすくなります。ただし、命令とオペランドの格納場所をさらに離すことができれば意図しないキャッシュ転送遅延やキャッシュ・ミスに繋がる可能性を減らすことはできます。
キャッシュの仕組み自体は複雑なので詳細は省きます。興味のある方は以下の資料を参照して下さい。

参考資料:IBM Z/LinuxONE System Processor Optimization Primer

どうすればよいのか?

プログラム・モジュール内で命令部分とデータ部分を完全に分離する、あるいはリエントラント構造のプログラムにする、といったことを行えばキャッシュ・ミスによる性能低下(CPUコスト増加)のような問題は起きませんが、そもそもバッチ処理プログラムなのでそのようなプログラム設計が必要なものではありませんし、数十年も前に作られ今でも現役で何のバグも出さずに安定して動いているような業務プログラムであれば今さらそのような大改造をすることは現実的ではありません。

最も簡単な回避策

最も簡単な方法は、命令部分とデータ領域の間にキャッシュ・ライン・サイズの緩衝域を置くことです。緩衝域の大きさはキャッシュ・メモリーのライン・サイズに合わせます。zEC12以降今日のz15プロセッサーでは、256バイトのキャッシュ・ラインになっています。命令部分とデータ部分が同じキャッシュ・ラインに乗らないように間を離すのです。キャッシュのライン・サイズが256バイトであれば、256バイトの領域を配置します。実際には256バイトよりも20~30バイト程度なら少なめでも大丈夫ですが、キャッシュのライン・サイズ分空ければ確実に同じキャッシュ・ラインには乗りません。緩衝域を置く方法は、簡単で既存のロジックにも影響を与えない対処方法です。

プログラムの問題なのか?

プログラムが悪いということはありません。一般的なバッチ処理アプリケーションなどでマルチ・タスク処理を必要としないものは、同じモジュール内に命令と書き込みを含むデータ領域を配置することは当たり前の作り方でした。しかしながら、決して誤りではないものの従来のアセンブラー・プログラムの作り方が、最新のプロセッサーにおいては適切ではなくなってきているということでしょう。
例えば、下記に示すような命令の使い方は、バッチ処理プログラムでは昔からよく使われたアセンブラー・プログラミング・テクニックですが、最新のプロセッサーではCPUの処理効率を落とす要因になります。

上記は、自己変更コードと呼ばれる自分で自分の命令コードを書き換えるテクニックです。非リエントラント・プログラムの場合は、プログラムの命令コードそのものを書き換えることができます。MVC命令の場合、長さを示すバイトは命令コードの先頭+1バイト目です。この1バイトを実際の移動長に書き換えれば可変長の文字列を処理することができます。EXECステートメントのPARMパラメーターで指定される文字列のように、プログラムを動かしてみなければ実際に何バイトをMVCしてよいかわからない、といったような場合に使われた、昔からよく知られているアセンブラー・プログラミング・テクニックの1つです。
しかしながら、自己変更コードのテクニックはアセンブラー・プログラムとしては決して間違っていませんが、今日のプロセッサーの下ではCPUコストを増加させてしまう要因の1つになってしまいました。たった1バイトとは言え自分で自分を書き換えてしまうのですから、読み込んだ命令キャッシュは無効となりキャッシュ・ミスとなってしまいます。また、今日のプロセッサーでは命令実行処理を最適化するためのパイプラインもとても長く(深く)なっているため命令コードそのものの変更コストはとても高いもの(CPUの処理効率を余計に落とす)になってしまうという特性があります。

こちらのようにEX命令を使用したオーソドックスなロジックを使用していれば、プロセッサーのキャッシュ・メカニズムと矛盾せずに本来のCPU性能で処理されます。もしくは下記のようにMVCL命令を使ってもいいでしょう。今日のプロセッサーではMVCL命令はミリコードで実装されていて複雑な処理も効率良く処理されるようになっています。