Hands-On GPU-Accelerated Machine Learning in Colab Enterprise with cuDF, cuML, and XGBoost on NVIDIA L4 GPU
Codelab GPU-accelerated ML · Colab Enterprise · NVIDIA L4
15 steps 2–3 hours cuDF · cuML · XGBoost Intermediate

Hands-On GPU-Accelerated Machine Learning in Colab Enterprise with cuDF, cuML, and XGBoost on NVIDIA L4 GPU

Move a pandas and scikit-learn workflow onto an NVIDIA L4 without rewriting it. Predict NYC taxi tips across millions of trips, then measure the speedup stage by stage and profile away the operations that quietly fall back to the CPU.

01

Introduction

This lab is about making an existing machine learning workflow run dramatically faster without rewriting it. You will take a tip-prediction problem on the NYC Yellow Taxi dataset — millions of trip records — and move the whole lifecycle onto an NVIDIA L4 GPU inside Colab Enterprise: loading, cleaning, feature engineering, cross-validated training, and ensembling.

The point of leverage is that you barely touch your code. Two IPython magics, %load_ext cudf.pandas and %load_ext cuml.accel, redirect pandas and scikit-learn calls onto the GPU while your import pandas as pd and RandomForestRegressor(...) lines stay exactly as written. XGBoost gets there with two parameters.

What you'll learn

  • How Colab Enterprise fits into Google Cloud, and when to pick it over Agent Platform Workbench
  • How to customize a Colab runtime with a specific GPU and memory configuration
  • How to apply GPU acceleration to a prediction task on a large dataset
  • How to accelerate pandas with NVIDIA cuDF, with no code changes
  • How to accelerate scikit-learn with NVIDIA cuML, with no code changes
  • How to profile your code to find and fix the parts that fall back to the CPU

What you'll need

  • A Google Cloud account and project with billing enabled
  • Access to Colab Enterprise and quota for an NVIDIA L4 GPU in your chosen region
  • Intermediate familiarity with Python, pandas, scikit-learn, cross-validation and ensembling
Dataset

The NYC Yellow Taxi trip records are downloaded for you inside the notebook — there is nothing to prepare in advance.

02

Why accelerate machine learning?

Data science is an iteration loop: form a hypothesis, engineer a feature, train, measure, repeat. The value of the loop depends almost entirely on how many times you can go around it. On CPU, a full pass over millions of rows — a few joins, a groupby aggregate, then a random forest and a gradient-boosted model with 3-fold cross-validation — can run for hours. That cost quietly reshapes how you work: you sample the data instead of using all of it, you test three hyperparameters instead of thirty, and you stop exploring before you have finished thinking.

GPU acceleration changes the arithmetic in three specific ways.

What changesWhy it matters
Faster iteration A feature idea or a hyperparameter sweep that took a coffee break now takes seconds, so you test the idea instead of guessing about it.
Full-dataset training When training on everything is affordable, you stop sampling. Models see the rare routes and edge cases a 5% sample throws away, and accuracy improves.
Lower total cost A GPU instance costs more per hour, but finishing in a fraction of the wall-clock time can make the job cheaper overall — and frees the analyst, who costs more than either.

The libraries in this lab — cuDF and cuML, from the NVIDIA RAPIDS suite — are designed so you get those three things without a migration project. They implement the pandas and scikit-learn APIs on the GPU and fall back to the CPU when they meet something they cannot accelerate, so your existing notebook keeps running either way.

03

Setup and requirements

Potential costs

Billing

This lab uses billable Google Cloud resources, including a Colab Enterprise runtime backed by an NVIDIA L4 GPU. GPU-attached runtimes bill for as long as they exist, not only while a cell is running.

Follow step 14, Clean up when you finish, and check the current Colab Enterprise pricing and GPU pricing before you start.

Before you begin

  • Sign in to the Google Cloud console.
  • Create a new project, or select an existing one you are willing to run GPU workloads in.
  • Confirm that billing is enabled for that project.

Enable the APIs

Open Cloud Shell from the console toolbar — the terminal icon in the top-right of the Google Cloud console.

