Skip to content

Possibility to align a split. - #9371

Draft
mcourteaux wants to merge 38 commits into
mainfrom
mcourteaux/aligned-split
Draft

Possibility to align a split.#9371
mcourteaux wants to merge 38 commits into
mainfrom
mcourteaux/aligned-split

Conversation

@mcourteaux

@mcourteaux mcourteaux commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Opening PR to show work in progress, and gather feedback and enter discussion.

Instead of splitting a loop where the rewritten inner and outer loop always start at 0, like this:

// f.split(x, xo, xi, 32);
for (xo, 0, (f.extent.0 + 31) / 32 - 1) {
  for (xi, 0, 31) {
      let x = xo * 32 + xi 
  }
}

It's now possible to align the first iteration of the inner loop, like so:

f.split(x, xo, xi, 32, -4);
for (xo, -4 / 32, (f.extent.0 - 4 + 31) / 32 - 1) {
  for (xi, 0 - 4, 31 - 4) {
      let x = xo * 32 + xi
      if (x >= 0 && x <= max) { // for GuardWithIf

      }
  }
}

This allows you to then unroll or vectorize the inner loop with a known alignment (modulo).
This comes up when demosaicing Bayer images where the offset of the filter pattern (CFA) is not known up front. Instead of compiling 4 different specializations of this pipeline with all for possible offsets, you can now pass in the CFA-offset as a runtime Param<int>:

f(x, y, c) = select((x + offset_x) % 2 == 0, /* similar select for (y + y_offset) */); 
f.split(x, xo, xi, 2, offset_x)
 .split(y, yo, yi, 2, offset_y)
 .reorder(c, xi, yi, xo, yo)
 .unroll(xi)
 .unroll(yi);

Alternative considered

After a very lengthy discussion with @abadams I attempted to implement a .guard_with_if() directive that would combine orthogonally with .align_bounds(). However, align bounds changes the bounds during bounds inference phase. The initial idea of "fixing" the widend bounds was to protect it with an if (hence guard_with_if()). However, what the if is supposed to do is to guard against out-of-bounds accesses, but align_bounds() actually changes the bounds, so there is nothing to protect against. For more details, see #9357.

While implementing this, the number of things that broke, missing simplifier rules to make it work, new behavior required in BoundsInference, BoundConstantExtentLoops, SlidingWindow was not pretty. While I think I got all of this working correctly in mcourteaux/guard-with-if, it is fundamentally backwards-incompatible because .align_bounds() is meant to change the size of the bounds (and impose constraints on it): it extends the computed region. Combining it with a ShiftInwards, or guard_with_if() again shrinks the computed region. There were several tests depending on the widening behavior of align_bounds().

Breaking changes

None, it's a new feature that isn't used anywhere.
If we can agree this is a good idea, I'll keep working on this to get it with the necessary documentation, tutorial, serialization, python bindings.

Checklist

As I said, checklist to be completed if others greenlight this.

  • Tests added or updated (not required for docs, CI config, or typo fixes)
  • Documentation updated (if public API changed)
  • Python bindings updated (if public API changed)
  • Benchmarks are included here if the change is intended to affect performance.
  • Commits include AI attribution where applicable (see Code of Conduct)

@mcourteaux mcourteaux added enhancement New user-visible features or improvements to existing features. release_notes For changes that may warrant a note in README for official releases. labels Aug 20, 2026
@abadams

abadams commented Aug 20, 2026

Copy link
Copy Markdown
Member

Could you provide an example where the starting loop is not already aligned in absolute coordinates? In your example before case it's aligned to zero.

Also, how do aligned splits interact with rfactor? It has to enact any relevant splits eagerly. Hopefully rfactor tolerates this as-is, but some adding some rfactor test cases that use aligned splits is probably a good idea.

@mcourteaux

Copy link
Copy Markdown
Contributor Author

Could you provide an example where the starting loop is not already aligned in absolute coordinates?

The starting loop is aligned in absolute coordinates. The problem is that the split() always rebases the inner and outer loop to zero. So your original loop was aligned (before rebase loops to zero lowering pass), but the initial scheduling rewrites the aligned loop by a split into two non-aligned loops. That happens here (notice + old_min):

