সরাসরি পর্বে যান
পর্ব ১২ · ৯১-এর মধ্যে · মডিউল ২

GPU, mixed precision আর গতি — device, AMP আর profiling

GPU, Mixed Precision and Speed — Devices, Correct Timing, Memory, AMP, torch.compile and the Profiler

Taught in Bangla, this lesson measures speed on real desktop GPU runs: why GPUs win on large matmuls but lose on tiny ones, why asynchronous execution needs synchronize or CUDA events for honest timing, and where memory goes across parameters, gradients, AdamW state and activations. It compares float16 and bfloat16 with autocast and GradScaler on step time and loss, tries torch.compile on Windows, reads the profiler, and covers learning without a GPU.

এই পর্বে যা শিখবেন

  • GPU কোন কাজে দ্রুত আর কোন কাজে নয়, আর একটি device চলক দিয়ে model আর batch সামলানো
  • Warm-up, torch.cuda.synchronize() আর CUDA event দিয়ে সঠিকভাবে সময় মাপা
  • GPU memory কোথায় যায়: parameter, gradient, optimizer state, activation; হাতে হিসাব আর মাপা মিলিয়ে দেখা
  • float16 বনাম bfloat16-এর পরিসর আর নির্ভুলতা, autocast আর loss scaling কীভাবে কাজ করে
  • torch.compile আর torch.profiler দিয়ে কোথায় সময় যায় তা দেখা, আর GPU ছাড়াও কীভাবে এগোবেন

মডিউল ২-এর আগের পর্বগুলোতে পর্ব ০৭-এ tensor-এর device আর .to(), পর্ব ১০-এ পুরো training loop, আর পর্ব ১১-এ DataLoader-এর গতি মেপেছেন। এই পর্বের প্রশ্ন: একই loop GPU-তে কত দ্রুত চলে, কেন, আর সেই “কত” সৎভাবে কীভাবে মাপবেন? PyTorch পাতায় চলে না, তাই GPU-র সব সংখ্যা ডেস্কটপের সত্যিকারের run থেকে নেওয়া। গোপনীয়তার নিয়মে যন্ত্রের মডেল বা memory-র মাপ দেওয়া হয়নি, শুধু “GPU-তে” আর “CPU-তে” সময় আর program-এর ব্যবহার করা MB. পাতার চারটি ব্লক ব্রাউজারের Python আর NumPy-তে চলে; প্রথমবার প্যাকেজ নামতে কয়েক সেকেন্ড লাগে।

১ · GPU কেন দ্রুত — অনেক ছোট কর্মী আর matrix গুণ

পরীক্ষার পর এক লাখ খাতা দেখতে হবে। একজন অভিজ্ঞ প্রধান পরীক্ষক (CPU-র একটি বড় core) কঠিন, জটিল উত্তরেও দ্রুত সিদ্ধান্ত নেন, কিন্তু একা এক লাখ খাতা দেখতে মাস লাগবে। অন্যদিকে দুই হাজার সহকারী পরীক্ষক (GPU-র ছোট core) প্রত্যেকে একই উত্তরপত্রের নিয়ম মেনে শুধু নিজের ভাগের খাতা দেখেন। কাজ সহজ আর একই ধরনের হলে সহকারীরা মিলে অনেক দ্রুত। কিন্তু মাত্র দশটি খাতা হলে খাতা বিলি আর ফেরত আনার ঝামেলাতেই সময় চলে যায়; তখন প্রধান পরীক্ষক একাই আগে শেষ করেন।

Matrix গুণ ঠিক “একই ধরনের অনেক কাজ”: C = AB-তে n × n ফলের প্রতিটি ঘর আলাদা একটি dot product, আর একটি ঘরের হিসাব অন্যটির অপেক্ষা করে না। মোট কাজ প্রায় 2n³ FLOP (প্রতিটি ঘরে n গুণ আর n যোগ)। GPU এই ঘরগুলো হাজার হাজার ছোট হিসাব-এককে ভাগ করে দেয়, আর আধুনিক NVIDIA GPU-তে Tensor Core নামের বিশেষ একক ছোট matrix-এর গুণ-যোগ এক ধাপে করে। GPU-তে সাধারণ হিসাব চালানোর প্ল্যাটফর্মের নাম CUDA. GPU-র ভেতরের গঠন (SIMT, memory hierarchy) কম্পিউটার আর্কিটেকচার কোর্সের পর্ব ৫৫-এ আছে; এখানে আমরা শুধু ব্যবহারকারীর দিক দেখব।

CPU-তেও একই নীতি কাজ করে, ছোট মাপে: Python-এর লুপে প্রতিটি গুণ আলাদা interpreter-এর কাজ, আর NumPy-র A @ B পুরো কাজ একবারে compiled BLAS library-কে দেয়, যা SIMD আর cache ঠিকমতো ব্যবহার করে। একে বলে vectorization (AI-এর ভিত্তি কোর্সের পর্ব ১৯)। নিচের ব্লক একই 64 × 64 গুণ দুভাবে করে, তারপর বড় মাপে NumPy-র GFLOP/s মাপে।

import time
import numpy as np

rng = np.random.default_rng(42)
n = 64
A, B = rng.normal(size=(n, n)), rng.normal(size=(n, n))

def matmul_loop(A, B):             # প্রতিটি গুণ-যোগ আলাদা Python কাজ
    C = [[0.0] * n for _ in range(n)]
    for i in range(n):
        for j in range(n):
            s = 0.0
            for k in range(n):
                s += A[i, k] * B[k, j]
            C[i][j] = s
    return np.array(C)

def per_call(fn, reps):            # ব্রাউজারের ঘড়ি মোটা দাগের, তাই অনেকবার চালিয়ে গড়
    fn()                           # warm-up
    t = time.perf_counter()
    for _ in range(reps): fn()
    return (time.perf_counter() - t) / reps

t_loop = per_call(lambda: matmul_loop(A, B), 2)
t_vec = per_call(lambda: A @ B, 2000)
print(f"64x64 matmul: {2 * n**3:,} floating-point operations (গুণ আর যোগ)")
print(f"Python loop: {t_loop * 1e3:8.1f} ms")
print(f"NumPy  A @ B: {t_vec * 1e3:8.3f} ms")
print(f"একই ফল? {np.allclose(matmul_loop(A, B), A @ B)} | loop / vectorised ≈ {t_loop / t_vec:,.0f} গুণ")
for m in (128, 256, 512):
    X = rng.normal(size=(m, m)); t = per_call(lambda: X @ X, 5)
    print(f"NumPy {m}x{m}: {t * 1e3:7.2f} ms, {2 * m**3 / t / 1e9:5.2f} GFLOP/s")

আউটপুট (পাতার Python-এ চালানো; সময় আপনার ব্রাউজার আর যন্ত্রে আলাদা হবে):

64x64 matmul: 524,288 floating-point operations (গুণ আর যোগ)
Python loop:     76.8 ms
NumPy  A @ B:    0.121 ms
একই ফল? True | loop / vectorised ≈ 634 গুণ
NumPy 128x128:    1.20 ms,  3.50 GFLOP/s
NumPy 256x256:   10.64 ms,  3.15 GFLOP/s
NumPy 512x512:  273.52 ms,  0.98 GFLOP/s

একই ফল, কিন্তু Python-এর লুপ প্রায় ৬৩৪ গুণ ধীর (এই run-এ; ডেস্কটপের সাধারণ Python-এ একই কোডে অনুপাত কয়েক হাজার গুণ এসেছিল)। কারণ লুপে প্রায় ২.৬ লাখ বার interpreter-কে আলাদা করে জিজ্ঞেস করতে হয় “এটি কোন ধরনের সংখ্যা, কোথায় আছে”, আর A @ B একবারেই পুরো কাজ compiled কোডে দেয়। নিচের তিন লাইন একটি সীমাও দেখায়: পাতার Python একটি thread-এ WebAssembly-তে চলে, তাই গতি কয়েক GFLOP/s, আর 512 × 512-এ data cache-এ না ধরায় আরও কমেছে। অংশ ৪-এ দেখবেন, একই ধরনের গুণ GPU-তে প্রায় ৮ TFLOP/s, অর্থাৎ হাজার গুণের ঘরে বেশি। শিক্ষাটি একই: কাজকে বড়, একসঙ্গে করা যায় এমন টুকরোয় দিলে hardware পুরো ক্ষমতা দেখাতে পারে।

২ · Device-এর নিয়ম — একটি device চলক, model আর batch সরানো

PyTorch-এ প্রতিটি tensor একটি device-এ থাকে, আর একটি কাজের সব tensor একই device-এ থাকতে হয়। কোড পরিষ্কার রাখার প্রচলিত ধাঁচ তিনটি: (১) শুরুতে একবার device ঠিক করা; (২) model একবার model.to(device); (৩) প্রতিটি batch লুপের ভেতরে xb.to(device). কোথাও .cuda() ছড়িয়ে লিখলে CPU-তে চালাতে গেলে সব জায়গায় বদলাতে হয়। ডেস্কটপে CUDA-সহ PyTorch install করা হলো (PyTorch-এর CUDA wheel index pyproject.toml-এ, CPU index-এর মতোই; ঠিক কোন CUDA version তা নিজের driver দেখে PyTorch-এর install পাতা থেকে বেছে নিতে হয়):

~/bazarghar-dl$ uv add torch numpy
 …
 + numpy==2.5.4
 + torch==2.15.0+cu132
 …
import torch
from torch import nn
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("device:", device)

model = nn.Linear(16, 1).to(device)              # model একবার সরানো
xb = torch.randn(8, 16)                           # batch এখনো CPU-তে
try:
    model(xb)
except RuntimeError as e:
    print("RuntimeError:", str(e).splitlines()[0][:160])
out = model(xb.to(device))
print("ok:", out.device, out.shape, "| param on", next(model.parameters()).device)
print("tensor .to() returns a copy:", xb.to(device) is xb, "| module .to() in place:",
      model.to(device) is model)

সত্যিকারের run (ডেস্কটপ, Python 3.14.4, PyTorch 2.15.0 CUDA build, NumPy 2.5.4; লেখার সময়ের সংস্করণ):

