Understanding Performance Characteristics

Understanding Performance Characteristics

Modern password hashing algorithms exhibit fundamentally different performance characteristics than traditional cryptographic operations. While SHA-256 processes gigabytes per second, Argon2 deliberately constrains throughput to dozens of operations per second. This 100,000x performance difference isn't a bug—it's the central security feature. Understanding these characteristics enables appropriate optimization strategies.

Memory-hard functions like Argon2 and scrypt create unique scaling challenges. Unlike CPU-bound operations that benefit from faster processors, memory-hard functions require bandwidth and capacity. A system hashing passwords with 64MB of memory per operation needs 64GB of RAM to process 1,000 concurrent authentications. This memory pressure differs qualitatively from typical web application scaling patterns.

import time
import psutil
import threading
import multiprocessing
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
from argon2 import PasswordHasher, Type
import matplotlib.pyplot as plt
import numpy as np

class PasswordHashingBenchmark:
    """Comprehensive benchmarking for password hashing performance"""
    
    def __init__(self):
        self.results = {}
        
    def benchmark_algorithm_scaling(self):
        """Benchmark how different algorithms scale with parameters"""
        
        password = "BenchmarkPassword123!"
        
        # Argon2 scaling with memory
        print("Benchmarking Argon2 memory scaling...")
        memory_costs = [16, 32, 64, 128, 256, 512]  # MB
        argon2_times = []
        
        for memory_mb in memory_costs:
            ph = PasswordHasher(
                memory_cost=memory_mb * 1024,  # Convert to KB
                time_cost=3,
                parallelism=4,
                type=Type.ID
            )
            
            start = time.perf_counter()
            for _ in range(10):
                ph.hash(password)
            elapsed = time.perf_counter() - start
            
            argon2_times.append(elapsed / 10)
            print(f"  {memory_mb}MB: {elapsed/10:.3f}s per hash")
        
        # Bcrypt scaling with cost factor
        print("\nBenchmarking bcrypt cost scaling...")
        import bcrypt
        bcrypt_costs = [10, 11, 12, 13, 14, 15]
        bcrypt_times = []
        
        for cost in bcrypt_costs:
            start = time.perf_counter()
            for _ in range(10):
                bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=cost))
            elapsed = time.perf_counter() - start
            
            bcrypt_times.append(elapsed / 10)
            print(f"  Cost {cost}: {elapsed/10:.3f}s per hash")
        
        return {
            'argon2': {'memory_costs': memory_costs, 'times': argon2_times},
            'bcrypt': {'costs': bcrypt_costs, 'times': bcrypt_times}
        }
    
    def measure_concurrency_impact(self):
        """Measure impact of concurrent hashing on performance"""
        
        ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
        password = "ConcurrencyTest123!"
        
        def hash_password():
            return ph.hash(password)
        
        concurrency_levels = [1, 2, 4, 8, 16, 32]
        results = {}
        
        for level in concurrency_levels:
            # Measure throughput
            start = time.perf_counter()
            
            with ThreadPoolExecutor(max_workers=level) as executor:
                futures = [executor.submit(hash_password) for _ in range(100)]
                for future in futures:
                    future.result()
            
            elapsed = time.perf_counter() - start
            throughput = 100 / elapsed
            
            # Measure memory usage
            process = psutil.Process()
            memory_mb = process.memory_info().rss / 1024 / 1024
            
            results[level] = {
                'throughput': throughput,
                'latency': elapsed / 100,
                'memory_mb': memory_mb
            }
            
            print(f"Concurrency {level}: {throughput:.1f} ops/sec, "
                  f"{elapsed/100:.3f}s/op, {memory_mb:.0f}MB RAM")
        
        return results
    
    def analyze_memory_patterns(self):
        """Analyze memory access patterns for optimization"""
        
        import tracemalloc
        
        # Start memory profiling
        tracemalloc.start()
        
        ph = PasswordHasher(memory_cost=65536, time_cost=3)
        password = "MemoryAnalysis123!"
        
        # Take snapshot before
        snapshot1 = tracemalloc.take_snapshot()
        
        # Hash password
        hash_result = ph.hash(password)
        
        # Take snapshot after
        snapshot2 = tracemalloc.take_snapshot()
        
        # Analyze differences
        top_stats = snapshot2.compare_to(snapshot1, 'lineno')
        
        print("\nMemory allocation analysis:")
        total_allocated = 0
        for stat in top_stats[:10]:
            print(f"{stat}")
            total_allocated += stat.size_diff
        
        print(f"\nTotal allocated: {total_allocated / 1024 / 1024:.1f}MB")
        
        # Measure memory bandwidth impact
        self._measure_memory_bandwidth_impact()
        
        return {
            'peak_memory_mb': total_allocated / 1024 / 1024,
            'allocation_count': len(top_stats)
        }
    
    def _measure_memory_bandwidth_impact(self):
        """Measure impact of memory bandwidth on performance"""
        
        print("\nMemory bandwidth impact test:")
        
        # Create memory pressure
        large_array = np.zeros((1000, 1000, 100), dtype=np.float64)  # ~800MB
        
        ph = PasswordHasher(memory_cost=65536)
        password = "BandwidthTest123!"
        
        # Baseline without memory pressure
        start = time.perf_counter()
        for _ in range(10):
            ph.hash(password)
        baseline = time.perf_counter() - start
        
        # With memory pressure
        def create_memory_pressure():
            while True:
                # Random access to prevent caching
                large_array[np.random.randint(1000), np.random.randint(1000)] += 1
        
        pressure_thread = threading.Thread(target=create_memory_pressure, daemon=True)
        pressure_thread.start()
        
        time.sleep(0.1)  # Let pressure build
        
        start = time.perf_counter()
        for _ in range(10):
            ph.hash(password)
        with_pressure = time.perf_counter() - start
        
        print(f"Baseline: {baseline/10:.3f}s per hash")
        print(f"With memory pressure: {with_pressure/10:.3f}s per hash")
        print(f"Performance degradation: {(with_pressure/baseline - 1) * 100:.1f}%")

# Run benchmarks
benchmark = PasswordHashingBenchmark()
scaling_results = benchmark.benchmark_algorithm_scaling()
concurrency_results = benchmark.measure_concurrency_impact()
memory_results = benchmark.analyze_memory_patterns()