Skip to content

Commit 4fe854e

Browse files
committed
estargz: parallelize MinChunkSize builds
Builds with MinChunkSize > 0 run on a single core, no matter how large the layer is. They are serial because the writer maintains one invariant -- every gzip stream except the last holds at least MinChunkSize compressed bytes -- and does so by placing each stream boundary based on the compressed size of everything written before it. The build can be parallelized while preserving the invariant as long as: - each worker's slice of the tar holds at least MinChunkSize * 1032 uncompressed bytes (1032 is DEFLATE's maximum compression ratio), so every slice fills at least one full stream; - the trailing stream of a slice, which usually ends below the minimum, is folded into the stream before it. The writer now withholds a full stream's terminator until the next stream also reaches the minimum, and folds the tail back by replaying its buffered raw bytes. Only the short tail is ever recompressed. The invariant also comes out stronger: the trailing stream of the whole blob, previously allowed to end short, is folded as well. A stream now ends below MinChunkSize if and only if the data itself is smaller (or a prefetch landmark forces a boundary). Note that with this change MinChunkSize layer digests differ from previous releases: large layers build in parallel (the layout depends on GOMAXPROCS, like every other eStargz build) and trailing short streams are folded away. eStargz makes no cross-version byte stability promise. Signed-off-by: Simone Primarosa <simone.primarosa@gmail.com>
1 parent d4043f0 commit 4fe854e

6 files changed

Lines changed: 483 additions & 48 deletions

File tree

