Skip to content

perf(umbp): send and resolve a key set once, not once per layer group - #644

Merged
isytwu merged 10 commits into
mainfrom
perf/umbp-ranged-keyset
Sep 11, 2026
Merged

isytwu merged 10 commits into
mainfrom
perf/umbp-ranged-keyset

Conversation

@isytwu

@isytwu isytwu commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Make the ranged-get key handle survive the access pattern a chunked reader actually produces

What this is

BatchGetRanges used to resend the whole key list on every call. #644 originally
added a wire key handle so a repeat call could name its keys instead, and
stopped PoolClient::ResolveLocalBatch from rebuilding the key vector for each
batch. Both were validated only on a host-only microbench.

This PR adds the instrumentation that says whether they fire, fixes a cliff the
instrumentation exposed, fixes two real bugs found along the way, and prices all
of it end to end.

Headline, measured end to end on DeepSeek-V4-Pro conc=128 agentic replay:

control this PR Δ run-to-run noise
Input throughput 248,740 tok/s 251,559 tok/s +1.13% 0.02%
TTFT p50 1935.88 ms 1835.14 ms −5.20% 0.7%
Inter-token latency 53.63 ms 51.83 ms −3.36% 0.6%

The control is the branch point, which contains no key handle at all — it
resends the whole key list on every ranged get. Under this PR, 86% of 178,578
ranged-get calls send an 8-byte handle instead. One paired run; the noise column
is the spread between two runs of the control itself, on different nodes.

Two caveats stated up front rather than buried. TTFT mean and p99 are not
measurable here
— their run-to-run spread (5.9% and 16.7%) exceeds the effect,
so the −1.33% and +10.36% in the full table below are both noise. And an earlier
revision of this description claimed "0% hit rate in the real path"; that came
from a microbench with a deliberately constructed access cycle, and direct
measurement of production says otherwise — ~99% of key-set reuses there are
already inside the old capacity. The 0% is a real worst case a workload can
land on, not a description of the one we run.

What the production workload actually does

One instrumented run of DeepSeek-V4-Pro conc=128 agentic replay, eight ranks,
~19–20k key-set reuses each, UMBP_KEY_HANDLE_DEBUG=1. This is a count of the
access pattern, not a timing, so it has no noise floor to argue with:

rank reuses <8 <16 <64 <512 <1024
umbp-1768 20,158 99.2% 99.8% 99.8% 99.8% 100%
umbp-1770 19,154 98.5% 99.8% 99.9% 99.9% 100%
umbp-1773 19,179 99.4% 100% 100% 100% 100%
umbp-1775 19,119 99.5% 99.9% 99.9% 99.9% 100%

<N is the share of reuses an LRU of capacity N would have caught. The old
capacities were 8 (client) and 64 (server).

So on this workload the old capacity was already catching 98.5–99.5% of
reuses, and the new one catches ~99.9%.
The curve is nearly flat from 8 to
512; raising capacity buys about one percentage point here. The last 0.2%
needs 1024.

Server side, same run: hits=153835 misses=277 mints=24466 hit_rate=99.8% held=512. The mechanism is working — 153k calls named their keys instead of
resending them — and it was working at the old capacity too.

The reader's shape, for anyone sizing this differently:
distinct_batch_sizes=426, top: 2048keys:577 12keys:515 2keys:448 7keys:384.

The cliff: LRU inverts under a fixed-order cycle

_run_layer_wise_batch chunks each layer group's keys into
step = RANGES_PER_CALL // ranges_per_object pieces and walks those chunks in
the same order, once per layer group. That is a cycle of C distinct key sets.

An LRU of capacity < C evicts precisely the entry it is about to need next. It
does not degrade gracefully; it inverts. The old capacities were 8 (client) and
64 (server), and C in production is far larger than either.

Measured on two independent machines, capacity pinned to 8:

cycle length hits mints remints
C = 8 440 (100%) 8 0
C = 9 0 504 495
C = 16 0 896 880

The cliff is one entry wide. C=16 produced exactly 176 hits on both machines
independently once the fix was in, which is what gave confidence this is
structural rather than a timing artifact.

