1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

hunyuanvideoのVAE tiledについて

Posted at

hunyuanvideoのComfyUIでは以下のようなVAE Tiledのパラメータがある。
しかしながらこれらのパラメータが何を表すかはよく分からないし、値を触るのもなんか怖い。このためこれが何なのか全く理解できないないまま出力width、height、frame_sizeを触るのだが、これらに関係があるかを考えてみたい。

デフォルトは上から256,64,64,8だが値を半分にするとVRAMメモリが節約できるという記事があった。temporalは「時間的な」という意味である。

image.png

画像生成AIのVAE Decode Tiled

2次元VAEのTiledの解説が下記にある。

要するに非常に巨大な画像を一回でVAEに突っ込むと非常に大きなVRAMを消費するので、分割しながらVAEに突っ込めば最大使用VRAMはある一定の大きさで抑えられる。しかし、分割してVAEに突っ込むと分割端の画像が若干変質してしまうため分割にオーバーラップを用いて分割境界の端を少し広くとって、オーバーラップ分をブレンドする。

このようにVAE Decode Tiledという概念は画像生成AIの頃にあったが、動画生成AIでは時間軸でもoverlapを考える必要がある。
hunyuanvideoは3次元VAEであるがtiledという点では同じだろう。

diffuser hunyuanvideo VAE

diffuserのhunyuanvideo VAEのコードを確認してみたい。

気になるパラメータを抜き出すと以下が目につく。タイル幅とstride幅が見て取れる。しかし、これらの値はComfyUIのパラメータ定義(タイル幅とオーバーラップ幅を定義)と違いがある。

        spatial_compression_ratio: int = 8,
        temporal_compression_ratio: int = 4,
...
        # The minimal tile height and width for spatial tiling to be used
        self.tile_sample_min_height = 256
        self.tile_sample_min_width = 256
        self.tile_sample_min_num_frames = 16

        # The minimal distance between two spatial tiles
        self.tile_sample_stride_height = 192
        self.tile_sample_stride_width = 192
        self.tile_sample_stride_num_frames = 12

空間タイルオーバーラップ

空間タイルのオーバーラップの抜き出し方は以下の通りである。
sampleはVAE Decoderを通ったあとの動画の形で、latentはVAE Decoderを通る前の潜在変数次元。
潜在空間では空間は1/8、時間軸は1/4に圧縮される。
しかし、空間は単純に1/8だが、時間軸は厳密に1/4ではない。

さて、ここでtile_latent_min_height=256/8=32、tile_latent_stride_height=192/8=24、blend_height=256-192=64
抜き出す幅がtile_latent_min_heightで、抜き出す間隔がtile_latent_stride_height
要するにVAE Decoderを通った後のsampleのオーバーラップ間隔は64となる。
for i in range(0, height, tile_latent_stride_height):
tile = z[:, :, :, i : i + tile_latent_min_height, j : j + tile_latent_min_width]
これは前述のComfyUIのtile_size=256とoverwrap=64の設定と等しい。

image.png

    def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> 
    ...

        batch_size, num_channels, num_frames, height, width = z.shape
        sample_height = height * self.spatial_compression_ratio
        sample_width = width * self.spatial_compression_ratio

        tile_latent_min_height = self.tile_sample_min_height // self.spatial_compression_ratio
        tile_latent_min_width = self.tile_sample_min_width // self.spatial_compression_ratio
        tile_latent_stride_height = self.tile_sample_stride_height // self.spatial_compression_ratio
        tile_latent_stride_width = self.tile_sample_stride_width // self.spatial_compression_ratio

        blend_height = self.tile_sample_min_height - self.tile_sample_stride_height
        blend_width = self.tile_sample_min_width - self.tile_sample_stride_width

        # Split z into overlapping tiles and decode them separately.
        # The tiles have an overlap to avoid seams between tiles.
        rows = []
        for i in range(0, height, tile_latent_stride_height):
            row = []
            for j in range(0, width, tile_latent_stride_width):
                tile = z[:, :, :, i : i + tile_latent_min_height, j : j + tile_latent_min_width]
                tile = self.post_quant_conv(tile)
                decoded = self.decoder(tile)
