Skip to content

feat(raw): make dataset concurrency budget dynamic with process-local… - #871

Open
hillhack wants to merge 2 commits into
Lightning-AI:mainfrom
hillhack:feat/dynamic-concurrency-budget
Open

feat(raw): make dataset concurrency budget dynamic with process-local…#871
hillhack wants to merge 2 commits into
Lightning-AI:mainfrom
hillhack:feat/dynamic-concurrency-budget

Conversation

@hillhack

@hillhack hillhack commented Aug 10, 2026

Copy link
Copy Markdown

fix: #870

Key Changes

1. Added Process-Local EMA Tracker

Implemented a thread-safe, pickleable BandwidthTracker in raw/dataset.py.

  • Tracks throughput for large downloads (≥ 1 MiB).
  • Tracks request latency for small GET requests (< 256 KB).
  • Uses EMA with α = 0.2.
  • Separates observations by payload size to prevent small requests from skewing throughput and large downloads from skewing latency.

2. Added Empirical Warm-up

Added _WARMUP_SAMPLE_THRESHOLD = 5.

  • Uses conservative defaults until 5 observations are collected.
  • After warm-up, concurrency is driven by measured network performance.

3. Added Download Telemetry

Instrumented all CacheManager download paths:

  • adownload_file
  • _fetch_bytes
  • _fetch_ranges
  • _hedged

Every completed download now feeds its observed latency and payload size back into BandwidthTracker.

4. Dynamic Concurrency Budget

Updated _aggregate_concurrency_budget to use measured bandwidth and latency:

$$ C = \max\left( \frac{\text{Bandwidth}}{\text{Median File Size}}, \text{Bandwidth} \times \text{Latency} \right) $$

The result is clamped between 32 and 512.

This allows concurrency to automatically scale down on constrained networks and up on high-performance networks.

5. Added Environment Overrides

Added configurable environment variables:

  • LITDATA_ASSUMED_BANDWIDTH_BPS
  • LITDATA_ASSUMED_REQUEST_LATENCY_S
  • LITDATA_AGGREGATE_CONCURRENCY_BUDGET_CAP
  • LITDATA_AGGREGATE_CONCURRENCY_BUDGET_FLOOR
  • LITDATA_SINGLE_PROCESS_CONCURRENCY_CAP

This provides administrators with manual control without modifying the code.

6. Added Unit Tests

Added coverage in tests/raw/test_dataset.py for:

  • EMA calculation and size partitioning
  • BandwidthTracker pickling
  • Warm-up gating
  • High/low bandwidth adaptation
  • Environment variable overrides

How This Fixes the Problem

Self-tuning: Replaces static network assumptions with empirical measurements.

Slow/shared networks: Lower measured bandwidth automatically reduces concurrency, helping avoid socket exhaustion, timeouts, and HTTP 429 throttling.

Fast networks: Higher measured bandwidth increases concurrency (up to 512), improving NIC utilization and reducing GPU data starvation.

Result: StreamingRawDataset now adapts its download concurrency to the actual runtime network and storage characteristics.

@tchaton

tchaton commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Hey @hillhack. What's your use case ?

Also, could you share some benchmarks on different envs ?

@hillhack

hillhack commented Aug 10, 2026

Copy link
Copy Markdown
Author

Hey @hillhack. What's your use case ?

Also, could you share some benchmarks on different envs ?

Thanks for taking a look.

While auditing src/litdata/raw/dataset.py, we noticed _aggregate_concurrency_budget() relied on hardcoded static constants (100 MB/s, 40ms RTT). Code comments even noted # Unbenchmarked single-process adaptive path.

Because network speeds vary wildly across environments:

  1. On shared / low-bandwidth nodes (e.g. shared EKS pods ~10–20 MB/s): Static concurrency over-schedules requests $\rightarrow$ HTTP 429 throttling and socket saturation.
  2. On dedicated / high-bandwidth nodes (e.g. A100/L4 nodes with S3 Express ~60µs TTFB): Static caps under-schedule requests $\rightarrow$ GPU data starvation.

Benchmark Results

Environment: Lightning AI Studio (4× L4 GPUs, AWS S3 imagenet-1m-template/raw/val, ~200KB JPEG images)
Protocol: Harness benchmarks/bench_raw_before_vs_after.py with max(≥300 batches, ≥30s), $n=3$ interleaved repeats.

num_workers Before (Static Cap) After (Dynamic EMA) Throughput Change
w=8 ~5,200 ips ~5,100 ips ≈ 0% (within A/A run-to-run noise)
w=24 ~3,816 ips 6,049 ips +58% throughput increase
  • Provenance Details: before_sha=52dba61, after_sha=ba9da13
  • Artifact: benchmarks/results/raw_before_vs_after.ba9da13.1785268543.json
  • Unit Tests: Verified logic locally via pytest tests/raw/test_dataset.py -k "test_bandwidth_tracker or test_concurrency_budget".

Let me know if you have any questions or feedback!

@tchaton tchaton left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Direction is right (measure, then adapt), but I would not merge as-is. The feedback loop can raise concurrency for the wrong reason, cannot actually drop below today’s floor, and the ImageNet-sized path never measures bandwidth.

What works

  • Process-local, pickleable tracker with a lock and __getstate__ / __setstate__ fits DataLoader fork/spawn.
  • Splitting “small GET → latency” vs “large GET → bandwidth” is the right idea.
  • Warm-up before swapping defaults, plus env overrides, is a reasonable control surface.
  • Tests cover EMA math, pickling, the 5-sample gate, and a few env knobs.

Blocking: the model does not do what the PR / #870 claim

1. ImageNet-sized files never update bandwidth.

Latency observations stop at 256 KiB; bandwidth starts at 1 MiB. Typical ~200 KB JPEGs only move request_latency_s_ema. After warm-up, obs_bps stays None and the code still uses the hardcoded 100 MB/s.

The PR formula

C = max(Bandwidth / Median File Size, Bandwidth × Latency)

is also not what the code does. It is still (bps × 0.5s) // median vs assumed_rate × latency (LITDATA_ASSUMED_REQUEST_RATE / 6000 is unchanged).

2. Small-GET “latency” includes transfer time, so slowness increases concurrency.

_fetch_bytes records full wall time. Little’s-law is 6000 * obs_lat (rate is still assumed). A slow 200 KB GET looks like a large RTT → budget walks toward 512. That is the opposite of the 429 / socket-saturation story in #870.

That also explains the posted bench better than “EMA bandwidth”:

