Skip to content

Commit c5317d6

Browse files
authored
perf(zero-rust): store a row's columns by its table's layout (#172)
A json::Object keeps its keys apart from its values, and the rows of a table share one key list, their layout. The parser reuses a recent object's keys when the next has the same ones, so a synced row allocates neither its column names nor their list; comparators and filters remember where their column is. Same-machine medians: memory for 100k rows 181 -> 83 MB (Swift 127, JS 239); materialize 100k 20.9 -> 10.5 ms (Swift 16.8, JS 30.1); 100k as structs 48.3 -> 23.8 ms (Swift 37.4). Conformance unchanged, 0 failures. Adds a zero-rust patch changeset. Amp-Thread-ID: https://ampcode.com/threads/T-01a1c6b9-fc99-73a1-bf70-e71fd484042c
1 parent 3a700df commit c5317d6

10 files changed

Lines changed: 573 additions & 172 deletions

File tree

‎.changeset/zero-rust-row-layout.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-rust': patch
3+
---
4+
5+
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.

‎rust/BENCHMARKS.md‎

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -37,52 +37,56 @@ gate on. CI builds them with the workspace (`cargo clippy --all-targets`,
3737

3838
## Results
3939

40-
Medians; lower is better. Rust and JS ran one after the other on one
41-
machine: an 8-core Intel Xeon at 2.6 GHz with 16 GB, Linux 6.1, Rust 1.99.1
42-
(release build) and Node 24.22 under vitest. The Swift column is
43-
`swift/BENCHMARKS.md`'s Linux column, from the same kind of machine but not
44-
the same run, with Swift 6.2.4; its cells marked † are from before Swift's
45-
last performance pass.
46-
47-
In short: Rust is 2–5× faster than JS on views of up to 10,000 rows, 1.3×
48-
on 100,000, 18× on an optimistic update, and 20–40× on hashing (where
49-
vitest flatters Rust), and uses about three quarters of JS's memory. It is
50-
at or ahead of Swift on views of up to 10,000 rows, single-row changes,
51-
and pokes, and behind Swift on materializing 100,000 rows and on reading
52-
rows as structs (see What they found).
53-
54-
| Benchmark | JS (Node) | Rust | Swift (Linux) |
55-
| ------------------------------------------ | --------------------------- | ------------------------ | -------------- |
56-
| zero.bench: all 1000 rows x 10 columns | 236 µs | 56.6 µs | 70.4 µs |
57-
| zero.bench: pk = N | 11.0 µs | 4.58 µs | 8.39 µs |
58-
| zero.bench: lower 500 rows | 152 µs | 58.6 µs | 89.7 µs |
59-
| materialize 1,000 rows | 242 µs | 50.5 µs | 63.2 µs |
60-
| materialize 10,000 rows | 2.15 ms | 716 µs | 757 µs |
61-
| materialize 100,000 rows | 29.7 ms | 23.2 ms | 7.07 ms |
62-
| memory for 100,000 rows and a view of them | 245 MB | 181 MB | 128 MB† |
63-
| optimistic update with a 1000-row view | 274 µs | 15.4 µs | 23 µs† |
64-
| h32, 64 / 1024 / 8192 chars | 433 ns / 3.87 µs / 32.3 µs | 10 ns / 151 ns / 1.21 µs | |
65-
| h64, 64 / 1024 / 8192 chars | 703 ns / 6.37 µs / 51.5 µs | 23 ns / 303 ns / 2.42 µs | – / 318 ns / – |
66-
| h128, 64 / 1024 / 8192 chars | 1.29 µs / 12.5 µs / 95.3 µs | 42 ns / 606 ns / 5.60 µs | |
40+
Medians of three runs; lower is better. Rust and Swift ran in turns on one
41+
machine, an 8-core Intel Xeon at 2.6 GHz with 16 GB, Linux 6.1, with Rust
42+
1.99.1 (release build) and Swift 6.2.4 (`swift build -c release` of this
43+
checkout); JS ran just before, with Node 24.22 under vitest. The JS
44+
`zero.bench` and hash rows are from an earlier run on the same machine.
45+
46+
`swift/BENCHMARKS.md`'s Linux column has Swift 2–3 times faster than it ran
47+
here (materializing 100,000 rows in 7.07 ms, not 16.8 ms), from what it
48+
calls the same kind of machine; JS ran here as fast as that document has
49+
it; why isn't known yet. Against that column, Rust is behind Swift on
50+
materializing 100,000 rows (10.5 ms against 7.07 ms) and level with it on
51+
reading them as structs (23.8 ms against 24.8 ms).
52+
53+
In short: on this machine Rust is 2–5× faster than JS and 1.5–4.6× faster
54+
than Swift on views of 1,000 to 100,000 rows, 17× faster than JS on an
55+
optimistic update, and 20–40× on hashing (where vitest flatters Rust). It
56+
uses about a third of JS's memory and two thirds of Swift's.
57+
58+
| Benchmark | JS (Node) | Rust | Swift (Linux) |
59+
| ------------------------------------------ | --------------------------- | ------------------------ | ------------------------ |
60+
| zero.bench: all 1000 rows x 10 columns | 236 µs | 52.1 µs | 91.6 µs |
61+
| zero.bench: pk = N | 11.0 µs | 4.60 µs | 21.3 µs |
62+
| zero.bench: lower 500 rows | 152 µs | 54.6 µs | 130 µs |
63+
| materialize 1,000 rows | 229 µs | 46.3 µs | 85.6 µs |
64+
| materialize 10,000 rows | 2.06 ms | 565 µs | 863 µs |
65+
| materialize 100,000 rows | 30.1 ms | 10.5 ms | 16.8 ms |
66+
| memory for 100,000 rows and a view of them | 239 MB | 83 MB | 127 MB |
67+
| optimistic update with a 1000-row view | 274 µs | 15.9 µs | 39.5 µs |
68+
| 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 |
69+
| 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 |
70+
| 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 |
6771

6872
As `hash.bench.ts` notes, vitest keeps imported functions from being
6973
inlined, so its JS hash numbers are pessimistic; bundled, they are a few
70-
times faster. The JS memory figure is the change in resident memory, as
71-
Rust's is (`VmRSS`), from one run.
74+
times faster. The memory figures are the change in resident memory
75+
(`VmRSS`), from one run.
7276

7377
Rust and Swift only:
7478

7579
| Benchmark | Rust | Swift (Linux) |
7680
| --------------------------------------------------------- | ------- | ------------- |
77-
| materialize 1,000 rows as structs | 142 µs | 182 µs |
78-
| materialize 10,000 rows as structs | 3.50 ms | 1.94 ms |
79-
| materialize 100,000 rows as structs | 52.4 ms | 24.8 ms |
80-
| poke putting 1 row, 1000-row view | 11.4 µs | 29 µs† |
81-
| poke putting 100 rows, 1000-row view | 647 µs | 1.00 ms† |
82-
| poke putting 1000 rows, 1000-row view | 8.83 ms | 9.82 ms† |
83-
| round trip (update, push, confirming poke), 1000-row view | 40.0 µs | 80 µs† |
84-
| first sync: zbugs poke of 2,058 rows, issue list view | 19.0 ms | |
85-
| first sync: the same poke, no view | 9.91 ms | |
81+
| materialize 1,000 rows as structs | 121 µs | 240 µs |
82+
| materialize 10,000 rows as structs | 1.42 ms | 2.41 ms |
83+
| materialize 100,000 rows as structs | 23.8 ms | 37.4 ms |
84+
| poke putting 1 row, 1000-row view | 12.4 µs | 49.1 µs |
85+
| poke putting 100 rows, 1000-row view | 485 µs | 1.18 ms |
86+
| poke putting 1000 rows, 1000-row view | 5.01 ms | 10.8 ms |
87+
| round trip (update, push, confirming poke), 1000-row view | 39.7 µs | 118 µs |
88+
| first sync: zbugs poke of 2,058 rows, issue list view | 15.3 ms | 42.9 ms |
89+
| first sync: the same poke, no view | 8.86 ms | 24.4 ms |
8690

8791
`first sync` replays zbugs' first sync as the zbugs-swift app got it, with
8892
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.
117121
181 MB, a first sync from 25.7 to 19.0 ms (17.9 to 9.9 ms without a
118122
view), a poke of 1000 rows from 15.0 to 8.8 ms, and materializing
119123
100,000 rows from 32 to 23 ms.
124+
- **Every row had its own column names.** A row kept a copy of each of
125+
its column names next to its values, so 100,000 rows of 10 columns kept
126+
a million strings, and a synced row allocated each of them when it was
127+
parsed. A `json::Object` now keeps its keys apart from its values, in a
128+
list that the rows of a table share, their layout, as Swift keys rows by
129+
column index and JS engines share hidden classes. The parser follows a
130+
recent object's keys while the next object has the same ones, so a
131+
synced row allocates neither its keys nor their list, and comparators
132+
and filters remember where their column was. Run in turns on one
133+
machine: the memory of 100,000 rows went from 181 to 83 MB,
134+
materializing them from 20.9 to 10.5 ms and reading them as structs
135+
from 48.3 to 23.8 ms, 10,000 rows from 678 to 565 µs (as structs 1.99
136+
to 1.42 ms), a poke of 1000 rows from 7.74 to 5.01 ms, and a first
137+
sync from 17.0 to 15.3 ms. Materializing 100,000 rows went from 3.1
138+
times the cost per row of 10,000 to 1.9 times.
120139

121140
Where Rust is still behind:
122141

123-
- **Big views.** Materializing 100,000 rows takes about 3.2 times as long
124-
per row as 10,000 do (JS's 1.4 times; Swift's scales linearly), and reading them
125-
as structs takes about twice Swift's time. Swift keys a row's columns by index
126-
into its table's layout (see `swift/BENCHMARKS.md`); Rust rows are still
127-
JSON objects keyed by name.
142+
- **The biggest views.** Materializing 100,000 rows costs about 1.9 times
143+
as much per row as 10,000 do, as Swift's did here (2.0 times; JS's 1.5
144+
times). What is left is spread over building the view's entries (its
145+
list copies them as it grows), comparing rows to place them, and
146+
dropping them.
147+
- **The smallest changes.** Putting a row in its table's layout compares
148+
its column names with the layout's, so a poke of one row went from 11.4
149+
to 12.4 µs; `pk = N`, an optimistic update, and a round trip are 3–4%
150+
slower than before it.

‎rust/zero-client/src/client/ivm_branch.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,35 @@ use std::sync::Arc;
2222
/// aggregate table, shared by a branch and its forks.
2323
type Optimism = Rc<RefCell<HashMap<String, HashMap<String, OptimisticAggregate>>>>;
2424

25+
/// The layout of each table's rows: a row of each, whose keys the next rows
26+
/// with the same columns in the same order share (`Object::share_keys`), so
27+
/// a table's rows keep one list of column names. A row with other columns,
28+
/// or in another order, becomes the layout.
29+
#[derive(Default)]
30+
pub(crate) struct Layouts(RefCell<HashMap<String, Object>>);
31+
32+
impl Layouts {
33+
/// Puts `row`, a row of `table`, in the table's layout.
34+
pub(crate) fn share(&self, table: &str, row: &mut Object) {
35+
let mut layouts = self.0.borrow_mut();
36+
match layouts.get(table) {
37+
Some(layout) if row.share_keys(layout) => {}
38+
_ => {
39+
layouts.insert(table.to_owned(), row.clone());
40+
}
41+
}
42+
}
43+
}
44+
2545
/// The IVM sources of one state of the replica. The main branch feeds the
2646
/// app's views; mutators run against forks, so a rebase can replay them
2747
/// over a newer server snapshot without touching the views.
2848
pub(crate) struct IvmBranch {
2949
sources: RefCell<HashMap<String, Option<Rc<MemorySource>>>>,
3050
schema: Arc<Schema>,
3151
optimism: Optimism,
52+
/// Shared by a branch and its forks.
53+
pub(crate) layouts: Rc<Layouts>,
3254
/// Only the main branch registers optimistic aggregates and releases
3355
/// aggregate sources (see `aggregate_source`).
3456
is_fork: bool,
@@ -48,6 +70,7 @@ impl IvmBranch {
4870
sources: RefCell::default(),
4971
schema,
5072
optimism: Rc::default(),
73+
layouts: Rc::default(),
5174
is_fork: false,
5275
})
5376
}
@@ -158,6 +181,7 @@ impl IvmBranch {
158181
sources: RefCell::new(sources),
159182
schema: self.schema.clone(),
160183
optimism: self.optimism.clone(),
184+
layouts: self.layouts.clone(),
161185
is_fork: true,
162186
})
163187
}

‎rust/zero-client/src/client/poke_handler.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! (`packages/zero-client/src/client/zero-poke-handler.ts`). The engine
44
//! schedules the applies.
55
6+
use super::ivm_branch::Layouts;
67
use super::replica_store::KvPatchOp;
78
use crate::decode::JsonCodable;
89
use crate::json::{self, Value, number};
@@ -82,6 +83,8 @@ pub(crate) struct PokeHandler {
8283
client_id: String,
8384
schema: Arc<Schema>,
8485
server_to_client: NameMapper,
86+
/// The layout of each table's rows from the server.
87+
layouts: Layouts,
8588
receiving: Option<Receiving>,
8689
buffer: Vec<Received>,
8790
pub(crate) playback_running: bool,
@@ -91,6 +94,7 @@ impl PokeHandler {
9194
pub(crate) fn new(client_id: String, schema: Arc<Schema>) -> Self {
9295
PokeHandler {
9396
server_to_client: NameMapper::server_to_client(&schema),
97+
layouts: Layouts::default(),
9498
client_id,
9599
schema,
96100
receiving: None,
@@ -293,10 +297,11 @@ impl PokeHandler {
293297
let Some(table) = self.server_to_client.table_name_if_known(table_name) else {
294298
return Ok(None);
295299
};
296-
let row = self
300+
let mut row = self
297301
.server_to_client
298302
.row(table_name, value)
299303
.map_err(mapping)?;
304+
self.layouts.share(&table, &mut row);
300305
KvPatchOp::Put {
301306
key: self.key(&table, &row)?,
302307
value: Value::Object(row),

‎rust/zero-client/src/client/transaction.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ impl Transaction {
175175
row.insert(column.clone(), Value::Null);
176176
}
177177
}
178+
self.branch.layouts.share(table_name, &mut row);
178179
let row: Row = Rc::new(row);
179180
self.write
180181
.borrow_mut()
@@ -209,6 +210,7 @@ impl Transaction {
209210
for (k, v) in value.iter() {
210211
next.insert(k.clone(), v.clone());
211212
}
213+
self.branch.layouts.share(table_name, &mut next);
212214
self.write
213215
.borrow_mut()
214216
.set(&key, Value::Object(next.clone()));

‎rust/zero-client/src/ivm/data.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use super::Row;
55
use crate::ast::{Direction, OrderPart};
66
use crate::compare;
77
use crate::json::Value;
8+
use std::cell::Cell;
89
use std::cmp::Ordering;
910
use std::rc::Rc;
1011

@@ -47,13 +48,13 @@ pub fn identical(a: Option<&Value>, b: Option<&Value>) -> bool {
4748

4849
/// `makeComparator(order, reverse)`.
4950
pub fn make_comparator(order: &[OrderPart], reverse: bool) -> Comparator {
50-
let parts: Vec<(String, bool)> = order
51+
let parts: Vec<(String, bool, Cell<usize>)> = order
5152
.iter()
52-
.map(|p| (p.field.clone(), p.direction == Direction::Asc))
53+
.map(|p| (p.field.clone(), p.direction == Direction::Asc, Cell::new(0)))
5354
.collect();
5455
Rc::new(move |a: &Row, b: &Row| {
55-
for (field, asc) in &parts {
56-
let c = compare_values(a.get(field), b.get(field));
56+
for (field, asc, hint) in &parts {
57+
let c = compare_values(a.get_hinted(field, hint), b.get_hinted(field, hint));
5758
if c != Ordering::Equal {
5859
let c = if *asc { c } else { c.reverse() };
5960
return if reverse { c.reverse() } else { c };

‎rust/zero-client/src/ivm/predicate.rs‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::compare;
77
use crate::json::{Value, number};
88
use crate::like::LikePattern;
99
use crate::query::simplify_condition;
10+
use std::cell::Cell;
1011
use std::cmp::Ordering;
1112
use std::rc::Rc;
1213

@@ -69,15 +70,15 @@ fn simple_predicate(
6970
Rc::new(move |_| result)
7071
}
7172
ValuePosition::Json { column, path } => {
72-
let (column, path) = (column.clone(), path.clone());
73+
let (column, path, hint) = (column.clone(), path.clone(), Cell::new(0));
7374
Rc::new(move |row| {
74-
let v = json_value_at(&path, row.get(&column));
75+
let v = json_value_at(&path, row.get_hinted(&column, &hint));
7576
identical(Some(v), rhs.as_ref()) != is_not
7677
})
7778
}
7879
ValuePosition::Column(name) => {
79-
let name = name.clone();
80-
Rc::new(move |row| identical(row.get(&name), rhs.as_ref()) != is_not)
80+
let (name, hint) = (name.clone(), Cell::new(0));
81+
Rc::new(move |row| identical(row.get_hinted(&name, &hint), rhs.as_ref()) != is_not)
8182
}
8283
ValuePosition::Static { .. } => unreachable!("checked above"),
8384
});
@@ -107,9 +108,9 @@ fn simple_predicate(
107108
},
108109
};
109110
let negated = matches!(op, Ne | NotLike | NotIlike | NotIn);
110-
let (column, path) = (column.clone(), path.clone());
111+
let (column, path, hint) = (column.clone(), path.clone(), Cell::new(0));
111112
Rc::new(move |row| {
112-
let lhs = json_value_at(&path, row.get(&column));
113+
let lhs = json_value_at(&path, row.get_hinted(&column, &hint));
113114
if lhs.is_null() {
114115
return false;
115116
}
@@ -120,8 +121,8 @@ fn simple_predicate(
120121
})
121122
}
122123
ValuePosition::Column(name) => {
123-
let name = name.clone();
124-
Rc::new(move |row| match row.get(&name) {
124+
let (name, hint) = (name.clone(), Cell::new(0));
125+
Rc::new(move |row| match row.get_hinted(&name, &hint) {
125126
None | Some(Value::Null) => false,
126127
Some(lhs) => imp(lhs),
127128
})

0 commit comments

Comments
 (0)