python

examples

examples.pyšŸ
"""
Python Performance & Optimization - Practical Examples
This file demonstrates profiling, timing, and optimization techniques
"""

import time
import timeit
import functools
from collections import deque
from typing import List, Any
import sys

# ============================================================
# 1. TIMING TECHNIQUES
# ============================================================

print("=" * 60)
print("1. TIMING TECHNIQUES")
print("=" * 60)

# Using time module
def simple_timing():
    """Demonstrate basic timing with time module."""
    # time.time() - wall clock time
    start = time.time()
    result = sum(range(100000))
    end = time.time()
    print(f"time.time(): {end - start:.6f} seconds")
    
    # time.perf_counter() - high precision timer
    start = time.perf_counter()
    result = sum(range(100000))
    end = time.perf_counter()
    print(f"time.perf_counter(): {end - start:.6f} seconds")
    
    # time.process_time() - CPU time only
    start = time.process_time()
    result = sum(range(100000))
    end = time.process_time()
    print(f"time.process_time(): {end - start:.6f} seconds")

simple_timing()

# Timer context manager
from contextlib import contextmanager

@contextmanager
def timer(name="Operation"):
    """Context manager for timing code blocks."""
    start = time.perf_counter()
    yield
    elapsed = time.perf_counter() - start
    print(f"{name}: {elapsed:.6f} seconds")

# Using the timer
with timer("Sum of range"):
    result = sum(range(100000))

with timer("List comprehension"):
    result = [x ** 2 for x in range(10000)]


# ============================================================
# 2. TIMEIT FOR BENCHMARKING
# ============================================================

print("\n" + "=" * 60)
print("2. TIMEIT BENCHMARKING")
print("=" * 60)

# Compare different approaches
print("Comparing string concatenation methods:")

# Method 1: String concatenation with +
time1 = timeit.timeit(
    'result = ""; [result := result + str(i) for i in range(100)]',
    number=1000
)
print(f"  + concatenation: {time1:.4f}s")

# Method 2: Join method
time2 = timeit.timeit(
    '"".join(str(i) for i in range(100))',
    number=1000
)
print(f"  join() method: {time2:.4f}s")

# Method 3: List then join
time3 = timeit.timeit(
    '"".join([str(i) for i in range(100)])',
    number=1000
)
print(f"  list + join(): {time3:.4f}s")

print(f"\njoin() is {time1/time2:.1f}x faster than + concatenation")


# Compare list operations
print("\nComparing list operations:")

setup = "data = list(range(10000))"

tests = [
    ("x in list", "5000 in data"),
    ("list.count()", "data.count(5000)"),
    ("list.index()", "data.index(5000)"),
]

for name, stmt in tests:
    t = timeit.timeit(stmt, setup, number=1000)
    print(f"  {name}: {t:.4f}s")


# ============================================================
# 3. LIST VS SET VS DICT PERFORMANCE
# ============================================================

print("\n" + "=" * 60)
print("3. DATA STRUCTURE PERFORMANCE")
print("=" * 60)

# Membership testing comparison
n = 10000
test_list = list(range(n))
test_set = set(range(n))
test_dict = {i: None for i in range(n)}

print("Membership testing (item in collection):")

# List - O(n)
time_list = timeit.timeit(
    f'{n//2} in test_list',
    globals={'test_list': test_list},
    number=1000
)
print(f"  List: {time_list:.6f}s")

# Set - O(1)
time_set = timeit.timeit(
    f'{n//2} in test_set',
    globals={'test_set': test_set},
    number=1000
)
print(f"  Set:  {time_set:.6f}s")

# Dict - O(1)
time_dict = timeit.timeit(
    f'{n//2} in test_dict',
    globals={'test_dict': test_dict},
    number=1000
)
print(f"  Dict: {time_dict:.6f}s")

print(f"\nSet is {time_list/time_set:.0f}x faster than list for membership!")


# ============================================================
# 4. CACHING WITH LRU_CACHE
# ============================================================

print("\n" + "=" * 60)
print("4. CACHING WITH LRU_CACHE")
print("=" * 60)

