parquet_sorting sorts plain Fortran arrays and this library's own column types. It is also the
engine behind every other ordering the library performs: a read-time
parquet_open_reader(..., sort_by=), a post-open parquet_reader_set_sort and
parquet_table%sort_by all go through it. So a read-time sort, a table sort and a raw-array sort
cannot disagree about where nulls go, where NaNs go, or how ties are broken — not because three
implementations are kept in step, but because there is only one, and it is this one.
Everything here is reachable from use parquet, and from use parquet_sorting on its own. A
smaller import covering only pf_argsort over the intrinsic types is described in
The lighter import: parquet_argsort below.
pf_, not parquet_Public names in this module carry the pf_ prefix (for parquet-fortran, the library as a
whole) rather than parquet_, because their subject is not a parquet file at all — pf_sort will
happily sort an array that never came from one. The module itself is parquet_sorting rather than
parquet_sort because Fortran does not allow a module to share its name with a procedure it
declares.
| Call | Does |
|---|---|
pf_argsort(values, perm) |
the permutation that would sort values; never modifies it |
pf_sort(values, sorted) |
an independent sorted copy; never modifies its input |
pf_permute(values, perm) |
applies a permutation to values in place |
pf_is_sorted(values, answer) |
whether values is already in the stated order |
pf_partial_sort(values, sorted, n) |
the first n in order, without sorting the rest |
pf_partial_argsort(values, perm, n) |
their indices instead |
pf_nth_element(values, nth, p_value, [index]) |
the value a full sort puts at rank nth |
pf_nth_quantile(values, quantile, p_value, [index]) |
a quantile of the non-null values |
pf_lower_bound(values, target, pos) |
where target belongs in an already-sorted array |
pf_upper_bound(values, target, pos) |
one past the last element equal to target |
pf_equal_range(values, target, first, last) |
both bounds, as an inclusive range |
pf_unique_count(values, count, [n_null]) |
how many distinct non-null values there are |
pf_unique(values, distinct, [n_null]) |
those values themselves, in order |
pf_rank(values, ranks, [method]) |
the rank of every element, in place order |
pf_minmax(values, vmin, vmax) |
the smallest and largest value |
pf_argminmax(values, imin, imax) |
where those two are |
pf_merge(a, b, merged) |
merges two already-sorted arrays in linear time |
The first four are covered immediately below; the rest have their own sections — Selecting without sorting, Searching a sorted array, Distinct values and ranks, Extremes and Merging two sorted arrays.
Optional arguments are shown in square brackets — in the table above, and in prose throughout this page. They are optional at the call site, not part of the syntax, so they never appear that way in a runnable example.
Every index-valued result above is generic over both integer kinds. perm, ranks, index,
pos, first/last, count and imin/imax may each be declared integer(int32) or
integer(int64), and the answers are identical either way — declare whichever suits the code around
the call. The one case where it matters is scale: an array of more than huge(int32) elements
cannot be addressed by an int32 result, and asking for one aborts rather than wrapping to a
plausible wrong index. group_offsets reaches that ceiling one row sooner than perm does, because
its last entry is the sentinel n + 1.
program sort_quickstart
use parquet
use iso_fortran_env, only: int32
implicit none
integer(int32) :: v(6) = [30, 10, 50, 20, 60, 40]
integer(int32), allocatable :: perm(:), sorted(:)
logical :: ok
call pf_argsort(v, perm) ! perm = [2, 4, 1, 6, 3, 5]; v unchanged
call pf_sort(v, sorted) ! sorted = [10, 20, 30, 40, 50, 60]; v unchanged
call pf_permute(v, perm) ! v is now [10, 20, 30, 40, 50, 60]
call pf_is_sorted(v, ok) ! ok = .true.
end program sort_quickstart
The kind names (int32, int64, real64) come from iso_fortran_env, not from parquet — the
later examples on this page are fragments and leave both use lines out.
pf_argsort is the one to reach for when several arrays have to stay in step: sort one, then
apply the same permutation to the rest.
call pf_argsort(ra, perm)
call pf_permute(ra, perm)
call pf_permute(dec, perm)
call pf_permute(name, perm)
Eleven element types, in three groups.
| Type | is_valid= |
pf_sort/_partial_sort |
pf_nth_* |
Notes |
|---|---|---|---|---|
integer(int32), integer(int64) |
yes | yes | yes | |
real(real32), real(real64) |
yes | yes | yes | NaNs get their own tier, see below |
logical |
yes | yes | yes | .false. before .true. |
character(len=*) |
yes | yes | yes | compared over the full declared length |
parquet_date, parquet_time |
no | yes | yes | carries its own null state |
parquet_timestamp |
no | yes | yes | ordered by seconds, then nanoseconds |
parquet_string_column |
no | no | yes | pf_permute reorders the store in place |
parquet_column |
no | no | no | element kind resolved at runtime |
pf_argsort, pf_partial_argsort, pf_permute and pf_is_sorted apply to all eleven: their
answer is a permutation, a boolean or an index, never a value, so a runtime element type is no
obstacle.
A parquet_column has two restrictions the others do not, both settled when the sort reads the
column rather than at compile time. It must be a scalar column: a vector column — one whose
%colwidth() is more than 1 — is refused, because a whole vector row has no defined order. And its
element kind must be one the engine can order: the nine scalar kinds int32, int64, float32,
float64, logical, string, date, time and timestamp. Anything else aborts and names the
kind it was given.
The remaining operations follow the same two rules — a type is out wherever the answer would need a compile-time element type it does not have, and stays in wherever the answer is a permutation, a boolean or an integer:
| Type | search | pf_unique_count |
pf_unique |
pf_rank |
pf_minmax |
pf_argminmax |
pf_merge |
|---|---|---|---|---|---|---|---|
integer(int32/int64), real(real32/real64) |
yes | yes | yes | yes | yes | yes | yes |
logical |
yes | yes | yes | yes | no | no | yes |
character(len=*) |
yes | yes | yes | yes | yes | yes | yes |
parquet_date, parquet_time, parquet_timestamp |
yes | yes | yes | yes | yes | yes | yes |
parquet_string_column |
yes | yes | yes | yes | yes | yes | no |
parquet_column |
no | yes | no | yes | no | yes | no |
logical is left out of pf_minmax/pf_argminmax as vacuous — "where is the first .false." is
not a question worth an API, and letting one of the pair accept it while the other could not would
be worse than either. pf_merge is defined on plain arrays only: merging two columns is
%append_column followed by a sort.
The is_valid= column says where nullness comes from. The six types with no null state of their
own take an optional is_valid(:) mask; the temporal types and the two column types carry
their own and take no such argument, which keeps a single source of truth for whether an element
is null. When you do pass a mask it must have exactly as many elements as values; a length
mismatch aborts rather than being padded or truncated.
pf_sort is deliberately not defined for the two container types: copying a whole column in order
to sort it serves no purpose, and reordering one in place is pf_argsort followed by pf_permute,
which says what it does at the call site.
character(len=*) is compared over its full declared length, trailing blanks included — which
is exactly how Fortran's own < compares two equal-length strings, so pf_is_sorted agrees with a
hand-written a(k) <= a(k+1) loop rather than quietly trimming behind it.
parquet_argsortpf_argsort over the six intrinsic element types — integer(int32/int64),
real(real32/real64), logical and character(len=*) — together with pf_sort_threads and the
sort engine itself, lives one tier down, in a module of its own:
use parquet_argsort
That import compiles 4 of this library's Fortran files, against 21 for use parquet_sorting,
and its Fortran graph never reaches the Parquet C++ bindings. use parquet_sorting and
use parquet are unaffected: they re-export the tier and extend pf_argsort with the five
element types that need a column, a packed string store or a temporal element, so a single
use parquet_sorting resolves all eleven. You would never know the split existed unless you went
looking for the smaller import.
What the smaller import does not include: pf_sort, pf_permute, pf_is_sorted, the
selection, search, uniqueness, rank, extreme and merge operations, pf_sort_keys, and every
element type beyond the six intrinsic ones. Those all stay in parquet_sorting. If you need any of
them, import that instead — the answers are identical either way, because there is one engine.
Why it exists: fpm prunes at module granularity, so without the split a library wanting one
pf_argsort specific would compile the whole sorting graph and link the Parquet C++ stack with it.
Keeping the intrinsic-type argsort in its own tier is what holds use parquet_sampling to 8
Fortran files. See
Choosing a module for the full picture, including the caveat
that no import makes the package Arrow-free.
The tier re-exports the four sorting settings — sort_threads, sort_radix_path,
sort_counting_path, sort_counting_bucket_limit — plus verbosity and message_stream, getter
and setter both, so a program importing it alone can still tune and quieten its own sorting.
The ordering reproduces arrow::compute::SortIndices exactly, which is what lets a pyarrow
cross-check of a sorted file agree row for row.
Nulls and NaNs sit in absolute tiers. descending reverses the values and never the tiers:
ascending (default): values … NaNs nulls
nulls_first=.true.: nulls NaNs … values
descending=.true.: values (reversed) … NaNs nulls
A NaN is an ordinary value that happens to be unordered, so it is tiered rather than treated as missing; a null is missing. The two are separate on purpose, and a NaN is not a null.
Every sort here is stable, unconditionally. Ties keep their original input order, so there is
no stable= argument to pass and no unstable mode to opt into. Stability comes from the
comparator's tiebreaker on the row index, and the integer counting fast path places in order by
construction.
call pf_argsort(v, perm, descending=.true.)
call pf_argsort(v, perm, is_valid=mask, nulls_first=.true.)
call pf_is_sorted(v, ok, descending=.true.) ! same options as the sort
pf_is_sorted takes the same descending/nulls_first options for a reason: an ordering question
is only meaningful against a stated order. It compares adjacent elements only, with no index
tiebreaker, so a run of equal values counts as sorted.
A null-free array should pass no mask at all. Passing an unallocated allocatable to an optional
dummy makes that dummy absent (F2018 15.5.2.12), so a caller whose mask is simply not allocated
gets the engine's no-nulls fast path with no if (present(...)) fork of their own:
logical, allocatable :: mask(:) ! left unallocated when nothing is null
call pf_argsort(v, perm, is_valid=mask) ! reaches the engine as "no mask"
Fortran generics cannot express "an optional second and third array, each of any type" — with
eleven element types that would need over a thousand specific procedures. Multi-key sorting
therefore goes through pf_sort_keys, which accumulates keys of any mix of types:
type(pf_sort_keys) :: k
integer(int64), allocatable :: perm(:)
call k%add(ra) ! primary key
call k%add(dec, descending=.true.) ! breaks ties on ra
call k%add(name) ! breaks ties on both
call pf_argsort(k, perm)
Keys apply in the order added, the first being primary, and each carries its own descending= and
nulls_first=. %nkeys_added() reports how many there are — one per %add call, whatever their
types; %clear() drops them all, leaving the object reusable for an unrelated sort. Every key must
describe the same number of rows, which is checked as each one is added rather than at sort time.
A pf_sort_keys holding no key at all is an error, not an empty sort: pf_argsort, pf_is_sorted
and pf_partial_argsort each abort on one, and so "reusable" means reusable after the next %add,
not immediately after %clear().
A pf_sort_keys also works with pf_is_sorted and pf_partial_argsort, so a multi-key order can
be tested or partially built, not only sorted in full:
call pf_is_sorted(k, ok) ! already in this order? O(n), no allocation
call pf_partial_argsort(k, perm, 10) ! just the ten best rows
pf_argsort can report, in the same pass, where each run of equal rows begins:
integer(int64), allocatable :: perm(:), go(:)
call pf_argsort(k, perm, group_offsets=go)
do g = 1, size(go) - 1
! rows of group g, in order: perm(go(g) : go(g+1) - 1)
end do
group_offsets has length ngroups + 1, and its last entry is the sentinel n + 1 — which is what
lets the loop above slice every group the same way, with no special case for the last one. An empty
array gives [1], meaning no groups.
Two rows share a group when they compare equal, which has one consequence worth stating: all
nulls form one group, and all NaNs form one group. That is deliberately unlike pf_unique, which
drops nulls from its output entirely — a group list has to account for every row.
By default a group is a run equal under every key. group_nkeys= narrows that to the first few,
without changing the sort:
call k%add(field_id)
call k%add(mag)
call pf_argsort(k, perm, group_offsets=go, group_nkeys=1)
! one group per field_id, and within each group the rows are ordered by mag
group_nkeys counts the keys you added, one per %add. That is not always the engine's own
count — a parquet_timestamp key becomes two keys internally — and this argument never exposes the
difference. It must be between 1 and %nkeys_added(), and it requires group_offsets: on its own
it would change nothing, so passing it alone is an error rather than a silent no-op.
Asking for group_offsets costs one extra copy of a single key. Without it a one-key pf_argsort
borrows the key it just extracted; with it the call goes through the builder, which owns its keys.
That is the price of one engine entry point serving three operations instead of three of them.
pf_sort_keys holds no handle of any kind — its keys are ordinary allocatable Fortran arrays, and
nothing outside the type outlives a call. That keeps it free of a finalizer, which in turn makes it
usable per-thread in the obvious way (a finalizable type must never be given to OpenMP's
private() — see Thread safety).
pf_permute validates perm before writing anything, because an invalid permutation does not
fail — it silently duplicates some elements and drops others, and the array afterwards looks
perfectly ordinary. The check is O(N) in front of an O(N) operation, so it is a constant factor
rather than a change of complexity.
call pf_permute(v, perm) ! validated (the default)
call pf_permute(v, perm, assume_valid=.true.) ! skipped: perm came from pf_argsort
Use assume_valid=.true. only for a permutation you know is good — one that came straight from
pf_argsort is the intended case. Note the polarity: this library's check_* arguments (such as
check_null on parquet_string_column) are opt-in checks defaulting .false., so a
check_valid= defaulting .true. would read backwards. assume_valid states the caller's claim
instead, and defaults to .false. so that forgetting it is safe.
assume_valid means the same thing for all eleven types, the two column ones included — they
reorder their storage without the scan instead of with it.
It skips the contents check only. perm's length is checked either way, because a short
permutation would make the gather read past the end of values, and no promise from the caller can
make that defined. What a false promise costs is the other half: elements silently duplicated and
dropped, with no abort and no symptom.
"In place" is about the result, not about memory. pf_permute modifies values rather than
handing back a reordered copy, but it gathers through a temporary the size of the array and copies
back, so it needs room for a second copy of the column while it runs. Passing an int32 perm
to a pf_permute also builds an int64 copy of the permutation. Neither matters until the array
is large enough for a second copy to be the constraint.
To reorder a whole parquet_table, use its own %sort_by, which reorders every column
together:
call t%sort_by(["mass"], descending=[.true.])
That call runs on this same engine, so it orders rows exactly as pf_argsort would on the same
key values. A key column that has not been read yet is read for you.
The table has three read-only counterparts that answer the same questions without moving anything —
%argsort_by, %is_sorted_by and %argsort_partial. Because they do not reorder, they do not
detach the table from its file, which %sort_by does. See
Ordering rows without reordering them.
%top_n is the mutating counterpart of %argsort_partial, and stands in the same relation to
%sort_by as pf_partial_argsort does to pf_argsort: it keeps only the n rows the keys put
first, selecting rather than ordering everything. See
Keeping only the best rows.
Do not
pf_permutea pointer obtained from%col.%colhands back a writable pointer into a table's live storage, so permuting through it reorders that one column and leaves every other column exactly where it was — silently breaking the row correspondence that makes the table a table. Nothing detects this: the table still reports the same row count, and every subsequent read returns values that are individually valid and jointly wrong.%sort_byis the supported way to reorder a table.
Sorting a standalone parquet_column — one you built yourself, or copied out of a table — is
perfectly safe, and is what the parquet_column row of the type table above is for.
Three operations answer "which element ends up here?" without ordering the whole array.
call pf_partial_sort(v, top, n=10) ! the 10 smallest, in order
call pf_partial_argsort(v, perm, n=10) ! their indices instead
call pf_nth_element(v, 5, val) ! the value a full sort puts at rank 5
call pf_nth_quantile(v, 0.5_real64, med) ! the median of the non-null values
n is clamped, not checked. pf_partial_sort(v, top, n=1000) on a 100-element array returns
all 100, in order — so an n derived from a fraction, a config value or a post-filter row count
needs no min(n, size(v)) of your own. A negative n is still an error.
pf_nth_element's nth is the opposite: it is checked, not clamped. A rank outside
1 .. size(values) aborts rather than being pulled to the nearest end. The two arguments sit one
line apart and behave oppositely on purpose — asking for "the best 1000 of 100" is a reasonable
thing to mean, while asking for "the 1000th of 100" is not a request that has an answer. Clamp
nth yourself if it comes from arithmetic that can overshoot.
The four selection operations take threads= too; what they do and do not thread is set out under
What threads= reaches in a selection.
"The last N" is descending=.true., not a separate procedure:
call pf_partial_sort(flux, brightest, n=10, descending=.true.)
On a whole table the same operation is parquet_table%top_n, which keeps the n best rows of every
column together — see Keeping only the best rows.
The complexity claim, with its caveat. pf_partial_sort is O(n log k) for k results and
pf_nth_element is O(n), against O(n log n) for a full sort — but pf_partial_sort stops
paying as k approaches the array size, and at k = size it is strictly worse than calling
pf_sort. Use it when you want a small slice of a large array; use pf_sort when you want most of
it. (One case gets both for free: a low-cardinality integer key takes a counting-sort path that is
already O(n) and already fully ordered, so partial and full cost the same there.)
pf_nth_element reportsindex is optional, and the value it reports is the index a full stable sort would give —
not merely an element equal to the answer. That matters whenever the array has duplicates:
integer(int32) :: v(6) = [5, 3, 5, 1, 5, 3]
call pf_nth_element(v, 3, val, index) ! val = 3, index = 6
Ranks 2 and 3 both hold the value 3; rank 3 is the second of them, so it reports the later
original position. std::nth_element on its own leaves an arbitrary member of an equal run at that
position, which would make the answer vary between builds — the ordering here ends with a
tiebreaker on the original index, so it does not.
nth counts nulls as ranked elements, placed by the same tier rules as the sort, and
descending/nulls_first/is_valid mean exactly what they do for pf_argsort.
pf_nth_quantile's quantile argument is on a 0–1 scale, not 0–100. Passing 50 aborts
rather than silently answering, because 0.5 is valid on both scales and means completely
different things.
call pf_nth_quantile(flux, 0.5_real64, median)
call pf_nth_quantile(flux, 0.9_real64, p90, is_valid=mask, n_null=nmissing)
call pf_nth_quantile(flux, 0.5_real64, median, index) ! and where that value came from
index is optional here exactly as it is on pf_nth_element, and reports the same thing under the
same rule: the element a full stable sort would have put at that position, so a value occurring
several times reports the right one of them rather than an arbitrary one. The only difference is
which population the position is taken over — the non-null values, since that is what a quantile of
this array means.
Nulls are excluded from the population, not placed in it — the one operation in this module
where that is true, and the reason it takes neither descending nor nulls_first: there is no
null tier to position, and a descending quantile is just 1 - quantile. n_null reports how many
values were dropped, so you can decide whether the answer is trustworthy.
An all-null array aborts. There is no value to return and no sentinel that works across all ten
supported types, so returning an undefined p_value would hand back something that looks like data.
n_null is for partial nullness; guard with count(mask) (or a column's own null count) if the
all-null case can happen.
A fractional position is resolved by rounding=, matched case-insensitively:
call pf_nth_quantile(v, 0.5_real64, q, rounding="down") ! "nearest" (default), "down", "up"
An unrecognized token aborts and names the valid ones.
pf_sort and pf_partial_sort take an optional sorted_valid= reporting which output elements
are null — the mask you passed in describes the input order, which is not the order you get back:
call pf_sort(v, sorted, is_valid=mask, sorted_valid=out_mask) ! out_mask(k) describes sorted(k)
It is always allocated when you ask for it, including when you passed no is_valid at all (in
which case it is all .true.). That differs from the "unallocated means no nulls" convention used
for inputs, deliberately: an input you leave unallocated is you declining to supply information,
while an output you explicitly asked for is a direct question.
pf_lower_bound, pf_upper_bound and pf_equal_range locate a value in an array that is
already sorted. All three answer with 1-based positions in 1 .. size(values)+1, so the
answer is an insertion point: where the target belongs, whether or not it is there.
integer(int32) :: v(9) = [10, 20, 20, 20, 30, 40, 40, 50, 60]
integer :: lo, hi, first, last
call pf_lower_bound(v, 20_int32, lo) ! lo = 2 -- the first 20
call pf_upper_bound(v, 20_int32, hi) ! hi = 5 -- one past the last 20
call pf_equal_range(v, 20_int32, first, last) ! first = 2, last = 4
call pf_equal_range(v, 25_int32, first, last) ! first = 5, last = 4 -- absent
hi - lo is how many elements equal the target, and pf_equal_range returns the same information
as an inclusive range from one pass. When the target is absent, last == first - 1, so
last - first + 1 is zero — check that count before reading values(first).
By default each search runs an O(n) sortedness check in front of an O(log n) search. That default is deliberate and should not be flipped: searching unsorted input does not fail, it returns a plausible index with no abort and no symptom at all — the worst failure mode this module has.
The cost is meant to be paid once, not per call:
call pf_is_sorted(v, ok) ! O(N), once
if (.not. ok) call pf_sort(v, v)
do k = 1, m
call pf_lower_bound(v, targets(k), pos, assume_sorted=.true.) ! O(log N) each
end do
Without assume_sorted=.true. in that loop, m searches cost O(m·N) rather than O(m·log N)
and the feature looks broken. Only pass it for an order you have actually established.
descending= and nulls_first= select the comparison, they do not reorder anything — they
must describe the order the array is genuinely in, or the check rejects it. A null is ordered after
every value by default, so a target larger than every value lands before the nulls rather than at
the end of the array.
A character target is compared at the array's element length: a shorter target is
blank-padded, exactly as a Fortran comparison would pad it. A target carrying non-blank characters
past that length has no exact answer and is refused rather than silently truncated. A
parquet_string_column stores bytes verbatim and has no declared width, so its target is used
verbatim too, trailing blanks included.
integer(int32) :: v(8) = [30, 10, 20, 10, 30, 30, 40, 20]
integer(int32), allocatable :: d(:)
integer, allocatable :: r(:)
integer :: c
call pf_unique_count(v, c) ! c = 4
call pf_unique(v, d) ! d = [10, 20, 30, 40]
call pf_rank(v, r) ! r = [5, 1, 3, 1, 5, 5, 8, 3]
Those ranks are the default "competition" method, which leaves a gap after each tie — the two
10s both rank 1 and the next value ranks 3. "dense" would give [3, 1, 2, 1, 3, 3, 4, 2]
instead; see Tie handling in pf_rank below.
Nulls are outside the population in all three. They are excluded from the count, absent from
the distinct values, and given rank 0 — which is why none of the three takes nulls_first:
there is no null tier to position. pf_unique_count and pf_unique report how many were dropped
through an optional n_null=.
pf_unique returns the distinct values sorted; descending= reverses that order.
pf_unique_count takes no descending at all, since a count does not depend on direction.
Distinctness is the sort comparator's own equality, which on floating point is exact:
real(real64) :: v(2) = [0.1_real64 + 0.2_real64, 0.3_real64]
call pf_unique_count(v, c) ! c = 2, not 1
That is correct and surprising, and it is the same equality every other operation here uses. In the
other direction, every NaN counts as one value collectively — NaNs compare equal to each other
under this comparator even though == reports every NaN pair as unequal.
pf_rankmethod= chooses how ties are ranked, matched case-insensitively:
method= |
ranks of 10, 20, 20, 30 |
|---|---|
"competition" (the default) |
1, 2, 2, 4 |
"dense" |
1, 2, 2, 3 |
"ordinal" |
1, 2, 3, 4 |
An unrecognized token aborts, naming the valid ones. "ordinal" ranks are exactly the inverse of
pf_argsort's permutation — r(perm(k)) == k — so reach for pf_argsort when you want the order
and pf_rank when you want a per-element answer that stays aligned with the input.
NaNs are ranked as ordinary values (all tying with each other), unlike nulls.
call pf_minmax(v, vmin, vmax) ! the values
call pf_argminmax(v, imin, imax) ! where they are
Two procedures rather than one call with optional index arguments: optional imin/imax varying
only by integer kind would make a positional call ambiguous, and each name says what it returns. A
caller who wants both pays one extra call rather than every caller paying for indices they did not
ask for.
Both skip nulls and NaNs — a NaN is an ordinary value everywhere else in this module, but it is
not the minimum or maximum of anything. Both report the first occurrence of a tied extreme, at
either end. Neither takes descending/nulls_first: a minimum and a maximum are absolute, and
reversing the order would only exchange the two answers.
Both abort when every value is null or NaN. There is nothing to return and no sentinel that
works across all nine types — the same decision pf_nth_quantile makes for the same unanswerable
question. Guard with count(is_valid) where that can happen.
pf_merge merges two already-sorted arrays in O(size(a) + size(b)), rather than the
O(n log n) of sorting their concatenation:
integer(int32) :: a(4) = [1, 4, 6, 9], b(5) = [2, 3, 6, 7, 10]
integer(int32), allocatable :: m(:)
call pf_merge(a, b, m) ! m = [1, 2, 3, 4, 6, 6, 7, 9, 10]
Ties take from a first, so the result matches pf_sort of the concatenation element for element.
descending=/nulls_first= select the comparison exactly as in the searches, and both inputs are
checked for sortedness unless assume_sorted=.true..
Supply is_valid_a/is_valid_b whenever either input has nulls. A sorted array containing
nulls is precisely what pf_sort(..., is_valid=) produces, and a merge that is not told which
elements are null compares them as ordinary values and interleaves them into the middle of the
result. The precondition cannot be checked either — a null's stored value is indistinguishable from
a real one without the mask.
call pf_merge(a, b, m, is_valid_a=ma, is_valid_b=mb, merged_valid=mv)
merged_valid follows the same rule as pf_sort's sorted_valid: always allocated when you
ask for it, all .true. when neither input mask was supplied.
Two character arrays of different declared lengths merge into the wider one, so merged is
declared character(len=:), allocatable rather than at a fixed width — the one output in this
module whose length comes from two inputs rather than one.
Sorting is parallel by default. pf_argsort, pf_sort, pf_unique_count, pf_unique and
pf_rank all use the machine automatically, as do the read-time
parquet_open_reader(..., sort_by=) and parquet_table%sort_by. There is nothing to switch on.
pf_partial_sort, pf_partial_argsort, pf_nth_element and pf_nth_quantile take threads=
too, but thread less of their work — see
What threads= reaches in a selection below.
Those nine are the whole list. pf_permute, pf_is_sorted, the three searches, pf_minmax,
pf_argminmax and pf_merge take no threads= at all — each is a single linear pass, so there is
nothing to hand a team — and passing one is a compile error rather than a silently ignored argument.
threads= is therefore how you turn parallelism down, not up:
call pf_argsort(v, perm) ! auto -- all available cores
call pf_argsort(v, perm, threads=4) ! at most four
call pf_argsort(v, perm, threads=1) ! serial
The answer never changes. A parallel sort returns a permutation bit-identical to the serial
one, on every input, at every thread count. That is structural rather than lucky: the comparator
ends with a tiebreaker on the row index, making it a total order under which no two rows compare
equal, so every correct sorting algorithm — serial, threaded, stable or not — must produce the same
answer. threads= is a performance control and nothing else.
| situation | threads used |
|---|---|
| ordinary serial code | omp_get_max_threads() |
inside any !$omp parallel region |
1 — serial |
explicit threads=n, inside a region running on 2+ threads |
n — honoured |
explicit threads=n, inside a region running on 1 thread |
1 — see below |
explicit threads=n, ordinary serial code |
n |
| array below the minimum-work threshold | 1 |
| a narrow-range integer key, on a small team | 1 — the counting path (see below) |
The second row is the one worth knowing. Inside a parallel region, auto stays serial because
nested regions are the caller's business — eight OpenMP threads each asking for eight more would
be sixty-four threads, slower than not threading at all. If you genuinely want a threaded sort from
inside your own parallel region, say so with an explicit threads=.
The one place an explicit threads= is not honoured is a region that exists but is running on a
single thread — !$omp parallel if(cond) with cond false, or any region at all under
OMP_NUM_THREADS=1. A sort there runs serially however many threads you asked for. This is not a
policy choice: opening a thread team one level down from such a region deadlocks GNU's OpenMP
runtime, intermittently and with no diagnostic, and refusing is the only reliable way to avoid it.
A region running on two or more threads is unaffected and honours threads= exactly as it does in
ordinary serial code.
pf_sort_threads() reports what auto would do right now, if you want to log it or size something
against it.
A small array. Spawning threads to sort a few thousand elements costs more than the sort, so
there is a minimum below which threads= is ignored.
The integer fast path. A single integer key spanning a narrow enough range is counting-sorted,
which is already O(n) and already produces this exact permutation, so it is taken in preference to
threading. threads= is a hint, not a command, and a sort that reports one thread on such a key
is behaving correctly.
Two things decide whether that path is taken, and neither is how many distinct values there are
— it keys on the value range. The range has to be narrow relative to the row count, so the
same range qualifies on a large array and not on a small one; and the team has to be small, because
a counting sort is serial and past a couple of threads the parallel radix sort simply wins. Nulls
do not disqualify it — a null row's slot is skipped when the range is measured, so a null-bearing
key reaches the same path. parquet_set_sort_counting_bucket_limit caps the range in absolute
terms as well; see Tuning the sort for that knob and
for turning the path off entirely.
pf_argsort over scattered real(real64), on a small machine, best of several rounds — the shape
matters here rather than the absolute times, which are whatever your own hardware gives you.
bench/benchmark_sort_engine.sh measures it there (see CONTRIBUTING.md):
| rows | 8 threads against serial |
|---|---|
| 2 000 | threading declines — see below |
| 32 000 | threading declines — see below |
| 1 000 000 | a few times faster |
| 20 000 000 | a few times faster, and gaining slowly with size |
The two smallest rows do not show threading losing; they show it declining. Below the
minimum-work threshold threads= is ignored, so both arms run the same serial sort and differ only
by measurement noise. That is the threshold behaving correctly rather than a cost being paid —
opening a thread team costs more than sorting a few tens of thousands of elements does in the first
place, which is the comparison the threshold makes.
The speedup falls short of the thread count, and that is expected. A radix sort makes one pass over the data per byte of key, so its cost is dominated by memory traffic rather than by comparisons; adding threads adds bandwidth demand rather than relieving it. The gap widens with size for the same reason — at twenty million rows the working set no longer fits in cache, and the sort becomes bound by memory bandwidth rather than by how many threads are available.
Threading is not the only thing that decides how long a sort takes, and often not the largest. A single integer key with a narrow enough value range takes the counting path described above, which is O(n) and serial — so a key that qualifies beats every row of this table without opening a team at all. Whether it qualifies depends on the requested team as well as on the data, since past a couple of threads the parallel radix sort is the faster of the two.
Peak memory does not depend on threads=. A threaded sort needs a scratch buffer the size of
the permutation, but so does a serial one — the threaded arm allocates it up front and the serial
arm on first need, and they are the same size. Measured peak resident set for a 50-million-row
real(real64) argsort was 2816 MB at threads=1 and 2816 MB at threads=8. So passing
threads=1 to save memory gives up the speed and saves nothing.
What the scratch pays for is the radix path, not the threading, and that has its own knob: see
Tuning the sort, which sets out what the radix path
costs per row and the trade against turning it off. On the same 50-million-row sort,
parquet_set_sort_radix_path(.false.) took the peak from 2816 MB to 1216 MB.
threads= reaches in a selectionAll four selection operations accept threads= — pf_partial_sort, pf_partial_argsort,
pf_nth_element and pf_nth_quantile — with the same meaning and the same defaults as everywhere
else, and with the same guarantee that the answer never changes.
They thread less of the work than pf_argsort does, and it is worth knowing which part. A
selection has three stages: reading the values into the engine's key form, choosing the elements
that belong at the requested positions, and handing the result back. Only the first is threaded.
It walks every element, so it is usually most of the cost; the choosing in the middle is what makes
a selection cheaper than a full sort and has no threaded form.
So threads= helps here in proportion to the array, not in proportion to how much you asked for:
call pf_partial_argsort(flux, brightest, n=10, threads=8) ! the read of flux is threaded
call pf_partial_sort(flux, top, n=10, threads=8) ! likewise
call pf_nth_element(flux, 1000, median, threads=8)
call pf_nth_quantile(flux, 0.5_real64, median, threads=8)
call pf_partial_argsort(flux, brightest, n=10, threads=1) ! wholly serial
Exactly what each one leaves serial, since the answer differs a little:
| call | threaded | serial |
|---|---|---|
pf_partial_argsort |
the read, and the int32 narrowing | the selection |
pf_partial_sort |
the read | the selection, and the n-element gather that builds the result |
pf_nth_element |
the read | the selection |
pf_nth_quantile |
the read | the selection, and the pass that counts the non-null population |
On a large array and a small n the read is most of the cost, which is exactly the case this
argument exists for. On a plain integer array it is cheap already and threads= will buy little;
on a string or a parquet_column key it is the dominant term.
One specific takes the argument and can do nothing with it: pf_partial_argsort(keys, perm, n) on a
pf_sort_keys producing an int64 permutation. Its keys were already built by %add, so there
is nothing to read, and an int64 permutation needs no narrowing. It is accepted so that the generic
behaves uniformly rather than rejecting an argument its int32 sibling takes.
pf_partial_sort and pf_partial_argsort are not defined for parquet_string_column or
parquet_column, for the same reason pf_sort is not. pf_nth_element and pf_nth_quantile are
not defined for parquet_column: its element type is a runtime discriminator, so there is no
compile-time type for the value they return.