Files
2026-07-13 13:17:40 +08:00

210 lines
8.1 KiB
Python

import logging
import os
from functools import lru_cache
from typing import List, Optional, Tuple
from ray._private.accelerators.accelerator import AcceleratorManager
from ray._private.ray_constants import env_bool
logger = logging.getLogger(__name__)
# Ray uses ``FURIOSA_DEVICES`` to track which Furiosa NPUs are assigned to a
# worker/actor process. The value uses ``npu:<index>`` notation. Ray's
# scheduler operates at the device level, so the value Ray writes is always
# the device-level form (e.g. ``npu:0,npu:3``). Bare integer IDs are also
# accepted on read for convenience.
#
# Note that ``furiosa-llm``'s Python API does not honor ``FURIOSA_DEVICES``
# automatically; callers must pass ``devices=os.environ["FURIOSA_DEVICES"]``
# (or an equivalent list) explicitly to ``furiosa_llm.LLM(...)``. The
# ``furiosa-llm`` CLI does read the value but accepts a richer
# ``npu:X:Y`` (PE-level) form that Ray does not currently preserve through
# worker scheduling; see ``_strip_npu_prefix`` below.
FURIOSA_VISIBLE_DEVICES_ENV_VAR = "FURIOSA_DEVICES"
NOSET_FURIOSA_VISIBLE_DEVICES_ENV_VAR = "RAY_EXPERIMENTAL_NOSET_FURIOSA_DEVICES"
_FURIOSA_DEVICE_PREFIX = "npu:"
@lru_cache(maxsize=None)
def _ensure_furiosa_initialized() -> bool:
"""Run ``furiosa_smi_py.init()`` exactly once per process."""
from furiosa_smi_py import init
init()
return True
def _get_furiosa_list_devices():
"""Lazy import + one-shot init of ``furiosa_smi_py``.
Returns the current ``list_devices`` callable on success, or ``None`` if
the SDK is unavailable or initialization fails. ``list_devices`` is
re-imported on each call so test monkeypatches on the module attribute
take effect.
"""
try:
_ensure_furiosa_initialized()
from furiosa_smi_py import list_devices
except Exception as e:
logger.debug("furiosa_smi_py is unavailable: %s", e)
return None
return list_devices
def _strip_npu_prefix(token: str) -> str:
"""Return the numeric device index from an ``npu:<id>`` token.
Accepts bare integers (``"3"``) as well as the prefixed form
(``"npu:3"``) so that values written by other tooling round-trip
cleanly.
"""
token = token.strip()
if token.startswith(_FURIOSA_DEVICE_PREFIX):
token = token[len(_FURIOSA_DEVICE_PREFIX) :]
# ``furiosa-llm`` accepts both ``npu:X`` (whole NPU) and ``npu:X:Y``
# (PE-level, e.g. ``npu:0:0-3`` for fused PE 0-3 of NPU 0). Ray's
# scheduler currently operates at the device level only, so we keep
# the device index and drop any trailing PE selector. Round-tripping
# PE-level partitioning through worker scheduling is tracked as a
# follow-up enhancement.
return token.split(":", 1)[0]
class FuriosaAcceleratorManager(AcceleratorManager):
"""FuriosaAI NPU accelerators.
Resource name is ``FURIOSA``. The accelerator type is reported as
``FURIOSA_<ARCH>`` where ``<ARCH>`` is the architecture identifier
that the Furiosa SMI SDK exposes via its ``Arch`` enum. The current
SDK variants are ``Rngd``, ``RngdS``, ``RngdMax`` and ``RngdPlus``,
which surface here as ``FURIOSA_RNGD``, ``FURIOSA_RNGDS``,
``FURIOSA_RNGDMAX`` and ``FURIOSA_RNGDPLUS`` respectively.
Supporting any architecture the SDK reports keeps this manager
forward-compatible with new SKUs as Furiosa adds them to
``furiosa_smi_py``.
Device visibility is tracked through the ``FURIOSA_DEVICES``
environment variable, formatted as ``npu:<id>`` tokens. The value
can be passed to the ``furiosa-llm`` CLI (e.g.,
``furiosa-llm serve --devices "$FURIOSA_DEVICES" ...``). When
invoking the ``furiosa_llm.LLM`` Python API directly, the assigned
devices must be passed explicitly, e.g.
``LLM(model_path, devices=os.environ["FURIOSA_DEVICES"])``;
``LLM(devices=None)`` allocates all visible NPUs and would bypass
Ray's per-worker isolation.
"""
@staticmethod
def get_resource_name() -> str:
return "FURIOSA"
@staticmethod
def get_visible_accelerator_ids_env_var() -> str:
return FURIOSA_VISIBLE_DEVICES_ENV_VAR
@staticmethod
def get_current_process_visible_accelerator_ids() -> Optional[List[str]]:
visible_devices = os.environ.get(
FuriosaAcceleratorManager.get_visible_accelerator_ids_env_var()
)
if visible_devices is None:
return None
if visible_devices == "":
return []
return [
_strip_npu_prefix(token)
for token in visible_devices.split(",")
if token.strip()
]
@staticmethod
def get_current_node_num_accelerators() -> int:
"""Detects the number of Furiosa NPU devices on the current machine."""
list_devices = _get_furiosa_list_devices()
if list_devices is None:
return 0
try:
return len(list_devices())
except Exception as e:
logger.debug("Could not list Furiosa NPU devices: %s", e)
return 0
@staticmethod
def get_current_node_accelerator_type() -> Optional[str]:
"""Gets the architecture of the Furiosa NPU on the current node.
Returns a string like ``FURIOSA_RNGD``, ``FURIOSA_RNGDMAX``,
``FURIOSA_RNGDS``, or ``FURIOSA_RNGDPLUS``. Ray assumes a single
accelerator type per node, so the architecture of the first detected
device is used.
The architecture is read from
``device.device_info().arch()`` to mirror the upstream Furiosa SMI
interface. The Arch enum string is normalized into an
accelerator-type label: ``+`` is mapped to ``plus`` so distinct
SKUs do not collide (``rngd+`` becomes ``FURIOSA_RNGDPLUS``,
matching the PyO3 enum form ``RngdPlus``), and any remaining
non-alphanumeric characters are stripped.
"""
list_devices = _get_furiosa_list_devices()
if list_devices is None:
return None
try:
devices = list_devices()
if not devices:
return None
arch_obj = devices[0].device_info().arch()
if arch_obj is None:
return None
# PyO3 enums typically stringify as "<EnumName.Variant>",
# "EnumName.Variant", or just "Variant". Take the trailing
# component.
raw = str(arch_obj).split(".")[-1].strip()
if not raw:
return None
# Map special suffixes to their alphabetic equivalents so that the
# ``Arch::ToString`` form ("rngd+") and the PyO3 enum form
# ("RngdPlus") produce the same Ray accelerator type label.
# Without this, stripping "+" would collapse "rngd+" into
# "rngd", colliding with the distinct ``Rngd`` SKU.
raw = raw.replace("+", "plus")
normalized = "".join(ch for ch in raw if ch.isalnum()).upper()
if not normalized:
return None
return f"FURIOSA_{normalized}"
except Exception as e:
logger.debug("Failed to detect Furiosa NPU type: %s", e)
return None
@staticmethod
def validate_resource_request_quantity(
quantity: float,
) -> Tuple[bool, Optional[str]]:
if isinstance(quantity, float) and not quantity.is_integer():
return (
False,
f"{FuriosaAcceleratorManager.get_resource_name()} resource quantity"
" must be a whole number. Furiosa NPUs do not support"
" fractional resource sharing."
f" The specified quantity {quantity} is invalid.",
)
return (True, None)
@staticmethod
def set_current_process_visible_accelerator_ids(
visible_furiosa_devices: List[str],
) -> None:
if env_bool(NOSET_FURIOSA_VISIBLE_DEVICES_ENV_VAR, False):
return
formatted = ",".join(
f"{_FURIOSA_DEVICE_PREFIX}{_strip_npu_prefix(str(d))}"
for d in visible_furiosa_devices
)
os.environ[
FuriosaAcceleratorManager.get_visible_accelerator_ids_env_var()
] = formatted