~/bazarghar-dl$ uv run python device_threads.py
device: cuda
RuntimeError: Expected all tensors to be on the same device, but got mat1 is on cpu, different from other tensors on cuda:0 (when checking argument in method wrapper_CUDA_add …
ok: cuda:0 torch.Size([8, 1]) | param on cuda:0
tensor .to() returns a copy: False | module .to() in place: True
…

Error-টি নিজেই বলে দেয় কোন tensor (mat1, অর্থাৎ input) কোথায় আছে। শেষ লাইনের পার্থক্য খুব কাজের: nn.Module-এর .to() নিজের parameter-গুলোকেই সরায় আর একই object ফেরত দেয়, তাই model.to(device) আলাদা করে লিখলেও চলে। কিন্তু tensor-এর .to() নতুন tensor ফেরত দেয়; xb.to(device) লিখে ফল কোথাও না রাখলে xb CPU-তেই থাকে। এটি AI-এর লেখা কোডের একটি চেনা বাগ। Optimizer তৈরির আগেই model সরানো ভালো অভ্যাস, যাতে optimizer সঠিক parameter ধরে।

GPU-তে প্রথম কাজটি অস্বাভাবিক ধীর হয়। নিচের stepper একই run-এর gpu_timing.py-র প্রথম লাইনের সংখ্যা ধরে দেখায় কেন।

একই process-এ পরপর চারটি কাজ · 1024 × 1024 float32
কাজ
পেছনে যা ঘটে
মাপা সময়
  1. Import. import torch GPU-তে কিছু চালায় না; CUDA-র আসল প্রস্তুতি হয় প্রথম কাজের সময় (lazy initialization).
  2. Context. প্রথম CUDA tensor তৈরিতে ৭১ ms: driver এই process-এর জন্য CUDA context বানায়, যা প্রতিটি process-এ একবারই হয়।
  3. প্রথম matmul. ১০২.৩ ms, যদিও আসল গুণে এক millisecond-এরও কম লাগে। বাকিটা cuBLAS লোড, প্রস্তুতি আর kernel-এর প্রথম লোডের এককালীন খরচ।
  4. দ্বিতীয় matmul. ১.২৯ ms, তবু পরের মাপের median ০.২৯৩ ms-এর চেয়ে বেশি; GPU অলস অবস্থা থেকে পূর্ণ গতিতে উঠতে আরও কয়েকটি কাজ নিতে পারে।
  5. শিক্ষা. প্রথম কয়েকটি call বাদ দিয়ে মাপুন; সঙ্গে প্রথম call-এর সময়ও আলাদা জানান, কারণ ছোট script বা server চালু হওয়ার সময় ব্যবহারকারী সেটাই অনুভব করেন।

৩ · সঠিকভাবে সময় মাপা — asynchronous execution, synchronize আর CUDA event

CPU যখন C = A @ B লাইনে আসে, GPU-কে একটি ছোট প্রোগ্রাম (kernel) চালানোর নির্দেশ একটি সারিতে (CUDA stream) রেখেই পরের লাইনে চলে যায়; একে বলে kernel launch. ফলে CPU-র ঘড়ি দিয়ে মাপলে শুধু পাঠানোর সময় আসে। torch.cuda.synchronize() CPU-কে থামিয়ে রাখে যতক্ষণ না GPU-র সারি খালি হয়। কোনো ফল CPU-তে আনলে (.item(), .cpu(), print) সেটাও লুকিয়ে synchronize করে; তাই training loop-এ প্রতি ধাপে loss.item() ছাপা loop-কে ধীর করতে পারে।

CPU কাজ সারিতে পাঠায়, GPU পরে চালায়

Asynchronous execution: what a timer measures with and without synchronize

Timeline of CPU launching CUDA kernels and GPU executing them CPU GPU k1k2k3 k1 চলছেk2 চলছেk3 চলছে t0 sync ছাড়া থামে sync-সহ থামে লাল দাগ: শুধু “পাঠানোর” সময় (ভুল) সবুজ দাগ: GPU-র কাজ সত্যিই শেষ (সঠিক)
ছবি ১২.১ — CPU তিনটি kernel-কে GPU-র সারিতে (CUDA stream) রেখে সঙ্গে সঙ্গে পরের লাইনে চলে যায়। torch.cuda.synchronize() CPU-কে সবুজ দাগ পর্যন্ত অপেক্ষা করায়।
n = 4096
A = torch.randn(n, n, device=device); B = torch.randn(n, n, device=device)
for _ in range(3): A @ B                                  # warm-up
torch.cuda.synchronize()
t0 = time.perf_counter(); C = A @ B; t1 = time.perf_counter()
print(f"without synchronize: {1e3*(t1-t0):.3f} ms  (শুধু কাজ queue-তে পাঠানোর সময়)")
torch.cuda.synchronize()
t0 = time.perf_counter(); C = A @ B; torch.cuda.synchronize(); t1 = time.perf_counter()
print(f"with synchronize:    {1e3*(t1-t0):.3f} ms")
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
start.record(); C = A @ B; end.record(); torch.cuda.synchronize()
print(f"cuda.Event timing:   {start.elapsed_time(end):.3f} ms")

সত্যিকারের run (GPU-তে, float32):

~/bazarghar-dl$ uv run python gpu_timing.py
Python 3.14.4 | torch 2.15.0+cu132 | numpy 2.5.4
CUDA available: True
first CUDA call (context): 71 ms | first matmul: 102.3 ms | second matmul: 1.29 ms
without synchronize: 0.190 ms  (শুধু কাজ queue-তে পাঠানোর সময়)
with synchronize:    20.126 ms
cuda.Event timing:   16.997 ms
…

একই কাজ, তিনটি উত্তর। Synchronize ছাড়া ০.১৯০ ms: এটি শুধু launch-এর সময়, আসল কাজের প্রায় একশো ভাগের এক ভাগ। এই সংখ্যা দিয়ে CPU-র সঙ্গে তুলনা করলে হাজার গুণের ভুয়া “speedup” পাওয়া যেত। Synchronize-সহ ২০.১ ms সঠিক, তবে এতে CPU-র ঘড়ি পড়া আর synchronize-এর ছোট খরচও আছে। CUDA event দুটি GPU-র সারিতেই বসে, তাই GPU যখন সত্যিই সেখানে পৌঁছায় তখনকার সময় লেখে: ১৭.০ ms. একটি মাপে ৩ ms-এর পার্থক্য স্বাভাবিক ওঠানামাও হতে পারে; তাই নিয়ম হলো কয়েকবার মেপে median নেওয়া, যা পরের অংশের টেবিলে করা হয়েছে।

GPU-তে সময় মাপার চার নিয়ম

(১) আগে warm-up; (২) টাইমার থামানোর আগে torch.cuda.synchronize(), বা CUDA event; (৩) কয়েকবার মেপে median, আর ছড়ানোটাও দেখা; (৪) কী মাপছেন তা লেখা: শুধু kernel, পুরো training ধাপ, নাকি ডেটা কপিসহ। Python-এ ছোট কাজ মাপার জন্য torch.utils.benchmark এই নিয়মগুলো নিজেই মানে।

৪ · CPU বনাম GPU — matmul-এর আকার, একটি training ধাপ আর ডেটা কপি

এবার নিয়ম মেনে মাপ: প্রতিটি আকারে দুবার warm-up, তারপর পাঁচবার মেপে median. CPU-তে PyTorch-এর default thread-সংখ্যা ব্যবহার হয়েছে, অর্থাৎ CPU-কেও পূর্ণ সুযোগ দেওয়া হয়েছে।

~/bazarghar-dl$ uv run python gpu_timing.py
…
 n     CPU ms     GPU ms   GPU TFLOP/s  speedup
   64     0.013     0.023       0.02      0.6x
  256     0.076     0.050       0.68      1.5x
 1024     3.337     0.293       7.33     11.4x
 2048    25.546     2.128       8.07     12.0x
 4096   205.244    17.571       7.82     11.7x

64 × 64-এ GPU CPU-র চেয়ে ধীর: কাজ মাত্র ৫ লাখ FLOP, অথচ launch আর synchronize-এর স্থির খরচই কয়েক microsecond. আকার বাড়লে GPU-র অর্জিত গতি ০.০২ থেকে প্রায় ৮ TFLOP/s-এ ওঠে, তারপর থিতিয়ে যায়: তখন GPU-র float32 হিসাবের ক্ষমতাই সীমা। Speedup তাই আকারের function, একটি সংখ্যা নয়। (এক যন্ত্রের এক run; অন্য যন্ত্রে অনুপাত অনেক আলাদা হবে।)

এবার বাজারঘরের একটি সত্যিকারের training ধাপ: ১৬টি feature থেকে ডেলিভারির সময় (seed দিয়ে বানানো ৬৫,৫৩৬টি কাল্পনিক অর্ডার), model 16 → 1024 → 1024 → 1, AdamW, প্রতি ধাপে forward, backward আর step. ধাপের ভেতরে প্রতিবার batch .to(device, non_blocking=True) দিয়ে পাঠানো হয়।

~/bazarghar-dl$ uv run python train_step.py
model 16 -> 1024 -> 1024 -> 1, AdamW, ms per training step
batch    64: CPU     2.68 ms | GPU   1.17 ms |   2.3x
batch  1024: CPU    14.27 ms | GPU   1.65 ms |   8.7x
batch  8192: CPU    92.67 ms | GPU   8.37 ms |  11.1x
batch 8192, data resident: GPU   8.30 ms
batch 8192, data pageable: GPU   8.09 ms
batch 8192, data pinned  : GPU   8.08 ms

Batch ৬৪-এ GPU মাত্র ২.৩ গুণ দ্রুত: প্রতি ধাপে কয়েক ডজন ছোট kernel, আর Python ও launch-এর খরচই প্রধান, GPU-র বেশির ভাগ একক খালি বসে থাকে। Batch ৬৪ থেকে ১০২৪-এ, অর্থাৎ ১৬ গুণ ডেটায়, GPU-র সময় বেড়েছে মাত্র ১.১৭ থেকে ১.৬৫ ms: বাড়তি কাজ খালি এককগুলোতে ঢুকে গেছে। এজন্যই GPU-তে বড় batch প্রায় “বিনা খরচে” আসে, যতক্ষণ memory আর শেখার মান অনুমতি দেয় (batch আকার আর learning rate-এর সম্পর্ক পর্ব ১৫-তে)।

শেষ তিন লাইন ডেটা কপির প্রশ্ন: পুরো ডেটা আগে থেকে GPU-তে (resident), সাধারণ RAM থেকে প্রতি ধাপে কপি (pageable), আর pinned memory থেকে কপি। এখানে তিনটি প্রায় সমান, কারণ একটি batch মাত্র 8192 × 16 × 4 byte = ০.৫ MB, যা হিসাবের তুলনায় নগণ্য। ছবি বা লম্বা টেক্সটের batch অনেক বড়, তখন pinned memory আর non_blocking=True কপিকে হিসাবের সঙ্গে একসঙ্গে চালাতে সাহায্য করে; DataLoader-এ pin_memory=True আর num_workers পর্ব ১১-এর অংশ ৭-এ মাপা হয়েছে। সত্যিকারের প্রকল্পে প্রায়ই bottleneck GPU নয়, ডেটা: ছবি decode, টেক্সট tokenize বা disk থেকে পড়া। GPU খালি বসে থাকলে দ্রুততর GPU কিছুই বদলায় না।

৫ · Memory কোথায় যায় — parameter, gradient, optimizer state আর activation

GPU-র memory CPU-র RAM-এর চেয়ে সাধারণত অনেক কম, আর “CUDA out of memory” error-ই নতুনদের সবচেয়ে চেনা দেয়াল। Training-এ চার ধরনের জিনিস memory নেয়: parameter (weight আর bias); gradient, প্রতিটি parameter-এর সমান আকারের; optimizer state, momentum-সহ SGD-তে প্রতি parameter-এ একটি, AdamW-এ দুটি moment (মেশিন লার্নিং কোর্সের পর্ব ১৪); আর activation, backward-এর জন্য জমিয়ে রাখা forward-এর মাঝের মান (পর্ব ০৪-এর অংশ ৭)। প্রথম তিনটি batch-এর ওপর নির্ভর করে না, শেষটি batch আর স্তরের সঙ্গে বাড়ে।

float32 training-এ: মোট ≈ P × (4 + 4 + 4k) byte + activation

এখানে P হলো parameter-এর সংখ্যা আর k হলো parameter-প্রতি optimizer state-এর সংখ্যা (SGD ০, momentum ১, AdamW ২)। AdamW-এ তাই প্রতি parameter-এ ১৬ byte. PyTorch-এ মাপার হাতিয়ার torch.cuda.memory_allocated() (এই মুহূর্তে tensor-এর জন্য ব্যবহৃত) আর max_memory_allocated() (শেষ reset-এর পর সর্বোচ্চ)। একটি 1024 → 4096 → 4096 → 1024 MLP, batch ২৫৬, তিনটি optimizer, দ্বিতীয় ধাপে মাপা (প্রথম ধাপে optimizer state তৈরি হয়):

~/bazarghar-dl$ uv run python gpu_memory.py
SGD           params 25.18 M (96.0 MB float32) | model 98.0 | after forward 108.0 | after backward 196.1 | after step+zero_grad 100.0 | peak 208.2 MB
SGD+momentum  params 25.18 M (96.0 MB float32) | model 98.0 | after forward 204.1 | after backward 292.1 | after step+zero_grad 196.1 | peak 304.2 MB
AdamW         params 25.18 M (96.0 MB float32) | model 98.0 | after forward 300.1 | after backward 388.1 | after step+zero_grad 292.1 | peak 484.2 MB

লাইন ধরে পড়ুন। model ৯৮.০: ৯৬ MB weight আর ২ MB input ও target. after forward: SGD-তে +১০ MB activation; momentum-এ আরও ৯৬ MB, AdamW-এ আরও ১৯২ MB, যা আগের ধাপের optimizer state. after backward: +৮৮ MB, অর্থাৎ ৯৬ MB gradient এসেছে আর প্রায় ৮ MB activation মুক্ত হয়েছে। after step+zero_grad: set_to_none=True gradient মুছে দেয়, তাই SGD-তে আবার ১০০ MB. peak: SGD-তে ২০৮ MB, প্রায় weight + gradient + activation. AdamW-এর peak ৪৮৪ MB, হাতের হিসাবের (৯৮ + ৯৬ + ১৯২ + ১০ ≈ ৩৯৬) চেয়ে প্রায় ৮৮ MB বেশি; সম্ভবত step-এর সময় PyTorch-এর দ্রুত (foreach) রূপ কিছু অস্থায়ী tensor বানায়। তাই হাতের হিসাব নিচের সীমা, আসল সংখ্যা মেপেই জানতে হয়।

নিচের ব্লকটি একই হিসাবের একটি calculator. প্রথম অংশ ঠিক এই MLP-এর জন্য, যাতে মাপার সঙ্গে মেলানো যায়; দ্বিতীয় অংশ ১০০ কোটি parameter-এর একটি model-এর জন্য, তিনটি precision-এর ধাঁচে। Activation-এর হিসাবে প্রতিটি স্তরের Z আর H দুটিই ধরা হয়েছে, তাই এটি উপরের সীমার দিকে।

MB = 2 ** 20
BYTES = {"float32": 4, "float16": 2, "bfloat16": 2}
OPT_STATES = {"SGD": 0, "SGD+momentum": 1, "AdamW": 2}    # parameter-প্রতি কতটি float32 state

def mlp_params(sizes):             # nn.Linear(i, o): i*o weight + o bias
    return sum(i * o + o for i, o in zip(sizes[:-1], sizes[1:]))

def train_memory(P, opt, act_count=0, dtype="float32", master=False):
    p = P * BYTES[dtype]                       # model-এর weight
    g = P * BYTES[dtype]                       # gradient, weight-এর dtype-এ
    m = P * 4 if master else 0                 # mixed precision: float32 master copy
    s = P * 4 * OPT_STATES[opt]                # optimizer state সাধারণত float32
    a = act_count * BYTES[dtype]               # backward-এর জন্য জমা activation
    return {k: v / MB for k, v in dict(param=p, grad=g, master=m, opt=s, act=a).items()}

sizes = [1024, 4096, 4096, 1024]; P = mlp_params(sizes)
B = 256; acts = B * (1024 + 4096 * 2 + 4096 * 2 + 1024)   # x, Z1, H1, Z2, H2, out
print(f"MLP {sizes}: {P:,} parameter, batch {B}")
for opt in OPT_STATES:
    r = train_memory(P, opt, acts)
    print(f"{opt:13s}", " ".join(f"{k} {v:6.1f}" for k, v in r.items()),
          f"| মোট {sum(r.values()):6.1f} MB")

print("\n১০০ কোটি (1B) parameter-এর model, AdamW, activation বাদে:")
for name, dt, master in [("সব float32", "float32", False),
                         ("bf16 weight + fp32 master", "bfloat16", True),
                         ("শুধু bf16 weight (inference)", "bfloat16", False)]:
    r = train_memory(10**9, "AdamW", 0, dt, master)
    if name.startswith("শুধু"): r = {"param": r["param"]}
    print(f"{name:30s} {sum(r.values()) / 1024:6.1f} GB  ({', '.join(f'{k} {v/1024:.1f}' for k, v in r.items() if v)})")

আউটপুট (পাতার Python-এ চালানো):

MLP [1024, 4096, 4096, 1024]: 25,175,040 parameter, batch 256
SGD           param   96.0 grad   96.0 master    0.0 opt    0.0 act   18.0 | মোট  210.1 MB
SGD+momentum  param   96.0 grad   96.0 master    0.0 opt   96.0 act   18.0 | মোট  306.1 MB
AdamW         param   96.0 grad   96.0 master    0.0 opt  192.1 act   18.0 | মোট  402.1 MB

১০০ কোটি (1B) parameter-এর model, AdamW, activation বাদে:
সব float32                       14.9 GB  (param 3.7, grad 3.7, opt 7.5)
bf16 weight + fp32 master        14.9 GB  (param 1.9, grad 1.9, master 3.7, opt 7.5)
শুধু bf16 weight (inference)      1.9 GB  (param 1.9)

Parameter-এর সংখ্যা সত্যিকারের run-এর সঙ্গে হুবহু (২৫.১৮ M, ৯৬.০ MB). হাতের হিসাবে মোট SGD-তে ২১০, momentum-এ ৩০৬, AdamW-এ ৪০২ MB; মাপা peak ২০৮, ৩০৪ আর ৪৮৪ MB. প্রথম দুটি প্রায় মিলেছে, কারণ calculator activation একটু বেশি ধরেছে (১৮ MB, মাপা প্রায় ১০ MB: PyTorch ReLU-র input আর output দুটি না রেখে একটি রাখে) আর input-এর ২ MB ধরেনি। AdamW-এ বাড়তি প্রায় ৮২ MB আসলে step-এর অস্থায়ী tensor, যা কোনো সরল সূত্রে ধরা পড়ে না। দ্বিতীয় অংশটি বড় model-এর জন্য গুরুত্বপূর্ণ শিক্ষা: ১০০ কোটি parameter-এ সব float32-এ ১৪.৯ GB, আর bfloat16 weight-এর সঙ্গে float32 master কপি রাখলেও ঠিক একই ১৪.৯ GB (প্রতি parameter-এ ২ + ২ + ৪ + ৮ = ১৬ byte). অর্থাৎ mixed precision parameter-এর memory বাঁচায় না; বাঁচায় activation আর গতি। শুধু inference-এ (gradient আর optimizer নেই) bfloat16 weight ১.৯ GB, training-এর প্রায় আট ভাগের এক ভাগ। (১ GB এখানে 230 byte.)

৬ · float16 আর bfloat16 — পরিসর বনাম নির্ভুলতা

একটি দশমিক সংখ্যা তিন ভাগে রাখা হয়: চিহ্নের ১ bit, মাপের (exponent) কয়েক bit আর অঙ্কের (mantissa) কয়েক bit; বিস্তারিত কম্পিউটার আর্কিটেকচার কোর্সের পর্ব ২৩-এ। Exponent ঠিক করে পরিসর, অর্থাৎ কত বড় বা কত ছোট সংখ্যা ধরা যায়; mantissa ঠিক করে নির্ভুলতা, অর্থাৎ পাশাপাশি দুটি সংখ্যার ফাঁক কত। ১৬ bit-এ দুটিকেই পুরো পাওয়া যায় না, তাই দুটি ভিন্ন আপস জনপ্রিয়: float16 আর bfloat16.

তিনটি format-এর bit-এর ভাগ

Sign · exponent (range) · mantissa (precision)

Bit layout of float32, float16 and bfloat16 float32 · 32 bit float16 · 16 bit bfloat16 · 16 bit exp 8mantissa 23 exp 5mant 10 exp 8mant 7 max 65504 eps ≈ 0.00098 max ≈ 3.4e38 eps ≈ 0.0078
ছবি ১২.২ — লাল: চিহ্ন, নীল: exponent, সবুজ: mantissa. bfloat16 আসলে float32-এর ওপরের ১৬ bit: exponent একই (পরিসর একই), mantissa ২৩ থেকে ৭ bit-এ ছাঁটা। eps হলো machine epsilon, ১-এর পরের সংখ্যার দূরত্ব।

NumPy-তে float16 আছে, bfloat16 নেই। কিন্তু bfloat16 যেহেতু float32-এর ওপরের ১৬ bit, তাই float32-এর bit-গুলো একটি পূর্ণসংখ্যা হিসেবে পড়ে নিচের ১৬ bit শূন্য করলেই তার নকল পাওয়া যায় (এখানে সবচেয়ে কাছের মানে rounding-সহ, যেমন hardware সাধারণত করে)। নিচের ব্লক দুটি format-এর সীমা, কয়েকটি মান, আর একটি ছোট সংখ্যা বারবার যোগের ফল দেখায়।

import numpy as np

def to_bf16(x):                    # bfloat16-এর নকল: float32-এর নিচের ১৬ bit বাদ (round to nearest even)
    b = np.asarray(x, dtype=np.float32).view(np.uint32).astype(np.uint64)
    b = (b + 0x7FFF + ((b >> 16) & 1)) & 0xFFFF0000
    return b.astype(np.uint32).view(np.float32)

f16 = np.finfo(np.float16)
print(f"float16 : max {float(f16.max):.0f}, smallest normal {float(f16.tiny):.2e}, eps {float(f16.eps):.2e}")
bf_max = np.array([0x7F7F0000], dtype=np.uint32).view(np.float32)[0]
print(f"bfloat16: max {float(bf_max):.4g}, smallest normal {float(np.finfo(np.float32).tiny):.2e}, eps {2.0**-7:.2e}")

vals = np.array([70000.0, 1 / 3, 1e-8, 1001.0, 3.14159265], dtype=np.float32)
print("\n     মান        float16        bfloat16")
with np.errstate(over="ignore"):            # desktop-এ overflow warning চুপ রাখতে
    h = vals.astype(np.float16)
for v, a, b in zip(vals, h, to_bf16(vals)):
    print(f"{v:11.6g} {float(a):12.6g} {float(b):14.6g}")

# ছোট ধাপ বারবার যোগ: accumulator-এর dtype কেন গুরুত্বপূর্ণ
s16, s_bf, s32 = np.float16(0), np.float32(0), np.float32(0)
for _ in range(10000):
    s16 = np.float16(s16 + np.float16(0.01))
    s_bf = to_bf16(s_bf + to_bf16(0.01))
    s32 = np.float32(s32 + np.float32(0.01))
print(f"\n0.01 দশ হাজার বার যোগ (সঠিক 100): float16 {float(s16):.4g} | bfloat16 {float(s_bf):.4g} | float32 {float(s32):.6g}")

আউটপুট (পাতার Python-এ চালানো):

float16 : max 65504, smallest normal 6.10e-05, eps 9.77e-04
bfloat16: max 3.39e+38, smallest normal 1.18e-38, eps 7.81e-03

     মান        float16        bfloat16
      70000          inf          70144
   0.333333     0.333252       0.333984
      1e-08            0    1.00117e-08
       1001         1001           1000
    3.14159      3.14062        3.14062

0.01 দশ হাজার বার যোগ (সঠিক 100): float16 32 | bfloat16 4 | float32 100.003

প্রথম দুই লাইন ছবি ১২.২-এর সংখ্যা: float16-এর সর্বোচ্চ 65504 আর ছোটতম স্বাভাবিক মান 6.1 × 10−5; bfloat16-এর পরিসর float32-এর মতোই (3.39 × 1038 থেকে 1.18 × 10−38), কিন্তু eps ০.০০৭৮১, float16-এর আট গুণ মোটা। টেবিলে: 70000 float16-এ inf, bfloat16-এ 70144 (কাছের প্রতিনিধিত্বযোগ্য মান); সত্যিকারের PyTorch run-এও হুবহু এই দুটি মান এসেছে (অংশ ৭)। 10−8 float16-এ শূন্য, bfloat16-এ টিকে আছে। কিন্তু 1001 bfloat16-এ 1000 হয়ে গেছে: হাজারের ঘরে পাশাপাশি দুটি মানের দূরত্ব ৪, তাই ১ হারিয়ে যায়। শেষ লাইনটি সবচেয়ে শিক্ষণীয়: ০.০১ বারবার যোগ করলে যোগফল যখন এত বড় হয় যে তার ফাঁকের অর্ধেক ০.০১-এর চেয়ে বেশি, তখন প্রতিটি যোগ rounding-এ হারিয়ে যায়। float16 আটকে গেছে ৩২-এ, bfloat16 মাত্র ৪-এ; float32 প্রায় সঠিক ১০০.০০৩. এজন্যই mixed precision-এ যোগফল (matmul-এর ভেতরেও) আর weight-এর আপডেট float32-এ রাখা হয়।

Training-এ দুটি ঝুঁকি তাই আলাদা। float16-এ সমস্যা পরিসর: gradient প্রায়ই 10−6 থেকে 10−9-এর ঘরে, যা float16-এ শূন্য (underflow), আর বড় activation 65504 পেরোলে inf (overflow). bfloat16-এ পরিসরের ঝুঁকি প্রায় নেই, কিন্তু নির্ভুলতা কম। দুটির ক্ষেত্রেই সমাধানের মূল কথা: যোগফল, loss আর weight-এর আপডেট float32-এ রাখা। float16-এর পরিসরের সমস্যার জন্য আছে loss scaling: backward-এর আগে loss-কে বড় একটি সংখ্যা S দিয়ে গুণ করলে chain rule-এ প্রতিটি gradient-ও S গুণ হয়, ছোট gradient শূন্যে পড়ে না; তারপর float32-এ S দিয়ে ভাগ করে আপডেট। নিচের ব্লক এই কৌশলটি NumPy-তে নকল করে।

import numpy as np

rng = np.random.default_rng(42)    # ছোট-বড় মেশানো ১০ লাখ gradient (float32-এ "সত্যি" মান)
g = (rng.choice([-1, 1], 10**6) * 10.0 ** rng.uniform(-10, -2, 10**6)).astype(np.float32)

def through_fp16(g, scale):        # GradScaler-এর নকল: scale করে float16, তারপর float32-এ unscale
    with np.errstate(over="ignore"):
        h = (g * np.float32(scale)).astype(np.float16)
    return h.astype(np.float32) / np.float32(scale), h

print(" scale     শূন্য হয়ে গেছে   inf   গড় আপেক্ষিক ভুল")
for scale in (1, 2.0**10, 2.0**16, 2.0**24):
    back, h = through_fp16(g, scale)
    ok = np.isfinite(back) & (back != 0)
    rel = np.abs(back[ok] - g[ok]) / np.abs(g[ok])
    print(f"{scale:>9.0f} {np.mean(h == 0):12.1%} {np.isinf(h).sum():7d} {rel.mean():12.2e}")

# dynamic loss scaling: inf দেখলে scale অর্ধেক, ধাপ বাদ; নির্দিষ্ট সংখ্যক ভালো ধাপের পর দ্বিগুণ
scale, good, log = 2.0**24, 0, []
for step in range(8):
    _, h = through_fp16(g, scale)
    if np.isinf(h).any():
        log.append(f"{step}: inf, skip, scale {scale:.0f} -> {scale / 2:.0f}"); scale /= 2; good = 0
    else:
        good += 1; log.append(f"{step}: ok, scale {scale:.0f}")
        if good == 3: scale *= 2; good = 0
print("\n".join(log))

আউটপুট (পাতার Python-এ চালানো):

 scale     শূন্য হয়ে গেছে   inf   গড় আপেক্ষিক ভুল
        1        30.9%       0     4.30e-02
     1024         0.0%       0     7.45e-03
    65536         0.0%       0     2.65e-04
 16777216         0.0%   51107     1.76e-04
0: inf, skip, scale 16777216 -> 8388608
1: inf, skip, scale 8388608 -> 4194304
2: ok, scale 4194304
3: ok, scale 4194304
4: ok, scale 4194304
5: inf, skip, scale 8388608 -> 4194304
6: ok, scale 4194304
7: ok, scale 4194304

দশ লাখ নকল gradient-এর মাপ 10−10 থেকে 10−2. Scale ১ মানে সরাসরি float16: প্রায় ৩১% gradient শূন্য, অর্থাৎ ওই weight-গুলো কিছুই শিখত না। Scale 1024-এ কেউ শূন্য নয়, তবে খুব ছোটগুলো float16-এর কম নির্ভুল (subnormal) অঞ্চলে, তাই গড় আপেক্ষিক ভুল ০.৭%. 65536 (= 216, PyTorch-এর GradScaler-এর শুরুর মান) ভুল নামিয়েছে ০.০২৭%-এ। কিন্তু 224-এ বড় gradient-গুলো 65504 পেরিয়ে ৫১,১০৭টি inf. তাই scale একটি দোলনা: বেশি ছোট হলে underflow, বেশি বড় হলে overflow. শেষ আট লাইন dynamic loss scaling-এর নকল: inf পেলে সেই ধাপের আপডেট বাদ আর scale অর্ধেক; এখানে টানা তিনটি ভালো ধাপের পর দ্বিগুণ, যাতে দোলাটা চোখে পড়ে। PyTorch-এর default-এ এই সংখ্যা ২০০০ (এই version-এর source-এ growth_interval=2000), তাই বাস্তবে scale বাড়ে অনেক ধীরে।

৭ · Mixed precision — autocast আর GradScaler, সত্যিকারের গতি আর loss

Mixed precision-এর ধারণা: ভারী কাজ (matmul, convolution) ১৬ bit-এ, কারণ সেখানে Tensor Core অনেক দ্রুত আর memory-র যাতায়াত অর্ধেক; সংবেদনশীল কাজ (softmax, log, loss, যোগফল, weight আপডেট) float32-এ। PyTorch-এ পুরো model হাতে বদলাতে হয় না: torch.autocast-এর ভেতরে প্রতিটি কাজ নিজের তালিকা দেখে dtype ঠিক করে। নিচের stepper একটি Linear স্তরের ভেতরে কী হয় তা দেখায়; dtype-গুলো সত্যিকারের run থেকে।

x: (2, 8) float32 · W: (4, 8) float32 parameter
ধাপ
dtype
কী ঘটে
  1. বাইরে. সব tensor PyTorch-এর default dtype float32-এ; matmul-ও float32-এ চলে।
  2. ঢোকা. autocast parameter নিজে বদলায় না; Linear-এর জন্য x আর W-এর অস্থায়ী float16 কপি বানায়। একই region-এ W আবার লাগলে কপিটি cache থেকে আসে।
  3. গুণ. ফল h-এর dtype float16. Tensor Core-এ গুণ ১৬ bit-এ হলেও ভেতরের যোগফল সাধারণত float32-এ জমে, তারপর float16-এ লেখা হয়; তাই লম্বা dot product-এ নির্ভুলতা টিকে থাকে।
  4. পরের কাজ. softmax আর mse_loss float32 তালিকায়, তাই h float32-এ তুলে হিসাব। সত্যিকারের run-এ দুটিরই dtype float32 এসেছে।
  5. Backward. gradient float16 কপির পথ ধরে ফিরে আসল parameter-এ পৌঁছায়; W.grad float32. Optimizer-এর আপডেট তাই float32-এ হয়, যা আলাদা master কপির কাজ করে।

এবার পুরো training: বাজারঘরের একই ডেলিভারি ডেটা, model 16 → 2048 → 2048 → 2048 → 1, batch ৪০৯৬, AdamW, ৩০০ ধাপ, একই seed. তিনটি precision, আর চতুর্থ সারিতে float32 matmul-এ TF32 চালু করে। মূল লুপ:

scaler = torch.amp.GradScaler("cuda", enabled=(mode == "float16"))
for i in range(steps):
    xb, yb = next_batch(i)
    with torch.autocast("cuda", dtype=dtype, enabled=dtype is not None):
        loss = nn.functional.mse_loss(model(xb), yb)
    opt.zero_grad(set_to_none=True)
    scaler.scale(loss).backward()    # float16: loss × scale; অন্যথায় কিছুই করে না
    scaler.step(opt)                 # unscale, inf থাকলে এই ধাপ বাদ
    scaler.update()                  # পরের ধাপের scale ঠিক করা

সত্যিকারের run (GPU-তে; প্রতি ধাপের সময় প্রথম ২০ ধাপ বাদ দিয়ে median, প্রতি ধাপে synchronize; eval হলো শেষ ৮১৯২ সারিতে float32-এ loss):

~/bazarghar-dl$ uv run python amp_train.py
bf16 supported: True
weight torch.float32 | linear out torch.float16 | softmax torch.float32 | mse_loss torch.float32 | weight.grad torch.float32
float16(70000) = inf | bfloat16(70000) = 70144.0 | float16(1e-8) = 0.0
float32 :  28.03 ms/step | last loss 0.00423 | eval (float32) 0.00421 | peak 266 MB
float16 :  12.36 ms/step | last loss 0.00459 | eval (float32) 0.00451 | peak 262 MB | final scale 16384
bfloat16:  11.90 ms/step | last loss 0.00429 | eval (float32) 0.00424 | peak 262 MB
matmul precision 'high' (TF32) -> float32 :  20.83 ms/step | last loss 0.00430 | eval (float32) 0.00421 | peak 266 MB

গতি: float16 আর bfloat16 দুটিতেই ধাপ প্রায় ২.৩ গুণ দ্রুত (২৮.০ থেকে ১২.৪ ও ১১.৯ ms). TF32 মাঝামাঝি, ২০.৮ ms: input float32-ই থাকে, শুধু matmul-এর ভেতরে mantissa ছেঁটে Tensor Core ব্যবহার। নির্ভুলতা: bfloat16-এর eval loss ০.০০৪২৪, float32-এর ০.০০৪২১-এর প্রায় সমান; float16-এ ০.০০৪৫১, সামান্য বেশি। এক seed-এর এক run থেকে বলা যায় না যে float16 সাধারণভাবে খারাপ; বলা যায় শুধু, এই কাজে তিনটিই কাছাকাছি, আর পার্থক্য আছে কি না তা কয়েকটি seed-এ যাচাই করতে হবে (পর্ব ১৯-এ experiment-এর পদ্ধতি)। Scale: GradScaler-এর শুরুর scale 216 = 65536 (এই version-এর default); ৩০০ ধাপ শেষে 16384, অর্থাৎ পথে অন্তত দুবার inf পেয়ে ধাপ বাদ দিয়ে অর্ধেক হয়েছে। Default নিয়মে টানা ২০০০টি ভালো ধাপ হলে তবেই দ্বিগুণ হয়, তাই এত ছোট run-এ আর বাড়েনি।

Memory: পুরো run-এর peak প্রায় একই (২৬৬ বনাম ২৬২ MB). আলাদা একটি মাপ (amp_mem.py) কারণ দেখায়:

~/bazarghar-dl$ uv run python amp_mem.py
float32 : saved by forward   96.0 MB | peak in backward + 165.0 MB | peak in optimizer step  161.1 MB (absolute)
bfloat16: saved by forward   64.1 MB | peak in backward + 110.1 MB | peak in optimizer step  161.1 MB (absolute)

forward-এর জমা activation ৯৬ থেকে ৬৪ MB-এ নেমেছে (পুরো অর্ধেক নয়, কারণ weight-এর ১৬-bit কপিও জায়গা নেয়), আর backward-এর peak ১৬৫ থেকে ১১০ MB. কিন্তু parameter, gradient আর AdamW state autocast-এ float32-ই থাকে; এই ছোট model-এ সেগুলোই মোটের বড় অংশ। Activation-ভারী model-এ (বড় batch, লম্বা sequence, বড় ছবি) সঞ্চয় অনেক বেশি চোখে পড়ে।

দুটি চেনা ফাঁদ। (১) পুরোনো লেখায় আর AI-এর কোডে torch.cuda.amp.autocast() আর torch.cuda.amp.GradScaler() দেখবেন; নতুন version-এ এগুলো deprecated, বর্তমান রূপ উপরের কোডের মতো torch.autocast("cuda", …) আর torch.amp.GradScaler("cuda"). (২) Gradient clipping (পর্ব ১৭) করলে আগে scaler.unscale_(opt), নইলে বড় করা gradient-কে কাটা হয়। bfloat16-এ সাধারণত GradScaler লাগে না, কারণ তার পরিসর float32-এর সমান।

৮ · torch.compile আর profiler — কোথায় সময় যায়

Eager মোডে প্রতিটি কাজ আলাদা kernel: matmul, তারপর bias যোগ, তারপর GELU, প্রতিবার ফল memory-তে লেখা আর আবার পড়া। torch.compile (PyTorch 2.0, ২০২৩ থেকে) কোড চলার সময় graph ধরে, তারপর TorchInductor নামের compiler দিয়ে কয়েকটি কাজ জুড়ে নতুন kernel বানায় (kernel fusion); GPU-তে সেই kernel লেখা হয় Triton ভাষায়। পর্ব ০৫-এর define-by-run আর static graph-এর আলোচনার এটি আধুনিক মিশ্রণ। এই Windows যন্ত্রে প্রথম চেষ্টার সৎ ফল:

~/bazarghar-dl$ uv run python compile_try.py
W1106 … torch\utils\flop_counter.py:113] triton not found; flop counting will not work for triton kernels
eager:    first call     63.6 ms | then 7.361 ms per forward
torch.compile failed: TritonMissing
Cannot find a working triton installation. Either the package is not installed or it is too old. More information on installing Triton can be found at: https://github.com/triton-lang/triton

