Tech | AI tech studio https://cyberagent.ai AI事業本部は、サイバーエージェントグループのデジタルマーケティング分野におけるサービス開発を行う組織です。 Wed, 08 Mar 2023 14:30:04 +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) は以上です。これでチューニングテクニックシリーズは完結とします。

]]>
アプリ運用センター 内定者バイト「Four Keys計測による生産性の可視化」 https://cyberagent.ai/blog/pr/culture/18301/ Tue, 20 Dec 2022 09:41:48 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=18301 はじめに

初めまして、23卒エンジニア職内定者の丸山司と申します。

2022年9月7日から1ヶ月弱、サイバーエージェントのAI事業本部にある小売DX アプリ運用センターという部署で、サーバーサイドエンジニアとして内定者としてアルバイトをさせていただきました。

私は、開発者の生産性を向上させるためのあらゆるDevOps技術に興味があり(トランクベース開発、フィーチャーフラグ、自動テスト、リリースエンジニアリング等)、上記のようなDevOps技術を1つ経験することと、現場で働く中でエンジニアとしての目標を引き直すことを目的に内定者バイトに参加しました。

この記事では、実際に行った業務と学んだことについてまとめたいていきたいと思います。

アプリ運用センターとは

 

サイバーエージェントでは現在、小売/行政/医療などのドメインにおいて様々な企業様と提携し開発を行うDX事業に取り組んでいます。

アプリ運用センター(通称アプ運)はその中でも小売事業を展開されている企業様向けにアプリ開発/データ基盤構築をはじめとした開発を行う事業部です。POSシステムやAIカメラ、ビーコンなどの多岐にわたるテクノロジーを連携し、アプリ単体ではなく購買体験全体のUXを高めるアプリ開発を実現して行きます。(詳しくはこちらでご確認ください)

取り組んだこと

今回のインターンでは、バックエンド開発チームのFour Keysの計測と可視化を行いました。

Four Keysとは

Four KeysとはDevOps Research and Assesment (DORA) チームが実施した 6 年間の研究で明らかにしたソフトウェア開発チームの健全性を示す以下の4つの指標です。[1]

  • リードタイム:組織による正常な本番環境へのリリース頻度
  • デプロイ頻度:commit から本番環境稼働までの所要時間
  • 変更失敗率:デプロイが原因で本番環境で障害が発生する割合(%)
  • 復元時間:組織が本番環境での障害から回復するのにかかる時間

DORAの研究結果によると、Four Keysは開発チームの生産性だけではなく、サービスや企業の収益にも強い正の相関があると結論づけています。[2]

また、Four Keysは、CI/CDなどのDevOpsの実践と組織の開発文化が成熟しているチームほど優れた数値となっていることが明らかにされています。Four Keysの数値をきっかけに、開発チームの文化やDevOpsの実践について振り返ることが計測の目的です。

[1] エリート DevOps チームであることを Four Keys プロジェクトで確認する | Google Cloud Blog

[2] Nicole Forsgren Ph.D 他 『Lean と DevOps の科学』

計測の概要・流れ

現状、計測可能な「デプロイの頻度」と「変更のリードタイム」を計測しました。インシデントを管理できていないため、変更の失敗率や復元時間については、計測不可能でした。(実質、Two Keysです)

  • GitHub Webhookから送信されるイベントを集計して計測
    • mainブランチへのプッシュイベント
    • GitHub Actionsのデプロイワークフロー完了イベント

計測基盤の実装

計測基盤は、OSSであるGCP/fourkeysをベースに構築しました。

  • GitHub Webhook から送信されるイベントを受け取るサーバー(Event Handler)
  • イベントをパースしてBigQueryにログを吐くサーバー(Data Parser)
  • 各指標を可視化するGrafanaダッシュボード(Dashboard)

既存のOSSをベースとして、一部修正を行いました

  • 既存のOSSでは受け付けていない、GitHub Actionsの実行イベントをBigQueryに保存する処理を追加(Data Parser)
  • Grafanaダッシュボードを社内IPからのみアクセスできるアクセス制限を追加(Dashboard)

エリート DevOps チームであることを Four Keys プロジェクトで確認する | Google Cloud Blog より拝借

アプリ運用センターならではの計測

BtoBtoCという業態に由来する長い仕様決定のリードタイムがボトルネックとなり、頻繁なリリースが現状求められていません。短期的には、継続的なインテグレーションを目的として、マージを基準とした指標も計測した方がいいと考えました。1つの修正のサイズ(PRの大きさ)を小さくすることや、コードレビューを速くすることなどにより、マージを基準とした指標を改善することができます。

計測後の運用

私が内定者バイトを終えた後、「誰もダッシュボードの見方や活用方法がわからない」「継続的に数値を振り返えることを誰もしない」という懸念がありました。そこで、Four Keysがどう重要であるかやダッシュボードの見方、計測内容から改善が必要な箇所の提案などのドキュメントとして残しました。

Four Keys自体は、明確な開発のボトルネックを見つけることはできないので、開発プロセスを改善して、メトリクスの変化を評価するという改善サイクルを継続的に行っていく必要があります。入社後に、運用・改善を任せてもらって、チームの課題と対峙したいと思います。

学び・感想

内定者ランチで学びがたくさん

サイバーエージェントには、内定者バイトの学生と先輩エンジニアがランチに行って、技術や社内のことなどざっくばらんに話す文化があります。今回のインターンでは、AI事業本部含めて、社内の多くの先輩エンジニアとランチに行って、記事や書籍では知ることのできない技術的なお話をたくさんすることができました。

特に、私が最近気になっている技術・考え方であるトランクベース開発を実践されている江頭さんに、たくさん質問をさせていただきました。トランクベース開発は開発のアウトカムを向上させるプラクティスですが、多くの技術が下支えしていることを学びました。

技術的な広がり

ソフトウェアの設計やコード規約などのチーム課題、CI/CDの実現、リリースエンジニアリングなど、さまざまな技術が開発の効率を上げるために地続きでつながっていると感じました。

例えば、トランクベース開発で目的とする「重要なデプロイの心理的ハードルを下げる」ためには、Progressive Deliveryなどのリリースエンジニアリングを充実させたり、自動テストの適用範囲を広げたりなど密接に関わっています。ソフトウェアエンジニアとして大成するためには、繋がりを意識しつつ、技術習得をしていくことが大事だと感じました。

技術以外の部分・小売DXの面白さ

今回のFour Keysの計測をする中で、開発の生産性は技術的な部分に限らないことも感じました。例えば、企業様とやり取りをする業態の場合、企業様とどう仕様をすり合わせるかといった部分が開発のアウトカムを向上させる上で重要となります。

開発チームのマネージャーである後藤さんとお話した際に、「BtoBtoCという業態でのアジャイルを実現したい」とおっしゃっていて、小売DXドメインの面白さを垣間見ました。(後藤さんのインタビュー記事でも同様の内容をおっしゃられています)

最後に

アプリ運用センターはエンジニアの挑戦を歓迎している雰囲気があり、今回少し難しい課題でも挑戦させていただきました。また、技術習得にも積極的で、コミュニケーションもとりやすく、非常に働きやすい環境でした。メンターの坂上さんをはじめ、先輩の社員の方のサポートのおかげで、充実した1ヶ月を過ごすことができました。短い間でしたがお世話になりました!ありがとうございました!

]]>
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
]]>
ONNXモデルのチューニングテクニック (基礎編) https://cyberagent.ai/blog/tech/17300/ Tue, 15 Nov 2022 05:59:43 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=17300 基礎編 / 応用編1 / 応用編2

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

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

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

1. このブログに記載すること

  • ONNXモデル生成の基礎的な方法
  • ONNXモデル生成の応用的な方法
  • ONNXモデルの簡易的な推論方法
  • ONNXモデルの簡易的なベンチマーク方法
  • ONNXモデルのチューニング方法
  • ONNXモデルのフレームワーク間コンバートのテクニック

2. このブログに記載しないこと

  • ONNXとは何か
  • モデルのトレーニング
  • 数学的な演算ステップの効率化
  • ランタイムに最適化したカスタムオペレーションの実装
  • ONNX推論高速化のためのロジック側の工夫
  • ONNX推論高速化のための onnxruntime / onnxruntime-gpu のパラメータチューニング
  • ONNX以外のフレームワークの特徴
  • ONNX以外のフレームワークを使用した推論方法
  • ONNX以外のフレームワークへ転用したあとの推論の高速化手法

3. このブログに記載のソースコードを動かすための環境

このブログ内で使用する環境は下記のとおりです。HostPCの環境を壊さないために、 Docker や Google Colaboratory を活用するとトラブルなく試すことができます。 Anaconda は非推奨です。

3-1. Docker を使用して試す場合

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

pip install torch torchvision torchaudio \
  --extra-index-url https://download.pytorch.org/whl/cpu

pip install onnxruntime==1.13.1

3-2. Google Colaboratory を使用して試す場合

!sudo add-apt-repository -y ppa:deadsnakes/ppa
!sudo apt-get -y update
!sudo apt-get -y install python3.9
!sudo apt-get -y install python3.9-dev
!sudo apt-get -y install python3-pip
!sudo apt-get -y install python3.9-distutils
!python3.9 -m pip install -U setuptools \
  && python3.9 -m pip install -U pip \
  && python3.9 -m pip install -U distlib
!sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.7 1
!sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 2
!python3.9 -m pip install tensorflow==2.10.0 \
  && python3.9 -m pip install -U onnx \
  && python3.9 -m pip install -U onnxruntime==1.13.1 \
  && python3.9 -m pip install -U nvidia-pyindex \
  && python3.9 -m pip install -U onnx-graphsurgeon \
  && python3.9 -m pip install -U onnxsim \
  && python3.9 -m pip install -U simple_onnx_processing_tools \
  && python3.9 -m pip install -U onnx2tf \
  && python3.9 -m pip install torch torchvision torchaudio \
    --extra-index-url https://download.pytorch.org/whl/cpu

3-3. HostPC を使用して試す場合 (PyTorchのバージョンは1.12.0以上であればどのバージョンでも問題ありません、GPUは不要です)

# CPU
pip install -U onnx \
&& pip install -U nvidia-pyindex \
&& pip install -U onnx-graphsurgeon \
&& pip install -U onnxsim \
&& pip install -U onnxruntime==1.13.1 \
&& pip install -U simple_onnx_processing_tools \
&& pip install -U onnx2tf \
&& pip install -U tensorflow==2.10.0 \
&& pip install -U torch torchvision torchaudio \
    --extra-index-url https://download.pytorch.org/whl/cpu

or

# CUDA 11.6 version
pip install -U onnx \
&& pip install -U nvidia-pyindex \
&& pip install -U onnx-graphsurgeon \
&& pip install -U onnxsim \
&& pip install -U onnxruntime-gpu==1.13.1 \
&& pip install -U simple_onnx_processing_tools \
&& pip install -U onnx2tf \
&& pip install -U tensorflow==2.10.0 \
&& pip install -U torch torchvision torchaudio \
    --extra-index-url https://download.pytorch.org/whl/cu116

4. モデル生成の基礎 [PyTorch -> ONNX]

良く知られたONNXを生成するための基本的なワークフローは、PyTorch で生成したモデルを torch.onnx.export(model, ...) あるいは torch.onnx.export(torch.jit.script(model), ...) で出力する方法です。改めてこのブログで説明するまでもない内容ではありますが、以降の章で説明する内容の前提知識として簡単に触れておきます。

ONNXを生成するために必要となる最低限の段取りは2つだけです。

  1. PyTorch でモデルを定義する
  2. PyTorch の torch.onnx.export(...) を実行してONNXファイルを生成する

PyTorch を使用して検証用の簡易的な1層モデルを生成します。複雑なモデルをサンプルとして生成するのは手間が掛かりますので、このブログでは LayerNormalization というオペレーションを1個だけ生成する PyTorch のソースコードを作成します。関数などでロジックを共通化したりせずプレーンに記載しています。モデルのトレーニングをすることを主眼としたブログではないため、本来は1.と2.の作業の間に モデルのトレーニング が必要であることにご注意願います。生成済みのモデルをチューニングする、という観点においては、モデルをトレーニングする、ということに関するテクニックはほとんど不要です。重みを微調整するのではなく、演算の順序や構成を組み替えて最適化することに主眼を置くためです。

from onnxsim import simplify は onnx-simplifier というONNXのモデル構造を機械的に最適化してくれるとても便利なツールをインポートしています。この章以降も頻繁に活用するため、あらかじめ作業用の端末にインストールしておく必要があります。

  • make_LayerNormalization.py

    #! /usr/bin/env python
    
    import torch
    import torch.nn as nn
    import onnx
    from onnxsim import simplify
    import numpy as np
    np.random.seed(0)
    
    class LayerNormalization(nn.Module):
        def __init__(
            self,
            embedding_dim,
            weight,
            bias,
        ):
            super(LayerNormalization, self).__init__()
            self.embedding_dim = embedding_dim
            self.weight = weight
            self.bias = bias
    
        def forward(self, x):
            layernormed = nn.functional.layer_norm(
                input=x,
                normalized_shape=self.embedding_dim,
                weight=self.weight,
                bias=self.bias,
                eps=1e-05,
            )
            return layernormed
    
    if __name__ == "__main__":
        OPSET = [11, 17]
        MODEL = f'LayerNormalization2D'
        N, C, H, W = 20, 5, 10, 10
    
        for opset in OPSET:
            embedding_dim = [C, H, W]
            embedding_dim_tensor = torch.zeros(
                size=[C, H, W],
            )
            input = torch.randn(N, C, H, W)
            model = LayerNormalization(
                embedding_dim=embedding_dim,
                weight=torch.tensor(
                    data=torch.full_like(
                        input=embedding_dim_tensor,
                        fill_value=0.1,
                    ),
                    dtype=torch.float32,
                ),
                bias=torch.tensor(
                    data=torch.full_like(
                        input=embedding_dim_tensor,
                        fill_value=0.2,
                    ),
                    dtype=torch.float32,
                ),
            )
            onnx_file = f"{MODEL}_{opset}.onnx"
            torch.onnx.export(
                model,
                args=(input),
                f=onnx_file,
                opset_version=opset,
                input_names=[
                    f'{MODEL}_input',
                ],
                output_names=[
                    f'{MODEL}_output',
                ],
            )
            model_onnx1 = onnx.load(onnx_file)
            model_onnx1 = onnx.shape_inference.infer_shapes(model_onnx1)
            onnx.save(model_onnx1, onnx_file)
            model_onnx2 = onnx.load(onnx_file)
            model_simp, check = simplify(model_onnx2)
            onnx.save(model_simp, onnx_file)

