Skip to content

Commit 9cb2ee8

Browse files
Merge pull request #803 from SKaiNET-developers/fix/graph-executor-permute-axes
fix(compile-dag): ComputeGraphExecutor replays permute with its recorded axes
2 parents 28fc18a + e27ae12 commit 9cb2ee8

3 files changed

Lines changed: 94 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
## [Unreleased]
44

5+
### Fixed
6+
7+
- **`ComputeGraphExecutor` replayed `permute` as a plain transpose, dropping the recorded axes.**
8+
The builtin dispatch grouped `permute` with `transpose`/`transpose2d`, so a traced
9+
`permute(t, axes)` executed as a last-two-dims swap — only coincidentally correct for rank-2.
10+
Any rank-3 permutation (e.g. multi-head attention's heads/sequence swap `[1, 0, 2]` in a
11+
full-sequence encoder or prefill trace) silently produced the wrong layout; single-token decode
12+
paths never hit it, which is why generation workloads didn't surface the bug. `permute` now
13+
replays with its recorded `axes` (the same `List<Int>` convention `permuteBackward` and the
14+
StableHLO converter already parse), falling back to the legacy transpose behavior for older
15+
traces without axes. Surfaced by the BERT DSL-path encoder work in SKaiNET-transformers, which
16+
carries a temporary `LLMFusedOpHandlers` override to be removed once this fix ships.
17+
518
## [0.35.0] - 2026-07-08
619

720
### Added

skainet-compile/skainet-compile-dag/src/commonMain/kotlin/sk/ainet/lang/graph/exec/ComputeGraphExecutor.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,25 @@ public class ComputeGraphExecutor(
192192
}
193193

194194
// Shape ops
195-
"transpose", "permute", "transpose2d" -> listOf(ops.transpose(inputs[0]))
195+
"transpose", "transpose2d" -> listOf(ops.transpose(inputs[0]))
196+
"permute" -> {
197+
// The trace records axes as a List<Int> (axes.toList()) — same
198+
// convention permuteBackward parses. Replaying permute as a
199+
// plain last-two-dims transpose is only correct for rank-2;
200+
// e.g. attention's heads/sequence swap [1, 0, 2] on rank-3
201+
// silently produced the wrong layout.
202+
val axes = when (val a = params["axes"]) {
203+
is IntArray -> a
204+
is List<*> -> IntArray(a.size) { (a[it] as Number).toInt() }
205+
else -> null
206+
}
207+
if (axes != null && axes.size == inputs[0].rank) {
208+
listOf(ops.permute(inputs[0], axes))
209+
} else {
210+
// No usable axes recorded (older traces) — legacy behavior.
211+
listOf(ops.transpose(inputs[0]))
212+
}
213+
}
196214
"reshape", "view" -> {
197215
// Shape can come from different parameter keys depending on trace source
198216
val targetShape = (params["shape"] as? List<Int>)

skainet-compile/skainet-compile-dag/src/commonTest/kotlin/sk/ainet/compile/graph/ComputeGraphExecutorTest.kt

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import sk.ainet.lang.tensor.ops.TensorSpec
1414
import sk.ainet.lang.types.DType
1515
import sk.ainet.lang.types.FP32
1616
import kotlin.test.Test
17+
import kotlin.test.assertContentEquals
1718
import kotlin.test.assertEquals
1819
import kotlin.test.assertNotNull
1920
import kotlin.test.assertTrue
@@ -119,6 +120,62 @@ class ComputeGraphExecutorTest {
119120
assertNotNull(results["mm"])
120121
}
121122

123+
@Test
124+
fun permuteReplaysRecordedAxes() {
125+
// Regression: permute used to be dispatched as a plain last-two-dims
126+
// transpose, silently dropping the recorded axes — wrong for any
127+
// rank-3 permutation (e.g. attention's heads/sequence swap [1, 0, 2]).
128+
var replayedAxes: IntArray? = null
129+
var transposeCalled = false
130+
val ops = object : TensorOps by TestTensorOps() {
131+
override fun <T : DType, V> permute(tensor: Tensor<T, V>, axes: IntArray): Tensor<T, V> {
132+
replayedAxes = axes
133+
return tensor
134+
}
135+
136+
override fun <T : DType, V> transpose(tensor: Tensor<T, V>): Tensor<T, V> {
137+
transposeCalled = true
138+
return tensor
139+
}
140+
}
141+
142+
val graph = DefaultComputeGraph()
143+
val input = graph.addNode(inputNode("input"))
144+
// The tape records axes as a List<Int> (axes.toList())
145+
val perm = graph.addNode(opNode("perm", "permute", mapOf("axes" to listOf(1, 0, 2))))
146+
graph.addEdge(GraphEdge("e1", input, perm, tensorSpec = spec()))
147+
148+
val executor = ComputeGraphExecutor(graph, ops)
149+
val rank3 = TestTensor(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f), shape = intArrayOf(2, 1, 3))
150+
val results = executor.execute<FP32, Float>(mapOf("input" to rank3))
151+
152+
assertNotNull(results["perm"])
153+
assertContentEquals(intArrayOf(1, 0, 2), replayedAxes, "permute must replay with its recorded axes")
154+
assertTrue(!transposeCalled, "permute must not degrade to transpose when axes are recorded")
155+
}
156+
157+
@Test
158+
fun permuteWithoutRecordedAxesFallsBackToTranspose() {
159+
var transposeCalled = false
160+
val ops = object : TensorOps by TestTensorOps() {
161+
override fun <T : DType, V> transpose(tensor: Tensor<T, V>): Tensor<T, V> {
162+
transposeCalled = true
163+
return tensor
164+
}
165+
}
166+
167+
val graph = DefaultComputeGraph()
168+
val input = graph.addNode(inputNode("input"))
169+
val perm = graph.addNode(opNode("perm", "permute"))
170+
graph.addEdge(GraphEdge("e1", input, perm, tensorSpec = spec()))
171+
172+
val executor = ComputeGraphExecutor(graph, ops)
173+
val results = executor.execute<FP32, Float>(mapOf("input" to TestTensor(floatArrayOf(1f, 2f))))
174+
175+
assertNotNull(results["perm"])
176+
assertTrue(transposeCalled, "legacy traces without axes keep the transpose behavior")
177+
}
178+
122179
@Test
123180
fun emptyGraphReturnsEmptyResults() {
124181
val graph = DefaultComputeGraph()
@@ -133,8 +190,11 @@ class ComputeGraphExecutorTest {
133190
* Used for testing graph execution without needing a full tensor backend.
134191
*/
135192
@Suppress("UNCHECKED_CAST")
136-
private class TestTensor(val values: FloatArray) : Tensor<FP32, Float> {
137-
private val tensorShape = sk.ainet.lang.tensor.Shape(intArrayOf(1, values.size))
193+
private class TestTensor(
194+
val values: FloatArray,
195+
shape: IntArray = intArrayOf(1, values.size),
196+
) : Tensor<FP32, Float> {
197+
private val tensorShape = sk.ainet.lang.tensor.Shape(shape)
138198
override val data: sk.ainet.lang.tensor.data.TensorData<FP32, Float> = object : sk.ainet.lang.tensor.data.TensorData<FP32, Float> {
139199
override fun get(vararg indices: Int): Float = values[indices.last()]
140200
override fun set(vararg indices: Int, value: Float) { values[indices.last()] = value }

0 commit comments

Comments
 (0)