Skip to content

Report size and speed of remote cache transfers - #20

Open
kozak wants to merge 4 commits into
mainfrom
remote-cache-speed-logging
Open

Report size and speed of remote cache transfers#20
kozak wants to merge 4 commits into
mainfrom
remote-cache-speed-logging

Conversation

@kozak

@kozak kozak commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Why

Remote cache transfers were logged as a single duration, which cannot answer the question a slow restore actually raises: what were we waiting for?

What

measureTransfer sits in the conduit pipeline and records how long it was blocked waiting for upstream to hand over a chunk versus blocked waiting for downstream to accept one. Conduit runs those strictly alternately — await returns when upstream has a chunk, yield when downstream wants the next — so together they account for the pipeline's whole wall clock. Cost is two getMonotonicTime calls per chunk.

Tapping either side of zstd on the save path splits it three ways:

Packed and uploaded 286.11 MiB in 2.02s (141.66 MiB/s), compressed from 286.11 MiB - blocked on reading files 0.09s, on compression 0.18s, on upload 1.72s
Downloaded and unpacked 378.87 MiB in 2.25s (168.73 MiB/s) - blocked on download 0.24s, on decompression and unpacking 1.97s

The taps nest rather than partition — when zstd wants input it pulls through the upstream tap, so the upload tap's wait already contains the wait for tar. Compression is the difference between the two, which makes the three figures sum to the elapsed time and gives a self-check on the numbers.

Restore only gets a two-way split: there zstd runs inside tar, so decompression can't be separated from the file writes. It becomes three-way in #21, which decompresses in-process.

Reading these numbers

They are stall times, not time spent doing the work. A socket write returns once the data is copied into the kernel send buffer, and the kernel transmits it while the next chunk is compressed — so transfer overlaps the rest of the pipeline and is undercounted here; if the pipeline is CPU-bound the writes cost almost nothing. Reads are the mirror image: bytes accumulate in the receive buffer while we are busy, so an await that finds them already there is free.

So they answer "where did the pipeline stall", which is what identifies the bottleneck. They are not a breakdown of where the bytes' time went.

Verification

Three workloads over the same link, blame landing somewhere different each time and summing to the elapsed time:

workload reading files compression upload elapsed
8,000 tiny files, 7.82 MiB 0.11s 0.01s 0.00s 0.13s
one 381 MiB compressible file 0.12s 0.06s 0.00s 0.19s
one 286 MiB incompressible file 0.09s 0.18s 1.72s 2.02s

The middle row shows the undercounting directly: 381 MiB was read and compressed, but only 36 KiB reached the wire.

countBytes is gone — the new upstream tap already counts uncompressed bytes.

What it found

On CI, across seven tasks: 13.6s of stalling on download against 420.7s on unpacking. That motivated #21, and the measurements there in turn showed the win depends entirely on the archive's entry-size profile — which this logging is what made visible.

🤖 Generated with Claude Code

Closes the "report speed, size etc." TODO in RemoteCache. There was no way
to tell how long restoring or saving a cache actually took, or how fast,
which made it impossible to reason about remote cache performance.

Adds Utils.timed and Utils.transferSummary (on top of the existing
bytesfmt), and a countBytes conduit that tallies bytes passing through
without buffering, then instruments both directions:

  Downloaded and unpacked 190.74 MiB in 0.33s (579.28 MiB/s)
  Packed and uploaded 190.74 MiB in 1.39s (137.08 MiB/s), compressed from 190.74 MiB

Both are debug level, so they always land in the per-task log but stay out
of normal output - and out of the golden files, since the numbers are not
reproducible.

The rates cover the whole pipeline rather than just the network: download
plus zstd plus tar one way, tar plus zstd plus upload the other. That is
the number that matters for wall clock, and the comments say so explicitly
so it is not mistaken for link speed.

Tested against a local MinIO with the same env CI uses: all 63 tests pass,
no golden files change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kozak
kozak requested a review from zyla July 31, 2026 13:12
The single duration could not answer the question it most often raises:
when restoring a cache is slow, is it the network or the unpacking? For a
cache full of small files - .stack-work and friends - it is almost always
the unpacking, but there was no way to see that from CI logs.

measureTransfer sits in the pipeline and records how long it waits for
upstream to produce data versus for downstream to consume it. Conduit runs
those strictly alternately, so it is an exact attribution of the
pipeline's wall clock rather than a sample, and it costs two clock reads
per chunk.

  Downloaded and unpacked 378.87 MiB in 2.25s (168.73 MiB/s)
    - downloading 0.24s, unpacking 1.97s
  Packed and uploaded 378.87 MiB in 6.51s (58.17 MiB/s), compressed from
    1.26 GiB - packing and compressing 4.22s, uploading 2.27s