The Cloud Shell button in the Google Cloud console toolbar.
Fig 3.1The Cloud Shell button in the Google Cloud console toolbar.

First point the shell at the project you intend to use, replacing the placeholder with your own project ID:

bash
gcloud config set project <PROJECT_ID>

Then enable the four APIs this lab depends on. Enabling can take a minute or two to propagate.

bash
gcloud services enable \
    compute.googleapis.com \
    dataform.googleapis.com \
    notebooks.googleapis.com \
    aiplatform.googleapis.com
Tip

If gcloud asks you to authorize the call, accept it — Cloud Shell needs your credentials to change project configuration.

04

Choosing a notebook environment

Google Cloud offers two managed notebook surfaces, and they optimize for different things. The choice is about how much of the infrastructure you want to own.

Agent Platform Workbench

Optimizes for control

  • You manage the underlying instance and its lifecycle
  • Custom containers and your own dependency stack
  • Detailed network and VPC configuration
  • Deep integration with MLOps tooling and pipelines

Colab Enterprise Used here

Optimizes for speed and collaboration

  • Fast setup with no instance management
  • Secure sharing and collaboration on notebooks
  • Integrated with BigQuery Studio
  • A consistent experience across Google Cloud surfaces

This lab uses Colab Enterprise. It ships with cuDF, cuML and XGBoost already installed, so you can go from an empty project to a GPU-accelerated training run without building an environment first.

05

Configure a runtime template

A runtime template is a reusable specification for the machine your notebook connects to — machine type, accelerator, idle timeout, Python version. You define the shape of the environment once, then stamp out runtimes from it.

Why templates are worth the extra step

  • Consistency. Everyone on the team gets an identical environment, so “it works on my runtime” stops being a category of bug.
  • Security. Network and access settings are enforced centrally rather than left to whoever clicks Create.
  • Cost control. Machine size and idle shutdown are fixed up front, so a forgotten GPU cannot bill all weekend.

Create the template

Navigation menu › Agent Platform › Notebooks › Runtime templates › New template
Navigation menu: Agent Platform &rsaquo; Notebooks.
Fig 5.1Navigation menu: Agent Platform › Notebooks.
The Runtime templates tab, with the New template button.
Fig 5.2The Runtime templates tab, with the New template button.

Fill in the sections as follows.

SettingValue
Display namegpu-template
RegionYour preferred region — it must have L4 capacity
Machine typeg2-standard-4
Accelerator typeNVIDIA L4
Accelerator count1
Idle shutdown60 minutes
Python version3.11
Runtime basics: display name and region.
Fig 5.3Runtime basics: display name and region.
Configure compute: machine type g2-standard-4, one NVIDIA L4, 60-minute idle shutdown.
Fig 5.4Configure compute: machine type g2-standard-4, one NVIDIA L4, 60-minute idle shutdown.
Environment: Python 3.11.
Fig 5.5Environment: Python 3.11.

The g2 machine family is the L4 family on Compute Engine, so choosing g2-standard-4 is what makes the NVIDIA L4 available in the accelerator list. Leave the remaining fields at their defaults and click Create.

06

Request GPU quota

Projects often start with a global GPU quota of zero, and a runtime can't start from the template until you have quota for at least one GPU. Request it before you create a runtime.

Navigation menu › IAM & Admin › IAM
  1. In the IAM & Admin menu on the left, scroll down to Cloud administration and open Quotas & System Limits.
  2. In the Filter box, search for GPUS_ALL_REGIONS.
  3. In the matching row, click the three-dot menu on the right and choose Edit quota.
  4. Set New value to 1 and submit the request.
Ask for 1

This lab only needs one GPU. In testing, a request for 2 was rejected and a request for 1 was approved.

You'll get two emails from cloudquota@google.com. The first says your request is being processed. The second says the quota change has been approved.

Approval email from cloudquota@google.com showing GPUS_ALL_REGIONS approved at a limit of 1.
Fig 6.1The approval email: GPUS_ALL_REGIONS approved at a limit of 1.

The approval email notes that the new quota can take up to 15 minutes to show up in the console.

When the approval email arrives, go on to the next step.

07

Start a runtime

The template describes a machine; a runtime is an actual machine running from it.

Runtimes › Create
  1. Under runtime template, select the gpu-template you just created.
  2. Click Create.
Creating a runtime from the gpu-template runtime template.
Fig 7.1Creating a runtime from the gpu-template runtime template.

Wait for the runtime to finish provisioning. Allocating a GPU-backed VM usually takes several minutes.

The runtime starting up.
Fig 7.2The runtime starting up.

Once it finishes, the runtime shows as available and is ready to accept a notebook connection.

The runtime listed as available.
Fig 7.3The runtime listed as available.
If creation fails

GPU capacity is regional and demand fluctuates. If the runtime fails to start because L4 accelerators are unavailable, create a second runtime template in a different region and try again there.

08

Set up the notebook

Import the notebook

My notebooks › Import
The Import dialog, with the URL source option selected.
Fig 8.1The Import dialog, with the URL source option selected.

Choose the URL option and paste in the notebook source:

The notebook URL pasted into the import dialog.
Fig 8.2The notebook URL pasted into the import dialog.

Click Import. The notebook is added to your list.

Connect to the runtime

  1. Open the imported notebook.
  2. Click the down arrow next to Connect.
  3. Select Connect to a runtime.
The Connect dropdown, with &ldquo;Connect to a runtime&rdquo;.
Fig 8.3The Connect dropdown, with “Connect to a runtime”.
  1. Pick the runtime you created in the previous step from the dropdown.
  2. Click Connect.
Selecting the existing runtime, then connecting.
Fig 8.4Selecting the existing runtime, then connecting.

Built-in dependencies

Nothing to install. The Colab Enterprise image already contains the acceleration stack this lab uses:

cuDF — GPU DataFrames cuML — GPU scikit-learn XGBoost — native CUDA support
09

Prepare the NYC Taxi dataset

Confirm the GPU is available

Before anything else, verify that the runtime really sees an accelerator. Run this in the first cell:

notebook cell
nvidia-smi
nvidia-smi output confirming an NVIDIA L4 is attached.
Fig 9.1nvidia-smi output confirming an NVIDIA L4 is attached.

Read the output and confirm two things: the product name column shows NVIDIA L4, and the memory column reports roughly 24 GB of device memory. If nvidia-smi is not found, you are connected to a CPU-only runtime — go back and reconnect to the one built from gpu-template.

Download the data

This pulls the 2024 Yellow Taxi monthly Parquet files. The loop skips files that are already on disk, so it is safe to re-run.

python
from tqdm import tqdm
import requests
import time
import os

YEAR = 2024
DATA_DIR = "nyc_taxi_data"

os.makedirs(DATA_DIR, exist_ok=True)
print(f"Checking/Downloading files for {YEAR}...")

for month in tqdm(range(1, 13), unit="file"):
    file_name = f"yellow_tripdata_{YEAR}-{month:02d}.parquet"
    local_path = os.path.join(DATA_DIR, file_name)
    url = f"https://d37ci6vzurychx.cloudfront.net/trip-data/{file_name}"

    if not os.path.exists(local_path):
        try:
            with requests.get(url, stream=True) as response:
                response.raise_for_status()
                with open(local_path, 'wb') as f:
                    for chunk in response.iter_content(chunk_size=8192):
                        f.write(chunk)
            time.sleep(1)
        except requests.exceptions.HTTPError as e:
            print(f"\nSkipping {file_name}: {e}")
            if os.path.exists(local_path):
                os.remove(local_path)

print("\nDownload complete.")

Accelerate pandas with cuDF

This is the whole intervention on the dataframe side. The magic must run before you import pandas:

python
%load_ext cudf.pandas

Now import pandas as you always would, and inspect the module to see what you actually got:

python
import pandas as pd

pd

The module repr shows a cudf.pandas proxy rather than plain pandas. Every call you make through pd is now dispatched to the GPU where cuDF supports it, and silently to CPU pandas where it does not. Your code does not change.

Load and clean

