diff --git a/.tekton/odh-trustyai-nemo-guardrails-server-pull-request.yaml b/.tekton/odh-trustyai-nemo-guardrails-server-pull-request.yaml index 21790731a8..3d91a516c7 100644 --- a/.tekton/odh-trustyai-nemo-guardrails-server-pull-request.yaml +++ b/.tekton/odh-trustyai-nemo-guardrails-server-pull-request.yaml @@ -43,6 +43,7 @@ spec: - linux/x86_64 - linux-m2xlarge/arm64 - linux/ppc64le + - linux/s390x - name: prefetch-input value: | [{"type": "generic", "path": "."}] @@ -54,7 +55,7 @@ spec: value: . timeouts: pipeline: 4h - tasks: 2h + tasks: 3h pipelineRef: resolver: git params: diff --git a/.tekton/odh-trustyai-nemo-guardrails-server-push.yaml b/.tekton/odh-trustyai-nemo-guardrails-server-push.yaml index 1016a6c6f2..eb9feec4f8 100644 --- a/.tekton/odh-trustyai-nemo-guardrails-server-push.yaml +++ b/.tekton/odh-trustyai-nemo-guardrails-server-push.yaml @@ -47,4 +47,4 @@ spec: - name: git-auth secret: secretName: '{{ git_auth_secret }}' -status: {} \ No newline at end of file +status: {} diff --git a/Dockerfile.konflux b/Dockerfile.konflux index 9695a48b13..dc4572247f 100644 --- a/Dockerfile.konflux +++ b/Dockerfile.konflux @@ -10,15 +10,28 @@ ENV PATH="$HOME/.cargo/bin:$PATH" ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/ ARG TARGETARCH -# Install dependencies -RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - dnf install -y \ - make libtool wget patch ninja-build cmake xz bzip2-devel libffi-devel zlib-devel openssl-devel libevent-devel \ - python3.12 python3.12-devel python3.12-pip clang \ - gcc-toolset-14-gcc gcc-toolset-14-gcc-c++ gcc-toolset-14-gcc-gfortran skopeo && \ - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ - dnf clean all && \ - pip install --upgrade setuptools-rust pip 'cmake<4' setuptools wheel maturin ; \ +# Install build dependencies per arch. +# ppc64le builds OpenBLAS from source; s390x links against system openblas-devel. +RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ + dnf install -y \ + make libtool wget patch ninja-build cmake xz bzip2-devel \ + libffi-devel zlib-devel openssl-devel libevent-devel \ + python3.12 python3.12-devel python3.12-pip clang \ + gcc-toolset-14-gcc gcc-toolset-14-gcc-c++ \ + gcc-toolset-14-gcc-gfortran skopeo && \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ + dnf clean all && \ + pip install --upgrade setuptools-rust pip 'cmake<4' setuptools wheel maturin ; \ + elif [ "$TARGETARCH" = "s390x" ]; then \ + dnf install -y --nodocs --setopt=install_weak_deps=False \ + gcc-toolset-14 make git wget unzip patch \ + rust cargo \ + gcc-gfortran openblas-devel \ + pkgconfig cmake ninja-build && \ + dnf clean all && \ + rm -rf /var/cache/dnf && \ + pip install --no-cache-dir --upgrade pip setuptools wheel \ + 'cmake>=3.28,<4' packaging numpy maturin setuptools-rust ; \ fi COPY requirements* /app/ @@ -27,7 +40,7 @@ COPY requirements* /app/ RUN touch /tmp/control ############################################################### -# Stage 2 to build OpenBLAS +# Stage 2 to build OpenBLAS (ppc64le only) ############################################################### FROM packages-build AS openblas-builder @@ -36,24 +49,23 @@ ARG TARGETARCH WORKDIR /app -# Creating a directory for OpenBlas RUN mkdir /tmp/openblas RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - source /opt/rh/gcc-toolset-14/enable && \ - wget https://github.com/OpenMathLib/OpenBLAS/releases/download/v${OPENBLAS_VERSION}/OpenBLAS-${OPENBLAS_VERSION}.zip && \ - unzip OpenBLAS-${OPENBLAS_VERSION}.zip -d /tmp/ && mv -T /tmp/OpenBLAS-${OPENBLAS_VERSION} /tmp/openblas && \ - cd /tmp/openblas && \ - make -j$(nproc) TARGET=POWER9 BINARY=64 USE_OPENMP=1 USE_THREAD=1 NUM_THREADS=120 DYNAMIC_ARCH=1 INTERFACE64=0 ; \ + source /opt/rh/gcc-toolset-14/enable && \ + wget https://github.com/OpenMathLib/OpenBLAS/releases/download/v${OPENBLAS_VERSION}/OpenBLAS-${OPENBLAS_VERSION}.zip && \ + unzip OpenBLAS-${OPENBLAS_VERSION}.zip -d /tmp/ && \ + mv -T /tmp/OpenBLAS-${OPENBLAS_VERSION} /tmp/openblas && \ + cd /tmp/openblas && \ + make -j$(nproc) TARGET=POWER9 BINARY=64 USE_OPENMP=1 USE_THREAD=1 NUM_THREADS=120 DYNAMIC_ARCH=1 INTERFACE64=0 ; \ fi ############################################################### -# Stage 3 to build pyTorch +# Stage 3 to build PyTorch (ppc64le + s390x) ############################################################### FROM packages-build AS torch-builder USER root - WORKDIR /app ARG MAX_JOBS @@ -62,23 +74,23 @@ ARG TARGETARCH RUN mkdir -p /torchwheels -RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - export TORCH_VERSION=$(sed -n "s/^torch==//p" /app/requirements-torch.in) && \ - source /opt/rh/gcc-toolset-14/enable && \ +RUN if [ "$TARGETARCH" = "ppc64le" ] || [ "$TARGETARCH" = "s390x" ]; then \ + export TORCH_VERSION=$(sed -n "s/^torch==//p" /app/requirements-torch.in) && \ + source /opt/rh/gcc-toolset-14/enable && \ git clone --recursive https://github.com/pytorch/pytorch.git -b v${TORCH_VERSION} && \ - cd pytorch && pip install -r requirements.txt && \ - rm -f dist/torch*+git*whl && \ - # Set MAX_JOBS to 2 or 4 to prevent OOM - # MAX_JOBS=4 \ + cd pytorch && \ + pip install -r requirements.txt && \ + rm -f dist/torch*+git*whl && \ USE_NCCL=0 \ BUILD_TEST=0 \ PYTORCH_BUILD_VERSION=${TORCH_VERSION} \ PYTORCH_BUILD_NUMBER=1 \ - pip wheel . --no-build-isolation --wheel-dir /torchwheels/ \ -; fi + pip wheel . --no-build-isolation --wheel-dir /torchwheels/ && \ + cd /app && rm -rf pytorch /root/.cache ; \ + fi ############################################################### -# Stage 4 to build Tiktoken +# Stage 4 to build Tiktoken (ppc64le only) ############################################################### FROM packages-build as tiktoken-builder @@ -87,65 +99,110 @@ WORKDIR /app RUN mkdir -p /tiktokenwheels -RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - export TIKTOKEN_VERSION=$(sed -n "s/^tiktoken==//p" /app/requirements.txt) && \ - source /opt/rh/gcc-toolset-14/enable && \ - git clone https://github.com/openai/tiktoken.git && \ - cd tiktoken && \ - git checkout ${TIKTOKEN_VERSION} && \ - pip wheel . --wheel-dir /tiktokenwheels ; \ +RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ + export TIKTOKEN_VERSION=$(sed -n "s/^tiktoken==//p" /app/requirements.txt) && \ + source /opt/rh/gcc-toolset-14/enable && \ + git clone https://github.com/openai/tiktoken.git && \ + cd tiktoken && \ + git checkout ${TIKTOKEN_VERSION} && \ + pip wheel . --wheel-dir /tiktokenwheels ; \ fi ############################################################### -# Stage 5 to build onnxruntime +# Stage 5 to build onnxruntime (ppc64le + s390x, different paths) ############################################################### FROM packages-build AS onnxruntime-builder ARG TARGETARCH USER root - WORKDIR /app ENV LD_LIBRARY_PATH=/usr/local/lib64:/usr/local/lib:/usr/lib64:/usr/lib:/app/.openblas/lib -# copy built OpenBLAS sources +# Copy built OpenBLAS sources (ppc64le) COPY --from=openblas-builder /tmp/openblas/ /build/openblas -# Install OpenBLAS -RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - PREFIX=/app/.openblas make -C /build/openblas install && \ - ln -sf /app/.openblas/lib/libopenblasp-r0.3.30.so /app/.openblas/lib/libopenblasp.so.0 ; \ - fi +# Install OpenBLAS into /app/.openblas (ppc64le) +RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ + PREFIX=/app/.openblas make -C /build/openblas install && \ + ln -sf /app/.openblas/lib/libopenblasp-r0.3.30.so /app/.openblas/lib/libopenblasp.so.0 ; \ + fi COPY scripts/build_onnxruntime.sh /build/build_onnxruntime.sh RUN mkdir -p /onnxruntime_wheels # Build onnxruntime -RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - export ONNXRUNTIME_VERSION=$(sed -n "s/^onnxruntime==//p" /app/requirements.txt) && \ - sh /build/build_onnxruntime.sh ; \ +RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ + export ONNXRUNTIME_VERSION=$(sed -n "s/^onnxruntime==//p" /app/requirements.txt) && \ + sh /build/build_onnxruntime.sh ; \ + elif [ "$TARGETARCH" = "s390x" ]; then \ + export ONNXRUNTIME_VERSION=$(sed -n "s/^onnxruntime==//p" /app/requirements.txt) && \ + source /opt/rh/gcc-toolset-14/enable && \ + git clone --depth 1 --recursive --shallow-submodules \ + https://github.com/microsoft/onnxruntime.git -b v${ONNXRUNTIME_VERSION} && \ + cd onnxruntime && \ + ./build.sh --config Release \ + --build_shared_lib \ + --parallel \ + --build_wheel \ + --skip_tests \ + --allow_running_as_root \ + --cmake_extra_defines onnxruntime_ENABLE_CPUINFO=OFF \ + --cmake_extra_defines onnxruntime_BUILD_UNIT_TESTS=OFF && \ + cp ./build/Linux/Release/dist/onnxruntime-*s390x.whl /onnxruntime_wheels/ && \ + cd /app && rm -rf onnxruntime /root/.cache ; \ fi ############################################################### -# Stage 6 Install hf_xet from source ppc64le +# Stage 6 Install hf_xet from source (ppc64le + s390x) ############################################################### FROM packages-build AS hf-xet-builder ARG TARGETARCH USER root - WORKDIR /app + RUN mkdir -p /hf_xet_wheels -RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ - export HF_XET_VERSION=$(sed -n "s/^hf-xet==//p" /app/requirements.txt) && \ - git clone https://github.com/huggingface/xet-core.git && \ - cd xet-core/hf_xet && git checkout v${HF_XET_VERSION} && \ - sed -i '/python-source = "python"/d' pyproject.toml && \ - maturin build --release --out /hf_xet_wheels ; \ +RUN if [ "$TARGETARCH" = "ppc64le" ] || [ "$TARGETARCH" = "s390x" ]; then \ + export HF_XET_VERSION=$(sed -n "s/^hf-xet==//p" /app/requirements.txt) && \ + git clone https://github.com/huggingface/xet-core.git && \ + cd xet-core/hf_xet && \ + git checkout v${HF_XET_VERSION} && \ + sed -i '/python-source = "python"/d' pyproject.toml && \ + maturin build --release --out /hf_xet_wheels ; \ fi ############################################################### -# Stage 7 Install all the dependencies +# Stage 7 Build thinc from source (s390x only) +############################################################### +FROM packages-build AS thinc-builder +ARG TARGETARCH +USER root +WORKDIR /app + +# Upstream explosion/thinc and the base commit PR #970 is against (v8.3.x). +ARG THINC_UPSTREAM_REPO=https://github.com/explosion/thinc.git +ARG THINC_BASE_COMMIT=6c38b29924b218140e5c10c2cb77e3ff487b0ffa + +ENV BLIS_ARCH=generic + +RUN mkdir -p /thinc_wheels + +COPY patches/thinc-bigendian.patch /tmp/thinc-bigendian.patch + +RUN if [ "$TARGETARCH" = "s390x" ]; then \ + source /opt/rh/gcc-toolset-14/enable && \ + git clone ${THINC_UPSTREAM_REPO} thinc && \ + cd thinc && \ + git checkout ${THINC_BASE_COMMIT} && \ + git apply --whitespace=nowarn /tmp/thinc-bigendian.patch && \ + pip wheel . --no-deps --wheel-dir /thinc_wheels && \ + cd /app && \ + rm -rf thinc /root/.cache ; \ + fi + +############################################################### +# Stage 8 Install all the dependencies ############################################################### FROM registry.access.redhat.com/ubi9/python-312@sha256:ff373f4b42b662e99954adea770ca87b4ea963186cc752174ccb94aa08fa702d as build @@ -161,6 +218,14 @@ ENV PATH="$HOME/.cargo/bin:/app/.venv/bin:$PATH" ENV BLIS_ARCH=generic ENV GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 + +RUN if [ "$TARGETARCH" = "s390x" ]; then \ + dnf install -y --nodocs --setopt=install_weak_deps=False \ + gcc-gfortran openblas-devel pkgconfig cmake rust cargo && \ + dnf clean all && \ + rm -rf /var/cache/dnf ; \ + fi + COPY requirements.txt requirements-torch.txt pyproject.toml README.md ./ COPY nemoguardrails/ ./nemoguardrails/ COPY scripts/ ./scripts/ @@ -180,43 +245,80 @@ ENV HF_HOME=/app/.cache/huggingface \ RUN python3 -m venv /app/.venv -# Copy OpenBLAS +# Copy OpenBLAS (ppc64le only — empty dir otherwise) COPY --from=openblas-builder /tmp/openblas/ /tmp/openblas -# Dummy file to trigger build dependency +# Dummy file copies to trigger stage build dependency for each arch. COPY --from=torch-builder /tmp/control /dev/null COPY --from=tiktoken-builder /tmp/control /dev/null COPY --from=onnxruntime-builder /tmp/control /dev/null COPY --from=hf-xet-builder /tmp/control /dev/null +COPY --from=thinc-builder /tmp/control /dev/null +# Install source-built wheels from previous stages. RUN --mount=type=cache,from=torch-builder,source=/torchwheels/,target=/tmp/torchwheels/,ro \ --mount=type=cache,from=tiktoken-builder,source=/tiktokenwheels/,target=/tmp/tiktokenwheels/,ro \ --mount=type=cache,from=onnxruntime-builder,source=/onnxruntime_wheels/,target=/tmp/onnxruntime_wheels/,ro \ --mount=type=cache,from=hf-xet-builder,source=/hf_xet_wheels/,target=/tmp/hf_xet_wheels/,ro \ - if [ "$TARGETARCH" = "ppc64le" ]; then \ - /app/.venv/bin/pip install \ - /tmp/torchwheels/*.whl \ - /tmp/tiktokenwheels/*.whl \ - /tmp/onnxruntime_wheels/*.whl \ - /tmp/hf_xet_wheels/*.whl && \ - PREFIX=/app/.openblas make -C /tmp/openblas install && rm -rf /tmp/openblas ; \ + --mount=type=cache,from=thinc-builder,source=/thinc_wheels/,target=/tmp/thinc_wheels/,ro \ + if [ "$TARGETARCH" = "ppc64le" ]; then \ + /app/.venv/bin/pip install \ + /tmp/torchwheels/*.whl \ + /tmp/tiktokenwheels/*.whl \ + /tmp/onnxruntime_wheels/*.whl \ + /tmp/hf_xet_wheels/*.whl && \ + PREFIX=/app/.openblas make -C /tmp/openblas install && \ + rm -rf /tmp/openblas ; \ + elif [ "$TARGETARCH" = "s390x" ]; then \ + /app/.venv/bin/pip install \ + /tmp/torchwheels/*.whl \ + /tmp/onnxruntime_wheels/*.whl \ + /tmp/hf_xet_wheels/*.whl \ + /tmp/thinc_wheels/*.whl ; \ fi -# Temporary workaround for ppc64le with the pinned pandas version. Remove after upgrading pandas with v3.x -RUN if [ "$TARGETARCH" == "ppc64le" ]; then \ - export PANDAS_VERSION=$(sed -n "s/^pandas==//p" /app/requirements.txt) && \ - /app/.venv/bin/pip install --no-cache-dir "meson<1.10" meson-python ninja cython numpy versioneer[toml] packaging && \ - /app/.venv/bin/pip install --no-cache-dir --no-build-isolation pandas==${PANDAS_VERSION} ; \ +# Temporary workaround for ppc64le with the pinned pandas version. +# Remove after upgrading pandas with v3.x. +RUN if [ "$TARGETARCH" = "ppc64le" ]; then \ + export PANDAS_VERSION=$(sed -n "s/^pandas==//p" /app/requirements.txt) && \ + /app/.venv/bin/pip install --no-cache-dir \ + "meson<1.10" meson-python ninja cython numpy versioneer[toml] packaging && \ + /app/.venv/bin/pip install --no-cache-dir --no-build-isolation pandas==${PANDAS_VERSION} ; \ fi -RUN /app/.venv/bin/pip install --no-cache-dir -r requirements.txt && \ - /app/.venv/bin/pip install --no-cache-dir --no-deps . && \ +# Install the remaining requirements. +# s390x: drop onnxruntime / torch / thinc lines so the source-built wheels +# installed above are kept. +RUN if [ "$TARGETARCH" = "s390x" ]; then \ + export PKG_CONFIG_PATH=/usr/lib64/pkgconfig:/usr/share/pkgconfig && \ + grep -vE "^(onnxruntime|torch|thinc)==" requirements.txt > requirements-filtered.txt && \ + /app/.venv/bin/pip install --no-cache-dir \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + -r requirements-filtered.txt ; \ + else \ + /app/.venv/bin/pip install --no-cache-dir -r requirements.txt ; \ + fi && \ + /app/.venv/bin/pip install --no-cache-dir --no-deps . && \ rm -rf /root/.cache/pip /tmp/* /var/tmp/* +# s390x safety net: force-reinstall the patched thinc wheel +RUN --mount=type=cache,from=thinc-builder,source=/thinc_wheels/,target=/tmp/thinc_wheels/,ro \ + if [ "$TARGETARCH" = "s390x" ]; then \ + /app/.venv/bin/pip install --no-cache-dir --no-deps --force-reinstall \ + /tmp/thinc_wheels/*.whl ; \ + fi + +# Fix confection version mismatch on s390x: +RUN if [ "$TARGETARCH" = "s390x" ]; then \ + /app/.venv/bin/pip install --no-cache-dir --no-deps \ + "confection>=0.2.0" ; \ + fi + RUN /app/.venv/bin/pip install --no-cache-dir /cachi2/output/deps/generic/en_core_web_lg-3.8.0-py3-none-any.whl -RUN if [ "$TARGETARCH" != "ppc64le" ]; then \ - /app/.venv/bin/pip install --no-cache-dir -r requirements-torch.txt ; \ +# Install binary torch wheels from PyPI for amd64/arm64. +RUN if [ "$TARGETARCH" != "ppc64le" ] && [ "$TARGETARCH" != "s390x" ]; then \ + /app/.venv/bin/pip install --no-cache-dir -r requirements-torch.txt ; \ fi RUN set -ex && \ @@ -227,7 +329,7 @@ RUN set -ex && \ rm -rf /app/.cache/huggingface/xet /root/.cache /tmp/* /var/tmp/* ############################################################### -# Stage 8 Final Build +# Stage 9 Final runtime image ############################################################### FROM registry.access.redhat.com/ubi9/python-312:latest as runtime @@ -235,8 +337,21 @@ FROM registry.access.redhat.com/ubi9/python-312:latest as runtime USER 0 WORKDIR /app +ARG TARGETARCH + ENV LD_LIBRARY_PATH=/usr/local/lib64:/usr/local/lib:/usr/lib64:/usr/lib:/app/.openblas/lib +# resolve openblas symlinks for s390x +RUN if [ "$TARGETARCH" = "s390x" ]; then \ + dnf install -y --nodocs --setopt=install_weak_deps=False \ + openblas openblas-openmp && \ + dnf clean all && \ + rm -rf /var/cache/dnf && \ + ln -sf libopenblaso.so.0 /usr/lib64/libopenblas.so.0 && \ + ln -sf libopenblaso.so.0 /usr/lib64/libopenblas.so && \ + ldconfig ; \ + fi + COPY --from=build /app /app RUN rm -f /etc/security/namespace.conf /usr/lib64/security/pam_namespace.so || true && \ @@ -259,11 +374,11 @@ EXPOSE 8000 ENTRYPOINT ["./scripts/entrypoint.sh"] LABEL com.redhat.component="odh-nemo-guardrails" \ - name="rhoai/odh-nemo-guardrails-rhel9" \ - description="NeMo Guardrails is an open-source toolkit for easily adding programmable guardrails to LLM-based conversational systems" \ - summary="odh-nemo-guardrails" \ - maintainer="['managed-open-data-hub@redhat.com']" \ - io.openshift.expose-services="8000:http" \ - io.k8s.display-name="odh-nemo-guardrails" \ - io.k8s.description="NeMo Guardrails toolkit for controlling and securing LLM-based conversational systems" \ - com.redhat.license_terms="https://www.redhat.com/licenses/Red_Hat_Standard_EULA_20191108.pdf" + name="rhoai/odh-nemo-guardrails-rhel9" \ + description="NeMo Guardrails is an open-source toolkit for easily adding programmable guardrails to LLM-based conversational systems" \ + summary="odh-nemo-guardrails" \ + maintainer="['managed-open-data-hub@redhat.com']" \ + io.openshift.expose-services="8000:http" \ + io.k8s.display-name="odh-nemo-guardrails" \ + io.k8s.description="NeMo Guardrails toolkit for controlling and securing LLM-based conversational systems" \ + com.redhat.license_terms="https://www.redhat.com/licenses/Red_Hat_Standard_EULA_20191108.pdf" diff --git a/patches/thinc-bigendian.patch b/patches/thinc-bigendian.patch new file mode 100644 index 0000000000..2864c2a18d --- /dev/null +++ b/patches/thinc-bigendian.patch @@ -0,0 +1,177 @@ +diff --git a/thinc/backends/numpy_ops.pyx b/thinc/backends/numpy_ops.pyx +index b02a91e..ecb8e88 100644 +--- a/thinc/backends/numpy_ops.pyx ++++ b/thinc/backends/numpy_ops.pyx +@@ -18,7 +18,7 @@ from preshed.maps cimport PreshMap + + from .. import registry + from ..types import ArrayXd, DeviceTypes, DTypes, Shape +-from ..util import copy_array, get_array_module ++from ..util import copy_array, ensure_native_byteorder, get_array_module + + from .cblas cimport CBlas, daxpy, saxpy + from .linalg cimport Vec, VecVec +@@ -76,7 +76,7 @@ class NumpyOps(Ops): + if dtype is not None: + array = array.astype(dtype=dtype, copy=False) + +- return array ++ return ensure_native_byteorder(array) + + + def alloc(self, shape: Shape, *, dtype: Optional[DTypes] = "float32", zeros: bool = True) -> ArrayXd: +diff --git a/thinc/backends/ops.py b/thinc/backends/ops.py +index 75d786e..323c60c 100644 +--- a/thinc/backends/ops.py ++++ b/thinc/backends/ops.py +@@ -47,7 +47,7 @@ from ..types import ( + Xp, + _Floats, + ) +-from ..util import get_array_module, is_xp_array, to_numpy ++from ..util import ensure_native_byteorder, get_array_module, is_xp_array, to_numpy + from .cblas import CBlas + + ArrayT = TypeVar("ArrayT", bound=ArrayXd) +@@ -738,18 +738,19 @@ class Ops: + """Ensure a given array is of the correct type.""" + if isinstance(data, self.xp.ndarray): + if dtype is None: +- return data ++ array = data + elif data.dtype == dtype: +- return data ++ array = data + else: +- return self.xp.asarray(data, dtype=dtype) ++ array = self.xp.asarray(data, dtype=dtype) + elif hasattr(data, "numpy"): + # Handles PyTorch Tensor +- return data.numpy() # type: ignore[union-attr] ++ array = data.numpy() # type: ignore[union-attr] + elif dtype is not None: +- return self.xp.array(data, dtype=dtype) ++ array = self.xp.array(data, dtype=dtype) + else: +- return self.xp.array(data) ++ array = self.xp.array(data) ++ return ensure_native_byteorder(array) + + def as_contig(self, data: ArrayT, dtype: Optional[DTypes] = None) -> ArrayT: + """Allow the backend to make a contiguous copy of an array. +diff --git a/thinc/tests/backends/test_ops.py b/thinc/tests/backends/test_ops.py +index e36cbac..0d0365a 100644 +--- a/thinc/tests/backends/test_ops.py ++++ b/thinc/tests/backends/test_ops.py +@@ -1534,7 +1534,9 @@ def test_compare_activations_to_torch(ops, dtype, x, dY, torch_func): + ) + assert dx_thinc_inplace is dY_thinc_inplace + assert ops.xp.isclose(dx_thinc, dx_thinc_inplace) +- assert ops.xp.isclose(x_torch.grad.item() * dY, float(dx_thinc), atol=1e-06) ++ assert ops.xp.isclose( ++ x_torch.grad.item() * dY, float(dx_thinc.item()), atol=1e-06 ++ ) + elif params == {"Y", "dY"}: + dx_thinc = backward(dY_thinc, Y=y_thinc) + assert dx_thinc.dtype == x_thinc.dtype +@@ -1542,7 +1544,9 @@ def test_compare_activations_to_torch(ops, dtype, x, dY, torch_func): + dx_thinc, + backward(dY=dY_thinc_inplace, Y=y_thinc, inplace=True), + ) +- assert ops.xp.isclose(x_torch.grad.item() * dY, float(dx_thinc), atol=1e-06) ++ assert ops.xp.isclose( ++ x_torch.grad.item() * dY, float(dx_thinc.item()), atol=1e-06 ++ ) + elif params == {"dY", "X"}: + dx_thinc = backward(dY_thinc, X=x_thinc) + assert dx_thinc.dtype == x_thinc.dtype +@@ -1550,7 +1554,9 @@ def test_compare_activations_to_torch(ops, dtype, x, dY, torch_func): + dx_thinc, backward(dY=dY_thinc_inplace, X=x_thinc, inplace=True) + ) + assert ops.xp.isclose( +- x_torch.grad.item() * dY, float(backward(dY_thinc, X=x_thinc)), atol=1e-06 ++ x_torch.grad.item() * dY, ++ float(backward(dY_thinc, X=x_thinc).item()), ++ atol=1e-06, + ) + else: + raise NotImplementedError( +@@ -1608,3 +1614,40 @@ def test_asarray_from_list_uint64(ops): + # list contains int values both above and below int64.max + uint64_list = [16, 11648197037703959513] + assert uint64_list == list(ops.asarray(uint64_list, dtype="uint64")) ++ ++ ++@pytest.mark.parametrize("ops", CPU_OPS) ++@pytest.mark.parametrize("byteorder", ["<", ">"]) ++def test_asarray_converts_to_native_byteorder(ops, byteorder): ++ # Arrays serialized on a platform with the opposite endianness (e.g. a ++ # spaCy pipeline trained on x86_64 and loaded on s390x) must be normalised ++ # to native byte order — otherwise Cython typed memoryviews reject them. ++ import sys as _sys ++ ++ native = "<" if _sys.byteorder == "little" else ">" ++ expected = numpy.array([1.0, 2.0, 3.0], dtype="float32") ++ foreign = expected.astype(numpy.dtype("float32").newbyteorder(byteorder)) ++ out = ops.asarray(foreign) ++ assert out.dtype.byteorder in ("=", native) ++ assert_allclose(out, expected) ++ ++ ++def test_ensure_native_byteorder_helper(): ++ import sys as _sys ++ ++ from thinc.util import ensure_native_byteorder ++ ++ native = "<" if _sys.byteorder == "little" else ">" ++ ++ native_arr = numpy.array([1, 2, 3], dtype="int32") ++ assert ensure_native_byteorder(native_arr) is native_arr ++ ++ opposite = ">" if native == "<" else "<" ++ swapped = native_arr.astype(native_arr.dtype.newbyteorder(opposite)) ++ out = ensure_native_byteorder(swapped) ++ assert out.dtype.byteorder in ("=", native) ++ assert list(out) == [1, 2, 3] ++ ++ # Single-byte dtypes report "|" and must be passed through untouched. ++ byte_arr = numpy.array([1, 2, 3], dtype="int8") ++ assert ensure_native_byteorder(byte_arr) is byte_arr +diff --git a/thinc/util.py b/thinc/util.py +index 506850d..a824c95 100644 +--- a/thinc/util.py ++++ b/thinc/util.py +@@ -4,6 +4,7 @@ import inspect + import os + import platform + import random ++import sys + import tempfile + import threading + from contextvars import ContextVar +@@ -110,6 +111,25 @@ def fix_random_seed(seed: int = 0) -> None: # pragma: no cover + torch.backends.cudnn.benchmark = False + + ++_NATIVE_BYTEORDER = "<" if sys.byteorder == "little" else ">" ++ ++ ++def ensure_native_byteorder(array): ++ """Return ``array`` with native byte order, byteswapping if necessary. ++ ++ Arrays deserialized from models trained on a platform with a different ++ endianness (for example, spaCy pipelines shipped as little-endian data ++ loaded on an s390x big-endian host) carry a non-native ``dtype.byteorder`` ++ and are rejected by Cython typed memoryviews. Normalising to native byte ++ order here lets the same serialized weights run on either platform. ++ """ ++ # "=" means native, "|" means not applicable (e.g. single-byte dtypes). ++ byteorder = array.dtype.byteorder ++ if byteorder in ("=", "|") or byteorder == _NATIVE_BYTEORDER: ++ return array ++ return array.byteswap().view(array.dtype.newbyteorder("=")) ++ ++ + def is_xp_array(obj: Any) -> bool: + """Check whether an object is a numpy or cupy array.""" + return is_numpy_array(obj) or is_cupy_array(obj)