Skip to content

Commit 8f78df2

Browse files
Merge pull request #65 from ChrisRackauckas-Claude/sorted-search-strategies
Add SearchStrategy dispatch + in-place batched searchsorted!
2 parents 8776771 + d5b976e commit 8f78df2

29 files changed

Lines changed: 4945 additions & 586 deletions

.github/workflows/Benchmark.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: Benchmarks
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "src/**"
7+
- "bench/**"
8+
- "Project.toml"
9+
- ".github/workflows/Benchmark.yml"
10+
workflow_dispatch:
11+
12+
# Only one benchmark run per PR at a time; cancel stale ones.
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
pull-requests: write
20+
21+
jobs:
22+
benchmark:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v6
26+
with:
27+
fetch-depth: 0 # we need both PR head and base branch
28+
- uses: julia-actions/setup-julia@v3
29+
with:
30+
version: "1.11"
31+
- uses: julia-actions/cache@v2
32+
- name: Install AirspeedVelocity
33+
run: |
34+
julia -e 'using Pkg; Pkg.add(PackageSpec(name="AirspeedVelocity", version="0.6"))'
35+
# Resolve commit shas explicitly so AirspeedVelocity sees stable refs.
36+
- name: Resolve refs
37+
id: resolve
38+
run: |
39+
PR_SHA=$(git rev-parse HEAD)
40+
BASE_SHA=$(git rev-parse origin/${{ github.base_ref }})
41+
echo "pr_sha=$PR_SHA" >> "$GITHUB_OUTPUT"
42+
echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
43+
- name: Run benchmark comparison
44+
run: |
45+
export JULIA_NUM_THREADS=1
46+
~/.julia/bin/benchpkg FindFirstFunctions \
47+
--rev=${{ steps.resolve.outputs.base_sha }},${{ steps.resolve.outputs.pr_sha }} \
48+
--bench-on=${{ steps.resolve.outputs.pr_sha }} \
49+
--output-dir=results/ \
50+
--tune
51+
- name: Render comparison table
52+
run: |
53+
~/.julia/bin/benchpkgtable FindFirstFunctions \
54+
--rev=${{ steps.resolve.outputs.base_sha }},${{ steps.resolve.outputs.pr_sha }} \
55+
--input-dir=results/ \
56+
> benchmark_table.md
57+
cat benchmark_table.md
58+
- name: Post benchmark comment
59+
if: github.event_name == 'pull_request'
60+
uses: peter-evans/create-or-update-comment@v4
61+
with:
62+
issue-number: ${{ github.event.pull_request.number }}
63+
body-path: benchmark_table.md
64+
edit-mode: replace
65+
- name: Upload raw results
66+
uses: actions/upload-artifact@v4
67+
with:
68+
name: benchmark-results
69+
path: results/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/Manifest.toml
2+
bench/Manifest.toml
23
docs/build
34
docs/Manifest.toml
45
docs/src/assets/Manifest.toml

NEWS.md

Lines changed: 464 additions & 0 deletions
Large diffs are not rendered by default.

Project.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "FindFirstFunctions"
22
uuid = "64ca27bc-2ba2-4a57-88aa-44e436879224"
3-
version = "1.8.0"
3+
version = "2.0.0"
44
authors = ["Chris Elrod <elrodc@gmail.com> and contributors"]
55

66
[deps]
@@ -10,13 +10,15 @@ PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
1010
Pkg = "1.10"
1111
PrecompileTools = "1"
1212
SafeTestsets = "0.1"
13+
StableRNGs = "1"
1314
Test = "1.10"
1415
julia = "1.10"
1516

1617
[extras]
1718
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
1819
SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"
20+
StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3"
1921
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
2022

2123
[targets]
22-
test = ["Pkg", "SafeTestsets", "Test"]
24+
test = ["Pkg", "SafeTestsets", "StableRNGs", "Test"]

bench/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
results.csv
2+
Manifest.toml

bench/Project.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[deps]
2+
BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
3+
FindFirstFunctions = "64ca27bc-2ba2-4a57-88aa-44e436879224"
4+
PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d"
5+
StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3"
6+
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"

