Skip to main content

weir/fixed_point_scratch/
buffers.rs

1use super::FrontierDensityTelemetry;
2
3/// Cached CUDA-oriented changed-flag Program for one fixed-point graph shape.
4#[derive(Clone, Debug, PartialEq)]
5pub(crate) struct FixedPointForwardChangedProgramCache {
6    pub(crate) kind: crate::fixed_point_graph::FixedPointAnalysisKind,
7    pub(crate) node_count: u32,
8    pub(crate) edge_count: u32,
9    pub(crate) program: vyre::ir::Program,
10}
11
12/// Caller-owned scratch for Weir fixed-point GPU wrappers.
13#[derive(Clone, Debug, Default, PartialEq)]
14pub struct FixedPointScratch {
15    pub(crate) frontier_words: Vec<u32>,
16    pub(crate) frontier_bytes: Vec<u8>,
17    pub(crate) previous_frontier_bytes: Vec<u8>,
18    pub(crate) changed_bytes: Vec<u8>,
19    pub(crate) static_input_bytes: Vec<Vec<u8>>,
20    pub(crate) outputs: Vec<Vec<u8>>,
21    pub(crate) next: Vec<u32>,
22    pub(crate) resources: Vec<vyre::backend::Resource>,
23    pub(crate) alternate_resources: Vec<vyre::backend::Resource>,
24    pub(crate) changed_resources: Vec<vyre::backend::Resource>,
25    pub(crate) clear_changed_program: Option<vyre::ir::Program>,
26    pub(crate) forward_changed_program: Option<FixedPointForwardChangedProgramCache>,
27    pub(crate) edge_kind_masks: Vec<u32>,
28    pub(crate) reverse_indegree: Vec<usize>,
29    pub(crate) reverse_cursor: Vec<usize>,
30    pub(crate) reverse_offsets: Vec<u32>,
31    pub(crate) reverse_targets: Vec<u32>,
32    pub(crate) reverse_masks: Vec<u32>,
33    pub(crate) frontier_density: FrontierDensityTelemetry,
34    pub(crate) dispatch_config: vyre::DispatchConfig,
35    #[cfg(feature = "gpu-telemetry")]
36    pub(crate) telemetry_popcount_input_bytes: Vec<u8>,
37    #[cfg(feature = "gpu-telemetry")]
38    pub(crate) telemetry_popcount_output_bytes: Vec<u8>,
39    #[cfg(feature = "gpu-telemetry")]
40    pub(crate) telemetry_sum_bytes: Vec<u8>,
41    #[cfg(feature = "gpu-telemetry")]
42    pub(crate) telemetry_xor_words: Vec<u32>,
43    #[cfg(feature = "gpu-telemetry")]
44    pub(crate) telemetry_popcount_program: Option<(u32, vyre::ir::Program)>,
45    #[cfg(feature = "gpu-telemetry")]
46    pub(crate) telemetry_reduce_program: Option<(u32, vyre::ir::Program)>,
47}
48
49impl FixedPointScratch {
50    /// Allocate reusable fixed-point scratch with enough retained capacity for
51    /// a known hot loop shape.
52    ///
53    /// `frontier_words` sizes both the current and next frontier word buffers.
54    /// The byte frontier buffer is pre-sized to the exact little-endian byte
55    /// representation capacity used by dispatch wrappers. `output_slots`
56    /// preallocates reusable backend output vectors, and `resource_slots`
57    /// preallocates the dispatch resource list plus retained static input
58    /// byte slots for invariant graph buffers.
59    pub fn with_capacities(
60        frontier_words: usize,
61        output_slots: usize,
62        resource_slots: usize,
63    ) -> Result<Self, String> {
64        let frontier_bytes = frontier_words
65            .checked_mul(std::mem::size_of::<u32>())
66            .ok_or_else(|| {
67                "weir fixed-point scratch frontier byte capacity overflowed usize. Fix: lower frontier_words before allocating reusable scratch."
68                    .to_string()
69            })?;
70        let mut scratch = Self {
71            frontier_words: Vec::new(),
72            frontier_bytes: Vec::new(),
73            previous_frontier_bytes: Vec::new(),
74            changed_bytes: Vec::new(),
75            static_input_bytes: Vec::new(),
76            outputs: Vec::new(),
77            next: Vec::new(),
78            resources: Vec::new(),
79            alternate_resources: Vec::new(),
80            changed_resources: Vec::new(),
81            clear_changed_program: None,
82            forward_changed_program: None,
83            edge_kind_masks: Vec::new(),
84            reverse_indegree: Vec::new(),
85            reverse_cursor: Vec::new(),
86            reverse_offsets: Vec::new(),
87            reverse_targets: Vec::new(),
88            reverse_masks: Vec::new(),
89            frontier_density: FrontierDensityTelemetry::default(),
90            dispatch_config: vyre::DispatchConfig::default(),
91            #[cfg(feature = "gpu-telemetry")]
92            telemetry_popcount_input_bytes: Vec::new(),
93            #[cfg(feature = "gpu-telemetry")]
94            telemetry_popcount_output_bytes: Vec::new(),
95            #[cfg(feature = "gpu-telemetry")]
96            telemetry_sum_bytes: Vec::new(),
97            #[cfg(feature = "gpu-telemetry")]
98            telemetry_xor_words: Vec::new(),
99            #[cfg(feature = "gpu-telemetry")]
100            telemetry_popcount_program: None,
101            #[cfg(feature = "gpu-telemetry")]
102            telemetry_reduce_program: None,
103        };
104        crate::staging_reserve::reserve_vec(
105            &mut scratch.frontier_words,
106            frontier_words,
107            "fixed-point frontier word",
108        )?;
109        crate::staging_reserve::reserve_vec(
110            &mut scratch.frontier_bytes,
111            frontier_bytes,
112            "fixed-point frontier byte",
113        )?;
114        crate::staging_reserve::reserve_vec(
115            &mut scratch.previous_frontier_bytes,
116            frontier_bytes,
117            "fixed-point previous frontier byte",
118        )?;
119        crate::staging_reserve::reserve_vec(
120            &mut scratch.changed_bytes,
121            std::mem::size_of::<u32>(),
122            "fixed-point changed flag byte",
123        )?;
124        crate::staging_reserve::reserve_vec(
125            &mut scratch.static_input_bytes,
126            resource_slots,
127            "fixed-point static input byte slot",
128        )?;
129        crate::staging_reserve::resize_vec_slots(
130            &mut scratch.outputs,
131            output_slots,
132            "fixed-point output slot",
133        )?;
134        crate::staging_reserve::reserve_vec(
135            &mut scratch.next,
136            frontier_words,
137            "fixed-point next frontier word",
138        )?;
139        crate::staging_reserve::reserve_vec(
140            &mut scratch.resources,
141            resource_slots,
142            "fixed-point resident resource",
143        )?;
144        crate::staging_reserve::reserve_vec(
145            &mut scratch.alternate_resources,
146            resource_slots,
147            "fixed-point alternate resident resource",
148        )?;
149        crate::staging_reserve::reserve_vec(
150            &mut scratch.changed_resources,
151            resource_slots,
152            "fixed-point changed resident resource",
153        )?;
154        #[cfg(feature = "gpu-telemetry")]
155        {
156            crate::staging_reserve::reserve_vec(
157                &mut scratch.telemetry_popcount_input_bytes,
158                frontier_bytes,
159                "telemetry popcount input byte",
160            )?;
161            crate::staging_reserve::reserve_vec(
162                &mut scratch.telemetry_popcount_output_bytes,
163                frontier_bytes,
164                "telemetry popcount output byte",
165            )?;
166            crate::staging_reserve::reserve_vec(
167                &mut scratch.telemetry_sum_bytes,
168                std::mem::size_of::<u32>(),
169                "telemetry reduce sum byte",
170            )?;
171            crate::staging_reserve::reserve_vec(
172                &mut scratch.telemetry_xor_words,
173                frontier_words,
174                "telemetry xor word",
175            )?;
176        }
177        Ok(scratch)
178    }
179
180    /// Drop logical contents while preserving allocation capacity.
181    pub fn clear(&mut self) {
182        self.frontier_words.clear();
183        self.frontier_bytes.clear();
184        self.previous_frontier_bytes.clear();
185        self.changed_bytes.clear();
186        crate::output_scratch::clear_outputs_preserving_slots(&mut self.static_input_bytes);
187        crate::output_scratch::clear_outputs_preserving_slots(&mut self.outputs);
188        self.next.clear();
189        self.resources.clear();
190        self.alternate_resources.clear();
191        self.changed_resources.clear();
192        self.edge_kind_masks.clear();
193        self.reverse_indegree.clear();
194        self.reverse_cursor.clear();
195        self.reverse_offsets.clear();
196        self.reverse_targets.clear();
197        self.reverse_masks.clear();
198        self.frontier_density = FrontierDensityTelemetry::default();
199        #[cfg(feature = "gpu-telemetry")]
200        {
201            self.telemetry_popcount_input_bytes.clear();
202            self.telemetry_popcount_output_bytes.clear();
203            self.telemetry_sum_bytes.clear();
204            self.telemetry_xor_words.clear();
205            self.telemetry_popcount_program = None;
206            self.telemetry_reduce_program = None;
207        }
208    }
209
210    /// Prepare retained static input byte slots for invariant dispatch inputs.
211    pub(crate) fn prepare_static_input_byte_slots(&mut self, slots: usize) -> Result<(), String> {
212        crate::staging_reserve::resize_vec_slots(
213            &mut self.static_input_bytes,
214            slots,
215            "fixed-point static input byte slot",
216        )?;
217        crate::output_scratch::clear_outputs_preserving_slots(&mut self.static_input_bytes);
218        Ok(())
219    }
220
221    /// Number of backend output slots currently retained by this scratch.
222    #[must_use]
223    pub fn output_slot_count(&self) -> usize {
224        self.outputs.len()
225    }
226
227    /// Current retained capacity for backend output slots.
228    #[must_use]
229    pub fn output_slot_capacity(&self) -> usize {
230        self.outputs.capacity()
231    }
232
233    /// Current retained capacity for static dispatch input byte slots.
234    #[must_use]
235    pub fn static_input_slot_capacity(&self) -> usize {
236        self.static_input_bytes.capacity()
237    }
238
239    /// Current frontier byte-buffer capacity retained by this scratch.
240    #[must_use]
241    pub fn frontier_byte_capacity(&self) -> usize {
242        self.frontier_bytes.capacity()
243    }
244
245    /// Current frontier word-buffer capacity retained by this scratch.
246    #[must_use]
247    pub fn frontier_word_capacity(&self) -> usize {
248        self.frontier_words.capacity()
249    }
250
251    /// Current decoded-next-word capacity retained by this scratch.
252    #[must_use]
253    pub fn next_word_capacity(&self) -> usize {
254        self.next.capacity()
255    }
256
257    /// Current dispatch resource-list capacity retained by this scratch.
258    #[must_use]
259    pub fn resource_capacity(&self) -> usize {
260        self.resources.capacity()
261    }
262
263    /// Current alternate dispatch resource-list capacity retained by this scratch.
264    #[must_use]
265    pub fn alternate_resource_capacity(&self) -> usize {
266        self.alternate_resources.capacity()
267    }
268
269    /// Current filtered-edge-kind mask capacity retained by this scratch.
270    #[must_use]
271    pub fn edge_kind_mask_capacity(&self) -> usize {
272        self.edge_kind_masks.capacity()
273    }
274
275    /// Current reverse-CSR offset capacity retained by this scratch.
276    #[must_use]
277    pub fn reverse_offset_capacity(&self) -> usize {
278        self.reverse_offsets.capacity()
279    }
280
281    /// Current reverse-CSR target capacity retained by this scratch.
282    #[must_use]
283    pub fn reverse_target_capacity(&self) -> usize {
284        self.reverse_targets.capacity()
285    }
286
287    /// Latest fixed-point frontier density counters for this scratch session.
288    #[must_use]
289    pub fn frontier_density(&self) -> FrontierDensityTelemetry {
290        self.frontier_density
291    }
292
293    pub(crate) fn prepare_frontier_words(
294        &mut self,
295        words: usize,
296        seed_bits: &[u32],
297    ) -> Result<(), String> {
298        crate::dispatch_decode::try_write_zero_words(
299            &mut self.frontier_words,
300            words,
301            "fixed-point frontier word staging",
302        )?;
303        for (dst, src) in self
304            .frontier_words
305            .iter_mut()
306            .zip(seed_bits.iter().copied())
307        {
308            *dst = src;
309        }
310        Ok(())
311    }
312
313    pub(crate) fn begin_frontier_density(&mut self, domain_bits: u32) {
314        self.frontier_density = FrontierDensityTelemetry::begin(domain_bits);
315        self.record_current_frontier_sample();
316    }
317
318    pub(crate) fn record_decoded_frontier_transition(&mut self) {
319        let domain_bits = domain_bits_to_usize(
320            self.frontier_density.domain_bits,
321            &mut self.frontier_density,
322        );
323        let active = count_set_domain_bits(&self.next, domain_bits, &mut self.frontier_density);
324        let delta = count_changed_domain_bits(
325            &self.frontier_words,
326            &self.next,
327            domain_bits,
328            &mut self.frontier_density,
329        );
330        self.frontier_density.record_transition(active, delta);
331    }
332
333    pub(crate) fn record_compacted_frontier_window(&mut self, iterations: u32) {
334        let domain_bits = domain_bits_to_usize(
335            self.frontier_density.domain_bits,
336            &mut self.frontier_density,
337        );
338        let active = count_set_domain_bits(&self.next, domain_bits, &mut self.frontier_density);
339        let delta = count_changed_domain_bits(
340            &self.frontier_words,
341            &self.next,
342            domain_bits,
343            &mut self.frontier_density,
344        );
345        self.frontier_density
346            .record_window(iterations, active, delta);
347    }
348
349    fn record_current_frontier_sample(&mut self) {
350        let domain_bits = domain_bits_to_usize(
351            self.frontier_density.domain_bits,
352            &mut self.frontier_density,
353        );
354        let active = count_set_domain_bits(
355            &self.frontier_words,
356            domain_bits,
357            &mut self.frontier_density,
358        );
359        self.frontier_density.record_sample(active);
360    }
361
362    pub(crate) fn prepare_next_words(&mut self, words: usize) -> Result<(), String> {
363        self.next.clear();
364        reserve_total(&mut self.next, words, "fixed-point next frontier word")
365    }
366
367    pub(crate) fn prepare_grid_dispatch_config(
368        &mut self,
369        base: &vyre::DispatchConfig,
370        grid_override: [u32; 3],
371    ) {
372        if !dispatch_config_matches_base_except_grid(&self.dispatch_config, base) {
373            self.dispatch_config.profile.clone_from(&base.profile);
374            self.dispatch_config.ulp_budget = base.ulp_budget;
375            self.dispatch_config.timeout = base.timeout;
376            self.dispatch_config.label.clone_from(&base.label);
377            self.dispatch_config.max_output_bytes = base.max_output_bytes;
378            self.dispatch_config.workgroup_override = base.workgroup_override;
379            self.dispatch_config.fixpoint_iterations = base.fixpoint_iterations;
380            self.dispatch_config.speculation = base.speculation;
381            self.dispatch_config.persistent_thread = base.persistent_thread;
382            self.dispatch_config.cooperative = base.cooperative;
383        }
384        self.dispatch_config.grid_override = Some(grid_override);
385    }
386
387    pub(crate) fn prepare_edge_kind_masks(
388        &mut self,
389        edge_kind_mask: &[u32],
390        allowed_mask: u32,
391    ) -> Result<(), String> {
392        reserve_total(
393            &mut self.edge_kind_masks,
394            edge_kind_mask.len(),
395            "fixed-point filtered edge-kind mask",
396        )?;
397        if self.edge_kind_masks.len() == edge_kind_mask.len() {
398            for (slot, kind) in self.edge_kind_masks.iter_mut().zip(edge_kind_mask) {
399                *slot = *kind & allowed_mask;
400            }
401        } else {
402            self.edge_kind_masks.clear();
403            self.edge_kind_masks
404                .extend(edge_kind_mask.iter().map(|kind| kind & allowed_mask));
405        }
406        Ok(())
407    }
408
409    pub(crate) fn prepare_reverse_csr_scratch(
410        &mut self,
411        node_count: usize,
412        edge_count: usize,
413    ) -> Result<(), String> {
414        self.reverse_indegree.clear();
415        reserve_total(
416            &mut self.reverse_indegree,
417            node_count,
418            "fixed-point reverse CSR indegree",
419        )?;
420        self.reverse_indegree
421            .extend(std::iter::repeat_n(0, node_count));
422        self.reverse_cursor.clear();
423        reserve_total(
424            &mut self.reverse_cursor,
425            node_count,
426            "fixed-point reverse CSR cursor",
427        )?;
428        self.reverse_cursor
429            .extend(std::iter::repeat_n(0, node_count));
430        let offset_words = node_count.checked_add(1).ok_or_else(|| {
431            "fixed-point reverse CSR scratch node_count overflows sentinel offset. Fix: shard the graph before reverse CSR preparation."
432                .to_string()
433        })?;
434        crate::dispatch_decode::try_write_zero_words(
435            &mut self.reverse_offsets,
436            offset_words,
437            "fixed-point reverse CSR offset",
438        )?;
439        self.reverse_targets.clear();
440        reserve_total(
441            &mut self.reverse_targets,
442            edge_count.max(1),
443            "fixed-point reverse CSR target",
444        )?;
445        self.reverse_masks.clear();
446        reserve_total(
447            &mut self.reverse_masks,
448            edge_count.max(1),
449            "fixed-point reverse CSR mask",
450        )?;
451        Ok(())
452    }
453
454    #[cfg(test)]
455    pub(crate) fn replace_outputs_preserving_slots(&mut self, incoming: Vec<Vec<u8>>) {
456        crate::output_scratch::replace_outputs_preserving_slots(&mut self.outputs, incoming);
457    }
458}
459
460#[inline]
461fn reserve_total<T>(values: &mut Vec<T>, total: usize, field: &'static str) -> Result<(), String> {
462    crate::staging_reserve::reserve_vec(values, total, field)
463}
464
465fn dispatch_config_matches_base_except_grid(
466    cached: &vyre::DispatchConfig,
467    base: &vyre::DispatchConfig,
468) -> bool {
469    cached.profile == base.profile
470        && cached.ulp_budget == base.ulp_budget
471        && cached.timeout == base.timeout
472        && cached.label == base.label
473        && cached.max_output_bytes == base.max_output_bytes
474        && cached.workgroup_override == base.workgroup_override
475        && cached.fixpoint_iterations == base.fixpoint_iterations
476        && cached.speculation == base.speculation
477        && cached.persistent_thread == base.persistent_thread
478        && cached.cooperative == base.cooperative
479}
480
481#[inline]
482pub fn count_set_domain_bits(
483    words: &[u32],
484    domain_bits: usize,
485    telemetry: &mut FrontierDensityTelemetry,
486) -> u64 {
487    let full_words = domain_bits / 32;
488    let tail_bits = domain_bits % 32;
489    let required_words = full_words + usize::from(tail_bits != 0);
490    if words.len() < required_words {
491        telemetry.record_truncated_frontier_sample();
492    }
493    let available_full_words = full_words.min(words.len());
494    let mut count = 0_u64;
495    for word in words.iter().take(available_full_words) {
496        count = bounded_telemetry_add(count, u64::from(word.count_ones()), telemetry);
497    }
498    if tail_bits != 0 && words.len() > full_words {
499        let mask = (1u32 << tail_bits) - 1;
500        count = bounded_telemetry_add(
501            count,
502            u64::from((words[full_words] & mask).count_ones()),
503            telemetry,
504        );
505    }
506    count
507}
508
509#[inline]
510pub fn count_changed_domain_bits(
511    current: &[u32],
512    next: &[u32],
513    domain_bits: usize,
514    telemetry: &mut FrontierDensityTelemetry,
515) -> u64 {
516    let full_words = domain_bits / 32;
517    let tail_bits = domain_bits % 32;
518    let required_words = full_words + usize::from(tail_bits != 0);
519    if current.len() < required_words || next.len() < required_words {
520        telemetry.record_truncated_frontier_sample();
521    }
522    let available_full_words = full_words.min(current.len()).min(next.len());
523    let mut count = 0_u64;
524    for index in 0..available_full_words {
525        count = bounded_telemetry_add(
526            count,
527            u64::from((current[index] ^ next[index]).count_ones()),
528            telemetry,
529        );
530    }
531    if tail_bits != 0 && current.len() > full_words && next.len() > full_words {
532        let mask = (1u32 << tail_bits) - 1;
533        count = bounded_telemetry_add(
534            count,
535            u64::from(((current[full_words] ^ next[full_words]) & mask).count_ones()),
536            telemetry,
537        );
538    }
539    count
540}
541
542/// GPU-accelerated `count_set_domain_bits` using `bitset_popcount` + `reduce_sum`.
543///
544/// Composes two vyre primitives on the backend so that host work is reduced
545/// to a single scalar download. Falls back to the scalar host path for very
546/// small frontiers (fewer than 64 words) where dispatch overhead dominates.
547///
548/// # Errors
549///
550/// Returns an actionable error string when backend dispatch fails or the
551/// returned scalar cannot be decoded.
552#[cfg(feature = "gpu-telemetry")]
553pub fn count_set_domain_bits_gpu(
554    backend: &dyn vyre::VyreBackend,
555    config: &vyre::DispatchConfig,
556    words: &[u32],
557    domain_bits: usize,
558    telemetry: &mut FrontierDensityTelemetry,
559    scratch: &mut FixedPointScratch,
560) -> Result<u64, String> {
561    let full_words = domain_bits / 32;
562    let tail_bits = domain_bits % 32;
563    let required_words = full_words + usize::from(tail_bits != 0);
564    if words.len() < required_words {
565        telemetry.record_truncated_frontier_sample();
566    }
567
568    let available_words = required_words.min(words.len());
569    if available_words == 0 {
570        return Ok(0);
571    }
572
573    // Fallback to host scalar path for very small frontiers where
574    // kernel launch overhead dominates.
575    if available_words < 64 {
576        return Ok(count_set_domain_bits(words, domain_bits, telemetry));
577    }
578
579    let words_u32 = u32::try_from(available_words).map_err(|_| {
580        "telemetry word count exceeds u32 dispatch metadata. Fix: shard the frontier before GPU telemetry dispatch."
581            .to_string()
582    })?;
583
584    // Stage input bytes, masking the tail word so the GPU only counts
585    // valid domain bits.
586    crate::dispatch_decode::try_write_zero_words(
587        &mut scratch.telemetry_xor_words,
588        available_words,
589        "telemetry set bits staging",
590    )?;
591    for (dst, src) in scratch
592        .telemetry_xor_words
593        .iter_mut()
594        .zip(words.iter().copied())
595    {
596        *dst = src;
597    }
598    if tail_bits != 0 && available_words > full_words {
599        let mask = (1u32 << tail_bits) - 1;
600        scratch.telemetry_xor_words[full_words] &= mask;
601    }
602    crate::dispatch_decode::try_pack_u32_into(
603        &scratch.telemetry_xor_words[..available_words],
604        &mut scratch.telemetry_popcount_input_bytes,
605        "telemetry set bits input",
606    )?;
607
608    let popcount_outputs = dispatch_popcount_then_reduce(backend, config, words_u32, scratch)?;
609
610    let sum_bytes =
611        crate::dispatch_decode::only_output(&popcount_outputs, "telemetry reduce_sum", "sum")?;
612    let sum_u32 = crate::dispatch_decode::unpack_exact_u32_scalar(sum_bytes, "telemetry sum")?;
613    let sum_u64 = u64::from(sum_u32);
614    telemetry.record_sample(sum_u64);
615    Ok(sum_u64)
616}
617
618/// GPU-accelerated `count_changed_domain_bits` using `bitset_popcount` + `reduce_sum`.
619///
620/// XORs the two frontier buffers on the host (cheap: ~word_count XORs),
621/// then dispatches the popcount+reduce composition on the GPU.  The tail
622/// word is masked before dispatch so only valid domain bits are counted.
623///
624/// # Errors
625///
626/// Returns an actionable error string when backend dispatch fails or the
627/// returned scalar cannot be decoded.
628#[cfg(feature = "gpu-telemetry")]
629pub fn count_changed_domain_bits_gpu(
630    backend: &dyn vyre::VyreBackend,
631    config: &vyre::DispatchConfig,
632    current: &[u32],
633    next: &[u32],
634    domain_bits: usize,
635    telemetry: &mut FrontierDensityTelemetry,
636    scratch: &mut FixedPointScratch,
637) -> Result<u64, String> {
638    let full_words = domain_bits / 32;
639    let tail_bits = domain_bits % 32;
640    let required_words = full_words + usize::from(tail_bits != 0);
641    if current.len() < required_words || next.len() < required_words {
642        telemetry.record_truncated_frontier_sample();
643    }
644
645    let available_words = required_words.min(current.len()).min(next.len());
646    if available_words == 0 {
647        return Ok(0);
648    }
649
650    // Fallback to host scalar path for very small frontiers.
651    if available_words < 64 {
652        return Ok(count_changed_domain_bits(
653            current,
654            next,
655            domain_bits,
656            telemetry,
657        ));
658    }
659
660    let words_u32 = u32::try_from(available_words).map_err(|_| {
661        "telemetry word count exceeds u32 dispatch metadata. Fix: shard the frontier before GPU telemetry dispatch."
662            .to_string()
663    })?;
664
665    // XOR on host into reusable scratch, masking the tail word.
666    crate::dispatch_decode::try_write_zero_words(
667        &mut scratch.telemetry_xor_words,
668        available_words,
669        "telemetry changed bits staging",
670    )?;
671    for index in 0..available_words {
672        scratch.telemetry_xor_words[index] = current[index] ^ next[index];
673    }
674    if tail_bits != 0 && available_words > full_words {
675        let mask = (1u32 << tail_bits) - 1;
676        scratch.telemetry_xor_words[full_words] &= mask;
677    }
678    crate::dispatch_decode::try_pack_u32_into(
679        &scratch.telemetry_xor_words[..available_words],
680        &mut scratch.telemetry_popcount_input_bytes,
681        "telemetry changed bits input",
682    )?;
683
684    let popcount_outputs = dispatch_popcount_then_reduce(backend, config, words_u32, scratch)?;
685
686    let sum_bytes =
687        crate::dispatch_decode::only_output(&popcount_outputs, "telemetry reduce_sum", "sum")?;
688    let sum_u32 = crate::dispatch_decode::unpack_exact_u32_scalar(sum_bytes, "telemetry sum")?;
689    let sum_u64 = u64::from(sum_u32);
690    telemetry.record_sample(sum_u64);
691    Ok(sum_u64)
692}
693
694#[cfg(feature = "gpu-telemetry")]
695fn dispatch_popcount_then_reduce(
696    backend: &dyn vyre::VyreBackend,
697    config: &vyre::DispatchConfig,
698    words: u32,
699    scratch: &mut FixedPointScratch,
700) -> Result<Vec<Vec<u8>>, String> {
701    let popcount_byte_len = words
702        .checked_mul(4)
703        .ok_or_else(|| {
704            "telemetry popcount byte length overflows u32. Fix: shard the frontier before GPU telemetry dispatch."
705                .to_string()
706        })? as usize;
707
708    // Build or reuse cached popcount program.
709    let popcount_program = match &scratch.telemetry_popcount_program {
710        Some((cached_words, _)) if *cached_words == words => None,
711        _ => Some(vyre_primitives::bitset::popcount::bitset_popcount(
712            "input",
713            "count_words",
714            words,
715        )),
716    };
717    if let Some(program) = popcount_program {
718        scratch.telemetry_popcount_program = Some((words, program));
719    }
720    let popcount_program = scratch
721        .telemetry_popcount_program
722        .as_ref()
723        .map(|(_, p)| p)
724        .ok_or_else(|| {
725            "telemetry popcount program missing after cache update. Fix: report this as an internal logic error."
726                .to_string()
727        })?;
728
729    // Zero-popcount output staging so the backend sees a clean buffer.
730    crate::dispatch_decode::try_write_zero_bytes(
731        &mut scratch.telemetry_popcount_output_bytes,
732        popcount_byte_len,
733        "telemetry popcount output zero staging",
734    )?;
735
736    let popcount_inputs: Vec<Vec<u8>> = vec![
737        scratch.telemetry_popcount_input_bytes[..popcount_byte_len].to_vec(),
738        scratch.telemetry_popcount_output_bytes[..popcount_byte_len].to_vec(),
739    ];
740    let popcount_outputs = backend
741        .dispatch(popcount_program, &popcount_inputs, config)
742        .map_err(|error| {
743            format!(
744                "telemetry bitset_popcount dispatch failed: {error}. Fix: verify backend supports the bitset::popcount op and input buffers are sized correctly."
745            )
746        })?;
747
748    // Extract per-word popcounts from the popcount output.
749    let popcount_result = crate::dispatch_decode::only_output(
750        &popcount_outputs,
751        "telemetry bitset_popcount",
752        "count_words",
753    )?;
754
755    // Build or reuse cached reduce program.
756    let reduce_program = match &scratch.telemetry_reduce_program {
757        Some((cached_count, _)) if *cached_count == words => None,
758        _ => Some(vyre_primitives::reduce::sum::reduce_sum(
759            "values", "out", words,
760        )),
761    };
762    if let Some(program) = reduce_program {
763        scratch.telemetry_reduce_program = Some((words, program));
764    }
765    let reduce_program = scratch
766        .telemetry_reduce_program
767        .as_ref()
768        .map(|(_, p)| p)
769        .ok_or_else(|| {
770            "telemetry reduce program missing after cache update. Fix: report this as an internal logic error."
771                .to_string()
772        })?;
773
774    // Zero reduce output staging.
775    crate::dispatch_decode::try_write_zero_bytes(
776        &mut scratch.telemetry_sum_bytes,
777        std::mem::size_of::<u32>(),
778        "telemetry reduce sum output zero staging",
779    )?;
780
781    let reduce_inputs: Vec<Vec<u8>> = vec![
782        popcount_result.to_vec(),
783        scratch.telemetry_sum_bytes[..std::mem::size_of::<u32>()].to_vec(),
784    ];
785    backend
786        .dispatch(reduce_program, &reduce_inputs, config)
787        .map_err(|error| {
788            format!(
789                "telemetry reduce_sum dispatch failed: {error}. Fix: verify backend supports the reduce::sum op and input buffers are sized correctly."
790            )
791        })
792}
793
794#[inline]
795fn domain_bits_to_usize(domain_bits: u64, telemetry: &mut FrontierDensityTelemetry) -> usize {
796    match usize::try_from(domain_bits) {
797        Ok(bits) => bits,
798        Err(_) => {
799            // Domain overflow is a malformed-input cold path.
800            domain_bits_overflow(telemetry)
801        }
802    }
803}
804
805#[cold]
806fn domain_bits_overflow(telemetry: &mut FrontierDensityTelemetry) -> usize {
807    telemetry.record_domain_overflow();
808    usize::MAX
809}
810
811#[inline]
812fn bounded_telemetry_add(left: u64, right: u64, telemetry: &mut FrontierDensityTelemetry) -> u64 {
813    match left.checked_add(right) {
814        Some(sum) => sum,
815        None => {
816            // Arithmetic overflow in telemetry counters is a cold saturation path.
817            telemetry_overflow(telemetry)
818        }
819    }
820}
821
822#[cold]
823fn telemetry_overflow(telemetry: &mut FrontierDensityTelemetry) -> u64 {
824    telemetry.record_arithmetic_overflow();
825    u64::MAX
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn frontier_telemetry_counts_available_words_instead_of_panicking() {
834        let mut telemetry = FrontierDensityTelemetry::begin(96);
835        assert_eq!(count_set_domain_bits(&[u32::MAX], 96, &mut telemetry), 32);
836        assert_eq!(
837            count_changed_domain_bits(&[u32::MAX], &[0], 96, &mut telemetry),
838            32
839        );
840        assert_eq!(telemetry.truncated_frontier_samples, 2);
841    }
842
843    #[test]
844    fn fixed_point_scratch_buffers_source_has_no_release_path_panic_telemetry_or_resize_growth() {
845        let source = include_str!("buffers.rs");
846        let production = source
847            .split("#[cfg(test)]\nmod tests")
848            .next()
849            .expect("fixed-point scratch buffer production source must precede tests");
850        assert!(
851            !production.contains(concat!("panic", "!("))
852                && !production.contains(".unwrap_or_else(")
853                && !production.contains(".resize(")
854                && !production.contains("eprintln!"),
855            "Fix: fixed-point scratch buffers must reserve/extend explicitly and frontier telemetry must not abort or write stderr on malformed decode lengths."
856        );
857        assert!(
858            production.contains("bounded_telemetry_add")
859                && production.contains("record_truncated_frontier_sample")
860                && production.contains("extend(std::iter::repeat_n(0, node_count))"),
861            "Fix: fixed-point scratch buffers must keep non-aborting structured telemetry and resize-free reverse CSR staging."
862        );
863    }
864
865    #[cfg(feature = "gpu-telemetry")]
866    #[test]
867    fn gpu_telemetry_matches_cpu_reference() {
868        let backend = vyre_driver_reference::CpuRefBackend;
869        let config = vyre::DispatchConfig::default();
870
871        for &bits in &[96u32, 1_000, 10_000] {
872            let words = ((bits + 31) / 32) as usize;
873            let data: Vec<u32> = (0..words).map(|i| i as u32).collect();
874            let next: Vec<u32> = (0..words).map(|i| !(i as u32)).collect();
875            let mut scratch = FixedPointScratch::with_capacities(words, 2, 2)
876                .expect("test scratch must allocate");
877
878            let cpu_set = count_set_domain_bits(
879                &data,
880                bits as usize,
881                &mut FrontierDensityTelemetry::default(),
882            );
883            let gpu_set = count_set_domain_bits_gpu(
884                &backend,
885                &config,
886                &data,
887                bits as usize,
888                &mut FrontierDensityTelemetry::default(),
889                &mut scratch,
890            )
891            .expect("gpu set bits must succeed");
892            assert_eq!(cpu_set, gpu_set, "count_set_domain_bits mismatch at {bits}");
893
894            let cpu_chg = count_changed_domain_bits(
895                &data,
896                &next,
897                bits as usize,
898                &mut FrontierDensityTelemetry::default(),
899            );
900            let gpu_chg = count_changed_domain_bits_gpu(
901                &backend,
902                &config,
903                &data,
904                &next,
905                bits as usize,
906                &mut FrontierDensityTelemetry::default(),
907                &mut scratch,
908            )
909            .expect("gpu changed bits must succeed");
910            assert_eq!(
911                cpu_chg, gpu_chg,
912                "count_changed_domain_bits mismatch at {bits}"
913            );
914        }
915    }
916
917    #[cfg(feature = "gpu-telemetry")]
918    #[test]
919    fn gpu_telemetry_fallback_for_small_frontiers() {
920        let backend = vyre_driver_reference::CpuRefBackend;
921        let config = vyre::DispatchConfig::default();
922        let data: Vec<u32> = vec![0b1010_1010; 31]; // 31 words = 992 bits (< 64 words threshold)
923        let next: Vec<u32> = vec![0b0101_0101; 31];
924        let mut scratch =
925            FixedPointScratch::with_capacities(31, 2, 2).expect("test scratch must allocate");
926
927        let result = count_set_domain_bits_gpu(
928            &backend,
929            &config,
930            &data,
931            992,
932            &mut FrontierDensityTelemetry::default(),
933            &mut scratch,
934        )
935        .expect("small frontier gpu telemetry must succeed");
936        assert_eq!(result, 4 * 31);
937
938        let result = count_changed_domain_bits_gpu(
939            &backend,
940            &config,
941            &data,
942            &next,
943            992,
944            &mut FrontierDensityTelemetry::default(),
945            &mut scratch,
946        )
947        .expect("small frontier gpu changed telemetry must succeed");
948        assert_eq!(result, 8 * 31);
949    }
950}