作成したソースコードを実行して1層のみのONNXを生成します。

python make_LayerNormalization.py

LayerNormalization2D_11.onnx と LayerNormalization2D_17.onnx という2種類のONNXファイルが生成されました。これは、プログラム中の opset_version=opset の箇所でONNXのopsetバージョン番号に 11 と 17 を指定して2回実行しているためです。opset とは、ONNXのオペレーションのサブセットのようなものです。指定したバージョンごとに使用可能なオペレーションの種類が異なります。高いバージョンを指定すればするほど複数の処理をひとつのオペレーションに融合した効率的なオペレーションが割り当てられることがあります。詳しくはONNXの公式リポジトリにコミットされている こちら Operators.md の一覧をご覧ください。opsetごとに使用できるオペレーションに差が有ることが分かります。

image

では、opset=11 と opset=17 のそれぞれのバージョンで生成したONNXファイルにどのような差が生じたかをご覧ください。どちらも LayerNormalization の処理であり、入力の値が同じであれば出力の値は同じになります。従って、左の opset=11 で生成したプリミティブな演算の組み合わせを順次実行する処理が右の opset=17 で生成したたったひとつのオペレーションに融合していることが分かります。ONNXのモデルを onnxruntime という実行フレームワークへ読み込ませて実行したときには opset=11 であっても opset=17 であっても基本的には同じ振る舞いをします。この章は基本の章ですので詳細は割愛して後続の章で説明しますが、高いバージョン番号のopsetを指定してモデルの構造を綺麗にまとめたほうがメリットが大きいように見えるかもしれませんが、ONNX以外のフレームワークやTPUやMyriadなどの推論専用ハードウェアアクセラレータへモデルを転用するときに問題が生じることが多いです。そのため、あえて少しだけ古いopsetを指定してプリミティブな演算で構成されたオペレーションに変換しておく、というテクニックが活きるときがあります。実はこのテクニックが活きるシチュエーションはとても多いです。というのも、ハードウェアアクセラレータ側のランタイムのバージョンアップがONNX側の仕様改善のスピードに全く追いつけないからです。最たる例はTensorRTです。

  • Netron でモデル構造を可視化

    Opset=11 Opset=17
    image image

では、生成された LayerNormalization2D_11.onnx と LayerNormalization2D_17.onnx が正しく動作することを確認してみます。しかしながら、検証用の推論コードは 書きません。ONNXの動作を簡易的に検証するためのツールを自作していますのでそちらを利用して動作を確認します。3章の手順を正しく行って環境構築が終わっている場合はすでにそのツールが導入済みの状態になっています。ONNXの簡易動作検証用のツールは sit4onnx と名付けてパブリックに公開しています。
image

使い方は下記のとおりです。ヘルプメッセージは全て英語で記述していますので、READMEの日本語チュートリアル化のプルリクエストを歓迎します。

  • コマンドラインインタフェース

    sit4onnx -h
    
    usage: sit4onnx
    [-h]
    -if INPUT_ONNX_FILE_PATH
    [-b BATCH_SIZE]
    [-fs FIXED_SHAPES [FIXED_SHAPES ...]]
    [-tlc TEST_LOOP_COUNT]
    [-oep {tensorrt,cuda,openvino_cpu,openvino_gpu,cpu}]
    [-ifp INPUT_NUMPY_FILE_PATHS_FOR_TESTING]
    [-ofp]
    [-n]
    
    optional arguments:
      -h, --help
        show this help message and exit.
    
      -if INPUT_ONNX_FILE_PATH, --input_onnx_file_path INPUT_ONNX_FILE_PATH
        Input onnx file path.
    
      -b BATCH_SIZE, --batch_size BATCH_SIZE
        Value to be substituted if input batch size is undefined.
        This is ignored if the input dimensions are all of static size.
        Also ignored if input_numpy_file_paths_for_testing or
        numpy_ndarrays_for_testing or fixed_shapes is specified.
    
      -fs FIXED_SHAPES [FIXED_SHAPES ...], --fixed_shapes FIXED_SHAPES [FIXED_SHAPES ...]
        Input OPs with undefined shapes are changed to the specified shape.
        This parameter can be specified multiple times depending on
        the number of input OPs in the model.
        Also ignored if input_numpy_file_paths_for_testing or
        numpy_ndarrays_for_testing is specified.
        e.g.
        --fixed_shapes 1 3 224 224 \
        --fixed_shapes 1 5 \
        --fixed_shapes 1 1 224 224
    
      -tlc TEST_LOOP_COUNT, --test_loop_count TEST_LOOP_COUNT
        Number of times to run the test.
        The total execution time is divided by the number of times the test is executed,
        and the average inference time per inference is displayed.
    
      -oep {tensorrt,cuda,openvino_cpu,openvino_gpu,cpu}, \
        --onnx_execution_provider {tensorrt,cuda,openvino_cpu,openvino_gpu,cpu}
    
        ONNX Execution Provider.
    
      -ifp INPUT_NUMPY_FILE_PATHS_FOR_TESTING, \
        --input_numpy_file_paths_for_testing INPUT_NUMPY_FILE_PATHS_FOR_TESTING
    
        Use an external file of numpy.ndarray saved using np.save as input data for testing.
        This parameter can be specified multiple times depending on
        the number of input OPs in the model.
        If this parameter is specified, the value specified for
        batch_size and fixed_shapes are ignored.
        e.g.
        --input_numpy_file_paths_for_testing aaa.npy \
        --input_numpy_file_paths_for_testing bbb.npy \
        --input_numpy_file_paths_for_testing ccc.npy
    
      -ofp, --output_numpy_file
        Outputs the last inference result to an .npy file.
    
      -n, --non_verbose
        Do not show all information logs. Only error logs are displayed.

  • Pythonスクリプトから利用する場合

    >>> from sit4onnx import inference
    >>> help(inference)
    
    Help on function inference in module sit4onnx.onnx_inference_test:
    
    inference(
      input_onnx_file_path: str,
      batch_size: Union[int, NoneType] = 1,
      fixed_shapes: Union[List[int], NoneType] = None,
      test_loop_count: Union[int, NoneType] = 10,
      onnx_execution_provider: Union[str, NoneType] = 'tensorrt',
      input_numpy_file_paths_for_testing: Union[List[str], NoneType] = None,
      numpy_ndarrays_for_testing: Union[List[numpy.ndarray], NoneType] = None,
      output_numpy_file: Union[bool, NoneType] = False,
      non_verbose: Union[bool, NoneType] = False
    ) -> List[numpy.ndarray]
    
        Parameters
        ----------
        input_onnx_file_path: str
            Input onnx file path.
    
        batch_size: Optional[int]
            Value to be substituted if input batch size is undefined.
            This is ignored if the input dimensions are all of static size.
            Also ignored if input_numpy_file_paths_for_testing or
            numpy_ndarrays_for_testing is specified.
            Default: 1
    
        fixed_shapes: Optional[List[int]]
            Input OPs with undefined shapes are changed to the specified shape.
            This parameter can be specified multiple times depending on the number of input OPs
            in the model.
            Also ignored if input_numpy_file_paths_for_testing or numpy_ndarrays_for_testing
            is specified.
            e.g.
                [
                    [1, 3, 224, 224],
                    [1, 5],
                    [1, 1, 224, 224],
                ]
            Default: None
    
        test_loop_count: Optional[int]
            Number of times to run the test.
            The total execution time is divided by the number of times the test is executed,
            and the average inference time per inference is displayed.
            Default: 10
    
        onnx_execution_provider: Optional[str]
            ONNX Execution Provider.
            "tensorrt" or "cuda" or "openvino_cpu" or "openvino_gpu" or "cpu"
            Default: "tensorrt"
    
        input_numpy_file_paths_for_testing: Optional[List[str]]
            Use an external file of numpy.ndarray saved using np.save as input data for testing.
            If this parameter is specified, the value specified for batch_size and fixed_shapes
            are ignored.
            numpy_ndarray_for_testing Cannot be specified at the same time.
            For models with multiple input OPs, specify multiple numpy file paths in list format.
            e.g. ['aaa.npy', 'bbb.npy', 'ccc.npy']
            Default: None
    
        numpy_ndarrays_for_testing: Optional[List[np.ndarray]]
            Specify the numpy.ndarray to be used for inference testing.
            If this parameter is specified, the value specified for batch_size and fixed_shapes
            are ignored.
            input_numpy_file_paths_for_testing Cannot be specified at the same time.
            For models with multiple input OPs, specify multiple numpy.ndarrays in list format.
            e.g.
            [
                np.asarray([[[1.0],[2.0],[3.0]]], dtype=np.float32),
                np.asarray([1], dtype=np.int64),
            ]
            Default: None
    
        output_numpy_file: Optional[bool]
            Outputs the last inference result to an .npy file.
            Default: False
    
        non_verbose: Optional[bool]
            Do not show all information logs. Only error logs are displayed.
            Default: False
    
        Returns
        -------
        final_results: List[np.ndarray]
            Last Reasoning Results.

起動オプションが豊富過ぎて少々混乱するかもしれませんが、手軽にONNXの動作を確認するために作成したツールですので最低限指定すべきオプションは -if と -oep の2つのみです。-if で動作確認したいONNXファイルを指定し、-oep で ランタイム(エグゼキューションプロバイダ) を選びます。-oep cpu という表記はCPU上でONNX推論を行うことを表し、TensorRTで推論したいときは -oep tensorrt、CUDAで推論したいときは -oep cuda と指定します。CPU推論で動作確認をするときのコマンドは下記のとおりです。

sit4onnx -if LayerNormalization2D_11.onnx -oep cpu

実行結果は下記の通りです。sit4onnx の内部でONNXのモデル構造を読み取り、入力OPの形状から自動的にダミーのテスト用テンソルを生成してパフォーマンスを計測します。デフォルトは内部で11回ループし、初回ループの計測結果を捨てて、残りの10回分の推論パフォーマンスを計測しています。初回ループの計測結果を捨てているのは、初回モデルのロード時のみ前処理のオーバーヘッドの影響を大きく受けてパフォーマンスが著しく悪化するため、前段のオーバーヘッド部分を除いた推論のパフォーマンスのみを抽出しています。一部界隈では初回オーバーヘッドのことをウォームアップと呼んでいるようです。ベンチマーク結果の単位時間はミリ秒です。

INFO: file: LayerNormalization2D_11.onnx
INFO: providers: ['CPUExecutionProvider']
INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  0.3662109375 ms
INFO: avg elapsed time per pred:  0.03662109375 ms
INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

コンソールの表示イメージです。
image

ちなみに、TensorRT や CUDA で推論のベンチマークを行いたい場合は、3章で導入した各種パッケージのうち、onnxruntime あるいは onnxruntime-gpu をTensorRTバックエンドを有効な状態にしてカスタムビルドしたパッケージに差し替える必要があります。ご自身の環境に合う TensorRT対応 onnxruntime-gpu のインストーラ生成の方法は下記のとおりです。

注意点として、TensorRT 8.4.0 より新しいバージョンが導入された環境の場合、FP16の演算精度が著しく劣化することがある問題を確認しています。従って、私のHostPC上のTensorRTバージョンは 8.4.0 EA という少し古いバージョンを導入しています。

  • Ubuntu 20.04上でのインストーラ生成サンプルコマンド

    git clone https://github.com/microsoft/onnxruntime.git \
    && cd onnxruntime
    git checkout 49d7050b88338dd57839159aa4ce8fb0c199b064
    
    dpkg -l | grep TensorRT
    
    ii graphsurgeon-tf        8.4.0-1+cuda11.6   amd64 GraphSurgeon for TensorRT package
    ii libnvinfer-bin         8.4.0-1+cuda11.6   amd64 TensorRT binaries
    ii libnvinfer-dev         8.4.0-1+cuda11.6   amd64 TensorRT development libraries and headers
    ii libnvinfer-doc         8.4.0-1+cuda11.6   all   TensorRT documentation
    ii libnvinfer-plugin-dev  8.4.0-1+cuda11.6   amd64 TensorRT plugin libraries
    ii libnvinfer-plugin8     8.4.0-1+cuda11.6   amd64 TensorRT plugin libraries
    ii libnvinfer-samples     8.4.0-1+cuda11.6   all   TensorRT samples
    ii libnvinfer8            8.4.0-1+cuda11.6   amd64 TensorRT runtime libraries
    ii libnvonnxparsers-dev   8.4.0-1+cuda11.6   amd64 TensorRT ONNX libraries
    ii libnvonnxparsers8      8.4.0-1+cuda11.6   amd64 TensorRT ONNX libraries
    ii libnvparsers-dev       8.4.0-1+cuda11.6   amd64 TensorRT parsers libraries
    ii libnvparsers8          8.4.0-1+cuda11.6   amd64 TensorRT parsers libraries
    ii onnx-graphsurgeon      8.4.0-1+cuda11.6   amd64 ONNX GraphSurgeon for TensorRT package
    ii python3-libnvinfer     8.4.0-1+cuda11.6   amd64 Python 3 bindings for TensorRT
    ii python3-libnvinfer-dev 8.4.0-1+cuda11.6   amd64 Python 3 development package for TensorRT
    ii tensorrt               8.4.0.6-1+cuda11.6 amd64 Meta package of TensorRT
    ii uff-converter-tf       8.4.0-1+cuda11.6   amd64 UFF converter for TensorRT package
    
    sudo chmod +x build.sh
    sudo pip install cmake --upgrade
    
    ./build.sh \
    --config Release \
    --cudnn_home /usr/lib/x86_64-linux-gnu/ \
    --cuda_home /usr/local/cuda \
    --use_tensorrt \
    --use_cuda \
    --tensorrt_home /usr/src/tensorrt/ \
    --enable_pybind \
    --build_wheel \
    --parallel $(nproc) \
    --skip_tests
    
    find . -name "*.whl"
    ./build/Linux/Release/dist/onnxruntime_gpu-1.12.0-cp38-cp38-linux_x86_64.whl
    
    pip install ./build/Linux/Release/dist/onnxruntime_gpu-*.whl

    sit4onnx -if LayerNormalization2D_11.onnx -oep tensorrt