PyTorch-এর Windows wheel-এর সঙ্গে Triton আসে না, আর লেখার সময়ে Triton-এর মূল প্রকল্প Windows-এর জন্য অফিশিয়াল build দেয় না। একটি community-রক্ষিত রূপ আছে, triton-windows (লেখার সময়ে 3.9.0.post30). সেটি একই project-এ যোগ করে, compile cache project-এর ভেতরে রেখে আবার চালানো হলো:

~/bazarghar-dl$ uv add triton-windows
 …
 + triton-windows==3.9.0.post30
~/bazarghar-dl$ uv run python compile_try.py
… UserWarning: TRITON_DEFAULT_FP_FUSION is unset: defaulting to FP fusion ENABLED … (এক লাইনের সতর্কবার্তা)
eager:    first call     53.5 ms | then 7.335 ms per forward
compiled: first call   1510.8 ms | then 7.036 ms per forward
max |eager - compiled| = 1.430511474609375e-06
~/bazarghar-dl$ uv run python compile_try.py      # দ্বিতীয়বার, cache থেকে
 …
compiled: first call    516.3 ms | then 7.021 ms per forward

Model 512 → 2048 → GELU → 2048 → GELU → LayerNorm → 512, batch ৪০৯৬, inference. প্রথম call-এ compile-এর জন্য ১.৫ s, cache থাকলে ০.৫২ s; তারপর প্রতি forward ৭.৩৪ থেকে ৭.০৪ ms, মাত্র প্রায় ৪% লাভ, কারণ এখানে সময়ের বেশির ভাগ বড় matmul-এ, যা compile-এর আগেও cuBLAS-এর দ্রুত kernel-এ চলত; জোড়া লাগানোর মতো কাজ (GELU, LayerNorm) অল্প। ফলের পার্থক্য 1.4 × 10−6, float32-এর rounding-এর মাপ। শিক্ষা: compile-এর লাভ model আর platform-এর ওপর নির্ভর করে; অনেক ছোট উপাদান-ধরে কাজ থাকলে লাভ বেশি, লম্বা training-এ compile-এর এককালীন খরচ উঠে আসে, ছোট script-এ নাও আসতে পারে। Windows-এ বাড়তি package লাগবে, এটিও মাথায় রাখুন।

