Fastvideo JPEG2000 vs nvJPEG2000 on RTX 4090
Updated:
This is a comparison of two JPEG2000 codecs on a GPU. The Fastvideo fvJPEG2000 and the NVIDIA nvJPEG2000 codecs were tested on an RTX 4090 using the same images and compressed file size. The Fastvideo codec was 3.9 to 6.6 times faster at encoding. The two codecs performed similarly in terms of decoding, with no more than an 8% difference in either direction. Full information on the 2K and 4K encoding and decoding benchmarks, the profiler breakdown, and all the information needed to reproduce these measurements is provided below.

Final results: fvJPEG2000 versus nvJPEG2000 on an RTX 4090
RTX 4090, the same compressed file size for both codecs, three runs per point. The main numbers — speed, energy and the quality checks — come from a single run. The structural quality measures (section 9), PCRD mode (section 10), and GPU memory (section 11) were measured in separate runs; in each case this is stated on the spot.
At encoding fvJPEG2000 is faster, and by a wide margin. 1914 frames per second against 292 fps on 2K lossy, 616 fps against 160 fps on 4K. The gap is from 3.9 to 6.6 times depending on the frame and the mode. The typical task is to compress a data stream as quickly as possible, and encoder performance is the key factor here.
At decoding both codecs have the same performance. 1024 frames per second for fvJPEG2000 against 1033 fps on 2K lossy, and 436 fps against 438 fps on 2K lossless — under a percent apart, and both times are in favour of nvJPEG2000. On 4K the gap is larger and does go both ways: 8 % either way — on lossy nvJPEG2000 is ahead, 428 fps against 394 fps; on lossless fvJPEG2000 is ahead, 145 fps against 134 fps.
Single-frame latency. In single image mode, we measure the time taken to process one frame rather than the amount of data that can be processed in a given time. In this case, the nvJPEG2000 encoder is 1.5 to 2.5 times faster, achieving 2.6 milliseconds for 2K lossy compression compared to 5.1 milliseconds. When it comes to decoding a single frame, nvJPEG2000 is 1.5 to 2.1 times faster.
Below you can see how this was measured, why it comes out this way and how to reproduce it by yourself.
What is fvJPEG2000 compared against? nvJPEG2000 under our own wrapper, rather than the standard NVIDIA sample.
Which J2K codecs we have compared. We compared the fvJPEG2000 codec against the nvJPEG2000 library running under our own wrapper, with CPU multithreading, batch emulation, and the optimal combination of parameters we've searched over (section 4.3). The standard sample application from NVIDIA processes frames one-by-one and delivers less performance. Below you can see the performance we've got with our fvJPEG2000 codec, our nvJPEG200 wrapper, and nvJPEG2000 sample app. The speedup is shown in comparison with the nvJPEG2000 sample app.
Single image mode in the tables of sections 6 and 7 is what the standard NVIDIA sample does. This is measured, not assumed: with the same way of loading frames, the NVIDIA sample and our wrapper come out 1 to 3 % apart, and the compressed files they produce match byte for byte (appendix).
| Speedup of nvJPEG2000 with our wrapper | Encoding | Decoding |
|---|---|---|
| 2K, lossy | 1.47x | 3.47x |
| 2K, lossless | 1.28x | 1.85x |
| 4K, lossy | 1.25x | 2.22x |
| 4K, lossless | 1.14x | 1.47x |
The table shows how many times faster the best combination of threads and batch is than single image mode of the same library. Both values were measured by our own wrapper, on one card, on the same files, and the only difference between them is the way the frames are fed (section 4.2). So the margin of fvJPEG2000 reported in this article is measured against the best that could be obtained from nvJPEG2000, not against the usual way of running it.
One caveat, and it works against us. The single image mode is measured without transferring frames over the bus, while the best combination is measured together with it (section 4.2). Therefore, the gain from our wrapper shown here is underestimated rather than overestimated: single image mode is credited with doing less work than it actually does.
How does this affect the comparison between the two codecs? At encoding fvJPEG2000 is 3.9 to 6.6 times faster; had the comparison been against the standard NVIDIA sample, the difference would have been even greater, at 4.8 to 9.7 times faster. At decoding, the difference is even greater: section 7 reports the two codecs as being on a par, but against the standard sample, fvJPEG2000 would have been 1.6 to 3.4 times faster. In other words, the margin reported in this article is measured against the best possible performance of nvJPEG2000, rather than the standard way of running it.
1. Which JPEG2000 codecs are compared and why
There are currently several JPEG2000 codec implementations for the GPU, both commercial and open source. This article compares the two codecs that engineers most often have to choose between.
The first codec comes from the nvJPEG2000 library by NVIDIA. The library is free, but it's shipped separately from the CUDA Toolkit: it can be downloaded from the NVIDIA site or installed from a Python package. In the rest of the article this codec is referred to by its full name; in tables it is shortened to NV.
The second one is the JPEG2000 codec from Fastvideo, referred to below as fvJPEG2000 and shortened to FV in the tables. It's shipped as part of the Fastvideo SDK and it's licensed commercially.
Both codecs use CUDA and run on NVIDIA GPUs. The article's goal is to compare what can be achieved with two different versions of the same JPEG2000 standard using the same images and the same hardware. The whole computer matters, not just the GPU. Some of the work of a codec runs on the CPU. The instructions explain how to measure and set the codec settings so that you can reproduce them.
Why these two codecs?
There are others, both on CPU and on GPU. We didn't test either of them here. We didn't ask their developers for permission, and we're not going to interpret their license terms. The procedure for measuring performance is published here. Anyone who has a license for these products can run the same tests and publish their own results.
There is also OpenJPEG, an open source implementation, but it runs on the CPU and the performance gap against a GPU is very large. That codec will be added to the comparison soon.
One might ask, "Why pay for a J2K codec when NVIDIA already offers a free alternative?" Pictures and promises do not answer that question. These measurements are successful when readers can recreate them using their own graphics processing unit (GPU) and images. This article explains how to organise such measurements and what they demonstrate.
The idea of the article in one sentence
What matters here is not the performance results but the method by which they were obtained. The results change with each new version of the driver, the library, and the GPU. The process behind the results lasts much longer. The article is designed to be a manual. It explains how to test two different codecs under the same conditions. It also explains how to measure speed and why there are at least four modes. The article describes what goes into the measured time and how to ensure that the decoder successfully restored the image.
There are three rules to keep in mind, which we will use in every section below.
- Codecs have to be compared against the same result, not with the same value of the quality parameter. Quality scales differ from codec to codec, and the common criteria of measurement becomes the size of the compressed file in bytes. On top of that, the quality of the restored images has to be controlled as well — only together these two conditions make the comparison correct.
- The speed of encoding in frames per second (fps) is meaningless unless you also state the operating mode and codec parameters. The processing time of a single frame and the overall throughput while processing a stream are different values. They can differ by several times. For example, for the fvJPEG2000 encoder on a 2K frame, moving from the single image mode to the best combination of threads and batch gives a fivefold increase, and for the decoder, a sevenfold one.
- Speed alone is not enough for a comparison. Each measurement is done in three steps: the image is first encoded, then decoded, and then compared to the original.
If the reader only remembers this, the article has done its job. Even if the specific results change by then, that's okay.
This work is the first part of a larger topic. The plans for it are collected in section 16, and the open project into which the method moves from the article into code is in section 14.
2. Source images: 2K and 4K frames for the JPEG2000 codec test
The first rule says that we need to compare the codecs using the same result. The result depends completely on the input, so we have to start with the images.
The measurements use two images that are publicly available and have been used in public JPEG2000 benchmarks. The files can be downloaded and run locally: 2k_wild.ppm and 4k_wild.ppm. These are normal pictures with a lot of detail. They have smooth areas and fine texture. The quality of the material is determined by the amount of material in the frame.
| File | 2k_wild.ppm |
4k_wild.ppm |
|---|---|---|
| Resolution | 1920 × 1080 | 3840 × 2160 |
| Channels | 3 | 3 |
| Bit depth | 8 bit | 8 bit |
| Size, MB | 5.93 | 23.73 |
The PPM format was chosen deliberately: it is an uncompressed file with a minimal header — format, dimensions, maximum sample value (in effect, the bit depth) — followed immediately by the image data. Such a file is read quickly and easily, and both codecs get exactly the same bytes as input: no difference in unpacking the source, no influence of third-party libraries.
This set covers some topics and excludes others. Two resolutions are enough to see the main thing: how behavior changes once the frame is no longer small for the GPU. A 2K frame doesn't load an RTX 4090 completely, but a 4K frame does. This makes a big difference.
The set doesn't cover this, but both codecs can do it. The bit depth should be above eight bits per channel. Both fvJPEG2000 and nvJPEG2000 can work with data up to 16 bits per channel. This is exactly what is used for medical imaging and satellite imagery. JPEG2000 is usually chosen for these areas. These tests do not cover codec behavior at 12 and 16 bits. A separate data set and analysis are required for this. The same is true for 8K frames and larger, multi-tile images, and monochrome material.
A separate caveat about the method. The measurements are arranged as "one frame repeated N times", not "N different frames". A 2K frame takes 5.9 MB, a 4K frame 23.7 MB, and both fit entirely into the level 3 cache of a modern CPU. So after the first iteration the source data comes from the cache, not from RAM. For estimating the speed of the algorithm itself this is correct — we measure the codec, not the memory subsystem — but it is not the same as processing a folder of different files.
3. How the compression parameters were chosen to test codecs
This is the most important part of the work: whether the results mean anything at all depends on it.
3.1. Common denominator
The two codecs can do different things. A comparison is only possible if both systems use the same compression settings. All settings must be specified clearly on both sides. Default values may be different.
| Compressed file parameter | FV | NV |
|---|---|---|
| File format | JP2 | stream_type = STREAM_JP2 |
| Wavelet, lossy | -a irrev (CDF 9/7) | irreversible = 1 |
| Wavelet, lossless | -a rev (CDF 5/3) | irreversible = 0 |
| Code-block size | -c 32 | code_block_w = code_block_h = 32 |
| Resolution levels | -l 6 | num_resolutions = 6 |
| Quality layers | 1 | num_layers = 1 |
| Progression order | LRCP | prog_order = LRCP |
| Color transform | enabled | mct_mode = 1 |
| Chroma subsampling | 4:4:4 | full-size components |
| Tiles | disabled | enable_tiling = 0 |
| SOP and EPH markers | disabled | enable_SOP/EPH_marker = 0 |
| Precincts | default | num_precincts_init = 0 |
Four of these rows are not our choice but a constraint, and that is worth saying outright.
Code-block size 32×32. fvJPEG2000 supports 16×16, 32×32 and 64×64, nvJPEG2000 only 32 and 64, so 16×16 drops out of the comparison. Of the two that remain we took 32×32: it allows a higher degree of parallelism on the GPU.
One quality layer. In nvJPEG2000 the number of layers can only be one — the interface accepts no other value. In the fvJPEG2000 encoder there is also one layer. So per-layer quality is left out of the comparison.
Progression order LRCP. The fvJPEG2000 encoder produces only LRCP; its decoder understands all five. nvJPEG2000 can do all five when encoding. The common denominator is LRCP.
SOP and EPH markers disabled. In nvJPEG2000 they must be disabled, they cannot be turned on. Accordingly they are disabled in fvJPEG2000 as well.
Chroma subsampling 4:4:4. Both codecs also support 4:2:2 and 4:2:0, but the mode without chroma loss was taken for the comparison: it does not add yet another variable to the tests and is equally available to both sides.
3.2. The two codecs have different quality scales
Here the two codecs have to be brought to a common approach, and they do not offer the same number of ways to control the loss.
fvJPEG2000 has two, and they can work together. The first is the quality scale q from 0 to 100: it controls quantization, that is, how coarsely the wavelet coefficients are rounded. The file size then comes out as a consequence. The second is PCRD mode (Post-Compression Rate-Distortion, the -cr option): you give it the compression ratio you need, and the encoder discards the least significant bits of the code blocks until the compressed frame fits the size that ratio implies. Here it is the other way round: the size is set, and the quality comes out as a consequence. The two ways could be combined: first quantization at the given q, then PCRD down to the given compression ratio.
nvJPEG2000 has three: a target signal-to-noise ratio, a quantization step, or a Q-factor on a 1–100 scale. All three tell the encoder how coarsely to encode. A target for file size or compression ratio is not among them.
So there is exactly one common ground: the quality scale. In fvJPEG2000 it is q, and the Q-factor from nvJPEG2000 is working alike. Sections 6–9 are built on it: fvJPEG2000 encodes at q = 85, and nvJPEG2000 searches for the Q-factor that gives a file of the same size. PCRD mode is off in those measurements: nvJPEG2000 has no such mode at all. How it affects encoding speed is measured separately, in section 10.
You cannot simply set 85 for both codecs and get the same compression: these are different scales, and the files will come out different in size. And if the sizes of the compressed frame differ, the codecs also do different amounts of work, and any speed comparison loses its value.
How the fvJPEG2000 scale behaves on these two images:
Quality q |
2K ratio | 2K file, kB | 4K ratio | 4K file, kB |
|---|---|---|---|---|
| 80 | 14.2:1 | 429 | 27.7:1 | 878 |
| 83 | 11.8:1 | 517 | 22.5:1 | 1078 |
| 85 | 10.3:1 | 588 | 19.5:1 | 1246 |
| 87 | 9.1:1 | 671 | 16.8:1 | 1449 |
| 90 | 7.3:1 | 828 | 13.2:1 | 1847 |
Note that at the same value of q the compression ratio of the two frames differs by almost a factor of two. This is not an error and not a quirk of the codec. The two frames are different images, and there is no point in comparing their compression ratios with each other: at a given quality the compression ratio is determined by the content of the frame. The point is a different one: the same value of q does not give the same file size.
3.2.1. File size is a result, not a value you set
The quality parameter does not fix the file size, it fixes the quantization scheme. The encoder decomposes the image with a wavelet transform and quantizes the coefficients — the more coarsely, the lower the quality. How many bytes come out of that depends on the quality factor and on the content of the frame. File size here is not a parameter but a result.
To see this clearly, it helps to count not the compression ratio but bits per pixel: how many bits on average are needed to encode one pixel of the image. The source data is 24 bits per pixel, that is, eight bits per channel.
| Mode | 2K, bpp | 4K, bpp |
|---|---|---|
| Lossy, q 85 | 2.32 | 1.23 |
| Lossless | 11.44 | 8.65 |
What follows from this in practice.
First, the phrase "compression 20:1" means nothing without stating the image. The same value of the quality parameter on another frame will give another compression ratio. When codecs are compared somewhere "at 20:1 compression", the first question is: measured on what, exactly?
Second, a value of the quality parameter cannot be carried from one project to another and expected to give the same file size. If a fixed size is exactly what is needed — for example, to fit into a given bandwidth or into storage capacity — then what is required is not a quality parameter but bitrate control, which reduces the size of the compressed frame to the required value. In fvJPEG2000 this is done by PCRD mode (section 3.2); it is not used in this comparison, and how it affects speed is in section 10.
Third, this is exactly why the comparison of the two codecs is built on matching the size of the output file, not on matching the value of the quality parameter. Otherwise one of the sides would be doing less work, and any comparison of encoding/decoding speed would be meaningless.
3.3. We compare at the same compressed file size
Why can’t two codecs be compared at the same quality setting?
This is because their quality scales are different: the same number in the quality parameter gives a different file size and a different amount of distortion. The comparison has to be made not at the same value of the parameter but at the same result, which is compressed file size in bytes. The amount of work depends directly on it: if one codec produces a smaller file, it also does less work, and the codec that simply compressed harder will look faster. Size in bytes is convenient for another reason too: it is defined unambiguously.
The procedure is as follows. fvJPEG2000 encodes the reference file at quality 85. Then nvJPEG2000 searches for its Q-factor by bisection: it encodes, looks at the size, moves the boundary, repeats — until it hits the target to within one tenth of a percent.
The result of the search:
| Image | 2K lossy | 4K lossy |
|---|---|---|
| Target, bytes | 601,703 | 1,275,547 |
| Q found | 87.29 | 87.14 |
| Result, bytes | 601,940 | 1,274,517 |
| Deviation | 0.04% | 0.08% |
The sizes are matched up to one tenth of a percent, so both codecs have the same amount of work to do.
How much the mapping between the two scales depends on the frame? The values 87.29 and 87.14 are very close, which suggests that the two quality scales map onto each other by a constant factor. This was worth checking: if it were so, the value found could be reused from image to image.
The check was done separately: the same search was repeated with a tolerance twice as strict (0.05% by size), at three quality levels and from two different initial search intervals — [1, 100] and [50, 99]. The second is needed to separate a property of the codecs from an artifact of the search procedure itself. Because of the stronger tolerance the values found here differ slightly from the table above: for 4K at quality 85 it is 87.17 here against 87.14 there.
| FV quality | Equivalent for 2K | Equivalent for 4K | Difference |
|---|---|---|---|
| 80 | 74.86 | 74.77 | 0.10 |
| 85 | 87.29 | 87.17 | 0.12 |
| 90 | 94.63 | 94.56 | 0.07 |
The conclusion: the two quality scales are different, but they are very close to each other, and finding the exact correspondence is outside the scope of this work. It may well depend on the content of the frame. The value found carries over to another image as a good first approximation, but a search for a specific file size still has to be done again. That is exactly why in the procedure the search is done for each image separately, and not once for the whole set.
In lossless mode there is nothing to search for: there is no quality parameter there at all, and both codecs must produce a file that decodes back exactly. Below are the compressed file sizes for all four combinations:
| Image | FV file, kB | NV file, kB | Ratio |
|---|---|---|---|
| 2K lossy | 588 | 587 | 10.3:1 |
| 4K lossy | 1246 | 1244 | 19.5:1 |
| 2K lossless | 2896 | 2896 | 2.1:1 |
| 4K lossless | 8754 | 8754 | 2.8:1 |
A compression ratio of about 2:1 for a lossless compression algorithm is a usual value for JPEG2000 on photographic material, and it matches what we've got with RAW compression.
4. Method: JPEG2000 codec speed is not a single number
This section is about the second rule: a speed in frames per second means nothing unless the mode is stated.
The same codec on the same GPU can produce different speed values. The difference isn't in how the measurement is taken; it's in how the work is organized. When comparing these two options, it's important to consider not only how many frames per second are produced, but also the method used to compute that value.
4.1. Measurement modes
- Single image mode means processing of just one image. It could be done once or repeated many times (the
-repeatoption for fvJPEG2000). The next frame starts only after the previous one is fully finished, the work runs in one thread and there is no overlap between frames: the time is averaged over thousands of runs. This gives the processing time of a single frame — exactly the value you need when response time matters; over a large number of runs it repruduces the result within one percent of precision. - Batch mode. Several frames are utilized to create one larger virtual frame, so that this large frame is loaded into the GPU for processing in one go. The number of processed frames at the output stays the same, because this is a virtual procedure. There are no separate measurements for this mode in the article: it never turns out to be the fastest one, so in the tables of sections 6 and 7 batching always goes together with threads.
- Multithreaded mode, several threads. Several CPU threads, each with its own codec instance and its own queue of GPU jobs (CUDA stream). Processing of different frames on the GPU overlaps, and the processing speed goes up.
- Multithreaded mode with batching. In addition, several frames are combined into one larger virtual frame, so that more data is loaded into each processing thread at once. This is the fastest mode.
The first mode answers the question "how much time is needed to process one frame", the others answer "how many frames per second can we process". These are totally different values: in multithreaded mode and in batch mode the latency of a single frame processing is worse than in single image mode — that is the price of higher overall throughput.
Several GPUs at once are not covered here: we discuss just the case for a single GPU performance.
How much this matters: with fvJPEG2000 encoding 2K lossy, single image mode gives 381 frames per second and the best combination of threads and batch gives 1914 fps, five times more. With nvJPEG2000 on the same task it is 198 fps and 292 fps, that is one and a half times. One GPU, the same frame, the same compressed file size — and the answer to "how many frames per second" depends on the mode individually for these two codecs.
4.2. What is included in the measured time and what is not
The second question after the mode is what exactly falls inside the measured time.

The rule depends on the mode, and that has to be said outright.
In single image mode the timer runs from the data where the codec picks it up to the result on the other side: for the encoder, from the source frame in GPU memory to the compressed image in host memory; for the decoder, from the compressed image in host memory to the reconstructed frame in GPU memory. The transfer of the pixels themselves over the bus is outside the count — on neither side.
In multithreaded mode the timer runs from host memory to host memory in both directions: the transfers over the bus are inside the measured time for both codecs.
Why the difference. In single image mode frames go one at a time, and the codec itself reports the time of each of its stages — it can be measured from the inside. In multithreaded mode work on several frames runs on the GPU at once, and the time of one stage of one frame cannot be separated from the work on its neighbours; the only observable fact is that a frame has been processed whole. So the boundaries can only be external ones: when the data left host memory and when the result came back to it.
What time is measured. All the work of the codec, all its stages one after another: for the encoder this is data preparation and color transform, the wavelet transform, quantization and EBCOT Tier-1 on the GPU, then the transfer of the result to the CPU and Tier-2 — building the compressed image; for the decoder it is the same in reverse order. The fact that part of the work runs on the CPU is not a flaw of the measurement but a property of JPEG2000: not every stage of it can be parallelized efficiently. The heaviest stage, EBCOT Tier-1, is computed on the GPU, while Tier-2 — building the compressed image out of the finished packets when encoding and parsing its structure when decoding — is arranged differently in the two codecs, and what is known about it differs as well.
In fvJPEG2000 Tier-2 runs on the CPU, both when encoding and when decoding.
In the nvJPEG2000 decoder it runs on the CPU as well, and the NVIDIA documentation states this directly: "Tier 2 decode stage (first stage of decode) is run on the CPU. All other stages of the decoding process are offloaded to the GPU" (the nvJPEG2000 documentation). In the program this is the nvjpeg2kStreamParse step: it takes the compressed image from host memory and parses its structure.
For the nvJPEG2000 encoder the documentation makes no such definite statement. All it says is that the library uses both the GPU and the CPU to create JPEG2000 bitstreams, that the source image must be in GPU memory and that the compressed image is written to host memory. Which part of the work goes to the CPU is not stated, and we did not look at the encoder with a profiler. So we do not claim it either: that the CPU is at work during encoding follows from the documentation, that Tier-2 in particular runs on it does not. This item is listed in section 15, among the unverified ones.
All the work on the CPU is inside the measured time for both codecs and in both modes. Taking this part out of the brackets would be incorrect: in fvJPEG2000 it is included, so it must be included for nvJPEG2000 as well. The disk is excluded everywhere: nothing is written out, otherwise we would also be measuring the speed of the storage device, and waiting for the reader and writer queues is outside the count. Frames per second in multithreaded mode is the total number of frames divided by the time of the slowest thread.
4.2.1. The boundaries in the NVIDIA samples are drawn differently
In NVIDIA's open sample set CUDALibrarySamples the measurement boundaries are drawn differently. This is important to note, as without this information, the numbers in this article and those in NVIDIA's publications appear comparable, but they are not.
The decoding sample. Frames are processed strictly one at a time: one decoder state, one queue of GPU jobs, and a wait after every frame. The -b option, described as a batch size, groups only the reading of files from disk and does not change how the work is done.
The sample measures the running time of a single function, nvjpeg2kDecodeImage. The call is asynchronous: it puts the work into a GPU job queue and returns immediately, so a CPU clock cannot measure it. The sample handles that correctly. The time is taken with a pair of CUDA events on the same queue, before the call and after it, and that is the right way. There is nothing wrong with the measurement of the decoding itself.
The question is about the second term. Parsing of the compressed image is done by nvjpeg2kStreamParse. This is Tier-2, which NVIDIA's own documentation calls the first stage of decoding. Its time is measured separately, with a CPU clock, and added to the total. Here is how that is done in nvjpeg2000DecodeSample.cpp (call arguments omitted, everything else verbatim):
auto io_start = perfclock::now();
nvjpeg2kStreamParse(…);
auto io_end = perfclock::now();
double parse_time = std::chrono::duration_cast<std::chrono::seconds>(io_end - io_start).count();
…
time += static_cast<double>(loopTime / 1000.0);
time += parse_time;
The duration is converted to whole seconds, not to fractional ones. Anything shorter than a second becomes zero, and parsing a frame takes milliseconds. So time += parse_time always adds exactly zero. Allocating GPU buffers, reading the file and writing the result are not part of the measured time, and the finished frame is never copied back to host memory in the measured loop.
This is not a choice of measurement boundary, it is an error in the measurement. What counts as part of the algorithm can be decided in more than one way, and that is a fair argument to have. Here the term is written in the code but has no value for any frame on any hardware. How much is lost this way can be seen in the stage table in section 8: at decoding, Tier-2 takes from 15 % of the frame time at 2K to 29 % at 4K. Those are fvJPEG2000 shares, because the nvJPEG2000 library does not report time by stage and its own shares are unknown to us. But the stage is the same and runs on the CPU in the same way, so the magnitude is the same.
That is why the nvJPEG2000 numbers in this article do not come from that sample. A separate program was written for nvJPEG2000 (bench/nvj2k_bench-02/nvj2k_bench-02.cpp, section 14). Its timer starts before nvjpeg2kStreamParse and stops after decoding, once the GPU has finished, so Tier-2 is inside the measured time, exactly as it is for fvJPEG2000. Both sides are measured by the same rule; otherwise there is nothing to compare.
Hence what this means for the reader. The number printed by the NVIDIA sample and the number in this article cannot be put side by side: the first shows the time of a part of the algorithm, the second covers the whole of decoding. The first will always look better.
The encoding sample. Frames go one at a time there as well. Here the whole per-frame loop is measured, and the copy of the compressed image to host memory (nvjpeg2kEncodeRetrieveBitstream) is inside it. Loading the source frame onto the GPU stays outside, it is done when the file is read. That is exactly the boundary we use in single-frame mode, and there is nothing wrong with it.
4.3. The optimum is found by search, not assigned
The number of CPU threads and the batch size are not a "reasonable choice" but values found by search. The optimum lies inside the range, and in different tasks it may be in a different place. For the fvJPEG2000 encoder at 2K batching helps noticeably: eight threads with a batch of two give 1914 frames per second against 1776 for eight threads without batching, while sixteen threads turn out to be slower than eight. At 4K the picture is different: 8×1, 16×2 and 8×4 give 616, 610 and 605 frames per second — the same value within the spread between measurement series. A large frame loads the GPU even without batching, and there is nothing left to add.
That is why the measurement conditions publish the full list of combinations used, not the winning one: what is reproduced is the procedure, not a ready-made combination. Six combinations were tried, written as "number of threads × batch size": 8×1, 8×2, 16×2, 8×4, 32×1 and 32×2.
The grid includes points where the threads are many and the frame in a thread is one: 32×1 and 32×2. The CPU has 16 cores and 32 logical cores, so 32 threads is the whole machine, and such a point answers a direct question: is it enough to give the library more threads? The answer is in section 6, and it is not the same for the two codecs.
The tables below give all six combinations and, separately, the best one for each codec. In the rest of the article, instead of "the best combination of number of threads and batch size", we say the best combination of threads and batch for short.
An important caveat: batching works differently in the two codecs. This has to be said outright, otherwise the same word in the tables would mean two different things.
First, about what the notation itself means. 8×2 is eight CPU threads, and in each of them two frames are in flight on the GPU at the same time. There are exactly eight CPU threads at any batch size; they do not double. Something else doubles — the number of jobs the GPU computes at the same moment: not eight, but sixteen.
In fvJPEG2000 these two frames go into the codec in a single call: the batch is real, and the codec handles them as one job. This is a standard capability of Fastvideo SDK.
nvJPEG2000 has no such call. Not a single function in the library accepts an array of images — only one image per call. So the GPU load is built up differently: each thread creates as many independent codec states and as many CUDA streams as the batch size specifies. The thread submits encoding of the first frame to its first stream, and immediately after it, without waiting for the result, the second frame to the second stream, and only then waits for both. The calls are asynchronous and the streams are independent, so both frames are computed on the GPU at the same time.
This is done with the standard means of the NVIDIA library and of CUDA: multiple codec states, job queues and asynchronous calls are its regular features, and there are no workarounds here. The only thing missing from the library is a call that accepts several frames at once, so the order of the calls has to be built by hand.
This is also worth saying because it does not work by itself. A program that simply calls nvJPEG2000 one frame per thread — and that is exactly how the NVIDIA samples are built — will get eight simultaneous jobs instead of sixteen, and the result will be lower. How much lower can be seen at one and the same number of threads: on a 2K lossy frame eight threads give the encoder 205 frames per second without the technique and 245 with a batch of two, that is 1.2 times more. For the decoder on that same task the starting point is not measured reliably (section 8), so take the neighbouring one: on 4K lossy eight threads give 208 frames per second without the technique and 428 with four frames in flight — twice as much.
We still report exactly these values and take them as the best for nvJPEG2000: the comparison must be against the maximum that can be obtained from the library, not against what the standard way of using it gives.
4.4. What was not measured
Tiles, decoding of a selected region, bit depth above eight bits, multi-component transforms beyond the standard ones, operation on Jetson. Some of this exists on only one of the two sides and is compared by a feature table, not by speed; some of it is a separate piece of work.
5. Test system: NVIDIA GeForce RTX 4090, fvJPEG2000 and nvJPEG2000
A performance result without a description of the conditions it was obtained in is useless. All the test conditions are listed here: the software parameters and the hardware.
| Item | Value |
|---|---|
| GPU | NVIDIA GeForce RTX 4090, 24 GB |
| GPU driver | 610.88 |
| GPU maximum power | 450 W |
| CPU | AMD Ryzen 9 7950X, 16 cores, 32 logical cores |
| RAM | 128 GB |
| Fastvideo JPEG2000 codec (FV) | Fastvideo SDK 0.23.1.0, CUDA 13.3 |
| nvJPEG2000 library (NV) | version 0.11.0.51 |
| Operating system | Windows 11 |
| How fvJPEG2000 was measured | the test application from Fastvideo SDK |
| How nvJPEG2000 was measured | our own wrapper nvj2k_bench-02: CPU multithreading and batch emulation; the standard NVIDIA sample application was not used |
| Bus speed, measured | 25.2 GB/s from CPU to GPU |
| Measurement series per point | 3, the tables show the median |
| Measurement date | 31 August 2026 |
One condition needs to be stated separately, because every table depends on it. The nvJPEG2000 library was not measured with the sample application that ships with the NVIDIA set. That sample processes frames one-by-one and counts time by its own rules (section 4.2, section 4.2.1). For this comparison we wrote our own wrapper: CPU multithreading and batch emulation, with several codec states and job queues in every thread, and it counts time by the same rules we apply to fvJPEG2000. Run this way the library delivers considerably more (section 4.3 and the table in the final results), so the tables in sections 6 and 7 show the best result we could get from nvJPEG2000.
All the measurements are run by a single script: it prepares the reference files, runs the quality search, measures the performance of both implementations, checks the quality of the restored image and prints a ready table. A full run with three repeats per point takes about an hour. The script picks how many frames to process in each test on its own: first a short speed probe, then a calculation that makes the measuring window the same length everywhere. So a fast point and a slow point are measured for equally long rather than over an equal number of frames, and the run is built the same way on any GPU.
Such a run also shows which combination of thread count and batch size gives the maximum on your machine. In our measurements the best combination changed both with the frame size and with the compression mode — how it is searched for is described in section 4.3 — so taking someone else's optimum ready-made is not worth it: it is cheaper to find your own by the same search.
6. JPEG2000 encoding speed on RTX 4090
The results follow. Their value rests entirely on sections 3 and 4: the same file size for both codecs, the same compression parameters and a chosen operating mode.
Every cell in the tables below is frames per second. The "single" row comes from single image mode, the other six from multithreaded mode with different combinations of "number of threads × batch size". The best value in a column is in bold, and the same combination is named in the "optimum" row. The NV columns are the nvJPEG2000 library run with our wrapper (section 5), not the standard NVIDIA sample application. The two frames are split into two tables.
The bottom row is how many logical CPU cores are busy on average when the codec runs at its optimum. A logical core is what the operating system shows as a separate processor; this machine has 16 cores and 32 logical ones, so 32 is the whole machine. The value was measured separately, together with the energy (section 11): on a server that is doing something else as well, busy cores are as much a resource as watts.
2K frame, 1920 × 1080
| Mode | Lossy, FV | Lossy, NV | Lossless, FV | Lossless, NV |
|---|---|---|---|---|
| 8×1 | 1776 | 205 | 1120 | 158 |
| 8×2 | 1914 | 245 | 1179 | 164 |
| 16×2 | 1687 | 278 | 1039 | 178 |
| 8×4 | 1910 | 226 | 1136 | 165 |
| 32×1 | 1308 | 275 | 858 | 164 |
| 32×2 | 1450 | 292 | 912 | 187 |
| single | 381 | 198 | 329 | 146 |
| Optimum | 8×2 | 32×2 | 8×2 | 32×2 |
| Logical cores | 7.0 | 29.5 | 7.2 | 29.8 |
4K frame, 3840 × 2160
| Mode | Lossy, FV | Lossy, NV | Lossless, FV | Lossless, NV |
|---|---|---|---|---|
| 8×1 | 616 | 134 | 371 | 62 |
| 8×2 | 572 | 148 | 369 | 63 |
| 16×2 | 610 | 160 | 322 | 64 |
| 8×4 | 605 | 143 | 333 | 63 |
| 32×1 | 565 | 158 | 294 | 63 |
| 32×2 | — | — | — | — |
| single | 195 | 128 | 140 | 56 |
| Optimum | 8×1 | 16×2 | 8×1 | 16×2 |
| Logical cores | 7.5 | 14.7 | 7.6 | 14.8 |
The dashes in the 32×2 row of the 4K table mean the combination was not measured at all: thirty-two threads with two frames each on 4K do not fit in GPU memory for the fvJPEG2000 encoder. A combination that drops out for one codec is not measured for the other either — otherwise the table would carry a cell filled on one side and empty on the other, and it would read as "the other codec was slower here" when in truth it was never measured.
How many times faster is fvJPEG2000 than nvJPEG2000 at encoding?
On 2K lossy it is 1914 frames per second for fvJPEG2000 against 292 for nvJPEG2000, which is 6.6 times; on 4K lossy it is 616 against 160, or 3.9 times. The compressed files are the same size for both codecs.
| Encoding | FV over NV threads and batch |
FV over NV single image mode |
|---|---|---|
| 2K, lossy | 6.55x | 1.93x |
| 2K, lossless | 6.31x | 2.25x |
| 4K, lossy | 3.86x | 1.53x |
| 4K, lossless | 5.77x | 2.49x |
In single image mode the fvJPEG2000 encoder is 1.5 to 2.5 times faster than the nvJPEG2000 encoder. This is encoding latency — the response time for one frame, not throughput. In milliseconds per frame, fvJPEG2000 against nvJPEG2000: 2.6 against 5.1 and 3.0 against 6.8 on 2K, 5.1 against 7.8 and 7.1 against 17.8 on 4K. This is about encoding only; decoding gives a different picture, it is in the next section.
The nvJPEG2000 encoder gains almost nothing from multithreaded mode. All its results sit in a narrow band: from 205 to 292 frames per second on 2K and from 134 to 160 on 4K. Going from single images to 8 threads increases the speed by only three and a half percent; after that only a denser load on the card adds a little. For comparison, fvJPEG2000 on the same task speeds up by a factor of 4.7 when going from single images to 8 threads.
A word about thirty-two threads — the part of the grid that is new here. What we were checking is whether simply giving the library more CPU threads is enough. For nvJPEG2000 on 2K lossy 32×2 did come out ahead — 292 frames per second against 278 for 16×2 — but that is a gain of 5 %, and the repeats on these points disagree by more, so it cannot be called a win; on 4K thirty-two threads give nothing at all. For fvJPEG2000 thirty-two threads at encoding are worse than eight: 1308 and 1450 against 1914. The encoder already fills the card with eight threads, and the extra threads only add work for the CPU.
Look at the logical cores row. At its optimum fvJPEG2000 occupies 7.0 to 7.6 logical cores, nvJPEG2000 from 14.7 to 29.8. So the NVIDIA encoder not only delivers fewer frames but takes twice as much CPU for it on 4K and four times as much on 2K.
We checked whether this was a bug in the benchmark harness. On the 2K frame, at the best combination of threads and batch for nvJPEG2000, a single run with no averaging gives the encoder 279 frames per second; if the copy of the image into GPU memory is removed from the same loop, 323, that is 16 % more. So even without the copy the encoder stays six times slower than fvJPEG2000 and gains almost nothing from threads. And the decoder of the same library, in the same harness, on the same card and with the same threading scheme, speeds up by a factor of 3.5. So the cause is not the benchmark harness but the fact that the nvJPEG2000 encoder and decoder are built differently.
7. JPEG2000 decoding speed on RTX 4090
The tables are built the same way as in the previous section: rows are modes and combinations, columns are codecs, and the bottom row is how many logical cores are busy at the optimum.
2K frame, 1920 × 1080
| Mode | Lossy, FV | Lossy, NV | Lossless, FV | Lossless, NV |
|---|---|---|---|---|
| 8×1 | 425 | 310 | 272 | 360 |
| 8×2 | 640 | 751 | 365 | 403 |
| 16×2 | 873 | 764 | 425 | 412 |
| 8×4 | 1024 | 1033 | 425 | 438 |
| 32×1 | 596 | 532 | 395 | 369 |
| 32×2 | 883 | 719 | 436 | 411 |
| single | 144 | 298 | 116 | 237 |
| Optimum | 8×4 | 8×4 | 32×2 | 8×4 |
| Logical cores | 7.6 | 4.1 | 28.6 | 3.5 |
4K frame, 3840 × 2160
| Mode | Lossy, FV | Lossy, NV | Lossless, FV | Lossless, NV |
|---|---|---|---|---|
| 8×1 | 244 | 208 | 133 | 108 |
| 8×2 | 348 | 318 | 130 | 125 |
| 16×2 | 377 | 323 | 140 | 125 |
| 8×4 | 350 | 428 | 120 | 134 |
| 32×1 | 330 | 207 | 145 | 108 |
| 32×2 | 394 | 335 | 141 | 124 |
| single | 96 | 193 | 59 | 91 |
| Optimum | 32×2 | 8×4 | 32×1 | 8×4 |
| Logical cores | 25.9 | 4.8 | 27.8 | 3.4 |
One cell in the 2K table needs a caveat: for nvJPEG2000 at 8×1 the measurements split in two — the same point on the same machine gives now 309 frames per second, now 539. The table carries the median of the run, 310; the analysis is in section 8.
Here the picture is different, and it depends on the mode.
Which JPEG2000 decoder is faster on a GPU?
At the best combination of threads and batch size both decoders have the same performance: on 2K lossy 1024 frames per second for fvJPEG2000 against 1033 for nvJPEG2000, on 2K lossless 436 against 438 — under a percent apart, and both times in favour of nvJPEG2000. On 4K the gap is larger and goes both ways: 8 % either way — on lossy nvJPEG2000 is ahead (428 against 394), on lossless fvJPEG2000 is ahead (145 against 134). In single image mode nvJPEG2000 is 1.5 to 2.1 times faster. Below is the breakdown by frame and mode:
| Decoding | Threads and batch | Single image mode |
|---|---|---|
| 2K, lossy | NV by 0.8 % | NV by 2.07x |
| 2K, lossless | NV by 0.3 % | NV by 2.04x |
| 4K, lossy | NV by 8 % | NV by 2.01x |
| 4K, lossless | FV by 8 % | NV by 1.53x |
The column for the maximum speed repeats what was said above: the gap is no more than 8 % and it goes both ways. In single image mode nvJPEG2000 is ahead: on three of the four combinations of conditions it is exactly twice as fast. Where the decoding time of a single frame matters, that counts.
The optimum of the fvJPEG2000 decoder has moved to thirty-two threads on three tasks out of four, and the gain there is small: on 2K lossless 32×2 gives 436 frames per second against 425 for 16×2, that is 2.7 %. The logical cores row should be read together with that. Where the optimum landed on thirty-two threads, the decoder occupies 26 to 29 logical cores; where it landed on eight, 7.6. What exactly that 2.7 % cost we cannot say: logical cores are counted only at the point that turned out to be the optimum, and nobody measured the CPU load at 16×2. But the order of the cost is clear, and in a real system the choice between a few percent of speed and noticeably fewer busy logical cores is not one the table can make for you.
One of these optima is nominal. On 4K lossless 32×1 gives 145 frames per second against 141 for 32×2 and 140 for 16×2 — the same value within the spread between repeats, so the best point here can only be called best nominally. The same caveat is made for the encoder in section 4.3; it applies to the decoder in exactly the same way.
The files from the two encoders are of the same size, but inside they are built differently, and in theory one of them could give the decoder less work. This is checked by cross-decoding: each decoder is run on a file made by the other encoder. The difference did not exceed 1.4 % in any of the eight combinations of conditions — two decoders, two frames, two compression modes — and in six cases out of eight it is under half a percent. So the decoder comparison is correct: the files put the same load on them, and the result applies to the decoders themselves.
8. Where the speedup comes from: the stages of JPEG2000

Where does the speed difference between the codecs come from?
It adds up from three parts: how well each stage is parallelised inside, whether batching is available, and how multithreading behaves.
Inside each stage. The tables do not show this level at all: each stage — the wavelet transform, quantization, EBCOT Tier-1 — is itself spread over thousands of parallel GPU threads (CUDA threads). How efficiently that is done determines the frame time in any mode.
Batching glues several frames into one: the GPU sees one large frame instead of several small ones. nvJPEG2000 has no batching, and its role is played by the technique from section 4.3 — several frames in flight at the same time within a single thread. Neither technique overlaps stages; both only increase the load. Hence a consequence that the measurements confirmed: batching helps at 2K and is useless at 4K, where a single frame already loads the card. For the fvJPEG2000 encoder at 4K the best combination turned out to be 8×1, eight threads with no batching at all — but it is ahead of its nearest neighbour by less than the repeats disagree among themselves, so the right way to put it is this: batching gives nothing at 4K.
Multithreading makes it possible to process different frames at the same time. fvJPEG2000 also has separate read and write pools, but they take no part in these tests: the disk is excluded.
A separate breakdown shows how large the contribution of each technique is. Take a 2K frame in lossy mode. There are no new measurements here: all the speeds are taken from the tables of sections 6 and 7, from the "2K, lossy" rows. Single image mode is taken as the unit; the ratio of the 8×1 combination to it shows what multithreading gave, the ratio of the optimum to 8×1 shows what batching and a denser load added, and the last row is the product of the first two. The ratios are rounded, but they are computed from the unrounded frames per second.
| Encoder | fvJPEG2000 | nvJPEG2000 |
|---|---|---|
| Single image mode | 381 | 198 |
| 8×1 | 1776 | 205 |
| Optimum | 1914 (8×2) | 292 (32×2) |
| What multithreading gave | 4.7x | 1.0x |
| What the move to the optimum gave | 1.1x | 1.4x |
| Total speedup | 5.0x | 1.5x |
The row "what the move to the optimum gave" means different things for the two encoders, and that has to be said directly. For fvJPEG2000 the best combination turned out to be 8×2: the same number of threads, only batching was added — so 1.1x here is the contribution of batching in its pure form. For nvJPEG2000 the best one turned out to be 32×2: four times as many threads and two frames in flight in each of them — so 1.4x is the contribution of both changes at once. nvJPEG2000 has no batching at all: two frames at a time are obtained by the technique from section 4.3 — separate codec states and separate job queues within a thread.
The nvJPEG2000 encoder gets no speedup from multithreading at all — the factor is 1.035, that is, three and a half percent, which is smaller than the spread of the measurements themselves. Everything it gains comes not from multithreading but from a denser load on the card. In total this is 1.5 times against 5.0 for fvJPEG2000 — and that is where the gap of six and a half times comes from in section 6.
For the decoders the picture is different, and the gap there is much smaller. The speeds are taken from the table in section 7, the "2K, lossy" rows. For both decoders the best combination is eight threads, the same as in the 8×1 row: only the number of frames in flight inside a thread changes. So both sides are compared here in their pure form.
| Decoder | fvJPEG2000 | nvJPEG2000 |
|---|---|---|
| Single image mode | 144 | 298 |
| 8×1 | 425 | 310 |
| Optimum | 1024 (8×4) | 1033 (8×4) |
| What multithreading gave | 3.0x | 1.0x |
| What the move to the optimum gave | 2.4x | 3.3x |
| Total speedup | 7.1x | 3.5x |
Multithreading makes the fvJPEG2000 decoder three times faster, and a batch of four frames adds another 2.4 times, together 7.1. For the nvJPEG2000 decoder the steps fall out quite differently: multithreading gives it almost nothing, while four frames in flight inside a thread speed it up 3.3 times; together that is 3.5 times.
But the steps of the nvJPEG2000 decoder are not fully reliable. The factor of 1.0 is computed from the 8×1 combination, and that is the one place in this article where the measurements split in two — now 309 frames per second, now 539. Until that point is settled, the decoder speedup cannot be split into two steps: both factors are computed from it. Only the total is solid — 3.5 times from single image mode to the best combination — and it does not depend on the disputed point. The nvJPEG2000 decoder starts from a single frame twice as fast but scales half as well, which is why the two codecs meet at the optimum.
How the frame time is distributed between the stages of JPEG2000. The Fastvideo test application with the -info option prints the time of every stage separately. The stages in the table below are named as in that output and follow the same order — for the encoder from the source pixels to the compressed image, for the decoder the other way round. The numbers are the median of five runs from the same measurement series, a 2K frame and a 4K frame, lossy compression; the logs of all runs are in the repository. The breakdown exists only for fvJPEG2000: the nvJPEG2000 library does not report stage times, so the codecs cannot be compared stage by stage — the table describes how one codec is built, not an advantage of one over the other.
This is an estimate, not a measurement, and here is why. The codec reports stage times only for a single frame, and one-off costs land inside every stage: the first kernel launch on the GPU, the card coming up to speed and the synchronizations that the option itself inserts. How much that is can be seen in the two bottom rows of the table: the sum of the stages is 4.65 ms against a real 2.62 ms for encoding 2K. The extra two milliseconds are smeared across the stages, and they distort the fast stages on a small frame most of all: the color transform with the level shift takes 0.69 ms on 2K and 0.74 ms on 4K, although the frame is four times larger. The shares in the table should be read as an order of magnitude, not as exact percentages.
Two rows need explaining. The color transform and the level shift come first for the encoder and, in the inverse direction, last for the decoder, so in the table this is a single row with numbers on both sides. Buffers gathering is the collection of the finished code-blocks into one contiguous buffer before the transfer to the CPU. Quantization has no row of its own: it has no kernel of its own at all and runs on the GPU inside the context modeling kernel, that is, inside EBCOT. The shares are rounded to whole percent, so a column may add up to 99 or 101; the bottom row is the time of a single frame in single image mode from sections 6 and 7, for comparison with the sum of the stages.
Stage shares for fvJPEG2000, single image mode, lossy compression:
| Stage | Where | Encoding 2K | Encoding 4K | Decoding 2K | Decoding 4K |
|---|---|---|---|---|---|
| Color transform and level shift | GPU | 15% | 11% | 5% | 4% |
| Wavelet transform | GPU | 8% | 8% | 6% | 6% |
| EBCOT Tier-1 | GPU | 57% | 51% | 73% | 60% |
| Buffers gathering | GPU | 4% | 3% | — | — |
| Copy over the bus | — | 1% | 2% | 1% | 1% |
| Tier-2 | CPU | 15% | 26% | 15% | 29% |
| Sum of the stages, ms | — | 4.65 | 6.67 | 8.05 | 12.28 |
| Real frame time, ms | — | 2.62 | 5.13 | 6.96 | 10.41 |
Three conclusions from this estimate are large enough that the one-off costs do not cancel them.
The main work is entropy coding, EBCOT Tier-1. From a half to three quarters of the whole time, and it is exactly what determines the speed of the codec. Everything else put together weighs less.
The CPU work grows with the frame size, the GPU work almost does not. Tier-2 takes 0.72 ms on 2K and 1.73 ms on 4K when encoding, 1.23 and 3.60 ms when decoding — that is, two and a half to three times more on a frame four times larger. Over the same step the GPU stages add only tenths of a millisecond. This is exactly the CPU work mentioned in section 4.2, and on a large frame it turns from a detail into a quarter of the time.
The inverse color transform and level shift in the decoder weigh little — 0.39 ms out of eight on 2K. This matters for comparing the decoders: the nvJPEG2000 test application leaves the result as separate planes and does not do this work (section 15). Its contribution is small and does not affect the conclusion.
How repeatable the results are. Each point was measured three times; the median goes into the tables. A point whose three repeats disagreed by more than 7 % is measured again, up to two extra runs, and the median is then taken over all five. On average the spread is small: 4.5 % for fvJPEG2000 on encoding and 2.1 % on decoding, 2.8 % and 3.6 % for nvJPEG2000. But the average is not the whole story: out of a hundred and ten points with repeats, eleven disagreed by more than seven percent even after the extra runs — the run report names them one by one, with the spread and the number of runs. For most of them the cause is visible in the power draw of the card: on the slow repeat the card takes noticeably fewer watts, so it was simply not being fed — something else took the CPU time at that moment. Throttling would look the other way round, with the power at the limit. The main conclusions in this article rest on differences of several times, clearly larger than any such spread, but a single cell of a table should be read with that caveat in mind.
One particular case is more complicated than the rest, and it is worth telling in detail — not because it matters for the conclusions, but because it is exactly the kind of case that sections on method are written for. nvJPEG2000, decoding 2K lossy, 8×1: here the repeats do not scatter, they split in two. We measured this point twenty times in a row. Nine launches gave 309 frames per second, eleven gave 539, and inside each group the values agree to a tenth. The state is decided once when the program starts and holds for the whole run, from the first frame to the last.
It is not heat and it is not another program on the machine. The GPU clock is the same in both states, 2745 MHz, the temperature 46 to 52 degrees, GPU utilisation 97 and 98 percent; the neighbouring point 8×2, measured in between, ran evenly the whole time. What differs is something else: the slow state spends 45 % more CPU time per frame — 13.3 milliseconds per frame against 9.2 — and the card, given less work, draws 135 watts instead of 171. So it is the CPU side of decoding that slows down.
Two explanations were checked and rejected. Thread placement across the cores: the CPU of the test system consists of two eight-core dies, and exchange between dies is more expensive than inside one, so it looked plausible that the fast state is the one where all eight threads landed on a single die. But in 36 runs, each with an explicitly set core mask, both states reproduce on any mask, including the one where all eight threads sit on eight different cores of the same die. The way the GPU is waited for: CUDA can wait in four ways — spinning, yielding, blocking and the default choice — and how much CPU time goes into the waiting itself depends noticeably on that. We added an option to the test program that sets the way explicitly and took six runs for each: both states appear in all four. There is not a single intermediate value: 306–311 or 531–541 frames per second, and nothing in between.
We did find what the split depends on: the number of threads. The same run, six launches per variant. At one, two and four threads all the values fall into one group — 274, 309 and 309 frames per second. At eight and sixteen both states appear. At thirty-two all six runs gave the fast one, 531–534. The upper state of eight threads, 530–540, coincides both with what thirty-two threads always give and with the 532 of the neighbouring cell of the table. So the fast state is the norm and the slow one is a failure. The coincidence of the slow state with the level of two threads is not an explanation: in it a frame takes 13.3 milliseconds of CPU time, that is, about four logical cores are busy — two threads have nowhere to take that from. It is not that some threads idle, it is that every frame costs more. What exactly becomes more expensive should be shown by a profiler on a fast and a slow run side by side; that is the next step.
The table in section 7 carries the median of the run, 310. The neighbouring cells, though, argue for 539: on nvJPEG2000 decoding, thirty-two threads with one frame each give exactly what eight threads with one frame each give — 369 against 360 on 2K lossless, 207 against 208 on 4K lossy, 108 against 108 on 4K lossless. On 2K lossy 32×1 gives 532, and with the value 539 the cell joins that same row, while with 310 it stays the only exception. We keep the measured value anyway and state the doubt openly: fitting a number to a rule is a reliable way to get a tidy table and a wrong result. The logs of all twenty launches are in the repository.
A separate note on the dependence on data volume — and here it is important not to mix the two measurement modes. At the best combination of threads and batch, lossless compression gives five times more data, both sides run into the speed of entropy decoding, and the results nearly converge: on 2K it is 436 frames per second for fvJPEG2000 against 438 for nvJPEG2000. In single image mode the picture is different: there the fixed per-frame overhead decides, and fvJPEG2000 loses on it. On 2K the gap between the codecs grows only from 3.6 milliseconds per frame in lossy compression to 4.4 in lossless, even though there is five times more work. So the gap barely depends on the volume of data, which points to fixed overhead rather than to the work itself.
What the profiler shows
The table above has two limitations: it covers single image mode only, and only fvJPEG2000. The same picture can be obtained from outside the codec, and that is what we did with NVIDIA Nsight Systems. The profiler records a timeline of the whole test — every kernel launch on the GPU, every transfer over the bus, frame by frame — and shows which of them overlap. Neither codec was modified for this, and the command lines come from the logs of the measurement series behind sections 6 and 7.
Kernel. A kernel is a function the GPU executes on command from the CPU. In CUDA the CPU is called the host and the GPU the device, and each has its own memory. Unlike an ordinary function, a kernel does not run once per call: it runs in thousands of parallel threads, each thread working on its own piece of the data.
One word, three meanings. A kernel is a function executed by the GPU, and that is the only sense used below. CUDA cores are something else: in GPU specifications they are the compute units inside the card, sixteen thousand of them on an RTX 4090, and the card assigns work to them without any help from the program. CPU cores are the cores of the processor the program runs on — sixteen on our test system, or thirty-two logical cores as the operating system reports them. Sections 6, 7 and 11 deal with those.
A codec is a chain of kernel launches. On the GPU the stages run one after another: color transform with level shift, wavelet transform, code-block encoding (EBCOT Tier-1), and gathering of the code-block buffers. Most stages have a kernel of their own, and a large stage has several: Tier-1 in our encoder uses two kernels, and the wavelet transform takes several dozen launches per frame, one per decomposition level and channel. Quantization has no kernel at all — it is part of the context modeling kernel, and therefore part of EBCOT — so it appears nowhere in the timeline. The codec copies the frame into GPU memory, launches the kernels of the first stage, then the second, and so on to the end; each stage leaves its result in GPU memory for the next one, and only the compressed file is copied back to host memory. Packing the code-blocks into a file, Tier-2, never runs on the GPU: it is CPU work, and the table above lists it separately.
What the figures measure. From here on the numbers are GPU time: how long kernels kept the card busy, not how long a frame took from input to finished file. GPU time does not depend on how many frames are in flight, which is what makes it directly comparable between the codecs. The test conditions are those used throughout the article: at encoding both codecs receive the same source frame and produce files of the same size; at decoding each codec reads the file written by its own encoder, again of the same size. All stage times were measured in single image mode.
EBCOT Tier-1 stage time for both codecs, milliseconds per frame:
| Task | fvJPEG2000, ms | nvJPEG2000, ms | Which is faster |
|---|---|---|---|
| Encoding 2K lossy | 1.7 | 4.3 | fv, 2.5x |
| Encoding 2K lossless | 1.7 | 5.7 | fv, 3.4x |
| Encoding 4K lossy | 2.4 | 4.9 | fv, 2.0x |
| Encoding 4K lossless | 2.7 | 14.0 | fv, 5.2x |
| Decoding 2K lossy | 6.4 | 2.9 | nv, 2.2x |
| Decoding 2K lossless | 7.7 | 3.5 | nv, 2.2x |
| Decoding 4K lossy | 7.2 | 3.2 | nv, 2.3x |
| Decoding 4K lossless | 11.3 | 7.7 | nv, 1.5x |

At encoding, the entire advantage comes from EBCOT Tier-1. The fvJPEG2000 kernels finish a frame 2.0 to 5.2 times sooner, and every other stage is small beside it. This is the stage that accounts for half to three quarters of the frame time in the table above.
At decoding the picture is reversed: Tier-1 in nvJPEG2000 is 1.5 to 2.3 times faster. The decoding speeds in section 7 end up equal not because the fvJPEG2000 decoder computes faster, but because it keeps the card busier, as the rest of this section shows.
The wavelet transform is faster in nvJPEG2000 almost everywhere: 41 microseconds against 92 at encoding 2K lossy, 186 against 292 at 4K; 70 against 155 and 254 against 394 at decoding, which is 1.6 to 2.2 times faster. It changes little in the total, since the wavelet accounts for only a few percent of the frame time, but the measurement is there and we report it.
The second thing the profiler shows is idle time on the GPU. Within one frame the stages are strictly ordered: the wavelet transform cannot start until the color transform is finished, and Tier-1 cannot start until the wavelet is. So when frames are processed one at a time, only one kernel is ever running, and gaps appear between kernels: the next kernel has not been launched yet, or data is still being copied. NVIDIA calls these gaps GPU starvation — the card has nothing to do — and recommends looking for the cause in the CPU code that should be launching the next stage. The share of time filled by kernels is what NVIDIA calls kernel coverage; starvation is whatever is missing from 100 %.
The gaps are measurable. The table below gives, for all sixteen tasks and both codecs, the share of the test during which at least one kernel was running, and the share during which the card was doing either computation or a transfer.
| Mode | Kernel coverage | Busy with kernels or transfers |
|---|---|---|
| Single image mode | 42–86 % | 61–93 % |
| Best combination of threads and batch | 70–97 % | 93–104 % |
Figures slightly above 100 % come from tests where the profiler timeline includes the warm-up, while the test duration is derived from the speed the program reports; the discrepancy is up to 4 %.
In single image mode the card is genuinely idle. In the worst case for fvJPEG2000, encoding 4K lossless, kernels occupy 42 % of the time and computation together with transfers 61 %, which leaves 39 % of the test with the GPU doing nothing while it waits for the next job.
These numbers also confirm the chain of stages directly. Dividing the total kernel time by the coverage gives the number of kernels running simultaneously while the card was busy. In single image mode the answer is one, in all sixteen tasks — between 0.99 and 1.01, for both codecs. Nothing overlaps when frames go through one at a time.
Only other frames can fill the gaps. While the first frame is in Tier-1, the wavelet transform of the second one can run beside it. The GPU supports this: kernels submitted to different job queues execute simultaneously, which NVIDIA calls concurrent kernel execution.
Two levels are involved here, and different things control them.
The first level is inside a single kernel. The GPU is in charge: it groups the threads into sets of 32, called warps, and issues them to its CUDA cores, the sixteen thousand compute units of an RTX 4090. The codec has no say in this. It only states the size of the problem, and the card does the scheduling.
The second level is how many kernels are running at once. This is entirely up to the CPU code: how many frames it keeps in flight and which job queues it submits them to. The thread counts and batch sizes searched in sections 6 and 7 belong to this second level, and it is the only level discussed below; the first one we did not measure.
A single number captures it: the total kernel time over the test divided by the duration of the test. We call it average kernel concurrency, AKC. It is the same arithmetic as the average headcount of a shift: add up the hours worked and divide by the length of the shift. A value of 1 means one worker was busy the whole shift; 0.4 means one worker who spent most of the shift with nothing to do; 3 means three people working at any given moment. Here the shift is the test, the workers are the kernels, and the profiler keeps the timesheet.
AKC for both codecs, in single image mode and at the best combination of thread count and batch size:
| Task | fvJPEG2000, single | fvJPEG2000, optimum | nvJPEG2000, single | nvJPEG2000, optimum |
|---|---|---|---|---|
| Encoding 2K lossy | 0.66 | 2.27 | 0.80 | 1.39 |
| Encoding 2K lossless | 0.57 | 1.58 | 0.78 | 1.21 |
| Encoding 4K lossy | 0.52 | 1.88 | 0.65 | 0.93 |
| Encoding 4K lossless | 0.42 | 1.69 | 0.76 | 0.90 |
| Decoding 2K lossy | 0.86 | 2.20 | 0.82 | 4.19 |
| Decoding 2K lossless | 0.84 | 2.40 | 0.80 | 2.09 |
| Decoding 4K lossy | 0.70 | 2.19 | 0.68 | 2.29 |
| Decoding 4K lossless | 0.65 | 1.78 | 0.70 | 1.21 |

In single image mode AKC is below one for both codecs, as expected: frames go through one at a time and there is nothing to fill the gaps with. At the best combination the two diverge. At encoding, fvJPEG2000 reaches 1.6 to 2.3, while nvJPEG2000 stays near one — 0.90 to 1.39 — so at 4K the GPU is idle part of the time even at the optimum. This is the second reason for the difference in speed: in section 6 fvJPEG2000 encoded 3.9 to 6.6 times faster, and the speed of the kernels alone does not account for that. What also counts is how many of them run at once.
At decoding both codecs keep the card busy, and nvJPEG2000 reaches an AKC of 4.19 on 2K lossy, the highest figure either codec reaches anywhere. That is why the decoding speeds in section 7 came out equal: at the best combination the two are within 0.3 to 8 % of each other, in both directions.
The two codecs get there by different means, and the number of kernel launches per frame shows how. In fvJPEG2000, with a batch of two the main Tier-1 kernel is launched 0.50 times per frame, and with a batch of four, 0.25 times: a single launch handles two or four frames, so the work arrives in larger pieces and fewer gaps appear between launches. nvJPEG2000 launches it once per frame, 1.00 to 1.04 regardless of how many frames are in flight, because frames are never merged into one launch. The only way to keep the card busy is to feed it from more CPU threads, which is why the optima differ at encoding: eight threads are enough for fvJPEG2000, while nvJPEG2000 needs sixteen or thirty-two.
At the best combination the gaps are filled, but not by computation alone. Kernel coverage rises to 70–97 %, and coverage including transfers to 93–104 %: the card is almost always doing something, and what sits between the kernels is mostly traffic over the bus rather than idle waiting. For nvJPEG2000 at encoding 4K lossy, kernels take 70.2 % of the time and kernels plus transfers 99.6 %, so nearly a third of the test is spent copying rather than computing. For fvJPEG2000 on the same task the figures are 76.5 % and 98.5 %.
The third thing is the bus. The profiler also reports its load: the share of the test spent copying between host memory and GPU memory. At encoding 4K lossy the bus is busy 66 % of the time for fvJPEG2000 and 39 % for nvJPEG2000; at decoding 4K lossless, 21 % and 82 %. Transfers run at 22 to 26 GB/s, which is very high for PCIe 4.0 ×16. On a 4K frame the transfer time is already comparable with the compute time, so further speedups of the codec will bring less and less: the transfer time beside it does not shrink.
Three notes on how to read all this.
The profiler slows the test down, and not by a constant factor. Every point was therefore measured twice, with the profiler and without it, using the speed the program itself reports. At 22 points out of 32 the two agreed within 2 %; at the rest the difference reached 15.5 %. AKC is therefore a lower bound: without the profiler the same test runs faster while the total kernel time stays the same, so more kernels must be running at once.
Stage shares are meaningful only in single image mode. When frames are processed simultaneously, the stage times add up to more than the frame time, precisely because stages of different frames run at once. They cannot be summed to obtain the frame time in that mode.
Single image and multithreaded decoding do not measure the same thing. In single image mode the copy of the decoded image from GPU memory back to host memory falls outside the measured interval; in multithreaded mode it falls inside. This is stated in section 4.2, and the profiler timeline confirms it: there is not a single device-to-host copy in single image mode. The algorithm itself runs in full either way, and Tier-2 at decoding runs on the CPU at the start and is always inside the measured time. It is worth remembering when reading the table in section 7: the difference between its columns is not only a difference of modes.
9. Quality control in JPEG2000 compression
How do you check that the two codecs were compared under the same conditions?
Three checks. In lossless mode the decoded frame must match the source bit-for-bit — it did, for both codecs, in all four combinations. In lossy mode, at the same file size, PSNR is compared, and the difference between the codecs is a few tenths of a decibel. And the measurements were made on builds without a watermark, which the program verified.
Third rule: speed alone is not enough for a comparison.
Measuring speed without checking the result guarantees nothing: a decoder that does less work than it should looks faster. So on every run, for each of the eight combinations of conditions, a full cycle is performed: the image is encoded, decoded and compared with the original.
Lossless mode: exact match. All four combinations — both codecs, both frames — produced a decoded image bit-for-bit equal to the original. This is a mandatory condition: if there were no match in even one case, it would no longer be lossless compression and there would be nothing to compare the speeds against.
Lossy mode: signal-to-noise ratio. The comparison runs at a matched file size, so the table answers a direct question — at the same file size, who has less distortion.
| Image | fvJPEG2000, dB | nvJPEG2000, dB | Difference |
|---|---|---|---|
| 2K | 40.42 | 40.60 | 0.18 |
| 4K | 41.97 | 42.23 | 0.26 |
The difference favours nvJPEG2000, but it is small. For a sense of scale: a difference of 1 dB on photographic material is usually already indistinguishable by eye, and tenths lie within what the choice of parameters inside a single codec gives. So at the same file size the quality of the two implementations is practically the same — and that is exactly the conclusion that was needed: it confirms that the speed comparison is made at a comparable result, and not because one codec saves on quality.
About SSIM and MS-SSIM. Besides the signal-to-noise ratio there are structural measures — SSIM and its multi-scale variant MS-SSIM. They are brought up where the signal-to-noise ratio agrees poorly with visual perception: it counts the average error over the whole frame and does not tell an error spread evenly as noise from an error of the same magnitude gathered on a single edge. We computed them — on the same frames, at the same file sizes and against the same original.
| Image | SSIM, FV | SSIM, NV | MS-SSIM, FV | MS-SSIM, NV |
|---|---|---|---|---|
| 2K | 0.9824 | 0.9826 | 0.99676 | 0.99679 |
| 4K | 0.9827 | 0.9828 | 0.99649 | 0.99651 |
The order of the codecs is the same as by the signal-to-noise ratio, and the difference between them sits in the third and fourth decimal place. At this quality both measures are already saturated: they are close to one, and there is almost no room left for a difference. The first percentile of the SSIM map — the part of the frame that is restored worst — says the same: 0.941 against 0.942 at 2K. That is why the table above carries the signal-to-noise ratio: it is easier to check and to recompute. Where the quality is lower and the gap between the codecs is larger, the structural measures are the ones to look at.
The largest error, and how similar the two frames are to each other. The signal-to-noise ratio averages over the whole frame and hides local misses, so we look at two more values. The first is the largest deviation of a single sample from the original: at 2K it is 17 levels out of 255 for both codecs, at 4K 19 for fvJPEG2000 and 17 for nvJPEG2000. The second is a comparison of the two decoded frames with each other: they differ from each other less than each of them differs from the original. At 2K the signal-to-noise ratio between them is 46.7 dB against 40.4 and 40.6 dB relative to the original, at 4K 49.1 against 42.0 and 42.2. So both codecs produce almost the same picture.
About the watermark. Demo builds of the codecs put a watermark on the frame, and then the decoded frame cannot be compared directly with the original file: it would be the watermark being measured, not the codec. These measurements were made on a build without the watermark, and the benchmark harness verified this: neither codec had a watermark, so PSNR was computed directly against the original.
The quality check is reproducible on the demo version as well, and no special build is needed for it. The technique is this: the reference for PSNR is not the original file but the frame that came back through a lossless round trip on the same build. The watermark is applied before encoding, and lossless mode preserves everything bit-for-bit — so such a reference is exactly what the encoder received, and PSNR measures the encoding loss, not the watermark. The harness also checks this very condition: two independent lossless round trips must match byte for byte. All of this is already built into the script and turns on by itself.
10. PCRD mode: a fixed file size and encoding speed
How much does PCRD mode slow encoding down?
Least of all by 1.34 times on 4K: that is when the base quality is set in advance, one unit above the value at which the frame already comes out at the required size, and PCRD only trims the file to the target. If quantization is set to q = 100 and PCRD delivers the whole compression ratio, the gap is larger: 1.56 times on 4K and 1.84 on 2K. In single image mode the gap is smaller than in multithreaded mode: on 2K it is 1.84 times against 2.76.
In the previous sections both codecs worked the same way: we set a quality parameter, and the size of the compressed file came out as a consequence. In production that is not always the case. Often the bandwidth of the channel or the capacity of the medium is known in advance, and the frame has to fit a given size: compress by exactly a factor of twenty, or fit into so many megabytes.
The fvJPEG2000 codec can do this directly: in PCRD mode you set the compression ratio you need, and the encoder itself decides which least significant bits to discard in order to reach it. nvJPEG2000 has no such mode, so this whole section is about fvJPEG2000 only: there is nothing to compare.
Quantization and PCRD mode work in sequence, quantization first, then PCRD. The wavelet coefficients are quantized according to the quality parameter, and then PCRD discards as many least significant bits as it takes to reach the given compression ratio (section 3.2). That is how it is normally used: the base quality is chosen in advance, on frames of the same kind, and the -cr option sets the final file size.
First we look at encoding with PCRD at different compression ratios. Quantization here ran at q = 100, that is, it was relatively weak, and the resulting compression ratio was set mainly by the -cr option.
The table below is about encoding only: PCRD mode works on the encoder side, the decoder knows nothing about it and simply reads a finished file. The values were obtained in single image mode — frames are processed one at a time, without multithreading and without batching (section 4.1).
| Frame | Compression ratio | File, kB | Encoder, fps |
|---|---|---|---|
| 2K | 5:1 | 1213 | 200 |
| 2K | 10:1 | 602 | 212 |
| 2K | 20:1 | 295 | 223 |
| 4K | 5:1 | 4662 | 128 |
| 4K | 10:1 | 2408 | 122 |
| 4K | 20:1 | 1182 | 130 |
Encoding speed hardly depends on which compression ratio was requested: at 4K it is 128, 122 and 130 frames per second at 5:1, 10:1 and 20:1. That is what one should expect while quantization stays at q = 100: the amount of data to encode is the same, and the compression ratio changes only how many least significant bits are discarded afterwards.
Now to how much PCRD mode slows encoding down. To keep the comparison correct, every variant produces a file of one and the same size — the size that quality q = 85 gives: 588 kB at 2K and 1246 kB at 4K. That is the quality sections 6–9 work at. The same size is reached in four ways: by quantization alone, without PCRD, and by three more where PCRD brings the size down — at q = 90, 95 and 100.
The "slowdown" column shows how many times slower each row is compared with the first row for the same frame — the one with PCRD off. The file sizes agree across all rows to better than one tenth of a percent, so the rows can be compared with each other.
All the numbers in the table below come from a single run, so they can be divided by one another. The rows without PCRD are the same mode that produced the table in section 6; the speeds measured here differ from the published ones by no more than 6 %, and all four differ downwards: 358.5 against 381 in single image mode at 2K and 1879 against 1914 at the best combination of threads and batch.
| Frame | Quality q and mode |
Single image mode | Slowdown | Threads and batch | Slowdown | PSNR, dB |
|---|---|---|---|---|---|---|
| 2K | 85, no PCRD | 358.5 | — | 1879 (8×2) | — | 40.41 |
| 2K | 90 and PCRD | 211.5 | 1.70× | 823 (8×1) | 2.28× | 39.80 |
| 2K | 95 and PCRD | 201.5 | 1.78× | 755 (8×1) | 2.49× | 39.74 |
| 2K | 100 and PCRD | 194.6 | 1.84× | 681 (8×1) | 2.76× | 39.25 |
| 4K | 85, no PCRD | 187.2 | — | 614 (8×1) | — | 41.97 |
| 4K | 90 and PCRD | 134.1 | 1.40× | 400 (8×1) | 1.54× | 41.50 |
| 4K | 95 and PCRD | 129.1 | 1.45× | 362 (8×1) | 1.70× | 41.51 |
| 4K | 100 and PCRD | 120.1 | 1.56× | 314 (8×1) | 1.96× | 41.24 |
How much the encoder slows down depends on whether quantization was set. If quantization stays at q = 100 and the compression ratio is reached by PCRD alone, the encoder runs 1.56 times slower at 4K and 1.84 times slower at 2K. If quantization is set in advance, part of the speed comes back: at 4K the gap narrows from 1.56 to 1.40 times. About a quarter to a third comes back, and it cannot come back in full — the PCRD stage itself remains in any case, and the first row does not have it at all.
In multithreaded mode the gap is larger than in single image mode. At 2K it is 1.84 times by single frames and 2.76 times at the best combination of threads and batch; at 4K, 1.56 and 1.96 times. The difference is substantial: if a system is sized by total throughput, what PCRD mode takes away will be noticeably more than single image mode suggests.
At one and the same file size PCRD mode also gives slightly worse image quality. At 2K the PSNR is 39.25 dB against 40.41 dB in the row without PCRD; at 4K, 41.24 against 41.97. The pattern is the same in every row: the lower the quality level, the less data reaches the entropy encoder, so compression runs faster. q = 90 is faster than q = 95, and q = 95 is faster than q = 100. By PSNR the two combinations are almost equal, and both are noticeably better than PCRD alone.
What remains is to work out which quality to set. In the table above the best variant is q = 90, but there is a gap between it and q = 85: at 85 the frame already comes out at the required size and PCRD has nothing to trim, while at 90 the natural size is already one and a half times the target. The optimum lies somewhere between them, so q = 86, 87 and 88 were measured separately.
This is a separate run, so the absolute speeds in it are slightly higher than in the table above — a different measurement session. What has to be compared are the ratios, and they agree: the "100 and PCRD" row gives 1.79 times at 2K and 1.54 times at 4K here, against 1.84 and 1.56 in the previous run. All measurements are in single image mode, and the file sizes are matched to better than one tenth of a percent.
| Frame | Quality q and mode |
Encoder, fps | Slowdown | PSNR, dB |
|---|---|---|---|---|
| 2K | 85, no PCRD | 372.1 | — | 40.42 |
| 2K | 86 and PCRD | 228.4 | 1.63× | 40.40 |
| 2K | 87 and PCRD | 227.1 | 1.64× | 40.23 |
| 2K | 88 and PCRD | 230.3 | 1.62× | 39.98 |
| 2K | 100 and PCRD | 207.4 | 1.79× | 39.25 |
| 4K | 85, no PCRD | 194.4 | — | 41.97 |
| 4K | 86 and PCRD | 145.1 | 1.34× | 42.00 |
| 4K | 87 and PCRD | 142.7 | 1.36× | 41.89 |
| 4K | 88 and PCRD | 142.2 | 1.37× | 41.67 |
| 4K | 100 and PCRD | 126.6 | 1.54× | 41.24 |
The nearest quality above turns out to be the best one. At 4K q = 86 gives the smallest gap of all the variants — 1.34 times — and a PSNR of 42.00 dB, that is, no worse than a file of the same size compressed by quantization alone (41.97). At 2K the speed at 86, 87 and 88 is the same to within one percent, while PSNR falls: 40.40, 40.23 and 39.98 dB. So here too the nearest value above is the one to take.
The rule that follows is simple: set the base quality one or two units above the value at which the frame already comes out at the size you need. PCRD then has very little left to trim, encoding slows down the least, and the image quality stays at the level of ordinary quantization.
Where the gap comes from can be seen in the time of the individual encoding stages. The -info option prints that time for a single frame (section 8), and two separate components are visible there. The first is the PCRD stage itself: the first row does not have it, the others do. The second is the EBCOT Tier-1 time: the higher the quality, the more data reaches the entropy encoder and the longer that stage runs.
As in section 8, one thing has to be kept in mind here: the -info option synchronises the stages against each other, so their sum comes out larger than the real frame processing time. These numbers can be compared between rows, but the column must not be added up.
4K, quality q and mode |
Tier-1, ms | PCRD, ms |
|---|---|---|
| 85, no PCRD | 3.37 | — |
| 90 and PCRD | 3.94 | 1.85 |
| 95 and PCRD | 4.25 | 1.81 |
| 100 and PCRD | 4.84 | 1.77 |
In all three rows that have a PCRD stage it takes about the same time — around 1.8 ms. The Tier-1 time, on the other hand, grows as quantization gets weaker: 3.37, 3.94, 4.25 and 4.84 ms. At 2K the picture is the same: 2.76, 3.22, 3.41 and 3.70 ms, with the PCRD stage taking 1.5 to 1.7 ms. The file is the same size in all four rows; only the way it was produced differs.
Why the tables say q = 100 when no quality parameter was set. This was checked separately: setting q = 100 explicitly together with -cr gives the same numbers as leaving the quality parameter out — at 4K, 120.6 against 120.1 frames per second and a PSNR of 41.24 in both cases. So without a quality parameter the encoder quantizes exactly as it does at q = 100.
What follows from this. If the file size is not fixed and may vary from frame to frame, it is better to work with a quality parameter alone: that is both faster and better in quality. If the size is fixed — by the bandwidth of the channel, the write speed of the medium or a customer requirement — it is better to choose the quantization first, one or two units above the value at which the frame already comes out at the required size, and leave PCRD mode for the fine adjustment. Two ways of producing one and the same file can differ in speed by up to 2.8 times, so the mode of operation is better chosen while the system is being designed than after it has been built.
11. GPU energy per frame and CPU load for J2K codecs

On encoding the gap is 3.4 to 6.1 times; on decoding the speeds are nearly equal, and a frame still costs fvJPEG2000 1.3 to 1.8 times less.
How much energy does one frame cost?
First, what is measured here and what is not. Every joule in this section is energy spent by the GPU. The energy the CPU consumed was not measured at all: the card has an energy counter of its own, the CPU had no such measurement in this run. What the tables report for the CPU is a different quantity — how many of its logical cores are busy on average — and it is measured, but it is a share of the CPU, not watts and not joules. The two codecs load the CPU differently, and not in the same direction for encoding and for decoding: at encoding nvJPEG2000 takes more cores, at decoding fvJPEG2000 does. So the CPU energy that is missing from these numbers is not the same for the two codecs, and the tables flatter each of them in turn: nvJPEG2000 at encoding, fvJPEG2000 at decoding. How much and what follows from it is at the end of this section, and it is worth reading together with the tables.
It is easier to count the other way round: watts divided by joules per frame give the frame rate that a given power limit allows. At a limit of 100 W, fvJPEG2000 encodes 4K lossy at about 280 frames per second, nvJPEG2000 at about 83. This is a conversion from joules per frame, not a measurement taken at that power limit.
Speed answers the question "how many frames will one GPU process". The energy the GPU consumes also matters a great deal.
- How many cards will fit. A power supply is rated for a certain wattage, and how many cards fit into one chassis depends on the draw of a single card.
- Where to put the heat. Every joule spent turns into heat, and it has to go somewhere. In an airborne or embedded enclosure this limit can be reached before the available compute is exhausted.
- How long the battery lasts. On a drone or a portable rig the energy budget is finite, and joules per frame translate directly into a number of frames.
- What it costs. In a data center kilowatt-hours are money, and cooling costs come on top of what the cards draw.
How to convert one into the other. Joules per frame multiplied by frames per second give watts. The reverse conversion is more useful: the watts you have, divided by joules per frame, give the speed you can afford. The estimate of 280 and 83 frames per second at a hundred watts was obtained exactly this way, and it holds as long as joules per frame do not depend on the power limit that is set. We did not check that: the energy was measured while the card was running at 230 and 186 watts. Usually at a lower limit the card drops its clock and spends slightly less per frame, so the real number may turn out to be higher; the exact one has to be measured with the power limit in place.
How the energy was measured. We measure the energy the GPU consumes and attribute it to one frame. Power alone is no good for comparing codecs: the one that draws fewer watts but runs longer costs more. Joules per frame account for both the draw and the running time.
Why an average makes sense here. In every measurement the same kind of operation runs one after another: the same frame is encoded thousands of times with the same compression parameters. The frames differ neither in size nor in content, and the card is in a steady state. So the average energy per frame is the energy of any single frame, not a mix of different work. Were there different frames and different modes in the stream, the same average would hide the differences between them.
There are two meters, and they are independent. Both of them refer to the GPU and to the GPU alone.
- Power sampling. The
nvidia-smitool that ships with the NVIDIA driver reports the current draw of the card in watts. We sample it ten times a second, average over the run and multiply by the duration. The method is simple, but short spikes between samples are lost, and the whole running time of the program is counted, including start-up and buffer preparation. - The cumulative energy counter inside the card. The card itself keeps a count of the millijoules spent since the driver was loaded — a ready-made total, with nothing lost between two readings. The value is read through NVML, NVIDIA's management interface (
nvmlDeviceGetTotalEnergyConsumption). Not every model has this counter; the RTX 4090 does.
The differential method and what it gave. The counter is read from outside the program: a reading is taken before the run and after it, so fixed costs land inside it — starting the process, preparing buffers, the card coming up to speed. To remove them, each point is measured twice, on N frames and on 2N, and the energy of one frame is taken as the difference divided by N: everything that does not scale with the number of frames drops out of the difference.
The conclusion for anyone repeating this: a long run is enough, the difference between two runs adds nothing noticeable and costs twice as much time. The right way is to measure a window inside the loop itself — to start counting after a hundred frames, when the card is already up to speed — but for that the program has to read the counter on its own. We will do that in the next series of tests.
Both meters gave the same answer: the disagreement is 2 % at the median point and 10 % at the worst. The tables below show the counter readings computed by the differential method.
In both tables, for each codec the number of threads and the batch size that give the best speed were used. The power limit of the card is 450 watts. The last column is how many logical CPU cores are busy on average; the same numbers are in the bottom rows of the tables in sections 6 and 7. This is load, not power: there is nothing here to turn it into watts, see the end of this section.
Why the product does not agree with the power column. Joules per frame multiplied by frames per second from sections 6 and 7 do not give the number in the "card power" column. In most rows the disagreement is no more than 4 %; for the fvJPEG2000 decoder it reaches 6 % on 2K lossless and 10 and 16 % on 4K, and on 4K lossy encoding the product comes out 5 % lower. There are two reasons. Energy per frame is computed by the differential method, with the fixed costs removed, while power is an average over the whole run, with them included. And the speed comes from a different run, not from the one in which the energy was measured. So the columns are read separately: the codecs are compared by joules per frame, and the power shows the level the card was running at.
Encoding.
| Frame | Mode | Codec | J/frame, GPU | Card power, W | Logical cores |
|---|---|---|---|---|---|
| 2K | lossy | fvJPEG2000 | 0.122 | 230 | 7.0 |
| 2K | lossy | nvJPEG2000 | 0.523 | 156 | 29.5 |
| 2K | lossless | fvJPEG2000 | 0.224 | 258 | 7.2 |
| 2K | lossless | nvJPEG2000 | 1.373 | 249 | 29.8 |
| 4K | lossy | fvJPEG2000 | 0.355 | 230 | 7.5 |
| 4K | lossy | nvJPEG2000 | 1.199 | 186 | 14.7 |
| 4K | lossless | fvJPEG2000 | 0.729 | 264 | 7.6 |
| 4K | lossless | nvJPEG2000 | 4.195 | 259 | 14.8 |
Decoding.
| Frame | Mode | Codec | J/frame, GPU | Card power, W | Logical cores |
|---|---|---|---|---|---|
| 2K | lossy | fvJPEG2000 | 0.178 | 177 | 7.6 |
| 2K | lossy | nvJPEG2000 | 0.254 | 255 | 4.1 |
| 2K | lossless | fvJPEG2000 | 0.435 | 179 | 28.6 |
| 2K | lossless | nvJPEG2000 | 0.794 | 343 | 3.5 |
| 4K | lossy | fvJPEG2000 | 0.517 | 176 | 25.9 |
| 4K | lossy | nvJPEG2000 | 0.653 | 279 | 4.8 |
| 4K | lossless | fvJPEG2000 | 1.384 | 182 | 27.8 |
| 4K | lossless | nvJPEG2000 | 2.471 | 325 | 3.4 |
What the tables show. On encoding nvJPEG2000 draws fewer watts — 156 against 230 on 2K lossy — but produces six and a half times fewer frames for them, and a frame costs it 4.3 times more; across all four tasks the gap runs from 3.4 to 6.1 times. On decoding the speeds are nearly equal while the card works differently: 176–182 watts for fvJPEG2000 against 255–343 for nvJPEG2000, and a frame costs us 1.3 to 1.8 times less.
What about the energy of the CPU
It was not measured, and that has to be said plainly. The GPU has a built-in counter of the joules it has spent, and it applies to the GPU alone. The CPU had no such measurement in this run, so there are no joules per frame for the CPU in this article and there cannot be. All we know about it is the load: how many logical cores the codec itself kept busy. That figure is measured correctly — the CPU time of the codec process, user plus kernel, divided by the wall time of the same run: if a process spends seventy seconds of CPU time over ten seconds of work, seven logical cores were busy on average, and other load on the machine does not enter the figure. What it cannot do is turn into watts: one busy core spends a different amount of power on different work.
Why this is not a detail. CPU load differs between the two codecs by a factor of several, and in opposite directions for encoding and for decoding. So the missing part — the energy of the CPU — is not the same for both and will not cancel out in a comparison: it shifts the conclusions, and it is worth knowing which way.
At encoding it is nvJPEG2000 that takes more of the CPU. It occupies 29.5 and 29.8 logical cores on 2K and 14.7 and 14.8 on 4K, while fvJPEG2000 stays between 7.0 and 7.6 on all four tasks. That work never entered the joules. The margin measured on the GPU is 3.4 to 6.1 times; counted over the whole system it would be larger, not smaller.
At decoding it is fvJPEG2000 that takes more of the CPU, and that limits the conclusion of this section. Here the picture is reversed: nvJPEG2000 occupies 3.4 to 4.8 logical cores, while fvJPEG2000 takes 28.6 on 2K lossless and 25.9 and 27.8 on 4K, and only on 2K lossy does it stay at 7.6. The reason is where the optimum landed: on three tasks out of four the best point of the fvJPEG2000 decoder went to thirty-two threads, and thirty-two logical cores are the whole machine. So "a frame costs fvJPEG2000 1.3 to 1.8 times less" is a statement about the GPU, not about the system as a whole. Whether it still holds once the CPU is counted in, these measurements cannot say.
And it is not a price you have to pay. What follows is about the fvJPEG2000 decoder only: it is the one whose optimum went to thirty-two threads, and those threads buy a few per cent of speed. Here are its three tasks out of four where that happened, next to a thriftier point of the same grid — the numbers come from the same runs as section 7.
| Task, fvJPEG2000 decoder | Optimum | Frames per second | Logical cores | 16×2 point | Logical cores | Cost of stepping down |
|---|---|---|---|---|---|---|
| 2K, lossless | 32×2 | 436.2 | 26.6 | 424.6 | 14.2 | 2.7 % |
| 4K, lossy | 32×2 | 394.5 | 21.6 | 377.2 | 13.4 | 4.6 % |
| 4K, lossless | 32×1 | 145.1 | 26.2 | 139.9 | 13.4 | 3.7 % |
Three to five per cent of speed cost twice as many busy cores. The figures carry a decimal on purpose: rounded to whole frames per second, the percentages in the last column would come out a tenth of a per cent lower, and a reader divides what he sees. On 2K lossless there is an even better point: 8×4 gives 425.4 fps at 7.4 logical cores — the same speed as 16×2, three and a half times less CPU than at the optimum, and 2.5 % behind it. So if the CPU in the system is needed for anything else, those per cent are worth giving up for a smaller point; and the question of the missing CPU energy becomes far less pressing at the same time.
One caveat about the figures in this table: they come from the ordinary grid runs, while the tables above carry the figures of the energy run, which is longer. So 26.6 here and 28.6 there are the same quantity measured in two runs; they differ by a fraction of a core.
How to measure it, should it be needed. Modern processors have a built-in counter of the energy the die has spent — Intel calls it RAPL, AMD has the same thing. It reports the energy of the whole processor rather than of a single program, so it has to be read on a quiet machine and by the same difference of two runs as on the card: a reading before and after, twice, on N frames and on 2N. On Linux the counter is available out of the box; on Windows it needs a third-party driver, and that is a piece of work rather than a line in a script. We will do it in the next series of measurements, and then joules per frame can be given for the whole system.
And one more resource — GPU memory. Speed at the best point does not come free: every frame in flight takes up room in the memory of the card. We measured this the same way for both codecs — from the outside, by the card itself: the peak memory in use during the run minus what was in use before it.
| Task | FV point | Frames in flight | FV, GB | NV point | Frames in flight | NV, GB |
|---|---|---|---|---|---|---|
| Encoding 2K, lossy | 8×2 | 16 | 2.5 | 32×2 | 64 | 6.8 |
| Encoding 2K, lossless | 8×2 | 16 | 2.5 | 32×2 | 64 | 6.8 |
| Encoding 4K, lossy | 8×1 | 8 | 4.7 | 16×2 | 32 | 12.8 |
| Encoding 4K, lossless | 8×1 | 8 | 4.6 | 16×2 | 32 | 12.8 |
| Decoding 2K, lossy | 8×4 | 32 | 2.5 | 8×4 | 32 | 2.1 |
| Decoding 2K, lossless | 32×2 | 64 | 4.0 | 8×4 | 32 | 2.2 |
| Decoding 4K, lossy | 32×2 | 64 | 11.6 | 8×4 | 32 | 7.2 |
| Decoding 4K, lossless | 32×1 | 32 | 6.8 | 8×4 | 32 | 7.5 |
Per frame in flight both sides spend comparable amounts: when encoding, fvJPEG2000 takes 25 to 27 uncompressed frame sizes and nvJPEG2000 17 to 18; when decoding both stay within 8 to 14. The difference in the totals does not come from that but from the fact that the best points of the two codecs are different. nvJPEG2000 reaches its maximum with 64 frames in flight and fvJPEG2000 with 16, which is why on 2K encoding it takes 6.8 GB against 2.5, and on 4K 12.8 against 4.7. On decoding the same rule works the other way round: there it is our decoder that moved to 32 threads, and on 4K lossy it takes 11.6 GB against 7.2 — for the very speed gain whose price was discussed in section 7. Now that price has a second component besides the busy cores.
This is also where the dash in the 32×2 row of the 4K table in section 6 comes from. Eight frames in flight cost the fvJPEG2000 encoder 4.7 GB; sixty-four would require about thirty-seven, and the card has twenty-two. The combination was not left out by decision but because it does not fit.
How this was measured and what to trust here. The numbers are taken across the whole card, not per process: on Windows memory is not reported per process. So every number includes a fixed part as well — the CUDA context, about half a gigabyte — and they hold on condition that nothing else was on the card during the measurement. The programs' own numbers, printed as "Requested GPU memory size", are no good for comparison: for Fastvideo SDK this is the amount it requested, and it differs from the measured value by 1.1 to 2.0 times, while for our nvJPEG2000 harness it is only its own frame buffers, without what the library allocates inside itself, and there the disagreement reaches eighteen and a half times. Two codecs cannot be compared by those numbers, and we do not do it.
12. What this means in practice: latency and throughput
Here the measurement results are turned into a decision — what to choose for your task.
Encoding — fvJPEG2000 is faster, in all eight combinations: from 3.9 to 6.6 times on throughput and from 1.5 to 2.5 times on single image mode (min latency). There is mainly one reason — the nvJPEG2000 encoder barely speeds up from multithreading; on top of that it occupies two to four times more logical CPU cores.
Decoding — the same performance at the best combination of threads and batch, while in single image mode nvJPEG2000 is 1.5 to 2.1 times faster, and where the time of a single frame matters that is decisive. nvJPEG2000 is, however, noticeably lighter on the CPU: three to five logical cores against our seven, and against twenty-six on three tasks out of four.
At the same file size the quality is the same — the PSNR difference is within three tenths of a decibel in favour of nvJPEG2000, indistinguishable by eye. Energy per frame repeats the speed picture but does not amplify it: on encoding the energy gap is close to the speed gap or slightly smaller; on decoding, at nearly equal speed, a frame costs fvJPEG2000 1.3 to 1.8 times less.
These conclusions are worth restating in terms of applications, because in different applications the requirements for the encoder and the decoder can differ a great deal.
Where encoding is required. These are camera applications, embedded systems among them: the data comes from cameras and has to be compressed right away, and no slower than the given frame rate. Both values matter here: throughput and single-frame latency — in real-time capture a frame has to be compressed before the next one arrives. On encoding latency fvJPEG2000 is ahead by 1.5 to 2.5 times.
- Camera and industrial pipelines. The stream from the sensor goes through transform algorithms and then into JPEG2000, with no intermediate frames written.
- Satellite and aerial imaging. Compression happens on board, then the data is transmitted and decoded on the ground. The encoder works where power, weight and the communication channel are limited, which means the price of a frame in joules and the performance of a single GPU matter a lot.
- Film scanning and digital cinema package (DCP) mastering. Thousands of frames in a row, each in JPEG2000 lossless or at high quality; the winner is whoever processes the whole material faster.
- Microscopy and medical imaging. The frames are large, capture is continuous, and resolution keeps growing.
Where decoding is what matters. These are applications for viewing and processing finished material.
- Playback of digital cinema packages and master material. A stream of frames has to be decoded in real time, without drops and with minimal latency; single image mode matters here: every frame must arrive on time.
- Working through archives. Terabytes of already compressed material, and on this task the free NVIDIA library should work very well.
- Viewing and selective delivery of images — satellite, medical, cartographic: the user opens a frame and waits, so the time to decode a single frame and put it on the monitor matters.
And one more thing, about hitting a given file size. If the pipeline has to fit a given bandwidth of the channel or the medium, that shows up in the speed: PCRD mode in fvJPEG2000 runs slower than a fixed quality parameter — by 1.3 to 1.8 times in single image mode and by 1.5 to 2.8 times at the best combination of threads and batch. The smaller gap is when the base quality has been set and PCRD only brings the size down; the larger one is when PCRD does all the work (section 10). This has to be built into the calculation up front, rather than found out on a finished system.
All the results above were obtained on two ordinary photographic frames. On your material — with noise, with text, with medical or satellite specifics — the ratios will be different. Send us your frames: we will run them through both codecs by the same procedure and return the table and the decoded images, so that the conclusion is yours, not ours.
13. How to reproduce the measurements and to get the maximum performance
This is the section everything else was written for: a method is worth something only when you can run it yourself.
All measurements are done by a single Python script. It builds the benchmark harness for nvJPEG2000 itself, prepares the reference files, searches for the quality setting that gives the same file size, runs encoding and decoding, and prints the table. The source images are published on the site. There are no hidden steps in the procedure: the output of every run, together with the full command line, is saved to a log, and any result can be reproduced by hand.
A full run with three repeats per point takes about an hour. The script picks the number of frames in each test itself, so that the measuring window is the same length everywhere — a fast point does not degenerate into a fraction of a second of measuring, and a slow one does not stretch out of proportion.
What goes into the measured time does not have to be taken on trust. The measuring part of the Fastvideo SDK ships as source, the nvJPEG2000 program is in our open repository (bench/nvj2k_bench-02/nvj2k_bench-02.cpp), and the NVIDIA samples are in CUDALibrarySamples under a BSD licence. The boundaries described in sections 4.2 and 4.2.1 were read off those files, and every one of them can be found there by eye.
All of it is publicly available — the script, the benchmark harness, the results and the logs. Where exactly, and how to use it, is the next section.
14. Open project on GitHub: code and measurement data for the JPEG2000 codec test
The script and the benchmark harness are published on GitHub: github.com/fastvideo/jpeg2000-benchmark
Why it exists. Any codec comparison published by one of the parties rightly raises the question: were the conditions cherry-picked? The answer to that question is not assurances, but the ability to take the procedure and run it yourself, on your own GPU and your own images, and to check the sources. The results in this article were obtained for the chosen images and for one GPU. The repository is a way to get the results of your own tests.
The second reason is simpler: the method is awkward to retell. It is far clearer to show it as code, where every decision is visible — which switches are set, what is included in the measured time and what is not, exactly how the quality search works and how the quality check is computed.
What is there now:
- the script that performs all the measurement stages described in this article;
- the source code of the nvJPEG2000 benchmark harness — the very one whose timing rule is analyzed in section 4.2;
- the results of every measurement run: ready tables, the same data in machine-readable form, and the raw logs of every run;
- this article in full — next to the results it refers to;
- links to the source images;
- a short README: what this is, how to run it, what you get.
Why the article is there too. The repository is a standalone entry point: people arrive here from search, fork from here, carry a copy to a machine with no internet. A repository where you cannot read the procedure without opening a browser and finding the right page works only halfway. There is still only one text: the repository holds a snapshot of the article with a date and a link to the original, and it is updated only by exporting from the original, not by hand. The snapshot is tied to the results folder of the same measurement run — so a year later it is clear which revision of the method produced those results.
What we want to add next — as new measurements become ready, with no deadlines:
- OpenJPEG as a third participant in the comparison — the CPU implementation everyone else is usually compared against, and it is open;
- results on other GPUs and at other bit depths, as they are obtained;
- a separate page describing exactly what changed between measurement runs: driver version, library version, codec version.
How to use this. The simplest way is to make your own copy of the repository (a fork) and run the measurements yourself: in a topic like this, someone else's results are worth less than your own. nvJPEG2000 is free and downloadable from the NVIDIA site. The fvJPEG2000 codec is run as follows:
- speed is reproduced on the demo version of the SDK — it is freely downloadable, the link is in the repository;
- the quality check is also reproduced on the demo version: as the PSNR reference the script takes the frame after a lossless round trip on the same build, as described in section 9, so the watermark does not enter the calculation;
- a build without the watermark is only needed by those who want to compare the decoded frame directly with the source file. It is provided on request — write to us, and we will send it.
The license on the script and the benchmark harness is permissive — the only expected form of participation here is a fork. The SDK libraries come under their own license, which is stated separately.
Remarks on the procedure go to the repository's issues section: an error in the method is more useful found before the next numbers are published than after.
15. What remains unverified
The list is kept in the open, because it is part of the method: the reader must see where a conclusion is backed by measurement and where by reasoning. Five items out of nine are closed by measurement; four remain.
Closed.
- Each decoder read the file of its own encoder. This was the main threat to the conclusion: files of the same size are not necessarily of the same internal complexity, and a comparison of decoders could in fact turn out to be a comparison of what the encoders produced. Cross-decoding was carried out across all eight combinations of conditions: nowhere did the difference exceed 1.4 %, and in six cases out of eight it is under half a percent. The conclusion about the decoders held.
- Quality control across all eight combinations of conditions. Done, the results are in section 9: with lossless compression — an exact match for both codecs; with lossy compression — a PSNR difference within three tenths of a decibel.
- The matching Q value. What was checked is what the item was created for: whether this is a trace of the search procedure itself. The search was run from two different starting intervals and at three quality levels: the values found barely depend on the choice of interval, that is, the procedure adds nothing of its own. The scales of the two codecs are very close but do not coincide: between two frames the equivalents found differ by 0.07–0.12. We did not establish the exact correspondence of the scales, that is a separate task. Details in section 3.3.
- The speed of PCRD mode when quantization is set. Measured in a separate run, results in section 10. At one and the same file size PCRD alone slows encoding down by 1.5 to 1.8 times in single image mode and by 2.0 to 2.8 times at the best combination of threads and batch; a well chosen base quality narrows the gap to 1.3 to 1.6 times in single image mode and at the same time gives a better PSNR.
- Profiling of the nvJPEG2000 encoder. Done with NVIDIA Nsight Systems; the results are in section 8. The conclusion that the encoder gains almost nothing from multithreading used to rest on indirect evidence; it is now confirmed directly. At the best combination of threads and batch the AKC of nvJPEG2000 stays between 0.90 and 1.39, so at 4K the GPU is idle part of the time even at the optimum, and its main kernel is launched 1.00 to 1.04 times per frame, because frames are never merged into a single launch.
Remaining.
- Chroma subsampling. The whole comparison runs at 4:4:4. The NVIDIA codec takes components already brought to the required size, so the subsampling would have to be done by outside code, and the time of our filter, not the work of the codecs, would land inside the measured time. Such a comparison is worth making, but separately and with the filter stated explicitly; we did not do it.
- The decoder's output format. The nvJPEG2000 benchmark harness leaves the result as separate planes. If the fvJPEG2000 decoder assembles them into interleaved RGB inside the measured interval, that is work done by only one side. The stage breakdown showed that the inverse transforms — MCT and the level shift — take about five percent of the time in the fvJPEG2000 decoder. That is less than the gap between the codecs, so it does not affect the conclusion, but for an exact comparison the correction is worth keeping in mind.
- Why one point has two stable states. nvJPEG2000 decoding, 2K lossy, 8×1 gives either 309 frames per second or 539; the state is decided when the program starts and holds for the whole run. The cause is on the CPU side: in the slow state a frame costs 45 % more CPU time while the GPU runs at the same clock. Thread placement across the cores and the way the GPU is waited for were both checked and ruled out. What the split does depend on is the number of threads: it appears at eight and sixteen threads and nowhere else. This is the only result of its kind in the article; the other twenty-three decoding points repeat within a couple of percent.
- Where Tier-2 runs in the nvJPEG2000 encoder. The NVIDIA documentation names the CPU stage for the decoder only; about the encoder it says merely that both the GPU and the CPU are used.
16. What comes next
This article is a first step, not a conclusion. Below is what is planned next: first the measurements, then the place where all of it lives permanently.
Upcoming measurements.
| Topic | What it gives |
|---|---|
| 12 and 16 bit, monochrome | this is exactly where medical and satellite imaging live |
| Other cards | RTX 5090, professional and server cards |
| Jetson | the same codec on an embedded platform |
| 8K and multi-tile frames | where a frame no longer fits into memory as a whole |
The first two topics are already clear in their setup and will most likely become separate articles. At 12 and 16 bits it is not only the data volume that changes but the material itself: medical images and satellite frames are built differently from photographic scenes. The results of this series cannot be carried over to them — they were not part of the measurements. For Jetson a draft is already written — there the main quantity is not speed but energy per frame, and results from a desktop card do not carry over, not even as ratios.
Open project. Everything needed to reproduce this is in the jpeg2000-benchmark repository — it is described in section 14. New measurements from the list above will go there as well, together with the conditions and dates.
Rights to this material
The text of the article is under the CC BY-ND 4.0 license: it may be reprinted in full and quoted, including in commercial publications, with a link to the source; rewriting and translating — by agreement with us, and we usually do not object. The reason for the restriction is simple: a rewritten description of the procedure circulating under our name harms both the reader and the measurements themselves.
The measurement results and tables are under the CC BY 4.0 license, without that restriction: they may be carried over into your own materials, reassembled and used for further calculations. If you changed something, say what exactly.
The source link in both cases: Fastvideo, <article address>, measurements of <date>. Please state the measurement conditions next to the results: without them the result is not reproducible, and a result that has outlived its conditions is worse than no result at all.
Neither of these licenses applies to the images.
Appendix. The standard NVIDIA samples and our own wrapper
The appendix is divided into two parts. First, there are four observations about the nvJPEG2000 library from open sources. Second, a measurement that answers the question raised by these observations: does the NVIDIA library perform well when used with our own wrapper?
What is known about the nvJPEG2000 encoder from open sources
There are four observations from open sources. The first is a direct quotation from the documentation; the other three are indirect. All of them are consistent with the measurement results.
The NVIDIA documentation names the CPU stage only for the decoder. Regarding the decoder, it states the following: 'Tier-2 runs on the CPU; all the other stages are offloaded to the GPU.' Regarding the encoder, it only states that the library uses both the GPU and the CPU, and that the compressed image is written to host memory; which part of the work goes to the CPU is not stated (the nvJPEG2000 documentation).
The NVIDIA sample set includes a pipelined decoding sample, but not a pipelined encoding sample. The nvJPEG2000-Decoder-Pipelined sample shows decoding through several CUDA streams. The standard encoder sample processes frames strictly one after another: one CUDA stream, one encoder state, synchronization after every frame.
The library interface has no function that takes an array of images. Neither for encoding nor for decoding: only nvjpeg2kEncode and nvjpeg2kDecodeImage, for one frame. Checked against the header file, not the documentation.
In NVIDIA's own samples the decoder gets noticeably more attention than the encoder. The official CUDALibrarySamples set for nvJPEG2000 contains three decoding samples — a plain one, a pipelined one (nvJPEG2000-Decoder-Pipelined) and partial tile decoding — and one encoding sample, the plain one (the NVIDIA CUDALibrarySamples set). There is no pipelined sample for the encoder. This is indirect evidence, but it agrees with everything else the measurements show.
It is worth noting separately which results NVIDIA publishes itself. Public materials contain measurements for decoding — for example, in the blog post about the nvImageCodec library, which discusses accelerated decoding of medical images: it gives the GPU models, the image sizes, and a comparison with a CPU implementation (the NVIDIA blog post on decoding medical images). We were unable to find published results for JPEG2000 encoding — neither in the library documentation nor in the blog. This is an observation, not a reproach: they may simply never have been published.
Why the results of this article cannot be compared with the benchmarks from the NVIDIA blog
The blog post of 24 June 2021 has a chart of decoding speed for a 1920 × 1080 8-bit 4:4:4 lossless frame with the 5/3 wavelet (NVIDIA blog on JPEG2000 decoding in digital pathology). The resolution, the bit depth and the wavelet are the same as in section 7, and that is not enough to put the numbers side by side.
The chart was taken on version 0.2 of the library, and five years have passed since. The images are different ones, and lossless decoding speed is set by the content of the frame: the decoder parses as many bits as the code stream holds. The parameters those images were compressed with — the code block size, the number of resolution levels, the progression order — are not named in the post, and a decoder works exactly as long as the encoder made it work. And the measurement boundary on NVIDIA's side is a different one: parsing the compressed image on the CPU is outside their figure and inside ours (section 4.2.1). So the comparison with that chart is not made here.
There is another way to find out whether the library is given a fair run: not with someone else's numbers, but with a measurement on one test system, on one day, on the same files. That is what follows.
The standard NVIDIA samples against our own wrapper
All three columns are the same library doing the encoding — nvJPEG2000 from NVIDIA. Only two things differ: which program calls it, and how that program feeds the frames. On the left, the sample from the CUDALibrarySamples set: the program NVIDIA wrote for its own library and published itself, taken unchanged. In the middle, our own wrapper calling the same NVIDIA library and processing frames in the same order, one after another; it differs from the left column only in the code around nvJPEG2000 — reading the file, its own timer, the order of the calls. On the right, the same wrapper and the same NVIDIA library, but with several CPU threads and several frames in flight at once.
The middle column tests the measurement program, not the codec. If the code we wrap around the NVIDIA library kept it from running at full speed, the middle column would come out below the left one — and then the right column would prove nothing, because the gain could be put down to the library never having had a fair chance.
The NVIDIA sample was measured in both of the file-feeding modes it allows, and the better of its results went into the table. In either mode it processes frames strictly in order: one codec state, one CUDA stream, a synchronisation after every step.
Both encoders were given the same job, and that is checked rather than assumed. The standard NVIDIA encoder, run with the parameters of section 3.1, produced files of 601,940, 2,966,036, 1,274,517 and 8,964,924 bytes — byte for byte what our own wrapper produced.
Encoding, frames per second
| Frame and mode | NVIDIA sample | Our own wrapper | Our own wrapper |
|---|---|---|---|
| frames in order | frames in order | several frames at once | |
| 2K, lossy | 197 | 199 | 291 |
| 2K, lossless | 144 | 148 | 185 |
| 4K, lossy | 125 | 128 | 171 |
| 4K, lossless | 56 | 57 | 66 |
The best combination of thread count and batch size in the right-hand column: 32×1 on 2K lossy and on both 4K frames, 16×2 on 2K lossless.
The left and the middle column are 1 to 3 % apart. Two different programs on one test system, on the same files, on the same day get the same thing out of the NVIDIA library. So our wrapper does not keep it from running at full speed, and the whole gain in the right-hand column — 1.2 to 1.5 times — comes from the way the frames are fed, not from the difference between the programs.
Decoding needs a correction before the columns can be compared. The timer of the standard NVIDIA sample covers the parsing of the compressed stream and the decode call, but it measures the parsing in whole seconds, so the parsing enters its figure as zero. The timer of our wrapper covers the same region with the parsing counted in full. We measured how far apart they are: both programs were given frames one after another, the frame stays on the card in both cases, and the difference was taken.
| Frame and mode | NVIDIA sample, ms per frame | Our own wrapper, ms per frame | Difference | Share of the frame time |
|---|---|---|---|---|
| 2K, lossy | 3.16 | 3.38 | 0.23 ms | 6.7 % |
| 2K, lossless | 4.01 | 4.21 | 0.20 ms | 4.7 % |
| 4K, lossy | 4.63 | 5.15 | 0.52 ms | 10.1 % |
| 4K, lossless | 10.61 | 10.98 | 0.37 ms | 3.3 % |
At decoding, then, the left column is 3 to 10 % high before any comparison begins, and the gap between it and the middle column is explained by that alone, not by the speed of the library.
Decoding, frames per second
| Frame and mode | NVIDIA sample | Our own wrapper | Our own wrapper |
|---|---|---|---|
| frames in order | frames in order | several frames at once | |
| 2K, lossy | 317 | 296 | 1575 |
| 2K, lossless | 249 | 237 | 470 |
| 4K, lossy | 217 | 194 | 581 |
| 4K, lossless | 95 | 91 | 146 |
The best combination in the right-hand column: 8×2 on both 2K frames, 32×1 on both 4K frames.
Our own wrapper speeds up the nvJPEG2000 library itself: 1.2 to 1.5 times at encoding and 1.6 to 5.3 times at decoding. The ratio to take is the one from the middle column to the right-hand one: the same program with the same timer, and only the way of feeding frames differs. The figures are for the best combination of threads and batch found by search (section 4.3); the gain broken down by task is in the table at the top of the article.
Energy per frame — there is no column for the NVIDIA sample here, and this is why. Energy is counted by the card's own meter, by the same difference of two runs. The meter counts the whole time the program is running, and the standard sample re-reads the input files from disk for every batch, while our wrapper reads a file once. On 4K encoding that is 10.7 seconds of reading out of twenty-four seconds of work: in joules per frame the NVIDIA sample would be measured mostly on the disk, not on the codec. Speed is not affected — there each program's own timer is used, and it does not count the time spent reading files.
Energy of one frame, joules
| Frame and mode | Encoding | Decoding | ||
|---|---|---|---|---|
| frames in order | several frames at once | frames in order | several frames at once | |
| 2K, lossy | 0.68 | 0.59 | 0.45 | 0.22 |
| 2K, lossless | 1.45 | 1.36 | 0.90 | 0.79 |
| 4K, lossy | 1.38 | 1.22 | 0.87 | 0.62 |
| 4K, lossless | 4.42 | 4.15 | 2.77 | 2.46 |
Both columns are our own wrapper and the nvJPEG2000 library; only the way of feeding frames differs. Feeding several frames at once saves from 7 % of the energy of a frame at lossless encoding up to a factor of two at 2K lossy decoding: the card does the same work in less time and spends less of it waiting for the next frame.
The standard NVIDIA samples take no part in the comparison of the two codecs in sections 6 and 7. There the nvJPEG2000 library runs under our own wrapper, that is, in the best shape we could measure it in. A comparison against the standard sample would have made the margin of fvJPEG2000 larger than it is.
Measurement conditions. RTX 4090, driver 610.88, Windows 11, a CPU with 32 logical cores. The compression parameters are the ones of section 3.1: 32×32 code block, six resolution levels, one quality layer, LRCP progression, no tiling. The cells hold the results each program measures for itself. Every point was measured twice — on N frames and on twice as many; in most points the two measurements agree within 1 %, and in two points of the right-hand column they are up to 7 % apart. Every point in these tables ran at the full GPU clock, 2745 to 2760 MHz; the clock was sampled ten times a second throughout each measurement.
This is a separate run, not the one sections 6 and 7 are made from, so the numbers differ from them by a few per cent: the grid of combinations here is shorter. What should be compared is the columns inside these tables — they were measured one after another, under the same conditions. The run script and the full logs are in the repository (section 14).
Further reading
- Benchmarks for JPEG2000 encoders on CPU and GPU — fvJPEG2000 against CPU J2K encoders on the same images
- Benchmarks for J2K decoders on CPU and GPU — the same for decoding
- Fast JPEG2000 Codec on GPU: CUDA Encoder and Decoder — features, licensing and support
- github.com/fastvideo/jpeg2000-benchmark — the benchmark program, the results and the logs