Skip to main content

drmkit_planes/
scoring.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Placement scoring and static compatibility.
5//!
6//! Port of the pure decision functions from `src/planes/allocator.cpp`. None of
7//! these touch a device: they are the part of the allocator that can be tested
8//! exhaustively against synthetic fixtures.
9
10use drmkit_fmt::{BandwidthClass, Modifier, layer_fits_plane, scanout_cost_bytes};
11
12use crate::layer::{ContentType, Layer, Rect};
13use crate::prop::PropTag;
14use crate::registry::{PlaneCapabilities, PlaneType};
15
16/// The default worst-case compression ratio the cost model scores with.
17const COMPRESSED_RATIO: f32 = drmkit_fmt::DEFAULT_COMPRESSED_RATIO;
18
19/// Power-aware placement bonus for a buffer's bandwidth class.
20///
21/// Keeps compressed and tiled layers on planes — compositing one costs a GPU
22/// decompress — and lets the cheap `LINEAR` ones composite when planes are
23/// contested. A small tiebreak below the structural scores.
24#[must_use]
25pub const fn bandwidth_class_bonus(class: BandwidthClass) -> i32 {
26    match class {
27        // DCC / AFBC / UBWC: the biggest bandwidth and GPU-decompress saving.
28        BandwidthClass::Compression => 2,
29        // Better DRAM locality, same byte count.
30        BandwidthClass::Tiling => 1,
31        BandwidthClass::Linear => 0,
32    }
33}
34
35/// Bounded tiebreak by a layer's per-frame scanout byte cost.
36///
37/// A layer that moves more bytes is costlier to demote to composition, which
38/// re-reads both the source and the canvas. Bucketed to 0..=3 so it stays below
39/// the structural scores.
40#[must_use]
41pub const fn cost_bias(scanout_bytes: u64) -> i32 {
42    const MB: u64 = 1024 * 1024;
43    if scanout_bytes >= 16 * MB {
44        3 // ~4K RGBA and up
45    } else if scanout_bytes >= 4 * MB {
46        2 // ~1080p RGBA
47    } else if scanout_bytes >= MB {
48        1 // small overlay
49    } else {
50        0
51    }
52}
53
54/// Content-class placement priority.
55#[must_use]
56pub fn layer_priority(layer: &Layer) -> i32 {
57    if layer.content_type() == ContentType::Video {
58        return 100;
59    }
60    if layer.update_hz() > 30 {
61        return 80;
62    }
63    if layer.update_hz() > 0 {
64        return 50;
65    }
66    10
67}
68
69/// Keep-or-drop ordering under plane pressure.
70///
71/// Content class dominates; the application's own priority only breaks ties
72/// within a class. `layer_priority` is scaled above the `u8` range so a higher
73/// content class always outranks any `app_priority`.
74#[must_use]
75pub fn keep_priority(layer: &Layer) -> i32 {
76    layer_priority(layer) * 256 + i32::from(layer.app_priority())
77}
78
79/// The bonus for keeping a layer on the plane it held last frame.
80///
81/// Deliberately larger than the sum of every structural term below it, so that
82/// **among equal-cardinality matchings** the previous assignment wins.
83///
84/// This is the v2.0.1 warm-start stability bonus (plan §4.6). It is safe
85/// precisely because the matcher maximizes cardinality first and uses score
86/// only to order tie-breaks: it can never place fewer layers, and never
87/// overrides validity — [`plane_statically_compatible`] gates the edges and the
88/// `TEST_ONLY` commit is the arbiter.
89///
90/// Without it, any change to the layer *set* — a compositor inserting a UI
91/// backing-store slice while the platform-view overlays stay put — drops
92/// warm-start, makes the full search re-solve from scratch, and reshuffles
93/// already-placed layers onto different planes. Every overlay reprograms in one
94/// commit, which is seen as flicker.
95pub const WARM_STABILITY_BONUS: i32 = 100;
96
97/// Everything `score_pair` needs that is not the plane or the layer.
98#[derive(Debug, Clone, Copy, Default)]
99#[allow(
100    clippy::exhaustive_structs,
101    reason = "built by callers with `..Default::default()`, which a new field does not break"
102)]
103pub struct ScoreContext {
104    /// Whether the layer held this plane last frame.
105    pub held_last_frame: bool,
106    /// How many times this `(plane, property_hash)` pair has failed a test
107    /// commit. Subtracted from the score.
108    pub failure_hits: u32,
109}
110
111/// Score a candidate placement. Higher is more preferred.
112///
113/// Score orders the search only — it can never change how many layers get
114/// placed. See [`WARM_STABILITY_BONUS`].
115#[must_use]
116pub fn score_pair(plane: &PlaneCapabilities, layer: &Layer, context: ScoreContext) -> i32 {
117    let mut score = 0;
118
119    // A plane advertising the layer's exact (format, modifier) is strictly
120    // preferable to one that supports the format only via linear fallback.
121    if let Some(fourcc) = layer.format()
122        && layer_fits_plane(&plane.format_table, fourcc, Modifier(layer.modifier()))
123    {
124        score += 4;
125    }
126
127    let is_composition = layer.is_composition_layer();
128
129    // A composition layer strongly prefers the primary plane.
130    if is_composition && plane.plane_type == PlaneType::Primary {
131        score += 8;
132    }
133
134    let zpos = layer.property(PropTag::Zpos);
135
136    // A non-composition layer whose zpos matches primary's minimum slot is
137    // explicitly asking for direct scanout on primary -- the bottommost
138    // toolkit layer, with zpos pinned to primary.zpos_min. Without this the
139    // overlay bonus below wins, primary stays framebuffer-less, and whatever
140    // was on it before (typically the fbcon console) stays on scanout.
141    if !is_composition
142        && plane.plane_type == PlaneType::Primary
143        && let Some(z) = zpos
144    {
145        match plane.zpos_min {
146            Some(min) if z == min => score += 10,
147            // Platforms with no kernel-side zpos property (Tegra Orin display
148            // among others) cannot honor per-plane zpos, so the caller
149            // convention is zpos 0 == bottom-most == primary.
150            None if z == 0 => score += 10,
151            _ => {}
152        }
153    }
154
155    // Non-composition layers otherwise prefer overlays.
156    if !is_composition && plane.plane_type == PlaneType::Overlay {
157        score += 2;
158        // Pairs with the zpos-0-means-primary convention above: with no kernel
159        // zpos and a positive request, overlay-as-natural-top applies, so bump
160        // enough to beat primary's bare base score.
161        if let Some(z) = zpos
162            && plane.zpos_min.is_none()
163            && z > 0
164        {
165            score += 8;
166        }
167    }
168
169    // Bandwidth-aware bias: two nudges toward keeping the costliest layers on
170    // planes when planes are contested. The class comes from the scanout
171    // framebuffer's own modifier -- never a producer-internal compression
172    // state, which would double-count a saving on a bus this allocator does
173    // not manage.
174    if let Some(fourcc) = layer.format() {
175        let class = plane.bandwidth_class(layer.modifier());
176        score += bandwidth_class_bonus(class);
177        score += cost_bias(scanout_cost_bytes(
178            layer.width(),
179            layer.height(),
180            fourcc,
181            class,
182            COMPRESSED_RATIO,
183        ));
184    }
185
186    score += layer_priority(layer) / 10;
187
188    if context.held_last_frame {
189        score += WARM_STABILITY_BONUS;
190    }
191
192    // Penalize combinations a previous test commit rejected.
193    score -= i32::try_from(context.failure_hits).unwrap_or(i32::MAX);
194
195    score
196}
197
198/// Whether a plane could possibly scan out a layer.
199///
200/// **Necessary conditions only.** Passing does not mean the commit will
201/// succeed — the atomic `TEST_ONLY` remains the arbiter. Failing means there is
202/// no point asking.
203#[must_use]
204pub fn plane_statically_compatible(
205    plane: &PlaneCapabilities,
206    layer: &Layer,
207    crtc_index: u32,
208) -> bool {
209    // A force-composited layer is incompatible with every plane. The caller --
210    // a test rig, a debug overlay, an EGL Streams workaround -- has asked for
211    // the composition fallback, and short-circuiting here propagates that
212    // through every downstream path without each re-checking the flag.
213    if layer.is_force_composited() || layer.is_transient_composited() {
214        return false;
215    }
216    if !plane.compatible_with_crtc(crtc_index) {
217        return false;
218    }
219
220    let Some(fourcc) = layer.format() else {
221        // No format means no buffer this commit -- typically an EAGAIN skip
222        // where the layer was never lowered. The permissive reading ("no
223        // format fits anywhere") would phantom-place it: counted as assigned,
224        // but with an empty property map nothing reaches the kernel. The
225        // frame would then report the layer as both assigned and skipped,
226        // breaking the commit report's accounting.
227        return false;
228    };
229
230    // Honor the modifier: AFBC, DCC, and vendor tilings only land on planes
231    // whose IN_FORMATS advertises the pair. An untagged layer falls through
232    // the LINEAR/INVALID equivalence inside `layer_fits_plane`.
233    if !layer_fits_plane(&plane.format_table, fourcc, Modifier(layer.modifier())) {
234        return false;
235    }
236
237    // The plane must expose every requested rotate/reflect bit, not merely
238    // have a rotation property: a plane can advertise rotation and implement
239    // only 0/180 or reflect, silently dropping a 90/270 request.
240    let rotation = layer.rotation();
241    if rotation != 0 && plane.rotation_bits & rotation != rotation {
242        return false;
243    }
244
245    if layer.requires_scaling() && !plane.supports_scaling {
246        return false;
247    }
248
249    // No zpos range check: the requested value is not what gets written. The
250    // scene writes `stacked_zpos`'s dense numbering, which fits each plane's
251    // range, so a layer asking zpos 3 can sit on a `[0, 1]` plane (tidss on a
252    // BeaglePlay, where the gate kept every layer above 1 off the overlay).
253    // Where no dense numbering fits, the requested values go out and the
254    // TEST decides. Stacking order is checked per assignment instead
255    // (drm-cxx `ad47fa9`, `8bf20e6`).
256
257    if plane.plane_type == PlaneType::Cursor {
258        if plane.cursor_max_w > 0 && layer.width() > plane.cursor_max_w {
259            return false;
260        }
261        if plane.cursor_max_h > 0 && layer.height() > plane.cursor_max_h {
262            return false;
263        }
264    }
265
266    true
267}
268
269/// Whether two destination rectangles overlap.
270///
271/// Computed in `i64` so a rectangle placed near `i32::MAX` cannot wrap into a
272/// false negative.
273#[must_use]
274pub fn rects_intersect(a: Rect, b: Rect) -> bool {
275    let (ax, ay) = (i64::from(a.x), i64::from(a.y));
276    let (bx, by) = (i64::from(b.x), i64::from(b.y));
277    ax + i64::from(a.w) > bx
278        && bx + i64::from(b.w) > ax
279        && ay + i64::from(a.h) > by
280        && by + i64::from(b.h) > ay
281}
282
283/// Whether two layers overlap on the CRTC.
284#[must_use]
285pub fn layers_intersect(a: &Layer, b: &Layer) -> bool {
286    rects_intersect(a.crtc_rect(), b.crtc_rect())
287}
288
289/// Union-find root with path halving.
290///
291/// Every index passed in comes from `0..parent.len()`, so the indexing cannot
292/// go out of range.
293fn union_find_root(parent: &mut [usize], mut x: usize) -> usize {
294    while parent[x] != x {
295        parent[x] = parent[parent[x]]; // path halving
296        x = parent[x];
297    }
298    x
299}
300
301/// Partition layer indices into spatially independent groups.
302///
303/// Layers that do not overlap cannot contend for the same stacking slot, so
304/// each group can be solved on its own — a much smaller search than the whole
305/// scene at once.
306///
307/// The groups draw on one **shared** plane pool, so their order decides which
308/// group gets a contested plane. Highest [`keep_priority`] first, so the rule
309/// that holds inside a group (a video layer outranks a generic one) also holds
310/// between groups; ties in input order. Upstream's order was unspecified and
311/// ignored priority until drm-cxx#236.
312#[must_use]
313pub fn split_independent_groups(layers: &[&Layer]) -> Vec<Vec<usize>> {
314    independent_groups(
315        layers,
316        |a, b| layers_intersect(a, b),
317        |layer| keep_priority(layer),
318    )
319}
320
321/// Split `items` into groups connected by `intersect`, as indices into
322/// `items`, highest `priority` group first.
323///
324/// Port of upstream's `detail::independent_groups`: a group's priority is its
325/// highest member's; ties keep input order, by each group's first member; and
326/// members keep input order. Generic so the ordering can be pinned on plain
327/// values rather than layers.
328pub(crate) fn independent_groups<T, K: Ord>(
329    items: &[T],
330    intersect: impl Fn(&T, &T) -> bool,
331    priority: impl Fn(&T) -> K,
332) -> Vec<Vec<usize>> {
333    let mut parent: Vec<usize> = (0..items.len()).collect();
334    for i in 0..items.len() {
335        for j in (i + 1)..items.len() {
336            if intersect(&items[i], &items[j]) {
337                let (a, b) = (
338                    union_find_root(&mut parent, i),
339                    union_find_root(&mut parent, j),
340                );
341                // Root is the lowest index, so groups collect in order of
342                // their first member.
343                if a != b {
344                    parent[a.max(b)] = a.min(b);
345                }
346            }
347        }
348    }
349
350    let mut groups: Vec<Vec<usize>> = Vec::new();
351    let mut slot = vec![usize::MAX; items.len()];
352    for i in 0..items.len() {
353        let root = union_find_root(&mut parent, i);
354        if slot[root] == usize::MAX {
355            slot[root] = groups.len();
356            groups.push(Vec::new());
357        }
358        groups[slot[root]].push(i);
359    }
360
361    // Stable, so equal priorities keep first-member order; cached, so each
362    // group's priority is computed once rather than on every comparison.
363    groups.sort_by_cached_key(|group| {
364        std::cmp::Reverse(group.iter().map(|&i| priority(&items[i])).max())
365    });
366    groups
367}