Profiler — অনুমান না করে দেখা

“কোথায় সময় যাচ্ছে” প্রশ্নে অনুমান প্রায়ই ভুল হয়। torch.profiler কয়েকটি ধাপ চালিয়ে প্রতিটি কাজের CPU আর GPU সময় মাপে। schedule(wait=1, warmup=2, active=5) মানে প্রথম ধাপ উপেক্ষা, পরের দুটি warm-up, তারপর পাঁচটি মাপা। PyTorch-এর নিজের টেবিলে GPU kernel-এর পুরো নামও আসে, যাতে hardware-এর প্রজন্ম বোঝা যায়; গোপনীয়তার জন্য এখানে key_averages() থেকে শুধু aten:: কাজগুলো একই কলামে ছাপা হয়েছে। Model 16 → 1024 → 1024 → 1, batch ৪০৯৬, AdamW:

~/bazarghar-dl$ uv run python profile_steps.py
Name                            Self CPU  CPU total  Self CUDA      %  Calls
aten::mm                          0.37ms     0.61ms    11.91ms  39.1%     25
aten::addmm                       0.37ms     0.57ms     6.27ms  20.6%     15
aten::threshold_backward          0.07ms     0.13ms     1.46ms   4.8%     10
aten::clamp_min                   0.08ms     0.15ms     1.00ms   3.3%     10
aten::sum                         0.15ms     0.28ms     0.62ms   2.0%     15
aten::_foreach_mul_               0.06ms     0.19ms     0.27ms   0.9%     10
aten::_foreach_addcdiv_           0.03ms     0.06ms     0.27ms   0.9%      5
aten::_foreach_addcmul_           0.03ms     0.06ms     0.20ms   0.7%      5
26 distinct GPU kernels | 234 kernel launches
GPU kernel time total: 30.45 ms over 5 active steps
16 → 1024 → 1024 → 1 · batch 4096 · AdamW
যা দেখছি
অর্থ
হিসাব
  1. কলাম. Self মানে শুধু ওই কাজের নিজের সময়, total মানে তার ভেতরে ডাকা কাজসহ। “%” এখানে GPU kernel-এর মোট সময়ের ভাগ; পাঁচ ধাপে মোট ৩০.৪৫ ms, ধাপপ্রতি প্রায় ৬.১ ms.
  2. সবচেয়ে বড় সারি. aten::mm একাই ৩৯.১%. ২৫টি call পাঁচ ধাপে, অর্থাৎ ধাপপ্রতি ৫টি: backward-এ তিনটি স্তরের weight-gradient আর দুটি input-gradient (প্রথম স্তরের input-এর gradient লাগে না)।
  3. Forward. aten::addmm হলো matmul আর bias যোগ একসঙ্গে, ধাপপ্রতি তিনটি স্তরে তিনটি। এর সময় mm-এর প্রায় অর্ধেক: backward-এ forward-এর প্রায় দ্বিগুণ matmul, যা পর্ব ০৪-এ গুনে দেখেছিলেন।
  4. CPU বনাম GPU. mm-এর Self CPU মাত্র ০.৩৭ ms, GPU-তে ১১.৯১ ms. CPU শুধু কাজ পাঠায়, GPU আসল কাজ করে; এই loop GPU-bound, তাই Python-এর দিক দ্রুত করে লাভ নেই।
  5. ছোট কাজ. ReLU-র forward (clamp_min), তার backward, bias-gradient-এর sum আর AdamW-এর _foreach_ কাজগুলো প্রতিটি ৫%-এর কম (০.৭ থেকে ৪.৮%). এরা memory-bound: প্রতি সংখ্যায় এক-দুটি হিসাব, কিন্তু পুরো tensor পড়া-লেখা। ধাপপ্রতি প্রায় ৪৭টি kernel launch.
  6. সিদ্ধান্ত. matmul প্রায় ৬০%, তাই সবচেয়ে বড় লাভ precision বদলে (অংশ ৭-এ ২.৩ গুণ); ছোট কাজগুলোর জন্য fusion বা compile. Profiler না দেখে DataLoader বা Python optimize করলে এখানে প্রায় কিছুই বদলাত না।

