Xarray Chunking Best Practices - dask_setup Recipe

This notebook demonstrates intelligent chunking strategies for xarray datasets, implementing the 256-512 MiB per chunk rule and comparing different approaches for optimal memory usage and performance.

Key Learning Points

  • How to choose optimal chunk sizes for your data

  • When to use .chunk() vs rechunk_dataset()

  • Memory-efficient chunking patterns

  • Impact of chunk size on performance

  • Bad vs good chunking patterns

Setup and Imports

Import necessary libraries and configure the environment.

import time
import warnings
from typing import Dict, List, Optional, Tuple

# Core imports
import numpy as np
import dask
import dask.array as da
import xarray as xr

from dask_setup import recommend_chunks, setup_dask_client, validate_chunks
import pandas as pd

# Optional dependencies
try:
    import rechunker
    RECHUNKER_AVAILABLE = True
    print("✅ rechunker available")
except ImportError:
    RECHUNKER_AVAILABLE = False
    print("⚠️  rechunker not available - some features will be skipped")

try:
    import matplotlib.pyplot as plt
    PLOTTING_AVAILABLE = True
    print("✅ matplotlib available for plotting")
except ImportError:
    PLOTTING_AVAILABLE = False
    print("⚠️  matplotlib not available - plotting disabled")

print("\n🚀 Setup complete!")
⚠️  rechunker not available - some features will be skipped
✅ matplotlib available for plotting

🚀 Setup complete!

Create Sample Dataset

Let’s create a sample climate-like dataset to work with.

def create_sample_dataset(shape=(365, 180, 360), dims=('time', 'lat', 'lon'), variables=None):
    """Create a sample xarray dataset for chunking demonstrations."""
    if variables is None:
        variables = ['temperature', 'precipitation']
    
    print(f"Creating sample dataset with shape: {shape}")
    print(f"Dimensions: {dims}")
    print(f"Variables: {variables}")
    
    # Create coordinates
    coords = {}
    if 'time' in dims:
        time_idx = dims.index('time')
        coords['time'] = pd.date_range('2000-01-01', periods=shape[time_idx], freq='D')
    
    if 'lat' in dims:
        lat_idx = dims.index('lat')
        coords['lat'] = np.linspace(-90, 90, shape[lat_idx])
        
    if 'lon' in dims:
        lon_idx = dims.index('lon')
        coords['lon'] = np.linspace(-180, 180, shape[lon_idx])
    
    # Create data variables
    data_vars = {}
    for var in variables:
        # Create realistic-looking data
        if var == 'temperature':
            data = da.random.random(shape, chunks=-1) * 50 - 10 + 273.15  # Kelvin (one chunk)
            attrs = {'units': 'K', 'long_name': 'Near-surface air temperature'}
        elif var == 'precipitation':
            data = da.random.exponential(scale=2.0, size=shape, chunks=-1)  # mm/day
            attrs = {'units': 'mm/day', 'long_name': 'Daily precipitation'}
        else:
            data = da.random.random(shape, chunks=-1)
            attrs = {'units': 'unknown', 'long_name': f'Sample variable {var}'}
        
        data_vars[var] = (dims, data, attrs)
    
    # Create dataset
    ds = xr.Dataset(data_vars, coords=coords)
    
    # Add global attributes
    ds.attrs['title'] = 'Sample climate dataset for chunking demonstrations'
    ds.attrs['created_by'] = 'dask_setup xarray chunking recipe'
    
    return ds

# Create our sample dataset
dataset_shape = (365, 180, 360)  # One year of daily global data
ds = create_sample_dataset(
    shape=dataset_shape,
    dims=('time', 'lat', 'lon'),
    variables=['temperature', 'precipitation']
)

print(f"\n📊 Dataset Information:")
print(f"   Shape: {ds.temperature.shape}")
print(f"   Size: {ds.nbytes / (1024**2):.1f} MB")
print(f"   Variables: {list(ds.data_vars.keys())}")
print(f"   Coordinates: {list(ds.coords.keys())}")

# Display the dataset
print("\nDataset summary:")
print(ds)

# The real-world target is 256-512 MiB per chunk, but this laptop-sized dataset is
# only ~180 MiB per variable, so at that target every strategy would say "one chunk".
# Scale the target down so the strategies have something to compare. On real data,
# use the default target_chunk_mb=(256, 512).
TARGET_MB = (32, 64)
print(f"\nDemo chunk target: {TARGET_MB[0]}-{TARGET_MB[1]} MiB")
Creating sample dataset with shape: (365, 180, 360)
Dimensions: ('time', 'lat', 'lon')
Variables: ['temperature', 'precipitation']

📊 Dataset Information:
   Shape: (365, 180, 360)
   Size: 360.9 MB
   Variables: ['temperature', 'precipitation']
   Coordinates: ['time', 'lat', 'lon']

Dataset summary:
<xarray.Dataset> Size: 378MB
Dimensions:        (time: 365, lat: 180, lon: 360)
Coordinates:
  * time           (time) datetime64[us] 3kB 2000-01-01 ... 2000-12-30
  * lat            (lat) float64 1kB -90.0 -88.99 -87.99 ... 87.99 88.99 90.0
  * lon            (lon) float64 3kB -180.0 -179.0 -178.0 ... 178.0 179.0 180.0
Data variables:
    temperature    (time, lat, lon) float64 189MB dask.array<chunksize=(365, 180, 360), meta=np.ndarray>
    precipitation  (time, lat, lon) float64 189MB dask.array<chunksize=(365, 180, 360), meta=np.ndarray>
