diff --git a/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/Goldens.kt b/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/Goldens.kt index f28a30810..30e6ebdb9 100644 --- a/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/Goldens.kt +++ b/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/Goldens.kt @@ -9,6 +9,16 @@ package sk.ainet.exec.golden */ internal object Goldens { val expected: Map = mapOf( + // #1034 — the zero-copy packed transpose: a TensorView whose block axis moved, decoding + // the same matrix the block-grid permutation in DefaultCpuOps.transpose produces. + "transpose/Q4_0" to "n=384 fnv=272715de48d49929 head=3c442000,3df90000,be8ed800,be2e0000", + "transpose/Q4_K" to "n=3072 fnv=894ff6387d5c031f head=41ca6280,4129ccc0,414a2844,41d82e00", + "transpose/Q5_0" to "n=384 fnv=73c3dedcac1b0471 head=bd4ce000,3ddf7000,3e857000,3c2e0000", + "transpose/Q5_1" to "n=384 fnv=c7a3a894ff2b8d10 head=3ec3a800,be569800,3ef3fe00,bdc0f000", + "transpose/Q5_K" to "n=3072 fnv=9646306126643f2d head=41cc33a0,40480970,421ca022,41b5e2b8", + "transpose/Q6_K" to "n=3072 fnv=fee2e34031fb8e86 head=411c7680,c21b1e00,416739c0,4140f8c8", + "transpose/Q8_0" to "n=384 fnv=9c5a086e8bc0d528 head=40117580,bf912800,4014f800,3fb2db00", + // #1033 — the ternary encodings, encoded and decoded by TernaryCodec (the GGML layout, // interleave included). TQ1_0 and TQ2_0 share a decode digest on purpose: the same ternary // values in two different byte layouts must come back identical. diff --git a/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/PackedTransposeGoldenTest.kt b/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/PackedTransposeGoldenTest.kt new file mode 100644 index 000000000..a1596bdd6 --- /dev/null +++ b/skainet-backends/skainet-backend-cpu/src/goldenTest/kotlin/sk/ainet/exec/golden/PackedTransposeGoldenTest.kt @@ -0,0 +1,129 @@ +package sk.ainet.exec.golden + +import sk.ainet.context.DirectCpuExecutionContext +import sk.ainet.exec.golden.GoldenSupport.Packed +import sk.ainet.lang.memory.ExperimentalMemoryApi +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.Tensor +import sk.ainet.lang.tensor.data.Q4_0BlockTensorData +import sk.ainet.lang.tensor.data.Q4_KBlockTensorData +import sk.ainet.lang.tensor.data.Q5_0BlockTensorData +import sk.ainet.lang.tensor.data.Q5_1BlockTensorData +import sk.ainet.lang.tensor.data.Q5_KBlockTensorData +import sk.ainet.lang.tensor.data.Q6_KBlockTensorData +import sk.ainet.lang.tensor.data.Q8_0BlockTensorData +import sk.ainet.lang.tensor.data.TensorData +import sk.ainet.lang.tensor.storage.PackedBlockStorage +import sk.ainet.lang.tensor.t +import sk.ainet.lang.types.FP32 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * #1034: a packed transpose is metadata. + * + * `DefaultCpuOps.transpose` permutes the block grid byte by byte, because the packed matmul + * kernels read their weight as input-block-major regardless of its declared shape — the contract + * that made #968/#971 read garbage from a bare shape swap, and that #973 exists to write down. + * A `TensorView` needs no such permutation: transposing swaps two strides and moves the block axis + * ([sk.ainet.lang.memory.Layout.blockAxis]) with it. + * + * Both must describe the *same matrix*. This asserts exactly that, for every packed encoding, on + * JVM and Kotlin/Native alike — and records the transposed values as goldens. + */ +@OptIn(ExperimentalMemoryApi::class) +class PackedTransposeGoldenTest { + + private companion object { + const val ROWS = 4 + const val BLOCKS_PER_ROW = 3 + const val SEED = 0x5EED_0003L + } + + private val ctx = DirectCpuExecutionContext() + + private fun build(p: Packed, shape: Shape, bytes: ByteArray): PackedBlockStorage = when (p) { + Packed.Q4_0 -> Q4_0BlockTensorData(shape, bytes) + Packed.Q5_0 -> Q5_0BlockTensorData(shape, bytes) + Packed.Q5_1 -> Q5_1BlockTensorData(shape, bytes) + Packed.Q8_0 -> Q8_0BlockTensorData(shape, bytes) + Packed.Q4_K -> Q4_KBlockTensorData(shape, bytes) + Packed.Q5_K -> Q5_KBlockTensorData(shape, bytes) + Packed.Q6_K -> Q6_KBlockTensorData(shape, bytes) + } + + @Suppress("UNCHECKED_CAST") + private fun transposeAgrees(p: Packed) { + val shape = Shape(ROWS, BLOCKS_PER_ROW * p.blockSize) + val bytes = GoldenSupport.rowMajor(GoldenSupport.weightBlocks(p, ROWS, BLOCKS_PER_ROW, SEED)) + val data = build(p, shape, bytes) + val tensor: Tensor = ctx.fromData(data as TensorData, FP32::class) + + val view = data.packedView + val transposed = view.transpose() + assertEquals(Shape(shape[1], shape[0]), transposed.shape, "${p.name}: shape") + assertEquals(view.storage.id, transposed.storage.id, "${p.name}: the transpose must not touch the bytes") + + // 1. the view transposes what it decodes + for (r in 0 until shape[0]) { + for (c in 0 until shape[1]) { + assertEquals(view.get(r, c), transposed.get(c, r), "${p.name}: element ($r,$c)") + } + } + + // 2. and it describes the same matrix as the physical block-grid permutation the kernels + // still need. `DefaultCpuOps.transpose` reorders the blocks to *input-block-major* — + // block (bI, o) at index `bI * rows + o` — because that is how the packed kernels read a + // weight, whatever shape it declares (#968/#971; the contract #973 exists to write down). + // So the permuted bytes are not the row-major encoding of the transposed matrix, and the + // two paths are not interchangeable until #973 lands: decoded *as block-major*, they carry + // exactly the values the zero-copy view exposes. + val physical = tensor.t() + assertEquals(Shape(shape[1], shape[0]), physical.shape, "${p.name}: ops.transpose shape") + val permutedBytes = (physical.data as PackedBlockStorage).packedData + assertTrue( + permutedBytes.contentEquals(GoldenSupport.blockMajor(GoldenSupport.weightBlocks(p, ROWS, BLOCKS_PER_ROW, SEED))), + "${p.name}: ops.transpose must produce input-block-major bytes", + ) + val permuted = build(p, shape, permutedBytes) + val block = FloatArray(p.blockSize) + val fromView = FloatArray(shape.volume) + var i = 0 + for (c in 0 until shape[1]) { + for (r in 0 until shape[0]) { + permuted.dequantizeBlock((c / p.blockSize) * ROWS + r, block, 0) + assertEquals(view.get(r, c), block[c % p.blockSize], "${p.name}: block-major element ($r,$c)") + fromView[i++] = transposed.get(c, r) + } + } + GoldenSupport.check("transpose/${p.name}", GoldenSupport.digest(fromView)) + } + + @Test fun q4_0() = transposeAgrees(Packed.Q4_0) + @Test fun q5_0() = transposeAgrees(Packed.Q5_0) + @Test fun q5_1() = transposeAgrees(Packed.Q5_1) + @Test fun q8_0() = transposeAgrees(Packed.Q8_0) + @Test fun q4_K() = transposeAgrees(Packed.Q4_K) + @Test fun q5_K() = transposeAgrees(Packed.Q5_K) + @Test fun q6_K() = transposeAgrees(Packed.Q6_K) + + /** Narrowing a transposed packed view still addresses whole blocks — on the axis that carries them. */ + @Test + fun aTransposedPackedViewStillSlicesByBlock() { + val p = Packed.Q8_0 + val shape = Shape(ROWS, BLOCKS_PER_ROW * p.blockSize) + val bytes = GoldenSupport.rowMajor(GoldenSupport.weightBlocks(p, ROWS, BLOCKS_PER_ROW, SEED)) + val data = build(p, shape, bytes) + val view = data.packedView + val transposed = view.transpose() + + val head = transposed.narrow(0, 0, p.blockSize) // one block along the (now leading) block axis + assertEquals(Shape(p.blockSize, ROWS), head.shape) + for (c in 0 until p.blockSize) for (r in 0 until ROWS) assertEquals(view.get(r, c), head.get(c, r)) + + val rows = transposed.narrow(1, 1, 2) // the plain axis narrows freely + assertEquals(Shape(shape[1], 2), rows.shape) + assertEquals(view.get(1, 0), rows.get(0, 0)) + } +} diff --git a/skainet-lang/skainet-lang-core/api/jvm/skainet-lang-core.api b/skainet-lang/skainet-lang-core/api/jvm/skainet-lang-core.api index 0880f8ae0..1ec3db331 100644 --- a/skainet-lang/skainet-lang-core/api/jvm/skainet-lang-core.api +++ b/skainet-lang/skainet-lang-core/api/jvm/skainet-lang-core.api @@ -797,10 +797,11 @@ public final class sk/ainet/lang/memory/ForwardScope : sk/ainet/lang/memory/Scop public final class sk/ainet/lang/memory/Layout { public static final field Companion Lsk/ainet/lang/memory/Layout$Companion; - public fun (Lsk/ainet/lang/tensor/Shape;[IJIZ)V - public synthetic fun (Lsk/ainet/lang/tensor/Shape;[IJIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lsk/ainet/lang/tensor/Shape;[IJIZI)V + public synthetic fun (Lsk/ainet/lang/tensor/Shape;[IJIZIILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun byteOffsetOf ([I)J public fun equals (Ljava/lang/Object;)Z + public final fun getBlockAxis ()I public final fun getBlocked ()Z public final fun getElementBytes ()I public final fun getElementCount ()J @@ -814,6 +815,7 @@ public final class sk/ainet/lang/memory/Layout { public final fun isRowMajor ()Z public final fun narrow (III)Lsk/ainet/lang/memory/Layout; public final fun squeeze (I)Lsk/ainet/lang/memory/Layout; + public final fun step (II)Lsk/ainet/lang/memory/Layout; public fun toString ()Ljava/lang/String; public final fun transpose (II)Lsk/ainet/lang/memory/Layout; public static synthetic fun transpose$default (Lsk/ainet/lang/memory/Layout;IIILjava/lang/Object;)Lsk/ainet/lang/memory/Layout; @@ -1190,6 +1192,7 @@ public final class sk/ainet/lang/memory/TensorView { public final fun narrow (III)Lsk/ainet/lang/memory/TensorView; public final fun set ([IF)V public final fun squeeze (I)Lsk/ainet/lang/memory/TensorView; + public final fun step (II)Lsk/ainet/lang/memory/TensorView; public final fun toFloatArray ()[F public fun toString ()Ljava/lang/String; public final fun transpose (II)Lsk/ainet/lang/memory/TensorView; @@ -1236,6 +1239,13 @@ public final class sk/ainet/lang/memory/TernaryCodec { public final fun encodeTq2_0 ([F)[B } +public final class sk/ainet/lang/memory/ViewsKt { + public static final fun slice (Lsk/ainet/lang/memory/TensorView;Ljava/util/List;)Lsk/ainet/lang/memory/TensorView; + public static final fun slice (Lsk/ainet/lang/memory/TensorView;[Lsk/ainet/lang/tensor/Slice;)Lsk/ainet/lang/memory/TensorView; + public static final fun view (Lsk/ainet/lang/tensor/Tensor;)Lsk/ainet/lang/memory/TensorView; + public static final fun viewOrNull (Lsk/ainet/lang/tensor/Tensor;)Lsk/ainet/lang/memory/TensorView; +} + public final class sk/ainet/lang/memory/plan/ActualMemory { public static final field Companion Lsk/ainet/lang/memory/plan/ActualMemory$Companion; public fun (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Describe.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Describe.kt index 63494d4f2..649788adb 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Describe.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Describe.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.memory import sk.ainet.lang.tensor.Tensor diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Layout.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Layout.kt index 5909ad652..30007a3ae 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Layout.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Layout.kt @@ -16,6 +16,9 @@ import sk.ainet.lang.tensor.Shape * @property strides one stride per dimension, in elements (or blocks for a blocked layout) * @property offsetElements element (or block) offset of the first element inside the storage * @property elementBytes bytes per element (dense) or per block (blocked) + * @property blockAxis for a [blocked] layout, the axis whose extent is measured in blocks. It is + * the last axis as loaded, and it *moves* with [transpose]/[unsqueeze]/[squeeze] — which is what + * lets a packed weight be transposed as metadata and still decode correctly (#1034). */ @ExperimentalMemoryApi public class Layout( @@ -24,11 +27,13 @@ public class Layout( public val offsetElements: Long = 0L, public val elementBytes: Int = 4, public val blocked: Boolean = false, + public val blockAxis: Int = shape.rank - 1, ) { init { require(strides.size == shape.rank) { "strides (${strides.size}) must match rank (${shape.rank})" } require(offsetElements >= 0) { "offsetElements must be >= 0" } require(elementBytes > 0) { "elementBytes must be > 0" } + if (blocked) require(blockAxis in 0 until shape.rank) { "blockAxis $blockAxis out of range for rank ${shape.rank}" } } /** Number of elements addressed (the shape's volume). */ @@ -74,7 +79,7 @@ public class Layout( require(axis in 0 until shape.rank) { "axis $axis out of range for rank ${shape.rank}" } require(from >= 0 && size >= 0 && from + size <= shape[axis]) { "narrow($axis, $from, $size) outside extent ${shape[axis]}" } val dims = shape.dimensions.copyOf(); dims[axis] = size - return Layout(Shape(dims), strides.copyOf(), offsetElements + from.toLong() * strides[axis], elementBytes, blocked) + return Layout(Shape(dims), strides.copyOf(), offsetElements + from.toLong() * strides[axis], elementBytes, blocked, blockAxis) } /** Swap two axes — metadata only; the bytes are untouched (the packed-transpose trick, rule 5). */ @@ -84,7 +89,26 @@ public class Layout( val dims = shape.dimensions.copyOf(); val st = strides.copyOf() val d = dims[axis0]; dims[axis0] = dims[axis1]; dims[axis1] = d val s = st[axis0]; st[axis0] = st[axis1]; st[axis1] = s - return Layout(Shape(dims), st, offsetElements, elementBytes, blocked) + // The block axis travels with its extent: a transposed packed view still knows which index + // splits into (block, offset-within-block). + val movedBlockAxis = when (blockAxis) { axis0 -> axis1; axis1 -> axis0; else -> blockAxis } + return Layout(Shape(dims), st, offsetElements, elementBytes, blocked, movedBlockAxis) + } + + /** + * Every [step]-th element along [axis] — a stride multiply, zero-copy. This is what makes the + * strided half of the old `Slice.Step` a layout operation rather than an index remapper + * (#1034): the bytes are untouched and the result is an ordinary [Layout]. + */ + public fun step(axis: Int, step: Int): Layout { + require(axis in 0 until shape.rank) { "axis $axis out of range for rank ${shape.rank}" } + require(step > 0) { "step must be positive, got $step" } + if (step == 1) return this + val dims = shape.dimensions.copyOf() + dims[axis] = (shape[axis] + step - 1) / step + val st = strides.copyOf() + st[axis] = strides[axis] * step + return Layout(Shape(dims), st, offsetElements, elementBytes, blocked, blockAxis) } /** Insert a unit axis at [axis] (stride 0 — it is never stepped). */ @@ -95,7 +119,7 @@ public class Layout( for (i in 0..shape.rank) { if (i == axis) { dims[i] = 1; st[i] = 0 } else { dims[i] = shape[j]; st[i] = strides[j]; j++ } } - return Layout(Shape(dims), st, offsetElements, elementBytes, blocked) + return Layout(Shape(dims), st, offsetElements, elementBytes, blocked, if (axis <= blockAxis) blockAxis + 1 else blockAxis) } /** Drop the unit axis at [axis]. */ @@ -103,8 +127,9 @@ public class Layout( require(axis in 0 until shape.rank) { "axis $axis out of range for rank ${shape.rank}" } require(shape[axis] == 1) { "axis $axis has extent ${shape[axis]}, not 1" } val dims = ArrayList(shape.rank - 1); val st = ArrayList(shape.rank - 1) + require(!(blocked && axis == blockAxis)) { "cannot drop the block axis of a blocked layout" } for (i in 0 until shape.rank) if (i != axis) { dims += shape[i]; st += strides[i] } - return Layout(Shape(dims.toIntArray()), st.toIntArray(), offsetElements, elementBytes, blocked) + return Layout(Shape(dims.toIntArray()), st.toIntArray(), offsetElements, elementBytes, blocked, if (axis < blockAxis) blockAxis - 1 else blockAxis) } override fun toString(): String = @@ -112,12 +137,12 @@ public class Layout( override fun equals(other: Any?): Boolean = other is Layout && other.shape == shape && other.strides.contentEquals(strides) && other.offsetElements == offsetElements && - other.elementBytes == elementBytes && other.blocked == blocked + other.elementBytes == elementBytes && other.blocked == blocked && other.blockAxis == blockAxis override fun hashCode(): Int { var h = shape.hashCode() h = 31 * h + strides.contentHashCode(); h = 31 * h + offsetElements.hashCode() - h = 31 * h + elementBytes; h = 31 * h + blocked.hashCode() + h = 31 * h + elementBytes; h = 31 * h + blocked.hashCode(); h = 31 * h + blockAxis return h } diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/TensorView.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/TensorView.kt index 9b80e9ef0..bef37a9ef 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/TensorView.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/TensorView.kt @@ -49,19 +49,43 @@ public class TensorView( require(axis in 0 until shape.rank) { "axis $axis out of range for rank ${shape.rank}" } check(!(layout.blocked && layout.shape.rank != shape.rank)) { "this view's blocks span rows; slice it after materialize()" } require(from >= 0 && size >= 0 && from + size <= shape[axis]) { "narrow($axis, $from, $size) outside extent ${shape[axis]}" } - val onBlockAxis = layout.blocked && axis == shape.rank - 1 + val onBlockAxis = layout.blocked && axis == layout.blockAxis val unit = if (onBlockAxis) blockSize() else 1 if (onBlockAxis) require(from % unit == 0 && size % unit == 0) { "narrowing the block axis must align to the block size $unit" } return derive(narrowShape(axis, size), layout.narrow(axis, from / unit, size / unit), idSuffix = "$from..${from + size})") } - /** A transposed view — metadata only; packed bytes are untouched (the packed-transpose trick). */ + /** + * A transposed view — metadata only; the bytes are untouched, packed ones included. For a + * block-packed view the block axis travels with its extent ([Layout.blockAxis]), so the + * transposed view decodes the same matrix, transposed, without the O(bytes) block-grid + * permutation `DefaultCpuOps.transpose` still performs for the kernels that demand block-major + * bytes (#968/#971, contract #973). + */ public fun transpose(axis0: Int = shape.rank - 2, axis1: Int = shape.rank - 1): TensorView { - require(!format.isDense.not() || !layout.blocked || axis1 == shape.rank - 1) { "a blocked layout cannot move its block axis" } + require(shape.rank >= 2) { "transpose needs rank >= 2" } + require(axis0 in 0 until shape.rank && axis1 in 0 until shape.rank) { "axes out of range" } + check(!(layout.blocked && layout.shape.rank != shape.rank)) { "this view's blocks span rows; transpose it after materialize()" } val dims = shape.dimensions.copyOf(); val t = dims[axis0]; dims[axis0] = dims[axis1]; dims[axis1] = t return derive(Shape(dims), layout.transpose(axis0, axis1), idSuffix = "ᵀ") } + /** + * Every [step]-th element along [axis] — zero-copy, the strided view (`Slice.Step`). Not + * available on the block axis of a packed view: a block's bytes are indivisible. + */ + public fun step(axis: Int, step: Int): TensorView { + require(axis in 0 until shape.rank) { "axis $axis out of range for rank ${shape.rank}" } + require(step > 0) { "step must be positive, got $step" } + if (step == 1) return this + require(!(layout.blocked && axis == layout.blockAxis)) { + "cannot step the block axis of a ${format.encoding.name} view; materialize() first" + } + val dims = shape.dimensions.copyOf() + dims[axis] = (shape[axis] + step - 1) / step + return derive(Shape(dims), layout.step(axis, step), idSuffix = "::$step") + } + /** A view with a unit axis inserted at [axis]. */ public fun unsqueeze(axis: Int): TensorView { val dims = IntArray(shape.rank + 1); var j = 0 @@ -133,16 +157,17 @@ public class TensorView( } return layout.offsetElements * bs + flat } - val last = indices[indices.size - 1] - val blockIdxWithinRow = last / bs - val within = last % bs + // The block axis is the one measured in blocks; after a transpose it is no longer the last. + val blockAxis = if (layout.blocked) layout.blockAxis else indices.size - 1 + val along = indices[blockAxis] + require(along in 0 until shape[blockAxis]) { "index $along out of range for axis $blockAxis (extent ${shape[blockAxis]})" } + val within = along % bs var flat = layout.offsetElements - for (d in 0 until indices.size - 1) { + for (d in indices.indices) { val i = indices[d] require(i in 0 until shape[d]) { "index $i out of range for axis $d (extent ${shape[d]})" } - flat += i.toLong() * layout.strides[d] + flat += (if (d == blockAxis) (i / bs).toLong() else i.toLong()) * layout.strides[d] } - flat += blockIdxWithinRow.toLong() * layout.strides[indices.size - 1] return flat * bs + within } diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Views.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Views.kt new file mode 100644 index 000000000..1343e8f98 --- /dev/null +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/memory/Views.kt @@ -0,0 +1,64 @@ +package sk.ainet.lang.memory + +import sk.ainet.lang.tensor.Slice +import sk.ainet.lang.tensor.Tensor + +/** + * The one view mechanism (SKEEP-003 §4.6, #1034): everything that used to be a *separate* way of + * looking at someone else's bytes — the index-remapping `SlicedTensorView`, the byte-range + * `BufferHandle.Aliased`, the packed-transpose rewrap — is a [TensorView] with a different + * [Layout] over the same [Storage]. + * + * These entry points are how DSL code reaches it: [view] turns a [Tensor] into the view its data + * already exposes, and [slice] replays the old `Slice` DSL on top of `narrow`/`step`/`squeeze`, so + * a caller migrating off `Tensor.sliceView` keeps the same vocabulary and gets the same view type + * every other operation returns. + */ + +/** This tensor as a [TensorView] over the *same* bytes, or `null` if its data cannot expose one. */ +@ExperimentalMemoryApi +public fun Tensor<*, *>.viewOrNull(): TensorView? = data.view + +/** + * This tensor as a [TensorView] over the *same* bytes — zero-copy. + * + * @throws UnsupportedOperationException if the tensor's data has no view (a backend type that owns + * its bytes elsewhere, e.g. device-resident data); use `copyToFloatArray()` for those. + */ +@ExperimentalMemoryApi +public fun Tensor<*, *>.view(): TensorView = viewOrNull() ?: throw UnsupportedOperationException( + "${data::class.simpleName} does not expose a TensorView; it holds its bytes somewhere this milestone cannot address" +) + +/** + * The old `Slice` DSL as layout arithmetic: `Range`/`All` narrow, `Step` narrows then strides, + * `At` narrows to one and drops the axis. One slice per axis, exactly as `Tensor.slice` required. + * + * The result is an ordinary [TensorView] — the same type [narrow], [transpose], [unsqueeze] and + * [squeeze] return, which is the whole point of #1034. + */ +@ExperimentalMemoryApi +public fun TensorView.slice(slices: List>): TensorView { + require(slices.size == shape.rank) { + "expected one slice per axis (${shape.rank}), got ${slices.size}" + } + var v = this + val dropped = ArrayList() + slices.forEachIndexed { axis, raw -> + val extent = shape[axis] + require(raw.isValid(extent)) { "invalid slice for axis $axis (extent $extent): $raw" } + when (val s = raw.normalize(extent)) { + is Slice.All -> Unit + is Slice.Range -> v = v.narrow(axis, s.start, s.end - s.start) + is Slice.At -> { v = v.narrow(axis, s.index, 1); dropped += axis } + is Slice.Step -> v = v.narrow(axis, s.start, s.end - s.start).step(axis, s.step) + } + } + // Highest axis first, so the earlier indices stay valid while axes disappear. + for (axis in dropped.sortedDescending()) v = v.squeeze(axis) + return v +} + +/** [slice] with the slices spelled out positionally. */ +@ExperimentalMemoryApi +public fun TensorView.slice(vararg slices: Slice<*, *>): TensorView = slice(slices.toList()) diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/CopyMaterializationStrategy.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/CopyMaterializationStrategy.kt index 59588c7fb..6866edc8e 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/CopyMaterializationStrategy.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/CopyMaterializationStrategy.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.tensor.data.TensorData diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/LazyMaterializationStrategy.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/LazyMaterializationStrategy.kt index b3005f635..ec81ac966 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/LazyMaterializationStrategy.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/LazyMaterializationStrategy.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.tensor.GradState diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationExtensions.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationExtensions.kt index 7bcf3243e..4c1dd9c49 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationExtensions.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationExtensions.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.types.DType diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationStrategy.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationStrategy.kt index 7cb1c656f..be1615b56 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationStrategy.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/MaterializationStrategy.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.types.DType diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/SlicedTensorView.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/SlicedTensorView.kt index bb1026cdd..b0a2251f6 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/SlicedTensorView.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/SlicedTensorView.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // this file *is* the deprecated mechanism + package sk.ainet.lang.tensor import sk.ainet.lang.tensor.GradState @@ -40,6 +42,11 @@ import kotlin.reflect.KClass * @param parentTensor the parent tensor to create a view of * @param slices list of slice operations, one per dimension */ +@Deprecated( + message = "One view mechanism (SKEEP-003 §4.6, #1034): index remapping is layout arithmetic — " + + "`tensor.view().slice(slices)` produces a `sk.ainet.lang.memory.TensorView` over the same Storage, " + + "the same type every other view operation returns. Kept until the next major.", +) public class SlicedTensorView( override val parentTensor: Tensor, private val slices: List> diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorSlicingExtensions.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorSlicingExtensions.kt index 3ca5aff4e..638445159 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorSlicingExtensions.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorSlicingExtensions.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.types.DType diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorView.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorView.kt index ecdc26f41..afe9a8916 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorView.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorView.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // this file *is* the deprecated mechanism + package sk.ainet.lang.tensor import sk.ainet.lang.types.DType @@ -27,6 +29,13 @@ import sk.ainet.lang.types.DType * @param T the data type constraint extending DType, defining the numerical precision * @param V the actual value type that will be stored and accessed */ +@Deprecated( + message = "One view mechanism (SKEEP-003 §4.6, #1034): a view is a `sk.ainet.lang.memory.TensorView` — " + + "Shape + Format + Layout + Storage over the parent's bytes — built with `tensor.view()` and reshaped " + + "with `narrow`/`step`/`transpose`/`unsqueeze`/`squeeze`/`slice(slices)`. No ReplaceWith: the replacement " + + "is not type-parameterized, so an automatic fix would produce code that does not compile. This interface " + + "and its implementations keep working until the next major.", +) public interface TensorView : Tensor { /** diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorViewStrategy.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorViewStrategy.kt index be7f82536..6e0fe6e35 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorViewStrategy.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/TensorViewStrategy.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.types.DType diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ViewManagementExtensions.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ViewManagementExtensions.kt index 71ca63b5b..2dc56ddfa 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ViewManagementExtensions.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ViewManagementExtensions.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor import sk.ainet.lang.types.DType diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/benchmark/SlicingBenchmarks.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/benchmark/SlicingBenchmarks.kt index 087fac75f..150d3c63c 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/benchmark/SlicingBenchmarks.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/benchmark/SlicingBenchmarks.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor.benchmark import sk.ainet.benchmark.BenchmarkCaseBuilder diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferAccessor.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferAccessor.kt index 853386075..6527b51c2 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferAccessor.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferAccessor.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor.storage /** diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandle.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandle.kt index 9d1777bb9..8e50d089d 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandle.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandle.kt @@ -59,6 +59,11 @@ public sealed interface BufferHandle { * A slice/view into another [BufferHandle]. Shares the parent's backing * memory. Mutations (if the parent is mutable) are visible to both. */ + @Deprecated( + message = "One view mechanism (SKEEP-003 §4.6, #1034): a byte range of someone else's buffer is " + + "`storage.slice(offsetBytes, lengthBytes)` — a Storage with `Owner.Alias`, addressed by a Layout. " + + "Kept until the next major.", + ) public class Aliased( public val parent: BufferHandle, public val byteOffset: Long, diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandleFactory.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandleFactory.kt index 229a85c38..6458ddb58 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandleFactory.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/storage/BufferHandleFactory.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") // the pre-#1034 view mechanism: kept working until the next major + package sk.ainet.lang.tensor.storage /** diff --git a/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/OneViewMechanismTest.kt b/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/OneViewMechanismTest.kt new file mode 100644 index 000000000..84ea72873 --- /dev/null +++ b/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/memory/OneViewMechanismTest.kt @@ -0,0 +1,195 @@ +@file:Suppress("DEPRECATION") // half of this test's job is to compare against the deprecated mechanisms + +package sk.ainet.lang.memory + +import sk.ainet.context.data +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.Slice +import sk.ainet.lang.tensor.Tensor +import sk.ainet.lang.tensor.dsl.tensor +import sk.ainet.lang.tensor.slice +import sk.ainet.lang.tensor.storage.BufferHandle +import sk.ainet.lang.types.FP32 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * #1034 (SKEEP-003 §4.6): **one** view mechanism. + * + * Slicing used to be an index remapper (`SlicedTensorView`), aliasing a byte range + * (`BufferHandle.Aliased`) and a packed transpose a byte permutation — three unrelated ways of + * looking at someone else's bytes. This asserts that `TensorView` + `Layout` produces the same + * answers as each of them, over the same `Storage`, and that every view operation returns the same + * type so they compose. + */ +@OptIn(ExperimentalMemoryApi::class) +class OneViewMechanismTest { + + private fun rampTensor(vararg dims: Int): Tensor { + lateinit var built: Tensor + data { + built = tensor { + shape(*dims) { shape -> + init { indices -> + var flat = 0 + var stride = 1 + for (i in indices.indices.reversed()) { flat += indices[i] * stride; stride *= shape[i] } + flat.toFloat() + } + } + } + } + return built + } + + private fun valuesOf(t: Tensor): List { + val out = ArrayList(t.shape.volume) + val idx = IntArray(t.shape.rank) + repeat(t.shape.volume) { flat -> + var rem = flat + for (d in t.shape.rank - 1 downTo 0) { idx[d] = rem % t.shape[d]; rem /= t.shape[d] } + out += t.data.get(*idx) + } + return out + } + + // --- one mechanism, one type ------------------------------------------------------------- + + @Test + fun everyViewOperationReturnsAViewOverTheSameStorage() { + val t = rampTensor(4, 6) + val v = t.view() + val derived = listOf( + "narrow" to v.narrow(1, 2, 4), + "transpose" to v.transpose(), + "unsqueeze" to v.unsqueeze(0), + "step" to v.step(1, 2), + "slice" to v.slice(Slice.Range(1, 3), Slice.All()), + "squeeze" to v.narrow(0, 1, 1).squeeze(0), + ) + for ((name, d) in derived) { + assertEquals(v.storage.id, d.storage.id, "$name must be zero-copy over the same Storage") + assertEquals(v.format, d.format, "$name must not change what the values mean") + } + } + + @Test + fun sliceComposesFromNarrowStepAndSqueeze() { + val t = rampTensor(4, 6) + val v = t.view() + val bySlice = v.slice(Slice.At(2), Slice.Step(1, 6, 2)) + val byHand = v.narrow(0, 2, 1).narrow(1, 1, 5).step(1, 2).squeeze(0) + assertEquals(byHand.shape, bySlice.shape) + assertTrue(byHand.toFloatArray().contentEquals(bySlice.toFloatArray())) + } + + // --- subsumes SlicedTensorView (index remap) ---------------------------------------------- + + @Test + fun theViewAgreesWithTheOldSlicedTensorViewOnEverySliceKind() { + val t = rampTensor(4, 3, 2) + val cases: List>>> = listOf( + "all" to listOf(Slice.All(), Slice.All(), Slice.All()), + "range on axis 0" to listOf(Slice.Range(1, 3), Slice.All(), Slice.All()), + "range on two axes" to listOf(Slice.Range(1, 4), Slice.Range(0, 2), Slice.All()), + "at on axis 0" to listOf(Slice.At(2), Slice.All(), Slice.All()), + "at on axis 1" to listOf(Slice.All(), Slice.At(1), Slice.All()), + "two ats" to listOf(Slice.At(3), Slice.At(0), Slice.All()), + "step" to listOf(Slice.Step(0, 4, 2), Slice.All(), Slice.All()), + "step and range" to listOf(Slice.Step(0, 4, 3), Slice.Range(1, 3), Slice.At(1)), + ) + for ((name, slices) in cases) { + val old = t.slice(slices) + val new = t.view().slice(slices) + assertEquals(old.shape, new.shape, "$name: shape") + assertTrue( + valuesOf(old).toFloatArray().contentEquals(new.toFloatArray()), + "$name: values — old ${valuesOf(old)} vs new ${new.toFloatArray().toList()}", + ) + } + } + + @Test + fun aSliceOfASliceIsStillOneView() { + val t = rampTensor(6, 8) + val root = t.view() + val once = root.narrow(0, 1, 4) + val twice = once.narrow(1, 2, 4).step(0, 2) + assertEquals(Shape(2, 4), twice.shape) + assertEquals(root.storage.id, twice.storage.id, "composition never copies") + // the same elements the old mechanism would have reached + val expected = t.slice(listOf(Slice.Step(1, 5, 2), Slice.Range(2, 6))) + assertTrue(valuesOf(expected).toFloatArray().contentEquals(twice.toFloatArray())) + } + + // --- subsumes BufferHandle.Aliased (byte range) ------------------------------------------- + + @Test + fun aByteRangeAliasIsAViewOverASlicedStorage() { + val floats = FloatArray(64) { it.toFloat() } + val parent = Storage.Heap.wrap(floats) + val region = parent.slice(offsetBytes = 16 * 4, lengthBytes = 16 * 4) + val view = TensorView.dense(region, Shape(4, 4)) + + assertTrue(region.owner is Owner.Alias, "an alias declares its parent, like BufferHandle.Aliased did") + assertEquals(16f, view.get(0, 0), "the region starts where the alias starts") + assertEquals(31f, view.get(3, 3)) + assertEquals(64L * 4, parent.sizeBytes) + assertEquals(16L * 4, region.sizeBytes) + + // shared memory, exactly as Aliased promised: a write through the parent is visible here + floats[16] = -1f + assertEquals(-1f, view.get(0, 0)) + + // and the same bounds contract + assertFailsWith { parent.slice(60L * 4, 8L * 4) } + + // the deprecated handle described the same region, and nothing more + val handle = BufferHandle.Aliased(BufferHandle.Borrowed(ByteArray(64 * 4)), 16L * 4, 16L * 4) + assertEquals(handle.sizeInBytes, region.sizeBytes, "the same region, described the old way") + assertFailsWith { + BufferHandle.Aliased(BufferHandle.Borrowed(ByteArray(64 * 4)), 60L * 4, 8L * 4) + } + } + + // --- the layout is the mechanism ---------------------------------------------------------- + + @Test + fun eachViewCallWrapsTheSameBytesInAFreshHandle() { + // `view()` is a *handle* over the tensor's array, so two calls carry two StorageIds — but + // one array: a write through either is visible through the other. Callers that compare + // identity (tracing, plan-vs-actual) must hold on to one view rather than re-deriving it. + val t = rampTensor(2, 3) + val a = t.view() + val b = t.view() + assertNotEquals(a.storage.id, b.storage.id, "a new handle per call") + a.set(0, 0, value = -7f) + assertEquals(-7f, b.get(0, 0), "the same bytes underneath") + assertEquals(-7f, t.data.get(0, 0), "and the same bytes the TensorData reads") + } + + @Test + fun transposeIsMetadataOnly() { + val t = rampTensor(3, 5) + val v = t.view() + val tv = v.transpose() + assertEquals(Shape(5, 3), tv.shape) + assertEquals(v.storage.id, tv.storage.id) + assertNotEquals(v.layout.strides.toList(), tv.layout.strides.toList()) + for (r in 0 until 3) for (c in 0 until 5) assertEquals(v.get(r, c), tv.get(c, r), "($r,$c)") + assertTrue(!tv.isContiguous, "a transposed view is strided, not a copy") + } + + @Test + fun stepIsAStrideMultiply() { + val t = rampTensor(8) + val v = t.view().step(0, 3) + assertEquals(Shape(3), v.shape) + assertTrue(floatArrayOf(0f, 3f, 6f).contentEquals(v.toFloatArray())) + assertEquals(3, v.layout.strides[0]) + assertFailsWith { v.step(0, 0) } + } +}