Skip to main content

cubecl_opt/analyses/
liveness.rs

1use alloc::collections::vec_deque::VecDeque;
2use cubecl_ir::{AddressSpace, Id, Value, ValueKind};
3use hashbrown::{HashMap, HashSet};
4use petgraph::graph::NodeIndex;
5
6use crate::{Function, GlobalState, analyses::post_order::PostOrder};
7
8use super::Analysis;
9
10type LivePredicate = fn(&Function, &Value) -> Option<Id>;
11
12pub struct Liveness {
13    live_vars: HashMap<NodeIndex, HashSet<Id>>,
14}
15
16#[derive(Debug, Clone)]
17struct BlockSets {
18    generated: HashSet<Id>,
19    kill: HashSet<Id>,
20}
21
22struct State {
23    worklist: VecDeque<NodeIndex>,
24    block_sets: HashMap<NodeIndex, BlockSets>,
25}
26
27impl Analysis for Liveness {
28    fn init(func: &mut Function, state: &GlobalState) -> Self {
29        Self {
30            live_vars: compute_liveness(func, state, Function::local_variable_id),
31        }
32    }
33}
34
35impl Liveness {
36    pub fn empty(func: &Function) -> Self {
37        let live_vars = func
38            .node_ids()
39            .iter()
40            .map(|it| (*it, HashSet::new()))
41            .collect();
42        Self { live_vars }
43    }
44
45    pub fn at_block(&self, block: NodeIndex) -> &HashSet<Id> {
46        &self.live_vars[&block]
47    }
48
49    pub fn is_dead(&self, node: NodeIndex, var: Id) -> bool {
50        !self.at_block(node).contains(&var)
51    }
52}
53
54/// Block level liveness over all non-atomic local memories, including non-destructurable ones such
55/// as arrays.
56///
57/// [`Liveness`] only tracks destructurable locals (those that get promoted to SSA registers and
58/// phi nodes). Backends that keep local memory as actual allocations and need to free it when it
59/// dies (e.g. the CPU backend) must also track non-destructurable locals, which this analysis does.
60pub struct MemoryLiveness {
61    live_vars: HashMap<NodeIndex, HashSet<Id>>,
62}
63
64impl Analysis for MemoryLiveness {
65    fn init(func: &mut Function, state: &GlobalState) -> Self {
66        Self {
67            live_vars: compute_liveness(func, state, Function::local_memory_id),
68        }
69    }
70}
71
72impl MemoryLiveness {
73    pub fn empty(func: &Function) -> Self {
74        let live_vars = func
75            .node_ids()
76            .iter()
77            .map(|it| (*it, HashSet::new()))
78            .collect();
79        Self { live_vars }
80    }
81
82    pub fn at_block(&self, block: NodeIndex) -> &HashSet<Id> {
83        &self.live_vars[&block]
84    }
85
86    pub fn is_dead(&self, node: NodeIndex, var: Id) -> bool {
87        !self.at_block(node).contains(&var)
88    }
89}
90
91/// Do a conservative block level liveness analysis, tracking the variables selected by `pred`.
92fn compute_liveness(
93    func: &mut Function,
94    global_state: &GlobalState,
95    pred: LivePredicate,
96) -> HashMap<NodeIndex, HashSet<Id>> {
97    let mut live_vars: HashMap<NodeIndex, HashSet<Id>> = func
98        .node_ids()
99        .iter()
100        .map(|it| (*it, HashSet::new()))
101        .collect();
102    let mut state = State {
103        worklist: VecDeque::from(func.analysis::<PostOrder>(global_state).forward()),
104        block_sets: HashMap::new(),
105    };
106    while let Some(block) = state.worklist.pop_front() {
107        analyze_block(func, global_state, block, &mut state, &mut live_vars, pred);
108    }
109    live_vars
110}
111
112fn analyze_block(
113    func: &mut Function,
114    global_state: &GlobalState,
115    block: NodeIndex,
116    state: &mut State,
117    live_vars: &mut HashMap<NodeIndex, HashSet<Id>>,
118    pred: LivePredicate,
119) {
120    let BlockSets { generated, kill } = block_sets(func, global_state, block, state, pred);
121
122    let mut block_live = generated.clone();
123
124    for successor in func.successors(block) {
125        let successor = &live_vars[&successor];
126        block_live.extend(successor.difference(kill));
127    }
128
129    if block_live != live_vars[&block] {
130        state.worklist.extend(func.predecessors(block));
131        live_vars.insert(block, block_live);
132    }
133}
134
135fn block_sets<'a>(
136    func: &mut Function,
137    global_state: &GlobalState,
138    block: NodeIndex,
139    state: &'a mut State,
140    pred: LivePredicate,
141) -> &'a BlockSets {
142    let block_sets = state.block_sets.entry(block);
143    block_sets.or_insert_with(|| calculate_block_sets(func, global_state, block, pred))
144}
145
146fn calculate_block_sets(
147    func: &mut Function,
148    state: &GlobalState,
149    block: NodeIndex,
150    pred: LivePredicate,
151) -> BlockSets {
152    let mut generated = HashSet::new();
153    let mut kill = HashSet::new();
154
155    let ops = func[block].ops.clone();
156
157    let control_flow = func[block].control_flow.clone();
158    func.visit_control_flow(&mut control_flow.borrow_mut(), |func, val| {
159        if let Some(id) = pred(func, val) {
160            generated.insert(id);
161        }
162    });
163    let mut ops = ops.borrow().clone();
164    for op in ops.values_mut().rev() {
165        // Reads must be tracked after writes
166        func.visit_out(&mut op.out, |func, val| {
167            if let Some(id) = pred(func, val) {
168                kill.insert(id);
169                generated.remove(&id);
170            }
171        });
172        func.visit_operation(state, &mut op.operation, |func, val| {
173            if let Some(id) = pred(func, val) {
174                generated.insert(id);
175            }
176        });
177    }
178
179    BlockSets { generated, kill }
180}
181
182impl Function {
183    /// Gets the `id` of the variable if it's a destructurable `Local` and not atomic, `None`
184    /// otherwise.
185    pub fn local_variable_id(&self, value: &Value) -> Option<Id> {
186        match value.kind {
187            ValueKind::Value { id } if self.destructurable_local_memories().contains_key(&id) => {
188                Some(id)
189            }
190            _ => None,
191        }
192    }
193
194    /// Gets the `id` of the variable if it's a `Local` memory and not atomic, `None` otherwise.
195    ///
196    /// Unlike [`Function::local_variable_id`], this includes non-destructurable locals such as
197    /// arrays, so it can be used by backends that allocate and free those memories.
198    pub fn local_memory_id(&self, value: &Value) -> Option<Id> {
199        match value.kind {
200            ValueKind::Value { id } => match self.memories.get(&id) {
201                Some(mem)
202                    if matches!(mem.address_space, AddressSpace::Local)
203                        && !mem.value_ty.is_atomic() =>
204                {
205                    Some(id)
206                }
207                _ => None,
208            },
209            _ => None,
210        }
211    }
212}
213
214/// Shared memory liveness analysis and allocation
215pub mod shared {
216    use alloc::vec::Vec;
217    use cubecl_ir::{AddressSpace, Marker, Operation, Type, Value, ValueKind};
218
219    use crate::{MemoryBlock, Uniformity};
220
221    use super::*;
222
223    /// A specific allocation of shared memory at some `offset`
224    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
225    pub struct SmemAllocation {
226        pub id: Id,
227        /// The shared memory being allocated
228        pub smem: MemoryBlock,
229        /// The offset in the shared memory buffer
230        pub offset: usize,
231    }
232
233    /// Shared liveness works the other way around from normal liveness, since shared memory lives
234    /// forever by default. So any use (read or write) inserts it as live, while only `free` changes
235    /// the state to dead.
236    ///
237    /// It also handles allocation of slices to each shared memory object, using the analyzed
238    /// liveness. `allocations` contains a specific slice allocation for each shared memory, while
239    /// ensuring no shared memories that exist at the same time can overlap.
240    #[derive(Default, Clone)]
241    pub struct SharedLiveness {
242        live_vars: HashMap<NodeIndex, HashSet<Id>>,
243        /// Map of all shared memories by their ID. Populated during the first pass with all
244        /// accessed shared memories.
245        pub shared_memories: HashMap<Id, MemoryBlock>,
246        /// Map of allocations for each shared memory by its ID. Populated after the analysis, and
247        /// should contain all memories from `shared_memories`.
248        pub allocations: HashMap<Id, SmemAllocation>,
249    }
250
251    impl Analysis for SharedLiveness {
252        fn init(func: &mut Function, state: &GlobalState) -> Self {
253            let mut this = Self::empty(func);
254            this.analyze_liveness(func, state);
255            this.uniformize_liveness(func, state);
256            this.allocate_slices(func);
257            this
258        }
259    }
260
261    impl SharedLiveness {
262        pub fn empty(func: &Function) -> Self {
263            let live_vars = func
264                .node_ids()
265                .iter()
266                .map(|it| (*it, HashSet::new()))
267                .collect();
268            Self {
269                live_vars,
270                shared_memories: Default::default(),
271                allocations: Default::default(),
272            }
273        }
274
275        pub fn at_block(&self, block: NodeIndex) -> &HashSet<Id> {
276            &self.live_vars[&block]
277        }
278
279        fn is_live(&self, node: NodeIndex, var: Id) -> bool {
280            self.at_block(node).contains(&var)
281        }
282
283        /// Do a conservative block level liveness analysis
284        fn analyze_liveness(&mut self, func: &mut Function, global_state: &GlobalState) {
285            let mut state = State {
286                worklist: VecDeque::from(func.analysis::<PostOrder>(global_state).reverse()),
287                block_sets: HashMap::new(),
288            };
289            while let Some(block) = state.worklist.pop_front() {
290                self.analyze_block(func, global_state, block, &mut state);
291            }
292        }
293
294        /// Extend divergent liveness to the preceding uniform block. Shared memory is always
295        /// uniformly declared, so it must be allocated before the branch.
296        fn uniformize_liveness(&mut self, func: &mut Function, global_state: &GlobalState) {
297            let mut state = State {
298                worklist: VecDeque::from(func.analysis::<PostOrder>(global_state).forward()),
299                block_sets: HashMap::new(),
300            };
301            while let Some(block) = state.worklist.pop_front() {
302                self.uniformize_block(func, global_state, block, &mut state);
303            }
304        }
305
306        /// Allocate slices while ensuring no concurrent shared memory slices overlap.
307        /// See also [`allocate_slice`]
308        fn allocate_slices(&mut self, func: &mut Function) {
309            for block in func.node_ids() {
310                for live_smem in self.at_block(block).clone() {
311                    if !self.allocations.contains_key(&live_smem) {
312                        let smem = self.shared_memories[&live_smem];
313                        let offset = self.allocate_slice(block, smem.size(), smem.alignment);
314                        self.allocations.insert(
315                            live_smem,
316                            SmemAllocation {
317                                id: live_smem,
318                                smem,
319                                offset,
320                            },
321                        );
322                    }
323                }
324            }
325        }
326
327        /// Finds a valid offset for a specific slice, taking into account ranges that are already
328        /// in use.
329        ///
330        /// Essentially the same as the global memory pool, looking for a free slice first, then
331        /// extending the pool if there isn't one. Note that this linear algorithm isn't optimal
332        /// for offline allocations where we know all allocations beforehand, but should be good
333        /// enough for our current purposes. It may produce larger-than-required allocations in
334        /// some cases. Optimal allocation would require a far more complex algorithm.
335        fn allocate_slice(&mut self, block: NodeIndex, size: usize, align: usize) -> usize {
336            let live_slices = self.live_slices(block);
337            if live_slices.is_empty() {
338                return 0;
339            }
340
341            for i in 0..live_slices.len() - 1 {
342                let slice_0 = &live_slices[i];
343                let slice_1 = &live_slices[i + 1];
344                let end_0 = (slice_0.offset + slice_0.smem.size()).next_multiple_of(align);
345                let gap = slice_1.offset.saturating_sub(end_0);
346                if gap >= size {
347                    return end_0;
348                }
349            }
350            let last_slice = &live_slices[live_slices.len() - 1];
351            (last_slice.offset + last_slice.smem.size()).next_multiple_of(align)
352        }
353
354        /// List of allocations that are currently live
355        fn live_slices(&mut self, block: NodeIndex) -> Vec<SmemAllocation> {
356            let mut live_slices = self
357                .allocations
358                .iter()
359                .filter(|(k, _)| self.is_live(block, **k))
360                .map(|it| *it.1)
361                .collect::<Vec<_>>();
362            live_slices.sort_by_key(|it| it.offset);
363            live_slices
364        }
365
366        fn analyze_block(
367            &mut self,
368            func: &mut Function,
369            global_state: &GlobalState,
370            block: NodeIndex,
371            state: &mut State,
372        ) {
373            let BlockSets { generated, kill } = self.block_sets(func, global_state, block, state);
374
375            let mut live_vars = generated.clone();
376
377            for predecessor in func.predecessors(block) {
378                let predecessor = &self.live_vars[&predecessor];
379                live_vars.extend(predecessor.difference(kill));
380            }
381
382            if live_vars != self.live_vars[&block] {
383                state.worklist.extend(func.successors(block));
384                self.live_vars.insert(block, live_vars);
385            }
386        }
387
388        fn uniformize_block(
389            &mut self,
390            func: &mut Function,
391            global_state: &GlobalState,
392            block: NodeIndex,
393            state: &mut State,
394        ) {
395            let mut live_vars = self.live_vars[&block].clone();
396            let uniformity = func.analysis::<Uniformity>(global_state);
397
398            for successor in func.successors(block) {
399                if !uniformity.is_block_uniform(successor) {
400                    let successor = &self.live_vars[&successor];
401                    live_vars.extend(successor);
402                }
403            }
404
405            if live_vars != self.live_vars[&block] {
406                state.worklist.extend(func.predecessors(block));
407                self.live_vars.insert(block, live_vars);
408            }
409        }
410
411        fn block_sets<'a>(
412            &mut self,
413            func: &mut Function,
414            global_state: &GlobalState,
415            block: NodeIndex,
416            state: &'a mut State,
417        ) -> &'a BlockSets {
418            let block_sets = state.block_sets.entry(block);
419            block_sets.or_insert_with(|| self.calculate_block_sets(func, global_state, block))
420        }
421
422        /// Any use makes a shared memory live (`generated`), while `free` kills it (`kill`).
423        /// Also collects all shared memories into a map.
424        fn calculate_block_sets(
425            &mut self,
426            func: &mut Function,
427            state: &GlobalState,
428            block: NodeIndex,
429        ) -> BlockSets {
430            let mut generated = HashSet::new();
431            let mut kill = HashSet::new();
432
433            let ops = func[block].ops.clone();
434
435            for op in ops.borrow_mut().values_mut() {
436                func.visit_out(&mut op.out, |func, var| {
437                    if let Some((id, smem)) = shared_memory(func, var) {
438                        generated.insert(id);
439                        self.shared_memories.insert(id, smem);
440                    }
441                });
442                func.visit_operation(state, &mut op.operation, |func, var| {
443                    if let Some((id, smem)) = shared_memory(func, var) {
444                        generated.insert(id);
445                        self.shared_memories.insert(id, smem);
446                    }
447                });
448
449                if let Operation::Marker(Marker::Free(Value {
450                    ty: Type::Pointer(_, AddressSpace::Shared),
451                    kind: ValueKind::Value { id, .. },
452                    ..
453                })) = &op.operation
454                {
455                    kill.insert(*id);
456                    generated.remove(id);
457                }
458            }
459
460            BlockSets { generated, kill }
461        }
462    }
463
464    fn shared_memory(func: &Function, var: &Value) -> Option<(Id, MemoryBlock)> {
465        match var.kind {
466            ValueKind::Value { id } => {
467                if let Some(mem) = func.memories.get(&id)
468                    && matches!(mem.address_space, AddressSpace::Shared)
469                {
470                    Some((id, *mem))
471                } else {
472                    None
473                }
474            }
475            _ => None,
476        }
477    }
478}
479
480mod captures {
481    use cubecl_ir::Value;
482
483    use super::*;
484
485    pub struct Captures {
486        live_vars: HashMap<NodeIndex, HashSet<Value>>,
487    }
488
489    #[derive(Clone)]
490    struct BlockSets {
491        generated: HashSet<Value>,
492        kill: HashSet<Value>,
493    }
494
495    struct State {
496        worklist: VecDeque<NodeIndex>,
497        block_sets: HashMap<NodeIndex, BlockSets>,
498    }
499
500    impl Analysis for Captures {
501        fn init(func: &mut Function, state: &GlobalState) -> Self {
502            let mut this = Self::empty(func);
503            this.analyze_liveness(func, state);
504            this
505        }
506    }
507
508    impl Captures {
509        pub fn empty(func: &Function) -> Self {
510            let live_vars = func
511                .node_ids()
512                .iter()
513                .map(|it| (*it, HashSet::new()))
514                .collect();
515            Self { live_vars }
516        }
517
518        pub fn at_block(&self, block: NodeIndex) -> &HashSet<Value> {
519            &self.live_vars[&block]
520        }
521
522        /// Do a conservative block level liveness analysis
523        pub fn analyze_liveness(&mut self, func: &mut Function, global_state: &GlobalState) {
524            let mut state = State {
525                worklist: VecDeque::from(func.analysis::<PostOrder>(global_state).forward()),
526                block_sets: HashMap::new(),
527            };
528            while let Some(block) = state.worklist.pop_front() {
529                self.analyze_block(func, global_state, block, &mut state);
530            }
531        }
532
533        fn analyze_block(
534            &mut self,
535            func: &mut Function,
536            global_state: &GlobalState,
537            block: NodeIndex,
538            state: &mut State,
539        ) {
540            let BlockSets { generated, kill } = block_sets(func, global_state, block, state);
541
542            let mut live_vars = generated.clone();
543
544            for successor in func.successors(block) {
545                let successor = &self.live_vars[&successor];
546                live_vars.extend(successor.difference(kill));
547            }
548
549            if live_vars != self.live_vars[&block] {
550                state.worklist.extend(func.predecessors(block));
551                self.live_vars.insert(block, live_vars);
552            }
553        }
554    }
555
556    fn block_sets<'a>(
557        func: &mut Function,
558        global_state: &GlobalState,
559        block: NodeIndex,
560        state: &'a mut State,
561    ) -> &'a BlockSets {
562        let block_sets = state.block_sets.entry(block);
563        block_sets.or_insert_with(|| calculate_block_sets(func, global_state, block))
564    }
565
566    fn calculate_block_sets(
567        func: &mut Function,
568        state: &GlobalState,
569        block: NodeIndex,
570    ) -> BlockSets {
571        let mut generated = HashSet::new();
572        let mut kill = HashSet::new();
573
574        let ops = func[block].ops.clone();
575
576        let control_flow = func[block].control_flow.clone();
577        func.visit_control_flow(&mut control_flow.borrow_mut(), |_, var| {
578            generated.insert(*var);
579        });
580        for inst in ops.borrow_mut().values_mut().rev() {
581            // Reads must be tracked after writes
582            func.visit_out(&mut inst.out, |_, var| {
583                kill.insert(*var);
584                generated.remove(var);
585            });
586            func.visit_operation(state, &mut inst.operation, |_, var| {
587                generated.insert(*var);
588            });
589        }
590
591        BlockSets { generated, kill }
592    }
593}
594
595pub use captures::Captures;