Performance: Profiling & Optimization

Python প্রোগ্রামকে দ্রুত করা — আগে মাপুন, পরে অপ্টিমাইজ করুন

Read: ~35 min Hard 5 practice problems Live code runner

1. The First Rule of Optimization

Donald Knuth famously said, "Premature optimization is the root of all evil." The full quote continues: "Yet we should not pass up our opportunities in that critical 3%." In practice that means: measure first, then optimize only the slow 3% that actually matters. Guessing where a program is slow is almost always wrong.

Donald Knuth-এর বিখ্যাত উক্তি: "Premature optimization is the root of all evil" — অপরিণত অপ্টিমাইজেশন সব অনিষ্টের মূল। বাস্তব অর্থ: আগে মাপুন, তারপর যে ৩% অংশ আসলেই ধীর সেটি অপ্টিমাইজ করুন। অনুমান করে বলা "কোথায় ধীর" — প্রায় সব সময় ভুল হয়।

In this module you'll learn the Python tools that turn guessing into measuring: timeit, time.perf_counter, cProfile and tracemalloc. Then we'll apply a four-step optimization playbook on real examples.

2. Measuring Time — timeit and perf_counter

Python provides two reliable ways to measure wall-clock time: the timeit module for short, repeatable snippets, and time.perf_counter() for longer, one-off measurements. Never use time.time() for benchmarks — it is wall-clock and can be adjusted by NTP.

Python-এ সময় মাপার দুটি নির্ভরযোগ্য উপায়: ছোট repeatable কোডের জন্য timeit মডিউল এবং দীর্ঘ one-off পরিমাপের জন্য time.perf_counter()। Benchmark-এ time.time() কখনো ব্যবহার করবেন না — এটি wall-clock, NTP দ্বারা সমন্বিত হতে পারে।
measure.py
import timeit, time

# 1) timeit — repeats the snippet many times, returns total seconds
t = timeit.timeit("sum(range(10_000))", number=1000)
print(f"sum(range): {t*1000:.2f} ms for 1000 runs")

# 2) perf_counter — high-resolution monotonic clock
start = time.perf_counter()
total = 0
for i in range(10_000):
    total += i
elapsed = time.perf_counter() - start
print(f"manual loop: {elapsed*1000:.3f} ms")
The Optimization Playbook 1. Measure timeit / cProfile 2. Find hotspot top-of-profile 3. Change algorithm O(n²) → O(n log n) 4. Re-measure verify gain Figure 31.1 — Performance work is a loop: measure, find, change, re-measure।

3. Finding Hotspots — cProfile

cProfile measures how many times each function was called and how long it took. You get a breakdown sorted by cumulative or total time, which instantly reveals the 3%.

cProfile প্রতিটি ফাংশন কতবার ডাকা হলো এবং কত সময় নিল তার হিসাব দেয়। cumulative বা total সময় অনুযায়ী sort করলেই সেই ৩% hotspot বেরিয়ে আসে।
profile_demo.py
import cProfile, pstats, io

def slow_sum(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

def workload():
    for _ in range(5):
        slow_sum(100_000)

pr = cProfile.Profile()
pr.enable()
workload()
pr.disable()

buf = io.StringIO()
pstats.Stats(pr, stream=buf).sort_stats("cumulative").print_stats(5)
print(buf.getvalue())

4. The Biggest Win Is Almost Always Algorithmic

A smarter algorithm beats micro-optimization every time. Below, replacing repeated list membership (O(n) per lookup) with set membership (O(1)) turns a quadratic loop into a linear one.

ভালো algorithm-ই সবচেয়ে বড় জয়। list-এ in চেক O(n), কিন্তু set-এ in চেক O(1) — এটি quadratic loop-কে linear করে দেয়।
algo_win.py
import time

data = list(range(20_000))
lookup = list(range(0, 20_000, 2))

# Slow: list membership — O(n) each time
t0 = time.perf_counter()
hits = sum(1 for x in data if x in lookup)
print(f"list:  {(time.perf_counter()-t0)*1000:.1f} ms  hits={hits}")

# Fast: set membership — O(1) each time
lookup_set = set(lookup)
t0 = time.perf_counter()
hits = sum(1 for x in data if x in lookup_set)
print(f"set:   {(time.perf_counter()-t0)*1000:.1f} ms  hits={hits}")

5. Built-ins, Comprehensions and functools.lru_cache

Python's built-ins (sum, map, any) are implemented in C and usually beat a hand-written loop. For pure functions with repeated arguments, @lru_cache provides automatic memoization.

Python-এর built-in ফাংশনগুলো (sum, map) C-তে লেখা, হাত দিয়ে লেখা loop-এর চেয়ে দ্রুত। একই argument বারবার এলে @lru_cache দিয়ে স্বয়ংক্রিয় memoization করা যায়।
cache.py
from functools import lru_cache
import time

def fib_slow(n):
    if n < 2: return n
    return fib_slow(n-1) + fib_slow(n-2)

@lru_cache(maxsize=None)
def fib_fast(n):
    if n < 2: return n
    return fib_fast(n-1) + fib_fast(n-2)

t0 = time.perf_counter(); fib_slow(30); slow = time.perf_counter()-t0
t0 = time.perf_counter(); fib_fast(30); fast = time.perf_counter()-t0
print(f"slow {slow*1000:.1f} ms   fast {fast*1000:.3f} ms")
print(f"speedup: {slow/fast:.0f}x")

6. Memory Profiling with tracemalloc

CPU is not the only bottleneck. tracemalloc tracks peak memory and shows the exact source lines that allocated it.

শুধু CPU নয়, memory-ও bottleneck হতে পারে। tracemalloc peak memory ট্র্যাক করে এবং কোন লাইন সেটি allocate করেছে তা দেখায়।
mem.py
import tracemalloc

tracemalloc.start()
big = [i*i for i in range(200_000)]
current, peak = tracemalloc.get_traced_memory()
print(f"current={current/1024:.0f} KiB   peak={peak/1024:.0f} KiB")
tracemalloc.stop()
Generators save memory. Replace [x*x for x in range(N)] with (x*x for x in range(N)) when you only iterate once.

একবারই iterate করলে list comprehension-এর বদলে generator expression ব্যবহার করলে memory অনেক কম লাগে।

7. Vocabulary (শব্দভাণ্ডার)

TermMeaningবাংলায়
HotspotThe small part of code where most time is spent.যে ছোট অংশে বেশিরভাগ সময় ব্যয় হয়।
Big-OAsymptotic growth of running time w.r.t. input size.ইনপুটের সাথে runtime কেমন বাড়ে তার মাপ।
MemoizationCaching function results keyed by arguments.একই argument-এর জন্য ফলাফল cache করা।
cProfilePython's deterministic profiler in the stdlib.Python-এর built-in deterministic profiler।
tracemallocStdlib module to trace memory allocations.Memory allocation ট্র্যাক করার stdlib মডিউল।
VectorizationReplacing loops with array operations (NumPy).Loop-এর বদলে array operation ব্যবহার।

8. Practice Problems

Measure before you change. Each problem asks you to time two versions and compare.

পরিবর্তনের আগে মাপ নিন। প্রতিটি সমস্যায় দুটি সংস্করণের সময় তুলনা করতে বলা হয়েছে।
  1. Use timeit to compare sum(range(100_000)) with a hand-written loop.
    timeit দিয়ে sum(range(100_000)) এবং হাতে-লেখা loop-এর সময় তুলনা করুন।
    ✨ Show Answer (উত্তর দেখুন)
    ans1.py
    import timeit
    a = timeit.timeit("sum(range(100_000))", number=200)
    b = timeit.timeit("""
    t = 0
    for i in range(100_000):
        t += i
    """, number=200)
    print(f"built-in sum : {a*1000:.1f} ms")
    print(f"manual loop  : {b*1000:.1f} ms")
  2. Replace a list-based membership filter with a set-based one and measure.
    List-ভিত্তিক membership filter-কে set-ভিত্তিক করে সময় মাপুন।
    ✨ Show Answer (উত্তর দেখুন)
    ans2.py
    import time
    banned = list(range(0, 5000, 3))
    users  = list(range(5000))
    t = time.perf_counter()
    ok = [u for u in users if u not in banned]
    print(f"list  : {(time.perf_counter()-t)*1000:.1f} ms")
    s = set(banned)
    t = time.perf_counter()
    ok = [u for u in users if u not in s]
    print(f"set   : {(time.perf_counter()-t)*1000:.1f} ms")
  3. Memoize a recursive function of your choice with @lru_cache and show the speedup.
    একটি recursive ফাংশনকে @lru_cache দিয়ে memoize করে speedup দেখান।
    ✨ Show Answer (উত্তর দেখুন)
    ans3.py
    from functools import lru_cache
    @lru_cache(maxsize=None)
    def paths(r, c):
        if r == 0 or c == 0: return 1
        return paths(r-1, c) + paths(r, c-1)
    print(paths(15, 15))
  4. Why should you not use time.time() for benchmarking? Answer in two sentences.
    Benchmark-এ time.time() কেন ব্যবহার করবেন না — দুই বাক্যে বলুন।
    ✨ Show Answer (উত্তর দেখুন)

    Answer: time.time() returns wall-clock time which can be adjusted by the OS (NTP, DST), so a benchmark could even read negative. time.perf_counter() is monotonic and has the highest resolution the platform offers.

    time.time() wall-clock দেয় যা OS (NTP/DST) সমন্বয় করতে পারে — benchmark negative-ও হতে পারে। time.perf_counter() monotonic ও সর্বোচ্চ রেজোলিউশনের।

  5. Use tracemalloc to measure peak memory of creating a list of 500,000 ints, and compare with a generator.
    tracemalloc দিয়ে ৫,০০,০০০ int-এর list ও generator-এর peak memory তুলনা করুন।
    ✨ Show Answer (উত্তর দেখুন)
    ans5.py
    import tracemalloc
    
    tracemalloc.start()
    data = [i for i in range(500_000)]
    _, peak_list = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    
    tracemalloc.start()
    total = sum(i for i in range(500_000))
    _, peak_gen = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    
    print(f"list peak  : {peak_list/1024:.0f} KiB")
    print(f"gen  peak  : {peak_gen/1024:.0f} KiB")

Summary — Module 31

Measure first with timeit, perf_counter, cProfile and tracemalloc. The biggest wins are almost always algorithmic (O(n²) → O(n log n), list → set, recursion → memoization). Reach for Python built-ins, comprehensions, and generators. Only after all of that should you consider C extensions, Cython, Numba, or PyPy.

আগে মাপুন — তারপর পরিবর্তন। সবচেয়ে বড় জয় সাধারণত algorithmic। list-কে set করা, recursion-এ memoization, built-in ফাংশন ব্যবহার — এগুলো বেশিরভাগ সময় যথেষ্ট। C-extension, Cython, Numba — এগুলো একদম শেষে।

Next Module → Concurrency: threading, multiprocessing ও GIL — একাধিক কাজ একসাথে চালানো।