← Back to list

Python and NVIDIA: Hardware Interaction and Native Support

The Hardware Foundation

ThamizhElango Natarajan · 2025-05-29 02:35 · 0 claps · 7.3 min read paywalled
#nvidia #cuda #cupy #numba #pytorch
Open on Medium ↗
Wiki topics: OPS · LLMOps & Inference ML · Machine Learning

Python and NVIDIA: Hardware Interaction and Native Support

The Hardware Foundation

At the bottom of the stack sits NVIDIA’s GPU hardware — thousands of CUDA cores, tensor cores, and specialized units designed for parallel computation. This silicon is controlled by firmware and low-level drivers that manage power, memory, and basic operations.

NVIDIA’s Software Stack

Driver Layer

NVIDIA’s proprietary GPU driver sits directly above the hardware, providing:

  • Device management — Initialize and configure GPU resources
  • Memory management — Handle GPU memory allocation and transfers
  • Kernel execution — Launch and manage parallel compute kernels
  • Hardware abstraction — Present a consistent interface across different GPU architectures

CUDA Runtime and Libraries

Above the driver, NVIDIA provides several key binary libraries:

  • CUDA Runtime API — High-level C/C++ interface for GPU programming
  • cuBLAS — Optimized linear algebra operations
  • cuDNN — Deep learning primitives (convolutions, activations, etc.)
  • cuFFT — Fast Fourier Transform implementations
  • Thrust — Parallel algorithms library

These are compiled, optimized binary libraries that contain hand-tuned assembly code for maximum performance on NVIDIA hardware.

Python’s Integration Journey

Traditional Approach: Wrapper Libraries

Historically, Python accessed NVIDIA functionality through wrapper libraries:

PyCUDA

import pycuda.driver as cuda
import pycuda.autoinit
from pycuda.compiler import SourceModule

# Compile CUDA C code at runtime
mod = SourceModule("""
__global__ void multiply_them(float *dest, float *a, float *b)
{
  const int i = threadIdx.x;
  dest[i] = a[i] * b[i];
}
""")

CuPy (NumPy-like interface)

import cupy as cp
x_gpu = cp.array([1, 2, 3, 4, 5])
y_gpu = cp.array([6, 7, 8, 9, 10])
result = x_gpu * y_gpu  # Executed on GPU

These libraries use Python’s C extension API to call into NVIDIA’s binary libraries, essentially acting as translators between Python objects and C function calls.

Python Interpreter Access to NVIDIA Native Code

The CPython Extension Mechanism

Python accesses NVIDIA’s native code through the CPython C API, which allows compiled C/C++ code to be called from Python:

C Extension Structure:

// Example CuPy wrapper function
static PyObject* cupy_matmul(PyObject* self, PyObject* args) {
    PyArrayObject *a, *b;
    // Parse Python arguments
    if (!PyArg_ParseTuple(args, "O!O!", &PyArray_Type, &a, &PyArray_Type, &b))
        return NULL;

    // Convert Python arrays to CUDA memory
    float *d_a, *d_b, *d_result;
    cudaMalloc(&d_a, size_a);
    cudaMemcpy(d_a, PyArray_DATA(a), size_a, cudaMemcpyHostToDevice);

    // Call NVIDIA's optimized binary code
    cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, m, n, k, 
                &alpha, d_a, lda, d_b, ldb, &beta, d_result, ldc);

    // Convert result back to Python object
    return PyArray_FromBuffer(...);
}

The Translation Process

When you call a GPU operation from Python:

  1. Python function call — You call something like cupy.matmul(a, b)
  2. CPython interpreter — Looks up function in module’s method table
  3. C extension wrapper — CuPy’s C extension receives Python objects via CPython API
  4. Python object introspection — Extract data pointers, shapes, dtypes from PyArrayObject
  5. Data marshaling — Python arrays converted to C pointers and GPU memory
  6. CUDA library call — C wrapper calls cublasSgemm() or similar NVIDIA binary
  7. GPU execution — NVIDIA’s pre-compiled binary code executes on hardware
  8. Result marshaling — GPU results copied back and wrapped in Python objects
  9. Python object creation — New PyArrayObject created and returned to interpreter

