eae-runtime 0.1.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.
Files changed (38) hide show
  1. eae_runtime/__init__.py +80 -0
  2. eae_runtime/_version.py +24 -0
  3. eae_runtime/adjoint.py +128 -0
  4. eae_runtime/backend.py +52 -0
  5. eae_runtime/blocks.py +69 -0
  6. eae_runtime/boundary_store.py +65 -0
  7. eae_runtime/config.py +48 -0
  8. eae_runtime/contrib/__init__.py +25 -0
  9. eae_runtime/contrib/transformer_blocks.py +259 -0
  10. eae_runtime/events.py +84 -0
  11. eae_runtime/forward_executor.py +35 -0
  12. eae_runtime/logging_utils.py +41 -0
  13. eae_runtime/memory.py +175 -0
  14. eae_runtime/passes/__init__.py +18 -0
  15. eae_runtime/passes/base.py +31 -0
  16. eae_runtime/passes/clip.py +25 -0
  17. eae_runtime/passes/logging_pass.py +29 -0
  18. eae_runtime/passes/quantize.py +55 -0
  19. eae_runtime/passes/regularization.py +47 -0
  20. eae_runtime/passes/synthetic_gradient.py +67 -0
  21. eae_runtime/pipeline.py +58 -0
  22. eae_runtime/profiler.py +47 -0
  23. eae_runtime/py.typed +0 -0
  24. eae_runtime/reconstruction.py +89 -0
  25. eae_runtime/runtime.py +180 -0
  26. eae_runtime/schedulers/__init__.py +38 -0
  27. eae_runtime/schedulers/async_scheduler.py +65 -0
  28. eae_runtime/schedulers/base.py +87 -0
  29. eae_runtime/schedulers/distributed.py +78 -0
  30. eae_runtime/schedulers/pipeline_scheduler.py +67 -0
  31. eae_runtime/schedulers/sequential.py +27 -0
  32. eae_runtime-0.1.0.dist-info/METADATA +229 -0
  33. eae_runtime-0.1.0.dist-info/RECORD +38 -0
  34. eae_runtime-0.1.0.dist-info/WHEEL +5 -0
  35. eae_runtime-0.1.0.dist-info/licenses/LICENSE +13 -0
  36. eae_runtime-0.1.0.dist-info/scm_file_list.json +59 -0
  37. eae_runtime-0.1.0.dist-info/scm_version.json +8 -0
  38. eae_runtime-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ..adjoint import AdjointState
