応用編 | AI tech studio https://cyberagent.ai AI事業本部は、サイバーエージェントグループのデジタルマーケティング分野におけるサービス開発を行う組織です。 Wed, 08 Mar 2023 14:28:06 +0000 ja hourly 1 https://wordpress.org/?v=5.3.26 ONNXモデルのチューニングテクニック (応用編2) https://cyberagent.ai/blog/tech/18523/ Wed, 08 Mar 2023 07:36:57 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=18523 基礎編 / 応用編1 / 応用編2

サイバーエージェント AI Lab の Human Computer Interaction Team に所属している兵頭です。今回は私が半年ほど蓄積したONNXのチューニングテクニックを全てブログに残したいと思います。皆さんが既にご存知であろう基本的なことから、かなりトリッキーなチューニングまで幅広くご紹介したいと思います。長文になりますがご容赦願います。今回は応用編2です。

8. 各種トリック

PyTorchやTensorFlowを経由して生成されたONNXから他のフレームワークへモデルを転用する場合に有効な様々なトリック、ワークアラウンドをご紹介します。あまり真新しい知見ではありませんが、以下でご紹介するようなトリックが記事としてまとまっているものはあまり見かけませんのでご参考になれば幸いです。エンジニアよりもリサーチャーの方々に是非読んでいただきたい内容です。ほとんどの内容が地味で目立たないテクニックですが、実用まで見据えたうえではとても重要なものが一部含まれていると考えています。この最終回では実装の内容へ完全にフォーカスします。

8-1. GridSample (Bilinear no-loop only)

GridSample は ONNX の opset=16 から使用可能なオペレーションです。opset=16 が使用可能になるまで、古い PyTorch や onnxruntime では生成できませんでした。これは、PyTorchからONNXへエクスポートするときにバックエンドでコールしている onnx-optimizer、onnxruntime、PyTorchの onnx exporter の実装に依存しており、全てのランタイムが最新化されていなければ使用できないためです。しかし、アテンション系やステレオ画像をインプットとして扱うモデルでのアフィン変換で使用する必要があるなど、ニッチですが実は需要が高いオペレーションでもあります。

opset=16 では 下図のようにとてもシンプルな GridSample OP が生成されます。
image

このまま opset=16 の設定のモデルで onnxruntime を使用する場合は特に問題になることはありません。しかし、ONNXの利用シーンは最も基本的な利用形態の ONNX + CPU や ONNX + CUDA に加えて ONNX + OpenVINO、ONNX + DirectML、ONNX + NNAPI、ONNX + CoreML、ONNX + TensorRT などの様々なハードウェアやフレームワークへ最適化された環境で利用することも多いと思います。その場合、転用先のフレームワークによっては GridSample のような最新のオペレータに対応しておらず、CPUやCUDAへフォールバックしてしまい、動作が遅くなったりすることが多いです。 特に TensorRT は Jetson などのエッジデバイスで高速に推論するために利用されている方が多いと思いますが、JetPack のバージョンによって利用できる TensorRT のバージョンが大きく異なるため、サポートされるオペレーションとサポートされないオペレーションの差が大きいです。

下記の Jetpack の概要を読むと、Jetson ORIN/NX で利用可能な JetPack 5.1 では TensorRT 8.5.2 が利用できるのに対して、Jetson Nano で利用可能な JetPack 4.6 では TensorRT 8.2.1 とあり、マイナーバージョンで 3 の差というのはかなり大きな差で、扱えるオペレーションの種類が大きく異なります。
https://developer.nvidia.com/embedded/jetpack

ご参考までに TensorRT 8.5 と TensorRT 8.4 の差分を下記の通りマイナーバージョン 1 つ分の差ではどれほど違うかが分かるURLを共有します。

TensorRT 8.5 で対応しているオペレータの一覧は下記のとおりです。

TensorRT 8.4 で対応しているオペレータの一覧は下記のとおりです。

TensorRT 8.2 で対応しているオペレータの一覧は下記の通りです。

opset=17 まで対応しているバージョンでも GridSample に対応しているか、対応していないかの差分があります。また、バージョン差によるバグの内在などの問題によって、一見すると対応しているオペレーションに見えても正常に動作しないこともあります。したがって、最新のバージョンでモデルの構造を大きく最適化することだけが運用するうえでの最適な選択とは言えないため、ココではあえて GridSample をプリミティブなレイヤーへ分解して再実装します。
では、opset=16 の GridSample と opset=11 の GridSample (Bilinear no-loop only) を生成してみます。

  • make_GridSample.py
    import torch
    import torch.nn as nn
    
    class Model16(nn.Module):
        def __init__(self):
            super(Model16, self).__init__()
    
        def forward(self, x, grid):
            ret = nn.functional.grid_sample(input=x, grid=grid)
            return ret
    
    class Model11(nn.Module):
        def __init__(self):
            super(Model11, self).__init__()
    
        def forward(self, x, grid):
            Nt, C, H, W = x.shape
            grid_H = grid.shape[1]
            grid_W = grid.shape[2]
            xgrid, ygrid = torch.split(
                tensor=grid,
                split_size_or_sections=[1, 1],
                dim=-1,
            )
            mask = (
                (xgrid >= 0) & (ygrid >= 0) & (xgrid < W - 1) & (ygrid < H - 1)
            ).float()
            x0 = torch.floor(xgrid)
            x1 = x0 + 1
            y0 = torch.floor(ygrid)
            y1 = y0 + 1
            wa = ((x1 - xgrid) * (y1 - ygrid)).permute(3, 0, 1, 2)
            wb = ((x1 - xgrid) * (ygrid - y0)).permute(3, 0, 1, 2)
            wc = ((xgrid - x0) * (y1 - ygrid)).permute(3, 0, 1, 2)
            wd = ((xgrid - x0) * (ygrid - y0)).permute(3, 0, 1, 2)
            x0 = (x0 * mask).view(Nt, grid_H, grid_W).long()
            y0 = (y0 * mask).view(Nt, grid_H, grid_W).long()
            x1 = (x1 * mask).view(Nt, grid_H, grid_W).long()
            y1 = (y1 * mask).view(Nt, grid_H, grid_W).long()
            ind = torch.arange(Nt)
            ind = ind\
                .view(Nt, 1)\
                .expand(-1, grid_H)\
                .view(Nt, grid_H, 1)\
                .expand(-1, -1, grid_W)\
                .long()
            x = x.permute(1, 0, 2, 3)
            output_tensor = (
                x[:, ind, y0, x0] * wa \
                + x[:, ind, y1, x0] * wb \
                + x[:, ind, y0, x1] * wc \
                + x[:, ind, y1, x1] * wd
            ).permute(1, 0, 2, 3)
            ret = output_tensor * mask.permute(0, 3, 1, 2).expand(-1, C, -1, -1)
            return ret
    
    
    model = Model16()
    x = torch.randn([1,3,224,224])
    grid = torch.randn([1,32,32,2])
    onnx_file = f'GridSample_16.onnx'
    torch.onnx.export(
        model,
        args=(x, grid),
        f=onnx_file,
        opset_version=16,
        input_names=[
            'image',
            'grid',
        ],
        output_names=[
            'output',
        ],
    )
    import onnx
    from onnxsim import simplify
    model_onnx2 = onnx.load(onnx_file)
    model_simp, check = simplify(model_onnx2)
    onnx.save(model_simp, onnx_file)
    
    
    model = Model11()
    x = torch.randn([1,3,224,224])
    grid = torch.randn([1,32,32,2])
    onnx_file = f'GridSample_11.onnx'
    torch.onnx.export(
        model,
        args=(x, grid),
        f=onnx_file,
        opset_version=11,
        input_names=[
            'image',
            'grid',
        ],
        output_names=[
            'output',
        ],
    )
    import onnx
    from onnxsim import simplify
    model_onnx2 = onnx.load(onnx_file)
    model_simp, check = simplify(model_onnx2)
    onnx.save(model_simp, onnx_file)

画像が小さく見づらいですが、下図のとおりの生成結果となりました。独自実装の opset=11 の GridSample はプリミティブなオペレーションのみで構成されているため、比較的古いバージョンのフレームワーク群でも実行可能な構成になっています。

  • opset=16 で生成した GridSample (再掲)
    image

  • opset=11 で生成した自力実装の pseudo_GridSample
    image

    最適化と機能性のトレードオフのギリギリを攻めていますので、Loop などのファンクショナルなオペレーションを使用せずに無理やりリニアに実装していることと、4次元までの Bilinear にしか対応していません。

    20230222172855

念の為、処理パフォーマンスにどれほど差が出るかを見てみます。手元の TensorRT バージョンは 8.5.3、CUDA バージョンは 11.6、cuDNN バージョンは 8.6.0、GPUは RTX3070、CPUは Corei9 Gen.10 です。1ms未満の世界の話ではありますが、CUDAあるいはCPUを選択すると独自実装では 5倍〜10倍程度 のパフォーマンス劣化が発生するようです。GridSample を使用できる環境の場合は素直に GridSample を使用したほうが良さそうですね。TensorRT以外のフレームワークではベンチマークしませんが、その他のフレームワークに GridSample と同じ動作をするオペレーションがある場合は問題にならないかもしれません。オペレーションが存在しない場合はいずれにせよプリミティブなオペレーションへ分解する必要はありそうです。

  • opset=16 で生成した GridSample
    sit4onnx -if GridSample_16.onnx -oep tensorrt
    
    INFO: file: GridSample_16.onnx
    INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: image shape: [1, 3, 224, 224] dtype: float32
    INFO: input_name.2: grid shape: [1, 32, 32, 2] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  1.2667179107666016 ms
    INFO: avg elapsed time per pred:  0.12667179107666016 ms
    INFO: output_name.1: output shape: [1, 3, 32, 32] dtype: float32

    sit4onnx -if GridSample_16.onnx -oep cuda
    
    INFO: file: GridSample_16.onnx
    INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: image shape: [1, 3, 224, 224] dtype: float32
    INFO: input_name.2: grid shape: [1, 32, 32, 2] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  0.5278587341308594 ms
    INFO: avg elapsed time per pred:  0.05278587341308594 ms
    INFO: output_name.1: output shape: [1, 3, 32, 32] dtype: float3

    sit4onnx -if GridSample_16.onnx -oep cpu
    
    INFO: file: GridSample_16.onnx
    INFO: providers: ['CPUExecutionProvider']
    INFO: input_name.1: image shape: [1, 3, 224, 224] dtype: float32
    INFO: input_name.2: grid shape: [1, 32, 32, 2] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  0.26607513427734375 ms
    INFO: avg elapsed time per pred:  0.026607513427734375 ms
    INFO: output_name.1: output shape: [1, 3, 32, 32] dtype: float32
  • opset=11 で生成した自力実装の pseudo_GridSample
    sit4onnx -if GridSample_11.onnx -oep tensorrt
    
    INFO: file: GridSample_11.onnx
    INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: image shape: [1, 3, 224, 224] dtype: float32
    INFO: input_name.2: grid shape: [1, 32, 32, 2] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  1.1916160583496094 ms
    INFO: avg elapsed time per pred:  0.11916160583496094 ms
    INFO: output_name.1: output shape: [1, 3, 32, 32] dtype: float32

    sit4onnx -if GridSample_11.onnx -oep cuda
    
    INFO: file: GridSample_11.onnx
    INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: image shape: [1, 3, 224, 224] dtype: float32
    INFO: input_name.2: grid shape: [1, 32, 32, 2] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  2.634763717651367 ms
    INFO: avg elapsed time per pred:  0.2634763717651367 ms
    INFO: output_name.1: output shape: [1, 3, 32, 32] dtype: float32

    sit4onnx -if GridSample_11.onnx -oep cpu
    
    INFO: file: GridSample_11.onnx
    INFO: providers: ['CPUExecutionProvider']
    INFO: input_name.1: image shape: [1, 3, 224, 224] dtype: float32
    INFO: input_name.2: grid shape: [1, 32, 32, 2] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  3.2453536987304688 ms
    INFO: avg elapsed time per pred:  0.3245353698730469 ms
    INFO: output_name.1: output shape: [1, 3, 32, 32] dtype: float32

8-2. Mulit-Class Batched NonMaxSuppression

基礎編 で簡単にご紹介した Multi-Batch / Multi-Class の NonMaxSuppression (NMS) 実装の具体例をご紹介します。まずは PyTorch から正攻法で生成可能な NMS です。