Attributes:
    title:       Sample climate dataset for chunking demonstrations
    created_by:  dask_setup xarray chunking recipe

Demo chunk target: 32-64 MiB

Chunk Recommendations from dask_setup

recommend_chunks() sizes chunks from the dataset and the workers’ memory, and shapes them for the workload type, matching the topology setup_dask_client() would start:

  • "cpu": square-ish blocks, good for computation

  • "io": keep each time step whole and slice along time, good for streaming reads and writes

  • "mixed": split spatial dimensions first, keep time long

Dimensions left out of the result stay whole.

chunk_strategies = {}
for workload in ["cpu", "io", "mixed"]:
    rec = recommend_chunks(ds, workload_type=workload, target_chunk_mb=TARGET_MB, verbose=False)
    # Spell out dimensions left whole, so every strategy lists all three
    chunk_strategies[f"optimal_{workload}"] = {dim: rec.get(dim, size) for dim, size in ds.sizes.items()}

print("📋 dask_setup's recommendations:")
for strategy, chunks in chunk_strategies.items():
    print(f"   {strategy:15s}: {chunks}")
📋 dask_setup's recommendations:
   optimal_cpu    : {'time': 182, 'lat': 180, 'lon': 180}
   optimal_io     : {'time': 129, 'lat': 180, 'lon': 360}
   optimal_mixed  : {'time': 365, 'lat': 90, 'lon': 180}

Chunking Analysis and Comparison

Let’s analyze different chunking options and compare their properties.

def estimate_chunk_memory(ds, chunks=None):
    """Estimate memory usage for each variable in the dataset."""
    estimates = {}
    
    for var_name, var in ds.data_vars.items():
        if chunks:
            # Apply chunking to get chunk info
            var_chunked = var.chunk(chunks)
            if hasattr(var_chunked.data, 'chunks'):
                # Calculate actual chunk sizes
                chunk_shapes = [tuple(c[0] if len(c) == 1 else max(c) for c in var_chunked.data.chunks)]
                if chunk_shapes:
                    chunk_elements = np.prod(chunk_shapes[0])
                    chunk_bytes = chunk_elements * np.dtype(var.dtype).itemsize
                    chunk_mb = chunk_bytes / (1024**2)
                    total_chunks = var_chunked.data.npartitions
                    total_mb = chunk_mb * total_chunks
                else:
                    chunk_mb = var.nbytes / (1024**2)
                    total_chunks = 1
                    total_mb = chunk_mb
            else:
                chunk_mb = var.nbytes / (1024**2)
                total_chunks = 1 
                total_mb = chunk_mb
        else:
            # No chunking - single chunk
            chunk_mb = var.nbytes / (1024**2)
            total_chunks = 1
            total_mb = chunk_mb
        
        estimates[var_name] = {
            'chunk_mb': chunk_mb,
            'total_chunks': total_chunks,
            'total_mb': total_mb
        }
    
    return estimates


