Skip to main content

cudf/io/
mod.rs

1//! Low-level I/O entry points and format-specific modules.
2//!
3//! This module provides the table-oriented layer underlying high-level
4//! [`crate::DataFrame`] helpers such as `DataFrame::read_parquet` and
5//! `DataFrame::write_parquet`.
6//!
7//! # Module Shape
8//!
9//! - [`parquet`] exposes file-format readers and writers over low-level owned
10//!   [`Table`] values.
11//! - Host-copy Arrow `RecordBatch` interop lives under
12//!   [`crate::interop::arrow_host`], outside `cudf::io`, because it is an
13//!   external data interchange boundary. That module does not read or write
14//!   file formats.
15//!
16//! # Naming and Ownership
17//!
18//! Low-level file readers return [`TableReadOutput`], which pairs the
19//! [`Table`] with optional schema names, because `Table` itself is unnamed.
20//! When a format can report schema names, they are returned separately via
21//! `column_names` so callers can decide whether to rebuild a named
22//! [`crate::DataFrame`] or keep working at the low-level table boundary.
23//!
24//! Writers borrow owned [`Table`] inputs and do not rename or otherwise mutate
25//! the source table. Any output names supplied
26//! through write options only affect the serialized file format.
27//!
28//! # Runtime Placement
29//!
30//! Parquet option types may carry an explicit [`crate::Runtime`].
31//! For reads, that runtime determines the device, stream, and memory resource
32//! retained by the returned low-level table. For writes, it controls the
33//! device-pinned stream and memory resource used for the I/O operation itself
34//! while the input table remains a borrow.
35
36use std::{
37    error::Error as StdError,
38    ffi::{CStr, CString, c_char},
39    fs,
40    path::Path,
41    sync::Arc,
42};
43
44use crate::{
45    core::{
46        error::{self, Error, ErrorCode, FACT_COLUMN_INDEX, Result},
47        table::{Table, TableView},
48    },
49    runtime::{Runtime, execution_resolver::ResolvedExecution},
50};
51
52pub mod parquet;
53pub mod source;
54
55/// KvikIO compatibility mode, the `KVIKIO_COMPAT_MODE` setting.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum KvikioCompatMode {
58    /// cuFile where the file system and driver support it, POSIX otherwise.
59    Auto,
60    /// POSIX reads through a pinned host bounce buffer.
61    On,
62    /// cuFile only; a read fails where cuFile is unavailable.
63    Off,
64}
65
66impl KvikioCompatMode {
67    const fn as_ffi(self) -> i32 {
68        match self {
69            Self::Auto => cudf_sys::CUDF_IO_KVIKIO_COMPAT_MODE_AUTO as i32,
70            Self::On => cudf_sys::CUDF_IO_KVIKIO_COMPAT_MODE_ON as i32,
71            Self::Off => cudf_sys::CUDF_IO_KVIKIO_COMPAT_MODE_OFF as i32,
72        }
73    }
74
75    fn from_ffi(value: i32) -> Option<Self> {
76        match value {
77            value if value == cudf_sys::CUDF_IO_KVIKIO_COMPAT_MODE_AUTO as i32 => Some(Self::Auto),
78            value if value == cudf_sys::CUDF_IO_KVIKIO_COMPAT_MODE_ON as i32 => Some(Self::On),
79            value if value == cudf_sys::CUDF_IO_KVIKIO_COMPAT_MODE_OFF as i32 => Some(Self::Off),
80            _ => None,
81        }
82    }
83
84    /// KvikIO's lowercase spelling of the mode.
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::Auto => "auto",
88            Self::On => "on",
89            Self::Off => "off",
90        }
91    }
92}
93
94/// KvikIO's process-global settings: one thread pool and one task size shared
95/// by local compatibility-mode reads and remote range reads, across every
96/// device in the process.
97///
98/// Design: `website/docs/design/data-path.mdx#local-nvme-and-compatibility-mode`.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub struct KvikioProcessConfig {
101    /// `KVIKIO_COMPAT_MODE`.
102    pub compat_mode: KvikioCompatMode,
103    /// `KVIKIO_NTHREADS`: workers in the process-wide pool.
104    pub thread_count: u32,
105    /// `KVIKIO_TASK_SIZE`: bytes per pool task; one read splits into tasks of
106    /// at most this size.
107    pub task_size_bytes: u64,
108}
109
110impl KvikioProcessConfig {
111    /// Algeon's values for the fields the KvikIO environment leaves unset.
112    ///
113    /// 64 threads keep a cold local NVMe read near the device's throughput,
114    /// where libcudf's own four-thread setup reached about a quarter of it,
115    /// and match the concurrency remote range reads need. 4 MiB tasks are
116    /// KvikIO's default; a larger task splits one read into fewer parallel
117    /// pieces.
118    pub const DEFAULT: Self =
119        Self { compat_mode: KvikioCompatMode::On, thread_count: 64, task_size_bytes: 4 * 1024 * 1024 };
120
121    fn from_ffi(raw: &cudf_sys::cudf_io_kvikio_process_config_t) -> Option<Self> {
122        Some(Self {
123            compat_mode: KvikioCompatMode::from_ffi(raw.compat_mode)?,
124            thread_count: raw.thread_count,
125            task_size_bytes: raw.task_size_bytes,
126        })
127    }
128
129    const fn as_ffi(&self) -> cudf_sys::cudf_io_kvikio_process_config_t {
130        cudf_sys::cudf_io_kvikio_process_config_t {
131            compat_mode: self.compat_mode.as_ffi(),
132            thread_count: self.thread_count,
133            task_size_bytes: self.task_size_bytes,
134        }
135    }
136}
137
138const fn zeroed_kvikio_process_config() -> cudf_sys::cudf_io_kvikio_process_config_t {
139    cudf_sys::cudf_io_kvikio_process_config_t { compat_mode: 0, thread_count: 0, task_size_bytes: 0 }
140}
141
142/// Apply `defaults` to every KvikIO process setting the environment leaves
143/// unset, and return the effective configuration.
144///
145/// Call this once per process before any I/O worker starts. libcudf's own
146/// once-only KvikIO setup runs first, with `device_ordinal` current, so a
147/// later local file open cannot reset the pool; the ordinal must be a device
148/// the caller owns because that setup initializes the CUDA runtime on it. A
149/// field named by `KVIKIO_COMPAT_MODE`, `KVIKIO_NTHREADS` or
150/// `KVIKIO_TASK_SIZE` keeps the environment's value. Active I/O returns
151/// [`ErrorCode::IoBusy`] without changing anything, and a cuDF build without
152/// KvikIO support returns [`ErrorCode::UnsupportedOperation`]. Repeated calls
153/// are idempotent.
154pub fn configure_kvikio_process(device_ordinal: usize, defaults: &KvikioProcessConfig) -> Result<KvikioProcessConfig> {
155    const OPERATION: &str = "io::configure_kvikio_process";
156    let device_ordinal = i32::try_from(device_ordinal).map_err(|_| {
157        Error::new_with(ErrorCode::ForeignInvalidArgument, OPERATION, "device ordinal exceeds the CUDA device range")
158            .with_detail("device_ordinal", device_ordinal.to_string())
159    })?;
160    let raw_defaults = defaults.as_ffi();
161    let mut raw_effective = zeroed_kvikio_process_config();
162    let mut active_io = 0u64;
163    error::check_status_in_with_details(
164        // SAFETY: every pointer refers to a live, aligned stack value for the
165        // duration of the call.
166        unsafe {
167            cudf_sys::cudf_io_kvikio_configure_process(
168                device_ordinal,
169                &raw_defaults,
170                &mut raw_effective,
171                &mut active_io,
172            )
173        },
174        OPERATION,
175        [("resource", "kvikio_defaults".to_string())],
176    )?;
177    kvikio_configure_result(&raw_effective, active_io)
178}
179
180fn kvikio_configure_result(
181    raw_effective: &cudf_sys::cudf_io_kvikio_process_config_t,
182    active_io: u64,
183) -> Result<KvikioProcessConfig> {
184    const OPERATION: &str = "io::configure_kvikio_process";
185    if active_io != 0 {
186        return Err(kvikio_busy_error(
187            OPERATION,
188            "cannot configure KvikIO while I/O is active",
189            "kvikio_defaults",
190            active_io,
191            "stop and join all I/O workers before configuring KvikIO",
192        ));
193    }
194    KvikioProcessConfig::from_ffi(raw_effective).ok_or_else(|| {
195        Error::new_with(
196            ErrorCode::ForeignInvariantViolation,
197            OPERATION,
198            "KvikIO reported an unknown compatibility mode",
199        )
200        .with_detail("compat_mode", raw_effective.compat_mode.to_string())
201    })
202}
203
204/// Return KvikIO's effective process settings, or `None` for a cuDF build
205/// without KvikIO support.
206pub fn kvikio_process_config() -> Option<KvikioProcessConfig> {
207    let mut raw = zeroed_kvikio_process_config();
208    // SAFETY: the out-pointer refers to a live aligned stack value.
209    let status = unsafe { cudf_sys::cudf_io_kvikio_process_config(&mut raw) };
210    (status == cudf_sys::CUDF_SUCCESS).then(|| KvikioProcessConfig::from_ffi(&raw)).flatten()
211}
212
213fn kvikio_busy_error(
214    operation: &'static str,
215    message: &'static str,
216    resource: &'static str,
217    active_io: u64,
218    remedy: &'static str,
219) -> Error {
220    Error::new_with(ErrorCode::IoBusy, operation, message)
221        .with_detail("resource", resource)
222        .with_detail("reason", "active_io")
223        .with_detail("active_io", active_io.to_string())
224        .with_detail("remedy", remedy)
225}
226
227/// Drain KvikIO's retained host bounce-buffer pools.
228///
229/// This operation is idempotent. It must run after I/O workers stop and before
230/// CUDA teardown. Active I/O returns [`ErrorCode::IoBusy`] without clearing
231/// any buffer.
232pub fn drain_kvikio_bounce_buffer_pools() -> Result<u64> {
233    const OPERATION: &str = "io::drain_kvikio_bounce_buffer_pools";
234    let mut cleared_bytes = 0u64;
235    let mut active_io = 0u64;
236    error::check_status_in_with_details(
237        // SAFETY: both out-pointers refer to live aligned u64 stack values.
238        unsafe { cudf_sys::cudf_io_kvikio_drain_bounce_buffer_pools(&mut cleared_bytes, &mut active_io) },
239        OPERATION,
240        [("resource", "kvikio_bounce_buffer_pools".to_string())],
241    )?;
242    kvikio_drain_result(cleared_bytes, active_io)
243}
244
245fn kvikio_drain_result(cleared_bytes: u64, active_io: u64) -> Result<u64> {
246    if active_io == 0 {
247        return Ok(cleared_bytes);
248    }
249    Err(kvikio_busy_error(
250        "io::drain_kvikio_bounce_buffer_pools",
251        "cannot drain KvikIO bounce buffers while I/O is active",
252        "kvikio_bounce_buffer_pools",
253        active_io,
254        "stop and join all I/O workers before retrying bounce-buffer drain",
255    ))
256}
257
258/// Probe whether the cuFile driver can be opened by this process.
259///
260/// This is a deployment capability check for GPUDirect Storage. It only
261/// verifies that `libcufile` can be loaded and `cuFileDriverOpen()` succeeds;
262/// callers still need their own source-location eligibility checks before
263/// choosing a GDS read path.
264pub fn gds_driver_available() -> Result<()> {
265    const OPERATION: &str = "io::gds_driver_available";
266    error::check_status_in_with_details(
267        // SAFETY: The shim takes no borrowed pointers and performs a
268        // process-local cuFile driver open/close probe.
269        unsafe { cudf_sys::cudf_gds_driver_available() },
270        OPERATION,
271        [("resource", "cufile_driver".to_string())],
272    )
273}
274
275/// Shared output envelope for low-level table readers.
276pub struct TableReadOutput {
277    pub table: Table,
278    pub column_names: Option<Vec<String>>,
279}
280
281fn resolve_write_execution(
282    table: &TableView<'_>,
283    runtime: Option<&Runtime>,
284    operation: &'static str,
285) -> Result<ResolvedExecution> {
286    let write_domain = runtime
287        .map(|runtime| Arc::clone(runtime.execution_domain()))
288        .unwrap_or_else(|| Arc::clone(table.execution_domain()));
289    ResolvedExecution::with_output_domain(write_domain, [table.execution_domain()], operation)
290}
291
292/// Build a domestic IO error carrying the offending path and the underlying
293/// source error.
294///
295/// Local filesystem and parser IO failures are classified as
296/// `ErrorCode::IoFailure` regardless of whether the underlying `std::io`
297/// error kind was `NotFound` or another flavor. The caller performs local
298/// file I/O, so the failure surface is I/O-shaped, and the error kind is
299/// `DependencyFailure` even for a missing file. The exact underlying cause
300/// stays reachable through the standard source chain.
301fn map_path_io_error(
302    source: impl StdError + Send + Sync + 'static,
303    path: &Path,
304    operation: &'static str,
305    message: &'static str,
306) -> Error {
307    Error::new_with(ErrorCode::IoFailure, operation, message)
308        .with_detail("path", path.display().to_string())
309        .with_source(source)
310}
311
312fn validate_input_path(path: &Path, operation: &'static str) -> Result<()> {
313    if path.to_string_lossy().contains("://") {
314        return Ok(());
315    }
316
317    let metadata = fs::metadata(path)
318        .map_err(|source| map_path_io_error(source, path, operation, "failed to access input path"))?;
319
320    if metadata.is_file() {
321        return Ok(());
322    }
323
324    if metadata.is_dir() {
325        return Err(Error::new_with(ErrorCode::PathIsDirectory, operation, "input path is a directory")
326            .with_detail("path", path.display().to_string())
327            .with_detail("resource", "input_path")
328            .with_detail("reason", "path_is_directory")
329            .with_detail("remedy", "provide a regular file path instead of a directory"));
330    }
331
332    Err(Error::new_with(ErrorCode::IoFailure, operation, "input path is not a regular file")
333        .with_detail("path", path.display().to_string())
334        .with_detail("resource", "input_path")
335        .with_detail("reason", "path_not_regular_file")
336        .with_detail("remedy", "provide a regular file path"))
337}
338
339/// Convert a filesystem path to a `CString` for FFI, with operation context.
340fn path_to_cstring(path: &Path, operation: &'static str) -> Result<CString> {
341    let path_str = path.to_str().ok_or_else(|| {
342        // Local-IO precondition failure: the filesystem path cannot be
343        // presented to libcudf's C API. The boundary is this crate's local
344        // file I/O contract, so the failure is a domestic `IoFailure`.
345        Error::new_with(ErrorCode::IoFailure, operation, "path is not valid UTF-8")
346            .with_detail("resource", "path")
347            .with_detail("reason", "invalid_utf8")
348    })?;
349    CString::new(path_str).map_err(|_| {
350        error::interior_null_byte_error(operation, "path", "path contains interior null byte")
351            .with_detail("path", path.to_string_lossy().into_owned())
352    })
353}
354
355unsafe fn collect_column_names(
356    names_ptr: *mut *mut c_char,
357    num_names: i32,
358    operation: &'static str,
359) -> Result<Vec<String>> {
360    if num_names < 0 {
361        return Err(error::foreign_invariant_violation(
362            operation,
363            "ffi returned negative column name count",
364            "column_name_count",
365            "negative_name_count",
366            "ensure the foreign reader reports a non-negative column name count",
367        )
368        .with_detail("column_name_count", num_names.to_string()));
369    }
370
371    if num_names > 0 && names_ptr.is_null() {
372        return Err(error::foreign_invariant_violation(
373            operation,
374            "ffi returned null column name array with positive count",
375            "column_names",
376            "null_name_array",
377            "ensure the foreign reader returns a name array whenever column_name_count is positive",
378        )
379        .with_detail("column_name_count", num_names.to_string()));
380    }
381
382    let mut names = Vec::with_capacity(num_names as usize);
383    for i in 0..num_names {
384        // SAFETY: the caller guarantees `names_ptr` addresses at least
385        // `num_names` entries; the positive-count null case was rejected.
386        let name_ptr = unsafe { *names_ptr.add(i as usize) };
387        if name_ptr.is_null() {
388            return Err(error::foreign_invariant_violation(
389                operation,
390                "ffi returned null column name entry",
391                "column_name",
392                "null_name_entry",
393                "ensure the foreign reader returns one non-null name entry per reported column",
394            )
395            .with_detail("column_name_count", num_names.to_string())
396            .with_fact(FACT_COLUMN_INDEX, i as usize));
397        }
398
399        // SAFETY: the FFI contract requires each non-null entry to reference
400        // a NUL-terminated string for the duration of this conversion.
401        let cstr = unsafe { CStr::from_ptr(name_ptr) };
402        let name = cstr.to_str().map_err(|_| {
403            error::foreign_invariant_violation(
404                operation,
405                "column name from FFI is not valid UTF-8",
406                "column_name",
407                "invalid_utf8",
408                "ensure the foreign reader returns valid UTF-8 column names",
409            )
410            .with_detail("column_name_count", num_names.to_string())
411            .with_fact(FACT_COLUMN_INDEX, i as usize)
412        })?;
413        names.push(name.to_owned());
414    }
415
416    Ok(names)
417}
418
419#[cfg(test)]
420mod tests {
421    use std::{ffi::CString, fs, os::raw::c_char, ptr};
422
423    use crate::core::error::{ErrorCode, ErrorKind, ErrorStatus, FactKeyName, FactValue};
424
425    #[test]
426    fn kvikio_bounce_buffer_drain_busy_error_is_flat_and_retryable() {
427        let error = super::kvikio_drain_result(0, 2).unwrap_err();
428        assert_eq!(error.code(), ErrorCode::IoBusy);
429        assert_eq!(error.kind(), ErrorKind::ResourceExhausted);
430        assert_eq!(error.status(), ErrorStatus::Temporary);
431        assert_eq!(error.detail_value("active_io"), Some("2"));
432        assert_eq!(error.detail_value("reason"), Some("active_io"));
433    }
434
435    #[test]
436    fn kvikio_bounce_buffer_drain_idle_result_is_idempotent() {
437        assert_eq!(super::kvikio_drain_result(16 * 1024 * 1024, 0).unwrap(), 16 * 1024 * 1024);
438        assert_eq!(super::kvikio_drain_result(0, 0).unwrap(), 0);
439    }
440
441    #[test]
442    fn test_collect_column_names_rejects_negative_count() {
443        let err = unsafe { super::collect_column_names(ptr::null_mut(), -1, "test") }.unwrap_err();
444        assert_eq!(err.kind(), ErrorKind::InvariantViolation);
445        assert_eq!(err.code(), ErrorCode::ForeignInvariantViolation);
446        assert_eq!(err.operation(), "test");
447        assert!(err.details().iter().any(|(k, v)| *k == "column_name_count" && v == "-1"));
448        assert!(!err.details().iter().any(|(k, _)| *k == "num_names"));
449        assert!(err.details().iter().any(|(k, v)| *k == "resource" && v == "column_name_count"));
450        assert!(err.details().iter().any(|(k, v)| *k == "reason" && v == "negative_name_count"));
451        assert!(
452            err.details()
453                .iter()
454                .any(|(k, v)| *k == "remedy"
455                    && v == "ensure the foreign reader reports a non-negative column name count")
456        );
457    }
458
459    #[test]
460    fn test_collect_column_names_rejects_null_names_ptr_with_positive_count() {
461        let err = unsafe { super::collect_column_names(ptr::null_mut(), 1, "test") }.unwrap_err();
462        assert_eq!(err.kind(), ErrorKind::InvariantViolation);
463        assert_eq!(err.code(), ErrorCode::ForeignInvariantViolation);
464        assert_eq!(err.operation(), "test");
465        assert!(err.details().iter().any(|(k, v)| *k == "column_name_count" && v == "1"));
466        assert!(err.details().iter().any(|(k, v)| *k == "resource" && v == "column_names"));
467        assert!(err.details().iter().any(|(k, v)| *k == "reason" && v == "null_name_array"));
468        assert!(err.details().iter().any(|(k, v)| *k == "remedy"
469            && v == "ensure the foreign reader returns a name array whenever column_name_count is positive"));
470        assert!(!err.details().iter().any(|(k, _)| *k == "names_ptr_null"));
471    }
472
473    #[test]
474    fn test_collect_column_names_rejects_null_entry_pointer() {
475        let mut ptrs: Vec<*mut c_char> = vec![ptr::null_mut()];
476        let err = unsafe { super::collect_column_names(ptrs.as_mut_ptr(), 1, "test") }.unwrap_err();
477        assert_eq!(err.kind(), ErrorKind::InvariantViolation);
478        assert_eq!(err.code(), ErrorCode::ForeignInvariantViolation);
479        assert_eq!(err.operation(), "test");
480        assert_eq!(err.fact_value(FactKeyName::ColumnIndex), Some(&FactValue::Usize(0)));
481        assert!(!err.details().iter().any(|(k, _)| *k == "column_index"));
482        assert!(err.details().iter().any(|(k, v)| *k == "resource" && v == "column_name"));
483        assert!(err.details().iter().any(|(k, v)| *k == "reason" && v == "null_name_entry"));
484        assert!(err.details().iter().any(|(k, v)| *k == "remedy"
485            && v == "ensure the foreign reader returns one non-null name entry per reported column"));
486        assert!(!err.details().iter().any(|(k, _)| *k == "name_index"));
487    }
488
489    #[test]
490    fn test_collect_column_names_success() {
491        let mut raw = [CString::new("alpha").unwrap(), CString::new("beta").unwrap()];
492        let mut ptrs: Vec<*mut c_char> = raw.iter_mut().map(|s| s.as_ptr() as *mut c_char).collect();
493        let names = unsafe { super::collect_column_names(ptrs.as_mut_ptr(), ptrs.len() as i32, "test") }.unwrap();
494        assert_eq!(names, vec!["alpha", "beta"]);
495    }
496
497    #[test]
498    fn test_validate_input_path_reports_io_failure() {
499        let path = std::env::temp_dir().join("cudf_rs_missing_input_path_test_12345.csv");
500        let err = super::validate_input_path(&path, "test").unwrap_err();
501
502        // Local filesystem IO failures classify as the domestic `IoFailure`
503        // leaf. The `DependencyFailure` kind projection is the coarse
504        // downstream policy bucket that covers local IO, CUDA runtime, and
505        // fatal CUDA.
506        assert_eq!(err.code(), ErrorCode::IoFailure);
507        assert_eq!(err.kind(), ErrorKind::DependencyFailure);
508        assert_eq!(err.operation(), "test");
509        assert!(err.details().iter().any(|(k, v)| *k == "path" && v == &path.display().to_string()));
510    }
511
512    #[test]
513    fn test_validate_input_path_rejects_directory() {
514        let path = std::env::temp_dir().join(format!("cudf_rs_directory_input_path_test_{}", std::process::id()));
515        fs::create_dir_all(&path).unwrap();
516
517        let result = super::validate_input_path(&path, "test");
518        let _ = fs::remove_dir(&path);
519        let err = result.unwrap_err();
520        let path_display = path.display().to_string();
521
522        assert_eq!(err.code(), ErrorCode::PathIsDirectory);
523        assert_eq!(err.code().to_string(), "PathIsDirectory");
524        assert_eq!(err.kind(), ErrorKind::InvalidInput);
525        assert_eq!(err.status(), ErrorStatus::Permanent);
526        assert_eq!(err.operation(), "test");
527        assert_eq!(err.detail_value("path"), Some(path_display.as_str()));
528        assert_eq!(err.detail_value("resource"), Some("input_path"));
529        assert_eq!(err.detail_value("reason"), Some("path_is_directory"));
530        assert_eq!(err.detail_value("remedy"), Some("provide a regular file path instead of a directory"));
531    }
532}