AI 物見遊山 - 自然言語処理編 - LSTM モデル推論の効率化 その弐

自然言語処理編 - LSTM モデル推論の効率化 その弐

アイキャッチ画像

前の記事では、推論の効率化を行った。 この記事では、Float16 変換と量子化による推論の効率化を体験する。

ONNX モデルの Float16 変換

モデルを float32 から float16 にする。 モデルのサイズが半分程度になる。 単純な変換のみなので、非常にお手軽な手段となる。

必要なパッケージをインストールする

pip install onnx onnxconverter-common

コンバートする。

convert2onnx_float16.py

import os

import onnx
from onnxconverter_common import float16

# データディレクトリ
DATA_DIR = '.'

# モデルファイル名
MODEL_FILE = os.path.join(DATA_DIR, 'lstm-layer2-bidirect.onnx')
# FP16 ONNX モデルファイル名
ONNX_FP16_FILE = os.path.join(DATA_DIR, 'lstm-layer2-bidirect-float16.onnx')

# ONNX モデルを読み込む
onnx_model = onnx.load(MODEL_FILE)
# FP16 モデルに変換する
onnx_model = float16.convert_float_to_float16(onnx_model)
# 変換したモデルをファイルに保存する
onnx.save(onnx_model, ONNX_FP16_FILE)

print(f'ONNX fp16 model size: {os.path.getsize(ONNX_FP16_FILE) / 1024 / 1024:.2f} MB')

onnx_model = onnx.load(ONNX_FP16_FILE)
onnx.checker.check_model(onnx_model)
print(f'ONNX fp16 model is valid.')

変換ログ

ONNX fp16 model size: 8.79 MB

推論をしてみる。 推論は、infer_onnx.py のモデルファイル名(MODEL_FILE)を lstm-layer2-bidirect-float16.onnx に変更するだけで実行できる。

以下のような実行結果となる。

CPU で実行した結果。 正答率は変化無しだが、推論速度が、42% 程低下しているようだ。 そういう仕様なのか、変換パラメータの問題なのか、環境の問題なのか原因は未調査。

前回(ONNX 変換のみ): 全体経過時間: 7.5133 s (58665 times/s)

Using providers: ['CPUExecutionProvider']
Loading datas...
Start inference...
Profile file: 
OK: 428195, Total: 440767, Accuracy: 0.9715
全体経過時間: 12.8086 s (34412 times/s)
ループ回数: 27
初回時間: 0.7488 s
次回以降平均時間: 0.4638 s

T4 GPU で実行した結果。 こちらは、推論速度が、75% 程向上している。

前回(ONNX 変換のみ): 全体経過時間: 3.2806 s (134355 times/s)

Using providers: ['CUDAExecutionProvider']
Loading datas...
Start inference...
Profile file: 
OK: 428202, Total: 440767, Accuracy: 0.9715
全体経過時間: 1.8700 s (235707 times/s)
ループ回数: 27
初回時間: 0.1328 s
次回以降平均時間: 0.0668 s

ONNX モデルの量子化

ONNX モデルの量子化 とは、32 ビット浮動小数点モデルを 8 ビット整数モデルに変換すること。 量子化には、動的量子化と静的量子化がある。 公式ドキュメントには、以下のようにある。

一般的に、RNN およびトランスフォーマーベースのモデルには動的量子化を使用し、 CNN モデルには静的量子化を使用することをお勧めします。

今回のモデルは、RNN 系なので、動的量子化が適していると思われるが、両方試してみる。

ONNX量子化表現形式には、以下のような種類があるようだ。

  • 演算子指向 (QOperator)
  • テンソル指向 (QDQ; 量子化と逆量子化)

データタイプには、activations と weights の組み合わせで、 それぞれ符号付き (int8) または、符号なし (uint8) のいずれかを選択でき、 以下のような組み合わせがあるようだ。

  • U8U8(activations: uint8, weights: uint8)
  • U8S8(activations: uint8, weights: int8)
  • S8S8(activations: int8, weights: int8)

CPU 上の ONNX ランタイム量子化では、U8U8、U8S8、S8S8 を選択可能。 QDQ の場合は、S8S8 がデフォルトとなり、パフォーマンスと精度のバランスが取れている。 QOperator の場合、S8S8 は x86-64 CPU で遅くなり、 GPU 上で ONNX ランタイムの量子化処理を行う場合は、S8S8 のみサポートされているようだ。

動的量子化

convert2onnx_dynamic.py

import os

import onnx
from onnxruntime.quantization import quantize_dynamic
from onnxruntime.quantization.preprocess import quant_pre_process

# データディレクトリ
DATA_DIR = '.'

# モデルファイル名
MODEL_FP32 = os.path.join(DATA_DIR, 'lstm-layer2-bidirect.onnx')
# 前処理済みモデルファイル名
MODEL_PRE_PROCESS = os.path.join(DATA_DIR, 'lstm-layer2-bidirect-pre-process.onnx')
# 量子化済みモデルファイル名
MODEL_QUANT = os.path.join(DATA_DIR, 'lstm-layer2-bidirect-quant-dynamic.onnx')

# 量子化するための前処理済みのモデルを生成する
quant_pre_process(MODEL_FP32, MODEL_PRE_PROCESS)
print(f'ONNX pre-processed model size: {os.path.getsize(MODEL_PRE_PROCESS) / 1024 / 1024:.2f} MB')

# 動的量子化済みモデルファイルを生成する
quantize_dynamic(MODEL_PRE_PROCESS, MODEL_QUANT)
print(f'ONNX quantized model size: {os.path.getsize(MODEL_QUANT) / 1024 / 1024:.2f} MB')

model = onnx.load(MODEL_QUANT)
onnx.checker.check_model(model)
print(f'ONNX model is valid.')

変換ログ

ONNX pre-processed model size: 17.56 MB
ONNX quantized model size: 4.41 MB

推論をしてみる。 推論は、infer_onnx.py のモデルファイル名(MODEL_FILE)を lstm-layer2-bidirect-quant-dynamic.onnx に変更するだけで実行できる。

以下のような実行結果となる。

CPU で実行した場合。 正答率は変化無しで、推論速度が、多少改善しているようだ。

Using providers: ['CPUExecutionProvider']
Loading datas...
Start inference...
Profile file: 
OK: 428282, Total: 440767, Accuracy: 0.9717
全体経過時間: 7.6324 s (57749 times/s)
ループ回数: 27
初回時間: 0.4474 s
次回以降平均時間: 0.2763 s

T4 GPU で実行した場合。 なぜか、とんでもなく遅い。 Colab 環境の問題なのか、量子化方法の問題なのか未調査。

Using providers: ['CUDAExecutionProvider']
Loading datas...
Start inference...
Profile file: 
OK: 428327, Total: 440767, Accuracy: 0.9718
全体経過時間: 91.9086 s (4796 times/s)
ループ回数: 27
初回時間: 4.1985 s
次回以降平均時間: 3.3735 s

静的量子化

import os

import numpy as np
import onnx
import onnxruntime
from onnxruntime.quantization import (
    CalibrationDataReader,
    quantize_static,
    QuantType,
    QuantFormat
)
from onnxruntime.quantization.preprocess import quant_pre_process

# データディレクトリ
DATA_DIR = '.'

# バッチサイズ
BATCH_SIZE = 64
# テスト用訓練データ出力ファイル名
X_TEST_FILE = os.path.join(DATA_DIR, 'lstm-optim-x-test.npy')

# モデルファイル名
MODEL_FP32 = os.path.join(DATA_DIR, 'lstm-layer2-bidirect.onnx')
# 前処理済みモデルファイル名
MODEL_PRE_PROCESS = os.path.join(DATA_DIR, 'lstm-layer2-bidirect-pre-process.onnx')
# 量子化済みモデルファイル名
MODEL_QUANT = os.path.join(DATA_DIR, 'lstm-layer2-bidirect-quant-static.onnx')


