Target: raylib-miniscript. Status: implemented in MatrixCore/MatrixClass.cpp, and a revised matrixUtil.ms.
Replaces the pure-MiniScript sys/lib/matrixUtil.ms.
Fast bulk linear algebra for:
- neural network inference (training possible, but as a later MiniScript layer)
- simple physics over many entities (position / velocity / inertia / rotation)
- general linear algebra in games
The point is bulk operations that obviate scripted loops. Scalar element access should be adequate, not fast.
- No operator overloading, no index overloading. Intrinsic classes can't do this,
and it's not obvious what
*should mean anyway. Named methods only. - No views / aliasing. Every op returns fresh storage or writes into a supplied matrix. This keeps GC trivial and avoids lifetime and reshape-vs-view headaches. (Passing the same matrix as input and output is fine; this is handled internally.)
- No N-dimensional tensors. See "Why 2D only" below.
- Single-threaded. Revisit with benchmarks, not on principle.
- No automatic differentiation in C++.
- No lazy expression graphs. See "Rejected: lazy expression graphs" below.
Matrix is an ordinary MiniScript class (a map with predefined methods), with the
numeric payload behind an opaque handle value stored in the instance map.
struct MatrixData {
double* data; // capacityElems doubles
long capacityElems;
int rows, columns;
};- Row-major. Row
rbegins atdata + r*columns. - Live region is
data[0 .. rows*columns-1], always contiguous. Spare capacity sits after the live region. - Capacity is measured in elements, not rows (so that
reshapeto a different column count leaves the bookkeeping meaningful). Row capacity is derived:capacityElems / columns. - Stride is always exactly
columns. No separate column capacity. Growing the row count is amortized O(1); changing the column count is a realloc-and-copy, documented as expensive. Justification: for all three use cases, rows are what change (entities spawn/die, batch sizes vary) while columns are structural. - Element type is
doublethroughout. Adtypeoption could be added later without disturbing the handle model; the storage type for serialization is separate (see RawData).
- On overflow:
newCap = max(2*oldCap, needed). - Never shrink capacity automatically. A pool oscillating between 900 and 1000
rows allocates zero times after warmup.
trimis the explicit release. - Total size limit: a sensible cap (TBD, order of a few hundred MB); exceeding it, or a failed allocation, returns an error value.
- Refcounted storage, freed on collection. No manual
disposerequired. Matrixitself must hold a null handle. Otherwisenew Matrixproduces a map containing only__isa, andm.handleresolves up the chain toMatrix's handle, silently sharing one buffer across every instance. Every constructor attaches a fresh handle; every intrinsic errors on a null handle.- Consequence:
m = new Matrixfollowed by manual field assignment is no longer a valid user pattern.ofSizeand friends are the only allocators. - Only
.clonecopies the payload. Any map-level copy shares the handle.
Plain map entries, written by the C++ side on every resize. Keeps m.rows syntax and
map-lookup speed. A plain store would desync them from the handle, so each has a
property setter ("rows=", "columns=", and "size=" for the pair) that resizes
instead: m.rows = 5 is m.resize(5, m.columns). They are installed on the class map
by host code, which the VM's setter cache cannot see on its own, so the class is stamped
with NoteSetterDefined once it is wrapped (see src/HostSetters.h).
capacity gets a null setter, making it read-only by name: capacity is always a
whole number of rows, and there is no element count worth assigning. Grow it with
m.reserve.
m1 == m2 compares maps — __isa plus the handle — so two matrices with identical
contents compare unequal. Use .equals. The handle value has identity equality only.
All matrix products — every named script wrapper, and the m.gemm intrinsic itself —
funnel through one GEneral Matrix Multiply routine, BLAS-style:
gemm(double alpha, const Matrix& A, bool transA,
const Matrix& B, bool transB,
double beta, Matrix& C); // C := alpha*op(A)*op(B) + beta*CtransA/transB are plain bools. (BLAS itself uses 'N'/'T'/'C' characters, a
Fortran-API holdover; we have no complex types, so there's no third case to encode.)
op(A) is A if transA is false, or A.transpose if transA is true.
The MiniScript-visible m.gemm (see "Matrix product") separates addend from out,
which this BLAS-shaped kernel does not. The intrinsic reconciles them: broadcast-copy
beta*addend into out, then call the kernel with beta=1. That copy is O(mn) against
an O(mnk) multiply — noise — and is skipped entirely when out is addend, preserving
the optimal path. This is what keeps the backend swappable: our richer signature still
reduces to a stock BLAS call.
- Exposing
alpha/betarather than hiding them is what keeps the C++ method count at one: transposition, scaling, and accumulation are parameters, not variants. beta == 0must not readC, just overwrite it (fresh storage may hold garbage;0 * NaNisNaN). Same contract as BLAS.- The transpose flags do not transpose anything — they change the inner-loop indexing.
All three variants have contiguous row-major formulations:
A·BbroadcastsA[i][k]over a contiguousjloop;A·Bᵀis a dot product of two contiguous rows;Aᵀ·Bis a rank-1 update over contiguous rows of both. SotransposedTimes/timesTransposedcost no allocation and no copy — which is the whole reason those wrappers exist, and a large part of why lazy expression graphs bought too little to justify themselves (see "Rejected: lazy expression graphs").
Level-1 ops enter through gemm but must dispatch away from it immediately. Because
add, plus, subtract, negate, addScaled, transpose, clone, and copyFrom are
all defined as gemm calls with a null B operand (see "Convenience wrapper methods"),
gemm is the entry point for a great deal of bandwidth-bound work that has nothing to do
with matrix multiplication.
So gemm must early-dispatch on a null B to a simple axpby loop, and on
transA with null B to a transpose-copy — never letting either walk into the blocked
matmul path, which would make every matrix addition pay matmul setup and lose to a naive
loop. Internally this is three clearly separate kernels — axpby, transpose-copy,
and matmul — sharing only argument validation and de-aliasing. That keeps the
intrinsic-count reduction from becoming a performance regression, and keeps the C++
readable instead of one seven-mode function.
Implementation: custom C++, no BLAS dependency. The workload is dominated by small
matrices and bandwidth-bound level-1 ops, where BLAS's advantage is near zero; and
consistent results across platforms matter for a program-sharing platform. Use i-k-j
loop order (contiguous inner loop, auto-vectorizes at -O2), __restrict on the
kernel pointers (legitimate because the intrinsic de-aliases into a scratch buffer before the kernel is entered, and because we have no user-visible views), and cache blocking only above
~128. The seam exists so an Accelerate/OpenBLAS backend can be swapped in behind an
#ifdef later without touching the API.
The scratch buffer is maintained internally as static thread-local storage. It grows as needed, and never shrinks automatically, so a steady-state loop that aliases allocates zero times after warmup. There is no reentrancy hazard because gemm never calls back into MiniScript.
| Form | Meaning | Example |
|---|---|---|
| imperative verb | mutates in place, returns self or nothing |
m.add x |
-ed / noun form |
returns a new matrix, receiver untouched | m.plus(x) |
...Into(args, out) |
writes result into caller-supplied out |
a.timesInto(b, out) |
Existing inconsistencies (transpose and round, both imperative-but-not-matching)
are fixed, not grandfathered — the current library has almost no users. So: transpose mutates, transposed returns new; round mutates, roundedreturns new.
...Into forms exist for the hot-loop ops (times, plus, elemTimes, apply) so
that per-frame / per-batch code allocates nothing.
Use null, not -1. e.g. m.reshape 4, null infers columns.
MiniScript 2.0 errors are values, and existing intrinsics already propagate them. So:
- Dimension mismatch, bad index, null handle, allocation failure → return an error value.
- Errors propagate through chained calls:
a.times(b).plus(c)on a mismatch yields the original error rather than a second, more confusing one. - Note for users: an error value is not
nulland not0. Check it.
randomize and any @rnd-style fill use the interpreter's RNG state, so rnd(seed)
gives reproducible weight initialization. (Passing @rnd as a fill function gets this
for free.)
Matrix.ofSize(rows, columns, initialValue=0)
initialValue may be a number, or a function taking no arguments, invoked once per
element, in row-major order — e.g. Matrix.ofSize(3, 3, @rnd). (Matches list.init /
list.init2d.) A function will always be slower than a numeric value; users understand
this.
The callback runs re-entrantly (VM::RunFunction pushes its frame above the intrinsic's
own registers), so it may do anything an ordinary function can: allocate, call other
intrinsics, even call ofSize with another callback. Each result must be a number:
- an error value returned by the callback is returned to the caller in place of the matrix — that exact error, not a generic wrapper;
- any other non-number is a
TypeError(this includes thenullfrom a function with no explicit return, so a mistaken callback fails loudly rather than filling with zeros); - a runtime error raised inside the callback stops the program, as it would anywhere else; the half-built matrix is discarded.
Matrix.identity(size) // square; ones on the main diagonal
Matrix.fromList(sourceList) // 1D list -> 1 x n row vector; 2D list -> n x m
Matrix.fromRawData(rd, dtype="auto", startPos=0, rows=null, columns=null)
fromList([]) yields a clean 0x0 matrix (the old code left elem = null).
m.rows, m.columns // map entries; number of rows and columns
m.size // [rows, columns]
m.capacity // read-only report on current element capacity
m.size (a wrapper) is treated as canonical in the docs, with rows/columns as 2D
conveniences, so a hypothetical N-D future wouldn't break the shape API. We'll take care to handle zero-row and zero-column matrices properly.
m.reshape(rows, columns) // in place, O(1); null infers one dimension
m.resize(rows, columns) // preserve overlapping submatrix, zero-fill growth
m.size = [rows, columns] // the same resize, as an assignment
m.rows = n, m.columns = n // likewise, one dimension at a time
m.reserve(rows) // pre-allocate, no visible change
m.removeRow(index) // order-preserving, O((rows-index)*columns)
m.removeRowFast(index) // swap-with-last, O(columns) — REORDERS
m.trim // release excess capacity
reshape is O(1) because the live region is contiguous (total size is required to be unchanged). It is not transpose — say so in the docs, users conflate them.
Two separate removal methods rather than one with a flag: the naming is the warning
that removeRowFast invalidates any external index→entity mapping.
m.getElem(row, col)
m.setElem(row, col, value)
Negative indices count from the end, as they do for MiniScript lists and
strings — and as they do in numpy, which is the closer reference for a matrix
type. m.getElem(-1, -1) is the bottom-right element. This applies to every
index the API takes, not just getElem/setElem. An index that is still out of
range after that adjustment returns an error value; in particular every index
into a zero-row or zero-column matrix is out of range.
Convenience path, not the loop path. For loops, use row/flat access:
m.getRow(r) / m.setRow(r, list) // plain MiniScript list of numbers
m.getColumn(c) / m.setColumn(c, list)
m.toFlatList // single flat list; index as r*columns + c
Block access — a rectangular sub-region, returned or written as a Matrix:
m.getSub(row=0, col=0, rows=null, columns=null, out=null) // -> new Matrix (or out); null = through the end
m.setSub(row, col, m2) // write m2 as a block, in place
out behaves as it does for gemm: given a Matrix, the block is written into it (reshaped
to fit, capacity kept) and it is returned, so a loop that reads the same-sized block every
frame makes no garbage. out may be m itself, which crops in place. Without it, reading
at an offset was the one thing that could not be done allocation-free: a shifted window of
a padded frame for convolution, a crop, or the edge bands for wrap-around padding.
Intrinsic rather than a wrapper despite the reduction goal: the script version would allocate a MiniScript list per row to accomplish what is fundamentally a strided memcpy, which is real garbage pressure for a 512-row mini-batch. The kernel is ~15 lines.
Motivating cases: mini-batch extraction (m.getSub(10, 0, 32)), pulling the 3x3 rotation
out of a 4x4 transform (m.getSub(0, 0, 3, 3)), and the inverse — assembling a 4x4 from a
rotation block plus a translation column.
Note the deliberate asymmetry with getRow, which returns a plain list: a 2-D result has
no natural flat-list form, so getSub returns a Matrix. Out-of-range indices return an
error value; setSub errors if m2 does not fit at the given offset rather than
silently clipping.
m.row(r), m.column(c), m.columnAsRow(c) still return Matrix copies, as today,
and are now wrappers over getSub.
Every plus/elemTimes-style op accepts a scalar, a Matrix, or a 2D list, and supports
row/column broadcasting (a 1 x m or n x 1 operand against an n x m matrix) — this is
what makes bias vectors and per-entity gravity one call.
Only the in-place forms are intrinsic. The copying counterparts (elemTimes,
elemOver, powed, absed, sqrted, rounded, clamped) are clone-then-mutate
wrappers, and the whole add/scale family (add, plus, subtract, minus, negate,
negated) comes from gemm. See Convenience wrapper methods.
m.elemMultiplyBy(x) // elementwise; Hadamard when x is a Matrix
m.elemDivideBy(x)
m.pow(k)
m.abs, m.sqrt
m.round(places=0)
m.clamp(lo, hi) // pass null for clamp-from-below or clamp-from-above
Exactly one product intrinsic in C++:
A.gemm(B, addend=null, out=null, transA=false, transB=false, alpha=1, beta=1)
Computes alpha*op(A)*op(B) + beta*addend, where op(A) is just A if transA is false, or A.transpose if transA is true (and similar for B). B may be null in which case this computes alpha*op(A) + beta*addend. Similarly, addend may be null (or beta==0) and the beta*addend term is skipped.
With out=null it allocates and returns a new matrix; otherwise it writes into out (resizing it as necessary) and returns it.
In the event of an error (e.g. dimension mismatch), it returns an error value.
Deliberate deviation from BLAS: real BLAS makes C serve as both addend and
destination. We separate them, because writing our own kernel means we don't have to
inherit that. Three payoffs:
addendmay be null, a scalar, a 1 x n or n x 1 vector (broadcast), or a full m x n matrix — sox.gemm(W, b)is a complete dense NN layer in one kernel pass, bias broadcast included. This is the most common operation in the primary use case.- A non-mutating
A·B + Cneeds no defensive clone. - In-place accumulate is just
out == addend, stated positively rather than as a trap.
Parameter order puts addend and out early because they're the common ad-hoc
overrides; transposition is usually reached through a named wrapper.
Aliasing is allowed everywhere:
| Combination | Allowed? |
|---|---|
out aliases addend |
Yes — the accumulate case, elementwise, and the fast path (no copy, unless a broadcast addend is about to be outgrown) |
out aliases A |
Yes — no copy on the untransposed elementwise path (B null); otherwise copied to an internal buffer before calculation |
out aliases B |
Yes — copied to an internal buffer before calculation |
addend aliases A or B |
Yes — read-only |
The no-copy cases are the ones where every output element depends only on the same
element of the aliased operand and the shape is not changing: the elementwise path
(out := alpha*A + beta*addend) is a single pass that reads each element before writing
it, so m.add(x), m.negate and acc.addScaled(s, w) cost one pass and no copy, and on
the matmul path C.addProduct(A, B) applies beta*C to C in place before accumulating.
A := A·B + ... would be impossible without a temporary regardless of shapes, so we maintain an internal scratch buffer, resized as necessary, used for this purpose.
There is no combinatorial explosion of C++ methods, because transposition, scaling, and
accumulation are parameters, not variants. The only real problem is that MiniScript has
positional defaults but no keyword arguments, so m.gemm(b, null, null, true) reads
poorly. But this can be fixed in script, rather than in C++, via wrapper methods (see Convenience wrapper methods).
m.fill(value)
m.randomize(mean=0, sd=1) // normal distribution
m.apply(@f) // f(value) -> value, in place; like list.apply
m.apply1(@f, arg1) // f(value, arg1); like list.apply1
Index-dependent initialization is deliberately not supported by a callback. Write a
loop, or build a list and use fromList. Where a MiniScript function is passed as an argument (e.g. apply), we will impose sensible reentrancy restrictions.
Axis parameter: null = whole matrix (scalar), 0 = down columns, 1 = across rows. Return value is a smaller matrix (1xN for axis 0, Mx1 for axis 1) or a simple scalar (axis==null).
m.sum(axis=null)
m.max(axis=null), m.min(axis=null)
m.argmax(axis=null), m.argmin(axis=null)
m.sumOfSquares(axis=null)
m.determinant
m.inverse // error value if singular
m.solve(b) // LU with partial pivot; -> n x k Matrix
m.swapRows(row1, row2), m.swapColumns(column1, column2)
m.equals(m2, tolerance=1e-9) // Matrix or 2D list
Closed-form fast paths for 1x1 / 2x2 / 3x3 / 4x4 determinant and inverse — not merely
an optimization, since a 4x4 transform inverted every frame is the common case, and
cofactor expansion at that size costs less than a pivot search. Above 4x4 both go
through one LU factorization with partial pivoting; inverse is that factorization plus
a single multi-column solve against the identity, not n separate solves.
A singular matrix has determinant 0 — an answer, not an error — but inverse and
solve return an error value, matching numpy's LinAlgError: an un-invertible transform
is a bug upstream, not a value worth propagating as NaN.
solve's b is a Matrix or a list. A flat list of n numbers is read as a column
vector rather than the 1 x n that fromList would make of it: this is the one place where
a bare list of numbers unambiguously means "the right-hand side of n equations", and numpy
reads a 1-D b the same way. A nested list or a Matrix must already be n x k. All k
right-hand sides are solved from one factorization.
transpose / transposed are not here: they fall out of gemm with a null B operand.
m.rowCross(m2) // n x 3, requires 3 columns
rowLengths, normalizeRows/normalizedRows, and rowDot are wrappers; only the
3-component cross product needs its own kernel.
Both operands need exactly 3 columns. m2 may have one row per row of m, or exactly
one row, which is then crossed with every row — the fixed-axis case (vel.rowCross(up)),
and the same row broadcasting the arithmetic ops do. A flat list of 3 numbers is that
single row.
Deliberate divergence from numpy: np.cross broadcasts in both directions, so a 1x3
crossed with an nx3 yields n rows. Here the result always has self's row count.
a.rowCross(b) reads as "for each of my rows", and a method that silently returned more
rows than the receiver has would be a trap. A 1x3 receiver against an nx3 operand is an
error, not an upcast.
m.convolve(kernel, out=null) // -> out, the same size as m
out[y][x] = sum over i, j of kernel[i][j] * m[y - kh/2 + i][x - kw/2 + j] (halves rounded down)
Motivating cases: blur, sharpen and edge detection on image data, and cellular automata
(Life is one convolution with [[2,2,2],[2,1,2],[2,2,2]], which makes each cell
2*neighbors + alive, then an elementwise rule: alive next exactly when that is 5, 6 or
7).
The decisions, and why:
- Correlation, named
convolve. The kernel is not flipped. This is whatscipy.ndimage.correlatecomputes and what every deep-learningconv2dcomputes under the name convolution; the name follows the latter. For symmetric kernels there is no difference.m.flipped(script layer) turns a kernel into its true-convolution form. - Same-size output, and only the positions where the whole kernel fits are written.
The rest of
outis left exactly as it was (zeros, in a fresh result, or in anoutthat had to be reshaped). So edge handling belongs to the caller: keep a border around the data and fill it before convolving —m.refreshBorder(kernel, mode)in the script layer does zeros/constant, wrap, nearest, reflect and mirror. The use case this is designed around is two same-sized buffers convolved back and forth, displaying one while computing the other: no padding copy per frame, no allocation, and a zero border stays zero for free because convolve never writes it. The alternatives considered were numpy/MATLAB's'valid'output (smaller than the input — every frame would need a pad or crop) and edge modes built into the intrinsic (more C++, and it would still either pad internally or special-case every edge pixel). A'valid'result is onegetSub(..., out)away. - Even-sized kernels center at the lower-right of the middle (
k/2rounded down, so the unwritten border is on the top/left), matchingscipy.ndimagewith origin 0. - Any kernel shape. A 1 x k kernel on a row vector is 1-D correlation; 1-D kernels
on a 2-D matrix are the passes of a separable filter (a large blur costs 2k per
element instead of k²). A flat list is a row kernel, so a vertical pass is
[[1],[2],[1]]. - Zero weights are skipped, as gemm skips a zero factor: a kernel with holes (Sobel) costs only its nonzero taps, and an inf/NaN under a zero weight does not spread.
outmay bem— the input is snapshotted into the scratch buffer, since every output element reads its neighbors. A kernel larger thanm, or empty, is an error.
The loop is row by row: each nonzero tap adds a scaled, contiguous run of an input row onto a contiguous run of an output row. At 512x512 a 3x3 kernel takes ~0.7 ms (versus ~2.9 ms for the best shift-and-add formulation in script, and ~17 ms for the naive script one), and a whole wrapped Life step ~2.3 ms, of which the elementwise rule is now the larger part.
Chosen so a MiniScript layer library can be built on top without further C++ work.
m.sigmoid
m.tanh
m.softmax(axis=1)
m.greaterThan(x)
m.softmaxCrossEntropy(targets, outProbs=null) // fused; -> n x 1 per-sample losses
Derivatives are expressed in terms of the layer's OUTPUT, not its input:
sigmoid' = y*(1-y), tanh' = 1-y², relu' = (y>0). So a layer caches only its
output, never the pre-activation. Meaningfully simpler layer code. The derivatives
themselves (sigmoidDeriv, tanhDeriv) and softmaxCrossEntropyGrad are script
wrappers — the last is literally yHat.minus(targets).
relu and related functions (relued, reluDeriv) are missing at the C++ layer because they are trivially defined in terms of clamp and greaterThan, but would probably be added at the MiniScript layer.
Fused because it is numerically necessary anyway (log-sum-exp stability) and because its
gradient collapses to yHat - y, removing the single most error-prone derivative from
user code.
Input is logits, not probabilities. It applies the softmax itself. State this loudly:
m.softmax exists as a separate method, and the obvious-looking z.softmax followed by
softmaxCrossEntropy is both wrong (double softmax) and numerically worse than the thing
the fusion exists to avoid.
Row-wise only, samples in rows — consistent with batch x features everywhere else
(getSub for mini-batches, sumRows for bias gradients). Deliberately no axis
parameter, unlike softmax: cross-entropy is inherently a per-sample loss, and an axis
knob would only invite confusion about what a sample is.
targets accepts two forms, dispatched on shape:
| Shape | Interpretation |
|---|---|
| n x k (same as logits) | target distribution — one-hot, but also label smoothing / soft targets |
| n x 1 | class indices — avoids materializing an n x k one-hot matrix per batch |
Dispatch: if targets.columns == self.columns it is a distribution; else if
targets.columns == 1 it is class indices; else error. (The two overlap only at k=1,
where softmax is degenerate anyway; distribution wins.) Out-of-range class indices return
an error value, as do fractional ones.
A flat list is always read as a column of class indices — the same reading solve
gives its right-hand side — never as a one-row distribution. Deliberately unconditional:
dispatching a flat list on whether n happens to equal k would make [2,0,1] mean one
thing for a 3-class batch of 3 and something else for a 4-class batch of 3. A distribution
must be written as a nested list or a Matrix.
outProbs may safely be the logits matrix itself — each row is fully read before anything
is written back to it — but not the targets matrix, which is refused, since reshaping
outProbs would move storage the targets are still being read from.
Returns the unreduced n x 1 per-sample loss vector, not a scalar. This matters because
of the gradient pairing: softmaxCrossEntropyGrad is yHat - targets, which is the
gradient of the summed loss. Had this returned the mean — the conventional default —
then a user pairing the documented loss with the documented gradient would train with
gradients n times too large: silent, batch-size-dependent, and exactly the class of bug
gradCheck exists to catch but that most people never run.
Returning unreduced removes the hidden convention. sum and mean are already
first-class reductions, so the choice is visible in the user's own code:
losses = z.softmaxCrossEntropy(targets) // n x 1
reported = losses.mean
dZ = z.softmaxCrossEntropyGrad(targets) // gradient of the SUM
Training on the mean means scaling the gradient by 1/n.
outProbs, if supplied, receives softmax(m) — backprop needs yHat as well as the
loss, and this avoids computing the softmax twice. Matches the out-parameter convention
used by gemm.
Numerics: natural log (nats, not bits), and log-sum-exp with max subtraction, so
log(0) is unreachable by construction.
m.toRawData(rd, dtype="float64", startPos=0, includeHeader=true) // -> ending position
m.readRawData(rd, dtype="auto", startPos=0) // -> ending position
(Matrix.fromRawData is listed under Construction.)
dtype:float64,float32, plus the integer formats (int8/uint8,int16/uint16,int32/uint32,int64) for completeness —uint8andint16matter for image and quantized-weight data.dtype="auto"on read means "the data starts with a header." Specifying an explicit dtype instead means "no header, read raw fromstartPos." This is the key affordance: it lets you load data produced by something other than us.- Headerless read gets its shape from the receiver (
readRawData) or from explicitrows/columns(fromRawData).fromRawDatawill infer whichever of the two you leave null from how much data remains afterstartPos, soMatrix.fromRawData(rd, "float32", 0, null, 3)loads "however many 3-vectors are in there". The division must come out even; a leftover tail means the data is not the shape you think it is, and that is worth an error rather than a quietly truncated matrix. Withdtype="auto", passingrows/columnsat all is an error — the header is the authority on shape, and accepting both would mean deciding what to do when they disagree. - Both directions return the ending position, so loading a whole network's weights out of one blob is a clean sequential loop.
toRawDatagrows the RawData to fit what it is writing (allocating one for a barenew RawData). The alternative — making the caller size the buffer first — means restating this file's own layout rules in script. A buffer we merely borrow rather than own is never reallocated; that is an error instead.- Header: 16 bytes — magic
MSMX, uint16 version, uint16 dtype code, int32 rows, int32 columns. Room to extend to N-D or new dtypes without breaking old files, and 16 keepsfloat64payload 8-byte aligned behind it. Dtype codes are part of the format and are never reused for a different type. - Little-endian, stated explicitly; these files will move between platforms. More
precisely, byte order follows the RawData object's own
littleEndianflag, which defaults to little-endian. The flag has to be honored so that headerless foreign data of the other byte order can be read at all, and it would be strange for it to governrd.intbut not matrix data in the same buffer.
Integer conversion: writing to an integer format rounds to nearest with ties away
from zero (matching MiniScript's round) and saturates at the ends of the range; NaN
stores as 0. This is a deliberate departure from numpy's astype, which truncates
toward zero and leaves out-of-range conversion undefined — in practice wrapping, so 300
becomes 44 in a uint8. For what these formats are actually for, a clamped bright pixel
is a small error and a wrapped one is garbage. Reading back is exact for every format
except int64 beyond 2^53, where the double itself is the limit.
m.format(fieldWidth=10, precision=null, columnSep="", rowSep=null) // -> string
m.str (a script wrapper) gets a truncating default — dimensions plus a few
elements — so a 1000x1000 matrix doesn't dump 10⁶ numbers into the console.
It is m.str rather than the global str(m) because MiniScript has no hook for a
map to override its own string form: Value.CodeForm stringifies a map by walking
its entries, with no per-map override to consult. What the global str(m) does
get is a readable __isa: the C++ side registers the class with
Intrinsic::AddShortName, which CodeForm consults for __isa entries, so
str(m) yields {"__isa": Matrix, "_handle": <value>, "rows": 2, "columns": 3}
instead of dumping every method in the class. (Every intrinsic class in the host
does this now, not just Matrix.) Should MiniScript ever gain a per-map _str hook,
pointing it at m.str is a one-line change.
The old if s.indexOf("E-") != null then s = "0" hack (matrixUtil.ms:281) is
gone, not made optional: it also silently zeroed legitimately small values like
1.5e-5. format handles the real case properly instead — with a precision,
fixed-point notation renders 1e-17 as 0.000 because that genuinely is its value
to three places, while the default stays honest about magnitude.
A large number of methods a matrix-math user would expect can be defined in terms of the existing C++ API. This will be done as MiniScript methods added to the Matrix class in the matrixUtil module, e.g.:
Matrix.times = function(m2)
return self.gemm(m2)
end function
GEMM wrapper methods:
| Wrapper | GEMM call |
|---|---|
A.times(B) |
A.gemm(B) if B matrix, or A.gemm(null, null, null, false, false, B) if B scalar |
A.multiplyBy(B) |
A.gemm(B, null, A) or A.gemm(null, null, A, false, false, B) |
A.timesInto(B, out) |
A.gemm(B, null, out) |
A.transposedTimes(B) |
A.gemm(B, null, null, true) |
A.timesTransposed(B) |
A.gemm(B, null, null, false, true) |
A.timesPlus(B,c) |
A.gemm(B, c) |
A.timesPlusInto(B,c,out) |
A.gemm(B, c, out) |
A.plusScaled(C,beta) |
A.gemm(null, C, null, false, false, 1, beta) |
A.addScaled(C,beta) |
A.gemm(null, C, A, false, false, 1, beta) |
C.addProduct(A,B) |
A.gemm(B, C, C) |
— transpose & copy: no second operand, so op(A) is the whole product term — |
|
A.transposed |
A.gemm(null, null, null, true) |
A.transpose |
A.gemm(null, null, A, true) |
A.clone |
A.gemm(null) |
A.copyFrom(B) |
B.gemm(null, null, A) (note swapped receiver) |
— axpby family: alpha*A + beta*addend — |
|
A.plus(x) |
A.gemm(null, x) |
A.add(x) |
A.gemm(null, x, A) |
A.minus(x) |
A.gemm(null, x, null, false, false, 1, -1) |
A.subtract(x) |
A.gemm(null, x, A, false, false, 1, -1) |
A.negated |
A.gemm(null, null, null, false, false, -1) |
A.negate |
A.gemm(null, null, A, false, false, -1) |
addScaled(m2, k) is the level-1 accumulate, addProduct(a, b) the level-3 one; both
mutate the receiver, per the naming convention. New named combinations can be added
freely without touching C++; or users may call gemm directly.
transpose/transposed falling out of gemm is the non-obvious one: with no second
operand the product term is just op(A), so the transpose flag alone does the whole job
and no separate transpose kernel is needed. It relies on out being resized to fit.
copyFrom swaps the receiver (B.gemm(null, null, A)) rather than using alpha = 0,
which avoids needing a "don't read A" contract on alpha to match the one on beta.
fill would need that contract, so it stays an intrinsic.
Not subsumable: elemMultiplyBy / elemDivideBy stay intrinsic for the Hadamard
case. Their scalar paths could route to gemm's alpha, but that's a script-level
branch and saves no C++.
Every -ed copying form is just clone plus the in-place version, so only the mutator
needs to be intrinsic:
minus, elemTimes, elemOver, powed, absed, sqrted, rounded, clamped,
applied, applied1, sigmoided, tanhed, softmaxed, normalizedRows, reshaped,
flattened
Cost is two passes instead of one on the copying variants. That's the right trade: both forms exist precisely because hot-loop code uses the in-place one, so the convenience form is the one that can afford to be slower.
| Wrapper | Definition |
|---|---|
m.size, m.sameSize, m.rowRange, m.colRange |
from the rows / columns map entries |
m.row(r) |
m.getSub(r, 0, 1) |
m.column(c) |
m.getSub(0, c, null, 1) |
m.columnAsRow(c) |
m.getSub(0, c, null, 1).transposed |
Matrix.vcat(matrices) |
ofSize + setSub per block |
Matrix.hcat(matrices) |
same, roles swapped |
m.appendRows(m2) |
resize + setSub at the old row count |
m.toList |
loop of getRow |
m.mean(axis) |
sum(axis), scaled |
m.sumRows / m.sumColumns |
sum(0) / sum(1) |
m.rowLengths |
sumOfSquares(1).sqrt |
m.normalizeRows |
elemDivideBy(rowLengths) — n x 1 broadcast |
m.flatten |
reshape(1, null) |
m.addRow / m.addRows |
resize + setRow |
Matrix.fromFlatList |
fromList + reshape |
m.trace |
O(n) getElem loop |
m.rowDot(m2) |
elemTimes(m2).sum(1) |
m.sigmoidDeriv |
y.elemTimes(y.negated.plus(1)) |
m.tanhDeriv |
y.powed(2).negated.plus(1) |
m.softmaxCrossEntropyGrad(t) |
yHat.minus(t) |
m.relu / m.relued |
clamp(0, null) / clamped(0, null) |
m.refreshBorder(kernel, mode) |
setRow/setColumn (constant), or getSub(..., out) + setSub per border row/column into a reused band |
m.flip / m.flipped |
swapRows / swapColumns from both ends |
m.print |
format |
m.str |
format, truncated to a few rows/columns plus the dimensions |
softmaxCrossEntropyGrad never needed to be C++ — it is documented as exactly
yHat - targets.
Concatenation takes a list, not a pair: pairwise chaining (a.vcat(b).vcat(c))
would recopy the accumulated prefix at every step, whereas one pass computes the total
size, allocates once, and block-copies each input into place — exactly what a C++
implementation would do.
appendRows is the one worth distinguishing from vcat: it grows in place, so against
reserved row capacity it allocates nothing at steady state. That is the case the storage
design was built for — a physics pool absorbing a batch of spawned entities. vcat
always allocates a fresh matrix.
hcat hits the expensive column-resize path, but that is unavoidable: row-major storage
means columns are never contiguous, so any horizontal concatenation is inherently a full
copy. No loss versus a hand-written kernel. Dimension mismatches (differing column counts
for vcat) return an error value, checked before allocating rather than partway through
the copy loop.
Note that the classic augmented-matrix trick — hcat-ing a column of ones to fold bias
into the weight matrix — is not needed here, because gemm's separate broadcast
addend handles bias directly.
Net effect of all three mechanisms: roughly 90 named methods down to ~50 intrinsics, with the script layer supplying the rest at no implementation cost.
Matrix is intrinsic, hence always in globals; import "matrixUtil" no longer needed.
Keep sys/lib/matrixUtil.ms as a thin script layer that is harmless to import, holds
the unit tests, and hosts pure-script conveniences not worth writing in C++. Later the
nn library lives beside it.
Breaking changes, all intentional:
m.elemis gone. UsegetElem/setElem,getRow/setRow,toList/toFlatList.m = new Matrix+ manual field assignment no longer works; useofSizeetc.transposeandroundnow mutate; usetransposed/roundedfor the old behavior.timeson incompatible sizes returns an error value instead ofprint-then-exit(matrixUtil.ms:206).rowRange/colRangeno longer cached on the instance.
The idea: methods like transpose, times, plus, and scaling would not compute
immediately, but return a Matrix holding a pointer to its input(s) and a record of the
pending operation. Chains would materialize only when something needed real elements,
resolving into a single gemm call — avoiding every intermediate allocation, and
removing the apparent need for a proliferation of fused methods.
Rejected primarily because laziness fights the mutation-heavy API we chose on purpose.
A lazy node holds references to its inputs, and our API is full of in-place mutators
(add, fill, apply, addScaled, copyFrom, resize, removeRowFast) that exist
specifically to avoid GC churn in game loops:
c = a.times(b) // lazy: holds refs to a and b, computes nothing
a.fill 0 // in-place mutation of an input
print c.getElem(0, 0) // materializes NOW — against the zeroed a
Silent wrong answer, no error, arbitrarily far from the call site — and not an exotic
case, since the physics loop mutates the same matrices every frame. Every fix is bad:
document-and-pray is unacceptable for a beginner platform; version-stamping inputs gives
every mutator a hidden, unpredictable matmul; copy-on-write makes fill sometimes
allocate a whole matrix, destroying the reason the mutators exist. This is Eigen's
aliasing problem, and a large part of why numpy stayed eager.
Note the pattern: laziness pays off in immutable array systems (JAX, Eigen const expressions); we deliberately chose mutable-in-place for game-loop performance. Taking both gets the worst of each.
Secondary costs: every intrinsic needs a materialize-if-needed prologue; errors surface far from their cause; lazy chains pin their inputs alive; printing a matrix in a debugger triggers computation; performance acquires cliffs at unpredictable points.
What we do instead: expose the full gemm as the single product intrinsic and name
its common specializations with script wrappers (see "Matrix product"). That captures
essentially all of the win — AᵀB, A·Bᵀ, and αA·B + βC all reach the kernel with zero
intermediate allocations — while staying eager, predictable, and alias-safe. The only
thing genuinely lost is fusion across operation types, and the important instance of
that (A·B + C) is already reachable via beta.
The three use cases are natively 2D: batch x features, entities x components, points x 3-or-4. N-D drags in general broadcasting, batched matmul, and axis permutation — collectively the most confusing part of numpy's surface, against MiniScript's readability ethos.
The cases wanting more dimensions have decent 2D spellings:
- Images
(H, W, C)→(H*W) x Cis arguably more natural: a list of pixels, each a row of channels. Column ops become per-channel ops. - Conv nets
(N, C, H, W)→ im2col + 2D matmul, which is what CPU conv implementations do internally anyway. If conv ever matters, addim2colas a primitive rather than adding a dimension to the type.
Because reshape is O(1), users can freely reinterpret a buffer as a collapsed N-D
array and do the leading-index arithmetic themselves. Document that idiom.
// y = softmax(relu(x*W1 + b1) * W2 + b2)
h = x.gemm(W1, b1) // x·W1 + b1, bias broadcast — one kernel pass
h.relu // equivalent to h.clamp(0, null)
y = h.gemm(W2, b2)
y.softmax
Weights load from one RawData blob via sequential readRawData calls. No scripted loop
anywhere in the forward pass.
pos = Matrix.ofSize(n, 2)
vel = Matrix.ofSize(n, 2)
acc = Matrix.ofSize(n, 2)
// per frame:
acc.fill 0
acc.add gravity // gravity is 1 x 2, broadcast down all rows
vel.addScaled acc, dt
pos.addScaled vel, dt
// spawn / despawn:
i = pos.addRow([x, y]); vel.addRow [vx, vy]; acc.addRows 1
pos.removeRowFast i; vel.removeRowFast i; acc.removeRowFast i
reserve up front means steady-state allocation is zero. removeRowFast is O(columns).
Parallel matrices must be resized in lockstep — a thin MiniScript EntityPool wrapper
should own that invariant.
worldPts = localPts.times(modelMatrix) // n x 4 times 4 x 4
dirs.normalizeRows
sims = dirsA.rowDot(dirsB)
A layer library, not autodifferentiation. Dense, ReLU, Sigmoid, Softmax, MSELoss,
each with forward and backward; Sequential runs the list forward, then backward.
~150 lines of MiniScript, zero additional C++. Each derivative is written once by the
library author in closed form, so users never hand-derive anything. (Keras worked this
way for years before eager mode.)
The dense backward pass is four calls, which is why the primitives above are shaped as they are:
dZ = dA.elemTimes(actDeriv)
dW = X.transposedTimes(dZ)
db = dZ.sumRows
dX = dZ.timesTransposed(W)
The loss layers own the reduction choice, and it lives in exactly one place. The
intrinsics deliberately return unreduced per-sample losses (see softmaxCrossEntropy),
so nn.ms must decide what a reported loss means and keep its gradient consistent. The
convention to adopt: losses report the mean over the batch, and the corresponding
backward scales the gradient by 1/n — so softmaxCrossEntropyGrad, which is the
gradient of the sum, gets divided by the batch size inside the loss layer and nowhere
else. Users then never see the scaling, and it cannot silently disagree with the loss
they are watching. Document it on the loss layers, not just in the code.
Ship a gradCheck helper (finite differences vs. the analytic gradient) alongside it.
That's the difference between "manual derivatives are practical" and "manual derivatives
are a nightmare" — and it is precisely what catches a reduction/gradient mismatch if the
convention above ever slips.
Real tape-based autodiff would also need no C++ changes. A tape records one entry per
matrix op — a few hundred per forward pass, not per element — so interpreter overhead is
noise next to the matmuls. It could be a pure-MiniScript Tensor wrapper written later,
by us or by a user. Worth not foreclosing; not worth building now.
An EntityPool class owning a set of parallel matrices, keeping their row counts in
lockstep, and handling the index remapping that removeRowFast implies. Integrators
(Euler, Verlet) are a handful of addScaled calls.
Statistics / curve fitting (solve + transposedTimes gives least squares directly),
image kernels via reshape + column ops, pathfinding cost grids.