Browse documentation

Custom Hash Map Implementations for Language Runtimes

A FreePascal Implementation with Benchmark Analysis

1. Problem Statement

Language interpreters and virtual machines require multiple map data structures with fundamentally different access patterns. The standard library TDictionary, while general-purpose, carries overhead from RTTI-based hashing and IEqualityComparer interface dispatch that becomes significant on hot paths such as variable resolution and property lookup.

This investigation designs, implements, and benchmarks a family of purpose-built hash maps in FreePascal (Delphi mode), each optimised for a specific runtime use case. We also compare against other RTL alternatives: TFPGMap (sorted generic map), TFPDataHashTable (chained hash table), and TStringList (sorted, binary search).

2. Architecture

2.1 Class Hierarchy

All maps inherit from a single generic base class, TBaseMap<TKey, TValue>, which defines the shared API contract and concrete iteration. Hash and equality logic stays in each subclass where it can be inlined or overridden as appropriate:

TBaseMap<K, V>
├─ TOrderedMap<K, V> insertion-ordered, virtual Hash/Equal
│ └─ TOrderedStringMap<V> overrides for string content
├─ THashMap<K, V> unordered, backshift delete, static inline
└─ TScopeMap<V> linear scan + parent chain walk (removed — see Update)

2.2 Design Decisions

Hash function: DJB2 (hash = hash * 33 + byte). Adequate distribution, minimal code. String keys hash over characters; value-type keys over raw memory bytes.

Ordered deletion: Tombstone-based with compaction when tombstones exceed 50% of entries.

Unordered deletion: Backshift (Robin Hood). Displaced entries shift to fill gaps, keeping the table dense for GC-style bulk operations.

Scope lookup: Flat parallel arrays with backwards linear scan. Most recently defined bindings found first, matching lexical scoping semantics.

Virtual vs inline: HashKey/KeysEqual are virtual in TOrderedMap (for TOrderedStringMap override) but static inline in THashMap. GetNextEntry is virtual but only called during bulk iteration, never on the hot lookup path.

3. Benchmark Results

All benchmarks: FPC 3.2.2, -O2, Linux x86-64. Best of 3 iterations with loop multipliers for measurable wall-clock times.

3.1 String Keys: All Competitors

Five string-keyed maps compared at interpreter-relevant sizes. This is the primary comparison for object property storage.

N (context)OperationTDictionaryTOrderedStringMapTFPGMapTFPDataHashTableTStringList
20 (scope)Insert250 ns50 ns100 ns400,833 ns83 ns
20 (scope)Lookup67 ns<10 ns50 ns17 ns50 ns
100 (object)Insert433 ns67 ns133 ns79,200 ns67 ns
100 (object)Lookup67 ns<10 ns133 ns33 ns67 ns
1,000 (large)Insert400 ns467 ns267 ns8,733 ns200 ns
1,000 (large)Lookup67 ns67 ns200 ns<10 ns133 ns

TOrderedStringMap delivers 4–6× faster inserts than TDictionary at scope and object sizes (N=20–100). TFPDataHashTable has catastrophic insert performance due to chained-bucket overhead with per-node allocation. TFPGMap (sorted binary search) and TStringList are competitive on insert but slower on lookup at larger N due to O(log N) binary search vs O(1) hash. At N=1000 the hash maps converge.

3.2 Scope Chain: Variable Resolution

TScopeMap compared against TDictionary for scope-chain variable lookup, the hottest path in any interpreter.

NTDictionary lookupTScopeMap lookupTScopeMap resolve (3-level)
1075 ns50 ns50 ns
3056 ns111 ns122 ns

TScopeMap wins at N=10 (1.5× faster) where linear scan has less overhead than hashing. At N=30 the O(n) scan crosses over and TDictionary's O(1) amortised lookup wins. The sweet spot for TScopeMap is scopes with fewer than ~20 bindings, which covers the vast majority of real-world function scopes. Its Resolve and Assign methods provide chain-walking semantics that no hash map can replicate.

