Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,18 @@ See [`.env.example`](./.env.example) for the complete list.
| GET | `/api/v1/readyz` | Readiness (constants + HF reachable). |
| GET | `/api/v1/gpus` | Built-in GPU SKU catalog. |
| GET | `/api/v1/models/{owner}/{repo}/profile` | Model introspection (no planning). |
| POST | `/api/v1/plan` | Main endpoint. Returns ranked configs. |
| POST | `/api/v1/plan` | Single model → ranked configs. |
| POST | `/api/v1/fit` | **Multiple models on a shared GPU pool** → per-model placement + cluster occupancy, with spatial co-tenancy (models packed onto the same GPU when memory fits). Fitment first; SLO/throughput advisory. |

### Multi-model fit

`/api/v1/fit` takes a working set of models and one shared inventory and returns
how they all fit together: for each model the smallest-footprint memory-feasible
config and its assigned GPUs, plus a `cluster` view of every GPU's tenants and
remaining capacity. Models may **co-tenant** a GPU (the GreenThread sharing
model) or be pinned `dedicated`. The web UI builds this request, shows the
cluster placement map, and encodes the entire setup (models + GPUs + settings)
into the URL hash so a **Share** button copies a link that reproduces it.

Request/response schemas are documented in [SPEC.md §2](./SPEC.md).

Expand Down
63 changes: 63 additions & 0 deletions internal/api/request.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package api

import (
"errors"
"fmt"
"strings"
)