あるいは、TensorRT やその他の主要MLフレームワークを全てカスタムビルドして導入済みの巨大Docker Imageをダウンロードして利用する方法もあります。23GBほどあります。

  • 主要MLフレームワーク全部載せDockerコンテナの起動サンプル

    docker pull ghcr.io/pinto0309/openvino2tensorflow:latest
    
    docker run --gpus all -it --rm \
    -v `pwd`:/home/user/workdir \
    ghcr.io/pinto0309/openvino2tensorflow:latest

    sit4onnx -if LayerNormalization2D_11.onnx -oep tensorrt

TensorRTを使用したベンチマーク結果は下記のとおりです。ONNXファイルを TRT Engine という形式にコンパイルする動作が実行されるため、ベンチマークがスタートするまでに1分ほど待つ必要があります。

INFO: file: LayerNormalization2D_11.onnx
INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  0.7319450378417969 ms
INFO: avg elapsed time per pred:  0.07319450378417969 ms
INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

演算精度はFP16ですが、CPUのほうが2倍ほど速いようです。
CUDAではどうでしょうか。

sit4onnx -if LayerNormalization2D_11.onnx -oep cuda

INFO: file: LayerNormalization2D_11.onnx
INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
INFO: test_loop_count: 10
INFO: total elapsed time:  0.7827281951904297 ms
INFO: avg elapsed time per pred:  0.07827281951904297 ms
INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

ほとんど性能差は出ませんでした。
比較がしにくいため、CPUとTensorRTとCUDAのベンチマーク結果を並べてみます。

  • CPU – Intel® Core™ i9-10900K CPU @ 3.70GHz 最大20スレッド

    INFO: file: LayerNormalization2D_11.onnx
    INFO: providers: ['CPUExecutionProvider']
    INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  0.3662109375 ms
    INFO: avg elapsed time per pred:  0.03662109375 ms
    INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

  • TensorRT 8.4.0 + CUDA 11.6 – RTX3070

    INFO: file: LayerNormalization2D_11.onnx
    INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  0.7319450378417969 ms
    INFO: avg elapsed time per pred:  0.07319450378417969 ms
    INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

  • CUDA 11.6 – RTX3070

    INFO: file: LayerNormalization2D_11.onnx
    INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
    INFO: test_loop_count: 10 INFO: total elapsed time:  0.7827281951904297 ms
    INFO: avg elapsed time per pred:  0.07827281951904297 ms
    INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

プリミティブな演算のみで構成された opset=11 の LayerNormalization を演算する場合は、私のHostPC上ではCPUのほうが速いようです。ただし、今回はFP16の精度のみでTensorRTの演算速度を検証したため、Int8へ量子化した場合は最大で 2.5倍 ほど速くなる可能性はあります。

念の為、opset=17 で生成した LayerNormalization でも比較しておきます。

  • CPU – Intel® Core™ i9-10900K CPU @ 3.70GHz 最大20スレッド

    INFO: file: LayerNormalization2D_17.onnx
    INFO: providers: ['CPUExecutionProvider']
    INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  0.4291534423828125 ms
    INFO: avg elapsed time per pred:  0.04291534423828125 ms
    INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

  • TensorRT 8.4.0 + CUDA 11.6 – RTX3070

    2022-11-10 06:55:15.182974909 [W:onnxruntime:Default, tensorrt_execution_provider.cc:1046 GetCapability] [TensorRT EP] No graph will run on TensorRT execution provider
    2022-11-10 06:55:15.949520352 [W:onnxruntime:Default, tensorrt_execution_provider.h:60 log] [2022-11-10 06:55:15 WARNING] external/onnx-tensorrt/onnx2trt_utils.cpp:367: Your ONNX model has been generated with INT64 weights, while TensorRT does not natively support INT64. Attempting to cast down to INT32.
    INFO: file: LayerNormalization2D_17.onnx
    INFO: providers: ['TensorrtExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
    EP Error: [ONNXRuntimeError] : 11 : EP_FAIL : Non-zero status code returned while running TRTKernel_graph_torch_jit_2590721683996607622_1 node. Name:'TensorrtExecutionProvider_TRTKernel_graph_torch_jit_2590721683996607622_1_0' Status Message: TensorRT EP output tensor data type: 0 not supported. using ['TensorrtExecutionProvider', 'CPUExecutionProvider']
    Falling back to ['CUDAExecutionProvider', 'CPUExecutionProvider'] and retrying.
    INFO: test_loop_count: 10
    INFO: total elapsed time:  1.1851787567138672 ms
    INFO: avg elapsed time per pred:  0.11851787567138672 ms
    INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

  • CUDA 11.6 – RTX3070

    INFO: file: LayerNormalization2D_17.onnx
    INFO: providers: ['CUDAExecutionProvider', 'CPUExecutionProvider']
    INFO: input_name.1: LayerNormalization2D_input shape: [20, 5, 10, 10] dtype: float32
    INFO: test_loop_count: 10
    INFO: total elapsed time:  1.1889934539794922 ms
    INFO: avg elapsed time per pred:  0.11889934539794922 ms
    INFO: output_name.1: LayerNormalization2D_output shape: [20, 5, 10, 10] dtype: float32

CPU推論が遅くなりましたね。TensorRT 8.4.0 に至っては EP Error が発生し、結果として CUDA にフォールバックしてしまいました。CUDAもCPUとは比べ物にならないほど遅いようです。特定のTensorRTのバージョンとCUDAのバージョンの組み合わせではオペレーションの対応状況が異なることが想定されますので上記のベンチマーク結果が全てではありませんし、最新のバージョンの組み合わせであれば期待通りのベンチマークが出る可能性があります。

さて、このベンチマークはほとんど意味のない比較であることをご理解願います。LayerNormalization 単体で構成されたモデルなど実用的なモデルではありません。そもそも真面目に推論速度を比較することがこの章でご紹介したかったことの本質ではありません。あくまで、簡易的にモデルを生成し、簡易的に推論のベンチマークを行う手段をご紹介したに過ぎません。ベンチマークプログラムを動かすにあたってどこがスループットのボトルネックになっているかまでは一切未検証です。もしかしたら、GPUへのテンソルの転送コストがネックになって全体の処理パフォーマンスが遅いように見えているだけかもしれません。従いまして、実用的な構成のモデルでベンチマークをとってみることのほうが重要です。

5. モデル生成の基礎 [ONNXのみ]

この章では、ONNXのモデル構造の基礎、PyTorchを経由せずにONNXのオペレーションおよびモデル構造を生成する方法をご紹介します。

5-1. ONNXモデルの構造

モデルの動作を微調整するためだけにPyTorchをインストールした環境を用意したくありません。CUDAのバージョンアンマッチや古いバージョンのPyTorchを動作させる環境を整えることにとても手間が掛かることがあります。ただモデルの微調整をする用途のためだけに用意するPyTorchは機能過剰です。すでに手元にあるONNXファイルを少し調整してフローを変えたり、演算の一部を別の演算に置き換えたりしたいときがあります。世界各国のリサーチャーの趣味嗜好が入り混じったPyTorchのソースコードをできるだけ読みたくないのです。他の方々が書いたソースコードを解析するのは非常にコストが掛かります。皆さんがどれほどそういった感覚を持たれるかどうかは分かりませんが、ちょっとした調整をする場合は構造的に綺麗に整理された状態のONNXファイルをNetronで眺めながら課題となる箇所を特定するほうがはるかに手間が少ないときがあります。

したがって、PyTorchに一切頼らずにONNXを直接生成することを考えますが、直接生成するためにはONNXの構造をまず知る必要があります。構造に関しては公式リポジトリにとても詳細な説明があります。Extensible computation graph model 情報量がとても多く理解するのがとても大変ですので、ONNXモデルを生成したりチューニングする際によく注目するポイントなどを絞ってご紹介します。

では、前章で生成した LayerNormalization をもとにして構造を解析してみます。使用するモデルは opset=17 で生成した LayerNormalization です。

  • モデル全体の定義

    image

  • LayerNormalization の定義

    image

まずは我々人間が構造を把握しやすいようにONNXファイルをJSONに変換してみます。ただ、JSON化するためにわざわざ変換プログラムを書きたくありませんので、自作のツール onnx2json を使用します。
image

  • コマンドラインインタフェース

    onnx2json -h
    
    usage:
      onnx2json [-h]
      -if INPUT_ONNX_FILE_PATH
      -oj OUTPUT_JSON_PATH
      [-i JSON_INDENT]
    
    optional arguments:
      -h, --help
          show this help message and exit
    
      -if INPUT_ONNX_FILE_PATH, --input_onnx_file_path INPUT_ONNX_FILE_PATH
          Input ONNX model path. (*.onnx)
    
      -oj OUTPUT_JSON_PATH, --output_json_path OUTPUT_JSON_PATH
          Output JSON file path (*.json) If not specified, no JSON file is output.
    
      -i JSON_INDENT, --json_indent JSON_INDENT
          Number of indentations in JSON. (default=2)

  • Pythonスクリプトから利用する場合

    >>> from onnx2json import convert
    >>> help(convert)
    
    Help on function convert in module onnx2json.onnx2json:
    
    convert(
      input_onnx_file_path: Union[str, NoneType] = '',
      onnx_graph: Union[onnx.onnx_ml_pb2.ModelProto, NoneType] = None,
      output_json_path: Union[str, NoneType] = '',
      json_indent: Union[int, NoneType] = 2
    )
    
        Parameters
        ----------
        input_onnx_file_path: Optional[str]
            Input onnx file path.
            Either input_onnx_file_path or onnx_graph must be specified.
            Default: ''
    
        onnx_graph: Optional[onnx.ModelProto]
            onnx.ModelProto.
            Either input_onnx_file_path or onnx_graph must be specified.
            onnx_graph If specified, ignore input_onnx_file_path and process onnx_graph.
    
        output_onnx_file_path: Optional[str]
            Output onnx file path. If not specified, no ONNX file is output.
            Default: ''
    
        json_indent: Optional[int]
            Number of indentations in JSON.
            Default: 2
    
        Returns
        -------
        onnx_json: dict
            Converted JSON dict.

3章の環境構築を正しく行っている場合はすでにインストール済みです。ONNXをJSONへ変換するコマンドは下記のとおりです。

onnx2json -if LayerNormalization2D_17.onnx -oj LayerNormalization2D_17.json

image

生成されたJSONファイルをVSCodeなどのIDEで開いて内容を確認してみます。rawData の部分のみ省略してJSON全体を転記したものが下記です。特に説明をしなくてもなんとなく雰囲気がつかめるレベルのシンプルさだと思います。直接書き換えてONNXファイルへ戻すことも可能です。

{
  "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"
        ],
        "dataType": 1,
        "name": "/Constant_output_0",
        "rawData": "zczMPc3MzD3NzM...Mw9zczMPc3MzD0="
      },
      {
        "dims": [
          "5",
          "10",
          "10"
        ],
        "dataType": 1,
        "name": "/Constant_1_output_0",
        "rawData": "zcxMPs3MTDNzEw...zw+zcxMPs3MTD4="
      }
    ],
    "input": [
      {
        "name": "LayerNormalization2D_input",
        "type": {
          "tensorType": {
            "elemType": 1,
            "shape": {
              "dim": [
                {
                  "dimValue": "20"
                },
                {
                  "dimValue": "5"
                },
                {
                  "dimValue": "10"
                },
                {
                  "dimValue": "10"
                }
              ]
            }
          }
        }
      }
    ],
    "output": [
      {
        "name": "LayerNormalization2D_output",
        "type": {
          "tensorType": {
            "elemType": 1,
            "shape": {
              "dim": [
                {
                  "dimValue": "20"
                },
                {
                  "dimValue": "5"
                },
                {
                  "dimValue": "10"
                },
                {
                  "dimValue": "10"
                }
              ]
            }
          }
        }
      }
    ]
  },
  "opsetImport": [
    {
      "domain": "",
      "version": "17"
    }
  ]
}

  • 各値の意味 – 公式の仕様に書かれていない情報も追記

    Key Value
     irVersion ONNXのIRフォーマットのバージョン番号
     producerName モデルを生成するために使用したツールの名前
     producerVersion モデルを生成するために使用したツールのバージョン
     graph モデルのグラフ本体の定義

    Key Value
     node オペレーションの入出力の接続関係をリスト形式で表現
     name グラフの名前
     initializer オペレーションに含まれる定数、Convの重みやテンソルの形状

    Key Value
     dims 定数の形状、Numpyのndarrayの各次元のサイズ
     dataType 定数のデータ型、inputのelemTypeを参照
     name 定数の名前
     rawData Float32やFloat16、Int8などの各種精度の
    Numpy.ndarrayがBase64 エンコードされた値
    Float32:[1,2,3,4] AACAPwAAAEAAAEBAAACAQA==
    Int64:[1,2] AQAAAAAAAAACAAAAAAAAAA==
     input 入力変数、一部省略

    Key Value
     elemType 変数のデータ型

    Type Values Type Values
     float16 10 int8 3
     float32 1 int16 5
     float64 11 int32 6
     bool 9 int64 7
     uint8 2  
     uint16 4
     uint32 12
     uint64 13
     dim dimValue: 数値、固定形状
    dimParam: 文字列、可変形状
     output 出力変数、’input’と同じ
     opsetImport opsetのドメイン名とopsetのバージョン番号

    Key Value
     domain ドメイン名
     version バージョン番号

    ドメイン名は
    ai.onnx vXX
    com.microsoft v1
    com.microsoft.nchwc v1
    ai.onnx.training v1
    ai.onnx.preview.training v1
    com.microsoft.experimental v1
    など

ONNXのJSONを手で書き換えて保存し、再びONNXファイルへ戻すテクニックは後続の章で取り上げます。ちなみに、Numpy.ndarrayをBase64エンコードした値を知りたい場合は sed4onnx を使用すると簡単です。
image

sed4onnx --constant_string [1,2] --dtype int64 --mode encode
AQAAAAAAAAACAAAAAAAAAA==