Expr base = outer * split.factor + old_min;

and

Halide/src/ApplySplit.cpp

Lines 176 to 181 in 5c21c82

Expr inner_extent = split.factor;
Expr outer_extent = (old_var_max - old_var_min + split.factor) / split.factor;
let_stmts.emplace_back(prefix + split.inner + ".loop_min", 0);
let_stmts.emplace_back(prefix + split.inner + ".loop_max", inner_extent - 1);
let_stmts.emplace_back(prefix + split.outer + ".loop_min", 0);
let_stmts.emplace_back(prefix + split.outer + ".loop_max", outer_extent - 1);

@mcourteaux

Copy link
Copy Markdown
Contributor Author

Still working on this. It's taking more and more shape. Will update tomorrow most likely. I ran into a really nice use case for combining align_bounds() on a producer, and an aligned split() on a consumer.

@abadams

abadams commented Aug 24, 2026

Copy link
Copy Markdown
Member

A use case I found today is roughly:

f(x) = select(x <= 0, ..., x < 7, likely(... x % 2 ...), ...)
f.bound(x, 0, 8)
f.split(x, xo, xi, 2, 1, TailStrategy::GuardWithIf).always_partition(xo);

I.e. I have a loop of size 8, and I want to unroll it 2x to remove a modulo, but I also want to carve off the first and last iteration as different. Does this make sense as a use of the feature? Partitioning xo without the alignment today carves off the first two and last two iterations, which is not what I want.

@mcourteaux

mcourteaux commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Does this make sense as a use of the feature? Partitioning xo without the alignment today carves off the first two and last two iterations, which is not what I want.

Cool! Yep, I think it fully makes sense. I'll add this particular case as a test to make sure it does what we expect here.

The test works!

 produce f {
  f[0] = 100
  for (f.s0.x.xo, 0, 2) {
   f[(f.s0.x.xo*2) + 1] = 1
   f[(f.s0.x.xo*2) + 2] = 0
  }
  f[7] = 200
 }

with this schedule indeed:

    Var x{"x"}, xo{"xo"}, xi{"xi"};
    Func f{"f"};
    f(x) = select(x <= 0, 100,
                  x < 7, likely(x % 2),
                  200);
    f.bound(x, 0, 8);
    f.split(x, xo, xi, 2, 1, TailStrategy::GuardWithIf)
        .always_partition(xo)
        .unroll(xi);

mcourteaux and others added 23 commits August 25, 2026 20:48
…cations/AllocationBoundsInference

Introduces BoundsTracker, a struct that accumulates enclosing pure
LetStmt/Let bindings and dominating facts while a mutator descends a
Stmt tree, and uses them to find constant bounds far more reliably
than a bare find_constant_bound() call. In addition to a Scope<Interval>
fast path, find_constant_bound_aggressive()/find_constant_bounds_aggressive()
fall back to wrapping an expression in all pending pure lets, inlining
them with substitute_in_all_lets, and re-simplifying under the
dominating facts -- generalizing the trick bound_constant_extent_loops
has always used to find constant loop extents.

Migrates all three targeted passes onto it:
 - BoundConstantExtentLoops: same two-tier (exact vs guarded upper
   bound) unroll/vectorize logic, now expressed via
   find_constant_bounds_aggressive()'s interval collapse-to-a-point
   check instead of a separate ad hoc IntImm check.
 - BoundSmallAllocations: Frame/visit_let chain now binds through
   tracker.push_let(); find_constant_bound() call sites upgraded to
   find_constant_bound_aggressive() so allocation/realize extents get
   the same aggressive treatment.
 - AllocationBoundsInference: gains LetStmt/For tracking it never had
   before, and runs the box_touched() result through
   tracker.simplify_with_context() before CSE, so a Realize's per-
   dimension min/max can be simplified using enclosing let context
   that box_touched (called with an empty scope) can't see on its own.