6
+ from ..events import EventBus, EventType
7
+ from .base import EAEPass
8
+
9
+
10
+ class LoggingPass(EAEPass):
11
+ """Emits an ADJOINT_MODIFIED event carrying adjoint.statistics(); does
12
+ not otherwise touch the adjoint. Demonstrates that observation-only
13
+ passes are just as first-class as mutating ones."""
14
+
15
+ name = "LoggingPass"
16
+
17
+ def __init__(self, event_bus: Optional[EventBus] = None):
18
+ self.event_bus = event_bus or EventBus()
19
+
20
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
21
+ stats = adjoint.statistics()
22
+ self.event_bus.emit(
23
+ EventType.ADJOINT_MODIFIED,
24
+ layer_id=adjoint.layer_id,
25
+ block=adjoint.block,
26
+ pass_name=self.name,
27
+ **stats,
28
+ )
29
+ return adjoint.clone()
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from ..adjoint import AdjointState
6
+ from .base import EAEPass
7
+
8
+
9
+ class FP16Pass(EAEPass):
10
+ """Downcasts the adjoint to fp16 and back, simulating the numerical
11
+ effect of a reduced-precision communication/compute path."""
12
+
13
+ name = "FP16Pass"
14
+
15
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
16
+ new = adjoint.clone()
17
+ orig_dtype = new.tensor.dtype
18
+ new.tensor = new.tensor.to(torch.float16).to(orig_dtype)
19
+ return new
20
+
21
+
22
+ class Int8QuantizationPass(EAEPass):
23
+ """Fake-quantizes the adjoint to int8 with a per-tensor symmetric scale,
24
+ then immediately dequantizes. This is the standard 'fake quant' trick
25
+ used to study the effect of low-precision gradient compression without
26
+ needing real int8 kernels. A production FP8Pass would follow the same
27
+ shape but call into real fp8 kernels via the Backend Manager."""
28
+
29
+ name = "Int8QuantizationPass"
30
+
31
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
32
+ new = adjoint.clone()
33
+ t = new.tensor.float()
34
+ max_abs = t.abs().max()
35
+ if max_abs == 0:
36
+ return new
37
+ scale = max_abs / 127.0
38
+ q = torch.clamp(torch.round(t / scale), -127, 127)
39
+ dq = (q * scale).to(adjoint.tensor.dtype)
40
+ new.tensor = dq
41
+ new.metadata["quantization_scale"] = scale.item()
42
+ return new
43
+
44
+
45
+ # Alias matching the spec's example name; same fake-quant strategy, kept
46
+ # distinct so a future PR can swap in a real fp8 kernel behind this class
47
+ # without touching Int8QuantizationPass or any user code that references it.
48
+ class FP8Pass(EAEPass):
49
+ name = "FP8Pass"
50
+
51
+ def __init__(self):
52
+ self._impl = Int8QuantizationPass()
53
+
54
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
55
+ return self._impl.process(adjoint, context)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import torch
6
+
7
+ from ..adjoint import AdjointState
8
+ from .base import EAEPass
9
+
10
+
11
+ class RegularizationPass(EAEPass):
12
+ """Adds a decay term proportional to the adjoint itself, i.e. a simple
13
+ stand-in for gradient-space regularization (e.g. an L2 penalty on the
14
+ *gradient*, distinct from weight decay which acts on parameters)."""
15
+
16
+ name = "RegularizationPass"
17
+
18
+ def __init__(self, strength: float = 0.0):
19
+ self.strength = strength
20
+
21
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
22
+ if self.strength == 0.0:
23
+ return adjoint.clone()
24
+ new = adjoint.clone()
25
+ new.tensor = new.tensor * (1.0 - self.strength)
26
+ return new
27
+
28
+
29
+ class GaussianNoisePass(EAEPass):
30
+ """Injects zero-mean Gaussian noise into the adjoint, useful for studying
31
+ differential-privacy style gradient noising or robustness."""
32
+
33
+ name = "GaussianNoisePass"
34
+
35
+ def __init__(self, std: float = 0.0, generator: Optional[torch.Generator] = None):
36
+ self.std = std
37
+ self.generator = generator
38
+
39
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
40
+ if self.std == 0.0:
41
+ return adjoint.clone()
42
+ new = adjoint.clone()
43
+ noise = torch.randn(
44
+ new.tensor.shape, dtype=new.tensor.dtype, device=new.tensor.device, generator=self.generator
45
+ ) * self.std
46
+ new.tensor = new.tensor + noise
47
+ return new
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+ from ..adjoint import AdjointState
9
+ from .base import EAEPass
10
+
11
+
12
+ class SyntheticGradientPass(EAEPass):
13
+ """Decoupled Neural Interfaces style synthetic gradient pass.
14
+
15
+ Wraps a small learned predictor `synthesizer(x) -> approx_grad` that can
16
+ be substituted for the true adjoint once it has been warmed up, letting
17
+ a researcher study update-unlocking / async credit assignment schemes.
18
+ During `warmup_steps`, the true adjoint passes through unchanged and is
19
+ used purely as a regression target to train the synthesizer; after
20
+ warmup, the pass optionally *replaces* the adjoint with the synthetic
21
+ estimate when `use_synthetic=True`.
22
+ """
23
+
24
+ name = "SyntheticGradientPass"
25
+
26
+ def __init__(
27
+ self,
28
+ feature_dim: int,
29
+ hidden_dim: int = 64,
30
+ lr: float = 1e-3,
31
+ warmup_steps: int = 0,
32
+ use_synthetic: bool = False,
33
+ device: Optional[torch.device] = None,
34
+ ):
35
+ self.synthesizer = nn.Sequential(
36
+ nn.Linear(feature_dim, hidden_dim),
37
+ nn.ReLU(),
38
+ nn.Linear(hidden_dim, feature_dim),
39
+ )
40
+ if device is not None:
41
+ self.synthesizer = self.synthesizer.to(device)
42
+ self.optimizer = torch.optim.Adam(self.synthesizer.parameters(), lr=lr)
43
+ self.warmup_steps = warmup_steps
44
+ self.use_synthetic = use_synthetic
45
+ self._step = 0
46
+ self.last_loss: Optional[float] = None
47
+
48
+ def process(self, adjoint: AdjointState, context=None) -> AdjointState:
49
+ new = adjoint.clone()
50
+ flat = new.tensor.reshape(new.tensor.shape[0], -1) if new.tensor.dim() > 1 else new.tensor.unsqueeze(0)
51
+
52
+ with torch.enable_grad():
53
+ pred = self.synthesizer(flat.detach().float())
54
+ target = flat.detach().float()
55
+ loss = torch.nn.functional.mse_loss(pred, target)
56
+ self.optimizer.zero_grad()
57
+ loss.backward()
58
+ self.optimizer.step()
59
+ self.last_loss = loss.item()
60
+ self._step += 1
61
+
62
+ if self.use_synthetic and self._step > self.warmup_steps:
63
+ with torch.no_grad():
64
+ synthetic = self.synthesizer(flat.float()).to(new.tensor.dtype)
65
+ new.tensor = synthetic.reshape(new.tensor.shape)
66
+ new.metadata["synthetic_gradient_loss"] = self.last_loss
67
+ return new
@@ -0,0 +1,58 @@
1
+ """
2
+ AdjointPipeline: the central abstraction that turns EAE from a memory
3
+ optimization into a research platform.
4
+
5
+ Every reverse step exchanges an AdjointState through this programmable
6
+ pipeline instead of the scheduler/reconstruction engine passing tensors
7
+ directly between steps:
8
+
9
+ AdjointState -> Pass1 -> Pass2 -> ... -> PassN -> next local VJP
10
+
11
+ A researcher working on gradient compression, synthetic gradients,
12
+ error-feedback, distributed communication or adaptive optimization only
13
+ ever implements a new Pass. They never need to understand or modify the
14
+ reconstruction engine, scheduler, or memory manager.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from typing import List, Optional
21
+
22
+ from .adjoint import AdjointState
23
+ from .events import EventBus, EventType
24
+ from .passes.base import EAEPass
25
+
26
+
27
+ class AdjointPipeline:
28
+ def __init__(self, passes: Optional[List[EAEPass]] = None, event_bus: Optional[EventBus] = None,
29
+ profiler=None):
30
+ self.passes: List[EAEPass] = list(passes) if passes else []
31
+ self.event_bus = event_bus or EventBus()
32
+ self.profiler = profiler
33
+
34
+ def add_pass(self, p: EAEPass) -> None:
35
+ self.passes.append(p)
36
+
37
+ def run(self, adjoint: AdjointState, context: Optional[dict] = None) -> AdjointState:
38
+ context = context or {}
39
+ for p in self.passes:
40
+ start = time.perf_counter()
41
+ adjoint = p(adjoint, context)
42
+ elapsed = time.perf_counter() - start
43
+ if self.profiler is not None:
44
+ self.profiler.record("pass:" + p.name, elapsed)
45
+ self.event_bus.emit(EventType.PASS_APPLIED, pass_name=p.name, layer_id=adjoint.layer_id,
46
+ elapsed_seconds=elapsed)
47
+ return adjoint
48
+
49
+ def __call__(self, adjoint: AdjointState, context: Optional[dict] = None) -> AdjointState:
50
+ return self.run(adjoint, context)
51
+
52
+ def __len__(self) -> int:
53
+ return len(self.passes)
54
+
55
+
56
+ # Backwards-compatible alias matching the spec's "Pass Manager" component
57
+ # name; AdjointPipeline is the concrete implementation of that role.
58
+ PassManager = AdjointPipeline
@@ -0,0 +1,47 @@
1
+ """
2
+ Built-in profiler: per-block reconstruction time, VJP time, pass time,
3
+ memory, allocations, synchronization. Researchers need measurements.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import time
10
+ from collections import defaultdict
11
+ from typing import Dict, List
12
+
13
+
14
+ class Profiler:
15
+ def __init__(self, enabled: bool = True):
16
+ self.enabled = enabled
17
+ self._records: Dict[str, List[float]] = defaultdict(list)
18
+
19
+ def record(self, name: str, elapsed_seconds: float) -> None:
20
+ if self.enabled:
21
+ self._records[name].append(elapsed_seconds)
22
+
23
+ @contextlib.contextmanager
24
+ def track(self, name: str):
25
+ if not self.enabled:
26
+ yield
27
+ return
28
+ start = time.perf_counter()
29
+ try:
30
+ yield
31
+ finally:
32
+ self.record(name, time.perf_counter() - start)
33
+
34
+ def report(self) -> Dict[str, Dict[str, float]]:
35
+ out = {}
36
+ for name, values in self._records.items():
37
+ out[name] = {
38
+ "count": len(values),
39
+ "total_seconds": sum(values),
40
+ "mean_seconds": sum(values) / len(values) if values else 0.0,
41
+ "max_seconds": max(values) if values else 0.0,
42
+ "min_seconds": min(values) if values else 0.0,
43
+ }
44
+ return out
45
+
46
+ def reset(self) -> None:
47
+ self._records.clear()
eae_runtime/py.typed ADDED
File without changes
@@ -0,0 +1,89 @@
1
+ """
2
+ Reconstruction Engine: given a block, a saved boundary activation, and an
3
+ incoming AdjointState, it rebuilds the local computation graph, computes the
4
+ VJP using PyTorch's *local* autograd (torch.autograd.grad), and returns a
5
+ new AdjointState plus parameter gradients.
6
+
7
+ No global graph ever exists. PyTorch owns local autograd / VJP / kernels;
8
+ the runtime owns everything around it (see spec, Primary Design Principle 2).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+
18
+ from .adjoint import AdjointState
19
+ from .events import EventBus, EventType
20
+
21
+
22
+ class ReconstructionEngine:
23
+ def __init__(self, event_bus: Optional[EventBus] = None, compute_dtype: Optional[torch.dtype] = None):
24
+ self.event_bus = event_bus or EventBus()
25
+ self.compute_dtype = compute_dtype
26
+
27
+ def reconstruct(
28
+ self,
29
+ block: nn.Module,
30
+ input_activation: torch.Tensor,
31
+ adjoint: AdjointState,
32
+ block_name: str = "",
33
+ ) -> Tuple[AdjointState, Dict[nn.Parameter, torch.Tensor]]:
34
+ """Rebuild `block`'s local graph from `input_activation`, run the
35
+ forward again with autograd enabled, and back-propagate `adjoint`
36
+ through it using local VJP only.
37
+
38
+ Returns:
39
+ (new_adjoint_for_block_input, {param: grad_tensor})
40
+ """
41
+ compute_dtype = self.compute_dtype or input_activation.dtype
42
+
43
+ x = input_activation.detach().to(compute_dtype).clone().requires_grad_(True)
44
+
45
+ with torch.enable_grad():
46
+ out = block(x)
47
+
48
+ params = [p for p in block.parameters() if p.requires_grad]
49
+
50
+ grad_outputs = adjoint.tensor.to(device=out.device, dtype=out.dtype)
51
+ if grad_outputs.shape != out.shape:
52
+ raise RuntimeError(
53
+ f"Adjoint shape {tuple(grad_outputs.shape)} does not match "
54
+ f"block output shape {tuple(out.shape)} for block '{block_name}'"
55
+ )
56
+
57
+ inputs: List[torch.Tensor] = [x, *params]
58
+ grads = torch.autograd.grad(
59
+ outputs=out,
60
+ inputs=inputs,
61
+ grad_outputs=grad_outputs,
62
+ retain_graph=False,
63
+ allow_unused=True,
64
+ )
65
+ grad_x, param_grads_list = grads[0], grads[1:]
66
+
67
+ if grad_x is None:
68
+ grad_x = torch.zeros_like(x)
69
+
70
+ param_grads = {p: g for p, g in zip(params, param_grads_list) if g is not None}
71
+
72
+ new_adjoint = AdjointState(
73
+ tensor=grad_x.detach(),
74
+ layer_id=adjoint.layer_id - 1,
75
+ block=block_name,
76
+ metadata=dict(adjoint.metadata),
77
+ )
78
+ new_adjoint.record(f"reconstruct:{block_name}")
79
+
80
+ self.event_bus.emit(
81
+ EventType.BLOCK_RECONSTRUCTED,
82
+ block=block_name,
83
+ layer_id=adjoint.layer_id,
84
+ grad_norm=float(torch.linalg.vector_norm(grad_x.float()).item()),
85
+ )
86
+
87
+ # free the local graph explicitly
88
+ del out
89
+ return new_adjoint, param_grads
eae_runtime/runtime.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ EAERuntime: the top-level object users instantiate. Ties together the
3
+ Forward Executor, Boundary Store, Reverse Scheduler, Reconstruction Engine,
4
+ Memory Manager, Adjoint Pipeline, Backend Manager, Event Bus and Profiler.
5
+
6
+ runtime = EAERuntime(model, optimizer, config)
7
+ runtime.train_step(batch)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Callable, Dict, List, Optional, Union
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+
17
+ from .adjoint import AdjointState
18
+ from .backend import BackendManager
19
+ from .blocks import BlockDecomposer
20
+ from .boundary_store import BoundaryStore
21
+ from .config import RuntimeConfig
22
+ from .events import EventBus, EventType
23
+ from .forward_executor import ForwardExecutor
24
+ from .logging_utils import attach_structured_logging
25
+ from .memory import MemoryManager
26
+ from .pipeline import AdjointPipeline
27
+ from .profiler import Profiler
28
+ from .reconstruction import ReconstructionEngine
29
+ from .schedulers import ReverseContext, build_scheduler
30
+
31
+
32
+ class EAERuntime:
33
+ def __init__(
34
+ self,
35
+ model: Union[nn.Sequential, List[nn.Module], nn.Module],
36
+ optimizer: Optional[torch.optim.Optimizer] = None,
37
+ config: Optional[RuntimeConfig] = None,
38
+ ):
39
+ self.config = config or RuntimeConfig()
40
+ self.optimizer = optimizer
41
+
42
+ self.blocks: List[nn.Module] = BlockDecomposer.decompose(model)
43
+ self.block_names: List[str] = [
44
+ getattr(b, "eae_name", None) or f"{type(b).__name__}_{i}" for i, b in enumerate(self.blocks)
45
+ ]
46
+
47
+ self.event_bus = EventBus()
48
+ if self.config.log_level:
49
+ attach_structured_logging(self.event_bus, level=self.config.log_level)
50
+
51
+ self.profiler = Profiler(enabled=self.config.enable_profiler)
52
+
53
+ self.backend_manager = (
54
+ self.config.backend if isinstance(self.config.backend, BackendManager) else BackendManager(self.config.backend)
55
+ )
56
+
57
+ self.memory_manager = MemoryManager(policy=self.config.memory, event_bus=self.event_bus)
58
+
59
+ self.boundary_store = BoundaryStore(
60
+ offload=self.config.boundary_offload,
61
+ precision=self.config.boundary_precision,
62
+ pin_memory=self.config.pin_memory,
63
+ )
64
+
65
+ self.forward_executor = ForwardExecutor(event_bus=self.event_bus, compute_dtype=self.config.compute_dtype)
66
+ self.reconstruction_engine = ReconstructionEngine(event_bus=self.event_bus, compute_dtype=self.config.compute_dtype)
67
+
68
+ passes = list(self.config.passes)
69
+ self.pipeline = AdjointPipeline(passes=passes, event_bus=self.event_bus, profiler=self.profiler)
70
+
71
+ self.scheduler = build_scheduler(self.config.scheduler, num_microbatches=self.config.num_microbatches)
72
+
73
+ self.last_loss: Optional[float] = None
74
+ self.last_grad_stats: Dict[str, float] = {}
75
+
76
+ # ------------------------------------------------------------------ #
77
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
78
+ """Run the detached forward pass, populating the boundary store, and
79
+ return the (detached) model output."""
80
+ self.boundary_store.clear()
81
+ with self.profiler.track("forward"):
82
+ out = self.forward_executor.run(self.blocks, x, self.boundary_store)
83
+ return out
84
+
85
+ def backward(self, output: torch.Tensor, loss_fn: Callable[[torch.Tensor], torch.Tensor]):
86
+ """Given the detached forward output, compute the loss (with a fresh
87
+ leaf tensor so we can extract dLoss/dOutput via ordinary autograd),
88
+ then run the reverse scheduler through the whole block stack.
89
+
90
+ Returns (loss_tensor, param_grads).
91
+ """
92
+ output_leaf = output.detach().clone().requires_grad_(True)
93
+ with torch.enable_grad():
94
+ loss = loss_fn(output_leaf)
95
+ loss.backward()
96
+
97
+ if output_leaf.grad is None:
98
+ raise RuntimeError(
99
+ "loss_fn produced no gradient w.r.t. the model output; make "
100
+ "sure it actually depends on its input tensor."
101
+ )
102
+
103
+ initial_adjoint = AdjointState(
104
+ tensor=output_leaf.grad.detach(),
105
+ layer_id=len(self.blocks),
106
+ block="output",
107
+ )
108
+ initial_adjoint = self.pipeline.run(initial_adjoint, context={"block_index": len(self.blocks), "block_name": "output"})
109
+
110
+ context = ReverseContext(
111
+ blocks=self.blocks,
112
+ block_names=self.block_names,
113
+ boundary_store=self.boundary_store,
114
+ reconstruction_engine=self.reconstruction_engine,
115
+ pipeline=self.pipeline,
116
+ memory_manager=self.memory_manager,
117
+ event_bus=self.event_bus,
118
+ profiler=self.profiler,
119
+ initial_adjoint=initial_adjoint,
120
+ )
121
+
122
+ with self.profiler.track("reverse_pass"):
123
+ param_grads = self.scheduler.run(context)
124
+
125
+ if self.config.grad_clip_norm is not None:
126
+ squared_norms = [(g.float() ** 2).sum() for g in param_grads.values()]
127
+ total_norm = torch.stack(squared_norms).sum().sqrt() if squared_norms else torch.tensor(0.0)
128
+ if total_norm > self.config.grad_clip_norm:
129
+ scale = self.config.grad_clip_norm / (total_norm + 1e-6)
130
+ param_grads = {p: g * scale for p, g in param_grads.items()}
131
+
132
+ return loss.detach(), param_grads
133
+
134
+ def apply_gradients(self, param_grads: Dict[nn.Parameter, torch.Tensor]) -> None:
135
+ for p, g in param_grads.items():
136
+ if p.grad is None:
137
+ p.grad = g.clone()
138
+ else:
139
+ p.grad = p.grad + g
140
+
141
+ def zero_grad(self) -> None:
142
+ for block in self.blocks:
143
+ for p in block.parameters():
144
+ p.grad = None
145
+
146
+ def train_step(self, x: torch.Tensor, loss_fn: Callable[[torch.Tensor], torch.Tensor]) -> float:
147
+ self.event_bus.emit(EventType.TRAIN_STEP_STARTED)
148
+ self.zero_grad()
149
+
150
+ out = self.forward(x)
151
+ loss, param_grads = self.backward(out, loss_fn)
152
+ self.apply_gradients(param_grads)
153
+
154
+ if self.optimizer is not None:
155
+ self.optimizer.step()
156
+ self.optimizer.zero_grad(set_to_none=True)
157
+
158
+ self.last_loss = loss.item()
159
+ total_grad_norm = 0.0
160
+ if param_grads:
161
+ squared_norms = [(g.float() ** 2).sum() for g in param_grads.values()]
162
+ total_grad_norm = float(torch.stack(squared_norms).sum().sqrt().item())
163
+ self.last_grad_stats = {
164
+ "num_params_with_grad": len(param_grads),
165
+ "total_grad_norm": total_grad_norm,
166
+ }
167
+ self.event_bus.emit(EventType.TRAIN_STEP_FINISHED, loss=self.last_loss, **self.last_grad_stats)
168
+ return self.last_loss
169
+
170
+ # convenience for tests / advanced users needing raw grads without an
171
+ # optimizer step:
172
+ def compute_gradients(self, x: torch.Tensor, loss_fn: Callable[[torch.Tensor], torch.Tensor]):
173
+ self.zero_grad()
174
+ out = self.forward(x)
175
+ loss, param_grads = self.backward(out, loss_fn)
176
+ return loss, param_grads
177
+
178
+ def parameters(self):
179
+ for block in self.blocks:
180
+ yield from block.parameters()
@@ -0,0 +1,38 @@
1
+ from .base import BaseScheduler, ReverseContext
2
+ from .sequential import SequentialScheduler
3
+ from .async_scheduler import AsyncScheduler
4
+ from .pipeline_scheduler import PipelineScheduler
5
+ from .distributed import DistributedScheduler
6
+
7
+ _REGISTRY = {
8
+ "sequential": SequentialScheduler,
9
+ "async": AsyncScheduler,
10
+ "pipeline": PipelineScheduler,
11
+ "distributed": DistributedScheduler,
12
+ }
13
+
14
+
15
+ def build_scheduler(spec, **kwargs) -> BaseScheduler:
16
+ if isinstance(spec, BaseScheduler):
17
+ return spec
18
+ if isinstance(spec, str):
19
+ if spec not in _REGISTRY:
20
+ raise ValueError(f"Unknown scheduler '{spec}', choices: {list(_REGISTRY)}")
21
+ cls = _REGISTRY[spec]
22
+ if spec == "pipeline":
23
+ return cls(num_microbatches=kwargs.get("num_microbatches", 1))
24
+ return cls()
25
+ if isinstance(spec, type) and issubclass(spec, BaseScheduler):
26
+ return spec()
27
+ raise TypeError(f"Cannot build scheduler from {spec!r}")
28
+
29
+
30
+ __all__ = [
31
+ "BaseScheduler",
32
+ "ReverseContext",
33
+ "SequentialScheduler",
34
+ "AsyncScheduler",
35
+ "PipelineScheduler",
36
+ "DistributedScheduler",
37
+ "build_scheduler",
38
+ ]
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ from typing import Dict
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+
9
+ from ..events import EventType
10
+ from .base import BaseScheduler, ReverseContext
11
+
12
+
13
+ class AsyncScheduler(BaseScheduler):
14
+ """Same numerical result as SequentialScheduler, but overlaps the (I/O
15
+ bound) boundary-activation fetch for block i-1 with the (compute bound)
16
+ reconstruction of block i using a background thread. This matters most
17
+ when the BoundaryStore is CPU-offloaded and the device is CUDA.
18
+
19
+ The reverse dependency chain (adjoint_{i-1} depends on adjoint_i) is
20
+ inherently sequential, so only the *fetch* is prefetched - the VJP
21
+ compute itself still runs in strict order, guaranteeing identical
22
+ results to SequentialScheduler.
23
+ """
24
+
25
+ name = "AsyncScheduler"
26
+
27
+ def __init__(self, max_workers: int = 2):
28
+ self.max_workers = max_workers
29
+
30
+ def run(self, context: ReverseContext) -> Dict[nn.Parameter, torch.Tensor]:
31
+ num_blocks = len(context.blocks)
32
+ order = list(reversed(range(num_blocks)))
33
+ adjoint = context.initial_adjoint
34
+ param_grads: Dict[nn.Parameter, torch.Tensor] = {}
35
+
36
+ if not order:
37
+ return param_grads
38
+
39
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
40
+ def fetch(i):
41
+ return context.boundary_store.get(i)
42
+
43
+ pending = {order[0]: executor.submit(fetch, order[0])}
44
+
45
+ for pos, i in enumerate(order):
46
+ input_activation = pending.pop(i).result()
47
+
48
+ if pos + 1 < len(order):
49
+ nxt = order[pos + 1]
50
+ pending[nxt] = executor.submit(fetch, nxt)
51
+
52
+ block = context.blocks[i]
53
+ name = context.block_names[i]
54
+ with context.profiler.track(f"reconstruct:{name}"):
55
+ new_adjoint, updates = context.reconstruction_engine.reconstruct(
56
+ block, input_activation, adjoint, block_name=name
57
+ )
58
+ new_adjoint = context.pipeline.run(new_adjoint, context={"block_index": i, "block_name": name})
59
+ context.memory_manager.release(input_activation)
60
+ context.event_bus.emit(EventType.SCHEDULER_STEP, block_index=i, block_name=name, async_prefetch=True)
61
+
62
+ adjoint = new_adjoint
63
+ self._accumulate(param_grads, updates)
64
+
65
+ return param_grads