SKEEP-005: Schedules โ structured concurrency for the compute layer
Status: Implemented
Audience: SKaiNET maintainers and contributors; SKaiNET-transformers maintainers (the first consumer is per-head attention)
Created: 2026-09-03
Tracking issue: to be filed (bugs fixed on the way: #1259, #1260; observed: #1261)
Origin: the Daily-StandAPP profile on SKaiNET 0.53.0 (JFR, i7-9750H 6C/12T): attention โ 40 % of every decoded token, scalar and single-threaded, while the only parallelism in the stack was the native matmul’s compile-time 4-thread pool and a runBlocking(Dispatchers.Default) island inside a few Panama kernels.
Summary
A network definition says what is computed. Nothing in SKaiNET said how many tasks compute it.
This proposal adds a second, optional half โ the schedule โ in the spirit of Halide’s
algorithm/schedule split: a small, dependency-free Schedule interface on the
ExecutionContext, a coroutine-backed JVM implementation, the engine’s first scheduled op
(scaledDotProductAttention), and a metadata channel so a schedule request can ride the
ComputeGraph into the StableHLO export. A schedule never changes a result: every implementation
runs the same body over the same disjoint ranges, so a scheduled run is bit-identical to a
sequential one. The DSL is untouched โ the schedule is a deployment property, exactly where
The DSL is compute says such knobs belong.
Motivation
-
Multi-head attention is embarrassingly parallel across heads, but both attention implementations (
DefaultCpuOps.scaledDotProductAttention, transformers'fusedDecodeAttention) were scalar loops on one thread, and the transformers path copied the whole K/V prefix per layer and token (111 MB per token at 622 positions on Llama-3.2-3B). -
The existing parallelism was kernel-private and invisible:
parallelChunkshard-codedDispatchers.DefaultandavailableProcessors(), could not be turned off, could not be observed, and nested arunBlockingon the very dispatcher a caller might already be on. -
The compile lane had no way to say "this op is head-parallel".
skainet.tensor_layoutsshowed the shape such a fact should take: a module attribute a consumer may read or ignore.
Goals
-
One abstraction,
sk.ainet.context.schedule.Schedule, usable from every synchronous op on every Kotlin target, with no dependency inskainet-lang-core. -
Structured concurrency on the JVM: a region is a
coroutineScope; the first failure cancels the siblings and is rethrown; no task outlives the region; writes happen-before its return. -
Hardware-aware defaults: the JVM
DirectCpuExecutionContextruns core-count coroutines out of the box; every other target and every context that does not opt in runs sequentially. -
An unhonoured request is visible (
TraceEvent.ScheduleDowngraded), never a silent downgrade. -
Bit-identical results under any schedule, proven by tests.
-
Schedule metadata through
dag { }โOperation.parametersโGraphNode.metadataโ theskainet.schedulemodule attribute of the StableHLO export.
Non-Goals
-
IREE-side consumption of
skainet.schedule. Decided against, not deferred โ see Key decision: who schedules what: the header states structure, the backend owns tiling, and the core count is a run-time property of the device. -
Concurrent forwards on one
ExecutionContextโ still one context per thread. -
Parallel schedules on Android, Kotlin/Native, JS or Wasm (they get
Sequential; the JVM implementation is the reference). -
Vectorised attention kernels (a SIMD reduction changes summation order; it is a separate, tolerance-tested change).
Proposed Design
Key decision: who schedules what
Structure at compile time, cores at run time. SKaiNET owns the structure of the graph:
which axes of an op are independent (parallel_dims), the head axis kept outermost, grouped-query
attention expressed by indexing rather than materialised. The compiler backend (IREE) owns the
instruction set, tiling and workgroup formation at compile time, and the placement of workgroups on
cores at run time. The number of cores is never written into an artifact: eager code reads
Schedule.parallelism; an IREE device reads the same value as its task-topology group count when
it is created. SKaiNET’s own executors โ the eager ops and the JVM ComputeGraphExecutor โ are
ours to parallelise through Schedule.
| Decision | Owner | When |
|---|---|---|
Which axes are independent; graph layout; GQA by index, never materialised |
SKaiNET passes ( |
compile time |
Instruction set, tiling, workgroup formation |
IREE ( |
compile time |
Mapping workgroups onto cores |
IREE task runtime, group count from the same knob as |
run time |
Eager ops and the JVM |
SKaiNET |
run time |
Consequences: the skainet.schedule header is a contract for tooling โ parallel_dims is
structural and authoritative, parallelism is advisory (stamped only when the DSL asked for it,
flagged by a diagnostic, read by no compile-time consumer). One .vmfb runs unchanged on a
4-core and an 8-core device. The open question "which IREE attribute should skainet.schedule
lower to?" is answered: none.
The Schedule contract (skainet-lang-core)
public interface Schedule {
public val parallelism: Int
public val name: String
public fun forRange(n: Int, grain: Int = 1, body: (start: Int, end: Int) -> Unit)
public fun forEach(count: Int, minPerTask: Int = 1, body: (index: Int) -> Unit)
public object Sequential : Schedule // body(0, n), inline
public companion object { fun tasksFor(n, grain, parallelism): Int; fun chunkFor(n, tasks): Int }
}
Contract for a forRange body: ranges are disjoint half-open intervals covering [0, n); a
task count of one runs inline; a body writes only into pre-allocated, disjoint output regions
and reads only inputs that are immutable for the region; it never allocates through an
ExecutionContext, never calls ctx.ops, and never starts a nested region (an implementation
runs a nested call inline); the first failure is rethrown after every sibling finished or was
cancelled; all writes happen-before the return.
Where it hangs
-
ExecutionContext.schedule: Schedule(defaultSequential) andExecutionContext.withSchedule(schedule): ExecutionContextโ the same rebuild seam aswithTensorDataFactory. The default cannot rebuild, returnsthis, and emitsTraceEvent.ScheduleDowngraded(requested, effective, reason). -
ScheduledExecutionContext(base, schedule) : ExecutionContext by baseandinline fun <R> ExecutionContext.withSchedule(schedule, block)โ the decorator mirrorsScopedExecutionContext; it survivesforwardScopebecausewithTensorDataFactorykeeps the schedule. -
Ops carry the schedule:
DefaultCpuOpsBase(dataFactory, schedule); the platform factory is(TensorDataFactory, Schedule) → TensorOps;DirectCpuExecutionContext(schedule = โฆ)defaults toplatformDefaultSchedule()โCoroutineSchedule.hardware()on the JVM,Sequentialelsewhere.
The JVM implementation (skainet-backend-cpu)
CoroutineSchedule(dispatcher = Dispatchers.Default, parallelism = cores, sink): a region is a
shared queue of tasks chunks โ tasks - 1 helpers are dispatched to the dispatcher, the caller
runs the first chunk itself and then drains the queue, and finally waits only for chunks some
thread has already claimed. A caller can therefore always finish a region on its own, which is
what makes it safe to enter from Dispatchers.Default. A nested region (detected with a
thread-local) runs inline; dedicated(parallelism) owns its own pool for callers that want
isolation from Dispatchers.Default. parallelChunks(outputDim, schedule) replaces the old
island; the Panama Q4_K/Q5_K SPI kernels gained a schedule-aware overload; ScheduleRegion
events report what ran when a sink is attached.
The first merged version ran a region as
runBlocking { coroutineScope { launch(dispatcher) { โฆ } โฆ } }. A coroutineScope waits for
every child, including the ones the pool never got to start, so once every Dispatchers.Default
worker was inside a region the pool had nobody left to run the children and the region hung.
That is exactly the 4-vCPU CI runner’s situation; it never reproduced on a 14-core laptop. The
chunk-queue region above replaced it.
|
First consumer: scaledDotProductAttention
Every (batch, head) pair is independent โ private scores scratch, disjoint output rows โ so
the pairs are the units of schedule.forRange. The per-pair arithmetic and its order are the
sequential loop’s, which is what makes the result bit-identical. Calls below
SDPA_PARALLEL_MIN_WORK multiply-adds (a decode step on a handful of heads) stay inline.
Compile lane
dag { schedule(parallel("heads")) { op(sdpa, โฆ) } } or op(matmul, โฆ, schedule =
parallel("rows", parallelism = 8)) stamp a ScheduleHint under skainet.schedule on the node
(the DtypePolicyDsl channel); ScheduleAnnotationPass validates the requested dimensions per
op (sdpa: batch, heads; matmul: rows; conv: batch, out_channels), stamps the normalized hint into
GraphNode.metadata and reports every rejection as a diagnostic; StableHloConverter emits
skainet.schedule = {<node> = {parallel_dims = ["heads"], parallelism = 8}} in the module header
beside skainet.tensor_layouts. One graph, extra schedule metadata.
Phase 2 makes the structure explicit whether or not the DSL said anything:
-
ScheduleAnnotationPassappliesstructuralDefaults()(attention โparallel_dims = [batch, heads]) to every op without a hint of its own; defaults may never carry a core count, and an explicitparallelismis kept but flagged advisory.HloGenerator.corePasses(target)exposes the layout and schedule passes for exporters that trace their own tape. -
scaledDotProductAttentionis grouped-query native: K/V[b, nKV, Sk, hd]withnKV | H. The CPU kernel reads K/V through the group index (same loop order, bit-identical whennKV == H); the StableHLO lowering views Q as[b, nKV, nRep, Sq, hd]and batches bothdot_general`s over `[b, nKV]withnRepa free axis โ K/V are never broadcast or concatenated. That is the structural statement the backend tiles over. -
The graph/tape contexts answer
schedulefrom the ops they wrap (ScheduledOps), so the JVMComputeGraphExecutorโ built fromctx.opsโ runs under the caller’s schedule and awithScheduleon a graph context yields a sibling instead of a downgrade.
Thread-safety work that made it possible
KernelDispatch and KernelRegistry now keep immutable snapshots with serialized writes, so
schedule workers may dispatch concurrently. Everything else in the hot path stays single-threaded
by contract: ForwardScope (bump allocator), ScratchPool, the DefaultCpuOps prepack caches,
KernelProfile โ hence the rule that bodies never touch a context.
Requirements
Compatibility and Migration
Additive throughout: new interface members with defaults, secondary constructors keeping the
old JVM signatures (DirectCpuExecutionContext, DefaultCpuOps), SPI overloads with default
bodies. Behaviour change on the JVM only: DirectCpuExecutionContext() now runs
scaledDotProductAttention on the hardware schedule โ bit-identical, opt out with
withSchedule(Schedule.Sequential).
Rollout Plan
-
Registries safe for concurrent reads (
KernelDispatchConcurrencyTest). -
Schedule,ScheduleHint, context seam, trace events (ScheduledExecutionContextTest). -
CoroutineSchedule,parallelChunks(schedule), ops/factory/context plumbing (CoroutineScheduleTest,DirectCpuExecutionContextScheduleTest). -
Scheduled SDPA (
SdpaScheduleParityTest,SdpaCoroutineParityTest, JMHSdpaScheduleBench). -
Compile lane (
ScheduleDslTest,ScheduleAnnotationPassTest,ScheduleModuleAttributeTest). -
Docs: Algorithm and schedule, Schedule getting started with an executable sample.
-
Downstream: SKaiNET-transformers per-head attention on the same
Schedule(its own spec). -
Phase 2 โ graph contexts honour the schedule of their ops (
ScheduledOps,ComputeGraphExecutorScheduleTest,DefaultGraphExecutionContextScheduleTest). -
Phase 2 โ grouped-query native SDPA, eager and lowered (
SdpaGqaParityTest,SdpaGqaHloExportTest). -
Phase 2 โ structural defaults and the advisory rule (
ScheduleAnnotationPassTest,ScheduleModuleAttributeTest),HloGenerator.corePasses. -
Phase 2 downstream โ transformers record GQA without
repeatKVHeads, export harnesses stamp the structural hints, OPTIMIZED-mode parity, and the IREE run-time knob (SKAINET_TASK_GROUPSโ task-topology group count) in the Android runtime.
Acceptance Criteria
-
Parity tests assert
assertContentEqualsbetween sequential and scheduled outputs. -
apiCheckgreen with dumps refreshed and no removed lines. -
SdpaScheduleBenchshows the hardware schedule ahead of sequential on 8 heads ร 4096 keys. -
Antora builds with the two new pages linked from
nav.adoc. -
Phase 2: a graph executed through
ComputeGraphExecutorwith scheduled ops routes SDPA units through that schedule, bit-identical to sequential; grouped K/V equals tiled K/V bit for bit; the GQA export contains nostablehlo.concatenateof K/V and its header statesparallel_dimswithout aparallelism; every.vmfbis core-count free.
Risks
-
Blocking a
Dispatchers.Defaultworker on the pool it belongs to โ the region never waits for a chunk that still needs a pool thread: the caller drains the queue itself and only joins chunks already running. Tested from inside a Default worker and, deterministically, from every thread of a saturated two-thread pool. -
A body that touches the context โ the contract is documented on
forRange; the parity tests run bodies on foreign threads. -
JIT-dependent lane reductions โ the Panama matmul’s
reduceLanesorder changes when the JIT intrinsifies it, so the very first call in a JVM can differ by an ULP from later ones regardless of schedule (observed while writing the tests; filed as #1261). Parity tests warm up first; golden gates should do the same. -
Region overhead on tiny ops โ
SDPA_PARALLEL_MIN_WORKandSchedule.tasksForkeep small calls inline; tune from the JMH numbers.
Measurements
Downstream, on the SKaiNET-transformers feature/attention-schedule branch
(AttentionScheduleSpeedProfile, Llama-3.2-1B-Instruct Q8_0, i7-9750H 6c/12t, JDK 25, 512-token
prefill + 32 greedy tokens, 2026-09-03; greedy tokens identical in every row):
| Schedule / KV cache | attn.fused_compute |
attn.kvcache |
Decode tok/s |
|---|---|---|---|
|
8,991 ms |
447 ms |
7.7 |
|
2,773 ms |
369 ms |
8.7 |
|
2,590 ms |
10 ms |
9.7 |
Phase 2, compiled JVM leg (OptimizedModeScheduleParityTest, SmolLM2-135M dequantized, 9 heads over
3 KV heads, compileUnoptimized, 2026-09-04): the OPTIMIZED runtimes under Sequential and
CoroutineSchedule.hardware() are bit-identical at every step and match the eager leg at
position 0 within 2e-5. No measurable speedup there (โ1.18 s/step either way): the shape-[1]
graph replays position 0, so its SDPA is far below SDPA_PARALLEL_MIN_WORK; the compiled leg’s
attention gain will show once the graph carries a real prefix (the KV-cache replay limitation
documented in the transformers StateManagementTest, unrelated to schedules).
Engine microbenchmark (SdpaScheduleBench, same machine): see the table below.
SdpaScheduleBench (DefaultCpuOps.scaledDotProductAttention, batch 1, headDim 64, causal,
JMH avgt 5ร10 s, 2026-09-03, i7-9750H 6c/12t, JDK 25; hardware = CoroutineSchedule.hardware()
with 12 tasks):
| heads | seqKV | seqQ | sequential | hardware | speed-up |
|---|---|---|---|---|---|
8 |
128 |
1 |
0.335 ms |
0.334 ms |
1.0ร (below |
8 |
128 |
64 |
15.9 ms |
4.0 ms |
4.0ร |
8 |
1024 |
1 |
3.9 ms |
1.8 ms |
2.2ร |
8 |
1024 |
64 |
185.5 ms |
52.9 ms |
3.5ร |
8 |
4096 |
1 |
16.2 ms |
7.9 ms |
2.0ร |
8 |
4096 |
64 |
833.1 ms |
223.3 ms |
3.7ร |
32 |
128 |
1 |
1.47 ms |
0.62 ms |
2.4ร |
32 |
128 |
64 |
64.3 ms |
13.3 ms |
4.8ร |
32 |
1024 |
1 |
16.7 ms |
6.2 ms |
2.7ร |
32 |
1024 |
64 |
755.6 ms |
185.1 ms |
4.1ร |
32 |
4096 |
1 |
68.6 ms |
31.7 ms |
2.2ร |
32 |
4096 |
64 |
2,859 ms |
835.7 ms |
3.4ร |
Six physical cores, eight or thirty-two units of work: the prefill shapes (seqQ = 64) reach
3.4โ4.8ร, the single-query decode shapes 2.0โ2.7ร (the per-head work is small enough for the
fork/join and the memory traffic to show). Sequential error bars are wide because the
single-threaded run is at the mercy of turbo clocks; the scheduled runs are steady.
Open Questions
-
Should Android get a
CoroutineSchedule(coroutines are jvmMain-only in backend-cpu today)? -
Should
ensureInstalled()andwithScheduledetect a caller already onDispatchers.Defaultand emitScheduleDowngradedinstead of running inline? -
~~When
TargetOptimizer.stableHloPasses()exists, which IREE attribute shouldskainet.schedulelower to?~~ Answered in phase 2: none. The header stays advisory; the structure is in the ops themselves and the core count is set on the device at run time.
References
-
Halide: Ragan-Kelley et al., "Halide: decoupling algorithms from schedules", PLDI 2013.
-
Kotlin structured concurrency:
coroutineScope,launch,Dispatchers.Default. -
The DSL is compute; SKEEP-003 (the memory model the contract leans on).
-
Daily-StandAPP
docs/modules/planning/pages/verification-2026-09.adocโ the profile that motivated this.