Skip to content

Commit

Permalink
Use NonMaxUsize for non-component SparseSets (bevyengine#12083)
Browse files Browse the repository at this point in the history
# Objective
Adoption of bevyengine#2104 and bevyengine#11843. The `Option<usize>` wastes 3-7 bytes of
memory per potential entry, and represents a scaling memory overhead as
the ID space grows.

The goal of this PR is to reduce memory usage without significantly
impacting common use cases.

Co-Authored By: @NathanSWard 
Co-Authored By: @tygyh 

## Solution
Replace `usize` in `SparseSet`'s sparse array with
`nonmax::NonMaxUsize`. NonMaxUsize wraps a NonZeroUsize, and applies a
bitwise NOT to the value when accessing it. This allows the compiler to
niche the value and eliminate the extra padding used for the `Option`
inside the sparse array, while moving the niche value from 0 to
usize::MAX instead.

Checking the [diff in x86 generated
assembly](james7132/bevy_asm_tests@6e4da65),
this change actually results in fewer instructions generated. One
potential downside is that it seems to have moved a load before a
branch, which means we may be incurring a cache miss even if the element
is not there.

Note: unlike bevyengine#2104 and bevyengine#11843, this PR only targets the metadata stores
for the ECS and not the component storage itself. Due to bevyengine#9907 targeting
`Entity::generation` instead of `Entity::index`, `ComponentSparseSet`
storing only up to `u32::MAX` elements would become a correctness issue.

This will come with a cost when inserting items into the SparseSet, as
now there is a potential for a panic. These cost are really only
incurred when constructing a new Table, Archetype, or Resource that has
never been seen before by the World. All operations that are fairly cold
and not on any particular hotpath, even for command application.

---

## Changelog
Changed: `SparseSet` now can only store up to `usize::MAX - 1` elements
instead of `usize::MAX`.
Changed: `SparseSet` now uses 33-50% less memory overhead per stored
item.
  • Loading branch information
james7132 authored and spectria-limina committed Mar 9, 2024
1 parent 904c348 commit 85f0124
Show file tree
Hide file tree
Showing 2 changed files with 17 additions and 12 deletions.
1 change: 1 addition & 0 deletions crates/bevy_ecs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fixedbitset = "0.4.2"
rustc-hash = "1.1"
serde = "1"
thiserror = "1.0"
nonmax = "0.5"

[dev-dependencies]
rand = "0.8"
Expand Down
28 changes: 16 additions & 12 deletions crates/bevy_ecs/src/storage/sparse_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::{
storage::{Column, TableRow},
};
use bevy_ptr::{OwningPtr, Ptr};
use nonmax::NonMaxUsize;
use std::{cell::UnsafeCell, hash::Hash, marker::PhantomData};

type EntityIndex = u32;
Expand Down Expand Up @@ -335,7 +336,7 @@ impl ComponentSparseSet {
pub struct SparseSet<I, V: 'static> {
dense: Vec<V>,
indices: Vec<I>,
sparse: SparseArray<I, usize>,
sparse: SparseArray<I, NonMaxUsize>,
}

/// A space-optimized version of [`SparseSet`] that cannot be changed
Expand All @@ -344,7 +345,7 @@ pub struct SparseSet<I, V: 'static> {
pub(crate) struct ImmutableSparseSet<I, V: 'static> {
dense: Box<[V]>,
indices: Box<[I]>,
sparse: ImmutableSparseArray<I, usize>,
sparse: ImmutableSparseArray<I, NonMaxUsize>,
}

macro_rules! impl_sparse_set {
Expand All @@ -368,7 +369,7 @@ macro_rules! impl_sparse_set {
pub fn get(&self, index: I) -> Option<&V> {
self.sparse.get(index).map(|dense_index| {
// SAFETY: if the sparse index points to something in the dense vec, it exists
unsafe { self.dense.get_unchecked(*dense_index) }
unsafe { self.dense.get_unchecked(dense_index.get()) }
})
}

Expand All @@ -379,7 +380,7 @@ macro_rules! impl_sparse_set {
let dense = &mut self.dense;
self.sparse.get(index).map(move |dense_index| {
// SAFETY: if the sparse index points to something in the dense vec, it exists
unsafe { dense.get_unchecked_mut(*dense_index) }
unsafe { dense.get_unchecked_mut(dense_index.get()) }
})
}

Expand Down Expand Up @@ -454,10 +455,11 @@ impl<I: SparseSetIndex, V> SparseSet<I, V> {
if let Some(dense_index) = self.sparse.get(index.clone()).cloned() {
// SAFETY: dense indices stored in self.sparse always exist
unsafe {
*self.dense.get_unchecked_mut(dense_index) = value;
*self.dense.get_unchecked_mut(dense_index.get()) = value;
}
} else {
self.sparse.insert(index.clone(), self.dense.len());
self.sparse
.insert(index.clone(), NonMaxUsize::new(self.dense.len()).unwrap());
self.indices.push(index);
self.dense.push(value);
}
Expand All @@ -468,11 +470,12 @@ impl<I: SparseSetIndex, V> SparseSet<I, V> {
pub fn get_or_insert_with(&mut self, index: I, func: impl FnOnce() -> V) -> &mut V {
if let Some(dense_index) = self.sparse.get(index.clone()).cloned() {
// SAFETY: dense indices stored in self.sparse always exist
unsafe { self.dense.get_unchecked_mut(dense_index) }
unsafe { self.dense.get_unchecked_mut(dense_index.get()) }
} else {
let value = func();
let dense_index = self.dense.len();
self.sparse.insert(index.clone(), dense_index);
self.sparse
.insert(index.clone(), NonMaxUsize::new(dense_index).unwrap());
self.indices.push(index);
self.dense.push(value);
// SAFETY: dense index was just populated above
Expand All @@ -491,11 +494,12 @@ impl<I: SparseSetIndex, V> SparseSet<I, V> {
/// Returns `None` if `index` does not have a value in the sparse set.
pub fn remove(&mut self, index: I) -> Option<V> {
self.sparse.remove(index).map(|dense_index| {
let is_last = dense_index == self.dense.len() - 1;
let value = self.dense.swap_remove(dense_index);
self.indices.swap_remove(dense_index);
let index = dense_index.get();
let is_last = index == self.dense.len() - 1;
let value = self.dense.swap_remove(index);
self.indices.swap_remove(index);
if !is_last {
let swapped_index = self.indices[dense_index].clone();
let swapped_index = self.indices[index].clone();
*self.sparse.get_mut(swapped_index).unwrap() = dense_index;
}
value
Expand Down

0 comments on commit 85f0124

Please sign in to comment.