parquet_argsort_kernel.f90 Source File


Source Code

!===========================================
! Author: Elmo Tempel (elmo.tempel@ut.ee)
!===========================================
!
! GENERATED FILE -- DO NOT EDIT BY HAND.
! Regenerate with:  tools/generate_parquet_sorting.py
! The type table lives in that script; edit it there, not here.
!
!> The six intrinsic `pf_argsort` specifics, their key extraction, and the engine dispatch.
!!
!! **This is the argsort tier's implementation, and what it does NOT reach is the point.** Nothing
!! here names `parquet_bindings`: the C++ engine is reached through the procedure pointers
!! `parquet_argsort` holds, which `parquet_sorting_oracle` binds and which a program that never
!! imports that module never has bound. See `parquet_argsort`'s own header.
!!
!! **This file decides nothing about order.** It extracts values, says which rows are null, and
!! passes the caller's `descending`/`nulls_first` flags through -- every ordering decision is made
!! in `sort_compare_key` (src/parquet_argsort_engine.f90), which is what stops a raw-array sort, a
!! table sort and a read-time `sort_by=` from ever disagreeing.
submodule (parquet_argsort) parquet_argsort_kernel
    implicit none
    !
contains
    !
    module procedure extract_i32
        integer(int64) :: n, nth
        integer :: team
        !
        n = size(values, kind=int64)
        allocate(buf(1))
        buf(1)%family = SK_INT
        buf(1)%descending = descending
        buf(1)%nulls_first = nulls_first
        allocate(buf(1)%ints(max(n, 1_int64)))
        ! **The pre-fill and the extraction share ONE static schedule, and that pairing is
        ! the point.** Whichever pass writes a page first decides which NUMA node it lives
        ! on for the rest of the sort, so a serial blanket fill puts the whole key buffer
        ! on the master thread's node and every other thread then reads it across the
        ! interconnect. Filling under the same `schedule(static)` the extraction uses means
        ! each thread faults exactly the range it is about to write.
        !
        ! **Do NOT drop the fill as a redundant pass**, however obviously the loop below
        ! covers every one of `1..n`. Removing it measured **23% SLOWER** at 64 threads
        ! (6.46 against 5.26 ns/element, f64, n = 5e6, ifx), reproduced against three
        ! earlier runs: the fill is a pure sequential sweep and faults pages far faster
        ! than the extraction loop, which interleaves a read of `values`. Cheaper work is
        ! not always less time. See feature_sort_report.md.
        !
        ! **`threads=` is honoured here, and the absent case falls back to the automatic
        ! policy.** `resolve_thread_count` is the same procedure the engine uses, so an
        ! explicit `threads=1` really does make the whole operation serial -- which the
        ! CHANGELOG promises for `pf_argsort`/`pf_sort`/`pf_unique*`/`pf_rank`, and which a
        ! bare `pf_sort_threads()` here would have quietly broken. Entry points that take
        ! no thread argument at all (`pf_sort_keys%add`, `pf_merge`, `pf_is_sorted`,
        ! `pf_partial_*`) pass nothing and get the automatic answer, which is the only
        ! thing they could ever have got.
        !
        ! **The threaded arm lives in `extract_i32_par`, and keeping it OUT of this
        ! procedure is load-bearing rather than tidiness.** With the two `!$omp parallel do`
        ! regions written inline here, gfortran's codegen for the SERIAL branch below -- whose
        ! statements are unchanged either way -- measured **2.4x slower**: 0.251 -> 0.609
        ! ns/element on `i64` and 0.250 -> 0.356 on `f64` (machine A, gfortran 15.2, n = 5e6,
        ! `--serial`, against a 0.004 ns cross-build floor). That arm is taken by every caller
        ! passing `threads=1`, by a single-core machine, and by every sort inside an existing
        ! OpenMP region, since `pf_sort_threads()` answers 1 there. See feature_sort.md 4k/4l.
        call resolve_thread_count(threads, n, nth)
        team = tail_team(nth, n)
        if (team > 1) then
            call extract_i32_par(buf(1)%ints, values, n, team)
        else
            call extract_i32_ser(buf(1)%ints, values, n)
        end if
        if (present(is_valid)) call valid_from_mask(is_valid, n, proc, buf(1)%valid)
    end procedure extract_i32
    !
    module procedure extract_i64
        integer(int64) :: n, nth
        integer :: team
        !
        n = size(values, kind=int64)
        allocate(buf(1))
        buf(1)%family = SK_INT
        buf(1)%descending = descending
        buf(1)%nulls_first = nulls_first
        allocate(buf(1)%ints(max(n, 1_int64)))
        ! **The pre-fill and the extraction share ONE static schedule, and that pairing is
        ! the point.** Whichever pass writes a page first decides which NUMA node it lives
        ! on for the rest of the sort, so a serial blanket fill puts the whole key buffer
        ! on the master thread's node and every other thread then reads it across the
        ! interconnect. Filling under the same `schedule(static)` the extraction uses means
        ! each thread faults exactly the range it is about to write.
        !
        ! **Do NOT drop the fill as a redundant pass**, however obviously the loop below
        ! covers every one of `1..n`. Removing it measured **23% SLOWER** at 64 threads
        ! (6.46 against 5.26 ns/element, f64, n = 5e6, ifx), reproduced against three
        ! earlier runs: the fill is a pure sequential sweep and faults pages far faster
        ! than the extraction loop, which interleaves a read of `values`. Cheaper work is
        ! not always less time. See feature_sort_report.md.
        !
        ! **`threads=` is honoured here, and the absent case falls back to the automatic
        ! policy.** `resolve_thread_count` is the same procedure the engine uses, so an
        ! explicit `threads=1` really does make the whole operation serial -- which the
        ! CHANGELOG promises for `pf_argsort`/`pf_sort`/`pf_unique*`/`pf_rank`, and which a
        ! bare `pf_sort_threads()` here would have quietly broken. Entry points that take
        ! no thread argument at all (`pf_sort_keys%add`, `pf_merge`, `pf_is_sorted`,
        ! `pf_partial_*`) pass nothing and get the automatic answer, which is the only
        ! thing they could ever have got.
        !
        ! **The threaded arm lives in `extract_i64_par`, and keeping it OUT of this
        ! procedure is load-bearing rather than tidiness.** With the two `!$omp parallel do`
        ! regions written inline here, gfortran's codegen for the SERIAL branch below -- whose
        ! statements are unchanged either way -- measured **2.4x slower**: 0.251 -> 0.609
        ! ns/element on `i64` and 0.250 -> 0.356 on `f64` (machine A, gfortran 15.2, n = 5e6,
        ! `--serial`, against a 0.004 ns cross-build floor). That arm is taken by every caller
        ! passing `threads=1`, by a single-core machine, and by every sort inside an existing
        ! OpenMP region, since `pf_sort_threads()` answers 1 there. See feature_sort.md 4k/4l.
        call resolve_thread_count(threads, n, nth)
        team = tail_team(nth, n)
        if (team > 1) then
            call extract_i64_par(buf(1)%ints, values, n, team)
        else
            call extract_i64_ser(buf(1)%ints, values, n)
        end if
        if (present(is_valid)) call valid_from_mask(is_valid, n, proc, buf(1)%valid)
    end procedure extract_i64
    !
    module procedure extract_f32
        integer(int64) :: n, nth
        integer :: team
        !
        n = size(values, kind=int64)
        allocate(buf(1))
        buf(1)%family = SK_REAL
        buf(1)%descending = descending
        buf(1)%nulls_first = nulls_first
        allocate(buf(1)%reals(max(n, 1_int64)))
        ! **The pre-fill and the extraction share ONE static schedule, and that pairing is
        ! the point.** Whichever pass writes a page first decides which NUMA node it lives
        ! on for the rest of the sort, so a serial blanket fill puts the whole key buffer
        ! on the master thread's node and every other thread then reads it across the
        ! interconnect. Filling under the same `schedule(static)` the extraction uses means
        ! each thread faults exactly the range it is about to write.
        !
        ! **Do NOT drop the fill as a redundant pass**, however obviously the loop below
        ! covers every one of `1..n`. Removing it measured **23% SLOWER** at 64 threads
        ! (6.46 against 5.26 ns/element, f64, n = 5e6, ifx), reproduced against three
        ! earlier runs: the fill is a pure sequential sweep and faults pages far faster
        ! than the extraction loop, which interleaves a read of `values`. Cheaper work is
        ! not always less time. See feature_sort_report.md.
        !
        ! **`threads=` is honoured here, and the absent case falls back to the automatic
        ! policy.** `resolve_thread_count` is the same procedure the engine uses, so an
        ! explicit `threads=1` really does make the whole operation serial -- which the
        ! CHANGELOG promises for `pf_argsort`/`pf_sort`/`pf_unique*`/`pf_rank`, and which a
        ! bare `pf_sort_threads()` here would have quietly broken. Entry points that take
        ! no thread argument at all (`pf_sort_keys%add`, `pf_merge`, `pf_is_sorted`,
        ! `pf_partial_*`) pass nothing and get the automatic answer, which is the only
        ! thing they could ever have got.
        !
        ! **The threaded arm lives in `extract_f32_par`, and keeping it OUT of this
        ! procedure is load-bearing rather than tidiness.** With the two `!$omp parallel do`
        ! regions written inline here, gfortran's codegen for the SERIAL branch below -- whose
        ! statements are unchanged either way -- measured **2.4x slower**: 0.251 -> 0.609
        ! ns/element on `i64` and 0.250 -> 0.356 on `f64` (machine A, gfortran 15.2, n = 5e6,
        ! `--serial`, against a 0.004 ns cross-build floor). That arm is taken by every caller
        ! passing `threads=1`, by a single-core machine, and by every sort inside an existing
        ! OpenMP region, since `pf_sort_threads()` answers 1 there. See feature_sort.md 4k/4l.
        call resolve_thread_count(threads, n, nth)
        team = tail_team(nth, n)
        if (team > 1) then
            call extract_f32_par(buf(1)%reals, values, n, team)
        else
            call extract_f32_ser(buf(1)%reals, values, n)
        end if
        if (present(is_valid)) call valid_from_mask(is_valid, n, proc, buf(1)%valid)
    end procedure extract_f32
    !
    module procedure extract_f64
        integer(int64) :: n, nth
        integer :: team
        !
        n = size(values, kind=int64)
        allocate(buf(1))
        buf(1)%family = SK_REAL
        buf(1)%descending = descending
        buf(1)%nulls_first = nulls_first
        allocate(buf(1)%reals(max(n, 1_int64)))
        ! **The pre-fill and the extraction share ONE static schedule, and that pairing is
        ! the point.** Whichever pass writes a page first decides which NUMA node it lives
        ! on for the rest of the sort, so a serial blanket fill puts the whole key buffer
        ! on the master thread's node and every other thread then reads it across the
        ! interconnect. Filling under the same `schedule(static)` the extraction uses means
        ! each thread faults exactly the range it is about to write.
        !
        ! **Do NOT drop the fill as a redundant pass**, however obviously the loop below
        ! covers every one of `1..n`. Removing it measured **23% SLOWER** at 64 threads
        ! (6.46 against 5.26 ns/element, f64, n = 5e6, ifx), reproduced against three
        ! earlier runs: the fill is a pure sequential sweep and faults pages far faster
        ! than the extraction loop, which interleaves a read of `values`. Cheaper work is
        ! not always less time. See feature_sort_report.md.
        !
        ! **`threads=` is honoured here, and the absent case falls back to the automatic
        ! policy.** `resolve_thread_count` is the same procedure the engine uses, so an
        ! explicit `threads=1` really does make the whole operation serial -- which the
        ! CHANGELOG promises for `pf_argsort`/`pf_sort`/`pf_unique*`/`pf_rank`, and which a
        ! bare `pf_sort_threads()` here would have quietly broken. Entry points that take
        ! no thread argument at all (`pf_sort_keys%add`, `pf_merge`, `pf_is_sorted`,
        ! `pf_partial_*`) pass nothing and get the automatic answer, which is the only
        ! thing they could ever have got.
        !
        ! **The threaded arm lives in `extract_f64_par`, and keeping it OUT of this
        ! procedure is load-bearing rather than tidiness.** With the two `!$omp parallel do`
        ! regions written inline here, gfortran's codegen for the SERIAL branch below -- whose
        ! statements are unchanged either way -- measured **2.4x slower**: 0.251 -> 0.609
        ! ns/element on `i64` and 0.250 -> 0.356 on `f64` (machine A, gfortran 15.2, n = 5e6,
        ! `--serial`, against a 0.004 ns cross-build floor). That arm is taken by every caller
        ! passing `threads=1`, by a single-core machine, and by every sort inside an existing
        ! OpenMP region, since `pf_sort_threads()` answers 1 there. See feature_sort.md 4k/4l.
        call resolve_thread_count(threads, n, nth)
        team = tail_team(nth, n)
        if (team > 1) then
            call extract_f64_par(buf(1)%reals, values, n, team)
        else
            call extract_f64_ser(buf(1)%reals, values, n)
        end if
        if (present(is_valid)) call valid_from_mask(is_valid, n, proc, buf(1)%valid)
    end procedure extract_f64
    !
    module procedure extract_bool
        integer(int64) :: n, nth
        integer :: team
        !
        n = size(values, kind=int64)
        allocate(buf(1))
        buf(1)%family = SK_INT
        buf(1)%descending = descending
        buf(1)%nulls_first = nulls_first
        allocate(buf(1)%ints(max(n, 1_int64)))
        ! **The pre-fill and the extraction share ONE static schedule, and that pairing is
        ! the point.** Whichever pass writes a page first decides which NUMA node it lives
        ! on for the rest of the sort, so a serial blanket fill puts the whole key buffer
        ! on the master thread's node and every other thread then reads it across the
        ! interconnect. Filling under the same `schedule(static)` the extraction uses means
        ! each thread faults exactly the range it is about to write.
        !
        ! **Do NOT drop the fill as a redundant pass**, however obviously the loop below
        ! covers every one of `1..n`. Removing it measured **23% SLOWER** at 64 threads
        ! (6.46 against 5.26 ns/element, f64, n = 5e6, ifx), reproduced against three
        ! earlier runs: the fill is a pure sequential sweep and faults pages far faster
        ! than the extraction loop, which interleaves a read of `values`. Cheaper work is
        ! not always less time. See feature_sort_report.md.
        !
        ! **`threads=` is honoured here, and the absent case falls back to the automatic
        ! policy.** `resolve_thread_count` is the same procedure the engine uses, so an
        ! explicit `threads=1` really does make the whole operation serial -- which the
        ! CHANGELOG promises for `pf_argsort`/`pf_sort`/`pf_unique*`/`pf_rank`, and which a
        ! bare `pf_sort_threads()` here would have quietly broken. Entry points that take
        ! no thread argument at all (`pf_sort_keys%add`, `pf_merge`, `pf_is_sorted`,
        ! `pf_partial_*`) pass nothing and get the automatic answer, which is the only
        ! thing they could ever have got.
        !
        ! **The threaded arm lives in `extract_bool_par`, and keeping it OUT of this
        ! procedure is load-bearing rather than tidiness.** With the two `!$omp parallel do`
        ! regions written inline here, gfortran's codegen for the SERIAL branch below -- whose
        ! statements are unchanged either way -- measured **2.4x slower**: 0.251 -> 0.609
        ! ns/element on `i64` and 0.250 -> 0.356 on `f64` (machine A, gfortran 15.2, n = 5e6,
        ! `--serial`, against a 0.004 ns cross-build floor). That arm is taken by every caller
        ! passing `threads=1`, by a single-core machine, and by every sort inside an existing
        ! OpenMP region, since `pf_sort_threads()` answers 1 there. See feature_sort.md 4k/4l.
        call resolve_thread_count(threads, n, nth)
        team = tail_team(nth, n)
        if (team > 1) then
            call extract_bool_par(buf(1)%ints, values, n, team)
        else
            call extract_bool_ser(buf(1)%ints, values, n)
        end if
        if (present(is_valid)) call valid_from_mask(is_valid, n, proc, buf(1)%valid)
    end procedure extract_bool
    !
    module procedure extract_chr
        integer(int64) :: k, n, total, pos, j, ln
        !
        n = size(values, kind=int64)
        allocate(buf(1))
        buf(1)%family = SK_STR
        buf(1)%descending = descending
        buf(1)%nulls_first = nulls_first
        ! Sorted on the FULL declared length, trailing blanks included, which is exactly
        ! Fortran's own `<` for equal-length strings -- so pf_is_sorted agrees with a
        ! hand-written a(k) <= a(k+1) loop rather than quietly trimming behind it.
        ln = int(len(values), int64)
        allocate(buf(1)%offsets(n + 1_int64))
        total = 0_int64
        do k = 1_int64, n + 1_int64
            buf(1)%offsets(k) = total
            total = total + ln
        end do
        buf(1)%offsets(n + 1_int64) = n * ln
        allocate(buf(1)%data(max(n * ln, 1_int64)))
        pos = 0_int64
        do k = 1_int64, n
            do j = 1_int64, ln
                buf(1)%data(pos + j) = values(k)(j:j)
            end do
            pos = pos + ln
        end do
        if (present(is_valid)) call valid_from_mask(is_valid, n, proc, buf(1)%valid)
    end procedure extract_chr
    !
    !> Threaded pre-fill and extraction for `extract_i32` -- the 32-bit integer arm.
    !!
    !! **The pre-fill and the extraction share ONE static schedule, and that pairing is the
    !! point.** Whichever pass writes a page first decides which NUMA node it lives on for the
    !! rest of the sort, so a serial blanket fill puts the whole key buffer on the master
    !! thread's node and every other thread then reads it across the interconnect.
    !!
    !! **Do NOT drop the fill as a redundant pass**, however obviously the second loop covers
    !! every one of `1..n`. Removing it measured 23% SLOWER at 64 threads (6.46 against 5.26
    !! ns/element, f64, n = 5e6, ifx): the fill is a pure sequential sweep and faults pages far
    !! faster than the extraction loop, which interleaves a read of `values`.
    subroutine extract_i32_par(dst, values, n, team)
        integer(int64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        integer(int32), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer, intent(in) :: team !! threads to use; the caller has already checked it is > 1.
        integer(int64) :: k
        !
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = 0_int64
        end do
        !$omp end parallel do
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = int(values(k), int64)
        end do
        !$omp end parallel do
    end subroutine extract_i32_par
    !
    !> Threaded pre-fill and extraction for `extract_i64` -- the 64-bit integer arm.
    !!
    !! **The pre-fill and the extraction share ONE static schedule, and that pairing is the
    !! point.** Whichever pass writes a page first decides which NUMA node it lives on for the
    !! rest of the sort, so a serial blanket fill puts the whole key buffer on the master
    !! thread's node and every other thread then reads it across the interconnect.
    !!
    !! **Do NOT drop the fill as a redundant pass**, however obviously the second loop covers
    !! every one of `1..n`. Removing it measured 23% SLOWER at 64 threads (6.46 against 5.26
    !! ns/element, f64, n = 5e6, ifx): the fill is a pure sequential sweep and faults pages far
    !! faster than the extraction loop, which interleaves a read of `values`.
    subroutine extract_i64_par(dst, values, n, team)
        integer(int64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        integer(int64), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer, intent(in) :: team !! threads to use; the caller has already checked it is > 1.
        integer(int64) :: k
        !
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = 0_int64
        end do
        !$omp end parallel do
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = values(k)
        end do
        !$omp end parallel do
    end subroutine extract_i64_par
    !
    !> Threaded pre-fill and extraction for `extract_f32` -- the 32-bit real arm.
    !!
    !! **The pre-fill and the extraction share ONE static schedule, and that pairing is the
    !! point.** Whichever pass writes a page first decides which NUMA node it lives on for the
    !! rest of the sort, so a serial blanket fill puts the whole key buffer on the master
    !! thread's node and every other thread then reads it across the interconnect.
    !!
    !! **Do NOT drop the fill as a redundant pass**, however obviously the second loop covers
    !! every one of `1..n`. Removing it measured 23% SLOWER at 64 threads (6.46 against 5.26
    !! ns/element, f64, n = 5e6, ifx): the fill is a pure sequential sweep and faults pages far
    !! faster than the extraction loop, which interleaves a read of `values`.
    subroutine extract_f32_par(dst, values, n, team)
        real(real64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        real(real32), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer, intent(in) :: team !! threads to use; the caller has already checked it is > 1.
        integer(int64) :: k
        !
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = 0.0_real64
        end do
        !$omp end parallel do
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = real(values(k), real64)
        end do
        !$omp end parallel do
    end subroutine extract_f32_par
    !
    !> Threaded pre-fill and extraction for `extract_f64` -- the 64-bit real arm.
    !!
    !! **The pre-fill and the extraction share ONE static schedule, and that pairing is the
    !! point.** Whichever pass writes a page first decides which NUMA node it lives on for the
    !! rest of the sort, so a serial blanket fill puts the whole key buffer on the master
    !! thread's node and every other thread then reads it across the interconnect.
    !!
    !! **Do NOT drop the fill as a redundant pass**, however obviously the second loop covers
    !! every one of `1..n`. Removing it measured 23% SLOWER at 64 threads (6.46 against 5.26
    !! ns/element, f64, n = 5e6, ifx): the fill is a pure sequential sweep and faults pages far
    !! faster than the extraction loop, which interleaves a read of `values`.
    subroutine extract_f64_par(dst, values, n, team)
        real(real64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        real(real64), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer, intent(in) :: team !! threads to use; the caller has already checked it is > 1.
        integer(int64) :: k
        !
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = 0.0_real64
        end do
        !$omp end parallel do
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = values(k)
        end do
        !$omp end parallel do
    end subroutine extract_f64_par
    !
    !> Threaded pre-fill and extraction for `extract_bool` -- the logical arm.
    !!
    !! **The pre-fill and the extraction share ONE static schedule, and that pairing is the
    !! point.** Whichever pass writes a page first decides which NUMA node it lives on for the
    !! rest of the sort, so a serial blanket fill puts the whole key buffer on the master
    !! thread's node and every other thread then reads it across the interconnect.
    !!
    !! **Do NOT drop the fill as a redundant pass**, however obviously the second loop covers
    !! every one of `1..n`. Removing it measured 23% SLOWER at 64 threads (6.46 against 5.26
    !! ns/element, f64, n = 5e6, ifx): the fill is a pure sequential sweep and faults pages far
    !! faster than the extraction loop, which interleaves a read of `values`.
    subroutine extract_bool_par(dst, values, n, team)
        integer(int64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        logical, intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer, intent(in) :: team !! threads to use; the caller has already checked it is > 1.
        integer(int64) :: k
        !
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = 0_int64
        end do
        !$omp end parallel do
        !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
        do k = 1_int64, n
            dst(k) = merge(1_int64, 0_int64, values(k))
        end do
        !$omp end parallel do
    end subroutine extract_bool_par
    !
    !> Serial pre-fill and extraction for `extract_i32` -- the 32-bit integer arm.
    !!
    !! The blanket fill is kept for the reason given on the threaded twin: it is a sequential
    !! sweep that faults pages faster than the extraction loop, which interleaves a read of
    !! `values`. Dropping it measured 23% SLOWER at 64 threads.
    subroutine extract_i32_ser(dst, values, n)
        integer(int64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        integer(int32), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer(int64) :: k
        !
        dst = 0_int64
        do k = 1_int64, n
            dst(k) = int(values(k), int64)
        end do
    end subroutine extract_i32_ser
    !
    !> Serial pre-fill and extraction for `extract_i64` -- the 64-bit integer arm.
    !!
    !! The blanket fill is kept for the reason given on the threaded twin: it is a sequential
    !! sweep that faults pages faster than the extraction loop, which interleaves a read of
    !! `values`. Dropping it measured 23% SLOWER at 64 threads.
    subroutine extract_i64_ser(dst, values, n)
        integer(int64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        integer(int64), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer(int64) :: k
        !
        dst = 0_int64
        do k = 1_int64, n
            dst(k) = values(k)
        end do
    end subroutine extract_i64_ser
    !
    !> Serial pre-fill and extraction for `extract_f32` -- the 32-bit real arm.
    !!
    !! The blanket fill is kept for the reason given on the threaded twin: it is a sequential
    !! sweep that faults pages faster than the extraction loop, which interleaves a read of
    !! `values`. Dropping it measured 23% SLOWER at 64 threads.
    subroutine extract_f32_ser(dst, values, n)
        real(real64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        real(real32), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer(int64) :: k
        !
        dst = 0.0_real64
        do k = 1_int64, n
            dst(k) = real(values(k), real64)
        end do
    end subroutine extract_f32_ser
    !
    !> Serial pre-fill and extraction for `extract_f64` -- the 64-bit real arm.
    !!
    !! The blanket fill is kept for the reason given on the threaded twin: it is a sequential
    !! sweep that faults pages faster than the extraction loop, which interleaves a read of
    !! `values`. Dropping it measured 23% SLOWER at 64 threads.
    subroutine extract_f64_ser(dst, values, n)
        real(real64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        real(real64), intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer(int64) :: k
        !
        dst = 0.0_real64
        do k = 1_int64, n
            dst(k) = values(k)
        end do
    end subroutine extract_f64_ser
    !
    !> Serial pre-fill and extraction for `extract_bool` -- the logical arm.
    !!
    !! The blanket fill is kept for the reason given on the threaded twin: it is a sequential
    !! sweep that faults pages faster than the extraction loop, which interleaves a read of
    !! `values`. Dropping it measured 23% SLOWER at 64 threads.
    subroutine extract_bool_ser(dst, values, n)
        integer(int64), intent(out), contiguous :: dst(:) !! the key buffer to fill.
        logical, intent(in) :: values(:) !! the caller's values.
        integer(int64), intent(in) :: n !! elements to extract.
        integer(int64) :: k
        !
        dst = 0_int64
        do k = 1_int64, n
            dst(k) = merge(1_int64, 0_int64, values(k))
        end do
    end subroutine extract_bool_ser
    !
    module procedure valid_from_mask
        integer(int64) :: k, m
        character(len=32) :: got_str, want_str
        !
        m = size(mask, kind=int64)
        if (m /= n) then
            write (got_str, "(i0)") m
            write (want_str, "(i0)") n
            error stop EP // proc // ": is_valid has " // trim(got_str) // " elements but the " // &
                "values have " // trim(want_str)
        end if
        ! Left UNALLOCATED when nothing is null: the caller turns that into a null pointer, which
        ! the engine reads as "no nulls" and takes its own fast path for. Answering `.true.` for
        ! every row instead would be correct and measurably slower.
        if (all(mask(1:n))) return
        allocate(valid(max(n, 1_int64)))
        valid = 1_c_int8_t
        do k = 1_int64, n
            if (.not. mask(k)) valid(k) = 0_c_int8_t
        end do
    end procedure valid_from_mask
    !
    module procedure drive_engine
        type(c_ptr) :: builder
        integer(int64) :: status, nthreads
        integer :: ik
        !
        if (size(keys) < 1) then
            ! Unreachable: every public entry point rejects an empty key list before reaching here.
            error stop EP // proc // ": no sort key was given; call keys%add(...) at least once" ! GCOVR_EXCL_LINE
        end if
        ! Sized EXACTLY, never max(nrows, 1): a zero-row sort must hand back a zero-length
        ! permutation, or `size(perm)` lies and a caller's `do k = 1, size(perm)` reads element 1
        ! of an empty array. The engine is not called at all below two rows, so nothing downstream
        ! needs the one-element floor the extraction buffers use.
        allocate(perm(nrows))
        ! Resolved BEFORE any identity fill, so a fill can be threaded. It was one of three
        ! whole-column serial loops that together were 48% of a 64-thread end-to-end sort before
        ! they were threaded -- see `bench/benchmark_sort_tail.f90`, which sizes each one.
        call resolve_thread_count(threads, nrows, nthreads)
        if (nrows < 2_int64) then
            ! Zero or one row: the identity IS the answer and no engine runs.
            call fill_identity(perm, nrows, nthreads)
            return
        end if
        ! **The Fortran engine gets NO identity fill, and that is checked rather than assumed.**
        ! Each of its four paths establishes `perm` itself: the counting and radix paths write
        ! every slot directly, `sort_comparison_permutation` opens with its own `perm(k) = k`
        ! loop, and `sort_radix_multi_permutation` fills before its first key. Filling here as
        ! well was an extra whole-column pass on every sort -- around 0.3-0.4 ns/element, which on
        ! a counting-path sort is a large share of the total.
        !
        ! **A new Fortran-engine path must fill `perm` itself, or restore a fill here.** A path
        ! that reads `perm` expecting the identity will usually still pass its tests, because a
        ! fresh allocation reads back as zeros -- the trap CLAUDE.md records under 'An intermittent
        ! test failure has THREE causes'. Verified against that by running the whole suite under an
        ! LD_PRELOAD malloc filling every block with 0xFF.
        if (dbg_fortran_engine) then
            ! Stage 2 scaffolding -- see `dbg_fortran_engine`'s declaration. **Stage 4 made this
            ! branch honour `threads`**, and the resolution above is deliberately SHARED with the
            ! C++ path below rather than repeated here, so the two engines are handed the same
            ! number by the same procedure and an A/B compares engines rather than policies.
            !
            ! The A/B stays valid at every thread count for a reason that is about the ordering and
            ! not about either implementation: both comparators end in a row-index tiebreaker, so no
            ! two distinct rows compare equal, exactly one permutation is correct, and a threaded
            ! answer that differs from a serial one is WRONG rather than merely different.
            call sort_build_permutation_threaded(keys, nrows, nthreads, perm)
            return
        end if
        ! **The C++ engine, reached through the pointer parquet_sorting_oracle bound.**
        ! Naming its bind(C) entry points here would put parquet_bindings -- and with it the
        ! whole Arrow stack -- into the use graph of every program that sorts anything, which
        ! is exactly what this tier exists to avoid. The oracle is TEST-ONLY: the shipped path
        ! is the Fortran branch above, and a build that never imports the oracle never
        ! compiles it. check_oracle aborts rather than falling back -- a silent fallback would
        ! make the A/B conformance tests compare the Fortran engine against itself and pass.
        call oracle_argsort(keys, nrows, nthreads, proc, perm)
    end procedure drive_engine
    !
    module procedure pf_sort_threads
        use parquet_settings_base, only : parquet_get_sort_threads
        use parquet_settings_base, only : parquet_auto_thread_count
        !
        ! **The rule itself lives in parquet_auto_thread_count (src/parquet_settings_base.f90)** --
        ! the serial-inside-a-parallel-region default, why the predicate is omp_get_level rather
        ! than omp_in_parallel, the libgomp deadlock behind that choice (feature_risks.md Risk-104),
        ! why a cap may only lower the answer, and the omp_get_num_procs clamp. It was moved there
        ! when parquet_random gained a threaded bulk permutation and needed the same answer:
        ! CLAUDE.md's auto-threading note names a further copy of this rule as the mistake, and
        ! parquet_random is pure Fortran, so it cannot reach this module without acquiring the C++
        ! dependency parquet_sorting carries. Behaviour here is unchanged.
        !
        ! What stays here is which SETTING caps the sort, and this is the ONE place it is read:
        ! Risk-40 records that pf_sort_threads is public precisely so a read-time sort_by= and a
        ! raw-array sort ask the same question, and a second reader is how the two would come to
        ! disagree.
        n = parquet_auto_thread_count(parquet_get_sort_threads(), "sorting")
    end procedure pf_sort_threads
    !
    module procedure resolve_thread_count
        use parquet_settings_base, only : parquet_nested_team_unsafe
#ifdef _OPENMP
        use omp_lib, only : omp_get_num_procs
#endif
        !
        if (present(threads)) then
            ! An explicit request is honoured wherever it is made, including inside a parallel
            ! region: the caller has said what they want, and refusing it there would leave no way
            ! to thread a sort at all from code that is itself parallel.
            count = max(1_int64, int(threads, int64))
            ! **One exception, and it is narrow on purpose: an enclosing region that is not
            ! actually running in parallel.** `parquet_nested_team_unsafe`
            ! (src/parquet_settings_base.f90) is that predicate and carries the reduction and the
            ! bisection behind it -- feature_risks.md Risk-104. It lives there rather than here so
            ! that parquet_random's threaded bulk permutation asks the identical question; two
            ! copies of a deadlock guard is exactly the shape that comes apart later.
            !
            ! **This clamps BOTH engines, and that is not a detail to get wrong.** The count
            ! resolved here is the one `drive_engine` hands to `sort_build_permutation_threaded`
            ! (Fortran) and to `parquet_sort_builder_build` (C++) alike, so the C++ engine is not
            ! exempt merely because it threads with `std::thread` rather than OpenMP. Only the
            ! ENCLOSING region's active level decides, never which engine is selected.
            if (parquet_nested_team_unsafe()) count = 1_int64
        else
            count = int(pf_sort_threads(), int64)
        end if
        ! Never more threads than rows; the C++ side clamps again by its own minimum chunk size.
        if (count > nrows) count = max(nrows, 1_int64)
#ifdef _OPENMP
        ! **Clamped to the processors actually available, and this is the ONE place that protects
        ! the tail.** The engine has always clamped separately (`sort_build_permutation_threaded`),
        ! so a bound process merely sorted serially -- but key extraction, the identity fill and the
        ! narrowing size their teams from this count and had no clamp at all, so they opened a full
        ! team on however few processors the mask allowed. Measured on machine B with
        ! `OMP_PLACES=cores`, where `omp_get_num_procs()` reports 2 while `omp_get_max_threads()`
        ! reports 64: extraction went **0.35 -> 2.58 ns/element**, a 7.3x loss from 32x
        ! oversubscription of two cores, while the engine only went serial.
        !
        ! **An explicit `threads=` is clamped too**, deliberately, unlike the parallel-region rule
        ! above which honours it. Those are different questions: a caller inside a parallel region
        ! has said something the library should obey, whereas a caller asking for 64 threads on a
        ! 2-processor mask has asked for something that cannot happen -- the threads would time-share
        ! and run slower than the serial path.
        !
        ! **The clamp cannot be avoided by asking the place list instead.** `omp_get_num_places()`
        ! and `omp_get_place_num_procs()` were measured on a process whose initial thread was
        ! pre-bound to 2 CPUs: they report 2 places totalling 2 processors, not the machine's 384,
        ! and a team of 64 then lands on 2 distinct CPUs. The true machine size is not recoverable
        ! from inside the process. See feature_sort_report.md sections 5 and 11.
        !
        ! **The clamp and its warning both live in `parquet_clamp_to_affinity`**
        ! (src/parquet_settings_base.f90), which is the one place four resolvers share -- this one,
        ! `pf_sort_threads` and the bulk random draws through `parquet_auto_thread_count`,
        ! `prefetch_thread_count` and `parquet_string_threads`. It receives the PRE-clamp count, so
        ! the message names what this call actually asked for rather than the environment's ICV.
        !
        ! **`pf_sort_threads()` has already clamped on the automatic path**, so the call below is a
        ! no-op there and the warning has come from inside `pf_sort_threads` instead. It is NOT a
        ! silent clamp: `parquet_clamp_to_affinity` has no silent variant, deliberately, and its own
        ! doc-comment says why. Letting a query clamp without printing was tried and is exactly what
        ! made the warning unreachable for the job it was written for -- an automatic path arriving
        ! here already clamped, from a caller who asked for 64 threads through `OMP_NUM_THREADS` and
        ! silently got 2. Before the clamp was shared, this site was the ONLY one that warned.
        count = int(parquet_clamp_to_affinity(int(count), "sorting"), int64)
#endif
    end procedure resolve_thread_count
    !
    module procedure tail_team
        !> Elements each thread must get from a tail pass for the team to be worth opening.
        !!
        !! **The tail's floor is its OWN, and this is the change that separated it.** It used to
        !! read the since-retired `sort_parallel_min_rows` setting -- which also decided whether the
        !! RADIX threads -- on the reasoning that the two should "decline together". They should
        !! not: a tail pass is memcpy-shaped (extraction, the identity fill, the int32 narrowing)
        !! while the sort is compute-bound over many passes, so one number could not be right for
        !! both and was measured being right for neither.
        !!
        !! **The floor is `max(SORT_TAIL_MIN_ROWS, SORT_TAIL_ELEMS_PER_THREAD * nt)`, and unlike the
        !! refinement floor it is dominated by its ABSOLUTE term.** Measured on machine B with
        !! `benchmark_sort_tail --extract-only`, threaded against serial over n x team: the flat 8192
        !! this replaces is wrong by **16.22x** under gfortran (n = 8192 at 64 threads) and 2.75x
        !! under ifx.
        !!
        !! **The two compilers genuinely disagree here, and ifx is weighted per the maintainer's
        !! rule.** Under ifx extraction threads profitably from n = 32768 at every team size (1.25x
        !! to 1.36x) and the gains reach 74x at 4 M rows; under gfortran it does not pay until
        !! 65536, and not until 262144 at 64 threads. The rule below costs gfortran at most 2.28x
        !! (n = 32768, 32 threads) while taking ifx's worst case to 1.25x -- against 16.22x and 2.75x
        !! for the constant it replaces, so both compilers gain substantially even though only one
        !! of them got its preferred value.
        integer(int64), parameter :: SORT_TAIL_MIN_ROWS = 32768_int64
        integer(int64), parameter :: SORT_TAIL_ELEMS_PER_THREAD = 1024_int64
        integer(int64) :: floor_rows !! resolved floor, after any debug override.
        !
        team = 1
        if (nthreads <= 1_int64) return
        floor_rows = max(SORT_TAIL_MIN_ROWS, SORT_TAIL_ELEMS_PER_THREAD * nthreads)
        if (dbg_sort_tail_min_rows >= 0_int64) floor_rows = dbg_sort_tail_min_rows
        if (n < floor_rows) return
        team = int(min(nthreads, int(huge(0), int64)))
        if (team < 1) team = 1
    end procedure tail_team
    !
    module procedure fill_identity
        integer(int64) :: ik
        integer :: team
        !
        team = tail_team(nthreads, n)
        ! **The threaded arm is currently UNREACHABLE, and is kept rather than deleted.** The one
        ! caller in the library is `drive_engine`, which fills the identity only when `nrows < 2` --
        ! every engine path establishes `perm` itself -- and `resolve_thread_count` clamps its count
        ! to `max(nrows, 1)`, so `nthreads` is always 1 here and `tail_team` declines. No fixture and
        ! no debug override can change that: `dbg_sort_tail_min_rows` lowers the element floor, not
        ! the thread count. It stays because this is a PUBLIC procedure of the argsort tier whose
        ! documented contract is "threaded when `nthreads` and `n` justify it", and because a future
        ! engine path that reinstates a whole-column fill would want it back -- at which point the
        ! markers below come off and `tools/coverage.sh` reports the exclusion as stale.
        if (team > 1) then
            ! GCOVR_EXCL_START
            !$omp parallel do num_threads(team) default(shared) private(ik) schedule(static)
            do ik = 1_int64, n
                perm(ik) = ik
            end do
            !$omp end parallel do
            return
            ! GCOVR_EXCL_STOP
        end if
        do ik = 1_int64, n
            perm(ik) = ik
        end do
    end procedure fill_identity
    !
    module procedure narrow_perm
        integer(int64) :: n, nthreads, k
        character(len=32) :: n_str
        integer :: team
        !
        n = size(perm64, kind=int64)
        if (n > int(huge(1_int32), int64)) then
            ! GCOVR_EXCL_START -- unreachable without a >2-billion-element sort; the array that
            ! would trip it cannot be built by any fixture this repository can run. Kept because
            ! the alternative is a silent truncation into a plausible wrong index.
            write (n_str, "(i0)") n
            error stop EP // proc // ": this array has " // trim(n_str) // " elements, which " // &
                "does not fit an int32 permutation; declare perm as integer(int64)"
            ! GCOVR_EXCL_STOP
        end if
        allocate(perm32(n))
        ! Threaded for the same reason `fill_identity` is: at 64 threads this whole-column copy was
        ! ~13% of an end-to-end argsort while every other phase had been parallelised around it.
        call resolve_thread_count(threads, n, nthreads)
        team = tail_team(nthreads, n)
        if (team > 1) then
            !$omp parallel do num_threads(team) default(shared) private(k) schedule(static)
            do k = 1_int64, n
                perm32(k) = int(perm64(k), int32)
            end do
            !$omp end parallel do
            return
        end if
        perm32 = int(perm64, int32)
    end procedure narrow_perm
    !
    module procedure narrow_offsets
        integer(int64) :: sentinel
        character(len=32) :: n_str
        !
        ! The SENTINEL, not the length -- and the difference is exactly one row. A permutation's
        ! largest entry is n, but this array's is n + 1, so at n == huge(int32) narrow_perm's own
        ! test passes while this one must not: a wrapped sentinel makes the last group's
        ! o(g+1) - 1 a huge negative bound, i.e. a silently wrong slice instead of an abort.
        sentinel = offsets64(size(offsets64))
        if (sentinel > int(huge(1_int32), int64)) then
            ! GCOVR_EXCL_START -- unreachable without a >2-billion-row sort; the same reason
            ! narrow_perm's own guard has no test either. Kept because the failure it prevents is
            ! silent, which is precisely when an untestable guard earns its place. The marker sits
            ! INSIDE the test: the test itself runs on every call, and excluding it too would file
            ! it forever as a stale-exclusion candidate in tools/coverage.sh's own report.
            write (n_str, "(i0)") sentinel - 1_int64
            error stop EP // proc // ": this array has " // trim(n_str) // " elements, so the " // &
                "group offsets do not fit int32; declare group_offsets as integer(int64)"
            ! GCOVR_EXCL_STOP
        end if
        allocate(offsets32(size(offsets64, kind=int64)))
        offsets32 = int(offsets64, int32)
    end procedure narrow_offsets
    !
    ! ---- The M3 engine drivers ----
    ! Unlike drive_engine above, these three always go through the builder, even for a single key.
    ! The one-shot entry points exist to skip a copy on the hottest path in the library, and none
    ! of these is it -- run detection, binary search and merging each cost one extra copy of an
    ! already-extracted buffer in exchange for one entry point per operation instead of three.
    module procedure engine_build_runs
        type(c_ptr) :: builder
        integer(int64) :: status, k, nthreads, gek
        integer :: ik
        !
        if (size(keys) < 1) then
            error stop EP // proc // ": no sort key was given" ! GCOVR_EXCL_LINE
        end if
        allocate(perm(nrows))
        allocate(tie(max(nrows, 1_int64)))
        tie = 0_c_int8_t
        do k = 1_int64, nrows
            perm(k) = k
        end do
        if (nrows < 2_int64) return
        call resolve_thread_count(threads, nrows, nthreads)
        ! Resolved HERE, never in C++: the boundary carries a real count, never a "0 means all"
        ! sentinel, so the C++ side obeys rather than interprets what a prefix of zero would mean.
        gek = int(size(keys), int64)
        if (present(group_ekeys)) gek = int(group_ekeys, int64)
        if (dbg_fortran_engine) then
            call sort_build_runs_permutation(keys, nrows, gek, perm, tie)
            return
        end if
        ! **The C++ engine, reached through the pointer parquet_sorting_oracle bound.**
        ! Naming its bind(C) entry points here would put parquet_bindings -- and with it the
        ! whole Arrow stack -- into the use graph of every program that sorts anything, which
        ! is exactly what this tier exists to avoid. The oracle is TEST-ONLY: the shipped path
        ! is the Fortran branch above, and a build that never imports the oracle never
        ! compiles it. check_oracle aborts rather than falling back -- a silent fallback would
        ! make the A/B conformance tests compare the Fortran engine against itself and pass.
        call oracle_runs(keys, nrows, nthreads, gek, proc, perm, tie)
    end procedure engine_build_runs
    !
    module procedure drive_engine_grouped
        integer(c_int8_t), allocatable :: tie(:)
        !
        if (.not. present(group_offsets)) then
            ! Nothing to report, so nothing is given up: this is drive_engine exactly, one-shot
            ! borrow and all. The branch is what keeps asking for boundaries the only thing that
            ! costs anything.
            call drive_engine(keys, nrows, proc, perm, threads=threads)
            return
        end if
        call engine_build_runs(keys, nrows, proc, perm, tie, threads=threads, group_ekeys=group_ekeys)
        call runs_to_offsets(tie, nrows, group_offsets)
    end procedure drive_engine_grouped
    !
    module procedure runs_to_offsets
        integer(int64) :: k, ngroups, pos
        !
        ! Bounded by nrows, NEVER by size(tie): engine_build_runs allocates tie with a
        ! max(nrows, 1) floor, so a zero-row sort leaves one element in it that describes no row
        ! and would otherwise be counted as a group.
        ngroups = 0_int64
        do k = 1_int64, nrows
            if (tie(k) == 0_c_int8_t) ngroups = ngroups + 1_int64
        end do
        allocate(offsets(ngroups + 1_int64))
        pos = 0_int64
        do k = 1_int64, nrows
            if (tie(k) == 0_c_int8_t) then
                pos = pos + 1_int64
                offsets(pos) = k
            end if
        end do
        ! The sentinel. It is what lets group g be perm(o(g) : o(g+1) - 1) for EVERY g including
        ! the last -- and for a zero-row sort it is the array's only entry, so `[1]` means "no
        ! groups" rather than an unallocated result every caller would have to test for.
        offsets(ngroups + 1_int64) = nrows + 1_int64
    end procedure runs_to_offsets
    !
    module procedure argsort_i32_i32
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        integer(int64), allocatable :: perm64(:), go64(:)
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_i32(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        if (present(group_offsets)) then
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, &
                threads=threads, group_offsets=go64)
            call narrow_offsets(go64, "pf_argsort", group_offsets)
        else
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, threads=threads)
        end if
        call narrow_perm(perm64, "pf_argsort", perm, threads=threads)
    end procedure argsort_i32_i32
    !
    module procedure argsort_i32_i64
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_i32(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm, threads=threads, &
            group_offsets=group_offsets)
    end procedure argsort_i32_i64
    !
    module procedure argsort_i64_i32
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        integer(int64), allocatable :: perm64(:), go64(:)
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_i64(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        if (present(group_offsets)) then
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, &
                threads=threads, group_offsets=go64)
            call narrow_offsets(go64, "pf_argsort", group_offsets)
        else
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, threads=threads)
        end if
        call narrow_perm(perm64, "pf_argsort", perm, threads=threads)
    end procedure argsort_i64_i32
    !
    module procedure argsort_i64_i64
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_i64(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm, threads=threads, &
            group_offsets=group_offsets)
    end procedure argsort_i64_i64
    !
    module procedure argsort_f32_i32
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        integer(int64), allocatable :: perm64(:), go64(:)
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_f32(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        if (present(group_offsets)) then
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, &
                threads=threads, group_offsets=go64)
            call narrow_offsets(go64, "pf_argsort", group_offsets)
        else
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, threads=threads)
        end if
        call narrow_perm(perm64, "pf_argsort", perm, threads=threads)
    end procedure argsort_f32_i32
    !
    module procedure argsort_f32_i64
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_f32(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm, threads=threads, &
            group_offsets=group_offsets)
    end procedure argsort_f32_i64
    !
    module procedure argsort_f64_i32
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        integer(int64), allocatable :: perm64(:), go64(:)
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_f64(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        if (present(group_offsets)) then
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, &
                threads=threads, group_offsets=go64)
            call narrow_offsets(go64, "pf_argsort", group_offsets)
        else
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, threads=threads)
        end if
        call narrow_perm(perm64, "pf_argsort", perm, threads=threads)
    end procedure argsort_f64_i32
    !
    module procedure argsort_f64_i64
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_f64(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm, threads=threads, &
            group_offsets=group_offsets)
    end procedure argsort_f64_i64
    !
    module procedure argsort_bool_i32
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        integer(int64), allocatable :: perm64(:), go64(:)
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_bool(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        if (present(group_offsets)) then
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, &
                threads=threads, group_offsets=go64)
            call narrow_offsets(go64, "pf_argsort", group_offsets)
        else
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, threads=threads)
        end if
        call narrow_perm(perm64, "pf_argsort", perm, threads=threads)
    end procedure argsort_bool_i32
    !
    module procedure argsort_bool_i64
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_bool(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm, threads=threads, &
            group_offsets=group_offsets)
    end procedure argsort_bool_i64
    !
    module procedure argsort_chr_i32
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        integer(int64), allocatable :: perm64(:), go64(:)
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_chr(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        if (present(group_offsets)) then
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, &
                threads=threads, group_offsets=go64)
            call narrow_offsets(go64, "pf_argsort", group_offsets)
        else
            call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm64, threads=threads)
        end if
        call narrow_perm(perm64, "pf_argsort", perm, threads=threads)
    end procedure argsort_chr_i32
    !
    module procedure argsort_chr_i64
        type(sort_key_buf), allocatable :: buf(:)
        logical :: desc, nlo
        !
        desc = .false.
        if (present(descending)) desc = descending
        nlo = .false.
        if (present(nulls_first)) nlo = nulls_first
        call extract_chr(values, buf, desc, nlo, "pf_argsort", is_valid=is_valid, threads=threads)
        call drive_engine_grouped(buf, size(values, kind=int64), "pf_argsort", perm, threads=threads, &
            group_offsets=group_offsets)
    end procedure argsort_chr_i64
    !
end submodule parquet_argsort_kernel ! GCOVR_EXCL_LINE