Skip to content

Commit 9ab27ce

Browse files
skywmkhona-nvidia
authored andcommitted
Improve eig utils (#190)
* move sort to its own function Signed-off-by: Hao Wu <skyw@nvidia.com> * move eigen bases based sort to its own function and share between QR and eigh path Signed-off-by: Hao Wu <skyw@nvidia.com> Signed-off-by: mikail <mkhona@nvidia.com>
1 parent b89585e commit 9ab27ce

5 files changed

Lines changed: 137 additions & 100 deletions

File tree

emerging_optimizers/soap/soap.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -468,17 +468,28 @@ def update_eigenbasis_and_exp_avgs(
468468
dims=[[0], [1]],
469469
)
470470

471-
# Step 2: Update eigenbases
471+
# Step 2a: Sort current eigenbases by descending approximate eigenvalues of the updated kronecker
472+
# factors, and permute exp_avg_sq.
473+
# Shared by both eigh and QR paths so the new eigh-path approximation matches the QR-path slot semantics
474+
# under small per-step drift.
475+
# Sorting eigenbases is not necessary for eigh path technically, but decided to keep API simple.
476+
eigenbasis_list, exp_avg_sq = soap_utils.sort_eigenbasis_by_approx_eigvals(
477+
kronecker_factor_list,
478+
eigenbasis_list,
479+
exp_avg_sq,
480+
)
481+
482+
# Step 2b: Update eigenbases
472483
if use_eigh:
473484
updated_eigenbasis_list = soap_utils.get_eigenbasis_eigh(
474485
kronecker_factor_list,
475486
)
476487
else:
477-
# Use QR decomposition and power iteration (orthogonal iteration)
478-
updated_eigenbasis_list, exp_avg_sq = soap_utils.get_eigenbasis_qr(
488+
# Use QR decomposition and power iteration (orthogonal iteration) starting from the
489+
# pre-sorted eigenbases.
490+
updated_eigenbasis_list = soap_utils.get_eigenbasis_qr(
479491
kronecker_factor_list,
480492
eigenbasis_list,
481-
exp_avg_sq,
482493
power_iter_steps,
483494
)
484495

emerging_optimizers/soap/soap_utils.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,41 @@
2525
__all__ = [
2626
"get_eigenbasis_eigh",
2727
"get_eigenbasis_qr",
28+
"sort_eigenbasis_by_approx_eigvals",
2829
]
2930

3031

32+
def sort_eigenbasis_by_approx_eigvals(
33+
kronecker_factor_list: TensorList,
34+
eigenbasis_list: TensorList,
35+
exp_avg_sq: torch.Tensor,
36+
) -> tuple[TensorList, torch.Tensor]:
37+
"""Permute each eigenbasis and matching ``exp_avg_sq`` axis by descending approximate eigenvalues.
38+
39+
Both the eigh and QR eigenbasis-update paths consume the sorted output: the eigh path discards
40+
the permuted eigenbasis but uses the permuted ``exp_avg_sq`` (the sort_idx best approximates the
41+
permutation component of the eigh-vs-old basis change under small drift); the QR path power-
42+
iterates from the pre-sorted basis.
43+
44+
Args:
45+
kronecker_factor_list: List of preconditioner matrices (L and R).
46+
eigenbasis_list: List of current eigenbases (QL and QR).
47+
exp_avg_sq: Inner Adam second moment tensor permuted along each Kronecker-factor
48+
axis to match the new descending-eigenvalue column ordering.
49+
50+
Returns:
51+
``(sorted_eigenbasis_list, sorted_exp_avg_sq)``.
52+
"""
53+
sorted_eigenbasis_list: TensorList = []
54+
sorted_exp_avg_sq = exp_avg_sq
55+
for ind, (kronecker_factor, eigenbasis) in enumerate(zip(kronecker_factor_list, eigenbasis_list, strict=True)):
56+
approx_eigvals = eig_utils.conjugate(kronecker_factor, eigenbasis, diag=True)
57+
sort_idx = torch.argsort(approx_eigvals, descending=True)
58+
sorted_eigenbasis_list.append(eigenbasis[:, sort_idx])
59+
sorted_exp_avg_sq = sorted_exp_avg_sq.index_select(ind, sort_idx)
60+
return sorted_eigenbasis_list, sorted_exp_avg_sq
61+
62+
3163
def get_eigenbasis_eigh(
3264
kronecker_factor_list: TensorList,
3365
) -> TensorList:
@@ -64,23 +96,22 @@ def get_eigenbasis_eigh(
6496
def get_eigenbasis_qr(
6597
kronecker_factor_list: TensorList,
6698
eigenbasis_list: TensorList,
67-
exp_avg_sq: torch.Tensor,
6899
power_iter_steps: int = 1,
69-
) -> tuple[TensorList, torch.Tensor]:
100+
) -> TensorList:
70101
"""Updates the eigenbases of the preconditioner using power iteration and QR.
71102
72103
Computes using multiple rounds of power iteration followed by QR decomposition (orthogonal iteration).
104+
``eigenbasis_list`` is expected to be already sorted by descending approximate eigenvalues (see
105+
:func:`sort_eigenbasis_by_approx_eigvals`).
73106
74107
Args:
75-
kronecker_factor_list: List containing preconditioner (:math:`GG^T` and :math:`G^TG`)
76-
eigenbasis_list: List containing eigenbases (:math:`Q_L` and :math:`Q_R`)
77-
exp_avg_sq: inner adam second moment (exp_avg_sq).
108+
kronecker_factor_list: List of preconditioner matrices (L and R).
109+
eigenbasis_list: List of current eigenbases (QL and QR).
78110
power_iter_steps: Number of power iteration steps to perform before QR decomposition.
79111
More steps can lead to better convergence but increased computation time.
80112
81113
Returns:
82-
Tuple of updated list of orthonormal kronecker factor eigenbases matrices and updated (sorted) inner
83-
Adam's second moment.
114+
Updated list of orthonormal eigenbases (QL and QR).
84115
85116
Example:
86117
.. code-block:: python
@@ -102,27 +133,19 @@ def get_eigenbasis_qr(
102133
perturbed_kronecker_factor_list[0] = k_factor1 + perturbation@perturbation.T
103134
perturbed_kronecker_factor_list[1] = k_factor2 + perturbation.T@perturbation
104135
105-
# Initialize exp_avg_sq tensor
106-
exp_avg_sq = torch.randn(n, m).abs()
107-
108-
# Refine the orthogonal matrices using QR
109-
updated_ortho_matrices, updated_exp_avg_sq = get_eigenbasis_qr(
136+
# Refine the orthogonal matrices using QR (eigenbasis_list already sorted)
137+
updated_ortho_matrices = get_eigenbasis_qr(
110138
perturbed_kronecker_factor_list,
111139
eigenbasis_list,
112-
exp_avg_sq
113140
)
114141
"""
115142
updated_eigenbasis_list: TensorList = []
116-
for ind, (kronecker_factor, eigenbasis) in enumerate(zip(kronecker_factor_list, eigenbasis_list, strict=True)):
117-
approx_eigvals = eig_utils.conjugate(kronecker_factor, eigenbasis, diag=True)
118-
Q, exp_avg_sq = eig_utils.orthogonal_iteration(
119-
approx_eigvals=approx_eigvals,
143+
for kronecker_factor, eigenbasis in zip(kronecker_factor_list, eigenbasis_list, strict=True):
144+
Q = eig_utils.orthogonal_iteration(
120145
kronecker_factor=kronecker_factor,
121146
eigenbasis=eigenbasis,
122-
ind=ind,
123-
exp_avg_sq=exp_avg_sq,
124147
power_iter_steps=power_iter_steps,
125148
)
126149
updated_eigenbasis_list.append(Q)
127150

128-
return updated_eigenbasis_list, exp_avg_sq
151+
return updated_eigenbasis_list

emerging_optimizers/utils/eig.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -69,51 +69,35 @@ def eigh_with_fallback(
6969

7070

7171
def orthogonal_iteration(
72-
approx_eigvals: torch.Tensor,
73-
kronecker_factor: torch.Tensor,
74-
eigenbasis: torch.Tensor,
75-
ind: int,
76-
exp_avg_sq: torch.Tensor,
72+
kronecker_factor: Tensor,
73+
eigenbasis: Tensor,
7774
power_iter_steps: int,
78-
) -> tuple[torch.Tensor, torch.Tensor]:
79-
"""Computes the eigenbases of the preconditioner using power iteration and QR decomposition.
75+
) -> Tensor:
76+
"""Refines an eigenbasis via power iteration with QR re-orthogonalization.
8077
81-
This function performs multiple rounds of power iteration followed by QR decomposition
82-
to recompute the eigenbases of the preconditioner kronecker factor. Generalizes Vyas et al.'s (SOAP) algorithm of 1 step of power iteration for updating the eigenbasis.
78+
Performs ``power_iter_steps`` rounds of ``Q = QR(kronecker_factor @ Q)`` starting from
79+
``eigenbasis``. The columns of ``eigenbasis`` are expected to already be aligned with the
80+
intended descending-eigenvalue ordering of ``kronecker_factor`` (see
81+
:func:`emerging_optimizers.soap.soap_utils.sort_eigenbasis_by_approx_eigvals`).
8382
8483
Args:
85-
approx_eigvals : Projection of kronecker factor onto the eigenbasis, should be close to diagonal
86-
kronecker_factor : Kronecker factor matrix.
87-
eigenbasis : Kronecker factor eigenbasis matrix.
88-
ind : Index for selecting dimension in the exp_avg_sq matrix to apply the sorting order over.
89-
exp_avg_sq : inner Adam second moment (exp_avg_sq).
90-
power_iter_steps: Number of power iteration steps to perform before QR decomposition.
91-
More steps can lead to better convergence but increased computation time.
84+
kronecker_factor: Kronecker factor matrix (symmetric, used as the projector).
85+
eigenbasis: Starting eigenbasis whose columns will be refined.
86+
power_iter_steps: Number of power-iteration / QR rounds to perform.
9287
9388
Returns:
94-
tuple[torch.Tensor, torch.Tensor]: A tuple containing:
95-
- Q: The updated eigenbasis
96-
- exp_avg_sq: The updated (sorted) inner Adam second moment
89+
The refined eigenbasis.
9790
"""
98-
# Sort the approximated eigenvalues according to their magnitudes
99-
sort_idx = torch.argsort(approx_eigvals, descending=True)
100-
# re-order the inner adam second moment
101-
exp_avg_sq = exp_avg_sq.index_select(ind, sort_idx)
102-
103-
# Initialize power iteration after sorting the columns of the eigenbasis matrix according to the descending eigenvalues
104-
Q = eigenbasis[:, sort_idx]
105-
106-
# Perform multiple steps of power iteration
91+
Q = eigenbasis
10792
for _ in range(power_iter_steps):
10893
# Project current eigenbases on kronecker factor
10994
Q = kronecker_factor @ Q
11095
# Perform QR to maintain orthogonality between iterations
11196
Q = torch.linalg.qr(Q).Q
112-
113-
return Q, exp_avg_sq
97+
return Q
11498

11599

116-
def conjugate(a: torch.Tensor, p: torch.Tensor, diag: bool = False) -> torch.Tensor:
100+
def conjugate(a: Tensor, p: Tensor, diag: bool = False) -> Tensor:
117101
"""Calculate similarity transformation
118102
119103
This function calculates :math:`B = P^T A P`. It assumes P is orthogonal so that :math:`P^{-1} = P^T` and

tests/test_eig_utils.py

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,14 @@ def test_update_eigenbasis_with_QR(self, N: int, power_iter_steps: int) -> None:
6262
eigenbasis = torch.randn(N, N, device=self.device)
6363
eigenbasis = torch.linalg.qr(eigenbasis).Q
6464

65-
# Create inner adam second moment (should be positive)
66-
exp_avg_sq = torch.abs(torch.randn(N, N, device=self.device))
67-
68-
# Create estimated eigenvalue matrix by projecting kronecker_factor onto eigenbasis's basis
69-
approx_eigenvalue_matrix = eigenbasis.T.mm(kronecker_factor).mm(eigenbasis)
70-
# Extract eigenvalues from the diagonal of the estimated eigenvalue matrix
71-
approx_eigvals = torch.diag(approx_eigenvalue_matrix)
72-
73-
# Call the QR function to update the eigenbases and re-order the inner adam second moment
74-
Q_new, exp_avg_sq_new = eig_utils.orthogonal_iteration(
75-
approx_eigvals=approx_eigvals,
65+
Q_new = eig_utils.orthogonal_iteration(
7666
kronecker_factor=kronecker_factor,
7767
eigenbasis=eigenbasis,
78-
ind=0, # Test with first dimension
79-
exp_avg_sq=exp_avg_sq,
8068
power_iter_steps=power_iter_steps,
8169
)
8270

83-
# Test 1: Check output shapes
71+
# Test 1: Check output shape
8472
self.assertEqual(Q_new.shape, (N, N))
85-
self.assertEqual(exp_avg_sq_new.shape, exp_avg_sq.shape)
8673

8774
# Test 2: Check orthogonality (Q^T Q ≈ I)
8875
expected_identity = torch.eye(N, dtype=Q_new.dtype, device=self.device)
@@ -94,20 +81,7 @@ def test_update_eigenbasis_with_QR(self, N: int, power_iter_steps: int) -> None:
9481
msg="Orthogonalization failed: Q^T Q is not close enough to the identity matrix.",
9582
)
9683

97-
# Test 3: Check that exp_avg_sq is properly sorted based on eigenvalues
98-
# The sorting should be based on the diagonal elements of estimated_eigenvalue_matrix
99-
sort_idx = torch.argsort(approx_eigvals, descending=True)
100-
expected_exp_avg_sq = exp_avg_sq.index_select(0, sort_idx)
101-
torch.testing.assert_close(
102-
exp_avg_sq_new,
103-
expected_exp_avg_sq,
104-
atol=1e-5,
105-
rtol=1e-5,
106-
msg="exp_avg_sq was not properly sorted based on eigenvalues.",
107-
)
108-
109-
# Test 4: Check that Q_new is different from input (transformation occurred)
110-
# This is a basic check - in practice they should be different due to power iteration
84+
# Test 3: Check that Q_new is different from input (power iteration ran)
11185
self.assertFalse(torch.allclose(Q_new, eigenbasis))
11286

11387
def test_eigh_with_fallback_descending_order(self) -> None:

tests/test_soap_utils.py

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,15 @@ def test_get_eigenbasis_qr(self, N: int, M: int) -> None:
5555
L = g.mm(g.t()).float()
5656
R = g.t().mm(g).float()
5757
# Fake preconditioner list and orth list in the state
58-
state = {
59-
"GG": [L, R], # precondition matrix
60-
"Q": [
61-
torch.randn(M, M, device=self.device),
62-
torch.randn(N, N, device=self.device),
63-
], # an existing Q (we'll refine it using QR)
64-
"exp_avg_sq": torch.abs(torch.randn(M, N, device=self.device)), # Some arbitrary tensor for example
65-
}
66-
67-
# We'll call get_eigenbasis_qr
68-
Q_new_list, exp_avg_sq_new = soap_utils.get_eigenbasis_qr(
69-
kronecker_factor_list=state["GG"],
70-
eigenbasis_list=state["Q"],
71-
exp_avg_sq=state["exp_avg_sq"],
58+
kronecker_factor_list = [L, R]
59+
eigenbasis_list = [
60+
torch.randn(M, M, device=self.device),
61+
torch.randn(N, N, device=self.device),
62+
]
63+
64+
Q_new_list = soap_utils.get_eigenbasis_qr(
65+
kronecker_factor_list=kronecker_factor_list,
66+
eigenbasis_list=eigenbasis_list,
7267
power_iter_steps=1,
7368
)
7469

@@ -99,8 +94,58 @@ def test_get_eigenbasis_qr(self, N: int, M: int) -> None:
9994
msg="Orthogonalization failed: Q^T Q is not close enough to the identity matrix.",
10095
)
10196

102-
# Also check that "exp_avg_sq" remains in the state with same shape if not merging
103-
self.assertEqual(exp_avg_sq_new.shape, (M, N))
97+
@parameterized.parameters( # type: ignore[misc]
98+
{"N": 4, "M": 8},
99+
{"N": 16, "M": 8},
100+
)
101+
def test_sort_eigenbasis_by_approx_eigvals(self, N: int, M: int) -> None:
102+
"""Sort function permutes eigenbasis columns and exp_avg_sq slots consistently."""
103+
g = torch.randint(-5, 6, (M, N), device=self.device) / 16.0
104+
# Add a small ridge so K is full-rank (g @ g.t() is rank-deficient when M > N, etc.),
105+
# which keeps the approximate eigenvalues away from numerical zero where ordering becomes
106+
# ambiguous under float rounding.
107+
eps_eye = lambda n: 1e-3 * torch.eye(n, device=self.device)
108+
kronecker_factor_list = [g @ g.t() + eps_eye(M), g.t() @ g + eps_eye(N)]
109+
eigenbasis_list = [torch.linalg.qr(Q).Q for Q in kronecker_factor_list]
110+
exp_avg_sq = torch.abs(torch.randint(-5, 6, (M, N), device=self.device) / 16.0)
111+
112+
sorted_eigenbasis_list, sorted_exp_avg_sq = soap_utils.sort_eigenbasis_by_approx_eigvals(
113+
kronecker_factor_list,
114+
eigenbasis_list,
115+
exp_avg_sq,
116+
)
117+
118+
# Compute the expected per-axis permutations from the originals.
119+
sort_idx_list = []
120+
for K, Q in zip(kronecker_factor_list, eigenbasis_list, strict=True):
121+
sort_idx_list.append(torch.argsort(torch.diag(Q.t() @ K @ Q), descending=True))
122+
123+
# Each eigenbasis is column-permuted by its own sort_idx.
124+
for i, (Q_old, Q_sorted) in enumerate(zip(eigenbasis_list, sorted_eigenbasis_list, strict=True)):
125+
torch.testing.assert_close(
126+
Q_sorted,
127+
Q_old[:, sort_idx_list[i]],
128+
msg=lambda m, i=i: f"eigenbasis i={i} not permuted by sort_idx\n\n{m}",
129+
atol=0,
130+
rtol=0,
131+
)
132+
133+
# exp_avg_sq is permuted along every axis cumulatively.
134+
expected_sq = exp_avg_sq
135+
for i, sort_idx in enumerate(sort_idx_list):
136+
expected_sq = expected_sq.index_select(i, sort_idx)
137+
torch.testing.assert_close(
138+
sorted_exp_avg_sq,
139+
expected_sq,
140+
msg=lambda m: f"exp_avg_sq not permuted to match sorted eigenbases\n\n{m}",
141+
atol=0,
142+
rtol=0,
143+
)
144+
145+
# Sorted eigenbases yield descending approximate eigenvalues.
146+
for K, Q in zip(kronecker_factor_list, sorted_eigenbasis_list, strict=True):
147+
sorted_eigvals = torch.diag(Q.t() @ K @ Q)
148+
self.assertTrue(torch.all(sorted_eigvals[:-1] >= sorted_eigvals[1:]))
104149

105150
@parameterized.parameters( # type: ignore[misc]
106151
{"dims": [128, 512]},

0 commit comments

Comments
 (0)