Benchmarking Methodology — যেখানে ভুল দাবি জন্ম নেয়, আর যেভাবে ঠেকানো যায়
Benchmarking Methodology and Statistical Rigor
বেঞ্চমার্কিং-এ যেসব ভুল বারবার হয় — warm-up ভুলে যাওয়া, wall-clock/CPU time গুলিয়ে ফেলা, noise না মেপে একটা run বিশ্বাস করা, আর compiler dead-code elimination — প্রতিটার সঠিক প্রতিকার, আর এখানেই এই ১৭-লেসনের module একত্রিত হয় একটা প্রশ্নে: একটা performance claim সত্যিই প্রতিরক্ষাযোগ্য কি না।
আগে এটা বুঝি
একটা blog post প্রকাশ হলো: “আমরা benchmark করে দেখেছি নতুন algorithm-টা পুরনোটার চেয়ে ৩ গুণ দ্রুত।” হাজার শেয়ার, কোম্পানি সেটা production-এ deploy করলো।
তিন সপ্তাহ পর একজন সন্দিহান engineer সেই benchmark পুনরায় চালালেন, কিন্তু এবার result variable-টা আসলেই ব্যবহার করলেন কোথাও। পার্থক্য উধাও — দুইটা algorithm প্রায় সমান গতির। আসল “৩ গুণ” ফলাফলটা এসেছিল কারণ optimizer পুরনো algorithm-এর benchmark loop-টা সম্পূর্ণ মুছে দিয়েছিল — সেই loop-এর ফলাফল কোথাও ব্যবহৃত হচ্ছিল না, তাই compiler প্রমাণ করে ফেলেছিল পুরো কাজটাই observable কিছু করে না, আর তাই কোনো কাজই করেনি। “৩ গুণ দ্রুত” আসলে ছিল “কিছু না করার সময় বনাম কিছু করার সময়”-এর তুলনা।
এই মডিউলের ১৬টা লেসনে আমরা একটা অসম্ভব বিস্তারিত মানসিক মডেল বানিয়েছি — cache coherence protocol-এর প্রতিটা state, memory ordering-এর প্রতিটা নিয়ম, speculative execution-এর নিরাপত্তা ফাঁক, SIMD-এর register width, roofline-এর ridge point, PMU counter-এর প্রতিটা ঘর। কিন্তু এই সবকিছুরই একটামাত্র উদ্দেশ্য: একটা সংখ্যা বলা যা সত্য।
আর ঠিক এইখানেই — শেষ ধাপে, সংখ্যাটা রিপোর্ট করার মুহূর্তে — বেশিরভাগ ভুল দাবির জন্ম হয়। এই লেসন, এই module-এর শেষ লেসন, সেই ভুলগুলো একে একে ধরবে — আর প্রতিটার প্রতিকার দেবে।
মূল ধারণা
সঠিক benchmarking একটা নিয়মশৃঙ্খলা, একটা একক টুল না — সাতটা পুনরাবৃত্ত ভুল, আর প্রতিটার নির্দিষ্ট প্রতিকার।
সমস্যা ১ — Warm-up: প্রথম run মিথ্যা বলে
প্রথমবার একটা function চালালে সেটা প্রায় সবসময় ধীরতম run — তিনটা ভিন্ন কারণে, ভিন্ন layer-এ:
১. JIT compilation (managed language-এ)। JVM, V8 (JavaScript), .NET-এর মতো runtime প্রথমে bytecode interpret করে — ধীর। একটা function যথেষ্টবার call হলে (একটা “hot” threshold পার হলে) JIT compiler সেটা native machine code-এ কম্পাইল করে — তারপর হঠাৎ কয়েকগুণ দ্রুত হয়ে যায়। প্রথম কয়েকশ call এই ধীর interpreted পর্যায়ে কাটে।
২. Cache warming। Cache hierarchy লেসনের সেই সংখ্যাগুলো মনে করুন — একটা cold cache miss মানে ~২০০ cycle DRAM latency। কোনো data structure প্রথমবার access করলে প্রতিটা access-ই প্রায় cold miss। দ্বিতীয়বার একই data access করলে তা L1/L2/L3-এ থেকে যায় — বহুগুণ দ্রুত। একটা “প্রথম call ধীর” আসলে অনেক সময় শুধুই এই cache warming-এর প্রভাব, algorithm-এর কোনো দোষ না।
৩. CPU frequency scaling / turbo boost। এটা সবচেয়ে পাল্টাপাল্টি — দুই দিকেই যেতে পারে:
- Idle অবস্থা থেকে হঠাৎ কাজ শুরু করলে, thermal headroom থাকায় CPU turbo boost frequency-তে চলে (datasheet base clock-এর চেয়ে বেশি) — প্রথম কয়েক সেকেন্ড বেশি দ্রুত, তারপর তাপ জমতে থাকলে sustained frequency-তে নেমে আসে (throttling)।
- উল্টোদিকে,
powersave/schedutilCPU governor ব্যবহার করা সিস্টেমে idle থেকে কাজ শুরু করলে clock কম frequency-তে শুরু হয়, ধীরে ধীরে বাড়ে — প্রথম কয়েক iteration কম দ্রুত।
প্রতিকার — warm-up iteration আলাদা করা:
warm-up phase: N বার চালান, ফলাফল সম্পূর্ণ বাতিল করুন
measured phase: তারপর M বার চালান, শুধু এগুলোই report করুনN-এর একটা ভালো নিয়ম: যতক্ষণ না সাম্প্রতিক কয়েকটা run-এর সময়
স্থিতিশীল হয় (একটা plateau-তে পৌঁছায়), ততক্ষণ warm-up চালিয়ে যান
— একটা নির্দিষ্ট সংখ্যা (যেমন “৫ বার”) অন্ধভাবে বিশ্বাস করবেন না।
সমস্যা ২ — Wall-clock time বনাম CPU time
দুইটা সম্পূর্ণ ভিন্ন জিনিস, প্রায়ই গুলিয়ে ফেলা হয়:
- Wall-clock time — real, elapsed সময়। Dorওয়ালে ঝোলানো ঘড়ি যা দেখাবে, সেই সময়। এতে অন্তর্ভুক্ত: actual কাজ + I/O-এর জন্য block হয়ে থাকা সময় + scheduler অন্য প্রসেসকে সময় দেওয়ার সময়।
- CPU time — process-টা আসলে CPU-তে executing ছিল এমন
সময়টুকু।
user(application code) +sys(kernel-এ syscall চলাকালীন) সময়ের যোগফল। Block হয়ে থাকার সময় এখানে ধরা হয় না।
$ time ./download_and_process large_file.json
real 0m8.421s ← wall-clock
user 0m0.312s ← CPU time, application code
sys 0m0.089s ← CPU time, kernel/syscallএই উদাহরণটা লক্ষ্য করুন — real (৮.৪ সেকেন্ড) আর user+sys
(মাত্র 0.4 সেকেন্ড)-এর মধ্যে বিশাল ফাঁক। কারণ প্রোগ্রামটা
বেশিরভাগ সময় নেটওয়ার্ক থেকে file download হওয়ার জন্য অপেক্ষা
করছিল — সেই সময় CPU সম্পূর্ণ idle বা অন্য কাজ করছিল, কিন্তু
user-এর কাছে তা এখনো ৮.৪ সেকেন্ডের অপেক্ষা।
যদি আপনি এখানে শুধু CPU time রিপোর্ট করেন (“প্রোগ্রামটা মাত্র
০.৪ সেকেন্ডে চলে!”) — এটা কারিগরিভাবে সত্য কিন্তু user-এর বাস্তব
অভিজ্ঞতাকে সম্পূর্ণ ভুলভাবে উপস্থাপন করে। যদি উল্টোটা করেন — একটা
CPU-heavy, multi-threaded compute-এ শুধু wall-clock দেখান, তাহলে
কতটা কাজ আসলে হলো তার হিসাব বিভ্রান্তিকর হয় (৮টা thread ১ সেকেন্ড
wall-clock-এ চললে সেটা 8 সেকেন্ড CPU-time কাজ করেছে — দুইটা
সংখ্যাই দরকার, ভিন্ন প্রশ্নের উত্তরের জন্য)।
সমস্যা ৩ — একটা run প্রমাণ করে না, distribution লাগে
একটা function ২০ বার চালিয়ে সময় মাপলে (warm-up-এর পর), বাস্তব ফলাফল সাধারণত এরকম দেখায়:
Run Time (ms)
1 11.8
2 12.1
3 11.9
4 12.3
5 12.0
6 45.2 ← outlier (GC pause / OS interrupt)
7 11.7
8 12.2
9 11.9
10 12.4
11 12.0
12 38.7 ← outlier (background cron job কেড়ে নিলো CPU)
13 11.8
14 12.1
15 11.9Mean (গড়): 16.0 ms — দুইটা outlier-এর কারণে বাস্তবের চেয়ে
৩৩% বেশি দেখাচ্ছে।
Median (মধ্যক): 12.0 ms — বাকি ১৩টা “স্বাভাবিক” run-এর
সাথে সঠিকভাবে মেলে, outlier-এর প্রভাব প্রায় শূন্য।
কেন median outlier-এর প্রতি resistant, mean না — mean প্রতিটা মানের সমষ্টি নেয়, তাই একটা চরম মান পুরো ফলাফল টেনে নিতে পারে। Median শুধু “মাঝের মান”-টা নেয় — কতগুলো মান কত চরম তা গুরুত্বপূর্ণ না, শুধু তাদের অবস্থান (rank) গুরুত্বপূর্ণ।
নিয়ম: বেঞ্চমার্কে সবসময় median রিপোর্ট করুন, mean নয় — যদি না আপনি নিশ্চিত হন যে distribution outlier-মুক্ত।
সমস্যা ৪ — Percentile: কেন শুধু median-ও যথেষ্ট না
উপরের একই dataset-এ median (12.0 ms) বলে “typical run ভালো।”
কিন্তু এটা 38.7 আর 45.2 ms-এর মতো spike সম্পূর্ণভাবে আড়াল
করে দেয়।
যদি এটা একটা web service-এর response time হতো, আর এই distribution-এর
মতো প্রতি ১৫টা request-এর ২টা ~40ms-এ গিয়ে ঠেকতো (median-এর প্রায়
৩.৫ গুণ), তাহলে সেই ২/১৫ ≈ ১৩% user একটা লক্ষণীয়ভাবে ধীর
অভিজ্ঞতা পাচ্ছেন — যা শুধু median দেখে সম্পূর্ণ অদৃশ্য।
Percentile এই তথ্যটা উদ্ধার করে:
উপরের ১৫টা sample-এ (sorted): p95 (উপরের ৫%) outlier
range-এর কাছাকাছি পড়বে (~39-45 ms), স্পষ্টভাবে দেখাবে “খারাপ
ক্ষেত্রে” ব্যবহারকারীর অভিজ্ঞতা কেমন।
সমস্যা ৫ — System noise নিয়ন্ত্রণ
Run-to-run variance-এর একটা বড় অংশ আসে benchmark-এর বাইরের কারণ থেকে:
- প্রতিযোগী প্রসেস — অন্য কোনো process cache/core-এর জন্য
প্রতিযোগিতা করলে আপনার benchmark-এর cache line evict হয়ে যেতে
পারে, অথবা scheduler আপনার thread-কে সাময়িকভাবে সরিয়ে অন্য
প্রসেসকে সময় দিতে পারে। প্রতিকার: benchmark চালানোর সময়
অপ্রয়োজনীয় সব প্রসেস বন্ধ রাখুন, বা
taskset/sched_setaffinityদিয়ে আপনার প্রসেসকে নির্দিষ্ট core-এ pin করুন, যাতে অন্তত সেই core-গুলো একচেটিয়া থাকে। - Thermal throttling — দীর্ঘ, sustained heavy load ধাপে ধাপে
CPU গরম করে, আর একটা নির্দিষ্ট তাপমাত্রার পরে hardware নিজে থেকেই
clock speed কমিয়ে দেয় (thermal throttling) — চাহিদা না কমলেও।
একটা দীর্ঘ benchmark-এ throughput সময়ের সাথে ধীরে ধীরে কমে
যেতে পারে শুধু এই কারণে, algorithm-এর কোনো দোষ ছাড়াই। প্রতিকার:
পুরো run জুড়ে CPU frequency/temperature monitor করুন
(
/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freqবাturbostat), আর throttling ধরা পড়লে হয় cooldown period দিন, নয়তো ফলাফলে সেটা স্পষ্টভাবে উল্লেখ করুন। - NUMA placement — NUMA লেসনে আমরা দেখেছি remote-node memory
access local-এর চেয়ে ধীর। যদি benchmark thread একটা NUMA node-এর
core-এ চলে কিন্তু তার memory ভিন্ন node-এ allocate হয়ে যায় (OS
scheduler-এর এলোমেলো সিদ্ধান্তে), ফলাফল আসলে algorithm না,
placement-এর দুর্ঘটনা মাপছে। প্রতিকার:
numactl --cpunodebind=0 --membind=0দিয়ে CPU আর memory একই node-এ pin করুন — অথবা, যদি cross-node আচরণই আপনার লক্ষ্য হয়, সেটা explicitly করুন আর স্পষ্টভাবে বলুন যে সেটাই মাপা হচ্ছে।
সমস্যা ৬ — Confidence interval: একটা সংখ্যার চেয়ে বেশি বলা
n টা measured run থেকে শুধু একটা median/mean রিপোর্ট করাও পুরো
ছবি দেয় না — সেই সংখ্যাটার উপর কতটা আস্থা রাখা যায় তা বলে না।
Standard error থেকে একটা approximate 95% confidence interval:
যেখানে s = sample standard deviation, n = sample সংখ্যা,
x̄ = sample mean।
উদাহরণ — দুইটা version তুলনা:
| Version | Mean | Stddev | n | SE | 95% CI |
|---|---|---|---|---|---|
| A (আগের কোড) | 10.2 ms | 0.6 ms | 30 | 0.110 | [9.99, 10.42] |
| B (নতুন কোড) | 10.4 ms | 0.9 ms | 30 | 0.164 | [10.08, 10.72] |
দুইটা interval overlap করে ([10.08, 10.42]) — অর্থাৎ, এই
data দিয়ে আত্মবিশ্বাসের সাথে বলা যায় না যে A দ্রুত, যদিও
A-এর sample mean সংখ্যাগতভাবে ছোট। পার্থক্যটা noise-এর মধ্যেই পড়ে
যেতে পারে।
কত sample যথেষ্ট — statistical power-এর সংক্ষিপ্ত ধারণা
SE = stddev / sqrt(n) সমীকরণটা লক্ষ্য করুন — sample সংখ্যা (n)
বাড়ালে SE কমে, কিন্তু √n-এর হারে, n-এর হারে না। এর
একটা ব্যবহারিক তাৎপর্য আছে যা প্রায়ই ভুল বোঝা হয়।
CI-কে অর্ধেক ছোট করতে (দ্বিগুণ নির্ভুলতা পেতে), sample সংখ্যা দ্বিগুণ না, চারগুণ করতে হয়:
n | Relative SE (n=30-এর তুলনায়) |
|---|---|
| 30 | 1.00× |
| 60 | 0.71× |
| 120 | 0.50× |
| 480 | 0.25× |
ব্যবহারিক ফলাফল — diminishing returns। 30 থেকে 120 run-এ
যাওয়া CI অর্ধেক করে (ভালো বিনিয়োগ)। কিন্তু 120 থেকে 480-এ
যেতে ৪ গুণ বেশি সময় ব্যয় করে একই অনুপাতে (আরেকবার অর্ধেক)
উন্নতি পাওয়া যায়। একটা point-এর পর বেশি run চালানো সময়ের অপচয় —
সেই সময়টা বরং আসল সমস্যা (noise-এর উৎস কমানো, যেমন CPU pinning)
সমাধানে ব্যয় করা ভালো।
সমস্যা ৭ — Dead-code elimination: microbenchmark যা কিছুই মাপে না
এই লেসনের ভূমিকার গল্পটা এই সমস্যা থেকেই এসেছে, আর এটা সবচেয়ে বিপজ্জনক কারণ ফলাফল প্রায়ই বিশ্বাসযোগ্য দেখায় — শুধু ভুলভাবে দ্রুত।
// একটা "microbenchmark" — কিন্তু একটা মারাত্মক bug সহ
#include \<time.h>
int main(void) {
double result = 0.0;
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (long i = 0; i \< 100000000; i++) {
result = result * 1.0000001 + 0.0000001; // "কাজ"
}
clock_gettime(CLOCK_MONOTONIC, &t1);
double dt = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
printf("সময়: %f সেকেন্ড\n", dt);
// লক্ষ্য করুন — `result` এখানে আর কোথাও ব্যবহৃত হয়নি!
return 0;
}gcc -O2 -o dce_bug dce_bug.c && ./dce_bug
সময়: 0.000000 সেকেন্ডশূন্য সেকেন্ড। -O2 optimizer একটা সহজ observation করেছে:
result-এর চূড়ান্ত মান কোথাও পড়া হচ্ছে না, print হচ্ছে না, কোনো
global-এ যাচ্ছে না — অর্থাৎ পুরো for loop-টার কোনো observable
প্রভাব নেই প্রোগ্রামের বাকি অংশে। যেহেতু compiler প্রমাণ করতে
পারে loop-টা মুছে দিলেও প্রোগ্রামের আচরণ (as observed) অভিন্ন
থাকবে, C standard-এর “as-if rule” অনুযায়ী পুরো loop-টা সম্পূর্ণ
মুছে ফেলা সম্পূর্ণ বৈধ — আর compiler ঠিক তাই করে।
ফলাফল: আপনি ভেবেছিলেন 10⁸ টা FMA operation মাপছেন। আসলে
মেপেছেন “কিছুই না করার সময়”।
আপনি লিখেছেন: compiler as-if rule প্রয়োগ করার পর:
┌─────────────────┐ ┌─────────────────┐
│ for (...) { │ │ │
│ result = ... │ → │ (কিছুই নেই — │
│ } │ │ loop মুছে গেছে)│
│ // result unused │ │ │
└─────────────────┘ └─────────────────┘
"১০ কোটি iteration" "০ instruction"প্রতিকার — compiler-কে বলুন ফলাফলটা “escape” করতে পারে বলে ধরে
নিতে (black_box / opaque-use)।
Rust-এ, standard, portable উপায়:
use std::hint::black_box;
let mut result = 0.0_f64;
for _ in 0..100_000_000 {
result = black_box(result) * 1.0000001 + 0.0000001;
}
black_box(result); // চূড়ান্ত মানও escape করাতে হবেblack_box একটা identity function — যা পায় তাই ফেরত দেয় — কিন্তু
compiler-কে এর ভেতরটা দেখতে দেয় না। ফলে compiler ধরে নিতে
বাধ্য হয় এই মানটা সত্যিই কোথাও ব্যবহৃত হতে পারে, তাই পুরো computation
মুছে ফেলা যাবে না। ভেতরে বাস্তবে কোনো real instruction যোগ হয় না
(শুধু একটা compiler-level optimization barrier) — তাই measured
সময়ে প্রায় শূন্য অতিরিক্ত খরচ যোগ হয়।
C/C++-এ সমতুল্য কৌশল (কোনো official standard library helper না থাকায় hand-rolled trick লাগে):
// একটা inline-asm compiler barrier — "escape hatch"
static inline void do_not_optimize(double x) {
asm volatile("" : : "r"(x) : "memory");
}
int main(void) {
double result = 0.0;
for (long i = 0; i \< 100000000; i++) {
result = result * 1.0000001 + 0.0000001;
// (চাইলে loop-এর বাইরে একবারও যথেষ্ট, নিচে দেখুন)
}
do_not_optimize(result); // compiler এখন এটা মুছতে পারবে না
return 0;
}gcc -O2 -o dce_fixed dce_fixed.c && ./dce_fixed
# এখন সময় বাস্তবসম্মত — শূন্য নয়C++-এ Google Benchmark library এই একই কৌশল একটা পোর্টেবল,
প্রস্তুত ফাংশনে মুড়ে দিয়েছে: benchmark::DoNotOptimize(result) আর
benchmark::ClobberMemory()।
ভেতরে কী ঘটছে
black_box আসলে কী কম্পাইল হয়
black_box/do_not_optimize-এর নিচে সাধারণত একটা inline
assembly block থাকে যাতে কোনো real instruction নেই — শুধু
compiler-কে বলা একটা metadata: “এই value-টা এখানে ধরে নাও ব্যবহৃত
হয়েছে (একটা register বা memory-তে), আর ‘memory’ clobber বলছে এই
বিন্দুর পরে তুমি আগের কোনো memory-related assumption বহন করতে
পারবে না।”
এটা compiler-এর optimization pass-এর জন্য একটা অস্বচ্ছ (opaque) বাধা — data-flow analysis এই বিন্দুতে থেমে যায়, value-টাকে “escaped” হিসেবে গণ্য করা হয়, dead-code elimination pass তাই এটাকে ছুঁতে পারে না। কোনো CPU cycle এর জন্য বাড়তি খরচ হয় না (কোনো actual instruction generate হয় না বললেই চলে, বড়জোর একটা register-এর মান স্থির রাখার জন্য সামান্য চাপ) — এটাই কারণ এই কৌশল measured সময়কে বিকৃত করে না, শুধু dead-code elimination-কে থামায়।
CPU frequency governor — reproducibility বনাম বাস্তবতা
Linux-এ CPU governor (performance, powersave, schedutil)
নির্ধারণ করে CPU কীভাবে load অনুযায়ী frequency বদলায়। Benchmark-এর
জন্য একটা সাধারণ পরামর্শ:
sudo cpupower frequency-set --governor performanceএটা CPU-কে সবসময় সর্বোচ্চ non-turbo frequency-তে রাখতে বাধ্য করে — run-to-run frequency-জনিত variance অনেকটা কমে যায়, ফলাফল বেশি reproducible।
কিন্তু একটা tension আছে — production-এ CPU সাধারণত schedutil-এর
মতো dynamic governor-এ চলে, যেখানে frequency লোড অনুযায়ী বদলায়।
performance governor-এ মাপা সংখ্যা তাই “সবচেয়ে reproducible”
কিন্তু “production-এর সবচেয়ে বিশ্বস্ত প্রতিনিধি” নাও হতে পারে।
সঠিক পছন্দ নির্ভর করে প্রশ্নের উপর — “এই দুইটা algorithm-এর মধ্যে
কোনটা মৌলিকভাবে দ্রুত” জানতে performance ভালো (noise কম); “user
বাস্তবে কী latency পাবে” জানতে production governor-এই মাপা উচিত।
উদাহরণ
একটা সম্পূর্ণ, সঠিকভাবে করা benchmark
সব সমস্যার প্রতিকার একসাথে প্রয়োগ করে একটা বাস্তবসম্মত harness:
#!/bin/bash
# সঠিক benchmark run — noise কমানো, warm-up আলাদা করা
sudo cpupower frequency-set --governor performance # frequency স্থির
taskset -c 2 numactl --cpunodebind=0 --membind=0 \
./bench --warmup=10 --iterations=100#include \<stdio.h>
#include \<time.h>
#include \<stdlib.h>
static inline void do_not_optimize(long x) {
asm volatile("" : : "r"(x) : "memory");
}
double run_once(long n) {
long acc = 0;
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (long i = 0; i \< n; i++) {
acc += (i * 2654435761u) % 97; // real, non-trivial কাজ
}
clock_gettime(CLOCK_MONOTONIC, &t1);
do_not_optimize(acc);
return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}
int cmp_double(const void *a, const void *b) {
double da = *(const double *)a, db = *(const double *)b;
return (da > db) - (da \< db);
}
int main(int argc, char **argv) {
long n = 10000000;
int warmup = 10, iters = 100;
for (int i = 0; i \< warmup; i++) run_once(n); // ফলাফল বাতিল
double *times = malloc(iters * sizeof(double));
for (int i = 0; i \< iters; i++) times[i] = run_once(n);
qsort(times, iters, sizeof(double), cmp_double);
double sum = 0;
for (int i = 0; i \< iters; i++) sum += times[i];
double mean = sum / iters;
double median = times[iters / 2];
double p95 = times[(int)(iters * 0.95)];
double p99 = times[(int)(iters * 0.99)];
double var = 0;
for (int i = 0; i \< iters; i++) var += (times[i] - mean) * (times[i] - mean);
double stddev = sqrt(var / (iters - 1));
double se = stddev / sqrt(iters);
double ci95 = 1.96 * se;
printf("mean : %.4f s\n", mean);
printf("median : %.4f s\n", median);
printf("p95 : %.4f s\n", p95);
printf("p99 : %.4f s\n", p99);
printf("95%% CI : [%.4f, %.4f]\n", mean - ci95, mean + ci95);
free(times);
return 0;
}প্রত্যাশিত (illustrative) output:
mean : 0.0124 s
median : 0.0121 s
p95 : 0.0138 s
p99 : 0.0156 s
95% CI : [0.0122, 0.0126]লক্ষ্য করুন এই একটা রিপোর্টে সাতটা সমস্যার প্রতিটাই সমাধান করা
আছে: warm-up আলাদা (১০ বার বাতিল), CPU time না, wall-clock ব্যবহৃত
(CLOCK_MONOTONIC), median আছে mean-এর পাশাপাশি, p95/p99
tail দেখাচ্ছে, taskset+numactl দিয়ে noise/NUMA নিয়ন্ত্রিত,
confidence interval আছে, আর do_not_optimize dead-code elimination
ঠেকিয়েছে।
এটাই সেই মান যা একটা performance claim প্রকাশ করার আগে পৌঁছানো
উচিত — একটা printf("সময়: %f\n", t) তার তুলনায় অনেক দূরে।
নিজে চালিয়ে দেখুন
Dead-code elimination-এর ফাঁদে নিজে পড়ুন, তারপর বেরিয়ে আসুন
#include \<stdio.h>
#include \<time.h>
double now(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec + t.tv_nsec / 1e9;
}
static inline void do_not_optimize(double x) {
asm volatile("" : : "r"(x) : "memory");
}
int main(void) {
const long N = 200000000;
// ── সংস্করণ ১: bug সহ — ফলাফল কখনো ব্যবহার হয় না ──
double t0 = now();
double result_buggy = 0.0;
for (long i = 0; i \< N; i++) {
result_buggy = result_buggy * 1.0000001 + 0.0000001;
}
double t1 = now();
printf("বাগ-সহ সংস্করণ : %.6f সেকেন্ড\n", t1 - t0);
// ── সংস্করণ ২: একই কাজ, কিন্তু ঠিকভাবে protect করা ──
double t2 = now();
double result_fixed = 0.0;
for (long i = 0; i \< N; i++) {
result_fixed = result_fixed * 1.0000001 + 0.0000001;
}
do_not_optimize(result_fixed);
double t3 = now();
printf("ঠিক করা সংস্করণ : %.6f সেকেন্ড\n", t3 - t2);
return 0;
}gcc -O2 -o dce_demo dce_demo.c && ./dce_demoসাধারণ (illustrative) output:
বাগ-সহ সংস্করণ : 0.000000 সেকেন্ড
ঠিক করা সংস্করণ : 0.412873 সেকেন্ডহুবহু একই loop, হুবহু একই গণনা — একটা মাত্র লাইনের পার্থক্য
(do_not_optimize call), আর সময়ের পার্থক্য শূন্য থেকে ৪১২
মিলিসেকেন্ড। -O0-এ (optimization বন্ধ) কম্পাইল করে দেখুন —
তখন দুইটা সংস্করণই প্রায় একই সময় নেবে, কারণ optimizer কিছুই মুছে
ফেলার সুযোগ পায়নি:
gcc -O0 -o dce_demo_o0 dce_demo.c && ./dce_demo_o0
বাগ-সহ সংস্করণ : 0.398214 সেকেন্ড
ঠিক করা সংস্করণ : 0.401556 সেকেন্ডএই differential-ই diagnostic — যদি -O0-এ দুইটা সংস্করণ
প্রায় সমান কিন্তু -O2-এ একটা শূন্য হয়ে যায়, সেটা নিশ্চিত প্রমাণ
যে dead-code elimination ঘটেছে।
একটা microbenchmark-এর ফলাফল ব্যবহার না করলে compiler পুরো কাজটাই মুছে দিতে পারে, আর সেই 'benchmark' তখন আসলে শূন্য সময় মাপে — সংখ্যাটা দেখতে বৈধ মনে হলেও সম্পূর্ণ অর্থহীন।
নিজে বানান
পরিসংখ্যানগতভাবে কঠোর Microbenchmark Harness
- Warm-up iteration আলাদাভাবে চালিয়ে বাতিল করুন, শুধু measured phase-এর সময় সংগ্রহ করুন
- Median, p95, p99, mean, stddev, আর 95% confidence interval হিসাব করুন
- দুইটা configuration-এর confidence interval overlap করে কি না স্বয়ংক্রিয়ভাবে যাচাই করুন
- Overlap থাকলে speedup দাবি না করে "পার্থক্য পরিসংখ্যানগতভাবে অনিশ্চিত" বলে সতর্ক করুন
import time, math, statistics
def benchmark(fn, warmup=10, iters=100):
for _ in range(warmup):
fn() # ফলাফল ব্যবহার হচ্ছে fn নিজের ভেতরে (নিচে দেখুন) — বাতিল শুধু সময়
times = []
for _ in range(iters):
t0 = time.perf_counter()
fn()
times.append(time.perf_counter() - t0)
times.sort()
n = len(times)
mean = statistics.fmean(times)
median = times[n // 2]
p95 = times[int(n * 0.95)]
p99 = times[int(n * 0.99)]
stddev = statistics.pstdev(times)
se = stddev / math.sqrt(n)
ci95 = 1.96 * se
return {
"mean": mean, "median": median, "p95": p95, "p99": p99,
"ci_low": mean - ci95, "ci_high": mean + ci95, "n": n,
}
def compare(name_a, stats_a, name_b, stats_b):
print(f"\n{name_a}: median={stats_a['median']*1000:.3f}ms "
f"95% CI=[{stats_a['ci_low']*1000:.3f}, {stats_a['ci_high']*1000:.3f}]ms")
print(f"{name_b}: median={stats_b['median']*1000:.3f}ms "
f"95% CI=[{stats_b['ci_low']*1000:.3f}, {stats_b['ci_high']*1000:.3f}]ms")
overlap = not (stats_a["ci_high"] \< stats_b["ci_low"] or
stats_b["ci_high"] \< stats_a["ci_low"])
if overlap:
print("→ 95% confidence interval overlap করছে — পরিসংখ্যানগতভাবে "
"তাৎপর্যপূর্ণ পার্থক্য প্রমাণিত হয়নি। speedup দাবি করবেন না।")
else:
faster = name_a if stats_a["median"] \< stats_b["median"] else name_b
print(f"→ Interval আলাদা — {faster} পরিসংখ্যানগতভাবে দ্রুত বলা যায়।")
# ── ব্যবহারের উদাহরণ ──
def version_a():
s = 0
for i in range(100000): s += i * i
return s
def version_b():
s = sum(i * i for i in range(100000))
return s
stats_a = benchmark(version_a)
stats_b = benchmark(version_b)
compare("version_a (manual loop)", stats_a, "version_b (generator)", stats_b)প্রত্যাশিত ধরনের output:
version_a (manual loop): median=8.234ms 95% CI=[8.198, 8.271]ms
version_b (generator): median=9.876ms 95% CI=[9.821, 9.931]ms
→ Interval আলাদা — version_a (manual loop) পরিসংখ্যানগতভাবে দ্রুত বলা যায়।নিজে বাড়ান:
warmup/itersসংখ্যা কমিয়ে দেখুন — কত কম sample-এ CI এত চওড়া হয়ে যায় যে কোনো তুলনাই আর সম্ভব থাকে না?- একটা কৃত্রিম outlier যোগ করুন (
time.sleep(0.05)মাঝে মাঝে) — mean আর median কতটা আলাদা হয়ে যায় দেখুন p99দুইটা version-এর জন্য আলাদা করে তুলনা করুন — median-এ যে version জিতেছে,p99-এও কি জেতে? (প্রায়ই না — worst-case আচরণ ভিন্ন কারণে ভিন্ন হয়)- Python-এর নিজস্ব GC pause simulate করতে
gc.collect()মাঝে মাঝে জোর করে চালিয়ে দেখুন outlier কেমন দেখতে লাগে বাস্তবে
বাস্তব সিস্টেমে
Benchmarking methodology যেখানে সত্যিই গুরুত্বপূর্ণ
একাডেমিক systems research-এর measurement bias সমস্যা। Mytkowicz et al.-এর বিখ্যাত ২০০৯ পেপার দেখিয়েছে কীভাবে সম্পূর্ণ “অপ্রাসঙ্গিক” জিনিস — যেমন shell-এ environment variable-এর মোট আকার (যা stack alignment বদলে দেয়), বা linking-এর সময় object file-এর ক্রম — একটা compiler optimization-এর measured effect-কে completely উল্টে দিতে পারে। এই paper-টাই systems community-কে বাধ্য করেছিল benchmark methodology-কে অনেক বেশি গুরুত্বের সাথে নিতে।
Google Benchmark ও Rust-এর Criterion.rs। দুইটাই এই লেসনের
প্রায় প্রতিটা প্রতিকার by-default প্রয়োগ করে — warm-up, একাধিক
run, statistical outlier detection, dead-code elimination protection
(DoNotOptimize/black_box)। একটা industry-standard benchmarking
library ব্যবহার করা প্রায়ই hand-rolled time.time() কোডের চেয়ে
বেশি নির্ভরযোগ্য, ঠিক এই কারণেই।
Database benchmark — TPC-C/TPC-H। এই audited, standardized benchmark-গুলোর নিয়মাবলীতে explicit ভাবে বলা থাকে কত run লাগবে, warm-up period কত, আর ফলাফল কীভাবে রিপোর্ট করতে হবে — কারণ vendor-রা নিজেদের সুবিধামতো number দেখানোর ইতিহাস আছে বলেই এত কড়াকড়ি প্রয়োজন হয়েছিল।
Cloud SLA-তে percentile-ভিত্তিক guarantee। AWS, GCP-র মতো
cloud provider তাদের latency SLA প্রায় সবসময় p99 বা p999-এ
সংজ্ঞায়িত করে, average-এ না — কারণ ব্যবসায়িক প্রভাব (একজন user-এর
রাগান্বিত হয়ে চলে যাওয়া) tail-এর সাথে সম্পর্কিত, average-এর সাথে
না।
Continuous benchmarking in CI। Rust compiler, Chromium-এর মতো প্রজেক্ট প্রতিটা commit-এ automated performance regression test চালায় — কিন্তু naive “আগের চেয়ে ধীর হলে fail” rule false positive-এ ভরে যায় CI machine-এর noise-এর কারণে। আধুনিক সিস্টেমগুলো এই লেসনের confidence-interval overlap পরীক্ষাই ব্যবহার করে regression সত্যিই statistically significant কি না যাচাই করতে।
Game/graphics benchmark — frame time percentile। শুধু average
FPS রিপোর্ট করা পুরনো পদ্ধতি — আধুনিক game review frame-time-এর
p99/p999 দেখায় (তথাকথিত “1% low” / “0.1% low” FPS), কারণ একটা
occasional frame-time spike (stutter) খেলোয়াড়ের কাছে গড় FPS-এর
চেয়ে বেশি লক্ষণীয়।
Hardware review methodology। সিরিয়াস CPU/GPU benchmark সাইট থার্মাল throttling নিয়ন্ত্রণে রাখতে দীর্ঘ cooldown period ব্যবহার করে, ambient temperature নিয়ন্ত্রণ করে, আর একাধিক run-এর median রিপোর্ট করে — ঠিক এই লেসনের নীতি অনুসরণ করেই একটা “৫% দ্রুত” দাবি বিশ্বাসযোগ্য করা হয়।
যে ভুলগুলো সবাই করে
“একটা run-এর সময় দেখেই যথেষ্ট, যদি সংখ্যাটা 'reasonable' মনে হয়।”
“Reasonable মনে হওয়া” measurement validity-র কোনো প্রমাণ না — এটা শুধু আপনার প্রত্যাশার সাথে মিলে যাওয়া।
উপরের একটা মাত্র run হয়তো একটা lucky (বা unlucky) outlier হতে পারে — একটা background process ঠিক সেই মুহূর্তে cache flush করে দিয়েছিল, বা turbo boost ঠিক তখনই সক্রিয় ছিল। কমপক্ষে ১৫-৩০টা run ছাড়া variance সম্পর্কে কোনো ধারণাই পাওয়া যায় না — আর variance না জানলে একটা single number-এর অর্থ নেই।
নিয়ম: সবসময় একাধিক run, সবসময় distribution রিপোর্ট (median + percentile), কখনো একটা সংখ্যায় সন্তুষ্ট থাকবেন না।
“Mean সবসময় সঠিক সারাংশ পরিসংখ্যান।”
শুধু তখনই, যখন distribution symmetric আর outlier-মুক্ত — বাস্তব performance measurement প্রায়ই তা না। GC pause, page fault, context switch — এসব ডানদিকে skewed (একদিকে লম্বা tail থাকা) distribution তৈরি করে, যেখানে mean বিভ্রান্তিকরভাবে বড় দেখায়।
এই লেসনের নিজস্ব উদাহরণে mean (16.0ms) typical run-এর (12.0ms)
চেয়ে 33% বেশি ছিল — শুধু দুইটা outlier-এর কারণে। Median এই
বিকৃতি প্রতিরোধ করে।
নিয়ম: performance data-তে ডিফল্ট হিসেবে median ব্যবহার করুন, mean-কে শুধু একটা সম্পূরক তথ্য হিসেবে রাখুন।
“Wall-clock time সবসময় সঠিক জিনিস মাপা, এটাই 'আসল' সময়।”
Wall-clock user-perceived latency-র জন্য সঠিক, কিন্তু “কতটা কাজ হলো” বা “কত resource খরচ হলো” প্রশ্নের জন্য না।
একটা program যদি একটা slow network call-এর জন্য ৮ সেকেন্ড অপেক্ষা
করে কিন্তু মাত্র 0.4 সেকেন্ড CPU ব্যবহার করে, তাহলে wall-clock
(8s) দিয়ে “প্রোগ্রামটা CPU-ভারী” বলাটা ভুল — সেই সময়ের প্রায়
পুরোটাই idle, অপেক্ষা।
উল্টোদিকে, cloud billing-এ vCPU-সেকেন্ড দিয়ে খরচ হিসাব করার সময় wall-clock দেখা ভুল — সেখানে CPU time-ই আসল খরচ নির্ধারণ করে (multi-threaded প্রোগ্রামে wall-clock-এর চেয়ে অনেক বেশি হতে পারে)।
নিয়ম: প্রশ্নটা প্রথমে ঠিক করুন — “user কতক্ষণ অপেক্ষা করলো” (wall-clock) নাকি “সিস্টেম কতটা কাজ করলো” (CPU time) — তারপর সঠিক metric বাছুন।
“Optimized (-O2/-O3) build-এ চালানো একটা microbenchmark স্বয়ংক্রিয়ভাবে বাস্তব কাজ মাপে।”
সরাসরি ভুল, আর এই লেসনের সবচেয়ে বিপজ্জনক ভুল — কারণ ফলাফল দেখতে সম্পূর্ণ স্বাভাবিক লাগে (একটা ছোট, ইতিবাচক সংখ্যা), শুধু ভুলভাবে ছোট।
-O2/-O3 optimizer আক্রমণাত্মকভাবে dead code সরিয়ে দেয় — যদি
আপনার benchmark loop-এর ফলাফল কোথাও ব্যবহৃত না হয় (কোনো side
effect, কোনো output, কোনো return value observable না), পুরো
computation-টাই legally মুছে ফেলা যায়। এই লেসনের নিজস্ব পরীক্ষায়
আমরা 100,000,000-বার FMA করার একটা loop-কে সম্পূর্ণ শূন্য
সেকেন্ডে নেমে যেতে দেখেছি ঠিক এই কারণে।
নিয়ম: প্রতিটা microbenchmark-এর ফলাফল অবশ্যই black_box
(Rust), benchmark::DoNotOptimize (C++ Google Benchmark), অথবা
একটা hand-rolled compiler-barrier (asm volatile) দিয়ে “escape”
করাতে হবে — input-সহ, শুধু output না। এই একটা লাইন না থাকলে পুরো
benchmark সম্ভাব্যভাবে অর্থহীন।
বুঝেছেন কি না দেখুন
1একটা benchmark-এ প্রথম ৫টা run যথাক্রমে 45ms, 38ms, 22ms, 15ms, 12ms — ক্রমাগত কমছে, তারপর ~11-12ms-এ স্থিতিশীল হয়ে যায়। এটা
কী নির্দেশ করে, আর সঠিক প্রতিক্রিয়া কী?
যুক্তি
45ms, 38ms, 22ms, 15ms, 12ms — ক্রমাগত কমছে, তারপর ~11-12ms-এ স্থিতিশীল হয়ে যায়। এটা
কী নির্দেশ করে, আর সঠিক প্রতিক্রিয়া কী?এটা classic warm-up signature — একাধিক কারণ একসাথে কাজ করতে পারে: cache warming (প্রথম access-এ সব cold miss, ধীরে ধীরে data cache-এ থিতু হচ্ছে), JIT compilation (যদি managed language হয় — interpreted থেকে native code-এ transition), বা CPU frequency ramp-up (powersave governor-এ idle থেকে কাজ শুরু করলে frequency ধীরে ধীরে বাড়ে)।
সঠিক প্রতিক্রিয়া: এই প্রথম ৫টা (বা যতগুলো লাগে “সমতল” পর্যন্ত
পৌঁছাতে) run-কে warm-up হিসেবে সম্পূর্ণ বাতিল করুন। শুধু
স্থিতিশীল হওয়ার পরের run-গুলো (~11-12ms অঞ্চলে) measured phase
হিসেবে ব্যবহার করুন, এবং সেগুলো থেকেই median/percentile হিসাব
করুন। প্রথম ৫টা include করলে পুরো ফলাফল বাস্তবের চেয়ে ধীর দেখাবে
— একটা “cold start” পরিস্থিতির সংখ্যা, “steady state” না।
2Version X: mean =8.1ms, 95% CI = [7.9, 8.3]। Version Y: mean
=8.6ms, 95% CI = [8.2, 9.0]। আপনি কি বলতে পারবেন “X, Y-এর চেয়ে
পরিসংখ্যানগতভাবে দ্রুত”? ব্যাখ্যা করুন।
প্রয়োগ
=8.1ms, 95% CI = [7.9, 8.3]। Version Y: mean
=8.6ms, 95% CI = [8.2, 9.0]। আপনি কি বলতে পারবেন “X, Y-এর চেয়ে
পরিসংখ্যানগতভাবে দ্রুত”? ব্যাখ্যা করুন।হ্যাঁ, এইবার বলা যায় — এটা আগের overlap-এর উদাহরণ থেকে আলাদা।
X-এর CI: [7.9, 8.3]। Y-এর CI: [8.2, 9.0]। এই দুইটা range
সামান্য overlap করছে বলে মনে হতে পারে (8.2 থেকে 8.3 পর্যন্ত),
তাই সতর্কভাবে দেখা দরকার।
আসলে overlap আছে ([8.2, 8.3]), তাই কঠোরভাবে বললে সম্পূর্ণ
নিশ্চিত দাবি করা উচিত না — যদিও overlap অংশটা খুব ছোট (0.1ms,
মোট range-এর তুলনায় নগণ্য), এটা একটা weak/borderline সংকেত।
সঠিক, সৎ উত্তর: “X সম্ভবত দ্রুত (mean পার্থক্য ও বেশিরভাগ CI range আলাদা), কিন্তু interval-এর সামান্য overlap থাকায় দাবিটা শক্তিশালী প্রমাণ না — বেশি sample নিয়ে (n বাড়িয়ে, যা CI সংকুচিত করে) নিশ্চিত হওয়া উচিত।” এই প্রশ্নটা দেখায় CI overlap একটা binary হ্যাঁ/না সিদ্ধান্ত না — overlap-এর পরিমাণ-ও তথ্যবহুল, formal hypothesis test (যেমন Mann-Whitney U বা t-test) আরো কঠোর সিদ্ধান্ত দিতে পারে।
3একটা Rust benchmark-এ একটা function-এর input compile-time known
constant (let x = 42;), আর output black_box-এ মোড়া। তবু
benchmark একটা অসম্ভব দ্রুত সময় দেখাচ্ছে। সম্ভাব্য কারণ কী?
প্রয়োগ
let x = 42;), আর output black_box-এ মোড়া। তবু
benchmark একটা অসম্ভব দ্রুত সময় দেখাচ্ছে। সম্ভাব্য কারণ কী?সম্ভবত compiler পুরো computation compile-time-এই সম্পন্ন করে
ফেলেছে (constant folding), কারণ input 42 একটা compile-time
known literal — optimizer পুরো function-টাকে একটা single ধ্রুবক
ফলাফলে রূপান্তরিত করতে পারে, তারপর সেই ধ্রুবকটাই black_box-এ
পাঠানো হচ্ছে।
black_box শুধু output-কে “escape” (observable) হিসেবে
চিহ্নিত করেছে — এটা compiler-কে input-টাকে “unknown” ভাবতে বাধ্য
করেনি। যেহেতু input compile-time-এ জানা, আর কোনো runtime randomness
বা side-effect নেই, পুরো computation build-time-এই ঘটে যেতে পারে
— runtime-এ শুধু একটা pre-computed মান load হচ্ছে, প্রকৃত computation
loop চলছেই না।
প্রতিকার: input-কেও black_box-এ মুড়ে দিন:
let x = black_box(42); // এখন compiler input-টাকে "unknown" ধরতে বাধ্য
let result = black_box(compute(x));এখন compiler input-এর মান compile-time-এ জানে না বলে ধরে নিতে বাধ্য, তাই পুরো computation সত্যিই runtime-এ ঘটতে হবে। নিয়ম: input আর output — দুইটাই protect করতে হবে, শুধু একটা না।
4একটা ৩০-মিনিট দীর্ঘ, একটানা compute-heavy benchmark-এ throughput
প্রথম ৫ মিনিটে সর্বোচ্চ, তারপর ধীরে ধীরে ~15% কমে একটা নিচু
মানে স্থির হয়ে যায়, আর বাকি সময় সেখানেই থাকে। Warm-up দিয়ে এটা
ব্যাখ্যা করা যায় কি? যদি না যায়, আসল কারণ কী?
যুক্তি
~15% কমে একটা নিচু
মানে স্থির হয়ে যায়, আর বাকি সময় সেখানেই থাকে। Warm-up দিয়ে এটা
ব্যাখ্যা করা যায় কি? যদি না যায়, আসল কারণ কী?না, এটা warm-up না — এটা উল্টো দিকের একটা ঘটনা, thermal throttling।
Warm-up-এ throughput ধীরে ধীরে বাড়ে (cold থেকে steady-state-এ ওঠা) আর প্রথম কয়েক সেকেন্ড/মিনিটের মধ্যেই স্থির হয়ে যায়। এখানে throughput প্রথমে সর্বোচ্চ, তারপর কমছে — বিপরীত প্যাটার্ন।
এটা ঠিক turbo boost → sustained frequency-তে নেমে আসার signature।
প্রথম কয়েক মিনিট CPU thermal headroom ব্যবহার করে turbo frequency-তে
চলে (datasheet base clock-এর চেয়ে বেশি) — এই সময় throughput সর্বোচ্চ।
কিন্তু সেই বাড়তি ক্লকে চালানো ক্রমাগত তাপ জমায়; একটা নির্দিষ্ট
তাপমাত্রার threshold-এ পৌঁছালে hardware নিজে থেকেই clock কমিয়ে
দেয় (thermal throttling) যতক্ষণ না তাপ উৎপাদন আর নিষ্কাশনের মধ্যে
একটা ভারসাম্য (~15% কম throughput-এ) পৌঁছায় — আর সেখানেই স্থির
থাকে যতক্ষণ load চলতে থাকে।
সঠিক প্রতিক্রিয়া: যদি লক্ষ্য “sustained, বাস্তব production throughput” মাপা হয়, তাহলে শুধু throttled/স্থির অংশটাই report করা উচিত (প্রথম কয়েক মিনিট বাতিল, ঠিক warm-up-এর মতোই — যদিও কারণ ভিন্ন)। যদি লক্ষ্য “সর্বোচ্চ সম্ভাব্য burst performance” হয়, তাহলে প্রথম অংশ প্রাসঙ্গিক, কিন্তু তখন স্পষ্টভাবে বলা উচিত এটা short-burst সংখ্যা, sustained না — দুইটা গুলিয়ে ফেলা একটা বাস্তব ও সাধারণ ভুল।
5আপনার team একটা নতুন caching layer যোগ করেছে আর দাবি করছে “p50
latency ২০% কমেছে।” কিন্তু কেউ complaint করছে “মাঝে মাঝে request
অনেক ধীর হয়ে যাচ্ছে, আগে এমন হতো না।” কীভাবে এই দুইটা পর্যবেক্ষণ
একসাথে সত্য হতে পারে, আর সঠিক benchmarking দিয়ে কীভাবে এটা ধরা
পড়তো?
ডিজাইন
এই পরিস্থিতি একদম বাস্তব, আর এটাই দেখায় কেন শুধু p50 রিপোর্ট করা যথেষ্ট না।
Cache যোগ করলে সাধারণ (cache hit) case দ্রুত হয় — p50 কমে যাওয়া সেটাই প্রতিফলিত করে, দাবিটা মিথ্যা না। কিন্তু cache miss হলে (নতুন key, বা eviction-এর পর) অতিরিক্ত layer-টা একটা নতুন ধরনের খরচ যোগ করে — cache lookup-এর overhead + miss হলে fallback + হয়তো cache populate করার জন্য বাড়তি লেখা। যদি এই miss path আগের (cache ছাড়া) direct path-এর চেয়ে ধীর হয়, তাহলে p50 কমলেও p99/p999 বেড়ে যেতে পারে — ঠিক সেই “মাঝে মাঝে অনেক ধীর” অভিজ্ঞতা যা user রিপোর্ট করছে।
সঠিক benchmarking এটা কীভাবে ধরত:
১. শুধু p50 না, p95, p99, p999 — সব percentile আগে ও পরে
তুলনা করা উচিত ছিল, বিশেষত এমন কোনো পরিবর্তনে যা naturally দুই
ধরনের path (hit/miss) তৈরি করে।
২. Realistic traffic pattern দিয়ে test করা — যদি benchmark শুধু একটা ছোট, বারবার-ব্যবহৃত key set দিয়ে চালানো হয় (সবসময় cache hit), miss path কখনোই exercise হবে না, আর সমস্যাটা লুকিয়ে থাকবে। বাস্তব production-এর মতো key distribution (long tail সহ) দিয়ে test করা জরুরি।
৩. Distribution-এর shape রিপোর্ট করা, শুধু একটা সংখ্যা না — একটা histogram বা CDF plot সরাসরি দেখাতো যে নতুন সিস্টেমে একটা দ্বিতীয়, দূরের “bump” (miss path) তৈরি হয়েছে, যা শুধু p50/mean দেখে ধরা পড়ত না।
মূল শিক্ষা: একটা optimization যা “গড় ব্যবহারকারী”-কে সাহায্য করে “কিছু ব্যবহারকারী”-কে ক্ষতি করতে পারে — আর যদি আপনার measurement শুধু কেন্দ্রীয় প্রবণতা (mean/median) দেখে, tail-এ কী ঘটছে তা সম্পূর্ণ অন্ধ থেকে যায়।
6দুইটা algorithm version, n=20 করে run। Version A: median 9.8ms,
সব run 9.5-10.1ms-এর মধ্যে (কম variance)। Version B: median
9.2ms (A-এর চেয়ে দ্রুত), কিন্তু run 7.0-14.0ms-এর মধ্যে
ছড়ানো (বেশি variance)। Production-এ deploy করার জন্য কোনটা
বাছবেন, আর কেন median-এর তুলনাই যথেষ্ট না?
ডিজাইন
n=20 করে run। Version A: median 9.8ms,
সব run 9.5-10.1ms-এর মধ্যে (কম variance)। Version B: median
9.2ms (A-এর চেয়ে দ্রুত), কিন্তু run 7.0-14.0ms-এর মধ্যে
ছড়ানো (বেশি variance)। Production-এ deploy করার জন্য কোনটা
বাছবেন, আর কেন median-এর তুলনাই যথেষ্ট না?শুধু median দেখলে B “দ্রুত” (9.2 \< 9.8), কিন্তু এই সিদ্ধান্ত
প্রেক্ষাপট ছাড়া অসম্পূর্ণ — variance-টাও সমান গুরুত্বপূর্ণ তথ্য,
বিশেষত production deployment-এর সিদ্ধান্তে।
A-এর প্রোফাইল — predictable। Range 9.5-10.1ms, spread মাত্র
0.6ms। User বা downstream system A-এর latency নিয়ে নির্ভরযোগ্য
প্রত্যাশা রাখতে পারে — timeout সেট করা সহজ, capacity planning
নির্ভরযোগ্য।
B-এর প্রোফাইল — unpredictable, যদিও গড়ে দ্রুত। Range
7.0-14.0ms — worst case (14.0ms) A-এর worst case (10.1ms)-এর
চেয়ে ৪০% বেশি খারাপ। যদি এই সংখ্যা একটা API-এর response time
হয়, আর downstream-এ একটা 12ms timeout সেট করা থাকে, B মাঝেমধ্যে
সেই timeout-এ ধাক্কা খাবে — A কখনোই না।
সিদ্ধান্ত নির্ভর করে ব্যবহারের প্রসঙ্গের উপর:
- যদি লক্ষ্য throughput (batch processing, নির্দিষ্ট সময়ে যত বেশি সম্ভব কাজ শেষ করা, individual latency গুরুত্বপূর্ণ না) — B ভালো পছন্দ, কারণ গড়ে দ্রুত মানে মোট সময় কম।
- যদি লক্ষ্য latency-sensitive user-facing service (Dean ও
Barroso-র tail latency যুক্তি সরাসরি প্রযোজ্য) — A ভালো পছন্দ,
কারণ predictability এখানে গড়ের চেয়ে বেশি মূল্যবান। একটা
occasional
14msস্পাইক timeout/retry-এর মাধ্যমে পুরো সিস্টেমে ছড়িয়ে পড়তে পারে (cascading failure), গড় ভালো হলেও।
যা কখনো করা উচিত না: শুধু “B-এর median কম” দেখে, variance না দেখেই, B-কে “strictly better” ঘোষণা করা। একটা সম্পূর্ণ benchmark report-এ median-এর পাশাপাশি range (min-max) বা stddev অবশ্যই থাকা উচিত, ঠিক যেমন এই লেসনের প্রতিটা উদাহরণে median-এর সাথে percentile/CI-ও দেখানো হয়েছে।
এরপর কী
মডিউল শেষ — পিছনে তাকানো
সতেরোটা লেসন আগে এই module-এর driving question ছিল: “একই algorithm, একই complexity — তবু একটা ১০ গুণ দ্রুত কেন?”
এখন সেই প্রশ্নের সম্পূর্ণ উত্তর আমাদের হাতে, একটা একক শব্দে না, একটা স্তরায়িত ব্যাখ্যা হিসেবে:
Instruction-level-এ microarchitecture-এর frontend/backend/ retirement pipeline বলে দেয় কেন একই instruction সংখ্যা ভিন্ন cycle সংখ্যায় শেষ হতে পারে। এক স্তর উপরে, cache hierarchy, coherence protocol (MESI), আর false sharing বলে দেয় কেন memory access pattern-ই আসল খরচ, byte সংখ্যা না। NUMA topology বলে দেয় কেন “একই memory”-ও দূরত্বভেদে ভিন্ন গতিতে পাওয়া যায়। Memory consistency model আর atomics বলে দেয় কেন multi-core সঠিকতা নিজেই একটা performance-critical ডিজাইন সিদ্ধান্ত, শুধু algorithm-এর যুক্তি না। Speculative execution-এর গতি বাড়ানোর কৌশলই আবার Spectre-এর মতো নিরাপত্তা ফাঁকের জন্ম দেয় — গতি আর নিরাপত্তা একই সিলিকনে আপোষ করছে। Prefetching, SIMD, GPU-র SIMT model, আর parallel pattern (map/reduce/scan) প্রতিটাই hardware-এর সমান্তরালতা কাজে লাগানোর ভিন্ন ভিন্ন কৌশল। Amdahl আর Gustafson বলে দেয় সেই সমান্তরালতার একটা তাত্ত্বিক সিলিং আছে, serial fraction দিয়ে নির্ধারিত। Roofline model সেই সিলিং-এর পাশে আরেকটা সিলিং যোগ করে — compute না bandwidth, কোনটা আগে ফুরোয়। আর সবশেষে, profiling আর benchmarking — এই দুই লেসন ছাড়া উপরের সবকিছুই শুধু তত্ত্ব থেকে যেত, কখনো একটা বিশ্বাসযোগ্য সংখ্যায় পরিণত হতো না।
“১০ গুণ দ্রুত” প্রশ্নের উত্তর তাই কখনোই একটা কারণ না — এটা এই স্তরগুলোর যেকোনো একটায় (বা একাধিকে) ঘটা একটা পার্থক্য, যা শুধু সঠিক measurement দিয়েই কোন স্তর তা নির্ণয় করা যায়।
এই module-এর বিশেষত্ব — এটা কোনো একটা module-এর দোরগোড়া না
এই curriculum-এর প্রতিটা module সাধারণত একটা নির্দিষ্ট পরের module-এর ভিত্তি তৈরি করে — সেই module শেষে একটা প্রশ্ন দিয়ে পরের module-এর দরজা খোলা হয়। এই module সেই প্যাটার্ন অনুসরণ করে না, আর ইচ্ছাকৃতভাবেই না।
Curriculum-এর dependency graph দেখলে (src/data/curriculum.ts)
দেখা যায় — অন্য কোনো module-ই advanced-architecture-কে তার
requires-এ রাখেনি। এই module কোনো একটা নির্দিষ্ট পরের বিষয়ের
পূর্বশর্ত না। কারণ এই ১৭টা লেসনে যা শেখানো হয়েছে তা কোনো একটা
নির্দিষ্ট domain-এর জ্ঞান না — এটা একটা lens, একটা চিন্তাভঙ্গি,
যা বাকি পুরো curriculum জুড়ে বারবার প্রাসঙ্গিক হবে।
Database Systems (Level ৮)-এ ফিরে গেলে দেখবেন B-tree বনাম hash index-এর তুলনা আসলে এই module-এর cache-locality আলোচনার প্রায় হুবহু প্রতিধ্বনি — buffer pool replacement policy একটা cache hierarchy সিদ্ধান্ত, আর MVCC-এর concurrent access pattern-এ memory consistency-র প্রশ্নগুলো সরাসরি ফিরে আসবে।
Distributed Systems (Level ৯)-এ tail latency (p99, p999)
নিয়ে যে আলোচনা এই লেসনেই শুরু হলো, সেটাই সেই module-এর কেন্দ্রীয়
বিষয়বস্তুর একটা — একাধিক node জুড়ে সামান্য একটা slow outlier
কীভাবে পুরো সিস্টেমের অভিজ্ঞতা নষ্ট করে দেয়, ঠিক এই লেসনের
percentile আলোচনার স্কেল-আপ সংস্করণ।
Virtualization, Containers ও Cloud (Level ১২)-এ hardware resource বরাদ্দের সিদ্ধান্ত (কোন instance type, কত vCPU) নেওয়ার সময় এই module-এর roofline classification আর NUMA-সচেতনতা সরাসরি কাজে লাগবে — একটা workload memory-bound না compute-bound তা না জেনে instance বাছাই একটা ব্যয়বহুল ভুল।
এগুলো কোনোটাই formal prerequisite না — এই module সেভাবে ডিজাইনও হয়নি। বরং এটা একটা cross-cutting দক্ষতা: এখন থেকে curriculum-এর যেকোনো জায়গায় “এটা ধীর কেন?” প্রশ্ন উঠলে, উত্তর খোঁজার প্রথম প্রশ্নটা হওয়া উচিত এই module-এরই শেখানো প্রশ্ন — “কোন resource ফুরিয়ে যাচ্ছে, আর সেটা কি সত্যিই measured, না অনুমান করা?”
সেই প্রশ্নটাই এখন আপনার সাথে থাকবে, বাকি পুরো curriculum জুড়ে।
আরও পড়ুন
- Producing Wrong Data Without Doing Anything Obviously Wrong! — Todd Mytkowicz, Amer Diwan, Matthias Hauswirth, Peter F. Sweeney · ASPLOS 2009 — দেখায় কীভাবে environment size, link order-এর মতো 'অপ্রাসঙ্গিক' জিনিসও benchmark ফলাফল উল্টে দিতে পারে
- The Tail at Scale — Jeffrey Dean, Luiz André Barroso · Communications of the ACM, 2013 — কেন average নয়, tail latency (p99, p999) বড় সিস্টেমে আসল মাপকাঠি
- std::hint::black_box — Rust documentation · Compiler optimization ঠেকানোর standard, portable উপায়ের সরকারি ডকুমেন্টেশন
- Google Benchmark: DoNotOptimize / ClobberMemory · C++-এ একই সমস্যার industry-standard সমাধান