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

LLM serving — batching, KV cache, throughput আর খরচ

LLM Serving: Batching, KV Cache, Throughput and Cost

Taught in Bangla, this lesson measures LLM serving with TTFT, TPOT, end-to-end latency, throughput, goodput and percentiles. It shows with real batch timings on the GPU why prefill is compute-bound and decode memory-bound, computes KV cache memory from a real config.json, compares static, dynamic and continuous batching, explains PagedAttention and prefix caching, and load-tests a quantized llama.cpp server checked with Little's law, costing in machine-seconds, with in-page Python.

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

  • TTFT, TPOT, E2E, throughput, goodput আর p50/p95 দিয়ে serving মাপা
  • Prefill কেন compute-bound আর decode কেন memory-bound, সত্যিকারের batch মাপসহ
  • আসল config.json থেকে KV cache-এর memory হাতে হিসাব, আর কয়টি অনুরোধ একসঙ্গে ধরবে
  • Static, dynamic আর continuous batching; PagedAttention আর prefix caching-এর ধারণা
  • llama.cpp server-এ seed-সহ load test, Little-এর সূত্রে যাচাই, আর খরচ GPU-সেকেন্ডে

এই পর্ব মডিউল ১৩-এর (production আর ক্যাপস্টোন) প্রথম ধাপ। আগে দরকার: KV cache-এর ধারণা (পর্ব ৩৭), TTFT আর prefill (পর্ব ৬৫), আর quantization (পর্ব ৫৯)। পাতার তিনটি ব্লক ব্রাউজারের Python আর NumPy-তে চলে; প্রথমবার প্যাকেজ নামতে কয়েক সেকেন্ড লাগে। transformers আর llama.cpp-এর সব আউটপুট ডেস্কটপের সত্যিকারের run থেকে নেওয়া, একটি যন্ত্রে, এক run-এর; এগুলো সাধারণ নিয়ম নয়।

১ · একজন থেকে অনেকজন — serving-এর প্রশ্ন আর মাপকাঠি

ইফতারের আগে একটি জনপ্রিয় রেস্টুরেন্টের রান্নাঘর ভাবুন। একজন অতিথির কাছে দুটি প্রশ্নই আসল: প্রথম পদটি কখন এলো, আর তারপর বাকি পদ কত তাড়াতাড়ি আসছে। মালিকের প্রশ্ন আলাদা: এক ঘণ্টায় কত প্লেট বের হলো, আর চুলা কতক্ষণ খালি পড়ে ছিল। দুই দলের চাওয়া প্রায়ই টানাটানি করে। LLM serving ঠিক এই রান্নাঘর।

এ পর্যন্ত আমরা model চালিয়েছি একজনের জন্য, একটি prompt করে। LLM serving মানে একই model দিয়ে একসঙ্গে অনেক মানুষের অনুরোধের উত্তর দেওয়া। একটি অনুরোধের দুই ধাপ আপনি পর্ব ৬৫-এ দেখেছেন: prefill, যেখানে পুরো prompt একবারে চলে, আর decode, যেখানে এক এক করে token লেখা হয়। এই পর্বের প্রশ্ন: অনেক অনুরোধ একসঙ্গে এলে GPU বা CPU-কে কীভাবে ভাগ করবেন, যাতে সবাই দ্রুত উত্তর পায় আর যন্ত্রও অলস না থাকে?

আগে মাপকাঠিগুলো ঠিক করে নিই। এগুলো ছাড়া “দ্রুত” কথাটির কোনো অর্থ নেই।

মাপকী মাপেকার কাছে জরুরি
TTFTঅনুরোধ পাঠানো থেকে প্রথম token: সারিতে অপেক্ষা + prefillব্যবহারকারী: খালি পর্দা কতক্ষণ
TPOTপ্রথম token-এর পর প্রতি token-এ গড় সময়ব্যবহারকারী: লেখা কত দ্রুত আসে
ITLপরপর দুটি token-এর মাঝের সময়, প্রতিটি আলাদা করেমাঝে মাঝে থেমে যাওয়া ধরতে
E2Eপাঠানো থেকে শেষ token পর্যন্তপুরো উত্তর লাগে এমন কাজ
Throughputসব অনুরোধ মিলিয়ে প্রতি সেকেন্ডে token বা অনুরোধমালিক: যন্ত্রের খরচ
Goodputসময়সীমা মেনে শেষ হওয়া অনুরোধ প্রতি সেকেন্ডেদুই পক্ষই

এদের মধ্যে একটি সরল সম্পর্ক আছে। N-টি output token-এর একটি অনুরোধে:

E2E = TTFT + (N − 1) × TPOT

আর গড় নয়, percentile দেখুন। p50 মানে অর্ধেক অনুরোধ এর চেয়ে দ্রুত; p95 মানে ১০০ জনের ৯৫ জন এর মধ্যে পেয়েছেন। বাকি ৫ জনের ধীর অভিজ্ঞতাই tail latency. Percentile আর tail-এর ভিত্তি আছে সিস্টেম ডিজাইন কোর্সের পর্ব ৪-এ; model serving-এর সাধারণ কাঠামো মেশিন লার্নিং কোর্সের পর্ব ৬৮-এ। LLM-এ নতুন হলো: উত্তরের দৈর্ঘ্য আগে জানা নেই, আর প্রতিটি অনুরোধ চলার পুরো সময় memory দখল করে রাখে।

২ · Prefill বনাম decode — compute-bound আর memory-bound

কেন batching এত জরুরি, তা বুঝতে একটি decode ধাপের ভেতরে তাকান। নতুন একটি token বানাতে model-এর সব weight memory থেকে একবার আনতে হয়। ০.৫B parameter-এর model bf16-এ প্রায় ১ GB. অথচ এক token-এর জন্য গুণ হয় প্রতি parameter-এ মোটামুটি দুটি (একটি গুণ, একটি যোগ)। আনা প্রতি byte-এ গণনা প্রায় এক: arithmetic intensity খুব কম। GPU-র গুণের ক্ষমতা পড়ে থাকে, অপেক্ষা চলে memory-র জন্য। এ কারণে decode memory-bound (পর্ব ১২)।

এখন batch-এ ১৬টি অনুরোধ নিন। Weight একবারই আনা হয়, কিন্তু সেই weight দিয়ে ১৬টি token-এর গুণ হয়। আনা byte প্রায় একই, কাজ ১৬ গুণ। তাই ধাপের সময় সামান্য বাড়ে, কিন্তু প্রতি ধাপে token ১৬টি। Prefill-এ এই কাজ আপনা-আপনি হয়: ৫০০ token-এর prompt মানে এক pass-এ ৫০০ token-এর গুণ, তাই prefill সাধারণত compute-bound.

ঢাকা থেকে রাজশাহী একটি বাস যাবেই; তেল আর চালকের খরচ প্রায় একই, যাত্রী একজন হোক বা চল্লিশজন। একজনকে নিয়ে গেলে প্রতি যাত্রীর খরচ আকাশছোঁয়া। Decode-এ weight পড়া সেই বাসের খরচ, আর batch-এর অনুরোধগুলো যাত্রী। কিন্তু যাত্রী বেশি হলে বাস ছাড়তে দেরি হতে পারে, আর ভেতরে জায়গাও সীমিত: সেই জায়গা হলো KV cache, অংশ ৩-এর বিষয়।

সত্যিকারের মাপ: Qwen2.5-0.5B-Instruct (Apache 2.0, model card দেখে নিশ্চিত), transformers-এর generate(), greedy, প্রতিটি অনুরোধে ঠিক ৬৪টি নতুন token, বাজারঘরের ১৬টি বাংলা প্রশ্ন, বাঁয়ে padding. প্রতিটি ধাপের সময় একটি ছোট LogitsProcessor দিয়ে লেখা (ধাপের শেষে synchronize-সহ)। প্রতিটি batch-এ একবার warm-up, তারপর তিনবারের median.

~/bazarghar-dl$ uv run python hf_batch.py cuda    # সত্যিকারের run, GPU-তে
torch 2.15.0+cu132 | transformers 5.22.2 | device cuda
…
layers=24 kv_heads=2 head_dim=64 dtype=torch.bfloat16 bytes=2
weights: 494,032,768 params, 942 MiB
B= 1 prompt_len(padded)=111 real_prompt_tokens=111/111 TTFT=   18.3ms TPOT= 17.02ms total=  1.10s agg=   58.0 tok/s per_req=  58.8 tok/s peak=  964.5MiB
      KV cache measured=2,138,112 B formula 2*L*kvh*hd*T*B*bytes=2,138,112 B (T=174)
      first answer: এই পদ্ধতি আমি মনে হয় যে, একটি বাইরে ডেলিভারি চার্জ (স্ট্রোড) ক
B= 4 prompt_len(padded)=115 real_prompt_tokens=441/460 TTFT=   28.7ms TPOT= 20.89ms total=  1.38s agg=  186.1 tok/s per_req=  47.9 tok/s peak=  983.9MiB
      KV cache measured=8,749,056 B formula 2*L*kvh*hd*T*B*bytes=8,749,056 B (T=178)
B=16 prompt_len(padded)=117 real_prompt_tokens=1733/1872 TTFT=   90.8ms TPOT= 21.18ms total=  1.44s agg=  709.7 tok/s per_req=  47.2 tok/s peak= 1072.2MiB
      KV cache measured=35,389,440 B formula 2*L*kvh*hd*T*B*bytes=35,389,440 B (T=180)
--- prefill time vs prompt length (B=1, median of 3) ---
T=  128 prefill=    17.2 ms      7445 prompt tok/s
T=  512 prefill=    34.1 ms     15017 prompt tok/s
T= 1024 prefill=    62.4 ms     16400 prompt tok/s
T= 2048 prefill=   120.5 ms     16999 prompt tok/s
BTTFTTPOTমোট token/sএকজনের token/sসর্বোচ্চ memory
১১৮.৩ ms১৭.০২ ms৫৮.০৫৮.৮৯৬৪.৫ MiB
৪২৮.৭ ms২০.৮৯ ms১৮৬.১৪৭.৯৯৮৩.৯ MiB
১৬৯০.৮ ms২১.১৮ ms৭০৯.৭৪৭.২১,০৭২.২ MiB

পড়ুন, আর সৎভাবে পড়ুন:

  • Batch-এর জাদু: B = ১ থেকে ১৬-এ প্রতি ধাপ মাত্র ২৪% লম্বা (১৭.০২ থেকে ২১.১৮ ms), কিন্তু মোট throughput ১২.২ গুণ (৫৮ থেকে ৭১০ token/s). একজন ব্যবহারকারীর গতি কমেছে সামান্য, ৫৮.৮ থেকে ৪৭.২ token/s.
  • Prefill কত সস্তা: ২,০৪৮ token-এর prompt ১২০.৫ ms-এ, প্রায় ১৭,০০০ token/s. Decode-এ B = ১-এ ৫৮.৮ token/s. প্রতি token-এ prefill প্রায় ২৯০ গুণ দ্রুত, কারণ সব token একসঙ্গে গুণ হয়।
  • সতর্কতা: ১২৮ token-এর prefill (১৭.২ ms) আর একটি decode ধাপ (১৭.০ ms) প্রায় সমান। এত ছোট model-এ GPU-তে সময়ের বড় অংশ kernel পাঠানোর স্থির খরচ (পর্ব ১২ আর ৩৭-এ দেখা), শুধু memory-র গতি নয়। বড় model-এ memory-র ভাগ বাড়ে; দিক একই থাকে, অনুপাত বদলায়। GPU তখন অন্য কাজেও ব্যবহৃত হচ্ছিল।
  • গতি মান নয়: B = ১-এর উত্তরের শুরু “এই পদ্ধতি আমি মনে হয় যে…”: ০.৫B model-এর বাংলা দুর্বল। Serving দ্রুত করে, উত্তর ভালো করে না; মান মাপার কাজ পর্ব ৫৮-এর।

