第24回UE5ぷちコンに参加しました

この記事について

2025/07/18(金)~2025/08/31(日)の間、第24回UE5ぷちコン向けの作業を実施して作品を提出しました。

www.youtube.com

本記事は上記に対する振り返り記事(感想文)です。作業を通じて得られた気づきや学びを共有する目的の記事となります。

開始時点の状況

ぷちコン作業開始時点(2025/07/18夜)では「UE5極め本を読破済み」「UdemyのUE関連講座を10時間程度実施済み」の状況でした。
あと、昔UE5.2あたりの時代でPCGを少し触ったことがあったのですが、開始時点ではそのことをすっかり忘れているくらいUEに関する記憶が消えていました(極め本の内容もだいぶ忘れていました)。

こんな状況だったのでUEの使い方を勉強しつつ作品作りを進めていきました。

理想と現実

予定と実績がどうだったか思い出しつつ振り返ります。

目標達成状況

ぷちコンへの参加を決めた理由は「UEを触ってゲームやシミュレーターに対する理解度を引き上げたい」というのが主な理由です。
応募要項で動画投稿が必要ということだったのでサブ目標として「動画投稿者(の真似事)をやってみる」という項目も追加しました。

具体的な目標と実施結果は以下の通りです。

目標 自己評価
毎週進捗報告動画を出す ◎ : 達成
UEで簡単なゲームを作ってみたい ◎ : 達成
UEの天候システムを勉強したい ○ : 達成。標準で入っているものをゲームに組み込めた
物理エンジンを触ってみたい ○ : 達成。ゲームの一部に物理要素を組み込めた
Chaos Destructionを触ってみたい ○ : 達成。ゲームの一部に組み込めた
Niagaraを触ってみたい ○ : 達成。ゲームの一部に組み込めた
Chaos Vehiclesを触ってみたい × : 未達成。ただし今後のモチベーションにつながりそう
流体シミュレーションを触ってみたい × : 未達成
Chaos Clothを触ってみたい × : 未達成

※未達成の目標は時間が足りずチャレンジを断念したのが未達成となった原因です

コメント

動画投稿はやっておいてよかったです。本番の予行演習になったのと、なによりサボり防止効果が大きかったです。
反省点としては最後まで音量調節がうまくいかなかった点が心残りですね。あと、ゲーム内で思い通りの場面を発生させるのが難しくて提出動画が微妙だったのも心残りです(字幕と解説音声で誤魔化しました)。

ぷちコン参加の観点では最小構成とはいえゲームを完成できたというのが次につながるモチベーションになったと感じました。
実際に今回未達成で終わった項目も「機会を見てやりたい」という気持ちを持てています。(いつもなら満足して終わってしまうところだった)

スケジュール

ぷちコンの作業を始めた当初は「勉強しながら少しずつ作っていけばいいや」と軽く考えていたので線表も何もない状態で何となく進めていました。

「このままじゃマズイ」と気づいたのが7/27の進捗報告を作った時でした。想定以上に作るのも勉強も進んでいないと気づいたのです。毎週進捗動画を作っていたことが進捗の客観視につながりました。
そこで急遽7/28週以降の仮線表を引きました。ゲーム内容もその時点の自分に実現可能な内容に絞りました。

以下がその時作った計画(表の「計画時」欄)と実際の実績表です。

日程 計画時 実績
7/18~20 Chaos Destructionで壊れる壁を作成, ボールを発射する機能を実装
7/21週 ボールを止めてエネルギーを貯める機能を実装, 壁が壊れる際に派手に吹き飛ぶ機能を実装, 反射フィールドを実装
7/28週 技術検証(Volumetric Cloud, Niagara, UMG:Unreal Motion Graphicsウィジェットブループリント) 時計台ギミックを実装, UIお試し実装
8/04週 ギミック作成(ジャンプ板+重力, リバーシ壁, 硬い壁) ジャンプ板を実装, リバーシ壁を実装, 硬い壁を実装
8/11週(お盆) Niagaraエフェクト作成, スコアシステム, ギミック作成(霧+風車, 強風+風車, 水車+水流, 落雷) 風車ギミックを実装, 水車ギミックを実装, 落雷ギミックを仮実装, エネルギー貯めにエフェクトを追加, スコアシステム仮実装
8/18週 バランス調整(特にボールの反射), アセット差し替え(出来高。最悪グレーボックスのまま) ステージ1を実装, 通常壁と時計台アセットを本番に差し替え, スコアシステムを本実装, プレイヤーキャラクターにアニメーションを適用, 落雷ギミックを本実装, ボールの反射を仮調整, SEを追加
8/25週(最終) ブラッシュアップ(タイトル画面,ゲーム終了機能), 最終バランス調整 バランス調整, タイトル画面を実装, ステージクリア画面を実装, パッケージ化, BGMを追加

どのように進めたか(マネジメント面)

7/28以降は計画表に従って作業を進めました。計画表と状況を比べて以下の方針で進めました。管理は1週間単位で進めています。

  • 遅れていれば何かをあきらめる
  • 進んでいれば余った時間の枠内で追加できそうな要素を追加する

結果として計画表の内容はほぼ実装しました。「霧+風車」のギミックのみ風車ギミックと差別化が難しくオミットした程度です。
実績欄が多く見えるかもしれませんが短時間で追加できる要素を追加した程度になっています。

当初想定と違っていたこと(甘く考えていたこと)

これは自分で大いに反省すべき点と思いますが、極め本を読んでUEを分かったつもりになって「書けばすぐ動く」と思っていたことがことごとく「やってみないと分からないことだらけ」だったというのが誤算でした。

特にはまったのは以下の機能です。

UEそのものに対する理解度が低いので「自分が思っている動きをしてくれない」場合に何が原因なのかが分からず手当たり次第にパラメータなどをいじるような試行錯誤のパワープレイで進めてしまいました。

