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.
- eae_runtime/__init__.py +80 -0
- eae_runtime/_version.py +24 -0
- eae_runtime/adjoint.py +128 -0
- eae_runtime/backend.py +52 -0
- eae_runtime/blocks.py +69 -0
- eae_runtime/boundary_store.py +65 -0
- eae_runtime/config.py +48 -0
- eae_runtime/contrib/__init__.py +25 -0
- eae_runtime/contrib/transformer_blocks.py +259 -0
- eae_runtime/events.py +84 -0
- eae_runtime/forward_executor.py +35 -0
- eae_runtime/logging_utils.py +41 -0
- eae_runtime/memory.py +175 -0
- eae_runtime/passes/__init__.py +18 -0
- eae_runtime/passes/base.py +31 -0
- eae_runtime/passes/clip.py +25 -0
- eae_runtime/passes/logging_pass.py +29 -0
- eae_runtime/passes/quantize.py +55 -0
- eae_runtime/passes/regularization.py +47 -0
- eae_runtime/passes/synthetic_gradient.py +67 -0
- eae_runtime/pipeline.py +58 -0
- eae_runtime/profiler.py +47 -0
- eae_runtime/py.typed +0 -0
- eae_runtime/reconstruction.py +89 -0
- eae_runtime/runtime.py +180 -0
- eae_runtime/schedulers/__init__.py +38 -0
- eae_runtime/schedulers/async_scheduler.py +65 -0
- eae_runtime/schedulers/base.py +87 -0
- eae_runtime/schedulers/distributed.py +78 -0
- eae_runtime/schedulers/pipeline_scheduler.py +67 -0
- eae_runtime/schedulers/sequential.py +27 -0
- eae_runtime-0.1.0.dist-info/METADATA +229 -0
- eae_runtime-0.1.0.dist-info/RECORD +38 -0
- eae_runtime-0.1.0.dist-info/WHEEL +5 -0
- eae_runtime-0.1.0.dist-info/licenses/LICENSE +13 -0
- eae_runtime-0.1.0.dist-info/scm_file_list.json +59 -0
- eae_runtime-0.1.0.dist-info/scm_version.json +8 -0
- 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
|
eae_runtime/pipeline.py
ADDED
|
@@ -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
|
eae_runtime/profiler.py
ADDED
|
@@ -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
|