File size: 20,695 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
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
# SPDX-FileCopyrightText: Β© 2026 Tenstorrent USA, Inc.
# SPDX-License-Identifier: Apache-2.0

"""
Reusable LoRA mixin for the Linear family.

LoRA-aware Linears keep a *host bank* of registered adapters and apply
the active adapter via one of two execution paths, chosen at construction
with ``lora_mode``:

    fuse     β€” merge the delta into the base weight at bind time:
                   W += scale * A.T @ B.T   on bind
                   W -= scale * A.T @ B.T   on unbind
               Forward runs the un-modified base path with every fused
               kernel intact (matmul+gelu, matmul+addcmul, etc.) and
               forward-time LoRA overhead is zero. Swap cost is bounded
               to ~one small matmul + an in-place add per Linear.

    runtime  β€” keep A, B on device and add their effect to every forward:
                   y = base(x) + scale * (x @ A) @ B
               Swap cost is microseconds (a host-side index assignment +
               re-uploading A/B). Per-forward cost is two extra small
               matmuls. Useful when the per-forward overhead is cheaper
               than the bind-time matmul (very tall stacks of cheap adapters,
               or quick A/B experiments).

Both A and B are uploaded with sharding inferred from W's own ``mesh_axes``
so the matmul lands the right shard:
  - A is [in, rank] β†’ shard A's dim-0 the same way W's dim-0 is sharded.
  - B is [rank, out] β†’ shard B's dim-1 the same way W's dim-1 is sharded.

Runtime mode supports replicated outputs only. Chunked-output Linears
(``chunks`` set on ColParallel) must use fuse mode β€” splitting the LoRA
delta across the same chunk boundaries is not implemented and would
defeat the runtime path's main advantage (zero CCL).

Runtime mode is also incompatible with the fused-addcmul paths
(``RowParallelLinear.forward_fused_addcmul``,
``WanAttention._to_out_fused_addcmul``): those kernels combine matmul,
reduce-scatter, and addcmul in a single op and read ``weight.data``
directly, so the runtime A/B tensors have nowhere to participate. Fuse
mode works on these paths automatically because the delta lives in
``weight.data``.

TODO: ``lora_enabled`` is currently threaded through 5 constructors
(``WanPipeline`` β†’ ``WanTransformer3DModel`` β†’ ``WanTransformerBlock`` β†’
``WanAttention`` / ``ParallelFeedForward``) to pick between the LoRA
class and its base. The LoRA classes could instead be drop-in
replacements with a zero-overhead empty-bank fast path in
``LoRAMixin.forward``, removing the flag entirely. Deferred to a
follow-up β€” needs perf validation that the empty-bank forward is
truly identical to the base.

Note: bind/unbind in fuse mode adds and subtracts the delta in bf16,
which is not bit-exactly inverse. W drifts by ~O(N Β· eps_bf16) over N
swap cycles. In production this is bounded automatically by
``dynamic_load=True`` (``cache.load_model`` restores the cached base
on every page-in). For static pipelines (``dynamic_load=False``) that
need bit-exact restore across many swaps, snapshot the state dict once
at construction and call ``load_torch_state_dict`` to re-seed W when
drift becomes material. (With the A/B cache enabled β€” ``cache_capacity>0``
β€” unbind subtracts the *same* device delta that bind added for a cached
adapter, so those swap cycles are exact and do not drift.)

Fuse-mode A/B cache (``cache_capacity``)
----------------------------------------
Each bind/unbind/reload otherwise re-uploads A and B from host and
re-lays them out for W's sharding. With ``cache_capacity>0`` the uploaded
device factors are kept in a per-module LRU (keyed by bank index), so a
re-bind of an already-seen adapter β€” and, under ``dynamic_load``, the
re-merge after every page-in β€” skips the host upload. The cache holds
only the rank-sized A/B (never the full delta), survives page-out (the
factors are plain tensors, not tracked ``Parameters``, so
``deallocate_weights`` leaves them alone), and is bounded to
``cache_capacity`` adapters per Linear. ``cache_capacity=0`` disables it
and restores the upload-then-free behavior.
"""
from __future__ import annotations

from collections import OrderedDict
from dataclasses import dataclass

import torch

import ttnn

from ..utils.tensor import from_torch as _from_torch


@dataclass
class LoRAAdapter:
    """Host-resident LoRA adapter record. ``A`` and ``B`` are torch tensors
    in PyTorch LoRA layout (A: [rank, in], B: [out, rank])."""

    name: str
    A: torch.Tensor
    B: torch.Tensor
    rank: int
    scale: float


class LoRAMixin:
    """Adds a LoRA bank + active-adapter state to a Linear-family Module.

    Subclasses call ``_init_lora_state(mode=...)`` at the end of their
    ``__init__``. The mixin handles bind/unbind and (for ``runtime`` mode)
    overrides ``forward`` to add the delta.
    """

    # Layout-neutral (adds no __dict__/__weakref__ of its own) so a plain
    # Linear instance can be promoted in place via ``__class__`` assignment
    # (see experimental/lora/promote.py); state still lives in Module's __dict__.
    __slots__ = ()

    LORA_MODES = ("fuse", "runtime")

    # ---- one-time state init ----
    def _init_lora_state(self, mode: str = "fuse", cache_capacity: int = 0) -> None:
        if mode not in self.LORA_MODES:
            raise ValueError(f"lora_mode must be one of {self.LORA_MODES}; got {mode!r}")
        self.lora_mode = mode
        self.lora_bank: list[LoRAAdapter | None] = []
        self.active_idx: int | None = None
        self.active_scale: float = 1.0
        # Fuse-mode LRU of device-resident A/B factors, keyed by bank index:
        # {idx: (A_dev, B_dev, scale)}, MRU last. Bounded to ``lora_cache_capacity``
        # adapters; 0 disables caching (upload-then-free per bind).
        self.lora_cache_capacity: int = int(cache_capacity)
        self._ab_cache: OrderedDict[int, tuple[ttnn.Tensor, ttnn.Tensor, float]] = OrderedDict()
        # ``active_idx`` records intent (which adapter should be used).
        # ``_delta_applied`` (fuse mode) records whether the merge is
        # currently present on the on-device weight. These can diverge
        # under dynamic_load: when the transformer is paged out the
        # device weight is gone; when it pages back in the cached base
        # weight is restored and the delta must be re-applied.
        self._delta_applied: bool = False
        # Device-resident A/B for runtime mode (None when no adapter
        # is bound or when the layer is paged out).
        self._runtime_A: ttnn.Tensor | None = None
        self._runtime_B: ttnn.Tensor | None = None

    # ---- introspection ----
    @property
    def is_lora_active(self) -> bool:
        """True if any LoRA is currently bound (whether or not the on-device
        side state is currently realized β€” e.g. paged out under dynamic_load)."""
        return self.active_idx is not None

    # ---- bank management ----
    def register_lora(
        self,
        A_torch: torch.Tensor,
        B_torch: torch.Tensor,
        scale: float = 1.0,
        name: str = "",
    ) -> int:
        # swiglu doubles out_features AND head-permutes W in _prepare_torch_state;
        # the LoRA delta would not undergo the same permutation, so the merged
        # weight's gate/value split would be wrong. Refuse rather than silently
        # corrupt β€” and avoid the confusing 2*out validation error.
        if getattr(self, "fused_activation_fn", None) is None and getattr(self, "activation_fn", None) == "swiglu":
            raise ValueError(
                "LoRA adapters are not supported on swiglu layers; the merged "
                "delta would not match the swiglu head-permuted weight layout"
            )

        rank = A_torch.shape[0]
        if A_torch.shape != (rank, self.in_features):
            raise ValueError(
                f"A must be [rank, in_features]; got {tuple(A_torch.shape)} " f"expected (*, {self.in_features})"
            )
        if B_torch.shape != (self.out_features, rank):
            raise ValueError(
                f"B must be [out_features, rank]; got {tuple(B_torch.shape)} " f"expected ({self.out_features}, {rank})"
            )

        adapter = LoRAAdapter(
            name=name,
            A=A_torch.detach().clone(),
            B=B_torch.detach().clone(),
            rank=rank,
            scale=float(scale),
        )
        self.lora_bank.append(adapter)
        return len(self.lora_bank) - 1

    def unregister_lora(self, idx: int) -> None:
        if not (0 <= idx < len(self.lora_bank)):
            return
        if self.active_idx == idx:
            self.unbind_active()
        self._drop_cached_ab(idx)
        self.lora_bank[idx] = None

    def set_lora_cache_capacity(self, n: int) -> None:
        """Set how many adapters keep their device A/B factors resident (LRU).
        Shrinking evicts the least-recently-used down to ``n``; ``n<=0`` frees
        the whole cache and disables further caching."""
        self.lora_cache_capacity = int(n)
        if self.lora_cache_capacity <= 0:
            self._free_ab_cache()
        else:
            self._evict_ab_cache()

    # ---- bind / unbind ----
    def bind_active(self, idx: int, scale: float | None = None) -> None:
        """Make bank[idx] the active LoRA.

        If a different adapter is currently bound, it is removed first.
        When the layer's weights are not currently on device (dynamic_load
        page-out), the merge is deferred β€” ``active_idx`` and ``active_scale``
        record the intent and ``reapply_after_load`` will pick it up on
        the next load.
        """
        # Validate the target slot BEFORE touching the current active adapter,
        # so a bad idx doesn't leave W with the prior delta half-undone.
        if not (0 <= idx < len(self.lora_bank)) or self.lora_bank[idx] is None:
            raise IndexError(f"invalid lora slot {idx}")

        adapter = self.lora_bank[idx]
        use_scale = adapter.scale if scale is None else float(scale)

        # Idempotent: don't subtract-and-re-add the same delta β€” that round-trip
        # accumulates bf16 quantization drift in W.
        if self.active_idx == idx and self.active_scale == use_scale:
            return

        if self.active_idx is not None:
            self._undo_active()

        self.active_idx = idx
        self.active_scale = use_scale
        if self.is_loaded():
            self._realize_active()

    def unbind_active(self) -> None:
        if self.active_idx is None:
            return
        self._undo_active()

    def _realize_active(self) -> None:
        """Materialize the on-device effect of the active adapter.

        fuse β†’ add the delta into ``W``. runtime β†’ upload ``A`` and ``B``
        for the forward path to consume.
        """
        if self.lora_mode == "fuse":
            self._apply_delta(self.active_idx, self.active_scale, sign=+1)
            self._delta_applied = True
        else:  # runtime
            adapter = self.lora_bank[self.active_idx]
            self._upload_runtime_ab(adapter.A, adapter.B)

    def _undo_active(self) -> None:
        if self.is_loaded():
            if self.lora_mode == "fuse" and self._delta_applied:
                if self.lora_bank[self.active_idx] is not None:
                    self._apply_delta(self.active_idx, self.active_scale, sign=-1)
            elif self.lora_mode == "runtime":
                self._free_runtime_ab()
        self._delta_applied = False
        self.active_idx = None
        self.active_scale = 1.0

    def reapply_after_load(self) -> None:
        """Re-materialize the active adapter after a dynamic_load reload.

        Called by the pipeline after a reload restores the cached base
        weights and (for runtime mode) wipes the device-resident A/B.
        No-op when no adapter is active or the effect is already in place.
        """
        if self.active_idx is None or not self.is_loaded():
            return
        if self.lora_mode == "fuse" and self._delta_applied:
            return
        if self.lora_mode == "runtime" and self._runtime_A is not None:
            return
        self._realize_active()

    def deallocate_weights(self) -> None:  # type: ignore[override]
        # Weights about to be freed β†’ any merged delta goes with them
        # and runtime A/B are tied to the layer being resident.
        self._delta_applied = False
        if self._runtime_A is not None or self._runtime_B is not None:
            self._free_runtime_ab()
        super().deallocate_weights()

    def deallocate_lora(self) -> None:
        if self.active_idx is not None:
            self.unbind_active()
        self._free_ab_cache()
        self.lora_bank = []

    # ---- forward override (runtime mode) ----
    def forward(self, x: ttnn.Tensor, *args, **kwargs):  # type: ignore[override]
        if self.lora_mode != "runtime" or self._runtime_A is None:
            return super().forward(x, *args, **kwargs)

        # Reference math: y = activation(linear(x, W, b) + delta). The base
        # Linear may fuse the activation into the matmul; if so, disable it
        # for this call so we can add the delta BEFORE the activation runs.
        saved_fused = getattr(self, "fused_activation_fn", None)
        saved_activation = getattr(self, "activation_fn", None)
        try:
            if saved_fused is not None:
                self.fused_activation_fn = None
            if saved_activation is not None:
                self.activation_fn = None
            base = super().forward(x, *args, **kwargs)
        finally:
            self.fused_activation_fn = saved_fused
            self.activation_fn = saved_activation

        if isinstance(base, list):
            raise RuntimeError(
                "runtime LoRA delta does not support chunked-output Linears; "
                "construct with lora_mode='fuse' for chunks>1"
            )
        # Honor a caller-passed compute_kernel_config override (the base
        # Linear path does), so runtime LoRA and the base matmul use the
        # same precision settings within one forward.
        delta_compute_config = kwargs.get("compute_kernel_config", self.compute_config)
        ax = ttnn.matmul(x, self._runtime_A, compute_kernel_config=delta_compute_config)
        delta = ttnn.matmul(ax, self._runtime_B, compute_kernel_config=delta_compute_config)
        ttnn.deallocate(ax)
        out = ttnn.add(base, delta)
        ttnn.deallocate(base)
        ttnn.deallocate(delta)

        if saved_fused is not None:
            # Only gelu is currently hoisted into fused_activation_fn.
            if saved_fused[0] == ttnn.UnaryOpType.GELU:
                out = ttnn.gelu(out)
            else:
                raise NotImplementedError(f"runtime LoRA cannot replay fused activation {saved_fused}")
        if saved_activation is not None:
            from .linear import _apply_activation_fn

            out = _apply_activation_fn(out, saved_activation)
        return out

    def forward_fused_addcmul(self, *args, **kwargs):  # type: ignore[override]
        # The fused matmul + reduce-scatter + addcmul kernel reads weight.data
        # directly and has nowhere to add a separate LoRA delta β€” runtime mode
        # would silently no-op. Fuse mode is fine because the delta is already
        # merged into weight.data.
        if self.lora_mode == "runtime" and self.is_lora_active:
            raise RuntimeError(
                "runtime LoRA mode is incompatible with forward_fused_addcmul; "
                "construct this layer with lora_mode='fuse'"
            )
        return super().forward_fused_addcmul(*args, **kwargs)

    # ---- internals ----
    def _apply_delta(self, idx: int, scale: float, sign: int) -> None:
        """Add (``sign>0``) or subtract (``sign<0``) bank[idx]'s ``+scale`` delta
        into ``self.weight.data`` in place (fuse mode). The device A/B factors
        come from the LRU cache when enabled, so a re-bind or a post-reload
        re-merge skips the host upload; the same cached ``+scale`` delta serves
        both bind and unbind, making the pair an exact negation. The full-size
        delta itself is never cached (that would cost a whole weight per adapter)."""
        A_dev, B_dev, owned = self._acquire_delta_ab(idx, float(scale))
        delta = ttnn.matmul(A_dev, B_dev, compute_kernel_config=self.compute_config)
        if owned:
            ttnn.deallocate(A_dev)
            ttnn.deallocate(B_dev)
        if sign > 0:
            ttnn.add(self.weight.data, delta, output_tensor=self.weight.data)
        else:
            ttnn.subtract(self.weight.data, delta, output_tensor=self.weight.data)
        ttnn.deallocate(delta)

    def _acquire_delta_ab(self, idx: int, scale: float) -> tuple[ttnn.Tensor, ttnn.Tensor, bool]:
        """Return ``(A_dev, B_dev, owned)`` for bank[idx]'s ``+scale`` delta
        factors, uploading + laying them out for W's sharding on a miss.
        ``owned=True`` means the factors are uncached and the caller must
        deallocate them; cached factors are owned by the LRU (``owned=False``)."""
        adapter = self.lora_bank[idx]
        if self.lora_cache_capacity <= 0:
            A_dev, B_dev = self._upload_ab_for_w_sharding(adapter.A, adapter.B, scale=scale)
            return A_dev, B_dev, True
        cached = self._ab_cache.get(idx)
        if cached is not None and cached[2] == scale:
            self._ab_cache.move_to_end(idx)  # mark most-recently-used
            return cached[0], cached[1], False
        if cached is not None:  # same slot bound at a new scale β†’ rebuild
            self._drop_cached_ab(idx)
        A_dev, B_dev = self._upload_ab_for_w_sharding(adapter.A, adapter.B, scale=scale)
        self._ab_cache[idx] = (A_dev, B_dev, scale)  # inserted at the MRU end
        self._evict_ab_cache()
        return A_dev, B_dev, False

    def _evict_ab_cache(self) -> None:
        # Trim from the LRU end. The active adapter is always the MRU (just
        # inserted or touched), so for capacity >= 1 it is never evicted.
        while len(self._ab_cache) > self.lora_cache_capacity:
            _, (A_old, B_old, _) = self._ab_cache.popitem(last=False)
            ttnn.deallocate(A_old)
            ttnn.deallocate(B_old)

    def _drop_cached_ab(self, idx: int) -> None:
        entry = self._ab_cache.pop(idx, None)
        if entry is not None:
            ttnn.deallocate(entry[0])
            ttnn.deallocate(entry[1])

    def _free_ab_cache(self) -> None:
        for A_dev, B_dev, _ in self._ab_cache.values():
            ttnn.deallocate(A_dev)
            ttnn.deallocate(B_dev)
        self._ab_cache.clear()

    def _upload_runtime_ab(self, A_torch: torch.Tensor, B_torch: torch.Tensor) -> None:
        """Upload A and B for the runtime forward path. The active scale
        is baked into B so forward stays as two pure matmuls + one add."""
        self._runtime_A, self._runtime_B = self._upload_ab_for_w_sharding(A_torch, B_torch, scale=self.active_scale)

    def _free_runtime_ab(self) -> None:
        if self._runtime_A is not None:
            ttnn.deallocate(self._runtime_A)
            self._runtime_A = None
        if self._runtime_B is not None:
            ttnn.deallocate(self._runtime_B)
            self._runtime_B = None

    def _upload_ab_for_w_sharding(
        self,
        A_torch: torch.Tensor,
        B_torch: torch.Tensor,
        *,
        scale: float,
    ) -> tuple[ttnn.Tensor, ttnn.Tensor]:
        """Upload ``A`` ([rank,in] β†’ [in,rank]) and ``scale*B`` ([out,rank] β†’ [rank,out])
        with the sharding of ``W``:
          - A's dim 0 sharded like W's dim 0
          - B's dim 1 sharded like W's dim 1
        So that matmul(A, B) yields a tensor with W's combined sharding."""
        w_axes = self.weight.mesh_axes
        # Always take the multiply path so bind (+scale) and unbind (-scale)
        # exercise the same code with the same fp32β†’bf16 rounding boundaries;
        # the on-device deltas then cleanly negate each other.
        B_eff = B_torch * scale
        A_dev = _from_torch(
            A_torch.transpose(0, 1).contiguous().to(torch.bfloat16),
            device=self.mesh_device,
            layout=ttnn.Layout.TILE,
            dtype=ttnn.bfloat16,
            mesh_axes=(w_axes[0], None),
        )
        B_dev = _from_torch(
            B_eff.transpose(0, 1).contiguous().to(torch.bfloat16),
            device=self.mesh_device,
            layout=ttnn.Layout.TILE,
            dtype=ttnn.bfloat16,
            mesh_axes=(None, w_axes[1]),
        )
        return A_dev, B_dev