Author: Alexey Milovidov, 2019-04-20.
On disk — columns.
Data is stored by columns.
In RAM — columns.
Data is processed by columns.
As chunks of columns — for example, 65,536 elements.
Chunk size — depends on many things.
For SELECT — see the max_block_size setting.
Represented as objects with the IColumn interface.
Variants — ColumnVector<T>, ColumnString, ColumnArray...
ColumnVector<T> — almost like std::vector<T>.
But under the IColumn interface.
And instead of std::vector<T> — PODArray<T> (why?).
Previously it was std::vector. PODArray — is just an optimization.
PODArray:
— no extra memset;
— has 15 bytes padding at the end;
— uses an allocator interface different from std::allocator, which sometimes allows mremap.
ColumnString — consists of two components:
1. Bytes laid out sequentially.
2. Offsets to the i+1 string.
h e l l o \0 w o r l d \0
6 12
ColumnConst
Made of one nested column,
containing one value.
Basic operations:
— cut — extract part of a column, for LIMIT implementation;
— filter — for WHERE implementation;
— compareAt, permute — for ORDER BY implementation;
...
virtual Ptr filter(const Filter & filt, ssize_t result_size_hint) const = 0;
Instead of modifying contents, they create
and return a new column object.
This is normal, since operations are "coarse-grained".
But there are also "fine-grained", mutating operations.
— storing data in RAM;
— common operations on columns.
— Apache Arrow;
— arrays in NumPy;
— arrays in APL, J, K.
Isolate maximally efficient
inner loops from wrapper code.
Code doesn't have to be efficient as a whole.
Optimizable places should be localizable.
«vectorized engine»
Bonus:
— SIMD instructions;
— clever optimizations for homogeneous data
(IColumn::filter, LIKE function implementation);
SELECT f(x + y), g(x + y)
— x + y — is the same expression appearing multiple times.
SELECT x + y AS a, x + y AS b
— a and b — are actually the same columns.
Simple, elegant and incorrect solution:
— use shared_ptr<IColumn>.
Columns a and b would just reference the same thing.
SELECT f(x + y), g(x + y)
The query AST can also be glued — turned from a tree into a DAG:
x y x y x y ↘ ↙ ↘ ↙ ↘ ↙ + + + ↓ ↓ −−−> ↙ ↘ f g f g ↘ ↙ ↓ ↓ SELECT SELECT
Use shared_ptr<IAST> for nodes and share identical nodes.
Computations:
— can be executed independently,
in arbitrary order of graph traversal
and in parallel.
Some operations are mutable.
For example:
— IColumn::insert — insert one value at the end of the column;
— IColumn::insertRangeFrom — insert a chunk of another column at the end.
Usage example:
INSERT INTO table SELECT a, b FROM other
— gluing small blocks into larger ones during INSERT;
— but mutable operations cannot be applied to shared data.
Persistent data structures?
If the AST is glued into a DAG with shared_ptr,
then rewriting one piece of the query changes another.
Use shared_ptr<const T> for shared data
and unique_ptr<T> when data needs to be modified.
Example:
// Create object and modify it.
std::unique_ptr<T> u{std::make_unique<T>()};
u->modify();
// When object is ready, it can be shared.
std::shared_ptr<const T> s1{std::move(u)};
std::shared_ptr<const T> s2 = s1;
// Nobody can modify the shared object.
If we need to modify a shared object?
— just clone it:
// Create object and modify it.
std::unique_ptr<T> u{std::make_unique<T>()};
u->modify();
// When object is ready, it can be shared.
std::shared_ptr<const T> s1{u.release()};
std::shared_ptr<const T> s2 = s1;
// Nobody can modify the shared object.
// But can clone it, then modify the new copy.
std::unique_ptr<T> u2{s2->clone()};
u2->modify();
Gluing small blocks into larger ones.
Was:
result->insertRangeFrom(*source, 0, length);
Became:
result_mutable = result->clone();
result_mutable->insertRangeFrom(*source, 0, length);
result = result_mutable;
Extra copying, even if refcount == 1.
In case of many iterations, all copies except the first
will be guaranteed to be redundant.
Extra copying, even if refcount == 1.
In case of many iterations, all copies
except the first will be guaranteed to be redundant.
Can we clone an object only if refcount > 1 and otherwise
magically convert shared_ptr to unique_ptr?
No, because they have different memory layout and everything else.
sizeof(unique_ptr<T>) == sizeof(void*)
sizeof(shared_ptr<T>) == 2 * sizeof(void*)
(in libc++ or libstdc++ implementation)
The copy-on-write technique is notoriously known
from old implementations of std::string.
Example: libstdc++ with old C++ ABI by default in gcc before 5.1.
CoW-strings are slow.
Why CoW-strings — are not very good:
— CoW implementation requires refcount tracking, which requires atomic operations in multithreaded programs;
— but in typical cases, most strings are small and copying them (with SSO or a good allocator) is cheaper than changing refcnt;
— copying on modification is done implicitly, which requires extremely complex code;
— especially for proper support of non-const iterator
and non-const operator[];
— starting with C++11, move replaces copying of temporary objects;
CoW should NOT be used:
1. For small objects that are easy to copy.
— but if object is heavy, it's better to share it
and increase refcount instead of copying.
Example: fbstring uses CoW for long strings and SSO for small ones.
2. If copying is performed implicitly.
— but you can make one method that gives access
to the non-const part of the interface (prepare object for modification)
clone if refcount > 1 or "reinterpret_cast" otherwise.
// Creating and assigning to immutable ptr.
COW<T>::Ptr x = COW<T>::create(1);
// Sharing single immutable object in two ptrs.
COW<T>::Ptr y = x;
// Now x and y are shared.
// Change value of x.
{
// Creating mutable ptr.
// It can clone an object under the hood if it was shared.
COW<T>::MutablePtr mutate_x = std::move(*x).mutate();
// Using non-const methods of an object.
mutate_x->set(2);
// Assigning pointer 'x' to mutated object.
x = std::move(mutate_x);
}
// Now x and y are unshared and have different values.
COW<T>::Ptr — behaves similarly to std::shared_ptr<const T>
COW<T>::MutablePtr — behaves similarly to std::unique_ptr<T>
Converting mutable to immutable:
COW<T>::MutablePtr x;
COW<T>::Ptr y = std::move(x);
Converting immutable to mutable:
COW<T>::Ptr x;
COW<T>::MutablePtr y = std::move(*x).mutate();
— performs copying if refcount > 1.
std::move(*x).mutate();
or
std::move(x)->mutate();
?
1. In C++ there's no rvalue qualified operator->
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3723.html
2. We want to invalidate the value itself, not the Ptr.
(we can still use the assignment operator on Ptr)
Update after the talk: actually there are no problems with calling
the assignment operator after move.
template <typename Derived>
class COW : public
boost::intrusive_ref_counter<Derived>
— we'll use an intrusive pointer
— add refcount to the object.
class IColumn : public COW<IColumn>
{
private:
friend class COW<IColumn>;
virtual MutablePtr clone() const = 0;
— COW<T>::mutate method will call T::clone if needed.
template <typename Derived>
class COW : public boost::intrusive_ref_counter<Derived>
{
template <typename T>
class IntrusivePtr : public boost::intrusive_ptr<T> {...}
protected:
template <typename T>
class mutable_ptr : public IntrusivePtr<T> {...}
public: using MutablePtr = mutable_ptr<Derived>;
protected:
template <typename T>
class immutable_ptr : public IntrusivePtr<const T> {...}
public: using Ptr = immutable_ptr<Derived>;
}
template <typename T>
class mutable_ptr : public IntrusivePtr<T>
{
...
public:
/// Copy: not possible.
mutable_ptr(const mutable_ptr &) = delete;
/// Move: ok.
mutable_ptr(mutable_ptr &&) = default;
mutable_ptr & operator=(mutable_ptr &&) = default;
/// Initializing from temporary of compatible type.
template <typename U>
mutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {}
mutable_ptr() = default;
mutable_ptr(std::nullptr_t) {}
};
template <typename T>
class immutable_ptr : public IntrusivePtr<const T>
{ ...
public:
/// Copy from immutable ptr: ok.
immutable_ptr(const immutable_ptr &) = default;
immutable_ptr & operator=(const immutable_ptr &) = default;
template <typename U>
immutable_ptr(const immutable_ptr<U> & other) : Base(other) {}
/// Move: ok.
immutable_ptr(immutable_ptr &&) = default;
immutable_ptr & operator=(immutable_ptr &&) = default;
/// Initializing from temporary of compatible type.
template <typename U>
immutable_ptr(immutable_ptr<U> && other) : Base(std::move(other)) {}
/// Move from mutable ptr: ok.
template <typename U>
immutable_ptr(mutable_ptr<U> && other) : Base(std::move(other)) {}
/// Copy from mutable ptr: not possible.
template <typename U>
immutable_ptr(const mutable_ptr<U> &) = delete;
immutable_ptr() = default;
immutable_ptr(std::nullptr_t) {}
};
class COW : public boost::intrusive_ref_counter<Derived>
{
protected:
MutablePtr shallowMutate() const
{
if (this->use_count() > 1)
return derived()->clone();
else
return assumeMutable();
}
public:
MutablePtr mutate() const && { return shallowMutate(); }
MutablePtr assumeMutable() const
{
return const_cast<COW*>(this)->getPtr();
}
Derived & assumeMutableRef() const
{
return const_cast<Derived &>(*derived());
}
template <typename Base, typename Derived>
class COWHelper : public Base
class IColumn
: public COW<IColumn>
{
friend class COW<IColumn>;
virtual MutablePtr clone() const = 0;
virtual ~IColumn() {}
};
class ConcreteColumn
: public COWHelper<IColumn, ConcreteColumn>
{
friend class COWHelper<IColumn, ConcreteColumn>;
};
Inheritance diagram:
boost::intrusive_ref_counter<IColumn>
↑
COW<IColumn>
↑
IColumn
↑
COWHelper<IColumn, ConcreteColumn>
↑
ConcreteColumn
class ColumnPair final
: public COWHelper<IColumn, ColumnPair>
{
private:
SomePtr a;
SomePtr b;
}
It's unclear whether to make class members MutablePtr or Ptr?
Approach 1: immutable members.
Object contains immutable members inside.
In all non-const methods, mutate is called,
modifications are made and assigned back.
Disadvantages:
Extra checks and atomic operations
on each call to non-const methods.
Approach 2: mutable members.
Object contains mutable members inside.
Two objects cannot have shared class members.
Disadvantages:
Shared class members are needed.
Example: columns with arrays having matching lengths.
Approach 3: immutable members and deep-mutation.
Object contains immutable members inside.
The mutate method performs deep mutation of all class members,
which guarantees their uniqueness in mutable objects.
In all non-const methods assumeMutableRef is used.
Disadvantages:
Complex implementation.
The assumeMutableRef method is unsafe.
std::experimental::propagate_const
Approach 4: chameleon_ptr.
Object contains chameleon members inside.
Which behave as immutable if the object is const
and as mutable if the object is not const.
template <typename T>
class chameleon_ptr
{
private:
immutable_ptr value;
public:
const T & operator*() const { return *value; }
T & operator*() { return value->assumeMutableRef(); }
...
}
using T = X;
using const T = Y;
Where X and Y — are different types with the same memory layout.
T works approximately like union { X x; Y y; }.
template <typename T>
using COWPtr<T> = mutable_ptr<T>;
template <typename T>
using const COWPtr<T> = immutable_ptr<T>;
It's unlikely worth doing this in C++.
C++ is already too complex :)
https://github.com/ClickHouse/ClickHouse/
blob/master/dbms/src/Common/COW.h
— 300 lines of code, 40% of which are comments.