File size: 21,179 Bytes
45f1d40 8165eae 45f1d40 6a668e6 45f1d40 ef6b357 45f1d40 7995d55 45f1d40 b91f46c 45f1d40 bba8c7f 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 7995d55 e3d95bd 7995d55 45f1d40 7995d55 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 8165eae e3d95bd 8165eae e3d95bd 8165eae e3d95bd 8165eae 45f1d40 e3d95bd 45f1d40 e3d95bd 45f1d40 7995d55 45f1d40 1f22abe 7995d55 1f22abe 7995d55 1f22abe 7995d55 1f22abe b856632 1f22abe b856632 1f22abe 7995d55 1f22abe 7995d55 1f22abe 7995d55 1f22abe 45f1d40 1f22abe | 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 | # Phase 4 rehearsal on a CPU instance: every check a session can fail, run where failing costs no quota.
#
# The first GPU session of the run bills ~6.5 hours before it is provably healthy, and E-031/E-034 are both
# exactly the shape of bug that only shows up mid-run or at the very first launch. So this kernel does, on
# free CPU: fetches the pinned code and asserts its sha256s, resolves credentials, reads the checkpoint
# pointer, downloads the PUBLISHED mix from the Hub, hashes every shard against the manifest, rehearses the
# session planner across the whole schedule, and proves resume-equivalence of the reader at the boundaries
# the run actually crosses.
#
# It ends with the same PLAN_JSON the GPU session will print, so the numbers can be compared before and
# after rather than trusting that the code did not change in between.
import hashlib, json, os, subprocess, sys, time
os.chdir("/kaggle/working")
sys.path.insert(0, "/kaggle/working")
REV = os.environ.get("OUNCE100M_REV") or "c237b478076f68da43dabd3403daa5639286899e"
WANT = {
"ounce100m_credentials.py": ("ounce100m_credentials.py",
"6525f62f03f2d73650a1eb4f70fcb52d1194caad4ca88b2d8bd8fd54f88339b6"),
"shard_dataset.py": ("train/shard_dataset.py",
"f35653bf4c8f2cfe7bb0c2c7835e308505fb5a84b4eb002d0f82f2cada768ca6"),
"hubckpt.py": ("train/hubckpt.py",
"d4b50ed0928c678c94ce6764612a4655f2959c0a7b91d6fd588e8efddff298b2"),
"train_ounce100m.py": ("train/train_ounce100m.py",
"dcb0ac199c0616575423ddea0d6b76d2257c06089d3f73aae0e0359de958ff03"),
"phase4_session.py": ("kernels/phase4_session.py",
"5fb14bc3597d999a21772a66b6f409f01f722019dc78da313feddb2f9c769c48"),
}
BASE = "https://huggingface.co/Cion-lab/ounce100m-code/resolve/" + REV
for p, (rp, want) in sorted(WANT.items()):
r = subprocess.run(["curl", "-sfL", BASE + "/" + rp, "-o", p], capture_output=True, text=True)
assert r.returncode == 0, ("fetch failed", rp, (r.stderr or "")[:200])
got = hashlib.sha256(open(p, "rb").read()).hexdigest()
assert got == want, ("SHA MISMATCH", rp, got, want)
print("OK", p, got[:12], flush=True)
print("REV_OK", REV, flush=True)
import ounce100m_credentials as C
print("creds:", json.dumps(C.install(verify=True)), flush=True)
T0 = time.time()
def run(argv, label, timeout, env=None):
print("=== " + label, flush=True)
t0 = time.time()
e = dict(os.environ); e["PYTHONPATH"] = "/kaggle/working"
e.update(env or {})
import signal as _sig
import threading
p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
env=e, bufsize=1, start_new_session=True)
killed = []
def _kill():
# Kill the group, not the child: a staged `python -c snapshot_download` orphaned by a timeout
# keeps writing into mixroot while the next stage reads it.
killed.append(True)
try:
os.killpg(os.getpgid(p.pid), _sig.SIGTERM)
except Exception:
p.kill()
timer = threading.Timer(timeout, _kill)
timer.daemon = True
timer.start()
out = []
try:
for line in p.stdout:
out.append(line.rstrip("\n"))
print(" |", line[:300], flush=True)
finally:
timer.cancel()
rc = p.wait()
if killed:
print(" TIMEOUT after %d s, killed" % timeout, flush=True)
rc = -9
print("%s_RC %s seconds %.1f elapsed %.0f" % (label, rc, time.time() - t0, time.time() - T0),
flush=True)
return rc, "\n".join(out)
# Stage 1 -- the launcher itself, in rehearsal mode. On a CPU box torchrun is never reached: PREP_ONLY
# stops right after the pointer read, the mix download and the planner. Everything upstream of the first
# optimiser step is exercised with the real files and the real Hub.
PLANENV = {"PLANNING_TOK_PER_S": os.environ.get("PLANNING_TOK_PER_S", "9358"),
"SESSION_GPU_HOURS": os.environ.get("SESSION_GPU_HOURS", "6.9"),
"QUOTA_LEFT_HOURS": os.environ.get("QUOTA_LEFT_HOURS", "26.6"),
"MIN_FREE_GB": "6"}
rc1, out1 = run([sys.executable, "phase4_session.py", REV], "PREP_LAUNCHER", 3000,
env=dict(PLANENV, PHASE4_PREP_ONLY="1"))
if rc1 != 0:
print("VERDICT PREP_STOP launcher rehearsal rc", rc1, flush=True)
raise SystemExit(2)
# The launcher prints the argument list it *would* hand to torchrun, so stage 3 can parse exactly those
# flags against the pinned trainer. Duplicating them here would test a copy rather than the real thing.
ARGV_LINE = next((l for l in (out1 or "").splitlines() if l.startswith("TRAIN_ARGV ")), None)
assert ARGV_LINE, ("PREP_STOP: the launcher printed no TRAIN_ARGV -- its torchrun command line is "
"untested, which is how E-037 reached a billed session")
json.dump(json.loads(ARGV_LINE[len("TRAIN_ARGV "):]), open("train_argv.json", "w"))
print("TRAIN_ARGV captured", len(json.loads(ARGV_LINE[len("TRAIN_ARGV "):])), "tokens", flush=True)
# Stage 2 -- the published mix, byte for byte, and the reader at the boundaries the run will actually
# cross. `snapshot_download` proves the repo is anonymously readable; this proves the bytes are the ones
# the manifest certifies and that a resume at step X reads the same data an uninterrupted run would have.
CHECK = """
import hashlib, json, os, sys
sys.path.insert(0, "/kaggle/working")
ROOT = "/kaggle/working/mixroot"
man = json.load(open(os.path.join(ROOT, "manifest.json")))
tok_s = int(man["total_tokens"])
bad, nb = [], 0
for grp in ("shards", "val_shards"):
for rec in man.get(grp, []):
p = os.path.join(ROOT, rec["file"])
if not os.path.exists(p):
bad.append((rec["file"], "absent")); continue
h = hashlib.sha256()
with open(p, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 22), b""):
h.update(chunk)
nb += 1
if h.hexdigest() != rec["sha256"]:
bad.append((rec["file"], "sha " + h.hexdigest()[:12] + " != " + rec["sha256"][:12]))
print("SHASHED", nb, "files", "total_tokens", format(tok_s, ","), "mismatches", len(bad), flush=True)
for b in bad[:10]:
print(" BAD", b, flush=True)
assert not bad, "the published mix does not match its own manifest"
import phase4_session as S # constants and plan() only -- main() is behind __name__
import shard_dataset as SD
SEQ, steps = S.SEQ_LEN, S.HORIZON_STEPS
seqs_per_step = S.MICRO_BATCH * S.WORLD * S.ACCUM # the launcher's own geometry, not retyped
tps = seqs_per_step * SEQ
assert tps == S.TOKENS_PER_STEP, f"prep disagrees with the launcher about tokens/step: {tps}"
assert steps == int(S.TOKENS / tps), f"horizon disagrees: {steps}"
store = SD.PackedTokenStore(ROOT)
n_samples = store.total_tokens // SEQ
fingerprint = SD.mix_fingerprint(ROOT, man)
# The manifest is the corpus definition, and three different things have to agree about it: the record
# list, the counts it claims, and the bytes the reader actually mapped. A rebuild that truncated the list
# while leaving total_tokens alone would otherwise certify 1.11 B tokens of a smaller corpus (review).
if len(man["shards"]) != int(man.get("n_shards") or -1) or len(store.shards) != len(man["shards"]):
raise AssertionError(f"shard records {len(man['shards'])} vs n_shards {man.get('n_shards')} vs "
f"files opened {len(store.shards)}")
summed = sum(int(s["tokens"]) for s in man["shards"])
if summed != tok_s or store.total_tokens != summed:
raise AssertionError(f"manifest does not add up: records sum {summed}, total_tokens says {tok_s}, "
f"bytes the reader mapped say {store.total_tokens}")
print("READER", format(store.total_tokens, ","), "tokens in", len(store.shards), "shards ->",
format(n_samples, ","), "windows of", SEQ, "|", steps, "steps x", tps, "tok =",
format(steps * tps, ","), "tokens | fingerprint", fingerprint, flush=True)
assert n_samples >= steps * seqs_per_step, "not enough windows for the horizon"
# The schedule the run will actually live through: drive the real planner from step 0 until it reports the
# horizon, and require every leg to start where the previous one stopped. This is the check that catches a
# planner which stops one interval short of the end -- the last four steps of the run, unreachable, while
# every later session fails its own budget test for want of a whole interval.
legs, start = [], 0
while start < steps and len(legs) < 40:
pl = S.plan(start, 15.0)
if not pl["planned_steps"]:
raise AssertionError(f"the planner refuses to start at step {start} with "
f"{pl['usable_hours']} usable hours -- the run can never finish")
end = steps if pl["stop_after_steps"] == 0 else pl["stop_after_steps"]
assert end > start, (start, end)
need = (steps - start) * seqs_per_step # the trainer's own guard, at this start step
avail = n_samples - start * seqs_per_step
assert avail >= need, (start, need, avail)
store.tokens((end * seqs_per_step - 1) * SEQ, SEQ) # last window this leg reads must be addressable
legs.append((start, end))
print("LEG %5d -> %5d pct %5.1f -> %5.1f need %9d avail %9d" % (
start, end, 100.0 * start / steps, 100.0 * end / steps, need, avail), flush=True)
start = end
print("SESSIONS", len(legs), "ends_at", legs[-1][1], "horizon", steps, "contiguous",
[l[0] for l in legs[1:]] == [l[1] for l in legs[:-1]], flush=True)
assert legs[-1][1] == steps and [l[0] for l in legs[1:]] == [l[1] for l in legs[:-1]], legs
# Resume-equivalence through the cursor the trainer would actually write, at boundaries the run crosses:
# the first checkpoint, a mid-session one, and the last interval before the horizon. Building the resumed
# dataset from load_cursor() rather than a hand-computed offset is the point -- the derivation from
# step to samples_consumed is part of what is being tested.
full = SD.make_dataset(store, SEQ, 20260919, start_sample=0)
order = SD.perm_sha(n_samples, 20260919)
assert full.order_sha == order, "two derivations of the order identity disagree"
for boundary in sorted({S.CKPT_EVERY, legs[len(legs) // 2][0], steps - S.CKPT_EVERY}):
d = "/tmp/cur_%d" % boundary
os.makedirs(d, exist_ok=True)
SD.save_cursor(d, SD.cursor_dict(SEQ, boundary * seqs_per_step, 20260919, fingerprint,
step=boundary, order_sha=order))
c = SD.load_cursor(d)
assert c["step"] * seqs_per_step == c["samples_consumed"], c
assert c["dataset_files_sha"] == fingerprint, c
assert c["shuffle_perm_sha"] == order, c
s0 = c["samples_consumed"]
resumed = SD.make_dataset(store, SEQ, c["shuffle_seed"], start_sample=s0)
assert resumed.order_sha == order, "a resume lost the visit order"
assert len(resumed) == len(full) - s0, (len(resumed), len(full), s0)
same = True
for j in (0, 1, len(resumed) // 2, len(resumed) - 1):
a, b = full[s0 + j], resumed[j]
same = same and a["input_ids"].tolist() == b["input_ids"].tolist() \
and a["labels"].tolist() == b["labels"].tolist()
print("RESUME_EQUIV step", boundary, "start_sample", s0, "len", len(resumed), "identical", same,
flush=True)
assert same, boundary
print("CHECK_OK windows", format(n_samples, ","), "shards", len(store.shards), flush=True)
"""
open("prepcheck.py", "w").write(CHECK)
rc2, _ = run([sys.executable, "prepcheck.py"], "CHECK_PUBLISHED_MIX", 2400, env=dict(PLANENV))
# Stage 3 -- the reader's cursor format round-trips, and the trainer imports and prints --help on this
# image. Both are cheap and both have bitten before (E-028: a build script that could not import its
# sibling module surfaced only after a GPU session had already been billed).
rc3, _ = run([sys.executable, "-c",
"import sys; sys.path.insert(0, '/kaggle/working')\n"
"import shard_dataset as SD, json, os\n"
"os.makedirs('/tmp/cur', exist_ok=True)\n"
"c = SD.cursor_dict(1024, 97536, 20260919, dataset_files_sha='ab' * 32, step=381)\n"
"SD.save_cursor('/tmp/cur', c)\n"
"back = SD.load_cursor('/tmp/cur')\n"
"print('CURSOR', json.dumps(back), 'roundtrip', back == c)\n"
"assert back == c\n"
"import subprocess\n"
"p = subprocess.run([sys.executable, 'train_ounce100m.py', '--help'],\n"
" capture_output=True, text=True)\n"
"print('TRAINER_HELP_RC', p.returncode, 'lines', len((p.stdout or '').splitlines()))\n"
"assert p.returncode == 0, (p.stdout or '')[-1500:] + (p.stderr or '')[-1500:]\n"
# The launcher's own flag list, parsed by the pinned trainer. argparse validates every
# option it has already consumed by the time it reaches a trailing --help, so rc 0 here
# means the real session command line is well-formed -- and rc 2 names the flag that is not.
"argv = json.load(open('/kaggle/working/train_argv.json'))\n"
"p2 = subprocess.run([sys.executable, 'train_ounce100m.py'] + argv[1:] + ['--help'],\n"
" capture_output=True, text=True)\n"
"out2 = (p2.stdout or '') + (p2.stderr or '')\n"
"print('LAUNCHER_ARGV_RC', p2.returncode, 'flags',\n"
" sum(1 for a in argv if a.startswith('--')))\n"
"assert p2.returncode == 0, out2[-1200:]\n"],
"CURSOR_AND_IMPORT", 900)
# Stage 4 -- checkpoint verification against real Hub bytes, on the theory that a gate which authorises
# deleting the only local copy of a checkpoint should be tested where failing is free. Probe v5 measured
# the old whole-object read-back at `push+verify=797.7s` for an 850 MB optimizer.pt (and 26 s for the same
# cycle once the CDN was warm), so the read-back is now two bounded Range requests; this proves the
# replacement still says yes to the 1.27 GB the probe really pushed and still says no three different ways.
# PREP_VERIFY_REPO exists because the probe repos are scratch: point it at `Cion-lab/ounce100m-ckpt` plus a
# `ckpt/checkpoint-N` once the run has one and this stage outlives them.
VERIFY = """
import json, os, sys, time
sys.path.insert(0, "/kaggle/working")
import hubckpt
from huggingface_hub import snapshot_download
REPO = os.environ.get("PREP_VERIFY_REPO") or "Cion-lab/ounce100m-ckpt-probe-0920-0747"
SUB = os.environ.get("PREP_VERIFY_SUB") or "ckpt/checkpoint-10"
d = "/kaggle/working/verify_subject"
t0 = time.time()
snapshot_download(repo_id=REPO, repo_type="dataset", allow_patterns=SUB + "/*", local_dir=d,
max_workers=4)
local = os.path.join(d, SUB)
print("GOT", len(os.listdir(local)), "files from", REPO, SUB, "in", round(time.time() - t0, 1), "s",
flush=True)
assert os.path.exists(os.path.join(local, "model.safetensors")), sorted(os.listdir(local))
t0 = time.time()
v = hubckpt.verify_checkpoint(REPO, local, SUB, repo_type="dataset")
secs = round(time.time() - t0, 1)
print("VERIFY_CLEAN ok", v["ok"], "readback_ok", v["readback_ok"], "seconds", secs,
json.dumps({k: v[k] for k in ("n_local", "n_hub", "missing", "mismatch", "extra", "readback")},
default=str)[:600], flush=True)
assert v["ok"] and v["readback_ok"], v
assert secs < 180, f"verification took {secs}s -- the bounded read-back was supposed to make this cheap"
rb = v["readback"]
assert rb["path"] == "optimizer.pt" and rb["bytes"] < (10 << 20) and len(rb["ranges"]) == 2, rb
# 1. A byte the Hub does not have, flipped INSIDE the read-back window, so the byte compare itself is what
# catches it (the oid compare would too, but only the compare proves the served offset is ours).
p = os.path.join(local, "optimizer.pt")
size = hubckpt.hub_listing(REPO, "dataset")[SUB + "/optimizer.pt"][0]
print("SUBJECT", size, "bytes; ranges", [[0, (4 << 20) - 1], [size // 2, size // 2 + (4 << 20) - 1]],
flush=True)
def tamper(offset):
with open(p, "r+b") as fh:
fh.seek(offset)
b = fh.read(1)
fh.seek(offset)
fh.write(bytes([b[0] ^ 0xFF]))
return b, offset
def restore(b, offset):
with open(p, "r+b") as fh:
fh.seek(offset)
fh.write(b)
b, off = tamper(12345)
v2 = hubckpt.verify_checkpoint(REPO, local, SUB, repo_type="dataset")
print("VERIFY_TAMPERED_IN_RANGE ok", v2["ok"], "readback",
json.dumps(v2["readback"].get("error", ""))[:200], flush=True)
assert not v2["ok"], "a flipped byte at 12,345 verified clean"
assert "differ from the local file" in str(v2["readback"]["error"]), v2["readback"]
restore(b, off)
b, off = tamper(500_000_001) # outside both windows: only the stored-oid compare can see this
v2b = hubckpt.verify_checkpoint(REPO, local, SUB, repo_type="dataset")
print("VERIFY_TAMPERED_OUTSIDE ok", v2b["ok"], "mismatch", json.dumps(v2b["mismatch"])[:200],
"readback_ok", v2b["readback_ok"], flush=True)
assert not v2b["ok"] and v2b["mismatch"] and v2b["readback_ok"], "the oid compare did not see it"
restore(b, off)
assert hubckpt.verify_checkpoint(REPO, local, SUB, repo_type="dataset")["ok"], "restoring did not help"
# 2. A path that is not on the Hub at all: nothing listed means nothing verified. `missing` is capped at
# ten entries because it is a log field, so the uncapped fact (`n_hub`) is what the assertion uses --
# rehearsing this stage caught that, not a defect in the verifier.
v3 = hubckpt.verify_checkpoint(REPO, local, "ckpt/checkpoint-99999999", repo_type="dataset")
print("VERIFY_ABSENT ok", v3["ok"], "n_hub", v3["n_hub"], "missing(shown)", len(v3["missing"]),
"of n_local", v3["n_local"], flush=True)
assert not v3["ok"] and v3["n_hub"] == 0 and v3["missing"], v3
# 3. Three dishonest servers, each caught by a different clause of the bounded read-back.
class _Resp:
def __init__(self, status, body, crange):
self.status, self._b, self._cr = status, body, crange
self.headers = {} if crange is None else {"Content-Range": crange}
def __enter__(self): return self
def __exit__(self, *a): return False
def read(self, n=-1): return self._b if n is None or n < 0 else self._b[:n]
def _fake(kind):
def f(req, *a, **k):
if "/resolve/" not in getattr(req, "full_url", str(req)):
return real(req, *a, **k) # hub_listing shares urlopen; only the download is faked
rng = req.get_header("Range") or ""
lo, _, hi = rng.split("=")[-1].partition("-")
n = int(hi) - int(lo) + 1 if lo.isdigit() and hi.isdigit() else 0
if kind == "ignores_range": # 200 with a short body and no Content-Range at all
return _Resp(200, b"x" * max(n - 1, 0), None)
if kind == "wrong_span": # 206, exactly the bytes asked for, from the wrong offset
return _Resp(206, b"y" * n, f"bytes 0-{n - 1}/{n}")
return _Resp(206, bytes(n), f"bytes {lo}-{hi}/{size}") # right span, someone else's bytes
return f
real = hubckpt.urllib.request.urlopen
for kind, needle in (("ignores_range", "expected 206"), ("wrong_span", "Content-Range"),
("wrong_bytes", "differ from the local file")):
hubckpt.urllib.request.urlopen = _fake(kind)
try:
vx = hubckpt.verify_checkpoint(REPO, local, SUB, repo_type="dataset")
finally:
hubckpt.urllib.request.urlopen = real
print("VERIFY_" + kind.upper(), "ok", vx["ok"], json.dumps(vx["readback"].get("error"))[:190],
flush=True)
assert not vx["ok"] and needle in str(vx["readback"]["error"]), (kind, vx["readback"])
# 4. The deletion guard itself (E-042): an unverified result must leave the directory alone.
d2 = "/tmp/prune_verified_probe"
os.makedirs(d2, exist_ok=True)
open(os.path.join(d2, "model.safetensors"), "wb").write(b"z" * 1024)
try:
hubckpt.prune_verified(d2, {"verify": {"ok": False, "missing": ["a"], "mismatch": [],
"extra": [], "readback_ok": False}}, min_free_gb=0.0)
raise AssertionError("prune_verified deleted an unverified checkpoint")
except RuntimeError as e:
print("PRUNE_BLOCKED", str(e)[:150], "dir_still_there", os.path.isdir(d2), flush=True)
assert os.path.isdir(d2), "the copy was deleted anyway"
hubckpt.prune_verified(d2, {"verify": {"ok": True, "missing": [], "mismatch": [], "extra": [],
"readback_ok": True}}, min_free_gb=0.0)
assert not os.path.isdir(d2), "a verified checkpoint was not pruned"
print("VERIFY_STAGE_OK clean, two tampers, absent, three dishonest servers, prune guard", flush=True)
"""
open("prepverify.py", "w").write(VERIFY)
rc4, _ = run([sys.executable, "prepverify.py"], "CHECK_VERIFY", 1800, env=dict(PLANENV))
print("VERDICT PHASE4_PREP rc_launcher", rc1, "rc_check", rc2, "rc_cursor", rc3, "rc_verify", rc4,
"seconds", round(time.time() - T0, 1), flush=True)
raise SystemExit(0 if (rc1 == rc2 == rc3 == rc4 == 0) else 4)
|