...

時間タイルオーバーラップ

tile_sample_min_num_frames = 16、tile_sample_stride_num_frames = 12ならば、tile_latent_min_num_frames=16/4=4、tile_latent_stride_num_frames=12/4=3、blend_num_frames=16-12=4であるから時間オーバーラップは4にみえる。

しかし、時間軸は厳密な1/4ではないため、latent_num_frame=$(\frac{T}{4}+1)=N$とおけば$(T+1)=4*(\frac{T}{4}+1)-3=4N-3$となる。
image.png

従ってlatentを4フレームずつVAEに突っ込んだ時に出力frameが$16$ではなく$13$フレームになるならば時間軸のオーバーラップは不完全にならないだろうか、という疑問が浮かんだ。

image.png

しかし、再度コードを読んでみるとtile = z[:, :, i : i + tile_latent_min_num_frames + 1, :, :]と$+1$が入ってるので$4N-3$ではなく$4N+1$なのかもしれない。それなら出力フレームは$17$となりオーバーラップに問題はない。
しかし、ComfyUIのtemporal_size=64,temporal_overlap=8にくらべ、diffuserはsize=17、overlap=5なのでちょっと大きさが異なるように思える。
厳密にはdiffuserもtemp_size=16、temp_stride=12、temp_overlap=4であるからブレンド幅が1小さい。

image.png

    def _temporal_tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> Union[DecoderOutput, torch.Tensor]:
        batch_size, num_channels, num_frames, height, width = z.shape
        num_sample_frames = (num_frames - 1) * self.temporal_compression_ratio + 1

        tile_latent_min_height = self.tile_sample_min_height // self.spatial_compression_ratio
        tile_latent_min_width = self.tile_sample_min_width // self.spatial_compression_ratio
        tile_latent_min_num_frames = self.tile_sample_min_num_frames // self.temporal_compression_ratio
        tile_latent_stride_num_frames = self.tile_sample_stride_num_frames // self.temporal_compression_ratio
        blend_num_frames = self.tile_sample_min_num_frames - self.tile_sample_stride_num_frames

        row = []
        for i in range(0, num_frames, tile_latent_stride_num_frames):
            tile = z[:, :, i : i + tile_latent_min_num_frames + 1, :, :]
            if self.use_tiling and (tile.shape[-1] > tile_latent_min_width or tile.shape[-2] > tile_latent_min_height):
                decoded = self.tiled_decode(tile, return_dict=True).sample
...

タイルブレンド

抜き出されたタイルは以下のようにブレンドされる。これは$c=a*(1-t)+b*t$みたいな感じで$0<t<1$で$c$は$a~b$の間を連続的に変位する。これを線形補間という。
たしかに$a,b$の2つの値をブレンドするだけならこの考え方は正しい。

ただ、二次元的に考えるなら正方形の四隅は4つの値がブレンドされることになろう。
三次元的に考えるなら立方体の頂点の八隅は8つの値がブレンドされるのだろう。また、立方体の辺上は4つの値のブレンドになろう。

例えば正方形の4点のブレンドは二個の変数、$0<t_1<1,0<t_2<1$を用いて、$v=a*(1-t_1)*(1-t_2)+b*(1-t_1)*t_2+c*t_1*(1-t_2)+d*t_1*t_2$
となろう。これを双線形(Bilinear)補間という。diffuserのブレンド方法が4点や8点のブレンド方法を与える式になっているのか自分にはよく分からない。

    def blend_v(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int) -> torch.Tensor:
        blend_extent = min(a.shape[-2], b.shape[-2], blend_extent)
        for y in range(blend_extent):
            b[:, :, :, y, :] = a[:, :, :, -blend_extent + y, :] * (1 - y / blend_extent) + b[:, :, :, y, :] * (
                y / blend_extent
            )
        return b

    def blend_h(self, a: torch.Tensor, b: torch.Tensor, blend_extent: int) -> torch.Tensor:
        blend_extent = min(a.shape[-1], b.shape[-1], blend_extent)
        for x in range(blend_extent):
            b[:, :, :, :, x] = a[:, :, :, :, -blend_extent + x] * (1 - x / blend_extent) + b[:, :, :, :, x] * (
                x / blend_extent
            )
        return b