# Fibonacci without cache (exponential time)
def fib_slow(n):
    if n < 2:
        return n
    return fib_slow(n - 1) + fib_slow(n - 2)

# Fibonacci with cache (linear time)
@functools.lru_cache(maxsize=None)
def fib_fast(n):
    if n < 2:
        return n
    return fib_fast(n - 1) + fib_fast(n - 2)

print("Fibonacci comparison:")

# Time without cache (limit n to keep it reasonable)
start = time.perf_counter()
result1 = fib_slow(30)
time1 = time.perf_counter() - start
print(f"  Without cache (n=30): {time1:.4f}s")

# Time with cache
start = time.perf_counter()
result2 = fib_fast(30)
time2 = time.perf_counter() - start
print(f"  With cache (n=30): {time2:.6f}s")

# Can now compute much larger values
start = time.perf_counter()
result3 = fib_fast(500)
time3 = time.perf_counter() - start
print(f"  With cache (n=500): {time3:.6f}s")

print(f"\nCache info: {fib_fast.cache_info()}")


# Custom memoization decorator
def memoize(func):
    """Simple memoization decorator."""
    cache = {}
    
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    wrapper.cache = cache
    wrapper.clear_cache = cache.clear
    return wrapper

@memoize
def expensive_calculation(x, y):
    """Simulated expensive calculation."""
    time.sleep(0.01)  # Simulate work
    return x * y + x ** 2

print("\nCustom memoize demo:")
with timer("First call"):
    result = expensive_calculation(10, 20)
with timer("Cached call"):
    result = expensive_calculation(10, 20)


# ============================================================
# 5. GENERATOR VS LIST PERFORMANCE
# ============================================================

print("\n" + "=" * 60)
print("5. GENERATORS VS LISTS")
print("=" * 60)

import sys

# Memory comparison
n = 1000000

# List comprehension - stores all in memory
list_comp = [x ** 2 for x in range(n)]
list_size = sys.getsizeof(list_comp)

# Generator expression - lazy evaluation
gen_exp = (x ** 2 for x in range(n))
gen_size = sys.getsizeof(gen_exp)

print("Memory usage:")
print(f"  List (1M items): {list_size:,} bytes ({list_size / 1024 / 1024:.2f} MB)")
print(f"  Generator: {gen_size:,} bytes")
print(f"  Memory saved: {(list_size - gen_size) / 1024 / 1024:.2f} MB")

del list_comp  # Free memory

# Performance for iteration
print("\nSum of squares (1M items):")

time_list = timeit.timeit(
    'sum([x ** 2 for x in range(100000)])',
    number=10
)
print(f"  List comprehension: {time_list:.4f}s")

time_gen = timeit.timeit(
    'sum(x ** 2 for x in range(100000))',
    number=10
)
print(f"  Generator expression: {time_gen:.4f}s")


# ============================================================
# 6. LOOP OPTIMIZATION
# ============================================================

print("\n" + "=" * 60)
print("6. LOOP OPTIMIZATION")
print("=" * 60)

# Test data
data = list(range(100000))

# Method 1: For loop with append
def loop_append():
    result = []
    for x in data:
        result.append(x ** 2)
    return result

# Method 2: List comprehension
def list_comp():
    return [x ** 2 for x in data]

# Method 3: Map function
def map_func():
    return list(map(lambda x: x ** 2, data))

# Method 4: Local variable optimization
def local_append():
    result = []
    append = result.append  # Local reference
    for x in data:
        append(x ** 2)
    return result

print("Loop optimization comparison:")
for name, func in [
    ("For loop + append", loop_append),
    ("List comprehension", list_comp),
    ("Map function", map_func),
    ("Local append ref", local_append),
]:
    t = timeit.timeit(func, number=100)
    print(f"  {name}: {t:.4f}s")


# ============================================================
# 7. DEQUE VS LIST FOR QUEUES
# ============================================================

print("\n" + "=" * 60)
print("7. DEQUE VS LIST FOR QUEUES")
print("=" * 60)

