moe-l2 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
moe_l2/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """moe-l2: MoE inference L2 hot-cache scheduler."""
2
+
3
+ from .predictor import (
4
+ DOMAINS,
5
+ domain_to_expert_ids,
6
+ enable_semantic,
7
+ get_backbone_experts,
8
+ get_layer_specificity,
9
+ get_preload_set,
10
+ is_semantic_available,
11
+ load_mapping,
12
+ predict,
13
+ predict_hybrid,
14
+ )
15
+
16
+ __version__ = "0.2.0"
17
+ __all__ = [
18
+ "DOMAINS",
19
+ "load_mapping",
20
+ "predict",
21
+ "predict_hybrid",
22
+ "domain_to_expert_ids",
23
+ "get_preload_set",
24
+ "get_backbone_experts",
25
+ "get_layer_specificity",
26
+ "enable_semantic",
27
+ "is_semantic_available",
28
+ ]
moe_l2/cache.py ADDED
@@ -0,0 +1,549 @@
1
+ """L2 hot-cache manager for moe-l2.
2
+
3
+ Manages a shared-memory pool of preloaded expert weights with:
4
+ - mmap-backed shared memory in /dev/shm/ for zero-copy access
5
+ - Per-layer LRU eviction with pinned expert support
6
+ - Asynchronous preloading from domain predictions
7
+ - Thread-safe operations (protected by RLock)
8
+ - Hit/miss statistics tracking
9
+
10
+ Architecture:
11
+ L2 (RAM) sits between L3 (SSD/GGUF file) and L0 (GPU pool).
12
+ Experts that are likely to be needed are preloaded into L2
13
+ based on domain predictions, so they can be memcpy'd into
14
+ GPU pool in ~1150 µs instead of ~6500 µs from SSD.
15
+
16
+ Usage:
17
+ cache = L2Cache()
18
+ cache.pin_experts(0, [41, 72, 89]) # backbone experts
19
+
20
+ # Preload after prediction
21
+ cache.preload_domain("codegen", expert_map)
22
+
23
+ # On each token's expert selection
24
+ hot = cache.request(layer=15, expert_id=42)
25
+
26
+ # Check stats
27
+ print(cache.stats())
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import threading
34
+ import time
35
+ from concurrent.futures import ThreadPoolExecutor, Future
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ from .gguf_reader import MoEGGUFReader
40
+
41
+ # ── Defaults ──
42
+ DEFAULT_N_LAYERS = 40
43
+ DEFAULT_SLOTS_PER_LAYER = 96
44
+ DEFAULT_EXPERT_SIZE = 1_010_000 # ~1.01 MB (fallback when no model_path)
45
+ DEFAULT_L2_DIR = Path("/dev/shm/moe_l2")
46
+ DEFAULT_LOADER_WORKERS = 2
47
+
48
+ # Sentinels for slot tracking
49
+ _EMPTY = -1 # slot is free
50
+ _RESERVED = -2 # slot is being loaded (between evict and write)
51
+
52
+
53
+ class L2Cache:
54
+ """Per-layer LRU cache of expert weights in POSIX shared memory.
55
+
56
+ Each slot is an mmap'd file under {l2_dir}/. Filename: L{layer}_S{slot}.
57
+ Content: raw float16 expert weights (expert_size bytes).
58
+
59
+ Design choices (from LRU simulation + 8-domain expert analysis):
60
+ - 96 slots/layer: 84.4% hit rate across 8 domains (near ceiling)
61
+ - Per-layer independent LRU: layer variances were only ~7pp
62
+ - Pinned backbone/domain experts: small benefit at 96-slots,
63
+ critical at smaller capacities
64
+ - Async loading: non-blocking L3→L2 during user thinking time
65
+
66
+ Thread safety: all public methods use RLock internally.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ n_layers: Optional[int] = None,
72
+ slots_per_layer: int = DEFAULT_SLOTS_PER_LAYER,
73
+ expert_size: Optional[int] = None,
74
+ l2_dir: Optional[Path] = None,
75
+ loader_workers: int = DEFAULT_LOADER_WORKERS,
76
+ expert_data_dir: Optional[Path] = None,
77
+ model_path: Optional[str | Path] = None,
78
+ ):
79
+ # ── Auto-detect from GGUF model ──
80
+ self._gguf_reader: Optional[MoEGGUFReader] = None
81
+ if model_path is not None:
82
+ self._gguf_reader = MoEGGUFReader(model_path)
83
+ self.n_layers = n_layers or self._gguf_reader.num_layers
84
+ self.expert_size = (
85
+ expert_size or self._gguf_reader.per_expert_size()
86
+ )
87
+ else:
88
+ self.n_layers = n_layers or DEFAULT_N_LAYERS
89
+ self.expert_size = expert_size or DEFAULT_EXPERT_SIZE
90
+
91
+ self.slots_per_layer = slots_per_layer
92
+ self.l2_dir = l2_dir or DEFAULT_L2_DIR
93
+ self.expert_data_dir = expert_data_dir
94
+
95
+ # Create shared memory directory
96
+ self.l2_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ # ── Slot state ──
99
+ # _slots[layer][slot] -> expert_id or _EMPTY
100
+ # _lru_order[layer] -> list[expert_id]; most recent at end
101
+ # _pinned[layer] -> set[expert_id]; never evictable
102
+ # _domain_pinned[layer] -> set[expert_id]; pinned by active domain
103
+ self._slots: list[list[int]] = [
104
+ [_EMPTY] * slots_per_layer for _ in range(self.n_layers)
105
+ ]
106
+ self._lru_order: list[list[int]] = [[] for _ in range(self.n_layers)]
107
+ self._pinned: list[set[int]] = [set() for _ in range(self.n_layers)]
108
+ self._domain_pinned: list[set[int]] = [set() for _ in range(self.n_layers)]
109
+
110
+ # ── Statistics ──
111
+ self._hits: int = 0
112
+ self._misses: int = 0
113
+ self._loads_completed: int = 0
114
+
115
+ # ── Threading ──
116
+ self._lock = threading.RLock()
117
+ self._loader = ThreadPoolExecutor(
118
+ max_workers=loader_workers,
119
+ thread_name_prefix="moe-l2-loader",
120
+ )
121
+ # Track in-flight loads: (layer, expert_id) -> Future
122
+ self._pending_loads: dict[tuple[int, int], Future] = {}
123
+
124
+ # ── Active domain tracking ──
125
+ self._active_domain: Optional[str] = None
126
+
127
+ # ═══════════════════════════════════════════════════════════════
128
+ # Core API
129
+ # ═══════════════════════════════════════════════════════════════
130
+
131
+ def request(self, layer: int, expert_id: int) -> bool:
132
+ """Request an expert. Returns True if cached (hot), False if miss.
133
+
134
+ On hit: marks the expert as MRU (moved to LRU tail).
135
+ On miss: submits async L3→L2 load if not already pending.
136
+ """
137
+ with self._lock:
138
+ if self._is_cached(layer, expert_id):
139
+ self._touch(layer, expert_id)
140
+ self._hits += 1
141
+ return True
142
+
143
+ self._misses += 1
144
+ if (layer, expert_id) not in self._pending_loads:
145
+ future = self._loader.submit(
146
+ self._load_expert, layer, expert_id
147
+ )
148
+ self._pending_loads[(layer, expert_id)] = future
149
+ return False
150
+
151
+ def request_batch(self, requests: list[tuple[int, int]]) -> dict:
152
+ """Request multiple experts at once.
153
+
154
+ Args:
155
+ requests: list of (layer, expert_id) pairs.
156
+
157
+ Returns:
158
+ dict with keys: hits, misses, hit_count, miss_count
159
+ """
160
+ hits: list = []
161
+ misses: list = []
162
+ with self._lock:
163
+ for layer, expert_id in requests:
164
+ if self._is_cached(layer, expert_id):
165
+ self._touch(layer, expert_id)
166
+ self._hits += 1
167
+ hits.append((layer, expert_id))
168
+ else:
169
+ self._misses += 1
170
+ if (layer, expert_id) not in self._pending_loads:
171
+ future = self._loader.submit(
172
+ self._load_expert, layer, expert_id
173
+ )
174
+ self._pending_loads[(layer, expert_id)] = future
175
+ misses.append((layer, expert_id))
176
+
177
+ return {
178
+ "hits": hits,
179
+ "misses": misses,
180
+ "hit_count": len(hits),
181
+ "miss_count": len(misses),
182
+ }
183
+
184
+ def preload_domain(
185
+ self, domain: str, expert_map: dict
186
+ ) -> None:
187
+ """Preload all experts for a predicted domain.
188
+
189
+ Loads the per-layer top expert IDs from the domain→expert mapping.
190
+ Domain-preferred experts are pinned while this domain is active.
191
+ Old domain pins are cleared first.
192
+
193
+ Args:
194
+ domain: Domain label (e.g. "codegen", "math").
195
+ expert_map: Domain→expert mapping dict (from predictor._get_map()).
196
+ Structure: {"domains": {domain: {"per_layer_top": {layer: [ids]}}}}
197
+ """
198
+ domain_data = expert_map.get("domains", {}).get(domain, {})
199
+ per_layer_top = domain_data.get("per_layer_top", {})
200
+ if not per_layer_top:
201
+ return
202
+
203
+ with self._lock:
204
+ # Clear previous domain pins
205
+ for layer_idx in range(self.n_layers):
206
+ self._domain_pinned[layer_idx].clear()
207
+ self._active_domain = domain
208
+
209
+ # Collect loads and pin
210
+ to_load: list[tuple[int, int]] = []
211
+ for layer_str, expert_ids in per_layer_top.items():
212
+ layer = int(layer_str)
213
+ if not (0 <= layer < self.n_layers):
214
+ continue
215
+ for eid in expert_ids:
216
+ if not self._is_cached(layer, eid):
217
+ to_load.append((layer, eid))
218
+ # All domain-preferred experts get domain-pinned
219
+ self._domain_pinned[layer].add(eid)
220
+
221
+ # Submit async loads
222
+ for layer, eid in to_load:
223
+ if (layer, eid) not in self._pending_loads:
224
+ f = self._loader.submit(self._load_expert, layer, eid)
225
+ self._pending_loads[(layer, eid)] = f
226
+
227
+ def preload_batch(self, pairs: list[tuple[int, int]]) -> None:
228
+ """Preload a specific list of (layer, expert_id) pairs."""
229
+ with self._lock:
230
+ for layer, expert_id in pairs:
231
+ if not self._is_cached(layer, expert_id) \
232
+ and (layer, expert_id) not in self._pending_loads:
233
+ f = self._loader.submit(self._load_expert, layer, expert_id)
234
+ self._pending_loads[(layer, expert_id)] = f
235
+
236
+ def contains(self, layer: int, expert_id: int) -> bool:
237
+ """Check if an expert is already in L2 cache."""
238
+ with self._lock:
239
+ return self._is_cached(layer, expert_id)
240
+
241
+ def pin_experts(self, layer: int, expert_ids: list[int]) -> None:
242
+ """Permanently pin backbone/universal experts (never evicted).
243
+
244
+ These are experts activated across ALL domains — they should
245
+ never be evicted from cache.
246
+ """
247
+ with self._lock:
248
+ self._pinned[layer].update(expert_ids)
249
+
250
+ def unpin_experts(self, layer: int, expert_ids: list[int]) -> None:
251
+ """Remove permanent pin status from experts."""
252
+ with self._lock:
253
+ self._pinned[layer].difference_update(expert_ids)
254
+
255
+ def clear_domain_pins(self) -> None:
256
+ """Remove all domain-level pins (called on domain switch)."""
257
+ with self._lock:
258
+ for layer in range(self.n_layers):
259
+ self._domain_pinned[layer].clear()
260
+ self._active_domain = None
261
+
262
+ def wait_for_pending(self, timeout: float = 30.0) -> int:
263
+ """Wait for all pending expert loads to complete.
264
+
265
+ Args:
266
+ timeout: Max seconds to wait.
267
+
268
+ Returns:
269
+ Number of completed loads.
270
+ """
271
+ with self._lock:
272
+ pending = list(self._pending_loads.values())
273
+
274
+ done = 0
275
+ for f in pending:
276
+ try:
277
+ f.result(timeout=timeout)
278
+ done += 1
279
+ except Exception:
280
+ pass
281
+ return done
282
+
283
+ def get_slot_path(self, layer: int, slot: int) -> Optional[Path]:
284
+ """Get the shared memory file path for a slot.
285
+
286
+ Returns None if invalid layer/slot indices.
287
+ """
288
+ with self._lock:
289
+ if 0 <= layer < self.n_layers and 0 <= slot < self.slots_per_layer:
290
+ return self.l2_dir / f"L{layer}_S{slot}"
291
+ return None
292
+
293
+ def get_expert_slot(self, layer: int, expert_id: int) -> Optional[int]:
294
+ """Get the slot index for a cached expert, or None if not cached."""
295
+ with self._lock:
296
+ for slot, eid in enumerate(self._slots[layer]):
297
+ if eid == expert_id:
298
+ return slot
299
+ return None
300
+
301
+ def stats(self) -> dict:
302
+ """Return cache statistics.
303
+
304
+ Includes: hits, misses, hit rate, utilization, memory usage,
305
+ pending loads, active domain, and per-layer breakdown.
306
+ """
307
+ with self._lock:
308
+ total = self._hits + self._misses
309
+ hit_rate = round(self._hits / total * 100, 1) if total > 0 else 0.0
310
+
311
+ total_slots = self.n_layers * self.slots_per_layer
312
+ used_slots = sum(
313
+ 1 for ls in self._slots for s in ls if s != _EMPTY
314
+ )
315
+ pinned_count = sum(
316
+ len(self._pinned[l] | self._domain_pinned[l])
317
+ for l in range(self.n_layers)
318
+ )
319
+
320
+ # Per-layer breakdown (compact)
321
+ layer_stats = []
322
+ for layer in range(self.n_layers):
323
+ used = sum(1 for s in self._slots[layer] if s != _EMPTY)
324
+ pinned = len(
325
+ self._pinned[layer] | self._domain_pinned[layer]
326
+ )
327
+ layer_stats.append(
328
+ f" L{layer:2d}: {used:3d}/{self.slots_per_layer:3d} slots "
329
+ f"({pinned:3d} pinned)"
330
+ )
331
+
332
+ return {
333
+ "hits": self._hits,
334
+ "misses": self._misses,
335
+ "total_requests": total,
336
+ "hit_rate_pct": hit_rate,
337
+ "total_slots": total_slots,
338
+ "used_slots": used_slots,
339
+ "pinned_experts": pinned_count,
340
+ "pending_loads": len(self._pending_loads),
341
+ "loads_completed": self._loads_completed,
342
+ "utilization_pct": (
343
+ round(used_slots / total_slots * 100, 1)
344
+ if total_slots else 0.0
345
+ ),
346
+ "memory_usage_mb": round(
347
+ used_slots * self.expert_size / (1024 * 1024), 1
348
+ ),
349
+ "active_domain": self._active_domain,
350
+ "per_layer_lines": layer_stats,
351
+ }
352
+
353
+ def close(self) -> None:
354
+ """Clean up all shared memory regions and thread pool."""
355
+ self._loader.shutdown(wait=False)
356
+
357
+ # Clean up shm files
358
+ for layer in range(self.n_layers):
359
+ for slot in range(self.slots_per_layer):
360
+ path = self._get_shm_path(layer, slot)
361
+ if path.exists():
362
+ path.unlink()
363
+
364
+ # Remove directory if empty
365
+ try:
366
+ self.l2_dir.rmdir()
367
+ except OSError:
368
+ pass
369
+
370
+ def __enter__(self):
371
+ return self
372
+
373
+ def __exit__(self, *args):
374
+ self.close()
375
+
376
+ # ═══════════════════════════════════════════════════════════════
377
+ # Internal helpers
378
+ # ═══════════════════════════════════════════════════════════════
379
+
380
+ def _get_shm_path(self, layer: int, slot: int) -> Path:
381
+ """Absolute path to a slot's shared memory file."""
382
+ return self.l2_dir / f"L{layer}_S{slot}"
383
+
384
+ def _is_cached(self, layer: int, expert_id: int) -> bool:
385
+ """Check if expert weights are in shared memory.
386
+
387
+ NOTE: caller MUST hold self._lock.
388
+ """
389
+ if not (0 <= layer < self.n_layers):
390
+ return False
391
+ return expert_id in self._slots[layer]
392
+
393
+ def _touch(self, layer: int, expert_id: int) -> None:
394
+ """Mark expert as MRU — move to end of LRU list.
395
+
396
+ NOTE: caller MUST hold self._lock.
397
+ Pinned experts are not moved in LRU order.
398
+ """
399
+ if expert_id in self._pinned[layer] or expert_id in self._domain_pinned[layer]:
400
+ return # pinned: don't track in LRU
401
+ lru = self._lru_order[layer]
402
+ if expert_id in lru:
403
+ lru.remove(expert_id)
404
+ lru.append(expert_id) # most recent = end
405
+
406
+ def _find_evictable_slot(self, layer: int) -> Optional[int]:
407
+ """Find a slot to use for loading.
408
+
409
+ Priority: empty slot > LRU unpinned slot.
410
+ Skips _RESERVED slots (in-flighting loads).
411
+ Returns slot index, or None if all slots pinned/reserved.
412
+
413
+ NOTE: caller MUST hold self._lock.
414
+ """
415
+ slots = self._slots[layer]
416
+ pinned = self._pinned[layer] | self._domain_pinned[layer]
417
+ lru = self._lru_order[layer]
418
+
419
+ # 1. Prefer an already-empty slot (not in-flight)
420
+ for i, eid in enumerate(slots):
421
+ if eid == _EMPTY:
422
+ return i
423
+
424
+ # 2. Evict LRU unpinned expert (front of list = least recently used)
425
+ for eid in lru:
426
+ if eid not in pinned:
427
+ for i, seid in enumerate(slots):
428
+ if seid == eid:
429
+ return i
430
+
431
+ return None # all pinned/reserved — can't load anything new
432
+
433
+ def _evict_slot(self, layer: int, slot: int) -> None:
434
+ """Reserve a slot for loading. Caller MUST hold self._lock.
435
+
436
+ Sets the slot to _RESERVED to prevent concurrent loaders
437
+ from taking it. The actual expert_id is set after I/O completes.
438
+ """
439
+ old_eid = self._slots[layer][slot]
440
+ if old_eid not in (_EMPTY, _RESERVED):
441
+ # Remove from LRU tracking
442
+ lru = self._lru_order[layer]
443
+ if old_eid in lru:
444
+ lru.remove(old_eid)
445
+ # Remove the shm file
446
+ path = self._get_shm_path(layer, slot)
447
+ if path.exists():
448
+ path.unlink()
449
+ self._slots[layer][slot] = _RESERVED
450
+
451
+ def _load_expert(self, layer: int, expert_id: int) -> None:
452
+ """Load expert weights from disk into a shared memory slot.
453
+
454
+ Runs in background thread pool. Sequence:
455
+ 1. Reserve a slot (find or evict), set to _RESERVED
456
+ 2. Read weights from disk (I/O, outside lock)
457
+ 3. Write to shared memory file
458
+ 4. Update slot from _RESERVED to expert_id (inside lock)
459
+
460
+ On failure: revert slot to _EMPTY.
461
+ """
462
+ slot: Optional[int] = None
463
+ try:
464
+ # Step 1: reserve a slot
465
+ with self._lock:
466
+ # Double-check: loaded while we were queued
467
+ if self._is_cached(layer, expert_id):
468
+ self._pending_loads.pop((layer, expert_id), None)
469
+ return
470
+
471
+ slot = self._find_evictable_slot(layer)
472
+ if slot is None:
473
+ # All slots pinned/reserved — silently skip
474
+ self._pending_loads.pop((layer, expert_id), None)
475
+ return
476
+
477
+ # Evict occupant → set slot to _RESERVED
478
+ self._evict_slot(layer, slot)
479
+
480
+ # Step 2-3: I/O outside lock
481
+ data = self._read_expert_weights(layer, expert_id)
482
+ shm_path = self._get_shm_path(layer, slot)
483
+ with open(shm_path, "wb") as f:
484
+ f.write(data)
485
+
486
+ # Step 4: finalize (inside lock)
487
+ with self._lock:
488
+ # Only overwrite if still _RESERVED (not stolen)
489
+ if self._slots[layer][slot] == _RESERVED:
490
+ self._slots[layer][slot] = expert_id
491
+ self._touch(layer, expert_id)
492
+ self._loads_completed += 1
493
+ self._pending_loads.pop((layer, expert_id), None)
494
+
495
+ except Exception:
496
+ # On failure: free the slot
497
+ with self._lock:
498
+ self._pending_loads.pop((layer, expert_id), None)
499
+ if slot is not None \
500
+ and 0 <= layer < self.n_layers \
501
+ and 0 <= slot < self.slots_per_layer \
502
+ and self._slots[layer][slot] == _RESERVED:
503
+ self._slots[layer][slot] = _EMPTY
504
+
505
+ def _read_expert_weights(self, layer: int, expert_id: int) -> bytes:
506
+ """Read expert weights from disk.
507
+
508
+ Priority:
509
+ 1. GGUF model (via MoEGGUFReader) — if model_path was provided
510
+ 2. Pre-extracted .bin file: {expert_data_dir}/L{layer}_E{expert_id}.bin
511
+ 3. Zero-filled placeholder (fallback)
512
+
513
+ Returns exactly expert_size bytes.
514
+ """
515
+ # Priority 1: read from GGUF model
516
+ if self._gguf_reader is not None:
517
+ data = self._gguf_reader.read_expert_weights(layer, expert_id)
518
+ if len(data) >= self.expert_size:
519
+ return data[:self.expert_size]
520
+ return data + b"\x00" * (self.expert_size - len(data))
521
+
522
+ # Priority 2: pre-extracted .bin files
523
+ if self.expert_data_dir:
524
+ bin_path = self.expert_data_dir / f"L{layer}_E{expert_id}.bin"
525
+ if bin_path.exists():
526
+ with open(bin_path, "rb") as f:
527
+ data = f.read()
528
+ if len(data) >= self.expert_size:
529
+ return data[:self.expert_size]
530
+ # Pad if shorter
531
+ return data + b"\x00" * (self.expert_size - len(data))
532
+
533
+ # Priority 3: placeholder zeros
534
+ return b"\x00" * self.expert_size
535
+
536
+ def _pin_backbone_from_map(self, expert_map: dict) -> None:
537
+ """Pin backbone/universal experts from the domain→expert mapping.
538
+
539
+ Backbone experts are those activated in ALL tested domains.
540
+ In Qwen3.6 data, the 10 backbone experts are:
541
+ [41, 72, 89, 95, 112, 127, 191, 217, 221, 231]
542
+ (These appear in the top-15 of all 8 domains.)
543
+
544
+ This is called once at init time if auto_pin_backbone is set.
545
+ """
546
+ backbone = expert_map.get("backbone_experts", [])
547
+ if backbone:
548
+ for layer in range(self.n_layers):
549
+ self.pin_experts(layer, backbone)