Skip to content

apply foreach_point in CM cache - #4788

Open
Mikolaj-A-Kowalski wants to merge 8 commits into
mainfrom
iccs/cm-cache
Open

Mikolaj-A-Kowalski wants to merge 8 commits into
mainfrom
iccs/cm-cache

Conversation

@Mikolaj-A-Kowalski

@Mikolaj-A-Kowalski Mikolaj-A-Kowalski commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Apply the foreach_point to the longest running kernel in the set_microphysics_tendency_cache! function

To-do

  • Obtain some speed-up

Content

At the moment we don't really observe any notable runtime improvement:

State Register Usage Mean Time
New 255 31.96 ms (-1.5%)
Old 255 32.45ms

Taken from the NCU reports below

reports.tar.gz

Looking at what the profiler is saying it feels like the main bottleneck for this kernel is the traffic to the local memory (~ 100 million memory requests to local memory vs around 800k issued to the global memory).

In my opinion it is caused by the usage of the stack by the kernel. The compiler does not seem to be able assert that the structs we pass as arguments are not mutated, hence it feels compelled to create stack, which uses local memory and creates the heavy memory traffic.

I don't think it is the case of simple register spillover. In that case I would expect to see similar number of stores and loads (at least it feels like it should be the case). Here we have a heavy imbalance (72 million loads vs 31 million stores) .


  • I have read and checked the items on the review checklist.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Docs preview for this PR: https://clima.github.io/ClimaAtmos.jl/previews/PR4788/

@Mikolaj-A-Kowalski

Mikolaj-A-Kowalski commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor Author

@imreddyTeja @dennisYatunin @sjavis

I have spend some time trying to eliminate the stack in the heaviest CM cache kernel and in the end what did the trick was to parametrise the Microphysics1MEvaluator on the values of thp and cmp.

This basically allows the values to be passed to the function not via variables, but via instructions. This, in turn, allows the compiler to avoid copying the thp and cmp to stack and removes the majority of the local memory traffic. Overall when tested in the minimal reproducer it speeds up the kernel by good 30%.

I do assume that the values of thp and cmp are constant over the course of the calculation so we should not run into recompilation penalty?

Also to shrink the size of the closure I have used Val to pass the parameters via the type.

Below is the summary of the kernel runtimes from the NCU reports (and original NCU reports as well)

State Kernel Time (ncu)
Old 32.45 ms
foreach_point 31.96 ms (-1.51%)
foreach_point + value 21.91 (-32.48%)

new-reports.tar.gz

I would like now to clean up this PR and move it out of the draft status. For that I have two questions:

  • I have the utility function unwrap_value to get the object from the Value type. What is the best place to put it?
  • I have went with the path of least resistance and I wrap the parameters into Value when constructing the evaluator. Would it be better to do the wrapping inside its outer constructor? (EDIT: I have wrapped them in outer constructor to keep backwards compatibility with the tests in the end)

@Mikolaj-A-Kowalski
Mikolaj-A-Kowalski marked this pull request as ready for review September 8, 2026 14:39
@Mikolaj-A-Kowalski Mikolaj-A-Kowalski self-assigned this Sep 8, 2026
@Mikolaj-A-Kowalski Mikolaj-A-Kowalski added the Launch Buildkite Add to launch buildkite run label Sep 8, 2026
Co-authored by: Teja Reddy <treddy0205@gmail.com>
Gives a healthy increase of 6% but still uses a lot of stack do to the
function call in the kernel. The benefit seems to originate from better
cache efficiency (all threads read the same data)
Instead of trying to access parameters as variables, we move them to the
types and place in the instructions.

Removes most of the local memory traffic by preventing the compiler from
creating the copy of the parameter packs on the stack (since now they
are embedded in the type). We observe ~30% speedup.
It does not fix the type instability per se (it must happen at some
point since the return type depends on the value of arguments with type
parametrised with values). We, however can move it higher in the call
stack where it may be less problematic.

@dennisYatunin dennisYatunin 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.

Moving the parameters into instruction memory seems like the right call here, since they are guaranteed to be constant for every simulation. I suspect we'll need to generalize this pattern to keep fusing more kernels.

However, this implementation is still limited by inlining---if the CloudMicrophysics.jl functions that take in the parameters aren't inlined, LLVM might not be able to perform SROA (scalar reduction of aggregates) and infer that only a few parameters need to be stored in registers. To guarantee minimal register usage, the Val wrapper (or any singleton equivalent) would need to be propagated all the way through the CloudMicrophysics.jl source code. For sufficiently complex thermodynamics functions, the same would have to be done in Thermodynamics.jl. I'm happy to see that such extensive changes aren't required for this example, though; maybe we need a lot more complexity before they become necessary.

Comment on lines +1007 to +1008
let thp_val=Val(thp), cmp_val=Val(cmp), dt=dt, nsubs=nsubs, nsubs_quad=nsubs_quad,
sgs_quad=sgs_quad, corr_Tq=corr_Tq, α=α

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.

What's the purpose of this let-block? I've only seen something like let dt = dt used to avoid boxing inside a closure where dt is reassigned, but this isn't inside a closure. The do-block after foreach_point is a closure, but the let-block is outside of that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here it is not necessary per see, but I am wrapping the variables in the let block to ensure they are captured by value. In the other case I had a problem that a variable would get boxed and captured by reference if it was reassigned in the parent scope of the foreach_block (i.e. outside of closure)

Comment on lines +261 to +274
# Inner constructor: `cmp` and `thp` are captured as `Val`s so that the
# parameter structs live in the type domain (type parameters `MP`/`TPS`),
# not in the evaluator's fields. They may be passed either as plain structs
# or already `Val`-wrapped; both are unwrapped before specialization.
function Microphysics1MEvaluator(
scheme::S, mp, tps, ρ::FT,
q_rai::FT, q_sno::FT,
λ::FT, λ_lagrange::FT, mu_S::FT, α::FT,
dt::FT, nsubs::Int, args::Args,
) where {S, FT, Args <: Tuple}
return new{S, unwrap_value(mp), unwrap_value(tps), FT, Args}(
scheme, ρ, q_rai, q_sno, λ, λ_lagrange, mu_S, α, dt, nsubs, args,
)
end

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.

If you're always going to store mp and tps as type parameters, there's no benefit to passing them in without Val wrappers. Doing so would probably trigger dynamic dispatch, which can't be compiled on GPUs. So, instead of adding this inner constructor, you can keep the original default constructor and use unwrap_val(eval.mp)/unwrap_val(eval.tps) below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm afraid I don't quite follow I am not 100% sure what you mean by

So, instead of adding this inner constructor, you can keep the original default constructor and use unwrap_val(eval.mp)/unwrap_val(eval.tps) below.

I think you are right that I didn't do thinks idiomatically, but my impression is that we are kind of protected from dynamic dispatch depending how we call this constructor.

If we wrap the parameters in Val like:

Microphysics1MEvaluator(scheme, Val(mp), Val(thp) ...) 

or mp isa Value && thp is a Value the constructor should not have type instability and we would avoid dynamic dispatch.

It will only happen if we call the constructor with the values of the arguments.

I guess the more full-proof approach would be to just replace mp -> ::Val{mp} in the argument list Is this what you meant?

@trontrytel

Copy link
Copy Markdown
Member

@haakon-e - The debate is here

@imreddyTeja
imreddyTeja self-requested a review September 21, 2026 15:34

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Launch Buildkite Add to launch buildkite run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants