Skip to content

JIT Compilation & Optimization

Overview

Just-In-Time (JIT) compilation makes Python fast enough for serious computation:

  • PyPy: Alternative Python runtime with tracing JIT
  • Numba: JIT compile numerical Python to machine code (100-1000x speedup!)
  • Cython: Write C-like Python that compiles to C
  • Type specialization: JIT infers types and specializes code
  • Guard-based optimization: Assumes types stay constant

-

Numba: JIT for Numerical Code

Basic Numba Usage

from numba import jit
import time
import numpy as np

# Pure Python (slow)
def sum_loop_python(arr):
 total = 0
 for x in arr:
 total += x
 return total

# Numba JIT (fast!)
@jit
def sum_loop_numba(arr):
 total = 0
 for x in arr:
 total += x
 return total

# Benchmark
arr = np.arange(1000000)

start = time.time()
result_python = sum_loop_python(arr)
python_time = time.time() - start

start = time.time()
result_numba = sum_loop_numba(arr)
numba_time = time.time() - start

print(f"Python: {python_time:.3f}s")
print(f"Numba: {numba_time:.3f}s")
print(f"Speedup: {python_time / numba_time:.0f}x")
# Typical

Numba Type Specification

from numba import jit, float64, int64
import numpy as np

# Specify input/output types for optimization
@jit(float64(float64[:], float64[:]))
def dot_product(a, b):
 """Compute dot product."""
 total = 0.0
 for i in range(len(a)):
 total += a[i] * b[i]
 return total

a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])
result = dot_product(a, b) # 32.0

Numba CUDA for GPU Acceleration

from numba import cuda
import numpy as np

# Compile to GPU
@cuda.jit
def gpu_add(out, a, b):
 """Add two arrays on GPU."""
 idx = cuda.grid(1) # Get thread index
 if idx < out.size:
 out[idx] = a[idx] + b[idx]

# Use it
a = np.array([1, 2, 3, 4, 5], dtype=np.float32)
b = np.array([6, 7, 8, 9, 10], dtype=np.float32)
out = np.empty_like(a)

# Call with block/thread configuration
gpu_add[1, 32](out, a, b)
print(out) # [7. 9. 11. 13. 15.]

PyPy: Alternative Runtime with JIT

Why PyPy is Fast

# PyPy uses tracing JIT:
# 1. Interprets code initially
# 2. Detects hot loops
# 3. JIT compiles hot paths to machine code
# 4. Specializes for observed types

# Example where PyPy excels

def fibonacci(n):
 """Classic recursive computation."""
 if n <= 1:
 return n
 return fibonacci(n-1) + fibonacci(n-2)

# CPython
# PyPy
# Why? JIT optimizes the recursive calls

result = fibonacci(35)
print(result)

Running with PyPy

# Install PyPy (if not already)
# On macOS

# Run Python script with PyPy
pypy3 script.py

# Check if using PyPy
python -c "import sys; print(sys.implementation.name)" # 'cpython'
# With PyPy:
pypy3 -c "import sys; print(sys.implementation.name)" # 'pypy'

How JIT Works: Type Specialization

Type Guards

def add_numbers(a, b):
 """This function can work with different types."""
 return a + b

# CPython (no JIT)
# Calls
# Overhead

# Numba JIT
# First call
# Compiles
# Subsequent calls

# Type change (guards fail):
# add_numbers("hello", "world") → Falls back to Python

Tracing JIT (PyPy)

# PyPy traces hot code paths

def hot_loop():
 """This will be traced and JIT compiled."""
 total = 0
 for i in range(1000000):
 total += i # Hot path (executed 1M times)
 return total

# PyPy:
# 1. Initially interprets (slow)
# 2. Detects hot loop (loop counter)
# 3. Records trace
# 4. Compiles trace to machine code
# 5. Replaces hot path with compiled code
# 6. Result

-

Optimization Strategies

Write JIT-Friendly Code

from numba import jit
import time
import numpy as np

# SLOW
def slow_version():
 arr = np.arange(1000000)
 result = []
 for x in arr:
 if x % 2 == 0:
 result.append(x * x)
 return sum(result)

# FAST
@jit
def fast_version():
 arr = np.arange(1000000)
 result = 0
 for x in arr:
 if x % 2 == 0:
 result += x * x
 return result

# JIT guidelines:
# 1. Simple operations (arithmetic, comparisons)
# 2. Loops over arrays
# 3. Type consistency (no type changes in loop)
# 4. Avoid Python object creation

Avoid JIT Overhead

from numba import jit
import time
import numpy as np

# Overhead
@jit
def jit_function(n):
 return sum(range(n))

# First call (includes compilation)
start = time.time()
result1 = jit_function(10000)
first_call = time.time() - start

# Subsequent calls (uses compiled code)
start = time.time()
for _ in range(1000):
 result = jit_function(10000)
subsequent_calls = time.time() - start

print(f"First call: {first_call*1000:.1f}ms (includes compilation)")
print(f"1000 calls: {subsequent_calls*1000:.1f}ms ({subsequent_calls/1000*1000:.2f}ms each)")

-

Real-World ML Example: Custom Loss Function

Without JIT

import torch
import time

def custom_loss_python(predictions, targets):
 """Custom loss function in pure Python."""
 loss = 0
 for i in range(len(predictions)):
 error = predictions[i] - targets[i]
 if error > 0:
 loss += error ** 2
 else:
 loss += error ** 0.5
 return loss

# Slow for large batches
predictions = torch.randn(10000)
targets = torch.randn(10000)

start = time.time()
for _ in range(100):
 loss = custom_loss_python(predictions.numpy(), targets.numpy())
python_time = time.time() - start
print(f"Python loop: {python_time:.2f}s")

With Numba JIT

from numba import jit
import numpy as np
import torch
import time

@jit
def custom_loss_numba(predictions, targets):
 """Same function but JIT compiled."""
 loss = 0
 for i in range(len(predictions)):
 error = predictions[i] - targets[i]
 if error > 0:
 loss += error ** 2
 else:
 loss += error ** 0.5
 return loss

# Fast JIT version
predictions_np = np.random.randn(10000).astype(np.float64)
targets_np = np.random.randn(10000).astype(np.float64)

start = time.time()
for _ in range(100):
 loss = custom_loss_numba(predictions_np, targets_np)
numba_time = time.time() - start
print(f"Numba JIT: {numba_time:.2f}s")
print(f"Speedup: {python_time / numba_time:.0f}x")

Comparison: JIT Methods

Method Speedup Use Case Ease
Numba @jit 100-1000x Numerical loops Easy
Numba CUDA 1000-10000x GPU acceleration Medium
PyPy 10-100x Long-running processes Hard to integrate
Cython 10-100x Mixed Python/C Medium
NumPy/PyTorch 10-1000x Vector operations Easy

-

When to Use JIT

Use Numba When

CPU-bound loops with numerical operations Need 100-1000x speedup Can't use NumPy/PyTorch Loop runs many times

Don't Use Numba When

Code is already fast (NumPy, PyTorch) Lots of Python object creation Complex logic with many branches Single-execution code

Use PyPy When

Long-running processes (servers, simulations) Pure Python code (no C extensions) Want automatic optimization Can't modify code


Summary: JIT Performance

Execution speed (relative):

CPython: 1x (baseline)
PyPy: 10-100x (tracing JIT)
Numba: 100-1000x (type specialization)
NumPy: 10-100x (vectorization + C)
PyTorch GPU: 1000-10000x (parallel GPUs)
Numba CUDA: 1000-10000x (specialized GPU code)

-

  • 01 Python Bytecode Fundamentals - What gets JIT compiled
  • [04 Profiling & Performance Analysis](/05-py3/09-bytecode-and-execution/(04-profiling-performance-analysis/) - Measuring JIT impact
  • 00 Readme - Memory during JIT execution
  • 00 Readme - Production optimization