Skip to content

Commit 2769eed

Browse files
feat(prefixcache): index reported KV residency
Add a NATS event contract and exact-residency provider for backend KV cache reports. Keep guessed request observations as the default routing source while maintaining the reported index for future producers. Assisted-by: Codex:gpt-5
1 parent 2207677 commit 2769eed

6 files changed

Lines changed: 245 additions & 0 deletions

File tree

core/application/distributed.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,14 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
319319
return nil, fmt.Errorf("subscribing to %s: %w", messaging.SubjectPrefixCacheInvalidate, err)
320320
}
321321

322+
// Keep an exact-residency index current so backend producers can report
323+
// their real KV state without coupling to router internals. Routing stays
324+
// on the guessed provider until a backend producer is available.
325+
reportedIndex := prefixcache.NewReportedIndex()
326+
if _, err := messaging.SubscribeJSON(natsClient, messaging.SubjectPrefixCacheResidency, reportedIndex.Apply); err != nil {
327+
return nil, fmt.Errorf("subscribing to %s: %w", messaging.SubjectPrefixCacheResidency, err)
328+
}
329+
322330
// Background eviction: sweep idle entries on the app context. Stopped
323331
// when the app context is cancelled (mirrors the reconciler loop which
324332
// also runs on options.Context). TTL/2 keeps stale entries from

core/services/messaging/subjects.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,28 @@ const subjectSyncStatePrefix = "state."
478478
const (
479479
SubjectPrefixCacheObserve = "prefixcache.observe"
480480
SubjectPrefixCacheInvalidate = "prefixcache.invalidate"
481+
SubjectPrefixCacheResidency = "prefixcache.residency"
481482
)
482483

484+
// PrefixCacheOperation describes a backend-reported KV-cache residency change.
485+
type PrefixCacheOperation string
486+
487+
const (
488+
PrefixCacheStore PrefixCacheOperation = "store"
489+
PrefixCacheRemove PrefixCacheOperation = "remove"
490+
PrefixCacheClear PrefixCacheOperation = "clear"
491+
)
492+
493+
// PrefixCacheResidencyEvent reports exact backend KV-cache residency. Chain is
494+
// the compatible shallow-to-deep prefix hash chain used by the router.
495+
type PrefixCacheResidencyEvent struct {
496+
Operation PrefixCacheOperation `json:"operation"`
497+
Model string `json:"model"`
498+
NodeID string `json:"node_id"`
499+
Replica int `json:"replica"`
500+
Chain []uint64 `json:"chain,omitempty"`
501+
}
502+
483503
// PrefixCacheObserveEvent announces that the replica (NodeID, Replica) served a
484504
// request whose prefix chain ends at the given hashes for model. Chain is the
485505
// full shallow-to-deep hash chain so peers can insert the same path. Affinity is

core/services/messaging/subjects_prefixcache_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ var _ = Describe("PrefixCache subjects", func() {
1111
It("exposes stable subject constants", func() {
1212
Expect(messaging.SubjectPrefixCacheObserve).To(Equal("prefixcache.observe"))
1313
Expect(messaging.SubjectPrefixCacheInvalidate).To(Equal("prefixcache.invalidate"))
14+
Expect(messaging.SubjectPrefixCacheResidency).To(Equal("prefixcache.residency"))
15+
})
16+
17+
It("defines the reported residency event operations", func() {
18+
Expect([]messaging.PrefixCacheOperation{
19+
messaging.PrefixCacheStore,
20+
messaging.PrefixCacheRemove,
21+
messaging.PrefixCacheClear,
22+
}).To(Equal([]messaging.PrefixCacheOperation{"store", "remove", "clear"}))
1423
})
1524

1625
It("carries a replica index on the observe event", func() {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package prefixcache
2+
3+
import (
4+
"sort"
5+
"sync"
6+
"time"
7+
8+
"github.com/mudler/LocalAI/core/services/messaging"
9+
)
10+
11+
// ReportedIndex is an exact-residency Provider populated only by backend
12+
// events. Request routing observations intentionally do not mutate it.
13+
type ReportedIndex struct {
14+
mu sync.RWMutex
15+
residencies map[string]map[ReplicaKey][][]uint64
16+
}
17+
18+
func NewReportedIndex() *ReportedIndex {
19+
return &ReportedIndex{residencies: map[string]map[ReplicaKey][][]uint64{}}
20+
}
21+
22+
func (ix *ReportedIndex) Apply(event messaging.PrefixCacheResidencyEvent) {
23+
key := ReplicaKey{NodeID: event.NodeID, Replica: event.Replica}
24+
if event.Model == "" || key.NodeID == "" {
25+
return
26+
}
27+
28+
ix.mu.Lock()
29+
defer ix.mu.Unlock()
30+
byReplica := ix.residencies[event.Model]
31+
switch event.Operation {
32+
case messaging.PrefixCacheStore:
33+
if len(event.Chain) == 0 {
34+
return
35+
}
36+
if byReplica == nil {
37+
byReplica = map[ReplicaKey][][]uint64{}
38+
ix.residencies[event.Model] = byReplica
39+
}
40+
for _, chain := range byReplica[key] {
41+
if equalChain(chain, event.Chain) {
42+
return
43+
}
44+
}
45+
byReplica[key] = append(byReplica[key], append([]uint64(nil), event.Chain...))
46+
case messaging.PrefixCacheRemove:
47+
if byReplica == nil || len(event.Chain) == 0 {
48+
return
49+
}
50+
chains := byReplica[key]
51+
for i, chain := range chains {
52+
if equalChain(chain, event.Chain) {
53+
chains = append(chains[:i], chains[i+1:]...)
54+
break
55+
}
56+
}
57+
if len(chains) == 0 {
58+
delete(byReplica, key)
59+
} else {
60+
byReplica[key] = chains
61+
}
62+
case messaging.PrefixCacheClear:
63+
delete(byReplica, key)
64+
}
65+
}
66+
67+
func (ix *ReportedIndex) Decide(model string, chain []uint64, candidates []ReplicaKey, _ time.Time) PrefixDecision {
68+
order := append([]ReplicaKey(nil), candidates...)
69+
sort.Slice(order, func(i, j int) bool { return order[i].less(order[j]) })
70+
d := PrefixDecision{ColdOrder: order}
71+
if len(chain) == 0 {
72+
return d
73+
}
74+
75+
ix.mu.RLock()
76+
defer ix.mu.RUnlock()
77+
byReplica := ix.residencies[model]
78+
bestDepth := 0
79+
for _, key := range order {
80+
for _, reported := range byReplica[key] {
81+
depth := commonDepth(chain, reported)
82+
if depth > bestDepth {
83+
bestDepth = depth
84+
d.Hot = key
85+
d.HasHot = true
86+
}
87+
}
88+
}
89+
if d.HasHot {
90+
d.MatchRatio = float64(bestDepth) / float64(len(chain))
91+
}
92+
return d
93+
}
94+
95+
func (ix *ReportedIndex) Observe(string, []uint64, ReplicaKey, time.Time) bool { return false }
96+
97+
func (ix *ReportedIndex) Invalidate(model string, key ReplicaKey) {
98+
ix.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheClear, Model: model, NodeID: key.NodeID, Replica: key.Replica})
99+
}
100+
101+
func (ix *ReportedIndex) InvalidateNode(model, nodeID string) {
102+
ix.mu.Lock()
103+
defer ix.mu.Unlock()
104+
for key := range ix.residencies[model] {
105+
if key.NodeID == nodeID {
106+
delete(ix.residencies[model], key)
107+
}
108+
}
109+
}
110+
111+
func (ix *ReportedIndex) Evict(time.Time) {}
112+
113+
func equalChain(a, b []uint64) bool {
114+
if len(a) != len(b) {
115+
return false
116+
}
117+
for i := range a {
118+
if a[i] != b[i] {
119+
return false
120+
}
121+
}
122+
return true
123+
}
124+
125+
func commonDepth(a, b []uint64) int {
126+
depth := min(len(a), len(b))
127+
for i := range depth {
128+
if a[i] != b[i] {
129+
return i
130+
}
131+
}
132+
return depth
133+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package prefixcache_test
2+
3+
import (
4+
"time"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
"github.com/mudler/LocalAI/core/services/messaging"
10+
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
11+
)
12+
13+
var _ prefixcache.Provider = (*prefixcache.ReportedIndex)(nil)
14+
15+
var _ = Describe("ReportedIndex", func() {
16+
var idx *prefixcache.ReportedIndex
17+
18+
BeforeEach(func() { idx = prefixcache.NewReportedIndex() })
19+
20+
It("routes to the replica with the longest reported prefix", func() {
21+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheStore, Model: "m", NodeID: "A", Replica: 0, Chain: []uint64{1, 2}})
22+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheStore, Model: "m", NodeID: "B", Replica: 0, Chain: []uint64{1, 2, 3, 4}})
23+
d := idx.Decide("m", []uint64{1, 2, 3, 9}, []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}, t0)
24+
Expect(d.HasHot).To(BeTrue())
25+
Expect(d.Hot).To(Equal(rk("B", 0)))
26+
Expect(d.MatchRatio).To(Equal(0.75))
27+
})
28+
29+
It("removes only the announced residency", func() {
30+
for _, chain := range [][]uint64{{1, 2}, {7, 8}} {
31+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheStore, Model: "m", NodeID: "A", Replica: 0, Chain: chain})
32+
}
33+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheRemove, Model: "m", NodeID: "A", Replica: 0, Chain: []uint64{1, 2}})
34+
Expect(idx.Decide("m", []uint64{1, 2}, []prefixcache.ReplicaKey{rk("A", 0)}, t0).HasHot).To(BeFalse())
35+
Expect(idx.Decide("m", []uint64{7, 8}, []prefixcache.ReplicaKey{rk("A", 0)}, t0).HasHot).To(BeTrue())
36+
})
37+
38+
It("clears every residency for one replica only", func() {
39+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheStore, Model: "m", NodeID: "A", Replica: 0, Chain: []uint64{1, 2}})
40+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheStore, Model: "m", NodeID: "B", Replica: 0, Chain: []uint64{3, 4}})
41+
idx.Apply(messaging.PrefixCacheResidencyEvent{Operation: messaging.PrefixCacheClear, Model: "m", NodeID: "A", Replica: 0})
42+
candidates := []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}
43+
Expect(idx.Decide("m", []uint64{1, 2}, candidates, t0).HasHot).To(BeFalse())
44+
Expect(idx.Decide("m", []uint64{3, 4}, candidates, t0).Hot).To(Equal(rk("B", 0)))
45+
})
46+
47+
It("ignores guessed request observations and keeps cold ordering deterministic", func() {
48+
Expect(idx.Observe("m", []uint64{1, 2}, rk("A", 0), t0)).To(BeFalse())
49+
d := idx.Decide("m", []uint64{1, 2}, []prefixcache.ReplicaKey{rk("B", 1), rk("A", 1), rk("A", 0)}, time.Now())
50+
Expect(d.HasHot).To(BeFalse())
51+
Expect(d.ColdOrder).To(Equal([]prefixcache.ReplicaKey{rk("A", 0), rk("A", 1), rk("B", 1)}))
52+
})
53+
})