This is a constructed cycle, not the production one. In a strict cycle of C
sets the reuse distance is exactly C−1, so capacity 8 and C=9 sit one entry
apart and every single reuse misses. The measured workload above does not sit
there — its distances are almost all below 8. Both facts are real: LRU's failure
here is a cliff rather than a slope, and the workload we run happens to stand
next to it rather than on it. What the fix buys is that the cliff stops
existing, not a large gain on today's traffic.

Commits

commit what
e36d3c9 bench: what a layer-wise restore costs on the standalone wire
0ab7423 perf: resolve a whole batch without rebuilding its key vector
bf6ef2f perf: let a ranged get name its keys instead of resending them
645dd17 feat: reuse-distance instrumentation — the diagnosis, kept regardless of the fix
33b25e3 test: bench the shape a chunked reader actually produces (--chunk, --ranges-per-key, --threads)
943e118 perf: size the key handles for a chunked reader, and stop aging them
3c944d2 fix: let a zero key-handle capacity actually turn the handles off
8d69559 test: guard the capacity against the cycle that broke it
6647871 fix: let the client's switch also stop the server remembering

The instrumentation is the load-bearing part

645dd17 adds a reuse-distance histogram keyed by key-set fingerprint, behind
UMBP_KEY_HANDLE_DEBUG. It reports, per client, hits/misses/mints/remints and a
CDF of "what hit rate would capacity N have given". At the broken configuration
it printed:

would-hit-at-capacity: <8:0.0% <16:100.0% <32:100.0% ...

That names the fix instead of inviting a guess at it, and it stays in the tree.

The fix is the eviction policy, not just the capacity

943e118 raises capacity (client 8 → 128, server 64 → 512, both env-tunable via
UMBP_KEY_HANDLE_SLOTS / UMBP_KEY_HANDLE_CAPACITY) and replaces LRU with
random replacement
. Capacity alone would only move the cliff: any fixed
capacity has a cycle length that defeats LRU exactly. Random replacement has no
worst case here — it degrades smoothly as capacity/cycle falls, so a workload
that outgrows the table loses proportionally instead of falling to zero.

The client-side move-to-front std::rotate goes with it: with random
replacement there is nothing to age.

What the new defaults cost. One entry holds one key set — roughly 160 KiB
for the thousand-odd 128-byte keys a layer group asks about — so the server's
512 entries are about 80 MiB, on a node whose tier is measured in terabytes.
The client's 128 slots are per worker process. Both are env-tunable
(UMBP_KEY_HANDLE_CAPACITY, UMBP_KEY_HANDLE_SLOTS) and both are allocated
lazily, so a deployment that never repeats a key set pays nothing. This is the
main resource trade in the PR and is stated here so it can be weighed rather
than discovered.

Two real bugs the new tests found

3c944d2 — UMBP_KEY_HANDLE_CAPACITY=0 was documented as "handles off" but the
eviction loop while (held_.size() >= capacity) spins forever at capacity 0
after the first insert. Found by trying to use the disable switch as a
benchmark arm.

6647871 — UMBP_KEY_HANDLE_SLOTS=0 turned off the client's cache and left the
bill with the server. LookupKeyHandle fingerprinted the keys on every miss
without consulting the slot count, and a non-zero fingerprint is exactly the
server's instruction to remember the key set, so a client that would never name
any of it still filled the table to capacity — about 80 MiB at the default — and
paid to hash every key on every call for an answer it discards. Handled the way
the empty-key case already is: send a zero fingerprint. Guarded by a test that
asserts through the handle counter (handles are issued from 1 and never reused,
so if the client's whole run minted nothing, the first handle a raw stub can get
is still 1), verified to fail without the change and pass with it.

This also corrects the microbench numbers below. tools/bench_key_handle_arms.sh
uses UMBP_KEY_HANDLE_SLOTS=0 as its "off" arm, so that arm was paying for a
table it never used. The gap it reported is therefore slightly wider than the
mechanism actually earns.

Nothing to gate for in-process deployments

An embedded deployment never reaches this code: CreateUMBPClient only builds a
StandaloneProcessClient when config.standalone_process is set, and every
key-handle symbol lives in the standalone client and server. The key_handle
fields are in umbp_standalone.proto — the standalone wire protocol only. There
is no runtime state to gate, so no gate was added; a guard on an unreachable
path would only suggest the path is reachable.

Results

