Skip to content
npcooleyPublic

About

Infrastructure for interacting with GPUs in R

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ardea 0.1.1

Nicholas Cooley 2026-10-09

R-CMD-check CRAN status Lifecycle: stable

Introduction

This package has been accepted to CRAN, and is relatively stable. It has only been thoroughly tested on a Mac M4, and a linux server running an NVIDIA A30.

Installation

The canonical form of ardea can be installed from CRAN via:

install.packages("ardea")

The version of ardea present in this repo can be installed from via pak:

if (requireNamespace("pak", quietly = TRUE)) {
  pak::pkg_install("npcooley/ardea")
}

Or by cloning the repo and installing directly:

# requires git
git clone https://github.com/npcooley/ardea.git
# requires a working R installation
R CMD install --no-build-vignettes ardea

This package is an attempt at building out infrastructure for users to dispatch code directly to GPU devices in R. It is currently designed to work wherever R works. During installation, it probes the system for the ability to successfully compile and run Hello World style device probes for target frameworks and their devices. Capabilities for specific frameworks are only compiled if the tool chain for those frameworks is successfully detected.

Because I do not have a Windows development environment, Windows is not currently supported.

Installation Details

ardea goes through a fair amount of gymnastics to detect capabilities and manage its own installation. A series of ARDEA_<FRAMEWORK>_<KEYWORD> environment variables will be ingested by the configure script if present and are used during build, so users can hypothetically correctly specify unique or unusual installation setups.

OpenCL

OpenCL is an open source standard for interacting with GPUs and other alternative compute devices, regardless of vendor. On Macs, this framework is not current, and does not appear to be updateable but does work still. On Linux systems, default OpenCL installations should be detected automatically over the course of package installation. If they are not, or users have a non-standard installation, ardea checks for user environment variables:

  • ARDEA_OPENCL_LIBS
  • ARDEA_OPENCL_INCLUDE

to specify library and linker locations.

Metal

Metal is Apple’s framework for interacting with GPUs and builtin devices within their ecosystem. If the package detects Darwin as the OS, Metal detection is triggered. Metal has gone through a few changes as Apple has used a few different device vendors over the years and though its most recent iteration appears focused on Silicon series chips.

Metal capabilities in this package were originally built out in the ACFmetal github only R package.

CUDA

CUDA is NVIDIA’s framework for interacting with NVIDIA devices. Because CUDA installations can have many different flavors and many of their own peculiarities, CUDA support requires user environment variables:

  • ARDEA_CUDA_LIBS
  • ARDEA_CUDA_STUBS
  • ARDEA_CUDA_INCLUDE

These must point to the library, linker, and stubs directories of a functioning CUDA installation, and CUDA’s NVCC compiler must be present in R’s PATH. Package installation will check for successful compilation, and CUDA capabilities will be enabled if compilation for the probe programs is successful, an existing device is not required.

CUDA capabilities in this package were originally built out in the ACFcuda github only R package.

Other

There are at least two other major frameworks (for AMD and Intel devices) that may be added in the future, should folks find this package to be useful. Right now the focus for this package is on hardening the codebase to avoid build errors and bugs, and ensuring that functionality is clearly described and accessible to typical R users.

Example Code

ardea provides access to GPU devices and frameworks natively in R. It currently provides rudimentary access to OpenCL, CUDA, and Metal; letting users compile, test, and implement their own device specific code. ardea currently only requires existing OpenCL to install appropriately, but will selectively build out access to frameworks and devices that it can detect during package installation.

This package currently does not provide an interface for vendor supplied libraries. Functionality is currently focused on ingesting a user supplied framework specific function, compiling that function and preparing it for dispatch, executing function dispatch and buffer management, and returning that function’s result to R.

library(ardea)

opencl_is_available()
cuda_is_available()
metal_is_available()
## [1] TRUE
## [1] FALSE
## [1] TRUE

Using matrix multiplication as an example, a user can bring a naive mm kernel function conforming to the a given framework’s rules and requirements. In this case we can just write out our functions to temp files, and walk them forward through the frameworks and devices available on the current system. One of ardea’s current quirks is that it expects the first argument of a function to be the output. There is no keyword or tooling to enforce this, but the underlying buffer management currently only returns that first input from the device to R.

