/build/source/nativelink-scheduler/src/resource_sketch.rs
Line | Count | Source |
1 | | // Copyright 2026 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | //! What each kind of action used, learned by the scheduler from the usage |
16 | | //! every attempt reports, and the reservation it shapes from that. |
17 | | //! |
18 | | //! The sketch is bounded by construction: a fixed-size entry per key, three |
19 | | //! LRU families with capacities from a byte budget, and a window after which |
20 | | //! a key not seen again is forgotten. A sample counts for between half a |
21 | | //! window and a whole one (two half-window histograms per entry), so what a |
22 | | //! key used last week stops shaping it, and a kill floor expires with the |
23 | | //! window. The families are split over |
24 | | //! [`SHARDS`] by key hash so a snapshot or an age-out never holds a lock the |
25 | | //! Execute path waits on for long. Nothing on the Execute path reads a file |
26 | | //! or parses JSON; a lookup is a hash probe and a 25-bucket scan. |
27 | | |
28 | | use core::hash::{BuildHasher, BuildHasherDefault}; |
29 | | use core::num::NonZeroUsize; |
30 | | use core::time::Duration; |
31 | | use std::hash::DefaultHasher; |
32 | | use std::sync::Arc; |
33 | | |
34 | | use lru::LruCache; |
35 | | use nativelink_config::schedulers::SizeClassSpec; |
36 | | use nativelink_error::{Code, Error, make_err}; |
37 | | use parking_lot::Mutex; |
38 | | |
39 | | /// Buckets of the log2 histograms, the rollup's `hist_bucket`: bucket 0 for |
40 | | /// values under 1000, bucket `i` for `1000 * 2^(i-1) <= v < 1000 * 2^i`. |
41 | | pub const HIST_BUCKETS: usize = 25; |
42 | | |
43 | | /// Bumped by hand when the worker's sampler changes what a measurement |
44 | | /// means (the process-group fix of v1.8.2 is the example). A snapshot of |
45 | | /// another generation is dropped at load and the sketch starts empty. |
46 | | pub const SAMPLER_GENERATION: u32 = 1; |
47 | | |
48 | | /// Fixed charge for an entry and its map/Arc bookkeeping. Key contents are |
49 | | /// charged separately. The allowance also covers its largest encoded form. |
50 | | pub const ENTRY_BYTES: u64 = (size_of::<Entry>() + size_of::<SketchKey>() + 160 + 256) as u64; |
51 | | /// Conservative allowance for a probation node, its Arc header and hash slot. |
52 | | pub const PROBATION_ENTRY_BYTES: u64 = 256; |
53 | | /// Reserved hash-table storage per configured slot, even when the LRU is empty. |
54 | | const HASH_SLOT_BYTES: u64 = 64; |
55 | | /// The maps, mutexes, sketch and two sentinel nodes allocated by each LRU, |
56 | | /// even when empty. Hash slots allow for table rounding and spare capacity. |
57 | | const FIXED_BYTES: u64 = (SHARDS |
58 | | * (size_of::<Mutex<Inner>>() |
59 | | + 6 * (size_of::<Entry>() + size_of::<SketchKey>() + 32) |
60 | | + 2 * 128) |
61 | | + size_of::<ResourceSketch>() |
62 | | + 1024) as u64; |
63 | | |
64 | | /// Shards the families are split over by key hash. A lookup or a usage |
65 | | /// report locks one; a snapshot and an age-out lock one at a time, so at |
66 | | /// the full budget neither holds a lock the Execute path waits on for more |
67 | | /// than a shard's worth of entries. |
68 | | pub const SHARDS: usize = 32; |
69 | | |
70 | | /// Default of `memory_budget_bytes`: about 300k keys. |
71 | | pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 128 << 20; |
72 | | |
73 | | /// Default of `window_days`. |
74 | | pub const DEFAULT_WINDOW_DAYS: u64 = 7; |
75 | | |
76 | | /// Default of `headroom_percent`, the chart's `headroomPercent`. |
77 | | pub const DEFAULT_HEADROOM_PERCENT: u64 = 20; |
78 | | |
79 | | /// What a measurement is filed under, chosen from what the client sent. |
80 | | #[derive(Clone, Debug, Hash, Eq, PartialEq)] |
81 | | pub enum SketchKey { |
82 | | /// The client named a target; `mnemonic` is `None` when it named no |
83 | | /// mnemonic, which is its own key. |
84 | | Target { |
85 | | target: Arc<str>, |
86 | | mnemonic: Option<Arc<str>>, |
87 | | }, |
88 | | /// The client named a mnemonic and no target. |
89 | | Mnemonic { mnemonic: Arc<str> }, |
90 | | /// The client named neither. The `Command` digest as `hash-size`: an |
91 | | /// identity every client has, and the same program with the same |
92 | | /// arguments regardless of its inputs, so one key gathers every run. |
93 | | Command { digest: Arc<str> }, |
94 | | } |
95 | | |
96 | | impl SketchKey { |
97 | | /// The key for an attempt: the target when the client sent one, else |
98 | | /// the mnemonic, else the command digest. Empty strings count as absent. |
99 | 40.2k | pub fn for_attempt(target: Option<&str>, mnemonic: Option<&str>, command_digest: &str) -> Self { |
100 | 40.2k | let target = target.filter(|s| !s35.2k .is_empty35.2k ()); |
101 | 40.2k | let mnemonic = mnemonic.filter(|s| !s35.2k .is_empty35.2k ()); |
102 | 40.2k | match (target, mnemonic) { |
103 | 35.2k | (Some(target), mnemonic) => Self::Target { |
104 | 35.2k | target: Arc::from(target), |
105 | 35.2k | mnemonic: mnemonic.map(Arc::from), |
106 | 35.2k | }, |
107 | 3 | (None, Some(mnemonic)) => Self::Mnemonic { |
108 | 3 | mnemonic: Arc::from(mnemonic), |
109 | 3 | }, |
110 | 5.01k | (None, None) => Self::Command { |
111 | 5.01k | digest: Arc::from(command_digest), |
112 | 5.01k | }, |
113 | | } |
114 | 40.2k | } |
115 | | |
116 | 2.12M | pub const fn family(&self) -> Family { |
117 | 2.12M | match self { |
118 | 1.08M | Self::Target { .. } => Family::Target, |
119 | 1.03M | Self::Mnemonic { .. } => Family::Mnemonic, |
120 | 5.04k | Self::Command { .. } => Family::Command, |
121 | | } |
122 | 2.12M | } |
123 | | } |
124 | | |
125 | | /// The three maps; each has its own capacity so a flood of nameless |
126 | | /// actions cannot evict a single target key. |
127 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
128 | | pub enum Family { |
129 | | Target, |
130 | | Mnemonic, |
131 | | Command, |
132 | | } |
133 | | |
134 | | impl Family { |
135 | | pub const ALL: [Self; 3] = [Self::Target, Self::Mnemonic, Self::Command]; |
136 | | |
137 | 0 | pub const fn as_str(self) -> &'static str { |
138 | 0 | match self { |
139 | 0 | Self::Target => "target", |
140 | 0 | Self::Mnemonic => "mnemonic", |
141 | 0 | Self::Command => "command", |
142 | | } |
143 | 0 | } |
144 | | |
145 | 4.18M | const fn index(self) -> usize { |
146 | 4.18M | match self { |
147 | 2.11M | Self::Target => 0, |
148 | 2.06M | Self::Mnemonic => 1, |
149 | 5.71k | Self::Command => 2, |
150 | | } |
151 | 4.18M | } |
152 | | } |
153 | | |
154 | | /// A log2 histogram with the rollup's buckets, so a snapshot and a rollup |
155 | | /// row say the same thing about the same samples. Counts saturate at |
156 | | /// `u64::MAX` is only a defensive limit; ordinary workloads must not saturate |
157 | | /// individual buckets and change their relative weights. |
158 | | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
159 | | pub struct Hist(pub [u64; HIST_BUCKETS]); |
160 | | |
161 | | impl Hist { |
162 | | /// The bucket a value falls in. |
163 | 2.07M | pub fn bucket(value: u64) -> usize { |
164 | 2.07M | if value < 1000 { |
165 | 14 | return 0; |
166 | 2.07M | } |
167 | 2.07M | let exponent = usize::try_from((value / 1000).ilog2()).unwrap_or(HIST_BUCKETS) + 1; |
168 | 2.07M | exponent.min(HIST_BUCKETS - 1) |
169 | 2.07M | } |
170 | | |
171 | 2.07M | pub fn add(&mut self, value: u64) { |
172 | 2.07M | let bucket = &mut self.0[Self::bucket(value)]; |
173 | 2.07M | *bucket = bucket.saturating_add(1); |
174 | 2.07M | } |
175 | | |
176 | 39 | pub fn total(&self) -> u64 { |
177 | 975 | self.039 .iter39 ().fold39 (0u64, |sum, &n| sum.saturating_add(n)) |
178 | 39 | } |
179 | | |
180 | | /// Upper bound of the bucket the 95th percentile sample falls in; zero |
181 | | /// when empty. The upper bound over-reserves by less than 2x against |
182 | | /// the true p95; a lower edge or a midpoint could under-reserve, which |
183 | | /// a hint must never do. |
184 | 4 | pub fn p95_upper(&self) -> u64 { |
185 | 4 | self.p95_upper_with(&Self::default()) |
186 | 4 | } |
187 | | |
188 | | /// The same over the samples of `self` and `other` together. |
189 | 141 | pub fn p95_upper_with(&self, other: &Self) -> u64 { |
190 | 141 | let total: u128 = self |
191 | 141 | .0 |
192 | 141 | .iter() |
193 | 141 | .chain(other.0.iter()) |
194 | 7.05k | .map141 (|&n| u128::from(n)) |
195 | 141 | .sum(); |
196 | 141 | if total == 0 { |
197 | 79 | return 0; |
198 | 62 | } |
199 | 62 | let target = (total * 95).div_ceil(100); |
200 | 62 | let mut seen = 0u128; |
201 | 527 | for (i, (&a, &b)) in self.062 .iter62 ().zip62 (other.062 .iter62 ()).enumerate62 () { |
202 | 527 | seen += u128::from(a) + u128::from(b); |
203 | 527 | if seen >= target { |
204 | 62 | return 1000u64 << i; |
205 | 465 | } |
206 | | } |
207 | 0 | 0 |
208 | 141 | } |
209 | | } |
210 | | |
211 | | /// The samples of one half-window: histograms and the exact maxima. |
212 | | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
213 | | pub struct Half { |
214 | | /// `peak_memory_kb` of every sampled attempt. |
215 | | pub memory: Hist, |
216 | | /// Millicores: `cpu_time_ms * 1000 / wall_time_ms`. |
217 | | pub cpu: Hist, |
218 | | /// `peak_disk_kb`; zero until workers measure it. |
219 | | pub disk: Hist, |
220 | | pub max_memory_kb: u64, |
221 | | pub max_cpu_millicores: u64, |
222 | | pub max_disk_kb: u64, |
223 | | } |
224 | | |
225 | | /// What the sketch knows about one key. |
226 | | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
227 | | pub struct Entry { |
228 | | pub samples: u32, |
229 | | /// The samples of the current half-window and of the one before it. A |
230 | | /// lookup reads both, so a sample counts for between half a window and |
231 | | /// a whole one and never longer: what a key used last week stops |
232 | | /// shaping it, however often the key runs. |
233 | | pub current: Half, |
234 | | pub previous: Half, |
235 | | /// The largest reservation an attempt was killed at for memory within |
236 | | /// the window. The next hint sits above it. |
237 | | pub killed_at_kb: u64, |
238 | | /// When that kill was seen; the floor expires a window after it. |
239 | | pub killed_at_s: u64, |
240 | | /// Seconds since the Unix epoch of the last sample, for the window. |
241 | | pub last_seen_s: u64, |
242 | | /// The half-window `current` counts: `now_s / (window_s / 2)`; see |
243 | | /// [`Entry::settle`]. |
244 | | pub period: u64, |
245 | | /// Set by the hints-file seed; replaced whole by the first real sample. |
246 | | pub seeded: bool, |
247 | | } |
248 | | |
249 | | /// One attempt's measurement as the sketch takes it. |
250 | | #[derive(Clone, Copy, Debug, Default)] |
251 | | pub struct Observation { |
252 | | pub peak_memory_kb: u64, |
253 | | /// `None` when the worker reported no wall time; `Some(0)` is a real |
254 | | /// measurement of no CPU and is kept (the reservation it ran under is |
255 | | /// not a measurement). |
256 | | pub cpu_millicores: Option<u64>, |
257 | | pub peak_disk_kb: u64, |
258 | | /// The memory the attempt was killed at, when it was killed for memory |
259 | | /// and the reservation is known. A kill at an unknown reservation |
260 | | /// records no floor: a floor of zero is no floor. |
261 | | pub killed_at_kb: Option<u64>, |
262 | | /// Any kill, for the probation rule. |
263 | | pub killed: bool, |
264 | | pub now_s: u64, |
265 | | } |
266 | | |
267 | | /// A reservation shaped from an entry. Zero is a dimension not stated. |
268 | | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
269 | | pub struct Shaped { |
270 | | /// In the unit of `cpu_property_name`, millicores on the chart. |
271 | | pub cpu_count: u64, |
272 | | pub memory_kb: u64, |
273 | | pub disk_kb: u64, |
274 | | pub class: Option<String>, |
275 | | } |
276 | | |
277 | | impl Shaped { |
278 | 15 | pub const fn is_empty(&self) -> bool { |
279 | 15 | self.cpu_count == 0 && self.memory_kb == 06 && self.disk_kb == 02 |
280 | 15 | } |
281 | | } |
282 | | |
283 | | /// The numbers `ShapeOne` takes today, from the scheduler's own spec. |
284 | | #[derive(Clone, Debug)] |
285 | | pub struct Policy { |
286 | | pub headroom_percent: u64, |
287 | | pub classes: Vec<SizeClassSpec>, |
288 | | /// Which dimensions the fleet reserves: a number on one it does not |
289 | | /// would be a minimum no worker advertises. |
290 | | pub reserve_cpu: bool, |
291 | | pub reserve_memory: bool, |
292 | | pub reserve_disk: bool, |
293 | | } |
294 | | |
295 | | /// Reserve three quarters for snapshot encoding/loading and transient storage. |
296 | | /// Of the resident quarter, 10% funds probation and the remaining |
297 | | /// entries split 80/10/10. Variable-length keys consume the same byte budget. |
298 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
299 | | pub struct Budget { |
300 | | pub target: usize, |
301 | | pub mnemonic: usize, |
302 | | pub command: usize, |
303 | | pub probation: usize, |
304 | | } |
305 | | |
306 | | impl Budget { |
307 | 45 | pub fn from_bytes(bytes: u64) -> Self { |
308 | 45 | let resident = bytes.saturating_sub(FIXED_BYTES) / 4; |
309 | 45 | let probation = resident / 10; |
310 | 45 | let entries = usize::try_from((resident - probation) / ENTRY_BYTES).unwrap_or(usize::MAX); |
311 | 45 | let tenth = entries / 10; |
312 | 45 | Self { |
313 | 45 | target: (entries - 2 * tenth).max(1), |
314 | 45 | mnemonic: tenth.max(1), |
315 | 45 | command: tenth.max(1), |
316 | 45 | probation: usize::try_from(probation / PROBATION_ENTRY_BYTES) |
317 | 45 | .unwrap_or(usize::MAX) |
318 | 45 | .max(1), |
319 | 45 | } |
320 | 45 | } |
321 | | } |
322 | | |
323 | | /// Counters the admin view and the metrics read. |
324 | | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
325 | | pub struct Stats { |
326 | | pub entries: [usize; 3], |
327 | | pub capacity: [usize; 3], |
328 | | pub evictions: [u64; 3], |
329 | | pub samples: [u64; 3], |
330 | | pub probation: usize, |
331 | | } |
332 | | |
333 | | struct Inner { |
334 | | families: [LruCache<SketchKey, Entry>; 3], |
335 | | probation: LruCache<Arc<str>, ()>, |
336 | | evictions: [u64; 3], |
337 | | samples: [u64; 3], |
338 | | bytes: [u64; 3], |
339 | | probation_bytes: u64, |
340 | | } |
341 | | |
342 | 40.2k | fn key_bytes(key: &SketchKey) -> u64 { |
343 | 40.2k | match key { |
344 | 40.2k | SketchKey::Target { target, mnemonic } => { |
345 | 40.2k | (target.len() + mnemonic.as_ref().map_or(0, |m| m40.2k .len40.2k ())) as u64 |
346 | | } |
347 | 51 | SketchKey::Mnemonic { mnemonic } => mnemonic.len() as u64, |
348 | 9 | SketchKey::Command { digest } => digest.len() as u64, |
349 | | } |
350 | 40.2k | } |
351 | | |
352 | 40.2k | fn entry_charge(key: &SketchKey) -> u64 { |
353 | 40.2k | ENTRY_BYTES - HASH_SLOT_BYTES - 256 + key_bytes(key) |
354 | 40.2k | } |
355 | | |
356 | 9.93k | const fn probation_charge(digest: &str) -> u64 { |
357 | 9.93k | PROBATION_ENTRY_BYTES - HASH_SLOT_BYTES - 128 + digest.len() as u64 |
358 | 9.93k | } |
359 | | |
360 | | impl Inner { |
361 | 35.3k | fn insert(&mut self, key: SketchKey, entry: Entry) -> bool { |
362 | 35.3k | let i = key.family().index(); |
363 | 35.3k | let map = &mut self.families[i]; |
364 | 35.3k | let allowance = map.cap().get() as u64 * (ENTRY_BYTES - HASH_SLOT_BYTES); |
365 | 35.3k | let charge = entry_charge(&key); |
366 | 35.3k | if charge > allowance { |
367 | 1 | return false; |
368 | 35.3k | } |
369 | 40.2k | while self.bytes[i] + charge > allowance || map35.3k .len35.3k () == map35.3k .cap().get() { |
370 | 4.96k | let Some((removed, _)) = map.pop_lru() else { |
371 | 0 | break; |
372 | | }; |
373 | 4.96k | self.bytes[i] -= entry_charge(&removed); |
374 | 4.96k | self.evictions[i] += 1; |
375 | | } |
376 | 35.3k | map.put(key, entry); |
377 | 35.3k | self.bytes[i] += charge; |
378 | 35.3k | true |
379 | 35.3k | } |
380 | | |
381 | 5.00k | fn remember_probation(&mut self, digest: Arc<str>) { |
382 | 5.00k | let allowance = |
383 | 5.00k | self.probation.cap().get() as u64 * (PROBATION_ENTRY_BYTES - HASH_SLOT_BYTES); |
384 | 5.00k | let charge = probation_charge(&digest); |
385 | 5.00k | if charge > allowance { |
386 | 0 | return; |
387 | 5.00k | } |
388 | 9.92k | while self.probation_bytes + charge > allowance |
389 | 9.92k | || self.probation.len() == self.probation.cap().get() |
390 | | { |
391 | 4.92k | let Some((removed, ())) = self.probation.pop_lru() else { |
392 | 0 | break; |
393 | | }; |
394 | 4.92k | self.probation_bytes -= probation_charge(&removed); |
395 | | } |
396 | 5.00k | self.probation.put(digest, ()); |
397 | 5.00k | self.probation_bytes += charge; |
398 | 5.00k | } |
399 | | } |
400 | | |
401 | | /// The sketch: three LRU families per shard, each shard under its own lock |
402 | | /// held for O(1) work or one shard's copy, never across an await. |
403 | | pub struct ResourceSketch { |
404 | | shards: Box<[Mutex<Inner>]>, |
405 | | hasher: BuildHasherDefault<DefaultHasher>, |
406 | | policy: Policy, |
407 | | window: Duration, |
408 | | budget: Budget, |
409 | | } |
410 | | |
411 | | impl core::fmt::Debug for ResourceSketch { |
412 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
413 | 0 | f.debug_struct("ResourceSketch") |
414 | 0 | .field("budget", &self.budget) |
415 | 0 | .field("window", &self.window) |
416 | 0 | .field("shards", &self.shards.len()) |
417 | 0 | .field("stats", &self.stats()) |
418 | 0 | .finish_non_exhaustive() |
419 | 0 | } |
420 | | } |
421 | | |
422 | 4.11k | fn cache(capacity: usize) -> LruCache<SketchKey, Entry> { |
423 | 4.11k | LruCache::new(NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN)) |
424 | 4.11k | } |
425 | | |
426 | | impl ResourceSketch { |
427 | 50 | pub fn new(policy: Policy, budget: Budget, window: Duration) -> Self { |
428 | 50 | Self::with_shards(policy, budget, window, SHARDS) |
429 | 50 | } |
430 | | |
431 | | /// `shards` is [`SHARDS`] in the scheduler; a test that wants one LRU |
432 | | /// order passes one. A family's capacity is spread over the shards by |
433 | | /// key hash, so it evicts a little before the whole budget is full. |
434 | 66 | pub fn with_shards(policy: Policy, budget: Budget, window: Duration, shards: usize) -> Self { |
435 | 66 | let shards = shards.clamp(1, SHARDS).min( |
436 | 66 | budget |
437 | 66 | .target |
438 | 66 | .min(budget.mnemonic) |
439 | 66 | .min(budget.command) |
440 | 66 | .min(budget.probation) |
441 | 66 | .max(1), |
442 | | ); |
443 | 5.48k | let per66 = |capacity: usize, shard: usize| { |
444 | 5.48k | (capacity / shards + usize::from(shard < capacity % shards)).max(1) |
445 | 5.48k | }; |
446 | 66 | let inners = (0..shards) |
447 | 1.37k | .map66 (|shard| { |
448 | 1.37k | Mutex::new(Inner { |
449 | 1.37k | families: [ |
450 | 1.37k | cache(per(budget.target, shard)), |
451 | 1.37k | cache(per(budget.mnemonic, shard)), |
452 | 1.37k | cache(per(budget.command, shard)), |
453 | 1.37k | ], |
454 | 1.37k | probation: LruCache::new( |
455 | 1.37k | NonZeroUsize::new(per(budget.probation, shard)) |
456 | 1.37k | .unwrap_or(NonZeroUsize::MIN), |
457 | 1.37k | ), |
458 | 1.37k | evictions: [0; 3], |
459 | 1.37k | samples: [0; 3], |
460 | 1.37k | bytes: [0; 3], |
461 | 1.37k | probation_bytes: 0, |
462 | 1.37k | }) |
463 | 1.37k | }) |
464 | 66 | .collect(); |
465 | 66 | Self { |
466 | 66 | shards: inners, |
467 | 66 | hasher: BuildHasherDefault::default(), |
468 | 66 | policy, |
469 | 66 | window, |
470 | 66 | budget, |
471 | 66 | } |
472 | 66 | } |
473 | | |
474 | 2.08M | fn shard(&self, key: &SketchKey) -> &Mutex<Inner> { |
475 | 2.08M | let hash = self.hasher.hash_one(key); |
476 | 2.08M | let index = usize::try_from(hash % self.shards.len() as u64).unwrap_or(0); |
477 | 2.08M | &self.shards[index] |
478 | 2.08M | } |
479 | | |
480 | 2.07M | const fn window_s(&self) -> u64 { |
481 | 2.07M | self.window.as_secs() |
482 | 2.07M | } |
483 | | |
484 | 0 | pub const fn policy(&self) -> &Policy { |
485 | 0 | &self.policy |
486 | 0 | } |
487 | | |
488 | 16 | pub const fn budget(&self) -> Budget { |
489 | 16 | self.budget |
490 | 16 | } |
491 | | |
492 | 0 | pub const fn window(&self) -> Duration { |
493 | 0 | self.window |
494 | 0 | } |
495 | | |
496 | | /// Records one attempt. A command key with no entry yet earns one on a |
497 | | /// kill or on its second sighting: a digest that ran once and was not |
498 | | /// killed is an action-cache hit next time, so a first sighting only |
499 | | /// goes on probation. An attempt that names a target and a mnemonic is |
500 | | /// recorded under both its target key and the mnemonic family, so a |
501 | | /// target the sketch has not seen is sized from its kind of action |
502 | | /// rather than the cold start. Returns whether a sample was recorded. |
503 | 1.03M | pub fn observe(&self, key: SketchKey, observation: &Observation) -> bool { |
504 | 1.03M | let mnemonic = match &key1.03M { |
505 | | SketchKey::Target { |
506 | 1.03M | mnemonic: Some(mnemonic), |
507 | | .. |
508 | 1.03M | } => Some(mnemonic.clone()), |
509 | 5.01k | _ => None, |
510 | | }; |
511 | 1.03M | let recorded = self.observe_one(key, observation); |
512 | 1.03M | if recorded && let Some(mnemonic1.03M ) = mnemonic1.03M { |
513 | 1.03M | self.observe_one(SketchKey::Mnemonic { mnemonic }, observation); |
514 | 1.03M | }5.01k |
515 | 1.03M | recorded |
516 | 1.03M | } |
517 | | |
518 | 2.06M | fn observe_one(&self, key: SketchKey, observation: &Observation) -> bool { |
519 | 2.06M | let family = key.family(); |
520 | 2.06M | let window_s = self.window_s(); |
521 | 2.06M | let mut inner = self.shard(&key).lock(); |
522 | 2.06M | if family == Family::Command |
523 | 5.00k | && !inner.families[family.index()].contains(&key) |
524 | 5.00k | && !observation.killed |
525 | | { |
526 | 5.00k | let SketchKey::Command { digest } = &key else { |
527 | 0 | unreachable!("family Command is a Command key"); |
528 | | }; |
529 | 5.00k | if inner.probation.pop(digest).is_some() { |
530 | 2 | inner.probation_bytes -= probation_charge(digest); |
531 | 2 | } else { |
532 | 5.00k | inner.remember_probation(digest.clone()); |
533 | 5.00k | return false; |
534 | | } |
535 | 2.06M | } |
536 | 2.06M | inner.samples[family.index()] += 1; |
537 | 2.06M | let map = &mut inner.families[family.index()]; |
538 | 2.06M | if let Some(entry2.03M ) = map.get_mut(&key) { |
539 | 2.03M | entry.record(observation, window_s); |
540 | 2.03M | return true; |
541 | 25.0k | } |
542 | 25.0k | let mut entry = Entry::default(); |
543 | 25.0k | entry.record(observation, window_s); |
544 | 25.0k | inner.insert(key, entry) |
545 | 2.06M | } |
546 | | |
547 | | /// Inserts a seeded entry when the key has none. Seeds never replace |
548 | | /// what was measured. |
549 | 10.2k | pub fn seed(&self, key: SketchKey, entry: Entry) -> bool { |
550 | 10.2k | let family = key.family(); |
551 | 10.2k | let mut inner = self.shard(&key).lock(); |
552 | 10.2k | let map = &mut inner.families[family.index()]; |
553 | 10.2k | if map.contains(&key) { |
554 | 1 | return false; |
555 | 10.2k | } |
556 | 10.2k | inner.insert(key, entry) |
557 | 10.2k | } |
558 | | |
559 | | /// The entry under `key`, promoted to recently used. A key not seen |
560 | | /// for the window is dropped here as well as on the age-out tick, so a |
561 | | /// lookup between ticks does not size from it. A seed stands until its |
562 | | /// first sample or its eviction. |
563 | 10.2k | pub fn lookup(&self, key: &SketchKey, now_s: u64) -> Option<Entry> { |
564 | 10.2k | let cutoff = now_s.saturating_sub(self.window_s()); |
565 | 10.2k | let mut inner = self.shard(key).lock(); |
566 | 10.2k | let map = &mut inner.families[key.family().index()]; |
567 | 10.2k | let entry10.2k = map.get(key)?36 ; |
568 | 10.2k | if !entry.seeded && entry.last_seen_s < cutoff44 { |
569 | 1 | map.pop(key); |
570 | 1 | inner.bytes[key.family().index()] -= entry_charge(key); |
571 | 1 | return None; |
572 | 10.2k | } |
573 | 10.2k | Some(entry.clone()) |
574 | 10.2k | } |
575 | | |
576 | | /// The reservation for an entry: `ShapeOne` of the hints producer, |
577 | | /// line for line. |
578 | 38 | pub fn shape(&self, entry: &Entry) -> Shaped { |
579 | 38 | shape(&self.policy, entry) |
580 | 38 | } |
581 | | |
582 | 31 | pub fn stats(&self) -> Stats { |
583 | 31 | let mut stats = Stats::default(); |
584 | 637 | for shard in &self.shards31 { |
585 | 637 | let inner = shard.lock(); |
586 | 637 | stats.probation += inner.probation.len(); |
587 | 1.91k | for family in Family::ALL { |
588 | 1.91k | let i = family.index(); |
589 | 1.91k | stats.entries[i] += inner.families[i].len(); |
590 | 1.91k | stats.capacity[i] += inner.families[i].cap().get(); |
591 | 1.91k | stats.evictions[i] += inner.evictions[i]; |
592 | 1.91k | stats.samples[i] += inner.samples[i]; |
593 | 1.91k | } |
594 | | } |
595 | 31 | stats |
596 | 31 | } |
597 | | |
598 | | /// Ages every entry to `now_s` (see [`Entry::settle`]) and drops the |
599 | | /// keys not seen for the window, one shard at a time. Runs on the |
600 | | /// scheduler's tick whether or not a snapshot store is configured. |
601 | | /// Returns how many keys were dropped. |
602 | 3 | pub fn age_out(&self, now_s: u64) -> usize { |
603 | 3 | let window_s = self.window_s(); |
604 | 3 | let cutoff = now_s.saturating_sub(window_s); |
605 | 3 | let mut dropped = 0usize; |
606 | 3 | for shard in &self.shards { |
607 | 3 | let mut inner = shard.lock(); |
608 | 9 | for family in Family::ALL { |
609 | 9 | let map = &mut inner.families[family.index()]; |
610 | 9 | let mut stale = Vec::new(); |
611 | 9 | for (key8 , entry8 ) in map.iter_mut() { |
612 | 8 | if !entry.seeded && entry.last_seen_s < cutoff7 { |
613 | 2 | stale.push(key.clone()); |
614 | 6 | } else { |
615 | 6 | entry.settle(now_s, window_s); |
616 | 6 | } |
617 | | } |
618 | 9 | dropped += stale.len(); |
619 | 9 | let reclaimed = stale.iter().map(entry_charge).sum::<u64>(); |
620 | 9 | for key2 in stale { |
621 | 2 | map.pop(&key); |
622 | 2 | } |
623 | 9 | inner.bytes[family.index()] -= reclaimed; |
624 | | } |
625 | | } |
626 | 3 | dropped |
627 | 3 | } |
628 | | |
629 | | /// Every entry still in the window, least recently used first within |
630 | | /// each shard, so a loader that inserts in order ends with the same |
631 | | /// recency. One shard is locked at a time, for its copy only. |
632 | 5 | pub fn snapshot(&self, now_s: u64) -> Snapshot { |
633 | 5 | let cutoff = now_s.saturating_sub(self.window_s()); |
634 | 5 | let mut entries = Vec::new(); |
635 | 13 | for shard in &self.shards5 { |
636 | 13 | let inner = shard.lock(); |
637 | 39 | for family in Family::ALL { |
638 | 39 | let map = &inner.families[family.index()]; |
639 | 39 | let start = entries.len(); |
640 | 39 | entries.extend( |
641 | 39 | map.iter() |
642 | 48 | .filter39 (|(_, entry)| entry.seeded || entry.last_seen_s >= cutoff) |
643 | 46 | .map39 (|(key, entry)| (key.clone(), entry.clone())), |
644 | | ); |
645 | | // `iter` is most recently used first. |
646 | 39 | entries[start..].reverse(); |
647 | | } |
648 | | } |
649 | 5 | Snapshot { |
650 | 5 | sampler_generation: SAMPLER_GENERATION, |
651 | 5 | written_at_s: now_s, |
652 | 5 | entries, |
653 | 5 | } |
654 | 5 | } |
655 | | |
656 | | /// Maximum snapshot payload, including its header, within the reserved |
657 | | /// transient part of the budget. Also bounds reads before decoding. |
658 | 49 | pub const fn snapshot_byte_limit(&self) -> u64 { |
659 | 49 | 25 + (self.budget.target + self.budget.mnemonic + self.budget.command) as u64 * ENTRY_BYTES |
660 | 49 | } |
661 | | |
662 | | /// Encode directly under one shard lock at a time. Reserve the output |
663 | | /// once, so map cloning and buffer growth cannot multiply peak memory. |
664 | 24 | pub fn encode_snapshot(&self, now_s: u64) -> Vec<u8> { |
665 | 24 | let limit = self.snapshot_byte_limit(); |
666 | 24 | let mut bytes = Vec::with_capacity(usize::try_from(limit).unwrap_or(usize::MAX)); |
667 | 24 | let header = Snapshot { |
668 | 24 | sampler_generation: SAMPLER_GENERATION, |
669 | 24 | written_at_s: now_s, |
670 | 24 | entries: Vec::new(), |
671 | 24 | } |
672 | 24 | .encode(); |
673 | 24 | bytes.extend_from_slice(&header); |
674 | 24 | let cutoff = now_s.saturating_sub(self.window_s()); |
675 | 24 | let mut count = 0u64; |
676 | 652 | for shard in &self.shards24 { |
677 | 652 | let inner = shard.lock(); |
678 | 1.95k | for map in &inner.families652 { |
679 | 1.95k | for (key83 , entry83 ) in map.iter().rev() { |
680 | 83 | if entry.seeded || entry.last_seen_s >= cutoff { |
681 | 83 | encode_entry(&mut bytes, key, entry); |
682 | 83 | count += 1; |
683 | 83 | }0 |
684 | | } |
685 | | } |
686 | | } |
687 | 24 | bytes[17..25].copy_from_slice(&count.to_le_bytes()); |
688 | 24 | bytes |
689 | 24 | } |
690 | | |
691 | | /// Conservative charged resident allocation, including unused hash slots. |
692 | 4 | pub fn charged_bytes(&self) -> u64 { |
693 | 4 | let mut bytes = FIXED_BYTES; |
694 | 51 | for shard in &self.shards4 { |
695 | 51 | let inner = shard.lock(); |
696 | 51 | bytes += inner.bytes.iter().sum::<u64>() + inner.probation_bytes; |
697 | 153 | bytes += (inner.families51 .iter51 ().map51 (|m| m.cap().get()).sum51 ::<usize>() |
698 | 51 | + inner.probation.cap().get()) as u64 |
699 | | * HASH_SLOT_BYTES; |
700 | | } |
701 | 4 | bytes |
702 | 4 | } |
703 | | |
704 | | /// Restore a replica's snapshot at startup. Existing entries are left |
705 | | /// alone, making replay idempotent and preserving newer local observations. |
706 | | /// Skip expired keys and refuse an incompatible sampler generation. |
707 | 19 | pub fn load(&self, snapshot: Snapshot, now_s: u64) -> Result<usize, Error> { |
708 | 19 | if snapshot.sampler_generation != SAMPLER_GENERATION { |
709 | 1 | return Err(make_err!( |
710 | 1 | Code::FailedPrecondition, |
711 | 1 | "resource sketch snapshot is of sampler generation {}, this build is {}", |
712 | 1 | snapshot.sampler_generation, |
713 | 1 | SAMPLER_GENERATION |
714 | 1 | )); |
715 | 18 | } |
716 | 18 | let cutoff = now_s.saturating_sub(self.window_s()); |
717 | 18 | let mut loaded = 0usize; |
718 | 38 | for (key, entry) in snapshot.entries18 { |
719 | 38 | if !entry.seeded && entry.last_seen_s < cutoff { |
720 | 0 | continue; |
721 | 38 | } |
722 | 38 | let family = key.family(); |
723 | 38 | let mut inner = self.shard(&key).lock(); |
724 | 38 | let map = &mut inner.families[family.index()]; |
725 | 38 | if map.contains(&key) || !35 inner35 .insert35 (key, entry) { |
726 | 3 | continue; |
727 | 35 | } |
728 | 35 | loaded += 1; |
729 | | } |
730 | 18 | Ok(loaded) |
731 | 19 | } |
732 | | |
733 | | /// The shaped hints of every entry of `family` (every family when |
734 | | /// `None`), most recently seen first, at most `limit`. |
735 | 4 | pub fn hints(&self, family: Option<Family>, limit: usize) -> Vec<HintRow> { |
736 | 4 | let mut rows = Vec::new(); |
737 | 4 | for shard in &self.shards { |
738 | 4 | let inner = shard.lock(); |
739 | 12 | for f in Family::ALL { |
740 | 12 | if family.is_some_and(|wanted| wanted6 != f6 ) { |
741 | 4 | continue; |
742 | 8 | } |
743 | 8 | for (key7 , entry7 ) in inner.families[f.index()].iter().take(limit) { |
744 | 7 | rows.push(HintRow { |
745 | 7 | key: key.clone(), |
746 | 7 | shaped: shape(&self.policy, entry), |
747 | 7 | samples: entry.samples, |
748 | 7 | last_seen_s: entry.last_seen_s, |
749 | 7 | seeded: entry.seeded, |
750 | 7 | }); |
751 | 7 | } |
752 | | } |
753 | | } |
754 | 8 | rows4 .sort_by_key4 (|row| core::cmp::Reverse(row.last_seen_s)); |
755 | 4 | rows.truncate(limit); |
756 | 4 | rows |
757 | 4 | } |
758 | | } |
759 | | |
760 | | /// One line of the admin view: a key with what the scheduler reserves for |
761 | | /// it now. |
762 | | #[derive(Clone, Debug, PartialEq, Eq)] |
763 | | pub struct HintRow { |
764 | | pub key: SketchKey, |
765 | | pub shaped: Shaped, |
766 | | pub samples: u32, |
767 | | pub last_seen_s: u64, |
768 | | pub seeded: bool, |
769 | | } |
770 | | |
771 | | impl Entry { |
772 | 61 | pub fn max_memory_kb(&self) -> u64 { |
773 | 61 | self.current.max_memory_kb.max(self.previous.max_memory_kb) |
774 | 61 | } |
775 | | |
776 | 45 | pub fn max_cpu_millicores(&self) -> u64 { |
777 | 45 | self.current |
778 | 45 | .max_cpu_millicores |
779 | 45 | .max(self.previous.max_cpu_millicores) |
780 | 45 | } |
781 | | |
782 | 45 | pub fn max_disk_kb(&self) -> u64 { |
783 | 45 | self.current.max_disk_kb.max(self.previous.max_disk_kb) |
784 | 45 | } |
785 | | |
786 | 47 | pub fn memory_p95_upper(&self) -> u64 { |
787 | 47 | self.current.memory.p95_upper_with(&self.previous.memory) |
788 | 47 | } |
789 | | |
790 | 45 | pub fn cpu_p95_upper(&self) -> u64 { |
791 | 45 | self.current.cpu.p95_upper_with(&self.previous.cpu) |
792 | 45 | } |
793 | | |
794 | 45 | pub fn disk_p95_upper(&self) -> u64 { |
795 | 45 | self.current.disk.p95_upper_with(&self.previous.disk) |
796 | 45 | } |
797 | | |
798 | | /// Every sample of the key still counted, both halves together. |
799 | 18 | pub fn memory_samples(&self) -> u64 { |
800 | 18 | self.current |
801 | 18 | .memory |
802 | 18 | .total() |
803 | 18 | .saturating_add(self.previous.memory.total()) |
804 | 18 | } |
805 | | |
806 | 2.08M | fn half_s(window_s: u64) -> u64 { |
807 | 2.08M | (window_s / 2).max(1) |
808 | 2.08M | } |
809 | | |
810 | | /// Ages the entry to `now_s`. When the half-window turns, the current |
811 | | /// samples become the previous ones and the previous ones are gone; two |
812 | | /// turns at once clear both. So a sample counts for between half a |
813 | | /// window and a whole one, and a rarely seen key keeps its last few |
814 | | /// samples that long and no longer. The kill floor expires a window |
815 | | /// after the kill. A seed is left as it is: it stands until its first |
816 | | /// sample. |
817 | 2.06M | pub fn settle(&mut self, now_s: u64, window_s: u64) { |
818 | 2.06M | if self.seeded { |
819 | 1 | return; |
820 | 2.06M | } |
821 | 2.06M | let period = now_s / Self::half_s(window_s); |
822 | 2.06M | if period > self.period { |
823 | 5 | if period == self.period + 1 { |
824 | 4 | self.previous = core::mem::take(&mut self.current); |
825 | 4 | } else { |
826 | 1 | self.previous = Half::default(); |
827 | 1 | self.current = Half::default(); |
828 | 1 | } |
829 | 5 | self.period = period; |
830 | 2.06M | } |
831 | 2.06M | if self.killed_at_kb > 0 && now_s12 .saturating_sub12 (self.killed_at_s) > window_s { |
832 | 2 | self.killed_at_kb = 0; |
833 | 2 | self.killed_at_s = 0; |
834 | 2.06M | } |
835 | 2.06M | } |
836 | | |
837 | 2.06M | fn record(&mut self, observation: &Observation, window_s: u64) { |
838 | 2.06M | if self.seeded { |
839 | 2 | // The seed stood in for measurements; the first one replaces it |
840 | 2 | // whole, so a seed above the truth neither caps nor lifts it. |
841 | 2 | *self = Self::default(); |
842 | 2.06M | } |
843 | 2.06M | if self.period == 0 { |
844 | 27.0k | self.period = observation.now_s / Self::half_s(window_s); |
845 | 2.03M | } |
846 | 2.06M | self.settle(observation.now_s, window_s); |
847 | 2.06M | self.samples = self.samples.saturating_add(1); |
848 | 2.06M | self.last_seen_s = self.last_seen_s.max(observation.now_s); |
849 | 2.06M | let half = &mut self.current; |
850 | 2.06M | if observation.peak_memory_kb > 0 { |
851 | 2.06M | half.memory.add(observation.peak_memory_kb); |
852 | 2.06M | half.max_memory_kb = half.max_memory_kb.max(observation.peak_memory_kb); |
853 | 2.06M | }2 |
854 | 2.06M | if let Some(millicores6 ) = observation.cpu_millicores { |
855 | 6 | half.cpu.add(millicores); |
856 | 6 | half.max_cpu_millicores = half.max_cpu_millicores.max(millicores); |
857 | 2.06M | } |
858 | 2.06M | if observation.peak_disk_kb > 0 { |
859 | 4 | half.disk.add(observation.peak_disk_kb); |
860 | 4 | half.max_disk_kb = half.max_disk_kb.max(observation.peak_disk_kb); |
861 | 2.06M | } |
862 | 2.06M | if let Some(killed_at_kb11 ) = observation.killed_at_kb |
863 | 11 | && killed_at_kb > 0 |
864 | 11 | && killed_at_kb >= self.killed_at_kb |
865 | 11 | { |
866 | 11 | self.killed_at_kb = killed_at_kb; |
867 | 11 | self.killed_at_s = observation.now_s; |
868 | 2.06M | } |
869 | 2.06M | self.seeded = false; |
870 | 2.06M | } |
871 | | |
872 | | /// An entry standing in for a hint from a file: one sample at each |
873 | | /// stated number, so the first real sample takes over. |
874 | 10.2k | pub fn seeded(cpu: u64, memory_kb: u64, disk_kb: u64, samples: u32, last_seen_s: u64) -> Self { |
875 | 10.2k | let mut entry = Self { |
876 | 10.2k | samples: samples.max(1), |
877 | 10.2k | last_seen_s, |
878 | 10.2k | seeded: true, |
879 | 10.2k | ..Self::default() |
880 | 10.2k | }; |
881 | 10.2k | if memory_kb > 0 { |
882 | 10.2k | entry.current.memory.add(memory_kb); |
883 | 10.2k | entry.current.max_memory_kb = memory_kb; |
884 | 10.2k | }3 |
885 | 10.2k | if cpu > 0 { |
886 | 7 | entry.current.cpu.add(cpu); |
887 | 7 | entry.current.max_cpu_millicores = cpu; |
888 | 10.2k | } |
889 | 10.2k | if disk_kb > 0 { |
890 | 7 | entry.current.disk.add(disk_kb); |
891 | 7 | entry.current.max_disk_kb = disk_kb; |
892 | 10.2k | } |
893 | 10.2k | entry |
894 | 10.2k | } |
895 | | } |
896 | | |
897 | | /// The p95 bucket's upper bound, capped by the exact maximum: an entry with |
898 | | /// one sample reserves what that sample used, one with a thousand less than |
899 | | /// twice the true p95 and never less than it. |
900 | 135 | const fn p95_capped(upper: u64, max: u64) -> u64 { |
901 | 135 | if max == 0 { |
902 | 78 | return 0; |
903 | 57 | } |
904 | 57 | if upper == 0 || max < upper { |
905 | 56 | max |
906 | | } else { |
907 | 1 | upper |
908 | | } |
909 | 135 | } |
910 | | |
911 | 139 | const fn with_headroom(value: u64, headroom_percent: u64) -> u64 { |
912 | 139 | if value == 0 { |
913 | 78 | return 0; |
914 | 61 | } |
915 | 61 | value.saturating_mul(100 + headroom_percent) / 100 |
916 | 139 | } |
917 | | |
918 | | /// The first class on the ladder that holds the numbers on every dimension |
919 | | /// it names and sits above a reservation the action was killed at. Past the |
920 | | /// top of the ladder it is the top class, unless the action was killed at |
921 | | /// or above the top's memory: no class then sits above the kill, and the |
922 | | /// raw numbers stand rather than a class whose memory would be killed again. |
923 | 43 | fn smallest_dominating_class( |
924 | 43 | classes: &[SizeClassSpec], |
925 | 43 | memory_kb: u64, |
926 | 43 | cpu: u64, |
927 | 43 | disk_kb: u64, |
928 | 43 | killed_at_kb: u64, |
929 | 43 | ) -> Option<&SizeClassSpec> { |
930 | 43 | let top15 = classes.last()?28 ; |
931 | 15 | if top.memory_kb <= killed_at_kb { |
932 | 1 | return None; |
933 | 14 | } |
934 | 21 | for class in classes14 { |
935 | 21 | if class.memory_kb < memory_kb || class.memory_kb <= killed_at_kb15 { |
936 | 6 | continue; |
937 | 15 | } |
938 | 15 | if class.cpu_count > 0 && cpu > 0 && class.cpu_count < cpu3 { |
939 | 2 | continue; |
940 | 13 | } |
941 | 13 | if class.disk_kb > 0 && disk_kb > 00 && class.disk_kb < disk_kb0 { |
942 | 0 | continue; |
943 | 13 | } |
944 | 13 | return Some(class); |
945 | | } |
946 | 1 | Some(top) |
947 | 43 | } |
948 | | |
949 | 45 | fn shape(policy: &Policy, entry: &Entry) -> Shaped { |
950 | 45 | let headroom = policy.headroom_percent; |
951 | 45 | let mut memory_kb = with_headroom( |
952 | 45 | p95_capped(entry.memory_p95_upper(), entry.max_memory_kb()), |
953 | 45 | headroom, |
954 | | ); |
955 | 45 | if entry.killed_at_kb > 0 && memory_kb <= entry.killed_at_kb7 { |
956 | 4 | // Killed at that reservation: the next one must be larger, however |
957 | 4 | // small the samples that survived were, by the same headroom a |
958 | 4 | // measurement gets. |
959 | 4 | memory_kb = with_headroom(entry.killed_at_kb, headroom) + 1; |
960 | 41 | } |
961 | | // A CPU p95 of zero is an action that used no measurable CPU or a |
962 | | // worker that reported none; neither keeps the action off a small |
963 | | // class, so no CPU need is stated and the class goes by memory. |
964 | 45 | let mut cpu = with_headroom( |
965 | 45 | p95_capped(entry.cpu_p95_upper(), entry.max_cpu_millicores()), |
966 | 45 | headroom, |
967 | | ); |
968 | 45 | let mut disk_kb = with_headroom( |
969 | 45 | p95_capped(entry.disk_p95_upper(), entry.max_disk_kb()), |
970 | 45 | headroom, |
971 | | ); |
972 | | // A number on a dimension the fleet does not reserve says nothing about |
973 | | // the ones it does, so it is dropped before anything is decided from |
974 | | // it: a disk-only measurement is no measurement, and the cold start |
975 | | // applies, rather than the smallest class. |
976 | 45 | if !policy.reserve_cpu { |
977 | 0 | cpu = 0; |
978 | 45 | } |
979 | 45 | if !policy.reserve_memory { |
980 | 0 | memory_kb = 0; |
981 | 45 | } |
982 | 45 | if !policy.reserve_disk { |
983 | 7 | disk_kb = 0; |
984 | 38 | } |
985 | 45 | if memory_kb == 0 && cpu == 03 && disk_kb == 02 { |
986 | 2 | return Shaped::default(); |
987 | 43 | } |
988 | 43 | let mut class = None; |
989 | 14 | if let Some(found) = |
990 | 43 | smallest_dominating_class(&policy.classes, memory_kb, cpu, disk_kb, entry.killed_at_kb) |
991 | | { |
992 | 14 | class = Some(found.name.clone()); |
993 | 14 | if found.memory_kb > 0 { |
994 | 14 | memory_kb = found.memory_kb; |
995 | 14 | }0 |
996 | 14 | if found.cpu_count > 0 { |
997 | 14 | cpu = found.cpu_count; |
998 | 14 | }0 |
999 | 14 | if found.disk_kb > 0 { |
1000 | 1 | disk_kb = found.disk_kb; |
1001 | 13 | } |
1002 | 29 | } |
1003 | | Shaped { |
1004 | 43 | cpu_count: if policy.reserve_cpu { cpu } else { 00 }, |
1005 | 43 | memory_kb: if policy.reserve_memory { memory_kb } else { 00 }, |
1006 | 43 | disk_kb: if policy.reserve_disk { disk_kb38 } else { 05 }, |
1007 | 43 | class, |
1008 | | } |
1009 | 45 | } |
1010 | | |
1011 | | /// What each replica writes every `snapshot_interval_s` under its own key |
1012 | | /// and restores on restart. History from other replicas is not merged. |
1013 | | #[derive(Clone, Debug, PartialEq, Eq)] |
1014 | | pub struct Snapshot { |
1015 | | pub sampler_generation: u32, |
1016 | | pub written_at_s: u64, |
1017 | | /// Least recently used first within a shard, so a loader that inserts |
1018 | | /// in order ends with the same recency. |
1019 | | pub entries: Vec<(SketchKey, Entry)>, |
1020 | | } |
1021 | | |
1022 | | const SNAPSHOT_MAGIC: &[u8; 4] = b"NLHS"; |
1023 | | const SNAPSHOT_VERSION: u8 = 3; |
1024 | | |
1025 | | impl Snapshot { |
1026 | | /// A compact binary form: histograms as their non-zero buckets, since |
1027 | | /// most of the 25 are zero. No compression and no dependency; the budget |
1028 | | /// bounds the size. |
1029 | 25 | pub fn encode(&self) -> Vec<u8> { |
1030 | 25 | let mut out = Vec::with_capacity(32 + self.entries.len() * 160); |
1031 | 25 | out.extend_from_slice(SNAPSHOT_MAGIC); |
1032 | 25 | out.push(SNAPSHOT_VERSION); |
1033 | 25 | out.extend_from_slice(&self.sampler_generation.to_le_bytes()); |
1034 | 25 | out.extend_from_slice(&self.written_at_s.to_le_bytes()); |
1035 | 25 | out.extend_from_slice( |
1036 | 25 | &u64::try_from(self.entries.len()) |
1037 | 25 | .unwrap_or(u64::MAX) |
1038 | 25 | .to_le_bytes(), |
1039 | | ); |
1040 | 25 | for (key3 , entry3 ) in &self.entries { |
1041 | 3 | encode_entry(&mut out, key, entry); |
1042 | 3 | } |
1043 | 25 | out |
1044 | 25 | } |
1045 | | |
1046 | | /// Header bytes needed to select a restart snapshot without reading its payload. |
1047 | | pub const HEADER_BYTES: u64 = 17; |
1048 | | |
1049 | 49 | fn read_header(r: &mut Reader<'_>) -> Result<(u32, u64), Error> { |
1050 | 49 | if r.take(4)?0 != SNAPSHOT_MAGIC { |
1051 | 0 | return Err(make_err!( |
1052 | 0 | Code::InvalidArgument, |
1053 | 0 | "resource sketch snapshot: not a snapshot" |
1054 | 0 | )); |
1055 | 49 | } |
1056 | 49 | let version = r.u8()?0 ; |
1057 | 49 | if version != SNAPSHOT_VERSION { |
1058 | 1 | return Err(make_err!( |
1059 | 1 | Code::InvalidArgument, |
1060 | 1 | "resource sketch snapshot: version {version}, this build reads {SNAPSHOT_VERSION}" |
1061 | 1 | )); |
1062 | 48 | } |
1063 | 48 | Ok((r.u32()?0 , r.u64()?0 )) |
1064 | 49 | } |
1065 | | |
1066 | | /// The write time only when this build understands the snapshot's format |
1067 | | /// and sampler. Discovery never needs to allocate full histogram entries. |
1068 | 24 | pub fn compatible_written_at(bytes: &[u8]) -> Result<u64, Error> { |
1069 | 24 | let (generation, written_at) = Self::read_header(&mut Reader { bytes, at: 0 })?0 ; |
1070 | 24 | if generation != SAMPLER_GENERATION { |
1071 | 4 | return Err(make_err!( |
1072 | 4 | Code::FailedPrecondition, |
1073 | 4 | "resource sketch snapshot: incompatible sampler generation {generation}" |
1074 | 4 | )); |
1075 | 20 | } |
1076 | 20 | Ok(written_at) |
1077 | 24 | } |
1078 | | |
1079 | | /// Bound the decoded entries as well as the encoded bytes: sparse |
1080 | | /// histograms occupy much more memory when expanded than on disk. |
1081 | 25 | pub fn decode(bytes: &[u8], max_entries: usize) -> Result<Self, Error> { |
1082 | | const MIN_ENTRY_BYTES: usize = 96; |
1083 | 25 | let mut r = Reader { bytes, at: 0 }; |
1084 | 25 | let (sampler_generation24 , written_at_s24 ) = Self::read_header(&mut r)?1 ; |
1085 | 24 | let count = usize::try_from(r.u64()?0 ) |
1086 | 24 | .map_err(|_| make_err!0 (Code::InvalidArgument0 , "resource sketch snapshot: count"))?0 ; |
1087 | 24 | if count > max_entries || count23 > (bytes23 .len() - r.at) / MIN_ENTRY_BYTES { |
1088 | 2 | return Err(make_err!( |
1089 | 2 | Code::InvalidArgument, |
1090 | 2 | "resource sketch snapshot: entry count exceeds budget or payload" |
1091 | 2 | )); |
1092 | 22 | } |
1093 | 22 | let mut entries = Vec::with_capacity(count); |
1094 | 22 | for _ in 0..count { |
1095 | 81 | let key = match r.u8()?0 { |
1096 | | 0 => { |
1097 | 58 | let target = r.str()?0 ; |
1098 | 58 | let mnemonic = if r.u8()?0 == 1 { Some(r.str()?0 ) } else { None0 }; |
1099 | 58 | SketchKey::Target { target, mnemonic } |
1100 | | } |
1101 | 21 | 1 => SketchKey::Mnemonic { mnemonic: r.str()?0 }, |
1102 | 2 | 2 => SketchKey::Command { digest: r.str()?0 }, |
1103 | 0 | tag => { |
1104 | 0 | return Err(make_err!( |
1105 | 0 | Code::InvalidArgument, |
1106 | 0 | "resource sketch snapshot: key tag {tag}" |
1107 | 0 | )); |
1108 | | } |
1109 | | }; |
1110 | 81 | let samples = r.u32()?0 ; |
1111 | 81 | let current = r.half()?0 ; |
1112 | 81 | let previous = r.half()?0 ; |
1113 | 81 | let killed_at_kb = r.u64()?0 ; |
1114 | 81 | let killed_at_s = r.u64()?0 ; |
1115 | 81 | let last_seen_s = r.u64()?0 ; |
1116 | 81 | let period80 = r.u64()?1 ; |
1117 | 80 | let seeded79 = r.u8()?1 == 1; |
1118 | 79 | entries.push(( |
1119 | 79 | key, |
1120 | 79 | Entry { |
1121 | 79 | samples, |
1122 | 79 | current, |
1123 | 79 | previous, |
1124 | 79 | killed_at_kb, |
1125 | 79 | killed_at_s, |
1126 | 79 | last_seen_s, |
1127 | 79 | period, |
1128 | 79 | seeded, |
1129 | 79 | }, |
1130 | 79 | )); |
1131 | | } |
1132 | 20 | if r.at != bytes.len() { |
1133 | 0 | return Err(make_err!( |
1134 | 0 | Code::InvalidArgument, |
1135 | 0 | "resource sketch snapshot: {} trailing bytes", |
1136 | 0 | bytes.len() - r.at |
1137 | 0 | )); |
1138 | 20 | } |
1139 | 20 | Ok(Self { |
1140 | 20 | sampler_generation, |
1141 | 20 | written_at_s, |
1142 | 20 | entries, |
1143 | 20 | }) |
1144 | 25 | } |
1145 | | } |
1146 | | |
1147 | 147 | fn put_str(out: &mut Vec<u8>, s: &str) { |
1148 | 147 | let len = u32::try_from(s.len()).expect("key fits within the sketch budget"); |
1149 | 147 | out.extend_from_slice(&len.to_le_bytes()); |
1150 | 147 | out.extend_from_slice(&s.as_bytes()[..len as usize]); |
1151 | 147 | } |
1152 | | |
1153 | 516 | fn put_hist(out: &mut Vec<u8>, hist: &Hist) { |
1154 | 12.9k | let nonzero516 = hist.0516 .iter516 ().filter516 (|&&n| n > 0).count516 (); |
1155 | 516 | out.push(u8::try_from(nonzero).unwrap_or(u8::MAX)); |
1156 | 12.9k | for (i, &n) in hist.0516 .iter516 ().enumerate516 () { |
1157 | 12.9k | if n > 0 { |
1158 | 241 | out.push(u8::try_from(i).unwrap_or(u8::MAX)); |
1159 | 241 | out.extend_from_slice(&n.to_le_bytes()); |
1160 | 12.6k | } |
1161 | | } |
1162 | 516 | } |
1163 | | |
1164 | | struct Reader<'a> { |
1165 | | bytes: &'a [u8], |
1166 | | at: usize, |
1167 | | } |
1168 | | |
1169 | | impl<'a> Reader<'a> { |
1170 | 2.57k | fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { |
1171 | 2.57k | let end = self |
1172 | 2.57k | .at |
1173 | 2.57k | .checked_add(n) |
1174 | 2.57k | .filter(|&end| end <= self.bytes.len()); |
1175 | 2.57k | let Some(end2.57k ) = end else { |
1176 | 2 | return Err(make_err!( |
1177 | 2 | Code::InvalidArgument, |
1178 | 2 | "resource sketch snapshot: truncated at byte {}", |
1179 | 2 | self.at |
1180 | 2 | )); |
1181 | | }; |
1182 | 2.57k | let out = &self.bytes[self.at..end]; |
1183 | 2.57k | self.at = end; |
1184 | 2.57k | Ok(out) |
1185 | 2.57k | } |
1186 | | |
1187 | 994 | fn u8(&mut self) -> Result<u8, Error> { |
1188 | 994 | Ok(self.take(1)?1 [0993 ]) |
1189 | 994 | } |
1190 | | |
1191 | 268 | fn u32(&mut self) -> Result<u32, Error> { |
1192 | 268 | let b = self.take(4)?0 ; |
1193 | 268 | Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) |
1194 | 268 | } |
1195 | | |
1196 | 1.12k | fn u64(&mut self) -> Result<u64, Error> { |
1197 | 1.12k | let b1.12k = self.take(8)?1 ; |
1198 | 1.12k | let mut arr = [0u8; 8]; |
1199 | 1.12k | arr.copy_from_slice(b); |
1200 | 1.12k | Ok(u64::from_le_bytes(arr)) |
1201 | 1.12k | } |
1202 | | |
1203 | 139 | fn str(&mut self) -> Result<Arc<str>, Error> { |
1204 | 139 | let len = self.u32()?0 as usize; |
1205 | 139 | let b = self.take(len)?0 ; |
1206 | 139 | core::str::from_utf8(b) |
1207 | 139 | .map(Arc::from) |
1208 | 139 | .map_err(|_| make_err!0 (Code::InvalidArgument0 , "resource sketch snapshot: utf8")) |
1209 | 139 | } |
1210 | | |
1211 | 162 | fn half(&mut self) -> Result<Half, Error> { |
1212 | | Ok(Half { |
1213 | 162 | memory: self.hist()?0 , |
1214 | 162 | cpu: self.hist()?0 , |
1215 | 162 | disk: self.hist()?0 , |
1216 | 162 | max_memory_kb: self.u64()?0 , |
1217 | 162 | max_cpu_millicores: self.u64()?0 , |
1218 | 162 | max_disk_kb: self.u64()?0 , |
1219 | | }) |
1220 | 162 | } |
1221 | | |
1222 | 486 | fn hist(&mut self) -> Result<Hist, Error> { |
1223 | 486 | let nonzero = usize::from(self.u8()?0 ); |
1224 | 486 | if nonzero > HIST_BUCKETS { |
1225 | 0 | return Err(make_err!( |
1226 | 0 | Code::InvalidArgument, |
1227 | 0 | "resource sketch snapshot: {nonzero} histogram buckets" |
1228 | 0 | )); |
1229 | 486 | } |
1230 | 486 | let mut hist = Hist::default(); |
1231 | 486 | for _ in 0..nonzero { |
1232 | 240 | let i = usize::from(self.u8()?0 ); |
1233 | 240 | let n = self.u64()?0 ; |
1234 | 240 | if i >= HIST_BUCKETS { |
1235 | 0 | return Err(make_err!( |
1236 | 0 | Code::InvalidArgument, |
1237 | 0 | "resource sketch snapshot: histogram bucket {i}" |
1238 | 0 | )); |
1239 | 240 | } |
1240 | 240 | hist.0[i] = n; |
1241 | | } |
1242 | 486 | Ok(hist) |
1243 | 486 | } |
1244 | | } |
1245 | | |
1246 | 86 | fn encode_entry(out: &mut Vec<u8>, key: &SketchKey, entry: &Entry) { |
1247 | 86 | match key { |
1248 | 61 | SketchKey::Target { target, mnemonic } => { |
1249 | 61 | out.push(0); |
1250 | 61 | put_str(out, target); |
1251 | 61 | match mnemonic { |
1252 | 61 | Some(mnemonic) => { |
1253 | 61 | out.push(1); |
1254 | 61 | put_str(out, mnemonic); |
1255 | 61 | } |
1256 | 0 | None => out.push(0), |
1257 | | } |
1258 | | } |
1259 | 24 | SketchKey::Mnemonic { mnemonic } => { |
1260 | 24 | out.push(1); |
1261 | 24 | put_str(out, mnemonic); |
1262 | 24 | } |
1263 | 1 | SketchKey::Command { digest } => { |
1264 | 1 | out.push(2); |
1265 | 1 | put_str(out, digest); |
1266 | 1 | } |
1267 | | } |
1268 | 86 | out.extend_from_slice(&entry.samples.to_le_bytes()); |
1269 | 172 | for half in [&entry.current, &entry.previous]86 { |
1270 | 516 | for hist in [172 &half.memory172 , &half.cpu, &half.disk] { |
1271 | 516 | put_hist(out, hist); |
1272 | 516 | } |
1273 | 516 | for value in [ |
1274 | 172 | half.max_memory_kb, |
1275 | 172 | half.max_cpu_millicores, |
1276 | 172 | half.max_disk_kb, |
1277 | 516 | ] { |
1278 | 516 | out.extend_from_slice(&value.to_le_bytes()); |
1279 | 516 | } |
1280 | | } |
1281 | 344 | for value in [ |
1282 | 86 | entry.killed_at_kb, |
1283 | 86 | entry.killed_at_s, |
1284 | 86 | entry.last_seen_s, |
1285 | 86 | entry.period, |
1286 | 344 | ] { |
1287 | 344 | out.extend_from_slice(&value.to_le_bytes()); |
1288 | 344 | } |
1289 | 86 | out.push(u8::from(entry.seeded)); |
1290 | 86 | } |