Post-implementation finding (March 2026): Based on these benchmarks, TScopeMap was implemented and deployed for scope bindings in PR #66. However, real-world profiling with macOS sample during integration testing revealed that the assumption — most scopes have fewer than ~20 bindings — did not hold for the global scope and bridged contexts. CreateBridgedContext consumed 51% of CPU samples vs 24% on main, a 2.7× regression. The linear-scan IndexOf dominated cost at the actual scope sizes encountered in practice. Scope bindings were reverted to TOrderedStringMap<TLexicalBinding> with recursive parent walking in TGocciaScope, and ScopeMap.pas was deleted.

3.3 Integer Keys (Ordered): Symbol Properties

TOrderedMap compared against TDictionary for symbol-keyed properties that require insertion order.

NOperationTDictionary<Integer>TOrderedMap<Integer>
20 (typical)Insert33 ns33 ns
20 (typical)Lookup<10 ns<10 ns
1,000Insert38 ns13 ns
1,000Lookup13 ns13 ns

Near-parity at typical symbol counts (N=20). At N=1000, TOrderedMap is faster on insert. Both are extremely fast on integer keys. TOrderedMap provides insertion-order iteration which TDictionary cannot, making it the correct choice for spec-compliant symbol property storage regardless of performance.

3.4 Pointer Keys (Unordered): GC Mark Sets

NOperationTDictionary<Pointer>THashMap<Pointer>
1,000Insert25 ns13 ns
50,000Insert220 ns173 ns
50,000Lookup20 ns27 ns

THashMap wins on insert (the dominant GC operation) with its backshift deletion preventing tombstone accumulation across mark/sweep cycles. Lookup is comparable. The advantage grows at scale.

3.5 Value Type Impact: Managed vs Unmanaged

N=100TOrderedStringMap<Integer>TOrderedStringMap<string>Overhead
Insert67 ns100 ns+50%
Lookup<10 ns<10 ns~None

String values incur ~50% overhead on insert due to reference counting and memory allocation. Lookup is unaffected since it returns by reference. For value-heavy objects where the stored value is a managed type (string, dynamic array, interface), this cost is unavoidable but should be factored into capacity planning.

4. RTL Alternatives Assessment

CollectionTypeStrengthsWeaknesses
TDictionaryGeneric hash map (RTTI)Mature, well-tested, fast at scaleRTTI overhead at small N, no insertion order
TFPGMapGeneric sorted mapSimple, low overheadO(log N) lookup, O(N) insert (shift)
TFPDataHashTableChained hash tableStandard RTLCatastrophic insert: per-node heap allocation
TStringList (sorted)Sorted array + binary searchUbiquitous, simpleO(log N) lookup, string-only keys

TFPDataHashTable should be avoided entirely in performance-sensitive code. TFPGMap and TStringList are reasonable for small, read-heavy datasets but their O(log N) lookup prevents them from competing with hash-based structures at scale.

5. Recommendations

Runtime ComponentRecommended MapRationale
Object string propertiesTOrderedStringMap<V>4–6× faster than TDictionary at typical sizes; preserves insertion order per spec
Symbol-keyed propertiesTOrderedMap<TSymbolID, V>Parity with TDictionary; provides required insertion order
Scope chain variablesTScopeMap<V>TOrderedStringMap<TLexicalBinding>Originally recommended TScopeMap (linear scan, optimal at N<20). Reverted after profiling showed 2.7× regression at real-world scope sizes — see section 3.2
GC mark sets, object trackingTHashMap<Pointer, V>Backshift deletion prevents tombstone accumulation
Integer-keyed cachesTDictionary<Integer, V> or THashMapFPC integer hash is trivially cheap; consider custom hash for THashMap

6. Source Code (Interface Sections)

BaseMap.pas

{
TBaseMap<TKey, TValue> - Abstract generic base for all map types.
Provides:
- Shared type aliases (TKeyValuePair, TKeyValueArray, etc.)
- Abstract API contract (Add, TryGetValue, ContainsKey, Remove, Clear)
- Concrete iteration (ToArray, Keys, Values, ForEach) built on
a single abstract GetNextEntry primitive.
Performance notes:
- No hash or equality logic lives here. Each subclass brings its own,
typically as static/inline class methods — zero virtual overhead on
the inner probe loops.
- GetNextEntry is virtual but only invoked during bulk iteration,
never on the hot lookup path.
- Core operations (Add, TryGetValue, etc.) are virtual for polymorphism
but the actual work (hashing, probing, scanning) is private to each
subclass and fully inlined.
}
unit BaseMap;
{$mode delphi}{$H+}
interface
uses
SysUtils;
type
TBaseMap<TKey, TValue> = class
public type
TKeyValuePair = record
Key: TKey;
Value: TValue;
end;
TKeyValueArray = array of TKeyValuePair;
TKeyArray = array of TKey;
TValueArray = array of TValue;
TForEachCallback = procedure(const AKey: TKey; const AValue: TValue);
protected
function GetCount: Integer; virtual; abstract;
function GetValue(const AKey: TKey): TValue; virtual; abstract;
procedure SetValue(const AKey: TKey; const AValue: TValue); virtual; abstract;
{ Iteration primitive. Subclasses advance AIterState (opaque integer)
and return the next active entry. Return False when exhausted.
AIterState is initialized to 0 before the first call. }
function GetNextEntry(var AIterState: Integer;
out AKey: TKey; out AValue: TValue): Boolean; virtual; abstract;
public
procedure Add(const AKey: TKey; const AValue: TValue); virtual; abstract;
function TryGetValue(const AKey: TKey; out AValue: TValue): Boolean; virtual; abstract;
function ContainsKey(const AKey: TKey): Boolean; virtual; abstract;
function Remove(const AKey: TKey): Boolean; virtual; abstract;
procedure Clear; virtual; abstract;
{ Concrete iteration — built once on GetNextEntry }
function ToArray: TKeyValueArray;
procedure ForEach(ACallback: TForEachCallback);
function Keys: TKeyArray;
function Values: TValueArray;
property Items[const AKey: TKey]: TValue read GetValue write SetValue; default;
property Count: Integer read GetCount;
end;

(Full implementation: 137 lines)

OrderedMap.pas

{
TOrderedMap<TKey, TValue> - Insertion-order-preserving generic map.
Inherits TBaseMap<TKey, TValue> for shared types and iteration.
Hash and equality are protected virtual methods so subclasses can
override them for specific key types (e.g. TOrderedStringMap for
proper string hashing). Default implementation: DJB2 over raw key
bytes + byte-level equality — correct for all fixed-size value types.
Performance note on virtual hash/equality:
The VMT hop per probe step is negligible in practice. For string
keys the comparison itself dominates. For small value types the
byte loop is branch-predictor friendly. The benefit is one ordered
map implementation instead of two.
}
unit OrderedMap;
{$mode delphi}{$H+}
interface
uses
SysUtils, BaseMap;
type
TOrderedMap<TKey, TValue> = class(TBaseMap<TKey, TValue>)
public type
TEntry = record
Key: TKey;
Value: TValue;
Hash: Cardinal;
Active: Boolean;
end;
TEntryArray = array of TEntry;
private const
EMPTY_SLOT = -1;
DELETED_SLOT = -2;
INITIAL_CAPACITY = 16;
LOAD_FACTOR_PERCENT = 70;
private
FEntries: TEntryArray;
FBuckets: array of Int32;
FCount: Integer;
FEntryCount: Integer;
FBucketCount: Integer;
function FindBucket(const AKey: TKey; AHash: Cardinal;
out ABucketIdx: Integer): Boolean;
procedure Grow;
procedure Rehash(ANewBucketCount: Integer);
procedure Compact;
protected
{ Override these for key types that need custom hash/equality (e.g. strings). }
function HashKey(const AKey: TKey): Cardinal; virtual;
function KeysEqual(const A, B: TKey): Boolean; virtual;
function GetCount: Integer; override;
function GetValue(const AKey: TKey): TValue; override;
procedure SetValue(const AKey: TKey; const AValue: TValue); override;
function GetNextEntry(var AIterState: Integer;
out AKey: TKey; out AValue: TValue): Boolean; override;
public
constructor Create; overload;
constructor Create(AInitialCapacity: Integer); overload;
destructor Destroy; override;
procedure Add(const AKey: TKey; const AValue: TValue); override;
function TryGetValue(const AKey: TKey;
out AValue: TValue): Boolean; override;
function ContainsKey(const AKey: TKey): Boolean; override;
function Remove(const AKey: TKey): Boolean; override;
procedure Clear; override;
function EntryAt(AIndex: Integer): TKeyValuePair;
property Capacity: Integer read FBucketCount;
end;

