From ad6ccbad4f8bc21ed7885dd842365cb54533902f Mon Sep 17 00:00:00 2001 From: NeelM0906 Date: Thu, 20 Aug 2026 15:57:36 -0400 Subject: [PATCH 1/6] perf: coalesce disk-adjacent expert misses into scattered preadv runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Misses whose blobs are exactly contiguous on disk (uniform stride or a permuted expertOffsets table) are grouped into runs and fetched with one preadv per run — one syscall and one sequential NVMe read instead of a random pread per expert. Applies to both the cache-plan path and the speculative/async fill path. Roadmap Task 14e. --- .../Streaming/PreadExpertStreamer.swift | 104 +++++++++++-- .../PreadExpertStreamerTests+Coalescing.swift | 139 ++++++++++++++++++ 2 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 Tests/Mference/Core/Infrastructure/Streaming/PreadExpertStreamerTests+Coalescing.swift diff --git a/Sources/Mference/Infrastructure/Streaming/PreadExpertStreamer.swift b/Sources/Mference/Infrastructure/Streaming/PreadExpertStreamer.swift index f3224b0..24d46ac 100644 --- a/Sources/Mference/Infrastructure/Streaming/PreadExpertStreamer.swift +++ b/Sources/Mference/Infrastructure/Streaming/PreadExpertStreamer.swift @@ -275,15 +275,23 @@ public final class PreadExpertStreamer: @unchecked Sendable { precondition(plan.assignedSlots.count == plan.experts.count, "expert cache plan slot count mismatch") + let missFileOffsets = try plan.misses.map { index in + try fileOffsetForExpert(plan.experts[index]) + } + let runs = Self.coalescedReadRuns(offsets: missFileOffsets, + stride: layout.expertStride) let errorLock = NSLock() nonisolated(unsafe) var firstError: Error? - DispatchQueue.concurrentPerform(iterations: plan.misses.count) { missOffset in - let index = plan.misses[missOffset] + DispatchQueue.concurrentPerform(iterations: runs.count) { runIndex in + let run = runs[runIndex] + let destinations = run.map { + self.slotPointers[plan.assignedSlots[plan.misses[$0]]] + } do { - _ = try self.loadExpert( - layer: 0, - expert: plan.experts[index], - slot: plan.assignedSlots[index]) + try self.readScattered( + into: destinations, + fileOffset: missFileOffsets[run[0]], + strideBytes: Int(self.layout.expertStride)) } catch { errorLock.lock() if firstError == nil { firstError = error } @@ -378,15 +386,23 @@ public final class PreadExpertStreamer: @unchecked Sendable { _ reservation: [(expert: Int, slot: Int)] ) -> UInt64 { guard !reservation.isEmpty else { return 0 } + let fileOffsets = reservation.map { entry in + (try? fileOffsetForExpert(entry.expert)) ?? UInt64.max + } + let readable = reservation.indices.filter { fileOffsets[$0] != UInt64.max } + let runs = Self.coalescedReadRuns(offsets: readable.map { fileOffsets[$0] }, + stride: layout.expertStride) let loadedLock = NSLock() nonisolated(unsafe) var loaded: [Int] = [] - DispatchQueue.concurrentPerform(iterations: reservation.count) { index in - let entry = reservation[index] - guard (try? self.loadExpert(layer: 0, - expert: entry.expert, - slot: entry.slot)) != nil else { return } + DispatchQueue.concurrentPerform(iterations: runs.count) { runIndex in + let entries = runs[runIndex].map { readable[$0] } + let destinations = entries.map { self.slotPointers[reservation[$0].slot] } + guard (try? self.readScattered( + into: destinations, + fileOffset: fileOffsets[entries[0]], + strideBytes: Int(self.layout.expertStride))) != nil else { return } loadedLock.lock() - loaded.append(index) + loaded.append(contentsOf: entries) loadedLock.unlock() } @@ -558,6 +574,70 @@ public final class PreadExpertStreamer: @unchecked Sendable { maxCallNanos: maxCallNanos) } + private func fileOffsetForExpert(_ expert: Int) throws -> UInt64 { + let regionOffset = layout.expertOffset(layer: 0, expert: expert) + guard regionOffset + layout.expertStride <= layout.streamSize else { + throw StreamerError.offsetOutOfRange(regionOffset) + } + return layout.streamOffset + regionOffset + } + + /// Groups reads of `stride` bytes at `offsets` into runs that are exactly + /// contiguous on disk, so each run can be fetched with one scattered + /// `preadv` instead of one random `pread` per expert. Returns runs of + /// indices into `offsets`, each run in ascending disk order. Duplicate + /// offsets never share a run: their reads would overlap. + static func coalescedReadRuns(offsets: [UInt64], stride: UInt64) -> [[Int]] { + let sorted = offsets.indices.sorted { offsets[$0] < offsets[$1] } + var runs: [[Int]] = [] + for index in sorted { + if let last = runs.last?.last, offsets[index] == offsets[last] &+ stride { + runs[runs.count - 1].append(index) + } else { + runs.append([index]) + } + } + return runs + } + + /// Reads `destinations.count * strideBytes` contiguous file bytes starting + /// at `fileOffset`, scattering `strideBytes` into each destination in + /// order. Single-destination runs use the plain `pread` path. + private func readScattered(into destinations: [UnsafeMutableRawPointer], + fileOffset: UInt64, + strideBytes: Int) throws { + guard destinations.count > 1 else { + return try readFull(into: destinations[0], + fileOffset: fileOffset, + count: strideBytes) + } + let total = destinations.count * strideBytes + var filled = 0 + while filled < total { + let startIndex = filled / strideBytes + let within = filled % strideBytes + var vectors = [iovec( + iov_base: destinations[startIndex].advanced(by: within), + iov_len: strideBytes - within)] + for index in (startIndex + 1).. disk position: id 0 lives at blob 2, id 1 at blob 3, + // id 2 at blob 0, id 3 at blob 1. Runs form on disk order (ids 2,3 then + // 0,1), not id order. + let positions = [2, 3, 0, 1] + let layout = StreamLayout( + path: url.path, + streamOffset: Self.streamOffset, + streamSize: Self.streamSize, + expertsPerLayer: Self.numExperts, + expertStride: stride, + expertOffsets: positions.map { UInt64($0) * stride }) + let streamer = try PreadExpertStreamer( + layout: layout, device: device, slotCount: Self.numExperts) + + let results = try streamer.loadExpertsCached(experts: Array(0.. Date: Thu, 20 Aug 2026 16:08:20 -0400 Subject: [PATCH 2/6] perf: port pilot/shadow speculative expert prefetch to Inkling decode SpeculativeRouterInkling mirrors the DSV4 pilot: layer L+1's sigmoid router run against layer L's post-attention state inside layer L's own command buffer, read at the same router wake, feeding the existing reserve/read/join/confirm machinery and the shadow issue budget. Same kernels as the real router, so ranking is bit-identical (pinned by a parity test); a real-install test pins off-vs-shadow greedy identity. Mode stays opt-in (MFERENCE_SPEC_PREFETCH) until an A/B on the install accepts a default, matching how DSV4 earned its shadow default. --- .../MoE/SpeculativeRouterInkling.swift | 110 ++++++++++++++++++ .../Runtime/Inference/RealForwardRunner.swift | 73 +++++++++++- .../SpeculativeRouterInklingTests.swift | 101 ++++++++++++++++ .../InklingGenerationRegressionTests.swift | 39 +++++++ 4 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 Sources/Mference/Kernels/MoE/SpeculativeRouterInkling.swift create mode 100644 Tests/Mference/Core/Kernels/Inkling/SpeculativeRouterInklingTests.swift diff --git a/Sources/Mference/Kernels/MoE/SpeculativeRouterInkling.swift b/Sources/Mference/Kernels/MoE/SpeculativeRouterInkling.swift new file mode 100644 index 0000000..ad7bb63 --- /dev/null +++ b/Sources/Mference/Kernels/MoE/SpeculativeRouterInkling.swift @@ -0,0 +1,110 @@ +import Foundation +import Metal + +/// PILOT router-lookahead for Inkling-Small: layer L+1's sigmoid router +/// applied to layer L's post-attention state, encoded into layer L's command +/// buffer so the CPU learns a *guess* at the next layer's expert set at the +/// same wake as the real router readback — no extra command buffer, no extra +/// sync. The Inkling twin of `SpeculativeRouterDSV4`. +/// +/// Deliberately a separate object from `InklingKernels` rather than an extra +/// method on it: it must not share that class's `routerLogits` singleton (the +/// real router's logits are live in the same command buffer), and keeping it +/// in its own file means the speculative path cannot perturb the validated +/// decode router. Same kernels (`router_gemv_bf16_r4` + +/// `inkling_router_select`, both built without function constants exactly as +/// `InklingKernels` builds them), so the ranking — sigmoid scoring with the +/// per-expert gate bias — is bit-identical to what layer L+1 will compute for +/// real. Recall depends entirely on that fidelity; only the *input +/// activation* is an approximation. +final class SpeculativeRouterInkling { + + private let gemvPSO: MTLComputePipelineState + private let selectPSO: MTLComputePipelineState + private let logits: MTLBuffer + /// Predicted expert ids, `topK` x UInt32. Read by the CPU on the router + /// signal. + let predictedIndices: MTLBuffer + /// The select kernel writes routing weights and shared-expert gammas + /// alongside the indices; the prediction never uses them, but the kernel + /// contract needs the buffers. + private let discardedWeights: MTLBuffer + private let discardedGammas: MTLBuffer + + init(context: MetalContext, + numRouted: Int, + numShared: Int, + topK: Int) throws { + self.gemvPSO = try context.pipeline("router_gemv_bf16_r4") + self.selectPSO = try context.pipeline("inkling_router_select") + + guard let logits = context.device.makeBuffer( + length: (numRouted + numShared) * MemoryLayout.stride, + options: .storageModeShared), + let indices = context.device.makeBuffer( + length: topK * MemoryLayout.stride, + options: .storageModeShared), + let weights = context.device.makeBuffer( + length: topK * MemoryLayout.stride, + options: .storageModeShared), + let gammas = context.device.makeBuffer( + length: max(numShared, 1) * MemoryLayout.stride, + options: .storageModeShared) else { + throw MetalError.noDevice + } + logits.label = "inkling.pilot_router_logits" + indices.label = "inkling.pilot_router_indices" + self.logits = logits + self.predictedIndices = indices + self.discardedWeights = weights + self.discardedGammas = gammas + } + + /// Encodes the lookahead GEMV + sigmoid top-k. Must be encoded *before* + /// the router signal so the CPU sees the prediction at the same wake as + /// the real indices. Dispatch geometry mirrors + /// `InklingKernels.encodeRouter` exactly. + func encodePrediction(commandBuffer cb: MTLCommandBuffer, + weights: MTLBuffer, weightsOffset: Int, + hidden: MTLBuffer, + onesScale: MTLBuffer, + gateBias: MTLBuffer, gateBiasOffset: Int, + globalScale: MTLBuffer, globalScaleOffset: Int, + numRouted: UInt32, numShared: UInt32, + topK: UInt32, routeScale: Float, + d: UInt32) { + var total = numRouted + numShared + var dim = d + if let enc = cb.makeComputeCommandEncoder() { + enc.setComputePipelineState(gemvPSO) + enc.setBuffer(weights, offset: weightsOffset, index: 0) + enc.setBuffer(hidden, offset: 0, index: 1) + enc.setBuffer(onesScale, offset: 0, index: 2) + enc.setBuffer(logits, offset: 0, index: 3) + enc.setBytes(&total, length: 4, index: 4) + enc.setBytes(&dim, length: 4, index: 5) + enc.dispatchThreadgroups( + MTLSize(width: (Int(total) + 3) / 4, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: 128, height: 1, depth: 1)) + enc.endEncoding() + } + + guard let enc = cb.makeComputeCommandEncoder() else { return } + var nr = numRouted, ns = numShared, tk = topK, rs = routeScale + enc.setComputePipelineState(selectPSO) + enc.setBuffer(logits, offset: 0, index: 0) + enc.setBuffer(gateBias, offset: gateBiasOffset, index: 1) + enc.setBuffer(globalScale, offset: globalScaleOffset, index: 2) + enc.setBuffer(predictedIndices, offset: 0, index: 3) + enc.setBuffer(discardedWeights, offset: 0, index: 4) + enc.setBuffer(discardedGammas, offset: 0, index: 5) + enc.setBytes(&nr, length: 4, index: 6) + enc.setBytes(&ns, length: 4, index: 7) + enc.setBytes(&tk, length: 4, index: 8) + enc.setBytes(&rs, length: 4, index: 9) + enc.dispatchThreadgroups( + MTLSize(width: 1, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: 32, height: 1, depth: 1)) + enc.endEncoding() + } +} diff --git a/Sources/Mference/Runtime/Inference/RealForwardRunner.swift b/Sources/Mference/Runtime/Inference/RealForwardRunner.swift index 28111f6..17122c4 100644 --- a/Sources/Mference/Runtime/Inference/RealForwardRunner.swift +++ b/Sources/Mference/Runtime/Inference/RealForwardRunner.swift @@ -517,8 +517,12 @@ public final class RealForwardRunner: ChunkedPrefillRunner, ContextWindowReporti /// PILOT lookahead kernels, built on first use so `off` pays nothing and /// the (concurrently edited) DSV4 init block stays untouched. private var pilotRouter: SpeculativeRouterDSV4? - /// The prediction read out of `pilotRouter` (or the hash table) at the - /// current layer's router wake, consumed by `issueSpeculativePrefetch`. + /// Inkling twin of `pilotRouter`: same lazy construction, same lifecycle, + /// family-matched scoring kernels. + private var inklingPilotRouter: SpeculativeRouterInkling? + /// The prediction read out of `pilotRouter`/`inklingPilotRouter` (or the + /// hash table) at the current layer's router wake, consumed by + /// `issueSpeculativePrefetch`. private var pilotPrediction: (layer: Int, experts: [Int])? public init(model: Model, context: MetalContext, maxContext: Int, @@ -1296,6 +1300,29 @@ public final class RealForwardRunner: ChunkedPrefillRunner, ContextWindowReporti return pilotRouter } + private func ensureInklingPilotRouter() -> SpeculativeRouterInkling? { + if let inklingPilotRouter { return inklingPilotRouter } + inklingPilotRouter = try? SpeculativeRouterInkling( + context: ctx, + numRouted: cfg.numExperts, + numShared: cfg.numSharedExperts, + topK: cfg.topKExperts) + return inklingPilotRouter + } + + /// Inkling wake-side twin of `capturePilotPrediction`: no hash-routed + /// layers exist in this family, so the prediction comes only from the + /// pilot GEMV. + private func captureInklingPilotPrediction(nextLayer: Int, gemvEncoded: Bool) { + pilotPrediction = nil + guard speculativePrefetchMode == .pilot || speculativePrefetchMode == .shadow, + nextLayer < cfg.numLayers, gemvEncoded, + let buffer = inklingPilotRouter?.predictedIndices else { return } + let ptr = buffer.contents().assumingMemoryBound(to: UInt32.self) + let cap = cfg.numExperts - 1 + pilotPrediction = (nextLayer, (0.. UInt16 { + UInt16(truncatingIfNeeded: v.bitPattern >> 16) + } + + private static func buffer(_ device: MTLDevice, _ values: [T]) -> MTLBuffer { + values.withUnsafeBytes { raw in + device.makeBuffer(bytes: raw.baseAddress!, length: raw.count, + options: .storageModeShared)! + } + } + + @Test func predictionMatchesRealRouterBitForBit() throws { + let context = try MetalContext() + let device = context.device + let kernels = try InklingKernels(context: context, + numRouted: Self.numRouted, + numShared: Self.numShared) + let pilot = try SpeculativeRouterInkling(context: context, + numRouted: Self.numRouted, + numShared: Self.numShared, + topK: Self.topK) + + var rng = SplitMix64(seed: 0x1_2C0F) + let total = Self.numRouted + Self.numShared + let weights = Self.buffer( + device, + (0..