feat(modelpool): cross-pod sticky model swapping on a shared GPU slot - #1393
Conversation
7f048ba to
d895dd4
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
0f26245 to
f8a5643
Compare
Defilan
left a comment
There was a problem hiding this comment.
Thanks for this, and apologies for the slow first pass. The shape is good: the RBAC is a namespaced Role rather than a ClusterRole, scoped to inferenceservices, bound to a per-router ServiceAccount and owner-referenced so it garbage-collects with the ModelRouter. CRD, chart CRD, and RBAC are all in sync, and the test coverage is substantial.
One thing I want to resolve before merging: activation does not appear to be coordinated across proxy replicas.
Activator.mu is a single in-process mutex, and spec.proxy.replicas is user-settable with nothing constraining it when a pool is attached (replicas := int32(1) in newRouterDeployment is only a default). The member writes in activator_k8s.go use client.Patch(ctx, patched, client.MergeFrom(cur)) without WithOptimisticLock, so two proxies racing to activate different members do not conflict; the later write silently wins.
The controller-side exclusive-slot invariant still holds, so this is not corruption and two members never go resident at once. The cost is thrash: at two or more replicas, proxies receiving requests for different members each drive a swap, and every flip is a full drain plus a multi-gigabyte reload. That is the GPU ping-pong the coalescing logic exists to prevent, reappearing at replicas >= 2, with nothing in status or events to indicate it is happening.
The Activator doc comment says all swaps for a pool are serialized so at most one member is ever made resident at a time, which a reader would reasonably take as a cluster-wide guarantee rather than a per-process one.
Two resolutions I would be happy with:
- Validation. Reject or pin
spec.proxy.replicasto 1 while a ModelPool references the router, and state the constraint in the CRD field docs. Smallest change, and honest about what the current design guarantees. - Real single-writer.
client.MergeFromWithOptions(cur, client.MergeFromWithOptimisticLock{})so competing activations conflict instead of silently last-write-winning, plus a lease so one replica owns swap decisions for a pool.
Which did you have in mind? If it is 1, that is fine for this PR and 2 can be a follow-up issue.
Introduce a ModelPool CR that names a set of member InferenceServices sharing one exclusive GPU slot on a node, with a sticky swap policy. The reconciler enforces the exclusive-slot invariant: at most one member is Ready at a time, and the incumbent is fully drained and unloaded (VRAM freed) before the next member loads. Drain-before-unload reuses the same /slots idleness contract as the InferenceService rollout drain (factored into a shared llamaServerIdle helper): a busy incumbent is never scaled down, and an unreachable idle check fails closed (incumbent stays resident). Unlike a rollout there is no idleTimeout force branch, so request traffic can never force-unload a resident member; the bound on a waiting request is the router hold budget (added in the follow-up router commit), never a controller-side force. Co-residency gate for unified-memory nodes: the incoming owner is held at replicas 0 until the incumbent's Pods are actually gone (a Pod list, not the Stopped phase, which flips while the Pod is still Terminating). On a unified-memory node the device plugin frees gpu:1 on Pod termination but the allocation only releases on process exit, so gating the owner's start on Pod termination closes the reclaim-lag window where the scheduler could otherwise briefly co-schedule two residents and OOM. The in-flight owner is remembered in status.PendingMember so a deferred swap does not revert to the incumbent while the owner is held at zero. v1 is Kubernetes-GPU-only and sticky-only (priority mode deferred to a follow-up): on a one-GPU node the device plugin gates co-scheduling. Metal-backed members have no such gating and are refused (MetalSupported=False); metal-agent enforcement is a follow-up. Status surfaces ResidentMember, PendingMember, per-member phase, and the SlotAllocated / MembersValid / SwapDeferred (PodsBusy | IdleCheckFailed) / MetalSupported conditions. To keep the member replica patches from racing the InferenceService reconciler, both its status writer and its Deployment update now treat an optimistic-lock conflict as a clean requeue / retry (retry.RetryOnConflict with a fresh read) instead of a spurious error, so a ModelPool scaling a member during a swap no longer produces update- conflict log noise. Ginkgo coverage includes the exclusive-slot invariant, drain-before-load, busy-defer, idle-check fail-closed, a fast-interleaved swap (never two Ready), the unified-memory reclaim-lag gate, and the metal guard; a unit test covers the Deployment-update conflict retry. Refs defilantech#1111 Refs defilantech#516 Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
Teach the router-proxy to front ModelPool members: a request to a non-resident member is held open (the KServe activator pattern) while the proxy makes that member resident, then forwarded on the held connection. The swap policy lives in the proxy, in Go, so it needs no upstream llama.cpp change: - Sticky residency: the resident member stays until a different member is requested; no automatic restore of a default. - Coalesce until idle (anti-thrash): a cross-model request is held (never 503'd on this path) and the slot flips only once the incumbent is fully idle. A busy incumbent is never preempted; same-model demand arriving during the hold is served on the warm member. Idleness is the entire anti-thrash mechanism in v1: no request counting, no timers. - Cancel-on-timeout: when a hold ends with no remaining waiter, the pending activation is cancelled (member scaled back to zero) so the pool does not complete a useless swap. A swap load in progress is never aborted by a single caller giving up. - Budget fallback: when the hold exceeds the request budget, return 503 with Retry-After (never 429). The proxy commits a swap by scaling its chosen member's spec.replicas; the ModelPoolReconciler owns the idle-gated, fail-closed, never-force drain of the incumbent. Activation is gated behind --enable-activation, which the ModelRouter controller sets (with a dedicated ServiceAccount + namespaced Role granting get/list/watch/patch on InferenceServices) only when a backend resolves to a ModelPool member. The proxy now serves Prometheus metrics on --metrics-listen (:8081) with a metrics container port, so the already-emitted ModelPool metrics (residency, swaps, swap/hold duration, coalescing, held-request depth) are scrapeable by a ServiceMonitor rather than collected but invisible. Unit coverage: BackendPool compilation (pooled and unpooled), activation RBAC provisioning and least-privilege, deployment activation wiring (--enable-activation / ServiceAccount / ROUTER_NAME), the pool watch mapping, and the end-to-end 503 + Retry-After on an activation that exceeds the request budget. Refs defilantech#1111 Refs defilantech#516 Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
Add ModelPool.spec.swapBudget (default 300s) to bound the cross-model swap hold independently of the router's response-header (generation) timeout. Previously a single deadline capped both, so a large cold-load forced operators to inflate the generation timeout for every request just to let a swap finish. The proxy now acquires the pool slot under this dedicated budget and starts the dispatch clock only after the member is resident, so the swap wait no longer eats the generation budget. A pool without an explicit budget falls back to the dispatch default, preserving prior behavior. Also self-heal pool residency after out-of-band changes: replace the one-shot seed with a bounded reconcileResident resync and add InvalidateResident, which the proxy calls on a connection-level dispatch failure to a pooled backend so the next Acquire re-verifies residency immediately instead of returning stale 502s until a proxy restart. Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
ModelRouter had no way to place the router-proxy pod, so it could land on a node that lacks the proxy image (for example a node-local registry that only pushed the image to specific hosts), leaving the Deployment stuck pulling. Add RouterProxySpec.NodeSelector, a passthrough map that sets the proxy Pod's nodeSelector, so operators can pin the proxy to nodes that have the image or co-locate it with its backends. Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
…d CRD The ModelPool CRD file and reconciler shipped, but the CRD was never added to config/crd/kustomization.yaml, so make install did not install it. The manager's ModelPool informer then failed to sync (no matches for kind ModelPool), blocking cache sync for every controller and leaving unrelated resources such as ModelRouter unreconciled. Add the CRD to the kustomize resource list. Also regenerate the ModelPool CRD: the committed copy still carried a hand-written minResidency field and a priority swapPolicy enum value that are not in ModelPoolSpec, so the CRD-sync check reported drift. Config and Helm-chart CRDs now match the Go source. Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
…ot-owner logic Add native fake-client and pure-function unit tests for paths flagged by codecov: kubeMemberController (Activate/Deactivate/Phase/WaitReady incl. idempotent no-ops and error paths); reconcileRouterActivationRBAC (unpooled no-op plus the pooled ServiceAccount/Role/RoleBinding provisioning with owner refs and idempotency); and resolveSlotOwner with the replicasOf/memberReady/memberNames helpers across the cold/warm/pending/default slot-owner matrix. Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
ModelPool activation serializes swaps through a single in-process lock (Activator.mu), so a second proxy replica races it: two proxies receiving requests for different members each drive a swap, thrashing the shared GPU slot with full drains and multi-gigabyte reloads. The controller-side exclusive-slot invariant still holds (no corruption), but the coalescing that exists to prevent that thrash is defeated at replicas >= 2. Pin spec.proxy.replicas to 1 in newRouterDeployment whenever the router has pooled backends, log the override at reconcile time, and document the constraint on the CRD field. Cross-replica swap coordination (optimistic-lock writes plus a lease) is the real multi-replica fix and is tracked as a follow-up. Signed-off-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com>
9216237 to
20697d8
Compare
|
Thanks for the thorough pass. I went with option 1 (pin) for this PR and filed #1477 to track option 2 (the real cross-replica single-writer). What changed:
You are right that the #1477 covers the real fix: Also rebased onto latest main to clear the merge conflict. Worth flagging one resolution: the router-proxy metrics server collided with the new |
Defilan
left a comment
There was a problem hiding this comment.
Option 1 is the right call for this PR, and #1477 is the right place for the
rest. Approving.
I verified each of the four claims rather than taking them:
- The pin is real and in the right place:
if hasPools { replicas = 1 }in
newRouterDeployment, with the override logged at reconcile time when a user
asked for more. Putting it there rather than in the validating webhook is
correct; pool resolution needs a cluster lookup and that webhook is
deliberately pure-spec. TestNewRouterDeploymentPinsReplicasWhenPooledasserts both directions,
pooled pinning to 1 withreplicas: 3set and unpooled still honouring 3.
The negative half is what makes it a real test.- The CRD field doc carries the constraint and it is regenerated into both the
base CRD and the chart. - The drain-path conflict was resolved by adapting
memberIdleto main's newer
IdleDetectorinterface rather than taking either side wholesale. That is the
resolution I was most concerned about and it preserves the fail-closed
behaviour.
On the metrics registry: you found a live bug on main, and you are right.
I checked this empirically because it is exactly the kind of thing that silently
serves an empty endpoint. Built both binaries and scraped them:
main: 38 metric families
PR: 149 metric families, including certwatcher_* and controller_runtime_*
Those are different registries. internal/metrics registers every collector
into ctrlmetrics.Registry in its init(), while main's handler is
promhttp.Handler(), which serves the Prometheus default gatherer. So on main
the llmkube_router_* collectors exist in a registry nothing exposes, and
/metrics returns Go runtime and process metrics only. #1427 is effectively
still open, and the PodMonitor in #1473 is currently scraping an endpoint with
none of the series it was added for.
Your promhttp.HandlerFor(ctrlmetrics.Registry, ...) is the correct fix.
One request, and it is not a change to this PR: that fix is small, independent,
and repairs something broken on main right now, while this PR is large and will
take longer to land. Would you mind pulling it out into its own PR against main?
Happy to do it myself and credit you if you would rather not carry another
branch. This one can keep the change too; the duplicate resolves trivially
whichever lands second.
One non-blocking observation for later, arguably pre-existing rather than yours.
GenericBackend.IdleProbe returns errIdleUnsupported when the
AnnotationIdleEndpoint annotation is absent, and memberIdle surfaces that as
an error, which the caller treats like any probe failure: defer with
ReasonIdleCheckFailed. For a generic-backend member without that annotation
that is a permanent wedge reported as a transient condition, since the probe can
never start succeeding. The six runtimes that implement IdleDetector are all
fine, so this only bites the annotation-less generic case. Worth distinguishing
"cannot ever be probed" from "probe failed this time" at some point, perhaps
alongside #1477.
The endpoint served the Prometheus default registry while every llmkube collector registers into controller-runtime's, so it exposed none of the series it exists for. That shipped in defilantech#1457 and was fixed in defilantech#1393; nothing stops it regressing again. The failure is silent, which is the reason it needs a test rather than care. The scrape returns HTTP 200 with Go runtime and process metrics, the PodMonitor from defilantech#1473 reports a healthy target, and the dashboards are simply empty. Touching a real counter before scraping is load-bearing: a labelled collector emits no family until it has a child series, so an idle scrape is identical under either registry and the obvious version of this test passes on the bug. Verified by reintroducing promhttp.Handler() and confirming the test fails, rather than assuming a new test must catch something. newMetricsHandler() is extracted from main() only so there is something to assert against. Co-authored-by: Sylvain Niles <540991+sylvainsf@users.noreply.github.com> Signed-off-by: Christopher Maher <chris@mahercode.io>
…mkube (0.9.14 ➔ 0.9.16) (#294) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/home-operations/charts-mirror/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.14` → `0.9.16` | --- ### Release Notes <details> <summary>defilantech/LLMKube (ghcr.io/home-operations/charts-mirror/llmkube)</summary> ### [`v0.9.16`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0916-2026-08-10) [Compare Source](defilantech/LLMKube@v0.9.15...v0.9.16) ##### Features - **chart:** PodMonitor for router-proxy metrics ([#​1473](defilantech/LLMKube#1473)) ([fd7c640](defilantech/LLMKube@fd7c640)) - **foreman:** advisory two-site parity signal in the coder gate ([#​1456](defilantech/LLMKube#1456)) ([944f90d](defilantech/LLMKube@944f90d)) - **foreman:** image input, so a vision-capable coder can see what it rendered ([#​1471](defilantech/LLMKube#1471)) ([6c122be](defilantech/LLMKube@6c122be)) - **foreman:** read-only fetch\_pull\_request tool ([#​1458](defilantech/LLMKube#1458)) ([7f4910b](defilantech/LLMKube@7f4910b)) - **modelpool:** cross-pod sticky model swapping on a shared GPU slot ([#​1393](defilantech/LLMKube#1393)) ([ba7777d](defilantech/LLMKube@ba7777d)) - **router-proxy:** expose llmkube\_router\_\* metrics on a dedicated listener ([#​1457](defilantech/LLMKube#1457)) ([ca3730d](defilantech/LLMKube@ca3730d)) ##### Bug Fixes - **controller:** make the custom CA additive to the system trust store ([#​1469](defilantech/LLMKube#1469)) ([d490059](defilantech/LLMKube@d490059)) - **controller:** stage files and mmproj from an s3:// object store ([#​1475](defilantech/LLMKube#1475)) ([769b60d](defilantech/LLMKube@769b60d)) - **dashboard:** AMD dashboard vendor hygiene — remove runtime-agnostic llama.cpp panels, standardize on DS\_PROMETHEUS ([#​1446](defilantech/LLMKube#1446)) ([c6c0670](defilantech/LLMKube@c6c0670)) - **dashboard:** retarget amd-gpu-observability off amdgpu\_\*-only queries ([#​1474](defilantech/LLMKube#1474)) ([a02e8e8](defilantech/LLMKube@a02e8e8)) ### [`v0.9.15`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0915-2026-08-08) [Compare Source](defilantech/LLMKube@v0.9.14...v0.9.15) ##### Features - **chart:** expose --ca-cert-configmap as controllerManager.caCertConfigMap ([#​1440](defilantech/LLMKube#1440)) ([606c7f9](defilantech/LLMKube@606c7f9)) - **controller:** diagnose CUDA driver/runtime mismatch from crashed pods ([#​1425](defilantech/LLMKube#1425)) ([06670f9](defilantech/LLMKube@06670f9)) - **controller:** diagnose model-transfer failures from init containers ([#​1463](defilantech/LLMKube#1463)) ([a092de3](defilantech/LLMKube@a092de3)) - **foreman:** advisory gate for command-string changes tested only by shape ([#​1421](defilantech/LLMKube#1421)) ([b580e18](defilantech/LLMKube@b580e18)) - **inferenceservice:** add spec.modelCache.persistence for an ephemeral model cache ([#​1452](defilantech/LLMKube#1452)) ([c4ae7ee](defilantech/LLMKube@c4ae7ee)) ##### Bug Fixes - **api:** reject unservable speculativeDecoding type draft at admission ([#​1455](defilantech/LLMKube#1455)) ([5d3ab73](defilantech/LLMKube@5d3ab73)) - **chart:** default the router-proxy image tag to the chart appVersion ([#​1431](defilantech/LLMKube#1431)) ([3e07b81](defilantech/LLMKube@3e07b81)) - **controller:** clean up orphaned .tmp files from interrupted model transfers ([#​1459](defilantech/LLMKube#1459)) ([2f453d6](defilantech/LLMKube@2f453d6)) - **controller:** emit draft-simple for speculativeDecoding type draft ([#​1417](defilantech/LLMKube#1417)) ([ccccd02](defilantech/LLMKube@ccccd02)) - **controller:** publish model downloads atomically so an interrupted transfer is never cached ([#​1432](defilantech/LLMKube#1432)) ([206b6c3](defilantech/LLMKube@206b6c3)) - **controller:** route s3:// model sources to the runtime-resolved path ([#​1450](defilantech/LLMKube#1450)) ([f054960](defilantech/LLMKube@f054960)) - **controller:** set TerminationMessagePolicy on generated init containers ([#​1460](defilantech/LLMKube#1460)) ([bc732b3](defilantech/LLMKube@bc732b3)) - **dashboard:** select the latency success series by le!="" so llmkube-slo error-budget panels render for any threshold ([#​1444](defilantech/LLMKube#1444)) ([d381112](defilantech/LLMKube@d381112)) - **foreman:** add chart validation to the coder gate, and install helm so it can run ([#​1442](defilantech/LLMKube#1442)) ([b26c6ae](defilantech/LLMKube@b26c6ae)) - **foreman:** detect assertion-value churn in the test-dilution gate ([#​1416](defilantech/LLMKube#1416)) ([b53df14](defilantech/LLMKube@b53df14)) - **foreman:** preserve the coder Job name's uniqueness suffix on truncation ([#​1412](defilantech/LLMKube#1412)) ([929f0a8](defilantech/LLMKube@929f0a8)) - ground the PR body summary against the branch diff ([#​1448](defilantech/LLMKube#1448)) ([6f24527](defilantech/LLMKube@6f24527)) - **metrics:** correct the AMD doc's metric contract and guard prose docs ([#​1420](defilantech/LLMKube#1420)) ([36631a9](defilantech/LLMKube@36631a9)) - **runtime:** disable the llama.cpp prompt cache for embedding and rerank ([#​1413](defilantech/LLMKube#1413)) ([407de59](defilantech/LLMKube@407de59)) ##### Documentation - **b200:** record GB10 partial-proxy reachability and why NVLink/MIG/sm\_100 stay gated ([#​1422](defilantech/LLMKube#1422)) ([e6ab2e9](defilantech/LLMKube@e6ab2e9)) - **chart:** dashboard datasources examples must use the datasource UID, not its display name ([#​1433](defilantech/LLMKube#1433)) ([a57d499](defilantech/LLMKube@a57d499)) </details> --- ### Configuration 📅 **Schedule**: (in timezone America/New_York) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC43LjQiLCJ1cGRhdGVkSW5WZXIiOiI0NC43LjQiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbInJlbm92YXRlL2NvbnRhaW5lciIsInR5cGUvcGF0Y2giXX0=--> Reviewed-on: https://git.greyrock.io/greyrock-labs/home-ops/pulls/294
What
Adds
ModelPool, a CRD plus controller that lets several single-modelInferenceServices share one exclusive GPU slot. At most one member isReadyat a time; the controller drains and unloads the incumbent (freeing VRAM) before the next member loads. TheModelRouterproxy fronts the members, activates a stopped member on demand, and enforces a sticky, anti-thrash swap policy across the slot.Four commits:
feat(modelpool): add ModelPool CRD and exclusive-slot reconcilerfeat(router): activate ModelPool members with sticky idle-gated swappingfeat(modelpool): decouple swap hold budget and self-heal residencyfeat(router): pin the router-proxy with spec.proxy.nodeSelectorWhy
Fixes #1111.
Fixes #1123.
Refs #516.
ModelPoolis tracked as its own feature issue (#1111), distinct from the in-processruntime: llamacpp-routershape in #516.ModelPoolis the cross-pod shape: N independent single-model services sharing one exclusive GPU slot, so LLMKube owns the queue and swap policy in Go and the anti-thrash behavior needs no llama.cpp change. Members can also diverge in runtime (different images, context sizes, vLLM vs llama.cpp). The two shapes compose: aModelRoutercan front both transparently.On a single-GPU node only one model can be resident, and there was no safe primitive to switch models on demand.
ModelPoolprovides scale-from-zero on request, a VRAM-gated exclusive slot, and a sticky policy that will not thrash under mixed demand. The residency self-heal in this PR also closes the stale-resident activator bug (#1123): a member whose pod is gone no longer keeps serving stale 502s until a proxy restart.Priority-reclaim swap mode and metal-backed members are deliberately deferred and tracked in #1394.
How
Design notes (full write-up in
docs/proposals/516-modelpool-cross-pod-swapping.md):Pendinguntil the incumbent releases the device. v1 is Kubernetes-GPU-only; metal-backed members are refused withMetalSupported=False.IdleDetector.IdleProbe,/slotsfor llama.cpp): an unreachable idle check fails closed (the incumbent stays resident). There is no controller-side force-unload; the only bound on a waiting request is the router hold budget, surfaced as 503 plusRetry-After(never 429).spec.swapBudget(default 300s) bounds the swap hold independently of the response-header (generation) timeout, so a large cold load does not force operators to inflate the generation cap for every request. The dispatch clock starts only after the member is resident.ModelRouter; the proxy runs with--enable-activationonly when the router actually fronts pools.spec.proxy.nodeSelectorpins the router-proxy pod, which fixes a node-local-registry setup where the proxy scheduled onto a node that lacked its image.Metrics
New ModelPool observability, exported by the router-proxy at
:8081/metrics(registered via the sharedAllCollectorslist so nothing is silently dropped):llmkube_modelpool_resident{namespace,pool,member}: which member owns the slot (1 resident, 0 not).llmkube_modelpool_swaps_total{router,pool,from,to}: completed slot swaps by direction.llmkube_modelpool_swap_duration_seconds{router,pool}: incumbent unload plus target load.llmkube_modelpool_hold_duration_seconds{router,pool,member}: how long a request was held open waiting for activation.llmkube_modelpool_coalesced_total{router,pool,member}: requests served on the warm member without a swap.llmkube_modelpool_held_requests{router,pool,member}: requests currently held open per member.Testing
Cluster validation (author, on the ModelPool functionality)
Environment: k3s v1.36.2+k3s1, two nodes (helpy control-plane; corsair agent, Ubuntu 24.04.4, kernel 6.17-oem). Single AMD Radeon 8050S iGPU on corsair (gfx1151 / Strix Halo), served via the operator's Vulkan image
ghcr.io/defilantech/llmkube-llama-vulkanscheduled againstdevices.es/dri-render(generic-device-plugin, count 1), notamd.com/gpuor NVIDIA. Node labellab.sylvain.dev/inference=heavyon corsair. Models pre-staged on a local NVMe hostPath PVC (pvc://), no download.Live-tested commit:
ad4d8e0(the pre-rebase "decouple swap hold budget and self-heal residency" tip). Built controller and router-proxy with podman, imported into containerd viak3s ctr images import, rolled the controller-manager onto the new image, and applied the branch's ModelPool CRD (the live CRD predatedswapBudget).ModelPool
heavy-slot(namespacelab,gpu: 1,swapPolicy: sticky,default: coder,nodeSelector: {lab.sylvain.dev/inference: heavy},swapBudget: 300s) with two Vulkan members on the single GPU:coder= Qwen3.6-27B-Q6_K (about 21 GB)gemma-longctx= gemma-4-31B-it-Q4_K_M (about 18 GB)Fronted by a
ModelRouter(defaultRouteStrategy: BackendNameMatch); proxy and per-backendresponseHeaderTimeoutinitially 60s, later dropped to 15s for the decoupling proof.Results:
"swapBudget": 300000000000(300s) per backend pool alongside the backend"timeout", confirmingModelPool.spec.swapBudgetreachesBackendPool.SwapBudget, decoupled from the dispatch timeout.coderwarmed to Ready,gemma-longctxheld Stopped, pool Ready withresidentMember=coder.gemma-longctxdrainedcoderto Stopped, then loadedgemma-longctxto Ready. First run (60s timeout): HTTP 200 in 37s, server swap_duration about 34s.coder -> gemma-longctxreturned HTTP 200 in 35.2s with swap_duration about 32s. A 32s hold succeeding under a 15s generation cap is only possible becauseswapBudget(300s) governs the hold; under the old coupling that request would 503 at 15s.:8081/metrics:llmkube_modelpool_swaps_total{from="coder",to="gemma-longctx"} 1,swap_duration_secondssum about 32-34s (count 1),resident{member="coder"} 0andresident{member="gemma-longctx"} 1; the full family (swaps_total, swap_duration, resident, coalesced_total, hold_duration, held_requests) was present.--enable-activation(after the RBAC note below).Issues hit and worked around during the live run:
manager-rolepredated the activation rules, so the proxy Deployment was never created (operator loggedserviceaccounts is forbidden ... cannot list). Applying the branch's updatedconfig/rbac/role.yamland restarting the operator fixed it. A fresh install from this branch's chart provisions it correctly.ad4d8e0had no proxy placement field, so the proxy scheduled onto helpy, which lacked the node-local image (ImagePullBackOff). Cordoning helpy rescheduled it to corsair. This is exactly the gapspec.proxy.nodeSelector(commit 4) closes.Not live-exercised (verified at the code/test level only, stated to avoid overclaiming):
spec.proxy.nodeSelectorpinning: implemented in commit 4, verified via build/vet/tests and generated CRD/deepcopy match, but not run on the cluster (the live run used the helpy-cordon workaround instead).ModelRouteractivator serves a stale resident member and never self-heals after an out-of-band residency change (instant 502s until proxy restart) #1123 stale-resident self-heal: present and covered by unit/controller tests, but no deliberate out-of-band residency change was staged live; the live swaps completed without stale-502 symptoms.Local validation (on the rebased branch)
This branch was rebased onto current
upstream/main(0 behind), resolving the conflicts from upstream's idle-check refactor (the ModelPool drain now routes throughIdleDetector.IdleProbeinstead of a private/slotshelper) and the deployment-update path (merged upstream's suspension-aware HPA handling with the retry-on-conflict update that absorbs concurrent ModelPool replica scaling). Manifests were regenerated with the pinned controller-gen v0.19.0 (this also dropped staleminResidency/prioritydrift from the ModelPool CRD) and the CRDs and ClusterRole were synced into the Helm chart.On the rebased tip:
go build ./...(linux): cleango vet ./...(linux): cleangolangci-lint runv2.12.2 on the changed packages (internal/router/...,internal/metrics/...,internal/controller/...,api/v1alpha1/...): 0 issuesgo test ./internal/router/...: pass (about 122s), covering the activator sticky/coalesce/cancel-on-timeout paths, the swapBudget decoupling test, and the [BUG]ModelRouteractivator serves a stale resident member and never self-heals after an out-of-band residency change (instant 502s until proxy restart) #1123 self-heal testgo test ./internal/controller/...) was not re-run on the rebase host (Windows, no envtest binaries); it passed in the author's lab on the pre-rebase code. Reviewers on Linux CI will exercise it.Checklist
make lintpasses (golangci-lint v2.12.2, 0 issues on the changed packages)docs/proposals/)