এই পর্বে যা শিখবেন
- Knuth-এর বিখ্যাত উক্তির পুরো প্রসঙ্গ, আর latency, throughput ও p99 দিয়ে লক্ষ্য ঠিক করা
std::chronoদিয়ে সময় মাপা, আর মাপা কেন কঠিন — warm-up, optimizer-এর মুছে ফেলা কাজ, noise- profiler আর flame graph দিয়ে hotspot খোঁজা, তারপর algorithm বদলে বড় জয়
- মেমরির সিঁড়ি, cache line, সারি বনাম কলাম ধরে হাঁটা, আর AoS বনাম SoA
- কপি, allocation, branch prediction, inlining — আর কখন optimize করবেনই না
১ · অনুমান নয়, মাপুন — আর লক্ষ্য আগে ঠিক করুন
একটি দোকানের হিসাবের সফটওয়্যারের কথা ভাবুন। মাসের শেষে মালিক “বিক্রির রিপোর্ট” বোতাম চাপেন, আর রিপোর্ট আসতে লাগে ১০ সেকেন্ড। নতুন প্রোগ্রামার এসে বললেন, “ফাইল পড়াটা নিশ্চয়ই ধীর, ওটাকে দ্রুত করি।” দুই দিন খেটে তিনি ফাইল পড়া দ্বিগুণ দ্রুত করলেন। রিপোর্ট এখনো আসে প্রায় ১০ সেকেন্ডে। কারণ ফাইল পড়তে লাগত মাত্র ০.১ সেকেন্ড — বাকি সময় যাচ্ছিল অন্য কোথাও।
অভিজ্ঞ প্রোগ্রামাররাও আন্দাজে বলতে পারেন না, প্রোগ্রামের সময় ঠিক কোথায় যায়। তাই optimization-এর প্রথম নিয়ম কোনো কৌশল নয়, একটি অভ্যাস: আগে মাপুন।
Knuth-এর উক্তি — পুরোটা পড়ুন
“Premature optimization is the root of all evil” — এই লাইনটি আপনি হয়তো শুনেছেন। অনেকে এটিকে বলেন “পারফরম্যান্স নিয়ে ভাবার দরকারই নেই”। কিন্তু Donald Knuth ১৯৭৪ সালে আসলে যা লিখেছিলেন, তা পুরোটা পড়লে অর্থ বদলে যায়:
“Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs, and these attempts at efficiency actually have a strong negative impact when debugging and maintenance are considered. We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.”
— Donald E. Knuth, “Structured Programming with go to Statements”, ACM Computing Surveys, ১৯৭৪
সহজ বাংলায়: প্রোগ্রামের বেশিরভাগ অংশ (প্রায় ৯৭%) গতির জন্য গুরুত্বপূর্ণ নয়, সেখানে ছোটখাটো কৌশলে সময় নষ্ট করলে কোড শুধু জটিল হয়। কিন্তু একটি ছোট অংশ (প্রায় ৩%) সত্যিই সংকটপূর্ণ — সেখানে সুযোগ ছাড়া যাবে না। আর সেই ৩% কোথায়, তা খুঁজে বের করতে হবে মেপে। premature optimization মানে “সেই ৩% খোঁজার আগেই” অনুমানে কোড জটিল করা। ভাবনার প্রশ্ন ০১-এ এই প্রবন্ধের পুরো গল্প আছে।
ক্রমটা সবসময় এক: আগে সঠিক, তারপর পরিষ্কার, তারপর — যেখানে মাপ বলে — দ্রুত। ভুল উত্তর দ্রুত দেওয়া প্রোগ্রামের কোনো মূল্য নেই। আর সবচেয়ে বড় গতির লাভ প্রায় সবসময় আসে ভালো algorithm আর ভালো ডেটা-বিন্যাস থেকে, ছোট কৌশল থেকে নয়।
“দ্রুত” মানে কী — latency, throughput আর p99
মাপার আগে ঠিক করতে হবে, কী মাপবেন। দুটি আলাদা প্রশ্ন আছে।
⏱️ Latency
একটি কাজ শুরু থেকে শেষ হতে কত সময় লাগে। bKash-এ “Send Money” চাপার পরে নিশ্চিতকরণ বার্তা আসতে কত মিলিসেকেন্ড — গ্রাহকের কাছে এটাই “গতি”।
একক: ms, µs🚚 Throughput
নির্দিষ্ট সময়ে কতগুলো কাজ শেষ হয়। ঈদের আগে সার্ভার সেকেন্ডে কত হাজার লেনদেন সামলাতে পারে — সিস্টেমের মালিকের কাছে এটাই “গতি”।
একক: কাজ/সেকেন্ডদুটি সবসময় একসাথে বাড়ে না। মেট্রো রেলের কথা ভাবুন: বড় ট্রেন কম ঘন ঘন চালালে ঘণ্টায় বেশি যাত্রী যায় (throughput বাড়ে), কিন্তু প্ল্যাটফর্মে প্রত্যেকের অপেক্ষা বাড়ে (latency বাড়ে)। প্রোগ্রামেও কাজগুলো জমিয়ে একসাথে করলে (batching) throughput বাড়ে, কিন্তু প্রথম কাজটিকে অপেক্ষা করতে হয়।
আর latency-তে গড় দিয়ে বিচার করবেন না। ধরুন ১০০টি অনুরোধের ৯৯টি ১০ মিলিসেকেন্ডে শেষ হয়, আর একটি লাগে ২ সেকেন্ড। গড় প্রায় ৩০ মিলিসেকেন্ড — দেখতে ভালো। কিন্তু প্রতি ১০০ জন গ্রাহকের একজন দুই সেকেন্ড আটকে থাকছেন। তাই পেশাদাররা দেখেন percentile: p50 (মাঝের মান), p99 (১০০টির মধ্যে ৯৯টি এর চেয়ে দ্রুত)। এই খারাপ দিকের প্রান্তকে বলে tail latency. একটি পাতা খুলতে যদি সার্ভার ভেতরে ৫০টি ছোট অনুরোধ পাঠায়, তাহলে তাদের অন্তত একটি p99-এর মতো ধীর হওয়ার সম্ভাবনা প্রায় ৪০% — তাই বড় সিস্টেমে p99-ই আসল গতি ঠিক করে।
লক্ষ্যটা লিখে ফেলুন, সংখ্যায়: “মাসিক রিপোর্ট ১ সেকেন্ডের মধ্যে”, “p99 latency ৫০ মিলিসেকেন্ডের নিচে”, “সেকেন্ডে ১০,০০০ অনুরোধ”। লক্ষ্য না থাকলে কখন থামবেন, তা জানবেন না — আর optimization-এর কোনো শেষ নেই।
২ · মাপা — সহজ শোনায়, কিন্তু কঠিন
পর্ব ৪১-এ আপনি std::chrono দেখেছেন। সময় মাপার জন্য ঠিক ঘড়িটি হলো steady_clock — এটি কখনো পেছনে যায় না। (দেয়ালঘড়ির system_clock ইন্টারনেট থেকে সময় ঠিক করার সময় লাফ দিতে পারে, তাই মাপার কাজে অচল।) সবচেয়ে সরল মাপার কাঠামো এমন:
#include <chrono>
using clk = std::chrono::steady_clock;
auto t0 = clk::now();
auto total = build_report(sales); // je kaj mapchi
auto t1 = clk::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0);
std::println("report: {} us (total = {})", us.count(), total);দেখতে সহজ। কিন্তু এভাবে একবার মাপা সংখ্যা প্রায়ই ভুল পথে নিয়ে যায়। চারটি ফাঁদ চিনে রাখুন।
- ১. প্রথমবার আলাদা। প্রথমবার চালালে ডেটা cache-এ নেই, মেমরির পাতা অপারেটিং সিস্টেম তখনো দেয়নি, CPU হয়তো কম গতিতে ঘুমিয়ে ছিল। তাই আগে কয়েকবার “গরম” করুন — warm-up — তারপর অনেকবার মেপে মাঝের মান (median) নিন।
- ২. optimizer কাজটাই মুছে দিতে পারে। পর্ব ৪৩-এর as-if rule মনে করুন: ফলাফল যদি কোথাও ব্যবহার না হয়, compiler পুরো হিসাবটাই বাদ দিতে পারে — dead code elimination. তখন আপনার “১০ লাখবার হিসাব” মাপে ০ ন্যানোসেকেন্ড আসে। উপরের কোডে তাই
totalছাপানো হয়েছে, যাতে ফলটি “কাজে লাগে”। - ৩. noise. পাশে ব্রাউজার চলছে, ল্যাপটপ চার্জে নেই, CPU গরম হয়ে গতি কমিয়েছে — একই কোড দুবার চালালে ১০–২০% পার্থক্য খুব স্বাভাবিক। ৫% উন্নতি “পেয়েছি” বলার আগে কয়েকবার চালিয়ে দেখুন পার্থক্যটা noise-এর চেয়ে বড় কি না।
- ৪. ভুল build.
-O0(optimization বন্ধ, g++-এর ডিফল্ট) দিয়ে মাপা সংখ্যা প্রায় অর্থহীন। সেখানেstd::vector-এর প্রতিটি[]একটি আসল ফাংশন-ডাক, তাই C++-এর abstraction-গুলো কৃত্রিমভাবে ধীর দেখায়। সবসময় যে flag-এ প্রোগ্রাম আসলে চলবে (সাধারণত-O2), সেটা দিয়েই মাপুন।
Benchmark লাইব্রেরি — এই ফাঁদগুলো সামলানোর তৈরি হাতিয়ার
ছোট একটি ফাংশনের গতি আলাদা করে মাপাকে বলে microbenchmark. এর জন্য হাতে লুপ না লিখে তৈরি লাইব্রেরি ব্যবহার করুন। সবচেয়ে প্রচলিত Google Benchmark; হালকা একটি বিকল্প Martin Ankerl-এর nanobench (একটি header ফাইল)। এরা নিজে থেকেই warm-up করে, যথেষ্টবার চালায়, পরিসংখ্যান দেয়, আর ফল মুছে যাওয়া ঠেকানোর হাতিয়ার দেয়:
#include <benchmark/benchmark.h>
static void BM_Report(benchmark::State& state) {
auto sales = make_sales(20000); // prostuti: mapar baire
for (auto _ : state) { // library nije koto bar chalabe thik kore
auto total = build_report(sales);
benchmark::DoNotOptimize(total); // "eta byabohar hocche" - mucho na
}
}
BENCHMARK(BM_Report);
BENCHMARK_MAIN();DoNotOptimize-এর ধারণাটা মনে রাখুন: compiler-কে বলা “এই মানটি কেউ দেখবে, তাই হিসাবটা সত্যিই করো”, কিন্তু আর কোনো বাড়তি খরচ না যোগ করে। quick-bench.com সাইটে Google Benchmark অনলাইনেও চালানো যায়; নিজের কম্পিউটারে লাইব্রেরিটি ইনস্টল করে -lbenchmark দিয়ে link করতে হয় (পর্ব ৪৭-এর CMake দিয়ে সহজ)।
পুরো চক্র — মাপুন, খুঁজুন, ঠিক করুন, আবার মাপুন
দোকানের রিপোর্টের গল্পটি এবার ঠিক পথে চালিয়ে দেখি। নিচের stepper-এ প্রতিটি ধাপে কী করা হয় আর কী পাওয়া যায়, দেখুন।
- লক্ষ্য
< 1 sসংখ্যায় লিখুন - মাপা
10.0 sbaseline - Profile
perfসময় কোথায় - Hotspot
find_product৯৮% সময় - সমাধান
O(n·m) → O(n log m)algorithm বদল - আবার মাপা
0.2 sলক্ষ্য পূরণ
মালিকের চাওয়া: রিপোর্ট ১ সেকেন্ডের মধ্যে
ডেটা: ২০০০ পণ্য, মাসে ২০,০০০ বিক্রি
লক্ষ্য না থাকলে optimization কখনো শেষ হয় না
run 1: 10.31 s run 2: 9.97 s run 3: 10.02 s median = 10.0 s (সংখ্যাগুলো উদাহরণ; আপনার মেশিনে আলাদা হবে)
98.0% find_product 0.9% format_line 0.9% read_sales_file 0.2% [others]
for (const auto& p : catalog) // 2000 ponno, shuru theke if (p.id == pid) return p; ২০,০০০ বিক্রি × গড়ে ১০০০ তুলনা = ২ কোটি তুলনা
std::ranges::lower_bound(catalog, pid, {}, &Product::id);
প্রতি বিক্রিতে ~১১টি তুলনা, মোট ~২.২ লাখ
(বিকল্প: std::unordered_map, গড়ে O(1))
median = 0.2 s লক্ষ্য পূরণ — থামুন find_product এখন ~৫০% সময় নতুন profile: বাকি অর্ধেক format_line আর ফাইল পড়ায়
- লক্ষ্য ঠিক করা। মালিক চান রিপোর্ট এক সেকেন্ডের মধ্যে আসুক। এটাই থামার শর্ত। লক্ষ্য পূরণ হলে আর optimize করা মানে সময় নষ্ট আর কোড জটিল করা।
- baseline মাপা। আসল ডেটায়, আসল build flag (
-O2) দিয়ে, কয়েকবার চালিয়ে median নেওয়া হলো: ১০ সেকেন্ড। এই সংখ্যার সাথেই পরে তুলনা হবে — baseline না থাকলে “দ্রুত হয়েছে” বলার কোনো ভিত্তি নেই। - Profile করা। অনুমান না করে profiler চালানো হলো। ফল চমকে দেওয়ার মতো: ফাইল পড়া, যাকে সবাই সন্দেহ করছিলেন, এক শতাংশেরও কম। ৯৮% সময় যাচ্ছে একটি ছোট ফাংশনে।
- Hotspot বোঝা।
find_productপ্রতিটি বিক্রির জন্য ২০০০ পণ্যের তালিকা শুরু থেকে খোঁজে। মোট কাজ বিক্রির সংখ্যা × পণ্যের সংখ্যা — দুটোই বাড়লে কাজ বাড়ে গুণফলে। এটি একটি algorithm-এর সমস্যা, কোনো ছোট কৌশলের নয়। - সমাধান। catalog id অনুযায়ী সাজানো, তাই প্রতিটি খোঁজা binary search দিয়ে — প্রতিবার মোটামুটি log₂ ২০০০ ≈ ১১টি তুলনা। কোড বদলাল মাত্র এক লাইন। ৪ নম্বর অংশে এই কোড নিজেই চালিয়ে তুলনা গুনে দেখবেন।
- আবার মাপা — একই শর্তে। ০.২ সেকেন্ড, লক্ষ্যের অনেক নিচে। এখানেই থামুন। নতুন profile-এ format_line আর ফাইল পড়া এখন মোট সময়ের অর্ধেক — লক্ষ্য পূরণ না হলে চক্রটি আবার সেখান থেকে শুরু হতো। এই চক্রই পুরো পর্বের মেরুদণ্ড।
৩ · Profiler — সময় আসলে কোথায় যায়
stopwatch বলে “পুরোটায় কত সময়”; profiler বলে “কোন ফাংশনে কত সময়”। যে ছোট অংশে সবচেয়ে বেশি সময় যায়, তাকে বলে hotspot. profiler সাধারণত দুইভাবে কাজ করে:
📸 Sampling
প্রোগ্রাম চলার সময় সেকেন্ডে কয়েক হাজারবার থামিয়ে দেখে, “এই মুহূর্তে কোন ফাংশন চলছে, কে তাকে ডেকেছে”। যে ফাংশন বেশিবার ধরা পড়ে, সে-ই বেশি সময় নেয়। খরচ কম, আসল গতিতে মাপে। এটিই sampling profiler.
perf, VTune, VS Profiler🧾 Instrumentation
compile-এর সময় প্রতিটি ফাংশনের শুরু-শেষে হিসাবের কোড ঢোকায়। প্রতিটি ফাংশন কতবার ডাকা হলো তা নিখুঁত জানা যায়, কিন্তু প্রোগ্রাম ধীর হয় আর ছোট ফাংশনের মাপ বিকৃত হয়।
gprof (-pg)
কোন প্ল্যাটফর্মে কী ব্যবহার করবেন:
| টুল | কোথায় | কী জন্য ভালো |
|---|---|---|
| perf | Linux (বিনামূল্যে) | sampling, CPU-র ভেতরের counter (cache miss, branch miss) পড়া |
| Intel VTune | Windows, Linux (বিনামূল্যে পাওয়া যায়) | hotspot, মেমরি-বাধা আর thread বিশ্লেষণ, খুব বিস্তারিত |
| Visual Studio Performance Profiler | Windows | IDE-র ভেতরে CPU Usage, সহজ শুরু |
| gprof | GCC-র সাথে, পুরোনো | কোন ফাংশন কতবার ডাকা হলো; আজ বেশিরভাগ কাজে perf ভালো |
Linux-এ একটি সাধারণ সেশন এমন দেখায়। -g দিলে profiler ফাংশনের নাম আর লাইন দেখাতে পারে; frame pointer রাখলে কে কাকে ডেকেছে, সেই শিকল নির্ভরযোগ্যভাবে পাওয়া যায়:
g++ -std=c++23 -O2 -g -fno-omit-frame-pointer report.cpp -o report
perf record -g ./report # chalao, sample jomao
perf report # kon function-e koto %Flame graph — এক ছবিতে পুরো প্রোগ্রাম
profiler-এর লম্বা টেক্সট তালিকা পড়া কঠিন। ২০১১ সালে Brendan Gregg MySQL-এর একটি পারফরম্যান্স সমস্যা খুঁজতে গিয়ে একটি ছবি আবিষ্কার করেন, যা এখন সবখানে ব্যবহার হয় — flame graph. প্রথমবার দেখলে বিভ্রান্ত লাগে, কারণ একটি অক্ষ সবাই ভুল পড়েন। ধাপে ধাপে পড়া শিখুন:
উপরে: যে ফাংশন নিজে চলছিল [ str== 60% ][ hash ] [ find_product 98% ][f] [ build_report 99% ][l] [ main 100% ] f = format_line 1%, l = load 1% নিচে: শিকড় (প্রথম ডাক)
[ str ]
[ find_product 50% ][ fmt 25% ]
[ build_report 75% ][ load 25% ]
[ main 100% ]
মোট সময় ৫০ গুণ কম; এখন load আর fmt-ও চোখে পড়ে
- দুই অক্ষ। ওপর-নিচ (y) হলো call stack-এর গভীরতা: নিচে
main, তার ওপরে main যাকে ডেকেছে, তার ওপরে তার ডাকা ফাংশন। বাঁ-ডান (x) সময়ের ক্রম নয়! প্রতিটি বাক্সের প্রস্থ মানে মোট sample-এর কত অংশে এই ফাংশন stack-এ ছিল। বাক্সগুলো সাধারণত নাম অনুযায়ী সাজানো থাকে। রং সাধারণত শুধু চোখের আরামের জন্য, অর্থ বহন করে না। - চওড়া বাক্স খুঁজুন।
build_report৯৯% চওড়া — মোট সময়ের ৯৯% সে বা তার ডাকা কেউ চলছিল।loadমাত্র ১%. ফাইল পড়া দ্রুত করে বড়জোর ১% বাঁচবে — দোকানের গল্পের প্রোগ্রামারের ভুল এই ছবিতে এক নজরেই ধরা পড়ত। - ওপরের সমতল অংশ = নিজের সময়। একটি বাক্সের ওপরে যে অংশ ফাঁকা (আর কেউ নেই), সেখানে ফাংশনটি নিজেই কাজ করছিল।
find_product-এর ওপরেstr==৬০% চওড়া — অর্থাৎ বেশিরভাগ সময় যাচ্ছে string তুলনায়। পণ্যের id string হিসেবে তুলনা হচ্ছে, তাও লাখ লাখ বার। - প্রশ্ন করুন: কেন এতবার? flame graph বলে কোথায়, কিন্তু কেন বলে না। string তুলনা দ্রুত করার কৌশল খোঁজার আগে জিজ্ঞেস করুন — তুলনাটা এতবার হচ্ছে কেন? উত্তর: প্রতিটি বিক্রিতে পুরো তালিকা হাঁটা। সমাধান algorithm-এ, string-এ নয়।
- সমাধানের পরে আবার আঁকুন। binary search বসানোর পরে মোট সময় প্রায় ৫০ ভাগের এক ভাগ। ছবিটা বদলে গেছে:
find_productএখন মাত্র অর্ধেক, আর আগে প্রায় অদৃশ্যloadআরformat_lineমিলে বাকি অর্ধেক। মনে রাখবেন, flame graph শতাংশ দেখায়, আসল সময় নয় — আগে-পরে তুলনায় তাই মোট সময়ও দেখতে হয়। লক্ষ্য পূরণ হলে এখানেই থামুন; না হলে পরের সবচেয়ে চওড়া বাক্সে যান।
ঢাকার যানজট নিয়ে ভাবুন। আপনার মনে হয় অফিস যেতে দেরি হয় মোহাখালী মোড়ে। কিন্তু গাড়িতে একটি GPS logger বসিয়ে এক সপ্তাহ রেকর্ড করলে দেখা গেল, আসল সময় যায় বাসা থেকে বের হওয়ার গলিতে, যেখানে প্রতিদিন রিকশার জট। profiler ঠিক সেই GPS logger — অনুভূতি নয়, রেকর্ড।
৪ · আগে algorithm — O(n²) থেকে O(n log n)
profiler hotspot দেখানোর পরে প্রথম প্রশ্ন: “এখানে কাজের পরিমাণ কমানো যায়?” — কাজটা দ্রুত করার আগে। একটি পরিচিত সমস্যা দিয়ে দেখি: একটি তালিকায় কোনো সংখ্যা দুবার আছে কি না (যেমন একই মোবাইল নম্বরে দুটি অ্যাকাউন্ট খোলা হয়েছে কি না)। তিনটি উপায়:
// upay 1: protiti jora tulona - O(n^2)
bool has_dup_slow(const std::vector<long>& v) {
for (std::size_t i = 0; i < v.size(); ++i)
for (std::size_t j = i + 1; j < v.size(); ++j)
if (v[i] == v[j]) return true;
return false;
}
// upay 2: sajao, tarpor pashapashi dekho - O(n log n)
bool has_dup_sort(std::vector<long> v) { // copy, karon sajabo
std::ranges::sort(v);
return std::ranges::adjacent_find(v) != v.end();
}
// upay 3: hash set - gore O(n)
bool has_dup_hash(const std::vector<long>& v) {
std::unordered_set<long> seen;
seen.reserve(v.size());
for (long x : v)
if (!seen.insert(x).second) return true; // age theke chilo
return false;
}প্রথম উপায়ে nested loop প্রতিটি জোড়া তুলনা করে: কোনো ডুপ্লিকেট না থাকলে n(n−1)/২টি তুলনা। দ্বিতীয় উপায়ে sort (পর্ব ৩৩) মোটামুটি n log₂ n তুলনা করে, তারপর একবার সারি ধরে হাঁটে। তৃতীয় উপায়ে std::unordered_set (পর্ব ৩১) প্রতিটি সংখ্যার জন্য গড়ে ধ্রুবক সময়ের একটি খোঁজা করে। ছোট n-এ পার্থক্য চোখে পড়ে না — আর ঠিক এ কারণেই এমন কোড পরীক্ষার সময় পার হয়ে যায়, আর আসল ডেটায় এসে ভেঙে পড়ে। n বাড়লে কী হয়, দেখুন:
1// n ti shonkhya, kono duplicate nei2has_dup_slow(v); // n(n-1)/23has_dup_sort(v); // ~ n log2 n4// 1e9 sohoj kaj/sec dhore
| n | |
| n²/2 | |
| n log n | |
| অনুপাত | |
| আনুমানিক সময় |
- n = ১০. nested loop ৪৫টি তুলনা, sort-এর পথে প্রায় ৩৩টি। প্রায় সমান — বরং ছোট n-এ সরল লুপটি প্রায়ই দ্রুত, কারণ তার কোনো বাড়তি খরচ নেই। এখানে optimize করার কোনো মানে নেই।
- n = ১০০. প্রায় পাঁচ হাজার বনাম সাড়ে ছয়শো। অনুপাত সাত গুণ, কিন্তু দুটোই কয়েক মাইক্রোসেকেন্ডের হিসাব। পরীক্ষার ছোট ইনপুটে কোনো পার্থক্য দেখবেন না।
- n = ১,০০০. প্রায় পাঁচ লাখ বনাম দশ হাজার — পঞ্চাশ গুণ। খেয়াল করুন: n দশ গুণ বাড়ল, আর n²-এর কাজ বাড়ল একশো গুণ। n log n-এর কাজ বাড়ল পনেরো গুণের মতো।
- n = ১০,০০০. একটি ছোট দোকানের এক বছরের বিক্রি। প্রায় পাঁচ কোটি তুলনা — এখনো এক সেকেন্ডের নিচে, তাই অনেকে সমস্যাটা টেরই পান না।
- n = ১ লাখ. একটি জেলার সব গ্রাহক। nested loop-এ প্রায় ৫০০ কোটি তুলনা — কয়েক সেকেন্ড। sort-এর পথে কয়েক মিলিসেকেন্ড। এখন আর কোনো compiler flag বা ছোট কৌশল এই ফারাক মেটাতে পারবে না।
- n = ১০ লাখ. একটি মোবাইল ব্যাংকিং সেবার একদিনের লেনদেন। nested loop-এ কয়েক মিনিট, sort-এর পথে কয়েক সেকেন্ডের ভগ্নাংশ — হাজার হাজার গুণ। সময়ের হিসাবটি মোটা দাগের (সেকেন্ডে ১০০ কোটি সহজ কাজ ধরে), কিন্তু অনুপাতের শিক্ষাটা নিখুঁত: বড় n-এ algorithm-এর শ্রেণিই সব ঠিক করে।
এবার দোকানের রিপোর্টের আসল কোড। ২০০০ পণ্যের catalog id অনুযায়ী সাজানো, আর মাসে ২০,০০০ বিক্রি। দুই উপায়ে প্রতিটি বিক্রির দাম খুঁজে মোট বিক্রি যোগ করা হয়েছে, আর সময় না ছেপে তুলনার সংখ্যা গোনা হয়েছে — কারণ সময় মেশিনভেদে বদলায়, কিন্তু কাজের পরিমাণ বদলায় না:
#include <algorithm>
#include <print>
#include <vector>
struct Product { int id; int price; };
int main() {
std::vector<Product> catalog; // 2000 ponno, id onujayi sajano
for (int id = 0; id < 2000; ++id) catalog.push_back({id, 10 + id % 90});
std::vector<int> sales; // 20000 bikri: kon ponno
for (int i = 0; i < 20000; ++i) sales.push_back(i * 7919 % 2000);
long total = 0, steps = 0; // upay 1: protibar shuru theke khoja
for (int pid : sales)
for (const auto& p : catalog) {
++steps;
if (p.id == pid) { total += p.price; break; }
}
std::println("linear: total = {}, steps = {}", total, steps);
total = 0; steps = 0; // upay 2: binary search
auto less = [&steps](int a, int b) { ++steps; return a < b; };
for (int pid : sales)
total += std::ranges::lower_bound(catalog, pid, less, &Product::id)->price;
std::println("binary: total = {}, steps = {}", total, steps);
}দুই লাইনেই total = 1083000 — একই সঠিক উত্তর। প্রথম লাইনে steps = 20010000: প্রায় দুই কোটি তুলনা, কারণ প্রতিটি বিক্রিতে গড়ে এক হাজার পণ্য পেরোতে হয়েছে। দ্বিতীয় লাইনে steps = 219540: প্রতিটি বিক্রিতে ১০ বা ১১টি তুলনা, মোট প্রায় দুই লাখ — প্রায় ৯০ গুণ কম কাজ। lower_bound-এর তৃতীয় argument তুলনার নিয়ম (এখানে একটি lambda, যে প্রতিবার ডাকা হলে steps বাড়ায়), আর চতুর্থটি projection — &Product::id মানে “প্রতিটি পণ্যের শুধু id দিয়ে তুলনা করো”। আসল সময়ের পার্থক্য দেখতে চাইলে নিজের কম্পিউটারে -O2 দিয়ে compile করে steady_clock দিয়ে মাপুন — আপনার মেশিনের সংখ্যা আপনার নিজের।
Big-O দিয়ে বাজে পথ বাদ দিন, কিন্তু Big-O-কে শেষ কথা ভাববেন না। পর্ব ৩০-এ দেখেছেন, একই শ্রেণির দুটি পদ্ধতির মধ্যে (যেমন vector আর list-এ হাঁটা, দুটোই O(n)) আসল গতি ঠিক করে মেমরির বিন্যাস। এখানেও তাই: ২০০০ পণ্যের মতো ছোট তালিকায় std::unordered_map-এর চেয়ে সাজানো vector-এ binary search অনেক সময় দ্রুত হয়, যদিও কাগজে hash table O(1). কোনটা ভালো — মাপুন।
৫ · মেমরির সিঁড়ি আর cache-বান্ধব কোড
algorithm ঠিক করার পরে দ্বিতীয় বড় জয় লুকিয়ে থাকে ডেটা মেমরিতে কীভাবে সাজানো, তার মধ্যে। কারণ আধুনিক CPU হিসাব করতে যত দ্রুত, মেমরি থেকে ডেটা আনতে তার চেয়ে অনেক ধীর। তাই CPU আর RAM-এর মাঝে কয়েক স্তরের ছোট কিন্তু দ্রুত মেমরি থাকে — cache. পুরো ব্যবস্থাকে বলে memory hierarchy:
| স্তর | সাধারণ আকার | পড়তে মোটামুটি সময় | তুলনা (L1 = ১ সেকেন্ড ধরলে) |
|---|---|---|---|
| register | কয়েক ডজন, প্রতিটি ৮ বাইট | এক cycle-এর কম (< ০.৩ ns) | চোখের পলক |
| L1 cache | প্রতি core-এ ৩২–৪৮ KB | ~১ ns (৪–৫ cycle) | ১ সেকেন্ড |
| L2 cache | প্রতি core-এ ২৫৬ KB – ২ MB | ~৩–৫ ns | কয়েক সেকেন্ড |
| L3 cache | সব core মিলে কয়েক থেকে কয়েক ডজন MB | ~১০–২০ ns | ১০–২০ সেকেন্ড |
| RAM | কয়েক GB | ~৮০–১০০ ns | দেড় মিনিটের মতো |
সংখ্যাগুলো আনুমানিক — CPU-র প্রজন্ম আর নির্মাতা অনুযায়ী বদলায়। এই ধরনের তালিকা জনপ্রিয় হয় Peter Norvig-এর একটি প্রবন্ধ আর Jeff Dean-এর বক্তৃতার “Latency numbers every programmer should know” থেকে (ভাবনার প্রশ্ন ০৪)। আসল শিক্ষা অনুপাতে: RAM থেকে একবার পড়ার সময়ে CPU কয়েকশো সাধারণ হিসাব করে ফেলতে পারত। একটি cache miss মানে CPU-র সেই সময়টা বসে থাকা।
পর্ব ৩০-এ দেখেছেন, CPU মেমরি আনে একটি-একটি বাইট করে নয়, ৬৪ বাইটের cache line ধরে। এর থেকে দুটি নিয়ম আসে। spatial locality: একটি ঠিকানা পড়লে তার পাশেরগুলো বিনা খরচে চলে আসে, তাই পাশাপাশি থাকা ডেটা পরপর পড়ুন। temporal locality: সম্প্রতি পড়া ডেটা cache-এ থাকে, তাই একই ডেটা নিয়ে কাজগুলো কাছাকাছি সময়ে সেরে ফেলুন।
2D grid — সারি ধরে হাঁটবেন, না কলাম ধরে?
C++-এ একটি 2D array (বা vector-এর ভেতরে r * cols + c সূচকে রাখা grid) মেমরিতে থাকে row-major ক্রমে: প্রথম সারির সব ঘর, তারপর দ্বিতীয় সারির সব ঘর। পুরো grid-এর যোগফল বের করতে লুপের ক্রম দুই রকম হতে পারে — ফল একই, কিন্তু মেমরির কাছে দুটি সম্পূর্ণ আলাদা কাজ। একটি ছোট grid দিয়ে দেখুন, যার প্রতিটি সারিতে ১৬টি int = ঠিক ৬৪ বাইট = একটি cache line:
সারি 0: [ 0 1 2 ... 15 ] <- cache line A
সারি 1: [16 17 18 ... 31 ] <- cache line B
সারি 2: [32 33 34 ... 47 ] <- cache line C
সারি 3: [48 49 50 ... 63 ] <- cache line D
মেমরিতে: A এর ঠিক পরেই B, তারপর C, D
g[0][0] A আনো (miss) g[0][1] A তে আছে (hit) ... hit, hit, hit ... g[0][15] hit g[1][0] B আনো prefetcher আগেই এনে রাখতে পারে ৬৪টি ঘর, মোট ৪টি line আনা
g[0][0] A আনো g[1][0] B আনো ৬৪ বাইট দূরে লাফ g[2][0] C আনো g[3][0] D আনো g[0][1] A তে আছে? grid ছোট হলে হ্যাঁ
কলাম 0: সারি 0..255 -> 256টি আলাদা line
cache-এ জায়গা মাত্র ৩২টির
সারি 0-এর line ততক্ষণে বের হয়ে গেছে
কলাম 1: সারি 0 -> আবার আনো!
প্রতিটি ঘরেই নতুন line আনা
line আনা প্রতি line-এ কাজে লাগল সারি ধরে 4,096 16টি int কলাম ধরে 65,536 1টি int একই যোগফল, ১৬ গুণ বেশি মেমরি-যাতায়াত
- মেমরির ছবি। ৪ সারি × ১৬ কলামের
intgrid. row-major বিন্যাসে প্রতিটি সারি পাশাপাশি ৬৪ বাইট — একটি সম্পূর্ণ cache line. সারি ১ শুরু হয় সারি ০ ঠিক যেখানে শেষ, সেখান থেকে। - সারি ধরে হাঁটা। বাইরের লুপ সারি, ভেতরের লুপ কলাম। প্রথম ঘর পড়তে line A আনতে হলো, বাকি ১৫টি ঘর বিনা খরচে। তারপর line B — আর ঠিকানা সোজা এগোচ্ছে বলে hardware prefetcher সেটি আগেভাগেই আনতে শুরু করে। ৬৪টি ঘরে মাত্র ৪টি line.
- কলাম ধরে হাঁটা। বাইরের লুপ কলাম, ভেতরের লুপ সারি। প্রতিটি পরের ঘর ৬৪ বাইট দূরে — প্রতিবার একটি নতুন line. এই ছোট grid-এ চারটি line-ই cache-এ থেকে যায়, তাই পরের কলামে আবার কাজে লাগে। ছোট ডেটায় পার্থক্য তাই প্রায় চোখে পড়ে না।
- বড় grid-এ সমস্যা। ২৫৬ × ২৫৬ grid-এ একটি কলাম শেষ করতে ২৫৬টি আলাদা line লাগে। ধরুন cache-এ (খেলনা হিসাবে) মাত্র ৩২টি line ধরে। কলাম ০ শেষ হতে হতে সারি ০-এর line অনেক আগেই বের হয়ে গেছে। কলাম ১-এ সারি ০-এর জন্য সেই line আবার আনতে হয় — ১৬টি ঘরের জন্য ১৬ বার।
- ফল। একই ৬৫,৫৩৬টি ঘর, একই যোগফল। সারি ধরে ৪,০৯৬টি line আনা, কলাম ধরে ৬৫,৫৩৬টি — ১৬ গুণ। আসল কম্পিউটারে cache অনেক বড়, কিন্তু grid-ও অনেক বড় হয়; আর কলাম ধরে হাঁটলে prefetcher-ও কম সাহায্য করে। বড় grid-এ কয়েক গুণ গতির পার্থক্য খুব সাধারণ। নিচের প্রোগ্রাম এই হিসাবটিই গুনে দেখায়।
নিচের প্রোগ্রাম একটি খেলনা cache বানায়: ৩২টি line ধরে, আর জায়গা ফুরোলে সবচেয়ে পুরোনো ব্যবহৃত line বের করে দেয় (LRU নিয়ম)। তারপর ২৫৬ × ২৫৬ int grid-এর প্রতিটি ঘরের ঠিকানা দুই ক্রমে “ছুঁয়ে” গোনে, কয়বার নতুন line আনতে হলো:
#include <algorithm>
#include <deque>
#include <print>
constexpr long N = 256; // 256 x 256 int grid
long loads = 0;
std::deque<long> cache; // khelna cache: 32 line, LRU
void touch(long addr) {
long line = addr / 64; // 64-byte cache line
auto it = std::ranges::find(cache, line);
if (it != cache.end()) cache.erase(it); // hit
else { ++loads; if (cache.size() == 32) cache.pop_front(); }
cache.push_back(line); // shobcheye notun byabohar
}
int main() {
for (long r = 0; r < N; ++r) for (long c = 0; c < N; ++c) touch((r * N + c) * 4);
std::println("row-major: {} line loads", loads);
loads = 0; cache.clear();
for (long c = 0; c < N; ++c) for (long r = 0; r < N; ++r) touch((r * N + c) * 4);
std::println("column-major: {} line loads", loads);
}আউটপুট row-major: 4096 line loads আর column-major: 65536 line loads. সারি ধরে হাঁটায় প্রতিটি line থেকে ১৬টি int কাজে লেগেছে (৬৫,৫৩৬ ÷ ১৬ = ৪,০৯৬)। কলাম ধরে হাঁটায় প্রতিটি ঘরের জন্য নতুন line — কারণ কলামের ২৫৬টি সারি পার হতে হতে ৩২ ঘরের cache থেকে পুরোনো line বের হয়ে গেছে। ঠিকানার হিসাব (r * N + c) * 4 হলো row-major বিন্যাস। আসল CPU-র cache আরও জটিল (কয়েক স্তর, set-associative, prefetcher), তাই এটি শুধু ধারণার মডেল। আসল সময় মাপতে একটি বড় grid (যেমন ৪০৯৬ × ৪০৯৬) দুই ক্রমে যোগ করে -O2 দিয়ে নিজের কম্পিউটারে মাপুন (অনুশীলন ৪)।
AoS বনাম SoA — কোন ঘরগুলো পাশাপাশি রাখবেন
একটি বোর্ডের পরীক্ষার ফলাফলের সফটওয়্যারে প্রতিটি ছাত্রের তথ্য একটি struct-এ রাখা আছে, আর সবার একটি vector. এই সাধারণ বিন্যাসকে বলে array of structs (AoS):
struct Student { // AoS: protiti chhatro ekta 64-byte struct
int roll;
float gpa;
char name[56];
};
std::vector<Student> students; // shudhu gpa-r gor chaile?
struct Students { // SoA: protiti field alada sari-te
std::vector<int> roll;
std::vector<float> gpa; // shob gpa pashapashi
std::vector<std::string> name;
};এখন দশ লাখ ছাত্রের গড় GPA চাই। AoS-এ প্রতিটি ছাত্রের জন্য CPU পুরো ৬৪ বাইটের line আনে, অথচ কাজে লাগে মাত্র ৪ বাইটের gpa — বাকি ৬০ বাইট (নাম, roll) অকারণে মেমরি থেকে আসে। struct of arrays (SoA)-এ সব gpa একটি আলাদা ফাঁকহীন সারিতে, তাই প্রতিটি line-এ ১৬টি GPA — মেমরি-যাতায়াত ১৬ ভাগের এক ভাগ। তার ওপর পাশাপাশি থাকা একই টাইপের সংখ্যা compiler একসাথে কয়েকটি করে যোগ করতে পারে (SIMD).
তবে একজন ছাত্রের সব তথ্য একসাথে লাগলে (মার্কশিট ছাপানো) AoS-ই ভালো। নিয়মটি হলো: যে field-গুলো একসাথে পড়া হয়, সেগুলো পাশাপাশি রাখুন। এই চিন্তাকেই বলে data-oriented design — আগে ভাবুন ডেটা কীভাবে ব্যবহার হবে, তারপর বিন্যাস।
৬ · অকারণ কপি আর অকারণ allocation
C++-এর value semantics একটি বড় শক্তি — আবার লুকানো খরচের উৎসও। C-তে একটি বড় array কপি করতে হলে আপনাকে নিজে memcpy লিখতে হতো; C++-এ একটি = বা একটি ভুল parameter টাইপই এক লাখ উপাদানের vector চুপচাপ কপি করে ফেলে। profiler-এ এগুলো প্রায়ই দেখা যায় memcpy, operator new আর free নামে চওড়া বাক্স হিসেবে।
| যা লেখা হয় | লুকানো খরচ | যা লিখবেন |
|---|---|---|
void show(std::vector<Sale> v) | প্রতিটি ডাকে পুরো vector কপি | const&: const std::vector<Sale>& v (পর্ব ১০) |
for (auto s : sales) | প্রতিটি উপাদান কপি (string থাকলে allocation) | for (const auto& s : sales) |
শুধু পড়ার জন্য const std::string& নিয়ে "abc" পাঠানো | literal থেকে অস্থায়ী string তৈরি | std::string_view (পর্ব ১৪) |
শেষবার ব্যবহারের পরে out.push_back(line) | কপি, যদিও line আর লাগবে না | out.push_back(std::move(line)) (পর্ব ৩৬) |
সংখ্যা জেনেও লুপে push_back | বারবার reallocation আর সব উপাদান সরানো | আগে reserve(n) (পর্ব ১৩) |
ফাংশন থেকে বড় vector বা string মান হিসেবে ফেরত দেওয়া কিন্তু ঠিক আছে — copy elision আর move-এর কারণে সেখানে কপি হয় না (পর্ব ৩৬)। আর ছোট string-এর কথা মনে রাখুন: বেশিরভাগ লাইব্রেরিতে small string optimization থাকে — ছোট লেখা (লাইব্রেরি ভেদে মোটামুটি ১৫ থেকে ২২ অক্ষর পর্যন্ত) string object-এর ভেতরেই থাকে, heap-এ যায় না। তাই ছোট নামের কপি সস্তা, বড় লেখার কপি দামি।
বড় string বানানো — reserve আর +=
রিপোর্টের প্রতিটি লাইন একটি বড় string-এ জুড়ছেন। দুটি লেখার ধরন দেখতে প্রায় এক, খরচে আকাশ-পাতাল:
// dhire: protibar notun string toiri, purono shob copy - mot O(n^2)
std::string slow;
for (const auto& s : sales) slow = slow + s.name + "," + std::to_string(s.amount) + "\n";
// druto: ekbar jayga, tarpor shudhu shesh-e jog - mot O(n)
std::string fast;
fast.reserve(sales.size() * 32); // anumanik size
for (const auto& s : sales) {
fast += s.name;
fast += ',';
fast += std::to_string(s.amount);
fast += '\n';
}প্রথম লাইনে slow + s.name একটি সম্পূর্ণ নতুন string বানায়, যাতে এ পর্যন্ত জমা সব লেখা কপি হয় — প্রতিটি লাইনে। দশ হাজার লাইনের রিপোর্টে সেটা কোটি কোটি অক্ষর কপি। দ্বিতীয়টিতে += শুধু শেষে যোগ করে, আর reserve শুরুতেই যথেষ্ট জায়গা নিয়ে রাখে, তাই বারবার বড় ব্লকে সরানোও লাগে না। (C++20-এর std::format_to দিয়ে একটি back_inserter-এ লেখা আরেকটি পরিচ্ছন্ন উপায়।)
Allocation-এর দাম — pool, arena আর std::pmr
heap allocation (new, বা vector/string-এর ভেতরের allocation) একটি বিনামূল্যের কাজ নয়: allocator-কে খালি জায়গা খুঁজতে হয়, হিসাব রাখতে হয়, অনেক thread থাকলে সমন্বয় করতে হয়, আর নতুন জায়গা প্রায়ই cache-এ থাকে না। একটি গরম লুপের ভেতরে প্রতিবার একটি অস্থায়ী vector বানানো খুব সাধারণ hotspot:
for (const auto& order : orders) {
std::vector<int> items; // protibar notun allocation...
collect(order, items);
}
std::vector<int> items; // ...bodle bairer ekta, protibar clear()
for (const auto& order : orders) {
items.clear(); // size 0, kintu capacity thake
collect(order, items);
}এর চেয়ে বড় পরিসরে দুটি ধারণা আছে। object pool: একই মাপের অনেক বস্তু আগেই বানিয়ে রাখা, আর “মুছে ফেলা” বস্তু আবার ব্যবহার করা — যেমন গেমের গুলি বা নেটওয়ার্কের প্যাকেট। arena allocator: একটি বড় ব্লক নেওয়া, সেখান থেকে শুধু pointer এগিয়ে এগিয়ে ছোট ছোট জায়গা দেওয়া (খুব দ্রুত), আর কাজ শেষে পুরো ব্লক একবারে ফেরত দেওয়া — যেমন একটি HTTP অনুরোধ সামলানোর সব অস্থায়ী ডেটা। C++17 থেকে standard library-তে এর জন্য std::pmr (polymorphic memory resource) আছে, যাতে container-কে বলা যায় কোন জায়গা থেকে মেমরি নেবে:
#include <memory_resource>
std::array<std::byte, 64 * 1024> buf; // stack-e 64 KB
std::pmr::monotonic_buffer_resource arena{buf.data(), buf.size()};
std::pmr::vector<int> v{&arena}; // heap noy, arena theke
// arena dhongsho hole shob ekbare ferot - alada free neiএগুলো কোড জটিল করে — profiler allocation-কে বড় অংশ দেখালে তবেই। বেশিরভাগ ক্ষেত্রে reserve আর লুপের বাইরে container রাখাই যথেষ্ট।
৭ · CPU আর compiler-এর ভেতরের কাজ
Branch prediction — সাজানো ডেটা কেন দ্রুত
আধুনিক CPU একটি নির্দেশ শেষ হওয়ার অপেক্ষা না করে পরের অনেকগুলো নির্দেশ একসাথে এগিয়ে রাখে — কারখানার assembly line-এর মতো (pipeline). কিন্তু একটি if-এ এসে সে জানে না কোন দিকে যেতে হবে। অপেক্ষা না করে সে আন্দাজ করে এগিয়ে যায় — branch prediction. আন্দাজ ঠিক হলে কোনো খরচ নেই; ভুল হলে এগিয়ে রাখা কাজ ফেলে দিয়ে আবার শুরু — মোটামুটি ১০–২০ cycle নষ্ট, যাকে বলে branch misprediction.
long sum = 0;
for (int x : data) // data: 0..255-er elomelo shonkhya
if (x >= 128) sum += x; // ei if-er dik CPU anda-je ki dhorte pare?এলোমেলো ডেটায় এই if কোনো নিয়ম মানে না — আন্দাজ প্রায় অর্ধেক সময় ভুল। একই ডেটা আগে সাজিয়ে নিলে প্রথম অর্ধেক সব “না”, পরের অর্ধেক সব “হ্যাঁ” — আন্দাজ প্রায় সবসময় ঠিক। ২০১২ সালে Stack Overflow-এ “Why is processing a sorted array faster than processing an unsorted array?” প্রশ্নটি এই কারণেই বিখ্যাত হয়: প্রশ্নকর্তার মাপে সাজানো ডেটায় একই লুপ কয়েক গুণ দ্রুত চলেছিল। কিন্তু সেই আলোচনার একটি অংশ প্রায়ই ভুলে যাওয়া হয়: নতুন compiler উঁচু optimization-এ এই if-কে শাখাহীন নির্দেশ (যেমন conditional move) বা SIMD-এ বদলে দিতে পারে, আর তখন পার্থক্য প্রায় মুছে যায়। অর্থাৎ — আবার — মাপুন।
পর্ব ৪২-এ দেখা [[likely]] / [[unlikely]] (C++20 থেকে) compiler-কে বলে কোন শাখা বেশি ঘটে, যাতে সে সেই পথের কোড সোজা সাজায়। এটি ইঙ্গিত মাত্র, ভুল হলে উল্টো ক্ষতি; নিচের PGO আসল চলার তথ্য থেকে একই কাজ আরও নির্ভরযোগ্যভাবে করে।
Compiler আপনার সবচেয়ে বড় সহকারী
বেশিরভাগ “ছোট কৌশল” — গুণের বদলে shift, ছোট ফাংশন জুড়ে দেওয়া, লুপের ভেতরের স্থির হিসাব বাইরে আনা — compiler -O2-এ নিজেই করে। এর সবচেয়ে গুরুত্বপূর্ণটি হলো inlining: ছোট ফাংশনের ডাকের জায়গায় তার শরীরটাই বসিয়ে দেওয়া। এতে শুধু ডাকার খরচ বাঁচে না, আশপাশের কোডের সাথে মিলিয়ে আরও optimization-এর সুযোগ খোলে। এ কারণেই std::sort একটি lambda নিয়ে C-এর qsort-এর চেয়ে প্রায়ই দ্রুত — qsort প্রতিটি তুলনায় একটি function pointer ডাকে, sort-এর তুলনা inline হয়ে যায়।
| flag / কৌশল | কী করে | সাবধানতা |
|---|---|---|
-O2 | নিরাপদ, প্রচলিত optimization-এর পুরো সেট | release build-এর স্বাভাবিক পছন্দ |
-O3 | আরও আক্রমণাত্মক inlining, loop vectorization | কোড বড় হয়; সবসময় দ্রুত নয় — মেপে দেখুন |
-march=native | এই মেশিনের CPU-র সব নির্দেশ (যেমন AVX2) ব্যবহার | অন্য বা পুরোনো CPU-তে প্রোগ্রাম “illegal instruction” দিয়ে বন্ধ হতে পারে; অন্যকে দেওয়ার build-এ নয় |
-flto (LTO) | link-এর সময় সব ফাইল একসাথে দেখে optimize; ফাইলের সীমা পেরিয়ে inlining | build ধীর হয় |
| PGO | আগে চালিয়ে আসল ব্যবহারের তথ্য জমা (-fprofile-generate), তারপর সেই তথ্য দিয়ে compile (-fprofile-use) | প্রতিনিধিত্বমূলক ইনপুট লাগে; build প্রক্রিয়া জটিল |
-ffast-math | floating-point-এর কঠোর নিয়ম শিথিল করে দ্রুত করে | ফল বদলাতে পারে, NaN-এর হিসাব ভাঙতে পারে — বুঝে না নিলে নয় |
compiler আসলে কী বানাল, তা দেখার সবচেয়ে সহজ উপায় Compiler Explorer — godbolt.org (Matt Godbolt-এর বানানো)। বাঁ দিকে C++ লিখুন, ডান দিকে সাথে সাথে assembly দেখুন, বিভিন্ন compiler আর flag পাশাপাশি তুলনা করুন। একটি পরীক্ষা করে দেখুন: x / 2 (unsigned) আর x >> 1 লিখে -O2-এ দুটির assembly — হুবহু এক। হাতে bit hack লিখে আপনি কিছুই পাননি, শুধু কোড পড়া কঠিন করেছেন।
C-তে যা শিখেছেন, তার প্রায় সবই এখানে একই: cache, row-major বিন্যাস, -O2, gprof আর perf, Big-O. পার্থক্য দুই দিকে। C++-এর abstraction — vector, template, lambda, std::sort — optimization চালু থাকলে হাতে লেখা C-এর সমান বা আরও দ্রুত হতে পারে (inlining-এর কারণে)। কিন্তু C++-এ কপি আর allocation লুকিয়ে থাকে: একটি value parameter বা auto range-for পুরো vector বা string কপি করে, যা C-তে নিজে memcpy না লিখলে কখনো হতো না। জটিলতার হিসাব গভীরে দেখতে C কোর্সের কমপ্লেক্সিটি — Big-O আর বহুমাত্রিক অ্যারে পর্ব দেখুন।
৮ · শেষ অস্ত্র thread — আর কখন optimize করবেন না
একটি CPU-তে আজ ৮, ১৬ বা তারও বেশি core. তাহলে কাজটি ৮টি thread-এ ভাগ করলেই কি ৮ গুণ দ্রুত? পর্ব ৪৫-এ thread লিখতে শিখেছেন; এখানে প্রশ্নটা কখন। CPU-নির্ভর কাজে thread হলো শেষ অস্ত্র, প্রথম নয়, তিনটি কারণে।
- ভাগ না করা অংশ সীমা টানে। Amdahl's law: প্রোগ্রামের যে অংশ ভাগ করা যায় না, সেটিই সর্বোচ্চ গতি ঠিক করে। কাজের ২০% যদি এক thread-এই চলতে বাধ্য হয়, তাহলে অসীম core দিলেও ৫ গুণের বেশি দ্রুত হবে না (ভাবনার প্রশ্ন ০৫)।
- সমন্বয়ের খরচ। thread বানানো, mutex, কাজ ভাগ আর ফল জোড়া — সবকিছুর দাম আছে। আর দুটি thread যদি একই cache line-এর দুটি আলাদা ভ্যারিয়েবলে লেখে, CPU সেই line দুই core-এর মাঝে ক্রমাগত ঠেলাঠেলি করে — false sharing — আর multithread কোড single-thread-এর চেয়েও ধীর হতে পারে।
- বাগের নতুন জগৎ। data race (পর্ব ৪৩) আর deadlock খুঁজে পাওয়া কঠিন, কখনো কখনো শুধু গ্রাহকের মেশিনে দেখা দেয়।
তাই ক্রমটা মনে রাখুন: আগে algorithm, তারপর ডেটা-বিন্যাস আর কপি, তারপর compiler flag — সব শেষে, profiler দেখালে যে একটি বড়, স্বাধীনভাবে ভাগ করা যায় এমন কাজ এখনো সময় নিচ্ছে, তখন thread. সাধারণত std::sort-এর মতো algorithm-এ execution policy (std::execution::par) দিয়ে শুরু করা সবচেয়ে নিরাপদ।
কখন optimize করবেন না
🛑 থামুন যখন…
লক্ষ্য পূরণ হয়ে গেছে। কোডটি profiler-এ কোথাও চোখে পড়ে না। প্রোগ্রাম একবার চলে আর এক সেকেন্ডে শেষ হয়। কাজটা আসলে নেটওয়ার্ক বা ডিস্কের অপেক্ষা, CPU নয়। কোডটি এখনো ঠিক উত্তর দেয় না।
✅ শুরু থেকেই ভাবুন যখন…
ডেটার আকার বড় হবে বলে জানা — তখন O(n²) এড়ানো premature নয়, সাধারণ বুদ্ধি। container বাছাই (ডিফল্ট vector), const& আর reserve — এগুলো কোড জটিল না করেই ভালো অভ্যাস। Knuth-ও বলেছেন ৩%-এর সুযোগ ছাড়বেন না।
খেয়াল করুন, দুটি বিপরীত ভুল আছে। একটি হলো আন্দাজে কোড দুর্বোধ্য করা। অন্যটি হলো “premature optimization খারাপ” বলে শুরু থেকেই স্পষ্টভাবে বাজে নকশা বেছে নেওয়া — যেমন প্রতিটি অনুরোধে পুরো ডেটাবেস টেবিল মেমরিতে আনা। প্রথমটির নাম premature optimization; দ্বিতীয়টির নাম অবহেলা। ভালো নকশা আর পরিষ্কার কোড কখনো premature নয়।
তারপর — মেপে — দ্রুত।
- “Premature optimization is the root of all evil” — পুরো অর্থ কী? — Knuth (১৯৭৪) বলেছেন প্রায় ৯৭% কোডে ছোট কৌশল অর্থহীন, কিন্তু সংকটপূর্ণ ৩% মেপে খুঁজে সেখানে সুযোগ ছাড়া যাবে না।
- latency আর throughput-এর পার্থক্য? — latency একটি কাজের সময়; throughput একক সময়ে কাজের সংখ্যা। batching throughput বাড়ায়, কিন্তু latency বাড়াতে পারে।
- 2D array-তে
for i for j a[i][j]কেনa[j][i]-এর চেয়ে দ্রুত? — C++-এ array row-major; ভেতরের লুপে j বদলালে ঠিকানা পাশাপাশি এগোয়, প্রতিটি cache line-এর পুরোটা কাজে লাগে। - AoS আর SoA কী, কখন কোনটা? — AoS-এ প্রতিটি বস্তুর সব field একসাথে, SoA-তে প্রতিটি field আলাদা সারিতে। অল্প কয়েকটি field নিয়ে বহু বস্তুর ওপর কাজে SoA; একটি বস্তুর সব field একসাথে লাগলে AoS.
- benchmark-এ ফল ০ ns এলে কী সন্দেহ করবেন? — ফলাফল ব্যবহার না হওয়ায় optimizer কাজটাই মুছে ফেলেছে (dead code elimination); DoNotOptimize বা ফল ছাপিয়ে ঠিক করুন।
৯ · AI-এর যুগে — মাপা ছাড়া “দ্রুত” দাবি
AI-কে “এই কোড optimize করো” বললে সে প্রায়ই আত্মবিশ্বাসী উত্তর দেয়: “এখন ৩ গুণ দ্রুত!” — অথচ সে কোডটি একবারও চালায়নি, মাপেনি। তার পরিবর্তনগুলো প্রায়ই পুরোনো দিনের ছোট কৌশল, যা compiler নিজেই করে। অন্যদিকে AI একটি কাজে সত্যিই ভালো: কোড পড়ে জটিলতা বিশ্লেষণ করা আর ভালো data structure প্রস্তাব করা — যা মাপার পরে আপনি যাচাই করতে পারেন।
| AI যা করে | সমস্যা | যা হওয়া উচিত |
|---|---|---|
| হাতে loop unrolling (এক লুপে চারটি করে) | -O2/-O3 নিজেই করে; কোড লম্বা, শেষের বাকি ঘরে বাগের ঝুঁকি | সরল লুপ; দরকার হলে assembly দেখে সিদ্ধান্ত |
/ 2-এর বদলে >> 1, বিট-কৌশল | compiler একই কোড বানায়; signed সংখ্যায় ফল আলাদা হতে পারে | যা বোঝাতে চান তা-ই লিখুন |
সব ফাংশনে inline | C++-এ inline মূলত ODR-এর নিয়ম, “inline করো” আদেশ নয় | inline-এর সিদ্ধান্ত compiler-এর ওপর ছাড়ুন; LTO |
| “৩ গুণ দ্রুত” দাবি, কোনো মাপ ছাড়া | যাচাই না করা দাবি; noise বা -O0-এর মাপ | একই ডেটা, একই flag, before/after median |
vector বাদ দিয়ে raw array আর new | গতি একই, নিরাপত্তা কম, leak-এর ঝুঁকি | std::vector + reserve |
-ffast-math, -march=native চুপচাপ যোগ | ফল বদলাতে পারে; অন্য মেশিনে প্রোগ্রাম বন্ধ | কারণ আর ঝুঁকি লিখে, মেপে, তারপর |
লুপে std::endl রেখে দেওয়া, বা বাদ দিতে বলা | endl প্রতিবার flush করে — এটা সত্যিই বড় খরচ হতে পারে | এটি একটি আসল উন্নতি: '\n', তবে মেপে নিশ্চিত হোন |
AI-কে “দ্রুত করো” না বলে “বিশ্লেষণ করো আর মাপার পরিকল্পনা দাও” বলুন। profiler-এর ফল সাথে দিলে সে অনুমানের বদলে আসল hotspot নিয়ে ভাবে:
AI-এর “optimized” কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Knuth-এর “premature optimization” উক্তিটি একটি goto-বিষয়ক প্রবন্ধে কেন? প্রবন্ধটি আসলে কী বলছিল, আর উক্তিটি আজ কীভাবে ভুল ব্যবহার হয়?
১৯৬৮ সালে Edsger Dijkstra-র একটি ছোট চিঠি “Go To Statement Considered Harmful” প্রকাশিত হয়, আর তার পরে কয়েক বছর ধরে প্রোগ্রামিং জগতে তুমুল বিতর্ক চলে: goto কি পুরোপুরি নিষিদ্ধ হওয়া উচিত? এই বিতর্কের মাঝে Donald Knuth ১৯৭৪ সালের ডিসেম্বরে ACM Computing Surveys-এ লেখেন “Structured Programming with go to Statements”. তাঁর অবস্থান ছিল ভারসাম্যপূর্ণ: structured programming-এর লক্ষ্য ঠিক, কিন্তু কিছু ক্ষেত্রে goto (বা তার সমতুল্য কাঠামো) কোডকে পরিষ্কারও রাখে, দ্রুতও করে। প্রবন্ধের একটি বড় অংশ তাই দক্ষতা নিয়ে — লুপ থেকে বের হওয়া, খোঁজার লুপে একটি তুলনা কমানো ইত্যাদি।
এই প্রসঙ্গেই সেই বিখ্যাত অনুচ্ছেদ। পুরো লেখাটি পড়লে দেখা যায়, Knuth দক্ষতার বিরোধী ছিলেন না — বরং তিনি একই প্রবন্ধে লিখেছেন যে প্রতিষ্ঠিত প্রকৌশল শাখায় সহজে পাওয়া ১২% উন্নতিকে কেউ তুচ্ছ মনে করে না, সফটওয়্যারেও তা করা উচিত নয়। তাঁর আপত্তি ছিল দুটি জিনিসে: অসংকটপূর্ণ জায়গায় সময় নষ্ট, আর অনুমানের ওপর ভরসা। তিনি জোর দিয়েছিলেন মাপার যন্ত্রের ওপর — তাঁর পর্যবেক্ষণে, যাঁরা মাপার টুল ব্যবহার করেছেন তাঁদের সাধারণ অভিজ্ঞতা হলো, কোথায় সময় যায় সে বিষয়ে আন্দাজ প্রায়ই ভুল হয়। তাই একজন ভালো প্রোগ্রামার সংকটপূর্ণ কোড মনোযোগ দিয়ে দেখবেন, কিন্তু সেটি চিহ্নিত হওয়ার পরে।
আজ উক্তিটির অর্ধেক — “root of all evil” — উদ্ধৃত হয়, আর পরের বাক্যটি (“critical 3%”-এর সুযোগ ছাড়বেন না) প্রায়ই বাদ পড়ে। ফলে এটি হয়ে দাঁড়ায় একটি অজুহাত: “পারফরম্যান্স নিয়ে পরে ভাবব”, যদিও শুরুতেই O(n²) নকশা বা প্রতিটি অনুরোধে পুরো ডেটাবেস পড়া বেছে নেওয়া হচ্ছে। এটা Knuth-এর কথার উল্টো। মজার তথ্য: Knuth পরে এক লেখায় উক্তিটিকে Tony Hoare-এর বলে উল্লেখ করেছিলেন, কিন্তু Hoare নিজে বলেছেন তাঁর এমন কিছু বলার কথা মনে নেই — তাই সাধারণত এটি Knuth-এর ১৯৭৪ সালের প্রবন্ধের উক্তি হিসেবেই ধরা হয়।
মূল উপলব্ধি: উক্তিটি “পারফরম্যান্স অবহেলা করো” নয়, বরং “অনুমানে নয়, মেপে, সঠিক জায়গায় পারফরম্যান্সে মন দাও”। ৯৭% কোডে পরিষ্কারতা, ৩% কোডে পূর্ণ মনোযোগ — আর কোনটা কোন দলে, তা ঠিক করে মাপ।
প্র ০২Stroustrup-এর zero-overhead principle কী বলে? C++-এর abstraction কি সত্যিই “বিনামূল্যে” — নাকি এর সীমা আছে?
Bjarne Stroustrup C++-এর নকশার একটি মূল নীতি দুই ভাগে বলেছেন: “What you don't use, you don't pay for. And further: What you do use, you couldn't hand code any better.” প্রথম ভাগ: যে বৈশিষ্ট্য আপনি ব্যবহার করেন না, তার কোনো খরচ আপনার প্রোগ্রামে আসবে না — virtual ফাংশন না লিখলে vtable নেই, exception না ছুড়লে (সাধারণ implementation-এ) সাধারণ পথে বাড়তি নির্দেশ নেই। দ্বিতীয় ভাগ: যা ব্যবহার করেন, তা আপনি হাতে লিখলেও এর চেয়ে ভালো লিখতে পারতেন না — std::vector-এর [] একটি pointer-যোগ, std::unique_ptr একটি সাধারণ pointer-এর সমান আকার, std::sort তুলনাকে inline করে হাতে লেখা sort-এর মতোই দ্রুত।
এই নীতির জন্যই C++ বহু বছর ধরে গেম ইঞ্জিন, ব্রাউজার, ডেটাবেস, ট্রেডিং সিস্টেম আর embedded সিস্টেমে টিকে আছে: আপনি উঁচু স্তরের কোড লেখেন, কিন্তু নিচু স্তরের খরচের নিয়ন্ত্রণ হারান না। Java বা Python-এর মতো ভাষা অনেক সুবিধা দেয়, কিন্তু garbage collector বা interpreter-এর মতো কিছু খরচ সবসময় দিতে হয়।
তবে নীতিটির তিনটি সীমা জানা দরকার। প্রথমত, “বিনামূল্যে” কথাটি optimization চালু থাকলেই সত্যি। -O0-এ প্রতিটি ছোট template ফাংশন একটি আসল ডাক, তাই C++-এর debug build অনেক সময় C-এর চেয়ে অনেক ধীর — গেম ডেভেলপাররা এ নিয়ে প্রায়ই অভিযোগ করেন। দ্বিতীয়ত, কিছু বৈশিষ্ট্য নীতিটি পুরো মানে না বলে বিতর্ক আছে: exception আর RTTI বাইনারির আকার বাড়ায়, তাই অনেক প্রকল্প -fno-exceptions দিয়ে এগুলো বন্ধ রাখে। তৃতীয়ত, Chandler Carruth CppCon 2019-এ “There Are No Zero-cost Abstractions” বক্তৃতায় দেখান, কিছু abstraction-এর খরচ সূক্ষ্ম জায়গায় লুকিয়ে থাকে — যেমন কিছু প্ল্যাটফর্মের ABI-তে unique_ptr মান হিসেবে পাঠালে register-এ না গিয়ে মেমরি দিয়ে যায়; আর প্রতিটি abstraction compile-এর সময় আর মানুষের বোঝার সময়ও খরচ করে।
মূল উপলব্ধি: zero-overhead একটি নকশার প্রতিশ্রুতি, জাদু নয়। এটি সত্যি হয় optimizer চালু থাকলে আর আপনি খরচ কোথায় তা জানলে — তাই C++-এ abstraction নির্ভয়ে ব্যবহার করুন, কিন্তু assembly আর profiler দিয়ে মাঝে মাঝে যাচাই করুন।
প্র ০৩Mike Acton-এর CppCon 2014-এর “Data-Oriented Design and C++” বক্তৃতা এত আলোচিত কেন? object-ভিত্তিক নকশার সাথে তার দ্বন্দ্বটা কোথায়?
২০১৪ সালের CppCon-এ Insomniac Games-এর (Spider-Man, Ratchet & Clank গেমের নির্মাতা) ইঞ্জিন বিভাগের প্রধান Mike Acton একটি মূল বক্তৃতা দেন, যা C++ সম্প্রদায়ে আজও সবচেয়ে বেশি উদ্ধৃত বক্তৃতাগুলোর একটি। তাঁর কেন্দ্রীয় দাবি: প্রতিটি প্রোগ্রামের আসল কাজ ডেটাকে এক রূপ থেকে আরেক রূপে বদলানো। তাই নকশা শুরু করা উচিত ডেটা থেকে — কী ডেটা, কতগুলো, কতবার, কোন ক্রমে পড়া হয় — বাস্তব জগতের “বস্তু”র মডেল থেকে নয়। তাঁর আরেকটি কথা: সফটওয়্যার প্ল্যাটফর্ম নয়, হার্ডওয়্যারই প্ল্যাটফর্ম; আর হার্ডওয়্যারের সবচেয়ে বড় সত্য হলো cache miss-এর দাম।
দ্বন্দ্বটা দেখা যায় একটি সাধারণ উদাহরণে। object-ভিত্তিক নকশায় একটি GameObject class-এ অবস্থান, গতি, রং, নাম, একটি “সক্রিয় কি না” bool — সব একসাথে, আর প্রতিটি বস্তুর একটি update(). Acton দেখান, প্রতি ফ্রেমে শুধু অবস্থান হালনাগাদ করতে CPU প্রতিটি বস্তুর পুরো cache line আনে, যার বেশিরভাগ অব্যবহৃত — ঠিক যেমন আপনি AoS-এ GPA গড়ের উদাহরণে দেখেছেন। তাঁর আরেকটি বিখ্যাত পর্যবেক্ষণ: “যেখানে একটি আছে, সেখানে অনেক আছে” — একটি বস্তুর জন্য ফাংশন না লিখে হাজার বস্তুর একটি সারির জন্য লিখুন, তাহলে লুপ, cache আর SIMD সবাই সাহায্য করে।
বক্তৃতাটি বিতর্কও তুলেছিল। প্রশ্নোত্তর পর্বে কিছু শ্রোতা বলেন, সব সফটওয়্যার গেম ইঞ্জিন নয় — একটি ব্যাংকের হিসাবের সফটওয়্যারে রক্ষণাবেক্ষণ আর পরিবর্তনের সহজতা প্রায়ই কাঁচা গতির চেয়ে বেশি দামি। দুই পক্ষই আংশিক ঠিক। এই চিন্তা থেকে গেম শিল্পে Entity Component System (ECS) জনপ্রিয় হয়েছে, যেখানে প্রতিটি component টাইপ নিজের ফাঁকহীন সারিতে থাকে — মূলত SoA. ডেটাবেসে একই ধারণা column store: একটি কলামের সব মান পাশাপাশি, তাই “সব বিক্রির যোগফল” অনেক দ্রুত।
মূল উপলব্ধি: নকশার প্রথম প্রশ্ন হতে পারে “ডেটা কীভাবে চলাচল করবে?”, শুধু “কোন বস্তু কী করে?” নয়। গরম পথে ডেটা-বিন্যাস প্রায়ই algorithm-এর পরেই সবচেয়ে বড় গতির উৎস — আর দুই চিন্তা একই প্রোগ্রামের আলাদা অংশে পাশাপাশি থাকতে পারে।
প্র ০৪“Latency numbers every programmer should know” তালিকাটি কোথা থেকে এল? সংখ্যাগুলো বদলে গেলেও তালিকাটি আজও কেন জরুরি?
২০০১ সালে Peter Norvig-এর প্রবন্ধ “Teach Yourself Programming in Ten Years”-এ একটি ছোট টেবিল ছিল: একটি সাধারণ নির্দেশ চালাতে, L1 cache থেকে পড়তে, ভুল branch আন্দাজে, RAM থেকে পড়তে, ডিস্ক থেকে পড়তে আর অন্য মহাদেশে প্যাকেট পাঠাতে কত সময় লাগে। পরে Google-এর Jeff Dean তাঁর বড় distributed system-এর বক্তৃতাগুলোতে এমন একটি তালিকা ব্যবহার করেন, আর তালিকাটি “Latency numbers every programmer should know” নামে ইন্টারনেটে ছড়িয়ে পড়ে। মোটামুটি সংখ্যা: L1 থেকে পড়া প্রায় অর্ধেক ন্যানোসেকেন্ড, branch mispredict কয়েক ন্যানোসেকেন্ড, RAM থেকে প্রায় ১০০ ন্যানোসেকেন্ড, SSD থেকে এলোমেলো একটি ছোট অংশ পড়া শত মাইক্রোসেকেন্ডের ঘরে, পুরোনো hard disk-এ খোঁজা প্রায় ১০ মিলিসেকেন্ড, আর আমেরিকা থেকে ইউরোপে গিয়ে প্যাকেট ফেরত আসতে প্রায় ১৫০ মিলিসেকেন্ড।
এই সংখ্যাগুলো মানুষের মাপে আনলে বোঝা সহজ। L1 পড়াকে এক সেকেন্ড ধরলে RAM পড়া কয়েক মিনিট, SSD পড়া কয়েক দিন, hard disk-এ খোঁজা কয়েক মাস, আর মহাদেশ পেরোনো প্যাকেট কয়েক বছর! অর্থাৎ একটি ভুল জায়গা থেকে ডেটা আনা হাজার, লাখ, কোটি গুণ দামি হতে পারে।
সময়ের সাথে সংখ্যাগুলো বদলেছে — CPU অনেক দ্রুত হয়েছে, SSD এসে ডিস্কের ছবি বদলে দিয়েছে। কিন্তু একটি জিনিস প্রায় বদলায়নি: RAM-এর latency. ১৯৮০-এর দশক থেকে CPU-র গতি যে হারে বেড়েছে, মেমরির latency সেভাবে কমেনি; ১৯৯৫ সালে Wulf আর McKee এই ফাঁকের নাম দেন “memory wall”. তাই আজ একটি cache miss-এ CPU যত নির্দেশ চালাতে পারত, তা আগের চেয়ে বেশি — cache-বান্ধব কোডের গুরুত্ব কমেনি, বেড়েছে। তালিকার মূল্য তাই নির্দিষ্ট সংখ্যায় নয়, মাত্রার অনুপাতে। একটি নকশা পর্যালোচনায় “এই লুপের ভেতরে প্রতিবার একটি নেটওয়ার্ক ডাক” দেখলেই একজন অভিজ্ঞ প্রকৌশলী বোঝেন, মাপার আগেই এটি লাখ গুণ ভুলের ঘরে।
মূল উপলব্ধি: সংখ্যা মুখস্থ করার দরকার নেই, মাত্রা মনে রাখুন: register < cache < RAM < SSD < ডিস্ক < নেটওয়ার্ক, প্রতিটি ধাপে বহু গুণ লাফ। ডেটা কোথায় আছে — এই প্রশ্নই অনেক সময় algorithm-এর চেয়েও বড়।
প্র ০৫Amdahl's law মূলত parallel কম্পিউটারের যুক্তি ছিল। একটি single-thread প্রোগ্রাম optimize করার সময় এই সূত্র কী শেখায়?
১৯৬৭ সালে IBM-এর কম্পিউটার স্থপতি Gene Amdahl একটি সম্মেলনে (AFIPS Spring Joint Computer Conference) যুক্তি দেন, অনেক প্রসেসর জুড়ে দিলেই কম্পিউটার সমানুপাতিক হারে দ্রুত হবে না — কারণ প্রতিটি কাজের একটি অংশ ক্রমানুসারে চলতেই হয়। তাঁর যুক্তি পরে একটি সূত্রে লেখা হয়: কাজের যে ভগ্নাংশ p দ্রুত করা যায়, তাকে s গুণ দ্রুত করলে মোট গতি বাড়ে 1 / ((1 - p) + p / s) গুণ। s অসীম হলেও সর্বোচ্চ লাভ 1 / (1 - p).
এই সূত্র শুধু thread-এর জন্য নয় — যেকোনো optimization-এর জন্য খাটে। ধরুন একটি প্রোগ্রামের ৩০% সময় যায় একটি ফাংশনে, আর আপনি কঠোর পরিশ্রম করে সেটি ১০ গুণ দ্রুত করলেন। মোট লাভ: 1 / (0.7 + 0.03) ≈ ১.৩৭ গুণ। সেটিকে অসীম দ্রুত করলেও লাভ ১.৪৩ গুণের বেশি নয়। অন্যদিকে যে অংশে ৯০% সময় যায়, তাকে মাত্র দ্বিগুণ দ্রুত করলেই লাভ 1 / (0.1 + 0.45) ≈ ১.৮২ গুণ। শিক্ষা পরিষ্কার: কোন অংশ দ্রুত করছেন, তা কতটা দ্রুত করছেন তার চেয়ে বেশি গুরুত্বপূর্ণ। আর কোন অংশে কত সময় যায়, সেই p জানার একমাত্র উপায় profiler. দোকানের গল্পের প্রোগ্রামার মাত্র ১% অংশ দ্বিগুণ দ্রুত করে পেয়েছিলেন আধা শতাংশ উন্নতি — Amdahl ঠিক এটাই বলেন।
সূত্রটির আরেকটি দিকও আছে। প্রতিটি সফল optimization বাকি অংশের ভাগ বাড়িয়ে দেয়: hotspot ঠিক করার পরে যে ফাংশন আগে ৫% ছিল, সে এখন হয়তো ৪০%. তাই optimization একটি চক্র — প্রতিবার নতুন করে মাপতে হয়। ১৯৮৮ সালে John Gustafson আরেকটি দৃষ্টিভঙ্গি দেন: বাস্তবে বেশি প্রসেসর পেলে মানুষ সমস্যাটাও বড় করে (আরও বড় grid, আরও বেশি ডেটা), আর তখন parallel অংশের ভাগ বাড়ে — তাই বড় সমস্যায় parallelism-এর লাভ Amdahl-এর হিসাবের চেয়ে বেশি দেখা যায়। দুটো সূত্র একে অপরের বিরোধী নয়; একটি নির্দিষ্ট কাজের গতির কথা বলে, অন্যটি একই সময়ে বড় কাজের।
মূল উপলব্ধি: মোট লাভ সীমিত হয় যে অংশ আপনি ছুঁতে পারেননি, তা দিয়ে। তাই সবচেয়ে বড় অংশ থেকে শুরু করুন, প্রতিবার নতুন করে মাপুন, আর যখন বাকি অংশগুলো ছোট ছোট সমান টুকরো হয়ে যায় — তখন বুঝবেন থামার সময় এসেছে।
অনুশীলন
এগুলোর বেশিরভাগে কোড লিখতে হবে না — হাতে-কলমে দেখা আর ভাবাই আসল কাজ।
-
কোন অংশ আগে: একটি রিপোর্টে ১০ সেকেন্ড লাগে: ৬ সেকেন্ড ডেটা খোঁজায়, ৩ সেকেন্ড লাইন সাজানোয় (format), ১ সেকেন্ড ফাইলে লেখায়। (ক) format ৩ গুণ দ্রুত করলে মোট সময় কত? (খ) খোঁজা মাত্র ২ গুণ দ্রুত করলে? (গ) ফাইলে লেখা অসীম দ্রুত করলে সর্বোচ্চ লাভ কত?
(ক) ৬ + ১ + ১ = ৮ সেকেন্ড (১.২৫ গুণ)। (খ) ৩ + ৩ + ১ = ৭ সেকেন্ড (প্রায় ১.৪৩ গুণ) — ছোট উন্নতি, কিন্তু বড় অংশে, তাই বেশি লাভ। (গ) ৬ + ৩ + ০ = ৯ সেকেন্ড, অর্থাৎ বড়জোর প্রায় ১.১১ গুণ। এটাই Amdahl's law: সবচেয়ে বড় অংশ — খোঁজা — দিয়ে শুরু করুন, আর আগে দেখুন সেখানে algorithm বদলে (যেমন O(n·m) থেকে O(n log m)) আরও বড় লাভ আছে কি না।
-
Flame graph পড়ুন: একটি সার্ভারের flame graph-এ নিচ থেকে ওপরে:
main১০০%; তার ওপরেhandle_request৯৫%; তার ওপরেparse_json২০%,log_message৬০%,compute১৫%; আরlog_message-এর ওপরেstd::endlথেকে আসাflush৫৫%. hotspot কোথায়, আর প্রথম কী চেষ্টা করবেন?সবচেয়ে চওড়া অপ্রত্যাশিত বাক্স
log_message— মোট সময়ের ৬০%, যেখানে আসল কাজ (compute) মাত্র ১৫%. তার ভেতরে ৫৫%-ইflush: প্রতিটি log লাইনেstd::endlbuffer খালি করে ফাইলে বা টার্মিনালে লিখছে। প্রথম চেষ্টা:std::endl-এর বদলে'\n', যাতে buffer ভরলে একবারে লেখা হয়; দরকার হলে log-এর মাত্রা কমানো। তারপর আবার মাপুন — এরপর সম্ভবতparse_jsonপরের লক্ষ্য। খেয়াল করুন, কেউ এখানে আন্দাজেcompute-কে optimize করত, যেখানে বড়জোর ১৫% বাঁচত। -
লুকানো কপি খুঁজুন: নিচের ফাংশনে কমপক্ষে তিনটি অকারণ কপি বা allocation আছে। খুঁজে ঠিক করুন:
double avg(std::vector<Student> v)— ভেতরেfor (auto s : v)দিয়েtotal += s.gpa, আর প্রতিটি ছাত্রের জন্যlog = log + s.name + "\n".১. parameter:
std::vector<Student> vমান হিসেবে নেওয়ায় প্রতিটি ডাকে পুরো vector (আর প্রতিটি ছাত্রের নামের string) কপি। ঠিক:const std::vector<Student>& v. ২. লুপ:for (auto s : v)প্রতিটি Student কপি করে। ঠিক:for (const auto& s : v). ৩. string জোড়া:log = log + ...প্রতিবার পুরো log কপি করে নতুন string বানায় — মোট O(n²). ঠিক:log += s.name; log += '\n';আর সম্ভব হলে শুরুতেlog.reserve(...). বাড়তি পর্যবেক্ষণ: শুধু GPA-র গড় অনেকবার লাগলে SoA-তে GPA আলাদা সারিতে রাখা আরও cache-বান্ধব। -
নিজে মেপে দেখুন (কোড লেখার কাজ): একটি ৪০৯৬ × ৪০৯৬
intgrid (একটিstd::vector<int>, সূচকr * N + c) বানিয়ে সারি ধরে আর কলাম ধরে যোগ করুন।steady_clockদিয়ে দুটোর সময় মাপুন — একবার-O0, একবার-O2দিয়ে compile করে। যোগফল ছাপাতে ভুলবেন না। কী দেখলেন, কেন?কাঠামো: grid-এ যেকোনো নির্দিষ্ট মান রাখুন (যেমন
(r + c) % 7), প্রতিটি ক্রমে কয়েকবার চালিয়ে median নিন, আর যোগফল ছাপান — না ছাপালে-O2-এ compiler পুরো লুপ মুছে ফেলতে পারে আর আপনি ০ সময় পাবেন। grid-টি ৬৪ MB, যা সাধারণ L3 cache-এর চেয়ে অনেক বড়।সাধারণ কম্পিউটারে কলাম ধরে যোগ কয়েক গুণ ধীর হওয়ার কথা — ঠিক অনুপাত আপনার CPU-র cache আর মেমরির ওপর নির্ভর করে, তাই সংখ্যাটি নিজেই দেখুন।
-O2-এ সারি ধরে যোগ আরও দ্রুত হয়, কারণ compiler পাশাপাশি থাকা সংখ্যা SIMD দিয়ে একসাথে যোগ করতে পারে; কলাম ধরে লুপে সেই সুযোগ কম।-O0-এ দুটোই ধীর, আর পার্থক্যও কম দেখাতে পারে, কারণ তখন নির্দেশ চালানোর খরচই বড় — এ কারণেই-O0-এর মাপ বিভ্রান্তিকর।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি ধীর প্রোগ্রাম দ্রুত করার পেশাদার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- সংখ্যায় লক্ষ্য ঠিক করা (যেমন “১ সেকেন্ডের মধ্যে”)
- আসল build flag-এ baseline মাপা
- profiler দিয়ে hotspot খুঁজে বের করা
- hotspot-এ algorithm বা ডেটা-বিন্যাস বদলানো
- একই শর্তে আবার মেপে লক্ষ্যের সাথে মেলানো
Knuth-এর ১৯৭৪ সালের প্রবন্ধে “premature optimization is the root of all evil” বাক্যের ঠিক পরের কথাটির মূল বক্তব্য কী?
- পারফরম্যান্স নিয়ে কখনো ভাবার দরকার নেই
- সব কোড শুরু থেকেই সর্বোচ্চ দ্রুত করে লিখতে হবে
- সংকটপূর্ণ প্রায় ৩% কোডে optimization-এর সুযোগ ছাড়া যাবে না
- goto ব্যবহার করলেই প্রোগ্রাম ধীর হয়
C++-এ int a[2000][2000]-এর সব ঘর যোগ করতে কোন লুপ সাধারণত দ্রুত?
- বাইরে i, ভেতরে j:
a[i][j] - বাইরে j, ভেতরে i:
a[i][j] - দুটো সবসময় হুবহু সমান দ্রুত
- কলাম ধরে, কারণ C++ array column-major
একটি benchmark-এ ১০ লাখবার একটি হিসাব চালানোর মাপ এল প্রায় ০ ন্যানোসেকেন্ড। হিসাবের ফল কোথাও ব্যবহার হয়নি। সবচেয়ে সম্ভাব্য কারণ কী?
- CPU হিসাবটা অসম্ভব দ্রুত করেছে
- steady_clock এত ছোট সময় মাপতে পারে না
- সব ডেটা L1 cache-এ ছিল
- optimizer অব্যবহৃত হিসাবটাই মুছে ফেলেছে
একটি সার্ভারের “p99 latency ২০০ ms” মানে কী?
- সব অনুরোধের গড় সময় ২০০ ms
- ৯৯% অনুরোধ ২০০ ms বা তার কম সময়ে শেষ হয়
- সেকেন্ডে ৯৯টি অনুরোধ সামলানো যায়
- সবচেয়ে ধীর অনুরোধটি ২০০ ms নিয়েছে
একটি প্রোগ্রামের ৪০% সময় যায় একটি ফাংশনে। সেটিকে অসীম দ্রুত করলেও মোট গতি সর্বোচ্চ কত গুণ বাড়বে?
- অসীম গুণ
- ১.৪ গুণ
- প্রায় ১.৬৭ গুণ
- ২.৫ গুণ
একটি flame graph-এ একটি ফাংশনের বাক্স খুব চওড়া। এর সঠিক অর্থ কোনটি?
- মোট sample-এর বড় অংশে ফাংশনটি call stack-এ ছিল
- ফাংশনটি প্রোগ্রামের শুরুর দিকে চলেছিল
- ফাংশনটি অনেক গভীর recursion-এ আছে
- ফাংশনটি অনেকবার ডাকা হয়েছে, তাই লাল রঙের
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- আগে সঠিক, তারপর মেপে দ্রুত। Knuth-এর পুরো কথা: ৯৭% কোডে ছোট কৌশল নয়, কিন্তু মেপে খুঁজে পাওয়া সংকটপূর্ণ ৩%-এ পূর্ণ মনোযোগ।
- লক্ষ্য সংখ্যায় — latency না throughput, আর গড় নয়, p99. লক্ষ্য পূরণ হলে থামুন।
- ঠিকভাবে মাপুন:
-O2, warm-up, কয়েকবার চালিয়ে median, ফল ব্যবহার করে (DoNotOptimize); তারপর profiler আর flame graph দিয়ে hotspot. - জয়ের ক্রম: algorithm (O(n²) → O(n log n)) → ডেটা-বিন্যাস (ফাঁকহীন, সারি ধরে, SoA যেখানে মানায়) → অকারণ কপি ও allocation বাদ → compiler flag → সব শেষে thread.
- ছোট কৌশল compiler-এর কাজ; godbolt-এ যাচাই করুন। AI-এর “দ্রুত” দাবি মাপ ছাড়া গ্রহণ করবেন না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫০ · কনটেস্ট প্রোগ্রামিংয়ে C++পরবর্তী পর্বসময়সীমার ভেতরে সঠিক উত্তর — complexity আর STL-এর ব্যবহারিক কৌশল।
- C++ প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- পর্ব ৩০ · sequence containerআবার দেখুনvector বনাম list, cache line আর Big-O-র সীমা — এই পর্বের ভিত্তি।
- Computer Architectureপাশের ট্র্যাকcache, pipeline আর branch prediction-এর হার্ডওয়্যার কীভাবে কাজ করে।
- Design & Analysis of Algorithmsগভীরে যানcomplexity বিশ্লেষণ আর ভালো algorithm বানানোর কৌশল।