KNOWN ISSUE (not yet resolved): correctness_unroll_loop_with_implied_constant_bounds
segfaults via infinite recursion inside Simplify's fact/var_info
substitution machinery, triggered from BoundConstantExtentLoops's
aggressive fallback when two dominating facts (a bounds-query check
and a 4-way equality conjunction "three_channels") are both active
for the same simplify() call. Confirmed via debug instrumentation
that BoundsTracker builds the same wrapped expression and fact list
the original hand-rolled implementation would have; the crash is
inside the Simplify engine's own var_info replacement logic
(Simplify_Exprs.cpp:270-282), not in BoundsTracker's bookkeeping.
Root cause not yet isolated -- needs a minimal standalone repro
against Simplify() directly (bypassing BoundsTracker/Lower.cpp
entirely) to determine whether this is a latent pre-existing Simplify
bug that BoundConstantExtentLoops previously never triggered, or a
subtle behavioral difference between BoundsTracker's fact accumulation
and the original vector-based one. All other targeted correctness
tests (bounds, bound_small_allocations, unroll, vectorize, realize,
extern, sliding_window, partition_loops, split, etc.) pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EGMmdqNC6mTcMSCDBFbwV
…okup

BoundLoops::visit(For*) took bounds.max.as<IntImm>() as a raw pointer
while `bounds` was a stack-local Interval about to go out of scope. If
that Interval's Expr was the only thing keeping the underlying IntImm
node's refcount alive, the node could be freed as soon as `bounds` was
destroyed, leaving `e` dangling. The freed memory would typically get
reused shortly after (while unwinding through further LetStmt/IfThenElse
processing), corrupting the IntImm embedded in the constructed For loop
and manifesting later as an infinite Add/Sub/Variable recursion inside
Simplify -- reported by the user as a segfault in
correctness_unroll_loop_with_implied_constant_bounds, reproduced and
fixed with their help using an ASan build.

Fixed by copying the Expr into `extent_upper` (already a function-scoped
local used for the guarded-upper-bound case) before extracting the raw
IntImm pointer from it, so the node stays referenced for the rest of
the function regardless of which branch is taken.

Also fixes the CMake issue that blocked building an ASan config in the
first place: Halide_initmod (the object library holding the runtime's
embedded bitcode blobs, linked into the shared Halide target) never had
POSITION_INDEPENDENT_CODE set, unlike the Halide target itself. This
happened to link fine in optimized builds, where x86-64 codegen tends
to use RIP-relative addressing regardless, but failed with absolute
32-bit relocation errors in unoptimized/Debug/ASan builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EGMmdqNC6mTcMSCDBFbwV
Both DetermineAllocStride and LowerWarpShuffles maintained their own
Scope<Interval> bounds, populated on For loops only when the loop's min
and max were literal constants (is_const(op->min) && is_const(op->max)),
and never updated by LetStmt/Let at all -- so any allocation size or
stride computation that depended on a let-bound intermediate value (very
common after earlier lowering passes hoist bounds calculations into
lets) had no way to resolve to a constant.

All uses of `bounds` in this file only ever feed it into simplify() or
reduce_expr() (itself simplify()-based), never bounds_of_expr_in_scope()
directly -- and Simplify's own internal bounds representation is
constant-only anyway (it converts via as_const_int at ingestion), so
BoundsTracker's constant-collapsing scope loses nothing here, unlike
SlidingWindow/HexagonOptimize which need genuinely symbolic interval
tracking BoundsTracker doesn't provide (left unmigrated).

Adds two small BoundsTracker capabilities needed by this pass:
 - interval_scope(): exposes the underlying Scope<Interval> for passes
   that feed it directly to simplify()/similar rather than going through
   find_constant_bound().
 - push_interval(): pushes an already-computed Interval directly,
   for LowerWarpShuffles::visit(IfThenElse*)'s lane-masking case, which
   narrows an existing binding rather than deriving a new one.

