Symmetry Savings
Reduce FLOP costs when your tensors have symmetry.
You will learn:
- How to declare full and non-full tensor symmetries with
flops.as_symmetric() - How to generate example tensors for arbitrary permutation groups
- When to use
fnp.random.symmetric()(sample + project) versusflops.symmetrize()(project existing data) - How slicing, reductions, and binary pointwise ops preserve, weaken, or drop symmetry metadata
- When to re-tag results with
flops.as_symmetric()after conservative propagation
Why symmetry matters
Many tensors contain repeated structure. A symmetric matrix has only
n * (n + 1) / 2 unique elements instead of n^2, and higher-order tensors
with permutation symmetry can shrink the effective element count even more.
When Flopscope knows a tensor's symmetry, it charges FLOPs based on unique elements
instead of dense ones.
| Operation | Dense cost | Symmetry-aware cost | Why it drops |
|---|---|---|---|
fnp.exp(s2_matrix) | n^2 | n * (n + 1) / 2 | only unique matrix entries matter |
fnp.einsum('ki,kj->ij', x, x, symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1))) | m * n^2 | m * n * (n + 1) / 2 | the repeated x operand lets flopscope detect symmetric output and reduce the cost |
fnp.einsum('i,j,k->ijk', v, v, v) | n^3 | symmetry-reduced | repeated operands induce output symmetry |
By contrast, declaring symmetry= on an einsum output tags the result for downstream
operations; it does not reduce that einsum's own cost by itself.
Quick start
import flopscope as flops
import flopscope.numpy as fnp
with flops.BudgetContext(flop_budget=10**6) as budget:
s2_matrix = flops.as_symmetric(
fnp.array([[2.0, 1.0], [1.0, 3.0]]),
symmetric_axes=(0, 1),
)
exp_s2_matrix = fnp.exp(s2_matrix)
sliced_row = s2_matrix[0]
print(type(exp_s2_matrix).__name__) # SymmetricTensor
print(type(sliced_row).__name__) # FlopscopeArray
print(budget.flops_used)flops.as_symmetric() validates the data first. After that, Flopscope propagates
symmetry metadata algebraically through many operations. Unary pointwise ops preserve symmetry-aware costs and keep the same exact group, including non-full groups such as C_k or D_k. Slicing, reductions, and binary pointwise ops can weaken it or remove it entirely.
How to declare symmetry
Full symmetry with SymmetryGroup.symmetric
Use SymmetryGroup.symmetric when the tensor is invariant under every permutation of a
set of axes.
import flopscope as flops
import flopscope.numpy as fnp
matrix_data = fnp.array([[2.0, 1.0], [1.0, 3.0]])
s2_matrix = flops.as_symmetric(matrix_data, symmetric_axes=(0, 1))This is the most common declaration: full S_2 symmetry on matrix axes
(0, 1).
Multiple independent full symmetric groups
block_tensor_data = fnp.ones((2, 2, 3, 3))
block_s2_tensor = flops.as_symmetric(
block_tensor_data,
symmetry=flops.SymmetryGroup.young(blocks=((0, 1), (2, 3))),
)This declares one full symmetric group on axes (0, 1) and another on
(2, 3).
Explicit full symmetric groups with SymmetryGroup.symmetric
s3_group = flops.SymmetryGroup.symmetric(axes=(0, 1, 2))
s3_tensor = flops.as_symmetric(
fnp.ones((4, 4, 4)),
symmetry=s3_group,
)This explicit group is useful once you want to inspect or combine symmetry directly.
Cyclic symmetry with SymmetryGroup.cyclic
c3_group = flops.SymmetryGroup.cyclic(axes=(0, 1, 2))
c3_tensor = flops.as_symmetric(
fnp.ones((4, 4, 4)),
symmetry=c3_group,
)C_3 means rotations are allowed, but reflections are not. This is weaker
than S_3, so it usually gives fewer savings.
Dihedral symmetry with SymmetryGroup.dihedral
d4_group = flops.SymmetryGroup.dihedral(axes=(0, 1, 2, 3))
d4_tensor = flops.as_symmetric(
fnp.ones((4, 4, 4, 4)),
symmetry=d4_group,
)D_4 includes both rotations and reflections of a four-position structure.
Arbitrary subgroups from custom generators
opposite_pair_swap_group = flops.SymmetryGroup.from_generators(
[[2, 3, 0, 1]],
axes=(0, 1, 2, 3),
)
opposite_pair_swap_tensor = flops.as_symmetric(
fnp.ones((4, 4, 4, 4)),
symmetry=opposite_pair_swap_group,
)Use this form when the built-in constructors do not describe your symmetry.
Multiple explicit groups on one tensor
row_swap_group = flops.SymmetryGroup.symmetric(axes=(0, 1))
column_swap_group = flops.SymmetryGroup.symmetric(axes=(2, 3))
two_group_tensor = flops.as_symmetric(
fnp.ones((3, 3, 5, 5)),
symmetry=flops.SymmetryGroup.direct_product(row_swap_group, column_swap_group),
)This uses a direct product of two exact symmetry factors, one on (0, 1) and one on (2, 3).
Generating example data with the Reynolds operator
When you want example tensors for arbitrary groups, prefer fnp.random.symmetric.
It is a handy helper that samples from a distribution and applies the Reynolds
operator.
Use flops.symmetrize when you already have concrete data to symmetrize, and
flops.as_symmetric when you already have concrete data to validate and tag.
R_G(T) = (1 / |G|) * sum_{g in G} g · T
S = fnp.random.symmetric((4, 4, 4), s3_group)
T = fnp.random.symmetric((4, 4, 4), c3_group)
U = fnp.random.symmetric((4, 4, 4, 4), d4_group)This helper is ideal for docs, tests, and experiments:
- it works for
S_k,C_k,D_k, and custom generator sets - prefer
fnp.random.symmetric()for synthetic data generation fnp.random.symmetricinternally samples data and callsflops.symmetrizeso the projection and validation behavior is identical.- approximate costs (meaningful estimate):
fnp.random.symmetric:C_dist(n) + |G| * n + n+ validationflops.symmetrize:|G| * n + n+ validation
withntotal elements and|G|group order.
- in exact arithmetic it projects onto the invariant subspace, and in practice
flops.as_symmetric()validates the result with its usual validation tolerances - it keeps examples consistent across symmetry classes
s3_group = flops.SymmetryGroup.symmetric(axes=(0, 1, 2))
c3_group = flops.SymmetryGroup.cyclic(axes=(0, 1, 2))
d4_group = flops.SymmetryGroup.dihedral(axes=(0, 1, 2, 3))
s3_tensor = fnp.random.symmetric((4, 4, 4), s3_group)
c3_tensor = fnp.random.symmetric((4, 4, 4), c3_group)
d4_tensor = fnp.random.symmetric((4, 4, 4, 4), d4_group)The propagation examples below assume import flopscope as flops plus import flopscope.numpy as fnp; generated tensors
above use fnp.random.symmetric(...) and the explicit transform helper is
flops.symmetrize(...).
Symmetry propagation at a glance
Symmetry propagation is conservative. Flopscope keeps symmetry metadata only when the operation's structure guarantees that the output still respects the surviving group.
| Operation | Result type | Rule |
|---|---|---|
fnp.exp(s3_tensor) | SymmetricTensor | unary pointwise ops preserve the exact declared group |
fnp.add(s3_tensor, c3_tensor) | SymmetricTensor or FlopscopeArray | binary pointwise ops keep the intersection of both operands' groups |
s2_matrix * 3 | SymmetricTensor | scalar binary ops preserve the tensor's groups |
s2_matrix[0] | FlopscopeArray | integer indexing removes one axis; no nontrivial group survives |
s2_matrix[:3, :3] | SymmetricTensor | equal-size slices can preserve symmetry |
s2_matrix[:3, :2] | FlopscopeArray | unequal-size slices break symmetry between those axes |
s2_matrix[fnp.array([0, 1])] | FlopscopeArray | advanced indexing drops symmetry conservatively |
fnp.sum(s3_tensor, axis=0) | SymmetricTensor | reductions keep the surviving setwise stabilizer subgroup |
fnp.sum(d4_tensor, axis=(1, 3)) | SymmetricTensor | reductions can keep a proper subgroup like C_2 |
s2_matrix @ s2_matrix | FlopscopeArray | matrix products are not assumed symmetric in general |
fnp.exp(c3_tensor) keeps the original C_3 subgroup exactly; the same applies
to D_k and custom exact groups.
Slicing rules
Slicing uses the pointwise stabilizer of the removed axes. Informally: every removed axis must stay fixed under any surviving group element.
s2_matrix = flops.as_symmetric(
fnp.ones((6, 6)),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)),
)
same_size_slice = s2_matrix[:3, :3]
different_size_slice = s2_matrix[:3, :2]
expanded_s2_matrix = s2_matrix[fnp.newaxis, :, :]
advanced_index_slice = s2_matrix[fnp.array([0, 1])]What happens here:
same_size_slicestays symmetric because both surviving axes still have the same sizedifferent_size_sliceloses symmetry because the two axes no longer matchexpanded_s2_matrixkeeps the sameS_2action, but the axes are renumbered from(0, 1)to(1, 2)advanced_index_slicereturns a denseFlopscopeArray; flopscope does not attempt to propagate symmetry through array/list indexing
Ellipsis behaves like NumPy's normal expansion rules. It can change which
axes remain, but it does not change the propagation rule itself.
The difference between full and non-full groups matters immediately:
s3_group = flops.SymmetryGroup.symmetric(axes=(0, 1, 2))
c3_group = flops.SymmetryGroup.cyclic(axes=(0, 1, 2))
s3_tensor = fnp.random.symmetric((4, 4, 4), s3_group)
c3_tensor = fnp.random.symmetric((4, 4, 4), c3_group)
s3_slice = s3_tensor[:, :, 0]
c3_slice = c3_tensor[:, :, 0]s3_slicekeeps anS_2subgroup on the surviving axesc3_sliceloses all nontrivial symmetry, becauseC_3has no non-identity element that fixes one point
Reduction rules
Reductions use the setwise stabilizer of the reduced axes. Informally: reduced axes are allowed to permute among themselves, because summation treats all positions along the reduced set equivalently.
s3_group = flops.SymmetryGroup.symmetric(axes=(0, 1, 2))
c3_group = flops.SymmetryGroup.cyclic(axes=(0, 1, 2))
c4_group = flops.SymmetryGroup.cyclic(axes=(0, 1, 2, 3))
s3_tensor = fnp.random.symmetric((4, 4, 4), s3_group)
c3_tensor = fnp.random.symmetric((4, 4, 4), c3_group)
c4_tensor = fnp.random.symmetric((4, 4, 4, 4), c4_group)
s3_reduced = fnp.sum(s3_tensor, axis=0)
c3_reduced = fnp.sum(c3_tensor, axis=2)
c4_reduced = fnp.sum(c4_tensor, axis=(1, 3))
c4_keepdims = fnp.sum(c4_tensor, axis=(1, 3), keepdims=True)What happens here:
s3_reducedkeeps anS_2subgroup on the remaining axesc3_reducedloses all nontrivial symmetryc4_reducedkeeps aC_2subgroupc4_keepdimskeeps the same surviving subgroup, but the output axes stay in their original tensor positions becausekeepdims=True
Reducing an axis that is not in a symmetry group leaves that group alone, apart from any axis renumbering caused by the removed dimension.
Binary pointwise ops and broadcasting
Binary pointwise ops keep only the symmetry present in both operands. For general groups, that means element-set intersection on matching output axes, not just matching tuples of axis numbers.
s3_group = flops.SymmetryGroup.symmetric(axes=(0, 1, 2))
c3_group = flops.SymmetryGroup.cyclic(axes=(0, 1, 2))
s3_tensor = fnp.random.symmetric((4, 4, 4), s3_group)
c3_tensor = fnp.random.symmetric((4, 4, 4), c3_group)
intersection_tensor = fnp.add(s3_tensor, c3_tensor)intersection_tensor keeps C_3, because C_3 is the common subgroup of
S_3 and C_3.
Multiple groups are handled independently:
left_tensor = flops.as_symmetric(
fnp.ones((3, 3, 5, 5)),
symmetry=flops.SymmetryGroup.young(blocks=((0, 1), (2, 3))),
)
right_tensor = flops.as_symmetric(
fnp.ones((3, 3, 5, 5)),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)),
)
shared_group_tensor = fnp.add(left_tensor, right_tensor)shared_group_tensor keeps only the swap on (0, 1). In the exact-group
representation that surviving action is still embedded in the full output rank,
so the group's support tuple spans (0, 1, 2, 3) even though it acts
nontrivially only on (0, 1).
Broadcasting matters too:
stretched_s2_tensor = flops.as_symmetric(
fnp.ones((1, 1, 4)),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)),
)
plain_tensor = fnp.ones((3, 3, 4))
broadcast_sum = fnp.add(stretched_s2_tensor, plain_tensor)Before group intersection, any axis stretched from size 1 to a larger output
size is removed from the carried candidate group. So singleton broadcasting by
itself does not preserve symmetry. In this example, though, plain_tensor
already carries the same analytically provable S_2 symmetry on (0, 1), so
broadcast_sum keeps that shared block.
Warnings, conservative behavior, and re-tagging
Flopscope propagates symmetry metadata conservatively. When the operation does not
guarantee that the declared symmetry survives, the result falls back to a dense
FlopscopeArray with no .symmetry metadata.
s2_matrix = flops.as_symmetric(
fnp.ones((6, 6)),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)),
)
row_slice = s2_matrix[0]row_slice is a plain dense FlopscopeArray, not a SymmetricTensor. That is expected.
SymmetryLossWarning tells you that metadata was dropped or weakened during an
operation. If you know more about the result than the conservative propagation
rule does, you can re-tag it with flops.as_symmetric().
flops.configure(symmetry_warnings=False)One important caveat: the current implementation does not report every
possible partial weakening via SymmetryLossWarning. Treat the warning system
as helpful guidance, not as a complete audit of every same-axis subgroup
change.
Edge cases when SymmetricTensors meet NumPy protocols
Once your tensors flow through arbitrary user code (or third-party libraries), they
inevitably hit NumPy's __array_ufunc__ and __array_function__ machinery —
np.add(A, B), np.divmod(A, B), np.add.outer(A, B), np.add.at(A, idx, vals),
A @= B, np.tensordot(A, B, axes=...), and so on. Flopscope's
protocol implementations are conservative: when an operation would silently
corrupt your declared symmetry, flopscope refuses or strips rather than letting
it through. This section is a tour of those edge cases.
In-place dunders refuse symmetry-corrupting writes
A_sym = flops.symmetrize(
fnp.random.randn(4, 4),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)),
)
B_plain = fnp.random.randn(4, 4) # not symmetric
A_sym += B_plain # raises ValueErrorThe right-hand-side has no symmetry, so A_sym + B_plain returns a plain
FlopscopeArray. Writing that result back into A_sym's buffer would leave the
metadata claiming symmetry while the data is asymmetric. Flopscope refuses with
ValueError: in-place add on a SymmetricTensor would weaken or destroy the declared symmetry.
If you know the asymmetric write is intentional, downgrade first:
A_plain = A_sym.view(fnp.ndarray) # zero-copy view as plain FlopscopeArray, no symmetry
A_plain += B_plain # worksThe same guard applies to __isub__, __imul__, __itruediv__,
__ifloordiv__, __imod__, __ipow__, __iand__, __ior__, __ixor__,
__ilshift__, __irshift__, and __imatmul__ (which additionally falls back
to CPython's rebind-the-name semantics when the matmul output shape differs
from self.shape).
In-place sort / partition refuse on SymmetricTensor
A_sym.sort(axis=0) # raises ValueError
A_sym.partition(2) # raises ValueErrorA reorder along any axis breaks the permutation invariance. Use the out-of-place forms instead:
sorted_arr = fnp.sort(A_sym, axis=0) # plain FlopscopeArray, no symmetryufunc.at refuses on SymmetricTensor
np.add.at(a, indices, values) does an unbuffered fancy-index write — every
repeat of an index applies again (unlike a[indices] += values which dedupes).
On a SymmetricTensor this almost certainly breaks symmetry, so flopscope
refuses:
np.add.at(A_sym, ([0], [1]), 1.0) # ValueErrorDowngrade with A_sym.view(fnp.ndarray) first if you really need the unbuffered
update.
ufunc.outer produces direct-product symmetry
When both operands are symmetric, the output of np.<ufunc>.outer(A, B)
inherits the direct product of the input symmetries — A's symmetry on
its own axes, B's symmetry on the lifted slots A.ndim..A.ndim+B.ndim-1:
A = flops.symmetrize(fnp.random.randn(3, 3),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)))
B = flops.symmetrize(fnp.random.randn(2, 2),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)))
C = np.add.outer(A, B) # shape (3, 3, 2, 2), SymmetricTensor
# symmetry: S_2 on (0, 1) × S_2 on (2, 3)tensordot keeps surviving direct-product symmetry
The contracted axes drop out of each operand's symmetry; what's left of each operand's symmetry on the surviving (uncontracted) axes is direct-producted:
sym = flops.SymmetryGroup.symmetric(axes=(0, 1))
A = flops.symmetrize(fnp.random.randn(4, 4, 4, 4), symmetry=sym) # axes (0,1) symmetric
B = flops.symmetrize(fnp.random.randn(4, 4, 4, 4), symmetry=sym)
C = fnp.tensordot(A, B, axes=((2,), (2,)))
# A's surviving axes: (0, 1, 3) → S_2 on (0, 1) survives
# B's surviving axes: (0, 1, 3) → S_2 on (0, 1) survives
# C: shape (4,4,4,4,4,4), SymmetricTensor with S_2 on (0,1) × S_2 on (3,4)If the contracted axis is part of the symmetry group, that group is destroyed on the corresponding side.
Multi-output ufuncs preserve symmetry on every output
np.divmod(A, B), np.frexp(A), np.modf(A) are elementwise — both outputs
inherit the same symmetry as their inputs:
S = flops.symmetrize(fnp.array([[1.5, 2.5], [2.5, 3.5]]),
symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)))
frac, integer = fnp.modf(S) # both SymmetricTensor with the same symmetryout=(o1, o2) works (per-slot identity is preserved), and out=(o1, None)
lets numpy allocate just the second slot.
Cost model is a placeholder above degree 12
Flopscope charges dense_cost × unique_output_elements / dense_output_elements for
ufunc.outer and tensordot as a coarse proxy for the savings a
symmetry-aware implementation could realise. The underlying NumPy call still
does dense work; only the budget reflects the savings.
For a SymmetryGroup with degree above 12, flopscope skips this adjustment and
charges the full dense cost — Burnside enumeration on S_n for n > 12
becomes infeasible (13! ≈ 6.2 × 10⁹). The skip is announced via
CostFallbackWarning:
import warnings
deep = fnp.ones((1,) * 33) # auto-inferred S_33 symmetry
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with flops.BudgetContext(flop_budget=int(1e10)):
try:
fnp.tensordot(deep, deep, axes=0) # CostFallbackWarning: bailed to dense
except ValueError:
pass # ndim>64 — the warning still fires before numpy refusesThis is rare in practice — common user tensors have degree ≤ 8 — but high-rank
auto-inferred symmetries on degenerate shapes ((1,)*N for large N) trip
the cap. The warning fires once per (op_name, degree) pair per process to
avoid log flooding. Suppress with flops.configure(symmetry_warnings=False),
which shares the flag with SymmetryLossWarning.
A proper algorithmic-cost model is a follow-up.
Under the hood
The propagation rules are easier to predict if you keep three ideas in mind:
- Unary pointwise ops preserve symmetry-aware costs, but for full and non-full groups alike they keep the same exact group
- Slicing uses the pointwise stabilizer of the removed axes
- Reductions use the setwise stabilizer of the reduced axes
- Binary pointwise ops use intersection of both operands' groups after broadcast alignment
After computing the surviving subgroup, flopscope restricts it to the axes still present in the output and remaps those axes to the output tensor's numbering.
That is why:
- slicing one axis of
S_3leavesS_2 - slicing one axis of
C_3leaves nothing nontrivial - reducing
{1, 3}ofC_4can still leaveC_2
Going deeper
- Einsum Patterns — how declared and induced symmetry interact with
fnp.einsum - Symmetry Detection Deep Dive — the full detection algorithm for
einsum - Symmetry Explorer — experiment with symmetry interactively