bench/analyze.jl

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using DelimitedFiles, Statistics, Printf
2+
3+
const CSV = joinpath(@__DIR__, "results.csv")
4+
5+
# Read raw data
6+
raw, header = readdlm(CSV, ','; header = true)
7+
header = vec(string.(header))
8+
function col(name)
9+
j = findfirst(==(name), header)
10+
j === nothing && error("no column $name")
11+
return j
12+
end
13+
14+
const STRATS = ["LinearScan", "SIMDLinearScan", "BracketGallop", "ExpFromLeft", "InterpolationSearch"]
15+
16+
function row_winner(row)
17+
times = Float64[parse(Float64, string(row[col(s)])) for s in STRATS]
18+
j = argmin(times)
19+
return STRATS[j], times[j]
20+
end
21+
22+
function ratio_to(row, strat_name)
23+
times = Float64[parse(Float64, string(row[col(s)])) for s in STRATS]
24+
best = minimum(times)
25+
return parse(Float64, string(row[col(strat_name)])) / best
26+
end
27+
28+
println("==> Where does SIMDLinearScan win?")
29+
simd_wins = []
30+
for i in axes(raw, 1)
31+
local row = raw[i, :]
32+
local winner, _ = row_winner(row)
33+
if winner == "SIMDLinearScan"
34+
push!(
35+
simd_wins, (
36+
eltype = string(row[col("eltype")]),
37+
v = string(row[col("v_kind")]),
38+
q = string(row[col("q_kind")]),
39+
n = parse(Int, string(row[col("n")])),
40+
m = parse(Int, string(row[col("m")])),
41+
)
42+
)
43+
end
44+
end
45+
n_simd_wins = length(simd_wins)
46+
println(" SIMDLinearScan wins in $n_simd_wins cells")
47+
println()
48+
49+
# Tabulate where SIMD wins by m (gap proxy)
50+
println("==> SIMDLinearScan wins by m (proxy for batched-gap regime):")
51+
by_m = Dict{Int, Int}()
52+
for c in simd_wins
53+
by_m[c.m] = get(by_m, c.m, 0) + 1
54+
end
55+
for m in sort(collect(keys(by_m)))
56+
@printf(" m=%5d: %d cells\n", m, by_m[m])
57+
end
58+
println()
59+
60+
# By eltype
61+
println("==> SIMDLinearScan wins by eltype:")
62+
by_eltype = Dict{String, Int}()
63+
for c in simd_wins
64+
by_eltype[c.eltype] = get(by_eltype, c.eltype, 0) + 1
65+
end
66+
for k in sort(collect(keys(by_eltype)))
67+
println(" $k: $(by_eltype[k]) cells")
68+
end
69+
println()
70+
71+
# Now show: for sorted-batched cells where SIMDLinearScan wins, what's the
72+
# ratio of LinearScan/ExpFromLeft to SIMDLinearScan? This tells us the
73+
# magnitude of the improvement.
74+
println("==> SIMDLinearScan win margin over LinearScan and ExpFromLeft:")
75+
println(" (Higher = bigger SIMD speedup over the second-best Auto candidate)")
76+
margins = []
77+
for i in axes(raw, 1)
78+
row = raw[i, :]
79+
winner, best = row_winner(row)
80+
if winner == "SIMDLinearScan"
81+
t_simd = best
82+
t_lin = parse(Float64, string(row[col("LinearScan")]))
83+
t_exp = parse(Float64, string(row[col("ExpFromLeft")]))
84+
push!(
85+
margins, (
86+
ratio_lin = t_lin / t_simd,
87+
ratio_exp = t_exp / t_simd,
88+
n = parse(Int, string(row[col("n")])),
89+
m = parse(Int, string(row[col("m")])),
90+
)
91+
)
92+
end
93+
end
94+
println(" SIMD vs LinearScan: median $(median(m.ratio_lin for m in margins))x, max $(maximum(m.ratio_lin for m in margins))x")
95+
println(" SIMD vs ExpFromLeft: median $(median(m.ratio_exp for m in margins))x, max $(maximum(m.ratio_exp for m in margins))x")
96+
println()
97+
98+
# What does Auto pick in the cells where SIMDLinearScan would win?
99+
# Find the m, n, gap regime where SIMDLinearScan beats LinearScan AND ExpFromLeft.
100+
println("==> Best regime for SIMDLinearScan (cells where SIMD wins by >20%):")
101+
significant_simd_wins = filter(c -> c.ratio_lin > 1.2 && c.ratio_exp > 1.2, margins)
102+
println(" $(length(significant_simd_wins)) cells where SIMD beats both LinearScan and ExpFromLeft by >20%")
103+
n_by_m_n = Dict{Tuple{Int, Int}, Int}()
104+
for c in significant_simd_wins
105+
n_by_m_n[(c.n, c.m)] = get(n_by_m_n, (c.n, c.m), 0) + 1
106+
end
107+
for (n, m) in sort(collect(keys(n_by_m_n)))
108+
@printf(" n=%5d m=%5d: %d cells\n", n, m, n_by_m_n[(n, m)])
109+
end
110+
println()
111+
112+
# Compute: in each cell, the GAP. gap = n * span(queries)/span(v) / m
113+
# (the same heuristic Auto uses). Use n/m as a rough proxy since exact gap
114+
# depends on the query distribution which isn't recoverable from CSV alone.
115+
println("==> n/m ratio for SIMD-winning cells (rough gap proxy):")
116+
nm_buckets = Dict{Int, Int}()
117+
for c in simd_wins
118+
bucket = c.n ÷ c.m
119+
# Round to a power-of-2 bucket
120+
log_bucket = bucket == 0 ? 0 : floor(Int, log2(bucket))
121+
nm_buckets[log_bucket] = get(nm_buckets, log_bucket, 0) + 1
122+
end
123+
for b in sort(collect(keys(nm_buckets)))
124+
@printf(" n/m in [2^%d, 2^%d): %d cells\n", b, b + 1, nm_buckets[b])
125+
end
126+
println()
127+
128+
println("==> What strategy does Auto pick in cells where SIMD would win?")
129+
auto_picks_when_simd = Dict{String, Int}()
130+
for i in axes(raw, 1)
131+
row = raw[i, :]
132+
winner, _ = row_winner(row)
133+
if winner == "SIMDLinearScan"
134+
# We don't know Auto's pick from the CSV, but we know Auto's time.
135+
# The closest strategy time to Auto's time tells us the pick.
136+
t_auto = parse(Float64, string(row[col("Auto")]))
137+
candidates = [(s, parse(Float64, string(row[col(s)]))) for s in STRATS]
138+
# Pick the strategy whose time is closest to Auto's
139+
# (within 20% — heuristic).
140+
closest = argmin(c -> abs(c[2] - t_auto), candidates)
141+
auto_picks_when_simd[closest[1]] = get(auto_picks_when_simd, closest[1], 0) + 1
142+
end
143+
end
144+
for (s, n) in sort(collect(pairs(auto_picks_when_simd)); by = x -> -x[2])
145+
println(" Auto -> $s: $n cells (out of $(length(simd_wins)))")
146+
end

0 commit comments

Comments
 (0)