Skip to content

[5/15] Validate subscripts and make in-loop errors cancel OpenMP loops - #141

Merged
t-kalinowski merged 10 commits into
t-kalinowski:mainfrom
mns-nordicals:conformability/05-subscript-validation
Aug 2, 2026
Merged

[5/15] Validate subscripts and make in-loop errors cancel OpenMP loops#141
t-kalinowski merged 10 commits into
t-kalinowski:mainfrom
mns-nordicals:conformability/05-subscript-validation

Conversation

@mns-nordicals

Copy link
Copy Markdown
Contributor

Stacked PR 5 of 15 — branch conformability/05-subscript-validation,
cut from PR 4 (#140), branch conformability/04-ifelse-t-diag.
GitHub can only diff a fork branch against main, so until PR 4 merges
this page also shows PRs 1–4's commits; the 5 commits new to this
PR are Move emit_quickr_error_if() to error-handling.RExtract check_subscript_exprs() shared by read and write subscripts.

Stack overview and suggested review order: #152.

Fixes #126.

What changed

Negative and zero subscripts are now compile-time errors. R's negative
subscript means exclusion and its zero subscript is silently dropped —
both produce result shapes that depend on the subscript's value, which
quickr's static-shape model cannot represent — and the generated Fortran
read out of bounds (x(-1), x(0)) and returned the wrong shape:

fn <- function(x) {
  declare(type(x = double(4)))
  x[-1L]
}
# before: silent out-of-bounds read, scalar result (R returns length 3)
# after:  Error: subscripts must be positive; R's negative (exclusion) and
#         zero subscripts are not supported: -1L

The syntactic form x[-i] is rejected too — unary minus on a subscript is
unambiguously exclusion in R, so the form is refused even when the value
is unknown (negative subscripts (exclusion) are not supported: -i). Same
for negative/zero elements of literal vectors (x[c(-1L, -2L)]) and
literal range bounds < 1 (x[0:2]).

Assignment subscripts get the same validation. x[-1L] <- 9 and
x[0L] <- 1 bypassed the read-side checks entirely and compiled into
silent out-of-bounds Fortran writes. compile_subset_designator() now
runs the same checks, covering [<-, [<<-, and closure host writes.

Literal subscripts are checked against statically-known extents.
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
(subscript exceeds its dimension's extent (3): 4L; R's out-of-range subscripts (NA padding, vector growing) are not supported). 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 — symbolic
scalar subscripts stay trusted-in-bounds (see notes).

x[a:b] with runtime bounds gets a runtime guard. The emitted section
a:b:sign(1, b-a) and its claimed length abs(b-a)+1 are correct exactly
when both bounds are >= 1; x[1:n] with n = 0 used to read x(0) and
return 2 values where R returns 1. Now a single scalar check per statement
runs before any memory is touched:

if (n < 1_c_int) then
  call quickr_set_error_msg("index ranges in x[a:b] must have bounds >= 1")
  return
end if
out = x(1_c_int:n:sign(1, n-1_c_int))

Literal halves of the check are constant-folded away (x[2:4] emits no
guard), and descending ranges (x[n:1]) still work.

x[seq(a, b, by = step)]: a non-literal step is now a compile
error — 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
was an unguardable division-by-zero crash. (It was also mis-sized:
seq_like_length_expr() silently dropped a non-literal by, so
x[seq(1L, 9L, by = k)] claimed length 9 for every k, returning garbage
past the real section; the same fix makes seq(1L, 9L, by = k) in value
position size correctly.) With a literal step and symbolic bounds, the
wrong-sign case gets a runtime guard raising "wrong sign in 'by'
argument", as R does.

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)] (covered by a new
test). For-loops are untouched — do i = 1, 0 already runs zero times.

Errors raised inside OpenMP loops now cancel the loop. Generated
error paths emit !$omp cancel do, but per the OpenMP spec cancel
constructs are no-ops unless OMP_CANCELLATION=true is set when the
OpenMP runtime first initializes — and nothing set it. The error
message was recorded correctly, but every remaining iteration still
ran, and statements after a failed in-loop check kept executing in that
iteration's thread. quickr now sets 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 stays best-effort — messages are always correct
either way.

How

  • emit_quickr_error_if() moved from the BLAS translation file to
    R/error-handling.R (unchanged) so any handler can emit a statement-
    level runtime guard; this PR is the first non-BLAS consumer.
  • R/r2f-subscript.R: new check_subscript_exprs() validates the raw R
    index expressions at the top of the [ handler, where literalness and
    unary minus are still visible (per index, check_subscript_expr()
    handles literal values, unary minus, : endpoints, and recurses into
    c()); subscript_axis_extents() supplies the per-axis literal
    extents, and compile_subset_designator() (R/r2f-closures.R) calls
    the same check_subscript_exprs() for assignments.
  • R/r2f-iterables-helpers.R: new check_subscript_range_bounds()
    called from seq_like_r2f()'s subscript branch; plus the
    seq_like_length_expr() fix (the literal-bounds fast path now requires
    a literal by too).
  • R/zzz.R, R/declare.R: the OMP_CANCELLATION set-if-unset and its
    documentation.

Tests

New test-subscript-validation.R: compile-time rejections for every
exclusion form; literal out-of-extent rejections for reads and writes
(scalar, range, c(), per-axis matrix, linear indexing, [<<-) with
in-range and symbolic cases still compiling; runtime guard firing for
x[1:n] (n = 0 and negative)
with the valid and descending cases still passing; no-guard translation
snapshot for literal bounds; the seq-step compile error and wrong-sign
runtime guard; zero-length x[seq_len(0)] matching R; symbolic-by
seq() sizing in value position. Translation snapshots pin the guard
text. New test-onload.R covers set-when-unset / respect-when-preset.

Snapshot churn: only example-roll_mean.md — its x[i:(i + n - 1)] now
carries the bounds guard inside the loop (plus the error-message plumbing
in the subroutine signature and C bridge).

Notes for review

  • Behavior changes (candidates for NEWS): x[1:0]-style empty ranges now
    raise an R error instead of reading out of bounds (R drops the 0 and
    returns x[1] — a value-dependent shape quickr cannot produce);
    exclusion subscripts that previously "worked" by reading garbage now
    fail to compile; x[seq(a, b, by = k)] with non-literal k now fails
    to compile (it previously returned garbage past the real section);
    literal out-of-extent subscripts (x[4L] on double(3), reads and
    writes) now fail to compile instead of touching out-of-bounds memory.
  • Scalar symbolic subscripts (x[k] where k might be <= 0 at run time)
    are deliberately not guarded per-element — they stay trusted to be
    positive and in bounds, as before this PR; a quickr.bounds_check
    option could add opt-in guards later.
  • The guard inside roll_mean's loop is two scalar compares per
    iteration next to a windowed sum(); hoisting loop-invariant guards
    out of loops is a possible future optimization, not attempted here.

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.90265% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.37%. Comparing base (fafce1c) to head (37ea0a5).

Files with missing lines Patch % Lines
R/r2f-iterables-helpers.R 95.04% 5 Missing ⚠️
R/c-wrapper.R 96.66% 1 Missing ⚠️
R/r2f-subscript.R 98.70% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #141      +/-   ##
==========================================
+ Coverage   93.26%   93.37%   +0.11%     
==========================================
  Files          29       30       +1     
  Lines        6161     6370     +209     
==========================================
+ Hits         5746     5948     +202     
- Misses        415      422       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
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.
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.
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.
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.
@t-kalinowski
t-kalinowski force-pushed the conformability/05-subscript-validation branch from a4e79af to 87b3092 Compare July 31, 2026 01:37

@t-kalinowski t-kalinowski left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you very much for this series of PRs! For the roll mean example, I wonder if we need the check every loop. I understand how it happens, but it seems like quite a performance tax to introduce for every loop iteration, especially since we have full information in advance.

weights = ((weights / sum(weights)) * size(weights))
end if
do i = 1, size(out)
if (i < 1_c_int .or. (((i + n) - 1_c_int)) < 1_c_int) then

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Why do we need a bounds check here? Seems like the code is being overly defensive. For the generated code in quickr, I'd rather eer on the side of speed, even if it means users bear some responsibility to write correct (R) code.

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 agree and the general intent with runtime guards is for them to be cheap one time checks. Later when it come to matrix operations I think the runtime guards will be more useful there.

Also, the implementation is half-baked, so I have removed it in the last commit.



if (n < 1_c_int) then
call quickr_set_error_msg("index ranges in x[a:b] must have bounds >= 1")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The bigger risk would be n > size(x), no?

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.

See reply to previous comment

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.
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.
@mns-nordicals

Copy link
Copy Markdown
Contributor Author

The first fixes a pre-existing hoisting bug exposed by this PR’s new runtime guards. Unbraced for bodies inherited the enclosing statement’s hoist target, so loop-dependent guards or temporaries could be emitted before the loop and reference an uninitialized loop variable. The fix gives every loop body its own hoist target.

@mns-nordicals

Copy link
Copy Markdown
Contributor Author

gfortran seem to have an compile option called -fcheck=bounds which probably does a better runtime check than what was tried here. So using that for extra safety is likely a better option.

@t-kalinowski
t-kalinowski merged commit 33a58ad into t-kalinowski:main Aug 2, 2026
8 checks passed
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.

[5/15] Negative/zero/out-of-range subscripts and empty index ranges read (and write) out of bounds; OpenMP early exit never happens

2 participants