---
Cargo.lock | 2 +
Cargo.toml | 6 +-
src/object_store.rs | 45 +++++++----
src/reqwest_client.rs | 20 +++--
src/s3_ops.rs | 173 ++++++++++++++++++++++++++++--------------
src/s3_utils.rs | 100 +++++++++++++++---------
6 files changed, 232 insertions(+), 114 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 9c2fe74..eded635 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5208,6 +5208,7 @@ dependencies = [
"hdf5-metno-sys",
"hdrhistogram",
"http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
"humantime",
"hwlocality",
@@ -5240,6 +5241,7 @@ dependencies = [
"rustls-native-certs",
"serde",
"serde_json",
+ "sync_wrapper",
"tempfile",
"tfrecord",
"thiserror 1.0.69",
diff --git a/Cargo.toml b/Cargo.toml
index c25f260..0017583 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -57,10 +57,12 @@ tokio-stream = { version = "^0.1", features = ["sync"] }
tokio-util = "^0.7" # For CancellationToken and other utilities
async-stream = "^0.3" # For stream! macro in list_stream implementations
# HTTP stack
-reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] }
+reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "stream"] }
http = "^1" # Re-exported by reqwest, but needed as direct dep for response building
http-body-util = "^0.1" # BodyExt::collect() for SdkBody async collection
-aws-smithy-types = "^1" # SdkBody type used by reqwest_client.rs bridge
+http-body = "^1" # Frame type for the streaming response bridge (issue 9.2)
+sync_wrapper = { version = "^1", features = ["futures"] } # SyncStream: reqwest byte streams are Send but not Sync; SdkBody::from_body_1_x requires both
+aws-smithy-types = { version = "^1", features = ["http-body-1-x"] } # SdkBody + from_body_1_x for streaming responses
aws-smithy-http-client = { version = "^1", features = ["rustls-aws-lc"] }
aws-smithy-runtime-api = "^1" # ← Added for HTTP client types
diff --git a/src/object_store.rs b/src/object_store.rs
index 892b5bb..7c1d02a 100644
--- a/src/object_store.rs
+++ b/src/object_store.rs
@@ -1180,19 +1180,38 @@ impl ObjectStore for S3ObjectStore {
if let Some(client) = &self.client {
let (bucket, key) = parse_s3_uri(uri)?;
- let resp = client
- .get_object()
- .bucket(&bucket)
- .key(&key)
- .send()
- .await
- .sdk_context(format!("S3 GET failed for '{}'", uri))?;
- let data = resp
- .body
- .collect()
- .await
- .context("Failed to read S3 GET response body")?;
- return Ok(data.into_bytes());
+ // Bounded body-read retry: with the streaming connector
+ // (issue 9.2) the SDK retry no longer covers mid-body transport
+ // errors, so re-issue the whole GET on a body-read failure.
+ let max_attempts = crate::constants::max_retry_attempts().max(1);
+ let mut attempt: u32 = 0;
+ loop {
+ attempt += 1;
+ let resp = client
+ .get_object()
+ .bucket(&bucket)
+ .key(&key)
+ .send()
+ .await
+ .sdk_context(format!("S3 GET failed for '{}'", uri))?;
+ match resp.body.collect().await {
+ Ok(data) => return Ok(data.into_bytes()),
+ Err(e) if attempt < max_attempts => {
+ tracing::warn!(
+ "S3 GET '{}' body read failed (attempt {}/{}): {}; retrying",
+ uri, attempt, max_attempts, e
+ );
+ tokio::time::sleep(std::time::Duration::from_millis(
+ 100 * attempt as u64,
+ ))
+ .await;
+ }
+ Err(e) => {
+ return Err(anyhow::Error::new(e)
+ .context("Failed to read S3 GET response body"));
+ }
+ }
+ }
}
// Range optimization is ENABLED by default (v0.9.60+).
diff --git a/src/reqwest_client.rs b/src/reqwest_client.rs
index 07e784c..ef4ed0f 100644
--- a/src/reqwest_client.rs
+++ b/src/reqwest_client.rs
@@ -315,17 +315,25 @@ impl HttpConnector for ReqwestHttpConnector {
tracing::debug!("HTTP protocol: {:?}", version);
}
- let resp_body = resp
- .bytes()
- .await
- .map_err(|e| ConnectorError::io(e.into()))?;
+ // ── Build Smithy response (STREAMING body — issue 9.2) ───────
+ // Previously this did `resp.bytes().await`, fully buffering the
+ // body inside the connector: the SDK caller saw byte one only
+ // after the LAST byte arrived (no receive/consume overlap, an
+ // extra whole-body buffer per GET, and `first_byte_time` metrics
+ // that actually measured last-byte). Hand the SDK the live byte
+ // stream instead; consumers (collect(), the range engine, the
+ // loader) pull fragments as they arrive.
+ use futures_util::TryStreamExt as _;
+ let frame_stream = resp.bytes_stream().map_ok(http_body::Frame::data);
+ let stream_body = http_body_util::StreamBody::new(
+ sync_wrapper::SyncStream::new(frame_stream),
+ );
- // ── Build Smithy response ─────────────────────────────────────
let mut response = HttpResponse::new(
http::StatusCode::from_u16(status)
.map_err(|e| ConnectorError::other(e.into(), None))?
.into(),
- SdkBody::from(resp_body),
+ SdkBody::from_body_1_x(stream_body),
);
for (name, value) in &headers {
diff --git a/src/s3_ops.rs b/src/s3_ops.rs
index cebf926..6e06428 100644
--- a/src/s3_ops.rs
+++ b/src/s3_ops.rs
@@ -88,36 +88,69 @@ impl S3Ops {
start_time: SystemTime::now(),
};
- let result = self
- .client
- .get_object()
- .bucket(bucket)
- .key(key)
- .send()
- .await;
- let first_byte_time = SystemTime::now();
+ // Streaming bodies (issue 9.2): `send()` resolves at headers, so the
+ // SDK retry policy no longer covers transport failures during body
+ // transfer. Compensate with a bounded whole-request retry on
+ // body-read errors (budget: S3DLIO_MAX_RETRY_ATTEMPTS). The retry
+ // arm does not consume `ctx`; every exit arm returns, so `ctx` is
+ // moved at most once.
+ let max_attempts = crate::constants::max_retry_attempts().max(1);
+ let mut attempt: u32 = 0;
+ loop {
+ attempt += 1;
+ let result = self
+ .client
+ .get_object()
+ .bucket(bucket)
+ .key(key)
+ .send()
+ .await;
+ let first_byte_time = SystemTime::now();
- match result {
- Ok(output) => {
- let body = output.body.collect().await?.into_bytes();
- let bytes = body.len() as u64;
- self.log_op(ctx, &Ok::<(), &str>(()), bytes, Some(first_byte_time));
- // Zero-copy: return Bytes directly instead of converting to Vec
- Ok(body)
- }
- Err(e) => {
- // format_sdk_error preserves the connector chain (I/O, timeout,
- // TLS, DNS, refused) plus a hint string. Without this the bare
- // `SdkError::DispatchFailure` Display impl is the literal text
- // "dispatch failure" — the opaque message reported in
- // mlcommons/storage#506. Log uses the same detail so the
- // s3dlio op-log file matches what the caller sees.
- let detail = format_sdk_error(&e);
- self.log_op(ctx, &Err::<(), _>(detail), 0, None);
- Err(sdk_anyhow(
- format!("S3 GET for s3://{}/{} failed", bucket, key),
- e,
- ))
+ match result {
+ Ok(output) => match output.body.collect().await {
+ Ok(body) => {
+ let body = body.into_bytes();
+ let bytes = body.len() as u64;
+ self.log_op(ctx, &Ok::<(), &str>(()), bytes, Some(first_byte_time));
+ // Zero-copy: return Bytes directly instead of converting to Vec
+ return Ok(body);
+ }
+ Err(e) if attempt < max_attempts => {
+ tracing::warn!(
+ "S3 GET s3://{}/{} body read failed (attempt {}/{}): {}; retrying",
+ bucket, key, attempt, max_attempts, e
+ );
+ tokio::time::sleep(std::time::Duration::from_millis(
+ 100 * attempt as u64,
+ ))
+ .await;
+ }
+ Err(e) => {
+ // Previously a body-read failure `?`-escaped before
+ // log_op ran; now the op-log records it too.
+ let detail = format!("body read failed: {e}");
+ self.log_op(ctx, &Err::<(), _>(detail), 0, Some(first_byte_time));
+ return Err(anyhow::Error::new(e).context(format!(
+ "S3 GET for s3://{}/{} failed reading response body",
+ bucket, key
+ )));
+ }
+ },
+ Err(e) => {
+ // format_sdk_error preserves the connector chain (I/O, timeout,
+ // TLS, DNS, refused) plus a hint string. Without this the bare
+ // `SdkError::DispatchFailure` Display impl is the literal text
+ // "dispatch failure" — the opaque message reported in
+ // mlcommons/storage#506. Log uses the same detail so the
+ // s3dlio op-log file matches what the caller sees.
+ let detail = format_sdk_error(&e);
+ self.log_op(ctx, &Err::<(), _>(detail), 0, None);
+ return Err(sdk_anyhow(
+ format!("S3 GET for s3://{}/{} failed", bucket, key),
+ e,
+ ));
+ }
}
}
}
@@ -138,35 +171,61 @@ impl S3Ops {
};
let range = format!("bytes={}-{}", start, end);
- let result = self
- .client
- .get_object()
- .bucket(bucket)
- .key(key)
- .range(range)
- .send()
- .await;
- let first_byte_time = SystemTime::now();
+ // Bounded body-read retry — see the GET path above (issue 9.2).
+ let max_attempts = crate::constants::max_retry_attempts().max(1);
+ let mut attempt: u32 = 0;
+ loop {
+ attempt += 1;
+ let result = self
+ .client
+ .get_object()
+ .bucket(bucket)
+ .key(key)
+ .range(&range)
+ .send()
+ .await;
+ let first_byte_time = SystemTime::now();
- match result {
- Ok(output) => {
- let body = output.body.collect().await?.into_bytes();
- let bytes = body.len() as u64;
- self.log_op(ctx, &Ok::<(), &str>(()), bytes, Some(first_byte_time));
- // Zero-copy: return Bytes directly
- Ok(body)
- }
- Err(e) => {
- // See note on the GET path above re: format_sdk_error and #506.
- let detail = format_sdk_error(&e);
- self.log_op(ctx, &Err::<(), _>(detail), 0, None);
- Err(sdk_anyhow(
- format!(
- "S3 GET_RANGE for s3://{}/{} bytes={}-{} failed",
- bucket, key, start, end
- ),
- e,
- ))
+ match result {
+ Ok(output) => match output.body.collect().await {
+ Ok(body) => {
+ let body = body.into_bytes();
+ let bytes = body.len() as u64;
+ self.log_op(ctx, &Ok::<(), &str>(()), bytes, Some(first_byte_time));
+ // Zero-copy: return Bytes directly
+ return Ok(body);
+ }
+ Err(e) if attempt < max_attempts => {
+ tracing::warn!(
+ "S3 GET_RANGE s3://{}/{} bytes={}-{} body read failed (attempt {}/{}): {}; retrying",
+ bucket, key, start, end, attempt, max_attempts, e
+ );
+ tokio::time::sleep(std::time::Duration::from_millis(
+ 100 * attempt as u64,
+ ))
+ .await;
+ }
+ Err(e) => {
+ let detail = format!("body read failed: {e}");
+ self.log_op(ctx, &Err::<(), _>(detail), 0, Some(first_byte_time));
+ return Err(anyhow::Error::new(e).context(format!(
+ "S3 GET_RANGE for s3://{}/{} bytes={}-{} failed reading response body",
+ bucket, key, start, end
+ )));
+ }
+ },
+ Err(e) => {
+ // See note on the GET path above re: format_sdk_error and #506.
+ let detail = format_sdk_error(&e);
+ self.log_op(ctx, &Err::<(), _>(detail), 0, None);
+ return Err(sdk_anyhow(
+ format!(
+ "S3 GET_RANGE for s3://{}/{} bytes={}-{} failed",
+ bucket, key, start, end
+ ),
+ e,
+ ));
+ }
}
}
}
diff --git a/src/s3_utils.rs b/src/s3_utils.rs
index d6460ca..82546be 100644
--- a/src/s3_utils.rs
+++ b/src/s3_utils.rs
@@ -1208,44 +1208,72 @@ async fn concurrent_range_get_impl(
.map_err(|e| anyhow::anyhow!("Semaphore error: {}", e))?;
let range_header = format!("bytes={}-{}", range_start, range_end - 1);
- let resp = client
- .get_object()
- .bucket(&bucket)
- .key(key.trim_start_matches('/'))
- .range(&range_header)
- .send()
- .await
- .sdk_context(format!(
- "concurrent range GET for s3://{}/{} bytes={}-{} failed",
- bucket,
- key,
- range_start,
- range_end - 1
- ))?;
-
- // Stream fragments straight into this chunk's segment —
- // no intermediate whole-chunk buffer.
- let mut body = resp.body;
- let mut written = 0usize;
- while let Some(frag) = body.try_next().await.context("read chunk body")? {
- let end = written + frag.len();
- if end > seg.len() {
- anyhow::bail!(
- "range GET s3://{}/{} bytes={}-{}: server returned more than requested ({} > {})",
- bucket, key, range_start, range_end - 1, end, seg.len()
- );
+
+ // With streaming bodies (issue 9.2) `send()` resolves at response
+ // headers, so the SDK's transient-error retry no longer covers
+ // failures DURING body transfer. Compensate with a bounded
+ // whole-chunk retry: the segment never escapes until fully
+ // written, so re-issuing the ranged GET and rewriting from
+ // offset 0 is idempotent. Budget matches the SDK request-phase
+ // policy (S3DLIO_MAX_RETRY_ATTEMPTS, default 3).
+ let max_attempts = crate::constants::max_retry_attempts().max(1);
+ let mut attempt: u32 = 0;
+ loop {
+ attempt += 1;
+ let outcome: anyhow::Result<()> = async {
+ let resp = client
+ .get_object()
+ .bucket(&bucket)
+ .key(key.trim_start_matches('/'))
+ .range(&range_header)
+ .send()
+ .await
+ .sdk_context(format!(
+ "concurrent range GET for s3://{}/{} bytes={}-{} failed",
+ bucket,
+ key,
+ range_start,
+ range_end - 1
+ ))?;
+
+ // Stream fragments straight into this chunk's segment —
+ // no intermediate whole-chunk buffer.
+ let mut body = resp.body;
+ let mut written = 0usize;
+ while let Some(frag) = body.try_next().await.context("read chunk body")? {
+ let end = written + frag.len();
+ if end > seg.len() {
+ anyhow::bail!(
+ "range GET s3://{}/{} bytes={}-{}: server returned more than requested ({} > {})",
+ bucket, key, range_start, range_end - 1, end, seg.len()
+ );
+ }
+ seg[written..end].copy_from_slice(&frag);
+ written = end;
+ }
+ if written != seg.len() {
+ anyhow::bail!(
+ "range GET s3://{}/{} bytes={}-{}: short read ({} of {} bytes)",
+ bucket, key, range_start, range_end - 1, written, seg.len()
+ );
+ }
+ Ok(())
+ }
+ .await;
+
+ match outcome {
+ Ok(()) => return Ok::<(usize, BytesMut), anyhow::Error>((idx, seg)),
+ Err(e) if attempt < max_attempts => {
+ tracing::warn!(
+ "range chunk s3://{}/{} bytes={}-{} attempt {}/{} failed: {:#}; retrying",
+ bucket, key, range_start, range_end - 1, attempt, max_attempts, e
+ );
+ tokio::time::sleep(std::time::Duration::from_millis(100 * attempt as u64))
+ .await;
+ }
+ Err(e) => return Err(e),
}
- seg[written..end].copy_from_slice(&frag);
- written = end;
- }
- if written != seg.len() {
- anyhow::bail!(
- "range GET s3://{}/{} bytes={}-{}: short read ({} of {} bytes)",
- bucket, key, range_start, range_end - 1, written, seg.len()
- );
}
-
- Ok::<(usize, BytesMut), anyhow::Error>((idx, seg))
});
}
--
2.43.0
The issue
MLPerf Storage unet3d on S3 is client-bound, not storage-bound. On a 32-vCPU / 128 GB client node against an endpoint that serves ~10 GB/s to that single node (independently proven by warp), stock s3dlio 0.9.106 delivers:
Root causes (both in s3dlio, verified by code audit + isolated A/B):
buffer_unorderedinside a single spawned producer) — all requestdriving and body accumulation share ~one core.
Reproduction environment
Nodes: 32 vCPU, 128 GB RAM (the datagen sizing derives from this).
Stack: unmodified mlpstorage tree + s3dlio 0.9.106 venv; Credentials/endpoint in
.env.01_datagen.sh
02_read_s3dlio.sh
03_read_mlpstorage.sh
04_read_s5cmd.sh
05_read_warp.sh
Scripts 02–05 read the identical objects (numbers above are measured on
this environment). The chain of evidence:
stream count, and sub-linearly — at the benchmark's own shape (8 processes x prefetch 4) per-process throughput degrades in. my caese to ~700 MB/s and the node totals ~5GB/s. That, not 8 x 1.1, is the benchmark's node plateau; one emulated B200 accelerator demands 6,046 MB/s, so the stock client cannot pass regardless of worker count.
round-robin, so one worker's stall idles the rotation — which is why the benchmark (03) realizes only ~4GB/s of the ~5GB/s available.
Why it likely went unnoticed so far: the defect only binds when the storage outruns ~1 GB/s per client process AND the emulated accelerator
demands more. Slower endpoints hide it (storage is the bottleneck), and older accelerator profiles hide it (an a100/h100 unet3d profile demands a fraction of the B200's 6,046 MB/s, so 8 walled workers still keep up). B200-class demand against competent object storage — the target regime of MLPerf Storage v3 — is exactly where it becomes the limiting factor, for every submitter.
Proposed fixes (one per issue, apply in order)
With patches 0001+0003+0004 applied, one s3dlio process moves ~4× the data of stock (1.1 → 4.6 GB/s, matching what s5cmd achieves per process), with warp's 10 GB/s showing the remaining headroom in the HTTP layer.
0001 — loader: spawn each fetch as its own runtime task
All fetch futures were polled inside ONE spawned producer task via
buffer_unordered— request driving and body accumulation cooperatively shared a single worker thread (the ~1.1 GB/s per-process wall, flat vs prefetch depth). Onetokio::spawnper fetch parallelizes across the runtime;buffer_unordered(prefetch)still bounds in-flight tasks and the bounded channel still bounds delivery. Same change at all three sites (__iter__,items(), Parquet loader). Measured: loader 1,147 → ~4,600 MB/s per process.full patch:
0001-perf-loader-spawn-each-fetch-as-its-own-runtime-task.patch(apply withgit am)0002 — http: pin the HTTPS client to HTTP/1.1
Hardening, not required for the numbers here.
select_client()routes allhttps://requests to a client that is neither.http1_only()norH2-tuned (all
S3DLIO_H2_*env vars only configure the h2c client): on an h2-capable endpoint, ALPN silently negotiates HTTP/2 and hyper multiplexes ONE connection with a default 5 MiB window — an uncappable ~5 MiB/RTT per-process limit.full patch:
0002-fix-http-pin-the-HTTPS-client-to-HTTP-1.1.patch(apply withgit am)0003 — range engine: stream chunks into pre-split segments, O(1) unsplit
concurrent_range_get_implcollected every chunk into its ownBytesand then re-copied all of them into a secondBytesMut— an extra full copy of every byte and a transient 2× memory peak per object. Pre-split ONE zeroed allocation into per-chunk segments, write each chunk directly into its segment, rejoin in offset order withBytesMut::unsplit(O(1) for segments of one contiguous allocation).full patch:
0003-perf-range-stream-chunks-into-pre-split-segments-O-1.patch(apply withgit am)0004 — connector: stream response bodies; bounded body-read retries
The connector fully buffered every response (
resp.bytes().await) — the SDK caller saw byte one only after the last byte arrived: noreceive/consume overlap, an extra whole-body buffer per GET, and
first_byte_timemetrics that actually measured last-byte. Hand the SDK a live stream instead. Consequence handled in the same patch: with streaming,send()resolves at headers, so the SDK's transient-error retry no longer covers mid-body failures — every streaming-GET consumer (range chunks,S3Ops::get_object/get_object_range,S3ObjectStore::get) re-issues the request on body-read errors, bounded by the existingS3DLIO_MAX_RETRY_ATTEMPTSbudget.full patch:
0004-perf-connector-stream-response-bodies-to-the-SDK-bou.patch(apply withgit am)