I have a very hot segment of code in a large project. I've extracted the relevant segment and a toy example to illustrate it.
The project involves an unavoidable combinatorial explosion related to graph theory, so there's no limit to how fast is 'good enough' -- but rather, each significant speed increase lets me explore slightly larger graphs. If there is a tradeoff between speed and clarity or safety or anything else but correctness, this needs speed.
I'm not only new to Rust, but am a mathematician rather than coder, so don't have expertise with any language. I'm vaguely aware that for code that needs to be fast, we might care about cache misses, CPU branch prediction, inlining, SIMD, and probably more things that I haven't even heard of.
What this code does is use u64 to represent sets. I.e., bit i is true if element i is present. I will have a possibly large Vec of these u64-sets, each of which has k members. Then, I'm looking for collections of k+1 of these that have k+1 set bits in their union.
This line: "if !(j_elements ^ candidate_elements).is_power_of_two()" is almost always true, and triggers a continue.
In addition to help with making this code faster, are there profiling tools people would recommend beyond flamegraph? E.g., some way to see if the compiled code or CPUs execution is non-optimal?
use rustc_hash::FxHashSet;
fn main() {
let mut sets = vec![
TestSet { elements: 0b0011 },
TestSet { elements: 0b0101 },
TestSet { elements: 0b1001 },
TestSet { elements: 0b0110 },
];
let set_count = sets.len();
let subset_size = 3;
let mut explored_subsets = FxHashSet::<u64>::default();
let mut results = Vec::new();
for i in 0..=(set_count - subset_size) {
let i_elements = sets[i].elements;
for j in (i + 1)..=(set_count - subset_size + 1) {
let j_elements = sets[j].elements;
let candidate_elements = i_elements | j_elements;
if !(j_elements ^ candidate_elements).is_power_of_two() || explored_subsets.contains(&candidate_elements) {
continue;
}
explored_subsets.insert(candidate_elements);
let mut compatible_sets_needed = subset_size - 2;
for k in (j + 1)..set_count {
let k_elements = sets[k].elements;
if k_elements & candidate_elements == k_elements {
compatible_sets_needed -= 1;
if compatible_sets_needed == 0 {
results.push(candidate_elements);
break;
}
}
}
}
}
println!("Results: {:?}", results);
}
#[derive(Clone)]
struct TestSet {
elements: u64,
}
For context, while the toy code above only executes the nested loops once, in my full code they are executed many many times. As I said earlier, no speed is 'enough' but more is always better.
The full context is:
- I'm working with edge colorings of complete r-uniform hypergraphs on n vertices.
- An r-uniform hypergraph is an abstraction of a graph that redefines an edge to be a set of r vertices. I.e., an ordinary graph is a 2-uniform hypergraph.
- So let's take the simplest hypergraph that isn't an ordinary graph: The 3-uniform hypergraph on n edges. That is, r=3. Then there are choose(n, 3) edges. If we use merely 2 colors, there are 2^(choose(n, 3)) possible colorings (multi-counting isomorphisms)
- I'm doing simulated annealing with gradient-free optimization, in trying to find graphs where the count of maximal monochromatic cliques minimal. I.e., I randomly color the edges, use the provided code to find maximal cliques on k=4 vertices, then on k=5, then on k=6, and so on until there aren't enough monochromatic cliques for some k to enable k+1 to be possible.
- To find a monochromatic clique on k vertices, having already found all monochromatic cliques on k-1 vertices, I first look for a pair which have exactly k set bits in their union. Then, I need k-2 more cliques of the same color each of which has k-1 of these specific k bits set (and no other). I.e., the xor of the new clique and these k set bits is a single set bit, or a power of 2.
- The gradient descent step is flipping one edge and seeing if our count improved.
I'm not sure any of that context is important to my question, but I'm happy to provide it. What I'm asking is, assuming the nested for loops are called so often that these fast-and-simple operations are a bottleneck, how can I
- tell which specific lines of code are taking the most time, and
- if possible, how can I make them faster?
** update **
Here is the full method containing the above code (but without the variables renamed like they are above), and the struct it lives in:
struct HyperGraph {
edges: Vec<Clique>,
cliques: FxHashMap<u8, Vec<Clique>>,
members_of_cliques_which_should_be_deactivated: FxHashSet<u64>,
maximal_color_clique_ct: usize,
edge_order: u8,
color_ct: u8,
graph_order: u8,
graph_size: usize,
last_random_edge_index: usize,
set_bit_getter: math::SetBitGetter,
}
fn find_cliques_from_scratch(&mut self) {
self.cliques.clear();
self.maximal_color_clique_ct = 0;
let mut smaller_cliques_count: usize;
let mut order_usize: usize;
let mut compatible_cliques_needed: usize;
let mut key_small_cliques: u8; // key for order-1 cliques
let mut explored_subsets = FxHashSet::<u64>::default();
let mut cliques_to_add = Vec::<Clique>::new();
for color in 0..self.color_ct {
// println!("\n color: {}", color);
// Create all trivial color-cliques (those having order equal to EDGE_ORDER - 1)
self.add_clique_vec(
Clique::generate_all_cliques(color, self.edge_order - 1, self.graph_order),
self.get_key(color, self.edge_order - 1));
// Create all color-edge-cliques
let mut edge_cliques = Vec::<Clique>::new();
for edge in &mut self.edges {
if edge.color == color {
edge_cliques.push(edge.clone());
}
}
self.add_clique_vec(
edge_cliques,
self.get_key(color, self.edge_order));
// Mark fully contained trivial color-cliques as inactive (aka non-maximal)
self.mark_nonmaximal_cliques_inactive(color, self.edge_order);
// Create all color-order-cliques for orders > edge_order
// 'smaller_cliques' refers to all cliques that match the color
// we're examining, and have (order-1) vertices.
// The k'th bit of a cliques_index corresponds to the index in cliques[(color, order - 1)]
// These are used in a hot loop; allocating memory once here.
let mut candidate_clique_members: u64;
let mut cur_smaller_clique_members: u64;
let mut i_members: u64;
let mut j_members: u64;
for order in (self.edge_order + 1)..(self.graph_order + 1) {
// println!(" order: {}", order);
order_usize = order as usize;
key_small_cliques = self.get_key(color, order - 1);
// Confirm we have enough smaller cliques to build a bigger clique out of
let smaller_cliques = match self.cliques.get(&key_small_cliques) {
Some(cliques) => cliques,
None => break,
};
smaller_cliques_count = smaller_cliques.len();
if smaller_cliques_count < order_usize {
break;
}
cliques_to_add.clear(); // Clear previous cliques
explored_subsets.clear(); // Clear explored subsets
for i in 0..=(smaller_cliques_count - order_usize) {
i_members = smaller_cliques[i].members;
for j in (i + 1)..=(smaller_cliques_count - order_usize + 1) {
j_members = smaller_cliques[j].members;
candidate_clique_members = i_members | j_members;
if !(j_members ^ candidate_clique_members).is_power_of_two() || explored_subsets.contains(&candidate_clique_members) {
continue;
}
explored_subsets.insert(candidate_clique_members);
compatible_cliques_needed = order_usize - 2;
for k in (j + 1)..smaller_cliques_count {
cur_smaller_clique_members = smaller_cliques[k].members;
if cur_smaller_clique_members & candidate_clique_members == cur_smaller_clique_members {
compatible_cliques_needed -= 1;
if compatible_cliques_needed == 0 {
// We've found an order-clique!
cliques_to_add.push(Clique::new(candidate_clique_members, color, self.graph_order));
}
continue;
}
}
}
}
// Move cliques_to_add into add_clique
while let Some(clique) = cliques_to_add.pop() {
self.add_clique(clique);
}
// Having finished finding color-order cliques, mark any nonmaximal smaller cliques as inactive
self.mark_nonmaximal_cliques_inactive(color, order);
}
}
}
```