Benchmarking, ImageMagick comparison, and production observability
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:
- 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. - Shrink-on-load changes the work performed:
vips thumbnailautomatically enableslibjpegdiscrete cosine transform (DCT) block scaling during decode, skipping unneeded high-frequency coefficients. Comparingvips thumbnailagainstmagick -resizewithout-define jpeg:size=...(or with-defineplaced in the wrong argument position) measures DCT pre-scaling against a full 22-megapixel decode rather than comparing equivalent resize algorithms. - 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×1292sRGB 8-bit JPEG (2.5 MP, 610,191 bytes). - High-resolution derivative
large-5814x3876.jpg: a deterministic5814×3876sRGB 8-bit JPEG (22.5 MP, 7,196,272 bytes) generated frominput.jpgwith:
- Base image
vips replicate input.jpg large-5814x3876.jpg[Q=90] 3 3Scenario 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 pyvipsimg = 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:
- The bottleneck is inside a heavy encoder: Transcoding to AVIF, JPEG XL, or high-effort WebP spends most CPU cycles inside
libaom,libheif,libjxl, orlibwebp. Switching the outer pipeline from ImageMagick to libvips does not make the underlying codec algorithm faster. - 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.
- 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.avifcan silently fall back to writing JPEG data into an.aviffilename—producing a deceptively fast benchmark. Always inspect outputs withvipsheaderorfile. - 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 everyImageRefwas 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.
- See why demand-driven evaluation avoids full-frame intermediate buffers: The libvips demand-driven execution model
- Review what triggers full-image materialisation: Sequential access, random access, and materialisation
- Enforce process and per-image cleanup rules: govips lifecycle in production
- Separate handle leaks from allocator retention: Growing RSS and suspected memory leaks