sed4onnx --constant_string AQAAAAAAAAACAAAAAAAAAA== --dtype int64 --mode decode
[1,2]

畳み込みの重みやバイアス、Normalizationのバイアスなどは全てこのルールに則って参照・書き換えが可能です。

なお捕捉ですが、dimValue と dimParam の用法は下記のイメージです。例えば、バッチサイズが可変の定義 N のときは "dimParam: "N" となります。dimParam が指定された形状部分は未定義の次元として任意の文字列を指定することができます。サンプルは N を例示しましたが、batch_size でも良いですし、abcdefg でも -1 でも問題ありません。指定された値は全てただの表示用の文字列として扱われる仕様です。dimValue を使用する場合は正の整数のみ指定可能です。従って、-1 は文字列扱いとなりますので指定できません。

"input": [
      {
        "name": "Abs_input",
        "type": {
          "tensorType": {
            "elemType": 1,
            "shape": {
              "dim": [
                {
                  "dimParam": "N"
                },
                {
                  "dimValue": "100"
                }
              ]
            }
          }
        }
      }
    ],

image

実は、ココまでの基本が押さえられていればJSONをIDEで手書きしてONNXモデルをフルスクラッチで生成することが可能です。ただ、現実的にはJSONをゼロから書き上げるのは辛いですので、すでに手元にあるONNXファイルを微改造する程度のシチュエーションのほうが多いかもしれません。また運用上、モデルのチューニングを自動化するなどの要件がある場合は手書きはナンセンスですので、後続の章でご紹介するようなモデル加工用のツールをシェルで連続的に実行して自動化するのが現実的です。

5-2. onnx-graphsurgeon

ONNXモデルの構造を把握できたところで、この章以降で多用するとても重要で便利なツールをご紹介しておきます。NVIDIAさんが公開してくれている onnx-graphsurgeon というツールです。TensorRTの附属ツールとして公開されているため少し見つけ出しにくい場所にリソースが存在していますが、nvidia-pyindex というパッケージインデックスを介してpipパッケージを取得することができます。3章の手順を実行している場合はすでに導入済みになっています。surgeon は 外科医 と日本語訳され、その名のイメージの通りONNXのモデル(グラフ)を切除したり結合したりと自由自在に加工することができます。モデル加工のチュートリアルが数種類コミットされていますので、ご興味が有る方は examples フォルダを覗いてみると良いと思います。ただ、このツールの仕様や使い方をマスターすることがこのブログの本質ではないため、後続の章でご紹介するテクニックではこのツールを皆さんが直接呼び出すことはありません。全て自作ツールでラップして分かりにくい仕様部分をブラックボックス化した状態で利用します。

便利ですが、Pythonでソースコードを書いて onnx_graphsurgeon を適切に呼び出してモデルを生成したり加工する必要があります。ツールの仕様を理解することにコストを掛けたくないのが人情ですが、理解しておくと自作ツールを作成したりよりトリッキーな加工を行いたい要件が出てきたときに役に立つ時があるかもしれません。従いまして、onnx_graphsurgeon の examples の中のチュートリアルのひとつだけをココではご紹介します。

  • 01_creating_a_model

    import onnx_graphsurgeon as gs
    import numpy as np
    import onnx
    
    OPSET=11
    X = gs.Variable(name="X", dtype=np.float32, shape=(1, 3, 5, 5))
    Y = gs.Variable(name="Y", dtype=np.float32, shape=(1, 3, 1, 1))
    node = gs.Node(op="GlobalLpPool", attrs={"p": 2}, inputs=[X], outputs=[Y])
    
    graph = gs.Graph(nodes=[node], inputs=[X], outputs=[Y], opset=OPSET)
    onnx.save(gs.export_onnx(graph), f"GlobalLpPool_{OPSET}.onnx")

上記のソースコードを任意の .py ファイルとして保存して実行すると、下図のようなモデルが生成されます。
image

READMEに該当するドキュメントにはほとんど説明が書かれていないため、初見では意味が分からないことが多いと思います。onnx_graphsurgeon の構成要素をまずはご紹介します。

要素 概要
 onnx_graphsurgeon.Graph ONNXのgraphに相当、Nodeを連結したモデル全体構造を表す
 onnx_graphsurgeon.Node ONNXのnodeに相当、opとattrsとinputsとoutputsで構成、ひとつひとつのオペレーションに該当、opに指定可能な名前およびattrに指定可能な属性値および入力数と出力数は Operator Schemas を参照
 onnx_graphsurgeon.Variable ONNXのinputとoutputに相当、nameとdtypeとshapeで構成、オペレーションの変数部分
 onnx_graphsurgeon.Constant ONNXのinitializerに相当nameとdtypeとshapeとvaluesで構成、オペレーションの定数部分

Pythonのコードは小さな変数 Variable や定数 Constant から定義を初めて Variable を介して Node 間を結合していき、Graph を生成するイメージです。オペレーションごとにONNXの仕様に則って全ての変数や定数をあらかじめ定義しておく必要があり少々直感的ではありませんが、簡単なコードを書くことでモデルを構築できる雰囲気はご理解いただけると思います。

gs graph

onnx.save(gs.export_onnx(graph), "test_globallppool.onnx") の部分で onnx_graphsurgeon.Graph 形式のオブジェクトをONNXファイルとして出力しています。gs.export_onnx(graph) の部分は onnx.ModelProto という、ONNXファイルへ出力可能な Protocol Buffers 形式のオブジェクトを生成してくれています。onnx.save(...) はONNXの標準APIの save メソッドに onnx.ModelProto を引き渡しているだけです。

ここで繰り返しになりますが、便利で柔軟な対応が可能なツールではありますが、このツールの仕様を覚えることが本質ではありません。

5-3. Node生成

前述の onnx_graphsurgeon を使用してPythonのソースコードを書いて実行すれば簡単にONNXのモデルを生成できることはご理解いただけたかと思います。しかし、そもそもPythonのソースコードを書きたくないです。できればシェルで期待通りのオペレーションをポンッと生成したいです。細々としたソースコードを書く数十分の時間すら惜しいです。そこで、onnx_graphsurgeon をラップしてシェル操作だけでONNXのノードを生成できる sog4onnx というツールを作成しましたのでそちらを使用してNode生成を行ってみます。
image

Nodeを生成するために把握しなければいけないのはONNXのオペレーションの仕様です。GlobalLpPool の仕様はこちらです。GlobalLpPool 読んでいただくと分かるとおり、特に難しいことをしているオペレーションでは無さそうです。入力にひとつの値と属性値 p をとり、出力はひとつの値となるようです。属性値Attributes p は Int64、入力Variable X は Float16 又は Float32 又は Float64、出力Variable Y は Float16 又は Float32 又は Float64 の型のみが許容されるようです。

  • GlobalLpPool の仕様

    GlobalLpPool-2
    
    GlobalLpPool consumes an input tensor X and applies lp pool pooling across
    the values in the same channel. This is equivalent to
    LpPool with kernel size equal to the spatial dimension of input tensor.
    
    - Version
        This version of the operator has been available since version 2
        of the default ONNX operator set.
    
    - Attributes
        p : int (default is 2)
            p value of the Lp norm used to pool over the input data.
    
    - Inputs
        X (differentiable) : T
            Input data tensor from the previous operator; dimensions
            for image case are (N x C x H x W), where N is the batch size,
            C is the number of channels, and H and W are the height and
            the width of the data. For non image case, the dimensions are
            in the form of (N x C x D1 x D2 ... Dn), where N is the batch size.
    
    - Outputs
        Y (differentiable) : T
            Output data tensor from pooling across the input tensor.
            The output tensor has the same rank as the input.
            The first two dimensions of output shape are the same
            as the input (N x C), while the other dimensions are all 1.
    
    - Type Constraints
        T : tensor(float16), tensor(float), tensor(double)
            Constrain input and output types to float tensors.

では前節で onnx_graphsurgeon を使用して生成したオペレーションと同じ内容のオペレーションを sog4onnx を使用して生成してみます。

sog4onnx \
--op_type GlobalLpPool \
--opset 11 \
--op_name GlobalLpPool1 \
--input_variables X float32 [1,3,5,5] \
--output_variables Y float32 [1,3,1,1] \
--attributes p int64 2 \
--output_onnx_file_path GlobalLpPool_11_sog.onnx

生成できました。onnx_graphsurgeon で生成したモデルと同じです。バックエンドで onnx_graphsurgeon をコールしていることには変わりありませんが記述量が大幅に減りました。より直感的にONNXのオペレーションを生成できます。またシェルスクリプトへ記述して機械的に、連続的にオペレーションを生成することができます。
image

念の為ツールの仕様を記載します。op_type、opset、op_name、input_variables、output_variables、attributes そして output_onnx_file_path を必要数分だけ順番に指定していくだけです。

  • コマンドラインインタフェース

    sog4onnx -h
    
    usage: sog4onnx [-h]
      --ot OP_TYPE
      --os OPSET
      --on OP_NAME
      [-iv NAME TYPE VALUE]
      [-ov NAME TYPE VALUE]
      [-a NAME DTYPE VALUE]
      [-of OUTPUT_ONNX_FILE_PATH]
      [-n]
    
    optional arguments:
      -h, --help
        show this help message and exit
    
      -ot OP_TYPE, --op_type OP_TYPE
        ONNX OP type.
        https://github.com/onnx/onnx/blob/main/docs/Operators.md
    
      -os OPSET, --opset OPSET
        ONNX opset number.
    
      -on OP_NAME, --op_name OP_NAME
        OP name.
    
      -iv INPUT_VARIABLES INPUT_VARIABLES INPUT_VARIABLES,
            --input_variables INPUT_VARIABLES INPUT_VARIABLES INPUT_VARIABLES
        input_variables can be specified multiple times.
        --input_variables variable_name numpy.dtype shape
        https://github.com/onnx/onnx/blob/main/docs/Operators.md
    
        e.g.
        --input_variables i1 float32 [1,3,5,5] \
        --input_variables i2 int32 [1] \
        --input_variables i3 float64 [1,3,224,224]
    
      -ov OUTPUT_VARIABLES OUTPUT_VARIABLES OUTPUT_VARIABLES,
            --output_variables OUTPUT_VARIABLES OUTPUT_VARIABLES OUTPUT_VARIABLES
        output_variables can be specified multiple times.
        --output_variables variable_name numpy.dtype shape
        https://github.com/onnx/onnx/blob/main/docs/Operators.md
    
        e.g.
        --output_variables o1 float32 [1,3,5,5] \
        --output_variables o2 int32 [1] \
        --output_variables o3 float64 [1,3,224,224]
    
      -a ATTRIBUTES ATTRIBUTES ATTRIBUTES, --attributes ATTRIBUTES ATTRIBUTES ATTRIBUTES
        attributes can be specified multiple times.
        dtype is one of "float32" or "float64" or "int32" or "int64" or "str".
        --attributes name dtype value
        https://github.com/onnx/onnx/blob/main/docs/Operators.md
    
        e.g.
        --attributes alpha float32 1.0 \
        --attributes beta float32 1.0 \
        --attributes transA int32 0 \
        --attributes transB int32 0
    
      -of OUTPUT_ONNX_FILE_PATH, --output_onnx_file_path OUTPUT_ONNX_FILE_PATH
        Output onnx file path.
        If not specified, a file with the OP type name is generated.
    
        e.g. op_type="Gemm" -> Gemm.onnx
    
      -n, --non_verbose
        Do not show all information logs. Only error logs are displayed.

  • Pythonスクリプトから利用する場合

    >> from sog4onnx import generate
    >>> help(generate)
    Help on function generate in module sog4onnx.onnx_operation_generator:
    
    generate(
      op_type: str,
      opset: int,
      op_name: str,
      input_variables: dict,
      output_variables: dict,
      attributes: Union[dict, NoneType] = None,
      output_onnx_file_path: Union[str, NoneType] = '',
      non_verbose: Union[bool, NoneType] = False
    ) -> onnx.onnx_ml_pb2.ModelProto
    
        Parameters
        ----------
        op_type: str
            ONNX op type.
            See below for the types of OPs that can be specified.
            https://github.com/onnx/onnx/blob/main/docs/Operators.md
    
            e.g. "Add", "Div", "Gemm", ...
    
        opset: int
            ONNX opset number.
    
            e.g. 11
    
        op_name: str
            OP name.
    
        input_variables: Optional[dict]
            Specify input variables for the OP to be generated.
            See below for the variables that can be specified.
            https://github.com/onnx/onnx/blob/main/docs/Operators.md
            {"input_var_name1": [numpy.dtype, shape], "input_var_name2": [dtype, shape], ...}
    
            e.g.
            input_variables = {
              "name1": [np.float32, [1,224,224,3]],
              "name2": [np.bool_, [0]],
              ...
            }
    
        output_variables: Optional[dict]
            Specify output variables for the OP to be generated.
            See below for the variables that can be specified.
            https://github.com/onnx/onnx/blob/main/docs/Operators.md
            {"output_var_name1": [numpy.dtype, shape], "output_var_name2": [dtype, shape], ...}
    
            e.g.
            output_variables = {
              "name1": [np.float32, [1,224,224,3]],
              "name2": [np.bool_, [0]],
              ...
            }
    
        attributes: Optional[dict]
            Specify output attributes for the OP to be generated.
            See below for the attributes that can be specified.
            When specifying Tensor format values, specify an array converted to np.ndarray.
            https://github.com/onnx/onnx/blob/main/docs/Operators.md
            {"attr_name1": value1, "attr_name2": value2, "attr_name3": value3, ...}
    
            e.g.
            attributes = {
              "alpha": 1.0,
              "beta": 1.0,
              "transA": 0,
              "transB": 0
            }
            Default: None
    
        output_onnx_file_path: Optional[str]
            Output of onnx file path.
            If not specified, no .onnx file is output.
            Default: ''
    
        non_verbose: Optional[bool]
            Do not show all information logs. Only error logs are displayed.
            Default: False
    
        Returns
        -------
        single_op_graph: onnx.ModelProto
            Single op onnx ModelProto

5-4. Nodeのコンポーネント化