৩ · KV cache-এর হিসাব — প্রতি token, প্রতি অনুরোধ

Batch বাড়ালে throughput বাড়ে, তাহলে batch ১,০০০ কেন নয়? কারণ প্রতিটি অনুরোধ চলার পুরো সময় নিজের KV cache ধরে রাখে। পর্ব ৩৭-এ সূত্রটি দেখেছেন; এখন সেটি আসল model-এর config.json দিয়ে, GQA-সহ:

KV byte = 2 × L × Hkv × dh × bytes × T × B

এখানে 2 মানে K আর V, L স্তর, Hkv KV head-এর সংখ্যা (GQA-তে query head-এর চেয়ে কম), dh এক head-এর মাত্রা, bytes এক সংখ্যার মাপ (bf16-এ ২), T token সংখ্যা (prompt আর উত্তর মিলিয়ে), B একসঙ্গে চলা অনুরোধ। এবার Qwen2.5-1.5B-Instruct-এর আসল config থেকে, হাতে:

config.json থেকে গুণ করে এগোনো
এই ধাপ
ফল
মানে
  1. Config পড়া. আসল config.json-এ num_hidden_layers ২৮, num_attention_heads ১২, num_key_value_heads ২, hidden_size ১,৫৩৬. head_dim = ১,৫৩৬ ÷ ১২ = ১২৮.
  2. এক স্তরের K. Query head ১২টি হলেও K রাখা হয় শুধু ২টি KV head-এর জন্য: ২ × ১২৮ = ২৫৬টি সংখ্যা, এক token-এ।
  3. K আর V. দুটিই রাখতে হয়, তাই ২৫৬ × ২ = ৫১২টি সংখ্যা।
  4. সব স্তর. প্রতিটি স্তরের নিজের K, V: ৫১২ × ২৮ = ১৪,৩৩৬টি সংখ্যা প্রতি token-এ।
  5. Byte. bf16-এ প্রতিটি ২ byte: ২৮,৬৭২ byte, অর্থাৎ ঠিক ২৮ KiB. এটিই প্রতিটি নতুন token-এর memory-র দাম।
  6. এক অনুরোধ. ২,০৪৮ token (prompt আর উত্তর মিলিয়ে): ২৮ KiB × ২,০৪৮ = ৫৬ MiB.
  7. ১৬টি অনুরোধ. ৫৬ × ১৬ = ৮৯৬ MiB. সূত্রে B আর T সরলরেখায় বাড়ে, তাই দৈর্ঘ্য বা অনুরোধ দ্বিগুণ হলে memory-ও দ্বিগুণ।
  8. GQA না থাকলে. ১২টি KV head হলে সব ৬ গুণ: এক অনুরোধে ৩৩৬ MiB, ১৬ জনে ৫,৩৭৬ MiB, যা bf16-এ model-এর নিজের weight-এর (১৫৪ কোটি parameter × ২ byte ≈ ২.৯ GiB) চেয়েও বেশি। GQA এখানে ছয় গুণ বেশি অনুরোধ ধরার সুযোগ দেয়।

হাতের হিসাব আর বাস্তব মেলে কি? ০.৫B model-এ batch ১৬-এর run শেষে transformers-এর cache-এর প্রতিটি tensor-এর byte যোগ করা হয়েছে: B = ১৬, T = ১৮০ (pad-সহ prompt ১১৭ + ৬৪ − ১; শেষ token-এর K, V তখনো লেখা হয়নি): মাপা ৩,৫৩,৮৯,৪৪০ byte, আর সূত্রে 2 × 24 × 2 × 64 × 2 × 180 × 16 = ৩,৫৩,৮৯,৪৪০ byte. হুবহু এক। B = ১ আর ৪-এও তাই (২১,৩৮,১১২ আর ৮৭,৪৯,০৫৬ byte). লক্ষ করুন, T-তে pad-এর ঘরও আছে: cache pad-এর জন্যও জায়গা রাখে।

নিচের ব্লক একটি ছোট calculator. ৮ GiB memory-র একটি কাল্পনিক যন্ত্রে weight রাখার পর (১০% অন্য কাজের জন্য ছেড়ে) কয়টি অনুরোধ একসঙ্গে ধরে, prompt-এর দৈর্ঘ্য আর KV-এর dtype বদলে দেখুন। Parameter সংখ্যাও config থেকে হিসাব করা।

import numpy as np

# config.json থেকে পড়া আসল মান (Qwen2.5 Instruct, ডেস্কটপে পড়া)
MODELS = {"Qwen2.5-0.5B": dict(L=24, q_heads=14, kv_heads=2, head_dim=64, params=494_032_768),
          "Qwen2.5-1.5B": dict(L=28, q_heads=12, kv_heads=2, head_dim=128, params=1_543_714_304)}
BYTES = {"bf16": 2, "int8": 1}

def kv_per_token(m, kv_heads=None, dtype="bf16"):
    return 2 * m["L"] * (kv_heads or m["kv_heads"]) * m["head_dim"] * BYTES[dtype]

MiB, GiB = 2**20, 2**30
for name, m in MODELS.items():
    per = kv_per_token(m)
    print(f"{name}: {per:,} B/token | 2,048 tok: {per*2048/MiB:.0f} MiB"
          f" | x16: {per*2048*16/MiB:,.0f} MiB | no GQA x{m['q_heads']//m['kv_heads']}:"
          f" {kv_per_token(m, m['q_heads'])*2048/MiB:.0f} MiB")

