Files
prain/prain/policy.py
T

246 lines
9.4 KiB
Python

"""Per-layer VRAM slot cache: which slot holds which expert, what to evict, what to copy
first, how scans and prefetched experts are admitted. Pure Python, no torch."""
import json
import math
import os
from bisect import bisect_right
from prain.config import Policy
class Lru:
"""Evict the expert used longest ago."""
def __init__(self, num_experts, policy, cost):
self.last = [0.0] * num_experts
def score(self, e, now):
return self.last[e]
def hit(self, e, now, heat):
self.last[e] = now
def admit(self, e, now, heat, low):
self.last[e] = -math.inf if low else now
def evicted(self, e):
pass
class Lfu:
"""Evict the expert with the lowest exponentially decayed heat. Scores survive eviction,
so an expert that comes back keeps its history."""
def __init__(self, num_experts, policy, cost):
self.decay = 0.5 ** (1 / policy.lfu_half_life)
self.value = [0.0] * num_experts
self.when = [0] * num_experts
def score(self, e, now):
return self.value[e] * self.decay ** (now - self.when[e])
def hit(self, e, now, heat):
self.value[e] = self.score(e, now) + heat
self.when[e] = now
def admit(self, e, now, heat, low):
if not low: # a low-priority admit keeps its history: never-seen experts score 0
self.hit(e, now, heat)
def evicted(self, e):
pass
class Gdsf:
"""GreedyDual-Size-Frequency: score = floor + frequency x refetch cost. The floor rises
to each victim's score, so experts that stopped being used age out. cost[e] is 1 for a
RAM expert and policy.disk_cost for a disk one, so slow-to-refetch experts stay longer."""
def __init__(self, num_experts, policy, cost):
self.cost = cost
self.floor = 0.0
self.freq = [0.0] * num_experts
self.value = [0.0] * num_experts
def score(self, e, now):
return self.value[e]
def hit(self, e, now, heat):
self.freq[e] += heat
self.value[e] = self.floor + self.freq[e] * self.cost[e]
def admit(self, e, now, heat, low):
self.freq[e] = 0.0 if low else heat
self.value[e] = self.floor + self.freq[e] * self.cost[e]
def evicted(self, e):
self.floor = max(self.floor, self.value[e])
class Belady:
"""The offline optimum, for the simulator only: evict the expert used furthest in the
future. future[e] lists the forward numbers that route to e, ascending."""
def __init__(self, future):
self.future = future
def score(self, e, now):
uses = self.future.get(e, ())
i = bisect_right(uses, now)
return -uses[i] if i < len(uses) else -math.inf
def hit(self, e, now, heat):
pass
def admit(self, e, now, heat, low):
pass
def evicted(self, e):
pass
POLICIES = {"lru": Lru, "lfu": Lfu, "gdsf": Gdsf}
class SlotCache:
"""Slots [0, len(pinned)) hold the pinned experts for the whole run. The cache_slots
slots after them hold experts from RAM or disk, managed by an eviction policy."""
def __init__(self, pinned, num_experts, cache_slots, policy=Policy(), cost=None, evictor=None):
self.num_experts = num_experts
self.cache_slots = cache_slots
self.policy = policy
self.pinned = {e: slot for slot, e in enumerate(pinned)}
self.slots = {} # cached expert -> slot
first = len(self.pinned)
self.free = list(range(first + cache_slots - 1, first - 1, -1)) # pop() -> lowest slot
cost = cost or [1.0] * num_experts
self.evictor = evictor or POLICIES[policy.eviction](num_experts, policy, cost)
self.now = 0 # forwards planned so far
self.recent = set() # experts routed to in the last forward
self.prefetched = set() # prefetched experts that were not used yet
self.uses = [0] * num_experts # forwards that routed to each expert
self.hits = 0 # routed expert already in VRAM
self.misses = 0 # routed expert copied in
self.cpu = 0 # routed expert computed on the CPU instead
self.prefetches = 0 # experts copied in ahead of time (prefetch or admit-behind)
self.prefetch_hits = 0 # ... that were used before being evicted
self.prefetch_wasted = 0 # ... that were evicted unused
def choose_cpu(self, counts, miss):
"""Experts the CPU should compute: counts maps expert -> routed tokens."""
if miss.mode == "copy":
return set()
away = (e for e in counts if e not in self.pinned and e not in self.slots)
if miss.mode == "cpu":
return set(away)
return {e for e in away if counts[e] <= miss.cpu_max_tokens}
def plan(self, heat, tokens=1, cpu=()):
"""Split one forward pass into rounds.
heat: {expert: heat > 0} for every expert routed to in this pass.
tokens: tokens in the pass; a pass with >= policy.scan_tokens tokens is a scan.
cpu: routed experts the CPU computes; they get no slot.
Returns [(slot_of, loads), ...]: slot_of maps each expert computed in that round to
its slot; loads lists the (expert, slot) copies to do before the round. Experts in
VRAM run in round 0. The others follow coldest first, so the hottest are copied last
and are the ones left in VRAM afterwards.
"""
self.now += 1
now, scan = self.now, tokens >= self.policy.scan_tokens
norm = {e: h / tokens for e, h in heat.items()} # per token, so scans don't dominate
for e in heat:
self.uses[e] += 1
self.recent = set(heat)
pinned = [e for e in heat if e in self.pinned]
resident = [e for e in heat if e in self.slots]
missing = sorted((e for e in heat if e not in self.pinned and e not in self.slots
and e not in cpu), key=lambda e: (heat[e], e))
self.hits += len(pinned) + len(resident)
self.misses += len(missing)
self.cpu += len(cpu)
for e in resident:
self.evictor.hit(e, now, norm[e])
if e in self.prefetched:
self.prefetched.discard(e)
self.prefetch_hits += 1
if missing and not self.cache_slots:
raise RuntimeError(f"experts {missing} are not in VRAM and there are no cache slots")
protected = set()
if scan and self.policy.scan_keep:
ranked = sorted(self.slots, key=lambda e: (self.evictor.score(e, now), e))
protected = set(ranked[-self.policy.scan_keep:])
busy = set(resident) # experts computed in the round being built
slot_of = {e: self.pinned[e] for e in pinned} | {e: self.slots[e] for e in resident}
rounds, i = [], 0
while True:
room = len(self.free) + sum(1 for e in self.slots if e not in busy and e not in protected)
group = missing[i:i + room]
if i < len(missing) and not group and not busy:
raise RuntimeError("no evictable cache slot: lower policy.scan_keep")
i += len(group)
loads = []
for e in group:
slot = self._take_slot(now, busy | protected)
self.slots[e] = slot
self.evictor.admit(e, now, norm[e], low=scan)
busy.add(e)
loads.append((e, slot))
slot_of.update(loads)
rounds.append((slot_of, loads))
if i >= len(missing):
break
slot_of, busy = {}, set()
if scan and self.policy.promote:
for e in missing[-self.policy.promote:]:
if e in self.slots:
self.evictor.hit(e, now, norm[e])
return rounds
def prefetch(self, predicted, budget):
"""Copy predicted experts in ahead of time, at low priority: they are evicted first
unless a forward uses them. Never evicts an expert routed to in the last forward.
predicted: [(expert, probability)], most likely first. Returns the (expert, slot) loads."""
loads, keep = [], set(self.recent)
for e, _ in predicted:
if len(loads) >= budget:
break
if e in self.pinned or e in self.slots:
continue
if not self.free and all(r in keep for r in self.slots):
break
slot = self._take_slot(self.now, keep)
self.slots[e] = slot
self.evictor.admit(e, self.now, 0.0, low=True)
self.prefetched.add(e)
keep.add(e)
loads.append((e, slot))
self.prefetches += len(loads)
return loads
def _take_slot(self, now, keep):
if self.free:
return self.free.pop()
victim = min((e for e in self.slots if e not in keep),
key=lambda e: (self.evictor.score(e, now), e))
self.evictor.evicted(victim)
if victim in self.prefetched:
self.prefetched.discard(victim)
self.prefetch_wasted += 1
return self.slots.pop(victim)
COUNTERS = ("hits", "misses", "cpu", "prefetches", "prefetch_hits", "prefetch_wasted")
def write_stats(path, caches):
"""Atomically write {"layers": {"<layer>": {"uses": [...], "hits": n, ...}}}."""
data = {"layers": {str(layer): {"uses": c.uses, **{k: getattr(c, k) for k in COUNTERS}}
for layer, c in sorted(caches.items())}}
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, path)