Rust micro optimisations - #17
Conversation
Keep the neurons in three per-layer vectors, as the C++ and Crystal implementations do (input_layer / hidden_layer / output_layer plus one synapse list), instead of one flat neuron vector addressed through index vectors. Source and destination layers can then be borrowed independently, so the per-neuron clone of the incoming-synapse index list and the temporary hidden-error vector disappear. Random initialisation, synapse creation order, floating-point operation order, momentum and the training sequence are unchanged; the hidden layer now interleaves error computation and weight update per neuron exactly as the references do, which is numerically identical. Checksum unchanged. Local median (Ryzen 7 7840U): 1.616s -> 0.884s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
Parser::parse() cloned the whole expression list for a return value the caller threw away, then the caller moved the field out anyway. Consume the parser and return the list by move, as the C++ parser returns std::move(expressions) and Crystal assigns the array by reference. The parsing itself is unchanged. Checksum unchanged. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
The LZW dictionary was built from String::from_utf8_lossy of single bytes, which collapses every byte above 127 to U+FFFD and copies each phrase several times per step. Use Vec<u8> phrases, the equivalent of the byte std::string used by the C++ implementation, and move or borrow phrases instead of cloning them. The phrase dictionary and the 16-bit big-endian code format are unchanged; the benchmark input is ASCII, so checksums are unchanged, and inputs with high bytes now round-trip. Local medians (Ryzen 7 7840U): Encode 1.463s -> 0.530s, Decode 0.659s -> 0.506s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
TreeNode::new now boxes the node and then builds its children, which is the order the C++ (make_unique in the constructor) and Crystal implementations already use. Every node is still individually boxed, recursively summed and freed inside the timed run. Checksum unchanged. Local median (Ryzen 7 7840U): 2.581s -> 2.243s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
Split the body slice with split_at_mut instead of cloning each planet, mutating it, and writing it back. This is what the C++ and Crystal implementations do (they mutate the body in place); the arithmetic and its order are untouched. Checksum unchanged. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
Borrow the input &str and index its bytes instead of collecting a Vec<char> per run, and parse numbers straight from the source slice instead of building a temporary String. The grammar is ASCII-only, so the behaviour is unchanged; checksum unchanged. Note for review: the C++ and Crystal parsers do materialise a char array per run (std::vector<char> / Array(Char)), so this removes an allocation the reference implementations pay for. Local median (Ryzen 7 7840U), on top of the by-move return: 0.577s -> 0.481s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
Key the frequency map by the &str slices that split_whitespace already yields instead of allocating an owned String per word. Checksum unchanged. Note for review: C++ keys its unordered_map by owned std::string and Crystal's split allocates a String per word, so this removes a per-word allocation the reference implementations pay for. Local median (Ryzen 7 7840U): 1.097s -> 0.836s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
Move the computation of ci out of the per-pixel loop; the escape iteration is untouched. The C implementation computes ci per row as well; C++ and Crystal compute it per pixel. Checksum unchanged. Local median (Ryzen 7 7840U): 0.970s -> 0.800s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
LRUCache::get returns Option<&V> instead of cloning the value, and the V: Clone bounds go away. This matches the Crystal implementation, which returns the stored object; the C++ get returns std::optional<V> by copy. Checksum unchanged. Local median (Ryzen 7 7840U): 0.688s -> 0.662s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
Cells stored shared references to their neighbours that were created through raw pointers and then invalidated by the mutable iteration in next_generation, and the grid was typed Grid<'static> to make it compile. Store neighbour indices into a flat cell vector instead; each cell still links its eight neighbours and the two-phase simultaneous update is unchanged. No unsafe code remains. Checksum unchanged. A Vec<Vec<Cell>> grid with (row, column) neighbour indices was tried first to stay closer to the 2-D grids of the other implementations; the double indexing made the benchmark about 60% slower (1.63s vs 1.00s for the unsound original). Rc<RefCell<Cell>> neighbours are ruled out because benchmarks must be Send + Sync. The flat vector runs in 0.93s. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
NGram: keep the entry returned by entry().or_insert() instead of looking the same key up a second time, as the C++ try_emplace version does. Template::Regex: into_owned() instead of to_string() on the Cow avoids one copy of the rendered output. Checksums unchanged. Co-Authored-By: GPT-6 Astra via codex <noreply@openai.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMhfQE27cMJWCApUFcG9EN
|
Thanks that good changes, I will write about all 3 pr. 3 still not merging: about calculator and u8, yes this is questinable, and I found that different languages used u8 or unicode char. so there is already inconsistence in implementations. which I would fix later over all languages for me it is changed from 58.4416s to 53.4252s |
|
funny note about Mandelbrot, your change should not affect performance because it is just LICM (which should be done by compiler easily), but it show some weak spot in the rust compiler. may be need to revert as it is micro optimization, we doing the work which compiler should. |
|
WRT the GameOfLife commit — I've tried once again, and I see ~10% improvement on my local machine. Might lead us into a moderately-interesting rabbithole about OS and CPU architectures and minor differences in Rust compiler versions? But my bigger point is that the original code is just not sound; if you'd run it under Miri, you'll get a runtime failure both under StackedBorrows and TreeBorrows ( |
|
about game of life, changing to flat array is not equal with any other lang. so most close idiomatic rust version should be: diff --git a/rust/src/benchmarks/game_of_life.rs b/rust/src/benchmarks/game_of_life.rs
index e0de361..e96e30c 100644
--- a/rust/src/benchmarks/game_of_life.rs
+++ b/rust/src/benchmarks/game_of_life.rs
@@ -1,13 +1,13 @@
use super::super::{helper, Benchmark};
use crate::config_i64;
-struct Cell<'a> {
+struct Cell {
alive: bool,
next_state: bool,
- neighbors: Vec<&'a Cell<'a>>,
+ neighbors: Vec<(usize, usize)>,
}
-impl<'a> Cell<'a> {
+impl Cell {
fn new(alive: bool) -> Self {
Self {
alive,
@@ -16,17 +16,21 @@ impl<'a> Cell<'a> {
}
}
- fn add_neighbor(&mut self, cell: &'a Cell<'a>) {
- self.neighbors.push(cell);
+ fn add_neighbor(&mut self, x: usize, y: usize) {
+ self.neighbors.push((x, y));
}
- fn compute_next_state(&mut self) {
- let alive_neighbors = self.neighbors.iter().filter(|n| n.alive).count();
+ fn compute_next_state(&self, cells: &[Vec<Cell>]) -> bool {
+ let alive_neighbors = self
+ .neighbors
+ .iter()
+ .filter(|&&(x, y)| cells[y][x].alive)
+ .count();
if self.alive {
- self.next_state = alive_neighbors == 2 || alive_neighbors == 3
+ alive_neighbors == 2 || alive_neighbors == 3
} else {
- self.next_state = alive_neighbors == 3
+ alive_neighbors == 3
}
}
@@ -35,47 +39,31 @@ impl<'a> Cell<'a> {
}
}
-struct Grid<'a> {
+struct Grid {
width: usize,
height: usize,
- cells: Vec<Vec<Cell<'a>>>,
+ cells: Vec<Vec<Cell>>,
}
-impl<'a> Grid<'a> {
+impl Grid {
fn new(width: usize, height: usize) -> Self {
let mut grid = Grid {
width,
height,
- cells: Vec::with_capacity(height),
+ cells: (0..height)
+ .map(|_| (0..width).map(|_| Cell::new(false)).collect())
+ .collect(),
};
- for _ in 0..height {
- let mut row = Vec::with_capacity(width);
- for _ in 0..width {
- row.push(Cell::new(false));
- }
- grid.cells.push(row);
- }
-
grid.link_neighbors();
grid
}
fn link_neighbors(&mut self) {
- let cells_ref: Vec<Vec<*const Cell<'a>>> = (0..self.height)
- .map(|y| {
- (0..self.width)
- .map(|x| &self.cells[y][x] as *const Cell)
- .collect()
- })
- .collect();
-
for y in 0..self.height {
for x in 0..self.width {
- let cell = &mut self.cells[y][x];
-
- for dy in -1..=1 {
- for dx in -1..=1 {
+ for dy in -1..=1_i32 {
+ for dx in -1..=1_i32 {
if dx == 0 && dy == 0 {
continue;
}
@@ -84,8 +72,7 @@ impl<'a> Grid<'a> {
((y as i32 + dy + self.height as i32) % self.height as i32) as usize;
let nx = ((x as i32 + dx + self.width as i32) % self.width as i32) as usize;
- let neighbor = unsafe { &*cells_ref[ny][nx] };
- cell.add_neighbor(neighbor);
+ self.cells[y][x].add_neighbor(nx, ny);
}
}
}
@@ -93,16 +80,18 @@ impl<'a> Grid<'a> {
}
fn next_generation(&mut self) {
- self.cells.iter_mut().for_each(|row| {
- row.iter_mut().for_each(|cell| {
- cell.compute_next_state();
- });
- });
- self.cells.iter_mut().for_each(|row| {
- row.iter_mut().for_each(|cell| {
+ for y in 0..self.height {
+ for x in 0..self.width {
+ let next_state = self.cells[y][x].compute_next_state(&self.cells);
+ self.cells[y][x].next_state = next_state;
+ }
+ }
+
+ for row in &mut self.cells {
+ for cell in row {
cell.update();
- });
- });
+ }
+ }
}
fn count_alive(&self) -> u32 {
@@ -128,7 +117,7 @@ impl<'a> Grid<'a> {
}
pub struct GameOfLife {
- grid: Grid<'static>,
+ grid: Grid,
}
impl GameOfLife {but this version gives 1.7s, vs 0.87s in lifetimes version. |
This is a follow-up to my #16, and would only be ready for merging after that PR lands (would be glad to rebase/adjust as needed when that happens).
Here we're entering the questionable territory: hardcoding ASCII assumptions into the Calculator (so
u8can be used instead ofchar; in my defence, some other implementations also implicitly assume the same), reusing the sameciper row in Mandelbrot (should be fine, but still a logic change), non-copying (so potentially less multithreadable in the future)Option<&V>in Cache. Nothing too crazy (at least nothing I won't be willing to commit into production code myself), but your call in the end.Commits in this PR are independently droppable, in case you object.