11 ms·
Bulk Data Structures C++
- std_throwaway 7y agoI assume that we want to access these arrays as "array of structs" for most functions but as "structure of arrays" for some calculation intensive functions. The article suggests storing it as array of structs and to make copies for those calculations but this seems inefficient to me. Modern C++ should provide a way to efficiently decouple the access model from the memory layout. Can we hide the actual memory layout without big overhead using C++ inline/template functions/classes? Would that be the visitor pattern?
- mamcx 7y ago> Modern C++ should provide a way to efficiently decouple the access model from the memory layout. I try a lot to make a "array of structs" and also"structure of arrays" for my own little relational language in rust. Is just not possible (that I know). At best, you could store as packed arrays or arrays of arrays then at runtime static dispatch them. P.D: Or generate code for both. Anyway is not easy to build... the OPTIMAL algorithms for both cases diverge enough.
- a_t48 7y agoIIRC this was something JAI was trying to do.
- DiseasedBadger 7y agoI think that was just refactoring tools.
- dkersten 7y agoI’m pretty sure Jai has (or at least did when I last looked) a type modifier keyword that changes the layout (the code working with it doesn’t change)
- dymk 7y agoIt should based on livestreams, but details on Jai are so far and few between, it's hard to say
- dkersten 7y agoYeah, my comment was based on some old fan-made documentation and the latest live streams I've seen where he talked about it (which was a good many months ago now, but it certainly seemed like its supported)
- lasagnaphil 7y agoI've heard in one of his videos that Jonathan Blow ditched the AOS -> SOA conversion feature, but he may come back to the idea sometime later. (One problem with Jai is that unless you are viewing his Youtube videos regularly, you cannot catch up on what is going on with the language...)
- a_t48 7y agoThat's unfortunate.
- pavlov 7y agoI don't think the article is suggesting ever making temporary copies of data into a structure-of-arrays (SOA) format. Rather the choice should be made at design time and you write your code accordingly for those parts that deal with SOA data. The author's advice is that you should go with the standard array-of-structures (AOS) format by default, but if you know you'll be doing number crunching, use an "unrolled by eight" grouped SOA format that's both SIMD- and cache-friendly.
- std_throwaway 7y agoFTA: "Another thing I might consider is to keep the data stored at AoS, but generate temporary SoA data for processing by some algorithm."
- pavlov 7y agoMissed that, thanks!
- plopz 7y agoDoesn't the memory layout actually matter for cache locality? So you would still need to be able to have both memory layouts for performance.
- std_throwaway 7y agoCache locality is kind of the whole point of it. Some algorithms benefit hugely if you choose a specific layout. Other algorithms do some kind of random access to a few fields only and they don't benefit at all. Those algorithms can make up 90% of your code but only account for 10% of the computation. Therefore it would be easier to have your data look like a AoS in 90% of your code but actually be stored as a SoA to gain the speed in 90% of the computation.
- hermitdev 7y agoMost definitely. If, for example you've got a vector of structs (which is a basic tabular store, that is row major). Depending on the operations you're performing, you may see huge performance benefits from instead using a column oriented data structure. Especially with very large datasets. A large part of this because of cache locality and prefetch. I see this in finance often. For querying large, slowly changing datasets, column store RDBMS destroy traditional row oriented stores. Column stores can be colloquially an order of magnitude faster for some operations, such as computing aggregates grouped by a date (but theyre significantly much slower for inserts and even more so for updates). As usual, when it comes down to optimizations, depends on the use case, and experiment and measure, measure, measure. Also, another big caveate is that it can change arbitrarily with different hardware or even OS revisions. Edit: spelling
- tom_mellior 7y ago> Can we hide the actual memory layout without big overhead using C++ inline/template functions/classes? This seems to claim to do it: https://github.com/crosetto/SoAvsAoS https://github.com/crosetto/SoAvsAoS Found that while looking for this, which I vaguely knew about and which also seems to do that: https://m-sp.org/downloads/cgo2018-src-poster.pdf https://m-sp.org/downloads/cgo2018-src-poster.pdf
- jonv98 7y ago> make copies for those calculations but this seems inefficient to me I haven't read the whole article, but this "make copies of elements from an array into another array for the current frame only" is common in game development. Remember that on modern CPUs, an L3 miss is about 200x slower than an L1 hit. RAM isn't random access: randomly jumping around is slow, but iterating over an array is fast, both because of the cache and because of pre-fetching. Say you have a big array of A's, and another big array of B's. For the current frame, some of the A's need to interact with some of the B's. If you go through the entire list of B's, and copy the ones that will definitely need to interact into a new list, call it B2, then maybe (or not) do the same with the A's into A2, then it can often be approximately 30 times faster. Multiply that by 4 (or 8) if you can "zip" through your A2's and B2's with SIMD. Not only that, but your A2 and B2 lists can be put on a stack allocator (nothing to do with allocating on the stack - it's a special type of O(1) heap allocator whose contents are discarded at the end of each video frame).
- std_throwaway 7y agoCopying is fast if the access pattern is a good fit for the CPU architecture. If you need to copy only every N-th byte from a AoS it might be as inefficient as random access. So, copying could be expensive. The article suggests striping your data in blocks but then you end up with the worst of both worlds in terms of program code complexity.
- kllrnohj 7y ago> Can we hide the actual memory layout without big overhead using C++ inline/template functions/classes? Not super easily because the array type needs to know the fields of the class it's containing to do the re-write. This is where you need more substantial codegen to enter the picture. Something like the metaclasses proposal should handle it just fine. Or macros in the meantime.
- gpderetta 7y agoI posted this elsethread: https://godbolt.org/z/rBeWOA https://godbolt.org/z/rBeWOA But yes, better reflection is needed to make it truly generic.
- mhh__ 7y agoDo you mean switching the memory layout depending on runtime conditions or just changing the software interface to a constant (shape) block of memory?
- daemin 7y agoThis sounds like you want to use ranges, which were introduced in C++20 and quite a few game developers found them too complicated. The way I see it to store your data in whatever is the most efficient form for your computations to use, and use a simple view for those functions. Then for functions which need to look at the data in another form you use more complicated views which can abstract some of the data layout for you and make it simpler to manipulate. Unfortunately I can see some people decrying this sort of code as too complex and complicated, but I think it can be made to work rather well.
- slimscsi 7y ago> std::vector uses constructors and destructors to create and destroy objects which in some cases can be significantly slower than memcpy(). This is precisely what vector::emplace() solves, and std::move should be faster than swap and pop. Modern C++ has changed a lot, this article ignores the massive improvements added in c++11,14,17.
- codesushi42 7y agoIt is better to use push_back over emplace to be explicit about which constructor will be called.
- Koshkin 7y agoBut emplace() is already as explicit about it as it gets.
- codesushi42 7y agoNuh uh. If you're not careful, it will call an implicit constructor.
- wrsh07 7y ago+1, Google suggests doing this as well: https://abseil.io/tips/112 https://abseil.io/tips/112 > So in general, if both push_back() and emplace_back() would work with the same arguments, you should prefer push_back(), and likewise for insert() vs. emplace().
- daemin 7y agoThat's an interesting point the tip makes. Is there guidance on how to use the emplace_back() added to c++17 which returns a reference to the constructed element? The reference returning emplace_back() is used frequently in the code to construct a new element of a struct and then fill in its members, as opposed to creating a new struct then push_back() to copy the memory in.
- 7y ago
- saagarjha 7y ago> Also, without some additional measures, neither plain arrays or vectors support referencing individual objects. Uh, isn't this just subscripting? > But, as stated above, we don’t care about the order. Maybe std::unordered_set might be what you want?
- einpoklum 7y agoRemember `std::unordered_set` is typically rather slow.
- saagarjha 7y agoWell, it depends what you're doing with it.
- B4TMAN 7y agoCan you elaborate why is it slow? Shouldn't it be faster tham `std::ordered_set` which uses a Red Black Tree as the undelying data structure thus proviing a O(logn) time complexity on the other hand `std::unordered_set` uses hash functions to `index` in an array and retrieve which essentially is a O(1) time complexity.
- kllrnohj 7y agoThis is where we get into O(1) != fast territory. Algorithmic complexity has a weak relationship to CPU performance, not a strong one. If you want to find something in a set storing it as an array and doing a linear scan will beat a std::unordered_set up to a shockingly large number of items due to how CPU's work. In particular it's the pointer chasing aspect of std::unordered_set that becomes a problem (an issue shared with _most_ hash set implementations). Remember an unordered_set is not an array of items, it's an array of buckets of items (this is how hash collision is handled). Worse still, those buckets are usually linked lists. It typically can't be speculated effectively and it can't be prefetched effectively, so you become memory latency bound during an un-cached lookup. And memory latency is just shy of absolutely terrible. If you're expecting L1/L2/L3 cache hits on lookups then you're not dealing with vary large sizes probably and you're going to get much better cache density with the flat array than the array-of-buckets. There are alternative hashsets that are flat and avoid this, but they are less common and as far as I know no standard implementation on any language uses such a hash set. There's a good talk about such a dense, flat hash set here: https://www.youtube.com/watch?v=ncHmEUmJZf4 https://www.youtube.com/watch?v=ncHmEUmJZf4
- doctorpangloss 7y agoGame developers like me go through stages of grief in reinvention of memory management. In this case, what will eventually be reinvented is an arena allocator. Having just researched this, Cap'n'Proto is a good implementation of one that suits game development needs: (1) flexibility, (2) no serialization representation for networking and AI, (3) mutability of primitives, (4) garbage collection of stale objects in lists (i.e. removed items) is manual, (5) constraints to prevent non-performant design, and (6) support for these performance-sensitive idioms in multiple languages, not just C++. Migrating to an arena allocator is a completely different can of worms...
- gpderetta 7y agoExactly. Also using a vector as underlying storage instead of sets of fixed size memory chunks seems not ideal to say the least.
- jokoon 7y agoI often read that when in doubt, use a vector. It has its disadvantages, but for performance it's usually okay. Simplicity can be a good choice.
- exDM69 7y agoSound advise for general purpose programming but not for (high end) games. Especially these days with multi-threaded game engines, a call to malloc() (e.g. from vector resizing) may attempt to grab a contended mutex and end up waiting until it misses its frame. Modern game engines use a combination of memory management techniques which are tuned for different use cases. For example: large, persistent blocks of memory are allocated ahead of time. Small, transient objects are allocated from a thread-local, per-frame pool and they're never freed explicitly (at the end of the frame, memory will be reclaimed for reuse). Most game engines don't use the C++ standard library containers at all in the first place. There are gamedev-flavored STL variants like EASTL, though.
- 7y ago
- zenogais 7y agoI wanted to like this article because I'm been thinking about this a lot in the context of game development, noticed a few things. One thing I'll say from briefly playing with this - the code leaves lots out a looks ostensibly simpler than it really is. Would very much appreciate tips / pointers on this or a more fleshed out and working implementation of the code. For the bulk data with holes code: First, there's an initialization step that has to happen the first time you allocate your bulk_data_t. Namely, you need to iterate through every item in the list and set its next_free item to the item following it, looping the last item back around to zero. You also need to do this for all the items between the new size and old size every time you resize your item list. Second, safe iteration over all of the bulk data doesn't seem possible without adding some sort of flag to indicate whether or not an item is free. Am I missing something here?
- dhruvrrp 7y agoI would say it depends on how one might want to handle it, like when you create an item_t you set next_free = -1 as a flag to indicate that it is not free. And have bulk_data_t's 0th position's next_free be 0.
- zenogais 7y agoUpdate: I was indeed missing something. I think I've figured out roughly what the author intended, code below [0]. First, it looks like he's relying implicitly on data stored in std::vector. Namely vectors have both a capacity and a size. The capacity is total number of allocated elements. The size is the total number of elements stored actually stored. Second, vector::resize won't reallocate until it runs out of capacity, but it will give you access to extra elements if you need them. So this is used to lazily re allocate while bumping up the size of the vector. Both of these effectively make it "do the right thing" by leaning on the vector storing both size and capacity. If you hand manage those values yourself you can get a pretty compact C implementation without a lot of code. One last thing: Using a union here for the item_t is pretty much guaranteed to get you a segfault. The whole thing should really be a struct. This also allows for setting next to sentinel value if necessary. [0]: C code for bulk_data_t example: https://pastebin.com/Tfcdt39h https://pastebin.com/Tfcdt39h
- ball_of_lint 7y agoAlthough it's a fair amount of work, you can make it very simple to switch between SoA and AoS by writing a child class for a C++ vector<yourclass> that templates your original class, but returns values of a child class of yourclass that operates on the SoA data. With a public-data-heavy class that might run you into a performance problem with allocating the extra unused memory, but you can always pull out the interface as a virtual parent of both to avoid that as well. I would rarely be afraid of using SoA over AoS if it can lead to significant performance improvements. Done well it can hide all the complexity with some clever use of interfaces and classes.
- typon 7y agoCan you give an example?
- ball_of_lint 7y agoThis is mainly to illustrate the idea; I don't claim any correctness or good performance from this code. (if you do inserts after reading a [] you may invalidate some pointers!) https://pastebin.com/aZWTAL2J https://pastebin.com/aZWTAL2J impl_X is your base class with most of your logic. interface is used to pull out just the parts of the data that you might work with while wanting to have it in SoA format. Then we specialize the vector template for the interface to give us a dummy class with the things we need, but that sends our writes back to the backing array. If we need to get an individual struct out of it the conversion is automatic. If we just need to access some member vars it will (hopefully) optimize down to direct accesses. We do bear some complexity in implementation, but it's all confined here. I'm now realizing I was a bit imprecise in my earlier comment. the specialized vector is not around <yourclass> but around an interface parent of your class. You could also just specialize yourclass vector, but then you don't have the ability to switch.
- ball_of_lint 7y agoAfter writing that up, I saw below that someone else has done it much like I had envisioned and ironed out the odd parts. Better source: https://github.com/crosetto/SoAvsAoS https://github.com/crosetto/SoAvsAoS
- stephc_int13 7y agoReading all the discussions and visible confusion about the best C++ practices, when and where and when a constructor will be called etc. seems to be the perfect illustration of the author point.
- degski 7y agoThere is already a good solution: https://www.plflib.org/colony.htm https://www.plflib.org/colony.htm, that will [eventually] end up in the std [https://github.com/WG21-SG14/SG14/tree/master/SG14 https://github.com/WG21-SG14/SG14/tree/master/SG14].
- deleted 7y ago[deleted]
- person_of_color 7y agoI really need a resource on how to make code cache friendly (or at least, more aware of computer architecture). Got an interview coming up at a HFT firm. Please HN, deliver!
- westmeal 7y agoCheck out bisqwits videos on cache locality
- person_of_color 7y agoWho is bisqwit? Couldn't really get anything on Google.
- daemin 7y agoNot necessarily a fan of this sort of re-blogging so here's the original link: https://ourmachinery.com/post/data-structures-part-1-bulk-data/ https://ourmachinery.com/post/data-structures-part-1-bulk-da...
- sourthyme 7y agoOur Machinery has a lot of great resources and recommend reading when you have time.