(Full implementation: 355 lines)

OrderedStringMap.pas

{
TOrderedStringMap<TValue> - String-keyed ordered map.
Use case: JS object string properties.
Thin subclass of TOrderedMap<string, TValue> that overrides hash and
equality for proper string content comparison (DJB2 on chars, native =).
All ordered map machinery is inherited — zero duplication.
}
unit OrderedStringMap;
{$mode delphi}{$H+}
interface
uses
SysUtils, BaseMap, OrderedMap;
type
TOrderedStringMap<TValue> = class(TOrderedMap<string, TValue>)
protected
function HashKey(const AKey: string): Cardinal; override;
function KeysEqual(const A, B: string): Boolean; override;
end;

(Full implementation: 43 lines)

ScopeMap.pas (historical — removed March 2026)

This unit was implemented based on the benchmarks in section 3.2 and deployed in PR #66. Profiling against real workloads showed that the linear-scan assumption (most scopes have <20 bindings) did not hold for global scopes and bridged interpreter contexts, causing a 2.7× regression. The unit was deleted and scope bindings reverted to TOrderedStringMap<TLexicalBinding>. The source is preserved here for historical reference.

{
TScopeMap<TValue> - Flat-array string-keyed map for scope chains.
Use case: Variable/environment lookups in interpreter scope chains.
Inherits TBaseMap<string, TValue> for shared types and iteration.
No hashing — backwards linear scan, optimal for 5-30 bindings.
Built-in parent chain walking via Resolve/Assign/Has.
}
unit ScopeMap;
{$mode delphi}{$H+}
interface
uses
SysUtils, BaseMap;
type
TScopeMap<TValue> = class(TBaseMap<string, TValue>)
private
FNames: array of string;
FValues: array of TValue;
FCount: Integer;
FCapacity: Integer;
FParent: TScopeMap<TValue>;
procedure EnsureCapacity; inline;
function IndexOf(const AKey: string): Integer; inline;
protected
function GetCount: Integer; override;
function GetValue(const AKey: string): TValue; override;
procedure SetValue(const AKey: string; const AValue: TValue); override;
function GetNextEntry(var AIterState: Integer;
out AKey: string; out AValue: TValue): Boolean; override;
public
constructor Create(AParent: TScopeMap<TValue> = nil);
destructor Destroy; override;
procedure Add(const AKey: string; const AValue: TValue); override;
function TryGetValue(const AKey: string;
out AValue: TValue): Boolean; override;
function ContainsKey(const AKey: string): Boolean; override;
function Remove(const AKey: string): Boolean; override;
procedure Clear; override;
function Resolve(const AKey: string; out AValue: TValue): Boolean;
function Assign(const AKey: string; const AValue: TValue): Boolean;
function Has(const AKey: string): Boolean;
property Parent: TScopeMap<TValue> read FParent write FParent;
end;

(Full implementation: 234 lines)

HashMap.pas

