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

Deep learning model deploy — ONNX, TorchScript আর edge device

Deploying Deep Learning Models: ONNX, TorchScript and Edge Devices

Taught in Bangla, this lesson walks the deploy cycle from export to monitoring and rollback. It exports PyTorch models to ONNX, torch.export and TorchScript with dynamic shapes, checks ONNX Runtime outputs against PyTorch, and measures size, latency and accuracy for fp32, dynamic int8 and static int8 on a small CNN and a Bangla DistilBERT in real desktop runs. It picks batch size and threads from p95, builds a FastAPI service with validation, micro-batching and health checks, and covers edge runtimes.

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

  • PyTorch model-কে ONNX, torch.export আর TorchScript-এ নেওয়া; opset আর dynamic shape ঠিক করা
  • ONNX Runtime-এ চালিয়ে output মেলানো, আর fp32, dynamic int8 ও static int8-এর আকার, সময় ও মান মাপা
  • একটি ওজনের সারি হাতে int8-এ নিয়ে ত্রুটি হিসাব করা
  • সত্যিকারের সময় থেকে percentile, throughput আর batch ও thread-এর সিদ্ধান্ত
  • FastAPI-তে ইনপুট যাচাই, micro-batching আর health check; ফোন আর browser-এর runtime; version আর rollback

মেশিন লার্নিং কোর্সের পর্ব ৬৮-এ scikit-learn-এর model FastAPI আর ONNX দিয়ে চালু করা হয়েছিল, আর পর্ব ৬৯-এ চালু model-এর test আর নজরদারি। সেই ভিত্তি এখানে ধরে নিচ্ছি। Deep learning-এ নতুন যা আসে: model একটি গণনার graph, যাকে PyTorch থেকে বের করে আনতে হয়; ওজন লাখ থেকে কোটিতে; আর একই model GPU-হীন server, ফোন বা browser-এ চালাতে হতে পারে। Quantization-এর মূল সূত্র পর্ব ৫৯ · Quantization আর distillation-এ আছে; LLM-বিশেষ serving (KV cache, continuous batching) পর্ব ৮৩ · LLM serving-এর বিষয়, এখানে নয়। Desktop-এর সব run লেখার সময়ে (নভেম্বর ২০২৬) এই version-এ, CPU-তে ৩টি thread: Python 3.12, torch 2.15.0, onnx 1.23.2, onnxscript 0.7.2, onnxruntime 1.31.1, transformers 5.22.2, fastapi 0.162.2, uvicorn 0.54.0. শুধু একটি fine-tuning GPU-তে, তা পাশে লেখা আছে।

১ · Deploy মানে কী — train থেকে নজরদারি পর্যন্ত একটি পথ

একটি পোশাক কারখানার কথা ভাবুন। ডিজাইনারের নমুনা জামা হলো শেখানো model. নমুনা দিয়ে দোকান চলে না: যেকোনো মেশিনে চলে এমন কাটিং প্যাটার্ন (export), কাপড় বাঁচাতে ছোট করা প্যাটার্ন (optimise), লেবেল আর ব্যাচ নম্বরসহ প্যাকেট (package), দোকান (serve), আর ফেরত আসা জামা দেখা (monitor). কোনো ব্যাচে ত্রুটি পেলে আগের প্যাটার্নে ফেরা হয়।

Deploy মানে model-কে এমন জায়গায় চালু করা, যেখানে আসল ব্যবহারকারী বা অন্য program তাকে ডাকে, আর সেটি নির্ভরযোগ্যভাবে, মাপা সময়ে উত্তর দেয়। নিচের ছবিতে ছয়টি ধাপ:

Deploy path: train, export, optimise, package, serve, monitor ১ · TrainPyTorchcnn.pt, weights ২ · ExportONNX graphopset, dynamic ৩ · Optimiseint8, fusionআবার মাপা ৪ · Packageফাইল + versiontokenizer, image ৫ · ServeAPI + runtime/health, /predict ৬ · Monitorp95, driftconfidence আবার শেখানো rollback: আগের version-এ ফেরা প্রতিটি তীরের পরে একই test-এ মান আর সময় মাপা
Deploy একটি চক্র। Export আর optimise-এর পর প্রতিবার আসল model-এর সঙ্গে output মেলাতে হয়; package-এ model-এর সঙ্গে tokenizer, preprocessing আর version নম্বর একসঙ্গে যায়; monitor থেকে দুটি পথ ফেরে: সমস্যা হলে আগের version-এ rollback, আর ডেটা বদলালে আবার training.

প্রতিটি ধাপে কী হাতে আসে, তা লিখে রাখা জরুরি। Deep learning model-এর “চালানোর জিনিস” শুধু ওজন নয়:

ওজন আর graph

state_dict বা safetensors ফাইল, অথবা export করা graph (.onnx, .pt2, .pte).

Preprocessing

ছবির normalise-এর mean আর std, tokenizer-এর ফাইল, সর্বোচ্চ token সংখ্যা। Training-এর সঙ্গে এক বিন্দু আলাদা হলে ভুল উত্তর আসে, error নয়।

চুক্তি

Input-এর নাম, আকার আর dtype, output-এর মানে (কোন logit কোন শ্রেণি), version নম্বর আর কোন ডেটায় মাপা হয়েছে।

মেশিন লার্নিং কোর্সের পর্ব ৬৮-এ এই ফাঁদের নাম ছিল training–serving skew. Deep learning-এ এর সাধারণ রূপ: ছবি ০–১-এ না নিয়ে পাঠানো, RGB আর BGR উল্টে যাওয়া, বা অন্য tokenizer. Model তখনো উত্তর দেয়, শুধু ভুল উত্তর।

২ · Export ফরম্যাট — TorchScript, torch.export আর ONNX

PyTorch-এ model একটি সাধারণ Python program: forward() চলার সময় এক এক করে কাজ হয় (eager mode, পর্ব ৯ · nn.Module). Server-এ বা ফোনে Python না-ও থাকতে পারে। তাই model-এর গণনাকে একটি স্থির graph-এ লিখে ফেলা হয়: কোন operation, কোন ক্রমে, কোন আকারের tensor-এ। এটিই export. তিনটি প্রধান পথ, প্রতিটির অবস্থা লেখার সময়ের (নভেম্বর ২০২৬) নথি থেকে, পরিবর্তন হতে পারে:

পথকী বানায়নথিতে অবস্থা
TorchScript (torch.jit.trace / script)একটি .pt program, C++ থেকে চালানো যায়PyTorch-এর নথিতে লেখা: “TorchScript is deprecated, please use torch.export instead.” আমাদের torch 2.15.0-এর run-এও FutureWarning দিয়েছে
torch.exportএকটি ExportedProgram: পুরো graph, aten operation-এ; .pt2নতুন সুপারিশকৃত পথ; ExecuTorch আর Core ML-এর converter এখান থেকে নেয়
ONNX (torch.onnx.export)একটি খোলা ফরম্যাটের graph ফাইল, যেকোনো ONNX runtime-এ চলেtorch 2.9 থেকে dynamo=True ডিফল্ট: ভেতরে torch.export, তারপর ONNX-এ অনুবাদ

দুটি শব্দ মনে রাখুন। Opset হলো ONNX-এর operation-তালিকার version: একটি ফাইল যে opset-এ লেখা, runtime-কে তা চিনতে হয়; পুরনো runtime নতুন opset না-ও চিনতে পারে। আর dynamic shape: export-এর সময় একটি উদাহরণ input দিতে হয়, আর কোন মাত্রা (batch, token-সংখ্যা) পরে বদলাতে পারবে তা না বললে graph সেই উদাহরণের আকারেই আটকে যায়। নথি অনুযায়ী dynamo exporter-এ পুরনো dynamic_axes-এর বদলে dynamic_shapes ব্যবহারের কথা।

নিচের stepper একটি সত্যিকারের run: Fashion-MNIST-এর (Zalando Research, MIT) ছোট CNN, CPU-তে ২ epoch-এ ১৩.৬ সেকেন্ডে শেখানো, ১০,০০০ test ছবিতে accuracy ০.৮৭৯১, seed ০। একই model চারটি রূপে:

  1. PyTorchnn.Moduleeager, Python
  2. TraceTorchScriptdeprecated
  3. torch.exportaten graphdynamic batch
  4. ONNXopset 20১০টি node
  5. ORT sessionCPU EPoutput মেলানো
cnn.py১,০৫,৮৬৬টি parameter
x: (N, 1, 28, 28)
c1 Conv+ReLU+MaxPool
   → (N, 16, 14, 14)
c2 Conv+ReLU+MaxPool
   → (N, 32, 7, 7)
flatten → (N, 1568)
fc1+ReLU → (N, 64)
fc2 → (N, 10) logits
state_dict: 416.5 KiB
torch.jit.traceসত্যিকারের output, ভেঙে লেখা
FutureWarning:
`torch.jit.trace` is
deprecated. Please switch
to `torch.compile` or
`torch.export`.
aten::_convolution 2
aten::relu 3
aten::max_pool2d 2
aten::flatten 1
aten::linear 2
+ prim:: node 52টি
torch.exportপ্রথমে ব্যর্থ, তারপর ঠিক (ছোট করা)
# উদাহরণের batch = 1
UserError: Constraints
violated (batch)! ...
specialized it to be a
constant (1).
# batch = 2, Dim 1..1024
aten.conv2d 2
aten.relu 3
aten.max_pool2d 2
aten.flatten 1
aten.linear 2
cnn.onnxopset 20, checker পাস
Conv Relu MaxPool
Conv Relu MaxPool
Reshape Gemm Relu Gemm
in:  image
     ['batch', 1, 28, 28]
out: logits ['batch', 10]
ওজন 105,868 (9টি tensor)
cnn.onnx       12.6 KiB
cnn.onnx.data 413.1 KiB
onnxruntime 1.31.1CPUExecutionProvider
max|diff|, PyTorch বনাম ORT
batch 1  (1, 10)  1.431e-06
batch 32 (32, 10) 2.861e-06
batch 7  (7, 10)  2.384e-06
10,000 test:      3.8e-06
সিদ্ধান্ত একই: 10,000/10,000
accuracy 0.8791 = 0.8791
  1. শুরু: PyTorch model. দুটি convolution আর দুটি Linear layer, মোট ১,০৫,৮৬৬টি parameter. প্রতিটি ধাপে tensor-এর আকার পাশে লেখা; N হলো batch-এর ছবির সংখ্যা। ওজনের ফাইল ৪১৬.৫ KiB.
  2. TorchScript trace. একটি উদাহরণ ছবি চালিয়ে যা যা ঘটল, তা রেকর্ড হয়েছে: ২টি convolution, ৩টি ReLU, ২টি max pool, ১টি flatten, ২টি linear, সঙ্গে কিছু constant আর attribute পড়ার node. কিন্তু torch 2.15.0 নিজেই বলছে torch.jit.trace deprecated. Trace শুধু এই input-এ যে পথে গেছে সেটুকু ধরে; if অন্য পথে গেলে তা হারায়।
  3. torch.export, আর একটি শিক্ষণীয় ব্যর্থতা. Batch মাত্রাকে dynamic বলে উদাহরণ দিলাম ১টি ছবি; export ব্যর্থ, কারণ ১ আকারের মাত্রাকে সে একটি বিশেষ ধ্রুবক হিসেবে ধরে নেয় (০ আর ১ মাপের মাত্রা আলাদা আচরণ করতে পারে বলে)। উদাহরণ ২টি ছবি দিলে export সফল, আর graph-এ শুধু ১০টি aten operation.
  4. ONNX graph. Opset ২০-এ ১০টি node; Linear হয়েছে Gemm (সাধারণ matrix গুণ আর যোগ), flatten হয়েছে Reshape. Input-এর প্রথম মাত্রা নাম ধরে 'batch', অর্থাৎ যেকোনো আকার চলবে। ওজন গেছে আলাদা external data ফাইলে।
  5. ONNX Runtime-এ যাচাই. Session খুলে একই ছবিতে চালালাম। ১, ৩২ আর ৭ (export-এ কখনো দেখা হয়নি এমন) batch-এ PyTorch-এর সঙ্গে সর্বোচ্চ পার্থক্য প্রায় ১০−৬, যা fp32-এর গোলাকৃত ত্রুটির মাপ। ১০,০০০ ছবির প্রতিটিতে সিদ্ধান্ত হুবহু এক। এই পরীক্ষা না করে export-কে সফল বলা যায় না।

