A FreePascal Implementation with Benchmark Analysis
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).
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)
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.
All benchmarks: FPC 3.2.2, -O2, Linux x86-64. Best of 3 iterations with loop multipliers for measurable wall-clock times.
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.
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.
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.
| 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.
| 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.
| 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.
| 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 |
{
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)
{
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)
{
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)
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)
{
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)
-
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.
-
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.