এই পর্বে যা শিখবেন
- Generation-এ attention-এর খরচ কোথায় যায়, আর গুণের সংখ্যা গুনে তা মাপা
- KV cache নিজে লেখা, cache ছাড়া আর cache-সহ token মেলানো, সময় মাপা
- Cache-এর memory হাতে হিসাব, আর grouped-query attention-এর লাভ
- Batched generation-এ left padding আর position id কেন লাগে
- FlashAttention-এর ধারণা, scaled_dot_product_attention-এর backend, sliding window আর long context-এর কৌশল
এতদিন আমরা attention-এর অর্থ নিয়ে ভেবেছি: পর্ব ৩২ (Attention) আর পর্ব ৩৩ (Self-attention আর multi-head attention). পর্ব ৩৬-এ (নিজে Transformer লেখা) একটি ছোট model দাঁড়িয়েছে। এই পর্বের প্রশ্ন ভিন্ন: সেই model চালাতে খরচ কত, আর কমাবেন কীভাবে? LLM-এর ধারণা নতুন লাগলে আগে দেখে নিন AI-এর ভিত্তি কোর্সের পর্ব ৭০। পাতার চারটি ব্লক ব্রাউজারের Python আর NumPy-তে চলে; প্রথমবার প্যাকেজ নামতে কয়েক সেকেন্ড লাগে। PyTorch-এর সব আউটপুট ডেস্কটপের সত্যিকারের run থেকে নেওয়া।
১ · একটি নতুন token-এর দাম
চায়ের দোকানের হিসাব খাতার কথা ভাবুন। প্রতিটি নতুন কাপের পর দোকানি যদি খাতার প্রথম পাতা থেকে সব যোগ করেন, দিন শেষে হাত অবশ হবে। বুদ্ধিমান দোকানি আগের যোগফল লিখে রাখেন, আর শুধু নতুন কাপটি যোগ করেন। উত্তর একই, পরিশ্রম সামান্য। KV cache সেই লিখে রাখা যোগফল।
LLM লেখে এক token করে। প্রতিটি নতুন token আগের সব token দেখে তৈরি হয়; একে বলে autoregressive generation. সরল কোডটি এমন: এখন পর্যন্ত যত token আছে, সব model-এ দিন, শেষ ঘরের logits থেকে পরের token বাছুন, জুড়ে দিন, আবার।
পর্ব ৩৩-এর হিসাবে T-টি token-এর একটি self-attention-এ শুধু score বানাতেই T × T × dk × h = T²·d-টি গুণ লাগে। এখানে T token সংখ্যা, h head সংখ্যা, dk = d/h এক head-এর মাত্রা, আর d পুরো মাত্রা। এই কাজ প্রতিটি নতুন token-এ, প্রতিটি স্তরে হয়। T-টি token লিখতে মোট:
d · (1² + 2² + … + T²) ≈ d · T³ / 3
T দ্বিগুণ হলে কাজ আট গুণ। অথচ একটি নতুন token-এর জন্য দরকার মাত্র একটি সারি: নতুন query-র সঙ্গে সব key-র মিল, T·d-টি গুণ। বাকি সারিগুলো আগেই হিসাব করা, আর ফেলে দেওয়া।
২ · KV cache — কী রাখা হয়, কী হয় না
কেন পুরনো হিসাব আবার কাজে লাগে? কারণটি causal mask. Token ৩ কখনো token ৪-কে দেখে না। তাই token ৪ আসার পরেও token ৩-এর প্রতিটি স্তরের hidden vector হুবহু আগের মতো থাকে, ফলে তার k আর v-ও। পুরনো অংশ গাণিতিকভাবে অপরিবর্তিত, এটি কোনো অনুমান নয়। Mask না থাকলে এই কৌশল চলত না। নিচে একটি ছোট decoder: L = ৪, h = ৪, d = ১২৮, তাই dk = ৩২. গুণের হিসাব এক স্তরের, সব head মিলিয়ে।
| Model-এ ঢোকে | |
| নতুন হিসাব | |
| K, V-এর shape | |
| Score-এর আকার | |
| Score-এর গুণ | |
| Cache ছাড়া |
- প্রথম pass. Prompt-এর ৩টি token একসঙ্গে যায়। Score ৩ × ৩, cache থাকুক বা না থাকুক খরচ সমান: ১,১৫২। শেষে প্রতিটি স্তর নিজের K আর V রেখে দেয়; shape (1, 4, 3, 32).
- Token ৪. Model-এ যায় শুধু একটি token. তার q, k, v হিসাব হয়; k আর v জোড়া লাগে পুরনো K, V-এর পেছনে। Score এখন একটি সারি, ১ × ৪: ৫১২টি গুণ, যেখানে cache ছাড়া ২,০৪৮।
- Token ৫. K-এর দৈর্ঘ্য ১ বাড়ল, ৪ থেকে ৫। Score ১ × ৫: ৬৪০টি গুণ। Cache ছাড়া ৩,২০০। পার্থক্য প্রতি ধাপে t গুণ।
- Token ১,০০০. সারিটি এখন লম্বা, ১ × ১০০০: ১,২৮,০০০টি গুণ। Cache ছাড়া ১২ কোটি ৮০ লাখ, ঠিক ১,০০০ গুণ। তবে cache-এর সারিও ধীরে লম্বা হয়, তাই খরচ একেবারে স্থির নয়।
- কী রাখা হয় না. Q রাখার দরকার নেই: পুরনো token আর প্রশ্ন করবে না। Score-ও ফেলে দেওয়া হয়। থাকে শুধু K আর V, প্রতিটি (B, h, t, d/h). তাই নাম KV cache.
নিজে দেখুন। নিচের ছোট decoder একই seed-এ দুইভাবে লেখে, আর mm ফাংশন প্রতিটি matmul-এর গুণ গোনে। Sampling চালু আছে, যাতে “মিল” কথাটি অর্থপূর্ণ হয়: একটি probability সামান্য বদলালেও sequence আলাদা হতে পারত।
import numpy as np, matplotlib.pyplot as plt
rng = np.random.default_rng(37)
V, d, L = 20, 16, 2 # vocab, মাত্রা, স্তর; সরলতার জন্য এক head
E, P = rng.normal(0, .5, (V, d)), rng.normal(0, .5, (256, d))
W = rng.normal(0, .3, (L, 3, d, d)) # প্রতি স্তরে Wq, Wk, Wv
count = 0
def mm(A, B): # গুণ গুনে রাখা matmul
global count; count += A.shape[0] * A.shape[1] * B.shape[1]
return A @ B
def softmax(s):
e = np.exp(s - s.max(-1, keepdims=True)); return e / e.sum(-1, keepdims=True)
def step(ids, start, cache): # ids শুধু নতুন token; cache[i] = (K, V)
x, n = E[ids] + P[start:start + len(ids)], len(ids)
for i in range(L):
q, k, v = mm(x, W[i, 0]), mm(x, W[i, 1]), mm(x, W[i, 2])
if cache[i]: k, v = np.r_[cache[i][0], k], np.r_[cache[i][1], v]
cache[i] = (k, v)
s = mm(q, k.T) / np.sqrt(d)
s[np.triu(np.ones((n, len(k)), bool), len(k) - n + 1)] = -np.inf
x = x + mm(softmax(s), v)
return mm(x, E.T)
def generate(prompt, n, use_cache):
global count; count = 0; g = np.random.default_rng(1)
ids, new, cache, cost = list(prompt), list(prompt), [None] * L, []
for _ in range(n):
lg = (step(np.array(new), len(ids) - len(new), cache) if use_cache
else step(np.array(ids), 0, [None] * L))
ids.append(int(g.choice(V, p=softmax(lg[-1] / 8)))); new = ids[-1:]; cost.append(count)
return ids, np.array(cost)
a, ca = generate([3, 1, 4], 200, False); b, cb = generate([3, 1, 4], 200, True)
print("identical tokens:", a == b, "| first 12:", a[:12])
for n in (50, 100, 200): print(f"T+{n:3d} no cache {ca[n-1]:>11,} cache {cb[n-1]:>9,} ratio {ca[n-1]/cb[n-1]:5.1f}")
print("100 to 200 tokens: no cache x", round(ca[199]/ca[99], 2), "| cache x", round(cb[199]/cb[99], 2))
plt.figure(figsize=(5, 3)); plt.semilogy(ca, label="no cache"); plt.semilogy(cb, label="KV cache")
plt.xlabel("new tokens generated"); plt.ylabel("multiplications (total)"); plt.legend(); plt.show()আউটপুট (পাতার Python-এ চালানো):
identical tokens: True | first 12: [3, 1, 4, 10, 18, 3, 18, 6, 8, 16, 8, 10]
T+ 50 no cache 5,638,400 cache 184,896 ratio 30.5
T+100 no cache 32,716,800 cache 525,696 ratio 62.2
T+200 no cache 215,193,600 cache 1,687,296 ratio 127.5
100 to 200 tokens: no cache x 6.58 | cache x 3.21
Token হুবহু এক। গুণ ১০০ থেকে ২০০ token-এ cache ছাড়া ৬.৫৮ গুণ (আট গুণের দিকে যাচ্ছে), cache-সহ ৩.২১ গুণ (চার গুণের দিকে)। অর্থাৎ T³ থেকে T². মোট কাজ এখনো T²; মুক্তি নয়, এক ধাপ কম। ২০০ token-এ অনুপাত ১২৭.৫; T যত বড়, লাভ তত। ছবির লগ-স্কেলে দুই রেখার ফাঁক ক্রমে বাড়ে।
৩ · PyTorch-এ সত্যিকারের মাপ
এবার সত্যিকারের model. এই পর্বের জন্য নতুন একটি ছোট decoder বানানো হয়েছে: ১০ লাখের কিছু বেশি parameter, বাজারঘরের বানানো ডেলিভারি লাইনে character স্তরে ৬০০ ধাপ শেখানো। Attention-এর পুরো কৌশল কয়েকটি লাইনে (সংক্ষেপিত, আসল ফাইল থেকে নেওয়া):
q, k, v = self.qkv(x).chunk(3, dim=-1) # x: শুধু নতুন token
q, k, v = [z.view(B, t, h, dk).transpose(1, 2) for z in (q, k, v)]
if past is not None: # এটুকুই আসল কৌশল
k = torch.cat([past[0], k], dim=2)
v = torch.cat([past[1], v], dim=2)
y = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
return self.out(y.transpose(1, 2).reshape(B, t, -1)), (k, v)
দুটি জিনিস ভুল হলে নীরবে ফল নষ্ট হয়। এক, mask-এর আকার (t, off + t), যেখানে off হলো cache-এ থাকা token. দুই, position শুরু হবে off থেকে, শূন্য থেকে নয়। সত্যিকারের run (ডেস্কটপে; লেখার সময়ের সংস্করণ: Python 3.14.4, torch 2.15.0, numpy 2.5.4; সময় প্রতি token-এর, ২০টি token-এর median):
~/bazarghar-dl$ uv run python kv_generate.py
torch 2.15.0
step 1 loss 84.075
…
step 600 loss 0.327
params 1060480 | vocab 55
cuda greedy identical tokens: True
cuda sampled identical tokens: True
text: অর্ডার 567: বরিশাল, চাল, দুই দিনে পৌঁছেছে। / অর্ডার 878: চট্টগ্রাম, সা
max |logit difference| cached vs full: 1.2e-05
cuda no cache ms/token t=50: 4.99 t=200: 3.44 t=400: 3.74 t=800: 4.72 t=980: 10.04 | total 5.06 s
cuda cache ms/token t=50: 6.88 t=200: 5.78 t=400: 4.93 t=800: 5.29 t=980: 5.59 | total 5.60 s
cpu greedy identical tokens: True
cpu sampled identical tokens: True
…
cpu no cache ms/token t=50: 4.92 t=200: 14.52 t=400: 11.04 t=800: 23.42 t=980: 37.26 | total 19.16 s
cpu cache ms/token t=50: 1.43 t=200: 2.01 t=400: 2.06 t=800: 3.29 t=980: 2.48 | total 2.65 s
big params 26280448
big no cache ms/token t=50: 19.81 t=200: 22.07 t=400: 35.57 t=800: 74.23 t=1180: 120.20 | total 71.01 s
big cache ms/token t=50: 24.07 t=200: 19.17 t=400: 19.23 t=800: 21.77 t=1180: 21.68 | total 24.71 s
big identical tokens: True
big prompt 250: first pass 21.57 ms | next token 21.81 ms
big prompt 500: first pass 41.94 ms | next token 18.98 ms
big prompt 1000: first pass 93.07 ms | next token 19.31 ms
big prompt 2000: first pass 255.28 ms | next token 29.28 ms
…
K shape (1, 4, 1000, 32) torch.float32 | cache bytes 4096000 | formula 2*L*d*T*bytes = 4096000
পড়ুন, আর সৎভাবে পড়ুন:
- মিল: Greedy আর sampling, দুটিতেই token এক। Logit-এর সর্বোচ্চ পার্থক্য ১.২e-05: float32-এর গোলের ঘরের পার্থক্য, “হুবহু” মানে এটুকুই।
- CPU-তে পরিষ্কার লাভ: t ≈ ৯৮০-তে ৩৭.২৬ ms বনাম ২.৪৮ ms; মোট ১৯.১৬ s বনাম ২.৬৫ s. একই লাইনে t = ২০০ আর ৪০০-র ওঠানামা দেখুন: মেশিনে অন্য কাজও চলছিল।
- ছোট model-এ GPU-তে কোনো লাভ নেই: এখানে cache-সহ মোট সময় বরং সামান্য বেশি, ৫.৬০ s বনাম ৫.০৬ s. প্রতি ধাপের খরচে GPU-তে কাজ পাঠানোর খরচই বড় (পর্ব ১২), matmul নয়। এই GPU সেই সময় অন্য কাজেও ব্যস্ত ছিল, তাই সংখ্যাটি ঢিলেঢালাভাবে পড়ুন।
- বড় model-এ (২.৬ কোটি parameter, না-শেখানো, শুধু সময়ের জন্য) লাভ ফিরে আসে: t ≈ ১,১৮০-তে ১২০.২০ ms বনাম ২১.৬৮ ms. Cache-এর সারি প্রায় সমতল।
- প্রথম token-এর দেরি: ২,০০০ token-এর prompt-এ প্রথম pass ২৫৫ ms, পরের token ২৯ ms. Cache প্রথম pass-কে ছোঁয় না।
এগুলো একটি local run-এর সংখ্যা, সাধারণ নিয়ম নয়। নিজের model-এ মাপুন।
৪ · Cache-এর memory — হাতে হিসাব
গুণ বাঁচে, দাম দেয় memory. প্রতি স্তরে K আর V, দুটি tensor, প্রতিটি (B, h, T, d/h). গুণ করলে h কাটা পড়ে:
KV memory = 2 · L · B · T · d · bytes
এখানে L স্তর, B batch, T token, d পুরো মাত্রা, আর bytes এক সংখ্যার মাপ (float32-এ ৪, float16-এ ২)। Head বাড়ালে cache বড় হয় না। পরীক্ষায় এই হিসাব প্রায়ই আসে, তাই একবার ধাপে ধাপে দেখুন।
| এই ধাপ | |
| ফল | |
| মানে |
- এক head. dk = ৫১২ ÷ ৮ = ৬৪. এক token-এর এক head-এর K-তে ৬৪টি সংখ্যা।
- সব head. ৬৪ × ৮ = ৫১২, অর্থাৎ d-ই ফিরে এলো। এই কারণেই সূত্রে h নেই।
- K আর V. দুটি tensor, তাই ৫১২ × ২ = ১,০২৪টি সংখ্যা, প্রতি token-এ, প্রতি স্তরে।
- সব token. ১,০২৪ × ১,০২৪ = ১০,৪৮,৫৭৬টি সংখ্যা, এক স্তরে।
- সব স্তর. × ৬ = ৬২,৯১,৪৫৬টি সংখ্যা।
- Byte. float16-এ প্রতিটি ২ byte: ১,২৫,৮২,৯১২ byte.
- MiB. ১০,৪৮,৫৭৬ দিয়ে ভাগ করলে ঠিক ১২ MiB, একটি অনুরোধে।
- Batch. ৮টি অনুরোধ একসঙ্গে: ১২ × ৮ = ৯৬ MiB. Memory T আর B, দুইয়ের সঙ্গেই সরলরেখায় বাড়ে।
এবার নানা মাপের model-এ। সংখ্যাগুলো কাল্পনিক উদাহরণ, কোনো নির্দিষ্ট পণ্যের নয়। প্রথম সারিটি আমাদের lab-এর model, যার সত্যিকারের cache আগের run-এ ৪০,৯৬,০০০ byte ছিল।
import numpy as np
SIZE = {"float32": 4, "float16": 2, "bf16": 2, "int8": 1}
def kv_bytes(L, h, d, T, dtype, B=1, kv_heads=None):
"""K আর V: 2 টি tensor, প্রতিটি (B, kv_heads, T, d/h), L স্তরে।"""
return 2 * L * B * (kv_heads or h) * (d // h) * T * SIZE[dtype]
def human(n):
for unit, s in ((2**30, "GiB"), (2**20, "MiB"), (2**10, "KiB")):
if n >= unit: return f"{n / unit:7.1f} {s}"
return f"{n:7d} B"
print(" L h d T dtype B kv_h memory")
for L, h, d, T, dt, B, kh in [(4, 4, 128, 1000, "float32", 1, None),
(6, 8, 512, 1024, "float16", 1, None),
(6, 2, 512, 1024, "float16", 1, None),
(6, 8, 512, 1024, "float16", 8, None),
(12, 12, 768, 1024, "float16", 1, None),
(32, 32, 4096, 8192, "float16", 1, None),
(32, 32, 4096, 128000, "float16", 1, None),
(32, 32, 4096, 128000, "float16", 1, 8)]:
print(f"{L:2d} {h:2d} {d:4d} {T:7d} {dt:7s} {B:2d} {kh or h:4d} {human(kv_bytes(L, h, d, T, dt, B, kh))}")
L, h, d, T = 4, 4, 128, 1000
real = 0
for _ in range(L): # সত্যিকারের array বানিয়ে যাচাই
K = np.zeros((1, h, T, d // h), np.float32); V = np.zeros_like(K)
real += K.nbytes + V.nbytes
print("real arrays:", real, "| formula:", kv_bytes(L, h, d, T, "float32"))আউটপুট (পাতার Python-এ চালানো):
L h d T dtype B kv_h memory
4 4 128 1000 float32 1 4 3.9 MiB
6 8 512 1024 float16 1 8 12.0 MiB
6 2 512 1024 float16 1 2 12.0 MiB
6 8 512 1024 float16 8 8 96.0 MiB
12 12 768 1024 float16 1 12 36.0 MiB
32 32 4096 8192 float16 1 32 4.0 GiB
32 32 4096 128000 float16 1 32 62.5 GiB
32 32 4096 128000 float16 1 8 15.6 GiB
real arrays: 4096000 | formula: 4096000
দ্বিতীয় আর তৃতীয় সারি: h = ৮ আর h = ২, memory একই। ৬ষ্ঠ আর ৭ম সারি বড় কথাটি বলে: একই model-এ ৮,১৯২ token-এ ৪ GiB, ১,২৮,০০০ token-এ ৬২.৫ GiB, একজন ব্যবহারকারীর জন্য। শেষ সারি grouped-query attention (GQA; Ainslie ও সহকর্মী, ২০২৩): ৩২টি query head মিলে ভাগ করে ৮ জোড়া K, V; memory চার ভাগের এক, ১৫.৬ GiB. প্রবন্ধটির দাবি, মান multi-head-এর কাছাকাছি থাকে; তবু নিজের কাজে যাচাই করা ভালো। আমাদের ছোট model-এ ১,০০০ token-এর cache (৪.১ MB) প্রায় তার weight-এর সমান (১০,৬০,৪৮০ × ৪ byte ≈ ৪.২ MB). ছোট model-এও cache ফেলনা নয়।
Memory ফুরালে কী হয়? Cache নিজে কমে না, batch ছোট করতে হয়, বা অনুরোধ অপেক্ষায় থাকে। তাই serving system-গুলো একে সবচেয়ে দামি সম্পদের মতো সামলায়। এই প্রসঙ্গ আবার আসবে পর্ব ৫৯ (ছোট আর দ্রুত model) আর পর্ব ৮৩-এ (LLM serving).
৫ · Batched generation — কেন left padding
GPU-র লাভ পেতে কয়েকটি অনুরোধ একসঙ্গে চালাতে হয়। কিন্তু prompt-এর দৈর্ঘ্য আলাদা, আর tensor আয়তাকার। তাই পর্ব ১১-এর pad token লাগে। Classification-এ pad ডানে বসলেই চলে। Generation-এ নয়। (পর্ব ৩১-এর causal convolution-এর left padding অন্য জিনিস: সেখানে সময়ের অক্ষে শূন্য বসানো হয়, এখানে prompt সারিবদ্ধ করা।)
- সমস্যা. দুটি prompt: A B C D আর E F. দৈর্ঘ্য ৪ আর ২, একটি tensor-এ বসে না।
- Right padding. সারি ২: E F P P.
logits[:, -1]শেষ কলাম পড়ে: সারি ১-এ D, ঠিক; সারি ২-এ P, যে ঘরে কোনো আসল token নেই। Model-এর কাছে F-এর পরের অনুমান সেখানে নেই। - পরের ধাপ আরও খারাপ. নতুন token y বসে P-এর পরে: E F P P y. Mask দিলে P দেখা না-ও যেতে পারে, কিন্তু প্রথম y-টিই ভুল ঘর থেকে পড়া। এই ভুল ক্র্যাশ করে না।
- ঠিক করা যায়, তবে ঝামেলা. প্রতি সারির নিজের শেষ ঘর মনে রেখে পড়া আর লেখা: E F y P. KV cache-এ এটি আরও কঠিন, কারণ প্রতি সারি আলাদা জায়গায় লেখে। তাই বেশিরভাগ কোড এই পথে যায় না।
- Left padding. সারি ২: P P E F. শেষ কলাম দুই সারিতেই আসল token: D আর F. Position গোনা হয় mask থেকে: 0 0 0 1, যাতে E পায় 0, যেমন একা চললে পেত।
- পরের ধাপ সহজ. নতুন token সব সারিতে একই নতুন কলামে: x আর y. Position 2. Cache-এ সব সারিতে একই জায়গায় একটি করে নতুন k, v.
একটি ফাঁদ আছে: শুধু pad-এর দিক বদলালে হবে না। Pad-এর জন্য mask আর position id দুটোই লাগে। প্রথম তিনটি prompt, তারপর ৯৬টি prompt-এ, ১৬ করে batch-এ, সত্যিকারের run; প্রতি লাইনে দেখা হচ্ছে, batched ফল একা চালানোর সঙ্গে পুরো মেলে কি না:
~/bazarghar-dl$ uv run python padding_gen.py
prompt lengths: [18, 8, 15]
alone prompt 2: '14: ঢাকা, চাল, দাম ঠিক ছ'
alone prompt 3: 'েট, চাল, দাম ঠিক ছিল।\nঅর'
right ids prompt 2: [18, 41, 54, 30, 47, 41, 2, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
left ids prompt 2: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 41, 54, 30, 47, 41, 2, 11]
right, logits[:, -1] [True, True, False]
prompt 3: 'ালেট, চাল, দাম ঠিক ছিল।\n'
left, positions 0..t-1 [True, False, False]
prompt 3: 'েট, আলু, দাম ঠিক ছিল।\nঅর'
left, positions from mask [True, True, True]
prompt 3: 'েট, চাল, দাম ঠিক ছিল।\nঅর'
right, per-row bookkeeping [True, True, True]
prompt 3: 'েট, চাল, দাম ঠিক ছিল।\nঅর'
left, positions + KV cache [True, True, True]
prompt 3: 'েট, চাল, দাম ঠিক ছিল।\nঅর'
96 unseen-seed prompts, batches of 16: whole sequence equal to running alone
right, logits[:, -1] 16 / 96
left, positions 0..t-1 29 / 96
left, positions from mask 96 / 96
right, per-row bookkeeping 96 / 96
left, positions + KV cache 96 / 96
শিক্ষাগুলো: (১) Right padding মাঝে মাঝে মিলে যায়, যেমন prompt ২-এ, আর ১৬টি sequence-এ। তিনটি উদাহরণ দেখে “ঠিক আছে” বলা বিপজ্জনক। সারি ৩-এ “সিল”-এর পরে এসেছে “ালেট”: অর্থহীন। (২) Left padding-এ position ভুল হলে “চাল”-এর জায়গায় “আলু”: পড়তে ভালো, কিন্তু ভুল, আর ৯৬-এর ৬৭টিতে এমন অমিল। Learned position embedding-এ এটি সরাসরি ধরা পড়ে। (৩) ঠিক পদ্ধতিতে ৯৬-এ ৯৬, cache-সহও। (৪) Per-row হিসাবও ৯৬-এ ৯৬: right padding নিজে অসম্ভব নয়; left padding সহজতর। Hugging Face transformers-এর decoder-only model-এ ডানের pad ধরলে সতর্কবার্তা আসে, আর সংশ্লিষ্ট আলোচনায় maintainer-রা left padding-এর কথাই বলেন (লেখার সময়ে, নভেম্বর ২০২৬)। শেখানোর সময় pad-এর দিক সাধারণত এত গুরুত্বপূর্ণ নয়, কারণ সেখানে প্রতিটি ঘরের loss আলাদা, আর mask ও ignore_index pad-কে বাদ দেয়।
৬ · FlashAttention-এর ধারণা — ফল একই, memory-র পথ আলাদা
KV cache কমায় হিসাবের সংখ্যা। FlashAttention (Dao ও সহকর্মী, ২০২২) অন্য জায়গায় হাত দেয়। এর প্রবন্ধের যুক্তি: attention ধীর শুধু গুণের কারণে নয়, GPU-র বড় memory আর চিপের ভেতরের ছোট দ্রুত memory-র মধ্যে ডেটা আনা-নেওয়ার কারণেও। Memory hierarchy-র ধারণা আগেই আছে কম্পিউটার আর্কিটেকচার কোর্সের পর্ব ৪৪-এ। সাধারণ attention T × T score-এর পুরো matrix বড় memory-তে লেখে, softmax করে, আবার পড়ে। অথচ শেষে লাগে শুধু output.
পরীক্ষার খাতা দেখার সময় প্রতিটি খাতার নম্বর আলাদা রেজিস্টারে না লিখেও গড় বের করা যায়: হাতে একটি চলতি যোগফল আর গোনা সংখ্যা রাখলেই হয়। FlashAttention softmax-এর জন্য এই কাজটি করে।
দুটি উপাদান। Tile: Q, K, V-কে ছোট টুকরোয় ভেঙে এক এক টুকরো দ্রুত memory-তে আনা। Online softmax: সব score একসঙ্গে না রেখেই softmax. এক query-র জন্য তিনটি সংখ্যা রাখলেই চলে: এখন পর্যন্ত সর্বোচ্চ score m, স্কেল করা যোগফল l, আর স্কেল করা output o. নতুন টুকরোর score s এলে:
m′ = max(m, max s) · l′ = l·em−m′ + ∑es−m′ · o′ = o·em−m′ + ∑es−m′·v
শেষে output = o / l. পর্ব ৩-এর stable softmax-এর মতো এখানেও সর্বোচ্চ মান বিয়োগ করে সংখ্যা নিরাপদ রাখা হয়। NumPy-তে যাচাই করুন, ফল কি সত্যিই একই?
import numpy as np
rng = np.random.default_rng(37)
T, d, b = 256, 32, 32 # token, মাত্রা, এক টুকরোর মাপ
Q, K, V = rng.normal(size=(3, T, d))
def full(q, K, V): # সাধারণ পথ: T-টি score একসঙ্গে রাখা
s = K @ q / np.sqrt(d)
a = np.exp(s - s.max()); a /= a.sum()
return a @ V, len(s)
def online(q, K, V, b): # টুকরো টুকরো: score জমে না, শুধু m, l, o
m, l, o = -np.inf, 0.0, np.zeros(d)
for i in range(0, len(K), b):
s = K[i:i + b] @ q / np.sqrt(d)
m2 = max(m, s.max())
p = np.exp(s - m2) # নতুন সর্বোচ্চ দিয়ে স্কেল করা
r = np.exp(m - m2) # আগের যোগফল নতুন স্কেলে আনা
l = l * r + p.sum()
o = o * r + p @ V[i:i + b]
m = m2
return o / l, b
a = np.array([full(q, K, V)[0] for q in Q])
for size in (1, 8, 32, 128):
c = np.array([online(q, K, V, size)[0] for q in Q])
print(f"tile {size:3d}: max |full - online| = {np.abs(a - c).max():.1e}")
print("scores held at once, all queries: full", T * T, "| online", b)
big = K[:4] * 40 # খুব বড় score: সরাসরি e^s ফেটে যায় কি?
q = Q[0] * 40
s = big @ q / np.sqrt(d)
print("largest score", round(s.max(), 1), "| exp(score) =", np.exp(s.max()))
print("online on the same data:", np.round(online(q, big, V[:4], 2)[0][:3], 4))
print("full on the same data:", np.round(full(q, big, V[:4])[0][:3], 4))আউটপুট (পাতার Python-এ চালানো):
tile 1: max |full - online| = 8.9e-16
tile 8: max |full - online| = 8.9e-16
tile 32: max |full - online| = 8.9e-16
tile 128: max |full - online| = 7.8e-16
scores held at once, all queries: full 65536 | online 32
largest score 1716.3 | exp(score) = inf
online on the same data: [-1.466 1.5189 -0.3299]
full on the same data: [-1.466 1.5189 -0.3299]
পার্থক্য ১০−১৬-এর ঘরে, যে মাপের টুকরোই হোক: float64-এর গোলের সীমা। এই জন্য একে exact বলা হয়, approximate নয়। একই সময়ে রাখা score ৬৫,৫৩৬ থেকে ৩২. শেষ তিন লাইন: score ১,৭১৬-এ es সরাসরি inf, অথচ দুই পদ্ধতিই সর্বোচ্চ বিয়োগ করে নিরাপদ। এটি ধারণার খেলনা রূপ। আসল FlashAttention GPU কোডে লেখা, আর গতির লাভ আসে memory-র পথ থেকে, যা NumPy-তে দেখা যায় না।
PyTorch-এ এটি আলাদা করে ইনস্টল করতে হয় না। F.scaled_dot_product_attention-এর docs (২.১৫, লেখার সময়ে, নভেম্বর ২০২৬) তিনটি বাস্তবায়নের কথা বলে: FlashAttention-2, Memory-Efficient Attention আর C++-এ লেখা সাধারণ গণিত। সবগুলো ডিফল্টে চালু, আর function নিজে input দেখে একটি বাছে। হাতের লেখা সংস্করণের সঙ্গে তুলনা, B = 4, h = 8, dk = 64, causal mask, ৭–১১ বারের median; সত্যিকারের run:
~/bazarghar-dl$ uv run python sdpa_bench.py
torch 2.15.0
cpu float32 T= 1024 hand 94.89 ms
T= 1024 sdpa 57.96 ms | max diff 8.3e-07
cuda float32 T= 1024 hand 11.86 ms | extra peak 280.1 MiB
T= 1024 sdpa 6.15 ms | extra peak 20.0 MiB | max diff 2.9e-06
cuda float32 T= 2048 hand 40.77 ms | extra peak 1064.1 MiB
T= 2048 sdpa 22.67 ms | extra peak 48.0 MiB | max diff 2.3e-06
cuda float16 T= 1024 hand 4.65 ms | extra peak 144.1 MiB
T= 1024 sdpa 1.14 ms | extra peak 10.0 MiB | max diff 2.0e-03
cuda float16 T= 2048 hand 19.18 ms | extra peak 536.1 MiB
T= 2048 sdpa 4.37 ms | extra peak 24.0 MiB | max diff 2.0e-03
--- forcing one backend, GPU, float16, T=1024 ---
MATH mask 12.82 ms | extra peak 326.0 MiB
MATH is_causal 12.60 ms | extra peak 328.0 MiB
EFFICIENT_ATTENTION mask 1.32 ms | extra peak 10.0 MiB
EFFICIENT_ATTENTION is_causal 0.49 ms | extra peak 8.0 MiB
FLASH_ATTENTION mask refused: RuntimeError: No available kernel. Aborting execution.
FLASH_ATTENTION is_causal 0.53 ms | extra peak 8.1 MiB
পড়ুন: সবচেয়ে বড় পার্থক্য সময়ে নয়, memory-তে। হাতের সংস্করণে T = ২,০৪৮, float32-এ অতিরিক্ত ১,০৬৪ MiB; SDPA-তে ৪৮ MiB. কারণ হাতের কোড 4 × 8 × T² আকারের score matrix বানায়, T দ্বিগুণে memory চার গুণ। GPU-তে সময়ের লাভ এখানে প্রায় দুই থেকে চার গুণ, dtype অনুযায়ী; CPU-তে প্রায় ১.৬ গুণ। ফলের পার্থক্য float32-এ ১০−৬-এর ঘরে, float16-এ ২e-03: এগুলো ওই dtype-এর গোলের সীমা। মূল প্রবন্ধের গতির সংখ্যা তাদের নিজস্ব শর্তের; এই সংখ্যাগুলো একটি ব্যস্ত GPU-র একটি local run.
শেষ দুই ব্লক লক্ষ করুন। বুলিয়ান attn_mask-সহ FlashAttention জোর করলে এই build-এ প্রত্যাখ্যাত, is_causal=True-তে চলে। ডিফল্টে function অন্য kernel-এ চলে যায়, তাই সাধারণত ভুলটি চোখে পড়ে না। প্রতি সারির আলাদা pad mask-ও সাধারণ বুলিয়ান mask. তাই শুধু causal অংশটুকু হলে is_causal=True লেখা ভালো; নির্ভর করার আগে নিজের সংস্করণে পরীক্ষা করুন। MATH-এর memory বেশি কেন? Docs বলে, float16 input-এও ওই ব্যাকএন্ড মাঝের হিসাব float32-এ রাখে। আরেকটি ফাঁদ: এই function নিজে dropout বন্ধ করে না; model.eval()-এর সময় dropout_p শূন্য আপনাকেই পাঠাতে হয়।
৭ · Sliding window আর sparse attention — ফল বদলে খরচ কমানো
এতক্ষণের কৌশলে ফল বদলায়নি। এবার ভিন্ন লেনদেন: সবাইকে না দেখে কয়েকজনকে দেখা। Sliding window attention-এ প্রতিটি token শুধু শেষ W-টি token দেখে। Score-এর খরচ T²·d থেকে প্রায় T·W·d, আর cache-এর memory সীমিত থাকে W-তে। দাম? একটি স্তরে W-এর আগের কিছু দেখা যায় না। স্তরের পর স্তরে দৃষ্টি বাড়ে, পর্ব ৩১-এর receptive field-এর মতো।
import numpy as np, matplotlib.pyplot as plt
np.set_printoptions(precision=2, suppress=True, linewidth=72)
rng = np.random.default_rng(37)
T, d, W = 12, 8, 4 # ১২ token, মাত্রা ৮, জানালা ৪
X = rng.normal(size=(T, d))
Wq, Wk, Wv = rng.normal(0, .4, (3, d, d))
def attn(x, window=None):
s = (x @ Wq) @ (x @ Wk).T / np.sqrt(d)
n = len(x); keep = np.tril(np.ones((n, n), bool))
if window: keep &= ~np.tril(np.ones((n, n), bool), -window)
s = np.where(keep, s, -np.inf)
a = np.exp(s - s.max(-1, keepdims=True)); a /= a.sum(-1, keepdims=True)
return x + a @ (x @ Wv), a
def stack(x, layers, window=None):
for _ in range(layers): x = attn(x, window)[0]
return x
yf, af = attn(X); yw, aw = attn(X, W)
print("per-token |full - window|:", np.abs(yf - yw).max(1))
X2 = X.copy(); X2[0] += 5.0 # প্রথম token (অর্ডার নম্বর) বদলানো
for layers in (1, 2, 3, 4):
c_f = np.abs(stack(X2, layers) - stack(X, layers)).max(1)
c_w = np.abs(stack(X2, layers, W) - stack(X, layers, W)).max(1)
print(f"layers {layers}: token 0 reaches t = 11? full {c_f[11] > 0} | "
f"window {c_w[11] > 0} | window reach {np.flatnonzero(c_w).max()}")
print("score multiplications, T = 12, d = 8: full", T * T * d, "| window",
int(np.tril(np.ones((T, T)), 0).sum() - np.tril(np.ones((T, T)), -W).sum()) * d)
fig, ax = plt.subplots(1, 2, figsize=(6, 2.8))
for p, m, name in zip(ax, (af, aw), ("full causal", f"window W = {W}")):
p.imshow(m, vmin=0, vmax=1, cmap="Blues"); p.set_title(name)
p.set_xlabel("key position"); p.set_ylabel("query position")
fig.subplots_adjust(wspace=0.45, bottom=0.2); plt.show()আউটপুট (পাতার Python-এ চালানো):
per-token |full - window|: [0. 0. 0. 0. 0.35 0.09 1.03 0.91 2.03 1.17 0.6 0.91]
layers 1: token 0 reaches t = 11? full True | window False | window reach 3
layers 2: token 0 reaches t = 11? full True | window False | window reach 6
layers 3: token 0 reaches t = 11? full True | window False | window reach 9
layers 4: token 0 reaches t = 11? full True | window True | window reach 11
score multiplications, T = 12, d = 8: full 1152 | window 336
প্রথম চারটি token-এ পার্থক্য শূন্য: ওদের কাছে জানালা আর পুরো অতীত একই। এরপর ফল আলাদা, তাই এটি আসলে ভিন্ন একটি model; শুধু গতি বাড়ানোর কৌশল নয়। স্তরে স্তরে নাগাল ৩, ৬, ৯, ১১: প্রতি স্তরে W − ১ = ৩ ঘর। প্রথম token-এর তথ্য (ধরুন অর্ডার নম্বর) t = ১১-এ পৌঁছাতে চারটি স্তর লেগেছে। গুণ ১,১৫২ থেকে ৩৩৬. ডান দিকের heatmap-এ শুধু কর্ণের কাছের একটি ফিতা রঙিন।
| পদ্ধতি | ধারণা | ফল বদলায়? |
|---|---|---|
| KV cache | পুরনো K, V জমা রাখা | না |
| FlashAttention | Tile আর online softmax | না |
| Sliding window (Longformer ২০২০-এ global token-সহ; Mistral 7B-এর প্রবন্ধেও উল্লেখ আছে) | শেষ W-টি token | হ্যাঁ |
| Sparse attention (Child ও সহকর্মী ২০১৯; BigBird ২০২০) | নির্দিষ্ট নকশায় অল্প কিছু জোড়া | হ্যাঁ |
| Linformer (Wang ও সহকর্মী, ২০২০) | K, V-কে দৈর্ঘ্যের অক্ষে ছোট k-তে নামানো (low-rank) | হ্যাঁ, আসন্ন মান |
| Performer (Choromanski ও সহকর্মী, ২০২০) | Random feature দিয়ে softmax kernel-এর আসন্ন মান | হ্যাঁ, আসন্ন মান |
Linformer আর Performer প্রকাশের সময় বেশ আলোচিত ছিল, আর তাদের লক্ষ্য T² ভাঙা। তবে লেখার সময়ে আমাদের জানা মতে, বড় LLM-এর মূলধারা এদের পথে যায়নি; exact attention, দক্ষ kernel আর cache নিয়েই চলেছে। বিষয়টি এখনো গবেষণাধীন, তাই “কখনো কাজে লাগবে না” ধরে নেবেন না। Attention পুরো সরিয়ে দেওয়ার কৌশল (যেমন state-space model) এই পর্বের বাইরে।
৮ · Long context — সীমা আর কৌশল
লম্বা context window-এর সীমা তিন জায়গায়: সময় (প্রথম pass), memory (cache), আর position. Position-এর সীমা নির্ভর করে পদ্ধতির ওপর। আমাদের learned position table-এর দৈর্ঘ্য ২,০৪৮; তার বাইরে গেলে সত্যিকারের run-এ দুই রকম ভুল:
max_len of the position table: 2048
CPU-তে: IndexError: index out of range in self
GPU-তে: AcceleratorError: CUDA error: device-side assert triggered
GPU-র বার্তাটি কোন ভুল, তা বলে না; তাই debug করতে আগে CPU-তে চালিয়ে দেখা কাজের কৌশল। RoPE-এ (পর্ব ৩৪) সীমাটি নরম: rotation-এর সূত্র যেকোনো দৈর্ঘ্যে হিসাব করা যায়, আর score-এ অবস্থানের প্রভাব নির্ভর করে শুধু আপেক্ষিক দূরত্বে। তবু দুটি কথা মনে রাখুন। বাস্তবায়নে প্রায়ই আগে থেকে হিসাব করা নির্দিষ্ট দৈর্ঘ্যের table থাকে। আর সূত্র চলা মানেই model সেই দৈর্ঘ্যে ভালো কাজ করবে, এমন নয়; শেখানোর দৈর্ঘ্যের বাইরে মান পড়তে পারে, যা পর্ব ৩৪-এর বিষয়।
Cache ছাঁটা আর আবার চালানো এক নয়. একটি সহজ কৌশল হলো cache-এর পুরনো অংশ ফেলে দেওয়া। কিন্তু রেখে দেওয়া K, V তৈরি হয়েছিল পুরনো context দেখে। সত্যিকারের run, ৪০০ token-এর পর W = ৬৪, একই পরের token:
cropped cache vs window rerun: max |logit diff| 3.443
cropped cache vs full context: max |logit diff| 33.388
window rerun vs full context: max |logit diff| 31.866
same top token: True
তিনটি ভিন্ন ফল, যদিও এই উদাহরণে শীর্ষ token একই। Xiao ও সহকর্মীদের ২০২৩ সালের প্রবন্ধ দেখায়, শুধু শেষের K, V রাখলে text cache-এর চেয়ে লম্বা হলে ফল ভেঙে পড়ে, আর শুরুর কয়েকটি token-এর K, V রাখলে অনেকটা ফেরে। এদের নাম তাঁরা দেন attention sink. কারণ হিসেবে তাঁদের ব্যাখ্যা: শুরুর token-গুলো অর্থ নির্বিশেষে বেশি attention পায়।
| কৌশল | কী করে | যা হারায় |
|---|---|---|
| পুরনো অংশ বাদ | শুধু শেষের token | শুরুর নির্দেশ, যদি আলাদা করে না রাখেন |
| Cache ছাঁটা (sink-সহ) | শুরুর কয়েকটি আর শেষের W-টি | মাঝের অংশ; পুনরায় চালানোর সঙ্গে ফল আলাদা |
| সারসংক্ষেপ | পুরনো কথোপকথন ছোট করে লেখা | খুঁটিনাটি, আর সারাংশে ভুল ঢুকতে পারে |
| Retrieval (পর্ব ৬৯, RAG) | দরকারি টুকরো খুঁজে আনা | খুঁজে না পাওয়া অংশ; নতুন ভুলের উৎস |
| Prefix cache (পর্ব ৬৫) | একই শুরুর K, V আবার ব্যবহার | কিছু না; তবে শুরু হুবহু এক হতে হয় |
Speculative decoding, একটি নোট. ছোট একটি model কয়েকটি token আগাম লেখে; বড় model এক pass-এ সেগুলো যাচাই করে, যতটুকু মেলে রাখে (Leviathan ও সহকর্মী, ২০২২–২৩; Chen ও সহকর্মী, ২০২৩)। Rejection sampling-এর নিয়ম মানলে output-এর বণ্টন বড় model-এর সমান থাকে। এখানে KV cache পিছিয়ে নেওয়া লাগে: বাতিল token-এর K, V ছেঁটে ফেলতে হয়। Beam search-এও (পর্ব ৫০) cache সারির ক্রম বদলাতে হয়।
আরও গভীরে: FlashAttention প্রবন্ধটি IO complexity-র হিসাবও দেয়: SRAM-এর মাপ M আর head-এর মাত্রা d হলে বড় memory-তে প্রবেশ Θ(T²d²/M), যেখানে সাধারণ পদ্ধতিতে Θ(Td + T²); SRAM-এর একটি পরিসরে একে সর্বোত্তম বলে তাঁরা দাবি করেন। FlashAttention-2 (২০২৩) GPU-র সমান্তরালতা আরও ভালোভাবে কাজে লাগানোর দিকে এগোয়। KV cache-এর memory ভাগে vLLM-এর PagedAttention (Kwon ও সহকর্মী, ২০২৩) virtual memory-র page-এর মতো টুকরো ব্যবহার করে; অপারেটিং সিস্টেমের ধারণা আবার ফিরে এসেছে।
পরীক্ষা ও ইন্টারভিউতে:
- KV cache-এ Q রাখা হয় না কেন? পুরনো token আর প্রশ্ন করে না; নতুন query শুধু পুরনো K, V পড়ে।
- Cache কেন সঠিক? Causal mask-এর কারণে পুরনো token-এর hidden state নতুন token-এ বদলায় না।
- Memory-র সূত্র? 2·L·B·T·d·bytes; h বাদ, GQA-তে K, V-এর head কমে।
- Generation-এ left padding কেন?
logits[:, -1]আসল token থেকে পড়ে, নতুন কলাম সবার একই; position mask থেকে। - FlashAttention আর Linformer-এর মূল পার্থক্য? প্রথমটি exact, memory-র পথ বদলায়; দ্বিতীয়টি আসন্ন মান।
৯ · AI-এর যুগে — generation কোড যাচাই
AI assistant generate লুপ মিনিটে লিখে দেয়। এই লুপের সবচেয়ে খারাপ ভুলগুলো ক্র্যাশ করে না, শুধু অন্য কিছু লেখে।
| AI প্রায়ই যা করে | কেন ভুল | কীভাবে ধরবেন |
|---|---|---|
| Cache-এর ধাপে position আবার 0 থেকে | নতুন token ভুল জায়গার embedding পায় | Cache ছাড়া আর cache-সহ token মেলানো |
Batch-এ ডানে pad, logits[:, -1] | Pad-এর ঘর থেকে পড়া | একা চালানোর সঙ্গে অনেক prompt-এ মেলানো |
| Left padding, কিন্তু position 0..t−1 | আসল token ভুল position পায় | একই তুলনা; আমাদের run-এ ২৯/৯৬ |
পুরনো torch.backends.cuda.sdp_kernel | Docs এখন sdpa_kernel পছন্দ করে | নিজের সংস্করণের docs দেখা |
| “FlashAttention সবসময় N গুণ দ্রুত” | প্রবন্ধের শর্তের সংখ্যা; docs অনুযায়ী দক্ষ kernel CUDA-র জন্য | নিজের model-এ memory ও সময় মাপা |
| Linformer-কে exact বলা, বা বানানো সূত্র | এটি আসন্ন মান | মূল প্রবন্ধ পড়া, ছোট উদাহরণে হিসাব |
কোড চাওয়ার সময় পরীক্ষাগুলোও চাইতে বলুন:
AI-এর লেখা generate লুপ হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১২০১৯–২০ সালে T² ভাঙার অনেক প্রস্তাব এসেছিল: sparse, Linformer, Performer. তবু ২০২২-এর একটি exact পদ্ধতি বেশি ব্যবহৃত হয়ে উঠল বলে মনে হয়। কেন?
২০১৭ সালের Transformer-এর পর সবার চোখ পড়ে T²-এর ওপর। Child ও সহকর্মীরা ২০১৯ সালে sparse নকশা আনেন। ২০২০ সালে একসঙ্গে আসে Longformer, BigBird, Linformer আর Performer. এদের প্রবন্ধে নির্দিষ্ট কাজে ভালো ফলও ছিল। সমস্যা ছিল অন্যখানে। এরা model-কেই বদলায়। Pretrained weight যেমন আছে তেমন চলে না, আর আসন্ন মানের কারণে ফলের নিশ্চয়তা নতুন করে যাচাই করতে হয়।
২০২২ সালে FlashAttention সম্পূর্ণ ভিন্ন প্রশ্ন করে: T²-টি score না কমিয়ে, শুধু memory-র পথ বদলে কত লাভ হয়? ওই প্রবন্ধের অনেক আসন্ন পদ্ধতি নিয়ে একটি পর্যবেক্ষণ হলো, গুণ কমলেও memory-তে আনা-নেওয়া আর অতিরিক্ত হিসাবের কারণে বাস্তব সময় প্রায়ই সেই হারে কমেনি। Exact পদ্ধতির বড় সুবিধা হলো এটি শুধু একটি kernel বদলানো: একই weight, একই ফল, কোনো নতুন trade-off নেই। Library-তে ঢুকে গেলে ব্যবহারকারীর কোড বদলাতেই হয় না, যেমন PyTorch-এর function-এ হয়েছে।
আরেকটি কারণ হলো hardware-এর সঙ্গে মিল। GPU-র গুণ ঘন matrix-এ সবচেয়ে ভালো; এলোমেলো sparse নকশায় সুবিধা হারায়। আর বড় LLM-এ লম্বা context-এর ক্ষেত্রেও মূল সমস্যা প্রায়ই memory আর প্রথম pass-এর সময়, যেখানে KV cache, GQA আর ভালো kernel অনেকটা সাহায্য করে। তবে এই ব্যাখ্যা পশ্চাৎদৃষ্টি। “মূলধারা” বদলাতে পারে: খুব লম্বা context, বা স্মার্ট ফোনের মতো যন্ত্রে sub-quadratic পদ্ধতি আবার গুরুত্ব পেতে পারে, আর sliding window কিছু প্রকাশিত model-এ এখনো আছে।
মূল উপলব্ধি: গণিতের জটিলতা আর বাস্তব গতি এক নয়। আর “ফল বদলায় না” এমন উন্নতি গ্রহণ করা “ফল একটু বদলায়” এমন উন্নতির চেয়ে অনেক সহজ।
প্র ০২অঙ্কের হিসাবে cache-এ শত শত গুণ কম গুণ। তবু আমাদের ছোট model-এ GPU-তে সময় বাঁচেনি। এই ব্যবধান থেকে benchmark সম্পর্কে কী শেখা যায়?
গুণের সংখ্যা একটি মডেল, আসল সময় নয়। এক token-এর পেছনে ছোট GPU কাজে সময় যায় কয়েকটি জায়গায়: Python-এর লুপ, প্রতিটি operation পাঠানো, kernel শুরু করা, সব শেষে synchronize. পর্ব ১২-এ এই launch-এর খরচ দেখেছেন। চার স্তরের model-এ প্রতিটি forward-এ কয়েক ডজন operation আছে; matmul-এর আকার ছোট থাকলে সময়টা প্রায় স্থির থাকে, T যাই হোক। ১২৮ মাত্রা আর ১,০০০ token-এ GPU-র কাছে full forward-ও খুব ছোট কাজ। তাই আমাদের run-এ দুই পথে প্রতি token প্রায় ৫ ms.
Model বড় করতেই ছবি বদলাল: ২.৬ কোটি parameter-এ ১,১৮০ token-এ ১২০ ms বনাম ২২ ms. CPU-তে launch-এর খরচ কম, তাই ছোট model-এও ১৫ গুণ লাভ দেখা গেছে। অর্থাৎ একই কৌশলের “লাভ” নির্ভর করে model-এর মাপ, যন্ত্র, আর কতটা দীর্ঘ চালানো হলো তার ওপর।
এখানে আরও একটি সৎ কথা আছে: এই GPU-তে তখন অন্য কাজও চলছিল, আর একই লাইনের t = ৫০ কখনো t = ২০০-র চেয়ে ধীর। তাই একটি সংখ্যা ধরে “cache ১১% ধীর” লেখা ভুল হতো। ভালো benchmark-এ কয়েকবার চালানো, median নেওয়া, warm-up বাদ দেওয়া, আর GPU-তে synchronize করা লাগে। Serving-এর ক্ষেত্রে batch-এ সবাই একসঙ্গে থাকলে, বা memory ফুরালে, ছবি আবার বদলায়। তাই ছোট demo-র ফল নিয়ে বড় সিদ্ধান্ত নেওয়া যায় না। আর উল্টোটাও সত্য: কোনো প্রবন্ধের “৩ গুণ দ্রুত” আপনার মেশিনে নাও মিলতে পারে।
মূল উপলব্ধি: জটিলতার সূত্র বলে T বড় হলে কী হবে; কোন T-তে তা গুরুত্ব পাবে, তা বলে শুধু আপনার নিজের মাপা সময়।
প্র ০৩একজন ব্যবহারকারী চান দ্রুত উত্তর। প্রথম token-এর দেরি আর প্রতি token-এর গতি কেন আলাদা জিনিস, আর এদের একসঙ্গে উন্নত করা কঠিন কেন?
Chat-এ মানুষের অনুভূতি মূলত দুটি সংখ্যায়: কতক্ষণ পরে লেখা শুরু হলো, আর শুরুর পর কত দ্রুত আসছে। প্রথমটি prompt-এর পূর্ণ pass-এ বাঁধা, যার attention-এর গুণ T²-এর হারে বাড়ে; আমাদের run-এ ২৫০ token-এ ২২ ms থেকে ২,০০০ token-এ ২৫৫ ms. দ্বিতীয়টি cache-এর ধাপ, প্রায় স্থির ২০–৩০ ms. দুটির হিসাব আলাদা ধরনের কাজ।
এদের সামলাতে নানা কৌশল আছে, আর প্রতিটির একটি দাম আছে। Batch বড় করলে মোট throughput বাড়ে, কিন্তু একজনের অপেক্ষা বাড়ে। প্রচুর প্রথম pass একসঙ্গে এলে cache-এর ধাপগুলো থেমে যেতে পারে; এজন্য কিছু serving system এই দুই ধরনের কাজ আলাদা করে সাজায়। একই শুরুর prompt, যেমন system prompt, বারবার এলে তার K, V রেখে দেওয়া যায়; অনেক hosted LLM-এর “prompt caching” বৈশিষ্ট্যের পেছনে এই ধরনের ধারণা আছে বলে ধরা হয়। তবে প্রতিটি সেবার আসল বাস্তবায়ন তাদের নিজস্ব, আর পর্ব ৬৫-এ এই নিয়ে কথা হবে। Cache ধরে রাখতে memory লাগে, তাই কতক্ষণ রাখা হবে তাও একটি সিদ্ধান্ত।
বাজারঘরের সহায়কের কথা ভাবুন। প্রতিটি অনুরোধে পুরো নীতিমালা পাঠালে প্রথম token ধীর হবে; ছোট করলে উত্তরের মান পড়তে পারে। Speculative decoding প্রতি token দ্রুত করে, কিন্তু খসড়া model-এর জন্য বাড়তি memory চায়, আর খসড়া ঘন ঘন বাতিল হলে লাভ কমে। Quantization (পর্ব ৫৯) সব ধাপে সাহায্য করে, কিন্তু মান যাচাই লাগে। তাই কোনো একক সংখ্যা “দ্রুত” বলে না; প্রথম token, প্রতি token, আর একসঙ্গে কতজন, তিনটিই আলাদা করে মাপতে হয়। Percentile দেখাটাও জরুরি, কারণ গড় ভালো হলেও কিছু মানুষ দীর্ঘ অপেক্ষায় থাকতে পারেন।
মূল উপলব্ধি: “দ্রুত” একটি সংখ্যা নয়। প্রথম token আর প্রতি token আলাদা কাজ, আলাদা খরচ, আলাদা কৌশল।
প্র ০৪Padding-এর দিকের ভুল কেন এত দীর্ঘদিন মানুষের চোখ এড়ায়? এমন “নীরব” ভুল ধরার সাধারণ কৌশল কী?
ভুলটি ক্র্যাশ করে না, loss-ও দেখায় না, আর output দেখতে প্রায়ই ঠিক লাগে। আমাদের run-এ right padding-এ prompt ২-এর ফল হুবহু ঠিক ছিল, আর ১৬টি sequence মিলেছে। Left padding-এ position ভুল হলে “চাল” হয়ে গেল “আলু”, যা ব্যাকরণে দিব্যি চলে। কেউ চোখে পড়ে ধরবে কীভাবে? Hugging Face transformers-এর issue tracker-এ বছরের পর বছর এই প্রশ্ন বারবার এসেছে; maintainer-রা উত্তর দিয়েছেন যে decoder-only model-এ left padding লাগে। শেষ পর্যন্ত library নিজেই ডানের pad ধরলে সতর্কবার্তা দেয়। একটি সাধারণ pipeline কোনো এক সময়ে নিজে left padding বসানোর দিকেও গেছে। এত আলোচনা নিজেই বলে, ভুলটি কত সহজ।
আরেকটি কারণ হলো উল্টো অভ্যাস। Classification শেখার সময় ডানে pad সবচেয়ে স্বাভাবিক, আর শেখানোর সময় LLM-এও প্রায়ই চলে। একই tokenizer তাই একই ডিফল্টে generation-এও চলে আসে। আবার একটি prompt-এর demo-তে padding-ই থাকে না, তাই সমস্যা দেখা যায় batch-এ যাওয়ার পর, production-এ। Batch-এর গঠনও ভাগ্যের ওপর: সব prompt সমান হলে কিছুই হয় না।
সাধারণ কৌশলটি হলো invariant পরীক্ষা: এমন একটি কথা খুঁজুন, যা সবসময় সত্য হওয়া উচিত। “Batched ফল একা চালানোর ফলের সমান।” “Cache ছাড়া আর cache-সহ সমান।” “Pad-এর সংখ্যা বাড়ালে ফল বদলায় না।” এই পরীক্ষাগুলোর জন্য আসল উত্তর জানা লাগে না, শুধু দুটি পথের মিল। পর্ব ৩১-এর impulse test-ও একই জাতের। আর অনেক উদাহরণে চালাতে হবে, ভিন্ন দৈর্ঘ্যে; তিনটি উদাহরণে আমাদের ভুল প্রায় লুকিয়ে ছিল, ৯৬টিতে স্পষ্ট। মনে রাখবেন, float-এর কারণে একদম সমান logit আশা করা যায় না; সহনসীমা ঠিক করে নিতে হয়।
মূল উপলব্ধি: দেখে ঠিক লাগা প্রমাণ নয়। দুটি পথের মিল যাচাই করা একটি সস্তা, শক্তিশালী পরীক্ষা, আর এতে “সঠিক উত্তর” জানা লাগে না।
প্র ০৫Context window লাখ token ছাড়াচ্ছে। তবে কি সব নথি model-এ ঢেলে দিলেই সমস্যা শেষ? Retrieval কি অপ্রয়োজনীয় হয়ে যাবে?
খরচের দিক থেকে আগে দেখুন। এই পর্বের কাল্পনিক বড় model-এ ১,২৮,০০০ token-এর cache ৬২.৫ GiB, একজনের জন্য। প্রথম pass-এর গুণ T²-এর হারে বাড়ে। প্রতিটি প্রশ্নে পুরো নথি পাঠালে সময় আর compute দুটিই বাড়ে, যদি না prefix cache কাজে লাগে। হ্যাঁ, GQA আর ভালো kernel এই সীমা ঠেলে দিয়েছে, তাই লম্বা context বাস্তবে এসেছে। কিন্তু সেটি বিনামূল্যে আসেনি।
মানের দিকটি আরও সূক্ষ্ম। Liu ও সহকর্মীদের ২০২৩ সালের “Lost in the Middle” প্রবন্ধ রিপোর্ট করে, তাঁদের পরীক্ষিত model-গুলো লম্বা প্রসঙ্গের শুরু আর শেষের তথ্য মাঝের তথ্যের চেয়ে ভালো ব্যবহার করে। পরের model-এ এই ফল কতটা থাকে, তা ভিন্ন হতে পারে, তাই নিজে পরীক্ষা করা ভালো। Attention sink-এর পর্যবেক্ষণও দেখায়, attention সব token-কে সমান ব্যবহার করে না। “জায়গা আছে” আর “ব্যবহার করতে পারে” এক কথা নয়।
আবার নিয়ন্ত্রণের দিকও আছে। বাজারঘরের লক্ষ লক্ষ রিভিউ বা অর্ডার যেকোনো context-এর চেয়ে বড়; তথ্য বদলায়; আর সব ডেটা model-এ পাঠানো privacy-র দিক থেকেও ঠিক নয়। কে কোন তথ্য দেখতে পারবে, উত্তরের উৎস কী, এসবের জন্য retrieval (পর্ব ৬৯) একটি পরিষ্কার জায়গা দেয়। অন্যদিকে, একটি লম্বা চুক্তি পুরোটা পড়ে উত্তর দিতে গেলে retrieval-এর টুকরো টুকরো খোঁজ ভুল করতে পারে; সেখানে লম্বা context সত্যিই কাজে লাগে। সম্প্রদায়ে এই নিয়ে মতভেদ আছে, আর উত্তর কাজ অনুযায়ী বদলায়। একটি বাস্তবসম্মত পথ হলো দুটি মেলানো: retrieval দিয়ে প্রাসঙ্গিক অংশ বাছাই, তারপর লম্বা context-এ সেগুলো পাঠানো।
মূল উপলব্ধি: লম্বা context সামর্থ্য বাড়ায়, নকশার দায় সরায় না। প্রতিটি কাজে খরচ, মান আর নিয়ন্ত্রণ মেপে সিদ্ধান্ত নিতে হয়।
অনুশীলন
প্রথমে খাতায় করুন, তারপর উত্তর মেলান।
-
পূর্ণ numerical: একটি decoder: L = 12, h = 12, d = 768, float16. (ক) ২,০৪৮ token-এ এক অনুরোধের KV cache কত MiB? (খ) ৪টি অনুরোধের batch-এ? (গ) K, V-এর head ১২ থেকে ৪ করলে (ক)-এর উত্তর কত? (ঘ) Score-এর গুণ, এক স্তর: t = ২,০০০-এ নতুন একটি token লিখতে cache ছাড়া আর cache-সহ, আর অনুপাত।
(ক) ২ × ১২ × ৭৬৮ × ২,০৪৮ × ২ = ৭,৫৪,৯৭,৪৭২ byte = ৭২ MiB. (খ) × ৪ = ২৮৮ MiB. (গ) এক ভাগে d/h = ৬৪; নতুন আকার ৪ × ৬৪ = ২৫৬, তিন ভাগের এক: ২৪ MiB. (ঘ) Cache ছাড়া t²·d = ৪০,০০,০০০ × ৭৬৮ = ৩,০৭,২০,০০,০০০; cache-সহ t·d = ২,০০০ × ৭৬৮ = ১৫,৩৬,০০০; অনুপাত ২,০০০, অর্থাৎ t-এর সমান।
-
নকশা ও সমালোচনা: একজন সহকর্মী বলছেন: “আমাদের generate লুপে cache চালু, batch-এ ডানে pad, প্রতিবার position
arange(off, off + 1). একটি prompt-এ চালিয়ে দেখেছি, ঠিক আছে। সার্ভারে ৩২-এর batch দিয়ে চালাই। Memory সমস্যা হলে cache-এর পুরনো অংশ কেটে দেব, ফল তো একই থাকবে।” কী কী ভুল, আর কীভাবে যাচাই করবেন?(১) ডানে pad:
logits[:, -1]ছোট prompt-এ pad-এর ঘর পড়ে। বদল: left padding. (২) Position: সব সারিতে এক off; left padding-এ pad-সহ গোনা হলে ভুল। Mask থেকে cumsum দিন। (৩) Mask: Cache-এর ধাপেও সব পুরনো pad আড়াল করতে হবে; shape (B, 1, 1, t). (৪) একটি prompt-এ পরীক্ষা অর্থহীন, সেখানে padding-ই নেই। অনেক দৈর্ঘ্যের prompt-এ batched বনাম একা মেলান; আমাদের run-এ ভুল পথে মাত্র ১৬/৯৬ মিলেছে। (৫) “ফল একই থাকবে” ভুল: অংশ ৮-এর run-এ ছাঁটা cache-এর logit ৩৩ পর্যন্ত আলাদা। শুরুর sink রাখা, আর মান যাচাই করা লাগে। (৬) ৩২ জনের memory আগে হিসাব করুন: 2·L·32·T·d·bytes. -
আউটপুট অনুমান করুন: নিচের কোড কী ছাপবে?
import torch am = torch.tensor([[0, 0, 1, 1, 1], [1, 1, 1, 1, 1], [0, 0, 0, 0, 1]]).bool() print((am.long().cumsum(1) - 1).clamp(min=0)) print(am[:, None, None, :].shape) k = torch.zeros(2, 4, 10, 32) print(torch.cat([k, torch.zeros(2, 4, 1, 32)], dim=2).shape)সত্যিকারের run-এর আউটপুট:
tensor([[0, 0, 0, 1, 2], [0, 1, 2, 3, 4], [0, 0, 0, 0, 0]]) torch.Size([3, 1, 1, 5]) torch.Size([2, 4, 11, 32])প্রথম লাইন হলো mask থেকে position: প্রতি সারিতে আসল token গোনা, pad-এর ঘরে শূন্য। প্রথম আসল token সবসময় 0, যেমন একা চললে। দ্বিতীয়: batch, head, query-র অক্ষে ১, আর key-র অক্ষে ৫, cache-এর ধাপের pad mask. তৃতীয়:
dim=2হলো সময়ের অক্ষ, তাই ১০ থেকে ১১: নতুন token-এর একটি k জুড়ল। -
কোড লিখুন:
trim_cache(cache, W, keep_first=0)ফাংশন লিখুন।cacheহলো (K, V) জোড়ার তালিকা, প্রতিটি (B, h, t, d/h). শুরুরkeep_first-টি আর শেষের W-টি রাখবে; t ছোট হলে কিছু কাটবে না। ০–৯ সংখ্যার K-এW = 3, keep_first = 2দিয়ে পরীক্ষা করুন।import torch def trim_cache(cache, W, keep_first=0): out = [] for k, v in cache: t = k.shape[2] if t <= keep_first + W: out.append((k, v)) continue idx = torch.cat([torch.arange(keep_first), torch.arange(t - W, t)]) out.append((k[:, :, idx], v[:, :, idx])) return out c = [(torch.arange(10.).reshape(1, 1, 10, 1), torch.arange(10.).reshape(1, 1, 10, 1) * 2)] t = trim_cache(c, 3, keep_first=2) print(t[0][0].flatten().tolist(), t[0][1].shape) print(trim_cache(c, 20)[0][0].shape)সত্যিকারের run-এর আউটপুট:
[0.0, 1.0, 7.0, 8.0, 9.0] torch.Size([1, 1, 5, 1]) torch.Size([1, 1, 10, 1])K আর V দুটিতে একই সূচি বসাতে ভুলবেন না, আর GPU-তে
idx-কে একই device-এ নিন। মনে রাখুন, এর পরে mask-এর কলাম সংখ্যা আর learned position-এর হিসাবও মেলাতে হবে; RoPE-তে মূল token-এর অবস্থান ধরে রাখতে হয়। আর এই ছাঁটা ফল আবার চালানোর সমান নয়।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমKV cache-সহ batched generation-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- সব prompt বাঁয়ে pad করা, আর attention mask বানানো
- Mask থেকে position হিসাব করা
- পুরো prompt নিয়ে প্রথম pass; প্রতি স্তরে K, V জমা রাখা
- শেষ কলামের logits থেকে প্রতি সারির পরের token বাছা
- শুধু সেই token, তার position আর বাড়ানো mask নিয়ে পরের pass; K, V-তে একটি ঘর জোড়া
- সব সারি শেষ হলে pad আর শেষের চিহ্ন বাদ দিয়ে লেখা ফেরত দেওয়া
একটি causal Transformer decoder-এ KV cache ব্যবহার করলেও আগের token-এর K আর V নতুন করে হিসাব না করে একই ফল পাওয়া যায় কেন?
- কারণ softmax-কে আনুমানিক করে নেওয়া হয়
- কারণ causal mask-এর জন্য পুরনো token পরের token-এর ওপর নির্ভর করে না
- কারণ শুধু generation-এর সময় weight স্থির থাকে
- কারণ K আর V দুটিকেই ফেলে শুধু Q রাখা হয়
L = 6, h = 8, d = 512, T = 1024, B = 1, float16-এর একটি decoder-এর KV cache-এর memory কত?
- ৬ MiB
- ৯৬ MiB
- ১২ MiB
- ২৪ MiB
দৈর্ঘ্যের পার্থক্যসহ একটি decoder-only batch-এ ডানে pad করে logits[:, -1] থেকে পরের token বাছলে কী হয়?
- ছোট prompt-এর ক্ষেত্রে pad-এর ঘর থেকে পড়া হয়, আর প্রায়ই কোনো error ছাড়াই ফল ভুল হয়
- সবসময় shape-এর error দিয়ে program থেমে যায়
- কোনো প্রভাব নেই, কারণ mask সব সমাধান করে
- শুধু memory দ্বিগুণ হয়, ফল একই থাকে
FlashAttention সম্পর্কে কোন কথাটি সঠিক?
- এটি প্রতিটি token-কে শুধু শেষ W-টি token দেখায়
- এটি random feature দিয়ে softmax-এর আসন্ন মান বের করে
- এটি K আর V-কে দৈর্ঘ্যের অক্ষে ছোট মাত্রায় নামায়
- এটি একই attention-এর exact ফল দেয়, আর tile ও online softmax দিয়ে memory-র ব্যবহার কমায়
T-টি token cache ছাড়া generate করতে attention score-এর মোট গুণ T-এর কোন হারে বাড়ে, আর KV cache-সহ?
- ছাড়া T², সহ T
- ছাড়া T³, সহ T²
- ছাড়া T³, সহ T
- দুটিই T²
৪ জানালার sliding window attention-এ (প্রতিটি token নিজেসহ শেষ ৪টি দেখে) প্রথম token (t = ০)-এর তথ্য ১১ ঘর দূরের অবস্থান t = ১১-এ পৌঁছাতে কমপক্ষে কয়টি স্তর লাগে?
- ১টি
- ৩টি
- ৪টি
- ১১টি
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- KV cache: পুরনো K, V রাখা, শুধু নতুন সারি হিসাব। ফল এক, মোট কাজ T³ থেকে T². প্রথম pass-এ লাভ নেই।
- Memory: 2·L·B·T·d·bytes. h কাটা পড়ে; GQA K, V-এর head কমায়।
- Batched generation: বাঁয়ে pad, mask থেকে position, আর pad-কে mask. অনেক prompt-এ একা চালানোর সঙ্গে মেলান।
- FlashAttention: Exact; tile আর online softmax. SDPA-তে নিজে kernel বাছে; বুলিয়ান mask দিলে Flash না-ও চলতে পারে।
- Long context: Sliding window, sparse, Linformer, Performer ফল বদলায়। সময়, memory, position আর ব্যবহারের মান, চারটিই সীমা।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩৮ · Vision Transformer — ছবিকে patch-এর sequence হিসেবে দেখাপরবর্তী পর্বছবিকে patch-এ কেটে Transformer চালানো, আর একই আকারের CNN-এর সঙ্গে সৎ তুলনা দেখা।
- ডিপ লার্নিং, NLP আর Generative AI · কোর্স হোম৯১টি পর্ব১৪টি মডিউলের পুরো রোডম্যাপ; পরের পর্ব ৩৮ (Vision Transformer) এখান থেকে খুলবেন।
- পর্ব ১২ · GPU-তে গতিআগের পর্বLaunch-এর খরচ, synchronize, float16 আর memory মাপা।
- পর্ব ১১ · Data pipelineআগের পর্বPadding, attention mask আর batch বানানো।
- পর্ব ৩০ · Seq2seqআগের পর্বGreedy আর beam decoding-এর প্রথম দেখা।
- AI-এর ভিত্তি · পর্ব ৭০ · LLM কীভাবে কাজ করেআগের ভিত্তিToken, পরের শব্দের অনুমান আর decoding.
- কম্পিউটার আর্কিটেকচার · পর্ব ৪৪ · মেমরি হায়ারার্কিসংশ্লিষ্টLocality আর cache: FlashAttention-এর পেছনের ধারণা।
- মেশিন লার্নিং · পর্ব ৬৮ · Model servingআগের ভিত্তিBatch আর latency নিয়ে serving-এর প্রথম ধারণা।