Skip to content
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

[Merged by Bors] - Speed up CommandQueue by storing commands more densely #6391

Closed
wants to merge 52 commits into from
Closed
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
24b2748
Store commands contiguously
JoJoJet Oct 27, 2022
c1df95d
use `read_unaligned`
JoJoJet Oct 27, 2022
b71240c
add some safety comments
JoJoJet Oct 27, 2022
648cc38
optimize some repeated additions
JoJoJet Oct 27, 2022
b60f326
use ptr methods
JoJoJet Oct 27, 2022
70a984e
Optimize for zero-sized types
JoJoJet Oct 27, 2022
6b488fb
add safety comments to buffer writes
JoJoJet Oct 27, 2022
81a6d45
Add safety comments for pointer offsets
JoJoJet Oct 27, 2022
911b0fa
fix a word
JoJoJet Oct 27, 2022
c41cf9e
add a semicolon
JoJoJet Oct 27, 2022
f2bf50a
use a pointer write instead of copy_nonoverlapping
JoJoJet Oct 27, 2022
2274cde
finish a comment
JoJoJet Oct 27, 2022
c5ef708
use more pointer writes
JoJoJet Oct 27, 2022
5939090
inline a const
JoJoJet Oct 27, 2022
ed519de
import mem
JoJoJet Oct 27, 2022
c0e8694
Add a line break
JoJoJet Oct 27, 2022
6ef733e
move `set_len` closer to buffer writes
JoJoJet Oct 27, 2022
ca0b703
deduplicate pointer additions
JoJoJet Oct 27, 2022
f91f592
make sure some consts can get folded
JoJoJet Oct 27, 2022
0b856d9
clarify a comment
JoJoJet Oct 27, 2022
5a16594
Rename `cursor` -> `ptr`
JoJoJet Oct 27, 2022
435b28e
update an outdated comment
JoJoJet Oct 27, 2022
739c45b
return the size of a command from the function pointer
JoJoJet Oct 27, 2022
601025a
make a function name more explicit
JoJoJet Oct 27, 2022
ce4d864
refactor safety invariants for fn pointers
JoJoJet Oct 27, 2022
a24bdcc
Add another safety comment
JoJoJet Oct 27, 2022
64b1177
Improve a comment
JoJoJet Oct 27, 2022
7c7f775
tweak some comments
JoJoJet Oct 28, 2022
494240a
fix another outdated comment
JoJoJet Oct 28, 2022
f94d2d3
describe the format more naturally
JoJoJet Nov 10, 2022
92e9bf5
Improve docs for `CommandQueue`
JoJoJet Nov 10, 2022
3d24081
improve safety comments
JoJoJet Nov 10, 2022
16ceb57
be more explicit about ZSTs
JoJoJet Nov 10, 2022
7706eae
Update some old comments
JoJoJet Nov 10, 2022
daa6614
add `OwningPtr::read_unaligned`
JoJoJet Nov 10, 2022
453c6df
use owning pointers for commands
JoJoJet Nov 10, 2022
459eaa7
nitpick safety comments
JoJoJet Dec 2, 2022
f469f05
Merge remote-tracking branch 'upstream/main' into command-opt
JoJoJet Jan 19, 2023
1fecbae
Merge remote-tracking branch 'upstream/main' into command-opt
JoJoJet Jan 19, 2023
144eb7e
remove a duplicate fn
JoJoJet Jan 19, 2023
8e288e9
remove a turbofish
JoJoJet Jan 19, 2023
040e85d
reduce churn
JoJoJet Jan 19, 2023
f7a5b7a
reduce more comment churn
JoJoJet Jan 19, 2023
41c6183
copy another comment from upstream
JoJoJet Jan 19, 2023
4080b5a
use a packed struct to simplify writing
JoJoJet Jan 19, 2023
7ca7a54
tweak language for a comment
JoJoJet Jan 19, 2023
febaf23
Merge remote-tracking branch 'upstream/main' into command-opt
JoJoJet Jan 26, 2023
bc1741c
rename `WithMeta` -> `Packed`
JoJoJet Jan 26, 2023
9791ea6
use turbofish
JoJoJet Jan 27, 2023
d6f2e5c
add a comment to `Packed`
JoJoJet Jan 27, 2023
3142fe8
`C` -> `packed`
JoJoJet Jan 28, 2023
9deaee0
make a safety comment more rigorous
JoJoJet Jan 28, 2023
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
132 changes: 78 additions & 54 deletions crates/bevy_ecs/src/system/commands/command_queue.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
use std::{mem::MaybeUninit, ptr::NonNull};
use std::mem::MaybeUninit;

use bevy_ptr::{OwningPtr, Unaligned};

use super::Command;
use crate::world::World;

struct CommandMeta {
/// Offset from the start of `CommandQueue.bytes` at which the corresponding command is stored.
offset: usize,
/// SAFETY: The `value` must point to a value of type `T: Command`,
/// where `T` is some specific type that was used to produce this metadata.
apply_command: unsafe fn(value: OwningPtr<Unaligned>, world: &mut World),
///
/// Returns the size of `T` in bytes.
apply_command_and_get_size: unsafe fn(value: OwningPtr<Unaligned>, world: &mut World) -> usize,
}

/// A queue of [`Command`]s
/// Densely and efficiently stores a queue of heterogenous types implementing [`Command`].
//
// NOTE: [`CommandQueue`] is implemented via a `Vec<MaybeUninit<u8>>` instead of a `Vec<Box<dyn Command>>`
// as an optimization. Since commands are used frequently in systems as a way to spawn
Expand All @@ -22,12 +22,12 @@ struct CommandMeta {
// preferred to simplicity of implementation.
#[derive(Default)]
pub struct CommandQueue {
/// Densely stores the data for all commands in the queue.
// This buffer densely stores all queued commands.
//
// For each command, one `CommandMeta` is stored, followed by zero or more bytes
// to store the command itself. To interpret these bytes, a pointer must
// be passed to the corresponding `CommandMeta.apply_command_and_get_size` fn pointer.
bytes: Vec<MaybeUninit<u8>>,
/// Metadata for each command stored in the queue.
/// SAFETY: Each entry must have a corresponding value stored in `bytes`,
/// stored at offset `CommandMeta.offset` and with an underlying type matching `CommandMeta.apply_command`.
metas: Vec<CommandMeta>,
}

// SAFETY: All commands [`Command`] implement [`Send`]
Expand All @@ -43,45 +43,50 @@ impl CommandQueue {
where
C: Command,
{
let old_len = self.bytes.len();
// Stores a command alongside its metadata.
// `repr(C)` prevents the compiler from reordering the fields,
// while `repr(packed)` prevents the compiler from inserting padding bytes.
#[repr(C, packed)]
Copy link
Member

@james7132 james7132 Jan 27, 2023

Choose a reason for hiding this comment

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

This needs a comment as to why we need repr(packed) here. Preferably with a link to the appropriate Rustinomicon page. Probably also a safety comment too as repr(packed) can make UB from safe code.

Copy link
Member Author

Choose a reason for hiding this comment

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

This needs a comment as to why we need repr(packed) here.

Good point

Probably also a safety comment too as repr(packed) can make UB from safe code.

Rustc currently has a deny-by-default lint for that unsoundness, which will later get promoted into a hard error. IMO we can get away with just not worrying about it -- if this code compiles, then we know it's safe.

struct Packed<T: Command> {
meta: CommandMeta,
command: T,
}

// SAFETY: After adding the metadata, we correctly write the corresponding `command`
// of type `C` into `self.bytes`. Zero-sized commands do not get written into the buffer,
// so we'll just use a dangling pointer, which is valid for zero-sized types.
self.metas.push(CommandMeta {
offset: old_len,
apply_command: |command, world| {
// SAFETY: According to the invariants of `CommandMeta.apply_command`,
let meta = CommandMeta {
apply_command_and_get_size: |command, world| {
// SAFETY: According to the invariants of `CommandMeta.apply_command_and_get_size`,
// `command` must point to a value of type `C`.
let command: C = unsafe { command.read_unaligned() };
command.write(world);
std::mem::size_of::<C>()
},
});

let size = std::mem::size_of::<C>();
if size > 0 {
// Ensure that the buffer has enough space at the end to fit a value of type `C`.
// Since `C` is non-zero sized, this also guarantees that the buffer is non-null.
self.bytes.reserve(size);

// SAFETY: The buffer must be at least as long as `old_len`, so this operation
// will not overflow the pointer's original allocation.
let ptr: *mut C = unsafe { self.bytes.as_mut_ptr().add(old_len).cast() };

// Transfer ownership of the command into the buffer.
// SAFETY: `ptr` must be non-null, since it is within a non-null buffer.
// The call to `reserve()` ensures that the buffer has enough space to fit a value of type `C`,
// and it is valid to write any bit pattern since the underlying buffer is of type `MaybeUninit<u8>`.
unsafe { ptr.write_unaligned(command) };

// Grow the vector to include the command we just wrote.
// SAFETY: Due to the call to `.reserve(size)` above,
// this is guaranteed to fit in the vector's capacity.
unsafe { self.bytes.set_len(old_len + size) };
} else {
// Instead of writing zero-sized types into the buffer, we'll just use a dangling pointer.
// We must forget the command so it doesn't get double-dropped when the queue gets applied.
std::mem::forget(command);
};

let old_len = self.bytes.len();

// Reserve enough bytes for both the metadata and the command itself.
self.bytes.reserve(std::mem::size_of::<Packed<C>>());

// Pointer to the bytes at the end of the buffer.
// SAFETY: We know it is within bounds of the allocation, due to the call to `.reserve()`.
let ptr = unsafe { self.bytes.as_mut_ptr().add(old_len) };

// Write the metadata into the buffer, followed by the command.
// We are using a packed struct to write them both as one operation.
// SAFETY: `ptr` must be non-null, since it is within a non-null buffer.
// The call to `reserve()` ensures that the buffer has enough space to fit a value of type `C`,
// and it is valid to write any bit pattern since the underlying buffer is of type `MaybeUninit<u8>`.
unsafe {
ptr.cast::<Packed<C>>()
.write_unaligned(Packed { meta, command });
}

// Extend the length of the buffer to include the data we just wrote.
// SAFETY: The new length is guaranteed to fit in the vector's capacity,
// due to the call to `.reserve()` above.
unsafe {
self.bytes
.set_len(old_len + std::mem::size_of::<Packed<C>>());
}
}

Expand All @@ -92,23 +97,43 @@ impl CommandQueue {
// flush the previously queued entities
world.flush();

// Pointer that will iterate over the entries of the buffer.
let mut cursor = self.bytes.as_mut_ptr();

// The address of the end of the buffer.
let end_addr = cursor as usize + self.bytes.len();

// Reset the buffer, so it can be reused after this function ends.
// In the loop below, ownership of each command will be transferred into user code.
// SAFETY: `set_len(0)` is always valid.
unsafe { self.bytes.set_len(0) };

for meta in self.metas.drain(..) {
// SAFETY: `CommandQueue` guarantees that each metadata must have a corresponding value stored in `self.bytes`,
// so this addition will not overflow its original allocation.
let cmd = unsafe { self.bytes.as_mut_ptr().add(meta.offset) };
while (cursor as usize) < end_addr {
// SAFETY: The cursor is either at the start of the buffer, or just after the previous command.
// Since we know that the cursor is in bounds, it must point to the start of a new command.
let meta = unsafe { cursor.cast::<CommandMeta>().read_unaligned() };
// Advance to the bytes just after `meta`, which represent a type-erased command.
// SAFETY: For most types of `Command`, the pointer immediately following the metadata
// is guaranteed to be in bounds. If the command is a zero-sized type (ZST), then the cursor
// might be 1 byte past the end of the buffer, which is safe.
cursor = unsafe { cursor.add(std::mem::size_of::<CommandMeta>()) };
// Construct an owned pointer to the command.
// SAFETY: It is safe to transfer ownership out of `self.bytes`, since the call to `set_len(0)` above
// gaurantees that nothing stored in the buffer will get observed after this function ends.
// `cmd` points to a valid address of a stored command, so it must be non-null.
let cmd = unsafe { OwningPtr::new(NonNull::new_unchecked(cmd.cast())) };
// SAFETY: The underlying type of `cmd` matches the type expected by `meta.apply_command`.
unsafe {
(meta.apply_command)(cmd, world);
}
let cmd = unsafe {
OwningPtr::<Unaligned>::new(std::ptr::NonNull::new_unchecked(cursor.cast()))
};
// SAFETY: The data underneath the cursor must correspond to the type erased in metadata,
// since they were stored next to each other by `.push()`.
// For ZSTs, the type doesn't matter as long as the pointer is non-null.
let size = unsafe { (meta.apply_command_and_get_size)(cmd, world) };
// Advance the cursor past the command. For ZSTs, the cursor will not move.
// At this point, it will either point to the next `CommandMeta`,
// or the cursor will be out of bounds and the loop will end.
// SAFETY: The address just past the command is either within the buffer,
// or 1 byte past the end, so this addition will not overflow the pointer's allocation.
cursor = unsafe { cursor.add(size) };
}
}
}
Expand Down Expand Up @@ -217,7 +242,6 @@ mod test {
// even though the first command panicking.
// the `bytes`/`metas` vectors were cleared.
assert_eq!(queue.bytes.len(), 0);
assert_eq!(queue.metas.len(), 0);

// Even though the first command panicked, it's still ok to push
// more commands.
Expand Down