Owner of this document: the black-box side of ttpy2 — tt/algs/cross.py,
tt/algs/maxvol.py, and everything that goes through them
(tt/algs/multifuncrs.py, tt/algs/qtt_ell.py::invert/sqrt,
tt/algs/completion.py's index bookkeeping). It owns no mathematics that
docs/plans/ROADMAP.md has already assigned elsewhere; where it touches the
rounding tolerance or the history contract it defers to docs/REQUIREMENTS.md
R4/R7.
Sources actually read.
| what | where | state |
|---|---|---|
| ttpy2 | tt/algs/cross.py (583 lines), tt/algs/maxvol.py (644), tt/algs/_indexing.py (220), tt/algs/multifuncrs.py (644) |
read in full, run |
| ttcross | github.com/savostyanov/ttcross @ 9254e82 (2024-02-10): dmrgg.f90 (936), lr.f90, ttind.f90, rnd.f90, main.f90, README.md |
read in full; built and run on an M-series Mac (gfortran 15 + Accelerate + a single-process MPI stub; §2.1a) — b300 still has no toolchain. Measured numbers in §2.1a. |
| teneva | github.com/AndreiChertkov/teneva @ 5be12bc (v0.14.11): maxvol.py (137), cross.py (287), cross_act.py (277), utils.py, core.py |
read in full; installed and run on b300 (uv pip install teneva, 0.14.11, numpy 2.5.1) |
| legacy ttpy | ~/work/ttpy-modern/ttpy-src/tt/cross/rectcross/rect_cross.py |
read and run (micromamba ttlegacy, numpy 1.24.4) |
Ground rule for numbers. Every number below was produced by a script in
b300:~/work/ttpy-modern/scratch-cross/ (e1.py … e13.py, §9) run with
~/work/ttpy-modern/ttpy2/.venv/bin/python (numpy 2.4.6, scipy 1.18.0) or, for
the teneva half, with scratch-cross/tvenv/bin/python (numpy 2.5.1, teneva
0.14.11, ttpy2 on PYTHONPATH). Nothing was run locally. Anything not measured
says "not measured".
Baseline. pytest tests/ -q -n 8 on b300: 801 passed in 59.40 s. That is
what every change below must leave green.
-
Our
maxvol/rect_maxvolare better than teneva's on every axis measured — same pivots, same volume, sameK, same row-norm bound, but 3.0× faster for square maxvol and 18.0× faster for rectangular maxvol at$N = 100000, r = 200$ , and they raise where teneva silently returns a matrix full ofNaN. Do not port teneva's maxvol. Port nothing from it. (§3.1, §4.1) -
Our cross throws away 60–76 % of its function evaluations by re-asking for
entries it already has. teneva's
cache(cross.py::_func_eval) removes exactly that. Measured reduction in distinct evaluations: 2.5× (smoothd=5), 3.1× (QTTd=20), 4.2× (kinkd=6). It costs ~1.25 µs per probe in the prototype, so it is a loss for a black box cheaper than that and a 2.2× wall-clock win for one at 50 µs/point. Must be opt-in. (§4.3, §5 P1) -
tt.crossreturns a confidently wrong answer on a two-line function, with every indicator we own reading ~1e-15.$f(i) = 1/(10^{-2} + |\sum_k i_k/9 - 5/2|)$ ,d = 6,n = 10,eps = 1e-8: true relative error 3.82e-04,converged=True,err_rel=2.9e-15,err_round=3.5e-15,err_checkon 4000 held-out points3.7e-15, no warning. It hits 5 of 6 initial-guess seeds under numpy 2.4.6 and 4 of 6 under numpy 2.5.1 — which seed fails is roundoff. The legacy Fortran-eratt.cross.rectcrossdoes the same (3.82e-04, 1.81e-06 on two of four seeds) and so does teneva (3.82e-04 on one of six seeds). This is a defect of the whole rect-maxvol cross family, not a ttpy2 regression. (§1.3) -
The cure is the one thing
multifuncrs.py's docstring currently says we reject: a handful of uniformly random extra rows per micro-step (kickrank2in the legacymultifuncrs2,dr2inteneva.cross_act).kickrank2 = 2fixes 6 of 6 seeds on that problem (3.82e-04 → 2.84e-10) for 1.6–2.5× more distinct evaluations. The docstring's rejection is a projection of the concept that now lies and must be updated with the code. (§1.3, §5 P2) -
rect_maxvol stopped at K = …is a false alarm and accounts for 80 of the 93RuntimeWarnings the test suite emits. Measured cost of the early stop: zero — identical error, identical ranks, on three problems acrossrf ∈ {0,2,5,10,30}. It iscross._select_rowsasking for a budgetedKand then being told off for respecting the budget. Silence it at that call site, exactly asmaxvol(warn_nonconvergence=False)already does. (§1.2, §5 P3) -
From ttcross, port the error indicator, not the algorithm. ttcross's
greedy DMRG cross is a different animal (rank +1 per bond per sweep, MPI over
bonds, rook pivoting on the residual), and porting it wholesale is a second
engine we would have to keep correct. What is cheap and independently valuable
is its stopping rule —
pivotmax <= accuracy * amaxsustained for three consecutive sweeps (strike >= 3,dmrgg.f90:661-665) — and itsdtt_accchk, which reports the held-out error in both the ∞-norm and the Frobenius norm. (§3.2, §5 P4, §5 P5) -
Negative, and measured: the post-hoc rook/alternating-maximisation
residual probe does not catch the §1.3 failure (0/50 at 120 evaluations
vs 2/50 for uniform Monte Carlo at the same budget), and the "one-sweep
collapse of
err_rel" signature is not specific (a tensor of exact TT rank 3 collapses by 4.8e+14 in one sweep, correctly). Both were prototyped and both are rejected. (§5, "what not to take")
One-site alternating cross with rectangular-maxvol row selection.
-
_init_right_indices(cross.py:259) orthogonalisesx0right-to-left and runs square maxvol on each unfolding to get the starting right setsJ_k. -
_sweep_lr(cross.py:282) walksk = 0 .. d-2. It asksfunfor the whole block$I_k \times n_k \times J_k$ ($r_1 \cdot n_k \cdot r_2$ values), takes an SVD basis of the$(r_1 n_k) \times r_2$ unfolding —_left_basis, never truncated, deliberately, see its docstring — and calls_select_rows, which runsrect_maxvol(q, tau, maxK = min(npts, rho + kickrank + rf, max(rmax, rho)), min_add_K = kickrank). The chosen rows extendI_{k+1}. The values themselves are discarded. -
_sweep_rl(cross.py:296) walks back, asks for the same shaped blocks, and this time also builds the cores fromC = Q pinv(Q[ind])(_interp). - Stop:
||y − y_prev|| <= max(eps ||y||, eps_abs). Thenround(eps).
Cost per sweep: kickrank + rf, and capped from above by
the other side's set size, because rho = min(q.shape[1], rmax) = min(r1 n_k, r2).
Public entry points: rect_cross, cross, greedy_cross (an alias for
rect_cross), element, CrossHistory.
It fires 80 times in the 801-test suite (86 % of all RuntimeWarnings), always
in the same shape:
tt/algs/cross.py:232: RuntimeWarning: rect_maxvol stopped at K = 29 (limit maxK = 29)
with a remaining candidate row norm 1.17167 > tol = 1.1.
Where it comes from. _select_rows computes an explicit budget
max_k = min(npts, rho + kickrank + rf, max(rmax, rho)) and passes it as maxK.
rect_maxvol then stops because K == maxK, sets stop_reason = "maxK",
converged = False, and warns (maxvol.py:510-520). The budget is the caller's
deliberate choice; the warning is rect_maxvol complaining about being obeyed.
What the early stop costs, measured (E1a). Re-running each captured
rect_maxvol call from multifuncrs2([x, y], x/(1+y²)) with the budget removed
(N = [10,12,14,9,11], eps = 1e-8, float64, b300):
| basis shape | K with budget |
max‖C_i‖ | K free |
max‖C_i‖ free |
|---|---|---|---|---|
| (378, 22) | 29 | 1.1240 | 30 | 1.0896 |
| (99, 29) | 36 | 1.2445 | 39 | 1.0764 |
| (504, 27) | 34 | 1.2088 | 37 | 1.0988 |
3 of 20 calls stop early; 1–3 extra rows would have satisfied tau = 1.1; the
interpolation constant is 1.24 instead of 1.10.
What it costs end to end, measured (E1b). Sweeping rf (the slack that
decides whether the warning fires at all), float64, b300, kickrank=5:
| problem | rf |
warnings | error vs dense | ranks | fun_eval |
|---|---|---|---|---|---|
sin(s)/(1+s), d=5, n=12, eps=1e-10 |
0 / 2 / 5 / 10 / 30 | 2 / 1 / 0 / 0 / 0 | 5.861e-11 (all five) | [1,8,8,8,8,1] (all five) |
54720 … 55764 |
QTT 1/(1+t), d=20, eps=1e-10 |
0 / 2 / 5 / 10 / 30 | 12 / 5 / 0 / 0 / 0 | err_check 3.471e-11 (all five) |
max rank 5 (all five) | 18282 … 19262 |
| kink `1/(1e-2+ | s−5/2 | ), d=6, n=10, eps=1e-8` |
0 / 2 / 5 / 10 / 30 | 3 / 0 / 0 / 0 / 0 | 2.843e-10 (all five) |
Conclusion. The accuracy cost is zero to every digit printed; the only cost
of silencing it by raising rf is 1.5–5.4 % more function evaluations. So the
warning is cosmetic — and that is precisely what makes it a defect: it is 86 % of
the warning traffic on a channel whose entire job is to be believed when
tt cross: or maxvol did not converge really does fire. Warning fatigue on the
loud-failure channel is a violation of the "unexpected states fail loud" contract
in the other direction. Fix in §5 P3.
Reproducer (b300, .venv/bin/python, numpy 2.4.6, float64):
import numpy as np, tt
from tt.algs.cross import cross
d, n = 6, 10
f = lambda I: 1.0 / (1e-2 + np.abs(I.sum(1) / 9.0 - 2.5))
y = cross(f, n, d, eps=1e-8, r=2, seed=0, kickrank=2, n_check=4000, check_seed=7)
# no warning is emittedThe dense oracle is np.meshgrid-built and independent of TT:
10**6 grid.
| what the run reports | value | the truth |
|---|---|---|
history.converged |
True |
— |
history.err_rel (change between the last two sweeps) |
2.9e-15 | — |
history.err_round (exact, measured) |
3.5e-15 | — |
history.err_check on 4000 held-out points |
3.7e-15 | — |
| warnings emitted | none | — |
| relative Frobenius error against the dense oracle | — | 3.82e-04 |
| relative ∞-norm error against the dense oracle | — | 9.24e-03 |
Every number the API can produce is ~1e-15; the answer is 3.8e-4 wrong at a
requested eps of 1e-8.
How reproducible. kickrank=1, rf=2, eps=1e-8, seeds 0–5 of the random
x0, numpy 2.4.6: seed 0 gives 2.84e-10 (correct); seeds 1, 2, 3, 4, 5 all
give 3.82e-04, all with converged=True and err_rel between 2.3e-15 and
3.1e-15. At seed=0, kickrank ∈ {2, 3} gives 3.82e-04 and
kickrank ∈ {1, 5, 8} gives 2.84e-10 — i.e. which side of the cliff you land on
is not a monotone function of the exploration budget.
And it is not reproducible across numpy versions (E14 — the same script under
both interpreters, kickrank=1, spelling of n and wrapping of fun held
invariant, which changes nothing):
| seed | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
numpy 2.4.6 (.venv) |
2.84e-10 | 3.82e-04 | 3.82e-04 | 3.82e-04 | 3.82e-04 | 3.82e-04 |
fun_eval |
704650 | 394930 | 396910 | 230930 | 283610 | 270840 |
numpy 2.5.1 (tvenv) |
2.84e-10 | 3.84e-10 | 3.82e-04 | 3.82e-04 | 3.82e-04 | 3.82e-04 |
fun_eval |
572170 | 622400 | 316950 | 367180 | 518020 | 295170 |
Seed 1 lands on the other side of the cliff, and the evaluation counts differ by up to 1.8× on runs that reach the same answer. Nothing in the code is non-deterministic; the divergence is roundoff in the LAPACK the two numpy builds call, amplified by a maxvol pivot choice that then changes every subsequent index set. A test written against a specific seed of this problem is a test of a coin flip — which is why T3 in §7 asserts on a majority of seeds and T4 asserts a disjunction rather than a value.
Mechanism. Per-sweep history at seed=1, kickrank=1:
swp 0 err_rel=3.84e+00 max_rank= 4 swp 7 err_rel=2.98e-01 max_rank=21
swp 1 err_rel=8.76e-01 max_rank= 6 swp 8 err_rel=1.73e-01 max_rank=26
swp 2 err_rel=7.71e-01 max_rank= 8 swp 9 err_rel=1.34e-01 max_rank=32
swp 3 err_rel=7.61e-01 max_rank=10 swp 10 err_rel=9.43e-02 max_rank=38
swp 4 err_rel=6.74e-01 max_rank=12 swp 11 err_rel=5.31e-02 max_rank=44
swp 5 err_rel=5.19e-01 max_rank=15 swp 12 err_rel=1.51e-02 max_rank=50
swp 6 err_rel=3.47e-01 max_rank=17 swp 13 err_rel=3.06e-15 max_rank=56
The index sets reach a fixed point: at sweep 13 the cross interpolant on the
current sets reproduces itself exactly, so err_rel collapses by 13 orders of
magnitude in one sweep. The interpolant is then rounded from rank 56 to rank 24 —
round(eps) correctly reports that rank 24 represents the interpolant to
1e-15. Nothing in that chain ever looks at the function outside the sampled
fibers, so nothing sees that the interpolant is 3.8e-4 from it.
Where the error lives (E3b, seed 1). 99 % of the squared error sits on 394
of the 1000000 entries (0.039 %); 50 % on 145 entries. The worst entry is the
grid corner (9,9,9,9,9,9), where f = 0.2849 and the interpolant gives
0.14396. It is not a spike in the function — f is perfectly smooth there —
it is a region the index sets never reached, because the maxvol pivots all sit
near the peak at
Why err_check missed it. With 394 bad entries out of 10**6, a uniform
sample of 10*eps = 1e-7
(E11):
n_check |
200 | 500 | 1000 | 2000 | 4000 | 10000 |
|---|---|---|---|---|---|---|
| detects (Frobenius) | 7.5 % | 21.5 % | 36.5 % | 56.0 % | 77.5 % | 97.0 % |
| detects (∞-norm) | 7.5 % | 21.5 % | 36.5 % | 56.0 % | 77.5 % | 97.0 % |
| median reported (Frobenius) | 5.3e-15 | 5.4e-15 | 5.4e-15 | 3.6e-04 | 3.6e-04 | 3.8e-04 |
| median reported (∞-norm) | 6.0e-15 | 7.0e-15 | 7.8e-15 | 5.0e-03 | 6.5e-03 | 8.1e-03 |
Two honest readings of that table. (a) n_check is not a safety net at its
natural sizes: at 1000 points it is a coin-flip that lands on "everything is
fine" 63 % of the time. (b) The ∞-norm buys no extra detection — the
detection event is "did the sample hit the bad region at all", and once it does,
both norms are far above the threshold. What the ∞-norm buys is a 24× larger
number once detected, which is a better description of the damage, not a better
detector. Do not oversell it (§5 P5).
This is inherited, not ours. The legacy Fortran-era
tt.cross.rectcross.cross (numpy 1.24.4, ~/work/ttpy-modern/ttpy-src), same
problem, kickrank=1, eps=1e-8:
| seed | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| relative error | 3.82e-04 | 8.19e-10 | 2.84e-10 | 1.81e-06 |
| max rank | 24 | 26 | 26 | 25 |
and teneva (v0.14.11, dr_min=1, dr_max=2, e=1e-8, then truncate(1e-8))
gives 3.82e-04 on one of six seeds (§4.4). Same fixed point, same number.
It is a property of maxvol-driven rank-adaptive cross on this function.
What fixes it. Uniformly random extra rows per micro-step. Prototype (E5):
patch _select_rows to append kickrank2 rows drawn without replacement from
the not-yet-selected rows. kickrank=1, eps=1e-8, seeds 0–5:
| seed | k2 = 0 |
k2 = 2 |
k2 = 5 |
k2 = 10 |
|---|---|---|---|---|
| 0 | 2.84e-10 | 2.84e-10 | 2.84e-10 | 2.84e-10 |
| 1 | 3.82e-04 | 2.84e-10 | 2.84e-10 | 2.84e-10 |
| 2 | 3.82e-04 | 2.84e-10 | 2.84e-10 | 2.84e-10 |
| 3 | 3.82e-04 | 2.84e-10 | 2.84e-10 | 2.84e-10 |
| 4 | 3.82e-04 | 2.84e-10 | 2.84e-10 | 2.84e-10 |
| 5 | 3.82e-04 | 2.84e-10 | 2.84e-10 | 2.84e-10 |
6/6 correct at k2 = 2; the remaining 2.84e-10 is the accuracy of the rounded
interpolant, not of the sampling. Cost, at k2 = 2 with the cache on (E10):
176832–229063 distinct evaluations against 88641–162106 without — 1.4–2.5×.
tt/algs/multifuncrs.py's module docstring currently states, as a design
decision, that "kickrank2 (extra random rows) has no counterpart and is
rejected". That sentence is now contradicted by a measurement and is a projection
of the concept that lies; it must change in the same commit as the code (R7 /
IMMUNE-M).
-
maxvol/rect_maxvolthemselves: fastest of the three implementations compared, with the strongest safeguards (§4.1). - The
_left_basisnon-truncation decision and its docstring: the 12 %-error fixed point it describes is real and the reasoning is right. -
_evaluate's refusal of non-vectorizedfun, of a wrong-length return and of non-finite values;element's refusal of negative indices;_host_matrix's refusal ofNaN. teneva has none of these and pays for it (§4.1 C). -
CrossHistoryas a concept (R7). What is missing from it is listed in §6. - The "no cross method can certify its own accuracy" note at
cross.py:426. §1.3 is the sharper version of that note: the blind spot is not only spikes, it is any region the pivots avoid because$|f|$ is small there.
dtt_dmrgg (dmrgg.f90:10-691) is a greedy DMRG cross, i.e. the algorithm
of Savostyanov, Quasioptimality of maximum-volume cross interpolation of
tensors, LAA 2014, in the parallel form of Dolgov & Savostyanov,
arXiv:1903.11554. It is not our algorithm with different constants; it is a
different construction.
State. For each bond p it stores vip(p)%p(4, r_p) — for every rank index,
the quadruple (i, j, k, q) = (index into the left set at p−1, mode-p index,
mode-p+1 index, index into the right set at p+1). A full multi-index is
reconstructed by walking that linked list (dmrgg_fun, dmrgg.f90:694-707).
The sets are therefore nested by construction, and the cross submatrix
A(I_p, J_p) is carried as a growing LU factorisation inv(p), bordered by
one row and one column per added pivot (dmrgg.f90:386-390) and applied by
d2_lual/d2_luar (lr.f90). No inverse is ever recomputed.
Rank adaptation. Exactly +1 per bond per sweep. Iteration it is the
rank. There is no SVD anywhere in the sweep.
Pivoting — the pivot is the argmax of the residual A − col·row over a
candidate set, and there are three regimes selected by pivpar:
pivoting |
candidate set | evaluations per bond per sweep |
|---|---|---|
-1 (full) |
the entire two-site superblock |
|
0 |
a random "lottery" of |
|
|
|
as 0, then alternate: maximise the residual along the column, then along the row, until a fixed point or |
The lottery is a weighted sample (lottery2, rnd.f90:105-126) whose weights are
1 for every (i,j) not already a pivot and 0 for those that are — i.e. uniform
over the not-yet-selected entries. Rook pivoting is dmrgg.f90:298-359.
Pivot rejection. A pivot is only accepted if
upd(p) = (abs(pivot) > small_element*amax) .and. (abs(pivot) > small_pivot*pivotmax_prev)(dmrgg.f90:369), with small_element = 10·eps and small_pivot = 1e-5 for
float64 (dmrgg.f90:53). amax is the largest pivotmax_prev the largest residual of the previous sweep. A tiny pivot is not
added — the rank does not grow rather than growing by a numerically meaningless
direction. We have no analogue: _left_basis takes the full numerical rank
unconditionally.
Error indicator and stopping rule. pivotmax = the largest |residual|
found this sweep, over all bonds. Stop when
if (pivotmax <= accuracy*amax) strike = strike + 1 else strike = 0
ready = ready .or. (strike >= 3)(dmrgg.f90:661-665). Three properties worth naming: it is a max-norm residual
on freshly probed entries, not a difference between iterates; it is normalised
by amax, the largest value ever seen, not by a norm of the iterate; and it must
hold for three consecutive sweeps, which is what makes a single lucky sweep
insufficient.
Accuracy check. dtt_accchk (dmrgg.f90:708-752) samples nlot uniform
random multi-indices and returns four numbers — einf, efro, ainf, afro — so the
caller reports both einf/ainf and efro/afro, and optionally the
multi-index of the worst entry (pivot argument). main.f90 uses nlot = 2**20.
Quantity of interest. The optional quad argument is a TT of quadrature
weights; the sweep loop then also reports the running value of |1 − val/val_prev| per sweep (dmrgg.f90:634-657). For the
Ising susceptibility integrals the code targets, the convergence of the
integral is the thing that matters, and it is monitored directly.
Parallelism. MPI over bonds (own(0:nproc)), OpenMP over the entries of a
fiber; ranks and pivots are broadcast by a "tape" of (i,j,k,q) quadruples after
every sweep. Requires nproc < d. Irrelevant to a pure-Python port and not
considered further.
Precision. The point of the repository is very high accuracy: it compiles at
-fdefault-real-8/16 and ships an MPFUN/MPFR multiple-precision variant
(dmrggmp.f90, mpblas.f90). We are float64/float32 and out of scope here.
The "not built" above stopped being true on a machine with a Fortran toolchain.
Build: gfortran 15 (brew) + Accelerate as LAPACK/BLAS + a ~100-line
single-process MPI stub (mpif.h with size-encoding datatype constants and a C
file implementing init/rank/size/barrier as no-ops, allreduce/reduce/bcast as
memcpy honouring MPI_IN_PLACE, send/recv as loud aborts — with one process the
neighbour exchanges are never reached, and the stub proves it by construction).
Three fixes to our stub header along the way (MPI_2DOUBLE_PRECISION,
MPI_MAXLOC, MPI_PROD); the ttcross sources themselves compiled untouched
with -fallow-argument-mismatch.
Protocol. test_crs_ising.exe KIND m 65 RANK 1 against ttpy2 rect_cross
on the identical discretized tensor: test_crs_ising.f90. Both at OMP_NUM_THREADS=4, ttcross
single-process, ttpy2 at default kickrank=1, rf=2, numpy/Accelerate float64.
| problem | engine | knob | digits | evaluations | wall |
|---|---|---|---|---|---|
| C_6 | ttcross | rank 5 | 5.55 | 8 205 | 5 ms |
| C_6 | ttpy2 | eps 1e-5 | 7.13 | 71 110 | 40 ms |
| C_6 | ttcross | rank 10 | 7.54 | 26 315 | 8 ms |
| C_6 | ttpy2 | eps 1e-7 | 9.22 | 454 935 | 183 ms |
| C_6 | ttcross | rank 20 | 10.75 | 89 001 | 20 ms |
| C_6 | ttcross | rank 40 | 10.90 | 89 133 | 33 ms |
| C_6 | ttpy2 | eps 1e-11 | 13.63 | 1 092 000 | 415 ms |
| C_16 | ttcross | rank 20 | 11.19 | 88 671 | 25 ms |
| C_16 | ttpy2 | eps 1e-11 | 13.55 | 3 649 490 | 1.8 s |
| D_6 | ttcross | rank 25 | 10.39 | 88 275 | 20 ms |
| D_6 | ttpy2 | eps 1e-11 | 13.19 | 1 232 140 | 519 ms |
| E_6 | ttcross | rank 20 | 11.25 | 87 087 | 23 ms |
| E_6 | ttpy2 | eps 1e-11 | 12.62 | 1 111 565 | 400 ms |
What the numbers say, in both directions.
- At equal accuracy ttcross spends 5–10x fewer function evaluations on
these integrands, and its Fortran core is another order of magnitude faster
per evaluation — 20 ms against 415 ms end-to-end at the ~11-digit level.
This settles §4.2's "neither is uniformly cheaper in evaluations" for the
Ising family: here the greedy +1-per-bond growth with rook pivoting on the
residual is uniformly cheaper. The mechanism is visible in the counts:
its evaluations saturate near 89k (the pivot-value stopping rule fires)
while ours keep scaling with the requested
eps. - ttcross saturates at ~11 digits regardless of rank (rank 40 buys 0.15
digits over rank 20; its
accuracy = 500*eps_machineand the three-strike pivot rule stop it there), whilerect_crosspushed the same tensors to 12.6–13.6 digits. The ceiling is the stopping rule's, not the method's — but as shipped, the last two digits belong to us. Dimension scaling favours the greedy growth— withdrawn, it was a lucky run. The C_16-at-89k row below is real but atypical: the reference's lottery is unseeded and its accuracy rule is always armed, so occasionally the pivots freeze and it strikes out early (~88k evaluations, 11.19 digits). Seven repeats measured later: every one ran the full 19 sweeps at ~379.5k evaluations in 89–118 ms — the same schedule as the port's. The greedy's per-sweep cost grows linearly indfor everyone.- The §5 verdict — port the indicator, not the engine — was overtaken by
this table: the 5–40x evaluation gap on smooth integrands was judged worth
a second engine, and the port was ordered and done the same day
(
tt/algs/dmrg_cross.py, 2026-08-09). The three-strike rule (P4), the two-norm held-out check (P5) and the pivot rejection (P6) ship inside it.
tt.dmrg_cross (tt/algs/dmrg_cross.py), same protocol, same machine, same
OMP_NUM_THREADS=4:
| problem | knob | Fortran ttcross | ttpy2 port |
|---|---|---|---|
| C_6 | rank 5 | 5.55 digits @ 8 205 | 4.94 @ 8 205 |
| C_6 | rank 10 | 7.54 @ 26 315 | 7.93 @ 26 315 |
| C_6 | rank 20 | 10.75 @ 89 001 | 11.17 @ 89 199 |
| C_6 | acc 1.1e-13 | 10.90 @ 89 133 (saturated) | 14.68 @ 288 631 |
| C_16 | rank 20 | 11.19 @ 88 671 | 14.05 @ 379 605 |
| D_6 | rank 25 | 10.39 @ 88 275 | 11.80 @ 130 079 |
| E_6 | rank 20 | 11.25 @ 87 087 | 12.02 @ 86 955 |
The evaluation counts at matched rank caps agree with the Fortran to the
evaluation on C_6 (8 205 and 26 315 exactly): the port reproduces the
original's evaluation schedule, not merely its idea. Two deliberate
differences show in the other rows. The ~11-digit ceiling of §2.1a turns out
to belong to the reference build, not the algorithm: with the same stopping
constant (their hard-coded 500*eps_machine) the port reaches 14.7 digits,
because the cross matrix is factorised by LAPACK LU with partial pivoting
instead of the original's unpivoted bordered LU. And on C_16 the port spends
4.3x more evaluations for 2.9 more digits — a different point on the same
frontier, traceable to argmax tie-breaking in the rook search. Wall time is
37–350 ms against the Fortran's 20–30 ms: pure Python overhead, same ratio as
everywhere else in the package.
Wall clock: the port is now faster than the Fortran per evaluation.
Three rounds of measured work got it there (each profiled, best-of-5 timings,
OMP_NUM_THREADS=4, both binaries on the same cores):
- vectorising the two Python-loop hotspots (lottery draw via cumsum +
searchsorted — which is what
lottery2does anyway — and the point-index assembly): 37 -> 26 ms on C_6; - raw LAPACK
getrf/getrsinstead of the scipy wrappers, whose per-callcheck_finitescans and Python overhead dwarfed the$r \times r$ solve; - the structural one: never form the full
$M^{-1} R$ . The port used to apply the interpolant by solving for the whole block once per bond visit,$O(r^2 n r_2)$ flops where the original's incremental factors pay$O(r n)$ per fiber; solving only for the one column, one row or one scattered batch actually needed removes the gap entirely. An explicit$M^{-1}$ cached per bond was tried first and reverted: multiplication by an inverse is not backward stable, the residual noise floor rises from$\varepsilon$ to$\mathrm{cond}(M) \varepsilon$ , and the pivot-acceptance threshold (calibrated for a backward-stable residual) starts accepting duplicate pivots, which makes$M$ exactly singular — 6 tests caught it.
With the example's integrand numba-compiled (the Fortran driver's integrand is compiled too — equal footing; the numpy fallback computes identical values to 1 ulp), best of 5 against the Fortran's best of 3:
Medians over 7 runs (Fortran: 7 repeats, unseeded lottery) / 7 lottery seeds
(port), identical stopping rules on both sides (rank cap 20, accuracy
500*eps_machine with three strikes -- the port run with exactly the
driver's configuration; every one of the port's 21 runs stopped at the rank
cap, as did 20 of the Fortran's 21):
| rank 20, medians [min..max] | evaluations | digits | wall |
|---|---|---|---|
| C_6 Fortran | 88 935 [88 869..89 067] | 11.14 [10.21..11.83] | 26.2 ms [22.7..31.3] |
| C_6 port, numpy | 89 001 [88 803..89 199] | 10.80 [10.29..11.17] | 22.6 ms [20.6..29.1] |
| C_6 port, compiled | same | same | 13.1 ms [11.2..] |
| E_6 Fortran | 87 087 [86 955..87 285] | 11.86 [11.02..12.40] | 22.8 ms [21.9..26.2] |
| E_6 port, numpy | 87 219 [86 955..87 483] | 11.59 [10.94..12.43] | 24.2 ms [21.9..25.7] |
| E_6 port, compiled | same | same | 13.0 ms [11.6..] |
| C_16 Fortran | 379 473 [..379 671] | 11.69 [11.43..12.12] | ≈94 ms [89..118] |
| C_16 port, numpy | 379 473 [379 209..379 605] | 11.68 [11.43..14.06] | 96.8 ms [87.1..123.1] |
| C_16 port, compiled | same | same | 59.2 ms [53.6..] |
What the medians say: the evaluation schedules are statistically
identical (medians differ by under 0.15%, well inside each side's own
lottery spread), the delivered digits are statistically identical too, the
numpy engine runs at wall-clock parity with the compiled Fortran, and the
compiled path (tt/algs/_dmrg_fast.py, engages automatically when
fun is a numba dispatcher — one jitted kernel per bond visit,
hand-written LAPACK-convention triangular solves, identical lottery draws to
the numpy path) is 1.6–2.0x faster than the Fortran.
Three earlier single-shot claims died on the way to these medians and are
recorded as dead: the C_16 row was first published with a Fortran wall of
15.6 ms and a "4.3x smaller evaluation count" story — that was a rare
early-strike-out of the reference's always-armed accuracy rule under its
unseeded lottery (11.19 digits), not its typical cost; the port's "+2.9
digits on C_16" was one lucky seed of ours (the [11.43..14.06] spread above);
and "the numpy path is 8–14% faster per evaluation" from best-of timings is
inside session noise — parity is what the medians support. The eps-driven
regime is a different story: at the driver's own 500*eps_machine constant
without a rank cap the reference strikes out at ~89k evaluations / 10.9
digits even with rank headroom (rank cap 40), while the port continues to
rank 33+ and 14.7 digits — its residuals, computed through pivoted LAPACK
solves, stay meaningful below the reference build's ~1e-11 floor. More
digits for more evaluations, priced explicitly.
One porting trap recorded for the next reader: scipy's raw getrf wrapper
already returns 0-based pivot indices (LAPACK's are 1-based); subtracting 1
again corrupts the swaps.
The Fortran package's other two drivers (test_mc_ising,
test_qmc_ising) also build and run on the same machine and reproduce the
paper's core claim on C_6: plain MC reaches 3.6 digits and lattice QMC 7.5
digits at 4.19M evaluations each, where the greedy cross has 7.9 digits at
26k — two orders of magnitude fewer calls at equal accuracy.
examples/ising_integrals.py is the ported driver; at eps=1e-12, n=65 it
delivers 13.7–14.7 digits on C_6/C_16/D_6/E_6 in 50–230 ms, and the
31-dimensional C_32 at 14.3 digits in 355 ms / 820k evaluations.
Acceptance tests live in tests/test_dmrg_cross.py (18, dense-truth and
closed-form oracles); tt.greedy_cross now resolves to this engine — the
alias stopped lying (P2).
The pre-port section 2.1a compared rect_cross with the Fortran; here are the
package's own two engines head to head, medians over 5 seeds, the same
numba-jitted integrand handed to both (the compiled bond kernel engages for
dmrg_cross; rect_cross calls the same dispatcher as a plain black box),
kernel compile off the clock:
| C_6 | digits | evaluations | wall |
|---|---|---|---|
| dmrg eps 1e-5 / rect eps 1e-5 | 8.16 / 7.00 | 31k / 72k | 11 / 48 ms |
| dmrg 1e-7 / rect 1e-7 | 10.30 / 9.23 | 67k / 373k | 14 / 162 ms |
| dmrg 1e-9 / rect 1e-9 | 11.95 / 11.47 | 114k / 686k | 23 / 323 ms |
| dmrg 1e-11 / rect 1e-11 | 14.21 / 13.60 | 192k / 1 111k | 42 / 523 ms |
| C_16 | digits | evaluations | wall |
|---|---|---|---|
| dmrg 1e-5 / rect 1e-5 | 8.00 / 6.86 | 86k / 307k | 33 / 223 ms |
| dmrg 1e-7 / rect 1e-7 | 9.34 / 8.72 | 169k / 1 137k | 60 / 601 ms |
| dmrg 1e-9 / rect 1e-9 | 11.27 / 11.40 | 345k / 2 177k | 112 / 985 ms |
| dmrg 1e-11 / rect 1e-11 | 13.81 / 13.49 | 623k / 3 717k | 168 / 1 354 ms |
At matched digits the greedy needs 3–7x fewer evaluations (typically ~6x)
and 8–13x less wall time, and at every eps it lands a fraction of a digit
to a digit above rect_cross for its budget. Both engines top out near
14.5 digits on these tensors. This corrects the "5–40x fewer evaluations"
claim first drawn from 2.1a: the 40x end of that range came from the same
lucky Fortran C_16 row that section 2.1b withdrew; the measured spread against
rect_cross at equal accuracy is 3–7x. rect_cross keeps its own ground —
kickrank-sized rank jumps reach a target rank in far fewer sweeps, which is
what AMEn-style consumers want — but for quadrature-style smooth black boxes
the greedy is now the engine to reach for.
Artifacts: build recipe and stub in the session scratchpad
(ttcross/mpif.h, ttcross/mpi_stub.c); the ttpy2 side is
cross_vs_ttcross.py / accept_dmrg.py there, runnable against any
checkout.
teneva.cross (cross.py:13-205) is the same family as ours: one-site
alternating cross, maxvol_rect row selection.
Differences that matter:
_iter(cross.py:259-287) takes a QR of the block, not an SVD, and returns three things: the new coreG = B(the interpolation matrix), the interfaceR = Q[ind] @ Rcarried to the next core, and the extended index set. So the tensor is complete after each half-sweep, and the left-to-right half is not thrown away as it is in our_sweep_lr. This costs no extra function evaluations either way.- Rank growth is bounded per micro-step by
dr_max(default 1) with a floor ofdr_min, so ranks climb slowly and the run takes many more sweeps.dr_max = 0degrades to fixed-rank square maxvol. - A cache (
_func_eval,cross.py:229-256): adictkeyed by the tuple of the multi-index. Only the misses go tof. It also yields a convergence criterion —info['m_cache'] > m_cache_scale * info['m']means the sweeps are re-asking for what they already know, i.e. the index sets have stopped moving (cross.py:196). - A hard evaluation budget
m: if the next batch would exceed it, the run stops withinfo['stop'] = 'm'and returns what it has. fmay returnNoneto abort cleanly (info['stop'] = 'func').- A validation set
(I_vld, y_vld)with thresholde_vld, evaluated after every sweep and usable as the stopping criterion — ourn_checkis the same measurement but only once, at the end, and never as a criterion. - A
cb(Y, info, opts)callback with the index sets and the cache inopts. info['stop']is an explicit enum:func | m | e | nswp | conv | e_vld | cb.- The docstring is explicit that the result has inflated ranks and the caller
must
truncate— we do that internally (round_result=True).
teneva.maxvol / maxvol_rect (maxvol.py:15-137) are transcriptions of the
same two papers we cite. maxvol uses scipy.linalg.lu + two
solve_triangulars; maxvol_rect maintains B by the same
Sherman–Woodbury–Morrison update we use, but reallocates the whole (n, K)
matrix with np.hstack at every added row (maxvol.py:131). Neither has a rank
test, an identity post-condition, a non-finite test, or complex support (v = B.dot(B[i]) and F − l·v·v are written without conjugation).
teneva.cross_act (cross_act.py) is the analogue of our multifuncrs: an
AMEn-style cross for Rx carried per
input — the fused sampling our multifuncrs.py docstring says it gave up. Its
own docstring says "This is a draft … There is a problem in the rank-1 case".
It carries dr (kickrank) and dr2 — the random enrichment of §1.3
(core_qr_rand, core.py:41-55).
| axis | ttcross | teneva | ttpy2 | better, and why |
|---|---|---|---|---|
| square maxvol exists? | no separate routine — the cross submatrix is a bordered LU, never a "maxvol" call |
maxvol (maxvol.py:15) |
maxvol (maxvol.py:185) |
n/a |
| start | — |
scipy.linalg.lu, P[:, :r].argmax(0)
|
LAPACK getrf pivots, _check_rank on diag(U)
|
ttpy2 — same start, plus it refuses a rank-deficient input instead of returning a zero-volume submatrix |
| swap update | — |
B -= np.outer(bj, bi/B[i,j]), one (n,r) temporary per swap |
in-place BLAS ger/geru, verified to have aliased (_ger, maxvol.py:99) |
ttpy2 — no temporary; 3.0× faster at N=1e5, r=200 (§4.1) |
| complex | — |
no (B.dot(B[i]) unconjugated) |
yes (geru, explicit .conj()) |
ttpy2 |
| singular / near-singular submatrix | pivot rejected if ` | pivot | <= max(10·eps·amax, 1e-5·pivotmax_prev)` — the rank simply does not grow | returns; on an exactly rank-deficient input it returns a B that happens to reconstruct A, on a 1e-14-deficient one `max |
NaN in the input |
n/a | returns piv and an all-NaN B, silently |
ValueError naming the first offending index |
ttpy2 |
| rectangular maxvol | — |
maxvol_rect (maxvol.py:68) |
rect_maxvol (maxvol.py:331) |
— |
| rect update | — | SWM update, then np.hstack — |
growable F-ordered buffer with capacity doubling + in-place ger
|
ttpy2 — 18.0× faster at N=1e5, r=200 (§4.1) |
| rect stopping | — | k >= r_min and F[i] <= e² |
(row_norm_sqr[i] > tol² and K < maxK) or K < minK, plus top_k_index
|
equivalent; ttpy2 additionally distinguishes criterion met from post-condition met and names the reason (stop_reason) |
| reporting |
neval, per-sweep line |
none |
info dict: K, max_row_norm, max_row_norm_bounded, converged, stop_reason, nested square-maxvol info
|
ttpy2 |
| wrappers | — | none |
maxvol_qr, rect_maxvol_qr, maxvol_svd, rect_maxvol_svd
|
ttpy2 (legacy API surface, COMPAT) |
Verdict: port nothing from teneva's maxvol. The one thing worth taking from
ttcross is the pivot rejection idea, and it belongs in cross.py, not in
maxvol.py (§5 P6).
| axis | ttcross (dmrgg.f90) |
teneva (cross.py) |
ttpy2 (cross.py) |
better, and why |
|---|---|---|---|---|
| block | two-site (DMRG) superblock |
one-site |
one-site |
teneva/ttpy2 on cost per micro-step; ttcross sees |
| rank adaptation | +1 per bond per sweep, pivot = argmax residual |
dr_min … dr_max per micro-step (default 1..1), driven by maxvol_rect
|
kickrank … kickrank + rf per micro-step, driven by rect_maxvol
|
ttcross is the most parsimonious in rank; ttpy2 reaches the target rank in the fewest sweeps; on the Ising family ttcross is uniformly cheaper in evaluations, 5–40x (measured, §2.1a) — §4.2's "neither is uniformly cheaper" survives only as a statement about worst cases |
| pivot choice | max of the residual A − col·row, by full / lottery / rook search |
max 2-volume of an orthonormal basis (maxvol_rect) |
same as teneva |
ttcross — the residual is the quantity the error depends on; volume is a proxy for it. But rook pivoting needs 2p extra fibers per bond per sweep and is only affordable because the rank grows by 1 |
| exploration beyond the pivots | the lottery is a uniform random sample of unselected entries — random exploration is built into every pivot search | none in cross; dr2 exists in cross_act
|
none | ttcross, decisively. This is the same gap §1.3 measures |
| basis factorisation | none (bordered LU of the cross submatrix) | QR | SVD, untruncated | ttcross avoids it entirely; QR vs SVD is a wash in practice (§4.5) |
| stopping rule |
pivotmax <= accuracy·amax for 3 consecutive sweeps |
relative change e, or validation error e_vld, or budget m, or cache saturation, or nswp, or cb
|
relative change eps/eps_abs, or nswp, or stop_fun
|
ttcross for the indicator (a residual, not a difference of iterates) and the 3-strike rule; teneva for the variety — m and e_vld are things we simply lack |
| what it can report |
neval, pivotmax, running <w,X> from a quadrature TT, dtt_accchk in ∞ and Frobenius norm with the worst index |
info enum stop, m, m_cache, e, e_vld, nswp, r; cb gets the index sets and the cache |
CrossHistory — per-sweep err_rel/err_abs/erank/max_rank/fun_eval/time, err_round (exact), err_check (Frobenius only), rmax_active, converged
|
ttpy2 for per-sweep detail and for err_round being a measurement; ttcross for the norm pair and the worst index; teneva for a named stop reason |
| repeated evaluations | the nested-index design re-asks constantly; no cache |
cache dict, hit/miss counted, and used as a convergence signal |
none | teneva — this is the single largest measured saving available (§4.3) |
| evaluation budget |
maxrank only |
m, hard |
nswp, rmax only |
teneva |
| black box may give up | no |
f returns None → clean stop |
no | teneva |
non-finite value from f
|
not checked | not checked (np.array(y, dtype=float)) |
ValueError naming the index |
ttpy2 |
complex f
|
no (double precision) |
no (dtype=float in _func_eval) |
yes, with automatic promotion of a real x0
|
ttpy2 |
| backends | Fortran + MPI + OpenMP | numpy only | numpy / torch through tt.backend; index bookkeeping stays numpy |
ttpy2 |
| result rank | inflated, no rounding | inflated, caller must truncate
|
rounded to eps internally, err_round measured |
ttpy2 |
| parallelism | MPI over bonds, OMP over fibers | none | none (batched numpy inside a fiber) | ttcross, out of scope |
All on b300. .venv = numpy 2.4.6 / scipy 1.18.0; tvenv = numpy 2.5.1 /
teneva 0.14.11 with ttpy2 on PYTHONPATH. float64 everywhere. Times are single
runs on a shared 256-core machine (other jobs present) — read them as ±30 %; the
errors, ranks and evaluation counts are deterministic given the seed.
A = qr(randn(N, r)).Q. Square maxvol, tol = 1.05, max_iters = 100, mean of
5 repeats:
N |
r |
log det A[I] ours |
teneva | ours (ms) | teneva (ms) | swaps |
|---|---|---|---|---|---|---|
| 200 | 20 | −23.9240 | −23.9240 | 0.37 | 0.54 | 7 |
| 2000 | 50 | −99.8141 | −99.8141 | 9.10 | 8.99 | 11 |
| 20000 | 100 | −286.7560 | −286.7560 | 218.77 | 612.35 | 29 |
| 100000 | 200 | −680.5949 | −680.5949 | 4952.89 | 15054.09 | 48 |
Identical volume to 4 decimals at every size; 3.04× faster at the top size.
Rectangular maxvol, tol = 1.1, K free, mean of 3 repeats:
N |
r |
K ours |
K teneva |
max‖C_i‖ ours | teneva | ours (ms) | teneva (ms) |
|---|---|---|---|---|---|---|---|
| 200 | 20 | 30 | 29 | 1.0853 | 1.0953 | 0.79 | 0.81 |
| 2000 | 50 | 85 | 85 | 1.0969 | 1.0969 | 16.97 | 22.00 |
| 20000 | 100 | 177 | 177 | 1.0984 | 1.0984 | 206.94 | 1434.87 |
| 100000 | 200 | 361 | 361 | 1.0991 | 1.0991 | 2606.50 | 46881.35 |
Same K, same bound, 18.0× faster at the top size. The gap is entirely the
np.hstack rebuild per added row (teneva/maxvol.py:131).
Degenerate inputs, N = 200, r = 10 (E7 C):
| input | ttpy2 | teneva |
|---|---|---|
| exact rank deficiency (col 9 = col 0) | LinAlgError, names ` |
U_ii |
near deficiency 1e-14 |
LinAlgError |
returns; **`max |
| all zeros | LinAlgError, U[0,0] = 0 |
LinAlgError (from solve_triangular) |
one NaN |
ValueError, names the index |
returns; B is all NaN, no warning |
cond ≈ 1e18, full rank |
LinAlgError |
returns; `max |
| complex, well conditioned | `max | B[I] − I |
Honest counterweight on the last-but-one row: our rank test is stricter than
necessary and rejects a matrix teneva handles correctly.
B accurate to 2e-16. Our _check_rank threshold rcond=, maxvol_qr) and the
cross path always passes an orthonormal Q, so this never bites inside ttpy2 —
but a user calling maxvol directly on a badly scaled matrix will hit it.
Documented, not changed (§10 Q4).
err_rand = relative error on 5000 fixed uniform random multi-indices, computed
against f itself. raw = every value asked for; uniq = distinct
multi-indices — the number a cache would reduce the cost to and the fair
implementation-independent metric.
| problem | implementation | err_rand |
raw | uniq | max rank | time (s) |
|---|---|---|---|---|---|---|
sin(s)/(1+s), d=5, n=12, eps=1e-10 |
ttpy2 | 5.57e-11 | 33048 | 14425 | 8 | 0.05 |
| teneva | 5.57e-11 | 37056 | 14492 | 8 | 0.16 | |
| teneva + cache | 5.57e-11 | 14492 | 14492 | 8 | 0.20 | |
| kink `1/(1e-2+ | s−5/2 | ), d=6, n=10, eps=1e-8` |
ttpy2 (kickrank=2) |
5.67e-04 | 299200 | 97444 |
| teneva | 3.09e-10 | 578360 | 125238 | 26 | 16.95 | |
| teneva + cache | 3.09e-10 | 125238 | 125238 | 26 | 17.49 | |
QTT 1/(1+t), d=20, n=2, eps=1e-10 |
ttpy2 | 3.51e-11 | 15386 | 5147 | 5 | 0.05 |
| teneva | 4.61e-10 | 5906 | 1807 | 5 | 0.05 | |
| teneva + cache | 4.61e-10 | 1807 | 1807 | 5 | 0.06 | |
exact TT rank 3, d=8, n=16, eps=1e-10 |
ttpy2 | 1.81e-15 | 16064 | 11802 | 3 | 0.03 |
| teneva | 1.24e-15 | 20592 | 12502 | 3 | 0.06 | |
| teneva + cache | 1.24e-15 | 12502 | 12502 | 3 | 0.10 |
Readings:
- Distinct-evaluation counts are within ~10 % of each other on three of four problems. Neither algorithm is fundamentally cheaper in samples. On the QTT problem teneva is 2.8× cheaper in distinct evaluations but 13× less accurate (4.6e-10 vs 3.5e-11) — it stopped earlier, not smarter.
raw / uniqis 2.3–3.1× for ttpy2 and 2.6–4.6× for teneva. That ratio is pure waste, and it is what the cache recovers.- ttpy2 is 3–43× faster in wall clock at equal ranks. The kink row (0.39 s vs
17 s) is dominated by teneva's
maxvol_rectreallocation and its per-index Pythondictloop. - The kink row is the §1.3 defect showing up in a benchmark table.
Prototype: memoise cross._evaluate on the packed rows (np.unique over a
structured view, then one dict probe per distinct index, not per index).
| problem | mode | fun_eval |
time (s) | error | cache hits |
|---|---|---|---|---|---|
sin(s)/(1+s), d=5, n=12, eps=1e-10 |
plain | 34932 | 0.02 | 5.86e-11 | — |
| cache | 13884 (2.52×) | 0.06 | 5.86e-11 | 21048 | |
QTT 1/(1+t), d=20, eps=1e-10 |
plain | 5680 | 0.03 | — | — |
| cache | 1852 (3.07×) | 0.04 | — | 3828 | |
kink, d=6, n=10, eps=1e-8 |
plain | 704650 | 0.43 | 2.84e-10 | — |
| cache | 169421 (4.16×) | 1.31 | 2.84e-10 | 535229 | |
sin(s)/(1+s), black box slowed to 50 µs/point |
plain | 34932 | 1.85 | 5.86e-11 | — |
| cache | 13884 | 0.84 (2.2× faster) | 5.86e-11 | 21048 |
Two facts worth stating separately.
- Duplicates inside a single batch: zero, always. Every saving is across micro-steps and sweeps — the left-to-right sweep asks for a block, the right-to-left sweep asks for an overlapping one, and the next sweep asks again.
- Break-even. On the kink the cache added 0.88 s over 704650 probes, i.e.
≈ 1.25 µs per probe in this prototype. The cache is a net loss for a black
box cheaper than that per point, and a win above it. That is why it must be
opt-in and why the default must stay off (§5 P1). A numpy-side implementation
(sorted key array +
searchsortedinstead of a Python loop) would lower the break-even; not measured.
ttpy2 + kickrank2 = 2 + cache, against ttpy2 as it stands and teneva+cache.
Kink, d = 6, n = 10, eps = 1e-8, six seeds of the initial guess. This
table is the tvenv interpreter (numpy 2.5.1) — it has to be, teneva lives
there — so the "ttpy2" column is the second row of the §1.3 numpy table, not the
first: seed 1 passes here and fails under numpy 2.4.6.
| seed | ttpy2 error | uniq | +k2+cache error | uniq | time (s) | teneva+cache error | uniq | time (s) |
|---|---|---|---|---|---|---|---|---|
| 0 | 2.84e-10 | 139186 | 2.84e-10 | 176832 | 1.71 | 2.84e-10 | 125238 | 18.01 |
| 1 | 3.84e-10 | 162106 | 2.84e-10 | 229063 | 2.32 | 3.82e-04 | 98886 | 13.95 |
| 2 | 3.82e-04 | 88641 | 2.84e-10 | 219465 | 2.26 | 2.84e-10 | 91943 | 9.47 |
| 3 | 3.82e-04 | 111772 | 2.84e-10 | 225928 | 2.41 | 2.84e-10 | 113235 | 19.35 |
| 4 | 3.82e-04 | 131879 | 2.84e-10 | 212540 | 2.12 | 2.84e-10 | 112630 | 17.36 |
| 5 | 3.82e-04 | 102190 | 2.84e-10 | 226382 | 2.44 | 5.84e-10 | 97349 | 10.30 |
Smooth d = 5, n = 12, eps = 1e-10, three seeds: all nine
runs give 5.86e-11; uniq 13724–13766 (ttpy2), 19606–21161 (+k2+cache),
10655–14733 (teneva+cache); time 0.08–0.09 s vs 0.16–0.30 s.
Readings: the enrichment is 6/6 correct where the plain method is 2/6 (1/6 under numpy 2.4.6, §1.3) and teneva is 5/6; it costs 1.4–2.5× distinct evaluations on the hard problem and 1.4–1.5× on the easy one, which is the price of the insurance. The remaining 7–8× wall-clock gap to teneva is ours to keep. Note that teneva fails on a different seed than we do — further evidence that which seed fails is roundoff, and that the failure is a property of the method rather than of either implementation.
teneva takes a QR where we take an SVD, and we never truncate, so the column
spaces are identical (
block (m, k) |
svd (ms) |
qr (ms) |
speed-up |
|---|---|---|---|
| (240, 24) | 0.15 | 0.06 | 2.51× |
| (2400, 60) | 21.03 | 19.22 | 1.09× |
| (10000, 100) | 78.94 | 78.99 | 1.00× |
| (40000, 200) | 331.28 | 439.42 | 0.75× |
End to end, _left_basis patched to QR: smooth 0.020 → 0.016 s, kink 0.411 →
0.365 s, identical errors and ranks in all cases (including the bad seed:
3.82e-04 either way). numpy's gesdd is not the bottleneck at these shapes and
geqrf is slower on the biggest one. Marginal; not recommended as a
standalone change (§5, "not recommended").
2**d points,
eps = 1e-10, kickrank = 1, n_check = 2000:
d |
max rank | err_check |
fun_eval |
time (s) | fun_eval / 2**d |
|---|---|---|---|---|---|
| 10 | 6 | 1.10e-11 | 4066 | 0.02 | 3.97e+00 |
| 20 | 6 | 2.14e-11 | 12954 | 0.04 | 1.24e-02 |
| 40 | 6 | 1.53e-11 | 33318 | 0.08 | 3.03e-08 |
| 60 | 6 | 1.54e-11 | 54820 | 0.12 | 4.75e-14 |
No warnings; fun_eval grows linearly in d = 10 asks
for 4× the whole tensor — for a small 2**d the method is a pessimisation,
which is worth saying in the docstring.
Each item: what it is, which function changes, the exact signature after the change, what it buys (measured, or clearly marked as expected), what it costs.
P1 — an evaluation cache. tt/algs/cross.py. Rank 1.
What. teneva's cross.py::_func_eval idea: memoise fun on multi-indices;
only misses reach the black box.
Signature. cross.rect_cross gains one keyword; cross.cross forwards it:
def rect_cross(fun, x0, eps=1e-6, nswp=20, kickrank=1, rf=2, verbose=False,
eps_abs=0.0, rmax=None, tau=1.1, n_check=0, check_seed=0,
stop_fun=None, round_result=True,
cache=None):
"""...
Args:
cache: Memoise ``fun`` on multi-indices. ``None`` (default) does not
cache. ``True`` uses a private dict discarded on return. A dict is
used and filled in place, so it can be reused across calls with the
same ``fun``. Cuts the number of black-box evaluations by 2.5-4.2x
on the problems in docs/plans/cross-approximation.md §4.3 and costs
about 1.3 us per lookup: it is a net loss for a black box cheaper
than that per point. ``history.fun_eval`` counts misses only;
``history.fun_eval_cached`` counts hits.
"""CrossHistory gains fun_eval_cached: int = 0. The cache is keyed on the packed
int64 row (idx.view([("", np.int64)] * d)), which is exact for any d and
any mode size and never collides.
Buys. Measured: 2.52× / 3.07× / 4.16× fewer black-box calls (§4.3); 2.2× wall-clock on a 50 µs/point black box.
Costs. ≈1.25 µs per probe (prototype); a dict of fun is not a function of the index —
which rect_cross's contract already requires, and which the cache now enforces
by construction, turning a silent contract violation into a reproducible one.
P2 — kickrank2: uniformly random extra rows per micro-step.
tt/algs/cross.py::_select_rows, rect_cross. Rank 2 — this is the correctness
item.
What. The legacy multifuncrs2's kickrank2, teneva's cross_act dr2
(core.py::core_qr_rand), and — in a different guise — ttcross's lottery, which
draws its pivot candidates uniformly from the unselected entries at every bond
of every sweep. All three inject exploration that the volume criterion cannot.
Signature.
def rect_cross(fun, x0, ..., round_result=True, cache=None,
kickrank2=0, kick_seed=0):
"""...
Args:
kickrank2: Extra rows per micro-step drawn uniformly at random from the
rows rectangular maxvol did *not* select. Volume-based selection is
blind to a region where |f| is small relative to the pivots, and a
cross that never samples such a region converges to a wrong fixed
point while reporting a relative change of 1e-15 -- see
docs/plans/cross-approximation.md §1.3 for the reproducer. Two rows
fixed 6 of 6 failing seeds there, for 1.4-2.5x more evaluations.
Costs nothing when the sampling was adequate anyway (the extra rows
are removed by the final rounding).
kick_seed: Seed of that draw, so a run stays reproducible.
"""_select_rows(q, kickrank, rf, rmax, tau) becomes
_select_rows(q, kickrank, rf, rmax, tau, kickrank2=0, rng=None).
Buys. Measured: 3.82e-04 → 2.84e-10 on 5 failing seeds, 6/6 correct (§1.3, §4.4).
Costs. Measured: 1.4–2.5× distinct evaluations on the hard problem, 1.4–1.5× on
the easy one. Ranks before rounding grow by kickrank2 per bond per sweep; the
final round(eps) removes them (measured: identical final ranks). A non-zero
default is a behaviour change for every existing caller — see §10 Q1 for the
experiment that decides the default. Recommendation: default 0 in this
change, and a follow-up commit that flips it once Q1 is measured on the whole
suite.
P3 — stop rect_maxvol from crying wolf about a budget the caller set.
tt/algs/maxvol.py::rect_maxvol. Rank 3.
What. Add the flag that maxvol already has (warn_nonconvergence), and use it
at the one call site that always passes an explicit maxK.
Signature.
def rect_maxvol(a, tol=1.05, maxK=None, min_add_K=None, minK=None,
start_maxvol_iters=10, identity_submatrix=True, top_k_index=-1,
rcond=None, info=None, warn_budget=True):
"""...
Args:
warn_budget: Emit the RuntimeWarning when growth stopped at ``maxK``
or ``minK`` rather than at the tolerance. Set to ``False`` when a
budgeted ``K`` is the point (``tt.algs.cross._select_rows`` computes
``maxK = rho + kickrank + rf`` on purpose); ``info['converged']`` and
``info['stop_reason']`` still report the truth. The ``tol < 1`` and
``all rows selected`` warnings are *not* covered by this flag --
those are genuine contradictions in the request.
"""cross._select_rows passes warn_budget=False.
Buys. 80 of 93 RuntimeWarnings disappear from the suite; the remaining 13 are
all real. Measured accuracy change: none (§1.2).
Costs. One more boolean in a signature. The risk that a genuine "the budget is
too tight" situation goes unmentioned is covered by info['stop_reason'], which
cross should start recording per micro-step (§6, CrossHistory.maxvol_budget_hits).
P4 — ttcross's stopping rule: a residual indicator with a three-strike rule.
tt/algs/cross.py::rect_cross. Rank 4.
What. dmrgg.f90:661-665. Two independent ideas, both cheap:
-
The indicator is a residual on freshly probed entries,
pivotmax / amax, not a difference between iterates. Our one-site sweep already has the ingredients: at each micro-step, the blockA_kis evaluated and the current interpolant's value on the same block is one contraction away, somax |A_k − X_k| / amaxcosts$O(d n r^2)$ flops and zero extra function evaluations. -
The rule requires three consecutive sweeps below the threshold. Our §1.3
failure passes a one-sweep test trivially (
err_rel = 3e-15on the sweep it locks up). Whether a residual indicator plus three strikes would have caught it is not measured — see §10 Q2, which is the experiment that decides whether P4 is worth its complexity.
Signature.
def rect_cross(fun, x0, ..., kickrank2=0, kick_seed=0,
stop_strikes=1):
"""...
Args:
stop_strikes: How many consecutive sweeps must satisfy the stopping
criterion before the run is declared converged (ttcross uses 3,
dmrgg.f90:661). ``1`` reproduces the current behaviour exactly.
"""CrossHistory gains err_resid: float (the last max|A_k − X_k| / amax over
the sweep) and each sweep dict gains err_resid.
Buys. Expected, not measured: an indicator that is a residual rather than a
difference of iterates cannot be fooled by a fixed point of the index sets in the
way §1.3 is — the residual is evaluated on the newly probed rows, and with P2's
random rows those include unexplored regions. Cost of stop_strikes=3 is two
extra sweeps per run, i.e. roughly
Costs. Two more sweeps by default if the default changes (it should not, in the
first commit). The residual computation is
P5 — report the held-out error in both norms and name the worst index.
tt/algs/cross.py::_held_out_error, CrossHistory. Rank 5.
What. dtt_accchk (dmrgg.f90:708-752) returns einf, efro, ainf, afro and
optionally the multi-index of the worst entry.
Signature.
def held_out_error(fun, x, n, n_check, rng=None, seed=0):
"""Relative error on random multi-indices the cross never asked for.
Returns:
dict with ``rel_fro``, ``rel_inf``, ``worst_index`` (list of d ints),
``worst_exact``, ``worst_approx``, ``n_check``.
"""CrossHistory.err_check keeps its meaning (Frobenius, for compatibility) and
gains err_check_inf: float | None and err_check_worst: list | None. The
warning at cross.py:540 reports both numbers and the worst index.
Buys. Measured: on the §1.3 failure the ∞-norm reads 9.24e-03 against the
Frobenius 3.82e-04 — a 24× more honest description of the damage — and the worst
index (9,9,9,9,9,9) points straight at the unexplored corner, which is the
single most useful thing a user can be handed when the check fires.
Costs. Nothing: the same samples, three more reductions. Honest counterweight,
measured (§1.3, E11): the ∞-norm does not detect more often. Detection is
governed by whether the sample hits the bad region at all — 36.5 % at
n_check = 1000, 97 % at 10000, identical for both norms. Do not let the extra
number be read as extra safety.
P6 — refuse a numerically meaningless rank increment.
tt/algs/cross.py::_left_basis. Rank 6.
What. ttcross's upd(p) test (dmrgg.f90:369): a pivot smaller than
max(10·eps·amax, 1e-5·pivotmax_prev) is not added, and the rank does not grow.
Our _left_basis takes rho = min(q.shape[1], rmax) unconditionally, i.e. it
happily builds index sets on singular directions of the sampled block.
Signature.
def _left_basis(mat, rmax, rel_drop=0.0):
"""Orthonormal basis of the column space of ``mat``, capped at ``rmax``.
``rel_drop > 0`` drops the trailing directions whose singular value is below
``rel_drop * s[0]``. This is *not* truncation at the target accuracy -- see
the fixed point this docstring already describes, which is what that costs.
It is the ttcross ``small_element`` guard (dmrgg.f90:369): a direction at the
level of the roundoff of the block carries no information about the function
and the row maxvol selects for it is arbitrary.
"""exposed as rect_cross(..., basis_drop=0.0).
Buys. Not measured. The expectation is a small reduction in wasted ranks on
blocks that are exactly rank deficient — which _left_basis's own docstring says
is the normal case for f(i_1 + … + i_d). §10 Q3 is the experiment.
Costs. This is the knob whose careless use produces the 12 %-error fixed point
_left_basis warns about. It must default to 0.0 and its docstring must keep
that warning. Recommend implementing it and leaving it off, so that the
experiment in Q3 can be run without another patch.
P7 — a hard evaluation budget and a graceful abort. tt/algs/cross.py. Rank 7.
What. teneva's m (cross.py:231) and its f → None convention.
Signature.
def rect_cross(fun, x0, ..., max_eval=None):
"""...
Args:
max_eval: Hard cap on the number of black-box values. When the next
batch would exceed it the run stops *before* asking, returns the
current interpolant, sets ``history.stop_reason = 'max_eval'`` and
warns. ``None`` (default) is no cap.
"""CrossHistory gains stop_reason: str with values
'eps' | 'nswp' | 'max_eval' | 'stop_fun' | 'rmax', replacing the current
"converged plus four ad-hoc ifs at cross.py:523-544" with one named enum.
converged stays and means stop_reason == 'eps'.
Buys. Not measured (it is a control-flow feature, not a numerical one). It is the only way to run a cross against a black box that costs seconds per point without risking an unbounded run, and it is the natural way to write a "same accuracy per evaluation budget" benchmark, which §8 needs.
Costs. One more early-return path. The returned tensor is not converged and must warn loudly, which the existing note machinery already does.
-
teneva's
maxvol/maxvol_rect. Same pivots, 3–18× slower, no rank test, no identity post-condition, no non-finite test, no complex support. Measured in §4.1. Nothing to gain. -
teneva's per-index Python
dictcache as written.I_new = np.array([i for i in I if tuple(i) not in cache])plusnp.array([cache[tuple(i)] for i in I])is two Python loops over every index in the batch. Our P1 prototype loops over the distinct indices only, after onenp.unique, and is what should be implemented. -
teneva's
m_cache_scale"convergence" heuristic (cross.py:196: stop when the cache is hit 5× more often than it misses). It is a proxy for "the index sets stopped moving", which is exactly the state §1.3 shows is compatible with a 3.8e-4 error. Adopting it would add a third way to declare success while wrong. Reject. -
Porting ttcross's DMRG sweep wholesale. It is a second engine with its own
index representation (nested
(i,j,k,q)quadruples), its own bordered-LU arithmetic, and a two-site block that costs$n^2 r^2$ per micro-step against our$n r^2$ . §4.2 shows no evaluation-count advantage for the maxvol family that would justify it, anddocs/REQUIREMENTS.mdR0 is explicit that ttpy2 exists to have fewer engines. Take the indicator (P4), the pivot rejection (P6) and the two-norm check (P5); leave the sweep. - ttcross's MPI/OpenMP layer. Out of scope for a pure-Python package (R1).
-
ttcross's multiple-precision variant (
dmrggmp.f90, MPFUN/MPFR). Out of scope; R4 fixes float64 as the top of our ladder. -
A post-hoc rook/alternating-maximisation residual probe. Prototyped (E4) and
rejected on measurement: on the §1.3 failure, at a budget of 120
evaluations, alternating maximisation over fibers detected the error in 0 of
50 random starts against 2 of 50 for plain uniform Monte Carlo at the same
budget. The reason is visible once stated: post-hoc the residual field is a
needle — it is ~1e-15 everywhere except on 394 entries, so the argmax along a
fiber is roundoff noise and the search does not drift anywhere. Rook pivoting
works inside ttcross's sweeps because there the residual is still large and
smooth. On the spike blind spot (a single non-zero entry in
10**6) it is blind too, 0/3 at 360 evaluations, as is everything else. -
An "
err_relcollapsed by more thankorders in one sweep" detector. Prototyped (E6) and rejected: the signature is not specific. A tensor of exact TT rank 3 (d=8,n=16) drops fromerr_rel = 1.00to1.7e-15in a single sweep — correctly, it became exact — a 5.9e+14 collapse, larger than the 4.9e+12 of the failing run. Any threshold that flags §1.3 flags the healthy exact case. -
Replacing the SVD in
_left_basisby a QR as a performance change. Measured (§4.5): 2.5× on the smallest blocks, 1.00× at (10000, 100), 0.75× at (40000, 200); 10–20 % end to end. Not worth a change on its own. (If_left_basisgainsrel_dropunder P6 it needs the singular values anyway.) -
teneva's
dr_max-bounded rank growth. It makes the run take many more sweeps to reach the same rank; §4.2 shows no accuracy or evaluation benefit that ourkickrank/rfpair does not already give.
(T) = needs a test before it is called done. "Closest" = the existing function
it is nearest to, i.e. the one whose contract and error style it must match.
# --- C1 (T) the held-out check, promoted to a public, reusable function ------
def held_out_error(fun, x, n=None, n_check=1000, seed=0):
"""Relative error of a TT tensor against a black box on random unseen points.
Returns dict: rel_fro, rel_inf, worst_index, worst_exact, worst_approx,
n_check. Closest: tt.algs.cross._held_out_error (private, Frobenius only).
"""
# --- C2 (T) the residual of a cross interpolant on a fiber block -------------
def block_residual(fun, x, iset, jset, k):
"""max |fun(I_k x n_k x J_k) - x(I_k x n_k x J_k)| and the argmax index.
The ttcross ``pivotmax``. Zero extra evaluations when called from inside a
sweep, which is the only place it should be called from.
Closest: tt.algs.cross._sweep_lr (which already forms the left-hand side).
"""
# --- C3 (T) an index-set cache with an explicit owner ------------------------
class EvalCache:
"""Memoised black box. ``EvalCache(fun)`` is itself callable and is what
rect_cross(cache=...) wraps ``fun`` in.
Attributes: hits, misses, nbytes. Methods: clear().
Closest: tt.algs.cross._Counter (which counts but does not store).
"""
def __init__(self, fun, store=None): ...
def __call__(self, idx): ...
# --- C4 (T) explicit stop reasons --------------------------------------------
@dataclass
class CrossHistory:
"""... existing fields ...
stop_reason: str # 'eps' | 'nswp' | 'max_eval' | 'stop_fun' | 'rmax'
err_resid: float # ttcross pivotmax/amax, last sweep (nan if off)
err_check_inf: float|None # held-out relative error in the infinity norm
err_check_worst: list|None# multi-index of the worst held-out point
fun_eval_cached: int # cache hits (0 when cache is off)
maxvol_budget_hits: int # micro-steps where rect_maxvol stopped at maxK
"""
# --- C5 the black box for an elementwise function, factored out --------------
def funs_blackbox(X, funs, dtype=None):
"""``idx -> funs(stack of element(X_j, idx))``, the adapter multifuncrs builds.
Exposing it lets a caller hand the same black box to rect_cross with a cache
or a budget, which multifuncrs currently cannot pass through.
Closest: tt.algs.multifuncrs._make_blackbox (private, multifuncrs.py:291).
"""
# --- C6 a fair benchmark harness (bench/, not tt/) ---------------------------
def cross_benchmark(problems, methods, budgets, seeds, out=None):
"""Accuracy-vs-evaluations table for a set of (fun, oracle) problems.
The metric is DISTINCT multi-indices, not calls -- see §4.2 for why.
Closest: bench/ (there is no cross benchmark today).
"""Every test names its oracle, its expected number, where that number comes from,
and its tolerance. tests/test_cross.py and tests/test_verify_cross.py are the
files; the split follows the existing convention (behaviour vs. verification).
| # | test | problem | oracle | expected | tolerance |
|---|---|---|---|---|---|
| T1 | test_cache_does_not_change_the_answer |
smooth sin(s)/(1+s), d=5, n=12, eps=1e-10, seed 0 |
the same run without the cache — bit-for-bit | identical cores | == 0 exactly; the cache is a memo, not an approximation |
| T2 | test_cache_cuts_evaluations |
as T1 | measured, §4.3 | fun_eval 34932 → 13884; fun_eval + fun_eval_cached == 34932 |
fun_eval <= 0.45 * 34932; the identity is exact |
| T3 | test_kickrank2_fixes_the_documented_fixed_point |
§1.3 reproducer, seeds 0..5, kickrank=1 |
dense numpy grid |
k2=0 → 3.82e-04 on at least 4 of 6 seeds (which seeds depends on the LAPACK build, §1.3); k2=2 → ≤1e-8 on all 6 |
error ≤ 10*eps on the k2=2 half; the k2=0 half asserted as a count (>= 4 seeds above 1e-6), never per seed, so it neither pins a coin flip nor silently stops testing anything if the method improves |
| T4 | test_a_confidently_wrong_answer_is_not_returned_quietly — the fail-loud test |
§1.3 reproducer with n_check=20000, kickrank2=0 |
dense grid | the run must either be accurate to 10*eps or emit a RuntimeWarning naming a measured error above eps |
assert err <= 1e-7 or warned; at n_check=20000 the detection rate is 97 % (§1.3 table), so pin check_seed and assert the warning is present |
| T5 | test_rect_maxvol_budget_is_not_a_warning |
run the whole tests/test_cross.py + test_multifuncrs.py under -W error::RuntimeWarning |
the suite itself | zero rect_maxvol stopped at K warnings; 80 today (§1.2) |
exact count == 0 |
| T6 | test_rect_maxvol_still_warns_when_the_request_is_contradictory |
rect_maxvol(a, tol=0.05, maxK=r+2, warn_budget=False) |
the post-condition | still warns (tol < 1 is unreachable by construction) |
pytest.warns(RuntimeWarning) |
| T7 | test_max_eval_stops_before_asking |
QTT 1/(1+t), d=20, max_eval=5000 |
the counter | fun_eval <= 5000, stop_reason == 'max_eval', warned |
exact |
| T8 | test_held_out_reports_both_norms_and_the_worst_index |
§1.3 reproducer, n_check=20000, check_seed pinned to a detecting seed |
dense grid | rel_fro ≈ 3.8e-04, rel_inf ≈ 9.2e-03, worst_index == [9,9,9,9,9,9] |
20 % on the two errors (Monte Carlo); the index exactly |
| T9 | test_maxvol_beats_the_reference_on_volume_and_never_loses |
qr(randn(2000,50)).Q |
teneva's pivots, computed in the test from a 20-line transcription of teneva/maxvol.py:15-65 (no dependency added) |
logdet(ours) >= logdet(theirs) - 1e-10 |
as stated; §4.1 measured equality to 4 decimals |
| T10 | test_cross_on_an_exact_tt_rank_tensor_is_exact |
random TT rank 3, d=8, n=16 |
the tensor itself | ‖y − x‖/‖x‖ |
<= 4 * eps(float64) * d * r per R4; measured 1.81e-15 (§4.2) |
| T11 | test_evaluation_count_is_linear_in_d |
QTT exp(−3t)sin(12t)+1/(1+t), d ∈ {10,20,40,60} |
§4.6 | fun_eval = 4066 / 12954 / 33318 / 54820, max rank 6 throughout |
25 % on the counts, exact on the ranks |
| T12 | test_the_spike_blind_spot_is_still_documented_and_still_blind |
single non-zero entry in 10**6 |
the definition | ‖y‖ == 0, converged=True, and the docstring note is the only defence |
exact; this test exists to make the blind spot a decision rather than a surprise |
T4 is the one that must fail loudly rather than return a plausible wrong
answer, and it is written as a disjunction on purpose: cross cannot promise
accuracy, so what is asserted is that the library never simultaneously claims
success and is wrong by four orders of magnitude. Note the honest weakness of T4,
which the test's own docstring must state: it is a Monte Carlo detector at 97 %
(§1.3, n_check = 20000), so it is pinned to a fixed check_seed. A test whose
pass depends on a random draw is not a test; a test whose subject is a random
draw must pin it and say so.
(i) Runnable today
| # | problem | parameters | reference | source |
|---|---|---|---|---|
| H1 | sin(s)/(1+s), s = sum_k i_k/(n−1) |
d=5, n=12, eps=1e-10, kickrank=1 |
error 5.86e-11, max rank 8, fun_eval 34932 (14425 distinct) |
measured, §4.2/§4.3 |
| H2 | QTT 1/(1+t), t = i/2**d |
d=20, eps=1e-10 |
err_check 3.47e-11, max rank 5, fun_eval ≈ 1.9e4 |
measured, §1.2 |
| H3 | kink `1/(1e-2+ | s−5/2 | )` | d=6, n=10, eps=1e-8 |
| H4 | exact TT rank 3, random cores | d=8, n=16, eps=1e-10 |
1.81e-15 | measured, §4.2 |
| H5 | QTT exp(−3t)sin(12t)+1/(1+t) |
d ∈ {10,20,40,60}, eps=1e-10 |
table in §4.6 | measured |
| H6 | x/(1+y²) on two rank-2 sum-tensors |
n=[10,12,14,9,11], eps=1e-8 |
3.01e-09, ranks [1,7,8,8,7,1], fun_eval 54430 |
measured, §1.2 (this is tests/test_multifuncrs.py::test_two_arguments_matches_dense) |
| H7 | single spike in 10**6 |
d=6, n=10 |
‖y‖ = 0, converged=True — the documented blind spot |
measured, E4 |
(ii) After P1–P3 land
| # | problem | why it needs the change | reference |
|---|---|---|---|
| H8 | H3 with kickrank2=2 on 20 seeds |
needs P2 | expect ≥19/20 at ≤1e-8; not measured beyond 6 seeds |
| H9 | H1/H2/H3 at a fixed max_eval budget, accuracy as the output |
needs P7 | not measured — this is the table §4.2 should become |
| H10 | An expensive black box: f = a 1-D BVP solve per point, ~1 ms |
needs P1 for the cache to pay | expect ≈2.5× wall-clock from §4.3's 50 µs/point row extrapolated; extrapolation, not a measurement |
| H11 | qtt_ell.invert(a) / sqrt(a) on the coefficients of tests/test_qtt_ell.py, with n_check on |
needs P5 to report the ∞-norm; these are the calls that emit 2 of the 80 budget warnings today | not measured |
| H12 | float32 end to end | today x0 sets the width but nothing checks that tau and the maxvol thresholds still make sense at eps ≈ 1.2e-7 |
not measured |
(iii) Aspirational
| # | problem | what stands in the way |
|---|---|---|
| H13 | Ising susceptibility integrals C_n, D_n, E_n (ttcross's own test_crs_ising) with a QTT quadrature |
needs a quadrature-weighted running functional (quad in dmrgg.f90) and, at the accuracies ttcross targets (30+ digits), multiple precision, which we do not have. A float64 run against the published C_6 is a legitimate, if much weaker, check |
| H14 | 100-dimensional integration against a Monte Carlo reference | needs H9's budget harness and a defensible reference value |
| H15 | torch/GPU cross | the sweep is already backend-generic; the maxvol pivots are numpy by design. Nothing measured; the bottleneck may well be the host round-trip per micro-step |
| H16 | Cross of a genuinely noisy black box (f + 1e-6 noise) |
tests/test_cross.py::test_noise_is_reported_as_failure covers the current behaviour; what a robust cross would do (repeat, average, or refuse) is an open design question |
All scripts are on b300 in ~/work/ttpy-modern/scratch-cross/. Two interpreters:
A = ~/work/ttpy-modern/ttpy2/.venv/bin/python (numpy 2.4.6),
B = ~/work/ttpy-modern/scratch-cross/tvenv/bin/python (numpy 2.5.1 + teneva
0.14.11). Both need PYTHONPATH=$HOME/work/ttpy-modern/ttpy2.
| script | interpreter | what it measures | § |
|---|---|---|---|
e1.py |
A | captures every rect_maxvol call of multifuncrs2(x/(1+y²)) and re-runs the ones that stopped early with the budget removed |
1.2 |
e1b.py |
A | rf ∈ {0,2,5,10,30} on three problems: warnings, error, ranks, fun_eval |
1.2 |
e3.py |
A | the §1.3 failure: kickrank sweep, seed sweep, the plain-call transcript |
1.3 |
e3b.py |
A | where the error lives; held-out error vs sample size; a post-hoc pivotmax/amax |
1.3 |
e4.py |
A | rook vs uniform Monte Carlo detection at equal budget; the spike | 5 |
e5.py |
A | per-sweep history of the failure; kickrank sweep; the kickrank2 prototype |
1.3 |
e6.py |
A | is the one-sweep err_rel collapse specific? (no) |
5 |
e7.py |
B | maxvol head to head: volume, K, row norms, time, degenerate inputs |
4.1 |
e8.py |
legacy ~/micromamba/envs/ttlegacy/bin/python, PYTHONPATH=~/work/ttpy-modern/ttpy-src |
the same problems through the Fortran-era tt.cross.rectcross |
1.3 |
e9.py |
A | the cache prototype: fun_eval, time, break-even |
4.3 |
e10.py |
B | the integrated prototype (kickrank2 + cache) vs teneva, 6 seeds |
4.4 |
e11.py |
A | detection rate of the held-out check vs n_check, both norms, 200 samples |
1.3 |
e12.py |
A | SVD vs QR in _left_basis: micro and end to end |
4.5 |
e13.py |
A | scale, d ∈ {10,20,40,60} |
4.6 |
e14.py |
A and B | the same six seeds of the failure under both numpy versions, with the spelling of n and the wrapping of fun varied as controls |
1.3 |
ttcross and teneva are cloned at ~/work/ttpy-modern/scratch-cross/{ttcross,teneva}
at the commits named at the top of this document.
Q1 — what should kickrank2 default to? §1.3 shows 0 is a defect and 2
is a fix on one problem; §4.4 shows 2 costs 1.4–2.5× evaluations. The default
is a trade the whole package pays. Experiment: run tests/ with
kickrank2 ∈ {0,1,2,3} patched in as the rect_cross default and record, per
test, the wall time, the fun_eval totals and any accuracy assertion that moves.
Then rerun H1–H7 at each value. Decide on: the smallest value that fixes 6/6 of
H3's seeds and costs less than 1.5× on H1/H2/H6. Cheap — the whole suite is 59 s.
Q2 — does a residual indicator with three strikes (P4) actually catch §1.3?
This is the question that decides whether P4 is worth building. Experiment:
instrument _sweep_lr/_sweep_rl to compute max|A_k − X_k| / amax at each
micro-step with zero extra evaluations, run the six seeds of H3 with
kickrank2=0, and print the indicator per sweep next to the true error. If the
indicator is ~1e-15 on the sweep where the run locks up, P4 is worthless on its
own and only earns its place combined with P2's random rows — in which case
rerun with kickrank2=2 and see whether the indicator now stays above
accuracy*amax for the three sweeps the rule demands. My expectation, stated
so it can be wrong: the indicator alone will not catch it, because the residual
is measured on the rows the sweep chose, and those are exactly the rows that are
interpolated exactly at the fixed point.
Q3 — does basis_drop (P6) reduce ranks without reintroducing the 12 %
fixed point? Experiment: H1, H3, H6 and tests/test_multifuncrs.py's two
tensors with basis_drop ∈ {0, 1e-14, 1e-12, 1e-10, 1e-8}; record final ranks,
fun_eval and true error. The failure mode to watch for is the one
_left_basis's docstring names — ranks locking at a fixed point far above eps
with converged=True. If any basis_drop > 0 produces that on any problem, drop
P6 entirely and say so in the docstring.
Q4 — is maxvol's rank test too strict? §4.1 measured one case
(cond ≈ 1e18, full rank) where teneva returns a correct factorisation and we
raise. Experiment: sweep r = 10, 20, 50 and, for each, compare
rcond that would have let ours through. If the post-condition
_check_identity holds at 1e-3 everywhere teneva succeeds, then _check_rank
is redundant with it and the default rcond should be loosened to the point
where _check_identity becomes the binding test — one owner for "is this input
usable", instead of two.
Q5 — how much of the raw/uniq waste is structural? §4.3 shows every cache
hit comes from across micro-steps, never within a batch. If the overlap is
systematic (the left-to-right sweep asks for a block that the right-to-left sweep
then asks for again with one index changed), it may be removable by bookkeeping
rather than by a hash table. Experiment: log every (sweep, direction, k) and
the index set it asked for, then compute, for each hit, which earlier micro-step
produced it. If >70 % of hits come from the immediately preceding half-sweep at
the same k, a two-block ring buffer replaces the cache at a fraction of the
memory.
Q6 — does the cache change what the method converges to? It must not: the
rect_cross contract already requires fun to be a genuine function of the
index. But a cached run enforces that contract while an uncached one does not,
so a user whose fun is secretly stochastic will see two different answers.
Experiment: run H1 and H6 with and without the cache and compare the cores
bit-for-bit (T1). If they differ, something upstream is non-deterministic and
that is a finding in its own right.
Q7 — is our element() fast enough to be the bottleneck of multifuncrs?
multifuncrs samples every input TT at every requested index; with a cache in
rect_cross (P1) the black box gets called less, but element is inside the
black box, so the saving is on element, not on the user's funs. Experiment:
profile H6 with and without the cache and report the split between element,
funs and the sweep. If element dominates, the fused interface-matrix sampling
that multifuncrs.py's docstring says it gave up (and that teneva.cross_act
implements) becomes worth reconsidering — the docstring's claim that "the number
of user function evaluations is the same" is true and beside the point if the
adapter's own cost is the bottleneck.
-
ttcross was never compiled or run. b300 has no
gfortran, nompif90, nompirun, and no system LAPACK/BLAS. Everything §2.1 and §3.2 say about ttcross comes from readingdmrgg.f90,lr.f90,rnd.f90,ttind.f90andmain.f90, with line numbers given so the claims can be checked. No timing, no accuracy, and no evaluation count for ttcross appears anywhere in this document, and none should be inferred from the tables, which contain ttpy2, teneva and legacy ttpy only. - The papers behind ttcross were not read. Savostyanov (LAA 2014) and Dolgov–Savostyanov (arXiv:1903.11554) are cited by the repository's README; I read the code, not the papers. In particular the quasioptimality claim that gives the method its name is quoted, not checked.
-
teneva.cross_actwas read but not run. Its own docstring calls it a draft with a known rank-1 bug. Everything §2.2 says about it is from the source. - P4, P6, P7 are unmeasured. They are argued from the ttcross/teneva source and from the mechanism of §1.3. Q2 and Q3 are the experiments that decide P4 and P6; P7 is control flow and has no numerical claim attached.
- The cache break-even (≈1.25 µs/probe) is a property of my prototype, a Python loop over the distinct keys of each batch. A numpy-side implementation would be faster and would move the break-even down. Not measured.
-
The numpy-version divergence of §1.3 was measured, not explained. I
established that the same script with the same seed gives 3.82e-04 under
numpy 2.4.6 and 3.84e-10 under numpy 2.5.1 at seed 1, with the spelling of
nand the wrapping offunruled out as causes (E14). I did not trace which LAPACK call diverges, nor whether the two builds link different BLAS. The conclusion drawn — "the outcome is roundoff-sensitive, so no test may pin a single seed" — is safe under either explanation; the explanation itself is not verified. -
kickrank2 = 2was validated on one function and six seeds (§1.3, §4.4), plus three seeds of a smooth problem where it changes nothing but the cost. It is not a general theorem, and there is no reason to believe there is no function on which it fails; Q1 is the experiment that broadens the evidence. -
The failure of §1.3 is characterised, not explained in the sense of a
theorem. I measured that the error concentrates on 394 entries in a corner
where
$|f|$ is 50× smaller than at the pivots, and that the index sets reach a fixed point. I did not prove that "volume-maximal pivots avoid regions where$|f|$ is relatively small", and the literature reference for that (the quasioptimality bounds carry a$\sqrt{1 + \dots}$ factor in the max norm) was not read. - Nothing was run on a GPU. No claim here applies to the torch backend.
-
float32was not exercised. Every measurement is float64. Thetau,small_pivotandrcondconstants discussed in §3.1/§5 P6 all have precision-dependent values in ttcross (dmrgg.f90:52-58); ours do not, and whether they should is H12, unmeasured. - Single runs on a shared machine. Every wall-clock number in §4 is one run on b300 with other jobs present. Errors, ranks and evaluation counts are deterministic given the seed; times carry perhaps 30 %. The 18× and 3× maxvol ratios are large enough to survive that; the 10–20 % QR-vs-SVD numbers in §4.5 are not, which is another reason §5 does not recommend that change.
-
The legacy comparison is a correctness comparison, not a speed one. §1.3
reports that legacy
rect_crossreproduces the same 3.82e-04 fixed point, on a different numpy (1.24.4). The times in that experiment (0.44–1.60 s) are not comparable to ours: the two runs did different amounts of work (differentfun_eval, different final ranks), and perdocs/PERFORMANCE.md's rule a time comparison at unequal accuracy is not a comparison.