From 3568d1c3bd723bf8f38f40b48e0030823b783beb Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 11 Nov 2026 22:45:01 +0000 Subject: [PATCH 1/2] perf(zero-rust): store a row's columns by its table's layout A json::Object keeps its keys apart from its values, in a list that the rows of a table share (their layout), as Swift keys rows by column index and JS engines share hidden classes. The parser follows a recent object's keys while the next object has the same ones, so a synced row allocates neither its keys nor their list; the poke handler and mutators put rows in their table's layout. Comparators and filters remember where their column was, so they find it without searching the names. Conformance is unchanged: 63,453 cases, 0 failures. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c6b9-fc99-73a1-bf70-e71fd484042c Co-authored-by: tmm --- rust/zero-client/src/client/ivm_branch.rs | 24 ++ rust/zero-client/src/client/poke_handler.rs | 7 +- rust/zero-client/src/client/transaction.rs | 2 + rust/zero-client/src/ivm/data.rs | 9 +- rust/zero-client/src/ivm/predicate.rs | 17 +- rust/zero-client/src/json/parse.rs | 78 ++++- rust/zero-client/src/json/value.rs | 347 ++++++++++++++++---- rust/zero-client/src/map.rs | 147 ++++++--- 8 files changed, 502 insertions(+), 129 deletions(-) diff --git a/rust/zero-client/src/client/ivm_branch.rs b/rust/zero-client/src/client/ivm_branch.rs index d0a908cafc..caf1afa5ed 100644 --- a/rust/zero-client/src/client/ivm_branch.rs +++ b/rust/zero-client/src/client/ivm_branch.rs @@ -22,6 +22,26 @@ use std::sync::Arc; /// aggregate table, shared by a branch and its forks. type Optimism = Rc>>>; +/// The layout of each table's rows: a row of each, whose keys the next rows +/// with the same columns in the same order share (`Object::share_keys`), so +/// a table's rows keep one list of column names. A row with other columns, +/// or in another order, becomes the layout. +#[derive(Default)] +pub(crate) struct Layouts(RefCell>); + +impl Layouts { + /// Puts `row`, a row of `table`, in the table's layout. + pub(crate) fn share(&self, table: &str, row: &mut Object) { + let mut layouts = self.0.borrow_mut(); + match layouts.get(table) { + Some(layout) if row.share_keys(layout) => {} + _ => { + layouts.insert(table.to_owned(), row.clone()); + } + } + } +} + /// The IVM sources of one state of the replica. The main branch feeds the /// app's views; mutators run against forks, so a rebase can replay them /// over a newer server snapshot without touching the views. @@ -29,6 +49,8 @@ pub(crate) struct IvmBranch { sources: RefCell>>>, schema: Arc, optimism: Optimism, + /// Shared by a branch and its forks. + pub(crate) layouts: Rc, /// Only the main branch registers optimistic aggregates and releases /// aggregate sources (see `aggregate_source`). is_fork: bool, @@ -48,6 +70,7 @@ impl IvmBranch { sources: RefCell::default(), schema, optimism: Rc::default(), + layouts: Rc::default(), is_fork: false, }) } @@ -158,6 +181,7 @@ impl IvmBranch { sources: RefCell::new(sources), schema: self.schema.clone(), optimism: self.optimism.clone(), + layouts: self.layouts.clone(), is_fork: true, }) } diff --git a/rust/zero-client/src/client/poke_handler.rs b/rust/zero-client/src/client/poke_handler.rs index af378b7b2d..2c0a1dabf1 100644 --- a/rust/zero-client/src/client/poke_handler.rs +++ b/rust/zero-client/src/client/poke_handler.rs @@ -3,6 +3,7 @@ //! (`packages/zero-client/src/client/zero-poke-handler.ts`). The engine //! schedules the applies. +use super::ivm_branch::Layouts; use super::replica_store::KvPatchOp; use crate::decode::JsonCodable; use crate::json::{self, Value, number}; @@ -82,6 +83,8 @@ pub(crate) struct PokeHandler { client_id: String, schema: Arc, server_to_client: NameMapper, + /// The layout of each table's rows from the server. + layouts: Layouts, receiving: Option, buffer: Vec, pub(crate) playback_running: bool, @@ -91,6 +94,7 @@ impl PokeHandler { pub(crate) fn new(client_id: String, schema: Arc) -> Self { PokeHandler { server_to_client: NameMapper::server_to_client(&schema), + layouts: Layouts::default(), client_id, schema, receiving: None, @@ -293,10 +297,11 @@ impl PokeHandler { let Some(table) = self.server_to_client.table_name_if_known(table_name) else { return Ok(None); }; - let row = self + let mut row = self .server_to_client .row(table_name, value) .map_err(mapping)?; + self.layouts.share(&table, &mut row); KvPatchOp::Put { key: self.key(&table, &row)?, value: Value::Object(row), diff --git a/rust/zero-client/src/client/transaction.rs b/rust/zero-client/src/client/transaction.rs index 82a5c02d64..957c0f0bb9 100644 --- a/rust/zero-client/src/client/transaction.rs +++ b/rust/zero-client/src/client/transaction.rs @@ -175,6 +175,7 @@ impl Transaction { row.insert(column.clone(), Value::Null); } } + self.branch.layouts.share(table_name, &mut row); let row: Row = Rc::new(row); self.write .borrow_mut() @@ -209,6 +210,7 @@ impl Transaction { for (k, v) in value.iter() { next.insert(k.clone(), v.clone()); } + self.branch.layouts.share(table_name, &mut next); self.write .borrow_mut() .set(&key, Value::Object(next.clone())); diff --git a/rust/zero-client/src/ivm/data.rs b/rust/zero-client/src/ivm/data.rs index bc8d0e1b1e..c56805bcd9 100644 --- a/rust/zero-client/src/ivm/data.rs +++ b/rust/zero-client/src/ivm/data.rs @@ -5,6 +5,7 @@ use super::Row; use crate::ast::{Direction, OrderPart}; use crate::compare; use crate::json::Value; +use std::cell::Cell; use std::cmp::Ordering; use std::rc::Rc; @@ -47,13 +48,13 @@ pub fn identical(a: Option<&Value>, b: Option<&Value>) -> bool { /// `makeComparator(order, reverse)`. pub fn make_comparator(order: &[OrderPart], reverse: bool) -> Comparator { - let parts: Vec<(String, bool)> = order + let parts: Vec<(String, bool, Cell)> = order .iter() - .map(|p| (p.field.clone(), p.direction == Direction::Asc)) + .map(|p| (p.field.clone(), p.direction == Direction::Asc, Cell::new(0))) .collect(); Rc::new(move |a: &Row, b: &Row| { - for (field, asc) in &parts { - let c = compare_values(a.get(field), b.get(field)); + for (field, asc, hint) in &parts { + let c = compare_values(a.get_hinted(field, hint), b.get_hinted(field, hint)); if c != Ordering::Equal { let c = if *asc { c } else { c.reverse() }; return if reverse { c.reverse() } else { c }; diff --git a/rust/zero-client/src/ivm/predicate.rs b/rust/zero-client/src/ivm/predicate.rs index 04257b0742..77e58126a2 100644 --- a/rust/zero-client/src/ivm/predicate.rs +++ b/rust/zero-client/src/ivm/predicate.rs @@ -7,6 +7,7 @@ use crate::compare; use crate::json::{Value, number}; use crate::like::LikePattern; use crate::query::simplify_condition; +use std::cell::Cell; use std::cmp::Ordering; use std::rc::Rc; @@ -69,15 +70,15 @@ fn simple_predicate( Rc::new(move |_| result) } ValuePosition::Json { column, path } => { - let (column, path) = (column.clone(), path.clone()); + let (column, path, hint) = (column.clone(), path.clone(), Cell::new(0)); Rc::new(move |row| { - let v = json_value_at(&path, row.get(&column)); + let v = json_value_at(&path, row.get_hinted(&column, &hint)); identical(Some(v), rhs.as_ref()) != is_not }) } ValuePosition::Column(name) => { - let name = name.clone(); - Rc::new(move |row| identical(row.get(&name), rhs.as_ref()) != is_not) + let (name, hint) = (name.clone(), Cell::new(0)); + Rc::new(move |row| identical(row.get_hinted(&name, &hint), rhs.as_ref()) != is_not) } ValuePosition::Static { .. } => unreachable!("checked above"), }); @@ -107,9 +108,9 @@ fn simple_predicate( }, }; let negated = matches!(op, Ne | NotLike | NotIlike | NotIn); - let (column, path) = (column.clone(), path.clone()); + let (column, path, hint) = (column.clone(), path.clone(), Cell::new(0)); Rc::new(move |row| { - let lhs = json_value_at(&path, row.get(&column)); + let lhs = json_value_at(&path, row.get_hinted(&column, &hint)); if lhs.is_null() { return false; } @@ -120,8 +121,8 @@ fn simple_predicate( }) } ValuePosition::Column(name) => { - let name = name.clone(); - Rc::new(move |row| match row.get(&name) { + let (name, hint) = (name.clone(), Cell::new(0)); + Rc::new(move |row| match row.get_hinted(&name, &hint) { None | Some(Value::Null) => false, Some(lhs) => imp(lhs), }) diff --git a/rust/zero-client/src/json/parse.rs b/rust/zero-client/src/json/parse.rs index 9e59b67dd8..ade389c178 100644 --- a/rust/zero-client/src/json/parse.rs +++ b/rust/zero-client/src/json/parse.rs @@ -1,3 +1,4 @@ +use super::value::{ObjectBuilder, RecentKeys}; use super::{Object, Value}; use std::fmt; @@ -37,6 +38,7 @@ pub fn parse(text: &str) -> Result { let mut p = Parser { b: text.as_bytes(), i: 0, + recent: RecentKeys::default(), }; p.skip_whitespace(); let value = p.parse_value()?; @@ -50,13 +52,15 @@ pub fn parse(text: &str) -> Result { struct Parser<'a> { b: &'a [u8], i: usize, + recent: RecentKeys, } /// An array or object that the value being parsed is in. enum Container { Array(Vec), - /// An object and the key of the value being parsed. - Object(Object, String), + /// An object and the key of the value being parsed: `None` for the key + /// the layout it follows has next (see `Parser::next_key`). + Object(ObjectBuilder, Option), } impl Parser<'_> { @@ -102,8 +106,9 @@ impl Parser<'_> { } } else { if is_object { - let key = self.parse_key()?; - stack.push(Container::Object(Object::new(), key)); + let mut builder = ObjectBuilder::default(); + let key = self.next_key(&mut builder)?; + stack.push(Container::Object(builder, key)); } else { stack.push(Container::Array(Vec::new())); } @@ -149,12 +154,17 @@ impl Parser<'_> { } Some(Container::Object(o, key)) => { // A repeated key keeps its first place and its last value. - o.insert(std::mem::take(key), value); + match key.take() { + Some(k) => { + o.insert(k, value); + } + None => o.push_expected(value), + } match self.peek() { None => return Err(self.error("Unexpected end of JSON input")), Some(b',') => { self.i += 1; - *key = self.parse_key()?; + *key = self.next_key(o)?; break; } Some(b'}') => self.i += 1, @@ -166,7 +176,11 @@ impl Parser<'_> { } value = match stack.pop() { Some(Container::Array(a)) => Value::Array(a), - Some(Container::Object(o, _)) => Value::Object(o), + Some(Container::Object(o, _)) => { + let mut o = o.build(); + self.recent.remember(&mut o); + Value::Object(o) + } None => unreachable!(), }; } @@ -191,6 +205,56 @@ impl Parser<'_> { Ok(key) } + /// Reads `o`'s next key. When it is the next key of the layout `o` + /// follows, or of a recent layout with `o`'s keys before it, `o` follows + /// that layout and the key, which is the layout's, isn't allocated: + /// `None`. Otherwise the key as [`Parser::parse_key`] reads it. + fn next_key(&mut self, o: &mut ObjectBuilder) -> Result, ParseError> { + self.skip_whitespace(); + let found = o.prefix().and_then(|(prefix, next)| match next { + Some(k) if self.is_raw_key(k) => Some((None, k.len())), + _ => self + .recent + .find_next(prefix, |k| self.is_raw_key(k)) + .map(|layout| { + let len = layout.names[prefix.len()].len(); + (Some(layout), len) + }), + }); + let Some((layout, len)) = found else { + return Ok(Some(self.parse_key()?)); + }; + if let Some(layout) = layout { + o.follow(layout); + } + self.skip_raw_key(len)?; + Ok(None) + } + + /// Whether the input is at `key` as a string without escapes, which then + /// parses as `key`. + fn is_raw_key(&self, key: &str) -> bool { + let k = key.as_bytes(); + let start = self.i + 1; + self.peek() == Some(b'"') + && self.b.get(start..start + k.len()) == Some(k) + && self.b.get(start + k.len()) == Some(&b'"') + && k.iter().all(|&c| c >= 0x20 && c != b'"' && c != b'\\') + } + + /// Skips a key of `len` bytes that [`Parser::is_raw_key`] accepted, and + /// the colon after it, as [`Parser::parse_key`] does. + fn skip_raw_key(&mut self, len: usize) -> Result<(), ParseError> { + self.i += len + 2; + self.skip_whitespace(); + if self.peek() != Some(b':') { + return Err(self.error("Expected ':' after property name")); + } + self.i += 1; + self.skip_whitespace(); + Ok(()) + } + fn expect_literal(&mut self, literal: &[u8]) -> Result<(), ParseError> { if self.b.len() - self.i < literal.len() { return Err(self.error("Unexpected end of JSON input")); diff --git a/rust/zero-client/src/json/value.rs b/rust/zero-client/src/json/value.rs index 18937d5b20..bafea3d760 100644 --- a/rust/zero-client/src/json/value.rs +++ b/rust/zero-client/src/json/value.rs @@ -1,6 +1,7 @@ -use crate::map::{self, OrderedMap}; +use crate::map::{self, Keys}; +use std::cell::Cell; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; /// A JSON value, the data model Zero syncs and stores. /// @@ -157,14 +158,176 @@ impl> From> for Value { /// place, as assigning to an existing property does in JS. Two objects are /// equal when they have the same keys with equal values, in any order. /// -/// Cloning an object is cheap: copies share their entries until one of them -/// is changed, so a row read from the replica, its diff, and the IVM sources -/// is one set of entries. -#[derive(Clone, Default)] +/// The keys are kept apart from the values, in a list that objects with the +/// same keys in the same order can share: the rows of a table share one, the +/// table's layout, as JS engines' hidden classes do, so reading a row's +/// column reaches its value without touching a copy of the column's name. +/// Cloning an object is cheap: copies share their values until one of them +/// is changed. +#[derive(Clone)] pub struct Object { - entries: Arc>, - /// How many of the keys, at the front, are integer-like. - integer_keys: usize, + keys: Arc, + values: Arc<[Value]>, +} + +impl Default for Object { + /// An empty object, which allocates nothing. + fn default() -> Self { + static KEYS: LazyLock> = LazyLock::new(Arc::default); + static VALUES: LazyLock> = LazyLock::new(|| Arc::new([])); + Object { + keys: KEYS.clone(), + values: VALUES.clone(), + } + } +} + +/// An object being built, before its keys and values are shared. +#[derive(Default)] +pub(crate) struct ObjectBuilder { + keys: Keys, + values: Vec, + /// The keys of another object, which this one has had so far: its keys + /// are the first `values.len()` of them, and `keys` is empty. The parser + /// follows a recent object's keys, so that the rows of a table share + /// their keys without the keys being allocated. + following: Option>, +} + +impl ObjectBuilder { + /// The keys so far and the next key of the layout it follows, while it + /// follows one or has no keys yet. + pub(crate) fn prefix(&self) -> Option<(&[String], Option<&String>)> { + let n = self.values.len(); + match &self.following { + Some(layout) => Some((&layout.names[..n], layout.names.get(n))), + None if n == 0 => Some((&[], None)), + None => None, + } + } + + /// Follows `layout`, whose first keys are the keys so far (see + /// `following`). + pub(crate) fn follow(&mut self, layout: Arc) { + debug_assert!( + self.prefix() + .is_some_and(|(p, _)| *p == layout.names[..p.len()]) + ); + self.values + .reserve(layout.names.len().saturating_sub(self.values.len())); + self.following = Some(layout); + } + + /// Sets the next key of the layout it follows to `value`. + pub(crate) fn push_expected(&mut self, value: Value) { + debug_assert!(self.prefix().is_some_and(|(_, next)| next.is_some())); + self.values.push(value); + } + + /// Stops following a layout: the object gets its own keys. + fn own_keys(&mut self) { + if let Some(layout) = self.following.take() { + for name in &layout.names[..self.values.len()] { + let at = self.keys.names.len(); + self.keys.insert(at, name.clone()); + } + } + } + + /// [`Object::insert`]. + pub(crate) fn insert(&mut self, key: String, value: Value) -> Option { + self.own_keys(); + if let Some(i) = self.keys.position(&key) { + return Some(std::mem::replace(&mut self.values[i], value)); + } + // An integer-like key goes among the integer-like keys at the front, + // in numeric order. + let at = match array_index(&key) { + Some(n) => self + .keys + .names + .iter() + .position(|k| array_index(k).is_none_or(|m| m > n)) + .unwrap_or(self.values.len()), + None => self.values.len(), + }; + self.keys.insert(at, key); + self.values.insert(at, value); + None + } + + pub(crate) fn build(mut self) -> Object { + if self.values.is_empty() { + return Object::new(); + } + let keys = match self.following.take() { + Some(layout) if layout.names.len() == self.values.len() => layout, + layout => { + self.following = layout; + self.own_keys(); + Arc::new(self.keys) + } + }; + Object { + keys, + values: self.values.into(), + } + } +} + +/// The keys of the objects a parse built last, most recent first, which +/// the next objects follow when their keys match. +#[derive(Default)] +pub(crate) struct RecentKeys(Vec>); + +impl RecentKeys { + const SIZE: usize = 8; + + /// The most recent keys that start with `prefix` and then a key that + /// `is_next` accepts. + pub(crate) fn find_next( + &self, + prefix: &[String], + mut is_next: impl FnMut(&str) -> bool, + ) -> Option> { + let n = prefix.len(); + self.0 + .iter() + .find(|k| k.names.len() > n && k.names[..n] == *prefix && is_next(&k.names[n])) + .cloned() + } + + /// Makes `object`'s keys the most recent. An object that has the same + /// keys as a recent one, which it built its own of, shares them. + pub(crate) fn remember(&mut self, object: &mut Object) { + if object.is_empty() { + return; + } + let same = self + .0 + .iter() + .position(|k| Arc::ptr_eq(k, &object.keys)) + .or_else(|| { + let i = self.0.iter().position(|k| k.names == object.keys.names)?; + object.keys = self.0[i].clone(); + Some(i) + }); + match same { + Some(i) => self.0[..=i].rotate_right(1), + None => { + self.0.truncate(Self::SIZE - 1); + self.0.insert(0, object.keys.clone()); + } + } + } +} + +/// The values, moved out when nothing else shares them. +fn into_vec(mut values: Arc<[Value]>) -> Vec { + match Arc::get_mut(&mut values) { + Some(owned) => owned.iter_mut().map(std::mem::take).collect(), + None => values.to_vec(), + } } impl Object { @@ -172,98 +335,128 @@ impl Object { Self::default() } - pub fn with_capacity(capacity: usize) -> Self { - Object { - entries: Arc::new(OrderedMap::with_capacity(capacity)), - integer_keys: 0, - } + /// An empty object. Its values are allocated once it has keys, so the + /// capacity isn't used. + pub fn with_capacity(_capacity: usize) -> Self { + Self::default() } pub fn len(&self) -> usize { - self.entries.len() + self.values.len() } pub fn is_empty(&self) -> bool { - self.entries.is_empty() + self.values.is_empty() } pub fn get(&self, key: &str) -> Option<&Value> { - self.entries.get(key) + self.keys.position(key).map(|i| &self.values[i]) } pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> { - if !self.entries.contains_key(key) { - return None; - } - Arc::make_mut(&mut self.entries).get_mut(key) + let i = self.keys.position(key)?; + Some(&mut Arc::make_mut(&mut self.values)[i]) } pub fn contains_key(&self, key: &str) -> bool { - self.entries.contains_key(key) + self.keys.position(key).is_some() + } + + /// Changes the keys, after which the object has its own. + fn edit(&mut self, f: impl FnOnce(&mut ObjectBuilder) -> R) -> R { + let Object { keys, values } = std::mem::take(self); + let mut builder = ObjectBuilder { + keys: Arc::unwrap_or_clone(keys), + values: into_vec(values), + following: None, + }; + let result = f(&mut builder); + *self = builder.build(); + result } /// Sets `key` to `value`, returning the value it replaced. A new key goes /// after the others, or among the integer-like keys in numeric order. pub fn insert(&mut self, key: impl Into, value: Value) -> Option { let key = key.into(); - let entries = Arc::make_mut(&mut self.entries); - if let Some(slot) = entries.get_mut(&key) { - return Some(std::mem::replace(slot, value)); - } - if let Some(n) = array_index(&key) { - let at = entries - .keys() - .take(self.integer_keys) - .position(|k| array_index(k).is_some_and(|m| m > n)); - let at = at.unwrap_or(self.integer_keys); - entries.insert_at(at, key, value); - self.integer_keys += 1; - } else { - entries.insert(key, value); + if let Some(i) = self.keys.position(&key) { + return Some(std::mem::replace( + &mut Arc::make_mut(&mut self.values)[i], + value, + )); } - None + self.edit(|b| b.insert(key, value)) } /// Removes `key`, returning its value. The other keys keep their order. pub fn remove(&mut self, key: &str) -> Option { - if !self.entries.contains_key(key) { - return None; - } - let (i, value) = Arc::make_mut(&mut self.entries).remove_full(key)?; - if i < self.integer_keys { - self.integer_keys -= 1; - } - Some(value) + let i = self.keys.position(key)?; + self.edit(|b| { + b.keys.remove(i); + Some(b.values.remove(i)) + }) } /// The entries, in order. pub fn iter(&self) -> map::Iter<'_, Value> { - self.entries.iter() + map::Iter::new(&self.keys.names, &self.values) } pub fn keys(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { - self.entries.keys() + self.keys.names.iter() } pub fn values(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { - self.entries.values() + self.values.iter() } /// The entry at `index`, in order. pub fn get_index(&self, index: usize) -> Option<(&String, &Value)> { - self.entries.get_index(index) + Some((self.keys.names.get(index)?, &self.values[index])) + } + + /// [`Object::get`], looking first at the position in `hint`, where the + /// key was in the last object looked up: the rows of a table have their + /// columns in the same places, so a comparator or a filter finds a + /// column without searching the names. + pub(crate) fn get_hinted(&self, key: &str, hint: &Cell) -> Option<&Value> { + if let Some((k, v)) = self.get_index(hint.get()) + && k == key + { + return Some(v); + } + let i = self.keys.position(key)?; + hint.set(i); + Some(&self.values[i]) + } + + /// Shares `layout`'s keys when they are this object's keys in the same + /// order, so the two keep one list of them. Returns whether the keys + /// are shared now. + pub(crate) fn share_keys(&mut self, layout: &Object) -> bool { + if Arc::ptr_eq(&self.keys, &layout.keys) { + return true; + } + if self.keys.names != layout.keys.names { + return false; + } + self.keys = layout.keys.clone(); + true } } impl PartialEq for Object { fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.entries, &other.entries) || self.entries == other.entries + if Arc::ptr_eq(&self.keys, &other.keys) { + return Arc::ptr_eq(&self.values, &other.values) || self.values == other.values; + } + self.len() == other.len() && self.iter().all(|(k, v)| other.get(k) == Some(v)) } } impl fmt::Debug for Object { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_map().entries(self.entries.iter()).finish() + f.debug_map().entries(self.iter()).finish() } } @@ -271,7 +464,7 @@ impl<'a> IntoIterator for &'a Object { type Item = (&'a String, &'a Value); type IntoIter = map::Iter<'a, Value>; fn into_iter(self) -> Self::IntoIter { - self.entries.iter() + self.iter() } } @@ -279,7 +472,7 @@ impl IntoIterator for Object { type Item = (String, Value); type IntoIter = map::IntoIter; fn into_iter(self) -> Self::IntoIter { - Arc::unwrap_or_clone(self.entries).into_iter() + map::IntoIter::new(Arc::unwrap_or_clone(self.keys).names, into_vec(self.values)) } } @@ -287,11 +480,11 @@ impl> FromIterator<(K, Value)> for Object { /// A key that repeats keeps its first place and its last value, as in a /// JS object literal. fn from_iter>(iter: I) -> Self { - let mut o = Object::new(); + let mut o = ObjectBuilder::default(); for (k, v) in iter { - o.insert(k, v); + o.insert(k.into(), v); } - o + o.build() } } @@ -352,6 +545,47 @@ mod tests { ); } + #[test] + fn parsed_objects_with_the_same_keys_share_them() { + let text = r#"[{"a":1,"b":2},{"a":3,"b":4},{"a":5},{"a":6,"b":7,"c":8}, + {"b":1,"a":2},{"a":1,"a":2,"b":3},{"\u0061":1,"b\"":2},{"1":0,"0":1,"a":2}, + {"a":9,"b":10}]"#; + let Value::Array(objects) = crate::json::parse(text).unwrap() else { + panic!("an array"); + }; + let objects: Vec<&Object> = objects.iter().map(|v| v.as_object().unwrap()).collect(); + let entries: Vec> = objects + .iter() + .map(|o| { + o.iter() + .map(|(k, v)| (k.as_str(), v.as_f64().unwrap())) + .collect() + }) + .collect(); + assert_eq!( + entries, + [ + vec![("a", 1.0), ("b", 2.0)], + vec![("a", 3.0), ("b", 4.0)], + vec![("a", 5.0)], + vec![("a", 6.0), ("b", 7.0), ("c", 8.0)], + vec![("b", 1.0), ("a", 2.0)], + vec![("a", 2.0), ("b", 3.0)], + vec![("a", 1.0), ("b\"", 2.0)], + vec![("0", 1.0), ("1", 0.0), ("a", 2.0)], + vec![("a", 9.0), ("b", 10.0)], + ] + ); + let shared = |i: usize, j: usize| Arc::ptr_eq(&objects[i].keys, &objects[j].keys); + assert!(shared(0, 1) && shared(0, 8) && shared(0, 5)); + assert!(!shared(0, 2) && !shared(0, 3) && !shared(0, 4)); + // A change to an object whose keys are shared leaves the others'. + let mut changed = objects[1].clone(); + changed.insert("z", Value::Null); + assert_eq!(objects[0].keys().collect::>(), ["a", "b"]); + assert_eq!(changed.keys().collect::>(), ["a", "b", "z"]); + } + #[test] fn a_change_to_a_copy_leaves_the_original() { let mut original = Object::new(); @@ -375,7 +609,6 @@ mod tests { (&"b".to_owned(), &Value::from(2.0)) ] ); - assert_eq!(original.integer_keys, 0); } } } diff --git a/rust/zero-client/src/map.rs b/rust/zero-client/src/map.rs index 4cd3f06c43..ff8f97b0a7 100644 --- a/rust/zero-client/src/map.rs +++ b/rust/zero-client/src/map.rs @@ -17,17 +17,67 @@ const INDEX_THRESHOLD: usize = 16; /// any order. #[derive(Clone)] pub struct OrderedMap { - entries: Vec<(String, V)>, - /// Each key's position in `entries`, once there are more than + keys: Keys, + values: Vec, +} + +/// The keys of an [`OrderedMap`] or a JSON object, in order, apart from the +/// values, so that objects can share them. +#[derive(Clone, Default)] +pub(crate) struct Keys { + pub(crate) names: Vec, + /// Each key's position in `names`, once there are more than /// `INDEX_THRESHOLD`. index: Option>, } +impl Keys { + pub(crate) fn position(&self, key: &str) -> Option { + match &self.index { + Some(index) => index.get(key).copied(), + None => self.names.iter().position(|k| k == key), + } + } + + /// Inserts `key`, which must not be present yet, at position `at`. + pub(crate) fn insert(&mut self, at: usize, key: String) { + if let Some(index) = &mut self.index { + if at < self.names.len() { + for i in index.values_mut().filter(|i| **i >= at) { + *i += 1; + } + } + index.insert(key.clone(), at); + } + self.names.insert(at, key); + if self.index.is_none() && self.names.len() > INDEX_THRESHOLD { + self.index = Some( + self.names + .iter() + .enumerate() + .map(|(i, k)| (k.clone(), i)) + .collect(), + ); + } + } + + /// Removes the key at `at`. + pub(crate) fn remove(&mut self, at: usize) { + let key = self.names.remove(at); + if let Some(index) = &mut self.index { + index.remove(&key); + for i in index.values_mut().filter(|i| **i > at) { + *i -= 1; + } + } + } +} + impl Default for OrderedMap { fn default() -> Self { OrderedMap { - entries: Vec::new(), - index: None, + keys: Keys::default(), + values: Vec::new(), } } } @@ -39,33 +89,33 @@ impl OrderedMap { pub fn with_capacity(capacity: usize) -> Self { OrderedMap { - entries: Vec::with_capacity(capacity), - index: None, + keys: Keys { + names: Vec::with_capacity(capacity), + index: None, + }, + values: Vec::with_capacity(capacity), } } pub fn len(&self) -> usize { - self.entries.len() + self.values.len() } pub fn is_empty(&self) -> bool { - self.entries.is_empty() + self.values.is_empty() } /// The position of `key`. pub fn position(&self, key: &str) -> Option { - match &self.index { - Some(index) => index.get(key).copied(), - None => self.entries.iter().position(|(k, _)| k == key), - } + self.keys.position(key) } pub fn get(&self, key: &str) -> Option<&V> { - self.position(key).map(|i| &self.entries[i].1) + self.position(key).map(|i| &self.values[i]) } pub fn get_mut(&mut self, key: &str) -> Option<&mut V> { - self.position(key).map(|i| &mut self.entries[i].1) + self.position(key).map(|i| &mut self.values[i]) } pub fn contains_key(&self, key: &str) -> bool { @@ -74,7 +124,7 @@ impl OrderedMap { /// The entry at `index`, in order. pub fn get_index(&self, index: usize) -> Option<(&String, &V)> { - self.entries.get(index).map(|(k, v)| (k, v)) + Some((self.keys.names.get(index)?, &self.values[index])) } /// Sets `key` to `value`, returning the value it replaced. A new key goes @@ -82,9 +132,9 @@ impl OrderedMap { pub fn insert(&mut self, key: impl Into, value: V) -> Option { let key = key.into(); if let Some(i) = self.position(&key) { - return Some(std::mem::replace(&mut self.entries[i].1, value)); + return Some(std::mem::replace(&mut self.values[i], value)); } - let at = self.entries.len(); + let at = self.values.len(); self.insert_new(at, key, value); None } @@ -101,37 +151,16 @@ impl OrderedMap { } fn insert_new(&mut self, at: usize, key: String, value: V) { - if let Some(index) = &mut self.index { - if at < self.entries.len() { - for i in index.values_mut().filter(|i| **i >= at) { - *i += 1; - } - } - index.insert(key.clone(), at); - } - self.entries.insert(at, (key, value)); - if self.index.is_none() && self.entries.len() > INDEX_THRESHOLD { - self.index = Some( - self.entries - .iter() - .enumerate() - .map(|(i, (k, _))| (k.clone(), i)) - .collect(), - ); - } + self.keys.insert(at, key); + self.values.insert(at, value); } /// Removes `key`, returning its position and value. The other keys keep /// their order. pub fn remove_full(&mut self, key: &str) -> Option<(usize, V)> { let at = self.position(key)?; - if let Some(index) = &mut self.index { - index.remove(key); - for i in index.values_mut().filter(|i| **i > at) { - *i -= 1; - } - } - Some((at, self.entries.remove(at).1)) + self.keys.remove(at); + Some((at, self.values.remove(at))) } /// Removes `key`, returning its value. The other keys keep their order. @@ -141,24 +170,24 @@ impl OrderedMap { /// The entries, in order. pub fn iter(&self) -> Iter<'_, V> { - Iter(self.entries.iter()) + Iter::new(&self.keys.names, &self.values) } /// The entries, in order, with mutable values. pub fn iter_mut(&mut self) -> impl ExactSizeIterator { - self.entries.iter_mut().map(|(k, v)| (&*k, v)) + self.keys.names.iter().zip(self.values.iter_mut()) } pub fn keys(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { - self.entries.iter().map(|(k, _)| k) + self.keys.names.iter() } pub fn values(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { - self.entries.iter().map(|(_, v)| v) + self.values.iter() } pub fn values_mut(&mut self) -> impl ExactSizeIterator { - self.entries.iter_mut().map(|(_, v)| v) + self.values.iter_mut() } } @@ -188,13 +217,20 @@ impl, V> FromIterator<(K, V)> for OrderedMap { /// The entries of an [`OrderedMap`], in order. #[derive(Clone)] -pub struct Iter<'a, V>(std::slice::Iter<'a, (String, V)>); +pub struct Iter<'a, V>(std::iter::Zip, std::slice::Iter<'a, V>>); + +impl<'a, V> Iter<'a, V> { + /// The entries of `keys` with `values`, which have the same length. + pub(crate) fn new(keys: &'a [String], values: &'a [V]) -> Self { + Iter(keys.iter().zip(values)) + } +} impl<'a, V> Iterator for Iter<'a, V> { type Item = (&'a String, &'a V); fn next(&mut self) -> Option { - self.0.next().map(|(k, v)| (k, v)) + self.0.next() } fn size_hint(&self) -> (usize, Option) { @@ -204,14 +240,21 @@ impl<'a, V> Iterator for Iter<'a, V> { impl DoubleEndedIterator for Iter<'_, V> { fn next_back(&mut self) -> Option { - self.0.next_back().map(|(k, v)| (k, v)) + self.0.next_back() } } impl ExactSizeIterator for Iter<'_, V> {} /// The entries of an [`OrderedMap`], in order, by value. -pub struct IntoIter(std::vec::IntoIter<(String, V)>); +pub struct IntoIter(std::iter::Zip, std::vec::IntoIter>); + +impl IntoIter { + /// The entries of `keys` with `values`, which have the same length. + pub(crate) fn new(keys: Vec, values: Vec) -> Self { + IntoIter(keys.into_iter().zip(values)) + } +} impl Iterator for IntoIter { type Item = (String, V); @@ -247,7 +290,7 @@ impl IntoIterator for OrderedMap { type IntoIter = IntoIter; fn into_iter(self) -> IntoIter { - IntoIter(self.entries.into_iter()) + IntoIter::new(self.keys.names, self.values) } } From f191d51d0c38bd7e05df6f1bc971a64cb2ecaa91 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 11 Nov 2026 22:52:18 +0000 Subject: [PATCH 2/2] docs(zero-rust): benchmarks after storing rows by their table's layout Rust, Swift, and JS measured on one machine, with a zero-rust patch changeset. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c6b9-fc99-73a1-bf70-e71fd484042c Co-authored-by: tmm --- .changeset/zero-rust-row-layout.md | 5 ++ rust/BENCHMARKS.md | 109 +++++++++++++++++------------ 2 files changed, 71 insertions(+), 43 deletions(-) create mode 100644 .changeset/zero-rust-row-layout.md diff --git a/.changeset/zero-rust-row-layout.md b/.changeset/zero-rust-row-layout.md new file mode 100644 index 0000000000..d8694f4996 --- /dev/null +++ b/.changeset/zero-rust-row-layout.md @@ -0,0 +1,5 @@ +--- +'zero-rust': patch +--- + +The rows of a table share one list of column names, their layout, instead of each keeping its own copy, and a synced row no longer allocates its column names when it is parsed. 100,000 rows and a view of them take 83 MB instead of 181 MB, materializing them takes 10.5 ms instead of 20.9 ms, and reading them as structs 23.8 ms instead of 48.3 ms. diff --git a/rust/BENCHMARKS.md b/rust/BENCHMARKS.md index 0c3f4ec43d..db3cb04a67 100644 --- a/rust/BENCHMARKS.md +++ b/rust/BENCHMARKS.md @@ -37,52 +37,56 @@ gate on. CI builds them with the workspace (`cargo clippy --all-targets`, ## Results -Medians; lower is better. Rust and JS ran one after the other on one -machine: an 8-core Intel Xeon at 2.6 GHz with 16 GB, Linux 6.1, Rust 1.99.1 -(release build) and Node 24.22 under vitest. The Swift column is -`swift/BENCHMARKS.md`'s Linux column, from the same kind of machine but not -the same run, with Swift 6.2.4; its cells marked † are from before Swift's -last performance pass. - -In short: Rust is 2–5× faster than JS on views of up to 10,000 rows, 1.3× -on 100,000, 18× on an optimistic update, and 20–40× on hashing (where -vitest flatters Rust), and uses about three quarters of JS's memory. It is -at or ahead of Swift on views of up to 10,000 rows, single-row changes, -and pokes, and behind Swift on materializing 100,000 rows and on reading -rows as structs (see What they found). - -| Benchmark | JS (Node) | Rust | Swift (Linux) | -| ------------------------------------------ | --------------------------- | ------------------------ | -------------- | -| zero.bench: all 1000 rows x 10 columns | 236 µs | 56.6 µs | 70.4 µs | -| zero.bench: pk = N | 11.0 µs | 4.58 µs | 8.39 µs | -| zero.bench: lower 500 rows | 152 µs | 58.6 µs | 89.7 µs | -| materialize 1,000 rows | 242 µs | 50.5 µs | 63.2 µs | -| materialize 10,000 rows | 2.15 ms | 716 µs | 757 µs | -| materialize 100,000 rows | 29.7 ms | 23.2 ms | 7.07 ms | -| memory for 100,000 rows and a view of them | 245 MB | 181 MB | 128 MB† | -| optimistic update with a 1000-row view | 274 µs | 15.4 µs | 23 µs† | -| h32, 64 / 1024 / 8192 chars | 433 ns / 3.87 µs / 32.3 µs | 10 ns / 151 ns / 1.21 µs | | -| h64, 64 / 1024 / 8192 chars | 703 ns / 6.37 µs / 51.5 µs | 23 ns / 303 ns / 2.42 µs | – / 318 ns / – | -| h128, 64 / 1024 / 8192 chars | 1.29 µs / 12.5 µs / 95.3 µs | 42 ns / 606 ns / 5.60 µs | | +Medians of three runs; lower is better. Rust and Swift ran in turns on one +machine, an 8-core Intel Xeon at 2.6 GHz with 16 GB, Linux 6.1, with Rust +1.99.1 (release build) and Swift 6.2.4 (`swift build -c release` of this +checkout); JS ran just before, with Node 24.22 under vitest. The JS +`zero.bench` and hash rows are from an earlier run on the same machine. + +`swift/BENCHMARKS.md`'s Linux column has Swift 2–3 times faster than it ran +here (materializing 100,000 rows in 7.07 ms, not 16.8 ms), from what it +calls the same kind of machine; JS ran here as fast as that document has +it; why isn't known yet. Against that column, Rust is behind Swift on +materializing 100,000 rows (10.5 ms against 7.07 ms) and level with it on +reading them as structs (23.8 ms against 24.8 ms). + +In short: on this machine Rust is 2–5× faster than JS and 1.5–4.6× faster +than Swift on views of 1,000 to 100,000 rows, 17× faster than JS on an +optimistic update, and 20–40× on hashing (where vitest flatters Rust). It +uses about a third of JS's memory and two thirds of Swift's. + +| Benchmark | JS (Node) | Rust | Swift (Linux) | +| ------------------------------------------ | --------------------------- | ------------------------ | ------------------------ | +| zero.bench: all 1000 rows x 10 columns | 236 µs | 52.1 µs | 91.6 µs | +| zero.bench: pk = N | 11.0 µs | 4.60 µs | 21.3 µs | +| zero.bench: lower 500 rows | 152 µs | 54.6 µs | 130 µs | +| materialize 1,000 rows | 229 µs | 46.3 µs | 85.6 µs | +| materialize 10,000 rows | 2.06 ms | 565 µs | 863 µs | +| materialize 100,000 rows | 30.1 ms | 10.5 ms | 16.8 ms | +| memory for 100,000 rows and a view of them | 239 MB | 83 MB | 127 MB | +| optimistic update with a 1000-row view | 274 µs | 15.9 µs | 39.5 µs | +| h32, 64 / 1024 / 8192 chars | 433 ns / 3.87 µs / 32.3 µs | 10 ns / 151 ns / 1.21 µs | 27 ns / 172 ns / 1.23 µs | +| h64, 64 / 1024 / 8192 chars | 703 ns / 6.37 µs / 51.5 µs | 22 ns / 303 ns / 2.42 µs | 42 ns / 323 ns / 2.45 µs | +| h128, 64 / 1024 / 8192 chars | 1.29 µs / 12.5 µs / 95.3 µs | 41 ns / 605 ns / 4.85 µs | 67 ns / 628 ns / 4.88 µs | As `hash.bench.ts` notes, vitest keeps imported functions from being inlined, so its JS hash numbers are pessimistic; bundled, they are a few -times faster. The JS memory figure is the change in resident memory, as -Rust's is (`VmRSS`), from one run. +times faster. The memory figures are the change in resident memory +(`VmRSS`), from one run. Rust and Swift only: | Benchmark | Rust | Swift (Linux) | | --------------------------------------------------------- | ------- | ------------- | -| materialize 1,000 rows as structs | 142 µs | 182 µs | -| materialize 10,000 rows as structs | 3.50 ms | 1.94 ms | -| materialize 100,000 rows as structs | 52.4 ms | 24.8 ms | -| poke putting 1 row, 1000-row view | 11.4 µs | 29 µs† | -| poke putting 100 rows, 1000-row view | 647 µs | 1.00 ms† | -| poke putting 1000 rows, 1000-row view | 8.83 ms | 9.82 ms† | -| round trip (update, push, confirming poke), 1000-row view | 40.0 µs | 80 µs† | -| first sync: zbugs poke of 2,058 rows, issue list view | 19.0 ms | | -| first sync: the same poke, no view | 9.91 ms | | +| materialize 1,000 rows as structs | 121 µs | 240 µs | +| materialize 10,000 rows as structs | 1.42 ms | 2.41 ms | +| materialize 100,000 rows as structs | 23.8 ms | 37.4 ms | +| poke putting 1 row, 1000-row view | 12.4 µs | 49.1 µs | +| poke putting 100 rows, 1000-row view | 485 µs | 1.18 ms | +| poke putting 1000 rows, 1000-row view | 5.01 ms | 10.8 ms | +| round trip (update, push, confirming poke), 1000-row view | 39.7 µs | 118 µs | +| first sync: zbugs poke of 2,058 rows, issue list view | 15.3 ms | 42.9 ms | +| first sync: the same poke, no view | 8.86 ms | 24.4 ms | `first sync` replays zbugs' first sync as the zbugs-swift app got it, with the rows Swift's benchmark generates from the same seed: one poke of 21 @@ -117,11 +121,30 @@ timed from the poke's frames arriving to the view being flushed. 181 MB, a first sync from 25.7 to 19.0 ms (17.9 to 9.9 ms without a view), a poke of 1000 rows from 15.0 to 8.8 ms, and materializing 100,000 rows from 32 to 23 ms. +- **Every row had its own column names.** A row kept a copy of each of + its column names next to its values, so 100,000 rows of 10 columns kept + a million strings, and a synced row allocated each of them when it was + parsed. A `json::Object` now keeps its keys apart from its values, in a + list that the rows of a table share, their layout, as Swift keys rows by + column index and JS engines share hidden classes. The parser follows a + recent object's keys while the next object has the same ones, so a + synced row allocates neither its keys nor their list, and comparators + and filters remember where their column was. Run in turns on one + machine: the memory of 100,000 rows went from 181 to 83 MB, + materializing them from 20.9 to 10.5 ms and reading them as structs + from 48.3 to 23.8 ms, 10,000 rows from 678 to 565 µs (as structs 1.99 + to 1.42 ms), a poke of 1000 rows from 7.74 to 5.01 ms, and a first + sync from 17.0 to 15.3 ms. Materializing 100,000 rows went from 3.1 + times the cost per row of 10,000 to 1.9 times. Where Rust is still behind: -- **Big views.** Materializing 100,000 rows takes about 3.2 times as long - per row as 10,000 do (JS's 1.4 times; Swift's scales linearly), and reading them - as structs takes about twice Swift's time. Swift keys a row's columns by index - into its table's layout (see `swift/BENCHMARKS.md`); Rust rows are still - JSON objects keyed by name. +- **The biggest views.** Materializing 100,000 rows costs about 1.9 times + as much per row as 10,000 do, as Swift's did here (2.0 times; JS's 1.5 + times). What is left is spread over building the view's entries (its + list copies them as it grows), comparing rows to place them, and + dropping them. +- **The smallest changes.** Putting a row in its table's layout compares + its column names with the layout's, so a poke of one row went from 11.4 + to 12.4 µs; `pk = N`, an optimistic update, and a round trip are 3–4% + slower than before it.