# Copyright Advanced Micro Devices, Inc., or its affiliates. # SPDX-License-Identifier: MIT """Instruction scheduler for subtile-based mainloop. Interleaves non-MFMA instructions between MFMAs using a slot-based placer with pluggable scheduling rules. """ from typing import List, Tuple, Optional from rocisa.code import Module from rocisa.instruction import SWaitCnt, SBarrier, MFMAInstruction, MXMFMAInstruction, \ LocalReadInstruction, GlobalReadInstruction, CommonInstruction class _SlotPlacer: """Generic slot placement engine for interleaving instructions between MFMAs. Each interval (pair of adjacent MFMAs) has 2 placement slots. Rules are injected via callbacks: - validators: (placer, pos, inst) -> bool — reject invalid slots - adjusters: (placer, limit, inst) -> limit — shift search start - onPlace: (placer, pos, inst) -> None — update rule state after placement """ def __init__(self, intervals: int, numModules: int, pathOrders: List[List[int]], validators=None, adjusters=None, onPlace=None): self.totalSlots = intervals * 2 self._n = numModules self._prevInPath: List[int] = [-1] * numModules self._nextInPath: List[int] = [-1] * numModules for order in pathOrders: for a, b in zip(order, order[1:]): self._prevInPath[b] = a self._nextInPath[a] = b self._validators = validators or [] self._adjusters = adjusters or [] self._onPlace = onPlace self._placed: List[List[Tuple[int, object]]] = [[] for _ in range(self.totalSlots)] self._firstPos: List[Optional[int]] = [None] * numModules self._lastPos: List[Optional[int]] = [None] * numModules self.leftovers: List[Tuple[int, object]] = [] # ── Placement ── def _canPlace(self, pos: int, inst) -> bool: if pos < 0 or pos >= self.totalSlots or len(self._placed[pos]) >= 2: return False return all(v(self, pos, inst) for v in self._validators) def adjustLimit(self, limit: int, inst) -> int: for adj in self._adjusters: limit = adj(self, limit, inst) return limit def bounds(self, mid: int) -> Tuple[int, int]: lo = 0 pred = self._prevInPath[mid] if 0 <= pred < self._n and self._lastPos[pred] is not None: lo = self._lastPos[pred] + 1 hi = self.totalSlots - 1 succ = self._nextInPath[mid] if 0 <= succ < self._n and self._firstPos[succ] is not None: hi = self._firstPos[succ] - 1 return lo, hi def findSlot(self, mid: int, inst, limit: int, reverse: bool = False) -> Optional[int]: lo, hi = self.bounds(mid) if reverse: hi = min(hi, limit) else: lo = max(lo, limit) if hi < lo: return None for pos in (range(hi, lo - 1, -1) if reverse else range(lo, hi + 1)): if self._canPlace(pos, inst): return pos return None def _forceSlot(self, mid: int, limit: int, reverse: bool) -> int: """Find the closest valid slot respecting dependencies, allowing >2 items per slot.""" lo, hi = self.bounds(mid) if reverse: hi = min(hi, limit) lo = max(lo, 0) if hi < lo: hi = lo return hi else: lo = max(lo, limit) hi = min(hi, self.totalSlots - 1) if lo > hi: lo = hi return lo def place(self, pos: int, item: Tuple[int, object], reverse: bool = False): mid = item[0] if reverse: self._placed[pos].insert(0, item) else: self._placed[pos].append(item) if self._firstPos[mid] is None or pos < self._firstPos[mid]: self._firstPos[mid] = pos if self._lastPos[mid] is None or pos > self._lastPos[mid]: self._lastPos[mid] = pos if self._onPlace: self._onPlace(self, pos, item[1]) def placePath(self, pathInsts: List[Tuple[int, object]], reverse: bool = False, multiDU: bool = False): """Place a sequence of (moduleId, instruction) items into slots. Walks pathInsts in order, applying adjusters (forward only) and finding valid slots. When no empty slot is found, force-places at the closest valid position respecting dependencies (allowing >2 items per slot). When multiDU is True, wait_gr immediately followed by barrier is placed as an atomic pair in the same slot so other paths cannot insert m0 setup between them. """ limit = (self.totalSlots - 1) if reverse else 0 if not multiDU: for item in pathInsts: mid, inst = item if not reverse: limit = self.adjustLimit(limit, inst) pos = self.findSlot(mid, inst, limit, reverse=reverse) if pos is None: pos = self._forceSlot(mid, limit, reverse) self.place(pos, item, reverse=reverse) limit = (pos - 1) if reverse else (pos + 1) return idx = 0 while idx < len(pathInsts): item = pathInsts[idx] mid, inst = item if (not reverse and idx + 1 < len(pathInsts) and _isWaitGr(inst) and _isBarrier(pathInsts[idx + 1][1])): if not reverse: limit = self.adjustLimit(limit, inst) pos = self.findSlot(mid, inst, limit, reverse=reverse) if pos is None: pos = self._forceSlot(mid, limit, reverse) self.place(pos, item, reverse=reverse) sync_item = pathInsts[idx + 1] if self._canPlace(pos, sync_item[1]): self.place(pos, sync_item, reverse=reverse) else: sync_pos = self.findSlot(sync_item[0], sync_item[1], pos + 1, reverse=reverse) if sync_pos is None: sync_pos = self._forceSlot(sync_item[0], pos + 1, reverse) self.place(sync_pos, sync_item, reverse=reverse) pos = sync_pos limit = (pos - 1) if reverse else (pos + 1) idx += 2 continue if not reverse: limit = self.adjustLimit(limit, inst) pos = self.findSlot(mid, inst, limit, reverse=reverse) if pos is None: pos = self._forceSlot(mid, limit, reverse) self.place(pos, item, reverse=reverse) limit = (pos - 1) if reverse else (pos + 1) idx += 1 # ── Assembly ── def assemble(self, mfmas) -> Module: intervals = len(mfmas) - 1 result = Module() result.add(mfmas[0]) for i in range(intervals): for slot in (2 * i, 2 * i + 1): for item in self._placed[slot]: result.add(item[1]) result.add(mfmas[i + 1]) for _, inst in self.leftovers: result.add(inst) return result # ── Scheduling rules ── # Hardcoded gap to hide ds_read latency. TODO: compute this more accurately. # gfx1250 needs a larger gap (8); other archs use 4. _MIN_MFMA_GAP_DS_READ_TO_WAIT_DEFAULT = 4 _MIN_MFMA_GAP_DS_READ_TO_WAIT_GFX1250 = 8 _isDsRead = lambda x: isinstance(x, LocalReadInstruction) _isBufferLoad = lambda x: isinstance(x, GlobalReadInstruction) _isWaitCnt = lambda x: isinstance(x, SWaitCnt) _isM0Update = lambda x: isinstance(x, CommonInstruction) and hasattr(x, 'dst') and hasattr(x.dst, 'regType') and x.dst.regType == 'm' # Typed op detection (see SWaitCntEx.isWaitGr / SBarrier) rather than matching # substrings in the instruction comment, which is brittle to comment edits. _isWaitGr = lambda x: _isWaitCnt(x) and getattr(x, "isWaitGr", False) _isBarrier = lambda x: isinstance(x, SBarrier) class _SchedulingRules: """Scheduling rules for slot placement: validators, adjusters, and placement hooks. Owns all rule state (ds_read/waitcnt tracking, buffer-load spreading). Bound methods are passed as callbacks to _SlotPlacer. """ def __init__(self, totalSlots: int, minGapDsReadToWait: int = _MIN_MFMA_GAP_DS_READ_TO_WAIT_DEFAULT): # Cross-path state self.lastDsReadPos = -1 self.earliestWaitCntPos = totalSlots self.waitGrBarrierPos: Optional[int] = None self.maxDsReadAfterWaitGr = -1 self.minGap = minGapDsReadToWait # Per-path state self._resetPath() def _resetPath(self): self.firstBufLoadPos: Optional[int] = None self.bufLoadIdx = 0 self.bufLoadMaxSlot = 0 self.numBufLoads = 0 # ── Validators: (placer, pos, inst) -> bool ── def oneDsReadPerInterval(self, placer, pos, inst): """At most one ds_read per interval (pair of slots) to avoid same SIMD pair stalls as we have a single codepath""" if not _isDsRead(inst): return True peer = pos ^ 1 return not (0 <= peer < placer.totalSlots and any(_isDsRead(item[1]) for item in placer._placed[peer])) def minGapDsReadBeforeWait(self, placer, pos, inst): """Reject ds_read too close to an already-placed waitcnt ahead.""" if not _isDsRead(inst): return True gap = self.minGap * 2 return self.earliestWaitCntPos - pos >= gap def minGapDsReadToWait(self, placer, pos, inst): """Reject waitcnt too close to the last placed ds_read.""" if not _isWaitCnt(inst) or self.lastDsReadPos < 0: return True gap = self.minGap * 2 return pos - self.lastDsReadPos >= gap def noM0WithBufferLoad(self, placer, pos, inst): """Avoid placing M0 updates and buffer_loads in the same MFMA interval.""" if not _isM0Update(inst) and not _isBufferLoad(inst): return True peer = pos ^ 1 slots = [pos] if 0 <= peer < placer.totalSlots: slots.append(peer) if _isM0Update(inst): return not any(_isBufferLoad(item[1]) for s in slots for item in placer._placed[s]) return not any(_isM0Update(item[1]) for s in slots for item in placer._placed[s]) def grAfterWaitGrDsReads(self, placer, pos, inst): """Keep GR (m0 / buffer_load) out of the window after wait_gr+barrier until ds_reads finish.""" if not (_isBufferLoad(inst) or _isM0Update(inst)): return True if self.waitGrBarrierPos is None or self.maxDsReadAfterWaitGr < 0: return True if pos <= self.waitGrBarrierPos: return True return pos > self.maxDsReadAfterWaitGr # ── Adjusters: (placer, limit, inst) -> limit ── def deferGrAfterWaitGrDsReads(self, placer, limit, inst): """Once past wait_gr, start searching for GR only after post-barrier ds_reads.""" if not (_isBufferLoad(inst) or _isM0Update(inst)): return limit if self.waitGrBarrierPos is None or self.maxDsReadAfterWaitGr < 0: return limit if limit <= self.waitGrBarrierPos: return limit return max(limit, self.maxDsReadAfterWaitGr + 1) def spreadBufferLoads(self, placer, limit, inst): """Spread buffer_load instructions evenly across available range.""" if not _isBufferLoad(inst) or self.bufLoadMaxSlot <= 0: return limit if self.firstBufLoadPos is not None: stride = max(1, (self.bufLoadMaxSlot - self.firstBufLoadPos) // self.numBufLoads) limit = max(limit, self.firstBufLoadPos + self.bufLoadIdx * stride) self.bufLoadIdx += 1 return limit # ── Placement hook: (placer, pos, inst) -> None ── def trackPlacement(self, placer, pos, inst): """Update rule state after a successful placement.""" if _isDsRead(inst): self.lastDsReadPos = max(self.lastDsReadPos, pos) if _isWaitCnt(inst): self.earliestWaitCntPos = min(self.earliestWaitCntPos, pos) if _isBufferLoad(inst) and self.firstBufLoadPos is None: self.firstBufLoadPos = pos # ── Per-path setup ── def resetPath(self): self._resetPath() def setupBufLoadSpreading(self, placer, pathInsts, order): """Compute buffer-load spreading bounds for a forward path. Reserves tail slots for non-buffer-load instructions in modules that follow the last GR module (e.g. GR_INC SRD updates, LDS buffer swaps). """ self.numBufLoads = sum(1 for _, inst in pathInsts if _isBufferLoad(inst)) if self.numBufLoads > 1: _, rawMax = placer.bounds(pathInsts[-1][0]) grModuleIds = {mid for mid, inst in pathInsts if _isBufferLoad(inst)} lastGrIdx = max(order.index(m) for m in grModuleIds if m in order) tailModuleIds = set(order[lastGrIdx + 1:]) numTailInsts = sum(1 for mid, _ in pathInsts if mid in tailModuleIds) # this is an approximation as we don't know exactly how many slots will be use by modules after the GR yet (in this codepath) self.bufLoadMaxSlot = max(0, rawMax - numTailInsts) def setPostWaitGrDsReadFence(self, placer) -> None: """After the wait_gr path is placed, record ds_read coverage for GR deferral.""" wg_pos = None for pos, slot in enumerate(placer._placed): for _, inst in slot: if _isWaitGr(inst): wg_pos = pos if wg_pos is None else min(wg_pos, pos) if wg_pos is None: self.waitGrBarrierPos = None self.maxDsReadAfterWaitGr = -1 return max_ds = -1 for pos, slot in enumerate(placer._placed): if pos <= wg_pos: continue for _, inst in slot: if _isDsRead(inst): max_ds = max(max_ds, pos) self.waitGrBarrierPos = wg_pos self.maxDsReadAfterWaitGr = max_ds def _classifyPaths(pathOrders, emittedModules): """Classify paths by wait_gr presence, sorted: wait_gr first, then by index.""" paths = [] for order in pathOrders: hasWaitGR = any(emittedModules[i].opType == "wait_gr" for i in order) paths.append((order, hasWaitGR)) paths.sort(key=lambda p: (0 if p[1] else 1, p[0][0] if p[0] else 10**9)) return paths def _flattenPath(order, emittedModules, reverse=False): """Flatten a path of module indices into (moduleId, instruction) pairs.""" pathInsts = [(mid, inst) for mid in order for inst in emittedModules[mid].instructions] if reverse: pathInsts.reverse() return pathInsts def extractPathsFromBeforeDeps(emittedModules) -> Tuple[int, List[List[int]], List[List[int]]]: """Extract non-MFMA dependency paths using only EmittedModule.before links. Returns: (mfmaIdx, paths, preMfmaPaths) - mfmaIdx: index of the MFMA emitted module in emittedModules - paths: list of non-MFMA module-index paths to interleave between MFMAs - preMfmaPaths: paths that must be emitted before the first MFMA (reachable from the MFMA's before link) """ idToIdx = {em.moduleId: i for i, em in enumerate(emittedModules)} n = len(emittedModules) mfmaModuleIds = [i for i, em in enumerate(emittedModules) if em.opType == "mfma"] assert len(mfmaModuleIds) == 1, "extractPathsFromBeforeDeps expects exactly one MFMA emitted module" mfmaIdx = mfmaModuleIds[0] nonMfmaIds = [i for i in range(n) if i != mfmaIdx] nonMfmaSet = set(nonMfmaIds) # Identify the non-MFMA module the MFMA depends on (if any). mfmaBefore = emittedModules[mfmaIdx].before preMfmaTarget = None if mfmaBefore is not None: bi = idToIdx.get(mfmaBefore) if bi is not None and bi in nonMfmaSet: preMfmaTarget = bi # Each non-MFMA module has at most one predecessor, and each predecessor # has at most one child, so paths are simple chains. pred: List[int] = [-1 for _ in range(n)] child: List[int] = [-1 for _ in range(n)] for i in nonMfmaIds: parent = -1 b = emittedModules[i].before if b is not None: bi = idToIdx.get(b) if bi is not None and bi != i and bi in nonMfmaSet: parent = bi pred[i] = parent if parent != -1: assert child[parent] == -1, \ f"extractPathsFromBeforeDeps expects unique child per predecessor, got {child[parent]} and {i} for {parent}" child[parent] = i def _findHead(mid: int) -> int: cur = mid seen = [False for _ in range(n)] while pred[cur] != -1 and not seen[cur]: seen[cur] = True cur = pred[cur] return cur def _walkFromHead(head: int, used: List[bool]) -> List[int]: order: List[int] = [] localSeen = [False for _ in range(n)] cur = head while cur != -1 and not used[cur] and not localSeen[cur]: order.append(cur) localSeen[cur] = True cur = child[cur] return order used = [False for _ in range(n)] paths: List[List[int]] = [] for mid in nonMfmaIds: if used[mid]: continue head = _findHead(mid) order = _walkFromHead(head, used) assert order, f"extractPathsFromBeforeDeps produced empty path for module {mid}" for i in order: used[i] = True paths.append(order) # Separate paths that the MFMA depends on (must go before first MFMA). preMfmaPaths: List[List[int]] = [] regularPaths: List[List[int]] = [] for path in paths: if preMfmaTarget is not None and preMfmaTarget in path: preMfmaPaths.append(path) else: regularPaths.append(path) return mfmaIdx, regularPaths, preMfmaPaths def instructionSchedule(emittedModules, multiDU: bool = False, minGapDsReadToWait=_MIN_MFMA_GAP_DS_READ_TO_WAIT_DEFAULT): """Interleave non-MFMA instructions between MFMAs using 2 slots/interval. Rules (shared): - MFMA order is preserved. - Between two adjacent MFMAs there are 2 placement slots. - At most one ds_read (LocalReadInstruction) per interval. - Before dependencies are respected at module order level. - Minimm distance between ds_read and it waitcnt (hardcoded for now) - Module-internal instruction order is preserved. - GR path is spread as much as possible across remaining valid slots. When multiDU is False (single-DU / legacy path): - LR path containing a WAIT_GR is packed from the end backwards. When multiDU is True (MX multi-DU path): - LR path containing a WAIT_GR is placed forward (wait_gr → sync → LR insts). - wait_gr+barrier stay contiguous; GR m0/buffer_load defer until that path's post-barrier ds_reads complete. TODO : To be tested on multi-partition setup. """ if not emittedModules: return Module() isMFMA = lambda x: isinstance(x, (MFMAInstruction, MXMFMAInstruction)) n = len(emittedModules) mfmaIdx, pathOrders, preMfmaOrders = extractPathsFromBeforeDeps(emittedModules) mfmas = [x for x in emittedModules[mfmaIdx].instructions if isMFMA(x)] def _emitPreMfma(result): for order in preMfmaOrders: for mid in order: for inst in emittedModules[mid].instructions: result.add(inst) # Single MFMA: no slots to interleave into — emit preMfma, MFMA, then paths. if len(mfmas) < 2: result = Module() _emitPreMfma(result) for m in mfmas: result.add(m) for order in pathOrders: for mid in order: for inst in emittedModules[mid].instructions: result.add(inst) return result paths = _classifyPaths(pathOrders, emittedModules) rules = _SchedulingRules(totalSlots=(len(mfmas) - 1) * 2, minGapDsReadToWait=minGapDsReadToWait) base_validators = [rules.oneDsReadPerInterval, rules.minGapDsReadBeforeWait, rules.minGapDsReadToWait, rules.noM0WithBufferLoad] base_adjusters = [rules.spreadBufferLoads] if multiDU: base_validators.append(rules.grAfterWaitGrDsReads) base_adjusters.insert(0, rules.deferGrAfterWaitGrDsReads) placer = _SlotPlacer( len(mfmas) - 1, n, pathOrders, validators=base_validators, adjusters=base_adjusters, onPlace=rules.trackPlacement) for order, hasWaitGR in paths: if not order: continue reverse = (not multiDU) and hasWaitGR pathInsts = _flattenPath(order, emittedModules, reverse=reverse) rules.resetPath() if not hasWaitGR: rules.setupBufLoadSpreading(placer, pathInsts, order) placer.placePath(pathInsts, reverse=reverse, multiDU=multiDU) if multiDU and hasWaitGR: rules.setPostWaitGrDsReadFence(placer) scheduled = Module() _emitPreMfma(scheduled) scheduled.add(placer.assemble(mfmas)) # Post-pass: adjust vmcnt of any SWaitCnt to account for buffer_loads # that the scheduler placed before it within this subIterK. bufLoadCount = 0 for inst in scheduled.flatitems(): if _isBufferLoad(inst): bufLoadCount += 1 elif _isWaitCnt(inst) and inst.vlcnt >= 0: if getattr(inst, 'adjustVmcnt', True): inst.vlcnt += bufLoadCount return scheduled