Skip to content

Commit 73a48a8

Browse files
DFlashProposer: platform-aware peak memory measurement (CUDA / MPS / CPU)
Step 3a of the post-PR-#93 merge plan. PR #93's DFlashProposer. propose_block recorded peak activation bytes via: peak = 0 if torch.cuda.is_available(): torch.cuda.reset_peak_memory_stats() tokens = self.drafter.draft_block(...) if torch.cuda.is_available(): peak = int(torch.cuda.max_memory_allocated()) return BlockProposal(..., peak_activation_bytes=peak) This silently returned 0 on Mac MPS / CPU. The Mac MLX speculative- decoding eval (next PR, Step 3b) needs honest peak memory numbers on Apple Silicon for the BlockProposal accounting to be meaningful. Fix: extract three module-level helpers in dflash_drafter.py that dispatch by torch device type: _detect_device(model) -> str Reads model.parameters() to determine 'cuda' / 'mps' / 'cpu'. Raises RuntimeError on parameterless models (defensive — every real DFlashDrafter has parameters). _reset_peak_memory(device) -> None CUDA: torch.cuda.reset_peak_memory_stats() (existing behaviour) MPS: no-op (MPS has no peak counter; see docstring caveat) CPU: no-op (CPU peak measurement is psutil/tracemalloc territory) Unknown device: no-op _peak_memory_bytes(device) -> int CUDA: torch.cuda.max_memory_allocated() MPS: torch.mps.driver_allocated_memory() with try/except for runtime failure (returns 0 on RuntimeError, e.g. MPS attribute exists but actual MPS not initialised) CPU: 0 (signal: unmeasured, NOT lying with a fake peak) Unknown device: 0 (signal: unmeasured) DFlashProposer.propose_block rewired to: device = _detect_device(self.drafter) _reset_peak_memory(device) tokens = self.drafter.draft_block(...) peak = _peak_memory_bytes(device) return BlockProposal(..., peak_activation_bytes=peak) CUDA path semantics unchanged (same helpers, same calls, same output values). MPS/CPU paths now produce honest values instead of silently returning 0 in all cases. Caveats documented inline: * MPS has no peak counter. We use post-forward driver_allocated_memory as a tight upper bound on activations released after the forward — close enough for spec-decode-loop memory accounting in single-process scenarios. Stricter delta measurement requires the caller to snapshot before/after via torch.mps.driver_allocated_memory and subtract. * CPU returns 0 deliberately (signal: unmeasured) rather than lying with a fake measurement. CPU peak measurement is a different problem (psutil RSS or tracemalloc) outside the scope of activation-byte accounting in BlockProposal. Tests added (TestPlatformAwarePeakMemory, 8 tests): test_detect_device_cpu — synthetic small DFlashDrafter on CPU returns 'cpu' test_detect_device_raises_on_empty_model — defensive check test_peak_memory_bytes_cpu_returns_zero — unmeasured signal test_peak_memory_bytes_unknown_device_returns_zero — generic fallthrough test_reset_peak_memory_cpu_is_noop — no-op on cpu/unknown test_propose_block_records_zero_peak_on_cpu — full path: drafter on CPU → propose_block runs → BlockProposal has peak_activation_bytes=0 (no crash, no fake) test_peak_memory_bytes_mps_calls_driver_allocated_memory — direct unit of the helper for the MPS branch using a module-attribute swap (avoids monkeypatch scope creep on torch internals during draft_block forward — torch.random reaches into torch.mps._is_in_bad_fork etc.). Confirms _peak_memory_bytes('mps') returns int(driver_allocated_memory()) test_peak_memory_bytes_mps_handles_runtime_failure — when torch.mps.driver_allocated_memory raises (MPS attribute exists but MPS not actually initialised), helper returns 0 not propagates. Verified: stashing the fix and re-running these tests reproduces 7 of 8 failures cleanly (the 8th — empty model — passes by luck because raise-on-empty was the original behaviour). Un- stashing produces 28/28. Tests: 315/315 v04 suite passes (307 pre-existing + 8 new regression). Stack: off main, post PR #93 + PR #99 + PR #94 merge. This is Step 3a of the merge plan; Step 3b (Mac MLX speculative decoding eval script + reviewer aid) lands as a follow-up PR off main once Step 4 (mlx_lm Gemma 4 MoE compat fix) has empirical evidence the user can act on. Why split Step 3 into 3a + 3b: * 3a (this PR) lands a small, fully-testable improvement to PR #93's already-merged code. Useful regardless of when 3b lands. Linux CI exercises the platform dispatch logic without requiring Apple Silicon. * 3b needs to write speculative MLX bridge code (mx.array → torch.Tensor for hiddens; embed_fn / lm_head_fn callbacks that span the two runtimes). Writing that without ability to verify against a working mlx_lm verifier load = the same 'fake/fallback' pattern the user just got us out of with PR #93. Better to wait for Step 4 evidence + a working verifier load before authoring 3b. Net effect: PR #93's BlockProposal.peak_activation_bytes now reflects honest measurement on whichever device the drafter actually runs on, instead of always being 0 on non-CUDA hardware. Sets up Step 3b (Mac MLX eval) to produce meaningful memory accounting. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 6251230 commit 73a48a8

