File size: 17,457 Bytes
629b123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# SPDX-FileCopyrightText: © 2025 Tenstorrent AI ULC

# SPDX-License-Identifier: Apache-2.0

"""On-device Gemma-3 text encoder + embeddings connectors for LTX-2, following the
tt_dit encoder_pair convention (cf. ``encoders/t5/encoder_pair.py``)."""

from __future__ import annotations

import glob
import os
import time
from typing import Callable

import torch
from huggingface_hub import snapshot_download
from loguru import logger
from safetensors import safe_open
from safetensors.torch import load_file
from transformers import AutoTokenizer

import ttnn

from ...parallel.manager import CCLManager
from ...utils import cache as cache_module
from ...utils.mochi import get_rot_transformation_mat
from ...utils.tensor import bf16_tensor
from ...utils.tracing import traced_function
from .embeddings_connector import EmbeddingsConnector
from .feature_extractor import GemmaFeatureExtractor
from .model_gemma import GemmaConfig, GemmaEncoder

GEMMA_SEQUENCE_LENGTH = 1024
VIDEO_EMBED_DIM = 4096
AUDIO_EMBED_DIM = 2048

_CONNECTOR_PREFIXES = (
    "text_embedding_projection.video_aggregate_embed.",
    "text_embedding_projection.audio_aggregate_embed.",
    "model.diffusion_model.video_embeddings_connector.",
    "model.diffusion_model.audio_embeddings_connector.",
)


def _resolve_gemma_dir(gemma: str) -> str:
    """Resolve a Gemma reference to a local directory: a local dir is returned as-is, a
    HuggingFace repo id is snapshot-downloaded (cached). The original reference — not this
    resolved snapshot dir, whose basename is an opaque revision hash — names the cache."""
    if os.path.isdir(gemma):
        return gemma
    logger.info(f"Resolving HuggingFace Gemma repo {gemma} (auto-download if missing)")
    return snapshot_download(repo_id=gemma)


# --- source state dicts (lazy: only read on a cache miss) -------------------
def _gemma_state_dict(gemma_path: str) -> dict[str, torch.Tensor]:
    """All Gemma encoder weights, from the HF safetensors shards."""
    weight_files = sorted(glob.glob(f"{gemma_path}/model-*.safetensors")) or sorted(
        glob.glob(f"{gemma_path}/*.safetensors")
    )
    sd: dict[str, torch.Tensor] = {}
    for f in weight_files:
        sd.update(load_file(f))
    return sd


def _read_connector_checkpoint(checkpoint_name: str) -> dict[str, torch.Tensor]:
    """The connector + aggregate_embed tensors from the LTX checkpoint."""
    state: dict[str, torch.Tensor] = {}
    with safe_open(checkpoint_name, "pt") as f:
        for k in f.keys():
            if k.startswith(_CONNECTOR_PREFIXES):
                state[k] = f.get_tensor(k)
    return state


def _feature_extractor_state_dict(ckpt, *, mode: str, gemma_hidden_size: int, gemma_num_layers: int) -> dict:
    """video/audio aggregate_embed weights, permuted D-major→layer-major to match the
    on-device layer-major concat (see GemmaFeatureExtractor._weight_to_layer_major)."""
    sd = {}
    for axis in ("video", "audio") if mode == "av" else ("video",):
        prefix = f"text_embedding_projection.{axis}_aggregate_embed."
        for k, v in ckpt.items():
            if k.startswith(prefix):
                sub = k[len(prefix) :]
                if sub == "weight":
                    v = GemmaFeatureExtractor._weight_to_layer_major(v, gemma_hidden_size, gemma_num_layers)
                sd[f"{axis}_aggregate_embed.{sub}"] = v
    return sd


def _connector_state_dict(ckpt, axis: str, num_blocks: int) -> dict:
    """One connector's transformer blocks + norm, dropping blocks beyond ``num_blocks``."""
    prefix = f"model.diffusion_model.{axis}_embeddings_connector."
    sd = {}
    for k, v in ckpt.items():
        if not k.startswith(prefix):
            continue
        sub = k[len(prefix) :]
        if sub.startswith("transformer_1d_blocks.") and int(sub.split(".")[1]) >= num_blocks:
            continue
        sd[sub] = v
    return sd


class GemmaTokenizerEncoderPair:
    """Tokenizer + on-device Gemma-3 encoder + embeddings connectors for LTX-2.

    ``encode(prompts)`` returns ``[(video_embeds, audio_embeds), ...]``. Gemma weights load from
    ``gemma_path``; connector/feature-extractor weights from the LTX ``checkpoint_name``.
    """

    def __init__(
        self,
        gemma_path: str | None,
        *,
        mesh_device: ttnn.MeshDevice,
        ccl_manager: CCLManager,
        parallel_config,
        checkpoint_name: str | None = None,
        mode: str = "av",
        dynamic_load: bool = False,
        sequence_length: int = GEMMA_SEQUENCE_LENGTH,
        video_dim: int = VIDEO_EMBED_DIM,
        audio_dim: int = AUDIO_EMBED_DIM,
        num_layers: int = 48,
        hidden_layer_index: int = -1,
    ) -> None:
        # Name the cache by the caller's reference (a repo id / dir basenames cleanly),
        # mirroring T5; resolve it to a local dir for tokenizer + weight loading.
        self.gemma_ref = gemma_path
        self.gemma_path = _resolve_gemma_dir(gemma_path) if gemma_path else None
        self.mesh_device = mesh_device
        self.ccl_manager = ccl_manager
        self.parallel_config = parallel_config
        self.checkpoint_name = checkpoint_name
        self.mode = mode
        self.dynamic_load = dynamic_load
        self._num_layers = num_layers
        self._hidden_layer_index = hidden_layer_index
        self._sequence_length = sequence_length
        self._video_dim = video_dim
        self._audio_dim = audio_dim

        self.gemma_encoder = None
        self.tokenizer = None
        self.feature_extractor = None
        self.video_connector = None
        self.audio_connector = None
        self._coresident_peers: list = []
        self._cached_trans_mat = None
        # Trace the whole-encode graph only when the encoder stays resident. Under dynamic_load it
        # is evicted after each encode, so every encode would be a fresh (cold) capture — slower
        # than untraced — and the trace never amortizes. Resident (e.g. 4x8) captures once and
        # replays warm.
        self._encoder_trace = not dynamic_load
        # A capture taken after this one reuses the memory freed from its capture-time activations,
        # and replaying the encode then writes over whatever now owns that region. Consumers that
        # capture traces of their own after construction call ``defer_trace_capture`` and reopen the
        # gate once they are done, which makes the encode the last capture taken.
        self._trace_gate_open = True

    # Dims the pipeline warmup needs before the encoder is built.
    @property
    def sequence_length(self) -> int:
        return self._sequence_length

    @property
    def video_dim(self) -> int:
        return self._video_dim

    @property
    def audio_dim(self) -> int:
        return self._audio_dim

    def defer_trace_capture(self) -> None:
        """Hold the encode trace back until ``open_trace_gate`` (see ``_trace_gate_open``)."""
        self._trace_gate_open = False

    def open_trace_gate(self) -> None:
        """Allow the next encode to capture/replay its trace (see ``_trace_gate_open``)."""
        self._trace_gate_open = True

    def register_coresident_peers(self, peers: list) -> None:
        """Store the DiT/VAE peers the encoder modules must not be L1-coresident with.
        Exclusions are wired at each module's first build, so peers must be registered before
        the first ``ensure_loaded``/``load_*``."""
        self._coresident_peers = list(peers)

    def _register_exclusions(self, module) -> None:
        """Bidirectionally exclude a freshly-built encoder module against the stored peers,
        before its first load — so the encoder load auto-evicts the DiT (and the later DiT
        reload auto-evicts the encoder). No-op unless dynamic_load."""
        if not self.dynamic_load or not self._coresident_peers:
            return
        module.register_coresident_exclusions(*self._coresident_peers)
        for p in self._coresident_peers:
            p.register_coresident_exclusions(module)

    def is_loaded(self) -> bool:
        return self.gemma_encoder is not None and self.gemma_encoder.is_loaded()

    def ensure_loaded(self, connector_state: dict | Callable[[], dict] | None = None) -> None:
        """Build/reload the encoder + connector weights (evicted by dynamic_load)."""
        if self.is_loaded():
            return
        self.load_gemma_encoder()
        self.load_embeddings_connectors(
            connector_state
            if connector_state is not None
            else (lambda: _read_connector_checkpoint(self.checkpoint_name))
        )

    def load_gemma_encoder(self, gemma_path: str | None = None) -> None:
        """Load the TTNN Gemma-3 encoder. Built once and reused across reloads; weights reload
        from the shared cache (no re-tilizing 12B params). The CCLManager is supplied by the
        pipeline (shared with the connectors), so reloads never rebuild it."""
        gemma_path = gemma_path or self.gemma_path
        if self.gemma_encoder is None:
            config = GemmaConfig(
                num_hidden_layers=self._num_layers,
                hidden_layer_index=self._hidden_layer_index,
                max_position_embeddings=self._sequence_length,
            )
            self.gemma_encoder = GemmaEncoder(config, self.mesh_device, self.ccl_manager, self.parallel_config)
            self._register_exclusions(self.gemma_encoder)
            # Left-padding matches the reference FeatureExtractorV2: [PAD..PAD, BOS, real];
            # padded hidden states are zeroed via attention_mask on both sides.
            self.tokenizer = AutoTokenizer.from_pretrained(gemma_path)

        t0 = time.time()
        cache_module.load_model(
            self.gemma_encoder,
            model_name=os.path.basename(os.path.normpath(self.gemma_ref)),
            subfolder="text_encoder",
            parallel_config=self.parallel_config,
            mesh_shape=tuple(self.mesh_device.shape),
            mesh_device=self.mesh_device,
            get_torch_state_dict=lambda: _gemma_state_dict(gemma_path),
        )
        logger.info(f"Loaded TTNN Gemma encoder ({self._num_layers}L) in {time.time()-t0:.0f}s")

    def load_embeddings_connectors(
        self,
        checkpoint_state: dict[str, torch.Tensor] | Callable[[], dict[str, torch.Tensor]],
        *,
        gemma_hidden_size: int = 3840,
        gemma_num_layers: int = 49,  # embedding layer + 48 decoder layers
        video_num_blocks: int = 8,
        audio_num_blocks: int = 8,
        num_heads: int = 32,
    ) -> None:
        """Load the feature extractor + video/audio connectors from the LTX checkpoint through
        the shared cache. Modules are built once and reused across reloads; the CCLManager is
        supplied by the pipeline (shared with the Gemma encoder).

        ``checkpoint_state`` is a dict or a zero-arg callable; resolved once and only on a cache
        miss, so an all-cache-hit reload reads neither the checkpoint nor the tilizer.
        """
        ckpt = _memoize(checkpoint_state)
        ckpt_name = (
            os.path.basename(self.checkpoint_name).removesuffix(".safetensors")
            if self.checkpoint_name
            else "ltx-connectors"
        )

        if self.feature_extractor is None:
            self.feature_extractor = GemmaFeatureExtractor(
                input_dim=gemma_hidden_size * gemma_num_layers,
                embedding_dim=gemma_hidden_size,
                video_dim=self._video_dim,
                audio_dim=self._audio_dim if self.mode == "av" else None,
                mesh_device=self.mesh_device,
                ccl_manager=self.ccl_manager,
                parallel_config=self.parallel_config,
            )
            self._register_exclusions(self.feature_extractor)
        cache_module.load_model(
            self.feature_extractor,
            model_name=ckpt_name,
            subfolder="feature_extractor",
            parallel_config=self.parallel_config,
            mesh_shape=tuple(self.mesh_device.shape),
            mesh_device=self.mesh_device,
            get_torch_state_dict=lambda: _feature_extractor_state_dict(
                ckpt(), mode=self.mode, gemma_hidden_size=gemma_hidden_size, gemma_num_layers=gemma_num_layers
            ),
        )

        self.video_connector = self._load_connector(
            "video", self._video_dim, video_num_blocks, num_heads, ckpt_name, ckpt
        )
        self.audio_connector = (
            self._load_connector("audio", self._audio_dim, audio_num_blocks, num_heads, ckpt_name, ckpt)
            if self.mode == "av"
            else None
        )

    def _load_connector(self, axis, output_dim, num_blocks, num_heads, ckpt_name, ckpt) -> EmbeddingsConnector:
        connector = getattr(self, f"{axis}_connector")
        if connector is None:
            connector = EmbeddingsConnector(
                output_dim=output_dim,
                num_blocks=num_blocks,
                num_heads=num_heads,
                mesh_device=self.mesh_device,
                ccl_manager=self.ccl_manager,
                parallel_config=self.parallel_config,
            )
            self._register_exclusions(connector)
        cache_module.load_model(
            connector,
            model_name=ckpt_name,
            subfolder=f"{axis}_connector",
            parallel_config=self.parallel_config,
            mesh_shape=tuple(self.mesh_device.shape),
            mesh_device=self.mesh_device,
            dtype="float32",
            get_torch_state_dict=lambda: _connector_state_dict(ckpt(), axis, num_blocks),
        )
        logger.info(f"Loaded {axis} embeddings connector ({num_blocks} blocks, dim={output_dim})")
        return connector

    @traced_function(device=lambda self: self.mesh_device, clone_prep_inputs=False, prep_run=True)
    def _encode_device(self, tt_ids, tt_gemma_mask, fe_mask, src_idx, keep_mask):
        """Whole-encode device graph (gemma → feature extractor → connectors), captured as one
        ttnn trace and replayed per prompt. Per-prompt masks/indices are device inputs (built in
        encode); rope/trans_mat/registers are captured constants (prep_run allocates them before
        capture). Returns DEVICE (video, audio) embeds — tokenizer + final to_torch stay in encode."""
        trans_mat = self._prepare_trans_mat()
        all_hidden = self.gemma_encoder(tt_ids, tt_attn_mask=tt_gemma_mask)
        # FeatureExtractorV2 consumes [embed, L0..L46, final_norm]; the encoder emits
        # [embed, L0..L47, final_norm], so drop index -2.
        hs_list = list(all_hidden[:-2]) + [all_hidden[-1]]
        video_feats, audio_feats = self.feature_extractor(hs_list, fe_mask)
        video = self.video_connector(video_feats, src_idx, keep_mask, trans_mat=trans_mat)
        audio = (
            self.audio_connector(audio_feats, src_idx, keep_mask, trans_mat=trans_mat)
            if self.audio_connector is not None
            else None
        )
        return video, audio

    def encode(self, prompts: list[str]) -> list[tuple[torch.Tensor, torch.Tensor | None]]:
        """Tokenize → traced whole-encode device graph → host embeds, one
        ``(video_embeds, audio_embeds)`` per prompt. Pure compute; the disk cache lives in the
        pipeline."""
        assert self.gemma_encoder is not None, "Call ensure_loaded() first"

        results = []
        for prompt in prompts:
            tokens = self.tokenizer(
                prompt, return_tensors="pt", padding="max_length", max_length=self._sequence_length, truncation=True
            )
            tt_ids = ttnn.from_torch(
                tokens.input_ids, device=self.mesh_device, dtype=ttnn.uint32, layout=ttnn.ROW_MAJOR_LAYOUT
            )
            seq = tt_ids.shape[-1]
            # Per-prompt device inputs, built on host and copied into the trace on replay.
            tt_gemma_mask = self.gemma_encoder.build_attn_mask(tokens.attention_mask, seq)
            fe_mask = self.feature_extractor.build_mask(tokens.attention_mask)
            # src_idx/keep_mask are dim-independent → shared by both connectors (build once).
            src_idx, keep_mask = self.video_connector.build_indices(tokens.attention_mask, seq)

            video_dev, audio_dev = self._encode_device(
                tt_ids, tt_gemma_mask, fe_mask, src_idx, keep_mask, traced=self._encoder_trace and self._trace_gate_open
            )
            video_embeds = ttnn.to_torch(ttnn.get_device_tensors(video_dev)[0]).float()
            audio_embeds = (
                ttnn.to_torch(ttnn.get_device_tensors(audio_dev)[0]).float() if audio_dev is not None else None
            )
            results.append((video_embeds, audio_embeds))
        return results

    def _prepare_trans_mat(self) -> ttnn.Tensor:
        """Cached per-tile rotation matrix for rotary_embedding_llama."""
        if self._cached_trans_mat is None:
            self._cached_trans_mat = bf16_tensor(get_rot_transformation_mat(), device=self.mesh_device)
        return self._cached_trans_mat


def _memoize(state: dict | Callable[[], dict]) -> Callable[[], dict]:
    """Wrap a dict-or-callable as a zero-arg callable that resolves at most once."""
    cached: dict = {}

    def get() -> dict:
        if "sd" not in cached:
            cached["sd"] = state() if callable(state) else state
        return cached["sd"]

    return get