Skip to content

Commit 5d912a2

Browse files
committed
Speed up CommandQueue by storing commands more densely (#6391)
# Objective * Speed up inserting and applying commands. * Halve the stack size of `CommandQueue` to 24 bytes. * Require fewer allocations. ## Solution Store commands and metadata densely within the same buffer. Each command takes up 1 `usize` of metadata, plus the bytes to store the command itself. Zero-sized types take up no space except for the metadata. # Benchmarks All of the benchmarks related to commands. | Bench | Time | % Change | p-value | |----------------------------------------|-----------|--------------|-----------------| | empty_commands/0_entities | 4.7780 ns | -18.381% | 0.00 | | spawn_commands/2000_entities | 233.11 us | -0.9961% | 0.00 | | spawn_commands/4000_entities | 448.38 us | -3.1466% | 0.00 | | spawn_commands/6000_entities | 693.12 us | -0.3978% | _0.52_ | | spawn_commands/8000_entities | 889.48 us | -2.8802% | 0.00 | | insert_commands/insert | 609.95 us | -4.8604% | 0.00 | | insert_commands/insert_batch | 355.54 us | -2.8165% | 0.00 | | fake_commands/2000_commands | 4.8018 us | **-17.802%** | 0.00 | | fake_commands/4000_commands | 9.5969 us | **-17.337%** | 0.00 | | fake_commands/6000_commands | 14.421 us | **-18.454%** | 0.00 | | fake_commands/8000_commands | 19.192 us | **-18.261%** | 0.00 | | sized_commands_0_bytes/2000_commands | 4.0593 us | -4.7145% | 0.00 | | sized_commands_0_bytes/4000_commands | 8.1541 us | -4.9470% | 0.00 | | sized_commands_0_bytes/6000_commands | 12.806 us | -12.017% | 0.00 | | sized_commands_0_bytes/8000_commands | 17.096 us | -14.070% | 0.00 | | sized_commands_12_bytes/2000_commands | 5.3425 us | **-27.632%** | 0.00 | | sized_commands_12_bytes/4000_commands | 10.283 us | **-31.158%** | 0.00 | | sized_commands_12_bytes/6000_commands | 15.339 us | **-31.418%** | 0.00 | | sized_commands_12_bytes/8000_commands | 20.206 us | **-33.133%** | 0.00 | | sized_commands_512_bytes/2000_commands | 99.118 us | -9.9655% | 0.00 | | sized_commands_512_bytes/4000_commands | 201.96 us | -8.8235% | 0.00 | | sized_commands_512_bytes/6000_commands | 300.95 us | -9.2344% | 0.00 | | sized_commands_512_bytes/8000_commands | 404.69 us | -8.4578% | 0.00 |
1 parent cbb4c26 commit 5d912a2

File tree

1 file changed

+78
-54
lines changed

1 file changed

+78
-54
lines changed

crates/bevy_ecs/src/system/commands/command_queue.rs

+78-54
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
use std::{mem::MaybeUninit, ptr::NonNull};
1+
use std::mem::MaybeUninit;
22

33
use bevy_ptr::{OwningPtr, Unaligned};
44

55
use super::Command;
66
use crate::world::World;
77

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

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

3333
// SAFETY: All commands [`Command`] implement [`Send`]
@@ -43,45 +43,50 @@ impl CommandQueue {
4343
where
4444
C: Command,
4545
{
46-
let old_len = self.bytes.len();
46+
// Stores a command alongside its metadata.
47+
// `repr(C)` prevents the compiler from reordering the fields,
48+
// while `repr(packed)` prevents the compiler from inserting padding bytes.
49+
#[repr(C, packed)]
50+
struct Packed<T: Command> {
51+
meta: CommandMeta,
52+
command: T,
53+
}
4754

48-
// SAFETY: After adding the metadata, we correctly write the corresponding `command`
49-
// of type `C` into `self.bytes`. Zero-sized commands do not get written into the buffer,
50-
// so we'll just use a dangling pointer, which is valid for zero-sized types.
51-
self.metas.push(CommandMeta {
52-
offset: old_len,
53-
apply_command: |command, world| {
54-
// SAFETY: According to the invariants of `CommandMeta.apply_command`,
55+
let meta = CommandMeta {
56+
apply_command_and_get_size: |command, world| {
57+
// SAFETY: According to the invariants of `CommandMeta.apply_command_and_get_size`,
5558
// `command` must point to a value of type `C`.
5659
let command: C = unsafe { command.read_unaligned() };
5760
command.write(world);
61+
std::mem::size_of::<C>()
5862
},
59-
});
60-
61-
let size = std::mem::size_of::<C>();
62-
if size > 0 {
63-
// Ensure that the buffer has enough space at the end to fit a value of type `C`.
64-
// Since `C` is non-zero sized, this also guarantees that the buffer is non-null.
65-
self.bytes.reserve(size);
66-
67-
// SAFETY: The buffer must be at least as long as `old_len`, so this operation
68-
// will not overflow the pointer's original allocation.
69-
let ptr: *mut C = unsafe { self.bytes.as_mut_ptr().add(old_len).cast() };
70-
71-
// Transfer ownership of the command into the buffer.
72-
// SAFETY: `ptr` must be non-null, since it is within a non-null buffer.
73-
// The call to `reserve()` ensures that the buffer has enough space to fit a value of type `C`,
74-
// and it is valid to write any bit pattern since the underlying buffer is of type `MaybeUninit<u8>`.
75-
unsafe { ptr.write_unaligned(command) };
76-
77-
// Grow the vector to include the command we just wrote.
78-
// SAFETY: Due to the call to `.reserve(size)` above,
79-
// this is guaranteed to fit in the vector's capacity.
80-
unsafe { self.bytes.set_len(old_len + size) };
81-
} else {
82-
// Instead of writing zero-sized types into the buffer, we'll just use a dangling pointer.
83-
// We must forget the command so it doesn't get double-dropped when the queue gets applied.
84-
std::mem::forget(command);
63+
};
64+
65+
let old_len = self.bytes.len();
66+
67+
// Reserve enough bytes for both the metadata and the command itself.
68+
self.bytes.reserve(std::mem::size_of::<Packed<C>>());
69+
70+
// Pointer to the bytes at the end of the buffer.
71+
// SAFETY: We know it is within bounds of the allocation, due to the call to `.reserve()`.
72+
let ptr = unsafe { self.bytes.as_mut_ptr().add(old_len) };
73+
74+
// Write the metadata into the buffer, followed by the command.
75+
// We are using a packed struct to write them both as one operation.
76+
// SAFETY: `ptr` must be non-null, since it is within a non-null buffer.
77+
// The call to `reserve()` ensures that the buffer has enough space to fit a value of type `C`,
78+
// and it is valid to write any bit pattern since the underlying buffer is of type `MaybeUninit<u8>`.
79+
unsafe {
80+
ptr.cast::<Packed<C>>()
81+
.write_unaligned(Packed { meta, command });
82+
}
83+
84+
// Extend the length of the buffer to include the data we just wrote.
85+
// SAFETY: The new length is guaranteed to fit in the vector's capacity,
86+
// due to the call to `.reserve()` above.
87+
unsafe {
88+
self.bytes
89+
.set_len(old_len + std::mem::size_of::<Packed<C>>());
8590
}
8691
}
8792

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

100+
// Pointer that will iterate over the entries of the buffer.
101+
let mut cursor = self.bytes.as_mut_ptr();
102+
103+
// The address of the end of the buffer.
104+
let end_addr = cursor as usize + self.bytes.len();
105+
95106
// Reset the buffer, so it can be reused after this function ends.
96107
// In the loop below, ownership of each command will be transferred into user code.
97108
// SAFETY: `set_len(0)` is always valid.
98109
unsafe { self.bytes.set_len(0) };
99110

100-
for meta in self.metas.drain(..) {
101-
// SAFETY: `CommandQueue` guarantees that each metadata must have a corresponding value stored in `self.bytes`,
102-
// so this addition will not overflow its original allocation.
103-
let cmd = unsafe { self.bytes.as_mut_ptr().add(meta.offset) };
111+
while (cursor as usize) < end_addr {
112+
// SAFETY: The cursor is either at the start of the buffer, or just after the previous command.
113+
// Since we know that the cursor is in bounds, it must point to the start of a new command.
114+
let meta = unsafe { cursor.cast::<CommandMeta>().read_unaligned() };
115+
// Advance to the bytes just after `meta`, which represent a type-erased command.
116+
// SAFETY: For most types of `Command`, the pointer immediately following the metadata
117+
// is guaranteed to be in bounds. If the command is a zero-sized type (ZST), then the cursor
118+
// might be 1 byte past the end of the buffer, which is safe.
119+
cursor = unsafe { cursor.add(std::mem::size_of::<CommandMeta>()) };
120+
// Construct an owned pointer to the command.
104121
// SAFETY: It is safe to transfer ownership out of `self.bytes`, since the call to `set_len(0)` above
105122
// guarantees that nothing stored in the buffer will get observed after this function ends.
106123
// `cmd` points to a valid address of a stored command, so it must be non-null.
107-
let cmd = unsafe { OwningPtr::new(NonNull::new_unchecked(cmd.cast())) };
108-
// SAFETY: The underlying type of `cmd` matches the type expected by `meta.apply_command`.
109-
unsafe {
110-
(meta.apply_command)(cmd, world);
111-
}
124+
let cmd = unsafe {
125+
OwningPtr::<Unaligned>::new(std::ptr::NonNull::new_unchecked(cursor.cast()))
126+
};
127+
// SAFETY: The data underneath the cursor must correspond to the type erased in metadata,
128+
// since they were stored next to each other by `.push()`.
129+
// For ZSTs, the type doesn't matter as long as the pointer is non-null.
130+
let size = unsafe { (meta.apply_command_and_get_size)(cmd, world) };
131+
// Advance the cursor past the command. For ZSTs, the cursor will not move.
132+
// At this point, it will either point to the next `CommandMeta`,
133+
// or the cursor will be out of bounds and the loop will end.
134+
// SAFETY: The address just past the command is either within the buffer,
135+
// or 1 byte past the end, so this addition will not overflow the pointer's allocation.
136+
cursor = unsafe { cursor.add(size) };
112137
}
113138
}
114139
}
@@ -217,7 +242,6 @@ mod test {
217242
// even though the first command panicking.
218243
// the `bytes`/`metas` vectors were cleared.
219244
assert_eq!(queue.bytes.len(), 0);
220-
assert_eq!(queue.metas.len(), 0);
221245

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

0 commit comments

Comments
 (0)