Add change_type() directive with reduction-aware overflow checks - #9257
Conversation
75752de to
6f0a76e
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #9257 +/- ##
==========================================
- Coverage 70.06% 69.79% -0.28%
==========================================
Files 258 259 +1
Lines 78702 79163 +461
Branches 19160 19310 +150
==========================================
+ Hits 55143 55250 +107
- Misses 17871 17968 +97
- Partials 5688 5945 +257 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
f352290 to
0fdc17c
Compare
53d147e to
085bf96
Compare
085bf96 to
9458298
Compare
5376049 to
daf9049
Compare
| // guaranteed not to overflow. Safety is monotonic in the term count, so use | ||
| // ConstantInterval's overflow-aware arithmetic in a binary search rather than | ||
| // duplicating its endpoint math here. | ||
| int64_t maximum_safe_term_count(const ConstantInterval &accumulator, |
There was a problem hiding this comment.
Is this just (limit.min - accumulator.max) / step.max ?
There was a problem hiding this comment.
Not quite — accumulator, step, and limit are all general intervals here, not single points, and step's sign isn't fixed (a difference reduction negates the term, and even a single call's term range can straddle zero). That means the binding constraint can be the upper limit, the lower limit, or both, and a single division can't combine both endpoints' worst cases safely — it also breaks if step's interval contains zero. Rather than hand-deriving and separately overflow-hardening the endpoint math for every sign/zero combination, the binary search just reuses ConstantInterval's already-audited overflow-safe +/*, so limit.contains(accumulator + step * ConstantInterval(0, n)) is trivially correct for any sign combination. Added a comment (now on maximum_safe_term_count) explaining this.
There was a problem hiding this comment.
^ Quoting Claude here and elsewhere.
There was a problem hiding this comment.
Maybe ((limit - accumulator)/step).min then?
There was a problem hiding this comment.
Maybe ((limit - accumulator)/step).min then?
No. Here's a concrete input that deviates:
Condition failed: proposed == min_safe
Error: maximum_safe_term_count() binary search disagrees with the closed-form bound:
proposed = -2147483648
min_safe = 131071
--- inputs ---
accumulator = [0, 0]
step = [-16256, 16384]
limit = [-2147483648, 2147483647]
Is this just (limit.min - accumulator.max) / step.max ?
No. Here's a concrete input that deviates:
Condition failed: proposed == min_safe
Error: maximum_safe_term_count() binary search disagrees with the closed-form bound:
proposed = -131072
min_safe = 131071
--- inputs ---
accumulator = [0, 0]
step = [-16256, 16384]
limit = [-2147483648, 2147483647]
daf9049 to
4e4989d
Compare
4e4989d to
4237483
Compare
4237483 to
db7edbe
Compare
db7edbe to
fc7e2ad
Compare
| // Prove that the first update executes at least once for every pure coordinate, | ||
| // as required when translating an identity that does not round-trip through the | ||
| // target type. Symbolic extents produce a runtime precondition. | ||
| std::optional<std::string> nonempty_dense_update_precondition(const Function &fn, |
There was a problem hiding this comment.
For example, consider a min-histogram, in which you want to know the min value associated with each bucket instead of a count of the values that fall into each bucket. E.g. for every pixel in an image, for each possible value or r, what is the smallest value of g? Computed as float, you would initialize the histogram to inf. You might want to retype this to int if the values you are considering are ints. But as an int, there's no way to distinguish between "this bucket was never touched", and "the min value associated with this bucket was some sentinel integer (e.g. 255)". So we must add as a predicate that we know every bucket was touched.
| if (*ext <= 0) { | ||
| return "the first update has an empty reduction domain"; | ||
| } | ||
| } else if (optional<uint64_t> ext = as_const_uint(extent)) { |
There was a problem hiding this comment.
I didn't think reduction domain bounds could be uints
| // below; every other combiner can grow it faster than a single term's | ||
| // range in a way we don't model -- a product reduction most importantly, | ||
| // or an unrecognized shape -- so reject it rather than silently overflow. | ||
| if (!op || (*op != IRNodeType::Add && *op != IRNodeType::Sub)) { |
There was a problem hiding this comment.
Maybe add a TODO for saturating add. I'm thinking we want roughly the same list as the VectorReduce op enum, and I think saturating add is the only thing missing.
| } | ||
|
|
||
| void Function::clear_definition() { | ||
| contents->output_types.clear(); |
There was a problem hiding this comment.
Consider *contents = FunctionContents{}
There was a problem hiding this comment.
(So that new members of FunctionContents aren't missed)
There was a problem hiding this comment.
I think that new members need consideration in either direction, but I implemented it as a "what to keep" rather than "what to clear".
| Expr make_binary_op(IRNodeType t, const Expr &a, const Expr &b) { | ||
| switch (t) { | ||
| case IRNodeType::Add: | ||
| return a + b; |
There was a problem hiding this comment.
I think these should probably use the ::make methods to avoid all the type coercion logic. Or might you want type coercion somewhere here?
|
|
||
| /** Build a binary expression of node type `t` from operands `a` and `b`, using | ||
| * the corresponding operator overload (so the usual type matching and constant | ||
| * folding apply). `t` must be a binary operator; it is an internal error otherwise. */ |
There was a problem hiding this comment.
I don't think there's any constant folding (so at least fix the comment if you still want type coercion)
|
I think this needs a fuzzer but it could be a follow-up PR. This is a new feature so we're not going to break anyone if there's a subtle bug in it. |
be4b51f to
70a3120
Compare
Add Func::change_type(Type, unsafe), which changes the type at which a Func computes and stores its values. It works eagerly at schedule time by splitting the Func in two: a returned intermediate that copies the Func's definitions but accumulates at the new type (inserting casts, preferring integer forms like widening_mul over float round-trips), and the original Func, rewritten in place into an inline wrapper that casts the intermediate's result back to the original type so every existing consumer is unaffected. Safety is validated with the bounds machinery: for an integer target, change_type() bounds the accumulator by combining the per-term value range (constant_integer_bounds augmented by FuncValueBounds) with the reduction extent. Statically-safe cases pass silently; a case provable only under a runtime precondition (symbolic RDom extent) records that condition, which a new lowering pass (add_type_change_checks, modeled on add_split_factor_checks) injects into the pipeline's assertion block and no_asserts strips. Otherwise change_type() errors unless unsafe=true. Supporting changes: Function::clear_definition() to redefine a Func in place as the wrapper; FuncSchedule carries the injected type_change_checks; get_associative_identity() for retyped reduction identities; StrictifyFloat treats int<->float casts as strict so change_type() won't strip a user's strict_cast. Adds as_binary_operands()/make_binary_op() and select_binary_operand() as reusable binary-operator helpers, placed early in Func.cpp (alongside project_rdom()) so hoist_invariants() can reuse all three without redefining them. Adds a Python binding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
change_type_prove_safe() was only invoked for integer targets, so retyping a reduction to a float type got no safety check at all. Run it for float targets too, bounding the accumulation against the largest integer the target can represent exactly (e.g. 2048 for float16) rather than its full dynamic range. Also fixes bounds_of() to recover the exact integer value of a leaf that retype_leaf() constant-folded directly into a float literal (e.g. a seed of 0), which it previously treated as unbounded. Adds test coverage for float targets, and for sum-then-clamp, sum-scan, and histogram reductions confirming the existing extent-based bound is conservative for those shapes too.
…_integer_bounds Replace the eager cache_call_bounds() prewarming pass with an optional FuncValueBounds parameter on constant_integer_bounds() and lossless_cast(), consulted lazily when either function hits a Call::Halide node. Also add a codegen test verifying that a change_type() retype from float to Int(32) reaches CodeGen_ARM's dot-product instruction selection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
70a3120 to
9aff833
Compare
Adds a new
.change_type()directive to Halide that takes a typeSand replaces a funcFof typeTwith:Where
e' : Sis a type-adjusted version ofe : Tsuch thate = cast(T, e').We use
FuncValueBoundsandlossless_castto ensure that the replaced type will not perturb values in the pipeline. The directive is aware of reductions and will add runtime asserts to ensure that accumulations are not too long if the extent cannot be proven sufficiently short.When constructing
e',change_typemight adjust the form to use widening intrinsics. For instance, the result of changing the type off32(i8a) * f32(i8b)to Int32 would becast<int32>(widening_mul(i8a, i8b)), which is bitwise exact.A prototype of this was written over several sessions involving several LLMs. I probably wrote as much prompt as code was produced, and I feel that the ultimate implementation is more mine than any LLM's. The tests were written by machine outright, however.
Breaking changes
None: this is a new directive.
Checklist
Stack created with GitHub Stacks CLI • Give Feedback 💬