jundot--omlx
412 行
16 KiB
Python
412 行
16 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Minimal ``torch`` stub for the DMG bundle.
|
|
|
|
xgrammar 0.2.3 declares ``torch>=1.10.0`` as a runtime dep, but oMLX never
|
|
exercises its torch-backed code paths: bitmasks are allocated as numpy
|
|
``int32`` buffers, the C++ binding fills them, and the MLX kernel applies the
|
|
mask. The torch dep is load-bearing only at *import time* — module-level code
|
|
in ``xgrammar.matcher``, ``xgrammar.testing``, ``xgrammar.contrib.hf`` and
|
|
``tvm_ffi.core`` does ``import torch`` plus a handful of attribute lookups.
|
|
|
|
Real torch is ~500 MB unpacked on macOS arm64 — too heavy to ship in the DMG.
|
|
This stub provides just enough of the torch surface for those modules to
|
|
finish loading. Code paths that would actually call into torch raise
|
|
``RuntimeError`` from the helpers below; oMLX never reaches them.
|
|
|
|
When a real torch is installed (pip / Homebrew flow) the stub is a no-op:
|
|
``install()`` checks ``importlib.util.find_spec('torch')`` first.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
import types
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# xgrammar / tvm-ffi versions this stub is known to cover.
|
|
# This module is the *single source of truth* — packaging/build.py imports
|
|
# these constants to keep the DMG install pin in sync with the stub. Update
|
|
# both tuples here when bumping; the build script auto-tracks.
|
|
#
|
|
# Reachable-but-stubbed torch surface to be aware of when upgrading:
|
|
# - ``torch.full``: ``xgrammar.allocate_token_bitmask`` calls it. oMLX
|
|
# never invokes ``allocate_token_bitmask`` (we use the MLX kernel
|
|
# path), but the symbol is re-exported from ``xgrammar.__init__``.
|
|
# Any future caller that touches it will hit ``_unsupported("full")``
|
|
# and surface a clear RuntimeError.
|
|
# - ``torch.tensor`` returns a ``_StubTensor`` whose attribute access
|
|
# raises a stub-identifying RuntimeError. Module-level
|
|
# ``_FULL_MASK = torch.tensor(-1, ...)`` patterns succeed at import
|
|
# time; any subsequent method call (.fill_, .item, ...) fails.
|
|
_TARGET_XGRAMMAR_VERSIONS = ("0.2.3",)
|
|
_TARGET_TVM_FFI_VERSIONS = ("0.1.11",)
|
|
|
|
# Serialize install() across threads. Without this, two threads that both
|
|
# pass the "torch" in sys.modules check race to build modules and overwrite
|
|
# each other's sys.modules['torch'] entry, leaving threads that already
|
|
# dereferenced the loser's module with stale references. Reachable today
|
|
# from concurrent HTTP handlers that call install() on first xgrammar use.
|
|
_INSTALL_LOCK = threading.Lock()
|
|
_INSTALLED = False
|
|
|
|
|
|
class _StubTensor:
|
|
"""Placeholder for ``torch.Tensor`` (annotations + isinstance checks).
|
|
|
|
Any attribute access raises a clear RuntimeError so runtime use of a
|
|
stubbed tensor (e.g. ``some_tensor.fill_(...)``) fails loudly with a
|
|
pointer to the cause, rather than at the AttributeError level with a
|
|
generic ``has no attribute 'fill_'`` message.
|
|
"""
|
|
|
|
def __getattr__(self, name: str):
|
|
# Let dunder probes (pickle, copy.deepcopy, descriptor lookups,
|
|
# `hasattr` chains in third-party libs) fall through cleanly as
|
|
# AttributeError — that's the documented `__getattr__` contract.
|
|
# Real torch tensors lack many of these probed dunders anyway, so
|
|
# raising AttributeError is the correct, distinguishable signal.
|
|
if name.startswith("__") and name.endswith("__"):
|
|
raise AttributeError(name)
|
|
raise RuntimeError(
|
|
f"_StubTensor.{name} is not implemented: oMLX ships a torch "
|
|
"stub for xgrammar's import-time needs only. Reaching a real "
|
|
"tensor method means a code path that needs real torch was "
|
|
"exercised — install torch via pip/Homebrew or report this as "
|
|
"a bug if the call originated inside oMLX."
|
|
)
|
|
|
|
|
|
class _StubDtype:
|
|
__slots__ = ("_name",)
|
|
|
|
def __init__(self, name: str) -> None:
|
|
self._name = name
|
|
|
|
def __repr__(self) -> str:
|
|
return f"torch.{self._name}"
|
|
|
|
# Some xgrammar/tvm-ffi paths convert dtype to string via ``str(dt)``
|
|
# rather than ``repr(dt)`` (e.g. ``to_cpp_dtype`` strips the "torch."
|
|
# prefix). Match real torch's behaviour where ``str(torch.int32)`` is
|
|
# ``"torch.int32"`` so those paths keep working.
|
|
def __str__(self) -> str:
|
|
return f"torch.{self._name}"
|
|
|
|
|
|
def _stub_tensor_factory(*args, **kwargs) -> _StubTensor:
|
|
"""torch.tensor(...) stub: returns a _StubTensor instance.
|
|
|
|
Returning a real object (rather than None) means module-globals like
|
|
xgrammar.matcher._FULL_MASK = torch.tensor(-1, dtype=...) succeed at
|
|
import time. Any subsequent method call on the result (.fill_, .item,
|
|
etc.) raises with a clear pointer via _StubTensor.__getattr__.
|
|
"""
|
|
return _StubTensor()
|
|
|
|
|
|
def _false(*args, **kwargs) -> bool:
|
|
return False
|
|
|
|
|
|
def _true(*args, **kwargs) -> bool:
|
|
return True
|
|
|
|
|
|
def _zero(*args, **kwargs) -> int:
|
|
return 0
|
|
|
|
|
|
def _unsupported(qualname: str):
|
|
def _fn(*args, **kwargs):
|
|
raise RuntimeError(
|
|
f"torch.{qualname} is not available: this oMLX build ships a "
|
|
"torch stub for xgrammar's import-time needs only. Install "
|
|
"real torch via pip/Homebrew if you need this code path."
|
|
)
|
|
|
|
return _fn
|
|
|
|
|
|
# (canonical, alias) pairs — real torch aliases torch.int to torch.int32,
|
|
# torch.long to torch.int64, etc.; preserve those identities so code that
|
|
# does ``torch.int is torch.int32`` keeps working.
|
|
_DTYPE_ALIASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("int32", ("int",)),
|
|
("int16", ("short",)),
|
|
("int64", ("long",)),
|
|
("float16", ("half",)),
|
|
("float32", ("float",)),
|
|
("float64", ("double",)),
|
|
("int8", ()),
|
|
("uint8", ()),
|
|
("bfloat16", ()),
|
|
("bool", ()),
|
|
)
|
|
|
|
_TENSOR_ALIASES = (
|
|
"Tensor", "LongTensor", "FloatTensor", "IntTensor", "ByteTensor",
|
|
"DoubleTensor", "HalfTensor", "BoolTensor", "ShortTensor",
|
|
)
|
|
|
|
|
|
# Names that xgrammar / tvm_ffi probe via getattr(torch, name) for
|
|
# feature-detection — they catch AttributeError and fall back gracefully.
|
|
# Logging WARNING for these floods the log on every model load (one per
|
|
# name per process) with diagnostics that aren't actually actionable.
|
|
# Demote known-probed names to DEBUG; everything else stays WARNING so
|
|
# genuinely-missing attributes surface in operator logs.
|
|
_KNOWN_PROBE_NAMES: frozenset[str] = frozenset({
|
|
# Integer dtypes added post-torch-2.0 that tvm_ffi.dtypes enumerates
|
|
"uint16", "uint32", "uint64",
|
|
# FP8 / FP4 dtypes (probed by tvm_ffi.dtypes' dtype-mapping table)
|
|
"float8_e4m3fn", "float8_e4m3fnuz",
|
|
"float8_e5m2", "float8_e5m2fnuz",
|
|
"float8_e8m0fnu",
|
|
"float4_e2m1fn_x2",
|
|
})
|
|
|
|
|
|
def _make_top_level_torch_getattr() -> "callable":
|
|
"""Return a ``__getattr__`` for the stub's top-level torch module.
|
|
|
|
Real-torch users who reach an unset attribute would get an
|
|
``AttributeError``; consumers that probe with ``hasattr`` rely on that.
|
|
But we *also* want a clearly-identifiable message when downstream
|
|
libraries (transformers, accelerate, etc.) reach for a torch surface
|
|
we never stubbed — so this raises ``AttributeError`` whose message
|
|
pinpoints the omlx stub. ``pkgutil.iter_modules(torch.__path__)`` and
|
|
similar discovery paths see the empty ``__path__`` and short-circuit
|
|
before hitting this.
|
|
"""
|
|
|
|
_missing_attr_logged: set[str] = set()
|
|
|
|
def __getattr__(name: str): # noqa: N807
|
|
# Surface the miss at WARNING level so a future xgrammar release
|
|
# reaching for a new torch attribute is diagnosable from logs
|
|
# before the AttributeError surfaces in a request handler. Rate-
|
|
# limit per name so repeated probes (e.g. hasattr() under a
|
|
# loop) don't flood the journal — once per name per process is
|
|
# enough to identify the gap. Known-probed dtype names log at
|
|
# DEBUG because xgrammar / tvm_ffi catch the AttributeError and
|
|
# the WARNING is pure noise on every model load.
|
|
if name not in _missing_attr_logged:
|
|
_missing_attr_logged.add(name)
|
|
level = logging.DEBUG if name in _KNOWN_PROBE_NAMES else logging.WARNING
|
|
logger.log(
|
|
level,
|
|
"oMLX torch stub missing attribute: torch.%s "
|
|
"(install real torch if this is load-bearing)",
|
|
name,
|
|
)
|
|
# Dunder probes always fall through as AttributeError so pickling,
|
|
# copy.deepcopy, and similar Python machinery work as expected.
|
|
raise AttributeError(
|
|
f"torch.{name!s} is not provided by the oMLX torch stub. "
|
|
"Install real torch via pip/Homebrew if this attribute is "
|
|
"actually needed."
|
|
)
|
|
|
|
return __getattr__
|
|
|
|
|
|
def _build_modules() -> dict[str, types.ModuleType]:
|
|
torch = types.ModuleType("torch")
|
|
for alias in _TENSOR_ALIASES:
|
|
setattr(torch, alias, _StubTensor)
|
|
torch.dtype = _StubDtype
|
|
torch.__version__ = "0.0.0+omlx-stub"
|
|
# Pin the stub as the source of truth for the xgrammar version it
|
|
# targets; packaging/build.py imports this constant to stay in sync.
|
|
# (Module-level constant lives at the top of this file.)
|
|
for canonical, aliases in _DTYPE_ALIASES:
|
|
dt = _StubDtype(canonical)
|
|
setattr(torch, canonical, dt)
|
|
for a in aliases:
|
|
setattr(torch, a, dt)
|
|
torch.tensor = _stub_tensor_factory
|
|
torch.full = _unsupported("full")
|
|
torch.zeros = _unsupported("zeros")
|
|
torch.from_dlpack = _unsupported("from_dlpack")
|
|
|
|
cuda = types.ModuleType("torch.cuda")
|
|
cuda.is_available = _false
|
|
cuda.device_count = _zero
|
|
|
|
cuda_amp_common = types.ModuleType("torch.cuda.amp.common")
|
|
cuda_amp_common.amp_definitely_not_available = _true
|
|
cuda_amp = types.ModuleType("torch.cuda.amp")
|
|
cuda_amp.common = cuda_amp_common
|
|
cuda.amp = cuda_amp
|
|
|
|
class _Stream:
|
|
pass
|
|
|
|
cuda.Stream = _Stream
|
|
torch.cuda = cuda
|
|
|
|
backends_mps = types.ModuleType("torch.backends.mps")
|
|
backends_mps.is_available = _false
|
|
backends_mps.is_built = _false
|
|
backends_cudnn = types.ModuleType("torch.backends.cudnn")
|
|
backends_cudnn.deterministic = False
|
|
backends_cudnn.benchmark = False
|
|
backends = types.ModuleType("torch.backends")
|
|
backends.mps = backends_mps
|
|
backends.cudnn = backends_cudnn
|
|
torch.backends = backends
|
|
|
|
version = types.ModuleType("torch.version")
|
|
version.cuda = None
|
|
version.hip = None
|
|
torch.version = version
|
|
|
|
nn_functional = types.ModuleType("torch.nn.functional")
|
|
nn_functional.pad = _unsupported("nn.functional.pad")
|
|
nn = types.ModuleType("torch.nn")
|
|
nn.functional = nn_functional
|
|
torch.nn = nn
|
|
|
|
utils_dlpack = types.ModuleType("torch.utils.dlpack")
|
|
utils_dlpack.to_dlpack = _unsupported("utils.dlpack.to_dlpack")
|
|
utils = types.ModuleType("torch.utils")
|
|
utils.dlpack = utils_dlpack
|
|
torch.utils = utils
|
|
|
|
# Top-level __getattr__ so a future xgrammar that reaches into a
|
|
# torch surface we never stubbed (e.g. ``torch.compile``,
|
|
# ``torch.distributed``) fails with a stub-identifying message rather
|
|
# than a cryptic ``AttributeError: module 'torch' has no attribute…``.
|
|
torch.__getattr__ = _make_top_level_torch_getattr()
|
|
|
|
return {
|
|
"torch": torch,
|
|
"torch.cuda": cuda,
|
|
"torch.cuda.amp": cuda_amp,
|
|
"torch.cuda.amp.common": cuda_amp_common,
|
|
"torch.backends": backends,
|
|
"torch.backends.mps": backends_mps,
|
|
"torch.backends.cudnn": backends_cudnn,
|
|
"torch.version": version,
|
|
"torch.nn": nn,
|
|
"torch.nn.functional": nn_functional,
|
|
"torch.utils": utils,
|
|
"torch.utils.dlpack": utils_dlpack,
|
|
}
|
|
|
|
|
|
def install() -> bool:
|
|
"""Install the stub into ``sys.modules`` if no real torch is available.
|
|
|
|
Returns True if the stub was installed (or had been installed previously),
|
|
False if a real torch was found and left alone.
|
|
|
|
Thread-safe — concurrent callers (e.g. multiple FastAPI handlers hitting
|
|
the xgrammar entry points in parallel) serialize on _INSTALL_LOCK.
|
|
"""
|
|
global _INSTALLED
|
|
needs_version_check = False
|
|
with _INSTALL_LOCK:
|
|
if _INSTALLED:
|
|
return True
|
|
|
|
if "torch" in sys.modules:
|
|
already_stub = getattr(
|
|
sys.modules["torch"], "__version__", ""
|
|
).endswith("+omlx-stub")
|
|
_INSTALLED = already_stub
|
|
return already_stub
|
|
|
|
try:
|
|
if importlib.util.find_spec("torch") is not None:
|
|
# Real torch is on the path — leave it alone, install() is
|
|
# a no-op. Don't mark _INSTALLED so a future sys.modules
|
|
# reset (e.g. in tests) re-evaluates. Crucially, also DO
|
|
# NOT touch ``TVM_FFI_DISABLE_TORCH_C_DLPACK`` — the user
|
|
# has real torch and the tvm-ffi/torch-C-DLPack JIT path
|
|
# may be their preferred fast path.
|
|
return False
|
|
except Exception:
|
|
# find_spec can raise on broken parent packages, partial
|
|
# installs, or weird import hooks. Treat as "no torch" — the
|
|
# stub is the safe fallback.
|
|
pass
|
|
|
|
# No real torch — disable tvm_ffi's JIT torch-C-DLPack extension
|
|
# before any tvm-ffi / xgrammar import. Without this,
|
|
# tvm_ffi/_optional_torch_c_dlpack tries to JIT a C extension
|
|
# against our stub at first import, spawns a doomed Python
|
|
# subprocess that fails to ``import torch.utils.cpp_extension``
|
|
# (the stub does not provide it), and surfaces a misleading
|
|
# "Failed to JIT torch c dlpack extension" warning to users on
|
|
# every cold start. The guard inside that module honours this
|
|
# env var and skips the JIT path entirely.
|
|
os.environ.setdefault("TVM_FFI_DISABLE_TORCH_C_DLPACK", "1")
|
|
|
|
for name, mod in _build_modules().items():
|
|
# ``__spec__`` must be a real ModuleSpec (not None) so that
|
|
# ``importlib.util.find_spec`` succeeds when called by
|
|
# transformers and other consumers. ``__version__`` is a
|
|
# clearly-fake value so transformers refuses to take the
|
|
# torch-modeling path.
|
|
mod.__spec__ = importlib.machinery.ModuleSpec(name, loader=None)
|
|
mod.__loader__ = None
|
|
if "." not in name:
|
|
mod.__path__ = [] # type: ignore[attr-defined]
|
|
sys.modules[name] = mod
|
|
_INSTALLED = True
|
|
needs_version_check = True
|
|
|
|
# Fire the version-drift check OUTSIDE the install lock. xgrammar's
|
|
# C++ extension load can be slow on a cold disk; running it under
|
|
# the lock would block every concurrent install() caller behind one
|
|
# cold import. install() is idempotent at this point — _INSTALLED is
|
|
# set and any racing caller short-circuits at the top of the lock.
|
|
if needs_version_check:
|
|
try:
|
|
warn_if_unexpected_versions()
|
|
except Exception: # pragma: no cover — defensive
|
|
pass
|
|
return True
|
|
|
|
|
|
def warn_if_unexpected_versions() -> None:
|
|
"""Log a warning when bundled xgrammar / tvm-ffi versions drift past the
|
|
versions this stub was tested against. Best-effort: silent if the
|
|
imports themselves haven't happened yet, since the stub is installed
|
|
eagerly at startup.
|
|
"""
|
|
try:
|
|
import xgrammar # type: ignore[import-not-found]
|
|
|
|
v = getattr(xgrammar, "__version__", None)
|
|
if v and v not in _TARGET_XGRAMMAR_VERSIONS:
|
|
logger.warning(
|
|
"xgrammar %s is not in the torch-stub target set %s; "
|
|
"structured output may fail at runtime. Update the stub "
|
|
"or pin xgrammar back.",
|
|
v,
|
|
_TARGET_XGRAMMAR_VERSIONS,
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import tvm_ffi # type: ignore[import-not-found]
|
|
|
|
v = getattr(tvm_ffi, "__version__", None)
|
|
if v and v not in _TARGET_TVM_FFI_VERSIONS:
|
|
logger.warning(
|
|
"apache-tvm-ffi %s is not in the torch-stub target set %s; "
|
|
"structured output may fail at runtime.",
|
|
v,
|
|
_TARGET_TVM_FFI_VERSIONS,
|
|
)
|
|
except Exception:
|
|
pass
|