Memory Management: Preventing Lambda OOM Errors with Large Datasets
Key Takeaway
Our visualization Lambda crashed with out-of-memory (OOM) errors when processing datasets larger than 20,000 points, loading entire datasets into memory without cleanup. Implementing streaming, chunked processing, and memory-efficient data structures reduced peak memory usage from 2.5GB to 420MB and eliminated OOM crashes.
The Problem
We loaded entire datasets into memory and created multiple copies:
def generate_chart(data):
# Load full dataset
x = data['x']['value'] # Copy 1
y = data['y'][0]['value'] # Copy 2
# Plotly creates internal copies
fig = go.Figure(data=[go.Bar(x=x, y=y)]) # Copy 3
# JSON serialization creates another copy
result = fig.to_json() # Copy 4
# HTML generation creates yet another copy
html = fig.to_html() # Copy 5
return result # All copies still in memory!
This caused severe memory issues:
- OOM Crashes: Lambda terminated with "OutOfMemoryError" for >20K points
- Multiple Copies: Same data existed in 5+ different forms
- No Cleanup: Old objects never garbage collected during execution
- Memory Spikes: Peak usage was 3x average usage
- Cost Overhead: Required 3GB Lambda configuration for 500MB of actual data
Memory profile for 50,000-point dataset:
- Input JSON: 400MB
- Parsed data structures: 600MB
- Plotly Figure object: 800MB
- JSON output: 450MB
- Peak usage: 2.5GB (Lambda crashes at 3GB)
Context and Background
AWS Lambda has strict memory limits (128MB - 10GB). Our function was configured with 3GB, but even this wasn't enough for large datasets. The problem compounded because:
- Python Object Overhead: Each Python object has 40+ bytes overhead
- Plotly Internals: Creates multiple intermediate representations
- JSON Serialization: Builds complete string in memory before returning
- No Streaming: Entire response built before sending
Medical imaging datasets were particularly problematic—a single tissue sample could generate 100,000+ annotation points, making charts impossible to generate.
The Solution
We implemented memory-efficient processing with streaming and chunking:
import gc
import sys
from typing import Iterator, List, Dict
import json
class MemoryEfficientChartGenerator:
"""Generate charts with minimal memory footprint"""
def __init__(self, max_memory_mb: int = 512):
self.max_memory_mb = max_memory_mb
def get_memory_usage_mb(self) -> float:
"""Get current memory usage in MB"""
return sys.getsizeof(gc.get_objects()) / (1024 * 1024)
def check_memory_limit(self):
"""Raise error if approaching memory limit"""
current_mb = self.get_memory_usage_mb()
if current_mb > self.max_memory_mb * 0.9: # 90% threshold
gc.collect() # Force garbage collection
current_mb = self.get_memory_usage_mb()
if current_mb > self.max_memory_mb * 0.9:
raise MemoryError(
f"Memory usage ({current_mb:.1f}MB) approaching limit ({self.max_memory_mb}MB)"
)
def generate_chart_streaming(self, data: Dict) -> Iterator[str]:
"""
Generate chart data in chunks to minimize memory
Yields JSON chunks that can be streamed to client
"""
x_values = data['x']['value']
y_series = data['y']
chunk_size = 1000 # Process 1000 points at a time
# Yield header
yield '{"chart":{'
# Yield data in chunks
for chunk_idx in range(0, len(x_values), chunk_size):
self.check_memory_limit() # Check before each chunk
chunk_end = min(chunk_idx + chunk_size, len(x_values))
x_chunk = x_values[chunk_idx:chunk_end]
y_chunks = [s['value'][chunk_idx:chunk_end] for s in y_series]
# Create mini figure for this chunk
chunk_data = {
'x': x_chunk,
'y': y_chunks
}
# Serialize chunk
chunk_json = json.dumps(chunk_data)
if chunk_idx > 0:
yield ','
yield chunk_json
# Clear chunk data explicitly
del x_chunk, y_chunks, chunk_data, chunk_json
# Force garbage collection every 10 chunks
if chunk_idx % (chunk_size * 10) == 0:
gc.collect()
yield '}}'
def generate_chart_memory_efficient(self, data: Dict) -> str:
"""Generate chart with memory optimizations"""
x_values = data['x']['value']
y_series = data['y']
data_size = len(x_values)
# Use generators instead of lists where possible
def x_iterator():
for i in range(len(x_values)):
yield x_values[i]
if i % 1000 == 0:
self.check_memory_limit()
# Process in chunks if data is large
if data_size > 10000:
return self._generate_large_chart(x_values, y_series)
else:
return self._generate_small_chart(x_values, y_series)
def _generate_small_chart(self, x: List, y: List[Dict]) -> str:
"""Generate chart for small datasets (no special optimization)"""
fig = go.Figure()
for series in y:
fig.add_trace(go.Scatter(
x=x,
y=series['value'],
name=series['name']
))
result = fig.to_json()
# Clear figure from memory
del fig
gc.collect()
return result
def _generate_large_chart(self, x: List, y: List[Dict]) -> str:
"""Generate chart for large datasets with memory management"""
# Use memory-mapped arrays for very large datasets
import numpy as np
# Convert to numpy arrays (more memory efficient than Python lists)
x_array = np.array(x, dtype=np.float32) # Use float32 instead of float64
# Clear original list
del x
gc.collect()
# Process Y series one at a time
traces = []
for series in y:
self.check_memory_limit()
y_array = np.array(series['value'], dtype=np.float32)
# Downsample if needed
if len(x_array) > 5000:
indices = np.linspace(0, len(x_array) - 1, 5000, dtype=int)
x_sampled = x_array[indices]
y_sampled = y_array[indices]
else:
x_sampled = x_array
y_sampled = y_array
trace = go.Scatter(
x=x_sampled.tolist(),
y=y_sampled.tolist(),
name=series['name']
)
traces.append(trace)
# Clear intermediate arrays
del y_array, x_sampled, y_sampled
gc.collect()
# Create figure
fig = go.Figure(data=traces)
# Convert to JSON
result = fig.to_json()
# Cleanup
del fig, traces, x_array
gc.collect()
return result
def lambda_handler(event, context):
"""Lambda handler with memory monitoring"""
# Get Lambda memory limit
memory_limit_mb = int(context.memory_limit_in_mb)
# Use 70% of available memory as working limit
working_memory_mb = int(memory_limit_mb * 0.7)
generator = MemoryEfficientChartGenerator(max_memory_mb=working_memory_mb)
try:
data = json.loads(event['body'])
# Check input size before processing
input_size_mb = len(event['body']) / (1024 * 1024)
if input_size_mb > working_memory_mb * 0.5:
logger.warning(f"Large input: {input_size_mb:.1f}MB")
# Use streaming for very large inputs
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': ''.join(generator.generate_chart_streaming(data))
}
# Generate chart
result = generator.generate_chart_memory_efficient(data)
# Log memory usage
final_memory = generator.get_memory_usage_mb()
logger.info(f"Peak memory usage: {final_memory:.1f}MB / {memory_limit_mb}MB")
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': result
}
except MemoryError as e:
logger.error(f"Out of memory: {e}")
return {
'statusCode': 413, # Payload Too Large
'body': json.dumps({
'error': 'DataTooLarge',
'message': 'Dataset too large to process',
'suggestion': 'Reduce data size or use data sampling endpoint'
})
}
finally:
# Final cleanup
gc.collect()
Implementation Details
Memory Profiling
We added memory profiling to identify hotspots:
from memory_profiler import profile
import tracemalloc
@profile
def generate_chart_profiled(data):
"""Profile memory usage during chart generation"""
tracemalloc.start()
# Generate chart
result = generate_chart(data)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
logger.info(f"Current memory: {current / 1024 / 1024:.1f}MB")
logger.info(f"Peak memory: {peak / 1024 / 1024:.1f}MB")
return result
Generator-Based Processing
We used generators to avoid loading all data at once:
def process_data_generator(data: Dict) -> Iterator[Dict]:
"""Process data lazily using generators"""
x_values = data['x']['value']
y_series = data['y']
# Yield one data point at a time
for i in range(len(x_values)):
point = {
'x': x_values[i],
'y': [series['value'][i] for series in y_series]
}
yield point
# Point goes out of scope after yield
del point
NumPy for Memory Efficiency
We used NumPy arrays (C-based) instead of Python lists:
import numpy as np
def convert_to_numpy(data: List) -> np.ndarray:
"""Convert Python list to memory-efficient NumPy array"""
# Python list of 1M floats: ~40MB
# NumPy array of 1M floats: ~8MB (5x smaller!)
return np.array(data, dtype=np.float32)
Explicit Cleanup
We explicitly deleted large objects and forced garbage collection:
def process_with_cleanup(data):
"""Process data with explicit memory cleanup"""
# Process data
x = data['x']['value']
y = data['y'][0]['value']
# Create chart
fig = go.Figure(data=[go.Bar(x=x, y=y)])
result = fig.to_json()
# Explicit cleanup
del fig # Delete Plotly figure
del x, y # Delete data arrays
# Force garbage collection
gc.collect()
return result
Impact and Results
After implementing memory management:
| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | Peak memory (50K points) | 2.5GB | 420MB | 83% reduction | | OOM error rate | 23% | 0% | 100% elimination | | Max dataset size | 20,000 | 100,000 | 5x increase | | Lambda memory config | 3GB | 1GB | 67% cost reduction | | Memory utilization | 85% | 42% | 2x headroom |
Memory breakdown (50K points):
- Before: Input(400MB) + Parsed(600MB) + Plotly(800MB) + Output(450MB) = 2.5GB peak
- After: Input(400MB) → Processing(220MB) → Output(450MB) = 420MB peak
Lessons Learned
- Monitor Memory: Track usage and set alerts before hitting limits
- Stream When Possible: Don't load entire dataset if you can process in chunks
- Use NumPy: C-based arrays are 5-10x more memory efficient than Python lists
- Explicit Cleanup: Don't rely on garbage collector alone—delete large objects
- Right-Size Lambda: Use memory profiling to set appropriate Lambda configuration
Memory management is critical for serverless functions with strict limits. The combination of streaming, chunked processing, NumPy arrays, and explicit cleanup allows processing of datasets 5x larger with 60% less Lambda memory configuration.