Profiling, Optimization, and Native C/Rust Interoperability
Profiling, Optimization, and Native C/Rust Interoperability
After reading this chapter, you will master performance diagnostics using cProfile and pstats, track heap allocations and memory spikes with tracemalloc, interface with native C libraries using ctypes, and architect high-speed compiled extensions with PyO3 and Rust.
Mental model
Performance engineering follows an evidence-based feedback loop: never optimize without profiling data.
Performance Engineering Pipeline:
[ Macro Measurement ] ──▶ time.perf_counter() ──▶ Identifies slow high-level subsystems
│
▼
[ Deterministic Profiling ] ──▶ cProfile / pstats ──▶ Identifies exact hotspot functions
│
▼
[ Memory Profiling ] ──▶ tracemalloc ──▶ Locates exact lines allocating heap memory
│
▼
[ Optimization Paths ]:
1. Algorithmic Fix (O(N^2) -> O(N log N) using sets/dicts)
2. Built-in C-Accelerators (itertools, collections.deque)
3. Native FFI Binding (ctypes or PyO3 Rust extension)
Minimal example
Save as profiling_and_ctypes.py:
# profiling_and_ctypes.py
import cProfile
import ctypes
import pstats
import tracemalloc
def cpu_heavy_function() -> list[int]:
"""Function with an intentional list-growing bottleneck."""
data = []
for i in range(200_000):
data.append(i * 2)
return data
def demonstrate_profiling() -> None:
# 1. Deterministic Profiling with cProfile
profiler = cProfile.Profile()
profiler.enable()
cpu_heavy_function()
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("tottime")
print("--- Top Profiler Entries ---")
stats.print_stats(3)
def demonstrate_ctypes() -> None:
# 2. Native C interop via ctypes (Calling libc directly)
libc = ctypes.CDLL(None) # Accesses standard C library
print("\n--- Native C Interop with ctypes ---")
# Call standard C 'puts' function
libc.puts(b"Hello from native C library puts()!")
def main() -> None:
demonstrate_profiling()
demonstrate_ctypes()
if __name__ == "__main__":
main()Run via uv run python profiling_and_ctypes.py:
--- Top Profiler Entries ---
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.012 0.012 0.012 0.012 ...:cpu_heavy_function
...
--- Native C Interop with ctypes ---
Hello from native C library puts()!
Worked examples
Case 1: Locating Memory Leaks with tracemalloc
tracemalloc tracks every memory block allocated by CPython, recording the exact Python source file and line number:
# memory_leak_tracer.py
import tracemalloc
def simulate_memory_allocation() -> list[str]:
# Allocate 100,000 strings
return [f"cluster_telemetry_event_node_{i}" for i in range(100_000)]
def main() -> None:
# Start tracking memory allocations
tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
data = simulate_memory_allocation()
snapshot_after = tracemalloc.take_snapshot()
# Compare snapshots to identify top allocating lines
top_diffs = snapshot_after.compare_to(snapshot_before, "lineno")
print("--- Top Memory Allocating Lines ---")
for stat in top_diffs[:3]:
print(f" {stat}")
current, peak = tracemalloc.get_traced_memory()
print(f"\nCurrent memory: {current / (1024*1024):.2f} MB | Peak memory: {peak / (1024*1024):.2f} MB")
tracemalloc.stop()
if __name__ == "__main__":
main()Run:
uv run python memory_leak_tracer.pyOutput:
--- Top Memory Allocating Lines ---
...:simulate_memory_allocation: size=7524 KiB (+7524 KiB), count=100000 (+100000)
Current memory: 7.37 MB | Peak memory: 7.37 MB
Case 2: Calling Native C Functions with ctypes.Structure
You can define C-compatible memory structs in Python and pass them directly across native library boundaries:
# c_struct_ffi.py
import ctypes
class Point2D(ctypes.Structure):
_fields_ = [
("x", ctypes.c_double),
("y", ctypes.c_double),
]
def main() -> None:
pt = Point2D(10.5, 20.25)
print(f"Python C-compatible struct: x={pt.x}, y={pt.y}")
print(f"Size of struct in C memory: {ctypes.sizeof(pt)} bytes (2 x 8-byte doubles)")
if __name__ == "__main__":
main()Run:
uv run python c_struct_ffi.pyOutput:
Python C-compatible struct: x=10.5, y=20.25
Size of struct in C memory: 16 bytes (2 x 8-byte doubles)
Case 3: Modern High-Performance Extensions: PyO3 and Rust
When pure Python algorithms cannot meet latency budgets (e.g. cryptography, parsers, simulation), modern Python projects use PyO3 to author extensions in Rust.
Benefits of PyO3 over legacy C-extensions: - Memory safety guarantees without segmentation faults. - Seamless release of the GIL using Python::allow_threads. - Zero-cost conversion between Python types and Rust standard types.
Pitfalls
Pitfall 1: Premature Micro-Optimization
Bikeshedding string concatenations or swapping operators before profiling is wasted effort. In 95% of applications, performance bottlenecks are concentrated in I/O wait times or poorly chosen algorithmic complexity (\(O(N^2)\) lookups in lists).
Pitfall 2: Memory Hazards in ctypes
ctypes bypasses Python’s memory protections. Passing an incorrect pointer, writing past an array boundary, or calling a function with the wrong argument types will immediately trigger a hard crash (Segmentation Fault) that terminates the entire Python process.
Exercises
- Profile an algorithm that searches for items in a
listvs asetusingcProfileand compare their execution profiles. - Use
tracemallocto identify how much RAM is consumed by 50,000 instances of a standard class versus a class using__slots__. - Call the C standard library
qsortfunction usingctypesto sort an array of C integers. - Export profiling results to a
.proffile usingcProfile.run(..., filename="out.prof")and inspect it usingpstats.
Further reading
- Python Standard Library:
cProfile,pstats,tracemalloc, andctypesmodules. - PyO3 Project: Rust bindings for the Python interpreter.
- PEP 659: Zero-cost exception handling.