The filter on payment_type == 1 is doing real modelling work, not just tidying: tip amounts are only reliably recorded for credit card transactions, so cash trips would teach the model that a large fraction of rides get no tip.

python
import glob

# Load data into memory
df = pd.concat(
    [pd.read_parquet(f) for f in glob.glob("nyc_taxi_data/*-01.parquet")],
    ignore_index=True
)

# Filter for valid trips. We filter for payment_type=1 (credit card)
# because tip amounts are only reliably recorded for credit card transactions.
df = df[
    (df['fare_amount'] > 0) & (df['fare_amount'] < 500) &
    (df['trip_distance'] > 0) & (df['trip_distance'] < 100) &
    (df['tip_amount'] >= 0) & (df['tip_amount'] < 100) &
    (df['payment_type'] == 1)
].copy()

# Downcast numeric columns to save memory
float_cols = df.select_dtypes(include=['float64']).columns
df[float_cols] = df[float_cols].astype('float32')

int_cols = df.select_dtypes(include=['int64']).columns
df[int_cols] = df[int_cols].astype('int32')
Why downcast

Device memory is the binding constraint on a GPU, not system RAM. Halving every 64-bit column to 32-bit roughly halves the footprint of the frame, which is what lets the full dataset stay resident on a 24 GB L4.

Feature engineering

Four families of features: when the trip happened, what the fare looked like, how common the route is, and how generous the pickup neighbourhood usually is. The route-frequency and location-aggregate features are the expensive ones — a value_counts, a map, a groupby and a merge across millions of rows — and they are exactly the operations cuDF accelerates well.

python
import numpy as np

start_time = time.perf_counter()

# Time Features
df['hour'] = df['tpep_pickup_datetime'].dt.hour
df['dow'] = df['tpep_pickup_datetime'].dt.dayofweek
df['is_weekend'] = (df['dow'] >= 5).astype(int)
df['is_rush_hour'] = (
    ((df['hour'] >= 7) & (df['hour'] <= 9)) |
    ((df['hour'] >= 17) & (df['hour'] <= 19))
).astype(int)

# Fare Amount Features
df['fare_log'] = np.log1p(df['fare_amount'])
df['fare_decimal'] = (df['fare_amount'] % 1 * 100).astype(int)
df['is_round_fare'] = (df['fare_amount'] % 5 == 0).astype(int)

# Route Features
df['route_id'] = df['PULocationID'].astype(str) + '_' + df['DOLocationID'].astype(str)
route_counts = df['route_id'].value_counts()
df['route_frequency'] = df['route_id'].map(route_counts)

# Location Aggregation Features (Mean and Std Tip by Pickup Location)
pu_tip_stats = df.groupby('PULocationID')['tip_amount'].agg(['mean', 'std']).reset_index()
pu_tip_stats.columns = ['PULocationID', 'pu_tip_mean', 'pu_tip_std']
df = df.merge(pu_tip_stats, on='PULocationID', how='left')

print(f"Feature Engineering Time: {time.perf_counter() - start_time:.2f} seconds")
10

Train individual models with cross-validation

Accelerate scikit-learn with cuML

The same trick, one layer up the stack. cuml.accel patches scikit-learn estimators so that supported models execute on the GPU while you keep writing ordinary scikit-learn:

python
%load_ext cuml.accel

Features, target, and folds

python
from sklearn.model_selection import KFold

feature_cols = [
    'trip_distance', 'fare_amount', 'passenger_count',
    'hour', 'dow', 'is_weekend', 'is_rush_hour',
    'fare_log', 'fare_decimal', 'is_round_fare',
    'route_frequency', 'pu_tip_mean', 'pu_tip_std',
    'PULocationID', 'DOLocationID'
]

X = df[feature_cols].fillna(df[feature_cols].median())
y = df['tip_amount']

n_splits = 3
kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)

Each of the three models below uses the same fold split and writes its out-of-fold predictions into a full-length array. That matters: those out-of-fold vectors are what the ensemble in the next step is fitted on, and using them keeps the blend honest rather than letting it learn from predictions a model made on its own training data.

python
import numpy as np
import time
from sklearn.metrics import mean_squared_error
from tqdm.notebook import tqdm

