cholqr 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
cholqr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soki Fukuda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cholqr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: cholqr
3
+ Version: 0.1.0
4
+ Summary: Fast, compile-friendly shifted CholeskyQR3 for batched tall-skinny matrices in PyTorch
5
+ Project-URL: Homepage, https://github.com/createcentury/cholqr
6
+ Author: Soki Fukuda
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: cholesky,gpu,muon,pytorch,qr,randomized-svd
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: torch>=2.1
14
+ Description-Content-Type: text/markdown
15
+
16
+ # cholqr
17
+
18
+ Fast, compile-friendly **shifted CholeskyQR3** for batched tall-skinny matrices in PyTorch.
19
+ It is a drop-in for `torch.linalg.qr(Y, mode="reduced")[0]` when you only need an orthonormal basis of `range(Y)`,
20
+ for example in randomized SVD and sketching, low-rank optimizers such as randomized Muon, or subspace iteration.
21
+
22
+ ```python
23
+ import torch
24
+ from cholqr import cholesky_qr
25
+
26
+ Y = torch.randn(40, 768, 210, device="cuda") # batch of tall-skinny matrices
27
+ Q = cholesky_qr(Y) # orthonormal columns, same span as Y
28
+ fast = torch.compile(cholesky_qr, fullgraph=True) # single graph, no host synchronization
29
+ ```
30
+
31
+ ## Why
32
+
33
+ On GPUs, batched Householder QR (cuSOLVER `geqr2`) processes the batch one matrix at a time.
34
+
35
+ On an NVIDIA A100, for the sketches of randomized low-rank Muon on nanoGPT (two weight banks, sketch width 210):
36
+
37
+ | | Householder QR | `cholqr` | full Newton–Schulz (reference) |
38
+ |---|---|---|---|
39
+ | optimizer time per step, both banks, one GPU | 117.6 ms | **15.2 ms** | 17.1 ms |
40
+
41
+ - `torch.linalg.qr` reaches about **1.6% of FP32 peak** here.
42
+ - On the QR step alone, `cholqr` is 3–14x faster.
43
+ - End to end (1490 steps, A100-80GB), replacing the QR removes about 100 ms per step. Randomized Muon then trains as fast as full Muon at unchanged perplexity.
44
+
45
+ Library benchmark on an **NVIDIA Tesla T4** (torch 2.14.1, CUDA 13.0; mean of 20 calls; `benchmarks/bench_qr.py`):
46
+
47
+ | batch x m x r | `torch.linalg.qr` | `cholqr` (compiled) | speedup | `robust=True` | max ‖QᵀQ − I‖_F |
48
+ |---|---|---|---|---|---|
49
+ | 40 x 768 x 210 | 51.7 ms | 10.8 ms | **4.8x** | 45.1 ms | 3.5e-6 |
50
+ | 10 x 3072 x 210 | 23.0 ms | 9.7 ms | **2.4x** | 40.4 ms | 3.5e-6 |
51
+ | 16 x 4096 x 256 | 62.4 ms | 25.5 ms | **2.4x** | 104.2 ms | 4.3e-6 |
52
+ | 4 x 768 x 64 | 1.55 ms | 1.68 ms | 0.9x | 6.2 ms | 1.5e-6 |
53
+
54
+ - The gain comes from batches of moderately wide sketches. For small problems (e.g. 4 x 768 x 64), Householder QR is already fast, so do not expect a speedup there.
55
+ - `robust=True` is slow on the T4 because the T4 has low FP64 throughput. Use it only for ill-conditioned inputs, preferably on A100/H100-class GPUs.
56
+ - The A100 numbers in the table above come from the randomized-Muon training code (same algorithm).
57
+
58
+ Run `python benchmarks/bench_qr.py --compile` on your GPU to measure your own shapes.
59
+
60
+ ## How
61
+
62
+ Shifted CholeskyQR3 (Fukaya et al., 2020) with three implementation choices:
63
+
64
+ 1. **Column scaling before every Gram product.** The shift is scale invariant, and unnormalized inputs do not overflow FP32.
65
+ 2. **Explicit r x r triangular inverse applied as one batched GEMM.** This replaces a slow triangular solve over m rows.
66
+ 3. **`cholesky_ex` with a per-matrix `torch.where` fallback.** There is no host synchronization, and the routine compiles with zero graph breaks.
67
+
68
+ ## Accuracy and range of validity
69
+
70
+ | input condition number | default (FP32) | `robust=True` (FP64 first pass) |
71
+ |---|---|---|
72
+ | up to 1e6 | ‖QᵀQ − I‖_F ≈ 1e-5 | ‖QᵀQ − I‖_F ≈ 1e-5 |
73
+ | 5e6 – 1e8 | **breaks down** (orthogonality lost) | ‖QᵀQ − I‖_F ≈ 1e-5, subspace error ≈ 2e-6 |
74
+
75
+ These numbers were tested on 3072 x 210 inputs with geometric and power-law spectra (`tests/`).
76
+
77
+ - The default first-pass shift `1e-6 * tr(G)` is a heuristic. The a-priori shift of Fukaya et al. is impractically large in FP32 for large m·r.
78
+ - `robust=True` costs one FP64 Gram product. That is cheap on data-center GPUs (A100/H100) and slow on consumer GPUs.
79
+ - **Real optimizer states** (27 nanoGPT momentum snapshots, sketch width 210, bf16 power iteration as in training):
80
+ - κ(Y) ≤ 4.5×10⁴.
81
+ - No fallback was triggered.
82
+ - The final low-rank update differs from an FP64 Householder reference by at most 7.6×10⁻⁵ (relative).
83
+ - Sketches computed in bf16 are typically well inside the default range, because bf16 rounding caps their effective condition number.
84
+ - Exactly rank-deficient or zero inputs return finite outputs (not an orthonormal basis of a larger space).
85
+ - Real dtypes only; complex inputs raise `TypeError`. Autograd works (gradients agree with Householder QR to ~1e-7 in tests). Runs on CUDA, CPU and Apple MPS.
86
+ - Status: **experimental (0.x)**. The API may change. Issues and benchmarks on other GPUs are welcome.
87
+
88
+ ```python
89
+ Q = cholesky_qr(Y) # fast default, cond(Y) <~ 1e6
90
+ Q = cholesky_qr(Y, robust=True) # ill-conditioned inputs, cond(Y) up to >= 1e8
91
+ ```
92
+
93
+ ## Install
94
+
95
+ ```bash
96
+ pip install cholqr # or: pip install git+https://github.com/createcentury/cholqr
97
+ ```
98
+
99
+ ## Citation
100
+
101
+ Please cite the algorithm:
102
+
103
+ > T. Fukaya, R. Kannan, Y. Nakatsukasa, Y. Yamamoto, Y. Yanagisawa. *Shifted Cholesky QR for computing the QR
104
+ > factorization of ill-conditioned matrices.* SIAM J. Sci. Comput. 42(1):A477–A503, 2020.
105
+
106
+ > Y. Yamamoto, Y. Nakatsukasa, Y. Yanagisawa, T. Fukaya. *Roundoff error analysis of the CholeskyQR2 algorithm.*
107
+ > Electron. Trans. Numer. Anal. 44:306–326, 2015.
108
+
109
+ ## License
110
+
111
+ MIT
cholqr-0.1.0/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # cholqr
2
+
3
+ Fast, compile-friendly **shifted CholeskyQR3** for batched tall-skinny matrices in PyTorch.
4
+ It is a drop-in for `torch.linalg.qr(Y, mode="reduced")[0]` when you only need an orthonormal basis of `range(Y)`,
5
+ for example in randomized SVD and sketching, low-rank optimizers such as randomized Muon, or subspace iteration.
6
+
7
+ ```python
8
+ import torch
9
+ from cholqr import cholesky_qr
10
+
11
+ Y = torch.randn(40, 768, 210, device="cuda") # batch of tall-skinny matrices
12
+ Q = cholesky_qr(Y) # orthonormal columns, same span as Y
13
+ fast = torch.compile(cholesky_qr, fullgraph=True) # single graph, no host synchronization
14
+ ```
15
+
16
+ ## Why
17
+
18
+ On GPUs, batched Householder QR (cuSOLVER `geqr2`) processes the batch one matrix at a time.
19
+
20
+ On an NVIDIA A100, for the sketches of randomized low-rank Muon on nanoGPT (two weight banks, sketch width 210):
21
+
22
+ | | Householder QR | `cholqr` | full Newton–Schulz (reference) |
23
+ |---|---|---|---|
24
+ | optimizer time per step, both banks, one GPU | 117.6 ms | **15.2 ms** | 17.1 ms |
25
+
26
+ - `torch.linalg.qr` reaches about **1.6% of FP32 peak** here.
27
+ - On the QR step alone, `cholqr` is 3–14x faster.
28
+ - End to end (1490 steps, A100-80GB), replacing the QR removes about 100 ms per step. Randomized Muon then trains as fast as full Muon at unchanged perplexity.
29
+
30
+ Library benchmark on an **NVIDIA Tesla T4** (torch 2.14.1, CUDA 13.0; mean of 20 calls; `benchmarks/bench_qr.py`):
31
+
32
+ | batch x m x r | `torch.linalg.qr` | `cholqr` (compiled) | speedup | `robust=True` | max ‖QᵀQ − I‖_F |
33
+ |---|---|---|---|---|---|
34
+ | 40 x 768 x 210 | 51.7 ms | 10.8 ms | **4.8x** | 45.1 ms | 3.5e-6 |
35
+ | 10 x 3072 x 210 | 23.0 ms | 9.7 ms | **2.4x** | 40.4 ms | 3.5e-6 |
36
+ | 16 x 4096 x 256 | 62.4 ms | 25.5 ms | **2.4x** | 104.2 ms | 4.3e-6 |
37
+ | 4 x 768 x 64 | 1.55 ms | 1.68 ms | 0.9x | 6.2 ms | 1.5e-6 |
38
+
39
+ - The gain comes from batches of moderately wide sketches. For small problems (e.g. 4 x 768 x 64), Householder QR is already fast, so do not expect a speedup there.
40
+ - `robust=True` is slow on the T4 because the T4 has low FP64 throughput. Use it only for ill-conditioned inputs, preferably on A100/H100-class GPUs.
41
+ - The A100 numbers in the table above come from the randomized-Muon training code (same algorithm).
42
+
43
+ Run `python benchmarks/bench_qr.py --compile` on your GPU to measure your own shapes.
44
+
45
+ ## How
46
+
47
+ Shifted CholeskyQR3 (Fukaya et al., 2020) with three implementation choices:
48
+
49
+ 1. **Column scaling before every Gram product.** The shift is scale invariant, and unnormalized inputs do not overflow FP32.
50
+ 2. **Explicit r x r triangular inverse applied as one batched GEMM.** This replaces a slow triangular solve over m rows.
51
+ 3. **`cholesky_ex` with a per-matrix `torch.where` fallback.** There is no host synchronization, and the routine compiles with zero graph breaks.
52
+
53
+ ## Accuracy and range of validity
54
+
55
+ | input condition number | default (FP32) | `robust=True` (FP64 first pass) |
56
+ |---|---|---|
57
+ | up to 1e6 | ‖QᵀQ − I‖_F ≈ 1e-5 | ‖QᵀQ − I‖_F ≈ 1e-5 |
58
+ | 5e6 – 1e8 | **breaks down** (orthogonality lost) | ‖QᵀQ − I‖_F ≈ 1e-5, subspace error ≈ 2e-6 |
59
+
60
+ These numbers were tested on 3072 x 210 inputs with geometric and power-law spectra (`tests/`).
61
+
62
+ - The default first-pass shift `1e-6 * tr(G)` is a heuristic. The a-priori shift of Fukaya et al. is impractically large in FP32 for large m·r.
63
+ - `robust=True` costs one FP64 Gram product. That is cheap on data-center GPUs (A100/H100) and slow on consumer GPUs.
64
+ - **Real optimizer states** (27 nanoGPT momentum snapshots, sketch width 210, bf16 power iteration as in training):
65
+ - κ(Y) ≤ 4.5×10⁴.
66
+ - No fallback was triggered.
67
+ - The final low-rank update differs from an FP64 Householder reference by at most 7.6×10⁻⁵ (relative).
68
+ - Sketches computed in bf16 are typically well inside the default range, because bf16 rounding caps their effective condition number.
69
+ - Exactly rank-deficient or zero inputs return finite outputs (not an orthonormal basis of a larger space).
70
+ - Real dtypes only; complex inputs raise `TypeError`. Autograd works (gradients agree with Householder QR to ~1e-7 in tests). Runs on CUDA, CPU and Apple MPS.
71
+ - Status: **experimental (0.x)**. The API may change. Issues and benchmarks on other GPUs are welcome.
72
+
73
+ ```python
74
+ Q = cholesky_qr(Y) # fast default, cond(Y) <~ 1e6
75
+ Q = cholesky_qr(Y, robust=True) # ill-conditioned inputs, cond(Y) up to >= 1e8
76
+ ```
77
+
78
+ ## Install
79
+
80
+ ```bash
81
+ pip install cholqr # or: pip install git+https://github.com/createcentury/cholqr
82
+ ```
83
+
84
+ ## Citation
85
+
86
+ Please cite the algorithm:
87
+
88
+ > T. Fukaya, R. Kannan, Y. Nakatsukasa, Y. Yamamoto, Y. Yanagisawa. *Shifted Cholesky QR for computing the QR
89
+ > factorization of ill-conditioned matrices.* SIAM J. Sci. Comput. 42(1):A477–A503, 2020.
90
+
91
+ > Y. Yamamoto, Y. Nakatsukasa, Y. Yanagisawa, T. Fukaya. *Roundoff error analysis of the CholeskyQR2 algorithm.*
92
+ > Electron. Trans. Numer. Anal. 44:306–326, 2015.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,20 @@
1
+ """Benchmark cholqr.cholesky_qr against torch.linalg.qr on batched tall-skinny matrices (run on a GPU).
2
+ Usage: python benchmarks/bench_qr.py [--shapes 40x768x210,10x3072x210] [--compile]"""
3
+ import argparse, time, torch
4
+ from cholqr import cholesky_qr
5
+
6
+ ap = argparse.ArgumentParser(); ap.add_argument("--shapes", default="40x768x210,10x3072x210"); ap.add_argument("--compile", action="store_true")
7
+ a = ap.parse_args(); dev = "cuda" if torch.cuda.is_available() else "cpu"
8
+ fn = torch.compile(cholesky_qr, fullgraph=True) if a.compile else cholesky_qr
9
+ def timeit(f, Y, n=20):
10
+ for _ in range(3): f(Y)
11
+ if dev == "cuda": torch.cuda.synchronize()
12
+ t = time.perf_counter()
13
+ for _ in range(n): f(Y)
14
+ if dev == "cuda": torch.cuda.synchronize()
15
+ return (time.perf_counter() - t) / n * 1e3
16
+ for sh in a.shapes.split(","):
17
+ b, m, r = map(int, sh.split("x")); Y = torch.randn(b, m, r, device=dev)
18
+ th = timeit(lambda X: torch.linalg.qr(X, mode="reduced")[0], Y); tc = timeit(fn, Y)
19
+ Q = fn(Y); err = (Q.mT @ Q - torch.eye(r, device=dev)).norm(dim=(-2, -1)).max().item()
20
+ print(f"{dev} {b}x{m}x{r}: torch.linalg.qr {th:7.2f} ms | cholqr {tc:7.2f} ms | speedup {th/tc:5.1f}x | max ||Q^TQ-I||_F {err:.1e}")
@@ -0,0 +1,5 @@
1
+ Tesla T4, torch 2.14.1+cu130 (Colab), mean of 20 calls after 3 warmups
2
+ 40x768x210: torch.linalg.qr 51.66 ms | cholqr eager 11.26 | compiled 10.77 | robust 45.10 ms | speedup 4.8x | max||QtQ-I|| 3.5e-06
3
+ 10x3072x210: torch.linalg.qr 22.96 ms | cholqr eager 8.99 | compiled 9.70 | robust 40.44 ms | speedup 2.4x | max||QtQ-I|| 3.5e-06
4
+ 4x768x64: torch.linalg.qr 1.55 ms | cholqr eager 2.61 | compiled 1.68 | robust 6.15 ms | speedup 0.9x | max||QtQ-I|| 1.5e-06
5
+ 16x4096x256: torch.linalg.qr 62.35 ms | cholqr eager 23.14 | compiled 25.48 | robust 104.20 ms | speedup 2.4x | max||QtQ-I|| 4.3e-06
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cholqr"
7
+ version = "0.1.0"
8
+ description = "Fast, compile-friendly shifted CholeskyQR3 for batched tall-skinny matrices in PyTorch"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Soki Fukuda" }]
13
+ dependencies = ["torch>=2.1"]
14
+ keywords = ["qr", "cholesky", "randomized-svd", "muon", "pytorch", "gpu"]
15
+ classifiers = ["Programming Language :: Python :: 3", "Topic :: Scientific/Engineering :: Mathematics"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/createcentury/cholqr"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/cholqr"]
@@ -0,0 +1,12 @@
1
+ """cholqr: fast, compile-friendly shifted CholeskyQR3 for batched tall-skinny matrices in PyTorch.
2
+
3
+ Replaces ``torch.linalg.qr(Y, mode="reduced")[0]`` when only an orthonormal basis of ``range(Y)`` is needed
4
+ (randomized SVD / sketching, low-rank optimizers such as randomized Muon, subspace iteration).
5
+
6
+ Algorithm: shifted CholeskyQR3 (Fukaya, Kannan, Nakatsukasa, Yamamoto, Yanagisawa, SIAM J. Sci. Comput. 42(1), 2020)
7
+ built on CholeskyQR2 (Yamamoto, Nakatsukasa, Yanagisawa, Fukaya, ETNA 44, 2015).
8
+ """
9
+ from .core import cholesky_qr, orthonormal_basis
10
+
11
+ __all__ = ["cholesky_qr", "orthonormal_basis"]
12
+ __version__ = "0.1.0"
@@ -0,0 +1,68 @@
1
+ import torch
2
+
3
+ __all__ = ["cholesky_qr", "orthonormal_basis"]
4
+
5
+
6
+ def cholesky_qr(Y: torch.Tensor, shift: float = 1e-6, passes: int = 3, out_dtype=None,
7
+ robust: bool = False) -> torch.Tensor:
8
+ """Orthonormal basis Q of range(Y) for a (batch of) tall-skinny matrix Y of shape (..., m, r), r <= m.
9
+
10
+ Shifted CholeskyQR3 with three implementation choices that make it fast on GPUs:
11
+
12
+ * every pass rescales the columns before forming the Gram matrix (scale invariant, no FP32 overflow);
13
+ * the r x r triangular inverse is formed explicitly and applied as one batched GEMM (a triangular solve
14
+ over m rows is slow on GPUs);
15
+ * ``torch.linalg.cholesky_ex`` is used and a failed pass is skipped per matrix with ``torch.where``, so the
16
+ routine never synchronizes with the host and compiles into a single graph under ``torch.compile``.
17
+
18
+ Pass 1 uses the shift ``shift * tr(G)`` (a heuristic, not the a-priori shift of Fukaya et al., whose FP32 value is
19
+ impractically large for large m*r); later passes use ``(u / r) * tr(G)`` with u the FP32 unit roundoff, restoring
20
+ ``||Q^T Q - I||`` to O(u). Computation is in FP32; the result is cast to ``out_dtype`` (default: the dtype of Y).
21
+
22
+ Range of validity (tested on 3072 x 210 inputs with geometric and power-law spectra):
23
+
24
+ * default (FP32): reliable for cond(Y) up to about 1e6. Beyond ~5e6 the factorization breaks down
25
+ (orthogonality is lost and passes are skipped). Inputs that come from bf16 sketches are usually fine further,
26
+ because bf16 rounding caps their effective condition number.
27
+ * ``robust=True``: the first pass runs in FP64 with a tiny shift, which handles cond(Y) up to at least 1e8 with
28
+ ``||Q^T Q - I||_F ~ 1e-5``. It costs one FP64 Gram product (cheap on data-center GPUs, slow on consumer GPUs).
29
+
30
+ Args:
31
+ Y: tensor of shape (..., m, r) with r <= m.
32
+ shift: relative shift of the first pass.
33
+ passes: number of CholeskyQR passes (3 = shifted CholeskyQR3).
34
+ out_dtype: dtype of the result.
35
+ robust: run the first pass in FP64 (see above).
36
+ Returns:
37
+ Q of shape (..., m, r) with orthonormal columns spanning range(Y) (when Y has full column rank).
38
+ """
39
+ if Y.dim() < 2:
40
+ raise ValueError("Y must have at least 2 dimensions (..., m, r)")
41
+ if Y.is_complex():
42
+ raise TypeError("cholesky_qr supports real tensors only (got a complex dtype)")
43
+ m, r = Y.shape[-2], Y.shape[-1]
44
+ if r > m:
45
+ raise ValueError(f"cholesky_qr expects tall-skinny input (r <= m), got m={m}, r={r}")
46
+ u = torch.finfo(torch.float32).eps
47
+ Q = Y.double() if robust else Y.float()
48
+ for i in range(passes):
49
+ if robust and i == 1:
50
+ Q = Q.float()
51
+ eye = torch.eye(r, device=Q.device, dtype=Q.dtype)
52
+ Q = Q / Q.norm(dim=-2, keepdim=True).clamp_min(1e-30)
53
+ G = Q.mT @ Q
54
+ tr = G.diagonal(dim1=-2, dim2=-1).sum(dim=-1)[..., None, None]
55
+ if i == 0:
56
+ s = (1e-14 if robust else shift) * tr
57
+ else:
58
+ s = (u / r) * tr
59
+ L, info = torch.linalg.cholesky_ex(G + (s + 1e-30) * eye)
60
+ L_inv = torch.linalg.solve_triangular(L, eye.expand_as(L), upper=False)
61
+ Q_next = Q @ L_inv.mT
62
+ Q = torch.where((info == 0)[..., None, None], Q_next, Q)
63
+ return Q.float().to(Y.dtype if out_dtype is None else out_dtype)
64
+
65
+
66
+ def orthonormal_basis(Y: torch.Tensor, **kwargs) -> torch.Tensor:
67
+ """Alias of :func:`cholesky_qr` (drop-in for ``torch.linalg.qr(Y, mode="reduced")[0]`` when only Q is used)."""
68
+ return cholesky_qr(Y, **kwargs)
@@ -0,0 +1,95 @@
1
+ import math
2
+ import pytest
3
+ import torch
4
+ from cholqr import cholesky_qr
5
+
6
+
7
+ def make_spectrum(m, r, kappa, kind, seed=0):
8
+ g = torch.Generator().manual_seed(seed)
9
+ U = torch.linalg.qr(torch.randn(m, r, generator=g, dtype=torch.float64))[0]
10
+ V = torch.linalg.qr(torch.randn(r, r, generator=g, dtype=torch.float64))[0]
11
+ if kind == "geometric":
12
+ s = torch.logspace(0, -math.log10(kappa), r, dtype=torch.float64)
13
+ else:
14
+ s = torch.arange(1, r + 1, dtype=torch.float64) ** -(math.log(kappa) / math.log(r))
15
+ return ((U * s) @ V.mT).float()
16
+
17
+
18
+ def orth_err(Q):
19
+ Q = Q.double()
20
+ return (Q.mT @ Q - torch.eye(Q.shape[-1], dtype=torch.float64)).norm(dim=(-2, -1)).max().item()
21
+
22
+
23
+ def subspace_err(Q, Y):
24
+ Qr = torch.linalg.qr(Y.double())[0]
25
+ return torch.linalg.matrix_norm(Q.double() @ Q.double().mT - Qr @ Qr.mT, ord=2).max().item()
26
+
27
+
28
+ @pytest.mark.parametrize("kind", ["geometric", "powerlaw"])
29
+ @pytest.mark.parametrize("kappa", [1e1, 1e3, 1e5, 1e6])
30
+ def test_default_fp32_up_to_1e6(kind, kappa):
31
+ Y = make_spectrum(3072, 210, kappa, kind)
32
+ Q = cholesky_qr(Y)
33
+ assert orth_err(Q) < 5e-5
34
+ assert subspace_err(Q, Y) < 1e-2 * max(1.0, kappa / 1e5)
35
+
36
+
37
+ @pytest.mark.parametrize("kind", ["geometric", "powerlaw"])
38
+ @pytest.mark.parametrize("kappa", [1e6, 1e7, 1e8])
39
+ def test_robust_up_to_1e8(kind, kappa):
40
+ Y = make_spectrum(3072, 210, kappa, kind)
41
+ Q = cholesky_qr(Y, robust=True)
42
+ assert orth_err(Q) < 5e-5
43
+ assert subspace_err(Q, Y) < 1e-4
44
+
45
+
46
+ def test_default_breaks_down_beyond_range():
47
+ # documents the limitation: plain FP32 loses orthogonality for cond ~ 1e7
48
+ Y = make_spectrum(3072, 210, 1e7, "geometric")
49
+ assert orth_err(cholesky_qr(Y)) > 1.0
50
+
51
+
52
+ def test_batched_matches_unbatched():
53
+ Y = torch.stack([make_spectrum(768, 64, 1e3, "powerlaw", seed=k) for k in range(4)])
54
+ Qb = cholesky_qr(Y)
55
+ for k in range(4):
56
+ assert torch.allclose(Qb[k], cholesky_qr(Y[k]), atol=1e-5)
57
+
58
+
59
+ def test_degenerate_inputs_are_finite():
60
+ Z = torch.zeros(64, 8)
61
+ assert torch.isfinite(cholesky_qr(Z)).all()
62
+ Y = torch.randn(64, 4) @ torch.randn(4, 8) # exactly rank 4 < 8
63
+ assert torch.isfinite(cholesky_qr(Y)).all()
64
+
65
+
66
+ def test_dtypes():
67
+ Y = torch.randn(128, 16)
68
+ assert cholesky_qr(Y.bfloat16()).dtype == torch.bfloat16
69
+ assert cholesky_qr(Y, out_dtype=torch.float64).dtype == torch.float64
70
+
71
+
72
+ def test_rejects_wide_input():
73
+ with pytest.raises(ValueError):
74
+ cholesky_qr(torch.randn(8, 16))
75
+
76
+
77
+ @pytest.mark.parametrize("robust", [False, True])
78
+ def test_compiles_without_graph_breaks(robust):
79
+ explain = torch._dynamo.explain(cholesky_qr)(torch.randn(256, 32), robust=robust)
80
+ assert explain.graph_break_count == 0
81
+
82
+
83
+ def test_rejects_complex():
84
+ with pytest.raises(TypeError):
85
+ cholesky_qr(torch.randn(64, 8, dtype=torch.complex64))
86
+
87
+
88
+ def test_autograd_matches_householder():
89
+ torch.manual_seed(0)
90
+ Y = torch.randn(64, 8, dtype=torch.float64, requires_grad=True)
91
+ W = torch.randn(64, 64, dtype=torch.float64)
92
+ loss = lambda Q: ((Q @ Q.mT) * W).sum() # sign-invariant
93
+ g1, = torch.autograd.grad(loss(cholesky_qr(Y, out_dtype=torch.float64)), Y)
94
+ g2, = torch.autograd.grad(loss(torch.linalg.qr(Y)[0]), Y)
95
+ assert ((g1 - g2).norm() / g2.norm()).item() < 1e-5