Plan v0.2'nin Faz 0 çıktısı. Yazılan sıraya göre:
- cobra tabanlı CLI iskeleti; server/agent/ctl komutları henüz stub.
- internal/sim: OpenAI uyumlu sahte inference engine. Router'ı GPU olmadan
gerçekçi koşullarda test edebilmek için gerçek bir engine'in router
açısından önemli davranışlarını modeller:
* continuous batching ve KV blok muhasebesi (head-of-line blocking dahil)
* zincirlenmiş blok hash'leri + LRU ile otomatik prefix cache
* batch büyüdükçe artan token gecikmesi
* istemci koptuğunda isteğin düşürülüp blokların bırakılması
* vLLM metrik adlarıyla /metrics (plan §7.6 engine kontratı)
- Testler: prefix hash zincirlemesi, LRU tahliyesi, iptal propagasyonu,
kuyruklama, SSE formatı, usage/cached_tokens, metriklerin gerçek
Prometheus parser'ıyla okunabilirliği.
- Gitea Actions CI: gofmt, vet, race detector.
Modül yolu gitea.mynodes.xyz/ilker/llmgw; `make rename` ile değiştirilebilir.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
e07bc46c57
24 files changed
+3443
No files matched your search
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
|
||||
- name: Biçim kontrolü
|
||||
run: |
|
||||
out=$(gofmt -l .)
|
||||
if [ -n "$out" ]; then echo "gofmt gerekli:"; echo "$out"; exit 1; fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test (race detector ile)
|
||||
run: go test -race ./...
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
@@ -0,0 +1,7 @@
|
||||
/bin/
|
||||
/dist/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.DS_Store
|
||||
coverage.out
|
||||
@@ -0,0 +1,30 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- bodyclose
|
||||
- errorlint
|
||||
- gocritic
|
||||
- misspell
|
||||
- noctx
|
||||
- revive
|
||||
- unconvert
|
||||
- usetesting
|
||||
|
||||
settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
- name: package-comments
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# Testlerde okunabilirlik uğruna gevşetilenler.
|
||||
- path: _test\.go
|
||||
linters: [bodyclose, noctx, errorlint]
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
@@ -0,0 +1,32 @@
|
||||
MODULE := gitea.mynodes.xyz/ilker/llmgw
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -X '$(MODULE)/internal/version.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build test race lint fmt clean rename
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/llmgw ./cmd/llmgw
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
race:
|
||||
go test -race ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
go vet ./...
|
||||
|
||||
clean:
|
||||
rm -rf bin dist
|
||||
|
||||
# Modül yolunu değiştirmek için: make rename NEW=git.ornek.com/ekip/llmgw
|
||||
rename:
|
||||
@test -n "$(NEW)" || (echo "kullanim: make rename NEW=<yeni/modul/yolu>"; 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
|
||||
go build ./...
|
||||
@@ -0,0 +1,69 @@
|
||||
# llmgw
|
||||
|
||||
Çoklu InfiniBand fabric ve heterojen GPU için tek binary LLM gateway.
|
||||
|
||||
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).
|
||||
|
||||
## Durum
|
||||
|
||||
| Faz | Kapsam | Durum |
|
||||
|---|---|---|
|
||||
| 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 | ⬜ |
|
||||
|
||||
## Kurulum
|
||||
|
||||
```sh
|
||||
make build # bin/llmgw
|
||||
make test # birim testleri
|
||||
make race # yarış dedektörüyle
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
- 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`
|
||||
|
||||
```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}'
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### Engine metrik kontratı
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
vllm:num_requests_running
|
||||
vllm:num_requests_waiting
|
||||
vllm:kv_cache_usage_perc
|
||||
```
|
||||
|
||||
## Modül yolu
|
||||
|
||||
`gitea.mynodes.xyz/ilker/llmgw`. Değiştirmek için:
|
||||
|
||||
```sh
|
||||
make rename NEW=git.ornek.com/ekip/llmgw
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Root().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hata:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module gitea.mynodes.xyz/ilker/llmgw
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/prometheus/common v0.71.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
|
||||
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -0,0 +1,48 @@
|
||||
// Package cli, llmgw'nin komut satırı yüzeyini kurar.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||
)
|
||||
|
||||
type globalFlags struct {
|
||||
logLevel string
|
||||
logFormat string
|
||||
}
|
||||
|
||||
var global globalFlags
|
||||
|
||||
// Root, kök komutu ve tüm alt komutları döner.
|
||||
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.",
|
||||
Version: version.Version,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
||||
obs.Setup(obs.Options{Level: global.logLevel, Format: global.logFormat})
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
root.AddCommand(
|
||||
newServerCmd(),
|
||||
newAgentCmd(),
|
||||
newCtlCmd(),
|
||||
newSimEngineCmd(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func logger() *slog.Logger { return slog.Default() }
|
||||
@@ -0,0 +1,87 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/sim"
|
||||
)
|
||||
|
||||
func newSimEngineCmd() *cobra.Command {
|
||||
cfg := sim.Default()
|
||||
var listen string
|
||||
|
||||
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.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
log := logger()
|
||||
eng := sim.New(cfg)
|
||||
defer eng.Close()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: listen,
|
||||
Handler: sim.NewServer(eng, log),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("sim-engine dinliyor",
|
||||
"addr", ln.Addr().String(),
|
||||
"model", cfg.Model,
|
||||
"max_num_seqs", cfg.MaxNumSeqs,
|
||||
"num_gpu_blocks", cfg.NumGPUBlocks,
|
||||
"prefix_cache_blocks", cfg.PrefixCacheBlocks,
|
||||
)
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.Serve(ln) }()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
log.Info("kapanıyor")
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutCtx)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"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)")
|
||||
|
||||
func newServerCmd() *cobra.Command {
|
||||
var configPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Router + control plane (Faz 1-3)",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return errNotImplemented
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configPath, "config", "server.yaml", "konfigürasyon dosyası")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAgentCmd() *cobra.Command {
|
||||
var configPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "GPU node'unda çalışan agent (Faz 2)",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return errNotImplemented
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configPath, "config", "agent.yaml", "konfigürasyon dosyası")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCtlCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ctl",
|
||||
Short: "Yönetim CLI'ı (Faz 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)"},
|
||||
} {
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: sub.use,
|
||||
Short: sub.short,
|
||||
RunE: func(_ *cobra.Command, _ []string) error { return errNotImplemented },
|
||||
})
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package obs, log ve metrik kurulumunu tek yerden yapar.
|
||||
package obs
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Options, log kurulumunu belirler.
|
||||
type Options struct {
|
||||
Level string // debug, info, warn, error
|
||||
Format string // text, json
|
||||
}
|
||||
|
||||
// Setup, slog varsayılan logger'ını kurar ve onu döner.
|
||||
func Setup(o Options) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch strings.ToLower(o.Level) {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
|
||||
hopts := &slog.HandlerOptions{Level: lvl}
|
||||
var h slog.Handler
|
||||
if strings.EqualFold(o.Format, "text") {
|
||||
h = slog.NewTextHandler(os.Stderr, hopts)
|
||||
} else {
|
||||
h = slog.NewJSONHandler(os.Stderr, hopts)
|
||||
}
|
||||
|
||||
l := slog.New(h)
|
||||
slog.SetDefault(l)
|
||||
return l
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
)
|
||||
|
||||
// BlockHash, KV cache'in muhasebe birimi olan bir bloğun kimliği.
|
||||
type BlockHash uint64
|
||||
|
||||
// BlockHashes, token dizisi için zincirlenmiş blok hash'leri üretir.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Son eksik blok yok sayılır — yarım blok cache'lenmez.
|
||||
func BlockHashes(tokens []Token, blockSize int) []BlockHash {
|
||||
if blockSize <= 0 || len(tokens) < blockSize {
|
||||
return nil
|
||||
}
|
||||
n := len(tokens) / blockSize
|
||||
out := make([]BlockHash, 0, n)
|
||||
var prev BlockHash
|
||||
buf := make([]byte, 8)
|
||||
for i := range n {
|
||||
h := fnv.New64a()
|
||||
binary.LittleEndian.PutUint64(buf, uint64(prev))
|
||||
_, _ = h.Write(buf)
|
||||
for _, t := range tokens[i*blockSize : (i+1)*blockSize] {
|
||||
binary.LittleEndian.PutUint32(buf[:4], uint32(t))
|
||||
_, _ = h.Write(buf[:4])
|
||||
}
|
||||
prev = BlockHash(h.Sum64())
|
||||
out = append(out, prev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PrefixCache, engine'in otomatik prefix cache'ini modelleyen blok bazlı 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.
|
||||
type PrefixCache struct {
|
||||
capacity int // blok cinsinden
|
||||
ll *list.List
|
||||
m map[BlockHash]*list.Element
|
||||
|
||||
queries int64
|
||||
hits int64
|
||||
}
|
||||
|
||||
// NewPrefixCache, verilen blok kapasitesinde bir cache kurar.
|
||||
// capacity <= 0 ise cache devre dışıdır (her zaman miss).
|
||||
func NewPrefixCache(capacity int) *PrefixCache {
|
||||
return &PrefixCache{
|
||||
capacity: capacity,
|
||||
ll: list.New(),
|
||||
m: make(map[BlockHash]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled, cache'in açık olup olmadığını söyler.
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
func (c *PrefixCache) Lookup(hashes []BlockHash) int {
|
||||
if !c.Enabled() {
|
||||
return 0
|
||||
}
|
||||
c.queries += int64(len(hashes))
|
||||
n := 0
|
||||
for _, h := range hashes {
|
||||
el, ok := c.m[h]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
c.ll.MoveToFront(el)
|
||||
n++
|
||||
}
|
||||
c.hits += int64(n)
|
||||
return n
|
||||
}
|
||||
|
||||
// Insert, blokları cache'e yazar (LRU'nun başına) ve kapasiteyi aşarsa en eskileri atar.
|
||||
func (c *PrefixCache) Insert(hashes []BlockHash) {
|
||||
if !c.Enabled() {
|
||||
return
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if el, ok := c.m[h]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
continue
|
||||
}
|
||||
c.m[h] = c.ll.PushFront(h)
|
||||
}
|
||||
for c.ll.Len() > c.capacity {
|
||||
el := c.ll.Back()
|
||||
if el == nil {
|
||||
break
|
||||
}
|
||||
c.ll.Remove(el)
|
||||
delete(c.m, el.Value.(BlockHash))
|
||||
}
|
||||
}
|
||||
|
||||
// Stats, cache sorgu ve isabet sayılarını döner (blok cinsinden).
|
||||
func (c *PrefixCache) Stats() (queries, hits int64) {
|
||||
if c == nil {
|
||||
return 0, 0
|
||||
}
|
||||
return c.queries, c.hits
|
||||
}
|
||||
|
||||
// Len, cache'teki blok sayısı.
|
||||
func (c *PrefixCache) Len() int {
|
||||
if !c.Enabled() {
|
||||
return 0
|
||||
}
|
||||
return c.ll.Len()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package sim
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBlockHashesZincirleme(t *testing.T) {
|
||||
// Aynı içerik farklı ön ekten sonra geliyorsa blok hash'i farklı olmalı.
|
||||
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))
|
||||
}
|
||||
if a[1] == b[1] {
|
||||
t.Error("farklı ön ekten sonra gelen aynı blok aynı hash'i verdi; zincirleme çalışmıyor")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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))
|
||||
}
|
||||
if short[0] != long[0] {
|
||||
t.Fatal("hizalı ön ek blok hash'i değişti; ön ek kararlı değil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockHashesYarimBlokAtilir(t *testing.T) {
|
||||
// 3 token, blockSize 2 -> 1 tam blok
|
||||
if got := len(BlockHashes(make([]Token, 3), 2)); got != 1 {
|
||||
t.Errorf("beklenen 1 blok, alınan %d", got)
|
||||
}
|
||||
if got := len(BlockHashes(make([]Token, 1), 2)); got != 0 {
|
||||
t.Errorf("tam blok yokken blok üretildi: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixCacheKesintisizOnEk(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)
|
||||
}
|
||||
// Ortası eksik: ilk blok hit, sonrası miss olmalı.
|
||||
if n := c.Lookup([]BlockHash{1, 99, 3}); n != 1 {
|
||||
t.Errorf("kesintisiz ön ek 1 olmalıydı, alınan %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixCacheLRUTahliye(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
|
||||
|
||||
if n := c.Lookup([]BlockHash{1}); n != 1 {
|
||||
t.Error("taze blok tahliye edildi")
|
||||
}
|
||||
if n := c.Lookup([]BlockHash{2}); n != 0 {
|
||||
t.Error("en eski blok tahliye edilmeliydi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixCacheKapali(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Package sim, OpenAI uyumlu sahte bir inference engine'i barındırır.
|
||||
//
|
||||
// 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
|
||||
package sim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config, simüle edilen engine'in davranışını belirler.
|
||||
type Config struct {
|
||||
Model string
|
||||
|
||||
// MaxNumSeqs, aynı anda çalışabilecek dizi sayısı (continuous batch kapasitesi).
|
||||
MaxNumSeqs int
|
||||
// BlockSize, bir KV cache bloğundaki token sayısı.
|
||||
BlockSize int
|
||||
// NumGPUBlocks, toplam KV cache bloğu.
|
||||
NumGPUBlocks int
|
||||
// PrefixCacheBlocks, prefix cache'in tutacağı blok sayısı. 0 = kapalı.
|
||||
PrefixCacheBlocks int
|
||||
|
||||
// PrefillTokensPerSec, engine genelinde paylaşılan prefill hızı.
|
||||
PrefillTokensPerSec float64
|
||||
// DecodeTokensPerSec, tek dizi çalışırken token üretim hızı.
|
||||
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 float64
|
||||
|
||||
// DefaultOutputTokens, istek max_tokens vermediğinde üretilecek token sayısı.
|
||||
DefaultOutputTokens int
|
||||
// Tick, simülasyon adımı. Küçük olması çözünürlüğü artırır.
|
||||
Tick time.Duration
|
||||
|
||||
// FailRate, isteklerin hangi oranda 500 ile reddedileceği (kaos testi için).
|
||||
FailRate float64
|
||||
// Seed, deterministik testler için.
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// Default, makul varsayılanlarla bir Config döner.
|
||||
// Kabaca 8xH100 üstünde 70B bir modelin davranışına benzer ölçekler.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Model: "sim-model",
|
||||
MaxNumSeqs: 64,
|
||||
BlockSize: 16,
|
||||
NumGPUBlocks: 4096,
|
||||
PrefixCacheBlocks: 8192,
|
||||
PrefillTokensPerSec: 12000,
|
||||
DecodeTokensPerSec: 60,
|
||||
BatchContention: 0.012,
|
||||
DefaultOutputTokens: 128,
|
||||
Tick: 5 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) normalize() {
|
||||
if c.Model == "" {
|
||||
c.Model = "sim-model"
|
||||
}
|
||||
if c.MaxNumSeqs <= 0 {
|
||||
c.MaxNumSeqs = 64
|
||||
}
|
||||
if c.BlockSize <= 0 {
|
||||
c.BlockSize = 16
|
||||
}
|
||||
if c.NumGPUBlocks <= 0 {
|
||||
c.NumGPUBlocks = 4096
|
||||
}
|
||||
if c.PrefillTokensPerSec <= 0 {
|
||||
c.PrefillTokensPerSec = 12000
|
||||
}
|
||||
if c.DecodeTokensPerSec <= 0 {
|
||||
c.DecodeTokensPerSec = 60
|
||||
}
|
||||
if c.DefaultOutputTokens <= 0 {
|
||||
c.DefaultOutputTokens = 128
|
||||
}
|
||||
if c.Tick <= 0 {
|
||||
c.Tick = 5 * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
// EventKind, akıştaki olay türü.
|
||||
type EventKind int
|
||||
|
||||
const (
|
||||
// EventToken, üretilen bir token.
|
||||
EventToken EventKind = iota
|
||||
// EventDone, üretimin normal bitişi.
|
||||
EventDone
|
||||
// EventError, üretim sırasında oluşan hata.
|
||||
EventError
|
||||
)
|
||||
|
||||
// Event, engine'in istemciye ilettiği tek bir olay.
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Text string
|
||||
Err error
|
||||
// Result yalnızca EventDone'da doludur; prompt/cache/completion sayılarını taşır.
|
||||
Result *Result
|
||||
}
|
||||
|
||||
// Prompt, engine'e verilen istek.
|
||||
type Prompt struct {
|
||||
Text string
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// Result, istek bittiğinde okunabilen özet (Done olayından sonra geçerli).
|
||||
type Result struct {
|
||||
PromptTokens int
|
||||
CachedTokens int
|
||||
CompletionTokens int
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
type req struct {
|
||||
id string
|
||||
ctx context.Context
|
||||
prompt Prompt
|
||||
|
||||
promptTokens int
|
||||
cachedTokens int
|
||||
outputTokens int
|
||||
hashes []BlockHash
|
||||
|
||||
blocks int
|
||||
prefillLeft float64
|
||||
decodeLeft int
|
||||
decodeAccum float64
|
||||
emitted int
|
||||
|
||||
arrived time.Time
|
||||
firstAt time.Time
|
||||
lastTokAt time.Time
|
||||
|
||||
out chan Event
|
||||
pending []Event
|
||||
finished bool
|
||||
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ı.
|
||||
func (r *req) flush() {
|
||||
for len(r.pending) > 0 {
|
||||
select {
|
||||
case r.out <- r.pending[0]:
|
||||
r.pending = r.pending[1:]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *req) emit(ev Event) {
|
||||
r.pending = append(r.pending, ev)
|
||||
r.flush()
|
||||
}
|
||||
|
||||
// Engine, simüle edilen inference engine.
|
||||
type Engine struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex
|
||||
waiting []*req
|
||||
running []*req
|
||||
draining []*req
|
||||
freeBlocks int
|
||||
cache *PrefixCache
|
||||
rng *rand.Rand
|
||||
seq atomic.Uint64
|
||||
|
||||
m *metrics
|
||||
|
||||
stopOnce sync.Once
|
||||
stop chan 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ı.
|
||||
func New(cfg Config) *Engine {
|
||||
cfg.normalize()
|
||||
seed := cfg.Seed
|
||||
if seed == 0 {
|
||||
seed = time.Now().UnixNano()
|
||||
}
|
||||
e := &Engine{
|
||||
cfg: cfg,
|
||||
freeBlocks: cfg.NumGPUBlocks,
|
||||
cache: NewPrefixCache(cfg.PrefixCacheBlocks),
|
||||
rng: rand.New(rand.NewSource(seed)), //nolint:gosec // simülasyon
|
||||
m: newMetrics(),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go e.run()
|
||||
return e
|
||||
}
|
||||
|
||||
// Config, engine'in aktif konfigürasyonunu döner.
|
||||
func (e *Engine) Config() Config { return e.cfg }
|
||||
|
||||
// Close, simülasyon döngüsünü durdurur ve bekleyen istekleri hataya düşürür.
|
||||
func (e *Engine) Close() {
|
||||
e.stopOnce.Do(func() {
|
||||
close(e.stop)
|
||||
<-e.done
|
||||
})
|
||||
}
|
||||
|
||||
// Submit, isteği kuyruğa alır ve olay kanalını döner.
|
||||
// Kanal, üretim bitince veya hata oluşunca kapanır.
|
||||
func (e *Engine) Submit(ctx context.Context, p Prompt) (<-chan Event, error) {
|
||||
if e.cfg.FailRate > 0 {
|
||||
e.mu.Lock()
|
||||
fail := e.rng.Float64() < e.cfg.FailRate
|
||||
e.mu.Unlock()
|
||||
if fail {
|
||||
e.m.failed.Add(1)
|
||||
return nil, ErrInjected
|
||||
}
|
||||
}
|
||||
|
||||
tokens := Tokenize(p.Text)
|
||||
out := p.MaxTokens
|
||||
if out <= 0 {
|
||||
out = e.cfg.DefaultOutputTokens
|
||||
}
|
||||
|
||||
r := &req{
|
||||
id: fmt.Sprintf("sim-%d", e.seq.Add(1)),
|
||||
ctx: ctx,
|
||||
prompt: p,
|
||||
promptTokens: len(tokens),
|
||||
outputTokens: out,
|
||||
hashes: BlockHashes(tokens, e.cfg.BlockSize),
|
||||
arrived: time.Now(),
|
||||
out: make(chan Event, 64),
|
||||
}
|
||||
|
||||
if e.blocksFor(r.promptTokens+r.outputTokens) > e.cfg.NumGPUBlocks {
|
||||
e.m.failed.Add(1)
|
||||
return nil, ErrTooLong
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
select {
|
||||
case <-e.stop:
|
||||
e.mu.Unlock()
|
||||
return nil, ErrShutdown
|
||||
default:
|
||||
}
|
||||
e.waiting = append(e.waiting, r)
|
||||
e.mu.Unlock()
|
||||
|
||||
e.m.received.Add(1)
|
||||
return r.out, nil
|
||||
}
|
||||
|
||||
func (e *Engine) blocksFor(tokens int) int {
|
||||
return (tokens + e.cfg.BlockSize - 1) / e.cfg.BlockSize
|
||||
}
|
||||
|
||||
func (e *Engine) run() {
|
||||
defer close(e.done)
|
||||
t := time.NewTicker(e.cfg.Tick)
|
||||
defer t.Stop()
|
||||
dt := e.cfg.Tick.Seconds()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.stop:
|
||||
e.shutdown()
|
||||
return
|
||||
case <-t.C:
|
||||
e.mu.Lock()
|
||||
e.reap()
|
||||
e.schedule()
|
||||
e.advance(dt)
|
||||
e.flushAll()
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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ı.
|
||||
func (e *Engine) reap() {
|
||||
e.waiting = filterInPlace(e.waiting, func(r *req) bool {
|
||||
if r.ctx.Err() == nil {
|
||||
return true
|
||||
}
|
||||
e.m.cancelled.Add(1)
|
||||
close(r.out)
|
||||
return false
|
||||
})
|
||||
e.running = filterInPlace(e.running, func(r *req) bool {
|
||||
if r.ctx.Err() == nil {
|
||||
return true
|
||||
}
|
||||
e.freeBlocks += r.blocks
|
||||
e.m.cancelled.Add(1)
|
||||
close(r.out)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) schedule() {
|
||||
for len(e.waiting) > 0 && len(e.running) < e.cfg.MaxNumSeqs {
|
||||
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.
|
||||
break
|
||||
}
|
||||
|
||||
hitBlocks := e.cache.Lookup(r.hashes)
|
||||
r.cachedTokens = hitBlocks * e.cfg.BlockSize
|
||||
r.blocks = need
|
||||
r.prefillLeft = float64(r.promptTokens - r.cachedTokens)
|
||||
if r.prefillLeft < 0 {
|
||||
r.prefillLeft = 0
|
||||
}
|
||||
r.decodeLeft = r.outputTokens
|
||||
|
||||
e.freeBlocks -= need
|
||||
e.running = append(e.running, r)
|
||||
e.waiting = e.waiting[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) advance(dt float64) {
|
||||
if len(e.running) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefill bütçesi engine genelinde paylaşılır ve FIFO dağıtılır.
|
||||
budget := e.cfg.PrefillTokensPerSec * dt
|
||||
for _, r := range e.running {
|
||||
if budget <= 0 {
|
||||
break
|
||||
}
|
||||
if r.prefillLeft <= 0 {
|
||||
continue
|
||||
}
|
||||
used := min(budget, r.prefillLeft)
|
||||
r.prefillLeft -= used
|
||||
budget -= used
|
||||
}
|
||||
|
||||
// Decode hızı batch büyüdükçe düşer.
|
||||
n := 0
|
||||
for _, r := range e.running {
|
||||
if r.prefillLeft <= 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
rate := e.cfg.DecodeTokensPerSec / (1 + e.cfg.BatchContention*float64(n-1))
|
||||
|
||||
now := time.Now()
|
||||
for _, r := range e.running {
|
||||
if r.prefillLeft > 0 || r.finished {
|
||||
continue
|
||||
}
|
||||
r.decodeAccum += rate * dt
|
||||
for r.decodeAccum >= 1 && r.decodeLeft > 0 {
|
||||
r.decodeAccum--
|
||||
r.decodeLeft--
|
||||
r.emitted++
|
||||
|
||||
if r.firstAt.IsZero() {
|
||||
r.firstAt = now
|
||||
e.m.observeTTFT(now.Sub(r.arrived))
|
||||
} else {
|
||||
e.m.observeTPOT(now.Sub(r.lastTokAt))
|
||||
}
|
||||
r.lastTokAt = now
|
||||
r.emit(Event{Kind: EventToken, Text: tokenText(r.emitted)})
|
||||
}
|
||||
if r.decodeLeft == 0 {
|
||||
e.finish(r)
|
||||
}
|
||||
}
|
||||
|
||||
e.running = filterInPlace(e.running, func(r *req) bool { return !r.finished })
|
||||
}
|
||||
|
||||
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.
|
||||
full := Tokenize(r.prompt.Text + generatedText(r.emitted))
|
||||
e.cache.Insert(BlockHashes(full, e.cfg.BlockSize))
|
||||
|
||||
r.res = &Result{
|
||||
PromptTokens: r.promptTokens,
|
||||
CachedTokens: r.cachedTokens,
|
||||
CompletionTokens: r.emitted,
|
||||
}
|
||||
e.m.finished.Add(1)
|
||||
e.m.promptTokens.Add(int64(r.promptTokens))
|
||||
e.m.genTokens.Add(int64(r.emitted))
|
||||
|
||||
r.emit(Event{Kind: EventDone, Result: r.res})
|
||||
e.draining = append(e.draining, r)
|
||||
}
|
||||
|
||||
func (e *Engine) flushAll() {
|
||||
for _, r := range e.running {
|
||||
r.flush()
|
||||
}
|
||||
e.draining = filterInPlace(e.draining, func(r *req) bool {
|
||||
r.flush()
|
||||
if len(r.pending) == 0 {
|
||||
close(r.out)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) shutdown() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, r := range append(e.waiting, e.running...) {
|
||||
select {
|
||||
case r.out <- Event{Kind: EventError, Err: ErrShutdown}:
|
||||
default:
|
||||
}
|
||||
close(r.out)
|
||||
}
|
||||
for _, r := range e.draining {
|
||||
close(r.out)
|
||||
}
|
||||
e.waiting, e.running, e.draining = nil, nil, nil
|
||||
}
|
||||
|
||||
// Stats, router'ın scrape edeceği anlık durumu döner.
|
||||
func (e *Engine) Stats() Stats {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
used := e.cfg.NumGPUBlocks - e.freeBlocks
|
||||
q, h := e.cache.Stats()
|
||||
return Stats{
|
||||
Running: len(e.running),
|
||||
Waiting: len(e.waiting),
|
||||
KVCacheUsage: float64(used) / float64(e.cfg.NumGPUBlocks),
|
||||
BlockSize: e.cfg.BlockSize,
|
||||
NumGPUBlocks: e.cfg.NumGPUBlocks,
|
||||
PrefixQueries: q,
|
||||
PrefixHits: h,
|
||||
CachedBlocks: e.cache.Len(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if keep(r) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testConfig, testlerin hızlı ve deterministik koşması için hızlandırılmış ayarlar.
|
||||
func testConfig() Config {
|
||||
c := Default()
|
||||
c.Tick = time.Millisecond
|
||||
c.PrefillTokensPerSec = 1e6
|
||||
c.DecodeTokensPerSec = 5000
|
||||
c.BatchContention = 0
|
||||
c.Seed = 42
|
||||
return c
|
||||
}
|
||||
|
||||
// drain, kanaldaki tüm token'ları toplar ve bitişi bekler.
|
||||
func drain(t *testing.T, ch <-chan Event) (text string, done bool) {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case ev, open := <-ch:
|
||||
if !open {
|
||||
return b.String(), done
|
||||
}
|
||||
switch ev.Kind {
|
||||
case EventToken:
|
||||
b.WriteString(ev.Text)
|
||||
case EventDone:
|
||||
done = true
|
||||
case EventError:
|
||||
t.Fatalf("beklenmeyen hata: %v", ev.Err)
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("zaman aşımı: engine token üretmedi")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineTemelUretim(t *testing.T) {
|
||||
e := New(testConfig())
|
||||
defer e.Close()
|
||||
|
||||
ch, err := e.Submit(t.Context(), Prompt{Text: "merhaba dunya", MaxTokens: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, done := drain(t, ch)
|
||||
if !done {
|
||||
t.Error("EventDone gelmedi")
|
||||
}
|
||||
if got := len(strings.Fields(text)); got != 10 {
|
||||
t.Errorf("beklenen 10 token, alınan %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ı.
|
||||
cfg := testConfig()
|
||||
cfg.DecodeTokensPerSec = 20 // uzun sürsün ki iptal ederken çalışıyor olsun
|
||||
e := New(cfg)
|
||||
defer e.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch, err := e.Submit(ctx, Prompt{Text: strings.Repeat("x", 4000), MaxTokens: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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ı")
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
s := e.Stats()
|
||||
return s.Running == 0 && s.KVCacheUsage == 0
|
||||
})
|
||||
|
||||
// Kanal kapanmalı ki istemci tarafı bloke kalmasın.
|
||||
select {
|
||||
case _, open := <-ch:
|
||||
if open {
|
||||
// token kalmış olabilir; kapanana kadar tüket
|
||||
for range ch { //nolint:revive // kanalı boşalt
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("iptal sonrası kanal kapanmadı")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineBatchDolunkaKuyrugaAlir(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.MaxNumSeqs = 2
|
||||
cfg.DecodeTokensPerSec = 20
|
||||
e := New(cfg)
|
||||
defer e.Close()
|
||||
|
||||
for range 5 {
|
||||
if _, err := e.Submit(t.Context(), Prompt{Text: "abc", MaxTokens: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if s.Waiting != 3 {
|
||||
t.Errorf("waiting %d, beklenen 3", s.Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineKVBlogunaSigmayanIstekReddedilir(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.NumGPUBlocks = 4
|
||||
cfg.BlockSize = 16 // toplam 64 token kapasite
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnginePrefixCacheIkinciIstegiHizlandirir(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PrefillTokensPerSec = 2000 // prefill maliyeti ölçülebilir olsun
|
||||
cfg.DecodeTokensPerSec = 5000
|
||||
e := New(cfg)
|
||||
defer e.Close()
|
||||
|
||||
prompt := Prompt{Text: strings.Repeat("ortak sistem promptu. ", 200), MaxTokens: 5}
|
||||
|
||||
first := timeToFirstToken(t, e, prompt)
|
||||
// İlk istek bitip cache'e yazıldıktan sonra aynısını tekrar gönder.
|
||||
second := timeToFirstToken(t, e, prompt)
|
||||
|
||||
if second >= first {
|
||||
t.Errorf("prefix cache TTFT'yi düşürmedi: ilk=%v ikinci=%v", first, second)
|
||||
}
|
||||
|
||||
s := e.Stats()
|
||||
if s.PrefixHits == 0 {
|
||||
t.Error("prefix cache hit sayacı artmadı")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnginePrefixCacheKapaliykenHitYok(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PrefixCacheBlocks = 0
|
||||
e := New(cfg)
|
||||
defer e.Close()
|
||||
|
||||
p := Prompt{Text: strings.Repeat("ayni prompt. ", 50), MaxTokens: 3}
|
||||
for range 3 {
|
||||
ch, err := e.Submit(t.Context(), p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, ch)
|
||||
}
|
||||
if h := e.Stats().PrefixHits; h != 0 {
|
||||
t.Errorf("cache kapalıyken %d hit raporlandı", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineKapanisBekleyenleriBosaltir(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.DecodeTokensPerSec = 1
|
||||
e := New(cfg)
|
||||
|
||||
ch, err := e.Submit(t.Context(), Prompt{Text: "abc", MaxTokens: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, time.Second, func() bool { return e.Stats().Running == 1 })
|
||||
e.Close()
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case _, open := <-ch:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("kapanışta kanal kapanmadı")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineYapayHata(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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- yardımcılar ---
|
||||
|
||||
func timeToFirstToken(t *testing.T, e *Engine, p Prompt) time.Duration {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
ch, err := e.Submit(t.Context(), p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ttft time.Duration
|
||||
for ev := range ch {
|
||||
if ev.Kind == EventToken && ttft == 0 {
|
||||
ttft = time.Since(start)
|
||||
}
|
||||
}
|
||||
if ttft == 0 {
|
||||
t.Fatal("hiç token üretilmedi")
|
||||
}
|
||||
return ttft
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, limit time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(limit)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("koşul %v içinde sağlanmadı", limit)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ttftBuckets ve tpotBuckets, vLLM'in kullandıklarına yakın saniye sınırları.
|
||||
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.
|
||||
type histogram struct {
|
||||
bounds []float64
|
||||
counts []uint64
|
||||
sum float64
|
||||
count uint64
|
||||
}
|
||||
|
||||
func newHistogram(bounds []float64) *histogram {
|
||||
return &histogram{bounds: bounds, counts: make([]uint64, len(bounds))}
|
||||
}
|
||||
|
||||
func (h *histogram) observe(v float64) {
|
||||
h.sum += v
|
||||
h.count++
|
||||
for i, b := range h.bounds {
|
||||
if v <= b {
|
||||
h.counts[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// write, histogramı Prometheus text formatında yazar (kümülatif le bucket'ları).
|
||||
func (h *histogram) write(w io.Writer, name, labels string) {
|
||||
var cum uint64
|
||||
for i, b := range h.bounds {
|
||||
cum = h.counts[i]
|
||||
fmt.Fprintf(w, "%s_bucket{%s,le=\"%g\"} %d\n", name, labels, b, cum)
|
||||
}
|
||||
fmt.Fprintf(w, "%s_bucket{%s,le=\"+Inf\"} %d\n", name, labels, h.count)
|
||||
fmt.Fprintf(w, "%s_sum{%s} %g\n", name, labels, h.sum)
|
||||
fmt.Fprintf(w, "%s_count{%s} %d\n", name, labels, h.count)
|
||||
}
|
||||
|
||||
type metrics struct {
|
||||
received atomic.Int64
|
||||
finished atomic.Int64
|
||||
failed atomic.Int64
|
||||
cancelled atomic.Int64
|
||||
|
||||
promptTokens atomic.Int64
|
||||
genTokens atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
ttft *histogram
|
||||
tpot *histogram
|
||||
}
|
||||
|
||||
func newMetrics() *metrics {
|
||||
return &metrics{
|
||||
ttft: newHistogram(ttftBuckets),
|
||||
tpot: newHistogram(tpotBuckets),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *metrics) observeTTFT(d time.Duration) {
|
||||
m.mu.Lock()
|
||||
m.ttft.observe(d.Seconds())
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *metrics) observeTPOT(d time.Duration) {
|
||||
m.mu.Lock()
|
||||
m.tpot.observe(d.Seconds())
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// WriteMetrics, engine durumunu vLLM'in metrik adlarıyla Prometheus text
|
||||
// formatında yazar.
|
||||
//
|
||||
// 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.
|
||||
func (e *Engine) WriteMetrics(w io.Writer) {
|
||||
s := e.Stats()
|
||||
lbl := fmt.Sprintf("model_name=%q", e.cfg.Model)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:num_requests_running Number of requests currently running on GPU.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:num_requests_running gauge\n")
|
||||
fmt.Fprintf(w, "vllm:num_requests_running{%s} %d\n", lbl, s.Running)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:num_requests_waiting Number of requests waiting to be processed.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:num_requests_waiting gauge\n")
|
||||
fmt.Fprintf(w, "vllm:num_requests_waiting{%s} %d\n", lbl, s.Waiting)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:kv_cache_usage_perc KV-cache usage. 1 means 100 percent usage.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:kv_cache_usage_perc gauge\n")
|
||||
fmt.Fprintf(w, "vllm:kv_cache_usage_perc{%s} %g\n", lbl, s.KVCacheUsage)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:cache_config_info Information of the LLMEngine CacheConfig.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:cache_config_info gauge\n")
|
||||
fmt.Fprintf(w, "vllm:cache_config_info{block_size=\"%d\",num_gpu_blocks=\"%d\"} 1\n", s.BlockSize, s.NumGPUBlocks)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:prefix_cache_queries_total Prefix cache queries, in terms of number of queried blocks.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:prefix_cache_queries_total counter\n")
|
||||
fmt.Fprintf(w, "vllm:prefix_cache_queries_total{%s} %d\n", lbl, s.PrefixQueries)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:prefix_cache_hits_total Prefix cache hits, in terms of number of cached blocks.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:prefix_cache_hits_total counter\n")
|
||||
fmt.Fprintf(w, "vllm:prefix_cache_hits_total{%s} %d\n", lbl, s.PrefixHits)
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:prompt_tokens_total Number of prefill tokens processed.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:prompt_tokens_total counter\n")
|
||||
fmt.Fprintf(w, "vllm:prompt_tokens_total{%s} %d\n", lbl, e.m.promptTokens.Load())
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:generation_tokens_total Number of generation tokens processed.\n")
|
||||
fmt.Fprintf(w, "# TYPE vllm:generation_tokens_total counter\n")
|
||||
fmt.Fprintf(w, "vllm:generation_tokens_total{%s} %d\n", lbl, e.m.genTokens.Load())
|
||||
|
||||
fmt.Fprintf(w, "# HELP vllm:request_success_total Count of successfully processed requests.\n")
|
||||
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).
|
||||
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")
|
||||
fmt.Fprintf(w, "sim:requests_failed_total{%s} %d\n", lbl, e.m.failed.Load())
|
||||
|
||||
e.m.mu.Lock()
|
||||
defer e.m.mu.Unlock()
|
||||
fmt.Fprintf(w, "# TYPE vllm:time_to_first_token_seconds histogram\n")
|
||||
e.m.ttft.write(w, "vllm:time_to_first_token_seconds", lbl)
|
||||
fmt.Fprintf(w, "# TYPE vllm:time_per_output_token_seconds histogram\n")
|
||||
e.m.tpot.write(w, "vllm:time_per_output_token_seconds", lbl)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server, Engine'i OpenAI uyumlu bir HTTP yüzeyiyle sarar.
|
||||
type Server struct {
|
||||
eng *Engine
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer, engine için HTTP yüzeyini kurar.
|
||||
func NewServer(eng *Engine, log *slog.Logger) *Server {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &Server{eng: eng, log: log, mux: http.NewServeMux()}
|
||||
s.mux.HandleFunc("POST /v1/chat/completions", s.handleChat)
|
||||
s.mux.HandleFunc("POST /v1/completions", s.handleCompletions)
|
||||
s.mux.HandleFunc("GET /v1/models", s.handleModels)
|
||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
||||
|
||||
// --- istek/yanıt tipleri (yalnızca gereken alanlar) ---
|
||||
|
||||
type streamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
StreamOptions *streamOptions `json:"stream_options"`
|
||||
}
|
||||
|
||||
type completionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt json.RawMessage `json:"prompt"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
StreamOptions *streamOptions `json:"stream_options"`
|
||||
}
|
||||
|
||||
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.
|
||||
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ı.
|
||||
func (m chatMessage) text() string {
|
||||
var s string
|
||||
if err := json.Unmarshal(m.Content, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
return string(m.Content)
|
||||
}
|
||||
|
||||
func promptFromMessages(msgs []chatMessage) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
b.WriteString(m.Role)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(m.text())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- handler'lar ---
|
||||
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
maxTok := req.MaxTokens
|
||||
if maxTok == 0 {
|
||||
maxTok = req.MaxCompletionTokens
|
||||
}
|
||||
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
||||
|
||||
s.serve(w, r, servePlan{
|
||||
prompt: Prompt{Text: promptFromMessages(req.Messages), MaxTokens: maxTok},
|
||||
model: s.modelName(req.Model),
|
||||
stream: req.Stream,
|
||||
includeUsage: includeUsage,
|
||||
object: "chat.completion",
|
||||
chunkObject: "chat.completion.chunk",
|
||||
chat: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
var req completionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
var prompt string
|
||||
if err := json.Unmarshal(req.Prompt, &prompt); err != nil {
|
||||
prompt = string(req.Prompt)
|
||||
}
|
||||
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
||||
|
||||
s.serve(w, r, servePlan{
|
||||
prompt: Prompt{Text: prompt, MaxTokens: req.MaxTokens},
|
||||
model: s.modelName(req.Model),
|
||||
stream: req.Stream,
|
||||
includeUsage: includeUsage,
|
||||
object: "text_completion",
|
||||
chunkObject: "text_completion",
|
||||
chat: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) modelName(requested string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
return s.eng.Config().Model
|
||||
}
|
||||
|
||||
func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"object": "list",
|
||||
"data": []map[string]any{{
|
||||
"id": s.eng.Config().Model,
|
||||
"object": "model",
|
||||
"created": time.Now().Unix(),
|
||||
"owned_by": "llmgw-sim",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
s.eng.WriteMetrics(w)
|
||||
}
|
||||
|
||||
// --- ortak akış ---
|
||||
|
||||
type servePlan struct {
|
||||
prompt Prompt
|
||||
model string
|
||||
stream bool
|
||||
includeUsage bool
|
||||
object string
|
||||
chunkObject string
|
||||
chat bool
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request, p servePlan) {
|
||||
ch, err := s.eng.Submit(r.Context(), p.prompt)
|
||||
if err != nil {
|
||||
code := http.StatusInternalServerError
|
||||
kind := "server_error"
|
||||
if err == ErrTooLong {
|
||||
code, kind = http.StatusBadRequest, "invalid_request_error"
|
||||
}
|
||||
writeError(w, code, kind, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("cmpl-sim-%d", time.Now().UnixNano())
|
||||
created := time.Now().Unix()
|
||||
|
||||
if p.stream {
|
||||
s.streamResponse(w, r, ch, p, id, created)
|
||||
return
|
||||
}
|
||||
s.bufferedResponse(w, ch, p, id, created)
|
||||
}
|
||||
|
||||
func (s *Server) bufferedResponse(w http.ResponseWriter, ch <-chan Event, p servePlan, id string, created int64) {
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
var res *Result
|
||||
for ev := range ch {
|
||||
switch ev.Kind {
|
||||
case EventToken:
|
||||
b.WriteString(ev.Text)
|
||||
n++
|
||||
case EventError:
|
||||
writeError(w, http.StatusInternalServerError, "server_error", ev.Err.Error())
|
||||
return
|
||||
case EventDone:
|
||||
res = ev.Result
|
||||
}
|
||||
}
|
||||
|
||||
u := s.usage(p.prompt, n, res)
|
||||
body := map[string]any{
|
||||
"id": id, "object": p.object, "created": created, "model": p.model,
|
||||
"usage": u,
|
||||
}
|
||||
if p.chat {
|
||||
body["choices"] = []map[string]any{{
|
||||
"index": 0,
|
||||
"message": map[string]string{"role": "assistant", "content": b.String()},
|
||||
"finish_reason": "length",
|
||||
}}
|
||||
} else {
|
||||
body["choices"] = []map[string]any{{
|
||||
"index": 0, "text": b.String(), "finish_reason": "length",
|
||||
}}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
n := 0
|
||||
var res *Result
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
// İstemci koptu. Engine reap() ile isteği batch'ten düşürecek.
|
||||
return
|
||||
case ev, open := <-ch:
|
||||
if !open {
|
||||
if p.includeUsage {
|
||||
s.writeChunk(w, chunk(id, p, created, nil, "", s.usage(p.prompt, n, res)))
|
||||
flusher.Flush()
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
switch ev.Kind {
|
||||
case EventToken:
|
||||
n++
|
||||
s.writeChunk(w, chunk(id, p, created, &ev.Text, "", nil))
|
||||
flusher.Flush()
|
||||
case EventDone:
|
||||
res = ev.Result
|
||||
s.writeChunk(w, chunk(id, p, created, nil, "length", nil))
|
||||
flusher.Flush()
|
||||
case EventError:
|
||||
s.writeChunk(w, map[string]any{"error": map[string]any{
|
||||
"message": ev.Err.Error(), "type": "server_error",
|
||||
}})
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
}
|
||||
|
||||
func chunk(id string, p servePlan, created int64, text *string, finish string, u any) map[string]any {
|
||||
choice := map[string]any{"index": 0}
|
||||
if p.chat {
|
||||
delta := map[string]any{}
|
||||
if text != nil {
|
||||
delta["content"] = *text
|
||||
}
|
||||
choice["delta"] = delta
|
||||
} else if text != nil {
|
||||
choice["text"] = *text
|
||||
} else {
|
||||
choice["text"] = ""
|
||||
}
|
||||
if finish != "" {
|
||||
choice["finish_reason"] = finish
|
||||
} else {
|
||||
choice["finish_reason"] = nil
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"id": id, "object": p.chunkObject, "created": created, "model": p.model,
|
||||
}
|
||||
// Usage chunk'ında choices boş dizi olur (OpenAI davranışı).
|
||||
if u != nil {
|
||||
out["choices"] = []map[string]any{}
|
||||
out["usage"] = u
|
||||
} else {
|
||||
out["choices"] = []map[string]any{choice}
|
||||
}
|
||||
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.
|
||||
func (s *Server) usage(p Prompt, completion int, res *Result) usage {
|
||||
var u usage
|
||||
u.PromptTokens = CountTokens(p.Text)
|
||||
u.CompletionTokens = completion
|
||||
if res != nil {
|
||||
u.PromptTokens = res.PromptTokens
|
||||
u.CompletionTokens = res.CompletionTokens
|
||||
u.PromptTokensDetails.CachedTokens = res.CachedTokens
|
||||
}
|
||||
u.TotalTokens = u.PromptTokens + u.CompletionTokens
|
||||
return u
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, kind, msg string) {
|
||||
writeJSON(w, code, map[string]any{"error": map[string]any{
|
||||
"message": msg, "type": kind, "code": nil,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, cfg Config) *httptest.Server {
|
||||
t.Helper()
|
||||
eng := New(cfg)
|
||||
srv := httptest.NewServer(NewServer(eng, nil))
|
||||
t.Cleanup(func() {
|
||||
srv.Close()
|
||||
eng.Close()
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url string, body any) *http.Response {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(b)) //nolint:noctx // test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestChatCompletionsBuffered(t *testing.T) {
|
||||
srv := newTestServer(t, testConfig())
|
||||
|
||||
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
|
||||
"model": "sim-model",
|
||||
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
|
||||
"max_tokens": 7,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("durum %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Object string `json:"object"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage usage `json:"usage"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if out.Object != "chat.completion" {
|
||||
t.Errorf("object=%q", out.Object)
|
||||
}
|
||||
if len(out.Choices) != 1 {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// sseEvent, SSE akışından çözülmüş tek bir olay.
|
||||
type sseEvent struct {
|
||||
raw string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func readSSE(t *testing.T, body *bufio.Reader) []sseEvent {
|
||||
t.Helper()
|
||||
var out []sseEvent
|
||||
for {
|
||||
line, err := body.ReadString('\n')
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimPrefix(line, "data: ")
|
||||
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)
|
||||
}
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreaming(t *testing.T) {
|
||||
srv := newTestServer(t, testConfig())
|
||||
|
||||
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
|
||||
"model": "sim-model",
|
||||
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
|
||||
"max_tokens": 5,
|
||||
"stream": true,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Errorf("Content-Type=%q", ct)
|
||||
}
|
||||
|
||||
events := readSSE(t, bufio.NewReader(resp.Body))
|
||||
if len(events) == 0 {
|
||||
t.Fatal("hiç SSE olayı gelmedi")
|
||||
}
|
||||
if last := events[len(events)-1]; last.raw != "[DONE]" {
|
||||
t.Errorf("akış [DONE] ile bitmedi: %q", last.raw)
|
||||
}
|
||||
|
||||
var contentTokens, usageChunks, finishChunks int
|
||||
for _, ev := range events {
|
||||
if ev.raw == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ev.data["usage"]; ok {
|
||||
usageChunks++
|
||||
continue
|
||||
}
|
||||
choices, _ := ev.data["choices"].([]any)
|
||||
if len(choices) == 0 {
|
||||
continue
|
||||
}
|
||||
c, _ := choices[0].(map[string]any)
|
||||
if delta, ok := c["delta"].(map[string]any); ok {
|
||||
if _, has := delta["content"]; has {
|
||||
contentTokens++
|
||||
}
|
||||
}
|
||||
if fr, ok := c["finish_reason"].(string); ok && fr == "length" {
|
||||
finishChunks++
|
||||
}
|
||||
}
|
||||
|
||||
if contentTokens != 5 {
|
||||
t.Errorf("beklenen 5 içerik chunk'ı, alınan %d", contentTokens)
|
||||
}
|
||||
if finishChunks != 1 {
|
||||
t.Errorf("beklenen 1 finish_reason chunk'ı, alınan %d", finishChunks)
|
||||
}
|
||||
if usageChunks != 1 {
|
||||
t.Errorf("include_usage istendi ama %d usage chunk'ı geldi", usageChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingUsageIstenmezseGonderilmez(t *testing.T) {
|
||||
srv := newTestServer(t, testConfig())
|
||||
|
||||
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
|
||||
"messages": []map[string]string{{"role": "user", "content": "x"}},
|
||||
"max_tokens": 2,
|
||||
"stream": true,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
for _, ev := range readSSE(t, bufio.NewReader(resp.Body)) {
|
||||
if ev.raw == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ev.data["usage"]; ok {
|
||||
t.Error("istenmediği halde usage chunk'ı gönderildi")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIstemciKopmasiEngineeYansir(t *testing.T) {
|
||||
// Faz 1'in çıkış kriterinin temeli: istemci koptuğunda GPU boşa çalışmamalı.
|
||||
cfg := testConfig()
|
||||
cfg.DecodeTokensPerSec = 20
|
||||
eng := New(cfg)
|
||||
defer eng.Close()
|
||||
srv := httptest.NewServer(NewServer(eng, nil))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
|
||||
"max_tokens": 5000,
|
||||
"stream": true,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
srv.URL+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// İlk token'ı bekle, sonra istemciyi kopar.
|
||||
waitFor(t, 2*time.Second, func() bool { return eng.Stats().Running == 1 })
|
||||
cancel()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
waitFor(t, 3*time.Second, func() bool {
|
||||
s := eng.Stats()
|
||||
return s.Running == 0 && s.KVCacheUsage == 0
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompletionsUcNoktasi(t *testing.T) {
|
||||
srv := newTestServer(t, testConfig())
|
||||
|
||||
resp := postJSON(t, srv.URL+"/v1/completions", map[string]any{
|
||||
"model": "sim-model", "prompt": "merhaba", "max_tokens": 3,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
Object string `json:"object"`
|
||||
Choices []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Object != "text_completion" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsVeHealth(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Model = "llama-3.3-70b"
|
||||
srv := newTestServer(t, cfg)
|
||||
|
||||
resp, err := http.Get(srv.URL + "/health") //nolint:noctx // test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("/health durum %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, err = http.Get(srv.URL + "/v1/models") //nolint:noctx // test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
srv := newTestServer(t, testConfig())
|
||||
|
||||
// Biraz trafik üret ki sayaçlar dolsun.
|
||||
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
|
||||
"messages": []map[string]string{{"role": "user", "content": "merhaba dunya"}},
|
||||
"max_tokens": 4,
|
||||
})
|
||||
resp.Body.Close()
|
||||
|
||||
mresp, err := http.Get(srv.URL + "/metrics") //nolint:noctx // test
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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).
|
||||
parser := expfmt.NewTextParser(model.LegacyValidation)
|
||||
families, err := parser.TextToMetricFamilies(mresp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("metrikler parse edilemedi: %v", err)
|
||||
}
|
||||
|
||||
// §7.6 zorunlu üçlüsü.
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Opsiyoneller ve histogramlar.
|
||||
for _, name := range []string{
|
||||
"vllm:cache_config_info",
|
||||
"vllm:prefix_cache_queries_total",
|
||||
"vllm:prefix_cache_hits_total",
|
||||
"vllm:time_to_first_token_seconds",
|
||||
} {
|
||||
if _, ok := families[name]; !ok {
|
||||
t.Errorf("metrik eksik: %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)
|
||||
}
|
||||
} else {
|
||||
t.Error("vllm:generation_tokens_total eksik")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
srv := newTestServer(t, testConfig())
|
||||
|
||||
body := map[string]any{
|
||||
"messages": []map[string]string{{"role": "user", "content": strings.Repeat("ortak onek. ", 100)}},
|
||||
"max_tokens": 3,
|
||||
}
|
||||
|
||||
// İlk istek cache'i doldurur.
|
||||
resp := postJSON(t, srv.URL+"/v1/chat/completions", body)
|
||||
resp.Body.Close()
|
||||
|
||||
// İkincisi aynı ön eki kullanmalı.
|
||||
resp = postJSON(t, srv.URL+"/v1/chat/completions", body)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct {
|
||||
Usage usage `json:"usage"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
t.Error("ikinci istekte cached_tokens 0; prefix cache istemciye raporlanmıyor")
|
||||
}
|
||||
if out.Usage.PromptTokensDetails.CachedTokens > out.Usage.PromptTokens {
|
||||
t.Errorf("cached_tokens (%d) prompt_tokens'tan (%d) büyük",
|
||||
out.Usage.PromptTokensDetails.CachedTokens, out.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sim
|
||||
|
||||
import "strings"
|
||||
|
||||
// simWords, üretilen sahte çıktının kelime havuzu.
|
||||
var simWords = []string{
|
||||
"alfa", "beta", "gama", "delta", "epsilon", "zeta", "eta", "teta",
|
||||
"iota", "kappa", "lambda", "mu", "nu", "ksi", "omikron", "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ı.
|
||||
func tokenText(n int) string {
|
||||
return " " + simWords[(n-1)%len(simWords)]
|
||||
}
|
||||
|
||||
// generatedText, ilk n token'ın birleşimini verir.
|
||||
func generatedText(n int) string {
|
||||
var b strings.Builder
|
||||
b.Grow(n * 7)
|
||||
for i := 1; i <= n; i++ {
|
||||
b.WriteString(tokenText(i))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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ı.
|
||||
const bytesPerToken = 4
|
||||
|
||||
// Token, sahte tokenizer'ın ürettiği token kimliği.
|
||||
type Token uint32
|
||||
|
||||
// Tokenize, metni deterministik ve prefix-kararlı şekilde token'lara böler.
|
||||
//
|
||||
// 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.
|
||||
func Tokenize(s string) []Token {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
n := (len(s) + bytesPerToken - 1) / bytesPerToken
|
||||
out := make([]Token, 0, n)
|
||||
for i := 0; i < len(s); i += bytesPerToken {
|
||||
end := min(i+bytesPerToken, len(s))
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(s[i:end]))
|
||||
out = append(out, Token(h.Sum32()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CountTokens, metnin token sayısını tokenize etmeden verir.
|
||||
func CountTokens(s string) int {
|
||||
return (len(s) + bytesPerToken - 1) / bytesPerToken
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package sim
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTokenizeOnEkKararli(t *testing.T) {
|
||||
a := Tokenize("merhaba")
|
||||
b := Tokenize("merhaba dunya")
|
||||
if len(a) > len(b) {
|
||||
t.Fatal("uzun metin daha az token verdi")
|
||||
}
|
||||
for i := range a[:len(a)-1] { // son token yarım olabilir
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("token %d ön ek kararlı değil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountTokensTokenizeIleTutarli(t *testing.T) {
|
||||
for _, s := range []string{"", "a", "abcd", "abcde", "uzunca bir prompt metni"} {
|
||||
if got, want := CountTokens(s), len(Tokenize(s)); got != want {
|
||||
t.Errorf("%q: CountTokens=%d Tokenize=%d", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package version, build sırasında ldflags ile doldurulan sürüm bilgisini tutar.
|
||||
package version
|
||||
|
||||
// Version, `make build` tarafından git describe çıktısıyla doldurulur.
|
||||
var Version = "dev"
|
||||
+1045
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user