FramePack F1は1フレームずつ推論していくことで20秒くらいの長時間の動画を生成できる。
実はFramePack F1で動画を生成したことはないのだが、ソースコードを眺めてて思うことを述べる。
HunyuanVideoの1280x720x129frameの生成時間
HunyuanVideoの720P 129frameの生成時間はH100で30分程度、A100でおおよそ1時間くらいである。
おおまかに言うとH100とA100と2倍弱の性能差があり、A100と4090が同じくらい(やや4090のほうが上)。4090と4070は3倍程度の性能差があるから、H100は4070比で6倍くらい、A100は4070比で3倍くらいだろう。
要するに4070でVRAMの問題がなくとも720P 129frameのHunyuanVideoの動画生成に3時間かかる見積りである。このような3時間の推論を頻繁に試すユーザーはほとんどいないだろう。H100なんか持ってないほとんどのユーザはもっと低推論step、低解像度、低フレームの動画生成に励んでいるはずだ。 だから720P 129frameが高速化したと言われても、「ふーん……」という感想しか持たない。
RTX4070 | RTX4080 | RTX4090 | RTX5090 | A100 | H100 SXM | |
---|---|---|---|---|---|---|
FP16,FP8 (TFLOPS) |
106.6 | 195.0 | 330.4 | 419 | 312 | 989 |
TDP(W) | 200 | 320 | 450 | 575 | 400 | 700 |
HunyuanVideoのpatchfy
3D VAEの圧縮率は縦横1/8で時間方向1/4、latent次元16である。
VAE Encoderで解像度×フレーム数の動画をlatentに変換するとき$(H,W,F)→(H/8,W/8,(F+3)/4)$と変換できる。
逆にlatentをVAE Decoderに通して動画に変形するとき$(l_h,l_w,l_f)→(8l_h,8l_w,4l_f-3)$である。
HunyuanVideoでDiTに入る前に厳密には次元変換がある。
3次元latentを1次元Tokenに変換が行われる。
diffuserのコードでlatent次元をチェックするとheight=320, width=640, num_frames=13でhidden_statesのlatent次元をチェックするとDiTのtoken長は3200でVAE latent token長の1/4しかないのが分かる。
縦横(2,2)の潜在空間16次元を潜在空間64次元に変換するのが見て取れ、いわゆるpatchfyによってシーケンス長は1/4になる。
# 2. Conditional embeddings
temb, token_replace_emb = self.time_text_embed(timestep, pooled_projections, guidance)
print(hidden_states.shape)
hidden_states = self.x_embedder(hidden_states)
print(hidden_states.shape)
...
# 5. Output projection
print(hidden_states.shape)
hidden_states = hidden_states.reshape(
batch_size, post_patch_num_frames, post_patch_height, post_patch_width, -1, p_t, p, p
)
hidden_states = hidden_states.permute(0, 4, 1, 5, 2, 6, 3, 7)
hidden_states = hidden_states.flatten(6, 7).flatten(4, 5).flatten(2, 3)
print(hidden_states.shape)
--------------------------------------------------------------
torch.Size([1, 16, 4, 40, 80])
torch.Size([1, 3200, 3072])
...
torch.Size([1, 3200, 64])
torch.Size([1, 16, 4, 40, 80])
このpatchfy(self.x_embedder)は以下のように書かれており、次元変換をさらうと以下である。
patch_sizeのConv3Dが表れているがfilter重みが4通りの1.0と0.0を定義すれば単なる行列次元変換をConv3Dで示せる。16次元データ4つを64次元データ1つにする。そのあと64次元をhidden_dimの3072次元に線形射影する。
class HunyuanVideoPatchEmbed(nn.Module):
def __init__(
self,
patch_size: Union[int, Tuple[int, int, int]] = 16,
in_chans: int = 3,
embed_dim: int = 768,
) -> None:
super().__init__()
patch_size = (patch_size, patch_size, patch_size) if isinstance(patch_size, int) else patch_size
self.proj = nn.Conv3d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
print('patch_embed01=', hidden_states.shape)
hidden_states = self.proj(hidden_states)
print('patch_embed02=', hidden_states.shape)
hidden_states = hidden_states.flatten(2).transpose(1, 2) # BCFHW -> BNC
print('patch_embed03=', hidden_states.shape)
return hidden_states
-----------------------------------------------------
patch_embed01= torch.Size([1, 16, 4, 40, 80])
patch_embed02= torch.Size([1, 3072, 4, 20, 40])
patch_embed03= torch.Size([1, 3200, 3072])
実際、diffusersの次元変換はreshape、permute、flattenで書かれていて分かりにくいので、次元変換にeinops.rearrangeが使われるほかのコード例を示す。
class UNetMidBlockCausal3D(nn.Module):
...
def forward(self, hidden_states: torch.FloatTensor, temb: Optional[torch.FloatTensor] = None) -> torch.FloatTensor:
hidden_states = self.resnets[0](hidden_states, temb)
for attn, resnet in zip(self.attentions, self.resnets[1:]):
if attn is not None:
B, C, T, H, W = hidden_states.shape
hidden_states = rearrange(hidden_states, "b c f h w -> b (f h w) c")
attention_mask = prepare_causal_attention_mask(
T, H * W, hidden_states.dtype, hidden_states.device, batch_size=B
)
hidden_states = attn(hidden_states, temb=temb, attention_mask=attention_mask)
hidden_states = rearrange(hidden_states, "b (f h w) c -> b c f h w", f=T, h=H, w=W)
hidden_states = resnet(hidden_states, temb)
return hidden_states
LuminaVideoでは推論stepによって以下のような異なるpatchfyの変換が見られる。
def unpatchify(
...
x = x[:, :L].view(B, F_compute // pF, H // pH, W // pW, pF, pH, pW, self.out_channels)
x = rearrange(x, "b f h w pf ph pw c -> b c (f pf) (h ph) (w pw)")[:, :, :F]
...
def patchify_and_embed(
...
video = video.view(C, F // pF, pF, H // pH, pH, W // pW, pW)
video = rearrange(video, "c f pf h ph w pw -> (f h w) (pf ph pw c)")
FramePackにおいてもLuminaVideoにおけるpatchfyに見られる変換が行われているように見える。
class HunyuanVideoTransformer3DModelPacked(ModelMixin, ConfigMixin, PeftAdapterMixin, FromOriginalModelMixin):
...
def __init__(
...
patch_size: int = 2,
patch_size_t: int = 1,
...
def forward(
...
hidden_states = einops.rearrange(hidden_states, 'b (t h w) (c pt ph pw) -> b c (t pt) (h ph) (w pw)',
t=post_patch_num_frames, h=post_patch_height, w=post_patch_width,
pt=p_t, ph=p, pw=p)
最初はこのpatchfyこそがFramePack高速化の本質かと思ったのだが、デフォルトのHunyuanVideoでも(1,2,2)のpatchfyがなされているようであり、FramePackのpatchfyも(1,2,2)なのでこの点においては特に変わりない。
今後の計算の見積りにおいて重要なのはDiTのtoken長(シーケンス長)はVAE latent次元長さの1/4であることである。
計算コストの式
Wanの論文には計算量について以下のような記述がある。Hunyuanvideoも同じ計算式とみなして推論コストを考えたい。
$L(\alpha bsh^2+ \beta bs^2h)$
L:DiTレイヤー数
b:バッチサイズ
s:シーケンス長
h:hidden Dimention
α:線形レイヤーのコスト
β:Attentionレイヤーのコスト(Wanの場合、推論時β=4)
Hunyuanvideoの計算コスト比
シーケンス長が10kの時、線形レイヤーが64.8%でAttentionレイヤーが35.2%である。
シーケンス長が115kの時、線形レイヤーが13.8%でAttentionレイヤーが86.2%である。
s=10k,h=3072の時、$(\alpha \cdot 10k \cdot 3072^2):(\beta \cdot 10k^2 \cdot 3072)=64.8:35.2$
$(\alpha \cdot 3072):(\beta \cdot 10k)=64.8:35.2$
$\alpha=\beta \cdot \frac{10k\cdot 64.8}{3072\cdot 35.2}=\beta \cdot 5.99254…$
s=115k,h=3072の時、$(\alpha \cdot 115k \cdot 3072^2):(\beta \cdot 115k^2 \cdot 3072)=13.8:86.2$
$(\alpha \cdot 3072):(\beta \cdot 115k)=13.8:86.2$
$\alpha=\beta \cdot \frac{115k\cdot 13.8}{3072\cdot 86.2}=\beta \cdot 5.99306…$
なのでHunyuanVidoでは$\alpha=6\beta$である。
$\alpha=6\beta,\beta=4$ | Hidden Dim(=h) | Layer num(=L) | 推定式 | 推定式2 |
---|---|---|---|---|
HunyuanVideo | 3072 | 60 | $Lh\beta(6 hs+ s^2)$ | $737280\cdot(18432s+s^2)$ |
sが10kの時、Attentionの計算比率は35.2%。
sが18kより十分小さい時、Attentionの計算比率は50%より小さく、
sが18kと同程度の大きさの時、Attentionの計算比率は50%程度、
sが18kより十分大きい時、Attentionの計算比率が支配的になる。
ここでシーケンス長はpatchfyによってVAE token数の1/4となる。
解像度 | latent | 全体演算量(50step) PetaFLOPs | Attention rate | All calc(linear calc=1.0) |
---|---|---|---|---|
720x1280x125 | 90x160x32/4=115.2k | 567.5 | 86.2% | 7.25 |
720x1280x61 | 90x160x16/4=57.6k | 161.4 | 75.8% | 4.125 |
720x1280x33 | 90x160x9/4=32.4k | 60.7 | 63.7% | 2.76 |
720x640x33 | 90x80x9/4=16.2k | 20.7 | 46.8% | 1.88 |
640x360x33 | 80x45x9/4=8.1k | 7.9 | 30.5% | 1.44 |
720x1280x1 | 90x160x1/4=3.6k | 2.9 | 16.33% | 1.20 |
約1PFLOPSのH100で115.2kの動画生成に1800秒なので上述のFLOPsの見積り(567.5PetaFLOPs)が正確かは不明だが、線形レイヤーの計算量を1とした時のトータル計算量の割合だけ見てほしい。
720Pの時の生成速度/(解像度*frame)は、latentで1フレーム生成(動画次元で1フレーム)で1.20、9フレーム生成(動画次元で33フレーム)で2.76、32フレーム生成(動画次元で125フレーム)で7.25であるから、720Pの125フレームの生成は効率面で課題がある。
FramePackによるAttention演算量の削減
線形レイヤーにおいてはシーケンス長を1/2にして2回演算しても演算量は変化しない。
Attentionレイヤーにおいてシーケンス長を1/2にして2回演算するとAttention計算量はシーケンス長の2乗に比例するので1/4+1/4=1/2となりAttention計算量が半分に減る。
FramePackではlatent次元で9フレームずつ生成する。このように動画生成を分割すればシーケンス長が短くなり、Attention計算量は減るが、線形レイヤーの計算量は減らない。元のAttention計算量の割合が高ければシーケンス長の分割でより大きく削減できるが、低ければあまり削減できないことになる。
線形レイヤーの演算量を$1$と置いたときシーケンス長8.1kではAttention割合30.5%より$\frac{1}{1-0.305}=1.44$、シーケンス長16.2kではAttention割合46.8%より$\frac{1}{1-0.468}=1.88$、シーケンス長32.4kではAttention割合63.7%より$\frac{1}{1-0.637}=2.76$であり、115.2kでは$\frac{1}{1-0.862}=7.25$である。
したがって、線形レイヤーの演算量が変わらないなら、面積当たりフレーム当たりの計算量は$7.25→2.76$と半分以下に減るだろう。しかし、8.1k~16.2kぐらいのシーケンス長の動画を作ってる人にはフレーム当たりの計算量は$1.44~1.88$ぐらいの感覚であり、その人にとってはFramePackにおける$2.76$というフレーム当たりの計算量は2倍くらい多いのである。
感覚的には同一解像度において生成時間をフレーム長で割った時間はシーケンス長8.1kよりシーケンス長32.4kの方が倍かかる。逆にシーケンス長8.1kでは仮にAttention計算量を半分に出来たとしても多くは線形レイヤーの計算量が支配的であり、フレーム当たりの演算量は$1.44$が$1.22$に変わるだけなので大きなインパクトはない。
また、1回に処理するシーケンス長を制限しても、線形レイヤーの演算量は別に減るわけでもない。要するに動画時間が伸びても生成効率を悪化せずに作れるとはいえ、4倍の動画長さを作るには4倍の時間はかかるということである。
FramePackによるToken増加
演算量の減少についてシーケンス長の分割を行えばAttention計算量が減ることを示したが、単にシーケンス長の分割だけでは長時間の連続的な動画を生成することはできない。
動画生成領域よりも先の生成済み動画をTokenを間引いて含める。
以下のように入力画像をVAEに通してclean_latents_pre
とし、生成latentの内19frame(1+2+16frame)(動画次元なら77frame相当)を使用する。このlatent換算で19frame分を1frame、2frame、16frameに分割し、1frameのclean_latents_post
とclean_latents_pre
を結合する。
start_latent = vae_encode(input_image_pt, vae)
...
history_latents = torch.zeros(size=(1, 16, 1 + 2 + 16, height // 8, width // 8), dtype=torch.float32).cpu()
...
for latent_padding in latent_paddings:
...
clean_latents_pre = start_latent.to(history_latents)
clean_latents_post, clean_latents_2x, clean_latents_4x = history_latents[:, :, :1 + 2 + 16, :, :].split([1, 2, 16], dim=2)
clean_latents = torch.cat([clean_latents_pre, clean_latents_post], dim=2)
...
generated_latents = sample_hunyuan(
...
history_latents = torch.cat([generated_latents.to(history_latents), history_latents], dim=2)
論文にあるf1k1_x_g9_f1k1f2k2f16k4_tdを考えると
1フレーム(pre)をkernel(1,2,2)で削減。
9フレームをpatchfy(1,2,2)で生成。
1フレーム(post)をkernel(1,2,2)で削減。
2フレームをkernel(2,4,4)で削減。
16フレームをkernel(4,8,8)で削減。
Tail 生成終端(末尾)フレームなし。
この場合の入力Tokenのフレーム数換算は$9+\frac{1}{4}+\frac{1}{4}+\frac{2}{32}+\frac{16}{256}=9.625$
つまりマージ(平均化)によってTokenを間引くとはいえ生成フレームの前後のフレームを取り込むためToken数は7%ほど増加する計算になる。
これは以下のようにstrideのある畳み込み関数を掛ける。
class HunyuanVideoPatchEmbedForCleanLatents(nn.Module):
def __init__(self, inner_dim):
super().__init__()
self.proj = nn.Conv3d(16, inner_dim, kernel_size=(1, 2, 2), stride=(1, 2, 2))
self.proj_2x = nn.Conv3d(16, inner_dim, kernel_size=(2, 4, 4), stride=(2, 4, 4))
self.proj_4x = nn.Conv3d(16, inner_dim, kernel_size=(4, 8, 8), stride=(4, 8, 8))
...
class HunyuanVideoTransformer3DModelPacked(ModelMixin, ConfigMixin, PeftAdapterMixin, FromOriginalModelMixin):
...
def process_input_hidden_states(
...
clean_latents = self.gradient_checkpointing_method(self.clean_x_embedder.proj, clean_latents)
...
clean_latents_2x = self.gradient_checkpointing_method(self.clean_x_embedder.proj_2x, clean_latents_2x)
...
clean_latents_4x = self.gradient_checkpointing_method(self.clean_x_embedder.proj_4x, clean_latents_4x)
FramePack F1が最適なのか?
先ほど生成フレームがlatent次元で9フレームずつだと$9+\frac{1}{4}+\frac{1}{4}+\frac{2}{32}+\frac{16}{256}=9.625$で7%ほど入力Tokenが増加するのを示した。
FramePack F1は1フレームずつの生成だから同じ前後フレームのマージをするなら$1+\frac{1}{4}+\frac{1}{4}+\frac{2}{32}+\frac{16}{256}=1.625$となる。これは元の入力Token数よりも62.5%増加する。
このTokenの増加率は一回の生成領域が小さくなるほど割合が高くなる。
つまりFramePackのAttention計算量の減少とTokenの増加割合とどちらが重いのか?という疑問である。
それでも720PならばF9よりF1のほうが軽いが、解像度がある程度よりも小さいとき演算量の減少の割合は小さくなるが、Token増加による線形レイヤー演算量の増加はF9よりF1のほうが割合が高いので、結局どこかの解像度以下ではF1の生成速度がF9よりも遅くなるのではという懸念である。
適当に見積もったところ720Pの面積の1/2.7くらいの場合、F9とF1の計算量が逆転する。
720PのF9の線形レイヤーを1とした時の全部の計算量2.76
720PのF1の線形レイヤーを1とした時の全部の計算量1.20とすれば
720P F9:$(1*1.07+1.76*1.07*1.07)=3.085$
720P F1:$(1*1.625+0.20*1.625*1.625)=2.153$
440P F9:$(\frac{1}{2.7}*1.07+\frac{1}{2.7^2}*1.76*1.07*1.07)=0.6727$
440P F1:$(\frac{1}{2.7}*1.625+\frac{1}{2.7^2}*0.20*1.625*1.625)=0.6743$
ここである720Pの1/tの解像度でのF9-F1のコスト差をプロットするとt>2.7以上でF1のほうが大きい。
$(\frac{1}{t}*1.07+\frac{1}{t^2}*1.76*1.07*1.07)-(\frac{1}{t}*1.625+\frac{1}{t^2}*0.20*1.625*1.625)$
また、一回の生成フレームをxとしたFxを考えると、FramePackの前後フレームのマージ割合の場合、F9やF1よりもF2とかF3のほうが効率がいい。
720P Fx:$(1*\frac{x+0.625}{x}+0.20*x*\frac{x+0.625}{x}*\frac{x+0.625}{x})$
720P F2:$(1*\frac{2.625}{2}+0.20*2*\frac{2.625}{2}*\frac{2.625}{2})=2.002$
440P F3:$(\frac{1}{2.7}*\frac{3.625}{3}+\frac{1}{2.7^2}*0.20*3*\frac{3.625}{3}*\frac{3.625}{3})=0.568$
FramePackの解像度の記述は少ない
FramePackは画像を読み込み、その画像の解像度で動画を作成するので、ほかの人の記事でもFramePackの動画時間と生成にかかった時間から動画1秒当たりの生成時間を書いてくれているのだが、その画像の解像度については抜けていることが多い。
まあ、いろんな解像度の画像に対して試す人がほとんどで、生成動画の解像度を決めてからその解像度の画像を準備する人はいないから仕方がない。
また、FramePackの価値は生成速度ではなくて、お手軽さ、VRAM消費量、時間RoPEの配置の自由さだという意見もあるかもしれない。
FramePackのshift関数
論文によるとLower flow shiftとあり、flow-shift関数のshift値が小さいのが述べられている。
ところでFramePackの実装を確認すると以下である。
def flux_time_shift(t, mu=1.15, sigma=1.0):
return math.exp(mu) / (math.exp(mu) + (1 / t - 1) ** sigma)
$s=e^\mu, \sigma=1.0$とおけば
$\frac{e^\mu}{e^\mu+(1/t-1)^{\sigma}}=\frac{st}{1+(s-1)t}$となって自分の良く知るflow shift関数になる。
s=1ならshift=0.0
s=3ならshift=1.10
s=7ならshift=1.95であるのでFramePackのshift値とHunyuanvideoのshift値は定義の実装がやや異なる。shift値を小さくしないといけないというのはこの実装の違いによるものなのだろうか?
musubi-tunerではHunyuanVideoのshift値をFramePackのshift値にきちんと変換しているが、ComfyUIのラッパーとかにはこのlog変換は見られない。このためデフォルトのshift=0.0となっており混乱する。s=7.0としたければこのshift値には1.95を入力する。
if shift is None:
mu = calculate_flux_mu(seq_length, exp_max=7.0) # 1.9459... if seq_len is large, mu is clipped.
else:
mu = math.log(shift)
まとめ
FramePackの演算量を見積もった。
・FramePack F1は、720Pにおいては確かにF9よりも効率が良い。
・解像度が低下すると、Attentionの削減効果よりもToken増加による負荷の方が大きくなる。
・結果として、ある解像度以下ではF1よりF9の方が軽くなる可能性がある。
・また、F1、F9よりも中間的なFx(例:F2〜F3)の方が全体として効率的である可能性がある。
と考察した。