def analyze_chunking_options(ds: xr.Dataset, custom_chunks=None) -> Dict[str, Dict]:
    """Analyze different chunking options for a dataset."""
    
    # Define chunking options to test
    chunk_options = {
        **chunk_strategies,  # Our calculated optimal chunks
        "small_chunks": {dim: min(100, size // 10) for dim, size in ds.sizes.items()},
        "large_chunks": {dim: min(1000, size // 2) for dim, size in ds.sizes.items()},
        "time_continuous": {'time': ds.sizes['time'], 'lat': 90, 'lon': 180},
        "spatial_focus": {'time': 30, 'lat': 180, 'lon': 360}
    }
    
    if custom_chunks:
        chunk_options['custom'] = custom_chunks
    
    print("\n" + "=" * 60)
    print("📊 CHUNKING OPTIONS ANALYSIS")
    print("=" * 60)
    
    results = {}
    
    for option_name, chunks in chunk_options.items():
        print(f"\n🔍 Analyzing: {option_name}")
        
        try:
            # Estimate memory usage
            memory_estimates = estimate_chunk_memory(ds, chunks)
            
            # Calculate totals
            total_chunks = sum(est['total_chunks'] for est in memory_estimates.values())
            total_memory_mb = sum(est['total_mb'] for est in memory_estimates.values())
            avg_chunk_mb = np.mean([est['chunk_mb'] for est in memory_estimates.values()])
            
            # Assess chunk size quality
            lo, hi = TARGET_MB
            if lo <= avg_chunk_mb <= hi:
                quality = "✅ Optimal"
            elif lo / 2 <= avg_chunk_mb <= hi * 2:
                quality = "🟡 Acceptable"
            elif avg_chunk_mb < lo / 2:
                quality = "🔴 Too small (overhead issues)"
            else:
                quality = "🔴 Too large (memory issues)"
            
            results[option_name] = {
                'chunks': chunks,
                'total_chunks': total_chunks,
                'total_memory_mb': total_memory_mb,
                'avg_chunk_mb': avg_chunk_mb,
                'quality': quality,
                'memory_estimates': memory_estimates
            }
            
            print(f"   Chunks: {chunks}")
            print(f"   Total chunks: {total_chunks}")
            print(f"   Avg chunk size: {avg_chunk_mb:.1f} MB")
            print(f"   Total memory: {total_memory_mb:.1f} MB")
            print(f"   Quality: {quality}")
            
        except Exception as e:
            print(f"   ❌ Analysis failed: {e}")
            results[option_name] = {
                'error': str(e),
                'quality': '❌ Failed'
            }
    
    return results

# Analyze our chunking options
analysis_results = analyze_chunking_options(ds)

print("\n📋 Analysis Summary:")
for strategy, result in analysis_results.items():
    if 'error' not in result:
        print(f"   {strategy:20s} | {result['avg_chunk_mb']:6.1f} MB | {result['quality']}")
============================================================
📊 CHUNKING OPTIONS ANALYSIS
============================================================

🔍 Analyzing: optimal_cpu
   Chunks: {'time': 182, 'lat': 180, 'lon': 180}
   Total chunks: 12
   Avg chunk size: 45.0 MB
   Total memory: 539.9 MB
   Quality: ✅ Optimal

🔍 Analyzing: optimal_io
   Chunks: {'time': 129, 'lat': 180, 'lon': 360}
   Total chunks: 6
   Avg chunk size: 63.8 MB
   Total memory: 382.7 MB
   Quality: ✅ Optimal

🔍 Analyzing: optimal_mixed
   Chunks: {'time': 365, 'lat': 90, 'lon': 180}
   Total chunks: 8
   Avg chunk size: 45.1 MB
   Total memory: 360.9 MB
   Quality: ✅ Optimal

🔍 Analyzing: small_chunks
   Chunks: {'time': 36, 'lat': 18, 'lon': 36}
   Total chunks: 2200
   Avg chunk size: 0.2 MB
   Total memory: 391.6 MB
   Quality: 🔴 Too small (overhead issues)

🔍 Analyzing: large_chunks
   Chunks: {'time': 182, 'lat': 90, 'lon': 180}
   Total chunks: 24
   Avg chunk size: 22.5 MB
   Total memory: 539.9 MB
   Quality: 🟡 Acceptable

🔍 Analyzing: time_continuous
   Chunks: {'time': 365, 'lat': 90, 'lon': 180}
   Total chunks: 8
   Avg chunk size: 45.1 MB
   Total memory: 360.9 MB
   Quality: ✅ Optimal

🔍 Analyzing: spatial_focus
   Chunks: {'time': 30, 'lat': 180, 'lon': 360}
   Total chunks: 26
   Avg chunk size: 14.8 MB
   Total memory: 385.6 MB
   Quality: 🔴 Too small (overhead issues)

📋 Analysis Summary:
   optimal_cpu          |   45.0 MB | ✅ Optimal
   optimal_io           |   63.8 MB | ✅ Optimal
   optimal_mixed        |   45.1 MB | ✅ Optimal
   small_chunks         |    0.2 MB | 🔴 Too small (overhead issues)
   large_chunks         |   22.5 MB | 🟡 Acceptable
   time_continuous      |   45.1 MB | ✅ Optimal
   spatial_focus        |   14.8 MB | 🔴 Too small (overhead issues)

Performance Benchmarking

Let’s benchmark the performance of different chunking strategies using actual computations.

# Set up a Dask client for benchmarking
client, cluster, temp_dir = setup_dask_client(
    workload_type="mixed",
    max_workers=4,
    dashboard=True
)

print(f"✅ Cluster created with {len(client.nthreads())} workers")
print(f"   Dashboard: {client.dashboard_link}")
print(f"   Temp directory: {temp_dir}")


def benchmark_chunking_performance(
    ds: xr.Dataset,
    chunk_options: Dict[str, Dict[str, int]],
    operation: str = "mean"
) -> Dict[str, float]:
    """Benchmark performance of different chunking strategies."""
    
    print(f"\n⚡ PERFORMANCE BENCHMARK - {operation.upper()} OPERATION")
    print("=" * 50)
    
    results = {}
    
    for option_name, chunks in chunk_options.items():
        if chunks is None or 'error' in str(chunks):
            continue
            
        print(f"\n🏃 Testing: {option_name}")
        
        try:
            # Apply chunking
            chunked_ds = ds.chunk(chunks)
            
            # Select temperature variable for testing
            data_var = chunked_ds['temperature']
            
            # Define the operation
            if operation == "mean":
                computation = lambda: data_var.mean().compute()
            elif operation == "sum":
                computation = lambda: data_var.sum().compute()
            elif operation == "std":
                computation = lambda: data_var.std().compute()
            elif operation == "spatial_mean":
                computation = lambda: data_var.mean(['lat', 'lon']).compute()
            else:
                print(f"   Unknown operation: {operation}")
                continue
            
            # Warm up (first run can be slower)
            print("   Warming up...")
            try:
                _ = computation()
            except Exception as e:
                print(f"   Warmup failed: {e}")
                continue
            
            # Benchmark the operation
            print("   Running benchmark...")
            start_time = time.time()
            result = computation()
            elapsed_time = time.time() - start_time
            
            results[option_name] = elapsed_time
            # spatial_mean returns a time series; summarise it as one number
            print(f"   Result: {float(result.mean()):.6f}")
            print(f"   Time: {elapsed_time:.3f}s")
            
        except Exception as e:
            print(f"   ❌ Benchmark failed: {e}")
            results[option_name] = float('inf')
    
    return results


# Get valid chunking options (exclude errors)
valid_chunk_options = {
    k: v['chunks'] for k, v in analysis_results.items() 
    if 'error' not in v and v['chunks'] is not None
}

# Benchmark different operations
operations = ['mean', 'spatial_mean', 'std']
benchmark_results = {}

for operation in operations:
    print(f"\n🎯 Benchmarking {operation} operation...")
    benchmark_results[operation] = benchmark_chunking_performance(
        ds, valid_chunk_options, operation
    )

# Display benchmark summary
print("\n" + "=" * 60)
print("🏁 BENCHMARK SUMMARY")
print("=" * 60)

for operation, results in benchmark_results.items():
    print(f"\n{operation.upper()} Operation:")
    valid_results = {k: v for k, v in results.items() if v != float('inf')}
    if valid_results:
        best_time = min(valid_results.values())
        sorted_results = sorted(valid_results.items(), key=lambda x: x[1])
        
        for strategy, time_s in sorted_results:
            relative_speed = time_s / best_time
            print(f"   {strategy:20s} | {time_s:6.3f}s | {relative_speed:4.2f}x")
    else:
        print("   No valid results")
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-2802
Workers: 2 | threads/worker: 2 | processes: True
Memory: total ~15.6 GiB | usable ~11.6 GiB | per-worker ~5.8 GiB
Compression: spill=auto | comm=False
✅ Cluster created with 2 workers
   Dashboard: http://127.0.0.1:35981/status
   Temp directory: /tmp/dask-2802

🎯 Benchmarking mean operation...

⚡ PERFORMANCE BENCHMARK - MEAN OPERATION
==================================================

🏃 Testing: optimal_cpu
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.350s

🏃 Testing: optimal_io
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.714s

🏃 Testing: optimal_mixed
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.346s

🏃 Testing: small_chunks
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 3.045s

🏃 Testing: large_chunks
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.382s

🏃 Testing: time_continuous
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.338s

🏃 Testing: spatial_focus
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.398s

🎯 Benchmarking spatial_mean operation...

⚡ PERFORMANCE BENCHMARK - SPATIAL_MEAN OPERATION
==================================================

🏃 Testing: optimal_cpu
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.353s

🏃 Testing: optimal_io
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.359s

🏃 Testing: optimal_mixed
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.338s

🏃 Testing: small_chunks
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 3.378s

🏃 Testing: large_chunks
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.391s

🏃 Testing: time_continuous
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.339s

🏃 Testing: spatial_focus
   Warming up...
   Running benchmark...
   Result: 288.149274
   Time: 0.357s

🎯 Benchmarking std operation...

⚡ PERFORMANCE BENCHMARK - STD OPERATION
==================================================

🏃 Testing: optimal_cpu
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 0.423s

🏃 Testing: optimal_io
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 0.461s

🏃 Testing: optimal_mixed
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 0.432s

🏃 Testing: small_chunks
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 3.179s

🏃 Testing: large_chunks
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 0.450s

🏃 Testing: time_continuous
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 0.411s

🏃 Testing: spatial_focus
   Warming up...
   Running benchmark...
   Result: 14.432787
   Time: 0.462s

============================================================
🏁 BENCHMARK SUMMARY
============================================================

MEAN Operation:
   time_continuous      |  0.338s | 1.00x
   optimal_mixed        |  0.346s | 1.03x
   optimal_cpu          |  0.350s | 1.04x
   large_chunks         |  0.382s | 1.13x
   spatial_focus        |  0.398s | 1.18x
   optimal_io           |  0.714s | 2.12x
   small_chunks         |  3.045s | 9.02x

SPATIAL_MEAN Operation:
   optimal_mixed        |  0.338s | 1.00x
   time_continuous      |  0.339s | 1.00x
   optimal_cpu          |  0.353s | 1.04x
   spatial_focus        |  0.357s | 1.06x
   optimal_io           |  0.359s | 1.06x
   large_chunks         |  0.391s | 1.16x
   small_chunks         |  3.378s | 9.99x

STD Operation:
   time_continuous      |  0.411s | 1.00x
   optimal_cpu          |  0.423s | 1.03x
   optimal_mixed        |  0.432s | 1.05x
   large_chunks         |  0.450s | 1.10x
   optimal_io           |  0.461s | 1.12x
   spatial_focus        |  0.462s | 1.12x
   small_chunks         |  3.179s | 7.73x

Chunking Visualization

Let’s visualize the chunk structure and performance results.

if PLOTTING_AVAILABLE:
    import matplotlib.pyplot as plt
    import numpy as np
    
    # Create visualization of chunk sizes and performance
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))
    
    # Plot 1: Chunk sizes comparison
    strategies = []
    chunk_sizes = []
    colors = []
    
    for strategy, result in analysis_results.items():
        if 'error' not in result:
            strategies.append(strategy)
            chunk_sizes.append(result['avg_chunk_mb'])
            
            # Color code based on quality
            if '✅' in result['quality']:
                colors.append('green')
            elif '🟡' in result['quality']:
                colors.append('orange')
            else:
                colors.append('red')
    
    # Create bar plot
    bars = ax1.bar(range(len(strategies)), chunk_sizes, color=colors, alpha=0.7)
    ax1.set_xlabel('Chunking Strategy')
    ax1.set_ylabel('Average Chunk Size (MB)')
    ax1.set_title('Chunk Size Comparison')
    ax1.set_xticks(range(len(strategies)))
    ax1.set_xticklabels(strategies, rotation=45, ha='right')
    
    # Add optimal range shading
    ax1.axhspan(*TARGET_MB, alpha=0.2, color='green', label=f'Demo target ({TARGET_MB[0]}-{TARGET_MB[1]} MiB)')
    ax1.legend()
    
    # Add value labels on bars
    for bar, size in zip(bars, chunk_sizes):
        ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 10,
                f'{size:.1f}', ha='center', va='bottom', fontsize=8)
    
    # Plot 2: Performance comparison (using mean operation)
    if 'mean' in benchmark_results:
        perf_data = benchmark_results['mean']
        valid_perf = {k: v for k, v in perf_data.items() if v != float('inf')}
        
        if valid_perf:
            perf_strategies = list(valid_perf.keys())
            perf_times = list(valid_perf.values())
            
            # Normalize to show relative performance
            best_time = min(perf_times)
            relative_times = [t / best_time for t in perf_times]
            
            bars2 = ax2.bar(range(len(perf_strategies)), relative_times, 
                           color='skyblue', alpha=0.7)
            ax2.set_xlabel('Chunking Strategy')
            ax2.set_ylabel('Relative Performance (lower is better)')
            ax2.set_title('Performance Comparison (Mean Operation)')
            ax2.set_xticks(range(len(perf_strategies)))
            ax2.set_xticklabels(perf_strategies, rotation=45, ha='right')
            
            # Add value labels
            for bar, time_val in zip(bars2, relative_times):
                ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05,
                        f'{time_val:.2f}x', ha='center', va='bottom', fontsize=8)
    else:
        ax2.text(0.5, 0.5, 'No performance data available', 
                ha='center', va='center', transform=ax2.transAxes)
        ax2.set_title('Performance Comparison')
    
    plt.tight_layout()
    plt.show()
    
