Skip to content

Commit 5dc6b3e

Browse files
authored
Add barebones but working implementation of model preload (#209, #235)
Add barebones but working implementation of model preload * add config test for Preload hook * improve TestProxyManager_StartupHooks * docs for new hook configuration * add a .dev to .gitignore
1 parent 74c69f3 commit 5dc6b3e

10 files changed

Lines changed: 199 additions & 13 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ build/
44
dist/
55
.vscode
66
.DS_Store
7+
.dev/

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ Written in golang, it is very easy to install (single binary with no dependencie
3131
- ✅ Run multiple models at once with `Groups` ([#107](https://github.com/mostlygeek/llama-swap/issues/107))
3232
- ✅ Automatic unloading of models after timeout by setting a `ttl`
3333
- ✅ Use any local OpenAI compatible server (llama.cpp, vllm, tabbyAPI, etc)
34-
- ✅ Docker and Podman support
34+
-Reliable Docker and Podman support with `cmdStart` and `cmdStop`
3535
- ✅ Full control over server settings per model
36+
- ✅ Preload models on startup with `hooks` ([#235](https://github.com/mostlygeek/llama-swap/pull/235))
3637

3738
## How does llama-swap work?
3839

@@ -42,9 +43,9 @@ In the most basic configuration llama-swap handles one model at a time. For more
4243

4344
## config.yaml
4445

45-
llama-swap is managed entirely through a yaml configuration file.
46+
llama-swap is managed entirely through a yaml configuration file.
4647

47-
It can be very minimal to start:
48+
It can be very minimal to start:
4849

4950
```yaml
5051
models:
@@ -55,7 +56,7 @@ models:
5556
--port ${PORT}
5657
```
5758
58-
However, there are many more capabilities that llama-swap supports:
59+
However, there are many more capabilities that llama-swap supports:
5960
6061
- `groups` to run multiple models at once
6162
- `ttl` to automatically unload models
@@ -90,7 +91,7 @@ llama-swap can be installed in multiple ways
9091

9192
### Docker Install ([download images](https://github.com/mostlygeek/llama-swap/pkgs/container/llama-swap))
9293

93-
Docker images with llama-swap and llama-server are built nightly.
94+
Docker images with llama-swap and llama-server are built nightly.
9495

9596
```shell
9697
# use CPU inference comes with the example config above
@@ -137,10 +138,10 @@ $ docker run -it --rm --runtime nvidia -p 9292:8080 \
137138

138139
### Homebrew Install (macOS/Linux)
139140

140-
The latest release of `llama-swap` can be installed via [Homebrew](https://brew.sh).
141+
The latest release of `llama-swap` can be installed via [Homebrew](https://brew.sh).
141142

142143
```shell
143-
# Set up tap and install formula
144+
# Set up tap and install formula
144145
brew tap mostlygeek/llama-swap
145146
brew install llama-swap
146147
# Run llama-swap

config.example.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# llama-swap YAML configuration example
22
# -------------------------------------
33
#
4+
# 💡 Tip - Use an LLM with this file!
5+
# ====================================
6+
# This example configuration is written to be LLM friendly! Try
7+
# copying this file into an LLM and asking it to explain or generate
8+
# sections for you.
9+
# ====================================
10+
#
411
# - Below are all the available configuration options for llama-swap.
512
# - Settings with a default value, or noted as optional can be omitted.
613
# - Settings that are marked required must be in your configuration file
@@ -207,3 +214,19 @@ groups:
207214
- "forever-modelA"
208215
- "forever-modelB"
209216
- "forever-modelc"
217+
218+
# hooks: a dictionary of event triggers and actions
219+
# - optional, default: empty dictionary
220+
# - the only supported hook is on_startup
221+
hooks:
222+
# on_startup: a dictionary of actions to perform on startup
223+
# - optional, default: empty dictionar
224+
# - the only supported action is preload
225+
on_startup:
226+
# preload: a list of model ids to load on startup
227+
# - optional, default: empty list
228+
# - model names must match keys in the models sections
229+
# - when preloading multiple models at once, define a group
230+
# otherwise models will be loaded and swapped out
231+
preload:
232+
- "llama"

proxy/config.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ func (c *GroupConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
138138
return nil
139139
}
140140

141+
type HooksConfig struct {
142+
OnStartup HookOnStartup `yaml:"on_startup"`
143+
}
144+
145+
type HookOnStartup struct {
146+
Preload []string `yaml:"preload"`
147+
}
148+
141149
type Config struct {
142150
HealthCheckTimeout int `yaml:"healthCheckTimeout"`
143151
LogRequests bool `yaml:"logRequests"`
@@ -155,6 +163,9 @@ type Config struct {
155163

156164
// automatic port assignments
157165
StartPort int `yaml:"startPort"`
166+
167+
// hooks, see: #209
168+
Hooks HooksConfig `yaml:"hooks"`
158169
}
159170

160171
func (c *Config) RealModelName(search string) (string, bool) {
@@ -330,6 +341,22 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
330341
}
331342
}
332343

344+
// clean up hooks preload
345+
if len(config.Hooks.OnStartup.Preload) > 0 {
346+
var toPreload []string
347+
for _, modelID := range config.Hooks.OnStartup.Preload {
348+
modelID = strings.TrimSpace(modelID)
349+
if modelID == "" {
350+
continue
351+
}
352+
if real, found := config.RealModelName(modelID); found {
353+
toPreload = append(toPreload, real)
354+
}
355+
}
356+
357+
config.Hooks.OnStartup.Preload = toPreload
358+
}
359+
333360
return config, nil
334361
}
335362

proxy/config_posix_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ func TestConfig_LoadPosix(t *testing.T) {
100100
content := `
101101
macros:
102102
svr-path: "path/to/server"
103+
hooks:
104+
on_startup:
105+
preload: ["model1", "model2"]
103106
models:
104107
model1:
105108
cmd: path/to/cmd --arg1 one
@@ -163,6 +166,11 @@ groups:
163166
Macros: map[string]string{
164167
"svr-path": "path/to/server",
165168
},
169+
Hooks: HooksConfig{
170+
OnStartup: HookOnStartup{
171+
Preload: []string{"model1", "model2"},
172+
},
173+
},
166174
Models: map[string]ModelConfig{
167175
"model1": {
168176
Cmd: "path/to/cmd --arg1 one",

proxy/discardWriter.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package proxy
2+
3+
import "net/http"
4+
5+
// Custom discard writer that implements http.ResponseWriter but just discards everything
6+
type DiscardWriter struct {
7+
header http.Header
8+
status int
9+
}
10+
11+
func (w *DiscardWriter) Header() http.Header {
12+
if w.header == nil {
13+
w.header = make(http.Header)
14+
}
15+
return w.header
16+
}
17+
18+
func (w *DiscardWriter) Write(data []byte) (int, error) {
19+
return len(data), nil
20+
}
21+
22+
func (w *DiscardWriter) WriteHeader(code int) {
23+
w.status = code
24+
}
25+
26+
// Satisfy the http.Flusher interface for streaming responses
27+
func (w *DiscardWriter) Flush() {}

proxy/events.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const ChatCompletionStatsEventID = 0x02
77
const ConfigFileChangedEventID = 0x03
88
const LogDataEventID = 0x04
99
const TokenMetricsEventID = 0x05
10+
const ModelPreloadedEventID = 0x06
1011

1112
type ProcessStateChangeEvent struct {
1213
ProcessName string
@@ -48,3 +49,12 @@ type LogDataEvent struct {
4849
func (e LogDataEvent) Type() uint32 {
4950
return LogDataEventID
5051
}
52+
53+
type ModelPreloadedEvent struct {
54+
ModelName string
55+
Success bool
56+
}
57+
58+
func (e ModelPreloadedEvent) Type() uint32 {
59+
return ModelPreloadedEventID
60+
}

proxy/helpers_test.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import (
1313
)
1414

1515
var (
16-
nextTestPort int = 12000
17-
portMutex sync.Mutex
18-
testLogger = NewLogMonitorWriter(os.Stdout)
16+
nextTestPort int = 12000
17+
portMutex sync.Mutex
18+
testLogger = NewLogMonitorWriter(os.Stdout)
19+
simpleResponderPath = getSimpleResponderPath()
1920
)
2021

2122
// Check if the binary exists
@@ -69,13 +70,11 @@ func getTestSimpleResponderConfig(expectedMessage string) ModelConfig {
6970
}
7071

7172
func getTestSimpleResponderConfigPort(expectedMessage string, port int) ModelConfig {
72-
binaryPath := getSimpleResponderPath()
73-
7473
// Create a YAML string with just the values we want to set
7574
yamlStr := fmt.Sprintf(`
7675
cmd: '%s --port %d --silent --respond %s'
7776
proxy: "http://127.0.0.1:%d"
78-
`, binaryPath, port, expectedMessage, port)
77+
`, simpleResponderPath, port, expectedMessage, port)
7978

8079
var cfg ModelConfig
8180
if err := yaml.Unmarshal([]byte(yamlStr), &cfg); err != nil {

proxy/proxymanager.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/gin-gonic/gin"
18+
"github.com/mostlygeek/llama-swap/event"
1819
"github.com/tidwall/gjson"
1920
"github.com/tidwall/sjson"
2021
)
@@ -96,6 +97,35 @@ func New(config Config) *ProxyManager {
9697
}
9798

9899
pm.setupGinEngine()
100+
101+
// run any startup hooks
102+
if len(config.Hooks.OnStartup.Preload) > 0 {
103+
// do it in the background, don't block startup -- not sure if good idea yet
104+
go func() {
105+
discardWriter := &DiscardWriter{}
106+
for _, realModelName := range config.Hooks.OnStartup.Preload {
107+
proxyLogger.Infof("Preloading model: %s", realModelName)
108+
processGroup, _, err := pm.swapProcessGroup(realModelName)
109+
110+
if err != nil {
111+
event.Emit(ModelPreloadedEvent{
112+
ModelName: realModelName,
113+
Success: false,
114+
})
115+
proxyLogger.Errorf("Failed to preload model %s: %v", realModelName, err)
116+
continue
117+
} else {
118+
req, _ := http.NewRequest("GET", "/", nil)
119+
processGroup.ProxyRequest(realModelName, discardWriter, req)
120+
event.Emit(ModelPreloadedEvent{
121+
ModelName: realModelName,
122+
Success: true,
123+
})
124+
}
125+
}
126+
}()
127+
}
128+
99129
return pm
100130
}
101131

proxy/proxymanager_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/mostlygeek/llama-swap/event"
1718
"github.com/stretchr/testify/assert"
1819
"github.com/tidwall/gjson"
1920
)
@@ -832,3 +833,62 @@ func TestProxyManager_HealthEndpoint(t *testing.T) {
832833
assert.Equal(t, http.StatusOK, rec.Code)
833834
assert.Equal(t, "OK", rec.Body.String())
834835
}
836+
837+
func TestProxyManager_StartupHooks(t *testing.T) {
838+
839+
// using real YAML as the configuration has gotten more complex
840+
// is the right approach as LoadConfigFromReader() does a lot more
841+
// than parse YAML now. Eventually migrate all tests to use this approach
842+
configStr := strings.Replace(`
843+
logLevel: error
844+
hooks:
845+
on_startup:
846+
preload:
847+
- model1
848+
- model2
849+
groups:
850+
preloadTestGroup:
851+
swap: false
852+
members:
853+
- model1
854+
- model2
855+
models:
856+
model1:
857+
cmd: ${simpleresponderpath} --port ${PORT} --silent --respond model1
858+
model2:
859+
cmd: ${simpleresponderpath} --port ${PORT} --silent --respond model2
860+
`, "${simpleresponderpath}", simpleResponderPath, -1)
861+
862+
// Create a test model configuration
863+
config, err := LoadConfigFromReader(strings.NewReader(configStr))
864+
if !assert.NoError(t, err, "Invalid configuration") {
865+
return
866+
}
867+
868+
preloadChan := make(chan ModelPreloadedEvent, 2) // buffer for 2 expected events
869+
870+
unsub := event.On(func(e ModelPreloadedEvent) {
871+
preloadChan <- e
872+
})
873+
874+
defer unsub()
875+
876+
// Create the proxy which should trigger preloading
877+
proxy := New(config)
878+
defer proxy.StopProcesses(StopWaitForInflightRequest)
879+
880+
for i := 0; i < 2; i++ {
881+
select {
882+
case <-preloadChan:
883+
case <-time.After(5 * time.Second):
884+
t.Fatal("timed out waiting for models to preload")
885+
}
886+
}
887+
// make sure they are both loaded
888+
_, foundGroup := proxy.processGroups["preloadTestGroup"]
889+
if !assert.True(t, foundGroup, "preloadTestGroup should exist") {
890+
return
891+
}
892+
assert.Equal(t, StateReady, proxy.processGroups["preloadTestGroup"].processes["model1"].CurrentState())
893+
assert.Equal(t, StateReady, proxy.processGroups["preloadTestGroup"].processes["model2"].CurrentState())
894+
}

0 commit comments

Comments
 (0)