# ৮ GiB memory-র যন্ত্রে কয়টি অনুরোধ একসঙ্গে ধরবে? weight bf16-এ, ১০% অন্য কাজে
budget = 8 * GiB
for name, m in MODELS.items():
    free = budget * 0.9 - m["params"] * 2
    for T in (512, 2048, 8192):
        n = [int(free // (kv_per_token(m, dtype=dt) * T)) for dt in ("bf16", "int8")]
        print(f"{name} T={T:5d}: KV bf16 {n[0]:5d} | KV int8 {n[1]:5d} requests")

# ডেস্কটপ run-এ মাপা byte বনাম সূত্র (0.5B, B = 16, T = 180)
measured = 35_389_440
print("formula:", kv_per_token(MODELS["Qwen2.5-0.5B"]) * 180 * 16, "| measured:", measured)

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

Qwen2.5-0.5B: 12,288 B/token | 2,048 tok: 24 MiB | x16: 384 MiB | no GQA x7: 168 MiB
Qwen2.5-1.5B: 28,672 B/token | 2,048 tok: 56 MiB | x16: 896 MiB | no GQA x6: 336 MiB
Qwen2.5-0.5B T=  512: KV bf16  1071 | KV int8  2143 requests
Qwen2.5-0.5B T= 2048: KV bf16   267 | KV int8   535 requests
Qwen2.5-0.5B T= 8192: KV bf16    66 | KV int8   133 requests
Qwen2.5-1.5B T=  512: KV bf16   316 | KV int8   632 requests
Qwen2.5-1.5B T= 2048: KV bf16    79 | KV int8   158 requests
Qwen2.5-1.5B T= 8192: KV bf16    19 | KV int8    39 requests
formula: 35389440 | measured: 35389440

প্রথম দুই লাইন stepper-এর সংখ্যা মেলায়: ১.৫B-এ ২৮,৬৭২ byte, ৫৬ MiB, ৮৯৬ MiB. ০.৫B-এ ১৪টি query head আর ২টি KV head, তাই GQA-র সাশ্রয় সাত গুণ। এরপর ধারণক্ষমতা: ৮ GiB-এর যন্ত্রে ১.৫B model-এ ২,০৪৮ token-এর ৭৯টি অনুরোধ, ৮,১৯২ token-এ মাত্র ১৯টি। KV-কে int8-এ রাখলে সংখ্যা দ্বিগুণ, তবে মানের দাম যাচাই করতে হয় (§৮)। এগুলো ঊর্ধ্বসীমা: activation আর engine-এর নিজের memory ধরা হয়নি। শেষ লাইনে সূত্র আর ডেস্কটপে মাপা byte এক।

৪ · একটি অনুরোধের জীবন — সারি, prefill, decode

Server-এ একটি অনুরোধ তিনটি জায়গায় সময় কাটায়: সারিতে, prefill-এ, আর decode-এ। নিচের সংখ্যাগুলো বানানো নয়। §৭-এর সত্যিকারের load test থেকে একটি অনুরোধ (নম্বর ১৬) নেওয়া হয়েছে: llama.cpp-এর server, ৪টি slot, CPU-তে ৩ thread, প্রতি সেকেন্ডে গড়ে ১টি অনুরোধ। সময় load test শুরুর ঘড়িতে। Client মেপেছে আগমন, প্রথম দৃশ্যমান লেখা আর শেষ; server নিজে জানিয়েছে prompt-এর token আর prefill-এর সময় (timings).

Client আর server-এর মাপ মিলিয়ে
ঘড়ি
অবস্থা
এই অংশের সময়
  1. আগমন. ১৬.৯৩ সেকেন্ডে অনুরোধটি এলো। চারটি slot-ই তখন ব্যস্ত: অনুরোধ ১২, ১৩, ১৪ আর ১৫ উত্তর লিখছে।
  2. সারি. প্রথম slot খালি হলো ১৯.১২ সেকেন্ডে, যখন অনুরোধ ১৪ শেষ হলো (১৫.২২ + ৩.৯০)। এতক্ষণ অনুরোধ ১৬ কিছুই পায়নি: ২.১৯ সেকেন্ড শুধু অপেক্ষা।
  3. Prefill. System prompt আর chat template-সহ ১০৫টি prompt token. Server-এর হিসাবে ৩৮৩ ms. প্রথম দৃশ্যমান লেখা এলো আগমনের ২.৫৭ সেকেন্ড পরে: TTFT-এর ৮৫% ছিল সারি, prefill নয়।
  4. Decode. বাকি ৬৩টি token, প্রতিটিতে গড়ে ৪৪.৮ ms. একা চললে এই server-এ প্রতি token প্রায় ১৩ ms লাগে; এখানে প্রতিটি ধাপ আরও তিনজনের সঙ্গে ভাগ করা, তাই ধীর।
  5. শেষ. ৬৪তম token-এর পর অনুরোধ বের হলো, slot আর KV cache মুক্ত। E2E ৫.৩৯ সেকেন্ড।
  6. যোগফল. ২.১৯ + ০.৩৮ + ২.৮২ = ৫.৩৯; সূত্রে TTFT + (N − 1) × TPOT = ২.৫৭ + ৬৩ × ০.০৪৪৮ ≈ ৫.৩৯. সময়ের ৪১% সারিতে, ৭% prefill-এ, ৫২% decode-এ। দ্রুত করতে কোথায় হাত দেবেন, ভাঙন দেখেই ঠিক হয়।

এই ভাঙন একটি গুরুত্বপূর্ণ কথা বলে: ব্যস্ত সময়ে TTFT-এর বড় অংশ prefill নয়, সারি। পর্ব ৬৫-এর prefix cache prefill কমায়, কিন্তু সারি কমায় না। সারি কমাতে লাগে বেশি slot, দ্রুত decode, বা বেশি যন্ত্র। Streaming-এ ব্যবহারকারী প্রথম লেখা দেখেন TTFT-এ, তাই chat-এ TTFT এত গুরুত্বপূর্ণ।

৫ · Static, dynamic আর continuous batching

ফুচকাওয়ালার কথা ভাবুন। তিনি একজনের পুরো প্লেট শেষ করে পরের জনকে ধরেন না। ঘিরে দাঁড়ানো সবার প্লেটে একটি করে ফুচকা দেন, তারপর আবার ঘুরে আসেন। যাঁর প্লেট শেষ, তিনি সরে যান; নতুন কেউ এলে ফাঁকা জায়গায় দাঁড়িয়ে পরের রাউন্ড থেকেই পান। কাউকে পুরো দলের শেষ হওয়া পর্যন্ত অপেক্ষা করতে হয় না। Continuous batching এই ফুচকাওয়ালা।

অংশ ২ বলেছে batch বড় হলে decode-এর ধাপ প্রায় একই সময়ে বেশি token দেয়। কিন্তু LLM-এ batch বানানো ঝামেলার, কারণ কার উত্তর কত লম্বা, তা আগে জানা নেই। তিনটি কৌশল:

  • Static batching: কয়েকটি অনুরোধ একসঙ্গে শুরু, আর সবচেয়ে লম্বাটি শেষ না হওয়া পর্যন্ত batch আটকে থাকে। পর্ব ৩৭-এর batched generate() এমনই।
  • Dynamic batching: সারি থেকে একটি সর্বোচ্চ মাপ বা অল্প সময়ের সীমা পর্যন্ত জমিয়ে batch বানানো। Batch শুরু হলে তবু একসঙ্গে শেষ হয়।
  • Continuous batching: প্রতিটি decode ধাপের পর batch নতুন করে সাজানো। শেষ হওয়া অনুরোধ সঙ্গে সঙ্গে বের হয়, নতুনটি খালি জায়গায় ঢোকে।

শেষ ধারণাটির সূত্র Yu ও সহকর্মীদের Orca প্রবন্ধ (OSDI ২০২২), যেখানে একে iteration-level scheduling বলা হয়েছে। নিচে তিনটি slot, পাঁচটি অনুরোধ। A, B, C শুরুতে আসে; D আসে ধাপ ১-এ, E ধাপ ২-এ। প্রয়োজনীয় token: A ৪, B ২, C ৩, D ২, E ১. “✓” মানে সেই অনুরোধের শেষ token, “··” মানে খালি slot.

প্রতি ধাপে কোন slot-এ কে
Static
Continuous
সারিতে
মোট token
  1. ধাপ ১. দুই পদ্ধতিতেই A, B, C একসঙ্গে শুরু। এর মধ্যে D এসে সারিতে দাঁড়াল। দুই দিকেই ৩টি token.
  2. ধাপ ২. B-এর দুটি token শেষ (✓)। E-ও এসে সারিতে। এখনো দুই পদ্ধতি একই: মোট ৬টি token.
  3. ধাপ ৩: পার্থক্য শুরু. Static-এ B-এর slot খালি থাকে, কারণ batch এখনো A-র জন্য আটকে। Continuous-এ D সেই খালি slot-এ ঢুকে প্রথম token পেল। C শেষ। Static ৮, continuous ৯ token.
  4. ধাপ ৪. Static-এ শুধু A চলছে, দুটি slot অলস। Continuous-এ C-এর জায়গায় E ঢুকল; A, D, E তিনজনই এই ধাপে শেষ। ৪ ধাপে ১২টি token, কোনো slot একবারও খালি থাকেনি।
  5. ধাপ ৫. Static-এ এতক্ষণে নতুন batch: D আর E. D অপেক্ষা করেছে ধাপ ১ থেকে ধাপ ৫ পর্যন্ত, continuous-এ মাত্র ধাপ ৩ পর্যন্ত।
  6. ধাপ ৬. Static শেষ করল ৬ ধাপে। ১৮টি slot-ধাপের ৬টি খালি গেছে, অর্থাৎ এক-তৃতীয়াংশ। একই কাজ, দেড় গুণ সময়।
Static vs continuous batching on three slots Static · ৬ ধাপ A1 A2 A3 A4 D1 D2 B1 B2 E1 C1 C2 C3 Continuous · ৪ ধাপ A1 A2 A3 A4 B1 B2 D1 D2 C1 C2 C3 E1 slot ১slot ২slot ৩slot ১slot ২slot ৩ প্রতিটি ঘর = এক decode ধাপ · ধূসর = অলস slot
একই পাঁচটি অনুরোধ। Static-এ লম্বা A পুরো batch আটকে রাখে; continuous-এ খালি slot পরের ধাপেই ভরে যায়।

এবার বড় পরিসরে। নিচের simulator একই seed-এ ৪০০টি অনুরোধ বানায় আর তিন পদ্ধতিতে চালায়, ৪টি slot-এ। ধাপের সময় কাল্পনিক নয়: §৭-এর llama.cpp-এর মাপ থেকে একটি সরলরেখা টেনে নেওয়া (decode ধাপ ≈ ৭.২৯ + ৪.৮৬ × b ms, prefill প্রতি token ৩.৪ ms). Static এখানে চার জন জমা হওয়া বা প্রথম জনের ০.৫ সেকেন্ড অপেক্ষা পর্যন্ত দাঁড়ায়; dynamic সারিতে যে কজন আছে, তাদের নিয়েই শুরু করে।

import numpy as np
# ধাপের সময় ডেস্কটপের সত্যিকারের মাপ থেকে (llama.cpp, CPU-তে ৩ thread, Q4_K_M):
# decode ধাপ ≈ 7.29 + 4.86 × b ms (b = batch-এ অনুরোধ), prefill ≈ 3.4 ms প্রতি prompt token
T0, T1, PF, P = 0.00729, 0.00486, 0.0034, 110

def simulate(mode, lam, N=400, SLOTS=4, WAIT=0.5, seed=83):
    rng = np.random.default_rng(seed)
    arr = np.cumsum(rng.exponential(1 / lam, N)); out = rng.choice([32, 64, 128], N)
    t, i, q, run = 0.0, 0, [], {}
    first, done = np.zeros(N), np.zeros(N)
    while i < N or q or run:
        while i < N and arr[i] <= t: q.append(i); i += 1
        if not run and not q: t = arr[i]; continue
        if mode == "static" and not run and len(q) < SLOTS and i < N and t < arr[q[0]] + WAIT:
            t = min(arr[i], arr[q[0]] + WAIT); continue            # batch ভরার অপেক্ষা
        if q and (mode == "continuous" or not run):              # কে কখন ঢুকতে পারে
            new, q = q[:SLOTS - len(run)], q[SLOTS - len(run):]
            t += PF * P * len(new)                                # নতুনদের prefill
            for r in new: run[r] = out[r] - 1; first[r] = t
        t += T0 + T1 * len(run)                                   # এক decode ধাপ
        for r in list(run):
            run[r] -= 1
            if run[r] <= 0: done[r] = t; del run[r]
    ttft, e2e = first - arr, done - arr
    return out.sum() / done.max(), np.percentile(ttft, [50, 95]), np.percentile(e2e, [50, 95])

print("mode         λ/s  tok/s  TTFT p50  p95   E2E p50  p95")
for lam in (0.5, 0.8, 1.0):
    for m in ("static", "dynamic", "continuous"):
        th, tt, ee = simulate(m, lam)
        print(f"{m:11s} {lam:4.1f} {th:6.1f}  {tt[0]:7.2f} {tt[1]:6.2f}  {ee[0]:6.2f} {ee[1]:6.2f}")

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

mode         λ/s  tok/s  TTFT p50  p95   E2E p50  p95
static       0.5   40.3     1.22   3.26    2.42   4.95
dynamic      0.5   40.3     0.80   2.98    1.92   4.85
continuous   0.5   40.3     0.38   0.98    1.91   4.79
static       0.8   64.3     2.39   7.57    3.97   9.12
dynamic      0.8   64.2     2.14   7.02    3.70   8.78
continuous   0.8   64.4     0.43   3.57    2.96   7.52
static       1.0   77.4    17.81  25.85   19.78  27.63
dynamic      1.0   77.7    16.91  25.05   18.64  27.08
continuous   1.0   80.3     3.49  12.10    6.63  15.79

তিনটি পর্যবেক্ষণ। এক, হালকা চাপে (λ = ০.৫) throughput তিন পদ্ধতিতেই সমান, ৪০.৩ token/s: যন্ত্র যত কাজ পায়, ততই দেয়। পার্থক্য latency-তে: TTFT-এর p50 static-এ ১.২২ s, continuous-এ ০.৩৮ s, কারণ static batch ভরার অপেক্ষায় সময় নষ্ট করে। দুই, λ = ০.৮-এ continuous-এর TTFT p95 ৩.৫৭ s, static-এর ৭.৫৭ s. তিন, λ = ১.০-এ যন্ত্র ক্ষমতার কিনারে: static-এ TTFT-এর p50 ১৭.৮ s, continuous-এ ৩.৫ s, একই যন্ত্রে শুধু scheduling বদলে। তবু continuous-এর p95-ও ১২ s: ক্ষমতার কাছে গেলে যেকোনো পদ্ধতিতে সারি লম্বা হয় (সিস্টেম ডিজাইন কোর্সের পর্ব ১৮)। এটি একটি সরল মডেল: memory-র সীমা, chunked prefill আর আসল server-এর বাড়তি খরচ এতে নেই।

৬ · PagedAttention আর prefix caching — KV memory-র অপচয় কমানো

Continuous batching-এ অনুরোধ আসে-যায়, আর প্রত্যেকের KV cache বাড়ে এক token করে। সরল বাস্তবায়ন প্রতিটি অনুরোধের জন্য সর্বোচ্চ দৈর্ঘ্যের একটি টানা জায়গা আগেই রেখে দেয়। ধরুন সীমা ২,০৪৮ token, অথচ বাজারঘরের গড় প্রশ্ন আর উত্তর মিলিয়ে ৩০০ token. তাহলে রাখা জায়গার প্রায় ৮৫% কখনো ব্যবহারই হয় না। Batch-এ padding-ও একই অপচয় করে: আমাদের batch ১৬-এর run-এ prompt-এর ১,৮৭২টি ঘরের ১৩৯টি ছিল pad (৭.৪%), যদিও বাজারঘরের প্রশ্নগুলো প্রায় সমান লম্বা। দৈর্ঘ্যের পার্থক্য বেশি হলে অপচয়ও বাড়ে।

PagedAttention (Kwon ও সহকর্মী, SOSP ২০২৩; vLLM-এর ভিত্তি) সমাধানটি নিয়েছে অপারেটিং সিস্টেম থেকে। KV cache ভাগ হয় ছোট, সমান মাপের block-এ, যেমন প্রতি block-এ ১৬টি token. প্রতিটি অনুরোধের একটি block table থাকে: “আমার ০ নম্বর block আছে memory-র ৭ নম্বর ঘরে, ১ নম্বর ১ নম্বর ঘরে …”। Block টানা জায়গায় থাকতে হয় না। নতুন block লাগে শুধু আগেরটি ভরে গেলে, তাই অপচয় প্রতি অনুরোধে বড়জোর একটি অসম্পূর্ণ block. ধারণাটি অপারেটিং সিস্টেম কোর্সের paging আর virtual memory-র পর্বে দেখা page table-এর মতোই।

PagedAttention: block tables map logical KV blocks to scattered physical blocks Block table GPU memory-র block A:B: ০ ৭ ১ ৪ ০ ২ ৫ ০ prefix ১ A ২ B ৩ খালি ৪ A (আধা) ৫ B (আধা) ৬ খালি ৭ A ধূসর ০: একই system prompt, দুজনে ভাগ করে পড়ে অপচয় শুধু শেষের আধা-ভরা block-এ খালি ৩ আর ৬ যেকোনো নতুন অনুরোধ নিতে পারে
Block table logical block-কে ছড়ানো physical block-এ মেলায়। একই prefix-এর block একাধিক অনুরোধ ভাগ করে নিতে পারে।

এর দুটি বড় লাভ। এক, memory বাঁচে বলে একই GPU-তে বেশি অনুরোধ একসঙ্গে চলে, আর অংশ ২ অনুযায়ী বড় batch মানে বেশি throughput. প্রবন্ধটির সারাংশ দাবি করে, KV memory-র অপচয় “প্রায় শূন্য”, আর FasterTransformer ও Orca-র মতো ব্যবস্থার তুলনায় একই latency-তে throughput ২–৪ গুণ; এটি তাঁদের পরীক্ষার শর্তের ফল, সব ক্ষেত্রের নয়। দুই, block ভাগ করা যায়। একই system prompt-এর block দুটি অনুরোধ একসঙ্গে পড়তে পারে; কেউ লিখতে চাইলে তখন কপি হয়, অর্থাৎ copy-on-write. এটিই server-এর prefix caching-এর ভিত্তি, যার লাভ আপনি পর্ব ৬৫-এ নিজের যন্ত্রে মেপেছেন। SGLang-এর প্রবন্ধ (Zheng ও সহকর্মী, ২০২৩) এই পুনর্ব্যবহারকে একটি radix tree-তে সাজায়, নাম RadixAttention.

দামও আছে। Attention kernel-কে এখন ছড়ানো block থেকে পড়তে হয়, তাই kernel জটিল হয়। Block ছোট হলে অপচয় কম, কিন্তু table বড়; বড় হলে উল্টো। আর memory ফুরালে server-কে কাউকে থামাতে হয়: তার block মুছে পরে আবার prefill করা, বা CPU memory-তে সরিয়ে রাখা।

৭ · সত্যিকারের server — llama.cpp, quantization আর load test

এবার একটি আসল serving engine. llama.cpp-এর অফিসিয়াল Windows CPU binary (build 12211; প্রকল্পটি প্রায় প্রতিদিন নতুন build দেয়, আর GitHub-এ এগুলো pre-release হিসেবে চিহ্নিত), আর Qwen-এর নিজের প্রকাশ করা GGUF ফাইল (Qwen2.5-0.5B-Instruct-GGUF, Apache 2.0). সব run CPU-তে, ৩টি thread; মেশিনে একই সময়ে অন্য কাজও চলছিল।

ধাপ ১: quantization আর গতি. llama-bench দিয়ে prompt-এর গতি (pp) আর লেখার গতি (tg), তিনবারের গড় ± বিচ্যুতি:

ফাইলweight-এর মাপpp512 (token/s)pp2048tg128 (token/s)সর্বোচ্চ RAM
Q8_0638.74 MiB324.91 ± 6.61260.78 ± 52.8162.11 ± 1.96652 MiB
Q4_K_M462.96 MiB295.33 ± 2.99265.02 ± 0.7084.43 ± 0.44548 MiB

(সত্যিকারের run-এর আউটপুট থেকে, কয়েকটি কলাম বাদ দিয়ে; সর্বোচ্চ RAM আলাদা একটি ছোট script process-টির peak working set পড়ে মেপেছে।) এখানে অংশ ২-এর তত্ত্বের সুন্দর প্রমাণ: Q4_K_M-এ লেখার গতি ৩৬% বেশি (৬২ থেকে ৮৪ token/s), কারণ decode-এ প্রতি ধাপে কম byte পড়তে হয়। কিন্তু prompt-এর গতিতে কোনো লাভ নেই, pp512-এ বরং কিছু কম, কারণ prefill compute-bound, আর ৪-bit থেকে সংখ্যা খুলতেও হিসাব লাগে।

ধাপ ২: CPU-তে batch. llama-batched-bench-এ ১২৮ token-এর prompt আর ৬৪ token-এর উত্তর, একসঙ্গে B-টি অনুরোধ (Q4_K_M):

Bdecode-এ মোট token/sএক ধাপ (ms)একজনের token/s
১78.4512.7578.4
২121.5316.4560.8
৪153.2926.0938.3
৮170.0147.0621.2
১৬188.5084.8811.8

প্রথম দুটি কলাম সত্যিকারের আউটপুটের S_TG আর T_TG থেকে (T_TG ÷ ৬৪ = এক ধাপ)। CPU-র ছবি GPU-র মতো নয়। B = ১৬-এ মোট গতি মাত্র ২.৪ গুণ, আর ধাপ সাড়ে ছয় গুণ লম্বা। তিনটি CPU thread-এর গণনার ক্ষমতা কম, তাই batch একটু বাড়তেই কাজ compute-bound হয়ে যায়। এই CPU-তে ৪–৮-এর পর লাভ সামান্য, অথচ প্রত্যেকের লেখা অনেক ধীর।

ধাপ ৩: load test. llama-server একটি OpenAI-compatible API দেয়, তাই একই client কোড অন্য engine-এও চলত। Server খোলা হয় শুধু 127.0.0.1-এ; কাজ শেষে নিজের process ID দিয়ে বন্ধ। Slot সংখ্যা --parallel-এ। প্রতি slot-এ ২,০৪৮ token-এর context. সংক্ষেপিত command (আসল run-এ অন্য একটি port, আর একটি Python script server চালু ও বন্ধ করেছে):

~/bazarghar-dl$ llama-server -m qwen2.5-0.5b-instruct-q4_k_m.gguf \
    --host 127.0.0.1 --port 8000 -t 3 --parallel 4 -c 8192 --no-webui

আমাদের load generator (Python, httpx) seed ৮৩-এ ২৪টি অনুরোধ বানায়: Poisson আগমন (Poisson arrivals), বাজারঘরের ৮টি বাংলা প্রশ্নের একটি, আর উত্তরের দৈর্ঘ্য ৩২, ৬৪ বা ১২৮ token. দৈর্ঘ্য স্থির রাখতে ignore_eos, আর ন্যায্য তুলনার জন্য prompt cache বন্ধ। প্রতিটি অনুরোধ streaming-এ চলে, আর client প্রতিটির TTFT, TPOT, E2E লেখে। এটি open-loop: server ধীর হলেও অনুরোধ নির্ধারিত সময়েই যায়। একই seed-এ পাঁচটি run:

Runλ (অনুরোধ/s)Slottoken/sTTFT p50 / p95 (s)TPOT p50E2E p95 (s)
A১১49.82.18 / 7.6712 ms8.51
B১৪60.90.58 / 2.2638 ms5.72
C২১55.75.75 / 15.5412 ms16.32
D২৪, --no-cont-batching57.14.07 / 14.4826 ms16.42
E২৪77.91.69 / 7.0040 ms9.58
  • A বনাম B (λ = ১): ৪টি slot-এ TTFT-এর p95 ৭.৬৭ থেকে ২.২৬ s, E2E-এর p95 ৮.৫১ থেকে ৫.৭২ s. দাম: TPOT ১২ থেকে ৩৮ ms, কারণ প্রতিটি ধাপ চারজনে ভাগ। ৩৮ ms মানে সেকেন্ডে প্রায় ২৬টি token; বাংলায় একটি শব্দে প্রায়ই কয়েকটি token লাগে, তাই এই গতি পড়ার গতির কাছাকাছি হতে পারে। তবু ৭ সেকেন্ডের খালি পর্দার চেয়ে এটি অনেক কম চোখে লাগে।
  • C, D, E (λ = ২, চাহিদা ক্ষমতার বেশি): একটি slot-এ সারি ফুলে ওঠে, TTFT p95 ১৫.৫ s. Continuous batching-সহ ৪টি slot-এ (E) throughput ৪০% বেশি, ৭৭.৯ token/s, আর TTFT p95 অর্ধেকেরও কম।
  • D একটি আকর্ষণীয় ফল: ৪টি slot থাকলেও continuous batching বন্ধ করলে ফল প্রায় একটি slot-এর মতো। Server-এর help এই flag-কে শুধু continuous batching চালু বা বন্ধ বলে; ভেতরে ঠিক কী বদলায়, নথিতে বিস্তারিত পাইনি। তাই এটি আমাদের একটি run-এর পর্যবেক্ষণ, ব্যাখ্যা নয়।
  • সাবধানে পড়ুন: λ = ১-এ token/s মূলত ট্রাফিকের মাপ: ২৪টি অনুরোধে মোট ১,৬৩২ token, যা আসতেই প্রায় ২৪ সেকেন্ড লাগে। যন্ত্রের সর্বোচ্চ ক্ষমতা জানতে ধাপ ২-এর batched-bench দেখুন। Server-এর সর্বোচ্চ RAM ১ slot-এ ৫২০ MiB, ৪ slot-এ ৬০৬–৬২৩ MiB; পার্থক্যের বড় অংশ বাড়তি context-এর KV cache হওয়ার কথা (f16 ধরে সূত্রে ৯৬ বনাম ২৪ MiB).
  • সংখ্যা ছোট: ২৪টি অনুরোধ, একটি seed; p95 এখানে দু-একটি অনুরোধের ওপর দাঁড়িয়ে। সিদ্ধান্তের জন্য কয়েকশো অনুরোধ আর কয়েকটি seed লাগবে।

নিচের ব্লকে λ = ১-এর দুটি run-এর প্রতিটি অনুরোধের আসল সংখ্যা বসানো আছে। Percentile আবার হিসাব হয়, আর Little-এর সূত্র L = λ × W দুইভাবে যাচাই হয়: একবার সূত্রে, একবার প্রতিটি মুহূর্তে ভেতরে কয়টি অনুরোধ তা গুনে।

import numpy as np

# ডেস্কটপের সত্যিকারের load test: প্রতিটি অনুরোধের (আগমন s, TTFT s, E2E s, output token)
RUNS = {
 "slots=1": [(0.685, 0.479, 0.869, 32), (0.824, 1.117, 1.514, 32), (3.862, 0.426, 0.806, 32), (4.053, 0.978, 1.36, 32),
   (4.582, 1.185, 1.96, 64), (5.405, 1.508, 2.287, 64), (6.947, 1.121, 1.896, 64), (10.311, 0.434, 0.818, 32),
   (10.331, 1.165, 1.942, 64), (10.82, 1.847, 3.425, 128), (13.504, 1.091, 1.872, 64), (14.11, 1.618, 2.413, 64),
   (14.372, 2.515, 4.117, 128), (15.086, 3.79, 5.362, 128), (15.224, 5.572, 6.36, 64), (16.897, 5.077, 6.644, 128),
   (16.933, 6.97, 7.739, 64), (17.828, 7.211, 7.587, 32), (20.786, 5.008, 5.799, 64), (20.889, 6.081, 6.467, 32),
   (21.001, 6.707, 8.272, 128), (21.918, 7.74, 8.547, 64), (23.554, 7.3, 8.097, 64), (23.939, 8.083, 8.861, 64)],
 "slots=4": [(0.686, 0.426, 1.306, 32), (0.823, 0.682, 1.18, 32), (3.863, 0.427, 2.317, 32), (4.054, 0.621, 2.155, 32),
   (4.583, 0.464, 2.837, 64), (5.406, 0.422, 2.279, 64), (6.947, 0.442, 1.303, 64), (10.311, 0.446, 2.023, 32),
   (10.338, 0.802, 2.553, 64), (10.819, 0.733, 4.176, 128), (13.504, 0.415, 3.178, 64), (14.111, 0.411, 3.19, 64),
   (14.373, 0.541, 5.389, 128), (15.087, 0.468, 5.295, 128), (15.224, 1.845, 3.895, 64), (16.897, 0.808, 6.04, 128),
   (16.934, 2.568, 5.389, 64), (17.829, 2.328, 3.57, 32), (20.787, 0.456, 3.621, 64), (20.889, 0.901, 2.467, 32),
   (21.001, 1.704, 5.781, 128), (21.919, 1.436, 3.854, 64), (23.553, 0.442, 2.454, 64), (23.94, 0.87, 2.498, 64)],
}

for name, rows in RUNS.items():
    a = np.array(rows, dtype=float)
    arrive, ttft, e2e, n = a.T
    end = (arrive + e2e).max()
    tpot = (e2e - ttft) / (n - 1)
    lam, W = len(a) / end, e2e.mean()                               # জানালা: 0 থেকে শেষ উত্তর
    t = np.linspace(0, end, 2001)                                  # প্রতিটি মুহূর্তে কয়টি অনুরোধ ভেতরে
    inside = ((arrive[:, None] <= t) & (t < (arrive + e2e)[:, None])).sum(0)
    print(f"{name}: {n.sum() / end:5.1f} tok/s | TTFT p50 {np.percentile(ttft, 50):.2f} "
          f"p95 {np.percentile(ttft, 95):.2f} | TPOT p50 {np.percentile(tpot, 50) * 1000:.0f} ms "
          f"| E2E p95 {np.percentile(e2e, 95):.1f} s")
    print(f"   Little: lambda x W = {lam:.2f} x {W:.2f} = {lam * W:.2f} | time-average in system = {inside.mean():.2f}")

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

slots=1:  49.8 tok/s | TTFT p50 2.18 p95 7.67 | TPOT p50 12 ms | E2E p95 8.5 s
   Little: lambda x W = 0.73 x 4.38 = 3.20 | time-average in system = 3.20
slots=4:  60.9 tok/s | TTFT p50 0.58 p95 2.26 | TPOT p50 38 ms | E2E p95 5.7 s
   Little: lambda x W = 0.90 x 3.28 = 2.94 | time-average in system = 2.94

Percentile ডেস্কটপের সংখ্যাই ফেরত দিল। Little-এর সূত্রে দুই পথ মিলেছে। এখানে λ হলো ২৪টি অনুরোধ ভাগ পুরো জানালা (শূন্য থেকে শেষ উত্তর পর্যন্ত), তাই ০.৭৩ আর ০.৯০, নামমাত্র ১.০ নয়: জানালায় শেষের খালি হওয়ার সময়ও আছে। যে জানালার শুরু আর শেষে system খালি, সেখানে L = λ × W হুবহু খাটে; তাই না মিললে বুঝবেন সময় মাপায় কোনো ভুল আছে। কাজের ব্যবহারও সহজ: ১ slot-এ গড়ে ৩.২টি অনুরোধ ভেতরে ছিল, অথচ একসঙ্গে চলতে পারে মাত্র একটি, তাই বাকিরা সারিতে। এই সংখ্যাকে প্রতি অনুরোধের KV memory দিয়ে গুণ করলে memory-র চাহিদা পাওয়া যায়।

৮ · আরও কৌশল আর serving engine

Quantization দিয়ে serving. Decode memory-bound, তাই weight ছোট হলে প্রতি ধাপে কম byte আসে, আর ধাপ দ্রুত হয়। §৭-এর llama.cpp run-এ এটিই দেখা গেছে। Scale আর zero point-এর গণিত পর্ব ৫৯-এ; এখানে শুধু serving-এর দিক। KV cache-ও quantize করা যায় (যেমন ৮-bit), তখন প্রতি অনুরোধের memory অর্ধেক হয় আর একসঙ্গে বেশি অনুরোধ ধরে। দুই ক্ষেত্রেই উত্তরের মান নিজের eval set-এ (পর্ব ৫৮) যাচাই করতে হয়; বাংলা লেখায় ক্ষতি ইংরেজির চেয়ে আলাদা হতে পারে।

Chunked prefill. একটি ৮,০০০ token-এর prompt এলে তার prefill অনেক সময় নেয়, আর সেই সময় অন্যদের decode থেমে থাকতে পারে; তাদের ITL হঠাৎ লাফ দেয়। সমাধান: লম্বা prefill টুকরো করে প্রতিটি ধাপে decode-এর সঙ্গে মিশিয়ে চালানো। মোট কাজ একই, কিন্তু কেউ দীর্ঘক্ষণ আটকে থাকে না।

Speculative decoding. পর্ব ৩৭-এ নামটি এসেছিল: একটি ছোট খসড়া model k-টি token আগাম লেখে, আর বড় model এক pass-এ সেগুলো যাচাই করে। Leviathan ও সহকর্মীদের (২০২৩) বিশ্লেষণে, প্রতিটি খসড়া token গ্রহণের সম্ভাবনা α ধরলে বড় model-এর প্রতি pass-এ গড়ে (1 − αk+1) ÷ (1 − α)-টি token মেলে। α = ০.৮, k = ৪ হলে (১ − ০.৮৫) ÷ ০.২ = (১ − ০.৩২৭৭) ÷ ০.২ ≈ ৩.৩৬. তবে এটি গতির সরাসরি গুণ নয়: খসড়া model-এর নিজের সময় আছে। আর লাভটি আসে decode-এর অলস compute কাজে লাগিয়ে; batch বড় হয়ে GPU আগেই ব্যস্ত হলে লাভ কমে যেতে পারে। সঠিক rejection নিয়ম মানলে output-এর বণ্টন বড় model-এর সমান থাকে, প্রবন্ধটির দাবি অনুযায়ী।

Serving engine. নিজে scheduler আর kernel লেখার দরকার সাধারণত হয় না। নিচের তথ্য প্রতিটির নিজস্ব নথি বা README থেকে, লেখার সময়ে (নভেম্বর ২০২৬); এসব দ্রুত পরিবর্তন হতে পারে। এটি কোনো র‍্যাংকিং নয়।

Engineনথিতে যা বলা আছে
vLLMPagedAttention, continuous batching, chunked prefill, prefix caching; নানা quantization; speculative decoding; OpenAI-compatible server; NVIDIA, AMD, Intel GPU আর x86/ARM CPU
SGLangLLM, vision-language আর diffusion model-এর inference framework; প্রবন্ধে KV পুনর্ব্যবহারের জন্য RadixAttention
TGI (Hugging Face)Continuous batching, SSE streaming, tensor parallelism, quantization; README অনুযায়ী এখন maintenance mode-এ, আর vLLM, SGLang ও llama.cpp-এর মতো engine ব্যবহারের পরামর্শ
llama.cpp serverContinuous batching ডিফল্টে চালু; --parallel দিয়ে slot সংখ্যা; OpenAI-compatible /v1/chat/completions; draft model দিয়ে speculative decoding; prompt cache
OllamaFAQ অনুযায়ী প্রতি model-এ একসঙ্গে অনুরোধের ডিফল্ট ১ (OLLAMA_NUM_PARALLEL); parallel বাড়ালে context আর memory সেই অনুপাতে বাড়ে; OpenAI API-র একটি অংশ সমর্থন করে

Ollama-র নিয়মটি লক্ষ করুন: parallel অনুরোধ ৪ করলে context-এর memory চার গুণ। এটি §৩-এর সূত্রেরই প্রয়োগ। Engine বদলালেও KV cache-এর অঙ্ক বদলায় না।

৯ · খরচ — টাকা নয়, GPU-সেকেন্ডে ভাবা

দাম বদলায়, দেশ আর সরবরাহকারী ভেদে আলাদা; তাই এই পর্বে টাকার অঙ্ক নেই। কিন্তু খরচের কাঠামো স্থির: যন্ত্র যতক্ষণ চলে, ততক্ষণের দাম। তাই খরচ মাপুন যন্ত্র-সেকেন্ড প্রতি token-এ:

১০ লাখ token-এর যন্ত্র-সময় = ১০,০০,০০০ ÷ (মোট token/s)

আমাদের মাপা সংখ্যা বসাই (Qwen2.5-0.5B-Instruct, একটি local run):

ব্যবস্থামোট token/s১০ লাখ output token-এর যন্ত্র-সময়
transformers, GPU-তে, B = ১58.0১৭,২৪১ s ≈ ৪.৮ ঘণ্টা
transformers, GPU-তে, B = ১৬709.7১,৪০৯ s ≈ ২৩.৫ মিনিট
llama.cpp Q4_K_M, CPU, B = ১ (শুধু decode)78.45১২,৭৪৭ s ≈ ৩.৫ ঘণ্টা
llama.cpp Q4_K_M, CPU, B = ১৬ (শুধু decode)188.50৫,৩০৫ s ≈ ১.৫ ঘণ্টা
llama-server, ৪ slot, λ = ১ (load test B)60.9১৬,৪২০ s ≈ ৪.৬ ঘণ্টা

একই GPU, একই model: B = ১৬-এ একই সংখ্যক token-এর যন্ত্র-সময় B = ১-এর বারো ভাগের এক ভাগ। শেষ সারিটি বাস্তবের সবচেয়ে বড় শিক্ষা: load test-এ যন্ত্র নিজের ক্ষমতার চেয়ে কম কাজ পেয়েছে, তাই হিসাবে খরচ বেশি দেখায়। যন্ত্র অলস বসে থাকলেও সময়ের দাম দিতে হয়, তাই ব্যবহারের হার (utilization) খরচের সবচেয়ে বড় চাবি। হিসাবে prompt-এর token ধরা হয়নি; লম্বা prompt-এর prefill-এরও যন্ত্র-সময় আছে, যা পর্ব ৬৫-এর prefix cache কমায়। আর এই GPU-র memory-র একটি অংশ মাত্র ব্যবহার হয়েছে, অন্য কাজও চলছিল।

পর্ব ৮৫-এ পুরো বাজারঘর সহায়কের capacity হিসাব হবে: দিনে কত অনুরোধ, প্রতি অনুরোধে কত token, তা থেকে প্রয়োজনীয় token/s আর যন্ত্রের সংখ্যা। সেখানে এই সারণির সংখ্যা একটি শুরুর বিন্দু হতে পারে, তবে নিজের model আর যন্ত্রে আবার মাপা ছাড়া নয়।

আরও গভীরে: Decode-এর গতির একটি সরল ঊর্ধ্বসীমা: প্রতি ধাপে অন্তত সব weight একবার পড়তে হয়, তাই B = ১-এ token/s ≤ memory bandwidth ÷ weight-এর byte. বড় batch-এ KV cache পড়াও যোগ হয়, আর একসময় কাজ compute-bound হয়ে যায়; এই সীমারেখার ছবিকে roofline model বলে (Williams ও সহকর্মী, ২০০৯)। Prefill আর decode-এর এই বিপরীত চরিত্রের কারণে কিছু গবেষণা দুটিকে আলাদা যন্ত্রে চালানোর প্রস্তাব করে, যেমন DistServe (২০২৪); আর Sarathi-Serve (২০২৪) chunked prefill দিয়ে দুটিকে একই batch-এ মেশায়।

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

  • Prefill compute-bound আর decode memory-bound কেন? Prefill-এ এক weight পড়ায় অনেক token-এর গুণ; decode-এ প্রতি ধাপে সব weight পড়ে প্রতি অনুরোধে মাত্র একটি token.
  • KV cache-এর সূত্র? 2 × L × KV head × head_dim × bytes × T × B; GQA-তে KV head কম।
  • Continuous batching কী সমাধান করে? শেষ হওয়া অনুরোধের অলস slot আর নতুন অনুরোধের অপেক্ষা; প্রতি ধাপে batch নতুন করে সাজানো।
  • TTFT আর TPOT কী থেকে আসে? TTFT = সারি + prefill; TPOT = এক decode ধাপ, যা batch-এর মাপে বাড়ে। E2E = TTFT + (N − 1) × TPOT.
  • Little-এর সূত্রে capacity? একসঙ্গে চলমান অনুরোধ L = λ × W; L-কে প্রতি অনুরোধের KV memory দিয়ে গুণ করলে memory-র চাহিদা।

১০ · AI-এর যুগে — serving-এর কোড আর গতির দাবি যাচাই

AI assistant serving config বা benchmark মিনিটে লিখে দেয়; ভুল config-ও চলে, ভুল benchmark-ও সুন্দর সংখ্যা ছাপে।

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
পুরনো বা বানানো flag (engine-এর পুরনো সংস্করণের নাম)Engine দ্রুত বদলায়; flag বাদ পড়ে বা নাম বদলায়--help আর নিজের সংস্করণের README পড়া
KV cache-এর হিসাবে সব head ধরাGQA model-এ K, V-এর head অনেক কমconfig.json থেকে num_key_value_heads পড়া
একজন ব্যবহারকারী দিয়ে benchmarkBatching-এর আসল প্রভাব দেখা যায় নাSeed-সহ নির্দিষ্ট হারে অনেক অনুরোধ, open-loop
শুধু গড় latencyধীর প্রান্ত লুকায়p50, p95, p99 আলাদা করে, TTFT আর TPOT আলাদা
উত্তর ছোট হলে আগেই থেমে যাওয়া, তবু token/s তুলনাদুই run-এ ভিন্ন সংখ্যক tokenOutput token গুনে মেলানো, বা দৈর্ঘ্য স্থির রাখা
“vLLM X গুণ দ্রুত” ধরনের দাবিপ্রবন্ধের শর্তের সংখ্যানিজের model, যন্ত্র আর ট্রাফিকে মাপা
Server 0.0.0.0-এ খুলে রাখানেটওয়ার্কের যে কেউ ঢুকতে পারেপরীক্ষায় 127.0.0.1; production-এ gateway আর auth

কোড বা config চাওয়ার সময় যাচাইয়ের অংশও চেয়ে নিন:

আমি একটি ছোট open LLM (নাম আর config.json দিচ্ছি) নিজের সার্ভারে চালাতে চাই। Engine-এর নাম আর version-ও দিচ্ছি। ১. config.json থেকে স্তর, KV head আর head_dim পড়ে প্রতি token-এর KV cache হিসাব করো, ধাপে ধাপে। ২. আমার memory-তে ২,০৪৮ token-এর কয়টি অনুরোধ ধরবে, weight বাদ দিয়ে, দেখাও। ৩. Server চালানোর command দাও, শুধু 127.0.0.1-এ; প্রতিটি flag-এর মানে বলো, আর কোন flag নিয়ে নিশ্চিত নও তা চিহ্নিত করো। ৪. একটি load generator লিখে দাও: seed-সহ Poisson আগমন, streaming, প্রতিটি অনুরোধের TTFT, TPOT, E2E; শেষে p50 আর p95. ৫. Little-এর সূত্র দিয়ে ফল যাচাইয়ের একটি লাইন যোগ করো। ৬. কোনো গতির সংখ্যা বা প্রবন্ধের দাবি বানিয়ে লিখো না; অনুমান হলে বলো।

AI-এর লেখা serving config বা benchmark হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Batching তো বহু বছরের পুরনো কৌশল। তবু ২০২২ সালের Orca প্রবন্ধের “প্রতি ধাপে batch নতুন করে সাজানো” ধারণাটিকে LLM serving-এ বড় পরিবর্তন বলা হয় কেন?

ছবির classifier বা পুরনো ধরনের model-এ একটি অনুরোধ মানে একটি forward pass. দশটি ছবি জমিয়ে একসঙ্গে চালালে দশটিই একসঙ্গে শেষ হয়, কারণ সবার কাজের মাপ এক। তাই সারি থেকে অল্প সময় অপেক্ষা করে batch বানানো, অর্থাৎ dynamic batching, অনেক serving system-এ বহু আগে থেকেই ছিল। সেখানে প্রশ্ন ছিল শুধু, কতক্ষণ অপেক্ষা করলে batch যথেষ্ট বড় হবে কিন্তু latency খুব বাড়বে না।

LLM-এ একটি অনুরোধ একটি pass নয়, শত শত pass. কেউ চায় “হ্যাঁ” বা “না”, কেউ চায় পাঁচশো token-এর ব্যাখ্যা। দৈর্ঘ্য আগে জানা নেই, আর model নিজেই ঠিক করে কখন থামবে। পুরো অনুরোধকে একক ধরে batch বানালে ছোট উত্তরগুলো শেষ হয়ে বসে থাকে, আর নতুন অনুরোধ বাইরে অপেক্ষা করে। এই পর্বের stepper-এ পাঁচটি অনুরোধেই এক-তৃতীয়াংশ slot অলস গেছে। বাস্তবে উত্তরের দৈর্ঘ্যের পার্থক্য আরও বড় হতে পারে।

Yu ও সহকর্মীদের Orca প্রবন্ধ (OSDI ২০২২) এককটি বদলে দেয়: অনুরোধ নয়, একটি iteration, অর্থাৎ একটি decode ধাপ। প্রতি ধাপের পর scheduler দেখে কে শেষ, কে নতুন এসেছে। সমস্যা ছিল, ভিন্ন দৈর্ঘ্যের sequence-এর attention একসঙ্গে চালানো যায় না সরলভাবে; তাঁরা প্রস্তাব করেন, attention আলাদা আলাদা আর বাকি matmul একসঙ্গে চালানো, যাকে বলা হয় selective batching. এর পর KV memory-র ভাগাভাগি সহজ করতে আসে PagedAttention (২০২৩)। আজকের প্রধান open engine-গুলোর নথিতে continuous batching একটি মূল বৈশিষ্ট্য হিসেবে লেখা।

ধারণাটি নতুন শোনালেও এর শিকড় পুরনো: অপারেটিং সিস্টেমের time-sharing scheduler-ও ছোট ছোট সময়ে কাজ ভাগ করে। নতুনত্ব হলো ঠিক কোন এককে ভাগ করলে GPU-র memory-bound কাজের সঙ্গে খাপ খায়, তা খুঁজে পাওয়া।

মূল উপলব্ধি: Scheduling-এর একক ঠিক করা একটি নকশার সিদ্ধান্ত। LLM-এ সঠিক একক হলো token-এর ধাপ, পুরো অনুরোধ নয়।

প্র ০২PagedAttention-কে অনেকে বলেন “অপারেটিং সিস্টেমের পুরনো ধারণা GPU-তে ফিরে এলো”। এই তুলনা কতটা ঠিক, আর কোথায় খাটে না?

তুলনাটি অনেকটাই ঠিক। পুরনো কম্পিউটারে একটি program-কে memory-র একটি টানা অংশ দিতে হতো। Program ছোট-বড় হলে মাঝে মাঝে ছোট ছোট ফাঁক থেকে যেত, যা কেউ ব্যবহার করতে পারত না; একে বলে fragmentation. Paging-এর প্রথম দিকের রূপ সাধারণত ১৯৬০-এর দশকের শুরুর Manchester-এর Atlas কম্পিউটারের সঙ্গে যুক্ত করা হয়। সমাধান ছিল memory-কে সমান মাপের page-এ ভাগ করা, আর প্রতিটি program-এর একটি page table রাখা। তখন টানা জায়গার দরকার থাকে না, আর অপচয় সীমিত থাকে শেষ page-এ।

KV cache-এর সমস্যাও তাই। প্রতিটি অনুরোধ কতটা লম্বা হবে জানা নেই; আগে থেকে সর্বোচ্চ জায়গা রাখলে বেশিরভাগ খালি পড়ে থাকে, আর অনুরোধ শেষ হলে মাঝে ফাঁক তৈরি হয়। Kwon ও সহকর্মীরা (২০২৩) একই সমাধান আনেন: block, block table, আর ভাগ করা block-এ copy-on-write. Prefix sharing তো প্রায় হুবহু অপারেটিং সিস্টেমের shared page, যেমন একাধিক process একই library-র page পড়ে।

তবে পার্থক্যও আছে। অপারেটিং সিস্টেমে page table-এর অনুবাদ hardware করে, TLB-র মতো সাহায্যসহ। GPU-তে attention kernel-কে নিজেই block table পড়ে ছড়ানো block থেকে K, V আনতে হয়; তাই kernel লেখা কঠিন, আর block-এর মাপ গতিতে প্রভাব ফেলে। অপারেটিং সিস্টেমে memory ফুরালে page disk-এ যায় আর program কিছু না জেনেই অপেক্ষা করে; LLM server-এ বিকল্প হলো অনুরোধ থামিয়ে তার KV ফেলে দেওয়া আর পরে আবার prefill করা, বা CPU memory-তে সরানো। এখানে latency-র হিসাব সরাসরি ব্যবহারকারী টের পান। আরেকটি পার্থক্য: অপারেটিং সিস্টেমের page-এর ভেতরের ডেটা যেকোনো কিছু, আর KV block-এর ভেতরের কাঠামো জানা, তাই পুনর্ব্যবহারের সুযোগ আরও নির্দিষ্টভাবে খোঁজা যায়, যেমন SGLang-এর radix tree.

মূল উপলব্ধি: পুরনো system-এর ধারণা নতুন সমস্যায় খুব কাজে লাগে, কিন্তু নতুন hardware আর নতুন খরচের হিসাবে তাকে মানিয়ে নিতে হয়।

প্র ০৩ঈদের অফারের রাতে বাজারঘরের সহায়কে হঠাৎ দশ গুণ প্রশ্ন। batch সীমা বাড়ালে throughput বাড়ে, কিন্তু অনেকের TPOT-ও বাড়ে। কোন সংখ্যা ধরে সিদ্ধান্ত নেবেন?

প্রথমে মনে রাখুন, throughput মালিকের সংখ্যা আর latency ব্যবহারকারীর। বড় batch-এ প্রতি ধাপে বেশি token বের হয়, তাই যন্ত্রের খরচ প্রতি token-এ কমে। কিন্তু প্রতিটি ধাপও কিছুটা লম্বা হয়, আর একজন ব্যবহারকারী প্রতি ধাপে মাত্র একটি token পান। ফলে তাঁর কাছে লেখা ধীরে আসে। আবার batch-এর সীমা ছোট রাখলে নতুন অনুরোধ সারিতে দাঁড়ায়, আর TTFT বেড়ে যায়। দুই দিকেই কারও না কারও কষ্ট।

তাই একটি সংখ্যা যথেষ্ট নয়। ভালো পদ্ধতি হলো আগে লক্ষ্য লিখে ফেলা, যাকে বলে SLO: যেমন “৯৫% অনুরোধের TTFT ২ সেকেন্ডের কম, আর TPOT ১০০ মিলিসেকেন্ডের কম”। মানুষ সাধারণত সেকেন্ডে কয়েকটি শব্দ পড়েন, তাই TPOT সেই পড়ার গতির চেয়ে কম হলে বাড়তি গতি তাঁর কাছে প্রায় অদৃশ্য; এই সীমা কাজ অনুযায়ী বদলায়, তাই নিজের ব্যবহারকারী দেখে ঠিক করুন। তারপর মাপুন goodput: প্রতি সেকেন্ডে কতগুলো অনুরোধ এই দুই সীমাই মেনেছে। batch সীমা, slot সংখ্যা আর chunked prefill-এর মাপ এমনভাবে বাছুন, যাতে goodput সর্বোচ্চ হয়, token/s নয়।

দশ গুণ চাপের রাতে আরও কিছু সিদ্ধান্ত লাগে। সারির দৈর্ঘ্যের সীমা রাখা আর সীমা ছাড়ালে বিনয়ের সঙ্গে “একটু পরে চেষ্টা করুন” বলা, যাকে load shedding বলে (সিস্টেম ডিজাইন কোর্সের পর্ব ১৮)। লম্বা উত্তর সীমিত করা। আর সাধারণ প্রশ্নের উত্তর cache থেকে দেওয়া (পর্ব ৮৫-এ বিস্তারিত)। Little-এর সূত্র এখানে কাজের: দশ গুণ আগমনে একই W রাখতে হলে একসঙ্গে দশ গুণ অনুরোধ চলতে হবে, যা KV memory-তে ধরবে কি না, §৩-এর হিসাবেই দেখা যায়।

মূল উপলব্ধি: লক্ষ্য হলো “সময়সীমা মেনে কতজনকে সেবা দিলাম”, শুধু “কত token বানালাম” নয়। আগে SLO লিখুন, তারপর batch বাছুন।

প্র ০৪বাজারঘরের দল ভাবছে: hosted API ব্যবহার করবে, নাকি নিজের সার্ভারে open model চালাবে? এই পর্বের মাপা সংখ্যা এই সিদ্ধান্তে কী বলে, আর কী বলে না?

এই পর্বের সবচেয়ে বড় শিক্ষা হলো, নিজের সার্ভারের খরচ প্রায় পুরোটা নির্ভর করে ব্যবহারের হারের ওপর। GPU ঘণ্টা হিসেবে ভাড়া হয় বা কেনা হয়; অনুরোধ আসুক বা না আসুক, সময় চলে যায়। §৯-এর হিসাবে একই model, একই যন্ত্রে, batch ১-এ এক লাখ token-এর GPU-সময় batch ১৬-এর তুলনায় বহু গুণ। যদি সারাদিনে অল্প প্রশ্ন আসে, যন্ত্র বেশিরভাগ সময় অলস, আর প্রতি token-এর খরচ খুব বেশি। Hosted API সাধারণত ব্যবহার অনুযায়ী টাকা নেয়, আর অনেক গ্রাহকের ট্রাফিক মিলিয়ে নিজেদের batch ভরে রাখে।

অন্যদিকে, ট্রাফিক বেশি আর স্থির হলে নিজের সার্ভারে batch ভরা থাকে, আর হিসাব বদলে যেতে পারে। সংখ্যার বাইরেও প্রশ্ন আছে: গ্রাহকের ডেটা দেশের বাইরে যাবে কি না, আর রাতে সার্ভার কে সামলাবে। বাংলাদেশে ২০২৬ সালের ব্যক্তিগত উপাত্ত সুরক্ষা আইন আর সংশ্লিষ্ট আইনগুলো ব্যক্তিগত ডেটা পাঠানো ও রাখার বিষয়ে প্রাসঙ্গিক হতে পারে; নির্দিষ্ট বাধ্যবাধকতার জন্য আইনজ্ঞের পরামর্শ নিন।

আমাদের সংখ্যা কী বলে না, তাও জানা দরকার। এগুলো একটি ছোট ০.৫B model-এর, একটি যন্ত্রে, অন্য কাজের পাশে চালানো এক run-এর। বড় model, ভিন্ন GPU, লম্বা prompt বা ভিন্ন engine-এ সংখ্যা অনেক বদলাবে। ছোট model-এর উত্তরের মানও বড় hosted model-এর সমান নাও হতে পারে; গতি আর খরচের সঙ্গে মানের মূল্যায়ন (পর্ব ৫৮) ছাড়া তুলনা অসম্পূর্ণ। একটি বাস্তব পথ হলো মিশ্র ব্যবস্থা: সহজ, বেশি সংখ্যার প্রশ্ন ছোট নিজস্ব model-এ, কঠিনগুলো বড় model-এ। পর্ব ৮৫ এই routing নকশা নিয়ে কাজ করবে।

মূল উপলব্ধি: নিজের সার্ভারের খরচ ঠিক করে ব্যবহারের হার। নিজের ট্রাফিক, নিজের মানের মাপ আর নিজের ডেটার নিয়ম ছাড়া কোনো সাধারণ উত্তর নেই।

প্র ০৫দুটি benchmark-এ একই engine, একই model-এর throughput দুই রকম আসে। Serving benchmark এত সহজে বিভ্রান্তিকর হয় কেন, আর একটি সৎ benchmark-এ কী লিখে রাখা উচিত?

Serving-এর সংখ্যা নির্ভর করে অনেক লুকানো সিদ্ধান্তে। প্রথম, অনুরোধ কীভাবে আসে। “Closed-loop” benchmark-এ কয়েকজন কাল্পনিক ব্যবহারকারী উত্তর পেলেই পরের প্রশ্ন পাঠায়; server ধীর হলে আগমনও আপনা-আপনি কমে, তাই সারি কখনো ফুলে ওঠে না। বাস্তব ট্রাফিক server-এর গতির তোয়াক্কা করে না; তাই আমাদের load generator “open-loop”: seed-সহ Poisson আগমন, সময় হলেই পাঠায়। দুই পদ্ধতিতে p95 খুব আলাদা হতে পারে।

দ্বিতীয়, output-এর দৈর্ঘ্য। Model আগে থামলে কম token, কম সময়; তাই আমরা ignore_eos দিয়ে দৈর্ঘ্য স্থির রেখেছি, আর server-এর গোনা token মিলিয়েছি। তৃতীয়, prompt cache: একই prompt বারবার পাঠালে prefill প্রায় বাদ পড়ে, TTFT অবাস্তব রকম ভালো দেখায়; আমরা cache_prompt বন্ধ রেখেছি। চতুর্থ, warm-up: প্রথম অনুরোধে memory বরাদ্দ আর kernel প্রস্তুতির খরচ থাকে। পঞ্চম, যন্ত্রের অন্য কাজ: আমাদের মেশিনে একসঙ্গে অন্য কাজ চলছিল, তাই সংখ্যায় ওঠানামা আছে।

প্রকাশিত প্রবন্ধ আর engine-এর ব্লগের সংখ্যাও তাদের নিজস্ব শর্তে মাপা; শর্ত বদলালে ফল বদলায়, আর দুটি engine-এর তুলনায় প্রত্যেকের সেরা config ব্যবহার হয়েছিল কি না, সেই প্রশ্নও থাকে। এজন্য কোনো “X গুণ দ্রুত” দাবি দেখলে প্রথম প্রশ্ন হওয়া উচিত: কোন model, কোন যন্ত্র, কত অনুরোধ, কত দৈর্ঘ্য, কোন percentile?

একটি সৎ benchmark-এ লিখে রাখুন: engine আর model-এর সংস্করণ, quantization, যন্ত্রের ধরন (CPU না GPU), thread বা slot সংখ্যা, আগমনের ধরন ও হার, seed, prompt আর output-এর দৈর্ঘ্যের বণ্টন, warm-up, কতবার চালানো, আর TTFT, TPOT, E2E-এর p50 ও p95. Little-এর সূত্র দিয়ে একটি সহজ যাচাই করুন: মাপা গড় একসঙ্গে-চলা সংখ্যা আর λ × W মেলে কি না। না মিললে মাপার কোথাও ভুল।

মূল উপলব্ধি: Serving benchmark একটি পরীক্ষা, একটি সংখ্যা নয়। শর্ত না লিখলে ফল পুনরুৎপাদন করা যায় না, তুলনাও করা যায় না।

অনুশীলন

প্রথমে খাতায় করুন, তারপর উত্তর মেলান।

  1. পূর্ণ numerical: একটি model: L = ৩২ স্তর, ৩২টি query head, ৮টি KV head, head_dim = ১২৮, bf16. (ক) প্রতি token-এ KV cache কত byte? (খ) ৪,০৯৬ token-এর একটি অনুরোধে কত MiB? (গ) Weight রাখার পর ২০ GiB খালি থাকলে এমন কয়টি অনুরোধ একসঙ্গে ধরবে? (ঘ) GQA না থাকলে (৩২টি KV head)? (ঙ) TTFT ০.৪ s, TPOT ২৫ ms হলে ২০০ token-এর উত্তরের E2E? (চ) প্রতি সেকেন্ডে ৬টি অনুরোধ এলে Little-এর সূত্রে গড়ে কয়টি একসঙ্গে চলবে, আর (গ)-এর memory-তে ধরবে কি?

    (ক) ২ × ৩২ × ৮ × ১২৮ × ২ = ১,৩১,০৭২ byte = ১২৮ KiB. (খ) × ৪,০৯৬ = ৫৩,৬৮,৭০,৯১২ byte = ৫১২ MiB. (গ) ২০ GiB ÷ ০.৫ GiB = ৪০টি. (ঘ) চার গুণ বড়, প্রতি অনুরোধে ২ GiB: ১০টি. (ঙ) ০.৪ + ১৯৯ × ০.০২৫ = ০.৪ + ৪.৯৭৫ = ৫.৩৭৫ s. (চ) L = ৬ × ৫.৩৭৫ ≈ ৩২.২৫; ৪০-এর নিচে, তাই গড়ে ধরবে, তবে ওঠানামার জন্য হাতে জায়গা কম। GQA ছাড়া ১০টির সীমায় সারি জমতে থাকত।

  2. নকশা ও সমালোচনা: একজন সহকর্মীর পরিকল্পনা: “৩২-এর static batch বানাব, প্রতিটি অনুরোধের জন্য ৩২,০০০ token-এর KV জায়গা আগেই রাখব, যাতে কখনো কম না পড়ে। একজন ব্যবহারকারী দিয়ে পরপর ১০০টি প্রশ্ন পাঠিয়ে গড় latency মাপব। সার্ভার খুলব 0.0.0.0-এ, যাতে সবাই পরীক্ষা করতে পারে।” কী কী সমস্যা, আর আপনি কী বদলাবেন?

    (১) Static batch: লম্বা একটি উত্তর পুরো batch আটকে রাখে, নতুন অনুরোধ অপেক্ষা করে। Continuous batching আছে এমন engine নিন। (২) ৩২,০০০ token আগাম: বেশিরভাগ অনুরোধ কয়েকশো token, তাই প্রায় সব জায়গা অপচয়; একই memory-তে অনেক কম অনুরোধ ধরে। Paged KV, বাস্তবসম্মত সর্বোচ্চ দৈর্ঘ্য, আর memory-র হিসাব আগে করুন। (৩) একজন ব্যবহারকারী, পরপর: এটি closed-loop, batch কখনো ভরে না, সারিও তৈরি হয় না। Seed-সহ open-loop আগমন, কয়েকটি হারে। (৪) শুধু গড়: TTFT, TPOT, E2E আলাদা করে p50, p95; Little-এর সূত্রে যাচাই। (৫) 0.0.0.0: পরীক্ষায় 127.0.0.1; অন্যদের দিতে হলে সামনে auth আর rate limit-সহ gateway. (৬) Output-এর দৈর্ঘ্য আর warm-up লিখে রাখুন, আর মানের eval-ও চালান।

  3. আউটপুট অনুমান করুন: দশটি অনুরোধের E2E (সেকেন্ড) থেকে কী ছাপা হবে? (NumPy-র ডিফল্ট percentile পদ্ধতি দুই মানের মাঝে রৈখিকভাবে মেলায়।)
    import numpy as np
    e2e = np.array([2.0, 3.0, 2.5, 9.0, 3.5, 2.0, 4.0, 2.5, 3.0, 12.0])
    print(np.percentile(e2e, [50, 95]))
    print(e2e.mean(), np.median(e2e))
    lam = 2.0
    print("L =", lam * e2e.mean())

    সত্যিকারের run-এর আউটপুট (ডেস্কটপ, Python 3.14):

    [ 3.   10.65]
    4.35 3.0
    L = 8.7

    সাজালে: 2, 2, 2.5, 2.5, 3, 3, 3.5, 4, 9, 12. মাঝের দুটি 3 আর 3, তাই p50 = ৩. p95-এর অবস্থান ০.৯৫ × ৯ = ৮.৫৫, অর্থাৎ ৯ আর ১২-এর মাঝে ০.৫৫ ভাগ: ৯ + ০.৫৫ × ৩ = ১০.৬৫. গড় ৪.৩৫, median-এর চেয়ে বেশি, কারণ দুটি ধীর অনুরোধ গড়কে টেনে তোলে; এজন্যই গড় একা বিভ্রান্তিকর। প্রতি সেকেন্ডে ২টি আগমনে গড়ে ৮.৭টি অনুরোধ একসঙ্গে system-এ।

  4. কোড লিখুন: সব অনুরোধ শুরুতেই সারিতে আছে ধরে নিয়ে দুটি function লিখুন: steps_static(lengths, slots) (ক্রমানুসারে slots-টি করে batch, প্রতিটি batch চলে তার সবচেয়ে লম্বাটি পর্যন্ত) আর steps_continuous(lengths, slots) (প্রতি ধাপে খালি slot সারি থেকে ভরে)। মোট decode ধাপ ফেরত দিন। [4, 2, 3, 2, 1]-এ ৩টি slot, আর [100, 5, 5, 5, 5, 5, 5, 5]-এ ৪টি slot দিয়ে পরীক্ষা করুন।
    def steps_static(lengths, slots):
        total = 0
        for i in range(0, len(lengths), slots):
            total += max(lengths[i:i + slots])        # batch আটকে থাকে লম্বাটির জন্য
        return total
    
    def steps_continuous(lengths, slots):
        left, queue, steps = [], list(lengths), 0
        while left or queue:
            while queue and len(left) < slots:        # খালি slot ভরানো
                left.append(queue.pop(0))
            left = [x - 1 for x in left]              # এক ধাপ: সবাই একটি token
            left = [x for x in left if x > 0]         # শেষ হলে বের হয়
            steps += 1
        return steps
    
    L = [4, 2, 3, 2, 1]
    print(steps_static(L, 3), steps_continuous(L, 3))
    L2 = [100, 5, 5, 5, 5, 5, 5, 5]
    print(steps_static(L2, 4), steps_continuous(L2, 4), sum(L2))

    সত্যিকারের run-এর আউটপুট:

    6 4
    105 100 135

    প্রথম লাইন stepper-এর ফল মেলায়: ৬ বনাম ৪. দ্বিতীয়টি একটি সতর্কতা: একটি ১০০ token-এর অনুরোধ থাকলে continuous-ও ১০০ ধাপের কম পারে না, কারণ একটি অনুরোধ প্রতি ধাপে একটির বেশি token পায় না। এখানে লাভ মাত্র ৫ ধাপ; তবে মাঝের ছোট অনুরোধগুলোর অপেক্ষা অনেক কম হয়েছে, যা E2E-তে ধরা পড়বে, মোট ধাপে নয়। ১৩৫টি token ৪টি slot-এ ভাগ করলে নিম্নসীমা ৩৪ ধাপ, কিন্তু সবচেয়ে লম্বা অনুরোধের ১০০ ধাপই এখানে আসল সীমা।

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

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

ক্রমContinuous batching-সহ একটি LLM server-এ একটি অনুরোধের পথ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. অনুরোধ এসে সারিতে দাঁড়ায়
  2. Scheduler একটি খালি slot আর KV cache-এর block দেয়
  3. পুরো prompt-এর prefill হয়, প্রথম token streaming-এ যায়
  4. প্রতিটি decode ধাপে অন্যদের সঙ্গে একই batch-এ একটি করে token
  5. শেষ token-এর পর অনুরোধ batch থেকে বের হয়, block মুক্ত হয়

একটি model-এর config: ২৮টি স্তর, ১২টি query head, ২টি KV head, head_dim = ১২৮, bf16. ২,০৪৮ token-এর একটি অনুরোধের KV cache কত?

  • ২৮ MiB
  • ৩৩৬ MiB
  • ৫৬ MiB
  • ৮৯৬ MiB

Batch ১ থেকে ১৬ করলে LLM-এর decode ধাপের সময় খুব সামান্য বাড়ে, কিন্তু মোট token/s প্রায় batch-এর অনুপাতে বাড়ে। মূল কারণ কোনটি?

  • Batch বড় হলে prompt ছোট হয়ে যায়
  • Decode ধাপ মূলত weight পড়ায় আটকে থাকে, আর সেই পড়া batch-এর সবার জন্য একবারই হয়
  • Batch বড় হলে KV cache ছোট হয়
  • Batch-এ model কম স্তর ব্যবহার করে

একটি অনুরোধের TTFT ০.৫ s, TPOT ৪০ ms, আর output ১০১ token. E2E কত?

  • ৪.০ s
  • ৪.৫৪ s
  • ৪০.৫ s
  • ৪.৫ s

Continuous batching আর static batching-এর মূল পার্থক্য কোনটি?

  • প্রতি decode ধাপের পর batch-এ কে থাকবে তা নতুন করে ঠিক হয়
  • Continuous-এ প্রতিটি অনুরোধ আলাদা model পায়
  • Continuous output-এর মান কমিয়ে গতি বাড়ায়
  • Continuous-এ prefill লাগে না

একটি server-এ প্রতি সেকেন্ডে গড়ে ৪টি অনুরোধ আসে, আর প্রতিটির গড় E2E ৩ সেকেন্ড। স্থিতিশীল অবস্থায় গড়ে কয়টি অনুরোধ একসঙ্গে system-এ থাকে?

  • ১.৩৩টি
  • ৭টি
  • ১২টি
  • ৪টি

PagedAttention মূলত কোন সমস্যার সমাধান করে?

  • Attention-কে আসন্ন মানে নামিয়ে গুণ কমানো
  • প্রতিটি অনুরোধের জন্য টানা বড় KV জায়গা আগাম রাখার অপচয়
  • বাংলা লেখায় বেশি token হওয়া
  • Weight-কে ৪-bit-এ নামানো

শব্দকোষ

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

মনে রাখুন

  1. মাপকাঠি: TTFT, TPOT, E2E = TTFT + (N − 1) × TPOT, throughput আর goodput. গড় নয়, p50 আর p95.
  2. Prefill বনাম decode: Prefill অনেক token একসঙ্গে, compute-bound. Decode প্রতি ধাপে সব weight পড়ে, memory-bound; তাই batch-এ throughput বাড়ে।
  3. KV cache: প্রতি token 2 × L × KV head × head_dim × bytes. GQA এক বড় সাশ্রয়। Memory-ই ঠিক করে কয়টি অনুরোধ একসঙ্গে চলবে।
  4. Continuous batching আর PagedAttention: প্রতি ধাপে batch নতুন করে সাজানো, আর KV ছোট block-এ রাখা। অলস slot আর অপচয় কমে।
  5. মাপুন, অনুমান নয়: Seed-সহ open-loop load, স্থির output দৈর্ঘ্য, Little-এর সূত্রে যাচাই। খরচ ভাবুন GPU-সেকেন্ড প্রতি token-এ।

পরের পর্বে: পর্ব ৮৪ (Deep learning model deploy — ONNX, TorchScript আর edge device): LLM নয়, ছোট model-কে export করে ONNX Runtime-এ চালানো, quantize করা, আর ছোট যন্ত্রে নেওয়ার পথ।

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

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