class MyCalibrationDataReader(CalibrationDataReader):
    """キャリブレーション用のデータを読み込む
    
    Attributes:
        enum_data (iter): データのインデックスを返すイテレータ
        data (any): データ配列
        input_name (str): 入力データ名
    """

    def __init__(self):
        """コンストラクタ
        """
        self.enum_data = None
        # データを詠み込む
        self.data = np.load(X_TEST_FILE)
        # モデルを読み込む
        _session = onnxruntime.InferenceSession(MODEL_FP32, None)
        # 入力パラメータ名を取得する
        self.input_name = _session.get_inputs()[0].name

    def get_next(self) -> dict or None:
        """次のバッチデータを取得する
        
        Returns (dict or None): バッチデータを返す。データが無い場合は None を返す。
        """
        if self.enum_data is None:
            # バッチデータのインデックスを返すイテレータを生成する
            self.enum_data = iter(range(0, len(self.data), BATCH_SIZE))
        try:
            # バッチデータのインデックスを取得する
            index = next(self.enum_data)
            # データからバッチデータを切り出す
            return {self.input_name: self.data[index:index + BATCH_SIZE]}
        except StopIteration:
            return None

    def rewind(self) -> None:
        """データを先頭に巻き戻す"""
        self.enum_data = None


def main() -> None:
    """静的量子化モデルに変換する"""
    # 量子化するための前処理済みのモデルを生成する
    quant_pre_process(MODEL_FP32, MODEL_PRE_PROCESS)
    print(f'ONNX pre-processed model size: {os.path.getsize(MODEL_PRE_PROCESS) / 1024 / 1024:.2f} MB')

    # キャリブレーション用データを準備する
    calibration_data_reader = MyCalibrationDataReader()

    # 静的量子化済みモデルファイルを生成する
    quantize_static(
        MODEL_PRE_PROCESS,
        MODEL_QUANT,
        calibration_data_reader,
        quant_format=QuantFormat.QDQ,
        activation_type=QuantType.QInt8,
        weight_type=QuantType.QInt8
    )
    print(f'ONNX quantized model size: {os.path.getsize(MODEL_QUANT) / 1024 / 1024:.2f} MB')

    model = onnx.load(MODEL_QUANT)
    onnx.checker.check_model(model)
    print(f'ONNX model is valid.')


if __name__ == '__main__':
    main()

変換ログ

ONNX pre-processed model size: 17.56 MB
ONNX quantized model size: 5.55 MB
ONNX model is valid.

推論をしてみる。 推論は、infer_onnx.py のモデルファイル名(MODEL_FILE)を lstm-layer2-bidirect-quant-static.onnx に変更するだけで実行できる。

以下のような実行結果となる。

CPU で実行した場合。 正答率は変化無しで、推論速度が、多少改善しているようだ。

Using providers: ['CPUExecutionProvider']
Loading datas...
Start inference...
Profile file: 
OK: 428507, Total: 440767, Accuracy: 0.9722
全体経過時間: 9.3587 s (47097 times/s)
ループ回数: 27
初回時間: 0.5572 s
次回以降平均時間: 0.3385 s

T4 GPU で実行した場合。 静的量子化モデルの場合は、量子化前と正答率も、処理時間もさほど変わらない。

Using providers: ['CUDAExecutionProvider']
Loading datas...
Start inference...
Profile file: 
OK: 428507, Total: 440767, Accuracy: 0.9722
全体経過時間: 3.4794 s (126680 times/s)
ループ回数: 27
初回時間: 0.2613 s
次回以降平均時間: 0.1238 s

まとめ

効率化の結果を表にまとめる。 (カッコ内の数字は変換のみとの比率) モデルの変換処理で、なにも調整をしていないため、 これらの比較結果には、あまり意味が無い。 前の記事の参考資料にも記載がある通り、 条件によっては、推論時間が 10 倍になる場合もある。 改善策については、ここでは踏み入らないこととする。

ONNX モデル サイズ(MB) CPU 正答率 GPU 正答率 CPU 推論時間(秒) GPU 推論時間(秒)
変換のみ 17.56 0.9717 0.9715 7.5133 3.2806
float16 8.79 (50.1%) 0.9715 0.9715 12.8086 (170.5%) 1.8700 (57.0%)
動的量子化 4.41 (25.1%) 0.9717 0.9718 7.6324 (101.6%) 91.9086 (2801.5%)
静的量子化 5.55 (31.6%) 0.9722 0.9722 9.3587 (124.6%) 3.4794 (106.1%)