Your Hash Collisions Are Murdering Your Unity Framerate!
Stop Hash Collisions From Murdering Your Unity Framerate! An Advanced Guide to IEqualityComparer<T>
Introduction: The Invisible Performance Killer
You’ve meticulously profiled your Unity game, optimized your loops, batched your draw calls, and yet, those frustrating, seemingly random hitches persist. You might even observe them as momentary framerate dips or micro-stutters that defy conventional optimization techniques. Often, the silent assassin isn’t your rendering pipeline or physics engine, but rather the unassuming Dictionary<TKey, TValue> or HashSet<T> instances scattered throughout your codebase.
The culprit lies in how these collections determine object equality and generate hash codes for custom classes. By default, they rely on your object’s GetHashCode() and Equals() implementations. For simple value types or built-in reference types, this is usually fine. But for complex custom classes – especially those with nested structures or numerous fields – the default implementations (or even poorly optimized overrides) can be brutally slow. This leads to frequent hash collisions, forcing your collections to degrade from lightning-fast O(1) lookups to much slower O(N) linear searches, bottlenecking your entire application. This overlooked advanced C# technique is absolutely critical for performance-sensitive Unity systems.
Code Layout/Walkthrough: Crafting a Bulletproof Comparer
The core problem is that for a custom reference type, if you don’t override GetHashCode() and Equals(), Dictionary will use object.GetHashCode() (based on memory address) and object.Equals() (reference equality), which isn’t useful for comparing object values. If you do override them, you might still write slow code or generate poor hash distributions.
Let’s imagine a common Unity scenario: tracking game objects by a complex coordinate.
The Problematic Type (Implicitly)
Consider a GridCellCoordinate class that defines a unique location in your game world:
public class GridCellCoordinate
{
public int X { get; }
public int Y { get; }
public string LayerID { get; } // Identifies a specific layer within the grid
public GridCellCoordinate(int x, int y, string layerId)
{
X = x;
Y = y;
LayerID = layerId;
}
// No custom GetHashCode() or Equals() overrides here (or a bad one)
}
If you use GridCellCoordinate directly as a key in a Dictionary or HashSet without carefully overriding GetHashCode() and Equals(), you’re effectively telling the collection to guess how to compare them. This will result in poor performance or incorrect behavior (e.g., two GridCellCoordinate instances with identical X, Y, and LayerID values being treated as different keys). Even if you did override them, they might be inefficient for your specific use case.
The Solution: Implementing IEqualityComparer<T>
Instead of modifying the GridCellCoordinate class itself, we’ll create a dedicated comparer. This is particularly powerful because it allows you to define how equality is checked without altering the class, useful even for types you don’t own.
using System.Collections.Generic;
using System; // For HashCode.Combine
public class GridCellCoordinateComparer : IEqualityComparer<GridCellCoordinate>
{
// Caching a static instance is crucial to avoid GC pressure
public static readonly GridCellCoordinateComparer Default = new GridCellCoordinateComparer();
public bool Equals(GridCellCoordinate a, GridCellCoordinate b)
{
// Handle nulls and reference equality first for early exit
if (ReferenceEquals(a, b)) return true;
if (ReferenceEquals(a, null) || ReferenceEquals(b, null)) return false;
// Compare relevant, immutable fields for value equality
return a.X == b.X &&
a.Y == b.Y &&
string.Equals(a.LayerID, b.LayerID, StringComparison.Ordinal); // Use Ordinal for performance
}
public int GetHashCode(GridCellCoordinate obj)
{
if (obj == null) return 0; // Or throw ArgumentNullException if null keys aren't expected
// Use C# 8+ HashCode.Combine for a robust and concise hash generation
// Only include relevant, immutable fields that define equality
return HashCode.Combine(obj.X, obj.Y, obj.LayerID);
/* For pre-C# 8 projects, a manual prime multiplication approach:
unchecked // Allows arithmetic overflow without exception
{
int hash = 17; // A prime number
hash = hash * 23 + obj.X.GetHashCode();
hash = hash * 23 + obj.Y.GetHashCode();
hash = hash * 23 + (obj.LayerID?.GetHashCode() ?? 0); // Handle null LayerID
return hash;
}
*/
}
}
Understanding the Comparer:
Equals(GridCellCoordinate a, GridCellCoordinate b): This method defines value equality.- It first handles
nullreferences and reference equality (a == b) for quick exits. - Crucially, it then compares only the fields that define uniqueness for your use case (
X,Y,LayerID). Usingstring.Equals(..., StringComparison.Ordinal)is a performance optimization for string comparisons when cultural differences don’t matter.
- It first handles
GetHashCode(GridCellCoordinate obj): This method generates a deterministic hash code based on the same relevant fields used inEquals.- A good hash function aims to distribute values as evenly as possible across the hash table, minimizing collisions.
HashCode.Combine(C# 8+) is the modern, recommended way to combine hash codes robustly. For older C#, theuncheckedprime multiplication pattern is effective.- It’s vital that if two objects are considered equal by
Equals(), they must produce the exact same hash code fromGetHashCode().
Using Your Optimized Comparer
Finally, instruct your Dictionary or HashSet to use your custom comparer:
using System.Collections.Generic;
using UnityEngine; // For MonoBehaviour in a real Unity script
public class GameManager : MonoBehaviour
{
// Always cache your comparer instance! Don't create new ones repeatedly.
private readonly Dictionary<GridCellCoordinate, GameObject> _gameObjectsByCoord =
new Dictionary<GridCellCoordinate, GameObject>(GridCellCoordinateComparer.Default);
private readonly HashSet<GridCellCoordinate> _visitedCells =
new HashSet<GridCellCoordinate>(GridCellCoordinateComparer.Default);
void Start()
{
GridCellCoordinate coord1 = new GridCellCoordinate(1, 2, "Ground");
GridCellCoordinate coord2 = new GridCellCoordinate(1, 2, "Ground");
GridCellCoordinate coord3 = new GridCellCoordinate(3, 4, "Air");
_gameObjectsByCoord.Add(coord1, new GameObject("Cell_1_2_Ground"));
Debug.Log($"Contains coord2: {_gameObjectsByCoord.ContainsKey(coord2)}"); // Will be true!
_visitedCells.Add(coord1);
_visitedCells.Add(coord2); // Only one will be added due to custom equality
Debug.Log($"Visited cells count: {_visitedCells.Count}"); // Will be 1!
}
}
By providing GridCellCoordinateComparer.Default to the collection constructor, you’re overriding the default behavior with your highly optimized and deterministic logic. This guarantees that your lookups are fast, precise, and free from the hidden performance cost of hash collisions.
Conclusion: Reclaiming Your Framerate
The persistent, unexplained hitches in your Unity project might not be mysteries, but rather the silent, insidious work of hash collisions in your Dictionary and HashSet instances. Relying on default GetHashCode() and Equals() for custom complex types is a common pitfall that can degrade your application’s performance dramatically.
By implementing a custom IEqualityComparer<TKey>, you gain precise control over how your keys are compared and hashed. This allows you to define deterministic, highly optimized logic that factors in only the relevant, immutable fields. The result is dramatically improved lookup times, reduced CPU overhead, and a smoother, more responsive game experience. While C# record types offer some default equality benefits, IEqualityComparer provides ultimate flexibility and control, making it an indispensable tool for advanced Unity developers. Stop letting your dictionaries guess – explicitly tell them how to perform!