Skip to content

Commit 98ba4fa

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 4daea59 commit 98ba4fa

4 files changed

Lines changed: 550 additions & 25 deletions

File tree

docs/smaller-estargz.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ 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

99
## `--estargz-external-toc` usage
1010

estargz/build.go

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ func WithContext(ctx context.Context) Option {
122122
// By increasing this number, one gzip stream can contain multiple files
123123
// and it hopefully leads to smaller result blob.
124124
// NOTE: This adds a TOC property that old reader doesn't understand.
125+
// Builds run in parallel across runtime.GOMAXPROCS(0) workers; a trailing
126+
// stream that cannot reach minChunkSize is folded into its predecessor, so a
127+
// stream falls below minChunkSize only when the input itself is smaller.
125128
func WithMinChunkSize(minChunkSize int) Option {
126129
return func(o *options) error {
127130
o.minChunkSize = minChunkSize
@@ -223,13 +226,19 @@ func Build(tarBlob *io.SectionReader, opt ...Option) (_ *Blob, rErr error) {
223226
if err != nil {
224227
return nil, err
225228
}
229+
workers := runtime.GOMAXPROCS(0)
226230
var tarParts [][]*entry
227-
if opts.minChunkSize > 0 {
228-
// Each entry needs to know the size of the current gzip stream so they
229-
// cannot be processed in parallel.
231+
switch {
232+
case workers <= 1:
230233
tarParts = [][]*entry{entries}
231-
} else {
232-
tarParts = divideEntries(entries, runtime.GOMAXPROCS(0))
234+
case opts.minChunkSize > 0:
235+
// Give each worker enough data to fill at least one full stream even at
236+
// gzip's maximum compression ratio, so that folding a short trailing
237+
// stream (see cutGz) never crosses worker boundaries. This coarsens
238+
// parallelism, but layers small enough to stay sequential compress fast.
239+
tarParts = divideEntriesByMinSize(entries, workers, int64(opts.minChunkSize)*maxGzipCompressionRatio)
240+
default:
241+
tarParts = divideEntries(entries, workers)
233242
}
234243
writers := make([]*Writer, len(tarParts))
235244
payloads := make([]*os.File, len(tarParts))
@@ -371,6 +380,50 @@ func tocAndFooter(compressor Compressor, toc *JTOC, offset int64) (io.Reader, di
371380
return buf, tocDigest, nil
372381
}
373382

383+
// maxGzipCompressionRatio is the largest ratio DEFLATE can achieve: a 258-byte
384+
// match coded as a roughly two-bit symbol pair, i.e. 258*8/2 = 1032. A slice
385+
// of MinChunkSize*1032 bytes thus compresses to at least MinChunkSize.
386+
const maxGzipCompressionRatio = 1032
387+
388+
// divideEntriesByMinSize packs entries into at most maxParts consecutive
389+
// groups of at least minPartSize uncompressed bytes each (total/maxParts when
390+
// that is larger). A trailing remainder below minPartSize is folded into the
391+
// last group; data that cannot fill even one group is returned as one.
392+
func divideEntriesByMinSize(entries []*entry, maxParts int, minPartSize int64) (set [][]*entry) {
393+
var total int64
394+
for _, e := range entries {
395+
total += e.header.Size
396+
}
397+
target := total / int64(maxParts)
398+
if target < minPartSize {
399+
target = minPartSize
400+
}
401+
var (
402+
cur []*entry
403+
curSize int64
404+
)
405+
for _, e := range entries {
406+
cur = append(cur, e)
407+
curSize += e.header.Size
408+
// the last group takes the remainder, so the count never exceeds maxParts
409+
if curSize >= target && len(set) < maxParts-1 {
410+
set = append(set, cur)
411+
cur, curSize = nil, 0
412+
}
413+
}
414+
switch {
415+
case len(cur) == 0:
416+
case len(set) > 0 && curSize < minPartSize:
417+
set[len(set)-1] = append(set[len(set)-1], cur...)
418+
default:
419+
set = append(set, cur)
420+
}
421+
if len(set) == 0 {
422+
set = [][]*entry{entries}
423+
}
424+
return
425+
}
426+
374427
// divideEntries divides passed entries to the parts at least the number specified by the
375428
// argument.
376429
func divideEntries(entries []*entry, minPartsNum int) (set [][]*entry) {

0 commit comments

Comments
 (0)