torchvision.ops の nms を使用していますが、バッチサイズの指定に対応していないため N=10 (box数) と 4 (x1y1x2y2) だけを指定してオペレーションを生成しています。

  • NMS

    https://pytorch.org/vision/main/generated/torchvision.ops.nms.html

    boxes (Tensor[N, 4]))
      boxes to perform NMS on.
      They are expected to be in (x1, y1, x2, y2) format with 0 <= x1 < x2 and 0 <= y1 < y2.
    
    scores (Tensor[N])
      scores for each one of the boxes
    
    iou_threshold (float)
      discards all overlapping boxes with IoU > iou_threshold

  • 生成サンプル

    import torch
    import torch.nn as nn
    from torchvision.ops import nms
    
    
    class Model(nn.Module):
        def __init__(self, iou_threshold):
            super(Model, self).__init__()
    
            self.iou_threshold = iou_threshold
    
        def forward(self, boxes, scores):
            return nms(boxes, scores, self.iou_threshold)
    
    ########################################### TorchVision (No batch size)
    boxes = torch.randn([10,4]) # [N, 4]
    scores = torch.randn([10])  # [N]
    iou_threshold = 0.5
    
    model_size = Model(iou_threshold)
    onnx_file = f'nms_11.onnx'
    torch.onnx.export(
        model_size,
        args=(boxes, scores),
        f=onnx_file,
        opset_version=11,
        input_names=[
            'boxes',
            'scores',
        ],
        output_names=[
            'output',
        ],
    )
    import onnx
    from onnxsim import simplify
    model_onnx2 = onnx.load(onnx_file)
    model_simp, check = simplify(model_onnx2)
    onnx.save(model_simp, onnx_file)

    一見すると問題が無いように見えますが、nms に指定可能なパラメータにバッチサイズやクラス数が存在しないため、ONNX生成時にバッチサイズ 1、クラス数 1 が自動的に割り当てられています。したがって、Multi-Batch や Multi-Class の NMS を生成する場合は大量のNMSを生成する必要があります。また注意点として、PyTorch の NMS は X1Y1X2Y2 の座標入力を期待しますが、ONNX の NMS は Y1X1Y2X2 の座標入力を期待します。PyTorch 単体で NMS を含めた後処理を構築する場合はあらかじめ X1Y1X2Y2 を Y1X1Y2X2 などに転置しておくことを忘れないようにしないといけません。また、このように必要機能を満たさないオペレーションを生成するぐらいなら、ロジック側で素直にNMSの処理を実装したほうが効率的です。
    image

  • Mulit-Class Batched NonMaxSuppression の実装紹介

    ONNX の NMS は Mulit-Class と Mulit-Batch に対応しています。したがって、冗長な複数のNMSをPyTorchで生成するのではなく、ONNX の NMS を直接生成することでマルチクラス化とマルチバッチ化に対応します。Mulit-Class Batched NonMaxSuppression を生成するサンプルスクリプトは下記のとおりですが、Pythonのコードを書かずに NMS を生成します。私の作業環境がUbuntuであるため、便宜上 bash を利用して下記のスクリプトを実行することを想定しています。コマンドプロンプトやその他のシェルで実行する場合は、変数の宣言方法や参照方法、エスケープの記載方法が異なる点にご注意願います。

    #!/bin/bash
    
    sog4onnx \
    --op_type Constant \
    --opset 11 \
    --op_name max_output_boxes_per_class_const \
    --output_variables max_output_boxes_per_class int64 [1] \
    --attributes value int64 [20] \
    --output_onnx_file_path Constant_max_output_boxes_per_class.onnx
    
    sog4onnx \
    --op_type Constant \
    --opset 11 \
    --op_name iou_threshold_const \
    --output_variables iou_threshold float32 [1] \
    --attributes value float32 [0.5] \
    --output_onnx_file_path Constant_iou_threshold.onnx
    
    sog4onnx \
    --op_type Constant \
    --opset 11 \
    --op_name score_threshold_const \
    --output_variables score_threshold float32 [1] \
    --attributes value float32 [-inf] \
    --output_onnx_file_path Constant_score_threshold.onnx
    
    OP=NonMaxSuppression
    LOWEROP=${OP,,}
    NUM_BATCHES=5
    SPATIAL_DIMENSION=19248
    NUM_CLASSES=80
    OPSET=11
    sog4onnx \
    --op_type ${OP} \
    --opset ${OPSET} \
    --op_name ${LOWEROP}${OPSET} \
    --input_variables boxes_var float32 [${NUM_BATCHES},${SPATIAL_DIMENSION},4] \
    --input_variables scores_var float32 [${NUM_BATCHES},${NUM_CLASSES},${SPATIAL_DIMENSION}] \
    --input_variables max_output_boxes_per_class_var int64 [1] \
    --input_variables iou_threshold_var float32 [1] \
    --input_variables score_threshold_var float32 [1] \
    --output_variables selected_indices int64 [\'N\',3] \
    --attributes center_point_box int64 0 \
    --output_onnx_file_path ${OP}${OPSET}.onnx
    
    snc4onnx \
    --input_onnx_file_paths Constant_max_output_boxes_per_class.onnx NonMaxSuppression11.onnx \
    --srcop_destop max_output_boxes_per_class max_output_boxes_per_class_var \
    --output_onnx_file_path NonMaxSuppression11.onnx
    
    snc4onnx \
    --input_onnx_file_paths Constant_iou_threshold.onnx NonMaxSuppression11.onnx \
    --srcop_destop iou_threshold iou_threshold_var \
    --output_onnx_file_path NonMaxSuppression11.onnx
    
    snc4onnx \
    --input_onnx_file_paths Constant_score_threshold.onnx NonMaxSuppression11.onnx \
    --srcop_destop score_threshold score_threshold_var \
    --output_onnx_file_path NonMaxSuppression11.onnx

    5 Batch、80 Class の NMS が生成されました。この NMS はクラスIDを横断的に見て閾値を判定してくれます。生成したNMS単体ではメリットがありませんので、この部品を生成したあとにモデル本体へマージする必要があります。モデルへの部品のマージは 応用編1 をご覧ください。

    image

  • 各フレームワークへ横展開するうえでのポイント (ArgMax / ReduceMax活用による次元圧縮)

    昔から使用されている方法のため、テクニック、というほどのものではありませんが、バッチサイズとクラス数をNMSに入力する前の段階で 1 にシュリンクする方法があります。この方法であれば最近のほぼどのフレームワークでも実行可能な後処理(NMS)が生成されます。なお、ここで言う NMS は、NonMaxSuppression オペレーション単体を指すのではなく、下図画像のほぼ全体のオペレーション群全体を指します。オペレーションの数が多いためとても複雑なことをしているように見えるかもしれませんが、実はこの方法が最も多くのフレームワークで処理可能なシンプルな構造になっています。できるかぎりプリミティブなオペレーションを組み合わせると共にバッチサイズやクラス数を 1 に限定することで、ほとんどのフレームワークで実行可能なNMSになります。(1) ArgMax でスコアが最大のクラスIDを抽出し、(2) ReduceMax で最大スコアを抽出しています。(1) をバウンディングボックス群を特定のクラスIDひとつに絞り込むインデックスとして使用し、(2) の最大スコア (ひとつのクラスのスコア) とともに NonMaxSuppression の入力に使用しています。これは YOLOv7 の後処理をそのまま引用しています。この実装の弱点は、不必要な Flatten や Gather を大量に使用することにより、バッチサイズを無視してしまう実装になっていることと、1クラスのみのNMSに限定してしまっていることです。ただ、フレームワーク間の横展開の柔軟性が最も高い実装ですので、運用上、複数クラスのNMSが不要な場合や1バッチのみで処理できれば良い場合は良い選択肢になると考えています。

    image

    実装は下記のとおりです。YOLOv7の後処理の実装そのものです。感覚的に、YOLOv7の後処理はとても綺麗に実装されています。ポイントは max_score, category_id = scores.max(2, keepdim=True) の部分で ArgMax と ReduceMax を同時に生成しているところのみです。

    class ONNX_ORT(nn.Module):
        '''onnx module with ONNX-Runtime NMS operation.'''
        def __init__(
            self,
            max_obj=100,
            iou_thres=0.45,
            score_thres=0.25,
            max_wh=640,
            device=None,
            n_classes=80
        ):
            super().__init__()
            self.device = device if device else torch.device("cpu")
            self.max_obj = torch.tensor([max_obj]).to(device)
            self.iou_threshold = torch.tensor([iou_thres]).to(device)
            self.score_threshold = torch.tensor([score_thres]).to(device)
            self.max_wh = max_wh # if max_wh != 0 : non-agnostic else : agnostic
            self.convert_matrix = torch.tensor(
                [[1, 0, 1, 0], [0, 1, 0, 1], [-0.5, 0, 0.5, 0], [0, -0.5, 0, 0.5]],
                dtype=torch.float32,
                device=self.device
            )
            self.n_classes=n_classes
    
    
        def forward(self, x):
            boxes = x[:, :, :4]
            conf = x[:, :, 4:5]
            scores = x[:, :, 5:]
            if self.n_classes == 1:
                # for models with one class, cls_loss is 0 and cls_conf is always 0.5,
                # so there is no need to multiplicate.
                scores = conf
            else:
                scores *= conf  # conf = obj_conf * cls_conf
            boxes @= self.convert_matrix
            max_score, category_id = scores.max(2, keepdim=True)
            dis = category_id.float() * self.max_wh
            nmsbox = boxes + dis
            max_score_tp = max_score.transpose(1, 2).contiguous()
            selected_indices = \
                ORT_NMS.apply(
                    nmsbox,
                    max_score_tp,
                    self.max_obj,
                    self.iou_threshold,
                    self.score_threshold,
                )
            X, Y = selected_indices[:, 0], selected_indices[:, 2]
            selected_boxes = boxes[X, Y, :]
            selected_categories = category_id[X, Y, :].float()
            selected_scores = max_score[X, Y, :]
            X = X.unsqueeze(1).float()
            return torch.cat([X, selected_boxes, selected_categories, selected_scores], 1)

    class ORT_NMS(torch.autograd.Function):
        '''ONNX-Runtime NMS operation'''
        @staticmethod
        def forward(
            ctx,
            boxes,
            scores,
            max_output_boxes_per_class=torch.tensor([100]),
            iou_threshold=torch.tensor([0.45]),
            score_threshold=torch.tensor([0.25])
        ):
            device = boxes.device
            batch = scores.shape[0]
            num_det = random.randint(0, 100)
            batches = torch.randint(0, batch, (num_det,)).sort()[0].to(device)
            idxs = torch.arange(100, 100 + num_det).to(device)
            zeros = torch.zeros((num_det,), dtype=torch.int64).to(device)
            selected_indices = torch.cat(
                [batches[None],
                zeros[None],
                idxs[None]],
                0,
            ).T.contiguous()
            selected_indices = selected_indices.to(torch.int64)
            return selected_indices
    
    
        @staticmethod
        def symbolic(
            g,
            boxes,
            scores,
            max_output_boxes_per_class,
            iou_threshold,
            score_threshold
        ):
            return g.op(
                "NonMaxSuppression",
                boxes,
                scores,
                max_output_boxes_per_class,
                iou_threshold,
                score_threshold,
            )

8-3. Unity Barracuda用 GatherND

Barracuda は Unity がサポートしているデバイス上でONNXによる推論をサポートするためのパッケージです。2023年3月1日時点では v3.0.0 がリリースされているようです。私はあまりUnityを使用したことが無いのですが、VR/AR界隈で使用されているように見えます。Unityの他にも Unreal Engine というエンジンがあり、そちらでもONNXをサポートしているようです。CPU利用がメインのようですが、DirectMLを経由することでGPUを使うこともできるようです。このブログではそれぞれのエンジンの良し悪しを比較することが目的ではありませんので、ここでは Unity Barracuda での事例を取り上げます。

Unity Barracuda を使用してUnityへONNXを取り込み、UnityにONNX推論を行わせることができます。先述のように、すでに Barracuda は多くのオペレーションに対応しているため、多くのONNXモデルを実行することができるように見えます。

image

image

ここで「できるように見えます。」とあえて含みをもたせた表現をしたことには理由があり、仮に対応オペレーション一覧に記載があるオペレーションであったとしても、Barracuda内部の実装の問題で正常に動作しないことがあります。この点に関しては次の項でご紹介します。

さて本題に戻りますが、この項では対応オペレーション一覧に存在しないオペレーション GatherND を Unity Barracuda に対応させるためのワークアラウンドをご紹介します。GatherND を含むモデルを Unity Barracuda に読み込ませるとオペレーション非対応を示すエラーが表示されます。したがって、GatherND を Unity Barracuda に対応したオペレーションのみの組み合わせに置き換えることで無理やり対応させます。

ただし、PyTorchには GatherND が存在しません。自力で GatherND をシミュレートした処理をPyTorchで実装するか、PyTorch以外の手段で代替するか、のいずれかの方法を選択する必要があります。PyTorchの実装をアレコレと試しましたがなかなかうまくいきませんでした。今回は中間処理に TensorFlow を使用して GatherND を生成します。中間処理にわざわざ TensorFlow を使用することにはあまりメリットを感じないかもしれませんが、GatherND は重みを持たない単純なインデクシングのオペレーションですので PyTorch にこだわる必要もありません。最終的な目的がONNXを使用すること、である場合は、TensorFlow を使用して生成した GatherND を tf2onnx を介してONNXへ変換することでONNXの GatherND を生成します。このブログ自体がかなりトリッキーなノウハウを共有することを目的としているため難解な点がある場合はご容赦願います。

TensorFlow GatherND -> tf2onnx -> ONNX GatherND

#! /usr/bin/env python

import os
os.environ['CUDA_VISIBLE_DEVICES'] = '-1'
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import logging
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
warnings.simplefilter(action='ignore', category=Warning)
warnings.simplefilter(action='ignore', category=DeprecationWarning)
warnings.simplefilter(action='ignore', category=RuntimeWarning)
import numpy as np
np.random.seed(0)
import onnx
import tf2onnx
import tensorflow as tf
tf.random.set_seed(0)
tf.keras.utils.set_random_seed(0)
tf.config.experimental.enable_op_determinism()
tf.get_logger().setLevel('INFO')
tf.autograph.set_verbosity(0)
tf.get_logger().setLevel(logging.FATAL)
from sor4onnx import rename
from ast import literal_eval
from argparse import ArgumentParser

def barracuda_gather_nd(params, indices):
    idx_shape = indices.shape
    params_shape = params.shape
    idx_dims = idx_shape[-1]
    gather_shape = params_shape[idx_dims:]
    params_flat = tf.reshape(
        params,
        tf.concat([[-1], gather_shape], axis=0),
    )
    axis_step = tf.math.cumprod(
        params_shape[:idx_dims],
        exclusive=True,
        reverse=True,
    )
    mul = tf.math.multiply(
        indices,
        axis_step,
    )
    indices_flat = tf.reduce_sum(
        mul,
        axis=-1,
    )
    result_flat = tf.gather(
        params_flat,
        indices_flat,
    )
    return tf.reshape(
        result_flat,
        tf.concat([idx_shape[:-1], gather_shape], axis=0),
    )

def normal_gather_nd(params, indices):
    return tf.gather_nd(params, indices)


if __name__ == "__main__":
    """
    python make_gathernd_replace.py \
    --data_shape 1 48 64 17 \
    --indices_shape 6 3 \
    --opset 11
    """
    parser = ArgumentParser()
    parser.add_argument(
        '-ds',
        '--data_shape',
        type=str,
        nargs='+',
        required=True,
        help='Shape of input data "data"',
    )
    parser.add_argument(
        '-is',
        '--indices_shape',
        type=str,
        nargs='+',
        required=True,
        help='Shape of input data "indices"',
    )
    parser.add_argument(
        '-o',
        '--opset',
        type=int,
        default=11,
        help='onnx opset'
    )
    args = parser.parse_args()

    data_shape = []
    for s in args.data_shape:
        try:
            val = literal_eval(s)
            if isinstance(val, int) and val >= 0:
                data_shape.append(val)
            else:
                data_shape.append(s)
        except:
            data_shape.append(s)
    data_shape = np.asarray(data_shape, dtype=np.int32)

    indices_shape = []
    for s in args.indices_shape:
        try:
            val = literal_eval(s)
            if isinstance(val, int) and val >= 0:
                indices_shape.append(val)
            else:
                indices_shape.append(s)
        except:
            indices_shape.append(s)
    indices_shape = np.asarray(indices_shape, dtype=np.int32)

    opset = args.opset

    ############################################# normal gather_nd
    output_path = 'gather_nd_normal'
    model_name = output_path

    # Create a model - TFLite
    data = tf.keras.layers.Input(
        shape=data_shape[1:],
        batch_size=data_shape[0],
        dtype=tf.float32,
    )
    indices = tf.keras.layers.Input(
        shape=indices_shape[1:],
        batch_size=indices_shape[0],
        dtype=tf.int64,
    )
    output = normal_gather_nd(data, indices)
    model = tf.keras.models.Model(
        inputs=[data, indices],
        outputs=[output],
    )
    tf.saved_model.save(model, output_path)
    converter = tf.lite.TFLiteConverter.from_keras_model(model)
    converter.target_spec.supported_ops = [
        tf.lite.OpsSet.TFLITE_BUILTINS,
        tf.lite.OpsSet.SELECT_TF_OPS
    ]
    tflite_model = converter.convert()
    open(f"{output_path}/{model_name}.tflite", "wb").write(tflite_model)
    # Create a model - ONNX
    model_proto, external_tensor_storage = \
        tf2onnx.convert.from_tflite(
            tflite_path=f"{output_path}/{model_name}.tflite",
            opset=opset,
            output_path=f"{output_path}/{model_name}.onnx",
            inputs_as_nchw=['serving_default_input_1:0']
        )
    # Optimization - ONNX
    model_onnx = onnx.load(f"{output_path}/{model_name}.onnx")
    model_onnx = onnx.shape_inference.infer_shapes(model_onnx)
    onnx.save(model_onnx, f"{output_path}/{model_name}.onnx")
    # Rename
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_1:0','data'
        ],
        onnx_graph=model_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_2:0','indices'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'PartitionedCall:0','output'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_1:0__5:0','data_transpose_output'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_1:0__5','data_transpose'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    onnx.save(renamed_onnx, f"{output_path}/{model_name}.onnx")


    ############################################# barracuda gather_nd
    output_path = 'gather_nd_barracuda'
    model_name = output_path

    # Create a model - TFLite
    data = tf.keras.layers.Input(
        shape=data_shape[1:],
        batch_size=data_shape[0],
        dtype=tf.float32,
    )
    indices = tf.keras.layers.Input(
        shape=indices_shape[1:],
        batch_size=indices_shape[0],
        dtype=tf.int32,
    )
    output = barracuda_gather_nd(data, indices)

    model = tf.keras.models.Model(
        inputs=[data, indices],
        outputs=[output],
    )
    tf.saved_model.save(model, output_path)
    converter = tf.lite.TFLiteConverter.from_keras_model(model)
    converter.target_spec.supported_ops = [
        tf.lite.OpsSet.TFLITE_BUILTINS,
        tf.lite.OpsSet.SELECT_TF_OPS
    ]
    tflite_model = converter.convert()
    open(f"{output_path}/{model_name}.tflite", "wb").write(tflite_model)
    # Create a model - ONNX
    model_proto, external_tensor_storage = \
        tf2onnx.convert.from_tflite(
            tflite_path=f"{output_path}/{model_name}.tflite",
            opset=opset,
            output_path=f"{output_path}/{model_name}.onnx",
            inputs_as_nchw=['serving_default_input_3:0']
        )
    # Optimization - ONNX
    model_onnx = onnx.load(f"{output_path}/{model_name}.onnx")
    model_onnx = onnx.shape_inference.infer_shapes(model_onnx)
    onnx.save(model_onnx, f"{output_path}/{model_name}.onnx")
    # Rename
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_3:0','data'
        ],
        onnx_graph=model_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_4:0','indices'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'PartitionedCall:0','output'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_3:0__9:0','data_transpose_output'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'serving_default_input_3:0__9','data_transpose'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'model_1/tf.reshape/Reshape','data_reshape'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'model_1/tf.math.multiply/Mul','indices_mul'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    renamed_onnx = rename(
        old_new=[
            'model_1/tf.math.reduce_sum/Sum','indices_sum'
        ],
        onnx_graph=renamed_onnx,
        non_verbose=True,
    )
    onnx.save(renamed_onnx, f"{output_path}/{model_name}.onnx")

  • 通常の GatherND

    image

  • Unity Barracuda対応の GatherND

    image

GatherND を生成するためだけにこれほど多くのロジックを書くこと自体がコストに見合わない、という判断があるのはごもっともですが、最小限のオペレーション数で GatherND の処理を実現することができます。また、ここでご紹介したオペレーションの作例は一例です。応用編1 でご紹介したテクニックを使用して直接 GatherND を生成してしまったほうが圧倒的に早いと思います。このテクニックはモデルの実装が公開されていないシチュエーションで主に使用します。

8-4. Unity Barracuda用 Unsqueeze

Unity Barracuda の Unsqueeze オペレーションのハンドリングには致命的なバグがあり、誤った位置に次元が追加されてテンソルが壊れる問題があります。したがって、PyTorchで生成したモデルを最終的に Unity Barracuda へ転用して運用する想定がある場合は、あらかじめ Unsqueeze を別のオペレーションへ置き換えてモデルを生成しておく必要があります。具体的には、次元を外挿したあとのテンソルの形状が自明な場合に最も安全な置き換え方法は Reshape です。

単純に Unsqueeze を Reshape へ置き換えるだけではありますが、このワークアラウンドを適用してモデルの構造をできるだけ最適化するにあたってのポイントは3つです。

  1. 外挿する次元を指定する axis (dim) のパラメータの定数化が可能な場合はできるだけ定数化してからオペレーションを生成する。
  2. torch.Tensor の shape パラメータはモデルによっては x.shape のまま利用するとPyTorchのonnx exporterが型推定に失敗してエラーになることがあるため、shape が自明で定数化が可能な場合はあえてループの内包表記で Python の int型 へキャストしてからリストを再生成する。
  3. Reshape の shape パラメータには -1 を使用しない。

2.に関しては具体的にどういうモデルで発生する事象かまでは言及しませんが、モデルの中間構造が可変形状になるテンソルを持たない場合はできるだけ定数化したほうがオプティマイザの最適化が有効に働きます。3.に関しては、運用フェーズに入ったときに意外とインパクトが出ます。ONNXの形状を書き換えたくなったときに -1 が含まれた形状を shape に指定していると操作不能になります。分かりやすい状況としては、バッチサイズを可変形状に書き換える場合にこの Reshape の -1 が邪魔になります。また -1 が含まれている場合、各種フレームワークのバグを誘発しやすくなります。

import torch
import torch.nn as nn

############################################# normal Unsqueeze
class Model_Unsqueeze(nn.Module):
    def forward(self, x, axis):
        axis = int(axis.detach().numpy())
        return torch.unsqueeze(input=x, dim=axis)

############################################# replace Reshape
class Model_pseudo_Unsqueeze(nn.Module):
    def forward(self, x, axis):
        axis = int(axis.detach().numpy())
        unsqueezed_shape = [
            int(dim) for dim in x.shape
        ]
        unsqueezed_shape.insert(axis, 1)
        return torch.reshape(input=x, shape=tuple(unsqueezed_shape))

input_shape = [1,3,224,224]
expand_axis = 2
x = torch.randn(input_shape)

model = Model_Unsqueeze()
onnx_file = f'unsqueeze_normal_11.onnx'
torch.onnx.export(
    model,
    args=(x, expand_axis),
    f=onnx_file,
    opset_version=11,
    input_names=[
        'input',
    ],
    output_names=[
        'output',
    ],
)
import onnx
from onnxsim import simplify
model_onnx2 = onnx.load(onnx_file)
model_simp, check = simplify(model_onnx2)
onnx.save(model_simp, onnx_file)


model = Model_pseudo_Unsqueeze()
onnx_file = f'unsqueeze_pseudo_11.onnx'
torch.onnx.export(
    model,
    args=(x, expand_axis),
    f=onnx_file,
    opset_version=11,
    input_names=[
        'input',
    ],
    output_names=[
        'output',
    ],
)
import onnx
from onnxsim import simplify
model_onnx2 = onnx.load(onnx_file)
model_simp, check = simplify(model_onnx2)
onnx.save(model_simp, onnx_file)

  • 通常の Unsqueeze (opset=11)

    image

  • Unity Barracuda へ最適化した Reshape(Unsqueeze) (opset=11)

    image

データベースの正規化と同じように、状況によってあえてほんの少しだけコードに冗長性を持たせるだけで、モデル全体の実用性が大幅に向上する例です。

8-5. onnx.onnx_cpp2py_export.shape_inference.InferenceError の回避

PyTorchからONNXを生成するときに、一見すると意味不明なエラー(コード上の表現が正しくPyTorchで正常に動作するもの)が発生することがあります。PyTorchあるいはONNXのバグなのか仕様なのかを判断することが難しいエラーです。例えば下記のようなエラーです。読みやすいように改行を加えています。

onnx.onnx_cpp2py_export.shape_inference.InferenceError:
[ShapeInferenceError] (op_type:ReduceMax, node name: ReduceMax_4449):
[ShapeInferenceError] Inferred shape and existing shape differ in rank: (4) vs (0)

エラーが発生する箇所のコードは下記のとおりです。x には4次元のテンソルが入力されてくる想定です。特にこのエラー発生の原因になっている箇所は torch.max(input=x, dim=1)[0].unsqueeze(1) の部分なのですが一見すると意味合いの整合はとれています。指定された次元(dim)を軸にして最大値を抽出し、[0]でスライスして1つ目の出力を取得し、さらに1次元目を拡張する、というだけの処理です。注意点は、torch.max は2つの出力を得るという点で、戻り値が values と indicies のタプルになるということです。[0] は戻り値のタプルから values を抽出しています。

class ChannelPool(nn.Module):
    def forward(self, x):
        return torch.cat(
            (
                torch.max(input=x, dim=1)[0].unsqueeze(1),
                torch.mean(input=x, dim=1).unsqueeze(1),
            ),
            dim=1,
        )

しかし、下記はエラーになりません。最終的な結果は上記の処理と全く同じですが、次元圧縮と次元展開の処理順序が異なるだけです。torch.max(x, dim=1, keepdim=True) の部分で keepdim=True を指定することで torch.max による次元圧縮を抑止したうえでスライスを施している点がことなります。つまり、Unsqueeze(1) を処理から排除しただけです。おそらく ONNX側の内部実装に問題があるではないか、と推察しています。

class ChannelPool(nn.Module):
    def forward(self, x):
        return torch.cat(
            (
                torch.max(x, dim=1, keepdim=True)[0],
                torch.mean(x, dim=1, keepdim=True)
            ),
            dim=1,
        )

ここでご紹介したエラーはめったに発生しないからこそ、いざ発生したときに全く意味が分からなくて解消までに時間が掛かることがある特殊な問題です。onnx の内部実装を解析してプルリクエストを送ったほうが良いかもしれません。実際に気付いている人が居るのかどうかも分かりません。

8-6. PyTorch の Atan2 をONNXにエクスポートするワークアラウンド

ONNXには Atan の実装はありますが Atan2の実装がありません。
https://github.com/onnx/onnx/blob/main/docs/Operators.md#operator-schemas

image

したがって、Atan2 を使用している一部のモデルは何も手を加えない限りONNXへエクスポートすることができません。余談ですが、TensorFlow Lite には Atan2 の実装はありますが Atan の実装がありません。おそらく、少し手を加えるだけでオペレーションを表現できるためだとは思いますが、何故かどちらか一方しか実装されていないことがあります。本項では、PyTorchの Atan2 をONNXで表現します。

この実装は nikola-j/atan2.py をほぼそのまま引用させていただきました。

import torch
import torch.nn as nn
import numpy as np

class Model(nn.Module):
    def __init__(self):
        super(Model, self).__init__()

    # cited: https://gist.github.com/nikola-j/b5bb6b141b8d9920318677e1bba70466
    def forward(self, y, x):
        pi = torch.from_numpy(np.array([np.pi])).to(y.device, y.dtype)
        ret = torch.atan(y / x)
        ret += ((y > 0) & (x < 0)) * pi
        ret -= ((y < 0) & (x < 0)) * pi
        ret *= (1 - ((y > 0) & (x == 0)) * 1.0)
        ret += ((y > 0) & (x == 0)) * (pi / 2)
        ret *= (1 - ((y < 0) & (x == 0)) * 1.0)
        ret += ((y < 0) & (x == 0)) * (-pi / 2)
        return ret

model = Model()

x = torch.from_numpy(np.array([1.0]))
y = torch.from_numpy(np.array([5.0]))

onnx_file = f'Atan2_11.onnx'
torch.onnx.export(
    model,
    args=(y, x),
    f=onnx_file,
    opset_version=11,
    input_names=[
        'y',
        'x',
    ],
    output_names=[
        'output',
    ],
)
import onnx
from onnxsim import simplify
model_onnx2 = onnx.load(onnx_file)
model_simp, check = simplify(model_onnx2)
onnx.save(model_simp, onnx_file)

少々冗長ですが、下図のとおり Atan2 を表現できます。
image

8-7. PyTorch の torch.inverse をONNXにエクスポートするワークアラウンド

Transformer などのモデルで affine_grid が使用されているとき、何らかの方法で affine_grid を代替して再実装する必要があるのですが、計算を組み合わせていく過程で torch.linalg.inv あるいは torch.inverse を使用して逆行列を求めたいことがあります。しかし、ONNXの標準ランタイムには inverse が実装されていないため、inverse そのものをプリミティブなオペレーションへ分解して表現します。

先にお伝えしておくと、inverse が全く使えないわけではなくて、実際には com.microsoft という拡張モジュールを取り込めば利用できます。標準のランタイムにこだわる必要が無い場合は拡張モジュールを利用したほうが早いです。

image

  • https://pytorch.org/docs/stable/generated/torch.linalg.inv.html#torch.linalg.inv

  • 正方行列になるまで再帰処理でひたすらテンソル分解して逆行列を求めるワークアラウンド

  • torch.linalg.det が正方行列にしか対応していない

  • かなり冗長

    参考:https://github.com/pytorch/pytorch/issues/30563

    import torch
    import torch.nn as nn
    from torch.linalg import det
    
    class Model(nn.Module):
        def __init__(self):
            super(Model, self).__init__()
    
        def cof1(self, M, index):
            zs = M[:index[0]-1,:index[1]-1]
            ys = M[:index[0]-1,index[1]:]
            zx = M[index[0]:,:index[1]-1]
            yx = M[index[0]:,index[1]:]
            s = torch.cat((zs,ys),dim=1)
            x = torch.cat((zx,yx),dim=1)
            return det(torch.cat((s,x),dim=0))
    
        def alcof(self, M, index):
            return pow(-1, index[0]+index[1])*self.cof1(M, index)
    
        def adj(self, M):
            result = torch.zeros((M.shape[0], M.shape[1]))
            for i in range(1, M.shape[0]+1):
                for j in range(1, M.shape[1]+1):
                    result[j-1][i-1] = self.alcof(M, [i,j])
            return result
    
        def forward(self, x):
            def _inverse_matrix_recursion(x):
                x_shape = x.shape
                x_rank = len(x_shape)
                if x_rank == 2:
                    return torch.unsqueeze(1.0/det(x)*self.adj(x), dim=0)
                batched_tensor = []
                for splitted_tensor in x:
                    batched_tensor.append(
                        _inverse_matrix_recursion(splitted_tensor)
                    )
                return \
                    torch.unsqueeze(
                        torch.cat(
                            batched_tensor,
                            dim=0
                        ),
                    dim=0,
                )
            return \
                torch.squeeze(
                    _inverse_matrix_recursion(x), dim=0
                )
    
    
    model = Model()
    x = torch.randn([1,3,4,4], dtype=torch.float32)
    onnx_file = f'pseudo_invert_11_rank{len(x.shape)}.onnx'
    torch.onnx.export(
        model,
        args=(x),
        f=onnx_file,
        opset_version=11,
        input_names=['input'],
        output_names=['output'],
    )
    import onnx
    from onnxsim import simplify
    model_onnx2 = onnx.load(onnx_file)
    model_simp, check = simplify(model_onnx2)
    onnx.save(model_simp, onnx_file)

    もっと冗長性を排除したクリーンな実装方法があるかもしれませんが、torch.linalg.det を使用した結果です。正方行列になるまで高次元を分解しますので、実質何次元でも逆行列化できますが、ロジックがとても単調なためOP数が増えてモデルが肥大化します。
    image

8-8. PyTorch の torch.median をONNXにエクスポートするワークアラウンド

PyTorchにはテンソルの中央値を求めるための torch.median というオペレーションが存在しますがONNXには実装されていません。したがって、torch.median を含むモデルをそのままONNXへエクスポートしようとすると下記のようなエラーが発生します。

Exporting the operator 'aten::median' to ONNX opset version 11 is not supported.
Please feel free to request support or submit a pull request on PyTorch GitHub:
https://github.com/pytorch/pytorch/issues
  File "/home/xxxx/work/test/.vscode/make_median.py", line 31, in <module>
    torch.onnx.export(
torch.onnx.errors.UnsupportedOperatorError:
Exporting the operator 'aten::median' to ONNX opset version 11 is not supported.
Please feel free to request support or submit a pull request on PyTorch GitHub:
https://github.com/pytorch/pytorch/issues

動作検証が不十分ですのでバグがあるかもしれませんが、torch.median をONNXへエクスポート可能なオペレーションの組み合わせに置き換えて下記のように表現します。テスト用のコードはあえて中央値を求める次元を偶数の次元にしていますので、中央値の判定の仕方に少しクセがあります。実装上は、mode を切り替えることで、ちょうど境目にある2つの要素のうち、大きい方を選択するか、小さい方を選択するか、2つの要素の平均を計算するか、を切り替えできるようにしているつもりです。

  1. mode='ceil' : 境目の2つの要素のうち大きいほうを選択
  2. mode='floor' : 境目の2つの要素のうち小さいほうを選択
  3. mode='mean' : 境目の2つの要素の平均値を計算

かなり回りくどい書き方をしていますが、なるべく Tensor型 で定数を保持しないようにしていることと、形状指定に -1 をできるだけ使用しないテクニックを再現しています。定数を再計算する箇所では torch.xxx() を使用せず、np.xxx() あるいは PythonのList型 を使用して Tensor型 に変換されないようにしています。

import random
import torch
import torch.nn as nn
import numpy as np
import onnxruntime as ort

random.seed(0)
np.random.seed(0)
torch.manual_seed(0)

class Model_normal_median(nn.Module):
    def __init__(self, dim):
        super(Model_normal_median, self).__init__()
        self.dim = dim

    def forward(self, x):
        values, indices = torch.median(
            input=x,
            dim=self.dim,
            keepdim=True,
        )
        return values

class Model_pseudo_median(nn.Module):
    def __init__(self, dim, mode):
        super(Model_pseudo_median, self).__init__()
        self.dim = dim
        self.mode = mode

    def forward(self, x):
        x_shape = [int(d) for d in x.shape]
        transpose_perm = None
        reverse_transpose_perm = None

        if len(x_shape) >= 2:
            transpose_perm = [
                idx for idx in range(len(x_shape)) if idx != self.dim
            ] + [self.dim]
            reverse_transpose_perm = [
                transpose_perm.index(idx) \
                    for idx in range(len(transpose_perm))
            ]

            shape_before_compression = None
            shape_after_compression = None
            if len(transpose_perm) >= 2:
                transposed_x = x.permute(transpose_perm)
                transposed_x_shape = [
                    int(d) for d in transposed_x.shape
                ]
                shape_before_compression = \
                    transposed_x_shape[:-1] + [transposed_x_shape[-1]]
                shape_after_compression = [
                    np.prod(transposed_x_shape[:-1])
                ] + [transposed_x_shape[-1]]
            else:
                transposed_x = x
                shape_before_compression = [
                    int(d) for d in transposed_x.shape
                ]
                shape_after_compression = [
                    int(d) for d in transposed_x.shape
                ]

            transposed_reshaped_x = torch.reshape(
                input=transposed_x,
                shape=shape_after_compression,
            )
            l = transposed_reshaped_x.shape[-1]
            mid = l // 2 + 1
            values, indices = torch.topk(
                input=transposed_reshaped_x,
                k=mid,
                dim=len(transposed_reshaped_x.shape) - 1,
            )
            last_dim = [int(d) for d in values.shape][-1]
            median_1_idx = last_dim - 1 if last_dim >= 2 else last_dim
            median_2_idx = last_dim if last_dim >= 2 else last_dim
            if l % 2 == 1:
                median_values = values[:, median_1_idx:median_1_idx + 1]
            else:
                if self.mode == 'floor':
                    median_values =  values[:, median_1_idx - 1:median_1_idx]
                elif self.mode == 'ceil':
                    median_values =  values[:, median_2_idx - 1:median_2_idx]
                elif self.mode == 'mean':
                    median_values =  0.5 * (
                        values[:, median_1_idx - 1:median_1_idx] \
                            + values[:, median_2_idx - 1:median_2_idx]
                    )
            reshaped_median_values = torch.reshape(
                input=median_values,
                shape=shape_before_compression[:-1] + [1],
            )
            result = reshaped_median_values.permute(reverse_transpose_perm)

        else:
            x = torch.reshape(x, [-1])
            l = x.shape[0]
            mid = l // 2 + 1
            values, indices = torch.topk(x, mid)
            result = None
            if l % 2 == 1:
                result = values[-1]
            else:
                if self.mode == 'floor':
                    result = values[-1]
                elif self.mode == 'ceil':
                    result = values[-2]
                elif self.mode == 'mean':
                    result = 0.5 * (values[-1] + values[-2])

        return result

x = torch.randn([4,5,6])
dim = 2
mode = 'ceil'

normal_model = Model_normal_median(dim=dim)
normal_output = normal_model.forward(x).numpy()

pseudo_model = Model_pseudo_median(dim=dim, mode=mode)
onnx_file = f'median_11_pseudo.onnx'
torch.onnx.export(
    pseudo_model,
    args=(x),
    f=onnx_file,
    opset_version=11,
    input_names=[
        'input',
    ],
    output_names=[
        'output',
    ],
)
import onnx
from onnxsim import simplify
model_onnx2 = onnx.load(onnx_file)
model_simp, check = simplify(model_onnx2)
onnx.save(model_simp, onnx_file)

onnx_session = ort.InferenceSession(
    path_or_bytes=onnx_file,
    providers={
        'CUDAExecutionProvider',
        'CPUExecutionProvider',
    },
)
pseudo_result = \
    onnx_session.run(
        None,
        {'input': x.numpy()},
    )[0]

print(
    f'torch.median == pseudo_median: {np.allclose(normal_output, pseudo_result)}'

最終次元で中央値を比較すると、PyTorchとONNXの出力値が一致しました。

torch.median == pseudo_median: True

ONNXの形状は下図のとおりです。ArgMax で値の降順にソートしたテンソルを 要素数 // 2 + 1 個抽出し、抽出したテンソルからさらに末尾の要素、あるいは末尾からひとつ前の要素を抽出することで中央値を取得しています。6 // 2 + 1 = 4個。+1個を余分に抽出しているのは、mode='mean' のときに、境目にある2つの要素の平均を算出するために、境界をまたいだ2つ分の要素を抽出するためです。あえて回りくどい書き方をしたおかげで、Unsqueeze や Squeeze、Gather などの各種フレームワーク側で推論時に問題となるオペレーションを含めずに torch.median を表現できました。

image

8-9. MVN (MeanVarianceNormalization) のワークアラウンド

MeanVarianceNormalization は入力テンソルの平均-分散正規化を計算します。PyTorchそのものには MeanVarianceNormalization がオペレーションとして実装されていないように見えますが私の調査が足りていないだけかもしれません。少なくともONNXには MeanVarianceNormalization が実装されており、数式で表現してもあまり難しいものではありませんので、オペレーションそのものを利用することにあまり固執する必要は無いかもしれません。

https://github.com/onnx/onnx/blob/main/docs/Changelog.md#MeanVarianceNormalization-13

下記のように実装しました。今回もとても回りくどい実装をしています。PyTorchのひとつのオペレーションで処理できる部分をあえて分解して個別に実装しています。特に意味が無いことをすぐに見つけることができる部分は (reduce_sub * 1.0) の * 1.0 の箇所だと思いますが、実はこれには意味があります。まず、MeanVarianceNormalization が実装されているフレームワーク上でしか MeanVarianceNormalization を持つモデルを実行できないことは当然ですが、あえて MeanVarianceNormalization をプリミティブに近いオペレーションの組み合わせに分解して再実装することでほとんどのフレームワーク上で実行可能になります。また、仮にONNXのモデルに MeanVarianceNormalization が含まれているときにそのまま他のフレームワークへ変換すると MVN という名前の等価なオペレーションへそのまま変換されることが多いです。たとえば、ONNXからOpenVINOへ変換するとモデルオプティマイザが自動的に MVN へ置き換えて処理を最適化してくれます。ただ、MeanVarianceNormalization に対応していないフレームワークがその先の変換作業の工程にひとつでも存在するとその時点でモデルの汎用性が著しく低下します。したがって、各種フレームワークのモデルオプティマイザが優秀な最適化性能を持つことを逆手に取って、無意味な演算(計算値に影響を与えない演算)を加えて MeanVarianceNormalization が自動生成されないようにフォローしています。

import torch
import torch.nn as nn
import numpy as np
import onnxruntime as ort

from sog4onnx import generate
onnx_file1 = f'mvn_13_single_op.onnx'
single_op_graph = generate(
    op_type = 'MeanVarianceNormalization',
    opset = 13,
    op_name = "mvn",
    input_variables = {
        "input": [np.float32, data_shape],
    },
    output_variables = {
        "output": [np.float32, data_shape],
    },
    attributes = {
        "axes": [0,2,3],
    },
    non_verbose = True,
    output_onnx_file_path = onnx_file1
)

class Model(nn.Module):
    def __init__(self, dim, epsilon):
        super(Model, self).__init__()

        self.dim = dim
        self.epsilon = epsilon

    def forward(self, x):
        reduce_mean = torch.mean(
            input=x,
            dim=self.dim,
            keepdim=True,
        )
        reduce_sub = x - reduce_mean
        reduce_varriance = torch.mean(
            torch.square(reduce_sub),
            dim=self.dim,
            keepdim=True,
        )
        return (reduce_sub * 1.0) / torch.sqrt(reduce_varriance + self.epsilon)

data_shape = [1,3,128,256]
x = torch.randn(data_shape)
mvn_model = Model(dim=[0,2,3], epsilon=1e-6)
onnx_file2 = f'mvn_11.onnx'
torch.onnx.export(
    mvn_model,
    args=(x),
    f=onnx_file2,
    opset_version=11,
    input_names=[
        'input',
    ],
    output_names=[
        'output',
    ],
)
import onnx
from onnxsim import simplify
model_onnx2 = onnx.load(onnx_file2)
model_simp, check = simplify(model_onnx2)
onnx.save(model_simp, onnx_file2)

onnx_session1 = ort.InferenceSession(
    path_or_bytes=onnx_file1,
    providers={
        'CUDAExecutionProvider',
        'CPUExecutionProvider',
    },
)
mvn_single_op_result = \
    onnx_session1.run(
        None,
        {'input': x.numpy()},
    )[0]

onnx_session2 = ort.InferenceSession(
    path_or_bytes=onnx_file2,
    providers={
        'CUDAExecutionProvider',
        'CPUExecutionProvider',
    },
)
mvn_result = \
    onnx_session2.run(
        None,
        {'input': x.numpy()},
    )[0]

print(
    f'torch.median == pseudo_median: {np.allclose(mvn_result, mvn_single_op_result)}'
)

通常の MeanVarianceNormalization です。

image

左側の分岐の Mul が各フレームワークへ横展開するうえで有効になるワークアラウンドの * 1 の箇所です。これだけでフレームワークの推論エラーを一部回避できるようになります。全体の動作は MeanVarianceNormalization と同じです。出力値は一致します。

image

念の為それぞれの実装でベンチマークしてみます。結果は下記のとおりです。MeanVarianceNormalization 単体の実装のほうが推論速度が数倍速いですがフレームワーク間の汎用性は低いです。

sit4onnx -if mvn_13_single_op.onnx -oep tensorrt

INFO: file: mvn_13_single_op.onnx
INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
INFO: input_name.1: input shape: [1, 3, 128, 256] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  0.6437301635742188 ms
INFO: avg elapsed time per pred:  0.06437301635742188 ms
INFO: output_name.1: output shape: [1, 3, 128, 256] dtype: float32

sit4onnx -if mvn_13_single_op.onnx -oep cuda

INFO: file: mvn_13_single_op.onnx
INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
INFO: input_name.1: input shape: [1, 3, 128, 256] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  0.6847381591796875 ms
INFO: avg elapsed time per pred:  0.06847381591796875 ms
INFO: output_name.1: output shape: [1, 3, 128, 256] dtype: float32

sit4onnx -if mvn_13_single_op.onnx -oep cpu

INFO: file: mvn_13_single_op.onnx
INFO: providers: ['CPUExecutionProvider']
INFO: input_name.1: input shape: [1, 3, 128, 256] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  0.9531974792480469 ms
INFO: avg elapsed time per pred:  0.09531974792480469 ms
INFO: output_name.1: output shape: [1, 3, 128, 256] dtype: float32

分解した MeanVarianceNormalization のベンチマーク結果です。何故か TensorRT による推論が異様に遅いです。また、CPU推論が異様に速いです。なお、フレームワーク間での汎用性は高めです。

sit4onnx -if mvn_11.onnx -oep tensorrt

INFO: file: mvn_11.onnx
INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
INFO: input_name.1: input shape: [1, 3, 128, 256] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  3.3321380615234375 ms
INFO: avg elapsed time per pred:  0.33321380615234375 ms
INFO: output_name.1: output shape: [1, 3, 128, 256] dtype: float32

sit4onnx -if mvn_11.onnx -oep cuda

INFO: file: mvn_11.onnx
INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
INFO: input_name.1: input shape: [1, 3, 128, 256] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  1.9333362579345703 ms
INFO: avg elapsed time per pred:  0.19333362579345703 ms
INFO: output_name.1: output shape: [1, 3, 128, 256] dtype: float32

sit4onnx -if mvn_11.onnx -oep cpu

INFO: file: mvn_11.onnx
INFO: providers: ['CPUExecutionProvider']
INFO: input_name.1: input shape: [1, 3, 128, 256] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  1.5482902526855469 ms
INFO: avg elapsed time per pred:  0.1548290252685547 ms
INFO: output_name.1: output shape: [1, 3, 128, 256] dtype: float32

8-10. TopDown 骨格推定モデルのバッチ処理化ワークアラウンド

TopDown アプローチの骨格推定モデルに関するワークアラウンドです。TopDownの骨格推定モデルは、まず1段階目で物体検出モデルなどを使用して人物のエリアをクロップし、2段階目でクロップした人物エリアの画像に対して骨格推定を行うアプローチです。つまり、ひとつの画角に10人の人物が映っている場合は、物体検出された人物画像10人分を TopDown アプローチの骨格推定モデルに入力して利用します。特に難しいアプローチではなく、人物の身体特徴に合わせて Batch x Channel x Height x Width = 1 x 3 x 256 x 128 のような縦長の入力解像度が設定されていることが多いモデル群です。

モデル設計のアプローチ自体には問題がありません。しかし先述したとおり、物体検出で複数人、なおかつ撮影する映像によっては何人検出されるかが分からない状況でバッチサイズを予め固定したモデルを使用すると、無意味なゼロ埋めデータでパディングして推論する必要があるため推論パフォーマンスを最大化できないばかりか、無用なパディングロジックを記述する必要が生じるため処理が少しだけ冗長になります。例えば、最大10人を検出できるように 10 x 3 x 256 x 128 としてしまった場合、画角内に1人の人物しか映っていなくても不足する9人分の無意味なゼロパディングデータを追加して推論する必要がありますのでとても非効率です。フレームワーク側が可変バッチサイズによる推論に対応している場合(一部のエッジデバイス向けフレームワークを除いて直近の1年でほとんどのフレームワークが対応しました)はバッチサイズを可変サイズのままにしてモデルを生成しておき、必要な画像枚数だけを推論に掛けたいです。

一例として、こちらでプルリクエストを取り込んで頂き、MMPose の動作を変更しました。実装上、とても特別な配慮が必要な内容ではありません。

https://github.com/open-mmlab/mmpose/pull/1242

PyTorchからONNXへモデルをエクスポートする時点で動的サイズを指定できることはご存知だと思いますが、バッチサイズを可変にするにあたって注意しなければいけないポイントがあります。それは、モデルの中間部分のオペレーションにバッチサイズ以外の次元に -1 で形状指定された Reshape が含まれる場合に多くのフレームワークで可変バッチによる推論ができなくなることです。正確には、1次元目のバッチサイズにあたる部分に可変形状を指定できなくなるため、無理やりバッチサイズを可変形状に書き換えるとモデルが破損します。例えば、[-1, -1, 64, 48] のような状況になることを指します。下図は、バッチサイズを可変形状に書き換える前の Reshape の動作を示していますが、仮にこのバッチサイズにあたる1次元目を -1 に書き換えた場合、[-1, -1, 64, 48] となり、推論時にテンソルの形状を正しく推定できなくなりランタイムがAbortします。これはどのフレームワークでも共通の動作です。

image

したがって、下図のとおり Reshape の動作を変更します。これは、PyTorchでモデルを設計する時点で実用性を向上させる観点での配慮が必要な事項です。

image

モデル設計上の修正点は下記のとおりで承認いただけました。

  • From:

    channels_per_group = num_channels // groups
    
    x = x.view(batch_size, groups, channels_per_group, height, width)
    x = torch.transpose(x, 1, 2).contiguous()
    x = x.view(batch_size, -1, height, width)

    To:

    channels_per_group = num_channels // groups
    
    x = x.view(batch_size, groups, channels_per_group, height, width)
    x = torch.transpose(x, 1, 2).contiguous()
    x = x.view(batch_size, groups * channels_per_group, height, width)

モデルを実際に実戦投入して利用することを想定する場合は、サイズが自明な次元には必ず定数値でサイズ指定していただくようにしたほうが良いと感じています。

8-11. 入力テンソルのサイズが大きめの ArgMax のEdgeTPU対応とTensorRT対応と推論速度向上のワークアラウンド

背景透過などの用途でセグメンテーションモデルを利用されている方々がいらっしゃるかもしれません。世の中に出回っているセグメンテーション系のモデルはどんどん高性能化しており、最近ではかなり綺麗に背景透過ができるようになってきています。ただ、セグメンテーションモデルは全体的に処理負荷が高いものが多く、スペックが高めのマシンを使用することを前提として設計されていることが多いです。仮に、セグメンテーションモデルをロースペックなデバイスで利用しようとした場合に課題・問題となる点は下記のとおりです。

  1. セグメンテーションモデルの後処理をモデルの最後尾にマージしてピクセル単位でクラス分類が終わった状態で最終出力を得たい
  2. クラス分類をするとなると ArgMax を使用したいが、ArgMax の処理負荷が高く推論速度が非常に遅い
  3. バッファが小さいデバイスでは ArgMax を内部処理しきれず Abort してしまうことがある

そもそも、ArgMax の処理が遅いという話は昔からある内容ですので、これからご紹介するようなトリックを使用しない限りは上記の問題をクリアできません。では、どのように問題を小さくするかという点を以下にご紹介します。

FusedArgMax という仕組みをモデルに導入します。ArgMax に入力するテンソルのサイズをできる限り小さく押さえて演算し、ArgMax から出力されたテンソルをもともと必要としていたサイズへリサイズします。ものすごく単純ですね。

引用:https://github.com/tensorflow/models/tree/master/official/projects/edgetpu/vision#argmax-fusion-to-improve-segmentation-model-latency

image

image

ArgMax にテンソルを入力する時にフルサイズの解像度で入力するのではなく、一定比率のスケールへ縮小した状態で処理したあとに、ふたたび本来のスケールへリサイズしています。元の入力解像度が 512x512 でリスケール後の解像度が 256x256 だとした場合、要素数にして 2,097,152 個分の要素の演算を削減できます。スマートフォンの Pixel6 での推論パフォーマンスがおよそ2倍に向上しています。実装上は例えば、torch.nn.functional.interpolate の拡大率を調整するだけの簡単なカスタマイズでパフォーマンスを向上できます。

8-12. Korniaの最適化(rgb_to_ycbcr, ycbcr_to_rgb)

Kornia にはPyTorchで利用可能な便利なオペレーションが多数用意されています。その中でも画像処理で利用されることのある YCbCr 変換に関する処理を取り上げたいと思います。なお、モデルの精度向上などの観点ではなく色変換をする際の処理の書き方による汎用性の変化についてのみフォーカスします。

まずは、Kornia の公式実装の ycbcr_to_rgb です。

y: Tensor = image[..., 0, :, :]
    cb: Tensor = image[..., 1, :, :]
    cr: Tensor = image[..., 2, :, :]

    delta: float = 0.5
    cb_shifted: Tensor = cb - delta
    cr_shifted: Tensor = cr - delta

    r: Tensor = y + 1.403 * cr_shifted
    g: Tensor = y - 0.714 * cr_shifted - 0.344 * cb_shifted
    b: Tensor = y + 1.773 * cb_shifted
    return torch.stack([r, g, b], -3)

image

次に、少しだけ Kornia の実装を改造した ycbcr_to_rgb です。ほとんど公式実装と差がありませんので無意味な修正に感じるかもしれません。公式実装の Gather が Slice に置き換わり、スライスしたチャンネルの次元が保持されています。また、公式実装のモデルの最後方の Unsqueeze が全て消失しています。つまりたったこれだけで、Unsqueeze が無くなったことでフレームワーク間の転用がしやすくなったということです。ちなみに、モデル最後尾の ycbcr_to_rgb の部分のみをクローズアップしていますが、モデルの入口の部分には逆の変換の rgb_to_ycbcr が存在するため、合計で6個の Unsqueeze を消失させることができます。モデルを設計・変換する際には、できるだけ次元の圧縮を伴う Gather を使用するのではなく、次元の圧縮を伴わない Slice を使用したほうがモデル全体の構造を綺麗にすることができます。これは、モデルの構造が大きくなればなるほどインパクトが大きくなります。Korniaにプルリクエストをすればいいじゃないか、と思う方がいらっしゃるかもしれませんが、しません。

y: torch.Tensor = image[..., 0:1, :, :]
    cb: torch.Tensor = image[..., 1:2, :, :]
    cr: torch.Tensor = image[..., 2:3, :, :]

    delta: float = 0.5
    cb_shifted: torch.Tensor = cb - delta
    cr_shifted: torch.Tensor = cr - delta

    r: torch.Tensor = y + 1.403 * cr_shifted
    g: torch.Tensor = y - 0.714 * cr_shifted - 0.344 * cb_shifted
    b: torch.Tensor = y + 1.773 * cb_shifted
    return torch.cat([r, g, b], 1)

image

他にもまだ様々なトリックがありますが、終わらなくなるためここで止めます。

9. ONNXから各種フレームワーク向けモデルへの変換

PyTorchからONNXを生成できてさえいれば、あとはどのフレームワークへも期待通りの形式で簡単に変換することができます。主要な機械学習フレームワーク用、ランタイム用のモデルフォーマットへの変換方法を簡単にご紹介します。既出の情報を含みますが、あらためて一気通貫で全部記載します。

9-1. TensorFlow

onnx2tf を使用します。2022年09月に開発を始めた自作ツールですが、最初から他のフレームワークへモデルを転用することを想定した設計にしていますので、現時点ではRNNに対応していないことを除いて最も変換効率が高いです。基本構文は下記です。オプションを何も指定せずに変換すると、 TFLite のモデルが生成されます。

docker run --rm -it \
-v `pwd`:/workdir \
-w /workdir \
ghcr.io/pinto0309/onnx2tf:1.7.21

onnx2tf -i xxxx.onnx

メタデータなども含めて saved_model を出力する場合は下記の通りです。主に saved_model を他のフレームワーク向けに変換するために使用します。

onnx2tf -i xxxx.onnx -osd

Keras の .h5 形式で出力する場合は下記のとおりです。

onnx2tf -i xxxx.onnx -oh5

Keras の keras_v3 形式で出力する場合は下記のとおりです。

onnx2tf -i xxxx.onnx -okv3

MS-COCO データセットのサンプルデータ 20件 を使用して自動キャリブレーションとINT8量子化を行う場合は下記のとおりです。デフォルト動作は per-channel の量子化です。

onnx2tf -i xxxx.onnx -oiqt

per-tensor の量子化を行う場合は下記のとおりです。

onnx2tf -i xxxx.onnx -oiqt -qt per-tensor

オペレーションごとの変換前後の推論誤差を計測する場合は下記の通りです。

onnx2tf -i xxxx.onnx -cotof -cotoa 1e-3

下図GIFのように、全てのオペレーションに対してONNXと変換後のTensorFlowの推論誤差を計測して表示します。主にモデル変換時のデバッグに使用します。

212460284-f3480105-4d94-4519-94dc-320d641f5647

TFLite のバイナリを書き換えて、ONNXの入出力名と入出力オーダーにTFLiteの入出力名と入出力オーダーを合わせることができます。ただし、私が独自にカスタマイズした flatbuffers-compiler を導入する必要があります。上記のDocker環境内にはすでに導入済みです。

https://github.com/PINTO0309/onnx2tf#environment

onnx2tf -i xxxx.onnx -coion

 

image

flatbuffers-compiler の改造内容を知りたい方はこちらをご覧ください。コンパイラが生成する量子化値の算術精度不足を一部だけ解消しています。

https://github.com/PINTO0309/onnx2tf/issues/196

その他にも、FlexOP を無効化するオプションや疑似オペレーションを生成するオプション、 Transpose を指定した次元数まで分解するオプションやこのブログでご紹介した Fused ArgMax を組み込むオプションなど、様々なオプションが用意されていますので気になる方は README をご覧ください。

9-2. TensorFlow.js

昔からある方法ですので今更細かくはご紹介しません。上記の onnx2tf で -osd を指定して生成した saved_model を使用して生成可能です。

pip install tensorflowjs

tensorflowjs_converter \
--input_format tf_saved_model \
--output_format tfjs_graph_model \
saved_model \
tfjs_model

9-3. TensorRT

trtexec というTensorRTに付属するコンバーターを使用するか、ONNX の TensorRT ExecutionProvider で推論開始時に自動変換します。匠要素は少ないですが後者のほうが簡単です。手抜きをするならば、sit4onnx という本ブログのベンチマークで使用していたツールを実行するだけで trtengine を自動生成することができます。

docker run --rm -it --gpus all \
-v $PWD:/home/user/workdir \
pinto0309/ubuntu2004-cuda11.6-cudnn8.6-tensorrt8.5.3:latest

sit4onnx -if xxxx.onnx -oep tensorrt

image

推論コードは下記です。Dockerの中で実行すれば TensorRT で動作確認できます。

import onnxruntime

providers = [
    (
        'TensorrtExecutionProvider', {
            'trt_engine_cache_enable': True,
            'trt_engine_cache_path': '.',
            'trt_fp16_enable': True,
        }
    ),
    'CUDAExecutionProvider',
    'CPUExecutionProvider',
]

onnx_session = onnxruntime.InferenceSession(
    path_or_bytes='xxxx.onnx',
    providers=providers,
)
input_name = onnx_session.get_inputs()[0].name
input_shape = onnx_session.get_inputs()[0].shape

results = \
    onnx_session.run(
        None,
        {input_name: np.ones(input_shape, dtype=np.float32)},
    )

print(f'results[0].shape: {results[0].shape}')

9-4. CoreML

私は iPhone ユーザーではありませんので生成された CoreML モデルの動作確認をすることができませんが、下記でモデルを変換可能です。

pip install coremltools

https://coremltools.readme.io/docs/unified-conversion-api

import coremltools as ct

# Convert saved_model to Core ML
FOLDER_PATH = 'saved_model'

model = ct.convert(
    model=FOLDER_PATH,
    source='tensorflow',
)
model.save(f'{FOLDER_PATH}/model.mlmodel')

image

なお、NCHW形式のCoreMLモデルを生成したい場合は onnx2tf を実行する際に -k オプションを使用して下記のとおり処理します。

curl -LO https://github.com/PINTO0309/onnx2tf/releases/download/1.1.28/mobilenetv2-12.onnx

  • ONNX

    image

-k オプションはONNXの入力OPの形状を維持するようにツール動作を変更します。-ois オプションは入力形状を指定した静的形状へ上書きします。

onnx2tf -i mobilenetv2-12.onnx -k input -ois input:1,3,224,224 -osd

import coremltools as ct

# Convert saved_model to Core ML
FOLDER_PATH = 'saved_model'

model = ct.convert(
    model=FOLDER_PATH,
    source='tensorflow',
)
model.save(f'{FOLDER_PATH}/model.mlmodel')

  • CoreML

    image

9-5. OpenVINO

OpenVINO 2022.3 が手元の環境にインストールされている前提での変換コマンドは下記です。つまづく要素がありません。なお、さらに OAK-D (Myriad) に対応した形式へコンバートする場合は、ONNXの生成時点でMyriad用の多くのワークアラウンドが必要となることがあります。

mo --input_model xxxx.onnx

X. おわりに

少々長くなりましたが、ONNXのモデルチューニングテクニック (応用編2) は以上です。これでチューニングテクニックシリーズは完結とします。

]]>
ONNXモデルのチューニングテクニック (応用編1) https://cyberagent.ai/blog/tech/17657/ Thu, 24 Nov 2022 00:04:04 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=17657 基礎編 / 応用編1 / 応用編2

サイバーエージェント AI Lab の Conversational Agent Team に所属している兵頭です。今回は私が半年ほど蓄積したONNXのチューニングテクニックを全てブログに残したいと思います。皆さんが既にご存知であろう基本的なことから、かなりトリッキーなチューニングまで幅広くご紹介したいと思います。長文になりますがご容赦願います。今回は応用編1です。

このブログのメインターゲット層は「リサーチャーが実装したモデルを実環境へデプロイするタスクを有する方々」です。一部リサーチャーの方々の参考になる情報が混じっていることもあるかもしれませんが、あまり興味を引かない内容だとは思います。リサーチャーメインの組織に属しながらリサーチエンジニアの立ち位置で身を投じていますので、研究の観点の少し手前あるいは少しその先の部分を担っている立場からこのブログを記載しているものとご認識願います。

いきなりですが、我々のチームでは一緒に Human Computer Interaction の研究・開発を行っていただけるリサーチエンジニアを募集しています。本ブログを見てご興味を持って頂けた方は是非一度カジュアルにお話させてください。よろしくお願いします。

7. モデルのチューニング [応用]

基礎編 では、単一層のモデルやオペレーションの生成方法、簡易的なベンチマーク方法の一例をご紹介しました。今回は応用編として一歩踏み込んだモデルチューニングのテクニックをご紹介していきたいと思います。試行環境の構築方法やONNXの基本構造などをご理解いただいていることを前提として書き進めますので、基礎編 をご覧になっていない方はご一読いただいてからこちらの記事を読み進めていただくほうが理解が進みやすいと思います。この章では、simple-onnx-processing-tools に列挙されているツールをユースケースと利用方法を併せて上から順番にご紹介します。

7-1. モデルの融合 (snc4onnx)

2種類以上のモデルを1つのモデルへ融合します。これはあまり現実的なユースケースではないかもしれませんが、例えばVision系のモデル2種類であれば、1枚の2D画像を2つのモデルで1つの入力テンソルとしてシェアしつつ、1回の推論で2つのモデルの推論結果を同時に得られるようにすることができます。また、完成されたモデルではなくコンポーネントとしてバラバラにストックされたモデルを融合してひとつの end-to-end モデルへ構成することもできます。具体的に例を挙げてご紹介します。

名称未設定ファイル drawio (2)

分かりやすく2種類の論文実装モデルを1つのモデルに融合してみます。DMHead に動作イメージの動画やサンプルコード、実際に融合済みのONNXモデルをコミットしてあります。顔面あるいは頭部の6軸角度を推定するモデルです。イメージは下図のとおりです。

image

モデル 概要
 6DRepNet
 (RepVGG-B1g2)
 or
 SynergyNet
 (MobileNetV2)
顔前面の概ね -90°から+90° 範囲の Yaw, Roll, Pitch を高精度に検出するモデルです。図中の青い文字 Front side model と表現されている部分の推論を担当するモデルです。
 WHENet
 (EfficientNet-b0)
後頭部を含む 360° の Yaw, Roll, Pitch を検出可能とうたっているモデルです。図中の赤い文字 Rear side model と 青い文字 Front side model で表現されている部分の両方の推論を担当するモデルです。360° 一周分の方向を推定できますが精度がそれほど高くありません。また、Front side model の推論範囲と重複します。

論文に記載されている定量的な評価は下表のとおりです。平均絶対誤差(MAE:Mean Absolute Error)つまり、MAE の数値が小さいほど実態との乖離が小さいということです。実際にワイルドな環境下で試さないと分からないことなのですが、SynergyNet は指標上はとても性能が高く見えますが、マスクを着用したり、実際にマスクを着用した画像でトレーニングを行うと著しく性能が劣化します。整った条件下でないと論文指標どおりの性能は一切出ません。ただ、ベースモデルが MobileNetV2 ですので超軽量です。一方で、6DRepNet はベースモデルの性能が高いためか、マスクを着用した顔画像を使用してトレーニングを行うと論文指標を上回る性能が出ます。VGG16 の改良版 RepVGG-B1g2 を使用しているため、重みの大きさとは裏腹に若干動作が速いですが、やはりトレードオフで MobileNetV2 に比べると動作が重たいと言わざるを得ません。また、6DRepNet と SynergyNet は正面を向いた顔しか推定できませんが、WHENet だけは後方を向いた後頭部でもある程度角度を推定できます。つまり、それぞれのモデルに一長一短があるため、それぞれのモデルのメリットのみを抽出したいというユースケースがあると仮定します。

  1. 6DRepNet (RepVGG-B1g2) – 引用:https://github.com/thohemp/6DRepNet

    image

    image

  2. SynergyNet (MobileNetV2) – 引用:https://github.com/choyingw/SynergyNet

    image

    image

  3. WHENet (EfficientNet-b0) – 引用:https://github.com/Ascend-Research/HeadPoseEstimation-WHENet

    image

    image

下図のように、正面を向いた状態と後方を向いた状態の360°の範囲をできるだけ精度高く保ったまま6軸推定できるモデルを生成します。青色の枠は前向き、赤色の枠は後ろ向き、紫は中間ぐらい、というグラデーションで向いている方向が視認しやすいように表現しています。また、青の棒は Yaw、赤の棒は Roll、緑の棒は Pitch の角度を表します。これらのモデルは物体検出による顔位置の特定までは行わず、あくまで与えられた顔画像の6軸角度のみを推定するモデルですので、顔位置の検出には別途軽量な物体検出モデルを使用する必要があります。サンプル画像は YOLOv7-tiny を頭部画像データセットを使用してセルフトレーニングしたカスタムモデルを使用して頭部検出しています。全てのコンポーネントをNバッチ化済みですので、RAMの上限を超えない限り物体検出モデルからの出力内容を1度に何人分でも同時に投入できます。サンプル画像は15人同時に検出していて、物体検出と顔向き推定と全ての前処理と後処理を合算して20ms弱です。TensorRTですので速いです。

image

では、さきほどご紹介したとおり、正面側の精度が高い 6DRepNet と後方を検出できる WHENet を snc4onnx を使用して融合します。

使用するモデル・コンポーネントの概要です。大きく分けて合計4つのコンポーネントを融合します。個々のオペレーションの生成方法は 基礎編 でご紹介済みですのでここでは取り上げません。画像がガタガタしてしまっている点はご容赦願います。

モデル・コンポーネント 概要
 6DRepNet 157MB N個の顔画像を受け取り、N個の正面側Yaw,Roll,Pitchを出力します。公式リポジトリの PyTorch のロジックを使用してONNXモデルを生成しました。
image
image
 WHNet 17MB N個の顔画像を受け取り、N個の全方位のYaw,Roll,Pitchを出力します。公式リポジトリの Keras のロジックを使用してONNXモデルを生成しました。生成難易度が高いですが、こちらに作業メモを残していますので気になる方はご覧ください。WHENetのエクスポート Pickleの形式に互換性が無くなってしまった Python3.5以前のレガシーな環境が必要です。なお、トレーニングコードが公開されていないため手軽に重みをチューニングすることはできませんが、ONNXに変換してしまえばこのブログのテクニックを使用してモデル構造を自由に書き換えることはできます。すでに非公式にモデル構造を改変してバッチ処理化した状態です。
image
image
 shrunk_6drepnet_N 6DRepNetの出力のうちYawを-75°〜+75°の範囲のみ活性化するコンポーネントです。N個のYaw,Roll,Pitchを受け取り、YawをカットしたN個の出力を得ます。このコンポーネント自体も事前に snc4onnx を使用して複数のオペレーションをマージした状態です。
image
 shrunk_whenet_N WHENetの出力のうちYawを-76°〜-180°および+76°〜+180°の範囲のみ活性化するコンポーネントです。-75°〜+75°の範囲の角度を0°に補正します。N個のYaw,Roll,Pitchを受け取り、YawをカットしたN個の出力を得ます。このコンポーネント自体も事前に snc4onnx を使用して複数のオペレーションをマージした状態です。
image

2つのモデル、2つのコンポーネントをどのような順番で融合しても問題ありませんが注意点があります。どちらかというとコンポーネント生成時に注意すべき点です。

  1. 融合したモデル内でオペレーションの名前が重複しないように全体で一意なネーミングにしておくこと
  2. モデルのINPUT/OUTPUTの名前もオペレーションの名前と同様にモデル全体で一意なネーミングにしておくこと
  3. 融合するAとBのコンポーネントの結合部は同じ形状なおかつ同じ演算精度にしておくこと

の3点です。
前置きが長くなりましたが融合してみます。ファイル名が長くパラメーターの切れ目が分かりくいですので、あえて改行とインデントを多めに入れて転記しています。

  1. 6DRepNet と shrunk_6drepnet_N を融合

    snc4onnx \
    --input_onnx_file_paths \
        sixdrepnet_300w_lp_maskplus_prepost.onnx \
        shrunk_6drepnet_N.onnx \
    --srcop_destop \
        output_6drep_yaw_roll_pitch \
        shrunk_input \
    --output_onnx_file_path \
        sixdrepnet_300w_lp_maskplus_prepost_disable_rear_side_detection_Nx3x224x224.onnx

  2. WHNet と shrunk_whenet_N を融合

    snc4onnx \
    --input_onnx_file_paths \
        whenet_Nx3x224x224_prepost.onnx \
        shrunk_whenet_N.onnx \
    --srcop_destop \
        yaw_roll_pitch \
        shrunk_input \
    --output_onnx_file_path \
        whenet_prepost_disable_front_side_detection_Nx3x224x224.onnx

  3. 1.と2.を融合

    snc4onnx \
    --input_onnx_file_paths \
        whenet_prepost_disable_front_side_detection_Nx3x224x224.onnx \
        sixdrepnet_300w_lp_maskplus_prepost_disable_rear_side_detection_Nx3x224x224.onnx \
    --srcop_destop \
        input \
        input \
    --op_prefixes_after_merging \
        whenet \
        6drepnet \
    --output_onnx_file_path dmhead_merged_N.onnx

2種類のモデルを融合したため可視化すると横幅が大きくなりすぎて少々見づらいですが、左側が WHENet、右側が 6DRepNet のフローです。入力テンソルが2つのモデルで共用されている点と、出力の最終部で2つのモデルの出力を合算して推論結果を合成しています。単純加算で合成できる理由は少し前でご説明したとおり、2つのコンポーネントで出力の活性化と非活性化の角度範囲を制御していたからです。2つのモデルの出力角度がなるべく重複しないように(100%ではありません)調整されているため単純加算で乗り切れる状態になっていました。つまり、同じ角度範囲で2つのモデルからなるべく同時にゼロより大きい数値が出力されることが無いように調整したということです。最後の Add の部分は単純加算になっており境界付近では不安定になることが若干ありますので改善の余地があります。
image
image

  • 結果

    6DRepNet はファインチューニングしてマスク顔に対応し、さらに論文性能より0.11ポイント精度を向上した自作モデルを使用しました。前後ともに検出力はまずまずのようです。

    6DRepNet: Yaw: 3.31, Pitch: 4.90, Roll: 3.36, MAE: 3.86
    WHENet:   Yaw: 5.11, Pitch: 6.24, Roll: 4.92, MAE: 5.42

    • 6DRepNet側で検出

      image

    • シームレスにWHENet側で検出

      image

snc4onnx のパラメータを転記するとブログが無用に長くなってしまいますので、応用編では詳細仕様が記載されたREADMEのURLのみご案内します。

snc4onnx の README はこちらです

ここまででモデルやコンポーネントの融合のテクニックをご紹介しましたが、このようなユースケースが活きると考えているのは、特定のタスクに特化した強力で軽量な部品単位のモデルを複数準備しておき、最終的には1つのモデルに融合して、実質マルチタスクに近いモデルをトレーニング後に自由に生成できるというところです。

7-2. モデルの分離 (sne4onnx)

1つのモデルを分離します。モデルの途中の特定のオペレーションの出力内容をデバッグするときや、7-1. モデルの融合 のようなユースケースでコンポーネントとして使用するためにモデルの一部分を抽出するとき、ハードウェアアクセラレータや推論フレームワークのバグを回避するためにモデルの一部分を別のコンポーネントに差し替える場合などがユースケースとして考えられます。また、世界中のリサーチャーが設計してくれたモデルは、実環境に投入して高効率に推論することまでを想定した綺麗なモデル構造になっていないことが多く、onnx-simplifier で最適化処理を施したときにAbortしてしまったり、モデルの最適化が十分に適用されないことが多いです。リサーチャーの観点からすれば、モデルを効率的に運用することや高パフォーマンスで推論することまでを想定しながらモデル設計することは本懐ではないと私自身は考えていますので、モデルの最適化タスクはエンジニアのタスクである、と割り切っています。リサーチャーが設計してくれた良いモデルを更に最適化するために、最適化が可能な範囲にモデルを分離して最適化を施し、のちに最適化済みのコンポーネント同士を再結合して全体を最適化したモデルを再構成する場合などが最もユースケースとして多いです。たとえば、一例でしかありませんしPyTorchのロジックをくまなく読み込んで自分で改修すれば問題ない事例ではありますが、Reshape の形状変更時に -1 が指定されたものが含まれているモデルはバッチ処理化をするときにAbortします。利用するエンジニア目線では -1 をできる限り指定してほしくないです。何故 Reshape に -1 が指定されている状態でバッチ処理化するとAbortするかという理由に触れておきます。Reshape後の形状指定が仮に [1,3,-1,512] だったとします。これをバッチ処理化するとどうなるかというと [-1,3,-1,512] となります。Reshape を行ったあとの出力の形状は分かりますでしょうか? 無理です。つまり、ONNXに限らずどのフレームワークを使用したとしてもこの状況になるとAbortします。実質バッチ処理化して使えないモデルです。なお、-1 を指定した数値に書き換えるトリックは後続の節でご紹介します。

では、今回はとてもシンプルなモデルを分離してみます。MNIST – Handwritten Digit Recognition mnist-12.onnx

まず、onnx/models で公開されているサンプルONNXファイルをダウンロードし、基礎編でご紹介した onnxsim (onnx-simplifier) を3回実行してモデルの冗長さを排除します。

curl -OL https://github.com/onnx/models/raw/main/vision/classification/mnist/model/mnist-12.onnx
onnxsim mnist-12.onnx mnist-12.onnx
onnxsim mnist-12.onnx mnist-12.onnx
onnxsim mnist-12.onnx mnist-12.onnx

最適化後の MNIST ONNX の構造は下図のとおりです。
image

今回はモデルを分離してコンポーネント化する方法のご紹介のみにとどめるため、ユースケースに基づくモデルの分離ではない点にご注意願います。
では、下図の MaxPool の Pooling160_Output_0 の出力部分でモデルを分断してみます。分離をスタートするINPUT OPの名前と分離の終端となるOUTPUT OPの名前の両方を指定します。

image

sne4onnx \
--input_onnx_file_path mnist-12.onnx \
--input_op_names Input3 \
--output_op_names Pooling160_Output_0 \
--output_onnx_file_path mnist-12_part.onnx

分離されたONNXファイルが生成されました。Reshape と Gemm に含まれていた定数がモデルから除かれたかたちになりますのでファイルサイズが大きくシュリンクしているのが見て取れます。

image

Before After
image image

当然ながら、モデルの中間部分のみを分離することもできます。2つの MaxPool で挟まれた区間を分離してみます。

image

sne4onnx \
--input_onnx_file_path mnist-12.onnx \
--input_op_names ReLU32_Output_0 \
--output_op_names Pooling160_Output_0 \
--output_onnx_file_path mnist-12_part2.onnx

分離したONNXファイルが生成されました。 Conv に含まれていた定数がモデルから除かれたかたちになり、さらにモデルサイズがシュリンクしました。

image

Before After
image image

なおこのツールは、複数のINPUT NameとOUTPUT Nameを同時に指定することでひとつのモデルから複数の始点と複数の終点を持つモデルを抽出できるようにしてあります。 ONNXの標準API にも同様の処理が実装されていますし、onnx-graphsurgeon にも同様のインタフェースが用意されていますが、Pythonのソースコードを駆使してロジックを組み立てる必要があるため少々手間です。ちなみに、Idein さんも同じ思想で同様のツールを提供されています。onnigiri “The purpose of this package is to create subgraphs by partitioning computational graphs in order to facilitate the development of applications.” 私は完全にひとりでツールを作成していますので、Ideinさんのようにお仕事で作成されているツールをご利用いただくほうが運用の継続性の観点からはメリットがあると考えています。

sne4onnx の README はこちらです

7-3. Nodeの削除 (snd4onnx)

ONNXの標準APIや onnx-graphsurgeon を使用したPythonコードで表現するときにはかなり手間が多く複雑な操作です。そもそも、削除 という行為は複数のプリミティブな操作を組み合わせて実現する必要があり、コード量が若干多くなります。具体的には、

  1. モデルを指定のNodeの入出力位置で上下に分断してコンポーネント化する
  2. 削除対象のNodeをモデルから削除する
  3. 分断した上下部分のコンポーネントの終端と始端が同じ形状および同じ算術精度であることをチェックする
  4. 1.で分断した上下のコンポーネントを削除後の断面の入出力変数で結合しなおす
  5. 全てのコンポーネントを再結合して生成しなおしたモデルの構造が破綻していないかチェックする
  6. モデルをONNXファイルとして再出力する

という流れです。意味不明ですよね。でも、正常にモデルからNodeを削除するために必要な手番はこれだけあります。無理にこの節でご紹介するツールを使用しようとするとうまくNodeが削除できないパターンが多いですが、シンプルな構造のモデルであれば直感的に実施できます。実際に試してみます。snd4onnx というツールを使用します。

非常に限られたユースケースしかありませんが、不必要な cast を削除したり、フレームワークのバグを回避するための下準備として特定のNodeを削除する場合、などが該当します。
前節で使用した mnist-12.onnx を再び使用します。

意味の無い操作ですが、Conv の次にある Relu を削除してみます。

image

snd4onnx \
--remove_node_names ReLU114 \
--input_onnx_file_path mnist-12.onnx \
--output_onnx_file_path mnist-12_del.onnx

ONNXファイルが生成されました。

image

削除対象に指定した Relu Node名 ReLU114 がモデルから削除され、Conv と MaxPool が直結した状態のモデルが生成されました。

image

簡単にNodeが削除されてしまいましたが、現実的なシチュエーションでは複数の入力があるNodeや複数の出力があるNodeを削除したいことが多いと思いますので、このツール単体で手軽に実施できるパターンはごく限られています。削除はできても再接合部の形状不一致のワーニングやエラーが頻繁に出ます。あまり手を抜かず、他のツールを組み合わせて慎重にモデルをチューニングしたほうが確実です。

snd4onnx の README はこちらです

7-4. モデルサイズの圧縮 (scs4onnx)

これは 基礎編 でご紹介した onnx-simplifier を使用して最適化したONNXモデルを更にサイズ圧縮するためのテクニックを検証するために独自に考えた手法ですので、あまりユースケースとしては適切ではありません。したがいまして、ツールの使用方法や効果をこのブログで詳しくご紹介することはあえてしませんが、気になる方は scs4onnx のREADMEをご覧ください。

詳細には触れませんが、このツールのコンセプトだけを簡単にご説明します。

  1. onnx-simplifier で各種オペレーションを最適化・定数化する過程でモデルが肥大化することがある
  2. モデルが肥大化する原因は定数に置き換えられた画像のパッチ化処理などに必要となる大量のINT64精度のインデックス値など
  3. INT64 は 64bit で値を保持するため、定数化されたテンソルの 要素数 x 64bit の容量がモデルに埋め込まれる
  4. したがって、INT64 で埋め込まれた定数を INT32 などのより低精度な定数へ置換することでモデルのサイズを圧縮する
  5. INT32であれば50%、INT16であれば25%、INT8であれば12.5% まで圧縮できる
  6. ONNXファイル内部で複数回登場する定数を共用定数としてひとつに集約する
  7. ONNXファイルから指定した定数のみを外部ファイル.npyへ切り出してONNXファイルを軽量化できる

このツールで効果が出るモデルは限られていますが、念の為結果の画像だけ貼り付けておきます。最も効果が高かったモデルでは、1.8GB あったONNXファイルが 2.1MB まで圧縮できました。ただし、ONNX本体から巨大な定数を外部ファイル(.npy)に抽出した結果です。量子化は行いませんので一切精度劣化はしません。

  • 297.8MB -> 67.4MB (.onnx)

    image

  • 1.8GB -> 2.1MB (.onnx) + 884.7MB (.npy)

    image

ツールの仕様でちょっとだけ面白い点に触れさせて頂くとすれば、モデル内部の定数部分を xxx.npy というNumpyのndarray形式のバイナリファイルにエクスポートする機能があることです。下図のクラゲのように線が伸びている部分が xxx.npy ファイルから読み出したテンソルを入力するための入力OPです。xxx.npy へエクスポートするタイミングで自動的にモデルへ入力OPを追加します。クラゲのようにひとつの入力OP部から複数の線が伸びているのは同じ定数を使用するOPが線の本数分だけ存在することを表します。したがって、xxx.npy へエクスポートしたONNXの定数は 再利用されるOPの数 x 定数のサイズ だけ圧縮されます。実は、繰り返し登場する同じ定数を集約するだけでモデルファイルの全体サイズを圧縮する効果はかなり大きいです。xxx.npy ファイルへ抽出しなくても scs4onnx を実行するとデフォルトの動作で定数を自動集約してファイルサイズを圧縮したONNXファイルを再出力する仕様にしています。定数を集約する、の意味がいまいちイメージできない方は 基礎編 の 5-1. ONNXモデルの構造 をご覧いただき、定数の名前を複数のNode間で共用するだけで簡単に実現できるトリックであることをご確認ください。プログラムで変数や定数を使い回しすることと概念に違いはありません。initializer 部で定義した定数の rawData を複数のNodeから参照することができる仕組みです。使い方として成り立つのかどうかが分かりませんが、モデルに投入する定数を推論ごとに差し替えることもできますね。意味不明ですが。もしかすると、定数部分を外部ファイルに抽出できているので、外部ファイルに抽出された定数ファイルのみを暗号化して秘匿してしまうとか、イロイロと遊べそうな要素がありそうです。復号化ロジックをどのように隠蔽するのかは考えていません。

image

具体的な仕様が気になる方はこちらの scs4onnx の README をご覧ください。

7-5. Nodeの生成 (sog4onnx)

基礎編 の 5-3. Node生成 でご紹介しましたので応用編では説明を省略します。

sog4onnx の README はこちらです

7-6. Nodeの属性値や定数の書き換え (sam4onnx)

モデルのチューニングを行ううえで最も使用頻度が高くなるであろうツール sam4onnx をご紹介します。このツールでできることを直感的に把握していただくために、先に画像で結果をご覧ください。

  1. Transpose の perm 属性の書き換え例

    Before After
    image image
  2. Mul に埋め込まれた定数の書き換え

    Before After
    image image
  3. Reshape に埋め込まれた shape 定数の -1 を 1 に書き換え

    Before After
    image image
  4. NonMaxSuppression に埋め込まれた score_threshold 定数の -Infinity を 0.5 に書き換え

    Before After
    image image

本質的には類似のコマンドを実行するだけなのですが、1. と 3. と 4. のコマンドを例示します。各Nodeの属性や変数・定数に指定可能な値の種類や精度はONNX公式の一覧表をご覧ください。 ONNX Operator Schemas

  1. Transpose の perm 属性の書き換え例

    OP名 Transpose_17 の属性値 perm を [1,0] から [0,1] に書き換える

    sam4onnx \
    --input_onnx_file_path input.onnx \
    --output_onnx_file_path output.onnx \
    --op_name Transpose_17 \
    --attributes perm int64 [0,1]

  2. Reshape の shape 定数の書き換え例

    OP名 Reshape_34 の入力定数 241 を [-1] から [1] に書き換える

    sam4onnx \
    --input_onnx_file_path input.onnx \
    --output_onnx_file_path output.onnx \
    --op_name Reshape_34 \
    --input_constants 241 int64 [1]

  3. NonMaxSuppression の score_threshold 定数の書き換え例

    OP名 nonmaxsuppression11 の定数 score_threshold を [-Infinity] から [0.5] に書き換える

    sam4onnx \
    --input_onnx_file_path input.onnx \
    --output_onnx_file_path output.onnx \
    --op_name nonmaxsuppression11 \
    --input_constants score_threshold float32 [0.5]

このツールのメリットは、onnx-simplifier で最適化が終わった状態のONNXのパラメータを直接書き換えできることです。例えば、入力解像度に応じた複数種類のモデルを最適化した状態で用意しておき、後処理の検出閾値のみをあとから書き換えて検出精度の変化をテストしたい時や、前節で挙げたように、そもそも Reshape の変更後の形状に -1 が指定されている場合に onnx-simplifier の最適化やバッチ処理化が失敗する時に強制的に指定したパラメータに書き換えできることです。PyTorch や TensorFlow のロジックを一切読む必要がありません。また、おそらくモデル構造を最適化するタスクに取り組み始めるとすぐに直面する課題なのですが、PyTorch や TensorFlow からONNXを出力、あるいは変換して出力するときに問題になることがあるのが、PyTorch や TensorFlow のモデルオプティマイザが必要のないモデル構造変更を行ったり、少しの最適化を勝手にしてしまうことがあり、想定通りの構造のONNXモデルを素直に生成できないことがあります。そういう場合にも onnx-simplifier で最適化後のONNXファイルのパラメータを直接書き換えできるところに大きなメリットがあります。また、opset間で互換性の有無が有る属性値の追加や削除ができます。

