Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
sparse_cholesky.h
Go to the documentation of this file.
1/*
2 This file is part of Ansel,
3 Copyright (C) 2026 Aurélien PIERRE.
4
5 Ansel is free software: you can redistribute it and/or modify
6 it under the terms of the GNU General Public License as published by
7 the Free Software Foundation, either version 3 of the License, or
8 (at your option) any later version.
9
10 Ansel is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with darktable. If not, see <http://www.gnu.org/licenses/>.
17 */
18
19#pragma once
20
21// Reusable exact sparse SPD Cholesky solver (double precision), factored out of the
22// highlights harmonic-transposition code. Header-only, like common/../choleski.h. Takes a
23// symmetric-positive-definite matrix in upper-triangular compressed-sparse-column form and
24// solves A x = b; the caller assembles the matrix (see e.g. the region PDE assembly in the
25// highlights module). Large scratch buffers use the pipeline-cache arena, so the caller
26// passes the dt_dev_pixelpipe_t.
27
28#include <limits.h>
29#include <math.h>
30#include <stdlib.h>
31#include <string.h>
32
33#include "common/darktable.h"
34#include "develop/pixelpipe_hb.h" // dt_dev_pixelpipe_t (arena alloc) — self-contained include order
35
36// Factored SPD matrix (lower-triangular Cholesky factor L, column-compressed).
37typedef struct
38{
40 int *col_ptr; // dimension+1 column pointers
41 int *row_index; // row indices
42 double *values; // the FIRST entry of each column is the diagonal
44
45// ===== standalone sparse Cholesky for the region solvers ===================================
46// Exact direct solver for the symmetric-positive-definite (SPD) diffusion systems, in DOUBLE
47// precision (64-bit floats: single-precision conjugate gradient stalls -- its convergence
48// degrades with the fourth power of the hole size -- and single-precision Cholesky loses the
49// near-singular biharmonic modes). CSparse-style "up-looking" factorization A = L * L^T (L
50// lower-triangular, computed row by row) with a GEOMETRIC nested-dissection ordering: the
51// unknowns are 2D grid points, so the fill-reducing reordering that normally needs a generic
52// heuristic (approximate minimum degree) comes free from recursive bisection of the pixel
53// coordinates. No external dependency. Used by _biharmonic_dome (exact dome on a much finer
54// grid than the dense O(N^3) solve allowed) and _region_pde_solve (exact solve, factor shared
55// by the three chroma channels). Both keep their previous solver as fallback.
56
57
58
59// release a CPU sparse Cholesky factor (arrays + struct); NULL-safe
60static inline void _sp_chol_free(_sp_chol_t *factor)
61{
62 if(!factor) return;
66 free(factor);
67}
68
69// In-place geometric nested dissection (a reordering of the unknowns that keeps the Cholesky
70// factor sparse): rearrange ids[] so that recursive halves come first and their separating
71// band (width = the stencil reach) comes last -- the elimination order that keeps Cholesky
72// fill-in at the 2D-optimal O(N log N). Iterative with an explicit range stack.
73// unknown_x/unknown_y give each unknown's pixel coordinates.
74//
75// Nested dissection (maths bridge): the unknowns are pixels of a 2D grid, and the PDE operators
76// factored here (biharmonic Delta^2 L_sum = 0 for the luminance dome, screened Poisson
77// Delta^2 r - lambda r = ... for the chrominance, anisotropic div(D grad p) = 0 for the coefficient
78// transport -- article "Guided laplacian highlights", sections "Biharmonic inpainting" and "The
79// optimization problem", energies E_bihar / E_chrominance / E_transport) are all local stencils, so
80// two grid halves separated by a band of width `reach` interact ONLY through that band. Ordering
81// each half's unknowns before the separator makes the halves' blocks factor with no mutual fill;
82// only the (small) separator block fills. Recursing gives O(N log N) factor nonzeros / O(N^1.5)
83// flops, versus O(N^1.5) fill / O(N^2) flops for the natural raster order. This geometric bisection
84// replaces the generic approximate-minimum-degree heuristic a black-box sparse solver would need.
85static inline void _sp_nd_order(int *const restrict unknown_ids, const int count, const int *const restrict unknown_x,
86 const int *const restrict unknown_y, const int reach)
87{
88 typedef struct
89 {
90 int begin, end;
91 } _index_range;
92 int capacity = 64;
93 int stack_top = 0;
94 _index_range *stack = (_index_range *)malloc(sizeof(_index_range) * capacity);
95 if(!stack) return; // natural order still works, just with more fill
96 stack[stack_top++] = (_index_range){ 0, count };
97
98 // Iterative recursion over an explicit work stack (no call stack): each stack entry is a
99 // still-to-dissect sub-range of unknown_ids[]. Each pass pops one range, splits its unknowns
100 // into two halves plus a separator band, and pushes the two halves back to be dissected in turn.
101 while(stack_top > 0)
102 {
103 // `range` = the [begin, end) slice of unknown_ids[] currently being dissected.
104 const _index_range range = stack[--stack_top];
105 const int length = range.end - range.begin;
106 if(length <= 64) continue;
107
108 int xmin = INT_MAX, xmax = INT_MIN, ymin = INT_MAX, ymax = INT_MIN;
109 for(int i = range.begin; i < range.end; i++)
110 {
111 const int unknown_id = unknown_ids[i];
112 xmin = MIN(xmin, unknown_x[unknown_id]);
113 xmax = MAX(xmax, unknown_x[unknown_id]);
114 ymin = MIN(ymin, unknown_y[unknown_id]);
115 ymax = MAX(ymax, unknown_y[unknown_id]);
116 }
117
118 const int extent_x = xmax - xmin + 1;
119 const int extent_y = ymax - ymin + 1;
120 if(MAX(extent_x, extent_y) <= 2 * reach + 1) continue; // too thin to dissect
121
122 // bisect along the longer axis (keeps the separator band as short as possible => less fill),
123 // cutting at the midpoint coordinate of that axis
124 const int split_on_x = (extent_x >= extent_y);
125 const int *const coord = split_on_x ? unknown_x : unknown_y;
126 const int cut_position = (split_on_x ? xmin + extent_x / 2 : ymin + extent_y / 2);
127
128 // Three-way partition of the unknowns in unknown_ids[range.begin .. range.end) by each one's
129 // coordinate along the split axis, into: [ < cut_position | >= cut_position + reach | separator ].
130 // The separator (the middle band of width `reach`) is moved to the tail so it is eliminated
131 // last; the two flanking halves are pushed back onto the stack to dissect recursively.
132 int left_end = range.begin;
133 int right_end = range.end;
134 int i = range.begin;
135 while(i < right_end)
136 {
137 const int coord_value = coord[unknown_ids[i]];
138 if(coord_value < cut_position)
139 {
140 const int swap_id = unknown_ids[i];
141 unknown_ids[i] = unknown_ids[left_end];
142 unknown_ids[left_end] = swap_id;
143 left_end++;
144 i++;
145 }
146 else if(coord_value >= cut_position + reach)
147 {
148 i++;
149 }
150 else
151 {
152 right_end--;
153 const int swap_id = unknown_ids[i];
154 unknown_ids[i] = unknown_ids[right_end];
155 unknown_ids[right_end] = swap_id;
156 }
157 }
158 // now [range.begin, left_end) = left, [left_end, right_end) = right,
159 // [right_end, range.end) = separator (eliminated last)
160 if(left_end == range.begin && right_end == range.end) continue; // no separator found: done
161 if(stack_top + 2 > capacity)
162 {
163 capacity *= 2;
164 _index_range *grown = (_index_range *)realloc(stack, sizeof(_index_range) * capacity);
165 if(!grown) break;
166 stack = grown;
167 }
168 if(left_end - range.begin > 64) stack[stack_top++] = (_index_range){ range.begin, left_end };
169 if(right_end - left_end > 64) stack[stack_top++] = (_index_range){ left_end, right_end };
170 }
171 free(stack);
172}
173
174// Elimination tree (the column dependency order of the factorization: each column's parent is
175// the first column that uses its result) of an upper-triangular compressed-sparse-column (CSC)
176// matrix. Liu's classic algorithm with path compression via `ancestor`.
177//
178// Maths bridge: parent[k] = min{ i > k : L[i,k] != 0 } = the first row below the diagonal in
179// column k of the factor L, i.e. the first column whose elimination consumes column k's result.
180// This forest is exactly the column-dependency DAG the GPU level schedule parallelizes (columns
181// with disjoint root-paths are independent); on the CPU it drives _sp_ereach's pattern walk.
182static inline void _sp_etree(const int dimension, const int *const restrict col_ptr, const int *const restrict row_index,
183 int *const restrict parent, int *const restrict ancestor)
184{
185 // Not OpenMP-parallelizable: `ancestor` is path-compressed across columns, so column k reads and
186 // rewrites ancestor[] entries that earlier columns set -- a loop-carried dependency a parallel
187 // for would race on. It is also cheap (O(nnz * inverse-Ackermann)), far below the numeric
188 // factorization; the parallel factorization is the level-scheduled OpenCL path, not this.
189 for(int k = 0; k < dimension; k++)
190 {
191 parent[k] = -1;
192 ancestor[k] = -1;
193 for(int entry = col_ptr[k]; entry < col_ptr[k + 1]; entry++)
194 {
195 int i = row_index[entry];
196 // climb the partial tree from each above-diagonal nonzero A[i,k] (i < k) toward the root,
197 // compressing the path so every visited node points directly at k
198 while(i != -1 && i < k)
199 {
200 const int next_ancestor = ancestor[i];
201 ancestor[i] = k;
202 if(next_ancestor == -1) parent[i] = k; // i had no parent yet: k is its first user => parent[i] = k
203 i = next_ancestor;
204 }
205 }
206 }
207}
208
209// "Elimination reach": the set of columns that contribute to row k of the Cholesky factor
210// (mathematically L; stored column-compressed in the returned _sp_chol_t as its
211// values / row_index / col_ptr arrays) -- i.e. row k's nonzero pattern excluding the diagonal,
212// found by walking the elimination tree upward from each entry of column k of the input matrix A;
213// returned in topological (dependency) order. Returns `stack_top` such that
214// pattern_stack[stack_top..dimension-1] holds the pattern. mark[] holds per-k marks
215// (mark[i] == k means visited).
216//
217// Maths bridge: row k of L has a nonzero L[k,j] exactly for the columns j reachable from the
218// above-diagonal nonzeros of A's column k by walking parent[] up the elimination tree (the
219// symbolic Cholesky pattern theorem). Those j are precisely the columns whose contribution the
220// numeric factor must subtract when forming row k -- returned deepest-ancestor-last so the numeric
221// sweep applies them in valid dependency order (each L[j,*] already finalized when read).
222static inline int _sp_ereach(const int dimension, const int *const restrict col_ptr, const int *const restrict row_index,
223 const int k, const int *const restrict parent, int *const restrict pattern_stack,
224 int *const restrict mark)
225{
226 int stack_top = dimension;
227 mark[k] = k;
228 // Not OpenMP-parallelizable: the entries share `mark` (which dedups nodes already on the reach)
229 // and the single output stack, so parallel iterations would race both. The enclosing per-column
230 // loops in _sp_chol_factor are sequential anyway (column k depends on every column in its reach),
231 // which is why the parallel solver is the level-scheduled OpenCL path, not an OpenMP version here.
232 for(int entry = col_ptr[k]; entry < col_ptr[k + 1]; entry++)
233 {
234 int i = row_index[entry];
235 if(i >= k) continue;
236 int length = 0;
237 for(; mark[i] != k; i = parent[i])
238 {
239 pattern_stack[length++] = i;
240 mark[i] = k;
241 }
242 while(length > 0)
243 {
244 stack_top--;
245 pattern_stack[stack_top] = pattern_stack[--length];
246 // keep the path contiguous at the back: shift is avoided by the two-stack trick below
247 }
248 }
249 return stack_top;
250}
251
252// Up-looking sparse Cholesky A = L * L^T (Cholesky-Banachiewicz, row by row): factors the SPD
253// system of the region PDE / biharmonic dome (article "Guided laplacian highlights", sections
254// "Biharmonic inpainting" and "The optimization problem"). For each row k the elimination reach
255// (_sp_ereach) gives the columns j < k that contribute; the classic recurrences realized below are
256// off-diagonal L[k,j] = ( A[k,j] - sum_{m<j} L[k,m] L[j,m] ) / L[j,j] (j in reach of k)
257// diagonal L[k,k] = sqrt( A[k,k] - sum_{j<k} L[k,j]^2 )
258// implemented with a dense scratch row `work[]` (the running numerator A[k,*] - accumulated
259// products) that is scattered from column A[:,k], reduced by each reach column j, then read off.
260// Notation bridge (math symbol -> code name):
261// A the input matrix -> matrix_col_ptr / matrix_row_index / matrix_values
262// L the computed factor -> factor->col_ptr / factor->row_index / factor->values
263// L[k,k] the pivot (pre-sqrt) -> `pivot`
264// L[k,j] multiplier applied from earlier column j -> `multiplier`
265// the dense scratch row being eliminated for column k -> `work[]`
266// Returns NULL if the matrix turns out not positive definite or on out-of-memory.
267static inline _sp_chol_t *_sp_chol_factor(const int dimension, const int *const restrict matrix_col_ptr,
268 const int *const restrict matrix_row_index,
269 const double *const restrict matrix_values, const dt_dev_pixelpipe_t *pipe)
270{
271 // every O(dimension) or larger buffer lives in the pipeline-cache arena, so the LRU can evict
272 // cachelines to make room instead of the factorization competing blindly with them
273 _sp_chol_t *factor = (_sp_chol_t *)calloc(1, sizeof(_sp_chol_t));
275 int *ancestor = dt_pixelpipe_cache_alloc_align_int(dimension, pipe);
277 int *elim_stack = dt_pixelpipe_cache_alloc_align_int(dimension, pipe);
278 int *col_count = dt_pixelpipe_cache_alloc_align_int(dimension, pipe);
279 int *col_fill = dt_pixelpipe_cache_alloc_align_int(dimension, pipe);
281 if(!factor || IS_NULL_PTR(parent) || IS_NULL_PTR(ancestor) || IS_NULL_PTR(mark) || IS_NULL_PTR(elim_stack)
282 || IS_NULL_PTR(col_count) || IS_NULL_PTR(col_fill) || IS_NULL_PTR(work))
283 goto fail;
284
285 _sp_etree(dimension, matrix_col_ptr, matrix_row_index, parent, ancestor);
286
287 // symbolic: column counts of L (each row-k pattern entry j adds one entry to column j)
288 for(int i = 0; i < dimension; i++)
289 {
290 mark[i] = -1;
291 col_count[i] = 1; // diagonal
292 }
293 for(int k = 0; k < dimension; k++)
294 {
295 const int reach_top = _sp_ereach(dimension, matrix_col_ptr, matrix_row_index, k, parent, elim_stack, mark);
296 for(int reach_pos = reach_top; reach_pos < dimension; reach_pos++) col_count[elim_stack[reach_pos]]++;
297 }
298
299 factor->dimension = dimension;
300 factor->col_ptr = dt_pixelpipe_cache_alloc_align_int((size_t)dimension + 1, pipe);
301 if(IS_NULL_PTR(factor->col_ptr)) goto fail;
302 factor->col_ptr[0] = 0;
303 for(int i = 0; i < dimension; i++) factor->col_ptr[i + 1] = factor->col_ptr[i] + col_count[i];
304 const size_t nonzeros = factor->col_ptr[dimension];
305 factor->row_index = dt_pixelpipe_cache_alloc_align_int(nonzeros, pipe);
306 factor->values = dt_pixelpipe_cache_alloc_align_double(nonzeros, pipe);
307 if(IS_NULL_PTR(factor->row_index) || IS_NULL_PTR(factor->values)) goto fail;
308
309 // numeric
310 for(int i = 0; i < dimension; i++)
311 {
312 mark[i] = -1;
313 work[i] = 0.0;
314 col_fill[i] = factor->col_ptr[i] + 1; // slot 0 = diagonal, filled when row i is processed
315 }
316 // Not OpenMP-parallelizable as written: column k subtracts contributions from every earlier
317 // column in its elimination reach (reading their finished factor->values and accumulating into
318 // the shared `work` row), so the k-loop carries a true dependency and the inner reach-loop
319 // scatters into overlapping work[] entries -- neither is a safe parallel for. This is the
320 // bit-reproducible CPU reference the self-tests validate the GPU against; the parallel
321 // factorization is the OpenCL path (_sp_chol_factor_cl), which first builds an elimination-tree
322 // level schedule (independent columns per level) precisely because this straight column sweep
323 // cannot be parallelized in place.
324 for(int k = 0; k < dimension; k++)
325 {
326 const int reach_top = _sp_ereach(dimension, matrix_col_ptr, matrix_row_index, k, parent, elim_stack, mark);
327 // seed the scratch row with column k of A: work[i] = A[k,i] for i<k (numerator of L[k,i]),
328 // pivot = A[k,k] (numerator of the diagonal, before the sum of squares is subtracted)
329 double pivot = 0.0;
330 for(int entry = matrix_col_ptr[k]; entry < matrix_col_ptr[k + 1]; entry++)
331 {
332 const int i = matrix_row_index[entry];
333 if(i < k)
334 work[i] = matrix_values[entry];
335 else if(i == k)
336 pivot = matrix_values[entry];
337 }
338 for(int reach_pos = reach_top; reach_pos < dimension; reach_pos++)
339 {
340 const int j = elim_stack[reach_pos];
341 // L[k,j] = ( A[k,j] - sum_{m<j} L[k,m] L[j,m] ) / L[j,j]: work[j] holds the fully-reduced
342 // numerator here (every earlier reach column m<j already subtracted its L[k,m]L[j,m]),
343 // values[col_ptr[j]] = L[j,j] (the diagonal is stored first in each column)
344 const double multiplier = work[j] / factor->values[factor->col_ptr[j]];
345 work[j] = 0.0;
346 // apply column j's contribution to the still-pending numerators: work[i] -= L[i,j] * L[k,j]
347 // for the below-diagonal rows i of column j (this is the "up-looking" left-of-diagonal update)
348 for(int entry = factor->col_ptr[j] + 1; entry < col_fill[j]; entry++)
349 work[factor->row_index[entry]] -= factor->values[entry] * multiplier;
350 pivot -= multiplier * multiplier; // subtract L[k,j]^2 from the diagonal accumulator A[k,k]
351 // store L[k,j] as entry (row k) of column j -- CSC lower-triangular: (k,j) with k>j lives in column j
352 const int slot = col_fill[j]++;
353 factor->row_index[slot] = k;
354 factor->values[slot] = multiplier;
355 }
356 if(!(pivot > 0.0)) goto fail; // pivot = A[k,k] - sum_j L[k,j]^2 <= 0 (or NaN): matrix not SPD
357 factor->row_index[factor->col_ptr[k]] = k;
358 factor->values[factor->col_ptr[k]] = sqrt(pivot); // L[k,k] = sqrt( A[k,k] - sum_j L[k,j]^2 )
359 }
360
368 return factor;
369
370fail:
379 return NULL;
380}
381
382// Solve the factored system L L^T x = b in place (forward substitution then backward).
383// Notation bridge (math symbol -> code name): L is the factor (factor->values / row_index /
384// col_ptr); b, the intermediate y, and the solution x all live in `rhs`, overwritten in place
385// (rhs holds b on entry, y after the forward sweep, x after the backward sweep).
386// `forward_value` = y[j]; `accum` = x[j] accumulated before its final divide by the diagonal.
387// Two triangular solves realize x = A^{-1} b via L y = b then L^T x = y.
388static inline void _sp_chol_solve(const _sp_chol_t *const factor, double *const restrict rhs)
389{
390 const int dimension = factor->dimension;
391 for(int j = 0; j < dimension; j++) // forward: L y = b (column-oriented: y_j = (b_j - sum_{i<j} L[j,i] y_i)/L[j,j])
392 {
393 const double forward_value = rhs[j] / factor->values[factor->col_ptr[j]]; // y_j = (reduced b_j) / L[j,j]
394 rhs[j] = forward_value;
395 // once y_j is known, push its contribution forward: b_i -= L[i,j] y_j for all rows i>j of column j
396 for(int entry = factor->col_ptr[j] + 1; entry < factor->col_ptr[j + 1]; entry++)
397 rhs[factor->row_index[entry]] -= factor->values[entry] * forward_value;
398 }
399 for(int j = dimension - 1; j >= 0; j--) // backward: L^T x = y (x_j = (y_j - sum_{i>j} L[i,j] x_i)/L[j,j])
400 {
401 double accum = rhs[j]; // y_j
402 // gather the already-solved x_i (rows i>j of column j = columns i>j of row j in L^T)
403 for(int entry = factor->col_ptr[j] + 1; entry < factor->col_ptr[j + 1]; entry++)
404 accum -= factor->values[entry] * rhs[factor->row_index[entry]];
405 rhs[j] = accum / factor->values[factor->col_ptr[j]]; // x_j = (y_j - sum_{i>j} L[i,j] x_i) / L[j,j]
406 }
407}
#define dt_pixelpipe_cache_alloc_align_int(count, pipe)
Definition darktable.h:464
#define dt_pixelpipe_cache_alloc_align_double(count, pipe)
Definition darktable.h:469
#define dt_pixelpipe_cache_free_align(mem)
Definition darktable.h:475
#define IS_NULL_PTR(p)
C is way too permissive with !=, == and if(var) checks, which can mean too many things depending on w...
Definition darktable.h:293
int dimension(struct dt_imageio_module_format_t *self, dt_imageio_module_data_t *data, uint32_t *width, uint32_t *height)
float *const restrict const size_t k
const float factor
Definition pdf.h:90
static int _sp_ereach(const int dimension, const int *const restrict col_ptr, const int *const restrict row_index, const int k, const int *const restrict parent, int *const restrict pattern_stack, int *const restrict mark)
static void _sp_nd_order(int *const restrict unknown_ids, const int count, const int *const restrict unknown_x, const int *const restrict unknown_y, const int reach)
static _sp_chol_t * _sp_chol_factor(const int dimension, const int *const restrict matrix_col_ptr, const int *const restrict matrix_row_index, const double *const restrict matrix_values, const dt_dev_pixelpipe_t *pipe)
static void _sp_chol_free(_sp_chol_t *factor)
static void _sp_chol_solve(const _sp_chol_t *const factor, double *const restrict rhs)
static void _sp_etree(const int dimension, const int *const restrict col_ptr, const int *const restrict row_index, int *const restrict parent, int *const restrict ancestor)
double * values
#define MIN(a, b)
Definition thinplate.c:32
#define MAX(a, b)
Definition thinplate.c:29