Skip to content

Commit 33a58ad

Browse files
[5/15] Validate subscripts and make in-loop errors cancel OpenMP loops (#141)
* Move emit_quickr_error_if() to error-handling.R The statement-level runtime-guard emitter is generic error machinery, but it lived in the BLAS translation file, forcing any other handler that wants a guard into an odd dependency. Move it next to the rest of the quickr error plumbing, unchanged. * Validate subscripts: reject exclusion, guard index ranges R's negative subscript means exclusion and its zero subscript is dropped -- both produce value-dependent result shapes that quickr's static-shape model cannot represent -- but the `[` handler forwarded them verbatim into Fortran, where x(-1) and x(0) are silent out-of-bounds reads. Similarly, x[a:b] lowers to the section a:b:sign(1, b-a) with claimed length abs(b-a)+1, which is only correct when both bounds are >= 1: x[1:n] with n = 0 read x(0) and returned 2 values where R returns 1. Compile errors where the problem is visible statically: - any negative or zero subscript value, and the syntactic form x[-i] (unary minus is unambiguously exclusion in R) - literal range bounds < 1 - seq() in subscript position with a non-literal `by`: the result length divides by the step and is evaluated in the generated C bridge before any Fortran guard could run, so a zero step would be an unguardable division-by-zero crash there Runtime guards (one scalar check per statement, via emit_quickr_error_if) where the values are only known at run time: - x[a:b] bounds not provably >= 1, raising "index ranges in x[a:b] must have bounds >= 1"; literal-bound halves of the check are constant-folded away, and descending ranges x[n:1] still work - x[seq(a, b, by)] with literal step but symbolic bounds, raising "wrong sign in 'by' argument" as R does Also fixes seq_like_length_expr() silently dropping a non-literal `by` when from/to are literal: seq(1L, 9L, by = k) claimed length 9 regardless of k, mis-sizing constructor results (now sized by the step) and subscript sections (now rejected, above). x[seq_len(n)] / x[seq_along(y)] need no guard: their worst case is a legal zero-length section, matching R's x[integer(0)]. For-loop ranges are untouched (do i = 1, 0 already runs zero times). Behavior change to note: R's x[1:0] returns x[1] (the 0 is dropped); quickr now errors at runtime instead of reading out of bounds. * Enable OpenMP cancellation so in-loop errors exit early Error paths inside parallel loops emit `!$omp cancel do`, but per the OpenMP spec cancel constructs are no-ops unless the cancel-var ICV is true, which requires OMP_CANCELLATION=true in the environment when the OpenMP runtime first initializes. Nothing set it, so an error raised inside a parallel loop recorded its message correctly (first-wins critical section) but every remaining iteration still ran -- wasted work, and statements after a failed in-loop check kept executing in that iteration's thread. Set OMP_CANCELLATION=true in .onLoad when unset (a pre-set value is respected). Caveats documented in ?declare: no effect if another package already initialized the OpenMP runtime, so early exit is best-effort -- error messages are always correct either way. * Validate assignment subscripts and literal bounds against known extents Two gaps in subscript validation (both compile-and-return-garbage, found in external review): - The write side never validated at all: x[-1L] <- 9 and x[0L] <- 1 compiled into silent out-of-bounds Fortran writes, bypassing the exclusion/zero rejection the read-side `[` handler already had. compile_subset_designator() now runs the same checks, covering [<-, [<<-, and closure host writes. - Literal subscripts were never checked against a statically-known extent: x[4L] and x[2:4] on a declared double(3) compiled and read garbage, where R pads with NA (and grows the vector on writes) -- neither representable in quickr's static-shape model. When the base's extent along an axis is a literal, out-of-range literal values, `:` endpoints, and c() elements are now compile errors. A single subscript on a rank>1 base (R's linear indexing) checks against the product of the dims. Symbolic subscripts and symbolic extents are untouched, per the documented bounds contract. Range lower-bound validation (>= 1, including the runtime guard for symbolic endpoints) stays in check_subscript_range_bounds(); the new extent check only adds the upper side for literals. * Extract check_subscript_exprs() shared by read and write subscripts The read-side `[` handler and the write-side compile_subset_designator() carried the same six-line validation loop; the invariant that both sides validate identically is now pinned by a single helper instead of a comment asking to keep two copies in sync. Review finding (fable-final-review.md #3); no behavior change. * Validate coerced subscripts and symbolic seq steps * Accept signed literal seq steps * Accept valid singleton and double-negated subscripts * Fix hoisting in unbraced for-loop bodies Braced loop bodies give each statement its own hoist target, but a single-expression for-loop body inherited the target of the enclosing statement. Body-local setup could therefore be emitted before the loop, where it executed only once and could reference an uninitialized loop variable. Give both index- and value-iteration bodies a fresh hoist target. Iterable setup remains outside the loop, while guards and temporaries required by the body are emitted inside it. * Drop runtime guards for dynamic subscript bounds Dynamic range guards added work to hot loops while checking only lower bounds. Upper bounds and symbolic scalar and vector subscripts remained unchecked, so the partial guard did not provide a coherent safety contract. Keep zero-cost compile-time validation for unsupported or statically invalid subscripts, and keep runtime validation required for seq() step semantics. Dynamically computed array bounds remain the caller's responsibility. --------- Co-authored-by: Tomasz Kalinowski <kalinowskit@gmail.com>
1 parent fafce1c commit 33a58ad

15 files changed

Lines changed: 901 additions & 29 deletions

R/c-wrapper.R

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,26 @@ c_bridge_hoist_take_pending <- function(hoist) {
459459
pending
460460
}
461461

462+
c_bridge_hoist_seq_checks <- function(hoist, from, to, by) {
463+
stopifnot(
464+
is.environment(hoist),
465+
is_string(from),
466+
is_string(to),
467+
is_string(by)
468+
)
469+
hoist$pending <- c(
470+
hoist$pending,
471+
glue(
472+
'
473+
if (({from} != {to}) && ({by} == 0))
474+
Rf_error("invalid \'(to - from)/by\'");
475+
if ((({from} < {to}) && ({by} < 0)) ||
476+
(({from} > {to}) && ({by} > 0)))
477+
Rf_error("wrong sign in \'by\' argument");'
478+
)
479+
)
480+
}
481+
462482

463483
as_c_name <- function(var, c_hoist = NULL) {
464484
stopifnot(inherits(var, Variable))
@@ -582,6 +602,23 @@ dims2c_expr <- function(e, scope, c_hoist = NULL) {
582602
return(dims2c_dim_index_expr(call("[", call("dim", args[[1L]]), 2L), scope))
583603
}
584604

605+
if (identical(op, "quickr_seq_length")) {
606+
if (length(args) != 3L || is.null(c_hoist)) {
607+
stop("quickr_seq_length() requires three arguments and a C bridge hoist")
608+
}
609+
from <- dims2c_expr(args[[1L]], scope, c_hoist = c_hoist)
610+
to <- dims2c_expr(args[[2L]], scope, c_hoist = c_hoist)
611+
by <- dims2c_expr(args[[3L]], scope, c_hoist = c_hoist)
612+
c_bridge_hoist_seq_checks(c_hoist, from, to, by)
613+
614+
safe_by <- glue("(({by}) == 0 ? 1 : ({by}))")
615+
delta <- glue("((R_xlen_t)({to}) - (R_xlen_t)({from}))")
616+
quotient <- glue("({delta} / (R_xlen_t)({safe_by}))")
617+
return(glue(
618+
"((({quotient}) < 0 ? -({quotient}) : ({quotient})) + 1)"
619+
))
620+
}
621+
585622
if (identical(op, "abs")) {
586623
if (length(args) != 1L) {
587624
stop("abs() expects one argument")

R/declare.R

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@
1818
#' and `OMP_DYNAMIC` (disable/enable runtime adjustment). Set them before
1919
#' calling a compiled function, e.g. `Sys.setenv(OMP_NUM_THREADS = "4")`.
2020
#'
21+
#' When an error is raised inside a parallel loop, quickr cancels the
22+
#' remaining iterations via OpenMP cancellation, which the OpenMP runtime
23+
#' only honors when `OMP_CANCELLATION=true` is set before the runtime first
24+
#' initializes in the process. quickr sets it when the package loads (unless
25+
#' already set), but this has no effect if another package initialized the
26+
#' OpenMP runtime first. Early exit is best-effort either way: the error
27+
#' message is always recorded correctly; without cancellation the remaining
28+
#' iterations simply run to completion before the error is raised.
29+
#'
2130
#' @param ... Declarations, typically calls like `type(x = double(n))`.
2231
#' @returns `NULL`, invisibly.
2332
#' @rawNamespace if (getRversion() < "4.4.0") export(declare)

R/error-handling.R

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,32 @@ quickr_error_fortran_lines <- function(message = NULL, scope = NULL) {
112112
lines
113113
}
114114

115+
# Emit a runtime guard: if `condition` holds, record a quickr error and
116+
# bail out of the subroutine (or cancel the OpenMP loop). Statement-level
117+
# machinery shared by any handler that needs a runtime check.
118+
emit_quickr_error_if <- function(
119+
condition,
120+
message,
121+
hoist,
122+
scope
123+
) {
124+
stopifnot(
125+
is_string(condition),
126+
is_string(message),
127+
inherits(hoist, "environment"),
128+
inherits(scope, "quickr_scope")
129+
)
130+
mark_scope_uses_errors(scope)
131+
err_lines <- quickr_error_fortran_lines(message, scope = scope)
132+
hoist$emit(glue(
133+
"
134+
if ({condition}) then
135+
{indent(str_flatten_lines(err_lines))}
136+
end if
137+
"
138+
))
139+
}
140+
115141
quickr_error_return_if_set <- function(
116142
scope,
117143
openmp_depth = scope_openmp_depth(scope)

R/manifest.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,10 @@ dims2f_eval_base_env[["%%"]] <- function(e1, e2) {
522522
}
523523
dims2f_eval_base_env[["^"]] <- function(e1, e2) glue("({e1})**({e2})")
524524
dims2f_eval_base_env[["abs"]] <- function(x) glue("abs({x})")
525+
dims2f_eval_base_env[["quickr_seq_length"]] <- function(from, to, by) {
526+
safe_by <- glue("merge(int({by}), 1, int({by}) /= 0)")
527+
glue("(abs((int({to}) - int({from})) / {safe_by}) + 1)")
528+
}
525529
dims2f_eval_base_env[["length"]] <- function(x) {
526530
if (is.symbol(x)) {
527531
glue("size({as.character(x)})")

R/r2f-closures.R

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,11 @@ compile_subset_designator <- function(
13891389
is_bool(allow_logical_vector_subscripts)
13901390
)
13911391

1392+
# Same validation as the read-side `[` handler: assignment subscripts
1393+
# would otherwise lower R's exclusion/zero/out-of-range subscripts into
1394+
# silent out-of-bounds Fortran writes.
1395+
check_subscript_exprs(base_var, idx_args)
1396+
13921397
idxs <- whole_doubles_to_ints(idx_args)
13931398
idxs <- imap(idxs, function(idx, i) {
13941399
if (is_missing(idx)) {

R/r2f-control-flow.R

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ r2f_handlers[["while"]] <- function(args, scope, ...) {
7777
}
7878

7979
# ---- for ----
80-
r2f_handlers[["for"]] <- function(args, scope, ...) {
80+
r2f_handlers[["for"]] <- function(args, scope, ..., hoist = NULL) {
8181
.[var, iterable, body] <- args
8282
stopifnot(is.symbol(var))
8383
var <- as.character(var)
@@ -174,7 +174,10 @@ r2f_handlers[["for"]] <- function(args, scope, ...) {
174174
previous_openmp <- enter_openmp_scope(scope)
175175
on.exit(exit_openmp_scope(scope, previous_openmp), add = TRUE)
176176
}
177-
body <- r2f(body, scope, ...)
177+
# The body is a distinct execution region and needs its own hoist target.
178+
# Otherwise a single-expression body reuses the enclosing statement's
179+
# target and emits loop-dependent setup before the loop.
180+
body <- r2f(body, scope, ..., hoist = NULL)
178181
check_pending_parallel_consumed(scope)
179182
loop_stmts <- str_flatten_lines(glue("{var_name} = {element_expr}"), body)
180183

@@ -214,12 +217,14 @@ r2f_handlers[["for"]] <- function(args, scope, ...) {
214217
}
215218
scope[[var]] <- loop_var
216219

217-
iterable <- r2f_for_iterable(iterable, scope, ...)
220+
iterable <- r2f_for_iterable(iterable, scope, ..., hoist = hoist)
218221
if (!is.null(parallel)) {
219222
previous_openmp <- enter_openmp_scope(scope)
220223
on.exit(exit_openmp_scope(scope, previous_openmp), add = TRUE)
221224
}
222-
body <- r2f(body, scope, ...)
225+
# See the value-iteration path above: body-local setup must run inside the
226+
# loop even when the R body is not wrapped in braces.
227+
body <- r2f(body, scope, ..., hoist = NULL)
223228
check_pending_parallel_consumed(scope)
224229

225230
directives <- openmp_directives(parallel)

R/r2f-iterables-helpers.R

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ seq_like_length_expr <- function(from, to, by = NULL) {
4646
return(1L)
4747
}
4848

49-
if (is_scalar_integerish(from) && is_scalar_integerish(to)) {
49+
if (
50+
is_scalar_integerish(from) &&
51+
is_scalar_integerish(to) &&
52+
(is.null(by) || is_scalar_integerish(by)) # symbolic by: length needs it
53+
) {
5054
from_val <- as.integer(from)
5155
to_val <- as.integer(to)
5256
delta <- to_val - from_val
@@ -72,7 +76,39 @@ seq_like_length_expr <- function(from, to, by = NULL) {
7276
if (is.null(by)) {
7377
return(call("+", call("abs", call("-", to, from)), 1L))
7478
}
75-
call("+", call("abs", call("%/%", call("-", to, from), by)), 1L)
79+
call("quickr_seq_length", from, to, by)
80+
}
81+
82+
seq_like_step_needs_runtime_check <- function(info) {
83+
!is.null(info$by) &&
84+
!(is_scalar_integerish(info$from) &&
85+
is_scalar_integerish(info$to) &&
86+
is_scalar_integerish(info$by))
87+
}
88+
89+
emit_seq_step_runtime_checks <- function(from, to, by, hoist, scope) {
90+
stopifnot(
91+
inherits(from, Fortran),
92+
inherits(to, Fortran),
93+
inherits(by, Fortran),
94+
inherits(hoist, "environment"),
95+
inherits(scope, "quickr_scope")
96+
)
97+
emit_quickr_error_if(
98+
glue("({from} /= {to}) .and. ({by} == 0_c_int)"),
99+
"invalid '(to - from)/by'",
100+
hoist,
101+
scope
102+
)
103+
emit_quickr_error_if(
104+
glue(
105+
"(({to} > {from}) .and. ({by} < 0_c_int)) .or. ",
106+
"(({to} < {from}) .and. ({by} > 0_c_int))"
107+
),
108+
"wrong sign in 'by' argument",
109+
hoist,
110+
scope
111+
)
76112
}
77113

78114
# Parse a seq-like call into its components.
@@ -249,6 +285,24 @@ seq_like_r2f <- function(
249285
context <- "value"
250286
}
251287

288+
check_step_at_runtime <- kind == "seq" &&
289+
seq_like_step_needs_runtime_check(info)
290+
if (check_step_at_runtime && context != "[") {
291+
emit_seq_step_runtime_checks(
292+
from,
293+
to,
294+
by,
295+
hoist = list(...)$hoist,
296+
scope = scope
297+
)
298+
}
299+
if (check_step_at_runtime) {
300+
by <- Fortran(
301+
glue("merge(int({by}, kind=c_int), 1_c_int, {from} /= {to})"),
302+
Variable("integer")
303+
)
304+
}
305+
252306
if (is.null(len_expr) || is_scalar_na(len_expr)) {
253307
len_expr <- NA_integer_
254308
}
@@ -280,6 +334,18 @@ seq_like_r2f <- function(
280334
glue("{start}, {end}, {step}")
281335
}
282336
} else if (context == "[") {
337+
# Validate statically-known unsupported bounds and seq() step semantics.
338+
# Dynamically computed array bounds remain the caller's responsibility.
339+
if (kind %in% c(":", "seq")) {
340+
check_subscript_range_bounds(
341+
info,
342+
from,
343+
to,
344+
by_f = by,
345+
hoist = list(...)$hoist,
346+
scope = scope
347+
)
348+
}
283349
fr <- if (omit_step) {
284350
glue("{start}:{end}")
285351
} else {
@@ -297,6 +363,77 @@ seq_like_r2f <- function(
297363
Fortran(fr, val)
298364
}
299365

366+
# Validate an x[a:b] / x[seq(a, b, by)] index range where doing so has no
367+
# general bounds-checking cost. Statically bad literal bounds are compile
368+
# errors. Dynamic bounds are trusted, consistently with symbolic scalar and
369+
# vector subscripts. An explicit seq() step is handled below (literal-only,
370+
# plus a runtime wrong-sign check required by seq() semantics).
371+
# Used by: seq_like_r2f() (subscript context)
372+
check_subscript_range_bounds <- function(info, from, to, by_f, hoist, scope) {
373+
lit <- function(e) {
374+
e <- unwrap_parens(e)
375+
if (is_scalar_integerish(e)) as.integer(e) else NA_integer_
376+
}
377+
from_lit <- lit(info$from)
378+
to_lit <- lit(info$to)
379+
by_lit <- if (is.null(info$by)) 1L else lit(info$by)
380+
same_endpoint <- identical(
381+
unwrap_parens(info$from),
382+
unwrap_parens(info$to)
383+
)
384+
385+
bounds_msg <- "index ranges in x[a:b] must have bounds >= 1"
386+
if (isTRUE(from_lit < 1L) || isTRUE(to_lit < 1L)) {
387+
stop(
388+
bounds_msg,
389+
": ",
390+
deparse1(info$from),
391+
", ",
392+
deparse1(info$to),
393+
call. = FALSE
394+
)
395+
}
396+
397+
emit <- function(condition, message) {
398+
if (is.null(hoist)) {
399+
stop(
400+
"cannot emit a runtime subscript-range guard here; ",
401+
"use literal bounds >= 1 in x[a:b]",
402+
call. = FALSE
403+
)
404+
}
405+
emit_quickr_error_if(condition, message, hoist, scope)
406+
}
407+
408+
# Explicit seq() step. When the endpoints differ, the result length divides
409+
# by the step, and that length is evaluated in the C bridge *before* any
410+
# Fortran guard can run (a zero step would be a division-by-zero crash
411+
# there), so a non-literal step is a compile error, not a guard. With a
412+
# literal step and symbolic bounds, R errors when the step's sign opposes
413+
# the direction -- the emitted section would be zero-length while the
414+
# claimed length is not; that case is checkable at runtime. All-literal
415+
# ranges were already validated by seq_like_length_expr() at compile time.
416+
if (!is.null(info$by) && !same_endpoint) {
417+
if (is.na(by_lit)) {
418+
stop(
419+
"seq() in x[...] requires a literal `by` step ",
420+
"(the result length depends on it): by = ",
421+
deparse1(info$by),
422+
call. = FALSE
423+
)
424+
}
425+
if (is.na(from_lit) || is.na(to_lit)) {
426+
emit(
427+
glue(
428+
"(({to} /= {from}) .and. (sign(1_c_int, {by_f}) /= sign(1_c_int, {to} - {from})))"
429+
),
430+
"wrong sign in 'by' argument in x[seq(a, b, by)]"
431+
)
432+
}
433+
}
434+
invisible(NULL)
435+
}
436+
300437
# Unwrap a for-loop iterable, handling rev() calls.
301438
# Used by: r2f-control-flow.R
302439
r2f_unwrap_for_iterable <- function(iterable) {

R/r2f-matrix-blas.R

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -74,29 +74,6 @@ assert_conformable_dims <- function(left, right, context, err_msg) {
7474
invisible(TRUE)
7575
}
7676

77-
emit_quickr_error_if <- function(
78-
condition,
79-
message,
80-
hoist,
81-
scope
82-
) {
83-
stopifnot(
84-
is_string(condition),
85-
is_string(message),
86-
inherits(hoist, "environment"),
87-
inherits(scope, "quickr_scope")
88-
)
89-
mark_scope_uses_errors(scope)
90-
err_lines <- quickr_error_fortran_lines(message, scope = scope)
91-
hoist$emit(glue(
92-
"
93-
if ({condition}) then
94-
{indent(str_flatten_lines(err_lines))}
95-
end if
96-
"
97-
))
98-
}
99-
10077
# Return the R symbol name if operand is a bare symbol; otherwise NULL.
10178
symbol_name_or_null <- function(x) {
10279
stopifnot(inherits(x, Fortran))

0 commit comments

Comments
 (0)