1. Microbench — bench_umbp_standalone_ranged_wire, chunked-reader shape

Alternating arms, 5 reps, median, handle-off vs handle-on:

shape machine A machine B
8192 keys, 512-chunk −38.0% —
8192 keys, 1024-chunk −32.1% −28.4%
+ 8 ranges/key (realistic layer group) −20.4% −17.3%
4096 keys, 256-chunk, 8 workers (server table) −52.0% —

Server-table capacity sweep: 64 → 22.3% hit / 111.5 ms; 512 → 100% hit / 53.6 ms.
After the fix, C = 9 / 16 / 32 all hit 100% with zero remints, on both machines.

Read these as an upper bound, for the 6647871 reason above.

2. End to end — DeepSeek-V4-Pro, conc=128, agentic replay

Two paired runs, each arm back to back on one node, same corpus, same container,
one variable (the wheel).

metric control this branch Δ
TTFT mean 3715.94 ms 3666.47 ms −1.33%
TTFT p50 1935.88 ms 1835.14 ms −5.20%
TTFT p90 6171.25 ms 5747.30 ms −6.87%
TTFT p99 29602.73 ms 32669.23 ms +10.36%
Input throughput 248,740 tok/s 251,559 tok/s +1.13%
ISL mean 102,993 103,036 +0.04%
prefix_cache_hit 95.5% 95.5% 0

The second pair is what makes this readable. Its control arm is a repeat of
the first pair's control arm — same wheel, same configuration — so the two bound
what this benchmark can resolve:

metric pair 1 control pair 2 control spread between two identical runs
Input throughput 248,740 248,689 0.02%
TTFT p50 1935.88 1922.34 0.7%
ITL mean 53.63 53.33 0.6%
TTFT mean 3715.94 3934.36 5.9%
TTFT p99 29602.73 34549.62 16.7%

Putting the two tables together, effect against its own noise floor:

metric Δ noise ratio
Input throughput +1.13% 0.02% 57×
TTFT p50 −5.20% 0.7% 7.4×
ITL mean −3.36% 0.6% 5.6×
TTFT mean −1.33% 5.9% 0.2× — not measurable
TTFT p99 +10.36% 16.7% 0.6× — not measurable

So this benchmark resolves throughput, p50 and ITL well, and does not resolve
TTFT mean or p99 at all. The three it resolves all move the right way by five to
fifty-seven times their noise. The +10.36% p99 — the one number that looked like
a tail regression — is inside a 16.7% control-to-control spread; I raised it as a
possible cost when I had only one pair, and the second pair says it was noise.

That the two control runs agreed on throughput to 0.02% on different nodes is
what makes the throughput number worth quoting from a single pair.

The second pair's test arm did not complete: on that node, GPU IPC buffer
registration collapsed from 968 buffers in 0.65 s to roughly one per 83 s, from
its second tenancy onward. Same wheel, same tier, and the same node's first
tenancy an hour earlier registered in milliseconds — a node-level pathology, not
the build. It is called out here because the control-to-control comparison above
is what that run was worth, and it is worth a lot.

3. TTFT paired A/B — topology 3, 256K prompt, full hit

The measurement built for exactly this question: alternating base/opt so both
arms see the same machine drift, one fresh stack per rep, paired differences
with a t-statistic.

base: n=6  median 288.4 ms
opt:  n=7  median 288.6 ms

complete pairs: 6
  rep3  288.0 -> 289.1  (+1.1)
  rep4  288.7 -> 288.6  (-0.1)
  rep5  290.7 -> 285.2  (-5.5)
  rep6  292.3 -> 281.7  (-10.6)
  rep7  284.2 -> 290.4  (+6.2)
  rep8  286.9 -> 279.4  (-7.5)

TTFT paired diff: median -2.8 ms, mean -2.7 ms, 4/6 improved, sd 6.2, t=-1.08

−2.7 ms on ~288 ms is −0.94%, in the direction the change predicts, and not
significant
: |t| = 1.08 against the 2.571 that df=5 needs at p<0.05.

What I think this adds up to

Stating it the way the measurements support, rather than the way I first wrote
it:

The key handle mechanism works, and worked before this PR. 153,835 hits
against 24,466 mints on the production workload — those calls named their keys
instead of resending them, which is what #644 set out to do.

The capacity and eviction change removes a cliff that today's traffic does not
stand on.
Measured: 98.5–99.5% of reuses were already inside the old capacity,
so the change is worth about one percentage point of handle hit rate here. It is
worth much more on a workload whose chunk cycle happens to exceed the capacity,
where LRU goes to zero rather than degrading — but I have only produced that
workload deliberately, in a bench.

The end-to-end gain is real and comes almost entirely from the mechanism, not
from the capacity change.
+1.13% throughput, −5.20% TTFT p50, −3.36% ITL, each
five to fifty-seven times its own run-to-run noise. Attribute it correctly: the
control has no handle and resends every key list, so what is being measured is
86% of calls dropping from tens or hundreds of kilobytes to eight bytes. The
capacity change contributes about 0.65 pp of handle hits on top of that — real,
and small.

TTFT mean and p99 stay unmeasurable on this benchmark, and the single-prompt
256K TTFT A/B is separately uninformative: 1023 keys against a 1024-key chunk
step is one chunk, so it exercises neither the cycle nor much of the wire
volume. Its −0.94% at t=−1.08 is a null result about the wrong workload rather
than evidence about this one.

The instrumentation is what makes any of this checkable, and is the part I
would most argue to keep. It is what turned "we believe the handle cache works"
into eight ranks, twenty thousand reuses each, and a capacity curve — including
the part that contradicted my own initial claim.

Two genuine bugs were found on the way (3c944d2, 6647871), both in the
disable path, both invisible until something tried to use it.

Correctness

  • C++ unit tests pass, including test_standalone_shm_ipc. The new
    RangedGetKeyHandlesSurviveALongerCycleThanTheOldCapacity case mints a 40-set
    cycle and asks for all of them back in mint order; verified to fail under
    UMBP_KEY_HANDLE_CAPACITY=8. The new
    DisablingKeyHandlesAlsoStopsTheServerRemembering case likewise fails without
    6647871.
  • test_umbp_prim_gate.py / umbp_prim_ci.py batch_exists 1→8 rank scaling
    gate: no regression.
  • pre-commit passes on every commit individually, not just the squashed diff.
  • The A/B provenance was checked rather than assumed: the control wheel's base
    has src/umbp and tests/cpp/umbp byte-identical to this branch's base, and
    the test wheel differs from the control in exactly the five source files these
    commits touch.
  • Unchanged on purpose: fingerprint validation, the client-side full key
    comparison that is the actual source of correctness, and the
    key_handle_unknown → resend retry. The handle stays a hint.

Merge status

Merges cleanly into current main (f9697e1). The two files that overlap with
upstream work since the branch point — src/umbp/distributed/pool_client.cpp
and tests/cpp/umbp/distributed/CMakeLists.txt — auto-merge with no conflicts.

Not in this PR

  • The resolve cache / shared page list. ResolvedEntry::pages is read at
    154 sites across 8 non-test files; making it shared is a MediumBackend
    interface change, and folding it in would have made the e2e arm
    multi-variable. A previous attempt (kept on perf/umbp-resolve-cache-rejected)
    measured only −2.2%/−3.0% because it cached the map probe while
    BatchResolve still returned by value and paid every per-key page-vector copy
    — the copy is the cost, and removing it is the follow-up worth doing. Not
    attempted here
    , as distinct from measured and found worthless.
  • The sglang-side tail-group misalignment. Worth about 2.5% of restore time
    by the earlier estimate, which is under every noise floor measured above, and
    I have no measurement of my own that says otherwise.

@isytwu isytwu self-assigned this Sep 8, 2026
A layer-wise reader names one key set once per layer group, so the cost that
matters is not one ranged call but the whole group sweep over a fixed set --
and most of it is the key list, which is identical every time.

--threads is the dimension that is easiest to get wrong and most expensive to
miss: a deployment runs N ranks against one standalone server, so a change to
anything they share behaves differently under one caller than under eight. A
single-threaded bench cannot see a shared critical section at all.

Host only: no GPU, no RDMA, no hugepages, seconds per run. That is what makes
it usable as an attribution tool rather than a nightly.
ResolveLocalBatch gathered its query by copying every candidate key into a
fresh vector. For the case that matters -- a restore, where every key is a
candidate and the order is already the caller's -- `keys` IS the query, so the
copy rebuilt a thousand-odd ~128-byte strings to hand back what it was given.
A layer-wise reader pays that once per layer group.

Name the caller's vector directly when the batch is whole, and gather only
when it is genuinely a subset.

Ranged wire bench, 512 keys x 8 layer groups, medians of 5, restore ms:

  threads   before   after
  1          2.791   2.657   -4.8%
  8          2.765   2.654   -4.0%

Non-overlapping at both: the slowest optimised run beats the fastest baseline
run in each case.
A layer-wise restore asks about one key set once per layer group, changing
only which bytes of each object it wants. The keys are then the one part of
the request that is identical between the calls -- and at a thousand-odd
~128-byte keys they are the expensive part: over a hundred kilobytes to
serialize, a heap allocation per key to deserialize, and a copy out of the
protobuf before anything is looked up. Seven times out of eight, for nothing.

A handle stands in for the list. The server mints one when a caller sends keys
with a fingerprint, and the caller may name that handle on the next call and
send no keys at all.

What makes it a handle and not a session is that it promises nothing:

  * Nothing is opened and nothing has to be closed. The handle arrives as a
    field on an ordinary reply and is offered as a field on an ordinary
    request. There is no revoke to plumb through error paths and no cleanup a
    crashed client can skip, because there is no state whose absence is a bug.
  * A handle the server no longer holds is not an error. The table is bounded
    and evicts; a server that has restarted holds nothing at all. Either way
    the reply says key_handle_unknown, the caller drops it and repeats the call
    carrying its keys, and the result is what it would have been without any of
    this. Worst case is one extra round trip on a path that is already rare.
  * A handle cannot name the wrong list. The client matches a key set by full
    equality against what it last sent, and the fingerprint recorded when the
    handle was minted is rechecked on every use.
  * A caller that will not repeat pays nothing. No fingerprint means no handle
    is minted and nothing is remembered.

Client-side matching is a size check, then the two end keys, then the full
compare -- the cheap tests reject a different set outright, and the exact
compare runs only for the set about to hit. The fingerprint is computed once,
on the call that introduces the set and already pays to serialize it.

The server also holds the key list by shared pointer and hands it to the
backend by reference, so the copy out of the protobuf is gone from the first
call too, not just the repeats.

UMBP_KEY_HANDLE_DEBUG=1 reports hits/misses/mints, because a handle that never
hits is otherwise invisible: a miss still returns the right bytes.

Ranged wire bench, 512 keys x 8 layer groups, medians of 5, restore ms:

  threads   before   after
  1          2.647   1.889   -28.6%
  8          2.643   1.918   -27.4%

Non-overlapping at both thread counts.
A hit rate says the handles are not working. It does not say whether a bigger
table would fix it, and those call for different changes: one is a constant,
the other is the eviction policy. The existing counter cannot tell them apart,
and it only reports every 512 lookups, so a run can finish having said nothing.

This adds the measurement that separates them. Key sets are identified by
fingerprint rather than by handle -- the fingerprint is the only identifier
that survives the CLIENT's cache missing, since a set the client has forgotten
arrives as a fresh mint with a handle the server never issued. Counting mints
of a fingerprint already seen is therefore the only way the server can see
thrash on the client side at all; its own hit rate is silent about a lookup it
was never asked to do.

The distribution reported is reuse distance: how many other key sets a client
touched between two uses of one set. An LRU of capacity C hits exactly the
reuses below distance C, so the report reads directly as a sizing decision.
It also distinguishes the case no size fixes: a reader cycling through N sets
in a fixed order puts every reuse at distance N-1 with nothing below it, and
LRU turns that into a 0% hit rate rather than a degraded one, because the
entry it evicts is always the next one wanted.

Also reported is the batch-size histogram, because a reader whose chunking
does not divide evenly produces a second peak whose sets are new every time.

All of it is behind UMBP_KEY_HANDLE_DEBUG, which already existed for exactly
this question; nothing runs when it is unset. The distance walk is bounded by
a tracked-set cap, and is paid against an RPC that already resolves a thousand
keys.

Measured on bench_umbp_standalone_ranged_wire, which reports one key set,
first_sight=1 and a 100% hit rate -- confirming the bench cannot exercise the
case a chunked reader hits, and that its -28.6% was measured where the handles
always work.
This bench asked about one key set, over and over. A reader does not: it splits
a pool's keys to fit a per-call range budget and walks the pieces in order,
once per layer group. One repeated set becomes C of them cycled in a fixed
order -- and that is the single access pattern under which an LRU does not
degrade, it inverts, because the entry it evicts is always the next one wanted.

So the bench could not see the case worth seeing. --chunk makes it produce
that shape, and the result is a cliff rather than a slope, measured here with
512-key chunks and UMBP_KEY_HANDLE_DEBUG=1:

  C=1..8    100% hit,  8 mints total   (one per set, unavoidable)
  C=9         0% hit, 504 mints of which 495 are re-mints of a set just used
  C=16        0% hit, 896 mints of which 880 are re-mints

C=8 is perfect and C=9 is total, which is what a capacity of 8 predicts
exactly. The reuse-distance report names the fix without any guessing: every
reuse lands at distance <16 and none below 8, so a capacity of 16 would have
hit all of them and a capacity of 8 hits none.

Two other knobs, because the old defaults flattered the mechanism in a second
way. --ranges-per-key sets how many ranges a call carries per key: a handle
saves key bytes only, and at the default of 1 the keys are most of the request,
while a real layer group asks for one range per layer and dilutes them. And
--threads runs concurrent independent restores as separate ranks, which is what
puts pressure on the server's shared table rather than each client's own.

Threads are started once and time their own restores; a thread creation inside
a millisecond-scale measurement would be a large share of the result. Defaults
are unchanged, and reproduce the previous numbers.
… stop aging them

Both handle tables were sized for "one key set in flight per pool" and evicted
by age. A layer-wise reader does neither: it splits a pool's keys to fit a
per-call range budget and walks the pieces in order, once per layer group, so
the sets arrive as a fixed-order cycle of C of them. Under a cycle, LRU does
not degrade, it inverts -- the entry it evicts is always the next one wanted.

Measured on the wire bench at 512-key chunks, client side, before this change:

  C=8    100% hit,   8 mints
  C=9      0% hit, 504 mints, 495 of them re-minting a set just used
  C=16     0% hit, 896 mints, 880 re-mints

A cliff, not a slope, and exactly where a capacity of eight predicts it. The
same shape on the server, with eight concurrent readers making 128 sets live
against its 64 entries: 22.3% hit and 5,473 resends.

Two changes, and they are worth separating because they fix different halves.

Capacity now defaults to 128 slots per client and 512 entries on the shared
server table, and both are settable (UMBP_KEY_HANDLE_SLOTS,
UMBP_KEY_HANDLE_CAPACITY) because the cycle length belongs to the caller's
chunking and is not something either side gets to know. An entry holds one key
set -- some 160 KiB for a thousand 128-byte keys -- so the server default is
about 80 MiB on a node whose tier is terabytes.

Replacement is now random rather than by age. Capacity alone would leave the
cliff intact one ISL further out; random has no worst case. Measured at a fixed
capacity of 8 against a cycle of 16, where LRU scored 0 hits out of 896:

  capacity  4    12 hits    capacity 12   466 hits
  capacity  8   176 hits    capacity 128  880 hits (no eviction)

19.6% instead of 0% at the same size, decaying with capacity/cycle instead of
collapsing. The capacity is what makes it fast; the policy is what stops the
next deployment from finding the cliff again.

What it is worth, alternating arms, five repeats, median, against the same
binary with the client told to remember nothing (which is what the old eight
slots produced at any cycle past eight):

  8192 keys, 512-key chunks                   57.750 -> 35.832 ms   -38.0%
  8192 keys, 1024-key chunks                  60.870 -> 41.318 ms   -32.1%
  8192 keys, 1024-key chunks, 8 ranges/key   122.671 -> 97.631 ms   -20.4%
  4096 keys, 256-key chunks, 8 workers       111.535 -> 53.565 ms   -52.0%  (server table)

The third row is the honest one for a real layer group: a handle removes key
bytes only, and a group asking one range per layer dilutes them, so -20% is
what to expect where the earlier -28.6% was measured with one range per key.

