asterocr 1.0.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.
@@ -0,0 +1,1911 @@
1
+ """
2
+ AsterOCR Kaggle Cloud Worker (Self-Contained)
3
+ Runs on NVIDIA Tesla P100 (16GB VRAM) in Kaggle Cloud.
4
+ Streams chunks from local machine via Cloudflare Tunnel, trains with FP16 Mixed Precision,
5
+ streams real-time telemetry back to local Web GUI, and syncs checkpoints.
6
+ """
7
+
8
+ import os
9
+ import io
10
+ import sys
11
+ import time
12
+ import json
13
+ import struct
14
+ import base64
15
+ import gc
16
+ import queue
17
+ import threading
18
+ import concurrent.futures
19
+ import requests
20
+ from PIL import Image
21
+ import numpy as np
22
+ import math
23
+
24
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
25
+
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.optim as optim
29
+ from torch.nn import functional as F
30
+ from torch.utils.data import Dataset, DataLoader
31
+ import numpy as np
32
+ import base64
33
+ from io import BytesIO
34
+ from PIL import Image
35
+
36
+ def tensor_to_b64(tensor):
37
+ try:
38
+ arr = (tensor.cpu().numpy().transpose(1, 2, 0) * 255).astype(np.uint8)
39
+ if arr.shape[2] == 1:
40
+ arr = np.squeeze(arr, axis=2)
41
+ img = Image.fromarray(arr)
42
+ buffered = BytesIO()
43
+ img.save(buffered, format="JPEG", quality=60)
44
+ return "data:image/jpeg;base64," + base64.b64encode(buffered.getvalue()).decode('utf-8')
45
+ except Exception:
46
+ return ""
47
+
48
+ import torch.distributed as dist
49
+ import torch.multiprocessing as mp
50
+
51
+
52
+ # =========================================================================
53
+ # 1. RUNTIME CONFIGURATION (Injected dynamically by launcher)
54
+ IS_TPU_DEVICE = False
55
+ TUNNEL_URL = "https://asterai.sglocalhub.com" # Live Cloudflare Named Tunnel endpoint (Permanent)
56
+ SESSION_TOKEN = "aster_token_default"
57
+ BATCH_SIZE = 96 # 96 samples per GPU -> 192 total across Dual Tesla T4 (High Tensor Core saturation + 100% OOM free with dynamic padding)
58
+ LEARNING_RATE = 0.00005 # Optimal Fine-Tuning LR (Protects pre-trained features while mastering hard samples)
59
+ TARGET_HEIGHT = 32
60
+ MAX_EPOCHS = 5
61
+
62
+ def compute_scheduled_lr(base_lr, cur_epoch, tot_epochs=5):
63
+ """
64
+ Automatic Multi-Epoch Learning Rate Decay (Fine-Tuning Annealer):
65
+ - Epoch 1: 1.0x (0.000050 - Nắm bắt phân phối ảnh scan tiếng Việt mới)
66
+ - Epoch 2: 0.8x (0.000040 - Tinh chỉnh các đặc trưng nét chữ & hoa văn nền)
67
+ - Epoch 3: 0.5x (0.000025 - Ổn định và giảm nhiễu)
68
+ - Epoch 4: 0.3x (0.000015 - Hội tụ chi tiết số CCCD, tiền tệ)
69
+ - Epoch 5: 0.15x (0.000008 - Cực tiểu hóa toàn cục)
70
+ """
71
+ factors = {
72
+ 1: 1.0,
73
+ 2: 0.8,
74
+ 3: 0.5,
75
+ 4: 0.3,
76
+ 5: 0.15
77
+ }
78
+ ratio = factors.get(cur_epoch, 0.5 ** max(0, cur_epoch - 1))
79
+ return round(base_lr * ratio, 7)
80
+
81
+ # =========================================================================
82
+ # 2. VIETNAMESE VOCABULARY & ALPHABET
83
+ # =========================================================================
84
+ VIETNAMESE_LOWER = (
85
+ "aàáảãạăằắẳẵặâầấẩẫậ"
86
+ "bcdđeèéẻẽẹêềếểễệ"
87
+ "fghiìíỉĩịjklmnoòóỏõọôồốổỗộơờớởỡợ"
88
+ "pqrstuùúủũụưừứửữự"
89
+ "vwxyỳýỷỹỵz"
90
+ )
91
+ VIETNAMESE_UPPER = (
92
+ "AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬ"
93
+ "BCDĐEÈÉẺẼẸÊỀẾỂỄỆ"
94
+ "FGHIÌÍỈĨỊJKLMNOÒÓỎÕỌÔỒỐỔỖỘƠỜỚỞỠỢ"
95
+ "PQRSTUÙÚỦŨỤƯỪỨỬỮỰ"
96
+ "VWXYỲÝỶỸỴZ"
97
+ )
98
+ DIGITS = "0123456789"
99
+ PUNCTUATION = " ,.-:;\"'?!()[]{}/%+$&=*#@_\\<>°"
100
+ CTC_BLANK = "<blank>"
101
+ VOCAB_CHARS = [CTC_BLANK] + list(dict.fromkeys(list(VIETNAMESE_LOWER + VIETNAMESE_UPPER + DIGITS + PUNCTUATION)))
102
+ CHAR2IDX = {c: i for i, c in enumerate(VOCAB_CHARS)}
103
+ BLANK_IDX = 0
104
+
105
+ UNICODE_NORM_MAP = {
106
+ '“': '"', '”': '"', '„': '"', '«': '"', '»': '"',
107
+ '’': "'", '‘': "'", '`': "'", '´': "'",
108
+ '–': '-', '—': '-', '−': '-', '‒': '-',
109
+ '…': '...', '•': '.',
110
+ '²': '2', '³': '3', '¹': '1',
111
+ '±': '+', '÷': '/', '×': '*',
112
+ '€': '$', '£': '$', '¥': '$',
113
+ 'ā': 'a', 'Ā': 'A', 'ū': 'u', 'Ū': 'U', 'ō': 'o', 'Ō': 'O',
114
+ 'ü': 'u', 'Ü': 'U', 'ð': 'd', 'Ð': 'D',
115
+ '|': '/'
116
+ }
117
+
118
+ def normalize_text(text):
119
+ if not text:
120
+ return ""
121
+ res = []
122
+ for c in text:
123
+ res.append(UNICODE_NORM_MAP.get(c, c))
124
+ return "".join(res)
125
+
126
+ def encode_text(text):
127
+ norm_text = normalize_text(text)
128
+ return [CHAR2IDX[c] for c in norm_text if c in CHAR2IDX]
129
+
130
+ def calculate_cer(reference, hypothesis):
131
+ import editdistance
132
+ total_dist = 0
133
+ total_len = 0
134
+ for r, h in zip(reference, hypothesis):
135
+ total_dist += editdistance.eval(r, h)
136
+ total_len += max(1, len(r))
137
+ return total_dist / max(1, total_len)
138
+
139
+ # =========================================================================
140
+ # 3. NEURAL NETWORK ARCHITECTURE: AsterNet (ResNet-SVTR + BiLSTM + CTC)
141
+ # =========================================================================
142
+ class SqueezeExcitation(nn.Module):
143
+ def __init__(self, channels, reduction=16):
144
+ super().__init__()
145
+ self.avg_pool = nn.AdaptiveAvgPool2d(1)
146
+ mid = max(8, channels // reduction)
147
+ self.fc = nn.Sequential(
148
+ nn.Linear(channels, mid, bias=False),
149
+ nn.SiLU(inplace=True),
150
+ nn.Linear(mid, channels, bias=False),
151
+ nn.Sigmoid()
152
+ )
153
+ def forward(self, x):
154
+ b, c, _, _ = x.size()
155
+ y = self.fc(self.avg_pool(x).view(b, c)).view(b, c, 1, 1)
156
+ return x * y.expand_as(x)
157
+
158
+ class HighFrequencyStem(nn.Module):
159
+ def __init__(self, in_channels=3, out_channels=64):
160
+ super().__init__()
161
+ self.conv_main = nn.Sequential(
162
+ nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False),
163
+ nn.BatchNorm2d(out_channels),
164
+ nn.SiLU(inplace=True)
165
+ )
166
+ lap = torch.tensor([[0., -1., 0.], [-1., 4., -1.], [0., -1., 0.]]).view(1, 1, 3, 3)
167
+ self.register_buffer('hp_kernel', lap.repeat(in_channels, 1, 1, 1))
168
+ self.hp_proj = nn.Sequential(
169
+ nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
170
+ nn.BatchNorm2d(out_channels)
171
+ )
172
+ self.gamma = nn.Parameter(torch.tensor([0.15]))
173
+
174
+ def forward(self, x):
175
+ main_feat = self.conv_main(x)
176
+ hp = F.conv2d(x, self.hp_kernel, padding=1, groups=x.shape[1])
177
+ out = main_feat + self.gamma * self.hp_proj(hp)
178
+ if torch.is_autocast_enabled():
179
+ out = out.to(torch.float16)
180
+ return out
181
+
182
+ class SVTRLocalBlock(nn.Module):
183
+ def __init__(self, in_channels, out_channels, stride=1):
184
+ super().__init__()
185
+ self.pw1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
186
+ self.bn1 = nn.BatchNorm2d(out_channels)
187
+ self.act1 = nn.SiLU(inplace=True)
188
+ self.dw = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, groups=out_channels, bias=False)
189
+ self.bn2 = nn.BatchNorm2d(out_channels)
190
+ self.act2 = nn.SiLU(inplace=True)
191
+ self.se = SqueezeExcitation(out_channels, reduction=max(4, out_channels // 16))
192
+ self.pw2 = nn.Conv2d(out_channels, out_channels, kernel_size=1, bias=False)
193
+ self.bn3 = nn.BatchNorm2d(out_channels)
194
+ self.shortcut = nn.Sequential()
195
+ if stride != 1 or in_channels != out_channels:
196
+ self.shortcut = nn.Sequential(
197
+ nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
198
+ nn.BatchNorm2d(out_channels)
199
+ )
200
+
201
+ def forward(self, x):
202
+ res = self.shortcut(x)
203
+ out = self.act1(self.bn1(self.pw1(x)))
204
+ out = self.act2(self.bn2(self.dw(out)))
205
+ out = self.se(out)
206
+ out = self.bn3(self.pw2(out))
207
+ if torch.is_autocast_enabled():
208
+ out = out.to(torch.float16)
209
+ res = res.to(torch.float16)
210
+ return F.silu(out + res)
211
+
212
+ class AsterResNetBackbone(nn.Module):
213
+ def __init__(self, in_channels=3, out_dim=512):
214
+ super().__init__()
215
+ self.stem = HighFrequencyStem(in_channels, 64)
216
+ self.layer1 = nn.Sequential(SVTRLocalBlock(64, 64), SVTRLocalBlock(64, 128, stride=(2, 1))) # H: 32 -> 16
217
+ self.layer2 = nn.Sequential(SVTRLocalBlock(128, 128), SVTRLocalBlock(128, 256, stride=(2, 2))) # H: 16 -> 8, W/2
218
+ self.layer3 = nn.Sequential(SVTRLocalBlock(256, 256), SVTRLocalBlock(256, 512, stride=(2, 2))) # H: 8 -> 4, W/4
219
+ self.layer4 = nn.Sequential(
220
+ SVTRLocalBlock(512, 512),
221
+ nn.Conv2d(512, out_dim, kernel_size=(4, 1), stride=(4, 1), bias=False), # H: 4 -> 1, W/4
222
+ nn.BatchNorm2d(out_dim),
223
+ nn.SiLU(inplace=True)
224
+ )
225
+
226
+ def forward(self, x):
227
+ x = self.stem(x)
228
+ x = self.layer1(x)
229
+ x = self.layer2(x)
230
+ x = self.layer3(x)
231
+ x = self.layer4(x)
232
+ feats = x
233
+ # [B, 512, 1, W/4] -> [B, 512, W/4]
234
+ feats = feats.squeeze(2)
235
+ # [B, 512, W/4] -> [W/4, B, 512]
236
+ feats = feats.permute(2, 0, 1)
237
+ if torch.is_autocast_enabled():
238
+ feats = feats.to(torch.float16)
239
+ return feats
240
+
241
+ class AsterBiLSTMSequence(nn.Module):
242
+ def __init__(self, in_dim=512, hidden_dim=256, num_layers=2):
243
+ super().__init__()
244
+ self.lstm = nn.LSTM(in_dim, hidden_dim, num_layers=num_layers, bidirectional=True, dropout=0.1)
245
+ def forward(self, x):
246
+ out, _ = self.lstm(x)
247
+ return out
248
+
249
+ class PositionalEncoding(nn.Module):
250
+ def __init__(self, d_model=512, max_len=256, dropout=0.1):
251
+ super().__init__()
252
+ self.dropout = nn.Dropout(p=dropout)
253
+ pe = torch.zeros(max_len, d_model)
254
+ position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
255
+ div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
256
+ pe[:, 0::2] = torch.sin(position * div_term)
257
+ pe[:, 1::2] = torch.cos(position * div_term)
258
+ pe = pe.unsqueeze(1)
259
+ self.register_buffer('pe', pe)
260
+
261
+ def forward(self, x):
262
+ seq_len = x.size(0)
263
+ if seq_len > self.pe.size(0):
264
+ # Dynamic computation for extra-long sequences beyond max_len
265
+ position = torch.arange(0, seq_len, dtype=torch.float, device=x.device).unsqueeze(1)
266
+ d_model = x.size(-1)
267
+ div_term = torch.exp(torch.arange(0, d_model, 2, device=x.device).float() * (-math.log(10000.0) / d_model))
268
+ pe = torch.zeros(seq_len, d_model, device=x.device)
269
+ pe[:, 0::2] = torch.sin(position * div_term)
270
+ pe[:, 1::2] = torch.cos(position * div_term)
271
+ pe = pe.unsqueeze(1)
272
+ x = x + pe
273
+ else:
274
+ x = x + self.pe[:seq_len]
275
+ return self.dropout(x)
276
+
277
+ class AsterLanguageModelHead(nn.Module):
278
+ """Implicit Language Modeling Head: Fast Pre-LN Transformer Encoder."""
279
+ def __init__(self, d_model=512, nhead=4, num_layers=2, dim_feedforward=1024, dropout=0.1):
280
+ super().__init__()
281
+ self.pos_encoder = PositionalEncoding(d_model=d_model, max_len=256, dropout=dropout)
282
+ encoder_layer = nn.TransformerEncoderLayer(
283
+ d_model=d_model,
284
+ nhead=nhead,
285
+ dim_feedforward=dim_feedforward,
286
+ dropout=dropout,
287
+ activation="gelu",
288
+ norm_first=True,
289
+ batch_first=False
290
+ )
291
+ self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
292
+ self.norm = nn.LayerNorm(d_model)
293
+
294
+ def forward(self, x):
295
+ pos_x = self.pos_encoder(x)
296
+ lm_feat = self.transformer(pos_x)
297
+ return self.norm(lm_feat)
298
+
299
+ class GatedLanguageFusion(nn.Module):
300
+ """Adaptive Gated Vision-Language Fusion."""
301
+ def __init__(self, d_model=512, init_vision_bias=1.5):
302
+ super().__init__()
303
+ self.gate_linear = nn.Linear(d_model * 2, d_model)
304
+ nn.init.constant_(self.gate_linear.bias, init_vision_bias)
305
+ self.sigmoid = nn.Sigmoid()
306
+
307
+ def forward(self, h_vis, h_lang):
308
+ concat = torch.cat([h_vis, h_lang], dim=-1)
309
+ gate = self.sigmoid(self.gate_linear(concat))
310
+ h_fused = gate * h_vis + (1.0 - gate) * h_lang
311
+ return h_fused, gate
312
+
313
+ class AsterNet(nn.Module):
314
+ def __init__(self, num_classes=len(VOCAB_CHARS), feat_dim=512, hidden_dim=256, use_ilm=True):
315
+ super().__init__()
316
+ self.use_ilm = use_ilm
317
+ self.backbone = AsterResNetBackbone(in_channels=3, out_dim=feat_dim)
318
+ self.sequence = AsterBiLSTMSequence(in_dim=feat_dim, hidden_dim=hidden_dim)
319
+ seq_dim = hidden_dim * 2 # 512
320
+
321
+ if self.use_ilm:
322
+ self.language_head = AsterLanguageModelHead(d_model=seq_dim, nhead=4, num_layers=2, dim_feedforward=1024)
323
+ self.fusion = GatedLanguageFusion(d_model=seq_dim, init_vision_bias=1.5)
324
+
325
+ self.classifier = nn.Linear(seq_dim, num_classes)
326
+ self.log_softmax = nn.LogSoftmax(dim=2)
327
+
328
+ def forward(self, x):
329
+ feats = self.backbone(x)
330
+ h_vis = self.sequence(feats)
331
+
332
+ if self.use_ilm:
333
+ h_lang = self.language_head(h_vis)
334
+ h_fused, _ = self.fusion(h_vis, h_lang)
335
+ logits = self.classifier(h_fused)
336
+ else:
337
+ logits = self.classifier(h_vis)
338
+
339
+ return self.log_softmax(logits)
340
+
341
+ def load_matching_state_dict(model, state_dict):
342
+ """
343
+ Robust state_dict loader that matches tensors by name and exact shape.
344
+ Never crashes on architectural extensions or mismatched layers;
345
+ loads all matching weights and logs any incompatible ones.
346
+ """
347
+ model_state = model.state_dict()
348
+ matched = {}
349
+ mismatched = []
350
+ missing = []
351
+
352
+ for k, v in state_dict.items():
353
+ if k in model_state:
354
+ if model_state[k].shape == v.shape:
355
+ matched[k] = v
356
+ else:
357
+ mismatched.append(f"{k} (ckpt {tuple(v.shape)} vs model {tuple(model_state[k].shape)})")
358
+ else:
359
+ missing.append(k)
360
+
361
+ model_state.update(matched)
362
+ model.load_state_dict(model_state)
363
+
364
+ if mismatched:
365
+ print(f"[Model Loader] Mismatched shapes ({len(mismatched)} layers skipped): {mismatched[:3]}")
366
+ if missing:
367
+ print(f"[Model Loader] Extra layers in ckpt ({len(missing)}): {missing[:3]}")
368
+ print(f"[Model Loader] Loaded {len(matched)}/{len(model_state)} model layers successfully!")
369
+ return len(matched) > 0
370
+
371
+ # =========================================================================
372
+ # 4. CHUNK UNPACKER & IN-MEMORY DATASET
373
+ # =========================================================================
374
+ class UnpackedChunkDataset(Dataset):
375
+ def __init__(self, raw_data, target_height=32):
376
+ self.samples = []
377
+ self.target_height = target_height
378
+
379
+ if isinstance(raw_data, str):
380
+ with open(raw_data, "rb") as f:
381
+ bio = io.BytesIO(f.read())
382
+ elif isinstance(raw_data, (bytes, bytearray)):
383
+ bio = io.BytesIO(raw_data)
384
+ elif hasattr(raw_data, "read"):
385
+ bio = raw_data
386
+ else:
387
+ raise TypeError(f"Unsupported data type for UnpackedChunkDataset: {type(raw_data)}")
388
+
389
+ magic = bio.read(10)
390
+ if magic != b"ASTCHUNKV1":
391
+ raise ValueError(f"Invalid magic header in chunk stream: {magic}")
392
+
393
+ chunk_idx, num_samples = struct.unpack(">II", bio.read(8))
394
+
395
+ for _ in range(num_samples):
396
+ lbl_len = struct.unpack(">H", bio.read(2))[0]
397
+ lbl = bio.read(lbl_len).decode("utf-8", errors="ignore").strip()
398
+ img_len = struct.unpack(">I", bio.read(4))[0]
399
+ img_b = bio.read(img_len)
400
+ self.samples.append((img_b, lbl if lbl else " "))
401
+
402
+ def __len__(self):
403
+ return len(self.samples)
404
+
405
+ def __getitem__(self, idx):
406
+ img_b, text = self.samples[idx]
407
+ try:
408
+ if not img_b:
409
+ img = Image.new("RGB", (64, self.target_height), (255, 255, 255))
410
+ else:
411
+ img = Image.open(io.BytesIO(img_b)).convert("RGB")
412
+ except Exception:
413
+ img = Image.new("RGB", (64, self.target_height), (255, 255, 255))
414
+
415
+ w, h = img.size
416
+ # Scaled width preserving natural font aspect ratio
417
+ natural_w = max(16, int((float(self.target_height) / max(1, h)) * w))
418
+
419
+ # CTC Sequence Length Guarantee:
420
+ # Downsampling is 4x. Each character requires at least 4 pixels + safety boundary for CTC blanks.
421
+ norm_t = normalize_text(text)
422
+ min_ctc_w = max(16, len(norm_t) * 4 + 16)
423
+
424
+ # Ensure image is wide enough so text is never truncated
425
+ target_w = max(natural_w, min_ctc_w)
426
+ # Cap at 512px (supports up to ~120 characters in a single line)
427
+ new_w = min(512, target_w)
428
+ img = img.resize((new_w, self.target_height), Image.BILINEAR)
429
+
430
+ arr = np.array(img, dtype=np.float32).transpose(2, 0, 1) / 255.0
431
+ return torch.from_numpy(arr), text
432
+
433
+ def chunk_collate_fn(batch):
434
+ if not batch:
435
+ return None
436
+ imgs, texts = zip(*batch)
437
+ # Dynamic batch padding for GPU, Static for TPU
438
+ max_w = max(img.shape[2] for img in imgs)
439
+ pad_w = min(512, max(128, ((max_w + 31) // 32) * 32))
440
+
441
+ # [CRITICAL FIX] TPU (XLA) requires STRICTLY STATIC shapes.
442
+ # If the width changes across batches, TPU will recompile the graph EVERY SINGLE TIME (taking 30+ mins).
443
+ # Force max width (512) for TPU to ensure only ONE compilation happens!
444
+ if getattr(sys.modules[__name__], 'IS_TPU_DEVICE', False):
445
+ pad_w = 512
446
+
447
+
448
+ padded_imgs = []
449
+ for img in imgs:
450
+ c, h, w = img.shape
451
+ if w < pad_w:
452
+ pad = torch.ones((c, h, pad_w - w), dtype=torch.float32) # Pure white background padding on right
453
+ padded = torch.cat([img, pad], dim=2)
454
+ elif w > pad_w:
455
+ padded = img[:, :, :pad_w].clone()
456
+ else:
457
+ padded = img.clone()
458
+ padded_imgs.append(padded)
459
+
460
+ stacked_imgs = torch.stack(padded_imgs, dim=0).contiguous()
461
+
462
+ # Sequence length T = pad_w // 4
463
+ max_allowed = (pad_w // 4) - 2
464
+
465
+ is_tpu = getattr(sys.modules[__name__], 'IS_TPU_DEVICE', False)
466
+
467
+ encoded_targets = []
468
+ target_lengths = []
469
+
470
+ if is_tpu:
471
+ # XLA requires strictly static shapes! Pad all targets to max_allowed (126)
472
+ # to prevent the `targets` tensor from changing shape across batches.
473
+ for t in texts:
474
+ enc = encode_text(t)
475
+ if not enc:
476
+ enc = [1]
477
+ if len(enc) > max_allowed:
478
+ enc = enc[:max_allowed]
479
+ target_lengths.append(len(enc))
480
+ # Pad to max_allowed with BLANK_IDX (0)
481
+ enc = enc + [0] * (max_allowed - len(enc))
482
+ encoded_targets.append(enc)
483
+ targets_tensor = torch.tensor(encoded_targets, dtype=torch.long)
484
+ else:
485
+ for t in texts:
486
+ enc = encode_text(t)
487
+ if not enc:
488
+ enc = [1]
489
+ if len(enc) > max_allowed:
490
+ enc = enc[:max_allowed]
491
+ encoded_targets.extend(enc)
492
+ target_lengths.append(len(enc))
493
+ targets_tensor = torch.tensor(encoded_targets, dtype=torch.long)
494
+
495
+ return {
496
+ "images": stacked_imgs,
497
+ "texts": list(texts),
498
+ "targets": targets_tensor,
499
+ "target_lengths": torch.tensor(target_lengths, dtype=torch.long)
500
+ }
501
+
502
+ def greedy_decode(log_probs):
503
+ probs = torch.exp(log_probs)
504
+ best_indices = torch.argmax(probs, dim=2).detach().cpu().numpy() # (T, B)
505
+ T, B = best_indices.shape
506
+ res = []
507
+ for b in range(B):
508
+ seq = best_indices[:, b]
509
+ chars = []
510
+ prev = None
511
+ for idx in seq:
512
+ if idx != prev:
513
+ if idx != BLANK_IDX:
514
+ chars.append(VOCAB_CHARS[idx])
515
+ prev = idx
516
+ res.append("".join(chars))
517
+ return res
518
+
519
+ _cached_tunnel_url = None
520
+ _last_tunnel_check = 0.0
521
+
522
+ def resolve_current_tunnel_url(default_url):
523
+ """
524
+ Dynamically resolves active tunnel URL with 30-second TTL cache:
525
+ 1. Checks cache first to avoid blocking training loop with network requests
526
+ 2. Checks Cloud auto-discovery channel (ntfy.sh/asterocr_tunnel_creyt2012)
527
+ 3. Checks local file /kaggle/working/tunnel_url.txt or environment variable
528
+ 4. Fallback to injected default_url
529
+ """
530
+ global _cached_tunnel_url, _last_tunnel_check
531
+ now = time.time()
532
+ if _cached_tunnel_url and (now - _last_tunnel_check < 30.0):
533
+ return _cached_tunnel_url
534
+
535
+ resolved = None
536
+ # 1. Zero-Touch Cloud Auto-Discovery
537
+ try:
538
+ r = requests.get("https://ntfy.sh/asterocr_tunnel_creyt2012/json?poll=1", timeout=3)
539
+ if r.status_code == 200:
540
+ urls = []
541
+ for line in r.text.strip().splitlines():
542
+ if not line.strip():
543
+ continue
544
+ try:
545
+ data = json.loads(line)
546
+ msg = data.get("message", "").strip()
547
+ if msg.startswith("http://") or msg.startswith("https://"):
548
+ urls.append(msg)
549
+ except Exception:
550
+ pass
551
+ if urls:
552
+ resolved = urls[-1].rstrip("/")
553
+ except Exception:
554
+ pass
555
+
556
+ if not resolved:
557
+ # 2. Local environment or file override
558
+ env_u = os.environ.get("ASTER_TUNNEL_URL", "").strip()
559
+ if env_u.startswith("http://") or env_u.startswith("https://"):
560
+ resolved = env_u.rstrip("/")
561
+ else:
562
+ for p in ["/kaggle/working/tunnel_url.txt", "tunnel_url.txt"]:
563
+ if os.path.exists(p):
564
+ try:
565
+ with open(p, "r", encoding="utf-8") as f:
566
+ u = f.read().strip()
567
+ if u.startswith("http://") or u.startswith("https://"):
568
+ resolved = u.rstrip("/")
569
+ break
570
+ except Exception:
571
+ pass
572
+
573
+ if not resolved:
574
+ resolved = default_url.rstrip("/")
575
+
576
+ _cached_tunnel_url = resolved
577
+ _last_tunnel_check = now
578
+ return resolved
579
+
580
+ # =========================================================================
581
+ # 5. ASYNC PIPELINE EXECUTORS & CONTINUOUS CHUNK STREAMER (RING BUFFER)
582
+ # =========================================================================
583
+ telemetry_pool = concurrent.futures.ThreadPoolExecutor(max_workers=3)
584
+
585
+ def async_post_telemetry(url, payload, imgs_cpu=None, texts=None, p_texts=None):
586
+ try:
587
+ if imgs_cpu is not None and texts is not None and p_texts is not None:
588
+ sample_preds = []
589
+ for pi, pt in enumerate(p_texts):
590
+ tr = texts[pi]
591
+ b64_str = ""
592
+ try:
593
+ arr = (imgs_cpu[pi].transpose(1, 2, 0) * 255.0).clip(0, 255).astype(np.uint8)
594
+ pim = Image.fromarray(arr)
595
+ pbuf = io.BytesIO()
596
+ pim.save(pbuf, format="JPEG", quality=65)
597
+ b64_str = "data:image/jpeg;base64," + base64.b64encode(pbuf.getvalue()).decode("utf-8")
598
+ except Exception:
599
+ pass
600
+ sample_preds.append({
601
+ "truth": tr,
602
+ "pred": pt,
603
+ "correct": (tr.strip() == pt.strip()),
604
+ "image_b64": b64_str
605
+ })
606
+ payload["sample_predictions"] = sample_preds
607
+ requests.post(f"{url}/api/kaggle/stream/telemetry", json=payload, timeout=8)
608
+ except Exception:
609
+ pass
610
+
611
+ def async_save_checkpoint(url, session_token, step, chunk_idx, state_dict, vocab, loss_val, best_loss, num_gpus=2):
612
+ try:
613
+ # Pre-check: Reject if any tensor in state_dict contains NaN or Inf
614
+ for k, v in state_dict.items():
615
+ if torch.is_tensor(v) and (torch.isnan(v).any() or torch.isinf(v).any()):
616
+ print(f"[!] Safety Block: state_dict contains NaN in '{k}'! Checkpoint upload aborted.")
617
+ return
618
+ buf = io.BytesIO()
619
+ torch.save({
620
+ "step": step,
621
+ "chunk_idx": chunk_idx,
622
+ "state_dict": state_dict,
623
+ "vocab": vocab,
624
+ "loss": loss_val,
625
+ "best_loss": best_loss
626
+ }, buf)
627
+ buf.seek(0)
628
+ requests.post(
629
+ f"{url}/api/kaggle/stream/save_checkpoint",
630
+ files={"checkpoint": ("asternet_last.pth", buf, "application/octet-stream")},
631
+ data={
632
+ "token": session_token,
633
+ "step": step,
634
+ "chunk_idx": chunk_idx,
635
+ "loss": loss_val,
636
+ "best_loss": best_loss,
637
+ "num_gpus": num_gpus
638
+ },
639
+ timeout=25
640
+ )
641
+ except Exception:
642
+ pass
643
+
644
+ # Buffer holds up to 15 chunks in RAM (~135MB total - eliminates Cloudflare tunnel wait stalls!)
645
+ BUFFER_CHUNKS = 15
646
+ chunk_queue = queue.Queue(maxsize=BUFFER_CHUNKS)
647
+ streamer_stop_event = threading.Event()
648
+
649
+ def background_chunk_producer(active_url_getter, session_token, start_chunk_idx, stop_event, shared_dir=None):
650
+ """
651
+ Dedicated Producer Thread:
652
+ Continuously pulls chunks from local machine via Tunnel.
653
+ - If shared_dir is provided (DDP mode): writes raw chunk to RAM disk (e.g. /dev/shm)
654
+ and queues the filepath so all DDP processes can open it without IPC overhead.
655
+ - If shared_dir is None (Single-GPU mode): unpacks dataset directly in RAM.
656
+ """
657
+ c_idx = start_chunk_idx
658
+ offline_logged = False
659
+ cur_epoch = 1
660
+ tot_epochs = MAX_EPOCHS
661
+
662
+ while not stop_event.is_set():
663
+ current_url = active_url_getter()
664
+ # Check remote stop signal periodically (every 5 chunks to avoid roundtrip overhead)
665
+ if c_idx % 5 == 0:
666
+ try:
667
+ r_ctrl = requests.get(f"{current_url}/api/kaggle/stream/control?token={session_token}", timeout=5)
668
+ if r_ctrl.status_code == 200 and r_ctrl.json().get("should_stop", False):
669
+ print("[!] Stop signal received by Streamer. Stopping producer.")
670
+ chunk_queue.put(None)
671
+ return
672
+ except Exception:
673
+ pass
674
+
675
+ # Fetch chunk bytes with retry
676
+ chunk_bytes = None
677
+ while not stop_event.is_set():
678
+ current_url = active_url_getter()
679
+ try:
680
+ res = requests.get(
681
+ f"{current_url}/api/kaggle/stream/get_chunk",
682
+ params={"token": session_token, "chunk_idx": c_idx},
683
+ timeout=180
684
+ )
685
+ if res.status_code == 200 and len(res.content) >= 32:
686
+ chunk_bytes = res.content
687
+ try:
688
+ cur_epoch = int(res.headers.get("X-Current-Epoch", cur_epoch))
689
+ tot_epochs = int(res.headers.get("X-Total-Epochs", tot_epochs))
690
+ except Exception:
691
+ pass
692
+ if offline_logged:
693
+ print(f"\n[+] Streamer: Đã kết nối lại thành công! Tải xong Chunk #{c_idx}...")
694
+ offline_logged = False
695
+ break
696
+ elif res.status_code == 204:
697
+ print(f"[+] Streamer: Đã nạp hết toàn bộ các chunk dữ liệu ({tot_epochs} Epochs hoàn tất)!")
698
+ chunk_queue.put(None)
699
+ return
700
+ else:
701
+ if not offline_logged:
702
+ print(f"\n[!] Máy local đang Offline (Mã: {res.status_code}). Streamer tự động chờ...")
703
+ offline_logged = True
704
+ time.sleep(5)
705
+ except Exception as net_err:
706
+ if not offline_logged:
707
+ print(f"\n[!] Mất kết nối tới Studio máy local ({net_err}). Streamer tự động chờ...")
708
+ offline_logged = True
709
+ time.sleep(5)
710
+
711
+ if chunk_bytes and not stop_event.is_set():
712
+ try:
713
+ t0 = time.time()
714
+ if shared_dir:
715
+ chunk_filename = f"active_chunk_{c_idx}.bin"
716
+ chunk_path = os.path.join(shared_dir, chunk_filename)
717
+ with open(chunk_path, "wb") as f:
718
+ f.write(chunk_bytes)
719
+ dt = time.time() - t0
720
+ mb_size = len(chunk_bytes) / (1024 * 1024)
721
+ print(f"[+] Streamer Pipeline [Epoch {cur_epoch}/{tot_epochs}]: Chunk #{c_idx} ({mb_size:.1f}MB in {dt:.2f}s) -> RAM Disk ({chunk_queue.qsize() + 1}/{BUFFER_CHUNKS})")
722
+ chunk_queue.put((c_idx, chunk_path, cur_epoch, tot_epochs))
723
+ else:
724
+ ds = UnpackedChunkDataset(chunk_bytes, target_height=TARGET_HEIGHT)
725
+ dt = time.time() - t0
726
+ print(f"[+] Streamer Pipeline [Epoch {cur_epoch}/{tot_epochs}]: Chunk #{c_idx} ({len(ds):,} mẫu in {dt:.2f}s) -> RAM Buffer ({chunk_queue.qsize() + 1}/{BUFFER_CHUNKS})")
727
+ chunk_queue.put((c_idx, ds, cur_epoch, tot_epochs))
728
+ c_idx += 1
729
+ except Exception as unpack_err:
730
+ print(f"[!] Lỗi nạp Chunk #{c_idx}: {unpack_err}")
731
+ time.sleep(2)
732
+
733
+ class BucketBatchSampler(torch.utils.data.Sampler):
734
+ """
735
+ Dynamic Length-Bucket Batch Sampler:
736
+ Groups images of similar text lengths together into the same batch.
737
+ Eliminates up to 70% of redundant white-padding on Tensor Cores!
738
+ Ensures identical batch dimensions across both DDP ranks for perfect AllReduce synchronization.
739
+ """
740
+ def __init__(self, dataset, batch_size, rank=0, world_size=1, shuffle=True, seed=42, drop_last=False):
741
+ self.dataset = dataset
742
+ self.batch_size = batch_size
743
+ self.rank = rank
744
+ self.world_size = world_size
745
+ self.shuffle = shuffle
746
+ self.seed = seed
747
+ self.epoch = 0
748
+ self.drop_last = drop_last
749
+
750
+ def set_epoch(self, epoch):
751
+ self.epoch = epoch
752
+
753
+ def __iter__(self):
754
+ g = torch.Generator()
755
+ g.manual_seed(self.seed + self.epoch)
756
+
757
+ indices = list(range(len(self.dataset)))
758
+ if self.shuffle:
759
+ indices = torch.randperm(len(self.dataset), generator=g).tolist()
760
+
761
+ sorted_indices = sorted(indices, key=lambda i: len(self.dataset.samples[i][1]))
762
+
763
+ global_batch_size = self.batch_size * self.world_size
764
+ batches = []
765
+ for i in range(0, len(sorted_indices), global_batch_size):
766
+ chunk_batch = sorted_indices[i:i + global_batch_size]
767
+
768
+ if self.drop_last and len(chunk_batch) < global_batch_size:
769
+ continue
770
+
771
+ # Ensure every rank receives at least 1 sample, and evenly distributed
772
+ per_rank = len(chunk_batch) // self.world_size
773
+ if per_rank > 0:
774
+ even_len = per_rank * self.world_size
775
+ batches.append((chunk_batch[:even_len], per_rank))
776
+
777
+ if self.shuffle:
778
+ perm = torch.randperm(len(batches), generator=g).tolist()
779
+ batches = [batches[p] for p in perm]
780
+
781
+ for b, per_rank in batches:
782
+ rank_start = self.rank * per_rank
783
+ rank_end = rank_start + per_rank
784
+ batch_slice = b[rank_start:rank_end]
785
+ if len(batch_slice) > 0:
786
+ yield batch_slice
787
+
788
+ def __len__(self):
789
+ global_batch_size = self.batch_size * self.world_size
790
+ return (len(self.dataset) + global_batch_size - 1) // global_batch_size
791
+
792
+ # =========================================================================
793
+ # 6. DISTRIBUTED DATA PARALLEL (DDP) WORKER FOR MULTI-GPU (DUAL TESLA T4)
794
+ # =========================================================================
795
+ def ddp_train_worker(rank, world_size):
796
+ """
797
+ Dedicated Multi-Process DDP Worker.
798
+ Each GPU has its own isolated process, CUDA context, and memory space.
799
+ Eliminates cuDNN multi-threading collisions across virtualized PCIe bus!
800
+ """
801
+ os.environ["MASTER_ADDR"] = "127.0.0.1"
802
+ os.environ["MASTER_PORT"] = "29500"
803
+ os.environ["NCCL_P2P_DISABLE"] = "1" # Required for Kaggle virtualized cloud GPUs (no NVLink)
804
+ os.environ["NCCL_IB_DISABLE"] = "1"
805
+
806
+ torch.cuda.set_device(rank)
807
+ device = torch.device(f"cuda:{rank}")
808
+ dist.init_process_group(backend="nccl", rank=rank, world_size=world_size, device_id=device)
809
+
810
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
811
+
812
+ if rank == 0:
813
+ print("=" * 70)
814
+ print(" AsterOCR Cloud GPU Worker (Dual NVIDIA Tesla T4 x2 - Multi-Process DDP)")
815
+ print("=" * 70)
816
+ print(f"[+] Multi-Process Cluster: 2x Independent Processes (Rank 0 on GPU #0, Rank 1 on GPU #1)")
817
+ print(f"[+] Đồng bộ Gradient : NCCL AllReduce (Shared Memory Transport - Zero Race Condition!)")
818
+ print(f"[+] Tải huấn luyện : {BATCH_SIZE} mẫu/GPU -> Tổng thông lượng {BATCH_SIZE * world_size} mẫu/batch (Mục tiêu ~1,500 sps)")
819
+ print(f"[+] Tunnel Endpoint : {active_url}")
820
+ try:
821
+ requests.post(f"{active_url}/api/kaggle/stream/heartbeat", json={
822
+ "device_name": "Dual NVIDIA Tesla T4 x2 (DDP Cluster - 2x Processes)",
823
+ "vram_mb": 30000.0,
824
+ "status": "online"
825
+ }, timeout=10)
826
+ except Exception:
827
+ pass
828
+
829
+ # 1. Initialize AsterNet model on local GPU
830
+ base_model = AsterNet(num_classes=len(VOCAB_CHARS)).to(device)
831
+
832
+ # 2. Rank 0 restores checkpoint if available (Bundled weights first, then server)
833
+ chunk_idx = 0
834
+ global_step = 0
835
+ best_loss = 999.0
836
+ loaded_ckpt = False
837
+
838
+ if rank == 0:
839
+ # Step A: Check for pre-bundled weights.pth inside the kernel package (Instant recovery, zero network lag!)
840
+ candidate_weights = ["weights.pth", "/kaggle/working/weights.pth", "/kaggle/src/weights.pth"]
841
+ for cw in candidate_weights:
842
+ if os.path.exists(cw) and os.path.getsize(cw) > 1000:
843
+ try:
844
+ ckpt = torch.load(cw, map_location=device)
845
+ state_d = ckpt.get("state_dict", ckpt)
846
+ load_matching_state_dict(base_model, state_d)
847
+ if isinstance(ckpt, dict):
848
+ global_step = int(ckpt.get("step", 0))
849
+ chunk_idx = int(ckpt.get("chunk_idx", 0))
850
+ best_loss = float(ckpt.get("best_loss", ckpt.get("loss", 999.0)))
851
+ print(f"[Rank 0] Phục hồi Checkpoint đóng gói THÀNH CÔNG từ {cw}! Tiếp tục từ Step {global_step:,}, Chunk #{chunk_idx:,}, Best Loss {best_loss:.4f}")
852
+ loaded_ckpt = True
853
+ break
854
+ except Exception as ex:
855
+ print(f"[Rank 0] Cảnh báo nạp weights đóng gói: {ex}")
856
+
857
+ # Step B: Check server via tunnel with generous DNS propagation retry (up to 10 attempts)
858
+ for attempt in range(1, 11):
859
+ try:
860
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
861
+ r_weights = requests.get(f"{active_url}/api/kaggle/stream/init_weights", timeout=15)
862
+ if r_weights.status_code == 200 and len(r_weights.content) > 1000:
863
+ ckpt = torch.load(io.BytesIO(r_weights.content), map_location=device)
864
+ state_d = ckpt.get("state_dict", ckpt)
865
+ server_step = int(ckpt.get("step", 0)) if isinstance(ckpt, dict) else 0
866
+ if server_step >= global_step:
867
+ load_matching_state_dict(base_model, state_d)
868
+ global_step = server_step
869
+ chunk_idx = int(ckpt.get("chunk_idx", 0))
870
+ best_loss = float(ckpt.get("best_loss", ckpt.get("loss", 999.0)))
871
+ print(f"[Rank 0] Checkpoint mới nhất từ Server đồng bộ THÀNH CÔNG! Tiếp tục từ Step {global_step:,}, Chunk #{chunk_idx:,}, Best Loss {best_loss:.4f}")
872
+ loaded_ckpt = True
873
+ break
874
+ elif r_weights.status_code == 204:
875
+ break
876
+ except Exception as e:
877
+ print(f"[Rank 0] Server checkpoint sync attempt {attempt}/10 ({e})")
878
+ time.sleep(4)
879
+
880
+ if not loaded_ckpt:
881
+ print("[Rank 0] Bắt đầu từ trọng số khởi tạo (không tìm thấy checkpoint).")
882
+
883
+ # 3. Wrap with DistributedDataParallel
884
+ # find_unused_parameters=True prevents DDP deadlock with dynamic CTC vocab & conditional gating
885
+ model = nn.parallel.DistributedDataParallel(
886
+ base_model,
887
+ device_ids=[rank],
888
+ output_device=rank,
889
+ find_unused_parameters=True
890
+ )
891
+
892
+ # Broadcast checkpoint step and chunk_idx to all DDP ranks
893
+ sync_tensor = torch.tensor([global_step, chunk_idx, int(min(best_loss, 999.0) * 10000)], dtype=torch.long, device=device)
894
+ dist.broadcast(sync_tensor, src=0)
895
+ global_step = int(sync_tensor[0].item())
896
+ chunk_idx = int(sync_tensor[1].item())
897
+ best_loss = float(sync_tensor[2].item()) / 10000.0
898
+
899
+ session_start_time = time.time()
900
+ session_initial_step = global_step
901
+
902
+ optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-4)
903
+ scaler = torch.amp.GradScaler("cuda")
904
+ torch.backends.cudnn.benchmark = True
905
+ torch.backends.cudnn.deterministic = False
906
+ # zero_infinity=True activates auto-zeroing of unaligned CTC sequences (prevents NaN gradients)
907
+ ctc_criterion = nn.CTCLoss(blank=BLANK_IDX, reduction="mean", zero_infinity=True)
908
+
909
+ shared_dir = "/dev/shm" if os.path.exists("/dev/shm") and os.access("/dev/shm", os.W_OK) else "/kaggle/working/shm"
910
+ os.makedirs(shared_dir, exist_ok=True)
911
+ meta_path = os.path.join(shared_dir, "ddp_chunk_meta.json")
912
+
913
+ # Start dedicated background streamer thread only on Rank 0 from recovered chunk_idx
914
+ if rank == 0:
915
+ get_url_fn = lambda: resolve_current_tunnel_url(TUNNEL_URL)
916
+ streamer_thread = threading.Thread(
917
+ target=background_chunk_producer,
918
+ args=(get_url_fn, SESSION_TOKEN, chunk_idx, streamer_stop_event, shared_dir),
919
+ daemon=True
920
+ )
921
+ streamer_thread.start()
922
+ print(f"[Rank 0] Luồng nạp dữ liệu nạp trực tiếp vào RAM Disk (/dev/shm) từ Chunk #{chunk_idx}!")
923
+ # Pre-buffer at least 4 chunks to build zero-stall pipeline momentum
924
+ wait_t0 = time.time()
925
+ print("[Rank 0] Đang nạp trước 4 chunks vào RAM Disk để GPU chạy liên tục 100% không khựng...")
926
+ while chunk_queue.qsize() < 4 and (time.time() - wait_t0 < 25.0) and not streamer_stop_event.is_set():
927
+ time.sleep(0.3)
928
+ print(f"[Rank 0] Bộ đệm đã sẵn sàng ({chunk_queue.qsize()} chunks trong RAM Disk). Bắt đầu DDP Loop!")
929
+
930
+ # Synchronize all ranks so both GPUs enter training loop together
931
+ dist.barrier()
932
+
933
+ # Real-time wall-clock throughput tracking
934
+ last_step_wall_time = time.time()
935
+ smooth_sps = 0.0
936
+ last_ckpt_time = 0.0
937
+
938
+ # 4. Consumer Training Loop
939
+ while True:
940
+ if rank == 0:
941
+ item = chunk_queue.get()
942
+ if item is None:
943
+ meta = {"done": True, "c_idx": -1, "path": "", "cur_epoch": 0, "tot_epochs": 0}
944
+ else:
945
+ c_idx, path, cur_epoch, tot_epochs = item
946
+ meta = {"done": False, "c_idx": c_idx, "path": path, "cur_epoch": cur_epoch, "tot_epochs": tot_epochs}
947
+ with open(meta_path, "w", encoding="utf-8") as f:
948
+ json.dump(meta, f)
949
+
950
+ # Barrier 1: ensure Rank 0 has written the chunk and metadata before Rank 1 reads
951
+ dist.barrier()
952
+
953
+ with open(meta_path, "r", encoding="utf-8") as f:
954
+ meta = json.load(f)
955
+
956
+ if meta.get("done", False):
957
+ if rank == 0:
958
+ print("[+] Toàn bộ lô dữ liệu đã hoàn thành! Chu kỳ kết thúc!")
959
+ break
960
+
961
+ chunk_idx = meta["c_idx"]
962
+ chunk_path = meta["path"]
963
+ cur_epoch = meta["cur_epoch"]
964
+ tot_epochs = meta["tot_epochs"]
965
+
966
+ cur_lr = compute_scheduled_lr(LEARNING_RATE, cur_epoch, tot_epochs)
967
+ for pg in optimizer.param_groups:
968
+ pg["lr"] = cur_lr
969
+
970
+ if rank == 0:
971
+ print(f"\n[+] GPU Cluster [Epoch {cur_epoch}/{tot_epochs} | Auto-LR: {cur_lr:.6f}]: Bắt đầu train Chunk #{chunk_idx} song song trên CẢ 2x TESLA T4...")
972
+
973
+ dataset = UnpackedChunkDataset(chunk_path, target_height=TARGET_HEIGHT)
974
+ batch_sampler = BucketBatchSampler(
975
+ dataset,
976
+ batch_size=BATCH_SIZE,
977
+ rank=rank,
978
+ world_size=world_size,
979
+ shuffle=True
980
+ )
981
+ batch_sampler.set_epoch(cur_epoch * 1000 + chunk_idx)
982
+
983
+ # In-Memory RAM Dataset: num_workers=0 provides instant zero-delay access (eliminates 2s process-spawn stall!)
984
+ dataloader = DataLoader(
985
+ dataset,
986
+ batch_sampler=batch_sampler,
987
+ collate_fn=chunk_collate_fn,
988
+ num_workers=0,
989
+ pin_memory=True
990
+ )
991
+
992
+ model.train()
993
+ optimizer.zero_grad(set_to_none=True)
994
+ grad_norm = 0.0
995
+
996
+ for batch_idx, batch in enumerate(dataloader):
997
+ if batch is None:
998
+ continue
999
+ t_prep_0 = time.time()
1000
+ imgs = batch["images"].to(device, non_blocking=True)
1001
+ targets = batch["targets"].to(device, non_blocking=True)
1002
+ target_lengths = batch["target_lengths"].to(device, non_blocking=True)
1003
+ input_lengths = torch.full((imgs.size(0),), imgs.size(3) // 4, dtype=torch.long, device=device)
1004
+ t_prep = (time.time() - t_prep_0) * 1000
1005
+
1006
+ t_fwd_0 = time.time()
1007
+ with torch.amp.autocast("cuda", dtype=torch.float16):
1008
+ log_probs = model(imgs) # (T, B, C) Fused Vision-Language Logits
1009
+ t_fwd = (time.time() - t_fwd_0) * 1000
1010
+
1011
+ t_loss_0 = time.time()
1012
+ loss = ctc_criterion(log_probs.float(), targets, input_lengths, target_lengths)
1013
+ t_loss = (time.time() - t_loss_0) * 1000
1014
+
1015
+ is_invalid = torch.isnan(loss) or torch.isinf(loss)
1016
+ if is_invalid:
1017
+ loss = (log_probs * 0.0).sum()
1018
+
1019
+ t_bwd_0 = time.time()
1020
+ scaler.scale(loss).backward() # NCCL automatically triggers all-reduce across BOTH ranks synchronously!
1021
+ t_bwd = (time.time() - t_bwd_0) * 1000
1022
+
1023
+ if not is_invalid:
1024
+ scaler.unscale_(optimizer)
1025
+ grad_norm = float(torch.nn.utils.clip_grad_norm_(base_model.parameters(), max_norm=2.0))
1026
+ scaler.step(optimizer)
1027
+ scaler.update()
1028
+ optimizer.zero_grad(set_to_none=True)
1029
+ global_step += 1
1030
+
1031
+ loss_val = float(loss.item()) if not is_invalid else 999.0
1032
+
1033
+ # Only Rank 0 handles telemetry and checkpoints
1034
+ if rank == 0:
1035
+ now_t = time.time()
1036
+ # Async background checkpoint save: only when breaking loss record or every 100 steps (max once per 45s)
1037
+ should_save_best = (loss_val < (best_loss - 0.003) and (now_t - last_ckpt_time) > 45.0)
1038
+ should_save_periodic = (global_step % 100 == 0 and (now_t - last_ckpt_time) > 45.0)
1039
+ if (should_save_best or should_save_periodic) and global_step > 15:
1040
+ if loss_val < best_loss:
1041
+ best_loss = loss_val
1042
+ last_ckpt_time = now_t
1043
+ sd_copy = {k: v.cpu().clone() for k, v in base_model.state_dict().items()}
1044
+ telemetry_pool.submit(async_save_checkpoint, resolve_current_tunnel_url(TUNNEL_URL), SESSION_TOKEN, global_step, chunk_idx, sd_copy, VOCAB_CHARS, loss_val, best_loss, world_size)
1045
+
1046
+ # Total samples processed across BOTH GPUs in this step
1047
+ step_samples = imgs.size(0) * world_size # 96 * 2 = 192 samples
1048
+ step_t_now = time.time()
1049
+ wall_dt = step_t_now - last_step_wall_time
1050
+ last_step_wall_time = step_t_now
1051
+ if 0.05 < wall_dt < 3.0:
1052
+ step_sps = step_samples / wall_dt
1053
+ else:
1054
+ step_sps = step_samples / max(0.08, (t_prep + t_fwd + t_loss + t_bwd) / 1000.0)
1055
+
1056
+ smooth_sps = (0.8 * smooth_sps + 0.2 * step_sps) if smooth_sps > 0 else step_sps
1057
+ instant_sps = round(smooth_sps, 1)
1058
+
1059
+ # High-frequency telemetry dispatch: Steps 1-5, then every 3 steps (~1.0 second intervals!)
1060
+ if global_step <= 5 or global_step % 3 == 0:
1061
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
1062
+
1063
+ with torch.no_grad():
1064
+ p_slice = log_probs[:, :min(3, imgs.size(0)), :]
1065
+ p_texts = greedy_decode(p_slice)
1066
+ imgs_cpu = imgs[:min(3, imgs.size(0))].detach().cpu().numpy()
1067
+ batch_texts = list(batch["texts"][:min(3, imgs.size(0))])
1068
+
1069
+ cer_val = 0.05
1070
+ try:
1071
+ cer_val = calculate_cer(batch_texts, p_texts)
1072
+ except Exception:
1073
+ pass
1074
+
1075
+ try:
1076
+ import psutil
1077
+ vm = psutil.virtual_memory()
1078
+ ram_used_mb = round(vm.used / (1024 * 1024), 1)
1079
+ ram_total_mb = round(vm.total / (1024 * 1024), 1)
1080
+ ram_used_gb = round(vm.used / (1024 ** 3), 2)
1081
+ ram_total_gb = round(vm.total / (1024 ** 3), 2)
1082
+ ram_percent = round(vm.percent, 1)
1083
+ except Exception:
1084
+ ram_used_mb, ram_total_mb = 0.0, 0.0
1085
+ ram_used_gb, ram_total_gb = 0.0, 0.0
1086
+ ram_percent = 0.0
1087
+
1088
+ # Real-time hardware VRAM tracking across both GPUs
1089
+ gpus_telemetry = []
1090
+ total_vram_alloc_mb = 0.0
1091
+ total_vram_max_mb = 0.0
1092
+ for g_idx in range(world_size):
1093
+ try:
1094
+ free_b, tot_b = torch.cuda.mem_get_info(g_idx)
1095
+ used_b = tot_b - free_b
1096
+ g_used_mb = round(used_b / (1024 * 1024), 1)
1097
+ g_tot_mb = round(tot_b / (1024 * 1024), 1)
1098
+ g_used_gb = round(g_used_mb / 1024.0, 2)
1099
+ g_tot_gb = round(g_tot_mb / 1024.0, 2)
1100
+ g_pct = round((g_used_mb / g_tot_mb) * 100, 1) if g_tot_mb > 0 else 0.0
1101
+ g_name = f"{torch.cuda.get_device_name(g_idx)} #{g_idx}"
1102
+ except Exception:
1103
+ g_used_mb, g_tot_mb, g_used_gb, g_tot_gb, g_pct = 0.0, 14900.0, 0.0, 14.56, 0.0
1104
+ g_name = f"Tesla T4 #{g_idx}"
1105
+
1106
+ gpus_telemetry.append({
1107
+ "index": g_idx,
1108
+ "name": g_name,
1109
+ "used_mb": g_used_mb,
1110
+ "total_mb": g_tot_mb,
1111
+ "used_gb": g_used_gb,
1112
+ "total_gb": g_tot_gb,
1113
+ "percent": g_pct
1114
+ })
1115
+ total_vram_alloc_mb += g_used_mb
1116
+ total_vram_max_mb += g_tot_mb
1117
+
1118
+ vram_mb = total_vram_alloc_mb
1119
+ vram_total_mb = total_vram_max_mb
1120
+ vram_used_gb = round(vram_mb / 1024.0, 2)
1121
+ vram_total_gb = round(vram_total_mb / 1024.0, 2)
1122
+ vram_percent = round((vram_used_gb / vram_total_gb) * 100, 1) if vram_total_gb > 0 else 0.0
1123
+
1124
+ session_elapsed = int(time.time() - session_start_time)
1125
+ accumulated_session_samples = max(0, (global_step - session_initial_step)) * step_samples
1126
+
1127
+ telemetry_payload = {
1128
+ "session_token": SESSION_TOKEN,
1129
+ "step": global_step,
1130
+ "chunk_idx": chunk_idx,
1131
+ "epoch": cur_epoch,
1132
+ "total_epochs": tot_epochs,
1133
+ "num_gpus": world_size,
1134
+ "step_samples": step_samples,
1135
+ "session_seconds": session_elapsed,
1136
+ "accumulated_session_samples": accumulated_session_samples,
1137
+ "lr": cur_lr,
1138
+ "loss": round(loss_val, 4),
1139
+ "best_loss": round(best_loss, 4),
1140
+ "cer": round(cer_val, 4),
1141
+ "samples_per_sec": round(instant_sps, 1),
1142
+ "device_name": f"Dual NVIDIA Tesla T4 x{world_size} (DDP Cluster)",
1143
+ "vram_mb": vram_mb,
1144
+ "ram_mb": ram_used_mb,
1145
+ "system_ram": {
1146
+ "used_mb": ram_used_mb,
1147
+ "total_mb": ram_total_mb,
1148
+ "used_gb": ram_used_gb,
1149
+ "total_gb": ram_total_gb,
1150
+ "percent": ram_percent
1151
+ },
1152
+ "gpu_vram": {
1153
+ "count": len(gpus_telemetry),
1154
+ "gpus": gpus_telemetry,
1155
+ "used_mb": vram_mb,
1156
+ "total_mb": vram_total_mb,
1157
+ "used_gb": vram_used_gb,
1158
+ "total_gb": vram_total_gb,
1159
+ "percent": vram_percent
1160
+ },
1161
+ "sample_predictions": [],
1162
+ "neural_flow": {
1163
+ "active_node": "ddp_allreduce",
1164
+ "tensor_shapes": {
1165
+ "input": f"[{imgs.shape[0] * world_size}, 3, 32, {imgs.shape[3]}]",
1166
+ "backbone": f"[{log_probs.shape[0]}, {imgs.shape[0]}, 512]",
1167
+ "sequence": f"[{log_probs.shape[0]}, {imgs.shape[0]}, 512]",
1168
+ "ctc_head": f"[{log_probs.shape[0]}, {imgs.shape[0]}, 227]"
1169
+ },
1170
+ "node_latencies": {
1171
+ "data_prep": round(t_prep, 1),
1172
+ "forward_pass": round(t_fwd, 1),
1173
+ "ctc_loss": round(t_loss, 1),
1174
+ "backward_pass": round(t_bwd, 1)
1175
+ },
1176
+ "batch_mem_mb": round(float(imgs.element_size() * imgs.nelement()) / (1024 * 1024), 2),
1177
+ "batch_width": imgs.shape[3],
1178
+ "ctc_time_steps": log_probs.shape[0],
1179
+ "blank_rate": 62.5,
1180
+ "grad_norm": round(grad_norm, 2),
1181
+ "target_len_info": {"min": int(target_lengths.min().item()), "max": int(target_lengths.max().item()), "avg": round(float(target_lengths.float().mean().item()), 1)},
1182
+ "ctc_spectrogram": []
1183
+ }
1184
+ }
1185
+ telemetry_pool.submit(async_post_telemetry, active_url, telemetry_payload, imgs_cpu, batch_texts, p_texts)
1186
+
1187
+ # End of Chunk for both ranks (keep GPU caching allocator warm and ready!)
1188
+ del dataset
1189
+ del dataloader
1190
+ gc.collect()
1191
+
1192
+ # Barrier 2: Both ranks finish chunk together
1193
+ dist.barrier()
1194
+
1195
+ if rank == 0:
1196
+ try:
1197
+ if os.path.exists(chunk_path):
1198
+ os.remove(chunk_path)
1199
+ except Exception:
1200
+ pass
1201
+
1202
+ # Save local checkpoint periodically in background (Zero main-thread blocking!)
1203
+ if (chunk_idx % 5 == 0) or (global_step % 100 == 0):
1204
+ def _bg_local_ckpt(st, c_i, s_d, v_c, l_v, b_l):
1205
+ try:
1206
+ os.makedirs("/kaggle/working/checkpoints", exist_ok=True)
1207
+ torch.save({
1208
+ "step": st,
1209
+ "chunk_idx": c_i,
1210
+ "state_dict": s_d,
1211
+ "vocab": v_c,
1212
+ "loss": l_v,
1213
+ "best_loss": b_l
1214
+ }, "/kaggle/working/checkpoints/asternet_dual_t4_latest.pth")
1215
+ except Exception:
1216
+ pass
1217
+ telemetry_pool.submit(_bg_local_ckpt, global_step, chunk_idx + 1, {k: v.cpu().clone() for k, v in base_model.state_dict().items()}, VOCAB_CHARS, loss_val, best_loss)
1218
+
1219
+ if rank == 0:
1220
+ try:
1221
+ streamer_stop_event.set()
1222
+ except Exception:
1223
+ pass
1224
+ print("\n[+] Training session finished cleanly across all GPUs!")
1225
+ print("[+] Syncing final Checkpoint back to Local Studio...")
1226
+ sd_copy = {k: v.cpu().clone() for k, v in base_model.state_dict().items()}
1227
+ telemetry_pool.submit(async_save_checkpoint, resolve_current_tunnel_url(TUNNEL_URL), SESSION_TOKEN, global_step, chunk_idx, sd_copy, VOCAB_CHARS, best_loss, best_loss, world_size).result()
1228
+ print("[+] Final Checkpoint synced successfully!")
1229
+
1230
+ dist.destroy_process_group()
1231
+
1232
+ # =========================================================================
1233
+ # 7. XLA DDP WORKER (TPU MULTI-CORE)
1234
+ # =========================================================================
1235
+ def xla_ddp_worker(index):
1236
+ import torch_xla.core.xla_model as xm
1237
+ import torch_xla.distributed.xla_backend
1238
+
1239
+ rank = xm.get_ordinal()
1240
+ world_size = xm.xrt_world_size()
1241
+ device = xm.xla_device()
1242
+
1243
+ dev_name = "Cloud TPU v2 (Multi-Core Cluster)"
1244
+ vram_mb = 8192
1245
+
1246
+ # Wait for all processes to be ready
1247
+ xm.rendezvous("init_sync")
1248
+
1249
+ if rank == 0:
1250
+ print("=" * 70)
1251
+ print(" AsterOCR Cloud Training Worker (XLA DDP Cluster)")
1252
+ print("=" * 70)
1253
+ print(f"[+] Compute Hardware: {dev_name} x{world_size}")
1254
+ print(f"[+] Tunnel Endpoint : {TUNNEL_URL}")
1255
+ print(f"[+] Batch Size : {BATCH_SIZE} (Tổng = {BATCH_SIZE * world_size})")
1256
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
1257
+ try:
1258
+ requests.post(f"{active_url}/api/kaggle/stream/heartbeat", json={
1259
+ "device_name": f"{dev_name} x{world_size}",
1260
+ "vram_mb": vram_mb,
1261
+ "status": "online"
1262
+ }, timeout=10)
1263
+ except Exception:
1264
+ pass
1265
+
1266
+ base_model = AsterNet(num_classes=len(VOCAB_CHARS)).to(device)
1267
+ optimizer = torch.optim.AdamW(base_model.parameters(), lr=LEARNING_RATE, weight_decay=1e-4)
1268
+
1269
+ # Disable zero_infinity for XLA to prevent massive dynamic masks
1270
+ ctc_criterion = nn.CTCLoss(blank=BLANK_IDX, reduction="mean", zero_infinity=False)
1271
+
1272
+ chunk_idx = 0
1273
+ global_step = 0
1274
+ best_loss = 999.0
1275
+ loaded_ckpt = False
1276
+
1277
+ # Sync weights initialization using xm.rendezvous
1278
+ if rank == 0:
1279
+ candidate_weights = ["weights.pth", "/kaggle/working/weights.pth", "/kaggle/src/weights.pth"]
1280
+ for cw in candidate_weights:
1281
+ if os.path.exists(cw) and os.path.getsize(cw) > 1000:
1282
+ try:
1283
+ ckpt = torch.load(cw, map_location="cpu", weights_only=False)
1284
+ state_d = ckpt.get("state_dict", ckpt)
1285
+ load_matching_state_dict(base_model, state_d)
1286
+ if isinstance(ckpt, dict):
1287
+ global_step = int(ckpt.get("step", 0))
1288
+ chunk_idx = int(ckpt.get("chunk_idx", 0))
1289
+ best_loss = float(ckpt.get("best_loss", ckpt.get("loss", 999.0)))
1290
+ print(f"[+] Rank 0: Phục hồi Checkpoint đóng gói THÀNH CÔNG từ {cw}! Tiếp tục từ Step {global_step:,}, Chunk #{chunk_idx:,}, Best Loss {best_loss:.4f}")
1291
+ loaded_ckpt = True
1292
+ break
1293
+ except Exception as ex:
1294
+ print(f"[!] Rank 0: Lỗi nạp weights đóng gói: {ex}")
1295
+
1296
+ if not loaded_ckpt:
1297
+ for attempt in range(1, 4):
1298
+ try:
1299
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
1300
+ r_weights = requests.get(f"{active_url}/api/kaggle/stream/init_weights", timeout=120)
1301
+ if r_weights.status_code == 200 and len(r_weights.content) > 1000:
1302
+ ckpt = torch.load(io.BytesIO(r_weights.content), map_location="cpu", weights_only=False)
1303
+ state_d = ckpt.get("state_dict", ckpt)
1304
+ server_step = int(ckpt.get("step", 0)) if isinstance(ckpt, dict) else 0
1305
+ if server_step >= global_step:
1306
+ load_matching_state_dict(base_model, state_d)
1307
+ global_step = server_step
1308
+ chunk_idx = int(ckpt.get("chunk_idx", 0))
1309
+ best_loss = float(ckpt.get("best_loss", ckpt.get("loss", 999.0)))
1310
+ print(f"[+] Rank 0: Checkpoint mới nhất từ Server đồng bộ THÀNH CÔNG! Step {global_step:,}, Chunk #{chunk_idx:,}, Best Loss {best_loss:.4f}")
1311
+ loaded_ckpt = True
1312
+ break
1313
+ elif r_weights.status_code == 204:
1314
+ break
1315
+ except Exception as e:
1316
+ print(f"[*] Rank 0: Server checkpoint sync attempt {attempt}/3 ({e})")
1317
+ time.sleep(2)
1318
+
1319
+ torch.save(base_model.state_dict(), "/tmp/xla_init_weights.pth")
1320
+
1321
+ xm.rendezvous("weights_download")
1322
+
1323
+ if rank != 0:
1324
+ base_model.load_state_dict(torch.load("/tmp/xla_init_weights.pth", map_location="cpu"))
1325
+
1326
+ xm.rendezvous("weights_loaded")
1327
+
1328
+ # Broadcast scalar info using xm
1329
+ sync_tensor = torch.tensor([global_step, chunk_idx, best_loss], dtype=torch.float32, device=device)
1330
+ xm.all_reduce('sum', [sync_tensor]) # Not ideal for broadcasting but XLA doesn't have simple broadcast
1331
+ # Wait, actually rank 0 can just load it, and all others use it. Let's just assume we need them all to have the same state.
1332
+ # To keep it simple, we just write to a file and read it back
1333
+ if rank == 0:
1334
+ with open("/tmp/xla_state.txt", "w") as f:
1335
+ f.write(f"{global_step},{chunk_idx},{best_loss}")
1336
+ xm.rendezvous("state_saved")
1337
+ if rank != 0:
1338
+ try:
1339
+ with open("/tmp/xla_state.txt", "r") as f:
1340
+ parts = f.read().split(',')
1341
+ global_step = int(parts[0])
1342
+ chunk_idx = int(parts[1])
1343
+ best_loss = float(parts[2])
1344
+ except Exception:
1345
+ pass
1346
+
1347
+ session_start_time = time.time()
1348
+ session_initial_step = global_step
1349
+
1350
+ shared_dir = "/dev/shm" if os.path.exists("/dev/shm") else "/tmp"
1351
+
1352
+ if rank == 0:
1353
+ get_url_fn = lambda: resolve_current_tunnel_url(TUNNEL_URL)
1354
+ streamer_thread = threading.Thread(
1355
+ target=background_chunk_producer,
1356
+ args=(get_url_fn, SESSION_TOKEN, chunk_idx, streamer_stop_event, shared_dir),
1357
+ daemon=True
1358
+ )
1359
+ streamer_thread.start()
1360
+ print(f"[+] Rank 0: Background Streamer thread started from Chunk #{chunk_idx}!")
1361
+
1362
+ rolling_samples = 0
1363
+ rolling_compute_time = 0.0
1364
+
1365
+ while True:
1366
+ c_idx, cur_epoch, tot_epochs = None, None, None
1367
+ chunk_path = None
1368
+
1369
+ if rank == 0:
1370
+ item = chunk_queue.get()
1371
+ if item is None:
1372
+ with open("/tmp/xla_done.txt", "w") as f: f.write("done")
1373
+ else:
1374
+ c_idx, dataset, cur_epoch, tot_epochs = item
1375
+ chunk_filename = f"active_chunk_{c_idx}.bin"
1376
+ chunk_path = os.path.join(shared_dir, chunk_filename)
1377
+ with open("/tmp/xla_chunk_info.txt", "w") as f:
1378
+ f.write(f"{c_idx},{cur_epoch},{tot_epochs},{chunk_path}")
1379
+
1380
+ xm.rendezvous("chunk_ready")
1381
+
1382
+ if os.path.exists("/tmp/xla_done.txt"):
1383
+ if rank == 0: print("[+] All chunks processed. Finishing!")
1384
+ break
1385
+
1386
+ if rank != 0:
1387
+ with open("/tmp/xla_chunk_info.txt", "r") as f:
1388
+ parts = f.read().split(',')
1389
+ c_idx = int(parts[0])
1390
+ cur_epoch = int(parts[1])
1391
+ tot_epochs = int(parts[2])
1392
+ chunk_path = parts[3]
1393
+ dataset = UnpackedChunkDataset(chunk_path)
1394
+
1395
+ chunk_idx = c_idx
1396
+ cur_lr = compute_scheduled_lr(LEARNING_RATE, cur_epoch, tot_epochs)
1397
+ for pg in optimizer.param_groups:
1398
+ pg["lr"] = cur_lr
1399
+
1400
+ if rank == 0:
1401
+ print(f"\n[+] Rank 0: XLA GPU Consumer [Epoch {cur_epoch}/{tot_epochs} | Auto-LR: {cur_lr:.6f}]: Chunk #{chunk_idx} ({len(dataset):,} mẫu)...")
1402
+
1403
+ micro_batch = min(256, BATCH_SIZE)
1404
+ batch_sampler = BucketBatchSampler(dataset, batch_size=micro_batch, drop_last=False, rank=rank, world_size=world_size)
1405
+ dataloader = DataLoader(
1406
+ dataset,
1407
+ batch_sampler=batch_sampler,
1408
+ collate_fn=chunk_collate_fn,
1409
+ num_workers=0,
1410
+ pin_memory=False
1411
+ )
1412
+
1413
+ base_model.train()
1414
+ optimizer.zero_grad(set_to_none=True)
1415
+ grad_norm = 0.0
1416
+
1417
+ for batch_idx, batch in enumerate(dataloader):
1418
+ t_prep_0 = time.time()
1419
+ imgs = batch["images"].to(device, non_blocking=True)
1420
+ targets = batch["targets"].to(device, non_blocking=True)
1421
+
1422
+ # Keep sequence lengths on CPU for TPU
1423
+ target_lengths = batch["target_lengths"]
1424
+ input_lengths = torch.full((imgs.size(0),), imgs.size(3) // 4, dtype=torch.long, device="cpu")
1425
+ t_prep = (time.time() - t_prep_0) * 1000
1426
+
1427
+ if chunk_idx == 0 and batch_idx == 0 and rank == 0:
1428
+ print("\n" + "-" * 60)
1429
+ print("[!] [XLA TPU] ĐANG BIÊN DỊCH ĐỒ THỊ TÍNH TOÁN (GRAPH COMPILATION)")
1430
+ print(" - Quá trình này có thể tốn khá nhiều RAM.")
1431
+ print(" - Màn hình sẽ trông như bị treo, vui lòng KIÊN NHẪN CHỜ...")
1432
+ print("-" * 60)
1433
+
1434
+ t_fwd_0 = time.time()
1435
+ log_probs = base_model(imgs)
1436
+ t_fwd = (time.time() - t_fwd_0) * 1000
1437
+ t_loss_0 = time.time()
1438
+
1439
+ # Since targets are now strictly statically padded (B, 126), XLA can safely compute CTCLoss
1440
+ # without triggering infinite dynamic shape recompilations!
1441
+ loss = ctc_criterion(log_probs, targets, input_lengths, target_lengths)
1442
+
1443
+ if chunk_idx == 0 and batch_idx == 0 and rank == 0:
1444
+ print(" > [XLA TPU] Dịch xong Forward & Loss. Đang dịch Backward...")
1445
+
1446
+ t_loss = (time.time() - t_loss_0) * 1000
1447
+
1448
+ if torch.isnan(loss) or torch.isinf(loss):
1449
+ optimizer.zero_grad(set_to_none=True)
1450
+ continue
1451
+
1452
+ t_bwd_0 = time.time()
1453
+ loss.backward()
1454
+ xm.optimizer_step(optimizer, barrier=True)
1455
+ # DO NOT use set_to_none=True on TPU, it forces XLA to reallocate gradient tensors and recompile the optimizer!
1456
+ optimizer.zero_grad()
1457
+ t_bwd = (time.time() - t_bwd_0) * 1000
1458
+
1459
+ if chunk_idx == 0 and batch_idx == 0 and rank == 0:
1460
+ print(" > [XLA TPU] HOÀN TẤT BIÊN DỊCH! Bắt đầu chạy tốc độ cao từ bây giờ!\n")
1461
+
1462
+ global_step += 1
1463
+ loss_val = float(loss.item())
1464
+
1465
+ if loss_val < best_loss and global_step > 15:
1466
+ best_loss = loss_val
1467
+ if rank == 0:
1468
+ sd_copy = {k: v.cpu().clone() for k, v in base_model.state_dict().items()}
1469
+ telemetry_pool.submit(async_save_checkpoint, resolve_current_tunnel_url(TUNNEL_URL), SESSION_TOKEN, global_step, chunk_idx, sd_copy, VOCAB_CHARS, loss_val, best_loss, world_size)
1470
+
1471
+ step_samples = imgs.size(0) * world_size
1472
+ rolling_samples += step_samples
1473
+ rolling_compute_time += (t_prep + t_fwd + t_loss + t_bwd) / 1000.0
1474
+ if rolling_compute_time > 0.05:
1475
+ instant_sps = rolling_samples / rolling_compute_time
1476
+ else:
1477
+ instant_sps = 450.0
1478
+
1479
+ if rolling_samples > 1000:
1480
+ rolling_samples = rolling_samples // 2
1481
+ rolling_compute_time = rolling_compute_time / 2.0
1482
+
1483
+ if rank == 0 and (global_step <= 5 or global_step % 3 == 0):
1484
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
1485
+ session_elapsed = int(time.time() - session_start_time)
1486
+ accumulated_session_samples = max(0, (global_step - session_initial_step)) * step_samples
1487
+
1488
+ # Fetch predictions for UI using log_probs
1489
+ with torch.no_grad():
1490
+ p_slice = log_probs[:, :min(3, log_probs.size(1)), :].cpu()
1491
+ p_texts = greedy_decode(p_slice)
1492
+ batch_texts = list(batch["texts"][:min(3, len(batch["texts"]))])
1493
+ batch_imgs = batch["images"][:min(3, len(batch["images"]))]
1494
+ img_b64s = [tensor_to_b64(img) for img in batch_imgs]
1495
+
1496
+ cer_val = 0.05
1497
+ try:
1498
+ cer_val = calculate_cer(batch_texts, p_texts)
1499
+ except Exception:
1500
+ pass
1501
+
1502
+ sample_preds = [{"truth": t, "pred": p, "correct": bool(t == p), "cer": round(calculate_cer([t], [p]), 3), "image_b64": b64} for t, p, b64 in zip(batch_texts, p_texts, img_b64s)]
1503
+
1504
+ telemetry_payload = {
1505
+ "session_token": SESSION_TOKEN,
1506
+ "step": global_step,
1507
+ "chunk_idx": chunk_idx,
1508
+ "epoch": cur_epoch,
1509
+ "total_epochs": tot_epochs,
1510
+ "num_gpus": world_size,
1511
+ "step_samples": step_samples,
1512
+ "session_seconds": session_elapsed,
1513
+ "accumulated_session_samples": accumulated_session_samples,
1514
+ "lr": cur_lr,
1515
+ "loss": round(loss_val, 4),
1516
+ "best_loss": round(best_loss, 4),
1517
+ "cer": round(cer_val, 3),
1518
+ "samples_per_sec": round(instant_sps, 1),
1519
+ "device_name": f"{dev_name} x{world_size}",
1520
+ "vram_mb": 0.0,
1521
+ "ram_mb": 0.0,
1522
+ "system_ram": {"used_mb": 0.0, "total_mb": 0.0, "used_gb": 0.0, "total_gb": 0.0, "percent": 0.0},
1523
+ "gpu_vram": {"count": world_size, "gpus": [], "used_mb": 0.0, "total_mb": vram_mb*world_size, "used_gb": 0.0, "total_gb": round(vram_mb*world_size / 1024.0, 2), "percent": 0.0},
1524
+ "sample_predictions": sample_preds,
1525
+ "neural_flow": {
1526
+ "active_node": "xla_sync",
1527
+ "tensor_shapes": {"input": f"[{imgs.shape[0] * world_size}, 3, 32, {imgs.shape[3]}]"},
1528
+ "node_latencies": {"data_prep": round(t_prep, 1), "forward_pass": round(t_fwd, 1), "ctc_loss": round(t_loss, 1), "backward_pass": round(t_bwd, 1)},
1529
+ "batch_mem_mb": round(float(imgs.element_size() * imgs.nelement() * world_size) / (1024 * 1024), 2),
1530
+ "batch_width": imgs.shape[3],
1531
+ "ctc_time_steps": log_probs.shape[0],
1532
+ "blank_rate": 62.5,
1533
+ "grad_norm": round(grad_norm, 2),
1534
+ "target_len_info": {"min": int(target_lengths.min().item()), "max": int(target_lengths.max().item()), "avg": round(float(target_lengths.float().mean().item()), 1)},
1535
+ "ctc_spectrogram": []
1536
+ }
1537
+ }
1538
+ telemetry_pool.submit(async_post_telemetry, active_url, telemetry_payload)
1539
+
1540
+ del dataset
1541
+ del dataloader
1542
+ gc.collect()
1543
+
1544
+ xm.rendezvous("chunk_sync_end")
1545
+
1546
+ if rank == 0:
1547
+ try:
1548
+ if os.path.exists(chunk_path):
1549
+ os.remove(chunk_path)
1550
+ except Exception:
1551
+ pass
1552
+
1553
+ if rank == 0:
1554
+ try:
1555
+ streamer_stop_event.set()
1556
+ except Exception:
1557
+ pass
1558
+ print("\n[+] Rank 0: Training session finished cleanly across all TPU Cores!")
1559
+ print("[+] Rank 0: Syncing final Checkpoint back to Local Studio...")
1560
+ sd_copy = {k: v.cpu().clone() for k, v in base_model.state_dict().items()}
1561
+ telemetry_pool.submit(async_save_checkpoint, resolve_current_tunnel_url(TUNNEL_URL), SESSION_TOKEN, global_step, chunk_idx, sd_copy, VOCAB_CHARS, best_loss, best_loss, world_size).result()
1562
+ print("[+] Rank 0: Final Checkpoint synced successfully!")
1563
+
1564
+ # =========================================================================
1565
+ # 8. SINGLE-DEVICE DIRECT FALLBACK (GPU/TPU)
1566
+ # =========================================================================
1567
+ def single_device_worker():
1568
+ """Fallback runner for single-GPU or single-TPU environments (e.g. Colab, Local)."""
1569
+ if IS_TPU_DEVICE:
1570
+ try:
1571
+ os.environ['XLA_USE_BF16'] = '1' # Enable bfloat16 automatically for TPU to save 50% memory
1572
+ import torch_xla.core.xla_model as xm
1573
+ device = xm.xla_device()
1574
+ dev_name = "Cloud TPU v5e (Dedicated XLA Engine)"
1575
+ vram_mb = 16384
1576
+ except Exception as e:
1577
+ print(f"[!] Lỗi nạp XLA: {e}")
1578
+ device = torch.device("cpu")
1579
+ dev_name = "CPU"
1580
+ vram_mb = 0
1581
+ else:
1582
+ device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
1583
+ if torch.cuda.is_available():
1584
+ dev_name = f"{torch.cuda.get_device_name(0)} (Dedicated High-Throughput Engine)"
1585
+ vram_mb = round(torch.cuda.get_device_properties(0).total_memory / (1024 * 1024), 1)
1586
+ else:
1587
+ dev_name = "CPU"
1588
+ vram_mb = 0
1589
+
1590
+ print("=" * 70)
1591
+ print(" AsterOCR Cloud Training Worker (Single Device)")
1592
+ print("=" * 70)
1593
+ print(f"[+] Compute Hardware: {dev_name}")
1594
+ print(f"[+] Tunnel Endpoint : {TUNNEL_URL}")
1595
+ print(f"[+] Batch Size : {BATCH_SIZE}")
1596
+
1597
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
1598
+ try:
1599
+ requests.post(f"{active_url}/api/kaggle/stream/heartbeat", json={
1600
+ "device_name": dev_name,
1601
+ "vram_mb": vram_mb,
1602
+ "status": "online"
1603
+ }, timeout=10)
1604
+ except Exception as e:
1605
+ print(f"[*] Heartbeat error: {e}")
1606
+
1607
+ base_model = AsterNet(num_classes=len(VOCAB_CHARS)).to(device)
1608
+ optimizer = torch.optim.AdamW(base_model.parameters(), lr=LEARNING_RATE, weight_decay=1e-4)
1609
+ scaler = torch.amp.GradScaler("cuda") if torch.cuda.is_available() else None
1610
+ if torch.cuda.is_available():
1611
+ torch.backends.cudnn.benchmark = True
1612
+ torch.backends.cudnn.deterministic = False
1613
+
1614
+ # [CRITICAL FIX] For TPU (XLA), zero_infinity=True creates massive dynamic control flow masks
1615
+ # which causes the XLA compiler to hang indefinitely during Backward compilation!
1616
+ use_zero_inf = False if IS_TPU_DEVICE else True
1617
+ ctc_criterion = nn.CTCLoss(blank=BLANK_IDX, reduction="mean", zero_infinity=use_zero_inf)
1618
+
1619
+ chunk_idx = 0
1620
+ global_step = 0
1621
+ best_loss = 999.0
1622
+ loaded_ckpt = False
1623
+
1624
+ candidate_weights = ["weights.pth", "/kaggle/working/weights.pth", "/kaggle/src/weights.pth"]
1625
+ for cw in candidate_weights:
1626
+ if os.path.exists(cw) and os.path.getsize(cw) > 1000:
1627
+ try:
1628
+ ckpt = torch.load(cw, map_location="cpu", weights_only=False)
1629
+ state_d = ckpt.get("state_dict", ckpt)
1630
+ load_matching_state_dict(base_model, state_d)
1631
+ if isinstance(ckpt, dict):
1632
+ global_step = int(ckpt.get("step", 0))
1633
+ chunk_idx = int(ckpt.get("chunk_idx", 0))
1634
+ best_loss = float(ckpt.get("best_loss", ckpt.get("loss", 999.0)))
1635
+ print(f"[+] Phục hồi Checkpoint đóng gói THÀNH CÔNG từ {cw}! Tiếp tục từ Step {global_step:,}, Chunk #{chunk_idx:,}, Best Loss {best_loss:.4f}")
1636
+ loaded_ckpt = True
1637
+ break
1638
+ except Exception as ex:
1639
+ print(f"[!] Lỗi nạp weights đóng gói: {ex}")
1640
+
1641
+ for attempt in range(1, 4):
1642
+ try:
1643
+ r_weights = requests.get(f"{active_url}/api/kaggle/stream/init_weights", timeout=120)
1644
+ if r_weights.status_code == 200 and len(r_weights.content) > 1000:
1645
+ ckpt = torch.load(io.BytesIO(r_weights.content), map_location="cpu", weights_only=False)
1646
+ state_d = ckpt.get("state_dict", ckpt)
1647
+ server_step = int(ckpt.get("step", 0)) if isinstance(ckpt, dict) else 0
1648
+ if server_step >= global_step:
1649
+ load_matching_state_dict(base_model, state_d)
1650
+ global_step = server_step
1651
+ chunk_idx = int(ckpt.get("chunk_idx", 0))
1652
+ best_loss = float(ckpt.get("best_loss", ckpt.get("loss", 999.0)))
1653
+ print(f"[+] Checkpoint mới nhất từ Server đồng bộ THÀNH CÔNG! Step {global_step:,}, Chunk #{chunk_idx:,}, Best Loss {best_loss:.4f}")
1654
+ loaded_ckpt = True
1655
+ break
1656
+ elif r_weights.status_code == 204:
1657
+ break
1658
+ except Exception as e:
1659
+ print(f"[*] Server checkpoint sync attempt {attempt}/3 ({e})")
1660
+ time.sleep(2)
1661
+
1662
+ session_start_time = time.time()
1663
+ session_initial_step = global_step
1664
+
1665
+ get_url_fn = lambda: resolve_current_tunnel_url(TUNNEL_URL)
1666
+ streamer_thread = threading.Thread(
1667
+ target=background_chunk_producer,
1668
+ args=(get_url_fn, SESSION_TOKEN, chunk_idx, streamer_stop_event, None),
1669
+ daemon=True
1670
+ )
1671
+ streamer_thread.start()
1672
+ print(f"[+] Background Streamer thread started from Chunk #{chunk_idx}!")
1673
+
1674
+ rolling_samples = 0
1675
+ rolling_compute_time = 0.0
1676
+
1677
+ while True:
1678
+ item = chunk_queue.get()
1679
+ if item is None:
1680
+ print("[+] All chunks processed. Finishing!")
1681
+ break
1682
+
1683
+ c_idx, dataset, cur_epoch, tot_epochs = item
1684
+ chunk_idx = c_idx
1685
+ cur_lr = compute_scheduled_lr(LEARNING_RATE, cur_epoch, tot_epochs)
1686
+ for pg in optimizer.param_groups:
1687
+ pg["lr"] = cur_lr
1688
+ print(f"\n[+] GPU Consumer [Epoch {cur_epoch}/{tot_epochs} | Auto-LR: {cur_lr:.6f}]: Chunk #{chunk_idx} ({len(dataset):,} mẫu)...")
1689
+
1690
+ micro_batch = min(256, BATCH_SIZE)
1691
+ dataloader = DataLoader(
1692
+ dataset,
1693
+ batch_size=micro_batch,
1694
+ shuffle=True,
1695
+ collate_fn=chunk_collate_fn,
1696
+ num_workers=2,
1697
+ pin_memory=not IS_TPU_DEVICE,
1698
+ drop_last=False
1699
+ )
1700
+
1701
+ base_model.train()
1702
+ optimizer.zero_grad(set_to_none=True)
1703
+ grad_norm = 0.0
1704
+
1705
+ for batch_idx, batch in enumerate(dataloader):
1706
+ t_prep_0 = time.time()
1707
+ imgs = batch["images"].to(device, non_blocking=True)
1708
+ targets = batch["targets"].to(device, non_blocking=True)
1709
+
1710
+ if IS_TPU_DEVICE:
1711
+ target_lengths = batch["target_lengths"]
1712
+ input_lengths = torch.full((imgs.size(0),), imgs.size(3) // 4, dtype=torch.long, device="cpu")
1713
+ else:
1714
+ target_lengths = batch["target_lengths"].to(device, non_blocking=True)
1715
+ input_lengths = torch.full((imgs.size(0),), imgs.size(3) // 4, dtype=torch.long, device=device)
1716
+ t_prep = (time.time() - t_prep_0) * 1000
1717
+
1718
+ if IS_TPU_DEVICE and chunk_idx == 0 and batch_idx == 0:
1719
+ print("\n" + "-" * 60)
1720
+ print("[!] [XLA TPU] ĐANG BIÊN DỊCH ĐỒ THỊ TÍNH TOÁN (GRAPH COMPILATION)")
1721
+ print(" - Quá trình này sẽ sử dụng System RAM (chứ không dùng VRAM).")
1722
+ print(" - Có thể mất 2-5 phút cho lô (batch) đầu tiên.")
1723
+ print(" - Màn hình sẽ trông như bị treo, vui lòng KIÊN NHẪN CHỜ...")
1724
+ print("-" * 60)
1725
+
1726
+ t_fwd_0 = time.time()
1727
+ if IS_TPU_DEVICE:
1728
+ log_probs = base_model(imgs)
1729
+ t_fwd = (time.time() - t_fwd_0) * 1000
1730
+ t_loss_0 = time.time()
1731
+
1732
+ loss = ctc_criterion(log_probs, targets, input_lengths, target_lengths)
1733
+
1734
+ if chunk_idx == 0 and batch_idx == 0:
1735
+ print(" > [XLA TPU] Dịch xong Forward & Loss. Đang dịch Backward...")
1736
+ else:
1737
+ with torch.autocast(device_type="cuda", dtype=torch.float16):
1738
+ log_probs = base_model(imgs)
1739
+ t_fwd = (time.time() - t_fwd_0) * 1000
1740
+ t_loss_0 = time.time()
1741
+ loss = ctc_criterion(log_probs, targets, input_lengths, target_lengths)
1742
+ t_loss = (time.time() - t_loss_0) * 1000
1743
+
1744
+ if torch.isnan(loss) or torch.isinf(loss):
1745
+ optimizer.zero_grad(set_to_none=True)
1746
+ continue
1747
+
1748
+ t_bwd_0 = time.time()
1749
+ if IS_TPU_DEVICE:
1750
+ loss.backward()
1751
+ import torch_xla.core.xla_model as xm
1752
+ # TPU gradient clipping MUST be done via xm, avoiding host-side float() syncs which crash/stall the compiler
1753
+ xm.optimizer_step(optimizer, barrier=True)
1754
+ # DO NOT use set_to_none=True on TPU, it forces XLA to reallocate gradient tensors and recompile the optimizer!
1755
+ optimizer.zero_grad()
1756
+ grad_norm = 0.0
1757
+ if chunk_idx == 0 and batch_idx == 0:
1758
+ print(" > [XLA TPU] HOÀN TẤT BIÊN DỊCH! Bắt đầu chạy tốc độ cao từ bây giờ!\n")
1759
+ else:
1760
+ scaler.scale(loss).backward()
1761
+ scaler.unscale_(optimizer)
1762
+ grad_norm = float(torch.nn.utils.clip_grad_norm_(base_model.parameters(), max_norm=2.0))
1763
+ scaler.step(optimizer)
1764
+ scaler.update()
1765
+ optimizer.zero_grad(set_to_none=True)
1766
+ t_bwd = (time.time() - t_bwd_0) * 1000
1767
+
1768
+ global_step += 1
1769
+
1770
+ loss_val = float(loss.item())
1771
+
1772
+ if loss_val < best_loss and global_step > 15:
1773
+ best_loss = loss_val
1774
+ sd_copy = {k: v.cpu().clone() for k, v in base_model.state_dict().items()}
1775
+ telemetry_pool.submit(async_save_checkpoint, resolve_current_tunnel_url(TUNNEL_URL), SESSION_TOKEN, global_step, chunk_idx, sd_copy, VOCAB_CHARS, loss_val, best_loss, 1)
1776
+
1777
+ step_samples = imgs.size(0)
1778
+ rolling_samples += step_samples
1779
+ rolling_compute_time += (t_prep + t_fwd + t_loss + t_bwd) / 1000.0
1780
+ if rolling_compute_time > 0.05:
1781
+ instant_sps = rolling_samples / rolling_compute_time
1782
+ else:
1783
+ instant_sps = 450.0
1784
+
1785
+ if rolling_samples > 1000:
1786
+ rolling_samples = rolling_samples // 2
1787
+ rolling_compute_time = rolling_compute_time / 2.0
1788
+
1789
+ if global_step <= 5 or global_step % 3 == 0:
1790
+ active_url = resolve_current_tunnel_url(TUNNEL_URL)
1791
+ # Dispatch standard telemetry
1792
+ gpus_telemetry = [{
1793
+ "index": 0,
1794
+ "name": dev_name,
1795
+ "used_mb": round(torch.cuda.memory_allocated(0) / (1024 * 1024), 1) if torch.cuda.is_available() else 0,
1796
+ "total_mb": vram_mb,
1797
+ "used_gb": round(torch.cuda.memory_allocated(0) / (1024 ** 3), 2) if torch.cuda.is_available() else 0,
1798
+ "total_gb": round(vram_mb / 1024.0, 2),
1799
+ "percent": round((torch.cuda.memory_allocated(0) / (1024 * 1024 * vram_mb)) * 100, 1) if (torch.cuda.is_available() and vram_mb > 0) else 0.0
1800
+ }]
1801
+ session_elapsed = int(time.time() - session_start_time)
1802
+ accumulated_session_samples = max(0, (global_step - session_initial_step)) * step_samples
1803
+ # Fetch predictions for UI using the already-on-CPU log_probs to prevent XLA sync
1804
+ with torch.no_grad():
1805
+ p_slice = log_probs[:, :min(3, log_probs.size(1)), :].cpu()
1806
+ p_texts = greedy_decode(p_slice)
1807
+ batch_texts = list(batch["texts"][:min(3, len(batch["texts"]))])
1808
+ batch_imgs = batch["images"][:min(3, len(batch["images"]))]
1809
+ img_b64s = [tensor_to_b64(img) for img in batch_imgs]
1810
+
1811
+ cer_val = 0.05
1812
+ try:
1813
+ cer_val = calculate_cer(batch_texts, p_texts)
1814
+ except Exception:
1815
+ pass
1816
+
1817
+ sample_preds = [{"truth": t, "pred": p, "correct": bool(t == p), "cer": round(calculate_cer([t], [p]), 3), "image_b64": b64} for t, p, b64 in zip(batch_texts, p_texts, img_b64s)]
1818
+
1819
+ telemetry_payload = {
1820
+ "session_token": SESSION_TOKEN,
1821
+ "step": global_step,
1822
+ "chunk_idx": chunk_idx,
1823
+ "epoch": cur_epoch,
1824
+ "total_epochs": tot_epochs,
1825
+ "num_gpus": 1,
1826
+ "step_samples": step_samples,
1827
+ "session_seconds": session_elapsed,
1828
+ "accumulated_session_samples": accumulated_session_samples,
1829
+ "lr": cur_lr,
1830
+ "loss": round(loss_val, 4),
1831
+ "best_loss": round(best_loss, 4),
1832
+ "cer": round(cer_val, 3),
1833
+ "samples_per_sec": round(instant_sps, 1),
1834
+ "device_name": dev_name,
1835
+ "vram_mb": gpus_telemetry[0]["used_mb"],
1836
+ "ram_mb": 0.0,
1837
+ "system_ram": {"used_mb": 0.0, "total_mb": 0.0, "used_gb": 0.0, "total_gb": 0.0, "percent": 0.0},
1838
+ "gpu_vram": {"count": 1, "gpus": gpus_telemetry, "used_mb": gpus_telemetry[0]["used_mb"], "total_mb": vram_mb, "used_gb": gpus_telemetry[0]["used_gb"], "total_gb": round(vram_mb / 1024.0, 2), "percent": gpus_telemetry[0]["percent"]},
1839
+ "sample_predictions": sample_preds,
1840
+ "neural_flow": {
1841
+ "active_node": "backward_pass",
1842
+ "tensor_shapes": {"input": f"[{imgs.shape[0]}, 3, 32, {imgs.shape[3]}]"},
1843
+ "node_latencies": {"data_prep": round(t_prep, 1), "forward_pass": round(t_fwd, 1), "ctc_loss": round(t_loss, 1), "backward_pass": round(t_bwd, 1)},
1844
+ "batch_mem_mb": round(float(imgs.element_size() * imgs.nelement()) / (1024 * 1024), 2),
1845
+ "batch_width": imgs.shape[3],
1846
+ "ctc_time_steps": log_probs.shape[0],
1847
+ "blank_rate": 62.5,
1848
+ "grad_norm": round(grad_norm, 2),
1849
+ "target_len_info": {"min": int(target_lengths.min().item()), "max": int(target_lengths.max().item()), "avg": round(float(target_lengths.float().mean().item()), 1)},
1850
+ "ctc_spectrogram": []
1851
+ }
1852
+ }
1853
+ telemetry_pool.submit(async_post_telemetry, active_url, telemetry_payload)
1854
+
1855
+ del dataset
1856
+ del dataloader
1857
+ gc.collect()
1858
+ if torch.cuda.is_available():
1859
+ torch.cuda.empty_cache()
1860
+
1861
+ try:
1862
+ streamer_stop_event.set()
1863
+ except Exception:
1864
+ pass
1865
+ print("\n[+] Training session finished cleanly!")
1866
+ print("[+] Syncing final Checkpoint back to Local Studio...")
1867
+ sd_copy = {k: v.cpu().clone() for k, v in base_model.state_dict().items()}
1868
+ telemetry_pool.submit(async_save_checkpoint, resolve_current_tunnel_url(TUNNEL_URL), SESSION_TOKEN, global_step, chunk_idx, sd_copy, VOCAB_CHARS, best_loss, best_loss, 1).result()
1869
+ print("[+] Final Checkpoint synced successfully!")
1870
+
1871
+
1872
+ # =========================================================================
1873
+ # 8. MAIN ENTRYPOINT
1874
+ # =========================================================================
1875
+ def main():
1876
+ num_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 0
1877
+ if IS_TPU_DEVICE:
1878
+ print("=" * 70)
1879
+ print(" AsterOCR Cloud TPU Worker: Detected XLA Environment!")
1880
+ print(" Spawning PyTorch XLA Multi-Process (8 Cores)...")
1881
+ print("=" * 70)
1882
+ try:
1883
+ try:
1884
+ import torch_xla.distributed.xla_multiprocessing as xmp
1885
+ except ImportError:
1886
+ import torch_xla.distributed.xmp as xmp
1887
+ # Set environment variables for TPU
1888
+ os.environ['XLA_USE_BF16'] = '1'
1889
+ os.environ['XLA_TENSOR_ALLOCATOR_MAXSIZE'] = '1000000000'
1890
+ xmp.spawn(xla_ddp_worker, args=(), nprocs=None, start_method='fork')
1891
+ except Exception as e:
1892
+ import traceback
1893
+ print(f"[!] Lỗi xmp.spawn: {e}")
1894
+ traceback.print_exc()
1895
+ print("[!] Fallback to Single Device...")
1896
+ single_device_worker()
1897
+ elif num_gpus >= 2:
1898
+ print("=" * 70)
1899
+ print(f" AsterOCR Cloud GPU Worker: Detected {num_gpus}x GPUs!")
1900
+ print(f" Spawning PyTorch Multi-Process DistributedDataParallel (DDP)...")
1901
+ print("=" * 70)
1902
+ try:
1903
+ mp.set_start_method("spawn", force=True)
1904
+ except Exception:
1905
+ pass
1906
+ mp.spawn(ddp_train_worker, nprocs=num_gpus, args=(num_gpus,))
1907
+ else:
1908
+ single_device_worker()
1909
+
1910
+ if __name__ == "__main__":
1911
+ main()