417 lines
18 KiB
Markdown
417 lines
18 KiB
Markdown
## Introduction to tuple_vector
|
|
|
|
`tuple_vector` is a data container that is designed to abstract and simplify
|
|
the handling of a "structure of arrays" layout of data in memory. In
|
|
particular, it mimics the interface of `vector`, including functionality to do
|
|
inserts, erases, push_backs, and random-access. It also provides a
|
|
`RandomAccessIterator` and corresponding functionality, making it compatible
|
|
with most STL (and STL-esque) algorithms such as ranged-for loops, `find_if`,
|
|
`remove_if`, or `sort`.
|
|
|
|
When used or applied properly, this container can improve performance of
|
|
some algorithms through cache-coherent data accesses or allowing for
|
|
sensible SIMD programming, while keeping the structure of a single
|
|
container, to permit a developer to continue to use existing algorithms in
|
|
STL and the like.
|
|
|
|
## Review of "Structure of arrays" data layouts
|
|
|
|
When trying to improve the performance of some code, it can sometimes be
|
|
desirable to transform how some data is stored in memory to be laid out not as
|
|
an "array of structures", but as a "structure of arrays". That is, instead of
|
|
storing a series of objects as a single contiguous chunk of memory, one or
|
|
more data members are instead stored as separate chunks of memory that are
|
|
handled and accessed in parallel to each other.
|
|
|
|
This can be beneficial in two primary respects:
|
|
|
|
1) To improve the cache coherency of the data accesses, e.g. by utilizing more
|
|
data that is loaded per cache line loaded from memory, and thereby reducing
|
|
the amount of time waiting on memory accesses from off-CPU memory.
|
|
This presentation from Mike Acton touches on this, among other things:
|
|
https://www.youtube.com/watch?v=rX0ItVEVjHc
|
|
|
|
2) To allow the data to be more easily loaded and utilized by SIMD kernels,
|
|
by being able to load memory directly into a SIMD register.
|
|
This is touched on in this presentation from Andreas Fredriksson for writing
|
|
code with SIMD intrinsics:
|
|
http://www.gdcvault.com/play/1022249/SIMD-at-Insomniac-Games-How
|
|
...and as well in this guide for writing performant ISPC kernels:
|
|
https://ispc.github.io/perfguide.html
|
|
|
|
## How TupleVecImpl works
|
|
|
|
`tuple_vector` inherits from `TupleVecImpl`, which
|
|
provides the bulk of the functionality for those data containers. It manages
|
|
the memory allocated, marshals data members to each array of memory, generates
|
|
the necessary iterators, and so on.
|
|
|
|
When a `tuple_vector` is declared, it is alongside a list of types, or "tuple
|
|
elements", indicating what data to store in the container, similar to how `tuple`
|
|
operates. `TupleVecImpl` uses this list of tuple elements to then inherit from a series of
|
|
`TupleVecLeaf` structures, which each have their own pointer to an array of their
|
|
corresponding type in memory. When dereferencing the container, either to fetch a
|
|
tuple of references or just fetching pointers to the memory, it is these pointers
|
|
that are utilized or fetched.
|
|
|
|
While each `TupleVecLeaf` contains a pointer to its own block of memory, they
|
|
are not individual memory allocations. When `TupleVecImpl` needs to grow its
|
|
capacity, it calculates the total size needed for a single allocation, taking
|
|
into account the number of objects for the container, the size of each tuple
|
|
element's type, and the alignment requirements for each type. Pointers into the
|
|
allocation for each tuple element are also determined at the same time, which
|
|
are passed to each `TupleVecLeaf`. From there, many of the interactions with
|
|
`TupleVecImpl`, to modify or access members of the container, then reference
|
|
each `TupleVecLeaf`'s data pointer in series, using parameter packs to repeat
|
|
each operation for each parent `TupleVecLeaf`.
|
|
|
|
## How tuple_vector's iterator works
|
|
|
|
`TupleVecImpl` provides a definition to an iterator type, `TupleVecIter`.
|
|
As mentioned above, `TupleVecIter` provides all of the functionality to operate
|
|
as a `RandomAccessIterator`. When it is dereferenced, it provides a tuple of
|
|
references, similar to `at()` or `operator[]` on `TupleVecImpl`, as opposed to
|
|
a reference of some other type. As well, a customization of `move_iterator` for
|
|
`TupleVecIter` is provided, which will return a tuple of rvalue-references.
|
|
|
|
The way that `TupleVecIter` operates internally is to track an index into the
|
|
container, as well as a copy of all of the `TupleVecImpl`'s `TupleVecLeaf`
|
|
pointers at the time of the iterator's construction. As a result, modifying the
|
|
iterator involves just changing the index, and dereferencing the iterator into
|
|
the tuple of references involves dereferencing each pointer with an offset
|
|
specified by that index.
|
|
|
|
Of the various ways of handling the multitude of references, this tended to
|
|
provide the best code-generation. For example, having a tuple of pointers that
|
|
are collectively modified with each iterator modification resulted in the compiler
|
|
not being able to accurately determine which pointers were relevant to the final
|
|
output of some function, creating many redundant operations. Similarly, having
|
|
the iterator refer to the source `TupleVecImpl` for the series of pointers
|
|
often resulted in extra, unnecessary, data hops to the `TupleVecImpl` to repeatedly
|
|
fetch data that was not practically mutable, but theoretically mutable. While this
|
|
solution is the heaviest in terms of storage, the resulted assembly tends to be
|
|
competitive with traditional structure-of-arrays setups.
|
|
|
|
## How to work with tuple_vector, and where to use it
|
|
|
|
Put simply, `tuple_vector` can be used as a replacement for `vector`. For example,
|
|
instead of declaring a structure and vector as:
|
|
|
|
```
|
|
struct Entity
|
|
{
|
|
bool active;
|
|
float lifetime;
|
|
Vec3 position;
|
|
}
|
|
vector<Entity> entityVec;
|
|
```
|
|
|
|
...the `tuple_vector` equivalent of this can be defined as:
|
|
|
|
```
|
|
tuple_vector<bool, float, Vec3> entityVec;
|
|
```
|
|
|
|
In terms of how `tuple_vector` is modified and accessed, it has a similar
|
|
featureset as `vector`, except where `vector` would accept or return a single
|
|
value, it instead accepts or returns a tuple of values or unstructured series
|
|
of equivalent arguments.
|
|
|
|
For example, the following functions can be used to access the data, either by
|
|
fetching a tuple of references to a series of specific values, or the data
|
|
pointers to the tuple elements:
|
|
|
|
```
|
|
tuple<bool&, float&, Vec3&> operator[](size_type)
|
|
tuple<bool&, float&, Vec3&> at(size_type)
|
|
tuple<bool&, float&, Vec3&> iterator::operator*()
|
|
tuple<bool&&, float&&, Vec3&&> move_iterator::operator*()
|
|
tuple<bool*, float*, Vec3*> data()
|
|
|
|
// extract the Ith tuple element pointer from the tuple_vector
|
|
template<size_type I>
|
|
T* get<I>()
|
|
// e.g. bool* get<0>(), float* get<1>(), and Vec3* get<2>()
|
|
|
|
// extract the tuple element pointer of type T from the tuple_vector
|
|
// note that this function can only be used if there is one instance
|
|
// of type T in the tuple_vector's elements
|
|
template<typename T>
|
|
T* get<T>()
|
|
// e.g. bool* get<bool>(), float* get<float>(), and Vec3* get<Vec3>()
|
|
```
|
|
|
|
And `push_back(...)` has the following overloads, accepting either values or tuples as needed.
|
|
|
|
```
|
|
tuple<bool&, float&, Vec3&> push_back()
|
|
push_back(const bool&, const float&, const Vec3&)
|
|
push_back(tuple<const bool&, const float&,const Vec3&>)
|
|
push_back(bool&&, float&&, Vec3&&)
|
|
push_back(tuple<bool&&, float&&, Vec3&&>)
|
|
```
|
|
...and so on, and so forth, for others like the constructor, `insert(...)`,
|
|
`emplace(...)`, `emplace_back(...)`, `assign(...)`, and `resize(...)`.
|
|
|
|
As well, note that the tuple types that are accepted or returned for
|
|
`tuple_vector<Ts...>` have typedefs available in the case of not wanting to use
|
|
automatic type deduction:
|
|
```
|
|
typedef eastl::tuple<Ts...> value_tuple;
|
|
typedef eastl::tuple<Ts&...> reference_tuple;
|
|
typedef eastl::tuple<const Ts&...> const_reference_tuple;
|
|
typedef eastl::tuple<Ts*...> ptr_tuple;
|
|
typedef eastl::tuple<const Ts*...> const_ptr_tuple;
|
|
typedef eastl::tuple<Ts&&...> rvalue_tuple;
|
|
```
|
|
With this, and the fact that the iterator type satisfies
|
|
the `RandomAccessIterator` requirements, it is possible to use `tuple_vector` in
|
|
most ways and manners that `vector` was previously used, with few structural
|
|
differences.
|
|
|
|
However, even if not using it strictly as a replacement for `vector`, it is
|
|
still useful as a tool for simplifying management of a traditional structure of
|
|
arrays. That is, it is possible to use `tuple_vector` to just perform a single
|
|
large memory allocation instead of a series of smaller memory allocations,
|
|
by sizing the `tuple_vector` as needed, fetching the necessary pointers with
|
|
`data()` or `get<...>()`, and carrying on normally.
|
|
|
|
One example where this can be utilized is with ISPC integration. Given the
|
|
following ISPC function definition:
|
|
|
|
export void simple(uniform float vin[], uniform float vfactors[], uniform float vout[], uniform int size);
|
|
|
|
...which generates the following function prototype for C/C++ usage:
|
|
|
|
extern void simple(float* vin, float* vfactors, float* vout, int32_t size);
|
|
|
|
...this can be utilized with some raw float arrays:
|
|
```
|
|
float* vin = new float[NumElements];
|
|
float* vfactors = new float[NumElements];
|
|
float* vout = new float[NumElements];
|
|
|
|
// Initialize input buffer
|
|
for (int i = 0; i < NumElements; ++i)
|
|
{
|
|
vin[i] = (float)i;
|
|
vfactors[i] = (float)i / 2.0f;
|
|
}
|
|
|
|
// Call simple() function from simple.ispc file
|
|
simple(vin, vfactors, vout, NumElements);
|
|
|
|
delete vin;
|
|
delete vfactors;
|
|
delete vout;
|
|
```
|
|
or, with `tuple_vector`:
|
|
|
|
```
|
|
tuple_vector<float, float, float> simpleData(NumElements);
|
|
float* vin = simpleData.get<0>();
|
|
float* vfactors = simpleData.get<1>();
|
|
float* vout = simpleData.get<2>();
|
|
|
|
// Initialize input buffer
|
|
for (int i = 0; i < NumElements; ++i)
|
|
{
|
|
vin[i] = (float)i;
|
|
vfactors[i] = (float)i / 2.0f;
|
|
}
|
|
|
|
// Call simple() function from simple.ispc file
|
|
simple(vin, vfactors, vout, NumElements);
|
|
```
|
|
|
|
`simpleData` here only has a single memory allocation during its construction,
|
|
instead of the three in the first example, and also automatically releases the
|
|
memory when it falls out of scope.
|
|
|
|
It is possible to also skip a memory allocation entirely, in some circumstances.
|
|
EASTL provides "fixed" counterparts of many data containers which allows for a
|
|
data container to have an inlined buffer of memory. For example,
|
|
`eastl::vector<typename T>` has the following counterpart:
|
|
|
|
eastl::fixed_vector<typename T, size_type nodeCount, bool enableOverflow = true>
|
|
|
|
This buffer allows for enough space to hold a `nodeCount` number of `T` objects,
|
|
skipping any memory allocation at all, until the requested size becomes
|
|
greater than `nodeCount` - assuming `enableOverflow` is True.
|
|
|
|
There is a similar counterpart to `eastl::tuple_vector<typename... Ts>` available as well:
|
|
|
|
eastl::fixed_tuple_vector<size_type nodeCount, bool enableOverflow, typename... Ts>
|
|
|
|
This does the similar legwork in creating an inlined buffer, and all of the
|
|
functionality of `tuple_vector` otherwise is supported. Note the slight
|
|
difference in declaration, though: `nodeCount` and `enableOverflow` are defined
|
|
first, and `enableOverflow` is not a default parameter. This change arises out
|
|
of restrictions surrounding variadic templates, in that they must be declared
|
|
last, and cannot be mixed with default template parameters.
|
|
|
|
Lastly, `eastl::vector` and other EASTL data containers support custom Memory Allocator
|
|
types, through their template parameters. For example, `eastl::vector`'s full declaration
|
|
is actually:
|
|
|
|
eastl::vector<typename T, typename AllocatorType = EASTLAllocatorType>
|
|
|
|
However, because such a default template parameter cannot be used with
|
|
variadic templates, a separate type for `tuple_vector` is required for such a
|
|
definition:
|
|
|
|
eastl::tuple_vector_alloc<typename AllocatorType, typename... Ts>
|
|
|
|
Note that `tuple_vector` uses EASTLAllocatorType as the allocator.
|
|
|
|
## Performance comparisons/discussion
|
|
|
|
A small benchmark suite for `tuple_vector` is included when running the
|
|
EASTLBenchmarks project. It provides the following output on a Core i7 3770k
|
|
(Skylake) at 3.5GHz, with DDR3-1600 memory.
|
|
|
|
The `tuple_vector` benchmark cases compare total execution time of similar
|
|
algorithms run against `eastl::tuple_vector` and `std::vector`, such as
|
|
erasing or inserting elements, iterating through the array to find a specific
|
|
element, sum all of the elements together via operator[] access, or just
|
|
running `eastl::sort` on the data containers. More information about the
|
|
EASTLBenchmarks suite can be found in EASTL/doc/EASTL Benchmarks.html
|
|
|
|
Benchmark | STD execution time | EASTL execution time | Ratio
|
|
--------- | -------- | ---------- | -----
|
|
`tuple_vector<AutoRefCount>/erase ` | 1.7 ms | 1.7 ms | 1.00
|
|
`tuple_vector<MovableType>/erase ` | 104.6 ms | 106.3 ms | 0.98
|
|
`tuple_vector<MovableType>/reallocate ` | 1.3 ms | 1.7 ms | 0.77 -
|
|
| | |
|
|
`tuple_vector<uint64>/erase ` | 3.4 ms | 3.5 ms | 0.98
|
|
`tuple_vector<uint64>/insert ` | 3.4 ms | 3.4 ms | 0.99
|
|
`tuple_vector<uint64>/iteration ` | 56.3 us | 81.4 us | 0.69 -
|
|
`tuple_vector<uint64>/operator[] ` | 67.4 us | 61.8 us | 1.09
|
|
`tuple_vector<uint64>/push_back ` | 1.3 ms | 818.3 us | 1.53 +
|
|
`tuple_vector<uint64>/sort ` | 5.8 ms | 7.3 ms | 0.80
|
|
| | |
|
|
`tuple_vector<uint64,Padding>/erase ` | 34.7 ms | 32.9 ms | 1.05
|
|
`tuple_vector<uint64,Padding>/insert ` | 41.0 ms | 32.6 ms | 1.26
|
|
`tuple_vector<uint64,Padding>/iteration ` | 247.1 us | 80.5 us | 3.07 +
|
|
`tuple_vector<uint64,Padding>/operator[]` | 695.7 us | 81.1 us | 8.58 +
|
|
`tuple_vector<uint64,Padding>/push_back ` | 10.0 ms | 6.0 ms | 1.67 +
|
|
`tuple_vector<uint64,Padding>/sort ` | 8.2 ms | 10.1 ms | 0.81
|
|
| | |
|
|
`vector<AutoRefCount>/erase ` | 1.3 ms | 1.2 ms | 1.05
|
|
`vector<MovableType>/erase ` | 104.4 ms | 109.4 ms | 0.95
|
|
`vector<MovableType>/reallocate ` | 1.5 ms | 1.5 ms | 0.95
|
|
| | |
|
|
`vector<uint64>/erase ` | 4.3 ms | 3.6 ms | 1.20
|
|
`vector<uint64>/insert ` | 4.8 ms | 4.8 ms | 1.01
|
|
`vector<uint64>/iteration ` | 71.5 us | 77.3 us | 0.92
|
|
`vector<uint64>/operator[] ` | 90.7 us | 87.2 us | 1.04
|
|
`vector<uint64>/push_back ` | 1.6 ms | 1.2 ms | 1.38 +
|
|
`vector<uint64>/sort ` | 7.7 ms | 8.2 ms | 0.93
|
|
|
|
First off, `tuple_vector<uint64>`'s performance versus `std::vector<uint64>` is
|
|
comparable, as expected, as the `tuple_vector`'s management for one type
|
|
becomes very similar to just a regular vector. The major notable exception is
|
|
the iteration case, which runs `eastl::find_if`. This
|
|
performance differences is a consequence of the iterator design, and how
|
|
it works with indices, not a direct pointer, so the code generation suffers slightly
|
|
in this compute-bound scenario. This is worth noting as a demonstration of a
|
|
case where falling back to pointer-based iteration by fetching the `begin` and
|
|
`end` pointers of that tuple element may be preferable, instead of using the
|
|
iterator constructs.
|
|
|
|
The set of `tuple_vector<uint64,Padding>` tests are more interesting.
|
|
This is a comparison between a single `std::vector` with a
|
|
structure containing a `uint64` and 56 bytes of padding, and a `tuple_vector` with
|
|
two elements: one for `uint64` and one for 56 bytes of padding. The erase,
|
|
insert, push_back, and sort cases all perform at a similar relative rate as
|
|
they did in the `tuple_vector<uint64>` tests - demonstrating that operations
|
|
that have to touch all of elements do not have a significant change in
|
|
performance.
|
|
|
|
However, iteration and operator[] are very different, because
|
|
those only access the `uint64` member of both `vector` and `tuple_vector` to run
|
|
some operation. The iteration test now runs 3x faster whereas before it ran
|
|
0.7x as fast, and operator[] runs 8.5x faster, instead of 1.1x. This
|
|
demonstrates some of the utility of `tuple_vector`, in that these algorithms end
|
|
up being limited by the CPU's compute capabilities, as opposed to being
|
|
limited by how fast they can load memory in from DRAM.
|
|
|
|
In a series of other tests, generally speaking, `tuple_vector` tends to perform
|
|
on par with manual management of multiple arrays in many algorithms and
|
|
operations, often even generating the same code. It should be noted that
|
|
significant degrees of inlining and optimization are required to get the most out
|
|
of `tuple_vector`. Compared to accessing a series of arrays or vectors,
|
|
`tuple_vector` does perform a multitude of extra trivial function calls internally
|
|
in order to manage the various elements, or interact with `eastl::tuple` through
|
|
its interface, so running in debug configurations can run significantly slower
|
|
in some cases, e.g. sometimes running at 0.2x the speed compared to vector.
|
|
|
|
## The problem of referencing tuple elements
|
|
|
|
This will be experienced shortly after using `tuple_vector` in most capacities,
|
|
but it should be noted that the most significant drawback is that there is no
|
|
way to **symbolically** reference each tuple element of the `tuple_vector` - much
|
|
in the same way as `tuple`. For example, if translating a struct such as...
|
|
|
|
```
|
|
struct Entity
|
|
{
|
|
float x, y, z;
|
|
float lifetime;
|
|
};
|
|
```
|
|
...to `tuple_vector`, it will exist as:
|
|
|
|
```
|
|
tuple_vector<float, float, float, float> entityVec;
|
|
```
|
|
|
|
...and can only be accessed in a manner like `entityVec.get<3>()` to refer to
|
|
the `lifetime` member. With existing tools, the only good alternatives are to
|
|
encapsulate each float as a separate struct to give it unique typenames...
|
|
|
|
```
|
|
struct entityX { float val; };
|
|
struct entityY { float val; };
|
|
struct entityZ { float val; };
|
|
struct entityLifetime { float val; };
|
|
|
|
tuple_vector<entityX, entityY, entityZ, entityLifetime> entityVec;
|
|
```
|
|
...and then access each tuple element by typename like
|
|
`entityVec.get<entityLifetime>()`; or, creating an enumerated value to replace
|
|
the indices...
|
|
|
|
```
|
|
enum EntityTypeEnum
|
|
{
|
|
entityX = 0,
|
|
entityY = 1,
|
|
entityZ = 2,
|
|
entityLifetime = 3
|
|
};
|
|
|
|
tuple_vector<float, float, float, float> entityVec;
|
|
```
|
|
|
|
...and then access each tuple element by the enumerated value:
|
|
`entityVec.get<entityLifetime>()`.
|
|
|
|
Either way, there is a fairly significant maintenance and readability issue
|
|
around this. This is arguably more severe than with `tuple` on its own
|
|
because that is generally not intended for structures with long lifetime.
|
|
|
|
Ideally, if the language could be mutated to accommodate such a thing, it would
|
|
be good to have some combination of typenames and symbolic names in the
|
|
declaration, e.g. something like
|
|
|
|
```
|
|
tuple_vector<float x, float y, float z, float lifetime> entityVec;
|
|
```
|
|
and be able to reference the tuple elements not just by typename or index, but
|
|
through their corresponding symbol, like `entityVec.get<lifetime>()`. Or, it may
|
|
be interesting if the necessary `get` functions could be even automatically
|
|
generated through a reflection system, e.g. `entityVec.get_lifetime()`.
|
|
All of this remains a pipe dream for now.
|