Skip to content

feat(upload): split profile archive uploads into concurrent parts - #565

Open
lvaroqui wants to merge 3 commits into
mainfrom
cod-3700-multipart-upload
Open

lvaroqui wants to merge 3 commits into
mainfrom
cod-3700-multipart-upload

Conversation

@lvaroqui

@lvaroqui lvaroqui commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Upload profile archives as S3 multipart uploads, with parts sent concurrently and checked against CRC64NVME checksums.

S3 rejects a single upload request above 5 GiB, so large walltime and memory profile archives could not be uploaded (walltime folders above 5 GiB were gzipped on disk to try to fit). Even below that limit, a single connection to S3 only reaches about 20-25 MiB/s on GitHub-hosted runners, so a 1 GiB archive took close to a minute to upload.

How it works

  • Part layout: every archive is a multipart upload. The target is two parts per concurrent upload, so that a slot freed by a fast part picks up a remaining one instead of idling while the slowest part finishes. Part sizes are clamped between 16 MiB and 256 MiB, so the part count drifts from the target at both ends: with the default concurrency of 8 (16 parts), archives above 4 GiB get more 256 MiB parts, and archives below 256 MiB get fewer 16 MiB parts, down to a single part.
  • Checksums: the CRC64NVME of every part and of the whole archive are computed in one pass over the archive, on the blocking thread pool. They replace the md5 in the upload metadata (bumped to version 12): profileMd5 and profileEncoding become profileArchiveMetadata (encoding, size, crc64nvme, partSize, partCrc64nvmes).
  • Upload: the upload endpoint answers with multipartUpload instead of uploadUrl: one presigned UploadPart request per part (parts) and a presigned CompleteMultipartUpload request (complete), each as { url, headers }. The headers are part of the signature and are sent as is, so S3 checks each part and the whole object against the checksums. Parts are uploaded 8 at a time, each with its own retries, then the ETags are sent in part order to complete the upload.
  • Completion errors: S3 can report a failed completion with a 200 status, so the response body is checked for an error. Errors S3 documents as transient (InternalError, ServiceUnavailable, SlowDown, RequestTimeout) and dropped connections are retried; others such as InvalidPart fail right away.
  • Both archive kinds: on-disk (walltime, memory) archives are streamed from disk part by part, and in-memory gzip (simulation) archives are sliced without copying. Both go through the same retry loop.
  • S3 specifics (presigned requests, ETag, completion body and errors) live in the new upload::s3 module.
  • CODSPEED_UPLOAD_CONCURRENCY overrides the number of concurrent part uploads.

Other changes

  • Walltime profile folders above 5 GiB are no longer gzipped on disk, and the runner no longer caps the archive size: with 256 MiB parts, the S3 limit of 10,000 parts allows archives up to 2.5 TiB.
  • The md5 dependency is replaced by crc-fast.

Measurements

Concurrency sweep on a 6 GiB walltime archive (25 parts of 256 MiB), which led to the default of 8:

Concurrency ubuntu-latest CodSpeed macro runner (Ryzen 9950X)
1 240.3s · 25.6 MiB/s 59.5s · 103.2 MiB/s
2 83.4s · 73.7 MiB/s 33.9s · 181.4 MiB/s
4 57.2s · 107.4 MiB/s 21.3s · 289.0 MiB/s
8 45.2s · 135.8 MiB/s 8.2s · 746.5 MiB/s
16 48.7s · 126.1 MiB/s 10.3s · 596.5 MiB/s

Smaller archives with the final part layout, concurrency 1 (close to the previous single request) vs 8:

Archive Runner Concurrency 1 Concurrency 8
256 MiB ubuntu-latest 11.6s 2.8s (4.1×)
1 GiB ubuntu-latest 55.1s 9.0s (6.1×)
256 MiB macro runner 2.7s 1.3s (2.1×)
1 GiB macro runner 10.1s 1.6s (6.3×)

Hashing a 6 GiB archive, comparing the md5 the runner computed so far with the alternatives. The CRC64NVME of the whole archive is combined from the part CRCs, so each byte is hashed once and the whole + parts pass costs the same as hashing the whole archive alone:

Variant ubuntu-latest (EPYC 7763, 2 cores) Macro runner (9950X, 8 cores)
MD5 whole + parts (before) 24.3s · 0.25 GiB/s 14.5s · 0.41 GiB/s
Composite MD5, parts in parallel 6.7s · 0.89 GiB/s 0.9s · 6.46 GiB/s
CRC64NVME whole + parts (two digests per byte) 1.0s · 5.79 GiB/s 0.2s · 29.11 GiB/s
CRC64NVME parts + combine (this PR) 0.5s · 11.28 GiB/s 0.1s · 44.14 GiB/s
CRC64NVME whole only (reference) 0.5s · 11.35 GiB/s 0.1s · 46.83 GiB/s
CRC32C parts + combine 0.4s · 17.02 GiB/s 0.1s · 46.76 GiB/s
CRC32 parts + combine 0.5s · 11.28 GiB/s 0.1s · 46.48 GiB/s

The backend support for the version 12 upload metadata is not released yet, so the upload cannot be verified end to end against production for now.

Closes COD-3700

@codspeed

codspeed Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks
⏩ 13 skipped benchmarks1


Comparing cod-3700-multipart-upload (acfbe6a) with main (24f182e)

Open in CodSpeed

Footnotes

  1. 13 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@lvaroqui
lvaroqui force-pushed the cod-3700-multipart-upload branch from 454fdc6 to 1cc552d Compare October 6, 2026 13:30
@lvaroqui lvaroqui changed the title feat(upload): upload profile archives over 5 GiB in parts feat(upload): upload profile archives using S3 multipart in parts Oct 6, 2026
@lvaroqui
lvaroqui force-pushed the cod-3700-multipart-upload branch from 1cc552d to a2f09f3 Compare October 6, 2026 13:34
@lvaroqui lvaroqui changed the title feat(upload): upload profile archives using S3 multipart in parts feat(upload): split profile archive uploads into concurrent parts Oct 6, 2026
@lvaroqui
lvaroqui requested review from GuillaumeLagrange and removed request for GuillaumeLagrange October 6, 2026 13:37
@lvaroqui
lvaroqui marked this pull request as ready for review October 6, 2026 14:17
@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge, with a non-blocking safeguard needed for archives above S3’s multipart limit.

Fix All in Claude CodeFindings

  1. P2 Part count can exceed S3 limit ▶
Fix with agent prompt
### Issue 1
src/upload/profile_archive.rs:132-133
The part size stops growing at 256 MiB, but there is no limit on the number of parts. If a WallTime or Memory archive exceeds about 2.44 TiB, it needs more than S3’s 10,000-part maximum. The runner will hash the archive and prepare the upload before it fails. Checking the part count earlier would avoid that wasted work and give a clearer error.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR replaces single-request profile uploads with concurrent S3 multipart uploads and version 12 CRC64NVME archive metadata.

  • Disk archives are streamed by part; in-memory archives are sliced into parts.
  • Presigned requests carry signed headers, and completion checks S3’s response body.
  • The removed archive limit leaves the S3 part-count ceiling unenforced.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Create profile archive] --> B[Compute whole and part CRC64NVME]
  B --> C[Send version 12 metadata to API]
  C --> D[Receive presigned part and completion requests]
  D --> E[Upload parts concurrently]
  E --> F[Complete multipart upload in part order]
Loading

Reviews (5) · Last reviewed commit: "review changes (will squash merge)" · Reviewed by Greptile

Comment thread src/upload/uploader.rs
Comment thread src/upload/s3.rs Outdated
Comment thread src/upload/interfaces.rs Outdated
@lvaroqui
lvaroqui force-pushed the cod-3700-multipart-upload branch from a2f09f3 to c60ccf8 Compare October 6, 2026 15:38
Comment thread src/upload/uploader.rs Outdated
Comment thread src/upload/uploader.rs Outdated
@lvaroqui
lvaroqui force-pushed the cod-3700-multipart-upload branch from c60ccf8 to b2177c0 Compare October 6, 2026 16:13
Comment thread src/upload/s3.rs
Comment thread src/upload/uploader.rs
@greptile-apps

