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}