Verified with a full correctness suite run under an actual CUDA JIT
target (HL_TARGET/HL_JIT_TARGET=host-cuda, reconfigured via
-DHalide_TARGET=host-cuda since ctest bakes the target into each test's
ENVIRONMENT property at configure time rather than inheriting it from
the shell). One unrelated pre-existing failure
(correctness_gpu_register_at_block_level, in PromoteGPURegisters/
MultiRamp, which runs before LowerWarpShuffles in the pipeline) was
confirmed to reproduce identically against an unmodified origin/main
build with the same target override, so it predates this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EGMmdqNC6mTcMSCDBFbwV
…Tracker

SimplifyCorrelatedDifferences doesn't just back find_constant_bounds()
(via the exported bound_correlated_differences() on a single Expr) --
simplify_correlated_differences() is also run directly as a whole-tree
lowering pass in Lower.cpp, several times. Give it a BoundsTracker so it
gathers the same constant-bounds context the other migrated passes do,
and use it in cancel_correlated_subexpression()'s final simplify() call.

This complements rather than replaces the pass's existing `lets`
tracking (used to wrap terms for CSE before solve_expression): `lets`
deliberately excludes pure lets that are constant w.r.t. the current
loop_var, since the monotonicity analysis doesn't need them, but the
final simplify() can still benefit from resolving them, and from
dominating assert conditions this pass previously never looked at at
all (new visit(Block*) override, peeling leading asserts the same way
BoundConstantExtentLoops peels dominating if-conditions).

Deliberately uses interval_scope()/known_facts() fed straight into
simplify(), not find_constant_bound_aggressive()'s more powerful
wrap-every-pending-let-and-resimplify path: this pass is already
documented as quadratic in loop nesting depth and runs across the whole
tree multiple times, so paying that cost on every correlated-difference
site would be a real compile-time risk. (Chased what looked like a ~9x
compile-time regression down this path during development -- it turned
out to be an unrelated Debug-vs-RelWithDebInfo build type mismatch
between the two binaries being compared, not anything caused by this
change or the earlier migrations; a same-build-type comparison confirms
no regression.)

Verified with a full correctness suite run (CUDA JIT target enabled):
462/463 passed, with the one failure being the same pre-existing,
unrelated GPU register-allocation issue already confirmed to reproduce
against an unmodified origin/main build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EGMmdqNC6mTcMSCDBFbwV
A producer compute_at a plain (non-aligned) split tile of its consumer,
with the tail handled by PredicateStores, still needs bounds inference
to find a compile-time-constant bound for the tile's extent in order to
unroll it -- PredicateStores only predicates the consumer's store, not
the loads that feed it, so the producer's required region for a
boundary tile stays tied to the consumer's declared extent rather than
becoming an unconditional full tile.

Combined with align_bounds() rounding that region's extent up to a
multiple of the tile factor, the resulting extent expression is a
ceiling-divide of a min-clamped quantity minus the unclamped multiple
the clamp is anchored to: (min(x*c0 + c1, y) + c2)/c0 - x. No existing
rule covered it, so bounds inference found no bound at all and
unrolling failed outright, even though the region provably fits in one
tile.

Add that as an exact identity (not just a bound) to
SimplifyCorrelatedDifferences's PartiallyCancelDifferences: c0 > 0
means both "+c2" and "/c0" distribute over min, so it reduces to
min((y+c2)/c0 - x, (c1+c2)/c0) unconditionally, not just when c1/c2
happen to be multiples of c0.

Also push the enclosing loop's own range into BoundConstantExtentLoops'
BoundsTracker before recursing into its body, and resimplify the
extent with that context before giving up -- matching the pattern
BoundSmallAllocations, AllocationBoundsInference, and LowerWarpShuffles
already use. Not load-bearing for the new test (the
SimplifyCorrelatedDifferences rule alone already finds the bound), but
the same class of gap for any nested extent that only resolves once an
enclosing loop's bound is in scope.
An unrolled producer tile inside a PredicateStores split gets an extent
of the form (min(x*c + c, y) + c)/c*c - x*c, where the enclosing tile
loop's own max (a ceiling-divide of y) is exactly what bounds y from
below and makes the ceiling-divide exact. BoundConstantExtentLoops could
only find the upper bound, so it unrolled to the split factor and
wrapped the body in a guard that is always true.

Two gaps, both on BoundsTracker's deliberately-expensive slow path:

simplify_with_context inlines the enclosing lets, which is what makes
the loop variable appear on both sides of the extent's subtraction --
but nothing cancelled it back out. Run bound_correlated_differences and
re-simplify, keeping the result only when it actually shrank (it can
grow the expression). That turns the extent into
min((y + c)/c - x, 1)*c.

That form is monotonic in the loop variable, so its extremes over the
loop are reached at the ends of the loop's range -- but push_for only
recorded a constants-only Interval, which drops the fact that the
loop's max mentions y too. Record the range symbolically as well, and
have find_constant_bounds_aggressive substitute the endpoints into an
expression is_monotonic() says is monotonic in that variable.
Substitution keeps y correlated between the expression and the loop
bound where per-node interval arithmetic can't, so the extent comes out
as exactly [c, c].

This reuses Monotonic.h's existing analysis rather than teaching the
simplifier a new kind of fact, and costs nothing until the cheap paths
have already failed to find a bound.

split_predicate_stores_compute_at now checks for no guard at all
inside the unrolled tile, rather than at most one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SvYp54SUAXaJ49MizJg63
Co-authored-by: Andrew Adams <andrew.b.adams@gmail.com>
…alled/deadlocked several times but couldn't debug it.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Gemini Pro 3.1 <gemini@aistudio.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… those blend operations in case of aligned splits.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Fix old copy-paste bug in simplifier rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mpute_at test

Checkpoint of in-progress work before investigating the surviving mux
under compute_at with aligned splits.
Rename split_aligned_2d_6x6.cpp to split_aligned_2d_3x3.cpp and shrink
the pattern to 3x3, which reproduces the surviving mux with a much
smaller amount of IR to read.

Also fix the test itself: realize the 3-D output with a 3-D shape, check
all three channels, sweep all nine (offset_x, offset_y) alignments, and
include c in the reorder so it stays innermost. With c left outermost it
was unrolled around the xo/yo nest, triplicating the loop nest and
recomputing R/G/B once per channel.

The test currently fails at the mux count (27 = 9 tile positions x 3
channels); the runtime results are correct for every alignment.
mcourteaux and others added 10 commits August 26, 2026 15:33
An aligned split iterates over whole tiles anchored at align, but the
provides it makes are clamped to the Func's own bounds. Bounds inference
derived the producing stage's region at each loop level from those
provides, so a Func computed inside the split got a region starting at
max(outer * factor + align, old_min) -- no longer congruent to align
modulo factor.

That defeats the point of an aligned split. A producer indexing on
old_var % factor keeps a non-constant index after unrolling, so a mux
over the tile never folds to the single way it selects. Loop
partitioning would otherwise carve out a steady-state region where the
clamp is provably dead, so the problem only showed up under
never_partition_all(), and only when the producer was scheduled with
compute_at rather than inlined.

Record the tile alongside the clamped promise and prefer it when
defining the producing stage's bounds, in the same style as the existing
.guarded lookup. The provide keeps its [old_min, old_max] promise, which
is what confines the stores and keeps the output buffer's required
region correct.

This trades a little compute for the alignment: boundary tiles are now
computed in full, so a producer reading an input buffer requires up to
factor-1 more of it on each side, as it would under RoundUp.

Fixes correctness_split_aligned_2d_3x3, which now sees zero muxes.
A loop of eight whose first and last iterations are special and whose
interior is periodic with period two. Unrolling the interior by two
folds the % away, but only if the unrolled pairs line up with the
periodicity, which means the tiles have to start where the interior
does. An aligned split says exactly that, and partitioning then peels
one iteration at each end rather than two, leaving a steady-state loop
of three rather than two.

Checks the extent of the remaining loop, that the modulo folded away,
and the values. Dropping the alignment from the split fails the extent
check, so the test is measuring the thing it claims to.
The condition of a can_prove predicate in a rewrite rule was simplified
on its own, without any of the facts the simplifier has learned on the
way down the IR. Substitute those facts into the condition first, and
store facts in the same comparison direction the simplifier produces, so
that a fact stated as x > y is usable when it visits y < x.