sam4onnx の README はこちらです

7-7. opsetの書き換え (soc4onnx)

opsetの数字を強制的に書き換えるツールです。運用上はほとんど使いどころが無いため大幅に説明を省略します。下図のイメージです。opsetの数字を書き換えるだけでNodeの構造をopsetに対応した構造に書き換えることまではしません。ユースケースとしては、コンポーネント化したONNXファイルを複数結合する際にopsetアンマッチによる結合エラーを回避する場合が考えられます。

image

soc4onnx \
--input_onnx_file_path NonMaxSuppression.onnx \
--output_onnx_file_path NonMaxSuppression_13.onnx \
--opset 13

image

soc4onnx の README はこちらです

7-8. 入力OPのみのNCHW<->NHWC変換 および BGR<->RGB変換 (scc4onnx)

このツールもあまりユースケースが思い浮かびませんので説明を簡略化します。モデル全体を NCHW <-> NHWC 相互変換するのではなく、入力OPのみを NCHW <-> NHWC 相互変換します。また、BGR <-> RGB の変換用コンポーネントを入力OPの直後に外挿します。下図を見ていただくと雰囲気をご理解いただけると思います。

  1. NCHW <-> NHWC 相互変換

    ONNXの内部はNCHW形式で処理されるため、入力OPをNHWC形式に変更した場合は、入力OPの直後で NCHW形式に転置し直す必要があります。あくまで推論用プログラムをONNXとTensorFlowで共用したいときにモデルの入力形式をNHWC形式へ統一して運用する、あるいはその逆のNCHW形式へ統一して運用する、ぐらいの使いどころぐらいしか思い当たりません。

    Before After
    image image
  2. RGB <-> BGR 相互変換

    入力OPの直後に3チャンネルをスライスして順番を入れ替える処理を外挿します。プログラムの前処理側でOpenCVを使用して定型的に実装することが多い、BGR -> RGB 変換 あるいはその逆の変換をモデルの入口に外挿してしまうイメージです。

    Before After
    image image

scc4onnx の README はこちらです

7-9. Nodeの追加 (sna4onnx)

ONNXの標準APIや onnx-graphsurgeon を使用したPythonコードで表現するときにはかなり手間が多く複雑な操作です。7-3. Nodeの削除 と逆向きの操作を行うという点以外はほぼ同じです。追加 という行為は複数のプリミティブな操作を組み合わせて実現する必要があり、コード量が若干多くなります。具体的には、

  1. モデルを指定のNodeの入出力位置で上下に分断してコンポーネント化する
  2. 追加対象のNodeをsog4onnxを使用して生成する
  3. 分断した上下部分のコンポーネントの終端と始端が2.で新規生成したNodeの入出力と同じ形状、同じ算術精度であることをチェックする
  4. 1.で分断した上下のコンポーネントと2.で生成したNodeを間に挟んで入出力変数で結合しなおす
  5. 全てのコンポーネントを再結合して生成しなおしたモデルの構造が破綻していないかチェックする
  6. モデルをONNXファイルとして再出力する

こちらのツールも、無理に使用しようとするとうまくNodeが追加できないパターンが多いですが、シンプルな構造のモデルであれば直感的に実施できます。実際に試してみます。sna4onnx というツールを使用します。

前節で使用した mnist-12.onnx を再び使用します。

意味の無い操作ですが、Conv の次に Mul を追加してみます。

image

sna4onnx \
--input_onnx_file_path mnist-12.onnx \
--connection_src_op_output_name Convolution110 Convolution110_Output_0 dummy_mul inp1_var \
--connection_dest_op_input_name dummy_mul out1 ReLU114 Convolution110_Output_0 \
--add_op_type Mul \
--add_op_name dummy_mul \
--add_op_input_variables inp1_var float32 [1,16,14,14] \
--add_op_input_variables inp2_const float32 [1] \
--add_op_output_variables out1 float32 [1,16,14,14] \
--output_onnx_file_path mnist-12_add.onnx

ONNXファイルが生成されました。

image

追加指定した Mul Node名 dummy_mul がモデルに追加され、Conv と Relu の間を仲介しているモデルが生成されました。ただ、sna4onnx は定数を自動生成することができませんので、入力 B 側は inp2_const という名前の変数のみ定義された状態となっています。別途定数を定義して inp2_const に紐付ける必要があります。少し手間ですね。ややこしいですが、ONNXは Constant (定数) であってもひとつの Node (オペレーション) として独立して定義が存在しています。従って、Constant を新規に生成した場合は snc4onnx を使用して個別に融合する必要があります。ONNX公式の Constant オペレーション仕様はこちらに記載があります。Constant-12 一例としてopset=12の Constant を取り上げています。

image

定数を定義した Constant Node を生成します。

sog4onnx \
--op_type Constant \
--opset 12 \
--op_name const_custom1 \
--output_variables dummy_const float32 [1] \
--attributes value float32 [0.2]

生成されました。

image
image

では、前節で取り上げた snc4onnx を使用して Constant と Mul を結合してみます。

snc4onnx \
--input_onnx_file_paths Constant.onnx mnist-12_add.onnx \
--output_onnx_file_path mnist-12_merged.onnx \
--srcop_destop dummy_const inp2_const

融合済みのONNXファイルが生成されました。Conv と Relu の間に Mul を外挿することに成功しました。

image
image

少し手間ですが、任意の位置にNodeを追加することができました。オペレーションを1個追加する程度ではあまりメリットを感じませんが、大量に追加する場合はシェルで一気に操作できるため便利なときがあります。大半のシチュエーションではPyTorch側のロジックを調整して再エクスポートしたほうが早いです。

sna4onnx の README はこちらです

7-10. 固定バッチサイズを可変バッチサイズに書き換え (sbi4onnx)

可変バッチのONNXファイルを生成する場合、PyTorch側で torch.onnx.export(...) を実行するときに下記のように dynamic_axes にバッチサイズを表す次元を指定して初期化する文字列を与えるだけで生成することができます。下記の場合はゼロ次元目に batch_size と文字列が付与された入力OPが生成されます。

onnx_file = f"{MODEL}_HxW.onnx"
x = torch.randn(1, 3, 180, 320).cuda()
torch.onnx.export(
    self.model.module,
    args=(x),
    f=onnx_file,
    opset_version=11,
    input_names=['input'],
    output_names=['output'],
    dynamic_axes={
        'input' : {0: 'batch_size'},
        'output': {0: 'batch_size'},
    }
)
model_onnx = onnx.load(onnx_file)
model_onnx = onnx.shape_inference.infer_shapes(model_onnx)
onnx.save(model_onnx, onnx_file)

では、あえてこの章でご紹介する 固定バッチサイズを可変バッチサイズに書き換え のテクニックが必要となるシチュエーションはどんなものがあるかというと、

  1. PyTorchモデルのソースコードが存在しない、あるいは提供されていない
  2. バッチサイズが固定されたONNXは提供されているが可変バッチのONNXが提供されていない
  3. リサーチャーが書いてくれたソースコードが複雑過ぎて読み込む作業の精神的ストレスが大きい

というパターンです。こういう状況はわりと多いです。従いまして、運用上は可変バッチのほうが扱いやすい場合を想定し、提供された固定バッチのONNXファイルを可変バッチのONNXファイルに書き換えます。

前節で使用した mnist-12.onnx を再び使用します。

mnist-12.onnx はゼロ次元目のバッチサイズが 1 に固定されています。ここを可変バッチの N に書き換えてみます。

image

sbi4onnx \
--input_onnx_file_path mnist-12.onnx \
--output_onnx_file_path mnist-12_Nbatch.onnx \
--initialization_character_string N

ONNXファイルが生成されました。ゼロ次元目のバッチサイズが指定した文字列 N で更新されています。

image
image

注意点として、このツールはゼロ次元目を数値から文字列へ強制的に書き換える動作のみを行います。したがいまして、ゼロ次元目を含む形状変更を伴う Reshape の形状変更、が含まれている場合や、バッチサイズそのものの意味を表現できないNodeが含まれている場合は書き換え動作が失敗します。シンプルに機械的にゼロ次元目を書き換える動作のみに対応しています。とはいえ書き換える方法が無いわけではなく、モデルのバッチサイズを書き換えたい場合は 7-2. モデルの分離 (sne4onnx) を使用して該当部分のNodeを切り離したうえで、切り離したコンポーネント個々の単位でバッチサイズを初期化し、バッチサイズ初期化後に再び 7-1. モデルの融合 (snc4onnx) を使用して再結合すれば問題ありません。既出のツールを組み合わせて作業手順を組み立てる発想の転換次第でほとんどの問題は回避可能です。このブログのテクニックを総動員して生成したモデルがこちらにあります。yolact_edge_onnx_tensorrt_myriad

sbi4onnx の README はこちらです

7-11. Node名の書き換え (sor4onnx)

入力OP、出力OP、中間Nodeの名称を書き換えることができます。ユースケースは、PyTorchから出力されたONNXのNode名が冗長なときに、運用ルールに則した分かりやすいネーミングに一括で統一する場合や、7-1. モデルの融合 (snc4onnx) を使用して複数のコンポーネントを結合する際、コンポーネント間でNode名の重複がある場合に、融合後のモデル全体で一意な名前になるようにあらかじめ名前を更新しておくような状況で良く利用します。ONNXのNode名はモデル全体で必ず一意になる必要があります。

では、再び mnist-12.onnx を使用します。

  1. モデル全体のNodeを前方一致検索し Convolution を CONV に書き換え

    image

    sor4onnx \
    --input_onnx_file_path mnist-12.onnx \
    --output_onnx_file_path mnist-12_rename1.onnx \
    --old_new "Convolution" "CONV" \
    --mode full \
    --search_mode prefix_match

    image

  2. 入力OPのみを前方一致検索し Input3 を input に書き換え

    image

    sor4onnx \
    --input_onnx_file_path mnist-12.onnx \
    --output_onnx_file_path mnist-12_rename2.onnx \
    --old_new "Input3" "input" \
    --mode inputs \
    --search_mode prefix_match

    image

  3. 出力OPのみを前方一致検索し Plus214_Output_0 を output に書き換え

    image

    sor4onnx \
    --input_onnx_file_path mnist-12.onnx \
    --output_onnx_file_path mnist-12_rename3.onnx \
    --old_new "Plus214_Output_0" "output" \
    --mode outputs \
    --search_mode prefix_match

    image

名前の置換範囲や検索アルゴリズムには次のものが選択可能です。モデル全体、入力OPのみ、出力OPのみ、完全一致検索、部分一致検索、前方一致検索、後方一致検索。

-m {full,inputs,outputs}, \
--mode {full,inputs,outputs}

    Specifies the type of node to be replaced.
    full or inputs or outputs.
    full: Rename all nodes.
    inputs: Rename only the input node.
    outputs: Rename only the output node.
    Default: full

-sm {exact_match,partial_match,prefix_match,suffix_match}, \
--search_mode {exact_match,partial_match,prefix_match,suffix_match}

    OP name search mode.
    exact_match or partial_match or prefix_match or suffix_match.
    exact_match: Exact match search for OP name.
    partial_match: Partial match search for OP name.
    prefix_match: Prefix match search for OP name.
    suffix_match: Suffix match search for OP name.
    Default: exact_match

モデル構造を見直すときはかなり使用頻度が高くなるツールです。モデル利用者側から見た構造の視認性向上にも役立ちます。

sor4onnx の README はこちらです

7-12. モデルに出力OPを追加 (soa4onnx)

モデルの任意の位置のNodeから出力OPを追加生成します。すでにツール群 simple-onnx-processing-tools を活用している海外のエンジニアからのリクエストを受けて作成しました。 soa4onnx 各ノードの出力値をデバッグする場合や複数のモデルを融合するための接合点を生成するユースケースに使用します。

再び mnist-12.onnx を使用します。

最初の Relu から出力OPを生成してみます。加工前のモデルの出力は Plus214_Output_0 のひとつのみです。また、Relu の出力変数名は ReLU32_Output_0 です。

  • モデルの全体構造

  • 最初の Relu Nodeの情報

    image

Relu の出力変数名 ReLU32_Output_0 をモデルへ追加するように指示します。

soa4onnx \
--input_onnx_file_path mnist-12.onnx \
--output_op_names "ReLU32_Output_0" \
--output_onnx_file_path mnist-12_output_added.onnx

ONNXファイルが生成されました。ReLU32 Nodeの出力 ReLU32_Output_0 がモデルの出力として追加され、モデル全体の出力が2つに増えました。

image
image

ちょっとしたモデル構造の改変程度ではあまり利用する機会がありませんが、複数のモデルを融合するときに接合点を生成するときに役立つときがあります。
soa4onnx の README はこちらです

7-13. モデルの出力OPを削除 (sod4onnx)

それほど多くはありませんが、PyTorchの公式論文実装から出力したONNXの出力OPに使用されないものが複数含まれていることがあります。無駄な出力を含めたままにしていると、ハードウェアアクセラレータとHostPC間での無駄なデータ転送コストが発生してレイテンシを悪化させることにつながることがあります。特にUSBを経由して使用するハードウェアアクセラレータなどを使用する場合はできる限りHostとやりとりする情報の転送量を抑えるのがレイテンシ低減に効果的です。なお、PyTorchの論文実装をカスタムして出力OPを削除する場合は不用です。

今回は複数出力OPを備えたモデルを使用してテストする必要がありますので、こちらの Robust Video Matting (RVM) モデルを使用します。

入力ひとつに対し、出力が fgr pha r1o r2o r3o r4o の6つあるモデルです。仮にこの出力のうち pha だけが必要とした場合のユースケースを想定します。

image

では、pha 以外の出力を全て削除してみます。

sod4onnx \
--input_onnx_file_path rvm_mobilenetv3_538x960.onnx \
--output_op_names "fgr" "r1o" "r2o" "r3o" "r4o" \
--output_onnx_file_path rvm_mobilenetv3_538x960_output_del.onnx

ONNXファイルが生成されました。

image

削除する、ということ自体は当たり前のようにできるのですが、注目していただきたい点は別の部分にあります。下図の出力OP削除前と出力OP削除後を交互にご覧ください。出力OP削除前に fgr に接続されていた各種OP (Add と Clip) が fgr 削除後に一掃され、最終出力に不必要なモデルのフローが全て自動的に削除されました。つまり、無駄な出力OPを全て削除することで通過しなくてもよい演算ルートを全て一掃する効果があるという点です。今回テストに使用したモデル rvm_mobilenetv3_538x960.onnx は削除された最終出力OPの手前に接続されているNodeが2つしかありませんでしたので推論速度向上に対する効果は限定的ですが、より一層複雑なモデルに対して同様の削除操作を行った場合、不必要な演算を大幅にカットできることがあります。PyTorchのロジックを目視で読み込んで手作業で最適化しなくてもツールが自動的に最適化してくれます。

  • 出力OP削除前のモデルの最終出力近辺の様子

    image

  • 出力OP削除後のモデルの最終出力近辺の様子

    image

sod4onnx の README はこちらです

7-14. モデルの中間Nodeの形状推定操作のみ実行 (ssi4onnx)

モデルのチューニングをするにあたって、モデルの中間Nodeの形状推定をすることのメリットは 基礎編 の 6-1-2. オペレーションの形状推定と定数変換と埋め込み でご紹介しました。しかしながら、状況によっては onnx-simplifier を使用してモデル構造の最適化はしたくないが各Nodeの入出力の形状のみ事前に推定しておきたいシチュエーションがあります。例えば、最適化までを行ってしまうとONNX以外の推論フレームワークで問題が発生する場合や、最適化前の形状を正しく把握してどのNode部分を最適化対象とするかを分析したいときなどです。onnx-smiplifier を使用すると自動的に形状推定を行いつつモデル構造を最適化してしまいます。

したがって、そういうユースケースに対応するため、このツール ssi4onnx はモデル構造の最適化を一切行わず、全Nodeの形状推定処理のみを実行します。

今回はこちらのモデル efficientnet-lite4-11.onnx を使用します。モデルの構造は下図のとおりです。onnx-simplifier で未最適化の状態です。

image

比較のためにまずは onnx-simplifier で形状推定とモデル構造の最適化の両方を実行してみます。

onnxsim efficientnet-lite4-11.onnx efficientnet-lite4-11_onnxsim.onnx

モデルの形状推定と構造の最適化後の様子です。4つ目の Conv の次にあった BatchNormalization が Conv に取り込まれて消滅するのと同時に各Nodeの入出力情報が書き込まれ、モデル構造の視認性が向上しました。