# write out a character vector to a tempfile for opencl, metal, and cuda
opencl_mm_naive <- '
__kernel void opencl_mm_naive(__global float* output,
                              const long M,
                              const long K,
                              const long N,
                              __global const float* A,
                              __global const float* B)
{
    long row = get_global_id(0);
    long col = get_global_id(1);

    if (row < M && col < N) {
        float sum = 0.0f;
        for (long k = 0; k < K; k++) {
            sum += A[row + k * M] * B[k + col * K];
        }
        output[row + col * M] = sum;
    }
}
'
tmp_cl <- tempfile(fileext = ".cl")
writeLines(text = opencl_mm_naive,
           con = tmp_cl)

cuda_mm_naive <- '
extern "C" __global__ void cuda_mm_naive(float* output,
                                         const long long M,
                                         const long long K,
                                         const long long N,
                                         const float* A,
                                         const float* B)
{
    long long row = blockIdx.x * blockDim.x + threadIdx.x;
    long long col = blockIdx.y * blockDim.y + threadIdx.y;

    if (row < M && col < N) {
        float sum = 0.0f;
        for (long long k = 0; k < K; k++) {
            sum += A[row + k * M] * B[k + col * K];
        }
        output[row + col * M] = sum;
    }
}
'
tmp_cu <- tempfile(fileext = ".cu")
tmp_ptx <- tempfile(fileext = ".ptx")
writeLines(text = cuda_mm_naive,
           con = tmp_cu)

metal_mm_naive <- '
kernel void metal_mm_naive(device float* output [[buffer(0)]],
                           constant uint& M [[buffer(1)]],
                           constant uint& K [[buffer(2)]],
                           constant uint& N [[buffer(3)]],
                           device const float* A [[buffer(4)]],
                           device const float* B [[buffer(5)]],
                           uint2 id [[thread_position_in_grid]])
{
    uint row = id.x;
    uint col = id.y;

    if (row < M && col < N) {
        float sum = 0.0f;
        for (uint k = 0; k < K; k++) {
            sum += A[row + k * M] * B[k + col * K];
        }
        output[row + col * M] = sum;
    }
}
'
tmp_metal <- tempfile(fileext = ".metal")
tmp_metallib <- tempfile(fileext = ".metallib")
writeLines(text = metal_mm_naive,
           con = tmp_metal)

Matrix multiplication doesn’t need a lot of setup when using the builtin %*% function, which is calling on your R installation’s BLAS library and is highly optimized. However, because we’re reaching out to a device almost entirely ab initio with no helpers, we need to perform the work that those helpers would perform.

# generic inputs, regardless of framework
var00 <- vector(mode = "numeric",
                length = 250000)
var01 <- matrix(rnorm(250000),
                nrow = 500,
                ncol = 500)
var02 <- matrix(rnorm(250000),
                nrow = 500,
                ncol = 500)
dim01 <- nrow(var01)
dim02 <- ncol(var01)
# dim03 <- nrow(var02)
dim04 <- ncol(var02)

arg_list <- list(var00, # our expected output vector, we've just filled it with zeros
                 dim01,
                 dim02,
                 dim04,
                 as.vector(var01),
                 as.vector(var02))

Every framework has its own unique verbiage and intricacies, though they’re all kind of doing similar things. OpenCL is designed to be be deployable across a variety of device vendors and device types, not just GPUs. Metal is Apple’s proprietary GPU / accelerator framework, though it shares some conceptual choices with OpenCL as until recently Apple’s hardware stack was somewhat diverse. CUDA is NVIDIA’s well known framework for accessing their devices, and includes an intimidating array of libraries and functionalities.

This means that frameworks share concepts, but not necessarily the same keywords, type names, specific paths, or user interactions.

opencl_arg_types <- c("float",
                      "long",
                      "long",
                      "long",
                      "float",
                      "float")
metal_arg_types <- c("float",
                     "uint",
                     "uint",
                     "uint",
                     "float",
                     "float")
cuda_arg_types <- c("float",
                    "long",
                    "long",
                    "long",
                    "float",
                    "float")

Though each framework handles program / module / library preparation somewhat uniquely, an effort has been made to unify how users interact with these steps. In this package OpenCL supports API-side compilation and program generation, while Metal supports both API-side, and system-side compilation and program management, and CUDA only supports system-side compilation. Meaning that under the hood Metal and CUDA are calling on system/vendor-specific compilers to build their libraries/modules (i.e. clang and nvcc respectively).

