Skip to content

Commit 8949054

Browse files
author
Matthew A Johnson
authored
matrix-fma-and-fancy-indexing (#41)
A `Matrix` capability release. The dense matrix type gains NumPy-style fancy indexing, comparison masks and lexicographic comparison operators, a ``where`` selector, a single-rounding ``fma`` with vector broadcasting, and ``sqrt``. Two older method names move to their NumPy spellings: ``select`` becomes ``take`` and ``clip`` adopts ``min`` / ``max`` keyword bounds. **New Features** - **Fancy indexing** — ``m[[r0, r1]]`` / ``m[[r0, r1], :]`` gather rows and ``m[:, [c0, c1]]`` gathers columns, returning a :class:`Matrix`; the matching assignment forms scatter into rows or columns with last-write-wins duplicates and all-or-nothing validation. New :meth:`Matrix.take` and :meth:`Matrix.put` expose the same gather/scatter as methods, ``put`` with an ``accumulate=True`` mode that folds duplicate indices. - **Comparison masks** — :meth:`Matrix.less`, ``less_equal``, ``greater``, ``greater_equal``, ``equal``, and ``not_equal`` return a ``1.0`` / ``0.0`` mask matrix, accepting a same-shape matrix, a scalar (including ``bool``), a ``1x1`` matrix, a broadcasting row/column vector, or a list/tuple of numbers. Distinct from the comparison operators, which return a single bool. - **Lexicographic comparison operators** — ``<`` ``<=`` ``>`` ``>=`` ``==`` ``!=`` compare element by element in row-major order and return a single :class:`bool`. ``==`` / ``!=`` are total: a shape mismatch or an uncoercible list/tuple yields ``False`` / ``True`` rather than raising, so ``matrix in some_list`` works. A ``NaN`` never decides the comparison, so an all-``NaN`` matrix compares ``==`` equal to itself. Defining value equality makes :class:`Matrix` unhashable. - **`Matrix.where(mask, a, b)`** — a NumPy-style selector taking *a* where the mask is non-zero (``NaN`` counts as non-zero) and *b* elsewhere; *a* and *b* may each be a scalar, a same-shape matrix, or a list/tuple of numbers. - **`Matrix.fma(b, c)`** — fused multiply-add computing single-rounding ``self * b + c``; *b* and *c* may be a same-shape matrix, a ``1x1`` matrix, a scalar, or a row / column vector that broadcasts against ``self``. The contraction kernel is preserved so hardware FMA still applies. Use it as an accuracy primitive — compare results with :meth:`Matrix.allclose`, never ``==``. - **`Matrix.sqrt()`** — element-wise square root (negative inputs map to ``NaN``), with an ``in_place=True`` form. **Breaking Changes** - **`Matrix.select` renamed to `Matrix.take`.** The gather method is now spelled :meth:`Matrix.take` to match NumPy and pair with the new :meth:`Matrix.put`. Replace ``m.select(indices, axis)`` with ``m.take(indices, axis)``; the signature and semantics are otherwise unchanged. - **`Matrix.clip` bounds are now `min` / `max` keywords.** The signature changes from ``clip(min_or_maxval, maxval=None)`` to ``clip(min=None, max=None)``, matching :func:`numpy.clip`. Either bound may be omitted to leave that side unbounded: ``m.clip(min=0.0)`` clamps only below, ``m.clip(max=255.0)`` only above. **Documentation** - Expanded the :doc:`api` matrix surface for the new indexing, masking, comparison, ``where``, ``fma``, and ``sqrt`` methods via the ``__init__.pyi`` stub docstrings, including the totality, ``NaN``, and broadcasting rules. **Tests** - Extensive `test_matrix.py` additions covering fancy-index gather/scatter, ``take`` / ``put`` (including accumulate and all-or-nothing validation), the comparison masks, lexicographic operators (totality, ``NaN``, reflected-scalar, and list/tuple/bool coercion edge cases), ``where`` selection and value propagation, and ``fma`` row/column broadcasting. **Internal** - New `bench_fma` and `bench_take` micro-benchmarks in `scripts/bench_matrix.py`. The `examples/boids.py` demo migrates from ``select`` to ``take``. Signed-off-by: Matthew A Johnson <matthew@matthewajohnson.org> Signed-off-by: Matthew A Johnson <matjoh@microsoft.com>
1 parent a0002d6 commit 8949054

12 files changed

Lines changed: 3565 additions & 224 deletions

File tree

.flake8

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ extend-exclude = scripts/_vendored_warehouse_wheel.py
1717
per-file-ignores =
1818
test/*:D102,D103,D403
1919
# .pyi stubs intentionally:
20+
# A002: arguments shadowing Python builtins (Matrix.clip min/max bounds)
2021
# A003: methods shadowing Python builtins (Matrix.sum/min/max/abs/round)
2122
# D402: Sphinx-style docstrings start with the function signature
2223
# N802: NumPy-style ``Matrix.T`` transpose property is upper-case
23-
*.pyi:A003,D402,N802
24+
*.pyi:A003,D402,N802,A002

CHANGELOG.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,77 @@
1+
## 2026-06-17 - Version 0.12.0
2+
A `Matrix` capability release. The dense matrix type gains NumPy-style fancy
3+
indexing, comparison masks and lexicographic comparison operators, a
4+
``where`` selector, a single-rounding ``fma`` with vector broadcasting, and
5+
``sqrt``. Two older method names move to their NumPy spellings: ``select``
6+
becomes ``take`` and ``clip`` adopts ``min`` / ``max`` keyword bounds.
7+
8+
**New Features**
9+
10+
- **Fancy indexing**``m[[r0, r1]]`` / ``m[[r0, r1], :]`` gather rows and
11+
``m[:, [c0, c1]]`` gathers columns, returning a :class:`Matrix`; the
12+
matching assignment forms scatter into rows or columns with last-write-wins
13+
duplicates and all-or-nothing validation. New :meth:`Matrix.take` and
14+
:meth:`Matrix.put` expose the same gather/scatter as methods, ``put`` with
15+
an ``accumulate=True`` mode that folds duplicate indices.
16+
- **Comparison masks** — :meth:`Matrix.less`, ``less_equal``, ``greater``,
17+
``greater_equal``, ``equal``, and ``not_equal`` return a ``1.0`` / ``0.0``
18+
mask matrix, accepting a same-shape matrix, a scalar (including ``bool``), a
19+
``1x1`` matrix, a broadcasting row/column vector, or a list/tuple of
20+
numbers. Distinct from the comparison operators, which return a single
21+
bool.
22+
- **Lexicographic comparison operators**``<`` ``<=`` ``>`` ``>=`` ``==``
23+
``!=`` compare element by element in row-major order and return a single
24+
:class:`bool`. ``==`` / ``!=`` are total: a shape mismatch or an
25+
uncoercible list/tuple yields ``False`` / ``True`` rather than raising, so
26+
``matrix in some_list`` works. A ``NaN`` never decides the comparison, so an
27+
all-``NaN`` matrix compares ``==`` equal to itself. Defining value equality
28+
makes :class:`Matrix` unhashable.
29+
- **`Matrix.where(mask, a, b)`** — a NumPy-style selector taking *a* where the
30+
mask is non-zero (``NaN`` counts as non-zero) and *b* elsewhere; *a* and *b*
31+
may each be a scalar, a same-shape matrix, or a list/tuple of numbers.
32+
- **`Matrix.fma(b, c)`** — fused multiply-add computing single-rounding
33+
``self * b + c``; *b* and *c* may be a same-shape matrix, a ``1x1`` matrix,
34+
a scalar, or a row / column vector that broadcasts against ``self``. The
35+
contraction kernel is preserved so hardware FMA still applies. Use it as an
36+
accuracy primitive — compare results with :meth:`Matrix.allclose`, never
37+
``==``.
38+
- **`Matrix.sqrt()`** — element-wise square root (negative inputs map to
39+
``NaN``), with an ``in_place=True`` form.
40+
41+
**Breaking Changes**
42+
43+
- **`Matrix.select` renamed to `Matrix.take`.** The gather method is now
44+
spelled :meth:`Matrix.take` to match NumPy and pair with the new
45+
:meth:`Matrix.put`. Replace ``m.select(indices, axis)`` with
46+
``m.take(indices, axis)``; the signature and semantics are otherwise
47+
unchanged.
48+
- **`Matrix.clip` bounds are now `min` / `max` keywords.** The signature
49+
changes from ``clip(min_or_maxval, maxval=None)`` to
50+
``clip(min=None, max=None)``, matching :func:`numpy.clip`. Either bound may
51+
be omitted to leave that side unbounded: ``m.clip(min=0.0)`` clamps only
52+
below, ``m.clip(max=255.0)`` only above.
53+
54+
**Documentation**
55+
56+
- Expanded the :doc:`api` matrix surface for the new indexing, masking,
57+
comparison, ``where``, ``fma``, and ``sqrt`` methods via the
58+
``__init__.pyi`` stub docstrings, including the totality, ``NaN``, and
59+
broadcasting rules.
60+
61+
**Tests**
62+
63+
- Extensive `test_matrix.py` additions covering fancy-index gather/scatter,
64+
``take`` / ``put`` (including accumulate and all-or-nothing validation),
65+
the comparison masks, lexicographic operators (totality, ``NaN``,
66+
reflected-scalar, and list/tuple/bool coercion edge cases), ``where``
67+
selection and value propagation, and ``fma`` row/column broadcasting.
68+
69+
**Internal**
70+
71+
- New `bench_fma` and `bench_take` micro-benchmarks in
72+
`scripts/bench_matrix.py`. The `examples/boids.py` demo migrates from
73+
``select`` to ``take``.
74+
175
## 2026-06-14 - Version 0.11.0
276
A behavior-dispatch release. `@when` becomes a **runtime decorator backed by
377
a content-addressed marshalled-code registry** instead of a transpile-time

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ authors:
55
given-names: "Matthew Alastair"
66
orcid: "https://orcid.org/0000-0002-1019-8036"
77
title: "bocpy"
8-
version: 0.11.0
9-
date-released: 2026-06-14
8+
version: 0.12.0
9+
date-released: 2026-06-17
1010
url: "https://github.com/microsoft/bocpy"

examples/boids.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,8 @@ def build_cell_data(self, positions: Matrix, velocities: Matrix, row: int, colum
313313

314314
assert len(boids) > 0, "Invalid grid cell"
315315

316-
positions = positions.select(boids)
317-
velocities = velocities.select(boids)
316+
positions = positions.take(boids)
317+
velocities = velocities.take(boids)
318318
return CellData(Cell(row, column), tuple(boids), Cown(positions), Cown(velocities))
319319

320320
def step(self, width: int, height: int):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "bocpy"
7-
version = "0.11.0"
7+
version = "0.12.0"
88
authors = [
99
{name = "bocpy Team", email="bocpy@microsoft.com"}
1010
]

scripts/bench_matrix.py

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,48 @@ def bench_vecdot(results: list[dict[str, float]]) -> None:
298298
_print_row(results[-1])
299299

300300

301+
def bench_fma(results: list[dict[str, float]]) -> None:
302+
"""Bench fma(b, c) vs a*b + c.
303+
304+
On the shipped x86-64 SSE2 build libc ``fma()`` is an unvectorisable
305+
libcall, so ``a*b + c`` is expected to win on throughput there -- fma's
306+
headline is accuracy (single rounding), not speed. A hardware-FMA host
307+
(arm64, or x86-64 built with ``-mfma``) is where any throughput claim
308+
must be measured.
309+
"""
310+
_print_section("fma(b, c) vs a*b + c (accuracy primitive; libcall on SSE2)")
311+
312+
for rows, cols in [(1000, 3), (256, 256)]:
313+
a = _make_matrix(rows, cols, seed=40)
314+
b = _make_matrix(rows, cols, seed=41)
315+
c = _make_matrix(rows, cols, seed=42)
316+
label = f"{rows}x{cols}"
317+
results.append(measure(
318+
f"fma(b, c) matrix b,c {label}",
319+
lambda a=a, b=b, c=c: a.fma(b, c),
320+
))
321+
_print_row(results[-1])
322+
results.append(measure(
323+
f"a*b + c matrix b,c {label}",
324+
lambda a=a, b=b, c=c: a * b + c,
325+
))
326+
_print_row(results[-1])
327+
328+
a = _make_matrix(256, 256, seed=40)
329+
b = _make_matrix(256, 256, seed=41)
330+
c = _make_matrix(256, 256, seed=42)
331+
results.append(measure(
332+
"fma(b, c, in_place) matrix b,c 256x256",
333+
lambda a=a, b=b, c=c: a.fma(b, c, in_place=True),
334+
))
335+
_print_row(results[-1])
336+
results.append(measure(
337+
"fma(2.0, 1.0) scalar b,c 256x256",
338+
lambda a=a: a.fma(2.0, 1.0),
339+
))
340+
_print_row(results[-1])
341+
342+
301343
def bench_cross(results: list[dict[str, float]]) -> None:
302344
"""Bench cross across scalar and batch shapes."""
303345
_print_section("cross at 1x3, 1000x2 (2D row batch), 1000x3 (3D row batch), 3x1000 (3D col batch)")
@@ -624,22 +666,79 @@ def bench_transpose(results: list[dict[str, float]]) -> None:
624666
_print_row(results[-1])
625667

626668

627-
def bench_select(results: list[dict[str, float]]) -> None:
628-
"""Bench row and column gather across small and large index lists."""
629-
_print_section("select (row / column gather)")
669+
def bench_take(results: list[dict[str, float]]) -> None:
670+
"""Bench row and column gather across wide, narrow, and column cases.
671+
672+
The shared ``gather_axis`` helper copies each selected row with
673+
``memcpy`` (source and destination rows are both contiguous); the
674+
wide row case (1000x100) is where that path dominates. The narrow-C
675+
row case (1000x3) isolates the per-index unbox + bounds-check cost,
676+
a larger fraction of the work when each copied row is short. Column
677+
gather stays element-wise (inherently strided) and confirms no
678+
regression there.
679+
"""
680+
_print_section("take (row / column gather)")
630681

631682
shape = (1000, 100)
632683
m = _make_matrix(*shape, seed=108)
633684
row_idx = list(range(0, 1000, 10))
634685
col_idx = list(range(0, 100, 2))
635686
results.append(measure(
636-
f"select rows (100/1000) shape={shape}",
637-
lambda m=m, i=row_idx: m.select(i, 0),
687+
f"take rows (100/1000) shape={shape}",
688+
lambda m=m, i=row_idx: m.take(i, 0),
689+
))
690+
_print_row(results[-1])
691+
results.append(measure(
692+
f"take cols (50/100) shape={shape}",
693+
lambda m=m, i=col_idx: m.take(i, 1),
694+
))
695+
_print_row(results[-1])
696+
697+
narrow = _make_matrix(1000, 3, seed=51)
698+
results.append(measure(
699+
"take rows (narrow C) shape=(1000, 3) -> 100 rows",
700+
lambda m=narrow, i=row_idx: m.take(i, 0),
701+
))
702+
_print_row(results[-1])
703+
704+
705+
def bench_scatter(results: list[dict[str, float]]) -> None:
706+
"""Bench list-key scatter assignment (rows vs columns, fill vs matrix).
707+
708+
Row scatter writes each selected destination row with a contiguous
709+
``memcpy`` (the headline write-side path); column scatter is strided.
710+
The augmented ``m[[...]] += v`` case measures the gather -> in-place op
711+
-> scatter triple pass against the plain single scatter; if that ratio
712+
is large *and* the form is hot, that is the signal to revisit the
713+
deferred fused single-pass kernel.
714+
"""
715+
_print_section("scatter (row / column assignment)")
716+
717+
shape = (1000, 100)
718+
row_idx = list(range(0, 1000, 10))
719+
col_idx = list(range(0, 100, 2))
720+
rows_rhs = _make_matrix(len(row_idx), 100, seed=110)
721+
cols_rhs = _make_matrix(1000, len(col_idx), seed=111)
722+
723+
m = _make_matrix(*shape, seed=112)
724+
results.append(measure(
725+
f"scatter rows = scalar shape={shape}",
726+
lambda m=m, i=row_idx: m.__setitem__(i, 0.5),
727+
))
728+
_print_row(results[-1])
729+
results.append(measure(
730+
f"scatter rows = matrix shape={shape} (memcpy)",
731+
lambda m=m, i=row_idx, v=rows_rhs: m.__setitem__(i, v),
732+
))
733+
_print_row(results[-1])
734+
results.append(measure(
735+
f"scatter cols = matrix shape={shape} (strided)",
736+
lambda m=m, i=col_idx, v=cols_rhs: m.__setitem__((slice(None), i), v),
638737
))
639738
_print_row(results[-1])
640739
results.append(measure(
641-
f"select cols (50/100) shape={shape}",
642-
lambda m=m, i=col_idx: m.select(i, 1),
740+
f"scatter rows += scalar shape={shape} (triple pass)",
741+
lambda m=m, i=row_idx: m.__setitem__(i, m[i] + 0.5),
643742
))
644743
_print_row(results[-1])
645744

@@ -827,7 +926,8 @@ def main(argv: list[str] | None = None) -> int:
827926
bench_binary_arithmetic(results)
828927
bench_matmul(results)
829928
bench_transpose(results)
830-
bench_select(results)
929+
bench_take(results)
930+
bench_scatter(results)
831931
bench_copy_clip_allclose(results)
832932
bench_construction(results)
833933
bench_factories(results)
@@ -836,6 +936,7 @@ def main(argv: list[str] | None = None) -> int:
836936
bench_magnitude_squared(results)
837937
bench_negate(results)
838938
bench_vecdot(results)
939+
bench_fma(results)
839940
bench_cross(results)
840941
bench_normalize(results)
841942
bench_perpendicular(results)

sphinx/source/api.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Math
125125
.. autoclass:: Matrix
126126
:members:
127127
:undoc-members:
128-
:special-members: __init__
128+
:special-members: __init__, __eq__, __ne__, __lt__, __le__, __gt__, __ge__, __getitem__, __setitem__
129129

130130

131131
Messaging

sphinx/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
project = 'bocpy'
1515
copyright = '2026, Microsoft'
1616
author = 'Microsoft'
17-
release = '0.11.0'
17+
release = '0.12.0'
1818

1919
# -- General configuration ---------------------------------------------------
2020
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

0 commit comments

Comments
 (0)