plugmem_core/memory/shards.rs
1//! How many shards each arena gets.
2//!
3//! There is no correct constant. Too few shards on a large database lengthens
4//! the sorted page directory each insert memmoves within; too many on a small
5//! one buys a 4 KiB page for every shard that holds a single record, which is
6//! how a thousand facts came to occupy fourteen megabytes. The right number
7//! follows the data, so it is derived here rather than configured.
8//!
9//! Three properties this module owes the rest of the engine:
10//!
11//! 1. **It is a pure function of engine state.** `maintain` may re-shard, and
12//! the journal records only *that* a maintenance pass ran, not what layout
13//! it chose — so replay recomputes the layout from the same state and must
14//! reach the same answer. A rule that consulted the clock, the caller's
15//! config, or anything else outside the engine would make a journal replay
16//! diverge from the run it replays.
17//! 2. **It does not depend on pointer width.** Every product below overflows a
18//! 32-bit `usize` at sizes a database can really reach (90M facts is enough),
19//! so the arithmetic is `u64` throughout and the clamp happens before the
20//! single cast. wasm32 and a 64-bit host must agree, for the same replay
21//! reason.
22//! 3. **It reads only true payload, never occupancy.** Sizing from
23//! `pool_bytes()` would feed page slack back into the rule: an over-sharded
24//! arena reports more bytes, which would ask for more shards still. Counts
25//! times slot widths are exact and have no such loop.
26//!
27//! Only the *sharded* structures matter. A [`PostingStore`](crate::index) keeps
28//! its bulk in an unsharded chunk pool and shards only the per-term handles, so
29//! the postings count follows the number of terms and documents, not the size
30//! of the posting lists.
31
32use plugmem_arena::Slot;
33
34use crate::config::{Config, MAX_SHARDS, MIN_SHARDS, SHARD_TARGET_BYTES};
35use crate::index::bm25::DocLenSlot;
36use crate::index::postings::IdListSlot;
37use crate::model::{
38 EdgeHistorySlot, EdgeSlot, EntityByName, EntityRecord, FactAux, FactRecord, TemporalSlot,
39};
40
41/// Shards for one arena group holding `bytes` of slot payload.
42///
43/// Clamping before `next_power_of_two` is deliberate: it keeps the result
44/// inside `[MIN_SHARDS, MAX_SHARDS]` (both powers of two) without the rounding
45/// ever overflowing, and makes the final cast provably in range on a 32-bit
46/// target.
47fn shards_for(bytes: u64) -> usize {
48 let want = bytes
49 .div_ceil(SHARD_TARGET_BYTES as u64)
50 .clamp(MIN_SHARDS as u64, MAX_SHARDS as u64);
51 debug_assert!(want <= MAX_SHARDS as u64);
52 want.next_power_of_two() as usize
53}
54
55/// Payload of `count` records of one slot type, in `u64` so the product cannot
56/// overflow the way `count * T::SIZE` would in a 32-bit `usize`.
57fn payload<T: Slot>(count: u64) -> u64 {
58 count.saturating_mul(T::SIZE as u64)
59}
60
61/// What the engine holds, in records — the only input the layout rule takes.
62///
63/// Counts, not bytes: see the module note on why occupancy must not feed back
64/// into the rule.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub(crate) struct Population {
67 /// Fact records that survive a purge (tombstones excluded).
68 pub facts: u64,
69 /// Entities. Never purged.
70 pub entities: u64,
71 /// Currently open edges, counted once per mirror arena.
72 pub edges: u64,
73 /// Edge history versions, open and closed.
74 pub edge_versions: u64,
75 /// Interned terms carrying postings.
76 pub terms: u64,
77 /// Distinct tags carrying id lists.
78 pub tags: u64,
79 /// Documents in the BM25 length arena.
80 pub documents: u64,
81}
82
83/// Shard counts for every arena group.
84///
85/// Public because it is observable: it appears in [`Stats`](crate::Stats) and
86/// in a [`MaintainReport`](crate::MaintainReport), which is how a caller sees
87/// that a database re-sharded itself and what it moved to.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub struct ShardLayout {
91 /// Facts and their cold sidecar.
92 pub facts: usize,
93 /// Entities, the by-name index and the per-entity fact lists.
94 pub entities: usize,
95 /// Both current edge mirrors and both history mirrors.
96 pub edges: usize,
97 /// The `recorded_at` index.
98 pub temporal: usize,
99 /// BM25 term handles and document lengths, plus the tag lists.
100 pub postings: usize,
101}
102
103impl Default for ShardLayout {
104 /// The floor — what an empty database is laid out with.
105 fn default() -> Self {
106 Self {
107 facts: MIN_SHARDS,
108 entities: MIN_SHARDS,
109 edges: MIN_SHARDS,
110 temporal: MIN_SHARDS,
111 postings: MIN_SHARDS,
112 }
113 }
114}
115
116impl ShardLayout {
117 /// How far a database may drift below its ideal layout before a
118 /// maintenance pass moves it.
119 ///
120 /// Observable rather than internal: it is what tells a caller how much
121 /// under-sharding to expect between rebuilds, and it is the figure a test
122 /// needs to know how much data provokes one. Under-sharding is the cheap
123 /// direction — see [`ShardLayout::group_earns_rebuild`] — so this is wide
124 /// on purpose.
125 pub const GROWTH_MARGIN: usize = GROW_FACTOR;
126
127 /// How far it may drift above before one shrinks it. Narrower, because
128 /// over-sharding is the direction that costs memory.
129 pub const SHRINK_MARGIN: usize = SHRINK_FACTOR;
130
131 /// The layout `population` calls for.
132 ///
133 /// Arenas that share a shard count are sized by the largest of them: that
134 /// one sets the page-directory length, and the smaller ones simply end up
135 /// with fewer pages per shard, which costs nothing.
136 pub(crate) fn for_population(population: &Population) -> Self {
137 let Population {
138 facts,
139 entities,
140 edges,
141 edge_versions,
142 terms,
143 tags,
144 documents,
145 } = *population;
146 Self {
147 // `facts` and `fact_aux`.
148 facts: shards_for(payload::<FactRecord>(facts).max(payload::<FactAux>(facts))),
149 // `entities`, `by_name` and the `entity_facts` id lists.
150 entities: shards_for(
151 payload::<EntityRecord>(entities)
152 .max(payload::<EntityByName>(entities))
153 .max(payload::<IdListSlot>(entities)),
154 ),
155 // Four arenas: both current mirrors and both history mirrors.
156 edges: shards_for(
157 payload::<EdgeSlot>(edges).max(payload::<EdgeHistorySlot>(edge_versions)),
158 ),
159 temporal: shards_for(payload::<TemporalSlot>(facts)),
160 // BM25 term handles, BM25 document lengths, and the tag id lists.
161 postings: shards_for(
162 payload::<IdListSlot>(terms)
163 .max(payload::<DocLenSlot>(documents))
164 .max(payload::<IdListSlot>(tags)),
165 ),
166 }
167 }
168
169 /// The layout a config records.
170 pub(crate) fn of_config(cfg: &Config) -> Self {
171 Self {
172 facts: cfg.shards_facts,
173 entities: cfg.shards_entities,
174 edges: cfg.shards_edges,
175 temporal: cfg.shards_temporal,
176 postings: cfg.shards_postings,
177 }
178 }
179
180 /// Writes this layout into `cfg`.
181 pub(crate) fn apply(&self, cfg: &mut Config) {
182 cfg.shards_facts = self.facts;
183 cfg.shards_entities = self.entities;
184 cfg.shards_edges = self.edges;
185 cfg.shards_temporal = self.temporal;
186 cfg.shards_postings = self.postings;
187 }
188
189 /// Whether one group's move from `have` to `want` earns a rebuild.
190 ///
191 /// Asymmetric, and in the direction the measurement points rather than the
192 /// intuitive one. Being *under*-sharded costs almost nothing: the sweep
193 /// behind [`PAGES_PER_SHARD`](crate::Config) found write throughput flat
194 /// out to 1465 pages per shard, and fewer shards also means fewer pages,
195 /// so it costs less memory too. Being *over*-sharded is the expensive
196 /// direction — that is the whole defect this rule exists to fix. So growth
197 /// waits for a wide margin and shrinking does not.
198 ///
199 /// The margin is not free to widen indefinitely: every crossing is a full
200 /// rebuild, and a bulk load that climbs through the size classes pays one
201 /// at each. A three-doubling band keeps a growing database under two
202 /// rebuilds on the way to a million facts while never letting it past
203 /// ~512 pages per shard, still well inside where the sweep measured
204 /// nothing.
205 ///
206 /// The gap between the two thresholds is also what stops a database
207 /// sitting near a boundary from rebuilding on every pass, and guarantees
208 /// the count a rebuild lands on is itself stable.
209 fn group_earns_rebuild(have: usize, want: usize) -> bool {
210 want >= have.saturating_mul(GROW_FACTOR) || have >= want.saturating_mul(SHRINK_FACTOR)
211 }
212
213 /// Whether the groups a compacting pass rebuilds are far enough out.
214 ///
215 /// Deliberately excludes the edges: a compaction does not touch the edge
216 /// arenas, so including them here would make every pass report work,
217 /// rebuild everything else, leave the edges as they were, and be asked
218 /// again immediately.
219 pub(crate) fn compacted_groups_earn_rebuild(&self, target: &Self) -> bool {
220 [
221 (self.facts, target.facts),
222 (self.entities, target.entities),
223 (self.temporal, target.temporal),
224 (self.postings, target.postings),
225 ]
226 .iter()
227 .any(|&(have, want)| Self::group_earns_rebuild(have, want))
228 }
229
230 /// Whether the four edge arenas are far enough out to be worth repacking.
231 pub(crate) fn edges_earn_rebuild(&self, target: &Self) -> bool {
232 Self::group_earns_rebuild(self.edges, target.edges)
233 }
234
235 /// This layout with the groups a compaction rebuilt taken from `target`,
236 /// and the edges taken from it only when they too were rebuilt.
237 ///
238 /// What a pass may claim in the config is exactly what it actually laid
239 /// out. Claiming more would leave the config describing a shape the file
240 /// does not have — the loader would then read those arenas with the wrong
241 /// shard count, which is corruption, not inefficiency.
242 pub(crate) fn realized(&self, target: &Self, edges_rebuilt: bool) -> Self {
243 Self {
244 facts: target.facts,
245 entities: target.entities,
246 temporal: target.temporal,
247 postings: target.postings,
248 edges: if edges_rebuilt {
249 target.edges
250 } else {
251 self.edges
252 },
253 }
254 }
255}
256
257/// How far a group must outgrow its shard count before growing it: three
258/// doublings. Wide because under-sharding is the cheap direction and each
259/// crossing costs a full rebuild — see [`ShardLayout::group_earns_rebuild`].
260const GROW_FACTOR: usize = 8;
261/// How far it must fall below before shrinking: two doublings. Narrower than
262/// [`GROW_FACTOR`] because over-sharding is the direction that actually costs
263/// memory, and still wide enough that the two thresholds cannot both be true
264/// at once.
265const SHRINK_FACTOR: usize = 4;
266
267impl super::Memory<'_> {
268 /// What this engine currently holds, as the layout rule counts it.
269 ///
270 /// Facts are counted **live**: a rebuild does not re-insert tombstoned
271 /// records, so sizing for them would leave the fresh arenas over-sharded
272 /// by exactly the amount just purged.
273 pub(crate) fn population(&self) -> Population {
274 Population {
275 // `saturating_sub` rather than `-`: the tombstone count is carried
276 // state, and a wrapped subtraction here would not be caught, it
277 // would be *acted on* — a huge count sends the rule straight to
278 // `MAX_SHARDS` and the next pass allocates for it.
279 facts: self.facts.len().saturating_sub(self.tombstones) as u64,
280 entities: self.entities.len() as u64,
281 edges: self.edges_out.len() as u64,
282 edge_versions: self.edges_hist_out.len() as u64,
283 terms: self.bm25.postings().keys() as u64,
284 tags: self.tags_idx.keys() as u64,
285 documents: self.bm25.doc_len_arena().len() as u64,
286 }
287 }
288
289 /// The layout this engine's contents call for.
290 pub(crate) fn target_layout(&self) -> ShardLayout {
291 ShardLayout::for_population(&self.population())
292 }
293
294 /// Whether the arenas are laid out for a database this one is no longer.
295 ///
296 /// **O(1)** — every input is a stored record count, so a host may ask on
297 /// every write. That is the point: without a trigger of its own, a growing
298 /// database would keep the layout it was created with until somebody ran
299 /// `maintain` by hand, and the automatic maintenance that does exist
300 /// (`maintain_every_forgets`) is off by default.
301 ///
302 /// Self-limiting, which is what makes it safe to act on: the thresholds are
303 /// a doubling up and a fourfold drop, so this turns true a handful of times
304 /// over a database's whole life, not continuously.
305 pub fn shard_layout_is_stale(&self) -> bool {
306 let stored = ShardLayout::of_config(&self.cfg);
307 let target = self.target_layout();
308 stored.compacted_groups_earn_rebuild(&target) || stored.edges_earn_rebuild(&target)
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 /// A population, spelled out so the table below reads as data.
317 fn population(
318 facts: u64,
319 entities: u64,
320 edges: u64,
321 edge_versions: u64,
322 terms: u64,
323 tags: u64,
324 ) -> Population {
325 Population {
326 facts,
327 entities,
328 edges,
329 edge_versions,
330 terms,
331 tags,
332 // Every live fact is one BM25 document.
333 documents: facts,
334 }
335 }
336
337 fn layout(p: Population) -> (usize, usize, usize, usize, usize) {
338 let l = ShardLayout::for_population(&p);
339 (l.facts, l.entities, l.edges, l.temporal, l.postings)
340 }
341
342 /// The rule, pinned. Change these numbers only on purpose: they decide how
343 /// much memory every database on every host occupies, and a silent shift
344 /// re-shards every database in the field on its next maintenance pass.
345 #[test]
346 fn the_layout_rule_is_a_fixed_table() {
347 // Empty: nothing to hold, so every group sits on the floor.
348 assert_eq!(layout(population(0, 0, 0, 0, 0, 0)), (4, 4, 4, 4, 4));
349 // One fact. Same floor — a shard costs a page, and one page is plenty.
350 assert_eq!(layout(population(1, 1, 0, 0, 3, 1)), (4, 4, 4, 4, 4));
351 // A personal memory: thousands of facts, still entirely on the floor.
352 // This is the case the whole change exists for.
353 assert_eq!(
354 layout(population(1_000, 50, 20, 25, 1_500, 5)),
355 (4, 4, 4, 4, 4)
356 );
357 assert_eq!(
358 layout(population(5_000, 200, 100, 120, 6_000, 12)),
359 (4, 4, 4, 4, 4)
360 );
361 // 100k facts.
362 assert_eq!(
363 layout(population(100_000, 4_096, 30_000, 60_000, 34_000, 32)),
364 (32, 4, 16, 8, 8)
365 );
366 // The 1M benchmark corpus, the point the target was calibrated on.
367 assert_eq!(
368 layout(population(910_051, 4_096, 287_972, 549_482, 34_604, 64)),
369 (256, 4, 128, 64, 64)
370 );
371 // Ten million: the rule keeps pages-per-shard flat rather than letting
372 // either the directory or the page floor run away.
373 assert_eq!(
374 layout(population(
375 10_000_000, 40_000, 3_000_000, 6_000_000, 200_000, 128
376 )),
377 (2048, 4, 2048, 512, 1024)
378 );
379 }
380
381 /// Products in this rule pass `u32::MAX` at populations a database can
382 /// really reach, so they are computed in `u64` and the clamp happens before
383 /// the one cast. On wasm32 a `usize` product would have wrapped here, and a
384 /// wrapped layout means a journal replays differently there than on the
385 /// host that wrote it.
386 #[test]
387 fn the_rule_does_not_depend_on_pointer_width() {
388 // 100M facts x 48 B = 4.8 GB, past u32::MAX.
389 let big = population(100_000_000, 1_000, 0, 0, 1_000_000, 256);
390 assert!(u64::from(u32::MAX) < 100_000_000u64 * 48);
391 assert_eq!(layout(big).0, 32768);
392 // Saturating all the way up still lands on the ceiling, not on garbage.
393 let absurd = population(u64::MAX, u64::MAX, u64::MAX, u64::MAX, u64::MAX, u64::MAX);
394 assert_eq!(
395 layout(absurd),
396 (MAX_SHARDS, MAX_SHARDS, MAX_SHARDS, MAX_SHARDS, MAX_SHARDS)
397 );
398 }
399
400 /// Every value the rule can produce is a legal shard count.
401 #[test]
402 fn every_produced_count_is_a_usable_shard_count() {
403 let mut facts = 0u64;
404 while facts < 40_000_000 {
405 let l = ShardLayout::for_population(&population(facts, facts / 8, 0, facts, facts, 16));
406 for n in [l.facts, l.entities, l.edges, l.temporal, l.postings] {
407 assert!(n.is_power_of_two(), "{n} is not a power of two");
408 assert!((MIN_SHARDS..=MAX_SHARDS).contains(&n), "{n} out of range");
409 }
410 facts = (facts + 1) * 3 / 2;
411 }
412 }
413
414 fn at(n: usize) -> ShardLayout {
415 ShardLayout {
416 facts: n,
417 entities: n,
418 edges: n,
419 temporal: n,
420 postings: n,
421 }
422 }
423
424 #[test]
425 fn growth_is_eager_and_shrinking_is_lazy() {
426 // Unchanged: nothing to do.
427 assert!(!at(64).compacted_groups_earn_rebuild(&at(64)));
428 // Growth waits for a wide margin: a doubling is not enough.
429 assert!(!at(64).compacted_groups_earn_rebuild(&at(128)));
430 assert!(!at(64).compacted_groups_earn_rebuild(&at(256)));
431 assert!(at(64).compacted_groups_earn_rebuild(&at(512)));
432 // Shrinking waits for four times over, so the two thresholds leave a
433 // band in which neither fires and a database near a boundary rests.
434 assert!(!at(64).compacted_groups_earn_rebuild(&at(32)));
435 assert!(at(64).compacted_groups_earn_rebuild(&at(16)));
436 // And the band means a rebuild cannot bounce back: having acted, the
437 // new count is itself stable against the population that caused it.
438 assert!(!at(16).compacted_groups_earn_rebuild(&at(16)));
439 assert!(!at(128).compacted_groups_earn_rebuild(&at(128)));
440 // One group out of step is enough.
441 let mut skewed = at(64);
442 skewed.postings = 4;
443 assert!(at(64).compacted_groups_earn_rebuild(&skewed));
444 }
445
446 /// The edges are asked about separately because only a repack rebuilds
447 /// them. Were they folded into the compaction question, a database whose
448 /// edges alone were out of layout would rebuild everything else on every
449 /// pass, never fix the edges, and be asked again forever.
450 #[test]
451 fn the_edge_arenas_are_judged_on_their_own() {
452 let mut edges_only = at(64);
453 edges_only.edges = 1024;
454 assert!(!at(64).compacted_groups_earn_rebuild(&edges_only));
455 assert!(at(64).edges_earn_rebuild(&edges_only));
456 }
457
458 /// A pass may only claim the layout it actually built.
459 #[test]
460 fn a_pass_claims_only_what_it_laid_out() {
461 let stored = at(64);
462 let target = at(512);
463 // Compaction alone: four groups move, the untouched edges do not.
464 let without = stored.realized(&target, false);
465 assert_eq!(
466 (
467 without.facts,
468 without.entities,
469 without.temporal,
470 without.postings
471 ),
472 (512, 512, 512, 512)
473 );
474 assert_eq!(without.edges, stored.edges);
475 // With a repack, the edges move too.
476 assert_eq!(stored.realized(&target, true), target);
477 }
478
479 /// The floor and the ceiling are both reachable, and neither is crossed.
480 #[test]
481 fn the_clamp_holds_at_both_ends() {
482 assert_eq!(shards_for(0), MIN_SHARDS);
483 assert_eq!(shards_for(1), MIN_SHARDS);
484 assert_eq!(
485 shards_for(SHARD_TARGET_BYTES as u64 * MIN_SHARDS as u64),
486 MIN_SHARDS
487 );
488 assert_eq!(shards_for(u64::MAX), MAX_SHARDS);
489 // Just past the floor the rule starts following the data.
490 assert_eq!(
491 shards_for(SHARD_TARGET_BYTES as u64 * MIN_SHARDS as u64 + 1),
492 MIN_SHARDS * 2
493 );
494 }
495}