Initial commit: Divide-and-conquer sorting algorithms benchmark

- Implement Merge Sort and Quick Sort algorithms with instrumentation
- Add Quick Sort pivot strategies: first, last, median_of_three, random
- Create dataset generators for 5 dataset types (sorted, reverse, random, nearly_sorted, duplicates_heavy)
- Build comprehensive benchmarking CLI with metrics collection
- Add performance measurement (time, memory, comparisons, swaps)
- Configure logging with rotating file handlers
- Generate plots for time and memory vs size
- Include comprehensive test suite with pytest
- Add full documentation in README.md
This commit is contained in:
Carlos Gutierrez
2025-10-30 21:14:37 -04:00
commit 10570af981
15 changed files with 1518 additions and 0 deletions

2
src/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Algorithms Week 2: Divide-and-Conquer Sorting Benchmarks."""

View File

@@ -0,0 +1,2 @@
"""Sorting algorithm implementations."""

View File

@@ -0,0 +1,53 @@
"""Merge Sort implementation with instrumentation support."""
from typing import List, Optional, Callable
def merge_sort(
arr: List[int],
instrument: Optional[Callable[[str], None]] = None,
) -> List[int]:
"""
Sort array using merge sort algorithm.
Args:
arr: List of integers to sort
instrument: Optional callback function for counting operations.
Called with 'comparison' or 'swap' strings.
Returns:
Sorted copy of the input array.
"""
if len(arr) <= 1:
return arr[:]
def _merge(left: List[int], right: List[int]) -> List[int]:
"""Merge two sorted arrays."""
result: List[int] = []
i, j = 0, 0
while i < len(left) and j < len(right):
if instrument:
instrument("comparison")
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
def _merge_sort_recursive(arr_inner: List[int]) -> List[int]:
"""Recursive merge sort helper."""
if len(arr_inner) <= 1:
return arr_inner[:]
mid = len(arr_inner) // 2
left = _merge_sort_recursive(arr_inner[:mid])
right = _merge_sort_recursive(arr_inner[mid:])
return _merge(left, right)
return _merge_sort_recursive(arr)

View File

@@ -0,0 +1,97 @@
"""Quick Sort implementation with pivot strategies and instrumentation support."""
from typing import List, Optional, Callable, Literal
import random
PivotStrategy = Literal["first", "last", "median_of_three", "random"]
def quick_sort(
arr: List[int],
pivot_strategy: PivotStrategy = "first",
instrument: Optional[Callable[[str], None]] = None,
seed: Optional[int] = None,
) -> List[int]:
"""
Sort array using quick sort algorithm.
Args:
arr: List of integers to sort
pivot_strategy: Strategy for selecting pivot ('first', 'last',
'median_of_three', 'random')
instrument: Optional callback function for counting operations.
Called with 'comparison' or 'swap' strings.
seed: Optional random seed for 'random' pivot strategy
Returns:
Sorted copy of the input array.
"""
if len(arr) <= 1:
return arr[:]
arr_copy = arr[:]
def _choose_pivot(left: int, right: int) -> int:
"""Choose pivot index based on strategy."""
if pivot_strategy == "first":
return left
elif pivot_strategy == "last":
return right
elif pivot_strategy == "median_of_three":
mid = (left + right) // 2
if instrument:
instrument("comparison")
instrument("comparison")
if arr_copy[left] <= arr_copy[mid] <= arr_copy[right] or \
arr_copy[right] <= arr_copy[mid] <= arr_copy[left]:
return mid
elif arr_copy[mid] <= arr_copy[left] <= arr_copy[right] or \
arr_copy[right] <= arr_copy[left] <= arr_copy[mid]:
return left
else:
return right
elif pivot_strategy == "random":
return random.randint(left, right)
else:
raise ValueError(f"Unknown pivot strategy: {pivot_strategy}")
def _partition(left: int, right: int, pivot_idx: int) -> int:
"""Partition array around pivot and return final pivot position."""
pivot_val = arr_copy[pivot_idx]
# Move pivot to end
arr_copy[pivot_idx], arr_copy[right] = arr_copy[right], arr_copy[pivot_idx]
if instrument:
instrument("swap")
store_idx = left
for i in range(left, right):
if instrument:
instrument("comparison")
if arr_copy[i] <= pivot_val:
if i != store_idx:
arr_copy[i], arr_copy[store_idx] = arr_copy[store_idx], arr_copy[i]
if instrument:
instrument("swap")
store_idx += 1
# Move pivot to final position
arr_copy[store_idx], arr_copy[right] = arr_copy[right], arr_copy[store_idx]
if instrument:
instrument("swap")
return store_idx
def _quick_sort_recursive(left: int, right: int) -> None:
"""Recursive quick sort helper."""
if left < right:
pivot_idx = _choose_pivot(left, right)
final_pivot = _partition(left, right, pivot_idx)
_quick_sort_recursive(left, final_pivot - 1)
_quick_sort_recursive(final_pivot + 1, right)
if seed is not None:
random.seed(seed)
_quick_sort_recursive(0, len(arr_copy) - 1)
return arr_copy

2
src/bench/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Benchmarking utilities."""

433
src/bench/benchmark.py Normal file
View File

@@ -0,0 +1,433 @@
"""Benchmark CLI for sorting algorithms."""
import argparse
import csv
import json
import sys
from pathlib import Path
from typing import List, Dict, Any, Optional
import random
from src.algorithms.merge_sort import merge_sort
from src.algorithms.quick_sort import quick_sort, PivotStrategy
from src.bench.datasets import generate_dataset, DatasetType
from src.bench.metrics import measure_sort_performance, Metrics, aggregate_metrics
from src.bench.logging_setup import setup_logging, get_logger
def parse_args() -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Benchmark divide-and-conquer sorting algorithms",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--algorithms",
type=str,
default="merge,quick",
help="Comma-separated list of algorithms (merge, quick)",
)
parser.add_argument(
"--pivot",
type=str,
default="random",
choices=["first", "last", "median_of_three", "random"],
help="Pivot strategy for Quick Sort",
)
parser.add_argument(
"--datasets",
type=str,
default="sorted,reverse,random,nearly_sorted,duplicates_heavy",
help="Comma-separated list of dataset types",
)
parser.add_argument(
"--sizes",
type=str,
default="1000,5000,10000,50000",
help="Comma-separated list of dataset sizes",
)
parser.add_argument(
"--runs",
type=int,
default=5,
help="Number of runs per experiment",
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="Random seed for reproducibility",
)
parser.add_argument(
"--outdir",
type=str,
default="results",
help="Output directory for results",
)
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Logging level",
)
parser.add_argument(
"--instrument",
action="store_true",
help="Count comparisons and swaps",
)
parser.add_argument(
"--make-plots",
action="store_true",
help="Generate plots after benchmarking",
)
return parser.parse_args()
def run_benchmark(
algorithm: str,
pivot_strategy: Optional[str],
dataset_type: DatasetType,
size: int,
runs: int,
seed: int,
instrument: bool,
logger: Any,
) -> List[Dict[str, Any]]:
"""
Run benchmark for a single algorithm/dataset/size combination.
Returns:
List of result dictionaries, one per run
"""
results: List[Dict[str, Any]] = []
# Get sort function
if algorithm == "merge":
sort_func = merge_sort
sort_kwargs: Dict[str, Any] = {}
elif algorithm == "quick":
sort_func = quick_sort
sort_kwargs = {
"pivot_strategy": pivot_strategy or "first",
}
# Only pass seed for random pivot strategy
if pivot_strategy == "random":
sort_kwargs["seed"] = seed
else:
raise ValueError(f"Unknown algorithm: {algorithm}")
for run_idx in range(runs):
logger.info(
f"Running {algorithm} on {dataset_type} size={size} run={run_idx+1}/{runs}"
)
# Generate dataset with unique seed per run
dataset_seed = seed + run_idx * 1000 if seed is not None else None
arr = generate_dataset(size, dataset_type, seed=dataset_seed)
# For quick sort with random pivot, use unique seed per run
if algorithm == "quick" and pivot_strategy == "random":
sort_kwargs["seed"] = (seed + run_idx * 1000) if seed is not None else None
# Run benchmark
sorted_arr, metrics = measure_sort_performance(
sort_func,
arr,
instrument=instrument,
**sort_kwargs,
)
# Verify correctness
expected = sorted(arr)
if sorted_arr != expected:
logger.error(
f"Correctness check failed for {algorithm} on {dataset_type} "
f"size={size} run={run_idx+1}"
)
logger.error(f"Expected: {expected[:10]}...")
logger.error(f"Got: {sorted_arr[:10]}...")
return [] # Return empty to indicate failure
# Store result
result = {
"algorithm": algorithm,
"pivot": pivot_strategy if algorithm == "quick" else None,
"dataset": dataset_type,
"size": size,
"run": run_idx + 1,
"time_s": metrics.time_seconds,
"peak_mem_bytes": metrics.peak_memory_bytes,
"comparisons": metrics.comparisons if instrument else None,
"swaps": metrics.swaps if instrument else None,
"seed": seed,
}
results.append(result)
return results
def save_results_csv(results: List[Dict[str, Any]], csv_path: Path) -> None:
"""Save results to CSV file."""
if not results:
return
file_exists = csv_path.exists()
with open(csv_path, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=results[0].keys())
if not file_exists:
writer.writeheader()
writer.writerows(results)
def save_summary_json(results: List[Dict[str, Any]], json_path: Path) -> None:
"""Save aggregated summary to JSON file."""
if not results:
return
# Group by (algorithm, pivot, dataset, size)
grouped: Dict[tuple, List[Metrics]] = {}
for result in results:
key = (
result["algorithm"],
result.get("pivot"),
result["dataset"],
result["size"],
)
metrics = Metrics()
metrics.time_seconds = result["time_s"]
metrics.peak_memory_bytes = result["peak_mem_bytes"]
metrics.comparisons = result.get("comparisons") or 0
metrics.swaps = result.get("swaps") or 0
if key not in grouped:
grouped[key] = []
grouped[key].append(metrics)
# Aggregate
summary: Dict[str, Any] = {}
for key, metrics_list in grouped.items():
algo, pivot, dataset, size = key
key_str = f"{algo}_{pivot or 'N/A'}_{dataset}_{size}"
summary[key_str] = aggregate_metrics(metrics_list)
summary[key_str]["algorithm"] = algo
summary[key_str]["pivot"] = pivot
summary[key_str]["dataset"] = dataset
summary[key_str]["size"] = size
# Merge with existing summary if it exists
if json_path.exists():
with open(json_path, "r") as f:
existing = json.load(f)
existing.update(summary)
summary = existing
with open(json_path, "w") as f:
json.dump(summary, f, indent=2)
def generate_plots(results: List[Dict[str, Any]], plots_dir: Path, logger: Any) -> None:
"""Generate plots from results."""
try:
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg') # Non-interactive backend
except ImportError:
logger.warning("matplotlib not available, skipping plots")
return
plots_dir.mkdir(parents=True, exist_ok=True)
if not results:
logger.warning("No results to plot")
return
# Group results by algorithm and dataset
algorithms = sorted(set(r["algorithm"] for r in results))
datasets = sorted(set(r["dataset"] for r in results))
sizes = sorted(set(r["size"] for r in results))
# Time vs size plots
fig, axes = plt.subplots(len(datasets), 1, figsize=(10, 5 * len(datasets)))
if len(datasets) == 1:
axes = [axes]
for idx, dataset in enumerate(datasets):
ax = axes[idx]
for algo in algorithms:
algo_results = [
r for r in results
if r["algorithm"] == algo and r["dataset"] == dataset
]
if not algo_results:
continue
# Average time per size
size_times: Dict[int, List[float]] = {}
for r in algo_results:
size = r["size"]
if size not in size_times:
size_times[size] = []
size_times[size].append(r["time_s"])
avg_times = [sum(size_times[s]) / len(size_times[s]) for s in sizes if s in size_times]
plot_sizes = [s for s in sizes if s in size_times]
ax.plot(plot_sizes, avg_times, marker="o", label=algo)
ax.set_xlabel("Array Size")
ax.set_ylabel("Time (seconds)")
ax.set_title(f"Sorting Time vs Size - {dataset}")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(plots_dir / "time_vs_size.png", dpi=150)
plt.close()
# Memory vs size plots
fig, axes = plt.subplots(len(datasets), 1, figsize=(10, 5 * len(datasets)))
if len(datasets) == 1:
axes = [axes]
for idx, dataset in enumerate(datasets):
ax = axes[idx]
for algo in algorithms:
algo_results = [
r for r in results
if r["algorithm"] == algo and r["dataset"] == dataset
]
if not algo_results:
continue
# Average memory per size
size_memories: Dict[int, List[int]] = {}
for r in algo_results:
size = r["size"]
if size not in size_memories:
size_memories[size] = []
size_memories[size].append(r["peak_mem_bytes"])
avg_memories = [
sum(size_memories[s]) / len(size_memories[s])
for s in sizes if s in size_memories
]
plot_sizes = [s for s in sizes if s in size_memories]
ax.plot(plot_sizes, avg_memories, marker="o", label=algo)
ax.set_xlabel("Array Size")
ax.set_ylabel("Peak Memory (bytes)")
ax.set_title(f"Memory Usage vs Size - {dataset}")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(plots_dir / "memory_vs_size.png", dpi=150)
plt.close()
logger.info(f"Plots saved to {plots_dir}")
def main() -> int:
"""Main entry point."""
args = parse_args()
# Setup paths
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
plots_dir = Path("plots")
# Setup logging
setup_logging(outdir, args.log_level)
logger = get_logger(__name__)
# Parse arguments
algorithms = [a.strip() for a in args.algorithms.split(",")]
datasets = [d.strip() for d in args.datasets.split(",")]
sizes = [int(s.strip()) for s in args.sizes.split(",")]
# Validate algorithms
valid_algorithms = {"merge", "quick"}
for algo in algorithms:
if algo not in valid_algorithms:
logger.error(f"Invalid algorithm: {algo}")
return 1
# Set random seed
if args.seed is not None:
random.seed(args.seed)
# Run benchmarks
all_results: List[Dict[str, Any]] = []
correctness_failed = False
for algorithm in algorithms:
pivot_strategy = args.pivot if algorithm == "quick" else None
for dataset_type in datasets:
for size in sizes:
try:
results = run_benchmark(
algorithm,
pivot_strategy,
dataset_type, # type: ignore
size,
args.runs,
args.seed,
args.instrument,
logger,
)
if not results:
correctness_failed = True
else:
all_results.extend(results)
except Exception as e:
logger.error(
f"Error running benchmark: {algorithm}, {dataset_type}, {size}",
exc_info=True,
)
correctness_failed = True
# Save results
csv_path = outdir / "bench_results.csv"
json_path = outdir / "summary.json"
if all_results:
save_results_csv(all_results, csv_path)
save_summary_json(all_results, json_path)
logger.info(f"Results saved to {csv_path} and {json_path}")
# Generate plots
if args.make_plots or all_results:
generate_plots(all_results, plots_dir, logger)
# Exit with error if correctness failed
if correctness_failed:
logger.error("Benchmark failed due to correctness check failures")
return 1
logger.info("Benchmark completed successfully")
return 0
if __name__ == "__main__":
sys.exit(main())

54
src/bench/datasets.py Normal file
View File

@@ -0,0 +1,54 @@
"""Dataset generators for benchmarking."""
from typing import List, Literal, Optional
import random
DatasetType = Literal["sorted", "reverse", "random", "nearly_sorted", "duplicates_heavy"]
def generate_dataset(
size: int,
dataset_type: DatasetType,
seed: Optional[int] = None,
) -> List[int]:
"""
Generate a dataset of specified type and size.
Args:
size: Number of elements in the dataset
dataset_type: Type of dataset to generate
seed: Random seed for reproducibility
Returns:
List of integers with the specified characteristics
"""
if seed is not None:
random.seed(seed)
if dataset_type == "sorted":
return list(range(size))
elif dataset_type == "reverse":
return list(range(size - 1, -1, -1))
elif dataset_type == "random":
return [random.randint(0, size * 10) for _ in range(size)]
elif dataset_type == "nearly_sorted":
arr = list(range(size))
# Perform a few swaps (about 1% of elements)
num_swaps = max(1, size // 100)
for _ in range(num_swaps):
i = random.randint(0, size - 1)
j = random.randint(0, size - 1)
arr[i], arr[j] = arr[j], arr[i]
return arr
elif dataset_type == "duplicates_heavy":
# Generate array with many duplicate values
# Use only a small set of distinct values
distinct_values = max(1, size // 10)
return [random.randint(0, distinct_values - 1) for _ in range(size)]
else:
raise ValueError(f"Unknown dataset type: {dataset_type}")

View File

@@ -0,0 +1,96 @@
"""Logging configuration for benchmarks."""
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
from typing import Optional
import platform
def setup_logging(
log_dir: Path,
log_level: str = "INFO",
log_file: str = "bench.log",
) -> None:
"""
Configure logging to both console and rotating file.
Args:
log_dir: Directory to write log files
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
log_file: Name of the log file
"""
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / log_file
# Convert string level to logging constant
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
# Create logger
logger = logging.getLogger()
logger.setLevel(numeric_level)
# Remove existing handlers to avoid duplicates
logger.handlers.clear()
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(numeric_level)
console_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler.setFormatter(console_format)
logger.addHandler(console_handler)
# File handler with rotation (10MB max, keep 5 backups)
file_handler = RotatingFileHandler(
log_path,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding='utf-8',
)
file_handler.setLevel(numeric_level)
file_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(file_format)
logger.addHandler(file_handler)
# Log system information
logger.info("=" * 80)
logger.info("Benchmark session started")
logger.info(f"Python version: {sys.version}")
logger.info(f"Platform: {platform.platform()}")
logger.info(f"Architecture: {platform.machine()}")
# Try to get git commit if available
try:
import subprocess
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0:
logger.info(f"Git commit: {result.stdout.strip()}")
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
pass
logger.info("=" * 80)
def get_logger(name: Optional[str] = None) -> logging.Logger:
"""
Get a logger instance.
Args:
name: Logger name (defaults to calling module)
Returns:
Logger instance
"""
return logging.getLogger(name)

125
src/bench/metrics.py Normal file
View File

@@ -0,0 +1,125 @@
"""Performance metrics collection."""
import time
import tracemalloc
from typing import Dict, Any, Optional, List
import psutil
import os
class Metrics:
"""Container for benchmark metrics."""
def __init__(self) -> None:
self.time_seconds: float = 0.0
self.peak_memory_bytes: int = 0
self.comparisons: int = 0
self.swaps: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert metrics to dictionary."""
return {
"time_s": self.time_seconds,
"peak_mem_bytes": self.peak_memory_bytes,
"comparisons": self.comparisons,
"swaps": self.swaps,
}
def measure_sort_performance(
sort_func,
arr: List[int],
*args,
instrument: bool = False,
**kwargs,
) -> tuple[List[int], Metrics]:
"""
Measure performance of a sorting function.
Args:
sort_func: Sorting function to benchmark
arr: Input array to sort
*args: Additional positional arguments for sort_func
instrument: Whether to count comparisons and swaps
**kwargs: Additional keyword arguments for sort_func
Returns:
Tuple of (sorted_array, metrics)
"""
metrics = Metrics()
# Setup instrumentation
if instrument:
counters: Dict[str, int] = {"comparison": 0, "swap": 0}
def instrument_callback(op: str) -> None:
if op in counters:
counters[op] += 1
if "instrument" not in kwargs:
kwargs["instrument"] = instrument_callback
# Measure memory before
process = psutil.Process(os.getpid())
tracemalloc.start()
# Measure time
start_time = time.perf_counter()
sorted_arr = sort_func(arr, *args, **kwargs)
end_time = time.perf_counter()
# Measure memory
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
rss_memory = process.memory_info().rss
metrics.time_seconds = end_time - start_time
metrics.peak_memory_bytes = max(peak, rss_memory)
if instrument:
metrics.comparisons = counters.get("comparison", 0)
metrics.swaps = counters.get("swap", 0)
return sorted_arr, metrics
def aggregate_metrics(metrics_list: List[Metrics]) -> Dict[str, Any]:
"""
Aggregate metrics across multiple runs.
Args:
metrics_list: List of Metrics objects from multiple runs
Returns:
Dictionary with aggregated statistics
"""
if not metrics_list:
return {}
times = [m.time_seconds for m in metrics_list]
memories = [m.peak_memory_bytes for m in metrics_list]
comparisons = [m.comparisons for m in metrics_list if m.comparisons > 0]
swaps = [m.swaps for m in metrics_list if m.swaps > 0]
import statistics
result: Dict[str, Any] = {
"time_mean_s": statistics.mean(times),
"time_std_s": statistics.stdev(times) if len(times) > 1 else 0.0,
"time_best_s": min(times),
"time_worst_s": max(times),
"memory_mean_bytes": statistics.mean(memories),
"memory_std_bytes": statistics.stdev(memories) if len(memories) > 1 else 0.0,
"memory_peak_bytes": max(memories),
"runs": len(metrics_list),
}
if comparisons:
result["comparisons_mean"] = statistics.mean(comparisons)
result["comparisons_std"] = statistics.stdev(comparisons) if len(comparisons) > 1 else 0.0
if swaps:
result["swaps_mean"] = statistics.mean(swaps)
result["swaps_std"] = statistics.stdev(swaps) if len(swaps) > 1 else 0.0
return result