# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # ruff: noqa: E501, F401 """Namespace to store utilities for building web runtime.""" import hashlib import json import math import os import shutil # pylint: disable=unused-import import sys from collections.abc import Iterator, Mapping from types import GeneratorType from typing import Any, Optional, Union import numpy as np try: import ml_dtypes except ImportError: ml_dtypes = None import tvm from tvm.runtime import DataType from tvm.support.emcc import create_tvmjs_wasm, find_wasm_lib def _convert_f32_to_bf16(value): cap = np.finfo("float32").max assert -np.finfo("float32").max == np.finfo("float32").min bf16_limit = ((np.array([cap.view("uint32")]) >> 16) << 16).view("float32")[0] # When the value is in [-bf16_limit, bf16_limit], round to nearest even. # We can afford to do it in dumping phase to reduce overall rounding error. # # When the value is out of bound(usually mask values in attention), use truncation # so it is equivalent to clip to the limit values data = value.view("uint32") rounding_bias = np.where( np.logical_and(value < bf16_limit, value > -bf16_limit), ((data >> 16) & 1) + 0x7FFF, np.zeros_like(data), ) return ((data + rounding_bias) >> 16).astype("uint16") def _convert_bf16_to_f32(value): data = value.view("uint16") return (data.astype("uint32") << 16).view("float32") def _calculate_md5(filename): hash_md5 = hashlib.md5() with open(filename, "rb") as file: for chunk in iter(lambda: file.read(8192), b""): hash_md5.update(chunk) return hash_md5.hexdigest() class TensorCacheShardingManager: """Internal helper to shard ndarrays.""" def __init__( self, cache_dir: str, prefix: str, shard_cap_nbytes: int, initial_shard_records: Mapping[str, Any] | None = None, ): self.cache_dir = cache_dir self.prefix = prefix self.curr_records = [] self.curr_data = bytearray() self.shard_records = [] self.shard_cap_nbytes = shard_cap_nbytes self.counter = 0 self.name_to_record: Mapping[str, tuple[int, Mapping[str, Any]]] = {} self.updated_shards: set[int] = set() if initial_shard_records is not None: self.shard_records = initial_shard_records self.counter = len(initial_shard_records) for idx, shard in enumerate(initial_shard_records): for rec in shard["records"]: self.name_to_record[rec["name"]] = (idx, rec) def append_or_update(self, data, name, shape, dtype, encode_format, allow_update: bool = False): """Commit a record to the manager. Parameters ---------- data: bytes Raw bytes to be appended. name: str The name of the parameter shape: tuple The shape of the array dtype: str The dtype information encode_format: The encode format of the entry allow_update: bool If the record already exists, update the record. Otherwise, raise an error. """ rec = { "name": name, "shape": shape, "dtype": dtype, "format": encode_format, "nbytes": len(data), } if name in self.name_to_record: if not allow_update: raise ValueError(f"Duplicate name {name} found in the cache.") self.update_single_record(rec, data) return self.name_to_record[name] = (self.counter, rec) if self.pending_nbytes + len(data) >= self.shard_cap_nbytes: if len(data) * 2 >= self.shard_cap_nbytes: # out of band data rec["byteOffset"] = 0 self._commit_internal(data, [rec]) return self.commit() rec["byteOffset"] = self.pending_nbytes self.curr_records.append(rec) self.curr_data += data def update_single_record(self, rec, data): """Update a single record in a shard file.""" name = rec["name"] idx, old_rec = self.name_to_record[name] if old_rec["nbytes"] != rec["nbytes"]: raise ValueError(f"Cannot update record {name}, size mismatch.") data_path = self.shard_records[idx]["dataPath"] full_path = os.path.join(self.cache_dir, data_path) with open(full_path, "r+b") as outfile: outfile.seek(old_rec["byteOffset"]) outfile.write(data) self.name_to_record[name] = (idx, rec) self.updated_shards.add(idx) def commit(self): """Commit a record""" if self.pending_nbytes != 0: self._commit_internal(self.curr_data, self.curr_records) self.curr_data = bytearray() self.curr_records = [] def finish(self): """Finish building and return shard records.""" self.commit() for idx in self.updated_shards: full_path = os.path.join(self.cache_dir, self.shard_records[idx]["dataPath"]) self.shard_records[idx]["md5sum"] = _calculate_md5(full_path) return self.shard_records def _commit_internal(self, data, records): data_path = f"{self.prefix}_{self.counter}.bin" full_path = os.path.join(self.cache_dir, data_path) self.counter += 1 with open(full_path, "wb") as outfile: outfile.write(data) shard_record = { "dataPath": data_path, "format": "raw-shard", "nbytes": len(data), "records": records, "md5sum": _calculate_md5(full_path), } self.shard_records.append(shard_record) @property def pending_nbytes(self): """Return total bytes stored so far""" return len(self.curr_data) def dump_tensor_cache( params: Mapping[str, np.ndarray | tvm.runtime.Tensor] | Iterator[tuple[str, np.ndarray | tvm.runtime.Tensor]], cache_dir: str, encode_format="f32-to-bf16", meta_data=None, shard_cap_mb=32, show_progress: bool = True, update_if_exists: bool = False, ): """Dump parameters to Tensor cache. Parameters ---------- params: Union[ Mapping[str, Union[np.ndarray, tvm.runtime.Tensor]], Iterator[Tuple[str, Union[np.ndarray, tvm.runtime.Tensor]]], ] The parameter dictionary or generator cache_dir: str The path to the cache encode_format: {"f32-to-bf16", "raw"} Encoding format. meta_data: json-compatible-struct or Callable[[], Any] Extra meta_data to be stored in the cache json file, or a callable that returns the metadata. shard_cap_mb: int Maxinum number of MB to be kept per shard show_progress: bool A boolean indicating if to show the dump progress. update_if_exists: bool If the cache already exists, update the cache. When set to False, it will overwrite the existing files. """ if encode_format not in ("raw", "f32-to-bf16"): raise ValueError(f"Invalie encode_format {encode_format}") records = [] from_generator = isinstance(params, GeneratorType) total_bytes = 0 counter = 0 max_out_length = 0 if not os.path.exists(cache_dir): os.makedirs(cache_dir) f32_to_bf16_triggered = False print(f"Start storing to cache {cache_dir}") shard_cap_nbytes = shard_cap_mb * (1 << 20) nd_cache_json = os.path.join(cache_dir, "tensor-cache.json") if update_if_exists and os.path.exists(nd_cache_json): with open(nd_cache_json) as infile: old_data = json.load(infile) if meta_data is None: meta_data = old_data["metadata"] records = old_data["records"] shard_manager = TensorCacheShardingManager( cache_dir, "params_shard", shard_cap_nbytes, initial_shard_records=records ) param_generator = params.items() if not from_generator else params for k, origin_v in param_generator: shape = list(origin_v.shape) v = origin_v if not isinstance(v, np.ndarray): v = v.numpy() # prefer to preserve original dtype, especially if the format was bfloat16 dtype = origin_v.dtype if isinstance(origin_v, tvm.runtime.Tensor) else v.dtype if dtype in DataType._NUMPY_DTYPE_TO_STR: dtype = DataType._NUMPY_DTYPE_TO_STR[dtype] else: dtype = str(dtype) total_bytes += math.prod(v.shape) * np.dtype(v.dtype).itemsize # convert fp32 to bf16 if encode_format == "f32-to-bf16" and dtype == "float32": data = _convert_f32_to_bf16(v).tobytes() f32_to_bf16_triggered = True else: data = v.tobytes() shard_manager.append_or_update( data, name=k, shape=shape, dtype=dtype, encode_format=encode_format, allow_update=update_if_exists, ) counter += 1 if show_progress: last_cmd = f"[{counter:04d}] saving {k}" flush = "\r" + (" " * max_out_length) + "\r" max_out_length = max(len(last_cmd), max_out_length) sys.stdout.write(flush + last_cmd) records = shard_manager.finish() meta_data = {} if meta_data is None else meta_data if not callable(meta_data) else meta_data() with open(nd_cache_json, "w") as outfile: json.dump({"metadata": meta_data, "records": records}, outfile, indent=4) print( f"\nAll finished, {shard_manager.counter} total shards committed, record saved to {nd_cache_json}" ) if f32_to_bf16_triggered: for shard in records: for item in shard["records"]: if item["dtype"] == "float32": item["format"] = "raw" item["dtype"] = "bfloat16" b16_nd_cache_json = os.path.join(cache_dir, "tensor-cache-b16.json") # also dump a file that contains bf16 with open(b16_nd_cache_json, "w") as outfile: json.dump({"metadata": meta_data, "records": records}, outfile, indent=4) print(f"Also saved a bf16 record to {b16_nd_cache_json}") def load_tensor_cache(cachepath: str, device: tvm.runtime.Device): """Load the tensor cache from the directory or json. Parameters ---------- cachepath: str Path to the location or json file. device: tvm.runtime.Device The device we would like to load the data from. """ if not cachepath.endswith(".json"): cachepath = os.path.join(cachepath, "tensor-cache.json") cachedir = os.path.dirname(cachepath) json_info = json.loads(open(cachepath).read()) result_dict = {} for shard_rec in json_info["records"]: data_path = shard_rec["dataPath"] full_data_path = os.path.join(cachedir, data_path) raw_data = open(full_data_path, "rb").read() assert shard_rec["format"] == "raw-shard" assert shard_rec["nbytes"] == len(raw_data) for rec in shard_rec["records"]: name = rec["name"] shape = rec["shape"] dtype = rec["dtype"] encode_format = rec["format"] offset = rec["byteOffset"] nbytes = rec["nbytes"] arr = tvm.runtime.empty(shape, dtype, device=device) assert offset + nbytes <= len(raw_data) buffer_source = raw_data[offset : offset + nbytes] if dtype == "float8_e4m3fn": if ml_dtypes is not None: dtype = ml_dtypes.float8_e4m3fn else: raise RuntimeError( "ml_dtypes is not installed, cannot convert float8_e4m3fn array to numpy." ) if dtype == "float8_e5m2": if ml_dtypes is not None: dtype = ml_dtypes.float8_e5m2 else: raise RuntimeError( "ml_dtypes is not installed, cannot convert float8_e5m2 array to numpy." ) if encode_format == "f32-to-bf16" and dtype == "float32": data = np.frombuffer(buffer_source, dtype="uint16").reshape(shape) arr.copyfrom(_convert_bf16_to_f32(data)) elif dtype == "bfloat16": data = np.frombuffer(buffer_source, dtype="uint16").reshape(shape) arr.copyfrom(data) else: data = np.frombuffer(buffer_source, dtype=dtype).reshape(shape) arr.copyfrom(data) result_dict[name] = arr return result_dict, json_info["metadata"] def export_runtime(runtime_dir): """Export TVMJS runtime to the runtime_dir Parameters ---------- runtime_dir: str The runtime directory """ web_hint = ( "make sure you setup tvm web runtime correctly." + " obtain a copy of TVM source code, set TVM_HOME env variable:\n" + " cd /path/to/tvm/web; make; npm run bundle" ) jsbundle = find_wasm_lib("tvmjs.bundle.js", optional=True) if not jsbundle: raise RuntimeError("Cannot find tvmjs.bundle.js, " + web_hint) wasi = find_wasm_lib("tvmjs_runtime.wasi.js", optional=True) if not wasi: raise RuntimeError("Cannot find tvmjs_runtime.wasi.js, " + web_hint) print(f"Copy {jsbundle[0]} to {runtime_dir}") shutil.copy(jsbundle[0], runtime_dir) print(f"Copy {wasi[0]} to {runtime_dir}") shutil.copy(wasi[0], runtime_dir)