Files
llmgw/test/e2e/prefix_test.go
T
Ilker ManapandClaude Opus 5 a075ff4120
CI / test (push) Successful in 1m19s
Add approximate prefix routing, and find out it did nothing
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>
2026-09-16 12:13:19 +02:00

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
}