前節で単体のオペレーションを生成するだけの操作に何の意味があるのか疑問を持たれた方が多いと思います。たしかにオペレーション単体のモデルでは特にメリットを感じませんが、複数のオペレーションを生成して結合し、前処理や後処理をコンポーネント化してストックしておけるとしたらどうでしょうか。PyTorchで前処理・後処理を記述することと何ら変わりはありませんが、コンポーネント化した定型的な前処理・後処理をONNXファイルとしてあらかじめ生成しておき、モデル本体部分がどのようなものに変わってもその前後の処理はあとからシェルでマージできるようになります。また、実はPyTorchで表現できないオペレーションにも対応することができるようになりますし、元のモデルがPyTorchから生成されたものではなくなおかつトレーニングコードが公開されていないモデルの改造時に役に立ちます。

コンポーネント化がどのようなことを指すのかはこちらをご覧ください。components_of_onnx READMEの下部 Progress の部分は折りたたみになっており、展開するとコンポーネント化済みのONNXがコミットされていることが分かります。

文脈として読みづらいと思いますので、一部分を抜粋してこちらに転記します。各種ハードウェア向けの特殊構造をあらかじめ実装していたり、PyTorch (TorchVision) では実装不可能なオペレーションを生成したり、定型的なノーマライゼーションの処理、RGB<->BGR変換処理を生成してコミットしています。

  • ONNXの特殊コンポーネントの一部

    No. Operator Structure
     Z001  Normalization_rgb_imagenet 20220508222828
     Z002  Normalization_bgr_imagenet 20220508222945
     Z003  SingleClass_NonMaxSupression 480×640
     Z004  YOLACT_Edge_NonMaxSupression 550×550
    image
     Z005  Resize_0.5×0.5 1x3xHxW->1x3x(Hx0.5)x(Wx0.5)
    image
     Z006  Resize_HxW 1x3xHxW->1x3x(Hx?)x(Wx?)
    image
     Z007  Myriad_workaround_NonMaxSuppression image
     Z008  TensorRT_compatible_N_batch_Resize image
     Z009  Unity_Barracuda_compatible_GatherND image
     Z010  Unity_Barracuda_compatible_Split image

では、単体のオペレーションではなく、オペレーションのセット(コンポーネント)を生成する方法をご紹介します。方法は大きく2つです。

  1. PyTorchで必要なオペレーションセットの部分のみをモデル化してONNXへエクスポート
  2. ONNXの単体オペレーションを複数種類生成しておいてあとからマージ

それぞれの方法にメリット・デメリットがあります。

生成方法 メリット デメリット
 PyTorch  1. 総じてモデルを組み立てやすい
 2. Numpyの構文を使える
 3. 認知されていてサンプルが多い
 1. 本質的ではないロジックを書く必要がある
 2. 生成できないオペレーションがある
 3. 未対応処理、改善提案の反映が Meta あるいは Microsoft、(Linux Foundation) のエンジニアの気分次第
 4. PyTorchおよびONNX Exporterのオプティマイザの影響を受ける
 ONNX単体  1. PyTorchで生成できないオペレーションを生成できる
 2. ソースコードが書けなくてもモデルを作れる
 3. PyTorchおよびONNX Exporterのオプティマイザの影響を受けない
 1. オペレーションの数が多くなると結合作業が手間
 2. 簡単に壊れる
 3. Microsoftによる仕様改善のスピードが早すぎる

上記の中からPyTorchを経由するとできなくてもONNXならできることを取り上げます。ONNXの特殊コンポーネントの一部 のうち、一番分かりやすいものとして Z004 の Multi-Class NonMaxSupression が該当します。と言われてもピンとこないと思いますので実例を取り上げてご説明します。

まずVision系のモデルの後処理でよく利用されるオペレーション NMS の PyTorch(TrochVision) の仕様を見てみます。Torch Vision NMS あるいは Kornia NMS ここで、Kornia の詳細には触れませんが、PyTorch向けに開発されたとても便利なメソッド群です。PyTorchでフォローできていないオペレーションが大量に提供されています。
image

  • TorchVision NMSの仕様

    NMS
    torchvision.ops.nms(boxes: Tensor, scores: Tensor, iou_threshold: float) → Tensor[SOURCE]
    
    Performs non-maximum suppression (NMS) on the boxes according to
    their intersection-over-union (IoU).
    NMS iteratively removes lower scoring boxes which have an IoU
    greater than iou_threshold with another (higher scoring) box.
    If multiple boxes have the exact same score and satisfy
    the IoU criterion with respect to a reference box,
    the selected box is not guaranteed to be the same between CPU and GPU.
    This is similar to the behavior of argsort in PyTorch when repeated values are present.
    
    Parameters:
        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
    
    Returns:
        int64 tensor with the indices of the elements that have been kept by NMS,
        sorted in decreasing order of scores
    
    Return type:
        Tensor

何が問題なのでしょうか? バッチ処理およびマルチクラスに対応していないことです。では、バッチ処理およびマルチクラスに対応していないとはどういうことか。boxes と scores の入力テンソルの次元がひとつ、あるいは2つ足りないです。ほとんどのシチュエーションでは不要かもしれませんが、NMSの仕様がこうなっているがゆえにとても扱いづらいシチュエーションが存在します。実際は正確に同じシチュエーションを表現している図ではありませんが下図のイメージです。NMSが80個分(80クラス分)横に展開されています。

image

ONNXのNMSはこうです。バッチ処理およびマルチクラス処理に対応しています。余談ですが、TensorFlow のNMSもバッチ処理に対応しています。PyTorchはTensorFlowより圧倒的に扱いやすいのは事実ですが、かゆいところに手が届きません。

image

NonMaxSuppression-11

Filter out boxes that have high intersection-over-union (IOU)
overlap with previously selected boxes.
Bounding boxes with score less than score_threshold are removed.
Bounding box format is indicated by attribute center_point_box.
Note that this algorithm is agnostic to where the origin is in the coordinate system
and more generally is invariant to orthogonal transformations and translations of
the coordinate system; thus translating or reflections of the coordinate system result
in the same boxes being selected by the algorithm. The selected_indices output
is a set of integers indexing into the input collection of bounding boxes
representing the selected boxes.
The bounding box coordinates corresponding to the selected indices can then be obtained
using the Gather or GatherND operation.

- Version
    This version of the operator has been available since version 11
    of the default ONNX operator set.

- Attributes
    center_point_box : int (default is 0)
        Integer indicate the format of the box data.
        The default is 0.
        0 the box data is supplied as [y1, x1, y2, x2] where (y1, x1) and (y2, x2)
        are the coordinates of any diagonal pair of box corners
        and the coordinates can be provided as normalized
        (i.e., lying in the interval [0, 1]) or absolute.
        Mostly used for TF models. 1 - the box data is supplied as
        [x_center, y_center, width, height]. Mostly used for Pytorch models.

- Inputs (2 - 5)
    boxes : tensor(float)
        An input tensor with shape [num_batches, spatial_dimension, 4].
        The single box data format is indicated by center_point_box.
    scores : tensor(float)
        An input tensor with shape [num_batches, num_classes, spatial_dimension]
    max_output_boxes_per_class (optional) : tensor(int64)
        Integer representing the maximum number of boxes to be selected per batch per class.
        It is a scalar. Default to 0, which means no output.
    iou_threshold (optional) : tensor(float)
        Float representing the threshold for deciding whether boxes overlap
        too much with respect to IOU. It is scalar.
        Value range [0, 1]. Default to 0.
    score_threshold (optional) : tensor(float)
        Float representing the threshold for deciding when to remove boxes based on score.
        It is a scalar.

- Outputs
    selected_indices : tensor(int64)
        selected indices from the boxes tensor.
        [num_selected_indices, 3], the selected index format
        is [batch_index, class_index, box_index].

話が少し逸れましたが、オペレーションをコンポーネント化できる、ということにおいて、PyTorchから実質的に綺麗なオペレーションを生成できない部分を部品化したあとに単純な前後処理をマージしておくことで、いつも定型的にコーディングしている後処理・前処理を事前にストックしておいて再利用できる、というメリットの部分の雰囲気はご理解頂けたのではないでしょうか。ただし注意点は、たとえONNXの仕様上対応しているオペレーションであったとしてもTensorRTなどの他のフレームワーク側では対応していないことがあるという点ぐらいです。

余談ですが、下図はバッチ処理とマルチクラスに対応したNMSをMyriad向けにカスタムしたコンポーネントの一例です。フレームワーク側のバグをONNXのモデル構造でフォローすることができます。MyriadはNMSに対応しているにも関わらず、NMSの出力の型がINT64のままだとAbortするバグがありますので、NMSの直後にダミーの Mul をマージしておくだけでAbortを回避できるトリックがあります。これ以外にも特定のオペレーションの型変換のバグの回避など、挙げ始めるとキリがないためこれぐらいにしておきます。

image

6. ONNXモデルのチューニング [基礎]

この章では、PyTorchから生成したONNXあるいは直接生成したONNXの構造を機械的に最適化する方法ならびに手動で構造を書き換えて処理フローを変更する方法をご紹介します。

6-1. onnx-simplifierによる最適化

4章でも少しだけ触れた onnx-simplifier の基本動作および最適化の挙動をご紹介します。
コマンドラインインタフェースで使用する場合の基本構文は下記のとおりです。

onnxsim input.onnx output.onnx

たったこれだけを実行することでONNXのモデル構造を大幅に最適化し、場合によってはファイルサイズを大幅に削減してくれます。onnxsim を実行したときに内部で処理してくれる最適化の動きと注意点を下記の流れでご説明します。

  1. BatchNormalization の融合
  2. オペレーションの定数変換と埋め込み
  3. 形状推定
  4. 使用するうえでのテクニック
  5. Tile と GatherElements のコンビネーションによるモデル肥大化の話

6-1-1. BatchNormalization の融合

BatchNormalization 自体はそんなに複雑な処理をしていません。定型的な処理を担っているだけで、特に推論時にはオペレーションとしてモデルの中に存在している意義があまりありません。ONNXの公式仕様に書かれている BatchNormalization の演算仕様を拝借します。

image

output = (input_tensor - input_mean) / sqrt(input_var + epsilon) * scale + bias

入力値から平均値を引き算して分散のルートで割り算し、スケールを掛け算してバイアスを足しています。(数式をただ日本語にしただけです)epsilon はゼロ除算エラー防止のための微小な数値です。なお、input_mean と input_var と epsilon と scale と bias はトレーニング終了時点で確定しており全て定数になっている前提です。つまり、input_tensor 以外の数値は全て定数であり、前後のオペレーション(Convolution) などが保持している定数値と事前に足し掛けすることで BatchNormalization オペレーションそのものを無効化することができます。このあたりの考え方は私以外の世界中の方々が昔から解説記事などを沢山書かれていますので、詳しいことは先人の方々が書かれた記事をご覧ください。

ここでお伝えしたいのは、難しい BatchNormalization の前後オペレーションとの融合処理を onnx-simplifier が全て代行して自動で処理してくれる、ということです。下図に例示します。

Before After
image image

Conv の直後にある BatchNormalization が Conv に融合して無くなりました。しかし、画像右下の BatchNormalization は Conv 周辺には無いため融合も分解もされずそのまま残りました。この点は少し手を加えることで単純な Mul と Sub と Add に分離でき、ときには Mul と Add のみに分離することができますがここでは説明しません。BatchNormalization を分解することで内部的な演算回数を削減することができます。

6-1-2. オペレーションの形状推定と定数変換と埋め込み

事前に演算しておけるオペレーションは onnx-simplifier が定数に置き換えてくれます。前述の図の Unsqueeze や Constant、ConstantOfShape、Tile などです。Vision系のモデルで特に多いのが、Resize 後のサイズ、あるいはリサイズのスケール値を推定する処理部分の Resize オペレーションへの埋め込みです。

Before After
image image

Resize オペレーションの直前にあった Shape と Slice と Concat の3つのオペレーションから事前にResize形状を計算し、Resize オペレーションの size の Initializer に定数として埋め込まれました。このような動作は Resize に限ったことではなく最適化の過程で事前計算可能な部分は定数に置き換えて埋め込むことでモデル全体の演算量を減らしてくれます。最近流行りの Diffusion モデルなど全体構造が大きなモデルほど効果が大きくなりますが、onnx-simplifier の最適化処理に数時間掛かったり何日も掛かったりすることがあります。

6-1-3. 使用するうえでのテクニック

モデルの構造が極めて大きく、なおかつ形状推定と最適化によって定数化できるオペレショーンの数が非常に多い場合 (例としてモデル全体で数万オペレーションある場合など) は1度の最適化処理で構造を最適化しきれない場合があります。したがって、onnx-simplifier は定型的に3回〜5回実行することをおすすめします。

Hugging Face にコミットされている Stable Diffusion の vae_encoder.onnx を例にとります。合計で3回 onnxsim を実行し、2回目までモデル全体のオペレーションの総数とモデルサイズがシュリンクしていることが分かります。冗長な計算を行っているオペレーションをモデルからほぼ一掃してくれました。

最適化前と最適化後でどれほど差が出ているかをモデル構造を分析することのみに特化したツール ssc4onnx を使用して計測してみます。onnx-simplifier の構造分析部分のロジックを独自に改善し、分析処理を内部的に10倍以上高速化していることと、オペレーションの総数をカウントして表示できるように改良したツールです。onnx-simplifier は巨大な構造を持つモデルを最適化する過程でAbortすることがありますが、このツールはAbortしません。

image

  • コマンドラインインタフェース

    ssc4onnx -h
    
    usage:
        ssc4onnx [-h]
        -if INPUT_ONNX_FILE_PATH
    
    optional arguments:
      -h, --help
            show this help message and exit.
    
      -if INPUT_ONNX_FILE_PATH, --input_onnx_file_path INPUT_ONNX_FILE_PATH
            Input onnx file path.

  • Pythonスクリプトから利用する場合

    >>> from ssc4onnx import structure_check
    >>> help(structure_check)
    
    Help on function structure_check in module ssc4onnx.onnx_structure_check:
    
    structure_check(
        input_onnx_file_path: Union[str, NoneType] = '',
        onnx_graph: Union[onnx.onnx_ml_pb2.ModelProto, NoneType] = None
    ) -> Tuple[Dict[str, int], int]
    
        Parameters
        ----------
        input_onnx_file_path: Optional[str]
            Input onnx file path.
            Either input_onnx_file_path or onnx_graph must be specified.
            Default: ''
    
        onnx_graph: Optional[onnx.ModelProto]
            onnx.ModelProto.
            Either input_onnx_file_path or onnx_graph must be specified.
            onnx_graph If specified, ignore input_onnx_file_path and process onnx_graph.
    
        Returns
        -------
        op_num: Dict[str, int]
            Num of every op
        model_size: int
            Model byte size