সাবধান, external data: নতুন exporter ওজনগুলো আলাদা cnn.onnx.data ফাইলে রেখেছে, মূল .onnx মাত্র ১২.৬ KiB. শুধু .onnx কপি করে server-এ পাঠালে session খুলতেই ব্যর্থ হবে। দুটি ফাইল একসঙ্গে package করুন, আর ফাইলের আকার দেখে ভাববেন না model “ছোট হয়ে গেছে”।

৩ · ONNX Runtime-এ CNN — fp32 বনাম int8, সত্যিকারের run

ONNX Runtime (ORT) একটি C++ engine, যা ONNX graph পড়ে, পাশাপাশি থাকা operation জুড়ে দেয় (যেমন Conv-এর পরে ReLU একটি kernel-এ; এর নাম operator fusion), আর প্রতিটি operation যে হার্ডওয়্যারে চলবে তা বেছে দেয়। এই “হার্ডওয়্যার-সংযোগ”-কে ORT বলে execution provider: CPU-র জন্য CPUExecutionProvider, NVIDIA GPU-র জন্য CUDA বা TensorRT, Intel-এর জন্য OpenVINO, ইত্যাদি। Thread-এর সংখ্যা ঠিক হয় intra_op_num_threads দিয়ে।

একই CNN চারটি রূপে মাপা হলো: warm-up-এর পর batch ১-এ ৩০০টি, batch ৩২-এ ১০০টি মাপ; accuracy পুরো ১০,০০০ test ছবিতে। “সিদ্ধান্ত বদল” মানে PyTorch fp32-এর সঙ্গে কতগুলো ছবিতে শ্রেণি আলাদা। সত্যিকারের run, JSON থেকে সাজানো; একটি যন্ত্রের একটি run, সাধারণ নিয়ম নয়:

রূপআকার (KiB)Accuracyসিদ্ধান্ত বদলmax |diff|batch ১, p50 / p95 (µs)batch ৩২, p50 (ms)১ thread: batch ১ / ৩২
PyTorch fp32৪১৬.৫০.৮৭৯১——১১১ / ১৭৯১.৬৮১৯১ µs / ৪.৫৯ ms
ORT fp32১২.৬ + ৪১৩.১০.৮৭৯১০৩.৮ × ১০−৬৩০.৫ / ৩৮.৬০.২৫৩৩.৬ µs / ০.৬১ ms
ORT dynamic int8১১৩.২০.৮৭৯৬৩৬০.১৬০১৭৩ / ২০১৪.৩৯৩০৩ µs / ৯.২৮ ms
ORT static int8 (QDQ, per-channel)১১৮.২০.৮৭৯৭২২৫৩.০১৪৭.৯ / ৫২.২০.৮৬৫৫.৮ µs / ১.২৩ ms

ORT fp32 বনাম PyTorch: একই গণনা, একই উত্তর, তবু batch ১-এ প্রায় ৩.৬ গুণ আর batch ৩২-এ প্রায় ৬.৮ গুণ দ্রুত। এত ছোট model-এ সময়ের বড় অংশ গণনা নয়, প্রতিটি operation ডাকার খরচ: PyTorch-এর eager mode প্রতিটি layer-এ Python থেকে ডাকে, ORT পুরো graph একবারে C++-এ চালায় আর Conv+ReLU জুড়ে দেয়।

int8, সৎভাবে: আকার প্রায় ৩.৬ গুণ ছোট, accuracy কার্যত একই (১০,০০০-এ ৫–৬টি ছবি বেশি ঠিক, যা ওঠানামার মধ্যে)। কিন্তু এই CNN-এ দুই int8-ই ORT fp32-এর চেয়ে ধীর; dynamic int8 এমনকি PyTorch-এর চেয়েও। কারণ, এত ছোট গুণে quantize আর dequantize-এর বাড়তি কাজ সাশ্রয়ের চেয়ে বেশি, আর এই যন্ত্রে int8 convolution-এর পথ fp32-এর মতো দ্রুত হয়নি। সিদ্ধান্ত বদল: static int8-এ ২২৫টি ছবির (২.২৫%) উত্তর বদলেছে, যদিও মোট accuracy একই; কিছু ঠিক ভুল হয়েছে, কিছু ভুল ঠিক। গড় একই থাকলেও ব্যবহারকারী আলাদা উত্তর পাবেন, তাই বদলানো উত্তরের তালিকা দেখা জরুরি।

৪ · Deploy-এর জন্য quantization — dynamic বনাম static calibration

পর্ব ৫৯-এর সূত্র এক লাইনে: x̂ = s · (q − z), যেখানে x আসল দশমিক মান, q পূর্ণসংখ্যা, s scale আর z zero point. ORT-এর নথিও ঠিক এই সূত্রে লেখে: val_fp32 = scale * (val_quantized - zero_point). Deploy-এ প্রশ্ন একটাই: activation-এর scale কখন ঠিক হবে?

Dynamic int8

ওজন আগে থেকেই int8. Activation-এর scale প্রতিটি input চলার সময় মাপা হয়। Calibration লাগে না; বাড়তি খরচ চলার সময়ে। ORT-এর নথির সুপারিশ: RNN আর transformer-এ।

Static int8

কয়েকশো উদাহরণ (calibration ডেটা) চালিয়ে activation-এর পরিসর আগে মেপে scale ফাইলে লেখা। চলার সময় সস্তা, কিন্তু calibration ডেটা আসল ডেটার মতো হতে হবে। নথির সুপারিশ: CNN-এ।

ORT দুই ধরনের ফাইল বানাতে পারে। QOperator: প্রতিটি operation-এর নিজের int8 রূপ (ConvInteger, MatMulInteger). QDQ: মূল operation অক্ষত, আগে-পরে QuantizeLinear আর DequantizeLinear-এর জোড়া, যা runtime সুযোগ পেলে int8 kernel-এ মিলিয়ে চালায়। আমাদের static ফাইলে ৮টি Quantize আর ১৬টি Dequantize node; dynamic-এ ২টি ConvInteger, ২টি MatMulInteger আর ৪টি DynamicQuantizeLinear. Quantize-এর আগে নথির পরামর্শমতো quant_pre_process চালানো হয়েছে।

নিচের stepper-এ dynamic int8 ঠিক কী করে, তা আমাদের CNN-এর শেষ layer fc2-এর সারি ০ (শ্রেণি “T-shirt/top”-এর ৬৪টি ওজন) আর প্রথম test ছবির activation দিয়ে, হাতে। সব সংখ্যা desktop run থেকে:

হিসাবের খাতা
ধাপ
হিসাব
ফল
  1. ওজনের সারি. fc2-এর ওজন ১০ × ৬৪; সারি ০ শ্রেণি ০-এর logit বানায়। এর প্রথম মান ০.১০০৩১, সবচেয়ে বড় মাপের মান w₅ = −০.১৪৯১৬।
  2. Scale. ORT-এর dynamic int8 ওজনে symmetric, z = ০, আর scale পুরো tensor-এর একটি: s = max|w| ÷ ১২৭ = ০.২৬১৫৫ ÷ ১২৭ ≈ ০.০০২০৫৯৫। ORT-এর ফাইলে fc2.weight_scale ঠিক এই সংখ্যা, মিলিয়ে দেখা হয়েছে।
  3. Quantize. প্রতিটি ওজন ÷ s, তারপর কাছের পূর্ণসংখ্যা: ৪৮.৭১ থেকে ৪৯, −৭.২৩ থেকে −৭, −৭২.৪৩ থেকে −৭২। এক byte-এ রাখা যায়, তাই ফাইল প্রায় চার ভাগের এক ভাগ।
  4. Dequantize. q × s: ০.১০০৯১, −০.০১৪৪২, −০.১৪৮২৮। ত্রুটি ০.০০০৬১, ০.০০০৪৮, ০.০০০৮৮; প্রতিটি s/2 ≈ ০.০০১০৩-এর নিচে।
  5. পুরো সারি. ৬৪টি ওজনে সর্বোচ্চ ত্রুটি ০.০০১০০৬, তবু s/2-এর নিচে। এই সারির নিজস্ব scale (০.১৪৯১৬ ÷ ১২৭) নিলে ঘর বেশি ব্যবহার হয়, সর্বোচ্চ ত্রুটি নামে ০.০০০৫৮-এ। এটিই per-channel-এর যুক্তি।
  6. Activation, চলার সময়ে. প্রথম test ছবির fc1-এর ReLU output: ৬৪টি মান, ৩১টি শূন্য, সবচেয়ে বড় ১২.৯৮৭। Dynamic quantization এই ছবিটির জন্যই sx = ১২.৯৮৭ ÷ ২৫৫ ≈ ০.০৫০৯৩ মাপে, zx = ০ (uint8, ০..২৫৫)। পরের ছবিতে নতুন sx.
  7. পূর্ণসংখ্যায় গুণ. ৬৪টি qw × qx যোগ করে −৪৭,০৪৮, পুরোটা পূর্ণসংখ্যার হিসাব (int32-এ জমা)। শেষে একবার s × sx দিয়ে গুণ: −৪.৯৩৪৬, float-এ −৪.৯২৫৭। Bias যোগ করে logit −৪.৯৩৭৩ বনাম −৪.৯২৮৪, পার্থক্য ০.০০৮৯। এই ছবির শ্রেণি (৯, “Ankle boot”) বদলায়নি।

একই হিসাব পুরো সারিতে, পাতার Python-এ। সারির ৬৪টি ওজন আর একটি ছবির activation নিচের ছোট CSV ফাইলে (ওজন ৫ দশমিকে, activation ৩ দশমিকে রাখা, তাই শেষ অঙ্কে desktop-এর সঙ্গে সামান্য পার্থক্য হতে পারে):

import numpy as np
d = np.loadtxt("fc2_row0.csv", delimiter=",", skiprows=1)
w, a = d[:, 0], d[:, 1]          # fc2-এর সারি ০-এর ৬৪টি ওজন, একটি ছবির fc1 activation
s_ort = 0.0020594562             # ORT-এর int8 ফাইলে লেখা fc2.weight_scale (পুরো tensor)

def q_sym(x, s):                 # symmetric int8, zero point 0
    return np.clip(np.round(x / s), -127, 127)

print("ধরন       │ scale     │ q-পরিসর   │ max err  │ s/2      │ rel RMSE")
for name, s in [("per-tensor", s_ort), ("per-row", np.abs(w).max() / 127)]:
    q = q_sym(w, s)
    err = np.abs(w - q * s)                      # dequantize করে ত্রুটি
    rel = np.sqrt((err ** 2).mean()) / w.std()
    print(f"{name:10s} │ {s:.7f} │ {int(q.min()):4d}..{int(q.max()):3d} │"
          f" {err.max():.6f} │ {s / 2:.6f} │ {rel:.4f}")