বড় প্রকল্পে টেবিলের বদলে timeline দেখা আরও কাজের: prof.export_chrome_trace("trace.json") লিখে ব্রাউজারের trace viewer বা Perfetto-তে খুললে CPU আর GPU-র সারি পাশাপাশি দেখা যায়, আর GPU-র সারিতে ফাঁক মানে GPU কিছুর অপেক্ষায় বসে ছিল, প্রায়ই ডেটার।

৯ · GPU ছাড়া শেখা — CPU thread, ছোট model আর বিনামূল্যের notebook

এই কোর্সের মডিউল ১–৩-এর প্রায় সব কিছু CPU-তে শেখা যায়। প্রথম কৌশল CPU-র সব core কাজে লাগানো। PyTorch সাধারণত নিজেই কয়েকটি thread ব্যবহার করে, কিন্তু অন্য library বা পরিবেশ-চলক তা কমিয়ে দিতে পারে; torch.get_num_threads() দেখে নিন, দরকারে torch.set_num_threads(n). একই 2048 × 2048 matmul, thread বদলে (একই run-এর CPU অংশ):

~/bazarghar-dl$ uv run python device_threads.py
…
CPU threads 1:   127.2 ms per 2048x2048 matmul
CPU threads 2:    69.9 ms per 2048x2048 matmul
CPU threads 4:    37.5 ms per 2048x2048 matmul
CPU threads 8:    18.6 ms per 2048x2048 matmul

প্রায় পুরো রৈখিক লাভ: ৮ thread-এ ১ thread-এর প্রায় ৬.৮ গুণ দ্রুত। অংশ ৪-এর CPU-র সংখ্যা (default thread-এ ২৫.৫ ms) এর সঙ্গে তুলনা করুন; সেখানে GPU প্রায় ১২ গুণ দ্রুত ছিল, এক thread-এর CPU-র তুলনায় হলে দাবি হতো প্রায় ৬০ গুণ। ভাবনার প্রশ্ন ৩-এ এই ফাঁদের ইতিহাস আছে।

ছোট করে শিখুন

ছোট ছবি, ছোট vocab, কম স্তর, কম epoch; ধারণা একই থাকে, সময় কমে মিনিটে। আগে এক batch overfit করিয়ে বাগ ধরুন (পর্ব ১৮)।

Pretrained ব্যবহার করুন

ছোট pretrained model নিয়ে শুধু শেষ স্তর শেখানো CPU-তেও চলে (পর্ব ২৩); inference-এ torch.inference_mode() আর ছোট (distilled) model.

বিনামূল্যের notebook

কিছু অনলাইন notebook পরিষেবা সীমিত সময়ের GPU দেয়, সাধারণত account আর সময়ের সীমাসহ; নিয়ম প্রায়ই বদলায়। লম্বা run-এ checkpoint রাখুন (পর্ব ১০)।

একই কোড, দুই যন্ত্র

অংশ ২-এর device ধাঁচে লিখলে একই script ল্যাপটপের CPU-তে ছোট মাপে আর ধার করা GPU-তে বড় মাপে চলে, কোনো বদল ছাড়াই।