This makes fact-driven rewrite rules possible: max/min now pick a side
when the facts order the operands, and a division can cancel a
multiplication inside a max or min.

Co-authored-by: Claude <noreply@anthropic.com>
Facts and the conditions of can_prove predicates are now looked up in the
same canonical form: GT and GE are mapped onto LT, Not is unwrapped, and a
comparison can be settled by the other strictness of the same comparison in
either direction. This means it no longer matters how a fact was spelled
relative to how the rule that consumes it was, and a strict fact such as
x > y settles the non-strict predicate the max/min rules ask for.

Those rules ask non-strictly, since a tie makes either side of a max or min
an equally good answer, so a fact of x >= y is enough to pick a side.

Co-authored-by: Claude <noreply@anthropic.com>
Simplifying the condition of a can_prove predicate visits the operands
again, so a fact-driven rule that matches every node of its type recursed
without bound on nested min/max trees. Disable those rules while inside a
can_prove condition; the facts themselves are still substituted in at every
level.

Co-authored-by: Claude <noreply@anthropic.com>
Recursing further is occasionally useful in principle, but measurably
expensive: at a limit of 2, correctness_likely goes from 1.0s to 4.2s and
correctness_autodiff from 3.4s to 11.4s, with no test producing a better
simplification. Keep the limit at one level, but name the constant.

Co-authored-by: Claude <noreply@anthropic.com>
can_prove as a rewrite predicate recursively invokes the simplifier on every
expression matching the rule's left-hand side, so a rule whose left-hand side
also matches something built while proving the predicate recurses. It is also
simply expensive.

known_true instead looks the condition up in the facts directly. It cannot
recurse, and it is cheap enough to use on a rule that matches every node of
its type. The fact-driven max, min and division rules now use it, which is
enough for all of them: looking up a comparison already understands direction
and strictness.

Co-authored-by: Claude <noreply@anthropic.com>
mcourteaux and others added 5 commits August 27, 2026 15:49
The depth limit was checked in has_facts, which only protects rules that
consult it. Checking it on entry to the condition simplification instead
protects every can_prove, including the pre-existing rules and any future
one, and returning the condition unsimplified is the natural way to decline:
the predicate simply fails to prove anything.

That also frees has_facts to be a plain check, so the non-recursive
known_true rules can fire at any depth. The limit is raised to four, which
restricts nothing today: instrumenting every correctness test shows the
deepest can_prove nesting any of them reaches is one.

Co-authored-by: Claude <noreply@anthropic.com>
Refusing to simplify the condition past the depth limit meant the predicate
could never be proven there, even when the fact needed was already known.
substitute_facts is a plain tree walk (mutate_with over the generic
IRMutator base traversal) that never invokes a rewrite rule, so it cannot
re-trigger can_prove or known_true and stays safe at any depth: use it as
the fallback instead of returning the condition untouched.

Added a regression test built on the pre-existing can_prove-based min/max
subtraction cancellations in Simplify_Sub.cpp (the rules that motivated
the depth limit in the first place, since their predicate constructs a
fresh subtraction that can itself match the same rule). With the limit
disabled it hangs (confirmed: 15s timeout); with it in place it completes
in under a second.

Co-authored-by: Claude <noreply@anthropic.com>
The previous fallback ran substitute_facts, a full tree walk, on the
condition. But the only thing the caller checks is whether the result is
literally the constant true, and nothing runs afterward to fold a compound
expression: an And of two individually-known-true operands stays an
unfolded And, never becoming true. So substitute_facts's ability to resolve
facts about pieces of a compound condition was wasted work here — it can't
prove anything is_known_true on the condition itself couldn't already, since
folding that partial progress into a verdict is exactly the recursive work
the cap exists to avoid.

Co-authored-by: Claude <noreply@anthropic.com>
@mcourteaux
mcourteaux force-pushed the mcourteaux/aligned-split branch from 525990f to 1df274a Compare August 27, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New user-visible features or improvements to existing features. release_notes For changes that may warrant a note in README for official releases.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants