In a previous entry we saw how to decouple the logic of a class from the access to its member data so that the latter can be laid out in a DOD-friendly fashion for faster sequential processing. Instead of having a std::vector of, say, particles, now we can store the different particle members (position, velocity, etc.) in separate containers. This unfortunately results in more cumbersome initialization code: whereas for the traditional, OOP approach particle creation and access is compact and nicely localized:
std::vector<plain_particle> pp_; ... for(std::size_t i=0;i<n;++i){ pp_.push_back(plain_particle(...)); } ... render(pp_.begin(),pp_.end());
when using DOD, in contrast, the equivalent code grows linearly with the number of members, even if most of it is boilerplate:
std::vector<char> color_; std::vector<int> x_,y_,dx_,dy_; ... for(std::size_t i=0;i<n;++i){ color_.push_back(...); x_.push_back(...); y_.push_back(...); dx_.push_back(...); dy_.push_back(...); } ... auto beg_=make_pointer<particle>( access(&color_[0],&x_[0],&y_[0],&dx_[0],&dy_[0])), auto end_=beg_dod+n; render(beg_,end_);
We would like to rely on a container using SOA (structure of arrays) for its storage that allows us to retain our original OOP syntax:
using access=dod::access<color,x,y,dx,dy>; dod::vector<particle<access>> p_; ... for(std::size_t i=0;i<n;++i){ p_.emplace_back(...); } ... render(p_.begin(),p_.end());
Note that particles are inserted into the container using emplace_back rather than push_back: this is due to the fact that a particle object (which push_back accepts as its argument) cannot be created out of the blue without its constituent members being previously stored somewhere; emplace_back, on the other hand, does not suffer from this chicken-and-egg problem.
The implementation of such a container class is fairly straightfoward (limited here to the operations required to make the previous code work):
namespace dod{ template<typename Access> class vector_base; template<> class vector_base<access<>> { protected: access<> data(){return {};} void emplace_back(){} }; template<typename Member0,typename... Members> class vector_base<access<Member0,Members...>>: protected vector_base<access<Members...>> { using super=vector_base<access<Members...>>; using type=typename Member0::type; using impl=std::vector<type>; using size_type=typename impl::size_type; impl v; protected: access<Member0,Members...> data() { return {v.data(),super::data()}; } size_type size()const{return v.size();} template<typename Arg0,typename... Args> void emplace_back(Arg0&& arg0,Args&&... args){ v.emplace_back(std::forward<Arg0>(arg0)); try{ super::emplace_back(std::forward<Args>(args)...); } catch(...){ v.pop_back(); throw; } } }; template<typename T> class vector; template<template <typename> class Class,typename Access> class vector<Class<Access>>:protected vector_base<Access> { using super=vector_base<Access>; public: using iterator=pointer<Class<Access>>; iterator begin(){return super::data();} iterator end(){return this->begin()+super::size();} using super::emplace_back; }; } // namespace dod
dod::vector<Class<Members...>> derives from an implementation class that holds a std::vector for each of the Members declared. Inserting elements is just a simple matter of multiplexing to the vectors, and begin and end return dod::pointers to this structure of arrays. From the point of view of the user all the necessary magic is hidden by the framework and DOD processing becomes nearly identical in syntax to OOP.
We provide a test program that exercises dod::vector against the classical OOP approach based on a std::vector of plain (i.e., non DOD) particles. Results are the same as previously discussed when we used DOD with manual initialization, that is, there is no abstraction penalty associated to using dod::vector, so we won't present any additional figures here.
The framework we have constructed so far provides the bare minimum needed to test the ideas presented. In order to be fully usable there are various aspects that should be expanded upon:
- access<Members...> just considers the case where each member is stored separately. Sometimes the most efficient layout will call for mixed scenarios where some of the members are grouped together. This can be modelled, for instance, by having member accept multiple pieces of data in its declaration.
- dod::pointer does not properly implement const access, that is, pointer<const particle<...>> does not compile.
- dod::vector should be implemented to provide the full interface of a proper vector class.
All of this can be in principle tackled without serious design dificulties.
Many thanks for that!
ReplyDeleteThis is the best series of the possible DOD implementation with C++. Absolutely.
I'm beginning to analyze the code and try to understand this ;)
hello Joaquin, you may find https://github.com/gnzlbg/scattered interesting
ReplyDeleteYes, the ideas are very similar. Thanks for the pointer!
Delete