আরও গভীরে: Mixed precision-এর তিনটি কৌশল (float32 master weight, loss scaling, float32 যোগফল) Micikevicius ও সহকর্মীদের ২০১৮ সালের ICLR প্রবন্ধ “Mixed Precision Training”-এ বর্ণিত; bfloat16 নিয়ে একটি পরিচিত গবেষণা Kalamkar ও সহকর্মীদের ২০১৯ সালের প্রবন্ধ। বড় model-এ parameter-প্রতি ১৬ byte-এর হিসাব আর তা GPU-গুলোর মধ্যে ভাগ করার পদ্ধতি Rajbhandari ও সহকর্মীদের ২০২০ সালের ZeRO প্রবন্ধে আছে। কোন কাজ memory-bound আর কোনটি compute-bound তা বোঝার ছবি Williams, Waterman আর Patterson-এর ২০০৯ সালের roofline model. PyTorch-এর CUDA semantics আর AMP-এর অফিশিয়াল নোটে কোন কাজ কোন dtype তালিকায়, তার পুরো বিবরণ আছে।

পরীক্ষা ও ইন্টারভিউতে:

  • GPU কোড মাপার আগে কী লাগে? Warm-up আর টাইমার থামানোর আগে torch.cuda.synchronize() (বা CUDA event), কারণ কাজ asynchronous.
  • float32 AdamW-এ ১০ কোটি parameter-এর model-এ activation বাদে কত memory? 108 × ১৬ byte = 1.6 × 109 byte ≈ ১৫২৬ MB (প্রায় ১.৪৯ GB, ১ MB = 220 byte).
  • float16 বনাম bfloat16? float16: ৫-bit exponent, ১০-bit mantissa, সর্বোচ্চ 65504; bfloat16: ৮-bit exponent (float32-এর পরিসর), ৭-bit mantissa.
  • GradScaler কেন? float16-এ ছোট gradient শূন্য না হওয়ার জন্য loss বড় করে backward, তারপর unscale; inf পেলে ধাপ বাদ আর scale অর্ধেক।
  • ছোট batch-এ GPU কেন দ্রুত নয়? Kernel launch আর Python-এর স্থির খরচ প্রধান, বেশির ভাগ core খালি।

১০ · AI-এর যুগে — AI-এর দেওয়া গতির পরামর্শ আর benchmark যাচাই

“আমার training ধীর, দ্রুত করে দাও” বললে AI assistant এক নিঃশ্বাসে দশটি পরামর্শ দেয়: mixed precision, compile, বড় batch, num_workers, channels_last… কয়েকটি কাজে দেবে, কয়েকটি আপনার ক্ষেত্রে অর্থহীন, আর কয়েকটি পুরোনো API-তে লেখা। এই পর্বের মূল শিক্ষা এখানেই খাটে: আগে মাপুন, তারপর বদলান, তারপর আবার মাপুন।

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
time.time() দিয়ে GPU কোড মাপে, synchronize আর warm-up ছাড়াশুধু কাজ পাঠানোর সময় বা এককালীন খরচ মাপা হয়অংশ ৩-এর তিন উপায়ে একই কাজ মেপে মিলিয়ে দেখুন
পুরোনো API দেয়: torch.cuda.amp.autocast(), torch.cuda.amp.GradScaler()নতুন version-এ এগুলো deprecated; নতুন রূপ torch.autocast("cuda", …) আর torch.amp.GradScaler("cuda")নিজের version-এর docs দেখুন; warning পড়ুন, চাপা দেবেন না
bfloat16-এর সঙ্গেও GradScaler বসায়, বা float16-এ GradScaler ভুলে যায়bfloat16-এ পরিসর বড়, scaler সাধারণত লাগে না; float16-এ না দিলে ছোট gradient শূন্যdtype দেখে সিদ্ধান্ত নিন; float16-এ scaler.get_scale() লগ করুন
পুরো model-কে .half() করে training-এর পরামর্শ দেয়weight আপডেট আর optimizer state float16-এ হারিয়ে যেতে পারেautocast ব্যবহার করুন; weight float32-এ আছে কি না দেখুন
“torch.compile দিলেই ২ গুণ” বলে, platform না দেখেcompile-এর সময়, platform-এর সমর্থন আর graph break-এ লাভ বদলায়নিজের যন্ত্রে প্রথম call আর পরের call আলাদা মাপুন, ফল মিলিয়ে দেখুন
Memory-র হিসাবে optimizer state বা activation বাদ দেয়বাস্তব memory অনেক বেশি আসে, OOMপ্রতি parameter-এ byte গুনুন; max_memory_allocated দিয়ে মিলান

AI-কে পরামর্শের সঙ্গে মাপার পরিকল্পনাও চাইতে বলুন:

আমার PyTorch training loop (নিচে দিলাম) GPU-তে ধীর মনে হচ্ছে। আমি PyTorch-এর কোন version ব্যবহার করছি তাও দিলাম। ১. কোনো বদল প্রস্তাবের আগে warm-up আর torch.cuda.synchronize দিয়ে প্রতি ধাপের সময় মাপার কোড দাও। ২. torch.profiler দিয়ে কয়েকটি ধাপের সবচেয়ে বেশি সময় নেওয়া কাজগুলো দেখার কোড দাও, আর টেবিলের কোন কলাম কী বোঝায় ব্যাখ্যা করো। ৩. ডেটা লোডিং, CPU-GPU কপি আর হিসাব — কোনটি bottleneck, তা কীভাবে আলাদা করে মাপব বলো। ৪. Mixed precision-এর পরামর্শ দিলে আমার version-এর বর্তমান API ব্যবহার করো, float16 আর bfloat16-এর পার্থক্য আর GradScaler কখন লাগে বলো। ৫. প্রতিটি পরামর্শের সঙ্গে বলো, কোন মাপে বুঝব এটি কাজ করেছে, আর loss বা accuracy-তে কী কী মিলিয়ে দেখতে হবে। ৬. যেটা নিশ্চিত নও, সেটা অনুমান বলে চিহ্নিত করো।

AI-এর দেওয়া গতির পরামর্শ হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১ছবি আঁকার যন্ত্র GPU কীভাবে deep learning-এর প্রধান ইঞ্জিন হয়ে উঠল, আর এই ইতিহাস থেকে hardware আর গবেষণার সম্পর্ক নিয়ে কী শেখার আছে?

GPU তৈরি হয়েছিল খেলা আর ছবির জন্য: পর্দার লাখ লাখ pixel-এর প্রতিটির রং একই সূত্রে, একে অন্যের অপেক্ষা না করে হিসাব করতে হয়। এই “একই কাজ, অনেক ডেটা” ধাঁচ matrix গুণের সঙ্গে হুবহু মেলে। ২০০০-এর দশকের শুরুতে কিছু গবেষক graphics-এর API-কে কৌশলে বাঁকিয়ে সাধারণ হিসাব চালাতেন (একে GPGPU বলা হতো), যা ছিল কষ্টসাধ্য। NVIDIA ২০০৬–২০০৭ সালে CUDA আনে, যাতে C-এর মতো ভাষায় সরাসরি GPU-তে হিসাব লেখা যায়; এরপর বিজ্ঞানের নানা শাখায় GPU ছড়িয়ে পড়ে।

Deep learning-এ একটি পরিচিত প্রথম দিকের প্রবন্ধ Raina, Madhavan আর Ng-এর ২০০৯ সালের কাজ, যা বড় unsupervised model GPU-তে অনেক গুণ দ্রুত শেখানোর কথা জানায়। মোড় ঘোরানো মুহূর্ত হিসেবে সাধারণত ২০১২ সালের AlexNet-কে ধরা হয়: Krizhevsky, Sutskever আর Hinton দুটি consumer GPU-তে ImageNet-এর একটি বড় CNN শেখান, আর প্রতিযোগিতায় আগের পদ্ধতিগুলোর চেয়ে স্পষ্টভাবে ভালো ফল পান। তাঁদের প্রবন্ধে GPU-র memory-র সীমার জন্য model দুই ভাগে ভাগ করার বর্ণনাও আছে, যা আজকের model parallelism-এর একটি আদি রূপ। এরপর GPU নির্মাতারা নিজেরাই deep learning-এর দিকে ঝোঁকে: ২০১৭ সালের দিকে Tensor Core, তারপর TF32, bfloat16 আর আরও কম bit-এর format.

Sara Hooker ২০২০ সালের একটি প্রবন্ধে একে “hardware lottery” বলেন: কোন গবেষণা-ধারণা জেতে, তা অনেক সময় নির্ভর করে সেই সময়ের hardware-এ সেটি সহজে চলে কি না তার ওপর, শুধু ধারণাটি ভালো কি না তার ওপর নয়। Matrix গুণে ভরা neural network GPU-তে সহজে চলেছিল; অন্য অনেক ধারণা (যেমন খুব অনিয়মিত, শাখা-প্রশাখায় ভরা হিসাব) তুলনায় পিছিয়ে পড়েছে। এই ব্যাখ্যা নিয়ে বিতর্ক আছে, কিন্তু প্রশ্নটি গুরুত্বপূর্ণ।

আপনার জন্য বাস্তব শিক্ষা: একটি model দ্রুত কি না, তা তার FLOP-এর সংখ্যা ছাড়াও নির্ভর করে তার কাজগুলো hardware-এর পছন্দের আকারে (বড়, ঘন matrix গুণ) সাজানো কি না তার ওপর। এই পর্বের মাপে ৬৪×৬৪ matmul-এ GPU প্রায় কোনো সুবিধা দেয়নি, ৪০৯৬×৪০৯৬-এ দিয়েছে বিশাল সুবিধা।

মূল উপলব্ধি: GPU deep learning-এর জন্য তৈরি হয়নি; deep learning এমন আকারে বেড়েছে যা GPU-তে ভালো চলে, আর এখন দুটি একে অন্যকে গড়ে তুলছে।

প্র ০২float32 থেকে float16, bfloat16 আর তারপর আরও কম bit — সংখ্যার format ছোট করার এই দৌড় কোথায় থামে, আর কী হারায়?

দশমিক সংখ্যার মানক IEEE 754 ১৯৮৫ সালে আসে; ২০০৮ সালের সংশোধনে ১৬-bit-এর binary16 (আমাদের float16) মানক হিসেবে যোগ হয়, যা আগে মূলত graphics-এ ব্যবহৃত হতো। Deep learning-এ প্রশ্ন দাঁড়াল: training-এর সব হিসাবে কি সত্যিই ৭ অঙ্কের নির্ভুলতা লাগে? Micikevicius ও সহকর্মীদের (NVIDIA ও Baidu) ২০১৮ সালের “Mixed Precision Training” প্রবন্ধ তিনটি কৌশল দিয়ে দেখায় যে লাগে না: weight-এর একটি float32 master কপি, loss scaling, আর যোগফল float32-এ রাখা। তাঁদের পরীক্ষায় বহু model float32-এর কাছাকাছি নির্ভুলতায় পৌঁছায়।

float16-এর দুর্বলতা পরিসর: মাত্র ৫ bit exponent, তাই 65504-এর ওপরে inf আর 6 × 10−8-এর নিচে শূন্য। Google Brain-এর দল TPU-র জন্য অন্য একটি ভাগ বেছে নেয়: float32-এর পুরো ৮ bit exponent রেখে mantissa ৭ bit-এ ছাঁটা। এই bfloat16-এ পরিসর float32-এর সমান, তাই loss scaling প্রায় লাগে না, আর float32 থেকে রূপান্তর মানে শুধু নিচের ১৬ bit ফেলে দেওয়া (পাতার ব্লকে ঠিক এটাই করা হয়েছে)। Kalamkar ও সহকর্মীদের ২০১৯ সালের একটি গবেষণা নানা model-এ bfloat16 training-এর ফল float32-এর কাছাকাছি পাওয়ার কথা জানায়। দামটা নির্ভুলতায়: ১-এর কাছে পরপর দুটি মানের দূরত্ব প্রায় ০.০০৭৮, তাই ছোট ধাপ যোগ করলে হারিয়ে যায়।