image

次に ssi4onnx を使用して形状推定のみ実行してみます。

ssi4onnx \
--input_onnx_file_path efficientnet-lite4-11.onnx \
--output_onnx_file_path efficientnet-lite4-11_inf_only.onnx

モデル構造の最適化が行われていないため BatchNormalization が Conv に取り込まれることなくそのまま残っていますが、各Nodeの入出力情報が書き込まれ、モデル構造の視認性が向上しました。

image

ssi4onnx の README はこちらです

7-15. ONNXの推論速度の簡易的なベンチマーク (sit4onnx)

基礎編 4. モデル生成の基礎 [PyTorch -> ONNX] で使用方法をご紹介しましたので応用編では説明を省略します。

7-16. ONNXのJSON変換 (onnx2json)

基礎編 5-1. ONNXモデルの構造 で使用方法や生成されたJSONファイルの読み方、IDEを使用した書き換え方法などをご紹介しましたので、応用編では説明を省略します。

onnx2json の README はこちらです

7-17. JSONのONNX変換 (json2onnx)

基礎編 5-1. ONNXモデルの構造 で onnx2json を使用して生成したJSONファイルに対して自由な編集を加えたモデル構造を再びONNXファイルへ逆変換するためのツールです。これまでの節でご紹介したツール群によるモデル加工の代わりに、IDEを使用してモデルの構造を直接手で書き換えてからONNXファイルへ戻すほうが早いシチュエーションが数多く存在します。たとえば、モデル内に繰り返し登場する類似の処理部分を一括で置換して書き換えたい場合などです。IDEの検索と置換の機能は高機能ですので、IDEの正規表現の置換機能などを駆使してテキストベースで一括変更すると楽な時があります。

ここでは 基礎編 5-1. ONNXモデルの構造 の説明をベースにしてモデルをIDEで書き換えたうえでONNXファイルへ戻すテクニックをご紹介します。

加工対象のサンプルとするモデルは 基礎編 5-1. ONNXモデルの構造 で生成した opset=17 の LayerNormalization2D_17.onnx です。事例はシンプルな方が分かりやすいと思いますので、定数のBase64エンコードが不要な属性値 epsilon を 1e-05 (0.000009999999747378752) から 1e-01 (0.10000000149011612) へ単純に書き換えます。
image
image

まずはONNXファイルをJSONに変換します。

onnx2json -if LayerNormalization2D_17.onnx -oj LayerNormalization2D_17.json

JSONファイルをVSCodeなどのIDEで開きます。編集箇所は "f": 1e-05, の部分です。編集したらJSONを上書き保存します。

  • 編集前

    {
        "irVersion": "8",
        "producerName": "pytorch",
        "producerVersion": "1.14.0",
        "graph": {
        "node": [
            {
            "input": [
                "LayerNormalization2D_input",
                "/Constant_output_0",
                "/Constant_1_output_0"
            ],
            "output": [
                "LayerNormalization2D_output"
            ],
            "name": "/LayerNormalization",
            "opType": "LayerNormalization",
            "attribute": [
                {
                "name": "axis",
                "i": "-3",
                "type": "INT"
                },
                {
                "name": "epsilon",
                "f": 1e-05,
                "type": "FLOAT"
                }
            ]
            }
        ],
        "name": "torch_jit",
        "initializer": [
            {
            "dims": [
                "5",
                "10",
                "10"
            ],

  • 編集後

    {
        "irVersion": "8",
        "producerName": "pytorch",
        "producerVersion": "1.14.0",
        "graph": {
        "node": [
            {
            "input": [
                "LayerNormalization2D_input",
                "/Constant_output_0",
                "/Constant_1_output_0"
            ],
            "output": [
                "LayerNormalization2D_output"
            ],
            "name": "/LayerNormalization",
            "opType": "LayerNormalization",
            "attribute": [
                {
                "name": "axis",
                "i": "-3",
                "type": "INT"
                },
                {
                "name": "epsilon",
                "f": 1e-01,
                "type": "FLOAT"
                }
            ]
            }
        ],
        "name": "torch_jit",
        "initializer": [
            {
            "dims": [
                "5",
                "10",
                "10"
            ],

次に、JSONファイルをONNXファイルへ逆変換します。

json2onnx -ij LayerNormalization2D_17.json -of LayerNormalization2D_17_eps.onnx

JSONファイルからONNXファイルが生成されました。ONNXファイルをNetronで表示すると、属性値 epsilon の値が 1e-01 (0.10000000149011612) へ更新されていることが確認できます。

image
image

このレベルの簡易的な修正であれば、前節までのツールを使用してスクリプトを実行するよりも、JSON化してテキストベースで編集を加えたうえでONNXファイルを再生成したほうが直感的で手っ取り早いことが多いです。使い慣れたIDEを使用してONNXを一括書き換えできます。

json2onnx の README はこちらです

7-18. 定数値のBase64エンコード/デコード (sed4onnx)

基礎編 5-1. ONNXモデルの構造 で扱い方をご紹介しましたので応用編では説明を省略します。

sed4onnx の README はこちらです

7-19. 簡易的な構造分析 (ssc4onnx)

基礎編 6-1-3. 使用するうえでのテクニック で扱い方をご紹介しましたので応用編では説明を省略します。

7-20. モデルの入出力形状名の書き換え (sio4onnx)

ONNXのモデルは未定義の次元 (unk__*) を受け入れます。例えば下記のような事例です。unk__* の部分はどのような形状でも受け入れることを表します。PyTorchからONNXをエクスポートする時に torch.onnx.export(...) で dynamic_axes に名前を指定しなかった場合などです。PyTorchの公式ドキュメントに記載があります。torch.onnx.export 下記の例では、yolonms_layer_1/* という名前の出力OPのゼロ次元目を未定義の次元に指定していますが、表示名を指定していませんので unk__* という少し長めで直感的ではない未定義の次元名が自動で付与されます。あるいは、onnx-simplifier を使用して形状推定とモデル構造の最適化を行っていない状態のときに形状未定という意味で unk__* が割り当てられることがあります。ONNXを推論で使用するうえでは特に問題は発生しませんが直感的なネーミングではなく、そして少し冗長です。このネーミング部分を sio4onnx で書き換えます。

dynamic_axes={
    "yolonms_layer_1/ExpandDims_1:0": [0,1],
    "yolonms_layer_1/ExpandDims_1:3": [0,1,2],
    "yolonms_layer_1/concat_2:0": [0,1],
}

image

ネーミングを書き換えます。unk__* の部分を batch や H や W や boxes などに書き換えるコマンドは下記のとおりです。面倒ですが、全ての入出力OP名を列挙する必要があります。前節のJSON変換を使用してIDEで書き換えても大して作業コストに変わりはありません。

sio4onnx \
--input_onnx_file_path yolov3-10.onnx \
--output_onnx_file_path yolov3-10_upd.onnx \
--input_names "input_1" \
--input_names "image_shape" \
--input_shapes "batch" 3 "H" "W" \
--input_shapes "batch" 2 \
--output_names "yolonms_layer_1/ExpandDims_1:0" \
--output_names "yolonms_layer_1/ExpandDims_3:0" \
--output_names "yolonms_layer_1/concat_2:0" \
--output_shapes 1 "boxes" 4 \
--output_shapes 1 "classes" "boxes" \
--output_shapes "boxes" 3

image

sio4onnx の README はこちらです

7-21. Node間コネクションの切り替え (svs4onnx)

7-9. Nodeの追加 (sna4onnx) および 7-3. Nodeの削除 (snd4onnx) では、任意のNode間に新たなNodeを外装したり、任意のNodeを削除する方法をご紹介しました。しかし同時に文中でも触れているように、外挿あるいは削除するNode位置の前後の入出力形状と演算精度が必ず一致していないとエラーになるということにも触れました。7-9. Nodeの追加 (sna4onnx) と 7-3. Nodeの削除 (snd4onnx) は内部処理が若干複雑な手番を踏んでいるがゆえに手軽に使える反面柔軟性に乏しい面がありました。そこでこのツール svs4onnx は少しだけ発想を変えて、コネクション間に新たなNodeを追加したり、コネクション間に存在するNodeを直接削除するのではなく、下記の順序で慎重に、そして正確にコネクションを接続替えする操作を数段階踏むことでNodeを外挿したり対象のNodeを削除する操作を表現するために作成しました。具体的な動作イメージは下記の通りです。

  1. 入力ノードの出力変数に新規ノードを接合する
  2. 新規ノードの出力変数をモデルの任意の入力変数へ接合する
  3. 2.で新規Nodeの出力変数で入力変数が上書きされたNodeより手前の任意の数のNode(モデルの全体フローから外れたコンポーネント)をどのコネクションにも接続されていない離れ小島の状態にする
  4. モデルの整合性をチェックし、全体構造から分離して離れ小島になっているコンポーネント部分をクリーニング(削除)する

わけのわからないことをゴチャゴチャと書きましたが、要は下図のとおりコネクションの接続替えをすることができる。ということです。この操作ができることで、7-9. Nodeの追加 (sna4onnx) および 7-3. Nodeの削除 (snd4onnx) の操作を表現できます。

Nodeの追加やNodeの削除というややこしい考え方は置いておいて、単純にこのツールでコネクションの接合先を変更するコマンドは下記の通りです。Cast Nodeの出力変数 cast1_output を Tile の入力変数 StatefulPartitionedCall/strided_slice_21 に接合するように指示を出しています。From から To へ接続せよ。というだけの指示です。このタイミングで、もともと接続されていたコネクションは自動的に消失します。

svs4onnx \
--input_onnx_file_path movenet_multipose_lightning_192x256_nopost_tmp1.onnx \
--from_output_variable_name "cast1_output" \
--to_input_variable_name "StatefulPartitionedCall/strided_slice_21" \
--output_onnx_file_path movenet_multipose_lightning_192x256_nopost_tmp2.onnx

image
image

svs4onnx の README はこちらです

7-22. ONNX(NCHW)形式からTensorFlow(NHWC)形式への自動コンバート (onnx2tf)

このツールだけは他のツールのようにモデルの構造を書き換える手段を提供するものではなく、シンプルにONNXをTensorFlowフォーマットへ変換するツールです。数年前からONNXの公式ツールとして onnx-tensorflow というものが提供されていたのですが、ツールを利用する上で大きな問題を抱えています。

  1. NCHWをシミュレートするように無理やり変換するため、全ての Conv の前後に無駄な Transpose を大量に外装し、変換後のモデルの推論パフォーマンスが極端に悪化する
  2. ロジックの構造が TensorFlow v1.x を前提としたとてもレガシーな作りになっている
  3. 未定義の次元を含むモデルを変換できない
  4. 内部の作りが複雑なことに加えて依存パッケージが多く正常に動作しないパターンが多い
  5. コントリビュータが居ない

1.と2.の問題は大きな問題ではありますがOSSとして公開された当初からの仕様ですので、基本的な処理思想含めて大掛かりにリファクタリングをする必要があることを考えるとなんとか目をつぶることが出来ますが、5.はOSSとして致命的です。仮にプルリクエストを頑張って発行したとしてもレビューアが不在です。そして、内部のロジックの作りが複雑過ぎるためデバッグがとても困難でした。コントリビュータが36人も居て約5年?も継続的にメンテナンスされてきていたためとても残念ではありますが、メンテナ不在の時点で工数を掛ける気があまり湧きませんでした。

ということで、1.から5.の問題を全て解決すべく、ひとりでフルスクラッチしたツールがこちらのツール onnx2tf です。onnx-tensorflow が対応していない一部のオペレーションの変換にも追加で対応し、onnx-tensorflow で変換したモデルよりも 20% ほど高速なモデルを生成できます。ただし、RNN系とFFT系のオペレーションだけは TensorFlow v2.x 系での実装難易度が高すぎて今のところはうまく動作する状態で変換動作を実装することができていません。RNN LSTM GRU などです。もし気が向いた方がいらっしゃいましたら、是非プルリクエストをお送り下さい。

image

onnx2tf の README はこちらです

ちなみに、類似のツールとして onnx2tflite がありますが、現時点では以下の点で仕様の充足度が足りないと考えています。分かる範囲で記載しますので今後改善されていく可能性があるものも含みます。

  1. tf.keras を多用しているため、saved_model を出力するときに頻繁にエラーが発生する

  2. モデルの入力にひとつのテンソルしか許容しない (複数入力を許容しない)

  3. 画像を入力にとるモデルしか考慮されていない

  4. 量子化時のノーマライゼーションの実装に画像入力しか考慮されていない

  5. per-channel の量子化しか考慮されておらず per-tensor の量子化ができないためハードウェアアクセラレータへの配慮が不足している

  6. TPU や Myriad やその他のハードウェアアクセラレータに対するオペレーション置換のワークアラウンドが考慮されていない

  7. ONNXからTFLiteへの変換しか考慮されておらず、その他のフレームワークへ転用することを考慮していない

  8. 多次元の転置が考慮されていない

  9. 汎用的な動作を定義する実装が無く、対応できるモデルの種類がとても少ない

  10. TransposeConv の変換に問題がある

  11. Slice の変換に問題がある

  12. INT8量子化されたONNXモデルを変換することができない

  13. 実装が不足しているオペレーションが多い (例えばNMSなど)

  14. NHWC形式の入力を持つONNXモデルを変換できない

  15. 変換エラーが発生するパターンを回避するための手段としては、プログラムを独自改造する方法しか無い

  16. パッケージ化されていない

  17. CLIインタフェースが無い

7-23. 名前が未定義のNodeに対する自動ネーミング (sng4onnx)

opset=8 より古いONNXのモデルにはNodeに名前が付与されていないことがあります。そういったモデルは onnx-simplifier やこのブログでご紹介したツール群でのモデル最適化に失敗することがあります。したがって、名前が付与されていないNodeに対して自動的に一意な名前を生成して書き込むツールを作成しました。そもそもONNXのモデルが古すぎることが問題であって、現時点ではあまり問題とはならないシチュエーションへの対応です。

名前を自動生成するONNXファイルと出力するファイル名を指定するだけですので細かい説明は省略します。

sng4onnx \
--input_onnx_file_path emotion-ferplus-8.onnx \
--output_onnx_file_path emotion-ferplus-8_renamed.onnx

結果は下図のとおりです。

image
image

sng4onnx の README はこちらです

7-24. doc_stringの自動クリーニング (sde4onnx)

こちらもかなり限られたシチェーションのみを想定したツールです。Hugging Faceで公開されている Stable Diffusion の vae_encoder.onnx をターゲットに作成しました。どう見てもゴミとしか思えない文字列の羅列を除去します。sergei さんの環境情報にはあまり興味がありませんので削除します。

sde4onnx \
--input_onnx_file_path vae_encoder.onnx \
--output_onnx_file_path vae_encoder_erased.onnx

image
image

sde4onnx の README はこちらです

7-25. GUIによるモデルのビジュアライズと加工 (OnnxGraphQt)

シェルやPythonのコードを書くことに抵抗を感じる方がいらっしゃるかもしれませんが、実はGUIで前述のツール群を組み合わせてモデル加工を行うためのOSSが誕生しています。 OnnxGraphQt というツールです。Netronを活用されている方々は多いと思いますが、このツールはビジュアライズをしたうえにモデル加工まで行うことができる点でアドバンテージがあります。バックエンドで私が作成したツール群をコールしていただいているため、私のツール群側のバグあるいはインタフェース不足にUI機能が制約されている可能性はあります。私のツール側、あるいは OnnxGraphQt へプルリクエストを発行してください。心よりお待ちしております。

image

前述でご紹介したようなシェルを実行する代わりにUIで直感的に操作することができるため馴染みやすいのではないかと思います。文字で説明するより画像で雰囲気をご覧ください。

  • ONNXモデルの構造把握

image

  • ディテールの確認

image

  • Nodeの検索

image

トップ画面の右側ペインに、モデル加工を行うための操作ボタンが配置されており、私が作成したCUIツールとは違って、どのような操作ができるのか がボタンに明示されています。遷移先の子画面では、モデル加工に必要なパラメータをリストから選択したり直接キーボードで入力するようになっており、モデル加工を行ううえではとてもユーザーフレンドリーな設計になっています。ご興味がある方は是非お試し下さい。

image

X. おわりに

長くなりましたが、ONNXのモデルチューニングテクニック (応用編1) は以上です。次回は 応用編2 として、今回1記事で書ききれなかったテクニックをご紹介したいと思います。なお、次回の章タイトルは変更する可能性があります。

  1. 各種PyTorchトリック

  2. 各種フレームワーク向けモデルへの変換

    • TensorFlow
    • TensorFlow.js
    • TensorRT
    • CoreML
    • OpenVINO
]]>