- name
- hamilton-scale
- description
- Performance and parallelization patterns for Hamilton including async I/O, Spark, Ray, Dask, caching, and multithreading. Use for scaling Hamilton workflows.
- allowed-tools
- Read, Grep, Glob, Bash(python:*), Bash(pytest:*)
- user-invocable
- true
- disable-model-invocation
- false
<!-- SPDX-License-Identifier: Apache-2.0 -->
# Hamilton Scaling & Performance
This skill covers parallelization strategies and performance optimization for Apache Hamilton workflows.
## When to Scale
Choose your scaling strategy based on workload:
- **Async**: I/O-bound operations (API calls, database queries)
- **MultiThreading**: Synchronous I/O (legacy APIs without async support)
- **Spark**: Large datasets (multi-GB to multi-TB) on clusters
- **Ray/Dask**: Distributed Python computation
- **Caching**: Avoid redundant expensive computations
## Async Execution for I/O-Bound Workflows
**When to Use:**
- API calls (LLM providers, REST APIs, GraphQL)
- Database queries (async ORM operations)
- Dependent chains of I/O-bound calls
- Multiple parallel external service calls
**Key Benefits:**
- Automatic parallelization of independent async operations
- Natural expression of dependent chains
- Mix async and sync functions in the same DAG
- Significantly faster than sequential execution
**Basic Async Pattern:**
```python
from hamilton import async_driver
import aiohttp
from typing import List
# Mix async and sync functions
async def api_data(user_id: str) -> dict:
"""Fetch from API (async I/O)."""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.example.com/users/{user_id}") as resp:
return await resp.json()
def transform_data(api_data: dict) -> dict:
"""Transform data (sync CPU)."""
return {k: v.upper() if isinstance(v, str) else v for k, v in api_data.items()}
async def save_data(transform_data: dict) -> str:
"""Save to database (async I/O)."""
async with db_pool.acquire() as conn:
await conn.execute("INSERT INTO data VALUES ($1)", transform_data)
return "success"
# Use AsyncDriver
import my_async_module
dr = await async_driver.Builder().with_modules(my_async_module).build()
result = await dr.execute(["save_data"], inputs={"user_id": "123"})
```
**Parallel I/O Operations:**
```python
# These three operations execute in parallel automatically!
async def user_data(user_id: str) -> dict:
"""Fetch user data."""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.example.com/users/{user_id}") as resp:
return await resp.json()
async def user_orders(user_id: str) -> List[dict]:
"""Fetch user orders (parallel with user_data)."""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.example.com/orders?user={user_id}") as resp:
return await resp.json()
async def user_preferences(user_id: str) -> dict:
"""Fetch preferences (parallel with both above)."""
async with db_pool.acquire() as conn:
return await conn.fetchrow("SELECT * FROM preferences WHERE user_id=$1", user_id)
def user_profile(user_data: dict, user_orders: List[dict], user_preferences: dict) -> dict:
"""Combine all data (waits for all three to complete)."""
return {"data": user_data, "orders": user_orders, "preferences": user_preferences}
```
## MultiThreading for Sync I/O
For synchronous I/O-bound code (legacy APIs, blocking libraries):
```python
from hamilton import driver
from hamilton.execution import executors
# Use multithreading for sync I/O operations
dr = (
driver.Builder()
.with_modules(my_functions)
.with_local_executor(executors.MultiThreadingExecutor(max_tasks=10))
.build()
)
# Sync functions that do I/O will run concurrently
results = dr.execute(["final_output"], inputs={...})
```
**When to Use:**
- Synchronous I/O code (requests library, blocking DB drivers)
- Legacy APIs without async support
- Simple parallelization without code rewrite
## Scaling with Apache Spark
**When to Use Spark:**
- Dataset size exceeds single-machine memory (multi-GB to multi-TB)
- You have access to a Spark cluster
- Need distributed data processing
**Basic PySpark Pattern:**
```python
from pyspark.sql import DataFrame as SparkDataFrame, SparkSession
from hamilton.plugins import h_spark
def raw_data(spark_session: SparkSession) -> SparkDataFrame:
"""Load data into Spark."""
return spark_session.read.csv("data.csv", header=True)
def filtered_data(raw_data: SparkDataFrame) -> SparkDataFrame:
"""Filter using Spark operations."""
return raw_data.filter(raw_data.age > 18)
def aggregated_stats(filtered_data: SparkDataFrame) -> SparkDataFrame:
"""Aggregate using Spark."""
return filtered_data.groupBy("country").count()
# Driver Setup
dr = (
driver.Builder()
.with_modules(my_spark_functions)
.with_adapters(h_spark.SPARK_INPUT_CHECK)
.build()
)
result = dr.execute(["aggregated_stats"], inputs={"spark_session": spark})
```
**Column-Level Transformations with @with_columns:**
```python
from hamilton.plugins.h_spark import with_columns
import pandas as pd
# File: map_transforms.py
def normalized_amount(amount: pd.Series) -> pd.Series:
"""Pandas UDF for normalization."""
return (amount - amount.mean()) / amount.std()
def amount_category(normalized_amount: pd.Series) -> pd.Series:
"""Categorize based on normalized amount."""
return pd.cut(
normalized_amount,
bins=[-float("inf"), -1, 1, float("inf")],
labels=["low", "medium", "high"],
)
# Main dataflow
@with_columns(map_transforms, columns_to_pass=["amount"])
def enriched_data(raw_data: SparkDataFrame) -> SparkDataFrame:
"""Apply pandas UDFs to Spark DataFrame."""
return raw_data
```
**Spark Best Practices:**
1. Keep transformations lazy - Don't call `.collect()` until final nodes
2. Use `@with_columns` for map operations
3. Partition wisely between major operations
4. Cache DataFrames accessed multiple times
5. Test with small data first (`.limit(1000)`)
## Ray for Distributed Python
**When to Use Ray:**
- Distributed Python computation
- Machine learning workloads
- Custom parallelization logic
```python
from hamilton.plugins import h_ray
import ray
ray.init()
ray_executor = h_ray.RayGraphAdapter(result_builder={"base": dict})
dr = driver.Driver({}, my_functions, adapter=ray_executor)
results = dr.execute(["large_computation"], inputs={...})
```
## Dask for Parallel Computing
**When to Use Dask:**
- Pandas-like operations on larger-than-memory datasets
- Parallel computation on single machine or cluster
- Incremental migration from pandas
```python
from hamilton.plugins import h_dask
from dask import distributed
cluster = distributed.LocalCluster()
client = distributed.Client(cluster)
dask_executor = h_dask.DaskExecutor(client=client)
dr = driver.Builder().with_remote_executor(dask_executor).with_modules(my_functions).build()
```
## Caching for Performance
**When to Use Caching:**
- Expensive API calls (LLM inference, external services)
- Time-consuming data processing
- Iterative development in notebooks
- Shared preprocessing between pipelines
**Basic Caching Setup:**
```python
from hamilton import driver
# Enable caching
dr = driver.Builder().with_modules(my_functions).with_cache().build()
# First execution: computes and caches
result1 = dr.execute(["final_output"], inputs={"data_path": "data.csv"})
# Second execution: retrieves from cache (instant!)
result2 = dr.execute(["final_output"], inputs={"data_path": "data.csv"})
```
**Controlling Cache Behavior:**
```python
from hamilton.function_modifiers import cache
# Always recompute (for data loaders)
@cache(behavior="recompute")
def live_api_data(api_key: str) -> dict:
"""Always fetch fresh data."""
import requests
response = requests.get("https://api.example.com/data", headers={"Authorization": api_key})
return response.json()
# Never cache (for non-deterministic operations)
@cache(behavior="disable")
def random_sample(data: pd.DataFrame) -> pd.DataFrame:
"""Random sampling should not be cached."""
return data.sample(frac=0.1)
# Custom format for efficiency
@cache(format="parquet")
def large_dataframe(processed_data: pd.DataFrame) -> pd.DataFrame:
"""Store as parquet for efficiency."""
return processed_data
```
**Driver-Level Cache Control:**
```python
dr = (
driver.Builder()
.with_modules(my_functions)
.with_cache(
recompute=["raw_data"], # Always recompute these
disable=["random_sample"], # Never cache these
path="./my_cache", # Custom location
)
.build()
)
# Force complete refresh
dr = driver.Builder().with_modules(my_functions).with_cache(recompute=True).build()
```
**Cache Inspection:**
```python
# Visualize what was cached vs executed
dr.cache.view_run() # Green = cache hit, Orange = executed
# Access cached results
run_id = dr.cache.last_run_id
data_version = dr.cache.data_versions[run_id]["my_node"]
cached_result = dr.cache.result_store.get(data_version)
```
## Choosing the Right Scaling Strategy
**Decision Matrix:**
| Workload Type | Strategy | Use When |
|--------------|----------|----------|
| I/O-bound (async-capable) | AsyncDriver | Multiple API calls, async libraries available |
| I/O-bound (sync only) | MultiThreading | Legacy APIs, blocking I/O |
| Large datasets | Spark | Multi-GB/TB data, cluster available |
| Python computation | Ray/Dask | Custom parallel logic, ML workloads |
| Expensive operations | Caching | Repeated computations, LLM calls |
**Combining Strategies:**
- Cache + Async = Cache expensive API calls, parallelize independent calls
- Spark + Caching = Cache intermediate Spark results
- Ray + Caching = Distribute computation, cache results
## Performance Tips
1. **Profile first** - Identify bottlenecks before optimizing
2. **Start simple** - Begin with caching, add parallelization if needed
3. **Measure impact** - Benchmark before and after changes
4. **Consider costs** - Serialization overhead can negate parallelization benefits
5. **Test at scale** - Small data may hide parallelization overhead
## Additional Resources
- For basic Hamilton patterns, use `/hamilton-core`
- For LLM-specific workflows, use `/hamilton-llm`
- Apache Hamilton docs: hamilton.apache.org/concepts/parallel-task
View on GitHub