দৌড় থামেনি। ২০২২ সালে NVIDIA, Arm আর Intel-এর গবেষকেরা ৮-bit FP8-এর দুটি রূপ (E4M3 আর E5M2) প্রস্তাব করেন, যা নতুন কিছু GPU-তে training আর inference-এ ব্যবহৃত হচ্ছে বলে জানা যায়; inference-এ ৪-bit পর্যন্ত quantization প্রচলিত (পর্ব ৫৯-এ quantization আসবে)। প্রতিটি ধাপে কৌশল আরও সূক্ষ্ম হয়: block ধরে scale, কিছু স্তর উঁচু precision-এ রাখা, outlier আলাদা সামলানো।

সীমাটা আসে সংখ্যাতত্ত্ব থেকে: gradient-এর যোগফল, softmax-এর normalisation, optimizer-এর moment আর weight-এর খুব ছোট আপডেটের জন্য নির্ভুলতা দরকারই, তাই প্রায় সব কৌশল এগুলো float32-এ রাখে। পাতার ব্লকে ০.০১ দশ হাজার বার যোগ করে float16-এ ৩২ আর bfloat16-এ ৪ পাওয়া এই সীমার ছোট প্রমাণ।

মূল উপলব্ধি: কম bit মানে বেশি গতি আর কম memory, কিন্তু প্রতিটি বাদ দেওয়া bit কোনো না কোনো হিসাবের ঝুঁকি; তাই “mixed”, অর্থাৎ কোন কাজ কোন precision-এ, সেটাই আসল নকশা।

প্র ০৩“GPU-তে ১০০ গুণ দ্রুত” — এমন দাবি কেন প্রায়ই বিভ্রান্তিকর, আর একটি সৎ benchmark দেখতে কেমন?

২০১০ সালে Intel-এর গবেষক Victor Lee ও সহকর্মীরা ISCA সম্মেলনে একটি প্রবন্ধ প্রকাশ করেন, যার শিরোনামেই ছিল “Debunking the 100X GPU vs. CPU Myth”. তাঁরা এক গুচ্ছ কাজ ভালোভাবে optimize করা CPU আর GPU কোডে মেপে দেখান, গড় পার্থক্য সে সময় প্রচলিত বড় দাবিগুলোর চেয়ে অনেক কম, প্রায় কয়েক গুণের ঘরে। প্রবন্ধটি একটি CPU নির্মাতার গবেষকদের, তাই স্বার্থের প্রশ্ন ওঠে; কিন্তু তাঁদের মূল যুক্তি টিকে আছে: বহু “১০০×” তুলনায় GPU-র কোড যত্ন করে লেখা, CPU-র কোড single-thread আর vectorise না করা।

এই পর্বের নিজস্ব মাপেও একই শিক্ষা। Synchronize ছাড়া মাপলে GPU-র matmul প্রায় শূন্য সময় দেখায়, কারণ শুধু কাজ সারিতে পাঠানোর সময় মাপা হয়; তা দিয়ে হিসাব করলে হাজার গুণ “speedup” পাওয়া যেত। আবার ৬৪×৬৪ matmul বা batch ৬৪-এর training-এ GPU প্রায় কোনো সুবিধা দেয়নি; শুধু বড় আকার বেছে নিয়ে দাবি করলে ছবিটা একপেশে হয়। CPU-র thread-সংখ্যা, PyTorch-এর build, dtype (float32 নাকি TF32), আর ডেটা আগে থেকে GPU-তে ছিল কি না, প্রতিটিই ফল কয়েক গুণ বদলাতে পারে।

একটি সৎ benchmark-এর চেকলিস্ট: (১) warm-up বাদ দিয়ে মাপা, প্রথম call আলাদা জানানো; (২) GPU-তে synchronize বা CUDA event; (৩) কয়েকবার মেপে median আর ছড়ানো, একটি সংখ্যা নয়; (৪) দুই পক্ষেই যুক্তিসংগত সেরা কোড (CPU-তে সব thread, BLAS); (৫) একই dtype আর একই ফল যাচাই; (৬) end-to-end সময়, অর্থাৎ ডেটা লোড আর কপিসহ, কারণ ব্যবহারকারী সেটাই অনুভব করেন; (৭) hardware আর software version লিখে রাখা (যদিও এই কোর্সের পাতায় গোপনীয়তার কারণে hardware-এর নাম দেওয়া হয়নি)।

MLPerf-এর মতো শিল্প-মানের benchmark এই কারণেই নির্দিষ্ট নিয়ম, নির্ধারিত নির্ভুলতার লক্ষ্য আর “time to train” মাপে, শুধু একটি kernel-এর গতি নয়।

মূল উপলব্ধি: speedup একটি সংখ্যা নয়, একটি তুলনা; কোন আকার, কোন dtype, কোন baseline আর কীভাবে মাপা, তা না জানলে সংখ্যাটির অর্থ নেই।

প্র ০৪GPU-র হিসাবের ক্ষমতা বিশাল, তবু অনেক কাজ ধীর — “memory wall”, roofline আর kernel fusion দিয়ে এটি কীভাবে বোঝা যায়?

একটি কাজের গতি দুটি সীমার কোনো একটিতে আটকায়: প্রতি সেকেন্ডে কত FLOP করা যায়, আর প্রতি সেকেন্ডে memory থেকে কত byte আনা যায়। ১৯৯০-এর দশকেই Wulf আর McKee “memory wall” শব্দটি ব্যবহার করেন: processor-এর গতি memory-র গতির চেয়ে অনেক দ্রুত বাড়ছিল, ফলে বেশির ভাগ সময় processor ডেটার অপেক্ষায় বসে থাকে। Williams, Waterman আর Patterson ২০০৯ সালে এই ধারণাকে “roofline model” নামে একটি ছবিতে আনেন: x-অক্ষে arithmetic intensity (প্রতি byte-এ কত FLOP), y-অক্ষে অর্জনযোগ্য FLOP/s; নিচু intensity-তে ছাদ ঢালু (memory-bound), উঁচুতে সমান (compute-bound).

n × n matmul-এ 2n³ FLOP কিন্তু ডেটা মাত্র 3n² সংখ্যা, তাই n বাড়লে intensity বাড়ে আর কাজ compute-bound হয়; এই পর্বের মাপে বড় matmul-এ GPU-র TFLOP/s বেড়েছে ঠিক এই কারণে। অন্যদিকে ReLU, যোগ বা LayerNorm-এ প্রতিটি সংখ্যার জন্য একটি-দুটি FLOP মাত্র, কিন্তু সংখ্যাটি memory থেকে পড়তে আর লিখতে হয়; এরা memory-bound, আর GPU-র Tensor Core এখানে কোনো সাহায্য করে না। Profiler টেবিলে উপাদান-ধরে কাজগুলো অল্প FLOP হয়েও সময়ের উল্লেখযোগ্য ভাগ নেয় এই কারণে।

সমাধানের একটি প্রধান পথ kernel fusion: matmul-এর ঠিক পরের bias যোগ আর activation একই kernel-এ করা, যাতে মাঝের ফল memory-তে লিখে আবার পড়তে না হয়। torch.compile-এর মতো compiler এটাই স্বয়ংক্রিয়ভাবে করার চেষ্টা করে। আরও গভীর উদাহরণ Tri Dao ও সহকর্মীদের ২০২২ সালের FlashAttention: attention-এর হিসাবকে টুকরো করে GPU-র ছোট, দ্রুত on-chip memory-তে রেখে বড় মাঝের matrix কখনো মূল memory-তে না লিখে করা; তাঁরা একে “IO-aware” বলেন। এখানে FLOP প্রায় একই, কিন্তু memory-র যাতায়াত কম বলে গতি বেশি আর memory কম (পর্ব ৩৭-এ FlashAttention-এর ধারণা আসবে)।

দৈনন্দিন তুলনা: একজন দক্ষ রাঁধুনি (GPU-র হিসাব-একক) মিনিটে দশটি পদ বানাতে পারেন, কিন্তু বাজার থেকে কাঁচামাল আসে রিকশায় (memory). কাঁচামাল দেরিতে এলে রাঁধুনির দক্ষতা কাজে লাগে না; একবারের বাজারে কয়েকটি পদের মাল একসঙ্গে আনাই fusion.

মূল উপলব্ধি: GPU-র গতি শুধু FLOP নয়, byte-এর যাতায়াতও; কোন কাজ কোন সীমায় আটকে আছে তা জানলে বোঝা যায় কোন optimisation কাজে দেবে।

প্র ০৫নিজের GPU না থাকলে কি deep learning শেখা যায়? Compute-এর অসমতা, খরচ আর পরিবেশের প্রশ্ন কীভাবে ভাবা উচিত?

শেখার জন্য উত্তর হ্যাঁ, অনেকটাই। এই কোর্সের প্রথম এগারোটি পর্বের প্রায় সব ধারণা, অর্থাৎ autograd, MLP, training loop, DataLoader, ছোট ডেটায় পুরোপুরি CPU-তে বোঝা যায়, আর পাতার ব্লকগুলো চলে ব্রাউজারেই। কৌশল হলো আকার কমানো, ধারণা নয়: ছোট ছবি (২৮×২৮), ছোট vocab, কম স্তর, কম epoch; torch.set_num_threads দিয়ে CPU-র সব core কাজে লাগানো; pretrained model-এর ছোট (distilled) রূপ নিয়ে শুধু শেষ স্তর শেখানো (পর্ব ২৩-এ transfer learning); আর ধারণা যাচাইয়ের পর বড় run-এর জন্য সাময়িক GPU. কিছু অনলাইন notebook পরিষেবা বিনামূল্যে সীমিত সময়ের GPU দেয়, সাধারণত account আর ব্যবহারের সীমাসহ, যা সময়ের সঙ্গে বদলায়; ভাড়ার cloud GPU-ও আছে, ঘণ্টা ধরে খরচ।

কিন্তু গবেষণার সীমান্তে অসমতা বাস্তব। বড় language model শেখাতে যে compute লাগে, তা হাতে গোনা কিছু প্রতিষ্ঠানের কাছে আছে; বিশ্ববিদ্যালয় আর ছোট দেশের গবেষকেরা প্রায়ই সেই প্রতিযোগিতায় নেই। এর প্রতিক্রিয়ায় open model, ছোট কিন্তু দক্ষ model, quantization আর parameter-efficient fine-tuning (পর্ব ৫৫-তে LoRA) নিয়ে কাজ বেড়েছে; এগুলো কম compute-এ অর্থবহ কাজ সম্ভব করে।

পরিবেশের প্রশ্নও উঠেছে। Strubell, Ganesh আর McCallum ২০১৯ সালে একটি পরিচিত প্রবন্ধে কিছু NLP model শেখানোর বিদ্যুৎ খরচ আর কার্বন নিঃসরণের আনুমানিক হিসাব দেন, বিশেষ করে বিশাল hyperparameter search-এর। হিসাবের পদ্ধতি নিয়ে পরে সমালোচনা আর সংশোধন হয়েছে, কিন্তু মূল বার্তা গ্রহণযোগ্য: অপ্রয়োজনীয় বড় run এড়ানো, আগে ছোট পরীক্ষায় বাগ ধরা, mixed precision আর দক্ষ কোড ব্যবহার করা শুধু টাকা নয়, শক্তিও বাঁচায়।

লোডশেডিং বা ধীর internet-এর মতো স্থানীয় বাস্তবতাও পরিকল্পনায় রাখা ভালো: লম্বা run-এ নিয়মিত checkpoint (পর্ব ১০), যাতে বিদ্যুৎ গেলে শুরু থেকে আবার করতে না হয়।

