Work in progress
The content of this page was not yet updated for Godot
and may be outdated. If you know how to improve this page or you can confirm
that it's up to date, feel free to open a pull request.
Ever wondered whether one should approach problem X with data structure Y or Z? This article covers a variety of topics related to these dilemmas.
This article makes references to "[something]-time" operations. This terminology comes from algorithm analysis' Big O Notation.
Long-story short, it describes the worst-case scenario of runtime length. In laymen's terms:
"As the size of a problem domain increases, the runtime length of the algorithm..."
O(1): "...does not increase."
O(log n): "...increases at a slow rate."
O(n): "...increases at the same rate."
Imagine if one had to process 3 million data points within a single frame. It would be impossible to craft the feature with a linear-time algorithm since the sheer size of the data would increase the runtime far beyond the time allotted. In comparison, using a constant-time algorithm could handle the operation without issue.
By and large, developers want to avoid engaging in linear-time operations as much as possible. But, if one keeps the scale of a linear-time operation small, and if one does not need to perform the operation often, then it may be acceptable. Balancing these requirements and choosing the right algorithm / data structure for the job is part of what makes programmers' skills valuable.
Array vs. Dictionary vs. Object¶
Godot implements Array as a
Vector<Variant>. The engine stores the Array
contents in a contiguous section of memory, i.e. they are in a row adjacent
to each other.
For those unfamiliar with C++, a Vector is the name of the
array object in traditional C++ libraries. It is a "templated"
type, meaning that its records can only contain a particular type (denoted
by angled brackets). So, for example, a
PackedStringArray would be something like
Contiguous memory stores imply the following operation performance:
Iterate: Fastest. Great for loops.
Op: All it does is increment a counter to get to the next record.
Insert, Erase, Move: Position-dependent. Generally slow.
Op: Adding/removing/moving content involves moving the adjacent records over (to make room / fill space).
Fast add/remove from the end.
Slow add/remove from an arbitrary position.
Slowest add/remove from the front.
If doing many inserts/removals from the front, then...
invert the array.
do a loop which executes the Array changes at the end.
re-invert the array.
This makes only 2 copies of the array (still constant time, but slow) versus copying roughly 1/2 of the array, on average, N times (linear time).
Get, Set: Fastest by position. E.g. can request 0th, 2nd, 10th record, etc. but cannot specify which record you want.
Op: 1 addition operation from array start position up to desired index.
Find: Slowest. Identifies the index/position of a value.
Op: Must iterate through array and compare values until one finds a match.
Performance is also dependent on whether one needs an exhaustive search.
If kept ordered, custom search operations can bring it to logarithmic time (relatively fast). Laymen users won't be comfortable with this though. Done by re-sorting the Array after every edit and writing an ordered-aware search algorithm.
Godot implements Dictionary as an
OrderedHashMap<Variant, Variant>. The engine
stores a small array (initialized to 2^3 or 8 records) of key-value pairs. When
one attempts to access a value, they provide it a key. It then hashes the
key, i.e. converts it into a number. The "hash" is used to calculate the index
into the array. As an array, the OHM then has a quick lookup within the "table"
of keys mapped to values. When the HashMap becomes too full, it increases to
the next power of 2 (so, 16 records, then 32, etc.) and rebuilds the structure.
Hashes are to reduce the chance of a key collision. If one occurs, the table must recalculate another index for the value that takes the previous position into account. In all, this results in constant-time access to all records at the expense of memory and some minor operational efficiency.
Hashing every key an arbitrary number of times.
Hash operations are constant-time, so even if an algorithm must do more than one, as long as the number of hash calculations doesn't become too dependent on the density of the table, things will stay fast. Which leads to...
Maintaining an ever-growing size for the table.
HashMaps maintain gaps of unused memory interspersed in the table on purpose to reduce hash collisions and maintain the speed of accesses. This is why it constantly increases in size quadratically by powers of 2.
As one might be able to tell, Dictionaries specialize in tasks that Arrays do not. An overview of their operational details is as follows:
Op: Iterate over the map's internal vector of hashes. Return each key. Afterwards, users then use the key to jump to and return the desired value.
Insert, Erase, Move: Fastest.
Op: Hash the given key. Do 1 addition operation to look up the appropriate value (array start + offset). Move is two of these (one insert, one erase). The map must do some maintenance to preserve its capabilities:
update ordered List of records.
determine if table density mandates a need to expand table capacity.
The Dictionary remembers in what order users inserted its keys. This enables it to execute reliable iterations.
Get, Set: Fastest. Same as a lookup by key.
Op: Same as insert/erase/move.
Find: Slowest. Identifies the key of a value.
Op: Must iterate through records and compare the value until a match is found.
Note that Godot does not provide this feature out-of-the-box (because they aren't meant for this task).
Godot implements Objects as stupid, but dynamic containers of data content. Objects query data sources when posed questions. For example, to answer the question, "do you have a property called, 'position'?", it might ask its script or the ClassDB. One can find more information about what objects are and how they work in the Applying object-oriented principles in Godot article.
The important detail here is the complexity of the Object's task. Every time it performs one of these multi-source queries, it runs through several iteration loops and HashMap lookups. What's more, the queries are linear-time operations dependent on the Object's inheritance hierarchy size. If the class the Object queries (its current class) doesn't find anything, the request defers to the next base class, all the way up until the original Object class. While these are each fast operations in isolation, the fact that it must make so many checks is what makes them slower than both of the alternatives for looking up data.
When developers mention how slow the scripting API is, it is this chain of queries they refer to. Compared to compiled C++ code where the application knows exactly where to go to find anything, it is inevitable that scripting API operations will take much longer. They must locate the source of any relevant data before they can attempt to access it.
The reason GDScript is slow is because every operation it performs passes through this system.
C# can process some content at higher speeds via more optimized bytecode. But, if the C# script calls into an engine class' content or if the script tries to access something external to it, it will go