greptile-apps Bot commented Oct 7, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Start a greploop in Claude Code and it will work through the open comments and keep going until this PR reviews clean.

@adriencaccia adriencaccia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Seen together, let's remove the single part upload path and always use multipart, albeit with a single part for archives under 64MiB.

@lvaroqui
lvaroqui force-pushed the cod-3700-multipart-upload branch from 148e6dd to 810a9e5 Compare October 7, 2026 14:55

@GuillaumeLagrange GuillaumeLagrange left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

olgtm: the metadata snapshot update should have its morrir conterpart updated in platform repo in order to make sure metadata stays compatible, hit me up for more info.

Also some tests could be trimmed, the /deslop skill that we share in platform repo is quite a good judge for it usually

Comment thread src/upload/interfaces.rs
Comment thread src/upload/profile_archive.rs Outdated
Comment thread src/upload/profile_archive.rs Outdated
Comment thread src/upload/profile_archive.rs
Comment thread src/upload/uploader.rs Outdated
lvaroqui and others added 2 commits October 8, 2026 17:57
S3 rejects single uploads above 5 GiB, and a single connection to S3
only reaches about 20-25 MiB/s on GitHub-hosted runners, so large
profile archives were slow or impossible to upload.

Every archive is now sent as an S3 multipart upload, replacing the
single-request upload: about two parts per concurrent upload, each
between 16 MiB and 256 MiB, so a small archive is a single part. The
md5 of every part is computed in the same pass as the archive md5 and
sent in the upload metadata (version 12) as `profileMultipart`. The API
answers with `multipartUploadUrls`: the parts are uploaded 8 at a time
(overridable with `CODSPEED_UPLOAD_CONCURRENCY`), each with its own
retries, then the upload is completed with the part ETags in order.
This applies to both on-disk and in-memory (gzip) archives.

This requires an upload endpoint that accepts metadata version 12 and
answers with `multipartUploadUrls`.

Walltime profile folders above 5 GiB are no longer gzipped on disk to
fit in a single request, and the runner no longer caps the archive size
itself: the upload endpoint rejects archives above its limit, with the
reason shown in the runner output, so the limit can change without a
runner release. Archives are now hashed while streaming on the blocking
thread pool, instead of being read whole into memory on the async
runtime.

Closes COD-3700
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the md5 of each part and of the archive with CRC64NVME
checksums, and describe the profile archive with a single
`profileArchive` field in the upload metadata (version 12): its
encoding, size and CRC64NVME, and the size and CRC64NVME of its parts.
It replaces `profileEncoding`, `profileMd5` and `profileMultipart`.
Each part is hashed once, and the part CRCs are combined into the
archive's.

S3 can check a CRC64NVME on the whole archive of a multipart upload,
which md5 does not support, so a completion that leaves out a part or
assembles another archive is rejected, and not only a corrupted part.
It is also much cheaper to compute: about 0.5s for a 6 GiB archive on a
GitHub-hosted runner, against 24s for the part and archive md5s.

The upload endpoint now answers with `multipartUpload`, replacing
`multipartUploadUrls`: a presigned request per part and one to complete
the upload, each with the headers to send as is, as they are part of
the signature. The runner sends them without knowing which ones S3
checks, so the endpoint can change them without a runner release.

`crc-fast` is pinned to 1.9, the last release supporting Rust 1.88,
which requires `crc` 3.3.

Refs COD-3700
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@GuillaumeLagrange GuillaumeLagrange left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OLGTM!
I guess that CI failure is because prod does not yeat accept the new upload metadata. Let's keep this in mind before merging/releasing this and make sure everything's been deployed

Comment thread src/upload/interfaces.rs Outdated
Comment thread src/upload/profile_archive.rs
Comment thread src/upload/profile_archive.rs
Comment thread src/upload/profile_archive.rs
@lvaroqui
lvaroqui force-pushed the cod-3700-multipart-upload branch from 2c2bbfc to acfbe6a Compare October 9, 2026 10:36
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.

3 participants