Skip to content

Commit 2aec355

Browse files
committed
fix(epp): widen p2p source sampling to a one-block band
Exact-count argmax re-concentrates pull traffic the moment one replica drifts a block ahead: the queue weighting never sees near-tied peers, so a deeply-queued source absorbs every pull while an idle peer one block short sits unused. Sample among peers within one block of the maximum instead; the one-block shortfall costs the destination a single block of recompute, noise next to a queue-depth difference on the source. The stashed count is the chosen peer's own, so the minCachedTokenDelta gate judges the actual pull benefit. Signed-off-by: nilig <nili.ifergan@gmail.com>
1 parent 4fc4619 commit 2aec355

3 files changed

Lines changed: 83 additions & 39 deletions

File tree

pkg/epp/framework/plugins/requestcontrol/dataproducer/p2psource/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
**Type:** `p2p-source-producer`
44

5-
Sets the `x-kv-cache-source-host-port` header to the endpoint holding the most cached prompt prefix, so the routing sidecar can pull those blocks over the P2P connector instead of recomputing them. Runs in the request handling's `DataProducer` phase before scheduling, then emits the header in `PreRequest` after the scheduling decision.
5+
Sets the `x-kv-cache-source-host-port` header to an endpoint within one block of the most cached prompt prefix, so the routing sidecar can pull those blocks over the P2P connector instead of recomputing them. Runs in the request handling's `DataProducer` phase before scheduling, then emits the header in `PreRequest` after the scheduling decision.
66

7-
For each request the plugin consumes the per-endpoint `PrefixCacheMatchInfo` produced by a prefix-cache producer (`approx-prefix-cache-producer` or `precise-prefix-cache-producer`) and selects the source among the endpoints caching the most prompt tokens, sampling one with probability proportional to `1/(1+waiting queue)` using a request-ID hash as the deterministic sampling coordinate. Load-blind argmax alone would send every consumer of a widely-replicated prefix to the same peer, concentrating pull traffic on one source; a hard minimum-queue rule would herd whole scrape windows onto whichever peer last read the shortest queue, since metrics refresh about once per second and a one-request difference is noise at that granularity. Proportional weighting spreads exact ties uniformly, shifts share about 2:1 on a one-request queue difference, and effectively starves deeply-queued sources. The waiting queue is a proxy for engine-step responsiveness (a busy engine serves its P2P session slower), not for pull-serve fanout; the hash spread is what distributes pull traffic among equally-suitable sources. After scheduling it compares the chosen peer against the pod that will compute the prefix — the `prefill` profile target under P/D disaggregation, otherwise the primary target — and sets the header only when the peer out-caches the computing pod by at least `minCachedTokenDelta` tokens. Any inbound value of the header is removed. When no peer out-caches the computing pod, the request proceeds unchanged.
7+
For each request the plugin consumes the per-endpoint `PrefixCacheMatchInfo` produced by a prefix-cache producer (`approx-prefix-cache-producer` or `precise-prefix-cache-producer`) and selects the source among the endpoints caching within one block of the most prompt tokens, sampling one with probability proportional to `1/(1+waiting queue)` using a request-ID hash as the deterministic sampling coordinate. Load-blind argmax alone would send every consumer of a widely-replicated prefix to the same peer, concentrating pull traffic on one source; a hard minimum-queue rule would herd whole scrape windows onto whichever peer last read the shortest queue, since metrics refresh about once per second and a one-request difference is noise at that granularity. The one-block band keeps a peer one block short competitive — the shortfall costs the destination a single block of recompute, noise next to a queue-depth difference on the source — so exact-count argmax cannot re-concentrate traffic the moment one replica drifts a block ahead. Proportional weighting spreads ties uniformly, shifts share about 2:1 on a one-request queue difference, and effectively starves deeply-queued sources. The waiting queue is a proxy for engine-step responsiveness (a busy engine serves its P2P session slower), not for pull-serve fanout; the hash spread is what distributes pull traffic among equally-suitable sources. After scheduling it compares the chosen peer against the pod that will compute the prefix — the `prefill` profile target under P/D disaggregation, otherwise the primary target — and sets the header only when the peer out-caches the computing pod by at least `minCachedTokenDelta` tokens. Any inbound value of the header is removed. When no peer out-caches the computing pod, the request proceeds unchanged.
88

99
**Parameters:**
1010

pkg/epp/framework/plugins/requestcontrol/dataproducer/p2psource/producer.go

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
// Package p2psource emits the KV cache source header: the candidate pod
18-
// holding the most cached prefix KV blocks for the request, for the routing
19-
// sidecar to pull from over the P2P connector instead of recomputing them.
17+
// Package p2psource emits the KV cache source header: a candidate pod
18+
// within one block of the most cached prefix KV blocks for the request, for
19+
// the routing sidecar to pull from over the P2P connector instead of
20+
// recomputing them.
2021
package p2psource
2122

2223
import (
@@ -70,11 +71,11 @@ var (
7071
_ requestcontrol.PreRequest = &Producer{}
7172
)
7273

73-
// Producer stashes the candidate endpoint holding the most cached prefix
74-
// tokens during Produce, and in PreRequest sets routing.KVCacheSourceHeader
75-
// to that peer when it out-caches the pod computing the prefix (the prefill
76-
// endpoint under P/D disaggregation, the primary endpoint otherwise) by at
77-
// least minCachedTokenDelta tokens.
74+
// Producer stashes a pull-source candidate holding within one block of the
75+
// most cached prefix tokens during Produce, and in PreRequest sets
76+
// routing.KVCacheSourceHeader to that peer when it out-caches the pod
77+
// computing the prefix (the prefill endpoint under P/D disaggregation, the
78+
// primary endpoint otherwise) by at least minCachedTokenDelta tokens.
7879
type Producer struct {
7980
typedName plugin.TypedName
8081
prefixMatchDataKey plugin.DataKey
@@ -129,9 +130,10 @@ func (p *Producer) Consumes() plugin.DataDependencies {
129130
}
130131
}
131132

132-
// bestMatchPeer is the candidate endpoint holding the most cached prompt
133-
// tokens for the request, stashed as a request attribute so PreRequest can
134-
// compare it against the scheduled endpoint.
133+
// bestMatchPeer is the pull-source candidate chosen during Produce, stashed
134+
// as a request attribute so PreRequest can compare it against the scheduled
135+
// endpoint. cachedTokens is the chosen peer's own count, which may be one
136+
// block below the pool maximum.
135137
type bestMatchPeer struct {
136138
hostPort string
137139
cachedTokens int
@@ -144,17 +146,20 @@ func (p *Producer) attrKey() string {
144146
}
145147

146148
// Produce reads each candidate's PrefixCacheMatchInfo and stashes the chosen
147-
// source on the request: among the endpoints holding the most cached prompt
148-
// tokens, one is sampled with probability proportional to 1/(1+waiting
149-
// queue), using a request-ID hash as the sampling coordinate. Load-blind
150-
// argmax alone would send every consumer of a widely-replicated prefix to
151-
// the same peer (equal counts lose to iteration order), concentrating pull
152-
// traffic on one source. A hard minimum-queue rule would too: metrics
153-
// refresh once per scrape interval, so a stale one-request difference would
154-
// herd a whole window of requests onto the single "idle" peer. Proportional
155-
// weights spread exact ties uniformly, mildly prefer a one-request-shorter
156-
// queue, and starve deeply-queued sources. No-op when no candidate holds any
157-
// cached block.
149+
// source on the request: among the endpoints within one block of the most
150+
// cached prompt tokens, one is sampled with probability proportional to
151+
// 1/(1+waiting queue), using a request-ID hash as the sampling coordinate.
152+
// Load-blind argmax alone would send every consumer of a widely-replicated
153+
// prefix to the same peer (equal counts lose to iteration order),
154+
// concentrating pull traffic on one source. A hard minimum-queue rule would
155+
// too: metrics refresh once per scrape interval, so a stale one-request
156+
// difference would herd a whole window of requests onto the single "idle"
157+
// peer. The one-block band keeps exact-count argmax from re-concentrating
158+
// the moment one replica drifts a block ahead: a peer one block short costs
159+
// the destination a single block of recompute, noise next to a queue-depth
160+
// difference on the source. Proportional weights spread ties uniformly,
161+
// mildly prefer a one-request-shorter queue, and starve deeply-queued
162+
// sources. No-op when no candidate holds any cached block.
158163
func (p *Producer) Produce(ctx context.Context, request *scheduling.InferenceRequest, endpoints []scheduling.Endpoint) error {
159164
maxCached := 0
160165
for _, ep := range endpoints {
@@ -166,14 +171,17 @@ func (p *Producer) Produce(ctx context.Context, request *scheduling.InferenceReq
166171
best := bestMatchPeer{}
167172
if maxCached > 0 {
168173
var candidates []scheduling.Endpoint
174+
var cachedCounts []int
169175
var weights []float64
170176
total := 0.0
171177
for _, ep := range endpoints {
172-
if ep.GetMetadata() == nil || p.cachedTokenCount(ep) != maxCached {
178+
cached, blockSize := p.cachedTokens(ep)
179+
if ep.GetMetadata() == nil || cached == 0 || cached+blockSize < maxCached {
173180
continue
174181
}
175182
w := 1.0 / (1.0 + float64(waitingQueueSize(ep)))
176183
candidates = append(candidates, ep)
184+
cachedCounts = append(cachedCounts, cached)
177185
weights = append(weights, w)
178186
total += w
179187
}
@@ -190,7 +198,7 @@ func (p *Producer) Produce(ctx context.Context, request *scheduling.InferenceReq
190198
md := candidates[chosen].GetMetadata()
191199
best = bestMatchPeer{
192200
hostPort: net.JoinHostPort(md.Address, md.Port),
193-
cachedTokens: maxCached,
201+
cachedTokens: cachedCounts[chosen],
194202
}
195203
}
196204
}
@@ -273,17 +281,24 @@ func (p *Producer) PreRequest(ctx context.Context, request *scheduling.Inference
273281
logger.Info("set KV cache source header", "requestID", request.RequestID, "value", best.hostPort)
274282
}
275283

276-
// cachedTokenCount returns the endpoint's cached prompt tokens (unweighted
277-
// cached-block count times the block size) from its PrefixCacheMatchInfo,
278-
// or 0 when absent.
279-
func (p *Producer) cachedTokenCount(ep scheduling.Endpoint) int {
284+
// cachedTokens returns the endpoint's cached prompt tokens (unweighted
285+
// cached-block count times the block size) and its block size from its
286+
// PrefixCacheMatchInfo, or zeros when absent.
287+
func (p *Producer) cachedTokens(ep scheduling.Endpoint) (tokens, blockSize int) {
280288
raw, ok := ep.Get(p.prefixMatchDataKey.String())
281289
if !ok {
282-
return 0
290+
return 0, 0
283291
}
284292
info, ok := raw.(*attrprefix.PrefixCacheMatchInfo)
285293
if !ok {
286-
return 0
294+
return 0, 0
287295
}
288-
return info.CachedBlockCount() * info.BlockSizeTokens()
296+
return info.CachedBlockCount() * info.BlockSizeTokens(), info.BlockSizeTokens()
297+
}
298+
299+
// cachedTokenCount returns the endpoint's cached prompt tokens, or 0 when
300+
// its PrefixCacheMatchInfo is absent.
301+
func (p *Producer) cachedTokenCount(ep scheduling.Endpoint) int {
302+
tokens, _ := p.cachedTokens(ep)
303+
return tokens
289304
}

pkg/epp/framework/plugins/requestcontrol/dataproducer/p2psource/producer_test.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -374,22 +374,51 @@ func TestProduce_NearTie_NoHerding(t *testing.T) {
374374
assert.Greater(t, busy, 600/6, "busy peer must not be starved: %v", picks)
375375
}
376376

377-
// A strictly-better-cached peer wins regardless of load: the tie-break
378-
// applies to equal counts only.
379-
func TestProduce_HigherCachedWinsOverIdle(t *testing.T) {
377+
// A peer more than one block ahead wins regardless of load: the queue
378+
// weighting applies within the one-block band only.
379+
func TestProduce_BeyondBandWinsOverIdle(t *testing.T) {
380380
ctx := utils.NewTestContext(t)
381381
p := New("test", Config{MinCachedTokenDelta: 1})
382382

383383
req := &scheduling.InferenceRequest{RequestID: "req-strict"}
384384
eps := []scheduling.Endpoint{
385-
endpointWithLoad(p, "pod-a", "10.0.0.1", 4, 20),
385+
endpointWithLoad(p, "pod-a", "10.0.0.1", 5, 20),
386386
endpointWithLoad(p, "pod-b", "10.0.0.2", 3, 0),
387387
}
388388
require.NoError(t, p.Produce(ctx, req, eps))
389389
best, ok := scheduling.ReadRequestAttribute[*bestMatchPeer](req, p.attrKey())
390390
require.True(t, ok)
391391
assert.Equal(t, "10.0.0.1:8080", best.hostPort)
392-
assert.Equal(t, 4*testBlockSize, best.cachedTokens)
392+
assert.Equal(t, 5*testBlockSize, best.cachedTokens)
393+
}
394+
395+
// A peer one block short of the maximum competes on queue weight: an idle
396+
// one-block-short peer takes most of the traffic from a deeply-queued
397+
// maximum, and the stashed count is the chosen peer's own.
398+
func TestProduce_OneBlockShort_SharesByQueue(t *testing.T) {
399+
ctx := utils.NewTestContext(t)
400+
p := New("test", Config{MinCachedTokenDelta: 1})
401+
402+
// Weights: pod-ahead 1/21, pod-short 1.
403+
eps := []scheduling.Endpoint{
404+
endpointWithLoad(p, "pod-ahead", "10.0.0.1", 4, 20),
405+
endpointWithLoad(p, "pod-short", "10.0.0.2", 3, 0),
406+
}
407+
picks := map[string]int{}
408+
for i := 0; i < 400; i++ {
409+
req := &scheduling.InferenceRequest{RequestID: fmt.Sprintf("band-%d", i)}
410+
require.NoError(t, p.Produce(ctx, req, eps))
411+
best, ok := scheduling.ReadRequestAttribute[*bestMatchPeer](req, p.attrKey())
412+
require.True(t, ok)
413+
picks[best.hostPort]++
414+
want := 4 * testBlockSize
415+
if best.hostPort == "10.0.0.2:8080" {
416+
want = 3 * testBlockSize
417+
}
418+
assert.Equal(t, want, best.cachedTokens)
419+
}
420+
assert.Greater(t, picks["10.0.0.2:8080"], picks["10.0.0.1:8080"], "idle one-block-short peer must lead: %v", picks)
421+
assert.Greater(t, picks["10.0.0.1:8080"], 0, "max-cached peer must keep some share: %v", picks)
393422
}
394423

395424
// Peers tied on cached count and queue depth: requests spread across them by
@@ -433,7 +462,7 @@ func TestProduce_NilMetrics_NeutralLoad(t *testing.T) {
433462

434463
req := &scheduling.InferenceRequest{RequestID: "req-nilmetrics"}
435464
eps := []scheduling.Endpoint{
436-
endpoint(p, "pod-a", "10.0.0.1", 2),
465+
endpoint(p, "pod-a", "10.0.0.1", 1),
437466
endpoint(p, "pod-b", "10.0.0.2", 3),
438467
}
439468
require.NoError(t, p.Produce(ctx, req, eps))

0 commit comments

Comments
 (0)