Verified it discriminates rather than always blaming one side. Restoring
20160 small files totalling 1.26 GiB: 0.24s downloading, 1.97s unpacking.
Restoring a single 190 MiB file over the same link: 0.16s downloading,
0.20s unpacking.

Note it measures waiting, and downstream applies backpressure, so a slow
consumer cannot inflate the producing side - time lands there only when no
data was available yet.

All 63 tests pass against a local MinIO; no golden files change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kozak

kozak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Added the diagnostic split (aea608c), so CI logs can answer which part is slow rather than just how long it took.

measureTransfer sits in the pipeline and records how long it waits for upstream to produce data versus for downstream to consume it. Conduit runs those strictly alternately — await returns when upstream has a chunk, yield returns when downstream wants the next — so it's an exact attribution of the pipeline's wall clock, not a sample. Cost is two getMonotonicTime calls per chunk.

Downloaded and unpacked 378.87 MiB in 2.25s (168.73 MiB/s) - downloading 0.24s, unpacking 1.97s
Packed and uploaded 378.87 MiB in 6.51s (58.17 MiB/s), compressed from 1.26 GiB - packing and compressing 4.22s, uploading 2.27s

Verified it discriminates rather than always blaming one side, using two workloads of comparable size over the same link:

workload downloading unpacking
20,160 files, 1.26 GiB (.stack-work-shaped) 0.24s 1.97s
one 190 MiB file 0.16s 0.20s

It measures waiting, and downstream applies backpressure, so a slow consumer can't inflate the producing side — time only lands there when no data was available yet. The download side also excludes connection setup, which happens earlier in AWS.send.

Context for why this matters: for a .stack-work-shaped cache, extraction is ~94% of restore time (measured separately: zstd decompress 0.40s, tar parse 0.07s, extraction ~2.0s). It's per-file syscall and filesystem cost, roughly 100 µs per file, independent of link speed. This logging is the prerequisite for deciding whether to do anything about that — parallel extraction measured 2.6× faster on 4 vCPUs and 4.4× on 8, but that's only worth building if prod caches look like the synthetic one.

@zyla zyla 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.

The single duration could not answer the question it most often raises:
when restoring a cache is slow, is it the network or the unpacking?

Why not split it further? There are 3 possible things to blame:

  1. File I/O
  2. Compression/decompression
  3. Network upload/download

If you tap the pipeline with measureTransfer before and after zstd, you can get full attribution.

(See my other comment about the interpretation of the attribution)

btw, seems like PR description is stale, doesn't talk about aea608c )

Comment thread src/RemoteCache.hs Outdated
Comment on lines +178 to +181
countBytes :: MonadIO m => IORef Int -> ConduitT BS.ByteString BS.ByteString m ()
countBytes ref = C.awaitForever \chunk -> do
modifyIORef' ref (+ BS.length chunk)
C.yield chunk

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.

More idiomatic: https://hackage-content.haskell.org/package/conduit-1.3.6.1/docs/Data-Conduit-Combinators.html#v:iterM

countBytes ref = C.iterM \chunk -> modifyIORef' ref (+ BS.length chunk) 

Comment thread src/RemoteCache.hs Outdated
Comment on lines +202 to +204
-- Conduit runs the two strictly alternately - 'C.await' returns once upstream
-- has a chunk, and 'C.yield' returns once downstream wants the next one - so
-- this is an exact attribution of this pipeline's wall clock, not a sample.

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.

This might be true for CPU work. But not for I/O! Kernel has TCP send and receive buffers internally, and data is being transferred in parallel with the zstd compression. So the actual transfer time will be undercounted. In fact, if the pipeline is CPU-bound, the write()s will take approximately zero time (just copying into a kernel buffer, which will then be sent while the next chunk is compressed).

(the above is from an upload perspective; flip for a download)

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.

The paragraph below sounds like it wants to address this, but I doesn't quite explain it IMO, or even talks about a different issue.

Now, the time spent in network write() (or read() for downloads) is still useful for answering your question - "is the network the bottleneck" - I just wouldn't report it as "time spent uploading".

Three points from @zyla:

- Attribute the third component. Tapping either side of zstd separates
  reading the files from compressing them from the upload. The taps nest
  rather than partition - when zstd wants input it pulls through the
  upstream tap - so compression is the difference between the two, and the
  three figures now add up to the elapsed time.