1. XGBoost

XGBoost does not need cuml.accel — it has had native CUDA support for years. Setting tree_method='hist' and device='cuda' is enough.

python
import xgboost as xgb

start_time = time.perf_counter()

def train_xgb_cv(X, y):
    rmses = []
    preds_all = np.zeros(len(y))
    for train_idx, val_idx in tqdm(kf.split(X), total=n_splits):
        X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]

        # XGBoost handles GPU natively when tree_method='hist' and device='cuda'
        model = xgb.XGBRegressor(
            objective='reg:squarederror',
            max_depth=5,
            learning_rate=0.1,
            n_estimators=100,
            tree_method='hist',
            device='cuda',
            random_state=42
        )
        model.fit(X_train, y_train)

        preds = model.predict(X_val)
        preds_all[val_idx] = preds
        rmses.append(np.sqrt(mean_squared_error(y_val, preds)))

    return np.mean(rmses), preds_all

xgb_rmse, xgb_preds = train_xgb_cv(X, y)
print(f"\n{'XGBoost RMSE:':<20} ${xgb_rmse:.4f}")
print(f"{'Time:':<20} {time.perf_counter() - start_time:.2f} seconds")

2. Linear regression

Note what is not here: no cuml.LinearRegression import, no device argument. This is stock scikit-learn code that happens to be running on a GPU.

python
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import StandardScaler

start_time = time.perf_counter()

def train_linreg_cv(X, y):
    rmses = []
    preds_all = np.zeros(len(y))
    for train_idx, val_idx in tqdm(kf.split(X), total=n_splits):
        X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]

        # Scale features
        scaler = StandardScaler()
        X_train_scaled = scaler.fit_transform(X_train)
        X_val_scaled = scaler.transform(X_val)

        # Automatically accelerated by cuML
        model = LinearRegression()
        model.fit(X_train_scaled, y_train)

        preds = model.predict(X_val_scaled)
        preds_all[val_idx] = preds
        rmses.append(np.sqrt(mean_squared_error(y_val, preds)))

    return np.mean(rmses), preds_all

linreg_rmse, linreg_preds = train_linreg_cv(X, y)
print(f"\n{'Linear Reg RMSE:':<20} ${linreg_rmse:.4f}")
print(f"{'Time:':<20} {time.perf_counter() - start_time:.2f} seconds")

3. Random forest

This is the model where the GPU earns its keep. Building 100 trees to depth 10 over millions of rows is the slowest stage of the CPU pipeline by a wide margin.

python
from sklearn.ensemble import RandomForestRegressor

start_time = time.perf_counter()

def train_rf_cv(X, y):
    rmses = []
    preds_all = np.zeros(len(y))
    for train_idx, val_idx in tqdm(kf.split(X), total=n_splits):
        X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]

        # Automatically accelerated by cuML
        model = RandomForestRegressor(
            n_estimators=100,
            max_depth=10,
            n_jobs=-1,
            max_features='sqrt',
            random_state=42
        )
        model.fit(X_train, y_train)

        preds = model.predict(X_val)
        preds_all[val_idx] = preds
        rmses.append(np.sqrt(mean_squared_error(y_val, preds)))

    return np.mean(rmses), preds_all

rf_rmse, rf_preds = train_rf_cv(X, y)
print(f"\n{'Random Forest RMSE:':<20} ${rf_rmse:.4f}")
print(f"{'Time:':<20} {time.perf_counter() - start_time:.2f} seconds")
11

Evaluate the end-to-end pipeline

You now have three out-of-fold prediction vectors covering every row. The three models make different mistakes — the linear model misses non-linear structure, the forest and the boosted trees disagree on the tails — so a weighted blend of them should beat any one alone.

Rather than guessing the weights, fit them. A linear regression on the three prediction columns, constrained to positive coefficients and no intercept, finds the best blend directly:

python
ensemble_weights = LinearRegression(positive=True, fit_intercept=False).fit(
    np.c_[xgb_preds, rf_preds, linreg_preds], y
).coef_

# Normalize weights
ensemble_weights = ensemble_weights / ensemble_weights.sum()

ensemble_preds = np.c_[xgb_preds, rf_preds, linreg_preds] @ ensemble_weights
ensemble_rmse = np.sqrt(mean_squared_error(y, ensemble_preds))

positive=True keeps every model contributing in the same direction, and fit_intercept=False stops the blend from adding a constant offset to every prediction. Normalizing makes the weights read as shares that sum to one.

Now print the comparison:

python
print(f"\n{'Model':<20} {'RMSE':>10}")
print("-" * 32)
print(f"{'Linear Regression':<20} ${linreg_rmse:>9.4f}")
print(f"{'Random Forest':<20} ${rf_rmse:>9.4f}")
print(f"{'XGBoost':<20} ${xgb_rmse:>9.4f}")
print("-" * 32)
print(f"{'Ensemble':<20} ${ensemble_rmse:>9.4f}")
print(f"\nEnsemble lift: ${xgb_rmse - ensemble_rmse:.4f}")

RMSE here is in dollars of tip, so the numbers are directly interpretable: a value of 1.05 means the model's tip prediction is typically off by about a dollar. The final line reports the ensemble lift — how many cents of error the blend removes relative to the best single model.

What to expect

The ensemble should land at or below the best individual model, and the gain is usually small but real. A blend that is worse than its best member is a signal that the out-of-fold predictions were built with mismatched folds.

12

Compare CPU vs. GPU performance

So far you have taken the acceleration on faith. This step measures it: the same pipeline function, run twice in one session — once through plain pandas and scikit-learn, once with both magics loaded — timed stage by stage.

Restart the kernel first

The magics are sticky. To get a clean CPU baseline you need a session where cudf.pandas was never loaded, so restart before you begin:

python
import IPython
IPython.Application.instance().kernel.do_shutdown(True)

Define the pipeline once

The function takes the pandas module as an argument. That is the design trick that makes the comparison fair: the same body runs on both backends, and the only difference is which pandas gets passed in.

Timed stages
01Load Dataread_parquet
02Clean Datafilter + downcast
03Feature Engineeringdt / log1p
04Train Random Forest100 trees
05Train XGBoost100 rounds
python
def run_ml_pipeline(pd_module, use_gpu=False):
    import time
    import glob
    import numpy as np
    from sklearn.ensemble import RandomForestRegressor
    import xgboost as xgb

    timings = {}

    # 1. Load Data
    t0 = time.perf_counter()
    df = pd_module.concat(
        [pd_module.read_parquet(f) for f in glob.glob("nyc_taxi_data/*-01.parquet")],
        ignore_index=True
    )
    timings['Load Data'] = time.perf_counter() - t0

    # 2. Clean Data
    t0 = time.perf_counter()
    df = df[
        (df['fare_amount'] > 0) & (df['fare_amount'] < 500) &
        (df['trip_distance'] > 0) & (df['trip_distance'] < 100) &
        (df['tip_amount'] >= 0) & (df['tip_amount'] < 100) &
        (df['payment_type'] == 1)
    ].copy()

    # Downcast numeric columns to save memory
    float_cols = df.select_dtypes(include=['float64']).columns
    df[float_cols] = df[float_cols].astype('float32')
    int_cols = df.select_dtypes(include=['int64']).columns
    df[int_cols] = df[int_cols].astype('int32')
    timings['Clean Data'] = time.perf_counter() - t0

    # 3. Feature Engineering
    t0 = time.perf_counter()
    df['hour'] = df['tpep_pickup_datetime'].dt.hour
    df['dow'] = df['tpep_pickup_datetime'].dt.dayofweek
    df['is_weekend'] = (df['dow'] >= 5).astype(int)
    df['fare_log'] = np.log1p(df['fare_amount'])
    timings['Feature Engineering'] = time.perf_counter() - t0

    # 4. Modeling Prep
    feature_cols = ['trip_distance', 'fare_amount', 'passenger_count', 'hour', 'dow', 'is_weekend', 'fare_log']
    X = df[feature_cols].fillna(df[feature_cols].median())
    y = df['tip_amount'].copy()

    # Free memory we no longer need
    del df
    import gc
    gc.collect()

    # 5. Train Random Forest
    t0 = time.perf_counter()
    rf_model = RandomForestRegressor(
        n_estimators=100,
        max_depth=10,
        n_jobs=-1,
        max_features='sqrt',
        random_state=42
    ).fit(X, y)
    timings['Train Random Forest'] = time.perf_counter() - t0

    # 6. Train XGBoost
    t0 = time.perf_counter()
    params = {
        'objective': 'reg:squarederror',
        'max_depth': 5,
        'n_estimators': 100,
        'random_state': 42
    }
    if use_gpu:
        params['device'] = 'cuda'
        params['tree_method'] = 'hist'
    xgb_model = xgb.XGBRegressor(**params).fit(X, y)
    timings['Train XGBoost'] = time.perf_counter() - t0

    del X
    del y
    gc.collect()

    return timings

Run on the CPU

Be patient

This is the slow one. On CPU, the random forest stage alone can run for many minutes — that wait is the thing the rest of the lab is designed to remove.

python
import pandas as pd

print("Running pipeline on CPU...")
cpu_times = run_ml_pipeline(pd, use_gpu=False)
print("CPU Execution Finished.")

Run on the GPU

Load both magics, re-import pandas so the name binds to the accelerated proxy, and run the identical function with use_gpu=True. The small shim at the top guards against a version mismatch between IPython and the cuDF extension.

python
import IPython.core.magic
if not hasattr(IPython.core.magic, 'output_can_be_silenced'):
    IPython.core.magic.output_can_be_silenced = lambda x: x

%load_ext cudf.pandas
%load_ext cuml.accel
import pandas as pd

print("Running pipeline on GPU...")
gpu_times = run_ml_pipeline(pd, use_gpu=True)
print("GPU Execution Finished.")

Visualize the speedup

Grouped bars, CPU against GPU, one pair per stage — plus the overall ratio.

python
import matplotlib.pyplot as plt
import numpy as np

labels = list(cpu_times.keys())
cpu_values = list(cpu_times.values())
gpu_values = list(gpu_times.values())

x = np.arange(len(labels))
width = 0.35

fig, ax = plt.subplots(figsize=(10, 6))
rects1 = ax.bar(x - width/2, cpu_values, width, label='CPU', color='#4285F4')
rects2 = ax.bar(x + width/2, gpu_values, width, label='GPU', color='#76B900')

ax.set_ylabel('Execution Time (seconds)')
ax.set_title('NYC Taxi ML Pipeline: CPU vs. GPU Performance')
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=45, ha="right")
ax.legend()

# Add data labels
def autolabel(rects):
    for rect in rects:
        height = rect.get_height()
        ax.annotate(f'{height:.2f}s',
                    xy=(rect.get_x() + rect.get_width() / 2, height),
                    xytext=(0, 3),
                    textcoords="offset points",
                    ha='center', va='bottom', fontsize=9)
autolabel(rects1)
autolabel(rects2)

plt.tight_layout()
plt.show()

# Calculate overall speedup
total_cpu_time = sum(cpu_values)
total_gpu_time = sum(gpu_values)
overall_speedup = total_cpu_time / total_gpu_time
print(f"\nOverall Pipeline Speedup: {overall_speedup:.2f}x faster on GPU!")
Example output from the original codelab run &mdash; your own timings will differ.
Fig 12.1Example output from the original codelab run — your own timings will differ.

Read the chart per stage, not just in aggregate. The training stages typically show the largest gaps; loading Parquet is partly I/O-bound and will show a smaller one. Treat the figure above as an illustration of the shape of the result, not a target: your exact numbers depend on the machine type, the region and how many monthly files you downloaded.

13

Profile your code to find performance constraints

cuDF falls back to CPU pandas whenever it meets an operation it cannot execute on the device. That is what makes the drop-in behaviour possible, and it is also the main way a “GPU” notebook ends up slow: one innocuous line silently pulls the whole frame back to host memory. The profilers tell you which line.

Where does the time go? %%cudf.pandas.profile

A cell magic that reports, per operation, whether it ran on GPU or CPU.

python
%%cudf.pandas.profile

import glob
import pandas as pd

df = pd.concat([pd.read_parquet(f) for f in glob.glob("nyc_taxi_data/*-01.parquet")], ignore_index=True)

summary = (
    df
      .groupby(['PULocationID', 'payment_type'])
      [['passenger_count', 'fare_amount', 'tip_amount']]
      .agg(['min', 'mean', 'max'])
)
The profile report: each operation with the time it spent on GPU and on CPU.
Fig 13.1The profile report: each operation with the time it spent on GPU and on CPU.

Which line is the problem? %%cudf.pandas.line_profile

The same idea at line granularity. This cell is built to show the failure mode directly: it computes the same column twice, once the slow way and once the fast way.

python
%%cudf.pandas.line_profile

import glob
import pandas as pd

df = pd.concat([pd.read_parquet(f) for f in glob.glob("nyc_taxi_data/*-01.parquet")], ignore_index=True)
df = df.sample(1_000)

# Iterating row-by-row or using custom python apply functions often falls back to the CPU
def categorize_hour(hour):
    if hour < 12:
        return 'Morning'
    else:
        return 'Afternoon/Evening'

df['hour'] = df['tpep_pickup_datetime'].dt.hour

df['time_of_day_slow'] = df['hour'].apply(categorize_hour)

# Using vectorized pandas operations (like pd.cut) stays entirely on the GPU
cut_bins = [-1, 11, 24]
cut_labels = ['Morning', 'Afternoon/Evening']
df['time_of_day_fast'] = pd.cut(df['hour'], bins=cut_bins, labels=cut_labels)
The line profile: the apply() line records CPU time, while the pd.cut line stays on the GPU.
Fig 13.2The line profile: the apply() line records CPU time, while the pd.cut line stays on the GPU.

In the line report, .apply() with a Python function shows CPU time: cuDF cannot compile an arbitrary Python callable to run on the device, so it moves the column to the host, loops in the interpreter, and moves the result back. pd.cut expresses the identical logic as a vectorized binning operation and stays on the GPU throughout.

The rule of thumb

If a line passes a Python function into pandas — apply, map with a lambda, iterrows — assume it is running on the CPU and look for the vectorized equivalent. That single habit accounts for most of the gap between an accelerated notebook and a fast one.

14

Clean up

Do not skip this

A Colab Enterprise runtime with an L4 attached bills for as long as it exists, whether or not you are running cells. The idle shutdown you configured is a safety net, not a substitute for deleting the runtime.

Delete the downloaded data

python
print("Deleting local 'nyc_taxi_data' directory...")
!rm -rf nyc_taxi_data
print("Local files deleted.")

Shut down the runtime

Colab Enterprise › Runtimes
  1. Select the Region your runtime is in.
  2. Select the runtime you want to delete.
  3. Click Delete, then Confirm.

Delete the notebook

Colab Enterprise › My notebooks
  1. Select the Region your notebook is in.
  2. Select the notebook you want to delete.
  3. Click Delete, then Confirm.

If you created the project solely for this lab, deleting the whole project is the most thorough way to guarantee nothing keeps billing.

15

Congratulations

You took a conventional pandas and scikit-learn workflow and moved it onto a GPU with two magic commands and two XGBoost parameters — then measured the result rather than assuming it. That measurement habit is the transferable part: acceleration is only real once you have seen the per-stage timings and confirmed nothing important is falling back to the CPU.

What you covered

  • ✓Set up Colab Enterprise on Google Cloud and understood where it fits alongside Agent Platform Workbench
  • ✓Built a reusable runtime template with an NVIDIA L4 GPU, a g2-standard-4 machine, and an idle shutdown
  • ✓Applied GPU acceleration to a tip-prediction task over millions of NYC taxi records
  • ✓Accelerated pandas with NVIDIA cuDF without changing a line of dataframe code
  • ✓Accelerated scikit-learn with NVIDIA cuML without changing a line of modelling code
  • ✓Measured CPU against GPU stage by stage, and profiled to find the operations that fall back to the CPU

Reference documentation