fix(outputs): complete CRTC matching before live validation (#45)

Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-14 13:28:23 +01:00
1 parent e980bb2e43
commit 0589f5685b
3 files changed
+451 -39

No files matched your search

+213 -29
View File
@@ -1,5 +1,3 @@
use std::collections::HashSet;
use smithay::reexports::drm::control::crtc;
pub(super) struct Candidates {
@@ -8,45 +6,114 @@ pub(super) struct Candidates {
pub compatible: Vec<crtc::Handle>,
}
/// Reserve surviving outputs before assigning newly enabled connectors. Kernel
/// encoder routing can be stale for a disabled connector; applied ownership wins.
/// Maximum-cardinality, minimum-cost bipartite matching. Cardinality wins over
/// preferences: augment until no path remains, even if its cost is positive.
/// For that cardinality, retain the most valid applied assignments, then the
/// most kernel assignments. Stable connector/compatible-CRTC order breaks ties.
/// One applied edge outweighs every possible kernel edge, since applied ownership
/// is authoritative and encoder routing can be stale after disabling an output.
pub(super) fn assign(requests: &[Candidates]) -> Vec<Option<crtc::Handle>> {
let mut assigned = vec![None; requests.len()];
let mut used = HashSet::new();
for applied in [true, false] {
for (request, assigned) in requests.iter().zip(&mut assigned) {
let preferred = if applied { request.applied } else { request.kernel };
if assigned.is_none()
&& let Some(crtc) = preferred.filter(|crtc| request.compatible.contains(crtc))
&& used.insert(crtc)
{
*assigned = Some(crtc);
let mut crtcs = Vec::new();
for request in requests {
for crtc in &request.compatible {
if !crtcs.contains(crtc) {
crtcs.push(*crtc);
}
}
}
// Give connectors with fewer choices the first free CRTC.
let mut remaining = (0..requests.len())
.filter(|index| assigned[*index].is_none())
.collect::<Vec<_>>();
remaining.sort_by_key(|index| requests[*index].compatible.len());
for index in remaining {
if let Some(crtc) = requests[index].compatible.iter().find(|crtc| !used.contains(crtc)) {
used.insert(*crtc);
assigned[index] = Some(*crtc);
let source = requests.len() + crtcs.len();
let sink = source + 1;
let mut edges = Vec::new();
let mut assignments = Vec::new();
let applied_weight = requests.len() as i64 + 1;
for (index, request) in requests.iter().enumerate() {
add_edge(&mut edges, source, index, 0);
for (crtc_index, crtc) in crtcs.iter().enumerate() {
if request.compatible.contains(crtc) {
let cost = -applied_weight * i64::from(request.applied == Some(*crtc))
- i64::from(request.kernel == Some(*crtc));
assignments.push((index, *crtc, edges.len()));
add_edge(&mut edges, index, requests.len() + crtc_index, cost);
}
}
}
for crtc_index in 0..crtcs.len() {
add_edge(&mut edges, requests.len() + crtc_index, sink, 0);
}
while augment(&mut edges, source, sink) {}
let mut assigned = vec![None; requests.len()];
for (index, crtc, edge) in assignments {
if !edges[edge].available {
assigned[index] = Some(crtc);
}
}
assigned
}
struct Edge {
from: usize,
to: usize,
cost: i64,
available: bool,
}
fn add_edge(edges: &mut Vec<Edge>, from: usize, to: usize, cost: i64) {
edges.push(Edge {
from,
to,
cost,
available: true,
});
edges.push(Edge {
from: to,
to: from,
cost: -cost,
available: false,
});
}
/// Find the cheapest residual augmenting path. Reverse edges allow earlier
/// assignments to move. Bellman-Ford handles negative preference costs; starting
/// from an empty matching and using shortest paths leaves no negative cost cycle.
/// Failed searches leave the matching untouched. Strict improvements and stable
/// edge traversal make equal-cost choices deterministic without hash iteration.
fn augment(edges: &mut [Edge], source: usize, sink: usize) -> bool {
let mut distances = vec![None; sink + 1];
let mut previous = vec![None; sink + 1];
distances[source] = Some(0);
for _ in 0..sink {
let mut changed = false;
for (index, edge) in edges.iter().enumerate() {
if edge.available
&& let Some(distance) = distances[edge.from]
&& distances[edge.to].is_none_or(|current| distance + edge.cost < current)
{
distances[edge.to] = Some(distance + edge.cost);
previous[edge.to] = Some(index);
changed = true;
}
}
if !changed {
break;
}
}
if previous[sink].is_none() {
return false;
}
let mut vertex = sink;
while vertex != source {
let index = previous[vertex].expect("reachable augmenting path");
edges[index].available = false;
edges[index ^ 1].available = true;
vertex = edges[index].from;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn crtc(value: u32) -> crtc::Handle {
std::num::NonZeroU32::new(value).unwrap().into()
@@ -60,6 +127,123 @@ mod tests {
}
}
#[test]
fn every_connector_order_finds_a_complete_matching() {
let graph = [&[1, 2][..], &[1, 3][..], &[1, 3][..]];
for order in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
let requests = order.map(|index| candidates(None, None, graph[index]));
let result = assign(&requests);
assert_eq!(result.iter().flatten().count(), 3, "order {order:?}");
assert_eq!(assign(&requests), result, "deterministic for the same inventory");
}
}
#[test]
fn equal_preferences_use_connector_order() {
let result = assign(&[candidates(Some(1), None, &[1, 2]), candidates(Some(1), None, &[1, 2])]);
assert_eq!(result, vec![Some(crtc(1)), Some(crtc(2))]);
}
#[test]
fn preserving_two_applied_outputs_outweighs_preserving_the_first_one() {
assert_eq!(
assign(&[
candidates(Some(1), None, &[1, 4]),
candidates(Some(2), None, &[2, 4]),
candidates(Some(3), None, &[2, 3]),
candidates(None, None, &[1, 3]),
]),
vec![Some(crtc(4)), Some(crtc(2)), Some(crtc(3)), Some(crtc(1))]
);
}
#[test]
fn maximum_cardinality_and_preference_objective_match_exhaustive_oracle() {
fn score(requests: &[Candidates], assignment: &[Option<crtc::Handle>]) -> (usize, usize, usize) {
let retained = [true, false].map(|applied| {
requests
.iter()
.zip(assignment)
.filter(|(request, actual)| {
let preferred = if applied { request.applied } else { request.kernel };
preferred.is_some() && preferred == **actual
})
.count()
});
(assignment.iter().flatten().count(), retained[0], retained[1])
}
fn enumerate(
requests: &[Candidates],
prefix: &mut Vec<Option<crtc::Handle>>,
best: &mut (usize, usize, usize),
) {
if prefix.len() == requests.len() {
*best = std::cmp::max(*best, score(requests, prefix));
return;
}
let index = prefix.len();
prefix.push(None);
enumerate(requests, prefix, best);
prefix.pop();
for crtc in &requests[index].compatible {
if !prefix.contains(&Some(*crtc)) {
prefix.push(Some(*crtc));
enumerate(requests, prefix, best);
prefix.pop();
}
}
}
// All 512 three-by-three graphs, including empty and impossible ones.
// Vary both preference classes, including invalid/conflicting preferences.
for graph in 0..512 {
for seed in 0..8 {
let requests = (0..3)
.map(|index| {
let compatible = (1..=3)
.filter(|crtc| graph & (1 << (index * 3 + crtc - 1)) != 0)
.collect::<Vec<_>>();
let applied = ((seed + index) % 4 != 0).then_some((seed + index) % 4);
let kernel = ((seed * 3 + index) % 4 != 0).then_some((seed * 3 + index) % 4);
candidates(applied, kernel, &compatible)
})
.collect::<Vec<_>>();
let mut best = (0, 0, 0);
enumerate(&requests, &mut Vec::new(), &mut best);
let actual = assign(&requests);
let used = actual.iter().flatten().collect::<HashSet<_>>();
assert_eq!(used.len(), actual.iter().flatten().count());
assert!(
requests
.iter()
.zip(&actual)
.all(|(request, crtc)| crtc.is_none_or(|crtc| request.compatible.contains(&crtc)))
);
assert_eq!(score(&requests, &actual), best, "graph {graph}, preferences {seed}");
}
}
}
#[test]
fn three_flexible_connectors_need_an_augmenting_path() {
let result = assign(&[
candidates(None, None, &[1, 2]),
candidates(None, None, &[1, 3]),
candidates(None, None, &[1, 3]),
]);
assert_eq!(result.iter().flatten().count(), 3);
}
#[test]
fn completeness_can_require_moving_two_applied_outputs() {
let result = assign(&[
candidates(Some(1), Some(1), &[1, 2]),
candidates(Some(2), Some(2), &[2, 3]),
candidates(None, None, &[1]),
]);
assert_eq!(result, vec![Some(crtc(2)), Some(crtc(3)), Some(crtc(1))]);
}
#[test]
fn external_to_two_outputs_keeps_external_crtc_regardless_of_connector_order() {
// Extend and mirror both require two independent scanout assignments.