Skip to content

perf: compute forecast quantiles block-wise to bound memory - #1313

Merged
peanutfun merged 5 commits into
CLIMADA-project:developfrom
EnvDroneSense:feature/forecast-quantile-memory
Sep 2, 2026
Merged

perf: compute forecast quantiles block-wise to bound memory#1313
peanutfun merged 5 commits into
CLIMADA-project:developfrom
EnvDroneSense:feature/forecast-quantile-memory

Conversation

@EnvDroneSense

@EnvDroneSense EnvDroneSense commented Aug 25, 2026

Copy link
Copy Markdown

Changes proposed in this PR:

  • Add climada.util.forecast.sparse_quantile_axis0, which takes a quantile along axis 0 of a sparse matrix by densifying a block of columns at a time rather than the whole matrix. The block size is derived from a max_memory_mb budget.
  • Call it from HazardForecast._quantile (intensity and fraction) and ImpactForecast._quantile (imp_mat), replacing the .toarray() on the full matrix.

This PR fixes #1203

Peak memory no longer scales with the number of centroids. Measured on a full DWD ICON-EU-EPS run, 2560 events x 164,984 cells, float32, with [icon_full.py](https://github.com/user-attachments/files/31507435/icon_full.py) :

old    8110.9 ms (best of 3)   peak   3382.3 MB
new    2375.7 ms (best of 3)   peak    280.5 MB

The script asserts the two paths return equal arrays on every run.

One constraint worth flagging: implicit zeros have to take part in the quantile. A column of [0, 0, 0, -1, 9] has median 0.0, not the 4.0 you get from its two stored values. That is why the helper densifies blocks instead of reducing over matrix.data, which would be faster but silently wrong.

HazardForecast.quantile and ImpactForecast.quantile densified the entire
sparse matrix before calling np.quantile, so peak memory scaled with
n_events * n_centroids regardless of sparsity. On large forecasts this
exhausts memory and kills the process.

Add climada.util.forecast.sparse_quantile_axis0, which densifies at most
block_size columns at a time. Each column's quantile is independent, so
the result is unchanged; only the peak allocation differs. Implicit zeros
still take part in the quantile, as reducing over stored values alone
would silently inflate the result on sparse data.

The blocks are temporaries owned by the helper, so np.quantile may
partition them in place. That spares a second full-size copy: np.quantile
otherwise duplicates its input, which is why the old peak was twice the
dense size and why the block-wise version is faster rather than slower.

Measured on synthetic forecasts (density 0.02), results asserted
bit-identical to the previous code path:

  200 ev x  5k centr:  16.2 ->  8.3 MB peak, 2.41x faster
  500 ev x 20k centr: 160.8 -> 35.9 MB peak, 1.67x faster
 1000 ev x 50k centr: 802.1 -> 79.0 MB peak, 1.96x faster

The existing test_reduce, test_quantile_min_max and test_median_quantile
assert against the dense np.quantile reference and pass unmodified.

This PR fixes CLIMADA-project#1203

@peanutfun peanutfun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EnvDroneSense Thank you for raising a PR! I am happy about your contribution and your code generally looks good, but your PR description is lengthy, has many unsupported claims, and overall looks AI-generated.

In your "Results" section you claim a speedup and reduction in memory usage with the new code. How did you conduct these measurements? What code did you execute? Later, you claim that setting overwrite_input=True is faster. I see that it is reasonable to save memory, but the entire addition of the new function already serves that purpose. Did you measure the additional speedup as well? Also, the entire "Speedup" section never shows speedup but discusses saving memory. Finally, you claim that results are identical, but the unit tests use assert_allclose instead of assert_array_equal.

The dim is not None branch of both _quantile methods delegates to reduce_unique_selection and is not changed here. It was not measured, and if it densifies by another route then #1203 is only partly addressed by this PR. Happy to extend the scope if you would prefer that handled in one go.

This is not necessary. The "dim is not None branch" calls reduce_unique_selection, which performs a subselection and then delegates back to the calling attribute, in this case _quantile, so there is no extension needed to apply your changes to that particular branch.

I would be happy if you could answer my questions regarding your benchmarks. Please also review my code comments, which I would like to see resolved before approving a merge.

Again, thank you for your contribution! ☺️

Comment thread climada/engine/test/test_impact_forecast.py Outdated
Comment thread climada/util/forecast.py Outdated
Comment thread climada/util/forecast.py Outdated
Comment thread climada/util/test/test_forecast.py Outdated
Replace the block_size column count with a max_memory_mb budget.

- preserve the input dtype instead of forcing float64
- accept a sequence of quantiles, as np.quantile does
- handle a matrix with no columns
- fix the densification spy: it patched csr_matrix while the helper
  densifies csc_matrix, so it asserted nothing
@EnvDroneSense

Copy link
Copy Markdown
Author

@peanutfun sorry for the messy PR. (I have rewritten it)

On the speedup: part of that was me misinterpreting my own benchmark results, so I have redone them from scratch. This time I measured against real data rather than synthetic icon_full.py, and the results look quite good:

run 2026-08-27 00:00:00
2560 events x 164,984 cells, density 0.055, dense would be 1,689.4 MB

old    8110.9 ms (best of 3)   peak   3382.3 MB
new    2375.7 ms (best of 3)   peak    280.5 MB

On overwrite_input=True: I measured it separately this time. At the default 8 MB budget it makes no difference to peak memory (280.5 MB either way) but is about 2x faster (2078 vs 4985 ms), because without it every block gets copied before sorting. So it is a speed optimization rather than a memory one. icon_overwrite.py

2560 events x 164,984 cells, budget 8 MB = 781 columns over 212 blocks, best of 5

overwrite_input=True      2078 ms   peak  280.5 MB
overwrite_input=False     4985 ms   peak  280.5 MB
identical

I have also implemented the changes you recommended. One thing I am unsure about: the default for max_memory_mb is currently an arbitrary 8 MB. Is that acceptable, and if not, how would you suggest picking a better one? Should it go into climada.conf instead?

Thank you for your review, I am trying to learn more about code optimization.

@peanutfun

Copy link
Copy Markdown
Member

@EnvDroneSense Excellent work! Using your benchmark code, I could replicate these numbers on my computer. The tests also run through on my end. A final request: Please update the test test_quantile_uses_block_wise_helper with a mock for sparse_quantile_axis0, ensuring that it is called with the correct matrix object, and add a similar test for the HazardForecast class. Once this is done, we are ready to merge!

Mock sparse_quantile_axis0 where each forecast class uses it and assert
it receives the matrix object itself, rather than inferring the call from
a toarray() spy. Add the matching test for HazardForecast, which reduces
intensity and fraction and so must call the helper twice.
@EnvDroneSense

EnvDroneSense commented Sep 1, 2026

Copy link
Copy Markdown
Author

@peanutfun Great. Both tests mock sparse_quantile_axis0 and assert it receives the matrix object itself; the HazardForecast one covers both calls.

@peanutfun peanutfun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work! Thanks again ☺️

@peanutfun
peanutfun merged commit 697e8d6 into CLIMADA-project:develop Sep 2, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants