From 2dd1072b844bdcfaf22fd902093a051a78578ded Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Tue, 13 Oct 2026 19:22:25 +0000 Subject: [PATCH 1/3] dipy: Add version 1.12.1 --- .github/workflows/build-dipy.yml | 104 +++++++++++++++++++++++++++++++ docs/packages/dipy.yaml | 5 ++ 2 files changed, 109 insertions(+) create mode 100644 .github/workflows/build-dipy.yml create mode 100644 docs/packages/dipy.yaml diff --git a/.github/workflows/build-dipy.yml b/.github/workflows/build-dipy.yml new file mode 100644 index 000000000000..928af45dfc7e --- /dev/null +++ b/.github/workflows/build-dipy.yml @@ -0,0 +1,104 @@ +# 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' + push: + branches: [main] + paths: + - '.github/workflows/build-dipy.yml' + - 'docs/packages/dipy.yaml' + +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: 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 }} + CIBW_ENVIRONMENT: >- + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + PIP_ONLY_BINARY=numpy,scipy,h5py + 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 From dd2c6ad97e3a19c9f3e0f3e36e97f5722e03cf74 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Sat, 24 Oct 2026 01:39:40 +0000 Subject: [PATCH 2/3] dipy: Keep cachebox binary-only in the test environment deepdiff 9.1.0, pulled in through trx-python, requires cachebox<6, which has no riscv64 wheel; pip falls back to its sdist, which needs a Rust toolchain and fails, so installing the wheel for testing fails. The registry only carries cachebox 6.x. With cachebox binary-only, pip backtracks to deepdiff 9.0.0, which does not depend on cachebox. --- .github/workflows/build-dipy.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-dipy.yml b/.github/workflows/build-dipy.yml index 928af45dfc7e..af0ba9ef2c4a 100644 --- a/.github/workflows/build-dipy.yml +++ b/.github/workflows/build-dipy.yml @@ -72,9 +72,13 @@ jobs: 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 + 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" && From 380672576edec8128174123a06c96af7147186a8 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Sun, 25 Oct 2026 17:54:18 +0000 Subject: [PATCH 3/3] dipy: fix cp312/cp313/cp314 libgomp segfaults during testing All three legs of PR #3112 segfaulted inside libgomp while running `pytest --pyargs dipy`: cp312 and cp314 faulted building a StatefulTractogram in dipy/conftest.py's doctest runner, with the C stack going through dipy.libs/libgomp -> tractogen's compiled extension -> libgomp again; cp313 faulted inside the nlmeans compiled extension from denoise/tests/test_denoise.py::test_denoise. All three are a bare "Fatal Python error: Segmentation fault" with no Python frame at fault and the OpenMP runtime on the C stack -- CLAUDE.md gotcha 166's signature for the riscv64 runners' libgomp: a fault inside the OpenMP runtime, specific to the dynamic/guided work-share schedules, not confined to one function. `grep -rn "schedule(dynamic\|schedule(guided" --include=*.pyx` over dipy's tree finds seven loops across four files: tracking/tractogen.pyx (the one that crashed cp312/cp314), denoise/denspeed.pyx's _nlmeans_3d and nlmeans_3d_blockwise (the one that crashed cp313, plus two more dynamic loops in the same file), denoise/shift_twist_convolution.pyx, and tracking/fbcmeasures.pyx. Patch all seven to schedule(static), per gotcha 166, and apply the patch as part of the build. --- .github/workflows/build-dipy.yml | 11 ++ ...tic-for-the-dynamic-guided-OpenMP-lo.patch | 118 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 patches/dipy/1.12.1/0001-use-schedule-static-for-the-dynamic-guided-OpenMP-lo.patch diff --git a/.github/workflows/build-dipy.yml b/.github/workflows/build-dipy.yml index af0ba9ef2c4a..1a20fcb17d3c 100644 --- a/.github/workflows/build-dipy.yml +++ b/.github/workflows/build-dipy.yml @@ -17,11 +17,13 @@ on: 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 }} @@ -65,6 +67,15 @@ jobs: 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: 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)) +