Performance & Optimization
TinySave is designed for performance, but understanding how it works helps you optimize save/load operations for your game. This guide covers performance monitoring, best practices, and optimization techniques.
Performance Counters
TinySave includes built-in performance monitoring via the PerfCounters class. After every save or load operation, you can inspect timing and size metrics.
PerfCounters Class
public class PerfCounters
{
public long CaptureMs { get; } // Time to capture scene state
public long SerializeMs { get; } // Time to serialize to JSON
public long IoMs { get; } // Time for disk I/O
public long ResolveMs { get; } // Time to restore state (load only)
public long TotalMs { get; } // Total operation time
public long BytesWritten { get; } // Save file size in bytes
}Accessing Performance Data
using TinySave.Runtime;
// After a save operation
await SaveManager.SaveAsync("slot1");
PerfCounters perf = SaveManager.LastPerfCounters;
Debug.Log($"Capture: {perf.CaptureMs}ms");
Debug.Log($"Serialize: {perf.SerializeMs}ms");
Debug.Log($"IO: {perf.IoMs}ms");
Debug.Log($"Total: {perf.TotalMs}ms");
Debug.Log($"File size: {perf.BytesWritten} bytes");
// After a load operation
await SaveManager.LoadAsync("slot1");
perf = SaveManager.LastPerfCounters;
Debug.Log($"IO: {perf.IoMs}ms");
Debug.Log($"Deserialize: {perf.SerializeMs}ms");
Debug.Log($"Restore: {perf.ResolveMs}ms");
Debug.Log($"Total: {perf.TotalMs}ms");ToString() for Quick Logging
PerfCounters includes a convenient ToString() method:
await SaveManager.SaveAsync("slot1");
Debug.Log(SaveManager.LastPerfCounters.ToString());
// Output:
// "TinySave Perf: Capture=5ms, Serialize=12ms, IO=8ms, Resolve=0ms, Total=25ms, Bytes=52481"Save/Load Pipeline
Understanding the pipeline helps identify bottlenecks:
Save Pipeline
-
Capture (Main Thread)
- Scan scene for SaveID objects
- Read [SaveField] values via reflection
- Call ISaveCallbacks.OnBeforeSave()
- Build SaveFile structure
-
Serialize (Background Thread)
- Convert SaveFile to JSON
- Apply encryption if enabled
-
I/O (Background Thread)
- Write to temporary file
- Atomic rename to final file
Load Pipeline
-
I/O (Background Thread)
- Read file from disk
- Decrypt if encrypted
-
Deserialize (Background Thread)
- Parse JSON to SaveFile
- Apply migrations if needed
-
Restore (Main Thread)
- Find SaveID objects in scene
- Write values to [SaveField] fields
- Call ISaveCallbacks.OnAfterLoad()
Performance Characteristics
Reflection Caching
TinySave caches reflection data to minimize overhead:
// First access per type: ~1-5ms (one-time cost)
// Subsequent accesses: ~0.1ms (cached)The reflection cache is built automatically when:
- A component with [SaveField] is first encountered
- Types are discovered during scene traversal
Threading
- Capture and Restore run on the main thread (Unity requirement)
- Serialization and I/O run on background threads
- Async/await prevents blocking the game loop
Typical Performance
For reference, here are typical performance numbers:
Small game (10 objects, 50 fields):
- Save: 15-30ms
- Load: 10-20ms
- File size: 5-10 KB
Medium game (100 objects, 500 fields):
- Save: 50-100ms
- Load: 40-80ms
- File size: 50-100 KB
Large game (1000 objects, 5000 fields):
- Save: 200-400ms
- Load: 150-300ms
- File size: 500-1000 KBPerformance varies based on:
- Number of objects with SaveID
- Number of [SaveField] fields
- Complexity of data (nested collections, large strings)
- Encryption enabled/disabled
- Disk speed
Optimization Techniques
1. Mark Only Essential Fields
Only save what you need to restore game state:
// Bad: Saves everything
public class Player : MonoBehaviour
{
[SaveField] public float health;
[SaveField] public float maxHealth;
[SaveField] public Vector3 velocity; // Recalculated, don't save
[SaveField] public float jumpTimer; // Temporary state, don't save
[SaveField] public List<Effect> activeEffects; // Large, optimize separately
}
// Good: Only saves essential state
public class Player : MonoBehaviour
{
[SaveField] public float health;
[SaveField] public float maxHealth;
public Vector3 velocity; // Not saved, reset on load
public float jumpTimer; // Not saved
public List<Effect> activeEffects; // Use ISaveCustom for optimization
}2. Use ISaveCustom for Large Collections
Optimize large data structures with custom serialization:
// Before: Saves entire dictionary
public class WorldState : MonoBehaviour
{
[SaveField]
public Dictionary<string, ChunkData> chunks; // Could be huge!
}
// After: Save only modified chunks
public class WorldState : MonoBehaviour, ISaveCustom
{
public Dictionary<string, ChunkData> chunks;
private HashSet<string> modifiedChunks = new HashSet<string>();
public object CaptureState()
{
// Only save chunks that were modified
var saveData = new Dictionary<string, ChunkData>();
foreach (string chunkId in modifiedChunks)
{
if (chunks.ContainsKey(chunkId))
saveData[chunkId] = chunks[chunkId];
}
return saveData;
}
public void RestoreState(object state)
{
var saveData = state as Dictionary<string, ChunkData>;
foreach (var kvp in saveData)
{
chunks[kvp.Key] = kvp.Value;
}
}
}3. Disable Auto-Save for Inactive Objects
Use TinySaveAutoSave to selectively disable saving:
// Disable saving for inactive enemies
void OnEnemyDefeated()
{
var autoSave = GetComponent<TinySaveAutoSave>();
autoSave.AutoSaveEnabled = false;
}
// Or save destroyed state without full data
void OnEnemyDefeated()
{
var autoSave = GetComponent<TinySaveAutoSave>();
autoSave.saveDestroyed = true;
autoSave.ClearSelectedComponents(); // Don't save component data
}4. Compress String Data
If you save large strings, compress them manually:
using System.IO;
using System.IO.Compression;
using System.Text;
public class CompressedData : MonoBehaviour, ISaveCustom
{
public string largeTextData;
public object CaptureState()
{
byte[] bytes = Encoding.UTF8.GetBytes(largeTextData);
using (var output = new MemoryStream())
{
using (var gzip = new GZipStream(output, CompressionMode.Compress))
{
gzip.Write(bytes, 0, bytes.Length);
}
return System.Convert.ToBase64String(output.ToArray());
}
}
public void RestoreState(object state)
{
byte[] compressed = System.Convert.FromBase64String((string)state);
using (var input = new MemoryStream(compressed))
using (var gzip = new GZipStream(input, CompressionMode.Decompress))
using (var output = new MemoryStream())
{
gzip.CopyTo(output);
largeTextData = Encoding.UTF8.GetString(output.ToArray());
}
}
}5. Batch Save Operations
If saving frequently, add a debounce timer:
public class AutoSaveController : MonoBehaviour
{
private float timeSinceLastSave;
private bool pendingSave;
void Update()
{
timeSinceLastSave += Time.deltaTime;
// Save at most once every 30 seconds
if (pendingSave && timeSinceLastSave >= 30f)
{
_ = PerformSave();
pendingSave = false;
timeSinceLastSave = 0f;
}
}
public void RequestSave()
{
pendingSave = true;
}
private async Task PerformSave()
{
await SaveManager.SaveAsync("autosave");
}
}6. Use Synchronous Save Sparingly
SaveSync() blocks the main thread - use only when necessary:
// Good: OnApplicationQuit where blocking is acceptable
void OnApplicationQuit()
{
SaveManager.SaveSync("autosave");
}
// Bad: Frequent blocking saves during gameplay
void Update()
{
if (Input.GetKeyDown(KeyCode.S))
{
SaveManager.SaveSync("quicksave"); // Blocks game!
}
}
// Good: Async save during gameplay
async void Update()
{
if (Input.GetKeyDown(KeyCode.S))
{
await SaveManager.SaveAsync("quicksave"); // Non-blocking
}
}7. Minimize SaveID Components
Only add SaveID to objects that actually need persistence:
// Bad: SaveID on every object
GameObject particle = Instantiate(particlePrefab);
particle.AddComponent<SaveID>(); // Particles don't need saving!
// Good: SaveID only on persistent objects
GameObject player = Instantiate(playerPrefab);
player.AddComponent<SaveID>(); // Player needs saving
GameObject enemy = Instantiate(enemyPrefab);
enemy.AddComponent<SaveID>(); // Enemies need saving
GameObject particle = Instantiate(particlePrefab);
// No SaveID - particles are transientMonitoring and Profiling
Log Performance Metrics
Create a performance monitoring system:
public class SavePerformanceMonitor : MonoBehaviour
{
private List<PerfCounters> saveHistory = new List<PerfCounters>();
void OnEnable()
{
SaveManager.BeforeSave += OnBeforeSave;
}
void OnDisable()
{
SaveManager.BeforeSave -= OnBeforeSave;
}
async void OnBeforeSave()
{
// Wait for save to complete (next frame)
await Task.Yield();
PerfCounters perf = SaveManager.LastPerfCounters;
saveHistory.Add(perf);
// Log if save is slow
if (perf.TotalMs > 100)
{
Debug.LogWarning($"Slow save detected: {perf}");
}
// Keep last 100 saves
if (saveHistory.Count > 100)
saveHistory.RemoveAt(0);
}
public void PrintAveragePerformance()
{
if (saveHistory.Count == 0) return;
float avgTotal = saveHistory.Average(p => p.TotalMs);
float avgCapture = saveHistory.Average(p => p.CaptureMs);
float avgSerialize = saveHistory.Average(p => p.SerializeMs);
float avgIo = saveHistory.Average(p => p.IoMs);
long avgBytes = (long)saveHistory.Average(p => p.BytesWritten);
Debug.Log($"Average save performance over {saveHistory.Count} saves:");
Debug.Log($" Total: {avgTotal:F2}ms");
Debug.Log($" Capture: {avgCapture:F2}ms");
Debug.Log($" Serialize: {avgSerialize:F2}ms");
Debug.Log($" IO: {avgIo:F2}ms");
Debug.Log($" File size: {avgBytes} bytes");
}
}Unity Profiler Integration
You can profile TinySave operations with Unity’s Profiler:
using UnityEngine.Profiling;
public class ProfiledSave : MonoBehaviour
{
async void SaveGame()
{
Profiler.BeginSample("TinySave.SaveAsync");
await SaveManager.SaveAsync("slot1");
Profiler.EndSample();
}
}Best Practices Summary
- Only save essential data - Don’t save calculated or transient values
- Use async/await - Prevent frame drops with asynchronous operations
- Implement ISaveCustom for large collections - Optimize complex data structures
- Monitor performance - Use PerfCounters to identify bottlenecks
- Cache references - Don’t repeatedly find components or objects
- Disable auto-save for inactive objects - Reduce data volume
- Batch saves - Don’t save on every frame
- Profile in builds - Editor performance doesn’t match build performance
- Test with real data - Profile with realistic save file sizes
- Consider platform differences - Mobile has slower disk I/O
Platform-Specific Considerations
Mobile (iOS/Android)
- Disk I/O is slower than desktop
- Use smaller save files when possible
- Save less frequently
- Test on real devices, not just editor
WebGL
- Save to IndexedDB (handled automatically by Unity)
- I/O can be slower than native platforms
- Consider save file size limits (browser storage quotas)
Console (PlayStation, Xbox, Switch)
- Follow platform-specific save guidelines
- Use synchronous save on quit (async may not complete)
- Test save/load during suspend/resume
Troubleshooting Performance Issues
Slow Capture (High CaptureMs)
Cause: Too many SaveID objects or complex ISaveCallbacks.
Solutions:
- Reduce number of SaveID components
- Optimize ISaveCallbacks.OnBeforeSave()
- Use TinySaveAutoSave to selectively disable objects
Slow Serialization (High SerializeMs)
Cause: Large save files or complex nested data.
Solutions:
- Reduce data volume with ISaveCustom
- Compress large strings manually
- Simplify data structures
Slow I/O (High IoMs)
Cause: Slow disk, large files, or encryption overhead.
Solutions:
- Save to faster storage location
- Reduce file size
- Disable encryption for testing (minimal impact in practice)
Slow Restore (High ResolveMs)
Cause: Many objects to restore or complex ISaveCallbacks.
Solutions:
- Optimize ISaveCallbacks.OnAfterLoad()
- Reduce number of SaveID objects
- Defer non-critical initialization