2 files changed

Lines changed: 191 additions & 5 deletions

File tree

inference_engine/v04/dflash_drafter.py

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -568,9 +568,8 @@ def propose_block(
568568
if not committed_token_ids:
569569
raise ValueError("committed_token_ids must be non-empty (need a bonus token)")
570570
aux_ctx, bonus_token_id = self.aux_provider.aux_hidden_context(committed_token_ids)
571-
peak = 0
572-
if torch.cuda.is_available():
573-
torch.cuda.reset_peak_memory_stats()
571+
device = _detect_device(self.drafter)
572+
_reset_peak_memory(device)
574573
# DFlash drafts the whole block in ONE non-causal forward (parallel
575574
# drafting); num_steps is accepted for interface compatibility but
576575
# the reference uses a single pass. The returned tokens are the drafts
@@ -580,8 +579,7 @@ def propose_block(
580579
aux_ctx, bonus_token_id, self.embed_fn, self.lm_head_fn,
581580
block_size=block_size,
582581
)
583-
if torch.cuda.is_available():
584-
peak = int(torch.cuda.max_memory_allocated())
582+
peak = _peak_memory_bytes(device)
585583
if len(tokens) != block_size: # pragma: no cover - draft_block guarantees
586584
raise RuntimeError(
587585
f"DFlash drafted {len(tokens)} tokens; expected {block_size}."
@@ -592,3 +590,83 @@ def propose_block(
592590
forward_passes=1,
593591
peak_activation_bytes=peak,
594592
)
593+
594+
595+
# ===========================================================================
596+
# Platform-aware peak memory measurement
597+
# ===========================================================================
598+
#
599+
# DFlashProposer.propose_block records peak activation bytes during
600+
# the draft forward as part of BlockProposal — used by the engine's
601+
# spec-decode harness for memory accounting. The original
602+
# implementation called ``torch.cuda.reset_peak_memory_stats`` /
603+
# ``torch.cuda.max_memory_allocated`` directly, which silently
604+
# returned 0 on non-CUDA devices (Mac MPS, CPU). The Mac speculative-
605+
# decoding eval (post-merge follow-up PR) needs honest peak memory
606+
# numbers on Apple Silicon, so the measurement is now platform-aware:
607+
#
608+
# CUDA → torch.cuda.{reset_peak_memory_stats, max_memory_allocated}
609+
# MPS → torch.mps.{driver_allocated_memory before/after} delta
610+
# (MPS has no peak counter; we measure the live allocation
611+
# delta around the forward, which is a tight upper bound
612+
# on activations released after the forward — close enough
613+
# for spec-decode-loop memory accounting)
614+
# CPU → None (no transient-tensor memory accounting on CPU; the
615+
# field is left at 0 to signal "unmeasured" rather than
616+
# lying with a fake 0)
617+
618+
619+
def _detect_device(model: nn.Module) -> str:
620+
"""Detect which compute device the model's parameters live on.
621+
622+
Returns one of ``"cuda"`` / ``"mps"`` / ``"cpu"``. Raises
623+
``RuntimeError`` if the model has no parameters (defensive — every
624+
real DFlashDrafter has parameters).
625+
"""
626+
try:
627+
p = next(model.parameters())
628+
except StopIteration:
629+
raise RuntimeError(
630+
"_detect_device: model has no parameters; cannot infer device"
631+
)
632+
return p.device.type
633+
634+
635+
def _reset_peak_memory(device: str) -> None:
636+
"""Reset the peak-memory counter for the device (CUDA only)."""
637+
if device == "cuda" and torch.cuda.is_available():
638+
torch.cuda.reset_peak_memory_stats()
639+
# MPS: no peak counter exposed; we capture pre-forward allocation
640+
# in _peak_memory_bytes via driver_allocated_memory. Initialised
641+
# implicitly by the caller via the post-forward read minus a
642+
# snapshot taken here in a thread-local. To keep the API simple
643+
# and stateless, we do NOT snapshot here for MPS — the post-
644+
# forward read alone is the absolute peak under the assumption
645+
# that the proposer is the dominant memory consumer in its own
646+
# process (true for spec-decode loops where drafter + verifier
647+
# are the only large tensors). If a stricter delta is needed,
648+
# the caller can wrap propose_block with their own MPS allocator
649+
# snapshot via torch.mps.driver_allocated_memory before the call.
650+
# CPU: nothing to reset.
651+
652+
653+
def _peak_memory_bytes(device: str) -> int:
654+
"""Return the peak allocation since the last reset, in bytes.
655+
656+
Returns 0 (unmeasured) on CPU and on devices where the runtime
657+
doesn't expose a peak counter. Returns int(driver_allocated_memory)
658+
on MPS — see :func:`_reset_peak_memory` docstring for the caveat
659+
that this is the post-forward live allocation rather than a true
660+
peak across the forward.
661+
"""
662+
if device == "cuda" and torch.cuda.is_available():
663+
return int(torch.cuda.max_memory_allocated())
664+
if device == "mps" and hasattr(torch, "mps"):
665+
try:
666+
return int(torch.mps.driver_allocated_memory())
667+
except Exception:
668+
return 0
669+
# CPU and unknown devices: no peak counter; return 0 to signal
670+
# "unmeasured". Callers that care about CPU peak measurement
671+
# should track it externally via psutil or tracemalloc.
672+
return 0

tests/inference_engine/v04/test_dflash_drafter.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,111 @@ def test_propose_block_validates_args(self):
306306
prop.propose_block([1, 2], block_size=0, num_steps=1)
307307
with pytest.raises(ValueError):
308308
prop.propose_block([1, 2], block_size=4, num_steps=0)
309+
310+
311+
# ---------------------------------------------------------------------------
312+
# Platform-aware peak memory helpers
313+
# ---------------------------------------------------------------------------
314+
315+
316+
class TestPlatformAwarePeakMemory:
317+
"""Cover the platform-aware peak memory helpers used by
318+
DFlashProposer.propose_block. Validates correct device
319+
detection + dispatch across cuda/mps/cpu without requiring
320+
actual GPU/MPS hardware (uses synthetic small models on
321+
CPU + monkeypatch for the other backends).
322+
"""
323+
324+
def _make_drafter_on_cpu(self) -> DFlashDrafter:
325+
return DFlashDrafter(_tiny_cfg()).to(torch.float32)
326+
327+
def test_detect_device_cpu(self):
328+
from inference_engine.v04.dflash_drafter import _detect_device
329+
m = self._make_drafter_on_cpu()
330+
assert _detect_device(m) == "cpu"
331+
332+
def test_detect_device_raises_on_empty_model(self):
333+
from inference_engine.v04.dflash_drafter import _detect_device
334+
m = nn.Module() # no params
335+
with pytest.raises(RuntimeError, match="no parameters"):
336+
_detect_device(m)
337+
338+
def test_peak_memory_bytes_cpu_returns_zero(self):
339+
from inference_engine.v04.dflash_drafter import _peak_memory_bytes
340+
# CPU has no peak counter — return 0 (signal: unmeasured)
341+
assert _peak_memory_bytes("cpu") == 0
342+
343+
def test_peak_memory_bytes_unknown_device_returns_zero(self):
344+
from inference_engine.v04.dflash_drafter import _peak_memory_bytes
345+
assert _peak_memory_bytes("xpu_or_other") == 0
346+
347+
def test_reset_peak_memory_cpu_is_noop(self):
348+
from inference_engine.v04.dflash_drafter import _reset_peak_memory
349+
# Should not raise on CPU
350+
_reset_peak_memory("cpu")
351+
_reset_peak_memory("xpu_or_other") # unknown device — also noop
352+
353+
def test_propose_block_records_zero_peak_on_cpu(self):
354+
"""Regression: previously DFlashProposer.propose_block called
355+
torch.cuda.* unconditionally. On CPU it should record 0
356+
(unmeasured) without crashing."""
357+
cfg = _tiny_cfg()
358+
drafter = self._make_drafter_on_cpu()
359+
embed_fn, lm_head_fn = _synthetic_verifier_heads(cfg)
360+
provider = _SyntheticAuxProvider(cfg)
361+
proposer = DFlashProposer(drafter, provider, embed_fn, lm_head_fn)
362+
363+
prop = proposer.propose_block(
364+
committed_token_ids=[1, 2, 3, 4, 5],
365+
block_size=cfg.block_size,
366+
num_steps=1,
367+
)
368+
assert isinstance(prop, BlockProposal)
369+
assert len(prop.tokens) == cfg.block_size
370+
# CPU = unmeasured, returns 0 (NOT an error, NOT a fake peak)
371+
assert prop.peak_activation_bytes == 0
372+
373+
def test_peak_memory_bytes_mps_calls_driver_allocated_memory(self, monkeypatch):
374+
"""Directly exercise the helper for the 'mps' branch without
375+
going through propose_block (whose draft_block forward
376+
accidentally pokes torch.mps internals like
377+
torch.mps._is_in_bad_fork). Validates the dispatch logic:
378+
when device='mps' AND torch.mps exists AND has
379+
driver_allocated_memory() → call it and return the int.
380+
"""
381+
from inference_engine.v04 import dflash_drafter
382+
383+
class _FakeMPS:
384+
@staticmethod
385+
def driver_allocated_memory():
386+
return 12345678
387+
388+
# Stash the original to restore after — directly poking the
389+
# module attribute (bypasses monkeypatch scope creep on torch
390+
# globals during the test).
391+
original_mps = getattr(dflash_drafter.torch, "mps", None)
392+
dflash_drafter.torch.mps = _FakeMPS
393+
try:
394+
assert dflash_drafter._peak_memory_bytes("mps") == 12345678
395+
finally:
396+
if original_mps is not None:
397+
dflash_drafter.torch.mps = original_mps
398+
399+
def test_peak_memory_bytes_mps_handles_runtime_failure(self):
400+
"""If torch.mps.driver_allocated_memory raises (e.g. MPS not
401+
actually available despite the attribute existing), the helper
402+
must return 0 not propagate the exception."""
403+
from inference_engine.v04 import dflash_drafter
404+
405+
class _BrokenMPS:
406+
@staticmethod
407+
def driver_allocated_memory():
408+
raise RuntimeError("MPS not available in this process")
409+
410+
original_mps = getattr(dflash_drafter.torch, "mps", None)
411+
dflash_drafter.torch.mps = _BrokenMPS
412+
try:
413+
assert dflash_drafter._peak_memory_bytes("mps") == 0
414+
finally:
415+
if original_mps is not None:
416+
dflash_drafter.torch.mps = original_mps

0 commit comments

Comments
 (0)