docs/content/features/distributed-mode.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,28 @@ Notes:
948948

949949
The scheduling algorithm above is load-based (least in-flight, then least-recently-used). Work is underway to make routing **prefix-cache-aware**: bias each request toward the replica that already holds the relevant KV/prefix cache (multi-turn conversations and shared system prompts), so backends reuse cache instead of recomputing it. The first step is a router-side radix tree of prompt-prefix hashes mapped to nodes, with longest-prefix match, a load guard that preserves round-robin behavior under imbalance, and NATS sync across frontends. It is purely a routing-layer hint (no backend changes) and never routes worse than today's round-robin.
950950

951+
Backends can report exact KV-cache residency on the `prefixcache.residency`
952+
NATS subject. The JSON event contract is:
953+
954+
```json
955+
{
956+
"operation": "store",
957+
"model": "model-name",
958+
"node_id": "worker-id",
959+
"replica": 0,
960+
"chain": [1203053429005847826, 15485907386658061715]
961+
}
962+
```
963+
964+
`operation` is `store`, `remove`, or `clear`. `store` adds the announced
965+
shallow-to-deep chain for one model replica, `remove` removes only that exact
966+
announced chain, and `clear` removes all reported residency for that model
967+
replica (and may omit `chain`). Producers must generate the chain with exactly
968+
the same windowing and hashing algorithm as the router; hashes from a different
969+
chain algorithm are not compatible and will never match requests correctly.
970+
Reported events populate the exact-residency provider, but the guessed provider
971+
remains the routing default until a backend producer is available.
972+
951973
Further enhancements, surfaced from a survey of SGLang, vLLM production-stack, Ray Serve, llm-d, AIBrix, and NVIDIA Dynamo, are tracked under the routing roadmap epic ([#10063](https://github.com/mudler/LocalAI/issues/10063)):
952974

953975
- **Reported/precise KV-event mode** ([#10064](https://github.com/mudler/LocalAI/issues/10064)): subscribe to actual backend KV-cache events for exact residency instead of inferring it from routing history.

0 commit comments

Comments
 (0)