Skip to content

Commit f0f21a7

Browse files
authored
feat: add transposeMultiply (thisᵀ · other) without materializing the transpose (#211)
1 parent 071d688 commit f0f21a7

7 files changed

Lines changed: 179 additions & 2 deletions

File tree

benchmark/transposeMultiply.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use strict';
2+
3+
// Compares `A.transpose().mmul(B)` (materializes the transpose) with the fused
4+
// `A.transposeMultiply(B)` (streams rows, never materializes the transpose).
5+
// Run after `npm run compile`: node benchmark/transposeMultiply.js
6+
7+
let benchmark = require('benchmark');
8+
9+
let { Matrix } = require('..');
10+
let { SparseMatrix } = require('ml-sparse-matrix');
11+
12+
// (m x n)ᵀ · (m x p) -> (n x p)
13+
const cases = [
14+
{ m: 256, n: 256, p: 256, label: 'dense 256' },
15+
{ m: 512, n: 512, p: 512, label: 'dense 512' },
16+
{ m: 252, n: 252, p: 210, label: 'dense 252x210 (NMR block)' },
17+
];
18+
19+
for (const { m, n, p, label } of cases) {
20+
const A = Matrix.rand(m, n);
21+
const B = Matrix.rand(m, p);
22+
23+
const reference = A.transpose().mmul(B);
24+
const fused = A.transposeMultiply(B);
25+
if (!reference.to2DArray().flat().every((v, i) => v === fused.to2DArray().flat()[i])) {
26+
throw new Error('transposeMultiply result differs from transpose().mmul()');
27+
}
28+
29+
new benchmark.Suite(label)
30+
.add(`${label}: transpose().mmul()`, () => {
31+
A.transpose().mmul(B);
32+
})
33+
.add(`${label}: transposeMultiply()`, () => {
34+
A.transposeMultiply(B);
35+
})
36+
.on('cycle', (event) => {
37+
console.log(String(event.target));
38+
})
39+
.on('complete', function onComplete() {
40+
console.log(` fastest: ${this.filter('fastest').map('name')}\n`);
41+
})
42+
.run();
43+
}
44+
45+
// Sparse `this`: the zero-skip in transposeMultiply avoids most of the work.
46+
// Build the sparse operand with the real ml-sparse-matrix package (a few
47+
// non-zeros per row, as in the NMR spin-simulation operators) and densify it,
48+
// the path such callers take before multiplying.
49+
const builder = new SparseMatrix(512, 512);
50+
for (let i = 0; i < 512; i++) {
51+
for (let k = 0; k < 9; k++) builder.set(i, (i * 31 + k * 101) % 512, k + 1);
52+
}
53+
const sparse = new Matrix(builder.to2DArray());
54+
const dense = Matrix.rand(512, 256);
55+
new benchmark.Suite('sparse')
56+
.add('sparse 512x512 (9 nnz/row): transpose().mmul()', () => {
57+
sparse.transpose().mmul(dense);
58+
})
59+
.add('sparse 512x512 (9 nnz/row): transposeMultiply()', () => {
60+
sparse.transposeMultiply(dense);
61+
})
62+
.on('cycle', (event) => {
63+
console.log(String(event.target));
64+
})
65+
.on('complete', function onComplete() {
66+
console.log(` fastest: ${this.filter('fastest').map('name')}`);
67+
})
68+
.run();

matrix.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,16 @@ export abstract class AbstractMatrix {
626626
*/
627627
gram(): Matrix;
628628

629+
/**
630+
* Returns the matrix product `thisᵀ · other` without materializing the
631+
* transpose: each shared row is streamed once and applied as a rank-1 update,
632+
* so both operands are read row-major (contiguously) and zero entries of
633+
* `this` are skipped. As fast as `this.transpose().mmul(other)` on dense
634+
* matrices and much faster on sparse ones, with an identical result.
635+
* @param other - Other matrix, with the same number of rows as `this`.
636+
*/
637+
transposeMultiply(other: MaybeMatrix): Matrix;
638+
629639
/**
630640
* Returns the matrix product between `this` and its transpose (`this · thisᵀ`),
631641
* optionally weighting each column by `scale` (`this · diag(scale) · thisᵀ`).

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"jest-matcher-deep-close-to": "^3.0.2",
7676
"mathjs": "^14.5.2",
7777
"ml-dataset-iris": "^1.2.1",
78+
"ml-sparse-matrix": "^3.1.0",
7879
"ml-xsadd": "^3.0.1",
7980
"numeric": "^1.2.6",
8081
"prettier": "^3.5.3",

src/__tests__/matrix/utility.test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SparseMatrix } from 'ml-sparse-matrix';
12
import { describe, it, beforeEach, expect } from 'vitest';
23

34
import { Matrix, pseudoInverse, determinant } from '../..';
@@ -443,6 +444,75 @@ describe('utility methods', () => {
443444
);
444445
});
445446

447+
it('transposeMultiply', () => {
448+
let a = new Matrix([
449+
[1, 2, 3],
450+
[4, 5, 6],
451+
]);
452+
let b = new Matrix([
453+
[7, 8],
454+
[9, 10],
455+
]);
456+
// aᵀ · b, equivalent to a.transpose().mmul(b) without materializing the transpose
457+
expect(a.transposeMultiply(b).to2DArray()).toStrictEqual(
458+
a.transpose().mmul(b).to2DArray(),
459+
);
460+
});
461+
462+
it('transposeMultiply stays identical on a sparse matrix', () => {
463+
let a = new Matrix([
464+
[1, 0, 2],
465+
[0, 3, 0],
466+
[4, 0, 0],
467+
[0, 0, 5],
468+
]);
469+
let b = new Matrix([
470+
[1, 2, 3],
471+
[4, 5, 6],
472+
[7, 8, 9],
473+
[10, 11, 12],
474+
]);
475+
expect(a.transposeMultiply(b).to2DArray()).toStrictEqual(
476+
a.transpose().mmul(b).to2DArray(),
477+
);
478+
});
479+
480+
it('transposeMultiply throws on row mismatch', () => {
481+
let a = new Matrix([[1, 2, 3]]);
482+
let b = new Matrix([
483+
[1, 2],
484+
[3, 4],
485+
]);
486+
expect(() => a.transposeMultiply(b)).toThrow(
487+
'the number of rows of the two matrices must be equal',
488+
);
489+
});
490+
491+
it('transposeMultiply on a matrix built with ml-sparse-matrix', () => {
492+
// A genuinely sparse operand, built with our ml-sparse-matrix package.
493+
let sparse = new SparseMatrix(6, 4);
494+
sparse.set(0, 1, 2);
495+
sparse.set(2, 0, 3);
496+
sparse.set(3, 3, 5);
497+
sparse.set(5, 2, 7);
498+
expect(sparse.cardinality).toBe(4);
499+
500+
// Densify into a Matrix, the path a caller such as the NMR spin simulation
501+
// takes before multiplying, and check `sparseᵀ · b`.
502+
let a = new Matrix(sparse.to2DArray());
503+
let b = new Matrix([
504+
[1, 2, 3],
505+
[4, 5, 6],
506+
[7, 8, 9],
507+
[10, 11, 12],
508+
[13, 14, 15],
509+
[16, 17, 18],
510+
]);
511+
expect(a.transposeMultiply(b).to2DArray()).toStrictEqual(
512+
a.transpose().mmul(b).to2DArray(),
513+
);
514+
});
515+
446516
it('pseudoinverse', () => {
447517
// Actual values calculated by the Numpy library
448518

src/correlation.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function correlation(xMatrix, yMatrix = xMatrix, options = {}) {
3939
? sdx
4040
: yMatrix.standardDeviation('column', { unbiased: true });
4141

42-
const corr = xMatrix.transpose().mmul(yMatrix);
42+
const corr = xMatrix.transposeMultiply(yMatrix);
4343
for (let i = 0; i < corr.rows; i++) {
4444
for (let j = 0; j < corr.columns; j++) {
4545
corr.set(

src/covariance.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function covariance(xMatrix, yMatrix = xMatrix, options = {}) {
2626
yMatrix = yMatrix.center('column');
2727
}
2828
}
29-
const cov = xMatrix.transpose().mmul(yMatrix);
29+
const cov = xMatrix.transposeMultiply(yMatrix);
3030
for (let i = 0; i < cov.rows; i++) {
3131
for (let j = 0; j < cov.columns; j++) {
3232
cov.set(i, j, cov.get(i, j) * (1 / (xMatrix.rows - 1)));

src/matrix.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,34 @@ export class AbstractMatrix {
908908
return result;
909909
}
910910

911+
transposeMultiply(other) {
912+
other = Matrix.checkMatrix(other);
913+
if (this.rows !== other.rows) {
914+
throw new RangeError(
915+
'the number of rows of the two matrices must be equal',
916+
);
917+
}
918+
const n = this.columns;
919+
const p = other.columns;
920+
921+
const result = new Matrix(n, p);
922+
const otherRow = new Float64Array(p);
923+
for (let r = 0; r < this.rows; r++) {
924+
for (let j = 0; j < p; j++) {
925+
otherRow[j] = other.get(r, j);
926+
}
927+
for (let i = 0; i < n; i++) {
928+
const value = this.get(r, i);
929+
if (value === 0) continue;
930+
const resultRow = result.data[i];
931+
for (let j = 0; j < p; j++) {
932+
resultRow[j] += value * otherRow[j];
933+
}
934+
}
935+
}
936+
return result;
937+
}
938+
911939
mmulByTranspose(scale) {
912940
let m = this.rows;
913941
let n = this.columns;

0 commit comments

Comments
 (0)