Tighten up mark-and-compact GC.

Now it is in-place and should take less space for at least some workloads. See
TODOs left behind for thoughts on future directions it may make sense to go in
for performance tuning.

This still needs better test coverage, but these code improvements should make
it clear that there are no obvious fundamental flaws to this approach.
This commit is contained in:
Stu Black 2025-01-09 12:24:46 -05:00
parent d94fd76d94
commit 651ce5fd84
2 changed files with 268 additions and 189 deletions

View File

@ -1,3 +1,5 @@
use std::hash::Hash;
/// Internal edge identifier.
///
/// This type is not exported by the crate because it does not identify the
@ -7,8 +9,8 @@
pub(crate) struct EdgeId(pub usize);
impl EdgeId {
/// Converts an `EdgeId` to a usize that is guaranteed to be unique within a
/// graph.
/// Converts an `EdgeId` to a usize that is guaranteed to be unique among all
/// edges in a graph.
pub fn as_usize(self) -> usize {
let EdgeId(x) = self;
x
@ -33,6 +35,32 @@ impl symbol_map::SymbolId for VertexId {
}
}
// Decoding to/from a usize is done during garbage collection.
impl From<EdgeId> for usize {
fn from(id: EdgeId) -> usize {
id.0
}
}
impl From<usize> for EdgeId {
fn from(id: usize) -> Self {
EdgeId(id)
}
}
impl From<VertexId> for usize {
fn from(id: VertexId) -> usize {
id.0
}
}
impl From<usize> for VertexId {
fn from(id: usize) -> Self {
VertexId(id)
}
}
/// Internal type for graph edges.
///
/// The Hash, Ord, and Eq implementations will conflate parallel edges with

View File

@ -7,40 +7,158 @@
//! that maps from game states to their IDs.
use std::cmp::Eq;
use std::collections::VecDeque;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
use std::mem;
use std::slice;
use crate::base::{EdgeId, VertexId};
use crate::Graph;
use symbol_map::indexing::{HashIndexing, Indexing};
use symbol_map::SymbolId;
/// Permutes `data` so that element `i` of data is reassigned to be at index
/// `f(i)`.
///
/// Elements `j` of `data` for which `f(j)` is `None` are discarded.
fn permute_compact<T>(data: &mut Vec<T>, f: impl Fn(usize) -> Option<usize>) {
if data.is_empty() {
return;
/// Tracks graph object id reassignments. Provides methods for building them up
/// as you traverse the graph and for compacting graph data after reassignments
/// have been determined.
#[derive(Default)]
struct Reassignments<T: Copy + Eq + Hash + From<usize> + Into<usize>> {
/// Maps from the current id to the new id.
map: HashMap<T, T>,
}
/// Returned by `Reassignments::reassign` to indicate whether a new assignment
/// was made.
enum Reassignment<T> {
// A new assignment was made.
New(T),
/// No new assignment was made.
Extant(T),
}
impl<T: Copy + Eq + From<usize> + Hash + Into<usize>> Reassignments<T> {
fn with_capacity(cap: usize) -> Self {
Reassignments {
map: HashMap::with_capacity(cap),
}
}
// TODO: We should benchmark doing this in-place vs. via moving.
let mut new_data: Vec<T> = Vec::with_capacity(data.len());
let buffer: &mut [mem::MaybeUninit<T>] =
unsafe { slice::from_raw_parts_mut(new_data.as_mut_ptr() as _, new_data.capacity()) };
let compacted = data
.drain(..)
.enumerate()
.filter_map(|(old_index, t)| f(old_index).map(|new_index| (new_index, t)));
let mut retained_count = 0;
for (new_index, t) in compacted {
buffer[new_index].write(t);
retained_count += 1;
fn is_empty(&self) -> bool {
self.map.is_empty()
}
fn len(&self) -> usize {
self.map.len()
}
fn reassign(&mut self, old: T) -> Reassignment<T> {
let next_id = self.len();
match self.map.entry(old) {
Entry::Occupied(e) => Reassignment::Extant(*e.get()),
Entry::Vacant(e) => {
let next_id: T = next_id.into();
e.insert(next_id);
Reassignment::New(next_id)
}
}
}
fn get(&self, id: T) -> Option<T> {
self.map.get(&id).copied()
}
/// Permutes `data` according to this object's reassignments. Elements of
/// `data` which have no reassignment are discarded, and `data` is shortened
/// accordingly.
fn permute_compact<D>(mut self, data: &mut Vec<D>) {
if data.is_empty() {
return;
}
if self.is_empty() {
data.clear();
return;
}
// This in-place approach to compaction takes O(n) space and O(n) time:
//
// Space: We may have to track a reassignment for each item. (But we may
// never actually have to keep all reassignments in memory at once, so
// memory footprint may be sublinear in expectation.)
//
// Time: At each iteration of the compaction loop, at least one item is
// moved to its destination. It should take at most a linear number of
// iterations to move all items to their destinations.
//
// TODO: this could also be done by allocating a buffer, moving undiscarded
// items from data into it, and then overwriting data. That approach also
// takes a linear amount of space, but it requires more space because a
// buffer for data items must be allocated, and data items are larger than
// index reassignments. It also *always* allocates that whole buffer, so
// memory footprint is always worst-case.
//
// If we eventually get some real workloads going, it may be worth running
// memory and speed benchmarks to see which approach is preferable.
// Elements that are to be discarded are swapped into a discard region,
// which grows from the end downward.
let mut discard_end = data.len();
let new_len = self.len();
let mut i = 0;
while i < data.len() {
// Repeatedly swap items with data[i] until the item at data[i] is the one
// that belongs there. The proceed to data[i + 1].
let current_id: T = i.into();
match self.get(current_id) {
Some(new_id) if new_id == current_id => {
// data[i] is already correct.
self.map.remove(&new_id);
i += 1;
}
Some(new_id) => {
// Move data[i] to where it belongs.
data.swap(current_id.into(), new_id.into());
if let Some(new_assignment) = self.get(new_id) {
// Update reassignments so that the item now at data[i] will be put
// in the right place.
self.map.insert(current_id, new_assignment);
} else {
// The item now at data[i] should have no mapping (and will be moved
// to the discard region in the next iteration).
self.map.remove(&current_id);
}
// The item we just moved should stay where it is.
self.map.insert(new_id, new_id);
}
None if i < new_len => {
// data[i] is to be discarded, but it is outside of the discard
// region. Move it there.
discard_end -= 1;
let new_id = T::from(discard_end);
data.swap(current_id.into(), discard_end);
if let Some(new_assignment) = self.get(new_id) {
self.map.insert(current_id, new_assignment);
}
self.map.insert(new_id, new_id);
}
None => {
// data[i] is to be discarded and is already within the discard
// region.
i += 1;
}
}
}
data.truncate(new_len);
data.shrink_to_fit();
}
}
impl<T: Copy + Eq + From<usize> + Hash + Into<usize> + Ord> Reassignments<T> {
#[cfg(test)]
fn ordered_assignments(&self) -> Vec<(T, T)> {
let mut v: Vec<_> = self.map.iter().map(|(x, y)| (*x, *y)).collect();
v.sort();
v
}
unsafe { new_data.set_len(retained_count) }; // TODO: Maybe do this after each swap?
*data = new_data;
}
/// Garbage collector state.
@ -51,10 +169,8 @@ where
A: 'a,
{
graph: &'a mut Graph<T, S, A>,
marked_state_count: usize,
marked_arc_count: usize,
state_id_map: Vec<Option<VertexId>>,
arc_id_map: Vec<Option<EdgeId>>,
state_assignments: Reassignments<VertexId>,
arc_assignments: Reassignments<EdgeId>,
frontier: VecDeque<VertexId>,
}
@ -79,156 +195,98 @@ where
/// Creates a new mark-and-compact garbage collector with empty initial state.
fn new(graph: &'a mut Graph<T, S, A>) -> Self {
let empty_states = vec![None; graph.vertices.len()];
let empty_arcs = vec![None; graph.arcs.len()];
let vertex_count = graph.vertices.len();
let arc_count = graph.arcs.len();
Collector {
graph,
marked_state_count: 0,
marked_arc_count: 0,
state_id_map: empty_states,
arc_id_map: empty_arcs,
state_assignments: Reassignments::with_capacity(vertex_count),
arc_assignments: Reassignments::with_capacity(arc_count),
frontier: VecDeque::new(),
}
}
/// Traverses graph components reachable from `roots` and marks them as
/// reachable. Also builds a new graph component addressing scheme that
/// reassigns `VertexId` and `EdgeId` values.
///
/// As side effects, arc sources and vertex children are updated to use the
/// new addressing scheme.
/// Traverses graph components reachable from `roots` and populates
/// `self.state_assignments` and `self.arc_assignments` with assignments for
/// all graph components that are reachable.
fn mark(&mut self, roots: &[VertexId]) {
for id in roots.iter() {
Self::remap_state_id(&mut self.state_id_map, &mut self.marked_state_count, *id);
self.frontier.push_back(*id);
// TODO: earlier versions of this method rewrote part of the graph eagerly
// (updating vertex child arc ids and arc source ids here, when new ids are
// generated, rather than looking them up in a hashtable in sweep()). This
// could provide a speedup by reducing hashtable lookups. If hashtable
// lookups become a bottleneck, it may be worth returning to that approach.
for r in roots.iter() {
self.state_assignments.reassign(*r);
}
while self.mark_next() {}
}
self.frontier.extend(roots);
while let Some(state_id) = self.frontier.pop_front() {
self.state_assignments.reassign(state_id);
/// Looks up the mapping between old and new VertexIds. May update
/// `state_id_map` with a new mapping, given that we have remapped
/// `marked_state_count` VertexIds so far.
fn remap_state_id(
state_id_map: &mut [Option<VertexId>],
marked_state_count: &mut usize,
old_state_id: VertexId,
) -> VertexId {
let index = old_state_id.as_usize();
if let Some(new_state_id) = state_id_map[index] {
return new_state_id;
}
let new_state_id = VertexId(*marked_state_count);
state_id_map[index] = Some(new_state_id);
*marked_state_count += 1;
new_state_id
}
/// Looks up the mapping between old and new EdgeIds. May update
/// `arc_id_map` with a new mapping, given that we have remapped
/// `marked_arc_count` EdgeIds so far.
fn remap_arc_id(
arc_id_map: &mut [Option<EdgeId>],
marked_arc_count: &mut usize,
old_arc_id: EdgeId,
) -> EdgeId {
let index = old_arc_id.as_usize();
if let Some(new_arc_id) = arc_id_map[index] {
return new_arc_id;
}
let new_arc_id = EdgeId(*marked_arc_count);
arc_id_map[index] = Some(new_arc_id);
*marked_arc_count += 1;
new_arc_id
}
fn mark_next(&mut self) -> bool {
match self.frontier.pop_front() {
None => false,
Some(state_id) => {
let (new_state_id, mut child_arc_ids): (VertexId, Vec<EdgeId>) = {
let vertex = self.graph.get_vertex_mut(state_id);
(
self.state_id_map[state_id.as_usize()].unwrap(),
vertex.children.drain(0..).collect(),
)
};
for arc_id in child_arc_ids.iter_mut() {
let arc = self.graph.get_arc_mut(*arc_id);
// Update arc sources to use new state IDs.
arc.source = new_state_id;
if self.state_id_map[arc.target.as_usize()].is_none() {
Self::remap_state_id(
&mut self.state_id_map,
&mut self.marked_state_count,
arc.target,
);
self.frontier.push_back(arc.target);
}
let new_arc_id =
Self::remap_arc_id(&mut self.arc_id_map, &mut self.marked_arc_count, *arc_id);
self.arc_id_map[arc_id.as_usize()] = Some(new_arc_id);
*arc_id = new_arc_id;
for arc_id in &self.graph.get_vertex(state_id).children {
self.arc_assignments.reassign(*arc_id);
let arc = self.graph.get_arc(*arc_id);
if let Reassignment::New(_) = self.state_assignments.reassign(arc.target) {
// Arc target has not been marked yet. Reassign and enqueue
// it. Reassigning here ensures BFS ordering in the compacted graph.
self.state_assignments.reassign(arc.target);
self.frontier.push_back(arc.target);
}
// Update vertex children to use new EdgeIds.
self.graph.get_vertex_mut(state_id).children = child_arc_ids;
true
}
}
}
/// Drops vertices which were not reached in the previous `mark()`. Must be
/// run after `mark()`.
/// Drops edges and vertices which were not reached in the previous
/// `mark()`. Must be run after `mark()`.
///
/// Also, updates vertex pointers to parent edges to use the new `EdgeId`
/// addressing scheme built in the previous call to `mark()`.
fn sweep(&mut self) {
let state_id_map = {
let mut state_id_map = Vec::new();
mem::swap(&mut state_id_map, &mut self.state_id_map);
state_id_map
};
let arc_id_map = {
let mut arc_id_map = Vec::new();
mem::swap(&mut arc_id_map, &mut self.arc_id_map);
arc_id_map
};
// Compact marked vertices.
permute_compact(&mut self.graph.vertices, |i| {
state_id_map[i].map(|id| id.as_usize())
});
// Drop unmarked vertices.
self.graph.vertices.truncate(self.marked_state_count);
// Reassign and compact vertex parents.
/// Also updates all references between edges and vertices to maintain graph
/// integrity.
fn sweep(self) {
// Reassign and compact vertex parents and children.
for vertex in self.graph.vertices.iter_mut() {
let mut store_index = 0;
for scan_index in 0..vertex.parents.len() {
let old_arc_id = vertex.parents[scan_index];
if let Some(new_arc_id) = arc_id_map[old_arc_id.as_usize()] {
if let Some(new_arc_id) = self.arc_assignments.get(old_arc_id) {
vertex.parents[store_index] = new_arc_id;
store_index += 1;
}
}
vertex.parents.truncate(store_index);
vertex.parents.shrink_to_fit();
}
// Compact marked arcs.
permute_compact(&mut self.graph.arcs, |i| {
arc_id_map[i].map(|id| id.as_usize())
});
// Reassign arc targets.
for arc in self.graph.arcs.iter_mut() {
arc.target = state_id_map[arc.target.as_usize()].unwrap();
store_index = 0;
for scan_index in 0..vertex.children.len() {
let old_arc_id = vertex.children[scan_index];
if let Some(new_arc_id) = self.arc_assignments.get(old_arc_id) {
vertex.children[store_index] = new_arc_id;
store_index += 1;
}
}
vertex.children.truncate(store_index);
vertex.children.shrink_to_fit();
}
// Update state namespace to use new mapping.
let mut new_state_ids = HashIndexing::default();
mem::swap(&mut new_state_ids, &mut self.graph.state_ids);
let mut table = new_state_ids.to_table();
table.remap(|symbol| state_id_map[symbol.id().as_usize()]);
table.remap(|symbol| self.state_assignments.get(*symbol.id()));
self.graph.state_ids = HashIndexing::from_table(table);
// Reassign arc references.
for arc in self.graph.arcs.iter_mut() {
if let Some(new_source) = self.state_assignments.get(arc.source) {
arc.source = new_source;
}
if let Some(new_target) = self.state_assignments.get(arc.target) {
arc.target = new_target;
}
}
// Compact marked elements and drop unmarked ones.
self
.state_assignments
.permute_compact(&mut self.graph.vertices);
self.arc_assignments.permute_compact(&mut self.graph.arcs);
}
}
@ -289,10 +347,9 @@ mod test {
let root_ids = [VertexId(0), VertexId(1), VertexId(2)];
let mut c = Collector::new(&mut g);
c.mark(&root_ids);
for (i, new_id) in c.state_id_map.iter().enumerate() {
if new_id.is_some() {
assert!(root_ids.contains(&VertexId(i)));
}
assert_eq!(3, c.state_assignments.len());
for (i, _) in c.state_assignments.map.iter() {
assert!(root_ids.contains(&i));
}
}
@ -364,14 +421,14 @@ mod test {
let mut c = Collector::new(&mut g);
c.mark(&root_ids);
for (i, new_id) in c.state_id_map.iter().enumerate() {
if new_id.is_some() {
// Reachable IDs are remapped.
assert!(reachable_state_ids.contains(&VertexId(i)));
} else {
// Unreachable IDs aren't.
assert!(unreachable_state_ids.contains(&VertexId(i)));
}
assert_eq!(reachable_state_ids.len(), c.state_assignments.len());
for (i, _) in c.state_assignments.map.iter() {
// Reachable IDs are remapped.
assert!(reachable_state_ids.contains(i));
}
for i in &unreachable_state_ids {
// Unreachable IDs aren't.
assert!(c.state_assignments.get(*i).is_none());
}
// New VertexIds are:
@ -388,20 +445,17 @@ mod test {
// encounter them, not when they are visited. This should help by
// compacting memory so that child vertices are adjacent to one another.
assert_eq!(
c.state_id_map,
c.state_assignments.ordered_assignments(),
vec!(
Some(VertexId(5)),
Some(VertexId(7)),
Some(VertexId(8)),
None,
None,
None,
Some(VertexId(0)),
Some(VertexId(1)),
Some(VertexId(2)),
Some(VertexId(3)),
Some(VertexId(4)),
Some(VertexId(6)),
(VertexId(0), VertexId(5)),
(VertexId(1), VertexId(7)),
(VertexId(2), VertexId(8)),
(VertexId(6), VertexId(0)),
(VertexId(7), VertexId(1)),
(VertexId(8), VertexId(2)),
(VertexId(9), VertexId(3)),
(VertexId(10), VertexId(4)),
(VertexId(11), VertexId(6)),
)
);
@ -417,26 +471,23 @@ mod test {
// "2100" -> "0": 8
// Again, this places child arc data in contiguous segments of memory.
assert_eq!(
c.arc_id_map,
c.arc_assignments.ordered_assignments(),
vec!(
Some(EdgeId(6)),
Some(EdgeId(7)),
None,
None,
None,
Some(EdgeId(0)),
Some(EdgeId(1)),
Some(EdgeId(2)),
Some(EdgeId(3)),
Some(EdgeId(4)),
Some(EdgeId(5)),
Some(EdgeId(8)),
(EdgeId(0), EdgeId(6)),
(EdgeId(1), EdgeId(7)),
(EdgeId(5), EdgeId(0)),
(EdgeId(6), EdgeId(1)),
(EdgeId(7), EdgeId(2)),
(EdgeId(8), EdgeId(3)),
(EdgeId(9), EdgeId(4)),
(EdgeId(10), EdgeId(5)),
(EdgeId(11), EdgeId(8)),
)
);
c.sweep();
assert_eq!(
c.graph.vertices,
g.vertices,
vec!(
make_vertex("2_data", vec!(), vec!(EdgeId(0), EdgeId(1)),),
make_vertex("20_data", vec!(EdgeId(0)), vec!()),
@ -455,7 +506,7 @@ mod test {
);
assert_eq!(
c.graph.arcs,
g.arcs,
vec!(
make_arc("2_20_data", VertexId(0), VertexId(1)),
make_arc("2_21_data", VertexId(0), VertexId(2)),
@ -480,7 +531,7 @@ mod test {
state_associations.insert("00", VertexId(7));
state_associations.insert("01", VertexId(8));
let mut state_ids = HashIndexing::default();
mem::swap(&mut state_ids, &mut c.graph.state_ids);
mem::swap(&mut state_ids, &mut g.state_ids);
assert_eq!(state_ids.to_table().to_hash_map(), state_associations);
}