Report size and speed of remote cache transfers - #20
Conversation
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>
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>
|
Added the diagnostic split (aea608c), so CI logs can answer which part is slow rather than just how long it took.
Verified it discriminates rather than always blaming one side, using two workloads of comparable size over the same link:
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 Context for why this matters: for a |
zyla
left a comment
There was a problem hiding this comment.
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:
- File I/O
- Compression/decompression
- 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 )
| 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 |
There was a problem hiding this comment.
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) | -- 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. |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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>
|
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
Verified against three workloads over the same link, blame landing somewhere different each time:
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 PR description updated — it predated aea608c. |
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>
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
measureTransfersits 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 —awaitreturns when upstream has a chunk,yieldwhen downstream wants the next — so together they account for the pipeline's whole wall clock. Cost is twogetMonotonicTimecalls per chunk.Tapping either side of zstd on the save path splits it three ways:
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
zstdruns insidetar, 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
writereturns 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 anawaitthat 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:
The middle row shows the undercounting directly: 381 MiB was read and compressed, but only 36 KiB reached the wire.
countBytesis 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