cmd/ctr-remote/commands/convert.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ When '--all-platforms' is given all images in a manifest list must be available.
8282
},
8383
&cli.IntFlag{
8484
Name: "estargz-parallelism",
85-
Usage: "Number of workers used to build the layer. Pinning it makes builds reproducible across machines regardless of CPU count. 0 (default) uses GOMAXPROCS; 1 forces a sequential build. Has no effect with --estargz-min-chunk-size.",
85+
Usage: "Number of workers used to build the layer. Pinning it makes builds reproducible across machines regardless of CPU count. 0 (default) uses GOMAXPROCS; 1 forces a sequential build.",
8686
Value: 0,
8787
},
8888
&cli.BoolFlag{

cmd/ctr-remote/commands/optimize.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ var OptimizeCommand = &cli.Command{
115115
},
116116
&cli.IntFlag{
117117
Name: "estargz-parallelism",
118-
Usage: "Number of workers used to build the layer. Pinning it makes builds reproducible across machines regardless of CPU count. 0 (default) uses GOMAXPROCS; 1 forces a sequential build. Has no effect with --estargz-min-chunk-size (not applied to zstd:chunked)",
118+
Usage: "Number of workers used to build the layer. Pinning it makes builds reproducible across machines regardless of CPU count. 0 (default) uses GOMAXPROCS; 1 forces a sequential build (not applied to zstd:chunked)",
119119
Value: 0,
120120
},
121121
&cli.StringFlag{

docs/smaller-estargz.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ The following flags of `ctr-remote i convert` and `ctr-remote i optimize` allow
44

55
- `--estargz-external-toc`: Separate TOC JSON into another image (called "TOC image"). The result eStargz doesn't contain TOC so we can expect a smaller size than normal eStargz.
66

7-
- `--estargz-min-chunk-size`: The minimal number of bytes of data must be written in one gzip stream. If it's > 0, multiple files and chunks can be written into one gzip stream. Smaller number of gzip header and smaller size of the result blob can be expected. `--estargz-min-chunk-size=0` produces normal eStargz.
7+
- `--estargz-min-chunk-size`: The minimal number of bytes of data must be written in one gzip stream. If it's > 0, multiple files and chunks can be written into one gzip stream. Smaller number of gzip header and smaller size of the result blob can be expected. `--estargz-min-chunk-size=0` produces normal eStargz. A trailing gzip stream that cannot reach the minimum is folded into the preceding stream instead, so a stream falls below `--estargz-min-chunk-size` only when the layer itself is smaller.
88

9-
- `--estargz-parallelism`: The number of workers used to build each layer. The tar is split into this many slices that are compressed in parallel, so the value also fixes the chunk boundaries: pinning it makes builds reproducible across machines regardless of their CPU count. `0` (the default) uses `GOMAXPROCS`; `1` forces a fully sequential build. This has no effect with `--estargz-min-chunk-size`, which is built with a single worker.
9+
- `--estargz-parallelism`: The number of workers used to build each layer. The tar is split into this many slices that are compressed in parallel, so the value also fixes the chunk boundaries: pinning it makes builds reproducible across machines regardless of their CPU count. `0` (the default) uses `GOMAXPROCS`; `1` forces a fully sequential build. With `--estargz-min-chunk-size`, fewer workers may be used when the layer is too small to give each one at least one full gzip stream.
1010

1111
## `--estargz-external-toc` usage
1212

estargz/build.go

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ func WithContext(ctx context.Context) Option {
123123
// By increasing this number, one gzip stream can contain multiple files
124124
// and it hopefully leads to smaller result blob.
125125
// NOTE: This adds a TOC property that old reader doesn't understand.
126+
// Builds run in parallel across the configured workers (see WithParallelism);
127+
// a trailing stream that cannot reach minChunkSize is folded into its
128+
// predecessor, so a stream falls below minChunkSize only when the input itself
129+
// is smaller.
126130
func WithMinChunkSize(minChunkSize int) Option {
127131
return func(o *options) error {
128132
o.minChunkSize = minChunkSize
@@ -135,8 +139,8 @@ func WithMinChunkSize(minChunkSize int) Option {
135139
// concurrently, so the value also fixes the resulting chunk boundaries: pinning
136140
// it makes builds reproducible across machines regardless of their CPU count.
137141
// Zero (the default) selects runtime.GOMAXPROCS(0); a value of 1 forces a fully
138-
// sequential build. This has no effect together with WithMinChunkSize, which is
139-
// always built with a single worker.
142+
// sequential build. With WithMinChunkSize, fewer workers may be used when the
143+
// layer is too small to give each one at least one full gzip stream.
140144
func WithParallelism(workers int) Option {
141145
return func(o *options) error {
142146
o.parallelism = workers
@@ -246,11 +250,16 @@ func Build(tarBlob *io.SectionReader, opt ...Option) (_ *Blob, rErr error) {
246250
workers = runtime.GOMAXPROCS(0)
247251
}
248252
var tarParts [][]*entry
249-
if opts.minChunkSize > 0 {
250-
// Each entry needs to know the size of the current gzip stream so they
251-
// cannot be processed in parallel.
253+
switch {
254+
case workers <= 1:
252255
tarParts = [][]*entry{entries}
253-
} else {
256+
case opts.minChunkSize > 0:
257+
// Give each worker enough data to fill at least one full stream even at
258+
// gzip's maximum compression ratio, so that folding a short trailing
259+
// stream (see cutGz) never crosses worker boundaries. This coarsens
260+
// parallelism, but layers small enough to stay sequential compress fast.
261+
tarParts = divideEntriesByMinSize(entries, workers, int64(opts.minChunkSize)*maxGzipCompressionRatio)
262+
default:
254263
tarParts = divideEntries(entries, workers)
255264
}
256265
writers := make([]*Writer, len(tarParts))
@@ -393,6 +402,50 @@ func tocAndFooter(compressor Compressor, toc *JTOC, offset int64) (io.Reader, di
393402
return buf, tocDigest, nil
394403
}
395404

405+
// maxGzipCompressionRatio is the largest ratio DEFLATE can achieve: a 258-byte
406+
// match coded as a roughly two-bit symbol pair, i.e. 258*8/2 = 1032. A slice
407+
// of MinChunkSize*1032 bytes thus compresses to at least MinChunkSize.
408+
const maxGzipCompressionRatio = 1032
409+
410+
// divideEntriesByMinSize packs entries into at most maxParts consecutive
411+
// groups of at least minPartSize uncompressed bytes each (total/maxParts when
412+
// that is larger). A trailing remainder below minPartSize is folded into the
413+
// last group; data that cannot fill even one group is returned as one.
414+
func divideEntriesByMinSize(entries []*entry, maxParts int, minPartSize int64) (set [][]*entry) {
415+
var total int64
416+
for _, e := range entries {
417+
total += e.header.Size
418+
}
419+
target := total / int64(maxParts)
420+
if target < minPartSize {
421+
target = minPartSize
422+
}
423+
var (
424+
cur []*entry
425+
curSize int64
426+
)
427+
for _, e := range entries {
428+
cur = append(cur, e)
429+
curSize += e.header.Size
430+
// the last group takes the remainder, so the count never exceeds maxParts
431+
if curSize >= target && len(set) < maxParts-1 {
432+
set = append(set, cur)
433+
cur, curSize = nil, 0
434+
}
435+
}
436+
switch {
437+
case len(cur) == 0:
438+
case len(set) > 0 && curSize < minPartSize:
439+
set[len(set)-1] = append(set[len(set)-1], cur...)
440+
default:
441+
set = append(set, cur)
442+
}
443+
if len(set) == 0 {
444+
set = [][]*entry{entries}
445+
}
446+
return
447+
}
448+
396449
// divideEntries divides passed entries to the parts at least the number specified by the
397450
// argument.
398451
func divideEntries(entries []*entry, minPartsNum int) (set [][]*entry) {

0 commit comments

Comments
 (0)