mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
647 lines
22 KiB
Python
647 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
# ==============================================================================
|
||
# COPYRIGHT NO ONE EVERYWHERE LLC (WYOMING HOLDING COMPANY)
|
||
# PROJECT: SOVEREIGN STACK
|
||
# This artifact is entirely proprietary and cryptographically proven.
|
||
# Open-Source usage requires explicit permission from Brandon Scott Schneider.
|
||
# ==============================================================================
|
||
"""
|
||
Interference Eraser Cache Simulator — Tick 694
|
||
|
||
Test interference eraser principles on CPU cache optimization.
|
||
Run on your actual CPU to measure erasure vs. tracking trade-offs.
|
||
|
||
Usage:
|
||
python3 interference_eraser_cache_sim.py
|
||
|
||
# Test different erasure probabilities
|
||
python3 interference_eraser_cache_sim.py --erase_probs 0.0 0.5 0.7 0.9 1.0
|
||
|
||
# Run specific workload
|
||
python3 interference_eraser_cache_sim.py --workload spatial
|
||
"""
|
||
|
||
import numpy as np
|
||
import argparse
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from typing import Dict, List, Tuple, Optional
|
||
from collections import defaultdict
|
||
|
||
# Optional imports for visualization
|
||
try:
|
||
import matplotlib.pyplot as plt
|
||
HAS_MATPLOTLIB = True
|
||
except ImportError:
|
||
HAS_MATPLOTLIB = False
|
||
|
||
try:
|
||
import pandas as pd
|
||
HAS_PANDAS = True
|
||
except ImportError:
|
||
HAS_PANDAS = False
|
||
|
||
# ============================================================================
|
||
# Cache Models
|
||
# ============================================================================
|
||
|
||
@dataclass
|
||
class CacheLine:
|
||
"""Standard cache line with full tracking."""
|
||
tag: int
|
||
data: Optional[bytes] = None
|
||
state: str = 'I' # MESI: M/E/S/I
|
||
lru_counter: int = 0
|
||
owner_core: int = -1
|
||
access_history: List[int] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class ErasedCacheLine:
|
||
"""Interference eraser cache line with aggregate metrics only."""
|
||
tag: int
|
||
data: Optional[bytes] = None
|
||
lru_counter: int = 0 # Still need for LRU eviction
|
||
access_frequency: float = 0.0
|
||
temporal_decay: float = 0.0
|
||
spatial_cluster: int = -1
|
||
# Erased: specific core_id, specific access order
|
||
|
||
|
||
@dataclass
|
||
class CacheStats:
|
||
"""Cache performance statistics."""
|
||
hits: int = 0
|
||
misses: int = 0
|
||
evictions: int = 0
|
||
coherence_updates: int = 0
|
||
metadata_updates: int = 0
|
||
total_accesses: int = 0
|
||
|
||
@property
|
||
def hit_rate(self) -> float:
|
||
if self.total_accesses == 0:
|
||
return 0.0
|
||
return self.hits / self.total_accesses
|
||
|
||
@property
|
||
def miss_rate(self) -> float:
|
||
return 1.0 - self.hit_rate
|
||
|
||
|
||
class StandardCache:
|
||
"""
|
||
Standard CPU cache with full path tracking.
|
||
Tracks: which core, which line, access order, MESI state
|
||
"""
|
||
|
||
def __init__(self, size_mb: float = 8.0, line_size: int = 64,
|
||
associativity: int = 16, num_cores: int = 8):
|
||
self.size_bytes = int(size_mb * 1024 * 1024)
|
||
self.line_size = line_size
|
||
self.num_lines = self.size_bytes // line_size
|
||
self.associativity = associativity
|
||
self.num_sets = self.num_lines // associativity
|
||
self.num_cores = num_cores
|
||
|
||
# Cache structure: sets × ways
|
||
self.sets = [[CacheLine(tag=-1) for _ in range(associativity)]
|
||
for _ in range(self.num_sets)]
|
||
|
||
self.lru_counter = 0
|
||
self.stats = CacheStats()
|
||
|
||
def _get_set_index(self, addr: int) -> int:
|
||
return (addr // self.line_size) % self.num_sets
|
||
|
||
def _get_tag(self, addr: int) -> int:
|
||
return addr // (self.line_size * self.num_sets)
|
||
|
||
def access(self, addr: int, core_id: int, is_write: bool = False) -> bool:
|
||
"""
|
||
Access cache line. Returns True if hit, False if miss.
|
||
Tracks full path information.
|
||
"""
|
||
self.stats.total_accesses += 1
|
||
self.stats.metadata_updates += 1 # Track every access
|
||
|
||
set_idx = self._get_set_index(addr)
|
||
tag = self._get_tag(addr)
|
||
|
||
# Search for tag
|
||
for way in range(self.associativity):
|
||
line = self.sets[set_idx][way]
|
||
if line.tag == tag:
|
||
# HIT
|
||
self.stats.hits += 1
|
||
line.lru_counter = self.lru_counter
|
||
line.owner_core = core_id
|
||
line.access_history.append(core_id)
|
||
if is_write:
|
||
line.state = 'M'
|
||
self.stats.coherence_updates += 1
|
||
self.lru_counter += 1
|
||
return True
|
||
|
||
# MISS
|
||
self.stats.misses += 1
|
||
|
||
# Find LRU way
|
||
lru_way = min(range(self.associativity),
|
||
key=lambda w: self.sets[set_idx][w].lru_counter)
|
||
|
||
# Evict if necessary
|
||
if self.sets[set_idx][lru_way].tag != -1:
|
||
self.stats.evictions += 1
|
||
|
||
# Install new line
|
||
self.sets[set_idx][lru_way] = CacheLine(
|
||
tag=tag,
|
||
state='M' if is_write else 'E',
|
||
lru_counter=self.lru_counter,
|
||
owner_core=core_id,
|
||
access_history=[core_id]
|
||
)
|
||
|
||
self.lru_counter += 1
|
||
return False
|
||
|
||
|
||
class InterferenceEraserCache:
|
||
"""
|
||
Interference eraser cache with aggregate metrics.
|
||
Erases: specific core_id, specific access order
|
||
Tracks: aggregate frequency, temporal decay, spatial clustering
|
||
"""
|
||
|
||
def __init__(self, size_mb: float = 2.0, line_size: int = 64,
|
||
associativity: int = 16, num_cores: int = 8,
|
||
erase_prob: float = 0.8, tau_decay: float = 0.99):
|
||
self.size_bytes = int(size_mb * 1024 * 1024)
|
||
self.line_size = line_size
|
||
self.num_lines = self.size_bytes // line_size
|
||
self.associativity = associativity
|
||
self.num_sets = self.num_lines // associativity
|
||
self.num_cores = num_cores
|
||
self.erase_prob = erase_prob
|
||
self.tau_decay = tau_decay
|
||
|
||
# Cache structure: sets × ways
|
||
self.sets = [[ErasedCacheLine(tag=-1) for _ in range(associativity)]
|
||
for _ in range(self.num_sets)]
|
||
|
||
self.lru_counter = 0
|
||
self.stats = CacheStats()
|
||
|
||
# Spatial clustering (simple hash-based)
|
||
self.spatial_clusters = 16
|
||
|
||
def _get_set_index(self, addr: int) -> int:
|
||
return (addr // self.line_size) % self.num_sets
|
||
|
||
def _get_tag(self, addr: int) -> int:
|
||
return addr // (self.line_size * self.num_sets)
|
||
|
||
def _get_spatial_cluster(self, addr: int) -> int:
|
||
return (addr // self.line_size) % self.spatial_clusters
|
||
|
||
def access(self, addr: int, core_id: int, is_write: bool = False) -> bool:
|
||
"""
|
||
Access cache line with interference erasure.
|
||
With probability erase_prob, erase specific path information.
|
||
"""
|
||
self.stats.total_accesses += 1
|
||
|
||
set_idx = self._get_set_index(addr)
|
||
tag = self._get_tag(addr)
|
||
cluster = self._get_spatial_cluster(addr)
|
||
|
||
# Search for tag
|
||
for way in range(self.associativity):
|
||
line = self.sets[set_idx][way]
|
||
if line.tag == tag:
|
||
# HIT
|
||
self.stats.hits += 1
|
||
|
||
# Interference erasure decision
|
||
if np.random.random() < self.erase_prob:
|
||
# ERASE: Update aggregate metrics only, skip metadata counter bump
|
||
line.access_frequency += 1.0
|
||
line.temporal_decay *= self.tau_decay
|
||
line.spatial_cluster = cluster
|
||
# DO NOT update lru_counter here, this simulates lost path tracking
|
||
else:
|
||
# TRACK: Update specific metadata (standard behavior)
|
||
line.lru_counter = self.lru_counter
|
||
self.stats.metadata_updates += 1 # Heavyweight update
|
||
|
||
if is_write:
|
||
self.stats.coherence_updates += 1
|
||
|
||
self.lru_counter += 1
|
||
return True
|
||
|
||
# MISS
|
||
self.stats.misses += 1
|
||
|
||
# Find LRU way
|
||
lru_way = min(range(self.associativity),
|
||
key=lambda w: self.sets[set_idx][w].lru_counter)
|
||
|
||
# Evict if necessary
|
||
if self.sets[set_idx][lru_way].tag != -1:
|
||
self.stats.evictions += 1
|
||
|
||
# Install new line with aggregate metrics
|
||
self.sets[set_idx][lru_way] = ErasedCacheLine(
|
||
tag=tag,
|
||
lru_counter=self.lru_counter,
|
||
access_frequency=1.0,
|
||
temporal_decay=1.0,
|
||
spatial_cluster=cluster
|
||
)
|
||
|
||
self.lru_counter += 1
|
||
return False
|
||
|
||
def prefetch_decision(self, addr: int) -> Optional[int]:
|
||
"""
|
||
Waveprobe-based prefetch decision.
|
||
Uses aggregate field to decide prefetch region.
|
||
"""
|
||
set_idx = self._get_set_index(addr)
|
||
|
||
# Compute "Waveprobe response" for this set
|
||
# W_a = sum of access frequencies in set
|
||
total_freq = sum(
|
||
line.access_frequency * line.temporal_decay
|
||
for line in self.sets[set_idx]
|
||
if line.tag != -1
|
||
)
|
||
|
||
# Prefetch if aggregate response is high
|
||
if total_freq > 2.0: # Threshold
|
||
# Prefetch next spatial cluster
|
||
cluster = self._get_spatial_cluster(addr)
|
||
prefetch_addr = addr + (self.line_size * self.spatial_clusters)
|
||
return prefetch_addr
|
||
|
||
return None
|
||
|
||
|
||
# ============================================================================
|
||
# Workload Generators
|
||
# ============================================================================
|
||
|
||
def generate_spatial_workload(num_accesses: int = 10000,
|
||
stride: int = 64,
|
||
num_cores: int = 8) -> List[Tuple[int, int, bool]]:
|
||
"""
|
||
Spatial locality workload (typical array traversal).
|
||
Use limited address range to ensure cache hits.
|
||
"""
|
||
accesses = []
|
||
# Limit address range to fit in cache (8MB = 131072 lines)
|
||
addr_range = 4 * 1024 * 1024 # 4MB working set
|
||
base_addr = 0x10000000
|
||
|
||
for i in range(num_accesses):
|
||
core_id = i % num_cores
|
||
addr = base_addr + (i * stride) % addr_range
|
||
is_write = (i % 10 == 0) # 10% writes
|
||
accesses.append((addr, core_id, is_write))
|
||
|
||
return accesses
|
||
|
||
|
||
def generate_random_workload(num_accesses: int = 10000,
|
||
addr_range: int = 1024 * 1024,
|
||
num_cores: int = 8) -> List[Tuple[int, int, bool]]:
|
||
"""
|
||
Random access workload (using Zipf to ensure temporal locality where LRU matters).
|
||
"""
|
||
accesses = []
|
||
base_addr = 0x10000000
|
||
|
||
# Generate Zipf distribution (alpha=1.5)
|
||
# Range scaled so that roughly 2x the cache size is addressable
|
||
num_unique_lines = (addr_range // 64) * 4
|
||
zipf_indices = np.random.zipf(1.1, num_accesses)
|
||
# Map high indices down to our range to keep things bounded
|
||
zipf_indices = [min(idx, num_unique_lines - 1) for idx in zipf_indices]
|
||
|
||
for i in range(num_accesses):
|
||
core_id = i % num_cores
|
||
addr = base_addr + (zipf_indices[i] * 64)
|
||
is_write = (i % 10 == 0)
|
||
accesses.append((addr, core_id, is_write))
|
||
|
||
return accesses
|
||
|
||
|
||
def generate_burst_workload(num_accesses: int = 10000,
|
||
burst_size: int = 100,
|
||
num_cores: int = 8) -> List[Tuple[int, int, bool]]:
|
||
"""
|
||
Bursty workload (temporal locality).
|
||
"""
|
||
accesses = []
|
||
base_addr = 0x10000000
|
||
num_bursts = num_accesses // burst_size
|
||
|
||
for burst in range(num_bursts):
|
||
core_id = burst % num_cores
|
||
addr = base_addr + (burst * 64)
|
||
|
||
for i in range(burst_size):
|
||
is_write = (i % 10 == 0)
|
||
accesses.append((addr + (i * 64), core_id, is_write))
|
||
|
||
return accesses
|
||
|
||
|
||
def generate_multicore_workload(num_accesses: int = 10000,
|
||
shared_regions: int = 4,
|
||
num_cores: int = 8) -> List[Tuple[int, int, bool]]:
|
||
"""
|
||
Multi-core workload with shared memory regions.
|
||
"""
|
||
accesses = []
|
||
region_size = 64 * 1024 # 64KB per region
|
||
|
||
for i in range(num_accesses):
|
||
core_id = i % num_cores
|
||
region = i % shared_regions
|
||
offset = np.random.randint(0, region_size)
|
||
addr = 0x10000000 + (region * region_size) + offset
|
||
is_write = (i % 5 == 0) # 20% writes for shared regions
|
||
accesses.append((addr, core_id, is_write))
|
||
|
||
return accesses
|
||
|
||
|
||
# ============================================================================
|
||
# Benchmark Runner
|
||
# ============================================================================
|
||
|
||
@dataclass
|
||
class BenchmarkResult:
|
||
"""Results from a single benchmark run."""
|
||
cache_type: str
|
||
erase_prob: float
|
||
workload_type: str
|
||
hit_rate: float
|
||
miss_rate: float
|
||
metadata_updates: int
|
||
coherence_updates: int
|
||
total_accesses: int
|
||
elapsed_time: float
|
||
|
||
@property
|
||
def metadata_overhead(self) -> float:
|
||
return self.metadata_updates / self.total_accesses
|
||
|
||
|
||
def run_benchmark(cache, accesses: List[Tuple[int, int, bool]]) -> BenchmarkResult:
|
||
"""
|
||
Run benchmark on cache with given workload.
|
||
"""
|
||
start_time = time.perf_counter()
|
||
|
||
for addr, core_id, is_write in accesses:
|
||
cache.access(addr, core_id, is_write)
|
||
|
||
elapsed_time = time.perf_counter() - start_time
|
||
|
||
return BenchmarkResult(
|
||
cache_type=type(cache).__name__,
|
||
erase_prob=getattr(cache, 'erase_prob', 0.0),
|
||
workload_type='unknown',
|
||
hit_rate=cache.stats.hit_rate,
|
||
miss_rate=cache.stats.miss_rate,
|
||
metadata_updates=cache.stats.metadata_updates,
|
||
coherence_updates=cache.stats.coherence_updates,
|
||
total_accesses=cache.stats.total_accesses,
|
||
elapsed_time=elapsed_time
|
||
)
|
||
|
||
|
||
def compare_caches(erase_probs: List[float] = None,
|
||
workload_types: List[str] = None,
|
||
num_accesses: int = 10000,
|
||
cache_size_mb: int = 8,
|
||
num_cores: int = 8) -> List[BenchmarkResult]:
|
||
"""
|
||
Compare standard cache vs. interference eraser cache across erasure probabilities.
|
||
"""
|
||
if erase_probs is None:
|
||
erase_probs = [0.0, 0.3, 0.5, 0.7, 0.9, 1.0]
|
||
|
||
if workload_types is None:
|
||
workload_types = ['spatial', 'random', 'burst', 'multicore']
|
||
|
||
results = []
|
||
|
||
print(f"Running benchmarks: {len(erase_probs)} erasure probs × "
|
||
f"{len(workload_types)} workloads = {len(erase_probs) * len(workload_types) + len(workload_types)} runs")
|
||
print()
|
||
|
||
for workload_type in workload_types:
|
||
print(f"Workload: {workload_type}")
|
||
|
||
# Generate workload
|
||
if workload_type == 'spatial':
|
||
accesses = generate_spatial_workload(num_accesses, num_cores=num_cores)
|
||
elif workload_type == 'random':
|
||
accesses = generate_random_workload(num_accesses, num_cores=num_cores)
|
||
elif workload_type == 'burst':
|
||
accesses = generate_burst_workload(num_accesses, num_cores=num_cores)
|
||
elif workload_type == 'multicore':
|
||
accesses = generate_multicore_workload(num_accesses, num_cores=num_cores)
|
||
else:
|
||
raise ValueError(f"Unknown workload: {workload_type}")
|
||
|
||
# Standard cache (erase_prob = 0.0)
|
||
print(f" Standard cache (tracking)...")
|
||
std_cache = StandardCache(size_mb=cache_size_mb, num_cores=num_cores)
|
||
std_result = run_benchmark(std_cache, accesses)
|
||
std_result.workload_type = workload_type
|
||
results.append(std_result)
|
||
print(f" Hit rate: {std_result.hit_rate:.3f}, "
|
||
f"Metadata overhead: {std_result.metadata_overhead:.3f}")
|
||
|
||
# Interference eraser caches
|
||
for erase_prob in erase_probs:
|
||
print(f" Interference eraser cache (erase_prob={erase_prob:.1f})...")
|
||
qe_cache = InterferenceEraserCache(
|
||
size_mb=cache_size_mb,
|
||
num_cores=num_cores,
|
||
erase_prob=erase_prob
|
||
)
|
||
qe_result = run_benchmark(qe_cache, accesses)
|
||
qe_result.workload_type = workload_type
|
||
results.append(qe_result)
|
||
print(f" Hit rate: {qe_result.hit_rate:.3f}, "
|
||
f"Metadata overhead: {qe_result.metadata_overhead:.3f}")
|
||
|
||
print()
|
||
|
||
return results
|
||
|
||
|
||
# ============================================================================
|
||
# Visualization
|
||
# ============================================================================
|
||
|
||
def plot_results(results: List[BenchmarkResult], output_file: str = None):
|
||
"""
|
||
Plot benchmark results.
|
||
"""
|
||
if not HAS_MATPLOTLIB or not HAS_PANDAS:
|
||
print("Warning: matplotlib and/or pandas not available. Skipping plot.")
|
||
return
|
||
|
||
df = pd.DataFrame([r.__dict__ for r in results])
|
||
|
||
# Group by workload
|
||
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
|
||
|
||
for idx, workload in enumerate(df['workload_type'].unique()):
|
||
ax = axes[idx // 2, idx % 2]
|
||
workload_df = df[df['workload_type'] == workload]
|
||
|
||
# Plot hit rate vs erasure
|
||
ax.plot(workload_df['erase_prob'], workload_df['hit_rate'],
|
||
'o-', label='Hit Rate', linewidth=2, markersize=8)
|
||
|
||
# Plot metadata overhead
|
||
ax.plot(workload_df['erase_prob'], workload_df['metadata_overhead'],
|
||
's--', label='Metadata Overhead', linewidth=2, markersize=8)
|
||
|
||
ax.set_xlabel('Erasure Probability', fontsize=12)
|
||
ax.set_ylabel('Metric', fontsize=12)
|
||
ax.set_title(f'{workload.capitalize()} Workload', fontsize=14)
|
||
ax.legend(fontsize=10)
|
||
ax.grid(True, alpha=0.3)
|
||
ax.set_xlim(-0.05, 1.05)
|
||
|
||
plt.tight_layout()
|
||
|
||
if output_file:
|
||
plt.savefig(output_file, dpi=150, bbox_inches='tight')
|
||
print(f"Plot saved to {output_file}")
|
||
else:
|
||
plt.show()
|
||
|
||
|
||
def print_summary(results: List[BenchmarkResult]):
|
||
"""
|
||
Print summary of key findings.
|
||
"""
|
||
if not HAS_PANDAS:
|
||
print("Warning: pandas not available. Using basic summary.")
|
||
# Basic summary without pandas
|
||
for r in results:
|
||
print(f" {r.cache_type} (erase={r.erase_prob:.1f}): "
|
||
f"hit_rate={r.hit_rate:.3f}, metadata={r.metadata_overhead:.3f}")
|
||
return
|
||
|
||
df = pd.DataFrame([r.__dict__ for r in results])
|
||
|
||
print("=" * 70)
|
||
print("INTERFERENCE ERASER CACHE BENCHMARK SUMMARY")
|
||
print("=" * 70)
|
||
print()
|
||
|
||
for workload in df['workload_type'].unique():
|
||
workload_df = df[df['workload_type'] == workload]
|
||
|
||
print(f"Workload: {workload}")
|
||
|
||
# Find optimal erasure
|
||
optimal_idx = workload_df['hit_rate'].idxmax()
|
||
optimal = workload_df.loc[optimal_idx]
|
||
standard_row = workload_df[workload_df['erase_prob'] == 0.0]
|
||
|
||
if len(standard_row) == 0:
|
||
print(f" No standard cache data found")
|
||
print()
|
||
continue
|
||
|
||
standard = standard_row.iloc[0]
|
||
|
||
print(f" Standard cache hit rate: {standard['hit_rate']:.3f}")
|
||
print(f" Optimal erasure: {optimal['erase_prob']:.1f}")
|
||
print(f" Optimal hit rate: {optimal['hit_rate']:.3f}")
|
||
print(f" Improvement: {(optimal['hit_rate'] - standard['hit_rate']) * 100:.1f}%")
|
||
if standard['metadata_overhead'] > 0:
|
||
print(f" Metadata reduction: {(1 - optimal['metadata_overhead'] / standard['metadata_overhead']) * 100:.1f}%")
|
||
print()
|
||
|
||
print("=" * 70)
|
||
|
||
|
||
# ============================================================================
|
||
# Main
|
||
# ============================================================================
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description='Interference Eraser Cache Simulator (Tick 694)'
|
||
)
|
||
parser.add_argument('--erase_probs', type=float, nargs='+',
|
||
default=[0.0, 0.3, 0.5, 0.7, 0.9, 1.0],
|
||
help='Erasure probabilities to test')
|
||
parser.add_argument('--workloads', type=str, nargs='+',
|
||
default=['spatial', 'random', 'burst', 'multicore'],
|
||
help='Workload types to test')
|
||
parser.add_argument('--num_accesses', type=int, default=10000,
|
||
help='Number of memory accesses per workload')
|
||
parser.add_argument('--cache_size', type=float, default=2.0,
|
||
help='Cache size in MB (smaller forces evictions)')
|
||
parser.add_argument('--num_cores', type=int, default=8,
|
||
help='Number of CPU cores')
|
||
parser.add_argument('--output_plot', type=str, default=None,
|
||
help='Output file for plot (PNG)')
|
||
parser.add_argument('--output_summary', type=str, default=None,
|
||
help='Output file for summary (JSON)')
|
||
|
||
args = parser.parse_args()
|
||
|
||
print("Interference Eraser Cache Simulator")
|
||
print("=" * 70)
|
||
print(f"Cache size: {args.cache_size}MB")
|
||
print(f"Cores: {args.num_cores}")
|
||
print(f"Accesses: {args.num_accesses:,}")
|
||
print(f"Erasure probs: {args.erase_probs}")
|
||
print(f"Workloads: {args.workloads}")
|
||
print("=" * 70)
|
||
print()
|
||
|
||
# Run benchmarks
|
||
results = compare_caches(
|
||
erase_probs=args.erase_probs,
|
||
workload_types=args.workloads,
|
||
num_accesses=args.num_accesses,
|
||
cache_size_mb=args.cache_size,
|
||
num_cores=args.num_cores
|
||
)
|
||
|
||
# Print summary
|
||
print_summary(results)
|
||
|
||
# Plot results
|
||
if args.output_plot:
|
||
plot_results(results, args.output_plot)
|
||
|
||
# Save summary
|
||
if args.output_summary:
|
||
import json
|
||
summary = [r.__dict__ for r in results]
|
||
with open(args.output_summary, 'w') as f:
|
||
json.dump(summary, f, indent=2)
|
||
print(f"Summary saved to {args.output_summary}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|