これは想像ですが恐らくUEのソースコードを読んである程度仕組みを理解してから触る方が良いのだろうと思います。1か月半の期間中にそれができるかどうかは別問題ですけど

あと、バグなのか仕様なのか自分のやり方が悪いのかよく分からない現象に何度か遭遇して本当に困りました。欲をかいてUE最新版を使わず安定してそうなバージョンにすべきだったかも

技術面での気づき・学び

通化

今回自分でやってみて痛感したのは「内部処理の共通化は本当に重要」ということです。今回は以下2点のみ実施できました。

他にアクタに対してTagを付与したりしました。

途中でリファクタリングしながら進めていたのですが、最初からある程度共通化の構想を練っておいた方が楽だと感じました。
普段UE以外でプログラムを作るときは先にお試し実装してから共通化を考えたりしているのですがUnreal Editorであちこち共通化のためにいじり倒すのは結構面倒に感じました。(単純に慣れてないからかもしれません)

管理用クラス??

単純なアクタとは別に管理用のBPクラスを作りました。例えばリバーシ壁です。

リバーシ壁は以下のスクショにあるように黒の壁と白の壁が通常の壁を挟んだ状態で1つのグループを(内部的に)形成しています。

リバーシ

実装としては専用のBPクラスを作ってConstruction Scriptの中で壁を生成しています。

他にはスコアの管理用クラスなどいくつか「アクタと1対1ではないBPクラス」を作りました。
このあたりの力加減?ベストプラクティス?のようなものがまだよく分かっていません。

音を鳴らす仕組み

今回SEとBGMはどちらもアクタにAudioコンポーネントを追加するかPlay Sound at Locationを呼ぶかどちらかで実装しました。
音量は実際にゲーム内で鳴らしてみて調整しました。

このやり方だと音量調整が音の鳴らす箇所それぞれに手を入れる必要があってゲームが大規模化したらすぐ破綻しそうに感じました。
次にやる時は以下のようにやるのがよさそうと思っています。

  • アセットとしてimportするに音量レベルをそろえておく
  • 音を鳴らす仕組みを共通処理として実装して音量調整できる仕組みを共通箇所に入れる

アセットとゲームのコンセプト

元々ゲームのアセット探しは大変というイメージを持っていて、実際その通りでしたが本当に大変でした。とにかく見つからない。

  1. ゲームの要素を作りこむ
  2. イメージに合致するアセットを探す

という手順で進めたのですがイメージに合うアセットを探すのにとても時間がかかりました。

ぷちコン完走できるか自信がなかったので今回は極力お金を使わない方向で考えていました。期間的にもアセット制作を誰かに依頼するのも厳しいでしょう(そもそもツテがない)。

無料で使わせてもらえるアセットだけでゲームを作るのなら先にアセットを探してからアセットのイメージに合うステージやギミックを発想していくやり方の方が楽かもしれないと感じました。

完走した感想

最後にぷちコンに参加してみての感想ですが、「やってよかった」というのが一番感じたことです。

提出した作品は「ギリギリゲームを名乗れるかどうか」みたいな状態ですが逆に自分の今の立ち位置を再確認できたと思っています。
期間中は大変でしたが勉強はすごく進んだので「手を動かすこと」の重要さを再確認できました。本当にやってみないと分からないことだらけだったと感じています。

ぷちコンを通じて色々勉強できたのでやりたいことが爆発的に増えてしまいました。
直近で何をやろうか悩んでいます(うれしい悲鳴)。

rqt_image_viewでCompressedImageを表示するにはトピック名を固定にする必要がある件

環境

  • ROS2 Humble
  • 以下のパッケージをインストール済み
    • ros-humble-camera-info-manager
    • ros-humble-image-transport
    • ros-humble-image-transport-plugins
  • source /opt/ros/humble/setup.bash実施済み

現象

以下のトピックをpublishしている状況

内容 メッセージ型
カメラ画像 CompressedImage
深度画像 CompressedImage
カメラ情報 CameraInfo

ros2 run rqt_image_view rqt_image_viewでrqt_image_viewのウィンドウを出してカメラ画像のトピックや深度画像のトピックを選択するとエラーのダイアログボックスが表示されて画像が表示されない。

エラーの内容は以下の通り。

カメラ画像

Unable to load plugin for transport 'image_transport/image_sub', error string: According to the loaded plugin descriptions the class image_transport/image_sub with base class type image_transport::SubscriberPlugin does not exist. Declared types are image_transport/compressedDepth_sub image_transport/compressed_sub image_transport/raw_sub image_transport/theora_sub

深度画像

Unable to load plugin for transport 'image_transport/depth_sub', error string: According to the loaded plugin descriptions the class image_transport/depth_sub with base class type image_transport::SubscriberPlugin does not exist. Declared types are image_transport/compressedDepth_sub image_transport/compressed_sub image_transport/raw_sub image_transport/theora_sub

解決方法

こちらのフォーラムに書かれている通り、トピック名の最後(階層の最後のエントリ)が固定名じゃないと表示されない。

項目 トピック名
カメラ画像 /xxx/yyy/compressed
深度画像 /xxx/yyy/compressedDepth
カメラ情報 /xxx/yyy/camera_info

なお、ソースコードを見たらCameraInfoのトピックは他のトピックと同じ階層であることを前提としているようなコードになっていたので階層(上の表の/xxx/yyy部分)をそろえておいた方がよさそう。

余談

CompressedImageの深度画像を表示するときに以下のエラーが表示される。

SubscriberPlugin::subscribeImpl with five arguments has not been overridden

これはHumbleのSubscriberPlugin::subscribeImpl()メソッドで用意されている実装コードでロガーに出しているエラーが表示されている。処理自体は4引数版のsubscribeImpl()を呼んでいてそれらしく動いているので問題はなさそう。

※デフォルトブランチ(記事執筆時rolling)のソースコードでは該当箇所のコードが変化して4引数版メソッドがなくなっているので恐らく当該エラーは出なくなっているのでは(実際の動作は確認していないけど)

DirectML使ってみた

冬は寒いのでDNNの学習を回すのにぴったり!GPUの廃熱で暖房費節約だぜ!などと思ったけれど、メインマシンはメインOSはWindowsで運用していてビデオカードAMDなのでDNNフレームワークを動かすのはしんどい。 調べたらDirectMLってのでWindows+AMDでもいけそうじゃん!ということで試しに使ってみた。
結論としてはGPU使用率があまり高くならず暖かくならなかった

環境構築(共通)

環境はAnacodaを使わずにWindowsPythonをインストールしてvenvで構築する。Anacondaはライセンス変わっちゃったからね

Pythonのインストール

python.orgからインストーラをダウンロードする。自分はこちらのページから3.8.10の「Windows installer (64-bit)」を選んだ。

インストール時にはpipが一緒にインストールされるようにオプションがOnになっていることを確認した。(自分の環境ではデフォルトでOnになっていた)

あと、Pythonのパッケージを入れる際に git.exe と cl.exe も必要になるのでインストールしてパスに追加する。

gitは「Git for Windows」を入れたような気がするが、ずいぶん前の事なので詳細は不明。

cl.exeはMicrosoft C++ Build Toolsページから「Build Tools のダウンロード」ボタンを押してインストーラを取得、インストーラからMicrosoft Visual C++だか何だかを選択して入れたような気がする。(こちらもうろ覚え)

venv

Pythonをインストールした時点でvenvも入っているのでそのままvenv環境を作れる。
構築直後はpipのバージョンが古いのでバージョンアップしておく。この時、直接pipコマンドでバージョンアップしようとすると環境を壊してしまうので注意。python -m pipでバージョンアップする。

>python -m venv env_top_dir
>env_top_dir\Scripts\activate
>python -m pip install --upgrade pip

ONNXRuntime

環境構築

venv環境にonnxとonnxruntime-directmlパッケージをインストールすればOK。

>pip install onnx onnxruntime-directml

お試し

基本的にはDirectMLのExecution Provider(DmlExecutionProvider)を指定するだけだが、2点注意点がある。

  1. opsetバージョンはv17まで
  2. セッションのオプションでenable_mem_patternを無効化しておく必要がある

どちらもDirectML版ONNXRuntimeが対応してないっぽい。ちなみにenable_mem_patternの方は無効化しなくても以下の警告が表示されて自動的に無効化されるっぽい。

[W:onnxruntime:, inference_session.cc:491 onnxruntime::InferenceSession::RegisterExecutionProvider] Having memory pattern enabled is not supported while using the DML Execution Provider. So disabling it for this session since it uses the DML Execution Provider.

以下お試しコード。モデルはConv1個だけのなんちゃってモデル。

import onnx
import onnx.numpy_helper
import numpy as np
import onnxruntime as ort


# Conv1個だけのモデル
inputs  = [onnx.helper.make_tensor_value_info('input' , onnx.TensorProto.FLOAT, [1, 3, 4, 4])]
outputs = [onnx.helper.make_tensor_value_info('output', onnx.TensorProto.FLOAT, [1, 1, 4, 4])]
nodes   = [onnx.helper.make_node('Conv', ['input', 'weight'], ['output'])]
inits   = [onnx.numpy_helper.from_array(1.0 / 4.0 * np.ones([1, 3, 1, 1], dtype=np.float32), 'weight')]
model = onnx.helper.make_model(onnx.helper.make_graph(nodes, 'conv', inputs, outputs, inits), opset_imports=[onnx.helper.make_opsetid('', 17)])

# Onだと警告が出るのであらかじめOff設定を入れておく
options = ort.SessionOptions()
options.enable_mem_pattern = False

# ExecutionProviderにDML版を指定して実行する
sess = ort.InferenceSession(model.SerializeToString(), options, ['DmlExecutionProvider', 'CPUExecutionProvider'])
sess.run(None, {'input': np.ones([1, 3, 4, 4], dtype=np.float32)})

TensorFlow

環境構築

venv環境にpipで入れるだけ。ほかに必要なパッケージは依存パッケージとして自動で入った。

>pip install tensorflow-directml-plugin

お試し

これで普通に動いた。'/job:localhost/replica:0/task:0/device:GPU:0'などと表示されたのでたぶん動いてる。

import tensorflow as tf
a = tf.constant([1.5])
b = tf.constant([0.5])
(a + b).device

あと公式のサンプルをそのまま書かれている通りに実行してみたら普通に動いた。データセットのダウンロードも自動で実行してくれてとても楽だった。

PyTorch

環境構築

同じくvenv環境にpipで入れる。

>pip install torchvision==0.14.0
>pip install torch==1.13
>pip install torch-directml

お試し

torch.deviceをDirectMLのもので指定すれば良いらしい。Tensor.to()には文字列を指定できずtorch.deviceを渡す必要がある。

あと、torch.Tensorをrepr()などで表示しようとするとエラーになる。(CPUに転送すれば表示できる)

import torch
import torch_directml


dml = torch_directml.device()

a = torch.tensor([1.5]).to(dml)
b = torch.tensor([0.5]).to(dml)
c = a + b
c.to('cpu')

簡単なモデルを作って動かしてみたがConv2d、BatchNorm2d、ReLU、Linearあたりは普通に動きそうだった。

mmdetectionでDETRの学習

PyTorchで動く物体検出向けフレームワーク?のMMDetectionを使ってDETR実装で学習を回すところまで改造してみた。

結論を先に言っておくとGPU使用率は上がらず温まらなかったtouch.deviceを入れ替えるだけでは動かなかった。

まだまだCPU実行時と同じ動きをしてくれないオペレーションがあるので既存のフレームワークなんかをそのまま使うのは厳しい、ということが分かった。
今はまだ公式のサンプルを使うのがよさそうに思える。サンプルのyolov3を試そうとしたらデータセットのダウンロード方法がよくわからず面倒になってやめてしまった

環境構築

このあたりを参考にしつつ以下の手順でvenv環境にインストールした。

>pip install mmcv-full==1.7.0 -f https://download.openmmlab.com/mmcv/dist/cpu/torch1.13/index.html
>git clone https://github.com/open-mmlab/mmdetection.git
>cd mmdetection
>pip install -v -e .
>pip install opencv-python

※DirectMLで動かすためにgit cloneしたmmdetectionリポジトリソースコードを改造して無理やり動かしている

さらにDETRの定義ファイルと重みデータをダウンロードする。

>pip install -U openmim
>mim download mmdet --config yolov3_mobilenetv2_320_300e_coco --dest checkpoints

データセットのダウンロード方法を見ながらMS COCOデータセットを用意して、学習の実行方法を参考にした。

最終的にはデータセットの置き場所をEドライブのdatasetsに変更していたので以下の感じで実行した。

>set "MMDET_DATASETS=E:/datasets/coco/"
>python source_packages\mmdetection\tools\train.py checkpoints\detr_r50_8x2_150e_coco.py --cfg-options data.samples_per_gpu=4

samples_per_gpuは1枚のビデオカードで一度に読み出すデータ数らしくてビデオカードが1枚しか存在しない環境ならそのままバッチサイズになるらしい。(たぶん。↑だとバッチサイズ4ということ)

困ったこと

DirectMLで動かそうとして遭遇したことは以下の通り。

  • VRAMが足りなくなるとブルースクリーンでOSごと落ちる(正確にはPCが再起動する)
  • DirectMLが対応していないオペレーションがある
    • エラーになるケース(Pythonの例外が送出される)とエラーにならず実行結果がCPU実行時と異なるケースの2パターンある
    • どちらのケースも該当箇所の処理をCPUデバイスで実行するようにすればとりあえず動くようになる

DirectMLが対応していなかった箇所(DETRで通過する箇所のみ)

mmdet/core/bbox/match_costs/match_cost.py

torch.cdist()で例外になる。

RuntimeError: The size of tensor a (2) must match the size of tensor b (100) at non-singleton dimension 0

CPU実行時はバッチ次元が異なっても問題なく実行できるがDirectML実行時はエラーになる。

@@ -47,8 +47,8 @@ class BBoxL1Cost:
             gt_bboxes = bbox_xyxy_to_cxcywh(gt_bboxes)
         elif self.box_format == 'xyxy':
             bbox_pred = bbox_cxcywh_to_xyxy(bbox_pred)
-        bbox_cost = torch.cdist(bbox_pred, gt_bboxes, p=1)
-        return bbox_cost * self.weight
+        bbox_cost = torch.cdist(bbox_pred.to('cpu'), gt_bboxes.to('cpu'), p=1)
+        return bbox_cost.to(gt_bboxes.device) * self.weight
mmdet/core/bbox/samplers/pseudo_sampler.py

unique()でエラーになる。(※エラーの内容はメモり忘れてた…)

@@ -33,9 +33,9 @@ class PseudoSampler(BaseSampler):
             :obj:`SamplingResult`: sampler results
         """
         pos_inds = torch.nonzero(
-            assign_result.gt_inds > 0, as_tuple=False).squeeze(-1).unique()
+            assign_result.gt_inds > 0, as_tuple=False).squeeze(-1).cpu().unique().to(gt_bboxes.device)
         neg_inds = torch.nonzero(
-            assign_result.gt_inds == 0, as_tuple=False).squeeze(-1).unique()
+            assign_result.gt_inds == 0, as_tuple=False).squeeze(-1).cpu().unique().to(gt_bboxes.device)
         gt_flags = bboxes.new_zeros(bboxes.shape[0], dtype=torch.uint8)
         sampling_result = SamplingResult(pos_inds, neg_inds, bboxes, gt_bboxes,
                                          assign_result, gt_flags)
mmdet/models/dense_heads/detr_head.py

このファイルは2か所あって、1つ目はテンソルの一部をSlice指定で上書きするコードがDirectMLだとなぜか上書きされないという挙動になる。 2つ目はバッチ次元が0(教師データのBBox数が0個)の時に [0, 4] shape との演算にDirectMLが対応していなくて例外になる。

RuntimeError: self must have at least one element!

@@ -244,10 +244,11 @@ class DETRHead(AnchorFreeHead):
         # ignored positions, while zero values means valid positions.
         batch_size = x.size(0)
         input_img_h, input_img_w = img_metas[0]['batch_input_shape']
-        masks = x.new_ones((batch_size, input_img_h, input_img_w))
+        masks = x.new_ones((batch_size, input_img_h, input_img_w)).cpu()
         for img_id in range(batch_size):
             img_h, img_w, _ = img_metas[img_id]['img_shape']
             masks[img_id, :img_h, :img_w] = 0
+        masks = masks.to(x.device)

         x = self.input_proj(x)
@@ -537,8 +538,8 @@ class DETRHead(AnchorFreeHead):
         # the box format should be converted from defaultly x1y1x2y2 to cxcywh.
         factor = bbox_pred.new_tensor([img_w, img_h, img_w,
                                        img_h]).unsqueeze(0)
-        pos_gt_bboxes_normalized = sampling_result.pos_gt_bboxes / factor
-        pos_gt_bboxes_targets = bbox_xyxy_to_cxcywh(pos_gt_bboxes_normalized)
+        pos_gt_bboxes_normalized = sampling_result.pos_gt_bboxes / factor if len(sampling_result.pos_gt_bboxes) else sampling_result.pos_gt_bboxes
+        pos_gt_bboxes_targets = bbox_xyxy_to_cxcywh(pos_gt_bboxes_normalized) if len(sampling_result.pos_gt_bboxes) else sampling_result.pos_gt_bboxes
         bbox_targets[pos_inds] = pos_gt_bboxes_targets
         return (labels, label_weights, bbox_targets, bbox_weights, pos_inds,
                 neg_inds)

torch.device関連コード(参考)

説明が面倒になってきたのでそのままソースコードの差分だけ貼っておきます。

ちゃんと対応するにはmmcv側から改造が必要になるのと、mmdetection内ではtorch.deviceを使わずデバイス名のstrを受け取る形で実装されているので、以下の箇所以外に色々修正しないとダメだったりで不完全なので。

mmdet/apis/inference.py
@@ -151,6 +151,7 @@ def inference_detector(model, imgs):
             assert not isinstance(
                 m, RoIPool
             ), 'CPU inference with RoIPool is not supported currently.'
+        data['img'] = [cpu_tensor.to(device) for cpu_tensor in data['img']]

     # forward the model
     with torch.no_grad():
mmdet/apis/train.py
@@ -41,6 +41,10 @@ def init_random_seed(seed=None, device='cuda'):
     if world_size == 1:
         return seed

+    if device == 'dml':
+        import torch_directml
+        device = torch_directml.device()
+
     if rank == 0:
         random_num = torch.tensor(seed, dtype=torch.int32, device=device)
     else:
mmdet/utils/util_distribution.py
@@ -33,6 +33,12 @@ def build_dp(model, device='cuda', dim=0, *args, **kwargs):
         from mmcv.device.mlu import MLUDataParallel
         dp_factory['mlu'] = MLUDataParallel
         model = model.mlu()
+    elif device == 'dml':
+        import torch_directml
+        from mmdet.device.dml import DMLDataParallel
+        dp_factory['dml'] = DMLDataParallel
+        dml = torch_directml.device()
+        model = model.to(dml)

     return dp_factory[device](model, dim=dim, *args, **kwargs)
@@ -55,7 +61,7 @@ def build_ddp(model, device='cuda', *args, **kwargs):
                      DistributedDataParallel.html
     """
     assert device in ['cuda', 'mlu',
-                      'npu'], 'Only available for cuda or mlu or npu devices.'
+                      'npu', 'dml'], 'Only available for cuda or mlu or npu devices.'
     if device == 'npu':
         from mmcv.device.npu import NPUDistributedDataParallel
         torch.npu.set_compile_mode(jit_compile=False)

