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