[5/15] Validate subscripts and make in-loop errors cancel OpenMP loops - #141
Conversation
d4a12a5 to
a4e79af
Compare
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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.
a4e79af to
87b3092
Compare
t-kalinowski
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
The bigger risk would be n > size(x), no?
There was a problem hiding this comment.
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.
|
The first fixes a pre-existing hoisting bug exposed by this PR’s new runtime guards. Unbraced |
|
gfortran seem to have an compile option called |
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:The syntactic form
x[-i]is rejected too — unary minus on a subscript isunambiguously exclusion in R, so the form is refused even when the value
is unknown (
negative subscripts (exclusion) are not supported: -i). Samefor negative/zero elements of literal vectors (
x[c(-1L, -2L)]) andliteral range bounds < 1 (
x[0:2]).Assignment subscripts get the same validation.
x[-1L] <- 9andx[0L] <- 1bypassed the read-side checks entirely and compiled intosilent out-of-bounds Fortran writes.
compile_subset_designator()nowruns the same checks, covering
[<-,[<<-, and closure host writes.Literal subscripts are checked against statically-known extents.
x[4L]andx[2:4]on a declareddouble(3)compiled and read garbagewhere 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 subscripton 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 sectiona:b:sign(1, b-a)and its claimed lengthabs(b-a)+1are correct exactlywhen both bounds are >= 1;
x[1:n]withn = 0used to readx(0)andreturn 2 values where R returns 1. Now a single scalar check per statement
runs before any memory is touched:
Literal halves of the check are constant-folded away (
x[2:4]emits noguard), and descending ranges (
x[n:1]) still work.x[seq(a, b, by = step)]: a non-literalstepis now a compileerror — 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-literalby, sox[seq(1L, 9L, by = k)]claimed length 9 for everyk, returning garbagepast the real section; the same fix makes
seq(1L, 9L, by = k)in valueposition 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 alegal zero-length section, matching R's
x[integer(0)](covered by a newtest). For-loops are untouched —
do i = 1, 0already runs zero times.Errors raised inside OpenMP loops now cancel the loop. Generated
error paths emit
!$omp cancel do, but per the OpenMP spec cancelconstructs are no-ops unless
OMP_CANCELLATION=trueis set when theOpenMP 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=truein.onLoadwhen unset (a pre-set value is respected). Caveats documented in
?declare: no effect if another package already initialized the OpenMPruntime, so early exit stays best-effort — messages are always correct
either way.
How
emit_quickr_error_if()moved from the BLAS translation file toR/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: newcheck_subscript_exprs()validates the raw Rindex expressions at the top of the
[handler, where literalness andunary minus are still visible (per index,
check_subscript_expr()handles literal values, unary minus,
:endpoints, and recurses intoc());subscript_axis_extents()supplies the per-axis literalextents, and
compile_subset_designator()(R/r2f-closures.R) callsthe same
check_subscript_exprs()for assignments.R/r2f-iterables-helpers.R: newcheck_subscript_range_bounds()called from
seq_like_r2f()'s subscript branch; plus theseq_like_length_expr()fix (the literal-bounds fast path now requiresa literal
bytoo).R/zzz.R,R/declare.R: theOMP_CANCELLATIONset-if-unset and itsdocumentation.
Tests
New
test-subscript-validation.R: compile-time rejections for everyexclusion form; literal out-of-extent rejections for reads and writes
(scalar, range,
c(), per-axis matrix, linear indexing,[<<-) within-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-byseq()sizing in value position. Translation snapshots pin the guardtext. New
test-onload.Rcovers set-when-unset / respect-when-preset.Snapshot churn: only
example-roll_mean.md— itsx[i:(i + n - 1)]nowcarries the bounds guard inside the loop (plus the error-message plumbing
in the subroutine signature and C bridge).
Notes for review
x[1:0]-style empty ranges nowraise 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-literalknow failsto compile (it previously returned garbage past the real section);
literal out-of-extent subscripts (
x[4L]ondouble(3), reads andwrites) now fail to compile instead of touching out-of-bounds memory.
x[k]wherekmight 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_checkoption could add opt-in guards later.
roll_mean's loop is two scalar compares periteration next to a windowed
sum(); hoisting loop-invariant guardsout of loops is a possible future optimization, not attempted here.