// PlanRequest is the body for POST /v1/plan.
type PlanRequest struct {
Model Model `json:"model"`
Expand All @@ -14,6 +20,63 @@ type Model struct {
HFToken string `json:"hf_token,omitempty"`
}

// FitRequest is the body for POST /v1/fit — several models sharing one GPU
// inventory. The Workload/Constraints at the top level are the defaults applied
// to every model; a model may override them (advanced).
type FitRequest struct {
Models []FitModel `json:"models"`
Workload Workload `json:"workload"`
Constraints Constraints `json:"constraints"`
Inventory Inventory `json:"inventory"`
}

// FitModel is one model in a fit plan.
type FitModel struct {
HFID string `json:"hf_id"`
Revision string `json:"revision,omitempty"`
HFToken string `json:"hf_token,omitempty"`
Label string `json:"label,omitempty"` // display name; defaults to hf_id
Dedicated bool `json:"dedicated,omitempty"` // refuse co-tenants on its GPUs
Replicas int `json:"replicas,omitempty"` // independent copies to place (default 1)
// Per-model overrides (advanced). When nil the request-level defaults apply.
Workload *Workload `json:"workload,omitempty"`
Constraints *Constraints `json:"constraints,omitempty"`
}

// Validate checks a fit request.
func (r *FitRequest) Validate() error {
if len(r.Models) == 0 {
return errors.New("models must be non-empty")
}
for i, m := range r.Models {
if strings.TrimSpace(m.HFID) == "" {
return fmt.Errorf("models[%d].hf_id is required", i)
}
if m.Replicas < 0 {
return fmt.Errorf("models[%d].replicas must be >= 0", i)
}
}
// Reuse the single-model invariants for workload + inventory.
probe := PlanRequest{Model: Model{HFID: r.Models[0].HFID}, Workload: r.Workload, Inventory: r.Inventory}
return probe.Validate()
}

// ApplyDefaults fills request-level defaults and normalizes per-model fields.
func (r *FitRequest) ApplyDefaults() {
base := PlanRequest{Workload: r.Workload, Constraints: r.Constraints}
base.ApplyDefaults()
r.Workload = base.Workload
r.Constraints = base.Constraints
for i := range r.Models {
if r.Models[i].Replicas == 0 {
r.Models[i].Replicas = 1
}
if r.Models[i].Label == "" {
r.Models[i].Label = r.Models[i].HFID
}
}
}

type Workload struct {
MaxConcurrentRequests int `json:"max_concurrent_requests"`
MaxContextLength int `json:"max_context_length"`
Expand Down
67 changes: 67 additions & 0 deletions internal/api/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,73 @@ type Error struct {
Message string `json:"message"`
}

// FitResponse is the body for POST /v1/fit — multiple models packed onto one
// shared GPU inventory with spatial co-tenancy.
type FitResponse struct {
Verdict string `json:"verdict"`
Models []FitModelResult `json:"models"`
Cluster ClusterView `json:"cluster"`
Warnings []string `json:"warnings,omitempty"`
Diagnostics *Diagnostics `json:"diagnostics,omitempty"`
Error *Error `json:"error,omitempty"`
}

// Fit verdicts.
const (
VerdictAllFit = "all_fit" // every model placed
VerdictPartialFit = "partial_fit" // some models placed
VerdictNoneFit = "none_fit" // no model placed
VerdictFitNoModels = "no_models" // request had no models
)

// FitModelResult is the per-model outcome within a fit plan.
type FitModelResult struct {
Label string `json:"label"`
HFID string `json:"hf_id"`
Fitted bool `json:"fitted"`
Reason string `json:"reason,omitempty"` // why it did not fit
Dedicated bool `json:"dedicated,omitempty"`
Replicas int `json:"replicas,omitempty"`
ModelProfile *ModelProfile `json:"model_profile,omitempty"`
// Chosen is the placed configuration (quant, topology, per-GPU memory,
// performance, vLLM command). It is the smallest-footprint memory-feasible
// config; SLO is advisory, not a filter.
Chosen *Configuration `json:"chosen,omitempty"`
Alternatives []Configuration `json:"alternatives,omitempty"` // other feasible configs (advanced)
GPUIDs []string `json:"gpu_ids,omitempty"` // assigned GPU ids (all replicas)
}

// ClusterView is the cluster-wide placement picture: which models occupy each
// GPU and how full each GPU is.
type ClusterView struct {
GPUs []GPUOccupancy `json:"gpus"`
TotalGPUs int `json:"total_gpus"`
UsedGPUs int `json:"used_gpus"` // GPUs hosting ≥1 model
TotalMemGB float64 `json:"total_mem_gb"`
UsedMemGB float64 `json:"used_mem_gb"`
FreeMemGB float64 `json:"free_mem_gb"`
}

// GPUOccupancy describes one GPU's tenants and remaining capacity.
type GPUOccupancy struct {
ID string `json:"id"`
Node string `json:"node"`
SKU string `json:"sku"`
CapacityGB float64 `json:"capacity_gb"` // memory_free_gb the user reported
UsableGB float64 `json:"usable_gb"` // capacity × gpu_memory_utilization
Tenants []GPUTenant `json:"tenants,omitempty"`
UsedGB float64 `json:"used_gb"`
FreeGB float64 `json:"free_gb"` // usable − used
Dedicated bool `json:"dedicated,omitempty"` // locked to a single model
}

// GPUTenant is one model's footprint on a shared GPU.
type GPUTenant struct {
Label string `json:"label"`
HFID string `json:"hf_id"`
UsedGB float64 `json:"used_gb"` // this model's per-GPU memory footprint
}

const (
VerdictFeasible = "feasible"
VerdictInfeasibleOOM = "infeasible_oom"
Expand Down
163 changes: 163 additions & 0 deletions internal/server/fit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package server

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/greenthread-ai/fit/internal/api"
"github.com/greenthread-ai/fit/internal/estimator"
"github.com/greenthread-ai/fit/internal/gpu"
"github.com/greenthread-ai/fit/internal/hf"
"github.com/greenthread-ai/fit/internal/solver"
)

// fakeHub serves a config.json + safetensors index per repo id. cfgByRepo maps
// a substring of the repo path to (configJSON, totalSizeBytes).
func fakeHub(t *testing.T, cfgByRepo map[string]struct {
cfg string
size string
}) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/models/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sha":"sha","gated":false,"private":false,"siblings":[]}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
for key, v := range cfgByRepo {
if strings.Contains(r.URL.Path, key) {
switch {
case strings.HasSuffix(r.URL.Path, "/config.json"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(v.cfg))
return
case strings.HasSuffix(r.URL.Path, "/model.safetensors.index.json"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"metadata":{"total_size":` + v.size + `},"weight_map":{}}`))
return
}
}
}
http.NotFound(w, r)
})
return httptest.NewServer(mux)
}

func TestPostFit_SpatialCoTenancy(t *testing.T) {
small7B := `{"architectures":["Qwen3ForCausalLM"],"hidden_size":4096,"intermediate_size":12288,
"num_hidden_layers":32,"num_attention_heads":32,"num_key_value_heads":8,"head_dim":128,
"vocab_size":151936,"max_position_embeddings":32768,"torch_dtype":"bfloat16"}`
small3B := `{"architectures":["LlamaForCausalLM"],"hidden_size":2048,"intermediate_size":8192,
"num_hidden_layers":36,"num_attention_heads":16,"num_key_value_heads":2,"head_dim":128,
"vocab_size":151936,"max_position_embeddings":32768,"torch_dtype":"bfloat16"}`
hub := fakeHub(t, map[string]struct{ cfg, size string }{
"model-a": {small7B, "15000000000"},
"model-b": {small3B, "6000000000"},
})
defer hub.Close()

consts := estimator.Default()
client := hf.New(hub.URL, "", hf.NewMemoryCache(100))
cat := gpu.New()
sv := solver.New(client, cat, consts)
h := &Handler{Solver: sv, Catalog: cat, HF: client, Const: consts}
ts := httptest.NewServer(NewRouter(h, RequestID))
defer ts.Close()

// One H100 — both small models must share it (spatial co-tenancy).
body := api.FitRequest{
Models: []api.FitModel{
{HFID: "org/model-a", Label: "A"},
{HFID: "org/model-b", Label: "B"},
},
Workload: api.Workload{MaxConcurrentRequests: 8, MaxContextLength: 4096},
Inventory: api.Inventory{Nodes: []api.Node{{
Name: "n1", Interconnect: "nvlink",
GPUs: []api.GPU{{ID: "g0", Model: "H100-SXM5", MemoryTotalGB: 80, MemoryFreeGB: 80}},
}}},
}
buf, _ := json.Marshal(body)
resp, err := http.Post(ts.URL+"/v1/fit", "application/json", bytes.NewReader(buf))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
var out api.FitResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Verdict != api.VerdictAllFit {
t.Fatalf("verdict=%s models=%+v", out.Verdict, out.Models)
}
if len(out.Cluster.GPUs) != 1 {
t.Fatalf("expected 1 GPU in cluster view, got %d", len(out.Cluster.GPUs))
}
g := out.Cluster.GPUs[0]
if len(g.Tenants) != 2 {
t.Fatalf("expected 2 co-tenants on g0, got %d (%+v)", len(g.Tenants), g.Tenants)
}
if g.UsedGB <= 0 || g.FreeGB <= 0 || g.UsedGB > g.UsableGB {
t.Fatalf("bad occupancy math: used=%.1f free=%.1f usable=%.1f", g.UsedGB, g.FreeGB, g.UsableGB)
}
for _, m := range out.Models {
if !m.Fitted || m.Chosen == nil {
t.Fatalf("model %s did not fit: %s", m.Label, m.Reason)
}
}
}

func TestPostFit_DedicatedAndOverflow(t *testing.T) {
big := `{"architectures":["LlamaForCausalLM"],"hidden_size":8192,"intermediate_size":28672,
"num_hidden_layers":80,"num_attention_heads":64,"num_key_value_heads":8,"head_dim":128,
"vocab_size":128256,"max_position_embeddings":131072,"torch_dtype":"bfloat16"}`
hub := fakeHub(t, map[string]struct{ cfg, size string }{
"big": {big, "141000000000"}, // ~131 GiB weights → needs multi-GPU
})
defer hub.Close()

consts := estimator.Default()
client := hf.New(hub.URL, "", hf.NewMemoryCache(100))
cat := gpu.New()
sv := solver.New(client, cat, consts)
h := &Handler{Solver: sv, Catalog: cat, HF: client, Const: consts}
ts := httptest.NewServer(NewRouter(h, RequestID))
defer ts.Close()

body := api.FitRequest{
Models: []api.FitModel{
{HFID: "org/big", Label: "big-fp8", Constraints: &api.Constraints{AllowedQuantizations: []string{"fp8"}}},
},
Workload: api.Workload{MaxConcurrentRequests: 16, MaxContextLength: 8192},
Inventory: api.Inventory{Nodes: []api.Node{{
Name: "n1", Interconnect: "nvlink",
GPUs: []api.GPU{
{ID: "g0", Model: "H100-SXM5", MemoryTotalGB: 80, MemoryFreeGB: 80},
{ID: "g1", Model: "H100-SXM5", MemoryTotalGB: 80, MemoryFreeGB: 80},
},
}}},
}
buf, _ := json.Marshal(body)
resp, err := http.Post(ts.URL+"/v1/fit", "application/json", bytes.NewReader(buf))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
var out api.FitResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
// 131 GiB fp8 ≈ 70 GiB weights → fits across 2×H100 with TP=2.
if out.Verdict != api.VerdictAllFit {
t.Fatalf("expected all_fit across 2 GPUs, got %s: %+v", out.Verdict, out.Models)
}
if got := len(out.Models[0].GPUIDs); got != 2 {
t.Fatalf("expected big model spread across 2 GPUs, got %d", got)
}
}
17 changes: 17 additions & 0 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func NewRouter(h *Handler, mws ...func(http.Handler) http.Handler) http.Handler
r.Get("/v1/gpus", h.GetGPUs)
r.Get("/v1/models/{owner}/{repo}/profile", h.GetProfile)
r.Post("/v1/plan", h.PostPlan)
r.Post("/v1/fit", h.PostFit)
return r
}

Expand Down Expand Up @@ -115,6 +116,22 @@ func (h *Handler) PostPlan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}

func (h *Handler) PostFit(w http.ResponseWriter, r *http.Request) {
var req api.FitRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, httpError{Code: http.StatusBadRequest, Body: api.Error{Code: "INVALID_JSON", Message: err.Error()}})
return
}
if err := req.Validate(); err != nil {
writeError(w, httpError{Code: http.StatusBadRequest, Body: api.Error{Code: "VALIDATION_FAILED", Message: err.Error()}})
return
}
resp := h.Solver.Fit(r.Context(), req)
writeJSON(w, http.StatusOK, resp)
}

// httpError carries an HTTP status + JSON-serializable body.
type httpError struct {
Code int
Expand Down
Loading
Loading