xlog_integration/wcoj_dispatch.rs
1//! v0.6.2 minimal env-gated GPU 3-way WCOJ triangle dispatch.
2//!
3//! Single public entry:
4//! [`try_wcoj_triangle_u32_dispatch`] (env-driven) and
5//! [`try_wcoj_triangle_u32_dispatch_with_gate`] (boolean-driven,
6//! for tests). The slice is intentionally narrow:
7//!
8//! * **Env flag only.** `XLOG_USE_WCOJ_TRIANGLE_U32=1` (or
9//! `true`/`TRUE`) opts in. Anything else (unset, `0`,
10//! `false`, etc.) means the helper returns `Ok(None)`
11//! unconditionally — the caller takes the existing
12//! binary-join path.
13//! * **Recognizes exactly one shape.** A rule of the form
14//! `tri(X, Y, Z) :- e1(X, Y), e2(Y, Z), e3(X, Z)` over
15//! 2-column WCOJ-eligible relations (U32, Symbol, or U64
16//! keys — see `WcojKeyWidth`): three positive 2-arity body
17//! atoms covering the head's three distinct variables in
18//! head-position order. No negation, no comparison filters,
19//! no recursion (head predicate not in body), no
20//! reversed-axis atoms (e.g. `e1(Y, X)`), no constants in
21//! atom args. The planner must also return
22//! [`xlog_logic::hypergraph::RulePlan::MultiwayCandidate`].
23//! * **Width uniformity.** All three slots must share a key
24//! width. A mixed-width triangle (e.g. e1 U32, e2 U64) is
25//! rejected at this dispatch level — the binary-join chain
26//! handles it.
27//! * **Silent fallback.** Any mismatch — gate off, shape
28//! mismatch, planner verdict not multiway, missing input
29//! buffer, unsupported scalar type, mixed-width slots —
30//! returns `Ok(None)` without an error or log line. The
31//! caller is expected to silently route to the existing
32//! binary-join path. This keeps the env flag truly opt-in
33//! and prevents the helper from accidentally diverting
34//! work it can't handle.
35//! * **Strict GPU pipeline on dispatch.** When all checks pass,
36//! the helper builds three sorted+deduped layouts and runs
37//! the matching WCOJ triangle kernel on the configured
38//! `launch_stream` — `wcoj_layout_u32_recorded` /
39//! `wcoj_triangle_u32_recorded` for 4-byte keys, the
40//! `_u64_recorded` siblings for 8-byte keys. All
41//! [`xlog_cuda::launch::LaunchRecorder`] discipline carries
42//! through unchanged.
43//!
44//! What this slice deliberately does NOT do:
45//!
46//! * No automatic detection at the executor level — callers
47//! pass the rule + input buffers explicitly. Executor
48//! wiring lives in `xlog-runtime`.
49//! * No recursion / SCC mixed execution.
50//! * No cost model.
51//! * No mixed-width admission (U32+U64 triangle stays on the
52//! binary-join path).
53//! * No histogram-guided block dispatch.
54
55use std::collections::{BTreeMap, BTreeSet};
56
57use xlog_core::{resolve_bool, Result, ScalarType};
58use xlog_cuda::device_runtime::StreamId;
59use xlog_cuda::memory::CudaBuffer;
60use xlog_cuda::CudaKernelProvider;
61use xlog_logic::ast::{BodyLiteral, Rule, Term};
62use xlog_logic::hypergraph::{plan_rule, RefRelation, RefRelationStore, RefValue, RulePlan};
63
64/// Env variable controlling the dispatch gate. Treated as ON
65/// when set to `"1"` or case-insensitive `"true"`; anything else
66/// (unset, `"0"`, `"false"`, empty string, …) means OFF.
67pub const ENV_USE_WCOJ_TRIANGLE_U32: &str = "XLOG_USE_WCOJ_TRIANGLE_U32";
68
69/// Env-driven entry. Reads `XLOG_USE_WCOJ_TRIANGLE_U32` and
70/// delegates to [`try_wcoj_triangle_u32_dispatch_with_gate`].
71///
72/// Tests should prefer [`try_wcoj_triangle_u32_dispatch_with_gate`]
73/// to avoid races on the process-global env var; the env-driven
74/// form exists for production callers that want to opt in via
75/// configuration alone.
76pub fn try_wcoj_triangle_u32_dispatch(
77 rule: &Rule,
78 inputs: &BTreeMap<String, CudaBuffer>,
79 provider: &CudaKernelProvider,
80 launch_stream: StreamId,
81) -> Result<Option<CudaBuffer>> {
82 let gate = resolve_bool(None, ENV_USE_WCOJ_TRIANGLE_U32, false)?;
83 try_wcoj_triangle_u32_dispatch_with_gate(gate, rule, inputs, provider, launch_stream)
84}
85
86/// Test-friendly form that takes the gate as an explicit boolean.
87/// Production callers use [`try_wcoj_triangle_u32_dispatch`] which
88/// reads the env var.
89pub fn try_wcoj_triangle_u32_dispatch_with_gate(
90 gate_enabled: bool,
91 rule: &Rule,
92 inputs: &BTreeMap<String, CudaBuffer>,
93 provider: &CudaKernelProvider,
94 launch_stream: StreamId,
95) -> Result<Option<CudaBuffer>> {
96 if !gate_enabled {
97 return Ok(None);
98 }
99 // Match the triangle shape — silently bail on any mismatch.
100 let Some(matched) = match_triangle_shape(rule, inputs) else {
101 return Ok(None);
102 };
103
104 // Run the planner on a synthetic store carrying the inputs'
105 // schemas. plan_rule needs schemas (not rows) for typed
106 // gating; rows are not consulted at the gate phase.
107 let mut plan_store: RefRelationStore = BTreeMap::new();
108 for atom_match in &matched.atoms {
109 // Skip duplicates: the same predicate may legitimately
110 // appear in multiple slots (e.g. all three atoms over a
111 // single edge relation), and a BTreeMap insert collapses
112 // them naturally. Schemas are guaranteed identical
113 // because they all came from the same `inputs[name]`.
114 plan_store.insert(
115 atom_match.predicate.clone(),
116 schema_only_relation(&inputs[&atom_match.predicate]),
117 );
118 }
119 let plan = plan_rule(rule, &plan_store).ok();
120 match plan {
121 Some(RulePlan::MultiwayCandidate { .. }) => {}
122 _ => return Ok(None),
123 }
124
125 // Construct sorted+deduped layouts for each slot and run
126 // the WCOJ triangle kernel. Branch by width: 4-byte
127 // (U32 / Symbol) and 8-byte (U64) inputs go to parallel
128 // provider entries. Mixed-width triangles never reach this
129 // point — `match_triangle_shape` requires all three slots
130 // to share a width.
131 let result = match matched.width {
132 WcojKeyWidth::FourByte => {
133 let buf_xy = provider.wcoj_layout_u32_recorded(matched.e_xy, launch_stream)?;
134 let buf_yz = provider.wcoj_layout_u32_recorded(matched.e_yz, launch_stream)?;
135 let buf_xz = provider.wcoj_layout_u32_recorded(matched.e_xz, launch_stream)?;
136 provider.wcoj_triangle_u32_recorded(&buf_xy, &buf_yz, &buf_xz, launch_stream)?
137 }
138 WcojKeyWidth::EightByte => {
139 let buf_xy = provider.wcoj_layout_u64_recorded(matched.e_xy, launch_stream)?;
140 let buf_yz = provider.wcoj_layout_u64_recorded(matched.e_yz, launch_stream)?;
141 let buf_xz = provider.wcoj_layout_u64_recorded(matched.e_xz, launch_stream)?;
142 provider.wcoj_triangle_u64_recorded(&buf_xy, &buf_yz, &buf_xz, launch_stream)?
143 }
144 };
145 Ok(Some(result))
146}
147
148/// Matched atom slots after pattern recognition. Each `&CudaBuffer`
149/// borrows from the caller's `inputs` map. `width` is the key
150/// width shared by all three slots (mixed-width triangles never
151/// reach this struct — see `match_triangle_shape`).
152struct MatchedAtoms<'a> {
153 atoms: Vec<MatchedAtom>,
154 e_xy: &'a CudaBuffer,
155 e_yz: &'a CudaBuffer,
156 e_xz: &'a CudaBuffer,
157 width: WcojKeyWidth,
158}
159
160struct MatchedAtom {
161 predicate: String,
162}
163
164/// Physical key width for a WCOJ-eligible binary relation.
165/// `FourByte` covers `U32` and `Symbol` (bit-identical layout);
166/// `EightByte` covers `U64`. Other scalar types are not
167/// WCOJ-eligible at this dispatch level (the planner upstream
168/// is the source of truth for cross-relation type compatibility;
169/// `analyze_typed` rejects them before we get here).
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum WcojKeyWidth {
172 FourByte,
173 EightByte,
174}
175
176/// Attempt to match the rule + inputs against the v1 triangle
177/// shape. Returns `Some(...)` if every check passes; `None`
178/// otherwise. All checks are pure validation — no buffer
179/// modification, no kernel launches, no errors.
180fn match_triangle_shape<'a>(
181 rule: &Rule,
182 inputs: &'a BTreeMap<String, CudaBuffer>,
183) -> Option<MatchedAtoms<'a>> {
184 // Head must have exactly 3 distinct variable terms.
185 let head_vars = head_vars_or_none(rule)?;
186
187 // Body must have exactly 3 BodyLiteral::Positive atoms; no
188 // negation, no comparison filters, no is-expr.
189 if rule.body.len() != 3 {
190 return None;
191 }
192 let mut positive_atoms = Vec::with_capacity(3);
193 for literal in &rule.body {
194 match literal {
195 BodyLiteral::Positive(atom) => positive_atoms.push(atom),
196 _ => return None,
197 }
198 }
199
200 // Head predicate must NOT appear in the body — rules out
201 // direct recursion. Indirect recursion through SCC structure
202 // is also unsupported in this slice; since we only see one
203 // rule at a time, "head predicate equals any body
204 // predicate" is the conservative check.
205 let head_predicate = &rule.head.predicate;
206 for atom in &positive_atoms {
207 if &atom.predicate == head_predicate {
208 return None;
209 }
210 }
211
212 // Each atom must be 2-arity, with both args being head
213 // variables (not constants, anonymous, or non-head vars).
214 // Collect (head_position_0, head_position_1) per atom; the
215 // pair must be in ascending order — i.e. atom args are in
216 // head-position order, not reversed.
217 //
218 // After this collection, the three atoms together must
219 // cover EXACTLY the three pairs {0,1}, {1,2}, {0,2} (the
220 // triangle's three edges over head positions).
221 let mut atom_specs: Vec<(usize, usize, &xlog_logic::ast::Atom)> = Vec::with_capacity(3);
222 for atom in &positive_atoms {
223 if atom.terms.len() != 2 {
224 return None;
225 }
226 let pos0 = head_pos_of_var(&head_vars, &atom.terms[0])?;
227 let pos1 = head_pos_of_var(&head_vars, &atom.terms[1])?;
228 if pos0 >= pos1 {
229 // Same variable twice (`e(X, X)`) or reversed
230 // axis (`e(Y, X)`). Both are out-of-scope for v1
231 // dispatch.
232 return None;
233 }
234 atom_specs.push((pos0, pos1, atom));
235 }
236 let pair_set: BTreeSet<(usize, usize)> = atom_specs.iter().map(|(a, b, _)| (*a, *b)).collect();
237 let mut expected: BTreeSet<(usize, usize)> = BTreeSet::new();
238 expected.insert((0, 1));
239 expected.insert((1, 2));
240 expected.insert((0, 2));
241 if pair_set != expected {
242 return None;
243 }
244
245 // Look up each input buffer by predicate name. Validate
246 // every buffer is a 2-column WCOJ-eligible relation
247 // (4-byte U32/Symbol or 8-byte U64) AND that all three
248 // slots share the same width — mixed-width triangles fall
249 // back here so the binary-join chain handles them.
250 let mut e_xy: Option<&CudaBuffer> = None;
251 let mut e_yz: Option<&CudaBuffer> = None;
252 let mut e_xz: Option<&CudaBuffer> = None;
253 let mut atoms_out: Vec<MatchedAtom> = Vec::with_capacity(3);
254 let mut shared_width: Option<WcojKeyWidth> = None;
255 for (pos0, pos1, atom) in &atom_specs {
256 let buf = inputs.get(&atom.predicate)?;
257 let w = classify_two_col_wcoj_width(buf)?;
258 match shared_width {
259 None => shared_width = Some(w),
260 Some(prev) if prev == w => {}
261 Some(_) => return None,
262 }
263 atoms_out.push(MatchedAtom {
264 predicate: atom.predicate.clone(),
265 });
266 match (*pos0, *pos1) {
267 (0, 1) => e_xy = Some(buf),
268 (1, 2) => e_yz = Some(buf),
269 (0, 2) => e_xz = Some(buf),
270 _ => unreachable!("pair_set check above guarantees these three"),
271 }
272 }
273 Some(MatchedAtoms {
274 atoms: atoms_out,
275 e_xy: e_xy?,
276 e_yz: e_yz?,
277 e_xz: e_xz?,
278 width: shared_width?,
279 })
280}
281
282/// Return the rule head's variable names if every head term is a
283/// distinct `Term::Variable`; `None` otherwise.
284fn head_vars_or_none(rule: &Rule) -> Option<Vec<String>> {
285 if rule.head.terms.len() != 3 {
286 return None;
287 }
288 let mut out = Vec::with_capacity(3);
289 for term in &rule.head.terms {
290 match term {
291 Term::Variable(name) => out.push(name.clone()),
292 _ => return None,
293 }
294 }
295 // Distinctness.
296 let unique: BTreeSet<&String> = out.iter().collect();
297 if unique.len() != 3 {
298 return None;
299 }
300 Some(out)
301}
302
303/// Map an atom-arg term to its position in the head variable list
304/// (0, 1, or 2). Returns `None` for anything that isn't a
305/// head-list variable.
306fn head_pos_of_var(head_vars: &[String], term: &Term) -> Option<usize> {
307 match term {
308 Term::Variable(name) => head_vars.iter().position(|v| v == name),
309 _ => None,
310 }
311}
312
313/// Classify a binary [`CudaBuffer`]'s key width for WCOJ
314/// dispatch. Returns
315///
316/// * `Some(WcojKeyWidth::FourByte)` when both columns are
317/// `U32` or `Symbol` (bit-identical 4-byte physical layout —
318/// the WCOJ kernel reads them identically),
319/// * `Some(WcojKeyWidth::EightByte)` when both columns are
320/// `U64`,
321/// * `None` for any other arity / type combination, including
322/// mixed widths within a single buffer (e.g. one column U32
323/// and the other U64).
324///
325/// Cross-relation type-compatibility (so a Symbol column never
326/// joins with a U32 column with the same bit pattern, and a
327/// U32 column never joins with a U64 column) is enforced by
328/// the planner upstream via `xlog_logic::hypergraph::analyze_typed`;
329/// the dispatch helper additionally requires width-uniformity
330/// across the three slots in `match_triangle_shape`.
331fn classify_two_col_wcoj_width(buf: &CudaBuffer) -> Option<WcojKeyWidth> {
332 if buf.arity() != 2 {
333 return None;
334 }
335 let c0 = buf.schema().column_type(0)?;
336 let c1 = buf.schema().column_type(1)?;
337 let width0 = scalar_wcoj_width(c0)?;
338 let width1 = scalar_wcoj_width(c1)?;
339 if width0 != width1 {
340 return None;
341 }
342 Some(width0)
343}
344
345/// Map a single [`ScalarType`] to its WCOJ key width, or `None`
346/// if the type is not supported by any current WCOJ entry.
347fn scalar_wcoj_width(ty: ScalarType) -> Option<WcojKeyWidth> {
348 match ty {
349 ScalarType::U32 | ScalarType::Symbol => Some(WcojKeyWidth::FourByte),
350 ScalarType::U64 => Some(WcojKeyWidth::EightByte),
351 _ => None,
352 }
353}
354
355/// Build a synthetic schema-only [`RefRelation`] for the planner's
356/// view. Rows are intentionally empty: `plan_rule` only consults
357/// schemas for typed gating, never rows.
358fn schema_only_relation(buf: &CudaBuffer) -> RefRelation {
359 let arity = buf.arity();
360 let schema: Vec<ScalarType> = (0..arity)
361 .map(|i| buf.schema().column_type(i).unwrap_or(ScalarType::U32))
362 .collect();
363 RefRelation {
364 schema,
365 rows: Vec::<Vec<RefValue>>::new(),
366 }
367}