...
    def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> 
...
        for i, row in enumerate(rows):
            result_row = []
            for j, tile in enumerate(row):
                # blend the above tile and the left tile
                # to the current tile and add the current tile to the result row
                if i > 0:
                    tile = self.blend_v(rows[i - 1][j], tile, blend_height)
                if j > 0:
                    tile = self.blend_h(row[j - 1], tile, blend_width)
                result_row.append(tile[:, :, :, : self.tile_sample_stride_height, : self.tile_sample_stride_width])
            result_rows.append(torch.cat(result_row, dim=-1))

参考:

VAE encode

前述のオーバーラップはtiled_decode、_temporal_tiled_decodeの「デコード(latent→動画)」でオーバーラップを作成していたが、実は「エンコード(動画→latent)」でも同じようなオーバーラップがある。3D VAE Encoder処理が必要となるのは将来的にhanyuanvideoでi2vやv2vの画像やビデオを入力にするときになるだろうが、その時もこのような空間や時間軸のオーバーラップの設定に留意する必要があるのかもしれない。

…とMovie Genの論文に載ってる図を見て思った。
以下は時間軸で直接VAEでvideo(sample)をlatentに変換するのではなくオーバーラップで抜き出してlatentを時間軸でブレンド(線形補間)している。

image.png

image.png

diffuserでもtiledエンコードがある。

    def tiled_encode(self, x: torch.Tensor) -> AutoencoderKLOutput:
        batch_size, num_channels, num_frames, height, width = x.shape
        latent_height = height // self.spatial_compression_ratio
        latent_width = width // self.spatial_compression_ratio

        tile_latent_min_height = self.tile_sample_min_height // self.spatial_compression_ratio
        tile_latent_min_width = self.tile_sample_min_width // self.spatial_compression_ratio
        tile_latent_stride_height = self.tile_sample_stride_height // self.spatial_compression_ratio
        tile_latent_stride_width = self.tile_sample_stride_width // self.spatial_compression_ratio

        blend_height = tile_latent_min_height - tile_latent_stride_height
        blend_width = tile_latent_min_width - tile_latent_stride_width

        rows = []
        for i in range(0, height, self.tile_sample_stride_height):
            row = []
            for j in range(0, width, self.tile_sample_stride_width):
                tile = x[:, :, :, i : i + self.tile_sample_min_height, j : j + self.tile_sample_min_width]
                tile = self.encoder(tile)
...

個人的には潜在空間であるlatentが高次ガウス分布(超ガウス球)に近いなら2点の線形補間じゃなくて球面補間(slerp)のほうがいいのかという疑問はある。sample空間(動画)での球面補間は多分よくない。

VAEのフレーム補間

3D VAEは時間軸の潜在空間では時間軸の間引きもする。
この時の普通に考えると間引きされた1/4のフレームでは他の時間軸のフレーム情報をぎゅうぎゅうに圧縮して無理やり同じ空間座標に詰め込んでるとも考えられる。

image.png

しかし、または基礎的な情報からフレーム補間を再構成しているのかもしれない。
何が言いたいかというと圧縮されたフレームが他の時間軸のフレームの図形情報を全くもっていなかったにしても深度(前景か背景か)、移動速度と方向、縦横の拡大率という小さな情報さえ持っていれば間引かれたフレームの図形情報を持ってなくても残ったフレームの図形情報と移動情報から再構成出来るのではないか?

