Tier 1 of plan §7.5: a radix tree of request prefixes that estimates which replica has already prefilled one. Bytes, not tokens — needing the engine's exact tokenizer is what makes tier 2 a separate project. It went in inert, and the decision metric is what caught it: 144 lookups, zero hits. The cause was a rule I had written and tested for. A node's label is the first request that created it, so the second request for the same system prompt diverges in its last few bytes — a new question after the same preamble, the single most common shape there is. Requiring the whole label to match threw away the 700 bytes they shared. What matters is the shared head, because that is what the engine cached, and a partial label match now counts. Two unit tests asserted the wrong rule and have been inverted. With that fixed, measured end to end against engines whose caches are too small for the working set: 22.5% cache hits without, 81.7% with, and the breakdown shows the misses are exactly the first round, when the tree is empty. The scenario matters as much as the number. With a handful of prompts and a large cache, scattering is free — every replica ends up holding everything — and prefix routing can only add constraint. The first version of the test measured that case and showed a loss, which was a true statement about a situation the feature is not for. Two things keep it from being the disaster prefix routing usually is. The imbalance guard, which needs both an absolute and a relative gap before it fires, because either alone misfires — absolute on an idle cluster, relative on a busy one. And it is a scorer rather than a filter: affinity is a vote the queue and inflight scorers can outvote, not a rule. The tree records the replica the picker actually chose, not the one the scorer proposed, and a node prefers the replica it has seen most rather than most recently — a count is stable under a randomizing picker, where recency just chases whatever the picker last did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
6.3 KiB
Go
212 lines
6.3 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
)
|
|
|
|
// tenants is how many distinct system prompts the load uses.
|
|
//
|
|
// Enough that the working set does not fit in one replica's cache, which is the
|
|
// condition tier 1 exists for. With a handful of prompts and a large cache,
|
|
// scattering is free — every replica ends up holding everything — and prefix
|
|
// routing can only add constraint. Measured that way it made cache hits worse,
|
|
// which is a true statement about a scenario the feature is not for.
|
|
const tenants = 24
|
|
|
|
func systemPrompt(i int) string {
|
|
return fmt.Sprintf("SYSTEM PROMPT %d. %s ", i,
|
|
strings.Repeat("You are a careful assistant with detailed instructions. ", 12))
|
|
}
|
|
|
|
// askAll sends one request per tenant, repeated, and reports how many prompt
|
|
// tokens the engines served from cache.
|
|
func askAll(t *testing.T, h *harness, rounds int) (cached, prompt int64) {
|
|
t.Helper()
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
|
|
for round := range rounds {
|
|
for tenant := range tenants {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
body, _ := json.Marshal(map[string]any{
|
|
"model": testModel,
|
|
"max_tokens": 4,
|
|
"messages": []map[string]string{
|
|
{"role": "system", "content": systemPrompt(tenant)},
|
|
{"role": "user", "content": fmt.Sprintf("question %d", round)},
|
|
},
|
|
})
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
h.router.URL+"/v1/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
var out struct {
|
|
Usage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
PromptTokensDetails struct {
|
|
CachedTokens int `json:"cached_tokens"`
|
|
} `json:"prompt_tokens_details"`
|
|
} `json:"usage"`
|
|
}
|
|
if json.Unmarshal(raw, &out) != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
cached += int64(out.Usage.PromptTokensDetails.CachedTokens)
|
|
prompt += int64(out.Usage.PromptTokens)
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
return cached, prompt
|
|
}
|
|
|
|
// TestPrefixRoutingRaisesCacheHits is the measurement the tier exists for.
|
|
//
|
|
// Many tenants with distinct system prompts against four replicas whose caches
|
|
// are too small to hold all of them. Without prefix awareness a tenant's
|
|
// requests scatter, each replica re-prefills the same prompt, and they evict
|
|
// each other's; with it a tenant converges on the replica that already has it.
|
|
func TestPrefixRoutingRaisesCacheHits(t *testing.T) {
|
|
tight := []engineOpt{tightPrefixCache}
|
|
|
|
withoutPrefix := startTuned(t, 4, tight, func(c *config.Server) {
|
|
c.Router.DefaultProfile = "long-context"
|
|
})
|
|
baseCached, basePrompt := askAll(t, withoutPrefix, 6)
|
|
|
|
withPrefix := startTuned(t, 4, tight, func(c *config.Server) {
|
|
c.Router.DefaultProfile = "long-context"
|
|
c.Router.Prefix = config.Prefix{
|
|
Enabled: true, MinMatch: 32, MatchRatio: 0.2,
|
|
BalanceAbs: 8, BalanceRel: 1.5,
|
|
}
|
|
})
|
|
prefixCached, prefixPrompt := askAll(t, withPrefix, 6)
|
|
|
|
if basePrompt == 0 || prefixPrompt == 0 {
|
|
t.Fatal("no requests completed")
|
|
}
|
|
baseRate := float64(baseCached) / float64(basePrompt)
|
|
prefixRate := float64(prefixCached) / float64(prefixPrompt)
|
|
t.Logf("cache hit rate: %.1f%% without prefix routing, %.1f%% with",
|
|
baseRate*100, prefixRate*100)
|
|
t.Logf("prefix decisions: %v", prefixDecisions(t, withPrefix))
|
|
|
|
if prefixRate <= baseRate {
|
|
t.Errorf("prefix routing did not improve cache hits: %.3f vs %.3f", prefixRate, baseRate)
|
|
}
|
|
}
|
|
|
|
// TestPrefixRoutingDoesNotDrownOneReplica is the other half, and the reason the
|
|
// guard exists. A cache hit rate bought by sending everything to one replica is
|
|
// not a win.
|
|
func TestPrefixRoutingDoesNotDrownOneReplica(t *testing.T) {
|
|
h := start(t, 4, func(c *config.Server) {
|
|
c.Router.DefaultProfile = "long-context"
|
|
c.Router.Prefix = config.Prefix{
|
|
Enabled: true, MinMatch: 32, MatchRatio: 0.2,
|
|
BalanceAbs: 8, BalanceRel: 1.5,
|
|
}
|
|
})
|
|
|
|
// One tenant, hammered: the shape that makes affinity dangerous.
|
|
var wg sync.WaitGroup
|
|
for range 60 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
body, _ := json.Marshal(map[string]any{
|
|
"model": testModel,
|
|
"max_tokens": 8,
|
|
"messages": []map[string]string{
|
|
{"role": "system", "content": systemPrompt(0)},
|
|
{"role": "user", "content": "go"},
|
|
},
|
|
})
|
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
h.router.URL+"/v1/chat/completions", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// Every replica should have done some of it. PrefixQueries counts requests
|
|
// the engine looked up, which is one per request it was given.
|
|
var served int
|
|
for _, e := range h.engines {
|
|
if e.eng.Stats().PrefixQueries > 0 {
|
|
served++
|
|
}
|
|
}
|
|
if served < 2 {
|
|
t.Errorf("only %d of %d replicas served anything; affinity drowned the rest",
|
|
served, len(h.engines))
|
|
}
|
|
}
|
|
|
|
// prefixDecisions reads the router's own count of which branch each prefix
|
|
// lookup took. This is the metric that exists so tuning is not guesswork.
|
|
func prefixDecisions(t *testing.T, h *harness) map[string]int {
|
|
t.Helper()
|
|
mux, err := h.rt.AdminHandler()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/metrics", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
out := map[string]int{}
|
|
for _, line := range strings.Split(string(body), "\n") {
|
|
if !strings.HasPrefix(line, "llmgw_prefix_decisions_total{") {
|
|
continue
|
|
}
|
|
name := line[strings.Index(line, `"`)+1 : strings.LastIndex(line, `"`)]
|
|
fields := strings.Fields(line)
|
|
n, _ := strconv.Atoi(strings.TrimSuffix(fields[len(fields)-1], ".0"))
|
|
if f, err := strconv.ParseFloat(fields[len(fields)-1], 64); err == nil {
|
|
n = int(f)
|
|
}
|
|
out[name] = n
|
|
}
|
|
return out
|
|
}
|