Tutorial 4: Performance Optimization

Learn how to handle billion-point datasets efficiently and achieve smooth 60 FPS interaction.

Performance Fundamentals

Luxar’s Performance Model:

Performance = f(Spatial Locality, Cache Hit Rate, Chunk Granularity, GPU Throughput)

Understanding the Bottlenecks

  1. Network I/O: Fetching chunks from HTTP (~100ms each)

    • Solution: Intelligent prefetching, aggressive caching

  2. Decompression: Blosc decompression (~5ms per chunk)

    • Solution: Parallel decompression, L0 cache for decoded chunks

  3. GPU Rendering: Drawing millions of points (~60ms for 10M points)

    • Solution: Spatial culling, LOD, instancing

  4. Memory Allocation: Creating TypedArrays (~10ms for large buffers)

    • Solution: Object pooling, reuse buffers

The Critical Path:

User navigates → Query chunks (1ms)
               → S-cache / L0 check (decoded slices & chunks, ~1μs)
               → L1 check (compressed, ~1μs)
               → L2 check (OPFS, ~1ms) ← Usually hits here (80-95%)
               → HTTP fetch (100ms) ← Only first time
               → Decode (5ms)
               → GPU upload (2ms)
               → Render (10ms)

Target: <16ms total (60 FPS)
Achieved: 10-20ms with cache hits, 120ms on cache miss

Optimization 1: Chunk Size Selection

The Tradeoff:

  • Small chunks (e.g., 256 points):

    • Pros: Granular loading, less waste

    • Cons: Many HTTP requests, overhead per chunk

  • Large chunks (e.g., 8192 points):

    • Pros: Fewer requests, less overhead

    • Cons: Coarse granularity, load unnecessary points

Recommended Sizes:

# For static 3D data (no time dimension)
compiler = LuxarZarrCompiler('scene.luxar.zarr')

# For time-series (4D with time animation)
compiler = LuxarZarrCompiler('timeseries.luxar.zarr')

# For massive static datasets with best compression
compiler = LuxarZarrCompiler(
    'huge.luxar.zarr',
    ordering_method="hilbert",  # Better compression for large datasets
)

Calculation:

Target chunk size in bytes: 32KB - 1MB

3D positions (3 x float32 = 12 bytes per point)
+ RGB colors (3 x uint8 = 3 bytes per point)
= 15 bytes per point

Target 64KB chunks:
chunk_size = 64 * 1024 / 15 = ~4369 points

Round to power of 2: 4096 points

Optimization 2: Spatial Ordering Strategy

Morton vs Hilbert:

Morton (Z-Order):

  • Pros: Faster to compute, simpler bit-interleaving

  • Cons: Occasional “jumps” at quadrant boundaries

  • Best for: General-purpose, 3D data

Hilbert:

  • Pros: Better locality (no jumps), slightly better compression

  • Cons: More complex algorithm, slightly slower

  • Best for: Maximum compression, smooth navigation

# Default: Hilbert curve (best spatial locality, recommended)
compiler = LuxarZarrCompiler('scene.luxar.zarr')  # ordering_method="hilbert" by default

# Alternative: Morton curve (faster to compute, slightly worse locality)
compiler = LuxarZarrCompiler('scene.luxar.zarr', ordering_method="morton")

Measured Impact:

  • Compression: Hilbert ~10% better than Morton

  • Write speed: Morton ~20% faster than Hilbert

  • Query speed: Equivalent

Optimization 3: Encoding Mode Selection

AUTO Mode (Recommended):

Analyzes data and chooses optimal encoding:

from luxar.encoding import EncodingMode

compiler = LuxarZarrCompiler('scene.luxar.zarr', encoding_mode=EncodingMode.AUTO)

# Positions: Analyzes range, uses uint16 if |max-min| < 65536
# Colors: uint8 for SDR, geolog_perchannel_u16 for HDR
# Radii: Broadcasts if uniform, uint16 if varying

PRECISION Mode (Lossless):

Keeps float32 for everything:

compiler = LuxarZarrCompiler('scene.luxar.zarr', encoding_mode=EncodingMode.PRECISION)

# Positions: float32 (no quantization)
# Colors: float32 (full HDR range)
# Radii: float32

# Use when: Precision critical, storage not constrained

MEMORY Mode (Aggressive):

Maximum compression:

compiler = LuxarZarrCompiler('scene.luxar.zarr', encoding_mode=EncodingMode.MEMORY)

# Positions: uint16 or uint8 (aggressive quantization)
# Colors: uint8 (SDR only)
# Radii: uint8 or broadcast

# Use when: Network-limited, compression critical

Compression Ratios:

1M points, 3D RGB:
- PRECISION: 24MB (no quantization) + 12MB blosc = 36MB
- AUTO: 12MB (uint16+uint8) + 3MB blosc = 15MB (2.4× smaller)
- MEMORY: 6MB (uint8) + 1.5MB blosc = 7.5MB (4.8× smaller)

