Skip to main content

oxicuda_memory/compression/
compressed_buffer.rs

1//! Hardware-accelerated lossless memory compression bookkeeping.
2//!
3//! On Ampere (compute capability 8.0) and newer GPUs the virtual-memory
4//! management API (`cuMemCreate`) accepts a
5//! `CU_MEM_ALLOCATION_COMPRESSION_TYPE_GENERIC` allocation flag that enables
6//! the memory controller's lossless compression engine.  Compressible
7//! allocations transparently reduce the number of bytes that travel across the
8//! L2 ↔ DRAM interface, raising effective bandwidth for compressible data
9//! (e.g. activation tensors with large runs of zeros).
10//!
11//! This module models the *host-side bookkeeping* required to drive that
12//! feature: capability gating, compression-granularity alignment, an
13//! effective-bandwidth model, and a [`CompressedDeviceBuffer`] descriptor that
14//! tracks the logical / physical footprint of a compressed allocation.  The
15//! actual `cuMemCreate` call requires a GPU and lives behind the device-gated
16//! path; everything here is deterministic and unit-testable on the host.
17//!
18//! # Example
19//!
20//! ```rust
21//! # use oxicuda_memory::compression::compressed_buffer::*;
22//! // Ampere supports generic compression; the granularity is 2 MiB.
23//! let support = CompressionSupport::for_compute_capability(8, 0);
24//! assert!(support.is_supported());
25//!
26//! // A 5 MiB request is rounded up to a multiple of the granularity.
27//! let plan = CompressionPlan::new(5 * 1024 * 1024, support)?;
28//! assert_eq!(plan.physical_bytes() % support.granularity(), 0);
29//! # Ok::<(), oxicuda_driver::error::CudaError>(())
30//! ```
31
32use oxicuda_driver::error::{CudaError, CudaResult};
33
34// ---------------------------------------------------------------------------
35// CompressionType
36// ---------------------------------------------------------------------------
37
38/// The kind of memory compression requested for an allocation.
39///
40/// Mirrors the `CUmemAllocationCompType` enumeration from the CUDA virtual
41/// memory management API.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[repr(u8)]
44pub enum CompressionType {
45    /// No compression.  Equivalent to `CU_MEM_ALLOCATION_COMP_NONE`.
46    None = 0,
47    /// Generic hardware lossless compression.  Equivalent to
48    /// `CU_MEM_ALLOCATION_COMP_GENERIC`.
49    Generic = 1,
50}
51
52impl CompressionType {
53    /// Returns `true` if this requests an actual compression engine.
54    #[inline]
55    #[must_use]
56    pub fn is_compressed(self) -> bool {
57        matches!(self, Self::Generic)
58    }
59}
60
61impl std::fmt::Display for CompressionType {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::None => write!(f, "none"),
65            Self::Generic => write!(f, "generic"),
66        }
67    }
68}
69
70// ---------------------------------------------------------------------------
71// CompressionSupport
72// ---------------------------------------------------------------------------
73
74/// The default compression page granularity on supported hardware (2 MiB).
75///
76/// `cuMemCreate` allocations must be a multiple of the granularity returned by
77/// `cuMemGetAllocationGranularity`.  On all currently shipping Ampere/Hopper
78/// parts this is the large-page size of 2 MiB.
79pub const DEFAULT_COMPRESSION_GRANULARITY: usize = 2 * 1024 * 1024;
80
81/// Describes whether and how a given device supports generic compression.
82///
83/// Construct via [`CompressionSupport::for_compute_capability`] to apply the
84/// capability gate (compression requires compute capability ≥ 8.0).
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct CompressionSupport {
87    /// Whether generic compression is available at all.
88    supported: bool,
89    /// The allocation granularity in bytes.  Always non-zero.
90    granularity: usize,
91}
92
93impl CompressionSupport {
94    /// Returns the compression support for a device with the given compute
95    /// capability `(major, minor)`.
96    ///
97    /// Generic memory compression is an Ampere+ feature, so any capability
98    /// with `major >= 8` is considered supported.  Older devices report
99    /// unsupported with the granularity still set to the default (so that
100    /// alignment math remains well-defined).
101    #[must_use]
102    pub fn for_compute_capability(major: u32, minor: u32) -> Self {
103        let _ = minor;
104        Self {
105            supported: major >= 8,
106            granularity: DEFAULT_COMPRESSION_GRANULARITY,
107        }
108    }
109
110    /// Builds an explicit support descriptor.
111    ///
112    /// `granularity` is clamped to a minimum of 1 so that alignment never
113    /// divides by zero.
114    #[must_use]
115    pub fn new(supported: bool, granularity: usize) -> Self {
116        Self {
117            supported,
118            granularity: granularity.max(1),
119        }
120    }
121
122    /// Returns `true` if generic compression is available.
123    #[inline]
124    #[must_use]
125    pub fn is_supported(&self) -> bool {
126        self.supported
127    }
128
129    /// Returns the allocation granularity in bytes.
130    #[inline]
131    #[must_use]
132    pub fn granularity(&self) -> usize {
133        self.granularity
134    }
135}
136
137// ---------------------------------------------------------------------------
138// CompressionPlan
139// ---------------------------------------------------------------------------
140
141/// Rounds `value` up to the next multiple of `granularity`.
142///
143/// `granularity` is assumed non-zero (guaranteed by [`CompressionSupport`]).
144/// Returns `None` on overflow.
145#[inline]
146fn round_up(value: usize, granularity: usize) -> Option<usize> {
147    if value == 0 {
148        return Some(0);
149    }
150    let blocks = value.checked_add(granularity - 1)? / granularity;
151    blocks.checked_mul(granularity)
152}
153
154/// A validated, granularity-aligned plan for a compressed allocation.
155///
156/// Records the logically requested size and the physically reserved size
157/// (rounded up to the compression granularity).  The physical footprint is the
158/// number of bytes that `cuMemCreate` would reserve; the *effective* footprint
159/// after compression depends on the data and is modelled separately by
160/// [`CompressedDeviceBuffer`].
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct CompressionPlan {
163    requested_bytes: usize,
164    physical_bytes: usize,
165    granularity: usize,
166}
167
168impl CompressionPlan {
169    /// Builds a compression plan for `requested_bytes` against `support`.
170    ///
171    /// # Errors
172    ///
173    /// * [`CudaError::InvalidValue`] if `requested_bytes` is zero or rounding
174    ///   to the granularity overflows.
175    /// * [`CudaError::NotSupported`] if `support` reports that the device does
176    ///   not support generic compression.
177    pub fn new(requested_bytes: usize, support: CompressionSupport) -> CudaResult<Self> {
178        if requested_bytes == 0 {
179            return Err(CudaError::InvalidValue);
180        }
181        if !support.is_supported() {
182            return Err(CudaError::NotSupported);
183        }
184        let physical_bytes =
185            round_up(requested_bytes, support.granularity()).ok_or(CudaError::InvalidValue)?;
186        Ok(Self {
187            requested_bytes,
188            physical_bytes,
189            granularity: support.granularity(),
190        })
191    }
192
193    /// The number of bytes the caller asked for.
194    #[inline]
195    #[must_use]
196    pub fn requested_bytes(&self) -> usize {
197        self.requested_bytes
198    }
199
200    /// The number of physical bytes reserved (granularity-aligned).
201    #[inline]
202    #[must_use]
203    pub fn physical_bytes(&self) -> usize {
204        self.physical_bytes
205    }
206
207    /// The granularity this plan was aligned to.
208    #[inline]
209    #[must_use]
210    pub fn granularity(&self) -> usize {
211        self.granularity
212    }
213
214    /// The padding bytes added by granularity rounding (`physical - requested`).
215    #[inline]
216    #[must_use]
217    pub fn padding_bytes(&self) -> usize {
218        self.physical_bytes.saturating_sub(self.requested_bytes)
219    }
220}
221
222// ---------------------------------------------------------------------------
223// CompressedDeviceBuffer
224// ---------------------------------------------------------------------------
225
226/// Host-side descriptor for a compressed device allocation.
227///
228/// Tracks the logical size, the physically reserved (granularity-aligned)
229/// size, and an *observed* compression ratio that the application can update
230/// as it learns how compressible its data is.  The descriptor never owns a GPU
231/// pointer here — that is filled in by the device-gated path — but it provides
232/// the bandwidth and footprint accounting that callers use to decide whether
233/// compression is worthwhile.
234///
235/// The compression ratio is defined as `physical / effective`: a ratio of
236/// `2.0` means the data occupies half its physical footprint over the memory
237/// bus, doubling effective bandwidth for that region.
238#[derive(Debug, Clone, Copy, PartialEq)]
239pub struct CompressedDeviceBuffer {
240    plan: CompressionPlan,
241    comp_type: CompressionType,
242    /// Observed compression ratio (`physical / effective`).  `1.0` means data
243    /// did not compress at all.  Always `>= 1.0`.
244    ratio: f64,
245}
246
247impl CompressedDeviceBuffer {
248    /// Creates a descriptor for a freshly planned compressed buffer.
249    ///
250    /// The compression ratio starts at `1.0` (no measured compression yet).
251    #[must_use]
252    pub fn new(plan: CompressionPlan) -> Self {
253        Self {
254            plan,
255            comp_type: CompressionType::Generic,
256            ratio: 1.0,
257        }
258    }
259
260    /// Builds a descriptor directly from a request, applying capability gating
261    /// and granularity alignment in one step.
262    ///
263    /// # Errors
264    ///
265    /// Forwards errors from [`CompressionPlan::new`].
266    pub fn alloc(requested_bytes: usize, support: CompressionSupport) -> CudaResult<Self> {
267        Ok(Self::new(CompressionPlan::new(requested_bytes, support)?))
268    }
269
270    /// Returns the underlying allocation plan.
271    #[inline]
272    #[must_use]
273    pub fn plan(&self) -> CompressionPlan {
274        self.plan
275    }
276
277    /// Returns the compression type recorded for this buffer.
278    #[inline]
279    #[must_use]
280    pub fn compression_type(&self) -> CompressionType {
281        self.comp_type
282    }
283
284    /// The logical (requested) size in bytes.
285    #[inline]
286    #[must_use]
287    pub fn logical_bytes(&self) -> usize {
288        self.plan.requested_bytes()
289    }
290
291    /// The physical (granularity-aligned) footprint in bytes.
292    #[inline]
293    #[must_use]
294    pub fn physical_bytes(&self) -> usize {
295        self.plan.physical_bytes()
296    }
297
298    /// Records an observed compression ratio (`physical / effective`).
299    ///
300    /// Values below `1.0` are clamped to `1.0` (data cannot expand under
301    /// lossless compression as far as bus traffic is concerned).  Non-finite
302    /// inputs are ignored.
303    pub fn set_ratio(&mut self, ratio: f64) {
304        if ratio.is_finite() {
305            self.ratio = ratio.max(1.0);
306        }
307    }
308
309    /// Returns the recorded compression ratio (`>= 1.0`).
310    #[inline]
311    #[must_use]
312    pub fn ratio(&self) -> f64 {
313        self.ratio
314    }
315
316    /// The effective number of bytes that traverse the memory bus given the
317    /// recorded compression ratio.
318    ///
319    /// Equal to `physical_bytes / ratio`, rounded down.  With a ratio of `1.0`
320    /// this equals the physical footprint.
321    #[must_use]
322    pub fn effective_bus_bytes(&self) -> usize {
323        if self.ratio <= 1.0 {
324            return self.physical_bytes();
325        }
326        (self.physical_bytes() as f64 / self.ratio) as usize
327    }
328
329    /// Estimates the effective bandwidth seen by a transfer of this buffer's
330    /// physical footprint, given a raw `dram_gbps` DRAM bandwidth.
331    ///
332    /// Because compression reduces bus traffic by `ratio`, the *effective*
333    /// bandwidth observed for compressible data is `dram_gbps * ratio`.
334    /// Non-positive bandwidth returns `0.0`.
335    #[must_use]
336    pub fn effective_bandwidth_gbps(&self, dram_gbps: f64) -> f64 {
337        if dram_gbps <= 0.0 {
338            return 0.0;
339        }
340        dram_gbps * self.ratio
341    }
342
343    /// The number of physical bytes saved on the bus relative to an
344    /// uncompressed transfer (`physical - effective`).
345    #[inline]
346    #[must_use]
347    pub fn bytes_saved(&self) -> usize {
348        self.physical_bytes()
349            .saturating_sub(self.effective_bus_bytes())
350    }
351}
352
353impl std::fmt::Display for CompressedDeviceBuffer {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        write!(
356            f,
357            "CompressedDeviceBuffer(type={}, logical={} B, physical={} B, ratio={:.2})",
358            self.comp_type,
359            self.logical_bytes(),
360            self.physical_bytes(),
361            self.ratio,
362        )
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Tests
368// ---------------------------------------------------------------------------
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn compression_type_is_compressed() {
376        assert!(!CompressionType::None.is_compressed());
377        assert!(CompressionType::Generic.is_compressed());
378    }
379
380    #[test]
381    fn support_gated_on_compute_capability() {
382        // Ampere (8.0), Hopper (9.0) -> supported.
383        assert!(CompressionSupport::for_compute_capability(8, 0).is_supported());
384        assert!(CompressionSupport::for_compute_capability(9, 0).is_supported());
385        // Turing (7.5), Volta (7.0), Pascal (6.1) -> unsupported.
386        assert!(!CompressionSupport::for_compute_capability(7, 5).is_supported());
387        assert!(!CompressionSupport::for_compute_capability(7, 0).is_supported());
388        assert!(!CompressionSupport::for_compute_capability(6, 1).is_supported());
389    }
390
391    #[test]
392    fn support_granularity_default() {
393        let s = CompressionSupport::for_compute_capability(8, 6);
394        assert_eq!(s.granularity(), 2 * 1024 * 1024);
395    }
396
397    #[test]
398    fn support_new_clamps_zero_granularity() {
399        let s = CompressionSupport::new(true, 0);
400        assert_eq!(s.granularity(), 1);
401    }
402
403    #[test]
404    fn round_up_exact_multiple_unchanged() {
405        let g = 2 * 1024 * 1024;
406        assert_eq!(round_up(g, g), Some(g));
407        assert_eq!(round_up(2 * g, g), Some(2 * g));
408    }
409
410    #[test]
411    fn round_up_partial_block_rounds_up() {
412        let g = 2 * 1024 * 1024;
413        // 1 byte -> one full granularity block.
414        assert_eq!(round_up(1, g), Some(g));
415        // g + 1 -> two blocks.
416        assert_eq!(round_up(g + 1, g), Some(2 * g));
417    }
418
419    #[test]
420    fn round_up_zero_is_zero() {
421        assert_eq!(round_up(0, 4096), Some(0));
422    }
423
424    #[test]
425    fn round_up_overflow_returns_none() {
426        assert_eq!(round_up(usize::MAX, 2 * 1024 * 1024), None);
427    }
428
429    #[test]
430    fn plan_rejects_zero() {
431        let s = CompressionSupport::for_compute_capability(8, 0);
432        assert_eq!(CompressionPlan::new(0, s), Err(CudaError::InvalidValue));
433    }
434
435    #[test]
436    fn plan_rejects_unsupported_device() {
437        let s = CompressionSupport::for_compute_capability(7, 5);
438        assert_eq!(CompressionPlan::new(4096, s), Err(CudaError::NotSupported));
439    }
440
441    #[test]
442    fn plan_aligns_to_granularity() {
443        let s = CompressionSupport::for_compute_capability(8, 0);
444        let g = s.granularity();
445        // 5 MiB request with 2 MiB granularity -> 6 MiB physical (3 blocks).
446        let plan = CompressionPlan::new(5 * 1024 * 1024, s).expect("plan");
447        assert_eq!(plan.requested_bytes(), 5 * 1024 * 1024);
448        assert_eq!(plan.physical_bytes(), 3 * g);
449        assert_eq!(plan.physical_bytes() % g, 0);
450        // padding = 6 MiB - 5 MiB = 1 MiB.
451        assert_eq!(plan.padding_bytes(), 1024 * 1024);
452    }
453
454    #[test]
455    fn plan_exact_multiple_has_no_padding() {
456        let s = CompressionSupport::for_compute_capability(8, 0);
457        let plan = CompressionPlan::new(s.granularity(), s).expect("plan");
458        assert_eq!(plan.padding_bytes(), 0);
459        assert_eq!(plan.physical_bytes(), s.granularity());
460    }
461
462    #[test]
463    fn buffer_alloc_defaults_to_generic_ratio_one() {
464        let s = CompressionSupport::for_compute_capability(8, 0);
465        let buf = CompressedDeviceBuffer::alloc(1024 * 1024, s).expect("alloc");
466        assert_eq!(buf.compression_type(), CompressionType::Generic);
467        assert!((buf.ratio() - 1.0).abs() < 1e-12);
468        // With ratio 1.0, effective == physical and nothing is saved.
469        assert_eq!(buf.effective_bus_bytes(), buf.physical_bytes());
470        assert_eq!(buf.bytes_saved(), 0);
471    }
472
473    #[test]
474    fn buffer_ratio_clamped_and_effective_bytes() {
475        let s = CompressionSupport::for_compute_capability(8, 0);
476        let mut buf = CompressedDeviceBuffer::alloc(2 * 1024 * 1024, s).expect("alloc");
477        // physical is exactly 2 MiB.
478        assert_eq!(buf.physical_bytes(), 2 * 1024 * 1024);
479        buf.set_ratio(2.0);
480        assert!((buf.ratio() - 2.0).abs() < 1e-12);
481        // 2 MiB / 2.0 = 1 MiB effective on the bus.
482        assert_eq!(buf.effective_bus_bytes(), 1024 * 1024);
483        assert_eq!(buf.bytes_saved(), 1024 * 1024);
484    }
485
486    #[test]
487    fn buffer_ratio_below_one_clamped() {
488        let s = CompressionSupport::for_compute_capability(8, 0);
489        let mut buf = CompressedDeviceBuffer::alloc(2 * 1024 * 1024, s).expect("alloc");
490        buf.set_ratio(0.5);
491        assert!((buf.ratio() - 1.0).abs() < 1e-12);
492    }
493
494    #[test]
495    fn buffer_ignores_non_finite_ratio() {
496        let s = CompressionSupport::for_compute_capability(8, 0);
497        let mut buf = CompressedDeviceBuffer::alloc(2 * 1024 * 1024, s).expect("alloc");
498        buf.set_ratio(3.0);
499        buf.set_ratio(f64::NAN);
500        buf.set_ratio(f64::INFINITY);
501        // Unchanged from the last finite value.
502        assert!((buf.ratio() - 3.0).abs() < 1e-12);
503    }
504
505    #[test]
506    fn buffer_effective_bandwidth_scales_with_ratio() {
507        let s = CompressionSupport::for_compute_capability(8, 0);
508        let mut buf = CompressedDeviceBuffer::alloc(2 * 1024 * 1024, s).expect("alloc");
509        buf.set_ratio(2.5);
510        // 1000 GB/s DRAM with 2.5x compression -> 2500 GB/s effective.
511        assert!((buf.effective_bandwidth_gbps(1000.0) - 2500.0).abs() < 1e-9);
512        assert_eq!(buf.effective_bandwidth_gbps(0.0), 0.0);
513    }
514
515    #[test]
516    fn buffer_display_contains_fields() {
517        let s = CompressionSupport::for_compute_capability(8, 0);
518        let buf = CompressedDeviceBuffer::alloc(1024 * 1024, s).expect("alloc");
519        let text = format!("{buf}");
520        assert!(text.contains("generic"));
521        assert!(text.contains("physical="));
522    }
523}