Skip to content

Commit ab52813

Browse files
feat(modelartifacts): support bounded parallel Hugging Face file downloads (#11162)
* feat(modelartifacts): support bounded parallel Hugging Face file downloads Closes #11114. Snapshot materialization fetched every file through the sequential executor in DownloadFilesWithContext, so a repository split into many shards spent most of its wall clock in per-file request latency rather than moving bytes. Add DownloadFilesWithConcurrency, an errgroup with SetLimit, and keep DownloadFilesWithContext as a wrapper that passes a limit of 1. That leaves the two non-artifact callers (core/gallery and the model config loader) on exactly the path they had: 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 are untouched. Two details the parallel path forced: - completedBytes becomes an atomic.Int64. Several AfterDownload hooks add to it while other files' progress callbacks read it; without this the race detector reports three races on the new specs. - The caller's status callback is serialized. The sequential path gave it an implicit guarantee of never being entered twice at once, and it belongs to the caller, so the executor keeps that promise rather than pushing locking onto every caller. AfterDownload is deliberately not serialized -- it does the verify-and-promote work that parallelism exists to overlap. Manifest order needed no work: each hook already writes its own manifest.Files slot by snapshot index, so entries stay in snapshot order whatever the completion order. A spec now pins that. The default is 1, unchanged behaviour. A shared models volume is often the bottleneck rather than the link, so raising it is a deployment decision; --artifact-download-concurrency and LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY expose it on both `run` and `models install`. Not done here, per the issue: no chunk-level parallelism within a single file, and no throughput measurements across concurrency 1/2/4/8 -- that needs a representative sharded repo and a real link. Assisted-by: Claude:claude-opus-5 go-test gofmt Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> * feat(modelartifacts): expose download concurrency in settings Follow-up to review feedback on #11162: - The CLI flag and docs no longer describe the limit as Hugging Face specific. It applies to any artifact source, as @mudler pointed out. - artifact_download_concurrency is now a persisted runtime setting and is editable from the WebUI, so it can be changed without a restart. The manager's limit becomes an atomic.Int64 behind SetDownloadConcurrency, because a live runtime setting can be updated while a materialization is already in flight. Injected materializers stay compatible through an optional setter interface, so a manager that does not implement it is simply left alone. Verified before taking this on: go build, go vet and go test -race all pass for pkg/modelartifacts, pkg/downloader and core/config. The React UI builds with vite, artifact_download_concurrency is present in the built Settings chunk, and eslint reports the same 8 pre-existing warnings on Settings.jsx as it does without the change. Implementation contributed by localai-org-maint-bot on the review thread; reviewed, verified and signed off by me. Assisted-by: Codex:gpt-5 Assisted-by: Claude:claude-opus-5 go-test vite eslint Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> --------- Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
1 parent 5ff25d9 commit ab52813

16 files changed

Lines changed: 524 additions & 38 deletions

core/cli/models.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type ModelsCMDFlags struct {
2828
Color string `env:"COLOR" hidden:""`
2929
NoColor string `env:"NO_COLOR" hidden:""`
3030
HFToken string `env:"HF_TOKEN" hidden:""`
31+
32+
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"`
3133
}
3234

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

8890
artifactMaterializer := modelartifacts.NewDefaultManager(
8991
modelartifacts.WithHuggingFaceToken(mi.HFToken),
92+
modelartifacts.WithDownloadConcurrency(mi.ArtifactDownloadConcurrency),
9093
)
9194
galleryService := galleryop.NewGalleryService(&config.ApplicationConfig{
9295
SystemState: systemState,

core/cli/run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type RunCMD struct {
4141
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
4242
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"`
4343
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
44+
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"`
4445
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"`
4546
UploadPath string `env:"LOCALAI_UPLOAD_PATH,UPLOAD_PATH" type:"path" default:"${uploadpath}" help:"Path to store uploads from files api" group:"storage"`
4647
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"`
@@ -278,8 +279,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
278279

279280
opts := []config.AppOption{
280281
config.WithContext(context.Background()),
282+
config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency),
281283
config.WithModelArtifactMaterializer(modelartifacts.NewDefaultManager(
282284
modelartifacts.WithHuggingFaceToken(r.HFToken),
285+
modelartifacts.WithDownloadConcurrency(r.ArtifactDownloadConcurrency),
283286
)),
284287
config.WithModelPreloadDisplay(r.Color, r.NoColor != ""),
285288
config.WithConfigFile(r.ModelsConfigFile),

core/config/application_artifact_materializer_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ func (*applicationArtifactMaterializer) Ensure(context.Context, string, modelart
1616
return modelartifacts.Result{}, nil
1717
}
1818

19+
type configurableApplicationArtifactMaterializer struct {
20+
applicationArtifactMaterializer
21+
concurrency int
22+
}
23+
24+
func (m *configurableApplicationArtifactMaterializer) SetDownloadConcurrency(concurrency int) {
25+
m.concurrency = concurrency
26+
}
27+
1928
var _ = Describe("ApplicationConfig model artifact materializer", func() {
2029
It("provides a default materializer", func() {
2130
Expect(NewApplicationConfig().ModelArtifactMaterializer).NotTo(BeNil())
@@ -31,4 +40,15 @@ var _ = Describe("ApplicationConfig model artifact materializer", func() {
3140
Expect(field.Tag.Get("json")).To(Equal("-"))
3241
Expect(field.Tag.Get("yaml")).To(Equal("-"))
3342
})
43+
44+
It("applies runtime download concurrency to configurable materializers", func() {
45+
materializer := &configurableApplicationArtifactMaterializer{}
46+
appConfig := NewApplicationConfig(WithModelArtifactMaterializer(materializer))
47+
concurrency := 4
48+
49+
appConfig.ApplyRuntimeSettings(&RuntimeSettings{ArtifactDownloadConcurrency: &concurrency})
50+
51+
Expect(appConfig.ArtifactDownloadConcurrency).To(Equal(4))
52+
Expect(materializer.concurrency).To(Equal(4))
53+
})
3454
})

core/config/application_config.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type ApplicationConfig struct {
3535
// network interfaces (e.g. eth0), filtering out docker0/veth noise.
3636
WebRTCICEInterfaces []string
3737
UploadLimitMB, Threads, ContextSize int
38+
ArtifactDownloadConcurrency int
3839
F16 bool
3940
Debug bool
4041
EnableTracing bool
@@ -58,12 +59,12 @@ type ApplicationConfig struct {
5859
// gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
5960
HTTPCompressionMinLength int
6061
PreloadJSONModels string
61-
PreloadModelsFromPath string
62-
CORSAllowOrigins string
63-
ApiKeys []string
64-
P2PToken string
65-
P2PNetworkID string
66-
Federated bool
62+
PreloadModelsFromPath string
63+
CORSAllowOrigins string
64+
ApiKeys []string
65+
P2PToken string
66+
P2PNetworkID string
67+
Federated bool
6768

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

690+
func WithArtifactDownloadConcurrency(concurrency int) AppOption {
691+
return func(o *ApplicationConfig) {
692+
if concurrency < 1 {
693+
concurrency = modelartifacts.DefaultDownloadConcurrency
694+
}
695+
o.ArtifactDownloadConcurrency = concurrency
696+
}
697+
}
698+
688699
// WithModelPreloadDisplay configures terminal rendering for model preload output.
689700
func WithModelPreloadDisplay(renderMode string, disableColor bool) AppOption {
690701
return func(o *ApplicationConfig) {
@@ -1190,6 +1201,11 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
11901201
xsysinfo.SetDefaultVRAMBudget(b)
11911202
}
11921203
}
1204+
if settings.ArtifactDownloadConcurrency != nil {
1205+
if configurable, ok := o.ModelArtifactMaterializer.(interface{ SetDownloadConcurrency(int) }); ok {
1206+
configurable.SetDownloadConcurrency(o.ArtifactDownloadConcurrency)
1207+
}
1208+
}
11931209
// Note: ApiKeys need env-merge handling (MergeAPIKeys) - done by the
11941210
// caller, because the env-provided keys live on the startup config.
11951211
return requireRestart

core/config/runtime_settings.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,16 @@ type RuntimeSettings struct {
3333
LRUEvictionRetryInterval *string `json:"lru_eviction_retry_interval,omitempty"` // Interval between retries when waiting for busy models (e.g., 1s, 2s) (default: 1s)
3434

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

4647
// Security/CORS settings
4748
CORS *bool `json:"cors,omitempty"`

core/config/runtime_settings_registry.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,15 @@ var runtimeSettingsFields = []fieldSpec{
227227
func(s *RuntimeSettings) **int { return &s.ContextSize },
228228
func(o *ApplicationConfig) int { return o.ContextSize },
229229
func(o *ApplicationConfig, v int) { o.ContextSize = v }),
230+
field("artifact_download_concurrency",
231+
func(s *RuntimeSettings) **int { return &s.ArtifactDownloadConcurrency },
232+
func(o *ApplicationConfig) int { return o.ArtifactDownloadConcurrency },
233+
func(o *ApplicationConfig, v int) {
234+
if v < 1 {
235+
v = 1
236+
}
237+
o.ArtifactDownloadConcurrency = v
238+
}),
230239
// VRAM budget: the cap string ("80%"/"12GB"/"" = uncapped). The live
231240
// side effect (xsysinfo.SetDefaultVRAMBudget) is post-processing in the
232241
// apply loop, not here - the row only owns the config member, matching

core/config/runtime_settings_registry_internal_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ var _ = Describe("runtime settings registry", func() {
7171
src.LRUEvictionRetryInterval = 3 * time.Second
7272
src.Threads = 7
7373
src.ContextSize = 8192
74+
src.ArtifactDownloadConcurrency = 6
7475
src.VRAMBudget = "12GiB"
7576
src.F16 = true
7677
src.Debug = true

core/config/runtime_settings_startup.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettingsAtStartup(settings *RuntimeSetti
9898
xsysinfo.SetDefaultVRAMBudget(b)
9999
}
100100
}
101+
if settings.ArtifactDownloadConcurrency != nil {
102+
if configurable, ok := o.ModelArtifactMaterializer.(interface{ SetDownloadConcurrency(int) }); ok {
103+
configurable.SetDownloadConcurrency(o.ArtifactDownloadConcurrency)
104+
}
105+
}
101106
}

core/http/react-ui/e2e/settings-backend-logging.spec.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ test.describe('Settings - Backend Logging', () => {
1111
await expect(page.locator('text=Enable Backend Logging')).toBeVisible()
1212
})
1313

14+
test('artifact download concurrency is configurable', async ({ page }) => {
15+
const input = page.getByLabel('Artifact Download Concurrency')
16+
await expect(input).toBeVisible()
17+
await input.fill('4')
18+
await expect(input).toHaveValue('4')
19+
})
20+
1421
test('backend logging toggle can be toggled', async ({ page }) => {
1522
// Find the checkbox associated with backend logging
1623
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })

core/http/react-ui/src/pages/Settings.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,9 @@ export default function Settings() {
394394
<SettingRow label="Default Context Size" description="Default context window size for models">
395395
<input className="input col-w-120" type="number" value={settings.context_size ?? ''} onChange={(e) => update('context_size', parseInt(e.target.value) || 0)} placeholder="2048" />
396396
</SettingRow>
397+
<SettingRow label="Artifact Download Concurrency" description="Maximum artifact files downloaded at once. 1 downloads sequentially.">
398+
<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))} />
399+
</SettingRow>
397400
<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.">
398401
<input className="input col-w-120" type="text" value={settings.vram_budget ?? ''} onChange={(e) => update('vram_budget', e.target.value)} placeholder="e.g. 80% or 12GB" />
399402
</SettingRow>

0 commit comments

Comments
 (0)