Skip to content

Add LinearBinarySearch{MAX}: bounded linear walk + binary fallback - #72

Draft
ChrisRackauckas-Claude wants to merge 2 commits into
SciML:mainfrom
ChrisRackauckas-Claude:linearbinary-search
Draft

ChrisRackauckas-Claude wants to merge 2 commits into
SciML:mainfrom
ChrisRackauckas-Claude:linearbinary-search

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented May 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds LinearBinarySearch{MAX} <: SearchStrategy to FFF: walk linearly from the hint for up to MAX steps, fall back to a binary search if the answer isn't bracketed within the window. Mirrors the strategy of the same name in FastInterpolations.jl; FFF previously had ExpFromLeft (forward exponential doubling) and BracketGallop (bidirectional doubling) but no bounded-linear-with-binary-fallback option.

The intended workload is small-gap ODE-style monotone-forward sweeps where the exponential-doubling overhead of ExpFromLeft/BracketGallop is pure cost and a tight unrolled linear walk is fastest.

Design choices

  • Default MAX = 8, matching FastInterpolations.jl. Allowed values are {0, 1, 2, 4, 8, 16, 32, 64, 128} via a factory constructor — curated to keep the per-MAX method specialization table bounded. Arbitrary integers via the parametric form LinearBinarySearch{k}() still work but won't go through validation.
  • MAX is a type parameter, so the walk is fully unrolled. For MAX ≤ 16 an @generated function produces flat branchless-ish code; for MAX > 16 the walk falls back to a bounded while-loop (unrolling at 128 would balloon the code size).
  • Order-aware — uses Base.Order.lt on the predicate, so Forward and Reverse orderings share one code path.
  • No-hint and out-of-range hint fall through to BinaryBracket.
  • MAX covers gaps 0..MAX (initial gap-0 check plus MAX advance-and-check pairs).

Bench (bench/linearbinary_sweep.jl, n = 100k Float64, ns/query)

gap LBS{4} LinearScan ExpFromLeft BracketGallop
1 9.6 10.0 11.7 17.0
2 11.0 11.0 12.8 20.5
4 14.0 14.0 22.0 23.5
8 43.0 19.0 22.7 25.0 ← MAX exceeded → fallback
16 42.0 30.0 26.0 29.5
64 42.0 101.0 33.0 39.5

LinearBinarySearch{4} wins by ~0.5–1 ns/q at gap = 1 and ties LinearScan at gap = 2. At gaps beyond MAX, the binary fallback caps the worst case at O(log n) — slightly worse than BracketGallop at large gaps but bounded.

Auto integration decision: opt-in only

The gap=1 win is too marginal (~1 ns/q) for a runtime Auto heuristic to recoup — the per-call branch to choose LinearBinarySearch over LinearScan would itself cost the same. This matches FFF's existing pattern for BitInterpolationSearch: keep the strategy as opt-in for callers with workloads they've measured. Auto's per-query tree is unchanged.

Test plan

  • Construction & dispatch on all allowed MAX values, including ArgumentError on bad values
  • Parity vs Base.searchsortedlast/first fuzz on Float64 + Int64 across MAX ∈ {0, 1, 2, 4, 8, 16, 32, 64, 128}
  • Reverse-order parity fuzz
  • Hint past / below / at the answer (walks correct direction)
  • Gap = MAX vs gap = MAX + 1 (binary fallback boundary)
  • Edge cases: empty vector, n=1, n=1M, hint at firstindex/lastindex, hint == answer, out-of-range hint, no-hint dispatch
  • Duplicates (verifies searchsortedfirst finds the first occurrence after a backward walk)
  • All ~36k new tests pass; full suite passes (136766 passes total)
  • Runic-clean

Please ignore until reviewed by @ChrisRackauckas.

🤖 Generated with Claude Code


CI triage (2026-09-25)

The head commit (9b82f92, May 2026) sat on a 4-month-stale base and every
failing check's logs have since expired (GitHub API returns HTTP 410 for run
26253914507 et al.), so classification below is by failure signature against
the current base (main @ 2263792, all green — e.g. Tests 35739730551,
IntegrationTest 35739730245, Documentation 35739730216 successes on
2026-09-22). The branch has now been merged up to current main
(58b3e9e, merge commit, no rebase) with the feature ported to the v3 API
(see commit message), which supersedes the stale results.

check (May head) classification evidence
Core - Julia pre - ubuntu-latest (fail, 4s; pre macos/windows passed) infra flake (setup failure, siblings green) https://github.com/SciML/FindFirstFunctions.jl/actions/runs/26253914507/job/77271812755 (logs expired, HTTP 410)
DataInterpolations.jl/Core/1 (fail, 9s), ModelingToolkit.jl/InterfaceI/1 (fail, 9s) stale-base (dep-resolve failure on May base; downstream green on current base) https://github.com/SciML/FindFirstFunctions.jl/actions/runs/26253914460/job/77271812382 ; base IntegrationTest green https://github.com/SciML/FindFirstFunctions.jl/actions/runs/35739730245
Documentation (fail, 5m32s) stale-base (docs rewritten for v3 since; verified clean locally on merged head) https://github.com/SciML/FindFirstFunctions.jl/actions/runs/26253914628/job/77271813377 ; base docs green https://github.com/SciML/FindFirstFunctions.jl/actions/runs/35739730216
QA - Julia 1 - ubuntu-latest (fail, 3m33s) stale-base, exact cause unrecoverable (logs expired); GROUP=QA passes locally on merged head (21 + 34) https://github.com/SciML/FindFirstFunctions.jl/actions/runs/26253914507/job/77271812736
Spell Check with Typos (fail, 10s) stale-base / resolved (typos clean locally; upstream _typos.toml merged in) https://github.com/SciML/FindFirstFunctions.jl/actions/runs/26253914505/job/77271812432
benchmark (fail, 2m56s) stale-base/infra (old benchpkg workflow; upstream replaced it with the AirspeedVelocity action, adopted via merge) https://github.com/SciML/FindFirstFunctions.jl/actions/runs/26253914506/job/77271812520
Core 1/lts (all OSes), pre mac/win, runic (passed in May) unaffected same run 26253914507

