Skip to content

Commit f25de35

Browse files
feat: add anonymous feature-usage telemetry
New `bitsandbytes._telemetry.report_feature()` sends one event per distinct feature per process via `huggingface_hub.utils.send_telemetry()`, mirroring the pattern Transformers uses for its `quant` user-agent field. Data lands in the Hub telemetry index under `path_prefix=/api/telemetry/bitsandbytes/` and informs which features are worth maintaining or retiring. Wired at: Linear4bit/Linear8bitLt forward, Params4bit/Int8Params __new__, all Embedding variants, Optimizer8bit.step, GlobalOptimManager overrides, OutlierAwareLinear and int8_double_quant (deprecation candidates). All metadata keys namespaced under `bitsandbytes.*`. Fingerprint carries bnb version, OS, arch, libc, Python/torch versions, and accelerator vendor / name / arch / count. No model names, file paths, or user-derived values are ever sent. Opt-out via BNB_DISABLE_TELEMETRY, HF_HUB_DISABLE_TELEMETRY, or HF_HUB_OFFLINE. Auto-disabled under pytest so CI and local test runs don't pollute the real-usage stream. Silent no-op when huggingface_hub is not installed. End-to-end verification: `scripts/verify_telemetry.py` emits every feature once tagged with a unique run_id via BNB_TELEMETRY_TAG, for correlation in Elasticsearch queries on `ds-hub-telemetry`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2de5ec3 commit f25de35

7 files changed

Lines changed: 617 additions & 0 deletions

File tree

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,43 @@ bitsandbytes has the following minimum requirements for all platforms:
183183
* 🤗 [Diffusers](https://huggingface.co/docs/diffusers/quantization/bitsandbytes)
184184
* 🤗 [PEFT](https://huggingface.co/docs/peft/developer_guides/quantization#quantize-a-model)
185185

186+
## Telemetry
187+
188+
`bitsandbytes` sends anonymous, aggregate feature-usage telemetry to the
189+
Hugging Face Hub. This data is used to prioritize maintenance (which quantization
190+
methods and optimizers are actually in use?) and to safely retire features that
191+
are no longer called by anyone.
192+
193+
### What is collected
194+
195+
* A session fingerprint sent once per process: `bitsandbytes` version, OS
196+
name/version, CPU architecture, Python/PyTorch versions, accelerator
197+
vendor/name/arch/count (e.g. `nvidia`, `NVIDIA H100`, `sm_90`, `1`).
198+
* One event per distinct feature used, with feature-specific flags. For
199+
example: using `Linear4bit` sends `quant_type=nf4`, `blocksize=64`; using
200+
`AdamW8bit.step()` sends `name=AdamW8bit`, `is_paged=false`.
201+
202+
### What is never collected
203+
204+
Model names, file paths, tensor shapes, parameter values, user identifiers, or
205+
anything derived from user input.
206+
207+
### How to opt out
208+
209+
Set any one of these environment variables:
210+
211+
| Variable | Scope |
212+
| ---------------------------- | ---------------------------- |
213+
| `BNB_DISABLE_TELEMETRY=1` | `bitsandbytes` only |
214+
| `HF_HUB_DISABLE_TELEMETRY=1` | all Hugging Face libraries |
215+
| `HF_HUB_OFFLINE=1` | all Hugging Face libraries |
216+
217+
Telemetry is also automatically suppressed while running under `pytest` (so
218+
CI and local test runs don't pollute the stream) and a silent no-op when
219+
`huggingface_hub` is not installed. The implementation lives in
220+
[`bitsandbytes/_telemetry.py`](bitsandbytes/_telemetry.py) and each event
221+
fires at most once per process.
222+
186223
## :heart: Sponsors
187224
The continued maintenance and development of `bitsandbytes` is made possible thanks to the generous support of our sponsors. Their contributions help ensure that we can keep improving the project and delivering valuable updates to the community.
188225

bitsandbytes/_telemetry.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# Copyright (c) Facebook, Inc. and its affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
"""Anonymous feature-usage telemetry for bitsandbytes.
6+
7+
Sends one HEAD request per distinct feature per process via
8+
`huggingface_hub.utils.send_telemetry()`. Data lands in the Hugging Face
9+
Hub telemetry index under `path_prefix == "/api/telemetry/bitsandbytes/"`
10+
and informs maintenance and deprecation decisions.
11+
12+
What is collected
13+
- Session fingerprint (once per process, first feature use):
14+
bnb version, OS name/version, CPU arch, glibc version, Python/torch
15+
versions, accelerator vendor/name/arch/count.
16+
- Per-feature events: feature name plus feature-specific metadata
17+
(e.g. `quant_type="nf4"`, `bits="8"`, `paged="true"`).
18+
19+
What is NOT collected
20+
Model names, file paths, parameter shapes, user identifiers, training
21+
data, gradient values, or any value derived from user input.
22+
23+
Automatically disabled when running under pytest (detected via
24+
`pytest` in `sys.modules` or `PYTEST_CURRENT_TEST` env var) so that test
25+
runs in CI and locally do not pollute the real-usage stream.
26+
27+
Opt-out (any of the following env vars disables all telemetry):
28+
- BNB_DISABLE_TELEMETRY=1 (bitsandbytes only)
29+
- HF_HUB_DISABLE_TELEMETRY=1 (all HF libraries)
30+
- HF_HUB_OFFLINE=1 (all HF libraries)
31+
32+
End-to-end verification:
33+
Set `BNB_TELEMETRY_TAG=<some-id>` before importing bitsandbytes and the
34+
value is attached as `bitsandbytes.tag` on every event. Use this to
35+
correlate a single run's events in ES.
36+
37+
No-ops silently if `huggingface_hub` is not installed, and never raises.
38+
39+
Keys are namespaced under `bitsandbytes.*` in the resulting
40+
`metadata.bitsandbytes.*` fields so they do not collide with fields logged
41+
by other libraries in the shared telemetry index.
42+
"""
43+
44+
from __future__ import annotations
45+
46+
import logging
47+
import os
48+
import platform
49+
import sys
50+
from typing import Optional
51+
52+
logger = logging.getLogger(__name__)
53+
54+
_REPORTED: set[str] = set()
55+
_FINGERPRINT: Optional[dict[str, str]] = None
56+
57+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
58+
59+
60+
def _is_pytest() -> bool:
61+
"""Detect whether we are running inside a pytest process.
62+
63+
Telemetry is suppressed during test runs so that CI and local test
64+
invocations don't pollute the real-usage stream. Tests that want to
65+
assert on telemetry behavior monkey-patch this function to return False.
66+
"""
67+
return "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
68+
69+
70+
def _is_disabled() -> bool:
71+
for var in ("BNB_DISABLE_TELEMETRY", "HF_HUB_DISABLE_TELEMETRY", "HF_HUB_OFFLINE"):
72+
if os.environ.get(var, "").strip().lower() in _TRUTHY:
73+
return True
74+
if _is_pytest():
75+
return True
76+
return False
77+
78+
79+
def _os_info() -> tuple[str, str]:
80+
os_name = platform.system()
81+
os_name = {"Darwin": "macOS"}.get(os_name, os_name)
82+
if os_name == "Windows":
83+
try:
84+
build = sys.getwindowsversion().build
85+
os_version = f"11 (build {build})" if build >= 22000 else f"10 (build {build})"
86+
except Exception:
87+
os_version = platform.release()
88+
elif os_name == "macOS":
89+
os_version = platform.mac_ver()[0] or platform.release()
90+
else:
91+
os_version = platform.release()
92+
return os_name, os_version
93+
94+
95+
def _accel_info() -> dict[str, str]:
96+
info: dict[str, str] = {}
97+
try:
98+
import torch
99+
except ImportError:
100+
info["bitsandbytes.accel"] = "unknown"
101+
return info
102+
103+
try:
104+
if torch.cuda.is_available():
105+
vendor = "amd" if getattr(torch.version, "hip", None) else "nvidia"
106+
info["bitsandbytes.accel"] = vendor
107+
info["bitsandbytes.accel_count"] = str(torch.cuda.device_count())
108+
props = torch.cuda.get_device_properties(0)
109+
info["bitsandbytes.accel_name"] = props.name
110+
if vendor == "nvidia":
111+
info["bitsandbytes.accel_arch"] = f"sm_{props.major}{props.minor}"
112+
else:
113+
info["bitsandbytes.accel_arch"] = getattr(props, "gcnArchName", "unknown")
114+
return info
115+
116+
if hasattr(torch, "xpu") and torch.xpu.is_available():
117+
info["bitsandbytes.accel"] = "xpu"
118+
info["bitsandbytes.accel_count"] = str(torch.xpu.device_count())
119+
try:
120+
info["bitsandbytes.accel_name"] = torch.xpu.get_device_properties(0).name
121+
except Exception:
122+
pass
123+
return info
124+
125+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
126+
info["bitsandbytes.accel"] = "mps"
127+
return info
128+
129+
if hasattr(torch, "hpu") and torch.hpu.is_available():
130+
info["bitsandbytes.accel"] = "hpu"
131+
return info
132+
except Exception:
133+
pass
134+
135+
info["bitsandbytes.accel"] = "cpu"
136+
return info
137+
138+
139+
def _fingerprint() -> dict[str, str]:
140+
global _FINGERPRINT
141+
if _FINGERPRINT is not None:
142+
return _FINGERPRINT
143+
144+
try:
145+
import bitsandbytes
146+
147+
version = bitsandbytes.__version__
148+
except Exception:
149+
version = "unknown"
150+
151+
os_name, os_version = _os_info()
152+
info = {
153+
"bitsandbytes.version": version,
154+
"bitsandbytes.os": os_name,
155+
"bitsandbytes.os_version": os_version,
156+
"bitsandbytes.arch": platform.machine(),
157+
"bitsandbytes.python": platform.python_version(),
158+
}
159+
if os_name == "Linux":
160+
try:
161+
libc_name, libc_ver = platform.libc_ver()
162+
if libc_name:
163+
info["bitsandbytes.libc"] = f"{libc_name}-{libc_ver}"
164+
except Exception:
165+
pass
166+
try:
167+
import torch
168+
169+
info["bitsandbytes.torch"] = torch.__version__
170+
except ImportError:
171+
pass
172+
173+
info.update(_accel_info())
174+
175+
_FINGERPRINT = info
176+
return info
177+
178+
179+
def report_feature(feature: str, details: Optional[dict[str, object]] = None) -> None:
180+
"""Report that a bitsandbytes feature was used.
181+
182+
Fires at most once per `feature` per process. Subsequent calls with the
183+
same `feature` are O(1) no-ops.
184+
185+
Args:
186+
feature: Short feature name. Becomes the final URL path segment:
187+
`/api/telemetry/bitsandbytes/{feature}` (so it appears as
188+
`path_filename` in ES queries).
189+
details: Optional feature-specific key/value metadata. Keys without a
190+
`bitsandbytes.` prefix are prefixed automatically.
191+
"""
192+
if feature in _REPORTED:
193+
return
194+
_REPORTED.add(feature)
195+
196+
if _is_disabled():
197+
return
198+
199+
try:
200+
from huggingface_hub.utils import send_telemetry
201+
except ImportError:
202+
return
203+
204+
fingerprint = _fingerprint()
205+
user_agent = dict(fingerprint)
206+
user_agent["bitsandbytes.feature"] = feature
207+
if details:
208+
for k, v in details.items():
209+
key = k if k.startswith("bitsandbytes.") else f"bitsandbytes.{k}"
210+
user_agent[key] = str(v)
211+
212+
tag = os.environ.get("BNB_TELEMETRY_TAG", "").strip()
213+
if tag:
214+
user_agent["bitsandbytes.tag"] = tag
215+
216+
try:
217+
send_telemetry(
218+
topic=f"bitsandbytes/{feature}",
219+
library_name="bitsandbytes",
220+
library_version=fingerprint.get("bitsandbytes.version", "unknown"),
221+
user_agent=user_agent,
222+
)
223+
except Exception as e:
224+
logger.debug("bitsandbytes telemetry send failed: %s", e)
225+
226+
227+
def _reset_for_testing() -> None:
228+
"""Clear module state. Intended for use in test fixtures only."""
229+
global _FINGERPRINT
230+
_REPORTED.clear()
231+
_FINGERPRINT = None

bitsandbytes/functional.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import torch
1313
from torch import Tensor
1414

15+
from bitsandbytes._telemetry import report_feature
1516
from bitsandbytes.utils import pack_dict_to_tensor, unpack_tensor_to_dict
1617

1718
from .cextension import lib
@@ -1593,6 +1594,8 @@ def int8_double_quant(
15931594
- `torch.Tensor` with dtype `torch.int32`, *optional*: A list of column indices which contain outlier features.
15941595
"""
15951596

1597+
report_feature("int8_double_quant")
1598+
15961599
if row_stats is not None:
15971600
raise ValueError("row_stats must be None. int8_double_quant() does not support pre-allocated row_stats.")
15981601
if col_stats is not None:

bitsandbytes/nn/modules.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import torch.nn.functional as F
1212

1313
import bitsandbytes as bnb
14+
from bitsandbytes._telemetry import report_feature
1415
from bitsandbytes.functional import (
1516
QuantState,
1617
_convert_weight_packed_for_cpu,
@@ -97,6 +98,7 @@ def __init__(
9798
)
9899
self.norm = torch.nn.LayerNorm(embedding_dim, device=device)
99100
GlobalOptimManager.get_instance().register_module_override(self, "weight", {"optim_bits": 32})
101+
report_feature("embedding", {"variant": "stable"})
100102

101103
def reset_parameters(self) -> None:
102104
torch.nn.init.xavier_uniform_(self.weight)
@@ -179,6 +181,7 @@ def __init__(
179181
device=device,
180182
)
181183
GlobalOptimManager.get_instance().register_module_override(self, "weight", {"optim_bits": 32})
184+
report_feature("embedding", {"variant": "standard"})
182185

183186
def reset_parameters(self) -> None:
184187
torch.nn.init.xavier_uniform_(self.weight)
@@ -239,6 +242,15 @@ def __new__(
239242
self.bnb_quantized = bnb_quantized
240243
self.data = data
241244
self.module = module
245+
report_feature(
246+
"params_4bit",
247+
{
248+
"quant_type": quant_type,
249+
"blocksize": blocksize,
250+
"compress_statistics": compress_statistics,
251+
"quant_storage": str(quant_storage).replace("torch.", ""),
252+
},
253+
)
242254
return self
243255

244256
def __getstate__(self):
@@ -607,6 +619,16 @@ def _save_to_state_dict(self, destination, prefix, keep_vars):
607619
destination[prefix + "weight." + k] = v if keep_vars else v.detach()
608620

609621
def forward(self, x: torch.Tensor):
622+
report_feature(
623+
"linear_4bit",
624+
{
625+
"quant_type": getattr(self.weight, "quant_type", "unknown"),
626+
"blocksize": getattr(self.weight, "blocksize", 0),
627+
"compress_statistics": getattr(self.weight, "compress_statistics", False),
628+
"input_dtype": str(x.dtype).replace("torch.", ""),
629+
"compute_dtype": (str(self.compute_dtype).replace("torch.", "") if self.compute_dtype else "auto"),
630+
},
631+
)
610632
fix_4bit_weight_quant_state_from_module(self)
611633
quant_state = self.weight.quant_state
612634

@@ -732,6 +754,7 @@ def __new__(
732754
obj.CB = CB
733755
obj.SCB = SCB
734756
obj.has_fp16_weights = has_fp16_weights
757+
report_feature("int8_params", {"has_fp16_weights": has_fp16_weights})
735758
return obj
736759

737760
def _quantize(self, device):
@@ -855,6 +878,7 @@ def __init__(self, num_embeddings, embedding_dim, device=None, dtype=None):
855878
self.dtype = self.weight.data.dtype
856879

857880
self.weight = Int8Params(self.weight.data, has_fp16_weights=False, requires_grad=False)
881+
report_feature("embedding", {"variant": "8bit"})
858882

859883
def _save_to_state_dict(self, destination, prefix, keep_vars):
860884
raise NotImplementedError("Saving Embedding8bit module is not implemented")
@@ -926,6 +950,7 @@ def __init__(
926950
f"Embedding size {embedding_dim} is not divisible by block size {blocksize}. "
927951
"This will lead to slow inference.",
928952
)
953+
report_feature("embedding", {"variant": "4bit", "quant_type": quant_type})
929954

930955
def _forward_with_partial_dequantize(self, input: Tensor):
931956
assert self.embedding_dim % self.weight.quant_state.blocksize == 0
@@ -1178,6 +1203,14 @@ def to(self, *args, **kwargs):
11781203
return result
11791204

11801205
def forward(self, x: torch.Tensor):
1206+
report_feature(
1207+
"linear_8bit",
1208+
{
1209+
"has_fp16_weights": self.state.has_fp16_weights,
1210+
"threshold": self.state.threshold,
1211+
"input_dtype": str(x.dtype).replace("torch.", ""),
1212+
},
1213+
)
11811214
self.state.is_training = self.training
11821215
if self.weight.CB is not None:
11831216
self.init_8bit_state()
@@ -1199,6 +1232,7 @@ def __init__(self, input_features, output_features, bias=True, device=None):
11991232
super().__init__(input_features, output_features, bias, device)
12001233
self.outlier_dim = None
12011234
self.is_quantized = False
1235+
report_feature("outlier_aware_linear")
12021236

12031237
def forward_with_outliers(self, x, outlier_idx):
12041238
raise NotImplementedError("Please override the `forward_with_outliers(self, x, outlier_idx)` function")

0 commit comments

Comments
 (0)