Memory Layout Translation

// Python NumPy array structure (simplified)
typedef struct {
    PyObject_HEAD
    char *data;           // Pointer to raw data
    int nd;              // Number of dimensions
    npy_intp *dimensions; // Array shape
    npy_intp *strides;   // Memory layout
    PyArray_Descr *descr; // Data type descriptor
} PyArrayObject;

// CUDA expects simple C-style pointers
float* gpu_data;
cudaMalloc((void**)&gpu_data, total_bytes);
cudaMemcpy(gpu_data, numpy_array->data, total_bytes, cudaMemcpyHostToDevice);

Recent Native Support Developments

CUDA Python (Official NVIDIA Support)

In recent years, NVIDIA has dramatically improved Python support with official bindings:

CUDA Python provides direct access to CUDA APIs:

from cuda import cuda, nvrtc

# Direct CUDA driver API access
result, device = cuda.cuDeviceGet(0)
result, context = cuda.cuCtxCreate(0, device)

# Runtime compilation of CUDA code
program = nvrtc.nvrtcCreateProgram(cuda_source, "kernel.cu", 0, [], [])
nvrtc.nvrtcCompileProgram(program, [])

GPU-Accelerated Libraries with Python-First Design

CuDF (GPU DataFrames)

import cudf
df = cudf.read_csv('large_dataset.csv')
result = df.groupby('category').sum()  # Executed entirely on GPU

JAX with GPU Support

import jax.numpy as jnp
from jax import jit, grad

@jit  # Compiled to XLA, then to CUDA
def predict(params, inputs):
    return jnp.dot(inputs, params)

# Automatically uses GPU if available
result = predict(weights, data)

Python Native GPU Support in Popular Frameworks

PyTorch’s Deep CUDA Integration

import torch
x = torch.tensor([1, 2, 3]).cuda()
y = torch.tensor([4, 5, 6]).cuda()
z = x * y  # Direct GPU execution, minimal Python overhead

TensorFlow’s GPU Acceleration

import tensorflow as tf
with tf.device('/GPU:0'):
    a = tf.constant([1.0, 2.0, 3.0])
    b = tf.constant([4.0, 5.0, 6.0])
    c = a * b  # Executed on GPU

The Modern Architecture

Reduced Python Overhead

Recent developments focus on minimizing the Python interpreter overhead:

  1. Lazy Evaluation — Operations build computation graphs instead of executing immediately
  2. Kernel Fusion — Multiple operations combined into single GPU kernels
  3. Memory Pool Management — Persistent GPU memory allocation to avoid frequent transfers
  4. Asynchronous Execution — GPU work continues while Python prepares next operations

CUDA Compilation Pipeline and JIT Processes

Static Compilation (Traditional NVIDIA Libraries)

NVIDIA’s core libraries (cuBLAS, cuDNN, etc.) are pre-compiled:

# NVIDIA's internal compilation process (simplified)
nvcc kernel.cu → PTX assembly → SASS machine code → cubin binary

These binaries contain optimized machine code for specific GPU architectures (compute capabilities like 7.5, 8.0, 8.6).

Runtime JIT Compilation Stages

1. NVRTC (NVIDIA Runtime Compilation)

When Python code uses runtime compilation:

import cupy as cp
from cupy import cuda

# CUDA C source code as string
kernel_code = '''
extern "C" __global__
void my_kernel(float* a, float* b, float* result, int n) {
    int idx = blockDim.x * blockIdx.x + threadIdx.x;
    if (idx < n) {
        result[idx] = a[idx] + b[idx];
    }
}
'''

# Python → NVRTC → PTX → SASS pipeline
raw_kernel = cp.RawKernel(kernel_code, 'my_kernel')

