From 08e89024b5bb01c5310a9a708ccc1bd0d6429273 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:47:19 +0000 Subject: [PATCH 1/5] Initial plan From 963290c63dbdbd8a7cd9a5b90e529f05c6401ef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:08:53 +0000 Subject: [PATCH 2/5] Extract Layout class from Matrix; Phase 1 & 2 complete Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com> --- bytecodeAudit/package.mill | 68 +++--- experiments/src/cheatsheet.scala | 2 +- experiments/src/mnist.scala | 10 +- vecxt/src-js/doublematrix.scala | 12 +- vecxt/src-jvm/doublematrix.scala | 12 +- vecxt/src-jvm/floatmatrix.scala | 14 +- vecxt/src-jvm/intmatrix.scala | 4 +- vecxt/src-native/doublematrix_native.scala | 12 +- vecxt/src/MatrixInstance.scala | 12 +- vecxt/src/dimMatCheck.scala | 2 +- vecxt/src/doublematrix.scala | 50 ++-- vecxt/src/matrix.scala | 214 +++++++++++----- vecxt/src/matrixutil.scala | 9 +- vecxt/test/src/layout.test.scala | 243 +++++++++++++++++++ vecxt/test/src/matMulLayoutChecks.test.scala | 4 +- vecxt_io/package.mill | 1 - 16 files changed, 496 insertions(+), 173 deletions(-) create mode 100644 vecxt/test/src/layout.test.scala diff --git a/bytecodeAudit/package.mill b/bytecodeAudit/package.mill index 8b497d96..c23c53eb 100644 --- a/bytecodeAudit/package.mill +++ b/bytecodeAudit/package.mill @@ -6,25 +6,25 @@ import java.io.File /** Tier 1 of https://github.com/Quafadas/vecxt/issues/105 — static bytecode analysis. * * Phase 0 landed C6a as a munit test in `experiments.test`, deliberately ahead of any infrastructure. Phase 1 is that - * infrastructure: HotSpot threshold discovery (§1.0), the `@HotPath`/`@Thin`/`@AllocFree` annotations, checks C1, C2, C3 - * and C9, a checked-in baseline, and a report. C6a moves here with them, so that the exclusion list, the report and the - * checks are one thing rather than three. + * infrastructure: HotSpot threshold discovery (§1.0), the `@HotPath`/`@Thin`/`@AllocFree` annotations, checks C1, C2, + * C3 and C9, a checked-in baseline, and a report. C6a moves here with them, so that the exclusion list, the report and + * the checks are one thing rather than three. * - * The checks run as munit tests rather than as a Mill task, which is worth explaining because the plan describes a task. - * Two reasons, and the second is the load-bearing one: + * The checks run as munit tests rather than as a Mill task, which is worth explaining because the plan describes a + * task. Two reasons, and the second is the load-bearing one: * - * - The analyzer is ordinary module code, so the checks and their canary fixtures call exactly the same functions. An - * analyzer living in a build file can only be exercised by running the build, which is the reason the plan's own - * acceptance criteria ask for canaries that are *verified* to fail. - * - A failing test produces a JUnit report, which CI turns into check-run annotations against the diff. A failing Mill - * task produces console output. When a finding has to travel from CI back to whoever is reading the PR, the first is - * the channel that works. + * - The analyzer is ordinary module code, so the checks and their canary fixtures call exactly the same functions. + * An analyzer living in a build file can only be exercised by running the build, which is the reason the plan's + * own acceptance criteria ask for canaries that are *verified* to fail. + * - A failing test produces a JUnit report, which CI turns into check-run annotations against the diff. A failing + * Mill task produces console output. When a finding has to travel from CI back to whoever is reading the PR, the + * first is the channel that works. * - * The report is still written to a fixed directory on every run (pass or fail) for CI to upload and to post as a sticky - * PR comment, so the task-shaped outputs the plan asks for all exist — see `reportDir` below. + * The report is still written to a fixed directory on every run (pass or fail) for CI to upload and to post as a + * sticky PR comment, so the task-shaped outputs the plan asks for all exist — see `reportDir` below. * - * ./mill bytecodeAudit.test # run every check, write out/bytecode-audit/ - * ./mill bytecodeAudit.updateBaseline # adopt the proposal the last run wrote + * ./mill bytecodeAudit.test # run every check, write out/bytecode-audit/ ./mill bytecodeAudit.updateBaseline # adopt + * the proposal the last run wrote */ object `package` extends ScalaModule: @@ -38,14 +38,14 @@ object `package` extends ScalaModule: ) /** Where the report lands. Not `Task.dest`: CI has to find it without asking Mill, `updateBaseline` has to find the - * proposal a *failed* run wrote, and neither works from a task-scoped directory. Deliberately not `out/bytecodeAudit`, - * which is Mill's own output directory for this module. + * proposal a *failed* run wrote, and neither works from a task-scoped directory. Deliberately not + * `out/bytecodeAudit`, which is Mill's own output directory for this module. * * Takes the workspace root rather than reading `os.pwd`. The first version used `os.pwd`, which is not the workspace * root inside a task — the build evaluates in a server process — so the report was written somewhere consistent but - * wrong. The suite's own "the report was written" assertion passed against that path while CI's artifact upload and PR - * comment found nothing, which is the failure mode to avoid: an audit that reports on itself successfully and publishes - * nothing. + * wrong. The suite's own "the report was written" assertion passed against that path while CI's artifact upload and + * PR comment found nothing, which is the failure mode to avoid: an audit that reports on itself successfully and + * publishes nothing. */ def reportDir(ws: os.Path): os.Path = ws / "out" / "bytecode-audit" @@ -57,10 +57,10 @@ object `package` extends ScalaModule: * guess at the API a third time, this uses `sources()`, which this file already calls and which resolves to * `/bytecodeAudit/src`. * - * The `require` is the point. The previous two attempts were both wrong *silently*: the report went to a path that was - * consistent enough for the suite's own "was it written" assertion to pass, while CI's upload and PR comment found - * nothing. A derivation that cannot be checked is how that happens twice, so this one asserts it landed somewhere with a - * `build.mill` in it. + * The `require` is the point. The previous two attempts were both wrong *silently*: the report went to a path that + * was consistent enough for the suite's own "was it written" assertion to pass, while CI's upload and PR comment + * found nothing. A derivation that cannot be checked is how that happens twice, so this one asserts it landed + * somewhere with a `build.mill` in it. */ private def workspaceOf(sourceDirs: Seq[os.Path]): os.Path = val src = sourceDirs @@ -74,23 +74,23 @@ object `package` extends ScalaModule: /** Adopts the baseline proposal from the last audit run. * * Deliberately does not depend on the audit task. The audit FAILs when the baseline is missing or the ratchet has - * moved, and a command that ran the audit first could never be used to fix that — Mill would refuse to run it. So the - * audit always writes its proposal, and this copies it. + * moved, and a command that ran the audit first could never be used to fix that — Mill would refuse to run it. So + * the audit always writes its proposal, and this copies it. */ def updateBaseline() = Task.Command { val ws = workspaceOf(sources().map(_.path)) val target = baselineFile(ws) val proposed = reportDir(ws) / "baseline-proposed.json" - if !os.exists(proposed) then - sys.error(s"$proposed does not exist — run ./mill bytecodeAudit.test first, then this") + if !os.exists(proposed) then sys.error(s"$proposed does not exist — run ./mill bytecodeAudit.test first, then this") + end if os.makeDir.all(target / os.up) os.copy.over(proposed, target) Task.log.info(s"baseline adopted from $proposed -> $target") PathRef(target) } - /** Deliberately-broken fixtures. Compiled, never audited: the whole point is that every check below FAILs on them, and - * a check that has never been observed to fail might not work. + /** Deliberately-broken fixtures. Compiled, never audited: the whole point is that every check below FAILs on them, + * and a check that has never been observed to fail might not work. * * Not on any audited root, so nothing here can leak into a production finding. */ @@ -104,8 +104,8 @@ object `package` extends ScalaModule: * * That sweep is here rather than in `experiments` because it is the other half of C6a, not a separate concern. C6a * cannot see a generic *allocation*: `new Array[A](n)` resolves through `ClassTag[A]`, and whether the result is a - * `double[]` or an `Object[]` of boxed values depends on which ClassTag arrived, which bytecode does not record. So one - * check reads the library and the other runs it, they cover one question between them, and a change that weakens + * `double[]` or an `Object[]` of boxed values depends on which ClassTag arrived, which bytecode does not record. So + * one check reads the library and the other runs it, they cover one question between them, and a change that weakens * either should be looking at the other in the same directory. * * It is why this module's *test* scope depends on `vecxt` while the analyzer in `src` does not: reading classfiles @@ -121,8 +121,8 @@ object `package` extends ScalaModule: /** The audit's scope, handed over as system properties. * * Explicit roots rather than walking `java.class.path` the way Phase 0 did. A classpath walk cannot distinguish - * "this module is clean" from "this module was never on the classpath", so coverage could shrink silently; with the - * roots written down here, `CoverageSuite` can assert the set is the intended one. + * "this module is clean" from "this module was never on the classpath", so coverage could shrink silently; with + * the roots written down here, `CoverageSuite` can assert the set is the intended one. */ override def forkArgs = Task { val roots = Seq( diff --git a/experiments/src/cheatsheet.scala b/experiments/src/cheatsheet.scala index 91b02425..9a4a6080 100644 --- a/experiments/src/cheatsheet.scala +++ b/experiments/src/cheatsheet.scala @@ -124,7 +124,7 @@ object CheatsheetTest: private def matrixReverseSlicing(): Unit = val m = Matrix(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0), 3, 3) println(s"submatrix: ${m((m.rows - 1 until 0 by -1), ::).printMat}") - println(s"submatrix: ${m((m.rows - 1 until 0 by -1), ::).layout}") + println(s"submatrix: ${m((m.rows - 1 until 0 by -1), ::).layoutString}") end matrixReverseSlicing private def elementWiseOps(): Unit = diff --git a/experiments/src/mnist.scala b/experiments/src/mnist.scala index 3d247989..6eb0ced0 100644 --- a/experiments/src/mnist.scala +++ b/experiments/src/mnist.scala @@ -86,7 +86,7 @@ import scala.annotation.targetName s"pixelData shape: ${pixelData.shape}, pixelData rows: ${pixelData.rows}, pixelData cols: ${pixelData.cols}, pixelData rowStride: ${pixelData.rowStride}, pixelData colStride: ${pixelData.colStride} pixelData offset: ${pixelData.offset}" ) - println(s"x layout ${pixelData.layout}") + println(s"x layout ${pixelData.layoutString}") println(s"weight1 shape: ${weight1.shape}, weight1 rows: ${weight1.rows}, weight1 cols: ${weight1.cols}") println(s"weight2 shape: ${weight2.shape}, weight2 rows: ${weight2.rows}, weight2 cols: ${weight2.cols}") @@ -160,8 +160,8 @@ def foward_prop(w1: Matrix[Double], b1: Array[Double], w2: Matrix[Double], b2: A // println(s"weight1 shape: ${w1.shape}, weight1 rows: ${w1.rows}, weight1 cols: ${w1.cols}") // println(s"weight2 shape: ${w2.shape}, weight2 rows: ${w2.rows}, weight2 cols: ${w2.cols}") - // println(s"m: ${x.layout}") - // println(s"b: ${w1.layout}") + // println(s"m: ${x.layoutString}") + // println(s"b: ${w1.layoutString}") val z1 = (x @@ w1) // z1 += b1 z1.mapRowsInPlace(r => r.tap(_ += b1)) @@ -239,8 +239,8 @@ def back_prop( val m_inv = 1.0 / m // println(s"m: $m, m_inv: $m_inv") // println(s"Y shape: ${Y.shape}, Y rows: ${Y.rows}, Y cols: ${Y.cols} y colStride: ${Y.colStride}, y rowStride: ${Y.rowStride}, y offset: ${Y.offset}") - // println(a2.layout) - // println(Y.layout) + // println(a2.layoutString) + // println(Y.layoutString) val dz2 = a2 - Y val dw2 = m_inv * (a1.transpose @@ dz2) diff --git a/vecxt/src-js/doublematrix.scala b/vecxt/src-js/doublematrix.scala index cf1c2de9..51cdf850 100644 --- a/vecxt/src-js/doublematrix.scala +++ b/vecxt/src-js/doublematrix.scala @@ -18,7 +18,7 @@ object JsDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) >= d j += 1 end while @@ -34,7 +34,7 @@ object JsDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) > d j += 1 end while @@ -52,7 +52,7 @@ object JsDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) <= d j += 1 end while @@ -70,7 +70,7 @@ object JsDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) < d j += 1 end while @@ -94,8 +94,8 @@ object JsDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val mIdx = m.offset + i * m.rowStride + j * m.colStride - val bIdx = bmat.offset + i * bmat.rowStride + j * bmat.colStride + val mIdx = m.layout.linearIndex(i, j) + val bIdx = bmat.layout.linearIndex(i, j) newArr(i + j * m.rows) = if bmat.raw(bIdx) then m.raw(mIdx) else 0.0 j += 1 end while diff --git a/vecxt/src-jvm/doublematrix.scala b/vecxt/src-jvm/doublematrix.scala index 4f350d38..87dc3ed5 100644 --- a/vecxt/src-jvm/doublematrix.scala +++ b/vecxt/src-jvm/doublematrix.scala @@ -83,8 +83,8 @@ object JvmDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val mIdx = m.offset + i * m.rowStride + j * m.colStride - val bIdx = bmat.offset + i * bmat.rowStride + j * bmat.colStride + val mIdx = m.layout.linearIndex(i, j) + val bIdx = bmat.layout.linearIndex(i, j) newArr(i + j * m.rows) = if bmat.raw(bIdx) then m.raw(mIdx) else 0.0 j += 1 end while @@ -154,7 +154,7 @@ object JvmDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) >= d j += 1 end while @@ -170,7 +170,7 @@ object JvmDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) > d j += 1 end while @@ -186,7 +186,7 @@ object JvmDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) <= d j += 1 end while @@ -202,7 +202,7 @@ object JvmDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) < d j += 1 end while diff --git a/vecxt/src-jvm/floatmatrix.scala b/vecxt/src-jvm/floatmatrix.scala index 07f50cf7..1ae57407 100644 --- a/vecxt/src-jvm/floatmatrix.scala +++ b/vecxt/src-jvm/floatmatrix.scala @@ -99,8 +99,8 @@ object JvmFloatMatrix: while i < m.rows do var j = 0 while j < m.cols do - val mIdx = m.offset + i * m.rowStride + j * m.colStride - val bIdx = bmat.offset + i * bmat.rowStride + j * bmat.colStride + val mIdx = m.layout.linearIndex(i, j) + val bIdx = bmat.layout.linearIndex(i, j) newArr(i + j * m.rows) = if bmat.raw(bIdx) then m.raw(mIdx) else 0.0f j += 1 end while @@ -171,7 +171,7 @@ object JvmFloatMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) >= d j += 1 end while @@ -188,7 +188,7 @@ object JvmFloatMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) > d j += 1 end while @@ -205,7 +205,7 @@ object JvmFloatMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) <= d j += 1 end while @@ -222,7 +222,7 @@ object JvmFloatMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) < d j += 1 end while @@ -336,7 +336,7 @@ object JvmFloatMatrix: sameDimMatCheck(m, mat1) if sameDenseElementWiseMemoryLayoutCheck(m, mat1) then val newArr = vecxt.floatarrays.-(m.raw)(mat1.raw) - Matrix[Float](newArr, m.rows, m.cols, m.rowStride, m.colStride, m.offset) + Matrix(newArr, m.layout) else val newArr = Array.ofDim[Float](m.numel) val newMat = diff --git a/vecxt/src-jvm/intmatrix.scala b/vecxt/src-jvm/intmatrix.scala index d004e988..99e88e63 100644 --- a/vecxt/src-jvm/intmatrix.scala +++ b/vecxt/src-jvm/intmatrix.scala @@ -96,8 +96,8 @@ object JvmIntMatrix: while i < m.rows do var j = 0 while j < m.cols do - val mIdx = m.offset + i * m.rowStride + j * m.colStride - val bIdx = bmat.offset + i * bmat.rowStride + j * bmat.colStride + val mIdx = m.layout.linearIndex(i, j) + val bIdx = bmat.layout.linearIndex(i, j) newArr(i + j * m.rows) = if bmat.raw(bIdx) then m.raw(mIdx) else 0 j += 1 end while diff --git a/vecxt/src-native/doublematrix_native.scala b/vecxt/src-native/doublematrix_native.scala index e92cdb90..faa9169e 100644 --- a/vecxt/src-native/doublematrix_native.scala +++ b/vecxt/src-native/doublematrix_native.scala @@ -25,8 +25,8 @@ object NativeDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val mIdx = m.offset + i * m.rowStride + j * m.colStride - val bIdx = bmat.offset + i * bmat.rowStride + j * bmat.colStride + val mIdx = m.layout.linearIndex(i, j) + val bIdx = bmat.layout.linearIndex(i, j) newArr(i + j * m.rows) = if bmat.raw(bIdx) then m.raw(mIdx) else 0.0 j += 1 end while @@ -91,7 +91,7 @@ object NativeDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) >= d j += 1 end while @@ -107,7 +107,7 @@ object NativeDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) > d j += 1 end while @@ -125,7 +125,7 @@ object NativeDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) <= d j += 1 end while @@ -143,7 +143,7 @@ object NativeDoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) < d j += 1 end while diff --git a/vecxt/src/MatrixInstance.scala b/vecxt/src/MatrixInstance.scala index 4413ee3c..b1d91572 100644 --- a/vecxt/src/MatrixInstance.scala +++ b/vecxt/src/MatrixInstance.scala @@ -22,7 +22,7 @@ object MatrixInstance: m.raw(row * m.cols + col) = value else // General case: arbitrary offset/stride - m.raw(m.offset + row * m.rowStride + col * m.colStride) = value + m.raw(m.layout.linearIndex(row, col)) = value end if end update @@ -166,10 +166,10 @@ object MatrixInstance: // inline if boundsCheck == BoundsCheck.DoBoundsCheck.yes then // println(s"Element index for ($row, $col) in matrix with shape ${m.shape} is being checked") // println(s"Offset: ${m.offset}, Row stride: ${m.rowStride}, Col stride: ${m.colStride}") - // println(s"Calculated index: ${m.offset + row * m.rowStride + col * m.colStride}") - // println(s"element: ${m.raw(m.offset + row * m.rowStride + col * m.colStride)}") + // println(s"Calculated index: ${m.layout.linearIndex(row, col)}") + // println(s"element: ${m.raw(m.layout.linearIndex(row, col))}") // end if - m.offset + row * m.rowStride + col * m.colStride + m.layout.linearIndex(row, col) end elementIndex @@ -182,7 +182,7 @@ object MatrixInstance: m.raw(row * m.cols + col) else // General case: arbitrary offset/stride - m.raw(m.offset + row * m.rowStride + col * m.colStride) + m.raw(m.layout.linearIndex(row, col)) end if end apply @@ -216,7 +216,7 @@ object MatrixInstance: val newRowsSpan = newRows.last - newRows.head + 1 val newColsSpan = newCols.last - newCols.head + 1 - val newOffset = m.offset + newRows.head * m.rowStride + newCols.head * m.colStride + val newOffset = m.layout.linearIndex(newRows.head, newCols.head) Matrix( raw = m.raw, rows = newRowsSpan, diff --git a/vecxt/src/dimMatCheck.scala b/vecxt/src/dimMatCheck.scala index 46615a3c..5486a179 100644 --- a/vecxt/src/dimMatCheck.scala +++ b/vecxt/src/dimMatCheck.scala @@ -18,7 +18,7 @@ end sameDimMatCheck */ object sameDenseElementWiseMemoryLayoutCheck: def apply[A, B](a: Matrix[A], b: Matrix[B]): Boolean = - a.isDenseColMajor && b.isDenseColMajor && a.rowStride == b.rowStride || a.isDenseRowMajor && b.isDenseRowMajor && a.colStride == b.colStride + a.layout.sameElementOrderAs(b.layout) end sameDenseElementWiseMemoryLayoutCheck object indexCheckMat: diff --git a/vecxt/src/doublematrix.scala b/vecxt/src/doublematrix.scala index f2f2034c..517b2ed3 100644 --- a/vecxt/src/doublematrix.scala +++ b/vecxt/src/doublematrix.scala @@ -42,7 +42,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val idx = m.offset + i * m.rowStride + j * m.colStride + val idx = m.layout.linearIndex(i, j) m.raw(idx) = m.raw(idx) * d j += 1 end while @@ -50,21 +50,19 @@ object DoubleMatrix: end while def *(n: Double): Matrix[Double] = - if m.hasSimpleContiguousMemoryLayout then - Matrix[Double](vecxt.doublearrays.*(m.raw)(n), m.rows, m.cols, m.rowStride, m.colStride, m.offset) + if m.hasSimpleContiguousMemoryLayout then Matrix(vecxt.doublearrays.*(m.raw)(n), m.layout) else ??? end * def /(n: Double): Matrix[Double] = - if m.hasSimpleContiguousMemoryLayout then - Matrix[Double](vecxt.doublearrays./(m.raw)(n), m.rows, m.cols, m.rowStride, m.colStride, m.offset) + if m.hasSimpleContiguousMemoryLayout then Matrix(vecxt.doublearrays./(m.raw)(n), m.layout) else val newArr = Array.ofDim[Double](m.numel) var i = 0 while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(srcIdx) / n j += 1 end while @@ -74,8 +72,7 @@ object DoubleMatrix: end / def +(n: Double): Matrix[Double] = - if m.hasSimpleContiguousMemoryLayout then - Matrix[Double](vecxt.doublearrays.+(m.raw)(n), m.rows, m.cols, m.rowStride, m.colStride, m.offset) + if m.hasSimpleContiguousMemoryLayout then Matrix(vecxt.doublearrays.+(m.raw)(n), m.layout) else val newArr = Array.ofDim[Double](m.numel) m.raw.copyToArray(newArr) @@ -111,8 +108,8 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val idx = i * m.rowStride + j * m.colStride + m.offset - val idxOther = i * other.rowStride + j * other.colStride + other.offset + val idx = m.layout.linearIndex(i, j) + val idxOther = other.layout.linearIndex(i, j) newArr(i * m.cols + j) = math.max(m.raw(idx), other.raw(idxOther)) j += 1 end while @@ -123,8 +120,7 @@ object DoubleMatrix: end maximum def -(n: Double): Matrix[Double] = - if m.hasSimpleContiguousMemoryLayout then - Matrix[Double](vecxt.doublearrays.-(m.raw)(n), m.rows, m.cols, m.rowStride, m.colStride, m.offset) + if m.hasSimpleContiguousMemoryLayout then Matrix(vecxt.doublearrays.-(m.raw)(n), m.layout) else val newArr = Array.ofDim[Double](m.numel) m.raw.copyToArray(newArr) @@ -147,7 +143,7 @@ object DoubleMatrix: sameDimMatCheck(m, m2) if sameDenseElementWiseMemoryLayoutCheck(m, m2) then val newArr = vecxt.doublearrays.+(m.raw)(m2.raw) - Matrix[Double](newArr, m.rows, m.cols, m.rowStride, m.colStride, m.offset) + Matrix(newArr, m.layout) else val newArr = Array.ofDim[Double](m.numel) m.raw.copyToArray(newArr) @@ -177,7 +173,7 @@ object DoubleMatrix: if sameDenseElementWiseMemoryLayoutCheck(m, m2) then // Fast path: use SIMD-optimized array multiplication val newArr = vecxt.doublearrays.*(m.raw)(m2.raw) - Matrix[Double](newArr, m.rows, m.cols, m.rowStride, m.colStride, m.offset) + Matrix(newArr, m.layout) else // Different memory layouts: materialize one matrix to match the other's layout if m.isDenseColMajor then @@ -213,15 +209,15 @@ object DoubleMatrix: sameDimMatCheck(m, m2) if sameDenseElementWiseMemoryLayoutCheck(m, m2) then val newArr = vecxt.doublearrays./(m.raw)(m2.raw) - Matrix[Double](newArr, m.rows, m.cols, m.rowStride, m.colStride, m.offset) + Matrix(newArr, m.layout) else val newArr = Array.ofDim[Double](m.numel) var i = 0 while i < m.rows do var j = 0 while j < m.cols do - val mIdx = m.offset + i * m.rowStride + j * m.colStride - val m2Idx = m2.offset + i * m2.rowStride + j * m2.colStride + val mIdx = m.layout.linearIndex(i, j) + val m2Idx = m2.layout.linearIndex(i, j) newArr(i + j * m.rows) = m.raw(mIdx) / m2.raw(m2Idx) j += 1 end while @@ -236,7 +232,7 @@ object DoubleMatrix: sameDimMatCheck(m, m2) if sameDenseElementWiseMemoryLayoutCheck(m, m2) then val newArr = vecxt.doublearrays.-(m.raw)(m2.raw) - Matrix[Double](newArr, m.rows, m.cols, m.rowStride, m.colStride, m.offset) + Matrix(newArr, m.layout) else val newArr = Array.ofDim[Double](m.numel) m.raw.copyToArray(newArr) @@ -266,7 +262,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val idx = m.offset + i * m.rowStride + j * m.colStride + val idx = m.layout.linearIndex(i, j) m.raw(idx) = Math.exp(m.raw(idx)) j += 1 end while @@ -280,7 +276,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val idx = m.offset + i * m.rowStride + j * m.colStride + val idx = m.layout.linearIndex(i, j) m.raw(idx) = Math.log(m.raw(idx)) j += 1 end while @@ -295,7 +291,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = Math.exp(m.raw(srcIdx)) j += 1 end while @@ -312,7 +308,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = Math.log(m.raw(srcIdx)) j += 1 end while @@ -327,7 +323,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val idx = m.offset + i * m.rowStride + j * m.colStride + val idx = m.layout.linearIndex(i, j) m.raw(idx) = Math.sqrt(m.raw(idx)) j += 1 end while @@ -343,7 +339,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = Math.sqrt(m.raw(srcIdx)) j += 1 end while @@ -359,7 +355,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = Math.sin(m.raw(srcIdx)) j += 1 end while @@ -374,7 +370,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val idx = m.offset + i * m.rowStride + j * m.colStride + val idx = m.layout.linearIndex(i, j) m.raw(idx) = Math.sin(m.raw(idx)) j += 1 end while @@ -390,7 +386,7 @@ object DoubleMatrix: while i < m.rows do var j = 0 while j < m.cols do - val srcIdx = m.offset + i * m.rowStride + j * m.colStride + val srcIdx = m.layout.linearIndex(i, j) newArr(i + j * m.rows) = Math.cos(m.raw(srcIdx)) j += 1 end while diff --git a/vecxt/src/matrix.scala b/vecxt/src/matrix.scala index abe741df..f8c0358a 100644 --- a/vecxt/src/matrix.scala +++ b/vecxt/src/matrix.scala @@ -1,46 +1,142 @@ package vecxt import scala.annotation.publicInBinary +import vecxt.annotations.Thin object matrix: + /** Describes the memory layout of a [[Matrix]]: dimensions, strides, offset, and the length of the backing array. + * + * This is a `final class` (not a `case class` and not a `sealed trait`) for performance reasons: + * - `case class` synthesises `productElement: Int => Object`, which boxes — unacceptable on the central layout + * type of a performance library (vecxt/issues/105). + * - A `sealed trait` makes all field reads `invokeinterface`, which defeats C2's escape analysis and blocks scalar + * replacement of `FloatVector`/`VectorMask` temporaries. + * - `final class` gives 5-byte getters, under `MaxTrivialSize` (6) — the strongest inline category. + * + * The `kind` field is reserved for Phase 3 structured layouts (Diagonal, Triangular, etc.). In Phase 1/2 it is + * always [[Layout.Strided]]. Do not branch on `kind` yet. + * + * Invariant enforced by every checked factory: `raw.size == dataLength`. This is what makes a shared `Layout` safe — + * the array length is baked in, not re-read from the array. The `.size`-not-`.length` note: `dataLength` is set by + * the factory overloads using `raw.size`, which avoids the ScalaRunTime$.array_length path for abstract element + * types (vecxt/issues/105, check C6a). The predicate `hasSimpleContiguousMemoryLayout` is now computed entirely from + * `Layout` fields and is no longer generic. + */ + final class Layout @publicInBinary() private[matrix] ( + val rows: Row, + val cols: Col, + val rowStride: Int, + val colStride: Int, + val offset: Int, + val dataLength: Int, + val kind: Byte + ): + val numel: Int = rows * cols + val isDenseColMajor: Boolean = rowStride == 1 && colStride == rows && offset == 0 + val isDenseRowMajor: Boolean = rowStride == cols && colStride == 1 && offset == 0 + val hasSimpleContiguousMemoryLayout: Boolean = + (isDenseRowMajor || isDenseColMajor) && dataLength == numel + + /** Computes the linear (flat) index into the backing array for element at (row, col). + * + * Annotated `@Thin` so the bytecode audit (check C3) asserts it fits inside HotSpot's `MaxInlineSize` (35 bytes). + * Expected size is ~20 bytes. If it ever exceeds 35, 50+ cold call sites regress at once. + */ + @Thin + inline def linearIndex(row: Int, col: Int): Int = offset + row * rowStride + col * colStride + + /** Returns the transposed layout: rows ↔ cols, rowStride ↔ colStride. Shares the same backing data. */ + def transpose: Layout = new Layout(cols, rows, colStride, rowStride, offset, dataLength, kind) + + /** Returns a copy of this layout with a different `dataLength`. Use when constructing a matrix backed by an array + * of a different size than the original (e.g., after slicing or materialising a submatrix). + */ + def withDataLength(newLength: Int): Layout = + new Layout(rows, cols, rowStride, colStride, offset, newLength, kind) + + /** Returns true if this layout and `that` layout have the same element traversal order, which allows elementwise + * operations to proceed directly over the raw arrays without reindexing. + * + * Semantics are identical to the original `sameDenseElementWiseMemoryLayoutCheck` expression — operator precedence + * is preserved literally, not "cleaned up". + */ + def sameElementOrderAs(that: Layout): Boolean = + isDenseColMajor && that.isDenseColMajor && rowStride == that.rowStride || isDenseRowMajor && that.isDenseRowMajor && colStride == that.colStride + + /** Reproduces the original `Matrix#layout` string byte-for-byte so no test or `experiments` output changes. */ + override def toString: String = + s"rows: $rows, cols: $cols, rowStride: $rowStride, colStride: $colStride, " + + s"offset: $offset, data length: $dataLength" + + override def equals(that: Any): Boolean = that match + case l: Layout => + rows == l.rows && cols == l.cols && rowStride == l.rowStride && + colStride == l.colStride && offset == l.offset && dataLength == l.dataLength && kind == l.kind + case _ => false + + override def hashCode: Int = + var h = rows.hashCode() + h = 31 * h + cols.hashCode() + h = 31 * h + rowStride.hashCode() + h = 31 * h + colStride.hashCode() + h = 31 * h + offset.hashCode() + h = 31 * h + dataLength.hashCode() + h = 31 * h + kind.hashCode() + h + end hashCode + end Layout + + object Layout: + /** Standard strided layout — the only kind used in Phase 1 and Phase 2. */ + val Strided: Byte = 0 + + /** Public factory for creating [[Layout]] instances. The constructor is `private[matrix]` to prevent bypassing the + * checked `Matrix` factories; this companion factory allows direct construction for testing and internal tooling. + */ + def apply( + rows: Row, + cols: Col, + rowStride: Int, + colStride: Int, + offset: Int, + dataLength: Int, + kind: Byte = Strided + ): Layout = + new Layout(rows, cols, rowStride, colStride, offset, dataLength, kind) + end Layout + /** This is a matrix. The constructor is private to ensure that you deliberately opt in or out of the bounds check. * * @param raw * The underlying array that holds the matrix data. - * @param rows - * The number of rows in the matrix. - * @param cols - * The number of columns in the matrix. - * @param rowStride - * The stride for rows, used for efficient access. - * @param colStride - * The stride for columns, used for efficient access. - * @param offset - * The offset in the raw array where the matrix data starts. + * @param layout + * The memory layout describing dimensions, strides, offset and data length. * @tparam A * The type of elements in the matrix, specialized for Double, Boolean */ - final class Matrix[A] @publicInBinary() private[matrix] ( val raw: Array[A], - val rows: Row, - val cols: Col, - val rowStride: Int, - val colStride: Int, - val offset: Int = 0 + val layout: Layout ): + inline def rows: Row = layout.rows + inline def cols: Col = layout.cols + inline def rowStride: Int = layout.rowStride + inline def colStride: Int = layout.colStride + inline def offset: Int = layout.offset + inline def numel: Int = layout.numel + inline def isDenseColMajor: Boolean = layout.isDenseColMajor + inline def isDenseRowMajor: Boolean = layout.isDenseRowMajor - /** If the matrix is dense and contiguous in row major order, it means that the data is stored in a single block of - * memory in row major order. Useful for performance optimizations. - * @return + /** If the matrix is dense and contiguous, it means that the data is stored in a single block of memory in row or + * column major order, with the exact number of elements matching the number of rows and columns. + * + * We can take advantage of this for performance. + * + * Originally checked `(isDenseRowMajor || isDenseColMajor) && raw.size == numel`. `raw.size` is now baked into + * `layout.dataLength` at construction time (vecxt/issues/105, check C6a): every checked factory reads `raw.size` + * once and stores it in the `Layout`, so this predicate is no longer generic — it reads only `Int` fields. */ - val isDenseColMajor: Boolean = - rowStride == 1 && colStride == rows && offset == 0 - - val isDenseRowMajor: Boolean = - rowStride == cols && colStride == 1 && offset == 0 - - val numel: Int = rows * cols + inline def hasSimpleContiguousMemoryLayout: Boolean = layout.hasSimpleContiguousMemoryLayout /** Runtime element type of the backing store - `double` for a specialised `Matrix[Double]`, `java.lang.Object` for * one whose data got boxed into an `Object[]`. See `NDArray#elementClass` for why this is exposed rather than @@ -48,23 +144,12 @@ object matrix: */ def elementClass: Class[?] = raw.getClass.getComponentType - /** If the matrix is dense and contiguous, it means that the data is stored in a single block of memory in row or - * column major, or row major order, with the exact number of elements matching the number of rows and columns. + /** Returns the layout description string. Delegates to [[Layout.toString]]. * - * We can take advantage of this for performance. - * - * @return + * Renamed from `layout` (which now refers to the [[Layout]] value) to `layoutString`. The string format is + * unchanged: "rows: …, cols: …, rowStride: …, colStride: …, offset: …, data length: …". */ - // `.size` rather than `.length` (vecxt/issues/105, check C6a): `raw` is this class's own abstract `A` - // here, not a caller's concrete one, so no factory overload reaches this - but Array#size (unlike - // #length) doesn't route through ScalaRunTime$.array_length even when the element type is abstract. - // Confirmed empirically: Matrix.apply's dim-based overload already used raw.size for this same check - // and never showed up as a hit of any kind, unlike the stride-based overload's raw.length. - val hasSimpleContiguousMemoryLayout: Boolean = - (isDenseRowMajor || isDenseColMajor) && raw.size == numel - - def layout: String = - s"rows: $rows, cols: $cols, rowStride: $rowStride, colStride: $colStride, offset: $offset, data length: ${raw.size}" + def layoutString: String = layout.toString end Matrix object Matrix: @@ -80,11 +165,7 @@ object matrix: strideMatInstantiateCheck(raw, rows, cols, rowStride, colStride, offset) new Matrix( raw = raw, - rows = rows, - cols = cols, - rowStride = rowStride, - colStride = colStride, - offset = offset + layout = new Layout(rows, cols, rowStride, colStride, offset, raw.size, Layout.Strided) ) end apply @@ -96,22 +177,22 @@ object matrix: // compiled class either way; see the comment on that field. def apply(raw: Array[Double], rows: Row, cols: Col, rowStride: Int, colStride: Int, offset: Int): Matrix[Double] = strideMatInstantiateCheck(raw, rows, cols, rowStride, colStride, offset) - new Matrix(raw = raw, rows = rows, cols = cols, rowStride = rowStride, colStride = colStride, offset = offset) + new Matrix(raw = raw, layout = new Layout(rows, cols, rowStride, colStride, offset, raw.size, Layout.Strided)) end apply def apply(raw: Array[Float], rows: Row, cols: Col, rowStride: Int, colStride: Int, offset: Int): Matrix[Float] = strideMatInstantiateCheck(raw, rows, cols, rowStride, colStride, offset) - new Matrix(raw = raw, rows = rows, cols = cols, rowStride = rowStride, colStride = colStride, offset = offset) + new Matrix(raw = raw, layout = new Layout(rows, cols, rowStride, colStride, offset, raw.size, Layout.Strided)) end apply def apply(raw: Array[Int], rows: Row, cols: Col, rowStride: Int, colStride: Int, offset: Int): Matrix[Int] = strideMatInstantiateCheck(raw, rows, cols, rowStride, colStride, offset) - new Matrix(raw = raw, rows = rows, cols = cols, rowStride = rowStride, colStride = colStride, offset = offset) + new Matrix(raw = raw, layout = new Layout(rows, cols, rowStride, colStride, offset, raw.size, Layout.Strided)) end apply def apply(raw: Array[Long], rows: Row, cols: Col, rowStride: Int, colStride: Int, offset: Int): Matrix[Long] = strideMatInstantiateCheck(raw, rows, cols, rowStride, colStride, offset) - new Matrix(raw = raw, rows = rows, cols = cols, rowStride = rowStride, colStride = colStride, offset = offset) + new Matrix(raw = raw, layout = new Layout(rows, cols, rowStride, colStride, offset, raw.size, Layout.Strided)) end apply def apply( @@ -123,7 +204,20 @@ object matrix: offset: Int ): Matrix[Boolean] = strideMatInstantiateCheck(raw, rows, cols, rowStride, colStride, offset) - new Matrix(raw = raw, rows = rows, cols = cols, rowStride = rowStride, colStride = colStride, offset = offset) + new Matrix(raw = raw, layout = new Layout(rows, cols, rowStride, colStride, offset, raw.size, Layout.Strided)) + end apply + + /** Constructs a checked `Matrix` from an existing [[Layout]]. Throws `IllegalArgumentException` if + * `raw.size != layout.dataLength` — that mismatch would mean the array is too small (or too large) for the layout + * that was promised at construction time. + */ + def apply[A](raw: Array[A], layout: Layout): Matrix[A] = + if raw.size != layout.dataLength then + throw new IllegalArgumentException( + s"raw array length ${raw.size} does not match layout.dataLength ${layout.dataLength}" + ) + end if + new Matrix(raw, layout) end apply def apply[A](raw: Array[A], dim: RowCol): Matrix[A] = @@ -131,11 +225,7 @@ object matrix: new Matrix( raw = raw, - rows = dim._1, - cols = dim._2, - rowStride = 1, - colStride = dim._1, - offset = 0 + layout = new Layout(dim._1, dim._2, 1, dim._1, 0, raw.size, Layout.Strided) ) end apply @@ -151,17 +241,19 @@ object matrix: dimMatInstantiateCheck(raw, (rows, cols)) new Matrix( raw = raw, - rows = rows, - cols = cols, - rowStride = 1, - colStride = rows, - offset = 0 + layout = new Layout(rows, cols, 1, rows, 0, raw.size, Layout.Strided) ) end apply def apply[A](dim: RowCol, raw: Array[A]): Matrix[A] = Matrix(raw, dim._1, dim._2) end apply + + /** Unchecked internal constructor — use only when the invariant `raw.size == layout.dataLength` has already been + * proven at the call site (e.g., the array was just created with exactly that length, or it comes from an existing + * checked `Matrix`). Mirrors `ndarray.mkNDArray`. + */ + private[vecxt] def mkMatrix[A](raw: Array[A], layout: Layout): Matrix[A] = new Matrix(raw, layout) end Matrix extension [A](m: Matrix[A]) diff --git a/vecxt/src/matrixutil.scala b/vecxt/src/matrixutil.scala index bdc1252a..4f84d241 100644 --- a/vecxt/src/matrixutil.scala +++ b/vecxt/src/matrixutil.scala @@ -142,14 +142,7 @@ object matrixUtil: * // [2, 4]] * }}} */ - def transpose: Matrix[A] = Matrix( - raw = m.raw, - rows = m.cols, // swap dimensions - cols = m.rows, - rowStride = m.colStride, // swap strides - colStride = m.rowStride, - offset = m.offset // same offset - ) + def transpose: Matrix[A] = Matrix.mkMatrix(m.raw, m.layout.transpose) inline def diag(using ClassTag[A]): Array[A] = val minDim = Math.min(m.rows, m.cols) diff --git a/vecxt/test/src/layout.test.scala b/vecxt/test/src/layout.test.scala new file mode 100644 index 00000000..e7677de7 --- /dev/null +++ b/vecxt/test/src/layout.test.scala @@ -0,0 +1,243 @@ +package vecxt + +import munit.FunSuite +import vecxt.matrix.* + +class LayoutTest extends FunSuite: + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private def colMajorLayout(rows: Int, cols: Int): Layout = + Layout(rows, cols, 1, rows, 0, rows * cols) + + private def rowMajorLayout(rows: Int, cols: Int): Layout = + Layout(rows, cols, cols, 1, 0, rows * cols) + + // ─── derived flags ────────────────────────────────────────────────────────── + + test("isDenseColMajor — true for standard col-major layout"): + val l = colMajorLayout(3, 4) + assert(l.isDenseColMajor) + assert(!l.isDenseRowMajor) + + test("isDenseRowMajor — true for standard row-major layout"): + val l = rowMajorLayout(3, 4) + assert(l.isDenseRowMajor) + assert(!l.isDenseColMajor) + + test("isDenseColMajor — false when rowStride != 1"): + val l = Layout(3, 4, 2, 3, 0, 3 * 4 * 2) + assert(!l.isDenseColMajor) + assert(!l.hasSimpleContiguousMemoryLayout) + + test("isDenseColMajor — false when colStride != rows"): + val l = Layout(3, 4, 1, 2, 0, 3 * 4) + assert(!l.isDenseColMajor) + assert(!l.hasSimpleContiguousMemoryLayout) + + test("hasSimpleContiguousMemoryLayout — false when offset != 0"): + val l = Layout(3, 4, 1, 3, 2, 14) + assert(!l.hasSimpleContiguousMemoryLayout) + + test("hasSimpleContiguousMemoryLayout — false when dataLength != numel"): + // Dense col-major strides but extra elements in the backing array + val l = Layout(3, 4, 1, 3, 0, 13) + assert(l.isDenseColMajor) + assert(!l.hasSimpleContiguousMemoryLayout) + + test("hasSimpleContiguousMemoryLayout — true for dense col-major, dataLength == numel"): + val l = colMajorLayout(3, 4) + assert(l.hasSimpleContiguousMemoryLayout) + + test("hasSimpleContiguousMemoryLayout — true for dense row-major, dataLength == numel"): + val l = rowMajorLayout(3, 4) + assert(l.hasSimpleContiguousMemoryLayout) + + test("numel is rows * cols"): + val l = colMajorLayout(5, 7) + assertEquals(l.numel, 35) + + // ─── linearIndex ──────────────────────────────────────────────────────────── + + test("linearIndex — col-major agrees with hand-written arithmetic"): + val l = colMajorLayout(3, 4) + for i <- 0 until 3 do + for j <- 0 until 4 do + assertEquals(l.linearIndex(i, j), l.offset + i * l.rowStride + j * l.colStride) + + test("linearIndex — row-major agrees with hand-written arithmetic"): + val l = rowMajorLayout(3, 4) + for i <- 0 until 3 do + for j <- 0 until 4 do + assertEquals(l.linearIndex(i, j), l.offset + i * l.rowStride + j * l.colStride) + + test("linearIndex — strided layout agrees with hand-written arithmetic"): + val l = Layout(3, 4, 2, 6, 1, 3 * 4 * 2 * 3) + for i <- 0 until 3 do + for j <- 0 until 4 do + assertEquals(l.linearIndex(i, j), 1 + i * 2 + j * 6) + + test("linearIndex — with offset"): + val l = Layout(2, 2, 1, 2, 5, 14) + assertEquals(l.linearIndex(0, 0), 5) + assertEquals(l.linearIndex(1, 0), 6) + assertEquals(l.linearIndex(0, 1), 7) + assertEquals(l.linearIndex(1, 1), 8) + + test("linearIndex — transposed layout"): + val l = colMajorLayout(3, 4) + val lt = l.transpose + // After transpose: rows = 4, cols = 3; accessing (j, i) in transposed == (i, j) in original + for i <- 0 until 3 do + for j <- 0 until 4 do + assertEquals(lt.linearIndex(j, i), l.linearIndex(i, j)) + + test("linearIndex — negative strides (reversed view)"): + // row-reversed view: start at index 2, rowStride = -1, one column + val l = Layout(3, 1, -1, 3, 2, 3) + assertEquals(l.linearIndex(0, 0), 2) + assertEquals(l.linearIndex(1, 0), 1) + assertEquals(l.linearIndex(2, 0), 0) + + test("linearIndex — zero row stride (broadcast rows)"): + val l = Layout(4, 3, 0, 1, 0, 3) + // All rows point to the same slice + for i <- 0 until 4 do + for j <- 0 until 3 do + assertEquals(l.linearIndex(i, j), j) + + // ─── transpose ────────────────────────────────────────────────────────────── + + test("transpose swaps rows/cols and strides"): + val l = colMajorLayout(3, 4) + val lt = l.transpose + assertEquals(lt.rows, 4) + assertEquals(lt.cols, 3) + assertEquals(lt.rowStride, l.colStride) + assertEquals(lt.colStride, l.rowStride) + assertEquals(lt.offset, l.offset) + assertEquals(lt.dataLength, l.dataLength) + + test("transpose.transpose == original (property)"): + val l = Layout(5, 7, 2, 10, 3, 100) + assertEquals(l.transpose.transpose, l) + + test("transpose.linearIndex(j, i) == linearIndex(i, j) (property)"): + val l = colMajorLayout(4, 6) + val lt = l.transpose + for i <- 0 until 4 do + for j <- 0 until 6 do + assertEquals(lt.linearIndex(j, i), l.linearIndex(i, j)) + + // ─── withDataLength ───────────────────────────────────────────────────────── + + test("withDataLength changes dataLength and nothing else"): + val l = colMajorLayout(3, 4) + val l2 = l.withDataLength(999) + assertEquals(l2.dataLength, 999) + assertEquals(l2.rows, l.rows) + assertEquals(l2.cols, l.cols) + assertEquals(l2.rowStride, l.rowStride) + assertEquals(l2.colStride, l.colStride) + assertEquals(l2.offset, l.offset) + assertEquals(l2.kind, l.kind) + + // ─── equals / hashCode ────────────────────────────────────────────────────── + + test("equals — same fields means equal"): + val l1 = colMajorLayout(3, 4) + val l2 = colMajorLayout(3, 4) + assertEquals(l1, l2) + + test("equals — different rows means not equal"): + assert(colMajorLayout(3, 4) != colMajorLayout(2, 4)) + + test("equals — different cols means not equal"): + assert(colMajorLayout(3, 4) != colMajorLayout(3, 5)) + + test("equals — different rowStride means not equal"): + val l1 = Layout(3, 4, 1, 3, 0, 12) + val l2 = Layout(3, 4, 2, 3, 0, 12) + assert(l1 != l2) + + test("equals — different offset means not equal"): + val l1 = Layout(3, 4, 1, 3, 0, 14) + val l2 = Layout(3, 4, 1, 3, 2, 14) + assert(l1 != l2) + + test("equals — different dataLength means not equal"): + val l1 = Layout(3, 4, 1, 3, 0, 12) + val l2 = Layout(3, 4, 1, 3, 0, 13) + assert(l1 != l2) + + test("hashCode — equal layouts have the same hashCode"): + val l1 = colMajorLayout(3, 4) + val l2 = colMajorLayout(3, 4) + assertEquals(l1.hashCode(), l2.hashCode()) + + test("equals — not equal to non-Layout"): + val l: Any = colMajorLayout(3, 4) + assert(l != "not a layout") + assert(l != 42) + + // ─── toString ─────────────────────────────────────────────────────────────── + + test("toString matches original Matrix#layout format"): + val l = Layout(3, 4, 1, 3, 0, 12) + assertEquals(l.toString, "rows: 3, cols: 4, rowStride: 1, colStride: 3, offset: 0, data length: 12") + + // ─── sameElementOrderAs ───────────────────────────────────────────────────── + + test("sameElementOrderAs — two col-major layouts with same rowStride"): + val l1 = colMajorLayout(3, 4) + val l2 = colMajorLayout(3, 4) + assert(l1.sameElementOrderAs(l2)) + + test("sameElementOrderAs — two row-major layouts with same colStride"): + val l1 = rowMajorLayout(3, 4) + val l2 = rowMajorLayout(3, 4) + assert(l1.sameElementOrderAs(l2)) + + test("sameElementOrderAs — col-major vs row-major is false"): + val l1 = colMajorLayout(3, 4) + val l2 = rowMajorLayout(3, 4) + assert(!l1.sameElementOrderAs(l2)) + + // ─── Matrix.apply(raw, layout) ─────────────────────────────────────────────── + + test("Matrix.apply(raw, layout) succeeds when raw.size == layout.dataLength"): + val raw = Array.tabulate(12)(_.toDouble) + val l = colMajorLayout(3, 4) + val m = Matrix(raw, l) + assertEquals(m.rows, 3) + assertEquals(m.cols, 4) + + test("Matrix.apply(raw, layout) throws when raw.size != layout.dataLength"): + val raw = Array.tabulate(10)(_.toDouble) + val l = colMajorLayout(3, 4) // dataLength = 12 + intercept[IllegalArgumentException]: + Matrix(raw, l) + + // ─── Matrix forwarders ────────────────────────────────────────────────────── + + test("Matrix forwarders delegate to layout"): + val raw = Array.tabulate(12)(_.toDouble) + val l = colMajorLayout(3, 4) + val m = Matrix(raw, l) + assertEquals(m.rows, l.rows) + assertEquals(m.cols, l.cols) + assertEquals(m.rowStride, l.rowStride) + assertEquals(m.colStride, l.colStride) + assertEquals(m.offset, l.offset) + assertEquals(m.numel, l.numel) + assertEquals(m.isDenseColMajor, l.isDenseColMajor) + assertEquals(m.isDenseRowMajor, l.isDenseRowMajor) + assertEquals(m.hasSimpleContiguousMemoryLayout, l.hasSimpleContiguousMemoryLayout) + + test("Matrix.layoutString delegates to layout.toString"): + val raw = Array.tabulate(12)(_.toDouble) + val l = colMajorLayout(3, 4) + val m = Matrix(raw, l) + assertEquals(m.layoutString, l.toString) + +end LayoutTest diff --git a/vecxt/test/src/matMulLayoutChecks.test.scala b/vecxt/test/src/matMulLayoutChecks.test.scala index 027b8fd8..90515acb 100644 --- a/vecxt/test/src/matMulLayoutChecks.test.scala +++ b/vecxt/test/src/matMulLayoutChecks.test.scala @@ -163,8 +163,8 @@ class DifferentMemoryLayoutTests extends FunSuite: // println("against") // println(matMul2.printMat) - // println(checkAgainst2.layout) - // println(matMul2.layout) + // println(checkAgainst2.layoutString) + // println(matMul2.layoutString) for i <- 0 until checkAgainst2.rows do for j <- 0 until checkAgainst2.cols do assertEqualsDouble(checkAgainst2(i, j), matMul2(i, j), 0.00001, (i, j)) diff --git a/vecxt_io/package.mill b/vecxt_io/package.mill index 18afa196..a30d0d0f 100644 --- a/vecxt_io/package.mill +++ b/vecxt_io/package.mill @@ -10,7 +10,6 @@ import mill.contrib.scoverage.ScoverageModule object `package` extends Module: trait VecxtModule extends PlatformScalaModule with build.VecxtPublishModule: def mvnDeps = super.mvnDeps() ++ Seq( - ) trait VecxtTest extends ScalaTests, TestModule.Munit: def mvnDeps = super.mvnDeps() ++ Seq( From f6e147a3700ae1b89322116eb73faff7e6e2d5f8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:37:49 +0000 Subject: [PATCH 3/5] [autofix.ci] apply automated fixes --- vecxt/test/src/layout.test.scala | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/vecxt/test/src/layout.test.scala b/vecxt/test/src/layout.test.scala index e7677de7..d16bd2c4 100644 --- a/vecxt/test/src/layout.test.scala +++ b/vecxt/test/src/layout.test.scala @@ -62,20 +62,19 @@ class LayoutTest extends FunSuite: test("linearIndex — col-major agrees with hand-written arithmetic"): val l = colMajorLayout(3, 4) for i <- 0 until 3 do - for j <- 0 until 4 do - assertEquals(l.linearIndex(i, j), l.offset + i * l.rowStride + j * l.colStride) + for j <- 0 until 4 do assertEquals(l.linearIndex(i, j), l.offset + i * l.rowStride + j * l.colStride) + end for test("linearIndex — row-major agrees with hand-written arithmetic"): val l = rowMajorLayout(3, 4) for i <- 0 until 3 do - for j <- 0 until 4 do - assertEquals(l.linearIndex(i, j), l.offset + i * l.rowStride + j * l.colStride) + for j <- 0 until 4 do assertEquals(l.linearIndex(i, j), l.offset + i * l.rowStride + j * l.colStride) + end for test("linearIndex — strided layout agrees with hand-written arithmetic"): val l = Layout(3, 4, 2, 6, 1, 3 * 4 * 2 * 3) - for i <- 0 until 3 do - for j <- 0 until 4 do - assertEquals(l.linearIndex(i, j), 1 + i * 2 + j * 6) + for i <- 0 until 3 do for j <- 0 until 4 do assertEquals(l.linearIndex(i, j), 1 + i * 2 + j * 6) + end for test("linearIndex — with offset"): val l = Layout(2, 2, 1, 2, 5, 14) @@ -88,9 +87,8 @@ class LayoutTest extends FunSuite: val l = colMajorLayout(3, 4) val lt = l.transpose // After transpose: rows = 4, cols = 3; accessing (j, i) in transposed == (i, j) in original - for i <- 0 until 3 do - for j <- 0 until 4 do - assertEquals(lt.linearIndex(j, i), l.linearIndex(i, j)) + for i <- 0 until 3 do for j <- 0 until 4 do assertEquals(lt.linearIndex(j, i), l.linearIndex(i, j)) + end for test("linearIndex — negative strides (reversed view)"): // row-reversed view: start at index 2, rowStride = -1, one column @@ -102,9 +100,8 @@ class LayoutTest extends FunSuite: test("linearIndex — zero row stride (broadcast rows)"): val l = Layout(4, 3, 0, 1, 0, 3) // All rows point to the same slice - for i <- 0 until 4 do - for j <- 0 until 3 do - assertEquals(l.linearIndex(i, j), j) + for i <- 0 until 4 do for j <- 0 until 3 do assertEquals(l.linearIndex(i, j), j) + end for // ─── transpose ────────────────────────────────────────────────────────────── @@ -125,9 +122,8 @@ class LayoutTest extends FunSuite: test("transpose.linearIndex(j, i) == linearIndex(i, j) (property)"): val l = colMajorLayout(4, 6) val lt = l.transpose - for i <- 0 until 4 do - for j <- 0 until 6 do - assertEquals(lt.linearIndex(j, i), l.linearIndex(i, j)) + for i <- 0 until 4 do for j <- 0 until 6 do assertEquals(lt.linearIndex(j, i), l.linearIndex(i, j)) + end for // ─── withDataLength ───────────────────────────────────────────────────────── From 518d890d8fd66e68fc0dc2e2dc326bc0d3cbbb8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:53:20 +0000 Subject: [PATCH 4/5] fix: remove @Thin from inline def linearIndex (invalid on inline methods) Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com> --- vecxt/src/matrix.scala | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vecxt/src/matrix.scala b/vecxt/src/matrix.scala index f8c0358a..2002df06 100644 --- a/vecxt/src/matrix.scala +++ b/vecxt/src/matrix.scala @@ -39,10 +39,8 @@ object matrix: /** Computes the linear (flat) index into the backing array for element at (row, col). * - * Annotated `@Thin` so the bytecode audit (check C3) asserts it fits inside HotSpot's `MaxInlineSize` (35 bytes). - * Expected size is ~20 bytes. If it ever exceeds 35, 50+ cold call sites regress at once. + * Inlined into every call site — the expression expands to ~4 JVM bytecodes. No separate method is emitted. */ - @Thin inline def linearIndex(row: Int, col: Int): Int = offset + row * rowStride + col * colStride /** Returns the transposed layout: rows ↔ cols, rowStride ↔ colStride. Shares the same backing data. */ From 1d30803447cef371d68b841fc5298a25928a11b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:23:30 +0000 Subject: [PATCH 5/5] fix: validate stride/offset in Matrix.apply(raw, layout); remove unused Thin import Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com> --- vecxt/src/matrix.scala | 5 +++-- vecxt/test/src/layout.test.scala | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/vecxt/src/matrix.scala b/vecxt/src/matrix.scala index 2002df06..0fdcc984 100644 --- a/vecxt/src/matrix.scala +++ b/vecxt/src/matrix.scala @@ -1,6 +1,5 @@ package vecxt import scala.annotation.publicInBinary -import vecxt.annotations.Thin object matrix: @@ -207,7 +206,8 @@ object matrix: /** Constructs a checked `Matrix` from an existing [[Layout]]. Throws `IllegalArgumentException` if * `raw.size != layout.dataLength` — that mismatch would mean the array is too small (or too large) for the layout - * that was promised at construction time. + * that was promised at construction time. Also delegates to [[strideMatInstantiateCheck]] to verify that every + * element reachable via the layout's strides and offset falls within `raw`'s bounds. */ def apply[A](raw: Array[A], layout: Layout): Matrix[A] = if raw.size != layout.dataLength then @@ -215,6 +215,7 @@ object matrix: s"raw array length ${raw.size} does not match layout.dataLength ${layout.dataLength}" ) end if + strideMatInstantiateCheck(raw, layout.rows, layout.cols, layout.rowStride, layout.colStride, layout.offset) new Matrix(raw, layout) end apply diff --git a/vecxt/test/src/layout.test.scala b/vecxt/test/src/layout.test.scala index d16bd2c4..be3e104e 100644 --- a/vecxt/test/src/layout.test.scala +++ b/vecxt/test/src/layout.test.scala @@ -214,6 +214,13 @@ class LayoutTest extends FunSuite: intercept[IllegalArgumentException]: Matrix(raw, l) + test("Matrix.apply(raw, layout) throws when stride/offset exceeds array bounds"): + // 2×2 layout with rowStride=60 — accessing (1, 0) yields index 60, beyond array size 4 + val raw = Array.tabulate(4)(_.toDouble) + val l = Layout(2, 2, 60, 1, 0, 4) + intercept[IndexOutOfBoundsException]: + Matrix(raw, l) + // ─── Matrix forwarders ────────────────────────────────────────────────────── test("Matrix forwarders delegate to layout"):