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) | Operation | TDictionary | TOrderedStringMap | TFPGMap | TFPDataHashTable | TStringList |
|---|---|---|---|---|---|---|
| 20 (scope) | Insert | 250 ns | 50 ns | 100 ns | 400,833 ns | 83 ns |
| 20 (scope) | Lookup | 67 ns | <10 ns | 50 ns | 17 ns | 50 ns |
| 100 (object) | Insert | 433 ns | 67 ns | 133 ns | 79,200 ns | 67 ns |
| 100 (object) | Lookup | 67 ns | <10 ns | 133 ns | 33 ns | 67 ns |
| 1,000 (large) | Insert | 400 ns | 467 ns | 267 ns | 8,733 ns | 200 ns |
| 1,000 (large) | Lookup | 67 ns | 67 ns | 200 ns | <10 ns | 133 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.
| N | TDictionary lookup | TScopeMap lookup | TScopeMap resolve (3-level) |
|---|---|---|---|
| 10 | 75 ns | 50 ns | 50 ns |
| 30 | 56 ns | 111 ns | 122 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,
TScopeMapwas implemented and deployed for scope bindings in PR #66. However, real-world profiling with macOSsampleduring integration testing revealed that the assumption — most scopes have fewer than ~20 bindings — did not hold for the global scope and bridged contexts.CreateBridgedContextconsumed 51% of CPU samples vs 24% onmain, a 2.7× regression. The linear-scanIndexOfdominated cost at the actual scope sizes encountered in practice. Scope bindings were reverted toTOrderedStringMap<TLexicalBinding>with recursive parent walking inTGocciaScope, andScopeMap.paswas deleted.
3.3 Integer Keys (Ordered): Symbol Properties
TOrderedMap compared against TDictionary for symbol-keyed properties that require insertion order.
| N | Operation | TDictionary<Integer> | TOrderedMap<Integer> |
|---|---|---|---|
| 20 (typical) | Insert | 33 ns | 33 ns |
| 20 (typical) | Lookup | <10 ns | <10 ns |
| 1,000 | Insert | 38 ns | 13 ns |
| 1,000 | Lookup | 13 ns | 13 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
| N | Operation | TDictionary<Pointer> | THashMap<Pointer> |
|---|---|---|---|
| 1,000 | Insert | 25 ns | 13 ns |
| 50,000 | Insert | 220 ns | 173 ns |
| 50,000 | Lookup | 20 ns | 27 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=100 | TOrderedStringMap<Integer> | TOrderedStringMap<string> | Overhead |
|---|---|---|---|
| Insert | 67 ns | 100 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
| Collection | Type | Strengths | Weaknesses |
|---|---|---|---|
| TDictionary | Generic hash map (RTTI) | Mature, well-tested, fast at scale | RTTI overhead at small N, no insertion order |
| TFPGMap | Generic sorted map | Simple, low overhead | O(log N) lookup, O(N) insert (shift) |
| TFPDataHashTable | Chained hash table | Standard RTL | Catastrophic insert: per-node heap allocation |
| TStringList (sorted) | Sorted array + binary search | Ubiquitous, simple | O(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 Component | Recommended Map | Rationale |
|---|---|---|
| Object string properties | TOrderedStringMap<V> | 4–6× faster than TDictionary at typical sizes; preserves insertion order per spec |
| Symbol-keyed properties | TOrderedMap<TSymbolID, V> | Parity with TDictionary; provides required insertion order |
| Scope chain variables | 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 tracking | THashMap<Pointer, V> | Backshift deletion prevents tombstone accumulation |
| Integer-keyed caches | TDictionary<Integer, V> or THashMap | FPC 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 ona 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 onthe 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 polymorphismbut the actual work (hashing, probing, scanning) is private to eachsubclass and fully inlined.}unit BaseMap;{$mode delphi}{$H+}interfaceusesSysUtils;typeTBaseMap<TKey, TValue> = classpublic typeTKeyValuePair = recordKey: TKey;Value: TValue;end;TKeyValueArray = array of TKeyValuePair;TKeyArray = array of TKey;TValueArray = array of TValue;TForEachCallback = procedure(const AKey: TKey; const AValue: TValue);protectedfunction 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;publicprocedure 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 canoverride them for specific key types (e.g. TOrderedStringMap forproper string hashing). Default implementation: DJB2 over raw keybytes + 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 stringkeys the comparison itself dominates. For small value types thebyte loop is branch-predictor friendly. The benefit is one orderedmap implementation instead of two.}unit OrderedMap;{$mode delphi}{$H+}interfaceusesSysUtils, BaseMap;typeTOrderedMap<TKey, TValue> = class(TBaseMap<TKey, TValue>)public typeTEntry = recordKey: TKey;Value: TValue;Hash: Cardinal;Active: Boolean;end;TEntryArray = array of TEntry;private constEMPTY_SLOT = -1;DELETED_SLOT = -2;INITIAL_CAPACITY = 16;LOAD_FACTOR_PERCENT = 70;privateFEntries: 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;publicconstructor 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 andequality for proper string content comparison (DJB2 on chars, native =).All ordered map machinery is inherited — zero duplication.}unit OrderedStringMap;{$mode delphi}{$H+}interfaceusesSysUtils, BaseMap, OrderedMap;typeTOrderedStringMap<TValue> = class(TOrderedMap<string, TValue>)protectedfunction 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+}interfaceusesSysUtils, BaseMap;typeTScopeMap<TValue> = class(TBaseMap<string, TValue>)privateFNames: array of string;FValues: array of TValue;FCount: Integer;FCapacity: Integer;FParent: TScopeMap<TValue>;procedure EnsureCapacity; inline;function IndexOf(const AKey: string): Integer; inline;protectedfunction 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;publicconstructor 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+}interfaceusesSysUtils, BaseMap;typeTHashMap<TKey, TValue> = class(TBaseMap<TKey, TValue>)private typeTSlot = recordKey: TKey;Value: TValue;Hash: Cardinal;Used: Boolean;end;private constINITIAL_CAPACITY = 16;LOAD_FACTOR_PERCENT = 70;privateFSlots: 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);protectedfunction 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;publicconstructor 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
-
Original spike (benchmarks above). Micro-benchmarks showed
TScopeMapoutperformingTDictionaryat N≤10 (1.5×) and crossing over around N=30. The recommendation was to useTScopeMapfor scope chain bindings, since most function scopes were assumed to have fewer than ~20 bindings. -
Implementation (PR #66).
TScopeMap<TLexicalBinding>was deployed for all scope bindings, with built-in parent chain walking (Resolve,Assign,Has).THashMapandTOrderedStringMapwere also adopted for their respective use cases. -
Integration testing revealed regressions. Bytecode benchmarks showed regressions of up to -42% in ArrayBuffer, TypedArray, collections, and string operations. Initial investigation focused on
THashMaphashing costs, leading to the optimizations below (multiplicative hash, bitwise AND indexing). -
Profiling identified
TScopeMapas the bottleneck. macOSsampleprofiling showedCreateBridgedContext(which triggered scope chain traversal) consuming 51% of CPU samples vs 24% onmain. The root cause: real-world global scopes and bridged interpreter contexts have significantly more than 20 bindings, pushingTScopeMap's linear-scanIndexOfinto 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. -
Revert.
TGocciaScopeBindingMapwas changed back toTOrderedStringMap<TLexicalBinding>,TGocciaScopereverted to recursive parent-chain walking, andScopeMap.paswas deleted. This eliminated the regressions.
Other changes since the original spike
-
TOrderedStringMaprefactored. It is now a standalone class inheritingTBaseMap<string, TValue>directly (not a thin subclass ofTOrderedMap) withstatic inlineDJB2 hash and native string equality — zero virtual dispatch on hash/equality. -
THashMaphash/equality optimized. Pointer-sized keys use multiplicative hash (golden-ratio/Fibonacci) instead of byte-by-byte DJB2, and direct integer equality instead ofCompareMem. Allmodoperations 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
TScopeMapfinding (section 3.2) is also correct at N=10 — the issue was that the assumption about real-world scope sizes did not hold.