実に 269個 のオペレーションが最適化の過程で削除されました。

  • 最適化前

  • 最適化後

6-1-4. Tile と GatherElements のコンビネーションによるモデル肥大化の話

onnx-simplifier の最適化の仕組みが事前演算によるオペレーションの定数化であることはご紹介しました。ここではその最適化仕様が裏目になってモデルサイズが超肥大化し、Protocol Buffers のファイルサイズ上限の 2GB を超えるほど肥大化してAbortしてしまう事例があることをご紹介します。

モデル肥大化の原因と対処方法を onnx-simplifier の作者と議論し、現状の最新バージョン v0.4.8 では提案内容が取り込まれてモデルサイズの肥大化が抑止される仕様が盛り込まれた状態になっていますが、諸刃の剣の対処であることを理解しつつ、現状ではデフォルト動作で Tile の最適化がスキップされるように修正されています。気になる方はこちらの issue Excessive bloating of ONNX files due to over-efficient conversion of “Tile” to constants (Protocol Buffers .onnx > 2GB) #178 をご覧ください。

ポイントを絞ってご説明すると、Tile オペレーションを定数化する過程で、Int64 形式の定数が 1x16x320x180x320個 (つまり 294,912,000個) 約3億個も生成されてONNXモデルに埋め込まれる爆弾を抱えていました。294,912,000 x 64bit ですので、2.2 GB です。PyTorchの torch.meshgrid を使用した場合に発生する問題です。
image

画像中の GatherElements は私のワークアラウンドを適用したあとの Int32 へキャストした GatherElements ですので、半分のサイズの 1.1 GB に縮小した状態です。それでもなお、Tile ひとつと GatherElements ひとつの組み合わせ部分だけで 1.1 GB ものモデル肥大化を招きます。onnx-simplifier のデフォルトの動作で Tile の最適化がスキップされるため、Tile を含めた最適化有効・最適化無効を切り替えるオプションの実装が待たれます。

image

6-2. simple-onnx-processing-tools

基礎編の最後です。次章以降に応用的なモデルチューニングをご紹介するに先立って、onnx-graphsurgeon や onnx-simplifier と組み合わせて使用するONNX加工用ツール群をご紹介します。simple-onnx-processing-tools というツール群です。ONNXモデルの分割、結合、OP削除、サイズ圧縮、属性・定数書き換え、OP生成、OPセット変更、指定入力順への変更、OP追加、RGB→BGR変換、バッチサイズ変更、OP一括リネーム、JSON変換などが可能です。すでに前章までの基礎編でごく一部のツールを活用してONNX構造の基礎理解に役立てましたが、応用編では未登場のツールを使用してあらゆる変換を試行します。

image

対応しているタスク・ツールは下記のとおりです。応用編で常用します。全て自作のツールですが、ONNX加工に関するほぼ全てのユースケースに対応しています。

No. Tool Name Tags Summary
1 snc4onnx
snc
PyPIsnc Simple tool to combine(merge) onnx models. Simple Network Combine Tool for ONNX.
2 sne4onnx
image
PyPIsne A very simple tool for situations where optimization with onnx-simplifier would exceed the Protocol Buffers upper file size limit of 2GB, or simply to separate onnx files to any size you want. Simple Network Extraction for ONNX.
3 snd4onnx
snd
PyPIsnd Simple node deletion tool for onnx. Simple Node Deletion for ONNX.
4 scs4onnx
scs
PyPIscs A very simple tool that compresses the overall size of the ONNX model by aggregating duplicate constant values as much as possible. Simple Constant value Shrink for ONNX.
5 sog4onnx
sog
PyPIsog Simple ONNX operation generator. Simple Operation Generator for ONNX.
6 sam4onnx
sam
PyPIsam A very simple tool to rewrite parameters such as attributes and constants for OPs in ONNX models. Simple Attribute and Constant Modifier for ONNX.
7 soc4onnx
soc
PyPIsam A very simple tool that forces a change in the opset of an ONNX graph. Simple Opset Changer for ONNX.
8 scc4onnx
scc
PyPIsam Very simple NCHW and NHWC conversion tool for ONNX. Change to the specified input order for each and every input OP. Also, change the channel order of RGB and BGR. Simple Channel Converter for ONNX.
9 sna4onnx
sna
PyPIsog Simple node addition tool for onnx. Simple Node Addition for ONNX.
10 sbi4onnx
sbi
PyPIsbi4onnx A very simple script that only initializes the batch size of ONNX. Simple Batchsize Initialization for ONNX.
11 sor4onnx
sor
PyPIsor4onnx Simple OP Renamer for ONNX.
12 soa4onnx
soa
PyPIsoa4onnx Simple model Output OP Additional tools for ONNX.
13 sod4onnx
sod
PyPIsod4onnx Simple model Output OP Deletion tools for ONNX.
14 ssi4onnx
ssi
PyPIssi4onnx Simple Shape Inference tool for ONNX.
15 sit4onnx
sit
PyPIsit4onnx Tools for simple inference testing using TensorRT, CUDA and OpenVINO CPU/GPU and CPU providers. Simple Inference Test for ONNX.
16 onnx2json
onnx2json
PyPIonnx2json Exports the ONNX file to a JSON file.
17 json2onnx
json2onnx
PyPIsog Converts a JSON file to an ONNX file.
18 sed4onnx
sed
PyPIsog Simple ONNX constant encoder/decoder. Since the constant values in the JSON files generated by onnx2json are Base64-encoded values, ASCII <-> Base64 conversion is required when rewriting JSON constant values.
19 ssc4onnx
ssc
PyPIsog Checker with simple ONNX model structure. Simple Structure Checker for ONNX. Analyzes and displays the structure of huge size models that cannot be displayed by Netron.
20 sio4onnx
image
PyPIsio Simple tool to change the INPUT and OUTPUT shape of ONNX.
21 svs4onnx
image
PyPIsio A very simple tool to swap connections between output and input variables in an ONNX graph. Simple Variable Switch for ONNX.
22 onnx2tf
image
PyPIonnx2tf Self-Created Tools to convert ONNX files (NCHW) to TensorFlow format (NHWC). The purpose of this tool is to solve the massive Transpose extrapolation problem in onnx-tensorflow (onnx-tf).
23 sng4onnx
image
PyPIsng4onnx A simple tool that automatically generates and assigns an OP name to each OP in an old format ONNX file.
24 sde4onnx
sde4onnx_icon
PyPIsde4onnx Simple doc_string eraser for ONNX.
25 components_of_onnx
components_of_onnx
[WIP]PyPIsog ONNX parts yard. The various operations described in Operator Schemas are converted in advance into OP stand-alone ONNX files.

X. おわりに

ONNXのモデルチューニングテクニック (基礎編) は以上です。次回は 応用編1 として実践的なONNXモデル加工のテクニックをご紹介したいと思います。

  1. モデルのチューニング [応用]
]]>
EBPMデータベースを作った理由 https://cyberagent.ai/blog/tech/oss/16582/ Fri, 27 May 2022 02:00:47 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=16582 経済学社会実装チームの森脇です。

本日、証拠に基づく政策(Evidence-Based Policy Making, EBPM)を促進するためのプラットフォーム、EBPMデータベースをリリースしました。このウェブサイトは査読付ジャーナルから政策現場における簡単なデータ分析までさまざまなレベルの政策に関するエビデンスを集めて共有することを意図したプラットフォームです。

大学やシンクタンクの専門家、行政組織の実務家などに政策効果に関する簡単なサマリー記事を「レビュー」という形で登録してもらい「データベース」化することで、政策を検討する際に過去の類似例の検証結果について参照できるようにしています。

EBPMデータベースのコンテンツは専門家のみなさまにどんどん追加していただきたいのですが、取り急ぎイメージを共有するために教育、医療、少子化の3分野について10本程度のレビューを掲載しています。いずれも分野の専門家が選定した査読付ジャーナルからの質の高いエビデンスを集めています。協力いただきました、伊藤寛武さん、高橋雅生さん、茂木良平さんにはこの場をお借りして御礼申し上げます。

EBPMは政府における議論がはじまった2017年ごろからバズり始め昨年急速に認知されるようになりました。厚生労働省では因果推論手法を用いた政策効果分析が発表されるなど着実に取り組みが進んでいます。私自身も中央省庁から政策のデータ分析に関するヒアリングを受ける機会があり政府におけるニーズの高まりを感じているところです。

trend

EBPMの検索数(Googleトレンド)

人的・財政的に慢性的なリソース不足に陥っている行政組織にとって、限られたリソースを有効な政策に振り向けることは喫緊の課題です。国の経済政策の司令塔である経済財政諮問会議においてEBPMアドバイザリーボードがエビデンスづくりを進めていますし、今年4月、経済産業研究所にEBPMセンターが設立されています。こうした、政府の取り組みはGoogleをはじめとするテック企業がA/Bテスト(ランダム化比較実験)によって客観的なエビデンスに基づいてサービスの改善を繰り返して成長してきたことと付合し、時代の趨勢と言えます。

EBPMデータベースの提供する価値

EBPMデータベースでは、主に3点の価値を提供したいと思っています。

低コストのEBPM

中央省庁のEBPMの取り組みは、「データを分析して」「証拠を生み出す」ところにフォーカスしているように見受けられます。日本最大のシンクタンクといわれ独自の研究所や傘下の研究独法をもつ中央省庁はともかく、自治体の現場でEBPMのためのデータ分析を行うのは容易ではありません。データ収集、分析、結果の活用という三段階のなかで、データエンジニア、データアナリスト、そして何より解析結果を幹部や議員に説明して意思決定を促す政策コンサルタントと、複数種類の専門家が揃うことはなかなかないと思います。

EBPMデータベースでは、すでに専門家がデータ収集と分析を行なった結果がレビューというかたちで投稿されています。適当なレビューを探して最後の意思決定の部分から始めることができます。必要なエビデンスが十分整理されていないといったご要望はお気軽にGitHubもしくはebpm@cyberagent.co.jpまでご要望ください。

EBPMについてのオープンな議論

EBPMデータベースのような試みは、諸外国でWhat Worksとかエビデンスストアといった名称で多数公開されています (注1)。しかし、これらは一方通行の情報提供にとどまり、研究者や政策現場とのインタラクションが難しくなっています。

EBPMデータベースでは、GitHubにソースコードをすべて公開しており、自由にレビューの追加や修正要望をできるようになっています。研究者の方がご自身の研究をアピールする場として使っていただくもよし、なんちゃって専門家によって歪んだ世論を糺すために分野で認められた高品質のエビデンスを追加していただくもよし、政策現場の方が実施してみたデータ分析結果を投稿していただくもよし、自由に使っていただければ幸いです。また、投稿されたレビューについての改善も歓迎しています。

政策現場とアカデミアの結節

COVID-19を契機に政策決定過程における専門家の役割が改めて議論されています。頑健なエビデンスをじっくり作り上げることに長けた専門家が、意思決定のためにオンデマンドなエビデンスを短時間で生み出す必要がある政策現場で何ができるのか。

急ぐあまりエビデンスの質が落ちれば仲間内から批判され、エビデンスの生産が遅いと政策現場からは見放されるという板挟み状況が改めて浮き彫りになりました。逆に、政策決定側は専門家に過剰に責任を負わせる構図も批判の対象となりました。

このような状況を打開するためには、EBPMデータベースを通じてエビデンスをつくる人と使う人が交流することでお互いの言語やインセンティブ、制約について相互理解を深めることで協働体制をつくっていくことが有用ではないかと思います。

既存のエビデンスについて共有するだけでなく、行政の現場での実証実験に専門家が参加し、その結果得たデータを論文化してまたEBPMデータベースに投稿するといったサイクルが生まれることを期待しています。

最後に

EBPMデータベースは開発途上のツールです。たくさんのレビューを投稿していただくとともに、実際に政策現場のユーザーに使った感想をフィードバックをいただき発展させていければと思います。

 

注1: What Works Clearing House, What Works Center for Local Growth  Education Endowment Foundation Teaching and Learning Toolkit  Kopernik Solution Catalog  J-Pal Crime Reduction Toolkit What Works For Children’s Social Care 3ie Development Evidence Portalなど

]]>
AirTrack 内定者アルバイト 「広告ログ集計基盤をSnowflakeへの移行による集計プロセスの改善」 https://cyberagent.ai/blog/pr/intern/15344/ Tue, 31 Aug 2021 03:06:24 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=15344 はじめに

初めまして、22卒エンジニア職内定者の松岡穂高と申します。

2021年6月1日から3ヶ月間、サイバーエージェントのAI事業本部にあるAirTrackという部署で、サーバーサイドの内定者アルバイトをさせていただきました。

今回は振り返りの意味も兼ねて、取り組んだ内容と学んだ事や知見について書き記したいと思います。

AirTrackとは

AirTrackとはAI事業本部の小売DX本部に位置し、スマートフォン端末から取得できる位置情報を活用し来店計測や広告配信を行うサービスを展開しているプロダクトです。位置情報を使って顧客属性を推測しターゲティングを行なっており、「人を動かす広告をつくる」ことを目指しています。

今回は、DSPと呼ばれる広告主向けの広告配信プラットフォームの開発に携わりました。DSP及びアドテクに関する詳しい解説はこちらの記事に書かれています。興味のある方はぜひご覧ください。

今回の目標

まず、今回AirTrackで働くにあたって成し遂げたいことは以下でした。

  • 大規模なデータを扱い、パフォーマンスチューニングができるようになりたい
  • 何かしらのビジネスインパクトを与えたい

学生レベルでは、どうしても事業目線で開発・運用を行う機会はないので、この内定者アルバイトではできる限り社員の方々と同じような視座を持つことを意識して取り組みました。