if (opencl_is_available() &
    opencl_devices_exist()) {
  print("OpenCL is available on this system!")
  cl_dvcs <- opencl_device_information()
  cl_ctx <- opencl_make_context(device = cl_dvcs[[1]])
  cl_program <- opencl_make_program(cl_file = tmp_cl,
                                    context = cl_ctx)
  cl_knl <- opencl_make_kernelptr(program = cl_program,
                                  kernel_names = "opencl_mm_naive")
}
if (cuda_is_available() &
    cuda_devices_exist()) {
  print("CUDA is available on this system!")
  cu_dvcs <- cuda_device_information()
  cu_ctx <- cuda_make_context(device = cu_dvcs[[1]])
  cu_program <- cuda_make_program(cuda_file = tmp_cu,
                                  context = cu_ctx,
                                  ptx_file = tmp_ptx)
  cu_knl <- cuda_make_kernelptr(program = cu_program,
                                context = cu_ctx,
                                kernel_names = "cuda_mm_naive")
}
if (metal_is_available() &
    metal_devices_exist()) {
  print("Metal is available on this system!")
  mtl_dvcs <- metal_device_information()
  mtl_ctx <- metal_make_context(device = mtl_dvcs[[1]])
  if (metal_compiler_is_available()) {
    mtl_program <- metal_make_program(metal_file = tmp_metal,
                                      metallib_file = tmp_metallib,
                                      context = mtl_ctx)
  } else {
    mtl_program <- metal_make_program(metal_file = tmp_metal,
                                      context = mtl_ctx)
  }
  mtl_knl <- metal_make_kernelptr(program = mtl_program,
                                  context = mtl_ctx,
                                  kernel_names = "metal_mm_naive")
}
## [1] "OpenCL is available on this system!"
## [1] "Metal is available on this system!"

A simple wrapper function is supplied with ardea, though it is mostly a thin wrapper for .Call() runner functions. It does perform some error checking, but for the most part users are being trusted with their judgements.

# our builtin optimized CPU implementation
print("Builtin implementation:")
system.time(res01 <- var01 %*% var02)
if (opencl_is_available() &
    opencl_devices_exist()) {
  print("OpenCL implementation:")
  print(system.time(res02 <- simple_wrapper(framework = "opencl",
                                            context_ptr = cl_ctx,
                                            kernel_ptr = cl_knl$opencl_mm_naive,
                                            arg_types = opencl_arg_types,
                                            arg_list = arg_list,
                                            problem_dims = as.integer(c(dim01,
                                                                        dim04,
                                                                        1L)),
                                            group_dims = NULL,
                                            workers_per = NULL)))
  plot(as.vector(res01),
       res02,
       pch = 46,
       xlab = "builtin",
       ylab = "OpenCL",
       main = "deviation")
}

if (cuda_is_available() &
    cuda_devices_exist()) {
  print("CUDA implementation:")
  print(system.time(res03 <- simple_wrapper(framework = "cuda",
                                            context_ptr = cu_ctx,
                                            kernel_ptr = cu_knl$cuda_mm_naive,
                                            arg_types = cuda_arg_types,
                                            arg_list = arg_list,
                                            problem_dims = as.integer(c(dim01,
                                                                        dim04,
                                                                        1L)),
                                            group_dims = NULL,
                                            workers_per = NULL)))
  plot(as.vector(res01),
       res03,
       pch = 46,
       xlab = "builtin",
       ylab = "CUDA",
       main = "deviation")
}
if (metal_is_available() &
    metal_devices_exist()) {
  print("Metal implementation:")
  print(system.time(res03 <- simple_wrapper(framework = "metal",
                                            context_ptr = mtl_ctx,
                                            kernel_ptr = mtl_knl$metal_mm_naive,
                                            arg_types = metal_arg_types,
                                            arg_list = arg_list,
                                            problem_dims = as.integer(c(dim01,
                                                                        dim04,
                                                                        1L)),
                                            group_dims = NULL,
                                            workers_per = NULL)))
  plot(as.vector(res01),
       res03,
       pch = 46,
       xlab = "builtin",
       ylab = "metal")
}

## [1] "Builtin implementation:"
##    user  system elapsed 
##   0.035   0.001   0.036 
## [1] "OpenCL implementation:"
##    user  system elapsed 
##   0.002   0.004   0.013 
## [1] "Metal implementation:"
##    user  system elapsed 
##   0.002   0.001   0.004

This is currently the limit of ardea’s functionality. This package began mostly as a curiousity project with Metal, and turning it into a CRAN acceptable package became a bit of a labor of love. If folks find this package useful I will consider expanding it to include other vendors, or more complicated dispatch routines. As it stands now, my primary concerns are conforming to CRAN submission guidelines, ensuring user interactions are clear and predictable, and working through a non-trivial TODO list.

About

Infrastructure for interacting with GPUs in R

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages