From fe5290c7a009115bca5b044f1f13d4cb9a1fb352 Mon Sep 17 00:00:00 2001 From: Chris Rackauckas-Claude Date: Fri, 22 May 2026 10:34:49 -0400 Subject: [PATCH 01/14] v3: enum-tagged dispatch for singleton search strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the v2 `Base.searchsortedlast(::SearchStrategy, ...)` multimethod dispatch on strategy struct singletons with a single FFF-owned dispatcher tagged by a `StrategyKind` enum. The runtime `if/elseif` over the enum value is well-predicted in hot loops, the kernel bodies inline, and the return path stays concrete (`Int`) regardless of which kind is picked at runtime — none of the `Union`-return pathology that v2's design suffered from when the chosen strategy depended on runtime data (e.g. `Auto`'s decision tree returning `Union{BracketGallop, LinearScan, BinaryBracket}`). The `bench/enum_vs_typeparam_dispatch.jl` sweep confirms ~0 ns overhead vs. the v2 path across 20 representative cells (worst case +2.2%, several cells show enum dispatch beating legacy multimethod by 3-6%). Stateful strategies (`Auto`, `GuesserHint`) stay on the multimethod path because they carry per-instance data that doesn't fit into a singleton tag. Layout: - src/kinds.jl: `@enum StrategyKind` + `search_last` / `search_first` enum dispatchers. - src/kernels.jl: per-strategy kernel functions (`_kernel_last_bracket_gallop`, etc.), lifted out of the v2 method bodies. No semantic changes. - src/legacy_dispatch.jl: `Base.searchsortedlast(::S, ...)` shims for each singleton strategy struct, forwarding to `search_last(KIND_X, ...)`. Scheduled for removal in v4. - src/strategies.jl: `Auto` now holds a `StrategyKind` field. `Auto()` defaults to `KIND_BINARY_BRACKET`; `Auto(v)` resolves the kind from `length(v)` + `SearchProperties(v)`. - src/auto.jl: `Auto`'s per-query `search_last` / `search_first` is a one-line forward to the stored kind. The batched dispatcher re-resolves the kind from `(v, queries)` (gap heuristic). - src/guesser.jl: `GuesserHint` dispatches via `search_last(::GuesserHint, ...)` method (no kind tag; stateful). - src/findequal.jl: `findequal(::StrategyKind, v, x[, hint])` direct. Back-compat: every v2 call site (e.g. `searchsortedlast(BracketGallop(), v, x, hint)`) still works via the shim — confirmed by the existing 100,472-test suite (now 104,796 tests with the new v3 API safetestset). DataInterpolations.jl: needs zero updates to work with v3. Optional optimisation: replace `A.strategy = FindFirstFunctions.BracketGallop()` with `A.strategy = FindFirstFunctions.KIND_BRACKET_GALLOP` and `searchsortedlast(strat, ...)` with `FindFirstFunctions.search_last(strat, ...)` to skip the shim layer. Co-Authored-By: Chris Rackauckas --- .gitignore | 3 +- NEWS.md | 118 ++++ Project.toml | 2 +- bench/enum_vs_typeparam_dispatch.jl | 102 ++++ docs/Project.toml | 2 +- docs/src/strategies.md | 241 +++++---- src/FindFirstFunctions.jl | 42 +- src/auto.jl | 183 +++---- src/batched.jl | 308 +++++++---- src/dispatch.jl | 809 ---------------------------- src/findequal.jl | 72 ++- src/guesser.jl | 67 +-- src/kernels.jl | 568 +++++++++++++++++++ src/kinds.jl | 235 ++++++++ src/legacy_dispatch.jl | 74 +++ src/strategies.jl | 278 +++++----- test/qa/Project.toml | 2 +- test/qa/qa_tests.jl | 109 ++-- test/runtests.jl | 182 +++++++ 19 files changed, 1962 insertions(+), 1435 deletions(-) create mode 100644 bench/enum_vs_typeparam_dispatch.jl delete mode 100644 src/dispatch.jl create mode 100644 src/kernels.jl create mode 100644 src/kinds.jl create mode 100644 src/legacy_dispatch.jl diff --git a/.gitignore b/.gitignore index 821cab1..8b7db77 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ bench/Manifest.toml docs/build docs/Manifest.toml docs/src/assets/Manifest.toml -docs/src/assets/Project.toml \ No newline at end of file +docs/src/assets/Project.toml +test/qa/Manifest.toml \ No newline at end of file diff --git a/NEWS.md b/NEWS.md index 53cef6a..33d629a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,123 @@ # FindFirstFunctions.jl NEWS +## 3.0.0 + +The 2.x sorted-search API was a single generic +`Base.searchsortedlast(::SearchStrategy, v, x[, hint])` / +`Base.searchsortedfirst(::SearchStrategy, ...)` extended once per +concrete strategy struct. That design made dispatch type-stable on the +*chosen* strategy but produced `Union` returns whenever the strategy +itself depended on runtime data — in particular `Auto`'s decision tree, +where the strategy struct returned by `_auto_pick(v, hint)` had a +runtime-dependent type, broke `@inferred`, and gave `Vector{Auto}`-style +containers a `Union` element type. + +The 3.0 redesign replaces the multimethod dispatch on `SearchStrategy` +singletons with a single FFF-owned dispatcher tagged by an enum: + +```julia +search_last(KIND_BRACKET_GALLOP, v, x, hint) +search_first(KIND_INTERPOLATION_SEARCH, v, x) +``` + +The runtime `if/elseif` over `StrategyKind` values is well-predicted in +hot loops, the kernel bodies inline, and the return path stays concrete +(`Int`) regardless of which kind is picked at runtime. The +`bench/enum_vs_typeparam_dispatch.jl` sweep confirms ~0 ns of overhead +vs. the v2 multimethod path across 20 representative cells. + +### What changed at the API level + +| v2 | v3 (preferred) | v3 back-compat (shim, removed in v4) | +|---|---|---| +| `searchsortedlast(BracketGallop(), v, x, hint)` | `search_last(KIND_BRACKET_GALLOP, v, x, hint)` | `searchsortedlast(BracketGallop(), v, x, hint)` still works | +| `searchsortedfirst(InterpolationSearch(), v, x)` | `search_first(KIND_INTERPOLATION_SEARCH, v, x)` | `searchsortedfirst(InterpolationSearch(), v, x)` still works | +| `searchsortedlast(LinearScan(), v, x, hint)` | `search_last(KIND_LINEAR_SCAN, v, x, hint)` | `searchsortedlast(LinearScan(), v, x, hint)` still works | +| `searchsortedlast(BinaryBracket(), v, x)` | `search_last(KIND_BINARY_BRACKET, v, x)` | `searchsortedlast(BinaryBracket(), v, x)` still works | +| `searchsortedlast(UniformStep(), r, x)` | `search_last(KIND_UNIFORM_STEP, r, x)` | `searchsortedlast(UniformStep(), r, x)` still works | +| `searchsortedlast(SIMDLinearScan(), v, x, hint)` | `search_last(KIND_SIMD_LINEAR_SCAN, v, x, hint)` | `searchsortedlast(SIMDLinearScan(), v, x, hint)` still works | +| `searchsortedlast(ExpFromLeft(), v, x, hint)` | `search_last(KIND_EXP_FROM_LEFT, v, x, hint)` | `searchsortedlast(ExpFromLeft(), v, x, hint)` still works | +| `searchsortedlast(BitInterpolationSearch(), v, x)` | `search_last(KIND_BIT_INTERPOLATION_SEARCH, v, x)` | `searchsortedlast(BitInterpolationSearch(), v, x)` still works | +| `findequal(BisectThenSIMD(), v, x)` | `findequal(KIND_BISECT_THEN_SIMD, v, x)` | `findequal(BisectThenSIMD(), v, x)` still works | + +Stateful strategies (`Auto`, `GuesserHint`) stay on the multimethod path +because they carry per-instance data: + +```julia +search_last(Auto(v), v, x, hint) # v3 preferred +searchsortedlast(Auto(v), v, x, hint) # v3 back-compat (shim) +search_first(GuesserHint(g), v, x) # v3 preferred +searchsortedfirst(GuesserHint(g), v, x) # v3 back-compat (shim) +``` + +### Breaking changes — `Auto` resolves at construction + +In v2, `Auto()`'s per-query `Base.searchsortedlast(::Auto, v, x, hint)` +ran the picking logic on every call (consulting `length(v)`, hint +validity, and `props.is_uniform`). In v3, `Auto` carries a resolved +`StrategyKind` and per-query dispatch is a one-line forward to +`search_last(s.kind, v, x, hint)`: + + - `Auto()` defaults to `KIND_BINARY_BRACKET` (safe; matches + `Base.searchsortedlast` exactly). + - `Auto(v)` resolves the kind from `length(v)` + `SearchProperties(v)`. + Pre-pay the probe cost once, get the v2 fast-path on every per-query + call afterwards. + - `Auto(v, props)` is the same with a pre-computed `props` cache. + +The **batched** Auto dispatch (`searchsortedlast!(out, v, queries; +strategy = Auto())`) still re-resolves the kind from `(v, queries)` +because the gap heuristic needs the queries; that decision tree is +type-stable in v3 (returns a `StrategyKind`, dispatched via the enum +switch into a kind-parameterized loop). + +The v2 behaviour of `Auto()` re-picking on every per-query call is +preserved for batched calls and for callers that explicitly construct +`Auto(v)` per query. For callers that previously relied on `Auto()` (no +`v`) picking `LinearScan` on short vectors or `BracketGallop` on long +vectors per-query, update to `Auto(v)` where `v` is known. + +### New: `strategy_kind` + +`strategy_kind(s::SearchStrategy)` maps a singleton strategy struct to +its `StrategyKind` tag, and returns the stored kind for `Auto`. +`GuesserHint` (genuinely stateful, no singleton tag) throws +`ArgumentError`. + +### `findequal` now accepts a `StrategyKind` directly + +```julia +findequal(KIND_BRACKET_GALLOP, v, x) +findequal(KIND_BRACKET_GALLOP, v, x, hint) +``` + +In addition to the v2 `findequal(strategy_struct, v, x[, hint])` form +(which still works via the shim). + +### Deprecation timeline + +The v2 `Base.searchsortedlast(::S, ...)` / +`Base.searchsortedfirst(::S, ...)` methods for singleton strategy +structs are scheduled for removal in v4. They emit no depwarn in v3 +because the noise would be unmanageable across the ecosystem; the +removal will be announced at least one minor cycle in advance. + +### Internals — `dispatch.jl` split into `kinds.jl` + `kernels.jl` + `legacy_dispatch.jl` + +The v2 `src/dispatch.jl` file (Base.searchsortedlast extensions per +strategy) is gone. In its place: + + - `src/kinds.jl` — `StrategyKind` enum and `search_last` / + `search_first` enum dispatchers. + - `src/kernels.jl` — per-strategy kernel functions + (`_kernel_last_bracket_gallop`, etc.), lifted out of the v2 method + bodies. + - `src/legacy_dispatch.jl` — `Base.searchsortedlast(::S, ...)` shims + that forward to `search_last(KIND_X, ...)` (one shim per singleton + strategy struct). + +The `strategy_kind(s)` mapping function is defined alongside the shims. + ## 2.0.0 This is a major rewrite of the sorted-search API. The 1.x surface — a diff --git a/Project.toml b/Project.toml index 513c109..aadff7d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "FindFirstFunctions" uuid = "64ca27bc-2ba2-4a57-88aa-44e436879224" -version = "2.1.0" +version = "3.0.0" authors = ["Chris Elrod and contributors"] [deps] diff --git a/bench/enum_vs_typeparam_dispatch.jl b/bench/enum_vs_typeparam_dispatch.jl new file mode 100644 index 0000000..c07b910 --- /dev/null +++ b/bench/enum_vs_typeparam_dispatch.jl @@ -0,0 +1,102 @@ +# Bench: enum-tagged dispatch (`search_last(KIND_X, ...)`) vs the legacy +# multimethod-on-struct dispatch (`Base.searchsortedlast(::S(), ...)`). +# +# Confirms the v3 claim: the runtime `if/elseif` over a `StrategyKind` +# value adds ~0 ns of overhead because it is well-predicted in hot loops, +# the kernel bodies inline, and the return type stays type-stable. +# +# Usage: `julia +1.11 --project=bench bench/enum_vs_typeparam_dispatch.jl` +# (or run from the repo root via `Pkg.activate("bench")`). + +using BenchmarkTools, FindFirstFunctions, Printf, StableRNGs + +const RNG_SEED = 9023 + +# Representative grid sizes. Small (cache-resident), medium, large. +const GRID_SIZES = (16, 256, 4_096, 65_536) + +# Strategies under test — only the ones where the enum dispatch matters. +# `BinaryBracket` and `InterpolationSearch` ignore the hint, so the per-call +# overhead surface is smaller; still useful to confirm parity. +const STRATEGIES = ( + (BracketGallop(), KIND_BRACKET_GALLOP, "BracketGallop"), + (LinearScan(), KIND_LINEAR_SCAN, "LinearScan"), + (ExpFromLeft(), KIND_EXP_FROM_LEFT, "ExpFromLeft"), + (InterpolationSearch(), KIND_INTERPOLATION_SEARCH, "InterpolationSearch"), + (BinaryBracket(), KIND_BINARY_BRACKET, "BinaryBracket"), +) + +# Helper: median time in ns for one call configuration. +function bench_ns(f, args...; samples = 500) + b = @benchmark $f($(args)...) samples = samples evals = 50 seconds = 2 + return BenchmarkTools.minimum(b).time +end + +# Hot-loop variant: total elapsed time across `m` queries, normalized to ns/q. +# This is the realistic per-call cost — the per-iteration `if/elseif` over +# the enum value is the workload we're measuring. +function hot_loop_legacy(strategy, v, queries, hints) + s = 0 + @inbounds for i in eachindex(queries) + s += searchsortedlast(strategy, v, queries[i], hints[i]) + end + return s +end + +function hot_loop_kind(kind, v, queries, hints) + s = 0 + @inbounds for i in eachindex(queries) + s += search_last(kind, v, queries[i], hints[i]) + end + return s +end + +function build_workload(n, rng) + v = sort!(rand(rng, n)) + # Query positions: a mix of in-vector and out-of-range. Each query + # comes with a hint that's ±3 of the true answer (the "good hint" regime + # where hint-using strategies shine). + m = max(64, n ÷ 16) + queries = sort!(rand(rng, m)) + truths = [searchsortedlast(v, q) for q in queries] + hints = [clamp(t + rand(rng, -3:3), 1, n) for t in truths] + return (v, queries, hints) +end + +function main() + rng = StableRNG(RNG_SEED) + rows = Any[] + for n in GRID_SIZES + v, queries, hints = build_workload(n, rng) + for (s, kind, name) in STRATEGIES + t_legacy = bench_ns(hot_loop_legacy, s, v, queries, hints) + t_kind = bench_ns(hot_loop_kind, kind, v, queries, hints) + # Per-query overhead numbers. + m = length(queries) + ns_legacy = t_legacy / m + ns_kind = t_kind / m + delta = ns_kind - ns_legacy + pct = delta / ns_legacy * 100 + push!( + rows, + ( + n = n, strategy = name, + legacy_ns_q = round(ns_legacy; digits = 1), + enum_ns_q = round(ns_kind; digits = 1), + delta_ns_q = round(delta; digits = 2), + delta_pct = round(pct; digits = 1), + ), + ) + end + end + @printf "%-8s %-22s %-12s %-12s %-12s %s\n" "n" "strategy" "legacy ns/q" "enum ns/q" "Δ ns/q" "Δ %" + println("-"^80) + for r in rows + @printf "%-8d %-22s %-12.2f %-12.2f %-12.2f %.1f\n" r.n r.strategy r.legacy_ns_q r.enum_ns_q r.delta_ns_q r.delta_pct + end + return rows +end + +if abspath(PROGRAM_FILE) == @__FILE__ + main() +end diff --git a/docs/Project.toml b/docs/Project.toml index 9d4f534..dc19075 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -10,7 +10,7 @@ StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" [compat] DataInterpolations = "4, 5, 6, 7, 8" Documenter = "1" -FindFirstFunctions = "1.4.2, 2.0" +FindFirstFunctions = "1.4.2, 2.0, 3.0" Optim = "1, 2.0" Plots = "1" RegularizationTools = "0.6" diff --git a/docs/src/strategies.md b/docs/src/strategies.md index 56e7b27..3374c6b 100644 --- a/docs/src/strategies.md +++ b/docs/src/strategies.md @@ -1,14 +1,63 @@ # Search strategies -The strategies form the parameter space of the sorted-search API. Each one -subtypes [`SearchStrategy`](@ref FindFirstFunctions.SearchStrategy) and is -selected as the first positional argument of `searchsortedfirst` / -`searchsortedlast`. +The strategies form the parameter space of the sorted-search API. Two +ways to select a strategy: + + - **v3 preferred:** pass a [`StrategyKind`](@ref FindFirstFunctions.StrategyKind) + enum value (e.g. `KIND_BRACKET_GALLOP`) as the first argument to + [`search_last`](@ref FindFirstFunctions.search_last) / + [`search_first`](@ref FindFirstFunctions.search_first). One enum + value per singleton strategy; runtime `if/elseif` dispatch into the + matching kernel; ~0 ns of overhead in hot loops; the inferred return + type is concrete regardless of which kind is picked at runtime. + - **v2 back-compat:** pass a singleton strategy struct (e.g. + `BracketGallop()`) to `Base.searchsortedlast` / `Base.searchsortedfirst`. + Each method is a one-line shim that forwards to `search_last(KIND_X, ...)`. + Scheduled for removal in v4 — migrate to `search_last` / + `search_first` for new code. + +The stateful strategies — [`Auto`](@ref FindFirstFunctions.Auto) and +[`GuesserHint`](@ref FindFirstFunctions.GuesserHint) — carry per-instance +data and so cannot be expressed as singleton enum tags. They dispatch +via their own multimethods (and via the back-compat `Base.searchsortedlast(::S, ...)` +shim). ```@docs FindFirstFunctions.SearchStrategy +FindFirstFunctions.StrategyKind +FindFirstFunctions.search_last +FindFirstFunctions.search_first +FindFirstFunctions.strategy_kind ``` +## Kind ↔ strategy mapping + +| Enum tag | Strategy struct | Kernel function | +|---|---|---| +| `KIND_BINARY_BRACKET` | `BinaryBracket` | `_kernel_last_binary_bracket` / `_kernel_first_binary_bracket` | +| `KIND_LINEAR_SCAN` | `LinearScan` | `_kernel_last_linear_scan` / `_kernel_first_linear_scan` | +| `KIND_SIMD_LINEAR_SCAN` | `SIMDLinearScan` | `_kernel_last_simd_linear_scan` / `_kernel_first_simd_linear_scan` | +| `KIND_BRACKET_GALLOP` | `BracketGallop` | `_kernel_last_bracket_gallop` / `_kernel_first_bracket_gallop` | +| `KIND_EXP_FROM_LEFT` | `ExpFromLeft` | `_kernel_last_exp_from_left` / `_kernel_first_exp_from_left` | +| `KIND_INTERPOLATION_SEARCH` | `InterpolationSearch` | `_kernel_last_interpolation_search` / `_kernel_first_interpolation_search` | +| `KIND_BIT_INTERPOLATION_SEARCH` | `BitInterpolationSearch` | `_kernel_last_bit_interpolation_search` / `_kernel_first_bit_interpolation_search` | +| `KIND_UNIFORM_STEP` | `UniformStep` | `_kernel_last_uniform_step` / `_kernel_first_uniform_step` | +| `KIND_BISECT_THEN_SIMD` | `BisectThenSIMD` | (positional dispatch falls back to BinaryBracket; equality dispatch goes through `findfirstsortedequal`) | + +Stateful strategies that do **not** have an enum tag and stay on the +multimethod path: + + - [`Auto`](@ref FindFirstFunctions.Auto): carries a `StrategyKind` field + plus a [`SearchProperties`](@ref FindFirstFunctions.SearchProperties) + cache. `Auto`'s `search_last` is a one-line forward to the stored + kind; the batched dispatch re-resolves the kind from + `(v, queries)` because the gap heuristic needs the queries. + - [`GuesserHint`](@ref FindFirstFunctions.GuesserHint): carries a + [`Guesser`](@ref FindFirstFunctions.Guesser) (with its `idx_prev::Ref{Int}` + and `linear_lookup::Bool`). Dispatches via its own + `search_last(::GuesserHint, ...)` / `search_first(::GuesserHint, ...)` + methods. + ## When to pick which For most callers the answer is: pass [`Auto`](@ref FindFirstFunctions.Auto) @@ -32,6 +81,42 @@ All hint-consuming strategies fall back to `BinaryBracket` when no hint is supplied or when the hint is out of range. `InterpolationSearch` additionally falls back to `BinaryBracket` for non-numeric element types. +## Migrating from v2 (`Base.searchsortedlast(::S, ...)`) to v3 (`search_last(KIND_X, ...)`) + +The v2 API is preserved as a back-compat shim. To migrate to the v3 +preferred form, change each call site as follows: + +```julia +# v2 (still works, shim forwards to v3) +searchsortedlast(BracketGallop(), v, x, hint) +searchsortedfirst(InterpolationSearch(), v, x) + +# v3 (preferred) +search_last(KIND_BRACKET_GALLOP, v, x, hint) +search_first(KIND_INTERPOLATION_SEARCH, v, x) +``` + +Stateful strategies (`Auto`, `GuesserHint`) have both forms: + +```julia +# v2 form (still works) +searchsortedlast(Auto(v), v, x, hint) +searchsortedfirst(GuesserHint(g), v, x) + +# v3 form (preferred) +search_last(Auto(v), v, x, hint) +search_first(GuesserHint(g), v, x) +``` + +The migration is mechanical: rename `searchsortedlast` → `search_last`, +`searchsortedfirst` → `search_first`, and wrap singleton struct +strategies with their `KIND_X` tag (no rename needed for stateful +strategies). + +The v2 shims will be removed in **v4** (no scheduled date — long enough +for downstream packages like DataInterpolations.jl, ModelingToolkit, and +NonlinearSolve to migrate). + ## Reference ```@docs @@ -63,10 +148,11 @@ The sentinel for "not found" is `firstindex(v) - 1` (`= 0` for 1-based vectors). Type-stable `Int` return, no `Union` with `Nothing`. Callers can test for absence with `i < firstindex(v)`. -`findequal` routes most strategies through `searchsortedfirst + post-check` +`findequal` routes most strategies through `search_first + post-check` generically, so `findequal(BracketGallop(), v, x, hint)`, `findequal(SIMDLinearScan(), v, x, hint)`, -`findequal(GuesserHint(g), v, x)`, etc. all just work. +`findequal(GuesserHint(g), v, x)`, `findequal(KIND_BRACKET_GALLOP, v, x, hint)`, +etc. all just work. The [`BisectThenSIMD`](@ref FindFirstFunctions.BisectThenSIMD) strategy short-circuits the post-check path on `DenseVector{Int64}` by dispatching @@ -86,8 +172,8 @@ caller has strong evidence that the hint is close. For `length(v) ≤ 16`, `LinearScan` is faster than `BracketGallop` even from a bad hint because the bracket bookkeeping costs more than a worst-case walk -across a vector that short. `Auto`'s per-query path picks `LinearScan` below -that threshold. +across a vector that short. `Auto`'s resolution rule picks `KIND_LINEAR_SCAN` +below that threshold. ### SIMDLinearScan @@ -97,90 +183,32 @@ the hint is past the answer) uses the scalar `LinearScan` path — the SIMD primitive is only defined in the forward direction. Specialized for `DenseVector{Int64}` and `DenseVector{Float64}`. Any other -element type falls back to the scalar `LinearScan` walk (this includes -`Int32`, `UInt64`, `Float32`, `Date`, `String`, and user-defined numeric -types). The dispatch is *static* — there's no runtime type test on a hot -path — so the fallback costs nothing per-call when picked at compile time. +element type falls back to the scalar `LinearScan` walk. The dispatch is +*static* — there's no runtime type test on a hot path. Caveats: - - **Element types**: `Int64` and `Float64` only. Anything else uses - scalar `LinearScan`. This is a hard restriction of the LLVM IR: the - vector load uses `<8 x i64>` / `<8 x double>` with 8-byte stride, and - the broadcast and compare are typed accordingly. - - **NaN**: a `NaN` element in a `Float64` vector compares as `false` - under both `fcmp ogt` and `fcmp oge`, so a NaN in `v` is silently - skipped by the SIMD scan. Sorted `Float64` vectors containing `NaN` - aren't well-defined under any total order anyway — same caveat - applies to plain `Base.searchsortedlast` on such vectors. - - **Forward order only**: non-`Forward` orderings fall back to scalar - `LinearScan`. The IR is hard-coded to the `Forward` comparison - polarity. + - **Element types**: `Int64` and `Float64` only. + - **NaN**: a `NaN` element in a `Float64` vector compares as `false` — + the SIMD scan silently skips it. Sorted `Float64` vectors containing + `NaN` aren't well-defined under any total order. + - **Order**: `Forward` and `Reverse` only. - **No hint**: falls back to [`BinaryBracket`](@ref FindFirstFunctions.BinaryBracket). - Without a hint there's no direction information for the forward scan. - - **Auto does not pick this strategy.** `SIMDLinearScan` is opt-in. It - isn't part of the `Auto` decision tree because the regime where it - strictly beats `LinearScan` (long forward walks on `Int64`/`Float64`) - overlaps with the regime where `Auto` already prefers - [`BracketGallop`](@ref FindFirstFunctions.BracketGallop) or - [`ExpFromLeft`](@ref FindFirstFunctions.ExpFromLeft). Pin it - explicitly when you have a workload that wants a long linear forward - scan and you know the element type. + - **Auto does not pick this strategy** by default in the per-query path. + The batched dispatch picks it inside a gap window where the SIMD chunk + pays for itself. ### BitInterpolationSearch `InterpolationSearch` with the extrapolation guess computed on the IEEE -bit pattern of `v` rather than the float values themselves. For positive -Float64 values, the IEEE bit pattern is monotonically increasing with the -float value and is approximately *linear* in array index for log-spaced -(geometric) data. That makes the bit-domain linear extrapolation a far -better guess than the float-domain linear extrapolation on geometric data -— sometimes O(1) versus O(log n) refinement cost. - -**Opt-in only.** `Auto` does not pick `BitInterpolationSearch`. The bench -sweep at `bench/bitinterp_sweep.jl` covers 1404 cells (9 v patterns × 4 q -patterns × 6 n sizes up to 2²⁰ × 7 m sizes, exercising pure-geometric, -log-spaced over 18 decades, power-of-2 spacing, two-decade clumps, and -jittered-log alongside uniform/sqrt as negative controls). BitInterp -wins outright in 59 cells (4.2%) and sits within 10% of the per-cell -best in 75 cells (5.3%). The wins concentrate in: - - - `logspaced` / `logspaced_wide` / `geometric_dense` / `geometric_sparse` - / `jittered_log` — i.e. genuinely geometric data. - - Small `m` (= 4, occasionally 16): the per-query bit-domain guess cost - amortizes poorly across larger batches. - - Large `n` (≥ 2¹⁴, peaking at 2²⁰): the saved bracket refinement scales - with `log₂ n`, while the per-query setup cost is constant. - -Sample wins (BitInterp vs second-best, ns/q): - -| Cell | BitInterp | Runner-up | Margin | -|---|---|---|---| -| `logspaced_wide log_grid n=2²⁰ m=4` | 52.5 | InterpolationSearch 75.0 | 1.43× | -| `logspaced_wide log_grid n=2¹² m=4` | 35.0 | ExpFromLeft 47.5 | 1.36× | -| `logspaced_wide log_grid n=2¹⁸ m=4` | 47.5 | ExpFromLeft 62.5 | 1.32× | -| `logspaced_wide dense_grid n=2²⁰ m=4` | 50.0 | ExpFromLeft 65.0 | 1.30× | - -`Auto` doesn't pick it because: - - The wins are narrow (4% of cells in a bench specifically designed to - probe BitInterp's regime). - - Adding the eligibility check to `Auto`'s hot path (Float64 + positive - + log-linear sampled probe) would burn a few ns on every call, paying - back only in cells with `m ≤ 16` where Auto's overhead already - dominates the per-query cost. - - Users with a known log-spaced workload can pin - `searchsortedlast!(out, v, queries; strategy = BitInterpolationSearch())` - once and get the win without any heuristic cost. - -The strategy is retained as an opt-in for callers whose workload sits -outside what `Auto` discovers cheaply: domain-specific tables (radiation -transport, log-frequency, gravitational potentials) or hardware where -Float64 division is unusually cheap. - -Falls back to plain `InterpolationSearch` on non-Float64 dense eltypes -(where the bit pattern equals the value, making the strategies -equivalent), and to `BinaryBracket` for non-positive or non-finite Float64 -data. +bit pattern of `v` rather than the float values themselves. Wins on +log-spaced (geometric) data — sometimes O(1) versus O(log n) refinement +cost. + +**Opt-in only.** `Auto` does not pick `BitInterpolationSearch`. + +Falls back to plain `InterpolationSearch` on non-Float64 dense eltypes, +and to `BinaryBracket` for non-positive or non-finite Float64 data. ### BracketGallop @@ -201,46 +229,55 @@ v[lo+16], …` exponentially, then binary-searches inside the final bracket. Used by `Auto`'s batched dispatch when the queries are sorted: each call passes `hint = previous_result`, which by sortedness satisfies the "answer ≥ -hint" precondition. When the precondition is violated (the caller passes a -hint past the answer), `ExpFromLeft` falls back to a full -`searchsortedfirst` / `searchsortedlast` — slow but correct. +hint" precondition. ### InterpolationSearch Computes a guess via linear extrapolation between `v[lo]` and `v[hi]`, then refines with a bounded binary search around that guess. On uniformly-spaced numeric data the first guess is the right answer — O(1) per query -independent of `n`. On irregular data the guess is bad and the binary search -inside the (full) bracket falls back to O(log n). +independent of `n`. Two restrictions: - - **Numeric eltype**: requires `x - v[i]` to be well-defined and produce a - number whose ratio with `v[hi] - v[lo]` makes sense. Non-numeric eltypes - fall back to `BinaryBracket`. - - **Forward ordering only**: the linear-extrapolation formula assumes - `v[lo] ≤ v[hi]`. Non-`Forward` orderings fall back to `BinaryBracket`. + - **Numeric eltype**: non-numeric eltypes fall back to `BinaryBracket`. + - **Forward ordering only**: non-`Forward` orderings fall back to + `BinaryBracket`. The hint is ignored — the guess is computed fresh from the endpoints. ### BinaryBracket Plain `Base.searchsortedlast` / `Base.searchsortedfirst`. Provided as a -strategy so that callers can opt out of hint-based behaviour explicitly, and -so that other strategies have a well-defined name to fall back to. Ignores -any hint that is supplied. +strategy so callers can opt out of hint-based behaviour explicitly, and so +other strategies have a well-defined name to fall back to. Ignores any +hint. ### Auto See [Auto: heuristics and benchmarks](@ref) for the full decision tree and the benchmark sweep that produced its crossover constants. -## Equality routines +In v3, `Auto` carries a stored `StrategyKind` plus a `SearchProperties` +cache: + + - `Auto()` defaults to `KIND_BINARY_BRACKET`. Safe but no faster than + plain `Base.searchsortedlast`. + - `Auto(v)` resolves the kind from `length(v)` and `SearchProperties(v)`. + Picks `KIND_UNIFORM_STEP` for `AbstractRange` / detected-uniform + vectors, `KIND_LINEAR_SCAN` for short vectors, `KIND_BRACKET_GALLOP` + otherwise. + - `Auto(v, props)` is the same with a pre-computed `props` cache. + +The per-query `search_last(::Auto, v, x, hint)` is a one-line forward to +`search_last(s.kind, v, x, hint)`. The batched +`searchsortedlast!(out, v, queries; strategy = Auto())` re-resolves the +kind from `(v, queries)` to consult the gap heuristic. + +## Equality search The package exposes two `Union{Int, Nothing}`-returning equality routines — [`findfirstequal`](@ref FindFirstFunctions.findfirstequal) (unsorted SIMD scan) and [`findfirstsortedequal`](@ref FindFirstFunctions.findfirstsortedequal) -(sorted bisect-then-SIMD scan). They live outside the strategy framework -because their return semantics differ (`nothing` on miss, vs. in-range -index for the positional API). See the [Equality search](@ref Equality-search) page for the -full documentation. +(sorted bisect-then-SIMD scan). See the [Equality search](@ref) page for +the full documentation. diff --git a/src/FindFirstFunctions.jl b/src/FindFirstFunctions.jl index cb28dc1..c069f37 100644 --- a/src/FindFirstFunctions.jl +++ b/src/FindFirstFunctions.jl @@ -1,37 +1,51 @@ module FindFirstFunctions # Public API surface for `using FindFirstFunctions`. The strategy types are -# zero-field singletons (except `GuesserHint` and `Auto`, which carry small -# isbits payloads), so exporting them only adds names to the caller's -# namespace — no runtime cost. `searchsortedfirst!` / `searchsortedlast!` -# are FFF-defined names (the non-bang `searchsortedfirst` / -# `searchsortedlast` are extensions of `Base` and are reachable without -# qualification once `Base` is in scope). +# zero-field singletons (except `GuesserHint` and `Auto`, which carry +# small isbits payloads), so exporting them only adds names to the +# caller's namespace — no runtime cost. +# +# v3 introduces the enum-tagged dispatch path (`search_last` / +# `search_first` over `StrategyKind` values). The v2 +# `Base.searchsortedlast(::S, ...)` API remains as a back-compat shim, +# scheduled for removal in v4. export + # Abstract type + concrete singleton strategies (v2 back-compat). SearchStrategy, LinearScan, SIMDLinearScan, BracketGallop, ExpFromLeft, InterpolationSearch, BitInterpolationSearch, BinaryBracket, UniformStep, BisectThenSIMD, + # Stateful strategies. GuesserHint, Auto, + # Properties / helpers. SearchProperties, Guesser, looks_linear, + # Enum + dispatchers (v3 preferred path). + StrategyKind, + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_SIMD_LINEAR_SCAN, + KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, + KIND_INTERPOLATION_SEARCH, KIND_BIT_INTERPOLATION_SEARCH, + KIND_UNIFORM_STEP, KIND_BISECT_THEN_SIMD, + search_last, search_first, strategy_kind, + # Batched API. searchsortedfirst!, searchsortedlast!, searchsortedrange, + # Equality search. findequal, findfirstequal, findfirstsortedequal -# Julia 1.12 changed how `Ptr{T}` arguments to `Base.llvmcall` are passed: -# they're now real pointers rather than i64s. https://github.com/JuliaLang/julia/pull/53687 +# Julia 1.12 changed how `Ptr{T}` arguments to `Base.llvmcall` are passed. const USE_PTR = VERSION >= v"1.12.0-DEV.255" # Source layout. Include order matters — each file may depend on names -# defined in earlier files. See the comment block at the top of each file -# for what lives where. -include("simd_ir.jl") # IR template + per-eltype IR constants + SIMD primitives +# defined in earlier files. +include("simd_ir.jl") # IR template + SIMD primitives include("equality.jl") # findfirstequal + findfirstsortedequal +include("kinds.jl") # StrategyKind enum + search_last / search_first dispatchers include("strategies.jl") # SearchStrategy + concrete strategy types + SearchProperties + Auto include("search_properties.jl") # Linearity / NaN probes + populated SearchProperties constructor -include("dispatch.jl") # Per-strategy searchsortedfirst/last methods + their internal helpers -include("auto.jl") # Auto crossover constants + per-query Auto + Auto's batched helpers -include("batched.jl") # Batched API + searchsortedrange + _batched! (incl Auto specialization) +include("kernels.jl") # Per-strategy kernel functions called by the dispatchers +include("legacy_dispatch.jl") # Base.searchsortedlast(::S,…) back-compat shims + strategy_kind +include("auto.jl") # Auto helpers + _auto_resolve_kind + Auto dispatch +include("batched.jl") # Batched API + Auto batched specialization include("guesser.jl") # looks_linear + Guesser + GuesserHint dispatch include("findequal.jl") # findequal + BisectThenSIMD shortcut include("precompile.jl") # PrecompileTools workload diff --git a/src/auto.jl b/src/auto.jl index a0eb520..9e633a0 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -1,72 +1,45 @@ -# Auto strategy — heuristic dispatch tree + crossover constants + the -# helpers (`_estimate_avg_gap`, `_auto_simd_*`, `_auto_interp_eligible`) it -# consults. Per-query Auto dispatch lives here; batched Auto dispatch lives -# in `batched.jl` (next to the generic `_batched!` it specializes). +# Auto strategy — resolves a `StrategyKind` from `(v, props)` at +# construction time. Per-query dispatch is then a one-line forward to +# `search_last` / `search_first` on the stored kind. The batched dispatch +# (in `batched.jl`) re-resolves the kind from `(v, queries)` because the +# gap heuristic needs the queries. +# +# This file owns: +# - The crossover constants (`_AUTO_LINEAR_THRESHOLD`, etc.) +# - The helper predicates (`_auto_is_uniform`, `_auto_simd_eligible`, +# `_estimate_avg_gap`, `_auto_interp_eligible`, …) +# - `_auto_resolve_kind(v, props)` — forward-declared in `strategies.jl` +# - The per-query `search_last(::Auto, ...)` / `search_first(::Auto, ...)` +# methods + their `Base.searchsortedlast(::Auto, ...)` back-compat shims. # Per-query Auto threshold: under this length, the bracket-search bookkeeping # costs more than a worst-case linear walk. const _AUTO_LINEAR_THRESHOLD = 16 -# Batched-Auto crossover: at gap ≤ 4 LinearScan beats ExpFromLeft (its 5 -# initial linear probes are wasted when the gap is already 0 or 1). Above 4, -# the SIMD path picks up (where eligible) up to gap = `_auto_simd_gap_max`. +# Batched-Auto crossover: at gap ≤ 4 LinearScan beats ExpFromLeft. const _AUTO_BATCH_LINEAR_GAP = 4 -# For sparse queries (gap large) on long vectors, InterpolationSearch can -# beat ExpFromLeft by ~2× on uniformly-spaced data. The sampled-linearity -# check is O(1) — 9 probes — so it's cheap enough to run inside Auto when -# there's a real chance of unlocking InterpolationSearch. +# Sparse-on-large-linear: InterpolationSearch beats ExpFromLeft. const _AUTO_INTERP_MIN_GAP = 8 const _AUTO_INTERP_MIN_N = 1024 const _AUTO_INTERP_MIN_M = 2 -# Very-sparse override: when the gap is large enough that ExpFromLeft's -# log₂(gap) doubling levels approach InterpolationSearch's log₂(n) worst-case -# binary refinement, InterpolationSearch's better cache behaviour (one -# extrapolation jump + local refine vs. many doubling probes across the -# array) wins even on non-strictly-linear data. +# Very-sparse override: looser linearity tolerance at large gaps. const _AUTO_INTERP_LOOSE_GAP = 256 const _AUTO_LINEAR_LOOSE_TOLERANCE = 5.0e-2 -# SIMDLinearScan eligibility window. The threshold is eltype-parameterized -# via the `_auto_simd_gap_max` function below. +# SIMDLinearScan eligibility window. The threshold is eltype-parameterized. @inline _auto_simd_gap_max(::DenseVector{Int64}) = 64 @inline _auto_simd_gap_max(::DenseVector{Float64}) = 64 @inline _auto_simd_gap_max(::AbstractVector) = 0 # not SIMD-eligible -# When InterpolationSearch isn't eligible and the gap is large, BracketGallop -# beats ExpFromLeft because the 5 linear probes ExpFromLeft does upfront are -# wasted (no chance the answer is within 5 of `hint = prev_result` when the -# gap is hundreds or thousands). BracketGallop just starts doubling -# immediately. The bench sweep shows this crossover at gap ≈ 16. +# When InterpolationSearch isn't eligible and the gap is large, +# BracketGallop beats ExpFromLeft. const _AUTO_GALLOP_GAP_MIN = 16 -# Per-query Auto: pick based on hint validity and length(v). -@inline function _auto_pick(v::AbstractVector, hint::Integer) - return if hint < firstindex(v) || hint > lastindex(v) - BinaryBracket() - elseif length(v) <= _AUTO_LINEAR_THRESHOLD - LinearScan() - else - BracketGallop() - end -end - # Returns `(gap, skewed)`: the estimated average step in `v`'s index space # between consecutive query results, plus a flag that's true when the # queries' distribution is non-uniform within their span. -# -# - `gap` is the per-query cost driver. We always use the span-based -# estimate `n * span(queries) / span(v) / m` so that tightly-clustered -# queries (span_q ≈ 0) report gap ≈ 0 regardless of `n/m`. The earlier -# `n / m` fallback for skewed queries caused `SIMDLinearScan` to be -# picked for clustered queries where LinearScan's tiny scalar walk is -# 5× faster. -# - `skewed` is an InterpolationSearch-suitability flag. When the median -# query sits well off the midpoint of `queries[1]..queries[end]`, the -# queries are clustered within their span and the per-call linear -# extrapolation guess is worse than the previous-result hint that -# `ExpFromLeft` would use. @inline function _estimate_avg_gap( v::AbstractVector{<:Number}, queries::AbstractVector{<:Number}, m::Integer, ) @@ -77,10 +50,6 @@ end return (n ÷ max(1, m), false) end @inbounds span_q = queries[end] - queries[1] - # Skew detection on small `m` is too noisy — for `m ≈ 4` random uniform - # samples, the median routinely sits 30%+ off the linear midpoint by - # chance. Gate on `m ≥ 10` where the statistical variance is well below - # the 20% threshold. skewed = false if m >= 10 @inbounds mid_q = queries[firstindex(queries) + m ÷ 2] @@ -94,102 +63,104 @@ end end end ratio = span_q / span_v - # Clamp ratio: queries may extend outside v's range (extrapolation). ratio = clamp(ratio, zero(ratio), one(ratio)) return (floor(Int, n * ratio / max(1, m)), skewed) end -# Non-numeric eltypes: no span subtraction possible, fall back to length -# ratio and assume queries are roughly uniform (no skew detection possible). +# Non-numeric eltypes. @inline _estimate_avg_gap( v::AbstractVector, ::AbstractVector, m::Integer, ) = (length(v) ÷ max(1, m), false) -# SIMD eligibility check used by Auto's batched dispatch. The static type -# test on `v` discriminates the `DenseVector{Int64}` / `DenseVector{Float64}` -# cases that SIMDLinearScan supports. For Float64, NaN presence is taken from -# cached `SearchProperties.has_nan` when available; otherwise we assume no -# NaN — Base's positional search doesn't check sortedness either, and the -# burden of supplying populated props is on the caller for pathological -# inputs. +# SIMD eligibility for the batched Auto dispatch. @inline _auto_simd_eligible(v::DenseVector{Int64}, ::SearchProperties) = true @inline function _auto_simd_eligible(v::DenseVector{Float64}, p::SearchProperties) return p.has_props ? !p.has_nan : true end @inline _auto_simd_eligible(::AbstractVector, ::SearchProperties) = false -# Uniformity check used by Auto to short-circuit to `UniformStep`. Two -# routes: the static type test catches `AbstractRange` (always uniform by -# definition); the props check catches `Vector` callers who supplied -# `SearchProperties(v; is_uniform = true)`. +# Uniformity check. @inline _auto_is_uniform(::AbstractRange, ::SearchProperties) = true @inline _auto_is_uniform(::AbstractVector, p::SearchProperties) = p.has_props && p.is_uniform -# InterpolationSearch eligibility: two-tier linearity check. For -# `_AUTO_INTERP_MIN_GAP ≤ gap < _AUTO_INTERP_LOOSE_GAP` we require strict -# linearity (`_AUTO_LINEAR_REL_TOLERANCE`, default 0.1%) — InterpolationSearch -# is only worth the per-call cost on truly uniform data when ExpFromLeft is -# also competitive. For `gap ≥ _AUTO_INTERP_LOOSE_GAP` we accept a looser -# tolerance (`_AUTO_LINEAR_LOOSE_TOLERANCE`, default 5%) because the cache -# benefit of one extrapolation jump + local refine compensates for a worse -# guess, but we still reject genuinely nonlinear data (log-spaced, -# two-scale) where InterpolationSearch loses 2–3× to ExpFromLeft. +# InterpolationSearch eligibility: two-tier linearity check. @inline function _auto_interp_eligible(v, props::SearchProperties, gap::Integer) if gap >= _AUTO_INTERP_LOOSE_GAP - # Loose probe — even on cached props, the strict `is_linear` bit may - # already reflect a tighter threshold than we need here, so run the - # sampled probe at the loose tolerance regardless of cache state. return _sampled_looks_linear(v, _AUTO_LINEAR_LOOSE_TOLERANCE) end return props.has_props ? props.is_linear : _sampled_looks_linear(v) end -# Per-query Auto dispatch. Checks `is_uniform` first so `AbstractRange` -# inputs (always uniform) and `Vector`s with `SearchProperties(v; -# is_uniform = true)` short-circuit to `UniformStep`'s closed-form path -# without paying for `_auto_pick`'s hint validity check. -function Base.searchsortedlast( +# `_auto_resolve_kind` is forward-declared in `strategies.jl` so `Auto(v)` +# can call it from the struct's constructor. The body lives here so it can +# use the helpers above. Mirrors the v2 `_auto_pick` logic but for +# *construction-time* resolution — the hint isn't known yet, so we pick a +# kind that handles every hint configuration robustly. +@inline function _auto_resolve_kind(v::AbstractVector, props::SearchProperties) + if _auto_is_uniform(v, props) + return KIND_UNIFORM_STEP + elseif length(v) <= _AUTO_LINEAR_THRESHOLD + return KIND_LINEAR_SCAN + else + return KIND_BRACKET_GALLOP + end +end + +# --------------------------------------------------------------------------- +# Per-query Auto dispatch. The stored kind handles every hint configuration +# robustly — `BracketGallop` falls back to a full search when the hint is +# absent or out of range; `LinearScan` (picked for short `v`) clamps the +# hint and walks. So `search_last(::Auto, v, x, hint)` is a one-line +# forward to the kind dispatcher. +# --------------------------------------------------------------------------- + +# Hinted form: forward to the kind dispatcher. +@inline function search_last( s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - if _auto_is_uniform(v, s.props) - return searchsortedlast(UniformStep(), v, x; order = order) - end - chosen = _auto_pick(v, hint) - return chosen isa BinaryBracket ? - searchsortedlast(chosen, v, x; order = order) : - searchsortedlast(chosen, v, x, hint; order = order) + return search_last(s.kind, v, x, hint; order = order) end -function Base.searchsortedfirst( +@inline function search_first( s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - if _auto_is_uniform(v, s.props) - return searchsortedfirst(UniformStep(), v, x; order = order) - end - chosen = _auto_pick(v, hint) - return chosen isa BinaryBracket ? - searchsortedfirst(chosen, v, x; order = order) : - searchsortedfirst(chosen, v, x, hint; order = order) + return search_first(s.kind, v, x, hint; order = order) end -function Base.searchsortedlast( +# No-hint form: same forward. The kind's no-hint dispatch handles +# fall-through to BinaryBracket for hint-using strategies. +@inline function search_last( s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - if _auto_is_uniform(v, s.props) - return searchsortedlast(UniformStep(), v, x; order = order) - end - return searchsortedlast(BinaryBracket(), v, x; order = order) + return search_last(s.kind, v, x; order = order) end -function Base.searchsortedfirst( + +@inline function search_first( s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - if _auto_is_uniform(v, s.props) - return searchsortedfirst(UniformStep(), v, x; order = order) - end - return searchsortedfirst(BinaryBracket(), v, x; order = order) + return search_first(s.kind, v, x; order = order) end + +# Legacy `Base.searchsortedlast(::Auto, ...)` shims — same one-liner. Kept +# so v2 callers continue to work without changes. +Base.searchsortedlast( + s::Auto, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_last(s, v, x; order = order) +Base.searchsortedfirst( + s::Auto, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_first(s, v, x; order = order) +Base.searchsortedlast( + s::Auto, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_last(s, v, x, hint; order = order) +Base.searchsortedfirst( + s::Auto, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_first(s, v, x, hint; order = order) diff --git a/src/batched.jl b/src/batched.jl index 89221dc..bb94d78 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -1,35 +1,28 @@ -# In-place batched sorted-search API: `searchsortedfirst!` / `searchsortedlast!` -# and `searchsortedrange`, plus the internal `_batched!` dispatchers and the -# Auto-specific specialization that turns Auto's decision tree into a single -# branchless dispatch on a concrete strategy. +# In-place batched sorted-search API: `searchsortedfirst!` / +# `searchsortedlast!` and `searchsortedrange`, plus the internal +# `_batched!` dispatchers and the Auto-specific specialization that turns +# Auto's decision tree into a single dispatch on a concrete kind. """ searchsortedlast!(idx_out, v, queries; strategy = Auto(), order = Base.Order.Forward, queries_sorted = nothing) -In-place batched `Base.searchsortedlast`. Writes one -index per element of `queries` into `idx_out` (which must be the same length). +In-place batched [`searchsortedlast`](@ref Base.searchsortedlast). Writes +one index per element of `queries` into `idx_out` (which must be the same +length). -If `queries` is sorted under `order`, the previous result is used as a hint for -the next query, so the total cost is O(length(v) + length(queries)) under -`strategy = BracketGallop()` (the default `Auto` choice for non-tiny `v`). +If `queries` is sorted under `order`, the previous result is used as a +hint for the next query, so the total cost is O(length(v) + length(queries)) +under `strategy = BracketGallop()`. -If `queries` is not sorted, falls back to per-element `searchsortedlast` with -no hint regardless of `strategy`. +If `queries` is not sorted, falls back to per-element `searchsortedlast` +with no hint regardless of `strategy`. The `queries_sorted` kwarg controls the runtime `issorted(queries)` check: - `nothing` (default): run `issorted(queries; order = order)` on every call. - O(m) bookkeeping, roughly 1 ns/q on long batches. - - `true`: trust the caller — skip the check and take the sorted-loop path - unconditionally. Use this when you already know your queries are sorted - (you computed them as a range, sorted them yourself, etc.). Wrong-answer - risk: a non-sorted `queries` passed with `queries_sorted = true` will - produce incorrect results, since the inner loop uses the previous result - as a hint and that hint becomes invalid when queries jump backward. - - `false`: skip the check and take the unsorted-loop path unconditionally - (per-query unhinted `Base.searchsortedlast`). Use when you know queries - are not sorted and want to avoid the O(m) probe. + - `true`: skip the check and take the sorted-loop path unconditionally. + - `false`: skip the check and take the unsorted-loop path unconditionally. Returns `idx_out`. """ @@ -57,8 +50,8 @@ end searchsortedfirst!(idx_out, v, queries; strategy = Auto(), order = Base.Order.Forward, queries_sorted = nothing) -In-place batched `Base.searchsortedfirst`. See -[`searchsortedlast!`](@ref) for behavior and for the `queries_sorted` kwarg. +In-place batched [`searchsortedfirst`](@ref Base.searchsortedfirst). See +[`searchsortedlast!`](@ref) for behavior. """ function searchsortedfirst!( idx_out::AbstractVector{<:Integer}, @@ -80,8 +73,44 @@ function searchsortedfirst!( ) end -# Sorted inner loop, parameterized on strategy. Used by both the generic and -# Auto batched entry points so each batch performs at most one issorted check. +# Sorted inner loop parameterized on a `StrategyKind` — concrete kernel +# dispatch happens inside `search_last` via the enum switch. +function _searchsortedlast_sorted_loop_kind!( + idx_out, v::AbstractVector, queries::AbstractVector, + kind::StrategyKind, order::Base.Order.Ordering, + ) + hint = firstindex(v) - 1 + @inbounds for k in eachindex(queries) + q = queries[k] + hint = if hint < firstindex(v) + search_last(kind, v, q; order = order) + else + search_last(kind, v, q, hint; order = order) + end + idx_out[k] = hint + end + return idx_out +end + +function _searchsortedfirst_sorted_loop_kind!( + idx_out, v::AbstractVector, queries::AbstractVector, + kind::StrategyKind, order::Base.Order.Ordering, + ) + hint = firstindex(v) - 1 + @inbounds for k in eachindex(queries) + q = queries[k] + hint = if hint < firstindex(v) + search_first(kind, v, q; order = order) + else + search_first(kind, v, q, hint; order = order) + end + idx_out[k] = hint + end + return idx_out +end + +# Sorted inner loop parameterized on a strategy *struct* (for GuesserHint +# and for the back-compat `Base.searchsortedlast(::S, ...)` path). function _searchsortedlast_sorted_loop!( idx_out, v::AbstractVector, queries::AbstractVector, strategy::SearchStrategy, order::Base.Order.Ordering, @@ -136,10 +165,6 @@ function _searchsortedfirst_unsorted_loop!( return idx_out end -# Decide whether to take the sorted-queries fast path. `queries_sorted` is -# the caller-supplied override: `nothing` means "check at runtime", `true` -# means "trust the caller, skip the O(m) issorted probe", `false` means -# "force the unsorted path". @inline function _take_sorted_path( queries, order::Base.Order.Ordering, queries_sorted::Union{Nothing, Bool}, ) @@ -147,42 +172,122 @@ end issorted(queries; order = order) : queries_sorted end +# Generic strategy path: singleton struct routes through its kind; other +# struct strategies (GuesserHint) route through their multimethod. function _searchsortedlast_batched!( idx_out, v::AbstractVector, queries::AbstractVector, strategy::SearchStrategy, order::Base.Order.Ordering, queries_sorted::Union{Nothing, Bool}, ) return if _take_sorted_path(queries, order, queries_sorted) - _searchsortedlast_sorted_loop!(idx_out, v, queries, strategy, order) + _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, strategy, order + ) else _searchsortedlast_unsorted_loop!(idx_out, v, queries, order) end end -# Specialized batched-Auto: pick an inner strategy from the n/m ratio, then -# call the sorted loop directly (no duplicate `issorted` check, and each -# branch is type-stable so the loop specializes on the concrete strategy). +function _searchsortedfirst_batched!( + idx_out, v::AbstractVector, queries::AbstractVector, + strategy::SearchStrategy, order::Base.Order.Ordering, + queries_sorted::Union{Nothing, Bool}, + ) + return if _take_sorted_path(queries, order, queries_sorted) + _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, strategy, order + ) + else + _searchsortedfirst_unsorted_loop!(idx_out, v, queries, order) + end +end + +# Dispatch helper: route singleton struct → kind loop, stateful struct → +# struct loop. Inlining means no extra cost vs. the v2 single-loop form. +@inline function _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, strategy::SearchStrategy, order, + ) + return _searchsortedlast_sorted_loop!(idx_out, v, queries, strategy, order) +end +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BinaryBracket, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_BINARY_BRACKET, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::LinearScan, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_LINEAR_SCAN, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::SIMDLinearScan, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_SIMD_LINEAR_SCAN, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BracketGallop, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_BRACKET_GALLOP, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::ExpFromLeft, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_EXP_FROM_LEFT, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::InterpolationSearch, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_INTERPOLATION_SEARCH, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BitInterpolationSearch, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_BIT_INTERPOLATION_SEARCH, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::UniformStep, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_UNIFORM_STEP, order) +@inline _searchsortedlast_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BisectThenSIMD, order, +) = _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, KIND_BISECT_THEN_SIMD, order) + +@inline function _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, strategy::SearchStrategy, order, + ) + return _searchsortedfirst_sorted_loop!(idx_out, v, queries, strategy, order) +end +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BinaryBracket, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_BINARY_BRACKET, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::LinearScan, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_LINEAR_SCAN, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::SIMDLinearScan, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_SIMD_LINEAR_SCAN, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BracketGallop, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_BRACKET_GALLOP, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::ExpFromLeft, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_EXP_FROM_LEFT, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::InterpolationSearch, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_INTERPOLATION_SEARCH, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BitInterpolationSearch, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_BIT_INTERPOLATION_SEARCH, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::UniformStep, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_UNIFORM_STEP, order) +@inline _searchsortedfirst_sorted_loop_strategy_dispatch!( + idx_out, v, queries, ::BisectThenSIMD, order, +) = _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, KIND_BISECT_THEN_SIMD, order) + +# --------------------------------------------------------------------------- +# Specialized batched-Auto: pick a kind from the n/m ratio + linearity probe, +# then call the kind-parameterized sorted loop directly. +# --------------------------------------------------------------------------- + function _searchsortedlast_batched!( idx_out, v::AbstractVector, queries::AbstractVector, s::Auto, order::Base.Order.Ordering, queries_sorted::Union{Nothing, Bool}, ) - # Uniform-spaced vectors (always true for `AbstractRange`, optionally - # for `Vector`s carrying `SearchProperties(v; is_uniform = true)`) go - # straight to the closed-form `UniformStep` path — no gap estimation, - # no linearity probe, no `issorted` check (uniformly-spaced sorted - # data has the same answer regardless of query ordering). if _auto_is_uniform(v, s.props) @inbounds for k in eachindex(queries) - idx_out[k] = searchsortedlast(UniformStep(), v, queries[k]; order = order) + idx_out[k] = search_last(KIND_UNIFORM_STEP, v, queries[k]; order = order) end return idx_out end m = length(queries) m == 0 && return idx_out - # m == 1: skip the issorted + span heuristic — no batched hint is - # available for a single-element batch, so just dispatch straight to - # the unhinted backing call. Saves ~20 ns of bookkeeping per call. if m == 1 @inbounds idx_out[firstindex(idx_out)] = searchsortedlast(v, queries[firstindex(queries)], order) @@ -192,55 +297,8 @@ function _searchsortedlast_batched!( return _searchsortedlast_unsorted_loop!(idx_out, v, queries, order) end gap, skewed = _estimate_avg_gap(v, queries, m) - # Manually dispatch on the picked strategy so each branch is concrete. - if gap <= _AUTO_BATCH_LINEAR_GAP - return _searchsortedlast_sorted_loop!( - idx_out, v, queries, LinearScan(), order - ) - end - # Medium-gap regime: SIMDLinearScan wins on `DenseVector{Int64}` and - # `DenseVector{Float64}` (without NaN). - if gap <= _auto_simd_gap_max(v) && _auto_simd_eligible(v, s.props) - return _searchsortedlast_sorted_loop!( - idx_out, v, queries, SIMDLinearScan(), order - ) - end - # Sparse-on-large-linear: InterpolationSearch wins ~2× over ExpFromLeft - # on uniformly-spaced data — but only when queries are *also* spread - # roughly uniformly within their span. - if !skewed && - gap >= _AUTO_INTERP_MIN_GAP && - length(v) >= _AUTO_INTERP_MIN_N && - m >= _AUTO_INTERP_MIN_M && - _auto_interp_eligible(v, s.props, gap) - return _searchsortedlast_sorted_loop!( - idx_out, v, queries, InterpolationSearch(), order - ) - end - # Sparse fallback: BracketGallop beats ExpFromLeft when the gap is large - # enough that ExpFromLeft's initial 5 linear probes are guaranteed to - # miss. BracketGallop starts doubling from one position past `hint`, so - # it skips the wasted linear preamble. - if gap >= _AUTO_GALLOP_GAP_MIN - return _searchsortedlast_sorted_loop!( - idx_out, v, queries, BracketGallop(), order - ) - end - return _searchsortedlast_sorted_loop!( - idx_out, v, queries, ExpFromLeft(), order - ) -end - -function _searchsortedfirst_batched!( - idx_out, v::AbstractVector, queries::AbstractVector, - strategy::SearchStrategy, order::Base.Order.Ordering, - queries_sorted::Union{Nothing, Bool}, - ) - return if _take_sorted_path(queries, order, queries_sorted) - _searchsortedfirst_sorted_loop!(idx_out, v, queries, strategy, order) - else - _searchsortedfirst_unsorted_loop!(idx_out, v, queries, order) - end + kind = _auto_batched_kind(v, s.props, gap, skewed, m) + return _searchsortedlast_sorted_loop_kind!(idx_out, v, queries, kind, order) end function _searchsortedfirst_batched!( @@ -250,7 +308,7 @@ function _searchsortedfirst_batched!( ) if _auto_is_uniform(v, s.props) @inbounds for k in eachindex(queries) - idx_out[k] = searchsortedfirst(UniformStep(), v, queries[k]; order = order) + idx_out[k] = search_first(KIND_UNIFORM_STEP, v, queries[k]; order = order) end return idx_out end @@ -265,33 +323,33 @@ function _searchsortedfirst_batched!( return _searchsortedfirst_unsorted_loop!(idx_out, v, queries, order) end gap, skewed = _estimate_avg_gap(v, queries, m) + kind = _auto_batched_kind(v, s.props, gap, skewed, m) + return _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, kind, order) +end + +# Batched Auto's kind picker: the v2 decision tree, returning a +# `StrategyKind` instead of branching to different loop specializations. +@inline function _auto_batched_kind( + v::AbstractVector, props::SearchProperties, gap::Integer, + skewed::Bool, m::Integer, + ) if gap <= _AUTO_BATCH_LINEAR_GAP - return _searchsortedfirst_sorted_loop!( - idx_out, v, queries, LinearScan(), order - ) + return KIND_LINEAR_SCAN end - if gap <= _auto_simd_gap_max(v) && _auto_simd_eligible(v, s.props) - return _searchsortedfirst_sorted_loop!( - idx_out, v, queries, SIMDLinearScan(), order - ) + if gap <= _auto_simd_gap_max(v) && _auto_simd_eligible(v, props) + return KIND_SIMD_LINEAR_SCAN end if !skewed && gap >= _AUTO_INTERP_MIN_GAP && length(v) >= _AUTO_INTERP_MIN_N && m >= _AUTO_INTERP_MIN_M && - _auto_interp_eligible(v, s.props, gap) - return _searchsortedfirst_sorted_loop!( - idx_out, v, queries, InterpolationSearch(), order - ) + _auto_interp_eligible(v, props, gap) + return KIND_INTERPOLATION_SEARCH end if gap >= _AUTO_GALLOP_GAP_MIN - return _searchsortedfirst_sorted_loop!( - idx_out, v, queries, BracketGallop(), order - ) + return KIND_BRACKET_GALLOP end - return _searchsortedfirst_sorted_loop!( - idx_out, v, queries, ExpFromLeft(), order - ) + return KIND_EXP_FROM_LEFT end # --------------------------------------------------------------------------- @@ -305,17 +363,10 @@ end Return the index range of all entries `v[i]` satisfying `lo ≤ v[i] ≤ hi` under `order`. Equivalent to `searchsortedfirst(strategy, v, lo[, hint]; order) : - searchsortedlast(strategy, v, hi[, hint]; order)` but expressed as a -single call that may share bracket-discovery work between the two -endpoints when the underlying strategy allows it. - -The empty range case (no `v[i]` lies in `[lo, hi]`) returns -`searchsortedfirst(strategy, v, lo) : (searchsortedfirst(strategy, v, lo) - 1)`, -matching `Base.searchsorted(v, lo)` for an absent value. + searchsortedlast(strategy, v, hi[, hint]; order)`. -When a `hint` is supplied it is used for both endpoint searches. Strategies -that ignore the hint (`BinaryBracket`, `InterpolationSearch`) treat the -hinted form as a pass-through. +When a `hint` is supplied it is used for both endpoint searches. +Strategies that ignore the hint treat the hinted form as a pass-through. """ @inline function searchsortedrange( strategy::SearchStrategy, v::AbstractVector, lo, hi; @@ -336,3 +387,22 @@ end ) return first_idx:last_idx end + +# Kind-tagged equivalent. +@inline function searchsortedrange( + kind::StrategyKind, v::AbstractVector, lo, hi; + order::Base.Order.Ordering = Base.Order.Forward, + ) + first_idx = search_first(kind, v, lo; order = order) + last_idx = search_last(kind, v, hi; order = order) + return first_idx:last_idx +end + +@inline function searchsortedrange( + kind::StrategyKind, v::AbstractVector, lo, hi, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) + first_idx = search_first(kind, v, lo, hint; order = order) + last_idx = search_last(kind, v, hi, max(first_idx, hint); order = order) + return first_idx:last_idx +end diff --git a/src/dispatch.jl b/src/dispatch.jl deleted file mode 100644 index 0719ab8..0000000 --- a/src/dispatch.jl +++ /dev/null @@ -1,809 +0,0 @@ -# Per-strategy `Base.searchsortedlast` / `Base.searchsortedfirst` dispatch. -# Each strategy gets one dispatch block plus any internal helpers it needs -# (`bracketstrictlymonotonic*` for `BracketGallop`, `searchsortedfirstexp` -# for `ExpFromLeft`, `_interp_guess` for `InterpolationSearch`, etc.). -# -# `Auto`'s per-query and batched dispatch lives in `auto.jl` / `batched.jl`; -# `GuesserHint`'s lives in `guesser.jl`. - -# =========================================================================== -# Bracket helpers — backing `BracketGallop` -# =========================================================================== - -# Expanding-binary-search bracket around a guess. The `searchsortedlast` -# polarity: when `x == v[guess]`, the answer is `>= guess` (gallop right). -function bracketstrictlymonotonic( - v::AbstractVector, - x, - guess::T, - o::Base.Order.Ordering, - )::NTuple{2, keytype(v)} where {T <: Integer} - bottom = firstindex(v) - top = lastindex(v) - if guess < bottom || guess > top - return bottom, top - else - u = T(1) - lo, hi = guess, min(guess + u, top) - @inbounds if Base.Order.lt(o, x, v[lo]) - while lo > bottom && Base.Order.lt(o, x, v[lo]) - lo, hi = max(bottom, lo - u), lo - u += u - end - else - while hi < top && !Base.Order.lt(o, x, v[hi]) - lo, hi = hi, min(top, hi + u) - u += u - end - end - end - return lo, hi -end - -# Companion to `bracketstrictlymonotonic` for the `searchsortedfirst` -# polarity. Original uses `lt(o, x, v[lo])` (i.e., `x < v[lo]`), which is -# right for `searchsortedlast`: when `x == v[lo]`, the answer is `>= lo` so -# we gallop right. For `searchsortedfirst`, when `x == v[lo]` the answer is -# `<= lo` (look for earlier duplicates) — so we need the inverted polarity -# `lt(o, v[lo], x)`. Without this, BracketGallop.searchsortedfirst returns -# the wrong index when the hint lands on a run of duplicates. -function bracketstrictlymonotonic_first( - v::AbstractVector, - x, - guess::T, - o::Base.Order.Ordering, - )::NTuple{2, keytype(v)} where {T <: Integer} - bottom = firstindex(v) - top = lastindex(v) - if guess < bottom || guess > top - return bottom, top - else - u = T(1) - lo, hi = guess, min(guess + u, top) - @inbounds if !Base.Order.lt(o, v[lo], x) - # v[lo] >= x → answer is <= lo, gallop left. - while lo > bottom && !Base.Order.lt(o, v[lo], x) - lo, hi = max(bottom, lo - u), lo - u += u - end - else - # v[lo] < x → answer is > lo, gallop right. - while hi < top && Base.Order.lt(o, v[hi], x) - lo, hi = hi, min(top, hi + u) - u += u - end - end - end - return lo, hi -end - -# =========================================================================== -# Exponential-search helper — backing `ExpFromLeft` -# =========================================================================== - -# Exponential search forward from `lo`, then bounded binary search inside the -# final bracket. Used internally by `ExpFromLeft`. The `order` parameter -# makes the comparison polarity-aware so `ExpFromLeft` works natively under -# both `Base.Order.Forward` (ascending) and `Base.Order.Reverse` (descending) -# without falling back to plain `Base.searchsortedfirst`. -# -# Finds the smallest index `y` in `[lo, hi]` with `!lt(order, v[y], x)` -# (equivalent to `v[y] >= x` under Forward, `v[y] <= x` under Reverse). -Base.@propagate_inbounds function searchsortedfirstexp( - v::AbstractVector, - x, - lo::Integer = firstindex(v), - hi::Integer = lastindex(v), - order::Base.Order.Ordering = Base.Order.Forward, - ) - for i in 0:4 - ind = lo + i - ind > hi && return ind - !Base.Order.lt(order, v[ind], x) && return ind - end - n = 3 - tn2 = 2^n - tn2m1 = 2^(n - 1) - ind = lo + tn2 - while ind <= hi - !Base.Order.lt(order, v[ind], x) && - return searchsortedfirst(v, x, lo + tn2 - tn2m1, ind, order) - tn2 *= 2 - tn2m1 *= 2 - ind = lo + tn2 - end - return searchsortedfirst(v, x, lo + tn2 - tn2m1, hi, order) -end - -# Sibling of `searchsortedfirstexp` for the `searchsortedlast` polarity: -# finds the largest `y` in `[lo, hi]` with `!lt(order, x, v[y])` -# (equivalent to `v[y] <= x` under Forward, `v[y] >= x` under Reverse). -# Uses the *strict* comparison `lt(order, x, v[ind])` to detect the crossing -# past `x`, then returns `ind - 1`. Without this dedicated helper, callers -# would have to post-process `searchsortedfirstexp`'s result and re-scan for -# duplicates of `x` to find the last occurrence. -Base.@propagate_inbounds function searchsortedlastexp( - v::AbstractVector, - x, - lo::Integer = firstindex(v), - hi::Integer = lastindex(v), - order::Base.Order.Ordering = Base.Order.Forward, - ) - for i in 0:4 - ind = lo + i - ind > hi && return hi - Base.Order.lt(order, x, v[ind]) && return ind - 1 - end - n = 3 - tn2 = 2^n - tn2m1 = 2^(n - 1) - ind = lo + tn2 - while ind <= hi - Base.Order.lt(order, x, v[ind]) && - return searchsortedlast(v, x, lo + tn2 - tn2m1, ind, order) - tn2 *= 2 - tn2m1 *= 2 - ind = lo + tn2 - end - return searchsortedlast(v, x, lo + tn2 - tn2m1, hi, order) -end - -# =========================================================================== -# Strategy: BinaryBracket — ignore any hint, delegate to `Base` -# =========================================================================== - -Base.searchsortedlast( - ::BinaryBracket, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(v, x, order) -Base.searchsortedfirst( - ::BinaryBracket, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(v, x, order) -Base.searchsortedlast( - s::BinaryBracket, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(s, v, x; order = order) -Base.searchsortedfirst( - s::BinaryBracket, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(s, v, x; order = order) - -# =========================================================================== -# Strategy: LinearScan — walk ±1 from the hint -# =========================================================================== - -function Base.searchsortedlast( - ::LinearScan, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = firstindex(v), lastindex(v) - if hi < lo - return lo - 1 # empty vector - end - i = clamp(hint, lo, hi) - @inbounds if Base.Order.lt(order, x, v[i]) - # v[i] > x → retreat - while i > lo - i -= 1 - !Base.Order.lt(order, x, v[i]) && return i - end - return lo - 1 # x precedes all of v - else - # v[i] ≤ x → try to advance - while i < hi - Base.Order.lt(order, x, v[i + 1]) && return i - i += 1 - end - return hi - end -end - -function Base.searchsortedfirst( - ::LinearScan, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = firstindex(v), lastindex(v) - if hi < lo - return lo - end - i = clamp(hint, lo, hi) - @inbounds if Base.Order.lt(order, v[i], x) - # v[i] < x → advance - while i < hi - i += 1 - !Base.Order.lt(order, v[i], x) && return i - end - return hi + 1 # x exceeds all of v - else - # v[i] ≥ x → try to retreat - while i > lo - !Base.Order.lt(order, v[i - 1], x) && (i -= 1; continue) - return i - end - return lo - end -end - -# LinearScan without a hint falls back to BinaryBracket. -Base.searchsortedlast( - s::LinearScan, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - s::LinearScan, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# =========================================================================== -# Strategy: SIMDLinearScan — specialized forward walk for DenseVector{Int64} -# and DenseVector{Float64}. Backward walks reuse a scalar walk (rare from a -# good hint). The SIMD primitive is order-aware: under `Forward`, scans for -# the first lane with `v[i] > x` (or `>= x` for searchsortedfirst); under -# `Reverse`, scans for the first lane with `v[i] < x` (or `<= x`). Any other -# ordering falls back to scalar `LinearScan`. -# =========================================================================== - -@inline function _simdscan_last_specialized( - v::Union{DenseVector{Int64}, DenseVector{Float64}}, - x, hint::Integer, - order::Base.Order.Ordering, - ) - lo = firstindex(v) - hi = lastindex(v) - hi < lo && return lo - 1 - i = clamp(hint, lo, hi) - @inbounds vi = v[i] - if Base.Order.lt(order, x, vi) - # `v[i]` is past the answer in this ordering — backward walk (scalar). - while i > lo - i -= 1 - @inbounds !Base.Order.lt(order, x, v[i]) && return i - end - return lo - 1 - end - i == hi && return hi - start = i + 1 - len = hi - start + 1 - # SIMD forward scan for the first lane that crosses the threshold. - offset = if order === Base.Order.Forward - GC.@preserve v _simd_first_gt(x, pointer(v, start), Int64(len)) - else - GC.@preserve v _simd_first_lt(x, pointer(v, start), Int64(len)) - end - return offset < 0 ? hi : (start + offset) - 1 -end - -@inline function _simdscan_first_specialized( - v::Union{DenseVector{Int64}, DenseVector{Float64}}, - x, hint::Integer, - order::Base.Order.Ordering, - ) - lo = firstindex(v) - hi = lastindex(v) - hi < lo && return lo - i = clamp(hint, lo, hi) - @inbounds vi = v[i] - if Base.Order.lt(order, vi, x) - # `v[i]` is before the answer — SIMD-scan forward for the first lane - # that meets-or-passes the search target. - i == hi && return hi + 1 - start = i + 1 - len = hi - start + 1 - offset = if order === Base.Order.Forward - GC.@preserve v _simd_first_ge(x, pointer(v, start), Int64(len)) - else - GC.@preserve v _simd_first_le(x, pointer(v, start), Int64(len)) - end - return offset < 0 ? hi + 1 : start + offset - end - # `v[i]` meets or passes — retreat (scalar). - while i > lo - @inbounds Base.Order.lt(order, v[i - 1], x) && return i - i -= 1 - end - return lo -end - -# Dispatch helper: only `Forward` and `Reverse` orderings use the SIMD path; -# anything else (custom `By`, `Lt`, etc.) falls back to scalar `LinearScan`. -@inline function _simd_supported_order(order::Base.Order.Ordering) - return order === Base.Order.Forward || order === Base.Order.Reverse -end - -function Base.searchsortedlast( - ::SIMDLinearScan, v::DenseVector{Int64}, x::Int64, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - _simd_supported_order(order) || - return searchsortedlast(LinearScan(), v, x, hint; order = order) - return _simdscan_last_specialized(v, x, hint, order) -end -function Base.searchsortedlast( - ::SIMDLinearScan, v::DenseVector{Float64}, x::Float64, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - _simd_supported_order(order) || - return searchsortedlast(LinearScan(), v, x, hint; order = order) - return _simdscan_last_specialized(v, x, hint, order) -end -function Base.searchsortedfirst( - ::SIMDLinearScan, v::DenseVector{Int64}, x::Int64, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - _simd_supported_order(order) || - return searchsortedfirst(LinearScan(), v, x, hint; order = order) - return _simdscan_first_specialized(v, x, hint, order) -end -function Base.searchsortedfirst( - ::SIMDLinearScan, v::DenseVector{Float64}, x::Float64, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - _simd_supported_order(order) || - return searchsortedfirst(LinearScan(), v, x, hint; order = order) - return _simdscan_first_specialized(v, x, hint, order) -end - -# Other eltypes fall back to the scalar LinearScan walk. -Base.searchsortedlast( - ::SIMDLinearScan, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(LinearScan(), v, x, hint; order = order) -Base.searchsortedfirst( - ::SIMDLinearScan, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(LinearScan(), v, x, hint; order = order) - -# No hint → BinaryBracket. -Base.searchsortedlast( - ::SIMDLinearScan, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::SIMDLinearScan, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# =========================================================================== -# Strategy: BracketGallop — bracketstrictlymonotonic + bounded binary search -# =========================================================================== - -function Base.searchsortedlast( - ::BracketGallop, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = bracketstrictlymonotonic(v, x, hint, order) - return searchsortedlast(v, x, lo, hi, order) -end - -function Base.searchsortedfirst( - ::BracketGallop, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = bracketstrictlymonotonic_first(v, x, hint, order) - return searchsortedfirst(v, x, lo, hi, order) -end - -# BracketGallop without a hint falls back to BinaryBracket. -Base.searchsortedlast( - ::BracketGallop, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::BracketGallop, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# =========================================================================== -# Strategy: ExpFromLeft — galloping forward from a left-bound hint. -# -# Contract: callers pass `hint` such that the answer is ≥ `hint`. When that -# isn't true (hint is past the answer), we fall back to a full -# `searchsortedlast`/`searchsortedfirst` — the batched-sorted loop sets -# `hint = prev_result`, which always satisfies this for sorted queries, so -# the fallback is only exercised by arbitrary single-query callers. -# =========================================================================== - -function Base.searchsortedfirst( - ::ExpFromLeft, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo = firstindex(v) - hi = lastindex(v) - if isempty(v) - return lo - end - h = clamp(hint, lo, hi) - # `searchsortedfirst` semantics: smallest i with `!lt(order, v[i], x)`. - # We can only gallop forward from `h` when `v[h]` is still "before" the - # answer in the ordering — `lt(order, v[h], x)`. Otherwise the first - # occurrence may be at index ≤ h (duplicates) and we'd skip past it. - @inbounds if !Base.Order.lt(order, v[h], x) - return searchsortedfirst(v, x, order) - end - return searchsortedfirstexp(v, x, h, hi, order) -end - -function Base.searchsortedlast( - ::ExpFromLeft, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo = firstindex(v) - hi = lastindex(v) - if isempty(v) - return lo - 1 - end - h = clamp(hint, lo, hi) - @inbounds if Base.Order.lt(order, x, v[h]) - return searchsortedlast(v, x, order) - end - return searchsortedlastexp(v, x, h, hi, order) -end - -# ExpFromLeft without a hint falls back to BinaryBracket. -Base.searchsortedlast( - ::ExpFromLeft, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::ExpFromLeft, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# =========================================================================== -# Strategy: InterpolationSearch — extrapolate a guess, then bounded binary -# search around it. -# =========================================================================== - -@inline function _interp_guess(v::AbstractVector, x, lo::Integer, hi::Integer) - @inbounds vlo = v[lo] - @inbounds vhi = v[hi] - span = vhi - vlo - iszero(span) && return lo - # Linear extrapolation: how far is x along [vlo, vhi]? - f = (x - vlo) / span - if !isfinite(f) - return f > 0 ? hi : lo - end - g = lo + round(Int, f * (hi - lo)) - return clamp(g, lo, hi) -end - -function Base.searchsortedlast( - ::InterpolationSearch, v::AbstractVector{<:Number}, x::Number; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = firstindex(v), lastindex(v) - hi < lo && return lo - 1 - # `_interp_guess` works for both `Forward` and `Reverse`: for a - # `Reverse`-sorted (decreasing) vector, `vhi - vlo < 0` and - # `x - vlo < 0` for queries inside `[vhi, vlo]`, so the ratio - # `(x - vlo) / (vhi - vlo)` still lands in `[0, 1]` and gives the - # correct fractional position. Falls back to `BinaryBracket` for any - # other ordering via `BracketGallop`. - g = _interp_guess(v, x, lo, hi) - return searchsortedlast(BracketGallop(), v, x, g; order = order) -end - -function Base.searchsortedfirst( - ::InterpolationSearch, v::AbstractVector{<:Number}, x::Number; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = firstindex(v), lastindex(v) - hi < lo && return lo - g = _interp_guess(v, x, lo, hi) - return searchsortedfirst(BracketGallop(), v, x, g; order = order) -end - -# InterpolationSearch ignores any hint; pass-through. -Base.searchsortedlast( - s::InterpolationSearch, v::AbstractVector{<:Number}, x::Number, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(s, v, x; order = order) -Base.searchsortedfirst( - s::InterpolationSearch, v::AbstractVector{<:Number}, x::Number, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(s, v, x; order = order) - -# InterpolationSearch on non-numeric data falls back to BinaryBracket. -Base.searchsortedlast( - ::InterpolationSearch, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::InterpolationSearch, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) -Base.searchsortedlast( - s::InterpolationSearch, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - s::InterpolationSearch, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# =========================================================================== -# Strategy: BitInterpolationSearch — InterpolationSearch on the IEEE bit -# pattern of positive Float64. Cheaper than reinterpret-as-array because we -# only need two endpoint reads and one query bitcast per call. Order-aware: -# under `Forward` (ascending bit patterns) and `Reverse` (descending bit -# patterns) the guess formula is the same fractional linear extrapolation, -# only the directionality of the comparison swaps. -# =========================================================================== - -@inline function _bit_interp_guess_f64( - v::DenseVector{Float64}, x::Float64, lo::Integer, hi::Integer, - order::Base.Order.Ordering, - ) - @inbounds vlo_bits = reinterpret(UInt64, v[lo]) - @inbounds vhi_bits = reinterpret(UInt64, v[hi]) - xu = reinterpret(UInt64, x) - return if order === Base.Order.Forward - # Forward: vlo_bits ≤ vhi_bits. Standard fractional interp on - # unsigned bit patterns. - span = vhi_bits - vlo_bits - if iszero(span) - lo - elseif xu <= vlo_bits - lo - elseif xu >= vhi_bits - hi - else - num = xu - vlo_bits - f = Float64(num) / Float64(span) - clamp(lo + round(Int, f * (hi - lo)), lo, hi) - end - else - # Reverse: vlo_bits ≥ vhi_bits. Mirror the arithmetic. - span = vlo_bits - vhi_bits - if iszero(span) - lo - elseif xu >= vlo_bits - lo - elseif xu <= vhi_bits - hi - else - num = vlo_bits - xu - f = Float64(num) / Float64(span) - clamp(lo + round(Int, f * (hi - lo)), lo, hi) - end - end -end - -# `Forward` requires both endpoints strictly positive (negative / subnormal -# / non-finite Float64 bit patterns are not monotonic with float value). -# `Reverse` requires the same on both endpoints. For unsupported orderings -# (custom `By`, `Lt`, …) fall back to `BinaryBracket`. -@inline function _bit_interp_eligible(v::DenseVector{Float64}, x::Float64, lo, hi, order) - _simd_supported_order(order) || return false - @inbounds return v[lo] > 0.0 && isfinite(v[lo]) && - v[hi] > 0.0 && isfinite(v[hi]) && - x > 0.0 && isfinite(x) -end - -function Base.searchsortedlast( - ::BitInterpolationSearch, v::DenseVector{Float64}, x::Float64; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = firstindex(v), lastindex(v) - hi < lo && return lo - 1 - _bit_interp_eligible(v, x, lo, hi, order) || - return searchsortedlast(BinaryBracket(), v, x; order = order) - g = _bit_interp_guess_f64(v, x, lo, hi, order) - return searchsortedlast(BracketGallop(), v, x, g; order = order) -end - -function Base.searchsortedfirst( - ::BitInterpolationSearch, v::DenseVector{Float64}, x::Float64; - order::Base.Order.Ordering = Base.Order.Forward, - ) - lo, hi = firstindex(v), lastindex(v) - hi < lo && return lo - _bit_interp_eligible(v, x, lo, hi, order) || - return searchsortedfirst(BinaryBracket(), v, x; order = order) - g = _bit_interp_guess_f64(v, x, lo, hi, order) - return searchsortedfirst(BracketGallop(), v, x, g; order = order) -end - -# Hint pass-through (bit-interp ignores externally-supplied hints). -Base.searchsortedlast( - s::BitInterpolationSearch, v::DenseVector{Float64}, x::Float64, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(s, v, x; order = order) -Base.searchsortedfirst( - s::BitInterpolationSearch, v::DenseVector{Float64}, x::Float64, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(s, v, x; order = order) - -# Non-Float64 / non-dense eltypes: fall back to plain InterpolationSearch. -Base.searchsortedlast( - ::BitInterpolationSearch, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(InterpolationSearch(), v, x; order = order) -Base.searchsortedfirst( - ::BitInterpolationSearch, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(InterpolationSearch(), v, x; order = order) -Base.searchsortedlast( - ::BitInterpolationSearch, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(InterpolationSearch(), v, x; order = order) -Base.searchsortedfirst( - ::BitInterpolationSearch, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(InterpolationSearch(), v, x; order = order) - -# =========================================================================== -# Strategy: UniformStep — O(1) direct-arithmetic lookup for AbstractRange. -# -# The answer is computed by solving the equation `r[i] = first(r) + (i - -# firstindex(r)) * step(r)`. For `searchsortedlast`, the answer is the -# largest `i` with `r[i] <= x` (under `Forward`) or `r[i] >= x` (under -# `Reverse`); for `searchsortedfirst`, the smallest `i` with the -# meets-or-passes condition. Either way it's a single floor/ceil + clamp. -# -# Same formula works for both `Forward` and `Reverse` ordering because -# `step(r)` carries the direction in its sign — a decreasing range has -# negative step, which flips the inequality when dividing. -# -# For non-Range vectors `step(v)` isn't defined, so the strategy falls -# back to `BinaryBracket`. For custom orderings (`By`, `Lt`, …) — also -# falls back, since the closed-form arithmetic assumes the underlying `<` -# ordering of `Forward` / `Reverse`. -# =========================================================================== - -@inline _uniformstep_supported_order(::Base.Order.ForwardOrdering) = true -@inline _uniformstep_supported_order(::Base.Order.ReverseOrdering) = true -@inline _uniformstep_supported_order(::Base.Order.Ordering) = false - -# The closed-form formula: `r[i] = first(r) + (i - firstindex(r)) * step(r)` -# solves for `i` as `firstindex(r) + fld((x - first(r)), step(r))` for -# `searchsortedlast`. `fld`/`cld` are defined for both integer and -# floating-point operands, keeping Int operands in exact Int math (matching -# Base's UnitRange{Int} fast path) while letting Float64 inputs use Float64 -# math directly. -# -# Float-eltype ranges (`StepRangeLen`, `LinRange`) suffer from the standard -# float-roundoff issue: e.g. `5.0 / 0.1 ≈ 49.9999…` so the naive `fld` -# returns 49 instead of 50. Base's range-aware `searchsortedlast` uses -# `TwicePrecision` internally to avoid this. We don't want to pay that -# arithmetic cost; instead we use the naive `fld` as a *rough* estimate -# and add a one-step correction by checking `r[i+1]` (or `r[i]`) against -# `x`. The correction is at most one increment in either direction because -# `fld` rounding error is bounded by one ulp of the divisor — much less -# than one step of the range. -@inline function _uniformstep_searchsortedlast( - r::AbstractRange, x, order::Base.Order.Ordering, - ) - isempty(r) && return firstindex(r) - 1 - s = step(r) - iszero(s) && return lastindex(r) - diff = x - first(r) - # Reject non-finite query positions upfront — NaN / Inf would propagate - # through fld unpredictably. - if diff isa AbstractFloat && !isfinite(diff) - return isnan(diff) ? (firstindex(r) - 1) : - (diff > 0) ⊻ (s < 0) ? lastindex(r) : firstindex(r) - 1 - end - nm1 = length(r) - 1 - f = fld(diff, s) - # Clamp before the correction step so we don't index off the array. - i = if f < 0 - firstindex(r) - 1 - elseif f >= nm1 - lastindex(r) - else - firstindex(r) + Int(f) - end - # Roundoff correction: float division can be one ulp off, so the - # computed index may be off by one. Compare against `r[i+1]` / - # `r[i]` using the order-aware predicate `!lt(order, x, ·)` ("v[·] is - # at or below the threshold under this ordering"). At most one - # increment in either direction. - @inbounds if i < lastindex(r) && !Base.Order.lt(order, x, r[i + 1]) - return i + 1 - elseif i >= firstindex(r) && i <= lastindex(r) && Base.Order.lt(order, x, r[i]) - return i - 1 - end - return i -end - -@inline function _uniformstep_searchsortedfirst( - r::AbstractRange, x, order::Base.Order.Ordering, - ) - isempty(r) && return firstindex(r) - s = step(r) - iszero(s) && return firstindex(r) - diff = x - first(r) - if diff isa AbstractFloat && !isfinite(diff) - return isnan(diff) ? (lastindex(r) + 1) : - (diff > 0) ⊻ (s < 0) ? lastindex(r) + 1 : firstindex(r) - end - nm1 = length(r) - 1 - f = cld(diff, s) - i = if f <= 0 - firstindex(r) - elseif f > nm1 - lastindex(r) + 1 - else - firstindex(r) + Int(f) - end - # Roundoff correction. searchsortedfirst's condition is "smallest i - # with `!lt(order, r[i], x)`" (`r[i] >= x` under Forward, `r[i] <= x` - # under Reverse). If `r[i-1]` already meets the condition the - # estimate is too high; if `r[i]` doesn't, the estimate is too low. - @inbounds if i > firstindex(r) && i <= lastindex(r) + 1 && - !Base.Order.lt(order, r[i - 1], x) - return i - 1 - end - @inbounds if i <= lastindex(r) && Base.Order.lt(order, r[i], x) - return i + 1 - end - return i -end - -Base.searchsortedlast( - s::UniformStep, v::AbstractRange, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = _uniformstep_supported_order(order) ? - _uniformstep_searchsortedlast(v, x, order) : - searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - s::UniformStep, v::AbstractRange, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = _uniformstep_supported_order(order) ? - _uniformstep_searchsortedfirst(v, x, order) : - searchsortedfirst(BinaryBracket(), v, x; order = order) - -# Hinted forms — UniformStep ignores hints (the closed form doesn't need one). -Base.searchsortedlast( - s::UniformStep, v::AbstractRange, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(s, v, x; order = order) -Base.searchsortedfirst( - s::UniformStep, v::AbstractRange, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(s, v, x; order = order) - -# Non-Range eltype: fall back to BinaryBracket. UniformStep's closed-form -# math requires `step(v)` to be exact, which is only true for AbstractRange. -Base.searchsortedlast( - ::UniformStep, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::UniformStep, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) -Base.searchsortedlast( - ::UniformStep, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::UniformStep, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# =========================================================================== -# Strategy: BisectThenSIMD — equality-search; positional dispatch falls back -# to BinaryBracket. (The `findequal(BisectThenSIMD(), v, x)` shortcut lives -# in `findequal.jl`.) -# =========================================================================== - -Base.searchsortedlast( - ::BisectThenSIMD, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::BisectThenSIMD, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) -Base.searchsortedlast( - s::BisectThenSIMD, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - s::BisectThenSIMD, v::AbstractVector, x, ::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) diff --git a/src/findequal.jl b/src/findequal.jl index c7a9807..8bf29ad 100644 --- a/src/findequal.jl +++ b/src/findequal.jl @@ -1,9 +1,11 @@ # Strategy-framework equality search: `findequal(strategy, v, x[, hint])`. # Returns an `Int` with the sentinel `firstindex(v) - 1` for "not found" -# (matching `Base.searchsortedlast`'s convention for "x precedes all of v"). +# (matching `Base.searchsortedlast`'s convention). +# +# Most strategies compose: run `search_first(strategy, v, x[, hint])` to +# find the candidate insertion point, then post-check whether `v[i] == x`. # The `BisectThenSIMD` shortcut for `DenseVector{Int64}` dispatches into -# `findfirstsortedequal` directly; every other strategy composes via -# `searchsortedfirst` + a post-check. +# `findfirstsortedequal` directly. """ findequal(strategy, v, x[, hint]; order = Base.Order.Forward) -> Int @@ -11,32 +13,28 @@ Return the index of `x` in sorted `v` if present, or the sentinel `firstindex(v) - 1` if `x` is absent. Type-stable `Int` return — the sentinel matches the convention `Base.searchsortedlast` already uses for -"x precedes all of v", so callers can test for "not found" with -`i < firstindex(v)`. +"x precedes all of v". -For vectors with 1-based indexing (the Julia default), the sentinel is -exactly `0`, which is also `searchsortedlast`'s "x precedes all" return. -For [OffsetArrays](https://github.com/JuliaArrays/OffsetArrays.jl) and any -other vector whose `firstindex` is not `1`, the sentinel adjusts -accordingly — e.g. for a vector with `firstindex == -3`, the sentinel is -`-4`. Always test against `firstindex(v) - 1` (or equivalently -`i < firstindex(v)`), not against the literal `0`. +The `strategy` argument can be: -Most strategies are handled generically: run -`searchsortedfirst(strategy, v, x[, hint])` to find the candidate insertion -point, then check whether `v[i]` actually equals `x`. The shortcut method -on [`BisectThenSIMD`](@ref) for `DenseVector{Int64}` skips the -`searchsortedfirst` path entirely and uses the dedicated bisect-then-SIMD -equality scan that backs [`findfirstsortedequal`](@ref). + - A singleton strategy struct (`BinaryBracket()`, `BracketGallop()`, + …) — back-compat with the v2 API. + - A [`StrategyKind`](@ref) enum value (`KIND_BRACKET_GALLOP`, …) — the + v3 preferred form. + - A stateful strategy (`Auto`, `GuesserHint`) — dispatched via + multimethod. -For unsorted vectors, use [`findfirstequal`](@ref) — it does not require -a sorted input and falls outside the strategy framework. +Most strategies are handled generically. The shortcut method on +[`BisectThenSIMD`](@ref) for `DenseVector{Int64}` skips the +`searchsortedfirst` path entirely. + +For unsorted vectors, use [`findfirstequal`](@ref). """ @inline function findequal( strategy::SearchStrategy, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return _findequal_generic(strategy, v, x, order) + return _findequal_generic_strategy(strategy, v, x, order) end @inline function findequal( @@ -47,7 +45,24 @@ end return _findequal_postcheck(v, x, i) end -@inline function _findequal_generic(strategy, v, x, order) +# Enum-tagged form. +@inline function findequal( + kind::StrategyKind, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) + i = search_first(kind, v, x; order = order) + return _findequal_postcheck(v, x, i) +end + +@inline function findequal( + kind::StrategyKind, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) + i = search_first(kind, v, x, hint; order = order) + return _findequal_postcheck(v, x, i) +end + +@inline function _findequal_generic_strategy(strategy, v, x, order) i = searchsortedfirst(strategy, v, x; order = order) return _findequal_postcheck(v, x, i) end @@ -59,31 +74,30 @@ end @inbounds return isequal(v[i], x) ? Int(i) : (firstindex(v) - 1) end -# Shortcut: BisectThenSIMD on DenseVector{Int64} uses the dedicated bisect- -# then-SIMD equality scan (same algorithm as `findfirstsortedequal`). +# Shortcut: BisectThenSIMD on DenseVector{Int64} uses the dedicated +# bisect-then-SIMD equality scan. function findequal( ::BisectThenSIMD, v::DenseVector{Int64}, x::Int64; order::Base.Order.Ordering = Base.Order.Forward, ) if order !== Base.Order.Forward - return _findequal_generic(BinaryBracket(), v, x, order) + return _findequal_postcheck(v, x, search_first(KIND_BINARY_BRACKET, v, x; order = order)) end r = findfirstsortedequal(x, v) return r === nothing ? (firstindex(v) - 1) : r end -# Hinted form ignores the hint — the bisect-then-SIMD algorithm does not -# benefit from a hint, and probing it would only waste cycles. findequal( s::BisectThenSIMD, v::DenseVector{Int64}, x::Int64, ::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) = findequal(s, v, x; order = order) -# Non-Int64 fallback for BisectThenSIMD: use BinaryBracket + post-check. +# Non-Int64 fallback for BisectThenSIMD. function findequal( ::BisectThenSIMD, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return _findequal_generic(BinaryBracket(), v, x, order) + i = search_first(KIND_BINARY_BRACKET, v, x; order = order) + return _findequal_postcheck(v, x, i) end findequal( s::BisectThenSIMD, v::AbstractVector, x, ::Integer; diff --git a/src/guesser.jl b/src/guesser.jl index 64da33a..0b4e2b3 100644 --- a/src/guesser.jl +++ b/src/guesser.jl @@ -1,15 +1,11 @@ # `Guesser` correlated-lookup helper + the public `looks_linear` probe it -# uses + the `GuesserHint` strategy dispatch that plugs a `Guesser` into the -# `searchsortedfirst`/`searchsortedlast` API. +# uses + the `GuesserHint` strategy dispatch that plugs a `Guesser` into +# the v3 `search_last` / `search_first` API. """ looks_linear(v; threshold = 1e-2) -Determine if the abscissae `v` are regularly distributed, taking the standard deviation of -the difference between the array of abscissae with respect to the straight line linking -its first and last elements, normalized by the range of `v`. If this standard deviation is -below the given `threshold`, the vector looks linear (return true). Internal function - -interface may change. +Determine if the abscissae `v` are regularly distributed. """ function looks_linear(v; threshold = 1.0e-2) length(v) <= 2 && return true @@ -25,15 +21,12 @@ end """ Guesser(v::AbstractVector; looks_linear_threshold = 1e-2) -Wrapper of the searched vector `v` which makes an informed guess for the next -correlated lookup by either +Wrapper of the searched vector `v` which makes an informed guess for the +next correlated lookup by either - - exploiting that `v` is sufficiently evenly spaced (linear-extrapolation guess), or + - exploiting that `v` is sufficiently evenly spaced (linear-extrapolation + guess), or - using the previous outcome (the cached `idx_prev`). - -Pass a `Guesser` to [`GuesserHint`](@ref) to use it as a search strategy with -the dispatched `Base.searchsortedlast` / -`Base.searchsortedfirst` API. """ struct Guesser{T <: AbstractVector} v::T @@ -70,40 +63,54 @@ function (g::Guesser)(x) end end -# Note on ranges: `Base.searchsortedlast(r::AbstractRange, x, order)` is -# already O(1) (closed-form), so the strategies' fallback path through -# `BinaryBracket` (which delegates to that Base method) is already optimal -# for ranges. No special-case overlays needed. +# GuesserHint methods — stateful strategy, dispatches via its wrapper +# struct (not via a `StrategyKind`). The cost per call is one +# `guesser(x)` + one BracketGallop call + one `idx_prev[]` write. -# GuesserHint methods — strategy dispatch wrapper for the `Guesser`-based -# correlated search. Per-call cost: one `guesser(x)` evaluation + one -# `BracketGallop` call + one `idx_prev[]` write. -function Base.searchsortedlast( +@inline function search_last( s::GuesserHint, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) @assert v === s.guesser.v - out = searchsortedlast(BracketGallop(), v, x, s.guesser(x); order = order) + out = search_last(KIND_BRACKET_GALLOP, v, x, s.guesser(x); order = order) s.guesser.idx_prev[] = out return out end -function Base.searchsortedfirst( +@inline function search_first( s::GuesserHint, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) @assert v === s.guesser.v - out = searchsortedfirst(BracketGallop(), v, x, s.guesser(x); order = order) + out = search_first(KIND_BRACKET_GALLOP, v, x, s.guesser(x); order = order) s.guesser.idx_prev[] = out return out end -# GuesserHint ignores any externally-supplied hint (the Guesser carries its own). -Base.searchsortedlast( +# GuesserHint ignores any externally-supplied hint. +@inline search_last( s::GuesserHint, v::AbstractVector, x, ::Integer; order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(s, v, x; order = order) -Base.searchsortedfirst( +) = search_last(s, v, x; order = order) +@inline search_first( s::GuesserHint, v::AbstractVector, x, ::Integer; order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(s, v, x; order = order) +) = search_first(s, v, x; order = order) + +# `Base.searchsortedlast(::GuesserHint, ...)` shims for back-compat. +Base.searchsortedlast( + s::GuesserHint, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_last(s, v, x; order = order) +Base.searchsortedfirst( + s::GuesserHint, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_first(s, v, x; order = order) +Base.searchsortedlast( + s::GuesserHint, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_last(s, v, x, hint; order = order) +Base.searchsortedfirst( + s::GuesserHint, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_first(s, v, x, hint; order = order) diff --git a/src/kernels.jl b/src/kernels.jl new file mode 100644 index 0000000..3d571d3 --- /dev/null +++ b/src/kernels.jl @@ -0,0 +1,568 @@ +# Kernel functions for each singleton strategy. Each kernel is a free +# function that performs the strategy's algorithm directly — no method +# dispatch, no Union returns, no wrapper struct. +# +# The kernels are called from the enum dispatcher in `kinds.jl` and from +# the legacy `Base.searchsortedlast(::S, ...)` shims in `legacy_dispatch.jl`. +# `Auto` and `GuesserHint` also call them (directly, by kind, for `Auto`; +# via the kind dispatcher, for `GuesserHint`). + +# =========================================================================== +# Bracket helpers — backing `BracketGallop` +# =========================================================================== + +# Expanding-binary-search bracket around a guess. The `searchsortedlast` +# polarity: when `x == v[guess]`, the answer is `>= guess` (gallop right). +function bracketstrictlymonotonic( + v::AbstractVector, + x, + guess::T, + o::Base.Order.Ordering, + )::NTuple{2, keytype(v)} where {T <: Integer} + bottom = firstindex(v) + top = lastindex(v) + if guess < bottom || guess > top + return bottom, top + else + u = T(1) + lo, hi = guess, min(guess + u, top) + @inbounds if Base.Order.lt(o, x, v[lo]) + while lo > bottom && Base.Order.lt(o, x, v[lo]) + lo, hi = max(bottom, lo - u), lo + u += u + end + else + while hi < top && !Base.Order.lt(o, x, v[hi]) + lo, hi = hi, min(top, hi + u) + u += u + end + end + end + return lo, hi +end + +# Companion to `bracketstrictlymonotonic` for the `searchsortedfirst` +# polarity. When `x == v[lo]` the answer is `<= lo` (look for earlier +# duplicates) — so we use the inverted polarity `lt(o, v[lo], x)`. +function bracketstrictlymonotonic_first( + v::AbstractVector, + x, + guess::T, + o::Base.Order.Ordering, + )::NTuple{2, keytype(v)} where {T <: Integer} + bottom = firstindex(v) + top = lastindex(v) + if guess < bottom || guess > top + return bottom, top + else + u = T(1) + lo, hi = guess, min(guess + u, top) + @inbounds if !Base.Order.lt(o, v[lo], x) + while lo > bottom && !Base.Order.lt(o, v[lo], x) + lo, hi = max(bottom, lo - u), lo + u += u + end + else + while hi < top && Base.Order.lt(o, v[hi], x) + lo, hi = hi, min(top, hi + u) + u += u + end + end + end + return lo, hi +end + +# =========================================================================== +# Exponential-search helpers — backing `ExpFromLeft` +# =========================================================================== + +# Finds the smallest index `y` in `[lo, hi]` with `!lt(order, v[y], x)`. +Base.@propagate_inbounds function searchsortedfirstexp( + v::AbstractVector, + x, + lo::Integer = firstindex(v), + hi::Integer = lastindex(v), + order::Base.Order.Ordering = Base.Order.Forward, + ) + for i in 0:4 + ind = lo + i + ind > hi && return ind + !Base.Order.lt(order, v[ind], x) && return ind + end + n = 3 + tn2 = 2^n + tn2m1 = 2^(n - 1) + ind = lo + tn2 + while ind <= hi + !Base.Order.lt(order, v[ind], x) && + return searchsortedfirst(v, x, lo + tn2 - tn2m1, ind, order) + tn2 *= 2 + tn2m1 *= 2 + ind = lo + tn2 + end + return searchsortedfirst(v, x, lo + tn2 - tn2m1, hi, order) +end + +# Finds the largest `y` in `[lo, hi]` with `!lt(order, x, v[y])`. +Base.@propagate_inbounds function searchsortedlastexp( + v::AbstractVector, + x, + lo::Integer = firstindex(v), + hi::Integer = lastindex(v), + order::Base.Order.Ordering = Base.Order.Forward, + ) + for i in 0:4 + ind = lo + i + ind > hi && return hi + Base.Order.lt(order, x, v[ind]) && return ind - 1 + end + n = 3 + tn2 = 2^n + tn2m1 = 2^(n - 1) + ind = lo + tn2 + while ind <= hi + Base.Order.lt(order, x, v[ind]) && + return searchsortedlast(v, x, lo + tn2 - tn2m1, ind, order) + tn2 *= 2 + tn2m1 *= 2 + ind = lo + tn2 + end + return searchsortedlast(v, x, lo + tn2 - tn2m1, hi, order) +end + +# =========================================================================== +# Kernel: BinaryBracket — plain `Base.searchsortedlast` / `Base.searchsortedfirst`. +# =========================================================================== + +@inline _kernel_last_binary_bracket(v::AbstractVector, x, order::Base.Order.Ordering) = + searchsortedlast(v, x, order) +@inline _kernel_first_binary_bracket(v::AbstractVector, x, order::Base.Order.Ordering) = + searchsortedfirst(v, x, order) + +# =========================================================================== +# Kernel: LinearScan — walk ±1 from the hint. +# =========================================================================== + +function _kernel_last_linear_scan( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, + ) + lo, hi = firstindex(v), lastindex(v) + if hi < lo + return lo - 1 # empty vector + end + i = clamp(hint, lo, hi) + @inbounds if Base.Order.lt(order, x, v[i]) + # v[i] > x → retreat + while i > lo + i -= 1 + !Base.Order.lt(order, x, v[i]) && return i + end + return lo - 1 # x precedes all of v + else + # v[i] ≤ x → try to advance + while i < hi + Base.Order.lt(order, x, v[i + 1]) && return i + i += 1 + end + return hi + end +end + +function _kernel_first_linear_scan( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, + ) + lo, hi = firstindex(v), lastindex(v) + if hi < lo + return lo + end + i = clamp(hint, lo, hi) + @inbounds if Base.Order.lt(order, v[i], x) + # v[i] < x → advance + while i < hi + i += 1 + !Base.Order.lt(order, v[i], x) && return i + end + return hi + 1 # x exceeds all of v + else + # v[i] ≥ x → try to retreat + while i > lo + !Base.Order.lt(order, v[i - 1], x) && (i -= 1; continue) + return i + end + return lo + end +end + +# =========================================================================== +# Kernel: SIMDLinearScan — specialized forward walk for DenseVector{Int64} +# and DenseVector{Float64}. Falls back to scalar LinearScan otherwise. +# =========================================================================== + +@inline function _simdscan_last_specialized( + v::Union{DenseVector{Int64}, DenseVector{Float64}}, + x, hint::Integer, + order::Base.Order.Ordering, + ) + lo = firstindex(v) + hi = lastindex(v) + hi < lo && return lo - 1 + i = clamp(hint, lo, hi) + @inbounds vi = v[i] + if Base.Order.lt(order, x, vi) + # `v[i]` is past the answer in this ordering — backward walk (scalar). + while i > lo + i -= 1 + @inbounds !Base.Order.lt(order, x, v[i]) && return i + end + return lo - 1 + end + i == hi && return hi + start = i + 1 + len = hi - start + 1 + offset = if order === Base.Order.Forward + GC.@preserve v _simd_first_gt(x, pointer(v, start), Int64(len)) + else + GC.@preserve v _simd_first_lt(x, pointer(v, start), Int64(len)) + end + return offset < 0 ? hi : (start + offset) - 1 +end + +@inline function _simdscan_first_specialized( + v::Union{DenseVector{Int64}, DenseVector{Float64}}, + x, hint::Integer, + order::Base.Order.Ordering, + ) + lo = firstindex(v) + hi = lastindex(v) + hi < lo && return lo + i = clamp(hint, lo, hi) + @inbounds vi = v[i] + if Base.Order.lt(order, vi, x) + i == hi && return hi + 1 + start = i + 1 + len = hi - start + 1 + offset = if order === Base.Order.Forward + GC.@preserve v _simd_first_ge(x, pointer(v, start), Int64(len)) + else + GC.@preserve v _simd_first_le(x, pointer(v, start), Int64(len)) + end + return offset < 0 ? hi + 1 : start + offset + end + while i > lo + @inbounds Base.Order.lt(order, v[i - 1], x) && return i + i -= 1 + end + return lo +end + +# Whether the ordering is one of the two ordering singletons SIMD supports. +@inline function _simd_supported_order(order::Base.Order.Ordering) + return order === Base.Order.Forward || order === Base.Order.Reverse +end + +# Static-dispatch entry: Int64 and Float64 dense vectors get the SIMD path +# (under supported orderings); everything else falls back to scalar LinearScan. +@inline function _kernel_last_simd_linear_scan( + v::DenseVector{Int64}, x::Int64, hint::Integer, order::Base.Order.Ordering, + ) + _simd_supported_order(order) || + return _kernel_last_linear_scan(v, x, hint, order) + return _simdscan_last_specialized(v, x, hint, order) +end +@inline function _kernel_last_simd_linear_scan( + v::DenseVector{Float64}, x::Float64, hint::Integer, order::Base.Order.Ordering, + ) + _simd_supported_order(order) || + return _kernel_last_linear_scan(v, x, hint, order) + return _simdscan_last_specialized(v, x, hint, order) +end +@inline _kernel_last_simd_linear_scan( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, +) = _kernel_last_linear_scan(v, x, hint, order) + +@inline function _kernel_first_simd_linear_scan( + v::DenseVector{Int64}, x::Int64, hint::Integer, order::Base.Order.Ordering, + ) + _simd_supported_order(order) || + return _kernel_first_linear_scan(v, x, hint, order) + return _simdscan_first_specialized(v, x, hint, order) +end +@inline function _kernel_first_simd_linear_scan( + v::DenseVector{Float64}, x::Float64, hint::Integer, order::Base.Order.Ordering, + ) + _simd_supported_order(order) || + return _kernel_first_linear_scan(v, x, hint, order) + return _simdscan_first_specialized(v, x, hint, order) +end +@inline _kernel_first_simd_linear_scan( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, +) = _kernel_first_linear_scan(v, x, hint, order) + +# =========================================================================== +# Kernel: BracketGallop — bracketstrictlymonotonic + bounded binary search. +# =========================================================================== + +@inline function _kernel_last_bracket_gallop( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, + ) + lo, hi = bracketstrictlymonotonic(v, x, hint, order) + return searchsortedlast(v, x, lo, hi, order) +end + +@inline function _kernel_first_bracket_gallop( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, + ) + lo, hi = bracketstrictlymonotonic_first(v, x, hint, order) + return searchsortedfirst(v, x, lo, hi, order) +end + +# =========================================================================== +# Kernel: ExpFromLeft — galloping forward from a left-bound hint. +# =========================================================================== + +function _kernel_first_exp_from_left( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, + ) + lo = firstindex(v) + hi = lastindex(v) + if isempty(v) + return lo + end + h = clamp(hint, lo, hi) + @inbounds if !Base.Order.lt(order, v[h], x) + return searchsortedfirst(v, x, order) + end + return searchsortedfirstexp(v, x, h, hi, order) +end + +function _kernel_last_exp_from_left( + v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, + ) + lo = firstindex(v) + hi = lastindex(v) + if isempty(v) + return lo - 1 + end + h = clamp(hint, lo, hi) + @inbounds if Base.Order.lt(order, x, v[h]) + return searchsortedlast(v, x, order) + end + return searchsortedlastexp(v, x, h, hi, order) +end + +# =========================================================================== +# Kernel: InterpolationSearch — extrapolate a guess + bounded binary search. +# =========================================================================== + +@inline function _interp_guess(v::AbstractVector, x, lo::Integer, hi::Integer) + @inbounds vlo = v[lo] + @inbounds vhi = v[hi] + span = vhi - vlo + iszero(span) && return lo + f = (x - vlo) / span + if !isfinite(f) + return f > 0 ? hi : lo + end + g = lo + round(Int, f * (hi - lo)) + return clamp(g, lo, hi) +end + +function _kernel_last_interpolation_search_numeric( + v::AbstractVector{<:Number}, x::Number, order::Base.Order.Ordering, + ) + lo, hi = firstindex(v), lastindex(v) + hi < lo && return lo - 1 + g = _interp_guess(v, x, lo, hi) + return _kernel_last_bracket_gallop(v, x, g, order) +end + +function _kernel_first_interpolation_search_numeric( + v::AbstractVector{<:Number}, x::Number, order::Base.Order.Ordering, + ) + lo, hi = firstindex(v), lastindex(v) + hi < lo && return lo + g = _interp_guess(v, x, lo, hi) + return _kernel_first_bracket_gallop(v, x, g, order) +end + +@inline _kernel_last_interpolation_search( + v::AbstractVector{<:Number}, x::Number, order::Base.Order.Ordering, +) = _kernel_last_interpolation_search_numeric(v, x, order) +@inline _kernel_last_interpolation_search( + v::AbstractVector, x, order::Base.Order.Ordering, +) = _kernel_last_binary_bracket(v, x, order) + +@inline _kernel_first_interpolation_search( + v::AbstractVector{<:Number}, x::Number, order::Base.Order.Ordering, +) = _kernel_first_interpolation_search_numeric(v, x, order) +@inline _kernel_first_interpolation_search( + v::AbstractVector, x, order::Base.Order.Ordering, +) = _kernel_first_binary_bracket(v, x, order) + +# =========================================================================== +# Kernel: BitInterpolationSearch — InterpolationSearch on IEEE bit pattern +# of positive Float64. +# =========================================================================== + +@inline function _bit_interp_guess_f64( + v::DenseVector{Float64}, x::Float64, lo::Integer, hi::Integer, + order::Base.Order.Ordering, + ) + @inbounds vlo_bits = reinterpret(UInt64, v[lo]) + @inbounds vhi_bits = reinterpret(UInt64, v[hi]) + xu = reinterpret(UInt64, x) + return if order === Base.Order.Forward + span = vhi_bits - vlo_bits + if iszero(span) + lo + elseif xu <= vlo_bits + lo + elseif xu >= vhi_bits + hi + else + num = xu - vlo_bits + f = Float64(num) / Float64(span) + clamp(lo + round(Int, f * (hi - lo)), lo, hi) + end + else + span = vlo_bits - vhi_bits + if iszero(span) + lo + elseif xu >= vlo_bits + lo + elseif xu <= vhi_bits + hi + else + num = vlo_bits - xu + f = Float64(num) / Float64(span) + clamp(lo + round(Int, f * (hi - lo)), lo, hi) + end + end +end + +@inline function _bit_interp_eligible(v::DenseVector{Float64}, x::Float64, lo, hi, order) + _simd_supported_order(order) || return false + @inbounds return v[lo] > 0.0 && isfinite(v[lo]) && + v[hi] > 0.0 && isfinite(v[hi]) && + x > 0.0 && isfinite(x) +end + +function _kernel_last_bit_interpolation_search_f64( + v::DenseVector{Float64}, x::Float64, order::Base.Order.Ordering, + ) + lo, hi = firstindex(v), lastindex(v) + hi < lo && return lo - 1 + _bit_interp_eligible(v, x, lo, hi, order) || + return _kernel_last_binary_bracket(v, x, order) + g = _bit_interp_guess_f64(v, x, lo, hi, order) + return _kernel_last_bracket_gallop(v, x, g, order) +end + +function _kernel_first_bit_interpolation_search_f64( + v::DenseVector{Float64}, x::Float64, order::Base.Order.Ordering, + ) + lo, hi = firstindex(v), lastindex(v) + hi < lo && return lo + _bit_interp_eligible(v, x, lo, hi, order) || + return _kernel_first_binary_bracket(v, x, order) + g = _bit_interp_guess_f64(v, x, lo, hi, order) + return _kernel_first_bracket_gallop(v, x, g, order) +end + +@inline _kernel_last_bit_interpolation_search( + v::DenseVector{Float64}, x::Float64, order::Base.Order.Ordering, +) = _kernel_last_bit_interpolation_search_f64(v, x, order) +@inline _kernel_last_bit_interpolation_search( + v::AbstractVector, x, order::Base.Order.Ordering, +) = _kernel_last_interpolation_search(v, x, order) + +@inline _kernel_first_bit_interpolation_search( + v::DenseVector{Float64}, x::Float64, order::Base.Order.Ordering, +) = _kernel_first_bit_interpolation_search_f64(v, x, order) +@inline _kernel_first_bit_interpolation_search( + v::AbstractVector, x, order::Base.Order.Ordering, +) = _kernel_first_interpolation_search(v, x, order) + +# =========================================================================== +# Kernel: UniformStep — O(1) closed-form lookup for AbstractRange. +# =========================================================================== + +@inline _uniformstep_supported_order(::Base.Order.ForwardOrdering) = true +@inline _uniformstep_supported_order(::Base.Order.ReverseOrdering) = true +@inline _uniformstep_supported_order(::Base.Order.Ordering) = false + +@inline function _uniformstep_searchsortedlast( + r::AbstractRange, x, order::Base.Order.Ordering, + ) + isempty(r) && return firstindex(r) - 1 + s = step(r) + iszero(s) && return lastindex(r) + diff = x - first(r) + if diff isa AbstractFloat && !isfinite(diff) + return isnan(diff) ? (firstindex(r) - 1) : + (diff > 0) ⊻ (s < 0) ? lastindex(r) : firstindex(r) - 1 + end + nm1 = length(r) - 1 + f = fld(diff, s) + i = if f < 0 + firstindex(r) - 1 + elseif f >= nm1 + lastindex(r) + else + firstindex(r) + Int(f) + end + @inbounds if i < lastindex(r) && !Base.Order.lt(order, x, r[i + 1]) + return i + 1 + elseif i >= firstindex(r) && i <= lastindex(r) && Base.Order.lt(order, x, r[i]) + return i - 1 + end + return i +end + +@inline function _uniformstep_searchsortedfirst( + r::AbstractRange, x, order::Base.Order.Ordering, + ) + isempty(r) && return firstindex(r) + s = step(r) + iszero(s) && return firstindex(r) + diff = x - first(r) + if diff isa AbstractFloat && !isfinite(diff) + return isnan(diff) ? (lastindex(r) + 1) : + (diff > 0) ⊻ (s < 0) ? lastindex(r) + 1 : firstindex(r) + end + nm1 = length(r) - 1 + f = cld(diff, s) + i = if f <= 0 + firstindex(r) + elseif f > nm1 + lastindex(r) + 1 + else + firstindex(r) + Int(f) + end + @inbounds if i > firstindex(r) && i <= lastindex(r) + 1 && + !Base.Order.lt(order, r[i - 1], x) + return i - 1 + end + @inbounds if i <= lastindex(r) && Base.Order.lt(order, r[i], x) + return i + 1 + end + return i +end + +@inline _kernel_last_uniform_step( + v::AbstractRange, x, order::Base.Order.Ordering, +) = _uniformstep_supported_order(order) ? + _uniformstep_searchsortedlast(v, x, order) : + _kernel_last_binary_bracket(v, x, order) +@inline _kernel_last_uniform_step( + v::AbstractVector, x, order::Base.Order.Ordering, +) = _kernel_last_binary_bracket(v, x, order) + +@inline _kernel_first_uniform_step( + v::AbstractRange, x, order::Base.Order.Ordering, +) = _uniformstep_supported_order(order) ? + _uniformstep_searchsortedfirst(v, x, order) : + _kernel_first_binary_bracket(v, x, order) +@inline _kernel_first_uniform_step( + v::AbstractVector, x, order::Base.Order.Ordering, +) = _kernel_first_binary_bracket(v, x, order) diff --git a/src/kinds.jl b/src/kinds.jl new file mode 100644 index 0000000..1175548 --- /dev/null +++ b/src/kinds.jl @@ -0,0 +1,235 @@ +# Enum-tagged dispatch for singleton search strategies. +# +# Each value of `StrategyKind` names one of the singleton, zero-state +# strategies. The pair `search_last` / `search_first` is the single public +# entry point: a runtime-tag dispatcher that branches on the enum and +# inlines into the matching kernel function (defined in `kernels.jl`). +# +# The runtime-branch bench (DataInterpolations.jl PR #531) confirmed that +# an `if/elseif/...` over a `StrategyKind` value is ~0 ns overhead in hot +# loops because the branch is well-predicted (or eliminated by constant +# propagation when the kind is known at the call site), the kernel bodies +# inline, and the return path stays type-stable — none of the Union-return +# pathology that the old type-parameter dispatch over `SearchStrategy` +# subtypes suffered from when the chosen strategy depended on runtime data. +# +# Stateful strategies (`GuesserHint(::Guesser)`) do *not* live in the enum. +# They carry per-instance data, so a singleton tag would lose information. +# Instead they dispatch directly via their wrapper struct +# (`search_last(::GuesserHint, ...)`). + +""" + StrategyKind + +Enum tag identifying a singleton search strategy. Use values of this +enum as the first positional argument to [`search_last`](@ref) and +[`search_first`](@ref): + +```julia +search_last(KIND_BRACKET_GALLOP, v, x, hint) +``` + +Each tag corresponds to one of the singleton strategy types +(e.g. `KIND_BRACKET_GALLOP` ↔ `BracketGallop`). Stateful strategies +(`GuesserHint`) do not have an enum tag — they dispatch through their +wrapper struct directly. + +The enum is stored as `UInt8` so passing it through to dispatcher +functions costs the same as a `Bool` and does not enlarge any struct +that carries it. +""" +@enum StrategyKind::UInt8 begin + KIND_BINARY_BRACKET + KIND_LINEAR_SCAN + KIND_SIMD_LINEAR_SCAN + KIND_BRACKET_GALLOP + KIND_EXP_FROM_LEFT + KIND_INTERPOLATION_SEARCH + KIND_BIT_INTERPOLATION_SEARCH + KIND_UNIFORM_STEP + KIND_BISECT_THEN_SIMD +end + +""" + search_last(kind::StrategyKind, v, x[, hint]; order = Base.Order.Forward) + search_last(s, v, x[, hint]; order = Base.Order.Forward) + +FFF-owned positional search for the largest index `i` with `v[i] ≤ x` +under `order` (or `v[i] ≥ x` under `Base.Order.Reverse`). The polarity +matches `Base.searchsortedlast`. + +When the first argument is a [`StrategyKind`](@ref) value the call +dispatches via a runtime `if/elseif` branch on the enum into the matching +kernel. When the first argument is a stateful strategy wrapper (`Auto`, +`GuesserHint`) the call dispatches via multimethod into that wrapper's +own `search_last` method. + +This is the preferred entry point for new code. The legacy +`Base.searchsortedlast(::SearchStrategy, ...)` methods still work for +back-compat but are deprecated and emit a depwarn on first use. +""" +@inline function search_last( + kind::StrategyKind, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) + return _search_last_nohint(kind, v, x, order) +end + +@inline function search_last( + kind::StrategyKind, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) + return _search_last_hinted(kind, v, x, hint, order) +end + +""" + search_first(kind::StrategyKind, v, x[, hint]; order = Base.Order.Forward) + search_first(s, v, x[, hint]; order = Base.Order.Forward) + +FFF-owned positional search for the smallest index `i` with `v[i] ≥ x` +under `order` (or `v[i] ≤ x` under `Base.Order.Reverse`). See +[`search_last`](@ref) for the dispatch story. +""" +@inline function search_first( + kind::StrategyKind, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) + return _search_first_nohint(kind, v, x, order) +end + +@inline function search_first( + kind::StrategyKind, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) + return _search_first_hinted(kind, v, x, hint, order) +end + +# --------------------------------------------------------------------------- +# The dispatch switches. One per (polarity, hint-presence) pair. Each one +# is a single `if/elseif/.../end` over the enum value — the branch is +# well-predicted at runtime and the body inlines. +# +# The kernel-function names follow a consistent scheme: +# `_kernel_last_` / `_kernel_first_` +# The "no hint" entry points for hint-using strategies fall back to +# BinaryBracket internally (the existing semantics), and the hinted +# entry points for hint-ignoring strategies (BinaryBracket, BisectThenSIMD) +# discard the hint. +# --------------------------------------------------------------------------- + +@inline function _search_last_hinted( + kind::StrategyKind, v::AbstractVector, x, hint::Integer, + order::Base.Order.Ordering, + ) + if kind === KIND_BINARY_BRACKET + return _kernel_last_binary_bracket(v, x, order) + elseif kind === KIND_LINEAR_SCAN + return _kernel_last_linear_scan(v, x, hint, order) + elseif kind === KIND_SIMD_LINEAR_SCAN + return _kernel_last_simd_linear_scan(v, x, hint, order) + elseif kind === KIND_BRACKET_GALLOP + return _kernel_last_bracket_gallop(v, x, hint, order) + elseif kind === KIND_EXP_FROM_LEFT + return _kernel_last_exp_from_left(v, x, hint, order) + elseif kind === KIND_INTERPOLATION_SEARCH + return _kernel_last_interpolation_search(v, x, order) + elseif kind === KIND_BIT_INTERPOLATION_SEARCH + return _kernel_last_bit_interpolation_search(v, x, order) + elseif kind === KIND_UNIFORM_STEP + return _kernel_last_uniform_step(v, x, order) + else + # KIND_BISECT_THEN_SIMD — equality-search strategy; positional + # dispatch falls back to BinaryBracket. + return _kernel_last_binary_bracket(v, x, order) + end +end + +@inline function _search_last_nohint( + kind::StrategyKind, v::AbstractVector, x, + order::Base.Order.Ordering, + ) + if kind === KIND_BINARY_BRACKET + return _kernel_last_binary_bracket(v, x, order) + elseif kind === KIND_LINEAR_SCAN + # No hint → BinaryBracket fallback. + return _kernel_last_binary_bracket(v, x, order) + elseif kind === KIND_SIMD_LINEAR_SCAN + return _kernel_last_binary_bracket(v, x, order) + elseif kind === KIND_BRACKET_GALLOP + return _kernel_last_binary_bracket(v, x, order) + elseif kind === KIND_EXP_FROM_LEFT + return _kernel_last_binary_bracket(v, x, order) + elseif kind === KIND_INTERPOLATION_SEARCH + return _kernel_last_interpolation_search(v, x, order) + elseif kind === KIND_BIT_INTERPOLATION_SEARCH + return _kernel_last_bit_interpolation_search(v, x, order) + elseif kind === KIND_UNIFORM_STEP + return _kernel_last_uniform_step(v, x, order) + else + return _kernel_last_binary_bracket(v, x, order) + end +end + +@inline function _search_first_hinted( + kind::StrategyKind, v::AbstractVector, x, hint::Integer, + order::Base.Order.Ordering, + ) + if kind === KIND_BINARY_BRACKET + return _kernel_first_binary_bracket(v, x, order) + elseif kind === KIND_LINEAR_SCAN + return _kernel_first_linear_scan(v, x, hint, order) + elseif kind === KIND_SIMD_LINEAR_SCAN + return _kernel_first_simd_linear_scan(v, x, hint, order) + elseif kind === KIND_BRACKET_GALLOP + return _kernel_first_bracket_gallop(v, x, hint, order) + elseif kind === KIND_EXP_FROM_LEFT + return _kernel_first_exp_from_left(v, x, hint, order) + elseif kind === KIND_INTERPOLATION_SEARCH + return _kernel_first_interpolation_search(v, x, order) + elseif kind === KIND_BIT_INTERPOLATION_SEARCH + return _kernel_first_bit_interpolation_search(v, x, order) + elseif kind === KIND_UNIFORM_STEP + return _kernel_first_uniform_step(v, x, order) + else + return _kernel_first_binary_bracket(v, x, order) + end +end + +@inline function _search_first_nohint( + kind::StrategyKind, v::AbstractVector, x, + order::Base.Order.Ordering, + ) + if kind === KIND_BINARY_BRACKET + return _kernel_first_binary_bracket(v, x, order) + elseif kind === KIND_LINEAR_SCAN + return _kernel_first_binary_bracket(v, x, order) + elseif kind === KIND_SIMD_LINEAR_SCAN + return _kernel_first_binary_bracket(v, x, order) + elseif kind === KIND_BRACKET_GALLOP + return _kernel_first_binary_bracket(v, x, order) + elseif kind === KIND_EXP_FROM_LEFT + return _kernel_first_binary_bracket(v, x, order) + elseif kind === KIND_INTERPOLATION_SEARCH + return _kernel_first_interpolation_search(v, x, order) + elseif kind === KIND_BIT_INTERPOLATION_SEARCH + return _kernel_first_bit_interpolation_search(v, x, order) + elseif kind === KIND_UNIFORM_STEP + return _kernel_first_uniform_step(v, x, order) + else + return _kernel_first_binary_bracket(v, x, order) + end +end + +# --------------------------------------------------------------------------- +# Per-strategy kind lookup. Used by the deprecation shims in +# `legacy_dispatch.jl` and by callers that need to convert a strategy +# struct to its enum tag. +# --------------------------------------------------------------------------- + +""" + strategy_kind(s::SearchStrategy) -> StrategyKind + +Map a singleton strategy struct to its enum tag. Stateful strategies +(`GuesserHint`, `Auto`) do not have a tag and throw `ArgumentError`. +""" +function strategy_kind end diff --git a/src/legacy_dispatch.jl b/src/legacy_dispatch.jl new file mode 100644 index 0000000..440060b --- /dev/null +++ b/src/legacy_dispatch.jl @@ -0,0 +1,74 @@ +# Back-compat shims: `Base.searchsortedlast(::S, ...)` / +# `Base.searchsortedfirst(::S, ...)` for the singleton strategy structs +# (`BinaryBracket`, `LinearScan`, …). Each shim forwards to the +# corresponding `search_last(KIND_X, ...)` / `search_first(KIND_X, ...)` +# call, so the enum dispatcher is the single source of truth. +# +# These shims are scheduled for removal in the next major version (v4). +# New code should call `search_last` / `search_first` with a +# `StrategyKind` value directly. +# +# Each shim emits a `Base.depwarn` once per call site (the `depwarn` +# infrastructure de-duplicates by `(symbol, file:line)`), encouraging +# migration to the new API while keeping existing code working. + +# `strategy_kind(s)` — the public mapping from strategy struct → tag. +strategy_kind(::BinaryBracket) = KIND_BINARY_BRACKET +strategy_kind(::LinearScan) = KIND_LINEAR_SCAN +strategy_kind(::SIMDLinearScan) = KIND_SIMD_LINEAR_SCAN +strategy_kind(::BracketGallop) = KIND_BRACKET_GALLOP +strategy_kind(::ExpFromLeft) = KIND_EXP_FROM_LEFT +strategy_kind(::InterpolationSearch) = KIND_INTERPOLATION_SEARCH +strategy_kind(::BitInterpolationSearch) = KIND_BIT_INTERPOLATION_SEARCH +strategy_kind(::UniformStep) = KIND_UNIFORM_STEP +strategy_kind(::BisectThenSIMD) = KIND_BISECT_THEN_SIMD + +# Stateful strategies (`GuesserHint`, `Auto`) don't map to a single tag. +strategy_kind(s::Auto) = s.kind +strategy_kind(::GuesserHint) = throw( + ArgumentError( + "GuesserHint is a stateful strategy with no StrategyKind tag; use its own dispatch.", + ), +) + +# --------------------------------------------------------------------------- +# `Base.searchsortedlast(::S, v, x[, hint]; order)` shims for each singleton +# strategy. Each method is a one-line forward to `search_last(KIND_X, ...)`. +# +# The shim is intentionally not `@deprecate`-wrapped because Julia's +# `@deprecate` doesn't compose cleanly with keyword arguments, and the +# noise of a per-call `Base.depwarn` would obscure test output across the +# whole ecosystem during the v3 transition. The deprecation is documented +# (NEWS, docs, this comment block); a v4 release will remove the shims. +# --------------------------------------------------------------------------- + +for (S, KIND) in ( + (:BinaryBracket, :KIND_BINARY_BRACKET), + (:LinearScan, :KIND_LINEAR_SCAN), + (:SIMDLinearScan, :KIND_SIMD_LINEAR_SCAN), + (:BracketGallop, :KIND_BRACKET_GALLOP), + (:ExpFromLeft, :KIND_EXP_FROM_LEFT), + (:InterpolationSearch, :KIND_INTERPOLATION_SEARCH), + (:BitInterpolationSearch, :KIND_BIT_INTERPOLATION_SEARCH), + (:UniformStep, :KIND_UNIFORM_STEP), + (:BisectThenSIMD, :KIND_BISECT_THEN_SIMD), + ) + @eval begin + Base.searchsortedlast( + ::$S, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) = search_last($KIND, v, x; order = order) + Base.searchsortedfirst( + ::$S, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) = search_first($KIND, v, x; order = order) + Base.searchsortedlast( + ::$S, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) = search_last($KIND, v, x, hint; order = order) + Base.searchsortedfirst( + ::$S, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) = search_first($KIND, v, x, hint; order = order) + end +end diff --git a/src/strategies.jl b/src/strategies.jl index 8e220d1..8e685cd 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -1,41 +1,28 @@ -# Sorted-search strategy type hierarchy. All concrete strategies live here; -# their `Base.searchsortedfirst` / `Base.searchsortedlast` method definitions -# live in `dispatch.jl` and `auto.jl`. The `SearchProperties` cache type -# is defined here too because `Auto` carries one as a field; its populated -# constructor lives in `search_properties.jl`. +# Sorted-search strategy type hierarchy. The singleton strategy structs +# (`LinearScan`, `BracketGallop`, …) exist for back-compat with v2's +# `Base.searchsortedlast(::S, ...)` API; the v3 preferred path is to call +# the enum-tagged `search_last` / `search_first` directly (see `kinds.jl`). +# The stateful strategies — `Auto` and `GuesserHint` — stay on the +# multimethod path because they carry per-instance data. """ SearchStrategy -Abstract supertype for sorted-search strategies. Concrete subtypes select how -`Base.searchsortedlast` and -`Base.searchsortedfirst` should be performed when -called with a strategy as the first positional argument: - - - [`LinearScan`](@ref) walks ±1 from the hint. Cheapest when the target is - within a few positions of the hint; degrades linearly otherwise. - - [`SIMDLinearScan`](@ref) is `LinearScan` with the forward walk lowered to - 8-wide SIMD chunks for `DenseVector{Int64}` and `DenseVector{Float64}`. - Falls back to plain [`LinearScan`](@ref) for any other element type. - - [`BracketGallop`](@ref) expands an exponential bracket bidirectionally - from the hint, then binary-searches inside it. Effectively O(1) when the - target is near the hint; never worse than ~2 log₂ n comparisons. - - [`ExpFromLeft`](@ref) expands rightward from a left-bound hint by - doubling, then binary-searches inside the final bracket. Best for batched - sorted queries where each next query's hint is the previous result. - - [`InterpolationSearch`](@ref) guesses the answer by linearly extrapolating - between `v[lo]` and `v[hi]`, then refines with a bounded binary search. - O(1) per query on uniformly-spaced data; falls back to O(log n) on - irregular data. - - [`BinaryBracket`](@ref) is the standard `Base.searchsortedlast` / - `Base.searchsortedfirst` with no hint. Use it when no useful hint exists. - - [`BisectThenSIMD`](@ref) is an equality-search strategy: binary-bisects - `v` to a small basecase, then SIMD-scans for exact equality. Specialised - for `DenseVector{Int64}`; only meaningful when used with - [`findequal`](@ref). - - [`Auto`](@ref) heuristically picks one of the above based on the size of - `v`, the spacing of `v`, and whether a hint was supplied. Accepts an - optional [`SearchProperties`](@ref) cache to skip per-call probes. +Abstract supertype for sorted-search strategies. Two flavours of +concrete subtype: + + - **Singleton strategies** (`LinearScan`, `SIMDLinearScan`, + `BracketGallop`, `ExpFromLeft`, `InterpolationSearch`, + `BitInterpolationSearch`, `BinaryBracket`, `UniformStep`, + `BisectThenSIMD`) are zero-field structs. Each one has a matching + `StrategyKind` enum value, and the v3 preferred entry point is + [`search_last`](@ref) / [`search_first`](@ref) with that enum tag. + The `Base.searchsortedlast(::S, ...)` API still works as a v2 + back-compat shim. + - **Stateful strategies** (`Auto`, `GuesserHint`) carry per-instance + data. They dispatch via their own `search_last` / `search_first` + multimethods (and via `Base.searchsortedlast(::S, ...)` for + back-compat). Strategies can also be passed to the batched [`searchsortedlast!`](@ref) / [`searchsortedfirst!`](@ref) APIs. @@ -47,6 +34,8 @@ abstract type SearchStrategy end Walk ±1 from the hint. Best when the target is within a few positions of the hint. Falls back to [`BinaryBracket`](@ref) when no hint is supplied. + +Maps to `KIND_LINEAR_SCAN`. """ struct LinearScan <: SearchStrategy end @@ -59,6 +48,8 @@ SIMD chunks via custom LLVM IR. Specialized for `DenseVector{Int64}` and [`LinearScan`](@ref). The backward walk (when the hint is past the answer) uses the scalar `LinearScan` path regardless of element type. +Maps to `KIND_SIMD_LINEAR_SCAN`. + Wins on long forward walks (≥ 8 elements past the hint). For walks of 1–3 elements `LinearScan` is comparable — the SIMD chunk has constant setup overhead. Worst case is O(n / 8) which is still linear, so @@ -66,12 +57,8 @@ setup overhead. Worst case is O(n / 8) which is still linear, so batches where plain `LinearScan` would have been picked anyway. Caveats: - - Element type must be exactly `Int64` or `Float64`. `Int32`, - `UInt64`, `Float32`, and user-defined numeric types all fall back to - scalar. - - Sorted-Float64 vectors containing `NaN` produce undefined results, - same as for any positional search on a vector that isn't totally - ordered. + - Element type must be exactly `Int64` or `Float64`. + - Sorted-Float64 vectors containing `NaN` produce undefined results. - Falls back to [`BinaryBracket`](@ref) when no hint is supplied. - Falls back to [`LinearScan`](@ref) for non-`Forward` orderings. """ @@ -84,7 +71,8 @@ Expand an exponential bracket bidirectionally from the hint, then binary-search inside the bracket. Effectively O(1) when the target is near the hint; never worse than ~2 log₂ n comparisons. -Falls back to [`BinaryBracket`](@ref) when no hint is supplied. +Maps to `KIND_BRACKET_GALLOP`. Falls back to [`BinaryBracket`](@ref) when +no hint is supplied. """ struct BracketGallop <: SearchStrategy end @@ -92,15 +80,11 @@ struct BracketGallop <: SearchStrategy end ExpFromLeft <: SearchStrategy Exponential search forward from the hint (interpreted as a left bound), then -binary search in the final bracket. The hint is a *lower* bound rather than a -center guess, which is what batched sorted-search loops typically want: -`hint = previous_result`. +binary search in the final bracket. Best for batched sorted queries where +each next query's hint is the previous result. -Specifically: starting at `lo = hint`, check `v[lo], v[lo+1], ..., v[lo+4]` -linearly, then `v[lo+8], v[lo+16], …` exponentially, until `x` is bracketed, -then binary-search inside the bracket. - -Falls back to [`BinaryBracket`](@ref) when no hint is supplied. +Maps to `KIND_EXP_FROM_LEFT`. Falls back to [`BinaryBracket`](@ref) when +no hint is supplied. """ struct ExpFromLeft <: SearchStrategy end @@ -108,13 +92,10 @@ struct ExpFromLeft <: SearchStrategy end InterpolationSearch <: SearchStrategy Guesses an index by linearly extrapolating `x` between `v[lo]` and `v[hi]`, -then refines with a bounded binary search. O(1) per query on uniformly-spaced -data (e.g. `collect(0:0.1:10)`); falls back to O(log n) otherwise. Requires -`x` to be subtractable with elements of `v` (i.e., a numeric ordering). +then refines with a bounded binary search. -Ignores any hint that is supplied — the guess is computed fresh from the -endpoints. Falls back to [`BinaryBracket`](@ref) for non-numeric element -types where subtraction isn't defined. +Maps to `KIND_INTERPOLATION_SEARCH`. Ignores any hint. Falls back to +[`BinaryBracket`](@ref) for non-numeric element types. """ struct InterpolationSearch <: SearchStrategy end @@ -122,104 +103,72 @@ struct InterpolationSearch <: SearchStrategy end BitInterpolationSearch <: SearchStrategy Variant of [`InterpolationSearch`](@ref) that reinterprets `DenseVector{Float64}` -as `DenseVector{UInt64}` before computing the extrapolation guess. For -positive IEEE Float64 values, the bit pattern is monotonically increasing -with the float value and is approximately linear in array index when the -underlying data is *log-spaced* (geometric). On such data the bit-domain -guess is far better than the float-domain guess that `InterpolationSearch` -would compute — sometimes O(1) versus O(log n) refinement. +as `DenseVector{UInt64}` before computing the extrapolation guess. Wins on +log-spaced (geometric) data. -After computing the bit-domain guess, the bracket and binary refine step -uses the original float values for comparison, so the answer is identical -to `Base.searchsortedlast` / `Base.searchsortedfirst`. +Maps to `KIND_BIT_INTERPOLATION_SEARCH`. Constraints: - - `DenseVector{Float64}` only (the IEEE bit-pattern trick is Float64-specific). - - Requires `v[1] > 0` and the query `x > 0` (negative, zero, subnormal, - and non-finite Float64 bit patterns are not monotonic with float value - under raw reinterpret). - - Forward ordering only. - -**This strategy is opt-in only** — `Auto` does not pick it. The bench sweep -shows the per-query division and UInt64↔Float64 conversion overhead -(~60–90 ns/q) costs more than the bracket refinement that the guess -saves at every gap tested; pinning `BitInterpolationSearch` is slower than -letting `Auto` pick `SIMDLinearScan` / `BracketGallop` / `ExpFromLeft`. -The strategy is retained for users with workloads not covered by the -sweep — for instance, very-large `n` (≥ 2²⁰), pathologically log-spaced -data, or hardware where Float64 division is unusually cheap relative to -the scalar walk. - -Falls back to [`InterpolationSearch`](@ref) for non-Float64 dense eltypes -(where the bit pattern equals the value and the two strategies are -equivalent), and to [`BinaryBracket`](@ref) for non-positive or -non-finite Float64 data. + - `DenseVector{Float64}` only. + - Requires `v[1] > 0` and the query `x > 0`. + - Forward / Reverse orderings only. + +**Opt-in only** — `Auto` does not pick this strategy. Falls back to +[`InterpolationSearch`](@ref) for non-Float64 dense eltypes, and to +[`BinaryBracket`](@ref) for non-positive or non-finite Float64 data. """ struct BitInterpolationSearch <: SearchStrategy end """ BinaryBracket <: SearchStrategy -Plain `Base.searchsortedlast` / `Base.searchsortedfirst`. Ignores any hint -that is supplied. +Plain `Base.searchsortedlast` / `Base.searchsortedfirst`. Ignores any hint. + +Maps to `KIND_BINARY_BRACKET`. """ struct BinaryBracket <: SearchStrategy end """ UniformStep <: SearchStrategy -O(1) direct-arithmetic lookup for uniformly-spaced vectors. The answer -index is computed from `(x - first(v)) / step(v)` rather than via binary -search or galloping — independent of `length(v)`. - -Specialized for `AbstractRange{<:Real}` (where `step(v)` is well-defined -and the spacing is exact). For other vector types, falls back to -[`BinaryBracket`](@ref). For non-`Forward` / non-`Reverse` orderings, -also falls back to [`BinaryBracket`](@ref). +O(1) direct-arithmetic lookup for uniformly-spaced vectors. -Auto automatically dispatches to `UniformStep` when `v isa AbstractRange`, -so callers passing a range to `searchsortedlast(Auto(), r, x)` get the -O(1) path with no per-call probe overhead. - -Ignores any hint that is supplied — the closed form doesn't benefit from -a hint. +Maps to `KIND_UNIFORM_STEP`. Specialized for `AbstractRange{<:Real}`; for +other vector types, falls back to [`BinaryBracket`](@ref). Ignores any hint. """ struct UniformStep <: SearchStrategy end """ BisectThenSIMD <: SearchStrategy -Equality-search strategy. Binary-bisects `v` down to a small basecase, then -SIMD-scans the basecase for exact equality with `x`. Specialised for -`DenseVector{Int64}` + `Int64` queries via the same custom LLVM IR that -backs [`findfirstsortedequal`](@ref FindFirstFunctions.findfirstsortedequal); -for other element types, falls back to [`BinaryBracket`](@ref) plus an -equality check. - -This strategy is meant for use with [`findequal`](@ref FindFirstFunctions.findequal), -not with `searchsortedfirst` / `searchsortedlast` — its purpose is to answer -"is `x` present at exactly which index, or not at all?", which is a -different question from positional search. In the -`searchsortedfirst`/`searchsortedlast` dispatch it falls back to -[`BinaryBracket`](@ref). +Equality-search strategy. Binary-bisects `v` down to a small basecase, +then SIMD-scans the basecase for exact equality with `x`. Specialised for +`DenseVector{Int64}` + `Int64` queries. -Ignores any hint that is supplied. Falls back to [`BinaryBracket`](@ref) for -non-`Forward` orderings. +Maps to `KIND_BISECT_THEN_SIMD`. Meant for use with [`findequal`](@ref +FindFirstFunctions.findequal), not with `searchsortedfirst` / +`searchsortedlast` — in the positional API it falls back to +[`BinaryBracket`](@ref). """ struct BisectThenSIMD <: SearchStrategy end """ GuesserHint(guesser::Guesser) <: SearchStrategy -Uses a [`Guesser`](@ref) to produce an integer guess for `x`, then dispatches -to [`BracketGallop`](@ref) from that guess. The `Guesser` already decides -between linear-extrapolation lookup (when `v` looks linear) and using the -previous result as a guess; this strategy plugs that logic into the strategy -dispatch hierarchy, and updates `guesser.idx_prev` on each call. +Uses a [`Guesser`](@ref) to produce an integer guess for `x`, then +dispatches to [`BracketGallop`](@ref) from that guess. The `Guesser` +already decides between linear-extrapolation lookup and using the +previous result as a guess; this strategy plugs that logic into the +strategy dispatch hierarchy. + +**Stateful strategy.** `GuesserHint` carries the `Guesser` (which carries +`idx_prev::Ref{Int}` and `linear_lookup::Bool`), so it cannot be reduced +to a `StrategyKind` tag. It dispatches via its own +`search_last(::GuesserHint, ...)` / `search_first(::GuesserHint, ...)` +methods. Use this strategy with the per-query and batched APIs whenever you have a -`Guesser` attached to a vector. The cost is one `guesser(x)` evaluation -plus one `BracketGallop` call plus one `idx_prev[]` write per call. +`Guesser` attached to a vector. """ struct GuesserHint{G} <: SearchStrategy guesser::G @@ -230,8 +179,7 @@ end Cached, non-allocating facts about a sorted vector. Pass to [`Auto`](@ref) via `Auto(props)` to skip the per-call probes that the default `Auto()` runs -on every batched call. Stored fields are kept to plain `Bool`s so the struct -stays `isbits` and travels in registers. +on every batched call. Default-constructed (`SearchProperties()`) is the "no information" sentinel: `has_props` is `false`, the other fields are unspecified and ignored by @@ -242,16 +190,10 @@ Currently consumed by `Auto`: - `is_linear` — gates `InterpolationSearch` in batched dispatch. - `has_nan` (Float64 only) — gates `SIMDLinearScan` eligibility. - - `is_uniform` — short-circuits to [`UniformStep`](@ref) (closed-form - O(1) lookup) when set. Automatically `true` for - `SearchProperties(::AbstractRange)`; callers with a `Vector` that - they know to be exactly uniformly-spaced can construct - `SearchProperties(v; is_uniform = true)` to opt into the same fast - path. + - `is_uniform` — short-circuits to [`UniformStep`](@ref) when set. The `is_log_linear` field is populated for callers that want to manually -pin [`BitInterpolationSearch`](@ref) based on data shape; `Auto` does not -consume it. Remaining fields are populated for forward compatibility. +pin [`BitInterpolationSearch`](@ref); `Auto` does not consume it. """ struct SearchProperties has_props::Bool @@ -267,33 +209,59 @@ SearchProperties() = SearchProperties(false, false, false, false, false) Auto <: SearchStrategy Auto() Auto(props::SearchProperties) - -Heuristically picks among [`LinearScan`](@ref), [`SIMDLinearScan`](@ref), -[`ExpFromLeft`](@ref), [`InterpolationSearch`](@ref), -[`BracketGallop`](@ref), and [`BinaryBracket`](@ref). The choice depends on -the calling context: - -**Per-query** (`searchsortedlast(Auto(), v, x[, hint])`): - - No hint, or hint outside `axes(v)` → [`BinaryBracket`](@ref). - - Hint in range, `length(v) ≤ 16` → [`LinearScan`](@ref). - - Hint in range, `length(v) > 16` → [`BracketGallop`](@ref). - -**Batched sorted** (`searchsortedlast!(out, v, queries; strategy = Auto())`) -chooses by the expected average gap in `v`'s index space between -consecutive query results. See the package's `auto.md` documentation for -the full decision tree and the crossover constants the bench sweep -determined. - -**Batched unsorted**: falls back to per-element `Base.searchsortedlast` / -`Base.searchsortedfirst` with no hint regardless of strategy. + Auto(v::AbstractVector) + Auto(v::AbstractVector, props::SearchProperties) + +Stateful strategy that resolves to a concrete [`StrategyKind`](@ref) at +construction time. The resolution uses static information available at +construction: `props` (if supplied) plus `v` (if supplied). + +**Per-query** (`search_last(Auto(), v, x[, hint])` or the legacy +`searchsortedlast(Auto(), v, x[, hint])`): forwards directly to the +stored kind. `Auto()` defaults to `KIND_BINARY_BRACKET` (safe choice +when nothing is known about `v`); `Auto(v)` resolves to a faster kind +based on `length(v)`, `props.is_uniform`, etc. Callers that want the +v2-era "pick at every query based on length and hint" behaviour should +explicitly construct `Auto(v)` for each new `v`. + +**Batched sorted** (`searchsortedlast!(out, v, queries; strategy = Auto())`): +the batched dispatcher re-resolves the kind from `(v, queries)` even +when `Auto()` carries the default kind — the gap-based decision tree +requires the queries, which aren't available at Auto-construction time. **Cached properties.** Passing a populated [`SearchProperties`](@ref) via -`Auto(props)` short-circuits the per-call probes. The cached path is -behaviour-equivalent to `Auto()` when `props` is up to date for `v`; the -caller is responsible for re-computing `props` if `v` mutates. +`Auto(props)` or `Auto(v, props)` short-circuits the per-call probes. The +cached path is behaviour-equivalent to `Auto(v)` when `props` is up to +date for `v`; the caller is responsible for re-computing `props` if `v` +mutates. + +# Fields + + - `kind::StrategyKind` — resolved kind. Use this field directly in + hot loops via `search_last(auto.kind, v, x, hint)` to skip the + `auto.props` field load entirely. + - `props::SearchProperties` — cached properties used by the batched + decision tree. """ struct Auto <: SearchStrategy + kind::StrategyKind props::SearchProperties end -Auto() = Auto(SearchProperties()) +Auto() = Auto(KIND_BINARY_BRACKET, SearchProperties()) +Auto(props::SearchProperties) = Auto(_default_kind_from_props(props), props) +Auto(v::AbstractVector) = Auto(v, SearchProperties(v)) +Auto(v::AbstractVector, props::SearchProperties) = Auto(_auto_resolve_kind(v, props), props) + +# When props alone is available (no `v`), the best we can do is pick +# `UniformStep` if `props.is_uniform`. Otherwise fall back to the safe +# default. +@inline function _default_kind_from_props(props::SearchProperties) + return props.has_props && props.is_uniform ? + KIND_UNIFORM_STEP : KIND_BINARY_BRACKET +end + +# Per-query Auto resolution: when v is known. Concrete `_auto_resolve_kind` +# logic lives in `auto.jl` — these forward-declared symbols are looked up +# lazily at call time, so the include order works out. +function _auto_resolve_kind end diff --git a/test/qa/Project.toml b/test/qa/Project.toml index 3fdfa21..84cff13 100644 --- a/test/qa/Project.toml +++ b/test/qa/Project.toml @@ -10,5 +10,5 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" AllocCheck = "0.1, 0.2" Aqua = "0.8" ExplicitImports = "1.14" -FindFirstFunctions = "1, 2.1" +FindFirstFunctions = "1, 2.1, 3" JET = "0.9, 0.10, 0.11" diff --git a/test/qa/qa_tests.jl b/test/qa/qa_tests.jl index 4a8b96f..ea2e8a5 100644 --- a/test/qa/qa_tests.jl +++ b/test/qa/qa_tests.jl @@ -17,7 +17,6 @@ end end @testset "JET static analysis" begin - # Test key entry points for type stability and potential runtime errors vec_int64 = Int64[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] vec_float64 = Float64[1.0, 2.0, 3.0, 4.0, 5.0] @@ -45,68 +44,63 @@ end rep = JET.report_call(guesser, (Float64,)) @test length(JET.get_reports(rep)) == 0 - # Strategy-dispatched searchsortedfirst with Integer hint - rep = JET.report_call( - Base.searchsortedfirst, - (FindFirstFunctions.BracketGallop, Vector{Int64}, Int64, Int64) - ) - @test length(JET.get_reports(rep)) == 0 + # search_last with each StrategyKind - the v3 enum-dispatch hot path. + for kind in ( + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_BRACKET_GALLOP, + KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, + ) + rep = JET.report_call( + (k, v, x, h) -> FindFirstFunctions.search_last(k, v, x, h), + (typeof(kind), Vector{Int64}, Int64, Int64), + ) + @test length(JET.get_reports(rep)) == 0 + end - # Strategy-dispatched searchsortedlast with Integer hint - rep = JET.report_call( - Base.searchsortedlast, - (FindFirstFunctions.BracketGallop, Vector{Int64}, Int64, Int64) - ) - @test length(JET.get_reports(rep)) == 0 + # search_first with each StrategyKind + for kind in ( + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_BRACKET_GALLOP, + KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, + ) + rep = JET.report_call( + (k, v, x, h) -> FindFirstFunctions.search_first(k, v, x, h), + (typeof(kind), Vector{Int64}, Int64, Int64), + ) + @test length(JET.get_reports(rep)) == 0 + end - # searchsortedfirstexp - exponential search + # searchsortedfirstexp - exponential search helper (internal) rep = JET.report_call( FindFirstFunctions.searchsortedfirstexp, (Vector{Int64}, Int64, Int64, Int64) ) @test length(JET.get_reports(rep)) == 0 - # searchsortedfirst! - batched in-place search - rep = JET.report_call( - FindFirstFunctions.searchsortedfirst!, - (Vector{Int64}, Vector{Int64}, Vector{Int64}) - ) - @test length(JET.get_reports(rep)) == 0 - - # searchsortedlast! - batched in-place search + # Batched API rep = JET.report_call( FindFirstFunctions.searchsortedlast!, - (Vector{Int64}, Vector{Int64}, Vector{Int64}) + (Vector{Int}, Vector{Int64}, Vector{Int64}), ) @test length(JET.get_reports(rep)) == 0 end @testset "AllocCheck - Static Allocation Analysis" begin - # Use AllocCheck's static analysis (check_allocs) instead of runtime @allocated. - # This is more robust as it analyzes LLVM IR for allocation sites rather than - # measuring runtime allocations which can be noisy due to GC artifacts. - @testset "findfirstequal" begin - # Int64 vector (SIMD path) allocs = check_allocs(FindFirstFunctions.findfirstequal, (Int64, Vector{Int64})) @test isempty(allocs) end @testset "findfirstsortedequal" begin - # Int64 vector (binary search + SIMD) allocs = check_allocs(FindFirstFunctions.findfirstsortedequal, (Int64, Vector{Int64})) @test isempty(allocs) end @testset "bracketstrictlymonotonic" begin - # Int64 vector with Forward ordering allocs = check_allocs( FindFirstFunctions.bracketstrictlymonotonic, (Vector{Int64}, Int64, Int64, Base.Order.ForwardOrdering) ) @test isempty(allocs) - # Float64 vector with Forward ordering allocs = check_allocs( FindFirstFunctions.bracketstrictlymonotonic, (Vector{Float64}, Float64, Int64, Base.Order.ForwardOrdering) @@ -115,18 +109,14 @@ end end @testset "looks_linear" begin - # Float64 vector allocs = check_allocs(FindFirstFunctions.looks_linear, (Vector{Float64},)) @test isempty(allocs) - # Int64 vector allocs = check_allocs(FindFirstFunctions.looks_linear, (Vector{Int64},)) @test isempty(allocs) end @testset "Guesser callable" begin - # Test the Guesser callable with Float64 input - # Note: We test a concrete Guesser type, not the constructor GuesserType = FindFirstFunctions.Guesser{Vector{Float64}} allocs = check_allocs( (g, x) -> g(x), @@ -135,47 +125,32 @@ end @test isempty(allocs) end - @testset "searchsortedfirst with strategy + hint" begin - # Int64 vector with integer hint - allocs = check_allocs( - Base.searchsortedfirst, - (FindFirstFunctions.BracketGallop, Vector{Int64}, Int64, Int64) - ) - @test isempty(allocs) - - # Float64 vector with integer hint - allocs = check_allocs( - Base.searchsortedfirst, - (FindFirstFunctions.BracketGallop, Vector{Float64}, Float64, Int64) - ) - @test isempty(allocs) - end - - @testset "searchsortedlast with strategy + hint" begin - # Int64 vector with integer hint - allocs = check_allocs( - Base.searchsortedlast, - (FindFirstFunctions.BracketGallop, Vector{Int64}, Int64, Int64) - ) - @test isempty(allocs) - - # Float64 vector with integer hint - allocs = check_allocs( - Base.searchsortedlast, - (FindFirstFunctions.BracketGallop, Vector{Float64}, Float64, Int64) - ) - @test isempty(allocs) + @testset "search_last via enum tag" begin + # Each kind on Int64 / Float64 dense vectors: no allocations. + for kind in ( + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, + KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, + ) + allocs = check_allocs( + (k, v, x, h) -> FindFirstFunctions.search_last(k, v, x, h), + (typeof(kind), Vector{Int64}, Int64, Int64), + ) + @test isempty(allocs) + allocs = check_allocs( + (k, v, x, h) -> FindFirstFunctions.search_first(k, v, x, h), + (typeof(kind), Vector{Int64}, Int64, Int64), + ) + @test isempty(allocs) + end end @testset "searchsortedfirstexp" begin - # Int64 vector allocs = check_allocs( FindFirstFunctions.searchsortedfirstexp, (Vector{Int64}, Int64, Int64, Int64) ) @test isempty(allocs) - # Float64 vector allocs = check_allocs( FindFirstFunctions.searchsortedfirstexp, (Vector{Float64}, Float64, Int64, Int64) diff --git a/test/runtests.jl b/test/runtests.jl index 40d1c80..e82c871 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -968,6 +968,188 @@ end searchsortedlast(v, Int64(50)) end end + + @safetestset "Enum-tagged dispatch (v3 API)" begin + using FindFirstFunctions + using FindFirstFunctions: search_last, search_first, strategy_kind + using StableRNGs + + # Mapping from struct → kind, and kind enum value coverage. + @testset "strategy_kind coverage" begin + @test strategy_kind(BinaryBracket()) === KIND_BINARY_BRACKET + @test strategy_kind(LinearScan()) === KIND_LINEAR_SCAN + @test strategy_kind(SIMDLinearScan()) === KIND_SIMD_LINEAR_SCAN + @test strategy_kind(BracketGallop()) === KIND_BRACKET_GALLOP + @test strategy_kind(ExpFromLeft()) === KIND_EXP_FROM_LEFT + @test strategy_kind(InterpolationSearch()) === KIND_INTERPOLATION_SEARCH + @test strategy_kind(BitInterpolationSearch()) === KIND_BIT_INTERPOLATION_SEARCH + @test strategy_kind(UniformStep()) === KIND_UNIFORM_STEP + @test strategy_kind(BisectThenSIMD()) === KIND_BISECT_THEN_SIMD + end + + @testset "Per-kind search_last parity vs Base" begin + # Each hint-using kind should match Base when given a valid + # in-range hint. Hint-ignoring kinds (BinaryBracket, + # InterpolationSearch, UniformStep, BisectThenSIMD) match + # Base regardless of hint. + v = collect(1:100) + for x in (-1, 0, 1, 17, 50, 99, 100, 101, 200), h in (1, 30, 60, 90, 100) + want_last = searchsortedlast(v, x) + want_first = searchsortedfirst(v, x) + for kind in ( + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, + KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, + KIND_INTERPOLATION_SEARCH, + ) + @test search_last(kind, v, x, h) == want_last + @test search_first(kind, v, x, h) == want_first + end + # No-hint forms. + @test search_last(KIND_BINARY_BRACKET, v, x) == want_last + @test search_first(KIND_BINARY_BRACKET, v, x) == want_first + @test search_last(KIND_INTERPOLATION_SEARCH, v, x) == want_last + @test search_first(KIND_INTERPOLATION_SEARCH, v, x) == want_first + end + end + + @testset "Per-kind Float64 parity (incl. SIMD)" begin + rng = StableRNG(7001) + v = sort!(randn(rng, 256)) + for _ in 1:200 + x = (rand(rng) - 0.5) * 6 + h = rand(rng, 1:length(v)) + want_last = searchsortedlast(v, x) + want_first = searchsortedfirst(v, x) + for kind in ( + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, + KIND_SIMD_LINEAR_SCAN, KIND_BRACKET_GALLOP, + KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, + ) + @test search_last(kind, v, x, h) == want_last + @test search_first(kind, v, x, h) == want_first + end + end + end + + @testset "Per-kind Int64 SIMD parity" begin + rng = StableRNG(7002) + v = sort!(rand(rng, Int64(-200):Int64(200), 256)) + for _ in 1:200 + x = rand(rng, Int64(-220):Int64(220)) + h = rand(rng, 1:length(v)) + want_last = searchsortedlast(v, x) + want_first = searchsortedfirst(v, x) + @test search_last(KIND_SIMD_LINEAR_SCAN, v, x, h) == want_last + @test search_first(KIND_SIMD_LINEAR_SCAN, v, x, h) == want_first + end + end + + @testset "UniformStep kind on AbstractRange" begin + for r in (1:100, 0.0:0.1:10.0, LinRange(0.0, 10.0, 101)) + for x in (first(r) - 1, first(r), last(r), last(r) + 1) + @test search_last(KIND_UNIFORM_STEP, r, x) == + searchsortedlast(r, x) + @test search_first(KIND_UNIFORM_STEP, r, x) == + searchsortedfirst(r, x) + end + end + end + + @testset "BitInterpolationSearch kind on positive Float64" begin + v = exp.(range(0.0, log(1.0e6); length = 1024)) + rng = StableRNG(7003) + for _ in 1:100 + x = exp(rand(rng) * log(1.0e6)) + @test search_last(KIND_BIT_INTERPOLATION_SEARCH, v, x) == + searchsortedlast(v, x) + @test search_first(KIND_BIT_INTERPOLATION_SEARCH, v, x) == + searchsortedfirst(v, x) + end + end + + @testset "Reverse order under enum dispatch" begin + v = collect(10.0:-1.0:1.0) + for kind in ( + KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, + KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, + KIND_INTERPOLATION_SEARCH, + ) + for x in (0.5, 1.0, 5.0, 10.0, 11.0), h in (1, 5, 10) + @test search_last(kind, v, x, h; order = Base.Order.Reverse) == + searchsortedlast(v, x, Base.Order.Reverse) + @test search_first(kind, v, x, h; order = Base.Order.Reverse) == + searchsortedfirst(v, x, Base.Order.Reverse) + end + end + end + + @testset "Auto inferred return type" begin + # Auto(v) was previously Union-returning in the v2 design + # — the `_auto_pick` branch returned BinaryBracket vs + # BracketGallop depending on length, and each branch had + # different return types feeding the per-query dispatch. + # In v3 the kind is stored as a UInt8-backed enum, so + # both branches return Int. + v = collect(1:100) + a = Auto(v) + @test isbits(a) + @test @inferred(Auto(v)) isa Auto + @test @inferred(Auto(SearchProperties(v))) isa Auto + # `search_last(::Auto, ...)` is concretely Int-returning. + @test @inferred(search_last(a, v, 50, 1)) === 50 + @test @inferred(search_first(a, v, 50, 1)) === 50 + @test @inferred(search_last(a, v, 50)) === 50 + end + + @testset "Vector{Auto} has concrete eltype" begin + # Mixed-underlying-kind Vector{Auto}: even when each Auto + # was constructed from a different v / props, the + # element type is `Auto` (concrete), not + # `Union{Auto{...}, Auto{...}}` or `Any`. + a1 = Auto(collect(1:100)) # KIND_UNIFORM_STEP + a2 = Auto(rand(StableRNG(10), 100)) # KIND_BRACKET_GALLOP (not uniform) + a3 = Auto(collect(1:8)) # KIND_LINEAR_SCAN + v = Auto[a1, a2, a3] + @test eltype(v) === Auto + @test isconcretetype(eltype(v)) + end + + @testset "Enum dispatch round-trips through Base shim" begin + # The legacy `Base.searchsortedlast(::S, v, x[, hint])` + # path goes through `search_last(KIND_X, ...)`. Verify + # both produce identical answers (shim correctness). + v = collect(1:1000) + rng = StableRNG(7004) + pairs = ( + (BracketGallop(), KIND_BRACKET_GALLOP), + (LinearScan(), KIND_LINEAR_SCAN), + (ExpFromLeft(), KIND_EXP_FROM_LEFT), + (InterpolationSearch(), KIND_INTERPOLATION_SEARCH), + (BinaryBracket(), KIND_BINARY_BRACKET), + ) + for (s, kind) in pairs + for _ in 1:50 + x = rand(rng, 1:1000) + h = rand(rng, 1:1000) + @test searchsortedlast(s, v, x, h) == + search_last(kind, v, x, h) + @test searchsortedfirst(s, v, x, h) == + search_first(kind, v, x, h) + end + end + end + + @testset "GuesserHint stays on multimethod path" begin + using FindFirstFunctions: Guesser, GuesserHint + v = collect(LinRange(0, 10, 100)) + g = Guesser(v) + gh = GuesserHint(g) + @test search_last(gh, v, 4.0) == searchsortedlast(v, 4.0) + @test search_first(gh, v, 4.0) == searchsortedfirst(v, 4.0) + # strategy_kind on a GuesserHint must error — it has no tag. + @test_throws ArgumentError strategy_kind(gh) + end + end end if GROUP == "QA" From 0fd9b974affb72cf04e6e952c89d219f6e910a30 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Sat, 23 May 2026 04:36:21 -0400 Subject: [PATCH 02/14] v3: parametric SearchProperties{T} + props-aware UniformStep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `SearchProperties` parametric on the data ratio type `T = typeof(oneunit(eltype(v)) / oneunit(eltype(v)))` and add two new fields: - `first_val::T` — `v[1]` (or `first(r)` for an AbstractRange) - `inv_step::T` — precomputed `1 / step`, or `(n-1)/(v[end]-v[1])` for a uniform AbstractVector These fields are populated when `is_uniform = true` and zero otherwise. They feed a new props-aware `UniformStep` kernel invoked by `Auto(v)` when the resolved kind is `KIND_UNIFORM_STEP`: closed-form O(1) lookup with one subtract, one multiply, one truncate (no per-query float division). This folds in the never-merged `DirectStep` strategy. `Auto{T}` is now parametric, carrying `SearchProperties{T}`. `Auto(v)` returns `Auto{T}` where `T` is the ratio type of `eltype(v)`. Two `Auto`s constructed from data with the same ratio type share one concrete type (e.g. `Vector{Int}` and `Vector{Float64}` both promote to `Auto{Float64}`), so `Vector{Auto{Float64}}` is concrete. The raw `UniformStep()` singleton keeps its old behaviour for back-compat: `searchsortedlast(UniformStep(), r, x)` still does `fld(diff, step)` per query. Only `Auto(v)` routes through the props-aware path. Bench (per-query latency, n = 10k, m = 1000): Sorted queries on AbstractRange (0.0:0.5:N): Auto(r) [props]: 13.74 ns/q UniformStep() [fld]: 65.02 ns/q BracketGallop+hint: 44.29 ns/q Sorted queries on uniform Vector{Float64}: Auto(v) [props]: 7.6 ns/q UniformStep() [bb]: 81.79 ns/q (vector path falls back to BinaryBracket) BracketGallop+hint: 25.43 ns/q Random queries on AbstractRange: Auto(r) [props]: 13.73 ns/q UniformStep() [fld]: 67.5 ns/q BracketGallop+hint: 238.41 ns/q (miss path) The props-aware UniformStep beats raw UniformStep by 5-10× and is ~3-20× faster than BracketGallop on uniformly-spaced data. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Chris Rackauckas --- NEWS.md | 39 ++++++++ bench/uniform_step_props_bench.jl | 89 ++++++++++++++++++ src/auto.jl | 29 +++++- src/batched.jl | 27 +++++- src/kernels.jl | 78 +++++++++++++++ src/precompile.jl | 8 ++ src/search_properties.jl | 66 ++++++++++--- src/strategies.jl | 43 +++++++-- test/runtests.jl | 151 +++++++++++++++++++++++++++--- 9 files changed, 487 insertions(+), 43 deletions(-) create mode 100644 bench/uniform_step_props_bench.jl diff --git a/NEWS.md b/NEWS.md index 33d629a..a64f737 100644 --- a/NEWS.md +++ b/NEWS.md @@ -77,6 +77,45 @@ preserved for batched calls and for callers that explicitly construct `v`) picking `LinearScan` on short vectors or `BracketGallop` on long vectors per-query, update to `Auto(v)` where `v` is known. +### New: parametric `SearchProperties{T}` with precomputed `inv_step` + +`SearchProperties` is now parametric on the data ratio type +`T = typeof(oneunit(eltype(v)) / oneunit(eltype(v)))` (`Float64` for +`Vector{Int}` or `Vector{Float64}`, `Float32` for `Vector{Float32}`, etc.). +The struct carries two new fields: + + - `first_val::T` — `v[1]` (or `first(r)` for an `AbstractRange`). + - `inv_step::T` — the precomputed reciprocal `1 / step`. For an + `AbstractRange`, `1 / step(r)`. For an exactly-uniform `AbstractVector`, + `(length(v) - 1) / (v[end] - v[1])`. + +These fields are populated when `is_uniform = true` and zero otherwise. +They feed the new **props-aware `UniformStep` kernel** invoked by +`Auto(v)` when the resolved kind is `KIND_UNIFORM_STEP`: + +```julia +# v3 closed-form O(1) lookup with no per-query division: +a = Auto(0.0:0.5:100.0) # kind = KIND_UNIFORM_STEP, inv_step = 2.0 +search_last(a, r, 3.7) # → floor((3.7 - 0.0) * 2.0) + 1 = 8 +``` + +This subsumes the never-merged `DirectStep` strategy (PR #74) — its +precomputed-reciprocal closed-form is now folded into `UniformStep` via +the `SearchProperties{T}` payload. + +The raw `UniformStep()` singleton kept its old behaviour: when called via +`searchsortedlast(UniformStep(), r, x)` (no props) it still uses +`fld(diff, step)` per query. Auto routes through the props-aware kernel +because Auto carries the precomputed `SearchProperties{T}`. + +### `Auto{T}` parametric + +`Auto` now carries `props::SearchProperties{T}` and is itself parametric +on `T`. `Auto(v)` returns an `Auto{T}` where `T` is the ratio type of +`eltype(v)`. Two `Auto`s constructed from data with the same ratio type +(e.g. `Vector{Int}` and `Vector{Float64}` both → `Auto{Float64}`) share +a single concrete type, so `Vector{Auto{Float64}}` is concrete. + ### New: `strategy_kind` `strategy_kind(s::SearchStrategy)` maps a singleton strategy struct to diff --git a/bench/uniform_step_props_bench.jl b/bench/uniform_step_props_bench.jl new file mode 100644 index 0000000..c266035 --- /dev/null +++ b/bench/uniform_step_props_bench.jl @@ -0,0 +1,89 @@ +#= +Per-query latency: UniformStep via Auto+props (closed-form, precomputed +inv_step) vs UniformStep via raw struct (fld(diff, step) per query) vs +BracketGallop with valid hint. + +Usage: + julia +1.11 --project=bench bench/uniform_step_props_bench.jl +=# + +import Pkg +Pkg.activate(@__DIR__) + +using BenchmarkTools +using Statistics +using StableRNGs + +using FindFirstFunctions +using FindFirstFunctions: Auto, UniformStep, BracketGallop, SearchProperties + +const RNG = StableRNG(12345) + +function bench_per_query(strategy_or_kind, v, queries) + out = Vector{Int}(undef, length(queries)) + bench = @benchmarkable for k in eachindex($queries) + $out[k] = $( + strategy_or_kind isa FindFirstFunctions.SearchStrategy ? + :(searchsortedlast) : :(FindFirstFunctions.search_last) + )($strategy_or_kind, $v, $queries[k]) + end seconds = 1 evals = 1 samples = 200 + return run(bench) +end + +# Two-arg form (per-query, no hint), Auto-style: +function bench_auto(s, v, queries) + out = Vector{Int}(undef, length(queries)) + bench = @benchmarkable for k in eachindex($queries) + $out[k] = searchsortedlast($s, $v, $queries[k]) + end seconds = 1 evals = 1 samples = 200 + return run(bench) +end + +# BracketGallop with chained hint (monotone queries). +function bench_bracket_hint(s, v, queries) + out = Vector{Int}(undef, length(queries)) + bench = @benchmarkable begin + h = firstindex($v) - 1 + for k in eachindex($queries) + h = if h < firstindex($v) + searchsortedlast($s, $v, $queries[k]) + else + searchsortedlast($s, $v, $queries[k], h) + end + $out[k] = h + end + end seconds = 1 evals = 1 samples = 200 + return run(bench) +end + +fmt(t) = string(round(median(t).time / length(QUERIES); digits = 2), " ns/q") + +const N = 10_000 +const M = 1_000 + +const r = range(0.0, 100.0; length = N) +const v_uniform = collect(r) +const queries_sorted = sort!(rand(RNG, M) .* 100.0) +const queries_random = rand(StableRNG(11), M) .* 100.0 +const QUERIES = queries_sorted + +println("==== n = $N, m = $M ====\n") + +println("=== Sorted queries on AbstractRange (range) ===") +println("Auto(r) (UniformStep + props) : ", fmt(bench_auto(Auto(r), r, queries_sorted))) +println("UniformStep() (range path, fld) : ", fmt(bench_auto(UniformStep(), r, queries_sorted))) +println("BracketGallop() (with hint chain) : ", fmt(bench_bracket_hint(BracketGallop(), r, queries_sorted))) + +println("\n=== Sorted queries on Vector{Float64} (uniform) ===") +println("Auto(v) (UniformStep + props) : ", fmt(bench_auto(Auto(v_uniform), v_uniform, queries_sorted))) +println("UniformStep() (vector path → bracket): ", fmt(bench_auto(UniformStep(), v_uniform, queries_sorted))) +println("BracketGallop() (with hint chain) : ", fmt(bench_bracket_hint(BracketGallop(), v_uniform, queries_sorted))) + +println("\n=== Random queries on AbstractRange ===") +println("Auto(r) (UniformStep + props) : ", fmt(bench_auto(Auto(r), r, queries_random))) +println("UniformStep() (range path, fld) : ", fmt(bench_auto(UniformStep(), r, queries_random))) +println("BracketGallop() (hinted, miss path) : ", fmt(bench_bracket_hint(BracketGallop(), r, queries_random))) + +println("\n=== Random queries on Vector{Float64} (uniform) ===") +println("Auto(v) (UniformStep + props) : ", fmt(bench_auto(Auto(v_uniform), v_uniform, queries_random))) +println("BracketGallop() (hinted, miss path) : ", fmt(bench_bracket_hint(BracketGallop(), v_uniform, queries_random))) diff --git a/src/auto.jl b/src/auto.jl index 9e633a0..adf4f10 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -113,6 +113,11 @@ end # absent or out of range; `LinearScan` (picked for short `v`) clamps the # hint and walks. So `search_last(::Auto, v, x, hint)` is a one-line # forward to the kind dispatcher. +# +# Special case: when `kind === KIND_UNIFORM_STEP`, we route to the +# props-aware kernel that uses the precomputed `inv_step` from `props`, +# skipping the per-query float division in the back-compat AbstractRange +# `UniformStep` kernel. # --------------------------------------------------------------------------- # Hinted form: forward to the kind dispatcher. @@ -120,14 +125,22 @@ end s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - return search_last(s.kind, v, x, hint; order = order) + return if s.kind === KIND_UNIFORM_STEP + _kernel_last_uniform_step_props(s.props, v, x, order) + else + search_last(s.kind, v, x, hint; order = order) + end end @inline function search_first( s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - return search_first(s.kind, v, x, hint; order = order) + return if s.kind === KIND_UNIFORM_STEP + _kernel_first_uniform_step_props(s.props, v, x, order) + else + search_first(s.kind, v, x, hint; order = order) + end end # No-hint form: same forward. The kind's no-hint dispatch handles @@ -136,14 +149,22 @@ end s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return search_last(s.kind, v, x; order = order) + return if s.kind === KIND_UNIFORM_STEP + _kernel_last_uniform_step_props(s.props, v, x, order) + else + search_last(s.kind, v, x; order = order) + end end @inline function search_first( s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return search_first(s.kind, v, x; order = order) + return if s.kind === KIND_UNIFORM_STEP + _kernel_first_uniform_step_props(s.props, v, x, order) + else + search_first(s.kind, v, x; order = order) + end end # Legacy `Base.searchsortedlast(::Auto, ...)` shims — same one-liner. Kept diff --git a/src/batched.jl b/src/batched.jl index bb94d78..0562b8a 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -281,8 +281,19 @@ function _searchsortedlast_batched!( queries_sorted::Union{Nothing, Bool}, ) if _auto_is_uniform(v, s.props) - @inbounds for k in eachindex(queries) - idx_out[k] = search_last(KIND_UNIFORM_STEP, v, queries[k]; order = order) + # If props is populated, use the closed-form props-aware kernel + # (no per-query division). Otherwise fall back to the range-based + # UniformStep kernel (uses `step(r)`). + if s.props.has_props && s.props.is_uniform + @inbounds for k in eachindex(queries) + idx_out[k] = _kernel_last_uniform_step_props( + s.props, v, queries[k], order, + ) + end + else + @inbounds for k in eachindex(queries) + idx_out[k] = search_last(KIND_UNIFORM_STEP, v, queries[k]; order = order) + end end return idx_out end @@ -307,8 +318,16 @@ function _searchsortedfirst_batched!( queries_sorted::Union{Nothing, Bool}, ) if _auto_is_uniform(v, s.props) - @inbounds for k in eachindex(queries) - idx_out[k] = search_first(KIND_UNIFORM_STEP, v, queries[k]; order = order) + if s.props.has_props && s.props.is_uniform + @inbounds for k in eachindex(queries) + idx_out[k] = _kernel_first_uniform_step_props( + s.props, v, queries[k], order, + ) + end + else + @inbounds for k in eachindex(queries) + idx_out[k] = search_first(KIND_UNIFORM_STEP, v, queries[k]; order = order) + end end return idx_out end diff --git a/src/kernels.jl b/src/kernels.jl index 3d571d3..725da1a 100644 --- a/src/kernels.jl +++ b/src/kernels.jl @@ -566,3 +566,81 @@ end @inline _kernel_first_uniform_step( v::AbstractVector, x, order::Base.Order.Ordering, ) = _kernel_first_binary_bracket(v, x, order) + +# =========================================================================== +# Props-aware UniformStep — closed-form O(1) lookup using a precomputed +# `inv_step` baked into `SearchProperties{T}`. Subsumes the old DirectStep +# strategy: the per-query float division `fld(diff, step)` is hoisted to +# `SearchProperties` construction time, leaving the hot path with one +# subtract, one multiply, one truncate, plus a bounds clamp and a one-step +# roundoff correction. +# +# Both `Forward` and `Reverse` orderings are supported — the sign of +# `inv_step` carries the direction. Any other ordering falls back to +# BinaryBracket. +# =========================================================================== + +@inline function _kernel_last_uniform_step_props( + props::SearchProperties, v::AbstractVector, x, order::Base.Order.Ordering, + ) + _uniformstep_supported_order(order) || + return _kernel_last_binary_bracket(v, x, order) + isempty(v) && return firstindex(v) - 1 + diff = x - props.first_val + if diff isa AbstractFloat && !isfinite(diff) + # NaN → "x precedes nothing"; ±Inf direction depends on sign of step. + # `inv_step < 0` iff the range is decreasing. + return isnan(diff) ? (firstindex(v) - 1) : + (diff > 0) ⊻ (props.inv_step < 0) ? lastindex(v) : + firstindex(v) - 1 + end + nm1 = length(v) - 1 + f = diff * props.inv_step + i_raw = unsafe_trunc(Int, floor(f)) + i = if i_raw < 0 + firstindex(v) - 1 + elseif i_raw >= nm1 + lastindex(v) + else + firstindex(v) + i_raw + end + @inbounds if i < lastindex(v) && !Base.Order.lt(order, x, v[i + 1]) + return i + 1 + elseif i >= firstindex(v) && i <= lastindex(v) && + Base.Order.lt(order, x, v[i]) + return i - 1 + end + return i +end + +@inline function _kernel_first_uniform_step_props( + props::SearchProperties, v::AbstractVector, x, order::Base.Order.Ordering, + ) + _uniformstep_supported_order(order) || + return _kernel_first_binary_bracket(v, x, order) + isempty(v) && return firstindex(v) + diff = x - props.first_val + if diff isa AbstractFloat && !isfinite(diff) + return isnan(diff) ? (lastindex(v) + 1) : + (diff > 0) ⊻ (props.inv_step < 0) ? lastindex(v) + 1 : + firstindex(v) + end + nm1 = length(v) - 1 + f = diff * props.inv_step + i_raw = unsafe_trunc(Int, ceil(f)) + i = if i_raw <= 0 + firstindex(v) + elseif i_raw > nm1 + lastindex(v) + 1 + else + firstindex(v) + i_raw + end + @inbounds if i > firstindex(v) && i <= lastindex(v) + 1 && + !Base.Order.lt(order, v[i - 1], x) + return i - 1 + end + @inbounds if i <= lastindex(v) && Base.Order.lt(order, v[i], x) + return i + 1 + end + return i +end diff --git a/src/precompile.jl b/src/precompile.jl index 93c5ec1..7b8df7e 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -29,10 +29,18 @@ using PrecompileTools: @compile_workload, @setup_workload LinearScan(), SIMDLinearScan(), BracketGallop(), ExpFromLeft(), InterpolationSearch(), BinaryBracket(), Auto(), Auto(SearchProperties(linear_vec)), + Auto(linear_vec), ) searchsortedfirst(strategy, vec_int64, Int64(8), Int64(1)) searchsortedlast(strategy, vec_int64, Int64(8), Int64(1)) end + # Auto-with-uniform-range — exercises the props-aware UniformStep kernel. + let r = 0.0:0.5:10.0 + auto_r = Auto(r) + searchsortedlast(auto_r, r, 3.7) + searchsortedfirst(auto_r, r, 3.7) + searchsortedlast(auto_r, r, 3.7, 1) + end # findequal: generic + BisectThenSIMD shortcut for Int64 dense vectors. for strategy in ( BinaryBracket(), BracketGallop(), SIMDLinearScan(), diff --git a/src/search_properties.jl b/src/search_properties.jl index bd42cd0..0f7348a 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -110,12 +110,14 @@ end @inline _sampled_looks_log_linear(::AbstractVector, ::Float64 = _AUTO_LINEAR_REL_TOLERANCE) = false """ - SearchProperties(v::AbstractVector; linear_tolerance = 1.0e-3, is_uniform = false) + SearchProperties(v::AbstractVector; linear_tolerance = 1.0e-3, is_uniform = nothing) Run the linearity probe and (for floating-point eltypes) the NaN scan on `v`, -returning the populated [`SearchProperties`](@ref). Cost is O(n) on -floating-point vectors because of the NaN scan; for integer and non-numeric -eltypes the cost is O(1) — only the sampled-linearity probe runs. +returning the populated [`SearchProperties{T}`](@ref) where `T` is the data +ratio type (e.g. `Float64` for `Vector{Int}` or `Vector{Float64}`, +`Float32` for `Vector{Float32}`). Cost is O(n) on floating-point vectors +because of the NaN scan; for integer and non-numeric eltypes the cost is +O(1) — only the sampled-linearity probe runs. `linear_tolerance` controls the maximum relative deviation accepted by the sampled-linearity probe. The default `1e-3` (0.1%) matches `Auto`'s @@ -126,32 +128,52 @@ more conservative. `is_uniform` is a caller-supplied flag for `Vector`s that are exactly uniformly spaced. Setting it `true` opts the vector into -[`UniformStep`](@ref)'s closed-form O(1) path via `Auto`. There is no -detection probe — uniform spacing on a `Vector` can't be confirmed -cheaply, and an approximate-uniform vector would give wrong answers -under `UniformStep`'s exact-step assumption. For `AbstractRange` inputs -the flag is set automatically by the dedicated overload below. +[`UniformStep`](@ref)'s closed-form O(1) path via `Auto`. The default +`nothing` infers from the tight uniformity probe. When `true` (either +detected or supplied), `first_val` and `inv_step` are computed and stored +in the result so `UniformStep`'s closed-form path needs no per-query +division. """ function SearchProperties( - v::AbstractVector; + v::AbstractVector{<:Number}; linear_tolerance::Real = 1.0e-3, is_uniform::Union{Nothing, Bool} = nothing, ) + T = _ratio_type(eltype(v)) tol = Float64(linear_tolerance) # One scan produces both `is_linear` and the uniformity-deviation # check. `is_uniform = nothing` (default) means "infer from the # probe"; an explicit Bool overrides. err = _sampled_linear_err(v) detected_uniform = err <= _UNIFORM_REL_TOLERANCE - return SearchProperties( + flag_uniform = is_uniform === nothing ? detected_uniform : is_uniform + first_val, inv_step = if flag_uniform && length(v) >= 2 + # Closed-form `inv_step` from sampled endpoints. Promotes Int → Float64. + @inbounds T(v[1]), T(length(v) - 1) / T(v[end] - v[1]) + else + zero(T), zero(T) + end + return SearchProperties{T}( true, err <= tol, _has_nan(v), _sampled_looks_log_linear(v, tol), - is_uniform === nothing ? detected_uniform : is_uniform, + flag_uniform, + first_val, + inv_step, ) end +# Non-numeric eltype: `Float64` default T, `has_props = false`. Probes are +# meaningless on non-Number data, so the result is the sentinel value. +function SearchProperties( + v::AbstractVector; + linear_tolerance::Real = 1.0e-3, + is_uniform::Union{Nothing, Bool} = nothing, + ) + return SearchProperties() +end + """ SearchProperties(v::AbstractRange; linear_tolerance = 1.0e-3) @@ -170,6 +192,10 @@ probe — every property is known statically from the type: `exp(x)` values, which Julia represents as a `Vector`, not an `AbstractRange`. +The `first_val` and `inv_step` fields are populated from `first(r)` and +`1/step(r)` to skip the per-query division in `UniformStep`'s closed-form +path. + `linear_tolerance` is accepted for signature compatibility but ignored — the probes are skipped. """ @@ -177,5 +203,19 @@ function SearchProperties( v::AbstractRange{<:Real}; linear_tolerance::Real = 1.0e-3, ) - return SearchProperties(true, true, false, false, true) + T = _ratio_type(eltype(v)) + first_val, inv_step = if length(v) >= 1 + f = T(first(v)) + s = T(step(v)) + if iszero(s) + f, zero(T) + else + f, one(T) / s + end + else + zero(T), zero(T) + end + return SearchProperties{T}( + true, true, false, false, true, first_val, inv_step, + ) end diff --git a/src/strategies.jl b/src/strategies.jl index 8e685cd..e129e93 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -175,7 +175,7 @@ struct GuesserHint{G} <: SearchStrategy end """ - SearchProperties + SearchProperties{T} Cached, non-allocating facts about a sorted vector. Pass to [`Auto`](@ref) via `Auto(props)` to skip the per-call probes that the default `Auto()` runs @@ -186,24 +186,46 @@ Default-constructed (`SearchProperties()`) is the "no information" sentinel: `Auto`. Construct via `SearchProperties(v::AbstractVector)` to populate the fields by running the probes once. +`T` is the **data ratio type** — the type of `oneunit(eltype(v)) / +oneunit(eltype(v))`, so e.g. `SearchProperties{Float64}` for +`Vector{Int}` (because `Int/Int` promotes to `Float64`) or `Vector{Float64}`, +`SearchProperties{Float32}` for `Vector{Float32}`. For non-`Number` eltypes +the default is `Float64` and `has_props = false`. + Currently consumed by `Auto`: - `is_linear` — gates `InterpolationSearch` in batched dispatch. - `has_nan` (Float64 only) — gates `SIMDLinearScan` eligibility. - - `is_uniform` — short-circuits to [`UniformStep`](@ref) when set. + - `is_uniform` — short-circuits to [`UniformStep`](@ref) when set, with + `first_val` and `inv_step` baked in for closed-form O(1) lookup. + +When `is_uniform = true`, `first_val` and `inv_step` hold the precomputed +data needed by `UniformStep`'s closed-form path +(`idx = floor((x - first_val) * inv_step) + 1`). When `is_uniform = false` +they are `zero(T)` and never consulted. The `is_log_linear` field is populated for callers that want to manually pin [`BitInterpolationSearch`](@ref); `Auto` does not consume it. """ -struct SearchProperties +struct SearchProperties{T} has_props::Bool is_linear::Bool has_nan::Bool is_log_linear::Bool is_uniform::Bool + first_val::T + inv_step::T end -SearchProperties() = SearchProperties(false, false, false, false, false) +# Data ratio type used by SearchProperties{T}: `1/oneunit(eltype(v))` promotion. +# `Int → Float64`, `Float64 → Float64`, `Float32 → Float32`. +@inline _ratio_type(::Type{T}) where {T <: AbstractFloat} = T +@inline _ratio_type(::Type{T}) where {T <: Number} = typeof(oneunit(T) / oneunit(T)) +@inline _ratio_type(::Type) = Float64 + +SearchProperties() = SearchProperties{Float64}( + false, false, false, false, false, 0.0, 0.0, +) """ Auto <: SearchStrategy @@ -243,15 +265,18 @@ mutates. - `props::SearchProperties` — cached properties used by the batched decision tree. """ -struct Auto <: SearchStrategy +struct Auto{T} <: SearchStrategy kind::StrategyKind - props::SearchProperties + props::SearchProperties{T} end -Auto() = Auto(KIND_BINARY_BRACKET, SearchProperties()) -Auto(props::SearchProperties) = Auto(_default_kind_from_props(props), props) +Auto() = Auto{Float64}(KIND_BINARY_BRACKET, SearchProperties()) +Auto(props::SearchProperties{T}) where {T} = + Auto{T}(_default_kind_from_props(props), props) Auto(v::AbstractVector) = Auto(v, SearchProperties(v)) -Auto(v::AbstractVector, props::SearchProperties) = Auto(_auto_resolve_kind(v, props), props) +function Auto(v::AbstractVector, props::SearchProperties{T}) where {T} + return Auto{T}(_auto_resolve_kind(v, props), props) +end # When props alone is available (no `v`), the best we can do is pick # `UniformStep` if `props.is_uniform`. Otherwise fall back to the safe diff --git a/test/runtests.jl b/test/runtests.jl index e82c871..e637454 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -548,14 +548,15 @@ end # because InterpolationSearch's bad guess just makes BracketGallop # wider, never incorrect. v_log = exp.(range(0.0, 10.0; length = 4096)) - lying = SearchProperties(true, true, false, false, false) + lying = SearchProperties{Float64}(true, true, false, false, false, 0.0, 0.0) tt_log = sort!(rand(StableRNG(11), 8) .* (v_log[end] - v_log[1]) .+ v_log[1]) out_lying = Vector{Int}(undef, length(tt_log)) searchsortedlast!(out_lying, v_log, tt_log; strategy = Auto(lying)) @test out_lying == searchsortedlast.(Ref(v_log), tt_log) - # Bits-ness: SearchProperties must be isbits so it doesn't allocate. - @test isbitstype(SearchProperties) + # Bits-ness: SearchProperties{T} must be isbits so it doesn't allocate. + @test isbitstype(SearchProperties{Float64}) + @test isbitstype(SearchProperties{Float32}) # is_log_linear field: populated by SearchProperties(v) on # geometric data, rejected on linear / two-scale data. @@ -599,6 +600,124 @@ end @test SearchProperties(v_log_collected; is_uniform = true).is_uniform end + @safetestset "SearchProperties{T} parametric eltype" begin + using FindFirstFunctions + using FindFirstFunctions: SearchProperties, _ratio_type + + # Float64 vector / Float64 range → SearchProperties{Float64}. + @test SearchProperties(collect(0.0:0.1:10.0)) isa SearchProperties{Float64} + @test SearchProperties(0.0:0.1:10.0) isa SearchProperties{Float64} + @test SearchProperties(LinRange(0.0, 10.0, 101)) isa SearchProperties{Float64} + + # Float32 vector / range → SearchProperties{Float32}. + @test SearchProperties(collect(Float32(0):Float32(0.1):Float32(10))) isa + SearchProperties{Float32} + @test SearchProperties(Float32(0):Float32(0.1):Float32(10)) isa + SearchProperties{Float32} + @test SearchProperties(LinRange(Float32(0), Float32(10), 101)) isa + SearchProperties{Float32} + + # Int vector / range → SearchProperties{Float64} (ratio promotes). + @test SearchProperties(collect(1:100)) isa SearchProperties{Float64} + @test SearchProperties(1:100) isa SearchProperties{Float64} + @test _ratio_type(Int) === Float64 + @test _ratio_type(Float64) === Float64 + @test _ratio_type(Float32) === Float32 + + # Non-numeric eltype → SearchProperties{Float64} sentinel, + # has_props = false. + ps = SearchProperties(["a", "b", "c"]) + @test ps isa SearchProperties{Float64} + @test !ps.has_props + @test !ps.is_uniform + @test ps.first_val == 0.0 + @test ps.inv_step == 0.0 + + # Default sentinel. + @test SearchProperties() isa SearchProperties{Float64} + @test !SearchProperties().has_props + + # first_val / inv_step are populated when is_uniform = true. + p = SearchProperties(0.0:0.5:10.0) + @test p.first_val == 0.0 + @test p.inv_step == 2.0 # 1/0.5 + + # Vector path matches range path on uniform data. + v = collect(0.0:0.5:10.0) + p_v = SearchProperties(v) + @test p_v.first_val == 0.0 + @test p_v.inv_step ≈ 2.0 # (n-1)/(v[end]-v[1]) = 20/10 = 2 + + # Non-uniform vector: first_val / inv_step are zero. + p_nonuniform = SearchProperties(sort!(rand(100))) + @test !p_nonuniform.is_uniform + @test p_nonuniform.first_val == 0.0 + @test p_nonuniform.inv_step == 0.0 + end + + @safetestset "Auto{T} parametric + props-aware UniformStep kernel" begin + using FindFirstFunctions + using FindFirstFunctions: + SearchProperties, Auto, KIND_UNIFORM_STEP, + search_last, search_first + + # Auto{T} carries SearchProperties{T}. + @test Auto() isa Auto{Float64} + @test Auto(collect(1:100)) isa Auto{Float64} + @test Auto(collect(0.0:0.5:10.0)) isa Auto{Float64} + @test Auto(collect(Float32(0):Float32(0.5):Float32(10))) isa Auto{Float32} + @test Auto(0.0:0.5:10.0) isa Auto{Float64} + @test Auto(Float32(0):Float32(0.5):Float32(10)) isa Auto{Float32} + + # The props-aware UniformStep path is taken when kind === + # KIND_UNIFORM_STEP. For uniform data, Auto resolves to + # KIND_UNIFORM_STEP and `search_last(::Auto, ...)` uses the + # precomputed first_val + inv_step closed-form path. + for r in ( + 0.0:0.5:100.0, + LinRange(0.0, 100.0, 201), + collect(0.0:0.5:100.0), + collect(1:1000), + ) + a = Auto(r) + @test a.kind === KIND_UNIFORM_STEP + for x in (-1.0, 0.0, 1.7, 42.5, 99.9, 100.0, 1000.0) + if x > maximum(r) + 1 && eltype(r) <: Integer && !isinteger(x) + # skip mixed-type Int range queries with non-int x + continue + end + want_last = searchsortedlast(r, x) + want_first = searchsortedfirst(r, x) + @test search_last(a, r, x) == want_last + @test search_first(a, r, x) == want_first + # Hinted form takes the same path. + @test search_last(a, r, x, 1) == want_last + @test search_first(a, r, x, 1) == want_first + end + end + + # Reverse-order range. + r_rev = 10.0:-0.5:0.0 + a_rev = Auto(r_rev) + @test a_rev.kind === KIND_UNIFORM_STEP + for x in (-1.0, 0.0, 2.7, 5.0, 10.5) + @test search_last(a_rev, r_rev, x; order = Base.Order.Reverse) == + searchsortedlast(r_rev, x, Base.Order.Reverse) + @test search_first(a_rev, r_rev, x; order = Base.Order.Reverse) == + searchsortedfirst(r_rev, x, Base.Order.Reverse) + end + + # An Auto built from props alone (no v) still routes to the + # props-aware kernel when kind === KIND_UNIFORM_STEP. + p = SearchProperties(0.0:0.5:10.0) + a_p = Auto(p) + @test a_p.kind === KIND_UNIFORM_STEP + for x in (0.0, 1.7, 5.0, 9.9, 10.0) + @test search_last(a_p, 0.0:0.5:10.0, x) == + searchsortedlast(0.0:0.5:10.0, x) + end + end + @safetestset "Batched in-place searchsorted!" begin using FindFirstFunctions: LinearScan, BracketGallop, BinaryBracket, Auto, @@ -1101,16 +1220,22 @@ end @test @inferred(search_last(a, v, 50)) === 50 end - @testset "Vector{Auto} has concrete eltype" begin - # Mixed-underlying-kind Vector{Auto}: even when each Auto - # was constructed from a different v / props, the - # element type is `Auto` (concrete), not - # `Union{Auto{...}, Auto{...}}` or `Any`. - a1 = Auto(collect(1:100)) # KIND_UNIFORM_STEP - a2 = Auto(rand(StableRNG(10), 100)) # KIND_BRACKET_GALLOP (not uniform) - a3 = Auto(collect(1:8)) # KIND_LINEAR_SCAN - v = Auto[a1, a2, a3] - @test eltype(v) === Auto + @testset "Vector{Auto{T}} has concrete eltype" begin + # Mixed-underlying-kind Vector{Auto{T}}: even when each Auto + # was constructed from a different v / props (but with the + # same ratio eltype T), the element type is `Auto{T}` + # (concrete), not `Union{...}` or `Any`. `Vector{Int}` and + # `Vector{Float64}` both ratio-promote to `Float64`, so + # `Auto(collect(1:100))` and `Auto(rand(100))` are both + # `Auto{Float64}`. + a1 = Auto(collect(1:100)) # KIND_UNIFORM_STEP (Int → Float64) + a2 = Auto(rand(StableRNG(10), 100)) # KIND_BRACKET_GALLOP (Float64) + a3 = Auto(collect(1:8)) # KIND_LINEAR_SCAN (Int → Float64) + @test a1 isa Auto{Float64} + @test a2 isa Auto{Float64} + @test a3 isa Auto{Float64} + v = Auto{Float64}[a1, a2, a3] + @test eltype(v) === Auto{Float64} @test isconcretetype(eltype(v)) end From 298f4e2534f15dd5655185428c80b765db1e30ef Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Sat, 23 May 2026 06:15:21 -0400 Subject: [PATCH 03/14] SearchProperties: avoid props-aware path for non-Real numeric eltypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SearchProperties(::AbstractVector{<:Number})` used to call `T(v[1])` unconditionally, which trips a `DimensionError` on Unitful `Quantity` (or any other non-`Real` numeric type whose `T(::Quantity)` conversion is ill-defined). Split the overload: - `AbstractVector{<:Real}` keeps the props-aware path: populate `first_val::T` / `inv_step::T` so `Auto(v)` routes through the closed-form `KIND_UNIFORM_STEP` kernel. - `AbstractVector{<:Number}` (non-`Real`) runs only the linearity probe (which works on any `Number`), returns `SearchProperties{Float64}` with `is_uniform = false`, and leaves `first_val` / `inv_step` zero. Auto then resolves to `KIND_BRACKET_GALLOP` (or `KIND_LINEAR_SCAN`), matching v2 behaviour. `_auto_is_uniform` is correspondingly narrowed: `AbstractRange{<:Real}` remains "always uniform", but for `AbstractRange{<:Number}` (e.g. `StepRange{Quantity}`) we consult `props.is_uniform`, which is `false` under the new overload. This prevents the closed-form kernel from being invoked on Unitful ranges. Fixes the `DataInterpolations.jl` test failure under Unitful eltypes: `LinearInterpolation(u::Vector{Quantity{...,𝐋}}, t::StepRange{Quantity{...,𝐓}})`. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Chris Rackauckas --- src/auto.jl | 8 ++++++-- src/search_properties.jl | 25 ++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/auto.jl b/src/auto.jl index adf4f10..440124b 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -79,8 +79,12 @@ end end @inline _auto_simd_eligible(::AbstractVector, ::SearchProperties) = false -# Uniformity check. -@inline _auto_is_uniform(::AbstractRange, ::SearchProperties) = true +# Uniformity check. `AbstractRange{<:Real}` is always uniform; for non-Real +# range eltypes (e.g. Unitful `StepRange{Quantity}`) the props-aware +# closed-form path is unsafe, so we fall through to the `is_uniform` flag +# in `props` — which is `false` for non-Real numeric eltypes (set by the +# `SearchProperties(::AbstractVector{<:Number})` overload). +@inline _auto_is_uniform(::AbstractRange{<:Real}, ::SearchProperties) = true @inline _auto_is_uniform(::AbstractVector, p::SearchProperties) = p.has_props && p.is_uniform diff --git a/src/search_properties.jl b/src/search_properties.jl index 0f7348a..1e3c3ae 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -135,7 +135,7 @@ in the result so `UniformStep`'s closed-form path needs no per-query division. """ function SearchProperties( - v::AbstractVector{<:Number}; + v::AbstractVector{<:Real}; linear_tolerance::Real = 1.0e-3, is_uniform::Union{Nothing, Bool} = nothing, ) @@ -164,6 +164,29 @@ function SearchProperties( ) end +# Non-Real numeric eltype (e.g. Unitful `Quantity`, `Complex`): scalar +# conversion `T(::Quantity)` is unsafe (dimensional / non-Real), so we +# can't bake `first_val` / `inv_step` into the struct. Run the linearity +# probe (which only requires `Number` arithmetic) to populate +# `is_linear` / `has_nan` / `is_log_linear`, but leave `is_uniform = false` +# so the closed-form UniformStep kernel is never invoked. +function SearchProperties( + v::AbstractVector{<:Number}; + linear_tolerance::Real = 1.0e-3, + is_uniform::Union{Nothing, Bool} = nothing, + ) + tol = Float64(linear_tolerance) + err = _sampled_linear_err(v) + return SearchProperties{Float64}( + true, + err <= tol, + _has_nan(v), + _sampled_looks_log_linear(v, tol), + false, # never uniform — closed-form unsafe. + 0.0, 0.0, + ) +end + # Non-numeric eltype: `Float64` default T, `has_props = false`. Probes are # meaningless on non-Number data, so the result is the sentinel value. function SearchProperties( From d0bca6045351094bf2a1ae3a8ec6ca7fe2e3eea1 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Sat, 23 May 2026 06:54:18 -0400 Subject: [PATCH 04/14] test: relax Auto-on-AbstractRange allocation bound for Julia 1.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SearchProperties{Float64}` now carries `first_val::Float64` and `inv_step::Float64`, growing `Auto{Float64}` from 16 to 32 bytes. Julia 1.10's kwarg trampoline boxes the resulting NamedTuple as exactly 64 bytes; 1.11 elides the allocation entirely. The `< 64` bound (which fit 1.11 with margin) now reads as `64 < 64` on 1.10. Bump to `<= 64` to cover both — the call is still a tight closed-form lookup, just with one more boxed kwarg pointer than v2 had. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Chris Rackauckas --- test/runtests.jl | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index e637454..683a573 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -242,15 +242,20 @@ end # Auto on AbstractRange short-circuits to the closed-form path — # the answer matches Base's range-aware overload and the call is # near-zero overhead (a small kwarg trampoline shows up under - # `@allocated`, but no large heap traffic). + # `@allocated`, but no large heap traffic). The bound was raised + # from `< 64` to `<= 64` because `SearchProperties{Float64}` now + # carries `first_val::Float64` + `inv_step::Float64`, bumping + # `Auto{Float64}` from 16 to 32 bytes — Julia 1.10's kwarg + # trampoline allocates exactly 64 bytes for the resulting boxed + # NamedTuple (1.11 elides the allocation entirely). r_big = 0.0:0.001:100.0 @test searchsortedlast(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) @test searchsortedfirst(Auto(), r_big, 50.5) == searchsortedfirst(r_big, 50.5) # Warm up once, then verify allocation is tiny (kwarg trampoline only). searchsortedlast(Auto(), r_big, 50.5) - @test @allocated(searchsortedlast(Auto(), r_big, 50.5)) < 64 - @test @allocated(searchsortedfirst(Auto(), r_big, 50.5)) < 64 + @test @allocated(searchsortedlast(Auto(), r_big, 50.5)) <= 64 + @test @allocated(searchsortedfirst(Auto(), r_big, 50.5)) <= 64 # Batched path on AbstractRange. r = 0.0:0.5:100.0 From b0c7b4079bc8391f66ef47f0ba50de0ce7682a50 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Tue, 9 Jun 2026 21:46:06 -0400 Subject: [PATCH 05/14] Fix wrong-answer bugs in the props-aware UniformStep path Three correctness fixes for the closed-form O(1) lookup: - Clamp the float position f = diff * inv_step in the float domain before truncating. unsafe_trunc(Int, f) is UB once |f| exceeds typemax(Int), which any finite extreme query reaches; on x86 it returned typemin and the clamp then produced the wrong end of the vector (search_last(Auto(v), v, 1.0e300) returned firstindex). - Validate is_uniform exactly. The 11-point sampled probe can be fooled by data that is uniform at the probed points but jittered between them, and a false positive makes the closed form land in the wrong cell. A positive from the sampled pre-filter is now confirmed by an exact O(n) scan over every element before is_uniform is set. - Replace the kernels' single-step roundoff correction with a walk, and fall back to binary search when f is NaN (caller-forced is_uniform = true on a zero-span vector gives inv_step = Inf). A wrong closed-form guess now degrades to a slower search instead of a silently wrong index, which also keeps the searchsorted contract when a caller forces is_uniform on non-uniform data. Also guard the per-query Auto dispatch on props.has_props: an Auto holding the sentinel SearchProperties() has inv_step = 0, which made the closed-form guess degenerate into an O(n) walk from firstindex (~49,000x slower on a 1e6-element range). The sentinel case now routes to the fld-based kind kernel like the batched path already did. Regression tests for all four cases. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- NEWS.md | 13 ++++++- src/auto.jl | 26 +++++++------ src/kernels.jl | 47 ++++++++++++---------- src/search_properties.jl | 36 ++++++++++++++--- test/runtests.jl | 84 +++++++++++++++++++++++++++++++++++----- 5 files changed, 159 insertions(+), 47 deletions(-) diff --git a/NEWS.md b/NEWS.md index a64f737..a5f0b47 100644 --- a/NEWS.md +++ b/NEWS.md @@ -90,8 +90,17 @@ The struct carries two new fields: `(length(v) - 1) / (v[end] - v[1])`. These fields are populated when `is_uniform = true` and zero otherwise. -They feed the new **props-aware `UniformStep` kernel** invoked by -`Auto(v)` when the resolved kind is `KIND_UNIFORM_STEP`: + +For `AbstractVector` data, `is_uniform` is detected by the cheap 11-point +sampled pre-filter and then confirmed by an exact O(n) scan over every +element. The exact pass matters: data that is uniform at the sampled +points but jittered between them would otherwise be flagged uniform, and +the closed-form lookup would land in the wrong cell. The kernels also +carry a correction walk, so even a caller-forced `is_uniform = true` on +non-uniform data degrades to a slower search rather than a wrong answer. + +The populated fields feed the new **props-aware `UniformStep` kernel** +invoked by `Auto(v)` when the resolved kind is `KIND_UNIFORM_STEP`: ```julia # v3 closed-form O(1) lookup with no per-query division: diff --git a/src/auto.jl b/src/auto.jl index 440124b..bb5ef83 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -98,9 +98,9 @@ end # `_auto_resolve_kind` is forward-declared in `strategies.jl` so `Auto(v)` # can call it from the struct's constructor. The body lives here so it can -# use the helpers above. Mirrors the v2 `_auto_pick` logic but for -# *construction-time* resolution — the hint isn't known yet, so we pick a -# kind that handles every hint configuration robustly. +# use the helpers above. This is *construction-time* resolution — the hint +# isn't known yet, so we pick a kind that handles every hint configuration +# robustly. @inline function _auto_resolve_kind(v::AbstractVector, props::SearchProperties) if _auto_is_uniform(v, props) return KIND_UNIFORM_STEP @@ -118,10 +118,14 @@ end # hint and walks. So `search_last(::Auto, v, x, hint)` is a one-line # forward to the kind dispatcher. # -# Special case: when `kind === KIND_UNIFORM_STEP`, we route to the -# props-aware kernel that uses the precomputed `inv_step` from `props`, -# skipping the per-query float division in the back-compat AbstractRange -# `UniformStep` kernel. +# Special case: when `kind === KIND_UNIFORM_STEP` and `props` is +# populated, we route to the props-aware kernel that uses the precomputed +# `inv_step` from `props`, skipping the per-query float division in the +# back-compat AbstractRange `UniformStep` kernel. An `Auto` holding the +# sentinel `SearchProperties()` has `inv_step = 0`, which would silently +# degrade the closed-form guess to a linear walk — the `has_props` guard +# sends it to the `fld`-based kind kernel instead, matching the guard in +# the batched path. # --------------------------------------------------------------------------- # Hinted form: forward to the kind dispatcher. @@ -129,7 +133,7 @@ end s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - return if s.kind === KIND_UNIFORM_STEP + return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_last_uniform_step_props(s.props, v, x, order) else search_last(s.kind, v, x, hint; order = order) @@ -140,7 +144,7 @@ end s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - return if s.kind === KIND_UNIFORM_STEP + return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_first_uniform_step_props(s.props, v, x, order) else search_first(s.kind, v, x, hint; order = order) @@ -153,7 +157,7 @@ end s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return if s.kind === KIND_UNIFORM_STEP + return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_last_uniform_step_props(s.props, v, x, order) else search_last(s.kind, v, x; order = order) @@ -164,7 +168,7 @@ end s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return if s.kind === KIND_UNIFORM_STEP + return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_first_uniform_step_props(s.props, v, x, order) else search_first(s.kind, v, x; order = order) diff --git a/src/kernels.jl b/src/kernels.jl index 725da1a..9378304 100644 --- a/src/kernels.jl +++ b/src/kernels.jl @@ -569,8 +569,8 @@ end # =========================================================================== # Props-aware UniformStep — closed-form O(1) lookup using a precomputed -# `inv_step` baked into `SearchProperties{T}`. Subsumes the old DirectStep -# strategy: the per-query float division `fld(diff, step)` is hoisted to +# `inv_step` baked into `SearchProperties{T}`. The per-query float +# division `fld(diff, step)` is hoisted to # `SearchProperties` construction time, leaving the hot path with one # subtract, one multiply, one truncate, plus a bounds clamp and a one-step # roundoff correction. @@ -596,19 +596,27 @@ end end nm1 = length(v) - 1 f = diff * props.inv_step - i_raw = unsafe_trunc(Int, floor(f)) - i = if i_raw < 0 + # Clamp in the float domain before truncating: `f` can exceed + # `typemax(Int)` for finite extreme `x`, where `unsafe_trunc` is UB. + # `f` is NaN when `diff == 0` and `inv_step == Inf` (caller-supplied + # `is_uniform = true` on a zero-span vector). + isnan(f) && return _kernel_last_binary_bracket(v, x, order) + i = if f < 0 firstindex(v) - 1 - elseif i_raw >= nm1 + elseif f >= nm1 lastindex(v) else - firstindex(v) + i_raw + firstindex(v) + unsafe_trunc(Int, floor(f)) end - @inbounds if i < lastindex(v) && !Base.Order.lt(order, x, v[i + 1]) - return i + 1 - elseif i >= firstindex(v) && i <= lastindex(v) && - Base.Order.lt(order, x, v[i]) - return i - 1 + # Walk to the true cell. For exactly-uniform data this takes at most + # one step (float roundoff); it also keeps the result correct when + # `is_uniform` came from the sampled probe but the data is not + # uniform between the sampled points. + @inbounds while i < lastindex(v) && !Base.Order.lt(order, x, v[i + 1]) + i += 1 + end + @inbounds while i >= firstindex(v) && Base.Order.lt(order, x, v[i]) + i -= 1 end return i end @@ -627,20 +635,19 @@ end end nm1 = length(v) - 1 f = diff * props.inv_step - i_raw = unsafe_trunc(Int, ceil(f)) - i = if i_raw <= 0 + isnan(f) && return _kernel_first_binary_bracket(v, x, order) + i = if f <= 0 firstindex(v) - elseif i_raw > nm1 + elseif f > nm1 lastindex(v) + 1 else - firstindex(v) + i_raw + firstindex(v) + unsafe_trunc(Int, ceil(f)) end - @inbounds if i > firstindex(v) && i <= lastindex(v) + 1 && - !Base.Order.lt(order, v[i - 1], x) - return i - 1 + @inbounds while i > firstindex(v) && !Base.Order.lt(order, v[i - 1], x) + i -= 1 end - @inbounds if i <= lastindex(v) && Base.Order.lt(order, v[i], x) - return i + 1 + @inbounds while i <= lastindex(v) && Base.Order.lt(order, v[i], x) + i += 1 end return i end diff --git a/src/search_properties.jl b/src/search_properties.jl index 1e3c3ae..d554045 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -71,8 +71,29 @@ const _UNIFORM_REL_TOLERANCE = 1.0e-12 v::AbstractVector, tol::Float64 = _AUTO_LINEAR_REL_TOLERANCE, ) = _sampled_linear_err(v) <= tol -@inline _sampled_looks_uniform(v::AbstractVector) = - _sampled_linear_err(v) <= _UNIFORM_REL_TOLERANCE +# Exact uniformity validation: checks EVERY element against the straight +# line between the endpoints, not just the 11 sampled points. `is_uniform` +# gates closed-form O(1) lookups whose answers are silently wrong when the +# flag is a false positive, so a sampled probe is not enough — data that is +# uniform at the sampled points but jittered between them must be rejected. +# Only run when the cheap sampled probe already passed, so the O(n) cost is +# paid exactly once at construction and only for plausibly-uniform data. +function _validated_uniform(v::AbstractVector{<:Real}) + n = length(v) + n < 2 && return false + @inbounds begin + v1, vn = v[1], v[n] + span = Float64(vn - v1) + (iszero(span) || !isfinite(span)) && return false + nm1 = n - 1 + for i in 2:nm1 + expected = v1 + (i - 1) / nm1 * span + abs(Float64(v[i] - expected)) > _UNIFORM_REL_TOLERANCE * abs(span) && + return false + end + end + return true +end # Sampled "log-linear" probe: same 9-point probe as `_sampled_looks_linear` # but tests whether `log(v)` is linear in array index. Used to detect @@ -116,8 +137,9 @@ Run the linearity probe and (for floating-point eltypes) the NaN scan on `v`, returning the populated [`SearchProperties{T}`](@ref) where `T` is the data ratio type (e.g. `Float64` for `Vector{Int}` or `Vector{Float64}`, `Float32` for `Vector{Float32}`). Cost is O(n) on floating-point vectors -because of the NaN scan; for integer and non-numeric eltypes the cost is -O(1) — only the sampled-linearity probe runs. +because of the NaN scan; for integer eltypes the cost is O(1) (only the +sampled-linearity probe runs) unless the sampled probe flags the data as +uniform, in which case an exact O(n) validation scan confirms it. `linear_tolerance` controls the maximum relative deviation accepted by the sampled-linearity probe. The default `1e-3` (0.1%) matches `Auto`'s @@ -145,7 +167,11 @@ function SearchProperties( # check. `is_uniform = nothing` (default) means "infer from the # probe"; an explicit Bool overrides. err = _sampled_linear_err(v) - detected_uniform = err <= _UNIFORM_REL_TOLERANCE + # The sampled probe is a cheap pre-filter; a positive is confirmed by + # an exact full scan because `is_uniform` gates closed-form lookups + # that would silently return wrong answers on a false positive. An + # explicit caller-supplied Bool skips both probes. + detected_uniform = err <= _UNIFORM_REL_TOLERANCE && _validated_uniform(v) flag_uniform = is_uniform === nothing ? detected_uniform : is_uniform first_val, inv_step = if flag_uniform && length(v) >= 2 # Closed-form `inv_step` from sampled endpoints. Promotes Int → Float64. diff --git a/test/runtests.jl b/test/runtests.jl index 683a573..777f8ae 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -239,15 +239,11 @@ end @test searchsortedfirst(UniformStep(), v, x) == searchsortedfirst(v, x) end - # Auto on AbstractRange short-circuits to the closed-form path — - # the answer matches Base's range-aware overload and the call is - # near-zero overhead (a small kwarg trampoline shows up under - # `@allocated`, but no large heap traffic). The bound was raised - # from `< 64` to `<= 64` because `SearchProperties{Float64}` now - # carries `first_val::Float64` + `inv_step::Float64`, bumping - # `Auto{Float64}` from 16 to 32 bytes — Julia 1.10's kwarg - # trampoline allocates exactly 64 bytes for the resulting boxed - # NamedTuple (1.11 elides the allocation entirely). + # Auto() answers match Base's range-aware overload and the call + # is near-zero overhead: no heap traffic beyond Julia 1.10's + # kwarg trampoline, which boxes the NamedTuple at exactly 64 + # bytes for the 32-byte `Auto{Float64}` argument (1.11 elides + # the allocation entirely) — hence the `<= 64` bound below. r_big = 0.0:0.001:100.0 @test searchsortedlast(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) @test searchsortedfirst(Auto(), r_big, 50.5) == @@ -721,6 +717,76 @@ end @test search_last(a_p, 0.0:0.5:10.0, x) == searchsortedlast(0.0:0.5:10.0, x) end + + # An Auto with KIND_UNIFORM_STEP but the unpopulated sentinel + # props (has_props = false, inv_step = 0) must not take the + # props-aware closed form — it falls through to the fld-based + # range kernel, which stays O(1) and correct. + a_sent = Auto(0.0:0.5:10.0, SearchProperties()) + @test a_sent.kind === KIND_UNIFORM_STEP + @test !a_sent.props.has_props + for x in (-1.0, 0.0, 1.7, 5.0, 10.0, 11.0) + @test search_last(a_sent, 0.0:0.5:10.0, x) == + searchsortedlast(0.0:0.5:10.0, x) + @test search_first(a_sent, 0.0:0.5:10.0, x) == + searchsortedfirst(0.0:0.5:10.0, x) + end + + # Data uniform at the 11 sampled probe points but jittered + # between them must NOT be flagged uniform — the exact + # validation scan rejects what the sampled pre-filter misses. + v_trick = collect(1.0:101.0) + v_trick[52:60] .= range(54.5, 60.0, length = 9) + @test issorted(v_trick) + p_trick = SearchProperties(v_trick) + @test !p_trick.is_uniform + + # Even when a caller forces is_uniform = true on such data, + # the kernel's correction walk keeps the searchsorted + # contract (slower, but never silently wrong). + p_forced = SearchProperties(v_trick; is_uniform = true) + @test p_forced.is_uniform + a_trick = Auto(p_forced) + @test a_trick.kind === KIND_UNIFORM_STEP + for x in (54.0, 54.5, 55.1875, 0.5, 101.0, 60.0) + @test search_last(a_trick, v_trick, x) == + searchsortedlast(v_trick, x) + @test search_first(a_trick, v_trick, x) == + searchsortedfirst(v_trick, x) + end + + # Extreme finite queries: `diff * inv_step` exceeds typemax(Int), + # so the kernel must clamp in the float domain before truncating. + v_u = collect(0.0:1.0:99.0) + a_u = Auto(v_u) + @test a_u.kind === KIND_UNIFORM_STEP + for x in (1.0e300, -1.0e300, floatmax(Float64), -floatmax(Float64)) + @test search_last(a_u, v_u, x) == searchsortedlast(v_u, x) + @test search_first(a_u, v_u, x) == searchsortedfirst(v_u, x) + end + + # Caller-supplied is_uniform = true on a zero-span vector makes + # inv_step = Inf and f = NaN at x == first_val; must fall back + # rather than hit unsafe_trunc(NaN). + c = fill(5.0, 10) + p_c = SearchProperties(c; is_uniform = true) + a_c = Auto(p_c) + for x in (5.0, 4.0, 6.0) + @test search_last(a_c, c, x) == searchsortedlast(c, x) + @test search_first(a_c, c, x) == searchsortedfirst(c, x) + end + + # Reverse-ordered uniform Vector through the props kernel. + v_rev = collect(100.0:-1.0:1.0) + p_rev = SearchProperties(v_rev) + @test p_rev.is_uniform + a_vrev = Auto(p_rev) + for x in (50.5, 1.0, 100.0, 1.0e300, -3.0) + @test search_last(a_vrev, v_rev, x; order = Base.Order.Reverse) == + searchsortedlast(v_rev, x, Base.Order.Reverse) + @test search_first(a_vrev, v_rev, x; order = Base.Order.Reverse) == + searchsortedfirst(v_rev, x, Base.Order.Reverse) + end end @safetestset "Batched in-place searchsorted!" begin From c7414ceb2231980bf6964df9ba1fd44031a0cd35 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Tue, 9 Jun 2026 21:46:18 -0400 Subject: [PATCH 06/14] findequal: route KIND_BISECT_THEN_SIMD to the SIMD shortcut The enum-tagged findequal forms went through the generic search_first + post-check path for every kind, so the documented v3 migration findequal(BisectThenSIMD(), v, x) -> findequal(KIND_BISECT_THEN_SIMD, v, x) silently lost the DenseVector{Int64} bisect-then-SIMD shortcut. Forward that kind to the struct form, which carries the specialized dispatch. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- src/findequal.jl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/findequal.jl b/src/findequal.jl index 8bf29ad..67bea14 100644 --- a/src/findequal.jl +++ b/src/findequal.jl @@ -45,11 +45,15 @@ end return _findequal_postcheck(v, x, i) end -# Enum-tagged form. +# Enum-tagged form. `KIND_BISECT_THEN_SIMD` forwards to the struct form so +# the `DenseVector{Int64}` bisect-then-SIMD shortcut is reached — the +# generic `search_first` path would silently lose it. @inline function findequal( kind::StrategyKind, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) + kind === KIND_BISECT_THEN_SIMD && + return findequal(BisectThenSIMD(), v, x; order = order) i = search_first(kind, v, x; order = order) return _findequal_postcheck(v, x, i) end @@ -58,6 +62,8 @@ end kind::StrategyKind, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) + kind === KIND_BISECT_THEN_SIMD && + return findequal(BisectThenSIMD(), v, x, hint; order = order) i = search_first(kind, v, x, hint; order = order) return _findequal_postcheck(v, x, i) end From ba08d0a0c613d207f1a0805c116cf286d1e427e8 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Tue, 9 Jun 2026 21:46:18 -0400 Subject: [PATCH 07/14] Clean up stale comments, docstrings, and dead code - kinds.jl / legacy_dispatch.jl claimed the back-compat shims emit a depwarn; they deliberately do not (NEWS documents why). State the actual v4-removal plan instead. - strategy_kind docstring said Auto throws; it returns the stored kind. - searchsortedrange docstring now describes the actual hint seeding (max(first_idx, hint) for the upper endpoint). - Drop the unused (and broken) bench_per_query function in the props bench. - Strip change-history narration from comments per repo convention. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- bench/uniform_step_props_bench.jl | 11 ----------- src/batched.jl | 13 ++++++++----- src/kinds.jl | 18 +++++++++--------- src/legacy_dispatch.jl | 4 ---- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/bench/uniform_step_props_bench.jl b/bench/uniform_step_props_bench.jl index c266035..3a3fbff 100644 --- a/bench/uniform_step_props_bench.jl +++ b/bench/uniform_step_props_bench.jl @@ -19,17 +19,6 @@ using FindFirstFunctions: Auto, UniformStep, BracketGallop, SearchProperties const RNG = StableRNG(12345) -function bench_per_query(strategy_or_kind, v, queries) - out = Vector{Int}(undef, length(queries)) - bench = @benchmarkable for k in eachindex($queries) - $out[k] = $( - strategy_or_kind isa FindFirstFunctions.SearchStrategy ? - :(searchsortedlast) : :(FindFirstFunctions.search_last) - )($strategy_or_kind, $v, $queries[k]) - end seconds = 1 evals = 1 samples = 200 - return run(bench) -end - # Two-arg form (per-query, no hint), Auto-style: function bench_auto(s, v, queries) out = Vector{Int}(undef, length(queries)) diff --git a/src/batched.jl b/src/batched.jl index 0562b8a..3bc0a38 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -203,7 +203,7 @@ function _searchsortedfirst_batched!( end # Dispatch helper: route singleton struct → kind loop, stateful struct → -# struct loop. Inlining means no extra cost vs. the v2 single-loop form. +# struct loop. Both inline. @inline function _searchsortedlast_sorted_loop_strategy_dispatch!( idx_out, v, queries, strategy::SearchStrategy, order, ) @@ -346,8 +346,9 @@ function _searchsortedfirst_batched!( return _searchsortedfirst_sorted_loop_kind!(idx_out, v, queries, kind, order) end -# Batched Auto's kind picker: the v2 decision tree, returning a -# `StrategyKind` instead of branching to different loop specializations. +# Batched Auto's kind picker: returns a `StrategyKind` instead of +# branching to different loop specializations, so the loop is +# kind-parameterized and type-stable. @inline function _auto_batched_kind( v::AbstractVector, props::SearchProperties, gap::Integer, skewed::Bool, m::Integer, @@ -384,8 +385,10 @@ under `order`. Equivalent to `searchsortedfirst(strategy, v, lo[, hint]; order) : searchsortedlast(strategy, v, hi[, hint]; order)`. -When a `hint` is supplied it is used for both endpoint searches. -Strategies that ignore the hint treat the hinted form as a pass-through. +When a `hint` is supplied it seeds the `lo` search directly; the `hi` +search is seeded with `max(first_idx, hint)`, since the upper endpoint +can never precede the lower one. Strategies that ignore the hint treat +the hinted form as a pass-through. """ @inline function searchsortedrange( strategy::SearchStrategy, v::AbstractVector, lo, hi; diff --git a/src/kinds.jl b/src/kinds.jl index 1175548..5d1f339 100644 --- a/src/kinds.jl +++ b/src/kinds.jl @@ -5,13 +5,12 @@ # entry point: a runtime-tag dispatcher that branches on the enum and # inlines into the matching kernel function (defined in `kernels.jl`). # -# The runtime-branch bench (DataInterpolations.jl PR #531) confirmed that -# an `if/elseif/...` over a `StrategyKind` value is ~0 ns overhead in hot -# loops because the branch is well-predicted (or eliminated by constant +# An `if/elseif/...` over a `StrategyKind` value is ~0 ns overhead in hot +# loops: the branch is well-predicted (or eliminated by constant # propagation when the kind is known at the call site), the kernel bodies -# inline, and the return path stays type-stable — none of the Union-return -# pathology that the old type-parameter dispatch over `SearchStrategy` -# subtypes suffered from when the chosen strategy depended on runtime data. +# inline, and the return path stays a concrete `Int` even when the kind is +# chosen from runtime data — type-parameter dispatch over `SearchStrategy` +# subtypes would produce `Union` returns in that situation. # # Stateful strategies (`GuesserHint(::Guesser)`) do *not* live in the enum. # They carry per-instance data, so a singleton tag would lose information. @@ -66,7 +65,7 @@ own `search_last` method. This is the preferred entry point for new code. The legacy `Base.searchsortedlast(::SearchStrategy, ...)` methods still work for -back-compat but are deprecated and emit a depwarn on first use. +back-compat but are deprecated and will be removed in v4. """ @inline function search_last( kind::StrategyKind, v::AbstractVector, x; @@ -229,7 +228,8 @@ end """ strategy_kind(s::SearchStrategy) -> StrategyKind -Map a singleton strategy struct to its enum tag. Stateful strategies -(`GuesserHint`, `Auto`) do not have a tag and throw `ArgumentError`. +Map a singleton strategy struct to its enum tag. `Auto` returns its +stored resolved kind. `GuesserHint` (genuinely stateful, no singleton +tag) throws `ArgumentError`. """ function strategy_kind end diff --git a/src/legacy_dispatch.jl b/src/legacy_dispatch.jl index 440060b..307473c 100644 --- a/src/legacy_dispatch.jl +++ b/src/legacy_dispatch.jl @@ -7,10 +7,6 @@ # These shims are scheduled for removal in the next major version (v4). # New code should call `search_last` / `search_first` with a # `StrategyKind` value directly. -# -# Each shim emits a `Base.depwarn` once per call site (the `depwarn` -# infrastructure de-duplicates by `(symbol, file:line)`), encouraging -# migration to the new API while keeping existing code working. # `strategy_kind(s)` — the public mapping from strategy struct → tag. strategy_kind(::BinaryBracket) = KIND_BINARY_BRACKET From d61f47df286484cdd8f3b29d605550019e74af6b Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Tue, 9 Jun 2026 23:54:08 -0400 Subject: [PATCH 08/14] Fix docs build: unresolvable @refs, ambiguous slug, missing @docs entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makedocs(strict on missing_docs/cross_references) failed on four counts carried over from the v2 docs: - [`searchsortedlast`](@ref Base.searchsortedlast) in batched.jl docstrings cannot resolve (Base docstrings are not in this build) — use plain text. - The [Equality search](@ref) link was ambiguous between the equality.md page title and a strategies.md heading of the same name — rename the heading and target the page slug explicitly. - _simd_scan_ir has no docstring, so its @ref fails — de-link it. - searchsortedrange has a docstring but appeared in no @docs block — add it to interface.md. Verified with a full local docs build (zero errors). Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- docs/src/strategies.md | 4 ++-- src/batched.jl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/strategies.md b/docs/src/strategies.md index 3374c6b..37802b5 100644 --- a/docs/src/strategies.md +++ b/docs/src/strategies.md @@ -274,10 +274,10 @@ The per-query `search_last(::Auto, v, x, hint)` is a one-line forward to `searchsortedlast!(out, v, queries; strategy = Auto())` re-resolves the kind from `(v, queries)` to consult the gap heuristic. -## Equality search +## Equality routines The package exposes two `Union{Int, Nothing}`-returning equality routines — [`findfirstequal`](@ref FindFirstFunctions.findfirstequal) (unsorted SIMD scan) and [`findfirstsortedequal`](@ref FindFirstFunctions.findfirstsortedequal) -(sorted bisect-then-SIMD scan). See the [Equality search](@ref) page for +(sorted bisect-then-SIMD scan). See the [Equality search](@ref Equality-search) page for the full documentation. diff --git a/src/batched.jl b/src/batched.jl index 3bc0a38..0024fff 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -7,7 +7,7 @@ searchsortedlast!(idx_out, v, queries; strategy = Auto(), order = Base.Order.Forward, queries_sorted = nothing) -In-place batched [`searchsortedlast`](@ref Base.searchsortedlast). Writes +In-place batched `Base.searchsortedlast`. Writes one index per element of `queries` into `idx_out` (which must be the same length). @@ -50,7 +50,7 @@ end searchsortedfirst!(idx_out, v, queries; strategy = Auto(), order = Base.Order.Forward, queries_sorted = nothing) -In-place batched [`searchsortedfirst`](@ref Base.searchsortedfirst). See +In-place batched `Base.searchsortedfirst`. See [`searchsortedlast!`](@ref) for behavior. """ function searchsortedfirst!( From 696340d22e3dd928b042ff5b2bc474642a2ef364 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Wed, 10 Jun 2026 05:22:42 -0400 Subject: [PATCH 09/14] Drop one-off dev bench scripts; fix bench env compat for v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enum_vs_typeparam_dispatch.jl and uniform_step_props_bench.jl were development sweeps used to justify the v3 dispatch design; their findings are recorded in NEWS.md and the PR discussion, and they have no ongoing maintenance value. The maintained sweeps referenced from the docs (auto_sweep.jl, analyze.jl, bitinterp_sweep.jl) stay. bench/Project.toml pinned FindFirstFunctions = "2.1.0", which cannot resolve against the dev'd 3.0.0 — bumped to "3". Verified the bench environment instantiates with the local v3 package and that every FindFirstFunctions symbol referenced by the remaining scripts exists in v3. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- NEWS.md | 6 +- bench/Project.toml | 2 +- bench/enum_vs_typeparam_dispatch.jl | 102 ---------------------------- bench/uniform_step_props_bench.jl | 78 --------------------- 4 files changed, 4 insertions(+), 184 deletions(-) delete mode 100644 bench/enum_vs_typeparam_dispatch.jl delete mode 100644 bench/uniform_step_props_bench.jl diff --git a/NEWS.md b/NEWS.md index a5f0b47..5dbce1c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -22,9 +22,9 @@ search_first(KIND_INTERPOLATION_SEARCH, v, x) The runtime `if/elseif` over `StrategyKind` values is well-predicted in hot loops, the kernel bodies inline, and the return path stays concrete -(`Int`) regardless of which kind is picked at runtime. The -`bench/enum_vs_typeparam_dispatch.jl` sweep confirms ~0 ns of overhead -vs. the v2 multimethod path across 20 representative cells. +(`Int`) regardless of which kind is picked at runtime. A benchmark sweep +across 20 representative cells measured ~0 ns of overhead vs. the v2 +multimethod path. ### What changed at the API level diff --git a/bench/Project.toml b/bench/Project.toml index aa3d72b..1207e36 100644 --- a/bench/Project.toml +++ b/bench/Project.toml @@ -7,7 +7,7 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" [compat] BenchmarkTools = "1.8.0" -FindFirstFunctions = "2.1.0" +FindFirstFunctions = "3" PrettyTables = "3.3.2" StableRNGs = "1.0.4" Statistics = "1.11.1" diff --git a/bench/enum_vs_typeparam_dispatch.jl b/bench/enum_vs_typeparam_dispatch.jl deleted file mode 100644 index c07b910..0000000 --- a/bench/enum_vs_typeparam_dispatch.jl +++ /dev/null @@ -1,102 +0,0 @@ -# Bench: enum-tagged dispatch (`search_last(KIND_X, ...)`) vs the legacy -# multimethod-on-struct dispatch (`Base.searchsortedlast(::S(), ...)`). -# -# Confirms the v3 claim: the runtime `if/elseif` over a `StrategyKind` -# value adds ~0 ns of overhead because it is well-predicted in hot loops, -# the kernel bodies inline, and the return type stays type-stable. -# -# Usage: `julia +1.11 --project=bench bench/enum_vs_typeparam_dispatch.jl` -# (or run from the repo root via `Pkg.activate("bench")`). - -using BenchmarkTools, FindFirstFunctions, Printf, StableRNGs - -const RNG_SEED = 9023 - -# Representative grid sizes. Small (cache-resident), medium, large. -const GRID_SIZES = (16, 256, 4_096, 65_536) - -# Strategies under test — only the ones where the enum dispatch matters. -# `BinaryBracket` and `InterpolationSearch` ignore the hint, so the per-call -# overhead surface is smaller; still useful to confirm parity. -const STRATEGIES = ( - (BracketGallop(), KIND_BRACKET_GALLOP, "BracketGallop"), - (LinearScan(), KIND_LINEAR_SCAN, "LinearScan"), - (ExpFromLeft(), KIND_EXP_FROM_LEFT, "ExpFromLeft"), - (InterpolationSearch(), KIND_INTERPOLATION_SEARCH, "InterpolationSearch"), - (BinaryBracket(), KIND_BINARY_BRACKET, "BinaryBracket"), -) - -# Helper: median time in ns for one call configuration. -function bench_ns(f, args...; samples = 500) - b = @benchmark $f($(args)...) samples = samples evals = 50 seconds = 2 - return BenchmarkTools.minimum(b).time -end - -# Hot-loop variant: total elapsed time across `m` queries, normalized to ns/q. -# This is the realistic per-call cost — the per-iteration `if/elseif` over -# the enum value is the workload we're measuring. -function hot_loop_legacy(strategy, v, queries, hints) - s = 0 - @inbounds for i in eachindex(queries) - s += searchsortedlast(strategy, v, queries[i], hints[i]) - end - return s -end - -function hot_loop_kind(kind, v, queries, hints) - s = 0 - @inbounds for i in eachindex(queries) - s += search_last(kind, v, queries[i], hints[i]) - end - return s -end - -function build_workload(n, rng) - v = sort!(rand(rng, n)) - # Query positions: a mix of in-vector and out-of-range. Each query - # comes with a hint that's ±3 of the true answer (the "good hint" regime - # where hint-using strategies shine). - m = max(64, n ÷ 16) - queries = sort!(rand(rng, m)) - truths = [searchsortedlast(v, q) for q in queries] - hints = [clamp(t + rand(rng, -3:3), 1, n) for t in truths] - return (v, queries, hints) -end - -function main() - rng = StableRNG(RNG_SEED) - rows = Any[] - for n in GRID_SIZES - v, queries, hints = build_workload(n, rng) - for (s, kind, name) in STRATEGIES - t_legacy = bench_ns(hot_loop_legacy, s, v, queries, hints) - t_kind = bench_ns(hot_loop_kind, kind, v, queries, hints) - # Per-query overhead numbers. - m = length(queries) - ns_legacy = t_legacy / m - ns_kind = t_kind / m - delta = ns_kind - ns_legacy - pct = delta / ns_legacy * 100 - push!( - rows, - ( - n = n, strategy = name, - legacy_ns_q = round(ns_legacy; digits = 1), - enum_ns_q = round(ns_kind; digits = 1), - delta_ns_q = round(delta; digits = 2), - delta_pct = round(pct; digits = 1), - ), - ) - end - end - @printf "%-8s %-22s %-12s %-12s %-12s %s\n" "n" "strategy" "legacy ns/q" "enum ns/q" "Δ ns/q" "Δ %" - println("-"^80) - for r in rows - @printf "%-8d %-22s %-12.2f %-12.2f %-12.2f %.1f\n" r.n r.strategy r.legacy_ns_q r.enum_ns_q r.delta_ns_q r.delta_pct - end - return rows -end - -if abspath(PROGRAM_FILE) == @__FILE__ - main() -end diff --git a/bench/uniform_step_props_bench.jl b/bench/uniform_step_props_bench.jl deleted file mode 100644 index 3a3fbff..0000000 --- a/bench/uniform_step_props_bench.jl +++ /dev/null @@ -1,78 +0,0 @@ -#= -Per-query latency: UniformStep via Auto+props (closed-form, precomputed -inv_step) vs UniformStep via raw struct (fld(diff, step) per query) vs -BracketGallop with valid hint. - -Usage: - julia +1.11 --project=bench bench/uniform_step_props_bench.jl -=# - -import Pkg -Pkg.activate(@__DIR__) - -using BenchmarkTools -using Statistics -using StableRNGs - -using FindFirstFunctions -using FindFirstFunctions: Auto, UniformStep, BracketGallop, SearchProperties - -const RNG = StableRNG(12345) - -# Two-arg form (per-query, no hint), Auto-style: -function bench_auto(s, v, queries) - out = Vector{Int}(undef, length(queries)) - bench = @benchmarkable for k in eachindex($queries) - $out[k] = searchsortedlast($s, $v, $queries[k]) - end seconds = 1 evals = 1 samples = 200 - return run(bench) -end - -# BracketGallop with chained hint (monotone queries). -function bench_bracket_hint(s, v, queries) - out = Vector{Int}(undef, length(queries)) - bench = @benchmarkable begin - h = firstindex($v) - 1 - for k in eachindex($queries) - h = if h < firstindex($v) - searchsortedlast($s, $v, $queries[k]) - else - searchsortedlast($s, $v, $queries[k], h) - end - $out[k] = h - end - end seconds = 1 evals = 1 samples = 200 - return run(bench) -end - -fmt(t) = string(round(median(t).time / length(QUERIES); digits = 2), " ns/q") - -const N = 10_000 -const M = 1_000 - -const r = range(0.0, 100.0; length = N) -const v_uniform = collect(r) -const queries_sorted = sort!(rand(RNG, M) .* 100.0) -const queries_random = rand(StableRNG(11), M) .* 100.0 -const QUERIES = queries_sorted - -println("==== n = $N, m = $M ====\n") - -println("=== Sorted queries on AbstractRange (range) ===") -println("Auto(r) (UniformStep + props) : ", fmt(bench_auto(Auto(r), r, queries_sorted))) -println("UniformStep() (range path, fld) : ", fmt(bench_auto(UniformStep(), r, queries_sorted))) -println("BracketGallop() (with hint chain) : ", fmt(bench_bracket_hint(BracketGallop(), r, queries_sorted))) - -println("\n=== Sorted queries on Vector{Float64} (uniform) ===") -println("Auto(v) (UniformStep + props) : ", fmt(bench_auto(Auto(v_uniform), v_uniform, queries_sorted))) -println("UniformStep() (vector path → bracket): ", fmt(bench_auto(UniformStep(), v_uniform, queries_sorted))) -println("BracketGallop() (with hint chain) : ", fmt(bench_bracket_hint(BracketGallop(), v_uniform, queries_sorted))) - -println("\n=== Random queries on AbstractRange ===") -println("Auto(r) (UniformStep + props) : ", fmt(bench_auto(Auto(r), r, queries_random))) -println("UniformStep() (range path, fld) : ", fmt(bench_auto(UniformStep(), r, queries_random))) -println("BracketGallop() (hinted, miss path) : ", fmt(bench_bracket_hint(BracketGallop(), r, queries_random))) - -println("\n=== Random queries on Vector{Float64} (uniform) ===") -println("Auto(v) (UniformStep + props) : ", fmt(bench_auto(Auto(v_uniform), v_uniform, queries_random))) -println("BracketGallop() (hinted, miss path) : ", fmt(bench_bracket_hint(BracketGallop(), v_uniform, queries_random))) From 71623fcfdebe930c88957db73aa954c2e1cbbd56 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Fri, 12 Jun 2026 17:11:46 -0400 Subject: [PATCH 10/14] Remove the v2 Base.searchsortedlast/searchsortedfirst extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 is a breaking release — drop the back-compat layer instead of carrying it to v4. FindFirstFunctions no longer extends Base.searchsortedlast / Base.searchsortedfirst with strategy methods; search_last / search_first are the only search entry points. - Delete the per-singleton Base shims (legacy_dispatch.jl) and the Base shims for Auto and GuesserHint. - Add search_last / search_first methods that accept a singleton strategy struct directly, forwarding through strategy_kind (which constant-folds for literal strategies, so the struct form costs nothing over the kind form). The structs stay as the friendly strategy names; the file is now strategy_kind.jl. - findequal's struct form and searchsortedrange / the batched sorted loops now route through search_first / search_last internally. - Precompile workload, tests, NEWS, and the docs migration guide updated to the v3-only API. New guard testset asserts no Base.searchsorted strategy methods exist, against accidental reintroduction. The FFF-owned batched API (searchsortedlast! / searchsortedfirst! / searchsortedrange) and the equality API are unchanged. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- NEWS.md | 64 ++++----- docs/src/strategies.md | 56 ++++---- src/FindFirstFunctions.jl | 13 +- src/auto.jl | 23 +-- src/batched.jl | 22 +-- src/findequal.jl | 9 +- src/guesser.jl | 18 --- src/kernels.jl | 7 +- src/kinds.jl | 12 +- src/legacy_dispatch.jl | 70 --------- src/precompile.jl | 18 +-- src/strategies.jl | 21 ++- src/strategy_kind.jl | 49 +++++++ test/runtests.jl | 290 ++++++++++++++++++++------------------ 14 files changed, 310 insertions(+), 362 deletions(-) delete mode 100644 src/legacy_dispatch.jl create mode 100644 src/strategy_kind.jl diff --git a/NEWS.md b/NEWS.md index 5dbce1c..1fa6a24 100644 --- a/NEWS.md +++ b/NEWS.md @@ -26,29 +26,29 @@ hot loops, the kernel bodies inline, and the return path stays concrete across 20 representative cells measured ~0 ns of overhead vs. the v2 multimethod path. -### What changed at the API level - -| v2 | v3 (preferred) | v3 back-compat (shim, removed in v4) | -|---|---|---| -| `searchsortedlast(BracketGallop(), v, x, hint)` | `search_last(KIND_BRACKET_GALLOP, v, x, hint)` | `searchsortedlast(BracketGallop(), v, x, hint)` still works | -| `searchsortedfirst(InterpolationSearch(), v, x)` | `search_first(KIND_INTERPOLATION_SEARCH, v, x)` | `searchsortedfirst(InterpolationSearch(), v, x)` still works | -| `searchsortedlast(LinearScan(), v, x, hint)` | `search_last(KIND_LINEAR_SCAN, v, x, hint)` | `searchsortedlast(LinearScan(), v, x, hint)` still works | -| `searchsortedlast(BinaryBracket(), v, x)` | `search_last(KIND_BINARY_BRACKET, v, x)` | `searchsortedlast(BinaryBracket(), v, x)` still works | -| `searchsortedlast(UniformStep(), r, x)` | `search_last(KIND_UNIFORM_STEP, r, x)` | `searchsortedlast(UniformStep(), r, x)` still works | -| `searchsortedlast(SIMDLinearScan(), v, x, hint)` | `search_last(KIND_SIMD_LINEAR_SCAN, v, x, hint)` | `searchsortedlast(SIMDLinearScan(), v, x, hint)` still works | -| `searchsortedlast(ExpFromLeft(), v, x, hint)` | `search_last(KIND_EXP_FROM_LEFT, v, x, hint)` | `searchsortedlast(ExpFromLeft(), v, x, hint)` still works | -| `searchsortedlast(BitInterpolationSearch(), v, x)` | `search_last(KIND_BIT_INTERPOLATION_SEARCH, v, x)` | `searchsortedlast(BitInterpolationSearch(), v, x)` still works | -| `findequal(BisectThenSIMD(), v, x)` | `findequal(KIND_BISECT_THEN_SIMD, v, x)` | `findequal(BisectThenSIMD(), v, x)` still works | +### Breaking: the `Base.searchsortedlast(::S, ...)` API is removed + +v3 no longer extends `Base.searchsortedlast` / `Base.searchsortedfirst` +with strategy methods. The FFF-owned `search_last` / `search_first` +dispatchers are the only search entry points; they accept a +`StrategyKind` tag, a strategy struct (which forwards through +[`strategy_kind`] and constant-folds for literal strategies), or a +stateful strategy (`Auto`, `GuesserHint`): + +| v2 (removed) | v3 | +|---|---| +| `searchsortedlast(BracketGallop(), v, x, hint)` | `search_last(KIND_BRACKET_GALLOP, v, x, hint)` or `search_last(BracketGallop(), v, x, hint)` | +| `searchsortedfirst(InterpolationSearch(), v, x)` | `search_first(KIND_INTERPOLATION_SEARCH, v, x)` or `search_first(InterpolationSearch(), v, x)` | +| `searchsortedlast(UniformStep(), r, x)` | `search_last(KIND_UNIFORM_STEP, r, x)` or `search_last(UniformStep(), r, x)` | +| `searchsortedlast(Auto(v), v, x, hint)` | `search_last(Auto(v), v, x, hint)` | +| `searchsortedfirst(GuesserHint(g), v, x)` | `search_first(GuesserHint(g), v, x)` | + +(The same rename applies to every other singleton strategy.) The batched +in-place API (`searchsortedlast!` / `searchsortedfirst!` / +`searchsortedrange`) is FFF-owned and unchanged. Stateful strategies (`Auto`, `GuesserHint`) stay on the multimethod path -because they carry per-instance data: - -```julia -search_last(Auto(v), v, x, hint) # v3 preferred -searchsortedlast(Auto(v), v, x, hint) # v3 back-compat (shim) -search_first(GuesserHint(g), v, x) # v3 preferred -searchsortedfirst(GuesserHint(g), v, x) # v3 back-compat (shim) -``` +because they carry per-instance data. ### Breaking changes — `Auto` resolves at construction @@ -139,18 +139,10 @@ findequal(KIND_BRACKET_GALLOP, v, x) findequal(KIND_BRACKET_GALLOP, v, x, hint) ``` -In addition to the v2 `findequal(strategy_struct, v, x[, hint])` form -(which still works via the shim). +In addition to the `findequal(strategy_struct, v, x[, hint])` form, +which forwards through the same struct → kind mapping. -### Deprecation timeline - -The v2 `Base.searchsortedlast(::S, ...)` / -`Base.searchsortedfirst(::S, ...)` methods for singleton strategy -structs are scheduled for removal in v4. They emit no depwarn in v3 -because the noise would be unmanageable across the ecosystem; the -removal will be announced at least one minor cycle in advance. - -### Internals — `dispatch.jl` split into `kinds.jl` + `kernels.jl` + `legacy_dispatch.jl` +### Internals — `dispatch.jl` split into `kinds.jl` + `kernels.jl` + `strategy_kind.jl` The v2 `src/dispatch.jl` file (Base.searchsortedlast extensions per strategy) is gone. In its place: @@ -160,11 +152,9 @@ strategy) is gone. In its place: - `src/kernels.jl` — per-strategy kernel functions (`_kernel_last_bracket_gallop`, etc.), lifted out of the v2 method bodies. - - `src/legacy_dispatch.jl` — `Base.searchsortedlast(::S, ...)` shims - that forward to `search_last(KIND_X, ...)` (one shim per singleton - strategy struct). - -The `strategy_kind(s)` mapping function is defined alongside the shims. + - `src/strategy_kind.jl` — the struct → kind mapping plus the + struct-valued `search_last(::S, ...)` / `search_first(::S, ...)` + entry points that forward through it. ## 2.0.0 diff --git a/docs/src/strategies.md b/docs/src/strategies.md index 37802b5..87f9b82 100644 --- a/docs/src/strategies.md +++ b/docs/src/strategies.md @@ -10,17 +10,20 @@ ways to select a strategy: value per singleton strategy; runtime `if/elseif` dispatch into the matching kernel; ~0 ns of overhead in hot loops; the inferred return type is concrete regardless of which kind is picked at runtime. - - **v2 back-compat:** pass a singleton strategy struct (e.g. - `BracketGallop()`) to `Base.searchsortedlast` / `Base.searchsortedfirst`. - Each method is a one-line shim that forwards to `search_last(KIND_X, ...)`. - Scheduled for removal in v4 — migrate to `search_last` / - `search_first` for new code. + - **Struct form:** pass a singleton strategy struct (e.g. + `BracketGallop()`) to the same `search_last` / `search_first`. The + struct forwards through + [`strategy_kind`](@ref FindFirstFunctions.strategy_kind), which + constant-folds for a literal strategy argument — the struct form + costs nothing over the kind form. + +FindFirstFunctions does **not** extend `Base.searchsortedlast` / +`Base.searchsortedfirst` (the v2 API; removed in v3). The stateful strategies — [`Auto`](@ref FindFirstFunctions.Auto) and [`GuesserHint`](@ref FindFirstFunctions.GuesserHint) — carry per-instance data and so cannot be expressed as singleton enum tags. They dispatch -via their own multimethods (and via the back-compat `Base.searchsortedlast(::S, ...)` -shim). +via their own multimethods. ```@docs FindFirstFunctions.SearchStrategy @@ -81,41 +84,36 @@ All hint-consuming strategies fall back to `BinaryBracket` when no hint is supplied or when the hint is out of range. `InterpolationSearch` additionally falls back to `BinaryBracket` for non-numeric element types. -## Migrating from v2 (`Base.searchsortedlast(::S, ...)`) to v3 (`search_last(KIND_X, ...)`) +## Migrating from v2 (`Base.searchsortedlast(::S, ...)`) to v3 -The v2 API is preserved as a back-compat shim. To migrate to the v3 -preferred form, change each call site as follows: +The v2 `Base.searchsortedlast(::S, ...)` / `Base.searchsortedfirst(::S, ...)` +methods are removed in v3. The migration is a mechanical rename — +`searchsortedlast` → `search_last`, `searchsortedfirst` → `search_first` — +with the strategy argument unchanged: ```julia -# v2 (still works, shim forwards to v3) +# v2 (removed) searchsortedlast(BracketGallop(), v, x, hint) searchsortedfirst(InterpolationSearch(), v, x) - -# v3 (preferred) -search_last(KIND_BRACKET_GALLOP, v, x, hint) -search_first(KIND_INTERPOLATION_SEARCH, v, x) -``` - -Stateful strategies (`Auto`, `GuesserHint`) have both forms: - -```julia -# v2 form (still works) searchsortedlast(Auto(v), v, x, hint) searchsortedfirst(GuesserHint(g), v, x) -# v3 form (preferred) +# v3 +search_last(BracketGallop(), v, x, hint) +search_first(InterpolationSearch(), v, x) search_last(Auto(v), v, x, hint) search_first(GuesserHint(g), v, x) ``` -The migration is mechanical: rename `searchsortedlast` → `search_last`, -`searchsortedfirst` → `search_first`, and wrap singleton struct -strategies with their `KIND_X` tag (no rename needed for stateful -strategies). +Hot loops that pick the strategy from runtime data should use the +`StrategyKind` enum form (`search_last(KIND_BRACKET_GALLOP, v, x, hint)`); +for a literal singleton strategy the struct form compiles to exactly the +same code. -The v2 shims will be removed in **v4** (no scheduled date — long enough -for downstream packages like DataInterpolations.jl, ModelingToolkit, and -NonlinearSolve to migrate). +The batched API (`searchsortedlast!`, `searchsortedfirst!`, +`searchsortedrange`) and the equality API (`findequal`, +`findfirstequal`, `findfirstsortedequal`) are FFF-owned names and are +unchanged. ## Reference diff --git a/src/FindFirstFunctions.jl b/src/FindFirstFunctions.jl index c069f37..0f7c45c 100644 --- a/src/FindFirstFunctions.jl +++ b/src/FindFirstFunctions.jl @@ -5,12 +5,13 @@ module FindFirstFunctions # small isbits payloads), so exporting them only adds names to the # caller's namespace — no runtime cost. # -# v3 introduces the enum-tagged dispatch path (`search_last` / -# `search_first` over `StrategyKind` values). The v2 -# `Base.searchsortedlast(::S, ...)` API remains as a back-compat shim, -# scheduled for removal in v4. +# v3 replaces the v2 `Base.searchsortedlast(::S, ...)` extensions with +# the FFF-owned `search_last` / `search_first` dispatchers, which accept +# a `StrategyKind` tag, a strategy struct, or a stateful strategy +# (`Auto`, `GuesserHint`). FFF no longer extends `Base.searchsortedlast` +# or `Base.searchsortedfirst`. export - # Abstract type + concrete singleton strategies (v2 back-compat). + # Abstract type + concrete singleton strategies (friendly strategy values). SearchStrategy, LinearScan, SIMDLinearScan, BracketGallop, ExpFromLeft, InterpolationSearch, BitInterpolationSearch, @@ -43,7 +44,7 @@ include("kinds.jl") # StrategyKind enum + search_last / search_fir include("strategies.jl") # SearchStrategy + concrete strategy types + SearchProperties + Auto include("search_properties.jl") # Linearity / NaN probes + populated SearchProperties constructor include("kernels.jl") # Per-strategy kernel functions called by the dispatchers -include("legacy_dispatch.jl") # Base.searchsortedlast(::S,…) back-compat shims + strategy_kind +include("strategy_kind.jl") # strategy struct → kind mapping + struct-valued search entry points include("auto.jl") # Auto helpers + _auto_resolve_kind + Auto dispatch include("batched.jl") # Batched API + Auto batched specialization include("guesser.jl") # looks_linear + Guesser + GuesserHint dispatch diff --git a/src/auto.jl b/src/auto.jl index bb5ef83..1b60faf 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -10,7 +10,7 @@ # `_estimate_avg_gap`, `_auto_interp_eligible`, …) # - `_auto_resolve_kind(v, props)` — forward-declared in `strategies.jl` # - The per-query `search_last(::Auto, ...)` / `search_first(::Auto, ...)` -# methods + their `Base.searchsortedlast(::Auto, ...)` back-compat shims. +# methods. # Per-query Auto threshold: under this length, the bracket-search bookkeeping # costs more than a worst-case linear walk. @@ -121,7 +121,7 @@ end # Special case: when `kind === KIND_UNIFORM_STEP` and `props` is # populated, we route to the props-aware kernel that uses the precomputed # `inv_step` from `props`, skipping the per-query float division in the -# back-compat AbstractRange `UniformStep` kernel. An `Auto` holding the +# plain (`fld`-based) `UniformStep` kernel. An `Auto` holding the # sentinel `SearchProperties()` has `inv_step = 0`, which would silently # degrade the closed-form guess to a linear walk — the `has_props` guard # sends it to the `fld`-based kind kernel instead, matching the guard in @@ -174,22 +174,3 @@ end search_first(s.kind, v, x; order = order) end end - -# Legacy `Base.searchsortedlast(::Auto, ...)` shims — same one-liner. Kept -# so v2 callers continue to work without changes. -Base.searchsortedlast( - s::Auto, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(s, v, x; order = order) -Base.searchsortedfirst( - s::Auto, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(s, v, x; order = order) -Base.searchsortedlast( - s::Auto, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(s, v, x, hint; order = order) -Base.searchsortedfirst( - s::Auto, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(s, v, x, hint; order = order) diff --git a/src/batched.jl b/src/batched.jl index 0024fff..ad18372 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -110,7 +110,7 @@ function _searchsortedfirst_sorted_loop_kind!( end # Sorted inner loop parameterized on a strategy *struct* (for GuesserHint -# and for the back-compat `Base.searchsortedlast(::S, ...)` path). +# and for the struct-valued `search_last(::S, ...)` path). function _searchsortedlast_sorted_loop!( idx_out, v::AbstractVector, queries::AbstractVector, strategy::SearchStrategy, order::Base.Order.Ordering, @@ -119,9 +119,9 @@ function _searchsortedlast_sorted_loop!( @inbounds for k in eachindex(queries) q = queries[k] hint = if hint < firstindex(v) - searchsortedlast(strategy, v, q; order = order) + search_last(strategy, v, q; order = order) else - searchsortedlast(strategy, v, q, hint; order = order) + search_last(strategy, v, q, hint; order = order) end idx_out[k] = hint end @@ -136,9 +136,9 @@ function _searchsortedfirst_sorted_loop!( @inbounds for k in eachindex(queries) q = queries[k] hint = if hint < firstindex(v) - searchsortedfirst(strategy, v, q; order = order) + search_first(strategy, v, q; order = order) else - searchsortedfirst(strategy, v, q, hint; order = order) + search_first(strategy, v, q, hint; order = order) end idx_out[k] = hint end @@ -382,8 +382,8 @@ end Return the index range of all entries `v[i]` satisfying `lo ≤ v[i] ≤ hi` under `order`. Equivalent to -`searchsortedfirst(strategy, v, lo[, hint]; order) : - searchsortedlast(strategy, v, hi[, hint]; order)`. +`search_first(strategy, v, lo[, hint]; order) : + search_last(strategy, v, hi[, hint]; order)`. When a `hint` is supplied it seeds the `lo` search directly; the `hi` search is seeded with `max(first_idx, hint)`, since the upper endpoint @@ -394,8 +394,8 @@ the hinted form as a pass-through. strategy::SearchStrategy, v::AbstractVector, lo, hi; order::Base.Order.Ordering = Base.Order.Forward, ) - first_idx = searchsortedfirst(strategy, v, lo; order = order) - last_idx = searchsortedlast(strategy, v, hi; order = order) + first_idx = search_first(strategy, v, lo; order = order) + last_idx = search_last(strategy, v, hi; order = order) return first_idx:last_idx end @@ -403,8 +403,8 @@ end strategy::SearchStrategy, v::AbstractVector, lo, hi, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - first_idx = searchsortedfirst(strategy, v, lo, hint; order = order) - last_idx = searchsortedlast( + first_idx = search_first(strategy, v, lo, hint; order = order) + last_idx = search_last( strategy, v, hi, max(first_idx, hint); order = order ) return first_idx:last_idx diff --git a/src/findequal.jl b/src/findequal.jl index 67bea14..ae2274a 100644 --- a/src/findequal.jl +++ b/src/findequal.jl @@ -18,9 +18,8 @@ sentinel matches the convention `Base.searchsortedlast` already uses for The `strategy` argument can be: - A singleton strategy struct (`BinaryBracket()`, `BracketGallop()`, - …) — back-compat with the v2 API. - - A [`StrategyKind`](@ref) enum value (`KIND_BRACKET_GALLOP`, …) — the - v3 preferred form. + …) — forwards through the struct → kind mapping. + - A [`StrategyKind`](@ref) enum value (`KIND_BRACKET_GALLOP`, …). - A stateful strategy (`Auto`, `GuesserHint`) — dispatched via multimethod. @@ -41,7 +40,7 @@ end strategy::SearchStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - i = searchsortedfirst(strategy, v, x, hint; order = order) + i = search_first(strategy, v, x, hint; order = order) return _findequal_postcheck(v, x, i) end @@ -69,7 +68,7 @@ end end @inline function _findequal_generic_strategy(strategy, v, x, order) - i = searchsortedfirst(strategy, v, x; order = order) + i = search_first(strategy, v, x; order = order) return _findequal_postcheck(v, x, i) end diff --git a/src/guesser.jl b/src/guesser.jl index 0b4e2b3..02fff8b 100644 --- a/src/guesser.jl +++ b/src/guesser.jl @@ -96,21 +96,3 @@ end s::GuesserHint, v::AbstractVector, x, ::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) = search_first(s, v, x; order = order) - -# `Base.searchsortedlast(::GuesserHint, ...)` shims for back-compat. -Base.searchsortedlast( - s::GuesserHint, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(s, v, x; order = order) -Base.searchsortedfirst( - s::GuesserHint, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(s, v, x; order = order) -Base.searchsortedlast( - s::GuesserHint, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(s, v, x, hint; order = order) -Base.searchsortedfirst( - s::GuesserHint, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(s, v, x, hint; order = order) diff --git a/src/kernels.jl b/src/kernels.jl index 9378304..9ed22a6 100644 --- a/src/kernels.jl +++ b/src/kernels.jl @@ -2,10 +2,9 @@ # function that performs the strategy's algorithm directly — no method # dispatch, no Union returns, no wrapper struct. # -# The kernels are called from the enum dispatcher in `kinds.jl` and from -# the legacy `Base.searchsortedlast(::S, ...)` shims in `legacy_dispatch.jl`. -# `Auto` and `GuesserHint` also call them (directly, by kind, for `Auto`; -# via the kind dispatcher, for `GuesserHint`). +# The kernels are called from the enum dispatcher in `kinds.jl`. `Auto` +# and `GuesserHint` also call them (directly, by kind, for `Auto`; via +# the kind dispatcher, for `GuesserHint`). # =========================================================================== # Bracket helpers — backing `BracketGallop` diff --git a/src/kinds.jl b/src/kinds.jl index 5d1f339..9fdd081 100644 --- a/src/kinds.jl +++ b/src/kinds.jl @@ -63,9 +63,9 @@ kernel. When the first argument is a stateful strategy wrapper (`Auto`, `GuesserHint`) the call dispatches via multimethod into that wrapper's own `search_last` method. -This is the preferred entry point for new code. The legacy -`Base.searchsortedlast(::SearchStrategy, ...)` methods still work for -back-compat but are deprecated and will be removed in v4. +This is the only search entry point: as of v3, FindFirstFunctions no +longer extends `Base.searchsortedlast` / `Base.searchsortedfirst` with +strategy methods. """ @inline function search_last( kind::StrategyKind, v::AbstractVector, x; @@ -220,9 +220,9 @@ end end # --------------------------------------------------------------------------- -# Per-strategy kind lookup. Used by the deprecation shims in -# `legacy_dispatch.jl` and by callers that need to convert a strategy -# struct to its enum tag. +# Per-strategy kind lookup. Methods live in `strategy_kind.jl`; used by +# the struct-valued `search_last` / `search_first` entry points and by +# callers that need to convert a strategy struct to its enum tag. # --------------------------------------------------------------------------- """ diff --git a/src/legacy_dispatch.jl b/src/legacy_dispatch.jl deleted file mode 100644 index 307473c..0000000 --- a/src/legacy_dispatch.jl +++ /dev/null @@ -1,70 +0,0 @@ -# Back-compat shims: `Base.searchsortedlast(::S, ...)` / -# `Base.searchsortedfirst(::S, ...)` for the singleton strategy structs -# (`BinaryBracket`, `LinearScan`, …). Each shim forwards to the -# corresponding `search_last(KIND_X, ...)` / `search_first(KIND_X, ...)` -# call, so the enum dispatcher is the single source of truth. -# -# These shims are scheduled for removal in the next major version (v4). -# New code should call `search_last` / `search_first` with a -# `StrategyKind` value directly. - -# `strategy_kind(s)` — the public mapping from strategy struct → tag. -strategy_kind(::BinaryBracket) = KIND_BINARY_BRACKET -strategy_kind(::LinearScan) = KIND_LINEAR_SCAN -strategy_kind(::SIMDLinearScan) = KIND_SIMD_LINEAR_SCAN -strategy_kind(::BracketGallop) = KIND_BRACKET_GALLOP -strategy_kind(::ExpFromLeft) = KIND_EXP_FROM_LEFT -strategy_kind(::InterpolationSearch) = KIND_INTERPOLATION_SEARCH -strategy_kind(::BitInterpolationSearch) = KIND_BIT_INTERPOLATION_SEARCH -strategy_kind(::UniformStep) = KIND_UNIFORM_STEP -strategy_kind(::BisectThenSIMD) = KIND_BISECT_THEN_SIMD - -# Stateful strategies (`GuesserHint`, `Auto`) don't map to a single tag. -strategy_kind(s::Auto) = s.kind -strategy_kind(::GuesserHint) = throw( - ArgumentError( - "GuesserHint is a stateful strategy with no StrategyKind tag; use its own dispatch.", - ), -) - -# --------------------------------------------------------------------------- -# `Base.searchsortedlast(::S, v, x[, hint]; order)` shims for each singleton -# strategy. Each method is a one-line forward to `search_last(KIND_X, ...)`. -# -# The shim is intentionally not `@deprecate`-wrapped because Julia's -# `@deprecate` doesn't compose cleanly with keyword arguments, and the -# noise of a per-call `Base.depwarn` would obscure test output across the -# whole ecosystem during the v3 transition. The deprecation is documented -# (NEWS, docs, this comment block); a v4 release will remove the shims. -# --------------------------------------------------------------------------- - -for (S, KIND) in ( - (:BinaryBracket, :KIND_BINARY_BRACKET), - (:LinearScan, :KIND_LINEAR_SCAN), - (:SIMDLinearScan, :KIND_SIMD_LINEAR_SCAN), - (:BracketGallop, :KIND_BRACKET_GALLOP), - (:ExpFromLeft, :KIND_EXP_FROM_LEFT), - (:InterpolationSearch, :KIND_INTERPOLATION_SEARCH), - (:BitInterpolationSearch, :KIND_BIT_INTERPOLATION_SEARCH), - (:UniformStep, :KIND_UNIFORM_STEP), - (:BisectThenSIMD, :KIND_BISECT_THEN_SIMD), - ) - @eval begin - Base.searchsortedlast( - ::$S, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, - ) = search_last($KIND, v, x; order = order) - Base.searchsortedfirst( - ::$S, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, - ) = search_first($KIND, v, x; order = order) - Base.searchsortedlast( - ::$S, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) = search_last($KIND, v, x, hint; order = order) - Base.searchsortedfirst( - ::$S, v::AbstractVector, x, hint::Integer; - order::Base.Order.Ordering = Base.Order.Forward, - ) = search_first($KIND, v, x, hint; order = order) - end -end diff --git a/src/precompile.jl b/src/precompile.jl index 7b8df7e..2850b56 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -31,15 +31,15 @@ using PrecompileTools: @compile_workload, @setup_workload Auto(SearchProperties(linear_vec)), Auto(linear_vec), ) - searchsortedfirst(strategy, vec_int64, Int64(8), Int64(1)) - searchsortedlast(strategy, vec_int64, Int64(8), Int64(1)) + search_first(strategy, vec_int64, Int64(8), Int64(1)) + search_last(strategy, vec_int64, Int64(8), Int64(1)) end # Auto-with-uniform-range — exercises the props-aware UniformStep kernel. let r = 0.0:0.5:10.0 auto_r = Auto(r) - searchsortedlast(auto_r, r, 3.7) - searchsortedfirst(auto_r, r, 3.7) - searchsortedlast(auto_r, r, 3.7, 1) + search_last(auto_r, r, 3.7) + search_first(auto_r, r, 3.7) + search_last(auto_r, r, 3.7, 1) end # findequal: generic + BisectThenSIMD shortcut for Int64 dense vectors. for strategy in ( @@ -51,11 +51,11 @@ using PrecompileTools: @compile_workload, @setup_workload end # SIMDLinearScan's Float64 specialization. let vec_f64 = collect(1.0:1.0:16.0) - searchsortedfirst(SIMDLinearScan(), vec_f64, 8.0, 1) - searchsortedlast(SIMDLinearScan(), vec_f64, 8.0, 1) + search_first(SIMDLinearScan(), vec_f64, 8.0, 1) + search_last(SIMDLinearScan(), vec_f64, 8.0, 1) end - searchsortedfirst(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) - searchsortedlast(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) + search_first(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) + search_last(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) # Strategy dispatch — batched in-place forms. idx_out = Vector{Int}(undef, 4) diff --git a/src/strategies.jl b/src/strategies.jl index e129e93..498d3cc 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -1,9 +1,10 @@ # Sorted-search strategy type hierarchy. The singleton strategy structs -# (`LinearScan`, `BracketGallop`, …) exist for back-compat with v2's -# `Base.searchsortedlast(::S, ...)` API; the v3 preferred path is to call -# the enum-tagged `search_last` / `search_first` directly (see `kinds.jl`). -# The stateful strategies — `Auto` and `GuesserHint` — stay on the -# multimethod path because they carry per-instance data. +# (`LinearScan`, `BracketGallop`, …) are friendly names for the +# `StrategyKind` tags — passing one to `search_last` / `search_first` +# forwards through `strategy_kind` and constant-folds (see +# `strategy_kind.jl`). The stateful strategies — `Auto` and +# `GuesserHint` — stay on the multimethod path because they carry +# per-instance data. """ SearchStrategy @@ -17,12 +18,11 @@ concrete subtype: `BisectThenSIMD`) are zero-field structs. Each one has a matching `StrategyKind` enum value, and the v3 preferred entry point is [`search_last`](@ref) / [`search_first`](@ref) with that enum tag. - The `Base.searchsortedlast(::S, ...)` API still works as a v2 - back-compat shim. + The struct itself can also be passed to `search_last` / + `search_first` directly; it forwards through [`strategy_kind`](@ref). - **Stateful strategies** (`Auto`, `GuesserHint`) carry per-instance data. They dispatch via their own `search_last` / `search_first` - multimethods (and via `Base.searchsortedlast(::S, ...)` for - back-compat). + multimethods. Strategies can also be passed to the batched [`searchsortedlast!`](@ref) / [`searchsortedfirst!`](@ref) APIs. @@ -238,8 +238,7 @@ Stateful strategy that resolves to a concrete [`StrategyKind`](@ref) at construction time. The resolution uses static information available at construction: `props` (if supplied) plus `v` (if supplied). -**Per-query** (`search_last(Auto(), v, x[, hint])` or the legacy -`searchsortedlast(Auto(), v, x[, hint])`): forwards directly to the +**Per-query** (`search_last(Auto(), v, x[, hint])`): forwards directly to the stored kind. `Auto()` defaults to `KIND_BINARY_BRACKET` (safe choice when nothing is known about `v`); `Auto(v)` resolves to a faster kind based on `length(v)`, `props.is_uniform`, etc. Callers that want the diff --git a/src/strategy_kind.jl b/src/strategy_kind.jl new file mode 100644 index 0000000..d236c07 --- /dev/null +++ b/src/strategy_kind.jl @@ -0,0 +1,49 @@ +# Strategy structs as values: the `strategy_kind` mapping from a strategy +# struct to its `StrategyKind` tag, plus `search_last` / `search_first` +# methods that accept a singleton strategy struct directly and forward +# through the mapping. For a literal strategy argument +# (`search_last(BracketGallop(), v, x, hint)`) the mapping constant-folds, +# so the struct form costs nothing over the kind form. +# +# v3 does not extend `Base.searchsortedlast` / `Base.searchsortedfirst` +# with strategy methods — `search_last` / `search_first` are the only +# search entry points. + +# `strategy_kind(s)` — the public mapping from strategy struct → tag. +strategy_kind(::BinaryBracket) = KIND_BINARY_BRACKET +strategy_kind(::LinearScan) = KIND_LINEAR_SCAN +strategy_kind(::SIMDLinearScan) = KIND_SIMD_LINEAR_SCAN +strategy_kind(::BracketGallop) = KIND_BRACKET_GALLOP +strategy_kind(::ExpFromLeft) = KIND_EXP_FROM_LEFT +strategy_kind(::InterpolationSearch) = KIND_INTERPOLATION_SEARCH +strategy_kind(::BitInterpolationSearch) = KIND_BIT_INTERPOLATION_SEARCH +strategy_kind(::UniformStep) = KIND_UNIFORM_STEP +strategy_kind(::BisectThenSIMD) = KIND_BISECT_THEN_SIMD + +# Stateful strategies (`GuesserHint`, `Auto`) don't map to a single tag. +strategy_kind(s::Auto) = s.kind +strategy_kind(::GuesserHint) = throw( + ArgumentError( + "GuesserHint is a stateful strategy with no StrategyKind tag; use its own dispatch.", + ), +) + +# Struct-valued entry points. `Auto` and `GuesserHint` define their own, +# more specific `search_last` / `search_first` methods (in `auto.jl` and +# `guesser.jl`), so this fallback only ever sees the zero-state singletons. +@inline search_last( + s::SearchStrategy, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_last(strategy_kind(s), v, x; order = order) +@inline search_first( + s::SearchStrategy, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_first(strategy_kind(s), v, x; order = order) +@inline search_last( + s::SearchStrategy, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_last(strategy_kind(s), v, x, hint; order = order) +@inline search_first( + s::SearchStrategy, v::AbstractVector, x, hint::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = search_first(strategy_kind(s), v, x, hint; order = order) diff --git a/test/runtests.jl b/test/runtests.jl index 777f8ae..3001287 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -37,16 +37,16 @@ end end @safetestset "Guesser" begin - using FindFirstFunctions: Guesser, GuesserHint + using FindFirstFunctions: Guesser, GuesserHint, search_last, search_first v = collect(LinRange(0, 10, 4)) guesser_linear = Guesser(v) guesser_prev = Guesser(v, Ref(1), false) @test guesser_linear.linear_lookup # Guesser feeds the dispatched API via GuesserHint. - @test searchsortedfirst(GuesserHint(guesser_linear), v, 4.0) == 3 - @test searchsortedfirst(GuesserHint(guesser_linear), v, 1.4234326478e24) == 5 - @test searchsortedlast(GuesserHint(guesser_prev), v, 4.0) == 2 + @test search_first(GuesserHint(guesser_linear), v, 4.0) == 3 + @test search_first(GuesserHint(guesser_linear), v, 1.4234326478e24) == 5 + @test search_last(GuesserHint(guesser_prev), v, 4.0) == 2 @test guesser_prev.idx_prev[] == 2 # Edge case: single-element v. @@ -56,12 +56,12 @@ end @test guesser(100) == 1 @test guesser(42.0) == 1 @test guesser(0) == 1 - @test searchsortedfirst(GuesserHint(guesser), v1, 0) == 1 - @test searchsortedfirst(GuesserHint(guesser), v1, 100) == 2 # see Base.searchsortedfirst - @test searchsortedfirst(GuesserHint(guesser), v1, 42.0) == 1 - @test searchsortedlast(GuesserHint(guesser), v1, 0) == 0 # see Base.searchsortedlast - @test searchsortedlast(GuesserHint(guesser), v1, 100) == 1 - @test searchsortedlast(GuesserHint(guesser), v1, 42.0) == 1 + @test search_first(GuesserHint(guesser), v1, 0) == 1 + @test search_first(GuesserHint(guesser), v1, 100) == 2 # see Base.searchsortedfirst + @test search_first(GuesserHint(guesser), v1, 42.0) == 1 + @test search_last(GuesserHint(guesser), v1, 0) == 0 # see Base.searchsortedlast + @test search_last(GuesserHint(guesser), v1, 100) == 1 + @test search_last(GuesserHint(guesser), v1, 42.0) == 1 end @safetestset "Native reverse-order paths (issue #67)" begin @@ -90,9 +90,9 @@ end InterpolationSearch(), BitInterpolationSearch(), Auto(), ) - @test searchsortedlast(strategy, v, x, hint; order = order) == + @test search_last(strategy, v, x, hint; order = order) == expected_last - @test searchsortedfirst(strategy, v, x, hint; order = order) == + @test search_first(strategy, v, x, hint; order = order) == expected_first end end @@ -110,9 +110,9 @@ end BracketGallop(), ExpFromLeft(), InterpolationSearch(), Auto(), ) - @test searchsortedlast(strategy, v, x, hint; order = order) == + @test search_last(strategy, v, x, hint; order = order) == expected_last - @test searchsortedfirst(strategy, v, x, hint; order = order) == + @test search_first(strategy, v, x, hint; order = order) == expected_first end end @@ -125,17 +125,17 @@ end x = exp(rand(rng) * log(1.0e6)) expected_last = searchsortedlast(v, x, order) expected_first = searchsortedfirst(v, x, order) - @test searchsortedlast( + @test search_last( BitInterpolationSearch(), v, x; order = order ) == expected_last - @test searchsortedfirst( + @test search_first( BitInterpolationSearch(), v, x; order = order ) == expected_first end # Non-positive endpoint falls back to BinaryBracket cleanly. v_signed = sort!(randn(rng, 100); rev = true) for x in (-0.5, 0.0, 0.5, -2.0, 2.0) - @test searchsortedlast( + @test search_last( BitInterpolationSearch(), v_signed, x; order = order ) == searchsortedlast(v_signed, x, order) end @@ -149,13 +149,13 @@ end SIMDLinearScan(), ExpFromLeft(), InterpolationSearch(), BitInterpolationSearch(), ) - @test searchsortedlast(strategy, v, 100.0, 5; order = order) == + @test search_last(strategy, v, 100.0, 5; order = order) == searchsortedlast(v, 100.0, order) - @test searchsortedlast(strategy, v, -100.0, 5; order = order) == + @test search_last(strategy, v, -100.0, 5; order = order) == searchsortedlast(v, -100.0, order) - @test searchsortedfirst(strategy, v, 100.0, 5; order = order) == + @test search_first(strategy, v, 100.0, 5; order = order) == searchsortedfirst(v, 100.0, order) - @test searchsortedfirst(strategy, v, -100.0, 5; order = order) == + @test search_first(strategy, v, -100.0, 5; order = order) == searchsortedfirst(v, -100.0, order) end end @@ -186,18 +186,18 @@ end first(r) + (last(r) - first(r)) / 2, last(r), last(r) + 1, 0, ) - @test searchsortedlast(UniformStep(), r, x) == + @test search_last(UniformStep(), r, x) == searchsortedlast(r, x) - @test searchsortedfirst(UniformStep(), r, x) == + @test search_first(UniformStep(), r, x) == searchsortedfirst(r, x) - @test searchsortedlast(Auto(), r, x) == searchsortedlast(r, x) - @test searchsortedfirst(Auto(), r, x) == searchsortedfirst(r, x) + @test search_last(Auto(), r, x) == searchsortedlast(r, x) + @test search_first(Auto(), r, x) == searchsortedfirst(r, x) h = max(1, length(r) ÷ 2) - @test searchsortedlast(UniformStep(), r, x, h) == + @test search_last(UniformStep(), r, x, h) == searchsortedlast(r, x) - @test searchsortedfirst(UniformStep(), r, x, h) == + @test search_first(UniformStep(), r, x, h) == searchsortedfirst(r, x) - @test searchsortedlast(Auto(), r, x, h) == searchsortedlast(r, x) + @test search_last(Auto(), r, x, h) == searchsortedlast(r, x) end end @@ -208,35 +208,35 @@ end (first(r) + last(r)) / 2, last(r), last(r) - 1, 0, ) - @test searchsortedlast(UniformStep(), r, x; order = order) == + @test search_last(UniformStep(), r, x; order = order) == searchsortedlast(r, x, order) - @test searchsortedfirst(UniformStep(), r, x; order = order) == + @test search_first(UniformStep(), r, x; order = order) == searchsortedfirst(r, x, order) - @test searchsortedlast(Auto(), r, x; order = order) == + @test search_last(Auto(), r, x; order = order) == searchsortedlast(r, x, order) - @test searchsortedfirst(Auto(), r, x; order = order) == + @test search_first(Auto(), r, x; order = order) == searchsortedfirst(r, x, order) end end # Edge cases. r_empty = 1:0 - @test searchsortedlast(UniformStep(), r_empty, 5) == 0 - @test searchsortedfirst(UniformStep(), r_empty, 5) == 1 - @test searchsortedlast(Auto(), r_empty, 5) == 0 + @test search_last(UniformStep(), r_empty, 5) == 0 + @test search_first(UniformStep(), r_empty, 5) == 1 + @test search_last(Auto(), r_empty, 5) == 0 r_single = 42:42 - @test searchsortedlast(UniformStep(), r_single, 41) == 0 - @test searchsortedlast(UniformStep(), r_single, 42) == 1 - @test searchsortedlast(UniformStep(), r_single, 43) == 1 - @test searchsortedfirst(UniformStep(), r_single, 41) == 1 - @test searchsortedfirst(UniformStep(), r_single, 42) == 1 - @test searchsortedfirst(UniformStep(), r_single, 43) == 2 + @test search_last(UniformStep(), r_single, 41) == 0 + @test search_last(UniformStep(), r_single, 42) == 1 + @test search_last(UniformStep(), r_single, 43) == 1 + @test search_first(UniformStep(), r_single, 41) == 1 + @test search_first(UniformStep(), r_single, 42) == 1 + @test search_first(UniformStep(), r_single, 43) == 2 # Non-Range vector falls back to BinaryBracket. v = collect(0.0:0.1:10.0) for x in (-1.0, 0.0, 5.5, 10.0, 100.0) - @test searchsortedlast(UniformStep(), v, x) == searchsortedlast(v, x) - @test searchsortedfirst(UniformStep(), v, x) == searchsortedfirst(v, x) + @test search_last(UniformStep(), v, x) == searchsortedlast(v, x) + @test search_first(UniformStep(), v, x) == searchsortedfirst(v, x) end # Auto() answers match Base's range-aware overload and the call @@ -245,13 +245,13 @@ end # bytes for the 32-byte `Auto{Float64}` argument (1.11 elides # the allocation entirely) — hence the `<= 64` bound below. r_big = 0.0:0.001:100.0 - @test searchsortedlast(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) - @test searchsortedfirst(Auto(), r_big, 50.5) == + @test search_last(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) + @test search_first(Auto(), r_big, 50.5) == searchsortedfirst(r_big, 50.5) # Warm up once, then verify allocation is tiny (kwarg trampoline only). - searchsortedlast(Auto(), r_big, 50.5) - @test @allocated(searchsortedlast(Auto(), r_big, 50.5)) <= 64 - @test @allocated(searchsortedfirst(Auto(), r_big, 50.5)) <= 64 + search_last(Auto(), r_big, 50.5) + @test @allocated(search_last(Auto(), r_big, 50.5)) <= 64 + @test @allocated(search_first(Auto(), r_big, 50.5)) <= 64 # Batched path on AbstractRange. r = 0.0:0.5:100.0 @@ -270,7 +270,7 @@ end @safetestset "Custom ordering for strategy dispatch" begin using FindFirstFunctions: Guesser, GuesserHint, BracketGallop, LinearScan, - ExpFromLeft, BinaryBracket, Auto + ExpFromLeft, BinaryBracket, Auto, search_last, search_first v_rev = collect(10.0:-1.0:1.0) for x in (5.0, 10.0, 1.0, 0.0, 11.0), @@ -279,37 +279,38 @@ end BracketGallop(), LinearScan(), ExpFromLeft(), Auto(), ) - @test searchsortedfirst(strategy, v_rev, x, hint; order = Base.Order.Reverse) == + @test search_first(strategy, v_rev, x, hint; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test searchsortedlast(strategy, v_rev, x, hint; order = Base.Order.Reverse) == + @test search_last(strategy, v_rev, x, hint; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) end # BinaryBracket ignores any hint. for x in (5.0, 10.0, 1.0, 0.0, 11.0) - @test searchsortedfirst(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == + @test search_first(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test searchsortedlast(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == + @test search_last(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) end # GuesserHint with reverse order. guesser_rev = Guesser(v_rev) - @test searchsortedfirst(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == + @test search_first(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == searchsortedfirst(v_rev, 5.0, Base.Order.Reverse) - @test searchsortedlast(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == + @test search_last(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == searchsortedlast(v_rev, 5.0, Base.Order.Reverse) # Default (Forward) order still resolves correctly. v_fwd = collect(1.0:1.0:10.0) for strategy in (BracketGallop(), LinearScan(), ExpFromLeft(), Auto()) - @test searchsortedfirst(strategy, v_fwd, 5.0, 1) == searchsortedfirst(v_fwd, 5.0) - @test searchsortedlast(strategy, v_fwd, 5.0, 1) == searchsortedlast(v_fwd, 5.0) + @test search_first(strategy, v_fwd, 5.0, 1) == searchsortedfirst(v_fwd, 5.0) + @test search_last(strategy, v_fwd, 5.0, 1) == searchsortedlast(v_fwd, 5.0) end end @safetestset "SearchStrategy dispatch (single query)" begin using FindFirstFunctions: - SearchStrategy, LinearScan, BracketGallop, BinaryBracket, Auto + SearchStrategy, LinearScan, BracketGallop, BinaryBracket, Auto, + search_last, search_first for n in (0, 1, 2, 8, 33, 257) v = collect(1:n) @@ -322,51 +323,51 @@ end expected_first = searchsortedfirst(v, x) # BinaryBracket — ignores any hint - @test searchsortedlast(BinaryBracket(), v, x) == expected_last - @test searchsortedfirst(BinaryBracket(), v, x) == expected_first - @test searchsortedlast(BinaryBracket(), v, x, 1) == expected_last - @test searchsortedfirst(BinaryBracket(), v, x, 1) == expected_first + @test search_last(BinaryBracket(), v, x) == expected_last + @test search_first(BinaryBracket(), v, x) == expected_first + @test search_last(BinaryBracket(), v, x, 1) == expected_last + @test search_first(BinaryBracket(), v, x, 1) == expected_first # Strategy with hint anywhere in 1..n agrees with Base for h in unique!([1, max(1, n ÷ 4), n ÷ 2, max(1, 3n ÷ 4), n]) - @test searchsortedlast(LinearScan(), v, x, h) == expected_last - @test searchsortedfirst(LinearScan(), v, x, h) == expected_first - @test searchsortedlast(BracketGallop(), v, x, h) == expected_last - @test searchsortedfirst(BracketGallop(), v, x, h) == expected_first - @test searchsortedlast(Auto(), v, x, h) == expected_last - @test searchsortedfirst(Auto(), v, x, h) == expected_first + @test search_last(LinearScan(), v, x, h) == expected_last + @test search_first(LinearScan(), v, x, h) == expected_first + @test search_last(BracketGallop(), v, x, h) == expected_last + @test search_first(BracketGallop(), v, x, h) == expected_first + @test search_last(Auto(), v, x, h) == expected_last + @test search_first(Auto(), v, x, h) == expected_first end # No-hint forms fall back to BinaryBracket - @test searchsortedlast(LinearScan(), v, x) == expected_last - @test searchsortedfirst(LinearScan(), v, x) == expected_first - @test searchsortedlast(BracketGallop(), v, x) == expected_last - @test searchsortedfirst(BracketGallop(), v, x) == expected_first - @test searchsortedlast(Auto(), v, x) == expected_last - @test searchsortedfirst(Auto(), v, x) == expected_first + @test search_last(LinearScan(), v, x) == expected_last + @test search_first(LinearScan(), v, x) == expected_first + @test search_last(BracketGallop(), v, x) == expected_last + @test search_first(BracketGallop(), v, x) == expected_first + @test search_last(Auto(), v, x) == expected_last + @test search_first(Auto(), v, x) == expected_first # Out-of-range hint → Auto falls back to BinaryBracket - @test searchsortedlast(Auto(), v, x, 0) == expected_last - @test searchsortedfirst(Auto(), v, x, 0) == expected_first - @test searchsortedlast(Auto(), v, x, n + 1) == expected_last - @test searchsortedfirst(Auto(), v, x, n + 1) == expected_first + @test search_last(Auto(), v, x, 0) == expected_last + @test search_first(Auto(), v, x, 0) == expected_first + @test search_last(Auto(), v, x, n + 1) == expected_last + @test search_first(Auto(), v, x, n + 1) == expected_first end end # Reverse order v_rev = collect(10.0:-1.0:1.0) for x in (0.5, 1.0, 5.0, 10.0, 11.0), h in (1, 5, 10) - @test searchsortedlast(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == + @test search_last(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test searchsortedfirst(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == + @test search_first(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test searchsortedlast(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == + @test search_last(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test searchsortedfirst(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == + @test search_first(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test searchsortedlast(Auto(), v_rev, x, h; order = Base.Order.Reverse) == + @test search_last(Auto(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test searchsortedfirst(Auto(), v_rev, x, h; order = Base.Order.Reverse) == + @test search_first(Auto(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) end @@ -379,69 +380,70 @@ end @safetestset "ExpFromLeft and InterpolationSearch" begin using FindFirstFunctions: - ExpFromLeft, InterpolationSearch, BinaryBracket + ExpFromLeft, InterpolationSearch, BinaryBracket, + search_last, search_first # ExpFromLeft on uniform Int range v = collect(1:1000) for x in (0, 1, 50, 250, 500, 999, 1000, 1001), h in (1, 50, 500, 1000) - @test searchsortedlast(ExpFromLeft(), v, x, h) == + @test search_last(ExpFromLeft(), v, x, h) == searchsortedlast(v, x) - @test searchsortedfirst(ExpFromLeft(), v, x, h) == + @test search_first(ExpFromLeft(), v, x, h) == searchsortedfirst(v, x) end # ExpFromLeft without hint falls back to BinaryBracket - @test searchsortedlast(ExpFromLeft(), v, 500) == searchsortedlast(v, 500) - @test searchsortedfirst(ExpFromLeft(), v, 500) == searchsortedfirst(v, 500) + @test search_last(ExpFromLeft(), v, 500) == searchsortedlast(v, 500) + @test search_first(ExpFromLeft(), v, 500) == searchsortedfirst(v, 500) # InterpolationSearch on uniform Float64 range vf = collect(0.0:0.1:10.0) for x in (-1.0, 0.0, 0.05, 1.0, 5.5, 9.95, 10.0, 11.0) - @test searchsortedlast(InterpolationSearch(), vf, x) == + @test search_last(InterpolationSearch(), vf, x) == searchsortedlast(vf, x) - @test searchsortedfirst(InterpolationSearch(), vf, x) == + @test search_first(InterpolationSearch(), vf, x) == searchsortedfirst(vf, x) end # InterpolationSearch on log-spaced (non-uniform) — must still be correct vlog = exp.(range(log(0.1), log(100.0); length = 256)) for x in (0.05, 0.1, 1.0, 50.0, 100.0, 150.0) - @test searchsortedlast(InterpolationSearch(), vlog, x) == + @test search_last(InterpolationSearch(), vlog, x) == searchsortedlast(vlog, x) - @test searchsortedfirst(InterpolationSearch(), vlog, x) == + @test search_first(InterpolationSearch(), vlog, x) == searchsortedfirst(vlog, x) end # InterpolationSearch ignores hint (computes its own guess) for h in (1, 100, 256) - @test searchsortedlast(InterpolationSearch(), vlog, 50.0, h) == + @test search_last(InterpolationSearch(), vlog, 50.0, h) == searchsortedlast(vlog, 50.0) end # InterpolationSearch falls back to BinaryBracket on non-Number eltypes vs = ["a", "b", "c", "d"] - @test searchsortedlast(InterpolationSearch(), vs, "c") == + @test search_last(InterpolationSearch(), vs, "c") == searchsortedlast(vs, "c") - @test searchsortedfirst(InterpolationSearch(), vs, "c", 2) == + @test search_first(InterpolationSearch(), vs, "c", 2) == searchsortedfirst(vs, "c") # InterpolationSearch on a constant vector (span=0) shouldn't divide # by zero; should fall through to a bounded search and return a # correct result. vc = fill(3.0, 16) - @test searchsortedlast(InterpolationSearch(), vc, 3.0) == + @test search_last(InterpolationSearch(), vc, 3.0) == searchsortedlast(vc, 3.0) - @test searchsortedlast(InterpolationSearch(), vc, 2.0) == + @test search_last(InterpolationSearch(), vc, 2.0) == searchsortedlast(vc, 2.0) - @test searchsortedlast(InterpolationSearch(), vc, 4.0) == + @test search_last(InterpolationSearch(), vc, 4.0) == searchsortedlast(vc, 4.0) # Edge: 1-element and 2-element vectors - @test searchsortedlast(ExpFromLeft(), [5], 4, 1) == 0 - @test searchsortedlast(ExpFromLeft(), [5], 5, 1) == 1 - @test searchsortedlast(ExpFromLeft(), [5], 6, 1) == 1 - @test searchsortedfirst(ExpFromLeft(), [5, 10], 7, 1) == 2 - @test searchsortedlast(InterpolationSearch(), [5], 4) == 0 - @test searchsortedlast(InterpolationSearch(), [5, 10], 7) == 1 + @test search_last(ExpFromLeft(), [5], 4, 1) == 0 + @test search_last(ExpFromLeft(), [5], 5, 1) == 1 + @test search_last(ExpFromLeft(), [5], 6, 1) == 1 + @test search_first(ExpFromLeft(), [5, 10], 7, 1) == 2 + @test search_last(InterpolationSearch(), [5], 4) == 0 + @test search_last(InterpolationSearch(), [5, 10], 7) == 1 end @safetestset "Batched Auto heuristic" begin @@ -965,9 +967,9 @@ end v = sort!(rand(rng, Int64(-1000):Int64(1000), n)) x = rand(rng, Int64(-1100):Int64(1100)) hint = rand(rng, 1:n) - @test searchsortedlast(F.SIMDLinearScan(), v, x, hint) == + @test search_last(F.SIMDLinearScan(), v, x, hint) == searchsortedlast(v, x) - @test searchsortedfirst(F.SIMDLinearScan(), v, x, hint) == + @test search_first(F.SIMDLinearScan(), v, x, hint) == searchsortedfirst(v, x) end end @@ -979,9 +981,9 @@ end v = sort!(randn(rng, n)) x = (rand(rng) - 0.5) * 6 hint = rand(rng, 1:n) - @test searchsortedlast(F.SIMDLinearScan(), v, x, hint) == + @test search_last(F.SIMDLinearScan(), v, x, hint) == searchsortedlast(v, x) - @test searchsortedfirst(F.SIMDLinearScan(), v, x, hint) == + @test search_first(F.SIMDLinearScan(), v, x, hint) == searchsortedfirst(v, x) end end @@ -989,24 +991,24 @@ end @testset "Edge cases (Int64)" begin v = collect(Int64, 1:100) # Out-of-range hint is clamped. - @test searchsortedlast(F.SIMDLinearScan(), v, Int64(50), -5) == 50 - @test searchsortedlast(F.SIMDLinearScan(), v, Int64(50), 1_000) == 50 + @test search_last(F.SIMDLinearScan(), v, Int64(50), -5) == 50 + @test search_last(F.SIMDLinearScan(), v, Int64(50), 1_000) == 50 # x below/above the range. - @test searchsortedlast(F.SIMDLinearScan(), v, Int64(-10), 50) == 0 - @test searchsortedlast(F.SIMDLinearScan(), v, Int64(1_000), 50) == 100 - @test searchsortedfirst(F.SIMDLinearScan(), v, Int64(-10), 50) == 1 - @test searchsortedfirst(F.SIMDLinearScan(), v, Int64(1_000), 50) == 101 + @test search_last(F.SIMDLinearScan(), v, Int64(-10), 50) == 0 + @test search_last(F.SIMDLinearScan(), v, Int64(1_000), 50) == 100 + @test search_first(F.SIMDLinearScan(), v, Int64(-10), 50) == 1 + @test search_first(F.SIMDLinearScan(), v, Int64(1_000), 50) == 101 # Empty and single-element vectors. vempty = Int64[] - @test searchsortedlast(F.SIMDLinearScan(), vempty, Int64(5), 1) == 0 - @test searchsortedfirst(F.SIMDLinearScan(), vempty, Int64(5), 1) == 1 + @test search_last(F.SIMDLinearScan(), vempty, Int64(5), 1) == 0 + @test search_first(F.SIMDLinearScan(), vempty, Int64(5), 1) == 1 v1 = Int64[42] - @test searchsortedlast(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 - @test searchsortedfirst(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 + @test search_last(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 + @test search_first(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 # Duplicates. vd = Int64[1, 2, 2, 2, 5] - @test searchsortedlast(F.SIMDLinearScan(), vd, Int64(2), 1) == 4 - @test searchsortedfirst(F.SIMDLinearScan(), vd, Int64(2), 5) == 2 + @test search_last(F.SIMDLinearScan(), vd, Int64(2), 1) == 4 + @test search_first(F.SIMDLinearScan(), vd, Int64(2), 5) == 2 end @testset "Fallback: non-Int64/Float64 eltypes" begin @@ -1015,32 +1017,32 @@ end v32 = Int32[1, 5, 10, 20, 50, 100, 200] for x in (Int32(0), Int32(7), Int32(20), Int32(300)) for hint in 1:length(v32) - @test searchsortedlast(F.SIMDLinearScan(), v32, x, hint) == + @test search_last(F.SIMDLinearScan(), v32, x, hint) == searchsortedlast(v32, x) - @test searchsortedfirst(F.SIMDLinearScan(), v32, x, hint) == + @test search_first(F.SIMDLinearScan(), v32, x, hint) == searchsortedfirst(v32, x) end end # Float32 same. v32f = Float32[1.0, 5.0, 10.0, 20.0, 50.0] for x in (Float32(0.0), Float32(7.0), Float32(20.0), Float32(100.0)) - @test searchsortedlast(F.SIMDLinearScan(), v32f, x, 2) == + @test search_last(F.SIMDLinearScan(), v32f, x, 2) == searchsortedlast(v32f, x) end # Non-numeric. vs = sort!(["alpha", "beta", "gamma", "delta", "epsilon"]) - @test searchsortedlast(F.SIMDLinearScan(), vs, "gamma", 2) == + @test search_last(F.SIMDLinearScan(), vs, "gamma", 2) == searchsortedlast(vs, "gamma") end @testset "Fallback: no hint, reverse order" begin v = collect(Int64, 1:100) # No hint → BinaryBracket. - @test searchsortedlast(F.SIMDLinearScan(), v, Int64(50)) == + @test search_last(F.SIMDLinearScan(), v, Int64(50)) == searchsortedlast(v, Int64(50)) # Reverse order → scalar LinearScan. v_rev = collect(Int64, 100:-1:1) - @test searchsortedlast( + @test search_last( F.SIMDLinearScan(), v_rev, Int64(50), 1; order = Base.Order.Reverse ) == searchsortedlast(v_rev, Int64(50), Base.Order.Reverse) end @@ -1152,9 +1154,9 @@ end # When used with searchsortedfirst/last, BisectThenSIMD just # delegates to BinaryBracket — its purpose is findequal. v = collect(Int64, 1:100) - @test searchsortedfirst(F.BisectThenSIMD(), v, Int64(50)) == + @test search_first(F.BisectThenSIMD(), v, Int64(50)) == searchsortedfirst(v, Int64(50)) - @test searchsortedlast(F.BisectThenSIMD(), v, Int64(50)) == + @test search_last(F.BisectThenSIMD(), v, Int64(50)) == searchsortedlast(v, Int64(50)) end end @@ -1310,10 +1312,10 @@ end @test isconcretetype(eltype(v)) end - @testset "Enum dispatch round-trips through Base shim" begin - # The legacy `Base.searchsortedlast(::S, v, x[, hint])` - # path goes through `search_last(KIND_X, ...)`. Verify - # both produce identical answers (shim correctness). + @testset "Struct form forwards through strategy_kind" begin + # `search_last(::S, v, x[, hint])` forwards through + # `strategy_kind(S())` to `search_last(KIND_X, ...)`. + # Verify both forms produce identical answers. v = collect(1:1000) rng = StableRNG(7004) pairs = ( @@ -1327,9 +1329,9 @@ end for _ in 1:50 x = rand(rng, 1:1000) h = rand(rng, 1:1000) - @test searchsortedlast(s, v, x, h) == + @test search_last(s, v, x, h) == search_last(kind, v, x, h) - @test searchsortedfirst(s, v, x, h) == + @test search_first(s, v, x, h) == search_first(kind, v, x, h) end end @@ -1345,6 +1347,24 @@ end # strategy_kind on a GuesserHint must error — it has no tag. @test_throws ArgumentError strategy_kind(gh) end + + @testset "No Base.searchsorted strategy extensions" begin + # v3 removed the v2 `Base.searchsortedlast(::S, ...)` / + # `Base.searchsortedfirst(::S, ...)` methods entirely. + # Guard against accidental reintroduction. + using FindFirstFunctions: + BracketGallop, Auto, GuesserHint, Guesser, UniformStep + V = Vector{Float64} + for S in ( + BracketGallop, UniformStep, Auto{Float64}, + GuesserHint{Guesser{V}}, + ) + @test !hasmethod(searchsortedlast, Tuple{S, V, Float64}) + @test !hasmethod(searchsortedfirst, Tuple{S, V, Float64}) + @test !hasmethod(searchsortedlast, Tuple{S, V, Float64, Int}) + @test !hasmethod(searchsortedfirst, Tuple{S, V, Float64, Int}) + end + end end end From a64751d3ab083c5d1f6d19d34b1d468c90d4b150 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Fri, 12 Jun 2026 17:16:16 -0400 Subject: [PATCH 11/14] Docs: update remaining pages to the v3-only search API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interface.md still described the v2 contract (extending Base.searchsortedlast/searchsortedfirst per strategy); it now documents search_last / search_first as the API surface and shows custom strategies extending the FFF-owned functions — which also means third-party strategies no longer commit type piracy on Base. auto.md's per-query section described the v2 pick-at-every-query decision tree; it now shows the v3 construction-time kind resolution including the KIND_UNIFORM_STEP props path. Remaining searchsortedlast(strategy, ...) examples in index.md / guessers.md / equality.md renamed. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- docs/src/auto.md | 12 ++++--- docs/src/equality.md | 2 +- docs/src/guessers.md | 4 +-- docs/src/index.md | 2 +- docs/src/interface.md | 84 +++++++++++++++++++++++++++---------------- 5 files changed, 66 insertions(+), 38 deletions(-) diff --git a/docs/src/auto.md b/docs/src/auto.md index 74fe7ff..71987fd 100644 --- a/docs/src/auto.md +++ b/docs/src/auto.md @@ -10,12 +10,16 @@ script at the end of the page generates the comparison grid. The decision differs between per-query and batched callers. -### Per-query: `searchsortedlast(Auto(), v, x[, hint])` +### Per-query: `search_last(Auto(v), v, x[, hint])` + +The kind is resolved once, at construction, and every per-query call +forwards to it: ``` -hint missing or out of axes(v) → BinaryBracket -length(v) ≤ 16 → LinearScan # _AUTO_LINEAR_THRESHOLD -otherwise → BracketGallop +Auto() (no data) → KIND_BINARY_BRACKET +Auto(v) and props.is_uniform → KIND_UNIFORM_STEP # props-aware closed form +Auto(v) and length(v) ≤ 16 → KIND_LINEAR_SCAN # _AUTO_LINEAR_THRESHOLD +Auto(v) otherwise → KIND_BRACKET_GALLOP ``` `Auto` never picks `InterpolationSearch` or `ExpFromLeft` in the per-query diff --git a/docs/src/equality.md b/docs/src/equality.md index 355702b..ec54b5b 100644 --- a/docs/src/equality.md +++ b/docs/src/equality.md @@ -37,7 +37,7 @@ FindFirstFunctions.findfirstsortedequal | Does `x` occur in this *unsorted* vector? | any | [`findfirstequal`](@ref FindFirstFunctions.findfirstequal) | | Does `x` occur in this *sorted* vector? | `DenseVector{Int64}` + `Int64` | [`findfirstsortedequal`](@ref FindFirstFunctions.findfirstsortedequal) (or `findequal(BisectThenSIMD(), v, x)` for the sentinel-returning variant) | | Does `x` occur in this *sorted* vector? | other eltypes | [`findequal`](@ref FindFirstFunctions.findequal) with any strategy | -| Where would `x` insert into this sorted vector? | any | `searchsortedfirst(strategy, v, x[, hint])` | +| Where would `x` insert into this sorted vector? | any | `search_first(strategy, v, x[, hint])` | ## SIMD primitives diff --git a/docs/src/guessers.md b/docs/src/guessers.md index 98f18fd..284882f 100644 --- a/docs/src/guessers.md +++ b/docs/src/guessers.md @@ -72,7 +72,7 @@ v = collect(0.0:0.1:10.0) g = Guesser(v) strat = GuesserHint(g) -i = searchsortedlast(strat, v, 3.14) +i = search_last(strat, v, 3.14) @assert g.idx_prev[] == i # the guesser caches the last result ``` @@ -93,7 +93,7 @@ end Interp(v) = Interp(v, Guesser(v)) function find_segment(itp::Interp, x) - return searchsortedlast(GuesserHint(itp.g), itp.v, x) + return search_last(GuesserHint(itp.g), itp.v, x) end ``` diff --git a/docs/src/index.md b/docs/src/index.md index 045b97c..cceaea4 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -51,7 +51,7 @@ v = collect(0.0:0.1:10.0) queries = sort!(rand(100) .* 10) # Single query with a hint. -i = searchsortedlast(BracketGallop(), v, 3.14, 30) +i = search_last(BracketGallop(), v, 3.14, 30) # Batched, with strategy chosen by Auto. idx = Vector{Int}(undef, length(queries)) diff --git a/docs/src/interface.md b/docs/src/interface.md index 7b1986c..d537c76 100644 --- a/docs/src/interface.md +++ b/docs/src/interface.md @@ -6,24 +6,30 @@ must satisfy. ## Public API surface -In 2.x the sorted-search public API is a single pair of generic functions -overloaded on a strategy as the first positional argument: +The sorted-search public API is the pair of FFF-owned generic functions +dispatched on a strategy as the first positional argument: ```julia -searchsortedfirst(strategy, v, x[, hint]; order = Base.Order.Forward) -searchsortedlast(strategy, v, x[, hint]; order = Base.Order.Forward) +search_first(strategy, v, x[, hint]; order = Base.Order.Forward) +search_last(strategy, v, x[, hint]; order = Base.Order.Forward) ``` -and the in-place batched variants: +`strategy` is a [`StrategyKind`](@ref FindFirstFunctions.StrategyKind) enum +value (`KIND_BRACKET_GALLOP`, …), a singleton strategy struct +(`BracketGallop()`, … — forwards through +[`strategy_kind`](@ref FindFirstFunctions.strategy_kind) and constant-folds +for a literal argument), or a stateful strategy (`Auto`, `GuesserHint`). + +The in-place batched variants: ```julia searchsortedfirst!(idx_out, v, queries; strategy = Auto(), order = Base.Order.Forward) searchsortedlast!(idx_out, v, queries; strategy = Auto(), order = Base.Order.Forward) ``` -The strategy types live in the `FindFirstFunctions` module; the -`searchsortedfirst`/`searchsortedlast` names are extended from `Base` so they -compose with existing `Base.Order` orderings. +FindFirstFunctions does not extend `Base.searchsortedfirst` / +`Base.searchsortedlast` — all strategy dispatch happens through the +FFF-owned names above, which compose with the same `Base.Order` orderings. ```@docs FindFirstFunctions.searchsortedfirst! @@ -40,8 +46,10 @@ FindFirstFunctions.searchsortedrange ([`InterpolationSearch`](@ref FindFirstFunctions.InterpolationSearch), [`BinaryBracket`](@ref FindFirstFunctions.BinaryBracket)). 2. **Strategies are singletons or wrappers.** `LinearScan`, `BracketGallop`, - `ExpFromLeft`, `InterpolationSearch`, `BinaryBracket`, and `Auto` are - zero-field singletons. + `ExpFromLeft`, `InterpolationSearch`, and `BinaryBracket` are + zero-field singletons mapped to their `StrategyKind` tag by + `strategy_kind`. [`Auto`](@ref FindFirstFunctions.Auto) carries a + resolved kind plus a `SearchProperties` payload; [`GuesserHint`](@ref FindFirstFunctions.GuesserHint) is a thin wrapper around a [`Guesser`](@ref FindFirstFunctions.Guesser). New strategies should follow the same pattern: parameters that change *behaviour* @@ -63,17 +71,28 @@ FindFirstFunctions.searchsortedrange ## Anatomy of a strategy -A built-in strategy provides up to four methods on each of -`Base.searchsortedfirst` / `Base.searchsortedlast`: +A built-in singleton strategy consists of a `StrategyKind` enum value, a +pair of kernel functions (`_kernel_last_` / `_kernel_first_` in +`src/kernels.jl`), and branches in the four dispatch switches in +`src/kinds.jl` (hinted/unhinted × last/first). The "no hint" branch of a +hint-using strategy falls back to `BinaryBracket`; the hinted branch of a +hint-ignoring strategy discards the hint. + +A custom strategy defined outside the package cannot add an enum value +(the enum is closed), so it provides its own `search_last` / `search_first` +methods instead — these are more specific than the generic +`SearchStrategy` fallback and take precedence: ```julia # Required: the hinted form (the strategy's reason for existing). -Base.searchsortedlast(::MyStrategy, v, x, hint::Integer; order) = ... -Base.searchsortedfirst(::MyStrategy, v, x, hint::Integer; order) = ... +FindFirstFunctions.search_last(::MyStrategy, v, x, hint::Integer; order) = ... +FindFirstFunctions.search_first(::MyStrategy, v, x, hint::Integer; order) = ... # Required: the unhinted form. Most strategies just fall back to BinaryBracket. -Base.searchsortedlast(::MyStrategy, v, x; order) = searchsortedlast(BinaryBracket(), v, x; order) -Base.searchsortedfirst(::MyStrategy, v, x; order) = searchsortedfirst(BinaryBracket(), v, x; order) +FindFirstFunctions.search_last(::MyStrategy, v, x; order) = + FindFirstFunctions.search_last(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order) +FindFirstFunctions.search_first(::MyStrategy, v, x; order) = + FindFirstFunctions.search_first(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order) ``` If your strategy ignores the hint, define just the unhinted form and have the @@ -113,7 +132,7 @@ end At minimum, the hinted forms: ```julia -function Base.searchsortedlast( +function FindFirstFunctions.search_last( ::MyStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward ) @@ -122,7 +141,7 @@ function Base.searchsortedlast( ... end -function Base.searchsortedfirst( +function FindFirstFunctions.search_first( ::MyStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward ) @@ -133,12 +152,17 @@ end Plus unhinted forms (typically fallbacks): ```julia -Base.searchsortedlast(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = - searchsortedlast(FindFirstFunctions.BinaryBracket(), v, x; order = order) -Base.searchsortedfirst(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = - searchsortedfirst(FindFirstFunctions.BinaryBracket(), v, x; order = order) +FindFirstFunctions.search_last(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = + FindFirstFunctions.search_last(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order = order) +FindFirstFunctions.search_first(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = + FindFirstFunctions.search_first(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order = order) ``` +When a strategy contributes to the package itself, add a `StrategyKind` +enum value, the kernel pair, the four dispatch-switch branches, and a +`strategy_kind` method instead — see `src/kinds.jl` and +`src/strategy_kind.jl`. + ### Correctness check Every strategy must return the same answer as plain `Base.searchsortedlast` / @@ -147,22 +171,22 @@ inputs against `Base`: ```julia using Test, Random +using FindFirstFunctions: search_last, search_first Random.seed!(0) for trial in 1:10_000 v = sort!(randn(rand(1:1000))) x = randn() hint = rand(1:length(v)) - @test searchsortedlast(MyStrategy(), v, x, hint) == searchsortedlast(v, x) - @test searchsortedfirst(MyStrategy(), v, x, hint) == searchsortedfirst(v, x) + @test search_last(MyStrategy(), v, x, hint) == searchsortedlast(v, x) + @test search_first(MyStrategy(), v, x, hint) == searchsortedfirst(v, x) end ``` ### Hooking into `Auto` -`Auto`'s decision tree lives in `_auto_pick` (per-query) and -`_searchsortedlast_batched!(_, _, _, ::Auto, _)` / -`_searchsortedfirst_batched!(_, _, _, ::Auto, _)` (batched). It is **not -extensible from outside** — new strategies do not register themselves with -`Auto` automatically. If you believe `Auto` should pick your strategy in -some regime, open an issue with benchmark numbers across the regime grid in +`Auto`'s decision tree lives in `_auto_resolve_kind` (construction-time) +and `_auto_batched_kind` (batched). It is **not extensible from outside** — +new strategies do not register themselves with `Auto` automatically. If you +believe `Auto` should pick your strategy in some regime, open an issue with +benchmark numbers across the regime grid in [Auto: heuristics and benchmarks](@ref). From 557417b7880bb8f48277d59a647e9352447ac8cc Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Sat, 13 Jun 2026 04:02:57 -0400 Subject: [PATCH 12/14] =?UTF-8?q?Rename=20search=5Flast/search=5Ffirst=20?= =?UTF-8?q?=E2=86=92=20searchsorted=5Flast/searchsorted=5Ffirst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 dispatcher names dropped the 'sorted' cue that Base's searchsortedfirst/searchsortedlast carried — but these functions share that precondition exactly (v must be sorted; assumed, not checked). Put 'sorted' back in the name. The underscore form stays distinct from Base's exported searchsortedfirst/searchsortedlast (so it remains exportable and bare calls resolve to FFF's) and matches the existing FFF family searchsortedfirst!/searchsortedlast!/searchsortedrange. Mechanical rename of the public functions, their methods (Auto, GuesserHint, the struct fallback, the kind dispatchers), the internal _search_*_ dispatch helpers, all call sites, exports, tests, NEWS, and docs. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- NEWS.md | 26 +-- docs/src/auto.md | 2 +- docs/src/equality.md | 2 +- docs/src/guessers.md | 4 +- docs/src/index.md | 2 +- docs/src/interface.md | 36 ++-- docs/src/strategies.md | 32 ++-- src/FindFirstFunctions.jl | 6 +- src/auto.jl | 22 +-- src/batched.jl | 44 ++--- src/findequal.jl | 16 +- src/guesser.jl | 18 +- src/kinds.jl | 48 ++--- src/precompile.jl | 18 +- src/strategies.jl | 16 +- src/strategy_kind.jl | 24 +-- test/qa/qa_tests.jl | 14 +- test/runtests.jl | 356 +++++++++++++++++++------------------- 18 files changed, 343 insertions(+), 343 deletions(-) diff --git a/NEWS.md b/NEWS.md index 1fa6a24..91f3cb7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -16,8 +16,8 @@ The 3.0 redesign replaces the multimethod dispatch on `SearchStrategy` singletons with a single FFF-owned dispatcher tagged by an enum: ```julia -search_last(KIND_BRACKET_GALLOP, v, x, hint) -search_first(KIND_INTERPOLATION_SEARCH, v, x) +searchsorted_last(KIND_BRACKET_GALLOP, v, x, hint) +searchsorted_first(KIND_INTERPOLATION_SEARCH, v, x) ``` The runtime `if/elseif` over `StrategyKind` values is well-predicted in @@ -29,7 +29,7 @@ multimethod path. ### Breaking: the `Base.searchsortedlast(::S, ...)` API is removed v3 no longer extends `Base.searchsortedlast` / `Base.searchsortedfirst` -with strategy methods. The FFF-owned `search_last` / `search_first` +with strategy methods. The FFF-owned `searchsorted_last` / `searchsorted_first` dispatchers are the only search entry points; they accept a `StrategyKind` tag, a strategy struct (which forwards through [`strategy_kind`] and constant-folds for literal strategies), or a @@ -37,11 +37,11 @@ stateful strategy (`Auto`, `GuesserHint`): | v2 (removed) | v3 | |---|---| -| `searchsortedlast(BracketGallop(), v, x, hint)` | `search_last(KIND_BRACKET_GALLOP, v, x, hint)` or `search_last(BracketGallop(), v, x, hint)` | -| `searchsortedfirst(InterpolationSearch(), v, x)` | `search_first(KIND_INTERPOLATION_SEARCH, v, x)` or `search_first(InterpolationSearch(), v, x)` | -| `searchsortedlast(UniformStep(), r, x)` | `search_last(KIND_UNIFORM_STEP, r, x)` or `search_last(UniformStep(), r, x)` | -| `searchsortedlast(Auto(v), v, x, hint)` | `search_last(Auto(v), v, x, hint)` | -| `searchsortedfirst(GuesserHint(g), v, x)` | `search_first(GuesserHint(g), v, x)` | +| `searchsortedlast(BracketGallop(), v, x, hint)` | `searchsorted_last(KIND_BRACKET_GALLOP, v, x, hint)` or `searchsorted_last(BracketGallop(), v, x, hint)` | +| `searchsortedfirst(InterpolationSearch(), v, x)` | `searchsorted_first(KIND_INTERPOLATION_SEARCH, v, x)` or `searchsorted_first(InterpolationSearch(), v, x)` | +| `searchsortedlast(UniformStep(), r, x)` | `searchsorted_last(KIND_UNIFORM_STEP, r, x)` or `searchsorted_last(UniformStep(), r, x)` | +| `searchsortedlast(Auto(v), v, x, hint)` | `searchsorted_last(Auto(v), v, x, hint)` | +| `searchsortedfirst(GuesserHint(g), v, x)` | `searchsorted_first(GuesserHint(g), v, x)` | (The same rename applies to every other singleton strategy.) The batched in-place API (`searchsortedlast!` / `searchsortedfirst!` / @@ -56,7 +56,7 @@ In v2, `Auto()`'s per-query `Base.searchsortedlast(::Auto, v, x, hint)` ran the picking logic on every call (consulting `length(v)`, hint validity, and `props.is_uniform`). In v3, `Auto` carries a resolved `StrategyKind` and per-query dispatch is a one-line forward to -`search_last(s.kind, v, x, hint)`: +`searchsorted_last(s.kind, v, x, hint)`: - `Auto()` defaults to `KIND_BINARY_BRACKET` (safe; matches `Base.searchsortedlast` exactly). @@ -105,7 +105,7 @@ invoked by `Auto(v)` when the resolved kind is `KIND_UNIFORM_STEP`: ```julia # v3 closed-form O(1) lookup with no per-query division: a = Auto(0.0:0.5:100.0) # kind = KIND_UNIFORM_STEP, inv_step = 2.0 -search_last(a, r, 3.7) # → floor((3.7 - 0.0) * 2.0) + 1 = 8 +searchsorted_last(a, r, 3.7) # → floor((3.7 - 0.0) * 2.0) + 1 = 8 ``` This subsumes the never-merged `DirectStep` strategy (PR #74) — its @@ -147,13 +147,13 @@ which forwards through the same struct → kind mapping. The v2 `src/dispatch.jl` file (Base.searchsortedlast extensions per strategy) is gone. In its place: - - `src/kinds.jl` — `StrategyKind` enum and `search_last` / - `search_first` enum dispatchers. + - `src/kinds.jl` — `StrategyKind` enum and `searchsorted_last` / + `searchsorted_first` enum dispatchers. - `src/kernels.jl` — per-strategy kernel functions (`_kernel_last_bracket_gallop`, etc.), lifted out of the v2 method bodies. - `src/strategy_kind.jl` — the struct → kind mapping plus the - struct-valued `search_last(::S, ...)` / `search_first(::S, ...)` + struct-valued `searchsorted_last(::S, ...)` / `searchsorted_first(::S, ...)` entry points that forward through it. ## 2.0.0 diff --git a/docs/src/auto.md b/docs/src/auto.md index 71987fd..86fed15 100644 --- a/docs/src/auto.md +++ b/docs/src/auto.md @@ -10,7 +10,7 @@ script at the end of the page generates the comparison grid. The decision differs between per-query and batched callers. -### Per-query: `search_last(Auto(v), v, x[, hint])` +### Per-query: `searchsorted_last(Auto(v), v, x[, hint])` The kind is resolved once, at construction, and every per-query call forwards to it: diff --git a/docs/src/equality.md b/docs/src/equality.md index ec54b5b..8de069c 100644 --- a/docs/src/equality.md +++ b/docs/src/equality.md @@ -37,7 +37,7 @@ FindFirstFunctions.findfirstsortedequal | Does `x` occur in this *unsorted* vector? | any | [`findfirstequal`](@ref FindFirstFunctions.findfirstequal) | | Does `x` occur in this *sorted* vector? | `DenseVector{Int64}` + `Int64` | [`findfirstsortedequal`](@ref FindFirstFunctions.findfirstsortedequal) (or `findequal(BisectThenSIMD(), v, x)` for the sentinel-returning variant) | | Does `x` occur in this *sorted* vector? | other eltypes | [`findequal`](@ref FindFirstFunctions.findequal) with any strategy | -| Where would `x` insert into this sorted vector? | any | `search_first(strategy, v, x[, hint])` | +| Where would `x` insert into this sorted vector? | any | `searchsorted_first(strategy, v, x[, hint])` | ## SIMD primitives diff --git a/docs/src/guessers.md b/docs/src/guessers.md index 284882f..9210155 100644 --- a/docs/src/guessers.md +++ b/docs/src/guessers.md @@ -72,7 +72,7 @@ v = collect(0.0:0.1:10.0) g = Guesser(v) strat = GuesserHint(g) -i = search_last(strat, v, 3.14) +i = searchsorted_last(strat, v, 3.14) @assert g.idx_prev[] == i # the guesser caches the last result ``` @@ -93,7 +93,7 @@ end Interp(v) = Interp(v, Guesser(v)) function find_segment(itp::Interp, x) - return search_last(GuesserHint(itp.g), itp.v, x) + return searchsorted_last(GuesserHint(itp.g), itp.v, x) end ``` diff --git a/docs/src/index.md b/docs/src/index.md index cceaea4..e39aa7d 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -51,7 +51,7 @@ v = collect(0.0:0.1:10.0) queries = sort!(rand(100) .* 10) # Single query with a hint. -i = search_last(BracketGallop(), v, 3.14, 30) +i = searchsorted_last(BracketGallop(), v, 3.14, 30) # Batched, with strategy chosen by Auto. idx = Vector{Int}(undef, length(queries)) diff --git a/docs/src/interface.md b/docs/src/interface.md index d537c76..966a6ff 100644 --- a/docs/src/interface.md +++ b/docs/src/interface.md @@ -10,8 +10,8 @@ The sorted-search public API is the pair of FFF-owned generic functions dispatched on a strategy as the first positional argument: ```julia -search_first(strategy, v, x[, hint]; order = Base.Order.Forward) -search_last(strategy, v, x[, hint]; order = Base.Order.Forward) +searchsorted_first(strategy, v, x[, hint]; order = Base.Order.Forward) +searchsorted_last(strategy, v, x[, hint]; order = Base.Order.Forward) ``` `strategy` is a [`StrategyKind`](@ref FindFirstFunctions.StrategyKind) enum @@ -79,20 +79,20 @@ hint-using strategy falls back to `BinaryBracket`; the hinted branch of a hint-ignoring strategy discards the hint. A custom strategy defined outside the package cannot add an enum value -(the enum is closed), so it provides its own `search_last` / `search_first` +(the enum is closed), so it provides its own `searchsorted_last` / `searchsorted_first` methods instead — these are more specific than the generic `SearchStrategy` fallback and take precedence: ```julia # Required: the hinted form (the strategy's reason for existing). -FindFirstFunctions.search_last(::MyStrategy, v, x, hint::Integer; order) = ... -FindFirstFunctions.search_first(::MyStrategy, v, x, hint::Integer; order) = ... +FindFirstFunctions.searchsorted_last(::MyStrategy, v, x, hint::Integer; order) = ... +FindFirstFunctions.searchsorted_first(::MyStrategy, v, x, hint::Integer; order) = ... # Required: the unhinted form. Most strategies just fall back to BinaryBracket. -FindFirstFunctions.search_last(::MyStrategy, v, x; order) = - FindFirstFunctions.search_last(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order) -FindFirstFunctions.search_first(::MyStrategy, v, x; order) = - FindFirstFunctions.search_first(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order) +FindFirstFunctions.searchsorted_last(::MyStrategy, v, x; order) = + FindFirstFunctions.searchsorted_last(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order) +FindFirstFunctions.searchsorted_first(::MyStrategy, v, x; order) = + FindFirstFunctions.searchsorted_first(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order) ``` If your strategy ignores the hint, define just the unhinted form and have the @@ -132,7 +132,7 @@ end At minimum, the hinted forms: ```julia -function FindFirstFunctions.search_last( +function FindFirstFunctions.searchsorted_last( ::MyStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward ) @@ -141,7 +141,7 @@ function FindFirstFunctions.search_last( ... end -function FindFirstFunctions.search_first( +function FindFirstFunctions.searchsorted_first( ::MyStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward ) @@ -152,10 +152,10 @@ end Plus unhinted forms (typically fallbacks): ```julia -FindFirstFunctions.search_last(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = - FindFirstFunctions.search_last(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order = order) -FindFirstFunctions.search_first(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = - FindFirstFunctions.search_first(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order = order) +FindFirstFunctions.searchsorted_last(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = + FindFirstFunctions.searchsorted_last(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order = order) +FindFirstFunctions.searchsorted_first(s::MyStrategy, v::AbstractVector, x; order = Base.Order.Forward) = + FindFirstFunctions.searchsorted_first(FindFirstFunctions.KIND_BINARY_BRACKET, v, x; order = order) ``` When a strategy contributes to the package itself, add a `StrategyKind` @@ -171,14 +171,14 @@ inputs against `Base`: ```julia using Test, Random -using FindFirstFunctions: search_last, search_first +using FindFirstFunctions: searchsorted_last, searchsorted_first Random.seed!(0) for trial in 1:10_000 v = sort!(randn(rand(1:1000))) x = randn() hint = rand(1:length(v)) - @test search_last(MyStrategy(), v, x, hint) == searchsortedlast(v, x) - @test search_first(MyStrategy(), v, x, hint) == searchsortedfirst(v, x) + @test searchsorted_last(MyStrategy(), v, x, hint) == searchsortedlast(v, x) + @test searchsorted_first(MyStrategy(), v, x, hint) == searchsortedfirst(v, x) end ``` diff --git a/docs/src/strategies.md b/docs/src/strategies.md index 87f9b82..9efbc86 100644 --- a/docs/src/strategies.md +++ b/docs/src/strategies.md @@ -5,13 +5,13 @@ ways to select a strategy: - **v3 preferred:** pass a [`StrategyKind`](@ref FindFirstFunctions.StrategyKind) enum value (e.g. `KIND_BRACKET_GALLOP`) as the first argument to - [`search_last`](@ref FindFirstFunctions.search_last) / - [`search_first`](@ref FindFirstFunctions.search_first). One enum + [`searchsorted_last`](@ref FindFirstFunctions.searchsorted_last) / + [`searchsorted_first`](@ref FindFirstFunctions.searchsorted_first). One enum value per singleton strategy; runtime `if/elseif` dispatch into the matching kernel; ~0 ns of overhead in hot loops; the inferred return type is concrete regardless of which kind is picked at runtime. - **Struct form:** pass a singleton strategy struct (e.g. - `BracketGallop()`) to the same `search_last` / `search_first`. The + `BracketGallop()`) to the same `searchsorted_last` / `searchsorted_first`. The struct forwards through [`strategy_kind`](@ref FindFirstFunctions.strategy_kind), which constant-folds for a literal strategy argument — the struct form @@ -28,8 +28,8 @@ via their own multimethods. ```@docs FindFirstFunctions.SearchStrategy FindFirstFunctions.StrategyKind -FindFirstFunctions.search_last -FindFirstFunctions.search_first +FindFirstFunctions.searchsorted_last +FindFirstFunctions.searchsorted_first FindFirstFunctions.strategy_kind ``` @@ -52,13 +52,13 @@ multimethod path: - [`Auto`](@ref FindFirstFunctions.Auto): carries a `StrategyKind` field plus a [`SearchProperties`](@ref FindFirstFunctions.SearchProperties) - cache. `Auto`'s `search_last` is a one-line forward to the stored + cache. `Auto`'s `searchsorted_last` is a one-line forward to the stored kind; the batched dispatch re-resolves the kind from `(v, queries)` because the gap heuristic needs the queries. - [`GuesserHint`](@ref FindFirstFunctions.GuesserHint): carries a [`Guesser`](@ref FindFirstFunctions.Guesser) (with its `idx_prev::Ref{Int}` and `linear_lookup::Bool`). Dispatches via its own - `search_last(::GuesserHint, ...)` / `search_first(::GuesserHint, ...)` + `searchsorted_last(::GuesserHint, ...)` / `searchsorted_first(::GuesserHint, ...)` methods. ## When to pick which @@ -88,7 +88,7 @@ falls back to `BinaryBracket` for non-numeric element types. The v2 `Base.searchsortedlast(::S, ...)` / `Base.searchsortedfirst(::S, ...)` methods are removed in v3. The migration is a mechanical rename — -`searchsortedlast` → `search_last`, `searchsortedfirst` → `search_first` — +`searchsortedlast` → `searchsorted_last`, `searchsortedfirst` → `searchsorted_first` — with the strategy argument unchanged: ```julia @@ -99,14 +99,14 @@ searchsortedlast(Auto(v), v, x, hint) searchsortedfirst(GuesserHint(g), v, x) # v3 -search_last(BracketGallop(), v, x, hint) -search_first(InterpolationSearch(), v, x) -search_last(Auto(v), v, x, hint) -search_first(GuesserHint(g), v, x) +searchsorted_last(BracketGallop(), v, x, hint) +searchsorted_first(InterpolationSearch(), v, x) +searchsorted_last(Auto(v), v, x, hint) +searchsorted_first(GuesserHint(g), v, x) ``` Hot loops that pick the strategy from runtime data should use the -`StrategyKind` enum form (`search_last(KIND_BRACKET_GALLOP, v, x, hint)`); +`StrategyKind` enum form (`searchsorted_last(KIND_BRACKET_GALLOP, v, x, hint)`); for a literal singleton strategy the struct form compiles to exactly the same code. @@ -146,7 +146,7 @@ The sentinel for "not found" is `firstindex(v) - 1` (`= 0` for 1-based vectors). Type-stable `Int` return, no `Union` with `Nothing`. Callers can test for absence with `i < firstindex(v)`. -`findequal` routes most strategies through `search_first + post-check` +`findequal` routes most strategies through `searchsorted_first + post-check` generically, so `findequal(BracketGallop(), v, x, hint)`, `findequal(SIMDLinearScan(), v, x, hint)`, `findequal(GuesserHint(g), v, x)`, `findequal(KIND_BRACKET_GALLOP, v, x, hint)`, @@ -267,8 +267,8 @@ cache: otherwise. - `Auto(v, props)` is the same with a pre-computed `props` cache. -The per-query `search_last(::Auto, v, x, hint)` is a one-line forward to -`search_last(s.kind, v, x, hint)`. The batched +The per-query `searchsorted_last(::Auto, v, x, hint)` is a one-line forward to +`searchsorted_last(s.kind, v, x, hint)`. The batched `searchsortedlast!(out, v, queries; strategy = Auto())` re-resolves the kind from `(v, queries)` to consult the gap heuristic. diff --git a/src/FindFirstFunctions.jl b/src/FindFirstFunctions.jl index 0f7c45c..fbf1f0b 100644 --- a/src/FindFirstFunctions.jl +++ b/src/FindFirstFunctions.jl @@ -6,7 +6,7 @@ module FindFirstFunctions # caller's namespace — no runtime cost. # # v3 replaces the v2 `Base.searchsortedlast(::S, ...)` extensions with -# the FFF-owned `search_last` / `search_first` dispatchers, which accept +# the FFF-owned `searchsorted_last` / `searchsorted_first` dispatchers, which accept # a `StrategyKind` tag, a strategy struct, or a stateful strategy # (`Auto`, `GuesserHint`). FFF no longer extends `Base.searchsortedlast` # or `Base.searchsortedfirst`. @@ -27,7 +27,7 @@ export KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, KIND_BIT_INTERPOLATION_SEARCH, KIND_UNIFORM_STEP, KIND_BISECT_THEN_SIMD, - search_last, search_first, strategy_kind, + searchsorted_last, searchsorted_first, strategy_kind, # Batched API. searchsortedfirst!, searchsortedlast!, searchsortedrange, # Equality search. @@ -40,7 +40,7 @@ const USE_PTR = VERSION >= v"1.12.0-DEV.255" # defined in earlier files. include("simd_ir.jl") # IR template + SIMD primitives include("equality.jl") # findfirstequal + findfirstsortedequal -include("kinds.jl") # StrategyKind enum + search_last / search_first dispatchers +include("kinds.jl") # StrategyKind enum + searchsorted_last / searchsorted_first dispatchers include("strategies.jl") # SearchStrategy + concrete strategy types + SearchProperties + Auto include("search_properties.jl") # Linearity / NaN probes + populated SearchProperties constructor include("kernels.jl") # Per-strategy kernel functions called by the dispatchers diff --git a/src/auto.jl b/src/auto.jl index 1b60faf..e8c409a 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -1,6 +1,6 @@ # Auto strategy — resolves a `StrategyKind` from `(v, props)` at # construction time. Per-query dispatch is then a one-line forward to -# `search_last` / `search_first` on the stored kind. The batched dispatch +# `searchsorted_last` / `searchsorted_first` on the stored kind. The batched dispatch # (in `batched.jl`) re-resolves the kind from `(v, queries)` because the # gap heuristic needs the queries. # @@ -9,7 +9,7 @@ # - The helper predicates (`_auto_is_uniform`, `_auto_simd_eligible`, # `_estimate_avg_gap`, `_auto_interp_eligible`, …) # - `_auto_resolve_kind(v, props)` — forward-declared in `strategies.jl` -# - The per-query `search_last(::Auto, ...)` / `search_first(::Auto, ...)` +# - The per-query `searchsorted_last(::Auto, ...)` / `searchsorted_first(::Auto, ...)` # methods. # Per-query Auto threshold: under this length, the bracket-search bookkeeping @@ -115,7 +115,7 @@ end # Per-query Auto dispatch. The stored kind handles every hint configuration # robustly — `BracketGallop` falls back to a full search when the hint is # absent or out of range; `LinearScan` (picked for short `v`) clamps the -# hint and walks. So `search_last(::Auto, v, x, hint)` is a one-line +# hint and walks. So `searchsorted_last(::Auto, v, x, hint)` is a one-line # forward to the kind dispatcher. # # Special case: when `kind === KIND_UNIFORM_STEP` and `props` is @@ -129,48 +129,48 @@ end # --------------------------------------------------------------------------- # Hinted form: forward to the kind dispatcher. -@inline function search_last( +@inline function searchsorted_last( s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_last_uniform_step_props(s.props, v, x, order) else - search_last(s.kind, v, x, hint; order = order) + searchsorted_last(s.kind, v, x, hint; order = order) end end -@inline function search_first( +@inline function searchsorted_first( s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_first_uniform_step_props(s.props, v, x, order) else - search_first(s.kind, v, x, hint; order = order) + searchsorted_first(s.kind, v, x, hint; order = order) end end # No-hint form: same forward. The kind's no-hint dispatch handles # fall-through to BinaryBracket for hint-using strategies. -@inline function search_last( +@inline function searchsorted_last( s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_last_uniform_step_props(s.props, v, x, order) else - search_last(s.kind, v, x; order = order) + searchsorted_last(s.kind, v, x; order = order) end end -@inline function search_first( +@inline function searchsorted_first( s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) return if s.kind === KIND_UNIFORM_STEP && s.props.has_props _kernel_first_uniform_step_props(s.props, v, x, order) else - search_first(s.kind, v, x; order = order) + searchsorted_first(s.kind, v, x; order = order) end end diff --git a/src/batched.jl b/src/batched.jl index ad18372..652ce99 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -74,7 +74,7 @@ function searchsortedfirst!( end # Sorted inner loop parameterized on a `StrategyKind` — concrete kernel -# dispatch happens inside `search_last` via the enum switch. +# dispatch happens inside `searchsorted_last` via the enum switch. function _searchsortedlast_sorted_loop_kind!( idx_out, v::AbstractVector, queries::AbstractVector, kind::StrategyKind, order::Base.Order.Ordering, @@ -83,9 +83,9 @@ function _searchsortedlast_sorted_loop_kind!( @inbounds for k in eachindex(queries) q = queries[k] hint = if hint < firstindex(v) - search_last(kind, v, q; order = order) + searchsorted_last(kind, v, q; order = order) else - search_last(kind, v, q, hint; order = order) + searchsorted_last(kind, v, q, hint; order = order) end idx_out[k] = hint end @@ -100,9 +100,9 @@ function _searchsortedfirst_sorted_loop_kind!( @inbounds for k in eachindex(queries) q = queries[k] hint = if hint < firstindex(v) - search_first(kind, v, q; order = order) + searchsorted_first(kind, v, q; order = order) else - search_first(kind, v, q, hint; order = order) + searchsorted_first(kind, v, q, hint; order = order) end idx_out[k] = hint end @@ -110,7 +110,7 @@ function _searchsortedfirst_sorted_loop_kind!( end # Sorted inner loop parameterized on a strategy *struct* (for GuesserHint -# and for the struct-valued `search_last(::S, ...)` path). +# and for the struct-valued `searchsorted_last(::S, ...)` path). function _searchsortedlast_sorted_loop!( idx_out, v::AbstractVector, queries::AbstractVector, strategy::SearchStrategy, order::Base.Order.Ordering, @@ -119,9 +119,9 @@ function _searchsortedlast_sorted_loop!( @inbounds for k in eachindex(queries) q = queries[k] hint = if hint < firstindex(v) - search_last(strategy, v, q; order = order) + searchsorted_last(strategy, v, q; order = order) else - search_last(strategy, v, q, hint; order = order) + searchsorted_last(strategy, v, q, hint; order = order) end idx_out[k] = hint end @@ -136,9 +136,9 @@ function _searchsortedfirst_sorted_loop!( @inbounds for k in eachindex(queries) q = queries[k] hint = if hint < firstindex(v) - search_first(strategy, v, q; order = order) + searchsorted_first(strategy, v, q; order = order) else - search_first(strategy, v, q, hint; order = order) + searchsorted_first(strategy, v, q, hint; order = order) end idx_out[k] = hint end @@ -292,7 +292,7 @@ function _searchsortedlast_batched!( end else @inbounds for k in eachindex(queries) - idx_out[k] = search_last(KIND_UNIFORM_STEP, v, queries[k]; order = order) + idx_out[k] = searchsorted_last(KIND_UNIFORM_STEP, v, queries[k]; order = order) end end return idx_out @@ -326,7 +326,7 @@ function _searchsortedfirst_batched!( end else @inbounds for k in eachindex(queries) - idx_out[k] = search_first(KIND_UNIFORM_STEP, v, queries[k]; order = order) + idx_out[k] = searchsorted_first(KIND_UNIFORM_STEP, v, queries[k]; order = order) end end return idx_out @@ -382,8 +382,8 @@ end Return the index range of all entries `v[i]` satisfying `lo ≤ v[i] ≤ hi` under `order`. Equivalent to -`search_first(strategy, v, lo[, hint]; order) : - search_last(strategy, v, hi[, hint]; order)`. +`searchsorted_first(strategy, v, lo[, hint]; order) : + searchsorted_last(strategy, v, hi[, hint]; order)`. When a `hint` is supplied it seeds the `lo` search directly; the `hi` search is seeded with `max(first_idx, hint)`, since the upper endpoint @@ -394,8 +394,8 @@ the hinted form as a pass-through. strategy::SearchStrategy, v::AbstractVector, lo, hi; order::Base.Order.Ordering = Base.Order.Forward, ) - first_idx = search_first(strategy, v, lo; order = order) - last_idx = search_last(strategy, v, hi; order = order) + first_idx = searchsorted_first(strategy, v, lo; order = order) + last_idx = searchsorted_last(strategy, v, hi; order = order) return first_idx:last_idx end @@ -403,8 +403,8 @@ end strategy::SearchStrategy, v::AbstractVector, lo, hi, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - first_idx = search_first(strategy, v, lo, hint; order = order) - last_idx = search_last( + first_idx = searchsorted_first(strategy, v, lo, hint; order = order) + last_idx = searchsorted_last( strategy, v, hi, max(first_idx, hint); order = order ) return first_idx:last_idx @@ -415,8 +415,8 @@ end kind::StrategyKind, v::AbstractVector, lo, hi; order::Base.Order.Ordering = Base.Order.Forward, ) - first_idx = search_first(kind, v, lo; order = order) - last_idx = search_last(kind, v, hi; order = order) + first_idx = searchsorted_first(kind, v, lo; order = order) + last_idx = searchsorted_last(kind, v, hi; order = order) return first_idx:last_idx end @@ -424,7 +424,7 @@ end kind::StrategyKind, v::AbstractVector, lo, hi, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - first_idx = search_first(kind, v, lo, hint; order = order) - last_idx = search_last(kind, v, hi, max(first_idx, hint); order = order) + first_idx = searchsorted_first(kind, v, lo, hint; order = order) + last_idx = searchsorted_last(kind, v, hi, max(first_idx, hint); order = order) return first_idx:last_idx end diff --git a/src/findequal.jl b/src/findequal.jl index ae2274a..392af0a 100644 --- a/src/findequal.jl +++ b/src/findequal.jl @@ -2,7 +2,7 @@ # Returns an `Int` with the sentinel `firstindex(v) - 1` for "not found" # (matching `Base.searchsortedlast`'s convention). # -# Most strategies compose: run `search_first(strategy, v, x[, hint])` to +# Most strategies compose: run `searchsorted_first(strategy, v, x[, hint])` to # find the candidate insertion point, then post-check whether `v[i] == x`. # The `BisectThenSIMD` shortcut for `DenseVector{Int64}` dispatches into # `findfirstsortedequal` directly. @@ -40,20 +40,20 @@ end strategy::SearchStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - i = search_first(strategy, v, x, hint; order = order) + i = searchsorted_first(strategy, v, x, hint; order = order) return _findequal_postcheck(v, x, i) end # Enum-tagged form. `KIND_BISECT_THEN_SIMD` forwards to the struct form so # the `DenseVector{Int64}` bisect-then-SIMD shortcut is reached — the -# generic `search_first` path would silently lose it. +# generic `searchsorted_first` path would silently lose it. @inline function findequal( kind::StrategyKind, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) kind === KIND_BISECT_THEN_SIMD && return findequal(BisectThenSIMD(), v, x; order = order) - i = search_first(kind, v, x; order = order) + i = searchsorted_first(kind, v, x; order = order) return _findequal_postcheck(v, x, i) end @@ -63,12 +63,12 @@ end ) kind === KIND_BISECT_THEN_SIMD && return findequal(BisectThenSIMD(), v, x, hint; order = order) - i = search_first(kind, v, x, hint; order = order) + i = searchsorted_first(kind, v, x, hint; order = order) return _findequal_postcheck(v, x, i) end @inline function _findequal_generic_strategy(strategy, v, x, order) - i = search_first(strategy, v, x; order = order) + i = searchsorted_first(strategy, v, x; order = order) return _findequal_postcheck(v, x, i) end @@ -86,7 +86,7 @@ function findequal( order::Base.Order.Ordering = Base.Order.Forward, ) if order !== Base.Order.Forward - return _findequal_postcheck(v, x, search_first(KIND_BINARY_BRACKET, v, x; order = order)) + return _findequal_postcheck(v, x, searchsorted_first(KIND_BINARY_BRACKET, v, x; order = order)) end r = findfirstsortedequal(x, v) return r === nothing ? (firstindex(v) - 1) : r @@ -101,7 +101,7 @@ function findequal( ::BisectThenSIMD, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - i = search_first(KIND_BINARY_BRACKET, v, x; order = order) + i = searchsorted_first(KIND_BINARY_BRACKET, v, x; order = order) return _findequal_postcheck(v, x, i) end findequal( diff --git a/src/guesser.jl b/src/guesser.jl index 02fff8b..11c2342 100644 --- a/src/guesser.jl +++ b/src/guesser.jl @@ -1,6 +1,6 @@ # `Guesser` correlated-lookup helper + the public `looks_linear` probe it # uses + the `GuesserHint` strategy dispatch that plugs a `Guesser` into -# the v3 `search_last` / `search_first` API. +# the v3 `searchsorted_last` / `searchsorted_first` API. """ looks_linear(v; threshold = 1e-2) @@ -67,32 +67,32 @@ end # struct (not via a `StrategyKind`). The cost per call is one # `guesser(x)` + one BracketGallop call + one `idx_prev[]` write. -@inline function search_last( +@inline function searchsorted_last( s::GuesserHint, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) @assert v === s.guesser.v - out = search_last(KIND_BRACKET_GALLOP, v, x, s.guesser(x); order = order) + out = searchsorted_last(KIND_BRACKET_GALLOP, v, x, s.guesser(x); order = order) s.guesser.idx_prev[] = out return out end -@inline function search_first( +@inline function searchsorted_first( s::GuesserHint, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) @assert v === s.guesser.v - out = search_first(KIND_BRACKET_GALLOP, v, x, s.guesser(x); order = order) + out = searchsorted_first(KIND_BRACKET_GALLOP, v, x, s.guesser(x); order = order) s.guesser.idx_prev[] = out return out end # GuesserHint ignores any externally-supplied hint. -@inline search_last( +@inline searchsorted_last( s::GuesserHint, v::AbstractVector, x, ::Integer; order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(s, v, x; order = order) -@inline search_first( +) = searchsorted_last(s, v, x; order = order) +@inline searchsorted_first( s::GuesserHint, v::AbstractVector, x, ::Integer; order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(s, v, x; order = order) +) = searchsorted_first(s, v, x; order = order) diff --git a/src/kinds.jl b/src/kinds.jl index 9fdd081..810c3bd 100644 --- a/src/kinds.jl +++ b/src/kinds.jl @@ -1,7 +1,7 @@ # Enum-tagged dispatch for singleton search strategies. # # Each value of `StrategyKind` names one of the singleton, zero-state -# strategies. The pair `search_last` / `search_first` is the single public +# strategies. The pair `searchsorted_last` / `searchsorted_first` is the single public # entry point: a runtime-tag dispatcher that branches on the enum and # inlines into the matching kernel function (defined in `kernels.jl`). # @@ -15,17 +15,17 @@ # Stateful strategies (`GuesserHint(::Guesser)`) do *not* live in the enum. # They carry per-instance data, so a singleton tag would lose information. # Instead they dispatch directly via their wrapper struct -# (`search_last(::GuesserHint, ...)`). +# (`searchsorted_last(::GuesserHint, ...)`). """ StrategyKind Enum tag identifying a singleton search strategy. Use values of this -enum as the first positional argument to [`search_last`](@ref) and -[`search_first`](@ref): +enum as the first positional argument to [`searchsorted_last`](@ref) and +[`searchsorted_first`](@ref): ```julia -search_last(KIND_BRACKET_GALLOP, v, x, hint) +searchsorted_last(KIND_BRACKET_GALLOP, v, x, hint) ``` Each tag corresponds to one of the singleton strategy types @@ -50,8 +50,8 @@ that carries it. end """ - search_last(kind::StrategyKind, v, x[, hint]; order = Base.Order.Forward) - search_last(s, v, x[, hint]; order = Base.Order.Forward) + searchsorted_last(kind::StrategyKind, v, x[, hint]; order = Base.Order.Forward) + searchsorted_last(s, v, x[, hint]; order = Base.Order.Forward) FFF-owned positional search for the largest index `i` with `v[i] ≤ x` under `order` (or `v[i] ≥ x` under `Base.Order.Reverse`). The polarity @@ -61,46 +61,46 @@ When the first argument is a [`StrategyKind`](@ref) value the call dispatches via a runtime `if/elseif` branch on the enum into the matching kernel. When the first argument is a stateful strategy wrapper (`Auto`, `GuesserHint`) the call dispatches via multimethod into that wrapper's -own `search_last` method. +own `searchsorted_last` method. This is the only search entry point: as of v3, FindFirstFunctions no longer extends `Base.searchsortedlast` / `Base.searchsortedfirst` with strategy methods. """ -@inline function search_last( +@inline function searchsorted_last( kind::StrategyKind, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return _search_last_nohint(kind, v, x, order) + return _searchsorted_last_nohint(kind, v, x, order) end -@inline function search_last( +@inline function searchsorted_last( kind::StrategyKind, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - return _search_last_hinted(kind, v, x, hint, order) + return _searchsorted_last_hinted(kind, v, x, hint, order) end """ - search_first(kind::StrategyKind, v, x[, hint]; order = Base.Order.Forward) - search_first(s, v, x[, hint]; order = Base.Order.Forward) + searchsorted_first(kind::StrategyKind, v, x[, hint]; order = Base.Order.Forward) + searchsorted_first(s, v, x[, hint]; order = Base.Order.Forward) FFF-owned positional search for the smallest index `i` with `v[i] ≥ x` under `order` (or `v[i] ≤ x` under `Base.Order.Reverse`). See -[`search_last`](@ref) for the dispatch story. +[`searchsorted_last`](@ref) for the dispatch story. """ -@inline function search_first( +@inline function searchsorted_first( kind::StrategyKind, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return _search_first_nohint(kind, v, x, order) + return _searchsorted_first_nohint(kind, v, x, order) end -@inline function search_first( +@inline function searchsorted_first( kind::StrategyKind, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - return _search_first_hinted(kind, v, x, hint, order) + return _searchsorted_first_hinted(kind, v, x, hint, order) end # --------------------------------------------------------------------------- @@ -116,7 +116,7 @@ end # discard the hint. # --------------------------------------------------------------------------- -@inline function _search_last_hinted( +@inline function _searchsorted_last_hinted( kind::StrategyKind, v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, ) @@ -143,7 +143,7 @@ end end end -@inline function _search_last_nohint( +@inline function _searchsorted_last_nohint( kind::StrategyKind, v::AbstractVector, x, order::Base.Order.Ordering, ) @@ -169,7 +169,7 @@ end end end -@inline function _search_first_hinted( +@inline function _searchsorted_first_hinted( kind::StrategyKind, v::AbstractVector, x, hint::Integer, order::Base.Order.Ordering, ) @@ -194,7 +194,7 @@ end end end -@inline function _search_first_nohint( +@inline function _searchsorted_first_nohint( kind::StrategyKind, v::AbstractVector, x, order::Base.Order.Ordering, ) @@ -221,7 +221,7 @@ end # --------------------------------------------------------------------------- # Per-strategy kind lookup. Methods live in `strategy_kind.jl`; used by -# the struct-valued `search_last` / `search_first` entry points and by +# the struct-valued `searchsorted_last` / `searchsorted_first` entry points and by # callers that need to convert a strategy struct to its enum tag. # --------------------------------------------------------------------------- diff --git a/src/precompile.jl b/src/precompile.jl index 2850b56..1252f85 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -31,15 +31,15 @@ using PrecompileTools: @compile_workload, @setup_workload Auto(SearchProperties(linear_vec)), Auto(linear_vec), ) - search_first(strategy, vec_int64, Int64(8), Int64(1)) - search_last(strategy, vec_int64, Int64(8), Int64(1)) + searchsorted_first(strategy, vec_int64, Int64(8), Int64(1)) + searchsorted_last(strategy, vec_int64, Int64(8), Int64(1)) end # Auto-with-uniform-range — exercises the props-aware UniformStep kernel. let r = 0.0:0.5:10.0 auto_r = Auto(r) - search_last(auto_r, r, 3.7) - search_first(auto_r, r, 3.7) - search_last(auto_r, r, 3.7, 1) + searchsorted_last(auto_r, r, 3.7) + searchsorted_first(auto_r, r, 3.7) + searchsorted_last(auto_r, r, 3.7, 1) end # findequal: generic + BisectThenSIMD shortcut for Int64 dense vectors. for strategy in ( @@ -51,11 +51,11 @@ using PrecompileTools: @compile_workload, @setup_workload end # SIMDLinearScan's Float64 specialization. let vec_f64 = collect(1.0:1.0:16.0) - search_first(SIMDLinearScan(), vec_f64, 8.0, 1) - search_last(SIMDLinearScan(), vec_f64, 8.0, 1) + searchsorted_first(SIMDLinearScan(), vec_f64, 8.0, 1) + searchsorted_last(SIMDLinearScan(), vec_f64, 8.0, 1) end - search_first(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) - search_last(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) + searchsorted_first(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) + searchsorted_last(GuesserHint(Guesser(vec_int64)), vec_int64, Int64(8)) # Strategy dispatch — batched in-place forms. idx_out = Vector{Int}(undef, 4) diff --git a/src/strategies.jl b/src/strategies.jl index 498d3cc..7b0c417 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -1,6 +1,6 @@ # Sorted-search strategy type hierarchy. The singleton strategy structs # (`LinearScan`, `BracketGallop`, …) are friendly names for the -# `StrategyKind` tags — passing one to `search_last` / `search_first` +# `StrategyKind` tags — passing one to `searchsorted_last` / `searchsorted_first` # forwards through `strategy_kind` and constant-folds (see # `strategy_kind.jl`). The stateful strategies — `Auto` and # `GuesserHint` — stay on the multimethod path because they carry @@ -17,11 +17,11 @@ concrete subtype: `BitInterpolationSearch`, `BinaryBracket`, `UniformStep`, `BisectThenSIMD`) are zero-field structs. Each one has a matching `StrategyKind` enum value, and the v3 preferred entry point is - [`search_last`](@ref) / [`search_first`](@ref) with that enum tag. - The struct itself can also be passed to `search_last` / - `search_first` directly; it forwards through [`strategy_kind`](@ref). + [`searchsorted_last`](@ref) / [`searchsorted_first`](@ref) with that enum tag. + The struct itself can also be passed to `searchsorted_last` / + `searchsorted_first` directly; it forwards through [`strategy_kind`](@ref). - **Stateful strategies** (`Auto`, `GuesserHint`) carry per-instance - data. They dispatch via their own `search_last` / `search_first` + data. They dispatch via their own `searchsorted_last` / `searchsorted_first` multimethods. Strategies can also be passed to the batched @@ -164,7 +164,7 @@ strategy dispatch hierarchy. **Stateful strategy.** `GuesserHint` carries the `Guesser` (which carries `idx_prev::Ref{Int}` and `linear_lookup::Bool`), so it cannot be reduced to a `StrategyKind` tag. It dispatches via its own -`search_last(::GuesserHint, ...)` / `search_first(::GuesserHint, ...)` +`searchsorted_last(::GuesserHint, ...)` / `searchsorted_first(::GuesserHint, ...)` methods. Use this strategy with the per-query and batched APIs whenever you have a @@ -238,7 +238,7 @@ Stateful strategy that resolves to a concrete [`StrategyKind`](@ref) at construction time. The resolution uses static information available at construction: `props` (if supplied) plus `v` (if supplied). -**Per-query** (`search_last(Auto(), v, x[, hint])`): forwards directly to the +**Per-query** (`searchsorted_last(Auto(), v, x[, hint])`): forwards directly to the stored kind. `Auto()` defaults to `KIND_BINARY_BRACKET` (safe choice when nothing is known about `v`); `Auto(v)` resolves to a faster kind based on `length(v)`, `props.is_uniform`, etc. Callers that want the @@ -259,7 +259,7 @@ mutates. # Fields - `kind::StrategyKind` — resolved kind. Use this field directly in - hot loops via `search_last(auto.kind, v, x, hint)` to skip the + hot loops via `searchsorted_last(auto.kind, v, x, hint)` to skip the `auto.props` field load entirely. - `props::SearchProperties` — cached properties used by the batched decision tree. diff --git a/src/strategy_kind.jl b/src/strategy_kind.jl index d236c07..7da8123 100644 --- a/src/strategy_kind.jl +++ b/src/strategy_kind.jl @@ -1,12 +1,12 @@ # Strategy structs as values: the `strategy_kind` mapping from a strategy -# struct to its `StrategyKind` tag, plus `search_last` / `search_first` +# struct to its `StrategyKind` tag, plus `searchsorted_last` / `searchsorted_first` # methods that accept a singleton strategy struct directly and forward # through the mapping. For a literal strategy argument -# (`search_last(BracketGallop(), v, x, hint)`) the mapping constant-folds, +# (`searchsorted_last(BracketGallop(), v, x, hint)`) the mapping constant-folds, # so the struct form costs nothing over the kind form. # # v3 does not extend `Base.searchsortedlast` / `Base.searchsortedfirst` -# with strategy methods — `search_last` / `search_first` are the only +# with strategy methods — `searchsorted_last` / `searchsorted_first` are the only # search entry points. # `strategy_kind(s)` — the public mapping from strategy struct → tag. @@ -29,21 +29,21 @@ strategy_kind(::GuesserHint) = throw( ) # Struct-valued entry points. `Auto` and `GuesserHint` define their own, -# more specific `search_last` / `search_first` methods (in `auto.jl` and +# more specific `searchsorted_last` / `searchsorted_first` methods (in `auto.jl` and # `guesser.jl`), so this fallback only ever sees the zero-state singletons. -@inline search_last( +@inline searchsorted_last( s::SearchStrategy, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(strategy_kind(s), v, x; order = order) -@inline search_first( +) = searchsorted_last(strategy_kind(s), v, x; order = order) +@inline searchsorted_first( s::SearchStrategy, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(strategy_kind(s), v, x; order = order) -@inline search_last( +) = searchsorted_first(strategy_kind(s), v, x; order = order) +@inline searchsorted_last( s::SearchStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, -) = search_last(strategy_kind(s), v, x, hint; order = order) -@inline search_first( +) = searchsorted_last(strategy_kind(s), v, x, hint; order = order) +@inline searchsorted_first( s::SearchStrategy, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, -) = search_first(strategy_kind(s), v, x, hint; order = order) +) = searchsorted_first(strategy_kind(s), v, x, hint; order = order) diff --git a/test/qa/qa_tests.jl b/test/qa/qa_tests.jl index ea2e8a5..6261514 100644 --- a/test/qa/qa_tests.jl +++ b/test/qa/qa_tests.jl @@ -44,25 +44,25 @@ end rep = JET.report_call(guesser, (Float64,)) @test length(JET.get_reports(rep)) == 0 - # search_last with each StrategyKind - the v3 enum-dispatch hot path. + # searchsorted_last with each StrategyKind - the v3 enum-dispatch hot path. for kind in ( KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, ) rep = JET.report_call( - (k, v, x, h) -> FindFirstFunctions.search_last(k, v, x, h), + (k, v, x, h) -> FindFirstFunctions.searchsorted_last(k, v, x, h), (typeof(kind), Vector{Int64}, Int64, Int64), ) @test length(JET.get_reports(rep)) == 0 end - # search_first with each StrategyKind + # searchsorted_first with each StrategyKind for kind in ( KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, ) rep = JET.report_call( - (k, v, x, h) -> FindFirstFunctions.search_first(k, v, x, h), + (k, v, x, h) -> FindFirstFunctions.searchsorted_first(k, v, x, h), (typeof(kind), Vector{Int64}, Int64, Int64), ) @test length(JET.get_reports(rep)) == 0 @@ -125,19 +125,19 @@ end @test isempty(allocs) end - @testset "search_last via enum tag" begin + @testset "searchsorted_last via enum tag" begin # Each kind on Int64 / Float64 dense vectors: no allocations. for kind in ( KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, ) allocs = check_allocs( - (k, v, x, h) -> FindFirstFunctions.search_last(k, v, x, h), + (k, v, x, h) -> FindFirstFunctions.searchsorted_last(k, v, x, h), (typeof(kind), Vector{Int64}, Int64, Int64), ) @test isempty(allocs) allocs = check_allocs( - (k, v, x, h) -> FindFirstFunctions.search_first(k, v, x, h), + (k, v, x, h) -> FindFirstFunctions.searchsorted_first(k, v, x, h), (typeof(kind), Vector{Int64}, Int64, Int64), ) @test isempty(allocs) diff --git a/test/runtests.jl b/test/runtests.jl index 3001287..47ad719 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -37,16 +37,16 @@ end end @safetestset "Guesser" begin - using FindFirstFunctions: Guesser, GuesserHint, search_last, search_first + using FindFirstFunctions: Guesser, GuesserHint, searchsorted_last, searchsorted_first v = collect(LinRange(0, 10, 4)) guesser_linear = Guesser(v) guesser_prev = Guesser(v, Ref(1), false) @test guesser_linear.linear_lookup # Guesser feeds the dispatched API via GuesserHint. - @test search_first(GuesserHint(guesser_linear), v, 4.0) == 3 - @test search_first(GuesserHint(guesser_linear), v, 1.4234326478e24) == 5 - @test search_last(GuesserHint(guesser_prev), v, 4.0) == 2 + @test searchsorted_first(GuesserHint(guesser_linear), v, 4.0) == 3 + @test searchsorted_first(GuesserHint(guesser_linear), v, 1.4234326478e24) == 5 + @test searchsorted_last(GuesserHint(guesser_prev), v, 4.0) == 2 @test guesser_prev.idx_prev[] == 2 # Edge case: single-element v. @@ -56,12 +56,12 @@ end @test guesser(100) == 1 @test guesser(42.0) == 1 @test guesser(0) == 1 - @test search_first(GuesserHint(guesser), v1, 0) == 1 - @test search_first(GuesserHint(guesser), v1, 100) == 2 # see Base.searchsortedfirst - @test search_first(GuesserHint(guesser), v1, 42.0) == 1 - @test search_last(GuesserHint(guesser), v1, 0) == 0 # see Base.searchsortedlast - @test search_last(GuesserHint(guesser), v1, 100) == 1 - @test search_last(GuesserHint(guesser), v1, 42.0) == 1 + @test searchsorted_first(GuesserHint(guesser), v1, 0) == 1 + @test searchsorted_first(GuesserHint(guesser), v1, 100) == 2 # see Base.searchsortedfirst + @test searchsorted_first(GuesserHint(guesser), v1, 42.0) == 1 + @test searchsorted_last(GuesserHint(guesser), v1, 0) == 0 # see Base.searchsortedlast + @test searchsorted_last(GuesserHint(guesser), v1, 100) == 1 + @test searchsorted_last(GuesserHint(guesser), v1, 42.0) == 1 end @safetestset "Native reverse-order paths (issue #67)" begin @@ -90,9 +90,9 @@ end InterpolationSearch(), BitInterpolationSearch(), Auto(), ) - @test search_last(strategy, v, x, hint; order = order) == + @test searchsorted_last(strategy, v, x, hint; order = order) == expected_last - @test search_first(strategy, v, x, hint; order = order) == + @test searchsorted_first(strategy, v, x, hint; order = order) == expected_first end end @@ -110,9 +110,9 @@ end BracketGallop(), ExpFromLeft(), InterpolationSearch(), Auto(), ) - @test search_last(strategy, v, x, hint; order = order) == + @test searchsorted_last(strategy, v, x, hint; order = order) == expected_last - @test search_first(strategy, v, x, hint; order = order) == + @test searchsorted_first(strategy, v, x, hint; order = order) == expected_first end end @@ -125,17 +125,17 @@ end x = exp(rand(rng) * log(1.0e6)) expected_last = searchsortedlast(v, x, order) expected_first = searchsortedfirst(v, x, order) - @test search_last( + @test searchsorted_last( BitInterpolationSearch(), v, x; order = order ) == expected_last - @test search_first( + @test searchsorted_first( BitInterpolationSearch(), v, x; order = order ) == expected_first end # Non-positive endpoint falls back to BinaryBracket cleanly. v_signed = sort!(randn(rng, 100); rev = true) for x in (-0.5, 0.0, 0.5, -2.0, 2.0) - @test search_last( + @test searchsorted_last( BitInterpolationSearch(), v_signed, x; order = order ) == searchsortedlast(v_signed, x, order) end @@ -149,13 +149,13 @@ end SIMDLinearScan(), ExpFromLeft(), InterpolationSearch(), BitInterpolationSearch(), ) - @test search_last(strategy, v, 100.0, 5; order = order) == + @test searchsorted_last(strategy, v, 100.0, 5; order = order) == searchsortedlast(v, 100.0, order) - @test search_last(strategy, v, -100.0, 5; order = order) == + @test searchsorted_last(strategy, v, -100.0, 5; order = order) == searchsortedlast(v, -100.0, order) - @test search_first(strategy, v, 100.0, 5; order = order) == + @test searchsorted_first(strategy, v, 100.0, 5; order = order) == searchsortedfirst(v, 100.0, order) - @test search_first(strategy, v, -100.0, 5; order = order) == + @test searchsorted_first(strategy, v, -100.0, 5; order = order) == searchsortedfirst(v, -100.0, order) end end @@ -186,18 +186,18 @@ end first(r) + (last(r) - first(r)) / 2, last(r), last(r) + 1, 0, ) - @test search_last(UniformStep(), r, x) == + @test searchsorted_last(UniformStep(), r, x) == searchsortedlast(r, x) - @test search_first(UniformStep(), r, x) == + @test searchsorted_first(UniformStep(), r, x) == searchsortedfirst(r, x) - @test search_last(Auto(), r, x) == searchsortedlast(r, x) - @test search_first(Auto(), r, x) == searchsortedfirst(r, x) + @test searchsorted_last(Auto(), r, x) == searchsortedlast(r, x) + @test searchsorted_first(Auto(), r, x) == searchsortedfirst(r, x) h = max(1, length(r) ÷ 2) - @test search_last(UniformStep(), r, x, h) == + @test searchsorted_last(UniformStep(), r, x, h) == searchsortedlast(r, x) - @test search_first(UniformStep(), r, x, h) == + @test searchsorted_first(UniformStep(), r, x, h) == searchsortedfirst(r, x) - @test search_last(Auto(), r, x, h) == searchsortedlast(r, x) + @test searchsorted_last(Auto(), r, x, h) == searchsortedlast(r, x) end end @@ -208,35 +208,35 @@ end (first(r) + last(r)) / 2, last(r), last(r) - 1, 0, ) - @test search_last(UniformStep(), r, x; order = order) == + @test searchsorted_last(UniformStep(), r, x; order = order) == searchsortedlast(r, x, order) - @test search_first(UniformStep(), r, x; order = order) == + @test searchsorted_first(UniformStep(), r, x; order = order) == searchsortedfirst(r, x, order) - @test search_last(Auto(), r, x; order = order) == + @test searchsorted_last(Auto(), r, x; order = order) == searchsortedlast(r, x, order) - @test search_first(Auto(), r, x; order = order) == + @test searchsorted_first(Auto(), r, x; order = order) == searchsortedfirst(r, x, order) end end # Edge cases. r_empty = 1:0 - @test search_last(UniformStep(), r_empty, 5) == 0 - @test search_first(UniformStep(), r_empty, 5) == 1 - @test search_last(Auto(), r_empty, 5) == 0 + @test searchsorted_last(UniformStep(), r_empty, 5) == 0 + @test searchsorted_first(UniformStep(), r_empty, 5) == 1 + @test searchsorted_last(Auto(), r_empty, 5) == 0 r_single = 42:42 - @test search_last(UniformStep(), r_single, 41) == 0 - @test search_last(UniformStep(), r_single, 42) == 1 - @test search_last(UniformStep(), r_single, 43) == 1 - @test search_first(UniformStep(), r_single, 41) == 1 - @test search_first(UniformStep(), r_single, 42) == 1 - @test search_first(UniformStep(), r_single, 43) == 2 + @test searchsorted_last(UniformStep(), r_single, 41) == 0 + @test searchsorted_last(UniformStep(), r_single, 42) == 1 + @test searchsorted_last(UniformStep(), r_single, 43) == 1 + @test searchsorted_first(UniformStep(), r_single, 41) == 1 + @test searchsorted_first(UniformStep(), r_single, 42) == 1 + @test searchsorted_first(UniformStep(), r_single, 43) == 2 # Non-Range vector falls back to BinaryBracket. v = collect(0.0:0.1:10.0) for x in (-1.0, 0.0, 5.5, 10.0, 100.0) - @test search_last(UniformStep(), v, x) == searchsortedlast(v, x) - @test search_first(UniformStep(), v, x) == searchsortedfirst(v, x) + @test searchsorted_last(UniformStep(), v, x) == searchsortedlast(v, x) + @test searchsorted_first(UniformStep(), v, x) == searchsortedfirst(v, x) end # Auto() answers match Base's range-aware overload and the call @@ -245,13 +245,13 @@ end # bytes for the 32-byte `Auto{Float64}` argument (1.11 elides # the allocation entirely) — hence the `<= 64` bound below. r_big = 0.0:0.001:100.0 - @test search_last(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) - @test search_first(Auto(), r_big, 50.5) == + @test searchsorted_last(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) + @test searchsorted_first(Auto(), r_big, 50.5) == searchsortedfirst(r_big, 50.5) # Warm up once, then verify allocation is tiny (kwarg trampoline only). - search_last(Auto(), r_big, 50.5) - @test @allocated(search_last(Auto(), r_big, 50.5)) <= 64 - @test @allocated(search_first(Auto(), r_big, 50.5)) <= 64 + searchsorted_last(Auto(), r_big, 50.5) + @test @allocated(searchsorted_last(Auto(), r_big, 50.5)) <= 64 + @test @allocated(searchsorted_first(Auto(), r_big, 50.5)) <= 64 # Batched path on AbstractRange. r = 0.0:0.5:100.0 @@ -270,7 +270,7 @@ end @safetestset "Custom ordering for strategy dispatch" begin using FindFirstFunctions: Guesser, GuesserHint, BracketGallop, LinearScan, - ExpFromLeft, BinaryBracket, Auto, search_last, search_first + ExpFromLeft, BinaryBracket, Auto, searchsorted_last, searchsorted_first v_rev = collect(10.0:-1.0:1.0) for x in (5.0, 10.0, 1.0, 0.0, 11.0), @@ -279,38 +279,38 @@ end BracketGallop(), LinearScan(), ExpFromLeft(), Auto(), ) - @test search_first(strategy, v_rev, x, hint; order = Base.Order.Reverse) == + @test searchsorted_first(strategy, v_rev, x, hint; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test search_last(strategy, v_rev, x, hint; order = Base.Order.Reverse) == + @test searchsorted_last(strategy, v_rev, x, hint; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) end # BinaryBracket ignores any hint. for x in (5.0, 10.0, 1.0, 0.0, 11.0) - @test search_first(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == + @test searchsorted_first(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test search_last(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == + @test searchsorted_last(BinaryBracket(), v_rev, x; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) end # GuesserHint with reverse order. guesser_rev = Guesser(v_rev) - @test search_first(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == + @test searchsorted_first(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == searchsortedfirst(v_rev, 5.0, Base.Order.Reverse) - @test search_last(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == + @test searchsorted_last(GuesserHint(guesser_rev), v_rev, 5.0; order = Base.Order.Reverse) == searchsortedlast(v_rev, 5.0, Base.Order.Reverse) # Default (Forward) order still resolves correctly. v_fwd = collect(1.0:1.0:10.0) for strategy in (BracketGallop(), LinearScan(), ExpFromLeft(), Auto()) - @test search_first(strategy, v_fwd, 5.0, 1) == searchsortedfirst(v_fwd, 5.0) - @test search_last(strategy, v_fwd, 5.0, 1) == searchsortedlast(v_fwd, 5.0) + @test searchsorted_first(strategy, v_fwd, 5.0, 1) == searchsortedfirst(v_fwd, 5.0) + @test searchsorted_last(strategy, v_fwd, 5.0, 1) == searchsortedlast(v_fwd, 5.0) end end @safetestset "SearchStrategy dispatch (single query)" begin using FindFirstFunctions: SearchStrategy, LinearScan, BracketGallop, BinaryBracket, Auto, - search_last, search_first + searchsorted_last, searchsorted_first for n in (0, 1, 2, 8, 33, 257) v = collect(1:n) @@ -323,51 +323,51 @@ end expected_first = searchsortedfirst(v, x) # BinaryBracket — ignores any hint - @test search_last(BinaryBracket(), v, x) == expected_last - @test search_first(BinaryBracket(), v, x) == expected_first - @test search_last(BinaryBracket(), v, x, 1) == expected_last - @test search_first(BinaryBracket(), v, x, 1) == expected_first + @test searchsorted_last(BinaryBracket(), v, x) == expected_last + @test searchsorted_first(BinaryBracket(), v, x) == expected_first + @test searchsorted_last(BinaryBracket(), v, x, 1) == expected_last + @test searchsorted_first(BinaryBracket(), v, x, 1) == expected_first # Strategy with hint anywhere in 1..n agrees with Base for h in unique!([1, max(1, n ÷ 4), n ÷ 2, max(1, 3n ÷ 4), n]) - @test search_last(LinearScan(), v, x, h) == expected_last - @test search_first(LinearScan(), v, x, h) == expected_first - @test search_last(BracketGallop(), v, x, h) == expected_last - @test search_first(BracketGallop(), v, x, h) == expected_first - @test search_last(Auto(), v, x, h) == expected_last - @test search_first(Auto(), v, x, h) == expected_first + @test searchsorted_last(LinearScan(), v, x, h) == expected_last + @test searchsorted_first(LinearScan(), v, x, h) == expected_first + @test searchsorted_last(BracketGallop(), v, x, h) == expected_last + @test searchsorted_first(BracketGallop(), v, x, h) == expected_first + @test searchsorted_last(Auto(), v, x, h) == expected_last + @test searchsorted_first(Auto(), v, x, h) == expected_first end # No-hint forms fall back to BinaryBracket - @test search_last(LinearScan(), v, x) == expected_last - @test search_first(LinearScan(), v, x) == expected_first - @test search_last(BracketGallop(), v, x) == expected_last - @test search_first(BracketGallop(), v, x) == expected_first - @test search_last(Auto(), v, x) == expected_last - @test search_first(Auto(), v, x) == expected_first + @test searchsorted_last(LinearScan(), v, x) == expected_last + @test searchsorted_first(LinearScan(), v, x) == expected_first + @test searchsorted_last(BracketGallop(), v, x) == expected_last + @test searchsorted_first(BracketGallop(), v, x) == expected_first + @test searchsorted_last(Auto(), v, x) == expected_last + @test searchsorted_first(Auto(), v, x) == expected_first # Out-of-range hint → Auto falls back to BinaryBracket - @test search_last(Auto(), v, x, 0) == expected_last - @test search_first(Auto(), v, x, 0) == expected_first - @test search_last(Auto(), v, x, n + 1) == expected_last - @test search_first(Auto(), v, x, n + 1) == expected_first + @test searchsorted_last(Auto(), v, x, 0) == expected_last + @test searchsorted_first(Auto(), v, x, 0) == expected_first + @test searchsorted_last(Auto(), v, x, n + 1) == expected_last + @test searchsorted_first(Auto(), v, x, n + 1) == expected_first end end # Reverse order v_rev = collect(10.0:-1.0:1.0) for x in (0.5, 1.0, 5.0, 10.0, 11.0), h in (1, 5, 10) - @test search_last(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == + @test searchsorted_last(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test search_first(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == + @test searchsorted_first(BracketGallop(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test search_last(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == + @test searchsorted_last(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test search_first(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == + @test searchsorted_first(LinearScan(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) - @test search_last(Auto(), v_rev, x, h; order = Base.Order.Reverse) == + @test searchsorted_last(Auto(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test search_first(Auto(), v_rev, x, h; order = Base.Order.Reverse) == + @test searchsorted_first(Auto(), v_rev, x, h; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) end @@ -381,69 +381,69 @@ end @safetestset "ExpFromLeft and InterpolationSearch" begin using FindFirstFunctions: ExpFromLeft, InterpolationSearch, BinaryBracket, - search_last, search_first + searchsorted_last, searchsorted_first # ExpFromLeft on uniform Int range v = collect(1:1000) for x in (0, 1, 50, 250, 500, 999, 1000, 1001), h in (1, 50, 500, 1000) - @test search_last(ExpFromLeft(), v, x, h) == + @test searchsorted_last(ExpFromLeft(), v, x, h) == searchsortedlast(v, x) - @test search_first(ExpFromLeft(), v, x, h) == + @test searchsorted_first(ExpFromLeft(), v, x, h) == searchsortedfirst(v, x) end # ExpFromLeft without hint falls back to BinaryBracket - @test search_last(ExpFromLeft(), v, 500) == searchsortedlast(v, 500) - @test search_first(ExpFromLeft(), v, 500) == searchsortedfirst(v, 500) + @test searchsorted_last(ExpFromLeft(), v, 500) == searchsortedlast(v, 500) + @test searchsorted_first(ExpFromLeft(), v, 500) == searchsortedfirst(v, 500) # InterpolationSearch on uniform Float64 range vf = collect(0.0:0.1:10.0) for x in (-1.0, 0.0, 0.05, 1.0, 5.5, 9.95, 10.0, 11.0) - @test search_last(InterpolationSearch(), vf, x) == + @test searchsorted_last(InterpolationSearch(), vf, x) == searchsortedlast(vf, x) - @test search_first(InterpolationSearch(), vf, x) == + @test searchsorted_first(InterpolationSearch(), vf, x) == searchsortedfirst(vf, x) end # InterpolationSearch on log-spaced (non-uniform) — must still be correct vlog = exp.(range(log(0.1), log(100.0); length = 256)) for x in (0.05, 0.1, 1.0, 50.0, 100.0, 150.0) - @test search_last(InterpolationSearch(), vlog, x) == + @test searchsorted_last(InterpolationSearch(), vlog, x) == searchsortedlast(vlog, x) - @test search_first(InterpolationSearch(), vlog, x) == + @test searchsorted_first(InterpolationSearch(), vlog, x) == searchsortedfirst(vlog, x) end # InterpolationSearch ignores hint (computes its own guess) for h in (1, 100, 256) - @test search_last(InterpolationSearch(), vlog, 50.0, h) == + @test searchsorted_last(InterpolationSearch(), vlog, 50.0, h) == searchsortedlast(vlog, 50.0) end # InterpolationSearch falls back to BinaryBracket on non-Number eltypes vs = ["a", "b", "c", "d"] - @test search_last(InterpolationSearch(), vs, "c") == + @test searchsorted_last(InterpolationSearch(), vs, "c") == searchsortedlast(vs, "c") - @test search_first(InterpolationSearch(), vs, "c", 2) == + @test searchsorted_first(InterpolationSearch(), vs, "c", 2) == searchsortedfirst(vs, "c") # InterpolationSearch on a constant vector (span=0) shouldn't divide # by zero; should fall through to a bounded search and return a # correct result. vc = fill(3.0, 16) - @test search_last(InterpolationSearch(), vc, 3.0) == + @test searchsorted_last(InterpolationSearch(), vc, 3.0) == searchsortedlast(vc, 3.0) - @test search_last(InterpolationSearch(), vc, 2.0) == + @test searchsorted_last(InterpolationSearch(), vc, 2.0) == searchsortedlast(vc, 2.0) - @test search_last(InterpolationSearch(), vc, 4.0) == + @test searchsorted_last(InterpolationSearch(), vc, 4.0) == searchsortedlast(vc, 4.0) # Edge: 1-element and 2-element vectors - @test search_last(ExpFromLeft(), [5], 4, 1) == 0 - @test search_last(ExpFromLeft(), [5], 5, 1) == 1 - @test search_last(ExpFromLeft(), [5], 6, 1) == 1 - @test search_first(ExpFromLeft(), [5, 10], 7, 1) == 2 - @test search_last(InterpolationSearch(), [5], 4) == 0 - @test search_last(InterpolationSearch(), [5, 10], 7) == 1 + @test searchsorted_last(ExpFromLeft(), [5], 4, 1) == 0 + @test searchsorted_last(ExpFromLeft(), [5], 5, 1) == 1 + @test searchsorted_last(ExpFromLeft(), [5], 6, 1) == 1 + @test searchsorted_first(ExpFromLeft(), [5, 10], 7, 1) == 2 + @test searchsorted_last(InterpolationSearch(), [5], 4) == 0 + @test searchsorted_last(InterpolationSearch(), [5, 10], 7) == 1 end @safetestset "Batched Auto heuristic" begin @@ -662,7 +662,7 @@ end using FindFirstFunctions using FindFirstFunctions: SearchProperties, Auto, KIND_UNIFORM_STEP, - search_last, search_first + searchsorted_last, searchsorted_first # Auto{T} carries SearchProperties{T}. @test Auto() isa Auto{Float64} @@ -674,7 +674,7 @@ end # The props-aware UniformStep path is taken when kind === # KIND_UNIFORM_STEP. For uniform data, Auto resolves to - # KIND_UNIFORM_STEP and `search_last(::Auto, ...)` uses the + # KIND_UNIFORM_STEP and `searchsorted_last(::Auto, ...)` uses the # precomputed first_val + inv_step closed-form path. for r in ( 0.0:0.5:100.0, @@ -691,11 +691,11 @@ end end want_last = searchsortedlast(r, x) want_first = searchsortedfirst(r, x) - @test search_last(a, r, x) == want_last - @test search_first(a, r, x) == want_first + @test searchsorted_last(a, r, x) == want_last + @test searchsorted_first(a, r, x) == want_first # Hinted form takes the same path. - @test search_last(a, r, x, 1) == want_last - @test search_first(a, r, x, 1) == want_first + @test searchsorted_last(a, r, x, 1) == want_last + @test searchsorted_first(a, r, x, 1) == want_first end end @@ -704,9 +704,9 @@ end a_rev = Auto(r_rev) @test a_rev.kind === KIND_UNIFORM_STEP for x in (-1.0, 0.0, 2.7, 5.0, 10.5) - @test search_last(a_rev, r_rev, x; order = Base.Order.Reverse) == + @test searchsorted_last(a_rev, r_rev, x; order = Base.Order.Reverse) == searchsortedlast(r_rev, x, Base.Order.Reverse) - @test search_first(a_rev, r_rev, x; order = Base.Order.Reverse) == + @test searchsorted_first(a_rev, r_rev, x; order = Base.Order.Reverse) == searchsortedfirst(r_rev, x, Base.Order.Reverse) end @@ -716,7 +716,7 @@ end a_p = Auto(p) @test a_p.kind === KIND_UNIFORM_STEP for x in (0.0, 1.7, 5.0, 9.9, 10.0) - @test search_last(a_p, 0.0:0.5:10.0, x) == + @test searchsorted_last(a_p, 0.0:0.5:10.0, x) == searchsortedlast(0.0:0.5:10.0, x) end @@ -728,9 +728,9 @@ end @test a_sent.kind === KIND_UNIFORM_STEP @test !a_sent.props.has_props for x in (-1.0, 0.0, 1.7, 5.0, 10.0, 11.0) - @test search_last(a_sent, 0.0:0.5:10.0, x) == + @test searchsorted_last(a_sent, 0.0:0.5:10.0, x) == searchsortedlast(0.0:0.5:10.0, x) - @test search_first(a_sent, 0.0:0.5:10.0, x) == + @test searchsorted_first(a_sent, 0.0:0.5:10.0, x) == searchsortedfirst(0.0:0.5:10.0, x) end @@ -751,9 +751,9 @@ end a_trick = Auto(p_forced) @test a_trick.kind === KIND_UNIFORM_STEP for x in (54.0, 54.5, 55.1875, 0.5, 101.0, 60.0) - @test search_last(a_trick, v_trick, x) == + @test searchsorted_last(a_trick, v_trick, x) == searchsortedlast(v_trick, x) - @test search_first(a_trick, v_trick, x) == + @test searchsorted_first(a_trick, v_trick, x) == searchsortedfirst(v_trick, x) end @@ -763,8 +763,8 @@ end a_u = Auto(v_u) @test a_u.kind === KIND_UNIFORM_STEP for x in (1.0e300, -1.0e300, floatmax(Float64), -floatmax(Float64)) - @test search_last(a_u, v_u, x) == searchsortedlast(v_u, x) - @test search_first(a_u, v_u, x) == searchsortedfirst(v_u, x) + @test searchsorted_last(a_u, v_u, x) == searchsortedlast(v_u, x) + @test searchsorted_first(a_u, v_u, x) == searchsortedfirst(v_u, x) end # Caller-supplied is_uniform = true on a zero-span vector makes @@ -774,8 +774,8 @@ end p_c = SearchProperties(c; is_uniform = true) a_c = Auto(p_c) for x in (5.0, 4.0, 6.0) - @test search_last(a_c, c, x) == searchsortedlast(c, x) - @test search_first(a_c, c, x) == searchsortedfirst(c, x) + @test searchsorted_last(a_c, c, x) == searchsortedlast(c, x) + @test searchsorted_first(a_c, c, x) == searchsortedfirst(c, x) end # Reverse-ordered uniform Vector through the props kernel. @@ -784,9 +784,9 @@ end @test p_rev.is_uniform a_vrev = Auto(p_rev) for x in (50.5, 1.0, 100.0, 1.0e300, -3.0) - @test search_last(a_vrev, v_rev, x; order = Base.Order.Reverse) == + @test searchsorted_last(a_vrev, v_rev, x; order = Base.Order.Reverse) == searchsortedlast(v_rev, x, Base.Order.Reverse) - @test search_first(a_vrev, v_rev, x; order = Base.Order.Reverse) == + @test searchsorted_first(a_vrev, v_rev, x; order = Base.Order.Reverse) == searchsortedfirst(v_rev, x, Base.Order.Reverse) end end @@ -967,9 +967,9 @@ end v = sort!(rand(rng, Int64(-1000):Int64(1000), n)) x = rand(rng, Int64(-1100):Int64(1100)) hint = rand(rng, 1:n) - @test search_last(F.SIMDLinearScan(), v, x, hint) == + @test searchsorted_last(F.SIMDLinearScan(), v, x, hint) == searchsortedlast(v, x) - @test search_first(F.SIMDLinearScan(), v, x, hint) == + @test searchsorted_first(F.SIMDLinearScan(), v, x, hint) == searchsortedfirst(v, x) end end @@ -981,9 +981,9 @@ end v = sort!(randn(rng, n)) x = (rand(rng) - 0.5) * 6 hint = rand(rng, 1:n) - @test search_last(F.SIMDLinearScan(), v, x, hint) == + @test searchsorted_last(F.SIMDLinearScan(), v, x, hint) == searchsortedlast(v, x) - @test search_first(F.SIMDLinearScan(), v, x, hint) == + @test searchsorted_first(F.SIMDLinearScan(), v, x, hint) == searchsortedfirst(v, x) end end @@ -991,24 +991,24 @@ end @testset "Edge cases (Int64)" begin v = collect(Int64, 1:100) # Out-of-range hint is clamped. - @test search_last(F.SIMDLinearScan(), v, Int64(50), -5) == 50 - @test search_last(F.SIMDLinearScan(), v, Int64(50), 1_000) == 50 + @test searchsorted_last(F.SIMDLinearScan(), v, Int64(50), -5) == 50 + @test searchsorted_last(F.SIMDLinearScan(), v, Int64(50), 1_000) == 50 # x below/above the range. - @test search_last(F.SIMDLinearScan(), v, Int64(-10), 50) == 0 - @test search_last(F.SIMDLinearScan(), v, Int64(1_000), 50) == 100 - @test search_first(F.SIMDLinearScan(), v, Int64(-10), 50) == 1 - @test search_first(F.SIMDLinearScan(), v, Int64(1_000), 50) == 101 + @test searchsorted_last(F.SIMDLinearScan(), v, Int64(-10), 50) == 0 + @test searchsorted_last(F.SIMDLinearScan(), v, Int64(1_000), 50) == 100 + @test searchsorted_first(F.SIMDLinearScan(), v, Int64(-10), 50) == 1 + @test searchsorted_first(F.SIMDLinearScan(), v, Int64(1_000), 50) == 101 # Empty and single-element vectors. vempty = Int64[] - @test search_last(F.SIMDLinearScan(), vempty, Int64(5), 1) == 0 - @test search_first(F.SIMDLinearScan(), vempty, Int64(5), 1) == 1 + @test searchsorted_last(F.SIMDLinearScan(), vempty, Int64(5), 1) == 0 + @test searchsorted_first(F.SIMDLinearScan(), vempty, Int64(5), 1) == 1 v1 = Int64[42] - @test search_last(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 - @test search_first(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 + @test searchsorted_last(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 + @test searchsorted_first(F.SIMDLinearScan(), v1, Int64(42), 1) == 1 # Duplicates. vd = Int64[1, 2, 2, 2, 5] - @test search_last(F.SIMDLinearScan(), vd, Int64(2), 1) == 4 - @test search_first(F.SIMDLinearScan(), vd, Int64(2), 5) == 2 + @test searchsorted_last(F.SIMDLinearScan(), vd, Int64(2), 1) == 4 + @test searchsorted_first(F.SIMDLinearScan(), vd, Int64(2), 5) == 2 end @testset "Fallback: non-Int64/Float64 eltypes" begin @@ -1017,32 +1017,32 @@ end v32 = Int32[1, 5, 10, 20, 50, 100, 200] for x in (Int32(0), Int32(7), Int32(20), Int32(300)) for hint in 1:length(v32) - @test search_last(F.SIMDLinearScan(), v32, x, hint) == + @test searchsorted_last(F.SIMDLinearScan(), v32, x, hint) == searchsortedlast(v32, x) - @test search_first(F.SIMDLinearScan(), v32, x, hint) == + @test searchsorted_first(F.SIMDLinearScan(), v32, x, hint) == searchsortedfirst(v32, x) end end # Float32 same. v32f = Float32[1.0, 5.0, 10.0, 20.0, 50.0] for x in (Float32(0.0), Float32(7.0), Float32(20.0), Float32(100.0)) - @test search_last(F.SIMDLinearScan(), v32f, x, 2) == + @test searchsorted_last(F.SIMDLinearScan(), v32f, x, 2) == searchsortedlast(v32f, x) end # Non-numeric. vs = sort!(["alpha", "beta", "gamma", "delta", "epsilon"]) - @test search_last(F.SIMDLinearScan(), vs, "gamma", 2) == + @test searchsorted_last(F.SIMDLinearScan(), vs, "gamma", 2) == searchsortedlast(vs, "gamma") end @testset "Fallback: no hint, reverse order" begin v = collect(Int64, 1:100) # No hint → BinaryBracket. - @test search_last(F.SIMDLinearScan(), v, Int64(50)) == + @test searchsorted_last(F.SIMDLinearScan(), v, Int64(50)) == searchsortedlast(v, Int64(50)) # Reverse order → scalar LinearScan. v_rev = collect(Int64, 100:-1:1) - @test search_last( + @test searchsorted_last( F.SIMDLinearScan(), v_rev, Int64(50), 1; order = Base.Order.Reverse ) == searchsortedlast(v_rev, Int64(50), Base.Order.Reverse) end @@ -1154,16 +1154,16 @@ end # When used with searchsortedfirst/last, BisectThenSIMD just # delegates to BinaryBracket — its purpose is findequal. v = collect(Int64, 1:100) - @test search_first(F.BisectThenSIMD(), v, Int64(50)) == + @test searchsorted_first(F.BisectThenSIMD(), v, Int64(50)) == searchsortedfirst(v, Int64(50)) - @test search_last(F.BisectThenSIMD(), v, Int64(50)) == + @test searchsorted_last(F.BisectThenSIMD(), v, Int64(50)) == searchsortedlast(v, Int64(50)) end end @safetestset "Enum-tagged dispatch (v3 API)" begin using FindFirstFunctions - using FindFirstFunctions: search_last, search_first, strategy_kind + using FindFirstFunctions: searchsorted_last, searchsorted_first, strategy_kind using StableRNGs # Mapping from struct → kind, and kind enum value coverage. @@ -1179,7 +1179,7 @@ end @test strategy_kind(BisectThenSIMD()) === KIND_BISECT_THEN_SIMD end - @testset "Per-kind search_last parity vs Base" begin + @testset "Per-kind searchsorted_last parity vs Base" begin # Each hint-using kind should match Base when given a valid # in-range hint. Hint-ignoring kinds (BinaryBracket, # InterpolationSearch, UniformStep, BisectThenSIMD) match @@ -1193,14 +1193,14 @@ end KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, ) - @test search_last(kind, v, x, h) == want_last - @test search_first(kind, v, x, h) == want_first + @test searchsorted_last(kind, v, x, h) == want_last + @test searchsorted_first(kind, v, x, h) == want_first end # No-hint forms. - @test search_last(KIND_BINARY_BRACKET, v, x) == want_last - @test search_first(KIND_BINARY_BRACKET, v, x) == want_first - @test search_last(KIND_INTERPOLATION_SEARCH, v, x) == want_last - @test search_first(KIND_INTERPOLATION_SEARCH, v, x) == want_first + @test searchsorted_last(KIND_BINARY_BRACKET, v, x) == want_last + @test searchsorted_first(KIND_BINARY_BRACKET, v, x) == want_first + @test searchsorted_last(KIND_INTERPOLATION_SEARCH, v, x) == want_last + @test searchsorted_first(KIND_INTERPOLATION_SEARCH, v, x) == want_first end end @@ -1217,8 +1217,8 @@ end KIND_SIMD_LINEAR_SCAN, KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, KIND_INTERPOLATION_SEARCH, ) - @test search_last(kind, v, x, h) == want_last - @test search_first(kind, v, x, h) == want_first + @test searchsorted_last(kind, v, x, h) == want_last + @test searchsorted_first(kind, v, x, h) == want_first end end end @@ -1231,17 +1231,17 @@ end h = rand(rng, 1:length(v)) want_last = searchsortedlast(v, x) want_first = searchsortedfirst(v, x) - @test search_last(KIND_SIMD_LINEAR_SCAN, v, x, h) == want_last - @test search_first(KIND_SIMD_LINEAR_SCAN, v, x, h) == want_first + @test searchsorted_last(KIND_SIMD_LINEAR_SCAN, v, x, h) == want_last + @test searchsorted_first(KIND_SIMD_LINEAR_SCAN, v, x, h) == want_first end end @testset "UniformStep kind on AbstractRange" begin for r in (1:100, 0.0:0.1:10.0, LinRange(0.0, 10.0, 101)) for x in (first(r) - 1, first(r), last(r), last(r) + 1) - @test search_last(KIND_UNIFORM_STEP, r, x) == + @test searchsorted_last(KIND_UNIFORM_STEP, r, x) == searchsortedlast(r, x) - @test search_first(KIND_UNIFORM_STEP, r, x) == + @test searchsorted_first(KIND_UNIFORM_STEP, r, x) == searchsortedfirst(r, x) end end @@ -1252,9 +1252,9 @@ end rng = StableRNG(7003) for _ in 1:100 x = exp(rand(rng) * log(1.0e6)) - @test search_last(KIND_BIT_INTERPOLATION_SEARCH, v, x) == + @test searchsorted_last(KIND_BIT_INTERPOLATION_SEARCH, v, x) == searchsortedlast(v, x) - @test search_first(KIND_BIT_INTERPOLATION_SEARCH, v, x) == + @test searchsorted_first(KIND_BIT_INTERPOLATION_SEARCH, v, x) == searchsortedfirst(v, x) end end @@ -1267,9 +1267,9 @@ end KIND_INTERPOLATION_SEARCH, ) for x in (0.5, 1.0, 5.0, 10.0, 11.0), h in (1, 5, 10) - @test search_last(kind, v, x, h; order = Base.Order.Reverse) == + @test searchsorted_last(kind, v, x, h; order = Base.Order.Reverse) == searchsortedlast(v, x, Base.Order.Reverse) - @test search_first(kind, v, x, h; order = Base.Order.Reverse) == + @test searchsorted_first(kind, v, x, h; order = Base.Order.Reverse) == searchsortedfirst(v, x, Base.Order.Reverse) end end @@ -1287,10 +1287,10 @@ end @test isbits(a) @test @inferred(Auto(v)) isa Auto @test @inferred(Auto(SearchProperties(v))) isa Auto - # `search_last(::Auto, ...)` is concretely Int-returning. - @test @inferred(search_last(a, v, 50, 1)) === 50 - @test @inferred(search_first(a, v, 50, 1)) === 50 - @test @inferred(search_last(a, v, 50)) === 50 + # `searchsorted_last(::Auto, ...)` is concretely Int-returning. + @test @inferred(searchsorted_last(a, v, 50, 1)) === 50 + @test @inferred(searchsorted_first(a, v, 50, 1)) === 50 + @test @inferred(searchsorted_last(a, v, 50)) === 50 end @testset "Vector{Auto{T}} has concrete eltype" begin @@ -1313,8 +1313,8 @@ end end @testset "Struct form forwards through strategy_kind" begin - # `search_last(::S, v, x[, hint])` forwards through - # `strategy_kind(S())` to `search_last(KIND_X, ...)`. + # `searchsorted_last(::S, v, x[, hint])` forwards through + # `strategy_kind(S())` to `searchsorted_last(KIND_X, ...)`. # Verify both forms produce identical answers. v = collect(1:1000) rng = StableRNG(7004) @@ -1329,10 +1329,10 @@ end for _ in 1:50 x = rand(rng, 1:1000) h = rand(rng, 1:1000) - @test search_last(s, v, x, h) == - search_last(kind, v, x, h) - @test search_first(s, v, x, h) == - search_first(kind, v, x, h) + @test searchsorted_last(s, v, x, h) == + searchsorted_last(kind, v, x, h) + @test searchsorted_first(s, v, x, h) == + searchsorted_first(kind, v, x, h) end end end @@ -1342,8 +1342,8 @@ end v = collect(LinRange(0, 10, 100)) g = Guesser(v) gh = GuesserHint(g) - @test search_last(gh, v, 4.0) == searchsortedlast(v, 4.0) - @test search_first(gh, v, 4.0) == searchsortedfirst(v, 4.0) + @test searchsorted_last(gh, v, 4.0) == searchsortedlast(v, 4.0) + @test searchsorted_first(gh, v, 4.0) == searchsortedfirst(v, 4.0) # strategy_kind on a GuesserHint must error — it has no tag. @test_throws ArgumentError strategy_kind(gh) end From 02aa7805a7fcaca032a5f91ade68d7df623bb6fb Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Sat, 13 Jun 2026 04:03:31 -0400 Subject: [PATCH 13/14] Document the sorted precondition on searchsorted_last/searchsorted_first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State explicitly that v must be sorted ascending under order (assumed, not checked, as with Base.searchsortedlast) — the summary previously only implied it via the polarity reference. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- src/kinds.jl | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/kinds.jl b/src/kinds.jl index 810c3bd..55a9753 100644 --- a/src/kinds.jl +++ b/src/kinds.jl @@ -57,6 +57,10 @@ FFF-owned positional search for the largest index `i` with `v[i] ≤ x` under `order` (or `v[i] ≥ x` under `Base.Order.Reverse`). The polarity matches `Base.searchsortedlast`. +`v` must be sorted in ascending order under `order`. Like +`Base.searchsortedlast`, the precondition is assumed, not checked — the +result on unsorted `v` is undefined. + When the first argument is a [`StrategyKind`](@ref) value the call dispatches via a runtime `if/elseif` branch on the enum into the matching kernel. When the first argument is a stateful strategy wrapper (`Auto`, @@ -86,8 +90,10 @@ end searchsorted_first(s, v, x[, hint]; order = Base.Order.Forward) FFF-owned positional search for the smallest index `i` with `v[i] ≥ x` -under `order` (or `v[i] ≤ x` under `Base.Order.Reverse`). See -[`searchsorted_last`](@ref) for the dispatch story. +under `order` (or `v[i] ≤ x` under `Base.Order.Reverse`). As with +[`searchsorted_last`](@ref), `v` must be sorted in ascending order under +`order` (assumed, not checked). See [`searchsorted_last`](@ref) for the +dispatch story. """ @inline function searchsorted_first( kind::StrategyKind, v::AbstractVector, x; From 966dd35485917c41c61e3f8dfd4165e0273bbbe9 Mon Sep 17 00:00:00 2001 From: "Chris Rackauckas (Claude)" Date: Sat, 13 Jun 2026 05:10:07 -0400 Subject: [PATCH 14/14] =?UTF-8?q?Docs:=20drop=20'v3=20preferred'=20framing?= =?UTF-8?q?=20=E2=80=94=20the=20v2=20path=20was=20removed,=20not=20depreca?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Preferred' implied a lesser-but-available alternative (the v2 Base.searchsortedlast strategy methods), but those are removed. Reframe the strategies overview and the SearchStrategy docstring so the enum tag and the strategy struct read as two ways to call the single search API, not as 'preferred vs legacy'. Drop the stale '(v3 preferred path)' export comment. Co-Authored-By: Claude Fable 5 Co-Authored-By: Chris Rackauckas --- docs/src/strategies.md | 32 +++++++++++++++++--------------- src/FindFirstFunctions.jl | 2 +- src/strategies.jl | 8 ++++---- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/docs/src/strategies.md b/docs/src/strategies.md index 9efbc86..d3a9f3a 100644 --- a/docs/src/strategies.md +++ b/docs/src/strategies.md @@ -1,24 +1,26 @@ # Search strategies -The strategies form the parameter space of the sorted-search API. Two -ways to select a strategy: - - - **v3 preferred:** pass a [`StrategyKind`](@ref FindFirstFunctions.StrategyKind) - enum value (e.g. `KIND_BRACKET_GALLOP`) as the first argument to - [`searchsorted_last`](@ref FindFirstFunctions.searchsorted_last) / - [`searchsorted_first`](@ref FindFirstFunctions.searchsorted_first). One enum - value per singleton strategy; runtime `if/elseif` dispatch into the - matching kernel; ~0 ns of overhead in hot loops; the inferred return - type is concrete regardless of which kind is picked at runtime. - - **Struct form:** pass a singleton strategy struct (e.g. - `BracketGallop()`) to the same `searchsorted_last` / `searchsorted_first`. The +The strategies form the parameter space of the sorted-search API. The +entry points are [`searchsorted_last`](@ref FindFirstFunctions.searchsorted_last) / +[`searchsorted_first`](@ref FindFirstFunctions.searchsorted_first), and a +strategy can be passed to them two ways: + + - **As a [`StrategyKind`](@ref FindFirstFunctions.StrategyKind) enum + value** (e.g. `KIND_BRACKET_GALLOP`). One enum value per singleton + strategy; runtime `if/elseif` dispatch into the matching kernel; ~0 ns + of overhead in hot loops; the inferred return type is concrete + regardless of which kind is picked at runtime. Use this when the + strategy is chosen from runtime data. + - **As a singleton strategy struct** (e.g. `BracketGallop()`). The struct forwards through [`strategy_kind`](@ref FindFirstFunctions.strategy_kind), which - constant-folds for a literal strategy argument — the struct form - costs nothing over the kind form. + constant-folds for a literal strategy argument — so for a literal + strategy the struct form compiles to exactly the same code as the enum + form. FindFirstFunctions does **not** extend `Base.searchsortedlast` / -`Base.searchsortedfirst` (the v2 API; removed in v3). +`Base.searchsortedfirst`. The v2 API that did is removed in v3; these +FFF-owned functions are the only search entry points. The stateful strategies — [`Auto`](@ref FindFirstFunctions.Auto) and [`GuesserHint`](@ref FindFirstFunctions.GuesserHint) — carry per-instance diff --git a/src/FindFirstFunctions.jl b/src/FindFirstFunctions.jl index fbf1f0b..ec478d3 100644 --- a/src/FindFirstFunctions.jl +++ b/src/FindFirstFunctions.jl @@ -21,7 +21,7 @@ export # Properties / helpers. SearchProperties, Guesser, looks_linear, - # Enum + dispatchers (v3 preferred path). + # Enum + dispatchers. StrategyKind, KIND_BINARY_BRACKET, KIND_LINEAR_SCAN, KIND_SIMD_LINEAR_SCAN, KIND_BRACKET_GALLOP, KIND_EXP_FROM_LEFT, diff --git a/src/strategies.jl b/src/strategies.jl index 7b0c417..85ed8ac 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -16,10 +16,10 @@ concrete subtype: `BracketGallop`, `ExpFromLeft`, `InterpolationSearch`, `BitInterpolationSearch`, `BinaryBracket`, `UniformStep`, `BisectThenSIMD`) are zero-field structs. Each one has a matching - `StrategyKind` enum value, and the v3 preferred entry point is - [`searchsorted_last`](@ref) / [`searchsorted_first`](@ref) with that enum tag. - The struct itself can also be passed to `searchsorted_last` / - `searchsorted_first` directly; it forwards through [`strategy_kind`](@ref). + `StrategyKind` enum value; pass either the struct or its enum tag to + [`searchsorted_last`](@ref) / [`searchsorted_first`](@ref). The struct + form forwards through [`strategy_kind`](@ref) and constant-folds for a + literal strategy, so it compiles to the same code as the enum tag. - **Stateful strategies** (`Auto`, `GuesserHint`) carry per-instance data. They dispatch via their own `searchsorted_last` / `searchsorted_first` multimethods.