CratonVM
Loop-pass proposals — round 10, lane loop
Status: PROPOSALS ONLY. Written at the close of a hard review of
jit/src/x64/licm.rs, licm_int.rs, loop_rewrite.rs, loop_unroll_admission.rs,
bce.rs, jit/src/loop_analysis.rs, scev.rs and range_analysis.rs. Nothing
below was built or measured; each item states its own cost.
1. Retire the native byte-copy unroller in favour of the bytecode rewriter
Where: jit/src/x64/loop_rewrite.rs (bytecode_loop_xform_rewrites_bytecode,
native_unroller_enabled), jit/src/x64/driver.rs's plan_native_unroll call
sites.
What is true today. Two unrollers exist and are mutually exclusive by
construction (native_unroller_enabled() == !bytecode_loop_xform_rewrites_bytecode(),
pinned by the_two_unrollers_are_never_both_live in
loop_unroll_admission.rs). The native one is live by default; the bytecode
rewriter is oracle-only (plan_native_unroll asks plan_loop_unroll for a
verdict and discards the rewritten bytes). loop_rewrite.rs's own module doc
says the three whole-compile refusals that used to block the rewriter
(deopt_real, precise exception frames, invokedynamic) are gone — the bci
translation through Compiler::orig_bci answers all three — and the fourth
(inline sites) is a real limit on TODAY's provenance map, not a fundamental one.
Proposal. Default-on the bytecode rewriter for loops with no inline sites,
after: (a) soaking loop_xform_deopt_frames_diverge (counted, never refused,
per rewritten_deopt_points_are_publishable) on a real corpus to confirm it
stays at the noise floor; (b) extending inline-scope provenance
(docs/jit/deopt-inline-scopes.md) so InlineSitesPresent stops excluding
every inlined hot loop.
Why it is worth doing. The native unroller duplicates MACHINE CODE and
re-resolves helper rel32s and IC slots per copy — real work repeated k
times per compile. The rewriter duplicates BYTECODE once and lets the ordinary
emitter compile it, so IC slots, field resolutions and inline sites are
computed once per compile regardless of k (replicate_pc_keyed's whole
point). It is also the only path that can PEEL a bypassable header
(LoopXformKind::Peel), which the native unroller cannot do at all — every
bypassable-header loop today keeps its unroll refused
(plan_native_unroll -> bypassable_headers.contains(&header) -> None)
rather than being peeled into an unrollable shape.
Cost. Inline-scope provenance is the real work — it means describing a callee's bci space inside the caller's rewritten-bytecode coordinate system, a change that touches inlining's snapshot machinery, not just this file. Rough size: comparable to the OSR-entry / deopt-bci translation this lane's doc comments describe as already landed, i.e. weeks, not days. Everything else (the default flip, the soak) is small.
2. A guard SET for plan_loop_version, not one guard
Where: jit/src/x64/licm.rs (encode_preheader_guard, LoopVersioning),
jit/src/x64/loop_rewrite.rs (trip_count_guard, which already refuses when
prove_trip_count_at_least returns more than one guard: [g] => Some(...), _ => None).
What is true today. TripCountProof::Guarded can legitimately carry more
than one PreheaderGuard (a StrideInRange term plus a numeric one, for a
runtime-strided loop with an unproven entry value). trip_count_guard throws
the whole obligation away the moment there is more than one, falling back to
the unguarded transform. encode_preheader_guard's doc is explicit that this
is a real restriction ("Nothing with two comparisons... would need two fallback
edges") and not an oversight.
Proposal. Extend LoopVersioning's layout with Vec<(PreheaderGuard, pc, len)> chained ANDs — each guard's failure edge targets the NEXT guard's
first byte, and only the last guard's failure edge targets fallback_base —
and let plan_versioned hand over every guard prove_trip_count_at_least
returns instead of discarding all but a singleton. GuardShape::covers in
bce.rs already answers per-guard coverage questions the same way a chained
encoder would need to.
Why it is worth doing. Every variable-stride loop (for (i = k; i < n; i += step), the Sieve-of-Eratosthenes inner loop this codebase's own tests use as
the canonical example) needs both a StrideInRange guard and a numeric
trip-count guard to be versioned at all. Today it gets neither: one guard
lost, the other never asked for.
Cost. Small and mechanical in licm.rs (a Vec instead of one guard, one
extra loop over encode_preheader_guard outputs, chaining offsets). No change
to PreheaderGuard's type — this is purely about how many of them
plan_loop_version accepts.
3. Sharper Range::ushr / Range::or / Range::xor for mixed-sign inputs
Where: jit/src/range_analysis.rs (ushr, or, xor, and's
(false, false) arm).
What is true today. or and xor answer Range::top the moment either
operand's range includes a negative value; ushr answers a width-wide
"non-negative, below 2^(bits-s)" bound whenever the input's lower endpoint is
negative, with no use of the upper endpoint at all. Each of these is SOUND
(documented and verified sound above) but throws away precision the interval
already has: a range entirely below zero (hi < 0) has an exactly computable
ushr image (unsigned representation is monotonic in the value for an
all-negative sub-range), and or/xor of two ranges that are each entirely
negative or entirely non-negative (not just "some value is") have tighter
closed forms than top.
Proposal. Split each transfer function's negative case into "entirely
negative" (hi < 0) vs. "straddles zero" (lo < 0 <= hi), and give the first
sub-case the monotonic closed form instead of falling through to the
imprecise combined case. This is the same shape and's (false, false) arm
already uses (if ah < 0 && bh < 0 { tight } else { top }) — or/xor/ushr
would gain the analogous split.
Why it is worth doing. hash & (table.length - 1) is the one masking
idiom and already special-cases and the comment names as load-bearing for
hashed-index bounds checks. x | Integer.MIN_VALUE (setting a bit) and
x >>> 1 on a provably-negative x (bit-manipulation code, Long.hashCode,
checksum accumulators) are the same shape one level over — currently every one
of them returns top the instant the analysis has proven the input negative
at all, undoing the very fact that made the proof interesting.
Cost. Small: pure-function changes to three transfer functions, each
provable by the same exact-arithmetic-then-narrow discipline the module
already states as its rule, plus a handful of #[cfg(test)] cases per
function (a fully-negative pair, a straddling pair, a fully-non-negative
pair, and the constant-count zero-shift identity). No callers change.
4. A shared "does this bytecode walk model wide?" lint, not eight retellings
Where: every file in this lane's ownership list.
What is true today. This codebase has fixed the "a wide-prefixed store
is invisible to a linear bytecode walk" bug class at least three times on
record (loop_analysis::modified_locals_strict's doc history, bce.rs's
find_induction_variable 0xc4 arm, and the licm_int.rs /
licm.rs::find_array_len_hoists comments about why they refuse a body
containing one at all rather than re-deriving the fix). Each fix is a
hand-written match arm plus a hand-written regression test; nothing enforces
that walk N+1 gets the same treatment.
Proposal. A #[cfg(test)]-only differential harness — call it
assert_wide_prefix_parity — that, given a bytecode buffer and a "does this
opcode write local L" predicate over the SHORT form, checks the predicate's
answer against the wide-prefixed encoding of the same instruction at the
same PC, for every opcode that has a wide form (iload/lload/fload/
dload/aload, their stores, and iinc). Every one of this lane's linear
walks that touches locals (find_induction_variable, find_iv_stride,
modified_locals_strict, find_iv_nonneg_start,
bound_arraylength_provenance_with, the arith/array-length/FP hoist finders)
runs it once in its own test module.
Why it is worth doing. The bug class is not hypothetical — it has cost a
real fix three times in this codebase's own history, and by construction it
is invisible to a reviewer reading any ONE of the eight files, because each
walk is a self-contained match over opcodes and "opcode 0xc4 has no arm" reads
as a boring default case, not a hole. A shared harness turns "did we remember
wide this time" from a per-function manual audit into a one-line call.
Cost. Small (a test-only helper, driven by a table of (short_opcode, wide_real_opcode, operand_len) this lane's files already enumerate
individually in comments) plus one call site per existing walk. No production
code changes.
5. Publish loop_xform_deopt_frames_diverge and loop_xform_planner_refused as a per-refusal-reason histogram
Where: jit/src/x64/loop_rewrite.rs (metrics::record_loop_xform_event),
jit/src/x64/licm.rs (LoopXformRefusal).
What is true today. plan_bytecode_loop_xform tallies loop_xform_*
counters by CATEGORY (loop_xform_planner_refused vs.
loop_xform_no_candidate_loop), but the actual LoopXformRefusal variant
(there are seventeen) is only ever logged behind
CRATONVM_DBG_JIT_GEN, never counted. So today's oracle-only mode (item 1's
prerequisite for even being SOAKED) cannot answer "which refusal is costing
the most loops" without re-running a debug-logged corpus and grepping.
Proposal. A [LoopXformRefusal; N]-shaped counter array (or a
FxHashMap<&'static str, AtomicU64> keyed on Debug output, matching the
existing record_loop_xform_event string-keyed style) bumped once per
Err(refusal) in plan_bytecode_loop_xform, independent of the debug-log
flag.
Why it is worth doing. It is the measurement item 1's own soak plan
needs, and it is what would have told this lane, without reading source,
whether BodyTooLarge or ExternalEntry or TimeToSafepointBudget is the
dominant refusal on a real corpus — which is exactly the question "should
this default flip" depends on.
Cost. Trivial — the refusal is already computed and matched on at the one call site; this only adds a counter bump next to the existing debug print.