C++ Quickstart¶
Vortex C++ API allows you to read and write .vortex files directly or via
an Arrow compatibility layer like nanoarrow.
The only dependency apart from Vortex is nanoarrow.
Note
C++ API is a work in progress. Please reach out to us if you are interested in using Vortex from C++ or you want a feature not covered yet e.g. extension support.
Installation¶
We don’t provide prebuilt library files (yet) so you will need to build Vortex from source, and for that you will need:
C++20,
Rust toolchain,
and CMake 3.10.
git clone --depth 1 https://github.com/vortex-data/vortex
cd vortex
cargo build --release -p vortex-ffi
cmake -S lang/cpp -Bbuild -DCMAKE_BUILD_TYPE=Release
# To build the examples, pass -DBUILD_EXAMPLES=1
# cmake -S lang/cpp -Bbuild -DBUILD_EXAMPLES=1
cmake --build build -j
This produces a shared and a static library which you can use directly or via
# static library
target_link_libraries(target PRIVATE vortex_cxx)
# shared library
target_link_libraries(target PRIVATE vortex_cxx_shared)
Have a look at the examples directory as well.
Reading files¶
Full source code for this example is reader.cpp
. For brevity we omit main(), system includes and using directives.
Assuming you have Vortex files people0, people1, and me in a local folder,
each containing U8 column “age” and U16 column “height”, this is how you
print all ages for specific heights:
const Session session;
const DataSource ds = DataSource::open(session, {"people*.vortex", "me.vortex"});
Scan scan = ds.scan({.filter = col("height") >= lit<uint16_t>(50)});
for (Partition &partition : scan.partitions()) {
for (Array &array : partition.batches()) {
const Array age = array.field("age");
const PrimitiveView<uint8_t> age_view = age.values<uint8_t>(session);
const std::span<const uint8_t> age_values = age_view.values();
for (uint8_t value : age_values) {
std::cout << int(value) << " ";
}
}
}
std::cout << "\n";
DataSource and Scan¶
First, you need to create a Vortex session, which does extension bookkeeping and may also hold metadata like object store credentials (we’ll get back to it later). Further, a DataSource provides a view over multiple files which may also be remote. You can specify globs for every item as it’s shown.
Once you have a DataSource, you can create Scans out of it. A Scan is a single
traversal of a DataSource which projects columns and filters on them. Our Scan
is consumed by following calls so it needs to be mutable. ScanOptions, which is
passed to Scan, is a simple C++ aggregate so you can initialize any fields you
want or avoid them altogether (auto scan = ds.scan()).
Expressions¶
If you omit .projection all columns are read as part of a scan. If we wanted
to return only the “age” column we’d write .projection = col("age"). We pass
an Expression to a filter which returns false for some “height” values, and the
scan filters them out.
Two additional things to look for in .filter: first, we allow overloading
Expression operators which produce Expressions themselves à la Eigen. This is
opt-in via using namespace vortex::expr::ops. If you prefer, you can use
eq(col("height"), lit<uint16_t>(180)) instead. The second thing is that we
explicitly pass the type to the lit expression, which creates a literal
constant. We don’t do any type coercion in Vortex so if you were to write
lit(180), C++ would likely deduce the type to int and fail at runtime.
Once we’re done with the scan, we need to consume the data it provides. Vortex has Arrow interoperability, but now let’s focus on Partitions and Arrays.
Partitions and Arrays¶
A Partition is an independent unit of work. Assuming your Vortex file processing will likely be multithreaded, each thread can pull Partitions from a Scan and handle them in parallel. For simplicity we don’t cover it here, but look for the next sections! Each Partition produces Arrays which are batches of rows and columns.
Note
Arrays, DataTypes, and Expressions are reference-counted so copying them is cheap.
Once we have an Array, we can get access to its values. First, we need to extract the “age” field.
Note
Default projection returns the root Array which is a Struct so to get
a field from it we need to use field(). If we were to use col() in
projection, the array we got would be “age” column already, so we wouldn’t
need to use field().
Then we want to get access to the raw bytes. However, Array likely references compressed data, so now we need to learn about canonicalization.
Canonicalization¶
Vortex files hold trees of compressed data where each node is a specific
encoding (zstd, FSST, delta) over another node. This is good for performance
because we defer decompression and we can also pass compressed data directly to
other systems. Say, if a reader knows how to deal with bitpacked integers but
not RLE and we have RLE(Bitpacked(U64)), we can decompress just RLE and
pass bitpacked array directly to the reader.
In this example we want to remove all encodings and uncompress all data fully. This form is called a canonical Array, and the process is canonicalization.
When we request .values(session), we canonicalize the Array and get a
PrimitiveView because we already know the type of the column. As PrimitiveView
holds uncompressed data, we can get raw uint64_t numbers by calling
.values() on the view.
Writing files¶
Now let’s write the first files to be read by our previous example. Source code for this example is writer.cpp.
const Session session;
const DataType dtype = dtype::struct_({
{"age", dtype::uint8()},
{"height", dtype::uint16(Nullable)},
});
constexpr size_t SAMPLE_ROWS = 100;
std::vector<uint8_t> age_buffer(SAMPLE_ROWS);
std::vector<uint16_t> height_buffer(SAMPLE_ROWS);
for (size_t i = 0; i < SAMPLE_ROWS; ++i) {
age_buffer[i] = static_cast<uint8_t>(i);
height_buffer[i] = static_cast<uint16_t>((i + 1) % 200);
}
Array age = Array::primitive<uint8_t>(age_buffer);
Array array = make_struct({
{"age", age},
{"height", Array::primitive<uint16_t>(height_buffer, AllValid)},
});
Expression age_gt_10 = expr::col("age") > expr::lit<uint8_t>(10);
Array validity_array = array.apply(age_gt_10);
const Validity validity = Validity::from_array(validity_array);
Array array2 = make_struct({
{"age", age},
{"height", Array::primitive<uint16_t>(height_buffer, validity)},
});
Writer writer = Writer::open(session, argv[1], dtype);
writer.push({array, array2});
writer.finish();
DataType¶
First, you want to create a schema. DataType is a logical representation of
Vortex’s type system. “Logical” as opposed to “physical” means DataType doesn’t
know what encodings the data use. As another example, Arrow and Parquet’s types
are physical. There’s a convenience function struct_ which creates a
DataTypeVariant::Struct with fields passed as a initializer_list.
Each DataType has a notion of nullability: whether some items in the row can be invalid (Vortex uses the terms “null” and “invalid” interchangeably). DataTypes are non-nullable by default, so “age” column is not nullable, but “height” is.
Once we create Arrays for columns we can merge them into a Struct Array with
make_struct, and we need to say a word about Validity.
Nullability and Validity¶
Nullability (and Nullable flag) is a type-system note that we may have
invalid elements in the column. A non-Nullable column can’t have nulls in
it, but a Nullable column’s items may be all valid as well. Validity, on the
other hand, tells us whether we do have nulls in a particular Array.
“height” in the first Array is AllValid which means in this Array we don’t have
invalid items. For the second Array we reuse age by copying it (which is a
reference clone so it’s cheap). But for “height” we say Validity is determined
by another Array. This array consists of Bools and we obtained it by applying a
comparison Expression to the “age” column of the first array. So, “height” field
in array2 will have null/invalid values if “age“‘s item at the same index
was less than 11.
Applying Expressions¶
One important thing to know is that applying an Expression is a constant-time operation, and the returned array contains the original values along with some meta-information about the expression. Real application happens when you execute the array. One example of executing the array is canonicalization, which in this example happens later when we write both arrays to the file.
Writing to a file¶
Once we’re done with the arrays, we need to initialize the Writer and push
arrays into it. finish() call writes the file footer. If you don’t call it and
let Writer go out of scope, the file will be visibly corrupted.
Now you can build the example and read back the generated files:
./build/examples/writer people0.vortex
./build/examples/writer people1.vortex
./build/examples/writer me.vortex
./build/examples/reader