diff --git a/.github/workflows/build-dipy.yml b/.github/workflows/build-dipy.yml new file mode 100644 index 000000000000..1a20fcb17d3c --- /dev/null +++ b/.github/workflows/build-dipy.yml @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# This workflow is based on the `build_linux_wheels` job of +# https://github.com/dipy/dipy/blob/1.12.1/.github/workflows/nightly.yml +name: Build dipy wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/dipy.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-dipy.yml' + - 'docs/packages/dipy.yaml' + - 'patches/dipy/**' + push: + branches: [main] + paths: + - '.github/workflows/build-dipy.yml' + - 'docs/packages/dipy.yaml' + - 'patches/dipy/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + with: + package: dipy + version: ${{ inputs.version }} + + build_wheels: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + name: Build dipy ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 360 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + # Upstream's CIBW_BUILD "cp3{11,12,13,14}-*" ships no free-threaded wheel. + python: ["cp312", "cp313", "cp314"] + + env: + DIPY_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout dipy ${{ env.DIPY_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: dipy/dipy + ref: ${{ env.DIPY_VERSION }} + fetch-depth: 0 + persist-credentials: false + + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Patch dipy source + run: git apply python-wheels/patches/dipy/${{ env.DIPY_VERSION }}/*.patch + + - name: Build wheels + uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + output-dir: wheelhouse/ + only: ${{ matrix.python }}-manylinux_riscv64 + env: + CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }} + # deepdiff 9.1.0 (trx-python's dependency) requires cachebox<6, which + # is sdist-only for riscv64 and needs Rust; the registry only has + # cachebox 6.x. Binary-only cachebox makes pip backtrack to deepdiff + # 9.0.0, which does not depend on cachebox. + CIBW_ENVIRONMENT: >- + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + PIP_ONLY_BINARY=numpy,scipy,h5py,cachebox + CIBW_TEST_REQUIRES: pytest + CIBW_TEST_COMMAND: >- + python -c "import dipy; print(dipy.__version__); from dipy.align.imaffine import AffineMap" && + pytest --pyargs dipy + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dipy-${{ env.DIPY_VERSION }}-${{ matrix.python }}-manylinux_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish dipy ${{ matrix.version }} + needs: [setup, build_wheels] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + secrets: + app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }} + with: + artifact-pattern: dipy-${{ matrix.version }}-*-manylinux_riscv64 diff --git a/docs/packages/dipy.yaml b/docs/packages/dipy.yaml new file mode 100644 index 000000000000..9b6464e2d76c --- /dev/null +++ b/docs/packages/dipy.yaml @@ -0,0 +1,5 @@ +package-name: dipy +source-code: https://github.com/dipy/dipy +license: BSD-3-Clause +versions: +- version: 1.12.1 diff --git a/patches/dipy/1.12.1/0001-use-schedule-static-for-the-dynamic-guided-OpenMP-lo.patch b/patches/dipy/1.12.1/0001-use-schedule-static-for-the-dynamic-guided-OpenMP-lo.patch new file mode 100644 index 000000000000..312dc9f3971c --- /dev/null +++ b/patches/dipy/1.12.1/0001-use-schedule-static-for-the-dynamic-guided-OpenMP-lo.patch @@ -0,0 +1,118 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Mon, 28 Sep 2026 00:00:00 +0000 +Subject: [PATCH] use schedule(static) for the dynamic/guided OpenMP loops + +Upstream-Status: Inappropriate [works around a libgomp defect on the riscv64 runners, riseproject-dev/python-wheels#617] + +CI's cp312, cp313 and cp314 legs each segfaulted inside libgomp during +`pytest --pyargs dipy` (PR #3112): cp312 and cp314 faulted with the C +stack landing in dipy.libs/libgomp-*.so.1.0.0 -> tractogen's compiled +extension -> libgomp again, from dipy/conftest.py's doctest runner +building a StatefulTractogram; cp313 faulted inside nlmeans's compiled +extension from denoise/tests/test_denoise.py::test_denoise. All three +show "Fatal Python error: Segmentation fault" with no Python frame at +fault and the OpenMP runtime on the C stack, matching gotcha 166's +signature for the runners' libgomp exactly: a fault inside the OpenMP +runtime, specific to the dynamic/guided work-share schedules, and not +confined to one function. + +`grep -rn "schedule(dynamic\|schedule(guided" --include=*.pyx` finds +seven loops across four files: dipy/tracking/tractogen.pyx's streamline +generator (the one that crashed cp312/cp314), dipy/denoise/denspeed.pyx's +_nlmeans_3d and nlmeans_3d_blockwise (the one that crashed cp313, plus +two more dynamic loops in the same file), dipy/denoise/shift_twist_convolution.pyx's +perform_convolution, and dipy/tracking/fbcmeasures.pyx's FBCMeasures. +Converting all seven to schedule(static) costs load balancing on loop +bodies whose per-iteration cost varies, not correctness. + +Tag it Inappropriate and revert once the runners' toolchain is fixed, +per gotcha 166. + +Signed-off-by: Ludovic Henry +--- + dipy/denoise/denspeed.pyx | 8 ++++---- + dipy/denoise/shift_twist_convolution.pyx | 2 +- + dipy/tracking/fbcmeasures.pyx | 2 +- + dipy/tracking/tractogen.pyx | 2 +- + 4 files changed, 7 insertions(+), 7 deletions(-) + +diff --git a/dipy/denoise/denspeed.pyx b/dipy/denoise/denspeed.pyx +index e2546e0..088182f 100644 +--- a/dipy/denoise/denspeed.pyx ++++ b/dipy/denoise/denspeed.pyx +@@ -106,7 +106,7 @@ def _nlmeans_3d(double[:, :, ::1] arr, double[:, :, ::1] mask, + # move the block + with nogil, parallel(): + +- for i in prange(B, I - B, schedule="dynamic"): ++ for i in prange(B, I - B, schedule="static"): + for j in range(B, J - B): + for k in range(B, K - B): + +@@ -966,7 +966,7 @@ def nlmeans_3d_blockwise(double[:, :, :] image, double[:, :, :] mask, int patch_ + + # Phase 1: Compute local statistics (means and variances) in parallel + with nogil, parallel(): +- for center_z in prange(img_depth, schedule="dynamic"): ++ for center_z in prange(img_depth, schedule="static"): + for center_x in range(img_width): + for center_y in range(img_height): + if mask[center_y, center_x, center_z] > 0: +@@ -986,7 +986,7 @@ def nlmeans_3d_blockwise(double[:, :, :] image, double[:, :, :] mask, int patch_ + + if use_sigma_map: + with nogil, parallel(): +- for center_z in prange(0, img_depth, 2, schedule="dynamic"): ++ for center_z in prange(0, img_depth, 2, schedule="static"): + thread_id = threadid() + for center_x in range(0, img_width, 2): + for center_y in range(0, img_height, 2): +@@ -1015,7 +1015,7 @@ def nlmeans_3d_blockwise(double[:, :, :] image, double[:, :, :] mask, int patch_ + img_height, img_width, img_depth) + else: + with nogil, parallel(): +- for center_z in prange(0, img_depth, 2, schedule="dynamic"): ++ for center_z in prange(0, img_depth, 2, schedule="static"): + thread_id = threadid() + for center_x in range(0, img_width, 2): + for center_y in range(0, img_height, 2): +diff --git a/dipy/denoise/shift_twist_convolution.pyx b/dipy/denoise/shift_twist_convolution.pyx +index b5eed05..2dcacf2 100644 +--- a/dipy/denoise/shift_twist_convolution.pyx ++++ b/dipy/denoise/shift_twist_convolution.pyx +@@ -173,7 +173,7 @@ cdef double [:, :, :, ::1] perform_convolution (double [:, :, :, ::1] odfs, + with nogil: + + # loop over ODFs cx,cy,cz,orient --> y and v +- for corient in prange(OR1, schedule='guided'): ++ for corient in prange(OR1, schedule='static'): + for cx in range(nx): + for cy in range(ny): + for cz in range(nz): +diff --git a/dipy/tracking/fbcmeasures.pyx b/dipy/tracking/fbcmeasures.pyx +index 50bfa34..af6a837 100644 +--- a/dipy/tracking/fbcmeasures.pyx ++++ b/dipy/tracking/fbcmeasures.pyx +@@ -260,7 +260,7 @@ cdef class FBCMeasures: + # compute fiber LFBC measures + with nogil: + +- for line_id in prange(num_fibers, schedule='guided'): ++ for line_id in prange(num_fibers, schedule='static'): + for point_id in range(streamlines_length[line_id] - 1): + score_mp[line_id] = 0.0 + for line_id2 in range(num_fibers): +diff --git a/dipy/tracking/tractogen.pyx b/dipy/tracking/tractogen.pyx +index 5777537..426d135 100644 +--- a/dipy/tracking/tractogen.pyx ++++ b/dipy/tracking/tractogen.pyx +@@ -171,7 +171,7 @@ cdef void generate_tractogram_c(double[:,::1] seed_positions, + if nbr_threads <= 0: + nbr_threads = 0 + +- for i in prange(_len, nogil=True, num_threads=nbr_threads, schedule='dynamic', chunksize=64): ++ for i in prange(_len, nogil=True, num_threads=nbr_threads, schedule='static', chunksize=64): + stream = malloc((params.max_nbr_pts * 3 * 2 + 1) * sizeof(double)) + stream_idx = malloc(2 * sizeof(int)) +