Skip to content
Open
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
3 changes: 3 additions & 0 deletions core/cli/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type ModelsCMDFlags struct {
Color string `env:"COLOR" hidden:""`
NoColor string `env:"NO_COLOR" hidden:""`
HFToken string `env:"HF_TOKEN" hidden:""`

ArtifactDownloadConcurrency int `env:"LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY" help:"How many files of a model artifact to download at once. 1 (the default) downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume" group:"storage" default:"1"`
}

type ModelsList struct {
Expand Down Expand Up @@ -87,6 +89,7 @@ func (mi *ModelsInstall) Run(ctx *cliContext.Context) error {

artifactMaterializer := modelartifacts.NewDefaultManager(
modelartifacts.WithHuggingFaceToken(mi.HFToken),
modelartifacts.WithDownloadConcurrency(mi.ArtifactDownloadConcurrency),
)
galleryService := galleryop.NewGalleryService(&config.ApplicationConfig{
SystemState: systemState,
Expand Down
3 changes: 3 additions & 0 deletions core/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type RunCMD struct {
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
ArtifactDownloadConcurrency int `env:"LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY" help:"How many files of a model artifact to download at once. 1 (the default) downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume" group:"storage" default:"1"`
GeneratedContentPath string `env:"LOCALAI_GENERATED_CONTENT_PATH,GENERATED_CONTENT_PATH" type:"path" default:"${generatedcontentpath}" help:"Location for generated content (e.g. images, audio, videos)" group:"storage"`
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"`
DataPath string `env:"LOCALAI_DATA_PATH" type:"path" default:"${basepath}/data" help:"Path for persistent data (collectiondb, agent state, tasks, jobs). Separates mutable data from configuration" group:"storage"`
Expand Down Expand Up @@ -278,8 +279,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {

opts := []config.AppOption{
config.WithContext(context.Background()),
config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency),
config.WithModelArtifactMaterializer(modelartifacts.NewDefaultManager(
modelartifacts.WithHuggingFaceToken(r.HFToken),
modelartifacts.WithDownloadConcurrency(r.ArtifactDownloadConcurrency),
)),
config.WithModelPreloadDisplay(r.Color, r.NoColor != ""),
config.WithConfigFile(r.ModelsConfigFile),
Expand Down
20 changes: 20 additions & 0 deletions core/config/application_artifact_materializer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ func (*applicationArtifactMaterializer) Ensure(context.Context, string, modelart
return modelartifacts.Result{}, nil
}

type configurableApplicationArtifactMaterializer struct {
applicationArtifactMaterializer
concurrency int
}

func (m *configurableApplicationArtifactMaterializer) SetDownloadConcurrency(concurrency int) {
m.concurrency = concurrency
}

var _ = Describe("ApplicationConfig model artifact materializer", func() {
It("provides a default materializer", func() {
Expect(NewApplicationConfig().ModelArtifactMaterializer).NotTo(BeNil())
Expand All @@ -31,4 +40,15 @@ var _ = Describe("ApplicationConfig model artifact materializer", func() {
Expect(field.Tag.Get("json")).To(Equal("-"))
Expect(field.Tag.Get("yaml")).To(Equal("-"))
})

It("applies runtime download concurrency to configurable materializers", func() {
materializer := &configurableApplicationArtifactMaterializer{}
appConfig := NewApplicationConfig(WithModelArtifactMaterializer(materializer))
concurrency := 4

appConfig.ApplyRuntimeSettings(&RuntimeSettings{ArtifactDownloadConcurrency: &concurrency})

Expect(appConfig.ArtifactDownloadConcurrency).To(Equal(4))
Expect(materializer.concurrency).To(Equal(4))
})
})
38 changes: 27 additions & 11 deletions core/config/application_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ApplicationConfig struct {
// network interfaces (e.g. eth0), filtering out docker0/veth noise.
WebRTCICEInterfaces []string
UploadLimitMB, Threads, ContextSize int
ArtifactDownloadConcurrency int
F16 bool
Debug bool
EnableTracing bool
Expand All @@ -58,12 +59,12 @@ type ApplicationConfig struct {
// gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
HTTPCompressionMinLength int
PreloadJSONModels string
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
P2PToken string
P2PNetworkID string
Federated bool
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
P2PToken string
P2PNetworkID string
Federated bool

// ExternalBaseURL is the externally visible base URL of this instance
// (scheme+host[:port]), set via LOCALAI_BASE_URL. When non-empty it is
Expand Down Expand Up @@ -276,11 +277,12 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
// force-enables it). It's a small in-memory ring buffer; the Settings
// toggle can still turn it off (a persisted false wins - see
// loadRuntimeSettingsFromFile).
EnableBackendLogging: true,
AgentJobRetentionDays: 30, // Default: 30 days
LRUEvictionMaxRetries: 30, // Default: 30 retries
LRUEvictionRetryInterval: 1 * time.Second, // Default: 1 second
ModelLoadFailureCooldown: 10 * time.Second, // Default: 10s base cooldown after a failed load
EnableBackendLogging: true,
ArtifactDownloadConcurrency: modelartifacts.DefaultDownloadConcurrency,
AgentJobRetentionDays: 30, // Default: 30 days
LRUEvictionMaxRetries: 30, // Default: 30 retries
LRUEvictionRetryInterval: 1 * time.Second, // Default: 1 second
ModelLoadFailureCooldown: 10 * time.Second, // Default: 10s base cooldown after a failed load
// WatchDogInterval is intentionally left at the zero value here.
// The startup loader applies a persisted runtime_settings.json value
// only when the interval is still 0 (its "not set by env var"
Expand Down Expand Up @@ -685,6 +687,15 @@ func WithModelArtifactMaterializer(materializer ArtifactMaterializer) AppOption
}
}

func WithArtifactDownloadConcurrency(concurrency int) AppOption {
return func(o *ApplicationConfig) {
if concurrency < 1 {
concurrency = modelartifacts.DefaultDownloadConcurrency
}
o.ArtifactDownloadConcurrency = concurrency
}
}

// WithModelPreloadDisplay configures terminal rendering for model preload output.
func WithModelPreloadDisplay(renderMode string, disableColor bool) AppOption {
return func(o *ApplicationConfig) {
Expand Down Expand Up @@ -1190,6 +1201,11 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
xsysinfo.SetDefaultVRAMBudget(b)
}
}
if settings.ArtifactDownloadConcurrency != nil {
if configurable, ok := o.ModelArtifactMaterializer.(interface{ SetDownloadConcurrency(int) }); ok {
configurable.SetDownloadConcurrency(o.ArtifactDownloadConcurrency)
}
}
// Note: ApiKeys need env-merge handling (MergeAPIKeys) - done by the
// caller, because the env-provided keys live on the startup config.
return requireRestart
Expand Down
19 changes: 10 additions & 9 deletions core/config/runtime_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ type RuntimeSettings struct {
LRUEvictionRetryInterval *string `json:"lru_eviction_retry_interval,omitempty"` // Interval between retries when waiting for busy models (e.g., 1s, 2s) (default: 1s)

// Performance settings
Threads *int `json:"threads,omitempty"`
ContextSize *int `json:"context_size,omitempty"`
VRAMBudget *string `json:"vram_budget,omitempty"` // Cap VRAM for allocation ("80%" or "12GB"; "" = no cap)
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
Threads *int `json:"threads,omitempty"`
ContextSize *int `json:"context_size,omitempty"`
ArtifactDownloadConcurrency *int `json:"artifact_download_concurrency,omitempty"`
VRAMBudget *string `json:"vram_budget,omitempty"` // Cap VRAM for allocation ("80%" or "12GB"; "" = no cap)
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`

// Security/CORS settings
CORS *bool `json:"cors,omitempty"`
Expand Down
9 changes: 9 additions & 0 deletions core/config/runtime_settings_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ var runtimeSettingsFields = []fieldSpec{
func(s *RuntimeSettings) **int { return &s.ContextSize },
func(o *ApplicationConfig) int { return o.ContextSize },
func(o *ApplicationConfig, v int) { o.ContextSize = v }),
field("artifact_download_concurrency",
func(s *RuntimeSettings) **int { return &s.ArtifactDownloadConcurrency },
func(o *ApplicationConfig) int { return o.ArtifactDownloadConcurrency },
func(o *ApplicationConfig, v int) {
if v < 1 {
v = 1
}
o.ArtifactDownloadConcurrency = v
}),
// VRAM budget: the cap string ("80%"/"12GB"/"" = uncapped). The live
// side effect (xsysinfo.SetDefaultVRAMBudget) is post-processing in the
// apply loop, not here - the row only owns the config member, matching
Expand Down
1 change: 1 addition & 0 deletions core/config/runtime_settings_registry_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ var _ = Describe("runtime settings registry", func() {
src.LRUEvictionRetryInterval = 3 * time.Second
src.Threads = 7
src.ContextSize = 8192
src.ArtifactDownloadConcurrency = 6
src.VRAMBudget = "12GiB"
src.F16 = true
src.Debug = true
Expand Down
5 changes: 5 additions & 0 deletions core/config/runtime_settings_startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettingsAtStartup(settings *RuntimeSetti
xsysinfo.SetDefaultVRAMBudget(b)
}
}
if settings.ArtifactDownloadConcurrency != nil {
if configurable, ok := o.ModelArtifactMaterializer.(interface{ SetDownloadConcurrency(int) }); ok {
configurable.SetDownloadConcurrency(o.ArtifactDownloadConcurrency)
}
}
}
7 changes: 7 additions & 0 deletions core/http/react-ui/e2e/settings-backend-logging.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ test.describe('Settings - Backend Logging', () => {
await expect(page.locator('text=Enable Backend Logging')).toBeVisible()
})

test('artifact download concurrency is configurable', async ({ page }) => {
const input = page.getByLabel('Artifact Download Concurrency')
await expect(input).toBeVisible()
await input.fill('4')
await expect(input).toHaveValue('4')
})

test('backend logging toggle can be toggled', async ({ page }) => {
// Find the checkbox associated with backend logging
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
Expand Down
3 changes: 3 additions & 0 deletions core/http/react-ui/src/pages/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,9 @@ export default function Settings() {
<SettingRow label="Default Context Size" description="Default context window size for models">
<input className="input" type="number" style={{ width: 120 }} value={settings.context_size ?? ''} onChange={(e) => update('context_size', parseInt(e.target.value) || 0)} placeholder="2048" />
</SettingRow>
<SettingRow label="Artifact Download Concurrency" description="Maximum artifact files downloaded at once. 1 downloads sequentially.">
<input aria-label="Artifact Download Concurrency" className="input" type="number" min="1" style={{ width: 120 }} value={settings.artifact_download_concurrency ?? 1} onChange={(e) => update('artifact_download_concurrency', Math.max(1, parseInt(e.target.value) || 1))} />
</SettingRow>
<SettingRow label="VRAM Budget" description="Cap VRAM used for model allocation on this node. Percentage (e.g. 80%) or absolute (e.g. 12GB). Empty uses all detected VRAM.">
<input className="input" type="text" style={{ width: 120 }} value={settings.vram_budget ?? ''} onChange={(e) => update('vram_budget', e.target.value)} placeholder="e.g. 80% or 12GB" />
</SettingRow>
Expand Down
3 changes: 2 additions & 1 deletion docs/content/features/runtime-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ You can configure these settings via the web UI or through environment variables

- **Threads**: Number of threads used for parallel computation (recommended: number of physical cores)
- **Context Size**: Default context size for models (default: `512`)
- **Artifact Download Concurrency**: Maximum number of artifact files downloaded at once. `1` downloads sequentially (default: `1`)
- **F16**: Enable GPU acceleration using 16-bit floating point
- **VRAM Budget**: Cap on VRAM used for model allocation (for example `80%` or `12GB`; empty means no cap). See [VRAM Management]({{%relref "advanced/vram-management" %}})

Expand Down Expand Up @@ -138,6 +139,7 @@ The `runtime_settings.json` file follows this structure:
"lru_eviction_retry_interval": "1s",
"threads": 8,
"context_size": 2048,
"artifact_download_concurrency": 4,
"f16": false,
"debug": false,
"cors": true,
Expand Down Expand Up @@ -221,4 +223,3 @@ If P2P is not starting:
2. Check network connectivity
3. Ensure the P2P network ID matches across nodes (if using federated mode)
4. Review logs for P2P-related errors

1 change: 1 addition & 0 deletions docs/content/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Complete reference for all LocalAI command-line interface (CLI) parameters and e
| `--localai-config-dir` | `BASEPATH/configuration` | Directory for dynamic loading of certain configuration files (currently runtime_settings.json, api_keys.json, and external_backends.json). See [Runtime Settings]({{%relref "features/runtime-settings" %}}) for web-based configuration. | `$LOCALAI_CONFIG_DIR` |
| `--localai-config-dir-poll-interval` | | Time duration to poll the LocalAI Config Dir if your system has broken fsnotify events (example: `1m`) | `$LOCALAI_CONFIG_DIR_POLL_INTERVAL` |
| `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` |
| `--artifact-download-concurrency` | `1` | How many files of a model artifact to download at once. `1` downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume. Whole files only — a single file is never split, so resume and per-file checksum verification are unaffected | `$LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY` |

## Backend Flags

Expand Down
73 changes: 62 additions & 11 deletions pkg/downloader/download_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package downloader

import (
"context"
"sync"

"github.com/mudler/xlog"
"golang.org/x/sync/errgroup"
)

// FileTask describes one download operation and an optional post-download
Expand All @@ -23,23 +25,72 @@ type FileTask struct {
// The helper centralizes the shared download path so callers only provide
// source/destination metadata and any post-download hook they need.
func DownloadFilesWithContext(ctx context.Context, tasks []FileTask, status func(string, string, string, float64), opts ...DownloadOption) error {
return DownloadFilesWithConcurrency(ctx, tasks, status, 1, opts...)
}

// DownloadFilesWithConcurrency runs up to concurrency downloads at once. A
// concurrency of one or less keeps the original sequential path, so callers that
// have not opted in are byte-for-byte unaffected: tasks still run in slice order
// and the first failure still returns before any later task starts.
//
// Only whole files run in parallel. A single file is never split, so the
// .partial resume machinery and the per-file SHA check in downloadTaskWithRetry
// keep working untouched.
//
// The status callback is serialized, because it belongs to the caller and the
// sequential path gave it an implicit guarantee of never being entered twice at
// once. AfterDownload is deliberately *not* serialized: it does the per-file
// verify-and-promote work that parallelism is meant to overlap, so hooks must be
// safe to run concurrently with each other.
func DownloadFilesWithConcurrency(ctx context.Context, tasks []FileTask, status func(string, string, string, float64), concurrency int, opts ...DownloadOption) error {
if concurrency < 1 {
concurrency = 1
}

if status != nil && concurrency > 1 {
var statusMutex sync.Mutex
unsynchronized := status
status = func(fileName, current, total string, percent float64) {
statusMutex.Lock()
defer statusMutex.Unlock()
unsynchronized(fileName, current, total, percent)
}
}

// errgroup.WithContext cancels the derived context on the first error, which
// is what stops in-flight transfers instead of letting them run to
// completion, and Wait reports that first error rather than the
// context.Canceled the siblings observe.
group, groupCtx := errgroup.WithContext(ctx)
group.SetLimit(concurrency)

for i := range tasks {
task := tasks[i]
if err := ctx.Err(); err != nil {
return err
if err := groupCtx.Err(); err != nil {
break
}
taskOpts := append([]DownloadOption{}, opts...)
taskOpts = append(taskOpts, task.Options...)
if err := downloadTaskWithRetry(ctx, task, status, taskOpts); err != nil {
return err
}
if task.AfterDownload != nil {
if err := task.AfterDownload(task.Destination); err != nil {
group.Go(func() error {
if err := groupCtx.Err(); err != nil {
return err
}
}
taskOpts := append([]DownloadOption{}, opts...)
taskOpts = append(taskOpts, task.Options...)
if err := downloadTaskWithRetry(groupCtx, task, status, taskOpts); err != nil {
return err
}
if task.AfterDownload != nil {
return task.AfterDownload(task.Destination)
}
return nil
})
}

if err := group.Wait(); err != nil {
return err
}
return nil
// A caller-cancelled context with no task in flight leaves the group clean,
// so report the cancellation the sequential loop would have reported.
return ctx.Err()
}

// downloadTaskWithRetry fetches one file, retrying transient failures. Without
Expand Down
Loading