Client lookup keeps its linear scan and full comparison. The cheap rejects
already stop at the first differing key, so a larger table costs a few short
compares, and keying the scan on the fingerprint would mean hashing every key
on the hit path -- the cost the fingerprint exists to avoid.
UMBP_KEY_HANDLE_CAPACITY=0 is the way to take the mechanism out of the picture
when bisecting a problem, so it has to be free, and it was not: the table still
minted a handle, still returned it, and the client still offered it back on the
next call -- to be told it was unknown, and to repeat the call carrying its
keys. Turning the feature off cost an extra round trip per ranged get.

Returning zero instead says "there is nothing to name here", which the client
already understands: RememberKeyHandle drops a zero handle on the floor.

Measured with the capacity at zero: 0 hits and 0 misses, where the misses are
what the wasted round trips would have shown up as.
…e it

The capacity and the replacement policy were both chosen against a measurement,
and nothing in the tree would have noticed either being put back. The failure
mode is invisible from the outside -- a dropped handle still returns the right
bytes, just after a call that carried a hundred kilobytes of keys it did not
need to -- so only an end-to-end run would have caught it, which is how the
original eight slots survived review in the first place.

This mints a cycle of forty key sets and asks for every one of them back, in
the order they were minted. That order is the point: an LRU smaller than the
cycle evicts precisely the set that comes round next, so it fails this on the
first reuse rather than on some fraction of them.

Confirmed to fail against what it guards: with UMBP_KEY_HANDLE_CAPACITY=8, the
capacity this replaced, the test fails.
@isytwu
isytwu force-pushed the perf/umbp-ranged-keyset branch from 8d39683 to 8d69559 Compare September 10, 2026 02:36
…membering

UMBP_KEY_HANDLE_SLOTS=0 turned off the client's handle cache and left the bill
with the server. LookupKeyHandle fingerprinted the keys on every miss without
consulting the slot count, and a non-zero fingerprint is exactly the server's
instruction to remember the key set -- so a client that would never name any of
it still filled the table to capacity, some 80 MiB at the default, and paid to
hash every key on every call for an answer it discards.

Handled the way the empty-key case already is: send a zero fingerprint, which
the server reads as "do not mint". No new mechanism, no new invariant, and the
hash is skipped with it.

This is also what the mechanism costs where it cannot pay. An in-process
deployment never reaches this code at all -- CreateUMBPClient only builds a
StandaloneProcessClient when config.standalone_process is set, and every
key-handle symbol lives in the standalone client and server -- so there is
nothing to gate there. The standalone client with its handles switched off was
the one configuration that was still being charged.

Guarded by a test that is self-gating on the variable it is about, because the
slot count is read once per process and a test cannot change it for a client
another test already built. It asserts through the handle counter: handles are
issued from 1 and never reused, so if the client's whole run minted nothing,
the first handle a raw stub can get is still 1. Verified to fail without this
change and pass with it, under UMBP_KEY_HANDLE_SLOTS=0.

Also means tools/bench_key_handle_arms.sh measures what it claims: its "off"
arm was paying for a table it never used, so the gap it reported between the
arms was slightly wider than the mechanism actually earns.
tools/bench_key_handle_arms.sh alternated a handles-off and a handles-on arm
and took a median. Its "off" arm is UMBP_KEY_HANDLE_SLOTS=0, and that switch
was only half a switch until the previous commit: the client kept no handles
but still sent a fingerprint, so the server was still told to remember every
key set and still filled its table, and the client still hashed every key on
every call. The off arm was therefore paying for a mechanism it was supposed
to have turned off, and the gap the script printed is wider than the mechanism
earns.

It could be corrected instead of removed, but there is little left to keep:
thirty-five lines around a loop and a median, whose outputs are already
recorded in the commit messages and the PR that used them. A driver that is
known to overstate is worse than no driver, because the next person has no
reason to doubt it.

The measurement itself stays reachable. bench_umbp_standalone_ranged_wire
takes --chunk/--ranges-per-key/--threads and is what actually produces the
numbers; running it twice under different UMBP_KEY_HANDLE_* settings is the
whole of what this script did.
@isytwu
isytwu marked this pull request as ready for review September 11, 2026 04:09
@isytwu
isytwu merged commit 7afe926 into main Sep 11, 2026
14 checks passed
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