sunandbean commited on
Commit
33d23e1
·
verified ·
1 Parent(s): 629b123

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +22 -0
  2. code/g2p/sources/chinese_lexicon.txt +3 -0
  3. code/g2p/sources/g2p_chinese_model/poly_bert_model.onnx +3 -0
  4. code/infer/example/edit_cn.lrc +23 -0
  5. code/infer/example/edit_cn.mp3 +3 -0
  6. code/infer/example/edit_en.lrc +19 -0
  7. code/infer/example/edit_en.mp3 +3 -0
  8. code/infer/example/eg_cn.lrc +16 -0
  9. code/infer/example/eg_cn.wav +3 -0
  10. code/infer/example/eg_cn_full.lrc +44 -0
  11. code/infer/example/eg_en.lrc +26 -0
  12. code/infer/example/eg_en.mp3 +3 -0
  13. code/infer/example/eg_en_full.lrc +57 -0
  14. code/infer/example/music_prompt_keywords.json +67 -0
  15. code/infer/example/output-windows/en-utf8-fixed.mp3 +3 -0
  16. code/infer/example/output-windows/en-without-utf8-fix.mp3 +3 -0
  17. code/infer/example/output/output.mp3 +3 -0
  18. code/infer/example/vocal.npy +3 -0
  19. code/models/tt_dit/experimental/lora/__init__.py +2 -0
  20. code/models/tt_dit/experimental/lora/adapter_loader.py +392 -0
  21. code/models/tt_dit/experimental/lora/ltx_adapter_loader.py +363 -0
  22. code/models/tt_dit/experimental/lora/promote.py +63 -0
  23. code/models/tt_dit/experimental/models/AniSora.md +136 -0
  24. code/models/tt_dit/experimental/models/Wan2_2_Distill.md +89 -0
  25. code/models/tt_dit/experimental/models/Wan2_2_LoRA.md +197 -0
  26. code/models/tt_dit/experimental/models/Wan2_2_SVI.md +204 -0
  27. code/models/tt_dit/experimental/pipelines/__init__.py +0 -0
  28. code/models/tt_dit/experimental/pipelines/pipeline_anisora.py +118 -0
  29. code/models/tt_dit/experimental/pipelines/pipeline_wan_distill.py +181 -0
  30. code/models/tt_dit/experimental/pipelines/pipeline_wan_lora.py +414 -0
  31. code/models/tt_dit/experimental/pipelines/pipeline_wan_runtime_lora.py +267 -0
  32. code/models/tt_dit/experimental/pipelines/pipeline_wan_svi.py +399 -0
  33. code/models/tt_dit/experimental/tests/__init__.py +0 -0
  34. code/models/tt_dit/experimental/tests/conftest.py +12 -0
  35. code/models/tt_dit/experimental/tests/test_adapter_loader.py +224 -0
  36. code/models/tt_dit/experimental/tests/test_lora_variants.py +262 -0
  37. code/models/tt_dit/experimental/tests/test_pipeline_anisora.py +227 -0
  38. code/models/tt_dit/experimental/tests/test_pipeline_lora.py +300 -0
  39. code/models/tt_dit/experimental/tests/test_pipeline_wan_distill_i2v.py +226 -0
  40. code/models/tt_dit/experimental/tests/test_pipeline_wan_svi.py +273 -0
  41. code/models/tt_dit/experimental/utils/__init__.py +0 -0
  42. code/models/tt_dit/experimental/utils/lightx2v_loader.py +199 -0
  43. code/models/tt_dit/models/audio_vae/minimax_h3/.gitkeep +0 -0
  44. code/models/tt_dit/models/audio_vae/minimax_h3/blockings_minimax_h3_audio.py +117 -0
  45. code/models/tt_dit/models/audio_vae/minimax_h3/convert_minimax_h3_audio.py +160 -0
  46. code/models/tt_dit/models/audio_vae/minimax_h3/decoder_minimax_h3_audio.py +171 -0
  47. code/models/tt_dit/models/audio_vae/minimax_h3/encoder_minimax_h3_audio.py +405 -0
  48. code/models/tt_dit/models/transformers/ltx/attention_ltx.py +870 -0
  49. code/models/tt_dit/models/transformers/ltx/quant_config.py +143 -0
  50. 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)