Repository navigation
Expand file tree
/
Copy pathspiral_runtime.py
More file actions
2905 lines (2653 loc) · 132 KB
/
Copy pathspiral_runtime.py
File metadata and controls
2905 lines (2653 loc) · 132 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Resident owner of one FitContext per rank and its optimizer loop.
The fitter thread constructs the context, drives its load/build/step phases,
and closes it; only that thread calls Torch/CUDA. Other threads communicate
through a condition variable and consume copied status snapshots.
"""
from __future__ import annotations
import copy
import dataclasses
import itertools
import json
import multiprocessing
import os
from pathlib import Path
import shutil
import sys
import tempfile
import threading
import time
import traceback
from typing import Any, ClassVar, Mapping
import uuid
from fit_session import (AUTOSAVE_CHECKPOINT_NAME, AUTOSAVE_INTERVAL_ITERATIONS,
ScrollSpec, SessionState,
SpiralInputPaths, SpiralPreviewConfig,
SpiralRunConfig, run_mutable_config,
write_autosave_metadata)
from checkpoint_migrations import tolerate_config
from config import Config, FitConfig, filter_known_config_keys
from spiral_progress import NullProgressReporter, ProgressReporter
_NO_PROGRESS = NullProgressReporter()
# The transitions the resident session is allowed to make, keyed by the state
# it is leaving. Error and Closing are reachable from every state and are
# therefore not repeated here; Closing is terminal. Anything outside this
# table is a programming error in the runtime, not a runtime condition.
_LEGAL_SESSION_TRANSITIONS = {
# A phase-only update repeats the current state, so every state may
# transition to itself.
SessionState.Loading: {SessionState.Loading, SessionState.Idle,
SessionState.ExportingPreview},
# Idle -> Loading is the in-session checkpoint load: inspecting and
# applying a checkpoint takes the session out of Idle for the whole
# two-phase operation, so nothing can be admitted against the model while
# it is being replaced.
SessionState.Idle: {SessionState.Idle, SessionState.Running,
SessionState.Saving, SessionState.Loading,
SessionState.ExportingPreview},
SessionState.Running: {SessionState.Running, SessionState.Idle,
SessionState.Saving},
SessionState.Saving: {SessionState.Saving, SessionState.Idle,
SessionState.ExportingPreview},
SessionState.ExportingPreview: {SessionState.ExportingPreview,
SessionState.Idle},
SessionState.Error: {SessionState.Error},
SessionState.Closing: {SessionState.Closing},
}
# An idle session reports one phase. "Ready" and "Paused" used to be
# separate lifecycle states, and briefly survived as labels derived from the
# completed iteration count, but the distinction is not one a user acts on:
# both mean the session is loaded, doing nothing, and ready to run. Clients
# that want to say how much work has happened read current_iteration.
IDLE_PHASE = "Idle"
# Bounded fail-stop budget for the distributed proxy.
#
# The rule these constants serve: every wait a client can be parked behind is
# bounded by a value chosen here, never by the process group's collective
# timeout. A rank that dies inside a collective leaves its siblings blocked in
# NCCL for FIT_SPIRAL_DDP_TIMEOUT_MIN (60 minutes by default), which is not an
# answer any interactive client can use, so the parent detects the death and
# takes the job down itself.
#
# Worst-case time from "a worker died" to "the client has a diagnosable
# error": WATCHDOG_POLL_INTERVAL_S + WORKER_TERMINATE_GRACE_S +
# WORKER_KILL_GRACE_S, i.e. about 11 seconds.
# How often the parent samples worker liveness. Small enough that a crash is
# reported in about the time a human notices a stall, large enough that
# polling a handful of processes costs nothing.
WATCHDOG_POLL_INTERVAL_S = 0.5
# Grace for a worker to exit after SIGTERM. Covers the process's own atexit /
# CUDA teardown; after it the worker is SIGKILLed.
WORKER_TERMINATE_GRACE_S = 5.0
# Grace for a SIGKILLed worker to be reaped. A worker stuck in an
# uninterruptible driver call can outlive this; the parent gives up waiting
# rather than blocking the client on it.
WORKER_KILL_GRACE_S = 5.0
# How long an all-rank command may take to be acknowledged by every rank.
# Admission commands (run/stop) only move a rank's own queue, so this is
# generous by two orders of magnitude; exceeding it means a rank is wedged.
COMMAND_ACK_TIMEOUT_S = 30.0
# Extra slack on top of a caller-supplied operation timeout (checkpoint save,
# close) before the parent calls the worker wedged.
COMMAND_ACK_GRACE_S = 5.0
# Object-store adapter report lines a session keeps for its status; a
# selection's report is a few dozen lines, so this only bounds a pathological
# one.
MAX_INPUT_REPORT_LINES = 500
# How long one requested preview export may take. The window covers the whole
# operation, not just the render: the host service Lasagna-flattens and hashes
# the generation synchronously from the status callback, inside the session's
# ExportingPreview window. Exceeding it reports a failure for an export that
# usually goes on to publish anyway, so it is sized for the largest fits.
PREVIEW_EXPORT_TIMEOUT_S = 1200.0
# All-rank commands that only a rank with nothing outstanding may enter.
_QUIESCENT_COMMANDS = frozenset({
"run", "preflight_checkpoint", "apply_checkpoint", "rebuild_model",
"prepare_inputs", "install_inputs", "publish_inputs",
})
def _describe_position(position):
"""A store position (or pins) in a message: its commit."""
value = (position or {}).get("position", position) or {}
sequence = value.get("commit_sequence") if isinstance(value, dict) else None
return f"commit {sequence}" if sequence is not None else "an unknown position"
class InputUpdateRefused(RuntimeError):
"""An input update refused before any rank released resident state:
the fit continues with the inputs it had."""
class InputUpdateFailed(RuntimeError):
"""An input update failed after resident input state was released on
some rank: the session has stopped and needs an explicit rebuild or a
checkpoint load."""
class CommandBarrierViolation(RuntimeError):
"""A rank refused a command because the barrier did not match its state.
Fail-stop, never a silent skip: a rank whose view of the epoch, the
command kind, the configuration revision, or the pending count disagrees
with the coordinator cannot enter another training step without diverging
from its siblings.
"""
@dataclasses.dataclass(frozen=True)
class CommandBarrier:
"""The coordinator's description of one all-rank command.
The same barrier is delivered to every participating rank, and every rank
validates it against its own state before acting on the command. The
epoch is monotonically increasing and owned by the coordinator, so a
repeated, skipped, or reordered barrier is detectable locally by each
rank without a collective.
``config_revision`` is the revision the coordinator believes every rank
holds; it is None when the coordinator has not yet observed a unanimous
revision (during startup), in which case the field is not checked.
``pending`` is the number of iterations that must still be outstanding at
the boundary: 0 for commands that require a quiescent rank (a run may
only be admitted by an idle rank), None for commands that are by nature
asynchronous with respect to the step loop (stop, close).
"""
epoch: int
kind: str
config_revision: int | None = None
pending: int | None = None
def violation(self, *, rank, expected_epoch, kind, config_revision,
pending):
"""Describe why this rank must refuse the barrier, or None."""
if self.epoch != expected_epoch:
return (f"rank {rank} expected command epoch {expected_epoch}, "
f"received epoch {self.epoch} for a {self.kind} command")
if self.kind != kind:
return (f"rank {rank} is executing a {kind} command under "
f"epoch {self.epoch}, which the coordinator issued as "
f"{self.kind}")
if (self.config_revision is not None
and self.config_revision != config_revision):
return (f"rank {rank} holds configuration revision "
f"{config_revision}, but epoch {self.epoch} "
f"({self.kind}) was issued against revision "
f"{self.config_revision}")
if self.pending is not None and self.pending != pending:
return (f"rank {rank} has {pending} iterations pending, but "
f"epoch {self.epoch} ({self.kind}) requires "
f"{self.pending}")
return None
# States rank 0 may occupy alone, as a coordinator sub-operation, while the
# other ranks are idle: only rank 0 publishes outputs, so only it saves the
# autosave/requested checkpoint and exports previews. These are named as
# coordinator work in the published phase instead of being dressed up as a
# collective state the other ranks are not in.
_COORDINATOR_OPERATIONS = {
SessionState.Saving: "saving",
SessionState.ExportingPreview: "exporting preview",
}
@dataclasses.dataclass(frozen=True)
class CollectiveView:
"""What the parent is entitled to publish from the per-rank snapshots.
``visible`` is True only when every participating rank reports the same
command epoch and the same state: that is the one situation in which a
state is a fact about the whole job. Otherwise the last agreed collective
state stands, and the phase says what is actually happening — either a
named coordinator sub-operation, or that the job is waiting for a
specific rank.
"""
state: SessionState
visible: bool
coordinator_operation: str | None = None
laggard: int | None = None
def _laggard_rank(states):
"""The rank whose report is holding the collective state back.
The coordinator's own state is the reference: the first other rank that
disagrees with it is what the job is waiting for. With no coordinator
report yet, the lowest reporting rank stands in.
"""
if not states:
return None
if 0 not in states:
return min(states)
reference = states[0]
return next((rank for rank in sorted(states)
if rank != 0 and states[rank] != reference), None)
def _rank_state(status):
try:
return SessionState(status.get("state"))
except ValueError:
return None
def collective_view(rank_statuses, world_size, last_state):
"""Decide the collective state from per-rank status snapshots."""
last_state = SessionState(last_state)
states = {rank: _rank_state(status)
for rank, status in rank_statuses.items()}
laggard = _laggard_rank(states)
if len(rank_statuses) < world_size or None in states.values():
return CollectiveView(last_state, False, laggard=laggard)
epochs = {int(status.get("command_epoch", 0) or 0)
for status in rank_statuses.values()}
if len(epochs) != 1:
# The ranks are executing different commands. Nothing about their
# states is a collective fact, whatever they happen to say.
epoch_laggard = _laggard_rank(
{rank: int(status.get("command_epoch", 0) or 0)
for rank, status in rank_statuses.items()})
return CollectiveView(last_state, False,
laggard=(laggard if laggard is not None
else epoch_laggard))
distinct = set(states.values())
if len(distinct) == 1:
return CollectiveView(distinct.pop(), True)
coordinator = states.get(0)
others = {state for rank, state in states.items() if rank != 0}
if (coordinator in _COORDINATOR_OPERATIONS
and others == {SessionState.Idle}):
return CollectiveView(
coordinator, False,
coordinator_operation=_COORDINATOR_OPERATIONS[coordinator])
return CollectiveView(last_state, False, laggard=laggard)
class FileStoreRendezvous:
"""A rendezvous whose endpoint the service owns for the session lifetime.
The previous scheme had rank 0 bind port 0, read the assigned port, close
the socket, and hand the number to the workers: between the close and the
workers' bind, any process on the host may take that port, and the
failure surfaces much later as a rendezvous that never completes. A c10d
file store has no such window. The endpoint is a path inside a directory
this object creates under the service's output root and holds until
:meth:`close`, so nothing else can claim it and nothing is left behind.
Only the (picklable) :class:`RendezvousEndpoint` crosses into the worker
processes; the directory stays owned by the parent.
"""
def __init__(self, root=None):
if root is not None:
Path(root).mkdir(parents=True, exist_ok=True)
self._directory = tempfile.mkdtemp(
prefix="spiral-rendezvous-", dir=(str(root) if root else None))
self._endpoint = RendezvousEndpoint(
store_path=str(Path(self._directory) / "c10d-store"))
@property
def directory(self):
return self._directory
@property
def endpoint(self):
return self._endpoint
def close(self):
shutil.rmtree(self._directory, ignore_errors=True)
@dataclasses.dataclass(frozen=True)
class RendezvousEndpoint:
"""The picklable half of a rendezvous: where the ranks meet."""
store_path: str
def external_input_manifest(paths):
"""The session's inputs read outside the object store, by path: the
umbilicus, the Lasagna dataset, fiber directions, winding inference, the
resumed checkpoint and the output roots, with the fit volume they are in
and their provenance. Store objects are identified by the object-store
digest instead."""
return paths.manifest()
class _SessionShutdown(BaseException):
pass
@dataclasses.dataclass
class SessionCommand:
"""One command queued for the fitter thread's pause boundary.
Commands are created by coordinator threads and executed by the fitter
thread while the session is idle between steps. Each one carries the
facts needed to decide, at execution time, whether it is still the
command the caller asked for: the session generation it was queued
against and the iteration/configuration revision it was computed from.
A mismatch cancels the command instead of applying stale work.
``epoch`` fences commands that belong to an all-rank command epoch: they
may only execute in the epoch that queued them, and a mismatch is a
fail-stop rather than a cancellation, because a rank that ran the work of
one epoch inside another has already diverged from its siblings. It is
None for coordinator sub-operations (a rank-0-only checkpoint save),
which do not participate in the epoch sequence at all.
"""
kind: ClassVar[str] = "command"
command_id: str = dataclasses.field(
default_factory=lambda: uuid.uuid4().hex)
session_generation: int = 0
epoch: int | None = None
expected_iteration: int | None = None
expected_config_revision: int | None = None
result: dict[str, Any] = dataclasses.field(default_factory=dict)
error: str | None = None
cancelled: bool = False
done: threading.Event = dataclasses.field(
default_factory=threading.Event, repr=False)
def stale_reason(self, *, session_generation, iteration, config_revision):
if self.session_generation != session_generation:
return (f"{self.kind} command {self.command_id} was queued "
f"against session generation {self.session_generation}, "
f"which is no longer current ({session_generation})")
if (self.expected_iteration is not None
and self.expected_iteration != iteration):
return (f"{self.kind} command {self.command_id} expected "
f"iteration {self.expected_iteration}, found {iteration}")
if (self.expected_config_revision is not None
and self.expected_config_revision != config_revision):
return (f"{self.kind} command {self.command_id} expected "
f"configuration revision {self.expected_config_revision}, "
f"found {config_revision}")
return None
def complete(self, **result):
self.result.update(result)
self.done.set()
def fail(self, error):
self.error = str(error)
self.done.set()
def cancel(self, reason):
self.cancelled = True
self.error = str(reason)
self.done.set()
def wait(self, timeout):
return self.done.wait(timeout)
@dataclasses.dataclass
class ConfigureCommand(SessionCommand):
"""Apply Run-scoped configuration and input path changes."""
kind: ClassVar[str] = "configure"
config: dict[str, Any] = dataclasses.field(default_factory=dict)
path_changes: dict[str, Any] = dataclasses.field(default_factory=dict)
previous_run_config: dict[str, Any] | None = None
@dataclasses.dataclass
class RebuildModelCommand(SessionCommand):
"""Replace the model stage without reloading the session's inputs.
Carries the request the rebuilt session is to be resolved from, because
the whole point is that the configuration changes: the fitter thread
installs these and re-derives its applied configuration from them.
"""
kind: ClassVar[str] = "rebuild_model"
paths: Any = None
run: Any = None
@dataclasses.dataclass
class DtLossScheduleCommand(SessionCommand):
"""Install one Run's transient DT-loss window on the fitter thread."""
kind: ClassVar[str] = "dt_loss_schedule"
run_start: int = 0
requested_iterations: int = 0
schedule: dict[str, Any] = dataclasses.field(default_factory=dict)
@dataclasses.dataclass
class SaveCheckpointCommand(SessionCommand):
"""Write a checkpoint from the fitter thread."""
kind: ClassVar[str] = "save"
path: str = ""
@dataclasses.dataclass
class ExportPreviewCommand(SessionCommand):
"""Export and publish one preview generation, on request.
Previews are no longer a side effect of pausing or of resuming from a
checkpoint: a client that wants one asks for one. Like a checkpoint save
it is a coordinator sub-operation - only the publishing rank exports -
so it carries no command epoch.
"""
kind: ClassVar[str] = "export_preview"
#: Compute the loss overlays alongside the surface. Off by default: they
#: cost about as much as the surface and only the client knows whether
#: anyone is looking at them.
diagnostics: bool = False
automatic: bool = False
@dataclasses.dataclass
class PreflightCheckpointCommand(SessionCommand):
"""Phase 1 of an in-session load: inspect a checkpoint on the CPU.
Reads the file and validates it against this rank's live model; it never
mutates resident state, so a refusal (on this or any sibling rank) leaves
the session exactly as it was. An accepted preflight retains the loaded
CPU payload for the apply command that follows it.
"""
kind: ClassVar[str] = "preflight_checkpoint"
path: str = ""
@dataclasses.dataclass
class ApplyCheckpointCommand(SessionCommand):
"""Phase 2 of an in-session load: move the preflighted payload in.
Issued only after every rank accepted the same checkpoint. A failure here
is fatal to the session rather than a refusal: the model and optimiser
are partially written by then, and a partially loaded optimiser is not a
state any rank may keep training from.
"""
kind: ClassVar[str] = "apply_checkpoint"
path: str = ""
@dataclasses.dataclass
class DiscardCheckpointCommand(SessionCommand):
"""Release a preflighted payload no rank will apply."""
kind: ClassVar[str] = "discard_checkpoint"
@dataclasses.dataclass
class PrepareInputsCommand(SessionCommand):
"""Phase 1 of an input update: read the inputs of a store position
(``position``: the pins' provenance, object_store_inputs.InputPins) and
derive everything they shape on the host (FitContext.load_replacement_inputs
and prepare_input_replacement), touching no resident state. A refusal
here, or on any sibling rank, leaves the fit exactly as it was."""
kind: ClassVar[str] = "prepare_inputs"
position: dict = None
@dataclasses.dataclass
class InstallInputsCommand(SessionCommand):
"""Phase 2 of an input update: release the old inputs' device state and
install the prepared ones (FitContext.replace_inputs). Issued only once
every rank prepared the same inputs. A failure is fatal to the session:
the old inputs are gone by then."""
kind: ClassVar[str] = "install_inputs"
position: dict = None
@dataclasses.dataclass
class PublishInputsCommand(SessionCommand):
"""Phase 3 of an input update: report the installed inputs as the
session's, once every rank installed them."""
kind: ClassVar[str] = "publish_inputs"
@dataclasses.dataclass
class DiscardInputsCommand(SessionCommand):
"""Release prepared inputs no rank will install."""
kind: ClassVar[str] = "discard_inputs"
def merge_session_config(checkpoint, run_config, warn):
"""The configuration a session build resolves before count scaling.
The Python defaults, then the resume ``checkpoint``'s stored ``cfg``
(tolerated, with ``warn`` naming dropped and defaulted keys), the run
window, then the request's known advanced overrides (``warn`` names
unknown ones). Returns ``(config, checkpoint profile config or None,
advanced overrides)``. Shared by the build and by the service's input
preparation, which must refresh links with the configuration the build
will apply.
"""
config = Config().as_dict()
checkpoint_profile_config = None
if checkpoint:
from checkpoint_io import load_checkpoint_cpu
checkpoint_config = load_checkpoint_cpu(checkpoint)
try:
if not isinstance(checkpoint_config, dict) or not isinstance(
checkpoint_config.get('cfg'), Mapping):
raise ValueError("Checkpoint has no current Spiral configuration")
# Dropped and defaulted keys are tolerated and reported;
# only an invalid stored value refuses.
durable, notes = tolerate_config(
checkpoint_config['cfg'], defaults=config)
for note in notes:
warn(f"Checkpoint {checkpoint} {note}")
durable = Config(durable).as_dict()
# Keep the durable configuration aligned with the canonical
# run window. The service restores a newly loaded checkpoint's
# range into this run block; later dock edits remain free to
# choose a narrower or otherwise compatible window.
durable["z_begin"] = int(run_config.z_begin)
durable["z_end"] = int(run_config.z_end)
config.update(durable)
# The session-scoped profile initially reproduces the
# checkpoint without applying scaling twice.
checkpoint_profile_config = copy.deepcopy(durable)
finally:
# This first load exists only to resolve configuration. Do
# not retain a complete model + optimiser checkpoint.
del checkpoint_config
# The canonical run window (the request normally, or the restored
# checkpoint window above) owns the applied configuration and Default
# profile.
config["z_begin"] = int(run_config.z_begin)
config["z_end"] = int(run_config.z_end)
# Saved workspace overrides may outlive any setting in the schema.
# Report and remove unknown keys before applying overrides or counts.
advanced_config = filter_known_config_keys(
run_config.config, config, label="advanced config", warn=warn)
return config, checkpoint_profile_config, advanced_config
class InteractiveFitSession:
def __init__(self, paths: SpiralInputPaths, run: SpiralRunConfig,
preview: SpiralPreviewConfig, scroll: ScrollSpec,
status_callback=None, publishes_outputs=True,
event_callback=None, rank=0, world_size=1, local_rank=0,
rendezvous=None) -> None:
# Who this session is in the job, passed in explicitly by whoever
# spawned it. Nothing here reads RANK/WORLD_SIZE from the
# environment; the DistributedContext is built from these values on
# the fitter thread, where torch is imported.
self.rank = int(rank)
self.world_size = int(world_size)
self.local_rank = int(local_rank)
self._rendezvous = rendezvous
# The world size the fitter thread actually joined, which is what
# per-rank count splitting divides by. Set once the process group
# exists; until then nothing resolves a configuration.
self._dist_world_size = self.world_size
self.paths = paths
self.run_config = run
self.scroll = scroll
self.preview_config = preview
self.input_manifest = external_input_manifest(paths)
# Identity of the frozen object-store inputs once they are loaded:
# their digest, the manifest file the run directory records (rank
# zero) and a summary small enough for every status snapshot.
self._input_digest = None
self._input_position = None
self._input_manifest_path = None
self._input_summary = None
self.requested_config = dict(run.config)
self._applied_config = None
self._run_config = None
self._run_config_limits = None
self._default_advanced_config = None
self._status_callback = status_callback
# Receives (rank, status) pairs; the in-process session is rank 0.
self._event_callback = event_callback
self.publishes_outputs = publishes_outputs
self._condition = threading.Condition()
self._state = SessionState.Loading
self._phase = "Importing fitter"
self._error = None
self._warnings = []
# What the object-store adapter reported while loading the inputs,
# and the problems that refused them (ObjectStoreInputError).
self._input_report = []
self._input_problems = None
self._completed = 0
self._target = 0
self._pending = 0
self._stop_requested = False
self._shutdown = False
self._latest_metrics = {}
self._output_path = paths.output_directory
self._preview_manifest = None
self._preview_generation = 0
self._preview_diagnostics = False
self._preview_source_iteration = None
self._preview_schedule = None
self._next_preview_iteration = None
self._automatic_previews_disabled = False
self._preview_session_id = uuid.uuid4().hex
# The checkpoint the resident model is byte-identical to, or None
# once a step has moved the parameters away from every saved file.
# Set by a save, a load and a resume; the host pairs it with the
# preview it published for the same model state.
self._checkpoint_state = None
# Set by every run; the default matters only for the interval before
# the first one.
self._autosave_on_pause = True
# The FitContext this session owns, set on the fitter thread once the
# device state is built. It doubles as the capability flag for the
# fitter operations (save/preview/apply/configure).
self._context = None
# Commands queued for the fitter thread's pause boundary, oldest
# first. Both the generation and the configuration revision fence
# commands that were computed against a session state the fitter no
# longer has.
self._commands = []
# The CPU-side checkpoint an accepted preflight is holding for the
# apply command that follows it, as (path, payload). Only the fitter
# thread ever reads or writes it.
self._pending_checkpoint = None
# An input update's prepared candidate, (position, candidate), between
# its prepare and install phases; and how many updates were published.
self._pending_inputs = None
self._input_generation = 0
self.session_generation = 0
self._config_revision = 0
# The all-rank command epoch this rank has entered. It advances only
# at an all-rank command barrier, identically on every rank, so two
# ranks reporting the same epoch are executing the same command.
self._command_epoch = 0
# The epoch and configuration revision the current run was admitted
# under. Re-checked at every pause boundary before another step.
self._step_epoch = 0
self._step_config_revision = 0
self.progress = ProgressReporter(
self._progress_changed,
stream=sys.stdout,
)
self._run_start_completed = 0
self._thread = threading.Thread(target=self._fit_main, name="spiral-fit-worker", daemon=True)
self._thread.start()
@property
def completed_iterations(self):
with self._condition:
return self._completed
def status(self):
with self._condition:
result = {
"state": self._state, "phase": self._phase,
"rank": getattr(self, "rank", 0),
"world_size": getattr(self, "world_size", 1),
# Published so the parent can tell "the ranks agree" from
# "the ranks are in different commands" without a collective.
"command_epoch": getattr(self, "_command_epoch", 0),
"config_revision": getattr(self, "_config_revision", 0),
"current_iteration": self._completed,
"target_iteration": self._target,
"session_horizon": None,
"latest_metrics": copy.deepcopy(self._latest_metrics),
"warnings": list(self._warnings), "error": self._error,
"input_report": list(getattr(self, "_input_report", ())),
"input_problems": copy.deepcopy(
getattr(self, "_input_problems", None)),
"preview_manifest_path": self._preview_manifest,
"preview_generation": self._preview_generation,
"preview_source_iteration": getattr(
self, "_preview_source_iteration", None),
"preview_schedule": copy.deepcopy(getattr(
self, "_preview_schedule", None)),
"next_preview_iteration": getattr(
self, "_next_preview_iteration", None),
"automatic_previews_disabled": bool(getattr(
self, "_automatic_previews_disabled", False)),
# Whether this generation carries loss overlays, so the host
# knows whether a diagnostics publication follows the surface.
"preview_diagnostics": getattr(
self, "_preview_diagnostics", False),
"checkpoint_state": copy.deepcopy(
getattr(self, "_checkpoint_state", None)),
"supports_input_incorporation": self._context is not None,
"input_manifest": copy.deepcopy(self.input_manifest),
"input_digest": getattr(self, "_input_digest", None),
"input_generation": getattr(self, "_input_generation", 0),
"input_manifest_path": getattr(self, "_input_manifest_path", None),
"input_summary": copy.deepcopy(getattr(self, "_input_summary", None)),
"progress": self._progress_reporter().snapshot(),
}
if self._run_config is not None:
result["run_config"] = copy.deepcopy(self._run_config)
if self._run_config_limits is not None:
result["run_config_limits"] = copy.deepcopy(
self._run_config_limits)
if self._default_advanced_config is not None:
result["default_advanced_config"] = copy.deepcopy(
self._default_advanced_config)
if getattr(self, "_applied_config", None) is not None:
result["applied_config"] = copy.deepcopy(self._applied_config)
return result
def _publish_status(self):
event_callback = getattr(self, "_event_callback", None)
if self._status_callback is None and event_callback is None:
return
status = self.status()
if event_callback is not None:
try:
event_callback(0, status)
except Exception:
traceback.print_exc(limit=4)
if self._status_callback is not None:
self._status_callback(status)
def _progress_reporter(self):
return getattr(self, "progress", _NO_PROGRESS)
def _progress_changed(self, snapshot):
if snapshot is not None:
with self._condition:
self._phase = str(snapshot.get("stage_name") or self._phase)
self._publish_status()
def _transition_locked(self, new_state, phase="", *, reason=None):
"""Authoritative lifecycle transition; call with the lock held.
Every state change in the session goes through here. An illegal
transition is a programming error in the runtime — the fitter thread
is the only writer and the legal set describes the whole loop — so it
raises instead of silently correcting itself.
"""
new_state = SessionState(new_state)
legal = _LEGAL_SESSION_TRANSITIONS[self._state]
if self._state is not SessionState.Closing:
legal = legal | {SessionState.Error, SessionState.Closing}
if new_state not in legal:
raise RuntimeError(
f"Illegal session transition {self._state.name} -> "
f"{new_state.name}"
+ (f" ({reason})" if reason else ""))
self._state = new_state
self._phase = phase or new_state.value
self._condition.notify_all()
def _transition(self, new_state, phase="", *, reason=None):
"""Transition and publish the resulting status snapshot."""
with self._condition:
self._transition_locked(new_state, phase, reason=reason)
self._publish_status()
def _enter_epoch_locked(self, kind, barrier):
"""Advance this rank's command epoch at an all-rank command boundary.
With a barrier (a rank inside a distributed job) the coordinator's
epoch, command kind, configuration revision, and pending count are
all validated against this rank's own state before the command is
allowed to proceed; a mismatch raises, which fail-stops the rank
rather than letting it run work its siblings are not running.
Without one (a single-rank session, which is its own coordinator) the
session issues the next epoch itself, so the epoch sequence and the
checks that read it work identically in both shapes.
"""
expected = self._command_epoch + 1
if barrier is None:
self._command_epoch = expected
return expected
violation = barrier.violation(
rank=self.rank, expected_epoch=expected, kind=kind,
config_revision=self._config_revision, pending=self._pending)
if violation is not None:
raise CommandBarrierViolation(violation)
self._command_epoch = barrier.epoch
return barrier.epoch
def _check_step_barrier_locked(self):
"""Validate this rank's step preconditions at the pause boundary.
Called immediately before the fitter is released into another
training step. The epoch that admitted the run must still be the
epoch this rank is in, and the configuration revision must be the one
the run's queued configuration command established: a step taken
under any other epoch or revision is a step the other ranks are not
taking.
"""
if self._command_epoch != self._step_epoch:
raise CommandBarrierViolation(
f"rank {self.rank} reached a step boundary in command epoch "
f"{self._command_epoch}, but its run was admitted in epoch "
f"{self._step_epoch}")
if self._config_revision != self._step_config_revision:
raise CommandBarrierViolation(
f"rank {self.rank} reached a step boundary at configuration "
f"revision {self._config_revision}, but its run was admitted "
f"against revision {self._step_config_revision}")
def _fit_main(self):
context = None
dist_context = None
try:
self._progress_reporter().begin("loading", "Importing Torch and fitter")
self._transition(SessionState.Loading, "Importing Torch and fitter")
import fit_spiral as fitter
from ddp_helpers import (DistributedContext,
maybe_destroy_distributed,
maybe_init_distributed)
# Explicit identity, and an endpoint the parent owns for the whole
# session: no environment lookup and no free-port race.
dist_context = maybe_init_distributed(
DistributedContext(rank=self.rank,
world_size=self.world_size,
local_rank=self.local_rank),
rendezvous=self._rendezvous)
self._dist_world_size = dist_context.world_size
config = self._resolve_session_config(self._dist_world_size)
self._progress_reporter().begin("loading", "Loading fit inputs and model")
self._transition(SessionState.Loading, "Loading fit inputs and model")
# The scroll is used exactly as the session's resolved inputs
# state it: its physical facts (name, resolution, outward sense)
# and its Lasagna scale. A session request may not restate
# any of them, so there is nothing left here to override.
scroll = self.scroll
# The runtime is the execution owner of the context: it constructs
# it, drives every phase on this fitter thread, and closes it.
# Configuration, the scroll facts, the resolved input paths, and
# the fit controls (resume, output directory, run tag) are passed
# explicitly; fit_spiral holds no module-global input state.
context = fitter.FitContext(
FitConfig(config),
scroll=scroll,
paths=self.paths,
interactive_driver=self,
progress=self.progress,
resume_path=self.paths.checkpoint or None,
out_base_dir=self.paths.output_directory,
run_tag=self.run_config.run_tag or None,
cache_dir=self.paths.cache_directory,
storage_backend=self.run_config.storage_backend,
render_volume_scale=self.run_config.render_volume_scale,
dist_context=dist_context,
input_log=self._input_line,
# A service fits from the packs its cache holds.
build_lasagna_packs=False)
context.check_cuda_ready()
context.load_host_inputs()
context.prepare_output()
context.build_device_state()
context.release_setup_only_tracks()
self._session_ready(context)
self._optimize(context)
except BaseException as exc:
from object_store_inputs import ObjectStoreInputError
with self._condition:
if self._shutdown and isinstance(exc, _SessionShutdown):
self._transition_locked(SessionState.Closing, "Stopped")
else:
if isinstance(exc, ObjectStoreInputError):
# Every refused input and refresh failure, including
# what detectors committed before failing.
self._input_problems = list(exc.problems)
self._error = f"{type(exc).__name__}: {exc}"
self._warnings.append(traceback.format_exc(limit=12))
self._transition_locked(SessionState.Error, "Error")
self._publish_status()
finally:
with self._condition:
# Resolve waiters: no queued command can run any more.
for command in self._commands:
command.fail(self._error or "Resident fitter closed")
self._commands.clear()
self._condition.notify_all()
if context is not None:
# Resource release runs here, on the owning fitter thread.
context.close()
if dist_context is not None:
maybe_destroy_distributed(dist_context)
self._progress_reporter().close()
def _input_line(self, line):
"""One line of the object-store adapter's report: printed as the CLI
prints it, kept in the status and shown as loading progress."""
print(line, flush=True)
with self._condition:
if len(self._input_report) < MAX_INPUT_REPORT_LINES:
self._input_report.append(str(line))
self._progress_reporter().pulse(detail=str(line))
self._publish_status()
def _resolve_session_config(self, world_size):
"""Resolve this session's fitter configuration and publish it.
The one place a session's applied configuration is derived, so a
model-stage rebuild reproduces exactly the chain the initial build
ran: the Python defaults, the resume checkpoint's stored ``cfg``, the
request's advanced overrides, z-range/DDP count scaling, and finally
the explicit sample counts that must not be scaled twice. It reads
``self.paths`` and ``self.run_config``, so replacing either and
calling it again is all a rebuild needs.
"""
from spiral_helpers import scale_and_split_counts
def warn(warning):
print(warning)
with self._condition:
if warning not in self._warnings:
self._warnings.append(warning)
if self.paths.checkpoint:
self._progress_reporter().begin(
"loading", "Reading checkpoint configuration",
detail=Path(self.paths.checkpoint).name)
config, checkpoint_profile_config, advanced_config = merge_session_config(
self.paths.checkpoint, self.run_config, warn)
if checkpoint_profile_config is not None:
default_advanced_config = checkpoint_profile_config
else:
# Without a checkpoint, Default is the Python baseline.
default_advanced_config = copy.deepcopy(config)
# Explicit sample-count overrides are literal active counts. This
# lets VC3D round-trip the host's post-scaling values through a
# reload without applying the z-range/DDP transforms twice.
# Checkpoint cfg values are resolved fitter values too, so give
# their counts the same treatment.
explicit_sampling_counts = {
key: value for key, value in (checkpoint_profile_config or {}).items()
if key.startswith("sample_count_")
}
explicit_sampling_counts.update({
key: value for key, value in advanced_config.items()
if key.startswith("sample_count_")