else:
    print("📊 Plotting not available (matplotlib not installed)")
    print("   Install matplotlib to see chunk size and performance visualizations")
../../../_images/a4b7d16a09ee470405944dda01e69991c498e9cd5c29d3866e1c556e2dc64289.png

Rechunking Demonstration

Use rechunk_dataset() from dask_setup to safely rechunk large datasets. It uses the rechunker library when available and compatible, and automatically falls back to xarray.to_zarr() on environments where rechunker is incompatible with the installed xarray version (e.g. NCI Gadi’s analysis3 environments).

from dask_setup import rechunk_dataset
import tempfile

def demonstrate_rechunking(ds, source_chunks, target_chunks, client, dask_tmp):
    """Demonstrate rechunking with rechunk_dataset()."""
    print("\n🔄 RECHUNKING DEMONSTRATION")
    print("=" * 40)

    source_ds = ds.chunk(source_chunks)
    print(f"Source chunks: {source_chunks}")
    print(f"Target chunks: {target_chunks}")

    # rechunk_dataset handles rechunker/xarray compatibility automatically.
    # If rechunker is incompatible (e.g. newer xarray requires zarr_format),
    # it falls back to native xarray.to_zarr() with no API change.
    ds_rechunked = rechunk_dataset(
        source_ds,
        target_chunks=target_chunks,
        client=client,
        dask_tmp=dask_tmp,
    )

    print(f"✅ Rechunked successfully")
    print(f"   New chunks: {dict(ds_rechunked.chunks)}")
    return ds_rechunked


if RECHUNKER_AVAILABLE:
    # One day per chunk -> 30 days per chunk, staged through dask_tmp
    ds_rechunked = demonstrate_rechunking(
        ds,
        source_chunks={'time': 1, 'lat': 90, 'lon': 180},
        target_chunks={'time': 30, 'lat': 90, 'lon': 180},
        client=client,
        dask_tmp=temp_dir,
    )
else:
    print("rechunker not installed — pip install rechunker zarr to run this demo")
rechunker not installed — pip install rechunker zarr to run this demo

Chunking Best Practices Guide

Based on our analysis, here are the key best practices for xarray chunking:

def print_chunking_best_practices():
    """Print comprehensive chunking best practices."""
    print("\n" + "=" * 60)
    print("💡 XARRAY CHUNKING BEST PRACTICES")
    print("=" * 60)
    
    print("""
🎯 OPTIMAL CHUNK SIZE RULE:
   • Target: 256-512 MB per chunk
   • Avoid: <100 MB (too much overhead) or >1 GB (memory issues)
   • Monitor: Actual memory usage vs estimates

📐 CHUNK SHAPE STRATEGIES:

   CPU-Intensive Workloads:
   • Square-ish chunks for better cache locality
   • Balance dimensions equally when possible
   • Example: (500, 500, 10) rather than (100, 100, 500)

   I/O-Intensive Workloads:
   • Preserve natural access patterns
   • Keep record dimensions (time) lightly chunked
   • Example: (365, 180, 90) for daily global data

   Mixed Workloads:
   • Mixed approach based on operation types
   • Consider downstream processing patterns

🔄 RECHUNKING STRATEGIES:

   Use .chunk() when:
   • Initial chunking for analysis
   • Simple chunk size adjustments
   • Interactive exploration

   Use rechunk_dataset() when:
   • Major chunk structure changes (writes to Zarr on fast local storage)
   • Large datasets (>1 GB) — handles rechunker/xarray compatibility
   • Need to persist rechunked data to disk for repeated access

⚠️  COMMON PITFALLS:

   BAD Chunking Examples:
   • Too small: (10, 10, 10) → ~8KB chunks (high overhead)
   • Too large: (5000, 5000, 100) → ~20GB chunks (memory issues)
   • Unbalanced: (1, 3600, 1800) → inefficient parallelization

   GOOD Chunking Examples:
   • Balanced: (240, 512, 512) → ~512MB chunks
   • Time-aware: (365, 180, 90) → preserves daily records
   • Analysis-optimized: (100, 500, 500) → good for spatial ops

🔧 CONFIGURATION EXAMPLES:

   # For large climate datasets
   ds.chunk({"time": 365, "lat": 180, "lon": 360})  # ~190MB chunks (float64)
   
   # For high-resolution spatial data
   ds.chunk({"time": 30, "y": 1000, "x": 1000})   # ~240MB chunks
   
   # For time series analysis
   ds.chunk({"time": -1, "station": 100})          # Keep time continuous

📊 MEMORY CALCULATION:
   chunk_bytes = chunk_elements × dtype_size
   
   Example: (500, 500, 10) float64 array
   = 500 × 500 × 10 × 8 bytes = 20MB (too small)
   
   Better: (500, 500, 100) float64 array  
   = 500 × 500 × 100 × 8 bytes = 200MB ✓ (acceptable)
""")

# Display the best practices
print_chunking_best_practices()
============================================================
💡 XARRAY CHUNKING BEST PRACTICES
============================================================

🎯 OPTIMAL CHUNK SIZE RULE:
   • Target: 256-512 MB per chunk
   • Avoid: <100 MB (too much overhead) or >1 GB (memory issues)
   • Monitor: Actual memory usage vs estimates

📐 CHUNK SHAPE STRATEGIES:

   CPU-Intensive Workloads:
   • Square-ish chunks for better cache locality
   • Balance dimensions equally when possible
   • Example: (500, 500, 10) rather than (100, 100, 500)

   I/O-Intensive Workloads:
   • Preserve natural access patterns
   • Keep record dimensions (time) lightly chunked
   • Example: (365, 180, 90) for daily global data

   Mixed Workloads:
   • Mixed approach based on operation types
   • Consider downstream processing patterns

🔄 RECHUNKING STRATEGIES:

   Use .chunk() when:
   • Initial chunking for analysis
   • Simple chunk size adjustments
   • Interactive exploration

   Use rechunk_dataset() when:
   • Major chunk structure changes (writes to Zarr on fast local storage)
   • Large datasets (>1 GB) — handles rechunker/xarray compatibility
   • Need to persist rechunked data to disk for repeated access

⚠️  COMMON PITFALLS:

   BAD Chunking Examples:
   • Too small: (10, 10, 10) → ~8KB chunks (high overhead)
   • Too large: (5000, 5000, 100) → ~20GB chunks (memory issues)
   • Unbalanced: (1, 3600, 1800) → inefficient parallelization

   GOOD Chunking Examples:
   • Balanced: (240, 512, 512) → ~512MB chunks
   • Time-aware: (365, 180, 90) → preserves daily records
   • Analysis-optimized: (100, 500, 500) → good for spatial ops

🔧 CONFIGURATION EXAMPLES:

   # For large climate datasets
   ds.chunk({"time": 365, "lat": 180, "lon": 360})  # ~190MB chunks (float64)

   # For high-resolution spatial data
   ds.chunk({"time": 30, "y": 1000, "x": 1000})   # ~240MB chunks

   # For time series analysis
   ds.chunk({"time": -1, "station": 100})          # Keep time continuous

📊 MEMORY CALCULATION:
   chunk_bytes = chunk_elements × dtype_size

   Example: (500, 500, 10) float64 array
   = 500 × 500 × 10 × 8 bytes = 20MB (too small)

   Better: (500, 500, 100) float64 array  
   = 500 × 500 × 100 × 8 bytes = 200MB ✓ (acceptable)

Summary Comparison Table

Let’s create a comprehensive comparison table of all our chunking strategies.

def create_chunking_comparison_table(analysis_results, benchmark_results=None):
    """Create a comprehensive comparison table."""
    print("\n" + "=" * 80)
    print("📋 COMPREHENSIVE CHUNKING STRATEGY COMPARISON")
    print("=" * 80)
    
    # Prepare headers
    headers = ["Strategy", "Chunks", "Avg Size (MB)", "Total Chunks", "Quality"]
    
    # Add performance columns if available
    if benchmark_results and 'mean' in benchmark_results:
        headers.extend(["Mean Time (s)", "Relative Speed"])
    
    # Prepare data
    table_data = []
    
    # Get best performance for relative comparison
    best_time = None
    if benchmark_results and 'mean' in benchmark_results:
        valid_times = [t for t in benchmark_results['mean'].values() if t != float('inf')]
        if valid_times:
            best_time = min(valid_times)
    
    # Sort strategies by chunk size (optimal range first)
    sorted_strategies = sorted(
        analysis_results.items(),
        key=lambda x: abs(x[1].get('avg_chunk_mb', 1000) - sum(TARGET_MB) / 2)  # middle of target range
    )
    
    for strategy, data in sorted_strategies:
        if 'error' in data:
            continue
            
        # Format chunks dictionary
        chunks_str = ", ".join([f"{k}:{v}" for k, v in data['chunks'].items()])
        if len(chunks_str) > 25:
            chunks_str = chunks_str[:22] + "..."
        
        row = [
            strategy,
            chunks_str,
            f"{data['avg_chunk_mb']:.1f}",
            str(data['total_chunks']),
            data['quality']
        ]
        
        # Add performance data if available
        if benchmark_results and 'mean' in benchmark_results:
            if strategy in benchmark_results['mean']:
                time_val = benchmark_results['mean'][strategy]
                if time_val != float('inf') and best_time:
                    relative_speed = time_val / best_time
                    row.extend([f"{time_val:.3f}", f"{relative_speed:.2f}x"])
                else:
                    row.extend(["Failed", "N/A"])
            else:
                row.extend(["-", "-"])
        
        table_data.append(row)
    
    # Print table with proper formatting
    if table_data:
        # Calculate column widths
        col_widths = []
        for i in range(len(headers)):
            col_width = max(
                len(headers[i]),
                max(len(str(row[i])) for row in table_data if len(row) > i)
            )
            col_widths.append(col_width)
        
        # Print header
        header_row = " | ".join(headers[i].ljust(col_widths[i]) for i in range(len(headers)))
        print(f"| {header_row} |")
        print("|" + "|".join("-" * (w + 2) for w in col_widths) + "|")
        
        # Print data rows
        for row in table_data:
            # Pad row if needed
            while len(row) < len(headers):
                row.append("-")
            
            data_row = " | ".join(str(row[i]).ljust(col_widths[i]) for i in range(len(headers)))
            print(f"| {data_row} |")
    else:
        print("No valid results to display")
    
    # Add interpretation guide
    print("\n💡 Interpretation Guide:")
    print(f"   • Demo target: {TARGET_MB[0]}-{TARGET_MB[1]} MiB (use 256-512 MiB on real data)")
    print("   • Quality indicators: ✅ Optimal, 🟡 Acceptable, 🔴 Problematic")
    print("   • Lower relative speed is better (1.0x = fastest)")
    print("   • Choose strategy based on your specific workload")

