Skip to content

Commit

Permalink
Merge pull request #22 from orxfun/recursive-growth-strategy
Browse files Browse the repository at this point in the history
recursive growth strategy is introduced
  • Loading branch information
orxfun authored Jan 15, 2024
2 parents 30f7247 + 3d74743 commit 8ac7dcc
Show file tree
Hide file tree
Showing 27 changed files with 697 additions and 44 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "orx-split-vec"
version = "1.2.1"
version = "1.3.0"
edition = "2021"
authors = ["orxfun <[email protected]>"]
description = "An efficient constant access time vector with dynamic capacity and pinned elements."
Expand All @@ -13,7 +13,7 @@ categories = ["data-structures", "rust-patterns"]
orx-pinned-vec = "1.0"

[[bench]]
name = "serial_access"
name = "append"
harness = false

[dev-dependencies]
Expand Down
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ As the name suggests, `SplitVec` is a vector represented as a sequence of multip

The vector is said to be at its capacity when all fragments are completely utilized. When the vector needs to grow further while at capacity, a new fragment is allocated. Therefore, growth does <ins>not</ins> require copying memory to a new memory location. Priorly pushed elements stay <ins>pinned</ins> to their memory locations.

### C.1. Available Growth Strategies
### C.1. Available Growth Strategies: **`Linear` | `Doubling` | `Recursive`**

The capacity of the new fragment is determined by the chosen growth strategy. Assume that `vec: SplitVec<_, G>` where `G: Growth` contains one fragment of capacity `C`, which is also the capacity of the vector since it is the only fragment. Assume, we used up all capacity; i.e., `vec.len() == vec.capacity()` (`C`). If we attempt to push a new element, `SplitVec` will allocate the second fragment with the following capacity:

Expand All @@ -43,6 +43,17 @@ The capacity of the new fragment is determined by the chosen growth strategy. As

`C` is set on initialization as a power of two for `Linear` strategy, and it is fixed to 4 for `Doubling` strategy to allow for access time optimizations.

In addition there exists the `Recursive` growth strategy, which behaves as the `Doubling` strategy at the beginning. However, it allows for zero-cost `append` operation at the expense of a reduced random access time performance. Please see the <a href="#section-benchmarks">E. Benchmarks</a> section for tradeoffs and details. The summary is as follows:

* Use `std::vec::Vec<T>` :)
* Use `SplitVec<T, Doubling>` (or equivalently `SplitVec<T>`)
* when it is required to have pinned elements and we need close to standard vector serial and random access performance, or
* when the elements are large and we don't have good capacity estimates, so that we can benefit from split vector's no-copy growth
* `SplitVec<T, Linear>` may be preferred when we have a good idea on the chunk size of the growth to reduce impact of wasted capacity with doubling of `std::vec::Vec` or `Doubling`.
* Use `SplitVec<T, Recursive>`
* when it is required to have pinned elements and we need close to standard vector serial access performance while it is okay to have slower random access performance, or
* when `append`ing other vectors or split vectors is a frequent and important operation.

### C.2. Custom Growth Strategies

In order to define a custom growth strategy, one needs to implement the `Growth` trait. Implementation is straightforward. The trait contains two methods. The following method is required:
Expand Down Expand Up @@ -186,7 +197,7 @@ assert_eq!(unsafe { *addr42 }, 42);

## E. Benchmarks

Recall that the motivation of using a split vector is to get benefit of the pinned elements, rather than to be used in place of the standard vector which is highly efficient. The aim of the performance optimizations and benchmarks is to make sure that the gap is kept within acceptable and constant limits. `SplitVec` seems to comfortably satisfy this. After optimizations, built-in growth strategies appear to have a similar peformance to `std::vec::Vec` in growth, serial access and random access benchmarks.
Recall that the motivation of using a split vector is to get benefit of the pinned elements and to avoid standard vector's memory copies in very specific situations; rather than to be a replacement. The aim of performance optimizations and benchmarks is to make sure that performance of critical operations is kept within desired ranges. `SplitVec` seems to satisfy this. After optimizations, built-in growth strategies appear to have a similar peformance to `std::vec::Vec` in growth, serial access and random access benchmarks, and a better performance in append benchmarks.

*You may find the details of each benchmark in the following subsections. All the numbers in tables below represent duration in milliseconds.*

Expand All @@ -200,28 +211,47 @@ The benchmark compares the build up time of vectors by pushing elements one by o

The baseline **std_vec_with_capacity** performs between 1.5 and 2.0 times faster than **std_vec_new** which has no capacity information and requires copies while growing. As mentioned before, **`SplitVec`** growth is copy-free guaranteeing that pushed elements stay pinned. Therefore, it is expected to perform in between. However, it performs almost as well as, and sometimes faster than, std_vec_with_capacity.

*`Recursive` strategy is omitted here since it behaves exactly as the `Doubling` strategy. For its differences, please see random access and append benchmarks.*

### E.2. Random Access

*You may see the benchmark at [benches/random_access.rs](https://github.com/orxfun/orx-split-vec/blob/main/benches/random_access.rs).*

In this benchmark, we access vector elements by indices in a random order. Here the baseline is again the standard vector created by `Vec::with_capacity`, which is compared with `Linear` and `Doubling` growth strategies of the `SplitVec` which are optimized specifically for the random access.
In this benchmark, we access vector elements by indices in a random order. Here the baseline is again the standard vector created by `Vec::with_capacity`, which is compared with `Linear` and `Doubling` growth strategies of the `SplitVec` which are optimized specifically for the random access. Furthermore, `Recursive` growth strategy which does not provide constant time random access operation is included in the benchmarks.

Note that `Recursive` uses the `Growth` trait's default `get_fragment_and_inner_indices` implementation, and hence, reflects the expected random access performance of custom growth strategies without a specialized access method.

<img src="https://raw.githubusercontent.com/orxfun/orx-split-vec/main/docs/img/bench_random_access.PNG" alt="https://raw.githubusercontent.com/orxfun/orx-split-vec/main/docs/img/bench_random_access.PNG" />

We can see that `Linear` is slower than `Doubling`. The difference of performances between `SplitVec<_, Doubling>` (the default growth) is always less than 50% and approaches to zero as the element size or number of elements gets larger.
We can see that `Linear` is slower than `Doubling`. Random access performance of `Doubling` is at most 50% slower than the standard vector and the difference approaches to zero as the element size or number of elements gets larger.

`Recursive`, on the other hand, does not have constant time complexity for random access operation which can be observed in the table. It is between 4 and 7 times slower than the slower access for small elements and around 1.5 times slower for large structs. In order to make the tradeoffs clear and brief; `SplitVec<_, Recursive>` mainly differs from standard and split vector alternatives by random access performance (worse) and append performance (better).

### E.3. Serial Access

*You may see the benchmark at [benches/serial_access.rs](https://github.com/orxfun/orx-split-vec/blob/main/benches/serial_access.rs).*

Lastly, we benchmark the case where we access each element of the vector in order starting from the first element to the last. We use the same standard vector as the baseline. For completeness, baseline is compared with `Linear` and `Doubling` strategies; however, `SplitVec` actually uses the same iterator to allow for the serial access for any growth startegy.
Here, we benchmark the case where we access each element of the vector in order starting from the first element to the last. We use the same standard vector as the baseline. For completeness, baseline is compared with `Doubling`, `Linear` and `Recursive` growth strategies; however, `SplitVec` actually uses the same iterator to allow for the serial access for any growth startegy.

<img src="https://raw.githubusercontent.com/orxfun/orx-split-vec/main/docs/img/bench_serial_access.PNG" alt="https://raw.githubusercontent.com/orxfun/orx-split-vec/main/docs/img/bench_serial_access.PNG" />

The results show that there are minor deviations but no significant difference between the variants.

### E.4. Append

*You may see the benchmark at [benches/serial_access.rs](https://github.com/orxfun/orx-split-vec/blob/main/benches/append.rs).*

Appending vectors to vectors might be a critical operation in certain cases. One example is the recursive data structures such as trees or linked lists or vectors themselves. We might append a tree to another tree to get a new merged tree. This operation could be handled by copying data to keep a certain required structure or by simply accepting the incoming chunk (no-ops).

* `std::vec::Vec<_>`, `SplitVec<_, Doubling>` and `SplitVec<_, Linear>` do the prior one in order to keep their internal structure which allows for efficient random access.
* `SplitVec<_, Recursive>`, on the other hand, utilizes its fragmented structure and follows the latter approach. Hence, appending another vector to it has no cost, simply no-ops. This does not degrade serial access performance. However, it leads to slower random access. Please refer to the corresponding benchmarks above.

<img src="https://raw.githubusercontent.com/orxfun/orx-split-vec/main/docs/img/bench_append.PNG" alt="https://raw.githubusercontent.com/orxfun/orx-split-vec/main/docs/img/bench_append.PNG" />

You may see that `SplitVec<T, Doubling>` (equivalently `SplitVec<T>` using the default) is around twice faster than `std::vec::Vec` when we don't have any prior information about the required capacity. When we have perfect information and create our vector with `std::vec::Vec::with_capacity` providing the exact required capacity, `std::vec::Vec` and `SplitVec` perform equivalently. This makes `SplitVec` a preferrable option.

`SplitVec<T, Recursive>` on the other hand is a different story allowing zero-cost appends which is independent of size of the data being appended.

## F. Relation to the `ImpVec`

Providing pinned memory location elements with `PinnedVec` is the first block for building self referential structures; the second building block is the [`ImpVec`](https://crates.io/crates/orx-imp-vec). An `ImpVec` wraps any `PinnedVec` implementation and provides specialized methods built on the pinned element guarantee in order to allow building self referential collections.
Expand Down
142 changes: 142 additions & 0 deletions benches/append.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use criterion::{black_box, criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
use orx_split_vec::prelude::*;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;

const NUM_APPEND_OPS: usize = 32;

struct Vectors(Vec<Vec<usize>>);
impl Clone for Vectors {
fn clone(&self) -> Self {
let vecs = (0..self.0.len()).map(|i| self.0[i].clone()).collect();
Self(vecs)
}
}

fn get_vectors(n: usize) -> Vectors {
let mut rng = ChaCha8Rng::seed_from_u64(685412);
Vectors(
(0..NUM_APPEND_OPS)
.map(|_| (0..n).map(|_| rng.gen_range(0..n)).collect())
.collect(),
)
}

fn calc_std_vec(mut vec: Vec<usize>, mut vectors: Vectors) -> Vec<usize> {
for x in &mut vectors.0 {
vec.append(x);
}
vec
}

fn calc_split_vec_extend<G: Growth>(
mut vec: SplitVec<usize, G>,
mut vectors: Vectors,
) -> SplitVec<usize, G> {
for x in &mut vectors.0 {
vec.extend_from_slice(&x);
x.clear();
}
vec
}

fn calc_split_vec_append(
mut vec: SplitVec<usize, Recursive>,
vectors: Vectors,
) -> SplitVec<usize, Recursive> {
let vectors = vectors.0;
for x in vectors {
vec.append(x)
}
vec
}

fn bench(c: &mut Criterion) {
let treatments = vec![1_024, 16_384, 262_144, 4_194_304];

let mut group = c.benchmark_group("append");

for n in treatments {
let treatment = format!("n={}]", n);
let vectors = get_vectors(n);

group.bench_with_input(BenchmarkId::new("std_vec_new", &treatment), &n, |b, _| {
b.iter_batched(
|| get_vectors(n),
|vectors| calc_std_vec(black_box(Vec::new()), black_box(vectors)),
BatchSize::LargeInput,
)
});

group.bench_with_input(
BenchmarkId::new("std_vec_with_exact_capacity", &treatment),
&n,
|b, _| {
let capacity: usize = vectors.0.iter().map(|x| x.len()).sum();
b.iter_batched(
|| get_vectors(n),
|vectors| {
calc_std_vec(black_box(Vec::with_capacity(capacity)), black_box(vectors))
},
BatchSize::LargeInput,
)
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_linear - 2^10", &treatment),
&n,
|b, _| {
b.iter_batched(
|| get_vectors(n),
|vectors| {
calc_split_vec_extend(
black_box(SplitVec::with_linear_growth(10)),
black_box(vectors),
)
},
BatchSize::LargeInput,
)
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_doubling", &treatment),
&n,
|b, _| {
b.iter_batched(
|| get_vectors(n),
|vectors| {
calc_split_vec_extend(
black_box(SplitVec::with_doubling_growth()),
black_box(vectors),
)
},
BatchSize::LargeInput,
)
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_recursive", &treatment),
&n,
|b, _| {
b.iter_batched(
|| get_vectors(n),
|vectors| {
calc_split_vec_append(
black_box(SplitVec::with_recursive_growth()),
black_box(vectors),
)
},
BatchSize::LargeInput,
)
},
);
}

group.finish();
}

criterion_group!(benches, bench);
criterion_main!(benches);
11 changes: 10 additions & 1 deletion benches/random_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ fn test_for_type<T: Default>(
b.iter(|| calc_split_vec(add, &vec, &indices))
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_recursive", &treatment),
n,
|b, _| {
let vec: SplitVec<_, Recursive> = split_vec_doubling(black_box(*n), value).into();
b.iter(|| calc_split_vec(add, &vec, &indices))
},
);
}
}

Expand All @@ -118,7 +127,7 @@ fn bench(c: &mut Criterion) {

let mut group = c.benchmark_group("random_access");

const N: usize = 1;
const N: usize = 16;
test_for_type::<[u64; N]>(&mut group, N, &treatments, get_value, add);

group.finish();
Expand Down
Binary file modified benches/results/grow.xlsx
Binary file not shown.
19 changes: 19 additions & 0 deletions benches/serial_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ fn test_for_type<T: Default>(
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_recursive", &treatment),
n,
|b, _| {
let vec: SplitVec<_, Recursive> = split_vec_doubling(black_box(*n), value).into();
b.iter(|| calc_split_vec(add, &vec))
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_linear - 2^10 - iter_mut", &treatment),
n,
Expand All @@ -123,6 +132,16 @@ fn test_for_type<T: Default>(
b.iter(|| calc_split_vec_itermut(add, &mut vec))
},
);

group.bench_with_input(
BenchmarkId::new("split_vec_recursive - iter_mut", &treatment),
n,
|b, _| {
let mut vec: SplitVec<_, Recursive> =
split_vec_doubling(black_box(*n), value).into();
b.iter(|| calc_split_vec_itermut(add, &mut vec))
},
);
}
}

Expand Down
Binary file added docs/img/bench_append.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/bench_random_access.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/bench_serial_access.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions src/fragment/into_fragments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use crate::{Fragment, Growth, SplitVec};

/// Converts self into a collection of [`Fragment`]s.
pub trait IntoFragments<T> {
/// Converts self into a collection of [`Fragment`]s.
fn into_fragments(self) -> impl Iterator<Item = Fragment<T>>;
}

impl<T> IntoFragments<T> for Vec<T> {
fn into_fragments(self) -> impl Iterator<Item = Fragment<T>> {
[Fragment::from(self)].into_iter()
}
}

impl<T, const N: usize> IntoFragments<T> for [Vec<T>; N] {
fn into_fragments(self) -> impl Iterator<Item = Fragment<T>> {
self.into_iter().map(Fragment::from)
}
}

impl<T> IntoFragments<T> for Vec<Vec<T>> {
fn into_fragments(self) -> impl Iterator<Item = Fragment<T>> {
self.into_iter().map(Fragment::from)
}
}

impl<T, G: Growth> IntoFragments<T> for SplitVec<T, G> {
fn into_fragments(self) -> impl Iterator<Item = Fragment<T>> {
self.fragments.into_iter()
}
}
1 change: 1 addition & 0 deletions src/fragment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ mod deref;
mod eq;
pub(crate) mod fragment_struct;
mod from;
pub(crate) mod into_fragments;
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{Fragment, SplitVec};
/// ```
/// use orx_split_vec::prelude::*;
///
/// // SplitVec<usize, DoublingGrowth>
/// // SplitVec<usize, Doubling>
/// let mut vec = SplitVec::with_doubling_growth();
///
/// assert_eq!(1, vec.fragments().len());
Expand Down
4 changes: 2 additions & 2 deletions src/growth/doubling/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
mod constants;
mod doubling_growth;
mod from;
mod impl_growth;

#[cfg(test)]
mod tests;

pub use impl_growth::Doubling;
pub use doubling_growth::Doubling;
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::{Fragment, SplitVec};
/// ```
/// use orx_split_vec::prelude::*;
///
/// // SplitVec<usize, LinearGrowth>
/// // SplitVec<usize, Linear>
/// let mut vec = SplitVec::with_linear_growth(4);
///
/// assert_eq!(1, vec.fragments().len());
Expand Down
4 changes: 2 additions & 2 deletions src/growth/linear/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
mod constants;
mod from;
mod impl_growth;
mod linear_growth;

#[cfg(test)]
mod tests;

pub use impl_growth::Linear;
pub use linear_growth::Linear;
Loading

0 comments on commit 8ac7dcc

Please sign in to comment.