@@ -81,9 +93,18 @@ def is_mlu_available():
     return hasattr(torch, 'is_mlu_available') and torch.is_mlu_available()


+def is_dml_available():
+    try:
+        import torch_directml
+        return torch_directml.is_available()
+    except ImportError as e:
+        return False
+
+
 def get_device():
     """Returns an available device, cpu, cuda or mlu."""
     is_device_available = {
+        'dml': is_dml_available(),
         'npu': is_npu_available(),
         'cuda': torch.cuda.is_available(),
         'mlu': is_mlu_available()
mmdet/device/dml 配下

こちらは本来はmmcvに入れるべきコード。面倒なのでmmdetection配下に入れた。

# __init__.py
from ._functions import scatter, scatter_kwargs
from .data_parallel import DMLDataParallel
from .distributed import DMLDistributedDataParallel


__all__ = ['scatter', 'scatter_kwargs', 'DMLDataParallel', 'DMLDistributedDataParallel']


# _functions.py
import torch
import torch_directml
from typing import Union, List
from mmcv.parallel.data_container import DataContainer
from mmcv.device._functions import Scatter


def _scatter_core(current_device: torch.device, obj: Union[List, torch.Tensor]):
    if isinstance(obj, list):
        return [_scatter_core(current_device, elem) for elem in obj]
    elif isinstance(obj, torch.Tensor):
        return obj.to(current_device)
    else:
        raise RuntimeError(f'obj is unsupported type {type(obj)}')


def _scatter_data_container(current_device: torch.device, obj: DataContainer):
    outputs = _scatter_core(current_device, obj.data)
    return tuple(outputs) if isinstance(outputs, list) else (outputs,)


def scatter(inputs, target_devices, dim=0):
    device_id = next(iter(target_devices), torch_directml.default_device())
    current_device = torch_directml.device(device_id)

    def scatter_map(obj):
        if isinstance(obj, torch.Tensor):
            if target_devices != [-1]:
                obj = obj.to(current_device)
                return [obj]
            else:
                # for CPU inference we use self-implemented scatter
                return Scatter.forward(target_devices, obj)
        if isinstance(obj, DataContainer):
            if obj.cpu_only:
                return obj.data
            else:
                return _scatter_data_container(current_device, obj)
        if isinstance(obj, tuple) and len(obj) > 0:
            return list(zip(*map(scatter_map, obj)))
        if isinstance(obj, list) and len(obj) > 0:
            out = list(map(list, zip(*map(scatter_map, obj))))
            return out
        if isinstance(obj, dict) and len(obj) > 0:
            out = list(map(type(obj), zip(*map(scatter_map, obj.items()))))
            return out
        return [obj for _ in target_devices]

    try:
        return scatter_map(inputs)
    finally:
        scatter_map = None


def scatter_kwargs(inputs, kwargs, target_devices, dim=0):
    inputs = scatter(inputs, target_devices, dim) if inputs else []
    kwargs = scatter(kwargs, target_devices, dim) if kwargs else []

    if len(inputs) < len(kwargs):
        inputs.extend([() for _ in range(len(kwargs) - len(inputs))])
    elif len(kwargs) < len(inputs):
        kwargs.extend([{} for _ in range(len(inputs) - len(kwargs))])

    inputs = tuple(inputs)
    kwargs = tuple(kwargs)

    return inputs, kwargs


# data_parallel.py
import torch_directml
from mmcv.parallel import MMDataParallel
from ._functions import scatter_kwargs


class DMLDataParallel(MMDataParallel):
    def __init__(self, *args, dim=0, **kwargs):
        super().__init__(*args, dim=dim, **kwargs)

        self.device_ids = kwargs.get('device_ids', [torch_directml.default_device()])
        self.src_device_obj = torch_directml.device(self.device_ids[0])

    def scatter(self, inputs, kwargs, device_ids):
        return scatter_kwargs(inputs, kwargs, device_ids, dim=self.dim)

venvのトップディレクトリにPyTorchの重みファイル(pth)を置いちゃダメって話

横着したらハマったので自戒を込めてメモ。

まとめ

  • Pythonには「サイト固有の設定フック」という機能がある
    • venv環境のトップディレクトリに入っている .pth ファイルを読み込んで処理する
  • PyTorchの学習済み重みファイル(拡張子 .pth)を置いていると上記機能と衝突して誤動作する
    • エラーでpython.exeが実行できなくなる。pipコマンドもエラーで実行できなくなる
  • venv環境と作業ディレクトリはツリーを分けよう(戒め)

サイト固有の設定フック機能

  • venv環境の直下とlib\site-packagesディレクトリに入っている .pth ファイルを読み込むらしい
    • 後述するsite.pyにprint文を埋め込んで上記2ディレクトリが対象になっていることを確認した
  • .pthファイルにはパス名が書かれている前提っぽい

エラーの内容

venvのトップディレクトリ(以下の例だとE:\soft\env_torch直下)に重みファイル(.pth)を置いてからpython.exeを実行しようとすると以下のようなエラーになる。

(env_torch) E:\soft\env_torch>python
Fatal Python error: init_import_size: Failed to import the site module
Python runtime state: initialized
Traceback (most recent call last):
  File "E:\soft\Python38\lib\site.py", line 580, in <module>
    main()
  File "E:\soft\Python38\lib\site.py", line 563, in main
    known_paths = venv(known_paths)
  File "E:\soft\Python38\lib\site.py", line 495, in venv
    addsitepackages(known_paths, [sys.prefix])
  File "E:\soft\Python38\lib\site.py", line 350, in addsitepackages
    addsitedir(sitedir, known_paths)
  File "E:\soft\Python38\lib\site.py", line 208, in addsitedir
    addpackage(sitedir, name, known_paths)
  File "E:\soft\Python38\lib\site.py", line 164, in addpackage
    for n, line in enumerate(f):
UnicodeDecodeError: 'cp932' codec can't decode byte 0x8a in position 2: illegal multibyte sequence

PyTorchの重みファイルはバイナリファイルだが、これをパス名が記載されているテキストファイルとして読み込もうとしてエラーになっているっぽい。

エラーメッセージは文字コード関連のメッセージだが騙されてはいけない。環境変数PYTHONUTF8を設定しても無駄である。

pythonもpipも実行できなくなるので問題のデバッグ自体がつらくなるので注意。今回はsite.pyを直接編集してprint文を埋め込んでデバッグした。

結論

  • venv環境の直下にはPyTorchの重みファイルを置いてはいけない
  • そもそも作業ディレクトリを別ツリーに分けておけば回避できる(横着はよくなかった)

一応.ptファイルなら誤動作しないと思われるが、そこは問題の本質ではないと思う。論文の実装コードで自動ダウンロードが走ったりすることがあるし。

CUDA実装のONNXRuntimeカスタムオペレータを実装してみた

前回の続き。

前回の記事 ↓ maminus.hatenadiary.org

今回のソースコードgithub.com

ありがたいことにGitHubのIssueでCUDA版のカスタムオペレータ実装方法について問い合わせをいただいたので時間ができた時に実装してみた。

※ONNXRuntimeのカスタムオペレータについては前回の記事を参照

ONNXRuntimeのExecution Providerについて

CUDA版のカスタムオペレータを実装する場合、一応以下の2パターン方法が考えられる。

  1. カスタムオペレータだけCUDAで実行し、他のオペレータはCPUで実行する
  2. すべてCUDAで実行する

1のパターンはあまりうれしくないと思うので2のカスタムオペレータも他のオペレータもCUDAで動かすことを考える。

この時デフォルトだとONNXRuntimeはCPUで動くのでCUDA版のExecution Providerというのを指定して動かす必要がある。

Execution ProviderというのはONNXRuntime内のフレームワークらしくて、推論処理などのハードウェアアクセラレーションが受けられそうな箇所を切り替え可能な機能ブロックとして分離しているらしい。

詳細は以下の公式ドキュメントを参照のこと。 onnxruntime.ai

そして、CUDAExecutionProviderを使うと推論処理をCUDA版で実行できるらしい。

CUDA版のカスタムオペレータを実装する時にはCUDAExecutionProvider版のカスタムオペレータとして実装すれば全体がCUDAで実行できることになる。

CUDAExecutionProviderでカスタムオペレータを実装する際のポイント

少しだけ前回の復習をしておくとC++でカスタムオペレータを実装する時には主に以下の要素を実装した。

実装すべきもの 主な用途
kernelクラス 計算処理本体
オペレータクラス カスタムオペレータの仕様(引数の個数など)に関する情報を返すクラス
Register関数など ONNXRuntimeへの登録

CUDA版を実装する際には大雑把には以下のような実装内容の違いがある。

実装すべきもの CPU版 CUDA版
kernelクラス 計算処理本体を実装する CUDAホスト関数を呼び出す
オペレータクラス CPUExecutionProviderとして実装する CUDAExecutionProviderとして実装する
Register関数など CPU/CUDAどちらも同じ CPU/CUDAどちらも同じ
CUDAソースコード 実装不要 計算処理本体を実装する

具体的な変更点は以下の通り。

  • kernelクラスのCompute()メソッド
    • GetTensorData()はCUDAのデバイスポインタが返ってくるのでそのままCUDA関数に渡してOK
    • CUDAのStream IDはKernelContext_GetGPUComputeStream()で取得できる(該当コード
  • オペレータクラスのGetExecutionProviderType()メソッド
    • CPU版ではオーバーライド不要
    • 文字列"CUDAExecutionProvider"を返すようにオーバーライドすることでCUDAExecutionProviderになる(該当コード

Pythonの呼び出しコード

Pythonの推論処理コードはInferenceSessionのコンストラクタ引数にExecutionProviderのリストを追加で指定するようにすればOK。

import onnxruntime as ort


model = ...

# カスタムオペレータを実装したDLLをロードする
option = ort.SessionOptions()
option.register_custom_ops_library('./libmy_custom_multi_with_cuda.so')

# 使いたいExecutioinProviderを指定する
providers = ['CUDAExecutionProvider']

# カスタムオペレータDLLとExecutionProviderを指定してセッションを生成する
sess = ort.InferenceSession(model.SerializeToString(), option, providers)

# sess.run()で推論できる
...

お試しコードか上で紹介した公式ドキュメントも参考に。

ONNXRuntimeのカスタムオペレータを実装してみた

ソースコードはここ↓ github.com

背景とか

TensorRTを使おうとしてモデルの一部をONNXのカスタムオペレータにすることがある。(TensorRTのプラグインを使うケース)

ただカスタムオペレータを含むモデルはそのままではONNXRuntimeで推論できない。ということはONNX Simplifierのようなツールを使うこともできない。 カスタムオペレータを実装することで推論が可能になってツール類も使える。

# ONNXのカスタムオペレータはノードの'domain' attributeに独自ドメイン名を指定すれば作れる
nodes = [onnx.helper.make_node('Fma', ['A', 'B', 'C'], ['out'], domain='ai.onnx.contrib')]

カスタムオペレータの実装手段

少し調べたところ、大きく分けて2つのやり方がある。

  1. Pythonでカスタムオペレータを実装する
  2. C++でカスタムオペレータを実装する

Pythonで実装すると推論コードもPythonで記載できてPythonのみで実現できるので楽ちん。ただし、onnxruntime-extensionsパッケージが必要になるのと、強めの制約がある。(後述)

C++は実装が面倒だがPythonよりは制約を緩められる。

Pythonで実装する方法

onnx_opデコレータをカスタムオペレータ実装ルーチンにつけるだけ。引数のテンソルはnumpy.ndarrayが渡される。戻り値のテンソルもndarrayを返せばOK。

# 引数はすべてfloat32型、戻り値もfloat32型。op_typeは'Fma'
@onnx_op(op_type='Fma', inputs=[PyOp.dt_float, PyOp.dt_float, PyOp.dt_float], outputs=[PyOp.dt_float])
def fma(a, b, c):
    return a * b + c

ただし、float32バージョンfloat64バージョン、のように扱うデータ型のバリデーションを作ることができない。 これはop_typeに対するルーチンが1つしか登録できない作りになっているためと思われる。

推論は以下のようにする。以下のコードでmodel_func()の呼び出しがONNXモデル全体の推論実行処理になっている。

model_func = PyOrtFunction.from_model(_ONNX_FILE_NAME)
result = model_func(A, B, C)

C++で実装する方法

C++で実装する場合は主に以下の要素を用意すればよい。

  • void Compute(OrtKernelContext* context)メソッドを持つkernelクラス
    • 計算処理本体を実装する
  • Ort::CustomOpBase<Op, Kernel>クラスを継承し必要なメソッドを実装したクラス
    • void* CreateKernel(OrtApi api, const OrtKernelInfo* info) const
    • const char* GetName() const
      • op_type名を返す
    • ONNXTensorElementDataType GetInputType(size_t index) const
      • index番目の入力データ型を返す
    • size_t GetInputTypeCount() const
      • オペレータ引数の数を返す
    • OrtCustomOpInputOutputCharacteristic GetInputCharacteristic(size_t index) const
      • 省略可能な引数を持つ場合に実装する
      • index番目の引数が必須か省略可能かを返す
    • ONNXTensorElementDataType GetOutputType(size_t index) const
      • index番目の戻り値のデータ型を返す
    • size_t GetOutputTypeCount() const
      • オペレータの戻り値の数を返す
    • OrtCustomOpInputOutputCharacteristic GetOutputCharacteristic(size_t index) const
      • 省略可能な戻り値を持つ場合に実装する
      • index番目の戻り値が必須か省略可能かを返す
  • RegisterCustomOps()関数

ソースコードのビルドはONNXRuntimeの3つのヘッダファイルがあればOK。 CMakeLists.txtではfind_path()でヘッダファイルの場所を探すようにしているので参考に。

kernelクラス

kernelクラスは大雑把に以下の構造になるように実装する。

struct FmaKernel {
    FmaKernel(OrtApi api):api_(api), ort_(api_) {}

    void Compute(OrtKernelContext* context) {
        // ... カスタムオペレータの計算処理
    }
private:
    OrtApi api_;
    Ort::CustomOpApi ort_;
};

入力データへのポインタなどの計算に必要なデータはOrt::CustomOpApiでアクセスできる。ただし、CustomOpApiクラスはコンストラクタ引数のOrtApiインスタンスを参照で保持するためOrtApiインスタンスのコピーをkernelクラスのメンバに保持する必要があるとのこと。

// 0番目の引数(float型)へのポインタをもらう例
const auto input_a = ort_.KernelContext_GetInput(context, 0);
auto ptr_a = ort_.GetTensorData<float>(input_a);

// 出力0を[1, 3, 224, 224]のshapeで作ってポインタをもらう例
size_t shape_dim = 4;
const int64_t shape[shape_dim] = {1, 3, 224, 224};
auto output_0 = ort_.KernelContext_GetOutput(context, 0, shape, shape_dim);
auto ptr_0 = ort_.GetTensorMutableData<float>(output_0);

もし出力shapeが入力と同じならGetTensorTypeAndShape()GetTensorShape()を呼ぶと入力shapeをもらえるので出力shapeの指定にそのまま使えばよい。

オペレータクラス

オペレータクラスは以下のようにCustomOpBaseクラスを継承する。CustomOpBaseクラスのテンプレート引数には自分自身とkernelクラスを指定する。

struct CustomOpFma : Ort::CustomOpBase<CustomOpFma, FmaKernel> {
    // 対応するkernelクラスのインスタンスをnewして返す
    void* CreateKernel(OrtApi api, const OrtKernelInfo* info) const {
        return new FmaKernel(api);
    }
    // 単純に対応するop_type名を返すだけでOK
    const char* GetName() const {
        return "Fma";
    }
    // ...
};

後のI/Fは特筆すべき内容は無いのでinput側だけ掲載する。具体的な実装コードはGitHubにpushした実装コードを参照のこと。

 // index番目の引数のデータ型をONNXTensorElementDataType(onnxruntime_c_api.hで定義されている)で返す
    ONNXTensorElementDataType GetInputType(size_t index) const {
        if (index > 0) {
            // "T"を指定可能なのは1つの引数のみ。残りはFLOATなどの具体的な型を返す必要がある
            return ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT;
        }
        // UNDEFINEDを返すとデータ型は"T"(任意型)として扱われる
        return ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED;
    }
    // 引数の数を返す。Fmaの例なら引数は3つなので3を返せばOK
    size_t GetInputTypeCount() const {
        return 3;
    }
    // index番目の引数が省略可能ならOPTIONAL、必須ならREQUIREDを返す
    OrtCustomOpInputOutputCharacteristic GetInputCharacteristic(size_t index) const {
        if (index > 1) {
            // 3つ目(index == 2)の引数を省略可能とする例
            return OrtCustomOpInputOutputCharacteristic::INPUT_OUTPUT_OPTIONAL;
        }
        return OrtCustomOpInputOutputCharacteristic::INPUT_OUTPUT_REQUIRED;
    }

注意点

float32版オペレータfloat64版オペレータなどと複数データ型に対応させたい場合に問題点がある。2つのやり方がある。

  1. データ型をONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINEDで報告する
  2. データ型ごとに別々のドメイン名に分ける

1の方法はデータ型を"T"(任意型)に指定する方法だが、任意型をとる引数が1つのみのケースしか使えない。FMAのように3つの引数を"T"と指定することができない。

2の方法はONNXモデル側でドメインを分ける必要が発生してしまうかわりに各データ型に対応する実装を作ることができる。もしデータ型ごとにop_type名を変えることができるならドメイン名は同じでop_type名で分ける方法もある。 (カスタムオペレータはop_type名につき1種類の実装しか登録できないためop_type名かドメイン名を分ける必要がある)

RegisterCustomOps関数

このルーチンはDLLのロード時(正確にはregister_custom_ops_library()呼び出し時)に呼ばれる。

constexpr std::string_view domain_name = "my_ops";
CustomOpFma op_fma;

extern "C" {
OrtStatus* ORT_API_CALL RegisterCustomOps(OrtSessionOptions* options, const OrtApiBase* api_base) {
    const OrtApi* ort_api = api_base->GetApi(ORT_API_VERSION);
    OrtStatus* status;

    // ドメインオブジェクトを作る
    OrtCustomOpDomain* domain = nullptr;
    if (status = ort_api->CreateCustomOpDomain(domain_name.data(), &domain)) {
        return status;
    }

    // ドメインオブジェクトを(後で解放処理を実行するために)覚えておく
    register_domain(domain, ort_api);

    // ドメインオブジェクトにカスタムオペレータを登録する
    if (status = ort_api->CustomOpDomain_Add(domain, &op_fma)) {
        return status;
    }

    // ドメインオブジェクトをONNXRuntimeに登録する
    status = ort_api->AddCustomOpDomain(options, domain);
    return status;
}
}   // extern "C"

register_domain()は何かのI/Fとかではなく、今回独自に実装した。やっていることはドメインオブジェクトのポインタをdeleter付きのunique_ptrでくるんでvectorに登録しているだけ。 ドメインオブジェクトだけ?はインスタンスの削除をこちらで実施する必要があるっぽい。そしてdeleteで削除するのではなくRelease系メソッド呼び出しが必要らしい。

具体的な処理内容は実装コードを参照のこと。

推論処理

Pythonで推論するには以下のようにInferenceSessionの引数にSessionOptionsを渡せばよい。(ONNXモデルのカスタムオペレータのドメイン名、op_type名とC++実装コードのドメイン名、CustomOpFma::GetName()が返す名前が一致している必要がある)

import onnx
import numpy as np
import onnxruntime as ort

option = ort.SessionOptions()
option.register_custom_ops_library('./libmy_custom_t.so')

model = onnx.load(_ONNX_FILE_NAME)
sess = ort.InferenceSession(model.SerializeToString(), option)

A = np.ones([1], dtype=np.float32)
B = np.ones([1], dtype=np.float32)
results = sess.run(None, {'A': A, 'B': B})

TensorRTビルド(とEfficientDet実行環境)のDockerfile作ってみた

AutoML版EfficientDetをTensorRT化しようとして色々あってTensorRTのビルド環境と一緒に環境作りたくなったのでDockerfileとscriptを作ってgitにpushしてみた。

github.com

はまったことや注意点を列挙したい。

pycudaより前にnumpyのインストールが必要

setup.pyでひたすらエラーになって何度もバージョンをかけてsetup.pyを実行しようとしてしまうので先にnumpyをインストールする

ARM版bazelのインストール方法がややこしかった

結局実行ファイルを直接ダウンロードして配置する方法しかダメだった

tensorflow-addonsのバージョン依存パナイ

tensorflow-hubとtensorflow-addonsはARM版パッケージが登録されてなさそうなのでソースコードからビルドした。

addonsはバージョン依存がきつくて一覧表とにらめっこしてふさわしいバージョンを探す必要がある。

AutoML版EfficientDetのバージョン依存

masterブランチだと指定されているバージョンのライブラリを用意できないので1.2のソースコードを使うしかなかった。ただし、kerasディレクトリが本家Kerasパッケージと名称衝突してる件とかTensorFlow v2.6のtf.data.experimental.OptimizationOptionsからmap_vectorizationが消えていたりして1.2のソースコードそのままだと実行ができなかった。
(さすがにこの部分はスクリプトにも含めなかった)