This document defines vectra’s public contract: which operations are supported, how types and coercion work, what streams and what materializes, and where guarantees stop. It is the reference for what vectra promises.
See ?tbl, ?filter, ?left_join,
and ?explain for function-level documentation.
vectra is an R-native columnar query engine for datasets that don’t
fit in memory. It provides dplyr-style verbs backed by a pure C11
pull-based execution engine and a custom on-disk format
(.vtr). All operations are lazy until
collect() materializes results as an R data.frame.
vectra is not a dplyr backend plugin. It defines its own S3 generics. The verbs share names and semantics with dplyr, but do not depend on it.
Every verb (filter, select,
mutate, …) builds a plan node. Nodes form a tree. No data
moves until collect() calls the root node’s
next_batch() function, which pulls data through the tree
one row group at a time.
A row group (internally: VecBatch) is a set of columnar
arrays, typically thousands to millions of rows. Each column is a typed
array with a validity bitmap for NA support.
filter() does not copy rows. It attaches a
selection vector to the batch: an integer array
indexing which physical rows pass the predicate. Downstream nodes read
only the selected rows. This avoids memory allocation and copying for
selective filters.
Data is stored and processed column-by-column, not row-by-row. This
means operations that touch few columns (e.g. select(id, x)
on a 100-column table) only read the columns they need from disk.
| Function | Format | Streaming |
|---|---|---|
tbl(path) |
.vtr (vectra native) |
yes, row-group-at-a-time |
tbl_csv(path) |
CSV | yes, batch-at-a-time |
tbl_parquet(path) |
Parquet (one file, several, or a directory) | yes, row-group-at-a-time |
tbl_sqlite(path, table) |
SQLite | yes, batch-at-a-time |
tbl_tiff(path) |
GeoTIFF | yes, row-strip-at-a-time |
tbl_fasta(path),
tbl_fastq(path) |
FASTA / FASTQ (plain or gzipped) | yes, batch-at-a-time |
tbl_bed(path) |
BED | yes, batch-at-a-time |
tbl_xlsx(path) |
Excel .xlsx |
no, the sheet is read into memory via openxlsx2 |
All sources produce the same vectra_node object. The
query engine does not know or care which source is upstream.
| Function | Format | Streaming |
|---|---|---|
collect() |
R data.frame | materializes full result in R memory |
write_vtr(df, path) |
.vtr |
writes from data.frame |
write_csv(x, path) |
CSV | streams batch-by-batch |
write_sqlite(x, path, table) |
SQLite | streams batch-by-batch |
write_tiff(x, path, pixel_type) |
GeoTIFF | streams batch-by-batch; pixel_type:
float64/float32/int16/int32/uint8/uint16 |
| Verb | Streams | Notes |
|---|---|---|
filter(...) |
yes | Zero-copy via selection vector |
select(...) |
yes | Full tidyselect: starts_with(),
where(), -col, etc. |
mutate(...) |
yes | Arithmetic, comparison, boolean, is.na(),
nchar(), substr(), grepl(), math
(abs, sqrt, log,
exp, log1p, expm1,
floor, ceiling, round,
log2, log10, sign,
trunc, ^), trigonometric and hyperbolic
functions (sin, cos, tan,
asin, acos, atan,
atan2, sinh, cosh,
tanh, asinh, acosh,
atanh), the constant pi,
if_else(), between(), %in%, type
casting (as.numeric), tolower(),
toupper(), trimws(), paste0(),
gsub(), sub(), startsWith(),
endsWith(), pmin(), pmax(),
year(), month(), day(),
hour(), minute(), second(),
as.Date() |
transmute(...) |
yes | Like mutate() but drops unmentioned
columns |
rename(...) |
yes | Full tidyselect rename support |
relocate(...) |
yes | Reorder columns with .before /
.after |
| Verb | Streams | Notes |
|---|---|---|
group_by(...) |
metadata only | Attaches grouping info; no data moves |
summarise(...) |
materializes | Hash aggregation; spills to disk when the groups outgrow the memory budget |
ungroup() |
metadata only | Removes grouping |
count(...) |
materializes | Sugar for
group_by() |> summarise(n = n()) |
tally(...) |
materializes | Like count() on existing groups |
Supported aggregation functions: n(),
sum(), mean(), min(),
max(), sd(), var(),
first(), last(), any(),
all(), median(), n_distinct().
All accept na.rm = TRUE.
| Verb | Streams | Notes |
|---|---|---|
arrange(...) |
materializes | External merge sort; spills sorted runs past the memory budget |
slice_head(n) |
yes | Limit node, stops after n rows |
slice_tail(n) |
materializes | Must see all rows to take last n |
slice_min(order_by, n) |
partial | Heap-based top-N; with_ties = TRUE
(default) includes ties |
slice_max(order_by, n) |
partial | Heap-based top-N; with_ties = TRUE
(default) includes ties |
head(n) |
yes | Alias for
slice_head() |> collect() |
slice(...) |
materializes | Select or exclude rows by position (positive or negative indices) |
distinct(...) |
materializes | Grouped aggregation with no aggregates, so the same hash path and spill |
| Verb | Streams | Notes |
|---|---|---|
inner_join(x, y) |
build side buffered | Hash join; left streams, right is the build side and spills past the budget |
left_join(x, y) |
build side buffered | Hash join; left streams |
right_join(x, y) |
build side buffered | Implemented as swapped left join |
full_join(x, y) |
build side buffered | Hash join + finalize pass |
semi_join(x, y) |
build side buffered | Hash join; returns left rows only |
anti_join(x, y) |
build side buffered | Hash join; returns non-matching left rows |
cross_join(x, y) |
materializes | Cartesian product; no key columns required |
All joins support: by = "col",
by = c("a" = "b"), by = NULL (natural join),
and suffix = c(".x", ".y").
Available inside mutate():
| Function | Description |
|---|---|
row_number() |
Sequential row number (respects groups) |
rank(col) |
Min rank with gaps for ties (like
dplyr::min_rank()) |
dense_rank(col) |
Consecutive rank without gaps |
lag(col, n, default) |
Previous value |
lead(col, n, default) |
Next value |
cumsum(col) |
Cumulative sum |
cummean(col) |
Cumulative mean |
cummin(col) |
Cumulative minimum |
cummax(col) |
Cumulative maximum |
ntile(n) |
Divide rows into n roughly equal buckets |
percent_rank(col) |
Relative rank scaled to [0, 1] |
cume_dist(col) |
Cumulative distribution (proportion of values <= current) |
Window functions respect group_by() partitions. A
grouped window sorts its input by the group keys through the external
sort and then processes one group at a time, so its peak is the largest
group. An ungrouped window streams the table in one forward pass; when
its functions need different orderings (for example lag()
next to rank()), it runs as a chain of single-function
windows, each of which streams.
| Verb | Streams | Notes |
|---|---|---|
pull(var) |
materializes | Collects one column as a vector |
bind_rows(...) |
yes | Streaming concat if schemas are compatible |
bind_cols(...) |
materializes | Collects all inputs, then cbind() |
across(...) |
n/a | Column expansion helper for
mutate()/summarise() |
explain() |
n/a | Prints the plan tree |
glimpse() |
materializes (preview only) | Shows column types and first few values |
select(), rename(),
relocate(), distinct(), and
across(.cols) support the full tidyselect vocabulary:
starts_with(), ends_with(),
contains(), matches()
everything(), last_col()
all_of(), any_of()
where() predicates
(e.g. where(is.numeric))
-column negation
where() works because vectra builds a 0-row typed proxy
data.frame from the schema, giving tidyselect enough type information to
evaluate predicates.
Every verb that takes expressions (filter(),
mutate(), transmute(),
summarise(), arrange(),
group_by(), count(), pull(),
slice_min(), slice_max(), and the others)
captures its arguments with rlang, so the usual injection tools
work:
!! and !!! inject a value, a symbol or a
list of them (group_by(!!sym(k)),
summarise(!!!aggs)){{ }} forwards an argument from a wrapper function!!name := expr builds a column name.data[[k]] and .data$k refer to a column
by name, .env$x to a variable in the calling
environment| C type | R input | R output (default) | R output (bit64 mode) |
|---|---|---|---|
int64 |
integer | double | integer64 |
double |
double | double | double |
bool |
logical | logical | logical |
string |
character | character | character |
R’s 32-bit integer is widened to 64-bit
int64 on write. On read, int64 is returned as
double by default (R has no native 64-bit integer). Set
options(vectra.int64 = "bit64") to get
bit64::integer64 output instead.
The .vtr schema stores a per-column annotation that
preserves R type metadata through the write/read cycle:
| R class | Annotation | Storage | Roundtrip |
|---|---|---|---|
| Date | "Date" |
double (days since epoch) | exact |
| POSIXct | "POSIXct\|tz" |
double (seconds since epoch) | exact (tz preserved) |
| factor | "factor\|lev1\|lev2\|..." |
string | exact (levels + order preserved) |
Annotations are metadata. The underlying C engine operates on the
base types only. Type restoration happens at collect()
time.
Date and POSIXct columns support component extraction via
year(), month(), day(),
hour(), minute(), second() in
mutate() and filter() expressions. Use
as.Date("2020-01-01") as a literal in filter comparisons.
Date arithmetic (adding/subtracting days) works via standard
+ and -.
The coercion hierarchy for numeric operations is:
bool < int64 < double
When an expression combines two different numeric types, the narrower type is promoted to the wider type before evaluation. String columns cannot participate in arithmetic or comparison with numeric columns; this is an error.
Join keys follow the same hierarchy. If the left key is
int64 and the right key is double, the
int64 side is coerced to double for hashing
and comparison. The coercion happens internally; the output column
retains the original left-side type.
Joining a string key against a numeric key is an
error.
When bind_rows() combines tables with different column
types, it computes the common type using the same
bool < int64 < double hierarchy. Per-batch coercion
happens at the C level during streaming — no R fallback needed.
If column names differ across inputs, the R fallback path is used:
all inputs are collected, aligned by column name, and combined with
rbind().
NAs are tracked by a per-column validity bitmap. Every column of every type supports NA values. The bitmap is bit-packed (1 bit per row, 1 = valid).
Arithmetic: NA + x = NA,
NA * x = NA
Comparison: NA > x = NA,
x == NA = NA
Boolean: NA & FALSE = FALSE,
NA & TRUE = NA, NA | TRUE = TRUE,
NA | FALSE = NA
Aggregation: NAs are included by default; use
na.rm = TRUE to exclude
Joins: NA keys never match (same as SQL NULL semantics)
Window functions: cumsum() and
friends propagate NA forward
is.na(col) is supported in filter() and
mutate() expressions. It returns a boolean column based on
the validity bitmap.
filter(), select(),
mutate(), rename(), relocate():
preserve input order
arrange(): produces a total order (stable
sort)
group_by() |> summarise(): groups come out sorted
by key, with NA keys last, whether or not the aggregation
spilled
distinct(): sorted by the distinct columns,
NA last (it runs through the same aggregation)
Joins: probe-side order is preserved within each batch; build-side order is not guaranteed
bind_rows(): child order is preserved (first child’s
rows, then second’s, etc.)
Scans (.vtr, Parquet, CSV, SQLite, TIFF,
FASTA/FASTQ, BED)
Filter
Project (select / mutate / rename / relocate / transmute)
Limit (slice_head, head)
Concat (bind_rows)
Ungrouped windows (see Window functions above)
These nodes buffer data. Every one of them is bounded: it either holds a fixed amount of state or spills to temporary files once its buffer reaches the memory budget.
| Node | What it buffers | Bounded by |
|---|---|---|
| Sort (arrange) | Input rows | Memory budget, then sorted runs on disk |
| GroupAgg (summarise, distinct, count) | Hash tables of groups + accumulators | Memory budget, then hash partitions on disk |
TopN (slice_min/max,
with_ties = FALSE) |
Heap of n rows | Requested n |
| Window, grouped | One group at a time, after the external sort | Largest group |
| Join (build side) | Right-side rows + hash table | Memory budget, then hash partitions on disk |
The memory budget is vectra_mem(), which defaults to
half of physical RAM (at least 1 GB) and is set with
options(vectra.memory = "4GB"). It bounds the whole query,
not each node. Before execution the optimizer creates one pool of
vectra_mem() bytes for the plan and gives every buffering
step (sort, join, grouped aggregate, fuzzy join, k-mer count) a claim on
it. A step reserves the bytes it actually allocates as its buffers grow,
releases them as it frees them, and spills to disk when the pool refuses
a reservation. A step that needs little leaves the rest of the pool to
the others. So that no step can be starved, each is guaranteed a floor
of 1/(4 x number of buffering steps) of the budget; the other three
quarters are shared, first come first served. The nodes a step creates
internally, such as a grouped aggregate’s sorts or a grace-hash join’s
sub-joins, draw on that step’s claim.
Spill files (sort runs, join and aggregation partitions, fuzzy-join
build runs, and the temporary file diff_vtr() collects
added rows in) are written uncompressed, since each is written once and
read once. They go to tempdir() and are deleted when the
node that wrote them is freed.
arrange() accumulates incoming batches into column
builders. Before taking a batch it reserves what the builders will hold
after growing, plus 32 bytes per buffered row for the sort’s working
space (the row permutation and the radix sort’s scratch). When the
reservation is refused, the node sorts what it holds and writes it to a
temporary .vtr file as one sorted run, in row groups of
65,536 rows, then resets the builders and continues. Sorting in memory
orders a row-index permutation: a radix sort for a single numeric key,
otherwise a parallel merge sort (OpenMP tasks above 32,768 rows).
Once the input is consumed, the node merges the runs with a min-heap. The number of runs merged at once (the fan-in) is capped: it is computed from the measured width of the spilled rows so that the rows held by an open merge stay within half the budget, and lies between 2 and 64. When there are more runs than the fan-in, groups of runs are first merged into fewer, longer runs, in as many passes as needed, before the final merge streams the result in batches of 65,536 rows. Merge memory therefore depends on the row width and the budget, not on the number of rows sorted.
If the input fits within the budget, nothing is written to disk and the sort runs in memory. It then keeps only the buffered columns and the permutation, and emits the result in batches of 131,072 rows, gathering each batch’s fixed-width columns in one parallel pass.
summarise() of the scalar aggregates (n(),
sum(), mean(), min(),
max(), sd(), var(),
first(), last(), any(),
all()) aggregates in hash tables. The key space is split by
hash into up to 64 shards, one hash table per shard, and the shards run
in parallel; a group lives in exactly one shard, so nothing has to be
merged afterwards. Hashing is FNV-1a on the key bytes, multi-key hashes
are combined by rotating and XOR-ing the per-key hashes.
When the tables reach their share of the budget they stop admitting new groups. Rows of groups already held keep aggregating in place; rows of any other group are written, by a salted hash of the key, to one of 64 temporary partition files. Each partition is then aggregated on its own with a differently salted hash, recursively, and a partition still too large after three levels takes the sort-based path. Every group is either wholly in memory or wholly in one partition, so no partial result is ever combined with another.
median() and n_distinct() need every value
of a group. With either of them in the summarise(), the
input is sorted by the group keys through the external sort and the
groups are aggregated one at a time; each group’s values go to a buffer
that spills to disk and is reduced by an external merge, so a single
very large group costs disk rather than memory.
Groups are emitted sorted by key, NA last. When nothing
spilled, the result table is sorted in memory; otherwise it passes
through the external sort.
Joins use a build-right, probe-left hash join:
When the build side outgrows the memory budget, the join switches to a grace hash join: both sides are written by key hash into 64 partition files and the partitions are joined one at a time. A partition that is itself still too large is partitioned again with a hash salted by the recursion depth, so keys that collided at one level split at the next. A single key value repeated more times than fits in memory cannot be split by any hash; after three levels such a partition is joined by a block nested loop, reading the build side in blocks that fit the budget and rescanning the probe side once per block. At that level the join holds one build block, one probe batch, and bitsets of one bit per row.
When both inputs are already sorted on the join keys, the join merges them instead of building a hash table. Which of the two it uses is decided before the build, because it sets what the build reserves: a hash join reserves the build columns plus the slot arrays (16 bytes per slot, with the slot count the next power of two at or above twice the build rows), a chain array and a temporary hash array (8 bytes per build row each), and for a full join a one-bit-per-row matched bitset. A merge join reserves only the columns and the bitset. The grace-hash partitions and block-nested-loop blocks check the same figure.
The left side streams and does not accumulate, so put the smaller
table on the right. right_join() does this by swapping the
sides internally and remapping the output columns.
The .vtr format is vectra’s native binary columnar
format, built on the tdc container (typed dimensional compression,
vendored in src/tdc/).
tdc container:
header (64 bytes): "TDC1" magic, flags, section offsets
schema: per-column name, type and annotation
blocks: one self-describing block record per column per row group
row-group index: block offsets and sizes, plus per-column
min / max / null count for every row group
vectra trailer (24 bytes):
container length (u64), digest of the container bytes (u64),
trailer version (u32), "VTRD"
Each column of each row group is one block record: a header naming how the block was encoded, the encoded payload, and the column’s validity bitmap. The reader decodes each block from its own header, so files written with different compression settings are read the same way.
The trailer records a digest of every byte written.
create_index() stamps its sidecar with a fingerprint built
from it, which is how an index notices that its store was replaced (see
Hash indexes). A reader that does not know the trailer ignores it, since
tdc never reads past the row-group index.
append_vtr() grows a store in place in either direction:
new row groups (along = "rows") or new columns
(along = "cols") are written after the old index together
with a rebuilt index, and the header is patched last, so an interrupted
append leaves the previous store readable.
The format is not compatible with .vtr files written
before the tdc container was adopted.
write_vtr(compress = ) picks the encoding per column
chunk:
"fast" (default): numeric columns are byte-shuffled
(the bytes of each value are regrouped by position, which exposes the
redundancy in exponent and high-order bytes) and compressed with LZ; a
non-decreasing integer column is delta-encoded first. String columns are
dictionary-encoded: the distinct values once, then an index per row. The
LZ runs at tdc’s fastest matching level."small": for each column chunk, tries a set of
candidate encodings and keeps the smallest. Candidates vary the model
(raw, delta, second-order delta, floating-point prediction, numeric
dictionary, sparse-zero) and the entropy coder (LZ at several parser
levels, FSE, Huffman, per-lane coding), and always include the
"fast" encoding, so "small" is never larger
than "fast". The trial encodes run in parallel, and the
choice does not depend on the thread count."none": no compression; strings are still stored as a
dictionary.Columns written with write_vtr(quantize = ) or
write_vtr(spatial = ) use tdc’s quantizing and 2-D
predictive models instead.
Because string columns are dictionary-encoded, collect()
converts each distinct string to an R string once and fills the column
by index.
explain() runs the optimizer before printing so you see
the actual execution plan. The optimizer rewrites the plan so the order
in which the verbs were written does not change how much is read.
A filter stays where it was written and still checks every row. In
addition, the optimizer hands a copy of its predicate down to every scan
it can reach, where the copy is used only to skip row groups. The copy
passes through mutate(), select(),
rename() (following renamed columns), other filters,
arrange(), and into every input of bind_rows()
whose column types match. A condition on a column computed or
overwritten by a mutate() is dropped from the copy, which
is safe because the filter above still evaluates it. The copy never
passes a limit, top-n, window, aggregate or join, since removing rows
before those would change the result.
A .vtr scan applies up to three pruning strategies on
its first batch:
Hash index pushdown (highest priority). If a
.vtri sidecar index exists for the predicate column(s), the
scan probes the index to build a row-group bitmap. Row groups not in the
bitmap are skipped entirely. This handles == and
%in% predicates. For composite indexes, AND-combined
equality predicates on the indexed columns are matched and probed as a
single composite key. See the Hash indexes section below for
details.
Binary search on sorted columns. If a column’s
values are sorted across row groups and the predicate is a simple
comparison (==, <, <=,
>, >=) against a literal, the scan
binary-searches the row-group statistics for the first and last row
groups that could contain matching rows. For AND-combined predicates on
the same sorted column (e.g. x >= 10 & x < 100),
both bounds are applied. For OR-combined predicates, the union of both
ranges is used.
Zone-map pruning (per row group). Each row group
stores per-column min/max and null counts. Before reading a row group,
the scan evaluates the predicate against these statistics and skips the
row group when no row can match (e.g. filter(x > 100)
where the row group’s max(x) is 50). This covers comparisons, AND/OR
combinations and %in% on numeric, logical and date columns.
A comparison on a column that is entirely NA in a row group
also skips it. String columns carry only the null count, so string
predicates prune through a hash index rather than zone maps.
These strategies compose: hash index and binary search narrow the candidate row groups, then zone maps are checked on each candidate before its bytes are read. A Parquet scan uses the same zone-map check against the min/max and null counts in the Parquet footer.
The optimizer walks the plan top-down and determines which columns
each node needs from its child: the columns referenced in its own
expressions (filter predicates, mutate expressions, aggregation inputs,
join keys) plus the columns its parent needs. A mutate()
drops the passed-through columns nothing above it uses, so pruning
continues below it. At scan nodes, unneeded columns are never read,
decompressed or decoded. For a 100-column .vtr file where 3
columns are needed, this shows in explain() as
3/100 cols (pruned).
explain() prints the optimized plan tree without
executing it. The output shows:
Node types in execution order (leaf to root)
Per-node annotations: streaming/materializing, column pruning,
predicate pushdown, row-group statistics (tdc stats), a
hash index when one will be used, hidden mutate
Grouping columns if present
Output schema (column names and types)
For an offloaded node or partition (see offload()),
its cost grade
tbl("data.vtr") |>
filter(x > 0) |>
select(id, x) |>
explain()
#> vectra execution plan
#>
#> ProjectNode [streaming]
#> FilterNode [streaming]
#> ScanNode [streaming, 2/5 cols (pruned), predicate pushdown, tdc stats]
#>
#> Output columns (2):
#> id <int64>
#> x <double>The plan tree is a description of what will happen, not a guarantee of how it will happen internally. Node ordering and naming may change between versions.
vectra supports persistent on-disk hash indexes stored as
.vtri sidecar files alongside .vtr data files.
An index names the row groups that may hold a key, so an equality
predicate reads those groups and skips the rest.
Both functions take the path of the .vtr file:
# Single-column index
create_index("data.vtr", "species")
# Case-insensitive index
create_index("data.vtr", "species", ci = TRUE)
# Composite (multi-column) index
create_index("data.vtr", c("country", "year"))
# Check whether a usable index exists
has_index("data.vtr", "species")create_index() reads the indexed column(s) of the
.vtr file one row group at a time and writes a
.vtri file holding one entry per distinct key per row
group. The file name encodes the indexed columns:
data.species.vtri for a single-column index,
data.country_year.vtri for a composite. Composite columns
are canonicalized to schema order, so they may be named in any
order.
The .vtri format is a sorted array of (key hash, row
group) entries. One layout covers both cases: a header with the indexed
column indices, a case-insensitive flag, and the row count, row-group
count and fingerprint of the store at build time, followed by the
entries in ascending hash order and a directory giving the first entry
for each range of leading hash bits.
Sorting the entries rather than chaining them is what lets an index be built in one forward pass through a fixed memory budget, since a chained table has to know every entry’s bucket before it can write the first one. It also drops the per-entry chain pointer and the bucket array: 12 bytes an entry rather than about 36.
Hashing uses FNV-1a on the raw column bytes; a case-insensitive index folds ASCII uppercase to lowercase before hashing. A composite key combines the per-column FNV-1a hashes by XOR and multiplication with the FNV prime, giving one 64-bit hash per entry.
The stored stamp is what makes an index safe to leave on disk.
vtri_open() compares it with the store being queried and
reports no index when any part disagrees, so an index that no longer
describes its store is ignored rather than used to prune row groups that
have moved or now hold other keys. The counts catch an append; the
fingerprint catches a store replaced by another of the same shape,
whether by write_vtr(), a download or a rename. It digests
the store’s row-group index together with a digest of the store’s bytes,
which the writers compute as they write and record in a 24-byte trailer
after the container, so checking it costs the store’s metadata rather
than its data. A store written before 0.12.4 has no trailer, and its
fingerprint covers its layout only. Index formats written before 0.12.4
lack the fingerprint and read as absent; create_index()
rebuilds them.
On its first batch, a ScanNode looks at its pushed-down
predicate, picks the column an equality or %in% clause
offers, and opens that column’s .vtri if there is one. For
composite indexes it collects the AND-combined equality clauses and
opens the sidecar covering exactly those columns. Opening is deferred to
this point, so a query reads an index only for the column it filters
on.
On match, the scan probes the index to produce a row-group bitmap.
For %in% predicates, the scan probes once per set element
and ORs the bitmaps together. Row groups with a 0 bit in the bitmap are
never read from disk. This is the first pruning step, applied before
binary search and zone-map checks.
A probe is a hash computation, one directory lookup, and a binary
search over the few entries that directory slot covers (the directory
holds one slot per four entries, up to 2^22 slots), repeated per query
key (once for ==, n times for %in%). A sidecar
of up to 4 MB is read into memory when the scan opens it; a larger one
is memory-mapped read-only and probed in place, so opening it does not
read the whole file. An index over a column of few distinct values stays
the same size as the store grows, so the cost of a lookup does not
follow the size of the store. For tables with many row groups and
selective equality predicates, index pushdown can reduce I/O by orders
of magnitude compared to zone-map pruning alone.
materialize() consumes a vectra node and stores the
result as a persistent in-memory columnar block. Unlike nodes, which are
consumed on collect() and cannot be reused, blocks persist
and support repeated lookups.
block_lookup() performs hash-based lookups on a string
column. Hash indices are built lazily on first use and cached for
subsequent calls. The return value is a data.frame with a
query_idx column (1-based position in the input keys
vector) plus all columns from the block.
block_fuzzy_lookup() computes string distances between
query keys and a block column. Three distance methods are available:
Damerau-Levenshtein ("dl", default), Levenshtein
("levenshtein"), and Jaro-Winkler ("jw").
Results are filtered by a maximum normalized distance threshold (default
0.2).
fuzzy <- block_fuzzy_lookup(blk, "canonicalName",
c("Quercus robar", "Pinus silvestris"),
method = "dl", max_dist = 0.2)An optional blocking column reduces the search space by requiring exact matches on a second column before computing distances. This is useful for taxonomic lookups where genus is known:
fuzzy <- block_fuzzy_lookup(blk, "canonicalName",
c("Quercus robar"),
block_col = "genus",
block_keys = c("Quercus"),
n_threads = 4L)Fuzzy lookups are OpenMP-parallelized. The n_threads
parameter controls the thread count (default 4).
Materialized blocks are designed for repeated lookups against a
reference table. The typical pattern is: load a backbone or codelist
once with materialize(), then probe it many times with
different query vectors. The block stays in memory across calls, and its
internal hash index (for exact lookups) is built once and reused.
The C engine uses OpenMP for CPU-bound operations where the per-element cost is high enough to justify thread management overhead. Parallelism is conditional: operations fall back to single-threaded execution when the batch size is below a threshold or when the platform does not support OpenMP.
| Operation | Threshold | Schedule | Notes |
|---|---|---|---|
filter() (selection vector build) |
32,768 rows | parallel prefix sum | Two-phase: count matches per thread, then write at offsets |
grepl() with regex |
1,000 rows | dynamic, 64 |
Per-thread regex compilation for thread safety |
levenshtein(), dl_dist(),
jaro_winkler() |
1,000 rows | dynamic, 64 |
Fuzzy string distance in mutate expressions |
| Sort (merge sort) | 32,768 rows | task |
Recursive task spawning for parallel merge sort; a single numeric key uses a radix sort instead |
| Sort (emit gather) | 32,768 rows x columns | static |
Fixed-width columns of each emitted batch gathered in one parallel loop |
| Join (build-side hashing) | 32,768 rows | static |
Parallel hash computation for build-side keys |
| Join (probe-side hashing) | 32,768 rows | static |
Parallel hash computation for probe-side keys |
| Grouped aggregation (key hashing) | 32,768 rows | static |
Hash and shard assignment per row |
| Grouped aggregation (shards) | 32,768 rows | dynamic, 1 |
One hash-table shard per thread |
| Window (data copy) | 32,768 rows | static |
Parallel copy of partition data |
| Window (group dispatch) | 64 groups | dynamic |
Parallel window computation across groups |
| Collect (column append) | 8 columns | static |
Parallel column-by-column append to R vectors |
collect() of a plain .vtr
scan |
always | dynamic |
Row groups decoded in parallel straight into the R vectors |
write_vtr(compress = "small") |
32,768 values | dynamic |
Candidate encodings tried in parallel |
| Parquet read (columns) | 32,768 values | dynamic, 1 |
Column chunks of a row group decoded in parallel |
| Block fuzzy lookup | always (if > 0 keys) | dynamic, 1 per key /
dynamic, 16 per row |
Parallelized by query key and by block row |
| Literal fill (broadcast) | 32,768 rows | static |
Parallel fill for constant columns |
The general-purpose threshold is 32,768 rows
(VEC_OMP_THRESHOLD). String operations use a lower
threshold of 1,000 rows because their per-element cost (regex
compilation, edit distance matrices) is much higher than arithmetic.
Regex operations (grepl with regex patterns) compile the
POSIX regex once per thread. Each thread owns its own
regex_t instance, allocated in thread-local scope inside
the #pragma omp parallel block. This avoids both contention
and the overhead of recompiling the regex per row.
The filter node uses a parallel prefix sum to build the selection vector without locking. Each thread counts matches in its chunk, a sequential scan computes prefix offsets, then each thread writes selected indices at its computed offset.
slice_tail materializes: There is no reverse-scan optimization.
distinct with .keep_all: Falls back to R when
.keep_all = TRUE with a column subset.
Predicate pushdown covers .vtr and Parquet only: CSV, SQLite, TIFF, FASTA/FASTQ and BED scans do not benefit from predicate pushdown, column pruning, or hash index acceleration. Parquet scans get column pruning and row-group pruning from footer statistics, but no hash indexes.
Joins are not rewritten by the optimizer: a join reads every column of both inputs, and filters above a join are not pushed into its inputs. Filter and select before the join to limit what it reads.
No SIMD: Arithmetic and comparison operations use scalar loops. The compiler may auto-vectorize some patterns, but there are no explicit SIMD intrinsics.
OpenMP availability varies: when vectra is built
from source, configure compiles, loads and runs a small
OpenMP test for each candidate set of compiler flags and uses the first
that works. On macOS it tries the OpenMP runtime that CRAN’s R ships in
R_HOME/lib first, then a Homebrew libomp. If
none works, vectra is built single-threaded, which changes speed but not
results. On Windows (Rtools) and Linux, OpenMP is typically available
out of the box.
vectra has these fallback paths to base R:
bind_rows with mismatched column names: If
column names differ across inputs, all tables are collected and combined
via rbind() in R.
distinct with .keep_all and column subset: Falls
back to duplicated() in R (emits a message).
slice_tail: Must see all rows to take the last n; returns a data.frame.
slice_min/slice_max with
with_ties = TRUE (the default): Collects all data
to identify ties at the boundary; returns a data.frame.
reframe: Always collects and evaluates in R; returns a data.frame.
All other operations execute entirely in C. There is no silent fallback to dplyr or any other package.
All verbs preserve group_by() metadata:
filter(), select(), mutate(),
rename(), relocate(), arrange(),
and transmute() pass grouping through.
rename() additionally updates group column names to match
the rename. summarise() drops grouping according to its
.groups argument.
vectra defines its own S3 generics for dplyr-like verbs
(filter, select, mutate, etc.)
and utility functions (glimpse, collect). If
dplyr is also loaded, whichever package was attached last will mask the
other’s generics. vectra’s methods will still dispatch correctly on
vectra_node objects regardless of masking order.