#fixed-size #optional #memory #block #array

no-std option-block

A minimal utility Rust crate for small, fixed-size blocks of optional types

4 releases

0.3.0 Jul 21, 2022
0.2.2 Jul 2, 2022
0.2.1 Jul 1, 2022
0.2.0 Jul 1, 2022
0.1.0 Jul 1, 2022

#539 in Embedded development

Download history 75/week @ 2024-06-17 55/week @ 2024-06-24 8/week @ 2024-07-01 65/week @ 2024-07-08 73/week @ 2024-07-15 87/week @ 2024-07-22 52/week @ 2024-07-29 55/week @ 2024-08-05 79/week @ 2024-08-12 49/week @ 2024-08-19 102/week @ 2024-08-26 135/week @ 2024-09-02 67/week @ 2024-09-09 61/week @ 2024-09-16 89/week @ 2024-09-23 71/week @ 2024-09-30

307 downloads per month
Used in 2 crates (via usbd-human-interface-devi…)

MIT license

25KB
295 lines

A Block of Optionals!

The option-block crate provides a simple primitive for fixed-size blocks of optional types. Formally speaking, it's a direct-address table with a fixed-size array as the storage medium.

Importantly, this is not to be confused with the popular slab crate, which internally uses the dynamically-sized, heap-allocated Vec. Although both crates provide indexed accesses and map-like features, option-block operates at a lower level.

Specifically, option-block does not keep track of the next empty slot in the allocation upon insertion (unlike slab). Instead, option-block is simply a wrapper around an array and a bit mask. The array contains the (maybe uninitialized) data while the bit mask keeps track of the valid (i.e. initialized) entries in the allocation. Again, it's basically a direct-address table.

This crate is compatible with no_std environments! Neither std nor alloc is necessary.

Example

let mut block = option_block::Block8::<u8>::default();

assert!(block.is_empty());

assert!(block.insert(0, 10).is_none());
assert!(block.insert(1, 20).is_none());

assert_eq!(block.insert(0, 100), Some(10));
assert_eq!(block.insert(1, 200), Some(20));

assert_eq!(block.get(0), Some(&100));
assert_eq!(block.get(1), Some(&200));
assert_eq!(block.remove(0), Some(100));
assert_eq!(block.remove(1), Some(200));

assert!(block.is_empty());

assert_eq!(block.get(0), None);
assert_eq!(block.get(1), None);
assert_eq!(block.remove(0), None);
assert_eq!(block.remove(1), None);

Motivation

The Nullable Pointer Optimization

Sometimes, a direct-address table with a fixed-size allocation on the stack is sufficient for simple look-ups. That is, a heap-allocated HashMap and Vec may be overkill. Intuitively, one may be inclined to implement such a table using an array of Option<T> (for some type T). This is not ideal, however, because for most types, the size of an Option<T> (in bytes) is unnecessarily large.

Certain types in Rust take advantage of the nullable pointer optimization. For some enum types (like Option), the compiler can do clever tricks to minimize its memory footprint. For instance, consider an Option<&T>. Assuming a 64-bit target without the nullable pointer optimization enabled, the compiler may naively allocate 16 bytes for a single Option<&T>: 8 bytes for the reference (i.e. the actual pointer) plus 8 bytes for the enum discriminant. This is indeed rather wasteful.

To resolve these issues, recall that all references in Rust are never null. The compiler can take advantage of this fact by assigning the None::<&T> variant to be the actual null pointer instead. Hence, we say that &T is None if the reference is null; otherwise, it is the Some variant (which has a valid reference). The enum discriminant is thus no longer necessary. An Option<&T> is now just 8 bytes!

The Rustonomicon discusses more examples that enable the optimization. The point is: some types have properties and assumptions that allow the compiler to forego some size overhead. But what if this size optimization cannot happen?

Double the Memory Footprint

Consider an Option<u64>. The core::mem::size_of function tells us that a single Option<u64> takes up 16 bytes of memory! The first 8 bytes belong to the u64 itself while the other 8 bytes belong to the enum discriminant. Again, this is rather wasteful.

To resolve the enum discriminant overhead, the standard library provides the core::num::NonZeroU64 type. The NonZeroU64 is a zero-cost wrapper for u64 that is assumed to be non-zero (as its name suggests).

This assumption makes NonZeroU64 eligible for the nullable pointer optimization. That is, an Option<NonZeroU64> is None if it contains 0; otherwise, it is the Some variant (which has a valid non-zero value). We may thus remove the overhead since the value already implicitly encodes the discriminant. An Option<NonZeroU64> is now just 8 bytes!

use core::{mem::size_of, num::NonZeroU64};
assert_eq!(size_of::<Option<u64>>(), 16);
assert_eq!(size_of::<Option<NonZeroU64>>(), 8);

For this reason, a direct-address table which internally uses an array of Option<T> values will inevitably consume more memory than necessary. Unless the inner type is conveniently eligible for the nullable pointer optimization, the enum discriminant overhead will (at most) double the memory footprint.

A New Crate is Born!

However, not all hope is lost. Observe that the discriminant for the Option type may actually be stored as a single bit. Therefore, it is possible to store multiple discriminants (for an array of optional values) in a single bit mask. This is exactly the abstraction that the option-block crate provides.

This crate provides five primitives: Block8, Block16, Block32, Block64, and Block128. As its name suggests, a Block8 is a block of at most 8 optional values, where the internal bit mask is a u8 (one for each cell). The rest of the primitives are basically the 16-, 32-, 64-, and 128-element analogs of the Block8.

use core::mem::size_of;
use option_block::Block16;

assert_eq!(size_of::<[Option<u16>; 16]>(), 64);
assert_eq!(size_of::<Block16<u16>>(), 34);

Implementation Details

Further internal details are explained in narrative format in a supplementary article titled "Dipping Toes into Unsafe Code".

Stack Limitations

Since option-block allocates on the stack, one must handle the Block64 and Block128 types with care. In the extreme case of the Block128 type, it allocates 128 instances of the inner data type plus 16 more bytes for the bit mask. Stack memory usage can easily skyrocket if too many are created. Thus, it is advised to use the larger block variants sparingly.

No runtime deps