Compare commits

...
Sign in to create a new pull request.

27 commits

Author SHA1 Message Date
pre-commit-ci[bot]
5564fe1750 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-21 00:36:54 +00:00
Daniel Han
78e2ece54c Merge remote-tracking branch 'staging/pr-5053-tests' into pr-5053-head 2026-04-21 00:36:39 +00:00
Daniel Han
e169ff2cd7 Consolidate review tests 2026-04-21 00:34:27 +00:00
Daniel Han
0c00397304 Add review tests 2026-04-21 00:34:27 +00:00
Daniel Han
a38ceafc88 Add review tests 2026-04-21 00:34:27 +00:00
Daniel Han
f11e520d06 Merge remote-tracking branch 'origin/main' 2026-04-21 00:34:27 +00:00
Daniel Han
2b9aee82db Merge remote-tracking branch 'origin/main' 2026-04-21 00:32:56 +00:00
Daniel Han
9741e2ff0b Split: keep only 10 file(s) 2026-04-20 23:19:40 +00:00
pre-commit-ci[bot]
564a031e70 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-16 18:06:26 +00:00
Daniel Han
89807227f7 Merge remote-tracking branch 'unslothai/fix/bnb-multidevice-inference-hooks' into pr-5053-head 2026-04-16 18:05:23 +00:00
Daniel Han
d1461ccc09 Add review tests for PR #5053 2026-04-16 18:02:38 +00:00
Daniel Han
7b324a63a1 Fix review findings for PR #15 2026-04-16 17:29:23 +00:00
Daniel Han
5e8e4487ed Fix review findings for PR #15 2026-04-16 17:01:46 +00:00
Daniel Han
29ab0fab74 Fix review findings for PR #15 2026-04-16 16:32:25 +00:00
Daniel Han
4edb8943c2 Fix review findings for PR #15 2026-04-16 15:58:59 +00:00
Daniel Han
705ef146da
Merge branch 'main' into fix/bnb-multidevice-inference-hooks 2026-04-16 08:39:22 -07:00
Daniel Han
dd3b2c1aab Merge remote-tracking branch 'origin/main' into pr-5053-head 2026-04-16 15:38:27 +00:00
Daniel Han
544195168c style: shorten call-site comment in vision.py 2026-04-16 15:35:09 +00:00
Daniel Han
01c5c1a581 style: trim docstrings and comments 2026-04-16 15:06:46 +00:00
Daniel Han
0df61f13e4 style: shorten inline comments 2026-04-16 14:54:58 +00:00
pre-commit-ci[bot]
bf3171e567 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-16 14:43:16 +00:00
Daniel Han
d6b758035a fix: address review findings -- force_hooks, CPU device conversion, llama.py path
Three fixes from 13-reviewer consensus:

1. Use dispatch_model(force_hooks=True) instead of manual AlignDevicesHook
   fallback. This properly handles single-device non-default GPU maps
   (e.g. all weights on cuda:1) with io_same_device routing, and
   eliminates the need for the separate add_hook_to_module call.

2. Convert non-CUDA devices (cpu, meta) to type strings in device_map_int.
   dispatch_model uses string equality (device == "cpu") internally, so
   torch.device("cpu") must become "cpu" not stay as an object.

3. Add _attach_bnb_multidevice_hooks call to FastLlamaModel.from_pretrained
   in llama.py. The default 4-bit FastLanguageModel path routes through
   llama.py:2482, not FastBaseModel, so the hook was never called for the
   most common use case (Llama, Gemma, Mistral, etc.).

Also removes redundant retie_parameters call (dispatch_model already
calls it internally) and unused imports.
2026-04-16 14:42:54 +00:00
pre-commit-ci[bot]
42b29a35ac [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-16 14:21:08 +00:00
Daniel Han
ab18542b7b fix: handle single-device non-default GPU and warn on parameter errors
dispatch_model skips AlignDevicesHook installation for single-device
maps (accelerate fast-path). When all weights sit on a non-default GPU
(e.g. cuda:1) the caller's inputs may still arrive on cuda:0, causing
a device mismatch. Detect this and manually add a root-level
AlignDevicesHook to route inputs.

Also replace the silent except-and-return on CUDA device collection
with a RuntimeWarning so users can debug failures.
2026-04-16 14:20:50 +00:00
Daniel Han
8603233829 fix: use dispatch_model and strip _is_hf_initialized for bnb multi-GPU hooks
Two bugs found during testing:

1. accelerate's set_module_tensor_to_device passes param.__dict__ as kwargs
   to Params4bit(), but HF Transformers adds _is_hf_initialized to that dict.
   Params4bit.__new__() does not accept it, causing TypeError. Fix: strip the
   key before dispatching, restore after.

2. attach_align_device_hook_on_blocks with a block-level device map only
   installs coarse-grained hooks. Sub-modules (e.g. RMSNorm) inherit the
   wrong execution device, causing cross-device errors at forward time.
   Fix: use dispatch_model which installs hooks at every sub-module level,
   matching HF's own from_pretrained behavior.

Tested with 28 tests across unit, single-GPU, multi-GPU (gemma-4-31B 4bit on
2x B200), and edge case suites -- all passing.
2026-04-16 12:47:02 +00:00
pre-commit-ci[bot]
bed825e04a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-16 11:48:58 +00:00
Daniel Han
45572dedd8 BUG: fix multi-GPU inference crash for bnb 4-bit/8-bit models
When load_in_4bit=True is used with device_map="sequential" and the model
is placed across multiple GPUs (or entirely on a non-default GPU like
cuda:1), the bitsandbytes loading path in transformers places weights on
the target CUDA devices but never calls dispatch_model. This leaves zero
AlignDevicesHook instances installed, so the first cross-device forward
call crashes with:

  RuntimeError: Expected all tensors to be on the same device, but got
  index is on cuda:0, different from other tensors on cuda:1

This commonly happens on Kaggle 2xT4 setups where Gemma-4-31B in 4-bit
(~17 GB) does not fit on a single 15 GB T4, forcing accelerate to shard
across both devices.

Fix: after from_pretrained returns a bnb-quantized model, infer a device
map from post-load parameter placement and call accelerate's
attach_align_device_hook_on_blocks to install input-routing hooks. Two
scenarios are handled:

1. Multi-device (weights span multiple GPUs): per-block hooks route each
   module's inputs to the device holding its weights.

2. Single non-default device (all weights on e.g. cuda:1 because cuda:0
   was too small): a root-level hook with io_same_device=True auto-moves
   inputs from any device to the model's device.

Guards ensure zero overhead for the common single-GPU-on-cuda:0 path:
- Only activates for load_in_4bit or load_in_8bit
- Skips fast_inference (vLLM), offload_embedding, already-dispatched models
- Skips when all weights are already on cuda:0

Tested on 2x B200 with Gemma-4-31B-it-unsloth-bnb-4bit across 6 different
max_memory configurations (1 GiB to 15 GiB per device). All pass.
2026-04-16 11:47:29 +00:00
13 changed files with 1134 additions and 0 deletions

View file

@ -0,0 +1,100 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _FakeMod:
def __init__(self, params = None, buffers = None, children = None, hf_device_map = None):
self._p = list(params or [])
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = hf_device_map
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for pn, pp in cm.named_parameters(
recurse = True, remove_duplicate = remove_duplicate
):
yield f"{cn}.{pn}", pp
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_attach_load_in_4bit_bool_alone_activates(monkeypatch):
"""Minimal contract: when only load_in_4bit=True is passed, the helper
activates without requiring model.is_loaded_in_4bit to also be True."""
import accelerate
called = {"n": 0}
monkeypatch.setattr(
accelerate,
"dispatch_model",
lambda *a, **kw: called.__setitem__("n", called["n"] + 1),
)
_, attach = _load_fns()
m = _FakeMod(params = [("w", "cuda:1")]) # no is_loaded_in_* attrs set
attach(
m,
load_in_4bit = True,
load_in_8bit = False,
offload_embedding = False,
fast_inference = False,
)
assert called["n"] == 1

View file

@ -0,0 +1,101 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _FakeMod:
def __init__(self, params = None, buffers = None, children = None, hf_device_map = None):
self._p = list(params or [])
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = hf_device_map
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for pn, pp in cm.named_parameters(
recurse = True, remove_duplicate = remove_duplicate
):
yield f"{cn}.{pn}", pp
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_attach_detects_bnb_via_is_loaded_in_8bit(monkeypatch):
"""8-bit loads via quantization_config zero out load_in_*bit booleans;
detection must still fire via model.is_loaded_in_8bit attribute."""
import accelerate
called = {"n": 0}
monkeypatch.setattr(
accelerate,
"dispatch_model",
lambda *a, **kw: called.__setitem__("n", called["n"] + 1),
)
_, attach = _load_fns()
m = _FakeMod(params = [("w", "cuda:1")])
m.is_loaded_in_8bit = True
attach(
m,
load_in_4bit = False,
load_in_8bit = False,
offload_embedding = False,
fast_inference = False,
)
assert called["n"] == 1

View file

@ -0,0 +1,103 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _FakeMod:
def __init__(self, params = None, buffers = None, children = None, hf_device_map = None):
self._p = list(params or [])
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = hf_device_map
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for pn, pp in cm.named_parameters(
recurse = True, remove_duplicate = remove_duplicate
):
yield f"{cn}.{pn}", pp
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_attach_main_device_skips_cpu_and_disk_candidates(monkeypatch):
"""When inferred_map values mix cpu + gpu, main_device fallback must skip
non-device entries. Verifies the iter-4 `d not in ("cpu", "disk")` filter
handles both string constants."""
import accelerate
rec = {}
monkeypatch.setattr(
accelerate, "dispatch_model", lambda model, **kw: rec.update(kw)
)
_, attach = _load_fns()
# First entry is cpu; fallback must find the cuda:1 entry instead.
a = _FakeMod(params = [("w", "cpu")])
b = _FakeMod(params = [("w", "cuda:1")])
m = _FakeMod(children = [("a", a), ("b", b)])
attach(
m,
load_in_4bit = True,
load_in_8bit = False,
offload_embedding = False,
fast_inference = False,
)
md = rec.get("main_device")
assert md == 1, f"main_device must skip cpu/disk strings, got {md!r}"

View file

@ -0,0 +1,110 @@
import ast
import warnings as _warnings
from pathlib import Path
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
class _RaisingLogger:
def info(self, *a, **kw):
raise RuntimeError("simulated broken logging handler")
def _load_fns(logger):
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logger}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _FakeMod:
def __init__(self, params = None):
self._p = list(params or [])
self.hf_device_map = None
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
return iter([])
def named_children(self):
return iter([])
def test_successful_dispatch_does_not_emit_misleading_warning_when_logger_raises(
monkeypatch,
):
"""When dispatch_model succeeds but the user's logger.info raises
(broken handler / strict test harness), the helper must NOT emit the
'Could not attach multi-device dispatch hooks automatically' warning,
because the hooks *are* installed."""
import accelerate
dispatch_called = {"n": 0}
monkeypatch.setattr(
accelerate,
"dispatch_model",
lambda *a, **kw: dispatch_called.__setitem__("n", dispatch_called["n"] + 1),
)
_, attach = _load_fns(_RaisingLogger())
m = _FakeMod(params = [("w", "cuda:1")])
with _warnings.catch_warnings(record = True) as caught:
_warnings.simplefilter("always")
try:
attach(
m,
load_in_4bit = True,
load_in_8bit = False,
offload_embedding = False,
fast_inference = False,
)
except RuntimeError:
# Post-fix: logger error may propagate (hooks are installed;
# surfacing a real logging misconfiguration is acceptable).
pass
assert dispatch_called["n"] == 1, "dispatch_model must have been called"
misleading = [
w
for w in caught
if "Could not attach multi-device dispatch hooks" in str(w.message)
]
assert not misleading, (
f"Must not emit the 'Could not attach' warning when dispatch "
f"actually succeeded (got {[str(w.message) for w in misleading]})"
)

View file

@ -0,0 +1,88 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _ObservableMod:
"""Tracks whether any mutation happened on its parameters."""
def __init__(self, params):
self._p = [(n, _P(d)) for n, d in params]
self.hf_device_map = None
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, p in self._p:
yield n, p
def parameters(self, recurse = True):
for _, p in self._p:
yield p
def named_buffers(self, recurse = True):
return iter([])
def named_children(self):
return iter([])
def test_early_exit_does_not_strip_params(monkeypatch):
"""When a guard triggers (fast_inference=True), the helper must return
before touching any parameter attributes (strip loop is never entered)."""
import accelerate
monkeypatch.setattr(accelerate, "dispatch_model", lambda *a, **kw: None)
_, attach = _load_fns()
m = _ObservableMod([("w", "cuda:1")])
p = m._p[0][1]
p._is_hf_initialized = "original"
p._other_attr = "keep"
attach(
m,
load_in_4bit = True,
load_in_8bit = False,
offload_embedding = False,
fast_inference = True,
)
assert p._is_hf_initialized == "original"
assert p._other_attr == "keep"

View file

@ -0,0 +1,89 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _TrackMod:
def __init__(self, params):
self._p = [(n, _P(d)) for n, d in params]
self.hf_device_map = None
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, p in self._p:
yield n, p
def parameters(self, recurse = True):
for _, p in self._p:
yield p
def named_buffers(self, recurse = True):
return iter([])
def named_children(self):
return iter([])
def test_attach_restores_is_hf_initialized_after_dispatch_raises(monkeypatch):
"""If dispatch_model raises, the inner finally must still restore the
stripped _is_hf_initialized attribute on every param."""
import accelerate
def boom(*a, **kw):
raise RuntimeError("dispatch blew up")
monkeypatch.setattr(accelerate, "dispatch_model", boom)
_, attach = _load_fns()
m = _TrackMod([("w", "cuda:1")])
p = next(iter(m._p))[1]
p._is_hf_initialized = True
with _warnings.catch_warnings():
_warnings.simplefilter("ignore")
attach(
m,
load_in_4bit = True,
load_in_8bit = False,
offload_embedding = False,
fast_inference = False,
)
assert p.__dict__.get("_is_hf_initialized") is True

View file

@ -0,0 +1,81 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _BufMod:
def __init__(self, buffers = None, children = None):
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = None
def named_parameters(self, recurse = True, remove_duplicate = False):
if False:
yield
def parameters(self, recurse = True):
if False:
yield
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_buffer_only_subtree_with_multi_device_recurses():
"""Param-less subtree whose buffers span multiple devices must NOT collapse
to the first buffer's device. It must recurse into children so each child
gets its own map entry keyed by its own prefix."""
infer, _ = _load_fns()
a = _BufMod(buffers = [("cache", "cuda:0")])
b = _BufMod(buffers = [("cache", "cuda:1")])
root = _BufMod(children = [("a", a), ("b", b)])
dm = infer(root)
assert dm.get("a") == torch.device("cuda", 0), dm
assert dm.get("b") == torch.device("cuda", 1), dm
assert "" not in dm, dm

View file

@ -0,0 +1,89 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _FakeMod:
def __init__(self, params = None, buffers = None, children = None, hf_device_map = None):
self._p = list(params or [])
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = hf_device_map
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for pn, pp in cm.named_parameters(
recurse = True, remove_duplicate = remove_duplicate
):
yield f"{cn}.{pn}", pp
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_infer_module_with_both_params_and_buffers():
"""A leaf carrying BOTH a param and a buffer on the same device collapses
to a single entry; the buffer must not confuse the single-device path."""
infer, _ = _load_fns()
m = _FakeMod(
params = [("weight", "cuda:1")],
buffers = [("running_mean", "cuda:1"), ("running_var", "cuda:1")],
)
dm = infer(m)
assert dm == {"": torch.device("cuda", 1)}

