From 28029eb43b77057f1322923e99ebf4b408cf165d Mon Sep 17 00:00:00 2001 From: ilker Date: Wed, 16 Sep 2026 01:15:21 +0200 Subject: [PATCH] Switch the project language to English Comments, doc comments, test names, error and log messages, CLI help, README, Makefile, CI step names and the design document are now all in English. No behaviour changes; the full suite passes under the race detector. Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/ci.yml | 6 +- .golangci.yml | 2 +- Makefile | 4 +- README.md | 77 +-- cmd/llmgw/main.go | 6 +- internal/cli/root.go | 14 +- internal/cli/simengine.go | 38 +- internal/cli/stubs.go | 24 +- internal/obs/log.go | 6 +- internal/sim/cache.go | 44 +- internal/sim/cache_test.go | 54 +- internal/sim/engine.go | 139 +++-- internal/sim/engine_test.go | 81 ++- internal/sim/metrics.go | 18 +- internal/sim/server.go | 32 +- internal/sim/server_test.go | 89 +-- internal/sim/text.go | 14 +- internal/sim/tokens.go | 20 +- internal/sim/tokens_test.go | 12 +- internal/version/version.go | 4 +- llmgw-plan.md | 1095 ++++++++++++++++++----------------- 21 files changed, 910 insertions(+), 869 deletions(-) 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.