mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
#1172: make the id cache cheap to rebuild, and walk on a stale path
The previous commit rebuilt the cache as soon as a cached path went stale, and built it as one Vec path per node, hashed with SipHash. An edit that removes many objects one by one (Pathfinder Unite of 1,000 paths) went stale on every removal, and Unite took 105 ms instead of 42 ms. - The cache is now one flat table of (parent entry, index among siblings) with a one-multiply id hasher, so a rebuild is about one walk and allocates nothing per node. - A stale path walks, as an id the cache doesn't have does, and rebuilds only after a few. Either way a lookup stays within a small factor of a walk. At 1,000 paths, Unite takes 45 ms against main's 42. At 100,000 paths the gains of the cache hold: hit test 1.7 → 0.36 ms, handle drag 6.5 → 2.7 ms per move. The cached-lookup tests pass unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
1 parent
7d79a32748
commit
2bb7646a39
2 files changed
+78
-27
No files matched your search
+70
-13
@@ -15,16 +15,72 @@
|
||||
//! duplicated ids come from damaged files, which are cached afresh when opened.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::{Node, NodeId};
|
||||
|
||||
/// Each id's index path; `None` for an id that appears more than once.
|
||||
pub(crate) type Paths = HashMap<NodeId, Option<Vec<usize>>>;
|
||||
/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the
|
||||
/// default SipHash dominated the cost of building the cache.
|
||||
#[derive(Default)]
|
||||
pub struct IdHasher(u64);
|
||||
|
||||
/// Ids found by walking because the cache didn't have them, before the cache is rebuilt: each
|
||||
/// rebuild costs a few walks, so rebuilding only after a few keeps a lookup's cost within a small
|
||||
/// factor of a walk whatever the edit pattern (say, add an object, look it up, again and again).
|
||||
impl Hasher for IdHasher {
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
}
|
||||
fn write_u64(&mut self, v: u64) {
|
||||
self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
}
|
||||
fn finish(&self) -> u64 {
|
||||
self.0 ^ (self.0 >> 29)
|
||||
}
|
||||
}
|
||||
|
||||
/// A map keyed by node id with [`IdHasher`].
|
||||
pub type IdMap<V> = HashMap<NodeId, V, BuildHasherDefault<IdHasher>>;
|
||||
/// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among
|
||||
/// its siblings. One flat table, so building it allocates nothing per node.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Paths {
|
||||
/// Each id's entry in `at`; `None` for an id that appears more than once.
|
||||
ids: IdMap<Option<u32>>,
|
||||
at: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Paths {
|
||||
/// The index path of `id`: `Some(None)` when the id appears more than once, `None` when it
|
||||
/// isn't in the table.
|
||||
pub(crate) fn get(&self, id: &NodeId) -> Option<Option<Vec<usize>>> {
|
||||
let e = (*self.ids.get(id)?)?;
|
||||
let mut path = Vec::new();
|
||||
let mut cur = e;
|
||||
// Each step goes to a parent entry, made before its children: at most `at.len()` steps.
|
||||
for _ in 0..=self.at.len() {
|
||||
let Some(&(parent, i)) = self.at.get(cur as usize) else { return Some(None) };
|
||||
path.push(i as usize);
|
||||
if parent == u32::MAX {
|
||||
path.reverse();
|
||||
return Some(Some(path));
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
Some(None)
|
||||
}
|
||||
pub(crate) fn contains_key(&self, id: &NodeId) -> bool {
|
||||
self.ids.contains_key(id)
|
||||
}
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.ids.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookups that had to walk (the cache didn't have the id, or its path no longer checks out)
|
||||
/// before the cache is rebuilt: a rebuild costs a few walks, so rebuilding only after a few keeps
|
||||
/// a lookup's cost within a small factor of a walk whatever the edit pattern (say, delete an
|
||||
/// object and look the next one up, again and again).
|
||||
const MISSES_PER_REBUILD: u32 = 4;
|
||||
|
||||
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
|
||||
@@ -39,18 +95,18 @@ impl IdIndex {
|
||||
|
||||
/// Rebuild the cache from `layers` (one walk) and return it.
|
||||
pub(crate) fn rebuild(&self, layers: &[Arc<Node>]) -> Arc<Paths> {
|
||||
fn walk(nodes: &[Arc<Node>], path: &mut Vec<usize>, out: &mut Paths) {
|
||||
fn walk(nodes: &[Arc<Node>], parent: u32, out: &mut Paths) {
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
path.push(i);
|
||||
out.entry(n.id).and_modify(|e| *e = None).or_insert_with(|| Some(path.clone()));
|
||||
let e = out.at.len() as u32;
|
||||
out.at.push((parent, i as u32));
|
||||
out.ids.entry(n.id).and_modify(|x| *x = None).or_insert(Some(e));
|
||||
if let Some(ch) = n.children() {
|
||||
walk(ch, path, out);
|
||||
walk(ch, e, out);
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
let mut out = Paths::new();
|
||||
walk(layers, &mut Vec::new(), &mut out);
|
||||
let mut out = Paths::default();
|
||||
walk(layers, u32::MAX, &mut out);
|
||||
let out = Arc::new(out);
|
||||
if let Ok(mut g) = self.0.write() {
|
||||
*g = Some(out.clone());
|
||||
@@ -59,7 +115,8 @@ impl IdIndex {
|
||||
out
|
||||
}
|
||||
|
||||
/// Note an id found by walking that the cache didn't have; true when it's time to rebuild.
|
||||
/// Note a lookup that had to walk (an id the cache didn't have, or a path that no longer
|
||||
/// checks out); true when it's time to rebuild.
|
||||
pub(crate) fn missed(&self) -> bool {
|
||||
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 >= MISSES_PER_REBUILD
|
||||
}
|
||||
|
||||
+8
-14
@@ -772,21 +772,15 @@ impl Document {
|
||||
// The cached path, while the node there still has the id ([`id_index`]).
|
||||
let cached = self.index.get();
|
||||
match cached.as_ref().and_then(|m| m.get(&id)) {
|
||||
Some(Some(p)) if self.node_at(p).is_some_and(|n| n.id == id) => return Some(p.clone()),
|
||||
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == id) => return Some(p),
|
||||
Some(None) => return self.walk_index_path(id),
|
||||
Some(Some(_)) => {
|
||||
// Stale: the tree changed. Rebuild, then answer from it (what a walk gives now).
|
||||
return match self.index.rebuild(&self.layers).get(&id) {
|
||||
Some(Some(p)) => Some(p.clone()),
|
||||
Some(None) => self.walk_index_path(id),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
None => {}
|
||||
_ => {}
|
||||
}
|
||||
// Not cached: deleted, added since, or no cache yet.
|
||||
// A stale path (the tree changed), an id the cache doesn't have (deleted or added since),
|
||||
// or no cache yet: walk, and rebuild now and then.
|
||||
let found = self.walk_index_path(id);
|
||||
if cached.is_none() || (found.is_some() && self.index.missed()) {
|
||||
let stale = cached.as_ref().is_some_and(|m| m.contains_key(&id));
|
||||
if cached.is_none() || ((stale || found.is_some()) && self.index.missed()) {
|
||||
self.index.rebuild(&self.layers);
|
||||
}
|
||||
found
|
||||
@@ -842,8 +836,8 @@ impl Document {
|
||||
// share of the document, one walk is quicker than checking each cached path.
|
||||
if let Some(cache) = self.index.get().filter(|c| want.len().saturating_mul(8) < c.len()) {
|
||||
want.retain(|id| match cache.get(id) {
|
||||
Some(Some(p)) if self.node_at(p).is_some_and(|n| n.id == *id) => {
|
||||
out.insert(*id, p.clone());
|
||||
Some(Some(p)) if self.node_at(&p).is_some_and(|n| n.id == *id) => {
|
||||
out.insert(*id, p);
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
|
||||
Reference in new issue
Block a user