Skip to content

lookup: answer the branchless-binary-search TODO — it is a wash - #168

Open
psiha wants to merge 3 commits into
bt/10-comparator-costfrom
perf/branchless-binary-search
Open

lookup: answer the branchless-binary-search TODO — it is a wash#168
psiha wants to merge 3 commits into
bt/10-comparator-costfrom
perf/branchless-binary-search

Conversation

@psiha

@psiha psiha commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Stacked on #166. Closes out the TODO branchless binary search at
b+tree/impl.hpp:295 — by answering it, not by adopting it. The default stays
off.

What

branchless_lower_bound / branchless_upper_bound in lookup.hpp: the same halving
as std::lower_bound, with both arms of each step written as a select rather than a
jump. The b+tree's binary arm now goes through binary_{lower,upper}_bound, which is
std:: unless PSI_VM_BRANCHLESS_BINARY_SEARCH is defined.

Measured — in one process, no A/B arms

lookup.branchless_vs_std_binary times both forms back to back in the same binary on
the same data, so there is no cross-process noise floor to defend. x86-64 (Arrow
Lake-H), resident, 8..1024 values:

values uint32 Δ uint64 Δ float Δ double Δ
8 +0.8% −1.2% −3.7% −1.8%
32 +4.5% +3.0% −3.1% −3.5%
128 −1.3% −1.3% +1.0% +3.9%
512 +0.7% +1.0% +2.0% −5.0%
1024 −0.7% +0.3% +1.3% −4.0%

Scatter of roughly ±5% with no systematic sign, on every type. (uint16 behaves the
same; two isolated points — uint64 at 64 and double at 64 — read +15% and +10% with
near-zero neighbours either side, which is noise, not signal.)

In-tree on AArch64 (M1), 4 blocks per arm, order reversed on alternate blocks,
with a deliberate same-config duplicate arm as the noise floor: every column —
sequential, random, indirect, u32, u64 — lands inside a 0.35–2.5% floor.

The float case, since it is the one that was predicted to win

x86 clang emits a branchy compare for floating point (vucomiss;jbe) and a cmov for
integers, so float is where an explicit branchless form had something to remove. It
does not show up. Worth noting why: float and double cost 13–41 ns per probe against
3–9 ns for the integers
— the FP comparison chain dominates, and removing branches
does not shorten a latency chain.

Why keep it rather than drop it

The knob and the instrument are what make this answer reproducible on the next ISA
instead of re-derived, and the correctness tests are worth having either way:
lookup.branchless_agrees_with_std_{uint32,uint64,float} compares against std:: over
lengths 0..1000 with duplicates (so lower_bound must find the first of a run and
upper_bound one past the last) and probes outside the range on both sides — which
is where the off-by-one in the "go right" step would surface.

Gate

Full suite, Release: 2082 passed / 1 pre-existing skip / 0 failed.

Not covered

The x86-64 in-tree arm. Its A/B came back with an 11–33% same-config noise floor —
the box was not quiet — so I am not reporting those numbers. The resident comparison
above and the AArch64 in-tree result are what this rests on.

🤖 Generated with Claude Code

psiha and others added 3 commits September 11, 2026 10:37
…ough

is_simple_comparator answers a semantic question - may == replace the
double-negation equivalence test - and linear_search_eligible reads it
as a cost one.  They are different properties, and a common comparator
shape separates them: a key that is a handle, ordered by fetching what
it points at.  Such a comparator is exactly as "simple" while every
comparison is a scattered load instead of a read of the cache line the
scan is already walking, so a trait that says nothing about locality is
selecting the strategy whose whole economics is locality.  That is a
defect whichever strategy turns out to win.

So state the cost question separately, as is_direct_comparator, and have
linear_search_eligible require both.  std::less/greater, the ranges
forms and the erasure adapters name the key type's own ordering, so they
read the keys and nothing else - every comparator currently reaching the
linear path through a built-in specialisation stays on it.

The default for everything else is std::lower_bound, and that is a
judgement rather than a measurement: against the benchmark's indirect
comparator at 512-byte nodes the two strategies trade places by ~14% in
opposite directions - binary ahead on lookup, the scan ahead on insert -
both about nine times the in-run noise floor.  The comments say so, so a
consumer with its own measurement declares rather than inherits.

The benchmark's indirect_less keeps its is_simple_comparator
specialisation, which is accurate, and declares itself non-direct.
PSI_VM_BENCH_INDIRECT_LINEAR=1 claims the directness it does not have,
which is the only route back to the scan and the reason that arm is a
knob rather than a value: it exists to be measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
map_memory() threads the reserved pool onto the free list through
free(), and free() writes each node's header and marks it dirty.  At
that point the bit says something untrue: the tree has only just been
created, no COW clone of it exists, and the storage already holds what
those nodes say.

commit_to() copies every dirty node, so the first commit of the tree's
first clone copied the entire reserved pool.  It then looked fine
forever after, because commit_to() clears the bit in its target and
later clones inherit the cleaned state - which is exactly why a
single-commit test cannot see this.

Measured with a counter in commit_to() over map_memory(20000) plus 100
inserted values: the first commit copied 170 nodes, 169 of which held no
values at all, to move one node of real change.  With the bits cleared
it copies 2.

Cleared in map_memory() only.  reserve()/reserve_additional() can run on
a clone that genuinely owes its new pool nodes to a target, so the same
reasoning does not hold there.

nodes_dirty() joins nodes_used()/nodes_reserved() so the property is
assertable rather than only observable from a probe, and the test drives
two commit cycles so a regression cannot hide behind the self-correction
described above.  Note what the test does NOT assert: dirty <= used is
not an invariant, because taking a node off the free list also rewrites
its successor's back-link (new_node -> unlink_right), so a single
allocation legitimately dirties two nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b+tree/impl.hpp carried a TODO for a branchless binary search
(Alexandrescu's TLC and friends).  This implements it and measures it, so
the TODO can stop being an open question: on the two ISAs available it
buys nothing, and the default stays off.

branchless_{lower,upper}_bound do the same halving as std::lower_bound
with both arms of each step written as a select rather than a jump.  The
b+tree's binary arm now goes through binary_{lower,upper}_bound, which is
std:: unless PSI_VM_BRANCHLESS_BINARY_SEARCH is set.

Measured in one process, both forms back to back on the same data, so
there are no A/B arms and no cross-process noise to defend
(lookup.branchless_vs_std_binary).  x86-64, 8..1024 values, resident:
every type scatters within about +-5% with no systematic sign - uint16,
uint32, uint64, float and double alike.  In-tree on AArch64, 4 blocks per
arm against a same-config duplicate arm, every column lands inside a
0.35-2.5% noise floor.

The float case is worth stating because it was the one predicted to win:
x86 clang emits a branchy compare for floating point and a cmov for
integers, so that is where an explicit branchless form had something to
remove.  It does not show up.  Note float and double cost 13-41 ns per
probe against 3-9 for the integers - the FP comparison chain dominates,
and taking branches out does not shorten it.

Kept rather than dropped: the knob and the instrument are what make the
answer reproducible on the next ISA, and the correctness tests are worth
having regardless.  Those compare against std:: over lengths 0..1000 with
duplicates - so lower_bound must find the first of a run and upper_bound
one past the last - and probe outside the range on both sides, which is
where the off-by-one in the "go right" step would show.

Full suite: 2082 passed, 1 pre-existing skip, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from fix/map-memory-clean-free-pool to bt/10-comparator-cost September 11, 2026 20:28
@psiha
psiha force-pushed the bt/10-comparator-cost branch from f044dad to 32784a2 Compare September 11, 2026 21:11
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.

1 participant