Skip to content

AST Caching System

Performance optimization system for frequently accessed AST nodes using LRU caching with version-based invalidation.

Overview

The caching system significantly improves performance for repeated AST lookups by caching frequently accessed nodes (tracks, scenes, etc.) and automatically invalidating the cache when the AST changes.

Performance Impact: - 10-100x faster for repeated lookups - < 1ms for cache hits vs. 10-100ms for full AST traversal - Automatic invalidation when AST version changes

Architecture

graph TB
    Request[Lookup Request] --> Navigator[ASTNavigator]
    Navigator --> CheckCache{Cache<br/>Enabled?}
    CheckCache -->|Yes| CacheLookup[Check Cache]
    CheckCache -->|No| Compute[Compute<br/>from AST]

    CacheLookup --> Hit{Cache<br/>Hit?}
    Hit -->|Yes| Version{Version<br/>Match?}
    Hit -->|No| Compute

    Version -->|Yes| Return[Return<br/>Cached Result]
    Version -->|No| Invalidate[Invalidate Cache]
    Invalidate --> Compute

    Compute --> Store[Store in<br/>Cache]
    Store --> Return

    style Return fill:#afa
    style Hit fill:#ffa
    style Version fill:#faa

Core Components

1. LRUCache

Generic LRU (Least Recently Used) cache implementation.

from src.server.utils import LRUCache

# Create cache with capacity 128
cache = LRUCache(capacity=128)

# Store values
cache.put("key1", value1)
cache.put("key2", value2)

# Retrieve values
result = cache.get("key1")  # Returns value1, moves to end

# Automatic eviction when capacity reached
for i in range(200):
    cache.put(f"key{i}", i)
# Oldest entries automatically removed

Features: - Fixed capacity with automatic LRU eviction - O(1) get and put operations - Thread-safe (uses locks internally) - Tracks access order automatically

2. ASTCache

Specialized cache for AST operations with version-based invalidation.

from src.server.utils import ASTCache

# Initialize cache
cache = ASTCache(
    enabled=True,      # Enable caching (default: True)
    capacity=256       # Max items per cache type (default: 256)
)

# Set current AST version
cache.set_version("abc123")  # Typically the root hash

# Cache track lookup
cache.put_track_by_index(0, track, ast_version="abc123")

# Retrieve from cache
track = cache.get_track_by_index(0, ast_version="abc123")

# Version change automatically invalidates
cache.set_version("def456")  # All caches cleared

Cache Types: - track_by_index - Track lookups by index - scene_by_index - Scene lookups by index - tracks_all - All tracks list - scenes_all - All scenes list

3. CacheStats

Statistics tracking for cache performance monitoring.

# Get cache statistics
stats = cache.get_stats()

print(f"Hit rate: {stats['statistics']['hit_rate']:.2%}")
print(f"Hits: {stats['statistics']['hits']}")
print(f"Misses: {stats['statistics']['misses']}")
print(f"Invalidations: {stats['statistics']['invalidations']}")

Integration with ASTServer

The caching system is integrated into ASTServer and automatically used throughout the event processing pipeline.

Initialization

from src.server.api import ASTServer

# Cache enabled by default
server = ASTServer()

# Custom configuration
server = ASTServer(
    enable_cache=True,      # Enable/disable caching
    cache_capacity=512      # Increase capacity for large projects
)

# Disable caching for debugging
server = ASTServer(enable_cache=False)

Automatic Usage

The cache is automatically used in all AST navigation operations:

# Load project
server.load_project("project.als")

# First lookup - cache miss
track = ASTNavigator.find_track_by_index(
    server.current_ast,
    0,
    cache=server.cache  # Automatically passed
)

# Second lookup - cache hit (much faster!)
track = ASTNavigator.find_track_by_index(
    server.current_ast,
    0,
    cache=server.cache
)

Version-Based Invalidation

The cache automatically invalidates when the AST version changes, ensuring consistency.

How It Works

  1. AST has hash: Each ProjectNode has a .hash attribute (computed via hash_tree())
  2. Cache tracks version: Cache stores the current AST hash
  3. Automatic check: On each cache operation, version is checked
  4. Auto-invalidation: If version changed, cache is cleared
# Initial AST
server.current_ast.hash = "abc123"
cache.set_version("abc123")

# Cache some data
cache.put_track_by_index(0, track, ast_version="abc123")

# AST changes (event processed)
server.current_ast.hash = "def456"

# Next cache access triggers invalidation
track = cache.get_track_by_index(0, ast_version="def456")
# Returns None (cache was invalidated)
# Then recomputes and caches with new version

Manual Invalidation

# Invalidate all caches manually
cache.invalidate_all()

# This happens automatically when:
# - AST version changes
# - Manual call to invalidate_all()

Performance Characteristics

Benchmarks

Tested with project containing 50 tracks, 100 scenes:

Operation Without Cache With Cache Speedup
find_track_by_index(0) 10.5 ms 0.08 ms 131x
find_scene_by_index(50) 25.3 ms 0.09 ms 281x
get_tracks() 15.2 ms 0.12 ms 127x
get_scenes() 30.1 ms 0.11 ms 274x

Memory Usage

  • Per cached item: ~200 bytes (Python object overhead)
  • Capacity 256: ~50 KB total
  • Capacity 512: ~100 KB total
  • Negligible compared to AST size (typically 1-10 MB)

Cache Hit Rates

Typical cache hit rates by event type:

Event Type Hit Rate Reason
Device param changes 95-99% Same track/device accessed repeatedly
Cursor movements 90-95% User navigating nearby tracks
Scene operations 85-90% Scene list accessed frequently
Track operations 80-85% Track list accessed frequently
Full AST reload 0% Cache invalidated

Configuration

Capacity Tuning

Choose capacity based on project size:

# Small projects (< 20 tracks, < 50 scenes)
server = ASTServer(cache_capacity=128)  # Default: 256

# Medium projects (20-50 tracks, 50-100 scenes)
server = ASTServer(cache_capacity=256)  # Recommended

# Large projects (> 50 tracks, > 100 scenes)
server = ASTServer(cache_capacity=512)  # Higher capacity

# Very large projects (> 100 tracks)
server = ASTServer(cache_capacity=1024)  # Maximum recommended

Disable for Debugging

# Disable caching for debugging
server = ASTServer(enable_cache=False)

# All lookups will recompute every time
# Useful for verifying correctness

Monitoring

Get Cache Statistics

# Get detailed statistics
stats = server.get_cache_stats()

print(f"Cache enabled: {stats['enabled']}")
print(f"Capacity: {stats['capacity']}")
print(f"Current version: {stats['current_version']}")

# Cache sizes
sizes = stats['cache_sizes']
print(f"Tracks cached: {sizes['track_by_index']}")
print(f"Scenes cached: {sizes['scene_by_index']}")

# Performance stats
perf = stats['statistics']
print(f"Hit rate: {perf['hit_rate']:.2%}")
print(f"Total requests: {perf['total_requests']}")
print(f"Hits: {perf['hits']}")
print(f"Misses: {perf['misses']}")
print(f"Invalidations: {perf['invalidations']}")

Example Output

{
  "enabled": true,
  "capacity": 256,
  "current_version": "8f3a2b...",
  "cache_sizes": {
    "track_by_index": 24,
    "scene_by_index": 48,
    "all_tracks_cached": true,
    "all_scenes_cached": true
  },
  "statistics": {
    "hits": 1523,
    "misses": 87,
    "invalidations": 3,
    "evictions": 0,
    "total_requests": 1610,
    "hit_rate": 0.946
  }
}

Usage Patterns

In Event Handlers

# Scene handler using cache
class SceneEventHandler(BaseEventHandler):
    async def handle_scene_added(self, args, seq_num):
        # Cache automatically used
        scenes = ASTNavigator.get_scenes(
            self.ast,
            cache=self.server.cache
        )

        current_scene_count = len(scenes)
        # ... process event

In Custom Code

# Manual cache usage
def my_custom_lookup(server, track_idx):
    # Use cache for performance
    track = ASTNavigator.find_track_by_index(
        server.current_ast,
        track_idx,
        cache=server.cache
    )

    if track:
        return track.attributes.get('name')
    return None

Bypass Cache

# Bypass cache when needed
track = ASTNavigator.find_track_by_index(
    server.current_ast,
    0,
    cache=None  # No caching
)

Best Practices

1. Let It Auto-Configure

The default settings work well for most projects:

# This is fine for 95% of use cases
server = ASTServer()  # Uses defaults

2. Monitor Hit Rates

Check cache effectiveness periodically:

stats = server.get_cache_stats()
if stats['statistics']['hit_rate'] < 0.70:
    print("⚠️ Low cache hit rate - consider increasing capacity")

3. Increase Capacity for Large Projects

# For large projects (> 50 tracks, > 100 scenes)
server = ASTServer(cache_capacity=512)

4. Disable for Correctness Testing

# When debugging AST consistency issues
server = ASTServer(enable_cache=False)

# Run tests without caching to verify correctness
assert compute_expected_result() == actual_result()

5. Reset Stats for Benchmarking

# Reset statistics before benchmark
server.cache.reset_stats()

# Run benchmark
for i in range(1000):
    do_lookup(i)

# Check results
stats = server.get_cache_stats()
print(f"Hit rate: {stats['statistics']['hit_rate']:.2%}")

Implementation Details

LRU Eviction

When cache reaches capacity, least recently used item is evicted:

cache = LRUCache(capacity=3)

cache.put("a", 1)  # Cache: [a]
cache.put("b", 2)  # Cache: [a, b]
cache.put("c", 3)  # Cache: [a, b, c]  ← Full

cache.get("a")     # Cache: [b, c, a]  ← 'a' moved to end

cache.put("d", 4)  # Cache: [c, a, d]  ← 'b' evicted (LRU)

Thread Safety

The cache is thread-safe using Python's built-in locks:

# Safe to use from multiple threads
thread1: cache.put("key", value1)
thread2: cache.get("key")  # Won't corrupt cache

Hash-Based Invalidation

Cache uses AST root hash for versioning:

# AST changes trigger invalidation
old_hash = server.current_ast.hash
process_event()  # Modifies AST
hash_tree(server.current_ast)  # Recompute hashes
new_hash = server.current_ast.hash

# On next cache access
if new_hash != cache._current_version:
    cache.invalidate_all()
    cache._current_version = new_hash

Troubleshooting

Low Hit Rate

Symptom: Hit rate < 70%

Possible causes: 1. AST changing frequently (many events) 2. Capacity too small for project size 3. Access pattern not suited for caching

Solutions:

# Increase capacity
server = ASTServer(cache_capacity=512)

# Check invalidation frequency
stats = server.get_cache_stats()
print(f"Invalidations: {stats['statistics']['invalidations']}")
# If high, AST is changing frequently (expected)

High Memory Usage

Symptom: Cache using too much memory

Solutions:

# Reduce capacity
server = ASTServer(cache_capacity=128)

# Or disable for memory-constrained environments
server = ASTServer(enable_cache=False)

Stale Data

Symptom: Cache returns outdated data

Cause: Version not updated after AST modification

Solution:

# Always recompute hashes after modifying AST
hash_tree(server.current_ast)

# This updates the version
# Next cache access will invalidate if needed

See Also