xlog_runtime/executor/recursive.rs
1//! Recursive SCC execution using semi-naive fixpoint iteration.
2
3use std::collections::{BTreeSet, HashMap, HashSet};
4use std::time::Instant;
5
6use xlog_core::{RelId, Result, Schema, XlogError};
7use xlog_cuda::{CudaBuffer, CudaKernelProvider};
8use xlog_ir::{ExecutionPlan, RirNode, Stratum};
9
10use crate::profiler::Profiler;
11
12use super::delta::DeltaRelationTracker;
13use super::Executor;
14
15impl Executor {
16 /// Maximum iterations for fixpoint computation to prevent infinite loops
17 const MAX_FIXPOINT_ITERATIONS: usize = 1000;
18
19 /// Union a batch of same-head contributions in one multiway pass,
20 /// recording a single profiled "union" op for the whole batch.
21 ///
22 /// Takes the provider and profiler as explicit arguments (instead of
23 /// `&mut self`) so call sites can hold `self.store` borrows across the
24 /// call; the field borrows stay disjoint.
25 fn union_batch_profiled(
26 provider: &CudaKernelProvider,
27 profiler: &mut Profiler,
28 inputs: &[&CudaBuffer],
29 ) -> Result<CudaBuffer> {
30 let union_input: u64 = inputs.iter().map(|b| b.num_rows()).sum();
31 let start = profiler.start_op();
32 let merged = provider.union_many_gpu(inputs)?;
33 if let Some(start) = start {
34 let mem = provider.memory().allocated_bytes();
35 profiler.record_op("union", union_input, merged.num_rows(), start, mem);
36 profiler.record_peak_memory(mem);
37 }
38 Ok(merged)
39 }
40
41 /// For a `MultiWayJoin` or `ChainJoin` body, try the specialized WCOJ
42 /// dispatchers first; on decline, fall back to the embedded fallback
43 /// subtree via `execute_node`. For any other RIR variant, defer to
44 /// `execute_node` directly.
45 ///
46 /// Used at two sites in the recursive engine: the seeding pass, where
47 /// stable rules and recursive rules get their initial dispatch on the full
48 /// body, and the per-variant loop, where each recursive scan with a
49 /// non-empty delta is rewritten to its delta `RelId` for one dispatch.
50 /// Multi-recursive bodies, including distinct recursive predicates and
51 /// same-predicate self-recursive bodies, reach a `MultiWayJoin` here after
52 /// the promoter admits bodies with more than one recursive scan; the
53 /// per-variant rewrite loop builds one variant per recursive occurrence
54 /// with a non-empty delta and dispatches each via this helper.
55 ///
56 /// Counter semantics: `wcoj_*_dispatch_count` increments once per
57 /// successful WCOJ kernel result: once per recursive rule, iteration, and
58 /// variant. Non-recursive dispatch sites increment once per rule per call.
59 fn execute_wcoj_or_fallback_node(&mut self, node: &RirNode) -> Result<CudaBuffer> {
60 if let RirNode::ChainJoin { .. } = node {
61 if let Some(buf) = self.try_dispatch_chain_on_body(node)? {
62 return Ok(buf);
63 }
64 return self.execute_node(node);
65 }
66 if let RirNode::MultiWayJoin { .. } = node {
67 // Triangle, 4-cycle, then K-clique. A body cannot
68 // match more than one paper-derived shape (different
69 // atom counts). The dispatcher's own gate handles
70 // env-var / config / adaptive decisions; this site is
71 // purely structural.
72 if let Some(buf) = self.try_dispatch_wcoj_triangle_on_body(node)? {
73 return Ok(buf);
74 }
75 if let Some(buf) = self.try_dispatch_wcoj_4cycle_on_body(node)? {
76 return Ok(buf);
77 }
78 // Recursive clique bodies use the same launch-local metadata
79 // builders as non-recursive K-clique dispatch, so rewritten
80 // semi-naive variants are eligible here too.
81 if let Some(buf) = self.try_dispatch_wcoj_clique5_on_body(node)? {
82 return Ok(buf);
83 }
84 if let Some(buf) = self.try_dispatch_wcoj_clique6_on_body(node)? {
85 return Ok(buf);
86 }
87 if let Some(buf) = self.try_dispatch_wcoj_clique7_on_body(node)? {
88 return Ok(buf);
89 }
90 if let Some(buf) = self.try_dispatch_wcoj_clique8_on_body(node)? {
91 return Ok(buf);
92 }
93 // Generalized Free Join dispatch for every multiway shape the
94 // dedicated dispatchers declined. The hook re-checks dedicated
95 // shapes structurally, so it only fires on general bodies.
96 if let Some(buf) = self.try_dispatch_free_join(node)? {
97 return Ok(buf);
98 }
99 }
100 self.execute_node(node)
101 }
102
103 fn refresh_kclique_edge_metadata_after_merge(
104 &mut self,
105 rules: &[xlog_ir::CompiledRule],
106 pred: &str,
107 ) {
108 let start = Instant::now();
109 let affected_rules = rules
110 .iter()
111 .filter(|rule| self.kclique_body_mentions_pred(&rule.body, pred))
112 .count() as u64;
113 self.record_kclique_histogram_refresh_time(start, affected_rules);
114 }
115
116 fn record_kclique_histogram_refresh_time(&mut self, start: Instant, affected_rules: u64) {
117 if affected_rules == 0 {
118 return;
119 }
120 self.kclique_histogram_refresh_count = self
121 .kclique_histogram_refresh_count
122 .saturating_add(affected_rules);
123 self.kclique_histogram_refresh_nanos = self
124 .kclique_histogram_refresh_nanos
125 .saturating_add(start.elapsed().as_nanos());
126 }
127
128 fn kclique_body_mentions_pred(&self, node: &RirNode, pred: &str) -> bool {
129 let RirNode::MultiWayJoin {
130 inputs, var_order, ..
131 } = node
132 else {
133 return false;
134 };
135 let Some(order) = var_order.as_ref().and_then(|order| order.kclique.as_ref()) else {
136 return false;
137 };
138 if !matches!(order.k, 5..=8) {
139 return false;
140 }
141 inputs.iter().any(|input| {
142 let RirNode::Scan { rel } = input else {
143 return false;
144 };
145 self.rel_names.get(rel).is_some_and(|name| name == pred)
146 })
147 }
148
149 /// Stub: always returns an error directing callers to use `execute_plan` instead.
150 pub fn execute_stratum(&mut self, _stratum: &Stratum) -> Result<()> {
151 Err(XlogError::Execution(
152 "execute_stratum cannot be called directly; use execute_plan instead which provides \
153 the required rules_by_scc context"
154 .to_string(),
155 ))
156 }
157
158 /// Execute all rules in a non-recursive strongly connected component once.
159 ///
160 /// Each contiguous run of same-head rules is merged into the store with
161 /// one multiway union instead of one union per rule, so many-rule heads
162 /// stay linear in their total rows. Flushing on every head switch (not
163 /// once per SCC group) preserves rule-order dataflow: promoter-generated
164 /// helper rules share an SCC group with their consumer, so a helper's
165 /// head must be installed before the next rule reads it.
166 pub fn execute_non_recursive_scc(&mut self, rules: &[xlog_ir::CompiledRule]) -> Result<()> {
167 let mut pending_head: Option<&str> = None;
168 let mut pending: Vec<CudaBuffer> = Vec::new();
169 for rule in rules {
170 if pending_head != Some(rule.head.as_str()) {
171 if let Some(head) = pending_head.take() {
172 let batch = std::mem::take(&mut pending);
173 self.install_plain_head_batch(head, batch)?;
174 }
175 pending_head = Some(rule.head.as_str());
176 } else if !pending.is_empty() && self.body_reads_own_head(rule) {
177 // A same-head rule that reads its own head (a non-monotone
178 // singleton SCC, admitted under the probabilistic profile)
179 // must observe prior same-head contributions exactly as the
180 // sequential per-rule path did: flush before evaluating it.
181 let batch = std::mem::take(&mut pending);
182 self.install_plain_head_batch(&rule.head, batch)?;
183 }
184 let result = self.execute_node(&rule.body)?;
185 pending.push(result);
186 }
187 if let Some(head) = pending_head {
188 self.install_plain_head_batch(head, pending)?;
189 }
190 Ok(())
191 }
192
193 /// Whether the rule's body reads its own head relation in a way that is
194 /// sensitive to same-pass installs. A body that IS a bare scan of the
195 /// head (`h :- h`, the planner's identity/carry rule for fact
196 /// predicates) is exempt: its scan result is always a subset of the
197 /// final merged relation, so batching cannot change the outcome. Any
198 /// other self-reading shape (negation, joins, or projections over the
199 /// head — possible only for non-monotone singleton SCCs admitted under
200 /// the probabilistic profile) must flush for sequential parity.
201 fn body_reads_own_head(&self, rule: &xlog_ir::CompiledRule) -> bool {
202 if let RirNode::Scan { rel } = &rule.body {
203 if self.get_rel_name(*rel).is_some_and(|n| n == rule.head) {
204 return false;
205 }
206 }
207 let mut scans = Vec::new();
208 Self::collect_scan_rels(&rule.body, &mut scans);
209 scans
210 .into_iter()
211 .any(|rel| self.get_rel_name(rel).is_some_and(|n| n == rule.head))
212 }
213
214 /// Install one head's batched results, skipping empty contributions.
215 /// Mirrors the pre-batching per-rule behavior: an existing relation is
216 /// left untouched when every contribution is empty, and a lone fresh
217 /// non-empty result records a single-input union (internally one dedup)
218 /// like the dispatched install path, so `--stats` accounting stays
219 /// uniform across both installers. The store is only mutated after the
220 /// merge succeeds, so a failed union leaves the existing relation
221 /// intact.
222 fn install_plain_head_batch(&mut self, head: &str, results: Vec<CudaBuffer>) -> Result<()> {
223 let non_empty: Vec<&CudaBuffer> = results.iter().filter(|r| !r.is_empty()).collect();
224
225 // An existing empty relation (e.g. a pre-seeded schema buffer)
226 // carries no rows to merge, so it takes the fresh-install path,
227 // matching the dispatched installer.
228 let existing = self.store.get(head).filter(|buf| !buf.is_empty());
229 if let Some(existing) = existing {
230 if non_empty.is_empty() {
231 // No new rows for this head: leave the relation untouched.
232 return Ok(());
233 }
234 let mut union_inputs = Vec::with_capacity(non_empty.len() + 1);
235 union_inputs.push(existing);
236 union_inputs.extend(non_empty);
237 let merged =
238 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
239 self.store_put(head, merged);
240 } else if non_empty.is_empty() {
241 // All contributions are empty: an absent head gets an empty
242 // relation with the result schema; an existing (empty) head is
243 // left untouched.
244 if self.store.get(head).is_none() {
245 let first = results.into_iter().next().ok_or_else(|| {
246 XlogError::Execution(format!("No results collected for head {}", head))
247 })?;
248 self.store_put(head, first);
249 }
250 } else {
251 let merged =
252 Self::union_batch_profiled(&self.provider, &mut self.profiler, &non_empty)?;
253 self.store_put(head, merged);
254 }
255 Ok(())
256 }
257
258 /// Install one head's batched non-recursive results with a single
259 /// profiled multiway union. Each entry's flag records whether the
260 /// route's output is already sorted+deduped, so a lone fresh WCOJ
261 /// result installs without a redundant dedup pass, mirroring the
262 /// per-route install behavior. The store is only mutated after the
263 /// merge succeeds, so a failed union leaves the existing relation
264 /// intact.
265 fn install_dispatched_head_batch(
266 &mut self,
267 head: &str,
268 results: Vec<(CudaBuffer, bool)>,
269 ) -> Result<()> {
270 // Union with existing result if the predicate already has rows. An
271 // existing empty relation (e.g. a pre-seeded schema buffer)
272 // contributes nothing, so it takes the fresh-install path — which
273 // is what lets a lone already-deduped WCOJ result skip the
274 // redundant dedup in production runs.
275 let existing = self.store.get(head).filter(|buf| !buf.is_empty());
276 if let Some(existing) = existing {
277 let mut union_inputs: Vec<&CudaBuffer> = Vec::with_capacity(results.len() + 1);
278 union_inputs.push(existing);
279 union_inputs.extend(results.iter().map(|(buf, _)| buf));
280 let merged =
281 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
282 self.store_put(head, merged);
283 } else if results.len() == 1 {
284 let (result, already_deduped) = results.into_iter().next().expect("len checked");
285 if already_deduped || result.is_empty() {
286 self.store_put(head, result);
287 } else {
288 // Set semantics for a lone raw result: a single-input
289 // multiway union (internally one dedup), recorded as a
290 // "union" op like the union-with-empty install it replaces.
291 let merged =
292 Self::union_batch_profiled(&self.provider, &mut self.profiler, &[&result])?;
293 self.store_put(head, merged);
294 }
295 } else {
296 let union_inputs: Vec<&CudaBuffer> = results.iter().map(|(buf, _)| buf).collect();
297 let merged =
298 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
299 self.store_put(head, merged);
300 }
301 Ok(())
302 }
303
304 /// Execute a stratum (internal implementation)
305 ///
306 /// Processes all SCCs in the stratum by executing their rules.
307 /// For recursive SCCs, uses semi-naive fixpoint iteration.
308 pub(super) fn execute_stratum_impl(
309 &mut self,
310 stratum: &Stratum,
311 plan: &ExecutionPlan,
312 ) -> Result<()> {
313 // Process each SCC in the stratum
314 for &scc_id in &stratum.sccs {
315 // Get rules for this SCC
316 if let Some(rules) = plan.rules_by_scc.get(scc_id as usize) {
317 // Get SCC metadata
318 let scc = plan.sccs.get(scc_id as usize);
319 let is_recursive = scc.map(|s| s.is_recursive).unwrap_or(false);
320
321 if is_recursive {
322 // Recursive SCC: use semi-naive fixpoint iteration. The
323 // recursive engine invokes WCOJ dispatch via
324 // `execute_wcoj_or_fallback_node` on both the seeding
325 // pass and per-variant evaluation when the promoted body
326 // shape is eligible.
327 self.execute_recursive_scc(rules)?;
328 } else {
329 // Non-recursive SCC: execute rules once, merging each
330 // contiguous run of same-head results with one multiway
331 // union instead of one union per rule, so many-rule heads
332 // stay linear in their total rows. Flushing on every head
333 // switch (not once per SCC group) preserves rule-order
334 // dataflow: promoter-generated helper rules share an SCC
335 // group with their consumer, so a helper's head must be
336 // installed before the next rule dispatches against it.
337 let mut pending_head: Option<&str> = None;
338 let mut pending: Vec<(CudaBuffer, bool)> = Vec::new();
339 for rule in rules {
340 if pending_head != Some(rule.head.as_str()) {
341 if let Some(head) = pending_head.take() {
342 let batch = std::mem::take(&mut pending);
343 self.install_dispatched_head_batch(head, batch)?;
344 }
345 pending_head = Some(rule.head.as_str());
346 } else if !pending.is_empty() && self.body_reads_own_head(rule) {
347 // A same-head rule that reads its own head (a
348 // non-monotone singleton SCC, admitted under the
349 // probabilistic profile) must observe prior
350 // same-head contributions exactly as the
351 // sequential per-rule path did: flush before
352 // evaluating it.
353 let batch = std::mem::take(&mut pending);
354 self.install_dispatched_head_batch(&rule.head, batch)?;
355 }
356
357 // Route two-atom ChainJoin bodies before the
358 // triangle/4-cycle/KC attempts. The dispatcher
359 // silently declines on non-chain bodies or when
360 // the env gate disables the route.
361 let entry = if let Some(chain_result) =
362 self.try_dispatch_chain_on_body(&rule.body)?
363 {
364 (chain_result, false)
365 }
366 // WCOJ triangle dispatch, gated by runtime configuration.
367 // Try to short-circuit the rule via the GPU
368 // 3-way kernel. On Some(_), record the result
369 // and skip the binary-join path for this rule.
370 // On None (gate off, shape mismatch, missing
371 // input, kernel error), fall through silently.
372 // See `wcoj_dispatch::try_dispatch_wcoj_triangle`
373 // for the full match contract. WCOJ output is
374 // already sorted+deduped, so a lone fresh
375 // install needs no dedup pass.
376 else if let Some(wcoj_result) = self.try_dispatch_wcoj_triangle(rule)? {
377 (wcoj_result, true)
378 }
379 // WCOJ 4-cycle dispatch.
380 // Same pattern as triangle. Order is a doc
381 // anchor — a body cannot match both shapes
382 // (different atom counts), so triangle's
383 // earlier attempt always returns None on a
384 // 4-cycle body and vice versa.
385 else if let Some(wcoj_result) = self.try_dispatch_wcoj_4cycle(rule)? {
386 (wcoj_result, true)
387 }
388 // K-clique dispatch for k=5..k=8.
389 // Same shape-gated default-dispatch
390 // pattern as triangle / 4-cycle; silent
391 // fallback to MultiWayJoin.fallback on
392 // dispatcher decline or kernel error.
393 else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique5(rule)? {
394 (wcoj_result, true)
395 } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique6(rule)? {
396 (wcoj_result, true)
397 } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique7(rule)? {
398 (wcoj_result, true)
399 } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique8(rule)? {
400 (wcoj_result, true)
401 }
402 // Generalized Free Join dispatch for every multiway
403 // shape the dedicated dispatchers above declined. The
404 // dispatcher re-checks those shapes structurally, so
405 // it only fires on general bodies. Unlike the
406 // dedicated kernels, the frontier engine emits one row
407 // per derivation path, so its output still needs the
408 // dedup the per-head merge (or the fresh-install
409 // dedup) provides.
410 else if let Some(fj_result) = self.try_dispatch_free_join(&rule.body)? {
411 (fj_result, false)
412 } else {
413 // When WCOJ dispatch declines on a `MultiWayJoin`
414 // body (gate off, kernel error, adaptive score below
415 // threshold, ...), execute the embedded `fallback`,
416 // the post-optimizer binary-join tree the promoter
417 // captured. `execute_node`'s `MultiWayJoin` arm is the
418 // defensive safety net; explicit destructuring here
419 // keeps the intent visible at the dispatch site.
420 let body_to_execute = match &rule.body {
421 xlog_ir::RirNode::MultiWayJoin { fallback, .. }
422 | xlog_ir::RirNode::ChainJoin { fallback, .. } => fallback.as_ref(),
423 other => other,
424 };
425 (self.execute_node(body_to_execute)?, false)
426 };
427
428 pending.push(entry);
429 }
430 if let Some(head) = pending_head {
431 self.install_dispatched_head_batch(head, pending)?;
432 }
433 }
434 }
435 }
436
437 Ok(())
438 }
439
440 /// Execute a recursive SCC using semi-naive fixpoint iteration
441 ///
442 /// The algorithm:
443 /// 1. Execute all rules once to get initial result
444 /// 2. Track which relations changed (delta)
445 /// 3. Re-execute rules, using delta from previous iteration
446 /// 4. Repeat until no changes (fixpoint reached)
447 pub fn execute_recursive_scc(&mut self, rules: &[xlog_ir::CompiledRule]) -> Result<()> {
448 // Reset the per-iteration stats trace at SCC entry so tests see a
449 // fresh trace per invocation. Gated on the `recursive-stats-trace`
450 // feature; default OFF.
451 #[cfg(feature = "recursive-stats-trace")]
452 {
453 self.last_recursive_stats_trace.entries.clear();
454 }
455 // Identify SCC predicates from rule heads (these are the recursive IDBs).
456 let mut recursive_pred_names: BTreeSet<String> = BTreeSet::new();
457 let mut schema_by_pred: HashMap<String, Schema> = HashMap::new();
458 for rule in rules {
459 recursive_pred_names.insert(rule.head.clone());
460 if rule.meta.schema.arity() > 0 {
461 schema_by_pred
462 .entry(rule.head.clone())
463 .or_insert_with(|| rule.meta.schema.clone());
464 }
465 }
466 let recursive_pred_lookup: HashSet<String> = recursive_pred_names.iter().cloned().collect();
467 let recursive_preds: Vec<String> = recursive_pred_names.into_iter().collect();
468
469 // Ensure all recursive predicates exist in the store so scans never fail
470 // due to evaluation order (mutual recursion can reference an as-yet-empty relation).
471 for pred in &recursive_preds {
472 if !self.store.contains(pred) {
473 let schema = schema_by_pred
474 .get(pred)
475 .cloned()
476 .or_else(|| self.store.get(pred).map(|b| b.schema().clone()))
477 .ok_or_else(|| {
478 XlogError::Execution(format!(
479 "Missing schema for recursive predicate {}",
480 pred
481 ))
482 })?;
483 let empty = self.create_empty_buffer(schema)?;
484 self.store_put(pred, empty);
485 }
486 }
487
488 // Create per-predicate delta relations (distinct RelIds) so semi-naive evaluation
489 // can target a single recursive Scan occurrence without overriding *all* scans of
490 // that predicate in a rule (required for self-joins like p(X,Y), p(Y,Z)).
491 let mut next_rel_id = self
492 .rel_names
493 .keys()
494 .map(|r| r.0)
495 .max()
496 .unwrap_or(0)
497 .saturating_add(1);
498
499 let mut delta_tracker = DeltaRelationTracker::new();
500 for pred in &recursive_preds {
501 let rel_id = RelId(next_rel_id);
502 next_rel_id = next_rel_id.saturating_add(1);
503 let name = format!("__delta_{}_{}", pred, rel_id.0);
504 self.register_relation(rel_id, &name);
505 delta_tracker.insert(pred.clone(), rel_id, name);
506 }
507
508 // Execute all rules once against the current store to seed initial results.
509 // Accumulate per-head before mutating the store to avoid order dependence.
510 //
511 // Route through `execute_wcoj_or_fallback_node` so promoted
512 // MultiWayJoin bodies for stable and linear-recursive triangles or
513 // 4-cycles get a chance at WCOJ dispatch on the seeding pass. Stable
514 // rules with zero recursive scans only run here, so without this hook
515 // they would never see a kernel.
516 let mut derived_initial: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
517 for rule in rules {
518 let result = self.execute_wcoj_or_fallback_node(&rule.body)?;
519 derived_initial
520 .entry(rule.head.clone())
521 .or_default()
522 .push(result);
523 }
524
525 // Initialize delta from the newly-derived tuples only.
526 //
527 // This supports incremental maintenance: if the SCC is executed again after EDB inserts,
528 // the delta relations start with only the *new* tuples, not a full rescan of the current
529 // fixed point.
530 for pred in &recursive_preds {
531 // Read the prior full relation in place: the store is only
532 // mutated after the merge succeeds, so a failed union leaves the
533 // relation (and its version counter) intact.
534 let full_old = self
535 .store
536 .get(pred)
537 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
538
539 let derived = derived_initial.remove(pred).unwrap_or_default();
540
541 // One multiway union per head: the prior full relation and every
542 // same-head seed contribution are concatenated, sorted, and
543 // deduplicated in a single pass instead of one union per rule.
544 let mut union_inputs: Vec<&CudaBuffer> = Vec::with_capacity(derived.len() + 1);
545 union_inputs.push(full_old);
546 union_inputs.extend(derived.iter());
547 let full_new =
548 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
549 drop(union_inputs);
550 drop(derived);
551
552 let delta_name = delta_tracker.delta_name(pred)?;
553
554 let full_old_rows = self.buffer_row_count(full_old)?;
555 let full_new_rows = self.buffer_row_count(&full_new)?;
556 let delta_initial = if full_new_rows == 0 {
557 self.create_empty_buffer(full_new.schema().clone())?
558 } else if full_old_rows == 0 {
559 self.clone_buffer(&full_new)?
560 } else {
561 let diff_input = full_new.num_rows() + full_old.num_rows();
562 let start = self.profiler.start_op();
563 let diffed = self.provider.diff_gpu(&full_new, full_old)?;
564 if let Some(start) = start {
565 let mem = self.provider.memory().allocated_bytes();
566 self.profiler
567 .record_op("diff", diff_input, diffed.num_rows(), start, mem);
568 self.profiler.record_peak_memory(mem);
569 }
570 diffed
571 };
572
573 // Seed-iteration cardinality refresh. Capture the actual
574 // `delta_initial` row count before the `store_put` move; after the
575 // move, the buffer is gone.
576 let delta_initial_rows = self.buffer_row_count(&delta_initial)? as u64;
577 let seed_full_rows = full_new_rows as u64;
578 // Pre-resolve rel_id lookups before the &mut self stats
579 // borrow below.
580 let full_rel_opt = self.name_to_rel_id(pred);
581 let delta_rel = delta_tracker.delta_rel_id(pred)?;
582
583 self.store_put(pred, full_new);
584 self.store_put(delta_name, delta_initial);
585
586 // Stats updates fire whether or not WCOJ ran on the seed
587 // pass. update_cardinality is a no-op for unregistered
588 // rel_ids (defensive: tests that don't register an IDB
589 // head get a no-op for the full_rel write).
590 if let Some(full_rel) = full_rel_opt {
591 self.stats.update_cardinality(full_rel, seed_full_rows);
592 }
593 self.stats.update_cardinality(delta_rel, delta_initial_rows);
594
595 // Seed stats trace entry, gated on `recursive-stats-trace`.
596 #[cfg(feature = "recursive-stats-trace")]
597 self.last_recursive_stats_trace
598 .entries
599 .push(super::RecursiveStatsTraceEntry {
600 iteration: 0,
601 pred: pred.clone(),
602 full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
603 delta_rel,
604 full_rows: seed_full_rows,
605 delta_rows: delta_initial_rows,
606 phase: super::RecursiveStatsPhase::Seed,
607 binary_est_for_variant: None,
608 });
609 }
610
611 // Iterate until no new tuples are produced.
612 let mut reached_fixpoint = false;
613 let max_iterations = self.config.max_iterations as usize;
614 let mut iteration_count = 0usize;
615 // D3 — per-fixpoint dispatch context for the factorized delta
616 // (domain bounds + normalized EDB statics are cached across
617 // iterations).
618 let mut fd_ctx = super::wcoj_dispatch::FactorizedDeltaCtx::default();
619 for _iteration in 0..max_iterations {
620 iteration_count += 1;
621 // Compute delta_new_raw per head by evaluating each rule once per recursive Scan occurrence.
622 // Contributions are collected unmerged; the per-head finalize
623 // below unions each head's batch in one multiway pass instead of
624 // one union per rule.
625 let mut delta_new_raw_by_head: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
626 // D3 — factorized novel sets per head: already diffed
627 // against the stable relation and full-row deduped at
628 // dispatch time. Kept separate from the raw accumulator so
629 // all-factorized heads can skip the legacy diff entirely.
630 let mut delta_novel_by_head: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
631
632 for rule in rules {
633 let mut scans = Vec::new();
634 Self::collect_scan_rels(&rule.body, &mut scans);
635
636 // Build a list of (rel_id, occurrence_idx, pred_name) for recursive scans.
637 let mut seen: HashMap<RelId, usize> = HashMap::new();
638 let mut variants: Vec<(RelId, usize, String)> = Vec::new();
639 for rel_id in scans {
640 let pred_name = match self.get_rel_name(rel_id) {
641 Some(n) => n.to_string(),
642 None => continue,
643 };
644 if !recursive_pred_lookup.contains(&pred_name) {
645 continue;
646 }
647
648 // Skip variants where the delta for this predicate is empty.
649 let delta_name = match delta_tracker.get(&pred_name) {
650 Some((_rel_id, name)) => name.as_str(),
651 None => continue,
652 };
653 let delta_is_empty = match self.store.get(delta_name) {
654 Some(delta) => self.buffer_row_count(delta)? == 0,
655 None => true,
656 };
657 if delta_is_empty {
658 continue;
659 }
660
661 let occ = seen.entry(rel_id).or_insert(0);
662 variants.push((rel_id, *occ, pred_name));
663 *occ += 1;
664 }
665
666 if variants.is_empty() {
667 // Base rule: it can only contribute on the first seeding pass.
668 continue;
669 }
670
671 let mut rule_delta_raw: Vec<CudaBuffer> = Vec::new();
672 let mut rule_delta_novel: Vec<CudaBuffer> = Vec::new();
673 for (rel_id, occ, pred_name) in variants {
674 let delta_rel_id = delta_tracker.delta_rel_id(&pred_name)?;
675
676 let variant_node =
677 Self::rewrite_scan_nth(&rule.body, rel_id, occ, delta_rel_id).ok_or_else(
678 || {
679 XlogError::Execution(format!(
680 "Failed to rewrite rule body for predicate {}",
681 pred_name
682 ))
683 },
684 )?;
685
686 // Try the factorized delta pipeline first: a qualifying
687 // ChainJoin variant returns the novel set directly
688 // (already diffed against the head's stable relation and
689 // deduped). Declines are silent and fall through to the
690 // legacy path.
691 if let Some(novel) = self.try_dispatch_factorized_delta(
692 &variant_node,
693 delta_rel_id,
694 &rule.head,
695 &recursive_pred_lookup,
696 &mut fd_ctx,
697 )? {
698 rule_delta_novel.push(novel);
699 continue;
700 }
701
702 // Try WCOJ on the rewritten variant body before falling
703 // back to the binary-join walker.
704 // For a linear-recursive triangle/4-cycle, the
705 // variant has one Scan's RelId swapped to its
706 // delta — the kernel reads from the delta store
707 // entry transparently, no special-case dispatch
708 // logic needed.
709 let out = self.execute_wcoj_or_fallback_node(&variant_node)?;
710 rule_delta_raw.push(out);
711 }
712
713 // D3 — a rule with BOTH factorized and legacy variant
714 // outputs folds its novel rows into the raw batch
715 // (the legacy diff is a no-op on novel rows, so this is
716 // sound); an all-factorized rule keeps its novel rows on
717 // the diff-free track.
718 if !rule_delta_raw.is_empty() {
719 rule_delta_raw.append(&mut rule_delta_novel);
720 delta_new_raw_by_head
721 .entry(rule.head.clone())
722 .or_default()
723 .append(&mut rule_delta_raw);
724 } else if !rule_delta_novel.is_empty() {
725 delta_novel_by_head
726 .entry(rule.head.clone())
727 .or_default()
728 .append(&mut rule_delta_novel);
729 }
730 }
731
732 // Finalize delta_new per head: delta_new = dedup(delta_raw - full).
733 delta_tracker.begin_iteration();
734
735 for pred in &recursive_preds {
736 let full = self
737 .store
738 .get(pred)
739 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
740 // Capture the current full row count for the trace's
741 // `full_rows` field before this iteration's delta relation is
742 // replaced. Gated on `recursive-stats-trace` so production
743 // builds do not compute it.
744 #[cfg(feature = "recursive-stats-trace")]
745 let pre_phase4_full_rows = self.buffer_row_count(full)? as u64;
746
747 let mut raw_bufs = delta_new_raw_by_head.remove(pred).unwrap_or_default();
748 let mut novel_bufs = delta_novel_by_head.remove(pred).unwrap_or_default();
749 // D3 — when a head received both raw and factorized
750 // contributions (different rules), fold the novel rows
751 // into the raw batch before the legacy diff (sound: the
752 // diff is a no-op on novel rows). An all-factorized
753 // head skips the diff entirely — its novel rows are
754 // already diffed and deduped by construction.
755 if !raw_bufs.is_empty() {
756 raw_bufs.append(&mut novel_bufs);
757 }
758 let delta_new = if !novel_bufs.is_empty() {
759 if novel_bufs.len() == 1 {
760 novel_bufs.pop().expect("len checked")
761 } else {
762 // Novel sets are deduped per rule, not across rules,
763 // so a multi-rule head still unions its novel batch.
764 let union_inputs: Vec<&CudaBuffer> = novel_bufs.iter().collect();
765 Self::union_batch_profiled(
766 &self.provider,
767 &mut self.profiler,
768 &union_inputs,
769 )?
770 }
771 } else if !raw_bufs.is_empty() {
772 let delta_raw = if raw_bufs.len() == 1 {
773 raw_bufs.pop().expect("len checked")
774 } else {
775 // One multiway union per head instead of one union
776 // per rule contribution.
777 let union_inputs: Vec<&CudaBuffer> = raw_bufs.iter().collect();
778 Self::union_batch_profiled(
779 &self.provider,
780 &mut self.profiler,
781 &union_inputs,
782 )?
783 };
784 drop(raw_bufs);
785 if self.buffer_row_count(&delta_raw)? == 0 {
786 self.create_empty_buffer(full.schema().clone())?
787 } else {
788 let diff_input = delta_raw.num_rows() + full.num_rows();
789 let start = self.profiler.start_op();
790 let diffed = self.provider.diff_gpu(&delta_raw, full)?;
791 if let Some(start) = start {
792 let mem = self.provider.memory().allocated_bytes();
793 self.profiler.record_op(
794 "diff",
795 diff_input,
796 diffed.num_rows(),
797 start,
798 mem,
799 );
800 self.profiler.record_peak_memory(mem);
801 }
802 diffed
803 }
804 } else {
805 self.create_empty_buffer(full.schema().clone())?
806 };
807
808 let delta_name = delta_tracker.delta_name(pred)?.to_string();
809 let delta_new_rows = self.buffer_row_count(&delta_new)? as u64;
810 if delta_new_rows != 0 {
811 delta_tracker.mark_changed();
812 }
813 // Pre-resolve rel_id lookups before the &mut self
814 // store_put + stats update below. `full_rel_opt` is
815 // only used by the trace under the
816 // `recursive-stats-trace` feature.
817 #[cfg(feature = "recursive-stats-trace")]
818 let full_rel_opt = self.name_to_rel_id(pred);
819 let delta_rel = delta_tracker.delta_rel_id(pred)?;
820 self.store_put(&delta_name, delta_new);
821
822 // Refresh the delta relation cardinality after computing this
823 // iteration's delta. The full relation cardinality is not
824 // updated here because the full relation has not changed yet
825 // this iteration; the merge step owns that.
826 self.stats.update_cardinality(delta_rel, delta_new_rows);
827
828 // Delta stats trace entry, gated on `recursive-stats-trace`.
829 // binary_est_for_variant captures the cost model's
830 // first-binary-hop estimate for the linear-recursive fixtures
831 // (`pred == "e1"` rewrites
832 // Scan(e1) → Scan(delta_e1); first hop is
833 // `delta_e1.col1 ⋈ e2.col0`). Populated inline because
834 // delta_rel is unregistered at fixpoint exit, so the
835 // test cannot recompute after `execute_plan` returns.
836 #[cfg(feature = "recursive-stats-trace")]
837 let binary_est_for_variant: Option<u64> = if pred == "e1" {
838 self.name_to_rel_id("e2").map(|e2_rel| {
839 self.stats
840 .estimate_join_cardinality(delta_rel, e2_rel, &[1], &[0])
841 })
842 } else {
843 None
844 };
845 #[cfg(feature = "recursive-stats-trace")]
846 self.last_recursive_stats_trace
847 .entries
848 .push(super::RecursiveStatsTraceEntry {
849 iteration: iteration_count,
850 pred: pred.clone(),
851 full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
852 delta_rel,
853 full_rows: pre_phase4_full_rows,
854 delta_rows: delta_new_rows,
855 phase: super::RecursiveStatsPhase::Phase2Delta,
856 binary_est_for_variant,
857 });
858 }
859
860 // Fixpoint reached if no deltas produced.
861 if delta_tracker.is_converged() {
862 reached_fixpoint = true;
863 self.profiler.record_iterations(iteration_count);
864 break;
865 }
866
867 // Merge deltas into full relations.
868 for pred in &recursive_preds {
869 let dn = delta_tracker.delta_name(pred)?.to_string();
870 // Read both relations in place: the store is only mutated
871 // after the merge succeeds, so a failed union leaves the
872 // full relation (and its version counter) intact, and the
873 // unchanged delta never needs a remove/re-put round trip.
874 let full_old = self
875 .store
876 .get(pred)
877 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
878 let delta = self
879 .store
880 .get(&dn)
881 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", dn)))?;
882
883 if self.buffer_row_count(delta)? == 0 {
884 // Zero-delta short-circuit: full and delta are unchanged
885 // this iteration. The delta relation record with zero
886 // rows stands, and the full relation record from the prior
887 // merge stands. No additional update is needed.
888 continue;
889 }
890
891 let union_input = full_old.num_rows() + delta.num_rows();
892 let start = self.profiler.start_op();
893 let merged = self.provider.union_gpu(full_old, delta)?;
894 if let Some(start) = start {
895 let mem = self.provider.memory().allocated_bytes();
896 self.profiler
897 .record_op("union", union_input, merged.num_rows(), start, mem);
898 self.profiler.record_peak_memory(mem);
899 }
900
901 let full_new = merged;
902 // Capture `full_new`'s row count before the `store_put` move
903 // and pre-resolve `full_rel_opt` before the mutable stats
904 // borrow. The delta row count and delta relation id are only
905 // used by the trace under the `recursive-stats-trace` feature.
906 let full_new_rows_phase4 = self.buffer_row_count(&full_new)? as u64;
907 #[cfg(feature = "recursive-stats-trace")]
908 let delta_rows_phase4 = self.buffer_row_count(delta)? as u64;
909 let full_rel_opt = self.name_to_rel_id(pred);
910 #[cfg(feature = "recursive-stats-trace")]
911 let delta_rel = delta_tracker.delta_rel_id(pred)?;
912 self.store_put(pred, full_new);
913
914 // Record the full relation's new cardinality. The delta
915 // relation was already recorded for this iteration.
916 if let Some(full_rel) = full_rel_opt {
917 self.stats
918 .update_cardinality(full_rel, full_new_rows_phase4);
919 }
920 self.refresh_kclique_edge_metadata_after_merge(rules, pred);
921
922 // Full-relation stats trace entry, gated on `recursive-stats-trace`.
923 #[cfg(feature = "recursive-stats-trace")]
924 self.last_recursive_stats_trace
925 .entries
926 .push(super::RecursiveStatsTraceEntry {
927 iteration: iteration_count,
928 pred: pred.clone(),
929 full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
930 delta_rel,
931 full_rows: full_new_rows_phase4,
932 delta_rows: delta_rows_phase4,
933 phase: super::RecursiveStatsPhase::Phase4Full,
934 binary_est_for_variant: None,
935 });
936 }
937 }
938
939 // Cleanup: remove delta relations from store and relation mapping.
940 for (_pred, (rel_id, delta_name)) in delta_tracker.into_inner() {
941 self.store_remove(&delta_name);
942 self.rel_names.remove(&rel_id);
943 self.name_to_rel.remove(&delta_name);
944 let _ = self.stats.unregister_relation(rel_id);
945 }
946
947 if !reached_fixpoint {
948 // Record iterations even on failure for debugging
949 self.profiler.record_iterations(iteration_count);
950 return Err(XlogError::Execution(format!(
951 "Recursive SCC iteration limit ({}) exceeded",
952 self.config.max_iterations
953 )));
954 }
955
956 Ok(())
957 }
958
959 /// Execute a Fixpoint node using semi-naive evaluation
960 ///
961 /// The semi-naive algorithm avoids redundant computation in recursive queries:
962 ///
963 /// 1. **Initialize:**
964 /// - Compute base case: `R = base_result`
965 /// - Set delta to base: `delta = R`
966 /// - Store both `R` and `delta` in RelationStore
967 ///
968 /// 2. **Iterate until fixpoint:**
969 /// - Compute new tuples: `delta_new = recursive_result` using current `delta`
970 /// - Remove already-known tuples: `delta_new = delta_new - R`
971 /// - If `delta_new` is empty, we have reached fixpoint
972 /// - Otherwise: `R = R union delta_new`, `delta = delta_new`
973 ///
974 /// 3. **Return:** Final `R`
975 ///
976 /// # Arguments
977 /// * `scc_id` - SCC identifier for logging/debugging
978 /// * `base` - Base case RIR tree (non-recursive facts/rules)
979 /// * `recursive` - Recursive RIR tree (references delta relation)
980 /// * `delta_rel` - RelId for delta relation
981 /// * `full_rel` - RelId for full relation
982 ///
983 /// # Returns
984 /// A CudaBuffer containing the final fixpoint result
985 ///
986 /// # Errors
987 /// Returns an error if iteration limit is exceeded
988 pub(super) fn execute_fixpoint(
989 &mut self,
990 scc_id: u32,
991 base: &RirNode,
992 recursive: &RirNode,
993 delta_rel: RelId,
994 full_rel: RelId,
995 ) -> Result<CudaBuffer> {
996 // Compute base case R = eval(base)
997 let r_initial = self.execute_node(base)?;
998
999 // Handle empty base case using device-resident row count
1000 if self.buffer_row_count(&r_initial)? == 0 {
1001 return Ok(r_initial);
1002 }
1003
1004 // Initialize delta = R (clone the base result)
1005 let delta_initial = self.clone_buffer(&r_initial)?;
1006
1007 // Get relation names for delta and full relations
1008 let delta_name = self.get_or_create_rel_name(delta_rel, &format!("__delta_{}", scc_id));
1009 let full_name = self.get_or_create_rel_name(full_rel, &format!("__full_{}", scc_id));
1010
1011 // Store initial R and delta in relation store
1012 self.store_put(&full_name, r_initial);
1013 self.store_put(&delta_name, delta_initial);
1014
1015 // Iterate until fixpoint
1016 for _iteration in 0..Self::MAX_FIXPOINT_ITERATIONS {
1017 // Evaluate recursive step using current delta
1018 // The recursive RIR tree should reference delta_rel internally
1019 let delta_new_raw = self.execute_node(recursive)?;
1020
1021 // Get current R for set difference
1022 let current_r = self.store.get(&full_name).ok_or_else(|| {
1023 XlogError::Execution(format!(
1024 "Full relation {} not found during fixpoint iteration",
1025 full_name
1026 ))
1027 })?;
1028
1029 // Compute delta_new = delta_new_raw - R (remove already-known tuples)
1030 let delta_new = self.provider.diff_gpu(&delta_new_raw, current_r)?;
1031
1032 // Check for fixpoint: if delta_new is empty, we are done
1033 if self.buffer_row_count(&delta_new)? == 0 {
1034 // Fixpoint reached - return final R
1035 let final_r = self.store_remove(&full_name).ok_or_else(|| {
1036 XlogError::Execution("Full relation lost during fixpoint".to_string())
1037 })?;
1038
1039 // Clean up delta relation
1040 self.store_remove(&delta_name);
1041
1042 return Ok(final_r);
1043 }
1044
1045 // Not at fixpoint yet: R = R union delta_new
1046 let new_r = self.provider.union_gpu(current_r, &delta_new)?;
1047
1048 // Update relations for next iteration
1049 // delta = delta_new (the newly discovered tuples)
1050 self.store_put(&delta_name, delta_new);
1051 self.store_put(&full_name, new_r);
1052 }
1053
1054 // Iteration limit exceeded
1055 Err(XlogError::Execution(format!(
1056 "Fixpoint iteration limit ({}) exceeded for SCC {}",
1057 Self::MAX_FIXPOINT_ITERATIONS,
1058 scc_id
1059 )))
1060 }
1061}