What happens internally:

// CuPy's internal process
nvrtcCreateProgram(&prog, kernel_code, "kernel.cu", 0, NULL, NULL);
nvrtcCompileProgram(prog, 0, NULL);  // Compile to PTX
nvrtcGetPTX(prog, &ptx);             // Get PTX assembly

cuModuleLoadDataEx(&module, ptx, 0, NULL, NULL);  // PTX → SASS JIT
cuModuleGetFunction(&function, module, "my_kernel");

2. PTX to SASS JIT Compilation

Even pre-compiled PTX undergoes JIT compilation:

Python Call → C Extension → CUDA Driver → JIT Compiler → GPU Execution

1. cuModuleLoadDataEx() called with PTX code
2. CUDA driver's JIT compiler activates:
   - Optimizes for specific GPU architecture
   - Applies register allocation
   - Performs instruction scheduling
   - Generates SASS (GPU machine code)
3. SASS code loaded into GPU memory
4. Kernel launch parameters set
5. GPU executes native machine code

3. Caching Mechanisms

NVIDIA implements several caching layers:

Persistent Thread Block Cache:

// CUDA driver caches compiled kernels
static std::unordered_map<std::string, CUfunction> kernel_cache;

if (kernel_cache.find(ptx_hash) != kernel_cache.end()) {
    // Use cached compiled kernel
    cached_function = kernel_cache[ptx_hash];
} else {
    // JIT compile and cache
    cuModuleLoadDataEx(&module, ptx, 0, NULL, NULL);
    kernel_cache[ptx_hash] = function;
}

Advanced JIT Compilation Examples

Numba CUDA JIT Process

from numba import cuda
import numpy as np

@cuda.jit  # This decorator triggers the JIT pipeline
def gpu_add(a, b, result):
    idx = cuda.grid(1)
    if idx < result.size:
        result[idx] = a[idx] + b[idx]

# First call triggers compilation
a = np.array([1, 2, 3, 4], dtype=np.float32)
b = np.array([5, 6, 7, 8], dtype=np.float32)
result = np.empty_like(a)

# Numba's compilation pipeline:
# 1. Python bytecode → Numba IR
# 2. Type inference and specialization  
# 3. Numba IR → LLVM IR
# 4. LLVM IR → PTX assembly
# 5. PTX → SASS (via CUDA driver JIT)
gpu_add[1, 4](a, b, result)

Numba’s Internal Process:

Python Function → Numba Type Inference → LLVM IR Generation → PTX Code → CUDA JIT → GPU

CuPy’s ElementwiseKernel JIT

import cupy as cp

# CuPy generates and compiles CUDA code on-the-fly
add_kernel = cp.ElementwiseKernel(
    'float32 x, float32 y',  # Input signature
    'float32 z',             # Output signature  
    'z = x + y',             # Operation
    'add_kernel'             # Kernel name
)

# Behind the scenes, CuPy:
# 1. Generates complete CUDA C kernel source
# 2. Calls NVRTC to compile to PTX
# 3. CUDA driver JIT compiles PTX to SASS
# 4. Caches result for future use

Generated CUDA code (simplified):

extern "C" __global__ void add_kernel(float* x, float* y, float* z, int size) {
    int i = blockDim.x * blockIdx.x + threadIdx.x;
    if (i < size) {
        z[i] = x[i] + y[i];
    }
}

Performance Implications of JIT

Cold Start vs Warm Execution

import time
import cupy as cp

# First call - includes JIT compilation time
start = time.time()
result1 = cp.add(a_gpu, b_gpu)  # ~10-100ms (includes compilation)
cold_time = time.time() - start

# Subsequent calls - uses cached compiled kernel  
start = time.time()
result2 = cp.add(c_gpu, d_gpu)  # ~0.1-1ms (just execution)
warm_time = time.time() - start

JIT Optimization Levels

CUDA’s JIT compiler applies various optimizations:

// Compilation options can be controlled
const char* jit_options[] = {
    "--optimization-level=3",      // Maximum optimization
    "--fmad=true",                // Fused multiply-add
    "--prec-div=false",           // Fast division
    "--use_fast_math",            // Fast math operations
    "--maxrregcount=32"           // Register usage limit
};

cuModuleLoadDataEx(&module, ptx, 5, jit_options, option_values);

Multi-Stage Compilation Summary

The complete pipeline when Python calls NVIDIA code:

Python Source Code
        ↓
CPython Interpreter 
        ↓
C Extension (CuPy/PyCUDA/etc.)
        ↓
CUDA Runtime API Call
        ↓
NVIDIA Driver (if JIT needed)
        ↓  
JIT Compiler (PTX → SASS)
        ↓
GPU Hardware Execution
        ↓
Result back through same chain

Each stage has different compilation and caching strategies, with modern Python GPU libraries increasingly using JIT compilation to generate optimized code tailored to specific use cases and hardware configurations.

Numba CUDA

from numba import cuda
import numpy as np

@cuda.jit
def gpu_multiply(a, b, result):
    idx = cuda.grid(1)
    if idx < result.size:
        result[idx] = a[idx] * b[idx]

# Compiles to CUDA PTX at runtime
gpu_multiply[blocks_per_grid, threads_per_block](a_gpu, b_gpu, result_gpu)

CuPy’s RawKernels

import cupy as cp

raw_kernel = cp.RawKernel(r'''
extern "C" __global__
void my_kernel(const float* x, float* y, int n) {
    int tid = blockDim.x * blockIdx.x + threadIdx.x;
    if (tid < n) {
        y[tid] = x[tid] * 2.0f;
    }
}
''', 'my_kernel')

Performance Considerations

Memory Management

  • Unified Memory — CUDA’s unified memory allows transparent GPU/CPU memory access
  • Memory Pools — Pre-allocated GPU memory reduces allocation overhead
  • Pinned Memory — Page-locked CPU memory for faster transfers

Execution Optimization

  • Stream Processing — Multiple CUDA streams for overlapping computation and memory transfers
  • Kernel Fusion — Combining multiple operations into single GPU kernels
  • Graph Optimization — Analyzing entire computation graphs for optimization opportunities

The Future: Native Python GPU Support

Python 3.12+ GPU Enhancements

Recent Python versions include better support for:

  • Buffer Protocol Extensions — More efficient memory sharing with GPU libraries
  • Improved C API — Faster Python/C integration for GPU libraries
  • Better Async Support — Improved asynchronous programming for GPU workloads

Emerging Standards

  • Array API Standard — Unified interface across NumPy, CuPy, JAX, and PyTorch
  • Python GPU Array Interface — Standard for GPU memory sharing between libraries
  • DLPack — Zero-copy data exchange between deep learning frameworks

Conclusion

Python’s interaction with NVIDIA hardware has evolved from simple wrapper libraries to sophisticated, high-performance integrations. Modern Python GPU programming combines the ease of Python with near-native GPU performance through JIT compilation, optimized memory management, and direct integration with NVIDIA’s binary libraries.

The key insight is that while Python remains the control language, the actual computation increasingly happens in compiled GPU code, with Python serving as an orchestrator rather than an interpreter for numerical operations. This hybrid approach provides both productivity and performance, making GPU computing accessible to Python developers while maintaining the raw speed necessary for demanding applications.


메타데이터
post_id
91d66c1ceaa3
slug
python-and-nvidia-hardware-interaction-and-native-support-91d66c1ceaa3
url
https://medium.com/@thamizhelango/python-and-nvidia-hardware-interaction-and-native-support-91d66c1ceaa3
canonical_url
https://medium.com/@thamizhelango/python-and-nvidia-hardware-interaction-and-native-support-91d66c1ceaa3
author_url
https://medium.com/@thamizhelango
status
ok
fetched_at
2026-06-15 20:49:13