{
THashMap<TKey, TValue> - Lightweight open-addressed generic hash map.
Use case: GC tracking, caches, interning.
Inherits TBaseMap<TKey, TValue> for shared types and iteration.
Hash/equality are static inline — no virtual dispatch on the hot path.
Backshift deletion — no tombstones ever.
}
unit HashMap;
{$mode delphi}{$H+}
interface
uses
SysUtils, BaseMap;
type
THashMap<TKey, TValue> = class(TBaseMap<TKey, TValue>)
private type
TSlot = record
Key: TKey;
Value: TValue;
Hash: Cardinal;
Used: Boolean;
end;
private const
INITIAL_CAPACITY = 16;
LOAD_FACTOR_PERCENT = 70;
private
FSlots: array of TSlot;
FCount: Integer;
FCapacity: Integer;
class function HashKey(const AKey: TKey): Cardinal; static; inline;
class function KeysEqual(const A, B: TKey): Boolean; static; inline;
function FindSlot(const AKey: TKey; AHash: Cardinal): Integer; inline;
procedure Grow;
procedure Reinsert(const ASlot: TSlot);
protected
function GetCount: Integer; override;
function GetValue(const AKey: TKey): TValue; override;
procedure SetValue(const AKey: TKey; const AValue: TValue); override;
function GetNextEntry(var AIterState: Integer;
out AKey: TKey; out AValue: TValue): Boolean; override;
public
constructor Create; overload;
constructor Create(AInitialCapacity: Integer); overload;
destructor Destroy; override;
procedure Add(const AKey: TKey; const AValue: TValue); override;
function TryGetValue(const AKey: TKey;
out AValue: TValue): Boolean; override;
function ContainsKey(const AKey: TKey): Boolean; override;
function Remove(const AKey: TKey): Boolean; override;
procedure Clear; override;
property Capacity: Integer read FCapacity;
end;

(Full implementation: 266 lines)

Update (March 2026)

Timeline

  1. Original spike (benchmarks above). Micro-benchmarks showed TScopeMap outperforming TDictionary at N≤10 (1.5×) and crossing over around N=30. The recommendation was to use TScopeMap for scope chain bindings, since most function scopes were assumed to have fewer than ~20 bindings.

  2. Implementation (PR #66). TScopeMap<TLexicalBinding> was deployed for all scope bindings, with built-in parent chain walking (Resolve, Assign, Has). THashMap and TOrderedStringMap were also adopted for their respective use cases.

  3. Integration testing revealed regressions. Bytecode benchmarks showed regressions of up to -42% in ArrayBuffer, TypedArray, collections, and string operations. Initial investigation focused on THashMap hashing costs, leading to the optimizations below (multiplicative hash, bitwise AND indexing).

  4. Profiling identified TScopeMap as the bottleneck. macOS sample profiling showed CreateBridgedContext (which triggered scope chain traversal) consuming 51% of CPU samples vs 24% on main. The root cause: real-world global scopes and bridged interpreter contexts have significantly more than 20 bindings, pushing TScopeMap's linear-scan IndexOf into its O(n) penalty zone. The micro-benchmark assumption (most scopes <20 bindings) was correct for function-local scopes but not for the scopes that dominate actual execution cost.

  5. Revert. TGocciaScopeBindingMap was changed back to TOrderedStringMap<TLexicalBinding>, TGocciaScope reverted to recursive parent-chain walking, and ScopeMap.pas was deleted. This eliminated the regressions.

Other changes since the original spike

  • TOrderedStringMap refactored. It is now a standalone class inheriting TBaseMap<string, TValue> directly (not a thin subclass of TOrderedMap) with static inline DJB2 hash and native string equality — zero virtual dispatch on hash/equality.

  • THashMap hash/equality optimized. Pointer-sized keys use multiplicative hash (golden-ratio/Fibonacci) instead of byte-by-byte DJB2, and direct integer equality instead of CompareMem. All mod operations replaced with bitwise AND.

  • Original benchmarks remain valid. The micro-benchmarks in sections 3.1–3.5 remain valid for the general comparison at the tested sizes. The TScopeMap finding (section 3.2) is also correct at N=10 — the issue was that the assumption about real-world scope sizes did not hold.