Optimization 4: Prefetching Configuration

Concept: Predict which chunks user will load next, fetch in background

Strategy: Load adjacent chunks (±1 in each dimension)

// In viewer, cache automatically prefetches:
// Current chunk: [5, 3, 2]
// Prefetches: [4,3,2], [6,3,2], [5,2,2], [5,4,2], [5,3,1], [5,3,3]

Configuration (viewer URL parameters):

?src=data.luxar.zarr                # Prefetching enabled by default
?src=data.luxar.zarr&noPrefetch    # Disable prefetching (debugging)
?src=data.luxar.zarr&prefetchDebug # Enable prefetch debug logging

Impact:

  • Prefetching enabled (default): 80-95% cache hit rate

  • Prefetching disabled (noPrefetch): 20-30% cache hit rate (only repeat views cached)

Optimization 5: Viewer Configuration

Automatic Optimizations:

The viewer applies several optimizations automatically:

  • World-space point sizing: Points specified in world units are sized based on camera distance

  • Frustum culling: Only points in the camera’s field of view are rendered (typically excludes 90%+ of points)

  • Chunk-based spatial queries: Only chunks overlapping the view are loaded

Cache Control (URL parameters):

?src=data.luxar.zarr                # Default: all caching enabled
?src=data.luxar.zarr&noCache       # Disable all caching tiers (S-cache + L0/L1/L2)
?src=data.luxar.zarr&clearCache    # Clear all caches on startup
?src=data.luxar.zarr&cacheDebug    # Enable cache debug logging

Rendering Configuration can be controlled via viewer_config in the Zarr scene metadata (set at write time in Python) or interactively via the rendering controls panel (press R).

Billion-Point Dataset Strategy

Scenario: 1 billion points, 5D (x, y, z, time=100, channel=4)

Naive Approach:

# DON'T DO THIS:
positions = np.random.randn(1_000_000_000, 5).astype(np.float32)
# Requires 20GB RAM! Will crash on most machines

Luxar Approach (Progressive):

with LuxarZarrCompiler(
    'billion_points.luxar.zarr',
    encoding_mode=EncodingMode.MEMORY,  # Aggressive compression
    ordering_method="hilbert",  # Best compression
) as compiler:

    scene = compiler.create_scene(dimensions=dims_5d)

    # Process in batches (only 10M in memory at once)
    batch_size = 10_000_000

    for batch_idx in range(100):  # 100 batches × 10M = 1B points
        # Generate or load this batch only
        positions_batch = generate_batch(batch_idx, batch_size)

        # Write immediately (no accumulation!)
        scene.add_points(
            f"batch_{batch_idx:03d}",
            positions_batch,
            colors=compute_colors(batch_idx),
            radii=1.0  # Scalar broadcasts
        )

        # Memory usage: ~400MB per batch (10M points × 40 bytes)
        # Total write time: ~1000 seconds (1M points/sec)

# Result: 1B points written using only 400MB RAM!

File Size:

Uncompressed: 1B × 5 × 4 bytes = 20GB
With MEMORY encoding: 1B × 5 × 1 byte = 5GB (quantized)
With blosc compression: 5GB → 1-2GB (3-5× compression)
Final: ~1.5GB for 1 billion 5D points (13× compression)

Viewing Performance:

Typical view: 100K points visible (0.01% of total)
Chunks loaded: ~50 chunks (0.01% of 488K total chunks)
Load time: First view ~2s, navigating ~50ms (prefetch)
Render: 60 FPS with 100K points

Monitoring Performance

Built-in Metrics:

The viewer provides real-time performance monitoring:

Press P to toggle performance stats (FPS, frame time, memory)
Press M to cycle the data loading monitor (chunk loading, cache stats)
Press H for help overlay with all keyboard shortcuts

Interpreting Metrics:

  • FPS < 60: Too many points or GPU bottleneck

    • Solution: Reduce maxPoints, enable LOD

  • Many L3 fetches: Poor cache hit rate

    • Solution: Enable prefetching, increase cache size

  • GPU time > 16ms: Rendering bottleneck

    • Solution: Reduce point count, simplify shaders

Profiling Tools

Python (Write Performance):

import time
from luxar import LuxarZarrCompiler, Dimensions

dims = Dimensions.default_3d()
start = time.time()
with LuxarZarrCompiler('scene.luxar.zarr') as compiler:
    scene = compiler.create_scene(dimensions=dims)
    scene.add_points("data", positions, colors)
elapsed = time.time() - start

rate = len(positions) / elapsed
print(f"Write rate: {rate/1e6:.2f}M points/sec")

TypeScript (Query Performance):

// Browser console (when ?debug in URL):
const state = window.__luxarDebug.getState();
console.log('Query time:', state.lastQueryTime);
console.log('Decode time:', state.lastDecodeTime);
console.log('Cache stats:', state.cacheStats);

Best Practices Summary

