Skip to content

Commit fed1791

Browse files
committed
feat: add balanceBy tokens option to burst prefix cache producer
Signed-off-by: Ezra Silvera <ezra@il.ibm.com>
1 parent b3ffd88 commit fed1791

6 files changed

Lines changed: 133 additions & 35 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ This producer emits `PrefixCacheMatchInfo`; it does not score. Reuse the
6161
| `maxPrefixTokensToMatch` | 0 | cap on matched prefix tokens; 0 uses the default block cap. A positive value must be >= `blockSizeTokens`, otherwise it yields zero prefix blocks. |
6262
| `minColocateBlocks` | 0 | min shared leading blocks for inter-unit co-location and for a single request to gain an affinity; 0 disables both (only identical groups are placed, purely load-balanced) |
6363
| `maxBatchSize` | 1000 | max requests one window may accumulate; Produce returns an error once reached. -1 = unlimited. |
64+
| `balanceBy` | `requests` | quantity balanced across replicas within a window: `requests` (by request count) or `tokens` (by prefix-block load, charging each unit's prefix once per replica and discounting the leading blocks already held there). Token balancing spreads long-prompt units so no replica saturates on stacked prefixes while request counts stay even. |
6465

6566
## Operational notes
6667

pkg/epp/framework/plugins/requestcontrol/dataproducer/burstprefix/assign.go

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ outer:
145145
// over the batch (longest-prefix first) so a shared prefix is prefilled once per
146146
// replica rather than scattered. Identical groups are placed before prefix-sharing
147147
// singletons so proven same-prompt co-location anchors the layout.
148-
func assign(entries []*entry, k, minColocateBlocks int) {
148+
func assign(entries []*entry, k, minColocateBlocks int, byTokens bool) {
149149
groups := map[string][]*entry{}
150150
order := []string{}
151151
var replicas []fwksched.Endpoint
@@ -169,18 +169,16 @@ func assign(entries []*entry, k, minColocateBlocks int) {
169169
shared := sharedPrefixKeys(groups, order, minColocateBlocks)
170170
var groupUnits, singleUnits []string
171171
counts := map[string]int{} // prefix-block count per placed unit, computed once
172-
total := 0
173172
for _, key := range order {
174173
switch {
175174
case len(groups[key]) >= 2:
176175
groupUnits = append(groupUnits, key)
177-
counts[key] = totalBlocks(groups[key][0].hashes)
178-
total += len(groups[key])
179176
case shared[key]:
180177
singleUnits = append(singleUnits, key)
181-
counts[key] = totalBlocks(groups[key][0].hashes)
182-
total += len(groups[key])
178+
default:
179+
continue
183180
}
181+
counts[key] = totalBlocks(groups[key][0].hashes)
184182
}
185183
if len(groupUnits) == 0 && len(singleUnits) == 0 {
186184
return
@@ -191,17 +189,28 @@ func assign(entries []*entry, k, minColocateBlocks int) {
191189
sortByPrefixLen(groupUnits, counts)
192190
sortByPrefixLen(singleUnits, counts)
193191

194-
maxShare := (total + len(replicas) - 1) / len(replicas) // ceil: equal samples per replica
192+
// total and load carry one unit: request count, or prefix blocks under token
193+
// balancing. Token totals sum full per-unit block counts (a loose fair-share
194+
// target); the shared-prefix discount is applied to load at placement time.
195+
total := 0
196+
for key := range counts {
197+
if byTokens {
198+
total += counts[key]
199+
} else {
200+
total += len(groups[key])
201+
}
202+
}
203+
maxShare := (total + len(replicas) - 1) / len(replicas) // ceil: fair share per replica
195204

196205
idx := newBatchIndex()
197-
load := map[string]int{} // batch-wide samples assigned per replica
206+
load := map[string]int{} // per-replica load in the balancing unit (requests or blocks)
198207
// Groups first, then prefix-sharing singletons: same order as before, without
199208
// allocating a combined slice.
200209
for _, key := range groupUnits {
201-
placeGroup(groups[key], replicas, k, minColocateBlocks, maxShare, idx, load)
210+
placeGroup(groups[key], replicas, k, minColocateBlocks, maxShare, counts[key], idx, load, byTokens)
202211
}
203212
for _, key := range singleUnits {
204-
placeGroup(groups[key], replicas, k, minColocateBlocks, maxShare, idx, load)
213+
placeGroup(groups[key], replicas, k, minColocateBlocks, maxShare, counts[key], idx, load, byTokens)
205214
}
206215
}
207216

@@ -217,19 +226,22 @@ func sortByPrefixLen(keys []string, counts map[string]int) {
217226
// preference bounded by the fair-share cap; remaining samples (when k caps the
218227
// group) spill to least-loaded replicas. The unit's blocks are then recorded so
219228
// later units can match against it.
220-
func placeGroup(members []*entry, replicas []fwksched.Endpoint, k, minColocateBlocks, maxShare int, idx *batchIndex, load map[string]int) {
229+
func placeGroup(members []*entry, replicas []fwksched.Endpoint, k, minColocateBlocks, maxShare, cost int, idx *batchIndex, load map[string]int, byTokens bool) {
221230
if len(replicas) == 0 {
222231
return
223232
}
224233
hashes := members[0].hashes // identical group: any member represents it
225234

226235
var matches map[string]int
227236
preferMatch := minColocateBlocks > 0
228-
if preferMatch {
237+
// Token balancing also needs the shared-prefix length per replica: a replica
238+
// only prefills the blocks it does not already hold, so its load is charged the
239+
// remaining cost rather than the full prefix.
240+
if preferMatch || byTokens {
229241
matches = idx.longestPrefix(hashes)
230242
}
231243

232-
perReplica := map[string]int{} // samples of this group already on a replica
244+
perReplica := map[string]int{} // requests of this unit already on a replica
233245
i := 0
234246
for i < len(members) {
235247
target := pickReplica(replicas, perReplica, k, load, matches, minColocateBlocks, maxShare, preferMatch)
@@ -248,8 +260,20 @@ func placeGroup(members []*entry, replicas []fwksched.Endpoint, k, minColocateBl
248260
for j := 0; j < run; j++ {
249261
members[i+j].assigned = target
250262
}
263+
firstTouch := perReplica[name] == 0
251264
perReplica[name] += run
252-
load[name] += run
265+
switch {
266+
case !byTokens:
267+
load[name] += run
268+
case firstTouch:
269+
// Charge the prefix once per replica this unit lands on, discounting the
270+
// leading blocks already held there (prefilled by an earlier request).
271+
inc := cost - matches[name]
272+
if inc < 0 {
273+
inc = 0
274+
}
275+
load[name] += inc
276+
}
253277
i += run
254278
preferMatch = false // only the primary replica gets the prefix preference
255279
}
@@ -322,12 +346,13 @@ func pickLeastLoaded(replicas []fwksched.Endpoint, perReplica map[string]int, k
322346
return best
323347
}
324348

325-
// less reports whether replica a should be preferred over replica b: fewer
326-
// assigned samples first, then fewer running requests, then lower name. The
327-
// batch-local assigned-sample count (load) is the primary signal; running
328-
// requests only break ties within a batch and deliberately differ from the
329-
// load-aware-scorer's WaitingQueueSize signal, since placement balances work
330-
// already committed in this window rather than queue depth observed downstream.
349+
// less reports whether replica a should be preferred over replica b: lower
350+
// assigned load first, then fewer running requests, then lower name. The
351+
// batch-local load (assigned requests, or prefix blocks under token balancing) is
352+
// the primary signal; running requests only break ties within a batch and
353+
// deliberately differ from the load-aware-scorer's WaitingQueueSize signal, since
354+
// placement balances work already committed in this window rather than queue depth
355+
// observed downstream.
331356
func less(a fwksched.Endpoint, aName string, load map[string]int, b fwksched.Endpoint, bName string) bool {
332357
if load[aName] != load[bName] {
333358
return load[aName] < load[bName]

pkg/epp/framework/plugins/requestcontrol/dataproducer/burstprefix/assign_test.go

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func TestAssign_UnlimitedColocatesWholeGroup(t *testing.T) {
7474
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2"), testEndpoint("pod3"), testEndpoint("pod4")}
7575
entries := group(8, []prefixhash.BlockHash{1, 2, 3}, replicas)
7676

77-
assign(entries, unlimitedPerReplica, 0)
77+
assign(entries, unlimitedPerReplica, 0, false)
7878

7979
for _, e := range entries {
8080
assert.Equal(t, "pod1", assignedName(e), "all samples of one group must co-locate when k=-1")
@@ -85,7 +85,7 @@ func TestAssign_CapSpreadsEvenly(t *testing.T) {
8585
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2"), testEndpoint("pod3"), testEndpoint("pod4")}
8686
entries := group(8, []prefixhash.BlockHash{1, 2, 3}, replicas)
8787

88-
assign(entries, 2, 0)
88+
assign(entries, 2, 0, false)
8989

9090
assert.Equal(t, map[string]int{"pod1": 2, "pod2": 2, "pod3": 2, "pod4": 2}, counts(entries),
9191
"k=2 over 8 samples and 4 replicas must place 2 per replica")
@@ -95,7 +95,7 @@ func TestAssign_CapFillsBeforeSpilling(t *testing.T) {
9595
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2"), testEndpoint("pod3"), testEndpoint("pod4")}
9696
entries := group(8, []prefixhash.BlockHash{1, 2, 3}, replicas)
9797

98-
assign(entries, 4, 0)
98+
assign(entries, 4, 0, false)
9999

100100
// k=4 fills one replica to 4 before using the next; only two replicas used.
101101
assert.Equal(t, map[string]int{"pod1": 4, "pod2": 4}, counts(entries))
@@ -105,7 +105,7 @@ func TestAssign_SingletonGetsNoAffinity(t *testing.T) {
105105
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2")}
106106
entries := group(1, []prefixhash.BlockHash{1, 2, 3}, replicas)
107107

108-
assign(entries, unlimitedPerReplica, 0)
108+
assign(entries, unlimitedPerReplica, 0, false)
109109

110110
assert.Nil(t, entries[0].assigned, "a singleton group has no reuse and must not receive an affinity")
111111
}
@@ -117,7 +117,7 @@ func TestAssign_EmptyPrefixGetsNoAffinity(t *testing.T) {
117117
{hashes: nil, pods: replicas},
118118
}
119119

120-
assign(entries, unlimitedPerReplica, 0)
120+
assign(entries, unlimitedPerReplica, 0, false)
121121

122122
for _, e := range entries {
123123
assert.Nil(t, e.assigned, "requests with no prefix must not be grouped")
@@ -130,7 +130,7 @@ func TestAssign_DistinctGroupsSpreadAcrossReplicas(t *testing.T) {
130130
groupB := group(8, []prefixhash.BlockHash{9, 9, 9}, replicas)
131131
entries := append(append([]*entry{}, groupA...), groupB...)
132132

133-
assign(entries, unlimitedPerReplica, 0)
133+
assign(entries, unlimitedPerReplica, 0, false)
134134

135135
// Each group co-locates, and the second group lands on a different, less
136136
// loaded replica than the first.
@@ -154,7 +154,7 @@ func TestAssign_SharedPrefixFamiliesColocateWithinFairShare(t *testing.T) {
154154
y2 := group(2, []prefixhash.BlockHash{7, 8, 6}, replicas)
155155
entries := concat(x1, x2, y1, y2)
156156

157-
assign(entries, unlimitedPerReplica, 2)
157+
assign(entries, unlimitedPerReplica, 2, false)
158158

159159
// Each family co-locates onto one replica, the two families land on
160160
// different replicas, and the batch stays balanced (4 samples per replica).
@@ -174,7 +174,7 @@ func TestAssign_FairShareSpreadsPrefixSharingGroups(t *testing.T) {
174174
g4 := group(2, []prefixhash.BlockHash{1, 2, 6}, replicas)
175175
entries := concat(g1, g2, g3, g4)
176176

177-
assign(entries, unlimitedPerReplica, 2)
177+
assign(entries, unlimitedPerReplica, 2, false)
178178

179179
assert.Equal(t, map[string]int{"pod1": 2, "pod2": 2, "pod3": 2, "pod4": 2}, counts(entries),
180180
"the fair-share cap must spread prefix-sharing groups, not stampede them onto one replica")
@@ -190,7 +190,7 @@ func TestAssign_SingletonAttachesToOverlappingGroup(t *testing.T) {
190190
sy := group(1, []prefixhash.BlockHash{7, 8, 9}, replicas)
191191
entries := concat(gx, sx, gy, sy)
192192

193-
assign(entries, unlimitedPerReplica, 2)
193+
assign(entries, unlimitedPerReplica, 2, false)
194194

195195
// The identical group stays whole, and the overlapping singleton attaches to
196196
// the replica holding the group it shares a prefix with.
@@ -206,7 +206,7 @@ func TestAssign_LoneSingletonGetsNoAffinity(t *testing.T) {
206206
lone := group(1, []prefixhash.BlockHash{5, 5, 5}, replicas) // overlaps nothing
207207
entries := concat(g, lone)
208208

209-
assign(entries, unlimitedPerReplica, 2)
209+
assign(entries, unlimitedPerReplica, 2, false)
210210

211211
assert.Nil(t, lone[0].assigned, "a request overlapping no other must keep no affinity")
212212
assert.Equal(t, assignedName(g[0]), assignedName(g[1]), "the identical group still co-locates")
@@ -222,7 +222,7 @@ func TestAssign_OverlappingSingletonsColocateWithoutGroups(t *testing.T) {
222222
s4 := group(1, []prefixhash.BlockHash{1, 2, 6}, replicas)
223223
entries := concat(s1, s2, s3, s4)
224224

225-
assign(entries, unlimitedPerReplica, 2)
225+
assign(entries, unlimitedPerReplica, 2, false)
226226

227227
for _, e := range entries {
228228
assert.NotNil(t, e.assigned, "overlapping singletons receive an affinity even without identical groups")
@@ -240,7 +240,7 @@ func TestAssign_SharedPrefixBelowThresholdDoesNotColocate(t *testing.T) {
240240
y2 := group(2, []prefixhash.BlockHash{7, 8, 6}, replicas)
241241
entries := concat(x1, x2, y1, y2)
242242

243-
assign(entries, unlimitedPerReplica, 3)
243+
assign(entries, unlimitedPerReplica, 3, false)
244244

245245
// The shared prefix falls short of the threshold, so groups are placed purely
246246
// by load and a family is not kept together.
@@ -257,21 +257,66 @@ func TestAssign_SingleReplicaPlacesEverything(t *testing.T) {
257257
groupB := group(4, []prefixhash.BlockHash{9, 9, 9}, replicas)
258258
entries := concat(groupA, groupB)
259259

260-
assign(entries, unlimitedPerReplica, 2)
260+
assign(entries, unlimitedPerReplica, 2, false)
261261

262262
for _, e := range entries {
263263
assert.Equal(t, "pod1", assignedName(e), "with a single replica every request must land on it")
264264
}
265265
}
266266

267+
func TestAssign_BalanceByTokensSpreadsLargePrefix(t *testing.T) {
268+
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2")}
269+
// One long-prefix unit and two short-prefix units with distinct prefixes.
270+
// Balancing by request count puts a short unit on the same replica as the long
271+
// one (block-imbalanced); balancing by tokens keeps the long unit alone and
272+
// packs the short ones onto the other replica.
273+
long := group(2, []prefixhash.BlockHash{1, 2, 3, 4}, replicas)
274+
short1 := group(2, []prefixhash.BlockHash{5}, replicas)
275+
short2 := group(2, []prefixhash.BlockHash{6}, replicas)
276+
277+
byReq := concat(long, short1, short2)
278+
assign(byReq, unlimitedPerReplica, 0, false)
279+
assert.Equal(t, assignedName(long[0]), assignedName(short2[0]),
280+
"balancing by requests colocates a short unit with the long-prefix unit")
281+
282+
long = group(2, []prefixhash.BlockHash{1, 2, 3, 4}, replicas)
283+
short1 = group(2, []prefixhash.BlockHash{5}, replicas)
284+
short2 = group(2, []prefixhash.BlockHash{6}, replicas)
285+
byTok := concat(long, short1, short2)
286+
assign(byTok, unlimitedPerReplica, 0, true)
287+
assert.NotEqual(t, assignedName(long[0]), assignedName(short1[0]),
288+
"balancing by tokens keeps the long-prefix unit off the replica holding the short ones")
289+
assert.Equal(t, assignedName(short1[0]), assignedName(short2[0]),
290+
"the short units pack onto one replica under token balancing")
291+
}
292+
293+
func TestAssign_BalanceByTokensDiscountsSharedPrefix(t *testing.T) {
294+
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2")}
295+
// Three units sharing a 9-block leading prefix. Charging each its full 10-block
296+
// prefix would push the third off the shared replica once the fair-share cap is
297+
// hit; discounting the already-prefilled 9 blocks keeps all three colocated so
298+
// the shared prefix is prefilled once.
299+
a1 := group(2, []prefixhash.BlockHash{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, replicas)
300+
a2 := group(2, []prefixhash.BlockHash{1, 2, 3, 4, 5, 6, 7, 8, 9, 11}, replicas)
301+
a3 := group(2, []prefixhash.BlockHash{1, 2, 3, 4, 5, 6, 7, 8, 9, 12}, replicas)
302+
entries := concat(a1, a2, a3)
303+
304+
assign(entries, unlimitedPerReplica, 9, true)
305+
306+
assert.Equal(t, assignedName(a1[0]), assignedName(a2[0]),
307+
"units sharing a prefix colocate when the shared prefill is not double-charged")
308+
assert.Equal(t, assignedName(a2[0]), assignedName(a3[0]),
309+
"the shared-prefix discount keeps the third unit on the shared replica")
310+
}
311+
267312
func TestAssign_OverflowGuardBalancesBeyondCap(t *testing.T) {
268313
replicas := []fwksched.Endpoint{testEndpoint("pod1"), testEndpoint("pod2")}
269314
// One group of 8 with k=2 over 2 replicas: k*replicas = 4 < 8, so the
270315
// per-replica cap cannot hold the whole group. The capLeft overflow guard must
271316
// still place every member and keep the batch balanced.
272317
entries := group(8, []prefixhash.BlockHash{1, 2, 3}, replicas)
273318

274-
assign(entries, 2, 0)
319+
assign(entries, 2, 0, false)
275320

276321
for _, e := range entries {
277322
assert.NotNil(t, e.assigned, "the overflow guard must still assign every member")

pkg/epp/framework/plugins/requestcontrol/dataproducer/burstprefix/plugin.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ func newDataProducer(_ context.Context, name string, cfg config) (*dataProducer,
112112
if cfg.MaxBatchSize != unlimitedBatchSize && cfg.MaxBatchSize < 1 {
113113
return nil, fmt.Errorf("invalid configuration: maxBatchSize must be -1 (unlimited) or >= 1 (current value: %d)", cfg.MaxBatchSize)
114114
}
115+
if cfg.BalanceBy == "" {
116+
cfg.BalanceBy = balanceByRequests
117+
}
118+
if cfg.BalanceBy != balanceByRequests && cfg.BalanceBy != balanceByTokens {
119+
return nil, fmt.Errorf("invalid configuration: balanceBy must be %q or %q (current value: %q)", balanceByRequests, balanceByTokens, cfg.BalanceBy)
120+
}
115121

116122
maxBlocks := defaultMaxPrefixBlocks
117123
if cfg.MaxPrefixTokensToMatch > 0 {
@@ -192,7 +198,7 @@ func (p *dataProducer) seal(b *batch) {
192198
entries := b.entries
193199
p.mu.Unlock()
194200

195-
assign(entries, p.config.MaxPerReplica, p.config.MinColocateBlocks)
201+
assign(entries, p.config.MaxPerReplica, p.config.MinColocateBlocks, p.config.BalanceBy == balanceByTokens)
196202
close(b.sealed)
197203
}
198204

pkg/epp/framework/plugins/requestcontrol/dataproducer/burstprefix/plugin_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ func TestNew_RejectsInvalidConfig(t *testing.T) {
6262

6363
_, err = newDataProducer(context.Background(), "burst", config{WindowDurationMs: 100, MaxPerReplica: -1, BlockSizeTokens: 0})
6464
assert.Error(t, err, "blockSizeTokens must be > 0")
65+
66+
_, err = newDataProducer(context.Background(), "burst", config{WindowDurationMs: 100, MaxPerReplica: -1, BlockSizeTokens: 64, BalanceBy: "bogus"})
67+
assert.Error(t, err, "balanceBy must be requests or tokens")
6568
}
6669

6770
func TestProduce_ColocatesIdenticalPromptBurst(t *testing.T) {

pkg/epp/framework/plugins/requestcontrol/dataproducer/burstprefix/types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ const (
5151
maxWindowDurationMs = 10000
5252
)
5353

54+
// balanceMode selects the quantity balanced across replicas within a window.
55+
type balanceMode string
56+
57+
const (
58+
// balanceByRequests balances placement by request count (the default).
59+
balanceByRequests balanceMode = "requests"
60+
// balanceByTokens balances placement by prefix-block (token) load, charging a
61+
// prefix only the blocks a replica must still prefill - blocks already held from
62+
// an earlier request sharing that prefix are free. Spreads long-prompt requests
63+
// so no replica saturates on stacked prefixes while shorter ones pack together.
64+
balanceByTokens balanceMode = "tokens"
65+
)
66+
5467
// config defines the configuration for the burst prefix cache producer.
5568
type config struct {
5669
// WindowDurationMs is the batch window T in milliseconds. Requests arriving
@@ -78,6 +91,10 @@ type config struct {
7891
// is reached, Produce returns an error instead of letting a sustained burst or
7992
// a long window grow the batch unbounded. -1 disables the cap.
8093
MaxBatchSize int `json:"maxBatchSize"`
94+
// BalanceBy selects the quantity balanced across replicas within a window:
95+
// "requests" (default) or "tokens". See balanceMode. An empty value defaults
96+
// to "requests".
97+
BalanceBy balanceMode `json:"balanceBy"`
8198
}
8299

83100
// defaultConfig provides sensible defaults for the burst prefix cache producer.
@@ -88,6 +105,7 @@ var defaultConfig = config{
88105
MaxPrefixTokensToMatch: 0,
89106
MinColocateBlocks: defaultMinColocateBlocks,
90107
MaxBatchSize: defaultMaxBatchSize,
108+
BalanceBy: balanceByRequests,
91109
}
92110

93111
// entry is one request collected into a batch window.

0 commit comments

Comments
 (0)