Skip to main content

fuel_dispatch/ranker/
judge.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `JudgeOracle` — abstraction over the Phase 6b empirical
3//! profiler for the cost composer's Layer-2 refinement.
4//!
5//! Phase 3 of the picker-work arc.
6//!
7//! # Why a trait
8//!
9//! The Phase 6b Judge + its `DispatchTable` + `ProfileReport` all
10//! live in `fuel-core` (specifically `fuel_tensor::judge::*` after the
11//! 2026-05-31 rename of `fuel_core::dispatch` → `fuel_core::judge`).
12//! `fuel-dispatch` can't depend on `fuel-core` (that would cycle —
13//! `fuel-core` already depends on `fuel-dispatch`), so the integration
14//! happens through a trait: this module defines the contract, and
15//! `fuel-core` ships an adapter impl on top of the live profile data.
16//!
17//! # The contract
18//!
19//! For each `(op, dtype, size_class, backend, kernel_source)`
20//! lookup, return the measured median latency in nanoseconds when
21//! available. `None` means "no measurement" — the cost composer
22//! leaves the Layer-1 static estimate in place rather than
23//! substituting a fabricated number.
24//!
25//! `kernel_source` distinguishes sibling kernels that register at the
26//! same `(backend, device)` pair — e.g. AOCL vs MKL vs portable-cpu
27//! at `BackendId::Cpu`, or cuBLAS vs CUTLASS at `BackendId::Cuda`.
28//! Use `""` for the legacy single-impl cells.
29//!
30//! Implementors are typically free-function HashMaps built once
31//! from a `ProfileReport`. The trait stays narrow on purpose: the
32//! optimizer ranker doesn't need to know HOW the Judge collected
33//! the data, just that it can be queried per cell.
34
35use fuel_ir::DType;
36use fuel_ir::dispatch::{OpKind, SizeClass};
37use fuel_ir::probe::BackendId;
38
39/// Read-only oracle over empirical measurements. Phase 3 wires this
40/// into [`super::cost::compute_static_costs`] as the optional
41/// Layer-2 refinement source.
42pub trait JudgeOracle: Send + Sync {
43    /// Median wall-clock latency in nanoseconds for the
44    /// `(op, dtype, size_class, backend, kernel_source,
45    /// kernel_revision_hash)` cell. Returns `None` when the cell isn't
46    /// profiled. Callers MUST treat absence as "no measurement" — not
47    /// "zero" — so the static estimate stays the fallback.
48    ///
49    /// `kernel_source` is the [`fuel_ir::dispatch::Pick::kernel_source`]
50    /// tag (e.g. `"aocl"`, `"mkl"`, `"cublas"`, `"cutlass"`,
51    /// `"portable-cpu"`, `"slang"`) — the *provider*-granular axis. It
52    /// alone does not disambiguate variant siblings of one provider
53    /// (forty tilings of `"baracuda"`), so `kernel_revision_hash` — the
54    /// content-derived *variant*-granular axis, completing
55    /// `ImplId = (backend, op, dtypes, kernel_source, revision)` — is
56    /// part of the key. Two distinct non-zero revisions are two cells.
57    /// Pass `""` / `0` for legacy / single-impl / untracked cells.
58    fn measured_latency_ns(
59        &self,
60        op: OpKind,
61        dtype: DType,
62        size_class: SizeClass,
63        backend: BackendId,
64        kernel_source: &str,
65        kernel_revision_hash: u64,
66    ) -> Option<u64>;
67}
68
69/// HashMap-backed JudgeOracle. The simplest possible impl —
70/// callers (and tests) populate it from a `ProfileReport` or
71/// equivalent and hand it to [`super::cost::compute_static_costs`].
72///
73/// Production callers typically use this directly: `fuel_core`'s
74/// integration computes a `HashMapJudge` from the cached
75/// `ProfileReport` once at plan-start and feeds the same instance
76/// to every `compile_plan` call until the topology generation
77/// changes.
78#[derive(Debug, Default, Clone)]
79pub struct HashMapJudge {
80    entries: std::collections::HashMap<(OpKind, DType, SizeClass, BackendId, String, u64), u64>,
81}
82
83impl HashMapJudge {
84    /// Empty map. Add entries via [`Self::insert`].
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Insert one measured latency. Replaces any prior entry with
90    /// the same key (last-write-wins, mirroring the conventions in
91    /// `fuel_tensor::judge::DispatchTable::rebuild_from`).
92    ///
93    /// `kernel_source` distinguishes sibling kernels at the same
94    /// `(backend, device)` slot — see [`JudgeOracle::measured_latency_ns`].
95    /// The owned `String` is stored as part of the key; pass `""`
96    /// for legacy single-impl cells.
97    /// `kernel_revision_hash` is last in the parameter list (after the
98    /// `latency_ns` value) deliberately: the key tuple's canonical order
99    /// keeps it beside `kernel_source`, but placing it last here lets the
100    /// many existing call sites append the argument rather than splice it
101    /// mid-call. `0` is the untracked / single-impl default.
102    pub fn insert(
103        &mut self,
104        op: OpKind,
105        dtype: DType,
106        size_class: SizeClass,
107        backend: BackendId,
108        kernel_source: &str,
109        latency_ns: u64,
110        kernel_revision_hash: u64,
111    ) {
112        self.entries.insert(
113            (
114                op,
115                dtype,
116                size_class,
117                backend,
118                kernel_source.to_string(),
119                kernel_revision_hash,
120            ),
121            latency_ns,
122        );
123    }
124
125    /// Total number of populated cells.
126    pub fn len(&self) -> usize {
127        self.entries.len()
128    }
129
130    /// Is the map empty?
131    pub fn is_empty(&self) -> bool {
132        self.entries.is_empty()
133    }
134}
135
136impl JudgeOracle for HashMapJudge {
137    fn measured_latency_ns(
138        &self,
139        op: OpKind,
140        dtype: DType,
141        size_class: SizeClass,
142        backend: BackendId,
143        kernel_source: &str,
144        kernel_revision_hash: u64,
145    ) -> Option<u64> {
146        // Look up via the borrowed-key trick: build a tuple with an
147        // owned `String` only when needed. Most cells use `""` or a
148        // small interned tag so the allocation is cheap, and HashMap
149        // borrow-trait keys for tuples-with-String don't work
150        // out-of-the-box.
151        self.entries
152            .get(&(
153                op,
154                dtype,
155                size_class,
156                backend,
157                kernel_source.to_string(),
158                kernel_revision_hash,
159            ))
160            .copied()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn hashmap_judge_round_trip() {
170        let mut j = HashMapJudge::new();
171        assert!(j.is_empty());
172        j.insert(
173            OpKind::MatMul,
174            DType::F32,
175            SizeClass(16),
176            BackendId::Cuda,
177            "cublas",
178            5_000_000,
179            0,
180        );
181        assert_eq!(j.len(), 1);
182        assert_eq!(
183            j.measured_latency_ns(
184                OpKind::MatMul,
185                DType::F32,
186                SizeClass(16),
187                BackendId::Cuda,
188                "cublas",
189                0,
190            ),
191            Some(5_000_000),
192        );
193    }
194
195    #[test]
196    fn hashmap_judge_miss_returns_none() {
197        let j = HashMapJudge::new();
198        assert!(
199            j.measured_latency_ns(
200                OpKind::AddElementwise,
201                DType::F32,
202                SizeClass(8),
203                BackendId::Cpu,
204                "",
205                0,
206            )
207            .is_none()
208        );
209    }
210
211    #[test]
212    fn hashmap_judge_last_write_wins_per_kernel_source() {
213        // Same (op, dtype, size, backend) but identical kernel_source —
214        // last write wins per cell.
215        let mut j = HashMapJudge::new();
216        j.insert(
217            OpKind::MatMul,
218            DType::F32,
219            SizeClass(16),
220            BackendId::Cpu,
221            "aocl",
222            1_000,
223            0,
224        );
225        j.insert(
226            OpKind::MatMul,
227            DType::F32,
228            SizeClass(16),
229            BackendId::Cpu,
230            "aocl",
231            2_000,
232            0,
233        );
234        assert_eq!(
235            j.measured_latency_ns(
236                OpKind::MatMul,
237                DType::F32,
238                SizeClass(16),
239                BackendId::Cpu,
240                "aocl",
241                0,
242            ),
243            Some(2_000),
244        );
245        // Distinct kernel_source at the same (op, dtype, size, backend)
246        // produces a DISTINCT entry — no collision.
247        j.insert(
248            OpKind::MatMul,
249            DType::F32,
250            SizeClass(16),
251            BackendId::Cpu,
252            "mkl",
253            1_500,
254            0,
255        );
256        assert_eq!(j.len(), 2);
257        assert_eq!(
258            j.measured_latency_ns(
259                OpKind::MatMul,
260                DType::F32,
261                SizeClass(16),
262                BackendId::Cpu,
263                "mkl",
264                0,
265            ),
266            Some(1_500),
267        );
268        assert_eq!(
269            j.measured_latency_ns(
270                OpKind::MatMul,
271                DType::F32,
272                SizeClass(16),
273                BackendId::Cpu,
274                "aocl",
275                0,
276            ),
277            Some(2_000),
278        );
279        // Unknown kernel_source at the same (op, dtype, size, backend)
280        // misses — no fallthrough to either sibling.
281        assert!(
282            j.measured_latency_ns(
283                OpKind::MatMul,
284                DType::F32,
285                SizeClass(16),
286                BackendId::Cpu,
287                "portable-cpu",
288                0,
289            )
290            .is_none()
291        );
292    }
293
294    #[test]
295    fn hashmap_judge_distinguishes_backends_at_same_key() {
296        let mut j = HashMapJudge::new();
297        let (op, dt, sc) = (OpKind::MatMul, DType::F32, SizeClass(16));
298        j.insert(op, dt, sc, BackendId::Cpu, "", 1_000_000, 0);
299        j.insert(op, dt, sc, BackendId::Cuda, "", 100_000, 0);
300        assert_eq!(
301            j.measured_latency_ns(op, dt, sc, BackendId::Cpu, "", 0),
302            Some(1_000_000)
303        );
304        assert_eq!(
305            j.measured_latency_ns(op, dt, sc, BackendId::Cuda, "", 0),
306            Some(100_000)
307        );
308        assert!(
309            j.measured_latency_ns(op, dt, sc, BackendId::Vulkan, "", 0)
310                .is_none()
311        );
312    }
313
314    #[test]
315    fn trait_is_dyn_compatible() {
316        // Compile-time check encoded as runtime construction.
317        let j: Box<dyn JudgeOracle> = Box::new(HashMapJudge::new());
318        assert!(
319            j.measured_latency_ns(
320                OpKind::AddElementwise,
321                DType::F32,
322                SizeClass(0),
323                BackendId::Cpu,
324                "",
325                0,
326            )
327            .is_none()
328        );
329    }
330
331    /// Two entries at the same `(op, dtype, size, backend, kernel_source)`
332    /// cell differing ONLY in `kernel_revision_hash` — both NON-ZERO —
333    /// resolve to DISTINCT latencies. `kernel_source` alone (`"baracuda"`)
334    /// cannot tell forty tilings of one provider apart, so without the
335    /// revision in the key they would collapse to one cell. Both revisions
336    /// are nonzero deliberately: a `0`-vs-nonzero variant would pass while
337    /// real siblings still collapsed.
338    #[test]
339    fn sibling_kernel_revisions_do_not_collide() {
340        let mut j = HashMapJudge::new();
341        let (op, dt, sc, be, src) = (
342            OpKind::MatMul,
343            DType::F32,
344            SizeClass(16),
345            BackendId::Cuda,
346            "baracuda",
347        );
348        j.insert(op, dt, sc, be, src, 5_000, 0x1111_1111_1111_1111);
349        j.insert(op, dt, sc, be, src, 9_000, 0x2222_2222_2222_2222);
350        assert_eq!(j.len(), 2, "distinct non-zero revisions are distinct cells");
351        assert_eq!(
352            j.measured_latency_ns(op, dt, sc, be, src, 0x1111_1111_1111_1111),
353            Some(5_000),
354        );
355        assert_eq!(
356            j.measured_latency_ns(op, dt, sc, be, src, 0x2222_2222_2222_2222),
357            Some(9_000),
358        );
359        // An untracked (0) revision at the same tag is its OWN cell — no
360        // fallthrough to either measured sibling.
361        assert!(j.measured_latency_ns(op, dt, sc, be, src, 0).is_none());
362    }
363}