মূল উপলব্ধি: ধারণা শিখতে বড় GPU লাগে না, লাগে ছোট আর সৎ পরীক্ষা; বড় compute তখনই, যখন ছোট পরীক্ষা বলে যে run-টি সার্থক হবে।

অনুশীলন

প্রথমে খাতায় করুন, তারপর উত্তর মেলান। অংশ ৫-এর memory calculator দিয়েও যাচাই করতে পারেন।

  1. পূর্ণ numerical: বাজারঘরের একটি review-classifier-এ ৫ কোটি (50M) parameter. (ক) float32-এ AdamW দিয়ে training-এ parameter, gradient আর optimizer state মিলিয়ে কত MB (১ MB = 220 byte)? (খ) bfloat16 weight আর gradient, float32 master কপি আর float32 AdamW state ধরলে কত? (গ) শুধু inference-এ bfloat16 weight কত? (ঘ) একটি GPU-তে 4096 × 4096 float32 matmul 12 ms নিলে অর্জিত গতি কত TFLOP/s?

    (ক) প্রতি parameter-এ ৪ (weight) + ৪ (gradient) + ৮ (দুটি moment) = ১৬ byte; 50 × 106 × 16 = 8 × 108 byte ÷ 1,048,576 ≈ ৭৬২.৯ MB. (খ) ২ + ২ + ৪ (master) + ৮ = আবারও ১৬ byte, তাই ≈ ৭৬২.৯ MB: mixed precision parameter-এর দিকের memory কমায় না; সঞ্চয় আসে activation আর গতিতে। (গ) 50 × 106 × 2 = 108 byte ≈ ৯৫.৪ MB. (ঘ) FLOP = 2n³ = 2 × 4096³ ≈ 1.374 × 1011; ÷ 0.012 s ≈ 1.145 × 1013 FLOP/s ≈ ১১.৫ TFLOP/s. Activation এই হিসাবের বাইরে, যা batch আর স্তরের সঙ্গে বাড়ে।

  2. সমালোচনা ও নকশা: একজন সহকর্মী লিখেছেন: “আমার GPU CPU-র চেয়ে ৯০০ গুণ দ্রুত!” তাঁর কোড: t = time.time(); y = model(x_gpu); print(time.time() - t), একবারই চালানো, আর CPU-র সময় তিনি মেপেছেন torch.set_num_threads(1) দিয়ে, batch ১৬-এ। কী কী ভুল, আর আপনি কীভাবে মাপতেন?

    ভুল ১, synchronize নেই: GPU-র কাজ asynchronous, তাই মাপা হয়েছে শুধু কাজ সারিতে পাঠানোর সময়। ভুল ২, warm-up নেই: একবারের মাপে CUDA context, library লোড বা cuBLAS-এর প্রথম ব্যবহারের খরচ ঢুকতে পারে, যা উল্টো দিকে ভুল করায়; দুটি ভুল মিলে সংখ্যাটি অর্থহীন। ভুল ৩, অসম baseline: CPU-কে এক thread-এ বেঁধে রাখা হয়েছে। ভুল ৪, একটি আকার: batch ১৬-এ GPU-র সুবিধা সাধারণত কম, তাই একটি আকার থেকে সাধারণ দাবি করা যায় না। ভুল ৫, ডেটা কপি বাদ: x আগে থেকেই GPU-তে; বাস্তব pipeline-এ কপির সময়ও লাগে। নকশা: warm-up কয়েকবার; torch.cuda.synchronize() বা CUDA event; ২০–৫০ বার মেপে median; CPU-তে default thread; কয়েকটি batch আকার; ডেটা কপিসহ আর ছাড়া দুটি সংখ্যা; torch.no_grad() সহ inference; ফল দুই device-এ মিলিয়ে দেখা; version লিখে রাখা।

  3. আউটপুট অনুমান করুন: একটি CUDA GPU-তে নিচের কোড কী ছাপবে? চালানোর আগে চারটি dtype লিখে ফেলুন।
    lin = nn.Linear(8, 4).cuda(); x = torch.randn(2, 8, device="cuda")
    with torch.autocast("cuda", dtype=torch.float16):
        h = lin(x)
        loss = nn.functional.mse_loss(h, torch.zeros_like(h))
    loss.backward()
    print(h.dtype, lin.weight.dtype, loss.dtype, lin.weight.grad.dtype)
    torch.float16 torch.float32 torch.float32 torch.float32

    Linear autocast-এর float16 তালিকায়, তাই output float16. Parameter নিজে কখনো বদলায় না; autocast শুধু একটি অস্থায়ী float16 কপি দিয়ে গুণ করে, তাই weight float32. mse_loss float32 তালিকায়, তাই input float32-এ তুলে loss float32. Backward-এ gradient অস্থায়ী কপি পেরিয়ে আসল parameter-এ পৌঁছায়, আর .grad-এর dtype parameter-এর dtype-এর মতো, float32. অংশ ৭-এর সত্যিকারের run-এ হুবহু এই চারটি dtype এসেছে।

  4. কোড লিখুন: একটি function cuda_ms(fn, warmup=3, reps=20) লিখুন, যা CUDA event দিয়ে fn()-এর প্রতি call-এর median সময় millisecond-এ ফেরত দেয়। তারপর লিখুন, কেন এখানে time.perf_counter()-এর বদলে event ব্যবহার করা যায়, আর তবুও শেষে কেন একবার synchronize লাগে।
    import statistics, torch
    
    def cuda_ms(fn, warmup=3, reps=20):
        for _ in range(warmup):
            fn()                                   # এককালীন খরচ বাদ
        times = []
        for _ in range(reps):
            start = torch.cuda.Event(enable_timing=True)
            end = torch.cuda.Event(enable_timing=True)
            start.record(); fn(); end.record()     # GPU-র সারিতে দুটি চিহ্ন
            end.synchronize()                      # end চিহ্ন পর্যন্ত কাজ শেষ হোক
            times.append(start.elapsed_time(end))  # millisecond
        return statistics.median(times)

    Event দুটি GPU-র নিজের সারিতে বসে, তাই GPU যখন সত্যিই সেখানে পৌঁছায় তখনকার সময় লেখে; CPU-র ঘড়ি আর কাজ পাঠানোর দেরি এতে ঢোকে না। কিন্তু elapsed_time পড়ার আগে end-এ পৌঁছানো নিশ্চিত হতে হবে, নইলে error আসে; তাই end.synchronize(). Median নেওয়া হয়েছে যাতে একবারের বিচ্যুতি ফল না বদলায়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমfloat16 autocast আর GradScaler দিয়ে একটি training ধাপ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Batch-কে model-এর device-এ পাঠানো
  2. torch.autocast-এর ভেতরে forward pass আর loss হিসাব
  3. পুরোনো gradient মোছা (zero_grad)
  4. scaler.scale(loss).backward() দিয়ে বড় করা loss-এর backward
  5. scaler.step(optimizer): gradient unscale করে inf না থাকলে আপডেট
  6. scaler.update() দিয়ে পরের ধাপের scale ঠিক করা

PyTorch-এ GPU-র একটি matmul-এর সময় মাপতে t = time.perf_counter(); C = A @ B; print(time.perf_counter() - t) লেখা হলো, আর সময় এলো প্রায় ০.০৩ ms, যদিও কাজটি বড়। সবচেয়ে সম্ভাব্য কারণ কী?

  • ফল আগের call থেকে cache-এ ছিল
  • PyTorch কাজটি বাদ দিয়েছে, কারণ C ব্যবহার হয়নি
  • GPU-র কাজ asynchronous, তাই শুধু কাজ সারিতে পাঠানোর সময় মাপা হয়েছে
  • perf_counter এত ছোট সময় মাপতে পারে না

70000 সংখ্যাটি float16 আর bfloat16-এ রাখলে যথাক্রমে কী পাওয়া যায়?

  • দুটিতেই 70000
  • float16-এ inf, bfloat16-এ 70144
  • float16-এ 70144, bfloat16-এ inf
  • দুটিতেই NaN

সব float32-এ AdamW দিয়ে training-এ (activation বাদে) প্রতি parameter-এ কত byte লাগে?

  • ৪ byte
  • ৮ byte
  • ১২ byte
  • ১৬ byte

float16 mixed precision-এ GradScaler-এর প্রধান কাজ কী?

  • ছোট gradient যাতে float16-এ শূন্য না হয়ে যায়, সেজন্য loss বড় করে backward আর পরে unscale
  • Learning rate ধাপে ধাপে বাড়ানো
  • Model-এর weight স্থায়ীভাবে float16-এ রূপান্তর
  • বড় gradient কেটে একটি সীমার মধ্যে রাখা

একটি ছোট MLP-তে batch ৬৪ নিয়ে training-এ GPU-র প্রতি ধাপের সময় CPU-র প্রায় সমান এলো, কিন্তু batch ৮১৯২-এ GPU অনেক দ্রুত। এর সবচেয়ে ভালো ব্যাখ্যা কোনটি?

  • ছোট batch-এ GPU ভুল হিসাব করে, তাই আবার চালায়
  • ছোট batch-এ kernel launch আর Python-এর স্থির খরচই প্রধান, GPU-র বেশির ভাগ core খালি থাকে
  • ৬৪ সংখ্যাটি ২-এর ঘাত নয়, তাই GPU ধীর
  • CPU ছোট batch-এ GPU-র কোড ধার করে

torch.profiler-এর টেবিলে একটি ছোট MLP-র training-এ GEMM (matmul) kernel-এর পরেই অনেক সময় নিয়েছে উপাদান-ধরে কাজ (যোগ, ReLU, optimizer-এর আপডেট)। এর সবচেয়ে সম্ভাব্য কারণ কী?

  • এদের FLOP matmul-এর চেয়ে বেশি
  • Profiler উপাদান-ধরে কাজের সময় দ্বিগুণ গোনে
  • এরা memory-bound: প্রতিটি সংখ্যায় অল্প হিসাব কিন্তু memory-তে পড়া-লেখা আর আলাদা kernel launch
  • এরা Tensor Core-এ চলে, যা ধীর

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Device: একটি device চলক; model একবার .to(device), প্রতিটি batch প্রতি ধাপে। Module-এর .to() নিজেকে বদলায়, tensor-এর .to() নতুন কপি ফেরত দেয়।
  2. মাপা: GPU-র কাজ asynchronous; warm-up বাদ দিয়ে, torch.cuda.synchronize() বা CUDA event দিয়ে, কয়েকবার মেপে median. প্রথম CUDA call-এর খরচ আলাদা।
  3. কখন GPU: বড় matmul আর বড় batch-এ বিশাল সুবিধা; ছোট কাজে launch আর Python-এর স্থির খরচ প্রধান, তখন CPU-র প্রায় সমান।
  4. Memory: float32 AdamW-এ প্রতি parameter-এ ১৬ byte (weight, gradient, দুটি moment) আর তার ওপর activation; max_memory_allocated দিয়ে মাপুন।
  5. Precision: float16-এর পরিসর ছোট (65504, loss scaling লাগে), bfloat16-এর পরিসর float32-এর মতো কিন্তু নির্ভুলতা কম; autocast matmul ১৬-bit-এ আর loss, softmax, যোগফল float32-এ রাখে; আগে মাপুন, তারপর profiler দেখে optimise.

পরের পর্বে: মডিউল ২ এখানে শেষ: tensor, autograd, nn.Module, training loop, DataLoader আর গতি। মডিউল ৩ শুরু হবে পর্ব ১৩ (Deep learning-এর loss — cross-entropy, label smoothing আর বিশেষ loss) দিয়ে: logits থেকে cross-entropy কেন সংখ্যাগতভাবে নিরাপদ, label smoothing কী বদলায়, আর ভারসাম্যহীন ডেটার জন্য বিশেষ loss.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান