Skip to content

FAQ initial revision #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions array.h
Original file line number Diff line number Diff line change
Expand Up @@ -2028,7 +2028,9 @@ using dense_array_ref = array_ref<T, dense_shape<Rank>>;
template <class T, size_t Rank>
using const_dense_array_ref = dense_array_ref<const T, Rank>;

/** A multi-dimensional array container that owns an allocation of memory. */
/** A multi-dimensional array container that owns an allocation of memory. `Alloc` is
* an allocator that can be an STL allocator type such as `std::allocator`. However,
* unlike STL allocators, it may be stateful. */
template <class T, class Shape, class Alloc = std::allocator<T>>
class array {
public:
Expand Down Expand Up @@ -2410,9 +2412,9 @@ class array {
* by `offset`. This function is disabled for non-trivial types, because it
* does not call the destructor or constructor for newly inaccessible or newly
* accessible elements, respectively. */
void set_shape(const Shape& new_shape, index_t offset = 0) {
void set_shape(Shape new_shape, index_t offset = 0) {
static_assert(std::is_trivial<value_type>::value, "set_shape is broken for non-trivial types.");
assert(new_shape.is_resolved());
new_shape.resolve();
assert(new_shape.is_subset_of(shape_, -offset));
shape_ = new_shape;
base_ = internal::pointer_add(base_, offset);
Expand Down Expand Up @@ -2737,7 +2739,8 @@ const_array_ref<U, Shape> reinterpret(const array<T, Shape, Alloc>& a) {
* `new_shape`, with a base pointer offset `offset`. */
template <class NewShape, class T, class OldShape>
NDARRAY_HOST_DEVICE array_ref<T, NewShape> reinterpret_shape(
const array_ref<T, OldShape>& a, const NewShape& new_shape, index_t offset = 0) {
const array_ref<T, OldShape>& a, NewShape new_shape, index_t offset = 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some places, array_ref and shape is passed by const ref, elsewhere, by value. What's the recommendation?

I think small things should always be passed by value in modern C++?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree passing these by value is quite reasonable. That said, they aren't that small if the shape has a few dimensions (each dim has the same size as 3 pointers). I changed this one to be by value because the implementation needs to mutate it, so it would be making a copy anyways.

In this case it doesn't really matter, but for non-trivial types, this is a lot better (enables move construction if the caller wants that, instead of construct + copy).

new_shape.resolve();
assert(new_shape.is_subset_of(a.shape(), -offset));
return array_ref<T, NewShape>(a.base() + offset, new_shape);
}
Expand Down Expand Up @@ -2814,10 +2817,13 @@ auto reorder(const array<T, OldShape, Allocator>& a) {
return reinterpret_shape(a, reorder<DimIndices...>(a.shape()));
}

/** Allocator satisfying the `std::allocator` interface that owns a buffer with
* automatic storage, and a fallback base allocator. For allocations, the
* allocator uses the buffer if it is large enough and not already allocated,
* otherwise it uses the base allocator. */
/** Allocator for use with `array` that owns a buffer with automatic storage,
* and a fallback base allocator. When allocating memory, this allocator uses
* the buffer if it is large enough and not already allocated, otherwise it
* uses the base allocator.
*
* While this allocator appears to be compatible with `std::allocator`, it is
* not safe to use with STL containers. */
template <class T, size_t N, size_t Alignment = alignof(T), class BaseAlloc = std::allocator<T>>
class auto_allocator {
alignas(Alignment) char buffer[N * sizeof(T)];
Expand Down
185 changes: 185 additions & 0 deletions test/faq.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#include "array.h"
#include "test.h"

namespace nda {

TEST(faq_c_array) {
// Q: How do I declare an `array` with the same memory layout as a C
// multidimensional array?

// A: In C, the last dimension is the "innermost" dimension, the dimension
// with the smallest stride. In `array`, the first dimension is the innermost
// dimension, i.e. Fortran ordering.

// To demonstrate this, we can construct a 3-dimensional array in C,
// where each element of the array is equal to its indices:
constexpr int width = 5;
constexpr int height = 4;
constexpr int depth = 3;
std::tuple<int, int, int> c_array[depth][height][width];
for (int z = 0; z < depth; z++) {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
c_array[z][y][x] = std::make_tuple(x, y, z);
}
}
}

// Now we can create an array_ref of this c_array, and check that
// the coordinates map to the same location, indicating that we used
// the same convention to determine strides as the C compiler:
dense_array_ref<std::tuple<int, int, int>, 3> c_array_ref(&c_array[0][0][0], {width, height, depth});
for_each_index(c_array_ref.shape(), [&](std::tuple<int, int, int> i) {
ASSERT_EQ(c_array_ref[i], i);
});
}

TEST(faq_reshape) {
// Q: How do I resize or change the shape of an already constructed array?

// A: There are several options, with different behaviors. First, we can use
// `array::reshape`, which changes the shape of an array while moving the
// elements of the intersection of the old shape and new shape to the new
// array, similar to `std::vector::resize`:
dense_array<int, 2> a({3, 4});
for_all_indices(a.shape(), [&](int x, int y) {
a(x, y) = y * 3 + x;
});
for (auto y : a.y()) {
for (auto x : a.x()) {
std::cout << a(x, y) << " ";
}
std::cout << std::endl;
}
std::cout << std::endl;
// Output:
// 0 1 2
// 3 4 5
// 6 7 8
// 9 10 11

a.reshape({2, 6});
for (auto y : a.y()) {
for (auto x : a.x()) {
std::cout << a(x, y) << " ";
}
std::cout << std::endl;
}
std::cout << std::endl;
// Output:
// 0 1
// 3 4
// 6 7
// 9 10
// 0 0
// 0 0
// Observe that the right column of the original array has been lost, and
// two default-constructed rows have been added to the bottom of the array.

// A: We can also reinterpret the shape of an existing array using
// `array::set_shape`:
a.set_shape({4, 3});
for (auto y : a.y()) {
for (auto x : a.x()) {
std::cout << a(x, y) << " ";
}
std::cout << std::endl;
}
std::cout << std::endl;
// Output:
// 0 1 3 4
// 6 7 9 10
// 0 0 0 0
// Observe that this has not removed or added any values from the array, the
// underlying memory has simply be reinterpreted as a different array.

// A: We can also use `reinterpret_shape` to make a new `array_ref` with the
// new shape:
auto a_reshaped = reinterpret_shape(a, dense_shape<2>{4, 3});
ASSERT(a_reshaped == a);

// Q: `array`'s move constructor requires the source array to have the same
// shape type. How do I move ownership to an array of a different shape type?

// A: The helper function `move_reinterpret_shape` combines move construction
// with `reinterpret_shape`:
array_of_rank<int, 3> source({3, 4, 5});
dense_array<int, 3> dest = move_reinterpret_shape<dense_shape<3>>(std::move(source));

// This can fail at runtime if the source shape is not compatible with the
// destination shape.
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a few more FAQs on what shape::resolve() does, and what shapes or array_refs can be passed to functions with automatic conversion?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, can you share a case in which you actually needed to care about resolve? I think it's not something that normally you should have to think about.

TEST(faq_crop) {
// Q: Cropping in this library is weird. How do I crop an array the way
// (my favorite library) does it?

// A: After cropping, the resulting array will have a min corresponding
// to the cropped region:
dense_array<int, 1> array({100});
for (int i = 0; i < array.size(); i++) {
array(i) = i;
}
const int crop_begin = 25;
const int crop_end = 50;
auto cropped = array(r(crop_begin, crop_end));
for (int i = crop_begin; i < crop_end; i++) {
ASSERT_EQ(array(i), cropped(i));
}

// This differs from most alternative libraries. To match this behavior,
// the `min` of the resulting cropped array needs to be changed to 0:
cropped.shape().dim<0>().set_min(0);
for (int i = crop_begin; i < crop_end; i++) {
ASSERT_EQ(array(i), cropped(i - crop_begin));
}

// The reason array works this way is to enable transparent tiling
// optimizations of algorithms.
}

TEST(faq_stack_allocation) {
// Q: How do I allocate an array on the stack?

// A: Use `auto_allocator<>` as the allocator for your arrays.

// Define an allocator that has storage for up to 100 elements.
using StackAllocator = auto_allocator<int, 100>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, it took me a few minutes to wrap my head around this. std::allocator is stateless, but auto_allocator is not - it owns a buffer. I was trying to figure out where it's allocated - only to remember that it's a template argument - so the instance actually owns the allocator instance!

Not sure how to point this out (or if that's necessary. If you understand allocators then it's not a problem, and the documentation assumes you're pretty good at C++ :)).

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is something that needs more documentation, in the array.h header actually. The docs on these allocators should explain they can't actually be used with STL containers, because they're stateful.

It's actually really annoying to me that STL allocators are stateful because they preclude exactly this use case, which is very powerful. Replicating absl::InlinedVector goes from just a simple allocator like this one to basically a total rewrite of std::vector :(

Copy link
Owner Author

@dsharlet dsharlet Mar 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually even more annoying than I remembered: STL allocators have all this machinery that looks like it attempts to enable stateful allocators, like these flags such as propagate_on_container_assignment and select_on_container_copy_construction. But no STL I tested respects those flags, argh! This is issue #7

ASSERT(sizeof(StackAllocator) > sizeof(int) * 100);
dense_array<int, 3, StackAllocator> stack_array({2, 5, 10});
// Check that the data in the array is the same as the address of the
// array itself.
ASSERT_EQ(stack_array.base(), reinterpret_cast<int*>(&stack_array));

// Q: My array still isn't being allocated on the stack! Why not?

// A: If the array is too big to fit in the allocator, it will use the
// `BaseAlloc` of `auto_allocator`, which is `std::allocator` by default:
dense_array<int, 3, StackAllocator> not_stack_array({3, 5, 10});
ASSERT(not_stack_array.base() != reinterpret_cast<int*>(&not_stack_array));
}

TEST(faq_no_initialization) {
// Q: When I declare an `array`, the memory is being initialized. I'd rather
// not incur the cost of initialization, how can I avoid this?

// A: Use `uninitialized_std_allocator<>` as the allocator for your arrays.
using UninitializedAllocator = uninitialized_std_allocator<int>;
dense_array<int, 3, UninitializedAllocator> uninitialized_array({2, 5, 10});
}

} // namespace nda