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}