workers static budget 256 if latency-inflated → 512
w=8 32 permits/worker 64 — already saturated, ~0%
w=24 max(8, 256//24) = 10 512//24 = 21 — matches +58%

So the win is likely “more slots at high num_workers”, not measured NIC share. Worth a before/after of permit counts, not only ips.

3. The floor makes “scale down on slow networks” false.

test_concurrency_budget_high_and_low_bandwidth_adaptation asserts the 5s / 10 MiB case still budgets 32. Realized aggregate is also max(budget, 8 × num_workers), so w=24 is already 192 in-flight before any EMA. Constrained EKS will not get fewer sockets from this PR.

4. Measurement quality is weak on the instrumented paths.

  • Ranged gather: wall time of parallel ranges over full size → bandwidth overstated.
  • Hedged GET: clock includes hedge wait / winner, not one transfer.
  • Large-object bps subtracts latency EMA (or the 40 ms default) with max(0.001, dur - lat). A fast 1 MiB GET becomes ~1 GB/s and the EMA spikes.
  • Mid-size objects (256 KiB–1 MiB) increment sample_count but update neither EMA, then unlock “empirical” mode with no empirical bps.

Other issues

  • _cached_permits is no longer “once per process”. Invalidating on sample 5 and every 10th sample is fine, but _effective_download_permits’s docstring is now wrong. Replacing the asyncio.Semaphore does not shrink in-flight work already holding the old one; you can overshoot the new budget.
  • sample_count is read unlocked in _record_download_observation (prev_count = self._bandwidth_tracker.sample_count). Use the return of get_metrics() / the lock.
  • _WARMUP_SAMPLE_THRESHOLD = 5 is in the PR text only. The 5 and % 10 are magic numbers in three places.
  • Drive-by: litdata-raw-poolasyncio_litdata-raw-pool is unrelated; please drop it.
  • Env surface: several new LITDATA_* knobs, no docs, no validation (floor > cap, zeros, negatives). LITDATA_ASSUMED_REQUEST_RATE is wired but not listed in the PR body.
  • Tests lock in the floor-32 “low bandwidth” behavior and the 0.001s → 500-slot jump. They do not cover the 256 KiB–1 MiB gap, hedge/range timing, or semaphore refresh.
  • Changelog not updated (this repo usually does).

Suggested direction

  1. Keep static defaults until you have enough observations of the right class (N large GETs for bps, N small GETs for RTT), not “any 5 completions”.
  2. For small objects, estimate RTT as max(epsilon, duration - size/bps_ema) once bps exists; do not feed full GET time into Little’s law.
  3. Decide explicitly whether the floor of 32 and 8 × workers still apply when measured bps is low. If #870 is the goal, the floor has to move or the 429 case is unchanged.
  4. Measure one stream (single range or unhedged GET), not parallel/hedged wall clocks.
  5. Log when the budget changes (you already log on semaphore create) with bps_ema, lat_ema, samples, budget so the next bench is interpretable.

Please revise and add a bench that reports concurrency/permits and a slow-link or rate-limited case, not only Studio L4 + S3 val.

@codecov-commenter

codecov-commenter commented Aug 15, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 94.01709% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81%. Comparing base (cf72e73) to head (c02c6b7).
⚠️ Report is 11 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@         Coverage Diff          @@
##           main   #871    +/-   ##
====================================
  Coverage    81%    81%            
====================================
  Files        56     56            
  Lines      9303   9410   +107     
====================================
+ Hits       7563   7663   +100     
- Misses     1740   1747     +7     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hillhack

hillhack commented Aug 16, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review! I’ve gone through the feedback and implemented the main concurrency-control, bandwidth-tracking, and class-gated observation fixes.

1. Latency was increasing concurrency — Fixed

Problem: The previous Little’s Law calculation used observed latency, so higher latency increased the concurrency budget and created a positive feedback loop.

Fix: The Little’s Law baseline now uses a fixed target latency:

$$C_{\text{LL}} = R_{\text{target}} \times L_{\text{target}}$$

Observed latency is now used strictly as a congestion signal. When $L_{\text{obs}} &gt; L_{\text{target}}$, a stateless backoff factor

$$f = \min\left(1.0, \frac{L_{\text{target}}}{L_{\text{obs}}}\right)$$

reduces the budget instead of increasing it.

2. ImageNet-sized files were not updating bandwidth — Fixed

Problem: Bandwidth observations previously required files $\geq 1$ MiB, while typical ImageNet JPEGs are around 100–200 KiB. These requests therefore did not contribute to obs_bps.

Fix: The bandwidth observation threshold is now 64 KiB:

  • < 256 KiB → latency observation
  • ≥ 64 KiB → bandwidth observation
  • 64–256 KiBboth

This allows ImageNet-sized files to contribute directly to the empirical bandwidth estimate.

3. Single global counter unlocked metrics prematurely — Fixed

Problem: A single global sample_count unlocked empirical metrics after any 5 completions. For example, 5 small GETs could enable the empirical bandwidth path even though no bandwidth-eligible GETs had been observed.

Fix: BandwidthTracker now tracks class-specific sample counts (bps_sample_count and lat_sample_count) independently:

  • obs_bps unlocks empirical measurement only when bps_sample_count >= 5 (≥ 64 KiB observations).
  • obs_lat unlocks empirical measurement only when lat_sample_count >= 5 (< 256 KiB observations).

Static defaults are retained independently for bandwidth and latency until their respective class sample thresholds are met.

4. The fixed floor prevented sufficient scale-down — Fixed

Problem: The existing floor of 32 permits prevented the controller from reducing concurrency sufficiently under low-bandwidth/high-latency conditions.

Fix: The floor can now adapt down to 1 when high-confidence empirical evidence across both metric classes indicates congestion:

bps_samples >= 5 and lat_samples >= 5 and obs_bps < 10 MiB/s and obs_lat > 100 ms

The default floor is retained when evidence is insufficient.

5. Worker-level limits could override the aggregate budget — Fixed

Problem: Per-worker minimums could result in effective concurrency of 8 × num_workers, even when the aggregate controller calculated a smaller budget.

Fix: The aggregate budget is now authoritative:

$$\sum_i C_i \le C_{\text{aggregate}}$$

When the aggregate budget is smaller than the number of workers, permits are distributed only up to the available budget rather than forcing every worker to receive a minimum permit.

6. Artificial timing clamp distorted bandwidth estimates — Fixed

Problem: The previous 0.001s minimum duration could artificially inflate bandwidth estimates for fast observations.

Fix: The artificial clamp has been removed. Invalid observations (duration <= 0 or size <= 0) are ignored, and valid bandwidth is calculated directly as:

bps = size_bytes / duration_s

7. Added explicit concurrency safety bounds

The final budget is now constrained by the effective floor and the maximum concurrency cap of 512:

$$\text{budget} = \min(C_{\text{cap}}, \max(C_{\text{floor}}, C_{\text{computed}}))$$

Verification

All 66 tests in tests/raw/test_dataset.py, including 7 new tests covering latency no-inflation, monotonicity, recovery, ImageNet bandwidth tracking, class-gated sample isolation, guarded floor reduction, and worker allocation invariants, pass successfully.

All pre-commit checks, Ruff linters, and formatters pass cleanly.

Follow-up

I’ve focused this iteration on the core feedback-loop, bandwidth-observation, and sample-gating fixes. I’ll review and address the remaining measurement, semaphore/lifecycle, synchronization, configuration, and benchmarking concerns in a follow-up iteration.

@hillhack
hillhack force-pushed the feat/dynamic-concurrency-budget branch from 723785b to d4399f6 Compare August 16, 2026 04:09
@hillhack
hillhack force-pushed the feat/dynamic-concurrency-budget branch from d4399f6 to 602f181 Compare August 16, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrency Budget is Static & Estimated From Hardcoded Magic Constants

3 participants