- Do not report stall time as time spent transferring. A socket write
  returns once the data is in the kernel send buffer and the kernel
  transmits it while the next chunk is compressed, so transfer overlaps the
  rest of the pipeline and is undercounted; reads are the mirror image.
  These numbers identify the bottleneck, they are not a breakdown of where
  the bytes' time went, and the wording now says so.

- countBytes is gone rather than converted to iterM: the new upstream tap
  already counts the uncompressed bytes.

Only a two-way split is available on restore, where zstd runs inside tar
and decompression cannot be separated from the file writes.

Verified against three workloads over the same link, blame landing
somewhere different each time and summing to the elapsed time:

  8000 tiny files, 7.82 MiB   reading 0.11s  compression 0.01s  upload 0.00s  (0.13s)
  one 381 MiB compressible    reading 0.12s  compression 0.06s  upload 0.00s  (0.19s)
  one 286 MiB incompressible  reading 0.09s  compression 0.18s  upload 1.72s  (2.02s)

The middle row is the point about undercounting: 381 MiB was read and
compressed, but only 36 KiB reached the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kozak

kozak commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three were right. Fixed in 81d1b02.

Third component. Tapping either side of zstd now separates the three. One thing worth recording: the taps nest rather than partition. When zstd wants input it pulls through the upstream tap, so the time the upload tap spent waiting for zstd already contains the time spent waiting for tar. My first attempt reported both raw and produced figures summing to 0.23s against 0.13s elapsed. Compression is the difference between the two, and the three now add up to the elapsed time — which is a useful self-check on the numbers.

Interpretation. You're right, and "time spent uploading" was the wrong label. The docs now say these are stall times, that a socket write returns once the data is in the kernel send buffer and transmission overlaps compressing the next chunk (so transfer is undercounted, and is ~free when the pipeline is CPU-bound), that reads are the mirror image, and that the numbers identify the bottleneck rather than breaking down where the bytes' time went.

Note this doesn't change the conclusion the logging was built to test — for a .stack-work-shaped cache the network wasn't what we were stalling on — but that is precisely a statement about stalls, not about transfer.

countBytes. Removed rather than converted to iterM: the new upstream tap already counts the uncompressed bytes, so it was redundant. Agreed iterM would have been the right form had it stayed.

Verified against three workloads over the same link, blame landing somewhere different each time:

workload reading files compression upload elapsed
8,000 tiny files, 7.82 MiB 0.11s 0.01s 0.00s 0.13s
one 381 MiB compressible file 0.12s 0.06s 0.00s 0.19s
one 286 MiB incompressible file 0.09s 0.18s 1.72s 2.02s

The middle row is your undercounting point made concrete: 381 MiB was read and compressed, but only 36 KiB reached the wire, so "upload" is 0.00s.

One limitation. Restore only gets a two-way split, because there zstd runs inside tar (tar -x --zstd) so decompression can't be separated from the file writes. It becomes three-way in the follow-up (#21), which decompresses in-process in order to split the stream across several tar processes.

PR description updated — it predated aea608c.

kozak added a commit that referenced this pull request Aug 2, 2026
Follows the review on #20. Decompressing in-process for the parallel path
means a tap can go between zstd and tar, so restore now separates network
from decompression from the file writes - which #20 could not do, since
there zstd runs inside tar. As on the save path the taps nest rather than
partition, so decompression is the difference between them.

Two corrections to the previous numbers:

- Dropped the feed loop's own timing. It blocks whenever a worker's pipe is
  full, so its wall clock conflated this thread's work with waiting for tar,
  and a large value read as "the splitter is slow" when it actually meant
  "the workers are the bottleneck". The tap either side of zstd attributes
  that correctly.

- Report how long tar keeps extracting after the last byte is handed over.
  That tail is outside the pipeline's accounting, so without it the figures
  visibly failed to add up: a 700 MB single-file archive reported 0.00s of
  stalling against 0.63s elapsed, because all the work happened after
  feeding finished.

All four combinations now sum to the elapsed time:

  20k small files, 8 workers  download 0.00s  decompress 0.01s  unpack 0.61s              (0.63s)
  20k small files, 1 worker   download 0.00s  decompress+unpack 0.42s  tar tail 0.49s     (0.91s)
  one 700 MB file, 8 workers  download 0.00s  decompress 0.04s  unpack 0.57s              (0.62s)
  one 700 MB file, 1 worker   download 0.00s  decompress+unpack 0.00s  tar tail 0.62s     (0.63s)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants