Skip to main content

rhdl_fpga_planner/
lending.rs

1//! Steps 2, 3 and 3b of a plan: best builds, lending and the remainder
2//! ([`plan_chip`]).
3
4use crate::*;
5
6/// The planned chip.
7#[derive(Clone, Debug, PartialEq)]
8pub struct ChipPlan {
9    /// Share and plan of each slot, in order (`None`: off or nothing fits).
10    /// The share is what the module was finally planned in: its guaranteed
11    /// share plus anything it borrowed ([`plan_chip`]).
12    pub parts: Vec<(Share, Option<Plan>)>,
13    /// Each slot's guaranteed share ([`shares`]).
14    pub guaranteed: Vec<Share>,
15    /// Everything together.
16    pub total: Cost,
17    /// Delivered rates.
18    pub delivered: Delivered,
19}
20
21/// `slot`'s best build in `share`, remembered across calls.
22///
23/// The balancing searches plan the same module in the same share many
24/// times; the key is everything a plan depends on (the component's name
25/// and knob values, the share and the workload), so a knob change simply
26/// misses the cache.
27pub fn plan_slot(slot: &Slot, share: &Share, cx: &Context) -> Option<Plan> {
28    use std::{cell::RefCell, collections::HashMap};
29    thread_local! {
30        static CACHE: RefCell<HashMap<String, Option<Plan>>> = RefCell::new(HashMap::new());
31    }
32    let c = &slot.component;
33    let knobs: Vec<f64> = c.knobs().iter().map(|k| k.value).collect();
34    let key = format!("{}|{knobs:?}|{share:?}|{}|{}", c.name(), cx.mhz, cx.best_case);
35    if let Some(hit) = CACHE.with(|m| m.borrow().get(&key).cloned()) {
36        return hit;
37    }
38    let plan = c.plan(share, cx);
39    CACHE.with(|m| {
40        let mut m = m.borrow_mut();
41        if m.len() > 200_000 {
42            m.clear();
43        }
44        m.insert(key, plan.clone());
45    });
46    plan
47}
48
49/// The resources of the budget no build in `parts` uses. A fixed line that
50/// builds less than its allowance (the SHA-512 line's pipelines come in
51/// steps) gives the difference back here.
52pub(crate) fn free_pool(budget: &Budget, parts: &[(Share, Option<Plan>)]) -> Share {
53    let mut free = budget.chip();
54    for (_, plan) in parts {
55        if let Some(p) = plan {
56            free = Share {
57                lut: free.lut.saturating_sub(p.cost.lut),
58                dsp: free.dsp.saturating_sub(p.cost.dsp),
59                bram36: free.bram36.saturating_sub(p.cost.bram36),
60                uram: free.uram.saturating_sub(p.cost.uram),
61            };
62        }
63    }
64    free
65}
66
67/// How much better `new` is than `old`: the relative gains of the two
68/// sides, summed (a side going from zero to something counts as 1).
69pub(crate) fn gain(old: &Delivered, new: &Delivered) -> f64 {
70    Side::BOTH
71        .iter()
72        .map(|&s| {
73            let (a, b) = (old.side(s).rate, new.side(s).rate);
74            if a <= 0.0 { if b > 0.0 { 1.0 } else { 0.0 } } else { (b - a) / a }
75        })
76        .sum()
77}
78
79/// Plan every slot of `mode` and the delivered rates.
80///
81/// 1. Every module is planned in its guaranteed share ([`shares`]): its
82///    power % of each resource.
83/// 2. **Lending.** A module rarely uses its share evenly (DSig signing uses
84///    almost no DSPs; an Ed25519 verifier may run out of URAM long before
85///    its LUTs). What the modules leave unused is counted in real units
86///    (LUTs, DSPs, BRAM36, URAM, not percentages), and each module is
87///    re-planned in its own build's resources plus what is free. The re-plan
88///    that raises the delivered rates the most is kept, and the round
89///    repeats until no module gains. First each side lends only within
90///    itself (its modules share what the side's guarantee leaves); then
91///    whatever is still free, which that side could not use, goes to
92///    either side. So a side never loses resources it could have used, and
93///    nothing sits idle that the other side could turn into rate.
94///
95/// **Floors.** A module whose side delivers nothing because its share is
96/// smaller than its smallest build gets that build first
97/// (`floor_starved`), when that raises the weaker side.
98///
99/// **Auto-sized lines.** A line whose [`size_for`](Component::size_for)
100/// answers (the SHA-512 line on "auto") gets the allowance of its smallest
101/// build that supplies what the chip wants ([`Delivered::sha_wanted`]), and
102/// the chip is planned again with the engines in what that leaves. A smaller
103/// line gives the engines LUTs, which raises what they want, so this repeats
104/// (at most four times) until the size settles; of the plans tried, the one
105/// the line does not limit, delivering the most, wins.
106pub fn plan_chip(mode: Mode, budget: &Budget, cx: &Context, slots: &[Slot]) -> ChipPlan {
107    let mut allow = allowances_in(mode, slots);
108    let auto: Vec<usize> = (0..slots.len())
109        .filter(|&k| mode.admits(&*slots[k].component) && slots[k].component.size_for(0.0, cx).is_some())
110        .collect();
111    let mut plan = plan_with(mode, budget, cx, slots, &allow);
112    floor_starved(mode, budget, cx, slots, &mut allow, &mut plan);
113    if auto.is_empty() {
114        return plan;
115    }
116    let rank = |p: &ChipPlan| (!p.delivered.sha_bound, p.delivered.sign.rate.min(p.delivered.verify.rate), p.delivered.sign.rate + p.delivered.verify.rate);
117    let mut best = plan.clone();
118    for _ in 0..4 {
119        // Each auto line supplies its part of what the chip wants.
120        let want = plan.delivered.sha_wanted / auto.len() as f64;
121        let mut changed = false;
122        for &k in &auto {
123            let a = slots[k].component.size_for(want, cx);
124            if a != allow[k] {
125                allow[k] = a;
126                changed = true;
127            }
128        }
129        if !changed {
130            break;
131        }
132        plan = plan_with(mode, budget, cx, slots, &allow);
133        if rank(&plan).partial_cmp(&rank(&best)) == Some(std::cmp::Ordering::Greater) {
134            best = plan.clone();
135        }
136    }
137    best
138}
139
140/// **A floor for a starved side.** A side whose guaranteed share is
141/// smaller than its smallest build delivers nothing, and lending then hands
142/// its unused share to the other side (at 50 % LUTs, DSig verify took 176k
143/// while no DSig signer fit in its own ~60k). For each such side this
144/// sets aside the smallest build of every starved module on it at once
145/// (DSig sign and its certificate signer together), as fixed allowances
146/// out of the budget, and plans the rest of the chip in what is left. The floor is
147/// kept only if the weaker side then delivers more (and, tied, the sum),
148/// the same rank the auto-sized line uses. Modules with manual limits are
149/// left as the user capped them.
150pub(crate) fn floor_starved(mode: Mode, budget: &Budget, cx: &Context, slots: &[Slot], allow: &mut [Option<Share>], plan: &mut ChipPlan) {
151    let rank = |p: &ChipPlan| (p.delivered.sign.rate.min(p.delivered.verify.rate), p.delivered.sign.rate + p.delivered.verify.rate);
152    let starved = |k: usize, plan: &ChipPlan, allow: &[Option<Share>]| {
153        let slot = &slots[k];
154        mode.admits(&*slot.component)
155            && allow[k].is_none()
156            && slot.power > 0.0
157            && slot.limits.iter().all(Option::is_none)
158            && plan.parts[k].1.is_none()
159            && slot.component.role().side().is_some_and(|side| plan.delivered.side(side).rate <= 0.0)
160    };
161    // Floor every starved module of one side in a single trial. Why: a side
162    // can need two modules (DSig sign and the Ed25519 signer that certifies
163    // it), and flooring one at a time never helps, because the side still
164    // delivers nothing until both have a build.
165    for side in [Side::Sign, Side::Verify] {
166        let group: Vec<usize> = (0..slots.len())
167            .filter(|&k| starved(k, plan, allow) && slots[k].component.role().side() == Some(side))
168            .collect();
169        if group.is_empty() {
170            continue;
171        }
172        let mut trial_allow = allow.to_vec();
173        let mut ok = true;
174        for &k in &group {
175            match smallest_build(&slots[k], &budget.rest_with(&trial_allow), cx) {
176                Some(floor) => trial_allow[k] = Some(floor),
177                None => ok = false,
178            }
179        }
180        if !ok {
181            continue;
182        }
183        let trial = plan_with(mode, budget, cx, slots, &trial_allow);
184        if rank(&trial) > rank(plan) {
185            allow.copy_from_slice(&trial_allow);
186            *plan = trial;
187        }
188    }
189}
190
191/// The cost of `slot`'s build in the smallest uniform fraction of `rest`
192/// that still plans one (bisected to 1/4096), or `None` if even all of
193/// `rest` plans nothing.
194pub(crate) fn smallest_build(slot: &Slot, rest: &Share, cx: &Context) -> Option<Share> {
195    let at = |t: f64| Share {
196        lut: (rest.lut as f64 * t) as u32,
197        dsp: (rest.dsp as f64 * t) as u32,
198        bram36: (rest.bram36 as f64 * t) as u32,
199        uram: (rest.uram as f64 * t) as u32,
200    };
201    plan_slot(slot, rest, cx)?;
202    let (mut lo, mut hi) = (0.0, 1.0);
203    for _ in 0..12 {
204        let mid = (lo + hi) / 2.0;
205        if plan_slot(slot, &at(mid), cx).is_some() { hi = mid } else { lo = mid }
206    }
207    plan_slot(slot, &at(hi), cx).map(|p| p.cost)
208}
209
210/// [`plan_chip`] with the fixed allowances given per slot.
211pub(crate) fn plan_with(mode: Mode, budget: &Budget, cx: &Context, slots: &[Slot], allow: &[Option<Share>]) -> ChipPlan {
212    let guaranteed = shares_with(budget, slots, allow);
213    let on: Vec<bool> =
214        slots.iter().map(|s| mode.admits(&*s.component) && (s.component.fixed().is_some() || s.power > 0.0)).collect();
215    let mut parts: Vec<(Share, Option<Plan>)> = slots
216        .iter()
217        .zip(&guaranteed)
218        .zip(&on)
219        .map(|((slot, share), &on)| (*share, if on { plan_slot(slot, share, cx) } else { None }))
220        .collect();
221    let mut delivered = deliver(mode, cx, slots, &parts);
222    // What `side`'s modules have left of their guaranteed shares.
223    let side_free = |side: Side, parts: &[(Share, Option<Plan>)]| {
224        let mut left = Share::default();
225        for k in 0..slots.len() {
226            if !on[k] || slots[k].component.fixed().is_some() || slots[k].component.role().side() != Some(side) {
227                continue;
228            }
229            let g = guaranteed[k];
230            left = left.add(g, 1);
231        }
232        for k in 0..slots.len() {
233            if !on[k] || slots[k].component.fixed().is_some() || slots[k].component.role().side() != Some(side) {
234                continue;
235            }
236            if let Some(p) = &parts[k].1 {
237                left = Share {
238                    lut: left.lut.saturating_sub(p.cost.lut),
239                    dsp: left.dsp.saturating_sub(p.cost.dsp),
240                    bram36: left.bram36.saturating_sub(p.cost.bram36),
241                    uram: left.uram.saturating_sub(p.cost.uram),
242                };
243            }
244        }
245        left
246    };
247    // Lending rounds; each commits one module's larger build. Phase 0: each
248    // side only lends within itself, so it first uses its own share. Phase
249    // 1: what is still free (a side could not use it) goes to anyone.
250    for phase in 0..2 {
251    for _ in 0..8 {
252        let global = free_pool(budget, &parts);
253        if global == Share::default() {
254            break;
255        }
256        let mut best: Option<(f64, usize, Share, Plan, Delivered)> = None;
257        for k in 0..slots.len() {
258            // A fixed line (the SHA-512 line) has no side; it borrows in
259            // phase 1, from what neither side could use.
260            if !on[k] || (phase == 0 && slots[k].component.fixed().is_some()) {
261                continue;
262            }
263            let free = match (phase, slots[k].component.role().side()) {
264                (0, Some(side)) => {
265                    let f = side_free(side, &parts);
266                    Share { lut: f.lut.min(global.lut), dsp: f.dsp.min(global.dsp), bram36: f.bram36.min(global.bram36), uram: f.uram.min(global.uram) }
267                }
268                _ => global,
269            };
270            if free == Share::default() {
271                continue;
272            }
273            // Its current build's resources plus everything free. A module
274            // with no build starts from nothing: its unused share is already
275            // in the free pool (counting it twice once let a plan exceed the
276            // budget).
277            let base = parts[k].1.as_ref().map_or(Share::default(), |p| p.cost);
278            let mut wider = Share {
279                lut: base.lut + free.lut,
280                dsp: base.dsp + free.dsp,
281                bram36: base.bram36 + free.bram36,
282                uram: base.uram + free.uram,
283            };
284            // A manual limit is a hard cap.
285            for (i, r) in Resource::ALL.into_iter().enumerate() {
286                if slots[k].limits[i].is_some() {
287                    wider = r.with(wider, r.of(&guaranteed[k]));
288                }
289            }
290            if wider == base {
291                continue;
292            }
293            let Some(p) = plan_slot(&slots[k], &wider, cx) else { continue };
294            if parts[k].1.as_ref().is_some_and(|q| p.rate <= q.rate * (1.0 + 1e-9)) {
295                continue;
296            }
297            let mut trial = parts.clone();
298            trial[k] = (wider, Some(p.clone()));
299            let d = deliver(mode, cx, slots, &trial);
300            let g = gain(&delivered, &d);
301            if g > 1e-6 && best.as_ref().is_none_or(|b| g > b.0) {
302                best = Some((g, k, wider, p, d));
303            }
304        }
305        let Some((g, k, mut wider, mut p, mut d)) = best else { break };
306        // Borrow only what the gain needs: the smallest slice of the free
307        // pool (1/16, 1/8, 1/4, 1/2 of it) that gains as much. A helper that
308        // needs one more machine should not take every spare LUT.
309        let base = parts[k].1.as_ref().map_or(Share::default(), |q| q.cost);
310        let room = Share { lut: wider.lut - base.lut, dsp: wider.dsp - base.dsp, bram36: wider.bram36 - base.bram36, uram: wider.uram - base.uram };
311        for div in [16u32, 8, 4, 2] {
312            let small = Share {
313                lut: base.lut + room.lut / div,
314                dsp: base.dsp + room.dsp / div,
315                bram36: base.bram36 + room.bram36 / div,
316                uram: base.uram + room.uram / div,
317            };
318            let Some(q) = plan_slot(&slots[k], &small, cx) else { continue };
319            let mut trial = parts.clone();
320            trial[k] = (small, Some(q.clone()));
321            let e = deliver(mode, cx, slots, &trial);
322            if gain(&delivered, &e) >= 0.999 * g {
323                (wider, p, d) = (small, q, e);
324                break;
325            }
326        }
327        parts[k] = (wider, Some(p));
328        delivered = d;
329    }
330    }
331    fill_remainder(mode, budget, cx, slots, &on, &guaranteed, &mut parts, &mut delivered);
332    let total = parts.iter().filter_map(|(_, p)| p.as_ref()).fold(Cost::default(), |a, p| a.add(p.cost, 1));
333    ChipPlan { parts, guaranteed, total, delivered }
334}
335
336/// `share + extra`, with `slot`'s manual limits as hard caps.
337pub(crate) fn widen(slot: &Slot, guaranteed: &Share, share: Share, extra: Share) -> Share {
338    let mut wider = share.add(extra, 1);
339    for (i, r) in Resource::ALL.into_iter().enumerate() {
340        if slot.limits[i].is_some() {
341            wider = r.with(wider, r.of(guaranteed));
342        }
343    }
344    wider
345}
346
347/// Neither side delivers less in `new` than in `old`.
348pub(crate) fn no_side_drops(old: &Delivered, new: &Delivered) -> bool {
349    Side::BOTH.iter().all(|&s| new.side(s).rate >= old.side(s).rate * (1.0 - 1e-9))
350}
351
352/// 3. **The remainder.** Lending hands out only what raises the delivered
353/// rates. Whatever is still free after it could not raise them (the
354/// bottleneck is elsewhere, or a build step is too coarse), but it can still
355/// make modules faster: a background Ed25519 module with DSig's unused DSPs
356/// has headroom for bursts and for a worse best case. So it is not left
357/// idle:
358///
359/// - **Pro rata.** Each module of the paper, the SHA-512 line included, is
360///   offered its pro-rata slice of each free resource (in proportion to its
361///   guaranteed share of that resource) on top of its build, and keeps a
362///   larger build if its capacity rises.
363/// - **Greedy.** If no pro-rata slice buys anything (one more core or
364///   slice costs more than the slice), modules in descending guaranteed
365///   LUTs may take the whole pool. The SHA-512 line grows this way, up to
366///   its largest build, when the engines cannot use the LUTs.
367///
368/// A step is kept only if no side delivers less (through the SHA-512 line,
369/// one side's growth could take blocks from the other). Rounds repeat until
370/// nothing grows, at most 6.
371#[allow(clippy::too_many_arguments)]
372pub(crate) fn fill_remainder(
373    mode: Mode,
374    budget: &Budget,
375    cx: &Context,
376    slots: &[Slot],
377    on: &[bool],
378    guaranteed: &[Share],
379    parts: &mut [(Share, Option<Plan>)],
380    delivered: &mut Delivered,
381) {
382    let mut order: Vec<usize> =
383        (0..slots.len()).filter(|&k| on[k] && (slots[k].component.fixed().is_some() || slots[k].power > 0.0)).collect();
384    order.sort_by(|&a, &b| guaranteed[b].lut.cmp(&guaranteed[a].lut));
385    // Each resource's guaranteed total over these modules (the pro-rata
386    // denominators).
387    let mut total = Share::default();
388    for &k in &order {
389        total = total.add(guaranteed[k], 1);
390    }
391    let part = |free: u32, mine: u32, all: u32| if all == 0 { 0 } else { (free as f64 * mine as f64 / all as f64) as u32 };
392    // Try `k` in `share`; keep it if its capacity rises and no side drops.
393    let try_grow = |k: usize, share: Share, parts: &mut [(Share, Option<Plan>)], delivered: &mut Delivered| -> bool {
394        let Some(p) = plan_slot(&slots[k], &share, cx) else { return false };
395        if parts[k].1.as_ref().is_some_and(|q| p.rate <= q.rate * (1.0 + 1e-9)) {
396            return false;
397        }
398        let old = std::mem::replace(&mut parts[k], (share, Some(p)));
399        let d = deliver(mode, cx, slots, parts);
400        if no_side_drops(delivered, &d) {
401            *delivered = d;
402            true
403        } else {
404            parts[k] = old;
405            false
406        }
407    };
408    for _ in 0..6 {
409        let free = free_pool(budget, parts);
410        if free == Share::default() {
411            return;
412        }
413        let mut grew = false;
414        for &k in &order {
415            let g = guaranteed[k];
416            let slice = Share {
417                lut: part(free.lut, g.lut, total.lut),
418                dsp: part(free.dsp, g.dsp, total.dsp),
419                bram36: part(free.bram36, g.bram36, total.bram36),
420                uram: part(free.uram, g.uram, total.uram),
421            };
422            let base = parts[k].1.as_ref().map_or(Share::default(), |p| p.cost);
423            let wider = widen(&slots[k], &guaranteed[k], base, slice);
424            if wider != base {
425                grew |= try_grow(k, wider, parts, delivered);
426            }
427        }
428        if !grew {
429            for &k in &order {
430                let free = free_pool(budget, parts);
431                let base = parts[k].1.as_ref().map_or(Share::default(), |p| p.cost);
432                let wider = widen(&slots[k], &guaranteed[k], base, free);
433                if wider != base && try_grow(k, wider, parts, delivered) {
434                    grew = true;
435                    break;
436                }
437            }
438        }
439        if !grew {
440            return;
441        }
442    }
443}