Cannon's algorithm in Python
Multiplies n×n matrices on a q×q processor grid; after an initial skew it shifts A left and B up q times, so each processor sends 2n^2/q words and does n^3/q^2 multiplications.
- File
cannons_algorithm.py, 112 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cannons-algorithm/. The authors are listed in prov/E9RHBT.jsonl.
from typing import NamedTuple, Optional
Matrix = list[list[int]]
class Cost(NamedTuple):
"""BSP cost of one run: supersteps S, total h-relation H, and the largest local work W."""
supersteps: int
h_total: int
max_multiplications: int
def _local(c: Matrix, a: Matrix, b: Matrix, mod: Optional[int]) -> int:
"""c += a * b by the triple loop; return the number of scalar multiplications."""
count = 0
for i, row in enumerate(a):
ci = c[i]
for k, aik in enumerate(row):
bk = b[k]
for j in range(len(ci)):
ci[j] += aik * bk[j]
count += len(ci)
if mod is not None:
c[i] = [x % mod for x in ci]
return count
def _block(x: Matrix, r0: int, r1: int, c0: int, c1: int) -> Matrix:
return [row[c0:c1] for row in x[r0:r1]]
def _words(x: Matrix) -> int:
return sum(len(row) for row in x)
def cannon_with_cost(a: Matrix, b: Matrix, q: int, p: Optional[int] = None,
mod: Optional[int] = None) -> tuple[Matrix, Cost]:
"""Simulate Cannon's algorithm on a q x q grid of processors; return (a * b, cost).
a is n x m and b is m x p (lists of rows); pass p when m = 0. Each side is
padded with zeros to a multiple of q, so processor (i, j) holds blocks of
size ceil(n/q) x ceil(m/q) and ceil(m/q) x ceil(p/q). The ring is Z, or Z/mod Z.
Cost: q + 1 supersteps, H = q (|A block| + |B block|) words for q >= 2,
and n m p / q^2 multiplications per processor when q divides the sides.
"""
if q < 1:
raise ValueError("the grid side q must be at least 1")
n, m = len(a), len(b)
if p is None:
p = len(b[0]) if m > 0 else 0
if any(len(row) != m for row in a) or any(len(row) != p for row in b):
raise ValueError("the shapes do not match")
bn, bm, bp = -(-n // q), -(-m // q), -(-p // q)
ap = [row + [0] * (bm * q - m) for row in a] + [[0] * (bm * q) for _ in range(bn * q - n)]
bpd = [row + [0] * (bp * q - p) for row in b] + [[0] * (bp * q) for _ in range(bm * q - m)]
grid = range(q)
a_at = {(i, j): _block(ap, i * bn, (i + 1) * bn, j * bm, (j + 1) * bm) for i in grid for j in grid}
b_at = {(i, j): _block(bpd, i * bm, (i + 1) * bm, j * bp, (j + 1) * bp) for i in grid for j in grid}
c_at = {(i, j): [[0] * bp for _ in range(bn)] for i in grid for j in grid} # @line L1
def exchange(a_dest: dict, b_dest: dict) -> int:
"""One h-relation: move every block to its destination; return h."""
nonlocal a_at, b_at
sent = {key: 0 for key in a_at}
recv = {key: 0 for key in a_at}
new_a, new_b = {}, {}
for src, blk in a_at.items():
dst = a_dest[src]
new_a[dst] = blk
if dst != src:
sent[src] += _words(blk)
recv[dst] += _words(blk)
for src, blk in b_at.items():
dst = b_dest[src]
new_b[dst] = blk
if dst != src:
sent[src] += _words(blk)
recv[dst] += _words(blk)
a_at, b_at = new_a, new_b
return max(max(sent[k], recv[k]) for k in sent)
supersteps, h_total = 1, 0
h_total += exchange({(i, j): (i, (j - i) % q) for i in grid for j in grid}, # @line L2
{(i, j): ((i - j) % q, j) for i in grid for j in grid})
work = {key: 0 for key in c_at}
for t in range(q): # @line L3
supersteps += 1
for key in c_at: # @line L4
work[key] += _local(c_at[key], a_at[key], b_at[key], mod)
if t < q - 1: # @line L5
h_total += exchange({(i, j): (i, (j - 1) % q) for i in grid for j in grid},
{(i, j): ((i - 1) % q, j) for i in grid for j in grid})
c = [[0] * p for _ in range(n)] # @line L6
for (i, j), blk in c_at.items():
for r, row in enumerate(blk):
gi = i * bn + r
if gi < n:
for s, x in enumerate(row):
gj = j * bp + s
if gj < p:
c[gi][gj] = x % mod if mod is not None else x
return c, Cost(supersteps, h_total, max(work.values()))
def cannons_algorithm(a: Matrix, b: Matrix, q: int, p: Optional[int] = None,
mod: Optional[int] = None) -> Matrix:
"""Return a * b computed by Cannon's algorithm on a simulated q x q processor grid."""
return cannon_with_cost(a, b, q, p, mod)[0]
test_cannons_algorithm.py 84 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cannons-algorithm/. The authors are listed in prov/E9RHBT.jsonl.
import random
import pytest
from cannons_algorithm import cannon_with_cost, cannons_algorithm as mul
def schoolbook(a, b, p, mod=None):
n, m = len(a), len(b)
c = [[0] * p for _ in range(n)]
for i in range(n):
for j in range(p):
s = 0
for k in range(m):
s += a[i][k] * b[k][j]
c[i][j] = s % mod if mod is not None else s
return c
def rand(rng, r, c, lo=-9, hi=9):
return [[rng.randint(lo, hi) for _ in range(c)] for _ in range(r)]
def test_example() -> None:
a = [[1, 2, 3], [4, 5, 6], [7, 8, 10]]
b = [[1, 0, 2], [0, 1, 1], [1, 1, 0]]
c, cost = cannon_with_cost(a, b, 3)
assert c == [[4, 5, 4], [10, 11, 13], [17, 18, 22]]
assert cost.supersteps == 4 and cost.h_total == 6 and cost.max_multiplications == 3
def test_edges() -> None:
assert mul([], [], 2) == []
assert mul([[5]], [[-3]], 1) == [[-15]]
assert mul([[5]], [[-3]], 3) == [[-15]]
assert mul([[], []], [], 2, p=2) == [[0, 0], [0, 0]]
with pytest.raises(ValueError):
mul([[1, 2]], [[1, 2]], 2)
with pytest.raises(ValueError):
mul([[1]], [[1]], 0)
def test_identity_and_zero() -> None:
rng = random.Random(31)
for n, q in ((4, 2), (6, 3), (5, 2), (7, 4)):
a = rand(rng, n, n)
eye = [[int(i == j) for j in range(n)] for i in range(n)]
zero = [[0] * n for _ in range(n)]
assert mul(a, eye, q) == a
assert mul(eye, a, q) == a
assert mul(zero, a, q) == zero
def test_random_against_schoolbook() -> None:
rng = random.Random(32)
for _ in range(600):
n, m, p = rng.randint(1, 8), rng.randint(1, 8), rng.randint(1, 8)
a, b = rand(rng, n, m), rand(rng, m, p)
assert mul(a, b, rng.randint(1, 4)) == schoolbook(a, b, p)
def test_mod_small_primes() -> None:
rng = random.Random(33)
for mod in (2, 3, 5, 7):
for _ in range(40):
n = rng.randint(1, 9)
a, b = rand(rng, n, n, 0, mod - 1), rand(rng, n, n, 0, mod - 1)
assert mul(a, b, rng.randint(1, 3), mod=mod) == schoolbook(a, b, n, mod)
def test_bsp_cost() -> None:
rng = random.Random(34)
for n, q in ((8, 2), (8, 4), (12, 3), (12, 6), (16, 4)):
a, b = rand(rng, n, n), rand(rng, n, n)
c, cost = cannon_with_cost(a, b, q)
assert c == schoolbook(a, b, n)
assert cost.supersteps == q + 1
assert cost.h_total == 2 * n * n // q # 2 n^2 / sqrt(P) with P = q^2
assert cost.max_multiplications == n ** 3 // (q * q)
_, cost = cannon_with_cost(rand(rng, 6, 6), rand(rng, 6, 6), 1)
assert cost.h_total == 0
What the next level needs
- the tests did not run in this build