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
Network I/O: Fetching chunks from HTTP (~100ms each)
Solution: Intelligent prefetching, aggressive caching
Decompression: Blosc decompression (~5ms per chunk)
Solution: Parallel decompression, L0 cache for decoded chunks
GPU Rendering: Drawing millions of points (~60ms for 10M points)
Solution: Spatial culling, LOD, instancing
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
✅ Enable spatial indexing (
enable_spatial_index=True)✅ Choose chunk size based on usage (512-4096 points)
✅ Use compound ordering for nD data
✅ Process data in batches for large datasets
✅ Use
EncodingMode.AUTOunless you have specific needs
Runtime Optimizations
✅ Enable prefetching (default: on)
✅ Configure cache sizes based on available RAM
✅ Use maxPoints limit for consistent FPS
✅ Enable frustum culling (default: on)
✅ Monitor performance overlay for bottlenecks
GPU Optimizations
✅ Use world-space sizing (automatic, no pixel-based scaling)
✅ Limit point count per frame (<1M for 60 FPS)
✅ Use additive blending (faster than premultiplied)
✅ 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: 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
?clearCacheURL parameter to reset caches on startupUse
?noCacheto disable caching entirely for debuggingCache 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):
Spatial indexing - Enables chunk-based queries (200,000× speedup)
Chunk size - Balances granularity vs overhead
Encoding mode - Compression reduces network I/O
Prefetching - Cache hits eliminate network latency
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:
Check spatial indexing is enabled
Verify chunk sizes are reasonable (1-4K points)
Monitor cache hit rate (enable prefetch)
Limit rendered points if GPU bound
Profile with browser dev tools
The combination of spatial indexing + intelligent caching + GPU rendering enables Luxar to handle billion-point datasets interactively!