Write-Time Optimizations

  1. ✅ Enable spatial indexing (enable_spatial_index=True)

  2. ✅ Choose chunk size based on usage (512-4096 points)

  3. ✅ Use compound ordering for nD data

  4. ✅ Process data in batches for large datasets

  5. ✅ Use EncodingMode.AUTO unless you have specific needs

Runtime Optimizations

  1. ✅ Enable prefetching (default: on)

  2. ✅ Configure cache sizes based on available RAM

  3. ✅ Use maxPoints limit for consistent FPS

  4. ✅ Enable frustum culling (default: on)

  5. ✅ Monitor performance overlay for bottlenecks

GPU Optimizations

  1. ✅ Use world-space sizing (automatic, no pixel-based scaling)

  2. ✅ Limit point count per frame (<1M for 60 FPS)

  3. ✅ Use additive blending (faster than premultiplied)

  4. ✅ Minimize post-processing effects (bloom is expensive)

Expected Performance

Realistic Targets (approximate, hardware-dependent):

Dataset Size │ Write Time │ View Load │ Navigate │ FPS
─────────────┼────────────┼───────────┼──────────┼─────
100K points  │   <1s      │   <1s     │   <50ms  │  60
1M points    │   ~1s      │   ~2s     │   ~50ms  │  60
10M points   │   ~10s     │   ~5s     │   ~100ms │  60
100M points  │   ~100s    │   ~10s    │   ~100ms │  60
1B points    │   ~1000s   │   ~20s    │   ~150ms │  60

Note

These are order-of-magnitude estimates on typical hardware (modern laptop/desktop with dedicated GPU, SSD, broadband). Actual performance varies with hardware, dataset structure, and network conditions.

Key Insight: Performance scales logarithmically with dataset size due to spatial indexing!

Common Issues and Solutions

Issue: Slow Initial Load

Symptoms: First view takes 30+ seconds

Causes:

  • No spatial indexing (scanning all chunks)

  • Large chunk size (loading MB when need KB)

  • No prefetching (loading chunks serially)

Solutions:

# Ensure spatial indexing enabled
compiler = LuxarZarrCompiler(
    'scene.luxar.zarr',
    enable_spatial_index=True,  # CRITICAL!
    ordering_method="hilbert",  # Default, best locality
)

# Use spatial ordering for best query performance
compiler = LuxarZarrCompiler('scene.luxar.zarr')  # Hilbert by default

Issue: Low FPS During Navigation

Symptoms: <30 FPS when rotating camera

Causes:

  • Too many points rendered (>1M)

  • Expensive post-processing

  • GPU bottleneck

Solutions:

  • Reduce point count by using smaller datasets or chunked loading

  • Disable expensive post-processing effects via the rendering controls panel (press R)

  • Configure rendering settings via viewer_config in scene metadata at write time

Issue: High Memory Usage

Symptoms: Browser using >4GB RAM, crashes on mobile

Causes:

  • L1 cache too large

  • Loading too many chunks

  • Not cleaning old chunks

Solutions:

  • The viewer uses LRU eviction automatically when caches are full

  • Use ?clearCache URL parameter to reset caches on startup

  • Use ?noCache to disable caching entirely for debugging

  • Cache sizes are managed automatically by the viewer

Advanced: Custom Culling

For Very Large Datasets (>100M points):

Implement custom spatial culling on the Python side:

def add_points_with_region(scene, name, all_positions, region_bounds):
    """Only write points within a specific region."""

    # Filter points
    mask = (
        (all_positions[:, 0] >= region_bounds[0]) &
        (all_positions[:, 0] <= region_bounds[1]) &
        (all_positions[:, 1] >= region_bounds[2]) &
        (all_positions[:, 1] <= region_bounds[3])
    )

    filtered_positions = all_positions[mask]

    scene.add_points(name, filtered_positions, ...)

    return len(filtered_positions)

# Create multiple regions for LOD
add_points_with_region(scene, "core_detail", positions, core_bounds)  # High detail
add_points_with_region(scene, "periphery", positions[::10], full_bounds)  # Downsampled

Result: Viewer loads high detail in center, lower detail in periphery.

Summary

Performance Hierarchy (Most Important First):

  1. Spatial indexing - Enables chunk-based queries (200,000× speedup)

  2. Chunk size - Balances granularity vs overhead

  3. Encoding mode - Compression reduces network I/O

  4. Prefetching - Cache hits eliminate network latency

  5. GPU budget - Limit points for consistent frame rate

Key Metrics to Watch:

  • Cache hit rate: Should be >80% with prefetching

  • FPS: Should stay at 60 during navigation

  • Load time: First view <10s, navigation <100ms

  • Memory: <2GB browser RAM for typical datasets

When Performance is Poor:

  1. Check spatial indexing is enabled

  2. Verify chunk sizes are reasonable (1-4K points)

  3. Monitor cache hit rate (enable prefetch)

  4. Limit rendered points if GPU bound

  5. Profile with browser dev tools

The combination of spatial indexing + intelligent caching + GPU rendering enables Luxar to handle billion-point datasets interactively!