hf-mem 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hf_mem-0.0.1/PKG-INFO +28 -0
- hf_mem-0.0.1/README.md +18 -0
- hf_mem-0.0.1/pyproject.toml +18 -0
- hf_mem-0.0.1/src/hf_mem/__init__.py +281 -0
- hf_mem-0.0.1/src/hf_mem/py.typed +0 -0
hf_mem-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hf-mem
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A CLI to estimate inference memory requirements for Hugging Face models, written in Python
|
|
5
|
+
Author: Alvaro Bartolome
|
|
6
|
+
Author-email: Alvaro Bartolome <36760800+alvarobartt@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# `hf-mem`
|
|
12
|
+
|
|
13
|
+
> [!WARNING]
|
|
14
|
+
> `hf-mem` works, but is still experimental. Ideally there should be a minor release anytime soon making things a bit more stable (and aesthetic).
|
|
15
|
+
|
|
16
|
+
`hf-mem` is a CLI to estimate inference memory requirements for Hugging Face models, written in Python. `hf-mem` is lightweight and free for third-party dependencies. It's recommended to run with [`uv`](https://github.com/astral-sh/uv) for a better experience.
|
|
17
|
+
|
|
18
|
+
`hf-mem` lets you estimate the inference requirements to run any model from the Hugging Face Hub, including Transformers, Diffusers and Sentence Transformers models, as well as any model that contains [Safetensors](https://github.com/huggingface/safetensors) compatible weights.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uvx hf-mem --model-id HuggingFaceTB/SmolLM3-3B
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## References
|
|
27
|
+
|
|
28
|
+
- [Safetensors Metadata parsing](https://huggingface.co/docs/safetensors/en/metadata_parsing)
|
hf_mem-0.0.1/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# `hf-mem`
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> `hf-mem` works, but is still experimental. Ideally there should be a minor release anytime soon making things a bit more stable (and aesthetic).
|
|
5
|
+
|
|
6
|
+
`hf-mem` is a CLI to estimate inference memory requirements for Hugging Face models, written in Python. `hf-mem` is lightweight and free for third-party dependencies. It's recommended to run with [`uv`](https://github.com/astral-sh/uv) for a better experience.
|
|
7
|
+
|
|
8
|
+
`hf-mem` lets you estimate the inference requirements to run any model from the Hugging Face Hub, including Transformers, Diffusers and Sentence Transformers models, as well as any model that contains [Safetensors](https://github.com/huggingface/safetensors) compatible weights.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uvx hf-mem --model-id HuggingFaceTB/SmolLM3-3B
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## References
|
|
17
|
+
|
|
18
|
+
- [Safetensors Metadata parsing](https://huggingface.co/docs/safetensors/en/metadata_parsing)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hf-mem"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A CLI to estimate inference memory requirements for Hugging Face models, written in Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Alvaro Bartolome", email = "36760800+alvarobartt@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["uv_build>=0.9.5,<0.10.0"]
|
|
15
|
+
build-backend = "uv_build"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
hf-mem = "hf_mem.__init__:main"
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
import sys
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from functools import reduce
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
# NOTE: Defines the bytes that will be fetched per safetensors file, but the metadata
|
|
14
|
+
# can indeed be larger than that
|
|
15
|
+
MAX_METADATA_SIZE = 100_000
|
|
16
|
+
REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", 30.0))
|
|
17
|
+
MAX_WORKERS = int(os.getenv("MAX_WORKERS", min(32, (os.cpu_count() or 1) + 4)))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(
|
|
21
|
+
format="[%(levelname)s] @ %(filename)s:%(lineno)d :: %(message)s",
|
|
22
|
+
level=logging.INFO,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_safetensors_metadata(url: str) -> Dict[str, Any]:
|
|
27
|
+
headers = {"Range": f"bytes=0-{MAX_METADATA_SIZE}"}
|
|
28
|
+
if token := os.getenv("HF_TOKEN", None):
|
|
29
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
30
|
+
|
|
31
|
+
request = Request(url, headers=headers, method="GET")
|
|
32
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
33
|
+
if response.status not in {200, 206}:
|
|
34
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
35
|
+
sys.exit(3)
|
|
36
|
+
|
|
37
|
+
metadata = response.read()
|
|
38
|
+
# NOTE: Parse the first 8 bytes as a little-endian uint64 (size of the metadata)
|
|
39
|
+
metadata_size = struct.unpack("<Q", metadata[:8])[0]
|
|
40
|
+
|
|
41
|
+
if metadata_size < MAX_METADATA_SIZE:
|
|
42
|
+
metadata = metadata[8 : metadata_size + 8]
|
|
43
|
+
return json.loads(metadata)
|
|
44
|
+
|
|
45
|
+
# NOTE: Given that by default we just fetch the first 100_000 bytes, if the content is larger
|
|
46
|
+
# then we simply fetch the remainder again
|
|
47
|
+
metadata = metadata[8 : MAX_METADATA_SIZE + 8]
|
|
48
|
+
headers["Range"] = f"bytes={MAX_METADATA_SIZE}-{metadata_size + 7}"
|
|
49
|
+
|
|
50
|
+
request = Request(url, headers=headers, method="GET")
|
|
51
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
52
|
+
if response.status not in {200, 206}:
|
|
53
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
54
|
+
sys.exit(3)
|
|
55
|
+
|
|
56
|
+
metadata += response.read()
|
|
57
|
+
return json.loads(metadata)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> None:
|
|
61
|
+
parser = argparse.ArgumentParser()
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--model-id", required=True, help="Model ID on the Hugging Face Hub"
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--revision",
|
|
67
|
+
default="main",
|
|
68
|
+
help="Model revision on the Hugging Face Hub",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
model_id = args.model_id
|
|
73
|
+
revision = args.revision
|
|
74
|
+
|
|
75
|
+
url = f"https://huggingface.co/api/models/{model_id}/tree/{revision}?recursive=true"
|
|
76
|
+
|
|
77
|
+
headers = {}
|
|
78
|
+
if token := os.getenv("HF_TOKEN", None):
|
|
79
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
80
|
+
|
|
81
|
+
request = Request(url, headers=headers, method="GET")
|
|
82
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
83
|
+
if response.status != 200:
|
|
84
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
85
|
+
sys.exit(3)
|
|
86
|
+
|
|
87
|
+
files = [
|
|
88
|
+
f["path"]
|
|
89
|
+
for f in json.loads(response.read())
|
|
90
|
+
if f.get("path", None) is not None and f.get("type", None) == "file"
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
if "model.safetensors" in files:
|
|
94
|
+
url = f"https://huggingface.co/{model_id}/resolve/{revision}/model.safetensors"
|
|
95
|
+
metadata = fetch_safetensors_metadata(url=url)
|
|
96
|
+
elif "model.safetensors.index.json" in files:
|
|
97
|
+
# TODO: We could eventually skip this request in favour of a greedy approach on trying to pull all the
|
|
98
|
+
# files following the formatting `model-00000-of-00000.safetensors`
|
|
99
|
+
url = f"https://huggingface.co/{model_id}/resolve/{revision}/model.safetensors.index.json"
|
|
100
|
+
request = Request(url, headers=headers, method="GET")
|
|
101
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
102
|
+
if response.status != 200:
|
|
103
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
104
|
+
sys.exit(3)
|
|
105
|
+
|
|
106
|
+
urls = {
|
|
107
|
+
f"https://huggingface.co/{model_id}/resolve/{revision}/{f}"
|
|
108
|
+
for _, f in json.loads(response.read())["weight_map"].items()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
metadatas = []
|
|
112
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
113
|
+
futures = {
|
|
114
|
+
executor.submit(fetch_safetensors_metadata, url): url for url in urls
|
|
115
|
+
}
|
|
116
|
+
for future in as_completed(futures):
|
|
117
|
+
try:
|
|
118
|
+
metadatas.append(future.result())
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logging.error(f'ANY OF THE FUTURES FAILED "{e}"')
|
|
121
|
+
sys.exit(4)
|
|
122
|
+
|
|
123
|
+
metadata = reduce(lambda acc, metadata: acc | metadata, metadatas, {})
|
|
124
|
+
elif "model_index.json" in files:
|
|
125
|
+
url = f"https://huggingface.co/{model_id}/resolve/{revision}/model_index.json"
|
|
126
|
+
request = Request(url, headers=headers, method="GET")
|
|
127
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
128
|
+
if response.status != 200:
|
|
129
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
130
|
+
sys.exit(3)
|
|
131
|
+
|
|
132
|
+
paths = {
|
|
133
|
+
k
|
|
134
|
+
for k, _ in json.loads(response.read()).items()
|
|
135
|
+
if not k.startswith("_") # e.g. `_class_name` or `_diffusers_version`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
path_urls: Dict[str, List[str]] = {}
|
|
139
|
+
for path in paths:
|
|
140
|
+
if path not in path_urls:
|
|
141
|
+
path_urls[path] = []
|
|
142
|
+
|
|
143
|
+
if f"{path}/diffusion_pytorch_model.safetensors" in files:
|
|
144
|
+
path_urls[path].append(
|
|
145
|
+
f"https://huggingface.co/{model_id}/resolve/{revision}/{path}/diffusion_pytorch_model.safetensors"
|
|
146
|
+
)
|
|
147
|
+
elif f"{path}/model.safetensors" in files:
|
|
148
|
+
path_urls[path].append(
|
|
149
|
+
f"https://huggingface.co/{model_id}/resolve/{revision}/{path}/model.safetensors"
|
|
150
|
+
)
|
|
151
|
+
elif f"{path}/diffusion_pytorch_model.safetensors.index.json" in files:
|
|
152
|
+
url = f"https://huggingface.co/{model_id}/resolve/{revision}/{path}/diffusion_pytorch_model.safetensors.index.json"
|
|
153
|
+
request = Request(url, headers=headers, method="GET")
|
|
154
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
155
|
+
if response.status != 200:
|
|
156
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
157
|
+
sys.exit(3)
|
|
158
|
+
|
|
159
|
+
path_urls[path] = list({
|
|
160
|
+
f"https://huggingface.co/{model_id}/resolve/{revision}/{path}/{f}"
|
|
161
|
+
for _, f in json.loads(response.read())[
|
|
162
|
+
"weight_map"
|
|
163
|
+
].items()
|
|
164
|
+
})
|
|
165
|
+
elif f"{path}/model.safetensors.index.json" in files:
|
|
166
|
+
url = f"https://huggingface.co/{model_id}/resolve/{revision}/{path}/model.safetensors.index.json"
|
|
167
|
+
request = Request(url, headers=headers, method="GET")
|
|
168
|
+
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
169
|
+
if response.status != 200:
|
|
170
|
+
logging.error(f"REQUEST FAILED WITH {response.status}")
|
|
171
|
+
sys.exit(3)
|
|
172
|
+
|
|
173
|
+
path_urls[path] = list({
|
|
174
|
+
f"https://huggingface.co/{model_id}/resolve/{revision}/{path}/{f}"
|
|
175
|
+
for _, f in json.loads(response.read())[
|
|
176
|
+
"weight_map"
|
|
177
|
+
].items()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
path_metadatas: Dict[str, List[Dict[str, Any]]] = {}
|
|
181
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
182
|
+
futures = {
|
|
183
|
+
executor.submit(fetch_safetensors_metadata, url): path
|
|
184
|
+
for path, urls in path_urls.items()
|
|
185
|
+
for url in urls
|
|
186
|
+
if len(urls) > 0
|
|
187
|
+
}
|
|
188
|
+
for future in as_completed(futures):
|
|
189
|
+
path = futures[future]
|
|
190
|
+
try:
|
|
191
|
+
if path not in path_metadatas:
|
|
192
|
+
path_metadatas[path] = []
|
|
193
|
+
path_metadatas[path].append(future.result())
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logging.error(f'ANY OF THE FUTURES FAILED "{e}"')
|
|
196
|
+
sys.exit(4)
|
|
197
|
+
|
|
198
|
+
metadatas: Dict[str, Dict[str, Any]] = {
|
|
199
|
+
path: reduce(lambda acc, metadata: acc | metadata, metadatas, {})
|
|
200
|
+
for path, metadatas in path_metadatas.items()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for path, metadata in metadatas.items():
|
|
204
|
+
ppdt = {}
|
|
205
|
+
for key, value in metadata.items():
|
|
206
|
+
if key in {"__metadata__"}:
|
|
207
|
+
continue
|
|
208
|
+
if value["dtype"] not in ppdt:
|
|
209
|
+
ppdt[value["dtype"]] = 0
|
|
210
|
+
ppdt[value["dtype"]] += math.prod(value["shape"])
|
|
211
|
+
|
|
212
|
+
total = 0
|
|
213
|
+
for dtype, count in ppdt.items():
|
|
214
|
+
logging.info(f"[PATH={path}] DTYPE={dtype}, COUNT={count}\n")
|
|
215
|
+
|
|
216
|
+
match dtype:
|
|
217
|
+
case "F64" | "I64" | "U64":
|
|
218
|
+
dtype_b = 8
|
|
219
|
+
case "F32" | "I32" | "U32":
|
|
220
|
+
dtype_b = 4
|
|
221
|
+
case "F16" | "BF16" | "I16" | "U16":
|
|
222
|
+
dtype_b = 2
|
|
223
|
+
case "F8_E5M2" | "F8_E4M3" | "I8" | "U8":
|
|
224
|
+
dtype_b = 1
|
|
225
|
+
case _:
|
|
226
|
+
logging.error(f"DTYPE={dtype} NOT HANDLED")
|
|
227
|
+
sys.exit(2)
|
|
228
|
+
|
|
229
|
+
total += count * dtype_b
|
|
230
|
+
|
|
231
|
+
logging.info(f"[PATH={path}] TOTAL VRAM={total} (IN BYTES)")
|
|
232
|
+
logging.info(
|
|
233
|
+
f"[PATH={path}] TOTAL VRAM={total / (1024**2):.2f} (IN MEGABYTES)"
|
|
234
|
+
)
|
|
235
|
+
logging.info(
|
|
236
|
+
f"[PATH={path}] TOTAL VRAM={total / (1024**3):.2f} (IN GIGABYTES)\n"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
sys.exit(0)
|
|
240
|
+
else:
|
|
241
|
+
logging.error(
|
|
242
|
+
"NONE OF `model.safetensors`, `model.safetensors.index.json`, `model_index.json` HAS BEEN FOUND"
|
|
243
|
+
)
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
|
|
246
|
+
ppdt = {}
|
|
247
|
+
for key, value in metadata.items():
|
|
248
|
+
if key in {"__metadata__"}:
|
|
249
|
+
continue
|
|
250
|
+
if value["dtype"] not in ppdt:
|
|
251
|
+
ppdt[value["dtype"]] = 0
|
|
252
|
+
ppdt[value["dtype"]] += math.prod(value["shape"])
|
|
253
|
+
|
|
254
|
+
total = 0
|
|
255
|
+
for dtype, count in ppdt.items():
|
|
256
|
+
logging.info(f"DTYPE={dtype}, COUNT={count}\n")
|
|
257
|
+
|
|
258
|
+
match dtype:
|
|
259
|
+
case "F64" | "I64" | "U64":
|
|
260
|
+
dtype_b = 8
|
|
261
|
+
case "F32" | "I32" | "U32":
|
|
262
|
+
dtype_b = 4
|
|
263
|
+
case "F16" | "BF16" | "I16" | "U16":
|
|
264
|
+
dtype_b = 2
|
|
265
|
+
case "F8_E5M2" | "F8_E4M3" | "I8" | "U8":
|
|
266
|
+
dtype_b = 1
|
|
267
|
+
case _:
|
|
268
|
+
logging.error(f"DTYPE={dtype} NOT HANDLED")
|
|
269
|
+
sys.exit(2)
|
|
270
|
+
|
|
271
|
+
bytes_count = count * dtype_b
|
|
272
|
+
total += bytes_count
|
|
273
|
+
logging.info(f"VRAM={bytes_count} (IN BYTES)")
|
|
274
|
+
logging.info(f"VRAM={bytes_count / (1024**2):.2f} (IN MEGABYTES)")
|
|
275
|
+
logging.info(f"VRAM={bytes_count / (1024**3):.2f} (IN GIGABYTES)\n")
|
|
276
|
+
|
|
277
|
+
logging.info(f"TOTAL VRAM={total} (IN BYTES)")
|
|
278
|
+
logging.info(f"TOTAL VRAM={total / (1024**2):.2f} (IN MEGABYTES)")
|
|
279
|
+
logging.info(f"TOTAL VRAM={total / (1024**3):.2f} (IN GIGABYTES)")
|
|
280
|
+
|
|
281
|
+
sys.exit(0)
|
|
File without changes
|