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
pandaswith NVIDIAcuDF, with no code changes - How to accelerate
scikit-learnwith NVIDIAcuML, 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
The NYC Yellow Taxi trip records are downloaded for you inside the notebook — there is nothing to prepare in advance.
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 changes | Why 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.
Setup and requirements
Potential costs
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.
First point the shell at the project you intend to use, replacing the placeholder with your own project ID:
gcloud config set project <PROJECT_ID>Then enable the four APIs this lab depends on. Enabling can take a minute or two to propagate.
gcloud services enable \
compute.googleapis.com \
dataform.googleapis.com \
notebooks.googleapis.com \
aiplatform.googleapis.comIf gcloud asks you to authorize the call, accept it — Cloud Shell needs your credentials to change project configuration.
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.
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
Fill in the sections as follows.
| Setting | Value |
|---|---|
| Display name | gpu-template |
| Region | Your preferred region — it must have L4 capacity |
| Machine type | g2-standard-4 |
| Accelerator type | NVIDIA L4 |
| Accelerator count | 1 |
| Idle shutdown | 60 minutes |
| Python version | 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.
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.
- In the IAM & Admin menu on the left, scroll down to Cloud administration and open Quotas & System Limits.
- In the Filter box, search for
GPUS_ALL_REGIONS. - In the matching row, click the three-dot menu on the right and choose Edit quota.
- Set New value to
1and submit the request.
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.
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.
Start a runtime
The template describes a machine; a runtime is an actual machine running from it.
- Under runtime template, select the gpu-template you just created.
- Click Create.
Wait for the runtime to finish provisioning. Allocating a GPU-backed VM usually takes several minutes.
Once it finishes, the runtime shows as available and is ready to accept a notebook connection.
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.
Set up the notebook
Import the notebook
Choose the URL option and paste in the notebook source:
Click Import. The notebook is added to your list.
Connect to the runtime
- Open the imported notebook.
- Click the down arrow next to Connect.
- Select Connect to a runtime.
- Pick the runtime you created in the previous step from the dropdown.
- Click Connect.
Built-in dependencies
Nothing to install. The Colab Enterprise image already contains the acceleration stack this lab uses:
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:
nvidia-smiRead 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.
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:
%load_ext cudf.pandasNow import pandas as you always would, and inspect the module to see what you actually got:
import pandas as pd
pdThe 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.
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')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.
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")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:
%load_ext cuml.accelFeatures, target, and folds
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.
import numpy as np
import time
from sklearn.metrics import mean_squared_error
from tqdm.notebook import tqdm1. XGBoost
XGBoost does not need cuml.accel — it has had native CUDA support for
years. Setting tree_method='hist' and device='cuda' is enough.
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.
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.
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")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:
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:
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.
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.
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:
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.
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 timingsRun on the CPU
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.
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.
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.
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!")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.
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.
%%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'])
)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.
%%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)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.
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.
Clean up
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
print("Deleting local 'nyc_taxi_data' directory...")
!rm -rf nyc_taxi_data
print("Local files deleted.")Shut down the runtime
- Select the Region your runtime is in.
- Select the runtime you want to delete.
- Click Delete, then Confirm.
Delete the notebook
- Select the Region your notebook is in.
- Select the notebook you want to delete.
- 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.
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-4machine, and an idle shutdown - ✓Applied GPU acceleration to a tip-prediction task over millions of NYC taxi records
- ✓Accelerated
pandaswith NVIDIA cuDF without changing a line of dataframe code - ✓Accelerated
scikit-learnwith 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