dashverse-srinivas / src /pipeline /streaming_processor.py
cheenchan's picture
RL-Enhanced Character Attribute Extraction Pipeline - Production Ready System with Decision Transformer, Ray Scaling, and Comprehensive Web Interface
2e88e30
Raw
History Blame Contribute Delete
19.9 kB
"""Streaming data processor for memory-efficient handling of large datasets."""
import logging
import time
import json
import csv
from typing import Iterator, Dict, Any, Optional, List, Callable
from pathlib import Path
import threading
import queue
from concurrent.futures import ThreadPoolExecutor, as_completed
import gc
import psutil
from dataclasses import asdict
import ray
from .base import PipelineStage, CharacterAttributes, ProcessingResult
from .input_loader import DatasetItem
from .retry_manager import RetryManager
logger = logging.getLogger(__name__)
class MemoryMonitor:
"""Monitor memory usage and trigger cleanup when needed."""
def __init__(self, max_memory_gb: float = 8.0):
self.max_memory_bytes = max_memory_gb * 1024 * 1024 * 1024
self.process = psutil.Process()
def get_memory_usage(self) -> Dict[str, float]:
"""Get current memory usage statistics."""
memory_info = self.process.memory_info()
return {
'rss_gb': memory_info.rss / (1024**3),
'vms_gb': memory_info.vms / (1024**3),
'percent': self.process.memory_percent()
}
def should_cleanup(self) -> bool:
"""Check if memory cleanup is needed."""
return self.process.memory_info().rss > self.max_memory_bytes
def force_cleanup(self):
"""Force garbage collection and memory cleanup."""
gc.collect()
logger.info(f"Memory cleanup performed. Current usage: {self.get_memory_usage()}")
class StreamingDataLoader:
"""Memory-efficient data loader for large datasets."""
def __init__(self, batch_size: int = 32, prefetch_size: int = 2):
self.batch_size = batch_size
self.prefetch_size = prefetch_size
def load_from_directory(self, directory_path: str,
file_extensions: List[str] = None) -> Iterator[DatasetItem]:
"""Stream dataset items from directory."""
if file_extensions is None:
file_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff']
directory = Path(directory_path)
if not directory.exists():
raise ValueError(f"Directory does not exist: {directory_path}")
# Use generator to avoid loading all paths into memory
for idx, file_path in enumerate(directory.rglob('*')):
if file_path.suffix.lower() in file_extensions:
# Check for corresponding text file
text_path = file_path.with_suffix('.txt')
text_file = str(text_path) if text_path.exists() else None
yield DatasetItem(
item_id=f"item_{idx}_{file_path.stem}",
image_path=str(file_path),
text_path=text_file
)
def load_from_manifest(self, manifest_path: str) -> Iterator[DatasetItem]:
"""Stream dataset items from manifest file."""
manifest_file = Path(manifest_path)
if manifest_file.suffix.lower() == '.json':
yield from self._load_from_json_manifest(manifest_file)
elif manifest_file.suffix.lower() == '.csv':
yield from self._load_from_csv_manifest(manifest_file)
elif manifest_file.suffix.lower() == '.txt':
yield from self._load_from_text_manifest(manifest_file)
else:
raise ValueError(f"Unsupported manifest format: {manifest_file.suffix}")
def _load_from_json_manifest(self, manifest_path: Path) -> Iterator[DatasetItem]:
"""Load from JSON Lines manifest."""
with open(manifest_path, 'r') as f:
for line_num, line in enumerate(f):
try:
data = json.loads(line.strip())
yield DatasetItem(
item_id=data.get('id', f"item_{line_num}"),
image_path=data['image_path'],
text_path=data.get('text_path')
)
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Skipping invalid line {line_num} in manifest: {e}")
def _load_from_csv_manifest(self, manifest_path: Path) -> Iterator[DatasetItem]:
"""Load from CSV manifest."""
with open(manifest_path, 'r') as f:
reader = csv.DictReader(f)
for row_num, row in enumerate(reader):
try:
yield DatasetItem(
item_id=row.get('id', f"item_{row_num}"),
image_path=row['image_path'],
text_path=row.get('text_path')
)
except KeyError as e:
logger.warning(f"Skipping invalid row {row_num} in CSV: missing {e}")
def _load_from_text_manifest(self, manifest_path: Path) -> Iterator[DatasetItem]:
"""Load from simple text file (one image path per line)."""
with open(manifest_path, 'r') as f:
for line_num, line in enumerate(f):
image_path = line.strip()
if image_path and Path(image_path).exists():
yield DatasetItem(
item_id=f"item_{line_num}",
image_path=image_path
)
def create_batches(self, data_stream: Iterator[DatasetItem]) -> Iterator[List[DatasetItem]]:
"""Create batches from data stream."""
batch = []
for item in data_stream:
batch.append(item)
if len(batch) >= self.batch_size:
yield batch
batch = []
# Yield remaining items
if batch:
yield batch
class StreamingProcessor(PipelineStage):
"""Streaming processor for memory-efficient large-scale processing."""
def __init__(self, config=None):
"""
Initializes the streaming processor.
Args:
config (dict): Configuration dictionary.
"""
if config:
self.pipeline = config.get("pipeline")
self.batch_size = config.get("batch_size", 16)
self.parallelism = config.get("parallelism", 4)
self.max_retries = config.get("max_retries", 3)
self.initial_retry_delay = config.get("initial_retry_delay", 1)
self.max_retry_delay = config.get("max_retry_delay", 60)
self.use_ray = config.get("use_ray", False)
else:
self.pipeline = None
self.batch_size = 16
self.parallelism = 4
self.max_retries = 3
self.initial_retry_delay = 1
self.max_retry_delay = 60
self.use_ray = False
if self.pipeline is None:
raise ValueError("A pipeline must be provided for processing.")
if self.use_ray:
if not ray.is_initialized():
ray.init(num_cpus=self.parallelism)
self.pipeline = ray.remote(self.pipeline)
self.retry_manager = RetryManager(
max_retries=self.max_retries,
initial_delay=self.initial_retry_delay,
max_delay=self.max_retry_delay,
)
def set_pipeline(self, pipeline):
"""Set the character extraction pipeline."""
self.pipeline = pipeline
def process_stream(self, data_source: str, output_path: str,
progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
"""Process data stream with memory-efficient streaming."""
if self.pipeline is None:
raise ValueError("Pipeline not set. Call set_pipeline() first.")
start_time = time.time()
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
# Determine data source type
source_path = Path(data_source)
if source_path.is_dir():
data_stream = self.data_loader.load_from_directory(data_source)
elif source_path.is_file():
data_stream = self.data_loader.load_from_manifest(data_source)
else:
raise ValueError(f"Invalid data source: {data_source}")
# Create batch stream
batch_stream = self.data_loader.create_batches(data_stream)
# Process batches
with open(output_file, 'w') as output_f:
self._write_output_header(output_f)
for batch_num, batch in enumerate(batch_stream):
try:
# Process batch
batch_results = self._process_batch(batch)
# Write results
for result in batch_results:
self._write_result(output_f, result)
if result.success:
self.success_count += 1
else:
self.error_count += 1
self.processed_count += 1
# Progress callback
if progress_callback:
progress_callback({
'processed': self.processed_count,
'success': self.success_count,
'errors': self.error_count,
'batch_num': batch_num
})
# Memory management
if self.memory_monitor.should_cleanup():
self.memory_monitor.force_cleanup()
# Checkpoint
if self.processed_count % self.checkpoint_interval == 0:
self._create_checkpoint(output_path, batch_num)
logger.info(f"Checkpoint: {self.processed_count} items processed")
except Exception as e:
logger.error(f"Failed to process batch {batch_num}: {e}")
self.error_count += len(batch)
self.processed_count += len(batch)
processing_time = time.time() - start_time
return {
'total_processed': self.processed_count,
'successful': self.success_count,
'errors': self.error_count,
'success_rate': self.success_count / self.processed_count if self.processed_count > 0 else 0,
'processing_time': processing_time,
'throughput': self.processed_count / processing_time if processing_time > 0 else 0,
'memory_usage': self.memory_monitor.get_memory_usage(),
'output_file': str(output_file)
}
def _process_batch(self, batch: List[DatasetItem]) -> List[ProcessingResult]:
"""Process a batch of items using thread pool."""
results = []
with ThreadPoolExecutor(max_workers=self.num_workers) as executor:
# Submit all items in batch
future_to_item = {
executor.submit(self._process_single_item, item): item
for item in batch
}
# Collect results as they complete
for future in as_completed(future_to_item):
item = future_to_item[future]
try:
result = future.result()
results.append(result)
except Exception as e:
logger.error(f"Failed to process {item.item_id}: {e}")
results.append(ProcessingResult(
item_id=item.item_id,
attributes=CharacterAttributes(),
success=False,
error_message=str(e)
))
return results
def _process_single_item(self, item: DatasetItem) -> ProcessingResult:
"""Process a single dataset item."""
start_time = time.time()
try:
# Extract attributes using pipeline
attributes = self.pipeline.extract_from_image(item.image_path)
processing_time = time.time() - start_time
return ProcessingResult(
item_id=item.item_id,
attributes=attributes,
success=True,
processing_time=processing_time
)
except Exception as e:
processing_time = time.time() - start_time
return ProcessingResult(
item_id=item.item_id,
attributes=CharacterAttributes(),
success=False,
error_message=str(e),
processing_time=processing_time
)
def _write_output_header(self, file_handle):
"""Write output file header based on format."""
if self.output_format == 'csv':
writer = csv.writer(file_handle)
writer.writerow([
'item_id', 'success', 'age', 'gender', 'ethnicity',
'hair_style', 'hair_color', 'hair_length', 'eye_color',
'body_type', 'dress', 'confidence_score', 'processing_time', 'error_message'
])
def _write_result(self, file_handle, result: ProcessingResult):
"""Write processing result to output file."""
if self.output_format == 'jsonl':
# JSON Lines format
result_dict = {
'item_id': result.item_id,
'success': result.success,
'attributes': asdict(result.attributes),
'processing_time': result.processing_time,
'error_message': result.error_message
}
file_handle.write(json.dumps(result_dict) + '\n')
elif self.output_format == 'csv':
# CSV format
writer = csv.writer(file_handle)
attrs = result.attributes
writer.writerow([
result.item_id, result.success, attrs.age, attrs.gender, attrs.ethnicity,
attrs.hair_style, attrs.hair_color, attrs.hair_length, attrs.eye_color,
attrs.body_type, attrs.dress, attrs.confidence_score,
result.processing_time, result.error_message
])
def _create_checkpoint(self, output_path: str, batch_num: int):
"""Create processing checkpoint."""
checkpoint_data = {
'processed_count': self.processed_count,
'success_count': self.success_count,
'error_count': self.error_count,
'batch_num': batch_num,
'timestamp': time.time(),
'memory_usage': self.memory_monitor.get_memory_usage()
}
checkpoint_path = f"{output_path}.checkpoint_{self.processed_count}.json"
with open(checkpoint_path, 'w') as f:
json.dump(checkpoint_data, f, indent=2)
def estimate_processing_time(self, data_source: str, sample_size: int = 100) -> Dict[str, Any]:
"""Estimate processing time for full dataset based on sample."""
if self.pipeline is None:
raise ValueError("Pipeline not set. Call set_pipeline() first.")
# Load sample data
source_path = Path(data_source)
if source_path.is_dir():
data_stream = self.data_loader.load_from_directory(data_source)
elif source_path.is_file():
data_stream = self.data_loader.load_from_manifest(data_source)
else:
raise ValueError(f"Invalid data source: {data_source}")
# Process sample
sample_items = []
for i, item in enumerate(data_stream):
if i >= sample_size:
break
sample_items.append(item)
if not sample_items:
return {'error': 'No items found in data source'}
# Time sample processing
start_time = time.time()
sample_results = self._process_batch(sample_items)
sample_time = time.time() - start_time
# Calculate metrics
successful_results = [r for r in sample_results if r.success]
avg_time_per_item = sample_time / len(sample_items)
success_rate = len(successful_results) / len(sample_items)
# Estimate full dataset size
if source_path.is_dir():
# Count files in directory
file_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff']
total_files = sum(1 for f in source_path.rglob('*') if f.suffix.lower() in file_extensions)
else:
# Estimate from manifest file size
with open(source_path, 'r') as f:
total_files = sum(1 for _ in f)
# Projections
estimated_total_time = total_files * avg_time_per_item
estimated_total_hours = estimated_total_time / 3600
estimated_total_days = estimated_total_hours / 24
return {
'sample_size': len(sample_items),
'sample_processing_time': sample_time,
'avg_time_per_item': avg_time_per_item,
'success_rate': success_rate,
'estimated_total_files': total_files,
'estimated_total_time_seconds': estimated_total_time,
'estimated_total_time_hours': estimated_total_hours,
'estimated_total_time_days': estimated_total_days,
'throughput_items_per_second': 1 / avg_time_per_item if avg_time_per_item > 0 else 0,
'memory_usage': self.memory_monitor.get_memory_usage(),
'recommendations': self._generate_processing_recommendations(total_files, avg_time_per_item)
}
def _generate_processing_recommendations(self, total_files: int, avg_time_per_item: float) -> List[str]:
"""Generate recommendations for processing optimization."""
recommendations = []
if total_files > 1_000_000:
recommendations.append("Consider distributed processing with Ray for datasets > 1M items")
if avg_time_per_item > 1.0:
recommendations.append("Processing time > 1s per item - consider GPU acceleration")
if total_files > 100_000:
recommendations.append("Enable Redis caching for large datasets")
recommendations.append("Use database sharding for better performance")
memory_usage = self.memory_monitor.get_memory_usage()
if memory_usage['percent'] > 80:
recommendations.append("High memory usage detected - reduce batch size")
recommendations.extend([
"Monitor memory usage during processing",
"Use checkpointing for long-running jobs",
"Consider preprocessing to filter out edge cases"
])
return recommendations
def process(self, input_data: Any) -> Any:
"""Process streaming data."""
if isinstance(input_data, dict):
operation = input_data.get('operation')
if operation == 'process_stream':
return self.process_stream(
input_data['data_source'],
input_data['output_path'],
input_data.get('progress_callback')
)
elif operation == 'estimate':
return self.estimate_processing_time(
input_data['data_source'],
input_data.get('sample_size', 100)
)
raise ValueError("StreamingProcessor expects operation dict as input")
def validate_input(self, input_data: Any) -> bool:
"""Validate input data."""
return isinstance(input_data, dict) and 'operation' in input_data