|
| 1 | +<!-- |
| 2 | + Copyright 2023–2026 Google LLC |
| 3 | +
|
| 4 | + Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + you may not use this file except in compliance with the License. |
| 6 | + You may obtain a copy of the License at |
| 7 | +
|
| 8 | + https://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +
|
| 10 | + Unless required by applicable law or agreed to in writing, software |
| 11 | + distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + See the License for the specific language governing permissions and |
| 14 | + limitations under the License. |
| 15 | + --> |
| 16 | + |
| 17 | +# LoRA Fine-tuning on multi-host TPUs |
| 18 | + |
| 19 | +**Low-Rank Adaptation (LoRA)** is a Parameter-Efficient Fine-Tuning (PEFT) technique designed to optimize large language models while minimizing resource consumption. |
| 20 | + |
| 21 | +Unlike traditional full-parameter fine-tuning, LoRA: |
| 22 | + |
| 23 | +- **Freezes the pre-trained model weights**, preserving the original knowledge. |
| 24 | +- **Injects trainable rank decomposition matrices** into the Transformer layers. |
| 25 | + |
| 26 | +This tutorial provides step-by-step instructions for setting up the multi-host TPU environment and performing LoRA fine-tuning on a Hugging Face dataset using MaxText. In this tutorial we use a multi-host TPU such as `v6e-256`. |
| 27 | + |
| 28 | +We use [Tunix](https://github.com/google/tunix), a JAX-based library, to power these post-training tasks. |
| 29 | + |
| 30 | +Let's get started! |
| 31 | + |
| 32 | +## Prerequisites |
| 33 | + |
| 34 | +Before starting, ensure you have: |
| 35 | + |
| 36 | +- Access to a Google Cloud Project with TPU quotas. |
| 37 | +- A Hugging Face account with an access token for downloading models. |
| 38 | +- Permissions for Google Artifact Registry (Artifact Registry Writer role). |
| 39 | +- Prerequisites for XPK installed (follow [official documentation](https://github.com/AI-Hypercomputer/xpk/blob/main/docs/installation.md#1-prerequisites)). |
| 40 | +- A Pathways-ready GKE cluster (see [create GKE cluster](https://docs.cloud.google.com/ai-hypercomputer/docs/workloads/pathways-on-cloud/create-gke-cluster)). |
| 41 | +- **Docker** installed and configured for sudoless use. Follow the steps to [configure sudoless Docker](https://docs.docker.com/engine/install/linux-postinstall/). |
| 42 | + |
| 43 | +## Build and upload MaxText Docker image |
| 44 | + |
| 45 | +For instructions on building and uploading the MaxText Docker image with post-training dependencies, please refer to the [official documentation](https://maxtext.readthedocs.io/en/latest/build_maxtext.html). |
| 46 | + |
| 47 | +## Create GKE cluster |
| 48 | + |
| 49 | +Use a pathways ready GKE cluster as described [here](https://docs.cloud.google.com/ai-hypercomputer/docs/workloads/pathways-on-cloud/create-gke-cluster). |
| 50 | + |
| 51 | +## Environment configuration |
| 52 | + |
| 53 | +Set up the following environment variables to configure your training run. Replace placeholders with your actual values. |
| 54 | + |
| 55 | +```bash |
| 56 | +# -- Model configuration -- |
| 57 | +# The MaxText model name. See `src/maxtext/configs/types.py` for `ModelName` for a |
| 58 | +# full list of supported models. |
| 59 | +export MODEL=<MODEL_NAME> # e.g., 'gemma4-26b' |
| 60 | + |
| 61 | +# Your Hugging Face access token. Required to download gated models like Gemma. |
| 62 | +# You can generate one at https://huggingface.co/settings/tokens. |
| 63 | +export HF_TOKEN=<HF_TOKEN> |
| 64 | + |
| 65 | +# -- MaxText configuration -- |
| 66 | +# Use a GCS bucket you own to store logs and checkpoints. Ideally in the same |
| 67 | +# region as your TPUs to minimize latency and costs. |
| 68 | +# You can list your buckets and their locations in the |
| 69 | +# [Cloud Console](https://console.cloud.google.com/storage/browser) or via |
| 70 | +# `gcloud storage buckets list --format="table(name, location)"`. |
| 71 | +export BASE_OUTPUT_DIRECTORY=<GCS_BUCKET> # e.g., gs://my-bucket/maxtext-runs |
| 72 | + |
| 73 | +# An arbitrary string to identify this specific run. |
| 74 | +# We recommend to include the model, user, and timestamp. |
| 75 | +# Note: Kubernetes requires workload names to be valid DNS labels (lowercase, no underscores or periods). |
| 76 | +export RUN_NAME=<RUN_NAME> |
| 77 | + |
| 78 | +# -- Workload configuration -- |
| 79 | +# Your GCP project ID. Find it on the [Cloud Console Dashboard](https://console.cloud.google.com/home/dashboard). |
| 80 | +# If you've already set it in your local config, you can retrieve it via: |
| 81 | +# gcloud config get-value project |
| 82 | +export PROJECT_ID=<PROJECT_ID> |
| 83 | + |
| 84 | +# The GCP location (listed as "Location" in the UI) and name of your |
| 85 | +# TPU-enabled GKE cluster. Both can be found on the |
| 86 | +# [Cloud Console](https://console.cloud.google.com/kubernetes/list). |
| 87 | +export ZONE=<ZONE> # e.g., 'us-central1' |
| 88 | +export GKE_CLUSTER=<CLUSTER_NAME> |
| 89 | + |
| 90 | +# For a full list of MaxText-supported TPU types, see: `src/maxtext/utils/accelerator_to_spec_map.py`. To see the TPU type |
| 91 | +# of your cluster: |
| 92 | + |
| 93 | +# 1. Connect to the cluster (required for kubectl commands later): |
| 94 | +# gcloud container clusters get-credentials ${GKE_CLUSTER?} --location ${ZONE?} --project ${PROJECT_ID?} |
| 95 | + |
| 96 | +# 2. Find your TPU type (e.g., 'v6e-256') by checking the accelerator labels on your nodes: |
| 97 | +# kubectl get nodes -l cloud.google.com/gke-tpu-accelerator -o jsonpath='{.items[*].metadata.labels.cloud\.google\.com/gke-tpu-accelerator}' | tr ' ' '\n' | sort -u |
| 98 | +export TPU_TYPE=<TPU_TYPE> |
| 99 | +export NUM_SLICES=<NUM_SLICES> |
| 100 | + |
| 101 | +# The Docker image you pushed in the prerequisite step |
| 102 | +export CLOUD_IMAGE_NAME=<IMAGE_NAME> |
| 103 | +export DOCKER_IMAGE="gcr.io/${PROJECT_ID?}/${CLOUD_IMAGE_NAME?}" |
| 104 | + |
| 105 | +# -- Fine-Tuning configuration -- |
| 106 | +export STEPS=<STEPS> # e.g., 1000 |
| 107 | +export PER_DEVICE_BATCH_SIZE=<BATCH_SIZE_PER_DEVICE> # e.g., 1 |
| 108 | +export LORA_RANK=<LORA_RANK> # e.g., 16 |
| 109 | +export LORA_ALPHA=<LORA_ALPHA> # e.g., 32.0 |
| 110 | +export LEARNING_RATE=<LEARNING_RATE> # e.g., 3e-6 |
| 111 | +export MAX_TARGET_LENGTH=<MAX_TARGET_LENGTH> # e.g., 1024 |
| 112 | + |
| 113 | +# -- Dataset configuration -- |
| 114 | +export DATASET_NAME=<DATASET_NAME> # e.g., openai/gsm8k |
| 115 | +export TRAIN_SPLIT=<TRAIN_SPLIT> # e.g., train |
| 116 | +export HF_DATA_DIR=<DATASET_PATH> # e.g., main |
| 117 | +export TRAIN_DATA_COLUMNS=<DATA_COLUMNS> # e.g., ['question','answer'] |
| 118 | + |
| 119 | +# -- LoRA Conversion configuration (Optional) -- |
| 120 | +export HF_LORA_ADAPTER_PATH=<HF_LORA_ADAPTER_PATH> # e.g., 'username/adapter-name' |
| 121 | +``` |
| 122 | + |
| 123 | +## Customizing Trainable Layers (Optional) |
| 124 | + |
| 125 | +By default, MaxText determines which layers to apply LoRA to based on the model's architecture by reading `src/maxtext/configs/post_train/lora_module_path.yml`. |
| 126 | + |
| 127 | +If you need to fine-tune specific components (e.g., targeting only Attention layers to optimize memory usage), you can override these defaults through the following hierarchy: |
| 128 | + |
| 129 | +### Configuration Hierarchy |
| 130 | + |
| 131 | +1. **Command Line Argument**: Pass the `lora_module_path` argument directly in your training command. |
| 132 | +2. **Task-Specific Config (`sft.yml`)**: Define the `lora_module_path` parameter in `src/maxtext/configs/post_train/sft.yml`. |
| 133 | +3. **Global Defaults**: Automatic detection via the model-to-regex mapping defined in `lora_module_path.yml`. |
| 134 | + |
| 135 | +## Get MaxText model checkpoint |
| 136 | + |
| 137 | +This section explains how to prepare your model checkpoint for use with MaxText. You have two options: using an existing MaxText checkpoint or converting a Hugging Face checkpoint. |
| 138 | + |
| 139 | +### Option 1: Using an existing MaxText checkpoint |
| 140 | + |
| 141 | +If you already have a MaxText-compatible model checkpoint, simply set the following environment variable and move on to the next section. |
| 142 | + |
| 143 | +```bash |
| 144 | +export MAXTEXT_CKPT_PATH=<CKPT_PATH> # e.g., gs://my-bucket/my-model-checkpoint/0/items |
| 145 | +``` |
| 146 | + |
| 147 | +**Note:** Make sure that `MAXTEXT_CKPT_PATH` has the checkpoints created using the correct storage flags: |
| 148 | + |
| 149 | +```bash |
| 150 | +export USE_PATHWAYS=0 # Set to 1 for Pathways, 0 for McJAX. |
| 151 | +checkpoint_storage_use_zarr3=$((1 - USE_PATHWAYS)) |
| 152 | +checkpoint_storage_use_ocdbt=$((1 - USE_PATHWAYS)) |
| 153 | +``` |
| 154 | + |
| 155 | +### Option 2: Converting a Hugging Face checkpoint |
| 156 | + |
| 157 | +Refer to the steps in [Hugging Face to MaxText](https://maxtext.readthedocs.io/en/maxtext-v0.2.1/guides/checkpointing_solutions/convert_checkpoint.html#hugging-face-to-maxtext) to convert a hugging face checkpoint to MaxText. Make sure you have correct checkpoint files converted and saved. Similar as Option 1, you can set the following environment and move on. |
| 158 | + |
| 159 | +```bash |
| 160 | +export MAXTEXT_CKPT_PATH=<CKPT_PATH> # gs://my-bucket/my-checkpoint-directory/0/items |
| 161 | +``` |
| 162 | + |
| 163 | +## Submit workload on GKE cluster |
| 164 | + |
| 165 | +This section provides the command to run LoRA Fine-Tuning on a GKE cluster. |
| 166 | + |
| 167 | +### Run a Fresh LoRA Fine-Tuning on Hugging Face Dataset |
| 168 | + |
| 169 | +#### LoRA with Multi-Controller JAX (McJAX) |
| 170 | + |
| 171 | +```bash |
| 172 | +xpk workload create \ |
| 173 | +--cluster=${GKE_CLUSTER?} \ |
| 174 | +--project=${PROJECT_ID?} \ |
| 175 | +--zone=${ZONE?} \ |
| 176 | +--docker-image=${DOCKER_IMAGE?} \ |
| 177 | +--workload=${RUN_NAME?} \ |
| 178 | +--tpu-type=${TPU_TYPE?} \ |
| 179 | +--num-slices=${NUM_SLICES?} \ |
| 180 | +--command="python3 -m maxtext.trainers.post_train.sft.train_sft run_name=${RUN_NAME?} base_output_directory=${BASE_OUTPUT_DIRECTORY?} model_name=${MODEL?} load_parameters_path=${MAXTEXT_CKPT_PATH?} hf_access_token=${HF_TOKEN?} hf_path=${DATASET_NAME?} train_split=${TRAIN_SPLIT?} hf_data_dir=${HF_DATA_DIR?} train_data_columns=${TRAIN_DATA_COLUMNS?} steps=${STEPS?} per_device_batch_size=${PER_DEVICE_BATCH_SIZE?} max_target_length=${MAX_TARGET_LENGTH?} learning_rate=${LEARNING_RATE?} chat_template_path=${CHAT_TEMPLATE_PATH?} enable_nnx=True pure_nnx_decoder=True lora.enable_lora=True lora.lora_rank=${LORA_RANK?} lora.lora_alpha=${LORA_ALPHA?}" |
| 181 | +``` |
| 182 | + |
| 183 | +Once the fine-tuning is completed, you can access your model checkpoints at `${BASE_OUTPUT_DIRECTORY}/${RUN_NAME/checkpoints`. |
| 184 | + |
| 185 | +#### LoRA with Pathways |
| 186 | + |
| 187 | +```bash |
| 188 | +export USE_PATHWAYS=1 |
| 189 | + |
| 190 | +xpk workload create-pathways \ |
| 191 | +--cluster=${GKE_CLUSTER?} \ |
| 192 | +--project=${PROJECT_ID?} \ |
| 193 | +--zone=${ZONE?} \ |
| 194 | +--docker-image=${DOCKER_IMAGE?} \ |
| 195 | +--workload=${RUN_NAME?} \ |
| 196 | +--tpu-type=${TPU_TYPE?} \ |
| 197 | +--num-slices=${NUM_SLICES?} \ |
| 198 | +--command="JAX_PLATFORMS=proxy JAX_BACKEND_TARGET=grpc://127.0.0.1:29000 ENABLE_PATHWAYS_PERSISTENCE=1 python3 -m maxtext.trainers.post_train.sft.train_sft run_name=${RUN_NAME?} base_output_directory=${BASE_OUTPUT_DIRECTORY?} model_name=${MODEL?} load_parameters_path=${MAXTEXT_CKPT_PATH?} hf_access_token=${HF_TOKEN?} hf_path=${DATASET_NAME?} train_split=${TRAIN_SPLIT?} hf_data_dir=${HF_DATA_DIR?} train_data_columns=${TRAIN_DATA_COLUMNS?} steps=${STEPS?} per_device_batch_size=${PER_DEVICE_BATCH_SIZE?} max_target_length=${MAX_TARGET_LENGTH?} learning_rate=${LEARNING_RATE?} chat_template_path=${CHAT_TEMPLATE_PATH?} enable_nnx=True pure_nnx_decoder=True lora.enable_lora=True lora.lora_rank=${LORA_RANK?} lora.lora_alpha=${LORA_ALPHA?} checkpoint_storage_use_zarr3=$((1 - USE_PATHWAYS)) checkpoint_storage_use_ocdbt=$((1 - USE_PATHWAYS)) enable_single_controller=True" |
| 199 | +``` |
| 200 | + |
| 201 | +Once the fine-tuning is completed, you can access your model checkpoints at `${BASE_OUTPUT_DIRECTORY}/${RUN_NAME}/checkpoints`. |
| 202 | + |
| 203 | +### (Optional) Resume from a previous LoRA checkpoint |
| 204 | + |
| 205 | +If you want to resume training from a previous run or further fine-tune an existing LoRA adapter, you can specify the LoRA checkpoint path. |
| 206 | + |
| 207 | +#### Step 1: Convert HF LoRA adapter to MaxText format with Multi-Controller JAX (McJAX) |
| 208 | + |
| 209 | +If your LoRA adapter is currently in Hugging Face format, you must convert it to MaxText format before it can be loaded. Use the integrated conversion utility: |
| 210 | + |
| 211 | +```sh |
| 212 | +xpk workload create \ |
| 213 | +--cluster=${GKE_CLUSTER?} \ |
| 214 | +--project=${PROJECT_ID?} \ |
| 215 | +--zone=${ZONE?} \ |
| 216 | +--docker-image=${DOCKER_IMAGE?} \ |
| 217 | +--workload=${RUN_NAME?} \ |
| 218 | +--tpu-type=${TPU_TYPE?} \ |
| 219 | +--num-slices=${NUM_SLICES?} \ |
| 220 | +--command="python3 -m maxtext.checkpoint_conversion.to_maxtext model_name=${MODEL?} hf_lora_adapter_path=${HF_LORA_ADAPTER_PATH?} base_output_directory=${BASE_OUTPUT_DIRECTORY?}/converted_adapter hf_access_token=${HF_TOKEN?} hardware=cpu skip_jax_distributed_system=True" |
| 221 | +``` |
| 222 | + |
| 223 | +#### Step 2: Set the restore path |
| 224 | + |
| 225 | +Point `LORA_RESTORE_PATH` to the converted MaxText adapter directory (the directory containing the `0/items` or Orbax files). |
| 226 | + |
| 227 | +- **load_parameters_path**: Points to the frozen base model weights (the original model). |
| 228 | +- **lora_restore_path**: Points to the previous LoRA adapter weights you wish to load. |
| 229 | + |
| 230 | +```sh |
| 231 | +export LORA_RESTORE_PATH=<LORA_RESTORE_PATH> # e.g., gs://my-bucket/run-1/checkpoints/0/items or /path/to/run-1/checkpoints/0/items |
| 232 | +``` |
| 233 | + |
| 234 | +#### Step 3-1: Run LoRA Fine-Tuning with the Restore Path through Multi-Controller JAX (McJAX) |
| 235 | + |
| 236 | +Once your environment variables and checkpoints are ready, you can start the LoRA fine-tuning process. |
| 237 | + |
| 238 | +Execute the following command to begin training: |
| 239 | + |
| 240 | +```sh |
| 241 | +xpk workload create \ |
| 242 | +--cluster=${GKE_CLUSTER?} \ |
| 243 | +--project=${PROJECT_ID?} \ |
| 244 | +--zone=${ZONE?} \ |
| 245 | +--docker-image=${DOCKER_IMAGE?} \ |
| 246 | +--workload=${RUN_NAME?} \ |
| 247 | +--tpu-type=${TPU_TYPE?} \ |
| 248 | +--num-slices=${NUM_SLICES?} \ |
| 249 | +--command="python3 -m maxtext.trainers.post_train.sft.train_sft run_name=${RUN_NAME?} base_output_directory=${BASE_OUTPUT_DIRECTORY?} model_name=${MODEL?} load_parameters_path=${MAXTEXT_CKPT_PATH?} hf_access_token=${HF_TOKEN?} hf_path=${DATASET_NAME?} train_split=${TRAIN_SPLIT?} hf_data_dir=${HF_DATA_DIR?} train_data_columns=${TRAIN_DATA_COLUMNS?} steps=${STEPS?} per_device_batch_size=${PER_DEVICE_BATCH_SIZE?} max_target_length=${MAX_TARGET_LENGTH?} lora.lora_restore_path=${LORA_RESTORE_PATH?} learning_rate=${LEARNING_RATE?} chat_template_path=${CHAT_TEMPLATE_PATH?} enable_nnx=True pure_nnx_decoder=True lora.enable_lora=True lora.lora_rank=${LORA_RANK?} lora.lora_alpha=${LORA_ALPHA?}" |
| 250 | +``` |
| 251 | + |
| 252 | +#### Step 3-2: Run LoRA Fine-Tuning with the Restore Path through Pathways |
| 253 | + |
| 254 | +```bash |
| 255 | +export USE_PATHWAYS=1 |
| 256 | + |
| 257 | +xpk workload create-pathways \ |
| 258 | +--cluster=${GKE_CLUSTER?} \ |
| 259 | +--project=${PROJECT_ID?} \ |
| 260 | +--zone=${ZONE?} \ |
| 261 | +--docker-image=${DOCKER_IMAGE?} \ |
| 262 | +--workload=${RUN_NAME?} \ |
| 263 | +--tpu-type=${TPU_TYPE?} \ |
| 264 | +--num-slices=${NUM_SLICES?} \ |
| 265 | +--command="JAX_PLATFORMS=proxy JAX_BACKEND_TARGET=grpc://127.0.0.1:29000 ENABLE_PATHWAYS_PERSISTENCE=1 python3 -m maxtext.trainers.post_train.sft.train_sft run_name=${RUN_NAME?} base_output_directory=${BASE_OUTPUT_DIRECTORY?} model_name=${MODEL?} load_parameters_path=${MAXTEXT_CKPT_PATH?} hf_access_token=${HF_TOKEN?} hf_path=${DATASET_NAME?} train_split=${TRAIN_SPLIT?} hf_data_dir=${HF_DATA_DIR?} train_data_columns=${TRAIN_DATA_COLUMNS?} steps=${STEPS?} per_device_batch_size=${PER_DEVICE_BATCH_SIZE?} max_target_length=${MAX_TARGET_LENGTH?} lora.lora_restore_path=${LORA_RESTORE_PATH?} learning_rate=${LEARNING_RATE?} chat_template_path=${CHAT_TEMPLATE_PATH?} enable_nnx=True pure_nnx_decoder=True lora.enable_lora=True lora.lora_rank=${LORA_RANK?} lora.lora_alpha=${LORA_ALPHA?} checkpoint_storage_use_zarr3=$((1 - USE_PATHWAYS)) checkpoint_storage_use_ocdbt=$((1 - USE_PATHWAYS)) enable_single_controller=True" |
| 266 | +``` |
| 267 | + |
| 268 | +Your fine-tuned model checkpoints will be saved here: `$BASE_OUTPUT_DIRECTORY/$RUN_NAME/checkpoints`. |
| 269 | + |
| 270 | +## (Optional) Convert Fine-tuned LoRA to Hugging Face Format with Multi-Controller JAX (McJAX) |
| 271 | + |
| 272 | +After completing the fine-tuning process, your LoRA weights are stored in MaxText/Orbax format. To use these weights with the Hugging Face ecosystem (e.g., for inference or sharing), convert them back using the `to_huggingface.py` script. |
| 273 | + |
| 274 | +```sh |
| 275 | +xpk workload create \ |
| 276 | +--cluster=${GKE_CLUSTER?} \ |
| 277 | +--project=${PROJECT_ID?} \ |
| 278 | +--zone=${ZONE?} \ |
| 279 | +--docker-image=${DOCKER_IMAGE?} \ |
| 280 | +--workload="${RUN_NAME?}-to-hf" \ |
| 281 | +--tpu-type=${TPU_TYPE?} \ |
| 282 | +--num-slices=1 \ |
| 283 | +--command="python3 -m maxtext.checkpoint_conversion.to_huggingface \ |
| 284 | + model_name=${MODEL?} \ |
| 285 | + lora.lora_restore_path=${BASE_OUTPUT_DIRECTORY?}/${RUN_NAME?}/checkpoints/<STEPS>/model_params \ |
| 286 | + base_output_directory=${BASE_OUTPUT_DIRECTORY?}/hf_lora_adapter \ |
| 287 | + hf_access_token=${HF_TOKEN?}" |
| 288 | + |
| 289 | +``` |
| 290 | + |
| 291 | +- `lora.lora_restore_path`: Point this to the specific checkpoint directory (e.g., `.../checkpoints/1000/items`) that you want to export. |
| 292 | +- `base_output_directory`: The local or GCS directory where the Hugging Face `adapter_model.safetensors` and `adapter_config.json` will be saved. |
| 293 | +- `lora.lora_rank` / `lora.lora_alpha`: Must match the values used during the training phase to ensure the `adapter_config.json` is generated correctly. |
| 294 | + |
| 295 | +## A Note on Multi-Host Resharding |
| 296 | + |
| 297 | +When running LoRA fine-tuning in a **multi-host environment** (e.g., a TPU pod with 64 hosts managing 256 TPUs, such as Pathways or McJAX), special care must be taken when resharding arrays. |
| 298 | + |
| 299 | +In a single-host environment, the host has a global view of all devices, so a standard `jax.device_put` can easily distribute slices of data to all local TPUs. However, in a multi-host setup: |
| 300 | + |
| 301 | +- **Addressability:** A host only has a local view of its directly attached devices and cannot push data directly to TPUs managed by other hosts. |
| 302 | +- **Memory Constraints:** If every host tries to load the entire weight matrix into RAM just to extract its local piece, the host CPUs will run out of memory (OOM). |
| 303 | + |
| 304 | +To solve this, MaxText uses `jax.make_array_from_callback` for a "safe reshard." Instead of pushing data *to* the devices, this flips the paradigm. It creates a global `jax.Array` construct where each host locally executes a callback (`lambda idx: val[idx]`) to load **only the specific slice** of the data that its attached TPUs need. This completely bypasses cross-host `device_put` limitations and prevents OOMs since each host only indexes what it requires. |
0 commit comments