Skip to content

Benchmarking, ImageMagick comparison, and production observability

Copy page

Search results for “libvips vs ImageMagick” frequently repeat two broad claims: that libvips is “3–10x faster” and that its memory use is “constant regardless of image size”. Stripped of workload details, pixel precision, and command-line flags, both statements mislead capacity planning. The official libvips Speed and Memory Use documentation explicitly notes that its own benchmark reflects a specific multi-step pipeline (crop, resize, and sharpen) on specific hardware rather than every image operation.

Why “3–10x faster” and “constant memory” mislead capacity planning

Section titled “Why “3–10x faster” and “constant memory” mislead capacity planning”

Treating a single headline multiplier as a universal constant hides three technical conditions:

  1. Pixel representation and intermediate buffers differ: Most Linux distributions build ImageMagick as Q16-HDRI (16-bit floating-point or integer samples per channel), where a full-image pixel cache requires 6 to 8 bytes per RGB/RGBA pixel. When processing 8-bit JPEG, PNG, or WebP inputs, libvips keeps 8-bit bands (3 bytes per RGB pixel) inside small horizontal regions in its line buffer cache, promoting precision only inside operators that require it.
  2. Shrink-on-load changes the work performed: vips thumbnail automatically enables libjpeg discrete cosine transform (DCT) block scaling during decode, skipping unneeded high-frequency coefficients. Comparing vips thumbnail against magick -resize without -define jpeg:size=... (or with -define placed in the wrong argument position) measures DCT pre-scaling against a full 22-megapixel decode rather than comparing equivalent resize algorithms.
  3. Low memory is not constant memory: Even under sequential access, libvips memory consumption scales with image width, operator window height, and worker thread count. As soon as a pipeline includes a 90° rotation, vertical flip, or repeated read that requires random access, libvips must materialise the full decoded image in memory or a temporary file.

Architectural differences that explain the real gap

Section titled “Architectural differences that explain the real gap”
Dimension libvips (8.17.3) ImageMagick (7.1.1-47 Q16-HDRI) Impact on production metrics
Evaluation model Demand-driven pipeline; the output encoder pulls small regions through upstream operators Sequential command evaluation; each operator reads and updates a full-image pixel cache Across chained operators, libvips avoids allocating and scanning full intermediate images
Default sample precision Preserves input bit depth (3 bytes/pixel for 8-bit sRGB) and promotes inside operators when needed Default Q16-HDRI build stores pixel caches at 16-bit HDRI precision Full-frame decode and multi-step filtering produce higher peak RSS in ImageMagick
JPEG thumbnail path vips thumbnail combines DCT shrink-on-load and sequential streaming by default Requires -define jpeg:size=WxH placed before the input filename When configured with the pre-scale hint in the right position, both tools cut latency and RSS
In-process threading Worker pool defaults to logical CPU core count, parallelising tiles within a pipeline OpenMP parallelises scanlines inside individual operators by default Under high request concurrency, unrestricted per-request threads cause contention in both tools

Reproducible experiments: environment, commands, and four measured scenarios

Section titled “Reproducible experiments: environment, commands, and four measured scenarios”

All measurements below were collected on 2026-10-15 on the same workstation using /usr/bin/time -v (1 warmup run followed by 5 measured runs, reporting the median and P95) and an in-process Go harness:

  • Hardware and kernel: Intel(R) Core(TM) Ultra 9 285H (16 logical cores), 62.3 GiB RAM, Linux 7.0.12-101.fc43.x86_64
  • Locked software versions: libvips 8.17.3, ImageMagick 7.1.1-47 Q16-HDRI x86_64, Go 1.27.0, govips v2.19.0, pyvips 3.2.0
  • Independent historical baseline: these numbers remain tied to the govips v2.19.0 workstation run from 2026-10-15. The current Recipe Reference is v2.20.0; the newer label must not be applied to the older measurements.
  • Input test images:
    • Base image input.jpg: 1938×1292 sRGB 8-bit JPEG (2.5 MP, 610,191 bytes).
    • High-resolution derivative large-5814x3876.jpg: a deterministic 5814×3876 sRGB 8-bit JPEG (22.5 MP, 7,196,272 bytes) generated from input.jpg with:
终端窗口
vips replicate input.jpg large-5814x3876.jpg[Q=90] 3 3

Scenario A: JPEG thumbnailing and shrink-on-load (22.5 MP → 400×267)

Section titled “Scenario A: JPEG thumbnailing and shrink-on-load (22.5 MP → 400×267)”

Downscaling 5814×3876 to 400×267 (JPEG Q=85) shows how DCT shrink-on-load and ImageMagick argument ordering change both runtime and memory:

终端窗口
# 1. vips thumbnail: enables libjpeg shrink-on-load and sequential streaming by default
/usr/bin/time -v vips thumbnail large-5814x3876.jpg out-vips-thumb.jpg[Q=85] 400
# 2. vips resize: streams sequentially, but decodes all 22.5 MP before scaling
/usr/bin/time -v vips resize large-5814x3876.jpg[access=sequential] out-vips-resize.jpg[Q=85] 0.06879945
# 3. ImageMagick: jpeg:size hint placed BEFORE the input path (DCT pre-scaling active)
/usr/bin/time -v magick -define jpeg:size=800x800 large-5814x3876.jpg -resize 400x267 -quality 85 out-magick-before.jpg
# 4. ImageMagick: jpeg:size hint placed AFTER the input path (common mistake; full decode already happened)
/usr/bin/time -v magick large-5814x3876.jpg -define jpeg:size=800x800 -resize 400x267 -quality 85 out-magick-after.jpg
# 5. ImageMagick: default full-frame decode followed by resize
/usr/bin/time -v magick large-5814x3876.jpg -resize 400x267 -quality 85 out-magick-full.jpg
Execution path Median time P95 time Median peak RSS What the measurement shows
vips thumbnail 129.7 ms 138.2 ms 65.4 MiB Uses DCT shrink-on-load plus sequential downscaling without extra flags
vips resize [access=sequential] 224.5 ms 238.7 ms 108.6 MiB Streams rows sequentially, but decodes the full 22.5 MP grid first
magick -define jpeg:size=800x800 (before input) 96.1 ms 100.5 ms 34.4 MiB libjpeg decodes a reduced grid directly; single-CLI time and RSS drop sharply
magick ... -define jpeg:size=800x800 (after input) 365.8 ms 392.8 ms 289.7 MiB ImageMagick 7 evaluates arguments in order; a decoder hint after the filename is ignored
magick -resize (default full decode) 357.7 ms 387.1 ms 289.7 MiB Allocates a full 22.5 MP Q16-HDRI pixel cache before resizing

Takeaway: For JPEG thumbnails, vips thumbnail wins by making shrink-on-load the default path. When ImageMagick is given -define jpeg:size=800x800 before the input filename, its peak RSS drops from 289.7 MiB to 34.4 MiB and finishes in 96.1 ms.

Scenario B: Multi-step pipeline vs heavy single-step encoder bottleneck

Section titled “Scenario B: Multi-step pipeline vs heavy single-step encoder bottleneck”

The classic libvips benchmark crops 10% off the edges, resizes to 90%, applies a sharpen convolution, and writes a JPEG. Because the output remains close to full resolution (90%), neither tool can use DCT shrink-on-load. We compare that multi-step pipeline against a single-step, compute-heavy WebP encode (Q=82, effort=6 / method=6):

终端窗口
# 1. Multi-step pipeline: libvips (pyvips 3.2.0 sequential crop + resize + sharpen)
/usr/bin/time -v python3 -c 'import pyvips
img = pyvips.Image.new_from_file("large-5814x3876.jpg", access="sequential")
img = img.crop(581, 388, 4652, 3100).resize(0.9).sharpen()
img.write_to_file("out-pipeline-vips.jpg", Q=85)'
# 2. Multi-step pipeline: ImageMagick (crop + resize + sharpen)
/usr/bin/time -v magick large-5814x3876.jpg -crop 4652x3100+581+388 +repage -resize 90% -sharpen 0x1 -quality 85 out-pipeline-magick.jpg
# 3. Single-step heavy encoder bottleneck: libvips to WebP (Q=82, effort=6)
/usr/bin/time -v vips copy input.jpg[access=sequential] out-vips.webp[Q=82,effort=6]
# 4. Single-step heavy encoder bottleneck: ImageMagick to WebP (quality=82, method=6)
/usr/bin/time -v magick input.jpg -quality 82 -define webp:method=6 out-magick.webp
Workload and tool Median time P95 time Median peak RSS Relative result
22.5 MP multi-step pipeline: libvips (pyvips sequential) 327.1 ms 340.3 ms 174.8 MiB 3.8× faster and 2.6× lower peak RSS than ImageMagick
22.5 MP multi-step pipeline: ImageMagick (Q16-HDRI) 1240.8 ms 1296.4 ms 462.7 MiB Each stage reads and writes a full-image Q16-HDRI pixel cache
2.5 MP heavy encode: vips copy → WebP (effort=6) 499.7 ms 554.9 ms 71.1 MiB Runtime is dominated by the underlying libwebp encoder
2.5 MP heavy encode: magick → WebP (method=6) 479.9 ms 501.0 ms 61.5 MiB Calls the same libwebp encoder at method=6; runtime is effectively tied (~1.0×)

Takeaway: On a multi-step geometry and convolution pipeline where shrink-on-load does not apply, demand-driven streaming gives libvips a 3.8× speed advantage and a 2.6× reduction in peak RSS. When the workload is dominated by a heavy encoder such as WebP at effort=6, both tools spend almost all of their CPU time inside libwebp and finish within 4% of each other.

Scenario C: Testing the “constant memory” claim across width, threads, and random access

Section titled “Scenario C: Testing the “constant memory” claim across width, threads, and random access”

To test whether libvips memory stays constant, compare sequential horizontal flips against a 90° clockwise rotation (rot d90) across 1938×1292 (2.5 MP) and 5814×3876 (22.5 MP), while varying VIPS_CONCURRENCY:

终端窗口
# 1. 2.5 MP sequential horizontal flip (single worker thread: VIPS_CONCURRENCY=1)
/usr/bin/time -v env VIPS_CONCURRENCY=1 vips flip input.jpg[access=sequential] out-small-seq.jpg[Q=85] horizontal
# 2. 22.5 MP sequential horizontal flip (single worker thread: VIPS_CONCURRENCY=1)
/usr/bin/time -v env VIPS_CONCURRENCY=1 vips flip large-5814x3876.jpg[access=sequential] out-large-seq-1t.jpg[Q=85] horizontal
# 3. 22.5 MP sequential horizontal flip (default 16 logical cores)
/usr/bin/time -v vips flip large-5814x3876.jpg[access=sequential] out-large-seq-16t.jpg[Q=85] horizontal
# 4. 22.5 MP 90-degree rotation (random access; materialises the decoded image)
/usr/bin/time -v vips rot large-5814x3876.jpg out-large-rot90.jpg[Q=85] d90
Scenario configuration Median time P95 time Median peak RSS Why memory is not constant
2.5 MP sequential flip (VIPS_CONCURRENCY=1) 67.0 ms 70.0 ms 46.5 MiB Narrow 1938-pixel row buffers and a single worker thread
22.5 MP sequential flip (VIPS_CONCURRENCY=1) 245.2 ms 298.3 ms 118.4 MiB Wider 5814-pixel scanlines and codec buffers raise peak RSS to 118.4 MiB
22.5 MP sequential flip (default 16 threads) 302.3 ms 312.0 ms 168.3 MiB 16 worker threads each hold tile and line buffers, adding 49.9 MiB
22.5 MP 90° rotation (random access materialisation) 283.1 ms 313.8 ms 151.2 MiB Reading columns vertically requires decoding and buffering the full 22.5 MP image

Takeaway: libvips memory use is not a fixed constant. Even in sequential mode, RSS grows with image width and thread count (VIPS_CONCURRENCY); when an operator requires random access (such as a 90° rotation or vertical flip), libvips must materialise the full decoded image. See Sequential access, random access, and materialisation for the underlying access rules.

Scenario D: Single-process CLI startup vs long-running govips service

Section titled “Scenario D: Single-process CLI startup vs long-running govips service”

Generating a 400×267 thumbnail from 1938×1292 via the vips CLI includes process creation, dynamic linking, GObject type registration, and teardown on every call. Compare that against an initialised govips v2.19.0 process (ConcurrencyLevel: 4, operation cache disabled, 5 warmup runs followed by 50 measured runs):

Measurement path Median latency P95 latency Peak RSS OpenImageRefs() (baseline → after)
vips thumbnail input.jpg out.jpg[Q=85] 400 (single CLI process) 59.10 ms 63.40 ms 53.7 MiB Not applicable (process exits after one image)
govips v2.19.0 in-process NewThumbnailFromFile + ExportJpeg 14.38 ms 18.10 ms 64.4 MiB 0 → 0 (Go heap allocation ~1.70 MiB)

Takeaway: On a 2.5 MP image, CLI process startup costs more than three times the actual thumbnailing work. Do not estimate microservice throughput by dividing 1 second by a single CLI /usr/bin/time run.

When libvips will not be significantly faster or lighter

Section titled “When libvips will not be significantly faster or lighter”

Set realistic expectations during architecture reviews when any of these four conditions apply:

  1. The bottleneck is inside a heavy encoder: Transcoding to AVIF, JPEG XL, or high-effort WebP spends most CPU cycles inside libaom, libheif, libjxl, or libwebp. Switching the outer pipeline from ImageMagick to libvips does not make the underlying codec algorithm faster.
  2. The first stage requires full-frame random access: Arbitrary-angle rotation, 90° rotation, global histogram operations, or reading the same non-seekable stream twice forces libvips to materialise the decoded image in memory or on disk.
  3. Every request spawns a fresh CLI subprocess: On small and medium inputs, process startup and library initialisation dominate wall-clock time. In addition, if an ImageMagick build lacks a write delegate for a format such as AVIF, running magick input.jpg out.avif can silently fall back to writing JPEG data into an .avif filename—producing a deceptively fast benchmark. Always inspect outputs with vipsheader or file.
  4. Request concurrency multiplies internal thread pools: On a 16-core node, handling 16 concurrent HTTP requests while leaving libvips at its default 16 worker threads creates up to 256 active threads competing for CPU caches and per-thread tile buffers.

Building a reproducible govips benchmark and observability harness

Section titled “Building a reproducible govips benchmark and observability harness”

The complete program below measures in-process govips v2.19.0 latency and memory boundaries: it pins ConcurrencyLevel, disables the operation cache so repeated runs actually execute the pipeline, separates warmup from measured iterations, and reports P50/P95 latency, peak RSS, Go heap usage, and vips.OpenImageRefs():

package main
import (
"fmt"
"log"
"os"
"runtime"
"sort"
"syscall"
"time"
"github.com/davidbyttow/govips/v2/vips"
)
func thumbnailOnce(inputPath string) (int, error) {
image, err := vips.NewThumbnailFromFile(inputPath, 400, 267, vips.InterestingNone)
if err != nil {
return 0, fmt.Errorf("thumbnail: %w", err)
}
defer image.Close()
params := vips.NewJpegExportParams()
params.Quality = 85
encoded, _, err := image.ExportJpeg(params)
if err != nil {
return 0, fmt.Errorf("export jpeg: %w", err)
}
return len(encoded), nil
}
func main() {
if len(os.Args) != 2 {
log.Fatalf("usage: %s <input.jpg>", os.Args[0])
}
inputPath := os.Args[1]
// Pin worker concurrency and disable the operation cache so repeated runs do not hit cached results.
vips.LoggingSettings(nil, vips.LogLevelError)
err := vips.Startup(&vips.Config{
ConcurrencyLevel: 4,
MaxCacheSize: 0,
MaxCacheMem: 0,
MaxCacheFiles: 0,
})
if err != nil {
log.Fatalf("start libvips: %v", err)
}
defer vips.Shutdown()
baselineRefs := vips.OpenImageRefs()
const warmupRuns = 5
for i := 0; i < warmupRuns; i++ {
if _, err := thumbnailOnce(inputPath); err != nil {
log.Fatalf("warmup %d: %v", i, err)
}
}
const measuredRuns = 50
durationsMs := make([]float64, 0, measuredRuns)
var lastBytes int
for i := 0; i < measuredRuns; i++ {
start := time.Now()
n, err := thumbnailOnce(inputPath)
if err != nil {
log.Fatalf("run %d: %v", i, err)
}
lastBytes = n
durationsMs = append(durationsMs, float64(time.Since(start).Microseconds())/1000.0)
}
sort.Float64s(durationsMs)
medianMs := (durationsMs[measuredRuns/2-1] + durationsMs[measuredRuns/2]) / 2.0
p95Ms := durationsMs[int(float64(measuredRuns)*0.95)-1]
var usage syscall.Rusage
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil {
log.Fatalf("getrusage: %v", err)
}
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
afterRefs := vips.OpenImageRefs()
fmt.Printf(
"runs=%d outputBytes=%d median=%.2fms p95=%.2fms peakRSS=%.1fMiB goHeap=%.1fKiB openRefs=%d->%d\n",
measuredRuns,
lastBytes,
medianMs,
p95Ms,
float64(usage.Maxrss)/1024.0,
float64(memStats.HeapAlloc)/1024.0,
baselineRefs,
afterRefs,
)
if afterRefs != baselineRefs {
log.Fatalf("unclosed ImageRef detected: baseline=%d after=%d", baselineRefs, afterRefs)
}
}

When running this harness in CI or load tests, keep three rules in place:

  • Disable the operation cache (MaxCacheSize: 0, MaxCacheMem: 0) when measuring raw operator cost, or repeated iterations on the same file will return cached results and understate CPU work.
  • Track OpenImageRefs(), Go heap, and process RSS separately: reference counts verify that every ImageRef was closed, but they do not measure allocator retention or RSS. See govips lifecycle in production and Growing RSS and suspected memory leaks.
  • Verify output format and dimensions after every benchmark run so silent codec fallbacks or misplaced CLI flags never enter capacity reports.