|
LensSerious 0.1
Lens-correction mathematics as data, not as a library of callbacks
|
A lens correction is only ever wanted so that something can resample with it. That sentence is the whole design, and it is worth stating before any code: the displacement map is not the product, it is an intermediate that one particular implementation happens to need.
lensfun needs it. Its corrections are delivered through callbacks that fill a buffer — lfModifier::ApplySubpixelGeometryDistortion() writes six floats for every output pixel, and the consumer reads them back to sample with. There is no way to ask lensfun for "the source
coordinate of this one pixel" cheaply, so the buffer is mandatory.
LensSerious has no such constraint. ls_eval_map() is a pure function of an ls_eval_t and a coordinate, so a consumer can call it where it needs the answer and never write a map at all. On a GPU that is exactly what happens: the evaluator is #included into the kernel that consumes the coordinates, and the correction crosses the bus as ~80 bytes of coefficients instead of six floats per pixel.
For a 24 Mpx frame, six floats per pixel is 549 MB. A two-pass pipeline writes all of it and then reads all of it back: 1.1 GB of traffic that a fused pass simply does not generate.
On a GPU that is the difference between 558 ms and 3.4 ms — the map has to be built on the CPU (482 ms, single-threaded, because lensfun's callbacks are) and then uploaded (76 ms). Evaluating in the consuming kernel is 165× cheaper, and the ratio is not subtle because neither term survives.
This is the part that is easy to get wrong, and the reason this page exists.
The same fusion on the CPU is not an unconditional win. Measured with a bilinear resampler over a 24 Mpx frame (tests/bench_lensfun.c), all three producing the same image:
| correct + resample | 1 thread | 2 | 4 | 8 |
|---|---|---|---|---|
| lensfun: map, then resample | 765 ms | 355 | 237 | 236 |
| LensSerious: map, then resample | 744 ms | 347 | 229 | 218 |
| LensSerious: fused, no map | 874 ms | 438 | 227 | 174 |
| fused vs. lensfun | 0.88× | 0.81× | 1.04× | 1.35× |
Fusing loses below four threads and wins above, crossing over at four:
A GPU sits so far past that crossover that the question does not arise. A CPU sits on either side of it depending on how many cores the caller gave the job.
lensserious_eval.h in the kernel that consumes the coordinates and call ls_eval_map() per work-item. opencl/lensserious.cl keeps map-writing kernels only so a host can verify the device against the CPU; they are not the way to use this.ls_correct_and_resample() in the API. A convenience function that fused for you would look like a free win and would be a loss on a two-core machine. The library gives you a per-pixel evaluator; the loop structure is the caller's decision, because only the caller knows the thread count and the filter.ls_eval_map() evaluates one pixel from absolute coordinates and has no state, so a fused loop, a row walker and a GPU work-item all pass identical operands to identical expressions. That is why ls_modifier_apply_subpixel_geometry() takes absolute xu/yu and sums + col at the call site rather than stepping an accumulator: an incremental step would be cheaper and would round differently, and the CPU and the kernel would stop agreeing.
The one caveat is not in this library: a consumer built with -ffast-math (Ansel is) lets the compiler reassociate and contract the CPU side, so its results are no longer bit-identical to the GPU's. The parity harness is built without it, and that is where the bit-exactness claim is asserted.