This page is an ipython notebook, meaning you can download it and run the executable cells. It directly follows Activity 7.2. Reference answers appear after the questions, challenges, predictions, and reflections. They show one reasonable solution or interpretation, but other correct approaches may exist.
Profiling, Bottlenecks, Parallelism & Numba¶
Tools: Python, NumPy, pandas, pytest, cProfile, optional line_profiler, optional Numba, Jupyter/VS Code
Learning objectives¶
By the end of this tutorial, you should be able to:
benchmark Python code;
distinguish noisy one-off timings from repeated benchmarks;
identify algorithmic bottlenecks;
use
cProfile;optionally use line-level profiling;
inspect memory consumption;
compare loop and vectorized implementations;
reason about vectorization vs. memory tradeoffs;
benchmark chunked processing;
understand parallel overhead;
accelerate appropriate loops with Numba;
separate JIT compilation time from steady-state timing;
validate optimized code against tests.
and:text src/week14_performance.py
Import:python from time import perf_counter
import numpy as np import pandas as pd
Create:python rng = np.random.default_rng( 556 )
Before optimizing anything, run your Week 13 test suite:bash pytest
------------------------------------------------------------------------
## Codespaces performance note
All performance comparisons in this activity should be made **within your own Codespace**. Do not interpret differences between students' absolute timings as differences in code quality. Cloud machine size and system load may differ.
For multiprocessing exercises, first inspect the CPU resources visible to your environment:python import os
os.cpu_count()
Parallel code is not expected to be faster for every workload or every Codespace. Measuring the overhead is part of the lesson.python x = rng.normal( size=1_000_000 )
Time:python start = perf_counter()
result = np.sum( x ** 2 )
elapsed = ( perf_counter() - start )
elapsed
Repeat manually several times.
### Question
Are all timings identical?
Why not?python
x ** 2)
Compare with your one-off timings.Reflection¶
Why is repeated benchmarking usually more informative?
Reference answer¶
A single timing is noisy because of scheduling, caches, background work, and warm-up. %timeit repeats the operation and summarizes repeated measurements.
python def sum_squares_python( x ): total = 0.0
for value in x:
total += (
value
* value
)
return total
Compare with:python def sum_squares_numpy( x ): return np.sum( x ** 2 )
Verify:python np.isclose( sum_squares_python(x), sum_squares_numpy(x) )
Then benchmark both.
------------------------------------------------------------------------Reference answer¶
Repeated benchmarking reduces the influence of one anomalous run. NumPy usually beats an equivalent Python loop because its inner work executes in optimized compiled code.
python total += value
instead of:python total += value * value
The broken version may still be fast.
### Question
Why must equivalent output be verified before benchmarking
implementations?
Restore the correct code.text n = 1,000 10,000 100,000 1,000,000
Store:text n python_time numpy_time speedup
in a DataFrame.Reflection¶
Does the relative advantage stay constant as n changes?
Reference answer¶
Verify equivalent outputs before timing; otherwise the faster implementation may simply be computing something different.
python values = list( range( 100_000 ) )
lookup_values = list( range( 50_000, 150_000 ) )
Write:python def count_matches_list( values, lookup_values ): count = 0
for value in values:
if value in lookup_values:
count += 1
return count
Benchmark.Reference answer¶
Relative speedups can change with input size because fixed overhead and algorithmic scaling contribute differently as n grows.
python def count_matches_set( values, lookup_values ): lookup = set( lookup_values )
count = 0
for value in values:
if value in lookup:
count += 1
return count
Verify identical output.
Benchmark.
### Question
Was the improvement caused by syntax or algorithm/data structure?python def slow_transform( x ): output = []
for value in x:
output.append(
np.sqrt(
value ** 2
+ 1
)
)
return np.array(
output
)def summarize( x ): transformed = ( slow_transform( x ) )
return {
"mean": transformed.mean(),
"sd": transformed.std(),
"max": transformed.max()
}def main(): rng = np.random.default_rng( 556 )
x = rng.normal(
size=500_000
)
print(
summarize(x)
)if name == “main”: main()
Reference answer¶
Profile the entire workflow to discover where time is actually spent, then optimize measured hotspots rather than guessed ones.
bash
python -m cProfile
src/week14_performance.py
Inspect the output.
Identify:text which function has highest cumulative time? which has highest direct time? how many calls were made?
bash
python -m cProfile
-s cumulative
src/week14_performance.py
### Question
Why is sorting by cumulative time useful when investigating a call
chain?Part 11 --- Profile only the hotspot¶
Once slow_transform() is identified, focus your attention there.
Reflection¶
Why would optimizing summarize() first be a poor use of time if almost
all runtime occurs inside slow_transform()?
Reference answer¶
Cumulative-time sorting identifies functions responsible for the most total work, including work in callees. Optimizing a tiny non-hotspot function gives little end-to-end benefit.
python
Import the function, then run:python
-f slow_transform \
slow_transform(x)
Identify the line consuming most runtime.
If `line_profiler` is unavailable, use this as a conceptual exercise
rather than installing blindly.python def fast_transform( x ): return np.sqrt( x ** 2 + 1 )
Check:python slow = slow_transform( x[:1000] )
fast = fast_transform( x[:1000] )
np.allclose( slow, fast )
Benchmark both on a large array.Part 14 --- Reprofile¶
Replace the slow implementation in the workflow.
Run cProfile again.
Questions¶
What is now the bottleneck?
How did total runtime change?
Why must profiling be repeated after optimization?
Reference answer¶
After optimization, profile again because the bottleneck can move. Compare new total runtime as well as the new hotspot.
python x = rng.normal( size=10_000_000 )
Inspect:python x.nbytes
Convert to MB:python x.nbytes / ( 1024 ** 2 )
### Question
How much memory do 10 million `float64` values require?python df = pd.DataFrame({ “id”: np.arange( 1_000_000 ), “group”: rng.choice( [ “A”, “B”, “C” ], size=1_000_000 ), “score”: rng.normal( size=1_000_000 ) })
Inspect:python df.memory_usage( deep=True )
and:python df.memory_usage( deep=True ).sum()
Reference answer¶
Ten million float64 values require about 80,000,000 bytes—roughly 80 MB (about 76.3 MiB)—before container/object overhead.
python df[“group”] = ( df[“group”] .astype( “category” ) )
Recheck memory.
### Reflection
Why can categorical data use less memory than repeated Python strings?python df_copy = df.copy()
Estimate the additional memory footprint.
### Question
When is copying worth the memory cost?
Connect this to Week 2's mutation/reference material.Reference answer¶
Categorical data can store repeated labels as compact integer codes plus one copy of each category, using less memory than many repeated Python strings.
python large_subset = df.copy()
with:python small_subset = df[ [ “group”, “score” ] ].copy()
Compare memory usage.
### Principle
Move and retain only the data needed for the task.Reference answer¶
Selecting only necessary columns reduces memory, copying, serialization, and downstream computation.
python for chunk in pd.read_csv( path, chunksize=100_000 ): ...
Calculate the sum/count required for an overall mean without loading the
entire file at once.
### Challenge
Maintain:text running_sum running_count
then calculate:text running_sum / running_count
Reference answer¶
Chunking lowers peak memory by processing smaller pieces, at the cost of extra I/O/coordination and sometimes slower execution.
python pd.read_csv( path )[“score”].mean()
Use:python np.isclose(...)
### Question
What resource tradeoff does chunking make?Reference answer¶
The chunked result should agree with the full-data result within numerical tolerance before interpreting performance.
python def simulation_scenario( seed ): rng = np.random.default_rng( seed )
return rng.normal(
size=1_000_000
).mean()
Each seed defines an independent task.
### Question
Why is this a good candidate for parallel execution?Reference answer¶
Chunking trades lower peak memory for additional I/O/loop overhead and more complex aggregation logic.
Part 23 --- Serial baseline¶
Create several independent seeds.
Run each scenario serially.
Time total execution.
Store results.
This is your baseline.
Reference answer¶
Independent tasks with little shared state are good parallel candidates because workers need little synchronization.
python from concurrent.futures import ( ProcessPoolExecutor )
Conceptually:python with ProcessPoolExecutor() as executor: results = list( executor.map( simulation_scenario, seeds ) )
Benchmark.
### Reflection
Did parallelism help?
If not, what overhead might explain the result?text small per-task workload large per-task workload
### Question
At what point does parallel overhead become less important?Reference answer¶
Parallel execution can be slower for small tasks because startup, serialization, scheduling, and communication overhead exceed useful work.
python np.random.default_rng( 556 )
inside every worker with the same seed.
Instead, pass distinct reproducible seeds.
### Question
What statistical error could identical random streams create?Reference answer¶
Parallel overhead becomes less important as each task contains more computation relative to setup/communication.
python import numba
numba.version
If Numba is not installed, the remaining Numba sections may be treated
as optional.
The goal is to understand when JIT compilation helps.Reference answer¶
Identical random streams across workers can duplicate simulated samples, understate Monte Carlo variability, and violate independence assumptions. Use independent streams.
python def sum_squares_loop( x ): total = 0.0
for value in x:
total += (
value
* value
)
return total
Benchmark.python from numba import njit
Create:python njit def sum_squares_numba( x ): total = 0.0
for value in x:
total += (
value
* value
)
return total
Call once:python sum_squares_numba( x )
Then benchmark subsequent calls.text first Numba call
and:text second Numba call
separately.
### Reflection
Why should the first call not be directly compared with steady-state
NumPy execution?text pure Python loop NumPy vectorized Numba-compiled loop
Verify all results are approximately equal.
Create a table:text method runtime speedup vs Python
------------------------------------------------------------------------Reference answer¶
The first Numba call includes compilation, so compare steady-state performance only after compilation. Verify Python/NumPy/Numba outputs agree first.
python A @ B
with any hand-written loop version.
### Question
Why might Numba wrapping add little to an operation already implemented
through optimized numerical libraries?Reference answer¶
NumPy matrix multiplication already uses highly optimized compiled libraries, so wrapping it in Numba often adds little.
Part 33 --- Numba-friendly algorithm challenge¶
Create a numerical loop that is less convenient to vectorize.
Example: a simple recurrence.
python def recurrence( x ): output = np.empty_like( x )
output[0] = x[0]
for i in range(
1,
len(x)
):
output[i] = (
0.9
* output[i - 1]
+ x[i]
)
return output
Compile with Numba and compare.
### Question
Why is this a better Numba candidate than simple element-wise squaring?Reference answer¶
Numba is most useful for numeric loops that are awkward to express as a single optimized NumPy operation; simple element-wise squaring is already excellent in NumPy.
python from numba import ( njit, prange )
Use:python njit( parallel=True ) def ...
with an independent loop.
Compare serial-JIT and parallel-JIT performance.
### Important
Verify correctness before interpreting speed.Reference answer¶
Parallel Numba helps when iterations are independent and each does enough work to amortize parallel overhead.
bash pytest
### Question
Why is Week 13's test suite essential when replacing a simple
implementation with a faster one?Reference answer¶
A performance regression test checks that code remains correct and does not become unexpectedly slower after future changes.
text bootstrap permutation test Monte Carlo integration gradient descent distance calculation
Complete:
1. Run tests / create correctness checks.
2. Benchmark a representative workload.
3. Profile it.
4. Identify the hotspot.
5. Make exactly one major performance improvement.
6. Re-run correctness tests.
7. Rebenchmark.
8. Calculate speedup.
9. Discuss memory implications.
10. Explain whether the added complexity is worth it.Reference answer¶
Tests are essential during optimization because implementation changes can easily alter statistical behavior even while improving speed.
text 90 seconds
of a:text 100-second
program occurs inside one function.
If you make that function 10 times faster:text 90 sec → 9 sec
What is the new total runtime?
What is the total speedup?
### Reflection
Why is hotspot identification so powerful?Reference answer¶
Amdahl’s law limits total speedup by the fraction of runtime left unoptimized. Making a small component infinitely fast cannot produce a large total improvement.
text What is the representative input size? What was the baseline runtime? How was runtime measured? What did profiling identify? What optimization was applied? Did memory usage change? Did tests still pass? What is the new runtime? What is the speedup? What new complexity was introduced? Would you keep the optimized version?
Reference answer¶
A performance audit should record representative input, environment, baseline runtime/memory, hotspot, optimization, new runtime/memory, correctness checks, and complexity tradeoffs.
bash pytest
Then:bash git status git add . git commit -m “Complete Week 14 profiling and performance exercises” git push
Part 40 --- Final reflection¶
1. Profiling¶
Why should profiling occur before optimization?
2. Benchmarking¶
Why are repeated representative benchmarks preferable to one timing?
3. Complexity¶
Why can changing an algorithm or data structure outperform micro-optimization?
4. Vectorization¶
Why is vectorization often faster?
5. Memory¶
Why can a fast vectorized method still be inappropriate?
6. Parallelism¶
When can parallel computation be slower than serial computation?
7. RNG¶
Why do parallel simulations need independent random streams?
8. Numba¶
What does JIT compilation do?
9. Compilation overhead¶
Why separate first-call and steady-state Numba timing?
10. Software engineering¶
Why must performance optimization be followed by correctness testing?
Completion checklist¶
Created Week 14 notebook
Ran correctness tests before optimization
Timed code using
perf_counterUsed
%timeitCompared Python-loop and NumPy implementations
Verified equivalent output before benchmarking
Ran a scaling experiment
Improved a membership algorithm using a better data structure
Created a profileable Python script
Used
cProfileInterpreted cumulative vs. direct timing
Reprofiled after optimization
Used line profiling or reviewed the concept
Inspected NumPy array memory
Inspected pandas memory usage
Reduced memory with an efficient dtype
Investigated copying cost
Selected only needed columns
Processed data in chunks
Validated chunked output
Established a serial parallel-computing baseline
Used or reviewed
ProcessPoolExecutorCompared parallel workloads of different sizes
Used distinct reproducible RNG seeds
Checked Numba availability
Compiled a numerical loop with
@njitSeparated compilation from execution timing
Compared Python, NumPy, and Numba
Identified a case where NumPy is already optimized
Accelerated a recurrence-like loop with Numba
Explored
prangeor reviewed parallel JIT conceptsReran tests after optimization
Completed the mini optimization project
Calculated overall speedup
Audited the performance tradeoff
Committed Week 14 work to Git
Pushed work to GitHub
What you should now understand¶
text Correct code ↓ Benchmark ↓ Profile ↓ Find hotspot ↓ Improve algorithm / vectorize / reduce memory / parallelize / JIT ↓ Retest ↓ Rebenchmark ↓ Keep only worthwhile complexity
This completes the main computational workflow arc of STA 556: from
environments and data engineering through reproducibility, numerical
statistics, testing, and performance-aware statistical software.Reference answer¶
Profile first, benchmark repeatedly on representative inputs, verify correctness, and optimize the real bottleneck. Algorithm/data-structure changes often beat micro-optimizations; vectorization is fast but can use substantial memory.