|
| 1 | +# Copyright 2023–2025 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# https://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Dynamic loading of HuggingFace checkpoints during training/eval workloads directly in the target format. |
| 16 | +
|
| 17 | +This module allows loading HuggingFace checkpoints (in Safetensors format) |
| 18 | +directly during MaxText training or evaluation runs, performing on-the-fly sharded |
| 19 | +restore and CPU/TPU transformations. This avoids offline pre-conversion steps |
| 20 | +and prevents host OOM. |
| 21 | +
|
| 22 | +Usage: |
| 23 | + To load Hugging Face checkpoints directly, configure the following flags: |
| 24 | + 1. `source_checkpoint_layout`: Set to `"safetensors_dynamic"`. |
| 25 | + 2. `load_parameters_path`: Set to the source path of the Hugging Face checkpoint. |
| 26 | +
|
| 27 | +Examples: |
| 28 | + A. Load from a Google Cloud Storage (GCS) directory containing `.safetensors`: |
| 29 | + ``` |
| 30 | + python3 maxtext/trainers/pre_train/train.py \ |
| 31 | + maxtext/configs/base.yml \ |
| 32 | + run_name=my_run \ |
| 33 | + model_name=llama3.1-8b \ |
| 34 | + source_checkpoint_layout="safetensors_dynamic" \ |
| 35 | + load_parameters_path="gs://my-bucket/path/to/safetensors_directory/" |
| 36 | + ``` |
| 37 | +
|
| 38 | + B. Load directly from the Hugging Face Hub (automatically cached to GCS): |
| 39 | + ``` |
| 40 | + python3 maxtext/trainers/pre_train/train.py \ |
| 41 | + maxtext/configs/base.yml \ |
| 42 | + run_name=my_run \ |
| 43 | + model_name=llama3.1-8b \ |
| 44 | + source_checkpoint_layout="safetensors_dynamic" \ |
| 45 | + load_parameters_path="hf://meta-llama/Meta-Llama-3-8B" \ |
| 46 | + hf_access_token="<your_token>" \ |
| 47 | + base_output_directory="gs://my-bucket/output/" |
| 48 | + ``` |
| 49 | +
|
| 50 | + C. Load from Hugging Face Hub using automatic model_name resolution: |
| 51 | + ``` |
| 52 | + python3 maxtext/trainers/pre_train/train.py \ |
| 53 | + maxtext/configs/base.yml \ |
| 54 | + run_name=my_run \ |
| 55 | + model_name=llama3.1-8b \ |
| 56 | + source_checkpoint_layout="safetensors_dynamic" \ |
| 57 | + load_parameters_path="" \ |
| 58 | + hf_access_token="<your_token>" \ |
| 59 | + base_output_directory="gs://my-bucket/output/" |
| 60 | + ``` |
| 61 | +
|
| 62 | +Note: |
| 63 | + - Hugging Face weights from HF Hub are cached to `base_output_directory`. |
| 64 | + - When loading from Hugging Face Hub, `base_output_directory` must start with |
| 65 | + "gs://" and `hf_access_token` is required if downloading gated models. |
| 66 | +""" |
| 67 | + |
| 68 | +import concurrent.futures |
| 69 | +import multiprocessing |
| 70 | +import os |
| 71 | +import random |
| 72 | +import time |
| 73 | + |
| 74 | +from flax import nnx |
| 75 | +import flax.traverse_util |
| 76 | +from google.cloud import storage |
| 77 | +import huggingface_hub |
| 78 | +import jax |
| 79 | +from maxtext.checkpoint_conversion.utils import hf_model_configs |
| 80 | +from maxtext.checkpoint_conversion.utils import param_mapping |
| 81 | +from maxtext.checkpoint_conversion.utils import tensor_handling |
| 82 | +from maxtext.utils import gcs_utils |
| 83 | +from maxtext.utils import globals as maxtext_globals |
| 84 | +from maxtext.utils import max_logging |
| 85 | +from orbax.checkpoint import v1 as ocp_v1 |
| 86 | +from orbax.checkpoint._src.arrays import sharding as sharding_utils |
| 87 | + |
| 88 | + |
| 89 | +HF_MODEL_CONFIGS = hf_model_configs.HF_MODEL_CONFIGS |
| 90 | +get_hf_loading_function = tensor_handling.get_hf_loading_function |
| 91 | + |
| 92 | + |
| 93 | +def build_gcs_cache_worker(fpath, gcs_cache_dir, hf_access_token): |
| 94 | + """Caches a file from Hugging Face to a GCS bucket cache directory. |
| 95 | +
|
| 96 | + Args: |
| 97 | + fpath: The full remote file path on the Hugging Face virtual file system |
| 98 | + (e.g., "meta-llama/Meta-Llama-3-8B/model-00001-of-00004.safetensors"). |
| 99 | + gcs_cache_dir: The destination directory in GCS. |
| 100 | + hf_access_token: The access token for Hugging Face. |
| 101 | + """ |
| 102 | + fs = huggingface_hub.HfFileSystem(token=hf_access_token) |
| 103 | + time.sleep(random.uniform(0.0, 5.0)) |
| 104 | + |
| 105 | + bucket_name, blob_prefix = gcs_utils.parse_gcs_bucket_and_prefix(gcs_cache_dir) |
| 106 | + blob_name = os.path.join(blob_prefix, os.path.basename(fpath)) |
| 107 | + |
| 108 | + storage_client = storage.Client() |
| 109 | + bucket = storage_client.bucket(bucket_name) |
| 110 | + blob = bucket.blob(blob_name) |
| 111 | + |
| 112 | + if blob.exists(): |
| 113 | + max_logging.log(f"[Worker] Cache hit for {os.path.basename(fpath)}.") |
| 114 | + return |
| 115 | + |
| 116 | + t0 = time.time() |
| 117 | + max_retries = 5 |
| 118 | + for attempt in range(max_retries): |
| 119 | + try: |
| 120 | + with fs.open(fpath, "rb") as remote_f: |
| 121 | + blob.chunk_size = 1024 * 1024 * 32 # 32MB chunks |
| 122 | + blob.upload_from_file(remote_f, client=storage_client) |
| 123 | + print( |
| 124 | + f"[Worker] Cached {os.path.basename(fpath)} in" f" {time.time() - t0:.1f}s", |
| 125 | + flush=True, |
| 126 | + ) |
| 127 | + break |
| 128 | + except Exception as e: # pylint: disable=broad-exception-caught |
| 129 | + if attempt < max_retries - 1: |
| 130 | + max_logging.log( |
| 131 | + f"Error fetching {fpath} to GCS: {e}. Retrying in 15 seconds..." f" (Attempt {attempt+1}/{max_retries})" |
| 132 | + ) |
| 133 | + time.sleep(15) |
| 134 | + else: |
| 135 | + max_logging.log(f"Failed to fetch {fpath} to GCS after {max_retries} attempts.") |
| 136 | + raise |
| 137 | + |
| 138 | + |
| 139 | +def get_hf_config_and_mappings(maxtext_config): |
| 140 | + """Gets HF config and parameter mapping based on the MaxText config.""" |
| 141 | + model_key = maxtext_config.model_name |
| 142 | + if "-Instruct" in model_key: |
| 143 | + model_key = model_key.replace("-Instruct", "") |
| 144 | + hf_config_obj = HF_MODEL_CONFIGS[model_key] |
| 145 | + hf_config_dict = hf_config_obj.to_dict() |
| 146 | + |
| 147 | + param_map_mt_to_hf = param_mapping.PARAM_MAPPING[model_key]( |
| 148 | + hf_config_dict, maxtext_config, scan_layers=maxtext_config.scan_layers |
| 149 | + ) |
| 150 | + hook_fn_map_mt = param_mapping.HOOK_FNS[model_key]( |
| 151 | + hf_config_dict, maxtext_config, scan_layers=maxtext_config.scan_layers, saving_to_hf=False |
| 152 | + ) |
| 153 | + return param_map_mt_to_hf, hook_fn_map_mt |
| 154 | + |
| 155 | + |
| 156 | +def load_sharded_hf_state(path): |
| 157 | + """Loads HF state with maximal sharding across TPU mesh to avoid host OOM. |
| 158 | +
|
| 159 | + Args: |
| 160 | + path: A directory path (either local or GCS starting with gs://) containing |
| 161 | + the .safetensors files (e.g., "gs://my-bucket/hf_cache/model_id" or |
| 162 | + "/path/to/safetensors_directory/"). If a Hugging Face Hub ID was used, |
| 163 | + it should already be cached/downloaded to GCS before calling this |
| 164 | + function. |
| 165 | +
|
| 166 | + Returns: |
| 167 | + The loaded Hugging Face state dictionary mapping parameter names to |
| 168 | + JAX arrays. |
| 169 | + """ |
| 170 | + t0 = time.time() |
| 171 | + context = ocp_v1.Context(checkpoint_layout=ocp_v1.options.CheckpointLayout.SAFETENSORS) |
| 172 | + with context: |
| 173 | + metadata = ocp_v1.pytree_metadata(path) |
| 174 | + simple_abstract_state = metadata.metadata |
| 175 | + |
| 176 | + # Distributed Sharded Download: Tell JAX to shard the HF Safetensors download |
| 177 | + # across the entire TPU mesh to avoid Host OOM. |
| 178 | + current_global_devices = jax.devices() |
| 179 | + shardings = sharding_utils.construct_maximal_shardings(simple_abstract_state, devices=current_global_devices) |
| 180 | + |
| 181 | + def combine_sharding(sds, single_sharding): |
| 182 | + return jax.ShapeDtypeStruct(shape=sds.shape, dtype=sds.dtype, sharding=single_sharding) |
| 183 | + |
| 184 | + sharded_abstract_state = jax.tree.map(combine_sharding, simple_abstract_state, shardings) |
| 185 | + |
| 186 | + max_logging.log("Reading raw Safetensors into memory (Distributed Sharded GCS Download)...") |
| 187 | + hf_state = ocp_v1.load_pytree(path, sharded_abstract_state) |
| 188 | + max_logging.log(f"load_sharded_hf_state took {time.time() - t0:.2f}s") |
| 189 | + return hf_state |
| 190 | + |
| 191 | + |
| 192 | +def transform_hf_state_to_mt_state(hf_state, target_tree, param_map_mt_to_hf, hook_fn_map_mt, maxtext_config): |
| 193 | + """Transforms HF state into MaxText state by applying param mappings and mathematical hooks.""" |
| 194 | + t0 = time.time() |
| 195 | + |
| 196 | + def tensor_getter(key): |
| 197 | + return hf_state.pop(key) |
| 198 | + |
| 199 | + flat_target = flax.traverse_util.flatten_dict(target_tree, sep=".") |
| 200 | + flat_restored = flat_target.copy() |
| 201 | + |
| 202 | + mapped_count = 0 |
| 203 | + keys_missed = [] |
| 204 | + max_logging.log("Starting fast in-memory Distributed Transformations...") |
| 205 | + |
| 206 | + for mt_key, hf_source in param_map_mt_to_hf.items(): |
| 207 | + mt_name = mt_key.replace("params-", "").replace("-", ".") |
| 208 | + |
| 209 | + # Determine the correct key in flat_target |
| 210 | + check_name = mt_name |
| 211 | + if check_name not in flat_target: |
| 212 | + if f"params.{mt_name}" in flat_target: |
| 213 | + check_name = f"params.{mt_name}" |
| 214 | + elif mt_key.replace("-", ".") in flat_target: |
| 215 | + check_name = mt_key.replace("-", ".") |
| 216 | + |
| 217 | + if check_name not in flat_target: |
| 218 | + keys_missed.append(mt_name) |
| 219 | + continue |
| 220 | + |
| 221 | + target_leaf = flat_target[check_name] |
| 222 | + hook_fn = hook_fn_map_mt.get(mt_key) |
| 223 | + |
| 224 | + load_fn = get_hf_loading_function( |
| 225 | + hf_source, |
| 226 | + tensor_getter, |
| 227 | + hook_fn, |
| 228 | + target_leaf, |
| 229 | + maxtext_config, |
| 230 | + ) |
| 231 | + |
| 232 | + # Execute transformation and assign to flat_restored |
| 233 | + t_layer = time.time() |
| 234 | + flat_restored[check_name] = load_fn() |
| 235 | + |
| 236 | + max_logging.log(f"Transformed {check_name} from {hf_source} in {time.time() - t_layer:.4f}s") |
| 237 | + mapped_count += 1 |
| 238 | + |
| 239 | + if mapped_count == 0: |
| 240 | + max_logging.log(f"All transformations missed! Sample missed mt_names: {keys_missed[:5]}") |
| 241 | + max_logging.log(f"Sample flat_target keys: {list(flat_target.keys())[:5]}") |
| 242 | + |
| 243 | + max_logging.log(f"Successfully mapped {mapped_count} parameters.") |
| 244 | + restored_params = flax.traverse_util.unflatten_dict(flat_restored, sep=".") |
| 245 | + |
| 246 | + if "params" in restored_params: |
| 247 | + restored_params = restored_params["params"] |
| 248 | + |
| 249 | + max_logging.log(f"transform_hf_state_to_mt_state took {time.time() - t0:.2f}s") |
| 250 | + |
| 251 | + return {"params": restored_params} |
| 252 | + |
| 253 | + |
| 254 | +def load_safetensors_dynamic_state(path, abstract_unboxed_pre_state, maxtext_config): |
| 255 | + """Main entry point to dynamically build and load safetensors into MaxText format. |
| 256 | +
|
| 257 | + Splits execution into: |
| 258 | + 1. Deriving Mappings |
| 259 | + 2. Loading Sharded arrays directly to TPUs |
| 260 | + 3. Processing the transformations natively on TPUs |
| 261 | + """ |
| 262 | + if maxtext_config is None: |
| 263 | + raise ValueError("maxtext_config must be provided for safetensors_dynamic loading.") |
| 264 | + |
| 265 | + model_name = maxtext_config.model_name |
| 266 | + if "-Instruct" in model_name: |
| 267 | + model_name = model_name.replace("-Instruct", "") |
| 268 | + |
| 269 | + if not path: |
| 270 | + if model_name not in maxtext_globals.HF_IDS: |
| 271 | + raise ValueError(f"Unsupported model name for automatic HF repo resolution: {model_name}.") |
| 272 | + path = maxtext_globals.HF_IDS[model_name] |
| 273 | + |
| 274 | + if path.startswith("hf://"): |
| 275 | + path = path[5:] |
| 276 | + |
| 277 | + if not path.startswith("gs://") and not os.path.isdir(path): |
| 278 | + fs = huggingface_hub.HfFileSystem(token=maxtext_config.hf_access_token) |
| 279 | + repo_id = path |
| 280 | + |
| 281 | + files = fs.glob(f"{repo_id}/*.safetensors") |
| 282 | + |
| 283 | + host_id = jax.process_index() |
| 284 | + |
| 285 | + if hasattr(maxtext_config, "base_output_directory") and maxtext_config.base_output_directory.startswith("gs://"): |
| 286 | + gcs_cache_dir = f"{maxtext_config.base_output_directory}/hf_cache/{repo_id.replace('/', '_')}" |
| 287 | + path = gcs_cache_dir |
| 288 | + |
| 289 | + # Only Host 0 downloads to the shared GCS cache |
| 290 | + if host_id == 0: |
| 291 | + max_logging.log("Dynamic HF Hub Fast DL: Host 0 is downloading to shared GCS" f" Cache: {gcs_cache_dir}") |
| 292 | + t_gcs_start = time.time() |
| 293 | + |
| 294 | + # List existing blobs to avoid spawning processes for already cached |
| 295 | + # files |
| 296 | + storage_client = storage.Client() |
| 297 | + bucket_name = gcs_cache_dir.replace("gs://", "").split("/", maxsplit=1)[0] |
| 298 | + blob_prefix = ( |
| 299 | + gcs_cache_dir.replace("gs://", "").split("/", maxsplit=1)[1] |
| 300 | + if "/" in gcs_cache_dir.replace("gs://", "") |
| 301 | + else "" |
| 302 | + ) |
| 303 | + |
| 304 | + existing_blobs = {blob.name for blob in storage_client.list_blobs(bucket_name, prefix=blob_prefix)} |
| 305 | + |
| 306 | + files_to_download = [] |
| 307 | + for fpath in files: |
| 308 | + expected_blob_name = os.path.join(blob_prefix, os.path.basename(fpath)) |
| 309 | + if expected_blob_name not in existing_blobs: |
| 310 | + files_to_download.append(fpath) |
| 311 | + |
| 312 | + if files_to_download: |
| 313 | + with concurrent.futures.ProcessPoolExecutor( |
| 314 | + max_workers=32, mp_context=multiprocessing.get_context("spawn") |
| 315 | + ) as executor: |
| 316 | + futures = [ |
| 317 | + executor.submit( |
| 318 | + build_gcs_cache_worker, |
| 319 | + fpath, |
| 320 | + gcs_cache_dir, |
| 321 | + maxtext_config.hf_access_token, |
| 322 | + ) |
| 323 | + for fpath in files_to_download |
| 324 | + ] |
| 325 | + |
| 326 | + while futures: |
| 327 | + done, futures = concurrent.futures.wait(futures, timeout=10) |
| 328 | + |
| 329 | + # Raise any exceptions if a worker failed |
| 330 | + for f in done: |
| 331 | + f.result() |
| 332 | + |
| 333 | + t_gcs_end = time.time() |
| 334 | + max_logging.log( |
| 335 | + f"GCS caching complete in {t_gcs_end - t_gcs_start:.2f}s." |
| 336 | + f" Downloaded {len(files_to_download)} missing files." |
| 337 | + ) |
| 338 | + |
| 339 | + # Global barrier: all hosts wait for Host 0 to finish downloading to the |
| 340 | + # shared GCS bucket |
| 341 | + max_logging.log(f"Host {host_id} waiting for GCS cache at {gcs_cache_dir} to be" " populated by Host 0...") |
| 342 | + jax.experimental.multihost_utils.sync_global_devices("dynamic_hf_download_complete") |
| 343 | + max_logging.log(f"Host {host_id} detected GCS cache is ready!") |
| 344 | + |
| 345 | + else: |
| 346 | + raise ValueError("base_output_directory with gs:// prefix is required for " "huggingface downloads.") |
| 347 | + |
| 348 | + t_total = time.time() |
| 349 | + param_map_mt_to_hf, hook_fn_map_mt = get_hf_config_and_mappings(maxtext_config) |
| 350 | + max_logging.log(f"[1/3] Mappings derived in {time.time() - t_total:.2f}s") |
| 351 | + |
| 352 | + target_tree = ( |
| 353 | + abstract_unboxed_pre_state.to_pure_dict() |
| 354 | + if isinstance(abstract_unboxed_pre_state, nnx.State) |
| 355 | + else abstract_unboxed_pre_state.params |
| 356 | + ) |
| 357 | + |
| 358 | + t1 = time.time() |
| 359 | + hf_state = load_sharded_hf_state(path) |
| 360 | + max_logging.log(f"[2/3] Distributed Sharded GCS load completed in {time.time() - t1:.2f}s") |
| 361 | + |
| 362 | + t2 = time.time() |
| 363 | + # Transform Hugging Face weight tensors on-the-fly into MaxText format |
| 364 | + # in-memory. This is done in-memory on each host, sharded across the mesh. |
| 365 | + restored_params = transform_hf_state_to_mt_state( |
| 366 | + hf_state, target_tree, param_map_mt_to_hf, hook_fn_map_mt, maxtext_config |
| 367 | + ) |
| 368 | + max_logging.log(f"[3/3] CPU Transformations completed in {time.time() - t2:.2f}s") |
| 369 | + max_logging.log(f"Total safetensors_dynamic duration: {time.time() - t_total:.2f}s") |
| 370 | + |
| 371 | + return None, restored_params |
0 commit comments