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:
2
src/__init__.py
Normal file
2
src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Algorithms Week 2: Divide-and-Conquer Sorting Benchmarks."""
|
||||
|
||||
2
src/algorithms/__init__.py
Normal file
2
src/algorithms/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Sorting algorithm implementations."""
|
||||
|
||||
53
src/algorithms/merge_sort.py
Normal file
53
src/algorithms/merge_sort.py
Normal 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)
|
||||
|
||||
97
src/algorithms/quick_sort.py
Normal file
97
src/algorithms/quick_sort.py
Normal 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
2
src/bench/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Benchmarking utilities."""
|
||||
|
||||
433
src/bench/benchmark.py
Normal file
433
src/bench/benchmark.py
Normal 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
54
src/bench/datasets.py
Normal 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}")
|
||||
|
||||
96
src/bench/logging_setup.py
Normal file
96
src/bench/logging_setup.py
Normal 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
125
src/bench/metrics.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user