flashruntime 0.3.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.
- flashml_workloads/__init__.py +7 -0
- flashml_workloads/fedavg_driver.py +569 -0
- flashml_workloads/fedavg_weights.py +223 -0
- flashml_workloads/fedavg_worker.py +166 -0
- flashml_workloads/kmeans_driver.py +134 -0
- flashml_workloads/kmeans_shard.py +69 -0
- flashml_workloads/sgd_trainer.py +127 -0
- flashml_workloads/sharded_kmeans.py +323 -0
- flashml_workloads/sklearn_trial.py +89 -0
- flashruntime/__init__.py +125 -0
- flashruntime/artifacts/__init__.py +25 -0
- flashruntime/artifacts/store.py +228 -0
- flashruntime/backends/__init__.py +26 -0
- flashruntime/backends/base.py +63 -0
- flashruntime/backends/kuberay.py +465 -0
- flashruntime/checkpoint/__init__.py +20 -0
- flashruntime/checkpoint/catalog.py +198 -0
- flashruntime/checkpoint/local.py +109 -0
- flashruntime/checkpoint/store.py +86 -0
- flashruntime/integrations/__init__.py +5 -0
- flashruntime/integrations/huggingface.py +59 -0
- flashruntime/integrations/pytorch.py +52 -0
- flashruntime/integrations/sklearn.py +42 -0
- flashruntime/launchers/__init__.py +130 -0
- flashruntime/launchers/local.py +126 -0
- flashruntime/leases/__init__.py +27 -0
- flashruntime/leases/manager.py +365 -0
- flashruntime/leases/sqlite_store.py +169 -0
- flashruntime/leases/store.py +103 -0
- flashruntime/monitor/__init__.py +7 -0
- flashruntime/monitor/sampler.py +232 -0
- flashruntime/planner/__init__.py +56 -0
- flashruntime/planner/candidates.py +597 -0
- flashruntime/planner/catalog.py +129 -0
- flashruntime/planner/comm.py +95 -0
- flashruntime/planner/explain.py +109 -0
- flashruntime/planner/memory.py +166 -0
- flashruntime/planner/resolve.py +120 -0
- flashruntime/planner/selector.py +169 -0
- flashruntime/planner/timecost.py +81 -0
- flashruntime/profiling/__init__.py +113 -0
- flashruntime/protocol/__init__.py +18 -0
- flashruntime/protocol/plan_v1alpha1.py +320 -0
- flashruntime/protocol/v1alpha1.py +465 -0
- flashruntime/providers/__init__.py +138 -0
- flashruntime/py.typed +0 -0
- flashruntime/recipes/__init__.py +135 -0
- flashruntime/recipes/command.py +166 -0
- flashruntime/recovery/__init__.py +21 -0
- flashruntime/recovery/policy.py +170 -0
- flashruntime/recovery/signals.py +135 -0
- flashruntime/recovery/taxonomy.py +91 -0
- flashruntime/scheduler/__init__.py +170 -0
- flashruntime/sdk.py +402 -0
- flashruntime/service/__init__.py +3 -0
- flashruntime/service/app.py +391 -0
- flashruntime/service/auth.py +180 -0
- flashruntime/service/checkpoints.py +90 -0
- flashruntime/service/cli.py +167 -0
- flashruntime/service/dashboard.py +193 -0
- flashruntime/service/ledger.py +101 -0
- flashruntime/service/modea.py +821 -0
- flashruntime/strategies/__init__.py +156 -0
- flashruntime/strategies/command.py +56 -0
- flashruntime/torch/__init__.py +274 -0
- flashruntime/viewer/__init__.py +20 -0
- flashruntime/viewer/_docs/benchmarks.html +771 -0
- flashruntime/viewer/_docs/concepts/architecture.html +302 -0
- flashruntime/viewer/_docs/get-started.html +263 -0
- flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
- flashruntime/viewer/_docs/guides/huggingface.html +223 -0
- flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
- flashruntime/viewer/_docs/guides/pytorch.html +313 -0
- flashruntime/viewer/_docs/guides/sklearn.html +232 -0
- flashruntime/viewer/_docs/index.html +251 -0
- flashruntime/viewer/_docs/reference/cli.html +254 -0
- flashruntime/viewer/_docs/reference/integrations.html +240 -0
- flashruntime/viewer/_docs/reference/sdk.html +341 -0
- flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
- flashruntime/viewer/_docs/search-index.json +1 -0
- flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
- flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
- flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
- flashruntime/viewer/flowmap.py +307 -0
- flashruntime/viewer/page.py +594 -0
- flashruntime/viewer/server.py +134 -0
- flashruntime/viewer/state.py +250 -0
- flashruntime/workloads/__init__.py +6 -0
- flashruntime/workloads/command.py +127 -0
- flashruntime-0.3.0.dist-info/METADATA +365 -0
- flashruntime-0.3.0.dist-info/RECORD +95 -0
- flashruntime-0.3.0.dist-info/WHEEL +5 -0
- flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
- flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
- flashruntime-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Tutorial: make a ConvNet fault-tolerant — flashruntime docs</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; margin: 0; }
|
|
10
|
+
body { font: 14px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #0d1117; color: #c9d1d9; }
|
|
11
|
+
a { color: oklch(0.80 0.16 200); text-decoration: none; }
|
|
12
|
+
a:hover { text-decoration: underline; }
|
|
13
|
+
.layout { display: flex; align-items: flex-start; min-height: 100vh; }
|
|
14
|
+
|
|
15
|
+
/* sidebar — PyTorch-docs-like: sections from _nav.yml, current page active */
|
|
16
|
+
.sidebar { flex: 0 0 264px; position: sticky; top: 0; height: 100vh; overflow-y: auto;
|
|
17
|
+
border-right: 1px solid #21262d; background: #0d1117; padding: 20px 16px; }
|
|
18
|
+
.brand { color: #e6edf3; font-size: 15px; letter-spacing: .04em; }
|
|
19
|
+
.brand small { display: block; margin-top: 2px; color: #8b949e; font-size: 10px;
|
|
20
|
+
text-transform: uppercase; letter-spacing: .14em; }
|
|
21
|
+
.search { position: relative; margin: 16px 0; }
|
|
22
|
+
#q { width: 100%; background: #010409; border: 1px solid #21262d; border-radius: 6px;
|
|
23
|
+
color: #c9d1d9; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 7px 9px; }
|
|
24
|
+
#q:focus { outline: 1px solid oklch(0.80 0.16 200); }
|
|
25
|
+
#results { position: absolute; left: 0; right: 0; top: 112%; z-index: 5; display: none;
|
|
26
|
+
background: #161b22; border: 1px solid #21262d; border-radius: 6px; overflow: hidden; }
|
|
27
|
+
#results.open { display: block; }
|
|
28
|
+
#results a { display: block; padding: 7px 9px; border-bottom: 1px solid #21262d; color: #c9d1d9; }
|
|
29
|
+
#results a:last-child { border-bottom: 0; }
|
|
30
|
+
#results a:hover { background: #010409; text-decoration: none; }
|
|
31
|
+
#results a b { color: #e6edf3; font-weight: 600; }
|
|
32
|
+
#results a span { display: block; margin-top: 2px; color: #8b949e; font-size: 11px; }
|
|
33
|
+
#results .nohit { padding: 7px 9px; color: #8b949e; }
|
|
34
|
+
.nav-section { margin: 16px 0 6px; color: #8b949e; font-size: 10px; font-weight: 600;
|
|
35
|
+
text-transform: uppercase; letter-spacing: .14em; }
|
|
36
|
+
.nav-link { display: block; padding: 4px 8px; border-radius: 5px; color: #c9d1d9; font-size: 13px; }
|
|
37
|
+
.nav-link:hover { background: #161b22; text-decoration: none; }
|
|
38
|
+
.nav-link.active { background: #161b22; color: #e6edf3; box-shadow: inset 2px 0 0 oklch(0.80 0.16 200); }
|
|
39
|
+
|
|
40
|
+
/* content */
|
|
41
|
+
.content { flex: 1 1 auto; max-width: 840px; min-width: 0; padding: 34px 40px 80px; }
|
|
42
|
+
.content h1 { color: #e6edf3; font-size: 26px; margin: 0 0 16px; }
|
|
43
|
+
.content h2 { color: #e6edf3; font-size: 18px; margin: 30px 0 10px;
|
|
44
|
+
padding-top: 10px; border-top: 1px solid #21262d; }
|
|
45
|
+
.content h3 { color: #e6edf3; font-size: 15px; margin: 22px 0 8px; }
|
|
46
|
+
.content p, .content li { color: #c9d1d9; }
|
|
47
|
+
.content ul, .content ol { padding-left: 22px; margin: 10px 0; }
|
|
48
|
+
.content li { margin: 4px 0; }
|
|
49
|
+
.content blockquote { margin: 14px 0; padding: 2px 14px; border-left: 3px solid oklch(0.80 0.18 60);
|
|
50
|
+
background: #161b22; border-radius: 0 6px 6px 0; color: #8b949e; }
|
|
51
|
+
.content :not(pre) > code { background: #010409; border: 1px solid #21262d;
|
|
52
|
+
border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
|
53
|
+
.content table { border-collapse: collapse; margin: 14px 0; display: block; overflow-x: auto; }
|
|
54
|
+
.content th, .content td { border: 1px solid #21262d; padding: 6px 10px; text-align: left; }
|
|
55
|
+
.content th { color: #e6edf3; background: #161b22; }
|
|
56
|
+
|
|
57
|
+
/* code blocks + copy button */
|
|
58
|
+
.codeblock { position: relative; margin: 14px 0; }
|
|
59
|
+
.codeblock pre { background: #010409; border: 1px solid #21262d; border-radius: 8px;
|
|
60
|
+
padding: 14px 16px; overflow-x: auto; }
|
|
61
|
+
.codeblock pre code { padding: 0; border: 0; background: none; font-size: 12.5px; color: #c9d1d9; }
|
|
62
|
+
.copybtn { position: absolute; top: 8px; right: 8px; padding: 3px 8px; cursor: pointer;
|
|
63
|
+
background: #161b22; border: 1px solid #21262d; border-radius: 5px;
|
|
64
|
+
color: #8b949e; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
65
|
+
.copybtn:hover { color: #e6edf3; border-color: oklch(0.80 0.16 200); }
|
|
66
|
+
|
|
67
|
+
@media (max-width: 800px) {
|
|
68
|
+
.layout { flex-direction: column; }
|
|
69
|
+
.sidebar { position: static; height: auto; width: 100%; flex-basis: auto;
|
|
70
|
+
border-right: 0; border-bottom: 1px solid #21262d; }
|
|
71
|
+
.content { padding: 24px 18px 60px; }
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="layout">
|
|
77
|
+
<aside class="sidebar">
|
|
78
|
+
<a class="brand" href="../index.html">flashruntime<small>documentation</small></a>
|
|
79
|
+
<div class="search">
|
|
80
|
+
<input id="q" type="search" placeholder="Search docs (press /)" autocomplete="off" spellcheck="false">
|
|
81
|
+
<div id="results"></div>
|
|
82
|
+
</div>
|
|
83
|
+
<nav><div class="nav-section">Getting started</div>
|
|
84
|
+
<a class="nav-link" href="../index.html">FlashRuntime</a>
|
|
85
|
+
<a class="nav-link" href="../get-started.html">Get started</a>
|
|
86
|
+
<div class="nav-section">Tutorials</div>
|
|
87
|
+
<a class="nav-link active" href="convnet.html">Tutorial: make a ConvNet fault-tolerant</a>
|
|
88
|
+
<a class="nav-link" href="sklearn-sweeps.html">Tutorial: parallel scikit-learn sweeps</a>
|
|
89
|
+
<a class="nav-link" href="fault-tolerance.html">Tutorial: automatic recovery, explained</a>
|
|
90
|
+
<div class="nav-section">Guides</div>
|
|
91
|
+
<a class="nav-link" href="../guides/pytorch.html">PyTorch guide</a>
|
|
92
|
+
<a class="nav-link" href="../guides/sklearn.html">scikit-learn guide</a>
|
|
93
|
+
<a class="nav-link" href="../guides/huggingface.html">Hugging Face guide</a>
|
|
94
|
+
<a class="nav-link" href="../guides/jobspec-and-isolation.html">JobSpec & isolation guide</a>
|
|
95
|
+
<a class="nav-link" href="../guides/federated-averaging.html">Federated averaging</a>
|
|
96
|
+
<div class="nav-section">Concepts</div>
|
|
97
|
+
<a class="nav-link" href="../concepts/architecture.html">Architecture</a>
|
|
98
|
+
<div class="nav-section">Reference</div>
|
|
99
|
+
<a class="nav-link" href="../reference/sdk.html">Reference: SDK (`flashruntime`)</a>
|
|
100
|
+
<a class="nav-link" href="../reference/integrations.html">Reference: integrations (`flashruntime.integrations`)</a>
|
|
101
|
+
<a class="nav-link" href="../reference/torch-helper.html">Reference: torch helper (`flashruntime.torch`)</a>
|
|
102
|
+
<a class="nav-link" href="../reference/cli.html">Reference: CLI (`flashruntime`)</a>
|
|
103
|
+
<div class="nav-section">Benchmarks</div>
|
|
104
|
+
<a class="nav-link" href="../benchmarks.html">Benchmarks</a></nav>
|
|
105
|
+
</aside>
|
|
106
|
+
<main class="content"><h1 id="tutorial-make-a-convnet-fault-tolerant">Tutorial: make a ConvNet fault-tolerant</h1>
|
|
107
|
+
<p>This is the flagship walkthrough. You start with an ordinary PyTorch ConvNet
|
|
108
|
+
and an ordinary training loop, and end with the <em>same</em> code — unchanged in its
|
|
109
|
+
math — running as a 2-process DDP job that survives a crash and resumes from
|
|
110
|
+
its last verified checkpoint, with a live page you can watch.</p>
|
|
111
|
+
<p>The promise throughout: <strong>FlashRuntime operates your code; it never rewrites
|
|
112
|
+
it.</strong> The model below is used verbatim, including one unusual thing it does,
|
|
113
|
+
because "your code, unmodified" is the whole point.</p>
|
|
114
|
+
<p>If you have not installed FlashRuntime and PyTorch yet, do the
|
|
115
|
+
<a href="../get-started.html">Get started</a> page first — you need <code>pip install
|
|
116
|
+
flashruntime</code> and a CPU build of <code>torch</code>. No GPU, no cluster.</p>
|
|
117
|
+
<hr />
|
|
118
|
+
<h2 id="1-the-plain-script-no-flashruntime">1. The plain script (no FlashRuntime)</h2>
|
|
119
|
+
<p>Here is the model and a plain single-process training loop. It reads its
|
|
120
|
+
hyperparameters from <code>argparse</code> and writes a <code>metrics.json</code> at the end — the
|
|
121
|
+
one convention FlashRuntime asks of any script. There is <strong>no FlashRuntime
|
|
122
|
+
import</strong> yet.</p>
|
|
123
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">import argparse
|
|
124
|
+
import json
|
|
125
|
+
|
|
126
|
+
import torch
|
|
127
|
+
import torch.nn as nn
|
|
128
|
+
from torch.utils.data import DataLoader, TensorDataset
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class OurConvNet(torch.nn.Module):
|
|
132
|
+
def __init__(self, num_outputs=20):
|
|
133
|
+
super(OurConvNet, self).__init__()
|
|
134
|
+
self.Conv1 = nn.Conv2d(3, 64, 5, 1, 2)
|
|
135
|
+
self.Sigma = nn.Sigmoid()
|
|
136
|
+
self.Avg = nn.AvgPool2d(2, stride=2, padding=0)
|
|
137
|
+
self.Conv2 = nn.Conv2d(64, 128, 5, 1)
|
|
138
|
+
self.Fl = nn.Flatten()
|
|
139
|
+
self.Linear = nn.Linear(128 * 5 * 5, num_outputs)
|
|
140
|
+
|
|
141
|
+
def forward(self, x):
|
|
142
|
+
out = self.Sigma(x) # NOTE: the Sigmoid is applied to the INPUT first.
|
|
143
|
+
out = self.Conv1(out)
|
|
144
|
+
out = self.Avg(out)
|
|
145
|
+
out = self.Conv2(out)
|
|
146
|
+
out = self.Avg(out)
|
|
147
|
+
out = self.Fl(out)
|
|
148
|
+
out = self.Linear(out)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def make_data(n=256, num_outputs=20, seed=0):
|
|
153
|
+
# Synthetic data with a fixed seed keeps the run deterministic on CPU.
|
|
154
|
+
g = torch.Generator().manual_seed(seed)
|
|
155
|
+
x = torch.randn(n, 3, 28, 28, generator=g)
|
|
156
|
+
y = torch.randint(0, num_outputs, (n,), generator=g)
|
|
157
|
+
return TensorDataset(x, y)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main():
|
|
161
|
+
parser = argparse.ArgumentParser()
|
|
162
|
+
parser.add_argument("--steps", type=int, default=40)
|
|
163
|
+
parser.add_argument("--lr", type=float, default=0.05)
|
|
164
|
+
args = parser.parse_args()
|
|
165
|
+
|
|
166
|
+
torch.manual_seed(0)
|
|
167
|
+
model = OurConvNet(num_outputs=20)
|
|
168
|
+
optimizer = torch.optim.SGD(model.parameters(), lr=args.lr)
|
|
169
|
+
loader = DataLoader(make_data(), batch_size=32, shuffle=False)
|
|
170
|
+
|
|
171
|
+
step = 0
|
|
172
|
+
loss = torch.tensor(0.0)
|
|
173
|
+
while step < args.steps:
|
|
174
|
+
for x, y in loader:
|
|
175
|
+
if step >= args.steps:
|
|
176
|
+
break
|
|
177
|
+
loss = torch.nn.functional.cross_entropy(model(x), y)
|
|
178
|
+
optimizer.zero_grad()
|
|
179
|
+
loss.backward()
|
|
180
|
+
optimizer.step()
|
|
181
|
+
step += 1
|
|
182
|
+
|
|
183
|
+
with open("metrics.json", "w") as f:
|
|
184
|
+
json.dump({"steps": step, "final_loss": round(loss.item(), 6)}, f)
|
|
185
|
+
print("done", step, loss.item())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|
|
190
|
+
</code></pre></div>
|
|
191
|
+
<p>Run it like any script:</p>
|
|
192
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-bash">python train.py --steps 30
|
|
193
|
+
</code></pre></div>
|
|
194
|
+
<h3 id="the-shape-math-so-128-5-5-is-not-a-magic-number">The shape math (so <code>128 * 5 * 5</code> is not a magic number)</h3>
|
|
195
|
+
<p>The input is <code>3 × 28 × 28</code>. Follow one image through <code>forward</code>, and every
|
|
196
|
+
<code>nn.Linear(128 * 5 * 5, ...)</code> factor falls out of the convolution arithmetic:</p>
|
|
197
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code> 28 --Conv1(k5, s1, p2)--> 28 (padding 2 keeps the size)
|
|
198
|
+
--Avg(2, 2)----------> 14
|
|
199
|
+
--Conv2(k5, s1, p0)--> 10 (no padding: 14 - 5 + 1 = 10)
|
|
200
|
+
--Avg(2, 2)----------> 5
|
|
201
|
+
=> Flatten = 128 channels · 5 · 5 = 3200 = nn.Linear(128 * 5 * 5, num_outputs)
|
|
202
|
+
</code></pre></div>
|
|
203
|
+
<p>So <code>3 × 28 × 28</code> is the input size that makes the flattened feature map land
|
|
204
|
+
<strong>exactly</strong> on <code>128 * 5 * 5</code>. Feed a different size and the <code>Flatten → Linear</code>
|
|
205
|
+
handoff mismatches; keep 28 and it fits.</p>
|
|
206
|
+
<h3 id="one-honest-note-about-this-model">One honest note about this model</h3>
|
|
207
|
+
<p><code>forward</code> applies <code>self.Sigma</code> (a <code>Sigmoid</code>) to the <strong>raw input</strong> before the
|
|
208
|
+
first convolution — an unusual ordering (a sigmoid is normally an activation
|
|
209
|
+
<em>between</em> layers, not a preprocessing step on the pixels). We keep it exactly
|
|
210
|
+
as written. FlashRuntime's promise is to operate <em>your</em> code, so we do not
|
|
211
|
+
"fix" the model to match convention — we run what you wrote.</p>
|
|
212
|
+
<hr />
|
|
213
|
+
<h2 id="2-make-it-resumable-import-flashruntimetorch-as-ft">2. Make it resumable: <code>import flashruntime.torch as ft</code></h2>
|
|
214
|
+
<p>The plain script has no checkpoints, so a crash starts over from step 0. One
|
|
215
|
+
import — <code>flashruntime.torch</code> (aliased <code>ft</code>) — gives you launch-anywhere DDP
|
|
216
|
+
and fault-tolerant checkpointing without rebuilding any framework machinery.
|
|
217
|
+
Here is the whole change, as a diff:</p>
|
|
218
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-diff"> import argparse
|
|
219
|
+
import json
|
|
220
|
+
|
|
221
|
+
import torch
|
|
222
|
+
import torch.nn as nn
|
|
223
|
+
from torch.utils.data import DataLoader, TensorDataset
|
|
224
|
+
|
|
225
|
+
+import flashruntime.torch as ft
|
|
226
|
+
+
|
|
227
|
+
|
|
228
|
+
def main():
|
|
229
|
+
parser = argparse.ArgumentParser()
|
|
230
|
+
parser.add_argument("--steps", type=int, default=40)
|
|
231
|
+
parser.add_argument("--lr", type=float, default=0.05)
|
|
232
|
+
+ parser.add_argument("--checkpoint-every", type=int, default=8)
|
|
233
|
+
+ parser.add_argument("--kill-at-step", type=int, default=None,
|
|
234
|
+
+ help="simulate a crash (fresh runs only; a resumed retry finishes)")
|
|
235
|
+
args = parser.parse_args()
|
|
236
|
+
|
|
237
|
+
torch.manual_seed(0)
|
|
238
|
+
model = OurConvNet(num_outputs=20)
|
|
239
|
+
optimizer = torch.optim.SGD(model.parameters(), lr=args.lr)
|
|
240
|
+
loader = DataLoader(make_data(), batch_size=32, shuffle=False)
|
|
241
|
+
|
|
242
|
+
- step = 0
|
|
243
|
+
+ model, optimizer, loader = ft.prepare(model, optimizer, loader)
|
|
244
|
+
+ start = ft.start_step()
|
|
245
|
+
+
|
|
246
|
+
+ step = start
|
|
247
|
+
loss = torch.tensor(0.0)
|
|
248
|
+
while step < args.steps:
|
|
249
|
+
for x, y in loader:
|
|
250
|
+
if step >= args.steps:
|
|
251
|
+
break
|
|
252
|
+
loss = torch.nn.functional.cross_entropy(model(x), y)
|
|
253
|
+
optimizer.zero_grad()
|
|
254
|
+
loss.backward()
|
|
255
|
+
optimizer.step()
|
|
256
|
+
step += 1
|
|
257
|
+
+ ft.checkpoint(model, optimizer, step=step, every=args.checkpoint_every)
|
|
258
|
+
+ ft.log_metrics({"step": step, "loss": round(loss.item(), 6)})
|
|
259
|
+
+ if args.kill_at_step and start == 0 and step >= args.kill_at_step:
|
|
260
|
+
+ raise SystemExit(3) # fresh run only — the retry resumes past this
|
|
261
|
+
|
|
262
|
+
- with open("metrics.json", "w") as f:
|
|
263
|
+
- json.dump({"steps": step, "final_loss": round(loss.item(), 6)}, f)
|
|
264
|
+
- print("done", step, loss.item())
|
|
265
|
+
+ ft.checkpoint(model, optimizer, step=step) # final checkpoint
|
|
266
|
+
+ if ft.is_main():
|
|
267
|
+
+ metrics = {"steps": step, "resumed_from": start, "final_loss": round(loss.item(), 6)}
|
|
268
|
+
+ with open("metrics.json", "w") as f:
|
|
269
|
+
+ json.dump(metrics, f)
|
|
270
|
+
+ print(metrics)
|
|
271
|
+
</code></pre></div>
|
|
272
|
+
<p>What each added call does — and <em>nothing more</em> (this is the whole surface):</p>
|
|
273
|
+
<ul>
|
|
274
|
+
<li><strong><code>ft.prepare(model, optimizer, loader)</code></strong> — launched distributed
|
|
275
|
+
(<code>WORLD_SIZE > 1</code>), it initializes torch's own process group (<code>gloo</code> on CPU,
|
|
276
|
+
<code>nccl</code> on GPU), wraps the model in <code>DistributedDataParallel</code>, and swaps the
|
|
277
|
+
DataLoader's sampler for a seed-0 <code>DistributedSampler</code> so each rank sees a
|
|
278
|
+
disjoint shard. It then restores the newest <strong>valid</strong> checkpoint if one
|
|
279
|
+
exists. Launched as plain <code>python train.py</code>, it is a no-op passthrough.</li>
|
|
280
|
+
<li><strong><code>ft.start_step()</code></strong> — <code>0</code> on a fresh run, <code>>0</code> after a resume (it is the
|
|
281
|
+
step <code>prepare</code> restored to). The loop starts from it.</li>
|
|
282
|
+
<li><strong><code>ft.checkpoint(model, optimizer, step=step, every=8)</code></strong> — rank 0 writes a
|
|
283
|
+
checkpoint under the parts-first / manifest-last contract; the manifest is
|
|
284
|
+
written <strong>last</strong>, so a half-written checkpoint is never treated as valid.</li>
|
|
285
|
+
<li><strong><code>ft.log_metrics({...})</code></strong> — rank 0 appends one JSON line to
|
|
286
|
+
<code>metrics.jsonl</code>; this is the streaming series the live page draws as a loss
|
|
287
|
+
curve. It never raises.</li>
|
|
288
|
+
</ul>
|
|
289
|
+
<p>The full surface is three verbs plus read-only launch-fact accessors — see
|
|
290
|
+
the <a href="../reference/torch-helper.html">torch helper reference</a>. There are no FSDP
|
|
291
|
+
policies, no autocast, no DeepSpeed config here: that is deliberate (ADR-0003 —
|
|
292
|
+
we do not rebuild Accelerate). A script that wants those uses the real
|
|
293
|
+
framework features; the launcher still launches it correctly.</p>
|
|
294
|
+
<p>The finished, copy-paste-runnable version of this script is in
|
|
295
|
+
<a href="#5-the-final-script-copy-paste-runnable">section 5</a> below.</p>
|
|
296
|
+
<hr />
|
|
297
|
+
<h2 id="3-operate-it-flashsubmitfr_torchddp">3. Operate it: <code>flash.submit(fr_torch.ddp(...))</code></h2>
|
|
298
|
+
<p>Now hand the script to FlashRuntime. The <code>integrations.pytorch</code> adapter builds
|
|
299
|
+
the <code>torchrun</code> command that starts N worker processes; <code>flash.submit()</code> runs
|
|
300
|
+
it, waits, collects the artifacts, and hands back a <code>Run</code>:</p>
|
|
301
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">import flashruntime as flash
|
|
302
|
+
from flashruntime.integrations import pytorch as fr_torch
|
|
303
|
+
|
|
304
|
+
run = flash.submit(fr_torch.ddp(
|
|
305
|
+
"train.py",
|
|
306
|
+
source=".", # directory holding train.py
|
|
307
|
+
nproc_per_node=2, # two worker processes on this host — gloo/CPU, no GPU
|
|
308
|
+
script_args="--steps 16",
|
|
309
|
+
))
|
|
310
|
+
print(run.state.value, run.trials)
|
|
311
|
+
# SUCCEEDED [{'steps': 16, 'resumed_from': 0, 'final_loss': 2.98...}]
|
|
312
|
+
</code></pre></div>
|
|
313
|
+
<p><code>ddp(script, *, source=".", nproc_per_node=2, nnodes=1, script_args="",
|
|
314
|
+
env=None)</code> emits <code>torchrun --nproc-per-node=2 --nnodes=1 --standalone
|
|
315
|
+
--local-addr=127.0.0.1 train.py --steps 16</code>. Two processes rendezvous on
|
|
316
|
+
loopback; each gets its <code>RANK</code> / <code>WORLD_SIZE</code> / <code>LOCAL_RANK</code>, and
|
|
317
|
+
<code>ft.prepare()</code> wires DDP from there. <code>nnodes > 1</code> raises <code>NotImplementedError</code>
|
|
318
|
+
today — multi-node rendezvous is a later slice.</p>
|
|
319
|
+
<blockquote>
|
|
320
|
+
<p><strong><code>torchrun</code> must be on your <code>PATH</code>.</strong> It ships with <code>torch</code>; if you
|
|
321
|
+
installed torch into a virtualenv, run from that environment so its
|
|
322
|
+
<code>torchrun</code> is found.</p>
|
|
323
|
+
</blockquote>
|
|
324
|
+
<hr />
|
|
325
|
+
<h2 id="4-crash-it-and-let-it-recover">4. Crash it, and let it recover</h2>
|
|
326
|
+
<p>Now the payoff. Point a run at a fixed <code>output_dir</code> so its checkpoint tree
|
|
327
|
+
persists, tell the script to crash mid-way with <code>--kill-at-step</code>, and give
|
|
328
|
+
<code>submit()</code> a restart budget with <code>max_restarts=1</code>. That is the <strong>only</strong> change
|
|
329
|
+
— one keyword argument — and recovery is automatic:</p>
|
|
330
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">import flashruntime as flash
|
|
331
|
+
from flashruntime.integrations import pytorch as fr_torch
|
|
332
|
+
|
|
333
|
+
run = flash.submit(
|
|
334
|
+
fr_torch.ddp(
|
|
335
|
+
"train.py",
|
|
336
|
+
source=".",
|
|
337
|
+
nproc_per_node=1,
|
|
338
|
+
script_args="--steps 24 --checkpoint-every 8 --kill-at-step 8",
|
|
339
|
+
),
|
|
340
|
+
output_dir="out/convnet", # a FIXED dir — the checkpoint tree lives here
|
|
341
|
+
max_restarts=1, # one automatic recovery attempt
|
|
342
|
+
)
|
|
343
|
+
print(run.state.value, run.trials)
|
|
344
|
+
# SUCCEEDED [{'steps': 24, 'resumed_from': 8, 'final_loss': 2.99...}]
|
|
345
|
+
</code></pre></div>
|
|
346
|
+
<p><code>resumed_from: 8</code> is the proof: the first attempt crashed at step 8, and the
|
|
347
|
+
retry <strong>resumed from the step-8 checkpoint</strong> rather than restarting at zero.
|
|
348
|
+
The run's event log tells the whole story:</p>
|
|
349
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code>LAUNCH_STARTED task-000 launched (pid ...)
|
|
350
|
+
FAILURE_CLASSIFIED task-000: worker_crash (exit 1)
|
|
351
|
+
RECOVERY_ACTION_SELECTED task-000: restart_group — a lost rank stops the group —
|
|
352
|
+
restart all workers from latest valid checkpoint
|
|
353
|
+
LAUNCH_STARTED task-000-r1 launched (pid ...)
|
|
354
|
+
</code></pre></div>
|
|
355
|
+
<p>That decision is not a guess. FlashRuntime turned the crash into failure
|
|
356
|
+
signals, <code>classify()</code>'d them (a <code>torchrun</code>-wrapped worker death is a
|
|
357
|
+
non-deterministic <code>worker_crash</code>, not a code bug), and looked the class up in a
|
|
358
|
+
versioned, deterministic policy table. Same failure + same policy version ⇒
|
|
359
|
+
same action, every time — no LLM in the loop. The mechanics are the subject of
|
|
360
|
+
the <a href="fault-tolerance.html">fault-tolerance tutorial</a>.</p>
|
|
361
|
+
<p>One honest constraint on bit-exact resume: on resume the <code>for</code> loop restarts
|
|
362
|
+
the dataloader at batch 0, so the resumed step must land on an <strong>epoch
|
|
363
|
+
boundary</strong>. With 256 samples / batch 32 = 8 batches per epoch single-process,
|
|
364
|
+
keep <code>--checkpoint-every</code> (and <code>--kill-at-step</code>) multiples of that. Off a
|
|
365
|
+
boundary, the resumed run is still correct training — it just will not match an
|
|
366
|
+
uninterrupted run byte-for-byte.</p>
|
|
367
|
+
<hr />
|
|
368
|
+
<h2 id="watch-it-live">Watch it live</h2>
|
|
369
|
+
<p>Pass <code>watch=True</code> (or just run at an interactive terminal, where it is the
|
|
370
|
+
default) and <code>flash.submit()</code> opens a live run page in your browser and records
|
|
371
|
+
its URL on <code>run.viewer_url</code>:</p>
|
|
372
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">import flashruntime as flash
|
|
373
|
+
from flashruntime.integrations import pytorch as fr_torch
|
|
374
|
+
|
|
375
|
+
run = flash.submit(
|
|
376
|
+
fr_torch.ddp("train.py", source=".", nproc_per_node=2, script_args="--steps 16"),
|
|
377
|
+
output_dir="out/convnet",
|
|
378
|
+
watch=True,
|
|
379
|
+
)
|
|
380
|
+
print(run.viewer_url) # http://127.0.0.1:<port>
|
|
381
|
+
</code></pre></div>
|
|
382
|
+
<p>The page polls a loopback server every couple of seconds and draws the run with
|
|
383
|
+
<strong>zero external assets</strong> (no CDN, no web font, no remote image) — it renders
|
|
384
|
+
with the network cut. Top to bottom, the panels are:</p>
|
|
385
|
+
<ul>
|
|
386
|
+
<li><strong>Header</strong> — a colored state badge (RUNNING cyan, SUCCEEDED green, FAILED
|
|
387
|
+
red), the exact command, the execution <code>mode</code>, <code>restarts</code> used out of your
|
|
388
|
+
budget, and the total <code>attempts</code> count.</li>
|
|
389
|
+
<li><strong>Topology</strong> — a single machine box labeled <code>127.0.0.1 · localhost</code> with one
|
|
390
|
+
node per attempt/rank, colored by state; a RUNNING node softly pulses. A
|
|
391
|
+
2-process DDP run shows two nodes.</li>
|
|
392
|
+
<li><strong>Loss</strong> — an autoscaled curve built from the <code>metrics.jsonl</code> your
|
|
393
|
+
<code>ft.log_metrics(...)</code> calls stream, with the latest value labeled. (No
|
|
394
|
+
<code>log_metrics</code> calls ⇒ no curve — this panel is fed by that stream.)</li>
|
|
395
|
+
<li><strong>Checkpoints</strong> — one violet marker per manifest: its step, a
|
|
396
|
+
<strong>hash-verified</strong> or <strong>invalid</strong> badge (re-verified at read time, not taken
|
|
397
|
+
on trust), a ★ <strong>latest</strong> tag on the one recovery would restore, and the part
|
|
398
|
+
count plus age.</li>
|
|
399
|
+
<li><strong>Events</strong> — newest first, the same log shown above;
|
|
400
|
+
<code>FAILURE_CLASSIFIED</code> is amber and <code>RECOVERY_ACTION_SELECTED</code> is cyan, each
|
|
401
|
+
carrying the failure class and the policy's human-readable reason.</li>
|
|
402
|
+
<li><strong>Logs</strong> — a collapsible tail of each attempt's captured stdout+stderr.</li>
|
|
403
|
+
</ul>
|
|
404
|
+
<p>These docs are served from that same viewer at <code>/docs</code>, so the page you watch
|
|
405
|
+
and the page you are reading are one product.</p>
|
|
406
|
+
<hr />
|
|
407
|
+
<h2 id="5-the-final-script-copy-paste-runnable">5. The final script (copy-paste runnable)</h2>
|
|
408
|
+
<p>The complete <code>train.py</code> from sections 2–4. It runs three ways from one file —
|
|
409
|
+
<code>python train.py</code>, <code>torchrun ... train.py</code>, or <code>flash.submit(fr_torch.ddp(
|
|
410
|
+
...))</code> — and needs only a CPU:</p>
|
|
411
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">"""ConvNet tutorial — your model, unmodified, made fault-tolerant.
|
|
412
|
+
|
|
413
|
+
python train.py --steps 40 # single process
|
|
414
|
+
torchrun --nproc-per-node=2 --standalone train.py # DDP by hand
|
|
415
|
+
flash.submit(fr_torch.ddp("train.py", ...)) # operated by FlashRuntime
|
|
416
|
+
|
|
417
|
+
Shapes: input 3x28x28 -> Conv1(k5,p2) 28 -> Avg 14 -> Conv2(k5) 10 -> Avg 5,
|
|
418
|
+
so Flatten = 128 * 5 * 5 = 3200 = nn.Linear(128*5*5, num_outputs).
|
|
419
|
+
"""
|
|
420
|
+
import argparse
|
|
421
|
+
import json
|
|
422
|
+
|
|
423
|
+
import torch
|
|
424
|
+
import torch.nn as nn
|
|
425
|
+
from torch.utils.data import DataLoader, TensorDataset
|
|
426
|
+
|
|
427
|
+
import flashruntime.torch as ft
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class OurConvNet(torch.nn.Module):
|
|
431
|
+
def __init__(self, num_outputs=20):
|
|
432
|
+
super(OurConvNet, self).__init__()
|
|
433
|
+
self.Conv1 = nn.Conv2d(3, 64, 5, 1, 2)
|
|
434
|
+
self.Sigma = nn.Sigmoid()
|
|
435
|
+
self.Avg = nn.AvgPool2d(2, stride=2, padding=0)
|
|
436
|
+
self.Conv2 = nn.Conv2d(64, 128, 5, 1)
|
|
437
|
+
self.Fl = nn.Flatten()
|
|
438
|
+
self.Linear = nn.Linear(128 * 5 * 5, num_outputs)
|
|
439
|
+
|
|
440
|
+
def forward(self, x):
|
|
441
|
+
out = self.Sigma(x) # NOTE: Sigmoid applied to the INPUT first — kept verbatim.
|
|
442
|
+
out = self.Conv1(out)
|
|
443
|
+
out = self.Avg(out)
|
|
444
|
+
out = self.Conv2(out)
|
|
445
|
+
out = self.Avg(out)
|
|
446
|
+
out = self.Fl(out)
|
|
447
|
+
out = self.Linear(out)
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def make_data(n=256, num_outputs=20, seed=0):
|
|
452
|
+
g = torch.Generator().manual_seed(seed)
|
|
453
|
+
x = torch.randn(n, 3, 28, 28, generator=g)
|
|
454
|
+
y = torch.randint(0, num_outputs, (n,), generator=g)
|
|
455
|
+
return TensorDataset(x, y)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def main():
|
|
459
|
+
parser = argparse.ArgumentParser()
|
|
460
|
+
parser.add_argument("--steps", type=int, default=40)
|
|
461
|
+
parser.add_argument("--lr", type=float, default=0.05)
|
|
462
|
+
parser.add_argument("--checkpoint-every", type=int, default=8)
|
|
463
|
+
parser.add_argument("--kill-at-step", type=int, default=None,
|
|
464
|
+
help="simulate a crash (fresh runs only; a resumed retry finishes)")
|
|
465
|
+
args = parser.parse_args()
|
|
466
|
+
|
|
467
|
+
torch.manual_seed(0)
|
|
468
|
+
model = OurConvNet(num_outputs=20)
|
|
469
|
+
optimizer = torch.optim.SGD(model.parameters(), lr=args.lr)
|
|
470
|
+
loader = DataLoader(make_data(), batch_size=32, shuffle=False)
|
|
471
|
+
|
|
472
|
+
model, optimizer, loader = ft.prepare(model, optimizer, loader)
|
|
473
|
+
start = ft.start_step()
|
|
474
|
+
|
|
475
|
+
step = start
|
|
476
|
+
loss = torch.tensor(0.0)
|
|
477
|
+
while step < args.steps:
|
|
478
|
+
for x, y in loader:
|
|
479
|
+
if step >= args.steps:
|
|
480
|
+
break
|
|
481
|
+
loss = torch.nn.functional.cross_entropy(model(x), y)
|
|
482
|
+
optimizer.zero_grad()
|
|
483
|
+
loss.backward()
|
|
484
|
+
optimizer.step()
|
|
485
|
+
step += 1
|
|
486
|
+
ft.checkpoint(model, optimizer, step=step, every=args.checkpoint_every)
|
|
487
|
+
ft.log_metrics({"step": step, "loss": round(loss.item(), 6)})
|
|
488
|
+
if args.kill_at_step and start == 0 and step >= args.kill_at_step:
|
|
489
|
+
raise SystemExit(3) # fresh run only — the retry resumes past this
|
|
490
|
+
|
|
491
|
+
ft.checkpoint(model, optimizer, step=step) # final checkpoint
|
|
492
|
+
if ft.is_main():
|
|
493
|
+
metrics = {"steps": step, "resumed_from": start, "final_loss": round(loss.item(), 6)}
|
|
494
|
+
with open("metrics.json", "w") as f:
|
|
495
|
+
json.dump(metrics, f)
|
|
496
|
+
print(metrics)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
main()
|
|
501
|
+
</code></pre></div>
|
|
502
|
+
<hr />
|
|
503
|
+
<h2 id="where-to-go-next">Where to go next</h2>
|
|
504
|
+
<ul>
|
|
505
|
+
<li><strong><a href="fault-tolerance.html">Fault tolerance, in depth</a></strong> — how a crash becomes
|
|
506
|
+
signals, a class, and a typed recovery action.</li>
|
|
507
|
+
<li><strong><a href="../guides/pytorch.html">PyTorch guide</a></strong> — the two launch paths (already-DDP
|
|
508
|
+
scripts vs. the <code>ft.</code> helper) and every caveat.</li>
|
|
509
|
+
<li><strong><a href="../concepts/architecture.html">Architecture</a></strong> — the four axes, leases,
|
|
510
|
+
manifests, and recovery that make the above work.</li>
|
|
511
|
+
</ul></main>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<script>
|
|
515
|
+
// ---- client-side search: fetch the builder's index, filter as you type -----
|
|
516
|
+
// (<=60 lines, vanilla JS, no external anything — the index is a sibling file.)
|
|
517
|
+
let INDEX = [];
|
|
518
|
+
// Path from THIS page back to the site root. The index stores root-relative
|
|
519
|
+
// URLs (e.g. "tutorials/deep.html"); we fetch it and build result links
|
|
520
|
+
// against ROOT so a page in a subdir resolves them too (see build_docs.py).
|
|
521
|
+
const ROOT = "../";
|
|
522
|
+
const q = document.getElementById("q");
|
|
523
|
+
const results = document.getElementById("results");
|
|
524
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|
525
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
526
|
+
fetch(ROOT + "search-index.json").then((r) => r.json()).then((d) => { INDEX = d; }).catch(() => {});
|
|
527
|
+
|
|
528
|
+
// A short context window around the first match, so a hit shows WHY it matched.
|
|
529
|
+
function snippet(text, needle) {
|
|
530
|
+
const i = text.toLowerCase().indexOf(needle);
|
|
531
|
+
if (i < 0) return "";
|
|
532
|
+
const start = Math.max(0, i - 32);
|
|
533
|
+
return (start > 0 ? "…" : "") + text.slice(start, i + needle.length + 44).trim() + "…";
|
|
534
|
+
}
|
|
535
|
+
function runSearch() {
|
|
536
|
+
const needle = q.value.trim().toLowerCase();
|
|
537
|
+
if (!needle) { results.className = ""; results.innerHTML = ""; return; }
|
|
538
|
+
const hits = INDEX.map((p) => {
|
|
539
|
+
const inTitle = p.title.toLowerCase().includes(needle);
|
|
540
|
+
const inText = p.text.toLowerCase().includes(needle);
|
|
541
|
+
if (!inTitle && !inText) return null;
|
|
542
|
+
return { url: p.url, title: p.title, snip: inText ? snippet(p.text, needle) : "" };
|
|
543
|
+
}).filter(Boolean).slice(0, 20);
|
|
544
|
+
results.className = "open";
|
|
545
|
+
results.innerHTML = hits.length
|
|
546
|
+
? hits.map((h) => '<a href="' + ROOT + h.url + '"><b>' + esc(h.title) + "</b>" +
|
|
547
|
+
(h.snip ? "<span>" + esc(h.snip) + "</span>" : "") + "</a>").join("")
|
|
548
|
+
: '<div class="nohit">no matches</div>';
|
|
549
|
+
}
|
|
550
|
+
q.addEventListener("input", runSearch);
|
|
551
|
+
q.addEventListener("focus", runSearch);
|
|
552
|
+
document.addEventListener("click", (e) => {
|
|
553
|
+
if (!e.target.closest(".search")) { results.className = ""; } // dismiss on outside click
|
|
554
|
+
});
|
|
555
|
+
document.addEventListener("keydown", (e) => { // "/" focuses search, like PyTorch docs
|
|
556
|
+
if (e.key === "/" && document.activeElement !== q) { e.preventDefault(); q.focus(); }
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// ---- copy buttons: read the <pre>'s own text into the clipboard ------------
|
|
560
|
+
document.querySelectorAll(".copybtn").forEach((btn) => {
|
|
561
|
+
btn.addEventListener("click", () => {
|
|
562
|
+
const code = btn.parentElement.querySelector("pre").innerText;
|
|
563
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
564
|
+
const was = btn.textContent; btn.textContent = "Copied";
|
|
565
|
+
setTimeout(() => { btn.textContent = was; }, 1200);
|
|
566
|
+
}).catch(() => {});
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
</script>
|
|
570
|
+
</body>
|
|
571
|
+
</html>
|