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.
Files changed (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. 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 &amp; 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(&quot;--steps&quot;, type=int, default=40)
163
+ parser.add_argument(&quot;--lr&quot;, 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 &lt; args.steps:
174
+ for x, y in loader:
175
+ if step &gt;= 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(&quot;metrics.json&quot;, &quot;w&quot;) as f:
184
+ json.dump({&quot;steps&quot;: step, &quot;final_loss&quot;: round(loss.item(), 6)}, f)
185
+ print(&quot;done&quot;, step, loss.item())
186
+
187
+
188
+ if __name__ == &quot;__main__&quot;:
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)--&gt; 28 (padding 2 keeps the size)
198
+ --Avg(2, 2)----------&gt; 14
199
+ --Conv2(k5, s1, p0)--&gt; 10 (no padding: 14 - 5 + 1 = 10)
200
+ --Avg(2, 2)----------&gt; 5
201
+ =&gt; 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(&quot;--steps&quot;, type=int, default=40)
231
+ parser.add_argument(&quot;--lr&quot;, type=float, default=0.05)
232
+ + parser.add_argument(&quot;--checkpoint-every&quot;, type=int, default=8)
233
+ + parser.add_argument(&quot;--kill-at-step&quot;, type=int, default=None,
234
+ + help=&quot;simulate a crash (fresh runs only; a resumed retry finishes)&quot;)
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 &lt; args.steps:
249
+ for x, y in loader:
250
+ if step &gt;= 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({&quot;step&quot;: step, &quot;loss&quot;: round(loss.item(), 6)})
259
+ + if args.kill_at_step and start == 0 and step &gt;= args.kill_at_step:
260
+ + raise SystemExit(3) # fresh run only — the retry resumes past this
261
+
262
+ - with open(&quot;metrics.json&quot;, &quot;w&quot;) as f:
263
+ - json.dump({&quot;steps&quot;: step, &quot;final_loss&quot;: round(loss.item(), 6)}, f)
264
+ - print(&quot;done&quot;, step, loss.item())
265
+ + ft.checkpoint(model, optimizer, step=step) # final checkpoint
266
+ + if ft.is_main():
267
+ + metrics = {&quot;steps&quot;: step, &quot;resumed_from&quot;: start, &quot;final_loss&quot;: round(loss.item(), 6)}
268
+ + with open(&quot;metrics.json&quot;, &quot;w&quot;) 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 &gt; 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>&gt;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
+ &quot;train.py&quot;,
306
+ source=&quot;.&quot;, # directory holding train.py
307
+ nproc_per_node=2, # two worker processes on this host — gloo/CPU, no GPU
308
+ script_args=&quot;--steps 16&quot;,
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 &gt; 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
+ &quot;train.py&quot;,
336
+ source=&quot;.&quot;,
337
+ nproc_per_node=1,
338
+ script_args=&quot;--steps 24 --checkpoint-every 8 --kill-at-step 8&quot;,
339
+ ),
340
+ output_dir=&quot;out/convnet&quot;, # 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(&quot;train.py&quot;, source=&quot;.&quot;, nproc_per_node=2, script_args=&quot;--steps 16&quot;),
377
+ output_dir=&quot;out/convnet&quot;,
378
+ watch=True,
379
+ )
380
+ print(run.viewer_url) # http://127.0.0.1:&lt;port&gt;
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">&quot;&quot;&quot;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(&quot;train.py&quot;, ...)) # operated by FlashRuntime
416
+
417
+ Shapes: input 3x28x28 -&gt; Conv1(k5,p2) 28 -&gt; Avg 14 -&gt; Conv2(k5) 10 -&gt; Avg 5,
418
+ so Flatten = 128 * 5 * 5 = 3200 = nn.Linear(128*5*5, num_outputs).
419
+ &quot;&quot;&quot;
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(&quot;--steps&quot;, type=int, default=40)
461
+ parser.add_argument(&quot;--lr&quot;, type=float, default=0.05)
462
+ parser.add_argument(&quot;--checkpoint-every&quot;, type=int, default=8)
463
+ parser.add_argument(&quot;--kill-at-step&quot;, type=int, default=None,
464
+ help=&quot;simulate a crash (fresh runs only; a resumed retry finishes)&quot;)
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 &lt; args.steps:
478
+ for x, y in loader:
479
+ if step &gt;= 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({&quot;step&quot;: step, &quot;loss&quot;: round(loss.item(), 6)})
488
+ if args.kill_at_step and start == 0 and step &gt;= 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 = {&quot;steps&quot;: step, &quot;resumed_from&quot;: start, &quot;final_loss&quot;: round(loss.item(), 6)}
494
+ with open(&quot;metrics.json&quot;, &quot;w&quot;) as f:
495
+ json.dump(metrics, f)
496
+ print(metrics)
497
+
498
+
499
+ if __name__ == &quot;__main__&quot;:
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
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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>