Add private variables to openMP loops - #88
Conversation
* new api inside private() we can now list the symbols that are private, e.g. declare(parallel(private = x))) or declare(parallel(private = c(x, y))) * error of symbol is not in scope * error of an assigned variable in openmp loop is assigned but not declare privat in order to not have race conditions.
… stack isn’t popped twice on success. This prevents inner loops from corrupting outer tracking Added a hard error for parallel(private=...) with sapply() since private variables aren’t implemented there yet. This avoids silent ignore.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a4b381e50a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| extra_private <- vapply( | ||
| parallel$private %||% character(), | ||
| fortranize_name, | ||
| character(1L) |
There was a problem hiding this comment.
Use scoped Fortran names for OpenMP private list
When a private variable is shadowed in a closure scope, its Fortran symbol can be renamed via make_shadow_fortran_name (e.g., i__local_). Here the private list is built with fortranize_name, which ignores those scope-specific renames and emits the base name instead. In nested closures or when a variable name collides with a parent scope, this produces !$omp parallel do private(i) even though the actual loop variable is i__local_, leading to a compile error (“symbol not declared”) or leaving the true variable non-private. Consider deriving private names from the scope (e.g., scope_fortran_symbol or get0(name, scope)@name) rather than re-fortranizing the raw symbol.
Useful? React with 👍 / 👎.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #88 +/- ##
==========================================
+ Coverage 92.76% 92.81% +0.05%
==========================================
Files 27 27
Lines 5734 5777 +43
==========================================
+ Hits 5319 5362 +43
Misses 415 415 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Thanks for putting this together! I was under the (perhaps mistaken!) impression that OpenMP and most compilers will automatically infer |
Current situation on mainSo currently my example on main generates this code: [note: I have reference example at the bottom] Loop versionsubroutine roll_mean_loop(x, window, out, x__len_) bind(c)
use iso_c_binding, only: c_double, c_int, c_ptrdiff_t
implicit none
! manifest start
! sizes
integer(c_ptrdiff_t), intent(in), value :: x__len_
! args
real(c_double), intent(in) :: x(x__len_)
integer(c_int), intent(in) :: window
real(c_double), intent(out) :: out(((x__len_ - window) + 1))
! locals
integer(c_int) :: i
real(c_double) :: acc
integer(c_int) :: j
! manifest end
out = 0
!$omp parallel do
do i = 1, size(out)
acc = 0.0_c_double
do j = 1, window
acc = (acc + x((((i + j) - 1_c_int))))
end do
out(i) = (acc / real(window, kind=c_double))
end do
!$omp end parallel do
end subroutineAs I understand it, the initial loop index sapply versionWhile an subroutine roll_mean_sapply(x, window, out, x__len_) bind(c)
use iso_c_binding, only: c_double, c_int, c_ptrdiff_t
implicit none
! manifest start
! sizes
integer(c_ptrdiff_t), intent(in), value :: x__len_
! args
real(c_double), intent(in) :: x(x__len_)
integer(c_int), intent(in) :: window
real(c_double), intent(out) :: out(((x__len_ - window) + 1))
! locals
integer(c_int) :: out_len
integer(c_int) :: tmp1_
! manifest end
out_len = ((size(x) - window) + 1_c_int)
out = 0
!$omp parallel do
do tmp1_ = 1_c_int, ((x__len_ - window) + 1_c_int)
call roll_mean(tmp1_, out(tmp1_))
end do
!$omp end parallel do
contains
subroutine roll_mean(i, res)
use iso_c_binding, only: c_double, c_int
implicit none
integer(c_int), intent(in) :: i
real(c_double), intent(out) :: res
real(c_double) :: acc
integer(c_int) :: j
acc = 0.0_c_double
do j = 1, window
acc = (acc + x((((i + j) - 1_c_int))))
end do
res = (acc / real(window, kind=c_double))
end subroutine
end subroutineSo as I understand it, the Summary of situation on mainThe loop version does not create a local block like: !$omp parallel do
do i = 1, size(out)
block
real(c_double) :: acc
integer(c_int) :: j
acc = 0.0_c_double
do j = 1, window
acc = acc + x(i + j - 1)
end do
out(i) = acc / real(window, kind=c_double)
end block
end do
!$omp end parallel doThis is also an alternative route to my PR. I think one of the difficulties is to infer what should be local and what should be global. For instance I will note that, when I run my two example below, that I get the same results, even though they are supposedly modifying the same variable. So maybe the compiler is somehow inferring the locals. NOTE ! after consulting an AI it seems my example is somewhat poorly constructed Claude/chatGPT had this to say about the safety of my example on main
and
My PRWith subroutine roll_mean_loop(x, window, out, x__len_) bind(c)
use iso_c_binding, only: c_double, c_int, c_ptrdiff_t
implicit none
! manifest start
! sizes
integer(c_ptrdiff_t), intent(in), value :: x__len_
! args
real(c_double), intent(in) :: x(x__len_)
integer(c_int), intent(in) :: window
real(c_double), intent(out) :: out(((x__len_ - window) + 1))
! locals
integer(c_int) :: i
real(c_double) :: acc
integer(c_int) :: j
! manifest end
out = 0
!$omp parallel do private(acc, j)
do i = 1, size(out)
acc = 0.0_c_double
do j = 1, window
acc = (acc + x((((i + j) - 1_c_int))))
end do
out(i) = (acc / real(window, kind=c_double))
end do
!$omp end parallel do
end subroutineI was actually not fully aware how the SummaryI think the Reference exampleLoop openMProll_mean_loop <- function(x, window) {
declare(
type(x = double(n)),
type(window = integer(1))
)
out <- double(length(x) - window + 1)
declare(parallel()) # and my PR has private = c(acc, j) inside parallel()
for (i in seq_along(out)) {
acc <- 0
for (j in seq_len(window)) {
acc <- acc + x[(i + j - 1)]
}
out[i] <- acc / window
}
out
}sapply openMProll_mean_sapply <- function(x, window) {
declare(
type(x = double(n)),
type(window = integer(1))
)
roll_mean <- function(i) {
acc <- 0
for (j in seq_len(window)) {
acc <- acc + x[(i + j - 1)]
}
acc / window
}
out_len <- length(x) - window + 1L
out <- double(out_len)
declare(parallel())
out <- sapply(seq_len(out_len), roll_mean)
out
} |
This reverts commit a4b381e.
|
I apologies for all these long comments. The loop version of openMP still needs some work. Actually unsafe exampleI tried to create a new example that is actually unsafe - a parallel sum. library(quickr)
loop_sum_unsafe <- function(x) {
declare(type(x = double(n)))
total <- 0.0
declare(parallel())
for (i in seq_along(x)) {
total <- total + x[i] # Race! All threads read-modify-write `total`
}
total
}
loop_sum_safe <- function(x) {
declare(type(x = double(n)))
total <- 0.0
declare(parallel(private = total))
for (i in seq_along(x)) {
total <- total + x[i]
}
total
}
x <- rnorm(1000, 5, 5)
unsafe_parallel_sum <- quick(loop_sum_unsafe)
safe_parallel_sum <- quick(loop_sum_safe)
quickr:::r2f(loop_sum_unsafe)
quickr:::r2f(loop_sum_safe)
r_loop_sum <- loop_sum_unsafe
r_loop_sum(x)
unsafe_parallel_sum(x)
safe_parallel_sum(x)
When running this (ignoring the generated code) I get [1] 5028.085
[1] 631.6976
[1] 0So at least this demonstrates unsafe openMP loop without private declaration. It also show my implementation is lacking as I now declared total as private so each thread get their own version and what is return is the global one that I set to 0. There seem to be many openMP clauses that you can set. In this case, it seem like we need So maybe we should just close this and think how to extend openmp loops - i didn't test with the sapply version, maybe it works. Maybe sapply version should be the only version. **** ADDED Do you want to limit quickr's openMP capabilities to map/apply style functions where input size and output size matches? In that case as noted above, I think allowing openMP with for-loop might be too flexible a prone to errors. |
|
Needs more thought - but it would be nice to support more OpenMP declarations. |
|
Apologies for not responding yet! This is a very thoughtful issue that identifies a real need, and I’m still collecting my thoughts on how to respond. It’s still on my to-do list to write up an issue describing how I’m imagining this interface will evolve. We will need a way to expose private variables and other syntax that can go into an OpenMP directive, but before doing so, I want to lay the groundwork for how |
|
I wouldn’t normally share raw output from ChatGPT, but in the interest of time, here is the last turn from a brainstorming ChatGPT session I had a few weeks ago, while thinking about how to evolve the parallel interface: ChatGPT outputBased on the tile/block-first patterns described in the cuTile summary you shared (hide threads, expose tiles, give smart defaults, make “kernel-ish” code feel like normal array code), here’s an updated, concrete I’m aiming for:
1) Core attachment model
Eligible constructs (initially):
Also add one more target:
2) Minimal surface APIA. The one-liner for naive usersdeclare(parallel())
for (i in seq_len(n)) { ... }declare(gpu())
for (i in seq_len(n)) { ... }
Both are “friendly defaults”. B. A cuTile-inspired knob: tile/block sizedeclare(gpu(tile = 256))
for (i in seq_len(n)) { ... }Interpretation: “run on GPU, chunk the iteration space into tiles of ~256 iterations per block”.
Also allow 2D tiling for collapsed loops: declare(gpu(tile = c(16, 16), collapse = 2))
for (i in seq_len(n)) {
for (j in seq_len(m)) {
...
}
}C. Data/target regions (avoid repeated copies)A block-scoped data region attaches to the next declare(gpu_data(copyin = vars(a, b), copyout = vars(y)))
{
declare(gpu(tile = 256))
for (i in seq_len(n)) y[i] <- a[i] + b[i]
declare(gpu(tile = 256))
for (i in seq_len(n)) y[i] <- y[i] * 2
}
D. “vars()” helper for symbol listsvars(a, b, y)This stays unevaluated inside 3) Unifying CPU and GPU:
|
|
It would be good to fix the correctness bug you identified however. I suspect that forcing parallel loop bodies to always emig |
|
Thank you for your reply and for providing details on your future direction. For what you want to support I see that this requires careful thinking and planning. For what it's worth, I think an api that looks something like this: declare(parallel(
num_threads = 8,
schedule = "static",
reduction = sum(total),
private = vars(tmp)
))
for (...) { ... }looks interesting and aligns with my own thoughts. This gives a lot of flexibility but off-loads a lot of responsibility to the user. If it is possible to auto infer some stuff like reductions and private variables that would be nice and create opportunity of a user writing |
Added support for using private variable in openMP loops. The implementation adds the new api:
The private variables must be in scope or we error. This is to protect against putting a wrong variable name in private.
I tried to add code that tracks if a scalar is changed and error if it is not private. This is in order to not create race conditions and possibly compile errors. However, this might be a bit difficult and there is likely many edge cases that cannot be anticipated. So I will leave it up to you how much the user should be protected from wrong use of openMP.
"Motivating example"
This is a simplified version of the roll_mean example. An LLM suggested this would be faster than just slapping the openMP declaration over the original example - turns out it didn't make much difference. But at least it is possible now.