Local verification on the merged head (58b3e9e): GROUP=Core 208501 pass,
GROUP=QA 21 + 34 pass, docs make.jl builds clean (with the package devved,
as CI does), Runic --check clean, typos clean. New CI on the merged head
is running (runs 36088032xxx).

Risk assessment

  • Risk: medium
  • Blast radius: additive feature (LinearBinarySearch, opt-in only).
    No existing dispatch changed: singleton enum/kind switches untouched, Auto
    picker untouched, batched paths route LBS through the generic struct loop.
    Files touched by the port: src/kernels.jl (append-only LBS section),
    src/strategy_kind.jl (+4 entry points, +1 strategy_kind throw),
    src/strategies.jl (LBS struct, unchanged semantics), test/core_tests.jl
    (new testset), bench/linearbinary_sweep.jl (v3 call names). src/dispatch.jl
    deletion is upstream's v3 redesign, not this PR.
  • Evidence: GROUP=Core: 208501 pass; GROUP=QA: 55 pass; LBS testset
    alone: 36296 pass; docs build clean; Runic/typos clean.
  • Independent review: none yet
  • Merge: needs review — parametric-strategy-without-enum-tag is a new
    pattern in the v3 architecture (mirrors GuesserHint precedent); confirm
    the maintainers want it vs. a different integration.

🤖 Posted by an AI agent — harness: opencode 1.18.31 · model: opencode/muse-spark-1.3-contributor-free
Conversation: /home/crackauc/sandbox/fleet-master-jobs/nw-findfirstfunctions-72/log.txt

ChrisRackauckas and others added 2 commits May 21, 2026 17:22
LinearBinarySearch{MAX} walks linearly from the hint for up to MAX steps,
then falls back to BinaryBracket if the answer isn't bracketed within the
window. The win regime is small-gap ODE-style monotone-forward workloads
where the exponential-doubling overhead of ExpFromLeft / BracketGallop is
pure cost and a tight unrolled linear walk is fastest.

Default MAX = 8; allowed values are {0, 1, 2, 4, 8, 16, 32, 64, 128} via a
factory constructor. The set is curated to keep the per-MAX method
specialization table bounded; arbitrary integers would explode it. MAX is
a type parameter so the walk is fully unrolled (for MAX ≤ 16) via
@generated, producing flat branchless-ish code that LLVM can fold.

Bench (bench/linearbinary_sweep.jl, n = 100k Float64, ns/query):

  gap   LBS{4}   LinearScan   ExpFromLeft   BracketGallop
  1      9.6     10.0         11.7          17.0
  2     11.0    11.0          12.8          20.5
  4     14.0    14.0          22.0          23.5
  8     43.0    19.0          22.7          25.0  ← MAX exceeded → fallback
  16    42.0    30.0          26.0          29.5
  64    42.0   101.0          33.0          39.5

LBS{4} wins by ~0.5–1 ns/q at gap = 1; ties LinearScan at gap = 2. At
gaps beyond MAX, the binary fallback caps the worst case at O(log n).
The strategy is opt-in — Auto does not pick it, because the gap=1 win is
too marginal for a runtime heuristic to recoup.

Includes tests covering: factory constructor validation, parity-vs-Base
across MAX ∈ {0, 1, 2, 4, 8, 16, 32, 64, 128}, Forward and Reverse
orderings, hint past / below / at the answer, gap = MAX vs gap = MAX+1
boundary, empty / n=1 / n=1M, duplicates, and no-hint fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
… to v3 API

Upstream replaced the v2 Base.searchsortedlast(::S, ...) extensions with
the v3 StrategyKind enum + kernels architecture (src/dispatch.jl deleted,
split into src/kernels.jl + src/kinds.jl + src/strategy_kind.jl; tests
moved to test/core_tests.jl via SciMLTesting).

Port preserving intent:
- LinearBinarySearch{MAX} walks/kernels move verbatim to src/kernels.jl
  as _kernel_last/first_linear_binary_search with trailing Val{MAX}.
- Struct entry points in src/strategy_kind.jl (multimethod, like
  GuesserHint); strategy_kind(::LinearBinarySearch) throws ArgumentError
  since no single enum tag can represent every MAX. Opt-in only: Auto
  unchanged, no new StrategyKind value.
- LBS tests move to test/core_tests.jl with searchsorted_last/first
  names; guard test covers LinearBinarySearch{8} for no-Base-extension.
- bench/linearbinary_sweep.jl updated to searchsorted_last.

Verified: Core 208501 pass, QA 21+34 pass, docs build clean,
Runic --check clean, typos clean.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Agent-Harness: opencode 1.18.31
Agent-Model: opencode/muse-spark-1.3-contributor-free
Agent-Session: /home/crackauc/sandbox/fleet-master-jobs/nw-findfirstfunctions-72/log.txt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants