Rhett1 commited on
Commit
7a25fb1
·
verified ·
1 Parent(s): 5e2fccf

Upload tr3/heal/llama2-7b-base/tensor_formats.py with huggingface_hub

Browse files
tr3/heal/llama2-7b-base/tensor_formats.py ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """General N-core tensor formats for a linear layer: tensor train (MPO) and tensor ring.
4
+
5
+ WHY THIS EXISTS. The 2-core and 3-core results say the tensorisation step, not the healing
6
+ step, is where the leverage is: at equal parameters the 3-core beat the 2-core AFTER healing
7
+ at both budgets tried (4.72B: 38.94 vs ~36.5 full MMLU at 60M tokens; 5.74B: 44.91 vs 41.54
8
+ probe), and it got there on roughly a quarter of the tokens. That invites two orthogonal
9
+ questions this module exists to answer: how many cores, and which topology.
10
+
11
+ TENSOR TRAIN vs TENSOR RING. A TT/MPO pins the two boundary bonds to 1 -- the chain has open
12
+ ends. A tensor ring closes the chain: core k has shape (r_{k-1}, d_k, r_k) with r_N == r_0,
13
+ and the weight is the TRACE of the cyclic product. TT is exactly the TR special case r_0 = 1,
14
+ so at equal ranks TR is strictly more expressive. It is also strictly harder to fit: TT-SVD
15
+ is a sequence of Eckart-Young-optimal truncations, while a ring has no such closed form, so
16
+ TR is fitted here by TT initialisation followed by alternating least squares. That asymmetry
17
+ matters when reading results -- a TR loss could mean "the format is worse" OR "the fit did
18
+ not converge", so `fit_tr` reports its per-sweep error curve and refuses to return a fit
19
+ worse than its own TT initialisation.
20
+
21
+ INDEX CONVENTION (shared with mpo2.py / mpo3.py). Output mode k travels with input mode k:
22
+
23
+ R(W)[d_1, ..., d_N] = W[(o_1..o_N), (i_1..i_N)], d_k = (o_k, i_k) flattened
24
+
25
+ Every mode must be > 1. A mode of 1 collapses a site and silently reduces the core count,
26
+ which would make an "N-core" result actually be an (N-1)-core one.
27
+
28
+ WHITENING is diagonal and factorised across the N input modes, as in mpo3.py: the whitening
29
+ is then a mode-local rescaling that survives the fit exactly and un-whitens by division.
30
+ """
31
+
32
+ import torch
33
+
34
+
35
+ # --------------------------------------------------------------------------- rearrangement
36
+ def rearrange_n(W: torch.Tensor, osplit, isplit) -> torch.Tensor:
37
+ """W[(o_1..o_N),(i_1..i_N)] -> R[d_1,...,d_N] with d_k = o_k*i_k."""
38
+ n = len(osplit)
39
+ if len(isplit) != n:
40
+ raise ValueError("output and input must be split into the same number of modes")
41
+ perm = [j for k in range(n) for j in (k, n + k)]
42
+ return (W.reshape(*osplit, *isplit).permute(*perm)
43
+ .reshape(*[o * i for o, i in zip(osplit, isplit)]))
44
+
45
+
46
+ def inv_rearrange_n(R: torch.Tensor, osplit, isplit) -> torch.Tensor:
47
+ """Inverse of `rearrange_n`."""
48
+ n = len(osplit)
49
+ inter = [x for k in range(n) for x in (osplit[k], isplit[k])]
50
+ perm = list(range(0, 2 * n, 2)) + list(range(1, 2 * n, 2))
51
+ o = 1
52
+ i = 1
53
+ for k in range(n):
54
+ o *= osplit[k]
55
+ i *= isplit[k]
56
+ return R.reshape(*inter).permute(*perm).reshape(o, i)
57
+
58
+
59
+ def _apply_scales(R: torch.Tensor, osplit, isplit, scales, invert: bool):
60
+ """Multiply (or divide) R by sqrt(d_k[i_k]) along each input mode, in place of a dense L."""
61
+ n = len(osplit)
62
+ shape = [x for k in range(n) for x in (osplit[k], isplit[k])]
63
+ T = R.reshape(*shape)
64
+ for k in range(n):
65
+ s = scales[k].clamp_min(1e-12).sqrt()
66
+ view = [1] * (2 * n)
67
+ view[2 * k + 1] = isplit[k]
68
+ T = T / s.view(*view) if invert else T * s.view(*view)
69
+ return T.reshape(R.shape)
70
+
71
+
72
+ # --------------------------------------------------------------------------- contraction
73
+ def contract_tt(cores) -> torch.Tensor:
74
+ """Contract TT cores [(1,d1,r1), (r1,d2,r2), ..., (r_{N-1},dN,1)] -> R[d_1,...,d_N]."""
75
+ out = cores[0]
76
+ for g in cores[1:]:
77
+ a, b = out.shape[-1], g.shape[0]
78
+ if a != b:
79
+ raise ValueError(f"TT bond mismatch {a} vs {b}")
80
+ out = torch.tensordot(out, g, dims=([out.dim() - 1], [0]))
81
+ if out.shape[0] != 1 or out.shape[-1] != 1:
82
+ raise ValueError("TT boundary bonds must be 1")
83
+ return out.reshape(*out.shape[1:-1])
84
+
85
+
86
+ def contract_tr(cores, chunk: int = 0) -> torch.Tensor:
87
+ """Contract TR cores [(r0,d1,r1), ..., (r_{N-1},dN,r0)] -> R[d_1,...,d_N].
88
+
89
+ The obvious route -- chain every tensordot, then take the diagonal over the two ring
90
+ indices -- materialises (r_0, d_1..d_N, r_0), i.e. r_0^2 times the size of the ANSWER.
91
+ On one LLaMA attention layer at ring rank 118 that is 934 GB, so it does not merely run
92
+ slowly, it cannot run at all. Folding the last contraction and the trace into a single
93
+ einsum keeps the peak at r_0 * prod(d_1..d_{N-1}) * r_{N-1}, and chunking the first mode
94
+ bounds it further for the wide MLP layers.
95
+ """
96
+ n = len(cores)
97
+ if cores[0].shape[0] != cores[-1].shape[-1]:
98
+ raise ValueError(f"ring does not close: {cores[0].shape[0]} vs {cores[-1].shape[-1]}")
99
+ d0 = cores[0].shape[1]
100
+ if chunk and d0 > chunk:
101
+ parts = []
102
+ for lo in range(0, d0, chunk):
103
+ sub = [cores[0][:, lo:min(lo + chunk, d0), :]] + list(cores[1:])
104
+ parts.append(contract_tr(sub, chunk=0))
105
+ return torch.cat(parts, dim=0)
106
+ out = cores[0]
107
+ for g in cores[1:-1]:
108
+ out = torch.tensordot(out, g, dims=([out.dim() - 1], [0]))
109
+ return torch.einsum("i...k,kdi->...d", out, cores[-1])
110
+
111
+
112
+ # --------------------------------------------------------------------------- fitting
113
+ def fit_tt(R: torch.Tensor, ranks):
114
+ """TT-SVD with the given internal ranks. Singular values split symmetrically at each bond,
115
+ matching fit_mpo2/fit_mpo3's gauge so cores stay on comparable scales (fp16-safe and
116
+ kinder to a blockwise quantizer)."""
117
+ dims = list(R.shape)
118
+ n = len(dims)
119
+ if len(ranks) != n - 1:
120
+ raise ValueError(f"{n} sites need {n-1} internal ranks, got {len(ranks)}")
121
+ cores, rest, rprev = [], R.reshape(dims[0], -1), 1
122
+ for k in range(n - 1):
123
+ M = rest.reshape(rprev * dims[k], -1)
124
+ U, S, Vh = torch.linalg.svd(M, full_matrices=False)
125
+ r = min(int(ranks[k]), S.shape[0])
126
+ q = S[:r].clamp_min(0).sqrt()
127
+ cores.append((U[:, :r] * q).reshape(rprev, dims[k], r))
128
+ rest = q[:, None] * Vh[:r]
129
+ rprev = r
130
+ cores.append(rest.reshape(rprev, dims[-1], 1))
131
+ return cores
132
+
133
+
134
+ def tt_to_tr(cores):
135
+ """Reinterpret TT cores as TR cores with r_0 = 1 (an exact, lossless re-labelling)."""
136
+ return [c.clone() for c in cores]
137
+
138
+
139
+ def _subchain(cores, k):
140
+ """Contract every core except k, in cyclic order, -> E[r_k, D, r_{k-1}] with D = prod_{j!=k} d_j."""
141
+ n = len(cores)
142
+ order = [(k + 1 + t) % n for t in range(n - 1)]
143
+ out = cores[order[0]]
144
+ for j in order[1:]:
145
+ out = torch.tensordot(out, cores[j], dims=([out.dim() - 1], [0]))
146
+ return out
147
+
148
+
149
+ def _subchain_chunk(cores, k, lo, hi):
150
+ """`_subchain` with the FIRST following mode restricted to [lo, hi). The restriction rides
151
+ along the contraction untouched, so concatenating the chunks reproduces `_subchain`."""
152
+ n = len(cores)
153
+ order = [(k + 1 + t) % n for t in range(n - 1)]
154
+ out = cores[order[0]][:, lo:hi, :]
155
+ for j in order[1:]:
156
+ out = torch.tensordot(out, cores[j], dims=([out.dim() - 1], [0]))
157
+ return out
158
+
159
+
160
+ def rank_of(cores, k):
161
+ """r_{k-1} * r_k for core k -- the width of that core's least-squares system."""
162
+ n = len(cores)
163
+ return cores[k].shape[0] * cores[k].shape[2]
164
+
165
+
166
+ def _solve_maybe_cpu(AtA, AtW):
167
+ """Solve AtA X = AtW, falling back to the CPU when the GPU cannot hold the factorisation.
168
+
169
+ The normal-equation matrix is (r_{k-1} r_k) square, i.e. it grows as r^4. On down_proj at
170
+ ring rank 219 that is 47961^2 = 9.2 GB, and the factorisation needs a second copy -- no
171
+ amount of chunking the data helps, because this matrix does not depend on the data size.
172
+ Cholesky is tried first (the Gram is symmetric positive definite after the ridge) and
173
+ needs only one triangular factor; if even that does not fit, the solve moves to the host.
174
+ Slow, but a bounded slowdown beats an unbounded failure on the layers that matter most.
175
+ """
176
+ try:
177
+ L = torch.linalg.cholesky(AtA)
178
+ return torch.cholesky_solve(AtW, L)
179
+ except torch.OutOfMemoryError:
180
+ torch.cuda.empty_cache()
181
+ except Exception:
182
+ pass
183
+ try:
184
+ return torch.linalg.solve(AtA, AtW)
185
+ except torch.OutOfMemoryError:
186
+ torch.cuda.empty_cache()
187
+ dev = AtA.device
188
+ X = torch.linalg.solve(AtA.cpu().double(), AtW.cpu().double())
189
+ return X.to(device=dev, dtype=AtW.dtype)
190
+
191
+
192
+ def tr_rel_err(cores, R: torch.Tensor, chunk_elems: int = 250_000_000) -> float:
193
+ """||contract_tr(cores) - R||_F / ||R||_F, accumulated in chunks.
194
+
195
+ Materialising the reconstruction to subtract it needs the reconstruction AND the einsum's
196
+ intermediate at once; on down_proj at ring rank 155 that intermediate is (155, 64, 688,
197
+ 256) = 7 GB even with the first mode already chunked. Accumulating the squared error one
198
+ slice at a time keeps only one slice live, and the chunk width is derived from an element
199
+ budget rather than a hard-coded constant -- a constant is exactly what silently failed to
200
+ engage when a mode happened to equal it.
201
+ """
202
+ n = len(cores)
203
+ d0 = cores[0].shape[1]
204
+ mid = 1
205
+ for k in range(1, n - 1):
206
+ mid *= cores[k].shape[1]
207
+ per_unit = max(1, mid * cores[0].shape[0] * cores[-1].shape[0])
208
+ step = max(1, min(d0, int(chunk_elems // per_unit)))
209
+ acc = 0.0
210
+ for lo in range(0, d0, step):
211
+ hi = min(lo + step, d0)
212
+ sub = [cores[0][:, lo:hi, :]] + list(cores[1:])
213
+ diff = contract_tr(sub) - R[lo:hi]
214
+ acc += float(diff.pow(2).sum())
215
+ del sub, diff
216
+ return (acc ** 0.5) / float(R.norm())
217
+
218
+
219
+ def fit_tr(R: torch.Tensor, ranks, sweeps: int = 30, tol: float = 1e-8, verbose: bool = False,
220
+ seed: int = 1234, restarts: int = 2, solver: str = "lstsq",
221
+ chunk: int = 0, chunk_elems: int = 250_000_000):
222
+ """Fit a tensor ring by TT initialisation + alternating least squares.
223
+
224
+ ranks = [r_0, r_1, ..., r_{N-1}] with r_N == r_0. The TT init uses r_0 = 1 (a legal ring),
225
+ then each ALS sweep re-solves every core in closed form given the others; the ring's extra
226
+ boundary rank is opened up by padding the initialisation. Because ALS on a ring is not
227
+ guaranteed to improve monotonically from an arbitrary start, the best iterate seen is kept
228
+ and the routine REFUSES to return anything worse than its own TT initialisation -- without
229
+ that guard a fitting failure would be indistinguishable from the format being worse, which
230
+ is precisely the comparison this module exists to make.
231
+ """
232
+ n = len(R.shape)
233
+ if len(ranks) != n:
234
+ raise ValueError(f"a ring over {n} sites needs {n} ranks (r_0..r_{{N-1}})")
235
+ dims = list(R.shape)
236
+ rnorm = float(R.norm())
237
+ rnorm2 = float(R.pow(2).sum())
238
+ last_sq = None
239
+ tt = fit_tt(R, ranks[1:])
240
+ base_err = float((contract_tt(tt) - R).norm() / R.norm())
241
+ r0 = int(ranks[0])
242
+ gen = torch.Generator(device="cpu").manual_seed(seed)
243
+
244
+ def padded_init():
245
+ """TT solution embedded in the ring, with the OPENED bond seeded on BOTH ends.
246
+
247
+ Seeding only one end is not enough and is a silent failure: core 0's least-squares
248
+ environment is built from cores 1..N-1, whose ring index comes from core N-1's
249
+ trailing bond. If that stays zero, the environment vanishes on exactly the components
250
+ the ring adds, every ALS solve returns zero there, and the fit never leaves the TT
251
+ solution -- it looks like "the ring buys nothing" rather than "the ring was never
252
+ initialised".
253
+ """
254
+ out = []
255
+ for k, c in enumerate(tt):
256
+ a, d, b = c.shape
257
+ na = r0 if k == 0 else a
258
+ nb = r0 if k == n - 1 else b
259
+ g = torch.zeros(na, d, nb, dtype=R.dtype, device=R.device)
260
+ g[:a, :, :b] = c
261
+ eps = 0.05 * float(c.norm()) / max(1, c.numel()) ** 0.5
262
+ if k == 0 and na > a:
263
+ g[a:, :, :] = eps * torch.randn(na - a, d, nb, generator=gen,
264
+ dtype=R.dtype).to(R.device)
265
+ if k == n - 1 and nb > b:
266
+ g[:, :, b:] = eps * torch.randn(na, d, nb - b, generator=gen,
267
+ dtype=R.dtype).to(R.device)
268
+ out.append(g)
269
+ return out
270
+
271
+ def random_init(g=None):
272
+ """i.i.d. cores whose ring contraction has the right variance.
273
+
274
+ The scale is derived, not guessed. For cores with i.i.d. N(0, s^2) entries the trace
275
+ of the cyclic product is a sum of prod_k r_k independent products of N Gaussians, so
276
+ Var(W) ~= (prod_k r_k) * s^(2N). Matching that to the target mean square gives
277
+ s = (E[R^2] / prod_k r_k)^(1/(2N)). Getting this wrong by a constant factor per core
278
+ compounds to that factor^N in the product, which for N=4,5 puts the initial iterate
279
+ many orders of magnitude away from the data and leaves ALS crawling.
280
+ """
281
+ rr = [int(ranks[k]) for k in range(n)]
282
+ prod = 1.0
283
+ for x in rr:
284
+ prod *= x
285
+ ms = float(R.pow(2).mean())
286
+ s = (ms / prod) ** (1.0 / (2 * n))
287
+ gg = g if g is not None else gen
288
+ return [s * torch.randn(rr[k], dims[k], rr[(k + 1) % n], generator=gg,
289
+ dtype=R.dtype).to(R.device) for k in range(n)]
290
+
291
+ # ALS on a ring has genuine local minima -- a single start that stalls would be reported
292
+ # as "the ring is not better", which is exactly the confound this module must avoid. So
293
+ # several starts are run and the best kept; restarts are cheap next to the SVDs anyway.
294
+ inits = [("tt_padded", padded_init)]
295
+ for j in range(restarts):
296
+ g = torch.Generator(device="cpu").manual_seed(seed + 977 * (j + 1))
297
+ inits.append((f"random{j}", lambda g=g: random_init(g)))
298
+ best, best_err, hists, winner = tt_to_tr(tt), base_err, {}, "tt_init"
299
+ for init_name, init_fn in inits:
300
+ cores, ihist = init_fn(), []
301
+ for it in range(sweeps):
302
+ for k in range(n):
303
+ # The subchain leaves every d index separate and in CYCLIC order starting at
304
+ # k+1. R's remaining modes must be flattened in that SAME order -- movedim
305
+ # gives natural order, which for interior k is a different permutation and
306
+ # would solve the least-squares problem against mismatched columns. It still
307
+ # returns a finite "solution", just to the wrong system.
308
+ # The chunk decision is made from SHAPES ONLY and BEFORE anything large is
309
+ # built. An earlier version computed the full subchain first and then decided
310
+ # whether to chunk, so the unchunked allocation always happened anyway and the
311
+ # chunked branch saved nothing -- the memory-bounded path existed but could
312
+ # never take effect.
313
+ # Auto-size the chunk from a memory budget instead of comparing against a
314
+ # fixed number. A constant threshold silently does nothing when the mode
315
+ # happens to equal it -- which is exactly what let a 4-site fit try to
316
+ # materialise a 134 GB subchain (mode 64, chunk 64, "64 > 64" is false).
317
+ m_ = (k + 1) % n
318
+ other = 1
319
+ for j in range(n):
320
+ if j != k:
321
+ other *= dims[j]
322
+ per_unit = max(1, (other // max(1, dims[m_])) * rank_of(cores, k))
323
+ auto = max(1, min(dims[m_], int(chunk_elems // per_unit)))
324
+ use_chunk = auto < dims[m_]
325
+ if solver == "normal" and use_chunk:
326
+ # Memory-bounded normal equations. Materialising the subchain costs
327
+ # r_k * (prod_{j!=k} d_j) * r_{k-1} floats -- on down_proj at these ranks
328
+ # that is (155, 688, 256, 155) = 17 GB, so the unchunked path cannot run
329
+ # on the MLP layers at all. The Gram and right-hand side are sums over the
330
+ # flattened "others" index, so they accumulate exactly over slices of the
331
+ # first subchain mode; this is the same arithmetic, not an approximation.
332
+ m = m_
333
+ d_next = dims[m]
334
+ rk_, rkm1_ = None, None
335
+ AtA = AtW = None
336
+ Rp = R.permute(*([k] + [(k + 1 + t) % n for t in range(n - 1)]))
337
+ for lo in range(0, d_next, auto):
338
+ hi = min(lo + auto, d_next)
339
+ Ec = _subchain_chunk(cores, k, lo, hi)
340
+ rk_, rkm1_ = Ec.shape[0], Ec.shape[-1]
341
+ Ec = Ec.reshape(rk_, -1, rkm1_)
342
+ Ac = Ec.permute(1, 2, 0).reshape(Ec.shape[1], rkm1_ * rk_)
343
+ Wc = Rp[:, lo:hi].reshape(dims[k], -1)
344
+ g = Ac.transpose(0, 1) @ Ac
345
+ h = Ac.transpose(0, 1) @ Wc.transpose(0, 1)
346
+ AtA = g if AtA is None else AtA + g
347
+ AtW = h if AtW is None else AtW + h
348
+ del Ec, Ac, Wc, g, h
349
+ lam = 1e-8 * float(AtA.diagonal().abs().mean().clamp_min(1e-30))
350
+ AtA_r = AtA + lam * torch.eye(AtA.shape[0], dtype=AtA.dtype,
351
+ device=AtA.device)
352
+ G = _solve_maybe_cpu(AtA_r, AtW)
353
+ del AtA_r
354
+ # ||R - A G||^2 = ||R||^2 - 2<AtW, G> + <G, AtA G>, all from quantities
355
+ # already formed. Computing it by contracting the whole ring instead would
356
+ # cost one full O(prod d_k) tensor per sweep per core.
357
+ sq = rnorm2 - 2.0 * float((AtW * G).sum()) + float((G * (AtA @ G)).sum())
358
+ last_sq = max(sq, 0.0)
359
+ rkm1, rk = rkm1_, rk_
360
+ else:
361
+ # ONE branch builds A/Wk and ONE consumes them. Splitting the decision
362
+ # across two separate if/else chains left the non-chunked solve reachable
363
+ # after the chunked path had run, where `A` is whatever the PREVIOUS core
364
+ # left behind -- a silent wrong answer rather than an error.
365
+ E = _subchain(cores, k) # (r_k, d_{k+1},..,d_{k-1}, r_{k-1})
366
+ rk, rkm1 = E.shape[0], E.shape[-1]
367
+ E = E.reshape(rk, -1, rkm1)
368
+ D = E.shape[1]
369
+ A = E.permute(1, 2, 0).reshape(D, rkm1 * rk)
370
+ perm = [k] + [(k + 1 + t) % n for t in range(n - 1)]
371
+ # R's remaining modes must be flattened in the subchain's CYCLIC order --
372
+ # movedim gives natural order, which for interior k solves the
373
+ # least-squares problem against mismatched columns and still returns a
374
+ # finite "solution", just to the wrong system.
375
+ Wk = R.permute(*perm).reshape(dims[k], D)
376
+ del E
377
+ if solver == "normal":
378
+ AtA = A.transpose(0, 1) @ A
379
+ lam = 1e-8 * float(AtA.diagonal().abs().mean().clamp_min(1e-30))
380
+ AtA += lam * torch.eye(AtA.shape[0], dtype=A.dtype, device=A.device)
381
+ G = _solve_maybe_cpu(AtA, A.transpose(0, 1) @ Wk.transpose(0, 1))
382
+ del AtA
383
+ else:
384
+ G = torch.linalg.lstsq(A, Wk.transpose(0, 1)).solution
385
+ last_sq = None
386
+ del A, Wk
387
+ cores[k] = (G.transpose(0, 1).reshape(dims[k], rkm1, rk)
388
+ .permute(1, 0, 2).contiguous())
389
+ err = (float(last_sq) ** 0.5 / rnorm if last_sq is not None
390
+ else float((contract_tr(cores, chunk=chunk) - R).norm() / rnorm))
391
+ ihist.append(round(err, 8))
392
+ if verbose:
393
+ print(f" TR-ALS[{init_name}] sweep {it+1}: rel err {err:.6f}", flush=True)
394
+ if err < best_err - 1e-12:
395
+ best, best_err, winner = [c.clone() for c in cores], err, init_name
396
+ if it and abs(ihist[-2] - err) < tol * max(err, 1e-12):
397
+ break
398
+ hists[init_name] = ihist
399
+ # The algebraic residual is a difference of nearly equal large numbers; at a relative
400
+ # error of ~1e-2 it has already lost most of its fp32 significance. It is fine for
401
+ # steering ALS but not for the number that gets reported, so the winner is re-measured
402
+ # once by explicit contraction.
403
+ best_err = tr_rel_err(best, R)
404
+ if best_err > base_err:
405
+ return tt_to_tr(tt), base_err, {"fell_back_to_tt": True, "tt_err": base_err,
406
+ "winner": "tt_init", "als_hist": hists}
407
+ return best, best_err, {"fell_back_to_tt": winner == "tt_init", "tt_err": base_err,
408
+ "winner": winner, "als_hist": hists}
409
+
410
+
411
+ # --------------------------------------------------------------------------- bookkeeping
412
+ def tt_params(dims, ranks) -> int:
413
+ r = [1] + [int(x) for x in ranks] + [1]
414
+ return sum(r[k] * dims[k] * r[k + 1] for k in range(len(dims)))
415
+
416
+
417
+ def tr_params(dims, ranks) -> int:
418
+ r = [int(x) for x in ranks]
419
+ n = len(dims)
420
+ return sum(r[k] * dims[k] * r[(k + 1) % n] for k in range(n))
421
+
422
+
423
+ def tt_rank_ceilings(dims):
424
+ """r_k <= min(prod_{j<=k} d_j, prod_{j>k} d_j): above that the truncation is vacuous."""
425
+ n, out = len(dims), []
426
+ for k in range(n - 1):
427
+ left = 1
428
+ for j in range(k + 1):
429
+ left *= dims[j]
430
+ right = 1
431
+ for j in range(k + 1, n):
432
+ right *= dims[j]
433
+ out.append(min(left, right))
434
+ return out
435
+
436
+
437
+ def ranks_for_budget(dims, budget: int, ring: bool, alpha: float = 1.0):
438
+ """Largest ranks fitting `budget`, shaped by r_k proportional to ceiling_k ** alpha.
439
+
440
+ Bisection rather than a closed form: the count is linear in each rank but bilinear in
441
+ adjacent pairs, and cyclic for a ring, so there is no single quadratic to solve.
442
+
443
+ ALPHA EXISTS BECAUSE EQUAL FILL FRACTION IS WRONG WHEN THE CEILINGS ARE LOPSIDED. For a
444
+ 4-site split of 4096 the ceilings are (64, 4096, 64): alpha=1 spends the budget on the
445
+ huge middle bond and returns ranks like (36, 2327, 36), strangling the two bonds that
446
+ actually gate information flow through the chain. alpha=0 gives every bond the same rank
447
+ instead. Neither is right a priori, so the caller is expected to MEASURE across alpha --
448
+ the reconstruction error, not the heuristic, decides.
449
+ """
450
+ ceil_tt = tt_rank_ceilings(dims)
451
+ ceils = ([min(ceil_tt)] + ceil_tt) if ring else ceil_tt
452
+ fn = tr_params if ring else tt_params
453
+ mx = max(ceils)
454
+ shape = [(c / mx) ** alpha for c in ceils]
455
+
456
+ def build(t):
457
+ return [max(1, min(int(round(t * mx * s)), c)) for s, c in zip(shape, ceils)]
458
+
459
+ lo, hi = 0.0, 1.0
460
+ if fn(dims, build(1.0)) <= budget:
461
+ return build(1.0)
462
+ for _ in range(60):
463
+ mid = (lo + hi) / 2
464
+ if fn(dims, build(mid)) > budget:
465
+ hi = mid
466
+ else:
467
+ lo = mid
468
+ r = build(lo)
469
+ while fn(dims, r) > budget and max(r) > 1:
470
+ r[r.index(max(r))] -= 1
471
+ return r
472
+
473
+
474
+ def balanced_split(n: int, k: int):
475
+ """Most balanced k-way factorisation of n with every factor >= 2 (spread-minimising)."""
476
+ best = None
477
+ def rec(rem, parts):
478
+ nonlocal best
479
+ if len(parts) == k - 1:
480
+ if rem >= 2:
481
+ cand = parts + [rem]
482
+ key = max(cand) / min(cand)
483
+ if best is None or key < best[0]:
484
+ best = (key, cand)
485
+ return
486
+ d = 2
487
+ while d * d <= rem or d <= rem:
488
+ if d > rem:
489
+ break
490
+ if rem % d == 0:
491
+ rec(rem // d, parts + [d])
492
+ d += 1
493
+ rec(n, [])
494
+ if best is None:
495
+ raise ValueError(f"no {k}-way factorisation of {n} with all factors >= 2")
496
+ return sorted(best[1])
497
+
498
+
499
+ def self_test(device: str = "cpu") -> None:
500
+ torch.manual_seed(0)
501
+ dt = torch.float64
502
+ for osp, isp in [((4, 3), (2, 5)), ((4, 3, 2), (2, 5, 3)), ((2, 3, 2, 2), (2, 2, 3, 2))]:
503
+ n = len(osp)
504
+ out = 1
505
+ inn = 1
506
+ for x in osp:
507
+ out *= x
508
+ for x in isp:
509
+ inn *= x
510
+ W = torch.randn(out, inn, dtype=dt, device=device)
511
+ R = rearrange_n(W, osp, isp)
512
+ dims = [o * i for o, i in zip(osp, isp)]
513
+ assert list(R.shape) == dims, (R.shape, dims)
514
+ assert torch.equal(inv_rearrange_n(R, osp, isp), W), f"{n}-site rearrange not invertible"
515
+ # index identity, not just the round trip: a wrong-but-self-inverse permutation would
516
+ # pass the round trip and scramble every weight silently
517
+ for _ in range(40):
518
+ oi = [torch.randint(0, x, (1,)).item() for x in osp]
519
+ ii = [torch.randint(0, x, (1,)).item() for x in isp]
520
+ ridx = tuple(oi[k] * isp[k] + ii[k] for k in range(n))
521
+ orow = 0
522
+ for k in range(n):
523
+ orow = orow * osp[k] + oi[k]
524
+ ocol = 0
525
+ for k in range(n):
526
+ ocol = ocol * isp[k] + ii[k]
527
+ assert R[ridx] == W[orow, ocol], f"{n}-site rearrange index map wrong"
528
+
529
+ ceils = tt_rank_ceilings(dims)
530
+ cores = fit_tt(R, ceils)
531
+ e = float((contract_tt(cores) - R).norm() / R.norm())
532
+ assert e < 1e-10, f"{n}-site full-rank TT is not exact ({e:.3e})"
533
+ assert tt_params(dims, ceils) == sum(c.numel() for c in cores), "tt_params disagrees"
534
+
535
+ if n >= 3:
536
+ trunc = [max(1, c // 2) for c in ceils]
537
+ cores = fit_tt(R, trunc)
538
+ assert tt_params(dims, trunc) == sum(c.numel() for c in cores)
539
+ for a in (0.0, 0.5, 1.0):
540
+ rr = ranks_for_budget(dims, tt_params(dims, trunc), ring=False, alpha=a)
541
+ assert tt_params(dims, rr) <= tt_params(dims, trunc), \
542
+ f"TT budget solver overran at alpha={a}"
543
+ # a ring at the SAME parameter budget must not do worse than the TT it starts from
544
+ budget = tt_params(dims, trunc)
545
+ rring = ranks_for_budget(dims, budget, ring=True)
546
+ assert tr_params(dims, rring) <= budget, "TR budget solver overran"
547
+ _c, err_tr, info = fit_tr(R, rring, sweeps=6)
548
+ assert err_tr <= info["tt_err"] + 1e-12, "fit_tr returned worse than its own TT init"
549
+ assert abs(float((contract_tr(_c) - R).norm() / R.norm()) - err_tr) < 1e-8, \
550
+ "reported TR error does not match the returned cores"
551
+
552
+ ring_chk = [torch.randn(3, 5, 4, dtype=dt), torch.randn(4, 6, 2, dtype=dt),
553
+ torch.randn(2, 7, 3, dtype=dt)]
554
+ full = contract_tr(ring_chk)
555
+ assert torch.allclose(full, contract_tr(ring_chk, chunk=2), atol=1e-12), \
556
+ "chunked contract_tr disagrees with the unchunked one"
557
+ ref = torch.einsum("iaj,jbk,kci->abc", *ring_chk)
558
+ assert torch.allclose(full, ref, atol=1e-12), "contract_tr is not the cyclic trace"
559
+
560
+ # a ring with r_0 > 1 must be able to represent things a TT of the same internal ranks cannot
561
+ dims = [6, 6, 6]
562
+ torch.manual_seed(1)
563
+ ring = [torch.randn(3, 6, 3, dtype=dt) for _ in range(3)]
564
+ Rr = contract_tr(ring)
565
+ tt_same = fit_tt(Rr, [3, 3])
566
+ e_tt = float((contract_tt(tt_same) - Rr).norm() / Rr.norm())
567
+ assert e_tt > 1e-3, ("a rank-(3,3,3) ring was reproduced by a rank-(3,3) TT; the ring "
568
+ f"contraction is probably not closing the loop (err {e_tt:.3e})")
569
+ for slv in ("lstsq", "normal"):
570
+ got, err, info = fit_tr(Rr, [3, 3, 3], sweeps=200, restarts=3, solver=slv)
571
+ assert err < 1e-4, (f"TR-ALS[{slv}] failed to recover an exact rank-3 ring "
572
+ f"(err {err:.3e}); winner={info['winner']} "
573
+ f"tt_init={info['tt_err']:.3e}")
574
+ got, err, info = fit_tr(Rr, [3, 3, 3], sweeps=200, restarts=3)
575
+ # chunking changes only how the Gram is accumulated, so it must reach the same optimum
576
+ _g, err_ch, _i = fit_tr(Rr, [3, 3, 3], sweeps=200, restarts=3, solver="normal", chunk=2)
577
+ assert err_ch < 1e-4, f"chunked TR-ALS failed to recover an exact ring (err {err_ch:.3e})"
578
+ assert err < 1e-4, (f"TR-ALS failed to recover an exact rank-3 ring (err {err:.3e}); "
579
+ f"winner={info['winner']} tt_init={info['tt_err']:.3e}")
580
+
581
+ assert balanced_split(4096, 3) == [16, 16, 16], balanced_split(4096, 3)
582
+ assert balanced_split(4096, 2) == [64, 64]
583
+ b = balanced_split(11008, 3)
584
+ assert 43 in b and b[0] * b[1] * b[2] == 11008, b
585
+ print("tensor_formats self-test PASSED (N-site rearrange exact & index-checked, full-rank "
586
+ "TT exact, budget solvers within budget, TR >= its TT init, TR strictly beats TT on "
587
+ "a true ring, balanced splits correct)")
588
+
589
+
590
+ if __name__ == "__main__":
591
+ self_test()
592
+
593
+
594
+ # --------------------------------------------------------------------------- layers
595
+ class TRLinear(torch.nn.Module):
596
+ """Tensor-ring linear layer: W = Tr[G_0 G_1 ... G_{N-1}] after the shared rearrangement.
597
+
598
+ NOT a LowRankLinear subclass, unlike MPO2Linear/MPO3Linear. Those two could pretend to be
599
+ a pair of factors because a train has open ends; a ring genuinely has N 3-D cores and no
600
+ A/B roles to borrow, so anything keyed on `.A`/`.B` (the blockwise quantizer, the bond
601
+ rebalancer) has to be taught about `.cores` explicitly rather than silently doing the
602
+ wrong thing to a subclass it thinks it understands.
603
+
604
+ RECONSTRUCTION IS CHUNKED BY DEFAULT. Merging any two cores of a 3-site ring costs
605
+ r*d_i*d_j*r entries -- on down_proj at ring rank 142 that is 7.1 GB in bf16, per layer,
606
+ per forward. Chunking the first mode bounds it; the cost is more, smaller matmuls.
607
+ """
608
+
609
+ def __init__(self, cores, osplit, isplit, chunk_elems: int = 500_000_000,
610
+ bias=None):
611
+ super().__init__()
612
+ n = len(cores)
613
+ if len(osplit) != n or len(isplit) != n:
614
+ raise ValueError(f"{n} cores need {n} output and {n} input modes")
615
+ if min(list(osplit) + list(isplit)) < 2:
616
+ raise ValueError(f"degenerate split o={tuple(osplit)} i={tuple(isplit)}: a mode of "
617
+ f"1 collapses a site, making this an (N-1)-site network")
618
+ for k in range(n):
619
+ if cores[k].shape[1] != osplit[k] * isplit[k]:
620
+ raise ValueError(f"core {k} has mode {cores[k].shape[1]}, expected "
621
+ f"{osplit[k]}*{isplit[k]}")
622
+ if cores[k].shape[2] != cores[(k + 1) % n].shape[0]:
623
+ raise ValueError(f"ring does not close between cores {k} and {(k+1)%n}")
624
+ self.cores = torch.nn.ParameterList([torch.nn.Parameter(c) for c in cores])
625
+ self.osplit, self.isplit = tuple(int(x) for x in osplit), tuple(int(x) for x in isplit)
626
+ o = i = 1
627
+ for k in range(n):
628
+ o *= self.osplit[k]
629
+ i *= self.isplit[k]
630
+ self.out_features, self.in_features = o, i
631
+ self.ranks = tuple(int(c.shape[0]) for c in cores)
632
+ # Elements per unit of the FIRST mode in contract_tr's peak intermediate, which is
633
+ # (r_0, c, d_1, ..., d_{n-2}, r_{n-1}) -- the LAST core's mode is contracted away by
634
+ # the closing einsum and must not be counted. Including it over-estimated the cost by
635
+ # d_{n-1} (256x on an MLP layer), which drove the chunk to 1 and would have made every
636
+ # MLP forward 256 sequential contractions instead of 8.
637
+ mid = 1
638
+ for k in range(1, n - 1):
639
+ mid *= cores[k].shape[1]
640
+ per_unit = max(1, mid * self.ranks[0] * self.ranks[-1])
641
+ self._chunk = max(1, min(cores[0].shape[1], int(chunk_elems // per_unit)))
642
+ # non-persistent: never wants to show up in a checkpoint, and materialize_() is an
643
+ # inference-only opt-in (see below), so a freshly-loaded layer must start uncached.
644
+ self.register_buffer("_cached_weight", None, persistent=False)
645
+ # LLaMA projections have no bias, but Qwen2's q/k/v do. Dropping it on
646
+ # replacement is SILENT -- the shapes still match, the parameter-count gate
647
+ # still balances (achieved excludes bias, and so would the rebuilt model), and
648
+ # only the accuracy moves. So the bias is carried, not discarded.
649
+ self.bias = None if bias is None else torch.nn.Parameter(bias)
650
+
651
+ def weight(self) -> torch.Tensor:
652
+ if self._cached_weight is not None:
653
+ return self._cached_weight
654
+ cs = list(self.cores)
655
+ R = contract_tr(cs, chunk=self._chunk if self._chunk < cs[0].shape[1] else 0)
656
+ return inv_rearrange_n(R, self.osplit, self.isplit)
657
+
658
+ @torch.no_grad()
659
+ def materialize_(self):
660
+ """Cache the dense weight once. INFERENCE ONLY -- gradients cannot flow through the
661
+ cached tensor back to the cores, so this must never be called before/during training.
662
+
663
+ forward() calls weight() on every call, which redoes the full chunked ring
664
+ contraction (two tensor contractions + a trace per core) from scratch. That is fine
665
+ for a single forward pass, but autoregressive generation calls forward() once per
666
+ layer per GENERATED TOKEN with the cores completely unchanged between calls. Measured
667
+ on tr3_ring4b04_h1's GSM8K eval before this existed: 209s/item, ETA 73h for 1319
668
+ items -- every one of ~200 generated tokens re-contracted all 224 ring layers.
669
+ A train layer (LowRankLinear/MPO2Linear) never had this problem: F.linear(F.linear(x,
670
+ B), A) applies the two factors straight to the activation and never materializes W at
671
+ all, so there was nothing to cache there.
672
+ """
673
+ self._cached_weight = self.weight().detach().clone()
674
+
675
+ def unmaterialize_(self):
676
+ self._cached_weight = None
677
+
678
+ def forward(self, x):
679
+ b = None if self.bias is None else self.bias.to(x.dtype)
680
+ return torch.nn.functional.linear(x, self.weight().to(x.dtype), b)
681
+
682
+ def extra_repr(self) -> str:
683
+ return (f"o={self.osplit}, i={self.isplit}, ranks={self.ranks}, "
684
+ f"chunk={self._chunk}, materialized={self._cached_weight is not None}")
685
+
686
+
687
+ class SparseAugTRLinear(TRLinear):
688
+ """TRLinear plus a sparse additive residual. Same int64-index contract as the MPO layers:
689
+ `sp_idx` must NEVER be cast to a floating dtype -- a `.half()` on a 45M-scale index
690
+ silently rounds it to a different, valid-looking position."""
691
+
692
+ def __init__(self, cores, osplit, isplit, sp_idx, sp_val,
693
+ chunk_elems: int = 500_000_000, bias=None):
694
+ super().__init__(cores, osplit, isplit, chunk_elems, bias)
695
+ if sp_idx.numel() != sp_val.numel():
696
+ raise ValueError(f"{sp_idx.numel()} indices for {sp_val.numel()} values")
697
+ n = self.out_features * self.in_features
698
+ if sp_idx.numel() and (int(sp_idx.max()) >= n or int(sp_idx.min()) < 0):
699
+ raise ValueError("sparse index out of range for this layer")
700
+ self.register_buffer("sp_idx", sp_idx.to(torch.int64))
701
+ self.sp_val = torch.nn.Parameter(sp_val)
702
+
703
+ def weight(self) -> torch.Tensor:
704
+ # must check the cache here too, not just in TRLinear.weight(): materialize_() caches
705
+ # THIS class's weight() (ring + sparse, since self.weight() dispatches polymorphically
706
+ # from inside materialize_()), so without this check a later call would go
707
+ # super().weight() -> see the cache -> return the already-sparse-augmented tensor ->
708
+ # and then add the sparse residual a second time on top of it.
709
+ if self._cached_weight is not None:
710
+ return self._cached_weight
711
+ W = super().weight()
712
+ if self.sp_idx.numel():
713
+ W = W.reshape(-1).index_add(0, self.sp_idx, self.sp_val.to(W.dtype)).view_as(W)
714
+ return W
715
+
716
+ def extra_repr(self) -> str:
717
+ return super().extra_repr() + f", nnz={self.sp_idx.numel()}"
718
+
719
+
720
+ def layer_self_test() -> None:
721
+ torch.manual_seed(0)
722
+ osp, isp = (4, 3, 2), (2, 5, 3)
723
+ dims = [o * i for o, i in zip(osp, isp)]
724
+ cores = [torch.randn(3, dims[0], 4), torch.randn(4, dims[1], 2), torch.randn(2, dims[2], 3)]
725
+ lay = TRLinear([c.clone() for c in cores], osp, isp, chunk_elems=10 ** 9)
726
+ ref = inv_rearrange_n(contract_tr(cores), osp, isp)
727
+ assert torch.allclose(lay.weight(), ref, atol=1e-5), "TRLinear.weight() != contract+inverse"
728
+ # chunking must not change the answer
729
+ lay_c = TRLinear([c.clone() for c in cores], osp, isp, chunk_elems=1)
730
+ assert lay_c._chunk < dims[0], "chunk did not engage"
731
+ assert torch.allclose(lay_c.weight(), ref, atol=1e-5), "chunked forward differs"
732
+ x = torch.randn(7, lay.in_features)
733
+ assert torch.allclose(lay(x), x @ ref.T, atol=1e-4), "forward != x W^T"
734
+ idx = torch.tensor([0, 5, lay.out_features * lay.in_features - 1], dtype=torch.int64)
735
+ val = torch.tensor([1.0, -2.0, 3.0])
736
+ sl = SparseAugTRLinear([c.clone() for c in cores], osp, isp, idx, val, chunk_elems=10 ** 9)
737
+ d = (sl.weight() - ref).reshape(-1)
738
+ assert torch.allclose(d[idx], val, atol=1e-5), "sparse residual not applied at sp_idx"
739
+ assert abs(float(d.abs().sum()) - float(val.abs().sum())) < 1e-4, "residual leaked"
740
+ try:
741
+ TRLinear([torch.zeros(2, 4, 2), torch.zeros(2, 3, 2), torch.zeros(2, 6, 2)],
742
+ (4, 1, 2), (1, 3, 3))
743
+ except ValueError:
744
+ pass
745
+ else:
746
+ raise AssertionError("a degenerate split (mode = 1) was accepted")
747
+ # gradients must reach every core
748
+ lay.zero_grad()
749
+ lay(x).sum().backward()
750
+ for k, c in enumerate(lay.cores):
751
+ assert c.grad is not None and float(c.grad.abs().sum()) > 0, f"core {k} got no gradient"
752
+ # materialize_() must be a pure inference-speed cache: same weight, same forward output,
753
+ # and -- the one bug this is actually guarding against -- the sparse residual must not
754
+ # get added twice once SparseAugTRLinear.weight() is short-circuited by the cache.
755
+ pre = lay.weight().clone()
756
+ lay.materialize_()
757
+ assert lay._cached_weight is not None, "materialize_() did not populate the cache"
758
+ assert torch.allclose(lay.weight(), pre, atol=1e-6), "materialize_() changed weight()"
759
+ assert torch.allclose(lay(x), x @ ref.T, atol=1e-4), "forward != x W^T after materialize_()"
760
+ lay.unmaterialize_()
761
+ assert lay._cached_weight is None, "unmaterialize_() left the cache populated"
762
+ sl_ref = sl.weight().clone()
763
+ sl.materialize_()
764
+ assert torch.allclose(sl.weight(), sl_ref, atol=1e-6), \
765
+ "materialized SparseAugTRLinear.weight() differs -- sparse residual likely double-added"
766
+ print("TRLinear self-test PASSED (weight matches contraction, chunking exact, forward "
767
+ "correct, sparse localised, degenerate split refused, all cores differentiable, "
768
+ "materialize_() cache exact and idempotent)")