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,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
eae_runtime.contrib.transformer_blocks
|
|
3
|
+
=======================================
|
|
4
|
+
|
|
5
|
+
Reference, current-generation (2024-2026 era) Transformer building blocks
|
|
6
|
+
that are known-good EAE Runtime blocks: each `nn.Module` here has a
|
|
7
|
+
`forward(x) -> Tensor` that is a pure function of `x` and its own
|
|
8
|
+
parameters/buffers - exactly the contract `ReconstructionEngine` relies on
|
|
9
|
+
(see `eae_runtime.blocks.EAEBlock`).
|
|
10
|
+
|
|
11
|
+
These are deliberately **not** shipped as part of the runtime core. The
|
|
12
|
+
runtime is architecture-agnostic by design (see README "Non-goals"); this
|
|
13
|
+
module exists to:
|
|
14
|
+
|
|
15
|
+
1. Prove, concretely, that the runtime handles modern Transformer
|
|
16
|
+
internals: RMSNorm, rotary position embeddings (RoPE), grouped-query
|
|
17
|
+
attention (GQA) via `F.scaled_dot_product_attention` (which
|
|
18
|
+
transparently dispatches to a fused flash-attention / memory-
|
|
19
|
+
efficient kernel on supported hardware), and SwiGLU feed-forward
|
|
20
|
+
layers - the components of e.g. LLaMA / Mistral / Qwen-style models.
|
|
21
|
+
2. Demonstrate the idiomatic pattern for handling information a block
|
|
22
|
+
needs besides `x` (a causal mask, RoPE tables) *without* breaking the
|
|
23
|
+
runtime's single-tensor forward contract: precompute it once in
|
|
24
|
+
`__init__` / `register_buffer`, never as an extra forward() argument.
|
|
25
|
+
3. Give you a working, forkable starting point. Copy this file into your
|
|
26
|
+
own project and edit it; the runtime core never needs to change to
|
|
27
|
+
support your architecture.
|
|
28
|
+
|
|
29
|
+
Usage::
|
|
30
|
+
|
|
31
|
+
from eae_runtime.contrib import PreNormTransformerBlock
|
|
32
|
+
|
|
33
|
+
blocks = nn.ModuleList([
|
|
34
|
+
PreNormTransformerBlock(dim=512, num_heads=8, num_kv_heads=2)
|
|
35
|
+
for _ in range(depth)
|
|
36
|
+
])
|
|
37
|
+
runtime = EAERuntime(blocks, optimizer, config)
|
|
38
|
+
|
|
39
|
+
Deliberately not covered here (fork and extend instead):
|
|
40
|
+
* Cross-attention / encoder-decoder blocks - attention over a second,
|
|
41
|
+
externally supplied sequence isn't expressible as a pure function of a
|
|
42
|
+
single `x`. Attach the memory tensor as an instance attribute the same
|
|
43
|
+
way RoPE tables are attached below, or override `forward` to unpack a
|
|
44
|
+
tensor you've concatenated/stacked upstream.
|
|
45
|
+
* KV-cache incremental decoding - this reference targets full-sequence
|
|
46
|
+
training; inference-time caching belongs in the caller's generation
|
|
47
|
+
loop, not the runtime.
|
|
48
|
+
* Mixture-of-Experts routing.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
from typing import Optional, Tuple
|
|
54
|
+
|
|
55
|
+
import torch
|
|
56
|
+
import torch.nn as nn
|
|
57
|
+
import torch.nn.functional as F
|
|
58
|
+
|
|
59
|
+
from ..blocks import EAEBlock
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"RMSNorm",
|
|
63
|
+
"RotaryPositionalEmbedding",
|
|
64
|
+
"apply_rotary",
|
|
65
|
+
"CausalSelfAttention",
|
|
66
|
+
"SwiGLU",
|
|
67
|
+
"PreNormTransformerBlock",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RMSNorm(nn.Module):
|
|
72
|
+
"""Root-Mean-Square LayerNorm (Zhang & Sennrich, 2019).
|
|
73
|
+
|
|
74
|
+
The default normalization in LLaMA / Mistral / Gemma-style models:
|
|
75
|
+
cheaper than LayerNorm (no mean-centering, no bias term) and matches
|
|
76
|
+
or beats it empirically at scale.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, dim: int, eps: float = 1e-6):
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.eps = eps
|
|
82
|
+
self.weight = nn.Parameter(torch.ones(dim))
|
|
83
|
+
|
|
84
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
85
|
+
dtype = x.dtype
|
|
86
|
+
xf = x.float()
|
|
87
|
+
rms = torch.rsqrt(xf.pow(2).mean(dim=-1, keepdim=True) + self.eps)
|
|
88
|
+
return (xf * rms).to(dtype) * self.weight
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RotaryPositionalEmbedding(nn.Module):
|
|
92
|
+
"""Rotary position embeddings (Su et al., 2021 / RoFormer) - the de
|
|
93
|
+
facto standard positional scheme in modern decoder-only Transformers.
|
|
94
|
+
|
|
95
|
+
Precomputes cos/sin tables as non-persistent buffers (so they move
|
|
96
|
+
with `.to(device)` but are never saved to a checkpoint or trained) up
|
|
97
|
+
to `max_seq_len`; `forward(seq_len)` slices out the prefix needed for
|
|
98
|
+
the current sequence.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
cos_cached: torch.Tensor
|
|
102
|
+
sin_cached: torch.Tensor
|
|
103
|
+
|
|
104
|
+
def __init__(self, head_dim: int, max_seq_len: int = 4096, base: float = 10000.0):
|
|
105
|
+
super().__init__()
|
|
106
|
+
if head_dim % 2 != 0:
|
|
107
|
+
raise ValueError(f"RoPE requires an even head_dim, got {head_dim}")
|
|
108
|
+
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))
|
|
109
|
+
t = torch.arange(max_seq_len).float()
|
|
110
|
+
freqs = torch.outer(t, inv_freq) # (max_seq_len, head_dim // 2)
|
|
111
|
+
self.register_buffer("cos_cached", freqs.cos(), persistent=False)
|
|
112
|
+
self.register_buffer("sin_cached", freqs.sin(), persistent=False)
|
|
113
|
+
|
|
114
|
+
def forward(self, seq_len: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
115
|
+
return self.cos_cached[:seq_len], self.sin_cached[:seq_len]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
|
119
|
+
"""Apply RoPE rotation to `x` of shape (batch, heads, seq, head_dim)
|
|
120
|
+
given per-position `cos`/`sin` tables of shape (seq, head_dim // 2)."""
|
|
121
|
+
x1, x2 = x[..., 0::2], x[..., 1::2]
|
|
122
|
+
cos = cos[None, None, :, :].to(x.dtype)
|
|
123
|
+
sin = sin[None, None, :, :].to(x.dtype)
|
|
124
|
+
rotated = torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
|
|
125
|
+
return rotated.flatten(-2)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CausalSelfAttention(nn.Module):
|
|
129
|
+
"""Causal (optionally grouped-query) self-attention built on
|
|
130
|
+
`torch.nn.functional.scaled_dot_product_attention`, which dispatches
|
|
131
|
+
to a fused flash-attention / memory-efficient kernel on supported
|
|
132
|
+
hardware with no extra code here - the current recommended way to
|
|
133
|
+
write attention in PyTorch (2.x+), rather than a hand-rolled
|
|
134
|
+
`softmax(QK^T / sqrt(d)) @ V`.
|
|
135
|
+
|
|
136
|
+
Set `num_kv_heads < num_heads` for grouped-query attention (GQA, as in
|
|
137
|
+
LLaMA-2 70B / Mistral) to shrink the KV cache at inference time;
|
|
138
|
+
`num_kv_heads=1` is multi-query attention (MQA). Leave it as `None`
|
|
139
|
+
for ordinary multi-head attention.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
dim: int,
|
|
145
|
+
num_heads: int,
|
|
146
|
+
num_kv_heads: Optional[int] = None,
|
|
147
|
+
max_seq_len: int = 4096,
|
|
148
|
+
dropout: float = 0.0,
|
|
149
|
+
rope: bool = True,
|
|
150
|
+
):
|
|
151
|
+
super().__init__()
|
|
152
|
+
if dim % num_heads != 0:
|
|
153
|
+
raise ValueError(f"dim ({dim}) must be divisible by num_heads ({num_heads})")
|
|
154
|
+
self.num_heads = num_heads
|
|
155
|
+
self.num_kv_heads = num_kv_heads or num_heads
|
|
156
|
+
if num_heads % self.num_kv_heads != 0:
|
|
157
|
+
raise ValueError("num_heads must be divisible by num_kv_heads for GQA")
|
|
158
|
+
self.head_dim = dim // num_heads
|
|
159
|
+
self.dropout = dropout
|
|
160
|
+
|
|
161
|
+
self.q_proj = nn.Linear(dim, num_heads * self.head_dim, bias=False)
|
|
162
|
+
self.k_proj = nn.Linear(dim, self.num_kv_heads * self.head_dim, bias=False)
|
|
163
|
+
self.v_proj = nn.Linear(dim, self.num_kv_heads * self.head_dim, bias=False)
|
|
164
|
+
self.out_proj = nn.Linear(num_heads * self.head_dim, dim, bias=False)
|
|
165
|
+
|
|
166
|
+
self.rope = RotaryPositionalEmbedding(self.head_dim, max_seq_len) if rope else None
|
|
167
|
+
|
|
168
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
169
|
+
batch, seq, _ = x.shape
|
|
170
|
+
q = self.q_proj(x).view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2)
|
|
171
|
+
k = self.k_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
|
172
|
+
v = self.v_proj(x).view(batch, seq, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
|
173
|
+
|
|
174
|
+
if self.rope is not None:
|
|
175
|
+
cos, sin = self.rope(seq)
|
|
176
|
+
q = apply_rotary(q, cos, sin)
|
|
177
|
+
k = apply_rotary(k, cos, sin)
|
|
178
|
+
|
|
179
|
+
if self.num_kv_heads != self.num_heads:
|
|
180
|
+
repeat = self.num_heads // self.num_kv_heads
|
|
181
|
+
k = k.repeat_interleave(repeat, dim=1)
|
|
182
|
+
v = v.repeat_interleave(repeat, dim=1)
|
|
183
|
+
|
|
184
|
+
out = F.scaled_dot_product_attention(
|
|
185
|
+
q, k, v, is_causal=True, dropout_p=self.dropout if self.training else 0.0
|
|
186
|
+
)
|
|
187
|
+
out = out.transpose(1, 2).contiguous().view(batch, seq, self.num_heads * self.head_dim)
|
|
188
|
+
return self.out_proj(out)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SwiGLU(nn.Module):
|
|
192
|
+
"""SwiGLU feed-forward (Shazeer, 2020) - the standard modern
|
|
193
|
+
replacement for a plain Linear -> GELU -> Linear FFN, used in
|
|
194
|
+
LLaMA / PaLM / Mistral. `hidden_dim` defaults to ~8/3 * dim rounded up
|
|
195
|
+
to a multiple of `multiple_of` for hardware-friendly matmul shapes.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, dim: int, hidden_dim: Optional[int] = None, multiple_of: int = 256):
|
|
199
|
+
super().__init__()
|
|
200
|
+
if hidden_dim is None:
|
|
201
|
+
hidden_dim = int(2 * (4 * dim) / 3)
|
|
202
|
+
hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)
|
|
203
|
+
self.gate_proj = nn.Linear(dim, hidden_dim, bias=False)
|
|
204
|
+
self.down_proj = nn.Linear(hidden_dim, dim, bias=False)
|
|
205
|
+
self.up_proj = nn.Linear(dim, hidden_dim, bias=False)
|
|
206
|
+
|
|
207
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
208
|
+
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PreNormTransformerBlock(EAEBlock):
|
|
212
|
+
"""One decoder-only Transformer block in the current dominant
|
|
213
|
+
architecture family (LLaMA/Mistral/Qwen-style): pre-norm residual
|
|
214
|
+
stream, RMSNorm, causal (optionally grouped-query) attention with
|
|
215
|
+
RoPE, RMSNorm, SwiGLU feed-forward.
|
|
216
|
+
|
|
217
|
+
Stack `depth` of these in an `nn.ModuleList` and hand that straight to
|
|
218
|
+
`EAERuntime` - `BlockDecomposer` treats each block as one
|
|
219
|
+
reconstructable unit::
|
|
220
|
+
|
|
221
|
+
blocks = nn.ModuleList([
|
|
222
|
+
PreNormTransformerBlock(dim=512, num_heads=8, num_kv_heads=2)
|
|
223
|
+
for _ in range(depth)
|
|
224
|
+
])
|
|
225
|
+
runtime = EAERuntime(blocks, optimizer, config)
|
|
226
|
+
|
|
227
|
+
Every sub-module here (`RMSNorm`, `CausalSelfAttention`, `SwiGLU`) is
|
|
228
|
+
independently reusable if your architecture only needs one piece, and
|
|
229
|
+
every EAE pass (`ClipPass`, `Int8QuantizationPass`, a custom
|
|
230
|
+
compression/synthetic-gradient pass, ...) applies to the adjoint
|
|
231
|
+
flowing out of this block exactly as it would for a plain `nn.Linear`.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
dim: int,
|
|
237
|
+
num_heads: int,
|
|
238
|
+
num_kv_heads: Optional[int] = None,
|
|
239
|
+
max_seq_len: int = 4096,
|
|
240
|
+
ffn_hidden_dim: Optional[int] = None,
|
|
241
|
+
dropout: float = 0.0,
|
|
242
|
+
norm_eps: float = 1e-6,
|
|
243
|
+
):
|
|
244
|
+
super().__init__()
|
|
245
|
+
self.attn_norm = RMSNorm(dim, eps=norm_eps)
|
|
246
|
+
self.attn = CausalSelfAttention(
|
|
247
|
+
dim,
|
|
248
|
+
num_heads,
|
|
249
|
+
num_kv_heads=num_kv_heads,
|
|
250
|
+
max_seq_len=max_seq_len,
|
|
251
|
+
dropout=dropout,
|
|
252
|
+
)
|
|
253
|
+
self.ffn_norm = RMSNorm(dim, eps=norm_eps)
|
|
254
|
+
self.ffn = SwiGLU(dim, hidden_dim=ffn_hidden_dim)
|
|
255
|
+
|
|
256
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
257
|
+
x = x + self.attn(self.attn_norm(x))
|
|
258
|
+
x = x + self.ffn(self.ffn_norm(x))
|
|
259
|
+
return x
|
eae_runtime/events.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event system: everything important that happens inside the runtime emits an
|
|
3
|
+
Event. Researchers should never need to edit runtime code just to observe
|
|
4
|
+
execution - they subscribe to the EventBus instead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventType:
|
|
15
|
+
FORWARD_STARTED = "ForwardStarted"
|
|
16
|
+
FORWARD_FINISHED = "ForwardFinished"
|
|
17
|
+
BLOCK_RECONSTRUCTED = "BlockReconstructed"
|
|
18
|
+
ADJOINT_CREATED = "AdjointCreated"
|
|
19
|
+
ADJOINT_MODIFIED = "AdjointModified"
|
|
20
|
+
MEMORY_ALLOCATED = "MemoryAllocated"
|
|
21
|
+
MEMORY_RELEASED = "MemoryReleased"
|
|
22
|
+
SCHEDULER_STEP = "SchedulerStep"
|
|
23
|
+
KERNEL_LAUNCH = "KernelLaunch"
|
|
24
|
+
COMMUNICATION_START = "CommunicationStart"
|
|
25
|
+
COMMUNICATION_FINISH = "CommunicationFinish"
|
|
26
|
+
TRAIN_STEP_STARTED = "TrainStepStarted"
|
|
27
|
+
TRAIN_STEP_FINISHED = "TrainStepFinished"
|
|
28
|
+
PASS_APPLIED = "PassApplied"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Event:
|
|
33
|
+
type: str
|
|
34
|
+
payload: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
timestamp: float = field(default_factory=time.time)
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
38
|
+
return f"Event({self.type}, {self.payload})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EventBus:
|
|
42
|
+
"""A minimal, synchronous pub/sub bus.
|
|
43
|
+
|
|
44
|
+
Subscribers are plain callables `fn(event: Event) -> None`. Subscribing
|
|
45
|
+
to `"*"` receives every event, regardless of type.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._subscribers: Dict[str, List[Callable[[Event], None]]] = {}
|
|
50
|
+
self._log: List[Event] = []
|
|
51
|
+
self._recording = False
|
|
52
|
+
|
|
53
|
+
def subscribe(self, event_type: str, callback: Callable[[Event], None]) -> None:
|
|
54
|
+
self._subscribers.setdefault(event_type, []).append(callback)
|
|
55
|
+
|
|
56
|
+
def unsubscribe(self, event_type: str, callback: Callable[[Event], None]) -> None:
|
|
57
|
+
if event_type in self._subscribers and callback in self._subscribers[event_type]:
|
|
58
|
+
self._subscribers[event_type].remove(callback)
|
|
59
|
+
|
|
60
|
+
def emit(self, event_type: str, **payload: Any) -> Event:
|
|
61
|
+
event = Event(type=event_type, payload=payload)
|
|
62
|
+
if self._recording:
|
|
63
|
+
self._log.append(event)
|
|
64
|
+
for callback in self._subscribers.get(event_type, []):
|
|
65
|
+
callback(event)
|
|
66
|
+
for callback in self._subscribers.get("*", []):
|
|
67
|
+
callback(event)
|
|
68
|
+
return event
|
|
69
|
+
|
|
70
|
+
# -- recording / introspection, handy for tests -------------------- #
|
|
71
|
+
def start_recording(self) -> None:
|
|
72
|
+
self._recording = True
|
|
73
|
+
self._log = []
|
|
74
|
+
|
|
75
|
+
def stop_recording(self) -> List[Event]:
|
|
76
|
+
self._recording = False
|
|
77
|
+
return self._log
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def log(self) -> List[Event]:
|
|
81
|
+
return list(self._log)
|
|
82
|
+
|
|
83
|
+
def events_of_type(self, event_type: str) -> List[Event]:
|
|
84
|
+
return [e for e in self._log if e.type == event_type]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forward Executor: detached forward, boundary extraction, boundary storage.
|
|
3
|
+
No autograd graph is kept alive - this is what makes the runtime
|
|
4
|
+
memory-cheap relative to a naive PyTorch backward.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
import torch.nn as nn
|
|
13
|
+
|
|
14
|
+
from .boundary_store import BoundaryStore
|
|
15
|
+
from .events import EventBus, EventType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForwardExecutor:
|
|
19
|
+
def __init__(self, event_bus: Optional[EventBus] = None, compute_dtype: Optional[torch.dtype] = None):
|
|
20
|
+
self.event_bus = event_bus or EventBus()
|
|
21
|
+
self.compute_dtype = compute_dtype
|
|
22
|
+
|
|
23
|
+
@torch.no_grad()
|
|
24
|
+
def run(self, blocks: List[nn.Module], x0: torch.Tensor, boundary_store: BoundaryStore) -> torch.Tensor:
|
|
25
|
+
self.event_bus.emit(EventType.FORWARD_STARTED, num_blocks=len(blocks))
|
|
26
|
+
|
|
27
|
+
x = x0
|
|
28
|
+
boundary_store.put(0, x)
|
|
29
|
+
for i, block in enumerate(blocks):
|
|
30
|
+
compute_x = x.to(self.compute_dtype) if self.compute_dtype is not None else x
|
|
31
|
+
x = block(compute_x)
|
|
32
|
+
boundary_store.put(i + 1, x)
|
|
33
|
+
|
|
34
|
+
self.event_bus.emit(EventType.FORWARD_FINISHED, num_boundaries=len(boundary_store))
|
|
35
|
+
return x
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging: every runtime event should be observable. This wires
|
|
3
|
+
the EventBus to Python's logging module using a JSON-ish structured
|
|
4
|
+
formatter, so events can be piped to any log aggregator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .events import Event, EventBus
|
|
14
|
+
|
|
15
|
+
_LOGGER_NAME = "eae_runtime"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_logger(level: str = "WARNING") -> logging.Logger:
|
|
19
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
20
|
+
if not logger.handlers:
|
|
21
|
+
handler = logging.StreamHandler()
|
|
22
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
logger.setLevel(getattr(logging, level.upper(), logging.WARNING))
|
|
25
|
+
return logger
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def attach_structured_logging(event_bus: EventBus, level: str = "WARNING", logger: Optional[logging.Logger] = None) -> None:
|
|
29
|
+
"""Subscribe a structured JSON logger to every event on `event_bus`.
|
|
30
|
+
|
|
31
|
+
`level` sets the logger's threshold (e.g. "WARNING" hides events by
|
|
32
|
+
default). Individual event records are always emitted at DEBUG
|
|
33
|
+
severity, so setting `level="DEBUG"` is what makes them visible.
|
|
34
|
+
"""
|
|
35
|
+
logger = logger or get_logger(level)
|
|
36
|
+
|
|
37
|
+
def _log(event: Event) -> None:
|
|
38
|
+
record = {"event": event.type, "timestamp": event.timestamp, **event.payload}
|
|
39
|
+
logger.debug(json.dumps(record, default=str))
|
|
40
|
+
|
|
41
|
+
event_bus.subscribe("*", _log)
|
eae_runtime/memory.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory Manager: owns temporary tensors, scratch buffers, reconstruction
|
|
3
|
+
workspace. Provides request()/release()/reuse() and exposes runtime
|
|
4
|
+
statistics. The allocator should minimize allocations, fragmentation and
|
|
5
|
+
synchronization.
|
|
6
|
+
|
|
7
|
+
Two policies are provided out of the box:
|
|
8
|
+
* PoolMemoryPolicy - buckets free tensors by (shape, dtype, device) and
|
|
9
|
+
reuses them, avoiding repeated cudaMalloc/alloc churn.
|
|
10
|
+
* NullMemoryPolicy - always allocates fresh / frees immediately; useful
|
|
11
|
+
as a correctness baseline and for leak tests.
|
|
12
|
+
|
|
13
|
+
Users can implement their own MemoryPolicy (Memory Policy API) without
|
|
14
|
+
touching reconstruction logic.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Dict, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
|
|
24
|
+
from .events import EventBus, EventType
|
|
25
|
+
|
|
26
|
+
PoolKey = Tuple[Tuple[int, ...], torch.dtype, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryPolicy:
|
|
30
|
+
"""Base class for pluggable memory policies."""
|
|
31
|
+
|
|
32
|
+
def allocate(self, shape, dtype, device) -> torch.Tensor:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def free(self, tensor: torch.Tensor) -> None:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def stats(self) -> Dict[str, int]:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def reset(self) -> None:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NullMemoryPolicy(MemoryPolicy):
|
|
46
|
+
"""No pooling: every request is a fresh allocation, every release is a
|
|
47
|
+
real deallocation (subject to Python/CUDA garbage collection)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self._active = 0
|
|
51
|
+
self._peak = 0
|
|
52
|
+
self._allocations = 0
|
|
53
|
+
|
|
54
|
+
def allocate(self, shape, dtype, device) -> torch.Tensor:
|
|
55
|
+
t = torch.empty(shape, dtype=dtype, device=device)
|
|
56
|
+
self._active += 1
|
|
57
|
+
self._allocations += 1
|
|
58
|
+
self._peak = max(self._peak, self._active)
|
|
59
|
+
return t
|
|
60
|
+
|
|
61
|
+
def free(self, tensor: torch.Tensor) -> None:
|
|
62
|
+
self._active = max(0, self._active - 1)
|
|
63
|
+
|
|
64
|
+
def stats(self) -> Dict[str, int]:
|
|
65
|
+
return {"active": self._active, "peak": self._peak, "allocations": self._allocations, "pooled": 0}
|
|
66
|
+
|
|
67
|
+
def reset(self) -> None:
|
|
68
|
+
self._active = 0
|
|
69
|
+
self._peak = 0
|
|
70
|
+
self._allocations = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PoolMemoryPolicy(MemoryPolicy):
|
|
74
|
+
"""Reuses freed tensors of matching (shape, dtype, device) instead of
|
|
75
|
+
reallocating. Thread-safe (relevant for the AsyncScheduler)."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, max_pool_per_key: int = 8):
|
|
78
|
+
self._pool: Dict[PoolKey, list] = {}
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._active = 0
|
|
81
|
+
self._peak = 0
|
|
82
|
+
self._allocations = 0
|
|
83
|
+
self._reuses = 0
|
|
84
|
+
self._max_pool_per_key = max_pool_per_key
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _key(shape, dtype, device) -> PoolKey:
|
|
88
|
+
return (tuple(shape), dtype, str(device))
|
|
89
|
+
|
|
90
|
+
def allocate(self, shape, dtype, device) -> torch.Tensor:
|
|
91
|
+
key = self._key(shape, dtype, device)
|
|
92
|
+
with self._lock:
|
|
93
|
+
bucket = self._pool.get(key)
|
|
94
|
+
if bucket:
|
|
95
|
+
t = bucket.pop()
|
|
96
|
+
self._reuses += 1
|
|
97
|
+
self._active += 1
|
|
98
|
+
self._peak = max(self._peak, self._active)
|
|
99
|
+
return t
|
|
100
|
+
self._allocations += 1
|
|
101
|
+
self._active += 1
|
|
102
|
+
self._peak = max(self._peak, self._active)
|
|
103
|
+
return torch.empty(shape, dtype=dtype, device=device)
|
|
104
|
+
|
|
105
|
+
def free(self, tensor: torch.Tensor) -> None:
|
|
106
|
+
key = self._key(tensor.shape, tensor.dtype, tensor.device)
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._active = max(0, self._active - 1)
|
|
109
|
+
bucket = self._pool.setdefault(key, [])
|
|
110
|
+
if len(bucket) < self._max_pool_per_key:
|
|
111
|
+
bucket.append(tensor.detach())
|
|
112
|
+
|
|
113
|
+
def stats(self) -> Dict[str, int]:
|
|
114
|
+
pooled = sum(len(v) for v in self._pool.values())
|
|
115
|
+
return {
|
|
116
|
+
"active": self._active,
|
|
117
|
+
"peak": self._peak,
|
|
118
|
+
"allocations": self._allocations,
|
|
119
|
+
"reuses": self._reuses,
|
|
120
|
+
"pooled": pooled,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def reset(self) -> None:
|
|
124
|
+
with self._lock:
|
|
125
|
+
self._pool.clear()
|
|
126
|
+
self._active = 0
|
|
127
|
+
self._peak = 0
|
|
128
|
+
self._allocations = 0
|
|
129
|
+
self._reuses = 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_POLICIES = {
|
|
133
|
+
"pool": PoolMemoryPolicy,
|
|
134
|
+
"none": NullMemoryPolicy,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MemoryManager:
|
|
139
|
+
"""Facade used by the rest of the runtime. Wraps a MemoryPolicy and adds
|
|
140
|
+
event emission + a scratch-buffer convenience API."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, policy="pool", event_bus: Optional[EventBus] = None):
|
|
143
|
+
if isinstance(policy, str):
|
|
144
|
+
if policy not in _POLICIES:
|
|
145
|
+
raise ValueError(f"Unknown memory policy '{policy}', choices: {list(_POLICIES)}")
|
|
146
|
+
policy = _POLICIES[policy]()
|
|
147
|
+
elif isinstance(policy, type) and issubclass(policy, MemoryPolicy):
|
|
148
|
+
policy = policy()
|
|
149
|
+
self.policy: MemoryPolicy = policy
|
|
150
|
+
self.event_bus = event_bus or EventBus()
|
|
151
|
+
|
|
152
|
+
def request(self, shape, dtype=torch.float32, device="cpu") -> torch.Tensor:
|
|
153
|
+
t = self.policy.allocate(shape, dtype, device)
|
|
154
|
+
self.event_bus.emit(EventType.MEMORY_ALLOCATED, shape=tuple(shape), dtype=str(dtype), device=str(device))
|
|
155
|
+
return t
|
|
156
|
+
|
|
157
|
+
def release(self, tensor: torch.Tensor) -> None:
|
|
158
|
+
if tensor is None:
|
|
159
|
+
return
|
|
160
|
+
shape = tuple(tensor.shape)
|
|
161
|
+
self.policy.free(tensor)
|
|
162
|
+
self.event_bus.emit(EventType.MEMORY_RELEASED, shape=shape)
|
|
163
|
+
|
|
164
|
+
def reuse(self, tensor: torch.Tensor) -> torch.Tensor:
|
|
165
|
+
"""Zero out and hand back a tensor for reuse without a full
|
|
166
|
+
free+allocate round trip."""
|
|
167
|
+
with torch.no_grad():
|
|
168
|
+
tensor.zero_()
|
|
169
|
+
return tensor
|
|
170
|
+
|
|
171
|
+
def stats(self) -> Dict[str, int]:
|
|
172
|
+
return self.policy.stats()
|
|
173
|
+
|
|
174
|
+
def reset(self) -> None:
|
|
175
|
+
self.policy.reset()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .base import EAEPass
|
|
2
|
+
from .clip import ClipPass
|
|
3
|
+
from .quantize import FP16Pass, FP8Pass, Int8QuantizationPass
|
|
4
|
+
from .synthetic_gradient import SyntheticGradientPass
|
|
5
|
+
from .regularization import RegularizationPass, GaussianNoisePass
|
|
6
|
+
from .logging_pass import LoggingPass
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"EAEPass",
|
|
10
|
+
"ClipPass",
|
|
11
|
+
"FP16Pass",
|
|
12
|
+
"FP8Pass",
|
|
13
|
+
"Int8QuantizationPass",
|
|
14
|
+
"SyntheticGradientPass",
|
|
15
|
+
"RegularizationPass",
|
|
16
|
+
"GaussianNoisePass",
|
|
17
|
+
"LoggingPass",
|
|
18
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pass API: every adjoint passes through Pass1 -> Pass2 -> ... -> Passn before
|
|
3
|
+
reaching the next block. Passes are plugins; the runtime never knows what
|
|
4
|
+
they do.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ..adjoint import AdjointState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EAEPass:
|
|
15
|
+
"""Base class for a single stage in the Adjoint Pipeline.
|
|
16
|
+
|
|
17
|
+
Subclasses implement `process(adjoint, context) -> AdjointState`.
|
|
18
|
+
`context` is a plain dict with useful ambient info (current block name,
|
|
19
|
+
layer_id, training step, event_bus, etc.) that a pass may read but
|
|
20
|
+
should not mutate destructively.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str = "EAEPass"
|
|
24
|
+
|
|
25
|
+
def process(self, adjoint: AdjointState, context: Optional[dict] = None) -> AdjointState:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
def __call__(self, adjoint: AdjointState, context: Optional[dict] = None) -> AdjointState:
|
|
29
|
+
result = self.process(adjoint, context or {})
|
|
30
|
+
result.record(self.name)
|
|
31
|
+
return result
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
from ..adjoint import AdjointState
|
|
6
|
+
from .base import EAEPass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClipPass(EAEPass):
|
|
10
|
+
"""Clips the adjoint tensor's norm to `max_norm`, mirroring
|
|
11
|
+
torch.nn.utils.clip_grad_norm_ but operating on a single adjoint in the
|
|
12
|
+
pipeline rather than a full parameter list."""
|
|
13
|
+
|
|
14
|
+
name = "ClipPass"
|
|
15
|
+
|
|
16
|
+
def __init__(self, max_norm: float = 1.0):
|
|
17
|
+
self.max_norm = max_norm
|
|
18
|
+
|
|
19
|
+
def process(self, adjoint: AdjointState, context=None) -> AdjointState:
|
|
20
|
+
new = adjoint.clone()
|
|
21
|
+
norm = torch.linalg.vector_norm(new.tensor.float())
|
|
22
|
+
if norm > self.max_norm:
|
|
23
|
+
scale = self.max_norm / (norm + 1e-6)
|
|
24
|
+
new.tensor = new.tensor * scale.to(new.tensor.dtype)
|
|
25
|
+
return new
|