diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6721dd4..65ed218 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,15 +16,15 @@ jobs: go-version: "1.26" cache: true - - name: Biçim kontrolü + - name: Check formatting run: | out=$(gofmt -l .) - if [ -n "$out" ]; then echo "gofmt gerekli:"; echo "$out"; exit 1; fi + if [ -n "$out" ]; then echo "gofmt needed on:"; echo "$out"; exit 1; fi - name: Vet run: go vet ./... - - name: Test (race detector ile) + - name: Test with the race detector run: go test -race ./... - name: Build diff --git a/.golangci.yml b/.golangci.yml index dcd2b00..51a8fa4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -20,7 +20,7 @@ linters: exclusions: rules: - # Testlerde okunabilirlik uğruna gevşetilenler. + # Relaxed in tests for readability. - path: _test\.go linters: [bodyclose, noctx, errorlint] diff --git a/Makefile b/Makefile index b77063a..661c0c5 100644 --- a/Makefile +++ b/Makefile @@ -23,9 +23,9 @@ fmt: clean: rm -rf bin dist -# Modül yolunu değiştirmek için: make rename NEW=git.ornek.com/ekip/llmgw +# Change the module path: make rename NEW=git.example.com/team/llmgw rename: - @test -n "$(NEW)" || (echo "kullanim: make rename NEW="; exit 1) + @test -n "$(NEW)" || (echo "usage: make rename NEW="; exit 1) go mod edit -module=$(NEW) grep -rl '$(MODULE)' --include='*.go' . | xargs sed -i '' 's|$(MODULE)|$(NEW)|g' sed -i '' 's|^MODULE := .*|MODULE := $(NEW)|' Makefile diff --git a/README.md b/README.md index 82a561e..030cac5 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,62 @@ # llmgw -Çoklu InfiniBand fabric ve heterojen GPU için tek binary LLM gateway. +A single-binary LLM gateway for clusters with multiple InfiniBand fabrics and +heterogeneous GPUs. -Kendi GPU sunucularında çalışan LLM'leri (vLLM / SGLang) tek bir OpenAI uyumlu -uç noktadan sunar. Tasarım dokümanı: [llmgw-plan.md](llmgw-plan.md). +It serves LLMs running on your own GPU servers (vLLM / SGLang) behind one +OpenAI-compatible endpoint. Design document: [llmgw-plan.md](llmgw-plan.md). -## Durum +## Status -| Faz | Kapsam | Durum | +| Phase | Scope | State | |---|---|---| -| 0 | İskelet + sim-engine | ✅ | -| 1 | Statik router (filter/score/pick, streaming proxy) | ⬜ | -| 1.5 | Admission control (öncelik bantları) | ⬜ | -| 2 | Agent: GPU/IB keşfi, tek-node engine | ⬜ | -| 3 | Control plane: katalog, scheduler, reconciler | ⬜ | -| 4 | Multi-node & fabric farkındalığı | ⬜ | -| 5 | Tenancy, prefix affinity, bench | ⬜ | +| 0 | Skeleton and sim-engine | done | +| 1 | Static router (filter/score/pick, streaming proxy) | in progress | +| 1.5 | Admission control (priority bands) | todo | +| 2 | Agent: GPU/IB discovery, single-node engines | todo | +| 3 | Control plane: catalog, scheduler, reconciler | todo | +| 4 | Multi-node placement and fabric awareness | todo | +| 5 | Tenancy, prefix affinity, benchmarking | todo | -## Kurulum +## Building ```sh make build # bin/llmgw -make test # birim testleri -make race # yarış dedektörüyle +make test # unit tests +make race # with the race detector ``` ## sim-engine -Gerçek bir inference engine'in **router açısından önemli** davranışlarını simüle -eder; GPU olmadan geliştirme ve test yapabilmek için. Modellediği şeyler: +Simulates the inference-engine behaviours **the router cares about**, so the +router can be developed and tested without GPUs. What it models: -- continuous batching (sınırlı eşzamanlı dizi sayısı) -- KV cache blok muhasebesi; blok yetmediğinde kuyruğa alma (head-of-line blocking dahil) -- otomatik prefix cache (zincirlenmiş blok hash'leri + LRU) ve cache hit'in TTFT'ye etkisi -- batch büyüdükçe token başına gecikmenin artması -- istemci koptuğunda isteğin batch'ten düşürülüp blokların bırakılması -- vLLM metrik adlarıyla `/metrics` +- continuous batching (bounded number of concurrent sequences) +- KV cache block accounting, queueing once blocks run out (head-of-line + blocking included) +- automatic prefix caching (chained block hashes plus an LRU) and its effect + on time to first token +- per-token latency growing with batch size +- dropping a request and freeing its blocks when the client disconnects +- `/metrics` using vLLM's metric names ```sh ./bin/llmgw sim-engine --listen :8000 --model llama-3.3-70b curl -N localhost:8000/v1/chat/completions \ -H 'content-type: application/json' \ - -d '{"messages":[{"role":"user","content":"merhaba"}],"max_tokens":8,"stream":true}' + -d '{"messages":[{"role":"user","content":"hello"}],"max_tokens":8,"stream":true}' ``` -Yük/gecikme davranışını ayarlamak için: `--max-num-seqs`, `--num-gpu-blocks`, -`--prefill-tps`, `--decode-tps`, `--batch-contention`, `--prefix-cache-blocks`. -Kaos testleri için: `--fail-rate`, deterministik koşu için `--seed`. +Tune load and latency behaviour with `--max-num-seqs`, `--num-gpu-blocks`, +`--prefill-tps`, `--decode-tps`, `--batch-contention` and +`--prefix-cache-blocks`. For chaos testing use `--fail-rate`, and `--seed` for +deterministic runs. -### Engine metrik kontratı +### The engine metric contract -Router'ın yönlendirme yapabilmesi için bir engine'in vermesi **zorunlu** üç metrik -(plan §7.6). sim-engine bunları gerçek vLLM adlarıyla yayınlar: +For the router to make routing decisions an engine **must** expose these three +metrics (plan §7.6). sim-engine publishes them under vLLM's real names: ``` vllm:num_requests_running @@ -60,10 +64,17 @@ vllm:num_requests_waiting vllm:kv_cache_usage_perc ``` -## Modül yolu +Adding support for a new engine means satisfying that contract. Metric names +drift between versions, so the name mapping is configurable. -`gitea.mynodes.xyz/ilker/llmgw`. Değiştirmek için: +## Module path + +`gitea.mynodes.xyz/ilker/llmgw`. To change it: ```sh -make rename NEW=git.ornek.com/ekip/llmgw +make rename NEW=git.example.com/team/llmgw ``` + +## Language + +Code, comments, tests, commit messages and documentation are in English. diff --git a/cmd/llmgw/main.go b/cmd/llmgw/main.go index 19823ff..e31dee7 100644 --- a/cmd/llmgw/main.go +++ b/cmd/llmgw/main.go @@ -1,5 +1,5 @@ -// Command llmgw, tek binary halinde router, control plane, agent ve yardımcı -// araçları barındırır. Hangi rolde çalışacağı alt komutla belirlenir. +// Command llmgw bundles the router, control plane, agent and supporting tools +// into a single binary. The subcommand selects which role it runs as. package main import ( @@ -11,7 +11,7 @@ import ( func main() { if err := cli.Root().Execute(); err != nil { - fmt.Fprintln(os.Stderr, "hata:", err) + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 1a7183e..3ebb441 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,4 +1,4 @@ -// Package cli, llmgw'nin komut satırı yüzeyini kurar. +// Package cli builds llmgw's command line surface. package cli import ( @@ -17,13 +17,13 @@ type globalFlags struct { var global globalFlags -// Root, kök komutu ve tüm alt komutları döner. +// Root returns the root command with all subcommands attached. func Root() *cobra.Command { root := &cobra.Command{ Use: "llmgw", - Short: "Çoklu IB fabric ve heterojen GPU için LLM gateway", - Long: "llmgw, kendi GPU sunucularında çalışan LLM'leri tek bir OpenAI uyumlu\n" + - "uç noktadan sunar. Tek binary, alt komutla farklı roller.", + Short: "LLM gateway for multi-fabric InfiniBand and heterogeneous GPU clusters", + Long: "llmgw serves LLMs running on your own GPU servers behind a single\n" + + "OpenAI-compatible endpoint. One binary, different roles per subcommand.", Version: version.Version, SilenceUsage: true, SilenceErrors: true, @@ -33,8 +33,8 @@ func Root() *cobra.Command { } pf := root.PersistentFlags() - pf.StringVar(&global.logLevel, "log-level", "info", "log seviyesi: debug|info|warn|error") - pf.StringVar(&global.logFormat, "log-format", "text", "log biçimi: text|json") + pf.StringVar(&global.logLevel, "log-level", "info", "log level: debug|info|warn|error") + pf.StringVar(&global.logFormat, "log-format", "text", "log format: text|json") root.AddCommand( newServerCmd(), diff --git a/internal/cli/simengine.go b/internal/cli/simengine.go index 18b544b..77a2245 100644 --- a/internal/cli/simengine.go +++ b/internal/cli/simengine.go @@ -20,10 +20,10 @@ func newSimEngineCmd() *cobra.Command { cmd := &cobra.Command{ Use: "sim-engine", - Short: "OpenAI uyumlu sahte inference engine (test için)", - Long: "Gerçek bir engine'in router açısından önemli davranışlarını simüle eder:\n" + - "continuous batching, KV blok muhasebesi, prefix cache, vLLM formatında /metrics.\n" + - "Router'ı GPU olmadan test etmek için kullanılır.", + Short: "OpenAI-compatible fake inference engine (for testing)", + Long: "Simulates the engine behaviours that matter to the router:\n" + + "continuous batching, KV block accounting, prefix caching, and vLLM-shaped\n" + + "/metrics. Lets the router be developed and tested without GPUs.", RunE: func(cmd *cobra.Command, _ []string) error { log := logger() eng := sim.New(cfg) @@ -39,7 +39,7 @@ func newSimEngineCmd() *cobra.Command { if err != nil { return err } - log.Info("sim-engine dinliyor", + log.Info("sim-engine listening", "addr", ln.Addr().String(), "model", cfg.Model, "max_num_seqs", cfg.MaxNumSeqs, @@ -60,7 +60,7 @@ func newSimEngineCmd() *cobra.Command { } return err case <-ctx.Done(): - log.Info("kapanıyor") + log.Info("shutting down") shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return srv.Shutdown(shutCtx) @@ -69,19 +69,19 @@ func newSimEngineCmd() *cobra.Command { } f := cmd.Flags() - f.StringVar(&listen, "listen", ":8000", "dinlenecek adres") - f.StringVar(&cfg.Model, "model", cfg.Model, "sunulan model adı") - f.IntVar(&cfg.MaxNumSeqs, "max-num-seqs", cfg.MaxNumSeqs, "eşzamanlı dizi sayısı (batch kapasitesi)") - f.IntVar(&cfg.BlockSize, "block-size", cfg.BlockSize, "KV blok başına token") - f.IntVar(&cfg.NumGPUBlocks, "num-gpu-blocks", cfg.NumGPUBlocks, "toplam KV cache bloğu") - f.IntVar(&cfg.PrefixCacheBlocks, "prefix-cache-blocks", cfg.PrefixCacheBlocks, "prefix cache blok kapasitesi (0=kapalı)") - f.Float64Var(&cfg.PrefillTokensPerSec, "prefill-tps", cfg.PrefillTokensPerSec, "prefill hızı (token/sn, engine geneli)") - f.Float64Var(&cfg.DecodeTokensPerSec, "decode-tps", cfg.DecodeTokensPerSec, "tek dizi decode hızı (token/sn)") - f.Float64Var(&cfg.BatchContention, "batch-contention", cfg.BatchContention, "batch'teki her ek dizinin yavaşlatma katsayısı") - f.IntVar(&cfg.DefaultOutputTokens, "default-output-tokens", cfg.DefaultOutputTokens, "max_tokens verilmezse üretilecek token") - f.DurationVar(&cfg.Tick, "tick", cfg.Tick, "simülasyon adımı") - f.Float64Var(&cfg.FailRate, "fail-rate", 0, "isteklerin yapay hata oranı (kaos testi)") - f.Int64Var(&cfg.Seed, "seed", 0, "rastgelelik tohumu (0=zaman)") + f.StringVar(&listen, "listen", ":8000", "address to listen on") + f.StringVar(&cfg.Model, "model", cfg.Model, "model name to serve") + f.IntVar(&cfg.MaxNumSeqs, "max-num-seqs", cfg.MaxNumSeqs, "concurrent sequences (continuous batch capacity)") + f.IntVar(&cfg.BlockSize, "block-size", cfg.BlockSize, "tokens per KV cache block") + f.IntVar(&cfg.NumGPUBlocks, "num-gpu-blocks", cfg.NumGPUBlocks, "total KV cache blocks") + f.IntVar(&cfg.PrefixCacheBlocks, "prefix-cache-blocks", cfg.PrefixCacheBlocks, "prefix cache capacity in blocks (0 disables)") + f.Float64Var(&cfg.PrefillTokensPerSec, "prefill-tps", cfg.PrefillTokensPerSec, "prefill rate in tokens/sec, shared engine-wide") + f.Float64Var(&cfg.DecodeTokensPerSec, "decode-tps", cfg.DecodeTokensPerSec, "single-sequence decode rate in tokens/sec") + f.Float64Var(&cfg.BatchContention, "batch-contention", cfg.BatchContention, "per-extra-sequence decode slowdown factor") + f.IntVar(&cfg.DefaultOutputTokens, "default-output-tokens", cfg.DefaultOutputTokens, "tokens generated when max_tokens is absent") + f.DurationVar(&cfg.Tick, "tick", cfg.Tick, "simulation step") + f.Float64Var(&cfg.FailRate, "fail-rate", 0, "fraction of requests failed artificially (chaos testing)") + f.Int64Var(&cfg.Seed, "seed", 0, "random seed (0 = time-based)") return cmd } diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index 3613135..cfe6caf 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -6,19 +6,19 @@ import ( "github.com/spf13/cobra" ) -// errNotImplemented, henüz yazılmamış fazların komutları için. -var errNotImplemented = errors.New("bu komut henüz uygulanmadı (bkz. llmgw-plan.md §11)") +// errNotImplemented marks commands belonging to phases not yet built. +var errNotImplemented = errors.New("not implemented yet (see llmgw-plan.md §11)") func newServerCmd() *cobra.Command { var configPath string cmd := &cobra.Command{ Use: "server", - Short: "Router + control plane (Faz 1-3)", + Short: "Router and control plane (phases 1-3)", RunE: func(_ *cobra.Command, _ []string) error { return errNotImplemented }, } - cmd.Flags().StringVar(&configPath, "config", "server.yaml", "konfigürasyon dosyası") + cmd.Flags().StringVar(&configPath, "config", "server.yaml", "configuration file") return cmd } @@ -26,26 +26,26 @@ func newAgentCmd() *cobra.Command { var configPath string cmd := &cobra.Command{ Use: "agent", - Short: "GPU node'unda çalışan agent (Faz 2)", + Short: "Node agent running on each GPU host (phase 2)", RunE: func(_ *cobra.Command, _ []string) error { return errNotImplemented }, } - cmd.Flags().StringVar(&configPath, "config", "agent.yaml", "konfigürasyon dosyası") + cmd.Flags().StringVar(&configPath, "config", "agent.yaml", "configuration file") return cmd } func newCtlCmd() *cobra.Command { cmd := &cobra.Command{ Use: "ctl", - Short: "Yönetim CLI'ı (Faz 3)", + Short: "Administrative CLI (phase 3)", } for _, sub := range []struct{ use, short string }{ - {"nodes", "node'ları listele"}, - {"models", "model kataloğunu göster"}, - {"replicas", "replikaları listele"}, - {"explain-placement", "bir modelin neden yerleştirilemediğini açıkla"}, - {"bench", "profil kapasitesini ölç (§10)"}, + {"nodes", "list nodes"}, + {"models", "show the model catalog"}, + {"replicas", "list replicas"}, + {"explain-placement", "explain why a model could not be placed"}, + {"bench", "measure profile capacity (§10)"}, } { cmd.AddCommand(&cobra.Command{ Use: sub.use, diff --git a/internal/obs/log.go b/internal/obs/log.go index c4f6952..d63bd28 100644 --- a/internal/obs/log.go +++ b/internal/obs/log.go @@ -1,4 +1,4 @@ -// Package obs, log ve metrik kurulumunu tek yerden yapar. +// Package obs sets up logging and metrics in one place. package obs import ( @@ -7,13 +7,13 @@ import ( "strings" ) -// Options, log kurulumunu belirler. +// Options controls logger setup. type Options struct { Level string // debug, info, warn, error Format string // text, json } -// Setup, slog varsayılan logger'ını kurar ve onu döner. +// Setup installs the default slog logger and returns it. func Setup(o Options) *slog.Logger { var lvl slog.Level switch strings.ToLower(o.Level) { diff --git a/internal/sim/cache.go b/internal/sim/cache.go index 9c61a0c..34125ac 100644 --- a/internal/sim/cache.go +++ b/internal/sim/cache.go @@ -6,17 +6,17 @@ import ( "hash/fnv" ) -// BlockHash, KV cache'in muhasebe birimi olan bir bloğun kimliği. +// BlockHash identifies one KV cache block, the unit of cache accounting. type BlockHash uint64 -// BlockHashes, token dizisi için zincirlenmiş blok hash'leri üretir. +// BlockHashes computes chained block hashes over a token sequence. // -// Zincirleme (h_i = hash(h_{i-1}, block_i)) vLLM'in yaptığının aynısıdır ve -// şart: blok kimliği sadece kendi içeriğini değil, kendisinden önce gelen tüm -// blokları da temsil etmeli. Aksi halde "aynı cümleyi farklı bağlamda içeren" -// iki istek yanlışlıkla cache hit sayılır. +// Chaining (h_i = hash(h_{i-1}, block_i)) mirrors what vLLM does and is +// required: a block's identity must represent not only its own contents but +// every block before it. Otherwise two requests containing the same sentence +// in different contexts would falsely count as a cache hit. // -// Son eksik blok yok sayılır — yarım blok cache'lenmez. +// A trailing partial block is ignored — half blocks are never cached. func BlockHashes(tokens []Token, blockSize int) []BlockHash { if blockSize <= 0 || len(tokens) < blockSize { return nil @@ -39,13 +39,12 @@ func BlockHashes(tokens []Token, blockSize int) []BlockHash { return out } -// PrefixCache, engine'in otomatik prefix cache'ini modelleyen blok bazlı LRU. +// PrefixCache models an engine's automatic prefix cache as a block-level LRU. // -// Gerçek engine'de bu bir radix tree'dir; ama yönlendirme kalitesini ölçmek için -// önemli olan davranış "hangi blokların hâlâ cache'te olduğu"dur ve onu LRU -// doğru modeller. +// A real engine uses a radix tree, but what matters for measuring routing +// quality is which blocks are still resident, and LRU models that faithfully. type PrefixCache struct { - capacity int // blok cinsinden + capacity int // in blocks ll *list.List m map[BlockHash]*list.Element @@ -53,8 +52,8 @@ type PrefixCache struct { hits int64 } -// NewPrefixCache, verilen blok kapasitesinde bir cache kurar. -// capacity <= 0 ise cache devre dışıdır (her zaman miss). +// NewPrefixCache creates a cache holding at most capacity blocks. +// A capacity of zero or less disables the cache (every lookup misses). func NewPrefixCache(capacity int) *PrefixCache { return &PrefixCache{ capacity: capacity, @@ -63,14 +62,14 @@ func NewPrefixCache(capacity int) *PrefixCache { } } -// Enabled, cache'in açık olup olmadığını söyler. +// Enabled reports whether the cache is active. func (c *PrefixCache) Enabled() bool { return c != nil && c.capacity > 0 } -// Lookup, hashes dizisinin cache'te bulunan **kesintisiz ön ek** uzunluğunu -// blok cinsinden döner ve bulunan blokları LRU'da tazeler. +// Lookup returns the length, in blocks, of the longest **contiguous prefix** +// of hashes present in the cache, refreshing those blocks in the LRU. // -// Kesintisiz olması önemli: ortadaki bir blok düşmüşse ondan sonrası da -// yeniden hesaplanmak zorundadır, çünkü attention önceki tüm bloklara bağlıdır. +// Contiguity matters: if a block in the middle was evicted, everything after +// it must be recomputed too, because attention depends on all preceding blocks. func (c *PrefixCache) Lookup(hashes []BlockHash) int { if !c.Enabled() { return 0 @@ -89,7 +88,8 @@ func (c *PrefixCache) Lookup(hashes []BlockHash) int { return n } -// Insert, blokları cache'e yazar (LRU'nun başına) ve kapasiteyi aşarsa en eskileri atar. +// Insert stores blocks at the front of the LRU, evicting the oldest ones once +// capacity is exceeded. func (c *PrefixCache) Insert(hashes []BlockHash) { if !c.Enabled() { return @@ -111,7 +111,7 @@ func (c *PrefixCache) Insert(hashes []BlockHash) { } } -// Stats, cache sorgu ve isabet sayılarını döner (blok cinsinden). +// Stats returns cumulative query and hit counts, in blocks. func (c *PrefixCache) Stats() (queries, hits int64) { if c == nil { return 0, 0 @@ -119,7 +119,7 @@ func (c *PrefixCache) Stats() (queries, hits int64) { return c.queries, c.hits } -// Len, cache'teki blok sayısı. +// Len returns the number of blocks currently cached. func (c *PrefixCache) Len() int { if !c.Enabled() { return 0 diff --git a/internal/sim/cache_test.go b/internal/sim/cache_test.go index d27320f..7feecaf 100644 --- a/internal/sim/cache_test.go +++ b/internal/sim/cache_test.go @@ -2,76 +2,76 @@ package sim import "testing" -func TestBlockHashesZincirleme(t *testing.T) { - // Aynı içerik farklı ön ekten sonra geliyorsa blok hash'i farklı olmalı. +func TestBlockHashesAreChained(t *testing.T) { + // The same content following a different prefix must hash differently. a := BlockHashes(Tokenize("AAAAAAAABBBBBBBB"), 2) b := BlockHashes(Tokenize("CCCCCCCCBBBBBBBB"), 2) if len(a) != 2 || len(b) != 2 { - t.Fatalf("beklenen 2 blok, alınan a=%d b=%d", len(a), len(b)) + t.Fatalf("expected 2 blocks each, got a=%d b=%d", len(a), len(b)) } if a[1] == b[1] { - t.Error("farklı ön ekten sonra gelen aynı blok aynı hash'i verdi; zincirleme çalışmıyor") + t.Error("identical block after different prefixes hashed the same; chaining is broken") } } -func TestBlockHashesOnEkKararli(t *testing.T) { - // Blok sınırına hizalı bir ön ek, uzun metinde aynı blok hash'lerini vermeli. - // Hizalama şart: son yarım token farklı olursa onu içeren blok da farklı - // çıkar — gerçek tokenizer'larda da olan sınır etkisi, bu yüzden prefix - // cache hit'i her zaman tam blok cinsinden ölçülür. - const aligned = "merhaba " // 8 bayt = 2 token = blockSize 2 ile 1 tam blok +func TestBlockHashesArePrefixStable(t *testing.T) { + // A prefix aligned to a block boundary must hash identically in a longer + // text. Alignment matters: a differing trailing partial token changes the + // block containing it — the same boundary effect real tokenizers have, + // which is why cache hits are always measured in whole blocks. + const aligned = "merhaba " // 8 bytes = 2 tokens = 1 full block at blockSize 2 short := BlockHashes(Tokenize(aligned), 2) long := BlockHashes(Tokenize(aligned+"dunya nasilsin"), 2) if len(short) != 1 { - t.Fatalf("beklenen 1 blok, alınan %d", len(short)) + t.Fatalf("expected 1 block, got %d", len(short)) } if short[0] != long[0] { - t.Fatal("hizalı ön ek blok hash'i değişti; ön ek kararlı değil") + t.Fatal("aligned prefix produced a different block hash; not prefix stable") } } -func TestBlockHashesYarimBlokAtilir(t *testing.T) { - // 3 token, blockSize 2 -> 1 tam blok +func TestBlockHashesDropPartialBlock(t *testing.T) { + // 3 tokens at blockSize 2 -> 1 full block if got := len(BlockHashes(make([]Token, 3), 2)); got != 1 { - t.Errorf("beklenen 1 blok, alınan %d", got) + t.Errorf("expected 1 block, got %d", got) } if got := len(BlockHashes(make([]Token, 1), 2)); got != 0 { - t.Errorf("tam blok yokken blok üretildi: %d", got) + t.Errorf("produced %d blocks with no full block available", got) } } -func TestPrefixCacheKesintisizOnEk(t *testing.T) { +func TestPrefixCacheReturnsContiguousPrefix(t *testing.T) { c := NewPrefixCache(10) h := []BlockHash{1, 2, 3, 4} c.Insert(h) if n := c.Lookup(h); n != 4 { - t.Fatalf("tam eşleşme beklendi, alınan %d", n) + t.Fatalf("expected a full match, got %d", n) } - // Ortası eksik: ilk blok hit, sonrası miss olmalı. + // With a gap in the middle only the first block counts. if n := c.Lookup([]BlockHash{1, 99, 3}); n != 1 { - t.Errorf("kesintisiz ön ek 1 olmalıydı, alınan %d", n) + t.Errorf("contiguous prefix should have been 1, got %d", n) } } -func TestPrefixCacheLRUTahliye(t *testing.T) { +func TestPrefixCacheEvictsLRU(t *testing.T) { c := NewPrefixCache(2) c.Insert([]BlockHash{1, 2}) - c.Lookup([]BlockHash{1}) // 1'i tazele - c.Insert([]BlockHash{3}) // kapasite aşıldı, en eski (2) düşmeli + c.Lookup([]BlockHash{1}) // refresh 1 + c.Insert([]BlockHash{3}) // over capacity: the oldest (2) must go if n := c.Lookup([]BlockHash{1}); n != 1 { - t.Error("taze blok tahliye edildi") + t.Error("recently used block was evicted") } if n := c.Lookup([]BlockHash{2}); n != 0 { - t.Error("en eski blok tahliye edilmeliydi") + t.Error("least recently used block should have been evicted") } } -func TestPrefixCacheKapali(t *testing.T) { +func TestPrefixCacheDisabled(t *testing.T) { c := NewPrefixCache(0) c.Insert([]BlockHash{1, 2}) if n := c.Lookup([]BlockHash{1}); n != 0 { - t.Errorf("kapalı cache hit verdi: %d", n) + t.Errorf("disabled cache reported a hit: %d", n) } } diff --git a/internal/sim/engine.go b/internal/sim/engine.go index 48ace20..eba6be5 100644 --- a/internal/sim/engine.go +++ b/internal/sim/engine.go @@ -1,12 +1,12 @@ -// Package sim, OpenAI uyumlu sahte bir inference engine'i barındırır. +// Package sim provides an OpenAI-compatible fake inference engine. // -// Amacı router'ı GPU olmadan gerçekçi koşullarda test edebilmek. Gerçek bir -// engine'in router açısından önemli olan davranışlarını modeller: -// - continuous batching (sınırlı eşzamanlı dizi sayısı) -// - KV cache blok muhasebesi ve blok yetmediğinde kuyruğa alma -// - otomatik prefix cache (blok bazlı LRU) ve cache hit'in TTFT'ye etkisi -// - batch büyüdükçe token başına gecikmenin artması -// - vLLM formatında /metrics +// Its purpose is to let the router be tested under realistic conditions +// without GPUs. It models the real-engine behaviours the router cares about: +// - continuous batching (bounded number of concurrent sequences) +// - KV cache block accounting, queueing once blocks run out +// - automatic prefix caching (block-level LRU) and its effect on TTFT +// - per-token latency growing as the batch grows +// - /metrics in vLLM's format package sim import ( @@ -19,40 +19,40 @@ import ( "time" ) -// Config, simüle edilen engine'in davranışını belirler. +// Config controls the simulated engine's behaviour. type Config struct { Model string - // MaxNumSeqs, aynı anda çalışabilecek dizi sayısı (continuous batch kapasitesi). + // MaxNumSeqs is how many sequences may run concurrently (batch capacity). MaxNumSeqs int - // BlockSize, bir KV cache bloğundaki token sayısı. + // BlockSize is the number of tokens per KV cache block. BlockSize int - // NumGPUBlocks, toplam KV cache bloğu. + // NumGPUBlocks is the total number of KV cache blocks. NumGPUBlocks int - // PrefixCacheBlocks, prefix cache'in tutacağı blok sayısı. 0 = kapalı. + // PrefixCacheBlocks is the prefix cache capacity in blocks. 0 disables it. PrefixCacheBlocks int - // PrefillTokensPerSec, engine genelinde paylaşılan prefill hızı. + // PrefillTokensPerSec is the prefill rate, shared engine-wide. PrefillTokensPerSec float64 - // DecodeTokensPerSec, tek dizi çalışırken token üretim hızı. + // DecodeTokensPerSec is the generation rate for a single running sequence. DecodeTokensPerSec float64 - // BatchContention, batch'teki her ek dizinin decode hızını ne kadar - // yavaşlattığı. Etkin hız = DecodeTokensPerSec / (1 + BatchContention*(n-1)). + // BatchContention is how much each additional sequence slows decoding: + // effective rate = DecodeTokensPerSec / (1 + BatchContention*(n-1)). BatchContention float64 - // DefaultOutputTokens, istek max_tokens vermediğinde üretilecek token sayısı. + // DefaultOutputTokens is generated when a request omits max_tokens. DefaultOutputTokens int - // Tick, simülasyon adımı. Küçük olması çözünürlüğü artırır. + // Tick is the simulation step; smaller values increase resolution. Tick time.Duration - // FailRate, isteklerin hangi oranda 500 ile reddedileceği (kaos testi için). + // FailRate is the fraction of requests rejected artificially, for chaos tests. FailRate float64 - // Seed, deterministik testler için. + // Seed makes runs deterministic. Seed int64 } -// Default, makul varsayılanlarla bir Config döner. -// Kabaca 8xH100 üstünde 70B bir modelin davranışına benzer ölçekler. +// Default returns a configuration with sensible values, roughly in the range +// of a 70B model on 8xH100. func Default() Config { return Config{ Model: "sim-model", @@ -95,34 +95,34 @@ func (c *Config) normalize() { } } -// EventKind, akıştaki olay türü. +// EventKind is the type of an event in the stream. type EventKind int const ( - // EventToken, üretilen bir token. + // EventToken carries one generated token. EventToken EventKind = iota - // EventDone, üretimin normal bitişi. + // EventDone marks normal completion. EventDone - // EventError, üretim sırasında oluşan hata. + // EventError reports a failure during generation. EventError ) -// Event, engine'in istemciye ilettiği tek bir olay. +// Event is a single event delivered to the caller. type Event struct { Kind EventKind Text string Err error - // Result yalnızca EventDone'da doludur; prompt/cache/completion sayılarını taşır. + // Result is set only on EventDone; it carries the final token counts. Result *Result } -// Prompt, engine'e verilen istek. +// Prompt is a request handed to the engine. type Prompt struct { Text string MaxTokens int } -// Result, istek bittiğinde okunabilen özet (Done olayından sonra geçerli). +// Result summarizes a finished request; valid from the Done event onwards. type Result struct { PromptTokens int CachedTokens int @@ -130,12 +130,12 @@ type Result struct { } var ( - // ErrTooLong, isteğin KV cache'e hiç sığmadığını belirtir. - ErrTooLong = errors.New("istek KV cache kapasitesine sığmıyor") - // ErrInjected, FailRate ile üretilen yapay hata. - ErrInjected = errors.New("yapay hata (sim fail_rate)") - // ErrShutdown, engine kapanırken bekleyen istekler için. - ErrShutdown = errors.New("engine kapanıyor") + // ErrTooLong means the request does not fit in the KV cache at all. + ErrTooLong = errors.New("request does not fit in KV cache capacity") + // ErrInjected is the artificial failure produced by FailRate. + ErrInjected = errors.New("injected failure (sim fail_rate)") + // ErrShutdown is returned to pending requests while the engine stops. + ErrShutdown = errors.New("engine shutting down") ) type req struct { @@ -164,9 +164,9 @@ type req struct { res *Result } -// flush, biriken olayları bloke olmadan kanala aktarır. -// Bloke olmamak şart: tick döngüsü kilidi tutarken yavaş bir istemci -// tüm engine'i durdurmamalı. +// flush moves buffered events into the channel without blocking. +// Not blocking is essential: the tick loop holds the engine lock, and one slow +// client must not stall every other request. func (r *req) flush() { for len(r.pending) > 0 { select { @@ -183,7 +183,7 @@ func (r *req) emit(ev Event) { r.flush() } -// Engine, simüle edilen inference engine. +// Engine is the simulated inference engine. type Engine struct { cfg Config @@ -203,8 +203,7 @@ type Engine struct { done chan struct{} } -// New, verilen konfigürasyonla bir engine kurar ve simülasyon döngüsünü başlatır. -// Kapatmak için Close çağrılmalı. +// New creates an engine and starts its simulation loop. Call Close to stop it. func New(cfg Config) *Engine { cfg.normalize() seed := cfg.Seed @@ -215,7 +214,7 @@ func New(cfg Config) *Engine { cfg: cfg, freeBlocks: cfg.NumGPUBlocks, cache: NewPrefixCache(cfg.PrefixCacheBlocks), - rng: rand.New(rand.NewSource(seed)), //nolint:gosec // simülasyon + rng: rand.New(rand.NewSource(seed)), //nolint:gosec // simulation only m: newMetrics(), stop: make(chan struct{}), done: make(chan struct{}), @@ -224,10 +223,10 @@ func New(cfg Config) *Engine { return e } -// Config, engine'in aktif konfigürasyonunu döner. +// Config returns the engine's effective configuration. func (e *Engine) Config() Config { return e.cfg } -// Close, simülasyon döngüsünü durdurur ve bekleyen istekleri hataya düşürür. +// Close stops the simulation loop and fails any pending requests. func (e *Engine) Close() { e.stopOnce.Do(func() { close(e.stop) @@ -235,8 +234,8 @@ func (e *Engine) Close() { }) } -// Submit, isteği kuyruğa alır ve olay kanalını döner. -// Kanal, üretim bitince veya hata oluşunca kapanır. +// Submit enqueues a request and returns its event channel. The channel closes +// when generation finishes or fails. func (e *Engine) Submit(ctx context.Context, p Prompt) (<-chan Event, error) { if e.cfg.FailRate > 0 { e.mu.Lock() @@ -310,9 +309,9 @@ func (e *Engine) run() { } } -// reap, istemcisi vazgeçmiş istekleri hem kuyruktan hem batch'ten düşürür. -// Router'ın iptal propagasyonunu test edebilmek için bu davranış şart: -// istemci koptuğunda GPU boşa çalışmamalı. +// reap drops requests whose client has gone away, from both the queue and the +// running batch. The router's cancellation propagation cannot be tested +// without this: when a client disconnects, the GPU must stop working. func (e *Engine) reap() { e.waiting = filterInPlace(e.waiting, func(r *req) bool { if r.ctx.Err() == nil { @@ -338,7 +337,7 @@ func (e *Engine) schedule() { r := e.waiting[0] need := e.blocksFor(r.promptTokens + r.outputTokens) if need > e.freeBlocks { - // Head-of-line blocking: gerçek engine de böyle davranır. + // Head-of-line blocking; real engines behave the same way. break } @@ -362,7 +361,7 @@ func (e *Engine) advance(dt float64) { return } - // Prefill bütçesi engine genelinde paylaşılır ve FIFO dağıtılır. + // The prefill budget is shared engine-wide and handed out FIFO. budget := e.cfg.PrefillTokensPerSec * dt for _, r := range e.running { if budget <= 0 { @@ -376,7 +375,7 @@ func (e *Engine) advance(dt float64) { budget -= used } - // Decode hızı batch büyüdükçe düşer. + // Decoding slows down as the batch grows. n := 0 for _, r := range e.running { if r.prefillLeft <= 0 { @@ -420,9 +419,9 @@ func (e *Engine) finish(r *req) { r.finished = true e.freeBlocks += r.blocks - // Üretilen çıktı da cache'e girer: bir sonraki turda konuşma geçmişi - // olarak geri geldiğinde hit vermeli. Çok turlu sohbeti modellemenin - // tek yolu bu. + // The generated output enters the cache too: when it comes back as + // conversation history on the next turn it must hit. That is the only way + // to model multi-turn chat. full := Tokenize(r.prompt.Text + generatedText(r.emitted)) e.cache.Insert(BlockHashes(full, e.cfg.BlockSize)) @@ -469,7 +468,19 @@ func (e *Engine) shutdown() { e.waiting, e.running, e.draining = nil, nil, nil } -// Stats, router'ın scrape edeceği anlık durumu döner. +// Stats returns the instantaneous state the router would scrape. +type Stats struct { + Running int + Waiting int + KVCacheUsage float64 + BlockSize int + NumGPUBlocks int + PrefixQueries int64 + PrefixHits int64 + CachedBlocks int +} + +// Stats returns a snapshot of the engine's current state. func (e *Engine) Stats() Stats { e.mu.Lock() defer e.mu.Unlock() @@ -487,18 +498,6 @@ func (e *Engine) Stats() Stats { } } -// Stats, engine'in anlık durumu. -type Stats struct { - Running int - Waiting int - KVCacheUsage float64 - BlockSize int - NumGPUBlocks int - PrefixQueries int64 - PrefixHits int64 - CachedBlocks int -} - func filterInPlace(s []*req, keep func(*req) bool) []*req { out := s[:0] for _, r := range s { diff --git a/internal/sim/engine_test.go b/internal/sim/engine_test.go index 4a62051..8eb323a 100644 --- a/internal/sim/engine_test.go +++ b/internal/sim/engine_test.go @@ -7,7 +7,7 @@ import ( "time" ) -// testConfig, testlerin hızlı ve deterministik koşması için hızlandırılmış ayarlar. +// testConfig speeds the simulation up so tests stay fast and deterministic. func testConfig() Config { c := Default() c.Tick = time.Millisecond @@ -18,7 +18,7 @@ func testConfig() Config { return c } -// drain, kanaldaki tüm token'ları toplar ve bitişi bekler. +// drain collects every token from the channel and waits for completion. func drain(t *testing.T, ch <-chan Event) (text string, done bool) { t.Helper() var b strings.Builder @@ -35,36 +35,36 @@ func drain(t *testing.T, ch <-chan Event) (text string, done bool) { case EventDone: done = true case EventError: - t.Fatalf("beklenmeyen hata: %v", ev.Err) + t.Fatalf("unexpected error: %v", ev.Err) } case <-deadline: - t.Fatal("zaman aşımı: engine token üretmedi") + t.Fatal("timed out waiting for tokens") } } } -func TestEngineTemelUretim(t *testing.T) { +func TestEngineGeneratesTokens(t *testing.T) { e := New(testConfig()) defer e.Close() - ch, err := e.Submit(t.Context(), Prompt{Text: "merhaba dunya", MaxTokens: 10}) + ch, err := e.Submit(t.Context(), Prompt{Text: "hello world", MaxTokens: 10}) if err != nil { t.Fatal(err) } text, done := drain(t, ch) if !done { - t.Error("EventDone gelmedi") + t.Error("no EventDone received") } if got := len(strings.Fields(text)); got != 10 { - t.Errorf("beklenen 10 token, alınan %d (%q)", got, text) + t.Errorf("expected 10 tokens, got %d (%q)", got, text) } } -func TestEngineIptalBloklariSerbestBirakir(t *testing.T) { - // Router'ın iptal propagasyonunu test edebilmesi için kritik davranış: - // istemci koptuğunda engine isteği düşürmeli ve KV bloklarını bırakmalı. +func TestCancellationReleasesBlocks(t *testing.T) { + // Essential for testing the router's cancellation propagation: when the + // client goes away the engine must drop the request and free its KV blocks. cfg := testConfig() - cfg.DecodeTokensPerSec = 20 // uzun sürsün ki iptal ederken çalışıyor olsun + cfg.DecodeTokensPerSec = 20 // slow, so it is still running when cancelled e := New(cfg) defer e.Close() @@ -76,7 +76,7 @@ func TestEngineIptalBloklariSerbestBirakir(t *testing.T) { waitFor(t, time.Second, func() bool { return e.Stats().Running == 1 }) if usage := e.Stats().KVCacheUsage; usage <= 0 { - t.Fatal("çalışan istek KV bloğu ayırmadı") + t.Fatal("running request reserved no KV blocks") } cancel() @@ -86,20 +86,19 @@ func TestEngineIptalBloklariSerbestBirakir(t *testing.T) { return s.Running == 0 && s.KVCacheUsage == 0 }) - // Kanal kapanmalı ki istemci tarafı bloke kalmasın. + // The channel must close so the client side never blocks. select { case _, open := <-ch: if open { - // token kalmış olabilir; kapanana kadar tüket - for range ch { //nolint:revive // kanalı boşalt + for range ch { //nolint:revive // drain remaining events } } case <-time.After(2 * time.Second): - t.Fatal("iptal sonrası kanal kapanmadı") + t.Fatal("channel stayed open after cancellation") } } -func TestEngineBatchDolunkaKuyrugaAlir(t *testing.T) { +func TestRequestsQueueWhenBatchIsFull(t *testing.T) { cfg := testConfig() cfg.MaxNumSeqs = 2 cfg.DecodeTokensPerSec = 20 @@ -115,56 +114,54 @@ func TestEngineBatchDolunkaKuyrugaAlir(t *testing.T) { waitFor(t, time.Second, func() bool { return e.Stats().Running == 2 }) s := e.Stats() if s.Running != 2 { - t.Errorf("running %d, MaxNumSeqs=2 aşıldı", s.Running) + t.Errorf("running=%d exceeds MaxNumSeqs=2", s.Running) } if s.Waiting != 3 { - t.Errorf("waiting %d, beklenen 3", s.Waiting) + t.Errorf("waiting=%d, expected 3", s.Waiting) } } -func TestEngineKVBlogunaSigmayanIstekReddedilir(t *testing.T) { +func TestOversizedRequestIsRejected(t *testing.T) { cfg := testConfig() cfg.NumGPUBlocks = 4 - cfg.BlockSize = 16 // toplam 64 token kapasite + cfg.BlockSize = 16 // 64 tokens of capacity in total e := New(cfg) defer e.Close() _, err := e.Submit(t.Context(), Prompt{Text: strings.Repeat("x", 10000), MaxTokens: 100}) if err != ErrTooLong { - t.Errorf("ErrTooLong beklendi, alınan %v", err) + t.Errorf("expected ErrTooLong, got %v", err) } } -func TestEnginePrefixCacheIkinciIstegiHizlandirir(t *testing.T) { +func TestPrefixCacheSpeedsUpRepeatedPrompt(t *testing.T) { cfg := testConfig() - cfg.PrefillTokensPerSec = 2000 // prefill maliyeti ölçülebilir olsun + cfg.PrefillTokensPerSec = 2000 // make prefill cost measurable cfg.DecodeTokensPerSec = 5000 e := New(cfg) defer e.Close() - prompt := Prompt{Text: strings.Repeat("ortak sistem promptu. ", 200), MaxTokens: 5} + prompt := Prompt{Text: strings.Repeat("shared system prompt. ", 200), MaxTokens: 5} first := timeToFirstToken(t, e, prompt) - // İlk istek bitip cache'e yazıldıktan sonra aynısını tekrar gönder. + // Send the same prompt again now that the first one populated the cache. second := timeToFirstToken(t, e, prompt) if second >= first { - t.Errorf("prefix cache TTFT'yi düşürmedi: ilk=%v ikinci=%v", first, second) + t.Errorf("prefix cache did not reduce TTFT: first=%v second=%v", first, second) } - - s := e.Stats() - if s.PrefixHits == 0 { - t.Error("prefix cache hit sayacı artmadı") + if e.Stats().PrefixHits == 0 { + t.Error("prefix cache hit counter did not advance") } } -func TestEnginePrefixCacheKapaliykenHitYok(t *testing.T) { +func TestNoHitsWhenPrefixCacheDisabled(t *testing.T) { cfg := testConfig() cfg.PrefixCacheBlocks = 0 e := New(cfg) defer e.Close() - p := Prompt{Text: strings.Repeat("ayni prompt. ", 50), MaxTokens: 3} + p := Prompt{Text: strings.Repeat("same prompt. ", 50), MaxTokens: 3} for range 3 { ch, err := e.Submit(t.Context(), p) if err != nil { @@ -173,11 +170,11 @@ func TestEnginePrefixCacheKapaliykenHitYok(t *testing.T) { drain(t, ch) } if h := e.Stats().PrefixHits; h != 0 { - t.Errorf("cache kapalıyken %d hit raporlandı", h) + t.Errorf("disabled cache reported %d hits", h) } } -func TestEngineKapanisBekleyenleriBosaltir(t *testing.T) { +func TestShutdownDrainsPendingRequests(t *testing.T) { cfg := testConfig() cfg.DecodeTokensPerSec = 1 e := New(cfg) @@ -197,23 +194,23 @@ func TestEngineKapanisBekleyenleriBosaltir(t *testing.T) { return } case <-deadline: - t.Fatal("kapanışta kanal kapanmadı") + t.Fatal("channel stayed open after shutdown") } } } -func TestEngineYapayHata(t *testing.T) { +func TestInjectedFailure(t *testing.T) { cfg := testConfig() cfg.FailRate = 1.0 e := New(cfg) defer e.Close() if _, err := e.Submit(t.Context(), Prompt{Text: "abc"}); err != ErrInjected { - t.Errorf("ErrInjected beklendi, alınan %v", err) + t.Errorf("expected ErrInjected, got %v", err) } } -// --- yardımcılar --- +// --- helpers --- func timeToFirstToken(t *testing.T, e *Engine, p Prompt) time.Duration { t.Helper() @@ -229,7 +226,7 @@ func timeToFirstToken(t *testing.T, e *Engine, p Prompt) time.Duration { } } if ttft == 0 { - t.Fatal("hiç token üretilmedi") + t.Fatal("no tokens were generated") } return ttft } @@ -243,5 +240,5 @@ func waitFor(t *testing.T, limit time.Duration, cond func() bool) { } time.Sleep(time.Millisecond) } - t.Fatalf("koşul %v içinde sağlanmadı", limit) + t.Fatalf("condition not met within %v", limit) } diff --git a/internal/sim/metrics.go b/internal/sim/metrics.go index 1132b0f..e32fdd6 100644 --- a/internal/sim/metrics.go +++ b/internal/sim/metrics.go @@ -8,13 +8,13 @@ import ( "time" ) -// ttftBuckets ve tpotBuckets, vLLM'in kullandıklarına yakın saniye sınırları. +// ttftBuckets and tpotBuckets use second boundaries close to vLLM's own. var ( ttftBuckets = []float64{0.001, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 20, 40, 80} tpotBuckets = []float64{0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1, 2.5} ) -// histogram, Prometheus uyumlu basit bir kümülatif histogram. +// histogram is a minimal Prometheus-compatible cumulative histogram. type histogram struct { bounds []float64 counts []uint64 @@ -36,7 +36,7 @@ func (h *histogram) observe(v float64) { } } -// write, histogramı Prometheus text formatında yazar (kümülatif le bucket'ları). +// write renders the histogram in Prometheus text format (cumulative le buckets). func (h *histogram) write(w io.Writer, name, labels string) { var cum uint64 for i, b := range h.bounds { @@ -81,12 +81,12 @@ func (m *metrics) observeTPOT(d time.Duration) { m.mu.Unlock() } -// WriteMetrics, engine durumunu vLLM'in metrik adlarıyla Prometheus text -// formatında yazar. +// WriteMetrics renders the engine's state in Prometheus text format using +// vLLM's metric names. // -// Metrik adları §7.6'daki engine kontratıdır; router bunlara göre parse eder. -// Gerçek vLLM sürümleriyle aynı adları kullanmak şart — sim-engine'in varlık -// sebebi router'ın gerçek engine'e karşı davranışını taklit etmek. +// Those names are the engine contract from plan §7.6; the router parses +// against them. Matching real vLLM names exactly is the point: sim-engine +// exists to stand in for a real engine as far as the router can tell. func (e *Engine) WriteMetrics(w io.Writer) { s := e.Stats() lbl := fmt.Sprintf("model_name=%q", e.cfg.Model) @@ -127,7 +127,7 @@ func (e *Engine) WriteMetrics(w io.Writer) { fmt.Fprintf(w, "# TYPE vllm:request_success_total counter\n") fmt.Fprintf(w, "vllm:request_success_total{%s,finished_reason=\"length\"} %d\n", lbl, e.m.finished.Load()) - // Simülasyona özgü (gerçek vLLM'de yok; kaos testlerinde işe yarar). + // Simulation-specific (absent in real vLLM; useful for chaos tests). fmt.Fprintf(w, "# TYPE sim:requests_cancelled_total counter\n") fmt.Fprintf(w, "sim:requests_cancelled_total{%s} %d\n", lbl, e.m.cancelled.Load()) fmt.Fprintf(w, "# TYPE sim:requests_failed_total counter\n") diff --git a/internal/sim/server.go b/internal/sim/server.go index b7039e0..a95f9c6 100644 --- a/internal/sim/server.go +++ b/internal/sim/server.go @@ -9,14 +9,14 @@ import ( "time" ) -// Server, Engine'i OpenAI uyumlu bir HTTP yüzeyiyle sarar. +// Server wraps an Engine in an OpenAI-compatible HTTP surface. type Server struct { eng *Engine log *slog.Logger mux *http.ServeMux } -// NewServer, engine için HTTP yüzeyini kurar. +// NewServer builds the HTTP surface for an engine. func NewServer(eng *Engine, log *slog.Logger) *Server { if log == nil { log = slog.Default() @@ -32,7 +32,7 @@ func NewServer(eng *Engine, log *slog.Logger) *Server { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } -// --- istek/yanıt tipleri (yalnızca gereken alanlar) --- +// --- request/response types (only the fields we need) --- type streamOptions struct { IncludeUsage bool `json:"include_usage"` @@ -64,15 +64,15 @@ type usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` - // OpenAI uyumlu cache raporlaması; prefix cache etkisini istemciye gösterir. + // OpenAI-compatible cache reporting; surfaces the prefix cache to clients. PromptTokensDetails struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` } -// text, mesaj içeriğini düz metne indirger. -// İçerik string ya da parça dizisi olabilir; sim için ikisini de kaba şekilde -// metne çeviriyoruz, çünkü tek ihtiyacımız deterministik bir token sayısı. +// text reduces a message's content to plain text. +// Content may be a string or an array of parts; the simulation coerces both +// crudely, since all it needs is a deterministic token count. func (m chatMessage) text() string { var s string if err := json.Unmarshal(m.Content, &s); err == nil { @@ -92,7 +92,7 @@ func promptFromMessages(msgs []chatMessage) string { return b.String() } -// --- handler'lar --- +// --- handlers --- func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { var req chatRequest @@ -168,7 +168,7 @@ func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) { s.eng.WriteMetrics(w) } -// --- ortak akış --- +// --- shared request flow --- type servePlan struct { prompt Prompt @@ -241,7 +241,7 @@ func (s *Server) bufferedResponse(w http.ResponseWriter, ch <-chan Event, p serv func (s *Server) streamResponse(w http.ResponseWriter, r *http.Request, ch <-chan Event, p servePlan, id string, created int64) { flusher, ok := w.(http.Flusher) if !ok { - writeError(w, http.StatusInternalServerError, "server_error", "streaming desteklenmiyor") + writeError(w, http.StatusInternalServerError, "server_error", "streaming not supported") return } h := w.Header() @@ -256,7 +256,7 @@ func (s *Server) streamResponse(w http.ResponseWriter, r *http.Request, ch <-cha for { select { case <-r.Context().Done(): - // İstemci koptu. Engine reap() ile isteği batch'ten düşürecek. + // Client disconnected; the engine's reap() drops it from the batch. return case ev, open := <-ch: if !open { @@ -291,7 +291,7 @@ func (s *Server) streamResponse(w http.ResponseWriter, r *http.Request, ch <-cha func (s *Server) writeChunk(w http.ResponseWriter, v any) { b, err := json.Marshal(v) if err != nil { - s.log.Error("chunk serileştirilemedi", "err", err) + s.log.Error("failed to marshal chunk", "err", err) return } fmt.Fprintf(w, "data: %s\n\n", b) @@ -319,7 +319,7 @@ func chunk(id string, p servePlan, created int64, text *string, finish string, u out := map[string]any{ "id": id, "object": p.chunkObject, "created": created, "model": p.model, } - // Usage chunk'ında choices boş dizi olur (OpenAI davranışı). + // The usage chunk carries an empty choices array (OpenAI behaviour). if u != nil { out["choices"] = []map[string]any{} out["usage"] = u @@ -329,9 +329,9 @@ func chunk(id string, p servePlan, created int64, text *string, finish string, u return out } -// usage, mümkünse engine'in raporladığı gerçek sayıları kullanır. -// res nil olabilir (istek iptal edildiyse veya hata aldıysa); o durumda -// prompt token sayısı metinden hesaplanır ve cached_tokens bilinmez. +// usage prefers the counts the engine reported. res may be nil (cancelled or +// failed request), in which case prompt tokens are derived from the text and +// cached_tokens is unknown. func (s *Server) usage(p Prompt, completion int, res *Result) usage { var u usage u.PromptTokens = CountTokens(p.Text) diff --git a/internal/sim/server_test.go b/internal/sim/server_test.go index 3cf064d..e2173e6 100644 --- a/internal/sim/server_test.go +++ b/internal/sim/server_test.go @@ -50,7 +50,7 @@ func TestChatCompletionsBuffered(t *testing.T) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - t.Fatalf("durum %d", resp.StatusCode) + t.Fatalf("status %d", resp.StatusCode) } var out struct { @@ -75,17 +75,17 @@ func TestChatCompletionsBuffered(t *testing.T) { t.Fatalf("choices=%d", len(out.Choices)) } if n := len(strings.Fields(out.Choices[0].Message.Content)); n != 7 { - t.Errorf("beklenen 7 token, alınan %d", n) + t.Errorf("expected 7 tokens, got %d", n) } if out.Usage.CompletionTokens != 7 { t.Errorf("usage.completion_tokens=%d", out.Usage.CompletionTokens) } if out.Usage.TotalTokens != out.Usage.PromptTokens+out.Usage.CompletionTokens { - t.Error("usage toplamı tutarsız") + t.Error("usage totals are inconsistent") } } -// sseEvent, SSE akışından çözülmüş tek bir olay. +// sseEvent is one decoded event from an SSE stream. type sseEvent struct { raw string data map[string]any @@ -107,7 +107,7 @@ func readSSE(t *testing.T, body *bufio.Reader) []sseEvent { ev := sseEvent{raw: payload} if payload != "[DONE]" { if err := json.Unmarshal([]byte(payload), &ev.data); err != nil { - t.Fatalf("SSE yükü çözülemedi: %v (%q)", err, payload) + t.Fatalf("could not decode SSE payload: %v (%q)", err, payload) } } out = append(out, ev) @@ -132,10 +132,10 @@ func TestChatCompletionsStreaming(t *testing.T) { events := readSSE(t, bufio.NewReader(resp.Body)) if len(events) == 0 { - t.Fatal("hiç SSE olayı gelmedi") + t.Fatal("no SSE events received") } if last := events[len(events)-1]; last.raw != "[DONE]" { - t.Errorf("akış [DONE] ile bitmedi: %q", last.raw) + t.Errorf("stream did not end with [DONE]: %q", last.raw) } var contentTokens, usageChunks, finishChunks int @@ -163,17 +163,17 @@ func TestChatCompletionsStreaming(t *testing.T) { } if contentTokens != 5 { - t.Errorf("beklenen 5 içerik chunk'ı, alınan %d", contentTokens) + t.Errorf("expected 5 content chunks, got %d", contentTokens) } if finishChunks != 1 { - t.Errorf("beklenen 1 finish_reason chunk'ı, alınan %d", finishChunks) + t.Errorf("expected 1 finish_reason chunk, got %d", finishChunks) } if usageChunks != 1 { - t.Errorf("include_usage istendi ama %d usage chunk'ı geldi", usageChunks) + t.Errorf("include_usage was requested but got %d usage chunks", usageChunks) } } -func TestStreamingUsageIstenmezseGonderilmez(t *testing.T) { +func TestStreamingOmitsUsageUnlessRequested(t *testing.T) { srv := newTestServer(t, testConfig()) resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{ @@ -188,13 +188,14 @@ func TestStreamingUsageIstenmezseGonderilmez(t *testing.T) { continue } if _, ok := ev.data["usage"]; ok { - t.Error("istenmediği halde usage chunk'ı gönderildi") + t.Error("usage chunk sent although it was not requested") } } } -func TestIstemciKopmasiEngineeYansir(t *testing.T) { - // Faz 1'in çıkış kriterinin temeli: istemci koptuğunda GPU boşa çalışmamalı. +func TestClientDisconnectReachesEngine(t *testing.T) { + // The basis of phase 1's exit criterion: when a client disconnects the GPU + // must stop working on that request. cfg := testConfig() cfg.DecodeTokensPerSec = 20 eng := New(cfg) @@ -220,7 +221,7 @@ func TestIstemciKopmasiEngineeYansir(t *testing.T) { t.Fatal(err) } - // İlk token'ı bekle, sonra istemciyi kopar. + // Wait for the first token, then drop the client. waitFor(t, 2*time.Second, func() bool { return eng.Stats().Running == 1 }) cancel() _ = resp.Body.Close() @@ -231,7 +232,7 @@ func TestIstemciKopmasiEngineeYansir(t *testing.T) { }) } -func TestCompletionsUcNoktasi(t *testing.T) { +func TestCompletionsEndpoint(t *testing.T) { srv := newTestServer(t, testConfig()) resp := postJSON(t, srv.URL+"/v1/completions", map[string]any{ @@ -252,11 +253,11 @@ func TestCompletionsUcNoktasi(t *testing.T) { t.Errorf("object=%q", out.Object) } if n := len(strings.Fields(out.Choices[0].Text)); n != 3 { - t.Errorf("beklenen 3 token, alınan %d", n) + t.Errorf("expected 3 tokens, got %d", n) } } -func TestModelsVeHealth(t *testing.T) { +func TestModelsAndHealth(t *testing.T) { cfg := testConfig() cfg.Model = "llama-3.3-70b" srv := newTestServer(t, cfg) @@ -267,7 +268,7 @@ func TestModelsVeHealth(t *testing.T) { } resp.Body.Close() if resp.StatusCode != http.StatusOK { - t.Errorf("/health durum %d", resp.StatusCode) + t.Errorf("/health status %d", resp.StatusCode) } resp, err = http.Get(srv.URL + "/v1/models") //nolint:noctx // test @@ -285,17 +286,17 @@ func TestModelsVeHealth(t *testing.T) { t.Fatal(err) } if len(out.Data) != 1 || out.Data[0].ID != "llama-3.3-70b" { - t.Errorf("/v1/models beklenen modeli listelemedi: %+v", out.Data) + t.Errorf("/v1/models did not list the expected model: %+v", out.Data) } } -// TestMetriklerPrometheusIleParseEdilebilir, §7.6'daki engine kontratını doğrular. -// Router bu metrikleri gerçek bir Prometheus parser'ıyla okuyacak; format -// bozuksa yönlendirme kör kalır. -func TestMetriklerPrometheusIleParseEdilebilir(t *testing.T) { +// TestMetricsParseWithPrometheus verifies the engine contract from plan §7.6. +// The router reads these metrics with a real Prometheus parser; if the format +// is wrong, routing goes blind. +func TestMetricsParseWithPrometheus(t *testing.T) { srv := newTestServer(t, testConfig()) - // Biraz trafik üret ki sayaçlar dolsun. + // Generate some traffic so the counters are non-zero. resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{ "messages": []map[string]string{{"role": "user", "content": "merhaba dunya"}}, "max_tokens": 4, @@ -308,26 +309,26 @@ func TestMetriklerPrometheusIleParseEdilebilir(t *testing.T) { } defer mresp.Body.Close() - // TextParser'ın sıfır değeri geçersiz; şema açıkça verilmeli. - // Legacy şema daha sıkı: metrik adlarımızın klasik Prometheus kurallarına - // uyduğunu da doğrulamış oluyoruz (vLLM adlarındaki ':' legacy'de de geçerli). + // TextParser's zero value is invalid; the scheme must be given explicitly. + // The legacy scheme is stricter, so this also checks our metric names obey + // classic Prometheus rules (the ':' in vLLM names is legal there too). parser := expfmt.NewTextParser(model.LegacyValidation) families, err := parser.TextToMetricFamilies(mresp.Body) if err != nil { - t.Fatalf("metrikler parse edilemedi: %v", err) + t.Fatalf("metrics failed to parse: %v", err) } - // §7.6 zorunlu üçlüsü. + // The three metrics plan §7.6 makes mandatory. for _, name := range []string{ "vllm:num_requests_running", "vllm:num_requests_waiting", "vllm:kv_cache_usage_perc", } { if _, ok := families[name]; !ok { - t.Errorf("zorunlu metrik eksik: %s", name) + t.Errorf("mandatory metric missing: %s", name) } } - // Opsiyoneller ve histogramlar. + // Optional metrics and histograms. for _, name := range []string{ "vllm:cache_config_info", "vllm:prefix_cache_queries_total", @@ -335,35 +336,35 @@ func TestMetriklerPrometheusIleParseEdilebilir(t *testing.T) { "vllm:time_to_first_token_seconds", } { if _, ok := families[name]; !ok { - t.Errorf("metrik eksik: %s", name) + t.Errorf("metric missing: %s", name) } } if f, ok := families["vllm:generation_tokens_total"]; ok { if got := f.Metric[0].GetCounter().GetValue(); got != 4 { - t.Errorf("generation_tokens_total=%v, beklenen 4", got) + t.Errorf("generation_tokens_total=%v, expected 4", got) } } else { - t.Error("vllm:generation_tokens_total eksik") + t.Error("vllm:generation_tokens_total is missing") } } -// TestUsageCachedTokensRaporlanir, prefix cache etkisinin istemciye ulaştığını -// doğrular. Router ileride bunu kullanmayacak olsa da, cache'in gerçekten -// çalıştığını uçtan uca gösteren tek sinyal bu. -func TestUsageCachedTokensRaporlanir(t *testing.T) { +// TestUsageReportsCachedTokens verifies the prefix cache's effect reaches the +// client. The router will not consume this, but it is the only end-to-end +// signal that the cache actually works. +func TestUsageReportsCachedTokens(t *testing.T) { srv := newTestServer(t, testConfig()) body := map[string]any{ - "messages": []map[string]string{{"role": "user", "content": strings.Repeat("ortak onek. ", 100)}}, + "messages": []map[string]string{{"role": "user", "content": strings.Repeat("shared prefix. ", 100)}}, "max_tokens": 3, } - // İlk istek cache'i doldurur. + // The first request populates the cache. resp := postJSON(t, srv.URL+"/v1/chat/completions", body) resp.Body.Close() - // İkincisi aynı ön eki kullanmalı. + // The second one must reuse that prefix. resp = postJSON(t, srv.URL+"/v1/chat/completions", body) defer resp.Body.Close() @@ -374,10 +375,10 @@ func TestUsageCachedTokensRaporlanir(t *testing.T) { t.Fatal(err) } if out.Usage.PromptTokensDetails.CachedTokens == 0 { - t.Error("ikinci istekte cached_tokens 0; prefix cache istemciye raporlanmıyor") + t.Error("cached_tokens is 0 on the second request; prefix cache is not reported") } if out.Usage.PromptTokensDetails.CachedTokens > out.Usage.PromptTokens { - t.Errorf("cached_tokens (%d) prompt_tokens'tan (%d) büyük", + t.Errorf("cached_tokens (%d) exceeds prompt_tokens (%d)", out.Usage.PromptTokensDetails.CachedTokens, out.Usage.PromptTokens) } } diff --git a/internal/sim/text.go b/internal/sim/text.go index c302e66..81dbea5 100644 --- a/internal/sim/text.go +++ b/internal/sim/text.go @@ -2,20 +2,20 @@ package sim import "strings" -// simWords, üretilen sahte çıktının kelime havuzu. +// simWords is the word pool the fake output is drawn from. var simWords = []string{ - "alfa", "beta", "gama", "delta", "epsilon", "zeta", "eta", "teta", - "iota", "kappa", "lambda", "mu", "nu", "ksi", "omikron", "pi", + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", + "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", } -// tokenText, n'inci üretilen token'ın metnini verir (n 1'den başlar). -// Deterministik olması şart: finish sırasında cache'e yazılan metinle -// istemciye akıtılan metin birebir aynı olmalı. +// tokenText returns the text of the n-th generated token (n starts at 1). +// It must be deterministic: the text written into the cache on completion has +// to match exactly what was streamed to the client. func tokenText(n int) string { return " " + simWords[(n-1)%len(simWords)] } -// generatedText, ilk n token'ın birleşimini verir. +// generatedText returns the concatenation of the first n tokens. func generatedText(n int) string { var b strings.Builder b.Grow(n * 7) diff --git a/internal/sim/tokens.go b/internal/sim/tokens.go index 36a1de3..aa6e17d 100644 --- a/internal/sim/tokens.go +++ b/internal/sim/tokens.go @@ -2,20 +2,20 @@ package sim import "hash/fnv" -// TokensPerChar, sahte tokenizer'ın kaç bayta bir token ürettiği. -// Gerçek BPE tokenizer'lara kaba bir yaklaşım; simülasyon için tek gereken -// deterministik ve prefix-kararlı olması. +// bytesPerToken is how many bytes the fake tokenizer folds into one token. +// A crude approximation of real BPE tokenizers; all the simulation needs is +// for it to be deterministic and prefix-stable. const bytesPerToken = 4 -// Token, sahte tokenizer'ın ürettiği token kimliği. +// Token is a token id produced by the fake tokenizer. type Token uint32 -// Tokenize, metni deterministik ve prefix-kararlı şekilde token'lara böler. +// Tokenize splits text into tokens deterministically and prefix-stably. // -// Prefix-kararlılık kritik: aynı ön eke sahip iki metnin token dizileri de aynı -// ön ekle başlamalı, yoksa prefix cache simülasyonu anlamsız olur. Bu yüzden -// bölme, metnin başından itibaren sabit bayt sayısıyla yapılır — içeriğe bağlı -// (BPE gibi) bir bölme kullanılmaz. +// Prefix stability is essential: two texts sharing a prefix must produce token +// sequences sharing that prefix, otherwise the prefix cache simulation is +// meaningless. That is why splitting happens at fixed byte offsets from the +// start rather than content-dependent (BPE-style) boundaries. func Tokenize(s string) []Token { if s == "" { return nil @@ -31,7 +31,7 @@ func Tokenize(s string) []Token { return out } -// CountTokens, metnin token sayısını tokenize etmeden verir. +// CountTokens returns the token count for a text without tokenizing it. func CountTokens(s string) int { return (len(s) + bytesPerToken - 1) / bytesPerToken } diff --git a/internal/sim/tokens_test.go b/internal/sim/tokens_test.go index cd544fa..0891e24 100644 --- a/internal/sim/tokens_test.go +++ b/internal/sim/tokens_test.go @@ -2,21 +2,21 @@ package sim import "testing" -func TestTokenizeOnEkKararli(t *testing.T) { +func TestTokenizeIsPrefixStable(t *testing.T) { a := Tokenize("merhaba") b := Tokenize("merhaba dunya") if len(a) > len(b) { - t.Fatal("uzun metin daha az token verdi") + t.Fatal("longer text produced fewer tokens") } - for i := range a[:len(a)-1] { // son token yarım olabilir + for i := range a[:len(a)-1] { // the last token may be partial if a[i] != b[i] { - t.Fatalf("token %d ön ek kararlı değil", i) + t.Fatalf("token %d is not prefix stable", i) } } } -func TestCountTokensTokenizeIleTutarli(t *testing.T) { - for _, s := range []string{"", "a", "abcd", "abcde", "uzunca bir prompt metni"} { +func TestCountTokensMatchesTokenize(t *testing.T) { + for _, s := range []string{"", "a", "abcd", "abcde", "a somewhat longer prompt"} { if got, want := CountTokens(s), len(Tokenize(s)); got != want { t.Errorf("%q: CountTokens=%d Tokenize=%d", s, got, want) } diff --git a/internal/version/version.go b/internal/version/version.go index dffa08c..736eb77 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,5 +1,5 @@ -// Package version, build sırasında ldflags ile doldurulan sürüm bilgisini tutar. +// Package version holds build information injected via ldflags. package version -// Version, `make build` tarafından git describe çıktısıyla doldurulur. +// Version is filled in by `make build` from git describe output. var Version = "dev" diff --git a/llmgw-plan.md b/llmgw-plan.md index ec6b580..1d5251a 100644 --- a/llmgw-plan.md +++ b/llmgw-plan.md @@ -1,80 +1,82 @@ -# llmgw — Çoklu IB Fabric ve Heterojen GPU için Tek Binary LLM Gateway +# llmgw — A Single-Binary LLM Gateway for Multi-Fabric InfiniBand and Heterogeneous GPUs -> Durum: Tasarım / Plan v0.2 -> v0.2 değişikliği: mevcut LLM gateway/router projeleri incelendi (§2.5) ve kritik -> tasarımları plana alındı — en önemlisi **admission ile scheduling'in ayrılması** (§7.2), -> eklenti tabanlı **filter → score → pick** çerçevesi (§7.3) ve **prefix/KV cache -> farkındalığının üç kademeli** modeli (§7.5). -> Hedef: Tek bir Go binary'si ile (farklı roller altında) kendi GPU sunucularımızda çalışan -> LLM'leri tek bir OpenAI uyumlu uç noktadan sunmak. Arkada birden fazla bağımsız -> InfiniBand fabric, IB'siz node'lar ve farklı GPU tipleri var. +> Status: Design / Plan v0.2 +> What changed in v0.2: existing LLM gateway and router projects were surveyed (§2.5) and +> their critical designs folded in — above all **separating admission from scheduling** (§7.2), +> the plugin-based **filter → score → pick** pipeline (§7.3), and the **three-tier model of +> prefix/KV cache awareness** (§7.5). +> Goal: one Go binary (running under different roles) that serves the LLMs on our own GPU +> servers behind a single OpenAI-compatible endpoint. Behind it sit several independent +> InfiniBand fabrics, nodes without IB, and different GPU types. --- -## 1. Kapsam +## 1. Scope -### 1.1 Yapılacaklar -- OpenAI uyumlu API (`/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/models`), SSE streaming dahil. -- Her GPU node'unda çalışan bir **agent**: GPU ve IB keşfi, inference engine süreçlerini (vLLM / SGLang) başlatma, izleme, durdurma. -- **Control plane**: node envanteri, model kataloğu, replika yerleşimi (placement), durum takibi. -- **Router**: iki katmanlı — **admission** (öncelik bantları, tenant adaleti, doygunluk kuyruklaması) - ve **scheduling** (eklenti tabanlı filter/score/pick; kuyruk/KV-cache/prefix farkındalığı), - üstüne fallback, retry, cooldown, circuit breaker. -- Fabric farkındalığı: multi-node replika **asla** iki fabric'e yayılmaz; TP grubu **asla** farklı GPU tiplerini karıştırmaz. -- API key, rate limit, kullanım (token) muhasebesi, Prometheus metrikleri. +### 1.1 In scope +- OpenAI-compatible API (`/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/models`), including SSE streaming. +- An **agent** on every GPU node: GPU and IB discovery; starting, supervising and stopping inference engine processes (vLLM / SGLang). +- **Control plane**: node inventory, model catalog, replica placement, state tracking. +- **Router**, in two layers: **admission** (priority bands, tenant fairness, queueing under + saturation) and **scheduling** (plugin-based filter/score/pick with queue, KV-cache and + prefix awareness), plus fallback, retry, cooldown and circuit breaking. +- Fabric awareness: a multi-node replica **never** spans two fabrics; a TP group **never** mixes GPU types. +- API keys, rate limits, token accounting, Prometheus metrics. -### 1.2 Yapılmayacaklar (en azından v1'de) -- Inference'ın kendisi. Go sadece orkestrasyon ve proxy yapar; modeli vLLM/SGLang çalıştırır. -- Kubernetes operatörü (bare metal / systemd hedefleniyor; K8s sonra düşünülebilir). -- Prefill/decode ayrıştırması (v2 hedefi, bkz. §12). -- Dış provider'lar (OpenAI, Anthropic) — mimari buna açık bırakılacak ama v1'de yok. +### 1.2 Out of scope (at least for v1) +- Inference itself. Go only orchestrates and proxies; vLLM/SGLang run the model. +- A Kubernetes operator (bare metal / systemd is the target; K8s can come later). +- Prefill/decode disaggregation (a v2 goal, see §12). +- External providers (OpenAI, Anthropic) — the architecture stays open to them, but not in v1. --- -## 2. Terimler +## 2. Terminology -| Terim | Anlamı | +| Term | Meaning | |---|---| -| **Node** | Agent'ın çalıştığı fiziksel sunucu. | -| **Fabric / Ada (Island)** | Bağımsız bir IB fabric'i. IB'siz node'lar `none` adasındadır ve her biri kendi başına tek-node adası sayılır. | -| **GPU Class** | Aynı model + aynı VRAM'e sahip GPU grubu (ör. `h100-80g`, `a100-40g`, `l40s-48g`). | -| **Pool** | (fabric, gpu class) ikilisi. Yerleşim birimi. | -| **Model** | Katalogdaki mantıksal model (ör. `llama-3.3-70b`). Kullanıcı bu adı görür. | -| **Profile** | Bir modelin belirli bir GPU class üzerinde nasıl çalışacağı (TP, PP, quant, max-len, engine argümanları). | -| **Replica** | Bir modelin çalışan tek bir kopyası. 1..N node'a yayılabilir. Router'ın gördüğü birim budur. | -| **Shard** | Bir replikanın tek bir node'daki parçası (engine süreci). | -| **Yönlendirme Profili** | Router'ın bir istek sınıfı için kullandığı scorer seti + ağırlıkları (chat / batch / long-context). §2'deki model **Profile**'ı ile karıştırma. | -| **Band** | Öncelik sınıfı (platinum/gold/silver/bronze). Admission kararının birimi. | -| **Block** | KV cache'in muhasebe birimi; sabit token sayısı (vLLM'de 16, GIE indexer'ında varsayılan 64). | +| **Node** | A physical server running the agent. | +| **Fabric / Island** | An independent IB fabric. Nodes without IB belong to the `none` island and each counts as its own single-node island. | +| **GPU Class** | A group of GPUs with the same model and VRAM (e.g. `h100-80g`, `a100-40g`, `l40s-48g`). | +| **Pool** | A (fabric, gpu class) pair. The unit of placement. | +| **Model** | A logical model in the catalog (e.g. `llama-3.3-70b`). This is the name users see. | +| **Profile** | How a model runs on a particular GPU class (TP, PP, quantization, max length, engine arguments). | +| **Replica** | One running copy of a model. May span 1..N nodes. This is the unit the router sees. | +| **Shard** | A replica's part on a single node (one engine process). | +| **Routing Profile** | The scorer set and weights the router uses for a class of request (chat / batch / long-context). Not to be confused with the model **Profile** above. | +| **Band** | A priority class (platinum/gold/silver/bronze). The unit of admission decisions. | +| **Block** | The unit of KV cache accounting; a fixed number of tokens (16 in vLLM, 64 by default in the GIE indexer). | --- -## 2.5 Referans Uygulamalar ve Ne Aldık +## 2.5 Reference Implementations and What We Took -Sıfırdan tasarlamak yerine bu alanda olgunlaşmış projelerin çözdüğü problemleri devraldık. +Rather than design from scratch, we inherited the problems already solved by mature projects +in this space. -| Proje | Konumu | llmgw'ye aldığımız | +| Project | What it is | What llmgw takes from it | |---|---|---| -| **Gateway API Inference Extension (EPP)** | K8s standardı; Envoy `ext_proc` üzerinden çalışan Endpoint Picker | Admission/scheduling ayrımı, eklenti tabanlı `filter → score → pick` çerçevesi, **model server metrik kontratı** (§7.6) | -| **llm-d** | GIE üzerinde "well-lit path"ler | KV event tabanlı **kesin** prefix index, öncelik bantları + tenant adaleti, doygunluk dedektörü | -| **AIBrix** (vLLM projesi) | Envoy ext_proc gateway + heterojen GPU optimizer | **Heterojen GPU SLO/maliyet modeli** (§6.2.1), gateway'de kuyruklama (`slo-least-load-pulling`), yük dengesizliğinde prefix affinity'yi iptal etme | -| **NVIDIA Dynamo** | Radix-tree KV router + SLA planner | Maliyet fonksiyonunun formu (§7.4), `router_temperature` ile sürü etkisine karşı softmax örnekleme | -| **SGLang router** | Yaklaşık radix tree ile cache-aware LB | v1'in prefix çözümü: yaklaşık ağaç + eşik mantığı (§7.5 Kademe 1) | -| **vLLM production-stack / vllm-project/router** | vLLM'in kendi router'ları | Session affinity (ölçümlerde %96 vs %93 cache hit), consistent hashing | -| **LiteLLM** | En yaygın çok-provider proxy'si | Virtual key → takım/proje → bütçe hiyerarşisi, cooldown, spend yazımının istek yolundan çıkarılması, bütçe aşımında fallback | -| **Envoy AI Gateway** | Envoy tabanlı AI gateway (v1.0, Haziran 2026) | Token bazlı (sadece istek bazlı değil) rate limit | +| **Gateway API Inference Extension (EPP)** | The K8s standard; an Endpoint Picker running over Envoy `ext_proc` | Admission/scheduling separation, the plugin-based `filter → score → pick` pipeline, the **model server metric contract** (§7.6) | +| **llm-d** | "Well-lit paths" built on GIE | KV-event-based **precise** prefix index, priority bands with tenant fairness, the saturation detector | +| **AIBrix** (a vLLM project) | An Envoy ext_proc gateway plus a heterogeneous GPU optimizer | The **heterogeneous GPU SLO/cost model** (§6.2.1), queueing at the gateway (`slo-least-load-pulling`), disabling prefix affinity under load imbalance | +| **NVIDIA Dynamo** | Radix-tree KV router plus SLA planner | The shape of the cost function (§7.4) and `router_temperature` softmax sampling against herding | +| **SGLang router** | Cache-aware load balancing over an approximate radix tree | v1's prefix answer: approximate tree plus threshold logic (§7.5 tier 1) | +| **vLLM production-stack / vllm-project/router** | vLLM's own routers | Session affinity (measured 96% vs 93% cache hit), consistent hashing | +| **LiteLLM** | The most widely deployed multi-provider proxy | Virtual key → team/project → budget hierarchy, cooldowns, keeping spend writes off the request path, budget-triggered fallback | +| **Envoy AI Gateway** | An Envoy-based AI gateway (v1.0, June 2026) | Token-based (not merely request-based) rate limiting | -Bilerek **almadıklarımız:** Kubernetes bağımlılığı (bare metal hedefi), Envoy `ext_proc` mimarisi -(tek binary hedefi — router'ı doğrudan Go'da yazıyoruz), semantic router / LLM tabanlı model seçimi -(kapsam dışı), dış provider yönetimi (v1'de yok). +What we deliberately **did not** take: a Kubernetes dependency (bare metal is the target), the +Envoy `ext_proc` architecture (single binary is the goal — we write the router directly in Go), +semantic routing / LLM-based model selection (out of scope), and external provider management +(not in v1). --- -## 3. Mimari +## 3. Architecture ``` ┌───────────────────────────────────────────┐ - İstemciler ─────► │ llmgw server │ + Clients ────────► │ llmgw server │ (OpenAI API) │ ┌────────────┐ ┌──────────────────────┐ │ │ │ Router │ │ Control Plane │ │ │ │ (data) │◄─┤ - Inventory │ │ @@ -83,78 +85,80 @@ Bilerek **almadıklarımız:** Kubernetes bağımlılığı (bare metal hedefi), │ │ │ - Store (SQLite) │ │ │ │ └─────────▲────────────┘ │ └────────┼───────────────────┼──────────────┘ - HTTP (eth) │ │ gRPC stream (mTLS, agent başlatır) + HTTP (eth) │ │ gRPC stream (mTLS, agent dials out) ┌────────────────────┼───────────────────┼─────────────────────┐ ▼ ▼ │ ▼ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ llmgw │ IB-A │ llmgw │ │ llmgw │ IB-B │ llmgw │ │ agent │◄──────►│ agent │ │ agent │◄───────►│ agent │ ... - │ vLLM rank0│ NCCL │ vLLM rank1│ │ SGLang │ │ (IB yok) │ + │ vLLM rank0│ NCCL │ vLLM rank1│ │ SGLang │ │ (no IB) │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ - Fabric A adası Fabric B adası none adası + Fabric A island Fabric B island none island ``` -### 3.1 Tek binary, çok rol +### 3.1 One binary, several roles ``` -llmgw server --config server.yaml # router + control plane (v1: aynı süreç) -llmgw agent --config agent.yaml # her GPU node'unda -llmgw ctl ... # CLI: node listele, model deploy, drain vs. -llmgw sim-engine # test için sahte OpenAI uyumlu engine +llmgw server --config server.yaml # router + control plane (same process in v1) +llmgw agent --config agent.yaml # on every GPU node +llmgw ctl ... # CLI: list nodes, deploy models, drain, ... +llmgw sim-engine # fake OpenAI-compatible engine for testing ``` -`router` ve `control` ileride ayrı süreçler olarak da çalışabilmeli (`llmgw server --roles=router`), bu yüzden aralarındaki bağımlılık bir interface üzerinden olmalı (bkz. §6.4). +`router` and `control` must eventually be able to run as separate processes +(`llmgw server --roles=router`), so the dependency between them goes through an interface +(see §6.4). -### 3.2 Build notu -`go-nvml` cgo gerektirir. Agent'taki NVML kodunu `//go:build nvml` tag'i arkasına koy; tag'siz build'de sahte/`nvidia-smi` tabanlı keşif kullanılsın. Böylece server tarafı cgo'suz derlenebilir ve geliştirme makinesinde GPU olmadan test yapılabilir. +### 3.2 Build note +`go-nvml` requires cgo. Put the agent's NVML code behind a `//go:build nvml` tag and use a +fake / `nvidia-smi`-based discovery path in untagged builds. That keeps the server side +cgo-free and lets development machines without GPUs run the tests. --- -## 4. Proje Yapısı +## 4. Project Layout ``` llmgw/ -├── cmd/llmgw/main.go # cobra root; alt komutlar +├── cmd/llmgw/main.go # cobra root; subcommands ├── api/ │ └── proto/llmgw/v1/*.proto # agent <-> control plane ├── internal/ -│ ├── config/ # koanf ile YAML + env +│ ├── config/ # YAML + env via koanf │ ├── agent/ -│ │ ├── agent.go # ana döngü, control plane bağlantısı +│ │ ├── agent.go # main loop, control plane connection │ │ ├── discovery/ │ │ │ ├── gpu_nvml.go # //go:build nvml │ │ │ ├── gpu_fallback.go -│ │ │ ├── ib.go # /sys/class/infiniband okuma -│ │ │ └── topology.go # NVLink, PCIe, NUMA, GPU<->HCA yakınlığı -│ │ ├── supervisor/ # engine süreç yönetimi (exec, restart, log) +│ │ │ ├── ib.go # reads /sys/class/infiniband +│ │ │ └── topology.go # NVLink, PCIe, NUMA, GPU<->HCA affinity +│ │ ├── supervisor/ # engine process management (exec, restart, logs) │ │ ├── engines/ │ │ │ ├── engine.go # Engine interface │ │ │ ├── vllm.go │ │ │ └── sglang.go -│ │ └── health/ # engine /health ve /metrics yoklama +│ │ └── health/ # polls engine /health and /metrics │ ├── control/ -│ │ ├── inventory/ # node & GPU durumu -│ │ ├── catalog/ # model + profile tanımları -│ │ ├── scheduler/ # placement algoritması -│ │ ├── reconciler/ # desired vs actual döngüsü +│ │ ├── inventory/ # node and GPU state +│ │ ├── catalog/ # model and profile definitions +│ │ ├── scheduler/ # placement algorithm +│ │ ├── reconciler/ # desired vs actual loop │ │ └── agentsvc/ # gRPC server │ ├── router/ -│ │ ├── openai/ # HTTP handler'lar, request parse -│ │ ├── proxy/ # upstream'e stream proxy -│ │ ├── admission/ # doygunluk dedektörü, bant kuyrukları (§7.2) -│ │ ├── scheduler/ # Filter/Scorer/Picker çerçevesi (§7.3) -│ │ │ ├── filters/ -│ │ │ ├── scorers/ # queue, kv, prefix, load, inflight, session -│ │ │ └── pickers/ # max-score, p2c, softmax -│ │ ├── prefix/ # Kademe 0 session hash, Kademe 1 approx tree, Kademe 2 kv index +│ │ ├── openai/ # HTTP handlers, request parsing +│ │ ├── proxy/ # streaming proxy to upstream +│ │ ├── admission/ # saturation detector, band queues (§7.2) +│ │ ├── scheduler/ # Filter/Scorer/Picker pipeline (§7.3) +│ │ ├── prefix/ # tier 0 session hash, tier 1 approx tree, tier 2 kv index +│ │ ├── endpoint/ # live endpoint state, inflight, staleness │ │ ├── breaker/ # circuit breaker + cooldown -│ │ └── metricsscrape/ # engine /metrics okuma (§7.6 kontratı) -│ ├── auth/ # API key, org/takım/proje, bütçe -│ ├── ratelimit/ # token bucket (istek + token) -│ ├── usage/ # token sayımı, kayıt -│ ├── store/ # SQLite (modernc.org/sqlite), migration +│ │ └── scrape/ # reads engine /metrics (the §7.6 contract) +│ ├── auth/ # API keys, org/team/project, budgets +│ ├── ratelimit/ # token bucket (requests + tokens) +│ ├── usage/ # token counting and recording +│ ├── store/ # SQLite (modernc.org/sqlite), migrations │ ├── obs/ # slog, prometheus, otel -│ └── sim/ # sahte engine +│ └── sim/ # fake engine ├── deploy/ │ ├── systemd/ │ └── examples/ @@ -162,123 +166,128 @@ llmgw/ └── docs/ ``` -### 4.1 Kütüphaneler +### 4.1 Libraries -| İhtiyaç | Seçim | +| Need | Choice | |---|---| | CLI | `github.com/spf13/cobra` | | Config | `github.com/knadh/koanf/v2` | -| RPC | `connectrpc.com/connect` (gRPC uyumlu, HTTP/2, debug kolay) | +| RPC | `connectrpc.com/connect` (gRPC compatible, HTTP/2, easy to debug) | | GPU | `github.com/NVIDIA/go-nvml` | -| DB | `modernc.org/sqlite` (cgo'suz) + `pressly/goose` migration | -| Metrik | `github.com/prometheus/client_golang`, parse için `github.com/prometheus/common/expfmt` | +| DB | `modernc.org/sqlite` (cgo-free) + `pressly/goose` migrations | +| Metrics | `github.com/prometheus/client_golang`, parsing via `github.com/prometheus/common/expfmt` | | Tracing | `go.opentelemetry.io/otel` | -| Log | `log/slog` (stdlib) | -| Rate limit | `golang.org/x/time/rate` (+ token bazlı için kendi bucket'ımız) | -| Hash | `github.com/cespare/xxhash/v2` | -| HA (v2) | `github.com/hashicorp/raft` veya etcd | -| Test | stdlib `testing`, `github.com/stretchr/testify` (opsiyonel) | +| Logging | `log/slog` (stdlib) | +| Rate limiting | `golang.org/x/time/rate` (plus our own bucket for token limits) | +| Hashing | `github.com/cespare/xxhash/v2` | +| HA (v2) | `github.com/hashicorp/raft` or etcd | +| Testing | stdlib `testing`, `github.com/stretchr/testify` (optional) | --- ## 5. Agent -### 5.1 GPU keşfi -Her GPU için: -- index, UUID, isim, VRAM, compute capability (FP8 vb. desteği için), sürücü/CUDA sürümü +### 5.1 GPU discovery +For every GPU: +- index, UUID, name, VRAM, compute capability (for FP8 and similar support), driver/CUDA version - NUMA node, PCIe bus id -- NVLink eşleri (NVML `GetNvLinkRemotePciInfo`) → aynı node içinde TP için hangi GPU'lar birlikte verimli -- sağlık: XID hataları, ECC, sıcaklık, kullanımdaki bellek (engine dışı süreçler var mı?) +- NVLink peers (NVML `GetNvLinkRemotePciInfo`) → which GPUs pair efficiently for TP within a node +- health: XID errors, ECC, temperature, memory in use (are there non-engine processes?) -`GPUClass` otomatik türetilir: `normalize(name) + "-" + round(vram_gb) + "g"` → `h100-80g`. Config ile override edilebilir. +`GPUClass` is derived automatically: `normalize(name) + "-" + round(vram_gb) + "g"` → `h100-80g`. +It can be overridden in configuration. -### 5.2 IB keşfi ve fabric kimliği -`/sys/class/infiniband//ports//` altından: +### 5.2 IB discovery and fabric identity +From `/sys/class/infiniband//ports//`: - `state` (ACTIVE?), `phys_state`, `rate`, `link_layer` (InfiniBand / Ethernet → RoCE) - `gids/0` → subnet prefix - `sm_lid`, `lid` -- HCA'nın PCIe adresi ve NUMA'sı → GPU yakınlığı +- the HCA's PCIe address and NUMA node → GPU affinity -**Fabric kimliği nasıl belirlenir?** Otomatik tespit güvenilir değildir çünkü OpenSM varsayılan subnet prefix'i (`fe80::`) tüm fabric'lerde aynıdır. Öncelik sırası: +**How is fabric identity determined?** Automatic detection is not reliable, because OpenSM's +default subnet prefix (`fe80::`) is the same on every fabric. In order of preference: -1. Agent config'inde açık etiket: `fabrics: [{name: ib-a, hcas: [mlx5_0, mlx5_1]}]` **(önerilen)** -2. Her OpenSM'de benzersiz `subnet_prefix` ayarlanmışsa GID prefix'inden otomatik eşleme (server config'inde `prefix → fabric` tablosu) -3. Hiçbiri yoksa: node `fabric=unknown` olarak işaretlenir ve **multi-node yerleşime alınmaz**. +1. An explicit label in the agent config: `fabrics: [{name: ib-a, hcas: [mlx5_0, mlx5_1]}]` **(recommended)** +2. If every OpenSM has a unique `subnet_prefix`, automatic mapping from the GID prefix (a `prefix → fabric` table in the server config) +3. Otherwise the node is marked `fabric=unknown` and **excluded from multi-node placement**. -Bonus doğrulama (v1.1): aynı fabric'te olduğunu iddia eden iki node arasında agent'lar `ibv_rc_pingpong` benzeri kısa bir test veya `ibping` ile erişilebilirliği doğrular. Tutarsızlık alarm üretir. +Bonus verification (v1.1): for two nodes claiming the same fabric, the agents verify +reachability with a short `ibv_rc_pingpong`-style test or `ibping`. A mismatch raises an alert. -Bir node'da birden fazla fabric'e bağlı HCA olabilir; model buna izin vermeli (`Node.Fabrics []FabricAttachment`). Ancak bir replika tek bir fabric seçer ve sadece o fabric'in HCA'larını kullanır. +A node may have HCAs attached to more than one fabric, and the model must allow it +(`Node.Fabrics []FabricAttachment`). A replica, however, picks exactly one fabric and uses only +that fabric's HCAs. ### 5.3 Engine interface ```go type Engine interface { Name() string // "vllm", "sglang" - Validate(p Profile, gpus []GPU) error // compute capability, quant uyumu vs. - BuildCommand(spec ShardSpec) (*exec.Cmd, error) // argüman + env üretimi + Validate(p Profile, gpus []GPU) error // compute capability, quantization fit, ... + BuildCommand(spec ShardSpec) (*exec.Cmd, error) // builds arguments and environment HealthURL(spec ShardSpec) string MetricsURL(spec ShardSpec) string - ParseMetrics(r io.Reader) (EngineStats, error) // engine'e özgü metrik adları + ParseMetrics(r io.Reader) (EngineStats, error) // engine-specific metric names } type ShardSpec struct { ReplicaID string - Rank int // 0 = leader (HTTP'yi sunan) + Rank int // 0 = leader (the one serving HTTP) NNodes int - LeaderAddr string // dist init adresi (ethernet IP) + LeaderAddr string // dist init address (ethernet IP) LeaderPort int GPUIndices []int - HCAs []string // seçilen fabric'in HCA'ları + HCAs []string // HCAs of the chosen fabric HTTPPort int Profile Profile ModelPath string } type EngineStats struct { - // §7.6'daki zorunlu kontrat — bunlar olmadan router yönlendiremez + // The mandatory contract from §7.6 — without these the router cannot route. Running, Waiting int KVCacheUsage float64 // 0..1 (vllm:kv_cache_usage_perc) - // opsiyonel + // Optional. BlockSize, NumGPUBlocks int PrefixHitRate float64 - TTFTp50, TPOTp50 time.Duration // histogramdan - DrainRate float64 // gözlenen tamamlanma/sn; `load` scorer'ı için + TTFTp50, TPOTp50 time.Duration // from histograms + DrainRate float64 // observed completions/sec; for the `load` scorer LoRA LoRAState // v2 - ScrapedAt time.Time // stale tespiti için — router bunu kullanır + ScrapedAt time.Time // for staleness detection — the router uses this } ``` -Yeni bir engine eklemenin tanımı = §7.6'daki zorunlu üçlüyü doldurabilmek. +Supporting a new engine is defined as: being able to fill in the mandatory three from §7.6. -**Multi-node başlatma:** -- SGLang: `--nnodes N --node-rank R --dist-init-addr host:port --tp T` doğal olarak destekler. -- vLLM: kullanılan sürüme göre ya Ray (leader'da `ray start --head`, worker'larda `ray start --address`) ya da yeni sürümlerdeki çok-node multiprocessing bayrakları. **Kullanacağın vLLM sürümünün dokümanını kontrol et**; `vllm.go` içinde iki yol da `launchMode` ile seçilebilir olsun. +**Multi-node launch:** +- SGLang supports it natively: `--nnodes N --node-rank R --dist-init-addr host:port --tp T`. +- vLLM: depending on the version, either Ray (`ray start --head` on the leader, `ray start --address` on the workers) or the newer multi-node multiprocessing flags. **Check the documentation for the vLLM version you deploy**; `vllm.go` should offer both paths behind a `launchMode`. -**Env üretimi (kritik):** +**Environment generation (critical):** ``` CUDA_VISIBLE_DEVICES= -NCCL_IB_HCA= # sadece seçilen fabric -NCCL_SOCKET_IFNAME= # bootstrap -NCCL_IB_DISABLE=1 # tek-node veya IB'siz ise -NCCL_CROSS_NIC / NCCL_NET_GDR_LEVEL # profile'dan opsiyonel -GLOO_SOCKET_IFNAME= +NCCL_IB_HCA= # the chosen fabric only +NCCL_SOCKET_IFNAME= # bootstrap +NCCL_IB_DISABLE=1 # single node, or no IB +NCCL_CROSS_NIC / NCCL_NET_GDR_LEVEL # optional, from the profile +GLOO_SOCKET_IFNAME= ``` ### 5.4 Supervisor -- Süreçleri kendi process group'unda başlat (`SysProcAttr.Setpgid`), durdururken tüm grubu öldür (SIGTERM → timeout → SIGKILL). -- stdout/stderr → satır bazlı slog'a + halka tampon (son N satır, `llmgw ctl logs` için). -- Durum makinesi: `Pending → Starting → Loading → Ready → Draining → Stopped | Failed`. -- `Ready` ancak `/health` 200 ve `/v1/models` modeli listeliyorsa. -- Model yükleme uzun sürer; `startupTimeout` profile'dan gelir (ör. 20 dk). -- Çökme: control plane'e raporla, yeniden başlatma kararını **control plane** verir (multi-node replikada tek shard'ı yeniden başlatmak işe yaramaz; tüm replika yeniden kurulmalı). -- Agent yeniden başlarsa: çalışan engine süreçlerini (pid dosyası + cmdline kontrolü) sahiplenmeyi dene, yoksa temizle. +- Start processes in their own process group (`SysProcAttr.Setpgid`) and kill the whole group when stopping (SIGTERM → timeout → SIGKILL). +- stdout/stderr → line-oriented slog plus a ring buffer (the last N lines, for `llmgw ctl logs`). +- State machine: `Pending → Starting → Loading → Ready → Draining → Stopped | Failed`. +- `Ready` only once `/health` returns 200 **and** `/v1/models` lists the model. +- Model loading takes a long time; `startupTimeout` comes from the profile (e.g. 20 min). +- On a crash: report to the control plane and let **the control plane** decide about restarting. Restarting a single shard of a multi-node replica accomplishes nothing; the whole replica must be rebuilt. +- If the agent restarts, try to adopt still-running engine processes (pid file plus cmdline check) and clean up whatever cannot be adopted. -### 5.5 Model ağırlıkları -- v1: paylaşımlı dosya sistemi (NFS/Lustre/Weka) veya node'da önceden indirilmiş yol. Agent sadece varlığını ve boyutunu kontrol eder. -- v1.1: agent'ın HF/S3'ten yerel cache'e indirmesi, ilerleme raporlaması, disk kotası. +### 5.5 Model weights +- v1: a shared filesystem (NFS/Lustre/Weka) or a path pre-populated on the node. The agent only checks existence and size. +- v1.1: the agent downloads from HF/S3 into a local cache, reports progress, and enforces a disk quota. -### 5.6 Agent ↔ Control plane protokolü -Agent bağlantıyı başlatır (NAT/firewall dostu), çift yönlü stream: +### 5.6 Agent ↔ control plane protocol +The agent dials out (NAT- and firewall-friendly) over a bidirectional stream: ```proto service AgentService { @@ -287,10 +296,10 @@ service AgentService { message AgentMessage { oneof msg { - Register register = 1; // node bilgisi, GPU'lar, fabric'ler, sürümler - Heartbeat heartbeat = 2; // her 5 sn: GPU sağlığı, shard durumları - ShardStatus shard = 3; // durum değişimi anında - EngineStats stats = 4; // her 2 sn (router da doğrudan scrape edebilir) + Register register = 1; // node info, GPUs, fabrics, versions + Heartbeat heartbeat = 2; // every 5s: GPU health, shard states + ShardStatus shard = 3; // sent on every state change + EngineStats stats = 4; // every 2s (the router may also scrape directly) } } @@ -304,22 +313,22 @@ message ControlMessage { } ``` -- Komutlar idempotent: `StartShard` aynı `shard_id` ile tekrar gelirse no-op. -- mTLS: node kimliği sertifika CN'sinden. v1'de basit bir iç CA; `llmgw ctl cert issue`. -- Heartbeat 3 kez kaçarsa node `Unreachable`, 60 sn sonra `Lost` → üzerindeki replikalar yeniden planlanır. +- Commands are idempotent: a repeated `StartShard` with the same `shard_id` is a no-op. +- mTLS, with node identity taken from the certificate CN. A simple internal CA in v1; `llmgw ctl cert issue`. +- Three missed heartbeats mark the node `Unreachable`; after 60s it becomes `Lost` and its replicas are rescheduled. --- ## 6. Control Plane -### 6.1 Veri modeli (özet) +### 6.1 Data model (abridged) ```go type Node struct { ID, Hostname, MgmtAddr string Labels map[string]string GPUs []GPU - Fabrics []FabricAttachment // boşsa IB'siz + Fabrics []FabricAttachment // empty means no IB State NodeState // Ready, Cordoned, Draining, Unreachable, Lost LastSeen time.Time } @@ -331,12 +340,12 @@ type FabricAttachment struct { } type Model struct { - Name string // kullanıcıya görünen + Name string // what users see Aliases []string - Source string // yol veya HF id + Source string // path or HF id Profiles []Profile - Scaling Scaling // min/max replika, hedef kuyruk vs. - Fallbacks []string // başka model adları + Scaling Scaling // min/max replicas, target queue, ... + Fallbacks []string // other model names } type Profile struct { @@ -344,222 +353,232 @@ type Profile struct { GPUClass string // "h100-80g" Engine string // "vllm" TP, PP int - GPUsPerNode int // genelde TP - Nodes int // PP*... ; >1 ise fabric zorunlu + GPUsPerNode int // usually TP + Nodes int // PP*... ; a fabric is mandatory when > 1 Args []string // --quantization fp8 --max-model-len 32768 ... Env map[string]string - Weight int // router için kapasite ağırlığı (benchmark'tan) - Priority int // yerleşim tercihi + Weight int // capacity weight for the router (from benchmarks) + Priority int // placement preference StartupTimeout time.Duration } type Replica struct { ID, Model, Profile string - Fabric string // multi-node ise dolu + Fabric string // set for multi-node replicas Shards []Shard State ReplicaState - Endpoint string // rank0 HTTP adresi + Endpoint string // rank0's HTTP address } ``` ### 6.2 Scheduler (placement) -Girdi: istenen replika sayısı, profile listesi, envanter. -Kısıtlar (**hard**): -1. Tüm shard'lar aynı `GPUClass`. -2. `Nodes > 1` ise tüm node'lar aynı `Fabric`'e bağlı olmalı ve o fabric'te ACTIVE HCA'ları olmalı. -3. `GPUsPerNode` kadar boş GPU; tercihen aynı NVLink domain'inde ve HCA ile aynı NUMA'da. -4. Node `Ready` ve `Cordoned` değil. -5. Engine `Validate` geçmeli (ör. FP8 profile'ı compute capability < 8.9 olan karta konmaz). +Input: the desired replica count, the profile list, and the inventory. -Tercihler (**soft**, skor): -- Profile `Priority` -- Bin-packing: kısmen dolu node'ları önce doldur (büyük modeller için boş node'ları koru) -- Aynı modelin replikalarını farklı fabric/node'lara yay (hata alanı dağıtımı) -- GPU-HCA NUMA yakınlığı +**Hard** constraints: +1. All shards share one `GPUClass`. +2. When `Nodes > 1`, every node must attach to the same `Fabric` and have ACTIVE HCAs on it. +3. `GPUsPerNode` free GPUs, preferably in the same NVLink domain and on the same NUMA node as the HCA. +4. The node is `Ready` and not `Cordoned`. +5. The engine's `Validate` must pass (e.g. an FP8 profile never lands on a card with compute capability < 8.9). -Algoritma (v1, basit ve deterministik): +**Soft** preferences (scored): +- the profile's `Priority` +- bin packing: fill partially used nodes first, keeping empty nodes free for large models +- spread a model's replicas across fabrics and nodes (failure domain distribution) +- GPU-to-HCA NUMA affinity + +Algorithm (v1, simple and deterministic): ``` for each profile in model.Profiles sorted by Priority desc: pools := groupNodes(by GPUClass == profile.GPUClass, by Fabric if profile.Nodes > 1) for each pool sorted by score: - nodes := pickNodes(pool, profile.Nodes, profile.GPUsPerNode) // greedy + NVLink grubu seçimi + nodes := pickNodes(pool, profile.Nodes, profile.GPUsPerNode) // greedy + NVLink group selection if nodes != nil: return Placement{profile, pool.Fabric, nodes, gpuAssignments} -return ErrUnschedulable (sebepleriyle birlikte — debug için çok önemli) +return ErrUnschedulable (with reasons — essential for debugging) ``` -`groupNodes`: IB'siz node'lar `Nodes == 1` profile'ları için havuza girer; `Nodes > 1` için asla. +`groupNodes`: nodes without IB join the pool for `Nodes == 1` profiles, and never for `Nodes > 1`. -`ctl` komutu: `llmgw ctl explain-placement ` her havuzun neden elendiğini göstermeli. +`llmgw ctl explain-placement ` must show why each pool was rejected. -### 6.2.1 Heterojen GPU: SLO/kapasite modeli +### 6.2.1 Heterogeneous GPUs: the SLO/capacity model -Farklı GPU sınıflarını "ağırlık" gibi tek bir skalerle karşılaştırmak yetersiz — AIBrix'in -heterojen GPU optimizer'ından alınan model daha doğru. Her (model, profile) çifti için -`ctl bench` (§10) bir **kapasite profili** üretir: +Comparing GPU classes through a single scalar "weight" is too crude; the model taken from +AIBrix's heterogeneous GPU optimizer is more accurate. For each (model, profile) pair, +`ctl bench` (§10) produces a **capacity profile**: ```go type CapacityProfile struct { Model, Profile string GPUClass string - // SLO'yu karşılayan maksimum eşzamanlılık; asıl kapasite ölçüsü + // Maximum concurrency that still meets the SLO; the real capacity measure. MaxConcurrencyAtSLO int ThroughputTokPerSec float64 TTFTp99, TPOTp99 time.Duration - RelativeCost float64 // GPU-saat cinsinden göreli maliyet (config'ten) + RelativeCost float64 // relative GPU-hour cost, from configuration MeasuredAt time.Time - EngineVersion string // sürüm değişince profil geçersiz + EngineVersion string // the profile expires when the version changes } ``` -SLO örneği: `TPOT p99 ≤ 50ms && TTFT p99 ≤ 2s`. +An example SLO: `TPOT p99 ≤ 50ms && TTFT p99 ≤ 2s`. -Bu profil üç yerde kullanılır: -1. **Router ağırlığı:** `Profile.Weight` artık elle yazılmaz, `MaxConcurrencyAtSLO`'dan türetilir. -2. **Admission eşikleri:** `TargetWaitingPerReplica` (§7.2.1) ölçülen değerden önerilir. -3. **Yerleşim/autoscaling hedefi:** "SLO'yu karşılayarak toplam maliyeti minimize et". Tahmini - yük için gereken replika sayısı GPU sınıfı başına `ceil(load / MaxConcurrencyAtSLO)` ile - hesaplanır; maliyeti en düşük kombinasyon seçilir. v1'de bu sadece `ctl` ile öneri üretir - (`ctl plan --target-rps N`), otomatik uygulama v1.1 autoscaler'ında. +The profile is used in three places: +1. **Router weight:** `Profile.Weight` is no longer hand-written; it derives from `MaxConcurrencyAtSLO`. +2. **Admission thresholds:** `TargetWaitingPerReplica` (§7.2.1) is suggested from the measurement. +3. **Placement and autoscaling objective:** "minimize total cost while meeting the SLO". For a + predicted load, the replica count needed per GPU class is `ceil(load / MaxConcurrencyAtSLO)`, + and the cheapest combination wins. In v1 this only produces advice via + `ctl plan --target-rps N`; automatic application arrives with the v1.1 autoscaler. -`EngineVersion` veya profile argümanları değiştiğinde ölçüm bayatlar: llmgw bunu uyarı olarak -yüzeye çıkarmalı (`llmgw_capacity_profile_stale{model,profile}`), sessizce eski sayıyı kullanmamalı. +When `EngineVersion` or the profile arguments change the measurement goes stale. llmgw must +surface that (`llmgw_capacity_profile_stale{model,profile}`) rather than silently reusing an +obsolete number. ### 6.3 Reconciler -- Her 5 sn veya olay geldiğinde: desired (model scaling) ile actual (replica durumları) karşılaştır. -- Eksik replika → schedule → shard'lar için `StartShard` gönder (önce worker'lar, sonra rank0 veya engine'in istediği sıra). -- Replika içindeki herhangi bir shard `Failed` → tüm replikayı durdur, backoff ile yeniden planla. -- Fazla replika → önce `Drain` (router yeni istek göndermez, mevcutlar biter), sonra `Stop`. -- Node `Lost` → replikalarını `Failed` say. +- Every 5s, or on an event: compare desired (model scaling) with actual (replica states). +- Missing replica → schedule → send `StartShard` for each shard (workers first, then rank0, or whatever order the engine requires). +- Any shard in a replica `Failed` → stop the whole replica and reschedule with backoff. +- Excess replicas → `Drain` first (the router stops sending new requests, existing ones finish), then `Stop`. +- Node `Lost` → treat its replicas as `Failed`. -Autoscaling (v1.1): model başına `targetWaitingPerReplica`; router metriklerinden ortalama hesaplanır, cooldown'lı ölçekleme. Boşta GPU yoksa düşük öncelikli modelleri küçültme (preemption) v2. +Autoscaling (v1.1): a per-model `targetWaitingPerReplica`, averaged from router metrics, with +cooldown-guarded scaling. Shrinking lower-priority models when no GPUs are free (preemption) is v2. -### 6.4 Router ile arayüz +### 6.4 Interface to the router ```go type EndpointSource interface { - // model adı -> Ready replikalar (+ pool, weight, fabric, gpuclass, capacity profile) + // model name -> Ready replicas (+ pool, weight, fabric, gpuclass, capacity profile) Endpoints(model string) []Endpoint - Subscribe() <-chan struct{} // değişiklik bildirimi + Subscribe() <-chan struct{} // change notification } ``` -v1'de bellek içi; router ve control ayrılırsa aynı interface bir watch stream'i ile gerçeklenir. +In-memory in v1; once the router and control plane split apart, the same interface is backed by +a watch stream. -Router bu listeyi **her zaman önbellekte tutar**: control plane düşse bile mevcut replikalara -yönlendirmeye devam eder (§14'teki SPOF riskinin azaltılması). Önbellek yaşlandığında -(`endpoint_cache_max_age`, ör. 5 dk) sadece uyarı metriği üretir, trafiği kesmez. +The router **always caches** this list: even if the control plane goes down it keeps routing to +the replicas it knows about, which mitigates the single-point-of-failure risk in §14. When the +cache ages past `endpoint_cache_max_age` (say 5 min) it only raises a warning metric; it never +stops traffic. ### 6.5 Store -- SQLite, WAL modu. Tablolar: `nodes`, `models`, `profiles`, `replicas`, `shards`, `api_keys`, `usage_rollup`, `events`. -- Envanter ve replika durumu aslında agent'lardan yeniden kurulabilir; kalıcı olması gereken asıl şey katalog, anahtarlar ve kullanım. -- HA (v2): Raft ile replike edilmiş durum veya dış Postgres/etcd. +- SQLite in WAL mode. Tables: `nodes`, `models`, `profiles`, `replicas`, `shards`, `api_keys`, `usage_rollup`, `events`. +- Inventory and replica state can be rebuilt from the agents; what genuinely needs to persist is the catalog, the keys and the usage records. +- HA (v2): Raft-replicated state, or an external Postgres/etcd. --- ## 7. Router -### 7.1 İstek yaşam döngüsü +### 7.1 Request lifecycle ``` -İstek - ├─ 1. Auth : key → proje/takım, izinli modeller, öncelik bandı, yönlendirme profili - ├─ 2. Kota : RPM/TPM + bütçe (bellek içi sayaç; DB istek yolunda değil) +Request + ├─ 1. Auth : key → project/team, permitted models, priority band, routing profile + ├─ 2. Quota : RPM/TPM and budget (in-memory counters; no DB on the request path) ├─ 3. Parse : model, stream, messages/prompt, max_tokens, user. - │ Gövdenin kalanı JSON olarak dokunulmadan iletilir. - ├─ 4. Çözümleme : model/alias → aday havuzlar (fabric × gpu_class) - ├─ 5. ADMISSION : "şimdi mi?" §7.2 ← havuz doygunsa istek burada bekler - ├─ 6. SCHEDULING : "nereye?" §7.3 filter → score → pick - ├─ 7. Proxy : upstream'e stream, iptal propagasyonu §7.8 - └─ 8. İstek sonrası (asenkron): usage, spend, metrik, log + │ The rest of the body passes through as untouched JSON. + ├─ 4. Resolve : model/alias → candidate pools (fabric × gpu_class) + ├─ 5. ADMISSION : "now?" §7.2 ← waits here while the pool is saturated + ├─ 6. SCHEDULING : "where?" §7.3 filter → score → pick + ├─ 7. Proxy : stream to upstream, propagate cancellation §7.8 + └─ 8. After the request (async): usage, spend, metrics, logs ``` -**Kritik ayrım (GIE / llm-d'den alındı).** *Ne zaman* ilerleyeceği ile *nereye* gideceği iki ayrı -karardır ve ayrı tutulmalıdır. Ayrı tutulduğunda router istekleri kendi merkezî kuyruğunda tutup -öncelik ve tenant adaletini **istek engine kuyruğuna girmeden önce** uygulayabilir. +**The critical separation (taken from GIE / llm-d).** *When* a request proceeds and *where* it +goes are two distinct decisions and must stay distinct. Kept apart, the router can hold requests +in its own central queue and apply priority and tenant fairness **before they enter an engine +queue**. -Neden önemli: engine'in kendi kuyruğu FIFO'dur ve tenant/öncelik kavramı yoktur. İstek oraya bir -kez girdikten sonra hiçbir önceliklendirme yapılamaz. Bir batch işi 500 istekle vLLM kuyruğunu -doldurduğunda, arkadan gelen interaktif sohbet isteğinin TTFT'si çöker ve router'ın yapabileceği -hiçbir şey kalmaz. v0.1'de bu katman yoktu; **paylaşımlı bir küme için en kritik eksikti.** +Why this matters: an engine's own queue is FIFO and has no notion of tenants or priority. Once a +request is in there, no prioritization is possible. When a batch job fills the vLLM queue with +500 requests, the TTFT of an interactive chat request arriving behind them collapses and there +is nothing the router can do. v0.1 had no such layer; **for a shared cluster this was the most +serious omission.** -### 7.2 Admission control (Flow Control) +### 7.2 Admission control (flow control) -#### 7.2.1 Doygunluk dedektörü +#### 7.2.1 Saturation detector -Havuz başına tek bir doygunluk sinyali üretilir; iki metriği birleştirir (llm-d'nin yaklaşımı — -tek başına kuyruk derinliği yetmez, KV baskısı da gerçek bir tükenme sinyalidir): +One saturation signal is produced per pool, combining two metrics (llm-d's approach — queue +depth alone is not enough, KV pressure is a genuine exhaustion signal too): ```go -// replika başına, sonra havuz için p90 veya ortalama +// per replica, then aggregated across the pool as p90 or mean func saturation(e EngineStats, t Thresholds) float64 { - q := float64(e.Waiting) / t.TargetWaitingPerReplica // ör. hedef 4 - kv := e.KVCacheUsage / t.KVCeiling // ör. tavan 0.90 + q := float64(e.Waiting) / t.TargetWaitingPerReplica // e.g. target 4 + kv := e.KVCacheUsage / t.KVCeiling // e.g. ceiling 0.90 return math.Max(q, kv) } ``` -Hedef, engine'de **küçük ama sağlıklı bir tampon** tutmaktır: continuous batching'in verimli -çalışmasına yetecek kadar istek olsun, ama kuyruk şişmesin. `TargetWaitingPerReplica` çok -düşük olursa GPU aç kalır, çok yüksek olursa kuyruk gecikmesi geri gelir. Bu iki eşik -model/profile başına ayarlanabilir olmalı ve `ctl bench` (§10) bunları önermeli. +The goal is to keep a **small but healthy buffer** on the engine: enough requests for continuous +batching to stay efficient, but not enough for the queue to balloon. Set +`TargetWaitingPerReplica` too low and the GPU starves; too high and queueing delay returns. Both +thresholds must be tunable per model/profile, and `ctl bench` (§10) should propose them. -#### 7.2.2 Öncelik bantları +#### 7.2.2 Priority bands -Her API key bir banda eşlenir. Bant, doygunluk tavanını belirler: +Every API key maps to a band, and the band sets the saturation ceiling: ```yaml bands: - - { name: platinum, priority: 400, saturation_ceiling: 1.00 } # hiç bekletme + - { name: platinum, priority: 400, saturation_ceiling: 1.00 } # never held back - { name: gold, priority: 300, saturation_ceiling: 0.85 } - { name: silver, priority: 200, saturation_ceiling: 0.60 } - - { name: bronze, priority: 100, saturation_ceiling: 0.30 } # batch işleri + - { name: bronze, priority: 100, saturation_ceiling: 0.30 } # batch jobs ``` -Dispatch döngüsü bantları yüksekten düşüğe tarar. Havuzun doygunluğu bandın tavanını aştıysa -o bandın istekleri kuyrukta bekler. Sonuç: küme dolarken önce batch trafiği yavaşlar, interaktif -trafik korunur. (llm-d ölçümlerinde platinum/gold/silver 1 sn altında kalırken bronze batch -belirgin şekilde geriye düşüyor — istenen davranış tam olarak bu.) +The dispatch loop scans bands from highest to lowest. Once pool saturation exceeds a band's +ceiling, that band's requests wait in the queue. The result: as the cluster fills, batch traffic +slows down first and interactive traffic is protected. (In llm-d's measurements platinum, gold +and silver stayed under a second while bronze batch fell far behind — exactly the desired +behaviour.) -#### 7.2.3 Adalet ve kuyruk yönetimi +#### 7.2.3 Fairness and queue management -- **Bant içi adalet:** aynı bantta birden fazla tenant varsa dispatch turları round-robin dağıtılır. - Tek bir tenant'ın ani yük artışı bandın tüm kapasitesini yutamaz. -- **Kapasite:** bant başına kuyruk boyu sınırlı. Dolduğunda yeni istek anında `503` (kuyrukta - sessizce birikmesindense hızlı reddedilsin). -- **TTL:** kuyrukta `queue_ttl` süresini aşan istek düşürülür (`504`). İstemcinin zaten vazgeçtiği - bir isteği GPU'ya göndermenin anlamı yok. -- **İstemci kopması:** kuyruktayken bağlantı koparsa istek sessizce düşer (context iptali). -- **Displacement (v1.1):** yüksek banda yer açmak için en düşük banttaki **en yeni** istekler - düşürülür (en eskiler zaten TTL'e yakın ve beklemeye en çok yatırım yapmış olanlar). +- **Fairness within a band:** when several tenants share a band, dispatch turns are distributed + round-robin. One tenant's traffic spike cannot consume the whole band. +- **Capacity:** each band's queue is bounded. When it is full, new requests get an immediate + `503` — far better than accumulating silently. +- **TTL:** a request that waits longer than `queue_ttl` is dropped (`504`). There is no point + sending the GPU work the client has already given up on. +- **Client disconnect:** if the connection drops while queued, the request is discarded quietly + (context cancellation). +- **Displacement (v1.1):** to make room for a higher band, drop the **newest** requests from the + lowest band — the oldest ones are already near their TTL and have invested the most waiting. ```go type Admission interface { - // İstek ilerleyebilene kadar bloke olur; ctx iptal/TTL'de hata döner. + // Blocks until the request may proceed; errors on cancellation or TTL. Acquire(ctx context.Context, req *RequestInfo) (Lease, error) } ``` -`Lease.Release()` yanıt tamamlandığında çağrılır; router'ın in-flight sayacını da bu tutar. +`Lease.Release()` is called once the response completes; it also maintains the router's inflight +counter. ### 7.3 Scheduler: filter → score → pick -v0.1'deki "strateji" listesi yerine eklenti çerçevesi (GIE EPP modeli). Her strateji ayrı bir -monolit olmaz; scorer'lar ağırlıklandırılarak birleştirilir. +The list of "strategies" from v0.1 is replaced by a plugin pipeline (the GIE EPP model). No +strategy is a separate monolith; scorers are combined by weight. ```go -type Filter interface { // adayı eler (hard constraint) +type Filter interface { // drops candidates (hard constraints) Name() string Filter(ctx *SchedCtx, eps []Endpoint) []Endpoint } -type Scorer interface { // 0..1 arası skor; 1 = en iyi +type Scorer interface { // 0..1 score; 1 is best Name() string Score(ctx *SchedCtx, eps []Endpoint) map[EndpointID]float64 } -type Picker interface { // skorlardan seçim +type Picker interface { // chooses from the scores Pick(ctx *SchedCtx, scores map[EndpointID]float64) Endpoint } @@ -571,178 +590,192 @@ type RoutingProfile struct { } ``` -Toplam skor: `score(ep) = Σ wᵢ · scoreᵢ(ep)` (her scorer kendi içinde 0..1'e normalize eder). +Total score: `score(ep) = Σ wᵢ · scoreᵢ(ep)`, with each scorer normalizing to 0..1 internally. -**İki aşamalı karar.** Heterojen topoloji yüzünden önce havuz, sonra replika seçilir: +**A two-stage decision.** Because the topology is heterogeneous, the pool is chosen first and +the replica second: -1. **Havuz seçimi:** `Priority` en yüksek havuz; oradaki tüm replikalar doygunsa (§7.2.1) bir - sonraki havuza taşma (spillover). Böylece H100 havuzu dolmadan A100 havuzuna düşülmez. -2. **Replika seçimi:** seçilen havuz içinde filter/score/pick. +1. **Pool selection:** the pool with the highest `Priority`; if every replica there is saturated + (§7.2.1), spill over to the next pool. This prevents falling back to the A100 pool before the + H100 pool is actually full. +2. **Replica selection:** filter/score/pick within the chosen pool. -**Yönlendirme profilleri** (config'ten, model başına varsayılan + `x-llmgw-profile` header'ı ile -istek başına override): +**Routing profiles**, from configuration (a per-model default, overridable per request via the +`x-llmgw-profile` header): -| Profil | Ağırlıklar | Kullanım | +| Profile | Weights | Use | |---|---|---| -| `chat` | prefix 3.0, queue 1.0, kv 1.0 | Çok turlu sohbet, agent — cache tekrarı yüksek | -| `batch` | queue 3.0, kv 1.0, prefix 0.5 | Toplu iş — throughput önemli, TTFT değil | -| `long-context` | kv 3.0, prefix 2.0, queue 1.0 | Uzun prompt — KV baskısı asıl darboğaz | -| `debug` | round-robin picker | Hata ayıklama | +| `chat` | prefix 3.0, queue 1.0, kv 1.0 | Multi-turn chat and agents — high cache reuse | +| `batch` | queue 3.0, kv 1.0, prefix 0.5 | Bulk work — throughput matters, TTFT does not | +| `long-context` | kv 3.0, prefix 2.0, queue 1.0 | Long prompts — KV pressure is the real bottleneck | +| `debug` | round-robin picker | Troubleshooting | -### 7.4 Scorer'lar ve formülleri +### 7.4 Scorers and their formulas -| Scorer | Formül | Not | +| Scorer | Formula | Notes | |---|---|---| -| `queue` | `1 − min(1, waiting / target_waiting)` | Kuyruk derinliği | -| `kv` | `1 − kv_cache_usage` | KV doluluğu; tavan üstü sert ceza | +| `queue` | `1 − min(1, waiting / target_waiting)` | Queue depth | +| `kv` | `1 − kv_cache_usage` | KV occupancy; a hard penalty above the ceiling | | `prefix` | `matched_blocks / prompt_blocks` | §7.5 | -| `load` | `1 − norm(running / drain_rate)` | AIBrix: `drain_rate` = gözlenen tamamlanma hızı. Yavaş bir replikayı `running` tek başına yakalayamaz. | -| `inflight` | `1 − norm(router_inflight)` | Router'ın kendi saydığı; scrape gecikmesini telafi eder. **Scrape aralığı 2 sn iken bu olmadan sürü etkisi kaçınılmaz.** | -| `pool-priority` | profile `Priority`'den sabit | Havuz tercihi | -| `session` | session hash eşleşmesi | §7.5 Kademe 0 | -| `lora` (v2) | adapter yüklü mü | `vllm:lora_requests_info` | +| `load` | `1 − norm(running / drain_rate)` | AIBrix: `drain_rate` is the observed completion rate. `running` alone cannot spot a slow replica. | +| `inflight` | `1 − norm(router_inflight)` | The router's own count; compensates for scrape lag. **At a 2s scrape interval, herding is unavoidable without it.** | +| `pool-priority` | constant, from the profile's `Priority` | Pool preference | +| `session` | session hash match | §7.5 tier 0 | +| `lora` (v2) | is the adapter loaded | `vllm:lora_requests_info` | -**Alternatif maliyet formu (Dynamo).** Prefill ve decode maliyetini doğrudan blok cinsinden -modellemek, normalize skorlardan daha isabetli olabilir: +**An alternative cost formulation (Dynamo).** Modelling prefill and decode cost directly in +blocks may be more accurate than normalized scores: ``` cost(ep) = w_overlap · (prompt_blocks − cached_blocks(ep)) + decode_blocks(ep) -seçim = argmin cost +choice = argmin cost ``` -`w_overlap` yüksek → cache tekrarını kollar (TTFT iyileşir); düşük → yükü eşitler (ITL iyileşir). -Her ikisi de `Scorer` olarak ifade edilebildiği için çerçeve bozulmaz; v1'de ağırlıklı skor, -v1.1'de bu maliyet formu bir scorer olarak eklenir ve `ctl bench` ile karşılaştırılır. +A high `w_overlap` favours cache reuse (better TTFT); a low one evens out load (better ITL). +Both can be expressed as a `Scorer`, so the pipeline survives either way: weighted scores in v1, +with this cost form added as a scorer in v1.1 and compared using `ctl bench`. -**Picker'lar:** -- `max-score`: en yüksek skor. Deterministik ama sürü etkisine açık. -- `p2c` (power-of-two-choices): rastgele iki aday, iyisi. Çok replikada varsayılan. -- `softmax(temperature)`: Dynamo'nun `router_temperature`'ı. Skorları logit sayıp örnekler. - `temperature=0` → argmax. Küçük bir sıcaklık, eşit skorlu replikalarda senkronize - kararların yarattığı salınımı kırar. +**Pickers:** +- `max-score`: the highest score. Deterministic but prone to herding. +- `p2c` (power of two choices): two random candidates, the better one. The default once there are many replicas. +- `softmax(temperature)`: Dynamo's `router_temperature`. Treats scores as logits and samples. `temperature=0` degenerates to argmax. A small temperature breaks the oscillation that synchronized decisions cause among equally scored replicas. -### 7.5 Prefix / KV cache farkındalığı — üç kademe +### 7.5 Prefix / KV cache awareness — three tiers -Bu, router'ın sağlayabileceği en büyük tek kazanç; ama kademeler arasındaki maliyet farkı çok -büyük. Kademeleri ayrı tutuyoruz ki model başına açılıp kapatılabilsin. +This is the single largest win the router can deliver, but the cost gap between the tiers is +enormous. They stay separate so they can be enabled per model. -#### Kademe 0 — Session affinity (v1, ucuz, yüksek getiri) -İstemcinin verdiği `x-session-id` header'ı (yoksa OpenAI `user` alanı) bounded-load consistent -hashing ile bir replikaya eşlenir. Replika aşırı yüklüyse (ortalamanın `load_factor` katı) sıradakine -geçilir. Kod olarak ~100 satır; production-stack ölçümlerinde round-robin'e göre cache hit -%93 → %96 civarı. Çok turlu sohbette ve agent iş yüklerinde en iyi maliyet/fayda oranı budur. +#### Tier 0 — session affinity (v1, cheap, high return) +The client-supplied `x-session-id` header (falling back to OpenAI's `user` field) maps to a +replica via bounded-load consistent hashing. If that replica is overloaded — beyond +`load_factor` times the average — the next one on the ring is used. Roughly 100 lines of code; +production-stack measured cache hits going from about 93% to 96% versus round robin. For +multi-turn chat and agent workloads this is the best cost/benefit ratio available. -#### Kademe 1 — Yaklaşık radix tree (v1.1, orta maliyet) -Router, gördüğü istek prefix'lerinden **worker'ların cache'ini tahmin eden** bir ağaç tutar -(SGLang router'ın yaklaşımı). Gerçek cache durumunu bilmez; lazy güncellenir, maliyeti düşüktür. +#### Tier 1 — approximate radix tree (v1.1, moderate cost) +The router maintains a tree that **estimates** each worker's cache from the request prefixes it +has seen (the SGLang router's approach). It does not know the true cache state; it updates +lazily and costs little. -Eşikler — gerçek implementasyonlarda kritik olan kısım bunlar: -- `cache_threshold`: eşleşme oranı bunun altındaysa "cache yok" sayılır, yük dengeleme devreye girer. -- `balance_abs_threshold` **ve** `balance_rel_threshold`: `max_load − min_load > abs` **ve** - `max_load / min_load > rel` ise **prefix tamamen yok sayılır** ve en az yüklü replikaya gidilir. - (AIBrix de aynı korumayı koyuyor.) Bu koruma olmadan prefix affinity tek bir replikayı boğar — - cache hit oranı artarken p99 gecikme çöker. -- `eviction_interval` + `max_tree_size`: ağaç sınırsız büyümesin. +The thresholds — the part that matters in real implementations: +- `cache_threshold`: below this match ratio, treat it as "no cache" and let load balancing decide. +- `balance_abs_threshold` **and** `balance_rel_threshold`: if `max_load − min_load > abs` + **and** `max_load / min_load > rel`, **ignore the prefix entirely** and send to the least + loaded replica. (AIBrix adds the same guard.) Without it, prefix affinity drowns one replica: + the cache hit rate rises while p99 latency collapses. +- `eviction_interval` plus `max_tree_size`: keep the tree from growing without bound. -Kararın hangi dalından geçtiği metriğe yazılmalı: `cache_hit | cache_miss_min_load | -load_balance_override | stale | no_tree | no_healthy_worker`. Bu olmadan prefix routing'i -ayarlamak körlemesine olur. +Which branch a decision took must be recorded as a metric: `cache_hit | cache_miss_min_load | +load_balance_override | stale | no_tree | no_healthy_worker`. Without that, tuning prefix routing +is guesswork. -#### Kademe 2 — Kesin KV index (v2, pahalı, opsiyonel) -vLLM `--kv-events-config` ile blok olaylarını ZMQ üzerinden yayınlar (`BlockStored`, -`BlockRemoved`). Router bunlara abone olup `block_hash → {replika}` haritası tutar; gerçek, -küresel bir görünüm elde eder. +#### Tier 2 — precise KV index (v2, expensive, optional) +vLLM publishes block events over ZMQ with `--kv-events-config` (`BlockStored`, `BlockRemoved`). +The router subscribes and maintains a `block_hash → {replica}` map, giving it a true global view. -**Tuzak — planın kabul etmesi gereken maliyet:** blok hash'leri vLLM'in **token-id tabanlı** -şemasıyla birebir aynı hesaplanmalıdır. Bu, router'ın istekleri **engine ile aynı tokenizer'la** -tokenize etmesini zorunlu kılar. Go tarafında bu demek: her model için tokenizer yüklemek -(cgo ile HF `tokenizers` veya saf Go bir implementasyon), sürüm uyumunu takip etmek ve istek -başına tokenizasyon CPU maliyetini ödemek. AIBrix bu yüzden "remote tokenizer" zorunluluğu -koyuyor. **Kademe 0 ve 1 tokenizer gerektirmez** — bu yüzden onlar v1'de, bu v2'de. +**The trap — a cost the plan must acknowledge:** block hashes must be computed identically to +vLLM's **token-id-based** scheme. That forces the router to tokenize requests with **the same +tokenizer as the engine**. On the Go side that means loading a tokenizer per model (HF +`tokenizers` via cgo, or a pure-Go implementation), tracking version compatibility, and paying +the tokenization CPU cost per request. This is why AIBrix requires a "remote tokenizer". +**Tiers 0 and 1 need no tokenizer** — hence they are v1 and this is v2. -Olay kaybı: olay sıra numaralarında boşluk görülürse replay endpoint'inden eksik aralık istenir; -alınamazsa o replikanın index'i düşürülür ve replika **Kademe 1'e** düşer (sessizce yanlış -yönlendirme yapmaktansa yaklaşık bilgiye dönmek doğrudur). Replika yeniden başlarsa index sıfırlanır. +Event loss: if a gap appears in the event sequence numbers, request the missing range from the +replay endpoint; if that fails, drop the index for that replica and fall back to **tier 1** for +it. Returning to approximate information beats silently routing on wrong data. If a replica +restarts, its index is reset. -### 7.6 Engine metrik kontratı +### 7.6 The engine metric contract -Bu, "yeni bir engine eklemek" demenin resmî tanımıdır. Bir engine bu üçlüyü Prometheus -formatında verirse llmgw onu yönlendirebilir (GIE'nin model server protokolü): +This is the formal definition of "adding a new engine". If an engine exposes these three metrics +in Prometheus format, llmgw can route to it (GIE's model server protocol): -| Kontrat alanı | vLLM metriği | Zorunlu | +| Contract field | vLLM metric | Mandatory | |---|---|---| -| `Waiting` | `vllm:num_requests_waiting` | ✅ | -| `Running` | `vllm:num_requests_running` | ✅ | -| `KVCacheUsage` | `vllm:kv_cache_usage_perc` | ✅ | -| `BlockSize`, `NumGPUBlocks` | `vllm:cache_config_info` | ➖ | -| LoRA durumu | `vllm:lora_requests_info` (`max_lora`, `running_lora_adapters`, `waiting_lora_adapters`) | ➖ | +| `Waiting` | `vllm:num_requests_waiting` | yes | +| `Running` | `vllm:num_requests_running` | yes | +| `KVCacheUsage` | `vllm:kv_cache_usage_perc` | yes | +| `BlockSize`, `NumGPUBlocks` | `vllm:cache_config_info` | no | +| LoRA state | `vllm:lora_requests_info` (`max_lora`, `running_lora_adapters`, `waiting_lora_adapters`) | no | -> Düzeltme: v0.1'de `vllm:gpu_cache_usage_perc` yazılmıştı; doğrusu `vllm:kv_cache_usage_perc`. -> Metrik adları sürümle değişiyor — `ParseMetrics` eşlemesi config'ten ezilebilir olmalı ve -> agent, engine sürümünü register ederken bildirmeli. +> Correction: v0.1 said `vllm:gpu_cache_usage_perc`; the correct name is +> `vllm:kv_cache_usage_perc`. Metric names drift between versions, so the `ParseMetrics` mapping +> must be overridable from configuration, and the agent must report the engine version when it +> registers. -Scrape kuralları: -- Her Ready endpoint 1–2 sn'de bir, endpoint başına goroutine, jitter ile. -- Başarısız scrape → endpoint "stale"; skoru kötüleşir ama hemen elenmez. -- 3 ardışık hata → breaker (§7.7). -- **Stale veriyle karar vermemek için** `inflight` scorer'ı (§7.4) her zaman açık kalır. +Scrape rules: +- Every Ready endpoint every 1–2s, one goroutine per endpoint, with jitter. +- A failed scrape marks the endpoint stale; its score drops but it is not removed. +- Three consecutive failures trip the breaker (§7.7). +- **So that decisions are never made purely on stale data**, the `inflight` scorer (§7.4) stays + enabled at all times. -### 7.7 Güvenilirlik +### 7.7 Reliability -- **Retry:** sadece upstream'e ilk byte yazılmadan önce ve bağlantı hatası / 503 / 429 durumunda, - farklı endpoint ile, en fazla N kez. Stream başladıktan sonra retry yok. -- **Cooldown (LiteLLM dersi):** hata veren endpoint `cooldown_seconds` boyunca aday havuzundan - çıkar. Uzun bir fallback listesi + cooldown'suz retry = retry fırtınası; bir endpoint'in - rate limit'i tüm kümeye yayılır. -- **Circuit breaker:** endpoint başına kapalı/açık/yarı-açık durum makinesi, üstel backoff. -- **Fallback:** modelin tüm endpoint'leri yok/dolu → `Fallbacks` listesindeki modele (key izin - veriyorsa). Yanıta `x-llmgw-served-model` header'ı. **Bütçe fallback'i:** key bütçesini aştıysa - reddetmek yerine daha ucuz bir modele düşürme seçeneği (§7.9). -- **Timeout:** bağlantı, ilk token (TTFT) ve toplam için ayrı; streaming'de token arası idle timeout. -- **İptal:** istemci bağlantıyı koparırsa upstream isteği de iptal et (context propagation) — - GPU boşa çalışmasın. Kuyrukta bekleyen istekler için de geçerli. -- **Drain:** replika `Draining` iken yeni istek almaz; aktif istekler bitene veya timeout'a kadar bekler. -- **Control plane kopması:** router son bilinen endpoint listesiyle çalışmaya devam eder (§14). +- **Retry:** only before the first byte has been written upstream, only on connection errors, + 503 or 429, always against a different endpoint, at most N times. No retries once the stream + has started. +- **Cooldown (the LiteLLM lesson):** an endpoint that errors leaves the candidate pool for + `cooldown_seconds`. A long fallback list plus retries without cooldown equals a retry storm: + one endpoint's rate limit spreads across the whole cluster. +- **Circuit breaker:** a closed/open/half-open state machine per endpoint, with exponential backoff. +- **Fallback:** when a model has no usable endpoints, fall back to a model in its `Fallbacks` + list (if the key permits it). The response carries an `x-llmgw-served-model` header. + **Budget fallback:** when a key exceeds its budget, optionally downgrade to a cheaper model + rather than rejecting the request (§7.9). +- **Timeouts:** separate limits for connection, first token (TTFT) and total, plus an + inter-token idle timeout while streaming. +- **Cancellation:** when the client disconnects, cancel the upstream request too (context + propagation) so the GPU stops working. This applies to queued requests as well. +- **Draining:** a `Draining` replica receives no new requests; active ones run to completion or + time out. +- **Control plane loss:** the router keeps serving from its last known endpoint list (§14). -### 7.8 Streaming proxy detayları +### 7.8 Streaming proxy details -- `http.Transport`: yüksek `MaxIdleConnsPerHost`, HTTP/1.1 keep-alive (engine'ler genelde h2c - desteklemez). -- `bufio.Reader` ile satır oku, `data:` satırlarını geçir, her event sonrası `http.Flusher.Flush()`. -- Usage için istemci `stream_options.include_usage` göndermemişse router ekleyebilir (config ile), - son chunk'taki usage'ı yakalar; istemci istememişse o chunk'ı istemciye iletmez. -- Upstream'in hata JSON'larını OpenAI formatına normalize et. -- Yanıt header'ları: `x-llmgw-replica`, `x-llmgw-pool`, `x-llmgw-served-model`, - `x-llmgw-queue-ms` (admission'da beklenen süre), `x-llmgw-cache-decision` (§7.5). +- `http.Transport` with a high `MaxIdleConnsPerHost` and HTTP/1.1 keep-alive (engines rarely + speak h2c). +- Read lines with `bufio.Reader`, pass `data:` lines through, and call `http.Flusher.Flush()` + after every event. +- If the client did not send `stream_options.include_usage`, the router may add it + (configurable) to capture usage from the final chunk, and then withhold that chunk from a + client that did not ask for it. +- Normalize upstream error JSON into OpenAI's format. +- Response headers: `x-llmgw-replica`, `x-llmgw-pool`, `x-llmgw-served-model`, + `x-llmgw-queue-ms` (time spent in admission) and `x-llmgw-cache-decision` (§7.5). -### 7.9 Kimlik, kota, kullanım (tenancy) +### 7.9 Identity, quota and usage (tenancy) -LiteLLM'in üretimde kanıtlanmış modeli: +LiteLLM's production-proven model: ``` -Organizasyon ─► Takım ─► Proje ─► API Key +Organization ─► Team ─► Project ─► API Key ``` -Her seviyede: bütçe (token veya sanal maliyet birimi), RPM/TPM limiti, izinli model listesi. -Key'de ayrıca: **öncelik bandı** (§7.2.2) ve **varsayılan yönlendirme profili** (§7.3). +Each level carries a budget (in tokens or notional cost units), RPM/TPM limits and a permitted +model list. A key additionally carries its **priority band** (§7.2.2) and its **default routing +profile** (§7.3). -- API key: `sk-llmgw-...`, DB'de sadece hash (SHA-256). Sızarsa upstream'e dokunmadan döndürülür. -- Rate limit: RPM (`x/time/rate`) + **TPM**. Token limiti istek başında `max_tokens + tahmini prompt` - ile rezerve edilir, yanıt bitince gerçek değerle düzeltilir. (Prompt tahmini v1'de bayt/4; - Kademe 2 tokenizer geldiğinde kesinleşir.) -- **Bütçe:** dönemsel (günlük/aylık) harcama. Aşımda: `429` veya bütçe fallback'i. Maliyeti sıfır - olan modeller bütçe kontrolünden muaf — böylece bütçesi bitmiş bir key'e "ucuz model" - fallback'i tanımlanabilir. -- **Spend yazımı istek yolunun dışında:** usage kaydı kanala → batch halinde SQLite'a; saatlik - rollup. İstek yolunda hiçbir DB yazımı yok. -- **Replika sabitleme (debug):** `x-llmgw-replica` header'ı, key'e scope'lu. Sabitlenen replika - cooldown'da veya yoksa normal yönlendirmeye düşer. -- v1 tek instance → bellek içi limiter. Çok instance olursa Redis veya tutarlı hash ile sticky (v2). +- API keys look like `sk-llmgw-...`; only the SHA-256 hash is stored. A leaked key is rotated + without touching upstream credentials. +- Rate limiting: RPM via `x/time/rate` plus **TPM**. The token limit is reserved at request + start as `max_tokens + estimated prompt` and reconciled against the real figure when the + response completes. (The prompt estimate is bytes/4 in v1; it becomes exact once the tier 2 + tokenizer arrives.) +- **Budgets:** periodic (daily/monthly) spend. On exhaustion: `429`, or a budget fallback. + Zero-cost models are exempt from budget checks, so a key whose budget is spent can still be + given a "cheap model" fallback. +- **Spend writes stay off the request path:** usage records go to a channel and are batched into + SQLite, with hourly rollups. Nothing writes to the database on the request path. +- **Replica pinning (debug):** the `x-llmgw-replica` header, scoped to the key. If the pinned + replica is in cooldown or gone, the request falls through to normal routing. +- v1 is a single instance with in-memory limiters. With multiple instances this becomes Redis or + consistent-hash stickiness (v2). --- -## 8. Gözlemlenebilirlik +## 8. Observability -Prometheus metrikleri (örnek): +Prometheus metrics (examples): ``` llmgw_requests_total{model,pool,status} llmgw_request_duration_seconds{model,pool} @@ -759,22 +792,22 @@ llmgw_agent_connected{node} llmgw_admission_queue_depth{band,model} llmgw_admission_wait_seconds{band,model} # histogram llmgw_admission_rejected_total{band,reason="full|ttl|client_gone"} -llmgw_pool_saturation{pool} # 0..1, dedektör çıktısı +llmgw_pool_saturation{pool} # 0..1, the detector's output -# yönlendirme kararları (§7.5) — bunlar olmadan prefix routing ayarlanamaz +# routing decisions (§7.5) — prefix routing cannot be tuned without these llmgw_cache_decision_total{model,decision="hit|miss_min_load|balance_override|stale|no_tree"} llmgw_prefix_match_ratio{model} # histogram llmgw_scorer_value{scorer,replica} llmgw_endpoint_metrics_stale{replica} llmgw_capacity_profile_stale{model,profile} ``` -- OpenTelemetry trace: router span → upstream span (traceparent header engine'e iletilir). -- Yapısal log (slog JSON), `request_id` her yerde. -- `llmgw ctl top`: havuz bazında anlık kuyruk ve doluluk. +- OpenTelemetry traces: router span → upstream span (the traceparent header is forwarded to the engine). +- Structured logs (slog JSON) with `request_id` everywhere. +- `llmgw ctl top`: live queue depth and occupancy per pool. --- -## 9. Konfigürasyon Örnekleri +## 9. Configuration Examples ### 9.1 server.yaml ```yaml @@ -790,7 +823,7 @@ store: fabrics: - name: ib-a - gid_prefix: "fec0:0000:0000:000a" # opsiyonel otomatik eşleme + gid_prefix: "fec0:0000:0000:000a" # optional automatic mapping - name: ib-b gid_prefix: "fec0:0000:0000:000b" @@ -798,12 +831,12 @@ router: scrape_interval: 2s inject_usage: true retries: 2 - cooldown_seconds: 15 # hata veren endpoint bu süre aday havuzdan çıkar - endpoint_cache_max_age: 5m # control plane düşerse bu kadar eskiye kadar çalışmaya devam + cooldown_seconds: 15 # how long a failing endpoint leaves the candidate pool + endpoint_cache_max_age: 5m # keep serving this long if the control plane goes down - # §7.2 admission — doygunluk dedektörü + # §7.2 admission — the saturation detector admission: - target_waiting_per_replica: 4 # ctl bench önerir + target_waiting_per_replica: 4 # proposed by ctl bench kv_ceiling: 0.90 queue_ttl: 30s queue_capacity_per_band: 2000 @@ -813,7 +846,7 @@ router: - { name: silver, priority: 200, saturation_ceiling: 0.60 } - { name: bronze, priority: 100, saturation_ceiling: 0.30 } - # §7.3 yönlendirme profilleri + # §7.3 routing profiles default_profile: chat profiles: chat: @@ -826,17 +859,17 @@ router: scorers: { kv: 3.0, prefix: 2.0, queue: 1.0 } picker: { type: softmax, temperature: 0.2 } - # §7.5 prefix farkındalığı + # §7.5 prefix awareness prefix: - session_affinity: { enabled: true, header: x-session-id, load_factor: 1.25 } # Kademe 0 - approx_tree: # Kademe 1 + session_affinity: { enabled: true, header: x-session-id, load_factor: 1.25 } # tier 0 + approx_tree: # tier 1 enabled: false # v1.1 cache_threshold: 0.30 balance_abs_threshold: 16 balance_rel_threshold: 1.5 max_tree_size: 2000000 eviction_interval: 60s - kv_events: { enabled: false } # Kademe 2 — tokenizer gerektirir, v2 + kv_events: { enabled: false } # tier 2 — needs a tokenizer, v2 models: - name: llama-3.3-70b @@ -844,7 +877,7 @@ models: scaling: { min: 2, max: 6 } fallbacks: [qwen2.5-32b] routing_profile: chat - slo: { ttft_p99: 2s, tpot_p99: 50ms } # ctl bench bu SLO'ya göre kapasite ölçer + slo: { ttft_p99: 2s, tpot_p99: 50ms } # ctl bench measures capacity against this SLO profiles: - name: h100-tp4-fp8 gpu_class: h100-80g @@ -870,7 +903,7 @@ models: gpu_class: h100-80g engine: sglang tp: 16 - nodes: 2 # → aynı fabric zorunlu + nodes: 2 # → must live on a single fabric gpus_per_node: 8 priority: 100 weight: 100 @@ -887,7 +920,7 @@ labels: { rack: r12 } fabrics: - name: ib-a hcas: [mlx5_0, mlx5_1, mlx5_2, mlx5_3] -port_range: [20000, 20999] # engine HTTP + dist init portları +port_range: [20000, 20999] # engine HTTP and dist-init ports engines: vllm: { bin: /opt/venvs/vllm/bin/vllm, launch_mode: ray } sglang: { python: /opt/venvs/sglang/bin/python } @@ -896,150 +929,150 @@ work_dir: /var/lib/llmgw-agent --- -## 10. Benchmark ve Ağırlıklandırma +## 10. Benchmarking and Weighting `llmgw ctl bench --profile

`: -- Hedef replikaya sabit prompt/çıktı uzunluklarıyla artan eşzamanlılıkta yük uygular. -- TTFT p50/p95, TPOT, throughput (tok/s), SLO'yu karşılayan maksimum eşzamanlılık ölçülür. -- Çıktı §6.2.1'deki `CapacityProfile`'dır: `MaxConcurrencyAtSLO`, throughput, TTFT/TPOT p99. -- `--apply` ile üç değeri birden günceller: profile `weight`, `target_waiting_per_replica`, - autoscaling hedefi. -- Farklı GPU sınıflarını karşılaştırmanın tek sağlıklı yolu bu; tahmini ağırlık kullanma. -- `--sweep-prefix` modu: prefix cache hit oranını 0/50/90% senaryolarıyla ölçer — yönlendirme - profillerinin ağırlıklarını (§7.3) körlemesine değil ölçerek ayarlamak için. -- Engine sürümü değişince profil bayatlar; `ctl bench` sonucu engine sürümüyle etiketlenir. +- Drives a target replica at increasing concurrency with fixed prompt and output lengths. +- Measures TTFT p50/p95, TPOT, throughput (tok/s) and the maximum concurrency that still meets the SLO. +- The output is the `CapacityProfile` from §6.2.1: `MaxConcurrencyAtSLO`, throughput, TTFT/TPOT p99. +- `--apply` updates three things at once: the profile's `weight`, `target_waiting_per_replica`, + and the autoscaling target. +- This is the only sound way to compare GPU classes; never use an estimated weight. +- `--sweep-prefix` mode measures behaviour at 0/50/90% prefix cache hit rates, so routing + profile weights (§7.3) can be tuned from measurements instead of guesses. +- A profile goes stale when the engine version changes, so every `ctl bench` result is tagged + with the engine version. --- -## 11. Yol Haritası +## 11. Roadmap -### Faz 0 — İskelet (1 hafta) -- [ ] `go mod init`, cobra komutları, koanf config, slog, Makefile, golangci-lint -- [ ] `sim-engine`: OpenAI uyumlu sahte engine (ayarlanabilir gecikme, token hızı, kuyruk, `/metrics` vLLM formatında) -- [ ] CI: `go test ./...`, race detector +### Phase 0 — Skeleton (1 week) +- [x] `go mod init`, cobra commands, koanf config, slog, Makefile, golangci-lint +- [x] `sim-engine`: OpenAI-compatible fake engine (tunable latency, token rate, queue, vLLM-format `/metrics`) +- [x] CI: `go test ./...`, race detector -### Faz 1 — Statik router (2 hafta) -- [ ] OpenAI handler'ları, streaming proxy, iptal propagasyonu -- [ ] Config'ten statik endpoint listesi -- [ ] **`Filter`/`Scorer`/`Picker` çerçevesi (§7.3)** — tek bir "strateji" değil, en baştan eklenti -- [ ] `queue`, `kv`, `inflight` scorer'ları; `max-score` ve `p2c` picker'ları -- [ ] Session affinity (§7.5 Kademe 0) — ucuz, erken kazanç -- [ ] Metrik scrape (§7.6 kontratı), stale tespiti, retry, cooldown, breaker -- [ ] Prometheus metrikleri + karar metrikleri (§8) -- **Çıkış kriteri:** 3 sim-engine arkasında 1000 eşzamanlı stream; istemci kopmalarında upstream'in - iptal edildiği doğrulanmış; router overhead p99 < 2 ms. +### Phase 1 — Static router (2 weeks) +- [ ] OpenAI handlers, streaming proxy, cancellation propagation +- [ ] Static endpoint list from configuration +- [x] **The `Filter`/`Scorer`/`Picker` pipeline (§7.3)** — a plugin pipeline from the start, not one "strategy" +- [x] `queue`, `kv`, `inflight` scorers; `max-score` and `p2c` pickers +- [x] Session affinity (§7.5 tier 0) — cheap, an early win +- [x] Metrics scraping (the §7.6 contract), staleness detection, retry, cooldown, breaker +- [ ] Prometheus metrics plus decision metrics (§8) +- **Exit criterion:** 1000 concurrent streams behind 3 sim-engines; upstream cancellation on + client disconnect verified; router overhead p99 < 2 ms. -### Faz 1.5 — Admission control (1 hafta) ⭐ -- [ ] Doygunluk dedektörü (§7.2.1) -- [ ] Öncelik bantları, bant içi tenant round-robin, kuyruk kapasitesi + TTL -- [ ] `Admission` interface + lease, kuyrukta iptal -- [ ] `llmgw_admission_*` metrikleri -- **Çıkış kriteri:** bronze banttan 500 eşzamanlı batch isteği akarken platinum bandından gelen - interaktif isteklerin TTFT p99'u boştaki değerin 1.5 katını aşmıyor. **Bu testi yazmadan - ileri gitme — Faz 1.5'in tek varlık sebebi bu.** +### Phase 1.5 — Admission control (1 week) ⭐ +- [ ] Saturation detector (§7.2.1) +- [ ] Priority bands, round-robin tenant fairness within a band, queue capacity and TTL +- [ ] `Admission` interface and lease, cancellation while queued +- [ ] `llmgw_admission_*` metrics +- **Exit criterion:** while 500 concurrent batch requests flow through the bronze band, the TTFT + p99 of interactive requests in the platinum band stays under 1.5× its idle value. **Do not + move on before writing this test — it is the entire reason phase 1.5 exists.** -### Faz 2 — Agent: keşif + tek node engine (2 hafta) -- [ ] GPU keşfi (NVML + fallback), IB keşfi, topoloji -- [ ] Engine interface, vLLM ve SGLang tek-node -- [ ] Supervisor (process group, log tamponu, durum makinesi, sahiplenme) +### Phase 2 — Agent: discovery and single-node engines (2 weeks) +- [ ] GPU discovery (NVML plus fallback), IB discovery, topology +- [ ] Engine interface, single-node vLLM and SGLang +- [ ] Supervisor (process groups, log buffer, state machine, adoption) - [ ] connect-go stream, register/heartbeat, mTLS -- **Çıkış kriteri:** gerçek bir node'da `ctl` ile model başlat/durdur. +- **Exit criterion:** start and stop a model on a real node via `ctl`. -### Faz 3 — Control plane (2–3 hafta) -- [ ] SQLite store + migration -- [ ] Katalog, envanter, scheduler (tek-node profile'lar), reconciler -- [ ] Router'ın `EndpointSource`'u dinamik hale gelir +### Phase 3 — Control plane (2–3 weeks) +- [ ] SQLite store and migrations +- [ ] Catalog, inventory, scheduler (single-node profiles), reconciler +- [ ] The router's `EndpointSource` becomes dynamic - [ ] `ctl`: nodes, models, replicas, cordon, drain, explain-placement, logs -- **Çıkış kriteri:** node kapatılınca replikalar başka node'a taşınıyor; router kesintisiz yönlendiriyor. +- **Exit criterion:** shutting down a node moves its replicas elsewhere while the router keeps serving. -### Faz 4 — Multi-node & fabric (2 hafta) -- [ ] Fabric kimliği (config + GID prefix), doğrulama -- [ ] Multi-node placement (fabric kısıtı), port ayırma, rank sıralı başlatma -- [ ] NCCL env üretimi, HCA seçimi -- [ ] Replika bazlı hata yönetimi (bir shard ölürse tümü) -- **Çıkış kriteri:** her iki fabric'te 2-node replika; bir node'u öldürünce replika aynı fabric'te yeniden kuruluyor, asla diğer fabric'e yayılmıyor. +### Phase 4 — Multi-node and fabrics (2 weeks) +- [ ] Fabric identity (config plus GID prefix), verification +- [ ] Multi-node placement (the fabric constraint), port allocation, rank-ordered startup +- [ ] NCCL environment generation, HCA selection +- [ ] Replica-level failure handling (one dead shard kills the replica) +- **Exit criterion:** a 2-node replica on each fabric; killing a node rebuilds the replica on the same fabric and never spreads it to the other one. -### Faz 5 — Üretim özellikleri (2–3 hafta) -- [ ] Tenancy: org/takım/proje/key hiyerarşisi, bütçe, RPM/TPM, usage kaydı (§7.9) -- [ ] Bütçe fallback'i, model allowlist, key→band eşlemesi -- [ ] Yönlendirme profilleri config'ten + `x-llmgw-profile` header'ı -- [ ] Yaklaşık radix tree (§7.5 Kademe 1) — eşikler ve karar metrikleriyle birlikte -- [ ] Fallback modeller, havuz taşması (spillover) +### Phase 5 — Production features (2–3 weeks) +- [ ] Tenancy: org/team/project/key hierarchy, budgets, RPM/TPM, usage records (§7.9) +- [ ] Budget fallback, model allowlists, key→band mapping +- [ ] Routing profiles from configuration plus the `x-llmgw-profile` header +- [ ] Approximate radix tree (§7.5 tier 1), with its thresholds and decision metrics +- [ ] Fallback models and pool spillover - [ ] `ctl bench` → `CapacityProfile` (§6.2.1), `ctl plan` - [ ] OTel tracing -- [ ] systemd unit'leri, dokümantasyon +- [ ] systemd units, documentation -### Faz 6 — v2 fikirleri -- [ ] Kesin KV index (§7.5 Kademe 2) — vLLM KV event'leri + tokenizer entegrasyonu -- [ ] Dynamo tarzı blok maliyet fonksiyonu, `softmax` picker'ın ayarlanması -- [ ] Admission displacement (düşük banttan istek düşürme) -- [ ] Autoscaling ve öncelik bazlı preemption +### Phase 6 — v2 ideas +- [ ] Precise KV index (§7.5 tier 2) — vLLM KV events plus tokenizer integration +- [ ] Dynamo-style block cost function, tuning the `softmax` picker +- [ ] Admission displacement (dropping requests from lower bands) +- [ ] Autoscaling and priority-based preemption - [ ] Control plane HA (Raft) -- [ ] Ağırlık indirme/cache yönetimi -- [ ] Prefill/decode ayrıştırması (§12) -- [ ] LoRA adapter yönlendirmesi (adapter'ı yüklü replikaya affinity) -- [ ] Dış provider desteği (fallback olarak) -- [ ] Admin web arayüzü +- [ ] Weight downloading and cache management +- [ ] Prefill/decode disaggregation (§12) +- [ ] LoRA adapter routing (affinity to a replica that already has the adapter) +- [ ] External provider support (as a fallback) +- [ ] Admin web UI --- -## 12. Prefill/Decode Ayrıştırması (v2 notları) -- Prefill ve decode replikaları **aynı fabric** içinde olmalı (KV transferi RDMA üzerinden). -- Router'da iki aşamalı akış: prefill replikasına gönder → KV handle → decode replikası. -- Engine desteği sürümlere çok bağlı (vLLM KV connector'ları, SGLang PD modu, NVIDIA Dynamo/NIXL). Kendi implementasyon yerine önce engine'in yerleşik PD router'ını bir "endpoint" gibi sarmak daha az riskli. -- llm-d ve Dynamo'nun gösterdiği: PD'nin kazancı esas olarak **uzun context**'te. Kısa promptlu - sohbet trafiğinde KV transferi maliyeti kazancı yiyor. Önce §10'daki bench ile iş yükünüzün - ISL/OSL dağılımını ölçün; PD'yi ancak uzun-context modelleri için açın. -- Scheduler'a `Role: prefill|decode` ve "aynı fabric'te eşli yerleşim" kısıtı eklenir. +## 12. Prefill/Decode Disaggregation (v2 notes) +- Prefill and decode replicas must live on the **same fabric** (KV transfer goes over RDMA). +- A two-stage flow in the router: send to a prefill replica → KV handle → decode replica. +- Engine support varies sharply by version (vLLM KV connectors, SGLang PD mode, NVIDIA Dynamo/NIXL). Wrapping the engine's own built-in PD router as a single "endpoint" is less risky than implementing this ourselves. +- What llm-d and Dynamo show: PD's benefit lies mainly in **long context**. For short-prompt chat + traffic the KV transfer cost eats the gain. Measure your workload's ISL/OSL distribution with + the benchmark in §10 first, and enable PD only for long-context models. +- The scheduler gains a `Role: prefill|decode` field and a "paired placement on the same fabric" constraint. --- -## 13. Test Stratejisi -- **Birim:** scheduler (tablo tabanlı: heterojen envanter + beklenen yerleşim / hata sebebi), balancer skorları, SSE parse, metrik parse (gerçek vLLM/SGLang `/metrics` çıktılarından fixture). -- **Özellik tabanlı:** scheduler çıktısında "multi-node replika tek fabric'te" ve "tek GPU class" invariant'ları her zaman doğru (`testing/quick` veya `rapid`). -- **Entegrasyon:** tek süreçte server + N sahte agent (sahte GPU/IB envanteri) + sim-engine'ler. -- **Kaos:** agent öldürme, ağ kesintisi (toxiproxy), engine çökmesi, yavaş engine, control plane'i - öldürüp router'ın önbellekle devam ettiğini doğrulama. -- **Öncelik izolasyonu:** Faz 1.5 çıkış kriteri — düşük öncelikli yük altında yüksek öncelikli - TTFT'nin korunduğu regresyon testi. Bu test CI'da kalıcı olmalı. -- **Yönlendirme kalitesi:** sim-engine'e gerçekçi bir prefix cache modeli koy (blok bazlı LRU), - sentetik çok-turlu trafikle cache hit oranını ölç. round-robin / session-affinity / approx-tree - karşılaştırması sayısal olsun. -- **Yük:** `k6` veya kendi Go yük üreticimiz; router'ın kendi overhead'ini ölç (hedef: p99 < 2 ms ek gecikme). -- **Gerçek donanım:** her fazın sonunda en az bir IB'li ve bir IB'siz node'da smoke test. +## 13. Test Strategy +- **Unit:** the scheduler (table-driven: heterogeneous inventory plus expected placement or failure reason), balancer scores, SSE parsing, metric parsing (fixtures taken from real vLLM/SGLang `/metrics` output). +- **Property-based:** the invariants "a multi-node replica lives on one fabric" and "one GPU class per replica" always hold in scheduler output (`testing/quick` or `rapid`). +- **Integration:** server plus N fake agents (fake GPU/IB inventory) plus sim-engines, in one process. +- **Chaos:** kill agents, break the network (toxiproxy), crash engines, slow engines, and kill + the control plane to verify the router keeps serving from its cache. +- **Priority isolation:** phase 1.5's exit criterion as a permanent regression test — high-priority + TTFT is preserved under low-priority load. This test must stay in CI. +- **Routing quality:** give sim-engine a realistic prefix cache model (block-level LRU) and + measure the hit rate against synthetic multi-turn traffic. The comparison between round robin, + session affinity and the approximate tree must be numeric. +- **Load:** `k6` or our own Go load generator; measure the router's own overhead (target: p99 under 2 ms of added latency). +- **Real hardware:** at the end of every phase, a smoke test on at least one node with IB and one without. --- -## 14. Riskler ve Açık Sorular -| Konu | Not | +## 14. Risks and Open Questions +| Topic | Note | |---|---| -| Engine sürüm farkları | CLI bayrakları ve metrik adları sık değişiyor → engine adapter'larını sürüm eşlemeli ve config'le ezilebilir yap. | -| vLLM multi-node başlatma | Ray mı yerleşik mp mi? Kullanılacak sürümde test edilmeli. | -| Fabric tespiti | OpenSM'lerde benzersiz subnet prefix ayarlanabilir mi? Değilse manuel etiket şart. | -| Aynı node'da çok fabric | Var mı? Varsa HCA-GPU NUMA eşleşmesi önemli. | -| Model depolama | Paylaşımlı FS var mı, yükleme süreleri? Startup timeout'ları buna göre. | -| Karma sürücü/CUDA sürümleri | Engine venv'leri GPU sınıfına göre farklı olmalı mı? (`engines` config'i profile başına override edilebilmeli.) | -| Tek control plane | v1'de SPOF. Router endpoint listesini önbellekte tutar (§6.4), control plane kısa süre düşse de çalışmaya devam eder. | -| Güvenlik | Engine portları sadece router'a açık olmalı (firewall / bind adresi). | -| **Tokenizer tutarlılığı** | Kesin KV index (§7.5 Kademe 2) router'ın engine ile **aynı tokenizer**'ı kullanmasını zorunlu kılar. Go'da bu cgo veya ayrı bir tokenizer servisi demek. Bu yüzden v2'ye bırakıldı; v1'in prefix çözümü tokenizer gerektirmiyor. TPM limitindeki prompt tahmini de bu yüzden v1'de yaklaşık (bayt/4). | -| **Prefix affinity'nin ters tepmesi** | Yük dengesizliği koruması (§7.5 Kademe 1 eşikleri) olmadan prefix routing cache hit oranını artırırken tek replikayı boğar ve p99'u bozar. Eşikler açılmadan prefix affinity **açılmamalı**. | -| **Admission eşiklerinin ayarı** | `target_waiting_per_replica` çok düşükse GPU aç kalır, çok yüksekse kuyruk gecikmesi geri gelir. Bu tek sayı sistemin davranışını belirliyor; `ctl bench` ile ölçülmeli, tahmin edilmemeli. | -| KV event kaybı | ZMQ fire-and-forget; olay kaybında router sessizce yanlış yönlendirir. Sıra numarası boşluğu tespiti + Kademe 1'e düşme zorunlu (§7.5). | -| Metrik bayatlığı | 2 sn'lik scrape aralığında 1000 istek/sn'de karar tamamen bayat veriye dayanır. `inflight` scorer'ı bu yüzden opsiyonel değil. | +| Engine version differences | CLI flags and metric names change often → make engine adapters version-aware and overridable from configuration. | +| vLLM multi-node launch | Ray or built-in multiprocessing? Must be tested on the version actually deployed. | +| Fabric detection | Can a unique subnet prefix be set on each OpenSM? If not, manual labelling is mandatory. | +| Multiple fabrics per node | Does this occur? If so, HCA-to-GPU NUMA matching matters. | +| Model storage | Is there a shared filesystem, and what are the load times? Startup timeouts follow from this. | +| Mixed driver/CUDA versions | Should engine virtualenvs differ per GPU class? (The `engines` config must be overridable per profile.) | +| Single control plane | A SPOF in v1. The router caches the endpoint list (§6.4) and keeps working through a brief control plane outage. | +| Security | Engine ports must be reachable only by the router (firewall or bind address). | +| **Tokenizer consistency** | The precise KV index (§7.5 tier 2) forces the router to use **the same tokenizer** as the engine. In Go that means cgo or a separate tokenizer service, which is why it is deferred to v2; v1's prefix approach needs no tokenizer. The same limitation makes the TPM prompt estimate approximate in v1 (bytes/4). | +| **Prefix affinity backfiring** | Without the load-imbalance guard (the §7.5 tier 1 thresholds), prefix routing raises the cache hit rate while drowning a single replica and wrecking p99. Prefix affinity **must not** be enabled before those thresholds are. | +| **Tuning the admission thresholds** | Too low a `target_waiting_per_replica` starves the GPU; too high brings queueing delay back. This single number governs the system's behaviour, so measure it with `ctl bench` rather than guessing. | +| KV event loss | ZMQ is fire-and-forget; a lost event makes the router silently misroute. Sequence-gap detection and falling back to tier 1 are mandatory (§7.5). | +| Metric staleness | At 1000 requests/sec with a 2s scrape interval, decisions rest entirely on stale data. That is why the `inflight` scorer is not optional. | --- -## 15. İlk Adımlar (VS Code'da buradan devam) -1. `mkdir llmgw && cd llmgw && go mod init /llmgw` -2. `cmd/llmgw/main.go`: cobra ile `server`, `agent`, `ctl`, `sim-engine` alt komutlarının boş iskeleti. -3. `internal/sim`: sahte engine — ilk yazılacak gerçek kod bu olsun, geri kalan her şey buna karşı test edilecek. -4. `internal/router/proxy`: streaming proxy + iptal testi. -5. `internal/router/scheduler`: `Filter`/`Scorer`/`Picker` interface'leri + `queue`, `kv`, - `inflight` scorer'ları. **Tek bir "least-load" fonksiyonu yazma** — çerçeveyi en baştan kur, - sonradan geri dönmek pahalı. -6. `internal/router/admission`: doygunluk dedektörü + bant kuyrukları. Faz 1.5. -7. Faz 1 ve Faz 1.5 çıkış kriterlerini sağlayan e2e testlerini yaz, sonra Faz 2'ye geç. +## 15. First Steps +1. `mkdir llmgw && cd llmgw && go mod init /llmgw` +2. `cmd/llmgw/main.go`: an empty cobra skeleton with the `server`, `agent`, `ctl` and `sim-engine` subcommands. +3. `internal/sim`: the fake engine — make this the first real code, because everything else is tested against it. +4. `internal/router/proxy`: streaming proxy plus a cancellation test. +5. `internal/router/scheduler`: the `Filter`/`Scorer`/`Picker` interfaces plus the `queue`, `kv` + and `inflight` scorers. **Do not write a single "least-load" function** — build the pipeline + from the start; coming back to it later is expensive. +6. `internal/router/admission`: saturation detector and band queues. Phase 1.5. +7. Write the end-to-end tests for the phase 1 and phase 1.5 exit criteria, then move to phase 2. -> `internal/router/balancer/` yerine `internal/router/scheduler/` (§4'teki ağaç buna göre -> güncellenmeli): "balancer" ismi yaptığı işten dar kalıyor — bu bileşen yük dengelemekten -> fazlasını yapıyor. +> Use `internal/router/scheduler/` rather than `internal/router/balancer/` (the tree in §4 +> reflects this): "balancer" understates the job — this component does more than balance load.