View file

@ -0,0 +1,93 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _FakeMod:
def __init__(self, params = None, buffers = None, children = None, hf_device_map = None):
self._p = list(params or [])
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = hf_device_map
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for pn, pp in cm.named_parameters(
recurse = True, remove_duplicate = remove_duplicate
):
yield f"{cn}.{pn}", pp
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_infer_three_level_deep_mixed():
"""Split at the third level of nesting: the algorithm must recurse deep
enough to distinguish grandchildren on different devices."""
infer, _ = _load_fns()
g1 = _FakeMod(params = [("w", "cuda:0")])
g2 = _FakeMod(params = [("w", "cuda:1")])
level2 = _FakeMod(children = [("g1", g1), ("g2", g2)])
level1 = _FakeMod(children = [("l2", level2)])
root = _FakeMod(children = [("l1", level1)])
dm = infer(root)
assert dm.get("l1.l2.g1") == torch.device("cuda", 0)
assert dm.get("l1.l2.g2") == torch.device("cuda", 1)
# Intermediate levels that are mixed must NOT collapse prematurely
assert "l1" not in dm or len({dm.get("l1"), dm.get("l1.l2.g1")}) > 1

77
test_infer_tied_params.py Normal file
View file

@ -0,0 +1,77 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = torch.device(dev) if isinstance(dev, str) else dev
class _TiedMod:
"""Emits the same parameter object under two different names to simulate
tied weights (lm_head.weight == embed.weight). With remove_duplicate=False
we yield both names; devices unioned must still be a single device."""
def __init__(self, dev):
self._shared = _P(dev)
self.hf_device_map = None
def named_parameters(self, recurse = True, remove_duplicate = False):
yield "embed.weight", self._shared
if not remove_duplicate:
yield "lm_head.weight", self._shared
def parameters(self, recurse = True):
yield self._shared
def named_buffers(self, recurse = True):
return iter([])
def named_children(self):
return iter([])
def test_infer_tied_params_single_entry():
"""Tied-weight models (same Parameter yielded twice under different names)
must still collapse to a single-device map entry."""
infer, _ = _load_fns()
m = _TiedMod("cuda:1")
dm = infer(m)
assert dm == {"": torch.device("cuda", 1)}

View file

@ -0,0 +1,92 @@
import ast
from pathlib import Path
import logging
import warnings as _warnings
import torch
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def _load_fns():
tree = ast.parse(_find_vision().read_text())
ns = {"torch": torch, "warnings": _warnings, "logger": logging.getLogger("test")}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {
"_infer_device_map_from_loaded_model",
"_attach_bnb_multidevice_hooks",
}:
exec(
compile(
ast.Module(body = [node], type_ignores = []),
str(_find_vision()),
"exec",
),
ns,
)
return ns["_infer_device_map_from_loaded_model"], ns[
"_attach_bnb_multidevice_hooks"
]
class _P:
def __init__(self, dev):
self.device = dev if isinstance(dev, torch.device) else torch.device(dev)
class _FakeMod:
def __init__(self, params = None, buffers = None, children = None, hf_device_map = None):
self._p = list(params or [])
self._b = list(buffers or [])
self._c = list(children or [])
self.hf_device_map = hf_device_map
def named_parameters(self, recurse = True, remove_duplicate = False):
for n, d in self._p:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for pn, pp in cm.named_parameters(
recurse = True, remove_duplicate = remove_duplicate
):
yield f"{cn}.{pn}", pp
def parameters(self, recurse = True):
for _, p in self.named_parameters(recurse = recurse):
yield p
def named_buffers(self, recurse = True):
for n, d in self._b:
yield n, _P(d)
if recurse:
for cn, cm in self._c:
for bn, bb in cm.named_buffers(recurse = True):
yield f"{cn}.{bn}", bb
def named_children(self):
yield from self._c
def test_infer_handles_xpu_device_recursion():
"""XPU / custom-device types must recurse and be assigned correctly. The
infer function is device-type agnostic and should treat different xpu
indices as distinct devices for map-building purposes."""
infer, _ = _load_fns()
# xpu:0 and xpu:1 — avoids any cuda-specific codepath in infer
a = _FakeMod(params = [("w", torch.device("xpu", 0))])
b = _FakeMod(params = [("w", torch.device("xpu", 1))])
root = _FakeMod(children = [("a", a), ("b", b)])
dm = infer(root)
assert dm.get("a") == torch.device("xpu", 0)
assert dm.get("b") == torch.device("xpu", 1)
assert "" not in dm

View file

@ -0,0 +1,70 @@
import ast
from pathlib import Path
def _find_llama():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "llama.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "llama.py",
]:
if p.exists():
return p
raise FileNotFoundError("llama.py not found")
def _collect_hook_calls_under(node):
"""Yield every Call node invoking _attach_bnb_multidevice_hooks within the
AST subtree rooted at `node`."""
for sub in ast.walk(node):
if (
isinstance(sub, ast.Call)
and getattr(sub.func, "id", None) == "_attach_bnb_multidevice_hooks"
):
yield sub
def _kwarg_literal(call, name):
for kw in call.keywords:
if kw.arg == name and isinstance(kw.value, ast.Constant):
return kw.value.value
if kw.arg == name:
return kw.value
return None
def test_seq_class_branch_passes_fast_inference_false():
"""The AutoModelForSequenceClassification branch (gated by
`if num_labels is not None:`) must pass fast_inference=False to
_attach_bnb_multidevice_hooks. That branch never reaches vLLM, so
forwarding a truthy fast_inference would short-circuit hook install
and regress multi-GPU bnb seq-class inference."""
tree = ast.parse(_find_llama().read_text())
hook_calls = []
for if_node in ast.walk(tree):
if not isinstance(if_node, ast.If):
continue
test = if_node.test
is_num_labels_not_none = (
isinstance(test, ast.Compare)
and isinstance(test.left, ast.Name)
and test.left.id == "num_labels"
and len(test.ops) == 1
and isinstance(test.ops[0], ast.IsNot)
and len(test.comparators) == 1
and isinstance(test.comparators[0], ast.Constant)
and test.comparators[0].value is None
)
if not is_num_labels_not_none:
continue
for body_node in if_node.body:
hook_calls.extend(_collect_hook_calls_under(body_node))
assert (
hook_calls
), "No _attach_bnb_multidevice_hooks call found under `if num_labels is not None:`"
for call in hook_calls:
v = _kwarg_literal(call, "fast_inference")
assert (
v is False
), f"seq-class hook call must use fast_inference=False (got {ast.dump(call)})"

View file

@ -0,0 +1,41 @@
import ast
from pathlib import Path
def _find_vision():
for p in [
Path(__file__).resolve().parent / "unsloth" / "models" / "vision.py",
Path(__file__).resolve().parents[1] / "unsloth" / "models" / "vision.py",
Path(
"/mnt/disks/unslothai/ubuntu/workspace_25/github_review/unsloth-pr-5053-staging-3/unsloth/models/vision.py"
),
]:
if p.exists():
return p
raise FileNotFoundError("vision.py not found")
def test_vision_fastbasemodel_from_pretrained_calls_helper():
"""FastBaseModel.from_pretrained must invoke _attach_bnb_multidevice_hooks
after the underlying model load so the inference hook path is reachable
via the base vision loader, not only via llama."""
src = _find_vision().read_text()
tree = ast.parse(src)
found = False
for cls in ast.walk(tree):
if not (isinstance(cls, ast.ClassDef) and cls.name == "FastBaseModel"):
continue
for fn in ast.walk(cls):
if not (isinstance(fn, ast.FunctionDef) and fn.name == "from_pretrained"):
continue
for node in ast.walk(fn):
if (
isinstance(node, ast.Call)
and getattr(node.func, "id", None)
== "_attach_bnb_multidevice_hooks"
):
found = True
break
assert (
found
), "FastBaseModel.from_pretrained must call _attach_bnb_multidevice_hooks"