Use the GPUs on Hydra
Use the GPUs on Hydra
This resource covers what a GPU actually is, when and why you should use one, how to access a GPU node on Hydra, and how to build a conda environment with nvForest so you can run a trained scikit-learn random forest on the GPU.
If you are new to high-performing computing or have never used the Poseidon or Hydra computer clusters before, I recommend checking out my other blog posts on high-performance computing:
đ HPC basics đ Getting started with Hydra
Here are the third party references I used when figuring this out:
đ nvForest docs đ RAPIDS install guide đ RAPIDS platform support
Overview
- Why use a GPU
- What is a GPU?
- When (and when not) to use a GPU
- Start an interactive session on a GPU node
- Check the GPU you got
- Set up a conda environment with nvForest
- Add cuDF and CuPy (optional: data on the GPU)
- A tiny example: scikit-learn â GPUForestInferenceRegressor
- The CPU/GPU boundary
- Scale to all four GPUs
- The payoff
Why use a GPU?
I have several Random Forest models that predict nitrous oxide (NâO) from things like temperature, salinity, oxygen, and nitrate. I tried using these models to predict NâO on a large feature dataset, using traditional CPU resources on Hydra. The result: disaster. Well not quite⊠but even with the backend parallel processing built into Scikit-Learnâs Random Forest Inference Regressor running on 16 parallel cores, it took four minutes for one model (out of four) to do the prediction on a single month of data (out of a 384-month dataset). Why? Each random forest model has 600 decision trees, and each month of data includes 20 million rows. Hereâs that example:
import numpy as np
import pandas as pd
import xarray as xr
import gsw
from joblib import load
TPATH = "/proj/boom/data/argo_rfromv22_temp"
SPATH = "/proj/boom/data/argo_rfromv22_sal"
O2PATH = "/proj/boom/data/gobai-highres-o2"
NO3PATH = "/proj/boom/data/gobai-highres-no3"
FEATURE_DIR = "/proj/boom/data/highres_features"
PRED_DIR = "/proj/boom/data/highres_n2o_pred/etphighres"
MODEL_DIR = "/user/colette.kelly/ml-argo-n2o"
# lat-lon slices for the eastern tropical Pacific
LAT = slice(-30,25)
LON = slice(360 - 180, 360 - 70)
# pressure window for prediction - set to something small for testing
PMIN, PMAX = 0.0, 5000.0
# model features
# needs to match the feature order each RF was trained on
MODEL_FEATURES = {
1: ["AOU", "CT", "SA", "no3"],
2: ["o2", "CT", "SA", "no3"],
3: ["AOU", "CT", "SA"],
4: ["o2", "CT", "SA"]}
F32 = np.float32
year = 2024
month = 1
tab = pd.read_parquet(f"{FEATURE_DIR}/features_{year}_{month:02d}.parquet")
sub = tab[(tab["pres"] > PMIN) & (tab["pres"] < PMAX)]
model_id = 1
cols = MODEL_FEATURES[model_id]
# set up Numpy array to do predictions on
# use "sub" -> so the N2O predictions will only be made for the selected pressure range
X = np.ascontiguousarray(sub[cols].to_numpy(dtype=F32))
X.shape
# load RF models
models = {mid: load(f"/user/colette.kelly/ml-argo-n2o/model{mid}_rf_full.joblib") for mid in (1,2,3,4)}
model = models[model_id]
model.n_jobs = -1
print(f"[backend] model {model_id}: sklearn ({type(model).__name__})") # expect RandomForestRegressor
%timeit y = np.asarray(model.predict(X), dtype=F32)
6min 15s ± 2.6 s per loop (mean ± std. dev. of 7 runs, 1 loop each)
What is a GPU?
So, time to turn to a GPU. WHOIâs new computer cluster Hydra has some amazing resources, including a GPU node. Hereâs that actually means:
The traditional compute nodes on Hydra have 48 powerful CPUs (central processing units) per node that are great at sequential logic: âdo this, then check that, then decide whatâs next.ââ The GPU node on Hydra has 4 NVDIA GPUs (graphics processing units), each of which has thousands of small, simple cores that are great at doing the same operation across a huge amount of data at once.
A commonly-used analogy: a CPU is a kitchen full of expert chefs each cooking a multi-course meal from start to finish. A GPU is a stadium-full (actually though) of line cooks all chopping onions at the same time. So is your problem, cook this fancy multi-course meal? Or chop 10 million onions the same way? If youâre doing something highly parallelizable, the GPU is the correct path.
The one catch: a GPU has its own memory, separate from the computerâs main (host) memory. Your data has to be copied from host memory into GPU (device) memory and the results copied back. So you should avoid shuttling data across that boundary, and only use a GPU if the computational savings from parallelizing on a GPU are greater than the cost of copying data back and forth.
When (and when not) to use a GPU
Good fits for a GPU:
- Training or running machine-learning models on lots of data
- Large array / linear-algebra math (big matrix ops, FFTs)
- DataFrame operations on large tables via RAPIDS (cuDF / CuPy)
- Anything âembarrassingly parallelâ â the same simple operation over millions of elements
My use case: applying trained random forest models to ~20 million rows of gridded ocean data to predict NâO. On a CPU this was painfully slow and kept running out of memory; on the GPU the same inference finishes in seconds.
Skip the GPU for:
- Small data, because the hostâdevice copy dominates and you gain nothing
- Serial, branchy logic that canât be parallelized
- I/O-bound work: reading/writing files, downloading data, conda installs
- Plain pandas on a modest table
A GPU can actually be slower once you count the transfer overhead.
GPU etiquette
GPU nodes are a scarce, shared resource. Only request one for work that actually uses the GPU. Do your feature engineering, file wrangling, and environment installs on a regular compute node and save the gpu partition for the inference itself.
Start an interactive session on a GPU node
Similar to starting an interactive session on a traditional compute node, but ask SLURM for the gpu partition and one GPU with --gpus-per-node:
[colette.kelly@hydra-l2 ~]$ srun --partition=gpu --gpus-per-node=1 --mem=32GB --time=00:30:00 --pty bash
Your prompt updates to the GPU node you were assigned:
[colette.kelly@gpu003 ~]$
A note on --mem: thatâs host RAM, which is separate from the GPUâs own memory. Loading a single large random forest into host memory can take ~14 GB on its own, so make sure youâve requested enough memory or the session will time out. You donât need to size it to the GPUâs memory.
Check the GPU you got
Itâs worth checking the GPU hardware to make sure youâre installing versions of libraries that are compatible with the hardware. This is a bit more involved on GPU than with a traditional compute node. Hereâs what you need to do.
Before installing anything, see what hardware you landed on:
[colette.kelly@gpu003 ~]$ nvidia-smi --query-gpu=name,compute_cap,driver_version --format=csv
name, compute_cap, driver_version
NVIDIA H200, 9.0, 610.43.02
-
nameâ NVIDIA H200. A current-generation (Hopper) datacenter GPU with ~141 GB of on-board memory. -
compute_capâ 9.0. The GPUâs compute capability â its architecture / feature level. The RAPIDS ecosystem, which includes the nvforest library for RF inference, requires 7.0 or higher (Volta and newer). -
driver_versionâ 610.43.02. The NVIDIA driver installed on the node. The driver determines the newest CUDA version the node can run. RAPIDS 26.06 needs driver 525.60.13+ for its CUDA 12 build or 580.65.06+ for CUDA 13. RAPIDS ships separate builds for CUDA 12 and CUDA 13, and you have to install one your driver supports. We have610so we can use either.
Sidebar: what is CUDA?
CUDA is NVIDIAâs platform for running general-purpose computation on their GPUs:
- The driver is the system-level CUDA, installed by the cluster admins, that sets the maximum CUDA version the node can run.
- The toolkit / runtime are the CUDA libraries your code actually links against. Conda installs this into your environment, so you control it.
You just need to make sure that the driver is new enough for the toolkit you install. On our H200 with driver 610, weâre clear for either CUDA 12 or 13.
Set up a conda environment with nvForest
nvForest is the RAPIDS library for fast decision-tree and forest inference on the GPU (itâs the successor to the now-deprecated cuml.fil). It imports an already-trained scikit-learn / XGBoost / LightGBM forest and runs its predictions on the GPU, so you donât retrain anything.
Step 1 â see whatâs actually available
Install everything on a regular compute node, not a GPU.
[colette.kelly@hydra-l1 ~]$ srun -N 1 -n 1 --mem=50GB --time=01:00:00 --partition=compute --pty bash
[colette.kelly@cn059 ~]$ module load miniconda/25.9
[colette.kelly@cn059 ~]$ . $CONDA_PREFIX/etc/profile.d/conda.sh
[colette.kelly@cn059 ~]$ # See what channels conda already has configured â look for `defaults`
conda config --show channels
channels:
- defaults
[colette.kelly@cn059 ~]$ conda search -c rapidsai -c conda-forge nvforest
2 channel Terms of Service accepted
Loading channels: done
# Name Version Build Channel
nvforest 26.04.00 cuda12_cp311_abi3_260408_e77480fb rapidsai
nvforest 26.04.00 cuda13_cp311_abi3_260408_e77480fb rapidsai
nvforest 26.06.00 cuda12_cp311_abi3_260604_a9216c61 rapidsai
nvforest 26.06.00 cuda13_cp311_abi3_260604_a9216c61 rapidsai
-
nvforestis on the rapidsai channel, with 26.06.00 available -
cuda12= the CUDA 12 build;cuda13= the CUDA 13 build -
cp311= built against CPython 3.11. -
abi3is the key part â these are built against Pythonâs stable ABI, so a single build is forward-compatible across later 3.x. The giveaway is that thereâs only one build per (version, CUDA) instead of separate cp311/cp312/cp313 rows. That means the package declares roughly python >=3.11, and 26.06 supports Python 3.11 through 3.14, so python=3.12 resolves to that same cuda12_cp311_abi3 build. Youâre not locked to 3.11.
Step 2 â create the environment
[colette.kelly@cn059 ~]$ CONDA_OVERRIDE_CUDA=12.9 conda create -n nvforest \
--override-channels -c rapidsai -c conda-forge \
--solver=libmamba \
nvforest=26.06.* python=3.12 'cuda-version>=12.2,<=12.9'
-
The CONDA_OVERRIDE_CUDA=12.9 matters because weâre solving on cn059, a regular compute node with no GPU driver visible â without it, conda canât detect a CUDA driver and the solve gets confused.
-
--override-channelsis needed because Hydraâs base conda has only thedefaults(Anaconda) channel configured and RAPIDS packages are not built fordefaults. -
-c rapidsai -c conda-forge -c nodefaultsare the channels that RAPIDS needs. -
--solver=libmambais a fast solver that handles big RAPIDS environments better than the classic solver and gives clearer errors. -
Pin nvforest=26.06.00 for reproducibility, so you get the build you just inspected rather than whateverâs latest later.
-
python=3.12â if python=3.12 ever throws a solver complaint, drop to python=3.11 to match the build label. -
The âcuda-version>=12.2,<=12.9â pin selects the cuda12 build (the cuda12_cp311_abi3 row) over the cuda13 one. Hydraâs H200 + driver 610 would run either, but cuda12 keeps the surrounding conda-forge ecosystem on the better-trodden path.
Step 3 - add other packages
[colette.kelly@cn059 ~]$ conda activate nvforest
(nvforest) [colette.kelly@cn059 ~]$ conda install xarray pandas pyarrow scikit-learn netcdf4 matplotlib dask
Add cuDF and CuPy (optional: data on the GPU)
If you want to read your feature files straight into GPU memory instead of going through pandas on the host, add cuDF. CuPy almost certainly came in already as an nvForest dependency, so check first:
(nvforest) [colette.kelly@cn059 ~]$ conda list | grep -E 'cudf|cupy'
cupy 14.1.1 ... conda-forge
cupy-core 14.1.1 ... conda-forge
If only cupy shows up, add cudf:
(nvforest) [colette.kelly@cn058 ~]$ CONDA_OVERRIDE_CUDA=12.9 conda install -n nvforest \
--override-channels -c rapidsai -c conda-forge \
--solver=libmamba \
'cudf=26.06.*'
Warnings:
- Donât
pip installcupy or cudf into a conda RAPIDS environment. Mixing Pipâs CUDA wheels and condaâs CUDA packages breaks things in confusing ways. - cuDF pins pandas to a supported range (e.g.
pandas < 2.4), so installing it may downgrade pandas. Ordinary feature-engineering code (building DataFrames,to_parquet) uses only stable pandas API and is unaffected.
Verify
Activate the environment and confirm everything imports together:
(nvforest) [colette.kelly@cn059 ~]$ python -c "import nvforest, cudf, cupy, sklearn; \
print('nvforest', '+ cudf', cudf.__version__, '+ cupy', cupy.__version__, 'â all import OK')"
A tiny example: scikit-learn â GPUForestInferenceRegressor
Exit the interactive session on the compute node:
(nvforest) [colette.kelly@cn059 ~]$ exit
exit
Now I start an interactive session on the GPU node and load an RF model:
[colette.kelly@hydra-l2 ~]$ srun --partition=gpu --gpus-per-node=1 --time=00:30:00 --mem=100GB --pty bash
[colette.kelly@gpu003 ~]$ module load miniconda/25.9
[colette.kelly@gpu003 ~]$ . $CONDA_PREFIX/etc/profile.d/conda.sh
[colette.kelly@gpu003 ~]$ conda activate nvforest
(nvforest) [colette.kelly@gpu003 ~]$ ipython
import joblib, nvforest
skl = joblib.load("/user/colette.kelly/ml-argo-n2o/model1_rf_full.joblib")
fm = nvforest.load_from_sklearn(skl, device = "gpu")
print(type(fm).__name__)
GPUForestInferenceRegressor
import numpy as np
X = np.random.rand(5, skl.n_features_in_).astype("float32")
print(fm.predict(X))
[[328.24543802]
[328.62658223]
[333.47100661]
[328.17146862]
[328.19903352]]
the CPU/GPU boundary
The tiny example handed nvForest a NumPy array sitting in host memory. That works, but it copies the data across the hostâdevice boundary on every call. But we want the data to land in GPU memory in the first place. Thatâs what cuDF is for. Itâs pandasâ API on the GPU, so the code barely changes:
import cudf, cupy as cp
cols = ["AOU", "CT", "SA", "no3"] # the features this forest was trained on
FEATURE_DIR = "/proj/boom/data/highres_features"
year = 2024
month = 1
path = f"{FEATURE_DIR}/features_{year}_{month:02d}.parquet"
# read the parquet straight into GPU memory, only the columns we need
gdf = cudf.read_parquet(path, columns=cols + ["pres", "flat_index"])
gdf = gdf[(gdf["pres"] > 0) & (gdf["pres"] < 5000)] # filter runs on the GPU
# feature matrix: contiguous float32, still on the GPU
Xg = cp.ascontiguousarray(gdf[cols].astype("float32").to_cupy())
%timeit yg = fm.predict(Xg) # inference on the GPU; yg is a CuPy array
344 ms ± 118 Όs per loop (mean ± std. dev. of 7 runs, 1 loop each)
â ïž nvForest always returns a device array that lives on the GPU. You need to copy it back explicitly before you can write it:
yg = fm.predict(Xg)
y = cp.asnumpy(yg).astype(np.float32).reshape(-1)
cp.asnumpy is also a synchronization point between where GPU does the math and CPU writes the file. For a fast pipeline: only copy back the small things youâll actually write. Here, we do inference on 20 million rows on the GPU but only bring home the 1-D prediction vector.
Finishing up:
flat = gdf["flat_index"].to_numpy() # move the cudf series to host numpy
TPATH = "/proj/boom/data/argo_rfromv22_temp"
import numpy as np, xarray as xr
# scatter + NetCDF write are host-side, since netCDF is a CPU library
LAT = slice(-30,25)
LON = slice(360 - 180, 360 - 70)
src = xr.open_dataset(
f"{TPATH}/RFROMV22_TEMP_STABLE_{year}_{month:02d}.nc"
).sel(latitude = LAT, longitude = LON)["ocean_temperature"]
out = np.full(src.size, np.nan, dtype = np.float32)
out[flat] = y
da = xr.DataArray(out.reshape(src.shape), dims = src.dims, coords = src.coords, name = f"pN2O_model1")
out = f"n2o_model1_{year}_{month:02d}.nc"
da.to_netcdf(out)
print(f"[predict {out} predicted={len(y):,}")
Scale to all four GPUs
One month of inference takes seconds. I have 384 of them (monthly, 1993â2024), and Hydraâs GPU node has four H200s. The obvious move is a SLURM job array. But check the partition first:
[colette.kelly@hydra-l1 ~]$ scontrol show partition gpu | grep -i oversubscribe
... OverSubscribe=NO
OverSubscribe=NO means that one job owns the whole machine at a time. So do one job that grabs all four GPUs, and fan the work out over one worker process per GPU:
#SBATCH --partition=gpu
#SBATCH --nodes=1
#SBATCH --gpus-per-node=4
#SBATCH --cpus-per-task=96 # the whole node â see the thread note below
#SBATCH --time=01:00:00
NWORKERS=4
THREADS=$(( SLURM_CPUS_PER_TASK / NWORKERS )) # 96 / 4 = 24 cores each
pids=()
for w in 0 1 2 3; do
CUDA_VISIBLE_DEVICES=${w} \
OMP_NUM_THREADS=${THREADS} OPENBLAS_NUM_THREADS=${THREADS} MKL_NUM_THREADS=${THREADS} \
python3 run_worker.py --worker ${w} --nworkers ${NWORKERS} \
> worker_${SLURM_JOB_ID}_gpu${w}.log 2>&1 &
pids+=($!)
done
fail=0
for pid in "${pids[@]}"; do wait "${pid}" || fail=1; done # reap all, remember failures
exit ${fail}
Each worker takes a round-robin slice of the months (months[worker::nworkers]), loads the four forests onto its GPU once, and reuses them across all its months:
months = all_months()[args.worker::args.nworkers] # this GPU's ~96 months
backends = {mid: load_backend(mid) for mid in (1, 2, 3, 4)} # load once, reuse
for (year, month) in months:
gdf = read_features(year, month)
for mid in (1, 2, 3, 4):
predict_and_write(year, month, gdf, mid, backends[mid])
CUDA_VISIBLE_DEVICES
To see what you can actually reach, ask CUDA:
python -c "import cupy; print(cupy.cuda.runtime.getDeviceCount())"
Donât let four workers each grab all 96 cores
The GPUs do the inference, but the parquet decode and NetCDF write still runs on the CPU. We donât want ~384 threads thrashing over 96 cores. The extra lines OMP_NUM_THREADS / OPENBLAS_NUM_THREADS / MKL_NUM_THREADS gives each CPU worker a clean slice.
The payoff
When the batch job finishes, seff summarizes what it actually used:
[colette.kelly@hydra-l1 ~]$ seff 118272
State: COMPLETED (exit code 0)
Cores per node: 96
CPU Utilized: 00:16:24
CPU Efficiency: 2.39% of 11:26:24 core-walltime
Job Wall-clock time: 00:07:09
Memory Utilized: 256.00 GB (100.00% of 256.00 GB)
Two things:
- CPU efficiency of 2.39% is OK. I reserved 96 cores for seven minutes but only spent 16 CPU-minutes, because the GPUs did the work. The cores were there so host-side I/O wouldnât bottleneck, and it didnât. If you see something similar, donât âfixâ this by shrinking the CPU request; on an exclusive node you own all 96 regardless.
- Memory at exactly 100% is fine but not great. Landing precisely on the ceiling usually means I bumped up against it. On this node there is ~1 TB to play with, so thereâs no reason to be frugal.
The payoff is that the same .predict() call, ~20 million rows, model already loaded and data already in memory:
| backend | time for one .predict()
|
|---|---|
| scikit-learn, CPU | 4 min 19 s |
| nvForest, one H200 | 0.34 s |
So thatâs a roughly 750Ă speedup on identical work. So for the full job â four forests over 384 months â thatâs about 31 billion predictions (20,452,736 grid points Ă 4 models Ă 384 months). What extrapolates to days of CPU inference finished in 7 minutes across the four H200s. Thatâs what makes using GPUs worth it.