開発言語に関しては自分自身、特にこだわりはなく、プロダクトに適した言語を選定し利用できるのが望ましいと考えています。理由としては、サーバーサイドの言語はトレンドが激しく、現在使用している言語が必ずしも将来のデファクトスタンダードであり続けるとは限らないからです。そのため、一つの言語に固執したくない、今まで触れてこなかった技術にも触りたいと思い、Scalaでのサーバーサイドやインフラレイヤーの実装に挑戦しました。

AirTrackで行ったこと

今回私がメインとして行ったことは、既存の広告ログ集計のシステム基盤をSnowflakeに移行するというタスクでした。SnowflakeとはSnowflake社が提供するクラウド型データウェアハウスです。この移行によって、運用コストの削減、集計プロセスの安定化、より信頼性の高いモニタリングや精度の高いCTR予測が可能になり、もともと私が成し遂げたかった「大規模データを触りたい」「ビジネスインパクトを与えたい」という目標が達成できると考えました。テーブルの移行自体はクエリを書くだけで簡単でしたが、移行したテーブルが元のテーブルと同等の内容になっているか(join や where などの集計処理が等しいものになっているか)という精度の評価で苦戦し自身の成長にもつながる部分でした。この検証の部分に力を入れていたのでこのブログで紹介していきます。

精度検証と評価

まずは、試験的にSnowflakeで新しいテーブルを作成し、既存のものと同等の精度であるかを検証します。両者のテーブルから8月の1ヶ月間でデータを絞り込み、1日ごとにグループ化します。そして、既存テーブルと今回作成した新規テーブルから日ごとのidの数を算出し、抽出したcount数から両者の差分の割合を算出します。

WITH new AS (
  SELECT
    DATE_TRUNC(DAY, requested_at) AS date, 
    COUNT(id) AS count
  FROM
    new_table_log
  WHERE
   requested_at BETWEEN '2021-08-01' AND '2021-09-01'
  GROUP BY 1 
  ORDER BY 1
), 
old AS (
  SELECT 
    DATE_TRUNC(DAY, requested_at) AS date, 
    COUNT(id) AS count 
  FROM
    old_table_log
  WHERE 
   requested_at BETWEEN '2021-08-01' AND '2021-09-01'
  GROUP BY 1 
  ORDER BY 1
) 
SELECT
  old.date, 
  old.count AS old_count, 
  new.count AS new_count, 
  new_count / old_count AS rate,
  new_count - old_count AS difference
FROM 
  new 
  FULL OUTER JOIN old ON new.date = old.date 
ORDER BY 1
;

以下は、SnowsightというSnowflakeのデータ結果をグラフで可視化するツールを使用し、検証結果をグラフ化したものになります。この結果から、ほとんどの差分率が1.0に近い範囲に収まり、既存のデータと新しく作成したデータがほとんど一致している事が示されました。

新旧のレコード数の差分率を表したグラフ(8月)

実装

検証結果を評価した後は実装に入ります。試験的に作成したクエリが先程の検証で精度的に適正であることが証明されたので、これを定常的に作動させます。今回は、CloudWatch Eventsで定刻になればECSタスクを実行するようにしました。また、インフラ構築はAWS CDKを用いてクラウドアプリケーションリソースを定義しました。

学び

技術力も含めて設計力の大切さ

まず何よりもこの内定者アルバイトで学んだことは、設計の大切さです。AirTrackに来てまず驚いたことは、要件定義・システム設計のドキュメントの多さでした。私自身、これまでシステム設計を行わずに最初から実装ということが多々あったため、設計に対するスキルは皆無でした。実際に設計のレビューを社員の方々に見てもらい、改めて認識の齟齬があったり、より効率的で効果的な実装方法を提案してくださったりしていただきました。実現したい事を達成するために、どのような技術を用いて、どのように課題解決へとアプローチするかという方法を考え、文書に落とし込むことは簡単そうに見えてとても難しかったです。

仮説検証の重要性

次に学んだことは、仮説検証の大切さです。実装したものが果たして、どのような効果をもたらし、何がボトルネックであるかを明確にする必要がありました。

今回は特にSQLを使ってデータを集計することがメインでした。既存のものと刷新したデータベーステーブルを比較し、レコード数やデータの欠損率を計測。そして仮説検証を行い原因追求の繰り返し行う。そのようにして課題解決を行なっていくという経験ができたのは、私自身にとって大きな成果だと思います。少ないデータ量であれば、数字を出さなくとも、実際目で見て比較すれば良い話ですが、億を超えるレコード数を扱うため、欲しい結果を得るためのクエリを書くことは難しかったです。大規模なデータを扱えるのはとても楽しく、広告事業ならではの醍醐味だと感じました。

さいごに

このメインのタスク以外にも、サーバーサイドの開発ではScalaに挑戦しました。関数型言語は扱ったことがなく、慣れない概念が多くて難しかったですが、エンジニアとしてのレベルをより一層高めることができたと思います。また、個人開発ではなかなか使用する機会がないAWSのサービスを扱えたのも貴重な経験だと思います。

今回は、私自身初めて内定者アルバイトをさせていただきました。参画当初はとても不安でチームの力になれるか心配でしたが、AirTrackの皆さんはとても優しく働きやすい環境でした。また、わからない質問に対しては自分の成長を考えた受け答えをしていただきとても学びになりました。しっかりアウトプットを出せたのも、自分一人の力ではなくメンターさんや先輩の社員の方々のおかげであり、とても感謝しています。そして、この3ヶ月で身につけたことや学んだことはこれからも活かして自分の成長に繋げたいと思います。短い間でしたがお世話になりました。本当にありがとうございました。

 

]]>
ミニマムにはじめるイベントストーミング https://cyberagent.ai/practical-introduction-to-your-first-event-storming Fri, 09 Oct 2020 02:55:41 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=13996 こんにちは。AIRTRACK の豊田です。今回は技術マネジメントの話をします。

先日、チーム内でコロナ対策を万全に講じてイベントストーミングを実施してみました。

社内でもあまり行われていない取り組みのようなので、イベントストーミングとはなにかについて簡単に説明し、どのような課題を解決する手法なのかを見ていきます。加えて、実施にあたってどのような点について注意したのかといった点について、簡単に書きたいと思います。

この記事は実践寄りの感想になるため、イベントストーミングのやり方そのものについては深くは掘り下げていません。

AIRTRACK とは

サイバーエージェントの AI 事業本部で、主に広告配信や小売向けのソリューションを提供しているプロダクトです。2014年から創業を開始し、以降さまざまなクライアントを獲得しながら発展してきたプロダクトで、現在では AI 事業本部の小売 DX 部門を支えるプロダクトです。

AIRTRACK が挑戦するのは、まだまだ確固たるソリューションが存在しない小売業界のデジタル化(DX という言葉が最近ははやっています)という領域です。チラシのデジタル化です。独自のアルゴリズムを用いて、消費者が必要としているモノに関する広告を届けられるように日夜工夫しています。

少し開発体制の話をさせてください。

開発チーム全体はデータサイエンティストも含めると10名程度、ビジネスサイドを含めると15名程度の規模をもつプロダクトです。私はそのうちのテックリードを務めており、私以外にはエンジニアリングマネージャーを務める方がいます。私自身の職責はソフトウェアアーキテクトと技術周りのマネジメント・戦略設計です。エンジニアリングマネージャーは、ピープルマネジメント(とカタカナでいうとカッコいいが要するにエンジニアの人的側面の管理)やプロダクトの開発プラン、優先順位決めを行っています。

開発プロセスの特徴としては、スクラム開発を採用しているという点があげられます。スクラムの原則である「検査・透明性・適用」を重要視したプロダクト開発をしており、スプリントは1週間単位で回されています。認定スクラムマスターも在籍しています。

AIRTRACK 開発チームマネジメントの哲学は、「誰もがどのような業務であってもできるようにする」です。これにより、たとえば新卒であっても(多少サポートを受けながら)シニアがこなすようなレベルの仕事をすることができます。また、サーバーサイドエンジニアであってもフロントエンドもインフラも触るということが可能になっています。いわゆる暗黙知や属人化をできるだけ減らすマネジメントをしていきたいと考えています。そこにスクラム開発が生きてくるのです。

イベントストーミングとは

『Introducing EventStorming』p. 14 に記載されている図。

イベントストーミングとは、いわゆるワークショップ形式のアクティビティです。このアクティビティを通じて、ドメインイベントを中心としたドメインモデルを作っていきます。

このアクティビティの最大の目標はモデルを組み上げることです。ドメイン駆動設計をご存知の方であれば、ドメインモデルがどのような形になっているのかを落とし込めるアクティビティだと思うと想像がつきやすいかもしれません。

ドメインモデルを組み上げる作業は、私が思うに開発の中でもっとも難しいプロセスで、とくにステークホルダー間で認識を合わせ続けるのは普通に開発をしていると至難の業です。イベントストーミングはこの問題に立ち向かいます。

イベントストーミングでは、まず対象とするドメインで起こる「ドメインイベント(以下、イベント)」を抽出していきます。イベントというのは、過去に起きた出来事です。要するにたとえば、広告配信システムなら「案件を入稿した」「配信を開始した」「配信が中断した」などといった出来事がイベントとなります。これをまずは抽出していきます。加えて、イベント以外にも「コマンド」「外部システム」「方針」「読み取りモデル」といった要素も、ビッグピクチャの中に加えていきます。

そして抽出した要素たちをタイムラインに沿って過去から未来に並べていくことで、最終的な成果物(ビッグピクチャと呼びます)が作り上げられます。つまり、このアクティビティの結果、複雑な現実の業務全体をシステムの言葉に落とした成果物ができあがり、のちの実装時にはこの絵を参照しながら開発を進めていくことができるようになるのです。

ユーザーストーリーマッピングとは違い、かなりシステム側によったアクティビティです。議論を進めていく内容も、システムにどう落としていくかという観点を中心に進められていきます。このアクティビティを見た瞬間に、これはドメイン駆動設計をかなり意識しているなと感じました。実際、関連する用語が多々登場します。

一般的なイベントストーミングの実施手順については『Introduction EventSourcing』という本や、Qiita のこの記事、あるいはこの記事などに詳しいです。

イベントストーミングで解決したかった問題

私が今回イベントストーミングを実施するにあたって解決したかった課題は下記です。

  • 今回実装の対象とするドメイン(業務内容)がそもそも複雑で、結局何をしたらよいかについて誰も全体像を描けていなかった。
  • 業務を行うためのシステムを実装するにあたって、自分たちがどのようなドメインをシステムに落とそうとしているのか、実施前には整理できていなかった。
  • また、直近でチームの人数が増えてきたが、それに伴ってドメインの知識が一部の人に偏って蓄積されてしまっており、属人化が進んでいた。

こうした課題に対して、イベントストーミングは非常に有効な手法なのではないかと以前から考えていました。百聞は一見にしかずということわざがありますが、最終的にひとつのビッグピクチャを全員で作り上げるこのプロセスは、まさに百聞は一見にしかずなのではないかと考えたのです。これは AIRTRACK の開発チーム運用の哲学ともマッチします。

結果的に、上記にリストアップした課題に対して、イベントストーミングは大きな効果を発揮し始めたように思います。まだイベントストーミングの結果は実装途中で、本当の効果が検証できるのはこれからですが。

実施した結果、

  • 実施した本人がまず、ドメインの全体像を手に入れられた。
  • メンバーと事後にドメインについて話をした際に明らかに以前と比べて理解度が違っていると感じられた。
  • 参加者が楽しそうだった。

といった効果をすでに感じています。参加者にとって実りある会だったのであれば、ひとまず開催した価値はあったと思っています。

ただ一方で、こうした活動は一過性のものとせず、継続して行ってこそはじめて改善活動になっていきます。私は次に、どうしたらスクラムチームの開発プロセスにイベントストーミングという新しいプロセスを自然に統合していけるかについて思案しています。

事前検討

ガイドに載っているものをそのまま実施したとしても、組織にフィットするとは限りません。また、一度きりのものにしたくはなかったので、先ほど紹介した本や先駆者の記事を参考に、継続的に行えるよう会をデザインし直しました。これがうまくいっていれば、私は次回「継続のコツ」のような記事をアップしていることでしょう(笑)。

参加者は絞り、時間は1時間にした

参加者と時間についてはかなり絞りました。『Introducing EvnetStorming』内では、何日間にもわたって行われているようですが、その時間はありませんでした。なので、

  • 参加者は全体で6名程度。
  • 時間は1時間。

で行いました。そのため、今回実施する対象としたドメインもかなり小さいものを採用しました。

イベントストーミングで重要なのは参加者を正しく選定することです。「その場で」結論を出し切りたいです。なので、

  • ファシリテータ: 会議の進行や参加者から質問を引き出したりする人。
  • ドメインエキスパート: イベントストーミングで扱う業務領域に詳しい人。
  • 開発者: 実際にその業務内容をシステムに落とす人、あるいは日常的にアプリケーションの開発に携わっている人。

の3つの役割の人たちをその場に集める必要があります。

今回は、データサイエンティストが広告配信において AB テストを行う際に、どうしたら彼らがやりたいことを実現できるかについて考える必要がありました。したがって、ドメインエキスパートはデータサイエンティストということになります。なので、彼らに参加してもらうことにしました。

ファシリテータは、今回がはじめての実施だったため、イベントストーミングについてある程度理解している必要がありました。なので、私が担当しました。本来であれば、ファシリテータは適切な問いを投げかけて議論を深める役割を担うので、イベントストーミングもわかっていて、かつドメインに関する知識もある程度ある人がよさそうではないかと考えています。

イベント、集約、Issue に絞って実施することにした

今回実施するにあたって、イベントストーミングをフルパッケージでは実施しないという手を取りました。これは FOLIO 社の方が発表されていたスライドの中で採用されていた手法で、初回を実施するにあたって非常にスマートに実施できそうだと感じたため採用しました。

イベントストーミングには、External System や Command といった多くの要素が存在しています。初めて実施するにあたって、1時間しかない中でこれらすべてを参加者に理解してもらいあげきるのは無理だと考えました。少々説明しなければならないことが多いためです。

したがって、スライドで紹介されていた「イベント」「集約」と「Issue」をあげるという手法を採用しました。これにより、最低限果たしたい目的は果たせました。ただ今後、要素は徐々に増やしていきたいと考えています。

事前に予行演習で課題を発見した

さらに1時間のワークショップを実施する前に、特定の小さなドメインに絞って2名で軽くイベントストーミングを実施しました。

その結果、思いつく限りのイベントを列挙する前に、イベントの始点となる付箋を何枚か用意しておくと、議論の起点となってよいのではないかというフィードバックを得られました。これも採用し、当日は始点をあらかじめ何枚か用意した状態でスタートさせました。

当日、始点の用意は発想の起点になったようです。このおかげで、私が見つけられていなかった始点も参加者がたくさん発見していきました。

ミニマムに実施した際のプロセス

上記をふまえながら、下記プロセスを最終的には採用しました。

  1. ファシリテータは「始点」を用意しておく。
  2. まず思いつく限りのイベントを列挙する。同時に議論の余地がある話を Issue として貼る。
  3. タイムラインに載せる。
  4. イベント間の依存関係を矢印で結ぶ。
  5. 集約を発見し、命名する。

ファシリテータは「始点」を用意しておく

先ほども説明したとおり、予行演習で発見した「始点」の確保を行いました。これを行うことによって、そのドメインに関してあまり知識をもっていなかったとしても、議論に参加しやすい状態を担保しました。

「始点」は本当になんでもよいです。たとえば広告配信であれば、「広告配信ボタンを押した」「広告を入稿した」といった文言がひとつの始点になりえます。そこから派生するイベントをあとは列挙していくだけです。

始点は最初にファシリテータが用意したもの以外に、参加者が自由に追加することもできます。もちろん時間内に会議を収め、成果物をすばやく作り上げるために「始点以外は貼り出さないようにしてください」と絞ってみてもよいでしょう。ここはファシリテータの腕の見せ所かもしれません。

始点の確保の最大の目的は、ドメインに関してあまり知識をもたない人でも参加できるようにするという点です。少なくとも始点があれば、参加者は質問を考えることができます。これが重要なのではないかと考えています。

まず思いつく限りのイベントを列挙する

思いつく限りのイベントを列挙する

オレンジのふせんでイベントを列挙し、列挙している最中に疑問に思ったことはピンクの付箋で貼っておく

先ほど説明した始点を起点にし、まず思いつく限りのイベントを参加者に列挙してもらいます。あげる内容は、ドメインに明らかに存在しないものでない限りはどんな些細なイベントであっても構わないです。オレンジの付箋で貼っていきます。

この段階では相当数のイベントが列挙されていくため、横に広いホワイトボードを用意しておくことをおすすめします。

また、先ほど説明した Issue もこの段階で貼っていきます。ガイドに従ってピンクの付箋を貼るようにしました。

タイムラインに載せる

先ほど列挙したイベントを、左から右に時間が流れるように順番を並び替えていきます。まず、まとめられるものをまとめ、不要だと思ったものは削除するというように整理をしていきます。

まとめられるもの同士をまとめる

まとめられそうなものに対してコメントをしながら、整理していく

次に、まとめられそうなものや、不要そうなものを整理しきったあと、時系列順になるように位置を調整してさらに整理します。

タイムラインに載せた

整理しきった結果を左から右に時系列順になるように再度並べ直す

無理に依存関係を整理する必要は、この時点ではありません。それは次のプロセスで行うためです。

イベント間の依存関係を矢印で結ぶ

依存関係を矢印で結ぶ

各イベントが、どのイベントに対して依存しているのかを示す矢印を書き込んでいく

タイムラインに一通り載せ終わったところで、お互いに依存し合うイベント同士を矢印で結び合います。(実施当日は私が勘違いしていた&あとで知ったのですが、『Introducing EventStorming』の中にはこのフェーズは登場してきていません。ただ、意外に有用なように思いました。)

基本的には始点の付箋から次の付箋、次の付箋…というように矢印が続いていくはずですが、中にはスタート地点がわからないものもあるかもしれません。途中からある特定の時間帯や条件を満たすと発生するイベントがそれです。その際は、「…→」というような矢印を採用してみました。

スタート地点がわからないものには、特定のアクターが関与しているかもしれません。今回は行いませんでしたが、そうした特定のアクターについても整理して記載しておくと、規模の大きめなプロジェクトでは役に立ちそうに感じました。

集約を発見し、命名する

集約を発見する

集約を見つけ出し、黄色い付箋とグルーピングの印をつける

依存関係を結び終わると、「これらのイベントはお互いに強く依存しあっていて、整合性を保つ必要がありそう」というイベントのまとまりを発見できるかもしれません。これは集約と呼ばれるもので、ひとまとまりに処理を仕切る単位として認識しておくべきものです。

まとまりあったグループを線で囲います。囲い終わったあとに、もう一度そのまとまりで正しいのかを見て、正しそうであればひとつひとつ名前をつけていきます。これがモデルとして発見されていくのです。

次回以降

やはりフルパッケージで行いたいです。本来であれば Command や Policy なども採用して、もう少し細かく議論することも可能なはずです。

今回の抽出されたイベントは、やや粒度が大きめの内容が多かったので、この精度で十分間に合うのかどうかは気になっています。これはイベントのみをリストアップする手法の弱いポイントなのかもしれないと感じました。

また、実装に落とす際にシームレスなものかどうかはまだわかっていません。弊プロダクトのシステムは、イベントストーミングの中で使用される用語から連想される CQRS + ES のアーキテクチャを採用できているわけではありません(したいけど)。こうしたシステムに対しても効力があるのかは、検証の余地がありそうです。

まとめ: 設計を”タンジブル”にする

最終的にできあがった成果物

今回行ってみた結果、複雑な業務やドメインイベント、ふるまいの関係性が、このようにひとつのホワイトボードで眺められる形にまとまった。中身はお見せできないのでモザイクで🙏

ドメインモデリングのような抽象作業は、何も手を打たないと抽象的な文章が羅列され、結局参照されないものができあがってしまいます。議論も口頭で行うと空中戦になりがちで、論点も散りがちです。その結果、システムやドメインに詳しくない人は、途端に議論への参加権を失います。これは健全な姿とは言えないでしょう。

こうした問題に対するキーワードとして「タンジブル(tangible)」というものがあります。この単語はノンネイティブには少し難しいですね。語源はラテン語の tangere で、この単語は英語に直すと touch です。これに -ble が付くので、touchable (触れられるもの)くらいの意味合いだと予想が立ちます。

タンジブルというのは要するに「触って感知できる状態にすること」です。モヤモヤしたものを実体化するという意味合いがあります。

モデリングにおいては、図表に起こしてひと目で見てわかる形にしておくというのが表現としてはわかりやすいでしょうか。モヤモヤしたモデルという存在を、図表という人間が目で見て観測可能な形にして用意しておく。これが設計をタンジブルにするという作業です。

イベントストーミングではまさにこの設計をタンジブルにする作業を行います。この手法が優れているのは、イベントを扱うのに特化して設計されているという点です。タイムラインに過去から未来に向けて項目を並べるというシンプルな作業ではあるのですが、これがイベントをタンジブルにするのにピッタリの作業です。

AIRTRACK では、こうした設計をタンジブルにするアクティビティを今後も設計し、積極的に開催していきたいと考えています!

 

]]>
音声処理におけるbarge-in https://www.ai-shift.co.jp/techblog/1202 Wed, 15 Jul 2020 08:57:01 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=13712 AWS SageMakerのハンズオンを実施していただきました! https://cyberagent.ai/blog/tech/seminar/13460/ Fri, 03 Jul 2020 01:02:29 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=13460 こんにちは!
AI事業本部 小売DXセンター AirTrackの宗政 一舟(ムネマサ イッシュウ)です。

先月6月18日(木)にSagemakerハンズオンをAWS様にオンラインで開催していただきました。
AirTrack用にカスタマイズされたハンズオンで、チームのデータサイエンティスト・サーバーサイドエンジニア皆で参加しました!

当日の様子と内容をブログにさせていただきます☆

はじめに

機械学習は実世界のサービスで広く活用されるようになってきました。
しかし、データサイエンティストやMLエンジニアの開発した学習モデルをいざサービスに導入しようとした時に
機械学習特有の課題に直面することがあると思います。

学習に用いたデータによって出力される値が変化するため、学習モデルのテストをすることが難しい点や
出力値に異常が存在したことが分かってもデータ、学習アルゴリズム、前処理等のプログラムの
どこに問題があったのかを突き止めることはとても大変です。

リリース時だけでなく、サービスで正常に動作させる運用面の課題もあります。

例えば
時間の経過とともに性能劣化する可能性のある学習モデルを定期的に更新できる状態にしておくこと
オフラインでの評価が良くても本番環境で暴走してしまう恐れがあるため、学習モデルの出力値をモニタリングできるようにしておくこと
などなど。。。

ここ数年で「MLOps」という言葉がでてきたように
機械学習の開発/運用は多くの企業で取り組まれるようになっています。

今回のハンズオンでは、そんな機械学習システムの開発/運用をしていく上で大事な要素の一つである
学習モデルの性能劣化への対応を題材に行っていただきました!

テーマ

AirTrackは位置情報を用いた来訪計測や広告配信を行うことのできるプロダクトです。
来訪見込みの高さを考慮して広告を配信するサービスがAirTrackにはあり、来訪を予測する機械学習モデルを採用しています。
このモデルの性能の劣化が確認されたときに、更新を行える機械学習パイプラインの作成を目指しました。

このハンズオンで想定している状況としては
「old期間中のデータ」で作成した来訪予測モデルが現在本番環境で動いているものとして
「new期間中のデータ」が揃っタイミングで、new期間のモデルとold期間のモデルとの性能を比較し
劣化が確認されたとき本番で動作しているモデルを入れ替えるものになります。

AirTrackサービスに基づいたハンズオンを用意していただいたので、実務を意識して取り組むことができました。

ハンズオンのダイジェスト

作成した機械学習パイプラインのアーキテクチャになります。

「モデルの学習」や「推論の部分」は Amazon SageMakerで作成し
old期間中で作成したモデルとnew期間中で作成したモデルの性能比較をAWS Lambdaで行うような構成で
ワークフローの作成にはStep Functionsを使っています。

 

開発環境の準備

Amazon SageMakerのコンソールからノートブックインスタンスを作成しました。
起動したいインスタンスタイプを設定すると、JupyterまたはJupyterLabで開けるようになります。

 

ノートブックの環境構築が簡単なことに加えて、必要なリソースを選択して開発者が各々で起動できるのは便利ですね☆

 

学習モデルの開発

来訪予測モデル作成にXGBoostを用いました。
AWS Sagemakerでは組み込みのアルゴリズムや独自のアルゴリズムを使うものなど、モデルのトレーニングを定義するのにいくつか方法があるみたいですが
今回はAWS Sagemakerが提供しているXGBoostのフレームワークを利用しました。

以下の xgb_script_mode_estimator の部分で

  •  学習用のpythonのスクリプト
  • 学習が動作するインスタンスサイズ、インスタンスの数
  • ハイパーパラメータ
  • 作成された学習モデルの保存先(S3のパス)

を渡してあげるとXGBoostが使用できるコンテナが呼び出されます。

続いてfit関数で学習の実行します。
この時、train用のデータやvalidation用のデータが置いてあるS3のパスを引数で渡すと
置いてあるデータを読み込みモデルのトレーニングをしてくれます。

学習の状況については
ノートブックインスタンスのセルや

CloudWatch Logsから確認できました。

モデルの開発者は「学習用のデータ」と「学習用のコード」を準備すれば
ノートブックからインスンタンスサイズと数を指定してモデルのトレーニングを実行できるようになるので、本番同様の環境での動作確認がかなりラクになると思いました。

また立ち上がったインスタンスはジョブが終了したら勝手に落ちてくれるので、お金まわりでも嬉しさがあるかもしれません☆

 

実行フローの作成

StepFunctionsに各処理を実行するためのワークフローを定義していきます。
以下のようなワークフローを目指しました。

StepFunctionsでの実行の流れとしては
まず新しいデータに対してモデルのトレーニングジョブ行います。
続いてnew期間中で作成された学習モデルでバッチ推論ジョブold期間中で作成された学習モデルでバッチ推論ジョブの実行。
最後にnew学習モデルとold学習モデルをAUCで評価して、良かった方を採用といったものになります。

 

StepFunctionsの各プロセス(step)の作成

StepFunctionsのビジュアルワークフローのModel Trainingの部分の例です。

trainining_stepにプロセスの名前やestimator、学習に用いるデータのS3パスを渡してTrainingStepを定義します。
ステートマシン図を書かなくてもpythonのSDKからプロセスを定義できるみたいです。
またノートブック上で動作の確認ができていればStepFunctionsのワークフローに乗せられるので、モデル開発者が本番環境のリソースやパッケージのバージョンを気にする負担は減らせるのかなと思いました。

 

StepFunctionsの各プロセス(step)を連結

training_stepのように、その他のプロセスについても定義した後
独立して定義されたプロセスを連結させてあげます。

作成したワークフローはノートブック上で可視化して確認することができます。

またノートブック上からStepFuncsionsをキックし、その実行状況もセルに出力することができました。

ノートブックインスタンス環境から色々なAWSのサービスを使えるようにするためのIAMの設定が必要になりますが
今回のハンズオンで作成した「SageMakerとStepFunctionsを用いた更新フロー」の開発はノートブック上で完結するものになっていました☆
3時間という短い時間で開発環境構築から機械学習パイプライン作成までできてしまうのは衝撃的でした。

最後に

実際のAirTrackサービスを意識した機械学習のパイプラインを作成するハンズオンをAWS様に開催していただきました。
機械学習サービスに携わったことのあるメンバーだけでなく初めて触れるメンバーも参加し、とても良い機会だったと思います。
また早速今回の知見を業務内で活かせる場面もあったりと、収穫の多いハンズオンでした。
オフライン開催の厳しい時世の中、急遽オンライン開催で対応していただき本当にありがとうございました!

ハンズオンの様子です☆

会社に来ていたメンバーで自席以外で作業したいメンバーは少し広めの部屋から参加しました☆

最後まで読んでいただきありがとうございます!

]]>
AI Shift コンペティションを開催 https://www.ai-shift.jp/techblog/1111 Wed, 27 May 2020 09:16:30 +0000 https://ai-blog-admin.cyberagent.ai/?post_type=blog&p=13193