# activation: dynamic uint8, scale মাপা হয় চলার সময়ে, প্রতিটি input-এ
sx = (a.max() - min(a.min(), 0)) / 255
zx = int(np.round(-min(a.min(), 0) / sx))
qa = np.clip(np.round(a / sx) + zx, 0, 255)
qw = q_sym(w, s_ort)
acc = int(np.dot(qw.astype(np.int64), (qa - zx).astype(np.int64)))  # পূর্ণসংখ্যার যোগফল
approx, exact = acc * s_ort * sx, float(np.dot(w, a))
print(f"activation: শূন্য {int((a == 0).sum())}টি, max {a.max():.3f},"
      f" s_x = {sx:.6f}, z_x = {zx}")
print(f"পূর্ণসংখ্যার যোগফল = {acc} → × s × s_x = {approx:.4f}")
print(f"float dot = {exact:.4f} │ পার্থক্য {abs(approx - exact):.4f}")

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

ধরন       │ scale     │ q-পরিসর   │ max err  │ s/2      │ rel RMSE
per-tensor │ 0.0020595 │  -72.. 65 │ 0.001006 │ 0.001030 │ 0.0076
per-row    │ 0.0011745 │ -127..114 │ 0.000587 │ 0.000587 │ 0.0042
activation: শূন্য 31টি, max 12.987, s_x = 0.050929, z_x = 0
পূর্ণসংখ্যার যোগফল = -47048 → × s × s_x = -4.9347
float dot = -4.9256 │ পার্থক্য 0.0091

প্রতিটি সারিতে max err ≤ s/2: round-এর ত্রুটি আধা ধাপের বেশি হয় না। ORT পুরো tensor-এর জন্য একটি scale নিয়েছে, যা অন্য সারির বড় মান (০.২৬২) থেকে আসে; তাই এই সারি −৭২ থেকে ৬৫-এর বেশি ঘর ব্যবহারই করে না। নিজস্ব scale (per-row, অর্থাৎ per-channel) দিলে পুরো −১২৭..১১৪ কাজে লাগে আর ত্রুটি প্রায় অর্ধেক (০.০০৭৬ থেকে ০.০০৪২)। Activation-এর অর্ধেক মান শূন্য (ReLU), সব ≥ ০, তাই zx = ০ আর uint8-এর পুরো পরিসর ধনাত্মক দিকে। পূর্ণসংখ্যার একটি যোগফল দুটি scale দিয়ে গুণ করলে float-এর উত্তরের প্রায় ০.০০৯-এর মধ্যে আসে।

৫ · বাংলা DistilBERT — ONNX Runtime-এ fp32 আর int8, ফোনের মতো ১ thread

এবার একটি বাংলা model. পর্ব ৪৮-এর Bangla BERT-এর ওজন আর হাতে নেই, তাই নতুন করে শেখানো হলো: distilbert/distilbert-base-multilingual-cased (Apache 2.0, model card অনুযায়ী), পর্ব ৪২ · Text classification-এর Sazzed-এর বাংলা YouTube মন্তব্যে (Mendeley Data, CC BY 4.0), একই প্রথম split (train ৭,৫১০, validation ১,০৫৬, test ২,১৪৩)। GPU-তে ৩ epoch, ৭৬.০ সেকেন্ড, মাপা সর্বোচ্চ GPU memory ২,৮১০ MiB, seed ০; validation macro-F1 ০.৯৩০৪। পর্ব ৪৮-এ একই model-এর ৫ ভাগের গড় macro-F1 ০.৯২৪; এখানকার test-এর ০.৯২৫৮ (নিচে) তার কাছাকাছি।

Export-এ batch আর token-সংখ্যা দুটিই dynamic (input ['batch', 'seq']), opset ২০, ২৮৭টি node, ৪.৫ সেকেন্ডে। পথে তিনটি সত্যিকারের বাধা এসেছিল, যা আপনিও পেতে পারেন:

pre_process with symbolic shape inference -> Exception : Incomplete symbolic shape inference
pre_process with skip_symbolic_shape=True -> ok
quantize_dynamic -> InferenceError : [ShapeInferenceError] Inferred shape and existing shape differ in dimension 0: (768) vs (2)
value_info entries removed: 414
quantize_dynamic after clearing value_info -> ok

এক, ORT-এর symbolic shape inference নতুন exporter-এর graph পুরো বোঝেনি; ধাপটি বাদ দেওয়া গেছে। দুই, exporter-এর লেখা আকার-টীকা (value_info) quantizer-এর হিসাবের সঙ্গে মেলেনি; টীকা মুছে দিলে quantizer নিজে আবার হিসাব করে। আগে আরেকটি: pre-processing আলাদা ফোল্ডারে গিয়ে .onnx.data পায়নি; একটিমাত্র ৫১৭ MiB-এর ফাইলে লিখে সমাধান। প্রতিটির পর output আবার মেলানো হয়েছে।

নিচের ছকে ২,১৪৩টি test মন্তব্যে macro-F1, PyTorch fp32-এর সঙ্গে বদলানো সিদ্ধান্ত আর logit-এর সর্বোচ্চ পার্থক্য। সময়: ২০০টি এলোমেলো মন্তব্য এক এক করে (batch ১), আর ৪০টি এলোমেলো ৩২-এর batch, warm-up-এর পর; “পুরো test” মানে দৈর্ঘ্য অনুযায়ী সাজিয়ে ৬৪-এর batch-এ। ১ thread দিয়ে কম শক্তির CPU-র একটি মোটা নকল, ফোনের মাপ নয়। সত্যিকারের run:

রূপআকার (MiB)Macro-F1সিদ্ধান্ত বদলmax |diff|পুরো test (s)batch ১, p50 / p95 (ms)batch ৩২, p50 (ms)১ thread: batch ১, p50 / p95
PyTorch fp32৫১৬.২০.৯২৫৮——২০.৭১২.১ / ৩৩.০৮৪৯৩০.৩ / ৭৯.৫
ORT fp32০.৯৭ + ৫১৬.২০.৯২৫৮০৮.৭ × ১০−৬২১.০১০.৩ / ২৮.৯৮৪৪২৪.৩ / ৭৫.২
ORT dynamic int8১২৯.৩০.৯০৬১৬১২.২৬১৪.৭৬.২ / ১৮.২৫৬৫১২.৭ / ৪৫.৩

এখানে int8 জিতেছে, তবে দাম দিয়ে: আকার চার ভাগের এক ভাগ (৫১৬ থেকে ১২৯ MiB; ORT embedding-এর Gather-ও int8 করেছে), batch ১-এ সময় প্রায় অর্ধেক (১০.৩ থেকে ৬.২ ms), ১ thread-এ p95 ৭৫.২ থেকে ৪৫.৩ ms. কারণ DistilBERT-এর সময় বড় matrix গুণে যায়, যেখানে int8 সত্যিই কাজে লাগে; ছোট CNN-এ তা ছিল না। কিন্তু macro-F1 প্রায় ২ পয়েন্ট পড়েছে (০.৯২৫৮ থেকে ০.৯০৬১), ৬১টি সিদ্ধান্ত বদলেছে। পর্ব ৫৯-এ PyTorch-এর int8-এ Bangla BERT-এর একটি সিদ্ধান্তও বদলায়নি; একই নামের “int8” ভিন্ন runtime-এ ভিন্ন ফল দেয়।

কোন সেটিং দায়ী, তা দেখতে একই pre-processed graph থেকে আরও চারটি int8 রূপ বানিয়ে শুধু মান আর batch ১-এর সময় মাপা হলো (৩ thread; আলাদা run, যন্ত্রে অন্য কাজও চলছিল, তাই সময় একটু ওঠানামা করে):

int8 রূপআকার (MiB)Macro-F1সিদ্ধান্ত বদলbatch ১, p50 (ms)
ডিফল্ট (per-tensor)১২৯.৩০.৯০৬১৬১৬.৪
শুধু MatMul (embedding fp32)৩৯৩.১০.৯০৭৭৫৬৬.৩
per-channel১২৯.৫০.২২১৯১,৪৭৯৫.৯
reduce-range১২৯.৩০.৮৭০১১৬৭৫.৬
per-channel + reduce-range১২৯.৫০.৯১৯০৩২৬.০

Embedding fp32-এ রেখে লাভ নেই (০.৯০৭৭), শুধু আকার তিন গুণ। সবচেয়ে চমকপ্রদ সারি per-channel: মান ভেঙে ০.২২-এ, প্রায় সব উত্তর এক শ্রেণিতে। কোনো error বা warning আসেনি। ORT-এর নথিতে ঠিক এই সতর্কতা আছে: AVX2 বা AVX-512-এর যন্ত্রে int8 গুণের একটি নির্দেশ (VPMADDUBSW) মাঝের যোগফলে সীমা ছাড়িয়ে আটকে যেতে পারে (saturation), তাই per-channel চালু করলে সাধারণত reduce-range-ও লাগে, যা ওজনকে ৭ bit-এ রাখে। দুটি একসঙ্গে দিলে এই run-এর সেরা int8: ০.৯১৯০, মাত্র ৩২টি বদল। শিক্ষা: quantize করা model-কে নতুন model ধরে প্রতিবার মাপুন; “চলেছে, error নেই” মানে “ঠিক আছে” নয়। অংশ ৭-এর service এই রূপটিই ব্যবহার করে।

৬ · Latency budget — p50, p95, batch আর thread

Percentile আর tail latency-র সংজ্ঞা সিস্টেম ডিজাইন কোর্সের পর্ব ৪-এ: nearest-rank-এ p95 হলো সাজানো তালিকার ⌈০.৯৫ × n⌉-তম মান। এখানে প্রশ্ন সিদ্ধান্তের: budget দেওয়া থাকলে কোন model, কোন batch আকার, কয়টি thread? Stepper-এর প্রথম তিন ধাপ অংশ ৫-এর run, বাকিগুলো একই model-এর আলাদা sweep:

সিদ্ধান্তের খাতা
প্রশ্ন
মাপা
ফল
  1. মাপ কী বলে. ২০০টি এলোমেলো মন্তব্য এক এক করে। সাজিয়ে nearest-rank-এ p50 = ১০০তম মান ৬.১৫ ms, p95 = ১৯০তম মান ১৮.২১ ms. গড় ৭.৮১, কারণ কিছু ধীর মাপ গড়কে ওপরে টানে; budget-এ তাই গড় নয়, p95.
  2. Tail কোথা থেকে. মন্তব্যের token-সংখ্যার median ২৬, কিন্তু p95 ১০০ (যেখানে কেটে দেওয়া হয়)। এই ২০০টিতে সময় আর token-সংখ্যার correlation ০.৯৯; ১৫ ms-এর বেশি লাগা মাপগুলোর token-সংখ্যার median ১০০, ৮ ms-এর কমগুলোর ১৯। Tail এখানে যন্ত্রের খামখেয়াল নয়, ডেটার বৈশিষ্ট্য।
  3. Online budget. ধরা যাক রিভিউ জমা দেওয়ার মুহূর্তে উত্তর চাই, p95 ≤ ৫০ ms. ৩ thread-এ তিনটি রূপই পাস (int8 ১৮.২, ORT fp32 ২৮.৯, PyTorch ৩৩.০)। ১ thread-এ (কম শক্তির CPU-র নকল) শুধু int8 পাস (৪৫.৩); fp32 ৭৫.২-এ ফেল।
  4. Batch, এলোমেলো. ৩২টি এলোমেলো মন্তব্য একসঙ্গে: ৫৩৬.৯ ms, অর্থাৎ প্রতি সেকেন্ডে ৫৯.৬টি, যা batch ১-এর ১৭২.৩-এর চেয়েও কম! প্রায় প্রতিটি ৩২-এর batch-এ অন্তত একটি ১০০ token-এর মন্তব্য থাকে, তাই সবাই ১০০-তে padding পায়, আর median ২৬-এর লেখার জন্যও ১০০-এর হিসাব চলে।
  5. Batch, দৈর্ঘ্যে সাজানো. কাছাকাছি দৈর্ঘ্যের ১৬টি মন্তব্য একসঙ্গে দিলে padding প্রায় থাকে না: প্রতি সেকেন্ডে ২৫৩.৬টি, batch ১-এর প্রায় দেড় গুণ। কিন্তু লম্বা মন্তব্যের batch-এ p95 ২২৩.৩ ms, তাই এটি online-এর জন্য নয়, রাতের একসঙ্গে-চালানো কাজের জন্য।
  6. Thread আর সিদ্ধান্ত. ১ থেকে ৩ thread-এ batch ১-এর throughput ৮০.০ থেকে ১৭২.৩ (প্রায় ২.২ গুণ), সাজানো batch ১৬-এ ১০৩.৩ থেকে ২৫৩.৬। সিদ্ধান্ত: online-এ int8, batch ১, ৩ thread, p95 প্রায় ১৮ ms; রাতে দৈর্ঘ্যে সাজিয়ে batch ১৬: ১ লাখ রিভিউ ÷ ২৫৩.৬ ≈ ৩৯৪ সেকেন্ড ≈ ৬.৬ মিনিট। সবই এই যন্ত্রের মাপ; লক্ষ্য-যন্ত্রে আবার মাপুন।

এবার নিজে হিসাব করুন। নিচের CSV-তে অংশ ৫-এর int8 model-এর ২০০টি সত্যিকারের সময় (batch ১, ৩ thread, ms). কোড percentile, budget-এর বিচার আর Little-এর সূত্র (L = λ · W: গড়ে একসঙ্গে চলমান অনুরোধ = আগমনের হার × প্রতিটির গড় সময়) দিয়ে একটি worker-এর ক্ষমতা আন্দাজ করে। প্রথমবার matplotlib লোড হতে কয়েক সেকেন্ড লাগে।

import numpy as np
import matplotlib.pyplot as plt
ms = np.loadtxt("t_int8_b1.csv", skiprows=1)   # ২০০টি সত্যিকারের মাপ: batch 1, int8, 3 thread
s = np.sort(ms)

def nearest_rank(q):                           # সাজানো তালিকার ⌈q·n⌉-তম মান
    return s[int(np.ceil(q * len(s))) - 1]

print(f"n = {len(ms)}, গড় {ms.mean():.2f} ms, সর্বনিম্ন {s[0]:.2f}, সর্বোচ্চ {s[-1]:.2f}")
for q in (0.50, 0.95, 0.99):
    print(f"p{int(q * 100):<3d} nearest-rank {nearest_rank(q):6.2f} │ NumPy linear {np.percentile(ms, q * 100):6.2f}")
budget = 20.0
verdict = "পাস" if nearest_rank(0.95) <= budget else "ফেল"
print(f"budget p95 ≤ {budget} ms → {verdict}; budget-এর চেয়ে ধীর: {(ms > budget).mean():.1%}")

cap = 1000 / ms.mean()                         # একটি worker, একবারে একটি অনুরোধ
print(f"একটি worker-এর সর্বোচ্চ ≈ {cap:.0f} অনুরোধ/সেকেন্ড")
for lam in (50, 100, 150):                     # আগমনের হার, অনুরোধ/সেকেন্ড
    print(f"λ = {lam:3d}/s → ব্যস্ততা ρ = {lam / cap:4.0%},"
          f" L = λ·W ≥ {lam * ms.mean() / 1000:.2f} (অপেক্ষা বাদে)")

fig, ax = plt.subplots(figsize=(6, 3))
ax.hist(ms, bins=30, color="#2563eb")
for q, c in ((0.50, "#059669"), (0.95, "#d97706")):
    ax.axvline(nearest_rank(q), color=c, ls="--", label=f"p{int(q * 100)}")
ax.set_xlabel("latency (ms), batch 1, int8, 3 threads")
ax.set_ylabel("count")
ax.legend()
plt.show()

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

n = 200, গড় 7.81 ms, সর্বনিম্ন 2.40, সর্বোচ্চ 21.91
p50  nearest-rank   6.15 │ NumPy linear   6.18
p95  nearest-rank  18.21 │ NumPy linear  18.21
p99  nearest-rank  20.47 │ NumPy linear  20.48
budget p95 ≤ 20.0 ms → পাস; budget-এর চেয়ে ধীর: 2.0%
একটি worker-এর সর্বোচ্চ ≈ 128 অনুরোধ/সেকেন্ড
λ =  50/s → ব্যস্ততা ρ =  39%, L = λ·W ≥ 0.39 (অপেক্ষা বাদে)
λ = 100/s → ব্যস্ততা ρ =  78%, L = λ·W ≥ 0.78 (অপেক্ষা বাদে)
λ = 150/s → ব্যস্ততা ρ = 117%, L = λ·W ≥ 1.17 (অপেক্ষা বাদে)

সঙ্গে একটি histogram: ২০০টির ১২৮টি ৩–৮ ms-এ, আর ডানে ১৫–২২ ms-এ ২৫টির একটি ছোট দল, মূলত লম্বা মন্তব্য।

দুই পদ্ধতির p50 আর p95 প্রায় একই, কারণ মাপ ২০০টি; মাপ কম হলে পার্থক্য বড় হয়, তাই report-এ পদ্ধতি লিখুন। ২০ ms-এর budget-এ p95 পাস, অথচ ২% অনুরোধ তার চেয়ে ধীর: p95 মানেই ৫% পর্যন্ত ধীর হতে দেওয়া। গড় ৭.৮১ ms থেকে একটি worker-এর সীমা প্রায় ১২৮ অনুরোধ/সেকেন্ড; অংশ ৭-এর service-এ মাপা throughput ১২২, খুব কাছাকাছি। λ = ১৫০-এ ব্যস্ততা ১০০% ছাড়ায়: সারি অসীমভাবে বাড়বে, দ্বিতীয় worker বা দ্রুততর model ছাড়া উপায় নেই। ব্যস্ততা ১-এর কাছে গেলে অপেক্ষা দ্রুত বাড়ে (সিস্টেম ডিজাইন কোর্সের পর্ব ১৮)।

৭ · FastAPI service — ইনপুট যাচাই, micro-batching আর health check

FastAPI-র মূল কথা Full-Stack Web Frameworks কোর্সের পর্ব ৬৯-এ, আর model-এর জন্য /health ও /predict-এর নকশা মেশিন লার্নিং কোর্সের পর্ব ৬৮-এ। Deep learning-এ তিনটি জিনিস যোগ হয়: tokenizer-কে model-এর সঙ্গে একই process-এ রাখা, লেখার দৈর্ঘ্যের সীমা (না হলে একটি বিশাল লেখা সবার সময় খায়), আর অনেক ছোট অনুরোধ জোড়া দিয়ে একবারে চালানোর micro-batching: সারিতে আসা অনুরোধ সর্বোচ্চ ১৬টি বা ৫ ms পর্যন্ত জমিয়ে একটি batch. Service শুধু 127.0.0.1-এ খোলা, model অংশ ৫-এর per-channel + reduce-range int8:

# serve.py — ছোট করা (পুরো ফাইল ৭৩ লাইন)
class Req(BaseModel):
    model_config = ConfigDict(extra="forbid")          # অজানা field হলে 422
    texts: list[Annotated[str, StringConstraints(strip_whitespace=True,
                min_length=1, max_length=500)]] = Field(min_length=1, max_length=32)

def infer(texts):                                      # tokenizer + ORT int8, একসঙ্গে
    enc = tok(texts, truncation=True, max_length=100, padding=True, return_tensors="np")
    z = sess.run(None, {"input_ids": enc["input_ids"].astype(np.int64),
                        "attention_mask": enc["attention_mask"].astype(np.int64)})[0]
    e = np.exp(z - z.max(1, keepdims=True))
    return (e[:, 1] / e.sum(1)).tolist()

async def batcher():                                   # micro-batch: ≤ 16টি লেখা বা 5 ms
    while True:
        items = [await queue.get()]
        t_end = time.perf_counter() + MAX_WAIT
        while sum(len(t) for t, _ in items) < MAX_BATCH and (left := t_end - time.perf_counter()) > 0:
            try: items.append(await asyncio.wait_for(queue.get(), left))
            except asyncio.TimeoutError: break
        probs = await asyncio.to_thread(infer, [s for t, _ in items for s in t])
        ...                                            # প্রতিটি অনুরোধকে নিজের অংশ ফেরত

@app.get("/health")
async def health():
    return {"status": "ok", "model_version": VERSION, "runtime": f"onnxruntime {ort.__version__}", "batching": BATCH}

সত্যিকারের run: একটি script server চালু করে তার PID রাখে, অনুরোধ পাঠায়, তারপর সেই PID ধরেই বন্ধ করে। লোড: test থেকে seed ৪২-এ বাছা ৩২০টি মন্তব্য, একসঙ্গে সর্বোচ্চ ১৬টি অনুরোধ (প্রতিটিতে একটি লেখা)। Output (ছোট করা: server চালু হওয়ার লাইন বাদ, দুটি load test-এর আগে বাংলা লেবেল যোগ):

test texts: 2143 | longer than 500 chars: 18 | longest: 1474
GET /health -> 200 {'status': 'ok', 'model_version': 'bazarghar-sentiment-2026.11.1', 'runtime': 'onnxruntime 1.31.1', 'batching': False}
POST /predict -> 200 {'model_version': 'bazarghar-sentiment-2026.11.1', 'p_positive': [0.9873, 0.0064], 'label': ['positive', 'negative']}
bad {"texts": []} -> 422 too_short
bad {"texts": ["   "]} -> 422 string_too_short
bad {"texts": ["ককককককক… (৫০১ অক্ষর) -> 422 string_too_long
bad {"text": "hi"} -> 422 missing
batching বন্ধ: {"p50": 126.95, "p95": 156.05, "mean": 126.47, "n": 320, "rps": 122.13, "wall_s": 2.62}
batching চালু: {"p50": 212.57, "p95": 287.46, "mean": 214.82, "n": 320, "rps": 71.97, "wall_s": 4.45}
server stopped, exit code 1   # দুবারই, নিজের PID ধরে terminate

যাচাই কাজ করছে: চারটি ভুল অনুরোধই model-এ পৌঁছানোর আগে ৪২২ পেয়েছে। প্রথম load test আসলে ব্যর্থ হয়েছিল: test-এর ১৮টি মন্তব্য ৫০০ অক্ষরের বেশি (সবচেয়ে বড় ১,৪৭৪)। সীমা ঠিক করার আগে আসল ডেটার দৈর্ঘ্য দেখুন; load test-এ ৫০০-এর মধ্যের মন্তব্য নেওয়া হয়েছে।

Micro-batching এখানে ক্ষতি করেছে: throughput ১২২ থেকে ৭২ অনুরোধ/সেকেন্ড, p95 ১৫৬ থেকে ২৮৭ ms. কারণ অংশ ৬-এর stepper-এ দেখা একই: batch-এর সব লেখা সবচেয়ে লম্বাটির সমান padding পায়, আর CPU-তে padding-এর হিসাবও পুরো দামে চলে। দৈর্ঘ্য অনুযায়ী দল বানানো batcher বা GPU-তে ফল আলাদা হতে পারে; মাপা হয়নি। Little-এর সূত্রের যাচাই: একসঙ্গে ১৬টি অনুরোধ (L = ১৬), λ = ১২২.১/s, তাই W = L/λ ≈ ১৩১ ms; মাপা গড় ১২৬.৫ ms. Batching-এ ১৬ / ৭২.০ ≈ ২২২ ms, মাপা ২১৪.৮ ms. অর্থাৎ প্রতিটি অনুরোধের বেশির ভাগ সময় কাটে সারিতে অপেক্ষায়, model-এ নয় (model-এর নিজের সময় p50 প্রায় ৬ ms).

Package: Dockerfile, নমুনা. Server-এ পাঠানোর সাধারণ পথ container: model-এর ফাইল, tokenizer, কোড আর ঠিক version-এর library একটি image-এ। নিচের Dockerfile একটি নমুনা, এই পর্বে চালানো হয়নি (Docker চালু করা হয়নি); কাঠামো দেখানোর জন্য। Multi-stage build আর non-root user-এর যুক্তি ক্লাউড ও DevOps কোর্সের পর্ব ৩৮-এ।

# নমুনা — চালানো হয়নি
FROM python:3.12-slim
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
# ওজন, tokenizer আর কোড একসঙ্গে; .onnx.data থাকলে সেটিও
COPY bert_int8_pcrr.onnx serve.py ./
COPY bert_ft/tokenizer* bert_ft/vocab.txt ./bert_ft/
ENV MODEL_VERSION=bazarghar-sentiment-2026.11.1 OMP_NUM_THREADS=3
RUN useradd -m app
USER app
EXPOSE 8000
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000"]

Container-এর ভেতরে 0.0.0.0 লাগে, যাতে বাইরের port-mapping কাজ করে; কিন্তু container-টি নিজে কোথায় খোলা থাকবে (শুধু ভেতরের নেটওয়ার্কে, gateway-এর পেছনে), তা আলাদা সিদ্ধান্ত। আমাদের desktop-এর পরীক্ষায় server শুধু 127.0.0.1-এ খোলা হয়েছিল।

৮ · Edge device — ফোন, browser আর বিশেষ হার্ডওয়্যার

Edge device মানে ব্যবহারকারীর নিজের যন্ত্র: ফোন, browser, দোকানের ছোট কম্পিউটার। লাভ: ইন্টারনেট ছাড়াও চলে (দুর্বল নেটওয়ার্ক বা লোডশেডিং-এর সময় কাজের), ডেটা যন্ত্র ছাড়ে না, server-এর খরচ নেই। দাম: কম memory আর thread, ব্যাটারি, app-এর আকার। নিচের বিবরণ প্রতিটির নিজের নথি থেকে, লেখার সময়ে (নভেম্বর ২০২৬), পরিবর্তন হতে পারে; তুলনামূলক মান বিচার করা হয়নি:

Runtimeনথি যা বলেPyTorch থেকে পথ
ExecuTorchPyTorch-এর edge-এর inference সমাধান, “mobile phones to embedded systems”torch.export → to_edge_transform_and_lower (যেমন mobile CPU-র জন্য XNNPACK partitioner) → to_executorch → .pte ফাইল
LiteRTGoogle-এর edge runtime, “Built on … TensorFlow Lite”নথি অনুযায়ী PyTorch, TensorFlow আর JAX থেকে সরাসরি .tflite-এ
Core ML (coremltools)Apple-এর যন্ত্রের জন্যPyTorch থেকে সরাসরি রূপান্তর সুপারিশকৃত; torch.export-এর পথ Core ML Tools 8.0-এ যোগ, নথিতে তখনো beta বলা
ONNX Runtime WebBrowser-এ ONNX modelএকই .onnx; CPU-তে WebAssembly, GPU-তে WebGPU, WebGL বা WebNN
OpenVINOIntel-এর toolkit; নথির সূচিতে CPU, GPU আর NPU যন্ত্রনথির সূচিতে PyTorch আর ONNX থেকে রূপান্তরের অংশ আছে
TensorRT“an SDK for optimizing deep learning inference on NVIDIA GPUs”PyTorch বা ONNX থেকে model নিয়ে একটি engine বানায়

যেকোনো runtime বাছাইয়ে প্রশ্ন একই: আমার model-এর সব operation এটি চেনে কি না, int8 বা fp16 সমর্থন আছে কি না, আর লক্ষ্য-যন্ত্রে সময় কত। শেষটির বিকল্প নেই; আমাদের কাছে ফোন ছিল না, তাই ১ thread-এর মাপগুলো কেবল “কম CPU”-র নকল।

Budget মেলানো একটি ছোট হিসাব: প্রতিটি লক্ষ্য-যন্ত্রের সর্বোচ্চ আকার আর p95, আর প্রতিটি প্রার্থী model-এর মাপা আকার ও p95. নিচের কোডে আমাদের desktop-এ মাপা সংখ্যা ডেটা হিসেবে বসানো; budget-গুলো উদাহরণ, বাজারঘরের দলের ধরা লক্ষ্য:

import pandas as pd
pd.set_option("display.width", 70)
cand = pd.DataFrame([            # desktop-এ মাপা: আকার MiB, macro-F1, p95 ms (3 আর 1 thread)
    ("PyTorch fp32", 516.2, 0.9258, 33.0, 79.5),
    ("ORT fp32", 517.2, 0.9258, 28.9, 75.2),
    ("ORT int8", 129.3, 0.9061, 18.2, 45.3),
    ("int8 pc+rr", 129.5, 0.9190, 17.9, 61.9),
], columns=["model", "mib", "f1", "p95_3t", "p95_1t"])

targets = {                      # উদাহরণ-লক্ষ্য: সর্বোচ্চ MiB, কোন p95, সর্বোচ্চ ms, সর্বনিম্ন F1
    "server": (1024, "p95_3t", 50, 0.92),
    "phone": (150, "p95_1t", 50, 0.90),
    "phone-strict": (150, "p95_1t", 40, 0.91),
}
for name, (mx, col, lim, f1) in targets.items():
    chk = pd.DataFrame({"model": cand.model, "size": cand.mib <= mx,
                        "speed": cand[col] <= lim, "quality": cand.f1 >= f1})
    chk["pass"] = chk[["size", "speed", "quality"]].all(axis=1)
    ok = list(chk.model[chk["pass"]])
    print(f"{name:12s} → {', '.join(ok) if ok else 'কোনোটিই নয়'}")
    if not ok:
        print(chk.to_string(index=False))

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

server       → PyTorch fp32, ORT fp32
phone        → ORT int8
phone-strict → কোনোটিই নয়
       model  size  speed  quality  pass
PyTorch fp32 False  False     True False
    ORT fp32 False  False     True False
    ORT int8  True  False    False False
  int8 pc+rr  True  False     True False

Server-এ F1 ≥ ০.৯২ চাইলে দুই fp32-ই পাস, int8 দুটি মানে আটকায়। “phone”-এ শুধু ডিফল্ট int8; per-channel রূপটির মান ভালো হলেও আলাদা run-এ ১ thread-এ p95 ৬১.৯ ms এসেছে (সেই সময় যন্ত্রে অন্য কাজ চলছিল, তাই এই সংখ্যা কম নির্ভরযোগ্য)। কড়া লক্ষ্যে কেউ পাস করেনি, আর ছকটি বলে কেন: ছোট দুটির গতি আর মান একসঙ্গে মেলে না। তখন quantization-এর সেটিং ঘোরানো নয়, model বদলানোর সময়: পর্ব ৫৯-এর distillation দিয়ে ছোট student, বা ছোট input (১০০-এর বদলে ৬৪ token). এই সিদ্ধান্ত মাপা সংখ্যা থেকে আসে, অনুমান থেকে নয়।

৯ · Version, rollback আর নজরদারি

চালু করার পর তিনটি অভ্যাস model-কে নিরাপদ রাখে।

এক, প্রতিটি উত্তরে version. আমাদের service প্রতিটি উত্তরে model_version ফেরত দেয়। সেই নামের পেছনে একটি স্থির রেকর্ড থাকা চাই: কোন ডেটা আর split, কোন কোডের অবস্থা, কোন seed, test-এর মাপ, export-এর opset, runtime-এর version, আর ফাইলের hash. এমন রেকর্ড রাখার জায়গাকে বলে model registry; ছোট দলে একটি ফোল্ডার আর একটি JSON ফাইলও যথেষ্ট, যদি নিয়ম মেনে রাখা হয়। পর্ব ১৯ · Experiment tracking-এর রেকর্ড এখান থেকেই শুরু, আর মানুষের পড়ার জন্য একটি model card.

দুই, ধাপে ধাপে চালু, আর ফেরার পথ খোলা. নতুন model আগে shadow-এ: আসল অনুরোধের কপি পায়, উত্তর দেখানো হয় না, শুধু পুরনোটির সঙ্গে মেলানো হয়। তারপর canary: অল্প কিছু ব্যবহারকারী, তারপর বেশি। সমস্যা হলে rollback: আগের version-এর ফাইলে ফেরা, যা মুছে ফেলা চলবে না। নতুন model-এর input-output চুক্তি (input-এর নাম, আকার, শ্রেণির ক্রম) পুরনোটির সমান রাখুন, তাহলে ফেরা মানে শুধু একটি pointer বদলানো। এই কৌশলগুলোর সাধারণ রূপ মেশিন লার্নিং কোর্সের পর্ব ৬৮ আর ক্লাউড ও DevOps কোর্সের পর্ব ৫৯-এ আছে।

তিন, ভেতরে কী ঢুকছে আর কী বের হচ্ছে, তা দেখা. Label প্রায়ই দেরিতে আসে, তাই মানের আগে লক্ষণ দেখতে হয় (মেশিন লার্নিং কোর্সের পর্ব ৬৯-এর চার স্তর)। Deep learning-এ যা নজরে রাখা কাজের:

  • Input-এর লক্ষণ: ছবির আকার আর উজ্জ্বলতা; লেখার token-সংখ্যা, অচেনা token-এর হার, রোমান হরফে লেখা বাংলার ভাগ। Data drift-এর প্রথম ইঙ্গিত এখানে।
  • Output-এর লক্ষণ: শ্রেণির অনুপাত আর softmax-এর সর্বোচ্চ probability-র বণ্টন; হঠাৎ বদল হলে খতিয়ে দেখুন।
  • System-এর মাপ: p50/p95, error-এর হার, ৪২২-এর হার (ভুল input), CPU আর memory. ৪২২ বেড়ে যাওয়া মানে কোনো client ভুল ফরম্যাটে পাঠাচ্ছে।
  • মান, যখন label আসে: একই slice-এ (জেলা, পণ্যের ধরন, লেখার ধরন) মাপুন; ইনপুট-আউটপুটের সম্পর্কই বদলে যাওয়া হলো concept drift, যার উত্তর আবার শেখানো।

ব্যক্তিগত তথ্য থাকলে log-এ পুরো লেখা না রেখে দৈর্ঘ্য, ভাষা আর confidence-এর মতো সারাংশ রাখুন। বাংলাদেশে ব্যক্তিগত উপাত্ত সুরক্ষা আইন ২০২৬ আর সংশ্লিষ্ট আইন এখন প্রযোজ্য; log আর retention-এর নিয়ম ঠিক করার আগে আইনজ্ঞের পরামর্শ নিন।

আরও গভীরে: torch.export graph ধরে “non-strict” মোডে Python চালিয়ে tracing করে, আর প্রতিটি dynamic মাত্রার জন্য symbolic আকার ও শর্ত (guard) রাখে; ব্যর্থতার বার্তায় যে “specialized to be a constant” দেখেছেন, তা এই guard-এরই ফল। ONNX-এর প্রতিটি operation-এর আচরণ opset-অনুযায়ী নির্দিষ্ট (onnx.ai-এর Operators তালিকা), আর exporter নিজের ONNX ফাইলকে onnxscript-এর optimizer দিয়ে ছোট করে। Edge-এ ExecuTorch-এর delegate (যেমন XNNPACK) graph-এর অংশ বিশেষ backend-কে দেয়, বাকিটা নিজের portable kernel-এ চালায়।

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

  • ONNX export-এর পর প্রথম যাচাই? একই input-এ PyTorch আর ORT-এর output-এর max |diff| (fp32-এ ~10−6) আর সিদ্ধান্ত মেলানো, export-এ না দেখা batch আকারেও।
  • Dynamic বনাম static quantization? Dynamic-এ activation-এর scale চলার সময় মাপা, calibration লাগে না; static-এ calibration ডেটায় আগে মাপা, চলার সময় সস্তা।
  • Symmetric int8, max|w| = ০.২৫৪: scale? ০.২৫৪ ÷ ১২৭ = ০.০০২; ত্রুটি সর্বোচ্চ ০.০০১।
  • ২০০টি মাপে p95 nearest-rank-এ কোনটি? সাজানো তালিকায় ⌈০.৯৫ × ২০০⌉ = ১৯০তম মান।
  • Batch বাড়ালে কী হয়? Throughput সাধারণত বাড়ে, প্রতিটি অনুরোধের latency-ও বাড়ে; budget-এর p95 মেনে সবচেয়ে বড় batch বাছা হয়।

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

Export আর serving-এর কোড AI assistant দ্রুত লেখে, কিন্তু এই ক্ষেত্রের API দ্রুত বদলায়। আমাদের run-এই torch 2.15.0 torch.jit.trace-কে deprecated বলেছে, ONNX exporter-এর ডিফল্ট torch 2.9-এ বদলেছে, আর dynamic_axes-এর জায়গায় dynamic_shapes এসেছে। পুরনো টিউটোরিয়াল থেকে শেখা AI প্রায়ই পুরনো পথ দেয়।

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
নতুন কোডে torch.jit.trace বা dynamic_axesনথিতে deprecated বা পুরনো পথনিজের torch version-এর নথি আর warning পড়া
Batch ১-এর উদাহরণে export, dynamic ঘোষণা ছাড়াGraph এক আকারে আটকে যায়Export-এ না দেখা batch আকারে (যেমন ৭) চালানো
Export-এর আগে model.eval() ভুলে যাওয়াDropout বা BatchNorm training-অবস্থায় graph-এ ঢোকেPyTorch-এর eval output-এর সঙ্গে max |diff|
“int8 মানে চার গুণ দ্রুত” লেখাআমাদের CNN-এ int8 ধীর হয়েছেWarm-up-এর পর নিজের যন্ত্রে p50/p95
Service-এ ইনপুট যাচাই বা সীমা না রাখাবিশাল বা খালি input server আটকায়খালি, অতি লম্বা আর ভুল-field অনুরোধে ৪২২ আসে কি না
Server 0.0.0.0-এ খুলে রাখা, বা process নাম ধরে বন্ধ করাঅপ্রয়োজনে বাইরে খোলা; অন্যের process মরেপরীক্ষায় 127.0.0.1, নিজের PID ধরে বন্ধ

কোড চাওয়ার সময় যাচাইও চেয়ে নিন:

আমার একটি PyTorch classifier আছে (torch আর onnxruntime-এর version লিখে দিচ্ছি)। ১. torch.onnx.export দিয়ে ONNX-এ নেওয়ার কোড দাও, batch আর token-সংখ্যা dynamic রেখে; প্রতিটি argument আমার version-এর নথিতে আছে কি না চিহ্নিত করো। ২. PyTorch আর ONNX Runtime-এর output-এর max |diff| আর সিদ্ধান্ত মেলানোর পরীক্ষা লেখো, export-এ না দেখা batch আকারসহ। ৩. ONNX Runtime-এ dynamic int8 করার কোড দাও, আর আকার, warm-up-এর পরের p50/p95 আর held-out test-এর মান মাপার কোড। ৪. FastAPI-তে /health আর /predict দাও, Pydantic দিয়ে ইনপুটের সীমা, শুধু 127.0.0.1-এ। ৫. গতি বা মানের কোনো সংখ্যা বানিয়ে লিখবে না; নিশ্চিত না হলে বলো।

AI-এর লেখা deploy কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১প্রতিটি framework-এর নিজের সংরক্ষণ-ফরম্যাট থাকতে ONNX-এর মতো একটি নিরপেক্ষ ফরম্যাট কেন দরকার হলো? এর দাম কী?

২০১৬–১৭ সালের দিকে deep learning-এর জগৎ ছিল বহু ভাগে বিভক্ত: গবেষকেরা এক framework-এ শেখাতেন, আর production দল হয়তো অন্য একটিতে, বা নিজস্ব C++ engine-এ চালাতেন। প্রতিটি জোড়ার জন্য আলাদা converter লিখতে হতো। ONNX-এর উদ্যোগ ২০১৭ সালে Facebook আর Microsoft-এর হাত ধরে শুরু হয় বলে প্রকল্পের ইতিহাসে লেখা আছে; পরে আরও প্রতিষ্ঠান যোগ দেয়, আর প্রকল্পটি এখন Linux Foundation-এর AI সংক্রান্ত ছাতার নিচে পরিচালিত হয় বলে জানা যায়। মূল ধারণা compiler-এর জগৎ থেকে চেনা: অনেক উৎস আর অনেক লক্ষ্যের মাঝে একটি সাধারণ মধ্যবর্তী রূপ রাখলে N × M converter-এর বদলে N + M লাগে।

লাভ স্পষ্ট। আমাদের run-এ একই .onnx ফাইল CPU-তে ONNX Runtime চালিয়েছে; নথি অনুযায়ী একই ফাইল browser-এ ONNX Runtime Web, Intel-এর যন্ত্রে OpenVINO বা NVIDIA GPU-তে TensorRT-এও নেওয়া যায়। Server-এ PyTorch-এর পুরো installation লাগে না। ফাইলটি pickle নয়, তাই খুললেই কোনো Python কোড চলে না। আর graph স্থির বলে runtime আগে থেকেই operation জুড়ে দেওয়া, memory পরিকল্পনা আর হার্ডওয়্যার বাছাইয়ের সুযোগ পায়; আমাদের ছোট CNN-এ তাতেই PyTorch-এর চেয়ে কয়েক গুণ দ্রুত হয়েছে।

দাম কোথায়? এক, operation-এর তালিকা: নতুন গবেষণার কোনো layer হয়তো ONNX-এর opset-এ নেই; তখন exporter সেটিকে ছোট operation-এ ভাঙে (ধীর হতে পারে), বা custom operation লাগে, যা প্রতিটি runtime-এ আলাদা লিখতে হয়। দুই, ফাইলের opset আর runtime-এর সমর্থিত opset মিলতে হবে। তিন, data-নির্ভর loop বা শর্ত graph-এ ধরা কঠিন। চার, প্রতিটি রূপান্তরে ভুল ঢোকার সুযোগ, তাই output মেলানো বাধ্যতামূলক।

মূল উপলব্ধি: ONNX framework আর হার্ডওয়্যারের মাঝে একটি সাধারণ মধ্যবর্তী ভাষা, যা N × M সমস্যাকে N + M করে। দাম operation-তালিকা, opset-এর মিল আর রূপান্তরের ঝুঁকি; তাই প্রতিটি export-এর পর output মেলানো আর লক্ষ্য-runtime-এ মাপা জরুরি।

প্র ০২TorchScript একসময় PyTorch model চালু করার প্রধান পথ ছিল। এখন নথি তাকে deprecated বলে কেন? এই বদল থেকে দীর্ঘমেয়াদি কোড রাখার কী শিক্ষা নেওয়া যায়?

PyTorch-এর শক্তি ছিল eager mode: সাধারণ Python-এর মতো লেখা, প্রতিটি লাইন সঙ্গে সঙ্গে চলে, debugger দিয়ে দেখা যায়। কিন্তু production-এ Python না রেখে চালাতে একটি স্থির রূপ দরকার ছিল। PyTorch ১.০-এর সময় (২০১৮ সালের শেষে) TorchScript এই উত্তর দেয়, দুই পথে। Tracing: একটি উদাহরণ input চালিয়ে যা যা ঘটে তা রেকর্ড করা; সহজ, কিন্তু if বা loop যে পথে গেল শুধু সেটুকু ধরে, অন্য পথ নিঃশব্দে হারায়। Scripting: Python কোডের একটি উপভাষা নিজে compile করা; শর্ত আর loop ধরে, কিন্তু Python-এর অনেক কিছু সমর্থন করে না, তাই কোড ঘুরিয়ে লিখতে হতো।

PyTorch ২.০ (২০২৩) থেকে নতুন ভিত্তি আসে: TorchDynamo, যা Python-এর bytecode পর্যায়ে কাজ করে graph ধরে। এর ওপর torch.compile (গতির জন্য) আর torch.export (পূর্ণ, স্থির graph-এর জন্য)। torch.export dynamic মাত্রার জন্য symbolic আকার আর শর্ত রাখে, আর কোথাও graph ধরতে না পারলে চুপচাপ ভুল না করে স্পষ্ট error দেয়; আমাদের batch-১-এর ব্যর্থতা তার উদাহরণ। নথি এখন TorchScript-কে deprecated বলে আর torch.export-এর কথা বলে; ONNX exporter-ও torch 2.9 থেকে ডিফল্টভাবে torch.export ব্যবহার করে, আর ExecuTorch ও Core ML-এর নতুন পথ সেখান থেকে শুরু। আমাদের torch 2.15.0-এর run-এ torch.jit.trace নিজেই FutureWarning দিয়েছে।

দীর্ঘমেয়াদি শিক্ষা কয়েকটি। এক, warning পড়ুন: FutureWarning আর DeprecationWarning হলো ভবিষ্যতের breaking change-এর আগাম নোটিশ। দুই, version স্থির রাখুন (lock file), আর upgrade করুন পরিকল্পনা করে, test চালিয়ে। তিন, AI assistant বা পুরনো ব্লগ থেকে পাওয়া কোড নিজের version-এর নথির সঙ্গে মেলান, কারণ তাদের বড় অংশ TorchScript-যুগের।

মূল উপলব্ধি: Tracing আর scripting-এর সীমা থেকেই torch.export এসেছে: পূর্ণ graph, dynamic মাত্রার স্পষ্ট নিয়ম আর চুপচাপ ভুলের বদলে স্পষ্ট error. নতুন কাজে নথির সুপারিশকৃত পথ নিন, version স্থির রাখুন, আর export-এর পরীক্ষা স্বয়ংক্রিয় করুন।

প্র ০৩একই “int8 quantization” ছোট CNN-কে ধীর করল, DistilBERT-কে দ্রুত করল, আর একটি সেটিংয়ে DistilBERT-এর মান প্রায় ধ্বংস করল। এই তিনটি ফল একসঙ্গে কী শেখায়?

প্রথমে গতি। একটি model চালানোর সময় তিন ধরনের খরচে ভাগ হয়: গুণ-যোগের হিসাব, memory থেকে ওজন আর activation আনা, আর প্রতিটি operation ডাকার নির্দিষ্ট খরচ (overhead). int8 প্রথম দুটি কমায়: এক byte-এর সংখ্যা আনতে কম সময়, আর CPU-র বিশেষ নির্দেশ একবারে বেশি int8 গুণ করে। কিন্তু তৃতীয়টি বাড়ায়: activation-কে quantize আর dequantize করার বাড়তি node, dynamic হলে প্রতিবার scale মাপা। আমাদের CNN-এ batch ১-এ পুরো model ORT fp32-এ মাত্র ৩০ মাইক্রোসেকেন্ড; এত ছোট কাজে overhead-ই প্রধান, তাই int8 ধীর হয়েছে। DistilBERT-এ প্রতিটি মন্তব্যে শত কোটির কাছাকাছি গুণ-যোগ, বড় matrix-এর গুণেই বেশির ভাগ সময়; সেখানে int8-এর লাভ overhead ছাপিয়ে গেছে, batch ১-এ সময় প্রায় অর্ধেক।

দ্বিতীয়ত, মান। CNN-এ static int8 ২২৫টি সিদ্ধান্ত বদলেছে কিন্তু মোট accuracy একই; DistilBERT-এ ডিফল্ট int8-এ macro-F1 প্রায় ২ পয়েন্ট পড়েছে, আর per-channel-এ ০.২২-এ নেমেছে। শেষটির কারণ ORT-এর নথিতেই বলা: কিছু x86 CPU-তে int8 গুণের একটি দ্রুত নির্দেশ মাঝপথের যোগফলকে ১৬ bit-এ আটকে দিতে পারে (saturation). Per-channel-এ প্রতিটি সারির ওজন নিজের পুরো −১২৭..১২৭ পরিসর ব্যবহার করে, তাই এই সীমা ছোঁয়ার সম্ভাবনা বাড়ে; reduce-range ওজনকে ৭ bit-এ রেখে জায়গা ছাড়ে। দুটি একসঙ্গে দিলে মান ফিরেছে (০.৯১৯০)। পর্ব ৫৯-এ PyTorch-এর int8-এ একটি সিদ্ধান্তও বদলায়নি; একই নামের পেছনে ভিন্ন kernel, ভিন্ন হার্ডওয়্যার-পথ।

তৃতীয়ত, ব্যর্থতার ধরন। মান ভেঙে পড়ার সময় কোনো error বা warning আসেনি; model চলেছে, উত্তর দিয়েছে, দ্রুতও ছিল। এটাই deploy-এর সবচেয়ে বিপজ্জনক ধরনের ভুল, কারণ health check আর latency-র মাপ সবই সবুজ দেখায়। ধরা পড়েছে শুধু held-out test-এ মান আর বদলানো সিদ্ধান্ত গোনার কারণে।

তাহলে নিয়ম কী? এক, quantize করা ফাইল একটি নতুন model; আসল model-এর সব মূল্যায়ন তার ওপর আবার চালান, বদলানো উত্তরের তালিকাসহ। দুই, গতি মাপুন লক্ষ্য-যন্ত্রে, লক্ষ্য batch আকারে, warm-up-এর পর, p95-সহ; অন্য কারও benchmark বা “int8 মানে চার গুণ” ধরনের নিয়ম এখানে কাজে আসে না। তিন, runtime-এর নথির সতর্কতাগুলো (reduce-range, format, হার্ডওয়্যার) পড়ুন, কারণ আমাদের সবচেয়ে বড় ভুলটির উত্তর সেখানেই লেখা ছিল।

মূল উপলব্ধি: int8-এর লাভ নির্ভর করে model-এর আকার (overhead বনাম গণনা), runtime-এর kernel আর CPU-র নির্দেশের ওপর; মানের ক্ষতি নীরব হতে পারে। প্রতিটি quantize করা রূপকে নতুন model ধরে মান, বদলানো সিদ্ধান্ত আর p95 লক্ষ্য-যন্ত্রে মাপুন।

প্র ০৪বাজারঘর রিভিউয়ের sentiment app-এর ভেতরেই (ফোনে) চালাবে, নাকি server-এ? কোন কোন বিষয় তুলনা করে সিদ্ধান্ত নেবেন?

প্রথমে কাজটি বুঝুন: কে উত্তর দেখবে, কত দ্রুত দরকার, আর ভুল হলে ক্ষতি কত। গ্রাহক রিভিউ লেখার সময় যদি সঙ্গে সঙ্গে “আপনার অভিজ্ঞতা খারাপ মনে হচ্ছে, সাহায্য লাগবে?” দেখাতে চান, latency আর ইন্টারনেট-নির্ভরতা জরুরি। কিন্তু যদি রিভিউগুলো রাতে একসঙ্গে বিশ্লেষণ করে বিক্রেতাকে প্রতিবেদন দেওয়াই লক্ষ্য হয়, তবে server-এ batch-এ চালানোই সহজ আর সস্তা; সেখানে ফোনে model পাঠানোর কোনো কারণ নেই।

ফোনে চালানোর পক্ষে: নেটওয়ার্ক দুর্বল বা বন্ধ থাকলেও চলে, যা গ্রাম বা লোডশেডিং-এর সময়ে কাজের; গ্রাহকের লেখা যন্ত্র ছেড়ে যায় না, যা গোপনীয়তার জন্য ভালো; আর server-এর খরচ ও ভিড় কমে। বিপক্ষে: app-এর আকার বাড়ে (আমাদের int8 DistilBERT-ও প্রায় ১৩০ MiB, যা অনেক কম-দামি ফোনে বড় বোঝা); কম শক্তির CPU-তে সময় বেশি, ব্যাটারি খরচ হয়; আর সবচেয়ে বড়: update আর rollback কঠিন। Server-এ model বদলাতে কয়েক মিনিট, ফোনে নতুন version পৌঁছাতে app store আর ব্যবহারকারীর update-এর ওপর নির্ভর, আর পুরনো version বহু দিন বাজারে থেকে যায়। নজরদারিও কঠিন, কারণ input-output সহজে দেখা যায় না।

তুলনার ছক: আকারের সীমা, p95-এর লক্ষ্য, ইন্টারনেট ছাড়া চলা কতটা জরুরি, গোপনীয়তা, আর update কত ঘন ঘন। প্রার্থী: server-এ বড় model; ফোনে ছোট (distilled বা TF-IDF-এর মতো সরল) model; আর মিশ্র: ফোনের ছোট model প্রথম উত্তর দেয়, confidence কম হলে server-এ পাঠায়। মিশ্র নকশা প্রায়ই বাস্তবসম্মত, তবে দুটি model-এর version সামলাতে হয়। ল্যাপটপের ১ thread শুধু সীমা-পরীক্ষা; শেষ মাপ লক্ষ্য-ফোনেই।

মূল উপলব্ধি: কাজের ধরন (সঙ্গে সঙ্গে বনাম পরে), ইন্টারনেট-নির্ভরতা, গোপনীয়তা, আকার আর update-এর সহজতা পাশাপাশি রেখে বিচার করুন। ফোনে চালালে update আর নজরদারির খরচ বাড়ে; প্রায়ই ছোট on-device model আর server-এর fallback-এর মিশ্র নকশা ভালো সমঝোতা।

প্র ০৫Label আসে সপ্তাহ পরে। এর মধ্যে চালু থাকা deep learning model খারাপ হচ্ছে কি না, কীভাবে বুঝবেন? বাংলা লেখার model-এ বিশেষ কী নজরে রাখবেন?

Sculley ও সহলেখকদের পরিচিত প্রবন্ধ “Hidden Technical Debt in Machine Learning Systems” (NeurIPS ২০১৫) দেখায়, বাস্তব ML system-এ model-এর কোড ছোট অংশ; ডেটা, serving আর নজরদারির অবকাঠামোই বড়, আর ঋণ জমে সেখানে। Model বদলায় না, পৃথিবী বদলায়: নতুন পণ্য, নতুন শব্দ, ঈদের মৌসুমের নতুন অভিযোগ। Label দেরিতে এলে পরোক্ষ লক্ষণ লাগে।

প্রথম স্তর system: p95, error আর ৪২২-এর হার। এগুলো দ্রুত ধরে কোনো client বদলেছে কি না। দ্বিতীয় স্তর input: token-সংখ্যার বণ্টন, অচেনা token-এর হার, আর লেখার হরফের মিশ্রণ। বাংলায় এটি বিশেষ জরুরি: অনেক মানুষ রোমান হরফে বাংলা লেখেন (Banglish), আর বাংলা-ইংরেজি মিশিয়েও লেখেন; শেখানো ডেটায় এর ভাগ কম থাকলে এই অংশ বাড়লে মান পড়তে পারে, যদিও system-এর কোনো error দেখা যাবে না। তৃতীয় স্তর output: প্রতিটি শ্রেণির অনুপাত আর সর্বোচ্চ probability-র বণ্টন। Softmax-এর আত্মবিশ্বাস সাধারণত ভালোভাবে calibrated নয়, তাই এর মান নয়, পরিবর্তনটাই সংকেত।

তুলনা করবেন কীসের সঙ্গে? একটি reference window: যেমন চালুর প্রথম সপ্তাহ, বা validation সেট। প্রতিটি লক্ষণের বণ্টন দুই window-এ মেলানোর জন্য PSI বা KS-এর মতো মাপ মেশিন লার্নিং কোর্সের পর্ব ৬৯-এ আছে। তবে বেশি alert মানে কেউ আর দেখে না; তাই সীমা ঠিক করুন অতীতের স্বাভাবিক ওঠানামা দেখে। আর সস্তা একটি অভ্যাস: প্রতিদিন কিছু উদাহরণ এলোমেলোভাবে বেছে মানুষ দিয়ে label করানো, যাতে label-এর দেরি থাকলেও একটি ছোট, নিরপেক্ষ নমুনায় মান মাপা যায়।

লক্ষণ দেখা দিলে পদক্ষেপ আগে থেকে লিখে রাখুন: কে দেখবেন, কখন rollback, কখন নতুন ডেটায় আবার শেখানো।

মূল উপলব্ধি: Label-এর অপেক্ষায় system, input আর output-এর লক্ষণ reference window-এর সঙ্গে মেলান; বাংলায় হরফের মিশ্রণ আর অচেনা token বিশেষ নজরে রাখুন। প্রতিদিনের ছোট মানব-লেবেল নমুনা আর আগে থেকে লেখা rollback-এর নিয়ম লক্ষণকে কাজে রূপ দেয়।

অনুশীলন

খাতা-কলমে করুন, তারপর উত্তর মেলান। পরীক্ষা আর ইন্টারভিউতে এই ধরনের হিসাব কাজে লাগে।

  1. হিসাব করুন: (ক) একটি ওজনের সারি w = [০.২৫৪, −০.১, ০.০৩০৬, −০.২]. Symmetric int8 (z = ০)-এ scale, প্রতিটি q, ফেরত মান আর ত্রুটি বের করুন। (খ) একটি service-এর ২০টি সময় (ms): ৯.১, ৮.৭, ৯.৪, ১০.২, ৮.৯, ৯.০, ৯.৮, ৩৫.০, ৯.২, ৮.৮, ৯.৫, ৯.৩, ১২.১, ৯.০, ৯.৬, ৮.৬, ৯.৯, ১০.৪, ৯.১, ১৪.৭। Nearest-rank পদ্ধতিতে p50 আর p95, আর গড়। (গ) ৩২-এর একটি batch ১৬ ms-এ শেষ হলে একটি worker প্রতি সেকেন্ডে কতটি input সামলায়?

    (ক) s = ০.২৫৪ ÷ ১২৭ = ০.০০২. q = round(w/s) = [১২৭, −৫০, ১৫, −১০০] (০.০৩০৬/০.০০২ = ১৫.৩ → ১৫)। ফেরত: [০.২৫৪, −০.১, ০.০৩০, −০.২]; ত্রুটি শুধু তৃতীয়টিতে, ০.০০০৬, যা s/2 = ০.০০১-এর কম। (খ) সাজালে: ৮.৬, ৮.৭, ৮.৮, ৮.৯, ৯.০, ৯.০, ৯.১, ৯.১, ৯.২, ৯.৩, ৯.৪, ৯.৫, ৯.৬, ৯.৮, ৯.৯, ১০.২, ১০.৪, ১২.১, ১৪.৭, ৩৫.০। p50: rank ⌈০.৫০ × ২০⌉ = ১০ → ৯.৩ ms; p95: rank ⌈০.৯৫ × ২০⌉ = ১৯ → ১৪.৭ ms; গড় ২২০.৩ ÷ ২০ ≈ ১১.০ ms, একটি ৩৫ ms গড়কে টেনেছে। (গ) ৩২ ÷ ০.০১৬ s = ২,০০০ input/সেকেন্ড; তবে প্রতিটি input-এর latency অন্তত ১৬ ms, সঙ্গে batch ভরার অপেক্ষা।

  2. আউটপুট অনুমান করুন: কোড না চালিয়ে চারটি লাইন লিখুন।
    import numpy as np
    t = np.array([12, 15, 11, 40, 13, 14, 12, 16, 13, 90])
    s = np.sort(t)
    print(s[int(np.ceil(0.5 * 10)) - 1], s[int(np.ceil(0.95 * 10)) - 1])
    print(np.percentile(t, 50), round(t.mean(), 1))
    x = np.array([0.0, 2.55, 1.0]); sc = 2.55 / 255
    print(np.round(x / sc).astype(int))
    print(32 / 0.016)
    13 90
    13.5 23.6
    [  0 255 100]
    2000.0

    সাজানো: ১১, ১২, ১২, ১৩, ১৩, ১৪, ১৫, ১৬, ৪০, ৯০। Nearest-rank-এ p50 পঞ্চম মান ১৩, p95 দশম মান ৯০ (১০টি মাপে p95 মানে সর্বোচ্চ)। NumPy-র ডিফল্ট পদ্ধতি পঞ্চম আর ষষ্ঠের মাঝে: ১৩.৫। গড় ২৩.৬, যা প্রায় কোনো মাপের কাছে নেই। sc = ০.০১, তাই ০, ২৫৫, ১০০। শেষ লাইন throughput: ২,০০০.০।

  3. নকশা সমালোচনা করুন: একটি দলের পরিকল্পনা: “model-এর .onnx ফাইলটি server-এ কপি করব (বাকি ফাইল লাগবে না)। Export করব torch.jit.trace দিয়ে, একটি ছবি উদাহরণ দিয়ে, model.train() অবস্থায়। int8 করলে নিশ্চয়ই চার গুণ দ্রুত হবে, তাই আর মাপব না। Service খুলব 0.0.0.0-এ, ইনপুটের কোনো সীমা ছাড়া, আর নতুন version এলে পুরনো ফাইল মুছে দেব। মান দেখব গড় latency দিয়ে।” অন্তত ছয়টি সমস্যা আর সমাধান বলুন।
    • শুধু .onnx: নতুন exporter ওজন .onnx.data-তে রাখতে পারে, tokenizer বা preprocessing-ও লাগে। সমাধান: সব ফাইল আর version একসঙ্গে package.
    • TorchScript trace: নথিতে deprecated, আর trace শুধু একটি পথ ধরে। সমাধান: torch.export বা torch.onnx.export(dynamo=True).
    • একটি ছবির উদাহরণ: batch ১-এ আটকে যেতে পারে। সমাধান: dynamic_shapes আর ২ বা বেশি আকারের উদাহরণ, অন্য batch-এ পরীক্ষা।
    • int8 না মেপে: আমাদের CNN-এ int8 ধীর হয়েছিল। সমাধান: আকার, মান, বদলানো উত্তর আর p50/p95 মাপা।
    • 0.0.0.0 আর সীমাহীন ইনপুট: অপ্রয়োজনে বাইরে খোলা, বিশাল input server আটকায়। সমাধান: সামনে gateway, Pydantic-এর সীমা, timeout.
    • পুরনো ফাইল মুছে ফেলা: rollback অসম্ভব। সমাধান: version-সহ সব artifact রাখা।
  4. কোড লিখুন: একটি function latency_report(ms, batch) লিখুন, যা মিলিসেকেন্ডের একটি NumPy array আর batch-এর আকার নিয়ে nearest-rank-এ p50, p95 আর প্রতি সেকেন্ডে input-সংখ্যা (batch ÷ মধ্যমা সময়) ফেরত দেয়। অনুশীলন ১-এর ২০টি সময়ে (batch ১) p50 = ৯.৩, p95 = ১৪.৭ আসে কি না যাচাই করুন।
    import numpy as np
    def nearest_rank(ms, q):
        s = np.sort(np.asarray(ms, dtype=float))
        return s[int(np.ceil(q * len(s))) - 1]
    
    def latency_report(ms, batch):
        p50, p95 = nearest_rank(ms, 0.50), nearest_rank(ms, 0.95)
        return {"p50": p50, "p95": p95, "per_s": batch / (p50 / 1000)}
    
    ms = [9.1, 8.7, 9.4, 10.2, 8.9, 9.0, 9.8, 35.0, 9.2, 8.8,
          9.5, 9.3, 12.1, 9.0, 9.6, 8.6, 9.9, 10.4, 9.1, 14.7]
    r = latency_report(ms, 1)
    assert r["p50"] == 9.3 and r["p95"] == 14.7
    print(r)   # per_s প্রায় 107.5

    Nearest-rank সবসময় সত্যিকারের একটি মাপা মান ফেরত দেয়, তাই report-এ সহজে ব্যাখ্যা করা যায়; NumPy-র ডিফল্ট দুটি মানের মাঝে রেখা টানে। মধ্যমা দিয়ে throughput একটি সরল আন্দাজ; আসল throughput মাপতে নির্দিষ্ট সময়ে কতগুলো শেষ হলো তা গুনুন, যেমন অংশ ৭-এর load test-এ।

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

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

ক্রমএকটি শেখানো PyTorch model নিরাপদে চালু করার ধাপগুলো সাজান (▲▼ চেপে সরান)

  1. model.eval() করে ওজন আর preprocessing-এর নিয়ম সংরক্ষণ
  2. Dynamic batch রেখে ONNX বা torch.export-এ export
  3. Runtime-এ চালিয়ে PyTorch-এর সঙ্গে output আর সিদ্ধান্ত মেলানো
  4. Quantize করে আবার মান, বদলানো উত্তর আর p50/p95 মাপা
  5. Version-সহ package করে /health-যুক্ত service-এ চালু
  6. Shadow বা canary, নজরদারি, আর দরকারে rollback

torch.export-এ batch মাত্রাকে dynamic ঘোষণা করে উদাহরণ হিসেবে মাত্র ১টি ছবি (আকার 1 × 1 × 28 × 28) দেওয়া হলো, আর export “Constraints violated … specialized it to be a constant (1)” error দিল। সবচেয়ে সরাসরি সমাধান কোনটি?

  • torch.jit.trace-এ ফিরে যাওয়া
  • ২ বা বেশি ছবির উদাহরণ দিয়ে আবার export করা
  • Dynamic ঘোষণা মুছে দেওয়া
  • Opset বাড়িয়ে দেওয়া

একটি ওজন-tensor-এর সর্বোচ্চ |w| = ০.৩৮১। Symmetric int8 (z = ০, পরিসর −১২৭..১২৭)-এ w = ০.১ কোন পূর্ণসংখ্যায় যায়?

  • ১০
  • ৬৭
  • ৩৩
  • ১২৭

ONNX Runtime-এর কোন quantization পদ্ধতিতে model বানানোর আগে কয়েকশো প্রতিনিধিত্বমূলক input (calibration ডেটা) লাগে?

  • Static quantization
  • Dynamic quantization
  • fp32 export
  • শুধু ওজনের (weight-only) quantization

একটি service-এর ২০টি latency মাপ ছোট থেকে বড়ে সাজানো আছে। Nearest-rank পদ্ধতিতে p95 কোনটি?

  • ২০তম মান
  • ১০তম মান
  • সব মানের গড়
  • ১৯তম মান

নতুন ONNX exporter একটি model লিখেছে model.onnx (১২ KiB) আর model.onnx.data (৪১৩ KiB) ফাইলে। শুধু model.onnx server-এ কপি করলে কী হওয়ার সম্ভাবনা সবচেয়ে বেশি?

  • Model ছোট হয়ে আরও দ্রুত চলবে
  • Session খোলার সময়ই ওজন না পেয়ে ব্যর্থ হবে
  • ওজন এলোমেলো রেখে চলবে, ভুল উত্তর দেবে
  • Runtime ইন্টারনেট থেকে ওজন নামিয়ে নেবে

একটি model-এ batch ১ চলে ২ ms-এ, আর batch ৩২ চলে ২০ ms-এ (একটি worker, অপেক্ষা বাদে)। কোন কথাটি ঠিক?

  • Batch ৩২-এ throughput কমে যায়
  • Batch ৩২-এ প্রতিটি input-এর latency কমে
  • Throughput প্রায় ৩.২ গুণ বাড়ে, কিন্তু প্রতিটি input-এর latency বাড়ে
  • Throughput আর latency দুটিই সমান থাকে

শব্দকোষ

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

মনে রাখুন

  1. Deploy একটি চক্র: train → export → optimise → package → serve → monitor, আর rollback-এর পথ; preprocessing আর tokenizer model-এরই অংশ।
  2. Export: ONNX (dynamo=True, dynamic_shapes, opset) বা torch.export; TorchScript নথিতে deprecated. ১ আকারের উদাহরণে dynamic batch আটকে যায়, আর external data ফাইল একসঙ্গে রাখুন।
  3. প্রথম যাচাই max |diff|: fp32-এ ~10−6 আর সিদ্ধান্ত হুবহু; int8-এ বদলানো উত্তর গুনুন।
  4. int8 = s · (q − z); dynamic-এ activation-এর scale চলার সময়ে, static-এ calibration-এ। ছোট model-এ int8 ধীরও হতে পারে; নিজের যন্ত্রে মাপুন।
  5. Budget p95-এ: batch আর thread বাছুন মাপা p95 আর throughput দেখে; service-এ ইনপুট-সীমা, /health, version আর নজরদারি রাখুন।

পরের পর্বে: পর্ব ৮৫ · GenAI system design-এ বাজারঘরের বাংলা সহায়ক chatbot-এর পুরো নকশা: চাহিদা, token-এ ক্ষমতার হিসাব, RAG, cache, guardrail আর নজরদারি।

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

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