flopscope.
Guides

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) versus flops.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.

OperationDense costSymmetry-aware costWhy it drops
fnp.exp(s2_matrix)n^2n * (n + 1) / 2only unique matrix entries matter
fnp.einsum('ki,kj->ij', x, x, symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1)))m * n^2m * n * (n + 1) / 2the repeated x operand lets flopscope detect symmetric output and reduce the cost
fnp.einsum('i,j,k->ijk', v, v, v)n^3symmetry-reducedrepeated 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.symmetric internally samples data and calls flops.symmetrize so the projection and validation behavior is identical.
  • approximate costs (meaningful estimate):
    • fnp.random.symmetric: C_dist(n) + |G| * n + n + validation
    • flops.symmetrize: |G| * n + n + validation
      with n total 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.

OperationResult typeRule
fnp.exp(s3_tensor)SymmetricTensorunary pointwise ops preserve the exact declared group
fnp.add(s3_tensor, c3_tensor)SymmetricTensor or FlopscopeArraybinary pointwise ops keep the intersection of both operands' groups
s2_matrix * 3SymmetricTensorscalar binary ops preserve the tensor's groups
s2_matrix[0]FlopscopeArrayinteger indexing removes one axis; no nontrivial group survives
s2_matrix[:3, :3]SymmetricTensorequal-size slices can preserve symmetry
s2_matrix[:3, :2]FlopscopeArrayunequal-size slices break symmetry between those axes
s2_matrix[fnp.array([0, 1])]FlopscopeArrayadvanced indexing drops symmetry conservatively
fnp.sum(s3_tensor, axis=0)SymmetricTensorreductions keep the surviving setwise stabilizer subgroup
fnp.sum(d4_tensor, axis=(1, 3))SymmetricTensorreductions can keep a proper subgroup like C_2
s2_matrix @ s2_matrixFlopscopeArraymatrix 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_slice stays symmetric because both surviving axes still have the same size
  • different_size_slice loses symmetry because the two axes no longer match
  • expanded_s2_matrix keeps the same S_2 action, but the axes are renumbered from (0, 1) to (1, 2)
  • advanced_index_slice returns a dense FlopscopeArray; 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_slice keeps an S_2 subgroup on the surviving axes
  • c3_slice loses all nontrivial symmetry, because C_3 has 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_reduced keeps an S_2 subgroup on the remaining axes
  • c3_reduced loses all nontrivial symmetry
  • c4_reduced keeps a C_2 subgroup
  • c4_keepdims keeps the same surviving subgroup, but the output axes stay in their original tensor positions because keepdims=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 ValueError

The 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                  # works

The 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 ValueError

A 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 symmetry

ufunc.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)   # ValueError

Downgrade 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 symmetry

out=(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 refuses

This 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_3 leaves S_2
  • slicing one axis of C_3 leaves nothing nontrivial
  • reducing {1, 3} of C_4 can still leave C_2

Going deeper

On this page