This is a series of posts on building imgplex, best read in order:
Part 1 - The why, what, and how of imgplex
Part 2 - Getting things up and running
Part 3 - The node definition system
Part 4 - Executing the node graph, making it fast
Part 5 - Two graphs in one
Part 6 - Multiple inputs and outputs, processing images as sets
Part 7 - Profiling and Optimization
From graph to commands
With the graph editor and node definitions working, I could start on the execution side: resolving a connected graph into ImageMagick commands and running those commands on the input images.
Before executing the graph, the engine sorts the nodes so that every node runs after its dependencies. I used Kahn’s algorithm for this. If the sort cannot include every node, the graph contains a cycle and processing stops before any ImageMagick commands are launched. This is the same problem a shader graph solves: evaluate the leaf nodes first and work toward the output.
Once the order is established, the engine walks the sorted list and resolves each
node’s parameters. Value nodes are resolved before image operations. By the time the
engine reaches an image node, its wired and local parameters have been reduced to
ordinary values that can be inserted into the command. For standard image nodes,
those resolved parameters become ImageMagick arguments and are passed to a magick
process.
The preview pipeline
The preview only evaluates the ancestors of the selected node. Nodes downstream of it, along with unrelated branches, are left out of the execution plan. It also processes only the image currently selected in the filmstrip.
Also, the input is small. Rather than spawning a fresh process to produce a
preview-resolution copy of the source, the preview pipeline reuses the thumbnail that
was already generated when the image was imported - a WebP capped at 256px (user
configurable) on its longest edge. I used lossy WebP thumbnails because they were
quick enough to generate and retained enough detail for the kinds of adjustments
shown in the preview. The quality setting is normally between 70 and 80 percent. That
thumbnail already exists in the cache, so the preview starts from it directly and
skips a magick spawn entirely on every single preview cycle. At 256 pixels,
operations such as brightness adjustment and crop framing are much cheaper to
evaluate than on a full-resolution texture.
The preview also caches each node’s output, keyed by a hash of that node’s inputs and parameters. When a node changes, the cache invalidates its output and the outputs of nodes that depend on it. Cached results for upstream nodes and unrelated branches remain valid. An 80 ms debounce also prevents continuous slider input from starting overlapping preview runs.
The spawn-cost problem
The main performance problem in the batch pipeline was process creation. On Windows, starting a magick process has a fixed cost even when the operation itself is small. That cost became significant when multiplied across every node and every image.
My first implementation launched one magick process for every image node. A
five-node graph processing 2,000 images therefore produced 10,000 process launches.
Getting rid of that overhead took a few things working together.
The first is command fusion. ImageMagick can apply several operations in one
invocation, so adjacent standard nodes are combined into one argument list. It walks
a chain of consecutive standard operations and accumulates them lazily into one
argument list, only actually spawning magick when it hits something that forces a
break: a branch where the image feeds two consumers, or a format change. A long
linear chain of nodes collapses into a single process launch. Even channel splitting,
which pulls the R, G, B, and A channels out as separate images, is done in one
magick call rather than four.
The second is about the moments when a chain does have to break and write an intermediate file to disk. The first version wrote intermediate images as PNG. That added PNG encoding and decoding at every break in the command chain, even though the files were temporary. I changed the intermediate format to MIFF, ImageMagick’s native format, to avoid that compression work. Only the final output is encoded to the user-selected format.
The batch processor also runs several images concurrently. Its default worker count is derived from the available CPU cores, and each worker takes the next image from the queue. The one subtlety here is that ImageMagick has its own internal multithreading, so if you run N workers and let each one spin up a full thread pool, you oversubscribe the CPU and everything gets slower - something I learned the hard way. imgplex now limits the number of ImageMagick threads assigned to each worker so that the combined thread count does not exceed the available CPU cores.
Avoiding repeated graph evaluation
Most of the time, the operations applied to every image are identical - the same resize, the same adjustment, the same conversion. For graphs whose parameters do not depend on the current image, the engine can reuse the same execution plan across the whole batch. That’s the fast path, and it skips per-image parameter evaluation entirely.
When every image uses the same operations and parameter values, the engine builds the plan once and reuses it for the rest of the batch. A Properties node can make the plan image-dependent. In that case, the relevant values are resolved again for each input image.
Not every property has the same cost. Filename, path, and file size come from the filesystem, while dimensions and bit depth require reading the image data through ImageMagick. I had mistakenly classified the file-size property as requiring full image metadata. As a result, every image launched two magick identify processes to retrieve a value that was already available through the filesystem. Correcting that classification reduced a 3,720-image test from roughly two minutes to about 2 seconds. These numbers came from my development machine and are intended to show the relative difference rather than provide a general benchmark. After finding that bug, I made the distinction explicit in the property definitions: filesystem values use Node.js APIs, while pixel-dependent values go through ImageMagick.
Import performance
The import pipeline initially launched ImageMagick separately to inspect and generate a thumbnail for each file. That made folders containing a few thousand images noticeably slow to open.
For PNG, JPEG, BMP, WebP, and TGA files, imgplex now reads dimensions directly from the file header. Thumbnail generation groups eight files into each ImageMagick invocation, and several groups can be processed concurrently.
The resulting thumbnails are stored in a disk cache keyed by the source path and modification timestamp. If a file has not changed, reopening the folder can reuse the existing thumbnail.
On my test folder of roughly 2,000 mixed PNG, TGA, JPEG, and PSD files, these changes reduced import time from about 44 seconds to about 2 seconds.
Non-fatal batch errors
A failed image does not stop the rest of the batch. imgplex records the error, continues with the remaining files, and includes the failures in the final summary.
The same information is written to a timestamped log beside the output. This made it possible to inspect failed files afterward without losing the results from the rest of the batch.
Next post in this series: Building imgplex: part 5 - Two graphs in one