Algorithm Search⌘K

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
cannons_algorithm.pyRawGitHub
# 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

Read the full page about Cannon's algorithm