# Using list as queue (slow for popleft)
def queue_with_list(n):
    queue = []
    for i in range(n):
        queue.append(i)
    while queue:
        queue.pop(0)  # O(n) operation!

# Using deque (fast for both ends)
def queue_with_deque(n):
    queue = deque()
    for i in range(n):
        queue.append(i)
    while queue:
        queue.popleft()  # O(1) operation

print("Queue operations (10000 items):")

t_list = timeit.timeit(lambda: queue_with_list(10000), number=10)
print(f"  List as queue: {t_list:.4f}s")

t_deque = timeit.timeit(lambda: queue_with_deque(10000), number=10)
print(f"  Deque: {t_deque:.4f}s")

print(f"\nDeque is {t_list/t_deque:.1f}x faster!")


# ============================================================
# 8. STRING OPERATIONS
# ============================================================

print("\n" + "=" * 60)
print("8. STRING OPERATIONS")
print("=" * 60)

# String concatenation methods
strings = ["word"] * 1000

# Method 1: + operator (bad for many strings)
def concat_plus():
    result = ""
    for s in strings:
        result += s
    return result

# Method 2: join (optimal)
def concat_join():
    return "".join(strings)

# Method 3: io.StringIO
import io
def concat_stringio():
    buffer = io.StringIO()
    for s in strings:
        buffer.write(s)
    return buffer.getvalue()

print("String concatenation (1000 strings):")
for name, func in [
    ("+ operator", concat_plus),
    ("join()", concat_join),
    ("StringIO", concat_stringio),
]:
    t = timeit.timeit(func, number=1000)
    print(f"  {name}: {t:.4f}s")


# F-strings vs format vs %
print("\nString formatting comparison:")

name = "Alice"
age = 30

t1 = timeit.timeit(
    'f"{name} is {age} years old"',
    globals={'name': name, 'age': age},
    number=100000
)
print(f"  f-string: {t1:.4f}s")

t2 = timeit.timeit(
    '"{} is {} years old".format(name, age)',
    globals={'name': name, 'age': age},
    number=100000
)
print(f"  .format(): {t2:.4f}s")

t3 = timeit.timeit(
    '"%s is %d years old" % (name, age)',
    globals={'name': name, 'age': age},
    number=100000
)
print(f"  % operator: {t3:.4f}s")


# ============================================================
# 9. BUILT-IN FUNCTIONS
# ============================================================

print("\n" + "=" * 60)
print("9. BUILT-IN FUNCTIONS PERFORMANCE")
print("=" * 60)

data = list(range(100000))

# sum() vs manual loop
def manual_sum():
    total = 0
    for x in data:
        total += x
    return total

t_manual = timeit.timeit(manual_sum, number=100)
t_builtin = timeit.timeit(lambda: sum(data), number=100)

print("Sum comparison:")
print(f"  Manual loop: {t_manual:.4f}s")
print(f"  Built-in sum(): {t_builtin:.4f}s")
print(f"  Built-in is {t_manual/t_builtin:.1f}x faster")

# any() vs manual check
def manual_any():
    for x in data:
        if x > 50000:
            return True
    return False

t_manual = timeit.timeit(manual_any, number=100)
t_builtin = timeit.timeit(lambda: any(x > 50000 for x in data), number=100)

print("\nAny check (first match):")
print(f"  Manual loop: {t_manual:.4f}s")
print(f"  Built-in any(): {t_builtin:.4f}s")


# ============================================================
# 10. SLOTS FOR MEMORY OPTIMIZATION
# ============================================================

print("\n" + "=" * 60)
print("10. __SLOTS__ FOR MEMORY")
print("=" * 60)

class PointNoSlots:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class PointWithSlots:
    __slots__ = ['x', 'y']
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

# Memory comparison
p1 = PointNoSlots(1, 2)
p2 = PointWithSlots(1, 2)

# Note: sys.getsizeof doesn't count the __dict__
size1 = sys.getsizeof(p1) + sys.getsizeof(p1.__dict__)
size2 = sys.getsizeof(p2)

print(f"Point without __slots__: {size1} bytes")
print(f"Point with __slots__: {size2} bytes")
print(f"Memory saved: {size1 - size2} bytes per instance")

# Performance for creation
t1 = timeit.timeit(
    'PointNoSlots(1, 2)',
    globals={'PointNoSlots': PointNoSlots},
    number=100000
)
t2 = timeit.timeit(
    'PointWithSlots(1, 2)',
    globals={'PointWithSlots': PointWithSlots},
    number=100000
)

print(f"\nCreation time (100K instances):")
print(f"  Without slots: {t1:.4f}s")
print(f"  With slots: {t2:.4f}s")


# ============================================================
# 11. PROFILING EXAMPLE
# ============================================================

print("\n" + "=" * 60)
print("11. PROFILING EXAMPLE")
print("=" * 60)

import cProfile
import pstats
import io

def slow_function():
    """Function with intentional inefficiencies."""
    result = []
    for i in range(1000):
        # Slow string concatenation
        s = ""
        for j in range(100):
            s += str(j)
        result.append(s)
    return result

def fast_function():
    """Optimized version."""
    result = []
    for i in range(1000):
        # Fast join
        s = "".join(str(j) for j in range(100))
        result.append(s)
    return result

# Profile the slow function
print("Profiling slow_function:")
profiler = cProfile.Profile()
profiler.enable()
slow_function()
profiler.disable()

# Print stats
stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream)
stats.sort_stats('cumulative')
stats.print_stats(5)
print(stream.getvalue())


# Compare timing
print("Timing comparison:")
t_slow = timeit.timeit(slow_function, number=10)
t_fast = timeit.timeit(fast_function, number=10)
print(f"  Slow function: {t_slow:.4f}s")
print(f"  Fast function: {t_fast:.4f}s")
print(f"  Speedup: {t_slow/t_fast:.1f}x")


# ============================================================
# 12. PRACTICAL OPTIMIZATION EXAMPLE
# ============================================================

print("\n" + "=" * 60)
print("12. PRACTICAL OPTIMIZATION")
print("=" * 60)

# Problem: Find all duplicate items in a list

# Naive approach - O(n²)
def find_duplicates_naive(items):
    duplicates = []
    for i, item in enumerate(items):
        if item in items[i+1:] and item not in duplicates:
            duplicates.append(item)
    return duplicates

# Better approach - O(n)
def find_duplicates_optimized(items):
    seen = set()
    duplicates = set()
    for item in items:
        if item in seen:
            duplicates.add(item)
        seen.add(item)
    return list(duplicates)

# Counter approach - O(n)
from collections import Counter
def find_duplicates_counter(items):
    counts = Counter(items)
    return [item for item, count in counts.items() if count > 1]

# Test data
test_data = list(range(5000)) + list(range(100))  # Some duplicates

print("Finding duplicates in 5100 items:")
for name, func in [
    ("Naive O(n²)", find_duplicates_naive),
    ("Optimized O(n)", find_duplicates_optimized),
    ("Counter O(n)", find_duplicates_counter),
]:
    t = timeit.timeit(lambda: func(test_data), number=10)
    print(f"  {name}: {t:.4f}s")


# ============================================================
# SUMMARY
# ============================================================

print("\n" + "=" * 60)
print("PERFORMANCE OPTIMIZATION SUMMARY")
print("=" * 60)

print("""
Key Optimization Techniques Demonstrated:
āœ“ Time measurement (time, timeit)
āœ“ Data structure selection (list vs set vs dict)
āœ“ Caching with lru_cache
āœ“ Generators for memory efficiency
āœ“ Loop optimization (comprehensions, local refs)
āœ“ Deque for queue operations
āœ“ String concatenation (join vs +)
āœ“ Built-in functions (sum, any, all)
āœ“ __slots__ for memory savings
āœ“ Profiling with cProfile

Remember:
1. Always measure before optimizing
2. Optimize the hot path first
3. Choose appropriate data structures
4. Use built-in functions when possible
5. Consider memory vs. speed trade-offs
""")
Examples - Python Tutorial | DeepML