# Create the comprehensive comparison table
create_chunking_comparison_table(analysis_results, benchmark_results)

# Clean up the cluster
client.close()
cluster.close()
print("\n🧹 Cluster cleaned up")
================================================================================
📋 COMPREHENSIVE CHUNKING STRATEGY COMPARISON
================================================================================
| Strategy        | Chunks                    | Avg Size (MB) | Total Chunks | Quality                       | Mean Time (s) | Relative Speed |
|-----------------|---------------------------|---------------|--------------|-------------------------------|---------------|----------------|
| optimal_mixed   | time:365, lat:90, lon:180 | 45.1          | 8            | ✅ Optimal                     | 0.346         | 1.03x          |
| time_continuous | time:365, lat:90, lon:180 | 45.1          | 8            | ✅ Optimal                     | 0.338         | 1.00x          |
| optimal_cpu     | time:182, lat:180, lon... | 45.0          | 12           | ✅ Optimal                     | 0.350         | 1.04x          |
| optimal_io      | time:129, lat:180, lon... | 63.8          | 6            | ✅ Optimal                     | 0.714         | 2.12x          |
| large_chunks    | time:182, lat:90, lon:180 | 22.5          | 24           | 🟡 Acceptable                  | 0.382         | 1.13x          |
| spatial_focus   | time:30, lat:180, lon:360 | 14.8          | 26           | 🔴 Too small (overhead issues) | 0.398         | 1.18x          |
| small_chunks    | time:36, lat:18, lon:36   | 0.2           | 2200         | 🔴 Too small (overhead issues) | 3.045         | 9.02x          |

💡 Interpretation Guide:
   • Demo target: 32-64 MiB (use 256-512 MiB on real data)
   • Quality indicators: ✅ Optimal, 🟡 Acceptable, 🔴 Problematic
   • Lower relative speed is better (1.0x = fastest)
   • Choose strategy based on your specific workload

🧹 Cluster cleaned up

Personalized Recommendations

Based on our analysis, here are specific recommendations for your dataset:

def generate_recommendations(analysis_results, benchmark_results=None):
    """Generate personalized chunking recommendations."""
    print("\n" + "=" * 60)
    print("🎯 PERSONALIZED RECOMMENDATIONS")
    print("=" * 60)
    
    # Find the best strategies
    optimal_strategies = []
    acceptable_strategies = []
    
    for strategy, data in analysis_results.items():
        if 'error' not in data:
            if '✅' in data['quality']:
                optimal_strategies.append((strategy, data))
            elif '🟡' in data['quality']:
                acceptable_strategies.append((strategy, data))
    
    # Find fastest strategy if performance data is available
    fastest_strategy = None
    if benchmark_results and 'mean' in benchmark_results:
        valid_times = {k: v for k, v in benchmark_results['mean'].items() if v != float('inf')}
        if valid_times:
            fastest_strategy = min(valid_times.items(), key=lambda x: x[1])
    
    print(f"\n📊 For your dataset (shape: {ds.temperature.shape}, size: {ds.nbytes/(1024**2):.1f} MB):")
    
    if optimal_strategies:
        print(f"\n✅ OPTIMAL STRATEGIES ({len(optimal_strategies)} found):")
        for strategy, data in optimal_strategies:
            print(f"   • {strategy}: {data['chunks']} → {data['avg_chunk_mb']:.1f} MB chunks")
            
            # Add context about when to use this strategy
            if 'cpu' in strategy:
                print(f"     → Use for: Heavy computations, mathematical operations")
            elif 'io' in strategy:
                print(f"     → Use for: File I/O operations, data loading/saving")
            elif 'mixed' in strategy:
                print(f"     → Use for: Mixed workloads, general analysis")
            elif 'time_continuous' in strategy:
                print(f"     → Use for: Time series analysis, temporal operations")
            elif 'spatial_focus' in strategy:
                print(f"     → Use for: Spatial analysis, geographical operations")
    else:
        print("\n🟡 No optimal strategies found. Acceptable alternatives:")
    
    if acceptable_strategies:
        if optimal_strategies:
            print(f"\n🟡 ACCEPTABLE ALTERNATIVES ({len(acceptable_strategies)} found):")
        for strategy, data in acceptable_strategies[:3]:  # Show top 3
            print(f"   • {strategy}: {data['chunks']} → {data['avg_chunk_mb']:.1f} MB chunks")
    
    # Performance recommendation
    if fastest_strategy:
        strategy_name, best_time = fastest_strategy
        print(f"\n⚡ FASTEST STRATEGY: {strategy_name} ({best_time:.3f}s for mean operation)")
        if strategy_name in analysis_results:
            print(f"   Configuration: {analysis_results[strategy_name]['chunks']}")
    
    # Specific use case recommendations
    print(f"\n🔧 SPECIFIC USE CASE RECOMMENDATIONS:")
    
    print(f"\n   For Time Series Analysis:")
    if 'time_continuous' in analysis_results:
        time_config = analysis_results['time_continuous']['chunks']
        print(f"     ds.chunk({time_config})")
    else:
        print(f"     ds.chunk({{'time': -1, 'lat': 90, 'lon': 180}})  # Keep time continuous")
    
    print(f"\n   For Spatial Analysis:")
    if 'spatial_focus' in analysis_results:
        spatial_config = analysis_results['spatial_focus']['chunks']
        print(f"     ds.chunk({spatial_config})")
    else:
        print(f"     ds.chunk({{'time': 30, 'lat': -1, 'lon': -1}})  # Keep spatial dims continuous")
    
    print(f"\n   For General Computing:")
    if optimal_strategies:
        best_general = optimal_strategies[0][1]['chunks']
        print(f"     ds.chunk({best_general})")
    elif acceptable_strategies:
        best_general = acceptable_strategies[0][1]['chunks']
        print(f"     ds.chunk({best_general})")
    
    # Memory considerations
    print(f"\n💾 MEMORY CONSIDERATIONS:")
    print(f"   • Your dataset: {ds.nbytes/(1024**3):.2f} GB total")
    print(f"   • Recommended worker memory: 4-8 GB per worker")
    print(f"   • Leave reserve_mem_gb at its default (20% of RAM, 4-50 GB) unless you've measured otherwise")

# Generate recommendations
generate_recommendations(analysis_results, benchmark_results)
============================================================
🎯 PERSONALIZED RECOMMENDATIONS
============================================================

📊 For your dataset (shape: (365, 180, 360), size: 360.9 MB):

✅ OPTIMAL STRATEGIES (4 found):
   • optimal_cpu: {'time': 182, 'lat': 180, 'lon': 180} → 45.0 MB chunks
     → Use for: Heavy computations, mathematical operations
   • optimal_io: {'time': 129, 'lat': 180, 'lon': 360} → 63.8 MB chunks
     → Use for: File I/O operations, data loading/saving
   • optimal_mixed: {'time': 365, 'lat': 90, 'lon': 180} → 45.1 MB chunks
     → Use for: Mixed workloads, general analysis
   • time_continuous: {'time': 365, 'lat': 90, 'lon': 180} → 45.1 MB chunks
     → Use for: Time series analysis, temporal operations

🟡 ACCEPTABLE ALTERNATIVES (1 found):
   • large_chunks: {'time': 182, 'lat': 90, 'lon': 180} → 22.5 MB chunks

⚡ FASTEST STRATEGY: time_continuous (0.338s for mean operation)
   Configuration: {'time': 365, 'lat': 90, 'lon': 180}

🔧 SPECIFIC USE CASE RECOMMENDATIONS:

   For Time Series Analysis:
     ds.chunk({'time': 365, 'lat': 90, 'lon': 180})

   For Spatial Analysis:
     ds.chunk({'time': 30, 'lat': 180, 'lon': 360})

   For General Computing:
     ds.chunk({'time': 182, 'lat': 180, 'lon': 180})

💾 MEMORY CONSIDERATIONS:
   • Your dataset: 0.35 GB total
   • Recommended worker memory: 4-8 GB per worker
   • Leave reserve_mem_gb at its default (20% of RAM, 4-50 GB) unless you've measured otherwise

Conclusion

You’ve now learned the comprehensive approach to xarray chunking optimization! Here’s what we covered:

Key Takeaways:

  1. Optimal Chunk Size: Target 256-512 MB per chunk for best performance

  2. Workload-Specific Strategies: recommend_chunks() shapes chunks for cpu, io and mixed

  3. Performance Impact: Proper chunking can significantly improve computation speed

  4. Memory Management: Balance chunk size with available memory

  5. Rechunking: Use rechunk_dataset() for major structural changes — it handles rechunker/xarray compatibility automatically

Best Practices Recap:

  • Measure first: Always analyze your data and benchmark different strategies

  • Consider your workload: Choose chunking strategy based on operations you’ll perform

  • Monitor memory: Watch for spills and memory pressure in the dashboard

  • Start conservative: Begin with smaller chunks and scale up

  • Test with real data: Synthetic examples may not capture your data’s characteristics

Next Steps:

  • Apply these techniques to your own datasets

  • Experiment with different chunk configurations

  • Monitor performance in the Dask dashboard

  • Use rechunk_dataset() for large-scale rechunking — it falls back gracefully when rechunker is incompatible with your xarray version

  • Integrate optimal chunking into your data processing pipelines

Happy chunking with dask_setup! 🧩✨