এই পর্বে যা শিখবেন
- Knuth-এর “premature optimization” কথাটির আসল প্রসঙ্গ, আর কাজভেদে “যথেষ্ট দ্রুত” ঠিক করা
time.perf_counter,timeitদিয়ে মাপা — আর মাপ কেন প্রতিবার একটু আলাদা আসেcProfile-এর টেবিল (ncalls,tottime,cumtime) পড়ে hotspot খোঁজা; py-spy, line_profiler আর মেমরির জন্যtracemalloc- Algorithm আগে: list বনাম set, nested loop বনাম dict, বারবার sort বনাম একবার — গুনে গুনে প্রমাণসহ
- Python-এর নিজস্ব কৌশল (built-in,
join, generator,lru_cache,__slots__), NumPy, দ্রুততর runtime — আর AI-এর “optimize করা” কোড কীভাবে যাচাই করবেন
১ · অনুমান নয়, মাপুন — আর “যথেষ্ট দ্রুত” আগে ঠিক করুন
মিরপুরের একটি সুপারশপের হিসাব রাখেন সুমাইয়া। প্রতি রাতে একটি Python স্ক্রিপ্ট দিনের সব বিক্রি (প্রায় দুই লাখ লাইন) পড়ে, প্রতিটি বিক্রির পণ্যের দাম খুঁজে বের করে, আর একটি রিপোর্ট বানায়। শুরুতে দোকানে পণ্য ছিল কয়েকশো, স্ক্রিপ্ট শেষ হতো চোখের পলকে। এখন পণ্য বিশ হাজার, আর স্ক্রিপ্ট চলে প্রায় এক মিনিট। মালিক বলছেন, “আরও শাখা খুলছি, তখন কী হবে?”
সুমাইয়ার প্রথম ভাবনা: “CSV পড়াটাই নিশ্চয়ই ধীর, ফাইলটা তো বিশাল।” তিনি ইন্টারনেট ঘেঁটে CSV পড়ার তিনটি “দ্রুত” পদ্ধতি চেষ্টা করলেন, দুই দিন গেল — স্ক্রিপ্ট দুই সেকেন্ডও দ্রুত হলো না। কারণ সময় আসলে CSV পড়ায় যাচ্ছিলই না। যাচ্ছিল এমন একটি লাইনে, যা দেখতে একদম নিরীহ। এই পর্বের শেষে আপনি সেই লাইনটি নিজেই খুঁজে বের করবেন — আন্দাজে নয়, মেপে।
এই গল্পের শিক্ষাটি পারফরম্যান্সের প্রথম নিয়ম: প্রোগ্রাম কোথায় সময় নষ্ট করছে, তা অভিজ্ঞ প্রোগ্রামারও আন্দাজে প্রায়ই ভুল বলেন. আধুনিক কম্পিউটার, Python-এর interpreter, অপারেটিং সিস্টেমের file cache — এত স্তর আছে যে “এটাই নিশ্চয়ই ধীর” অনুভূতি প্রায়ই মেলে না। তাই পদ্ধতিটি হলো একটি চক্র: মাপুন → profile করুন → hotspot খুঁজুন → শুধু সেটি ঠিক করুন → আবার মাপুন.
Knuth-এর বিখ্যাত কথা — পুরোটা পড়ুন
“Premature optimization is the root of all evil” — কথাটি প্রায়ই শোনা যায়, প্রায়ই ভুলভাবে। বলেছিলেন কম্পিউটার বিজ্ঞানী Donald Knuth, ১৯৭৪ সালে। পুরো বাক্যটি এরকম: ছোটখাটো দক্ষতার কথা প্রায় ৯৭% সময় ভুলে যাওয়া উচিত — অসময়ের optimization সব অনিষ্টের মূল; তবু বাকি গুরুত্বপূর্ণ ৩%-এর সুযোগ হাতছাড়া করা উচিত নয়. অর্থাৎ Knuth বলেননি “গতি নিয়ে ভাববেন না”. বলেছেন: যে অংশ আসলে গুরুত্বপূর্ণ নয়, সেখানে কোড জটিল করে লাভ নেই; আর কোন অংশ গুরুত্বপূর্ণ, সেটি মেপে জানুন। (প্রসঙ্গসহ পুরো গল্প ভাবনার প্রশ্ন ১-এ।)
ঢাকার সকালের অফিসযাত্রার কথা ভাবুন। আপনার বাসা থেকে অফিস পৌঁছাতে লাগে দেড় ঘণ্টা। আপনি জুতার ফিতা বাঁধার সময় দশ সেকেন্ড কমানোর কৌশল শিখলেন, লিফট না ধরে সিঁড়ি দিয়ে নামলেন — লাভ এক মিনিটও না। কারণ দেড় ঘণ্টার এক ঘণ্টাই যায় বিজয় সরণির একটি সিগন্যালে। সেখানে মেট্রো রেল ধরলে, বা রুটটাই বদলালে, একবারে চল্লিশ মিনিট বাঁচে। প্রোগ্রামেও তাই: সময় কোথায় যায়, আগে সেটা দেখুন (মাপা); তারপর সবচেয়ে বড় জটলায় হাত দিন (hotspot); আর পারলে রুট বদলান (ভালো algorithm).
“যথেষ্ট দ্রুত” — কাজভেদে আলাদা
Optimize শুরুর আগে একটি সংখ্যা ঠিক করুন — নইলে কখন থামবেন জানবেন না। “যত দ্রুত সম্ভব” কোনো লক্ষ্য নয়। কয়েকটি উদাহরণ:
- রাতের রিপোর্ট: সকালের আগে শেষ হলেই যথেষ্ট। ৫ মিনিট লাগুক বা ৫ সেকেন্ড, কারও কিছু যায় আসে না — যতক্ষণ না ডেটা বেড়ে ঘণ্টায় পৌঁছায়।
- ওয়েবসাইটের একটি পেজ: ব্যবহারকারী ক্লিক করে অপেক্ষা করছেন। কয়েকশো মিলিসেকেন্ডের বেশি লাগলে দেরি চোখে পড়ে, কয়েক সেকেন্ড লাগলে অনেকে চলে যান।
- একবার চালানোর স্ক্রিপ্ট: একটি পুরোনো ফাইল একবার রূপান্তর করবেন — ১০ মিনিট লাগলে চা খেয়ে আসুন। দুই ঘণ্টা optimize করে ৯ মিনিট বাঁচানো লোকসান।
- লাখো ব্যবহারকারীর সার্ভার: এখানে প্রতি অনুরোধে কয়েক মিলিসেকেন্ড বাঁচানো মানে কম সার্ভার, কম বিদ্যুৎ, কম খরচ — তাই এখানে গভীর optimization-এর মূল্য আছে।
সুমাইয়ার ক্ষেত্রে লক্ষ্য স্পষ্ট: “দশটি শাখার ডেটা হলেও রিপোর্ট ২ সেকেন্ডের মধ্যে, আর রিপোর্টের প্রতিটি সংখ্যা আগের মতোই।” শেষ অংশটি ভুলবেন না — দ্রুত কিন্তু ভুল প্রোগ্রাম কোনো উন্নতি নয়. তাই optimize করার আগে পর্ব ৩৯-এর মতো কিছু unit test থাকা চাই, যা প্রতিটি বদলের পরে প্রমাণ করবে ফলাফল একই আছে। এবার পুরো চক্রটি ধাপে ধাপে দেখুন:
- মাপা
perf_counterএখন কত সময়? - Profile
cProfileসময় কোথায়? - Hotspot
list.indexএকটি লাইন - ঠিক করা
dictশুধু সেখানে - আবার মাপা
test + সময়লক্ষ্য পূরণ?
$ python report.py রিপোর্ট তৈরি: 200000 বিক্রি, 20000 পণ্য মোট সময়: 41.9 s # লক্ষ্য: 2 s-এর কম
$ python -m cProfile -s cumtime report.py ncalls tottime cumtime function 1 0.310 41.870 build_report 200000 0.402 41.214 find_product 200000 40.812 40.812 {method 'index' of 'list'}
def find_product(code): i = codes.index(code) # list-এ রৈখিক খোঁজ return products[i] # প্রতি বিক্রিতে গড়ে # ১০,০০০ তুলনা!
by_code = {p["code"]: p for p in products} # একবার def find_product(code): return by_code[code] # গড়ে O(1)
$ pytest -q 6 passed # ফলাফল আগের মতোই $ python report.py মোট সময়: 0.6 s # লক্ষ্য পূরণ — এখানেই থামুন
- মাপা — শুরুর সংখ্যা। কিছু বদলানোর আগে বর্তমান অবস্থা মাপুন, একই ডেটা দিয়ে। এটাই আপনার benchmark — তুলনার ভিত্তি। এখানে ৪১.৯ সেকেন্ড, লক্ষ্য ২ সেকেন্ড। এই সংখ্যা না থাকলে পরে “দ্রুত হয়েছে” দাবির কোনো মানে নেই।
- Profile — সময়ের ঠিকানা। কোডে একটি লাইনও না বদলে cProfile চালানো হলো। সে প্রতিটি ফাংশনের ডাক আর সময় গুনে একটি টেবিল দেয়। এক নজরেই দেখা যাচ্ছে CSV পড়ার কোনো ফাংশন ওপরের দিকে নেই — সুমাইয়ার আন্দাজ ভুল ছিল।
- Hotspot — একটি লাইন। মোট ৪২ সেকেন্ডের প্রায় ৪১ সেকেন্ডই খাচ্ছে
list.index. দুই লাখ বিক্রির প্রতিটির জন্য বিশ হাজার পণ্যের list-এ শুরু থেকে খোঁজা হচ্ছে — গড়ে দশ হাজার তুলনা, মোট প্রায় দুইশো কোটি। পুরো প্রোগ্রামের ৯৭% সময় একটি লাইনে। - ঠিক করা — শুধু সেখানে। পণ্যের কোড থেকে পণ্যে একটি dict একবার বানানো হলো (বিশ হাজার ধাপ), তারপর প্রতিটি খোঁজ গড়ে এক ধাপ। CSV পড়া, রিপোর্ট লেখা — বাকি কোড ছোঁয়াই হয়নি। এটি কোনো “কৌশল” নয়; ভুল ডেটা স্ট্রাকচার বদলে ঠিকটি বসানো।
- আবার মাপা — আর থামা। আগে test: ফলাফল হুবহু আগের মতো। তারপর সময়: ০.৬ সেকেন্ড, প্রায় ৭০ গুণ দ্রুত। লক্ষ্য পূরণ, তাই এখানেই থামুন। এখন profile করলে হয়তো CSV পড়াই সবচেয়ে ওপরে আসবে — কিন্তু লক্ষ্য পূরণ হয়ে গেলে সেটা আর ঠিক করার দরকার নেই।
২ · সময় মাপা — perf_counter, timeit আর মাপের “গোলমাল”
পুরো কাজ মাপা: time.perf_counter()
একটি বড় কাজ (পুরো রিপোর্ট, একটি ফাংশনের একটি ডাক) কতক্ষণ নেয়, তা মাপার সহজ উপায় পর্ব ২৭-এ দেখা time.perf_counter(). এটি একটি উচ্চ-নির্ভুল ঘড়ি; এর মানের নিজস্ব কোনো অর্থ নেই, শুধু দুটি মানের পার্থক্যই সময়। time.time() ব্যবহার করবেন না — সেটি দেয়ালঘড়ির সময়, যা কম্পিউটারের ঘড়ি ঠিক করলে (যেমন ইন্টারনেট থেকে সময় মিলিয়ে নিলে) লাফ দিতে পারে।
import time
start = time.perf_counter()
report = build_report(sales, products) # যে কাজটি মাপতে চান
elapsed = time.perf_counter() - start
print(f"মোট সময়: {elapsed:.2f} s")এটি মাপে wall-clock time — শুরু থেকে শেষ পর্যন্ত আসল যে সময় গেল, তার মধ্যে ডিস্ক বা নেটওয়ার্কের অপেক্ষাও আছে। শুধু CPU কতক্ষণ কাজ করল, জানতে চাইলে time.process_time() আছে; অপেক্ষার সময় সেখানে গোনা হয় না। দুটির বড় পার্থক্য নিজেই একটি সূত্র: wall-clock অনেক বেশি হলে প্রোগ্রাম কাজ করছে না, অপেক্ষা করছে (অংশ ৭)।
ছোট টুকরো মাপা: timeit
এক লাইনের কোড, যা এক মাইক্রোসেকেন্ডে চলে — তাকে perf_counter দিয়ে একবার মাপলে ঘড়ির নিজের খরচই মাপের চেয়ে বড় হয়ে যায়। এর জন্য স্ট্যান্ডার্ড লাইব্রেরির timeit module: সে কোডটি হাজার হাজার বার চালায়, মোট সময়কে ভাগ করে, আর পুরো পরীক্ষাটি কয়েকবার করে। টার্মিনাল থেকে সবচেয়ে সহজ:
python -m timeit "sum(range(1000))"
python -m timeit -s "nums = list(range(1000))" "999 in nums"
python -m timeit -s "nums = set(range(1000))" "999 in nums"-s-এর পরের অংশ (setup) একবারই চলে, মাপা হয় না; শেষের অংশটি বারবার চলে আর মাপা হয়। আউটপুট দেখতে এরকম:
# উদাহরণ — আপনার মেশিনে সংখ্যা আলাদা হবে
50000 loops, best of 5: 6.1 usec per loop
50000 loops, best of 5: 5.4 usec per loop
5000000 loops, best of 5: 42.3 nsec per loop
পড়ুন এভাবে: “কোডটি একটানা ৫০,০০০ বার চালানো হলো, এমন পাঁচটি দফা হলো, সবচেয়ে ভালো দফায় প্রতিবারে গড়ে ৬.১ মাইক্রোসেকেন্ড।” কতবার চালাবে, timeit নিজেই ঠিক করে, যাতে একেকটি দফা যথেষ্ট লম্বা হয় (কমপক্ষে ০.২ সেকেন্ড)। শেষ দুই লাইনের তুলনা লক্ষ করুন: হাজার উপাদানের list-এ শেষ উপাদান খুঁজতে কয়েক মাইক্রোসেকেন্ড, set-এ কয়েক দশ ন্যানোসেকেন্ড — একশো গুণেরও বেশি পার্থক্য (কারণ অংশ ৪-এ)।
কোডের ভেতর থেকেও চালানো যায়। timeit.timeit-এ number= বলে দেয় কতবার চালাতে হবে (না দিলে ১০ লাখ বার!), আর ফল আসে মোট সেকেন্ডে:
import timeit
nums_list = list(range(10_000))
nums_set = set(nums_list)
t_list = timeit.timeit(lambda: 9_999 in nums_list, number=1_000)
t_set = timeit.timeit(lambda: 9_999 in nums_set, number=1_000)
print(f"list: {t_list:.4f} s, set: {t_set:.6f} s (১,০০০ বার)")Jupyter বা IPython-এ আরও সহজ: লাইনের আগে %timeit লিখলেই চলে।
মাপ কেন প্রতিবার আলাদা — আর কী করবেন
একই কোড দুবার মাপুন — দুটি আলাদা সংখ্যা পাবেন। এটা ভুল নয়, এটা বাস্তবতা। আপনার প্রোগ্রাম একা চলে না:
- অন্য প্রোগ্রাম: browser, antivirus, আপডেট — সবাই একই CPU ভাগ করে নিচ্ছে।
- CPU-র গতি বদলায়: ল্যাপটপ চার্জে আছে না ব্যাটারিতে, গরম হয়ে গেছে কি না — CPU নিজের গতি বাড়ায়-কমায়।
- প্রথমবার ধীর: প্রথম দফায় ফাইল ডিস্ক থেকে আসে, পরে মেমরির cache থেকে; module import-ও প্রথমবারে।
- Garbage collector: হঠাৎ কোনো এক দফায় garbage collection চলে সময় বাড়িয়ে দিতে পারে। (
timeitমাপার সময় এটি default-এ বন্ধ রাখে।)
(১) কয়েকবার মাপুন, একবার নয় — timeit এজন্যই “best of 5” দেয়; ছোট কোডের বেলায় সবচেয়ে কম সময়টিই সবচেয়ে কম গোলমালের। (২) বাস্তবের মতো ডেটা ব্যবহার করুন — ১০টি পণ্যে যে কোড দ্রুত, ২০,০০০টিতে সেটিই ধীরতম হতে পারে। (৩) তুলনা করুন একই মেশিনে, একই Python version-এ, একই সময়ে। (৪) ৫%-এর কম পার্থক্যকে সাধারণত গোলমাল ধরে নিন, যদি না বারবার একই দিকে আসে।
৩ · Profiler — সময় আসলে কোথায় যায়
timeit বলে কতক্ষণ, কিন্তু বলে না কোথায়. পাঁচশো লাইনের একটি প্রোগ্রামে প্রতিটি ফাংশনের চারপাশে হাতে perf_counter বসানো অসম্ভব। এই কাজের টুল হলো profiler: প্রোগ্রাম চলার সময় সে নজর রাখে কোন ফাংশন কতবার ডাকা হলো আর প্রতিটিতে কত সময় গেল। Python-এর সাথেই আসে cProfile (C-তে লেখা, তাই নিজে কম ধীর করে)। কোড না বদলেই চালানো যায়:
python -m cProfile -s cumtime report.py
python -m cProfile -o report.prof report.py # ফলাফল ফাইলে রাখা-s cumtime মানে টেবিলটি “মোট সময়” অনুযায়ী সাজানো হবে। সুমাইয়ার রিপোর্টে আউটপুটের ওপরের অংশ দেখতে এরকম (ছোট করে দেখানো):
# উদাহরণ — আপনার মেশিনে সংখ্যা আলাদা হবে
600214 function calls in 42.118 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 42.118 42.118 {built-in method builtins.exec}
1 0.009 0.009 42.118 42.118 report.py:1(<module>)
1 0.310 0.310 41.870 41.870 report.py:21(build_report)
200000 0.402 0.000 41.214 0.000 report.py:9(find_product)
200000 40.812 0.000 40.812 0.000 {method 'index' of 'list' objects}
1 0.161 0.161 0.239 0.239 report.py:13(read_sales)
200000 0.078 0.000 0.078 0.000 {method 'split' of 'str' objects}
কলামগুলোর মানে:
| কলাম | মানে | কী খুঁজবেন |
|---|---|---|
ncalls | ফাংশনটি কতবার ডাকা হয়েছে | অপ্রত্যাশিত বড় সংখ্যা — “এটা দুই লাখবার কেন?” |
tottime | ফাংশনের নিজের কোডে মোট সময়, ভেতর থেকে ডাকা অন্য ফাংশনের সময় বাদে | বড় tottime = আসল কাজ এখানেই হচ্ছে |
percall (প্রথম) | tottime ÷ ncalls | একেকটি ডাক কত ভারী |
cumtime | ফাংশনে ঢোকা থেকে বেরোনো পর্যন্ত মোট সময়, ভেতরের সব ডাকসহ | বড় cumtime = এই পথের কোথাও সময় যাচ্ছে |
percall (দ্বিতীয়) | cumtime ÷ ncalls | একটি ডাকের পুরো খরচ |
tottime আর cumtime-এর পার্থক্যই টেবিল পড়ার চাবি। একটি অফিসের কথা ভাবুন: ম্যানেজারের “cumtime” হলো তাঁর বিভাগের পুরো কাজের সময়, কিন্তু তাঁর “tottime” হলো শুধু তিনি নিজে যতক্ষণ কাজ করলেন — বাকিটা তিনি অন্যদের দিয়ে করিয়েছেন। Hotspot হলো সেই কর্মী, যার নিজের সময় (tottime) বড়। এবার টেবিলটি সারি ধরে পড়ুন:
600214 function calls in 42.118 seconds ncalls tottime cumtime function 1 0.000 42.118 builtins.exec 1 0.009 42.118 <module> 1 0.310 41.870 build_report 200000 0.402 41.214 find_product 200000 40.812 40.812 list.index 1 0.161 0.239 read_sales
600214 function calls in 42.118 seconds ncalls tottime cumtime function 1 0.000 42.118 builtins.exec 1 0.009 42.118 <module> 1 0.310 41.870 build_report 200000 0.402 41.214 find_product 200000 40.812 40.812 list.index 1 0.161 0.239 read_sales
600214 function calls in 42.118 seconds ncalls tottime cumtime function 1 0.000 42.118 builtins.exec 1 0.009 42.118 <module> 1 0.310 41.870 build_report 200000 0.402 41.214 find_product 200000 40.812 40.812 list.index 1 0.161 0.239 read_sales
600214 function calls in 42.118 seconds ncalls tottime cumtime function 1 0.000 42.118 builtins.exec 1 0.009 42.118 <module> 1 0.310 41.870 build_report 200000 0.402 41.214 find_product 200000 40.812 40.812 list.index 1 0.161 0.239 read_sales
600214 function calls in 42.118 seconds ncalls tottime cumtime function 1 0.000 42.118 builtins.exec 1 0.009 42.118 <module> 1 0.310 41.870 build_report 200000 0.402 41.214 find_product 200000 40.812 40.812 list.index 1 0.161 0.239 read_sales
- মোট ছবি আগে। প্রথম লাইন বলে প্রোগ্রাম মোট প্রায় ৪২ সেকেন্ড চলেছে, আর তার মধ্যে ছয় লাখের বেশি ফাংশন ডাক হয়েছে। মনে রাখবেন, profiler নিজেও একটু ধীর করে — প্রতিটি ডাক গুনতে খরচ আছে। তাই এই সময় সাধারণ চালানোর চেয়ে কিছুটা বেশি হতে পারে; গুরুত্বপূর্ণ হলো অনুপাত.
- ওপরের সারিগুলো শুধু দরজা।
execআর<module>-এর cumtime পুরো ৪২ সেকেন্ড, কারণ পুরো প্রোগ্রামই তাদের ভেতর দিয়ে চলেছে। কিন্তু tottime প্রায় শূন্য — তারা নিজে কিছু করেনি। cumtime অনুযায়ী সাজালে ওপরে সবসময় এমন “দরজা” থাকে; এগুলো পার হয়ে নিচে নামুন। - build_report: বড় cumtime, ছোট tottime. রিপোর্টের মূল ফাংশনের পথে প্রায় ৪২ সেকেন্ড, কিন্তু নিজের কোডে মাত্র ০.৩১ সেকেন্ড। অর্থাৎ সময় যাচ্ছে সে যাদের ডাকছে, তাদের ভেতরে। সূত্র: cumtime আর tottime-এর বড় ফাঁক মানে “আরও ভেতরে খুঁজুন”.
- find_product: ncalls = 200000. প্রতিটি বিক্রির জন্য একবার — সংখ্যাটি প্রত্যাশিত। একেকটি ডাকের percall ছাপা হচ্ছে 0.000, কারণ এক ডাকে মাত্র ০.২ মিলিসেকেন্ড, তিন দশমিকে ধরা পড়ে না। কিন্তু দুই লাখ দিয়ে গুণ করলে ৪১ সেকেন্ড! ছোট খরচ × বিশাল ডাকসংখ্যা — profiler-এর সবচেয়ে সাধারণ গল্প। আর এরও tottime ছোট, তাই আরও এক ধাপ ভেতরে।
- পাওয়া গেছে: list.index. tottime ৪০.৮ সেকেন্ড — পুরো প্রোগ্রামের ৯৭%. এটাই hotspot, সময় যেখানে আসলে খরচ হয়। পাশেই দেখুন
read_sales: সুমাইয়া যাকে সন্দেহ করেছিলেন, তার মোট সময় মাত্র ০.২৪ সেকেন্ড। CSV পড়া দশ গুণ দ্রুত করলেও লাভ হতো ০.২ সেকেন্ড। এখন প্রশ্ন একটাই —indexএত ধীর কেন? উত্তর অংশ ৪-এ।
আরও কিছু টুল — কখন কোনটি
🗺️ snakeviz
-o report.prof দিয়ে রাখা ফাইলটি browser-এ ছবি হিসেবে দেখায় — বড় আয়তক্ষেত্র মানে বেশি সময়। pip install snakeviz, তারপর snakeviz report.prof. স্ট্যান্ডার্ড লাইব্রেরির pstats দিয়েও ফাইলটি পড়া যায়।
🕵️ py-spy
একটি sampling profiler: প্রতিটি ডাক না গুনে প্রতি সেকেন্ডে বহুবার উঁকি দিয়ে দেখে প্রোগ্রাম কোথায় আছে। প্রায় ধীর করে না, আর চলমান সার্ভারের প্রোগ্রামেও (process id দিয়ে) লাগানো যায়, restart ছাড়াই। py-spy record একটি flame graph ছবি বানায়।
📏 line_profiler
ফাংশন নয়, প্রতিটি লাইনের সময় দেখায়। hotspot ফাংশন জানা হয়ে গেলে, তার ভেতরের কোন লাইনটি দোষী — এটা খুঁজতে। ফাংশনের ওপরে @profile বসিয়ে kernprof -l -v script.py চালাতে হয়।
🧠 tracemalloc
সময় নয়, মেমরি। স্ট্যান্ডার্ড লাইব্রেরিতে আছে (Python 3.4 থেকে)। tracemalloc.start() দিয়ে শুরু, পরে take_snapshot() নিয়ে দেখা যায় কোন লাইন সবচেয়ে বেশি মেমরি নিয়েছে; get_traced_memory() দেয় বর্তমান আর সর্বোচ্চ ব্যবহার।
tracemalloc বিশেষ কাজে লাগে যখন সমস্যা সময় নয়, মেমরি — যেমন ৫ GB-র একটি লগ ফাইল পুরোটা একবারে list-এ পড়তে গিয়ে প্রোগ্রাম MemoryError দিচ্ছে বা কম্পিউটার জমে যাচ্ছে। এর সমাধানও প্রায়ই algorithm-গোছের: সব একসাথে না রেখে একটি একটি করে প্রক্রিয়া করা (অংশ ৬-এর generator).
৪ · আগে algorithm — সবচেয়ে বড় লাভ এখানেই
সুমাইয়ার hotspot-এ codes.index(code) কী করে? সে list-এর প্রথম উপাদান থেকে শুরু করে একটি একটি করে মেলায়, যতক্ষণ না পায় — পর্ব ১৪-এর linear search. বিশ হাজার উপাদানের list-এ গড়ে দশ হাজার তুলনা। list-এ x in items-ও ঠিক একই কাজ করে। এর খরচ list-এর দৈর্ঘ্যের সমানুপাতিক — Big-O ভাষায় O(n).
অন্যদিকে পর্ব ১৬-১৭-এর set আর dict ভেতরে একটি hash table: চাবির hash থেকে সরাসরি হিসাব করে কোন ঘরে দেখতে হবে। তাই খোঁজ গড়ে O(1) — set-এ দশটি থাকুক বা দশ লাখ, খোঁজার খরচ প্রায় একই। (“গড়ে” কথাটি গুরুত্বপূর্ণ; অনেক চাবির hash একই ঘরে পড়লে খারাপ ক্ষেত্রে ধীর হতে পারে, কিন্তু বাস্তবে প্রায় কখনো ঘটে না।)
সরকারি অফিসে একটি ফাইল খুঁজছেন। প্রথম অফিসে ফাইলগুলো স্তূপ করে রাখা — কেরানি সাহেব ওপর থেকে একটি একটি করে দেখবেন। ফাইল যত বাড়বে, খুঁজতেও তত দেরি (list, O(n)). দ্বিতীয় অফিসে প্রতিটি ফাইল নম্বর অনুযায়ী নির্দিষ্ট তাকে — নম্বর শুনেই কেরানি সোজা সেই তাকে হাত দেন (dict/set, গড়ে O(1)). ফাইল দশ গুণ বাড়লেও দ্বিতীয় অফিসে খুঁজতে একই সময়। কিন্তু তাক সাজাতে একবার খাটুনি লাগে — dict বানানোর খরচটিও ঠিক তেমন, একবারের।
লুপের ভেতরে O(n) — লুকানো O(n²)
একটি list-খোঁজ নিজে ধীর নয় — হাজার উপাদানে কয়েক মাইক্রোসেকেন্ড। বিপদ হয় যখন সেটি একটি লুপের ভেতরে বসে। n বার ঘোরা লুপের প্রতিটি ধাপে n উপাদানের খোঁজ মানে মোট প্রায় n × n কাজ — O(n²). পর্ব ১২-এর nested loop সরাসরি লেখা না থাকলেও এটি একটি nested loop; in বা .index()-এর ভেতরে লুকিয়ে আছে। n বাড়লে কী ঘটে, গুনে দেখুন:
1# ক: প্রতিটি বিক্রিতে পুরো list-এ খোঁজ2for sale in sales: # n বার3 p = products[codes.index(sale.code)] # n পর্যন্ত45# খ: একবার dict বানানো, তারপর সরাসরি6by_code = {p.code: p for p in products} # n7for sale in sales: # n বার8 p = by_code[sale.code] # গড়ে ১
| n | |
| ক: n × n | |
| খ: n + n | |
| ক ÷ খ |
- n = 10: কোনো পার্থক্য নেই। ক-তে ১০০ ধাপ, খ-তে ২০। দুটিই মাইক্রোসেকেন্ডে শেষ। ঠিক এই কারণে O(n²) কোড শুরুতে ধরা পড়ে না — ছোট টেস্ট ডেটায় সব কোডই দ্রুত। সুমাইয়ার দোকানে শুরুতে পণ্য ছিল কম।
- n = 100: এখনো নিরীহ। ক-তে দশ হাজার ধাপ — কম্পিউটারের কাছে কিছুই না। কিন্তু অনুপাত লক্ষ করুন: ৫ থেকে ৫০ গুণ। n দশ গুণ হলো, ক-এর কাজ বাড়ল একশো গুণ, খ-এর মাত্র দশ গুণ।
- n = 1,000: ফাঁক খুলছে। ক-তে দশ লাখ তুলনা। Python-এ একেকটি তুলনা কয়েক ন্যানোসেকেন্ড থেকে কয়েক দশ ন্যানোসেকেন্ড, তাই এখনো এক সেকেন্ডের কম। কিন্তু পার্থক্য এখন পাঁচশো গুণ।
- n = 10,000: চোখে পড়ে। ক-তে দশ কোটি ধাপ — কয়েক সেকেন্ড অপেক্ষা। খ এখনো বিশ হাজার ধাপ, তাৎক্ষণিক। ব্যবহারকারী অভিযোগ শুরু করেন এই পর্যায়ে।
- n = 1,00,000: ভেঙে পড়া। ক-তে এক হাজার কোটি ধাপ — একটি রিপোর্টের জন্য অনেক মিনিট, আর ডেটা দ্বিগুণ হলে সময় চার গুণ। খ-তে মাত্র দুই লাখ ধাপ। কোনো ছোট কৌশল, কোনো দ্রুত কম্পিউটার এই ৫০,০০০ গুণ ফাঁক পূরণ করতে পারে না — পারে শুধু ঠিক ডেটা স্ট্রাকচার।
প্রমাণ গুনে দেখা: এক লাখ কন্টাক্টে ডুপ্লিকেট ফোন নম্বর
একটি বাস্তব কাজ: একটি কোম্পানির এক লাখ গ্রাহকের ফোন নম্বর আছে, কোনগুলো একাধিকবার এসেছে বের করতে হবে। সরল পদ্ধতি — প্রতিটি নম্বরকে তার পরের প্রতিটি নম্বরের সাথে মেলানো। ভালো পদ্ধতি — একটি set-এ “দেখা হয়েছে” নম্বর রাখা। নিচের প্রোগ্রামটি সময় মাপে না (সময় মেশিনভেদে বদলায়); বরং গোনে কতগুলো ধাপ লাগল — যা যেকোনো মেশিনে একই:
def dup_nested(phones):
dups, steps = set(), 0
for i in range(len(phones)):
for j in range(i + 1, len(phones)):
steps += 1 # একটি তুলনা
if phones[i] == phones[j]:
dups.add(phones[i])
return dups, steps
def dup_set(phones):
seen, dups = set(), set()
for p in phones: # প্রতিটি নম্বরে একটি set-খোঁজ
if p in seen:
dups.add(p)
seen.add(p)
return dups, len(phones)
for n in (10, 100, 1000):
phones = [f"017{i % (n - 2):08d}" for i in range(n)] # শেষ ২টি ডুপ্লিকেট
(d1, s1), (d2, s2) = dup_nested(phones), dup_set(phones)
print(f"n={n:>4}: nested {s1:>6} তুলনা | set {s2:>4} ধাপ | মিল {d1 == d2}, ডুপ্লিকেট {len(d2)}")
print(f"n=100000 হলে nested: {100_000 * 99_999 // 2:,} তুলনা")আউটপুট চার লাইন: n= 10: nested 45 তুলনা | set 10 ধাপ | মিল True, ডুপ্লিকেট 2, তারপর n=100-এ 4950 বনাম 100, n=1000-এ 499500 বনাম 1000 — তিনবারই মিল True আর ডুপ্লিকেট 2. শেষ লাইন n=100000 হলে nested: 4,999,950,000 তুলনা. nested পদ্ধতির ধাপ n×(n−1)÷২: n দশ গুণ হলে ধাপ প্রায় একশো গুণ। set পদ্ধতিতে ধাপ ঠিক n. দুটির উত্তর হুবহু এক (d1 == d2) — optimization-এ সবসময় এটা যাচাই করুন। এক লাখ নম্বরে nested লাগবে প্রায় পাঁচশো কোটি তুলনা (Python-এ কয়েক মিনিট), set লাগবে এক লাখ ধাপ (এক সেকেন্ডের ভগ্নাংশ)। phones বানানোর লাইনে i % (n - 2) শেষ দুটি নম্বরকে প্রথম দুটির সমান করে, তাই ঠিক ২টি ডুপ্লিকেট। (আরও ছোট লেখা: collections.Counter(phones) থেকে যার গণনা ১-এর বেশি — সেটিও O(n).)
আরও কয়েকটি algorithm-গোছের ভুল
- বারবার sort করা: প্রতিটি নতুন বিক্রি যোগ করার পর পুরো list
sorted()করা — n বার O(n log n). সব যোগ করে একবার sort করুন। সবসময় সাজানো রাখতেই হলেbisect.insort, আর শুধু সবচেয়ে বড় ১০টি দরকার হলেheapq.nlargest(10, items)— পুরোটা sort করার দরকারই নেই। - লুপের ভেতরে একই হিসাব:
for row in rows: if row.total > average(rows): ...— প্রতিটি সারির জন্য পুরো list-এর গড় আবার হিসাব, আবার O(n²). গড় লুপের আগে একবার হিসাব করে রাখুন। - list-এর সামনে থেকে সরানো:
queue.pop(0)প্রতিবার বাকি সব উপাদান এক ঘর সরায় — O(n). সারি (queue) দরকার হলে পর্ব ২৭-এরcollections.deque, যারpopleft()O(1). - ভুল প্রশ্ন: কখনো সবচেয়ে বড় লাভ আসে কাজটিই না করে — দশ লাখ সারি Python-এ এনে ফিল্টার করার বদলে ডেটাবেসকে
WHEREদিয়ে শুধু দরকারি ১০০ সারি দিতে বলা।
৫ · Python-এর নিজস্ব কৌশল — লুপটা C-তে ঠেলে দিন
Algorithm ঠিক থাকার পরেও Python-এ একটি বিশেষ সত্য কাজে লাগে। পর্ব ০৫-এ দেখেছেন, আপনার Python কোড bytecode-এ রূপান্তরিত হয়, আর CPython-এর virtual machine সেই নির্দেশগুলো একটি একটি করে চালায়। প্রতিটি নির্দেশে বাড়তি খরচ: type যাচাই, object তৈরি, reference গোনা। অন্যদিকে built-in function-গুলো (sum, max, min, sorted, any, all, len, str.join…) C-তে লেখা — তাদের ভেতরের লুপ machine code হিসেবে সরাসরি CPU-তে চলে। তাই নিয়মটি: যে লুপ একটি built-in বা লাইব্রেরি করে দিতে পারে, তা নিজে Python-এ লিখবেন না.
- for লুপ
total += xbytecode-এ লুপ - প্রতিটি সংখ্যা object
int objectবাক্সে মোড়া - sum(nums)
এক ডাকC-তে লুপ - NumPy
arr.sum()কাঁচা সংখ্যার সারি
total = 0 for x in nums: FOR_ITER → পরের উপাদান STORE_FAST → x-এ রাখা total += x LOAD_FAST ×2 → total, x আনা BINARY_OP (+=) → যোগ STORE_FAST → total-এ রাখা JUMP_BACKWARD → আবার ওপরে
BINARY_OP (+=): x কোন type? int — ঠিক আছে total কোন type? int — ঠিক আছে যোগফলের জন্য নতুন int object পুরোনো total-এর reference কমানো # ১০ লাখ সংখ্যা = এই সব ১০ লাখ বার
total = sum(nums) CALL → C ফাংশন builtin_sum # ভেতরের লুপ C-তে: # bytecode dispatch নেই, # ছোট int যোগ হয় C-র সংখ্যায়
import numpy as np arr = np.arange(1_000_000) # একটানা int64-এর সারি total = arr.sum() # সংকলিত C লুপ, # CPU একসাথে কয়েকটি যোগ করে
- Python লুপ — প্রতি পাকে কয়েকটি নির্দেশ।
for x in nums: total += x-এর প্রতিটি পাকে virtual machine-কে মোটামুটি ছয়-সাতটি bytecode নির্দেশ চালাতে হয় — পরের উপাদান আনা, নামে রাখা, দুটি মান আনা, যোগ, রাখা, লাফ দিয়ে ওপরে যাওয়া। প্রতিটি নির্দেশের জন্য interpreter-কে “এরপর কী?” দেখে সঠিক কোডে যেতে হয়। (নির্দেশের নাম Python version ভেদে একটু আলাদা — পর্ব ০৫-এরdisদিয়ে নিজের version-এ দেখুন।) - প্রতিটি সংখ্যা একটি object. Python-এ
5শুধু ৮ বাইটের একটি সংখ্যা নয় — এটি type, reference count আর মানসহ একটি object (পর্ব ০৫)। প্রতিটি যোগে দুই পাশের type যাচাই, যোগফলের জন্য সাধারণত নতুন object, পুরোনোটির reference কমানো। Python 3.11 থেকে specializing interpreter “দুটিই int” ধরে নিয়ে কিছুটা দ্রুত করে, কিন্তু object-এর খরচ থেকেই যায়। - sum() — লুপ চলে C-তে।
sum(nums)Python-এর দিক থেকে একটি মাত্র ডাক। পুরো লুপটি C-তে লেখাbuiltin_sumফাংশনের ভেতরে ঘোরে — bytecode-এর “এরপর কী?” নেই, আর যতক্ষণ যোগফল ছোট থাকে, যোগ হয় C-র সাধারণ সংখ্যায়, প্রতিবার নতুন object ছাড়া। ফল একই, সাধারণত কয়েক গুণ দ্রুত। - NumPy — object-ও নেই। NumPy array-তে সংখ্যাগুলো object নয়, মেমরিতে পাশাপাশি রাখা কাঁচা সংখ্যা — C-র array-র মতো। যোগের লুপ সংকলিত C-তে, আর আধুনিক CPU একটি নির্দেশে কয়েকটি সংখ্যা একসাথে যোগ করতে পারে। একই যোগফল সাধারণ Python লুপের চেয়ে কয়েক ডজন গুণ বা তারও বেশি দ্রুত আসতে পারে — সঠিক সংখ্যা মেশিন আর ডেটার ওপর নির্ভর করে। (NumPy-র বিস্তারিত পর্ব ৪৬-এ।)
এই ভাবনার নাম vectorization — “প্রতিটি উপাদানে এটা করো” লুপ না লিখে পুরো সারির ওপর একটি কাজ বলে দেওয়া, যাতে আসল লুপটি দ্রুত, সংকলিত কোডে চলে। NumPy, pandas, PyTorch — Python-এর পুরো ডেটা আর AI জগৎ এই নীতির ওপর দাঁড়িয়ে।
প্রতিদিনের Python-এ এর প্রয়োগ
| ধীর ধরন | ভালো বিকল্প | কেন |
|---|---|---|
| হাতে লুপ দিয়ে যোগ, সর্বোচ্চ খোঁজা | sum(...), max(...), min(...) | লুপ C-তে; লেখাও ছোট ও পরিষ্কার |
| flag দিয়ে লুপ, “কোনোটি কি শর্ত মানে?” | any(...), all(...) | C-তে লুপ, আর উত্তর জানা মাত্র থামে |
| নিজে লেখা sort | sorted(...), list.sort(), key= | পরীক্ষিত Timsort, C-তে |
লুপে s += part দিয়ে বড় string | ",".join(parts) | একবারে মোট দৈর্ঘ্য হিসাব করে একটি string বানায় |
append-ওয়ালা লুপ | list comprehension | প্রতি পাকে .append খোঁজা ও ডাকা বাঁচে — ছোট লাভ |
গণনার জন্য dict-এ if key in d | collections.Counter | গণনার লুপ C-তে, লেখাও এক লাইন |
বড় CSV লাইন: join বনাম +=
পর্ব ০৯-এ দেখেছেন, Python-এর string immutable — বদলানো যায় না। তাই line += field + "," নীতিগতভাবে প্রতিবার একটি নতুন string বানায় আর আগের সব অক্ষর কপি করে: ১০,০০০টি ঘর জুড়লে মোট কপি প্রায় n²÷২। CPython বিশেষ ক্ষেত্রে (string-টির আর কোনো reference না থাকলে) জায়গায় বাড়িয়ে নেওয়ার একটি কৌশল করে, তাই অনেক সময় এটা ততটা ধীর দেখায় না — কিন্তু এর ওপর ভরসা করা যায় না: অন্য implementation-এ (যেমন PyPy) বা সামান্য ভিন্ন কোডে কৌশলটি কাজ করে না। PEP 8 নিজেই বলে, এ কাজে join ব্যবহার করুন:
# ধীর হতে পারে: প্রতিবার নতুন string, পুরোনো অক্ষর কপি
line = ""
for value in row:
line += str(value) + ","
# সঠিক উপায়: টুকরোগুলো জমিয়ে একবারে জোড়া
line = ",".join(str(value) for value in row)(বাস্তবে CSV লিখতে পর্ব ২৬-এর csv module-ই ব্যবহার করুন — কমা বা উদ্ধৃতিচিহ্নওয়ালা ঘরও সে ঠিকভাবে সামলায়। এখানে মূল কথা হলো অনেক টুকরো থেকে একটি বড় string বানানোর concatenation-এর ধরন।)
৬ · বারবার কাজ এড়ানো আর মেমরি বাঁচানো
একই হিসাব দ্বিতীয়বার নয় — functools.lru_cache
একটি অনলাইন দোকান বিদেশি মুদ্রায় অর্ডার নেয়, আর প্রতিটি অর্ডারের দাম টাকায় রূপান্তর করে। রূপান্তর হার আনার ফাংশনটি ধীর — হয়তো একটি API ডাকে বা জটিল হিসাব করে। ছয় হাজার অর্ডার, কিন্তু মুদ্রা মাত্র তিনটি! প্রতিটি অর্ডারে হার নতুন করে আনা মানে ৫,৯৯৭টি অকারণ ধীর কাজ। পর্ব ৩৫-এর functools.lru_cache ঠিক এই সমস্যার সমাধান: প্রথমবার হিসাব করে উত্তর খাতায় রাখে, পরে একই প্রশ্নে খাতা থেকে বলে দেয় — memoization. আবারও সময় নয়, গুনে দেখুন:
from functools import lru_cache
slow_calls = 0
@lru_cache(maxsize=None)
def rate(src, dst):
global slow_calls
slow_calls += 1 # ধরুন এখানে ধীর হিসাব বা API ডাক
table = {("USD", "BDT"): 122.0, ("EUR", "BDT"): 132.5,
("INR", "BDT"): 1.45}
return table[(src, dst)]
orders = [("USD", 10), ("EUR", 5), ("USD", 3),
("INR", 200), ("USD", 7), ("EUR", 2)] * 1000
total = sum(amount * rate(cur, "BDT") for cur, amount in orders)
print(f"অর্ডার {len(orders)}টি, মোট {total:,.2f} টাকা")
print("ধীর হিসাব চলেছে:", slow_calls, "বার")
print(rate.cache_info())আউটপুট তিন লাইন: অর্ডার 6000টি, মোট 3,657,500.00 টাকা, ধীর হিসাব চলেছে: 3 বার, আর CacheInfo(hits=5997, misses=3, maxsize=None, currsize=3). ছয় হাজার অর্ডারে rate() ডাকা হয়েছে ছয় হাজার বার, কিন্তু ভেতরের ধীর অংশ চলেছে মাত্র ৩ বার — প্রতিটি আলাদা মুদ্রার জন্য একবার (misses = 3). বাকি ৫,৯৯৭টি উত্তর এসেছে খাতা থেকে (hits). প্রতিটি ধীর ডাক এক সেকেন্ড নিলে cache ছাড়া লাগত দেড় ঘণ্টার বেশি, cache-সহ তিন সেকেন্ড। লক্ষ করুন, যোগফলের লুপটিও sum() আর একটি generator expression দিয়ে লেখা। সতর্কতা: আসল মুদ্রা-হার প্রতিদিন বদলায়; চিরস্থায়ী cache কাল গতকালের হার দেখাবে। Cache নিরাপদ শুধু pure function-এ, অথবা প্রতিদিন rate.cache_clear() ডেকে বা সময়সীমাসহ cache ব্যবহার করে।
লুপের ভেতর থেকে অপরিবর্তিত কাজ বের করুন
লুপের প্রতিটি পাকে যে কাজের ফল বদলায় না, তা লুপের বাইরে একবার করুন: একটি regex compile করা (re.compile, পর্ব ২৮), একটি ফাইল খোলা, একটি কনফিগ পড়া, একটি বড় list থেকে set বানানো, len(items) বা গড় হিসাব। শুনতে স্পষ্ট, কিন্তু profiler-এর টেবিলে সবচেয়ে বেশি দেখা যাওয়া সমস্যার একটি এটি — বিশেষত যখন অপরিবর্তিত কাজটি অন্য একটি ফাংশনের ভেতরে লুকানো থাকে, যা লুপ থেকে ডাকা হয়।
ছোট কৌশল: local নাম বনাম global নাম
একটি local variable পড়া (LOAD_FAST) একটি global বা built-in নাম খোঁজার চেয়ে কিছুটা সস্তা। তাই পুরোনো লেখায় দেখবেন খুব গরম লুপের আগে append = result.append বা sqrt = math.sqrt লিখে নাম local করে নেওয়ার কৌশল। এটি সত্যি, কিন্তু একটি micro-optimization: লাভ সাধারণত কয়েক শতাংশ, আর Python 3.11-এর পরে global খোঁজাও cache হওয়ায় লাভ আরও কমেছে। কোড পড়তে অস্বাভাবিক লাগে। শুধু তখনই ভাবুন, যখন profiler বলছে ঠিক এই লুপটিই hotspot, আর algorithm আর built-in-এর পথ শেষ। একই কথা “comprehension বনাম append লুপ” নিয়েও — comprehension সাধারণত একটু দ্রুত আর বেশি পরিষ্কার, তাই পরিষ্কারতার জন্যই ব্যবহার করুন; গতির লাভটা বোনাস।
মেমরি: generator আর __slots__
পর্ব ৩৪-এর generator সব উপাদান একসাথে মেমরিতে রাখে না, একটি একটি করে তৈরি করে। sum([x * x for x in range(10_000_000)]) আগে এক কোটি সংখ্যার একটি পুরো list বানায় (কয়েকশো MB), তারপর যোগ করে; sum(x * x for x in range(10_000_000)) একবারে একটি সংখ্যাই রাখে। ৫ GB-র লগ ফাইলও for line in f: দিয়ে লাইন ধরে পড়লে মেমরি প্রায় স্থির থাকে। মেমরি কম লাগলে প্রায়ই সময়ও কম লাগে — বড় list বানানো আর মোছারও খরচ আছে।
পর্ব ২৯-এ দেখেছেন, সাধারণ object-এর প্রতিটির নিজস্ব একটি __dict__ থাকে — attribute রাখার একটি ছোট dict. দশ লাখ ছোট object (যেমন প্রতিটি বিক্রির একটি Sale object) হলে দশ লাখ dict! Class-এ __slots__ ঘোষণা করলে (__slots__ = ("code", "qty", "price")) object-গুলো dict ছাড়া নির্দিষ্ট ঘরে attribute রাখে — প্রতিটি object উল্লেখযোগ্যভাবে ছোট হয়, attribute পড়াও সামান্য দ্রুত। দাম: নতুন attribute চলার সময় যোগ করা যায় না। Python 3.10 থেকে @dataclass(slots=True) লিখলেই হয়। কয়েকশো object-এ এর কোনো দরকার নেই; লাখো object-এ tracemalloc দিয়ে মেপে তারপর সিদ্ধান্ত নিন।
৭ · দ্রুততর runtime, C/Rust, আর I/O-র অপেক্ষা
Algorithm ঠিক, built-in ব্যবহার করা হয়েছে, তবু একটি বিশুদ্ধ-Python হিসাব লক্ষ্যের চেয়ে ধীর? তখন আসে বড় অস্ত্রগুলো। সহজ থেকে কঠিন ক্রমে:
| উপায় | কী করে | কখন মানানসই | দাম |
|---|---|---|---|
| নতুন CPython | Python 3.11 থেকে “Faster CPython” প্রকল্পের উন্নতি — 3.11 আগের version-এর চেয়ে গড়ে প্রায় ২৫% দ্রুত | সবসময় প্রথম চেষ্টা: শুধু version আপডেট | প্রায় নেই; dependency-গুলো নতুন version সমর্থন করে কি না দেখুন |
| PyPy | আলাদা একটি Python implementation, JIT দিয়ে গরম লুপকে চলার সময় machine code বানায় | লম্বা সময় ধরে চলা বিশুদ্ধ-Python হিসাব | C extension-এ দুর্বল; নতুন Python version আসে দেরিতে |
| Cython | Python-এর মতো ভাষা, type ঘোষণাসহ, যা C-তে অনুবাদ হয়ে compile হয় | একটি গরম লুপ, সংখ্যার হিসাব | build-এর ঝামেলা, আলাদা ভাষা শেখা |
| mypyc | সাধারণ type-hint-ওয়ালা Python-কে C extension-এ compile করে (পর্ব ৩৭) | ইতিমধ্যে ভালো type hint-ওয়ালা কোড | সব Python বৈশিষ্ট্য সমর্থিত নয়; build ধাপ |
| C বা Rust-এ hotspot | ছোট একটি গরম অংশ C extension বা Rust-এ (PyO3) লিখে Python থেকে ডাকা | লাইব্রেরি, যা হাজারো মানুষ ব্যবহার করবেন | দুই ভাষা, compile, মেমরি-বাগের ঝুঁকি (C-তে) |
শেষ পথটি নতুন কিছু নয় — Python-এর জনপ্রিয় লাইব্রেরিগুলোর বড় অংশ ঠিক এভাবেই তৈরি। NumPy-র ভেতর C, পুরোনো গাণিতিক অংশে Fortran-এর ঐতিহ্য; Pydantic 2-এর মূল অংশ (pydantic-core) Rust-এ PyO3 দিয়ে লেখা; Polars নামের ডেটা লাইব্রেরিও Rust-এ। আপনি import করে Python-এর আরাম পান, কাজটা হয় সংকলিত কোডে। তবে মনে রাখুন: নিজের প্রজেক্টে এই পথ নেওয়া মানে দুটি ভাষা, একটি build পদ্ধতি আর বেশি রক্ষণাবেক্ষণ। প্রায় সব ক্ষেত্রে অংশ ৪-৫-এর কাজ এবং একটি তৈরি লাইব্রেরিই যথেষ্ট।
প্রোগ্রাম কাজ করছে, নাকি অপেক্ষা করছে?
একটি স্ক্রিপ্ট ৫০০টি ওয়েবসাইট থেকে দাম আনে, ১০ মিনিট লাগে। Profiler দেখায় সময়ের প্রায় সবটা যাচ্ছে নেটওয়ার্ক থেকে উত্তরের অপেক্ষায়। এই কাজ I/O-bound — CPU প্রায় অলস বসে থাকে। এখানে PyPy, Cython বা algorithm কিছুই সাহায্য করবে না, কারণ সমস্যা হিসাব নয়, অপেক্ষা। সমাধান হলো অপেক্ষাগুলো একসাথে করা: পর্ব ৪১-এর thread বা পর্ব ৪২-এর asyncio দিয়ে একসাথে ৫০টি অনুরোধ পাঠানো। ১০ মিনিট নেমে আসতে পারে কয়েক সেকেন্ডে।
উল্টো দিকে, যে কাজে CPU সত্যিই ব্যস্ত (ছবি প্রক্রিয়া, বড় হিসাব) — CPU-bound — সেখানে সাধারণ CPython-এ thread দিয়ে লাভ হয় না, কারণ GIL একসাথে একটি thread-কেই Python bytecode চালাতে দেয়। সেখানে multiprocessing, NumPy-র মতো লাইব্রেরি (যারা ভারী কাজের সময় GIL ছেড়ে দেয়), বা free-threaded build (পর্ব ৪১)। মূল প্রশ্নটি তাই প্রথমেই: wall-clock সময় বনাম CPU সময় — অংশ ২-এর দুটি ঘড়ির পার্থক্য এখানেই কাজে লাগে।
৮ · কখন optimize করবেন না
পারফরম্যান্সের সবচেয়ে গুরুত্বপূর্ণ দক্ষতা হলো জানা কখন কিছুই না করা উচিত:
- কোড এখনো ঠিকমতো কাজ করে না: আগে সঠিক, তারপর পরিষ্কার, তারপর — দরকার হলে — দ্রুত। ভুল উত্তর দ্রুত দেওয়া কোনো অর্জন নয়।
- লক্ষ্য ইতিমধ্যে পূরণ: রিপোর্ট ০.৬ সেকেন্ডে হয়, লক্ষ্য ছিল ২ সেকেন্ড। ০.৩-এ নামাতে একদিন খরচ করা লোকসান।
- মাপা হয়নি: “এটা নিশ্চয়ই ধীর” অনুভূতির ভিত্তিতে বদল নয়। আগে সংখ্যা।
- লাভ ছোট, দাম বড়: ৩% গতির জন্য কোড অর্ধেক পড়া যায় না এমন করা — পরের মানুষটি (ছয় মাস পরের আপনি) এর দাম দেবেন, বাগ খুঁজতে গিয়ে।
- এক্ষেত্রে সময় অন্য কোথাও: পেজ খুলতে ২ সেকেন্ড, যার ১.৮ সেকেন্ড ডেটাবেস কোয়েরিতে — Python লুপ ঘষেমেজে লাভ নেই; কোয়েরি বা index ঠিক করুন।
(১) লক্ষ্য ঠিক করুন আর test লিখুন। (২) মাপুন। (৩) Profile করে hotspot খুঁজুন। (৪) সেখানে আগে algorithm আর ডেটা স্ট্রাকচার। (৫) তারপর লুপ C-তে ঠেলুন — built-in, লাইব্রেরি, NumPy. (৬) অপেক্ষার কাজে concurrency. (৭) তারপরও দরকার হলে নতুন runtime, Cython বা Rust. প্রতিটি ধাপের পরে আবার মাপুন — আর লক্ষ্য পূরণ হলেই থামুন।
মূলনীতি এক — আগে মাপা, profiler, আর algorithm-ই বড় লাভ। পার্থক্য হলো কোন “ছোট” জিনিস গুরুত্বপূর্ণ। C++-এ cache-বান্ধব মেমরি বিন্যাস, অকারণ কপি আর compiler optimization বড় বিষয় (C++-এর পারফরম্যান্স পর্ব)। Python-এ প্রতিটি মান একটি object আর প্রতিটি নির্দেশ interpreter-এর মধ্য দিয়ে যায়, তাই সবচেয়ে বড় প্রশ্ন হলো “লুপটি কি Python-এ চলছে, নাকি C-তে?” এজন্যই Python-এর ভারী কাজ শেষ পর্যন্ত প্রায়ই C, C++ বা Rust-এ লেখা লাইব্রেরিতে যায়।
- Profiling কী, আর Python-এ কীভাবে করেন? — প্রোগ্রাম চলার সময় কোন ফাংশনে কত ডাক আর কত সময়, তা মাপা;
python -m cProfile -s cumtime script.py. tottimeআরcumtime-এর পার্থক্য? — tottime শুধু ফাংশনের নিজের কোডের সময়; cumtime ভেতর থেকে ডাকা সব ফাংশনসহ মোট সময়।- list-এ
inআর set-এin-এর জটিলতা? — list-এ O(n) (রৈখিক খোঁজ), set/dict-এ গড়ে O(1) (hash table). - অনেক string জোড়ায়
+=-এর বদলেjoinকেন? — string immutable, তাই+=প্রতিবার নতুন string বানিয়ে কপি করতে পারে (মোট O(n²));joinএকবারে বানায়, O(n). - “Premature optimization is the root of all evil” কার কথা, মানে কী? — Donald Knuth, ১৯৭৪; না মেপে অগুরুত্বপূর্ণ অংশ জটিল করবেন না, কিন্তু গুরুত্বপূর্ণ ৩% অবহেলাও করবেন না।
৯ · AI-এর যুগে — না-মাপা “দ্রুত” দাবি যাচাই করা
“এই কোডটা optimize করে দাও” — AI খুশিমনে নতুন কোড দেয়, সঙ্গে আত্মবিশ্বাসী দাবি: “এটি এখন ৩ গুণ দ্রুত।” কিন্তু AI আপনার কোড আপনার ডেটায় চালায়নি, মাপেনি। সংখ্যাটি প্রায়ই অনুমান — কখনো সত্যের কাছাকাছি, কখনো একদম উল্টো। পারফরম্যান্সে AI-এর লেখায় বারবার যে ভুলগুলো দেখা যায়:
| AI প্রায়ই যা করে | সমস্যা | কী চাইবেন |
|---|---|---|
| “X গুণ দ্রুত” দাবি, কোনো মাপ ছাড়া | যাচাইহীন সংখ্যা; আপনার ডেটায় হয়তো ধীরও | আগে-পরে timeit বা benchmark-এর কোড, যা আপনি নিজে চালাবেন |
| Profile না দেখে micro-trick: local নাম, bit-কৌশল, একলাইনে সব | hotspot-এ হাত পড়েই না; লাভ কয়েক শতাংশ বা শূন্য | আগে cProfile-এর আউটপুট দিন, শুধু hotspot-এ বদল চান |
| পরিষ্কার কোডকে দুর্বোধ্য করা | বাগ লুকানোর জায়গা বাড়ে, রক্ষণাবেক্ষণ কঠিন | “পাঠযোগ্যতা কমলে কারণ ও মাপা লাভ দেখাও” |
নেটওয়ার্ক বা ফাইলের কাজে multiprocessing | I/O-bound কাজে process-এর খরচই বেশি; লাভ নেই বা ক্ষতি | wall-clock বনাম CPU সময় আগে দেখা; I/O হলে asyncio বা thread |
| O(n²) রেখে দিয়ে চারপাশে কৌশল | আসল সমস্যা অক্ষত, ডেটা বাড়লে আবার ধীর | “লুপের ভেতরে list-খোঁজ বা পুনরাবৃত্ত কাজ আছে কি?” আগে জিজ্ঞেস |
| নতুন কোডে ফলাফল সামান্য বদলে যাওয়া | দ্রুত কিন্তু ভুল — সবচেয়ে বিপজ্জনক | পুরোনো আর নতুন ফাংশনের ফল মেলানোর test |
AI-কে পারফরম্যান্সের সাহায্য চাওয়ার সময় তথ্য আর শর্ত দুটোই দিন। নিচের কাঠামোটি কপি করে নিজের কাজ বসান:
AI-এর “optimize করা” কোড পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলো আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Knuth-এর “premature optimization is the root of all evil” কথাটি আসলে কোথায়, কোন প্রসঙ্গে বলা? প্রসঙ্গ বাদ দিয়ে উদ্ধৃতিটি ব্যবহার করলে কী ভুল হয়?
Donald E. Knuth — Stanford-এর অধ্যাপক, The Art of Computer Programming গ্রন্থমালার লেখক, TeX টাইপসেটিং ব্যবস্থার স্রষ্টা। ১৯৭৪ সালের ডিসেম্বরে ACM Computing Surveys পত্রিকায় তাঁর একটি দীর্ঘ প্রবন্ধ প্রকাশিত হয়: “Structured Programming with go to Statements”. সময়টা মনে রাখার মতো। ১৯৬৮ সালে Edsger Dijkstra-র বিখ্যাত চিঠি “Go To Statement Considered Harmful” প্রকাশের পরে তখন বিতর্ক চলছে — goto কি পুরোপুরি বাদ দেওয়া উচিত? Knuth-এর প্রবন্ধ এই বিতর্কে একটি ভারসাম্যপূর্ণ অবস্থান নেয়: বেশিরভাগ ক্ষেত্রে কাঠামোবদ্ধ কোডই ভালো, কিন্তু কিছু ক্ষেত্রে, বিশেষত দক্ষতার প্রয়োজনে, goto-র যুক্তিসংগত ব্যবহার আছে।
এই প্রবন্ধেই, দক্ষতা নিয়ে আলোচনার মাঝে, সেই বিখ্যাত অনুচ্ছেদ। তাঁর বক্তব্যের সারাংশ: প্রোগ্রামাররা তাঁদের প্রোগ্রামের অগুরুত্বপূর্ণ অংশের গতি নিয়ে ভেবে আর দুশ্চিন্তা করে বিপুল সময় নষ্ট করেন, আর ডিবাগিং ও রক্ষণাবেক্ষণের কথা ধরলে দক্ষতার এই চেষ্টাগুলো আসলে বড় ক্ষতি করে। ছোট দক্ষতাগুলোর কথা আমাদের প্রায় ৯৭% সময় ভুলে থাকা উচিত — premature optimization is the root of all evil. তবু সেই গুরুত্বপূর্ণ ৩%-এ আমাদের সুযোগ হাতছাড়া করা উচিত নয়। তিনি আরও বলেন, একজন ভালো প্রোগ্রামার এই যুক্তিতে আত্মতুষ্ট হবেন না; তিনি গুরুত্বপূর্ণ কোডটি মনোযোগ দিয়ে দেখবেন — কিন্তু সেটি চিহ্নিত হওয়ার পরেই. আর অভিজ্ঞতা থেকে তিনি সতর্ক করেন: কোন অংশ সত্যিই গুরুত্বপূর্ণ, সে বিষয়ে প্রোগ্রামারদের সহজাত অনুমান প্রায়ই ভুল হয় — তাই measurement টুল ব্যবহার করা উচিত।
লক্ষ করুন, একই প্রবন্ধে Knuth বলেছেন যে প্রতিষ্ঠিত প্রকৌশল শাখায় ১২% উন্নতি, যা সহজে পাওয়া যায়, কখনো তুচ্ছ বিবেচিত হয় না — আর সফটওয়্যারেও একই দৃষ্টিভঙ্গি থাকা উচিত। অর্থাৎ তিনি দক্ষতার বিরোধী নন, বরং দক্ষতার পক্ষে. তাঁর আপত্তি অন্ধ, না-মাপা, অসময়ের optimization-এ।
প্রসঙ্গ বাদ দিলে কী হয়? আজ অনেকে কথাটিকে অজুহাত বানান: “গতি নিয়ে ভাবাই খারাপ।” ফলে লুপের ভেতরে list-খোঁজ রেখে দেওয়া হয়, O(n²) নকশা চলে যায় production-এ — আর যখন ডেটা বাড়ে, পুরো সিস্টেম নতুন করে লিখতে হয়। ঠিক ডেটা স্ট্রাকচার বেছে নেওয়া “premature optimization” নয়; এটি ভালো নকশা, আর এর খরচ প্রায় শূন্য। অন্যদিকে না মেপে local-নামের কৌশলে কোড ভরে ফেলা — সেটিই Knuth-এর বর্ণিত অনিষ্ট।
উদ্ধৃতিটি প্রায়ই Tony Hoare-এর নামেও চলে; Knuth পরে এক জায়গায় এটিকে Hoare-এর কথা বলে উল্লেখ করেছিলেন, কিন্তু Hoare নিজে এর উৎস হওয়ার কথা মনে করতে পারেননি বলে জানা যায়। মূল মুদ্রিত উৎস হিসেবে ১৯৭৪-এর এই প্রবন্ধটিই ধরা হয়।
মূল উপলব্ধি: Knuth-এর বার্তা দুই অংশের — অগুরুত্বপূর্ণ ৯৭%-এ কোড জটিল করবেন না, কিন্তু গুরুত্বপূর্ণ ৩% মেপে খুঁজে বের করে সেখানে পূর্ণ মনোযোগ দিন। প্রথম অংশ ছাড়া কোড দুর্বোধ্য হয়; দ্বিতীয় অংশ ছাড়া প্রোগ্রাম ধীর থাকে।
প্র ০২Python 3.11 কোনো কোড বদল ছাড়াই দ্রুত হলো কীভাবে? “Faster CPython” প্রকল্প আর PEP 659-এর specializing adaptive interpreter থেকে মাপা নিয়ে কী শেখার আছে?
বহু বছর ধরে CPython-এর নীতি ছিল সরলতা: interpreter বোঝা আর রক্ষণাবেক্ষণ সহজ থাকুক, যাতে স্বেচ্ছাসেবীরা কাজ করতে পারেন। গতির উন্নতি আসত ধীরে। ২০২০ সালে core developer Mark Shannon কয়েক ধাপে CPython-কে বহুগুণ দ্রুত করার একটি পরিকল্পনা প্রকাশ করেন। ২০২১ সাল থেকে Microsoft একটি ছোট পূর্ণকালীন দলকে অর্থায়ন করে, যেখানে Guido van Rossum আর Mark Shannon দুজনেই ছিলেন — প্রকল্পের নাম “Faster CPython”. সব কাজ হয়েছে খোলাখুলি, CPython-এর মূল কোডেই।
৩.১১-এর (২০২২) কেন্দ্রে ছিল PEP 659 — Specializing Adaptive Interpreter. পর্ব ০৫-এ দেখেছেন মূল ভাবনা: একটি লাইন বাস্তবে প্রায় সবসময় একই type-এর মান পায়। তাই কয়েকবার চলার পর interpreter সাধারণ নির্দেশটিকে (যেমন যোগের BINARY_OP) বদলে বসায় একটি বিশেষায়িত নির্দেশ, যা “দুটিই int” বা “এটি এই class-এর এই attribute” ধরে নিয়ে সোজা পথে চলে; ধারণা ভুল হলে আবার সাধারণ নির্দেশে ফেরে। এর সাথে এসেছে সস্তা ফাংশন ডাক (frame তৈরির খরচ কম), “zero-cost” exception আর দ্রুত শুরু। অফিসিয়াল হিসাবে ৩.১১ আগের version-এর চেয়ে গড়ে প্রায় ২৫% দ্রুত, কাজভেদে ১০ থেকে ৬০%.
এই পর্বের দৃষ্টিতে সবচেয়ে শিক্ষণীয় হলো তাঁরা কীভাবে মেপেছেন. “গড়ে ২৫%” এসেছে pyperformance নামের একটি benchmark সংগ্রহ থেকে — বাস্তব ধরনের অনেকগুলো ছোট-বড় প্রোগ্রাম (template তৈরি, JSON, regex, গাণিতিক হিসাব ইত্যাদি)। প্রতিটি পরিবর্তন এই সংগ্রহে চালিয়ে দেখা হতো কোনটিতে লাভ, কোনটিতে ক্ষতি। লক্ষ করুন “গড়” শব্দটি: কোনো benchmark-এ লাভ অনেক, কোনোটিতে প্রায় শূন্য। আপনার প্রোগ্রাম সময়ের বেশিরভাগ যদি NumPy-র ভেতরে বা ডিস্কের অপেক্ষায় কাটায়, তাহলে interpreter দ্রুত হলেও আপনার লাভ সামান্য — কারণ interpreter আপনার hotspot-ই নয়। সেই একই নীতি: সময় কোথায় যায়, সেটাই ঠিক করে কোন উন্নতি কাজে লাগবে।
আরেকটি শিক্ষা: specializing interpreter অংশ ৬-এর কিছু পুরোনো micro-trick-এর মূল্য কমিয়ে দিয়েছে। Global নাম খোঁজা এখন cache হয়, তাই “global-কে local বানাও” কৌশলের লাভ ৩.১১-এর আগের চেয়ে কম। অনেক পুরোনো ব্লগ আর AI-এর উত্তর এখনো আগের যুগের সংখ্যা দেয় — আপনার version-এ নিজে মেপে দেখাই একমাত্র নিরাপদ পথ।
শেষে, প্রকল্পের শুরুর বড় লক্ষ্য (কয়েক version-এ বহুগুণ গতি) পুরোপুরি পূরণ হয়নি; পরের version-গুলোতে উন্নতি এসেছে ছোট ধাপে। বাস্তব প্রকৌশলে এটাই স্বাভাবিক — বিশেষ করে যখন লাখো প্যাকেজের C extension-এর সাথে মিল রাখতে হয়।
মূল উপলব্ধি: Faster CPython দেখায় বড় প্রকল্পেও গতি আসে মাপা, ধারাবাহিক উন্নতিতে — একটি benchmark সংগ্রহ, প্রতিটি বদলের আগে-পরে তুলনা। আর “গড়ে X% দ্রুত” আপনার প্রোগ্রামের জন্য কী মানে, তা জানতে আপনার নিজের মাপই লাগবে।
প্র ০৩PyPy কীভাবে বিশুদ্ধ Python কোড কয়েক গুণ দ্রুত চালায়? “JIT” আর “meta-tracing” মানে কী, আর এর সীমা কোথায়?
PyPy প্রকল্প শুরু হয় ২০০৩ সালের দিকে, একটি আপাত-অদ্ভুত ভাবনা নিয়ে: Python-এ লেখা Python interpreter. আসলে লেখা হয় RPython-এ (Restricted Python) — Python-এর একটি সীমিত রূপ, যার type আগে থেকে নির্ণয় করা যায়, তাই সেটিকে C-তে অনুবাদ করে compile করা সম্ভব। প্রকল্পটি ইউরোপের কিছু গবেষক ও ডেভেলপারের হাতে বড় হয়, ইউরোপীয় ইউনিয়নের গবেষণা অনুদানও পায়, আর ২০০৭ সালে PyPy 1.0 প্রকাশিত হয়। তবে বড় লাফটি আসে পরে, যখন এর JIT পরিণত হয় — ২০০৯ সালে Carl Friedrich Bolz, Antonio Cuni, Maciej Fijałkowski ও Armin Rigo-র “Tracing the Meta-Level: PyPy's Tracing JIT Compiler” গবেষণাপত্রে এর পদ্ধতি বর্ণিত হয়।
JIT (Just-In-Time) কম্পাইলার প্রোগ্রাম চলার সময় লক্ষ করে কোন অংশ বারবার চলছে (গরম লুপ), আর সেই অংশের জন্য সরাসরি machine code বানিয়ে ফেলে। Tracing JIT একটি গরম লুপের একটি পাকে আসলে কোন পথে কী ঘটল তা রেকর্ড করে (trace) — “এখানে দুটি int এসেছিল, এই শাখায় গিয়েছিল” — তারপর শুধু সেই পথের জন্য অত্যন্ত দ্রুত কোড বানায়, সঙ্গে ছোট পাহারা (guard) রাখে: ধারণা ভেঙে গেলে আবার সাধারণ interpreter-এ ফেরে। এতে অংশ ৫-এর অনেক খরচ উধাও হয় — type যাচাই একবার, অস্থায়ী int object প্রায়ই তৈরিই হয় না।
Meta-tracing হলো PyPy-র আসল চমক। সাধারণত JIT লিখতে হয় ভাষাটির জন্য হাতে, যা বিশাল কাজ। PyPy-তে JIT ট্রেস করে interpreter-কেই — RPython-এ লেখা interpreter-এ কিছু ইঙ্গিত দিলে টুলচেইন নিজে থেকে JIT তৈরি করে। তাই একই কাঠামো দিয়ে অন্য ভাষার দ্রুত interpreter-ও বানানো গেছে।
ফল: PyPy-র নিজস্ব benchmark-এ লম্বা সময় ধরে চলা বিশুদ্ধ-Python প্রোগ্রাম গড়ে CPython-এর চেয়ে কয়েক গুণ দ্রুত। তবু এটি ডিফল্ট নয় কেন, পর্ব ০৫-এর ভাবনার প্রশ্নে দেখেছেন: C extension (NumPy ইত্যাদি) CPython-এর ভেতরের গঠন ধরে লেখা, PyPy-তে তাদের অনুকরণ ধীর; নতুন Python version আসে দেরিতে; JIT-এর শুরুতে “গরম হওয়ার” সময় আর বেশি মেমরি লাগে, তাই এক সেকেন্ডের স্ক্রিপ্টে লাভ নেই।
এই পর্বের দৃষ্টিতে প্রশ্ন হলো: কখন PyPy চেষ্টা করবেন? যখন profiler দেখাচ্ছে সময় যাচ্ছে বিশুদ্ধ Python লুপে (NumPy-তে নয়, I/O-তে নয়), algorithm ইতিমধ্যে ঠিক, আর প্রোগ্রাম অনেকক্ষণ চলে — যেমন একটি simulation বা বড় টেক্সট প্রক্রিয়া। তখন কোড একটুও না বদলে pypy3 script.py চালিয়ে মেপে দেখা খুবই সস্তা একটি পরীক্ষা।
মূল উপলব্ধি: PyPy প্রমাণ করে Python “ধীর” ভাষার গুণ নয়, implementation-এর গুণ — একই কোড অন্য যন্ত্রে বহুগুণ দ্রুত চলতে পারে। কিন্তু ecosystem-এর সাথে মিল আর বাস্তব কাজের ধরন ঠিক করে সেই গতি আপনার কাজে লাগবে কি না।
প্র ০৪Python যদি “ধীর” ভাষা হয়, তাহলে বিশ্বের সবচেয়ে ভারী হিসাব — বৈজ্ঞানিক গবেষণা, মেশিন লার্নিং — কেন Python-এ হয়? “Python is the glue” কথাটির মানে কী?
প্রশ্নটির উত্তর লুকিয়ে আছে অংশ ৫-এর স্টেপারে: হিসাবটি আসলে Python-এ হয় না. Python শুধু বলে দেয় কী করতে হবে; আসল কাজ করে C, C++, Fortran বা CUDA-তে লেখা সংকলিত কোড।
ইতিহাসটা দেখুন। ১৯৯০-এর দশকেই বিজ্ঞানীরা Python-এ সংখ্যার array নিয়ে কাজ করার জন্য Numeric নামের একটি লাইব্রেরি বানান; পরে আসে আরেকটি প্রতিদ্বন্দ্বী, Numarray. দুই শিবিরে ভাগ হয়ে যাওয়া সম্প্রদায়কে এক করতে Travis Oliphant দুটির সেরা অংশ মিলিয়ে বানান NumPy, যার ১.০ প্রকাশিত হয় ২০০৬ সালে। NumPy-র ভেতরে রৈখিক বীজগণিতের ভারী কাজ যায় BLAS আর LAPACK-এর মতো লাইব্রেরিতে — যাদের ঐতিহ্য কয়েক দশকের পুরোনো Fortran কোডে, আর আধুনিক রূপ (OpenBLAS, Intel MKL) CPU-র প্রতিটি কৌশল কাজে লাগাতে হাতে-টিউন করা। এরপর এই ভিত্তির ওপর দাঁড়ায় SciPy, pandas, scikit-learn, আর পরে TensorFlow ও PyTorch — যেখানে ভারী হিসাব চলে C++ আর GPU-র CUDA kernel-এ।
এখানেই “glue language” ধারণা। Python আঠার মতো বিভিন্ন দ্রুত উপাদান জুড়ে দেয়: একটি ফাইল পড়ো (C-তে লেখা পার্সার), একটি ম্যাট্রিক্স গুণ করো (Fortran/C-র BLAS), GPU-তে মডেল চালাও (CUDA), ফল ছবিতে আঁকো। প্রোগ্রামের মোট সময়ের ৯৯%-এর বেশি কাটে এই দ্রুত উপাদানগুলোতে; Python-এর অংশ — কোন কাজের পরে কোনটি, কোন সেটিং — সময়ের সামান্য অংশ। তাই Python ধীর হলেও মোট গতিতে প্রায় কিছু যায় আসে না। আর বিনিময়ে মেলে বিশাল সুবিধা: গবেষক কয়েক লাইনে একটি পরীক্ষা লিখতে পারেন, Jupyter-এ ধাপে ধাপে দেখতে পারেন, C-তে যা লিখতে সপ্তাহ লাগত।
এটি আসলে Python-এর একদম শুরুর নকশারই ফল। Guido van Rossum Python-কে সহজে C দিয়ে বাড়ানোর উপযোগী করে বানিয়েছিলেন — C extension লেখার API শুরু থেকেই ছিল। এই পর্বের Knuth-এর ৩% নীতির বড় পরিসরের রূপও এটি: গুরুত্বপূর্ণ ৩% (ভারী লুপ) সংকলিত ভাষায়, বাকি ৯৭% (নিয়ন্ত্রণ, গোছানো, পরীক্ষা) আরামদায়ক ভাষায়।
তবে একটি ফাঁদ আছে, যা নতুনরা প্রায়ই পড়েন: NumPy ব্যবহার করেও উপাদান ধরে ধরে Python for লুপ লিখলে (for i in range(len(arr)): arr[i] * 2) সব সুবিধা হারায় — প্রতিটি উপাদান আবার Python object হয়ে আসে, এমনকি সাধারণ list-এর চেয়েও ধীর হতে পারে। গতি আসে শুধু পুরো array-র ওপর একটি কাজ (arr * 2) বলে দিলে — vectorization. পর্ব ৪৬-এ এটি বিস্তারিত দেখবেন।
মূল উপলব্ধি: “ভাষাটি ধীর” আর “প্রোগ্রামটি ধীর” এক কথা নয়। Python ভারী হিসাবের জগৎ জয় করেছে দ্রুত হয়ে নয়, বরং দ্রুত কোডকে সহজে জুড়ে দেওয়ার ভাষা হয়ে — সময় যেখানে যায়, সেখানে সংকলিত কোড; মানুষের সময় যেখানে যায়, সেখানে Python.
প্র ০৫Python 3.13-এ একটি পরীক্ষামূলক JIT যোগ হয়েছে (PEP 744). এটি কী, “copy-and-patch” কৌশল কী, আর আজ আপনার কোডের জন্য এর মানে কী?
Faster CPython-এর পরিকল্পনায় শুরু থেকেই ছিল একটি JIT. কিন্তু CPython-এ JIT যোগ করা কঠিন: interpreter-টি বহু প্ল্যাটফর্মে চলে, অনেক স্বেচ্ছাসেবী এটি রক্ষণাবেক্ষণ করেন, আর লাখো C extension-এর সাথে মিল রাখতে হয়। LLVM-এর মতো বড় compiler কাঠামো চলার সময় ব্যবহার করলে interpreter ভারী হয়ে যায়, নির্ভরতাও বাড়ে।
সমাধান এল একটি সাম্প্রতিক গবেষণা থেকে: copy-and-patch compilation, যা Haoran Xu আর Fredrik Kjolstad ২০২১ সালের একটি গবেষণাপত্রে বর্ণনা করেন। ভাবনাটি চমৎকার সরল: Python build করার সময়েই প্রতিটি ছোট নির্দেশের জন্য machine code-এর একটি “টেমপ্লেট” (stencil) তৈরি করে রাখা হয়, যেখানে কিছু ঘর ফাঁকা — কোন মান, কোন ঠিকানা। চলার সময় JIT শুধু দরকারি টেমপ্লেটগুলো পরপর কপি করে, আর ফাঁকা ঘরে আসল মান বসিয়ে (patch) দেয়। পুরো compiler চলার সময় লাগে না, তাই JIT ছোট আর দ্রুত। CPython-এ এটি যুক্ত করেন Brandt Bucher, আর প্রস্তাবটি PEP 744 — “JIT Compilation”.
এটি কাজ করে PEP 659-এর ওপরে আরেকটি স্তর হিসেবে: গরম কোডের বিশেষায়িত bytecode আগে ছোট ছোট “micro-op”-এ ভাঙা হয়, সেগুলো কিছুটা optimize করা হয়, তারপর copy-and-patch দিয়ে machine code বানানো হয়।
এখন সতর্কতার সাথে বাস্তব অবস্থা। Python 3.13-এ (২০২৪) JIT এসেছে পরীক্ষামূলক হিসেবে, default-এ বন্ধ; চালু করতে Python নিজে build করার সময় একটি বিশেষ option (--enable-experimental-jit) দিতে হয়। Python 3.14-এর অফিসিয়াল Windows আর macOS installer-এ JIT-টি অন্তর্ভুক্ত, তবে এখনো পরীক্ষামূলক এবং default-এ বন্ধ — একটি environment variable (PYTHON_JIT=1) দিয়ে চালু করা যায়। এ পর্যন্ত এর গতির লাভ সামান্য; কিছু benchmark-এ লাভ, কিছুতে প্রায় নেই বা সামান্য ক্ষতি। PEP 744 নিজেই বলে, JIT তখনই পরীক্ষামূলক তকমা হারাবে, যখন এটি অর্থপূর্ণ উন্নতি দেখাবে এবং রক্ষণাবেক্ষণযোগ্য প্রমাণিত হবে। এটি একটি দীর্ঘমেয়াদি ভিত্তি — বড় লাভ আসবে কি না, ভবিষ্যৎ version বলবে।
আপনার জন্য এর মানে? আজ কোনো প্রোগ্রাম ধীর হলে JIT-এর অপেক্ষায় থাকার কারণ নেই — এই পর্বের ক্রম (algorithm, built-in, NumPy, concurrency) এখনই বহুগুণ লাভ দেয়, JIT যা দিচ্ছে তার চেয়ে অনেক বেশি। কৌতূহল থাকলে 3.14-এ PYTHON_JIT=1 দিয়ে নিজের প্রোগ্রাম মেপে দেখুন — এবং মনে রাখুন, ফলাফলটি আপনার নিজের মাপ থেকেই আসা উচিত, কোনো ব্লগ বা AI-এর দাবি থেকে নয়।
মূল উপলব্ধি: CPython-এর JIT একটি বুদ্ধিদীপ্ত, হালকা নকশা — কিন্তু এখনো পরীক্ষামূলক, আর এর লাভ এখনো ছোট। ভাষার যন্ত্র দ্রুত হওয়া স্বাগত; কিন্তু O(n²) থেকে O(n)-এর মতো লাভ কোনো JIT এনে দেবে না — সেটা প্রোগ্রামারের কাজ।
অনুশীলন
বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — অনুমান করা, পড়া আর মিলিয়ে নেওয়াই আসল কাজ।
-
Profile টেবিল পড়ুন: একটি স্ক্রিপ্টের cProfile আউটপুটে তিনটি সারি —
main: ncalls 1, tottime 0.05, cumtime 12.0;clean_text: ncalls 50000, tottime 0.9, cumtime 11.6;re.compile: ncalls 50000, tottime 10.4, cumtime 10.6. Hotspot কোনটি? আন্দাজ করুন কোডে কী ভুল আছে, আর কী বদলাবেন।Hotspot
re.compile: নিজের সময় (tottime) ১০.৪ সেকেন্ড, মোট ১২ সেকেন্ডের প্রায় ৮৭%.mainআরclean_text-এর cumtime বড় কিন্তু tottime ছোট — তারা শুধু পথ। সবচেয়ে বড় সূত্র ncalls:re.compileডাকা হয়েছে ৫০,০০০ বার, ঠিকclean_text-এর সমান। অর্থাৎ প্রতিটি টেক্সট পরিষ্কার করার সময় একই regex নতুন করে compile হচ্ছে — লুপের ভেতরে অপরিবর্তিত কাজ। সমাধান: pattern-টি module-এর শুরুতে একবারPATTERN = re.compile(...)করে রাখা, আরclean_text-এPATTERN.sub(...)ব্যবহার করা। (Python-এরremodule ভেতরে সাম্প্রতিক কিছু pattern cache রাখে, তবু একবার compile করাই পরিষ্কার ও নিশ্চিত।) তারপর আবার মাপুন। -
কাজ গুনুন: একটি ফাংশন দুটি list নেয় —
registered(৫০,০০০ শিক্ষার্থী আইডি) আরattended(৪০,০০০ আইডি) — আর বের করে কারা নিবন্ধিত কিন্তু অনুপস্থিত:[s for s in registered if s not in attended]. সবচেয়ে খারাপ ক্ষেত্রে মোটামুটি কতগুলো তুলনা হবে? এক লাইনের কোন বদলে এটি O(n) হবে?প্রতিটি নিবন্ধিত আইডির জন্য
not in attendedএকটি list-খোঁজ — সবচেয়ে খারাপ ক্ষেত্রে ৪০,০০০ তুলনা। মোট প্রায় ৫০,০০০ × ৪০,০০০ = ২০০ কোটি তুলনা — কয়েক মিনিট পর্যন্ত লাগতে পারে। বদল: আগে একবারattended_set = set(attended)(৪০,০০০ ধাপ), তারপর[s for s in registered if s not in attended_set](৫০,০০০টি গড়ে-O(1) খোঁজ) — মোট প্রায় ৯০,০০০ ধাপ। ক্রম জরুরি না হলেset(registered) - set(attended)-ও চলে (পর্ব ১৭-এর set difference), কিন্তু তাতে মূল ক্রম হারায় আর ডুপ্লিকেট মুছে যায় — তাই কোনটি চাই আগে ভাবুন। অনেকে লুপের ভেতরেset(attended)লিখে ফেলেন — তাতে প্রতিবার নতুন set বানানো হয়, আরও ধীর! -
I/O না CPU: তিনটি কাজ — (ক) ২০০টি সরকারি ওয়েবসাইট থেকে নোটিশের PDF ডাউনলোড, (খ) ১০,০০০ ছবির আকার ছোট করা, (গ) একটি ফোল্ডারের ৫০,০০০ ছোট টেক্সট ফাইলের শব্দ গোনা। প্রতিটি I/O-bound না CPU-bound বলে মনে হয়? কীভাবে নিশ্চিত হবেন, আর প্রতিটির জন্য প্রথমে কোন পথ ভাববেন?
(ক) প্রায় নিশ্চিতভাবে I/O-bound — সময় যায় নেটওয়ার্কের উত্তরের অপেক্ষায়; asyncio বা thread দিয়ে একসাথে অনেক ডাউনলোড (সার্ভারের ওপর অতিরিক্ত চাপ না দিয়ে, সীমিত সংখ্যায়)। (খ) CPU-bound — প্রতিটি ছবির পিক্সেল হিসাব; Pillow-এর মতো C-তে লেখা লাইব্রেরি ব্যবহার, আর একাধিক core কাজে লাগাতে
multiprocessingবাconcurrent.futures.ProcessPoolExecutor. (গ) মিশ্র — ফাইল খোলায় ডিস্কের অপেক্ষা, শব্দ গোনায় CPU; আগে মাপুন। নিশ্চিত হওয়ার উপায়: একই কাজেtime.perf_counter()(wall-clock) আরtime.process_time()(CPU সময়) দুটিই মাপুন। wall-clock অনেক বেশি হলে প্রোগ্রাম অপেক্ষা করছে (I/O); দুটি প্রায় সমান হলে CPU ব্যস্ত। সাথে Task Manager-এ CPU ব্যবহার দেখাও একটি সহজ সূত্র। -
AI-এর দাবি যাচাই করুন (কোড লিখতে হবে): কোনো AI-কে বলুন, “সংখ্যার একটি list-এর বর্গের যোগফল বের করার সবচেয়ে দ্রুত Python কোড দাও।” তারপর তার উত্তর আর নিচের তিনটি রূপ
timeitদিয়ে নিজের মেশিনে মাপুন: (ক)forলুপ আরtotal +=, (খ)sum(x * x for x in nums), (গ)sum([x * x for x in nums]). AI-এর দাবির সাথে আপনার মাপ মিলল?একটি মাপার কাঠামো:
import timeit nums = list(range(100_000)) def with_loop(): total = 0 for x in nums: total += x * x return total variants = { "loop": with_loop, "genexpr": lambda: sum(x * x for x in nums), "listcomp": lambda: sum([x * x for x in nums]), } assert len({f() for f in variants.values()}) == 1 # সবার ফল এক for name, f in variants.items(): best = min(timeit.repeat(f, number=50, repeat=5)) print(f"{name:9} {best:.3f} s")প্রথমে
assertনিশ্চিত করে তিনটির ফল এক — দ্রুত কিন্তু ভুল কোড বাদ।timeit.repeatপাঁচ দফা চালায়, আর আমরা সবচেয়ে কম সময়টি নিই। সাধারণত (খ) আর (গ) লুপের চেয়ে কিছুটা দ্রুত, আর (গ) প্রায়ই (খ)-এর চেয়ে একটু দ্রুত কিন্তু বেশি মেমরি নেয় — তবে পার্থক্য ছোট আর version ভেদে বদলায়, তাই নিজের সংখ্যাই চূড়ান্ত। AI যদি “১০ গুণ দ্রুত” দাবি করে থাকে, প্রায় নিশ্চিতভাবে মিলবে না। সত্যিকারের বড় লাভ চাইলে NumPy:arr = np.array(nums), তারপরint((arr * arr).sum())(তবে খুব বড় সংখ্যায় int64 overflow-এর দিকে খেয়াল রাখুন)।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি ধীর Python প্রোগ্রাম দ্রুত করার কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- “যথেষ্ট দ্রুত”-এর লক্ষ্য ঠিক করা আর ফলাফল যাচাইয়ের test লেখা
- বাস্তবের মতো ডেটায় বর্তমান সময় মাপা
- Profiler চালিয়ে hotspot খোঁজা
- Hotspot-এ algorithm বা ডেটা স্ট্রাকচার বদলানো
- Test চালিয়ে দেখা ফলাফল একই আছে
- আবার মাপা, আর লক্ষ্য পূরণ হলে থামা
Python-এর cProfile আউটপুটে একটি ফাংশনের cumtime ৩০ সেকেন্ড কিন্তু tottime ০.২ সেকেন্ড। এর সবচেয়ে সম্ভাব্য অর্থ কী?
- ফাংশনটির নিজের কোডই hotspot
- সময় যাচ্ছে এই ফাংশনের ভেতর থেকে ডাকা অন্য ফাংশনগুলোতে
- profiler ভুল মেপেছে, আবার চালাতে হবে
- ফাংশনটি ১৫০ বার ডাকা হয়েছে
n উপাদানের একটি Python list-এ প্রতিটি উপাদানের জন্য আরেকটি n উপাদানের list-এ x in other চালালে মোট কাজের জটিলতা কত, আর other-কে set বানালে কত?
- দুই ক্ষেত্রেই O(n)
- O(n²) আর O(n log n)
- O(n²) আর গড়ে O(n)
- O(n) আর O(n²)
Python-এ sum(nums) সাধারণত একই কাজের হাতে লেখা for লুপের চেয়ে দ্রুত হয় কেন?
- sum-এর লুপ C-তে চলে, প্রতি উপাদানে bytecode নির্দেশ চালাতে হয় না
- sum কিছু উপাদান বাদ দিয়ে আনুমানিক যোগফল দেয়
- sum স্বয়ংক্রিয়ভাবে সব CPU core-এ কাজ ভাগ করে
- sum আগের ডাকের ফল cache করে রাখে
একটি স্ক্রিপ্ট ৩০০টি API থেকে ডেটা আনে আর ৮ মিনিট লাগে; time.perf_counter()-এ মাপা সময় ৮ মিনিট, কিন্তু time.process_time()-এ মাত্র ৩ সেকেন্ড। প্রথমে কোন পদক্ষেপ সবচেয়ে যুক্তিসংগত?
- PyPy-তে চালিয়ে দেখা
- মূল লুপটি Cython-এ লেখা
- multiprocessing দিয়ে প্রতিটি core-এ আলাদা process
- asyncio বা thread দিয়ে অনুরোধগুলো একসাথে পাঠানো
Python-এ ৫০,০০০টি টুকরো জুড়ে একটি বড় string বানানোর সবচেয়ে উপযুক্ত উপায় কোনটি?
- লুপে
s += piece - টুকরোগুলো list বা generator-এ রেখে
"".join(pieces) - লুপে
s = f"{s}{piece}" s.append(piece)
Python-এ মুদ্রার রূপান্তর হার আনার একটি ধীর ফাংশনে @functools.lru_cache(maxsize=None) দেওয়া হলো, আর হার প্রতিদিন বদলায়। সবচেয়ে বড় ঝুঁকি কোনটি?
- প্রোগ্রাম আগের চেয়ে ধীর হয়ে যাবে
- string argument দিলে
TypeErrorআসবে - হার বদলালেও পুরোনো হার ফেরত আসতে থাকবে
- কয়েকটি মুদ্রাতেই মেমরি ফুরিয়ে যাবে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- অনুমান নয়, মাপুন। আগে “যথেষ্ট দ্রুত” আর test, তারপর
perf_counter/timeitদিয়ে মাপ; মাপ গোলমেলে, তাই কয়েকবার, বাস্তবের মতো ডেটায়। - Profiler সময়ের ঠিকানা দেয়:
python -m cProfile -s cumtime; বড় cumtime + ছোট tottime মানে “আরও ভেতরে”, বড় tottime মানে hotspot. ncalls-এর অপ্রত্যাশিত বড় সংখ্যা প্রায়ই সূত্র। - Algorithm আগে: লুপের ভেতরে list-এ
in/index= লুকানো O(n²); set/dict দিলে O(n). বারবার sort বা একই হিসাব লুপের বাইরে আনুন। - লুপ C-তে ঠেলুন:
sum,max,any,sorted,join,Counter, NumPy.lru_cacheপুনরাবৃত্ত কাজ বাঁচায়, generator আর__slots__মেমরি। Local নাম, comprehension — ছোট লাভ। - অপেক্ষার কাজে concurrency, হিসাবের কাজে runtime: I/O-bound হলে asyncio/thread; CPU-bound হলে NumPy, multiprocessing, PyPy, Cython, Rust — সবই মাপার পরে। লক্ষ্য পূরণ হলে থামুন, আর AI-এর গতির দাবি নিজে মেপে যাচাই করুন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৪ · Pythonic কোড — PEP 8, idiom আর ruffপরবর্তী পর্বপরিষ্কার, প্রচলিত ধাঁচের Python — যা পড়তে সহজ, আর প্রায়ই দ্রুতও।
- Python প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — ৮টি মডিউল, কোন পর্ব কখন।
- পর্ব ১৭ · set — অনন্য মান ও সেট-গণিতঝালিয়ে নিনhash table আর গড়ে O(1) খোঁজ — এই পর্বের সবচেয়ে বড় লাভের উৎস।
- C++ · পারফরম্যান্সগভীরে যানএকই নীতি, মেশিনের আরও কাছে — cache, কপি, compiler আর profiler.