Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +22 -0
- code/g2p/sources/chinese_lexicon.txt +3 -0
- code/g2p/sources/g2p_chinese_model/poly_bert_model.onnx +3 -0
- code/infer/example/edit_cn.lrc +23 -0
- code/infer/example/edit_cn.mp3 +3 -0
- code/infer/example/edit_en.lrc +19 -0
- code/infer/example/edit_en.mp3 +3 -0
- code/infer/example/eg_cn.lrc +16 -0
- code/infer/example/eg_cn.wav +3 -0
- code/infer/example/eg_cn_full.lrc +44 -0
- code/infer/example/eg_en.lrc +26 -0
- code/infer/example/eg_en.mp3 +3 -0
- code/infer/example/eg_en_full.lrc +57 -0
- code/infer/example/music_prompt_keywords.json +67 -0
- code/infer/example/output-windows/en-utf8-fixed.mp3 +3 -0
- code/infer/example/output-windows/en-without-utf8-fix.mp3 +3 -0
- code/infer/example/output/output.mp3 +3 -0
- code/infer/example/vocal.npy +3 -0
- code/models/tt_dit/experimental/lora/__init__.py +2 -0
- code/models/tt_dit/experimental/lora/adapter_loader.py +392 -0
- code/models/tt_dit/experimental/lora/ltx_adapter_loader.py +363 -0
- code/models/tt_dit/experimental/lora/promote.py +63 -0
- code/models/tt_dit/experimental/models/AniSora.md +136 -0
- code/models/tt_dit/experimental/models/Wan2_2_Distill.md +89 -0
- code/models/tt_dit/experimental/models/Wan2_2_LoRA.md +197 -0
- code/models/tt_dit/experimental/models/Wan2_2_SVI.md +204 -0
- code/models/tt_dit/experimental/pipelines/__init__.py +0 -0
- code/models/tt_dit/experimental/pipelines/pipeline_anisora.py +118 -0
- code/models/tt_dit/experimental/pipelines/pipeline_wan_distill.py +181 -0
- code/models/tt_dit/experimental/pipelines/pipeline_wan_lora.py +414 -0
- code/models/tt_dit/experimental/pipelines/pipeline_wan_runtime_lora.py +267 -0
- code/models/tt_dit/experimental/pipelines/pipeline_wan_svi.py +399 -0
- code/models/tt_dit/experimental/tests/__init__.py +0 -0
- code/models/tt_dit/experimental/tests/conftest.py +12 -0
- code/models/tt_dit/experimental/tests/test_adapter_loader.py +224 -0
- code/models/tt_dit/experimental/tests/test_lora_variants.py +262 -0
- code/models/tt_dit/experimental/tests/test_pipeline_anisora.py +227 -0
- code/models/tt_dit/experimental/tests/test_pipeline_lora.py +300 -0
- code/models/tt_dit/experimental/tests/test_pipeline_wan_distill_i2v.py +226 -0
- code/models/tt_dit/experimental/tests/test_pipeline_wan_svi.py +273 -0
- code/models/tt_dit/experimental/utils/__init__.py +0 -0
- code/models/tt_dit/experimental/utils/lightx2v_loader.py +199 -0
- code/models/tt_dit/models/audio_vae/minimax_h3/.gitkeep +0 -0
- code/models/tt_dit/models/audio_vae/minimax_h3/blockings_minimax_h3_audio.py +117 -0
- code/models/tt_dit/models/audio_vae/minimax_h3/convert_minimax_h3_audio.py +160 -0
- code/models/tt_dit/models/audio_vae/minimax_h3/decoder_minimax_h3_audio.py +171 -0
- code/models/tt_dit/models/audio_vae/minimax_h3/encoder_minimax_h3_audio.py +405 -0
- code/models/tt_dit/models/transformers/ltx/attention_ltx.py +870 -0
- code/models/tt_dit/models/transformers/ltx/quant_config.py +143 -0
- code/models/tt_dit/models/transformers/ltx/rope_ltx.py +393 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,25 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
code/infer/example/eg_cn.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
code/infer/example/edit_cn.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
code/infer/example/edit_en.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
code/infer/example/eg_en.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
code/infer/example/output-windows/en-utf8-fixed.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
code/infer/example/output-windows/en-without-utf8-fix.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
code/infer/example/output/output.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
image/blobs/sha256/3c7495db974af283bccc2dee306a75da6491b2ca664d55597da926c4fd104dd5 filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
image/blobs/sha256/0b4ec6da503185d389dcfeefc2c5d03ab13e0d76620b5fb6ce022f79bb64140f filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
image/blobs/sha256/556ec3c4c0e12c97f4c9b8dd0cd65ae321238c55438fbf498055f0ad7afc05bb filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
image/blobs/sha256/1f47921d53a3b8de72172e2fd5f0a0d27f1e8f4b77016c6b8a1a4b591bb83ae5 filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
image/blobs/sha256/b9ddf00cad9d9a3bc91d09c8ee0f4fef42056861bb5ca1209fcc204b68950abf filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
image/blobs/sha256/2d16f4104fc6e34c556d42d6a35adedd5ffa396e71d59ef6dd7d5e88c01225e0 filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
image/blobs/sha256/b7c2e1ec67bfd9d4578a81ca6987f095b74869db5f518bf2fc9d3ffeb30821e5 filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
image/blobs/sha256/4ec2b03c373212fbe7fda97dbf9eaadb1bd42c2b0afc8b8a49806fe1dbc507f7 filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
image/blobs/sha256/49353ead98e6a241dd015dda937bf8dcf335e4eed27fe732df8163dd955d042f filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
image/blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
image/blobs/sha256/3375e6602aa7264d6d67ae69ccb99690c8f40af7ab48d0af060dac1a2e9c24c9 filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
image/blobs/sha256/e211ac567cceb6b5be62db5e135a1a5f3e77bcacebdac6a8cf9e0cdc24158c09 filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
code/g2p/sources/chinese_lexicon.txt filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
image/blobs/sha256/672d584c094e51e29aa5e46a6dd22cbfb993f4f839609e954d2c299912ea5d77 filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
image/blobs/sha256/428f99cfe94d9b1e42af2f3d8104598f8ca6e6105925c7687595a18c78d21011 filter=lfs diff=lfs merge=lfs -text
|
code/g2p/sources/chinese_lexicon.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a3a7685d1c3e68eb2fa304bfc63e90c90c3c1a1948839a5b1b507b2131b3e2fb
|
| 3 |
+
size 14779443
|
code/g2p/sources/g2p_chinese_model/poly_bert_model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8765d835ffdf9811c832d4dc7b6a552757aa8615c01d1184db716a50c20aebbc
|
| 3 |
+
size 76583333
|
code/infer/example/edit_cn.lrc
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[00:13.55]亲爱的 人间爱情就像一朵花
|
| 2 |
+
[00:20.29]想要种鲜花
|
| 3 |
+
[00:22.51]手上难免沾泥巴
|
| 4 |
+
[00:27.90]大红喜字铺开乡间田野
|
| 5 |
+
[00:32.01]让我们住下
|
| 6 |
+
[00:34.96]听微风吟唱吧
|
| 7 |
+
[00:38.44]用光阴发新芽
|
| 8 |
+
[00:41.04]裂缝钻出的新绿
|
| 9 |
+
[00:43.97]正叩响整个盛夏
|
| 10 |
+
[00:47.15]候鸟衔来远方的彩霞
|
| 11 |
+
[00:52.03]荆棘里开出的花
|
| 12 |
+
[00:55.67]每片花瓣都是回答
|
| 13 |
+
[00:58.56]伤疤酿成琥珀时差
|
| 14 |
+
[01:01.42]折射出星空的密码
|
| 15 |
+
[01:04.36]蝴蝶吻过褪皮的痂
|
| 16 |
+
[01:07.28]绽放成盔甲
|
| 17 |
+
[01:10.11]牵你的手啊
|
| 18 |
+
[01:12.90]在岁月种下鲜花
|
| 19 |
+
[01:16.49]在寻常烟火人家
|
| 20 |
+
[01:20.05]用漫长慢慢地回答
|
| 21 |
+
[01:25.23]云会升起 雨会落下
|
| 22 |
+
[01:28.86]不用催促啊
|
| 23 |
+
[01:33.25]耐心的人啊才可以看见童话
|
code/infer/example/edit_cn.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8fbc319c58215aff58b018a253abf6e6ec31673d6336947b932f1212964240d4
|
| 3 |
+
size 8534237
|
code/infer/example/edit_en.lrc
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[00:12.56]后来
|
| 2 |
+
[00:14.58]我总算学会了
|
| 3 |
+
[00:17.81]如何去爱
|
| 4 |
+
[00:19.37]可惜你 早已远去
|
| 5 |
+
[00:22.21]消失在人海
|
| 6 |
+
[00:25.42]Through tears I understood too late
|
| 7 |
+
[00:32.11]Some souls slip away beyond fate's gate
|
| 8 |
+
[00:39.86]White petals of gardenia
|
| 9 |
+
[00:46.32]Drift onto my blue pleated skirt
|
| 10 |
+
[00:51.95]I love you your whisper fell
|
| 11 |
+
[00:58.96]I bowed my head
|
| 12 |
+
[01:00.97]Breathing your lingering scent
|
| 13 |
+
[01:05.40]That eternal summer night
|
| 14 |
+
[01:09.35]Seventeen years young
|
| 15 |
+
[01:12.20]When your lips found mine
|
| 16 |
+
[01:18.23]Now through passing years
|
| 17 |
+
[01:21.79]Each wistful sigh
|
| 18 |
+
[01:24.91]Recalls starlight in your eyes
|
| 19 |
+
[01:30.91]Why did young love's promise
|
code/infer/example/edit_en.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d72cb739c351b00a738f50e37a94cfc783dfa12b091c84ef9943b85a064015eb
|
| 3 |
+
size 4771890
|
code/infer/example/eg_cn.lrc
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[00:18.23]让我掉下眼泪的
|
| 2 |
+
[00:21.80]不止昨夜的酒
|
| 3 |
+
[00:26.06]让我依依不舍的
|
| 4 |
+
[00:29.91]不止你的温柔
|
| 5 |
+
[00:33.79]余路还要走多久
|
| 6 |
+
[00:37.92]你攥着我的手
|
| 7 |
+
[00:41.81]让我感到为难的
|
| 8 |
+
[00:45.78]是挣扎的自由
|
| 9 |
+
[00:51.81]分别总是在九月
|
| 10 |
+
[00:55.66]回忆是思念的愁
|
| 11 |
+
[00:59.77]深秋嫩绿的垂柳
|
| 12 |
+
[01:03.55]亲吻着我额头
|
| 13 |
+
[01:07.55]在那座阴雨的小城里
|
| 14 |
+
[01:11.47]我从未忘记你
|
| 15 |
+
[01:15.37]成都 带不走的 只有你
|
| 16 |
+
[01:23.51]和我在成都的街头走一走
|
code/infer/example/eg_cn.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:67fc2163fa200544e5ecc1fcd7889124a60e6ff20fd70c53ad05a18859d79fd3
|
| 3 |
+
size 882044
|
code/infer/example/eg_cn_full.lrc
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[00:18.23]让我掉下眼泪的
|
| 2 |
+
[00:21.80]不止昨夜的酒
|
| 3 |
+
[00:26.06]让我依依不舍的
|
| 4 |
+
[00:29.91]不止你的温柔
|
| 5 |
+
[00:33.79]余路还要走多久
|
| 6 |
+
[00:37.92]你攥着我的手
|
| 7 |
+
[00:41.81]让我感到为难的
|
| 8 |
+
[00:45.78]是挣扎的自由
|
| 9 |
+
[00:51.81]分别总是在九月
|
| 10 |
+
[00:55.66]回忆是思念的愁
|
| 11 |
+
[00:59.77]深秋嫩绿的垂柳
|
| 12 |
+
[01:03.55]亲吻着我额头
|
| 13 |
+
[01:07.55]在那座阴雨的小城里
|
| 14 |
+
[01:11.47]我从未忘记你
|
| 15 |
+
[01:15.37]成都 带不走的 只有你
|
| 16 |
+
[01:23.51]和我在成都的街头走一走
|
| 17 |
+
[01:31.38]直到所有的灯都熄灭了也不停留
|
| 18 |
+
[01:39.23]你会挽着我的衣袖
|
| 19 |
+
[01:43.12]我会把手揣进裤兜
|
| 20 |
+
[01:47.13]走到玉林路的尽头
|
| 21 |
+
[01:50.97]坐在小酒馆的门口
|
| 22 |
+
[02:30.76]分别总是在九月
|
| 23 |
+
[02:34.46]回忆是思念的愁
|
| 24 |
+
[02:38.48]深秋嫩绿的垂柳
|
| 25 |
+
[02:42.38]亲吻着我额头
|
| 26 |
+
[02:46.40]在那座阴雨的小城里
|
| 27 |
+
[02:50.35]我从未忘记你
|
| 28 |
+
[02:54.21]成都 带不走的 只有你
|
| 29 |
+
[03:02.18]和我在成都的街头走一走
|
| 30 |
+
[03:10.02]直到所有的灯都熄灭了也不停留
|
| 31 |
+
[03:18.16]你会挽着我的衣袖
|
| 32 |
+
[03:21.90]我会把手揣进裤兜
|
| 33 |
+
[03:25.80]走到玉林路的尽头
|
| 34 |
+
[03:29.81]坐在小酒馆的门口
|
| 35 |
+
[03:37.97]和我在成都的街头走一走
|
| 36 |
+
[03:45.76]直到所有的灯都熄灭了也不停留
|
| 37 |
+
[03:53.72]和我在成都的街头走一走
|
| 38 |
+
[04:01.44]直到所有的灯都熄灭了也不停留
|
| 39 |
+
[04:09.68]你会挽着我的衣袖
|
| 40 |
+
[04:13.35]我会把手揣进裤兜
|
| 41 |
+
[04:17.41]走到玉林路的尽头
|
| 42 |
+
[04:21.27]走过小酒馆的门口
|
| 43 |
+
[04:35.78]和我在成都的街头走一走
|
| 44 |
+
[04:43.18]直到所有的灯都熄灭了也不停留
|
code/infer/example/eg_en.lrc
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[00:10.00]Moonlight spills through broken blinds
|
| 2 |
+
[00:13.20]Your shadow dances on the dashboard shrine
|
| 3 |
+
[00:16.85]Neon ghosts in gasoline rain
|
| 4 |
+
[00:20.40]I hear your laughter down the midnight train
|
| 5 |
+
[00:24.15]Static whispers through frayed wires
|
| 6 |
+
[00:27.65]Guitar strings hum our cathedral choirs
|
| 7 |
+
[00:31.30]Flicker screens show reruns of June
|
| 8 |
+
[00:34.90]I'm drowning in this mercury lagoon
|
| 9 |
+
[00:38.55]Electric veins pulse through concrete skies
|
| 10 |
+
[00:42.10]Your name echoes in the hollow where my heartbeat lies
|
| 11 |
+
[00:45.75]We're satellites trapped in parallel light
|
| 12 |
+
[00:49.25]Burning through the atmosphere of endless night
|
| 13 |
+
[01:00.00]Dusty vinyl spins reverse
|
| 14 |
+
[01:03.45]Our polaroid timeline bleeds through the verse
|
| 15 |
+
[01:07.10]Telescope aimed at dead stars
|
| 16 |
+
[01:10.65]Still tracing constellations through prison bars
|
| 17 |
+
[01:14.30]Electric veins pulse through concrete skies
|
| 18 |
+
[01:17.85]Your name echoes in the hollow where my heartbeat lies
|
| 19 |
+
[01:21.50]We're satellites trapped in parallel light
|
| 20 |
+
[01:25.05]Burning through the atmosphere of endless night
|
| 21 |
+
[02:10.00]Clockwork gears grind moonbeams to rust
|
| 22 |
+
[02:13.50]Our fingerprint smudged by interstellar dust
|
| 23 |
+
[02:17.15]Velvet thunder rolls through my veins
|
| 24 |
+
[02:20.70]Chasing phantom trains through solar plane
|
| 25 |
+
[02:24.35]Electric veins pulse through concrete skies
|
| 26 |
+
[02:27.90]Your name echoes in the hollow where my heartbeat lies
|
code/infer/example/eg_en.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cdf3610c437715af336102a9bd34c63a3e882d9d99fe86b9fb193d8ebf9f2aa4
|
| 3 |
+
size 10386551
|
code/infer/example/eg_en_full.lrc
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[00:00.52]Abracadabra abracadabra
|
| 2 |
+
[00:03.97]Ha
|
| 3 |
+
[00:04.66]Abracadabra abracadabra
|
| 4 |
+
[00:12.02]Yeah
|
| 5 |
+
[00:15.80]Pay the toll to the angels
|
| 6 |
+
[00:19.08]Drawin' circles in the clouds
|
| 7 |
+
[00:23.31]Keep your mind on the distance
|
| 8 |
+
[00:26.67]When the devil turns around
|
| 9 |
+
[00:30.95]Hold me in your heart tonight
|
| 10 |
+
[00:34.11]In the magic of the dark moonlight
|
| 11 |
+
[00:38.44]Save me from this empty fight
|
| 12 |
+
[00:43.83]In the game of life
|
| 13 |
+
[00:45.84]Like a poem said by a lady in red
|
| 14 |
+
[00:49.45]You hear the last few words of your life
|
| 15 |
+
[00:53.15]With a haunting dance now you're both in a trance
|
| 16 |
+
[00:56.90]It's time to cast your spell on the night
|
| 17 |
+
[01:01.40]Abracadabra ama-ooh-na-na
|
| 18 |
+
[01:04.88]Abracadabra porta-ooh-ga-ga
|
| 19 |
+
[01:08.92]Abracadabra abra-ooh-na-na
|
| 20 |
+
[01:12.30]In her tongue she's sayin'
|
| 21 |
+
[01:14.76]Death or love tonight
|
| 22 |
+
[01:18.61]Abracadabra abracadabra
|
| 23 |
+
[01:22.18]Abracadabra abracadabra
|
| 24 |
+
[01:26.08]Feel the beat under your feet
|
| 25 |
+
[01:27.82]The floor's on fire
|
| 26 |
+
[01:29.90]Abracadabra abracadabra
|
| 27 |
+
[01:33.78]Choose the road on the west side
|
| 28 |
+
[01:37.09]As the dust flies watch it burn
|
| 29 |
+
[01:41.45]Don't waste time on feeling
|
| 30 |
+
[01:44.64]Your depression won't return
|
| 31 |
+
[01:49.15]Hold me in your heart tonight
|
| 32 |
+
[01:52.21]In the magic of the dark moonlight
|
| 33 |
+
[01:56.54]Save me from this empty fight
|
| 34 |
+
[02:01.77]In the game of life
|
| 35 |
+
[02:03.94]Like a poem said by a lady in red
|
| 36 |
+
[02:07.52]You hear the last few words of your life
|
| 37 |
+
[02:11.19]With a haunting dance now you're both in a trance
|
| 38 |
+
[02:14.95]It's time to cast your spell on the night
|
| 39 |
+
[02:19.53]Abracadabra ama-ooh-na-na
|
| 40 |
+
[02:22.71]Abracadabra porta-ooh-ga-ga
|
| 41 |
+
[02:26.94]Abracadabra abra-ooh-na-na
|
| 42 |
+
[02:30.42]In her tongue she's sayin'
|
| 43 |
+
[02:32.83]Death or love tonight
|
| 44 |
+
[02:36.55]Abracadabra abracadabra
|
| 45 |
+
[02:40.27]Abracadabra abracadabra
|
| 46 |
+
[02:44.19]Feel the beat under your feet
|
| 47 |
+
[02:46.14]The floor's on fire
|
| 48 |
+
[02:47.95]Abracadabra abracadabra
|
| 49 |
+
[02:51.17]Phantom of the dance floor come to me
|
| 50 |
+
[02:58.46]Sing for me a sinful melody
|
| 51 |
+
[03:06.51]Ah-ah-ah-ah-ah ah-ah ah-ah
|
| 52 |
+
[03:13.76]Ah-ah-ah-ah-ah ah-ah ah-ah
|
| 53 |
+
[03:22.39]Abracadabra ama-ooh-na-na
|
| 54 |
+
[03:25.66]Abracadabra porta-ooh-ga-ga
|
| 55 |
+
[03:29.87]Abracadabra abra-ooh-na-na
|
| 56 |
+
[03:33.16]In her tongue she's sayin'
|
| 57 |
+
[03:35.55]Death or love tonight
|
code/infer/example/music_prompt_keywords.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"genre": [
|
| 3 |
+
"Pop",
|
| 4 |
+
"Rock",
|
| 5 |
+
"Electronic",
|
| 6 |
+
"Blues",
|
| 7 |
+
"R&B",
|
| 8 |
+
"Jazz",
|
| 9 |
+
"Soul",
|
| 10 |
+
"Country",
|
| 11 |
+
"Folk",
|
| 12 |
+
"Dance",
|
| 13 |
+
"Rap",
|
| 14 |
+
"Classical",
|
| 15 |
+
"Hip-Hop",
|
| 16 |
+
"Chinese",
|
| 17 |
+
"House",
|
| 18 |
+
"Punk",
|
| 19 |
+
"Metal",
|
| 20 |
+
"Funk",
|
| 21 |
+
"Reggae",
|
| 22 |
+
"Disco"
|
| 23 |
+
],
|
| 24 |
+
"mood": [
|
| 25 |
+
"Romantic",
|
| 26 |
+
"Nostalgic",
|
| 27 |
+
"Heartfelt",
|
| 28 |
+
"Happy",
|
| 29 |
+
"Melancholic",
|
| 30 |
+
"Love",
|
| 31 |
+
"Upbeat",
|
| 32 |
+
"Energetic",
|
| 33 |
+
"Uplifting",
|
| 34 |
+
"Carefree",
|
| 35 |
+
"Fun",
|
| 36 |
+
"Emotional",
|
| 37 |
+
"Heartbroken",
|
| 38 |
+
"Reflective",
|
| 39 |
+
"Mellow",
|
| 40 |
+
"Hopeful",
|
| 41 |
+
"Relaxed",
|
| 42 |
+
"Passionate",
|
| 43 |
+
"Aggressive"
|
| 44 |
+
],
|
| 45 |
+
"instrument": [
|
| 46 |
+
"Piano",
|
| 47 |
+
"Electric Guitar",
|
| 48 |
+
"Drums",
|
| 49 |
+
"Synthesizer",
|
| 50 |
+
"Bass",
|
| 51 |
+
"Strings",
|
| 52 |
+
"Bass Guitar",
|
| 53 |
+
"Acoustic Guitar",
|
| 54 |
+
"Guitar",
|
| 55 |
+
"Trumpet",
|
| 56 |
+
"Saxophone",
|
| 57 |
+
"Violin",
|
| 58 |
+
"Organ",
|
| 59 |
+
"Percussion",
|
| 60 |
+
"Vocal Sample",
|
| 61 |
+
"Flute",
|
| 62 |
+
"Harmonica",
|
| 63 |
+
"Banjo",
|
| 64 |
+
"Electric Bass",
|
| 65 |
+
"Cello"
|
| 66 |
+
]
|
| 67 |
+
}
|
code/infer/example/output-windows/en-utf8-fixed.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:10abbeefc22c1d03607cc074f825bbd4e0b7326e438fd13cb46985edf4a7ae6f
|
| 3 |
+
size 1522668
|
code/infer/example/output-windows/en-without-utf8-fix.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:acf1fd072cf269745eaf2ee3c8674662a4c99441fc839c3bc0635f2dd89794ca
|
| 3 |
+
size 1522668
|
code/infer/example/output/output.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6311c518821ee0d37d24c65d92c479f6c26da68b782687ab0ee69fb7a9ef97d3
|
| 3 |
+
size 2832762
|
code/infer/example/vocal.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6cb7d74eb7a8eda12acb8247b21d373928301db8a8cb0db480d341799fed3ce5
|
| 3 |
+
size 2176
|
code/models/tt_dit/experimental/lora/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
code/models/tt_dit/experimental/lora/adapter_loader.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Adapter loader for the runtime-LoRA Wan 2.2 pipeline.
|
| 6 |
+
|
| 7 |
+
Reads a LoRA safetensors file, parses A/B pairs using the same key
|
| 8 |
+
normalization as ``pipeline_wan_lora.py``, and uploads them into the
|
| 9 |
+
LoRA-aware Linear modules of a `WanTransformer3DModel` constructed with
|
| 10 |
+
``lora_enabled=True``.
|
| 11 |
+
|
| 12 |
+
Fused-QKV handling (the hard part):
|
| 13 |
+
Wan attention uses a fused ``to_qkv`` (self-attn) and ``to_kv``
|
| 14 |
+
(cross-attn) projection. The adapter ships separate ``to_q``,
|
| 15 |
+
``to_k``, ``to_v`` LoRA pairs. We combine them into a single LoRA
|
| 16 |
+
pair on the fused module by:
|
| 17 |
+
|
| 18 |
+
- stacking A along the rank dim: A_fused = [A_q; A_k; A_v] → [3r, in]
|
| 19 |
+
- building a block-diagonal B with rank=3r and head-interleaving
|
| 20 |
+
it the same way the base weight is head-interleaved:
|
| 21 |
+
B_fused = head_interleave([B_q | 0 | 0, 0 | B_k | 0, 0 | 0 | B_v])
|
| 22 |
+
|
| 23 |
+
This triples the LoRA matmul cost on those layers (rank=3r) but keeps
|
| 24 |
+
the math exact and shippable in v0. A follow-up could split this into
|
| 25 |
+
3 independent adapters at the layer level for ~3x lower cost.
|
| 26 |
+
|
| 27 |
+
`.diff` / `.diff_b` direct deltas are out of scope for v0 — the loader
|
| 28 |
+
warns and skips them.
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import re
|
| 33 |
+
from collections.abc import Sequence
|
| 34 |
+
from dataclasses import dataclass
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
from loguru import logger
|
| 39 |
+
from safetensors.torch import load_file
|
| 40 |
+
|
| 41 |
+
from models.tt_dit.experimental.utils.lightx2v_loader import wan_lightx2v_to_diffusers_key
|
| 42 |
+
|
| 43 |
+
# Reused from pipeline_wan_lora.py — kept inline so this module is self-contained.
|
| 44 |
+
_STRIP_PREFIXES = ("diffusion_model.", "transformer.", "unet.", "model.")
|
| 45 |
+
_LOW_RANK_RE = re.compile(r"^(?P<base>.*)\.lora_(?P<slot>A|B|down|up)(?:\.[^.]+)?\.weight$")
|
| 46 |
+
_SLOT_MAP = {"A": "A", "down": "A", "B": "B", "up": "B"}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _strip_known_prefixes(key: str) -> str:
|
| 50 |
+
for prefix in _STRIP_PREFIXES:
|
| 51 |
+
if key.startswith(prefix):
|
| 52 |
+
return key[len(prefix) :]
|
| 53 |
+
return key
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _kohya_to_lightx2v(key: str) -> str:
|
| 57 |
+
if not key.startswith("lora_unet_"):
|
| 58 |
+
return key
|
| 59 |
+
parts = key.split(".", 1)
|
| 60 |
+
module_path = parts[0][len("lora_unet_") :]
|
| 61 |
+
suffix = f".{parts[1]}" if len(parts) > 1 else ""
|
| 62 |
+
m = re.match(r"blocks_(\d+)_(cross_attn|self_attn)_([a-z]+)", module_path)
|
| 63 |
+
if m:
|
| 64 |
+
return f"blocks.{m.group(1)}.{m.group(2)}.{m.group(3)}{suffix}"
|
| 65 |
+
m = re.match(r"blocks_(\d+)_(ffn)_(\d+)", module_path)
|
| 66 |
+
if m:
|
| 67 |
+
return f"blocks.{m.group(1)}.{m.group(2)}.{m.group(3)}{suffix}"
|
| 68 |
+
logger.warning(f"unrecognized kohya key structure: {key}")
|
| 69 |
+
return key
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _normalize_key(raw: str) -> str:
|
| 73 |
+
return _kohya_to_lightx2v(_strip_known_prefixes(raw))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --------------------------------------------------------------------
|
| 77 |
+
# Public API
|
| 78 |
+
# --------------------------------------------------------------------
|
| 79 |
+
@dataclass(frozen=True)
|
| 80 |
+
class AdapterHandle:
|
| 81 |
+
name: str
|
| 82 |
+
rank: int # the "expected bound rank" — may be 3r for fused-QKV targets
|
| 83 |
+
target_indices: dict[str, int]
|
| 84 |
+
"""Map from a stable target identifier (e.g. ``'blocks.0.attn1.to_qkv'``)
|
| 85 |
+
to the bank index returned by `register_lora` on that module."""
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def load_adapter_into(
|
| 89 |
+
transformer, # WanTransformer3DModel constructed with lora_enabled=True
|
| 90 |
+
path: str,
|
| 91 |
+
*,
|
| 92 |
+
scale: float = 1.0,
|
| 93 |
+
name: str = "",
|
| 94 |
+
) -> AdapterHandle:
|
| 95 |
+
"""Load a single LoRA safetensors file into `transformer`.
|
| 96 |
+
|
| 97 |
+
Returns an `AdapterHandle` recording the bank index assigned to each
|
| 98 |
+
LoRA-targeted Linear. Use it later with
|
| 99 |
+
``pipeline.set_active_lora(handle)``.
|
| 100 |
+
"""
|
| 101 |
+
raw = load_file(str(path))
|
| 102 |
+
if not any(_is_lora_key(k) for k in raw):
|
| 103 |
+
raise RuntimeError(f"no LoRA-style keys (lora_A/lora_B, lora_down/lora_up) in {path}")
|
| 104 |
+
|
| 105 |
+
pairs, alphas, skipped_direct = _collect_pairs(raw)
|
| 106 |
+
if skipped_direct:
|
| 107 |
+
logger.warning(
|
| 108 |
+
f"{path}: skipping {skipped_direct} direct (.diff/.diff_b) deltas "
|
| 109 |
+
"— runtime-LoRA pipeline does not support these yet."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Group targets
|
| 113 |
+
fused_qkv: dict[tuple[int, str], dict[str, dict[str, torch.Tensor]]] = {}
|
| 114 |
+
singletons: list[tuple[str, dict[str, torch.Tensor]]] = [] # (diffusers_path, ab)
|
| 115 |
+
skipped_unmapped = 0
|
| 116 |
+
|
| 117 |
+
for base_path, ab in pairs.items():
|
| 118 |
+
diff_path = _diffusers_path(base_path)
|
| 119 |
+
if diff_path is None:
|
| 120 |
+
skipped_unmapped += 1
|
| 121 |
+
continue
|
| 122 |
+
m = re.match(r"blocks\.(\d+)\.(attn1|attn2)\.to_([qkv])$", diff_path)
|
| 123 |
+
if m:
|
| 124 |
+
block_idx, attn, qkv = int(m.group(1)), m.group(2), m.group(3)
|
| 125 |
+
# attn1 (self-attn): Q/K/V all fold into to_qkv.
|
| 126 |
+
# attn2 (cross-attn): Q is its own singleton; K/V fold into to_kv.
|
| 127 |
+
if attn == "attn1" or qkv in ("k", "v"):
|
| 128 |
+
fused_qkv.setdefault((block_idx, attn), {})[qkv] = ab
|
| 129 |
+
continue
|
| 130 |
+
# attn2.to_q is a real singleton — fall through.
|
| 131 |
+
# Everything else: 1:1 mapping
|
| 132 |
+
singletons.append((diff_path, ab))
|
| 133 |
+
|
| 134 |
+
if skipped_unmapped:
|
| 135 |
+
logger.warning(
|
| 136 |
+
f"{path}: {skipped_unmapped} pairs unmapped — adapter targets a module "
|
| 137 |
+
"that doesn't exist in the tt-dit name map."
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
target_indices: dict[str, int] = {}
|
| 141 |
+
fused_ranks: list[int] = []
|
| 142 |
+
singleton_ranks: list[int] = []
|
| 143 |
+
|
| 144 |
+
# Fused QKV / KV
|
| 145 |
+
for (block_idx, attn_name), qkvs in fused_qkv.items():
|
| 146 |
+
attn = getattr(transformer.blocks[block_idx], attn_name)
|
| 147 |
+
bank_idx, fused_rank = _register_fused_qkv(attn, block_idx, qkvs, alphas, scale, name)
|
| 148 |
+
if bank_idx is None:
|
| 149 |
+
continue
|
| 150 |
+
key = f"blocks.{block_idx}.{attn_name}.{'to_qkv' if attn.is_self else 'to_kv'}"
|
| 151 |
+
target_indices[key] = bank_idx
|
| 152 |
+
fused_ranks.append(fused_rank)
|
| 153 |
+
|
| 154 |
+
# Singletons
|
| 155 |
+
for diff_path, ab in singletons:
|
| 156 |
+
target, canonical = _resolve_singleton(transformer, diff_path)
|
| 157 |
+
if target is None:
|
| 158 |
+
logger.warning(f"unmapped singleton LoRA target: {diff_path}")
|
| 159 |
+
continue
|
| 160 |
+
A = ab["A"]
|
| 161 |
+
B = ab["B"]
|
| 162 |
+
rank = A.shape[0]
|
| 163 |
+
alpha = alphas.get(_lightx2v_for_path(diff_path), float(rank))
|
| 164 |
+
eff_scale = scale * (alpha / rank)
|
| 165 |
+
idx = target.register_lora(A, B, scale=eff_scale, name=name)
|
| 166 |
+
target_indices[canonical] = idx
|
| 167 |
+
singleton_ranks.append(rank)
|
| 168 |
+
|
| 169 |
+
# Sanity: every LoRA module in this adapter should have the same
|
| 170 |
+
# bound rank, but fused_QKV produces a 3x larger rank than singletons.
|
| 171 |
+
# The bound-tensor system handles per-module ranks independently, so
|
| 172 |
+
# we just record both for reporting.
|
| 173 |
+
all_ranks = fused_ranks + singleton_ranks
|
| 174 |
+
if not all_ranks:
|
| 175 |
+
raise RuntimeError(f"{path}: no LoRA targets registered")
|
| 176 |
+
representative_rank = max(all_ranks)
|
| 177 |
+
return AdapterHandle(name=name or Path(path).stem, rank=representative_rank, target_indices=target_indices)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# --------------------------------------------------------------------
|
| 181 |
+
# Helpers
|
| 182 |
+
# --------------------------------------------------------------------
|
| 183 |
+
def _is_lora_key(raw: str) -> bool:
|
| 184 |
+
key = _normalize_key(raw)
|
| 185 |
+
return bool(_LOW_RANK_RE.match(key)) or key.endswith((".diff", ".diff_b"))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _collect_pairs(raw: dict[str, torch.Tensor]):
|
| 189 |
+
"""Returns ({base_path: {'A': T, 'B': T}}, {base_path: alpha}, num_skipped_direct)."""
|
| 190 |
+
pairs: dict[str, dict[str, torch.Tensor]] = {}
|
| 191 |
+
alphas: dict[str, float] = {}
|
| 192 |
+
skipped_direct = 0
|
| 193 |
+
unrecognized: list[tuple[str, str]] = []
|
| 194 |
+
|
| 195 |
+
for raw_key, tensor in raw.items():
|
| 196 |
+
key = _normalize_key(raw_key)
|
| 197 |
+
m = _LOW_RANK_RE.match(key)
|
| 198 |
+
if m:
|
| 199 |
+
pairs.setdefault(m.group("base"), {})[_SLOT_MAP[m.group("slot")]] = tensor
|
| 200 |
+
elif key.endswith(".alpha"):
|
| 201 |
+
alphas[key[: -len(".alpha")]] = float(tensor.item())
|
| 202 |
+
elif key.endswith(".diff") or key.endswith(".diff_b"):
|
| 203 |
+
skipped_direct += 1
|
| 204 |
+
else:
|
| 205 |
+
unrecognized.append((raw_key, key))
|
| 206 |
+
|
| 207 |
+
# Surface dropped keys — a non-standard naming convention silently
|
| 208 |
+
# producing plausible-but-wrong output is the failure mode we cannot
|
| 209 |
+
# afford here.
|
| 210 |
+
if unrecognized:
|
| 211 |
+
sample = ", ".join(f"{raw}→{norm}" for raw, norm in unrecognized[:5])
|
| 212 |
+
more = "" if len(unrecognized) <= 5 else f" (+{len(unrecognized) - 5} more)"
|
| 213 |
+
logger.warning(
|
| 214 |
+
f"adapter loader: {len(unrecognized)} key(s) did not match any known "
|
| 215 |
+
f"pattern (lora_A/B, lora_down/up, .alpha, .diff, .diff_b) and were "
|
| 216 |
+
f"dropped. Samples: {sample}{more}"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
return pairs, alphas, skipped_direct
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _diffusers_path(lightx2v_base: str) -> str | None:
|
| 223 |
+
"""Map a lightx2v base path to its diffusers-canonical equivalent
|
| 224 |
+
(without ``.weight``). Returns None if mapping fails."""
|
| 225 |
+
try:
|
| 226 |
+
key = wan_lightx2v_to_diffusers_key(f"{lightx2v_base}.weight")
|
| 227 |
+
except Exception: # noqa: BLE001
|
| 228 |
+
return None
|
| 229 |
+
if not key.endswith(".weight"):
|
| 230 |
+
return None
|
| 231 |
+
return key[: -len(".weight")]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _lightx2v_for_path(diffusers_path: str) -> str:
|
| 235 |
+
"""Reverse-map (best-effort) a diffusers path back to lightx2v for alpha
|
| 236 |
+
lookups — alphas are keyed in the source naming."""
|
| 237 |
+
# Simple cases that the existing map covers:
|
| 238 |
+
rev = {
|
| 239 |
+
"attn1.to_q": "self_attn.q",
|
| 240 |
+
"attn1.to_k": "self_attn.k",
|
| 241 |
+
"attn1.to_v": "self_attn.v",
|
| 242 |
+
"attn1.to_out.0": "self_attn.o",
|
| 243 |
+
"attn2.to_q": "cross_attn.q",
|
| 244 |
+
"attn2.to_k": "cross_attn.k",
|
| 245 |
+
"attn2.to_v": "cross_attn.v",
|
| 246 |
+
"attn2.to_out.0": "cross_attn.o",
|
| 247 |
+
"ffn.net.0.proj": "ffn.0",
|
| 248 |
+
"ffn.net.2": "ffn.2",
|
| 249 |
+
}
|
| 250 |
+
for diff_seg, lx_seg in rev.items():
|
| 251 |
+
if diff_seg in diffusers_path:
|
| 252 |
+
return diffusers_path.replace(diff_seg, lx_seg)
|
| 253 |
+
return diffusers_path
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _resolve_singleton(transformer, diff_path: str):
|
| 257 |
+
"""Resolve a diffusers-style path to (module, tt_dit_dotted_path).
|
| 258 |
+
|
| 259 |
+
Returns (None, None) when no match. The tt-dit path is the one
|
| 260 |
+
consumed by the pipeline mixin's bind/unbind walker — it skips
|
| 261 |
+
diffusers-only segments like the trailing ``.0`` in ``to_out.0``.
|
| 262 |
+
"""
|
| 263 |
+
m = re.match(r"blocks\.(\d+)\.(attn1|attn2)\.to_q$", diff_path)
|
| 264 |
+
if m:
|
| 265 |
+
block_idx, attn_name = int(m.group(1)), m.group(2)
|
| 266 |
+
attn = getattr(transformer.blocks[block_idx], attn_name)
|
| 267 |
+
if not attn.is_self:
|
| 268 |
+
return attn.to_q, f"blocks.{block_idx}.{attn_name}.to_q"
|
| 269 |
+
return None, None # self-attn to_q shouldn't arrive here
|
| 270 |
+
m = re.match(r"blocks\.(\d+)\.(attn1|attn2)\.to_out\.0$", diff_path)
|
| 271 |
+
if m:
|
| 272 |
+
block_idx, attn_name = int(m.group(1)), m.group(2)
|
| 273 |
+
return getattr(transformer.blocks[block_idx], attn_name).to_out, (f"blocks.{block_idx}.{attn_name}.to_out")
|
| 274 |
+
m = re.match(r"blocks\.(\d+)\.ffn\.net\.0\.proj$", diff_path)
|
| 275 |
+
if m:
|
| 276 |
+
block_idx = int(m.group(1))
|
| 277 |
+
return transformer.blocks[block_idx].ffn.ff1, f"blocks.{block_idx}.ffn.ff1"
|
| 278 |
+
m = re.match(r"blocks\.(\d+)\.ffn\.net\.2$", diff_path)
|
| 279 |
+
if m:
|
| 280 |
+
block_idx = int(m.group(1))
|
| 281 |
+
return transformer.blocks[block_idx].ffn.ff2, f"blocks.{block_idx}.ffn.ff2"
|
| 282 |
+
return None, None
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _register_fused_qkv(
|
| 286 |
+
attn,
|
| 287 |
+
block_idx: int,
|
| 288 |
+
qkvs: dict[str, dict[str, torch.Tensor]],
|
| 289 |
+
alphas: dict[str, float],
|
| 290 |
+
scale: float,
|
| 291 |
+
name: str,
|
| 292 |
+
) -> tuple[int | None, int]:
|
| 293 |
+
"""Stack Q/K/V (or K/V for cross-attn) LoRA pairs onto attn.to_qkv (or
|
| 294 |
+
attn.to_kv). Returns (bank_idx, fused_rank) or (None, 0) on skip."""
|
| 295 |
+
if attn.is_self:
|
| 296 |
+
required = ["q", "k", "v"]
|
| 297 |
+
target = attn.to_qkv
|
| 298 |
+
else:
|
| 299 |
+
required = ["k", "v"]
|
| 300 |
+
target = attn.to_kv
|
| 301 |
+
|
| 302 |
+
missing = [r for r in required if r not in qkvs]
|
| 303 |
+
if missing:
|
| 304 |
+
logger.warning(
|
| 305 |
+
f"fused QKV LoRA on {target.__class__.__name__} is missing {missing}; "
|
| 306 |
+
"skipping this group (need all of "
|
| 307 |
+
f"{required}, got {list(qkvs)})."
|
| 308 |
+
)
|
| 309 |
+
return None, 0
|
| 310 |
+
|
| 311 |
+
A_per = [qkvs[r]["A"] for r in required]
|
| 312 |
+
B_per = [qkvs[r]["B"] for r in required]
|
| 313 |
+
ranks = [A.shape[0] for A in A_per]
|
| 314 |
+
if len(set(ranks)) != 1:
|
| 315 |
+
raise ValueError(f"QKV LoRA ranks must match; got {ranks}")
|
| 316 |
+
r = ranks[0]
|
| 317 |
+
n = len(required)
|
| 318 |
+
in_dim = attn.dim
|
| 319 |
+
out_per = attn.dim # each of Q/K/V has out=dim
|
| 320 |
+
|
| 321 |
+
# A stacked on rank dim → [n*r, in]
|
| 322 |
+
A_fused = torch.cat(A_per, dim=0)
|
| 323 |
+
assert A_fused.shape == (n * r, in_dim), f"A_fused {A_fused.shape}"
|
| 324 |
+
|
| 325 |
+
# B: each per-source B is [out_per=dim, r]. We embed each into a
|
| 326 |
+
# [out_per, n*r] tile (zeros for other-source rank columns). Then
|
| 327 |
+
# head-interleave the list of [out_per, n*r] tiles into a single
|
| 328 |
+
# [n*out_per, n*r] tensor — matching the head layout of the fused
|
| 329 |
+
# to_qkv / to_kv base weight.
|
| 330 |
+
n_dev = attn.parallel_config.tensor_parallel.factor
|
| 331 |
+
n_local_heads = attn.n_local_heads
|
| 332 |
+
head_dim = attn.head_dim
|
| 333 |
+
|
| 334 |
+
B_per_padded: list[torch.Tensor] = []
|
| 335 |
+
for i, B in enumerate(B_per):
|
| 336 |
+
pad = torch.zeros(out_per, n * r, dtype=B.dtype)
|
| 337 |
+
pad[:, i * r : (i + 1) * r] = B
|
| 338 |
+
B_per_padded.append(pad)
|
| 339 |
+
|
| 340 |
+
B_fused = _head_interleave_lora_B(B_per_padded, n_dev=n_dev, n_local_heads=n_local_heads, head_dim=head_dim)
|
| 341 |
+
assert B_fused.shape == (n * out_per, n * r), f"B_fused {B_fused.shape}"
|
| 342 |
+
|
| 343 |
+
# Effective scale: pick the first source's alpha (alphas are usually
|
| 344 |
+
# identical across Q/K/V in a single adapter; if not, the choice is
|
| 345 |
+
# safe-ish because we apply scale to all three the same way).
|
| 346 |
+
alpha_keys = [_lightx2v_qkv_key(attn, block_idx, r_name) for r_name in required]
|
| 347 |
+
alpha = next((alphas[k] for k in alpha_keys if k in alphas), float(r))
|
| 348 |
+
eff_scale = scale * (alpha / r)
|
| 349 |
+
|
| 350 |
+
bank_idx = target.register_lora(A_fused, B_fused, scale=eff_scale, name=name)
|
| 351 |
+
return bank_idx, n * r
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _lightx2v_qkv_key(attn, block_idx: int, qkv: str) -> str:
|
| 355 |
+
"""Build the lightx2v alpha key for a Q/K/V projection inside a
|
| 356 |
+
given WanAttention. Alphas in `_collect_pairs` are keyed by the full
|
| 357 |
+
normalized base path (``blocks.<i>.<side>.<qkv>``), so the block
|
| 358 |
+
index must be threaded in alongside ``attn.is_self``."""
|
| 359 |
+
side = "self_attn" if attn.is_self else "cross_attn"
|
| 360 |
+
return f"blocks.{block_idx}.{side}.{qkv}"
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _head_interleave_lora_B(
|
| 364 |
+
tensors: Sequence[torch.Tensor],
|
| 365 |
+
*,
|
| 366 |
+
n_dev: int,
|
| 367 |
+
n_local_heads: int,
|
| 368 |
+
head_dim: int,
|
| 369 |
+
) -> torch.Tensor:
|
| 370 |
+
"""Mirror of WanAttention._interleave_heads for LoRA B tensors.
|
| 371 |
+
|
| 372 |
+
Each input is [out_per_source=num_heads*head_dim, rank_fused].
|
| 373 |
+
Returns a concatenated [N*out_per, rank_fused] tensor with the out
|
| 374 |
+
dim head-interleaved so column-parallel sharding lines up with the
|
| 375 |
+
fused base weight's layout.
|
| 376 |
+
"""
|
| 377 |
+
out_per = n_dev * n_local_heads * head_dim
|
| 378 |
+
fused_rank = tensors[0].shape[1]
|
| 379 |
+
assert all(t.shape == (out_per, fused_rank) for t in tensors), (
|
| 380 |
+
f"all B tensors must be [{out_per}, {fused_rank}]; got " f"{[tuple(t.shape) for t in tensors]}"
|
| 381 |
+
)
|
| 382 |
+
n = len(tensors)
|
| 383 |
+
# Transpose each to [fused_rank, out_per]
|
| 384 |
+
transposed = [t.T for t in tensors]
|
| 385 |
+
# Reshape out dim to [fused_rank, n_dev, n_local_heads, head_dim]
|
| 386 |
+
reshaped = [t.reshape(fused_rank, n_dev, n_local_heads, head_dim) for t in transposed]
|
| 387 |
+
# Concat on heads sub-dim → [fused_rank, n_dev, N*n_local_heads, head_dim]
|
| 388 |
+
merged = torch.cat(reshaped, dim=2)
|
| 389 |
+
# Flatten back → [fused_rank, N*n_dev*n_local_heads*head_dim] = [fused_rank, N*out_per]
|
| 390 |
+
merged = merged.reshape(fused_rank, n * out_per)
|
| 391 |
+
# Transpose to [N*out_per, fused_rank]
|
| 392 |
+
return merged.T
|
code/models/tt_dit/experimental/lora/ltx_adapter_loader.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Adapter loader for on-device fuse-mode LoRA on the LTX-2.3 transformer.
|
| 6 |
+
|
| 7 |
+
Reads a LoRA safetensors file and registers its A/B pairs into the
|
| 8 |
+
LoRA-aware Linear modules of an ``LTXTransformerModel`` built with
|
| 9 |
+
``lora_enabled=True``. Swap is then a ``bind_active`` on each module
|
| 10 |
+
(``weight.data += scale * A@B`` on device) — no host fuse, no reload.
|
| 11 |
+
|
| 12 |
+
Why this differs from the Wan loader
|
| 13 |
+
-------------------------------------
|
| 14 |
+
LTX's ``LTXAttention._prepare_torch_state`` transforms the raw checkpoint
|
| 15 |
+
Q/K/V weights before they reach the device:
|
| 16 |
+
|
| 17 |
+
- ``_permute_qk``: reorders each head's channels from the checkpoint's
|
| 18 |
+
SPLIT rotary layout to the INTERLEAVED layout the RoPE op expects.
|
| 19 |
+
Applied to Q and K (self-attn) and to the cross-attn ``to_q``.
|
| 20 |
+
- ``_interleave_heads``: folds separate Q/K/V (or K/V) into the fused
|
| 21 |
+
``to_qkv`` / ``to_kv`` weight with heads interleaved so column-parallel
|
| 22 |
+
sharding lands the right head on each device.
|
| 23 |
+
|
| 24 |
+
The host-fuse path (``utils.fuse_loras.fuse_loras_into``) is correct
|
| 25 |
+
without any of this because it merges the delta into the *raw* checkpoint
|
| 26 |
+
weight and lets ``_prepare_torch_state`` permute the merged result. Here
|
| 27 |
+
the on-device weight is ALREADY permuted+interleaved, so the LoRA B must
|
| 28 |
+
be pre-transformed identically or the merged delta lands in the wrong
|
| 29 |
+
layout. ``to_out``/``ff1``/``ff2`` are untouched by ``_prepare_torch_state``,
|
| 30 |
+
so their adapters need no transform (the mixin copies W's own sharding).
|
| 31 |
+
|
| 32 |
+
Scope: every LoRA-adaptable Linear in the transformer — video/audio/cross-modal
|
| 33 |
+
attention (``to_qkv``/``to_q``/``to_kv``/``to_out``/``to_gate_logits``), both FFNs
|
| 34 |
+
(ff1/ff2), and the globals (``patchify_proj``, ``proj_out``, and the
|
| 35 |
+
``adaln_single`` / timestep-embedder linears). ``promote_to_lora`` makes the plain
|
| 36 |
+
globals bindable; attention/FFN are already LoRA via ``lora_enabled``. Only
|
| 37 |
+
attention q/k/v/q-of-cross carry the rotary permute + head-interleave transform;
|
| 38 |
+
everything else registers directly. ``.diff``/``.diff_b`` direct deltas remain
|
| 39 |
+
unsupported; unmapped keys are surfaced loudly, never silently dropped.
|
| 40 |
+
"""
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
import re
|
| 44 |
+
from dataclasses import dataclass
|
| 45 |
+
from pathlib import Path
|
| 46 |
+
|
| 47 |
+
import torch
|
| 48 |
+
from loguru import logger
|
| 49 |
+
from safetensors.torch import load_file
|
| 50 |
+
|
| 51 |
+
from ...layers.lora import LoRAMixin
|
| 52 |
+
from .promote import promote_to_lora
|
| 53 |
+
|
| 54 |
+
_STRIP_PREFIXES = ("model.diffusion_model.", "diffusion_model.", "transformer.", "model.")
|
| 55 |
+
_LOW_RANK_RE = re.compile(r"^(?P<base>.*)\.lora_(?P<slot>A|B|down|up)(?:\.[^.]+)?\.weight$")
|
| 56 |
+
_SLOT_MAP = {"A": "A", "down": "A", "B": "B", "up": "B"}
|
| 57 |
+
|
| 58 |
+
# Diffusers LTX checkpoints name blocks "transformer_blocks.<i>"; some exports
|
| 59 |
+
# use the shorter "blocks.<i>". Accept both.
|
| 60 |
+
_BLOCK_RE = r"(?:transformer_blocks|blocks)\.(\d+)"
|
| 61 |
+
|
| 62 |
+
# All LTXAttention instances on a block share the same to_q/k/v/out/gate_logits
|
| 63 |
+
# structure and fusion rules (self -> to_qkv, cross -> to_q + to_kv), so the loader
|
| 64 |
+
# handles them uniformly, reading is_self/heads off each resolved module.
|
| 65 |
+
_ATTN_NAMES = ("attn1", "attn2", "audio_attn1", "audio_attn2", "audio_to_video_attn", "video_to_audio_attn")
|
| 66 |
+
# Diffusers FFN container name -> tt-dit attribute (ff.net.0.proj/net.2 -> ffX).
|
| 67 |
+
_FF_NAME_TO_ATTR = {"ff": "ffn", "audio_ff": "audio_ff"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass(frozen=True)
|
| 71 |
+
class LTXAdapterHandle:
|
| 72 |
+
name: str
|
| 73 |
+
target_indices: dict[str, int]
|
| 74 |
+
"""Map from a canonical tt-dit module path (e.g. ``transformer_blocks.0.attn1.to_qkv``)
|
| 75 |
+
to the bank index returned by ``register_lora`` on that module."""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def load_ltx_adapter_into(
|
| 79 |
+
transformer, # LTXTransformerModel built with lora_enabled=True
|
| 80 |
+
path: str,
|
| 81 |
+
*,
|
| 82 |
+
scale: float = 1.0,
|
| 83 |
+
name: str = "",
|
| 84 |
+
) -> LTXAdapterHandle:
|
| 85 |
+
"""Register a single LoRA safetensors file into ``transformer``.
|
| 86 |
+
|
| 87 |
+
Returns a handle recording the bank index assigned per module; pass it
|
| 88 |
+
to the pipeline's ``set_active_lora`` to bind it on device.
|
| 89 |
+
"""
|
| 90 |
+
raw = load_file(str(path))
|
| 91 |
+
pairs, alphas, skipped_direct, unrecognized = _collect_pairs(raw)
|
| 92 |
+
if not pairs:
|
| 93 |
+
raise RuntimeError(f"no LoRA A/B pairs (lora_A/B or lora_down/up) found in {path}")
|
| 94 |
+
if skipped_direct:
|
| 95 |
+
logger.warning(f"{path}: skipping {skipped_direct} direct (.diff/.diff_b) deltas — not supported.")
|
| 96 |
+
if unrecognized:
|
| 97 |
+
sample = ", ".join(unrecognized[:5])
|
| 98 |
+
more = "" if len(unrecognized) <= 5 else f" (+{len(unrecognized) - 5} more)"
|
| 99 |
+
logger.warning(
|
| 100 |
+
f"{path}: {len(unrecognized)} key(s) matched no known LoRA pattern and were dropped: {sample}{more}"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Promote every plain Linear (adaln/proj/patchify/gate globals) to a LoRA
|
| 104 |
+
# variant so it can be bound; attn/ffn are already LoRA via lora_enabled.
|
| 105 |
+
promote_to_lora(transformer)
|
| 106 |
+
|
| 107 |
+
# Q/K/V fold into a fused projection; the rest are 1:1 block singletons or
|
| 108 |
+
# direct globals (patchify/proj_out/adaln/timestep-embedder).
|
| 109 |
+
fused: dict[tuple[int, str], dict[str, dict[str, torch.Tensor]]] = {}
|
| 110 |
+
singletons: list[tuple[int, str, str, dict[str, torch.Tensor]]] = [] # (block, container, sub, ab)
|
| 111 |
+
globals_: list[tuple[str, dict[str, torch.Tensor]]] = []
|
| 112 |
+
|
| 113 |
+
for base, ab in pairs.items():
|
| 114 |
+
parsed = _parse_target(base)
|
| 115 |
+
if parsed is None:
|
| 116 |
+
globals_.append((base, ab))
|
| 117 |
+
continue
|
| 118 |
+
block_idx, container, sub = parsed
|
| 119 |
+
if sub in ("to_q", "to_k", "to_v"):
|
| 120 |
+
attn = _get_attn(transformer, block_idx, container)
|
| 121 |
+
is_self = attn.is_self if attn is not None else container in ("attn1", "audio_attn1")
|
| 122 |
+
qkv = sub[-1]
|
| 123 |
+
# self-attn: q/k/v all fold into to_qkv. cross-attn: k/v fold into to_kv, q is a singleton.
|
| 124 |
+
if is_self or qkv in ("k", "v"):
|
| 125 |
+
fused.setdefault((block_idx, container), {})[qkv] = ab
|
| 126 |
+
continue
|
| 127 |
+
singletons.append((block_idx, container, sub, ab))
|
| 128 |
+
|
| 129 |
+
target_indices: dict[str, int] = {}
|
| 130 |
+
|
| 131 |
+
for (block_idx, container), qkvs in fused.items():
|
| 132 |
+
attn = _get_attn(transformer, block_idx, container)
|
| 133 |
+
if attn is None:
|
| 134 |
+
logger.warning(f"{path}: no attention module {container} in block {block_idx} — skipping fused group.")
|
| 135 |
+
continue
|
| 136 |
+
idx = _register_fused(attn, qkvs, alphas, scale, name, block_idx, container)
|
| 137 |
+
if idx is None:
|
| 138 |
+
continue
|
| 139 |
+
canonical = f"transformer_blocks.{block_idx}.{container}.{'to_qkv' if attn.is_self else 'to_kv'}"
|
| 140 |
+
target_indices[canonical] = idx
|
| 141 |
+
|
| 142 |
+
for block_idx, container, sub, ab in singletons:
|
| 143 |
+
if "A" not in ab or "B" not in ab:
|
| 144 |
+
logger.warning(f"{path}: {container}.{sub} in block {block_idx} missing one of A/B — skipping.")
|
| 145 |
+
continue
|
| 146 |
+
target, canonical, permute_dims = _resolve_singleton(transformer, block_idx, container, sub)
|
| 147 |
+
if target is None:
|
| 148 |
+
logger.warning(f"{path}: unmapped {container}.{sub} in block {block_idx} — skipping.")
|
| 149 |
+
continue
|
| 150 |
+
A = ab["A"]
|
| 151 |
+
B = ab["B"]
|
| 152 |
+
if permute_dims is not None:
|
| 153 |
+
B = _permute_qk_rows(B, *permute_dims)
|
| 154 |
+
rank = A.shape[0]
|
| 155 |
+
alpha = alphas.get(_alpha_key(block_idx, container, sub), float(rank))
|
| 156 |
+
idx = target.register_lora(A, B, scale=scale * (alpha / rank), name=name)
|
| 157 |
+
target_indices[canonical] = idx
|
| 158 |
+
|
| 159 |
+
unmapped_global = 0
|
| 160 |
+
for base, ab in globals_:
|
| 161 |
+
if "A" not in ab or "B" not in ab:
|
| 162 |
+
continue
|
| 163 |
+
target = _resolve_global(transformer, base)
|
| 164 |
+
if not isinstance(target, LoRAMixin):
|
| 165 |
+
unmapped_global += 1
|
| 166 |
+
continue
|
| 167 |
+
A = ab["A"]
|
| 168 |
+
B = ab["B"]
|
| 169 |
+
rank = A.shape[0]
|
| 170 |
+
alpha = alphas.get(base, float(rank))
|
| 171 |
+
idx = target.register_lora(A, B, scale=scale * (alpha / rank), name=name)
|
| 172 |
+
target_indices[base] = idx
|
| 173 |
+
|
| 174 |
+
if unmapped_global:
|
| 175 |
+
logger.warning(f"{path}: {unmapped_global} global key(s) matched no LTX module — dropped.")
|
| 176 |
+
|
| 177 |
+
if not target_indices:
|
| 178 |
+
raise RuntimeError(f"{path}: no LoRA targets registered — check the adapter's key naming against LTX modules.")
|
| 179 |
+
|
| 180 |
+
return LTXAdapterHandle(name=name or Path(path).stem, target_indices=target_indices)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def iter_lora_modules(root, prefix: str = ""):
|
| 184 |
+
"""Yield (dotted_path, module) for every LoRAMixin descendant of ``root``.
|
| 185 |
+
Paths match the loader's canonical keys (e.g. ``transformer_blocks.0.attn1.to_qkv``)."""
|
| 186 |
+
for name, child in root.named_children():
|
| 187 |
+
path = f"{prefix}.{name}" if prefix else name
|
| 188 |
+
if isinstance(child, LoRAMixin):
|
| 189 |
+
yield path, child
|
| 190 |
+
yield from iter_lora_modules(child, path)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# --------------------------------------------------------------------
|
| 194 |
+
# Key parsing
|
| 195 |
+
# --------------------------------------------------------------------
|
| 196 |
+
def _strip_prefixes(key: str) -> str:
|
| 197 |
+
for p in _STRIP_PREFIXES:
|
| 198 |
+
if key.startswith(p):
|
| 199 |
+
return key[len(p) :]
|
| 200 |
+
return key
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _collect_pairs(raw: dict[str, torch.Tensor]):
|
| 204 |
+
"""Return ({base: {'A','B'}}, {base: alpha}, num_skipped_direct, unrecognized_keys)."""
|
| 205 |
+
pairs: dict[str, dict[str, torch.Tensor]] = {}
|
| 206 |
+
alphas: dict[str, float] = {}
|
| 207 |
+
skipped_direct = 0
|
| 208 |
+
unrecognized: list[str] = []
|
| 209 |
+
for raw_key, tensor in raw.items():
|
| 210 |
+
key = _strip_prefixes(raw_key)
|
| 211 |
+
m = _LOW_RANK_RE.match(key)
|
| 212 |
+
if m:
|
| 213 |
+
pairs.setdefault(m.group("base"), {})[_SLOT_MAP[m.group("slot")]] = tensor
|
| 214 |
+
elif key.endswith(".alpha"):
|
| 215 |
+
alphas[key[: -len(".alpha")]] = float(tensor.item())
|
| 216 |
+
elif key.endswith((".diff", ".diff_b")):
|
| 217 |
+
skipped_direct += 1
|
| 218 |
+
else:
|
| 219 |
+
unrecognized.append(raw_key)
|
| 220 |
+
return pairs, alphas, skipped_direct, unrecognized
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _parse_target(base: str) -> tuple[int, str, str] | None:
|
| 224 |
+
"""Parse a stripped per-block LoRA base path into (block_idx, container, sub).
|
| 225 |
+
|
| 226 |
+
Covers every block attention family (video/audio/cross-modal) and both FFN
|
| 227 |
+
containers. Returns None for non-block paths (globals: patchify/proj_out/adaln),
|
| 228 |
+
which ``_resolve_global`` handles by direct path lookup."""
|
| 229 |
+
attn_alt = "|".join(_ATTN_NAMES)
|
| 230 |
+
m = re.match(rf"^{_BLOCK_RE}\.({attn_alt})\.to_(q|k|v|out|gate_logits)(?:\.0)?$", base)
|
| 231 |
+
if m:
|
| 232 |
+
raw = m.group(3)
|
| 233 |
+
sub = {"out": "to_out", "gate_logits": "to_gate_logits"}.get(raw, f"to_{raw}")
|
| 234 |
+
return int(m.group(1)), m.group(2), sub
|
| 235 |
+
ff_alt = "|".join(_FF_NAME_TO_ATTR)
|
| 236 |
+
m = re.match(rf"^{_BLOCK_RE}\.({ff_alt})\.net\.0\.proj$", base)
|
| 237 |
+
if m:
|
| 238 |
+
return int(m.group(1)), m.group(2), "ff1"
|
| 239 |
+
m = re.match(rf"^{_BLOCK_RE}\.({ff_alt})\.net\.2$", base)
|
| 240 |
+
if m:
|
| 241 |
+
return int(m.group(1)), m.group(2), "ff2"
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _alpha_key(block_idx: int, attn_name: str, sub: str) -> str:
|
| 246 |
+
return f"transformer_blocks.{block_idx}.{attn_name}.{sub}"
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# --------------------------------------------------------------------
|
| 250 |
+
# Module resolution
|
| 251 |
+
# --------------------------------------------------------------------
|
| 252 |
+
def _get_attn(transformer, block_idx: int, attn_name: str):
|
| 253 |
+
blocks = transformer.transformer_blocks
|
| 254 |
+
if not (0 <= block_idx < len(blocks)):
|
| 255 |
+
return None
|
| 256 |
+
return getattr(blocks[block_idx], attn_name, None)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _resolve_singleton(transformer, block_idx: int, container: str, sub: str):
|
| 260 |
+
"""Return (module, canonical_path, permute_dims) where permute_dims is
|
| 261 |
+
(num_heads, head_dim) if the base weight is _permute_qk'd, else None.
|
| 262 |
+
(None, None, None) when the target does not exist."""
|
| 263 |
+
blocks = transformer.transformer_blocks
|
| 264 |
+
if not (0 <= block_idx < len(blocks)):
|
| 265 |
+
return None, None, None
|
| 266 |
+
block = blocks[block_idx]
|
| 267 |
+
if sub in ("to_q", "to_out", "to_gate_logits"):
|
| 268 |
+
attn = getattr(block, container, None)
|
| 269 |
+
if attn is None:
|
| 270 |
+
return None, None, None
|
| 271 |
+
target = getattr(attn, sub, None)
|
| 272 |
+
if target is None:
|
| 273 |
+
return None, None, None
|
| 274 |
+
# Only cross-attn to_q is _permute_qk'd; to_out/to_gate_logits are not.
|
| 275 |
+
permute_dims = (attn.num_heads, attn.head_dim) if (sub == "to_q" and not attn.is_self) else None
|
| 276 |
+
return target, f"transformer_blocks.{block_idx}.{container}.{sub}", permute_dims
|
| 277 |
+
if sub in ("ff1", "ff2"):
|
| 278 |
+
ff_attr = _FF_NAME_TO_ATTR.get(container, "ffn")
|
| 279 |
+
ff = getattr(block, ff_attr, None)
|
| 280 |
+
if ff is None:
|
| 281 |
+
return None, None, None
|
| 282 |
+
target = getattr(ff, sub, None)
|
| 283 |
+
return target, f"transformer_blocks.{block_idx}.{ff_attr}.{sub}", None
|
| 284 |
+
return None, None, None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _resolve_global(transformer, base: str):
|
| 288 |
+
"""Resolve a non-block LoRA key (patchify_proj, proj_out, adaln_single.*, ...)
|
| 289 |
+
to the module at that dotted path on the transformer root, or None if absent.
|
| 290 |
+
These are plain replicated/parallel Linears with no rotary layout transform."""
|
| 291 |
+
mod = transformer
|
| 292 |
+
for part in base.split("."):
|
| 293 |
+
mod = getattr(mod, part, None)
|
| 294 |
+
if mod is None:
|
| 295 |
+
return None
|
| 296 |
+
return mod
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# --------------------------------------------------------------------
|
| 300 |
+
# Fused QKV / KV registration (permute_qk + head-interleave on B)
|
| 301 |
+
# --------------------------------------------------------------------
|
| 302 |
+
def _register_fused(attn, qkvs, alphas, scale, name, block_idx, attn_name) -> int | None:
|
| 303 |
+
required = ["q", "k", "v"] if attn.is_self else ["k", "v"]
|
| 304 |
+
missing = [r for r in required if r not in qkvs or "A" not in qkvs[r] or "B" not in qkvs[r]]
|
| 305 |
+
if missing:
|
| 306 |
+
logger.warning(f"fused LoRA on block {block_idx}.{attn_name} missing {missing}; skipping (got {list(qkvs)}).")
|
| 307 |
+
return None
|
| 308 |
+
|
| 309 |
+
ranks = [qkvs[r]["A"].shape[0] for r in required]
|
| 310 |
+
if len(set(ranks)) != 1:
|
| 311 |
+
raise ValueError(f"QKV LoRA ranks must match on block {block_idx}.{attn_name}; got {ranks}")
|
| 312 |
+
r = ranks[0]
|
| 313 |
+
n = len(required)
|
| 314 |
+
|
| 315 |
+
num_heads = attn.num_heads
|
| 316 |
+
head_dim = attn.head_dim
|
| 317 |
+
n_dev = attn.parallel_config.tensor_parallel.factor
|
| 318 |
+
n_local_heads = attn.n_local_heads
|
| 319 |
+
out_per = attn.dim # each fused source projects to dim
|
| 320 |
+
|
| 321 |
+
# A stacked on rank -> [n*r, in]
|
| 322 |
+
A_fused = torch.cat([qkvs[x]["A"] for x in required], dim=0)
|
| 323 |
+
|
| 324 |
+
# B: permute Q/K rows (RoPE), pad each into a block-diagonal [out, n*r] tile,
|
| 325 |
+
# then head-interleave the tiles into [n*out, n*r] to match the fused weight.
|
| 326 |
+
B_padded = []
|
| 327 |
+
for i, x in enumerate(required):
|
| 328 |
+
B = qkvs[x]["B"]
|
| 329 |
+
if x in ("q", "k"):
|
| 330 |
+
B = _permute_qk_rows(B, num_heads, head_dim)
|
| 331 |
+
pad = torch.zeros(out_per, n * r, dtype=B.dtype)
|
| 332 |
+
pad[:, i * r : (i + 1) * r] = B
|
| 333 |
+
B_padded.append(pad)
|
| 334 |
+
B_fused = _interleave_heads_rows(B_padded, num_heads, head_dim, n_dev, n_local_heads)
|
| 335 |
+
|
| 336 |
+
alpha = alphas.get(_alpha_key(block_idx, attn_name, f"to_{required[0]}"), float(r))
|
| 337 |
+
eff_scale = scale * (alpha / r)
|
| 338 |
+
# register_lora lives on the fused projection Linear (LoRAMixin), not the attention wrapper.
|
| 339 |
+
fused_linear = attn.to_qkv if attn.is_self else attn.to_kv
|
| 340 |
+
return fused_linear.register_lora(A_fused, B_fused, scale=eff_scale, name=name)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _permute_qk_rows(t: torch.Tensor, num_heads: int, head_dim: int) -> torch.Tensor:
|
| 344 |
+
"""Reorder each head's output channels SPLIT -> INTERLEAVED, mirroring
|
| 345 |
+
``LTXAttention._prepare_torch_state._permute_qk``. ``t`` is [num_heads*head_dim, ...]."""
|
| 346 |
+
D = head_dim
|
| 347 |
+
D_half = D // 2
|
| 348 |
+
perm = torch.empty(D, dtype=torch.long)
|
| 349 |
+
perm[0::2] = torch.arange(D_half)
|
| 350 |
+
perm[1::2] = torch.arange(D_half, D)
|
| 351 |
+
rest = t.shape[1:]
|
| 352 |
+
return t.reshape(num_heads, D, *rest).index_select(1, perm).reshape(num_heads * D, *rest)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _interleave_heads_rows(tensors, num_heads: int, head_dim: int, n_dev: int, n_local_heads: int) -> torch.Tensor:
|
| 356 |
+
"""Interleave the OUT (row) dim across sources, mirroring
|
| 357 |
+
``LTXAttention._prepare_torch_state._interleave_heads``. Each input is
|
| 358 |
+
[out=num_heads*head_dim, C]; returns [n*out, C]."""
|
| 359 |
+
transposed = [t.T for t in tensors] # [C, out]
|
| 360 |
+
reshaped = [t.reshape(t.shape[0], n_dev, n_local_heads, head_dim) for t in transposed]
|
| 361 |
+
merged = torch.cat(reshaped, dim=2) # [C, n_dev, n*n_local_heads, head_dim]
|
| 362 |
+
merged = merged.reshape(merged.shape[0], len(tensors) * num_heads * head_dim) # [C, n*out]
|
| 363 |
+
return merged.T # [n*out, C]
|
code/models/tt_dit/experimental/lora/promote.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""Generic in-place promotion of Linear-family modules to their LoRA variants.
|
| 5 |
+
|
| 6 |
+
Rather than thread a ``lora_enabled`` flag through every module constructor,
|
| 7 |
+
walk a built model once and upgrade each plain ``Linear``/``ColParallelLinear``/
|
| 8 |
+
``RowParallelLinear`` to the matching ``LoRAMixin`` subclass. The subclasses are
|
| 9 |
+
thin (mixin + base + ``_init_lora_state``), so swapping ``__class__`` and calling
|
| 10 |
+
``_init_lora_state`` post-construction is equivalent to having built the LoRA
|
| 11 |
+
variant directly. Modules already LoRA-aware are left untouched.
|
| 12 |
+
|
| 13 |
+
This keeps the swap surface identical — ``bind_active`` / ``set_active_lora`` /
|
| 14 |
+
``reapply_after_load`` act on the promoted modules exactly as before — while
|
| 15 |
+
making every Linear in the model a LoRA target, not just the attn/ffn subset.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from ...layers.linear import ColParallelLinear, Linear, RowParallelLinear
|
| 20 |
+
from ...layers.lora import LoRAMixin
|
| 21 |
+
|
| 22 |
+
# Base types eligible for in-place promotion. Exact-type match (not isinstance)
|
| 23 |
+
# so an unknown Linear subclass isn't silently mis-promoted.
|
| 24 |
+
_PROMOTABLE = (Linear, ColParallelLinear, RowParallelLinear)
|
| 25 |
+
|
| 26 |
+
# Per-base promotion classes, built lazily. Base FIRST, mixin SECOND: Python
|
| 27 |
+
# forbids ``__class__`` assignment when the mixin comes first (the instance
|
| 28 |
+
# "solid base" changes -> 'object layout differs'). With the base first the
|
| 29 |
+
# layout is identical to the original, so the swap is allowed. The pre-defined
|
| 30 |
+
# LoRA*Linear classes are mixin-first (so their forward override wins in runtime
|
| 31 |
+
# mode) and can't be used here; fuse mode never needs that override, and every
|
| 32 |
+
# bind method is LoRAMixin-exclusive, so ordering is otherwise irrelevant.
|
| 33 |
+
_PROMOTED_CACHE: dict[type, type] = {}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _promoted_class(base_cls: type) -> type:
|
| 37 |
+
cls = _PROMOTED_CACHE.get(base_cls)
|
| 38 |
+
if cls is None:
|
| 39 |
+
cls = type(f"LoRA{base_cls.__name__}", (base_cls, LoRAMixin), {})
|
| 40 |
+
_PROMOTED_CACHE[base_cls] = cls
|
| 41 |
+
return cls
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _iter_modules(root):
|
| 45 |
+
yield root
|
| 46 |
+
for _, child in root.named_children():
|
| 47 |
+
yield from _iter_modules(child)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def promote_to_lora(root, *, mode: str = "fuse") -> int:
|
| 51 |
+
"""Upgrade every plain Linear-family descendant of ``root`` to a LoRA-aware
|
| 52 |
+
class in place. Returns the number promoted. Idempotent: modules already
|
| 53 |
+
``LoRAMixin`` (e.g. built via ``lora_enabled``) are skipped."""
|
| 54 |
+
promoted = 0
|
| 55 |
+
for module in _iter_modules(root):
|
| 56 |
+
if isinstance(module, LoRAMixin):
|
| 57 |
+
continue
|
| 58 |
+
if type(module) not in _PROMOTABLE:
|
| 59 |
+
continue
|
| 60 |
+
module.__class__ = _promoted_class(type(module))
|
| 61 |
+
module._init_lora_state(mode=mode)
|
| 62 |
+
promoted += 1
|
| 63 |
+
return promoted
|
code/models/tt_dit/experimental/models/AniSora.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Index-AniSora V3.2 (I2V)
|
| 2 |
+
|
| 3 |
+
## Introduction
|
| 4 |
+
|
| 5 |
+
[IndexTeam/Index-anisora](https://huggingface.co/IndexTeam/Index-anisora) V3.2
|
| 6 |
+
is an anime-domain image-to-video model derived from
|
| 7 |
+
[Wan2.2-I2V-A14B](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers).
|
| 8 |
+
It preserves the two-expert (high-noise / low-noise) MoE structure of Wan2.2
|
| 9 |
+
and ships a finetuned weight pair specialized for anime-style motion.
|
| 10 |
+
|
| 11 |
+
This is a **full checkpoint replacement** (not a LoRA adapter or distilled model).
|
| 12 |
+
The model graph is identical to base Wan2.2 I2V — see [Wan2_2.md](Wan2_2.md) for
|
| 13 |
+
the underlying architecture.
|
| 14 |
+
|
| 15 |
+
## Details
|
| 16 |
+
|
| 17 |
+
The two-stage MoE schedule is preserved: the high-noise expert handles the
|
| 18 |
+
first 90% of the denoising trajectory and the low-noise expert handles the
|
| 19 |
+
final 10% (`boundary_ratio=0.9`, vs 0.5 for base Wan2.2).
|
| 20 |
+
|
| 21 |
+
The AniSora safetensors use the original-Wan key naming
|
| 22 |
+
(`blocks.X.self_attn.q.weight`, etc), identical to the lightx2v distill
|
| 23 |
+
checkpoints. The same `wan_lightx2v_to_diffusers_key` rename function in
|
| 24 |
+
`models/tt_dit/utils/lightx2v_loader.py` is reused.
|
| 25 |
+
|
| 26 |
+
`NUM_STEPS` is configurable (default 40). Lower step counts (16, 8) can produce video at decent quality.
|
| 27 |
+
|
| 28 |
+
## Weights
|
| 29 |
+
|
| 30 |
+
### AniSora V3.2 experts (DiT only)
|
| 31 |
+
|
| 32 |
+
- **Source:** [IndexTeam/Index-anisora](https://huggingface.co/IndexTeam/Index-anisora)
|
| 33 |
+
- **Files:**
|
| 34 |
+
- `V3.2/high_noise_model/diffusion_pytorch_model.safetensors` (~28 GB, BF16)
|
| 35 |
+
- `V3.2/low_noise_model/diffusion_pytorch_model.safetensors` (~28 GB, BF16)
|
| 36 |
+
- **Total download:** ~57 GB (auto-downloaded on first run with `TT_DIT_ALLOW_HF_DOWNLOAD=1`)
|
| 37 |
+
|
| 38 |
+
### Base Wan2.2 components (shared, not re-downloaded if already cached)
|
| 39 |
+
|
| 40 |
+
- **Source:** [Wan-AI/Wan2.2-I2V-A14B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers)
|
| 41 |
+
- **Components:** tokenizer, UMT5 text encoder, VAE, scheduler config
|
| 42 |
+
- **Total download:** ~12 GB (shared with base Wan2.2)
|
| 43 |
+
|
| 44 |
+
### Download weights manually (optional)
|
| 45 |
+
|
| 46 |
+
Weights are auto-downloaded via HuggingFace Hub on first run. To pre-download or use a local copy:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
# Option 1: Pre-download to HF cache
|
| 50 |
+
python -c "
|
| 51 |
+
from huggingface_hub import hf_hub_download
|
| 52 |
+
for f in ['V3.2/high_noise_model/diffusion_pytorch_model.safetensors',
|
| 53 |
+
'V3.2/low_noise_model/diffusion_pytorch_model.safetensors']:
|
| 54 |
+
hf_hub_download('IndexTeam/Index-anisora', f)
|
| 55 |
+
"
|
| 56 |
+
|
| 57 |
+
# Option 2: Point to a local directory
|
| 58 |
+
# Layout must be:
|
| 59 |
+
# $ANISORA_LOCAL_DIR/V3.2/high_noise_model/diffusion_pytorch_model.safetensors
|
| 60 |
+
# $ANISORA_LOCAL_DIR/V3.2/low_noise_model/diffusion_pytorch_model.safetensors
|
| 61 |
+
export ANISORA_LOCAL_DIR=/path/to/index-anisora-weights
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Inference defaults
|
| 65 |
+
|
| 66 |
+
Mirrors `anisoraV3.2/wan/configs/wan_i2v_A14B.py`:
|
| 67 |
+
|
| 68 |
+
| Parameter | Value | Note |
|
| 69 |
+
|---|---|---|
|
| 70 |
+
| `num_inference_steps` | 40 | UniPC sampler (configurable via `NUM_STEPS` env var) |
|
| 71 |
+
| `boundary_ratio` | 0.9 | High-noise covers first 90% of timesteps |
|
| 72 |
+
| `guidance_scale` | 3.5 | High-noise stage CFG |
|
| 73 |
+
| `guidance_scale_2` | 3.5 | Low-noise stage CFG |
|
| 74 |
+
| `sample_shift` | 5.0 | Inherited from base Wan2.2 scheduler config |
|
| 75 |
+
|
| 76 |
+
## Performance
|
| 77 |
+
|
| 78 |
+
BH Galaxy 4x8 Ring (sp=8, tp=4), 81 frames @ 16fps (~5s video):
|
| 79 |
+
|
| 80 |
+
| Stage | Base Wan2.2 I2V 480p | AniSora V3.2 I2V 480p | AniSora V3.2 I2V 720p |
|
| 81 |
+
|---|---|---|---|
|
| 82 |
+
| Text Encoding | 0.13s | 0.14s | 0.14s |
|
| 83 |
+
| Image Encoding | 5.03s | 5.02s | 12.84s |
|
| 84 |
+
| Denoising | 48.92s (40 steps, 1.22s/step) | 48.29s (40 steps, 1.21s/step) | 133.61s (40 steps, 3.34s/step) |
|
| 85 |
+
| VAE Decoding | 0.43s | 0.43s | 0.70s |
|
| 86 |
+
| **Total** | **54.52s** | **53.90s** | **147.32s** |
|
| 87 |
+
|
| 88 |
+
### Reduced-steps perf (AniSora V3.2)
|
| 89 |
+
|
| 90 |
+
| Steps | 480p (832x480) | 720p (1280x720) |
|
| 91 |
+
|---|---|---|
|
| 92 |
+
| 40 | 53.90s | 147.32s |
|
| 93 |
+
| 16 | 25.0s | 67.2s |
|
| 94 |
+
| 8 | 15.3s | 40.4s |
|
| 95 |
+
|
| 96 |
+
## Supported configurations
|
| 97 |
+
|
| 98 |
+
| System | Mesh | SP | TP | Topology | Test ID |
|
| 99 |
+
|---|---|---|---|---|---|
|
| 100 |
+
| BH Galaxy | 4x8 | 8 (axis 1) | 4 (axis 0) | Ring | `bh_4x8sp1tp0_ring` |
|
| 101 |
+
|
| 102 |
+
## Prerequisites
|
| 103 |
+
- Cloned [tt-metal repository](https://github.com/tenstorrent/tt-metal) for source code
|
| 104 |
+
- Installed: [TT-Metalium / TT-NN](https://github.com/tenstorrent/tt-metal/blob/main/INSTALLING.md)
|
| 105 |
+
|
| 106 |
+
## How to Run
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
# Environment setup
|
| 110 |
+
export TT_DIT_CACHE_DIR=/your/cache/path
|
| 111 |
+
export TT_METAL_HOME=$(pwd)
|
| 112 |
+
export PYTHONPATH=$(pwd)
|
| 113 |
+
export TT_DIT_ALLOW_HF_DOWNLOAD=1
|
| 114 |
+
|
| 115 |
+
# Set input image and prompt
|
| 116 |
+
export PROMPT_IMAGE=$TT_METAL_HOME/dog.jpg
|
| 117 |
+
export PROMPT="An anime girl smiling, soft lighting, cinematic"
|
| 118 |
+
export NUM_STEPS=40 # 40 default, or 16/8 for faster previews
|
| 119 |
+
export NO_PROMPT=1
|
| 120 |
+
|
| 121 |
+
# Run AniSora inference (480p)
|
| 122 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_anisora.py \
|
| 123 |
+
-v -k "bh_4x8sp1tp0_ring and resolution_480p and not random_weights" \
|
| 124 |
+
--timeout 1800 -s
|
| 125 |
+
# Output: ./wan_anisora_i2v_832x480_0.mp4
|
| 126 |
+
|
| 127 |
+
# Run AniSora inference (720p)
|
| 128 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_anisora.py \
|
| 129 |
+
-v -k "bh_4x8sp1tp0_ring and resolution_720p and not random_weights" \
|
| 130 |
+
--timeout 1800 -s
|
| 131 |
+
# Output: ./wan_anisora_i2v_1280x720_0.mp4
|
| 132 |
+
|
| 133 |
+
# Random-weights smoke test (no large download needed)
|
| 134 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_anisora.py::test_pipeline_inference_random_weights \
|
| 135 |
+
-v --timeout 1500 -s
|
| 136 |
+
```
|
code/models/tt_dit/experimental/models/Wan2_2_Distill.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wan2.2-Distill (lightx2v, 4-step I2V)
|
| 2 |
+
|
| 3 |
+
## Introduction
|
| 4 |
+
|
| 5 |
+
[lightx2v/Wan2.2-Distill-Models](https://huggingface.co/lightx2v/Wan2.2-Distill-Models)
|
| 6 |
+
is a 4-step distillation of [Wan2.2-I2V-A14B](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers).
|
| 7 |
+
It produces image-to-video outputs in roughly 1/10 the denoising steps of the
|
| 8 |
+
base model, with classifier-free guidance baked in.
|
| 9 |
+
|
| 10 |
+
This page documents the tt_dit integration. See [Wan2_2.md](Wan2_2.md) for the
|
| 11 |
+
underlying architecture — the distill is weight-only; the model graph is identical.
|
| 12 |
+
|
| 13 |
+
## Details
|
| 14 |
+
|
| 15 |
+
The distill ships only the DiT weights as flat `.safetensors` files. The
|
| 16 |
+
tokenizer, UMT5 text encoder, VAE, and scheduler are loaded from the base
|
| 17 |
+
`Wan-AI/Wan2.2-I2V-A14B-Diffusers` repo.
|
| 18 |
+
|
| 19 |
+
The two-stage MoE structure is preserved: a high-noise expert handles the first
|
| 20 |
+
half of the denoising trajectory and a low-noise expert handles the rest. With
|
| 21 |
+
4 inference steps and `boundary_ratio=0.5`, that's 2 steps per expert.
|
| 22 |
+
|
| 23 |
+
## Files used
|
| 24 |
+
|
| 25 |
+
From [lightx2v/Wan2.2-Distill-Models](https://huggingface.co/lightx2v/Wan2.2-Distill-Models):
|
| 26 |
+
|
| 27 |
+
- `wan2.2_i2v_A14b_high_noise_lightx2v_4step.safetensors` (BF16, 28.6 GB) — high-noise expert
|
| 28 |
+
- `wan2.2_i2v_A14b_low_noise_lightx2v_4step.safetensors` (BF16, 28.6 GB) — low-noise expert
|
| 29 |
+
|
| 30 |
+
From [Wan-AI/Wan2.2-I2V-A14B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers):
|
| 31 |
+
tokenizer, text encoder, VAE, scheduler config.
|
| 32 |
+
|
| 33 |
+
## Inference defaults
|
| 34 |
+
|
| 35 |
+
| Parameter | Value | Note |
|
| 36 |
+
|---|---|---|
|
| 37 |
+
| `num_inference_steps` | 4 | Distill target |
|
| 38 |
+
| `boundary_ratio` | 0.5 | 2 high-noise + 2 low-noise |
|
| 39 |
+
| `guidance_scale` | 1.0 | CFG baked in — disables negative-prompt path |
|
| 40 |
+
| `guidance_scale_2` | 1.0 | Same as above for the low-noise stage |
|
| 41 |
+
|
| 42 |
+
## Supported configurations (PR1)
|
| 43 |
+
|
| 44 |
+
| System | Mesh | SP | TP | Topology | Test ID |
|
| 45 |
+
|---|---|---|---|---|---|
|
| 46 |
+
| BH Galaxy | 4x8 | 8 (axis 1) | 4 (axis 0) | Ring | `bh_4x8sp1tp0_ring` |
|
| 47 |
+
|
| 48 |
+
Additional mesh shapes will be added in follow-up work.
|
| 49 |
+
|
| 50 |
+
## How to run
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
# Required environment
|
| 54 |
+
export TT_DIT_CACHE_DIR=/your/cache/path
|
| 55 |
+
export TT_METAL_HOME=$(pwd)
|
| 56 |
+
export PYTHONPATH=$(pwd)
|
| 57 |
+
|
| 58 |
+
# Allow first-time download of the lightx2v safetensors (~57 GB total).
|
| 59 |
+
# Skip if you've already downloaded them and put them in the HF cache or
|
| 60 |
+
# pointed LIGHTX2V_LOCAL_DIR at a directory that contains both files.
|
| 61 |
+
export TT_DIT_ALLOW_HF_DOWNLOAD=1
|
| 62 |
+
|
| 63 |
+
# Place a seed image at ./prompt_image.png in the working directory.
|
| 64 |
+
|
| 65 |
+
NO_PROMPT=1 pytest \
|
| 66 |
+
models/tt_dit/experimental/tests/test_pipeline_wan_distill_i2v.py \
|
| 67 |
+
-v -k "bh_4x8sp1tp0_ring and resolution_480p" --timeout 1500
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
To use weights from a custom location:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
export LIGHTX2V_LOCAL_DIR=/path/to/lightx2v/weights
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## Performance
|
| 77 |
+
|
| 78 |
+
BH Galaxy 4×8 Ring, 81 frames, single timed iteration (2026-05-08):
|
| 79 |
+
|
| 80 |
+
| Stage | Base 480p (40 steps, CFG) | Base 720p (40 steps, CFG) | Distill 480p (4 steps, no CFG) | Distill 720p (4 steps, no CFG) |
|
| 81 |
+
|---|---:|---:|---:|---:|
|
| 82 |
+
| Text encoding (UMT5) | 0.136 s | 0.133 s | 0.134 s | 0.138 s |
|
| 83 |
+
| Image encoding (CLIP+VAE encode) | 5.020 s | 13.024 s | 4.949 s | 12.577 s |
|
| 84 |
+
| Denoising | 48.806 s | 133.243 s | 2.436 s | 6.553 s |
|
| 85 |
+
| VAE decoding | 0.426 s | 0.711 s | 0.424 s | 0.746 s |
|
| 86 |
+
| **Total** | **54.4 s** | **147.1 s** | **7.96 s** | **20.0 s** |
|
| 87 |
+
| Speedup vs base | — | — | 6.84× | 7.34× |
|
| 88 |
+
|
| 89 |
+
Per-forward denoise cost is identical between base and distill at each resolution; distill's win is "10× fewer steps × 2× from no-CFG" with kernel performance unchanged.
|
code/models/tt_dit/experimental/models/Wan2_2_LoRA.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wan2.2 I2V — LoRA Adapter Pipeline
|
| 2 |
+
|
| 3 |
+
## Introduction
|
| 4 |
+
|
| 5 |
+
`WanPipelineI2VLora` fuses [LoRA](https://arxiv.org/abs/2106.09685) adapters
|
| 6 |
+
into the base [Wan2.2-I2V-A14B](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers)
|
| 7 |
+
weights on CPU before pushing to device. Inference uses vanilla I2V
|
| 8 |
+
machinery with no LoRA-specific runtime cost.
|
| 9 |
+
|
| 10 |
+
Each expert transformer (high-noise + low-noise) accepts an ordered LoRA
|
| 11 |
+
stack — multiple adapters can be combined on the same expert. Later entries
|
| 12 |
+
see earlier ones' deltas already folded into the base, which matches the
|
| 13 |
+
training-time expectation for adapters that were fine-tuned on top of
|
| 14 |
+
another adapter (e.g. an SVI LoRA on top of a LightX2V LoRA).
|
| 15 |
+
|
| 16 |
+
See [../../models/Wan2_2.md](../../models/Wan2_2.md) for the base model architecture and
|
| 17 |
+
[Wan2_2_SVI.md](Wan2_2_SVI.md) for long-video generation that builds on this pipeline.
|
| 18 |
+
|
| 19 |
+
## Details
|
| 20 |
+
|
| 21 |
+
- **CPU-side fusion:** `W' = W + sum_i scale_i * (B_i @ A_i)` for low-rank
|
| 22 |
+
adapter pairs, with per-module alpha scaling applied when an `.alpha` key
|
| 23 |
+
is present. The `fp32` promote → add → cast-back uses a fresh allocation
|
| 24 |
+
per pair to avoid mutating the base when a stack folds in multiple
|
| 25 |
+
adapters touching the same target.
|
| 26 |
+
- **Single aggregate verification:** after the full stack is fused, an L2
|
| 27 |
+
norm check confirms the base weights actually changed (catches silent
|
| 28 |
+
load failures from key mismatches).
|
| 29 |
+
- **Cache namespacing:** the TT cache is keyed by a SHA1 hash of the ordered
|
| 30 |
+
`(resolved_path, scale)` tuples per expert. Two stacks with the same files
|
| 31 |
+
in different order get different namespaces.
|
| 32 |
+
|
| 33 |
+
### Supported adapter key formats
|
| 34 |
+
|
| 35 |
+
`fuse_lora_state_dict` auto-detects these key shapes per adapter entry:
|
| 36 |
+
|
| 37 |
+
1. **Low-rank pairs** at one of:
|
| 38 |
+
- `<base>.lora_A.weight` / `<base>.lora_B.weight` (lightx2v native)
|
| 39 |
+
- `<base>.lora_down.weight` / `<base>.lora_up.weight` (legacy / kohya)
|
| 40 |
+
- PEFT-style `<base>.lora_A.default.weight` (the trailing `.default`
|
| 41 |
+
adapter-name segment is tolerated; SVI uses this convention)
|
| 42 |
+
2. **Bias deltas** at `<base>.diff_b` — added to the base `.bias`.
|
| 43 |
+
3. **Full-parameter deltas** at `<base>.diff` — added to the base
|
| 44 |
+
`.weight`. Some LoRAs ship RMSNorm gammas via this channel.
|
| 45 |
+
4. **Per-module alpha** at `<base>.alpha` — applied as
|
| 46 |
+
`effective_scale = scale * alpha / rank` for the low-rank pair under
|
| 47 |
+
the same `<base>`.
|
| 48 |
+
|
| 49 |
+
### Supported key namespaces (auto-stripped or remapped)
|
| 50 |
+
|
| 51 |
+
- **lightx2v native:** `blocks.<i>.attn.q.lora_A.weight` — passes through.
|
| 52 |
+
- **diffusers prefixes:** `diffusion_model.`, `transformer.`, `unet.`,
|
| 53 |
+
`model.` — stripped automatically before matching.
|
| 54 |
+
- **kohya / A1111:** `lora_unet_blocks_<i>_cross_attn_k.lora_down.weight`
|
| 55 |
+
— remapped to lightx2v-style.
|
| 56 |
+
|
| 57 |
+
Anything outside the above is logged as a skipped-unknown warning rather
|
| 58 |
+
than erroring, so legitimate-but-unsupported keys don't break the load.
|
| 59 |
+
|
| 60 |
+
## API
|
| 61 |
+
|
| 62 |
+
The adapter paths are constructor arguments, so build the config with
|
| 63 |
+
`default_config` (which pins the I2V model type and checkpoint) and construct the
|
| 64 |
+
pipeline directly.
|
| 65 |
+
|
| 66 |
+
```python
|
| 67 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_lora import LoRASpec, WanPipelineI2VLora
|
| 68 |
+
|
| 69 |
+
config = WanPipelineI2VLora.default_config(mesh_device=mesh_device, height=480, width=832)
|
| 70 |
+
|
| 71 |
+
# Single-LoRA, both experts
|
| 72 |
+
pipe = WanPipelineI2VLora(
|
| 73 |
+
device=mesh_device,
|
| 74 |
+
config=config,
|
| 75 |
+
lora_high="/path/high.safetensors",
|
| 76 |
+
lora_low="/path/low.safetensors",
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Multi-LoRA stack (LightX2V applied first, then SVI on top, half strength)
|
| 80 |
+
pipe = WanPipelineI2VLora(
|
| 81 |
+
device=mesh_device,
|
| 82 |
+
config=config,
|
| 83 |
+
lora_high=[
|
| 84 |
+
LoRASpec("/path/lightx2v_high.safetensors", scale=1.0),
|
| 85 |
+
LoRASpec("/path/svi_high.safetensors", scale=0.5),
|
| 86 |
+
],
|
| 87 |
+
lora_low=[
|
| 88 |
+
LoRASpec("/path/lightx2v_low.safetensors", scale=1.0),
|
| 89 |
+
LoRASpec("/path/svi_low.safetensors", scale=1.0),
|
| 90 |
+
],
|
| 91 |
+
)
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Either `lora_high` or `lora_low` may be `None` — that expert uses unmodified
|
| 95 |
+
base weights. At least one must be set.
|
| 96 |
+
|
| 97 |
+
## Weights
|
| 98 |
+
|
| 99 |
+
### Earth zoom-out LoRA
|
| 100 |
+
|
| 101 |
+
- **Source:** [wangkanai/wan22-fp16-i2v-loras](https://huggingface.co/wangkanai/wan22-fp16-i2v-loras)
|
| 102 |
+
- **File:** `loras/wan/wan22-camera-earthzoomout.safetensors` (293 MB)
|
| 103 |
+
- **Format:** Single-file, kohya/A1111-style keys
|
| 104 |
+
|
| 105 |
+
### Camera rotation LoRA
|
| 106 |
+
|
| 107 |
+
- **Source:** [wangkanai/wan22-fp16-i2v-loras](https://huggingface.co/wangkanai/wan22-fp16-i2v-loras)
|
| 108 |
+
- **File:** `loras/wan/wan22-camera-rotation-rank16-v2.safetensors` (293 MB)
|
| 109 |
+
- **Format:** Single-file, kohya/A1111-style keys
|
| 110 |
+
|
| 111 |
+
### Base Wan2.2 components (shared)
|
| 112 |
+
|
| 113 |
+
- **Source:** [Wan-AI/Wan2.2-I2V-A14B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers)
|
| 114 |
+
- **Components:** tokenizer, UMT5 text encoder, VAE, scheduler config, transformer base weights (~70 GB)
|
| 115 |
+
|
| 116 |
+
### Download
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
python -c "
|
| 120 |
+
from huggingface_hub import hf_hub_download
|
| 121 |
+
import os
|
| 122 |
+
os.makedirs('/path/to/lora-camera', exist_ok=True)
|
| 123 |
+
for f in ['loras/wan/wan22-camera-earthzoomout.safetensors',
|
| 124 |
+
'loras/wan/wan22-camera-rotation-rank16-v2.safetensors']:
|
| 125 |
+
hf_hub_download('wangkanai/wan22-fp16-i2v-loras', f, local_dir='/path/to/lora-camera')
|
| 126 |
+
"
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
## Supported configurations
|
| 130 |
+
|
| 131 |
+
| System | Mesh | SP | TP | Topology | Test ID |
|
| 132 |
+
|---|---|---|---|---|---|
|
| 133 |
+
| BH Loud Box | 2x4 | 4 (axis 1) | 2 (axis 0) | Linear | `bh_2x4sp1tp0` |
|
| 134 |
+
| BH Galaxy | 4x8 | 8 (axis 1) | 4 (axis 0) | Ring | `bh_4x8sp1tp0_ring` |
|
| 135 |
+
|
| 136 |
+
## How to Run
|
| 137 |
+
|
| 138 |
+
### Single-LoRA (40 steps)
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
export TT_DIT_CACHE_DIR=/your/cache/path
|
| 142 |
+
export TT_METAL_HOME=$(pwd)
|
| 143 |
+
export PYTHONPATH=$(pwd)
|
| 144 |
+
export TT_DIT_ALLOW_HF_DOWNLOAD=1
|
| 145 |
+
|
| 146 |
+
export LORA_HIGH_PATH=/path/to/lora-camera/wan22-camera-earthzoomout.safetensors
|
| 147 |
+
unset LORA_LOW_PATH # single-file: low-noise expert uses base weights
|
| 148 |
+
export LORA_SCALE=1.0
|
| 149 |
+
|
| 150 |
+
export PROMPT_IMAGE=$TT_METAL_HOME/dog.jpg
|
| 151 |
+
export PROMPT="A golden retriever running on a sandy beach, waves in the background"
|
| 152 |
+
export NUM_STEPS=40
|
| 153 |
+
export GUIDANCE_SCALE=3.5
|
| 154 |
+
export BOUNDARY_RATIO=0.875
|
| 155 |
+
export NO_PROMPT=1
|
| 156 |
+
|
| 157 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_lora.py \
|
| 158 |
+
-v -k "bh_4x8sp1tp0_ring and resolution_480p" \
|
| 159 |
+
--timeout 1800 -s
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
### Multi-LoRA stack
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
# Comma-separated path[:scale] entries. Order matters -- earlier entries are
|
| 166 |
+
# fused first.
|
| 167 |
+
export LORA_STACK_HIGH="/path/lightx2v_high.safetensors:1.0,/path/style_high.safetensors:0.5"
|
| 168 |
+
export LORA_STACK_LOW="/path/lightx2v_low.safetensors:1.0,/path/style_low.safetensors:1.0"
|
| 169 |
+
|
| 170 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_lora.py \
|
| 171 |
+
-v -k "bh_4x8sp1tp0_ring and resolution_480p" \
|
| 172 |
+
--timeout 1800 -s
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
## Environment variables
|
| 176 |
+
|
| 177 |
+
| Variable | Required | Default | Description |
|
| 178 |
+
|---|---|---|---|
|
| 179 |
+
| `LORA_HIGH_PATH` | Either this or stack | — | Path to high-noise expert LoRA `.safetensors` |
|
| 180 |
+
| `LORA_LOW_PATH` | No | None | Path to low-noise expert LoRA |
|
| 181 |
+
| `LORA_SCALE` | No | 1.0 | Blend strength applied to single-LoRA entries |
|
| 182 |
+
| `LORA_STACK_HIGH` | Either this or single | — | Comma-separated `path[:scale]` list for the high-noise expert |
|
| 183 |
+
| `LORA_STACK_LOW` | No | None | Comma-separated `path[:scale]` list for the low-noise expert |
|
| 184 |
+
| `NUM_STEPS` | No | 40 | Inference steps |
|
| 185 |
+
| `GUIDANCE_SCALE` | No | 3.5 | CFG scale |
|
| 186 |
+
| `BOUNDARY_RATIO` | No | 0.875 | High/low expert split ratio |
|
| 187 |
+
| `PROMPT_IMAGE` | No | `./prompt_image.png` | Seed image path |
|
| 188 |
+
| `PROMPT` | No | golden retriever prompt | Text prompt |
|
| 189 |
+
|
| 190 |
+
## Limitations / open items
|
| 191 |
+
|
| 192 |
+
- LoRA fusion happens on CPU before device push — adds ~5–10 s per expert to
|
| 193 |
+
pipeline initialization (one-time cost, scales with stack depth).
|
| 194 |
+
- Only `bh_4x8sp1tp0_ring` is exercised in CI.
|
| 195 |
+
- Any community LoRA trained for Wan2.2 I2V should work if its keys follow
|
| 196 |
+
lightx2v native, diffusers, or kohya/A1111 naming. Untested LoRAs may need
|
| 197 |
+
key remap additions.
|
code/models/tt_dit/experimental/models/Wan2_2_SVI.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wan2.2 I2V — Stable-Video-Infinity (SVI 2.0 Pro)
|
| 2 |
+
|
| 3 |
+
## Introduction
|
| 4 |
+
|
| 5 |
+
`WanPipelineSVI` generates long videos by autoregressively chaining short
|
| 6 |
+
Wan2.2 I2V clips. Per upstream's
|
| 7 |
+
[SVI 2.0 Pro doc](https://github.com/vita-epfl/Stable-Video-Infinity/blob/svi_wan22/docs/svi/svi_2.0_pro.md),
|
| 8 |
+
the conditioning latent for each clip is built as
|
| 9 |
+
|
| 10 |
+
```
|
| 11 |
+
y = concat([anchor_latent, motion_latent, padding], dim=temporal)
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
- **`anchor_latent`** — the VAE-encoded user-provided first frame. The same
|
| 15 |
+
anchor is used for every clip so the long video keeps a fixed visual
|
| 16 |
+
reference.
|
| 17 |
+
- **`motion_latent`** — the last `num_motion_latent` latent time steps from
|
| 18 |
+
the previous clip's denoised output (`None` on the first clip). This is a
|
| 19 |
+
continuity-strength knob, not a value derived from `num_frames`.
|
| 20 |
+
- **`padding`** — literal-zero latents for the remaining frame slots.
|
| 21 |
+
|
| 22 |
+
The original SVI 2.0 last-frame handoff is explicitly **not** used in 2.0
|
| 23 |
+
Pro — the decoded last frame of the previous clip is never re-encoded.
|
| 24 |
+
Setting `num_motion_latent=0` collapses 2.0 Pro back to 2.0 behavior (anchor
|
| 25 |
+
only, no latent handoff).
|
| 26 |
+
|
| 27 |
+
SVI ships as a LoRA adapter pair (one per Wan2.2 MoE expert) trained with
|
| 28 |
+
"Error-Recycling Fine-Tuning" so the model is robust to its own drift when
|
| 29 |
+
chained. See [Wan2_2_LoRA.md](Wan2_2_LoRA.md) for the underlying LoRA
|
| 30 |
+
fusion pipeline this builds on.
|
| 31 |
+
|
| 32 |
+
Upstream: [vita-epfl/Stable-Video-Infinity](https://github.com/vita-epfl/Stable-Video-Infinity)
|
| 33 |
+
(svi_wan22 branch).
|
| 34 |
+
|
| 35 |
+
## Sampling regimes
|
| 36 |
+
|
| 37 |
+
| Regime | Steps | CFG | Scheduler | flow_shift | LoRA stack (high / low) |
|
| 38 |
+
|---|---|---|---|---|---|
|
| 39 |
+
| `python` | 50 | 5.0 | `FlowMatchEulerDiscreteScheduler` → `EulerSolver` | 5.0 | SVI 1.0 / SVI 1.0 |
|
| 40 |
+
| `comfyui` | 6 | 1.5 | `UniPCMultistepScheduler(use_flow_sigmas=True)` → `UniPCSolver` *(see note)* | 8.0 | LightX2V 0.5 + SVI 1.0 / LightX2V 1.0 + SVI 1.0 |
|
| 41 |
+
|
| 42 |
+
The `python` regime matches upstream's `inference_svi_2.0_pro.py` —
|
| 43 |
+
diffsynth's `FlowMatchScheduler("Wan")` is a plain Euler step on a
|
| 44 |
+
flow-matching schedule, so `FlowMatchEulerDiscreteScheduler` + `EulerSolver`
|
| 45 |
+
is semantically equivalent.
|
| 46 |
+
|
| 47 |
+
**Scheduler caveat for `comfyui` regime.** The upstream ComfyUI workflow
|
| 48 |
+
(`comfyui_workflow/SVI-Wan22-1210-{4,10}-Clips.json`) uses k-diffusion's
|
| 49 |
+
`dpm++_sde` sampler. tt-metal has no on-device port of that stochastic
|
| 50 |
+
2nd-order singlestep solver, so we substitute UniPC at the same
|
| 51 |
+
`flow_shift=8`. Both are order-2 flow-aware solvers; output is visually
|
| 52 |
+
close but not bit-exact to ComfyUI. The 4-clip variant additionally uses
|
| 53 |
+
`cfg=2.0` for clips 1-2 and `cfg=1.5` for clips 3-4 — pass
|
| 54 |
+
`GUIDANCE_SCALE="2.0;2.0;1.5;1.5"` (and matching `SVI_NUM_CLIPS=4`) to
|
| 55 |
+
match that pattern; the 10-clip variant uses uniform `cfg=1.5` which is the
|
| 56 |
+
default.
|
| 57 |
+
|
| 58 |
+
## Weights
|
| 59 |
+
|
| 60 |
+
### SVI 2.0 Pro LoRA
|
| 61 |
+
|
| 62 |
+
- **Source:** [vita-video-gen/svi-model](https://huggingface.co/vita-video-gen/svi-model)
|
| 63 |
+
- **Files (one per MoE expert):**
|
| 64 |
+
- `SVI_Wan2.2-I2V-A14B_high_noise_lora_v2.0_pro.safetensors`
|
| 65 |
+
- `SVI_Wan2.2-I2V-A14B_low_noise_lora_v2.0_pro.safetensors`
|
| 66 |
+
- **Format:** PEFT-style — keys are `<base>.lora_A.default.weight` /
|
| 67 |
+
`<base>.lora_B.default.weight` (the trailing `.default` adapter-name
|
| 68 |
+
segment is tolerated by the loader; see
|
| 69 |
+
[Wan2_2_LoRA.md](Wan2_2_LoRA.md#supported-adapter-key-formats)).
|
| 70 |
+
|
| 71 |
+
### LightX2V LoRA (required for `comfyui` regime only)
|
| 72 |
+
|
| 73 |
+
- **Source:** [lightx2v/Wan2.2-Distill-Loras](https://huggingface.co/lightx2v/Wan2.2-Distill-Loras)
|
| 74 |
+
- **Files:**
|
| 75 |
+
- `wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors`
|
| 76 |
+
- `wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors`
|
| 77 |
+
|
| 78 |
+
### Download
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
python -c "
|
| 82 |
+
from huggingface_hub import hf_hub_download
|
| 83 |
+
for f in [
|
| 84 |
+
'SVI_Wan2.2-I2V-A14B_high_noise_lora_v2.0_pro.safetensors',
|
| 85 |
+
'SVI_Wan2.2-I2V-A14B_low_noise_lora_v2.0_pro.safetensors',
|
| 86 |
+
]:
|
| 87 |
+
hf_hub_download('vita-video-gen/svi-model', f, local_dir='/path/to/svi')
|
| 88 |
+
for f in [
|
| 89 |
+
'wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors',
|
| 90 |
+
'wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors',
|
| 91 |
+
]:
|
| 92 |
+
hf_hub_download('lightx2v/Wan2.2-Distill-Loras', f, local_dir='/path/to/lightx2v')
|
| 93 |
+
"
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
### Base Wan2.2 components (shared)
|
| 97 |
+
|
| 98 |
+
- **Source:** [Wan-AI/Wan2.2-I2V-A14B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B-Diffusers)
|
| 99 |
+
|
| 100 |
+
## Supported configurations
|
| 101 |
+
|
| 102 |
+
| System | Mesh | Topology | Test ID |
|
| 103 |
+
|---|---|---|---|
|
| 104 |
+
| BH Loud Box | 2x4 | Linear | `bh_2x4sp1tp0` |
|
| 105 |
+
| BH Galaxy | 4x8 | Ring | `bh_4x8sp1tp0_ring` |
|
| 106 |
+
|
| 107 |
+
## How to Run
|
| 108 |
+
|
| 109 |
+
### ComfyUI regime (6 steps, LightX2V-accelerated — recommended)
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
export TT_DIT_CACHE_DIR=/your/cache/path
|
| 113 |
+
export TT_METAL_HOME=$(pwd)
|
| 114 |
+
export PYTHONPATH=$(pwd)
|
| 115 |
+
export TT_DIT_ALLOW_HF_DOWNLOAD=1
|
| 116 |
+
|
| 117 |
+
export SVI_HIGH_PATH=/path/to/svi/SVI_Wan2.2-I2V-A14B_high_noise_lora_v2.0_pro.safetensors
|
| 118 |
+
export SVI_LOW_PATH=/path/to/svi/SVI_Wan2.2-I2V-A14B_low_noise_lora_v2.0_pro.safetensors
|
| 119 |
+
export LIGHTX2V_HIGH_PATH=/path/to/lightx2v/wan2.2_i2v_A14b_high_noise_lora_rank64_lightx2v_4step_1022.safetensors
|
| 120 |
+
export LIGHTX2V_LOW_PATH=/path/to/lightx2v/wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step_1022.safetensors
|
| 121 |
+
|
| 122 |
+
export PROMPT_IMAGE=$TT_METAL_HOME/example.png
|
| 123 |
+
export PROMPT="A subject moving naturally through the scene"
|
| 124 |
+
export SVI_NUM_CLIPS=10
|
| 125 |
+
|
| 126 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_wan_svi.py \
|
| 127 |
+
-v -k "bh_2x4sp1tp0 and resolution_480p and comfyui" \
|
| 128 |
+
--timeout 3600 -s
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### Python regime (50 steps, no LightX2V needed)
|
| 132 |
+
|
| 133 |
+
Same `SVI_HIGH_PATH` / `SVI_LOW_PATH` exports as above; omit the LightX2V
|
| 134 |
+
ones. Replace the `-k` selector with `... and python`. Expect roughly 6×
|
| 135 |
+
the wall time per clip.
|
| 136 |
+
|
| 137 |
+
## Configuration
|
| 138 |
+
|
| 139 |
+
### Required paths
|
| 140 |
+
|
| 141 |
+
- `SVI_HIGH_PATH` / `SVI_LOW_PATH`: SVI 2.0 Pro LoRA files for the high-noise
|
| 142 |
+
and low-noise Wan2.2 experts.
|
| 143 |
+
- `LIGHTX2V_HIGH_PATH` / `LIGHTX2V_LOW_PATH`: LightX2V LoRA files for the
|
| 144 |
+
high-noise and low-noise experts. Required only for `regime='comfyui'`.
|
| 145 |
+
|
| 146 |
+
### Normal inputs
|
| 147 |
+
|
| 148 |
+
- `PROMPT_IMAGE` (default `./prompt_image.png`): the anchor image. This is the
|
| 149 |
+
first-frame reference used for every clip.
|
| 150 |
+
- `PROMPT` (default golden retriever prompt): one prompt string for all clips,
|
| 151 |
+
or a semicolon-separated list with exactly `SVI_NUM_CLIPS` entries for
|
| 152 |
+
per-clip prompts. Example: `PROMPT="wide shot;close-up;camera pans left"` with
|
| 153 |
+
`SVI_NUM_CLIPS=3`.
|
| 154 |
+
- `SVI_NUM_CLIPS` (default `2`): number of 81-frame clips to generate and
|
| 155 |
+
chain.
|
| 156 |
+
|
| 157 |
+
### Expert knobs
|
| 158 |
+
|
| 159 |
+
These are useful for matching upstream workflows, ablations, or debugging. The
|
| 160 |
+
defaults are the intended starting point.
|
| 161 |
+
|
| 162 |
+
- `NUM_STEPS` (default `50` for `python`, `6` for `comfyui`): denoising steps
|
| 163 |
+
per clip. Higher values are slower and usually improve quality. The `comfyui`
|
| 164 |
+
default assumes the LightX2V acceleration LoRAs are present.
|
| 165 |
+
- `GUIDANCE_SCALE` (default `5.0` for `python`, `1.5` for `comfyui`): CFG for
|
| 166 |
+
the high-noise expert. Accepts either one value for all clips or a
|
| 167 |
+
semicolon-separated list of length `SVI_NUM_CLIPS`.
|
| 168 |
+
- `GUIDANCE_SCALE_2` (default: matches `GUIDANCE_SCALE`): CFG for the low-noise
|
| 169 |
+
expert. Uses the same scalar or semicolon per-clip format. For the upstream
|
| 170 |
+
ComfyUI 4-clip workflow, use
|
| 171 |
+
`GUIDANCE_SCALE="2.0;2.0;1.5;1.5"` and the same value for
|
| 172 |
+
`GUIDANCE_SCALE_2`.
|
| 173 |
+
- `SVI_SEED` (default `0`): base random seed. Clip `i` uses
|
| 174 |
+
`SVI_SEED + i * 42`, so each clip gets deterministic but distinct noise.
|
| 175 |
+
- `SVI_NUM_MOTION_LATENT` (default `1`): number of latent time steps copied
|
| 176 |
+
from the previous clip into the next clip's conditioning. Increasing this can
|
| 177 |
+
strengthen cross-clip continuity but may make the next clip follow the
|
| 178 |
+
previous motion more tightly. Setting it to `0` disables the SVI Pro latent
|
| 179 |
+
handoff and leaves only the anchor image.
|
| 180 |
+
- `SVI_NUM_OVERLAP_FRAME` (default `4`): number of decoded frames dropped from
|
| 181 |
+
the start of every clip after the first when concatenating the final video.
|
| 182 |
+
This removes duplicate or unstable boundary frames; it does not affect latent
|
| 183 |
+
conditioning.
|
| 184 |
+
|
| 185 |
+
## Limitations / open items
|
| 186 |
+
|
| 187 |
+
- **`comfyui` regime is not bit-exact to upstream** — see the scheduler
|
| 188 |
+
caveat above. A native `DPMSolverSDESolver` is the follow-up that would
|
| 189 |
+
close the gap.
|
| 190 |
+
- **Anchor encoding differs slightly from upstream.** Our implementation
|
| 191 |
+
reuses `WanPipelineI2V.prepare_latents`, which encodes an anchor-at-frame-0 /
|
| 192 |
+
zero-elsewhere video and then overwrites the non-anchor latents with the
|
| 193 |
+
motion-latent splice and zero-padding. Upstream's `wan_video_svi_pro`
|
| 194 |
+
encodes only the single anchor frame and concatenates. Both produce the
|
| 195 |
+
same anchor latent at frame 0 and zeros / motion at the rest, but the
|
| 196 |
+
encode path is not byte-identical.
|
| 197 |
+
- **CPU-side LoRA fusion** (~5–10 s per expert at cold cache, scales with
|
| 198 |
+
stack depth). Switching between LoRA stacks across runs rebuilds the TT
|
| 199 |
+
cache; runtime LoRA scale tuning or mid-clip switching is not supported.
|
| 200 |
+
- **Per-clip latency** (BH-LB 2x4, 480p, with anchor VAE-encode cache hit
|
| 201 |
+
after clip 1): `comfyui` regime ~63 s/clip; `python` regime not yet
|
| 202 |
+
measured end-to-end on this branch.
|
| 203 |
+
- `bh_2x4sp1tp0` (BH-LB) is the primary tested config; `bh_4x8sp1tp0_ring`
|
| 204 |
+
(Galaxy) is parameterized but not yet exercised.
|
code/models/tt_dit/experimental/pipelines/__init__.py
ADDED
|
File without changes
|
code/models/tt_dit/experimental/pipelines/pipeline_anisora.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent USA, Inc.
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""IndexTeam/Index-anisora V3.2 I2V pipeline.
|
| 6 |
+
|
| 7 |
+
A thin subclass of :class:`WanPipelineI2V` that:
|
| 8 |
+
|
| 9 |
+
1. Uses ``Wan-AI/Wan2.2-I2V-A14B-Diffusers`` for tokenizer, text encoder, VAE,
|
| 10 |
+
and scheduler — the AniSora HF repo only ships the DiT experts.
|
| 11 |
+
2. Replaces both expert transformer state dicts with AniSora's ``V3.2``
|
| 12 |
+
``diffusion_pytorch_model.safetensors`` files (high-noise + low-noise pair).
|
| 13 |
+
3. Defaults to 40 sampling steps with ``boundary_ratio=0.9`` and
|
| 14 |
+
``guidance_scale=3.5`` for both stages, matching the upstream config in
|
| 15 |
+
``anisoraV3.2/wan/configs/wan_i2v_A14B.py``.
|
| 16 |
+
|
| 17 |
+
AniSora's safetensors use the same original-Wan key naming as the lightx2v
|
| 18 |
+
distill checkpoints (verified: 1095 keys, identical layout). The
|
| 19 |
+
``wan_lightx2v_to_diffusers_key`` rename function in
|
| 20 |
+
:mod:`models.tt_dit.utils.lightx2v_loader` is reused as-is.
|
| 21 |
+
|
| 22 |
+
A ``random_weights`` mode is provided for smoke-testing without HuggingFace
|
| 23 |
+
downloads. It (a) skips the two ~28 GB transformer subfolder downloads by
|
| 24 |
+
constructing config-only random ``TorchWanTransformer3DModel`` instances and
|
| 25 |
+
(b) feeds those random ``state_dict()``s into the TT model in place of the
|
| 26 |
+
AniSora safetensors. Tokenizer, text encoder, VAE, and scheduler still come
|
| 27 |
+
from the base diffusers repo (~12 GB total).
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import contextlib
|
| 32 |
+
import os
|
| 33 |
+
from typing import TYPE_CHECKING
|
| 34 |
+
|
| 35 |
+
import ttnn
|
| 36 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_distill import _patch_torch_transformer_random
|
| 37 |
+
from models.tt_dit.experimental.utils.lightx2v_loader import load_lightx2v_state_dict
|
| 38 |
+
from models.tt_dit.pipelines.wan.pipeline_wan import WanPipelineConfig
|
| 39 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import WanPipelineI2V
|
| 40 |
+
from models.tt_dit.utils import cache
|
| 41 |
+
|
| 42 |
+
if TYPE_CHECKING:
|
| 43 |
+
from diffusers.schedulers import SchedulerMixin
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class AniSoraPipeline(WanPipelineI2V):
|
| 47 |
+
HF_REPO = "IndexTeam/Index-anisora"
|
| 48 |
+
HIGH_NOISE_FILE = "V3.2/high_noise_model/diffusion_pytorch_model.safetensors"
|
| 49 |
+
LOW_NOISE_FILE = "V3.2/low_noise_model/diffusion_pytorch_model.safetensors"
|
| 50 |
+
CACHE_NAMESPACE = "Index-anisora-V3.2"
|
| 51 |
+
RANDOM_CACHE_NAMESPACE = "Index-anisora-random"
|
| 52 |
+
ANISORA_BOUNDARY_RATIO = 0.9
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def _config_overrides(cls) -> dict[str, object]:
|
| 56 |
+
# Matches upstream anisoraV3.2/wan/configs/wan_i2v_A14B.py.
|
| 57 |
+
return {**super()._config_overrides(), "boundary_ratio": cls.ANISORA_BOUNDARY_RATIO}
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
*,
|
| 62 |
+
device: ttnn.MeshDevice,
|
| 63 |
+
config: WanPipelineConfig,
|
| 64 |
+
anisora_local_dir: str | None = None,
|
| 65 |
+
allow_download: bool | None = None,
|
| 66 |
+
random_weights: bool | None = None,
|
| 67 |
+
scheduler: SchedulerMixin | None = None,
|
| 68 |
+
run_warmup: bool = True,
|
| 69 |
+
lora_enabled: bool = False,
|
| 70 |
+
) -> None:
|
| 71 |
+
if allow_download is None:
|
| 72 |
+
allow_download = os.environ.get("TT_DIT_ALLOW_HF_DOWNLOAD") == "1"
|
| 73 |
+
if anisora_local_dir is None:
|
| 74 |
+
anisora_local_dir = os.environ.get("ANISORA_LOCAL_DIR")
|
| 75 |
+
if random_weights is None:
|
| 76 |
+
random_weights = os.environ.get("TT_DIT_RANDOM_WEIGHTS") == "1"
|
| 77 |
+
|
| 78 |
+
self._anisora_local_dir = anisora_local_dir
|
| 79 |
+
self._allow_download = allow_download
|
| 80 |
+
self._random_weights = random_weights
|
| 81 |
+
|
| 82 |
+
ctx = _patch_torch_transformer_random() if random_weights else contextlib.nullcontext()
|
| 83 |
+
with ctx:
|
| 84 |
+
super().__init__(
|
| 85 |
+
device=device, config=config, scheduler=scheduler, run_warmup=run_warmup, lora_enabled=lora_enabled
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def _prepare_transformer(self, idx: int):
|
| 89 |
+
state = self.transformer_states[idx]
|
| 90 |
+
if self._random_weights:
|
| 91 |
+
cache.load_model(
|
| 92 |
+
state.model,
|
| 93 |
+
model_name=self.RANDOM_CACHE_NAMESPACE,
|
| 94 |
+
subfolder=state.checkpoint.subfolder,
|
| 95 |
+
parallel_config=self.parallel_config,
|
| 96 |
+
mesh_shape=tuple(self.mesh_device.shape),
|
| 97 |
+
mesh_device=self.mesh_device,
|
| 98 |
+
is_fsdp=self.is_fsdp,
|
| 99 |
+
get_torch_state_dict=lambda s=state: s.checkpoint.state_dict(),
|
| 100 |
+
)
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
filename = self.HIGH_NOISE_FILE if idx == 0 else self.LOW_NOISE_FILE
|
| 104 |
+
cache.load_model(
|
| 105 |
+
state.model,
|
| 106 |
+
model_name=self.CACHE_NAMESPACE,
|
| 107 |
+
subfolder=state.checkpoint.subfolder,
|
| 108 |
+
parallel_config=self.parallel_config,
|
| 109 |
+
mesh_shape=tuple(self.mesh_device.shape),
|
| 110 |
+
mesh_device=self.mesh_device,
|
| 111 |
+
is_fsdp=self.is_fsdp,
|
| 112 |
+
get_torch_state_dict=lambda f=filename: load_lightx2v_state_dict(
|
| 113 |
+
self.HF_REPO,
|
| 114 |
+
f,
|
| 115 |
+
allow_download=self._allow_download,
|
| 116 |
+
local_dir=self._anisora_local_dir,
|
| 117 |
+
),
|
| 118 |
+
)
|
code/models/tt_dit/experimental/pipelines/pipeline_wan_distill.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent USA, Inc.
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Wan2.2 lightx2v distilled I2V pipeline.
|
| 6 |
+
|
| 7 |
+
A thin subclass of :class:`WanPipelineI2V` that:
|
| 8 |
+
|
| 9 |
+
1. Uses ``Wan-AI/Wan2.2-I2V-A14B-Diffusers`` for tokenizer, text encoder, VAE,
|
| 10 |
+
and scheduler — the lightx2v repo ships only the DiT.
|
| 11 |
+
2. Replaces both expert transformer state dicts with lightx2v's flat
|
| 12 |
+
``.safetensors`` files (4-step distill, high-noise + low-noise pair).
|
| 13 |
+
3. Defaults ``boundary_ratio=0.5`` (2 high-noise + 2 low-noise steps).
|
| 14 |
+
|
| 15 |
+
Test/caller passes ``num_inference_steps=4`` and ``guidance_scale=1.0`` for both
|
| 16 |
+
stages since CFG is baked into the distill.
|
| 17 |
+
|
| 18 |
+
A ``random_weights`` mode is provided for smoke-testing without HuggingFace
|
| 19 |
+
downloads. It (a) skips the two ~28 GB transformer subfolder downloads by
|
| 20 |
+
constructing config-only random ``TorchWanTransformer3DModel`` instances and
|
| 21 |
+
(b) feeds those random ``state_dict()``s into the TT model in place of the
|
| 22 |
+
lightx2v safetensors. Tokenizer, text encoder, VAE, and scheduler still come
|
| 23 |
+
from the base diffusers repo (~12 GB total).
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import contextlib
|
| 28 |
+
import os
|
| 29 |
+
from typing import TYPE_CHECKING
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
|
| 33 |
+
import ttnn
|
| 34 |
+
from models.tt_dit.experimental.utils.lightx2v_loader import load_lightx2v_state_dict
|
| 35 |
+
from models.tt_dit.models.transformers.wan2_2.transformer_wan import TorchWanTransformer3DModel
|
| 36 |
+
from models.tt_dit.pipelines.wan.pipeline_wan import WanPipelineConfig
|
| 37 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import WanPipelineI2V
|
| 38 |
+
from models.tt_dit.utils import cache
|
| 39 |
+
|
| 40 |
+
if TYPE_CHECKING:
|
| 41 |
+
from diffusers.schedulers import SchedulerMixin
|
| 42 |
+
|
| 43 |
+
# Hard-coded config for Wan2.2-I2V-A14B-Diffusers transformer subfolders. Used
|
| 44 |
+
# only in random_weights mode so we don't have to fetch transformer/config.json
|
| 45 |
+
# from HF. Both transformer and transformer_2 share this architecture; only
|
| 46 |
+
# weights differ between high-noise and low-noise experts.
|
| 47 |
+
_RANDOM_I2V_TRANSFORMER_CONFIG = dict(
|
| 48 |
+
patch_size=(1, 2, 2),
|
| 49 |
+
num_attention_heads=40,
|
| 50 |
+
attention_head_dim=128,
|
| 51 |
+
in_channels=36, # I2V: 4 mask + 16 image latent + 16 noise latent
|
| 52 |
+
out_channels=16,
|
| 53 |
+
text_dim=4096,
|
| 54 |
+
freq_dim=256,
|
| 55 |
+
ffn_dim=13824,
|
| 56 |
+
num_layers=40,
|
| 57 |
+
cross_attn_norm=True,
|
| 58 |
+
qk_norm="rms_norm_across_heads",
|
| 59 |
+
eps=1e-6,
|
| 60 |
+
rope_max_seq_len=1024,
|
| 61 |
+
image_dim=None,
|
| 62 |
+
added_kv_proj_dim=None,
|
| 63 |
+
pos_embed_seq_len=None,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@contextlib.contextmanager
|
| 68 |
+
def _patch_torch_transformer_random(seed: int = 0):
|
| 69 |
+
"""Replace ``TorchWanTransformer3DModel.from_pretrained`` with a stub that
|
| 70 |
+
instantiates from a hard-coded I2V config and never touches the network or
|
| 71 |
+
disk. Restored on exit."""
|
| 72 |
+
cls = TorchWanTransformer3DModel
|
| 73 |
+
sentinel = object()
|
| 74 |
+
saved = cls.__dict__.get("from_pretrained", sentinel)
|
| 75 |
+
|
| 76 |
+
def _stub(_cls, *args, **kwargs):
|
| 77 |
+
with torch.random.fork_rng():
|
| 78 |
+
torch.manual_seed(seed)
|
| 79 |
+
return _cls(**_RANDOM_I2V_TRANSFORMER_CONFIG)
|
| 80 |
+
|
| 81 |
+
cls.from_pretrained = classmethod(_stub)
|
| 82 |
+
try:
|
| 83 |
+
yield
|
| 84 |
+
finally:
|
| 85 |
+
if saved is sentinel:
|
| 86 |
+
# `from_pretrained` was inherited; delete our override to restore inheritance.
|
| 87 |
+
try:
|
| 88 |
+
delattr(cls, "from_pretrained")
|
| 89 |
+
except AttributeError:
|
| 90 |
+
pass
|
| 91 |
+
else:
|
| 92 |
+
cls.from_pretrained = saved
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class WanDistillPipelineI2V(WanPipelineI2V):
|
| 96 |
+
LIGHTX2V_REPO = "lightx2v/Wan2.2-Distill-Models"
|
| 97 |
+
HIGH_NOISE_FILE = "wan2.2_i2v_A14b_high_noise_lightx2v_4step.safetensors"
|
| 98 |
+
LOW_NOISE_FILE = "wan2.2_i2v_A14b_low_noise_lightx2v_4step.safetensors"
|
| 99 |
+
CACHE_NAMESPACE = "Wan2.2-Distill-lightx2v-4step"
|
| 100 |
+
RANDOM_CACHE_NAMESPACE = "Wan2.2-Distill-random"
|
| 101 |
+
DISTILL_BOUNDARY_RATIO = 0.5
|
| 102 |
+
|
| 103 |
+
@classmethod
|
| 104 |
+
def _config_overrides(cls) -> dict[str, object]:
|
| 105 |
+
# The 4-step distill splits 2 high-noise + 2 low-noise steps.
|
| 106 |
+
return {**super()._config_overrides(), "boundary_ratio": cls.DISTILL_BOUNDARY_RATIO}
|
| 107 |
+
|
| 108 |
+
def __init__(
|
| 109 |
+
self,
|
| 110 |
+
*,
|
| 111 |
+
device: ttnn.MeshDevice,
|
| 112 |
+
config: WanPipelineConfig,
|
| 113 |
+
lightx2v_local_dir: str | None = None,
|
| 114 |
+
allow_download: bool | None = None,
|
| 115 |
+
random_weights: bool | None = None,
|
| 116 |
+
scheduler: SchedulerMixin | None = None,
|
| 117 |
+
run_warmup: bool = True,
|
| 118 |
+
lora_enabled: bool = False,
|
| 119 |
+
) -> None:
|
| 120 |
+
if allow_download is None:
|
| 121 |
+
allow_download = os.environ.get("TT_DIT_ALLOW_HF_DOWNLOAD") == "1"
|
| 122 |
+
if lightx2v_local_dir is None:
|
| 123 |
+
lightx2v_local_dir = os.environ.get("LIGHTX2V_LOCAL_DIR")
|
| 124 |
+
if random_weights is None:
|
| 125 |
+
random_weights = os.environ.get("TT_DIT_RANDOM_WEIGHTS") == "1"
|
| 126 |
+
|
| 127 |
+
self._lightx2v_local_dir = lightx2v_local_dir
|
| 128 |
+
self._allow_download = allow_download
|
| 129 |
+
self._random_weights = random_weights
|
| 130 |
+
|
| 131 |
+
ctx = _patch_torch_transformer_random() if random_weights else contextlib.nullcontext()
|
| 132 |
+
with ctx:
|
| 133 |
+
super().__init__(
|
| 134 |
+
device=device, config=config, scheduler=scheduler, run_warmup=run_warmup, lora_enabled=lora_enabled
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
def prepare_text_conditioning(self, tt_model, prompt_embeds, buffer, traced=False):
|
| 138 |
+
# When CFG is baked in (guidance_scale=1.0), encode_prompt returns
|
| 139 |
+
# negative_prompt_embeds=None. The base loop still calls this for the
|
| 140 |
+
# negative buffer; forwarding None into the text embedder hits a
|
| 141 |
+
# NoneType.padded_shape in Linear. combined_step already short-circuits
|
| 142 |
+
# on do_classifier_free_guidance=False, so leaving the buffer as-is is
|
| 143 |
+
# safe.
|
| 144 |
+
if prompt_embeds is None:
|
| 145 |
+
return buffer
|
| 146 |
+
return super().prepare_text_conditioning(tt_model, prompt_embeds, buffer, traced)
|
| 147 |
+
|
| 148 |
+
def _prepare_transformer(self, idx: int):
|
| 149 |
+
state = self.transformer_states[idx]
|
| 150 |
+
if self._random_weights:
|
| 151 |
+
# Use the (random) state_dict the WanCheckpoint loaded under the
|
| 152 |
+
# _patch_torch_transformer_random monkey-patch. Cache under a
|
| 153 |
+
# separate namespace so a real-weights run doesn't reuse it.
|
| 154 |
+
cache.load_model(
|
| 155 |
+
state.model,
|
| 156 |
+
model_name=self.RANDOM_CACHE_NAMESPACE,
|
| 157 |
+
subfolder=state.checkpoint.subfolder,
|
| 158 |
+
parallel_config=self.parallel_config,
|
| 159 |
+
mesh_shape=tuple(self.mesh_device.shape),
|
| 160 |
+
mesh_device=self.mesh_device,
|
| 161 |
+
is_fsdp=self.is_fsdp,
|
| 162 |
+
get_torch_state_dict=lambda s=state: s.checkpoint.state_dict(),
|
| 163 |
+
)
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
filename = self.HIGH_NOISE_FILE if idx == 0 else self.LOW_NOISE_FILE
|
| 167 |
+
cache.load_model(
|
| 168 |
+
state.model,
|
| 169 |
+
model_name=self.CACHE_NAMESPACE,
|
| 170 |
+
subfolder=state.checkpoint.subfolder,
|
| 171 |
+
parallel_config=self.parallel_config,
|
| 172 |
+
mesh_shape=tuple(self.mesh_device.shape),
|
| 173 |
+
mesh_device=self.mesh_device,
|
| 174 |
+
is_fsdp=self.is_fsdp,
|
| 175 |
+
get_torch_state_dict=lambda f=filename: load_lightx2v_state_dict(
|
| 176 |
+
self.LIGHTX2V_REPO,
|
| 177 |
+
f,
|
| 178 |
+
allow_download=self._allow_download,
|
| 179 |
+
local_dir=self._lightx2v_local_dir,
|
| 180 |
+
),
|
| 181 |
+
)
|
code/models/tt_dit/experimental/pipelines/pipeline_wan_lora.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Wan2.2 I2V pipeline with LoRA adapters fused into the base weights.
|
| 6 |
+
|
| 7 |
+
Each expert (high/low noise) takes an ordered LoRA stack; stacks are fused
|
| 8 |
+
on CPU before TT conversion so inference has no LoRA-specific runtime cost.
|
| 9 |
+
See ``experimental/models/Wan2_2_LoRA.md`` for the adapter-key formats
|
| 10 |
+
detected by ``fuse_lora_state_dict`` and the supported namespaces.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import re
|
| 17 |
+
from collections.abc import Sequence
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import TYPE_CHECKING
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from loguru import logger
|
| 24 |
+
from safetensors.torch import load_file
|
| 25 |
+
|
| 26 |
+
import ttnn
|
| 27 |
+
from models.tt_dit.experimental.utils.lightx2v_loader import wan_lightx2v_to_diffusers_key
|
| 28 |
+
from models.tt_dit.pipelines.wan.pipeline_wan import WanPipelineConfig
|
| 29 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import WanPipelineI2V
|
| 30 |
+
from models.tt_dit.solvers.factory import CustomSigmaScheduler
|
| 31 |
+
from models.tt_dit.utils import cache
|
| 32 |
+
|
| 33 |
+
if TYPE_CHECKING:
|
| 34 |
+
from diffusers.schedulers import SchedulerMixin
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class LoRASpec:
|
| 39 |
+
"""A single LoRA adapter file and its blend strength."""
|
| 40 |
+
|
| 41 |
+
path: str
|
| 42 |
+
scale: float = 1.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
LoRAArg = LoRASpec | str | Sequence[LoRASpec | str] | None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class _FusionStats:
|
| 50 |
+
applied_pairs: int = 0
|
| 51 |
+
applied_direct: int = 0
|
| 52 |
+
skipped_unknown: int = 0
|
| 53 |
+
skipped_unmapped: int = 0
|
| 54 |
+
skipped_shape_mismatch: int = 0
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def applied(self) -> int:
|
| 58 |
+
return self.applied_pairs + self.applied_direct
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _normalize_lora_arg(arg: LoRAArg) -> list[LoRASpec]:
|
| 62 |
+
"""Coerce None / path / LoRASpec / sequence-of-either into LoRASpecs."""
|
| 63 |
+
if arg is None:
|
| 64 |
+
return []
|
| 65 |
+
if isinstance(arg, LoRASpec):
|
| 66 |
+
return [arg]
|
| 67 |
+
if isinstance(arg, str):
|
| 68 |
+
return [LoRASpec(arg)]
|
| 69 |
+
if not isinstance(arg, Sequence):
|
| 70 |
+
raise TypeError(f"Expected LoRASpec, str, sequence, or None; got {type(arg).__name__}")
|
| 71 |
+
out: list[LoRASpec] = []
|
| 72 |
+
for item in arg:
|
| 73 |
+
if isinstance(item, LoRASpec):
|
| 74 |
+
out.append(item)
|
| 75 |
+
elif isinstance(item, str):
|
| 76 |
+
out.append(LoRASpec(item))
|
| 77 |
+
else:
|
| 78 |
+
raise TypeError(f"Expected LoRASpec or str in LoRA list, got {type(item).__name__}")
|
| 79 |
+
return out
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
_STRIP_PREFIXES = ("diffusion_model.", "transformer.", "unet.", "model.")
|
| 83 |
+
|
| 84 |
+
# PEFT may insert an adapter name before ``.weight``, e.g. ``lora_A.default.weight``.
|
| 85 |
+
_LOW_RANK_RE = re.compile(r"^(?P<base>.*)\.lora_(?P<slot>A|B|down|up)(?:\.[^.]+)?\.weight$")
|
| 86 |
+
_SLOT_MAP = {"A": "A", "down": "A", "B": "B", "up": "B"}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _strip_known_prefixes(key: str) -> str:
|
| 90 |
+
for prefix in _STRIP_PREFIXES:
|
| 91 |
+
if key.startswith(prefix):
|
| 92 |
+
return key[len(prefix) :]
|
| 93 |
+
return key
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _kohya_to_lightx2v(key: str) -> str:
|
| 97 |
+
"""Convert kohya/A1111-style keys to lightx2v-style keys.
|
| 98 |
+
|
| 99 |
+
``lora_unet_blocks_0_cross_attn_k.lora_down.weight``
|
| 100 |
+
→ ``blocks.0.cross_attn.k.lora_down.weight``
|
| 101 |
+
"""
|
| 102 |
+
if not key.startswith("lora_unet_"):
|
| 103 |
+
return key
|
| 104 |
+
parts = key.split(".", 1)
|
| 105 |
+
module_path = parts[0][len("lora_unet_") :]
|
| 106 |
+
suffix = f".{parts[1]}" if len(parts) > 1 else ""
|
| 107 |
+
|
| 108 |
+
m = re.match(r"blocks_(\d+)_(cross_attn|self_attn)_([a-z]+)", module_path)
|
| 109 |
+
if m:
|
| 110 |
+
return f"blocks.{m.group(1)}.{m.group(2)}.{m.group(3)}{suffix}"
|
| 111 |
+
|
| 112 |
+
m = re.match(r"blocks_(\d+)_(ffn)_(\d+)", module_path)
|
| 113 |
+
if m:
|
| 114 |
+
return f"blocks.{m.group(1)}.{m.group(2)}.{m.group(3)}{suffix}"
|
| 115 |
+
|
| 116 |
+
logger.warning(f"Unrecognized kohya key structure: {key}")
|
| 117 |
+
return key
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _diffusers_target(lightx2v_base_path: str, suffix: str) -> str:
|
| 121 |
+
"""Map ``<lightx2v_base>.weight``/``.bias`` to the diffusers parameter key."""
|
| 122 |
+
weight_key = wan_lightx2v_to_diffusers_key(f"{lightx2v_base_path}.weight")
|
| 123 |
+
if suffix == ".weight":
|
| 124 |
+
return weight_key
|
| 125 |
+
return weight_key[: -len(".weight")] + suffix
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _is_lora_key(raw_key: str) -> bool:
|
| 129 |
+
key = _kohya_to_lightx2v(_strip_known_prefixes(raw_key))
|
| 130 |
+
return bool(_LOW_RANK_RE.match(key)) or key.endswith((".diff", ".diff_b"))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _has_lora_keys(state_dict: dict[str, torch.Tensor]) -> bool:
|
| 134 |
+
"""Return True iff the safetensors file contains at least one LoRA-style key."""
|
| 135 |
+
return any(_is_lora_key(k) for k in state_dict)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def fuse_lora_state_dict(
|
| 139 |
+
base_state_dict: dict[str, torch.Tensor],
|
| 140 |
+
lora_state_dict: dict[str, torch.Tensor],
|
| 141 |
+
*,
|
| 142 |
+
scale: float = 1.0,
|
| 143 |
+
return_stats: bool = False,
|
| 144 |
+
) -> dict[str, torch.Tensor] | tuple[dict[str, torch.Tensor], _FusionStats]:
|
| 145 |
+
"""Return a fused state dict, optionally with stats describing what applied.
|
| 146 |
+
|
| 147 |
+
The base dict is not mutated; entries that were touched are fresh tensors
|
| 148 |
+
so the caller can chain fusions across a stack safely. See
|
| 149 |
+
``experimental/models/Wan2_2_LoRA.md`` for the supported adapter key
|
| 150 |
+
formats. Raises ``KeyError`` when a low-rank pair is missing one half.
|
| 151 |
+
"""
|
| 152 |
+
pairs: dict[str, dict[str, torch.Tensor]] = {}
|
| 153 |
+
direct_deltas: list[tuple[str, str, torch.Tensor]] = [] # (base_path, suffix, tensor)
|
| 154 |
+
alphas: dict[str, float] = {}
|
| 155 |
+
skipped_unknown: list[str] = []
|
| 156 |
+
|
| 157 |
+
for raw_key, tensor in lora_state_dict.items():
|
| 158 |
+
key = _kohya_to_lightx2v(_strip_known_prefixes(raw_key))
|
| 159 |
+
m = _LOW_RANK_RE.match(key)
|
| 160 |
+
if m:
|
| 161 |
+
pairs.setdefault(m.group("base"), {})[_SLOT_MAP[m.group("slot")]] = tensor
|
| 162 |
+
continue
|
| 163 |
+
if key.endswith(".diff_b"):
|
| 164 |
+
direct_deltas.append((key[: -len(".diff_b")], ".bias", tensor))
|
| 165 |
+
elif key.endswith(".diff"):
|
| 166 |
+
direct_deltas.append((key[: -len(".diff")], ".weight", tensor))
|
| 167 |
+
elif key.endswith(".alpha"):
|
| 168 |
+
alphas[key[: -len(".alpha")]] = tensor.item()
|
| 169 |
+
else:
|
| 170 |
+
skipped_unknown.append(raw_key)
|
| 171 |
+
|
| 172 |
+
if skipped_unknown:
|
| 173 |
+
logger.warning(
|
| 174 |
+
f"LoRA fusion: {len(skipped_unknown)} unrecognized keys ignored. Examples: {skipped_unknown[:5]}"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
fused = dict(base_state_dict)
|
| 178 |
+
applied_pairs = 0
|
| 179 |
+
skipped_unmapped: list[str] = []
|
| 180 |
+
|
| 181 |
+
for base_path, ab in pairs.items():
|
| 182 |
+
if "A" not in ab or "B" not in ab:
|
| 183 |
+
raise KeyError(f"LoRA pair incomplete for '{base_path}': have {list(ab)}")
|
| 184 |
+
diffusers_key = _diffusers_target(base_path, ".weight")
|
| 185 |
+
if diffusers_key not in fused:
|
| 186 |
+
skipped_unmapped.append(diffusers_key)
|
| 187 |
+
continue
|
| 188 |
+
base_weight = fused[diffusers_key]
|
| 189 |
+
rank = ab["A"].shape[0]
|
| 190 |
+
alpha = alphas.get(base_path, float(rank))
|
| 191 |
+
effective_scale = scale * (alpha / rank)
|
| 192 |
+
delta = effective_scale * (ab["B"].to(torch.float32) @ ab["A"].to(torch.float32))
|
| 193 |
+
# ``+`` always allocates a new tensor; avoids the .to(fp32)+.add_()
|
| 194 |
+
# alias trap when base_weight is already fp32 (would mutate the
|
| 195 |
+
# caller's dict on a subsequent stacked-fusion pass).
|
| 196 |
+
fused[diffusers_key] = (base_weight.float() + delta).to(base_weight.dtype)
|
| 197 |
+
applied_pairs += 1
|
| 198 |
+
|
| 199 |
+
applied_direct = 0
|
| 200 |
+
skipped_shape_mismatch = 0
|
| 201 |
+
for base_path, suffix, tensor in direct_deltas:
|
| 202 |
+
diffusers_key = _diffusers_target(base_path, suffix)
|
| 203 |
+
if diffusers_key not in fused:
|
| 204 |
+
skipped_unmapped.append(diffusers_key)
|
| 205 |
+
continue
|
| 206 |
+
base = fused[diffusers_key]
|
| 207 |
+
if base.shape != tensor.shape:
|
| 208 |
+
logger.warning(
|
| 209 |
+
f"LoRA direct delta shape mismatch for '{diffusers_key}': "
|
| 210 |
+
f"base {tuple(base.shape)} vs delta {tuple(tensor.shape)}; skipping."
|
| 211 |
+
)
|
| 212 |
+
skipped_shape_mismatch += 1
|
| 213 |
+
continue
|
| 214 |
+
fused[diffusers_key] = (base.float() + scale * tensor.to(torch.float32)).to(base.dtype)
|
| 215 |
+
applied_direct += 1
|
| 216 |
+
|
| 217 |
+
if skipped_unmapped:
|
| 218 |
+
logger.warning(
|
| 219 |
+
f"LoRA fusion: {len(skipped_unmapped)} adapter targets not present in base; skipped. "
|
| 220 |
+
f"Examples: {skipped_unmapped[:5]}"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
logger.info(f"Fused {applied_pairs} low-rank pairs and {applied_direct} direct deltas (scale={scale})")
|
| 224 |
+
stats = _FusionStats(
|
| 225 |
+
applied_pairs=applied_pairs,
|
| 226 |
+
applied_direct=applied_direct,
|
| 227 |
+
skipped_unknown=len(skipped_unknown),
|
| 228 |
+
skipped_unmapped=len(skipped_unmapped),
|
| 229 |
+
skipped_shape_mismatch=skipped_shape_mismatch,
|
| 230 |
+
)
|
| 231 |
+
return (fused, stats) if return_stats else fused
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def verify_fusion_changed_weights(
|
| 235 |
+
base_sd: dict[str, torch.Tensor],
|
| 236 |
+
fused_sd: dict[str, torch.Tensor],
|
| 237 |
+
*,
|
| 238 |
+
min_changed: int = 3,
|
| 239 |
+
label: str,
|
| 240 |
+
) -> None:
|
| 241 |
+
"""Sanity check that at least ``min_changed`` weights actually differ post-fusion.
|
| 242 |
+
|
| 243 |
+
Catches the canonical "LoRA silently failed to apply" failure mode where
|
| 244 |
+
a key-mapping bug leaves every fused target absent from the base. Logs a
|
| 245 |
+
sample of changed tensors with their L2 diff for visibility.
|
| 246 |
+
|
| 247 |
+
Raises ``RuntimeError`` if no weights changed at all, or if fewer than
|
| 248 |
+
``min_changed`` weights changed (likely a partial load).
|
| 249 |
+
"""
|
| 250 |
+
changed: list[tuple[str, float]] = []
|
| 251 |
+
max_diff = 0.0
|
| 252 |
+
for k, base in base_sd.items():
|
| 253 |
+
fused = fused_sd.get(k)
|
| 254 |
+
if fused is None or fused.shape != base.shape:
|
| 255 |
+
continue
|
| 256 |
+
if fused.data_ptr() == base.data_ptr():
|
| 257 |
+
continue
|
| 258 |
+
diff = (fused.to(torch.float32) - base.to(torch.float32)).norm().item()
|
| 259 |
+
if diff > 0.0:
|
| 260 |
+
changed.append((k, diff))
|
| 261 |
+
max_diff = max(max_diff, diff)
|
| 262 |
+
|
| 263 |
+
if max_diff == 0.0:
|
| 264 |
+
raise RuntimeError(
|
| 265 |
+
f"LoRA silently failed to apply -- weights are unchanged for '{label}'. "
|
| 266 |
+
f"Verified {len(base_sd)} keys against fused dict; max L2 diff is 0.0."
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
sample = changed[:5]
|
| 270 |
+
logger.info(
|
| 271 |
+
f"LoRA fusion verified for '{label}': {len(changed)} tensors changed, "
|
| 272 |
+
f"max L2 diff={max_diff:.4f}. Sample diffs:"
|
| 273 |
+
)
|
| 274 |
+
for k, d in sample:
|
| 275 |
+
logger.info(f" {k}: L2={d:.4f}")
|
| 276 |
+
|
| 277 |
+
if len(changed) < min_changed:
|
| 278 |
+
raise RuntimeError(
|
| 279 |
+
f"LoRA fusion changed only {len(changed)} weights for '{label}' "
|
| 280 |
+
f"(require >= {min_changed}). Likely indicates partial LoRA load."
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _lora_stack_cache_namespace(specs_by_expert: dict[int, list[LoRASpec]]) -> str:
|
| 285 |
+
"""Hash ordered ``(resolved_path, scale)`` per expert so distinct stacks cache separately."""
|
| 286 |
+
h = hashlib.sha1()
|
| 287 |
+
for idx in sorted(specs_by_expert.keys()):
|
| 288 |
+
h.update(f"expert_{idx}\x00".encode())
|
| 289 |
+
for spec in specs_by_expert[idx]:
|
| 290 |
+
h.update(str(Path(spec.path).resolve()).encode())
|
| 291 |
+
h.update(b"\x00")
|
| 292 |
+
h.update(f"{spec.scale:.6f}".encode())
|
| 293 |
+
h.update(b"\x00")
|
| 294 |
+
return f"Wan2.2-I2V-LoRA-{h.hexdigest()[:12]}"
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class WanPipelineI2VLora(WanPipelineI2V):
|
| 298 |
+
"""Wan2.2 I2V with LoRA stacks fused into the base PyTorch weights.
|
| 299 |
+
|
| 300 |
+
Adapters distilled to be guidance-free want ``cfg_enabled=False``, which halves the
|
| 301 |
+
denoising cost by dropping the unconditional pass. With it off the caller must keep both
|
| 302 |
+
guidance scales at 1.0, which ``__call__`` enforces.
|
| 303 |
+
"""
|
| 304 |
+
|
| 305 |
+
def __init__(
|
| 306 |
+
self,
|
| 307 |
+
*,
|
| 308 |
+
device: ttnn.MeshDevice,
|
| 309 |
+
config: WanPipelineConfig,
|
| 310 |
+
lora_high: LoRAArg = None,
|
| 311 |
+
lora_low: LoRAArg = None,
|
| 312 |
+
scheduler: SchedulerMixin | None = None,
|
| 313 |
+
run_warmup: bool = True,
|
| 314 |
+
lora_enabled: bool = False,
|
| 315 |
+
) -> None:
|
| 316 |
+
high_specs = _normalize_lora_arg(lora_high)
|
| 317 |
+
low_specs = _normalize_lora_arg(lora_low)
|
| 318 |
+
|
| 319 |
+
if not high_specs and not low_specs:
|
| 320 |
+
raise ValueError(
|
| 321 |
+
"WanPipelineI2VLora requires at least one LoRA. "
|
| 322 |
+
"Pass lora_high and/or lora_low as a path, LoRASpec, or list."
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
for label, specs in [("lora_high", high_specs), ("lora_low", low_specs)]:
|
| 326 |
+
for spec in specs:
|
| 327 |
+
if not Path(spec.path).is_file():
|
| 328 |
+
raise FileNotFoundError(f"{label}: file does not exist: {spec.path}")
|
| 329 |
+
|
| 330 |
+
self._lora_specs: dict[int, list[LoRASpec]] = {0: high_specs, 1: low_specs}
|
| 331 |
+
self._cache_namespace = _lora_stack_cache_namespace(self._lora_specs)
|
| 332 |
+
# Cleared after TT cache handoff (see _prepare_transformer) to free CPU memory.
|
| 333 |
+
self._fused_state_dicts: dict[int, dict[str, torch.Tensor] | None] = {0: None, 1: None}
|
| 334 |
+
self.custom_sigmas = None
|
| 335 |
+
|
| 336 |
+
if scheduler is None:
|
| 337 |
+
scheduler = CustomSigmaScheduler()
|
| 338 |
+
self.custom_sigmas = [1.0, 0.9375, 0.8333333, 0.625, 0.0] # default for Wan 2.2 I2V Lightning
|
| 339 |
+
logger.info(
|
| 340 |
+
f"WanPipelineI2VLora: default Euler scheduler with custom sigmas {self.custom_sigmas} -- (Wan 2.2 I2V Lightning)"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
super().__init__(
|
| 344 |
+
device=device, config=config, scheduler=scheduler, run_warmup=run_warmup, lora_enabled=lora_enabled
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
def prepare_text_conditioning(self, tt_model, prompt_embeds, buffer, traced=False):
|
| 348 |
+
# guidance_scale=1.0 → encoder returns None for negative embeds; combined_step
|
| 349 |
+
# skips CFG so the untouched buffer is fine.
|
| 350 |
+
if prompt_embeds is None:
|
| 351 |
+
return buffer
|
| 352 |
+
return super().prepare_text_conditioning(tt_model, prompt_embeds, buffer, traced)
|
| 353 |
+
|
| 354 |
+
def prepare_schedule(self, num_inference_steps: int, *, flow_shift: float | None = None) -> None:
|
| 355 |
+
if self._solver.scheduler is None:
|
| 356 |
+
self._solver.set_schedule(sigmas=self.custom_sigmas)
|
| 357 |
+
else:
|
| 358 |
+
super().prepare_schedule(num_inference_steps, flow_shift=flow_shift)
|
| 359 |
+
|
| 360 |
+
def _build_fused_state_dict(self, idx: int) -> dict[str, torch.Tensor] | None:
|
| 361 |
+
specs = self._lora_specs[idx]
|
| 362 |
+
state = self.transformer_states[idx]
|
| 363 |
+
subfolder = state.checkpoint.subfolder
|
| 364 |
+
if not specs:
|
| 365 |
+
logger.info(f"No LoRA for expert idx={idx} ('{subfolder}') -- using base weights")
|
| 366 |
+
return None
|
| 367 |
+
|
| 368 |
+
base_sd = state.checkpoint.state_dict()
|
| 369 |
+
fused_sd = base_sd
|
| 370 |
+
for spec in specs:
|
| 371 |
+
logger.info(f"Loading LoRA for '{subfolder}' from {spec.path} (scale={spec.scale})")
|
| 372 |
+
lora_sd = load_file(str(spec.path))
|
| 373 |
+
if not _has_lora_keys(lora_sd):
|
| 374 |
+
raise RuntimeError(
|
| 375 |
+
f"No LoRA-style keys (lora_A/lora_B, lora_down/lora_up, diff/diff_b) found in {spec.path}"
|
| 376 |
+
)
|
| 377 |
+
previous_sd = fused_sd
|
| 378 |
+
fused_sd, stats = fuse_lora_state_dict(previous_sd, lora_sd, scale=spec.scale, return_stats=True)
|
| 379 |
+
label = f"{subfolder}: {Path(spec.path).name}"
|
| 380 |
+
if stats.applied == 0:
|
| 381 |
+
raise RuntimeError(
|
| 382 |
+
f"LoRA fusion applied no tensors for '{label}'. "
|
| 383 |
+
f"Skipped unmapped={stats.skipped_unmapped}, unknown={stats.skipped_unknown}, "
|
| 384 |
+
f"shape_mismatch={stats.skipped_shape_mismatch}."
|
| 385 |
+
)
|
| 386 |
+
verify_fusion_changed_weights(previous_sd, fused_sd, label=label)
|
| 387 |
+
return fused_sd
|
| 388 |
+
|
| 389 |
+
def _prepare_transformer(self, idx: int):
|
| 390 |
+
state = self.transformer_states[idx]
|
| 391 |
+
|
| 392 |
+
if not self._lora_specs[idx]:
|
| 393 |
+
super()._prepare_transformer(idx)
|
| 394 |
+
return
|
| 395 |
+
|
| 396 |
+
def _get_state_dict(idx_=idx):
|
| 397 |
+
cached = self._fused_state_dicts.get(idx_)
|
| 398 |
+
if cached is not None:
|
| 399 |
+
return cached
|
| 400 |
+
sd = self._build_fused_state_dict(idx_)
|
| 401 |
+
self._fused_state_dicts[idx_] = sd
|
| 402 |
+
return sd
|
| 403 |
+
|
| 404 |
+
cache.load_model(
|
| 405 |
+
state.model,
|
| 406 |
+
model_name=self._cache_namespace,
|
| 407 |
+
subfolder=state.checkpoint.subfolder,
|
| 408 |
+
parallel_config=self.parallel_config,
|
| 409 |
+
mesh_shape=tuple(self.mesh_device.shape),
|
| 410 |
+
mesh_device=self.mesh_device,
|
| 411 |
+
is_fsdp=self.is_fsdp,
|
| 412 |
+
get_torch_state_dict=_get_state_dict,
|
| 413 |
+
)
|
| 414 |
+
self._fused_state_dicts[idx] = None
|
code/models/tt_dit/experimental/pipelines/pipeline_wan_runtime_lora.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Wan 2.2 pipeline with runtime-swappable LoRA adapters.
|
| 6 |
+
|
| 7 |
+
Constructs the transformer with ``lora_enabled=True`` so every attention
|
| 8 |
+
and FFN Linear is a LoRA-aware variant. Exposes a small API for
|
| 9 |
+
registering and switching adapters at runtime:
|
| 10 |
+
|
| 11 |
+
pipeline.register_lora(name, high_path, low_path, scale) → AdapterHandle
|
| 12 |
+
pipeline.set_active_lora(handle | None)
|
| 13 |
+
pipeline.unregister_lora(handle)
|
| 14 |
+
pipeline.list_loras()
|
| 15 |
+
|
| 16 |
+
Adapter swap merges the active LoRA delta directly into the base weight:
|
| 17 |
+
``W += scale * A.T @ B.T`` per LoRA Linear, on device, with W's sharding.
|
| 18 |
+
Forward-time LoRA overhead is zero — the captured trace runs the
|
| 19 |
+
un-modified base path. Swap cost is bounded to ~one small matmul + an
|
| 20 |
+
in-place add per LoRA Linear; sub-second end to end. See
|
| 21 |
+
``models/tt_dit/layers/lora.py`` for the merge details.
|
| 22 |
+
|
| 23 |
+
Out of scope for v0:
|
| 24 |
+
- Stacking multiple adapters into a single bound slot (callers wanting
|
| 25 |
+
stacked LoRAs should pre-merge offline, or use the older CPU-fusion
|
| 26 |
+
pipeline at ``pipeline_wan_lora.py``).
|
| 27 |
+
- ``.diff`` / ``.diff_b`` direct deltas (the loader warns and skips).
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from dataclasses import dataclass, field
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
from loguru import logger
|
| 35 |
+
|
| 36 |
+
from models.tt_dit.experimental.lora.adapter_loader import AdapterHandle, load_adapter_into
|
| 37 |
+
from models.tt_dit.layers.lora import LoRAMixin
|
| 38 |
+
from models.tt_dit.pipelines.wan.pipeline_wan import WanPipeline
|
| 39 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import WanPipelineI2V
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class _RegisteredLoRA:
|
| 44 |
+
"""Per-pipeline record of a registered adapter pair."""
|
| 45 |
+
|
| 46 |
+
name: str
|
| 47 |
+
handle_high: AdapterHandle | None
|
| 48 |
+
handle_low: AdapterHandle | None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class CombinedAdapterHandle:
|
| 53 |
+
"""Returned by `register_lora`. Knows about both experts."""
|
| 54 |
+
|
| 55 |
+
name: str
|
| 56 |
+
high: AdapterHandle | None
|
| 57 |
+
low: AdapterHandle | None
|
| 58 |
+
rank: int = field(init=False)
|
| 59 |
+
|
| 60 |
+
def __post_init__(self):
|
| 61 |
+
ranks = [h.rank for h in (self.high, self.low) if h is not None]
|
| 62 |
+
if not ranks:
|
| 63 |
+
raise ValueError(f"adapter '{self.name}' loaded for neither expert")
|
| 64 |
+
self.rank = max(ranks)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _iter_lora_modules(module):
|
| 68 |
+
"""Yield every LoRAMixin-bearing submodule of `module`, recursively."""
|
| 69 |
+
if isinstance(module, LoRAMixin):
|
| 70 |
+
yield module
|
| 71 |
+
for _, child in module.named_children():
|
| 72 |
+
yield from _iter_lora_modules(child)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class _LoRAPipelineMixin:
|
| 76 |
+
"""Implements the LoRA swap API on top of a WanPipeline subclass.
|
| 77 |
+
|
| 78 |
+
Assumes the host pipeline was constructed with ``lora_enabled=True``
|
| 79 |
+
so both ``self.transformer`` and ``self.transformer_2`` have LoRA-aware
|
| 80 |
+
Linear modules throughout.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
def __init__(self, *args, lora_enabled: bool = True, **kwargs):
|
| 84 |
+
# Initialize LoRA state *before* super().__init__() because the base
|
| 85 |
+
# pipeline init calls self._prepare_transformer() during warmup, and
|
| 86 |
+
# our override below reads self._active_adapter_name.
|
| 87 |
+
self._lora_registry: dict[str, _RegisteredLoRA] = {}
|
| 88 |
+
self._active_adapter_name: str | None = None
|
| 89 |
+
super().__init__(*args, lora_enabled=lora_enabled, **kwargs)
|
| 90 |
+
|
| 91 |
+
# Fuse-mode bind/unbind adds and subtracts the delta in bf16; under
|
| 92 |
+
# dynamic_load=True the cache restore re-seeds the base every reload
|
| 93 |
+
# and drift is bounded. With dynamic_load=False the base weight is
|
| 94 |
+
# never refreshed, so W drifts by ~O(N · eps_bf16) over N swap cycles.
|
| 95 |
+
# See models/tt_dit/layers/lora.py for the snapshot+reseed escape hatch.
|
| 96 |
+
if not getattr(self, "dynamic_load", True):
|
| 97 |
+
logger.warning(
|
| 98 |
+
"LoRA fuse-mode pipeline constructed with dynamic_load=False — "
|
| 99 |
+
"repeated bind/unbind cycles will accumulate bf16 quantization "
|
| 100 |
+
"drift in the base weight. For long-lived sessions, snapshot "
|
| 101 |
+
"the transformer state dict at construction and re-seed via "
|
| 102 |
+
"load_torch_state_dict when drift becomes material."
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
@classmethod
|
| 106 |
+
def create_pipeline(cls, **kwargs):
|
| 107 |
+
# The base factory defaults lora_enabled=False; runtime LoRA needs it on.
|
| 108 |
+
kwargs.setdefault("lora_enabled", True)
|
| 109 |
+
return super().create_pipeline(**kwargs)
|
| 110 |
+
|
| 111 |
+
def _prepare_transformer(self, idx: int):
|
| 112 |
+
"""Re-apply any active LoRA delta after the transformer is reloaded.
|
| 113 |
+
|
| 114 |
+
Under dynamic_load the two transformers swap in and out of DRAM
|
| 115 |
+
between uses; each reload restores the cached *base* weights, so
|
| 116 |
+
the merged LoRA delta is wiped. We walk this transformer's LoRA
|
| 117 |
+
modules and re-merge their active adapter (no-op for modules that
|
| 118 |
+
already have the delta applied, e.g., the first load).
|
| 119 |
+
"""
|
| 120 |
+
super()._prepare_transformer(idx)
|
| 121 |
+
if self._active_adapter_name is None:
|
| 122 |
+
return
|
| 123 |
+
transformer = self.transformer_states[idx].model
|
| 124 |
+
for mod in _iter_lora_modules(transformer):
|
| 125 |
+
mod.reapply_after_load()
|
| 126 |
+
|
| 127 |
+
# ---- public API ----
|
| 128 |
+
def register_lora(
|
| 129 |
+
self,
|
| 130 |
+
name: str,
|
| 131 |
+
*,
|
| 132 |
+
high_path: str | Path | None = None,
|
| 133 |
+
low_path: str | Path | None = None,
|
| 134 |
+
scale: float = 1.0,
|
| 135 |
+
) -> CombinedAdapterHandle:
|
| 136 |
+
"""Load a LoRA adapter for one or both experts.
|
| 137 |
+
|
| 138 |
+
Both ``high_path`` and ``low_path`` are independently optional — pass
|
| 139 |
+
either or both. Adapter weights are parsed from disk and stored on the
|
| 140 |
+
host LoRA bank of every applicable Linear in the respective transformer;
|
| 141 |
+
device-side merge happens later in ``set_active_lora`` (fuse mode) or
|
| 142 |
+
in the layer's forward (runtime mode). Device-OOM therefore surfaces
|
| 143 |
+
at ``set_active_lora`` time, not here.
|
| 144 |
+
"""
|
| 145 |
+
if not high_path and not low_path:
|
| 146 |
+
raise ValueError("register_lora requires high_path and/or low_path")
|
| 147 |
+
if name in self._lora_registry:
|
| 148 |
+
raise ValueError(f"LoRA name '{name}' already registered")
|
| 149 |
+
|
| 150 |
+
high_handle = None
|
| 151 |
+
low_handle = None
|
| 152 |
+
|
| 153 |
+
if high_path is not None:
|
| 154 |
+
logger.info(f"loading LoRA '{name}' high-noise from {high_path} (scale={scale})")
|
| 155 |
+
high_handle = load_adapter_into(self.transformer, str(high_path), scale=scale, name=name)
|
| 156 |
+
if low_path is not None:
|
| 157 |
+
logger.info(f"loading LoRA '{name}' low-noise from {low_path} (scale={scale})")
|
| 158 |
+
low_handle = load_adapter_into(self.transformer_2, str(low_path), scale=scale, name=name)
|
| 159 |
+
|
| 160 |
+
combined = CombinedAdapterHandle(name=name, high=high_handle, low=low_handle)
|
| 161 |
+
self._lora_registry[name] = _RegisteredLoRA(name=name, handle_high=high_handle, handle_low=low_handle)
|
| 162 |
+
return combined
|
| 163 |
+
|
| 164 |
+
def unregister_lora(self, handle: CombinedAdapterHandle | str) -> None:
|
| 165 |
+
name = handle if isinstance(handle, str) else handle.name
|
| 166 |
+
rec = self._lora_registry.pop(name, None)
|
| 167 |
+
if rec is None:
|
| 168 |
+
return
|
| 169 |
+
if self._active_adapter_name == name:
|
| 170 |
+
self.set_active_lora(None)
|
| 171 |
+
# Walk and unregister bank entries
|
| 172 |
+
for transformer, handle_ in (
|
| 173 |
+
(self.transformer, rec.handle_high),
|
| 174 |
+
(self.transformer_2, rec.handle_low),
|
| 175 |
+
):
|
| 176 |
+
if handle_ is None:
|
| 177 |
+
continue
|
| 178 |
+
self._unregister_handle(transformer, handle_)
|
| 179 |
+
|
| 180 |
+
def set_active_lora(self, handle: CombinedAdapterHandle | str | None) -> None:
|
| 181 |
+
"""Bind the named adapter as the active LoRA for both experts.
|
| 182 |
+
|
| 183 |
+
Each LoRA Linear's bind merges the new delta into the base weight
|
| 184 |
+
in-place. If a different adapter is currently active, its delta is
|
| 185 |
+
subtracted first. Passing ``None`` removes the active delta (W is
|
| 186 |
+
restored to its base value).
|
| 187 |
+
"""
|
| 188 |
+
if handle is None:
|
| 189 |
+
self._unbind_all()
|
| 190 |
+
self._active_adapter_name = None
|
| 191 |
+
return
|
| 192 |
+
|
| 193 |
+
name = handle if isinstance(handle, str) else handle.name
|
| 194 |
+
rec = self._lora_registry.get(name)
|
| 195 |
+
if rec is None:
|
| 196 |
+
raise KeyError(f"no LoRA registered with name '{name}'")
|
| 197 |
+
|
| 198 |
+
self._bind_per_module(self.transformer, rec.handle_high)
|
| 199 |
+
self._bind_per_module(self.transformer_2, rec.handle_low)
|
| 200 |
+
self._active_adapter_name = name
|
| 201 |
+
|
| 202 |
+
def list_loras(self) -> list[str]:
|
| 203 |
+
return list(self._lora_registry)
|
| 204 |
+
|
| 205 |
+
def deallocate_all_loras(self) -> None:
|
| 206 |
+
"""Unbind every active adapter and clear the LoRA bank on every layer.
|
| 207 |
+
|
| 208 |
+
Releases host-side adapter records across both transformers. Use this
|
| 209 |
+
on a long-running server to reset state between unrelated jobs.
|
| 210 |
+
"""
|
| 211 |
+
self._unbind_all()
|
| 212 |
+
self._active_adapter_name = None
|
| 213 |
+
self._lora_registry.clear()
|
| 214 |
+
for transformer in (self.transformer, self.transformer_2):
|
| 215 |
+
for mod in _iter_lora_modules(transformer):
|
| 216 |
+
mod.deallocate_lora()
|
| 217 |
+
|
| 218 |
+
# ---- internals ----
|
| 219 |
+
def _path_to_module(self, transformer, dotted: str):
|
| 220 |
+
"""Resolve a dotted path like ``blocks.0.attn1.to_qkv`` to a child module."""
|
| 221 |
+
cur = transformer
|
| 222 |
+
for part in dotted.split("."):
|
| 223 |
+
if part.isdigit():
|
| 224 |
+
cur = cur[int(part)]
|
| 225 |
+
else:
|
| 226 |
+
cur = getattr(cur, part)
|
| 227 |
+
return cur
|
| 228 |
+
|
| 229 |
+
def _bind_per_module(self, transformer, handle: AdapterHandle | None):
|
| 230 |
+
addressed: set[int] = set()
|
| 231 |
+
if handle is not None:
|
| 232 |
+
for dotted, bank_idx in handle.target_indices.items():
|
| 233 |
+
mod = self._path_to_module(transformer, dotted)
|
| 234 |
+
mod.bind_active(bank_idx)
|
| 235 |
+
addressed.add(id(mod))
|
| 236 |
+
|
| 237 |
+
# Any LoRA module not touched by this adapter (including the all-orphan
|
| 238 |
+
# case where handle is None, e.g. switching to an adapter that only
|
| 239 |
+
# covers the other expert) but currently active from a previous bind
|
| 240 |
+
# must have its delta removed.
|
| 241 |
+
for mod in _iter_lora_modules(transformer):
|
| 242 |
+
if id(mod) in addressed:
|
| 243 |
+
continue
|
| 244 |
+
if mod.is_lora_active:
|
| 245 |
+
mod.unbind_active()
|
| 246 |
+
|
| 247 |
+
def _unbind_all(self):
|
| 248 |
+
for transformer in (self.transformer, self.transformer_2):
|
| 249 |
+
for mod in _iter_lora_modules(transformer):
|
| 250 |
+
if mod.is_lora_active:
|
| 251 |
+
mod.unbind_active()
|
| 252 |
+
|
| 253 |
+
def _unregister_handle(self, transformer, handle: AdapterHandle):
|
| 254 |
+
for dotted, bank_idx in handle.target_indices.items():
|
| 255 |
+
mod = self._path_to_module(transformer, dotted)
|
| 256 |
+
mod.unregister_lora(bank_idx)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# --------------------------------------------------------------------
|
| 260 |
+
# Public pipeline classes
|
| 261 |
+
# --------------------------------------------------------------------
|
| 262 |
+
class WanPipelineRuntimeLoRA(_LoRAPipelineMixin, WanPipeline):
|
| 263 |
+
"""T2V pipeline with runtime LoRA support."""
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class WanPipelineI2VRuntimeLoRA(_LoRAPipelineMixin, WanPipelineI2V):
|
| 267 |
+
"""I2V pipeline with runtime LoRA support."""
|
code/models/tt_dit/experimental/pipelines/pipeline_wan_svi.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Stable-Video-Infinity (SVI) 2.0 Pro pipeline for Wan2.2 I2V.
|
| 6 |
+
|
| 7 |
+
Chains short I2V clips into long videos with latent-space continuity.
|
| 8 |
+
See ``experimental/models/Wan2_2_SVI.md`` for the regime parameters,
|
| 9 |
+
upstream-workflow comparison, and the documented scheduler gap.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import List, Literal, Optional, Union
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
from diffusers.schedulers import FlowMatchEulerDiscreteScheduler, UniPCMultistepScheduler
|
| 20 |
+
from loguru import logger
|
| 21 |
+
from PIL import Image
|
| 22 |
+
|
| 23 |
+
import ttnn
|
| 24 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_lora import LoRASpec, WanPipelineI2VLora
|
| 25 |
+
from models.tt_dit.pipelines.wan.pipeline_wan import WanPipeline, WanPipelineConfig
|
| 26 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import ImagePrompt
|
| 27 |
+
|
| 28 |
+
Regime = Literal["python", "comfyui"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
_REGIMES = {
|
| 32 |
+
"python": {
|
| 33 |
+
"flow_shift": 5.0,
|
| 34 |
+
"call_defaults": dict(num_inference_steps=50, guidance_scale=5.0, guidance_scale_2=5.0),
|
| 35 |
+
},
|
| 36 |
+
"comfyui": {
|
| 37 |
+
"flow_shift": 8.0,
|
| 38 |
+
"call_defaults": dict(num_inference_steps=6, guidance_scale=1.5, guidance_scale_2=1.5),
|
| 39 |
+
},
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class _ClipSpec:
|
| 45 |
+
prompt: str
|
| 46 |
+
seed: int
|
| 47 |
+
guidance_scale: float
|
| 48 |
+
guidance_scale_2: float
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class WanPipelineSVI(WanPipelineI2VLora):
|
| 52 |
+
"""Wan2.2 I2V with SVI 2.0 Pro LoRA + autoregressive clip-chaining driver."""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
*,
|
| 57 |
+
device: ttnn.MeshDevice,
|
| 58 |
+
config: WanPipelineConfig,
|
| 59 |
+
svi_high: str,
|
| 60 |
+
svi_low: str,
|
| 61 |
+
lightx2v_high: Optional[str] = None,
|
| 62 |
+
lightx2v_low: Optional[str] = None,
|
| 63 |
+
regime: Regime = "python",
|
| 64 |
+
num_motion_latent: int = 1,
|
| 65 |
+
num_overlap_frame: int = 4,
|
| 66 |
+
) -> None:
|
| 67 |
+
if regime not in _REGIMES:
|
| 68 |
+
raise ValueError(f"regime must be one of {sorted(_REGIMES)}, got {regime!r}")
|
| 69 |
+
|
| 70 |
+
for label, path in [("svi_high", svi_high), ("svi_low", svi_low)]:
|
| 71 |
+
if not Path(path).is_file():
|
| 72 |
+
raise FileNotFoundError(f"{label}: file does not exist: {path}")
|
| 73 |
+
|
| 74 |
+
self._regime: Regime = regime
|
| 75 |
+
self._num_motion_latent = int(num_motion_latent)
|
| 76 |
+
self._num_overlap_frame = int(num_overlap_frame)
|
| 77 |
+
# Lives for the duration of one generate_long_video call; cleared in
|
| 78 |
+
# that method's finally block.
|
| 79 |
+
self._anchor_latents_cache: Optional[tuple] = None
|
| 80 |
+
|
| 81 |
+
lora_high, lora_low, scheduler = self._configure_regime(
|
| 82 |
+
regime=regime,
|
| 83 |
+
svi_high=svi_high,
|
| 84 |
+
svi_low=svi_low,
|
| 85 |
+
lightx2v_high=lightx2v_high,
|
| 86 |
+
lightx2v_low=lightx2v_low,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
super().__init__(
|
| 90 |
+
device=device,
|
| 91 |
+
config=config,
|
| 92 |
+
lora_high=lora_high,
|
| 93 |
+
lora_low=lora_low,
|
| 94 |
+
scheduler=scheduler,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def _configure_regime(
|
| 99 |
+
*,
|
| 100 |
+
regime: "Regime",
|
| 101 |
+
svi_high: str,
|
| 102 |
+
svi_low: str,
|
| 103 |
+
lightx2v_high: Optional[str],
|
| 104 |
+
lightx2v_low: Optional[str],
|
| 105 |
+
) -> tuple[list[LoRASpec], list[LoRASpec], object]:
|
| 106 |
+
"""Return ``(lora_high, lora_low, scheduler)`` per regime."""
|
| 107 |
+
shift = _REGIMES[regime]["flow_shift"]
|
| 108 |
+
|
| 109 |
+
if regime == "python":
|
| 110 |
+
if lightx2v_high is not None or lightx2v_low is not None:
|
| 111 |
+
raise ValueError("lightx2v_* are only valid in regime='comfyui'; drop them for 'python'.")
|
| 112 |
+
# Matches diffsynth's FlowMatchScheduler("Wan") — plain Euler on a
|
| 113 |
+
# flow-matching schedule, dispatched to EulerSolver downstream.
|
| 114 |
+
return (
|
| 115 |
+
[LoRASpec(svi_high, 1.0)],
|
| 116 |
+
[LoRASpec(svi_low, 1.0)],
|
| 117 |
+
FlowMatchEulerDiscreteScheduler(shift=shift),
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# comfyui regime: requires LightX2V LoRAs stacked under SVI.
|
| 121 |
+
if lightx2v_high is None or lightx2v_low is None:
|
| 122 |
+
raise ValueError(
|
| 123 |
+
"regime='comfyui' requires lightx2v_high and lightx2v_low LoRA paths. "
|
| 124 |
+
"See experimental/models/Wan2_2_SVI.md for the recommended HF repo."
|
| 125 |
+
)
|
| 126 |
+
for label, path in [("lightx2v_high", lightx2v_high), ("lightx2v_low", lightx2v_low)]:
|
| 127 |
+
if not Path(path).is_file():
|
| 128 |
+
raise FileNotFoundError(f"{label}: file does not exist: {path}")
|
| 129 |
+
# Per upstream docs/svi/comfyui.md: LightX2V at 1.0 on the high-noise
|
| 130 |
+
# expert hurts SVI; 0.5 is recommended.
|
| 131 |
+
# ComfyUI workflow uses k-diffusion dpm++_sde; tt-metal has no
|
| 132 |
+
# on-device port, so we substitute UniPC at the same flow_shift.
|
| 133 |
+
# Visually close, not bit-exact.
|
| 134 |
+
return (
|
| 135 |
+
[LoRASpec(lightx2v_high, 0.5), LoRASpec(svi_high, 1.0)],
|
| 136 |
+
[LoRASpec(lightx2v_low, 1.0), LoRASpec(svi_low, 1.0)],
|
| 137 |
+
UniPCMultistepScheduler(
|
| 138 |
+
use_flow_sigmas=True,
|
| 139 |
+
prediction_type="flow_prediction",
|
| 140 |
+
flow_shift=shift,
|
| 141 |
+
),
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
def __call__(
|
| 145 |
+
self,
|
| 146 |
+
*args,
|
| 147 |
+
anchor_image: Optional[Image.Image] = None,
|
| 148 |
+
prev_last_latent: Optional[torch.Tensor] = None,
|
| 149 |
+
**kwargs,
|
| 150 |
+
):
|
| 151 |
+
self._svi_anchor_image = anchor_image
|
| 152 |
+
self._svi_prev_last_latent = prev_last_latent
|
| 153 |
+
try:
|
| 154 |
+
return super().__call__(*args, **kwargs)
|
| 155 |
+
finally:
|
| 156 |
+
self._svi_anchor_image = None
|
| 157 |
+
self._svi_prev_last_latent = None
|
| 158 |
+
|
| 159 |
+
def prepare_latents(
|
| 160 |
+
self,
|
| 161 |
+
batch_size: int,
|
| 162 |
+
image_prompt,
|
| 163 |
+
num_channels_latents: int = 16,
|
| 164 |
+
height: int = 480,
|
| 165 |
+
width: int = 832,
|
| 166 |
+
num_frames: int = 81,
|
| 167 |
+
dtype=None,
|
| 168 |
+
device=None,
|
| 169 |
+
anchor_image: Optional[Image.Image] = None,
|
| 170 |
+
prev_last_latent: Optional[torch.Tensor] = None,
|
| 171 |
+
):
|
| 172 |
+
if anchor_image is None:
|
| 173 |
+
anchor_image = getattr(self, "_svi_anchor_image", None)
|
| 174 |
+
if prev_last_latent is None:
|
| 175 |
+
prev_last_latent = getattr(self, "_svi_prev_last_latent", None)
|
| 176 |
+
|
| 177 |
+
# WanPipelineI2V.__init__'s warmup_buffers invokes __call__ with no
|
| 178 |
+
# SVI state — defer to the plain I2V path for that one pass.
|
| 179 |
+
if anchor_image is None and prev_last_latent is None:
|
| 180 |
+
return super().prepare_latents(
|
| 181 |
+
batch_size=batch_size,
|
| 182 |
+
image_prompt=image_prompt,
|
| 183 |
+
num_channels_latents=num_channels_latents,
|
| 184 |
+
height=height,
|
| 185 |
+
width=width,
|
| 186 |
+
num_frames=num_frames,
|
| 187 |
+
dtype=dtype,
|
| 188 |
+
device=device,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
anchor_pil = _as_anchor_image(anchor_image if anchor_image is not None else image_prompt)
|
| 192 |
+
|
| 193 |
+
# Cache the encoded anchor + mask across clips; the encoder is the
|
| 194 |
+
# dominant non-DiT cost per clip (~3-8s on BH 2x4) and the result is
|
| 195 |
+
# invariant under (anchor, H, W, T, dtype).
|
| 196 |
+
cache_key = (id(anchor_pil), height, width, num_frames, num_channels_latents, dtype)
|
| 197 |
+
cached = self._anchor_latents_cache
|
| 198 |
+
if cached is not None and cached[0] == cache_key:
|
| 199 |
+
tt_y_template = cached[1]
|
| 200 |
+
latents, _ = WanPipeline.prepare_latents(
|
| 201 |
+
self,
|
| 202 |
+
batch_size=batch_size,
|
| 203 |
+
num_channels_latents=num_channels_latents,
|
| 204 |
+
height=height,
|
| 205 |
+
width=width,
|
| 206 |
+
num_frames=num_frames,
|
| 207 |
+
dtype=dtype,
|
| 208 |
+
device=device,
|
| 209 |
+
)
|
| 210 |
+
tt_y = tt_y_template.clone()
|
| 211 |
+
else:
|
| 212 |
+
latents, tt_y = super().prepare_latents(
|
| 213 |
+
batch_size=batch_size,
|
| 214 |
+
image_prompt=[ImagePrompt(image=anchor_pil, frame_pos=0)],
|
| 215 |
+
num_channels_latents=num_channels_latents,
|
| 216 |
+
height=height,
|
| 217 |
+
width=width,
|
| 218 |
+
num_frames=num_frames,
|
| 219 |
+
dtype=dtype,
|
| 220 |
+
device=device,
|
| 221 |
+
)
|
| 222 |
+
# Cloned because the motion-latent splice below mutates tt_y in place.
|
| 223 |
+
self._anchor_latents_cache = (cache_key, tt_y.clone())
|
| 224 |
+
|
| 225 |
+
# tt_y layout: (B, 4 mask channels + 16 image-latent channels, T_lat, H_lat, W_lat).
|
| 226 |
+
t_lat = tt_y.shape[2]
|
| 227 |
+
n = 0
|
| 228 |
+
if prev_last_latent is not None and self._num_motion_latent > 0:
|
| 229 |
+
n = min(self._num_motion_latent, prev_last_latent.shape[2], t_lat - 1)
|
| 230 |
+
if n > 0:
|
| 231 |
+
motion = prev_last_latent[:, :, -n:, :, :].to(tt_y.device, tt_y.dtype)
|
| 232 |
+
if motion.shape[-2:] != tt_y.shape[-2:]:
|
| 233 |
+
logger.warning(
|
| 234 |
+
f"prev_last_latent spatial shape {tuple(motion.shape[-2:])} != "
|
| 235 |
+
f"conditioning {tuple(tt_y.shape[-2:])}; skipping motion splice."
|
| 236 |
+
)
|
| 237 |
+
n = 0
|
| 238 |
+
else:
|
| 239 |
+
tt_y[:, 4:, 1 : 1 + n, :, :] = motion
|
| 240 |
+
|
| 241 |
+
if 1 + n < t_lat:
|
| 242 |
+
tt_y[:, 4:, 1 + n :, :, :] = 0.0
|
| 243 |
+
|
| 244 |
+
return latents, tt_y
|
| 245 |
+
|
| 246 |
+
def generate_long_video(
|
| 247 |
+
self,
|
| 248 |
+
*,
|
| 249 |
+
prompt: Union[str, List[str]],
|
| 250 |
+
num_clips: int,
|
| 251 |
+
anchor_image: Optional[Image.Image] = None,
|
| 252 |
+
image_prompt=None,
|
| 253 |
+
base_seed: int = 0,
|
| 254 |
+
seed_stride: int = 42,
|
| 255 |
+
partial_output_path: Optional[str] = None,
|
| 256 |
+
**call_kwargs,
|
| 257 |
+
):
|
| 258 |
+
"""Run ``num_clips`` chained I2V generations and return the concat.
|
| 259 |
+
|
| 260 |
+
``anchor_image`` is the persistent SVI reference frame. ``image_prompt``
|
| 261 |
+
is accepted for compatibility with the base I2V test harness.
|
| 262 |
+
"""
|
| 263 |
+
if num_clips < 1:
|
| 264 |
+
raise ValueError("num_clips must be >= 1")
|
| 265 |
+
call_kwargs.setdefault("output_type", "pt_with_last_latent")
|
| 266 |
+
|
| 267 |
+
defaults = _REGIMES[self._regime]["call_defaults"]
|
| 268 |
+
for k, v in defaults.items():
|
| 269 |
+
call_kwargs.setdefault(k, v)
|
| 270 |
+
|
| 271 |
+
anchor_pil = _as_anchor_image(anchor_image if anchor_image is not None else image_prompt)
|
| 272 |
+
clip_specs = _make_clip_specs(
|
| 273 |
+
prompt=prompt,
|
| 274 |
+
guidance_scale=call_kwargs.pop("guidance_scale"),
|
| 275 |
+
guidance_scale_2=call_kwargs.pop("guidance_scale_2"),
|
| 276 |
+
num_clips=num_clips,
|
| 277 |
+
base_seed=base_seed,
|
| 278 |
+
seed_stride=seed_stride,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
return self._generate_clips_loop(
|
| 283 |
+
clip_specs=clip_specs,
|
| 284 |
+
anchor_image=anchor_pil,
|
| 285 |
+
partial_output_path=partial_output_path,
|
| 286 |
+
call_kwargs=call_kwargs,
|
| 287 |
+
)
|
| 288 |
+
finally:
|
| 289 |
+
# Drop the anchor-encode cache so the next generate_long_video
|
| 290 |
+
# call can re-encode with a different anchor / shape.
|
| 291 |
+
self._anchor_latents_cache = None
|
| 292 |
+
|
| 293 |
+
def _generate_clips_loop(
|
| 294 |
+
self,
|
| 295 |
+
*,
|
| 296 |
+
clip_specs: List[_ClipSpec],
|
| 297 |
+
anchor_image: Image.Image,
|
| 298 |
+
partial_output_path,
|
| 299 |
+
call_kwargs,
|
| 300 |
+
):
|
| 301 |
+
prev_last_latent: Optional[torch.Tensor] = None
|
| 302 |
+
clips: List[torch.Tensor] = []
|
| 303 |
+
|
| 304 |
+
for clip_idx, spec in enumerate(clip_specs):
|
| 305 |
+
logger.info(
|
| 306 |
+
f"SVI clip {clip_idx + 1}/{len(clip_specs)} (seed={spec.seed}, "
|
| 307 |
+
f"num_motion_latent={self._num_motion_latent}, "
|
| 308 |
+
f"cfg={spec.guidance_scale}/{spec.guidance_scale_2}, "
|
| 309 |
+
f"prompt={spec.prompt[:80]!r}...)"
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
result = self(
|
| 313 |
+
prompts=[spec.prompt],
|
| 314 |
+
image_prompt=[ImagePrompt(image=anchor_image, frame_pos=0)],
|
| 315 |
+
seed=spec.seed,
|
| 316 |
+
guidance_scale=spec.guidance_scale,
|
| 317 |
+
guidance_scale_2=spec.guidance_scale_2,
|
| 318 |
+
anchor_image=anchor_image,
|
| 319 |
+
prev_last_latent=prev_last_latent,
|
| 320 |
+
**call_kwargs,
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
if not (isinstance(result, tuple) and len(result) == 2):
|
| 324 |
+
raise RuntimeError("SVI requires output_type='pt_with_last_latent' from the inner pipeline.")
|
| 325 |
+
frames, prev_last_latent = result
|
| 326 |
+
|
| 327 |
+
clips.append(frames[0] if frames.ndim == 5 else frames)
|
| 328 |
+
|
| 329 |
+
# Save partial concat so a mid-run hang still leaves a usable
|
| 330 |
+
# video covering the clips that completed.
|
| 331 |
+
if partial_output_path is not None:
|
| 332 |
+
partial = _concat_with_overlap(clips, overlap=self._num_overlap_frame)
|
| 333 |
+
torch.save(partial.detach(), partial_output_path)
|
| 334 |
+
logger.info(
|
| 335 |
+
f"saved partial output after clip {clip_idx + 1}/{len(clip_specs)} "
|
| 336 |
+
f"({partial.shape[0]} frames) to {partial_output_path}"
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
return _concat_with_overlap(clips, overlap=self._num_overlap_frame)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _make_clip_specs(
|
| 343 |
+
*,
|
| 344 |
+
prompt: Union[str, List[str]],
|
| 345 |
+
guidance_scale,
|
| 346 |
+
guidance_scale_2,
|
| 347 |
+
num_clips: int,
|
| 348 |
+
base_seed: int,
|
| 349 |
+
seed_stride: int,
|
| 350 |
+
) -> List[_ClipSpec]:
|
| 351 |
+
prompts = _broadcast_prompt(prompt, num_clips)
|
| 352 |
+
cfgs = _broadcast_float(guidance_scale, num_clips, "guidance_scale")
|
| 353 |
+
cfg2s = _broadcast_float(guidance_scale_2, num_clips, "guidance_scale_2")
|
| 354 |
+
return [
|
| 355 |
+
_ClipSpec(
|
| 356 |
+
prompt=prompts[i],
|
| 357 |
+
seed=base_seed + i * seed_stride,
|
| 358 |
+
guidance_scale=cfgs[i],
|
| 359 |
+
guidance_scale_2=cfg2s[i],
|
| 360 |
+
)
|
| 361 |
+
for i in range(num_clips)
|
| 362 |
+
]
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _broadcast_prompt(prompt: Union[str, List[str]], num_clips: int) -> List[str]:
|
| 366 |
+
if isinstance(prompt, list):
|
| 367 |
+
if len(prompt) != num_clips:
|
| 368 |
+
raise ValueError(f"prompt list length {len(prompt)} != num_clips {num_clips}")
|
| 369 |
+
return prompt
|
| 370 |
+
return [prompt] * num_clips
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _broadcast_float(value, num_clips: int, name: str) -> List[float]:
|
| 374 |
+
if isinstance(value, (list, tuple)):
|
| 375 |
+
if len(value) != num_clips:
|
| 376 |
+
raise ValueError(f"{name} list length {len(value)} != num_clips {num_clips}")
|
| 377 |
+
return [float(v) for v in value]
|
| 378 |
+
return [float(value)] * num_clips
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _as_anchor_image(value) -> Image.Image:
|
| 382 |
+
"""Accept a PIL image or single ImagePrompt/list wrapper from the I2V API."""
|
| 383 |
+
if isinstance(value, list):
|
| 384 |
+
if len(value) != 1:
|
| 385 |
+
raise ValueError("SVI supports a single anchor image; got list of length != 1")
|
| 386 |
+
return _as_anchor_image(value[0])
|
| 387 |
+
if isinstance(value, ImagePrompt):
|
| 388 |
+
return value.image
|
| 389 |
+
if not isinstance(value, Image.Image):
|
| 390 |
+
raise TypeError("SVI requires anchor_image or image_prompt to be a PIL image")
|
| 391 |
+
return value
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _concat_with_overlap(clips: List[torch.Tensor], *, overlap: int) -> torch.Tensor:
|
| 395 |
+
"""Temporal-concat decoded clips, dropping ``overlap`` boundary frames between adjacent clips."""
|
| 396 |
+
if not clips:
|
| 397 |
+
raise ValueError("no clips to concatenate")
|
| 398 |
+
pieces = [clips[0]] + [c[overlap:] if overlap > 0 else c for c in clips[1:]]
|
| 399 |
+
return torch.cat(pieces, dim=0)
|
code/models/tt_dit/experimental/tests/__init__.py
ADDED
|
File without changes
|
code/models/tt_dit/experimental/tests/conftest.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from loguru import logger
|
| 9 |
+
|
| 10 |
+
num_torch_threads = max(1, os.cpu_count())
|
| 11 |
+
logger.info(f"Setting torch num_threads to {num_torch_threads}")
|
| 12 |
+
torch.set_num_threads(num_torch_threads)
|
code/models/tt_dit/experimental/tests/test_adapter_loader.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Integration tests for the runtime-LoRA adapter loader.
|
| 6 |
+
|
| 7 |
+
Builds a tiny WanTransformer3DModel (1 block) with ``lora_enabled=True``,
|
| 8 |
+
writes a synthetic LoRA safetensors file, runs ``load_adapter_into``, and
|
| 9 |
+
checks that:
|
| 10 |
+
|
| 11 |
+
- every LoRA-aware Linear in the block got an adapter registered
|
| 12 |
+
- ``bind_active`` marks the right modules active across all paths
|
| 13 |
+
- ``unbind_active`` clears the active state
|
| 14 |
+
|
| 15 |
+
The forward math is covered by ``test_lora_variants.py``; this file
|
| 16 |
+
exercises the loader's key-mapping logic and the pipeline-mixin bind
|
| 17 |
+
flow against the default (``fuse``) mode.
|
| 18 |
+
|
| 19 |
+
Run with:
|
| 20 |
+
pytest -xvs models/tt_dit/experimental/tests/test_adapter_loader.py
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import tempfile
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
import torch
|
| 29 |
+
|
| 30 |
+
import ttnn
|
| 31 |
+
from models.tt_dit.experimental.lora.adapter_loader import load_adapter_into
|
| 32 |
+
from models.tt_dit.layers.lora import LoRAMixin
|
| 33 |
+
from models.tt_dit.models.transformers.wan2_2.transformer_wan import WanTransformer3DModel
|
| 34 |
+
from models.tt_dit.parallel.config import DiTParallelConfig, ParallelFactor
|
| 35 |
+
from models.tt_dit.parallel.manager import CCLManager
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _save_synthetic_adapter(
|
| 39 |
+
path: Path,
|
| 40 |
+
*,
|
| 41 |
+
num_blocks: int = 1,
|
| 42 |
+
dim: int = 128,
|
| 43 |
+
ffn_dim: int = 256,
|
| 44 |
+
rank: int = 8,
|
| 45 |
+
) -> None:
|
| 46 |
+
"""Write a fake LoRA safetensors that covers all 6 LoRA-targeted Linears
|
| 47 |
+
per block, using the lightx2v naming convention."""
|
| 48 |
+
from safetensors.torch import save_file
|
| 49 |
+
|
| 50 |
+
state: dict[str, torch.Tensor] = {}
|
| 51 |
+
dtype = torch.bfloat16
|
| 52 |
+
|
| 53 |
+
def add_pair(base: str, in_dim: int, out_dim: int):
|
| 54 |
+
state[f"{base}.lora_A.weight"] = torch.randn(rank, in_dim, dtype=dtype) * 0.02
|
| 55 |
+
state[f"{base}.lora_B.weight"] = torch.randn(out_dim, rank, dtype=dtype) * 0.02
|
| 56 |
+
|
| 57 |
+
for i in range(num_blocks):
|
| 58 |
+
# self-attn Q, K, V, O
|
| 59 |
+
add_pair(f"blocks.{i}.self_attn.q", dim, dim)
|
| 60 |
+
add_pair(f"blocks.{i}.self_attn.k", dim, dim)
|
| 61 |
+
add_pair(f"blocks.{i}.self_attn.v", dim, dim)
|
| 62 |
+
add_pair(f"blocks.{i}.self_attn.o", dim, dim)
|
| 63 |
+
# cross-attn Q, K, V, O
|
| 64 |
+
add_pair(f"blocks.{i}.cross_attn.q", dim, dim)
|
| 65 |
+
add_pair(f"blocks.{i}.cross_attn.k", dim, dim)
|
| 66 |
+
add_pair(f"blocks.{i}.cross_attn.v", dim, dim)
|
| 67 |
+
add_pair(f"blocks.{i}.cross_attn.o", dim, dim)
|
| 68 |
+
# ffn ff1, ff2 (lightx2v naming: ffn.0 = ff1, ffn.2 = ff2)
|
| 69 |
+
add_pair(f"blocks.{i}.ffn.0", dim, ffn_dim)
|
| 70 |
+
add_pair(f"blocks.{i}.ffn.2", ffn_dim, dim)
|
| 71 |
+
|
| 72 |
+
save_file(state, str(path))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _count_registered(transformer) -> int:
|
| 76 |
+
"""Count LoRA modules with at least one entry in their bank."""
|
| 77 |
+
n = 0
|
| 78 |
+
for mod in _iter_lora_modules(transformer):
|
| 79 |
+
if any(a is not None for a in mod.lora_bank):
|
| 80 |
+
n += 1
|
| 81 |
+
return n
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _iter_lora_modules(module):
|
| 85 |
+
if isinstance(module, LoRAMixin):
|
| 86 |
+
yield module
|
| 87 |
+
for _, child in module.named_children():
|
| 88 |
+
yield from _iter_lora_modules(child)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@pytest.mark.parametrize("mesh_device", [(1, 1)], indirect=True)
|
| 92 |
+
def test_adapter_loader_registers_all_lora_linears(mesh_device: ttnn.MeshDevice) -> None:
|
| 93 |
+
"""Loader picks up Q/K/V/O for both attn1/attn2 plus ff1/ff2 per block."""
|
| 94 |
+
num_heads = 4
|
| 95 |
+
head_dim = 32
|
| 96 |
+
dim = num_heads * head_dim
|
| 97 |
+
ffn_dim = 2 * dim
|
| 98 |
+
rank = 8
|
| 99 |
+
num_blocks = 1
|
| 100 |
+
|
| 101 |
+
parallel_config = DiTParallelConfig(
|
| 102 |
+
cfg_parallel=ParallelFactor(factor=1, mesh_axis=0),
|
| 103 |
+
tensor_parallel=ParallelFactor(factor=1, mesh_axis=1),
|
| 104 |
+
sequence_parallel=ParallelFactor(factor=1, mesh_axis=0),
|
| 105 |
+
)
|
| 106 |
+
ccl_manager = CCLManager(mesh_device, topology=ttnn.Topology.Linear)
|
| 107 |
+
|
| 108 |
+
transformer = WanTransformer3DModel(
|
| 109 |
+
patch_size=(1, 2, 2),
|
| 110 |
+
num_heads=num_heads,
|
| 111 |
+
dim=dim,
|
| 112 |
+
in_channels=16,
|
| 113 |
+
out_channels=16,
|
| 114 |
+
text_dim=128,
|
| 115 |
+
freq_dim=64,
|
| 116 |
+
ffn_dim=ffn_dim,
|
| 117 |
+
num_layers=num_blocks,
|
| 118 |
+
mesh_device=mesh_device,
|
| 119 |
+
ccl_manager=ccl_manager,
|
| 120 |
+
parallel_config=parallel_config,
|
| 121 |
+
is_fsdp=False,
|
| 122 |
+
lora_enabled=True,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Every block should have 5 LoRA-aware Linears wired in: to_qkv (self),
|
| 126 |
+
# to_q (cross), to_kv (cross), to_out for each attention (×2), ff1, ff2.
|
| 127 |
+
# That's 1 + 1 + 1 + 2 + 1 + 1 = 7 modules per block.
|
| 128 |
+
expected_per_block = 7
|
| 129 |
+
n_modules = sum(1 for _ in _iter_lora_modules(transformer))
|
| 130 |
+
assert (
|
| 131 |
+
n_modules == num_blocks * expected_per_block
|
| 132 |
+
), f"expected {num_blocks * expected_per_block} LoRA modules; got {n_modules}"
|
| 133 |
+
|
| 134 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 135 |
+
adapter_path = Path(tmp) / "synthetic.safetensors"
|
| 136 |
+
_save_synthetic_adapter(adapter_path, num_blocks=num_blocks, dim=dim, ffn_dim=ffn_dim, rank=rank)
|
| 137 |
+
|
| 138 |
+
handle = load_adapter_into(transformer, str(adapter_path), scale=1.0, name="synthetic")
|
| 139 |
+
|
| 140 |
+
# All 7 LoRA modules per block should have a registered adapter.
|
| 141 |
+
registered = _count_registered(transformer)
|
| 142 |
+
assert (
|
| 143 |
+
registered == num_blocks * expected_per_block
|
| 144 |
+
), f"expected {num_blocks * expected_per_block} registered adapters; got {registered}"
|
| 145 |
+
|
| 146 |
+
# Fused-QKV (to_qkv self-attn): rank should be 3*r. Fused KV (cross-attn
|
| 147 |
+
# to_kv): rank should be 2*r. Singletons: rank = r.
|
| 148 |
+
block0 = transformer.blocks[0]
|
| 149 |
+
assert block0.attn1.to_qkv.lora_bank[0].rank == 3 * rank
|
| 150 |
+
assert block0.attn2.to_kv.lora_bank[0].rank == 2 * rank
|
| 151 |
+
assert block0.attn2.to_q.lora_bank[0].rank == rank
|
| 152 |
+
assert block0.attn1.to_out.lora_bank[0].rank == rank
|
| 153 |
+
assert block0.attn2.to_out.lora_bank[0].rank == rank
|
| 154 |
+
assert block0.ffn.ff1.lora_bank[0].rank == rank
|
| 155 |
+
assert block0.ffn.ff2.lora_bank[0].rank == rank
|
| 156 |
+
|
| 157 |
+
# handle.rank tracks the max rank across targets (3*rank for the fused-QKV group).
|
| 158 |
+
assert handle.rank == 3 * rank
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@pytest.mark.parametrize("mesh_device", [(1, 1)], indirect=True)
|
| 162 |
+
def test_pipeline_bind_unbind_walks_all_modules(mesh_device: ttnn.MeshDevice) -> None:
|
| 163 |
+
"""The pipeline mixin's bind/unbind walks every LoRA module on each set_active_lora."""
|
| 164 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_runtime_lora import _iter_lora_modules as pipeline_iter
|
| 165 |
+
|
| 166 |
+
num_heads = 4
|
| 167 |
+
head_dim = 32
|
| 168 |
+
dim = num_heads * head_dim
|
| 169 |
+
ffn_dim = 2 * dim
|
| 170 |
+
rank = 8
|
| 171 |
+
|
| 172 |
+
parallel_config = DiTParallelConfig(
|
| 173 |
+
cfg_parallel=ParallelFactor(factor=1, mesh_axis=0),
|
| 174 |
+
tensor_parallel=ParallelFactor(factor=1, mesh_axis=1),
|
| 175 |
+
sequence_parallel=ParallelFactor(factor=1, mesh_axis=0),
|
| 176 |
+
)
|
| 177 |
+
ccl_manager = CCLManager(mesh_device, topology=ttnn.Topology.Linear)
|
| 178 |
+
|
| 179 |
+
transformer = WanTransformer3DModel(
|
| 180 |
+
patch_size=(1, 2, 2),
|
| 181 |
+
num_heads=num_heads,
|
| 182 |
+
dim=dim,
|
| 183 |
+
in_channels=16,
|
| 184 |
+
out_channels=16,
|
| 185 |
+
text_dim=128,
|
| 186 |
+
freq_dim=64,
|
| 187 |
+
ffn_dim=ffn_dim,
|
| 188 |
+
num_layers=1,
|
| 189 |
+
mesh_device=mesh_device,
|
| 190 |
+
ccl_manager=ccl_manager,
|
| 191 |
+
parallel_config=parallel_config,
|
| 192 |
+
is_fsdp=False,
|
| 193 |
+
lora_enabled=True,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 197 |
+
adapter_path = Path(tmp) / "synthetic.safetensors"
|
| 198 |
+
_save_synthetic_adapter(adapter_path, num_blocks=1, dim=dim, ffn_dim=ffn_dim, rank=rank)
|
| 199 |
+
handle = load_adapter_into(transformer, str(adapter_path), name="synthetic")
|
| 200 |
+
|
| 201 |
+
# Simulate the bind path the pipeline mixin runs.
|
| 202 |
+
for dotted, bank_idx in handle.target_indices.items():
|
| 203 |
+
cur = transformer
|
| 204 |
+
for part in dotted.split("."):
|
| 205 |
+
cur = cur[int(part)] if part.isdigit() else getattr(cur, part)
|
| 206 |
+
cur.bind_active(bank_idx)
|
| 207 |
+
|
| 208 |
+
# All modules covered by the handle should be active (delta merged).
|
| 209 |
+
for dotted in handle.target_indices:
|
| 210 |
+
cur = transformer
|
| 211 |
+
for part in dotted.split("."):
|
| 212 |
+
cur = cur[int(part)] if part.isdigit() else getattr(cur, part)
|
| 213 |
+
assert cur.is_lora_active, f"{dotted} not active after bind_active"
|
| 214 |
+
|
| 215 |
+
# Unbind everything → delta subtracted, is_lora_active False.
|
| 216 |
+
for mod in pipeline_iter(transformer):
|
| 217 |
+
if mod.is_lora_active:
|
| 218 |
+
mod.unbind_active()
|
| 219 |
+
for dotted in handle.target_indices:
|
| 220 |
+
cur = transformer
|
| 221 |
+
for part in dotted.split("."):
|
| 222 |
+
cur = cur[int(part)] if part.isdigit() else getattr(cur, part)
|
| 223 |
+
assert not cur.is_lora_active
|
| 224 |
+
assert cur.active_idx is None
|
code/models/tt_dit/experimental/tests/test_lora_variants.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Parity tests for the three LoRA-aware Linear subclasses, each exercised
|
| 6 |
+
under both ``lora_mode`` options:
|
| 7 |
+
|
| 8 |
+
fuse — bind merges ``scale * A.T @ B.T`` into the base weight in-place.
|
| 9 |
+
Forward runs the un-modified base path.
|
| 10 |
+
runtime — bind uploads A, B to device; forward adds
|
| 11 |
+
``scale * (x @ A) @ B`` on top of the base output.
|
| 12 |
+
|
| 13 |
+
The unbind branch is checked in both modes — for ``fuse`` it subtracts
|
| 14 |
+
the delta back out of W; for ``runtime`` it frees the A/B tensors so the
|
| 15 |
+
forward path collapses to the base.
|
| 16 |
+
|
| 17 |
+
Mesh shape is 1x1 so sharding collapses to the replicated case for all
|
| 18 |
+
three variants. The tests still exercise the LoRA-specific upload and
|
| 19 |
+
forward code paths and catch class-level bugs in shape handling,
|
| 20 |
+
fused-activation fallback, and chunked-output handling.
|
| 21 |
+
|
| 22 |
+
Run with:
|
| 23 |
+
pytest -xvs models/tt_dit/experimental/tests/test_lora_variants.py
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
import torch
|
| 29 |
+
|
| 30 |
+
import ttnn
|
| 31 |
+
from models.tt_dit.layers.linear import LoRAColParallelLinear, LoRALinear, LoRARowParallelLinear
|
| 32 |
+
from models.tt_dit.utils.check import assert_quality
|
| 33 |
+
from models.tt_dit.utils.tensor import bf16_tensor
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _make_pair(in_features: int, out_features: int, rank: int, dtype: torch.dtype):
|
| 37 |
+
A = torch.randn(rank, in_features, dtype=dtype) * 0.02
|
| 38 |
+
B = torch.randn(out_features, rank, dtype=dtype) * 0.02
|
| 39 |
+
return A, B
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _ref_lora(x, W, b, A, B, scale):
|
| 43 |
+
"""y = x @ (W + scale * B @ A).T + b"""
|
| 44 |
+
W_eff = W + scale * (B @ A)
|
| 45 |
+
return torch.nn.functional.linear(x, W_eff, b)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# --------------------------------------------------------------------
|
| 49 |
+
# LoRALinear (replicated)
|
| 50 |
+
# --------------------------------------------------------------------
|
| 51 |
+
@pytest.mark.parametrize("mesh_device", [(1, 1)], indirect=True)
|
| 52 |
+
@pytest.mark.parametrize("lora_mode", ["fuse", "runtime"])
|
| 53 |
+
@pytest.mark.parametrize(
|
| 54 |
+
("seq_len", "in_features", "out_features", "rank"),
|
| 55 |
+
[
|
| 56 |
+
(128, 256, 256, 8),
|
| 57 |
+
(256, 512, 1024, 16),
|
| 58 |
+
],
|
| 59 |
+
)
|
| 60 |
+
def test_lora_linear_variant(
|
| 61 |
+
mesh_device: ttnn.MeshDevice,
|
| 62 |
+
lora_mode: str,
|
| 63 |
+
seq_len: int,
|
| 64 |
+
in_features: int,
|
| 65 |
+
out_features: int,
|
| 66 |
+
rank: int,
|
| 67 |
+
) -> None:
|
| 68 |
+
torch.manual_seed(0)
|
| 69 |
+
dtype = torch.bfloat16
|
| 70 |
+
scale = 0.6
|
| 71 |
+
|
| 72 |
+
torch_lin = torch.nn.Linear(in_features, out_features, bias=True).to(dtype=dtype)
|
| 73 |
+
torch_lin.eval()
|
| 74 |
+
A_t, B_t = _make_pair(in_features, out_features, rank, dtype)
|
| 75 |
+
x_t = torch.randn(1, 1, seq_len, in_features, dtype=dtype)
|
| 76 |
+
|
| 77 |
+
with torch.no_grad():
|
| 78 |
+
y_base = torch_lin(x_t)
|
| 79 |
+
y_lora = _ref_lora(x_t, torch_lin.weight, torch_lin.bias, A_t, B_t, scale)
|
| 80 |
+
|
| 81 |
+
tt = LoRALinear(in_features, out_features, bias=True, mesh_device=mesh_device, lora_mode=lora_mode)
|
| 82 |
+
tt.load_torch_state_dict(torch_lin.state_dict())
|
| 83 |
+
idx = tt.register_lora(A_t, B_t, scale=scale)
|
| 84 |
+
x_tt = bf16_tensor(x_t, device=mesh_device)
|
| 85 |
+
|
| 86 |
+
# 1) no adapter bound → base behavior
|
| 87 |
+
y = tt(x_tt)
|
| 88 |
+
for t in ttnn.get_device_tensors(y):
|
| 89 |
+
assert_quality(y_base, ttnn.to_torch(t), pcc=0.999_5)
|
| 90 |
+
|
| 91 |
+
# 2) bind active → LoRA effect applied
|
| 92 |
+
tt.bind_active(idx, scale=scale)
|
| 93 |
+
y = tt(x_tt)
|
| 94 |
+
for t in ttnn.get_device_tensors(y):
|
| 95 |
+
assert_quality(y_lora, ttnn.to_torch(t), pcc=0.999_0)
|
| 96 |
+
|
| 97 |
+
# 3) unbind collapses back to base
|
| 98 |
+
tt.unbind_active()
|
| 99 |
+
y = tt(x_tt)
|
| 100 |
+
for t in ttnn.get_device_tensors(y):
|
| 101 |
+
assert_quality(y_base, ttnn.to_torch(t), pcc=0.999_5)
|
| 102 |
+
|
| 103 |
+
tt.deallocate_lora()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# --------------------------------------------------------------------
|
| 107 |
+
# LoRAColParallelLinear — single output and chunked output
|
| 108 |
+
# --------------------------------------------------------------------
|
| 109 |
+
# Chunked configs run fuse-only: the runtime delta path doesn't split
|
| 110 |
+
# its delta into the same chunk layout as the base output.
|
| 111 |
+
_COL_PARALLEL_PARAMS = [
|
| 112 |
+
# (label, lora_mode, seq, in, out, rank, chunks, activation_fn)
|
| 113 |
+
("plain_fuse", "fuse", 128, 256, 512, 8, None, None),
|
| 114 |
+
("plain_runtime", "runtime", 128, 256, 512, 8, None, None),
|
| 115 |
+
("gelu_fuse", "fuse", 128, 256, 1024, 8, None, "gelu"),
|
| 116 |
+
("gelu_runtime", "runtime", 128, 256, 1024, 8, None, "gelu"),
|
| 117 |
+
("chunked_qkv_fuse", "fuse", 128, 256, 3 * 256, 8, 3, None),
|
| 118 |
+
]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@pytest.mark.parametrize("mesh_device", [(1, 1)], indirect=True)
|
| 122 |
+
@pytest.mark.parametrize(
|
| 123 |
+
("lora_mode", "seq_len", "in_features", "out_features", "rank", "chunks", "activation_fn"),
|
| 124 |
+
[params[1:] for params in _COL_PARALLEL_PARAMS],
|
| 125 |
+
ids=[params[0] for params in _COL_PARALLEL_PARAMS],
|
| 126 |
+
)
|
| 127 |
+
def test_lora_col_parallel_variant(
|
| 128 |
+
mesh_device: ttnn.MeshDevice,
|
| 129 |
+
lora_mode: str,
|
| 130 |
+
seq_len: int,
|
| 131 |
+
in_features: int,
|
| 132 |
+
out_features: int,
|
| 133 |
+
rank: int,
|
| 134 |
+
chunks: int | None,
|
| 135 |
+
activation_fn: str | None,
|
| 136 |
+
) -> None:
|
| 137 |
+
torch.manual_seed(0)
|
| 138 |
+
dtype = torch.bfloat16
|
| 139 |
+
scale = 0.5
|
| 140 |
+
|
| 141 |
+
torch_lin = torch.nn.Linear(in_features, out_features, bias=True).to(dtype=dtype)
|
| 142 |
+
torch_lin.eval()
|
| 143 |
+
A_t, B_t = _make_pair(in_features, out_features, rank, dtype)
|
| 144 |
+
x_t = torch.randn(1, 1, seq_len, in_features, dtype=dtype)
|
| 145 |
+
|
| 146 |
+
def torch_act(y):
|
| 147 |
+
if activation_fn == "gelu":
|
| 148 |
+
return torch.nn.functional.gelu(y)
|
| 149 |
+
return y
|
| 150 |
+
|
| 151 |
+
with torch.no_grad():
|
| 152 |
+
y_base = torch_act(torch_lin(x_t))
|
| 153 |
+
y_lora = torch_act(_ref_lora(x_t, torch_lin.weight, torch_lin.bias, A_t, B_t, scale))
|
| 154 |
+
|
| 155 |
+
tt = LoRAColParallelLinear(
|
| 156 |
+
in_features,
|
| 157 |
+
out_features,
|
| 158 |
+
bias=True,
|
| 159 |
+
mesh_device=mesh_device,
|
| 160 |
+
mesh_axis=0,
|
| 161 |
+
activation_fn=activation_fn,
|
| 162 |
+
chunks=chunks,
|
| 163 |
+
lora_mode=lora_mode,
|
| 164 |
+
)
|
| 165 |
+
tt.load_torch_state_dict(torch_lin.state_dict())
|
| 166 |
+
idx = tt.register_lora(A_t, B_t, scale=scale)
|
| 167 |
+
x_tt = bf16_tensor(x_t, device=mesh_device)
|
| 168 |
+
|
| 169 |
+
def _join(out):
|
| 170 |
+
"""Concat chunked outputs along last dim; pass through single tensors."""
|
| 171 |
+
if isinstance(out, list):
|
| 172 |
+
torch_chunks = [ttnn.to_torch(ttnn.get_device_tensors(o)[0]) for o in out]
|
| 173 |
+
return torch.cat(torch_chunks, dim=-1)
|
| 174 |
+
return ttnn.to_torch(ttnn.get_device_tensors(out)[0])
|
| 175 |
+
|
| 176 |
+
# 1) no adapter bound → base behavior
|
| 177 |
+
assert_quality(y_base, _join(tt(x_tt)), pcc=0.999_5)
|
| 178 |
+
|
| 179 |
+
# 2) bind active → LoRA effect applied
|
| 180 |
+
tt.bind_active(idx, scale=scale)
|
| 181 |
+
assert_quality(y_lora, _join(tt(x_tt)), pcc=0.999_0)
|
| 182 |
+
|
| 183 |
+
# 3) unbind collapses to base
|
| 184 |
+
tt.unbind_active()
|
| 185 |
+
assert_quality(y_base, _join(tt(x_tt)), pcc=0.999_0)
|
| 186 |
+
|
| 187 |
+
tt.deallocate_lora()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# --------------------------------------------------------------------
|
| 191 |
+
# LoRARowParallelLinear — N_tp=1 (1x1 mesh) sanity
|
| 192 |
+
# --------------------------------------------------------------------
|
| 193 |
+
@pytest.mark.parametrize("mesh_device", [(1, 1)], indirect=True)
|
| 194 |
+
@pytest.mark.parametrize("lora_mode", ["fuse", "runtime"])
|
| 195 |
+
@pytest.mark.parametrize(
|
| 196 |
+
("seq_len", "in_features", "out_features", "rank"),
|
| 197 |
+
[
|
| 198 |
+
(128, 512, 256, 8),
|
| 199 |
+
(256, 1024, 512, 16),
|
| 200 |
+
],
|
| 201 |
+
)
|
| 202 |
+
def test_lora_row_parallel_variant(
|
| 203 |
+
mesh_device: ttnn.MeshDevice,
|
| 204 |
+
lora_mode: str,
|
| 205 |
+
seq_len: int,
|
| 206 |
+
in_features: int,
|
| 207 |
+
out_features: int,
|
| 208 |
+
rank: int,
|
| 209 |
+
) -> None:
|
| 210 |
+
from models.tt_dit.parallel.manager import CCLManager
|
| 211 |
+
|
| 212 |
+
torch.manual_seed(0)
|
| 213 |
+
dtype = torch.bfloat16
|
| 214 |
+
scale = 0.4
|
| 215 |
+
|
| 216 |
+
torch_lin = torch.nn.Linear(in_features, out_features, bias=True).to(dtype=dtype)
|
| 217 |
+
torch_lin.eval()
|
| 218 |
+
A_t, B_t = _make_pair(in_features, out_features, rank, dtype)
|
| 219 |
+
x_t = torch.randn(1, 1, seq_len, in_features, dtype=dtype)
|
| 220 |
+
|
| 221 |
+
with torch.no_grad():
|
| 222 |
+
y_base = torch_lin(x_t)
|
| 223 |
+
y_lora = _ref_lora(x_t, torch_lin.weight, torch_lin.bias, A_t, B_t, scale)
|
| 224 |
+
|
| 225 |
+
ccl_manager = CCLManager(mesh_device, topology=ttnn.Topology.Linear)
|
| 226 |
+
tt = LoRARowParallelLinear(
|
| 227 |
+
in_features,
|
| 228 |
+
out_features,
|
| 229 |
+
bias=True,
|
| 230 |
+
mesh_device=mesh_device,
|
| 231 |
+
mesh_axis=0,
|
| 232 |
+
ccl_manager=ccl_manager,
|
| 233 |
+
lora_mode=lora_mode,
|
| 234 |
+
)
|
| 235 |
+
tt.load_torch_state_dict(torch_lin.state_dict())
|
| 236 |
+
idx = tt.register_lora(A_t, B_t, scale=scale)
|
| 237 |
+
x_tt = bf16_tensor(x_t, device=mesh_device)
|
| 238 |
+
|
| 239 |
+
# baseline
|
| 240 |
+
y = tt(x_tt)
|
| 241 |
+
assert_quality(y_base, ttnn.to_torch(ttnn.get_device_tensors(y)[0]), pcc=0.999_5)
|
| 242 |
+
|
| 243 |
+
# bind active
|
| 244 |
+
tt.bind_active(idx, scale=scale)
|
| 245 |
+
y = tt(x_tt)
|
| 246 |
+
assert_quality(y_lora, ttnn.to_torch(ttnn.get_device_tensors(y)[0]), pcc=0.999_0)
|
| 247 |
+
|
| 248 |
+
# unbind
|
| 249 |
+
tt.unbind_active()
|
| 250 |
+
y = tt(x_tt)
|
| 251 |
+
assert_quality(y_base, ttnn.to_torch(ttnn.get_device_tensors(y)[0]), pcc=0.999_5)
|
| 252 |
+
|
| 253 |
+
tt.deallocate_lora()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# --------------------------------------------------------------------
|
| 257 |
+
# Construction-time validation
|
| 258 |
+
# --------------------------------------------------------------------
|
| 259 |
+
@pytest.mark.parametrize("mesh_device", [(1, 1)], indirect=True)
|
| 260 |
+
def test_invalid_lora_mode_raises(mesh_device: ttnn.MeshDevice, expect_error) -> None:
|
| 261 |
+
with expect_error(ValueError, "lora_mode"):
|
| 262 |
+
LoRALinear(64, 64, bias=False, mesh_device=mesh_device, lora_mode="bogus")
|
code/models/tt_dit/experimental/tests/test_pipeline_anisora.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent USA, Inc.
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import itertools
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import PIL
|
| 10 |
+
import pytest
|
| 11 |
+
import torch
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
import ttnn
|
| 15 |
+
from models.tt_dit.experimental.pipelines.pipeline_anisora import AniSoraPipeline
|
| 16 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import ImagePrompt
|
| 17 |
+
from models.tt_dit.utils.test import ring_params
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.mark.parametrize(
|
| 21 |
+
"no_prompt",
|
| 22 |
+
[{"1": True, "0": False}.get(os.environ.get("NO_PROMPT"), False)],
|
| 23 |
+
)
|
| 24 |
+
@pytest.mark.parametrize(
|
| 25 |
+
"mesh_device, mesh_shape, num_links, dynamic_load, device_params, topology, is_fsdp",
|
| 26 |
+
[
|
| 27 |
+
# BH Galaxy 4x8 Ring — sole supported config in the first AniSora PR.
|
| 28 |
+
[(4, 8), (4, 8), 2, False, ring_params, ttnn.Topology.Ring, False],
|
| 29 |
+
],
|
| 30 |
+
ids=["bh_4x8sp1tp0_ring"],
|
| 31 |
+
indirect=["mesh_device", "device_params"],
|
| 32 |
+
)
|
| 33 |
+
@pytest.mark.parametrize(
|
| 34 |
+
"width, height",
|
| 35 |
+
[
|
| 36 |
+
(832, 480),
|
| 37 |
+
(1280, 720),
|
| 38 |
+
],
|
| 39 |
+
ids=[
|
| 40 |
+
"resolution_480p",
|
| 41 |
+
"resolution_720p",
|
| 42 |
+
],
|
| 43 |
+
)
|
| 44 |
+
def test_pipeline_inference(
|
| 45 |
+
mesh_device,
|
| 46 |
+
mesh_shape,
|
| 47 |
+
num_links,
|
| 48 |
+
dynamic_load,
|
| 49 |
+
topology,
|
| 50 |
+
width,
|
| 51 |
+
height,
|
| 52 |
+
is_fsdp,
|
| 53 |
+
no_prompt,
|
| 54 |
+
):
|
| 55 |
+
parent_mesh = mesh_device
|
| 56 |
+
mesh_device = parent_mesh.create_submesh(ttnn.MeshShape(*mesh_shape))
|
| 57 |
+
|
| 58 |
+
pil_image = PIL.Image.open(os.environ.get("PROMPT_IMAGE", "./prompt_image.png"))
|
| 59 |
+
image_prompt = [ImagePrompt(image=pil_image, frame_pos=0)]
|
| 60 |
+
negative_prompt = ""
|
| 61 |
+
|
| 62 |
+
# AniSora upstream defaults (anisoraV3.2/wan/configs/wan_i2v_A14B.py):
|
| 63 |
+
# sample_steps=40, boundary=0.9, sample_guide_scale=(3.5, 3.5).
|
| 64 |
+
num_frames = 81
|
| 65 |
+
num_inference_steps = int(os.environ.get("NUM_STEPS", "40"))
|
| 66 |
+
guidance_scale = 3.5
|
| 67 |
+
guidance_scale_2 = 3.5
|
| 68 |
+
|
| 69 |
+
pipeline = AniSoraPipeline.create_pipeline(
|
| 70 |
+
mesh_device=mesh_device,
|
| 71 |
+
height=height,
|
| 72 |
+
width=width,
|
| 73 |
+
num_frames=num_frames,
|
| 74 |
+
config_overrides={
|
| 75 |
+
"num_links": num_links,
|
| 76 |
+
"dynamic_load": dynamic_load,
|
| 77 |
+
"topology": topology,
|
| 78 |
+
"is_fsdp": is_fsdp,
|
| 79 |
+
},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
prompt = "An anime girl smiling, soft lighting, cinematic."
|
| 83 |
+
|
| 84 |
+
def run(*, prompt, number, seed):
|
| 85 |
+
logger.info(f"Running AniSora inference with prompt: '{prompt}'")
|
| 86 |
+
logger.info(f"Parameters: {height}x{width}, {num_frames} frames, {num_inference_steps} steps")
|
| 87 |
+
|
| 88 |
+
with torch.no_grad():
|
| 89 |
+
result = pipeline(
|
| 90 |
+
prompts=[prompt],
|
| 91 |
+
image_prompt=image_prompt,
|
| 92 |
+
negative_prompts=[negative_prompt],
|
| 93 |
+
num_inference_steps=num_inference_steps,
|
| 94 |
+
seed=seed,
|
| 95 |
+
guidance_scale=guidance_scale,
|
| 96 |
+
guidance_scale_2=guidance_scale_2,
|
| 97 |
+
output_type="uint8",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if hasattr(result, "frames"):
|
| 101 |
+
frames = result.frames
|
| 102 |
+
else:
|
| 103 |
+
frames = result[0] if isinstance(result, tuple) else result
|
| 104 |
+
|
| 105 |
+
logger.info("AniSora inference completed successfully")
|
| 106 |
+
logger.info(f" Output shape: {frames.shape if hasattr(frames, 'shape') else 'Unknown'}")
|
| 107 |
+
logger.info(f" Output type: {type(frames)}")
|
| 108 |
+
|
| 109 |
+
if isinstance(frames, np.ndarray):
|
| 110 |
+
logger.info(f" Video data range: [{frames.min():.3f}, {frames.max():.3f}]")
|
| 111 |
+
elif isinstance(frames, torch.Tensor):
|
| 112 |
+
logger.info(f" Video data range: [{frames.min().item():.3f}, {frames.max().item():.3f}]")
|
| 113 |
+
|
| 114 |
+
frames = frames[0]
|
| 115 |
+
output_filename = f"wan_anisora_i2v_{width}x{height}_{number}.mp4"
|
| 116 |
+
try:
|
| 117 |
+
from models.tt_dit.utils.video import export_to_video
|
| 118 |
+
|
| 119 |
+
export_to_video(frames, output_filename, fps=16)
|
| 120 |
+
logger.info(f"Saved video to: {output_filename}")
|
| 121 |
+
except ImportError:
|
| 122 |
+
logger.info("Could not export video - imageio_ffmpeg not available")
|
| 123 |
+
|
| 124 |
+
if no_prompt:
|
| 125 |
+
run(prompt=prompt, number=0, seed=42)
|
| 126 |
+
else:
|
| 127 |
+
for i in itertools.count():
|
| 128 |
+
new_prompt = input("Enter the input prompt, or q to exit: ")
|
| 129 |
+
if new_prompt:
|
| 130 |
+
prompt = new_prompt
|
| 131 |
+
if prompt[0] == "q":
|
| 132 |
+
break
|
| 133 |
+
run(prompt=prompt, number=i, seed=i)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# Random-weight smoke test
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# Runs the full pipeline end-to-end without downloading the AniSora safetensors
|
| 140 |
+
# or the two ~28 GB transformer subfolders from Wan-AI. Tokenizer / UMT5 text
|
| 141 |
+
# encoder / VAE / scheduler still come from Wan-AI/Wan2.2-I2V-A14B-Diffusers
|
| 142 |
+
# (~12 GB total) and require TT_DIT_ALLOW_HF_DOWNLOAD=1 the first time.
|
| 143 |
+
# Output frames are garbage by design — this only validates compile / mesh /
|
| 144 |
+
# shape / CCL plumbing, not numeric quality. Uses 4 sampling steps to keep the
|
| 145 |
+
# smoke test short; the 40-step real-weights config is exercised above.
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@pytest.mark.parametrize(
|
| 149 |
+
"mesh_device, mesh_shape, num_links, dynamic_load, device_params, topology, is_fsdp",
|
| 150 |
+
[
|
| 151 |
+
[(4, 8), (4, 8), 2, False, ring_params, ttnn.Topology.Ring, False],
|
| 152 |
+
],
|
| 153 |
+
ids=["bh_4x8sp1tp0_ring"],
|
| 154 |
+
indirect=["mesh_device", "device_params"],
|
| 155 |
+
)
|
| 156 |
+
@pytest.mark.parametrize(
|
| 157 |
+
"width, height",
|
| 158 |
+
[
|
| 159 |
+
(832, 480),
|
| 160 |
+
],
|
| 161 |
+
ids=["resolution_480p"],
|
| 162 |
+
)
|
| 163 |
+
def test_pipeline_inference_random_weights(
|
| 164 |
+
mesh_device,
|
| 165 |
+
mesh_shape,
|
| 166 |
+
num_links,
|
| 167 |
+
dynamic_load,
|
| 168 |
+
topology,
|
| 169 |
+
width,
|
| 170 |
+
height,
|
| 171 |
+
is_fsdp,
|
| 172 |
+
):
|
| 173 |
+
parent_mesh = mesh_device
|
| 174 |
+
mesh_device = parent_mesh.create_submesh(ttnn.MeshShape(*mesh_shape))
|
| 175 |
+
|
| 176 |
+
pil_image = PIL.Image.open(os.environ.get("PROMPT_IMAGE", "./prompt_image.png"))
|
| 177 |
+
image_prompt = [ImagePrompt(image=pil_image, frame_pos=0)]
|
| 178 |
+
|
| 179 |
+
num_frames = 81
|
| 180 |
+
num_inference_steps = 4
|
| 181 |
+
|
| 182 |
+
os.environ["TT_DIT_RANDOM_WEIGHTS"] = "1"
|
| 183 |
+
try:
|
| 184 |
+
pipeline = AniSoraPipeline.create_pipeline(
|
| 185 |
+
mesh_device=mesh_device,
|
| 186 |
+
height=height,
|
| 187 |
+
width=width,
|
| 188 |
+
num_frames=num_frames,
|
| 189 |
+
config_overrides={
|
| 190 |
+
"num_links": num_links,
|
| 191 |
+
"dynamic_load": dynamic_load,
|
| 192 |
+
"topology": topology,
|
| 193 |
+
"is_fsdp": is_fsdp,
|
| 194 |
+
},
|
| 195 |
+
)
|
| 196 |
+
finally:
|
| 197 |
+
os.environ.pop("TT_DIT_RANDOM_WEIGHTS", None)
|
| 198 |
+
|
| 199 |
+
prompt = "smoke test — random weights, output is meaningless"
|
| 200 |
+
logger.info(f"Random-weight smoke test: {height}x{width}, {num_frames} frames, {num_inference_steps} steps")
|
| 201 |
+
|
| 202 |
+
with torch.no_grad():
|
| 203 |
+
result = pipeline(
|
| 204 |
+
prompts=[prompt],
|
| 205 |
+
image_prompt=image_prompt,
|
| 206 |
+
negative_prompts=[""],
|
| 207 |
+
num_inference_steps=num_inference_steps,
|
| 208 |
+
seed=42,
|
| 209 |
+
guidance_scale=3.5,
|
| 210 |
+
guidance_scale_2=3.5,
|
| 211 |
+
output_type="uint8",
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
if hasattr(result, "frames"):
|
| 215 |
+
frames = result.frames
|
| 216 |
+
else:
|
| 217 |
+
frames = result[0] if isinstance(result, tuple) else result
|
| 218 |
+
|
| 219 |
+
if isinstance(frames, np.ndarray):
|
| 220 |
+
rng_str = f"[{frames.min():.3f}, {frames.max():.3f}]"
|
| 221 |
+
elif isinstance(frames, torch.Tensor):
|
| 222 |
+
rng_str = f"[{frames.min().item():.3f}, {frames.max().item():.3f}]"
|
| 223 |
+
else:
|
| 224 |
+
rng_str = "n/a"
|
| 225 |
+
|
| 226 |
+
shape = frames.shape if hasattr(frames, "shape") else "Unknown"
|
| 227 |
+
logger.info(f"Random-weight smoke test PASSED. Output shape={shape}, range={rng_str}")
|
code/models/tt_dit/experimental/tests/test_pipeline_lora.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""End-to-end test for Wan2.2 I2V with LoRA adapters fused into the base.
|
| 6 |
+
|
| 7 |
+
Reads adapter paths via env vars (compatible with the previous experimental
|
| 8 |
+
test): ``LORA_HIGH_PATH``, ``LORA_LOW_PATH``, ``LORA_SCALE``. Set
|
| 9 |
+
``LORA_STACK_HIGH`` and/or ``LORA_STACK_LOW`` to a comma-separated list of
|
| 10 |
+
``path[:scale]`` entries to exercise multi-LoRA stacking. If both single-LoRA
|
| 11 |
+
and stack env vars are set, the stack form wins.
|
| 12 |
+
"""
|
| 13 |
+
import itertools
|
| 14 |
+
import os
|
| 15 |
+
import statistics
|
| 16 |
+
from typing import List, Tuple
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import PIL
|
| 20 |
+
import pytest
|
| 21 |
+
import torch
|
| 22 |
+
from loguru import logger
|
| 23 |
+
|
| 24 |
+
import ttnn
|
| 25 |
+
from models.perf.benchmarking_utils import BenchmarkProfiler
|
| 26 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_lora import LoRASpec, WanPipelineI2VLora
|
| 27 |
+
from models.tt_dit.pipelines.events import profiler_event_callback
|
| 28 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import ImagePrompt
|
| 29 |
+
from models.tt_dit.utils.test import line_params, ring_params_8k
|
| 30 |
+
|
| 31 |
+
# Only the 4x32 row traces, so only it needs the trace region carved out of DRAM.
|
| 32 |
+
DEVICE_PARAMS = {"trace_region_size": 150000000}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _rank() -> int:
|
| 36 |
+
return int(ttnn.distributed_context_get_rank()) if ttnn.using_distributed_env() else 0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_fractal_image(width: int, height: int) -> PIL.Image.Image:
|
| 40 |
+
c = np.linspace(-2.0, 1.0, width)[None, :] + 1j * np.linspace(-1.5, 1.5, height)[:, None]
|
| 41 |
+
z = np.zeros_like(c)
|
| 42 |
+
img = np.zeros(c.shape, dtype=np.uint8)
|
| 43 |
+
for i in range(32):
|
| 44 |
+
z = z * z + c
|
| 45 |
+
img[(img == 0) & (np.abs(z) > 2)] = 255 - 8 * i
|
| 46 |
+
return PIL.Image.fromarray(np.dstack((img, np.roll(img, width // 10, 1), np.roll(img, height // 10, 0))), "RGB")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _parse_stack(env_val: str) -> List[LoRASpec]:
|
| 50 |
+
"""Parse ``path[:scale],path[:scale]`` into a LoRASpec list."""
|
| 51 |
+
out: List[LoRASpec] = []
|
| 52 |
+
for entry in env_val.split(","):
|
| 53 |
+
entry = entry.strip()
|
| 54 |
+
if not entry:
|
| 55 |
+
continue
|
| 56 |
+
if ":" in entry:
|
| 57 |
+
path, scale_str = entry.rsplit(":", 1)
|
| 58 |
+
out.append(LoRASpec(path.strip(), float(scale_str)))
|
| 59 |
+
else:
|
| 60 |
+
out.append(LoRASpec(entry))
|
| 61 |
+
return out
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _resolve_lora_args() -> Tuple[List[LoRASpec], List[LoRASpec], float]:
|
| 65 |
+
stack_high = os.environ.get("LORA_STACK_HIGH")
|
| 66 |
+
stack_low = os.environ.get("LORA_STACK_LOW")
|
| 67 |
+
single_high = os.environ.get("LORA_HIGH_PATH")
|
| 68 |
+
single_low = os.environ.get("LORA_LOW_PATH")
|
| 69 |
+
scale = float(os.environ.get("LORA_SCALE", "1.0"))
|
| 70 |
+
|
| 71 |
+
if stack_high or stack_low:
|
| 72 |
+
return (
|
| 73 |
+
_parse_stack(stack_high) if stack_high else [],
|
| 74 |
+
_parse_stack(stack_low) if stack_low else [],
|
| 75 |
+
scale,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
high = [LoRASpec(single_high, scale)] if single_high else []
|
| 79 |
+
low = [LoRASpec(single_low, scale)] if single_low else []
|
| 80 |
+
return high, low, scale
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.mark.parametrize(
|
| 84 |
+
"no_prompt",
|
| 85 |
+
[{"1": True, "0": False}.get(os.environ.get("NO_PROMPT"), False)],
|
| 86 |
+
)
|
| 87 |
+
@pytest.mark.parametrize(
|
| 88 |
+
"mesh_device, mesh_shape, num_links, dynamic_load, device_params, topology, is_fsdp",
|
| 89 |
+
[
|
| 90 |
+
[(2, 4), (2, 4), 2, True, line_params, ttnn.Topology.Linear, False],
|
| 91 |
+
[(4, 8), (4, 8), 2, False, ring_params_8k, ttnn.Topology.Ring, False],
|
| 92 |
+
# ring_params_8k adds the fabric router config test_performance_wan uses at 4x32.
|
| 93 |
+
# [(4, 32), (4, 32), 2, False, {**DEVICE_PARAMS, **ring_params}, ttnn.Topology.Ring, False],
|
| 94 |
+
[(4, 32), (4, 32), 2, False, {**DEVICE_PARAMS, **ring_params_8k}, ttnn.Topology.Ring, False],
|
| 95 |
+
],
|
| 96 |
+
ids=["bh_2x4sp1tp0", "bh_4x8sp1tp0_ring", "bh_4x32sp1tp0_ring"],
|
| 97 |
+
indirect=["mesh_device", "device_params"],
|
| 98 |
+
)
|
| 99 |
+
@pytest.mark.parametrize(
|
| 100 |
+
"width, height",
|
| 101 |
+
[
|
| 102 |
+
(832, 480),
|
| 103 |
+
(1280, 720),
|
| 104 |
+
],
|
| 105 |
+
ids=[
|
| 106 |
+
"resolution_480p",
|
| 107 |
+
"resolution_720p",
|
| 108 |
+
],
|
| 109 |
+
)
|
| 110 |
+
def test_pipeline_inference(
|
| 111 |
+
mesh_device,
|
| 112 |
+
mesh_shape,
|
| 113 |
+
num_links,
|
| 114 |
+
dynamic_load,
|
| 115 |
+
topology,
|
| 116 |
+
width,
|
| 117 |
+
height,
|
| 118 |
+
is_fsdp,
|
| 119 |
+
no_prompt,
|
| 120 |
+
):
|
| 121 |
+
lora_high, lora_low, scale = _resolve_lora_args()
|
| 122 |
+
if not lora_high and not lora_low:
|
| 123 |
+
pytest.skip(
|
| 124 |
+
"Set LORA_HIGH_PATH / LORA_LOW_PATH (single-LoRA) or "
|
| 125 |
+
"LORA_STACK_HIGH / LORA_STACK_LOW (multi-LoRA) to run."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Trace on 4x32 only, matching test_performance_wan; TEST_TRACED overrides either way.
|
| 129 |
+
traced = True if mesh_shape == (4, 32) else False
|
| 130 |
+
logger.info(f"traced={traced} (mesh_shape={mesh_shape})")
|
| 131 |
+
|
| 132 |
+
parent_mesh = mesh_device
|
| 133 |
+
mesh_device = parent_mesh.create_submesh(ttnn.MeshShape(*mesh_shape))
|
| 134 |
+
|
| 135 |
+
prompt_image_path = os.environ.get("PROMPT_IMAGE")
|
| 136 |
+
pil_image = PIL.Image.open(prompt_image_path) if prompt_image_path else create_fractal_image(width, height)
|
| 137 |
+
image_prompt = [ImagePrompt(image=pil_image, frame_pos=0)]
|
| 138 |
+
negative_prompt = ""
|
| 139 |
+
|
| 140 |
+
num_frames = 81
|
| 141 |
+
num_inference_steps = int(os.environ.get("NUM_STEPS", "4"))
|
| 142 |
+
guidance_scale = float(os.environ.get("GUIDANCE_SCALE", "1"))
|
| 143 |
+
guidance_scale_2 = float(os.environ.get("GUIDANCE_SCALE_2", str(guidance_scale)))
|
| 144 |
+
boundary_ratio = float(os.environ.get("BOUNDARY_RATIO", "0.875"))
|
| 145 |
+
|
| 146 |
+
# A scale of 1.0 is a no-op lerp, so the unconditional pass is pure waste: skip CFG
|
| 147 |
+
# entirely and halve the denoising cost. Guidance-distilled LoRAs want exactly this.
|
| 148 |
+
cfg_enabled = guidance_scale > 1 or guidance_scale_2 > 1
|
| 149 |
+
logger.info(f"cfg_enabled={cfg_enabled} (guidance_scale={guidance_scale}, guidance_scale_2={guidance_scale_2})")
|
| 150 |
+
|
| 151 |
+
pipeline = WanPipelineI2VLora(
|
| 152 |
+
device=mesh_device,
|
| 153 |
+
config=WanPipelineI2VLora.default_config(
|
| 154 |
+
mesh_device=mesh_device,
|
| 155 |
+
height=height,
|
| 156 |
+
width=width,
|
| 157 |
+
num_frames=num_frames,
|
| 158 |
+
cfg_enabled=cfg_enabled,
|
| 159 |
+
config_overrides={
|
| 160 |
+
"num_links": num_links,
|
| 161 |
+
"dynamic_load": dynamic_load,
|
| 162 |
+
"topology": topology,
|
| 163 |
+
"is_fsdp": is_fsdp,
|
| 164 |
+
"boundary_ratio": boundary_ratio,
|
| 165 |
+
},
|
| 166 |
+
),
|
| 167 |
+
lora_high=lora_high if lora_high else None,
|
| 168 |
+
lora_low=lora_low if lora_low else None,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# prompt = os.environ.get("PROMPT", "A golden retriever running on a sandy beach, waves in the background")
|
| 172 |
+
prompt = os.environ.get("PROMPT", "A cat running on a sandy beach, waves in the background")
|
| 173 |
+
|
| 174 |
+
profiler = BenchmarkProfiler()
|
| 175 |
+
|
| 176 |
+
def run(*, prompt, number, seed, profiler_iteration=None):
|
| 177 |
+
logger.info(f"Running LoRA inference with prompt: '{prompt}'")
|
| 178 |
+
logger.info(
|
| 179 |
+
f"Parameters: {height}x{width}, {num_frames} frames, {num_inference_steps} steps, "
|
| 180 |
+
f"scale={scale}, stack_high={len(lora_high)}, stack_low={len(lora_low)}"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
call_kwargs = dict(
|
| 184 |
+
prompts=[prompt],
|
| 185 |
+
image_prompt=image_prompt,
|
| 186 |
+
negative_prompts=[negative_prompt],
|
| 187 |
+
num_inference_steps=num_inference_steps,
|
| 188 |
+
seed=seed,
|
| 189 |
+
guidance_scale=guidance_scale,
|
| 190 |
+
guidance_scale_2=guidance_scale_2,
|
| 191 |
+
output_type="uint8",
|
| 192 |
+
traced=traced,
|
| 193 |
+
)
|
| 194 |
+
if profiler_iteration is not None:
|
| 195 |
+
call_kwargs["on_event"] = profiler_event_callback(profiler, profiler_iteration)
|
| 196 |
+
|
| 197 |
+
with torch.no_grad():
|
| 198 |
+
result = pipeline(**call_kwargs)
|
| 199 |
+
|
| 200 |
+
if hasattr(result, "frames"):
|
| 201 |
+
frames = result.frames
|
| 202 |
+
else:
|
| 203 |
+
frames = result[0] if isinstance(result, tuple) else result
|
| 204 |
+
|
| 205 |
+
logger.info(f" Output shape: {frames.shape if hasattr(frames, 'shape') else 'Unknown'}")
|
| 206 |
+
if isinstance(frames, np.ndarray):
|
| 207 |
+
logger.info(f" Video data range: [{frames.min():.3f}, {frames.max():.3f}]")
|
| 208 |
+
elif isinstance(frames, torch.Tensor):
|
| 209 |
+
logger.info(f" Video data range: [{frames.min().item():.3f}, {frames.max().item():.3f}]")
|
| 210 |
+
|
| 211 |
+
frames = frames[0]
|
| 212 |
+
# Every rank holds the same gathered video, so only rank 0 writes it out.
|
| 213 |
+
if _rank() == 0:
|
| 214 |
+
output_filename = f"wan_lora_i2v_{width}x{height}_{number}.mp4"
|
| 215 |
+
try:
|
| 216 |
+
from models.tt_dit.utils.video import export_to_video
|
| 217 |
+
|
| 218 |
+
export_to_video(frames, output_filename, fps=16)
|
| 219 |
+
logger.info(f"Saved video to: {output_filename}")
|
| 220 |
+
except ImportError:
|
| 221 |
+
logger.info("Could not export video - imageio_ffmpeg not available")
|
| 222 |
+
else:
|
| 223 |
+
logger.info(f"Skipping video export on rank {_rank()}")
|
| 224 |
+
|
| 225 |
+
return frames
|
| 226 |
+
|
| 227 |
+
if no_prompt:
|
| 228 |
+
# Warmup run (not included in stats)
|
| 229 |
+
logger.info("Running warmup iteration...")
|
| 230 |
+
with profiler("warmup", iteration=0):
|
| 231 |
+
run(prompt=prompt, number=-1, seed=0)
|
| 232 |
+
logger.info(f"Warmup completed in {profiler.get_duration('warmup', 0):.2f}s")
|
| 233 |
+
|
| 234 |
+
# Timed performance run
|
| 235 |
+
num_perf_runs = 1
|
| 236 |
+
ttnn.synchronize_device(mesh_device)
|
| 237 |
+
# Line up all hosts so the measured window is comparable across ranks.
|
| 238 |
+
ttnn.distributed_context_barrier()
|
| 239 |
+
|
| 240 |
+
for i in range(num_perf_runs):
|
| 241 |
+
logger.info(f"Performance run {i+1}/{num_perf_runs}...")
|
| 242 |
+
with profiler("run", iteration=i):
|
| 243 |
+
run(prompt=prompt, number=i, seed=42, profiler_iteration=i)
|
| 244 |
+
logger.info(f" Run {i+1} completed in {profiler.get_duration('run', i):.2f}s")
|
| 245 |
+
|
| 246 |
+
# Collect timing stats. "prepare_latents" is the I2V image-conditioning
|
| 247 |
+
# section: host preprocess/upload of the conditioned frames plus the VAE encode.
|
| 248 |
+
encoder_times = [profiler.get_duration("encoder", i) for i in range(num_perf_runs)]
|
| 249 |
+
image_encode_times = [profiler.get_duration("prepare_latents", i) for i in range(num_perf_runs)]
|
| 250 |
+
denoising_times = [profiler.get_duration("denoising", i) for i in range(num_perf_runs)]
|
| 251 |
+
vae_times = [profiler.get_duration("vae", i) for i in range(num_perf_runs)]
|
| 252 |
+
total_times = [profiler.get_duration("run", i) for i in range(num_perf_runs)]
|
| 253 |
+
|
| 254 |
+
def fmt_stats(times):
|
| 255 |
+
if not times:
|
| 256 |
+
return "No data available"
|
| 257 |
+
mean = statistics.mean(times)
|
| 258 |
+
if len(times) > 1:
|
| 259 |
+
std = statistics.stdev(times)
|
| 260 |
+
return f"Mean: {mean:8.3f}s | Std: {std:7.3f}s | Min: {min(times):8.3f}s | Max: {max(times):8.3f}s"
|
| 261 |
+
return f"{mean:8.3f}s"
|
| 262 |
+
|
| 263 |
+
if _rank() == 0:
|
| 264 |
+
sp_factor = tuple(mesh_device.shape)[1]
|
| 265 |
+
tp_factor = tuple(mesh_device.shape)[0]
|
| 266 |
+
print("\n" + "=" * 80)
|
| 267 |
+
print("WAN 2.2 I2V LoRA — PERFORMANCE RESULTS")
|
| 268 |
+
print("=" * 80)
|
| 269 |
+
print(f" Resolution: {width}x{height}")
|
| 270 |
+
print(f" Frames: {num_frames}")
|
| 271 |
+
print(f" Denoising steps: {num_inference_steps}")
|
| 272 |
+
print(f" Mesh shape: {tuple(mesh_device.shape)}")
|
| 273 |
+
print(f" SP factor: {sp_factor} TP factor: {tp_factor}")
|
| 274 |
+
print(f" Topology: {topology}")
|
| 275 |
+
print(f" FSDP: {is_fsdp}")
|
| 276 |
+
print(f" Num links: {num_links}")
|
| 277 |
+
print(f" LoRA high: {len(lora_high)} adapter(s)")
|
| 278 |
+
print(f" LoRA low: {len(lora_low)} adapter(s)")
|
| 279 |
+
print(f" Perf runs: {num_perf_runs}")
|
| 280 |
+
print("-" * 80)
|
| 281 |
+
print(f" {'Text Encoding':25s} | {fmt_stats(encoder_times)}")
|
| 282 |
+
print(f" {'Image Encoding (total)':25s} | {fmt_stats(image_encode_times)}")
|
| 283 |
+
print(f" {'Denoising':25s} | {fmt_stats(denoising_times)}")
|
| 284 |
+
print(f" {'VAE Decoding':25s} | {fmt_stats(vae_times)}")
|
| 285 |
+
print(f" {'Total Pipeline':25s} | {fmt_stats(total_times)}")
|
| 286 |
+
print("=" * 80)
|
| 287 |
+
else:
|
| 288 |
+
logger.info(f"Skipping performance summary on rank {_rank()}")
|
| 289 |
+
else:
|
| 290 |
+
for i in itertools.count():
|
| 291 |
+
new_prompt = input("Enter the input prompt, or q to exit: ")
|
| 292 |
+
if new_prompt:
|
| 293 |
+
prompt = new_prompt
|
| 294 |
+
if prompt[0] == "q":
|
| 295 |
+
break
|
| 296 |
+
run(prompt=prompt, number=i, seed=i)
|
| 297 |
+
|
| 298 |
+
if traced:
|
| 299 |
+
# Release before the mesh fixture tears down, as test_performance_wan does.
|
| 300 |
+
pipeline.release_traces()
|
code/models/tt_dit/experimental/tests/test_pipeline_wan_distill_i2v.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent USA, Inc.
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import itertools
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import PIL
|
| 10 |
+
import pytest
|
| 11 |
+
import torch
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
import ttnn
|
| 15 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_distill import WanDistillPipelineI2V
|
| 16 |
+
from models.tt_dit.pipelines.wan.pipeline_wan_i2v import ImagePrompt
|
| 17 |
+
from models.tt_dit.utils.test import ring_params
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.mark.parametrize(
|
| 21 |
+
"no_prompt",
|
| 22 |
+
[{"1": True, "0": False}.get(os.environ.get("NO_PROMPT"), False)],
|
| 23 |
+
)
|
| 24 |
+
@pytest.mark.parametrize(
|
| 25 |
+
"mesh_device, mesh_shape, num_links, dynamic_load, device_params, topology, is_fsdp",
|
| 26 |
+
[
|
| 27 |
+
# BH Galaxy 4x8 Ring — sole supported config in the first Distill PR.
|
| 28 |
+
[(4, 8), (4, 8), 2, False, ring_params, ttnn.Topology.Ring, False],
|
| 29 |
+
],
|
| 30 |
+
ids=["bh_4x8sp1tp0_ring"],
|
| 31 |
+
indirect=["mesh_device", "device_params"],
|
| 32 |
+
)
|
| 33 |
+
@pytest.mark.parametrize(
|
| 34 |
+
"width, height",
|
| 35 |
+
[
|
| 36 |
+
(832, 480),
|
| 37 |
+
(1280, 720),
|
| 38 |
+
],
|
| 39 |
+
ids=[
|
| 40 |
+
"resolution_480p",
|
| 41 |
+
"resolution_720p",
|
| 42 |
+
],
|
| 43 |
+
)
|
| 44 |
+
def test_pipeline_inference(
|
| 45 |
+
mesh_device,
|
| 46 |
+
mesh_shape,
|
| 47 |
+
num_links,
|
| 48 |
+
dynamic_load,
|
| 49 |
+
topology,
|
| 50 |
+
width,
|
| 51 |
+
height,
|
| 52 |
+
is_fsdp,
|
| 53 |
+
no_prompt,
|
| 54 |
+
):
|
| 55 |
+
parent_mesh = mesh_device
|
| 56 |
+
mesh_device = parent_mesh.create_submesh(ttnn.MeshShape(*mesh_shape))
|
| 57 |
+
|
| 58 |
+
pil_image = PIL.Image.open(os.environ.get("PROMPT_IMAGE", "./prompt_image.png"))
|
| 59 |
+
image_prompt = [ImagePrompt(image=pil_image, frame_pos=0)]
|
| 60 |
+
# Distill bakes CFG in; negative_prompt is unused when guidance_scale == 1.0,
|
| 61 |
+
# but we keep one for parity with the base I2V test.
|
| 62 |
+
negative_prompt = ""
|
| 63 |
+
|
| 64 |
+
num_frames = 81
|
| 65 |
+
num_inference_steps = 4
|
| 66 |
+
guidance_scale = 1.0
|
| 67 |
+
guidance_scale_2 = 1.0
|
| 68 |
+
|
| 69 |
+
pipeline = WanDistillPipelineI2V.create_pipeline(
|
| 70 |
+
mesh_device=mesh_device,
|
| 71 |
+
height=height,
|
| 72 |
+
width=width,
|
| 73 |
+
num_frames=num_frames,
|
| 74 |
+
config_overrides={
|
| 75 |
+
"num_links": num_links,
|
| 76 |
+
"dynamic_load": dynamic_load,
|
| 77 |
+
"topology": topology,
|
| 78 |
+
"is_fsdp": is_fsdp,
|
| 79 |
+
},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
prompt = "The cat in the hat runs up the hill to the house."
|
| 83 |
+
|
| 84 |
+
def run(*, prompt, number, seed):
|
| 85 |
+
logger.info(f"Running distill inference with prompt: '{prompt}'")
|
| 86 |
+
logger.info(f"Parameters: {height}x{width}, {num_frames} frames, {num_inference_steps} steps")
|
| 87 |
+
|
| 88 |
+
with torch.no_grad():
|
| 89 |
+
result = pipeline(
|
| 90 |
+
prompts=[prompt],
|
| 91 |
+
image_prompt=image_prompt,
|
| 92 |
+
negative_prompts=[negative_prompt],
|
| 93 |
+
num_inference_steps=num_inference_steps,
|
| 94 |
+
seed=seed,
|
| 95 |
+
guidance_scale=guidance_scale,
|
| 96 |
+
guidance_scale_2=guidance_scale_2,
|
| 97 |
+
output_type="uint8",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if hasattr(result, "frames"):
|
| 101 |
+
frames = result.frames
|
| 102 |
+
else:
|
| 103 |
+
frames = result[0] if isinstance(result, tuple) else result
|
| 104 |
+
|
| 105 |
+
logger.info("Distill inference completed successfully")
|
| 106 |
+
logger.info(f" Output shape: {frames.shape if hasattr(frames, 'shape') else 'Unknown'}")
|
| 107 |
+
logger.info(f" Output type: {type(frames)}")
|
| 108 |
+
|
| 109 |
+
if isinstance(frames, np.ndarray):
|
| 110 |
+
logger.info(f" Video data range: [{frames.min():.3f}, {frames.max():.3f}]")
|
| 111 |
+
elif isinstance(frames, torch.Tensor):
|
| 112 |
+
logger.info(f" Video data range: [{frames.min().item():.3f}, {frames.max().item():.3f}]")
|
| 113 |
+
|
| 114 |
+
frames = frames[0]
|
| 115 |
+
output_filename = f"wan_distill_i2v_{width}x{height}_{number}.mp4"
|
| 116 |
+
try:
|
| 117 |
+
from models.tt_dit.utils.video import export_to_video
|
| 118 |
+
|
| 119 |
+
export_to_video(frames, output_filename, fps=16)
|
| 120 |
+
logger.info(f"Saved video to: {output_filename}")
|
| 121 |
+
except ImportError:
|
| 122 |
+
logger.info("Could not export video - imageio_ffmpeg not available")
|
| 123 |
+
|
| 124 |
+
if no_prompt:
|
| 125 |
+
run(prompt=prompt, number=0, seed=42)
|
| 126 |
+
else:
|
| 127 |
+
for i in itertools.count():
|
| 128 |
+
new_prompt = input("Enter the input prompt, or q to exit: ")
|
| 129 |
+
if new_prompt:
|
| 130 |
+
prompt = new_prompt
|
| 131 |
+
if prompt[0] == "q":
|
| 132 |
+
break
|
| 133 |
+
run(prompt=prompt, number=i, seed=i)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# Random-weight smoke test
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# Runs the full pipeline end-to-end without downloading the lightx2v safetensors
|
| 140 |
+
# or the two ~28 GB transformer subfolders from Wan-AI. Tokenizer / UMT5 text
|
| 141 |
+
# encoder / VAE / scheduler still come from Wan-AI/Wan2.2-I2V-A14B-Diffusers
|
| 142 |
+
# (~12 GB total) and require TT_DIT_ALLOW_HF_DOWNLOAD=1 the first time.
|
| 143 |
+
# Output frames are garbage by design — this only validates compile / mesh /
|
| 144 |
+
# shape / CCL plumbing, not numeric quality.
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@pytest.mark.parametrize(
|
| 148 |
+
"mesh_device, mesh_shape, num_links, dynamic_load, device_params, topology, is_fsdp",
|
| 149 |
+
[
|
| 150 |
+
[(4, 8), (4, 8), 2, False, ring_params, ttnn.Topology.Ring, False],
|
| 151 |
+
],
|
| 152 |
+
ids=["bh_4x8sp1tp0_ring"],
|
| 153 |
+
indirect=["mesh_device", "device_params"],
|
| 154 |
+
)
|
| 155 |
+
@pytest.mark.parametrize(
|
| 156 |
+
"width, height",
|
| 157 |
+
[
|
| 158 |
+
(832, 480),
|
| 159 |
+
],
|
| 160 |
+
ids=["resolution_480p"],
|
| 161 |
+
)
|
| 162 |
+
def test_pipeline_inference_random_weights(
|
| 163 |
+
mesh_device,
|
| 164 |
+
mesh_shape,
|
| 165 |
+
num_links,
|
| 166 |
+
dynamic_load,
|
| 167 |
+
topology,
|
| 168 |
+
width,
|
| 169 |
+
height,
|
| 170 |
+
is_fsdp,
|
| 171 |
+
):
|
| 172 |
+
parent_mesh = mesh_device
|
| 173 |
+
mesh_device = parent_mesh.create_submesh(ttnn.MeshShape(*mesh_shape))
|
| 174 |
+
|
| 175 |
+
pil_image = PIL.Image.open(os.environ.get("PROMPT_IMAGE", "./prompt_image.png"))
|
| 176 |
+
image_prompt = [ImagePrompt(image=pil_image, frame_pos=0)]
|
| 177 |
+
|
| 178 |
+
num_frames = 81
|
| 179 |
+
num_inference_steps = 4
|
| 180 |
+
|
| 181 |
+
os.environ["TT_DIT_RANDOM_WEIGHTS"] = "1"
|
| 182 |
+
try:
|
| 183 |
+
pipeline = WanDistillPipelineI2V.create_pipeline(
|
| 184 |
+
mesh_device=mesh_device,
|
| 185 |
+
height=height,
|
| 186 |
+
width=width,
|
| 187 |
+
num_frames=num_frames,
|
| 188 |
+
config_overrides={
|
| 189 |
+
"num_links": num_links,
|
| 190 |
+
"dynamic_load": dynamic_load,
|
| 191 |
+
"topology": topology,
|
| 192 |
+
"is_fsdp": is_fsdp,
|
| 193 |
+
},
|
| 194 |
+
)
|
| 195 |
+
finally:
|
| 196 |
+
os.environ.pop("TT_DIT_RANDOM_WEIGHTS", None)
|
| 197 |
+
|
| 198 |
+
prompt = "smoke test — random weights, output is meaningless"
|
| 199 |
+
logger.info(f"Random-weight smoke test: {height}x{width}, {num_frames} frames, {num_inference_steps} steps")
|
| 200 |
+
|
| 201 |
+
with torch.no_grad():
|
| 202 |
+
result = pipeline(
|
| 203 |
+
prompts=[prompt],
|
| 204 |
+
image_prompt=image_prompt,
|
| 205 |
+
negative_prompts=[""],
|
| 206 |
+
num_inference_steps=num_inference_steps,
|
| 207 |
+
seed=42,
|
| 208 |
+
guidance_scale=1.0,
|
| 209 |
+
guidance_scale_2=1.0,
|
| 210 |
+
output_type="uint8",
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
if hasattr(result, "frames"):
|
| 214 |
+
frames = result.frames
|
| 215 |
+
else:
|
| 216 |
+
frames = result[0] if isinstance(result, tuple) else result
|
| 217 |
+
|
| 218 |
+
if isinstance(frames, np.ndarray):
|
| 219 |
+
rng_str = f"[{frames.min():.3f}, {frames.max():.3f}]"
|
| 220 |
+
elif isinstance(frames, torch.Tensor):
|
| 221 |
+
rng_str = f"[{frames.min().item():.3f}, {frames.max().item():.3f}]"
|
| 222 |
+
else:
|
| 223 |
+
rng_str = "n/a"
|
| 224 |
+
|
| 225 |
+
shape = frames.shape if hasattr(frames, "shape") else "Unknown"
|
| 226 |
+
logger.info(f"Random-weight smoke test PASSED. Output shape={shape}, range={rng_str}")
|
code/models/tt_dit/experimental/tests/test_pipeline_wan_svi.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""End-to-end test for Wan2.2 I2V with SVI 2.0 Pro.
|
| 6 |
+
|
| 7 |
+
Required env vars (or the test is skipped):
|
| 8 |
+
- ``SVI_HIGH_PATH`` / ``SVI_LOW_PATH``: SVI LoRA safetensors (high/low noise)
|
| 9 |
+
- ``LIGHTX2V_HIGH_PATH`` / ``LIGHTX2V_LOW_PATH``: required only when regime='comfyui'
|
| 10 |
+
|
| 11 |
+
Normal optional env vars:
|
| 12 |
+
- ``PROMPT_IMAGE``: anchor image for every clip (default: ``./prompt_image.png``)
|
| 13 |
+
- ``PROMPT``: text prompt for generation. Pass one string for all clips, or a
|
| 14 |
+
semicolon-separated list with exactly ``SVI_NUM_CLIPS`` entries for per-clip
|
| 15 |
+
prompts.
|
| 16 |
+
- ``SVI_NUM_CLIPS``: number of 81-frame clips to chain (default: ``2``)
|
| 17 |
+
|
| 18 |
+
Example per-clip prompt format:
|
| 19 |
+
``PROMPT="clip 1 prompt;clip 2 prompt;clip 3 prompt"`` requires
|
| 20 |
+
``SVI_NUM_CLIPS=3``. Example invocation::
|
| 21 |
+
|
| 22 |
+
SVI_HIGH_PATH=/path/svi_high.safetensors \\
|
| 23 |
+
SVI_LOW_PATH=/path/svi_low.safetensors \\
|
| 24 |
+
LIGHTX2V_HIGH_PATH=/path/lx2v_high.safetensors \\
|
| 25 |
+
LIGHTX2V_LOW_PATH=/path/lx2v_low.safetensors \\
|
| 26 |
+
SVI_NUM_CLIPS=10 PROMPT_IMAGE=./example.png \\
|
| 27 |
+
pytest models/tt_dit/experimental/tests/test_pipeline_wan_svi.py \\
|
| 28 |
+
-v -k "comfyui and bh_2x4" -s
|
| 29 |
+
|
| 30 |
+
See ``experimental/models/Wan2_2_SVI.md`` for advanced knobs (steps, CFG, seed,
|
| 31 |
+
motion-latent handoff, overlap) and the upstream-workflow comparison.
|
| 32 |
+
"""
|
| 33 |
+
import os
|
| 34 |
+
from dataclasses import dataclass
|
| 35 |
+
from typing import List, Optional, Union
|
| 36 |
+
|
| 37 |
+
import pytest
|
| 38 |
+
import torch
|
| 39 |
+
from loguru import logger
|
| 40 |
+
from PIL import Image
|
| 41 |
+
|
| 42 |
+
import ttnn
|
| 43 |
+
from models.tt_dit.experimental.pipelines.pipeline_wan_svi import WanPipelineSVI
|
| 44 |
+
from models.tt_dit.utils.test import line_params, ring_params
|
| 45 |
+
|
| 46 |
+
PromptArg = Union[str, List[str]]
|
| 47 |
+
FloatArg = Union[float, List[float]]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
_DEFAULT_PROMPT = "A golden retriever running on a sandy beach, waves in the background"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass(frozen=True)
|
| 54 |
+
class _SVIRunConfig:
|
| 55 |
+
regime: str
|
| 56 |
+
svi_high: str
|
| 57 |
+
svi_low: str
|
| 58 |
+
lightx2v_high: Optional[str]
|
| 59 |
+
lightx2v_low: Optional[str]
|
| 60 |
+
anchor_image: Image.Image
|
| 61 |
+
prompt: PromptArg
|
| 62 |
+
num_clips: int
|
| 63 |
+
num_motion_latent: int
|
| 64 |
+
num_overlap_frame: int
|
| 65 |
+
base_seed: int
|
| 66 |
+
num_inference_steps: int
|
| 67 |
+
guidance_scale: FloatArg
|
| 68 |
+
guidance_scale_2: FloatArg
|
| 69 |
+
num_frames: int
|
| 70 |
+
width: int
|
| 71 |
+
height: int
|
| 72 |
+
output_basename: str
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _parse_per_clip_env(env_val: str, num_clips: int, name: str, cast):
|
| 76 |
+
"""Parse a scalar or semicolon-separated per-clip env var."""
|
| 77 |
+
parts = [p.strip() for p in env_val.split(";")]
|
| 78 |
+
if len(parts) == 1:
|
| 79 |
+
return cast(parts[0])
|
| 80 |
+
|
| 81 |
+
if len(parts) != num_clips:
|
| 82 |
+
pytest.fail(f"{name} has {len(parts)} entries but SVI_NUM_CLIPS={num_clips}")
|
| 83 |
+
return [cast(p) for p in parts]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _read_svi_config(regime: str, width: int, height: int) -> _SVIRunConfig:
|
| 87 |
+
svi_high = os.environ.get("SVI_HIGH_PATH")
|
| 88 |
+
svi_low = os.environ.get("SVI_LOW_PATH")
|
| 89 |
+
if not svi_high or not svi_low:
|
| 90 |
+
pytest.skip("SVI_HIGH_PATH and SVI_LOW_PATH env vars are required for this test.")
|
| 91 |
+
|
| 92 |
+
lightx2v_high = os.environ.get("LIGHTX2V_HIGH_PATH")
|
| 93 |
+
lightx2v_low = os.environ.get("LIGHTX2V_LOW_PATH")
|
| 94 |
+
if regime == "comfyui" and (not lightx2v_high or not lightx2v_low):
|
| 95 |
+
pytest.skip("regime='comfyui' requires LIGHTX2V_HIGH_PATH / LIGHTX2V_LOW_PATH env vars.")
|
| 96 |
+
|
| 97 |
+
num_clips = int(os.environ.get("SVI_NUM_CLIPS", "2"))
|
| 98 |
+
default_steps = "50" if regime == "python" else "6"
|
| 99 |
+
default_cfg = "5.0" if regime == "python" else "1.5"
|
| 100 |
+
guidance_scale_env = os.environ.get("GUIDANCE_SCALE", default_cfg)
|
| 101 |
+
|
| 102 |
+
prompt = _parse_per_clip_env(os.environ.get("PROMPT", _DEFAULT_PROMPT), num_clips, "PROMPT", str)
|
| 103 |
+
guidance_scale = _parse_per_clip_env(guidance_scale_env, num_clips, "GUIDANCE_SCALE", float)
|
| 104 |
+
guidance_scale_2 = _parse_per_clip_env(
|
| 105 |
+
os.environ.get("GUIDANCE_SCALE_2", guidance_scale_env),
|
| 106 |
+
num_clips,
|
| 107 |
+
"GUIDANCE_SCALE_2",
|
| 108 |
+
float,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
return _SVIRunConfig(
|
| 112 |
+
regime=regime,
|
| 113 |
+
svi_high=svi_high,
|
| 114 |
+
svi_low=svi_low,
|
| 115 |
+
lightx2v_high=lightx2v_high,
|
| 116 |
+
lightx2v_low=lightx2v_low,
|
| 117 |
+
anchor_image=Image.open(os.environ.get("PROMPT_IMAGE", "./prompt_image.png")),
|
| 118 |
+
prompt=prompt,
|
| 119 |
+
num_clips=num_clips,
|
| 120 |
+
num_motion_latent=int(os.environ.get("SVI_NUM_MOTION_LATENT", "1")),
|
| 121 |
+
num_overlap_frame=int(os.environ.get("SVI_NUM_OVERLAP_FRAME", "4")),
|
| 122 |
+
base_seed=int(os.environ.get("SVI_SEED", "0")),
|
| 123 |
+
num_inference_steps=int(os.environ.get("NUM_STEPS", default_steps)),
|
| 124 |
+
guidance_scale=guidance_scale,
|
| 125 |
+
guidance_scale_2=guidance_scale_2,
|
| 126 |
+
num_frames=81,
|
| 127 |
+
width=width,
|
| 128 |
+
height=height,
|
| 129 |
+
output_basename=f"wan_svi_{regime}_{num_clips}clips_{width}x{height}",
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _pipeline_kwargs(config: _SVIRunConfig):
|
| 134 |
+
"""The SVI-specific half of the constructor arguments (the config is built separately)."""
|
| 135 |
+
kwargs = dict(
|
| 136 |
+
svi_high=config.svi_high,
|
| 137 |
+
svi_low=config.svi_low,
|
| 138 |
+
regime=config.regime,
|
| 139 |
+
num_motion_latent=config.num_motion_latent,
|
| 140 |
+
num_overlap_frame=config.num_overlap_frame,
|
| 141 |
+
)
|
| 142 |
+
if config.regime == "comfyui":
|
| 143 |
+
kwargs["lightx2v_high"] = config.lightx2v_high
|
| 144 |
+
kwargs["lightx2v_low"] = config.lightx2v_low
|
| 145 |
+
return kwargs
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _assert_video(video: torch.Tensor, *, num_clips: int, num_frames: int, num_overlap_frame: int) -> None:
|
| 149 |
+
expected_t = num_clips * num_frames - (num_clips - 1) * num_overlap_frame
|
| 150 |
+
actual_t = video.shape[0]
|
| 151 |
+
logger.info(f"Concat shape: {tuple(video.shape)} (expected T={expected_t})")
|
| 152 |
+
assert actual_t == expected_t, f"Expected {expected_t} frames, got {actual_t}"
|
| 153 |
+
assert not torch.isnan(video.float()).any(), "video contains NaN"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _save_outputs(video: torch.Tensor, output_basename: str) -> None:
|
| 157 |
+
video = video.detach()
|
| 158 |
+
torch.save(video, f"{output_basename}.pt")
|
| 159 |
+
logger.info(f"Saved raw tensor to: {output_basename}.pt")
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
from models.tt_dit.utils.video import export_to_video
|
| 163 |
+
|
| 164 |
+
# output_type='pt_with_last_latent' decodes frames as (T, C, H, W) float;
|
| 165 |
+
# export_to_video wants (T, H, W, C) uint8.
|
| 166 |
+
arr = video
|
| 167 |
+
if arr.ndim == 4 and arr.shape[1] in (1, 3):
|
| 168 |
+
arr = arr.permute(0, 2, 3, 1).contiguous()
|
| 169 |
+
if arr.dtype != torch.uint8:
|
| 170 |
+
arr = arr.float()
|
| 171 |
+
if arr.min().item() < 0:
|
| 172 |
+
arr = (arr + 1.0) * 127.5
|
| 173 |
+
elif arr.max().item() <= 1.0 + 1e-3:
|
| 174 |
+
arr = arr * 255.0
|
| 175 |
+
arr = arr.clamp(0, 255).to(torch.uint8)
|
| 176 |
+
export_to_video(arr.numpy(), f"{output_basename}.mp4", fps=16)
|
| 177 |
+
logger.info(f"Saved video to: {output_basename}.mp4")
|
| 178 |
+
except ImportError:
|
| 179 |
+
logger.info("Could not export video - imageio_ffmpeg not available")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _touch(path) -> str:
|
| 183 |
+
path.write_bytes(b"")
|
| 184 |
+
return str(path)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@pytest.mark.parametrize(
|
| 188 |
+
"mesh_device, mesh_shape, num_links, dynamic_load, device_params, topology, is_fsdp",
|
| 189 |
+
[
|
| 190 |
+
[(2, 4), (2, 4), 2, True, line_params, ttnn.Topology.Linear, False],
|
| 191 |
+
[(4, 8), (4, 8), 2, False, ring_params, ttnn.Topology.Ring, False],
|
| 192 |
+
],
|
| 193 |
+
ids=["bh_2x4sp1tp0", "bh_4x8sp1tp0_ring"],
|
| 194 |
+
indirect=["mesh_device", "device_params"],
|
| 195 |
+
)
|
| 196 |
+
@pytest.mark.parametrize(
|
| 197 |
+
"width, height",
|
| 198 |
+
[
|
| 199 |
+
(832, 480),
|
| 200 |
+
],
|
| 201 |
+
ids=["resolution_480p"],
|
| 202 |
+
)
|
| 203 |
+
@pytest.mark.parametrize(
|
| 204 |
+
"regime",
|
| 205 |
+
["python", "comfyui"],
|
| 206 |
+
)
|
| 207 |
+
def test_long_video(
|
| 208 |
+
mesh_device,
|
| 209 |
+
mesh_shape,
|
| 210 |
+
num_links,
|
| 211 |
+
dynamic_load,
|
| 212 |
+
topology,
|
| 213 |
+
width,
|
| 214 |
+
height,
|
| 215 |
+
is_fsdp,
|
| 216 |
+
regime,
|
| 217 |
+
):
|
| 218 |
+
config = _read_svi_config(regime, width, height)
|
| 219 |
+
mesh_device = mesh_device.create_submesh(ttnn.MeshShape(*mesh_shape))
|
| 220 |
+
pipeline = WanPipelineSVI(
|
| 221 |
+
device=mesh_device,
|
| 222 |
+
config=WanPipelineSVI.default_config(
|
| 223 |
+
mesh_device=mesh_device,
|
| 224 |
+
height=config.height,
|
| 225 |
+
width=config.width,
|
| 226 |
+
num_frames=config.num_frames,
|
| 227 |
+
config_overrides={
|
| 228 |
+
"num_links": num_links,
|
| 229 |
+
"dynamic_load": dynamic_load,
|
| 230 |
+
"topology": topology,
|
| 231 |
+
"is_fsdp": is_fsdp,
|
| 232 |
+
},
|
| 233 |
+
),
|
| 234 |
+
**_pipeline_kwargs(config),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
logger.info(
|
| 238 |
+
f"SVI run: {config.num_clips} clips × {config.num_frames} frames at {width}x{height}, "
|
| 239 |
+
f"steps={config.num_inference_steps}, motion={config.num_motion_latent}, "
|
| 240 |
+
f"overlap={config.num_overlap_frame}"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
with torch.no_grad():
|
| 244 |
+
video = pipeline.generate_long_video(
|
| 245 |
+
prompt=config.prompt,
|
| 246 |
+
anchor_image=config.anchor_image,
|
| 247 |
+
num_clips=config.num_clips,
|
| 248 |
+
base_seed=config.base_seed,
|
| 249 |
+
num_inference_steps=config.num_inference_steps,
|
| 250 |
+
guidance_scale=config.guidance_scale,
|
| 251 |
+
guidance_scale_2=config.guidance_scale_2,
|
| 252 |
+
partial_output_path=f"{config.output_basename}.partial.pt",
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
_assert_video(
|
| 256 |
+
video,
|
| 257 |
+
num_clips=config.num_clips,
|
| 258 |
+
num_frames=config.num_frames,
|
| 259 |
+
num_overlap_frame=config.num_overlap_frame,
|
| 260 |
+
)
|
| 261 |
+
_save_outputs(video, config.output_basename)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_comfyui_regime_requires_lightx2v(tmp_path, expect_error):
|
| 265 |
+
"""regime='comfyui' must demand LightX2V LoRA paths."""
|
| 266 |
+
with expect_error(ValueError, "lightx2v_high"):
|
| 267 |
+
WanPipelineSVI(
|
| 268 |
+
device=None,
|
| 269 |
+
config=None,
|
| 270 |
+
svi_high=_touch(tmp_path / "svi_high.safetensors"),
|
| 271 |
+
svi_low=_touch(tmp_path / "svi_low.safetensors"),
|
| 272 |
+
regime="comfyui",
|
| 273 |
+
)
|
code/models/tt_dit/experimental/utils/__init__.py
ADDED
|
File without changes
|
code/models/tt_dit/experimental/utils/lightx2v_loader.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent USA, Inc.
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Loader for lightx2v Wan2.2 distilled weights.
|
| 6 |
+
|
| 7 |
+
The ``lightx2v/Wan2.2-Distill-Models`` HF repo ships flat ``.safetensors`` files
|
| 8 |
+
containing only the DiT weights (no ``transformer/``, ``vae/``, ``text_encoder/``
|
| 9 |
+
subfolders). The keys use the *original Wan* naming
|
| 10 |
+
(``blocks.X.self_attn.q.weight``, ``blocks.X.modulation``, ``head.head.weight``,
|
| 11 |
+
``time_embedding.0.weight``, ...), but tt_dit's ``WanTransformer3DModel``
|
| 12 |
+
consumes the *diffusers* layout (``blocks.X.attn1.to_q.weight``,
|
| 13 |
+
``blocks.X.scale_shift_table``, ``proj_out.weight``,
|
| 14 |
+
``condition_embedder.time_embedder.linear_1.weight``, ...).
|
| 15 |
+
|
| 16 |
+
:func:`load_lightx2v_state_dict` applies :func:`rename_lightx2v_to_diffusers`
|
| 17 |
+
to the loaded state dict using :func:`rename_substate` from the DiT library,
|
| 18 |
+
so the returned dict matches what ``WanTransformer3DModel._prepare_torch_state``
|
| 19 |
+
expects.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Mapping
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
from loguru import logger
|
| 29 |
+
from safetensors.torch import load_file
|
| 30 |
+
|
| 31 |
+
from models.tt_dit.utils.substate import rename_substate
|
| 32 |
+
|
| 33 |
+
# Per-block prefix renames (lightx2v → diffusers), applied under each
|
| 34 |
+
# ``blocks.<i>.`` subtree. Uses rename_substate for prefix-based renaming.
|
| 35 |
+
_BLOCK_PREFIX_REMAP: list[tuple[str, str]] = [
|
| 36 |
+
# self-attention
|
| 37 |
+
("self_attn.q", "attn1.to_q"),
|
| 38 |
+
("self_attn.k", "attn1.to_k"),
|
| 39 |
+
("self_attn.v", "attn1.to_v"),
|
| 40 |
+
("self_attn.o", "attn1.to_out.0"),
|
| 41 |
+
("self_attn.norm_q", "attn1.norm_q"),
|
| 42 |
+
("self_attn.norm_k", "attn1.norm_k"),
|
| 43 |
+
# cross-attention
|
| 44 |
+
("cross_attn.q", "attn2.to_q"),
|
| 45 |
+
("cross_attn.k", "attn2.to_k"),
|
| 46 |
+
("cross_attn.v", "attn2.to_v"),
|
| 47 |
+
("cross_attn.o", "attn2.to_out.0"),
|
| 48 |
+
("cross_attn.norm_q", "attn2.norm_q"),
|
| 49 |
+
("cross_attn.norm_k", "attn2.norm_k"),
|
| 50 |
+
# feedforward
|
| 51 |
+
("ffn.0", "ffn.net.0.proj"),
|
| 52 |
+
("ffn.2", "ffn.net.2"),
|
| 53 |
+
# cross-attn input norm
|
| 54 |
+
("norm3", "norm2"),
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
# Single-key renames that don't fit the prefix pattern (leaf key → leaf key).
|
| 58 |
+
_BLOCK_KEY_REMAP: dict[str, str] = {
|
| 59 |
+
"modulation": "scale_shift_table",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# Top-level prefix renames (lightx2v → diffusers).
|
| 63 |
+
_TOP_LEVEL_PREFIX_REMAP: list[tuple[str, str]] = [
|
| 64 |
+
("text_embedding.0", "condition_embedder.text_embedder.linear_1"),
|
| 65 |
+
("text_embedding.2", "condition_embedder.text_embedder.linear_2"),
|
| 66 |
+
("time_embedding.0", "condition_embedder.time_embedder.linear_1"),
|
| 67 |
+
("time_embedding.2", "condition_embedder.time_embedder.linear_2"),
|
| 68 |
+
("time_projection.1", "condition_embedder.time_proj"),
|
| 69 |
+
("head.head", "proj_out"),
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
_TOP_LEVEL_KEY_REMAP: dict[str, str] = {
|
| 73 |
+
"head.modulation": "scale_shift_table",
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _detect_num_blocks(state: dict[str, torch.Tensor]) -> int:
|
| 78 |
+
max_idx = -1
|
| 79 |
+
for k in state:
|
| 80 |
+
if k.startswith("blocks."):
|
| 81 |
+
parts = k.split(".")
|
| 82 |
+
if len(parts) >= 2 and parts[1].isdigit():
|
| 83 |
+
max_idx = max(max_idx, int(parts[1]))
|
| 84 |
+
return max_idx + 1
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def rename_lightx2v_to_diffusers(state: dict[str, torch.Tensor]) -> None:
|
| 88 |
+
"""Rename all keys in *state* from lightx2v/native-Wan to diffusers layout, in place.
|
| 89 |
+
|
| 90 |
+
Uses :func:`rename_substate` from the DiT library for prefix-based renames.
|
| 91 |
+
"""
|
| 92 |
+
num_blocks = _detect_num_blocks(state)
|
| 93 |
+
|
| 94 |
+
for i in range(num_blocks):
|
| 95 |
+
for src, dst in _BLOCK_PREFIX_REMAP:
|
| 96 |
+
rename_substate(state, f"blocks.{i}.{src}", f"blocks.{i}.{dst}")
|
| 97 |
+
for src, dst in _BLOCK_KEY_REMAP.items():
|
| 98 |
+
old_key = f"blocks.{i}.{src}"
|
| 99 |
+
if old_key in state:
|
| 100 |
+
state[f"blocks.{i}.{dst}"] = state.pop(old_key)
|
| 101 |
+
|
| 102 |
+
for src, dst in _TOP_LEVEL_PREFIX_REMAP:
|
| 103 |
+
rename_substate(state, src, dst)
|
| 104 |
+
|
| 105 |
+
for src, dst in _TOP_LEVEL_KEY_REMAP.items():
|
| 106 |
+
if src in state:
|
| 107 |
+
state[dst] = state.pop(src)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def wan_lightx2v_to_diffusers_key(key: str) -> str:
|
| 111 |
+
"""Rename one lightx2v key to its diffusers-canonical equivalent.
|
| 112 |
+
|
| 113 |
+
Convenience wrapper kept for callers that need per-key conversion
|
| 114 |
+
(e.g. LoRA key targeting). Internally builds a single-key dict and
|
| 115 |
+
applies :func:`rename_lightx2v_to_diffusers`.
|
| 116 |
+
"""
|
| 117 |
+
tmp = {key: torch.empty(0)}
|
| 118 |
+
rename_lightx2v_to_diffusers(tmp)
|
| 119 |
+
return next(iter(tmp))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class WeightsNotFoundError(FileNotFoundError):
|
| 123 |
+
"""Raised when a lightx2v weight file is not on disk and downloads are disabled."""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _hf_cache_root() -> Path:
|
| 127 |
+
return Path(os.environ.get("HF_HOME") or Path.home() / ".cache" / "huggingface")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _resolve_path(
|
| 131 |
+
repo_id: str,
|
| 132 |
+
filename: str,
|
| 133 |
+
*,
|
| 134 |
+
allow_download: bool,
|
| 135 |
+
local_dir: str | None,
|
| 136 |
+
) -> Path:
|
| 137 |
+
if local_dir is not None:
|
| 138 |
+
candidate = Path(local_dir) / filename
|
| 139 |
+
if candidate.is_file():
|
| 140 |
+
return candidate
|
| 141 |
+
|
| 142 |
+
repo_cache = _hf_cache_root() / "hub" / f"models--{repo_id.replace('/', '--')}" / "snapshots"
|
| 143 |
+
if repo_cache.is_dir():
|
| 144 |
+
for snap in sorted(repo_cache.iterdir(), reverse=True):
|
| 145 |
+
cand = snap / filename
|
| 146 |
+
if cand.is_file():
|
| 147 |
+
return cand
|
| 148 |
+
|
| 149 |
+
if not allow_download:
|
| 150 |
+
raise WeightsNotFoundError(
|
| 151 |
+
f"lightx2v weights '{filename}' not found for repo '{repo_id}'.\n"
|
| 152 |
+
f"Searched: {repo_cache}\n"
|
| 153 |
+
f"To download from HuggingFace, re-run with TT_DIT_ALLOW_HF_DOWNLOAD=1 "
|
| 154 |
+
f"or pass allow_download=True to the pipeline.\n"
|
| 155 |
+
f"Alternatively, place the file under a snapshot directory in: {repo_cache}"
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
from huggingface_hub import hf_hub_download
|
| 159 |
+
|
| 160 |
+
logger.info(f"Downloading lightx2v weights from HuggingFace: {repo_id}/{filename}")
|
| 161 |
+
return Path(hf_hub_download(repo_id=repo_id, filename=filename))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def load_lightx2v_state_dict(
|
| 165 |
+
repo_id: str,
|
| 166 |
+
filename: str,
|
| 167 |
+
*,
|
| 168 |
+
allow_download: bool = False,
|
| 169 |
+
local_dir: str | None = None,
|
| 170 |
+
key_remap: Mapping[str, str] | None = None,
|
| 171 |
+
) -> dict[str, torch.Tensor]:
|
| 172 |
+
"""Load a lightx2v safetensors file and return a torch state dict.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
repo_id: HuggingFace repo, e.g. ``"lightx2v/Wan2.2-Distill-Models"``.
|
| 176 |
+
filename: File within the repo, e.g.
|
| 177 |
+
``"wan2.2_i2v_A14b_high_noise_lightx2v_4step.safetensors"``.
|
| 178 |
+
allow_download: When ``False`` (default), the file must already exist
|
| 179 |
+
in ``local_dir`` or the HF cache. When ``True``, missing files are
|
| 180 |
+
fetched via ``huggingface_hub.hf_hub_download``.
|
| 181 |
+
local_dir: Optional directory to check before the HF cache.
|
| 182 |
+
key_remap: Flat-dict overrides applied after the lightx2v→diffusers
|
| 183 |
+
rename. Useful for one-off renames in tests.
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
State dict with diffusers-canonical keys ready for
|
| 187 |
+
``WanTransformer3DModel.load_torch_state_dict``.
|
| 188 |
+
"""
|
| 189 |
+
path = _resolve_path(repo_id, filename, allow_download=allow_download, local_dir=local_dir)
|
| 190 |
+
logger.info(f"Loading lightx2v state dict from '{path}'")
|
| 191 |
+
state = load_file(str(path))
|
| 192 |
+
|
| 193 |
+
rename_lightx2v_to_diffusers(state)
|
| 194 |
+
|
| 195 |
+
if key_remap:
|
| 196 |
+
for old_k, new_k in key_remap.items():
|
| 197 |
+
if old_k in state:
|
| 198 |
+
state[new_k] = state.pop(old_k)
|
| 199 |
+
return state
|
code/models/tt_dit/models/audio_vae/minimax_h3/.gitkeep
ADDED
|
File without changes
|
code/models/tt_dit/models/audio_vae/minimax_h3/blockings_minimax_h3_audio.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""conv3d blocking stubs for the MiniMax-H3 audio VAE.
|
| 6 |
+
|
| 7 |
+
``_FP32_BLOCKINGS`` in ``utils/conv3d.py`` is tuned for the **LTX** vocoder's channel
|
| 8 |
+
schedule (1536 -> 768 -> ... -> 24). H3's is different at both ends -- the decoder runs
|
| 9 |
+
1024 -> 512 -> ... -> **8** and the encoder 64 -> ... -> 2048 -- so every H3 audio conv
|
| 10 |
+
misses the table and falls back to ``(32, 32, 1, 1, 1)``. For a 1-D conv over a long time
|
| 11 |
+
axis (a 10 s clip decodes to 324000 samples) a ``T_out_block`` of 1 means one output frame
|
| 12 |
+
per work unit, and the conv3d DRAM slicer cannot find a workable configuration at all.
|
| 13 |
+
|
| 14 |
+
These are **stubs**, shaped after the LTX audio entries which have the same ``(k, 1, 1)``
|
| 15 |
+
form: ``C_out_block`` 32, ``H``/``W`` 1, and a ``T_out_block`` in the range LTX's tuned
|
| 16 |
+
values occupy (4-64). ``bruteforce_conv3d_sweep.py`` is the tool for real tuning, and that
|
| 17 |
+
is a performance-pass job -- these exist only so the correctness gates can run.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from ....layers.audio_ops import DEFAULT_MAX_C_IN_BLOCK
|
| 23 |
+
from ....utils.conv3d import _FP32_BLOCKINGS, aligned_channels
|
| 24 |
+
|
| 25 |
+
# 16 overshoots L1 by 1.26x (1979264 B against 1572864 B) at the widest audio convs.
|
| 26 |
+
T_OUT_BLOCK = 8
|
| 27 |
+
C_OUT_BLOCK = 32
|
| 28 |
+
|
| 29 |
+
# ``C_in_block`` is not only a performance knob: conv3d accumulates one partial sum per block over the
|
| 30 |
+
# reduction and those partials are rounded, so a narrower block is measurably less accurate *in isolation*
|
| 31 |
+
# -- on `conv_pre` (Cin 2048 x k 7) the error falls monotonically, 2.40e-03 at 32, 1.86e-03 at 128,
|
| 32 |
+
# 1.63e-03 at 512, and with operand splitting on, 512 is 1.48x better than 128.
|
| 33 |
+
#
|
| 34 |
+
# **That does not transfer end to end, so 128 stays.** Measured with every other lever on: 128 gives
|
| 35 |
+
# 3.20 % chain RMSE, 256 gives 3.31 % (no better), and 512 fails outright
|
| 36 |
+
# (`program.cpp:1706`). The reason is structural -- the chain is dominated by the 126 AMP convs at
|
| 37 |
+
# Cin 8-512, where the block cannot widen anyway, so only `conv_pre` and `dec_in_proj` would gain and
|
| 38 |
+
# there are two of them against 126. Pass ``max_c_in_block`` to re-sweep. A non-default cap changes
|
| 39 |
+
# the prepared weight *bytes* (`prepare_conv3d_weight_state` blocks by ``C_in_block``) with an
|
| 40 |
+
# unchanged file set, which is why `weights_variant` folds it into the device-weight cache key.
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _c_in_block(in_channels: int, max_c_in_block: int = DEFAULT_MAX_C_IN_BLOCK) -> int:
|
| 44 |
+
"""Largest 32-multiple <= ``max_c_in_block`` that divides ``in_channels`` evenly.
|
| 45 |
+
|
| 46 |
+
The kernel requires ``C_in_block`` to be a multiple of the tile width and to divide the
|
| 47 |
+
padded input channel count.
|
| 48 |
+
"""
|
| 49 |
+
aligned = aligned_channels(in_channels)
|
| 50 |
+
for block in range(min(max_c_in_block, aligned) // 32 * 32, 0, -32):
|
| 51 |
+
if aligned % block == 0:
|
| 52 |
+
return block
|
| 53 |
+
return 32
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def h3_audio_channel_widths(
|
| 57 |
+
*,
|
| 58 |
+
encoder_dim: int = 64,
|
| 59 |
+
encoder_rates: tuple[int, ...] = (2, 4, 4, 5, 5),
|
| 60 |
+
latent_dim: int = 2048,
|
| 61 |
+
latent_channels: int = 32,
|
| 62 |
+
decoder_dim: int = 1024,
|
| 63 |
+
decoder_rates: tuple[int, ...] = (5, 5, 2, 2, 2, 2, 2),
|
| 64 |
+
) -> set[tuple[int, int]]:
|
| 65 |
+
"""Every ``(in_channels, out_channels)`` pair the audio VAE's convolutions use."""
|
| 66 |
+
pairs: set[tuple[int, int]] = set()
|
| 67 |
+
|
| 68 |
+
# Encoder: conv_in, then per stage three residual units at dim//2 plus a strided
|
| 69 |
+
# channel-doubling conv.
|
| 70 |
+
pairs.add((aligned_channels(1), encoder_dim))
|
| 71 |
+
dim = encoder_dim
|
| 72 |
+
for stride in encoder_rates:
|
| 73 |
+
inner = dim
|
| 74 |
+
dim *= 2
|
| 75 |
+
pairs.add((inner, inner)) # residual unit k7 and k1
|
| 76 |
+
pairs.add((inner, dim)) # strided conv
|
| 77 |
+
pairs.add((dim, latent_dim))
|
| 78 |
+
|
| 79 |
+
# Decoder: conv_pre, the transposed upsamplers' inner convs, the AMP blocks, conv_post.
|
| 80 |
+
pairs.add((latent_channels, latent_dim)) # dec_in_proj
|
| 81 |
+
pairs.add((latent_dim, decoder_dim)) # conv_pre
|
| 82 |
+
channels = decoder_dim
|
| 83 |
+
for _ in decoder_rates:
|
| 84 |
+
nxt = channels // 2
|
| 85 |
+
pairs.add((channels, nxt)) # upsampler inner conv
|
| 86 |
+
pairs.add((aligned_channels(nxt), aligned_channels(nxt))) # AMP convs
|
| 87 |
+
channels = nxt
|
| 88 |
+
pairs.add((aligned_channels(channels), aligned_channels(1))) # conv_post -> mono
|
| 89 |
+
return pairs
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def register_h3_audio_blockings(*, max_c_in_block: int = DEFAULT_MAX_C_IN_BLOCK, **config) -> int:
|
| 93 |
+
"""Seed ``_FP32_BLOCKINGS`` for every H3 audio conv shape. Returns the number added.
|
| 94 |
+
|
| 95 |
+
``setdefault``, so a swept value that later lands in ``conv3d.py`` wins over these.
|
| 96 |
+
Kernels cover every size the model uses: 1 and 3 (projections), 4/7/8/9/10/11 (AMP
|
| 97 |
+
blocks, strided encoder convs and transposed upsamplers).
|
| 98 |
+
"""
|
| 99 |
+
kernels = (1, 3, 4, 7, 8, 9, 10, 11)
|
| 100 |
+
added = 0
|
| 101 |
+
for in_channels, out_channels in h3_audio_channel_widths(**config):
|
| 102 |
+
for kernel in kernels:
|
| 103 |
+
key = (aligned_channels(in_channels), max(32, out_channels), (kernel, 1, 1))
|
| 104 |
+
blocking = (_c_in_block(in_channels, max_c_in_block), C_OUT_BLOCK, T_OUT_BLOCK, 1, 1)
|
| 105 |
+
existing = _FP32_BLOCKINGS.get(key)
|
| 106 |
+
if existing is None:
|
| 107 |
+
_FP32_BLOCKINGS[key] = blocking
|
| 108 |
+
added += 1
|
| 109 |
+
elif existing[2] > T_OUT_BLOCK:
|
| 110 |
+
# A few of H3's (C_in, C_out, kernel) triples coincide with LTX's -- e.g.
|
| 111 |
+
# (128, 64, (4,1,1)) is LTX's ups[3] -- and LTX's tuned T_out_block of 32 is
|
| 112 |
+
# sized for its tensors, not H3's: at H3's widths it overshoots L1 (1979264 B
|
| 113 |
+
# against 1572864 B). Cap the temporal block for the shapes H3 actually uses
|
| 114 |
+
# rather than deferring to an entry tuned for a different model.
|
| 115 |
+
_FP32_BLOCKINGS[key] = (existing[0], existing[1], T_OUT_BLOCK, existing[3], existing[4])
|
| 116 |
+
added += 1
|
| 117 |
+
return added
|
code/models/tt_dit/models/audio_vae/minimax_h3/convert_minimax_h3_audio.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Torch-side conversion of the MiniMax-H3 audio VAE checkpoint. No ``ttnn`` here.
|
| 6 |
+
|
| 7 |
+
``tt_dit`` has no weight-norm support anywhere -- ``git grep weight_g`` over
|
| 8 |
+
``models/tt_dit`` returns nothing -- because the contract is that weight norm is removed
|
| 9 |
+
before a state dict reaches it. The H3 audio checkpoint stores ``weight_g``/``weight_v``
|
| 10 |
+
for all ~172 of its convolutions, so that fusion has to happen somewhere, and a single
|
| 11 |
+
torch-side conversion boundary is the right place: it is orthogonal to every conv class,
|
| 12 |
+
it would otherwise be duplicated across four of them, and it is testable with **zero
|
| 13 |
+
device time**.
|
| 14 |
+
|
| 15 |
+
The fusion rule is uniform because ``torch.nn.utils.weight_norm`` is used at its default
|
| 16 |
+
``dim=0`` everywhere::
|
| 17 |
+
|
| 18 |
+
weight = weight_g * weight_v / ||weight_v|| (L2 over all axes but axis 0)
|
| 19 |
+
|
| 20 |
+
The trap is that for ``ConvTranspose1d`` axis 0 is **``in_channels``**, not
|
| 21 |
+
``out_channels``, because its weight is stored ``(in, out, k)``. Fusing over the wrong
|
| 22 |
+
axis yields a well-formed decoder that sounds subtly wrong, which is the kind of bug that
|
| 23 |
+
gets misattributed to precision for days -- hence
|
| 24 |
+
:func:`assert_weight_norm_axes_consistent`.
|
| 25 |
+
|
| 26 |
+
Also folded here, so the device side stays simple:
|
| 27 |
+
|
| 28 |
+
* ``attn.q_bias`` / ``attn.zero_k_bias`` / ``attn.v_bias`` -> one ``attn.qkv.bias``. The
|
| 29 |
+
reference passes ``cat(q_bias, zero_k_bias, v_bias)`` to a single ``F.linear``; the k
|
| 30 |
+
third is a frozen zero, which is asserted before being discarded.
|
| 31 |
+
* ``activations.{2i}`` -> ``acts1.{i}`` and ``activations.{2i+1}`` -> ``acts2.{i}``, which
|
| 32 |
+
is the layout ``vocoder_ltx.AMPBlock1`` already expects.
|
| 33 |
+
* ``ups.{i}.0`` -> ``ups.{i}`` and ``activation_post`` -> ``act_post``.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import re
|
| 39 |
+
|
| 40 |
+
import torch
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def fuse_weight_norm(weight_g: torch.Tensor, weight_v: torch.Tensor) -> torch.Tensor:
|
| 44 |
+
"""``weight_g * weight_v / ||weight_v||``, L2-reduced over every axis but axis 0.
|
| 45 |
+
|
| 46 |
+
Matches ``torch.nn.utils.weight_norm`` at its default ``dim=0`` for both ``Conv1d``
|
| 47 |
+
(axis 0 = out_channels) and ``ConvTranspose1d`` (axis 0 = **in_channels**). The result
|
| 48 |
+
keeps the checkpoint's own layout; transposing is the conv class's job, not ours.
|
| 49 |
+
"""
|
| 50 |
+
norm = weight_v.flatten(1).norm(dim=1).view(-1, *([1] * (weight_v.dim() - 1)))
|
| 51 |
+
return weight_g * weight_v / norm
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def assert_weight_norm_axes_consistent(state: dict[str, torch.Tensor]) -> None:
|
| 55 |
+
"""Guard the axis-0 assumption on every weight-normed conv in the checkpoint.
|
| 56 |
+
|
| 57 |
+
For each ``weight_g``/``weight_v`` pair, ``weight_g`` must be a per-axis-0 scalar --
|
| 58 |
+
shape ``(axis0, 1, 1)`` -- and any sibling ``bias`` must be length ``out_channels``.
|
| 59 |
+
For a ``Conv1d`` that is ``weight_v.shape[0]``; for a ``ConvTranspose1d`` it is
|
| 60 |
+
``weight_v.shape[1]``. A bias that matches neither means the layout is not what this
|
| 61 |
+
converter assumes.
|
| 62 |
+
"""
|
| 63 |
+
for key in sorted(k for k in state if k.endswith(".weight_g")):
|
| 64 |
+
prefix = key[: -len(".weight_g")]
|
| 65 |
+
weight_g = state[key]
|
| 66 |
+
weight_v = state[f"{prefix}.weight_v"]
|
| 67 |
+
assert (
|
| 68 |
+
weight_g.shape[0] == weight_v.shape[0]
|
| 69 |
+
), f"{prefix}: weight_g axis 0 is {weight_g.shape[0]} but weight_v axis 0 is {weight_v.shape[0]}"
|
| 70 |
+
assert all(
|
| 71 |
+
size == 1 for size in weight_g.shape[1:]
|
| 72 |
+
), f"{prefix}: weight_g {tuple(weight_g.shape)} is not a per-axis-0 scalar, so dim=0 is the wrong axis"
|
| 73 |
+
bias = state.get(f"{prefix}.bias")
|
| 74 |
+
if bias is not None:
|
| 75 |
+
assert bias.shape[0] in (weight_v.shape[0], weight_v.shape[1]), (
|
| 76 |
+
f"{prefix}: bias {bias.shape[0]} matches neither weight_v axis "
|
| 77 |
+
f"({weight_v.shape[0]}, {weight_v.shape[1]})"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def fuse_all_weight_norms(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 82 |
+
"""Replace every ``weight_g``/``weight_v`` pair with a single fused ``weight``."""
|
| 83 |
+
assert_weight_norm_axes_consistent(state)
|
| 84 |
+
fused = {}
|
| 85 |
+
for key, value in state.items():
|
| 86 |
+
if key.endswith(".weight_v"):
|
| 87 |
+
prefix = key[: -len(".weight_v")]
|
| 88 |
+
fused[f"{prefix}.weight"] = fuse_weight_norm(state[f"{prefix}.weight_g"], value)
|
| 89 |
+
elif key.endswith(".weight_g"):
|
| 90 |
+
continue
|
| 91 |
+
else:
|
| 92 |
+
fused[key] = value
|
| 93 |
+
return fused
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def fuse_attention_biases(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 97 |
+
"""Fold ``q_bias`` / ``zero_k_bias`` / ``v_bias`` into one ``qkv.bias``.
|
| 98 |
+
|
| 99 |
+
The reference builds ``cat(q_bias, zero_k_bias, v_bias)`` for a single fused
|
| 100 |
+
projection. ``zero_k_bias`` is a frozen zero buffer; that is asserted rather than
|
| 101 |
+
assumed, so a future checkpoint that unfreezes it fails loudly instead of silently
|
| 102 |
+
losing a bias term.
|
| 103 |
+
"""
|
| 104 |
+
out = dict(state)
|
| 105 |
+
prefixes = {k[: -len(".q_bias")] for k in state if k.endswith(".q_bias")}
|
| 106 |
+
for prefix in sorted(prefixes):
|
| 107 |
+
q_bias = out.pop(f"{prefix}.q_bias")
|
| 108 |
+
k_bias = out.pop(f"{prefix}.zero_k_bias", None)
|
| 109 |
+
v_bias = out.pop(f"{prefix}.v_bias")
|
| 110 |
+
if k_bias is None:
|
| 111 |
+
k_bias = torch.zeros_like(q_bias)
|
| 112 |
+
else:
|
| 113 |
+
assert (
|
| 114 |
+
torch.count_nonzero(k_bias) == 0
|
| 115 |
+
), f"{prefix}.zero_k_bias is not all zero -- it is a real parameter and cannot be dropped"
|
| 116 |
+
out[f"{prefix}.qkv.bias"] = torch.cat([q_bias, k_bias, v_bias], dim=0)
|
| 117 |
+
return out
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
_ACTIVATION_RE = re.compile(r"^(?P<prefix>.*?)activations\.(?P<index>\d+)\.(?P<rest>.*)$")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def remap_amp_activations(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 124 |
+
"""``activations.{2i}`` -> ``acts1.{i}``, ``activations.{2i+1}`` -> ``acts2.{i}``.
|
| 125 |
+
|
| 126 |
+
H3 stores one flat list of six activations per AMP block; ``vocoder_ltx.AMPBlock1``
|
| 127 |
+
holds them as two lists of three, interleaved. Getting this backwards produces a
|
| 128 |
+
plausible-looking decoder, so it is worth doing in one obvious place.
|
| 129 |
+
"""
|
| 130 |
+
out = {}
|
| 131 |
+
for key, value in state.items():
|
| 132 |
+
match = _ACTIVATION_RE.match(key)
|
| 133 |
+
if match is None:
|
| 134 |
+
out[key] = value
|
| 135 |
+
continue
|
| 136 |
+
index = int(match["index"])
|
| 137 |
+
target = "acts1" if index % 2 == 0 else "acts2"
|
| 138 |
+
out[f"{match['prefix']}{target}.{index // 2}.{match['rest']}"] = value
|
| 139 |
+
return out
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def remap_decoder_names(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 143 |
+
"""``ups.{i}.0`` -> ``ups.{i}``, ``activation_post`` -> ``act_post``."""
|
| 144 |
+
out = {}
|
| 145 |
+
for key, value in state.items():
|
| 146 |
+
key = re.sub(r"(^|\.)ups\.(\d+)\.0\.", r"\1ups.\2.", key)
|
| 147 |
+
key = key.replace("activation_post.", "act_post.")
|
| 148 |
+
out[key] = value
|
| 149 |
+
return out
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def convert_minimax_h3_audio_state_dict(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 153 |
+
"""The whole conversion, in the order the steps depend on each other.
|
| 154 |
+
|
| 155 |
+
Weight norm is fused first so the later renames only ever move plain ``weight`` keys.
|
| 156 |
+
"""
|
| 157 |
+
converted = fuse_all_weight_norms(state)
|
| 158 |
+
converted = fuse_attention_biases(converted)
|
| 159 |
+
converted = remap_amp_activations(converted)
|
| 160 |
+
return remap_decoder_names(converted)
|
code/models/tt_dit/models/audio_vae/minimax_h3/decoder_minimax_h3_audio.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""MiniMax-H3 audio VAE decode: a 1x1 latent projection into a BigVGAN vocoder.
|
| 6 |
+
|
| 7 |
+
Almost all of this already exists. ``audio_vae/vocoder_ltx.py``'s ``Vocoder`` is a
|
| 8 |
+
BigVGAN-v2 AMP1 stack whose ``forward`` is *identical* to H3's reference
|
| 9 |
+
``MiniMaxH3AudioBigVGANDecoder.forward`` -- ``conv_pre``, then per stage
|
| 10 |
+
``ups[i]`` followed by the mean of three parallel AMP blocks, then ``act_post``,
|
| 11 |
+
``conv_post`` and ``clamp(-1, 1)`` -- including the row-major
|
| 12 |
+
``resblocks[i * num_kernels + j]`` ordering. Every loop in it is generic over
|
| 13 |
+
``len(upsample_rates)``, so H3's **seven** stages need only configuration, not code.
|
| 14 |
+
|
| 15 |
+
The channel schedule falls out of the same ``upsample_initial_channel // 2**i`` rule:
|
| 16 |
+
1024 -> 512, 256, 128, 64, 32, 16, **8**, and ``conv_post`` takes that 8 to 1 mono
|
| 17 |
+
channel. Stereo is carried as batch 2, so nothing here is stereo-aware.
|
| 18 |
+
|
| 19 |
+
What this module adds is only:
|
| 20 |
+
|
| 21 |
+
* ``dec_in_proj``, the 1x1 conv from 32 latent channels to the vocoder's 2048;
|
| 22 |
+
* the wiring that keeps its output on device instead of round-tripping to host between
|
| 23 |
+
the projection and the vocoder.
|
| 24 |
+
|
| 25 |
+
Naming is chosen so the converted checkpoint loads with no fixups: the vocoder is held as
|
| 26 |
+
a child called ``decoder``, matching ``decoder.conv_pre`` / ``decoder.ups.N`` /
|
| 27 |
+
``decoder.resblocks.N`` / ``decoder.act_post`` / ``decoder.conv_post``, and the projection
|
| 28 |
+
is ``dec_in_proj``. The ``ups.{i}.0 -> ups.{i}`` and ``activations -> acts1/acts2`` remaps,
|
| 29 |
+
and the weight-norm fusion, all happen in ``convert_minimax_h3_audio.py``.
|
| 30 |
+
|
| 31 |
+
fp32 throughout: ``vocoder_ltx``'s docstring records that bf16 accumulation measurably
|
| 32 |
+
degrades spectral metrics through its 108-conv chain, and H3's is longer still.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
|
| 39 |
+
import ttnn
|
| 40 |
+
|
| 41 |
+
from ....layers.audio_ops import DEFAULT_MAX_C_IN_BLOCK, _AlignedOutConv1d
|
| 42 |
+
from ....layers.module import Module
|
| 43 |
+
from ....parallel.config import ParallelFactor
|
| 44 |
+
from ....parallel.manager import CCLManager
|
| 45 |
+
from ....utils.tensor import local_device_to_torch
|
| 46 |
+
from ..vocoder_ltx import Vocoder
|
| 47 |
+
from .blockings_minimax_h3_audio import register_h3_audio_blockings
|
| 48 |
+
|
| 49 |
+
TILE_HEIGHT = 32
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class MiniMaxH3AudioDecoder(Module):
|
| 53 |
+
"""``(B, 32, T)`` latents to ``(B, 1, T * 800)`` mono waveform at 32 kHz."""
|
| 54 |
+
|
| 55 |
+
def __init__(
|
| 56 |
+
self,
|
| 57 |
+
*,
|
| 58 |
+
latent_channels: int = 32,
|
| 59 |
+
latent_dim: int = 2048,
|
| 60 |
+
decoder_dim: int = 1024,
|
| 61 |
+
decoder_rates: tuple[int, ...] = (5, 5, 2, 2, 2, 2, 2),
|
| 62 |
+
decoder_kernel_sizes: tuple[int, ...] = (9, 9, 4, 4, 4, 4, 4),
|
| 63 |
+
resblock_kernel_sizes: tuple[int, ...] = (3, 7, 11),
|
| 64 |
+
resblock_dilation_sizes: tuple[tuple[int, ...], ...] = ((1, 3, 5), (1, 3, 5), (1, 3, 5)),
|
| 65 |
+
mesh_device: ttnn.MeshDevice,
|
| 66 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 67 |
+
parallel_config: ParallelFactor | None = None,
|
| 68 |
+
ccl_manager: CCLManager | None = None,
|
| 69 |
+
split_mode: str = "full",
|
| 70 |
+
max_c_in_block: int = DEFAULT_MAX_C_IN_BLOCK,
|
| 71 |
+
) -> None:
|
| 72 |
+
super().__init__()
|
| 73 |
+
self.mesh_device = mesh_device
|
| 74 |
+
self.dtype = dtype
|
| 75 |
+
self.parallel_config = parallel_config
|
| 76 |
+
self.latent_channels = latent_channels
|
| 77 |
+
self.latent_dim = latent_dim
|
| 78 |
+
self.hop_length = 1
|
| 79 |
+
for rate in decoder_rates:
|
| 80 |
+
self.hop_length *= rate
|
| 81 |
+
|
| 82 |
+
# The conv3d precision levers default to accurate. H3-only: LTX constructs the same conv
|
| 83 |
+
# classes with its own fast defaults. Kept as attributes so the pipeline's device-weight
|
| 84 |
+
# cache key (`weights_variant`) reads the exact values this module was built with. The
|
| 85 |
+
# depthwise resample filters need no lever: their conv1d kernel is exact in fp32
|
| 86 |
+
# (see compute_depthwise_conv1d.cpp).
|
| 87 |
+
self.split_mode = split_mode
|
| 88 |
+
self.max_c_in_block = max_c_in_block
|
| 89 |
+
|
| 90 |
+
# H3's audio channel schedule differs from LTX's at both ends, so every conv misses
|
| 91 |
+
# _FP32_BLOCKINGS. Seed stubs before any conv is built; see that module for why stubs.
|
| 92 |
+
register_h3_audio_blockings(max_c_in_block=max_c_in_block)
|
| 93 |
+
|
| 94 |
+
# k1 conv, so no padding mode to get wrong. _AlignedOutConv1d rather than the base
|
| 95 |
+
# class per its own docstring: a non-32-multiple out count reaches conv3d and
|
| 96 |
+
# produces a buffer whose page size does not divide its length.
|
| 97 |
+
self.dec_in_proj = _AlignedOutConv1d(
|
| 98 |
+
latent_channels,
|
| 99 |
+
latent_dim,
|
| 100 |
+
kernel_size=1,
|
| 101 |
+
bias=True,
|
| 102 |
+
mesh_device=mesh_device,
|
| 103 |
+
dtype=dtype,
|
| 104 |
+
parallel_config=parallel_config,
|
| 105 |
+
ccl_manager=ccl_manager,
|
| 106 |
+
split_mode=split_mode,
|
| 107 |
+
)
|
| 108 |
+
self.decoder = Vocoder(
|
| 109 |
+
resblock_kernel_sizes=list(resblock_kernel_sizes),
|
| 110 |
+
upsample_rates=list(decoder_rates),
|
| 111 |
+
upsample_kernel_sizes=list(decoder_kernel_sizes),
|
| 112 |
+
resblock_dilation_sizes=[list(d) for d in resblock_dilation_sizes],
|
| 113 |
+
upsample_initial_channel=decoder_dim,
|
| 114 |
+
in_channels=latent_dim,
|
| 115 |
+
out_channels=1,
|
| 116 |
+
use_tanh_at_final=False,
|
| 117 |
+
apply_final_activation=True,
|
| 118 |
+
use_bias_at_final=False, # H3's conv_post has no bias
|
| 119 |
+
mesh_device=mesh_device,
|
| 120 |
+
dtype=dtype,
|
| 121 |
+
parallel_config=parallel_config,
|
| 122 |
+
ccl_manager=ccl_manager,
|
| 123 |
+
# H3-only opt-in: LTX's vocoder keeps its default single-conv weights.
|
| 124 |
+
split_mode=split_mode,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
def _project_latents_device(self, latents_BCT: torch.Tensor) -> torch.Tensor:
|
| 128 |
+
"""``(B, 32, T)`` -> ``(B, 2048, T)`` through ``dec_in_proj`` on device.
|
| 129 |
+
|
| 130 |
+
Torch in, torch out, matching the stage convention ``bwe_ltx.VocoderWithBWE``
|
| 131 |
+
uses (``_compute_mel_device``, ``_resample_device``): each stage owns its own
|
| 132 |
+
upload and readback, which keeps the stages independently testable.
|
| 133 |
+
"""
|
| 134 |
+
x = latents_BCT.transpose(1, 2).float().contiguous() # (B, T, C)
|
| 135 |
+
t_pad = self._t_padding(x.shape[1])
|
| 136 |
+
if t_pad:
|
| 137 |
+
x = torch.nn.functional.pad(x, (0, 0, 0, t_pad))
|
| 138 |
+
|
| 139 |
+
x_device = ttnn.from_torch(x, device=self.mesh_device, layout=ttnn.ROW_MAJOR_LAYOUT, dtype=self.dtype)
|
| 140 |
+
projected_device = self.dec_in_proj(x_device)
|
| 141 |
+
projected = local_device_to_torch(projected_device).float()
|
| 142 |
+
if t_pad:
|
| 143 |
+
# Crop the alignment padding back off before handing T to the vocoder, which
|
| 144 |
+
# applies its own padding for its own sharding.
|
| 145 |
+
projected = projected[:, : x.shape[1] - t_pad]
|
| 146 |
+
return projected.transpose(1, 2).contiguous() # (B, 2048, T)
|
| 147 |
+
|
| 148 |
+
def forward(self, latents_BCT: torch.Tensor, *, traced: bool = False) -> torch.Tensor:
|
| 149 |
+
"""``(B, latent_channels, T)`` torch in, ``(B, 1, T * hop_length)`` torch out.
|
| 150 |
+
|
| 151 |
+
``traced`` replays a captured device graph for the vocoder instead of dispatching it
|
| 152 |
+
op by op. The vocoder is ~70 % host-bound, so this is its dominant lever -- unlike the
|
| 153 |
+
*visual* halves, which measure 1.00x traced because they are device-bound. Needs a
|
| 154 |
+
``trace_region_size`` on the mesh device; the first call at a shape captures, later
|
| 155 |
+
calls replay.
|
| 156 |
+
"""
|
| 157 |
+
_, channels, _ = latents_BCT.shape
|
| 158 |
+
assert channels == self.latent_channels, f"expected {self.latent_channels} latent channels, got {channels}"
|
| 159 |
+
projected = self._project_latents_device(latents_BCT)
|
| 160 |
+
return self.decoder.forward_BCT_traced(projected) if traced else self.decoder.forward_BCT(projected)
|
| 161 |
+
|
| 162 |
+
def _t_padding(self, num_frames: int) -> int:
|
| 163 |
+
"""T padding needed for tile-aligned per-chip shards; zero when unsharded."""
|
| 164 |
+
if self.parallel_config is None or self.parallel_config.factor <= 1:
|
| 165 |
+
return 0
|
| 166 |
+
align = TILE_HEIGHT * self.parallel_config.factor
|
| 167 |
+
return (-num_frames) % align
|
| 168 |
+
|
| 169 |
+
def release_trace(self) -> None:
|
| 170 |
+
"""Free the vocoder's captured traces; safe when none is active."""
|
| 171 |
+
self.decoder.release_trace()
|
code/models/tt_dit/models/audio_vae/minimax_h3/encoder_minimax_h3_audio.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2026 Tenstorrent AI ULC
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""MiniMax-H3 audio VAE encode: a DAC waveform encoder plus a causal-attention projection.
|
| 6 |
+
|
| 7 |
+
``(B, 1, samples)`` at 32 kHz to a 40 Hz, 32-channel posterior -- a hop of
|
| 8 |
+
``prod(encoder_rates) = 800``. Mono; stereo is batch 2.
|
| 9 |
+
|
| 10 |
+
Structure mirrors the reference one-for-one so the converted checkpoint loads with no
|
| 11 |
+
fixups. Everything nests under ``block`` ModuleLists because the reference uses
|
| 12 |
+
``nn.Sequential``, which is why the checkpoint keys read
|
| 13 |
+
``encoder.block.1.block.0.block.1.weight``.
|
| 14 |
+
|
| 15 |
+
Reused rather than rewritten, all from ``layers/audio_ops.py``:
|
| 16 |
+
|
| 17 |
+
* ``Snake`` with ``alpha_logscale=False`` -- the DAC activation stores ``alpha`` linearly,
|
| 18 |
+
unlike the decoder's ``SnakeBeta`` which is log-scale;
|
| 19 |
+
* ``DilatedConv1d`` for the residual units' k=7 convs, whose ``eff_k // 2`` same-padding
|
| 20 |
+
already equals the reference's ``((7-1)*d)//2``;
|
| 21 |
+
* ``_AlignedOutConv1d`` everywhere else, per its own docstring: a non-32-multiple channel
|
| 22 |
+
count reaching ``conv3d`` produces a buffer whose page size does not divide its length.
|
| 23 |
+
|
| 24 |
+
The five strided channel-doubling convs pair ``kernel = 2 * stride`` with
|
| 25 |
+
``padding = ceil(stride / 2)``, passed through ``Conv1dViaConv3d``'s optional ``padding``
|
| 26 |
+
argument: the derived default ``eff_k // 2`` gives ``L/stride + 1`` instead of ``L/stride``.
|
| 27 |
+
The default stands, so LTX call sites are unaffected.
|
| 28 |
+
|
| 29 |
+
The reference's residual-unit shortcut crop is a no-op here -- the dilated conv's padding is
|
| 30 |
+
exact-same, so the crop width is zero -- but the lengths are asserted rather than assumed.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import math
|
| 36 |
+
|
| 37 |
+
import torch
|
| 38 |
+
|
| 39 |
+
import ttnn
|
| 40 |
+
|
| 41 |
+
from ....layers.audio_ops import DEFAULT_MAX_C_IN_BLOCK, Snake, _AlignedOutConv1d
|
| 42 |
+
from ....layers.module import Module, ModuleList
|
| 43 |
+
from ....layers.normalization import LayerNorm
|
| 44 |
+
from ....parallel.config import ParallelFactor
|
| 45 |
+
from ....parallel.manager import CCLManager
|
| 46 |
+
from ....utils.tensor import local_device_to_torch
|
| 47 |
+
from ..vocoder_ltx import DilatedConv1d
|
| 48 |
+
from .blockings_minimax_h3_audio import register_h3_audio_blockings
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _snake_row_major(snake: Snake, x_BTC: ttnn.Tensor) -> ttnn.Tensor:
|
| 52 |
+
"""Apply ``Snake`` and hand back ROW_MAJOR, which is what the next conv requires.
|
| 53 |
+
|
| 54 |
+
LTX only ever uses ``Snake``/``SnakeBeta`` inside ``Activation1d``, whose resamplers own
|
| 55 |
+
the layout. H3's DAC encoder applies a **bare** Snake between convolutions, and the
|
| 56 |
+
elementwise chain inside it returns TILE, so ``Conv1dViaConv3d``'s ROW_MAJOR assertion
|
| 57 |
+
fires without this.
|
| 58 |
+
"""
|
| 59 |
+
return ttnn.to_layout(snake(x_BTC), ttnn.ROW_MAJOR_LAYOUT)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class MiniMaxH3AudioResidualUnit(Module):
|
| 63 |
+
"""``Snake -> dilated Conv1d(k=7) -> Snake -> Conv1d(k=1)``, plus a residual add."""
|
| 64 |
+
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
dim: int,
|
| 68 |
+
dilation: int,
|
| 69 |
+
*,
|
| 70 |
+
mesh_device: ttnn.MeshDevice,
|
| 71 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 72 |
+
parallel_config: ParallelFactor | None = None,
|
| 73 |
+
ccl_manager: CCLManager | None = None,
|
| 74 |
+
split_mode: str = "off",
|
| 75 |
+
) -> None:
|
| 76 |
+
super().__init__()
|
| 77 |
+
shared = dict(mesh_device=mesh_device, dtype=dtype, parallel_config=parallel_config, ccl_manager=ccl_manager)
|
| 78 |
+
# Conv-only levers: Snake takes neither, so they ride a separate dict from `shared`.
|
| 79 |
+
levers = dict(split_mode=split_mode)
|
| 80 |
+
self.block = ModuleList(
|
| 81 |
+
[
|
| 82 |
+
Snake(dim, alpha_logscale=False, **{k: v for k, v in shared.items() if k != "ccl_manager"}),
|
| 83 |
+
DilatedConv1d(dim, dim, kernel_size=7, dilation=dilation, **shared, **levers),
|
| 84 |
+
Snake(dim, alpha_logscale=False, **{k: v for k, v in shared.items() if k != "ccl_manager"}),
|
| 85 |
+
_AlignedOutConv1d(dim, dim, kernel_size=1, **shared, **levers),
|
| 86 |
+
]
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def forward(self, x_BTC: ttnn.Tensor) -> ttnn.Tensor:
|
| 90 |
+
residual = x_BTC
|
| 91 |
+
for layer in self.block:
|
| 92 |
+
x_BTC = _snake_row_major(layer, x_BTC) if isinstance(layer, Snake) else layer(x_BTC)
|
| 93 |
+
assert x_BTC.shape[1] == residual.shape[1], (
|
| 94 |
+
f"residual unit changed T from {residual.shape[1]} to {x_BTC.shape[1]}; "
|
| 95 |
+
"the reference's centre crop is only a no-op when the dilated conv is exact-same"
|
| 96 |
+
)
|
| 97 |
+
return ttnn.add(residual, x_BTC)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class MiniMaxH3AudioEncoderBlock(Module):
|
| 101 |
+
"""Three residual units at dilations 1/3/9, then a strided channel-doubling conv."""
|
| 102 |
+
|
| 103 |
+
def __init__(
|
| 104 |
+
self,
|
| 105 |
+
dim: int,
|
| 106 |
+
stride: int,
|
| 107 |
+
*,
|
| 108 |
+
mesh_device: ttnn.MeshDevice,
|
| 109 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 110 |
+
parallel_config: ParallelFactor | None = None,
|
| 111 |
+
ccl_manager: CCLManager | None = None,
|
| 112 |
+
split_mode: str = "off",
|
| 113 |
+
) -> None:
|
| 114 |
+
super().__init__()
|
| 115 |
+
shared = dict(mesh_device=mesh_device, dtype=dtype, parallel_config=parallel_config, ccl_manager=ccl_manager)
|
| 116 |
+
levers = dict(split_mode=split_mode)
|
| 117 |
+
inner = dim // 2
|
| 118 |
+
self.stride = stride
|
| 119 |
+
self.block = ModuleList(
|
| 120 |
+
[
|
| 121 |
+
MiniMaxH3AudioResidualUnit(inner, 1, **shared, **levers),
|
| 122 |
+
MiniMaxH3AudioResidualUnit(inner, 3, **shared, **levers),
|
| 123 |
+
MiniMaxH3AudioResidualUnit(inner, 9, **shared, **levers),
|
| 124 |
+
Snake(inner, alpha_logscale=False, **{k: v for k, v in shared.items() if k != "ccl_manager"}),
|
| 125 |
+
# kernel = 2 * stride with padding = ceil(stride / 2) is what makes the output
|
| 126 |
+
# exactly L / stride; the class's default eff_k // 2 would give one extra frame.
|
| 127 |
+
_AlignedOutConv1d(
|
| 128 |
+
inner,
|
| 129 |
+
dim,
|
| 130 |
+
kernel_size=2 * stride,
|
| 131 |
+
stride=stride,
|
| 132 |
+
padding=math.ceil(stride / 2),
|
| 133 |
+
**shared,
|
| 134 |
+
**levers,
|
| 135 |
+
),
|
| 136 |
+
]
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def forward(self, x_BTC: ttnn.Tensor) -> ttnn.Tensor:
|
| 140 |
+
expected_out = x_BTC.shape[1] // self.stride
|
| 141 |
+
for layer in self.block:
|
| 142 |
+
x_BTC = _snake_row_major(layer, x_BTC) if isinstance(layer, Snake) else layer(x_BTC)
|
| 143 |
+
assert (
|
| 144 |
+
x_BTC.shape[1] == expected_out
|
| 145 |
+
), f"strided conv produced T={x_BTC.shape[1]}, expected {expected_out} -- check the padding override"
|
| 146 |
+
return x_BTC
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class MiniMaxH3AudioDACEncoder(Module):
|
| 150 |
+
"""``conv(1 -> encoder_dim, k7) -> 5 encoder blocks -> Snake -> conv(k3)``."""
|
| 151 |
+
|
| 152 |
+
def __init__(
|
| 153 |
+
self,
|
| 154 |
+
*,
|
| 155 |
+
encoder_dim: int = 64,
|
| 156 |
+
encoder_rates: tuple[int, ...] = (2, 4, 4, 5, 5),
|
| 157 |
+
latent_dim: int = 2048,
|
| 158 |
+
mesh_device: ttnn.MeshDevice,
|
| 159 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 160 |
+
parallel_config: ParallelFactor | None = None,
|
| 161 |
+
ccl_manager: CCLManager | None = None,
|
| 162 |
+
split_mode: str = "off",
|
| 163 |
+
) -> None:
|
| 164 |
+
super().__init__()
|
| 165 |
+
shared = dict(mesh_device=mesh_device, dtype=dtype, parallel_config=parallel_config, ccl_manager=ccl_manager)
|
| 166 |
+
no_ccl = {k: v for k, v in shared.items() if k != "ccl_manager"}
|
| 167 |
+
levers = dict(split_mode=split_mode)
|
| 168 |
+
|
| 169 |
+
layers: list[Module] = [_AlignedOutConv1d(1, encoder_dim, kernel_size=7, **shared, **levers)]
|
| 170 |
+
dim = encoder_dim
|
| 171 |
+
for stride in encoder_rates:
|
| 172 |
+
dim *= 2
|
| 173 |
+
layers.append(MiniMaxH3AudioEncoderBlock(dim, stride, **shared, **levers))
|
| 174 |
+
layers.append(Snake(dim, alpha_logscale=False, **no_ccl))
|
| 175 |
+
layers.append(_AlignedOutConv1d(dim, latent_dim, kernel_size=3, **shared, **levers))
|
| 176 |
+
self.block = ModuleList(layers)
|
| 177 |
+
self.hop_length = math.prod(encoder_rates)
|
| 178 |
+
|
| 179 |
+
def forward(self, x_BTC: ttnn.Tensor) -> ttnn.Tensor:
|
| 180 |
+
for layer in self.block:
|
| 181 |
+
x_BTC = _snake_row_major(layer, x_BTC) if isinstance(layer, Snake) else layer(x_BTC)
|
| 182 |
+
return x_BTC
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class MiniMaxH3AudioCausalAttention(Module):
|
| 186 |
+
"""Causal attention that narrows ``in_dim`` to ``out_dim`` by pooling, not concatenating.
|
| 187 |
+
|
| 188 |
+
The heads are **mean-pooled away** rather than concatenated, and the surviving head
|
| 189 |
+
dimension is average-pooled from ``in_dim // num_heads`` down to ``out_dim``. Since
|
| 190 |
+
``256 % 32 == 0``, that pool is exactly a mean over contiguous groups of 8, so it is a
|
| 191 |
+
reshape and a mean rather than anything adaptive.
|
| 192 |
+
|
| 193 |
+
The checkpoint stores one fused biasless ``qkv`` plus separate ``q_bias``/``v_bias`` and
|
| 194 |
+
a frozen-zero ``zero_k_bias``; the converter folds all three into ``qkv.bias``.
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
def __init__(
|
| 198 |
+
self,
|
| 199 |
+
in_dim: int,
|
| 200 |
+
out_dim: int,
|
| 201 |
+
num_heads: int,
|
| 202 |
+
*,
|
| 203 |
+
mesh_device: ttnn.MeshDevice,
|
| 204 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 205 |
+
) -> None:
|
| 206 |
+
super().__init__()
|
| 207 |
+
from ....layers.linear import Linear
|
| 208 |
+
|
| 209 |
+
self.in_dim = in_dim
|
| 210 |
+
self.out_dim = out_dim
|
| 211 |
+
self.num_heads = num_heads
|
| 212 |
+
self.head_dim = in_dim // num_heads
|
| 213 |
+
assert (
|
| 214 |
+
self.head_dim % out_dim == 0
|
| 215 |
+
), f"head_dim {self.head_dim} must be a whole multiple of out_dim {out_dim} for the pool to be a plain mean"
|
| 216 |
+
self.pool_group = self.head_dim // out_dim
|
| 217 |
+
self.mesh_device = mesh_device
|
| 218 |
+
self.dtype = dtype
|
| 219 |
+
|
| 220 |
+
self.qkv = Linear(in_dim, 3 * in_dim, bias=True, mesh_device=mesh_device, dtype=dtype)
|
| 221 |
+
self.proj = Linear(out_dim, out_dim, bias=True, mesh_device=mesh_device, dtype=dtype)
|
| 222 |
+
|
| 223 |
+
def forward(self, x_BSC: ttnn.Tensor) -> ttnn.Tensor:
|
| 224 |
+
batch, seq_len, _ = x_BSC.shape
|
| 225 |
+
# The qkv projection is 2048 -> 6144 over the whole latent sequence, and at a 10 s
|
| 226 |
+
# clip (405 frames, batch 2 for stereo) the default matmul blocking overshoots L1
|
| 227 |
+
# (1979264 B against 1572864 B). A smaller block trades a little throughput for a
|
| 228 |
+
# working shape; the real fix is a tuned config, which is a performance-pass job.
|
| 229 |
+
qkv = self.qkv(x_BSC, default_block_size=(4, 4, 4))
|
| 230 |
+
query, key, value = (
|
| 231 |
+
ttnn.permute(ttnn.reshape(part, (batch, seq_len, self.num_heads, self.head_dim)), (0, 2, 1, 3))
|
| 232 |
+
for part in ttnn.chunk(qkv, 3, dim=-1)
|
| 233 |
+
)
|
| 234 |
+
# SDPA is bf16-only (`sdpa_device_operation.cpp:43`), so this one op is a bf16 island
|
| 235 |
+
# inside the otherwise-fp32 audio path -- the same shape of compromise the visual
|
| 236 |
+
# encoder makes for GroupNorm.
|
| 237 |
+
query, key, value = (
|
| 238 |
+
ttnn.typecast(t, ttnn.bfloat16) if t.get_dtype() != ttnn.bfloat16 else t for t in (query, key, value)
|
| 239 |
+
)
|
| 240 |
+
attended = ttnn.transformer.scaled_dot_product_attention(query, key, value, is_causal=True)
|
| 241 |
+
if attended.get_dtype() != self.dtype:
|
| 242 |
+
attended = ttnn.typecast(attended, self.dtype)
|
| 243 |
+
|
| 244 |
+
# (B, heads, S, head_dim) -> mean over heads -> (B, S, head_dim)
|
| 245 |
+
pooled = ttnn.mean(ttnn.permute(attended, (0, 2, 1, 3)), dim=2)
|
| 246 |
+
pooled = ttnn.reshape(pooled, (batch, seq_len, self.out_dim, self.pool_group))
|
| 247 |
+
pooled = ttnn.mean(pooled, dim=3)
|
| 248 |
+
return self.proj(pooled)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class MiniMaxH3AudioGeGluMlp(Module):
|
| 252 |
+
"""Pre-norm GeGLU: ``w2(gelu_tanh(w0(norm(x))) * w1(norm(x)))``."""
|
| 253 |
+
|
| 254 |
+
def __init__(
|
| 255 |
+
self,
|
| 256 |
+
in_features: int,
|
| 257 |
+
hidden_features: int,
|
| 258 |
+
*,
|
| 259 |
+
mesh_device: ttnn.MeshDevice,
|
| 260 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 261 |
+
) -> None:
|
| 262 |
+
super().__init__()
|
| 263 |
+
from ....layers.linear import Linear
|
| 264 |
+
|
| 265 |
+
self.norm = LayerNorm(in_features, norm_eps=1e-5, bias=True, mesh_device=mesh_device)
|
| 266 |
+
self.w0 = Linear(in_features, hidden_features, bias=True, mesh_device=mesh_device, dtype=dtype)
|
| 267 |
+
self.w1 = Linear(in_features, hidden_features, bias=True, mesh_device=mesh_device, dtype=dtype)
|
| 268 |
+
self.w2 = Linear(hidden_features, in_features, bias=True, mesh_device=mesh_device, dtype=dtype)
|
| 269 |
+
|
| 270 |
+
def forward(self, x: ttnn.Tensor) -> ttnn.Tensor:
|
| 271 |
+
normed = self.norm(x)
|
| 272 |
+
# nn.GELU(approximate="tanh")
|
| 273 |
+
gated = ttnn.gelu(self.w0(normed), fast_and_approximate_mode=True)
|
| 274 |
+
return self.w2(ttnn.mul(gated, self.w1(normed)))
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class MiniMaxH3AudioAttnProjection(Module):
|
| 278 |
+
"""``pre_block``: ``proj(norm3(x)) + attn(norm1(x))``, then ``+ mlp(norm2(.))``.
|
| 279 |
+
|
| 280 |
+
Note the residual is a **projected** bypass, not ``x`` itself -- the block changes width
|
| 281 |
+
from ``latent_dim`` to ``latent_channels`` inside the residual, which is unusual and
|
| 282 |
+
easy to get wrong.
|
| 283 |
+
"""
|
| 284 |
+
|
| 285 |
+
def __init__(
|
| 286 |
+
self,
|
| 287 |
+
in_dim: int,
|
| 288 |
+
out_dim: int,
|
| 289 |
+
num_heads: int,
|
| 290 |
+
*,
|
| 291 |
+
mlp_ratio: int = 2,
|
| 292 |
+
mesh_device: ttnn.MeshDevice,
|
| 293 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 294 |
+
) -> None:
|
| 295 |
+
super().__init__()
|
| 296 |
+
from ....layers.linear import Linear
|
| 297 |
+
|
| 298 |
+
self.norm1 = LayerNorm(in_dim, norm_eps=1e-5, bias=True, mesh_device=mesh_device)
|
| 299 |
+
self.attn = MiniMaxH3AudioCausalAttention(in_dim, out_dim, num_heads, mesh_device=mesh_device, dtype=dtype)
|
| 300 |
+
self.proj = Linear(in_dim, out_dim, bias=True, mesh_device=mesh_device, dtype=dtype)
|
| 301 |
+
self.norm3 = LayerNorm(in_dim, norm_eps=1e-5, bias=True, mesh_device=mesh_device)
|
| 302 |
+
self.norm2 = LayerNorm(out_dim, norm_eps=1e-5, bias=True, mesh_device=mesh_device)
|
| 303 |
+
self.mlp = MiniMaxH3AudioGeGluMlp(out_dim, out_dim * mlp_ratio, mesh_device=mesh_device, dtype=dtype)
|
| 304 |
+
|
| 305 |
+
def forward(self, x: ttnn.Tensor) -> ttnn.Tensor:
|
| 306 |
+
hidden = ttnn.add(self.proj(self.norm3(x)), self.attn(self.norm1(x)))
|
| 307 |
+
return ttnn.add(hidden, self.mlp(self.norm2(hidden)))
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
class MiniMaxH3AudioEncoder(Module):
|
| 311 |
+
"""Waveform to posterior: DAC trunk, ``pre_block``, then ``mean_proj`` / ``logs_proj``.
|
| 312 |
+
|
| 313 |
+
Returns ``(mean, logs)`` as torch tensors. Sampling stays on host, matching how the
|
| 314 |
+
visual encoder leaves its posterior draw to the caller.
|
| 315 |
+
"""
|
| 316 |
+
|
| 317 |
+
def __init__(
|
| 318 |
+
self,
|
| 319 |
+
*,
|
| 320 |
+
encoder_dim: int = 64,
|
| 321 |
+
encoder_rates: tuple[int, ...] = (2, 4, 4, 5, 5),
|
| 322 |
+
latent_dim: int = 2048,
|
| 323 |
+
latent_channels: int = 32,
|
| 324 |
+
num_attention_heads: int = 8,
|
| 325 |
+
mesh_device: ttnn.MeshDevice,
|
| 326 |
+
dtype: ttnn.DataType = ttnn.float32,
|
| 327 |
+
parallel_config: ParallelFactor | None = None,
|
| 328 |
+
ccl_manager: CCLManager | None = None,
|
| 329 |
+
split_mode: str = "full",
|
| 330 |
+
max_c_in_block: int = DEFAULT_MAX_C_IN_BLOCK,
|
| 331 |
+
) -> None:
|
| 332 |
+
super().__init__()
|
| 333 |
+
self.mesh_device = mesh_device
|
| 334 |
+
self.dtype = dtype
|
| 335 |
+
self.latent_channels = latent_channels
|
| 336 |
+
self.hop_length = math.prod(encoder_rates)
|
| 337 |
+
|
| 338 |
+
# The precision levers default to accurate, same rationale as the decoder. H3-only: LTX
|
| 339 |
+
# constructs the same conv classes with its own fast defaults. Kept as attributes so the
|
| 340 |
+
# pipeline's device-weight cache key (`weights_variant`) reads the exact values this module
|
| 341 |
+
# was built with.
|
| 342 |
+
self.split_mode = split_mode
|
| 343 |
+
self.max_c_in_block = max_c_in_block
|
| 344 |
+
|
| 345 |
+
# Every H3 audio conv shape misses _FP32_BLOCKINGS; seed stubs before any conv is built.
|
| 346 |
+
register_h3_audio_blockings(max_c_in_block=max_c_in_block)
|
| 347 |
+
|
| 348 |
+
self.encoder = MiniMaxH3AudioDACEncoder(
|
| 349 |
+
encoder_dim=encoder_dim,
|
| 350 |
+
encoder_rates=encoder_rates,
|
| 351 |
+
latent_dim=latent_dim,
|
| 352 |
+
mesh_device=mesh_device,
|
| 353 |
+
dtype=dtype,
|
| 354 |
+
parallel_config=parallel_config,
|
| 355 |
+
ccl_manager=ccl_manager,
|
| 356 |
+
split_mode=split_mode,
|
| 357 |
+
)
|
| 358 |
+
self.pre_block = MiniMaxH3AudioAttnProjection(
|
| 359 |
+
latent_dim, latent_channels, num_attention_heads, mesh_device=mesh_device, dtype=dtype
|
| 360 |
+
)
|
| 361 |
+
self.mean_proj = _AlignedOutConv1d(
|
| 362 |
+
latent_channels,
|
| 363 |
+
latent_channels,
|
| 364 |
+
kernel_size=1,
|
| 365 |
+
mesh_device=mesh_device,
|
| 366 |
+
dtype=dtype,
|
| 367 |
+
split_mode=split_mode,
|
| 368 |
+
)
|
| 369 |
+
self.logs_proj = _AlignedOutConv1d(
|
| 370 |
+
latent_channels,
|
| 371 |
+
latent_channels,
|
| 372 |
+
kernel_size=1,
|
| 373 |
+
mesh_device=mesh_device,
|
| 374 |
+
dtype=dtype,
|
| 375 |
+
split_mode=split_mode,
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
def forward(self, waveform_BCT: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 379 |
+
"""``(B, 1, samples)`` torch in, ``(mean, logs)`` each ``(B, 32, samples/800)`` torch."""
|
| 380 |
+
_, channels, num_samples = waveform_BCT.shape
|
| 381 |
+
assert channels == 1, f"the audio VAE is mono; stereo is batch 2. Got {channels} channels"
|
| 382 |
+
assert (
|
| 383 |
+
num_samples % self.hop_length == 0
|
| 384 |
+
), f"{num_samples} samples is not a whole number of {self.hop_length}-sample hops"
|
| 385 |
+
|
| 386 |
+
x = waveform_BCT.transpose(1, 2).float().contiguous() # (B, T, 1)
|
| 387 |
+
x_device = ttnn.from_torch(x, device=self.mesh_device, layout=ttnn.ROW_MAJOR_LAYOUT, dtype=self.dtype)
|
| 388 |
+
|
| 389 |
+
trunk = self.encoder(x_device)
|
| 390 |
+
# pre_block is a transformer block, so it wants TILE; the convs want ROW_MAJOR.
|
| 391 |
+
projected = self.pre_block(ttnn.to_layout(trunk, ttnn.TILE_LAYOUT))
|
| 392 |
+
projected = ttnn.to_layout(projected, ttnn.ROW_MAJOR_LAYOUT)
|
| 393 |
+
|
| 394 |
+
# The upload replicates and nothing here is fractured, so every device holds the same
|
| 395 |
+
# result: read back one. A bare ``ttnn.to_torch`` asserts ``buffers.size() == 1`` and so
|
| 396 |
+
# only works on a single-device mesh. Same fix as ``MiniMaxH3AudioDecoder.__call__``.
|
| 397 |
+
def read(tensor: ttnn.Tensor) -> torch.Tensor:
|
| 398 |
+
# See `MiniMaxH3AudioDecoder.__call__`: a storage slice keeps the parent's distribution
|
| 399 |
+
# metadata and the converter rejects it on a multi-host mesh. The helper reads a shard this
|
| 400 |
+
# host owns instead, which for a replicated tensor is the whole answer.
|
| 401 |
+
return local_device_to_torch(tensor).float()
|
| 402 |
+
|
| 403 |
+
mean = read(self.mean_proj(projected))
|
| 404 |
+
logs = read(self.logs_proj(projected))
|
| 405 |
+
return mean.transpose(1, 2).contiguous(), logs.transpose(1, 2).contiguous()
|
code/models/tt_dit/models/transformers/ltx/attention_ltx.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent AI ULC
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
import ttnn
|
| 12 |
+
from models.common.utility_functions import is_blackhole
|
| 13 |
+
|
| 14 |
+
from ....layers.linear import ColParallelLinear, LoRAColParallelLinear, maybe_cast_activation, resolve_output_dtype
|
| 15 |
+
from ....layers.module import Module
|
| 16 |
+
from ....layers.normalization import DistributedRMSNorm
|
| 17 |
+
from ....parallel.config import DiTParallelConfig
|
| 18 |
+
from ....parallel.manager import CCLManager
|
| 19 |
+
from ....utils.matmul import get_fabric_agmm_config, get_matmul_config
|
| 20 |
+
from ....utils.substate import pop_substate, rename_substate
|
| 21 |
+
from ....utils.tensor import bf16_tensor
|
| 22 |
+
from .quant_config import LtxQuantProfile
|
| 23 |
+
|
| 24 |
+
# to_gate_logits and to_q/to_qkv are both ColParallelLinear fed the SAME activation, and each fuses
|
| 25 |
+
# its own TP all-gather of it (all_gather_minimal_matmul_async) — the activation crosses the fabric
|
| 26 |
+
# twice. The fused gather barely overlaps its matmul, so gathering once explicitly and running both
|
| 27 |
+
# projections as plain matmuls on the gathered tensor is the same math for one gather less.
|
| 28 |
+
# Set to 0 to restore the double gather for an A/B.
|
| 29 |
+
LTX_DEDUP_GATE_GATHER = os.environ.get("LTX_DEDUP_GATE_GATHER", "1") in ("1", "true", "True")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LTXAttention(Module):
|
| 33 |
+
# Map from (is_blackhole, sp_factor, tp_factor) -> (q_chunk_size, k_chunk_size)
|
| 34 |
+
sdpa_chunk_size_map = {
|
| 35 |
+
(False, 2, 4): (256, 256),
|
| 36 |
+
(False, 8, 4): (256, 256),
|
| 37 |
+
(True, 2, 2): (128, 512),
|
| 38 |
+
(True, 8, 4): (128, 512),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
# V2A cross ring-SDPA q_chunk = the per-device audio Q (audio_N / sp_factor), keyed by
|
| 42 |
+
# (is_blackhole, sp, tp); assumes audio_N=256. A q_chunk wider than the Q shard pads the
|
| 43 |
+
# query rows and burns ~2x SDPA compute, so it must track sp, not be fixed per model.
|
| 44 |
+
# k_chunk reuses the self-attn ring value; misses fall back to the self-attn ring q_chunk.
|
| 45 |
+
# TODO: audio_N depends on video duration (ceil(round((num_frames/fps)*25), 32*sp)); derive
|
| 46 |
+
# q_chunk from the actual Q shard (q_BHNE.shape[2]) instead of hardcoding per mesh.
|
| 47 |
+
cross_ring_sdpa_q_chunk_map = {
|
| 48 |
+
(True, 4, 2): 64, # BH 2x4
|
| 49 |
+
(True, 8, 4): 32, # BH 4x8
|
| 50 |
+
}
|
| 51 |
+
default_sdpa_chunk_size = (256, 256)
|
| 52 |
+
|
| 53 |
+
# Per-stage ring-SDPA chunk, keyed by (is_blackhole, sp, tp, N); N is the SP-padded
|
| 54 |
+
# sequence length passed to the op. Misses fall back to sdpa_chunk_size_map.
|
| 55 |
+
ring_sdpa_chunk_by_n = {
|
| 56 |
+
(True, 8, 4, 9728): (96, 256),
|
| 57 |
+
(True, 8, 4, 38912): (192, 512),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# Per-shape cross-attn SDPA chunk, keyed by (is_blackhole, q_seq, kv_seq); seqs are
|
| 61 |
+
# the per-device Q shard and full K. Misses fall back to sdpa_program_config.
|
| 62 |
+
sdpa_chunk_by_shape = {
|
| 63 |
+
(True, 1216, 32): (128, 128), # video text cross-attn, stage 1
|
| 64 |
+
(True, 4864, 32): (192, 128), # video text cross-attn, stage 2
|
| 65 |
+
(True, 1216, 256): (128, 128), # audio->video cross-attn, stage 1
|
| 66 |
+
(True, 4864, 256): (192, 256), # audio->video cross-attn, stage 2
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
*,
|
| 72 |
+
dim: int,
|
| 73 |
+
num_heads: int,
|
| 74 |
+
qk_norm: bool = True,
|
| 75 |
+
eps: float = 1e-6,
|
| 76 |
+
mesh_device: ttnn.MeshDevice,
|
| 77 |
+
ccl_manager: CCLManager | None = None,
|
| 78 |
+
parallel_config: DiTParallelConfig,
|
| 79 |
+
is_fsdp: bool = False,
|
| 80 |
+
is_self: bool = True,
|
| 81 |
+
context_dim: int | None = None,
|
| 82 |
+
query_input_dim: int | None = None,
|
| 83 |
+
output_dim: int | None = None,
|
| 84 |
+
apply_gated_attention: bool = False,
|
| 85 |
+
quant_config: LtxQuantProfile | None = None,
|
| 86 |
+
lora_enabled: bool = False,
|
| 87 |
+
) -> None:
|
| 88 |
+
super().__init__()
|
| 89 |
+
|
| 90 |
+
assert dim % num_heads == 0
|
| 91 |
+
self.dim = dim
|
| 92 |
+
self.num_heads = num_heads
|
| 93 |
+
self.head_dim = dim // num_heads
|
| 94 |
+
self.qk_norm = qk_norm
|
| 95 |
+
self.eps = eps
|
| 96 |
+
self.is_self = is_self
|
| 97 |
+
self.query_input_dim = query_input_dim or dim
|
| 98 |
+
self.output_dim = output_dim or dim
|
| 99 |
+
|
| 100 |
+
self.mesh_device = mesh_device
|
| 101 |
+
self.ccl_manager = ccl_manager
|
| 102 |
+
self.parallel_config = parallel_config
|
| 103 |
+
|
| 104 |
+
self.n_local_heads = self.num_heads // self.parallel_config.tensor_parallel.factor
|
| 105 |
+
|
| 106 |
+
fsdp_mesh_axis = self.parallel_config.sequence_parallel.mesh_axis if is_fsdp else None
|
| 107 |
+
|
| 108 |
+
rms_kwargs = {
|
| 109 |
+
"embedding_dim": dim,
|
| 110 |
+
"norm_eps": eps,
|
| 111 |
+
"norm_elementwise_affine": True,
|
| 112 |
+
"bias": False,
|
| 113 |
+
"mesh_device": mesh_device,
|
| 114 |
+
"mesh_axis": parallel_config.tensor_parallel.mesh_axis,
|
| 115 |
+
"ccl_manager": ccl_manager,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
self.norm_q = DistributedRMSNorm(**rms_kwargs)
|
| 119 |
+
self.norm_k = DistributedRMSNorm(**rms_kwargs)
|
| 120 |
+
|
| 121 |
+
col_parallel_kwargs = {
|
| 122 |
+
"bias": True,
|
| 123 |
+
"mesh_device": mesh_device,
|
| 124 |
+
"mesh_axis": parallel_config.tensor_parallel.mesh_axis,
|
| 125 |
+
"fsdp_mesh_axis": fsdp_mesh_axis,
|
| 126 |
+
"ccl_manager": ccl_manager,
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
self.kv_input_dim = context_dim if (context_dim is not None and not is_self) else dim
|
| 130 |
+
|
| 131 |
+
# Per-linear precision comes entirely from the quant profile (None => bf16 everywhere, matching
|
| 132 |
+
# the unquantized model). The profile owns the to_out carve-out and the LTX_QUANT_ACTIVATIONS
|
| 133 |
+
# gating; here each role's linear just spreads the kwargs the profile hands it.
|
| 134 |
+
def qk(role):
|
| 135 |
+
return quant_config.linear_kwargs(role) if quant_config is not None else {}
|
| 136 |
+
|
| 137 |
+
# Fuse-mode LoRA lives in weight.data, so the chunked (to_qkv/to_kv) and
|
| 138 |
+
# fused-addcmul (to_out) paths work unchanged; runtime mode is unsupported here. The quant
|
| 139 |
+
# kwargs forward through LoRAColParallelLinear's **kwargs to the base linear.
|
| 140 |
+
ColCls = LoRAColParallelLinear if lora_enabled else ColParallelLinear
|
| 141 |
+
|
| 142 |
+
# Fuse the per-head gate into the QKV/Q matmul via variable-width chunks
|
| 143 |
+
tp_factor = parallel_config.tensor_parallel.factor
|
| 144 |
+
uses_fused_agmm = tp_factor > 1 and ccl_manager is not None and ccl_manager.topology == ttnn.Topology.Ring
|
| 145 |
+
# A fused linear carries a single weight dtype, but the quant profile holds the gate at bf16
|
| 146 |
+
# while quantizing qkv/q, so the two cannot share one matmul: fusion is bf16-only.
|
| 147 |
+
self.fuse_gate = apply_gated_attention and uses_fused_agmm and quant_config is None
|
| 148 |
+
|
| 149 |
+
# Gate is num_heads/TP columns per device (sub-tile); pad to a whole tile so it's a legal chunk.
|
| 150 |
+
self.gate_width_per_device = self.n_local_heads
|
| 151 |
+
self.gate_padded_per_device = ((self.n_local_heads + ttnn.TILE_SIZE - 1) // ttnn.TILE_SIZE) * ttnn.TILE_SIZE
|
| 152 |
+
gate_fused_width = self.gate_padded_per_device * tp_factor
|
| 153 |
+
|
| 154 |
+
if is_self:
|
| 155 |
+
if self.fuse_gate:
|
| 156 |
+
self.to_qkv = ColCls(
|
| 157 |
+
dim,
|
| 158 |
+
3 * dim + gate_fused_width,
|
| 159 |
+
chunks=4,
|
| 160 |
+
chunk_sizes=[dim, dim, dim, gate_fused_width],
|
| 161 |
+
**col_parallel_kwargs,
|
| 162 |
+
**qk("qkv"),
|
| 163 |
+
)
|
| 164 |
+
else:
|
| 165 |
+
self.to_qkv = ColCls(dim, 3 * dim, chunks=3, **col_parallel_kwargs, **qk("qkv"))
|
| 166 |
+
else:
|
| 167 |
+
if self.fuse_gate:
|
| 168 |
+
self.to_q = ColCls(
|
| 169 |
+
self.query_input_dim,
|
| 170 |
+
dim + gate_fused_width,
|
| 171 |
+
chunks=2,
|
| 172 |
+
chunk_sizes=[dim, gate_fused_width],
|
| 173 |
+
**col_parallel_kwargs,
|
| 174 |
+
**qk("q"),
|
| 175 |
+
)
|
| 176 |
+
else:
|
| 177 |
+
self.to_q = ColCls(self.query_input_dim, dim, **col_parallel_kwargs, **qk("q"))
|
| 178 |
+
self.to_kv = ColCls(self.kv_input_dim, 2 * dim, chunks=2, **col_parallel_kwargs, **qk("kv"))
|
| 179 |
+
|
| 180 |
+
self.to_out = ColCls(
|
| 181 |
+
dim,
|
| 182 |
+
self.output_dim,
|
| 183 |
+
bias=True,
|
| 184 |
+
mesh_device=mesh_device,
|
| 185 |
+
mesh_axis=parallel_config.tensor_parallel.mesh_axis,
|
| 186 |
+
fsdp_mesh_axis=fsdp_mesh_axis,
|
| 187 |
+
ccl_manager=ccl_manager,
|
| 188 |
+
**qk("out"),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Per-head gate, sharded on num_heads to match the SDPA-output head layout
|
| 192 |
+
self.apply_gated_attention = apply_gated_attention
|
| 193 |
+
if apply_gated_attention and not self.fuse_gate:
|
| 194 |
+
# Standalone gate weight stays bf16 (the model's working dtype) while consuming the shared
|
| 195 |
+
# bf8 activation, so its output is pinned back to bf16 like the quantized projections.
|
| 196 |
+
self.to_gate_logits = ColParallelLinear(
|
| 197 |
+
in_features=self.query_input_dim,
|
| 198 |
+
out_features=self.num_heads,
|
| 199 |
+
bias=True,
|
| 200 |
+
dtype=ttnn.bfloat16,
|
| 201 |
+
mesh_device=mesh_device,
|
| 202 |
+
mesh_axis=parallel_config.tensor_parallel.mesh_axis,
|
| 203 |
+
fsdp_mesh_axis=fsdp_mesh_axis,
|
| 204 |
+
ccl_manager=ccl_manager,
|
| 205 |
+
**qk("gate"),
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
self.dummy_joint_input = bf16_tensor(torch.zeros((1, self.n_local_heads, 0, self.head_dim)), device=mesh_device)
|
| 209 |
+
|
| 210 |
+
full_grid = self.mesh_device.compute_with_storage_grid_size()
|
| 211 |
+
self.sdpa_program_config = ttnn.SDPAProgramConfig(
|
| 212 |
+
compute_with_storage_grid_size=full_grid,
|
| 213 |
+
q_chunk_size=256,
|
| 214 |
+
k_chunk_size=256,
|
| 215 |
+
exp_approx_mode=False,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
self.sdpa_worker_grid = (full_grid.x - 1, full_grid.y)
|
| 219 |
+
mesh_key = (
|
| 220 |
+
is_blackhole(),
|
| 221 |
+
self.parallel_config.sequence_parallel.factor,
|
| 222 |
+
self.parallel_config.tensor_parallel.factor,
|
| 223 |
+
)
|
| 224 |
+
ring_sdpa_chunk_size = self.sdpa_chunk_size_map.get(mesh_key, self.default_sdpa_chunk_size)
|
| 225 |
+
self.ring_sdpa_program_config = ttnn.SDPAProgramConfig(
|
| 226 |
+
compute_with_storage_grid_size=self.sdpa_worker_grid,
|
| 227 |
+
q_chunk_size=ring_sdpa_chunk_size[0],
|
| 228 |
+
k_chunk_size=ring_sdpa_chunk_size[1],
|
| 229 |
+
exp_approx_mode=False,
|
| 230 |
+
)
|
| 231 |
+
self._ring_pc_by_n = {
|
| 232 |
+
n: ttnn.SDPAProgramConfig(
|
| 233 |
+
compute_with_storage_grid_size=self.sdpa_worker_grid,
|
| 234 |
+
q_chunk_size=chunk[0],
|
| 235 |
+
k_chunk_size=chunk[1],
|
| 236 |
+
exp_approx_mode=False,
|
| 237 |
+
)
|
| 238 |
+
for (b, sp, tp, n), chunk in self.ring_sdpa_chunk_by_n.items()
|
| 239 |
+
if (b, sp, tp) == mesh_key
|
| 240 |
+
}
|
| 241 |
+
self._sdpa_pc_by_shape = {
|
| 242 |
+
(q, kv): ttnn.SDPAProgramConfig(
|
| 243 |
+
compute_with_storage_grid_size=full_grid,
|
| 244 |
+
q_chunk_size=chunk[0],
|
| 245 |
+
k_chunk_size=chunk[1],
|
| 246 |
+
exp_approx_mode=False,
|
| 247 |
+
)
|
| 248 |
+
for (b, q, kv), chunk in self.sdpa_chunk_by_shape.items()
|
| 249 |
+
if b == mesh_key[0]
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
# V2A cross ring SDPA: q_chunk matched to the per-device audio Q; k_chunk reuses the
|
| 253 |
+
# self-attn ring value.
|
| 254 |
+
cross_ring_q_chunk = self.cross_ring_sdpa_q_chunk_map.get(mesh_key, ring_sdpa_chunk_size[0])
|
| 255 |
+
self.cross_ring_sdpa_program_config = ttnn.SDPAProgramConfig(
|
| 256 |
+
compute_with_storage_grid_size=self.sdpa_worker_grid,
|
| 257 |
+
q_chunk_size=cross_ring_q_chunk,
|
| 258 |
+
k_chunk_size=ring_sdpa_chunk_size[1],
|
| 259 |
+
exp_approx_mode=False,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# All SDPA (ring + cross) runs HiFi2, matching the Wan attention config.
|
| 263 |
+
self.sdpa_compute_kernel_config = ttnn.init_device_compute_kernel_config(
|
| 264 |
+
self.mesh_device.arch(),
|
| 265 |
+
math_fidelity=ttnn.MathFidelity.HiFi2,
|
| 266 |
+
math_approx_mode=False,
|
| 267 |
+
fp32_dest_acc_en=False,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
self.rope_compute_kernel_config = ttnn.init_device_compute_kernel_config(
|
| 271 |
+
self.mesh_device.arch(),
|
| 272 |
+
math_fidelity=ttnn.MathFidelity.HiFi4,
|
| 273 |
+
math_approx_mode=False,
|
| 274 |
+
fp32_dest_acc_en=True,
|
| 275 |
+
packer_l1_acc=True,
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
device_grid = self.mesh_device.compute_with_storage_grid_size()
|
| 279 |
+
self.core_grid = ttnn.CoreGrid(x=device_grid.x, y=device_grid.y)
|
| 280 |
+
|
| 281 |
+
# Attention QKV/out matmuls run HiFi2, matching the Wan attention config.
|
| 282 |
+
self.mm_compute_kernel_config = ttnn.init_device_compute_kernel_config(
|
| 283 |
+
self.mesh_device.arch(),
|
| 284 |
+
math_fidelity=ttnn.MathFidelity.HiFi2,
|
| 285 |
+
math_approx_mode=True,
|
| 286 |
+
fp32_dest_acc_en=True,
|
| 287 |
+
packer_l1_acc=True,
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Under a quant preset, override the attention compute configs to the profile's fidelity.
|
| 291 |
+
# Self-attn additionally swaps in the ring-SDPA compute and narrows its SDPA inputs; cross-attn
|
| 292 |
+
# leaves SDPA and _sdpa_input_dtype unset, so forward's getattr(self, "_sdpa_input_dtype", None)
|
| 293 |
+
# keeps cross SDPA at bf16.
|
| 294 |
+
if quant_config is not None:
|
| 295 |
+
arch = self.mesh_device.arch()
|
| 296 |
+
self.mm_compute_kernel_config = quant_config.mm_compute_config(arch)
|
| 297 |
+
if self.is_self:
|
| 298 |
+
self.sdpa_compute_kernel_config, self._sdpa_input_dtype = quant_config.sdpa_self_config(arch)
|
| 299 |
+
|
| 300 |
+
def _prepare_torch_state(self, state: dict[str, torch.Tensor]) -> None:
|
| 301 |
+
rename_substate(state, "to_out.0", "to_out")
|
| 302 |
+
rename_substate(state, "q_norm", "norm_q")
|
| 303 |
+
rename_substate(state, "k_norm", "norm_k")
|
| 304 |
+
|
| 305 |
+
# Permute Q/K (and norm_q/norm_k) channels per head from checkpoint SPLIT rotation to
|
| 306 |
+
# the INTERLEAVED layout rotary_embedding_llama expects: even output lanes take the
|
| 307 |
+
# first half of each head's channels, odd lanes the second half.
|
| 308 |
+
D = self.head_dim
|
| 309 |
+
D_half = D // 2
|
| 310 |
+
perm = torch.empty(D, dtype=torch.long)
|
| 311 |
+
perm[0::2] = torch.arange(D_half)
|
| 312 |
+
perm[1::2] = torch.arange(D_half, D)
|
| 313 |
+
|
| 314 |
+
def _permute_qk(t: torch.Tensor) -> torch.Tensor:
|
| 315 |
+
rest = t.shape[1:]
|
| 316 |
+
t = t.reshape(self.num_heads, D, *rest).index_select(1, perm)
|
| 317 |
+
return t.reshape(self.num_heads * D, *rest)
|
| 318 |
+
|
| 319 |
+
for nk in ("norm_q.weight", "norm_k.weight"):
|
| 320 |
+
if nk in state:
|
| 321 |
+
state[nk] = _permute_qk(state[nk])
|
| 322 |
+
|
| 323 |
+
def _interleave_heads(tensors: list[torch.Tensor]):
|
| 324 |
+
n_dev = self.parallel_config.tensor_parallel.factor
|
| 325 |
+
tensors = [t.T for t in tensors]
|
| 326 |
+
tensors = [t.reshape(t.shape[0], n_dev, self.n_local_heads, self.head_dim) for t in tensors]
|
| 327 |
+
merged = torch.cat(tensors, dim=2)
|
| 328 |
+
merged = merged.reshape(merged.shape[0], len(tensors) * self.num_heads * self.head_dim)
|
| 329 |
+
return merged.T
|
| 330 |
+
|
| 331 |
+
def _pad_gate_to_tile(t: torch.Tensor) -> torch.Tensor:
|
| 332 |
+
"""Zero-pad each device's gate block from n_local_heads up to a whole tile (device-major)."""
|
| 333 |
+
n_dev = self.parallel_config.tensor_parallel.factor
|
| 334 |
+
pad = self.gate_padded_per_device - self.n_local_heads
|
| 335 |
+
if pad == 0:
|
| 336 |
+
return t
|
| 337 |
+
blocks = []
|
| 338 |
+
for d in range(n_dev):
|
| 339 |
+
blocks.append(t[d * self.n_local_heads : (d + 1) * self.n_local_heads])
|
| 340 |
+
blocks.append(torch.zeros(pad, *t.shape[1:], dtype=t.dtype))
|
| 341 |
+
return torch.cat(blocks, dim=0)
|
| 342 |
+
|
| 343 |
+
def _gate_state_or_zeros(reference: torch.Tensor, want_bias: bool) -> dict[str, torch.Tensor]:
|
| 344 |
+
"""Gate substate, zero-filled when the checkpoint has none (zeros -> identity gating)."""
|
| 345 |
+
g = pop_substate(state, "to_gate_logits")
|
| 346 |
+
if "weight" not in g:
|
| 347 |
+
g["weight"] = torch.zeros(self.num_heads, reference.shape[1], dtype=reference.dtype)
|
| 348 |
+
if want_bias and "bias" not in g:
|
| 349 |
+
g["bias"] = torch.zeros(self.num_heads, dtype=reference.dtype)
|
| 350 |
+
return g
|
| 351 |
+
|
| 352 |
+
def _interleave_device_major(tensors: list[torch.Tensor]):
|
| 353 |
+
"""Like _interleave_heads but for per-device widths that differ (q/k/v head_dim vs gate)."""
|
| 354 |
+
n_dev = self.parallel_config.tensor_parallel.factor
|
| 355 |
+
reshaped = []
|
| 356 |
+
for t in tensors:
|
| 357 |
+
t = t.T # [in, out]
|
| 358 |
+
assert t.shape[1] % n_dev == 0, f"projection width {t.shape[1]} not divisible by TP={n_dev}"
|
| 359 |
+
reshaped.append(t.reshape(t.shape[0], n_dev, t.shape[1] // n_dev))
|
| 360 |
+
merged = torch.cat(reshaped, dim=2)
|
| 361 |
+
return merged.reshape(merged.shape[0], -1).T
|
| 362 |
+
|
| 363 |
+
if self.is_self:
|
| 364 |
+
q_state = pop_substate(state, "to_q")
|
| 365 |
+
k_state = pop_substate(state, "to_k")
|
| 366 |
+
v_state = pop_substate(state, "to_v")
|
| 367 |
+
|
| 368 |
+
q_state["weight"] = _permute_qk(q_state["weight"])
|
| 369 |
+
k_state["weight"] = _permute_qk(k_state["weight"])
|
| 370 |
+
if "bias" in q_state:
|
| 371 |
+
q_state["bias"] = _permute_qk(q_state["bias"])
|
| 372 |
+
if "bias" in k_state:
|
| 373 |
+
k_state["bias"] = _permute_qk(k_state["bias"])
|
| 374 |
+
|
| 375 |
+
if self.fuse_gate:
|
| 376 |
+
# Fold the gate in as a 4th chunk, device-major: device d holds [q_d | k_d | v_d | gate_d].
|
| 377 |
+
g_state = _gate_state_or_zeros(q_state["weight"], "bias" in q_state)
|
| 378 |
+
g_state = {k: _pad_gate_to_tile(v) for k, v in g_state.items()}
|
| 379 |
+
state["to_qkv.weight"] = _interleave_device_major(
|
| 380 |
+
[q_state["weight"], k_state["weight"], v_state["weight"], g_state["weight"]]
|
| 381 |
+
)
|
| 382 |
+
if "bias" in q_state:
|
| 383 |
+
bias = _interleave_device_major(
|
| 384 |
+
[
|
| 385 |
+
q_state["bias"].unsqueeze(-1),
|
| 386 |
+
k_state["bias"].unsqueeze(-1),
|
| 387 |
+
v_state["bias"].unsqueeze(-1),
|
| 388 |
+
g_state["bias"].unsqueeze(-1),
|
| 389 |
+
]
|
| 390 |
+
)
|
| 391 |
+
state["to_qkv.bias"] = bias.squeeze(-1)
|
| 392 |
+
else:
|
| 393 |
+
state["to_qkv.weight"] = _interleave_heads([q_state["weight"], k_state["weight"], v_state["weight"]])
|
| 394 |
+
if "bias" in q_state:
|
| 395 |
+
bias = _interleave_heads(
|
| 396 |
+
[q_state["bias"].unsqueeze(-1), k_state["bias"].unsqueeze(-1), v_state["bias"].unsqueeze(-1)]
|
| 397 |
+
)
|
| 398 |
+
state["to_qkv.bias"] = bias.squeeze(-1)
|
| 399 |
+
else:
|
| 400 |
+
k_state = pop_substate(state, "to_k")
|
| 401 |
+
v_state = pop_substate(state, "to_v")
|
| 402 |
+
|
| 403 |
+
k_state["weight"] = _permute_qk(k_state["weight"])
|
| 404 |
+
if "bias" in k_state:
|
| 405 |
+
k_state["bias"] = _permute_qk(k_state["bias"])
|
| 406 |
+
|
| 407 |
+
if "to_q.weight" in state:
|
| 408 |
+
state["to_q.weight"] = _permute_qk(state["to_q.weight"])
|
| 409 |
+
if "to_q.bias" in state:
|
| 410 |
+
state["to_q.bias"] = _permute_qk(state["to_q.bias"])
|
| 411 |
+
|
| 412 |
+
if self.fuse_gate and "to_q.weight" in state:
|
| 413 |
+
# Cross-attn: gate rides along with Q as a 2nd chunk.
|
| 414 |
+
g_state = _gate_state_or_zeros(state["to_q.weight"], "to_q.bias" in state)
|
| 415 |
+
g_state = {k: _pad_gate_to_tile(v) for k, v in g_state.items()}
|
| 416 |
+
state["to_q.weight"] = _interleave_device_major([state["to_q.weight"], g_state["weight"]])
|
| 417 |
+
if "to_q.bias" in state and "bias" in g_state:
|
| 418 |
+
bias = _interleave_device_major([state["to_q.bias"].unsqueeze(-1), g_state["bias"].unsqueeze(-1)])
|
| 419 |
+
state["to_q.bias"] = bias.squeeze(-1)
|
| 420 |
+
|
| 421 |
+
state["to_kv.weight"] = _interleave_heads([k_state["weight"], v_state["weight"]])
|
| 422 |
+
if "bias" in k_state:
|
| 423 |
+
bias = _interleave_heads([k_state["bias"].unsqueeze(-1), v_state["bias"].unsqueeze(-1)])
|
| 424 |
+
state["to_kv.bias"] = bias.squeeze(-1)
|
| 425 |
+
|
| 426 |
+
def _to_out_fused_addcmul(
|
| 427 |
+
self,
|
| 428 |
+
x: ttnn.Tensor,
|
| 429 |
+
addcmul_residual: ttnn.Tensor,
|
| 430 |
+
addcmul_gate: ttnn.Tensor,
|
| 431 |
+
compute_kernel_config=None,
|
| 432 |
+
parallel_config: DiTParallelConfig | None = None,
|
| 433 |
+
dtype=None,
|
| 434 |
+
) -> ttnn.Tensor:
|
| 435 |
+
"""Fused to_out projection + addcmul: output = residual + (matmul(x, W) + bias) * gate."""
|
| 436 |
+
to_out = self.to_out
|
| 437 |
+
# to_out inlines the AG-matmul rather than calling ColParallelLinear.forward, so it has to
|
| 438 |
+
# honour the activation cast itself. The addcmul residual/gate stay bf16 — the kernel ties
|
| 439 |
+
# their tile size to the weight's, not the activation's — and the output is the residual
|
| 440 |
+
# stream, so it must be pinned back to bf16 rather than inheriting the bf8 activation.
|
| 441 |
+
x = maybe_cast_activation(x, to_out.activation_dtype)
|
| 442 |
+
if to_out.pin_output_bf16:
|
| 443 |
+
dtype = resolve_output_dtype(dtype, x)
|
| 444 |
+
|
| 445 |
+
if to_out.fsdp_mesh_axis is not None and to_out.mesh_device.shape[to_out.fsdp_mesh_axis] > 1:
|
| 446 |
+
unsqueezed_weight = ttnn.unsqueeze_to_4D(to_out.weight.data)
|
| 447 |
+
weight = self.ccl_manager.all_gather_persistent_buffer(
|
| 448 |
+
unsqueezed_weight, dim=2, mesh_axis=to_out.fsdp_mesh_axis
|
| 449 |
+
)
|
| 450 |
+
weight = ttnn.reshape(weight, (weight.shape[-2], weight.shape[-1]))
|
| 451 |
+
else:
|
| 452 |
+
weight = to_out.weight.data
|
| 453 |
+
|
| 454 |
+
if parallel_config is not None and parallel_config.tensor_parallel.factor > 1:
|
| 455 |
+
M, K, N_out = x.padded_shape[-2], weight.padded_shape[-2], weight.padded_shape[-1]
|
| 456 |
+
full_grid = self.mesh_device.compute_with_storage_grid_size()
|
| 457 |
+
|
| 458 |
+
# Known shapes route the addcmul to_out to the strided AG-matmul (out = a + scalar*matmul*b)
|
| 459 |
+
fabric_cfg = get_fabric_agmm_config(M, K, N_out, 1, full_grid)
|
| 460 |
+
if fabric_cfg is not None:
|
| 461 |
+
tp_axis = parallel_config.tensor_parallel.mesh_axis
|
| 462 |
+
dram = ttnn.MemoryConfig(ttnn.TensorMemoryLayout.INTERLEAVED, ttnn.BufferType.DRAM)
|
| 463 |
+
fabric_matmul_config = ttnn.MinimalMatmulConfig(
|
| 464 |
+
M_block_size=fabric_cfg.M_block_size,
|
| 465 |
+
K_block_size=fabric_cfg.K_block_size,
|
| 466 |
+
N_block_size=fabric_cfg.N_block_size,
|
| 467 |
+
subblock_h=fabric_cfg.subblock_h,
|
| 468 |
+
subblock_w=fabric_cfg.subblock_w,
|
| 469 |
+
compute_with_storage_grid_size=fabric_cfg.mm_core_grid,
|
| 470 |
+
)
|
| 471 |
+
outputs = ttnn.experimental.strided_all_gather_minimal_matmul_async(
|
| 472 |
+
x,
|
| 473 |
+
weight,
|
| 474 |
+
persistent_output_buffer=self.ccl_manager.get_ag_ping_pong_buffer(
|
| 475 |
+
x.shape, 3, tp_axis, dtype=x.get_dtype()
|
| 476 |
+
),
|
| 477 |
+
dim=3,
|
| 478 |
+
multi_device_global_semaphore=self.ccl_manager.get_strided_ag_mm_semaphore(
|
| 479 |
+
tp_axis, fabric_cfg.num_workers_per_link
|
| 480 |
+
),
|
| 481 |
+
strided_all_gather_core_grid_offset=fabric_cfg.ag_core_grid_offset,
|
| 482 |
+
num_links=self.ccl_manager.num_links,
|
| 483 |
+
memory_config_ag=dram,
|
| 484 |
+
topology=self.ccl_manager.topology,
|
| 485 |
+
cluster_axis=tp_axis,
|
| 486 |
+
bias=to_out.bias.data if to_out.bias is not None else None,
|
| 487 |
+
config=fabric_matmul_config,
|
| 488 |
+
memory_config_mm=dram,
|
| 489 |
+
compute_kernel_config=compute_kernel_config or to_out.compute_config,
|
| 490 |
+
num_workers_per_link=fabric_cfg.num_workers_per_link,
|
| 491 |
+
num_buffers_per_channel=fabric_cfg.num_buffers_per_channel,
|
| 492 |
+
read_local_slice_from_input=True,
|
| 493 |
+
fused_ternary_input_a=addcmul_residual,
|
| 494 |
+
fused_ternary_input_b=addcmul_gate,
|
| 495 |
+
fused_ternary_scalar=1.0,
|
| 496 |
+
chunks=1,
|
| 497 |
+
)
|
| 498 |
+
# Op returns [all_gather_output, matmul_chunk_0]; take the single matmul chunk.
|
| 499 |
+
return outputs[1]
|
| 500 |
+
|
| 501 |
+
core_grid = ttnn.CoreCoord(full_grid.x, full_grid.y - 1)
|
| 502 |
+
matmul_config = get_matmul_config(M, K, N_out, core_grid)
|
| 503 |
+
|
| 504 |
+
ag_persistent_buffer = self.ccl_manager.get_ag_ping_pong_buffer(
|
| 505 |
+
x.shape, 3, parallel_config.tensor_parallel.mesh_axis, dtype=x.get_dtype()
|
| 506 |
+
)
|
| 507 |
+
ag_global_semaphores = self.ccl_manager.get_ag_ping_pong_semaphore(
|
| 508 |
+
parallel_config.tensor_parallel.mesh_axis
|
| 509 |
+
)
|
| 510 |
+
output = ttnn.experimental.all_gather_minimal_matmul_async(
|
| 511 |
+
input_tensor=x,
|
| 512 |
+
weight_tensor=weight,
|
| 513 |
+
bias_tensor=to_out.bias.data if to_out.bias is not None else None,
|
| 514 |
+
config=matmul_config,
|
| 515 |
+
compute_kernel_config=compute_kernel_config or to_out.compute_config,
|
| 516 |
+
persistent_output_buffer=ag_persistent_buffer,
|
| 517 |
+
multi_device_global_semaphore=ag_global_semaphores,
|
| 518 |
+
num_links=self.ccl_manager.num_links,
|
| 519 |
+
topology=self.ccl_manager.topology,
|
| 520 |
+
cluster_axis=parallel_config.tensor_parallel.mesh_axis,
|
| 521 |
+
barrier_semaphore=None,
|
| 522 |
+
force_transpose=True,
|
| 523 |
+
num_workers_per_link=full_grid.x // self.ccl_manager.num_links,
|
| 524 |
+
num_buffers_per_channel=48 if not is_blackhole() else 24,
|
| 525 |
+
scalar=1.0,
|
| 526 |
+
addcmul_input_tensor1=addcmul_residual,
|
| 527 |
+
addcmul_input_tensor2=addcmul_gate,
|
| 528 |
+
dtype=dtype,
|
| 529 |
+
)[0]
|
| 530 |
+
else:
|
| 531 |
+
M, K, N_out = x.padded_shape[-2], x.padded_shape[-1], weight.padded_shape[-1]
|
| 532 |
+
core_grid = self.mesh_device.compute_with_storage_grid_size()
|
| 533 |
+
matmul_config = get_matmul_config(M, K, N_out, core_grid)
|
| 534 |
+
|
| 535 |
+
output = ttnn.experimental.dit_minimal_matmul_addcmul_fused(
|
| 536 |
+
x,
|
| 537 |
+
weight,
|
| 538 |
+
1.0,
|
| 539 |
+
addcmul_residual,
|
| 540 |
+
addcmul_gate,
|
| 541 |
+
bias_tensor=to_out.bias.data if to_out.bias is not None else None,
|
| 542 |
+
config=matmul_config,
|
| 543 |
+
compute_kernel_config=compute_kernel_config or to_out.compute_config,
|
| 544 |
+
dtype=dtype,
|
| 545 |
+
)
|
| 546 |
+
return output
|
| 547 |
+
|
| 548 |
+
def _gate_is_live(self) -> bool:
|
| 549 |
+
"""True when the gate projection will actually run (and so will gather its input).
|
| 550 |
+
|
| 551 |
+
A fused gate has no projection of its own — it falls out of the QKV/Q matmul — so it neither
|
| 552 |
+
runs nor gathers, and ``to_gate_logits`` is never built.
|
| 553 |
+
"""
|
| 554 |
+
return self.apply_gated_attention and not self.fuse_gate and self.to_gate_logits.weight._data is not None
|
| 555 |
+
|
| 556 |
+
def _compute_gate(
|
| 557 |
+
self, spatial_1BND: ttnn.Tensor, qkv_parallel_config: DiTParallelConfig | None
|
| 558 |
+
) -> ttnn.Tensor | None:
|
| 559 |
+
"""Per-head gate 2 * sigmoid(to_gate_logits(x)); returns (B, H_local, N, 1) or None."""
|
| 560 |
+
if not self._gate_is_live():
|
| 561 |
+
return None
|
| 562 |
+
|
| 563 |
+
gate_logits = self.to_gate_logits(spatial_1BND, parallel_config=qkv_parallel_config)
|
| 564 |
+
return self._gate_from_logits(gate_logits)
|
| 565 |
+
|
| 566 |
+
def _unpad_gate_logits(self, gate_logits: ttnn.Tensor) -> ttnn.Tensor:
|
| 567 |
+
"""Drop the tile padding from the fused gate chunk, leaving n_local_heads real columns."""
|
| 568 |
+
if self.gate_padded_per_device == self.gate_width_per_device:
|
| 569 |
+
return gate_logits
|
| 570 |
+
shape = gate_logits.shape
|
| 571 |
+
return ttnn.slice(
|
| 572 |
+
gate_logits,
|
| 573 |
+
[0, 0, 0, 0],
|
| 574 |
+
[shape[0], shape[1], shape[2], self.gate_width_per_device],
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
def _gate_from_logits(self, gate_logits: ttnn.Tensor) -> ttnn.Tensor:
|
| 578 |
+
"""2 * sigmoid(logits) as (B, H_local, N, 1); shared by the fused and unfused paths."""
|
| 579 |
+
gate = ttnn.multiply(ttnn.sigmoid(gate_logits), 2.0)
|
| 580 |
+
return ttnn.permute(gate, (1, 3, 2, 0))
|
| 581 |
+
|
| 582 |
+
def forward(
|
| 583 |
+
self,
|
| 584 |
+
spatial_1BND: ttnn.Tensor,
|
| 585 |
+
N: int,
|
| 586 |
+
prompt_1BLP: ttnn.Tensor | None = None,
|
| 587 |
+
rope_cos: ttnn.Tensor | None = None,
|
| 588 |
+
rope_sin: ttnn.Tensor | None = None,
|
| 589 |
+
trans_mat: ttnn.Tensor | None = None,
|
| 590 |
+
addcmul_residual: ttnn.Tensor | None = None,
|
| 591 |
+
addcmul_gate: ttnn.Tensor | None = None,
|
| 592 |
+
k_rope_cos: ttnn.Tensor | None = None,
|
| 593 |
+
k_rope_sin: ttnn.Tensor | None = None,
|
| 594 |
+
attn_mask: ttnn.Tensor | None = None,
|
| 595 |
+
skip_qk: bool = False,
|
| 596 |
+
kv_replicated: bool = False,
|
| 597 |
+
kv_logical_n: int | None = None,
|
| 598 |
+
) -> ttnn.Tensor:
|
| 599 |
+
"""Same interface as WanAttention.forward(); pass k_rope_cos/sin for separate K RoPE
|
| 600 |
+
in A2V/V2A cross-attention."""
|
| 601 |
+
if rope_cos is not None:
|
| 602 |
+
assert rope_sin is not None
|
| 603 |
+
assert trans_mat is not None, "INTERLEAVED RoPE requires trans_mat (load-time Q/K permute assumes it)"
|
| 604 |
+
|
| 605 |
+
use_nonfused_agmm = (self.ccl_manager.topology == ttnn.Topology.Linear) and (
|
| 606 |
+
self.parallel_config.tensor_parallel.factor > 1
|
| 607 |
+
)
|
| 608 |
+
# With the gate on, the gate and Q/QKV projections would each fuse a gather of the same
|
| 609 |
+
# activation; hoisting one explicit gather feeds both and halves the fabric traffic here.
|
| 610 |
+
dedup_gate_gather = (
|
| 611 |
+
LTX_DEDUP_GATE_GATHER
|
| 612 |
+
and not use_nonfused_agmm
|
| 613 |
+
and self._gate_is_live()
|
| 614 |
+
and self.parallel_config.tensor_parallel.factor > 1
|
| 615 |
+
)
|
| 616 |
+
if use_nonfused_agmm or dedup_gate_gather:
|
| 617 |
+
# Cast BEFORE the gather, not inside the linears downstream of it: this path hoists the
|
| 618 |
+
# gather out so Q/QKV (and the gate) can share it, so the fabric payload is this tensor.
|
| 619 |
+
# Casting it at the linear would happen on the already-gathered result and shrink nothing.
|
| 620 |
+
qkv_linear = self.to_qkv if self.is_self else self.to_q
|
| 621 |
+
spatial_1BND = maybe_cast_activation(spatial_1BND, qkv_linear.activation_dtype)
|
| 622 |
+
spatial_1BND = self.ccl_manager.all_gather_persistent_buffer(
|
| 623 |
+
spatial_1BND, dim=3, mesh_axis=self.parallel_config.tensor_parallel.mesh_axis
|
| 624 |
+
)
|
| 625 |
+
|
| 626 |
+
qkv_parallel_config = None if (use_nonfused_agmm or dedup_gate_gather) else self.parallel_config
|
| 627 |
+
|
| 628 |
+
# When fused, the gate falls out of the QKV/Q matmul below; otherwise it's its own projection.
|
| 629 |
+
gate_bhne = None if self.fuse_gate else self._compute_gate(spatial_1BND, qkv_parallel_config)
|
| 630 |
+
|
| 631 |
+
if self.is_self:
|
| 632 |
+
if self.fuse_gate:
|
| 633 |
+
q_1BNF, k_1BNF, v_1BNF, gate_logits = self.to_qkv(
|
| 634 |
+
spatial_1BND,
|
| 635 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 636 |
+
parallel_config=qkv_parallel_config,
|
| 637 |
+
)
|
| 638 |
+
gate_bhne = self._gate_from_logits(self._unpad_gate_logits(gate_logits))
|
| 639 |
+
else:
|
| 640 |
+
q_1BNF, k_1BNF, v_1BNF = self.to_qkv(
|
| 641 |
+
spatial_1BND,
|
| 642 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 643 |
+
parallel_config=qkv_parallel_config,
|
| 644 |
+
)
|
| 645 |
+
else:
|
| 646 |
+
kv_input = prompt_1BLP if prompt_1BLP is not None else spatial_1BND
|
| 647 |
+
# Cross K/V: gather TP-sharded context for to_kv (replicated text prompt is already full).
|
| 648 |
+
kv_parallel_config = None
|
| 649 |
+
if prompt_1BLP is not None and self.parallel_config.tensor_parallel.factor > 1:
|
| 650 |
+
local_k = kv_input.shape[-1]
|
| 651 |
+
kv_is_tp_sharded = local_k * self.parallel_config.tensor_parallel.factor == self.kv_input_dim
|
| 652 |
+
if kv_is_tp_sharded:
|
| 653 |
+
if use_nonfused_agmm:
|
| 654 |
+
kv_input = self.ccl_manager.all_gather_persistent_buffer(
|
| 655 |
+
kv_input, dim=3, mesh_axis=self.parallel_config.tensor_parallel.mesh_axis
|
| 656 |
+
)
|
| 657 |
+
else:
|
| 658 |
+
kv_parallel_config = self.parallel_config
|
| 659 |
+
if self.fuse_gate:
|
| 660 |
+
q_1BNF, gate_logits = self.to_q(
|
| 661 |
+
spatial_1BND,
|
| 662 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 663 |
+
parallel_config=qkv_parallel_config,
|
| 664 |
+
)
|
| 665 |
+
gate_bhne = self._gate_from_logits(self._unpad_gate_logits(gate_logits))
|
| 666 |
+
else:
|
| 667 |
+
q_1BNF = self.to_q(
|
| 668 |
+
spatial_1BND,
|
| 669 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 670 |
+
parallel_config=qkv_parallel_config,
|
| 671 |
+
)
|
| 672 |
+
k_1BNF, v_1BNF = self.to_kv(
|
| 673 |
+
kv_input,
|
| 674 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 675 |
+
parallel_config=kv_parallel_config,
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
# RMSNorm on Q/K fused with the head split (emits BHNE via num_heads_per_device).
|
| 679 |
+
q_BHNE = self.norm_q(q_1BNF, num_heads_per_device=self.n_local_heads)
|
| 680 |
+
k_BHNE = self.norm_k(k_1BNF, num_heads_per_device=self.n_local_heads)
|
| 681 |
+
|
| 682 |
+
def create_heads(inp):
|
| 683 |
+
out, _, _ = ttnn.experimental.nlp_create_qkv_heads(
|
| 684 |
+
inp, num_heads=self.n_local_heads, num_kv_heads=0, transpose_k_heads=False
|
| 685 |
+
)
|
| 686 |
+
return out
|
| 687 |
+
|
| 688 |
+
# V still goes through the explicit reshape (no norm to fuse with).
|
| 689 |
+
v_BHNE = create_heads(v_1BNF)
|
| 690 |
+
|
| 691 |
+
# Cross-attn K/V must be full-seq for SDPA; gather across SP only when genuinely sharded.
|
| 692 |
+
is_cross = prompt_1BLP is not None
|
| 693 |
+
sp_factor = self.parallel_config.sequence_parallel.factor
|
| 694 |
+
_k_cos_pe = k_rope_cos if k_rope_cos is not None else rope_cos
|
| 695 |
+
# V2A cross: K/V stay SP-sharded (caller passes the sharded K-rope and kv_logical_n) so the
|
| 696 |
+
# ring SDPA fuses the gather instead of an explicit K/V all-gather + local SDPA.
|
| 697 |
+
use_ring_cross = is_cross and sp_factor > 1 and not kv_replicated and kv_logical_n is not None
|
| 698 |
+
if is_cross and sp_factor > 1 and not use_ring_cross:
|
| 699 |
+
sp_axis = self.parallel_config.sequence_parallel.mesh_axis
|
| 700 |
+
if kv_replicated:
|
| 701 |
+
need_gather = False
|
| 702 |
+
elif _k_cos_pe is not None:
|
| 703 |
+
need_gather = k_BHNE.shape[2] < _k_cos_pe.shape[2]
|
| 704 |
+
else:
|
| 705 |
+
# Unknown sharded context with no rope reference: gather conservatively.
|
| 706 |
+
need_gather = True
|
| 707 |
+
if need_gather:
|
| 708 |
+
k_BHNE = self.ccl_manager.all_gather_persistent_buffer(k_BHNE, dim=2, mesh_axis=sp_axis)
|
| 709 |
+
v_BHNE = self.ccl_manager.all_gather_persistent_buffer(v_BHNE, dim=2, mesh_axis=sp_axis)
|
| 710 |
+
|
| 711 |
+
if rope_cos is not None:
|
| 712 |
+
_k_cos = _k_cos_pe
|
| 713 |
+
_k_sin = k_rope_sin if k_rope_sin is not None else rope_sin
|
| 714 |
+
q_BHNE = ttnn.experimental.rotary_embedding_llama(
|
| 715 |
+
q_BHNE, rope_cos, rope_sin, trans_mat, compute_kernel_config=self.rope_compute_kernel_config
|
| 716 |
+
)
|
| 717 |
+
k_BHNE = ttnn.experimental.rotary_embedding_llama(
|
| 718 |
+
k_BHNE, _k_cos, _k_sin, trans_mat, compute_kernel_config=self.rope_compute_kernel_config
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
# SDPA input quant, applied after RoPE so the rotation still runs at full precision. On the
|
| 722 |
+
# ring paths K/V are the fabric payload (SDPA fuses their SP gather), so this shrinks a
|
| 723 |
+
# collective as well as the QK^T/PV matmuls; dummy_joint is a real SDPA input and must carry
|
| 724 |
+
# the same dtype. Kept separate from the linear activation cast: SDPA inputs have the widest
|
| 725 |
+
# dynamic range in the block and are the likeliest place for bf8 to break accuracy.
|
| 726 |
+
sdpa_input_dtype = getattr(self, "_sdpa_input_dtype", None)
|
| 727 |
+
dummy_joint = self.dummy_joint_input
|
| 728 |
+
if sdpa_input_dtype is not None:
|
| 729 |
+
q_BHNE = maybe_cast_activation(q_BHNE, sdpa_input_dtype)
|
| 730 |
+
k_BHNE = maybe_cast_activation(k_BHNE, sdpa_input_dtype)
|
| 731 |
+
v_BHNE = maybe_cast_activation(v_BHNE, sdpa_input_dtype)
|
| 732 |
+
dummy_joint = maybe_cast_activation(dummy_joint, sdpa_input_dtype)
|
| 733 |
+
|
| 734 |
+
if skip_qk:
|
| 735 |
+
# STG perturbation: skip Q/K attention, use V passthrough.
|
| 736 |
+
spatial_BHNE = v_BHNE
|
| 737 |
+
elif prompt_1BLP is None:
|
| 738 |
+
if sp_factor > 1 and attn_mask is None:
|
| 739 |
+
spatial_BHNE, _prompt_BHLE, _lse = ttnn.transformer.ring_joint_scaled_dot_product_attention(
|
| 740 |
+
q_BHNE,
|
| 741 |
+
k_BHNE,
|
| 742 |
+
v_BHNE,
|
| 743 |
+
dummy_joint,
|
| 744 |
+
dummy_joint,
|
| 745 |
+
dummy_joint,
|
| 746 |
+
# The gather buffer must be allocated at the gathered tensor's dtype: the fabric
|
| 747 |
+
# writes raw tiles into it, so a dtype mismatch is silent corruption, not a cast.
|
| 748 |
+
persistent_output_buffer_k=self.ccl_manager.get_ag_ping_pong_buffer(
|
| 749 |
+
k_BHNE.shape, 2, self.parallel_config.sequence_parallel.mesh_axis, dtype=k_BHNE.get_dtype()
|
| 750 |
+
),
|
| 751 |
+
persistent_output_buffer_v=self.ccl_manager.get_ag_ping_pong_buffer(
|
| 752 |
+
v_BHNE.shape, 2, self.parallel_config.sequence_parallel.mesh_axis, dtype=v_BHNE.get_dtype()
|
| 753 |
+
),
|
| 754 |
+
joint_strategy="rear",
|
| 755 |
+
logical_n=N,
|
| 756 |
+
program_config=self._ring_pc_by_n.get(N, self.ring_sdpa_program_config),
|
| 757 |
+
compute_kernel_config=self.sdpa_compute_kernel_config,
|
| 758 |
+
dim=2,
|
| 759 |
+
multi_device_global_semaphore=self.ccl_manager.get_ag_ping_pong_semaphore(
|
| 760 |
+
self.parallel_config.sequence_parallel.mesh_axis
|
| 761 |
+
),
|
| 762 |
+
num_links=self.ccl_manager.num_links,
|
| 763 |
+
cluster_axis=self.parallel_config.sequence_parallel.mesh_axis,
|
| 764 |
+
mesh_device=self.mesh_device,
|
| 765 |
+
topology=self.ccl_manager.topology,
|
| 766 |
+
subdevice_id=self.ccl_manager.ccl_sub_device_id,
|
| 767 |
+
ccl_core_grid_offset=(self.sdpa_worker_grid[0], 0),
|
| 768 |
+
use_column_major_ccl=True,
|
| 769 |
+
)
|
| 770 |
+
elif sp_factor > 1:
|
| 771 |
+
# Masked audio self-attn: gather K/V, keep Q sharded; gather+local SDPA beats ring-joint here.
|
| 772 |
+
sp_axis = self.parallel_config.sequence_parallel.mesh_axis
|
| 773 |
+
k_full = self.ccl_manager.all_gather_persistent_buffer(k_BHNE, dim=2, mesh_axis=sp_axis)
|
| 774 |
+
v_full = self.ccl_manager.all_gather_persistent_buffer(v_BHNE, dim=2, mesh_axis=sp_axis)
|
| 775 |
+
spatial_BHNE = ttnn.transformer.scaled_dot_product_attention(
|
| 776 |
+
q_BHNE,
|
| 777 |
+
k_full,
|
| 778 |
+
v_full,
|
| 779 |
+
attn_mask=attn_mask,
|
| 780 |
+
is_causal=False,
|
| 781 |
+
program_config=self.sdpa_program_config,
|
| 782 |
+
compute_kernel_config=self.sdpa_compute_kernel_config,
|
| 783 |
+
)
|
| 784 |
+
else:
|
| 785 |
+
spatial_BHNE = ttnn.transformer.scaled_dot_product_attention(
|
| 786 |
+
q_BHNE,
|
| 787 |
+
k_BHNE,
|
| 788 |
+
v_BHNE,
|
| 789 |
+
attn_mask=attn_mask,
|
| 790 |
+
is_causal=False,
|
| 791 |
+
program_config=self.sdpa_program_config,
|
| 792 |
+
compute_kernel_config=self.sdpa_compute_kernel_config,
|
| 793 |
+
)
|
| 794 |
+
elif use_ring_cross:
|
| 795 |
+
# Short audio Q attends non-causally to the SP-sharded video K/V; is_cross fuses the
|
| 796 |
+
# K/V gather into the ring SDPA. Output is the per-device Q shard (same as local SDPA).
|
| 797 |
+
sp_mesh_axis = self.parallel_config.sequence_parallel.mesh_axis
|
| 798 |
+
spatial_BHNE, _prompt_BHLE, _lse = ttnn.transformer.ring_joint_scaled_dot_product_attention(
|
| 799 |
+
q_BHNE,
|
| 800 |
+
k_BHNE,
|
| 801 |
+
v_BHNE,
|
| 802 |
+
dummy_joint,
|
| 803 |
+
dummy_joint,
|
| 804 |
+
dummy_joint,
|
| 805 |
+
persistent_output_buffer_k=self.ccl_manager.get_ag_ping_pong_buffer(
|
| 806 |
+
k_BHNE.shape, 2, sp_mesh_axis, dtype=k_BHNE.get_dtype()
|
| 807 |
+
),
|
| 808 |
+
persistent_output_buffer_v=self.ccl_manager.get_ag_ping_pong_buffer(
|
| 809 |
+
v_BHNE.shape, 2, sp_mesh_axis, dtype=v_BHNE.get_dtype()
|
| 810 |
+
),
|
| 811 |
+
joint_strategy="rear",
|
| 812 |
+
logical_n=kv_logical_n,
|
| 813 |
+
is_cross=True,
|
| 814 |
+
program_config=self.cross_ring_sdpa_program_config,
|
| 815 |
+
compute_kernel_config=self.sdpa_compute_kernel_config,
|
| 816 |
+
dim=2,
|
| 817 |
+
multi_device_global_semaphore=self.ccl_manager.get_ag_ping_pong_semaphore(sp_mesh_axis),
|
| 818 |
+
num_links=self.ccl_manager.num_links,
|
| 819 |
+
cluster_axis=sp_mesh_axis,
|
| 820 |
+
mesh_device=self.mesh_device,
|
| 821 |
+
topology=self.ccl_manager.topology,
|
| 822 |
+
subdevice_id=self.ccl_manager.ccl_sub_device_id,
|
| 823 |
+
ccl_core_grid_offset=(self.sdpa_worker_grid[0], 0),
|
| 824 |
+
use_column_major_ccl=True,
|
| 825 |
+
)
|
| 826 |
+
else:
|
| 827 |
+
# Cross-attention: K/V full-seq, Q SP-sharded so local SDPA returns the local shard.
|
| 828 |
+
spatial_BHNE = ttnn.transformer.scaled_dot_product_attention(
|
| 829 |
+
q_BHNE,
|
| 830 |
+
k_BHNE,
|
| 831 |
+
v_BHNE,
|
| 832 |
+
attn_mask=attn_mask,
|
| 833 |
+
is_causal=False,
|
| 834 |
+
program_config=self._sdpa_pc_by_shape.get((q_BHNE.shape[2], k_BHNE.shape[2]), self.sdpa_program_config),
|
| 835 |
+
compute_kernel_config=self.sdpa_compute_kernel_config,
|
| 836 |
+
)
|
| 837 |
+
|
| 838 |
+
# Apply per-head gate in BHNE space.
|
| 839 |
+
if gate_bhne is not None:
|
| 840 |
+
spatial_BHNE = ttnn.multiply(spatial_BHNE, gate_bhne)
|
| 841 |
+
|
| 842 |
+
spatial_1BND = ttnn.transformer.concatenate_heads(spatial_BHNE)
|
| 843 |
+
spatial_1BND = ttnn.unsqueeze(spatial_1BND, 0)
|
| 844 |
+
|
| 845 |
+
# Ring fuses the TP all-gather into the to_out matmul; only Linear needs explicit AG.
|
| 846 |
+
addcmul_fused = addcmul_residual is not None and addcmul_gate is not None
|
| 847 |
+
to_out_explicit_ag = self.parallel_config.tensor_parallel.factor > 1 and use_nonfused_agmm
|
| 848 |
+
if to_out_explicit_ag:
|
| 849 |
+
# Same ordering rule as the QKV gather above: shrink the tensor before it crosses.
|
| 850 |
+
spatial_1BND = maybe_cast_activation(spatial_1BND, self.to_out.activation_dtype)
|
| 851 |
+
spatial_1BND = self.ccl_manager.all_gather_persistent_buffer(
|
| 852 |
+
spatial_1BND, dim=3, mesh_axis=self.parallel_config.tensor_parallel.mesh_axis
|
| 853 |
+
)
|
| 854 |
+
|
| 855 |
+
if addcmul_fused:
|
| 856 |
+
spatial_1BND = self._to_out_fused_addcmul(
|
| 857 |
+
spatial_1BND,
|
| 858 |
+
addcmul_residual,
|
| 859 |
+
addcmul_gate,
|
| 860 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 861 |
+
parallel_config=None if use_nonfused_agmm else self.parallel_config,
|
| 862 |
+
)
|
| 863 |
+
else:
|
| 864 |
+
spatial_1BND = self.to_out(
|
| 865 |
+
spatial_1BND,
|
| 866 |
+
compute_kernel_config=self.mm_compute_kernel_config,
|
| 867 |
+
parallel_config=None if to_out_explicit_ag else self.parallel_config,
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
return spatial_1BND
|
code/models/tt_dit/models/transformers/ltx/quant_config.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent USA, Inc.
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Quantization and compute-precision policy for the LTX DiT linears.
|
| 6 |
+
|
| 7 |
+
A single :class:`LtxQuantProfile` holds the whole policy — weight dtypes, the ``to_out`` carve-out,
|
| 8 |
+
activation/SDPA casts, and matmul/SDPA fidelities — and exposes it as construction kwargs. The
|
| 9 |
+
transformer modules stay precision-agnostic: they spread ``profile.linear_kwargs(role)`` onto each
|
| 10 |
+
linear and read ``profile.mm_compute_config`` / ``profile.sdpa_self_config`` for the op configs, with
|
| 11 |
+
no dtype or fidelity literals of their own.
|
| 12 |
+
|
| 13 |
+
Weight quantization happens at Parameter construction, not as a state_dict pass: there is no Torch
|
| 14 |
+
bfloat8, so ``ttnn.from_torch(dtype=...)`` is the only quantizer and the cache enforces the declared
|
| 15 |
+
``Parameter.dtype``. The profile therefore sets each linear's ``dtype`` at build time, so a cache miss
|
| 16 |
+
loads weights direct-to-quant and the cache write holds the quantized tensorbins.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
|
| 24 |
+
import ttnn
|
| 25 |
+
|
| 26 |
+
# When set, activations are narrowed to bf8 wherever they cross the fabric — both the linear activation
|
| 27 |
+
# casts and the ring-SDPA Q/K/V inputs — so this is the one knob that changes what the collectives move
|
| 28 |
+
# (the linear payloads and the SP-gathered K/V; SDPA itself stays HiFi2, only its inputs narrow). More
|
| 29 |
+
# aggressive than the weight quant it rides on: weights are a fixed, well-conditioned distribution,
|
| 30 |
+
# activations are not. On by default because the shipped 1080p tier is measured and VBench-gated with it
|
| 31 |
+
# on; set to 0 to A/B against the weight-only preset. Keeping the SDPA input cast on this same knob means
|
| 32 |
+
# a run can't narrow the linear activations while silently leaving SDPA at bf16 (or vice versa).
|
| 33 |
+
LTX_QUANT_ACTIVATIONS = os.environ.get("LTX_QUANT_ACTIVATIONS", "1") in ("1", "true", "True")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class LtxQuantProfile:
|
| 38 |
+
"""One quant profile for every DiT linear, with the ``to_out`` carve-out.
|
| 39 |
+
|
| 40 |
+
Every quantized projection (qkv/q/kv, ff1/ff2) shares one weight dtype and one compute profile;
|
| 41 |
+
only ``to_out`` carves out to ``out_weight_dtype`` (bf16), because it feeds the fused-addcmul
|
| 42 |
+
epilogue whose bf16 ternary inputs must match the weight tile format. The activation and SDPA-input
|
| 43 |
+
casts (and the output pins that go with them) ride ``LTX_QUANT_ACTIVATIONS`` together, so a
|
| 44 |
+
weight-only run keeps activations bf16.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
weight_dtype: ttnn.DataType # qkv/q/kv, ff1/ff2, and any other quantized projection
|
| 48 |
+
out_weight_dtype: ttnn.DataType # to_out carve-out
|
| 49 |
+
activation_dtype: ttnn.DataType # linear activation cast (gated by LTX_QUANT_ACTIVATIONS)
|
| 50 |
+
mm_fidelity: ttnn.MathFidelity
|
| 51 |
+
mm_fp32_dest_acc: bool
|
| 52 |
+
sdpa_input_dtype: ttnn.DataType # self-attn ring-SDPA input cast (gated by LTX_QUANT_ACTIVATIONS)
|
| 53 |
+
sdpa_fidelity: ttnn.MathFidelity
|
| 54 |
+
sdpa_fp32_dest_acc: bool
|
| 55 |
+
|
| 56 |
+
def linear_kwargs(self, role: str) -> dict:
|
| 57 |
+
"""Construction kwargs for a DiT linear of ``role`` ("out" carves out to bf16, else quantized).
|
| 58 |
+
|
| 59 |
+
A ``gate`` keeps its bf16 weight but still consumes the shared bf8 activation, so it is pinned
|
| 60 |
+
like the projections but gets no weight-dtype or activation-cast change.
|
| 61 |
+
"""
|
| 62 |
+
pin = LTX_QUANT_ACTIVATIONS
|
| 63 |
+
if role == "gate":
|
| 64 |
+
return {"pin_output_bf16": pin}
|
| 65 |
+
return {
|
| 66 |
+
"dtype": self.out_weight_dtype if role == "out" else self.weight_dtype,
|
| 67 |
+
"activation_dtype": self.activation_dtype if LTX_QUANT_ACTIVATIONS else None,
|
| 68 |
+
"pin_output_bf16": pin,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
def ffn_kwargs(self) -> dict:
|
| 72 |
+
"""Construction kwargs for the FFN (ParallelFeedForward), where ff1 and ff2 are both quantized."""
|
| 73 |
+
lk = self.linear_kwargs("ff")
|
| 74 |
+
return {
|
| 75 |
+
"ff1_dtype": lk["dtype"],
|
| 76 |
+
"ff2_dtype": lk["dtype"],
|
| 77 |
+
"activation_dtype": lk["activation_dtype"],
|
| 78 |
+
"pin_output_bf16": lk["pin_output_bf16"],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
def mm_compute_config(self, arch):
|
| 82 |
+
"""Compute-kernel config for the DiT-linear matmuls (attention QKV/out and the FFN).
|
| 83 |
+
|
| 84 |
+
``packer_l1_acc`` stays on regardless of tier: it accumulates matmul partials in L1 at fp32,
|
| 85 |
+
which is near-free and strictly helps. It is orthogonal to ``fp32_dest_acc_en`` — the pricier
|
| 86 |
+
fp32 *destination* accumulation that the LoFi/bf8 tier deliberately drops (``mm_fp32_dest_acc``).
|
| 87 |
+
"""
|
| 88 |
+
return ttnn.init_device_compute_kernel_config(
|
| 89 |
+
arch,
|
| 90 |
+
math_fidelity=self.mm_fidelity,
|
| 91 |
+
math_approx_mode=False,
|
| 92 |
+
fp32_dest_acc_en=self.mm_fp32_dest_acc,
|
| 93 |
+
packer_l1_acc=True,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def sdpa_self_config(self, arch) -> tuple:
|
| 97 |
+
"""``(compute_config, input_dtype)`` for the self-attn ring SDPA; input cast rides the gate.
|
| 98 |
+
|
| 99 |
+
Cross-attention keeps its default bf16/HiFi2 SDPA, so callers apply this to self-attn only.
|
| 100 |
+
"""
|
| 101 |
+
compute_config = ttnn.init_device_compute_kernel_config(
|
| 102 |
+
arch,
|
| 103 |
+
math_fidelity=self.sdpa_fidelity,
|
| 104 |
+
math_approx_mode=False,
|
| 105 |
+
fp32_dest_acc_en=self.sdpa_fp32_dest_acc,
|
| 106 |
+
)
|
| 107 |
+
return compute_config, (self.sdpa_input_dtype if LTX_QUANT_ACTIVATIONS else None)
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
def all_bf8_lofi() -> LtxQuantProfile:
|
| 111 |
+
"""Weights bf8, LoFi compute, bf8 activations. SDPA math stays HiFi2 with bf8 inputs.
|
| 112 |
+
|
| 113 |
+
SDPA stays fully unquantized in math (FastVideo kept attention higher precision); only its
|
| 114 |
+
inputs narrow to bf8 under ``LTX_QUANT_ACTIVATIONS``. ``ff2`` uses the RowParallel RS-fused
|
| 115 |
+
addcmul, which Wan runs at bf8 with no issue, so it is quantized; only ``to_out`` carves out.
|
| 116 |
+
"""
|
| 117 |
+
return LtxQuantProfile(
|
| 118 |
+
weight_dtype=ttnn.bfloat8_b,
|
| 119 |
+
out_weight_dtype=ttnn.bfloat16,
|
| 120 |
+
activation_dtype=ttnn.bfloat8_b,
|
| 121 |
+
mm_fidelity=ttnn.MathFidelity.LoFi,
|
| 122 |
+
mm_fp32_dest_acc=False,
|
| 123 |
+
sdpa_input_dtype=ttnn.bfloat8_b,
|
| 124 |
+
sdpa_fidelity=ttnn.MathFidelity.HiFi2,
|
| 125 |
+
sdpa_fp32_dest_acc=False,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def all_bf4_lofi() -> LtxQuantProfile:
|
| 130 |
+
"""Like ``all_bf8_lofi`` but 4-bit weights and 4-bit linear activations. Opt-in probe tier, not
|
| 131 |
+
shipped: bf4 *activations* visibly destroy quality (bf4 weights alone hold up), so this is for
|
| 132 |
+
A/B measurement only. Carve-outs and the ring-SDPA bf8 input match all_bf8_lofi.
|
| 133 |
+
"""
|
| 134 |
+
return LtxQuantProfile(
|
| 135 |
+
weight_dtype=ttnn.bfloat4_b,
|
| 136 |
+
out_weight_dtype=ttnn.bfloat16,
|
| 137 |
+
activation_dtype=ttnn.bfloat4_b,
|
| 138 |
+
mm_fidelity=ttnn.MathFidelity.LoFi,
|
| 139 |
+
mm_fp32_dest_acc=False,
|
| 140 |
+
sdpa_input_dtype=ttnn.bfloat8_b,
|
| 141 |
+
sdpa_fidelity=ttnn.MathFidelity.HiFi2,
|
| 142 |
+
sdpa_fp32_dest_acc=False,
|
| 143 |
+
)
|
code/models/tt_dit/models/transformers/ltx/rope_ltx.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: © 2025 Tenstorrent AI ULC
|
| 2 |
+
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""
|
| 6 |
+
LTX-2 Rotary Position Embeddings for tt_dit.
|
| 7 |
+
|
| 8 |
+
Computes cos/sin on CPU using LTX-2's fractional position encoding with custom
|
| 9 |
+
frequency grids, then converts to ttnn tensors for use with
|
| 10 |
+
ttnn.experimental.rotary_embedding_llama.
|
| 11 |
+
|
| 12 |
+
Reference: LTX-2/packages/ltx-core/src/ltx_core/model/transformer/rope.py
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import functools
|
| 18 |
+
import math
|
| 19 |
+
from enum import Enum
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
|
| 24 |
+
import ttnn
|
| 25 |
+
|
| 26 |
+
from ....parallel.config import DiTParallelConfig
|
| 27 |
+
from ....utils.patchifiers import (
|
| 28 |
+
AudioLatentShape,
|
| 29 |
+
VideoLatentShape,
|
| 30 |
+
audio_get_patch_grid_bounds,
|
| 31 |
+
get_pixel_coords,
|
| 32 |
+
video_get_patch_grid_bounds,
|
| 33 |
+
)
|
| 34 |
+
from ....utils.tensor import bf16_tensor, bf16_tensor_2dshard
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class LTXRopeType(Enum):
|
| 38 |
+
INTERLEAVED = "interleaved"
|
| 39 |
+
SPLIT = "split"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@functools.lru_cache(maxsize=5)
|
| 43 |
+
def generate_freq_grid(
|
| 44 |
+
positional_embedding_theta: float,
|
| 45 |
+
positional_embedding_max_pos_count: int,
|
| 46 |
+
inner_dim: int,
|
| 47 |
+
) -> torch.Tensor:
|
| 48 |
+
"""Power-law frequency grid for LTX-2 RoPE: theta^linspace(...) * pi/2.
|
| 49 |
+
|
| 50 |
+
Kept fp32, not HF's fp64: upgrading the grid to fp64 measurably hurt TT-vs-HF
|
| 51 |
+
audio correlation because the downstream residual stream is bf16. Keep fp32
|
| 52 |
+
unless the whole RoPE + residual path moves to fp32.
|
| 53 |
+
"""
|
| 54 |
+
theta = positional_embedding_theta
|
| 55 |
+
n_elem = 2 * positional_embedding_max_pos_count
|
| 56 |
+
|
| 57 |
+
pow_indices = np.power(
|
| 58 |
+
theta,
|
| 59 |
+
np.linspace(
|
| 60 |
+
np.log(1.0) / np.log(theta),
|
| 61 |
+
np.log(theta) / np.log(theta),
|
| 62 |
+
inner_dim // n_elem,
|
| 63 |
+
dtype=np.float64,
|
| 64 |
+
),
|
| 65 |
+
)
|
| 66 |
+
return torch.tensor(pow_indices * math.pi / 2, dtype=torch.float32)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_fractional_positions(indices_grid: torch.Tensor, max_pos: list[int]) -> torch.Tensor:
|
| 70 |
+
"""Normalize position indices to [0, 1] by dividing by max_pos per dimension.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
indices_grid: (B, n_dims, N) position indices after averaging start/end
|
| 74 |
+
max_pos: max position per dimension
|
| 75 |
+
"""
|
| 76 |
+
n_pos_dims = indices_grid.shape[1]
|
| 77 |
+
assert n_pos_dims == len(
|
| 78 |
+
max_pos
|
| 79 |
+
), f"Number of position dimensions ({n_pos_dims}) must match max_pos length ({len(max_pos)})"
|
| 80 |
+
return torch.stack([indices_grid[:, i] / max_pos[i] for i in range(n_pos_dims)], dim=-1)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def generate_freqs(
|
| 84 |
+
indices: torch.Tensor,
|
| 85 |
+
indices_grid: torch.Tensor,
|
| 86 |
+
max_pos: list[int],
|
| 87 |
+
use_middle_indices_grid: bool,
|
| 88 |
+
) -> torch.Tensor:
|
| 89 |
+
"""
|
| 90 |
+
Compute raw frequencies from position indices and frequency grid.
|
| 91 |
+
|
| 92 |
+
freqs = indices * (fractional_positions * 2 - 1)
|
| 93 |
+
This maps fractional positions from [0,1] to [-1,1] before multiplying by freq indices.
|
| 94 |
+
"""
|
| 95 |
+
if use_middle_indices_grid:
|
| 96 |
+
assert len(indices_grid.shape) == 4
|
| 97 |
+
assert indices_grid.shape[-1] == 2
|
| 98 |
+
indices_grid_start, indices_grid_end = indices_grid[..., 0], indices_grid[..., 1]
|
| 99 |
+
indices_grid = (indices_grid_start + indices_grid_end) / 2.0
|
| 100 |
+
elif len(indices_grid.shape) == 4:
|
| 101 |
+
indices_grid = indices_grid[..., 0]
|
| 102 |
+
else:
|
| 103 |
+
# 3D (B, N, n_dims) → transpose to (B, n_dims, N) for get_fractional_positions
|
| 104 |
+
indices_grid = indices_grid.transpose(1, 2)
|
| 105 |
+
|
| 106 |
+
fractional_positions = get_fractional_positions(indices_grid, max_pos)
|
| 107 |
+
indices = indices.to(device=fractional_positions.device)
|
| 108 |
+
freqs = (indices * (fractional_positions.unsqueeze(-1) * 2 - 1)).transpose(-1, -2).flatten(2)
|
| 109 |
+
return freqs
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def reshape_interleaved_to_bhnd(t: torch.Tensor, num_heads: int) -> torch.Tensor:
|
| 113 |
+
"""Reshape interleaved (B, N, dim) to (B, num_heads, N, head_dim) for rotary_embedding_llama."""
|
| 114 |
+
B, N, dim = t.shape
|
| 115 |
+
head_dim = dim // num_heads
|
| 116 |
+
return t.reshape(B, N, num_heads, head_dim).permute(0, 2, 1, 3).contiguous()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def interleaved_freqs_cis(freqs: torch.Tensor, pad_size: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 120 |
+
"""
|
| 121 |
+
Compute cos/sin with interleaved repeat for rotary embedding.
|
| 122 |
+
|
| 123 |
+
Each frequency value is duplicated: [f0, f0, f1, f1, ...] to match
|
| 124 |
+
the interleaved (x0, x1) pair structure expected by rotary_embedding_llama.
|
| 125 |
+
"""
|
| 126 |
+
cos_freq = freqs.cos().repeat_interleave(2, dim=-1)
|
| 127 |
+
sin_freq = freqs.sin().repeat_interleave(2, dim=-1)
|
| 128 |
+
if pad_size != 0:
|
| 129 |
+
cos_padding = torch.ones_like(cos_freq[:, :, :pad_size])
|
| 130 |
+
sin_padding = torch.zeros_like(cos_freq[:, :, :pad_size])
|
| 131 |
+
cos_freq = torch.cat([cos_padding, cos_freq], dim=-1)
|
| 132 |
+
sin_freq = torch.cat([sin_padding, sin_freq], dim=-1)
|
| 133 |
+
return cos_freq, sin_freq
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def split_freqs_cis(freqs: torch.Tensor, pad_size: int, num_attention_heads: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 137 |
+
"""
|
| 138 |
+
Compute cos/sin with split layout for rotary_embedding_llama.
|
| 139 |
+
|
| 140 |
+
The llama kernel expects (B, H, N, head_dim) and applies rotation to pairs (x[i], x[i+D/2]).
|
| 141 |
+
We repeat the unique frequencies: [f0,f1,...,f_{d/2}, f0,f1,...,f_{d/2}] so both halves
|
| 142 |
+
are rotated correctly.
|
| 143 |
+
|
| 144 |
+
Output shape: (B, num_heads, T, head_dim) where head_dim = 2 * D_half.
|
| 145 |
+
"""
|
| 146 |
+
cos_freq = freqs.cos()
|
| 147 |
+
sin_freq = freqs.sin()
|
| 148 |
+
|
| 149 |
+
if pad_size != 0:
|
| 150 |
+
cos_padding = torch.ones_like(cos_freq[:, :, :pad_size])
|
| 151 |
+
sin_padding = torch.zeros_like(sin_freq[:, :, :pad_size])
|
| 152 |
+
cos_freq = torch.concatenate([cos_padding, cos_freq], axis=-1)
|
| 153 |
+
sin_freq = torch.concatenate([sin_padding, sin_freq], axis=-1)
|
| 154 |
+
|
| 155 |
+
b, t = cos_freq.shape[0], cos_freq.shape[1]
|
| 156 |
+
# Reshape to per-head: (B, T, H, D_half) -> (B, H, T, D_half)
|
| 157 |
+
cos_freq = cos_freq.reshape(b, t, num_attention_heads, -1).swapaxes(1, 2)
|
| 158 |
+
sin_freq = sin_freq.reshape(b, t, num_attention_heads, -1).swapaxes(1, 2)
|
| 159 |
+
return cos_freq, sin_freq
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def precompute_freqs_cis(
|
| 163 |
+
indices_grid: torch.Tensor,
|
| 164 |
+
dim: int,
|
| 165 |
+
out_dtype: torch.dtype = torch.float32,
|
| 166 |
+
theta: float = 10000.0,
|
| 167 |
+
max_pos: list[int] | None = None,
|
| 168 |
+
use_middle_indices_grid: bool = False,
|
| 169 |
+
num_attention_heads: int = 32,
|
| 170 |
+
rope_type: LTXRopeType = LTXRopeType.INTERLEAVED,
|
| 171 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 172 |
+
"""Precompute (cos, sin) for LTX-2 RoPE.
|
| 173 |
+
|
| 174 |
+
Output shape depends on rope_type:
|
| 175 |
+
- INTERLEAVED: (B, seq_len, dim), each freq duplicated for the (x0, x1) pairs.
|
| 176 |
+
- SPLIT: (B, num_heads, seq_len, dim // (2 * num_heads)).
|
| 177 |
+
"""
|
| 178 |
+
if max_pos is None:
|
| 179 |
+
max_pos = [20, 2048, 2048]
|
| 180 |
+
|
| 181 |
+
# n_pos_dims is the number of position dimensions (e.g. 3 for temporal + H + W)
|
| 182 |
+
# For 4D grids (B, n_dims, N, 2), n_pos_dims is shape[1]; for 3D (B, N, n_dims), it's shape[-1]
|
| 183 |
+
if use_middle_indices_grid and len(indices_grid.shape) == 4:
|
| 184 |
+
n_pos_dims = indices_grid.shape[1] # (B, n_dims, N, 2)
|
| 185 |
+
else:
|
| 186 |
+
n_pos_dims = indices_grid.shape[-1]
|
| 187 |
+
indices = generate_freq_grid(theta, n_pos_dims, dim)
|
| 188 |
+
freqs = generate_freqs(indices, indices_grid, max_pos, use_middle_indices_grid)
|
| 189 |
+
|
| 190 |
+
if rope_type == LTXRopeType.SPLIT:
|
| 191 |
+
expected_freqs = dim // 2
|
| 192 |
+
current_freqs = freqs.shape[-1]
|
| 193 |
+
pad_size = expected_freqs - current_freqs
|
| 194 |
+
cos_freq, sin_freq = split_freqs_cis(freqs, pad_size, num_attention_heads)
|
| 195 |
+
else:
|
| 196 |
+
n_elem = 2 * n_pos_dims
|
| 197 |
+
cos_freq, sin_freq = interleaved_freqs_cis(freqs, dim % n_elem)
|
| 198 |
+
|
| 199 |
+
return cos_freq.to(out_dtype), sin_freq.to(out_dtype)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# =============================================================================
|
| 203 |
+
# Device-side RoPE builders (INTERLEAVED cos/sin, sharded onto the DiT mesh) for
|
| 204 |
+
# ttnn.experimental.rotary_embedding_llama. All positions stay fp32 — bf16 introduced
|
| 205 |
+
# catastrophic phase error in high-frequency RoPE channels (randomizing the top half of
|
| 206 |
+
# head_dim), so only the final ttnn tensors are cast to bf16.
|
| 207 |
+
# =============================================================================
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def pad_video_rope_sp(
|
| 211 |
+
cos_freq: torch.Tensor, sin_freq: torch.Tensor, sp_factor: int
|
| 212 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 213 |
+
"""Right-pad video RoPE cos/sin on dim=2 to the SP boundary (``ttnn.TILE_SIZE * sp_factor``).
|
| 214 |
+
|
| 215 |
+
Padded slots use cos=1, sin=0 (identity rotation); SDPA still masks them via ``logical_n``.
|
| 216 |
+
Same convention as the audio RoPE padding in ``prepare_audio_rope`` / ``prepare_av_cross_pe``.
|
| 217 |
+
"""
|
| 218 |
+
video_N_real = cos_freq.shape[2]
|
| 219 |
+
divisor = ttnn.TILE_SIZE * sp_factor
|
| 220 |
+
video_N = ((video_N_real + divisor - 1) // divisor) * divisor
|
| 221 |
+
if video_N == video_N_real:
|
| 222 |
+
return cos_freq, sin_freq
|
| 223 |
+
pad = video_N - video_N_real
|
| 224 |
+
H = cos_freq.shape[1]
|
| 225 |
+
d_half = cos_freq.shape[-1]
|
| 226 |
+
cos_pad = torch.ones(1, H, pad, d_half, dtype=cos_freq.dtype)
|
| 227 |
+
sin_pad = torch.zeros(1, H, pad, d_half, dtype=sin_freq.dtype)
|
| 228 |
+
cos_freq = torch.cat([cos_freq, cos_pad], dim=2)
|
| 229 |
+
sin_freq = torch.cat([sin_freq, sin_pad], dim=2)
|
| 230 |
+
return cos_freq, sin_freq
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def prepare_video_rope(
|
| 234 |
+
latent_frames: int,
|
| 235 |
+
latent_height: int,
|
| 236 |
+
latent_width: int,
|
| 237 |
+
*,
|
| 238 |
+
inner_dim: int,
|
| 239 |
+
num_attention_heads: int,
|
| 240 |
+
theta: float,
|
| 241 |
+
max_pos: list[int],
|
| 242 |
+
mesh_device: ttnn.MeshDevice,
|
| 243 |
+
parallel_config: DiTParallelConfig,
|
| 244 |
+
fps: float = 24.0,
|
| 245 |
+
) -> tuple[ttnn.Tensor, ttnn.Tensor]:
|
| 246 |
+
"""Compute video RoPE in INTERLEAVED layout, SP×TP sharded onto the mesh."""
|
| 247 |
+
v_shape = VideoLatentShape(batch=1, channels=128, frames=latent_frames, height=latent_height, width=latent_width)
|
| 248 |
+
v_coords = video_get_patch_grid_bounds(v_shape)
|
| 249 |
+
v_positions = get_pixel_coords(v_coords, scale_factors=(8, 32, 32), causal_fix=True).float()
|
| 250 |
+
v_positions[:, 0, ...] = v_positions[:, 0, ...] / fps
|
| 251 |
+
|
| 252 |
+
cos_freq, sin_freq = precompute_freqs_cis(
|
| 253 |
+
v_positions,
|
| 254 |
+
dim=inner_dim,
|
| 255 |
+
out_dtype=torch.float32,
|
| 256 |
+
theta=theta,
|
| 257 |
+
max_pos=max_pos,
|
| 258 |
+
use_middle_indices_grid=True,
|
| 259 |
+
num_attention_heads=num_attention_heads,
|
| 260 |
+
rope_type=LTXRopeType.INTERLEAVED,
|
| 261 |
+
) # (1, N, dim)
|
| 262 |
+
|
| 263 |
+
cos_freq = reshape_interleaved_to_bhnd(cos_freq, num_attention_heads)
|
| 264 |
+
sin_freq = reshape_interleaved_to_bhnd(sin_freq, num_attention_heads)
|
| 265 |
+
|
| 266 |
+
# Pad seq dim to ttnn.TILE_SIZE * sp_factor; padded slots use cos=1, sin=0 (identity).
|
| 267 |
+
cos_freq, sin_freq = pad_video_rope_sp(cos_freq, sin_freq, parallel_config.sequence_parallel.factor)
|
| 268 |
+
|
| 269 |
+
sp_axis = parallel_config.sequence_parallel.mesh_axis
|
| 270 |
+
tp_axis = parallel_config.tensor_parallel.mesh_axis
|
| 271 |
+
tt_cos = bf16_tensor_2dshard(cos_freq, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1})
|
| 272 |
+
tt_sin = bf16_tensor_2dshard(sin_freq, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1})
|
| 273 |
+
return tt_cos, tt_sin
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def prepare_audio_rope(
|
| 277 |
+
audio_N: int,
|
| 278 |
+
audio_N_real: int,
|
| 279 |
+
*,
|
| 280 |
+
theta: float,
|
| 281 |
+
mesh_device: ttnn.MeshDevice,
|
| 282 |
+
parallel_config: DiTParallelConfig,
|
| 283 |
+
) -> tuple[ttnn.Tensor, ttnn.Tensor]:
|
| 284 |
+
"""Compute audio RoPE in INTERLEAVED layout, SP×TP sharded onto the mesh."""
|
| 285 |
+
a_shape = AudioLatentShape(batch=1, channels=8, frames=audio_N_real, mel_bins=16)
|
| 286 |
+
a_positions = audio_get_patch_grid_bounds(a_shape).float() # (1, 1, N, 2)
|
| 287 |
+
|
| 288 |
+
a_cos, a_sin = precompute_freqs_cis(
|
| 289 |
+
a_positions,
|
| 290 |
+
dim=2048,
|
| 291 |
+
out_dtype=torch.float32,
|
| 292 |
+
theta=theta,
|
| 293 |
+
max_pos=[20],
|
| 294 |
+
use_middle_indices_grid=True,
|
| 295 |
+
num_attention_heads=32,
|
| 296 |
+
rope_type=LTXRopeType.INTERLEAVED,
|
| 297 |
+
) # (1, N, 2048)
|
| 298 |
+
a_cos = reshape_interleaved_to_bhnd(a_cos, num_heads=32) # (1, 32, N, 64)
|
| 299 |
+
a_sin = reshape_interleaved_to_bhnd(a_sin, num_heads=32)
|
| 300 |
+
|
| 301 |
+
if audio_N > audio_N_real:
|
| 302 |
+
head_dim = a_cos.shape[-1]
|
| 303 |
+
a_cos_padded = torch.ones(1, 32, audio_N, head_dim)
|
| 304 |
+
a_cos_padded[:, :, :audio_N_real, :] = a_cos
|
| 305 |
+
a_sin_padded = torch.zeros(1, 32, audio_N, head_dim)
|
| 306 |
+
a_sin_padded[:, :, :audio_N_real, :] = a_sin
|
| 307 |
+
a_cos, a_sin = a_cos_padded, a_sin_padded
|
| 308 |
+
|
| 309 |
+
sp_axis = parallel_config.sequence_parallel.mesh_axis
|
| 310 |
+
tp_axis = parallel_config.tensor_parallel.mesh_axis
|
| 311 |
+
return (
|
| 312 |
+
bf16_tensor_2dshard(a_cos, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1}),
|
| 313 |
+
bf16_tensor_2dshard(a_sin, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1}),
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def prepare_av_cross_pe(
|
| 318 |
+
latent_frames: int,
|
| 319 |
+
latent_height: int,
|
| 320 |
+
latent_width: int,
|
| 321 |
+
audio_N: int,
|
| 322 |
+
audio_N_real: int,
|
| 323 |
+
*,
|
| 324 |
+
theta: float,
|
| 325 |
+
mesh_device: ttnn.MeshDevice,
|
| 326 |
+
parallel_config: DiTParallelConfig,
|
| 327 |
+
fps: float = 24.0,
|
| 328 |
+
cross_pe_max_pos: int = 20,
|
| 329 |
+
) -> tuple[ttnn.Tensor, ttnn.Tensor, ttnn.Tensor, ttnn.Tensor, ttnn.Tensor, ttnn.Tensor]:
|
| 330 |
+
"""Temporal-only cross positional embeddings for A↔V cross-attention.
|
| 331 |
+
|
| 332 |
+
Reference: ``MultiModalTransformerArgsPreprocessor.prepare`` builds ``cross_pe`` from the
|
| 333 |
+
temporal slice ``modality.positions[:, 0:1, :]`` at ``dim=audio_cross_attention_dim`` with
|
| 334 |
+
``max_pos=[cross_pe_max_pos]``. Video and audio share the scheme so a video token and an
|
| 335 |
+
audio token at the same time get the same rotary phase (AV temporal sync).
|
| 336 |
+
|
| 337 |
+
Returns 6 device tensors used by inner_step:
|
| 338 |
+
(v_q_cos, v_q_sin) — video Q in A→V cross-attn (SP×TP sharded). Also reused as the
|
| 339 |
+
video K rope in V→A (ring SDPA gathers the SP-sharded K).
|
| 340 |
+
(a_q_cos, a_q_sin) — audio Q in V→A cross-attn (SP×TP sharded).
|
| 341 |
+
(a_k_cos, a_k_sin) — audio K in A→V cross-attn (TP-only; K side after AllGather).
|
| 342 |
+
"""
|
| 343 |
+
v_shape = VideoLatentShape(batch=1, channels=128, frames=latent_frames, height=latent_height, width=latent_width)
|
| 344 |
+
v_coords = video_get_patch_grid_bounds(v_shape)
|
| 345 |
+
v_positions = get_pixel_coords(v_coords, scale_factors=(8, 32, 32), causal_fix=True).float()
|
| 346 |
+
v_positions[:, 0, ...] = v_positions[:, 0, ...] / fps # temporal axis → seconds
|
| 347 |
+
v_temporal = v_positions[:, 0:1, :] # (1, 1, video_N, 2)
|
| 348 |
+
|
| 349 |
+
a_shape = AudioLatentShape(batch=1, channels=8, frames=audio_N_real, mel_bins=16)
|
| 350 |
+
a_positions = audio_get_patch_grid_bounds(a_shape).float() # (1, 1, audio_N_real, 2)
|
| 351 |
+
|
| 352 |
+
rope_kwargs = dict(
|
| 353 |
+
dim=2048, # audio_cross_attention_dim — both sides share this
|
| 354 |
+
out_dtype=torch.float32,
|
| 355 |
+
theta=theta,
|
| 356 |
+
max_pos=[cross_pe_max_pos],
|
| 357 |
+
use_middle_indices_grid=True,
|
| 358 |
+
num_attention_heads=32,
|
| 359 |
+
rope_type=LTXRopeType.INTERLEAVED,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
v_cos, v_sin = precompute_freqs_cis(v_temporal, **rope_kwargs) # (1, video_N, 2048)
|
| 363 |
+
a_cos, a_sin = precompute_freqs_cis(a_positions, **rope_kwargs) # (1, audio_N_real, 2048)
|
| 364 |
+
v_cos = reshape_interleaved_to_bhnd(v_cos, num_heads=32) # (1, 32, video_N, 64)
|
| 365 |
+
v_sin = reshape_interleaved_to_bhnd(v_sin, num_heads=32)
|
| 366 |
+
a_cos = reshape_interleaved_to_bhnd(a_cos, num_heads=32)
|
| 367 |
+
a_sin = reshape_interleaved_to_bhnd(a_sin, num_heads=32)
|
| 368 |
+
|
| 369 |
+
v_cos, v_sin = pad_video_rope_sp(v_cos, v_sin, parallel_config.sequence_parallel.factor)
|
| 370 |
+
|
| 371 |
+
if audio_N > audio_N_real:
|
| 372 |
+
head_dim = a_cos.shape[-1]
|
| 373 |
+
a_cos_padded = torch.ones(1, 32, audio_N, head_dim)
|
| 374 |
+
a_cos_padded[:, :, :audio_N_real, :] = a_cos
|
| 375 |
+
a_sin_padded = torch.zeros(1, 32, audio_N, head_dim)
|
| 376 |
+
a_sin_padded[:, :, :audio_N_real, :] = a_sin
|
| 377 |
+
a_cos, a_sin = a_cos_padded, a_sin_padded
|
| 378 |
+
|
| 379 |
+
sp_axis = parallel_config.sequence_parallel.mesh_axis
|
| 380 |
+
tp_axis = parallel_config.tensor_parallel.mesh_axis
|
| 381 |
+
|
| 382 |
+
# Q-side: SP×TP sharded (matches the Q tensor layout post-attention QKV split).
|
| 383 |
+
v_q_cos = bf16_tensor_2dshard(v_cos, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1})
|
| 384 |
+
v_q_sin = bf16_tensor_2dshard(v_sin, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1})
|
| 385 |
+
a_q_cos = bf16_tensor_2dshard(a_cos, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1})
|
| 386 |
+
a_q_sin = bf16_tensor_2dshard(a_sin, device=mesh_device, shard_mapping={sp_axis: 2, tp_axis: 1})
|
| 387 |
+
|
| 388 |
+
# K-side: TP-only on heads (sequence is replicated after AllGather on K). Only the audio K
|
| 389 |
+
# rope is needed (A→V gathers audio K); video K in V→A reuses the SP-sharded v_q rope.
|
| 390 |
+
a_k_cos = bf16_tensor(a_cos, device=mesh_device, mesh_axis=tp_axis, shard_dim=1)
|
| 391 |
+
a_k_sin = bf16_tensor(a_sin, device=mesh_device, mesh_axis=tp_axis, shard_dim=1)
|
| 392 |
+
|
| 393 |
+
return (v_q_cos, v_q_sin, a_q_cos, a_q_sin, a_k_cos, a_k_sin)
|