例えば以下のCNNフィルタを掛けると任意図形は左下に1だけ平行移動する。
image.png
前景オブジェクトにぼかし(平滑)フィルタを掛ければ領域が広がるし、先鋭化フィルタを掛ければ領域は縮まる。stride=2を使った畳み込みなら1/2の大きさに縮小するし、upsampleを使えば2倍に拡大する。stride=3の畳み込みにupsampleを使えば2/3の大きさになろう。
また前景オブジェクトの裏にある背景情報を得るには時間軸の異なる別フレームの値から値を引っ張ってこないといけないが時間方向の畳み込み(時間軸の平行移動)を使えば別フレームの情報を取ることは可能である。
カメラ回転(チルトとかロール)は出来るかどうかよく分からないが回転する3DCGのレンダリングをGPU使って計算出来るなら、時間軸と深度と空間座標を定義出来ればこれらをまとめて回転する何かいい方法があるのかもしれない。時間軸を回転するというのは途中でスローモーションや完全停止、逆再生になる動画を作るということだが。

image.png

とはいえ実際にはlatentにおいて複数フレーム情報を圧縮して持っているのか、1つのフレーム情報と移動情報から間引きフレームを再構成するのかは不明である。動画の動きが激しいとアーティファクトが出現するという傾向は感じる。もし複数フレームを圧縮して持っているだけなら動きが激しかろうが大人しかろうがVAEのフレーム補間には差がない筈ではないかとも考えられる。

または、単に近似精度の話で細長い直方体ならローポリゴンの3DCGでも近似可能だが、これを長軸に対して捻った立体はローポリゴンの3DCGでは上手く近似が出来ないという話なのかもしれない。

タイル幅は品質に影響を与えるか?

hunyuanvideoは少し触ったがVAE tiledの設定は最初に初期設定の半分の値にして以降、全く触っていないためこの値がどういう影響を与えるのかは実際には不明である。単に使用メモリに関係あるだけで生成結果に影響がないならよいが。

激しい動きの場合(よくわからない肉塊から一瞬で人間が生えてくるような動画)、動きの激しい箇所だけに網でこすったような変なノイズが見られることはあった。それがVAE由来なのかどうかは証拠がない。前述のように物理法則に則ったフレーム補間をしていると仮定すると、物理法則の破綻した絵のフレーム補間などはそもそも上手く行かないかもしれない。

逆に動画がほとんど動かないなら割と綺麗でオーバーラップを与えようが与えまいがVAEのフレーム補間にはほとんど差はないと思われる。

動画が横に大きく動く場合、空間タイルのwidthとwidthのオーバーラップに大きい値にするとよいだろう。同様に動画が縦に大きく動く場合はwidthではなくheightの値を調整するとよいだろう。

フレーム(時間)のオーバーラップを増やした時、空間のオーバーラップも同時に増やすべきなのだろうか?仮に等速運動している物体があったとしてフレームオーバーラップを増やして空間のオーバーラップを増やさなければ移動する物体が空間枠からフレームアウトする可能性はある。

あと気になったのはtemporal_size、temporal_overwrap、作成frame_numを適当に与えてやると最終フレーム付近でtemporal_sizeより小さい端数frameをVAEに与えることになるのではないだろうか?そのような場合、余分な端数が出ないようにtemporal_size、temporal_overwrapを調整した方がよいのだろうか?

hunyuanvideoの論文に書いてある事

4.1.2 Inferenceの機械翻訳をそのまま載せる。

4.1.2 推論
単一のGPUで高解像度の長時間の動画をエンコードおよびデコードすると、メモリ不足(OOM)エラーが発生する可能性がある。この問題に対処するため、我々は空間-時間タイリング戦略を使用し、入力映像を空間次元と時間次元に沿ってオーバーラップするタイルに分割する。各タイルは別々に符号化/復号化され、出力はつなぎ合わされる。オーバーラップする領域については、ブレンドのために線形結合を利用する。このタイリング戦略により、1つのGPUで任意の解像度と時間の動画をエンコード/デコードすることができる。我々は、推論中にタイリング戦略を直接使用すると、学習と推論の間の不整合により、目に見えるアーチファクトが発生する可能性があることを確認した。これを解決するために、我々は、学習中にタイリング戦略をランダムに有効/無効にする、追加の微調整フェーズを導入する。これにより、モデルはタイリング戦略と非タイリング戦略の両方に対応し、訓練と推論間の一貫性を維持する。表1は、我々のVAEとオープンソースの最先端VAEとの比較である。ビデオデータにおいて、我々のVAEは他のビデオVAEと比較して著しく高いPSNRを示した。画像では、我々の性能はビデオVAEと画像VAEの両方を上回っている。図7は、256×256の解像度におけるいくつかのケースを示している。我々のVAEは、テキスト、小さな顔、複雑なテクスチャにおいて大きな優位性を示している。

学習動画latent分布と推論生成latent分布の不一致を解消するのにVAE側の追加学習を行い、タイリングをランダムにON/OFFして学習させ、動画latent(タイリングあり)だけでなく推論latent(タイリングなし)の復元も出来るようにするという意味だろうか。それともVAEは関係なく拡散モデルの追加学習においてタイリングのみをON/OFFさせるという意味だろうか。後者の意味なら静止画のlora学習においても空間タイリングをON/OFFさせる必要があるのだろうか。(一般には静止画はタイリングして事前キャッシュされるが)

その他:IP2V

hunyuanvideoに画像や動画を渡そうとするならおそらく3D VAE Encoderに渡してlatentにする。多分、全体にノイズを足したlatentを拡散モデルを通せばi2vやv2v、空間か時間にMaskをかけてノイズを与えればInpaintになるだろう。仮に時間軸にうまくMaskを掛ければ二個のvideoを繋ぐ中間の動画を生成できる。この事情は画像生成AIのimg2imgと同様である。(とはいえhunyuanvideoでv2vがそれほど流行ってない事情を鑑みるに調整が難しく多分あまり実用的ではないのだろう)

ところで、hunyuanvideoのTextEncoder2はllava-llama-3というMLLM(マルチモーダルLLM)なのでこちらのLLMに画像や動画を渡す事も出来るはずである。そうすると生成したい画像的内容や動画的内容をMLLMにて指定することが出来るのだろうか、と思った。
実際、hunyuanvideoの論文においてもMLLMにTextではなくRefという画像を渡しているモデルもある。このようにVLMにimageを渡すのをimage-prompt-to-video(IP2V)というらしい。
RefをVAE Encoderを通したlatentにノイズを混ぜて拡散の途中stepから生成するのは画像生成AIと同じでこちらはi2vという。

image.png

例えば以下のコードではxtunerのllavaモデルに画像(image1)を渡しているのが見て取れる。
hunyuanvideoのllavaにImageEncoderが付属しているのか知らないが、付いてなければ画像からimage_token(vision_token)を変換して保存し、これを渡してtext_tokenと結合すればよいのだろう。(まるでTextual Inversion(TI)重みのよう)

とはいえこの実装はKijai系列の実装なのでそれ以外のComfyノードだと使えない。

class HyVideoTextEncode:
...
    def process(self, text_encoders, prompt, force_offload=True, prompt_template="video", custom_prompt_template=None, clip_l=None, image_token_selection_expr="::4", hyvid_cfg=None, image1=None, image2=None, clip_text_override=None):
...
            prompt_embeds, negative_prompt_embeds, attention_mask, negative_attention_mask = encode_prompt(self,
                                                                                                            prompt,
                                                                                                            negative_prompt, 
                                                                                                            text_encoder_1, 
                                                                                                            image_token_selection_expr=image_token_selection_expr,
                                                                                                            image1=image1,
                                                                                                            image2=image2)
...

class HyVideoTextImageEncode(HyVideoTextEncode):
    # Experimental Image Prompt to Video (IP2V) via VLM implementation by @Dango233
...

実際、IP2Vにおいては入力画像に対して対象を指定するにはDescribe this <image> in great detail.のような表現をされ、<image>はxtunerのllavaモデルのtokenizer_config.jsonをのぞくと "128257": {"content": "<image>",...な追加特殊tokenとして定義されていることが分かる。

This is an imatrix gguf conversion of xtuner/llava-llama-3-8b-v1_1-transformers.

Mainly intended to be used as the text encoder for Hunyuan Video, but possible to use for vision tasks with the mmproj file from the xtuner gguf repository.

(これは xtuner/llava-llama-3-8b-v1_1-transformers を imatrix gguf に変換したものです。主にHunyuan Videoのテキストエンコーダとして使用することを意図していますが、xtuner ggufリポジトリからmmprojファイルを使ってビジョンタスクに使用することも可能です。)

補足:ここでmmprojというのは多分ImageEncoderとMLLM(VLM)の間に挟まるMLP層のことかと思われる。

image.png

model.safetensors.index.jsonより二層のMLPであるのがわかる。

    "multi_modal_projector.linear_1.bias": "model-00001-of-00004.safetensors",
    "multi_modal_projector.linear_1.weight": "model-00001-of-00004.safetensors",
    "multi_modal_projector.linear_2.bias": "model-00001-of-00004.safetensors",
    "multi_modal_projector.linear_2.weight": "model-00001-of-00004.safetensors",

image.png

少し調べたがllava-llama-3では画像入力は336x336にリサイズされ、CLIP-ViTのpatch_size=14から24x24=576のimage_tokenがVLMの入力になるはずだ。
IP2Vにおいてimage_token_selection_expression="::4"のように設定して144 tokensのみをVLMに渡すと良いという設定はよく分からない。

検索すると上記の様なのもあった。これによるとVLMのimage_tokenの上限は128である。ViTが画像の(x,y)空間座標を得ているならある程度適当にimage_tokenの数を間引いても(なんならimage_tokenの順番をシャッフルしようが)VLM内では認識に問題ないのかもしれない。
(そう考えるのは異なるアスペクト比の画像をViTにて1次元tokenに変換した時、tokenの同じ順番とx,yの空間位置は無関係だからである)

もしimage_tokenの上限は128が本当だとするなら途中でimage_tokenの並びをシャッフルしてimage_token_selection_expression=":128"とすればランダムにimage_token数が576から128になる。(空間座標を持っていてimage_tokenの順番をシャッフルして問題なければだが)

(まあ、それ以前にMinimum 20GB Vram required (VLM qualtization not implemented yet)とあるためGPUのメモリ不足で動かないんだが…)

その他2:推論速度

12GB GPU(4070)でLoRA使いつつ推論(gguf Q4使用)すると動画のフレーム数が17フレームでは5[s/it]なので20stepの動画生成に100[s]なのだが、33フレームでは9~50[s/it]と推論速度にばらつきが大きくなる。運がよければ180[s]くらいだが運が悪いと1000[s]またはそれ以上かかる。69フレームでは生成時間は運が悪いと1時間を超える。またLoRAを使わない方が推論速度の低下が起こりにくい。

promptがそのままならCtrl+CでComfyUIを一回止めて再度推論する方が早くなることもある。この速度低下はVRAM不足のせいなのだろうか。VAE tiledは推論速度には関係なさそう(動画のデコード時に使われる設定)だった。

まとめ

hunyuanvideoのVAE tiledについてよく分からなかったので、diffuserのコードを一部読んだ。
拡散モデルの推論が終わった後、latentをVAE Decoderで動画(sample)に変換する際のVRAMを減らす工夫であるが、設定について情報はあまりない。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?