Skip to content

Profiler: Add counters and recompute statistics - #9164

Draft
abadams wants to merge 97 commits into
mainfrom
abadams/profile_recompute_with_counters
Draft

Profiler: Add counters and recompute statistics#9164
abadams wants to merge 97 commits into
mainfrom
abadams/profile_recompute_with_counters

Conversation

@abadams

@abadams abadams commented May 29, 2026

Copy link
Copy Markdown
Member

This is build on #9157 so ignore it until that one is in. Opening it for CI coverage. This adds the system of cheap counters. Output now looks like this:

--------------------------------------------------------------------------------------------------------
local_laplacian
 total time: 112.904640 ms  samples: 103  runs: 11  time per run: 10.264058 ms
 average threads used: 22.563107  parallel loops: 13  parallel tasks: 610
 heap allocations: 2409  peak heap usage: 101M
  name                   │ time     percent │ active│  parallel   │ heap │ peak │ avg  │recompute│
                         │                  │threads│ loops│ tasks│allocs│  mem │  mem │  ratio  │
  thread idle            │   3.55ms (34.6%) │ 17.45 │      │      │      │      │      │         │
  malloc                 │   0.00ms ( 0.0%) │       │      │      │      │      │      │         │
  free                   │   2.96ms (28.8%) │  1.00 │      │      │      │      │      │         │
  gray                   │   0.28ms ( 2.7%) │ 35.33 │    1 │   96 │    1 │   25M│   25M│    1.00 │
  remap                  │   0.00ms ( 0.0%) │       │      │      │    1 │   14K│   14K│    1.00 │
  gPyramid[1]            │   1.53ms (14.9%) │ 58.25 │    1 │  192 │    1 │   50M│   50M│    1.00 │
  inGPyramid[1]          │   0.18ms ( 1.8%) │ 25.00 │    1 │   48 │    1 │ 6271K│ 6271K│    1.00 │
  gPyramid[2]            │   0.48ms ( 4.7%) │ 55.59 │    1 │   96 │    1 │   13M│   13M│    1.00 │
  inGPyramid[2]          │   0.00ms ( 0.0%) │       │    1 │   24 │    1 │ 1563K│ 1563K│    1.00 │
  gPyramid[3]            │   0.19ms ( 1.9%) │  9.50 │    1 │   48 │    1 │ 3105K│ 3105K│    1.01 │
  inGPyramid[3]          │   0.00ms ( 0.0%) │       │    1 │   12 │    1 │  388K│  388K│    1.01 │
  gPyramid[4]            │   0.09ms ( 0.9%) │  8.00 │    1 │   24 │    1 │  766K│  766K│    1.02 │
  inGPyramid[4]          │   0.00ms ( 0.0%) │       │    1 │    6 │    1 │   96K│   96K│    1.02 │
  gPyramid[5]            │   0.00ms ( 0.0%) │       │    1 │    8 │    1 │  186K│  186K│    1.00 │
  inGPyramid[5]          │   0.00ms ( 0.0%) │       │      │      │    1 │   23K│   23K│    1.00 │
  gPyramid[6]            │   0.00ms ( 0.0%) │       │    1 │    8 │    1 │   44K│   44K│    1.00 │
  inGPyramid[6]          │   0.00ms ( 0.0%) │       │      │      │    1 │ 5520 │ 5520 │    1.00 │
  gPyramid[7]            │   0.00ms ( 0.0%) │       │    1 │    8 │    1 │ 9856 │ 9856 │    1.00 │
  inGPyramid[7]          │   0.00ms ( 0.0%) │       │      │      │    1 │ 1232 │ 1232 │    1.00 │
  outGPyramid[7]         │   0.00ms ( 0.0%) │       │      │      │    1 │ 1232 │ 1232 │    1.00 │
  outGPyramid[6]         │   0.00ms ( 0.0%) │       │      │      │    1 │ 4368 │ 4368 │    1.00 │
  outGPyramid[5]         │   0.00ms ( 0.0%) │       │      │      │    1 │   17K│   17K│    1.00 │
  output                 │   0.28ms ( 2.8%) │ 32.40 │    1 │   40 │      │      │      │    1.00 │
  ├outGPyramid[4]        │   0.00ms ( 0.0%) │       │      │      │   40 │   67K│ 1664 │   38.78 │
  ├outGPyramid[3]        │   0.19ms ( 1.8%) │ 26.00 │      │      │   40 │  128K│ 3200 │   13.44 │
  ├outGPyramid[2]        │   0.09ms ( 0.8%) │ 40.00 │      │      │   40 │  251K│ 6272 │    1.13 │
  ├outGPyramid[1]        │   0.09ms ( 0.9%) │ 40.00 │      │      │   40 │  497K│   12K│    1.06 │
  └outGPyramid[0]        │   0.29ms ( 2.8%) │ 34.00 │      │      │   40 │  246K│ 6144 │    1.00 │
--------------------------------------------------------------------------------------------------------

This lets you see that sliding window failed for outGPyramid 3 and 4. The recompute ratios are massive. Previously this was lost in the benchmarking noise.

abadams and others added 30 commits April 30, 2026 10:23
Adds per-instance counters (Realizations, points_required_at_realization /
_production / _root, points_computed) so the profile distinguishes
multiple appearances of the same Func and can diagnose recompute. Wires
the corresponding markers (declare_box_required_at_*, declare_inlined,
declare_stage) through ScheduleFunctions, BoundsInference, and Inline,
and consumes them in a new InjectCounters pass.

Adds GPU support: marker-billed counters track Funcs inside GPU kernels,
hoisting non-uniform contributions out via bounds_of_expr_in_scope (or
Let / Select / max as appropriate) and flagging the Func with
counters_approximated when it has to do so. Injects a halide_device_sync
at the end of every GPU kernel launch under -profile so kernel time
gets billed to the launching producer rather than the next blocking
host operation.

Splits per-Func time aggregation from counter aggregation: runs that
complete between two sampler ticks contribute to per-Func counters but
not to per-Func time, with a separate billed_runs field for the time
denominator.

Threads halide_copy_to_host/device synthetic instances into the timeline
view (parented to the producer they sit inside) instead of pulling them
into separate sections at the end, and counts each copy invocation via
a new halide_profiler_count_host_device_copy runtime helper.

Adds report rules:
  - mid-Func host<->device bouncing (forgotten device schedule on an
    update def)
  - counters_approximated note (per-Func) and unaccounted-runs
    summary (per-pipeline)
  - sliding-window-failure / RoundUp / GuardWithIf via the
    realization/production/root/computed counters

Adds test/generator/profiler_instances_{generator,aottest}.cpp covering
the per-instance machinery, recompute counters, the impure-condition
approximation path, and host/device copy synthetics (the GPU pieces are
gated on get_target().has_gpu_feature()).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds halide_profiler_func_kind (func / overhead / thread_idle / malloc /
free / copy_to_host / copy_to_device) and a buffer_func_id field on
halide_profiler_func_stats. The runtime gets the kind/buffer_func_id
arrays through halide_profiler_instance_start alongside the existing
names/parents/canonical_ids.

Replaces three sites that previously did the equivalent work by parsing
names or hardcoding bookkeeping slot indices:
  - Filtering empty bookkeeping rows from the table (used `i < 4` index
    checks)
  - Skipping bookkeeping and copy synthetics in the rules loop (used
    `idx < 4` plus strstr for the copy-name suffix)
  - The "stages computing on different devices" rule looking for both
    directions of copy of a given Func's buffer (used name-prefix match)

The expensive_free pipeline-level check now looks up the free slot by
kind rather than p->funcs[3]. JSON dump now emits kind, buffer_func_id,
and canonical_id. The dead suffix_cut argument on print_func_row /
emit_name (left over from the section-header refactor) is gone.

The IR-side *_id constants in Profiling.cpp stay (the bookkeeping slot
indices still matter for stack tracking and the set_current_func
emission) — they're no longer how the report identifies the slots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
halide_profiler_instance_state already uses "instance" to mean
one in-flight pipeline invocation; in src/Profiling.cpp the same word was
also doing duty for "one row in the per-Func stats array" (one
appearance of a Func in the schedule, distinguishing repeated
inlining sites or separately-realized update defs). The two senses are
unrelated and the collision was confusing.

Renames the per-row sense to "entry" throughout Profiling.cpp and the
test files:

  IdInfo            -> EntryInfo
  id_info           -> entry_info
  id_for_instance   -> id_for_entry
  instance_map      -> entry_map
  instances_by_name -> entries_by_name
  approximated_instances -> approximated_entries
  resolve_instance_id    -> resolve_entry_id
  PreAllocateInstances   -> PreAllocateEntries
  get_func_instance_id   -> get_func_entry_id

Test helper instances_of -> entries_of, plus the per-row scenario
assertions and comments. The runtime API (halide_profiler_instance_*,
the `instance` variable referring to halide_profiler_instance_state)
keeps the "instance" word — it's where it belongs. The test pipeline's
filename and registered Generator name stay (those drive the AOT build
path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes a "Clearing func stats" debug-level-0 print left over from
earlier development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e_box

declare_box_touched is a real annotation that bounds inference's
box_touched analysis follows: its first arg has to be a
Variable<Handle>(func.name()) so passes that substitute on names of
in-scope buffers transform it correctly. The new
declare_box_required_at_{realization,production,root} intrinsics are
profiler-only markers whose first arg is just a label for the report
and must not be confused with an in-scope reference (so it's a
StringImm).

The declare_box helper in ScheduleFunctions.cpp now picks the right
shape for the first arg based on the intrinsic. Restores the
extern-stage handling (correctness/extern_producer,
correctness/extern_output_expansion,
generator_aot_nested_externs_root/_inner) that regressed when the
refactor uniformly switched all declare_box_* intrinsics to StringImm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d loops

The previous version widened *every* impure Call in a vectorized loop,
which broadcast halide_trace_helper / make_struct calls to vector
types and produced LLVM-level signature mismatches (the assertion in
CallInst::init complaining about a bad signature) — for instance, the
vectorize_inlined subtest of correctness/compute_with would assert
during codegen.

The widening was only ever needed for the profiler's counter markers,
which encode per-lane counter contributions in the intrinsic's
type.lanes() and so have to be widened to match the surrounding loop's
lane count even when their args don't reference any vectorized var.
Restrict the special case to those:
  - declare_inlined (bills InlinedCalls per lane)
  - declare_box_required_at_realization / _production / _root
    (bills points_required_at_* per lane via box_total)

inline_marker is gone after resolve_inline_markers, and declare_stage
is idempotent (no lane-count dependence), so neither needs the widening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolve_inline_markers walks the IR looking for inline_markers that
need to be replaced with declare_inlined intrinsics. The expectation
was that every chain of markers sits inside some Provide (the
production being billed), but extern stages can have markers in their
call args (e.g. nested_externs_root reads an Input scalar Func through
inline_marker as part of the args to halide_copy_to_device /
nested_externs_inner) — those have no surrounding Provide.

BuildInlineGraph already strips the inline_marker intrinsics during
its walk, so the rewritten Stmt is well-formed. The previous code
asserted on the missing Provide name; now we just return the rewritten
Stmt without emitting a declare_inlined. The inlined work still
happens; the profiler simply doesn't bill it to an entry, which is
the right call for extern-stage arg evaluation anyway.

Fixes generator_aot_nested_externs_root and _inner under
HL_TARGET=host-profile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline.cpp wraps every inlined call site in an inline_marker so the
profiler can later stamp down a declare_inlined for its surrounding
Provide. Extern stages don't have a surrounding Provide — their
ScheduleFunctions-emitted IR is a LetStmt whose value is the extern
call. Without an anchor, resolve_inline_markers asserted when
inline_markers appeared in extern call args (which happens whenever a
Func is inlined into an extern stage's scalar args).

Wrap each extern call's value in a new pure intrinsic
extern_stage_marker(name, value) under -profile. BuildInlineGraph
recognizes the wrapper and uses the extern stage's name as the
billing target for any inline_markers inside it, exactly as it would
for a Provide. resolve_inline_markers gains an Evaluate handler so a
top-level Evaluate of an inline_marker-bearing expression is treated
as its own subtree (mirroring the Provide and LetStmt cases). The
inline_markers may live in any combination of CSE-hoisted LetStmts
above the extern call or directly in its args; both paths now route
through process_inlining_subtree.

Test case in profiler_instances inlines a Func into an extern stage's
scalar arg and asserts that the inlined entry's parent in the report
is the extern stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BoundsInference Inliner had keep_inlined_calls=true under -profile,
wrapping each inlined call site in return_second(call, body) so
boxes_required could still see the original call when recording the box
required for the inlined Func. The wrapper served as a no-op at runtime
(return_second discards the first arg), but the wrapped Halide call
referenced a Func with no buffer or producer — once storage flattening
turned it into a load, codegen would fail with "Name not in Scope".

The leak path wasn't limited to extern bounds-query args: any time
boxes_required walked an expression produced by the Inliner, the args
of the original Halide calls could end up in the resulting Box's min/max
intervals (e.g. f(f(x)) with f inlined → box of outer f contains the
inlined-form of inner f(x), wrappers and all), and those intervals get
baked into the `.s0.x.min` / `.s0.x.max` LetStmts that flow into
runtime IR.

Switch the wrapper to inline_marker(call, body) — same dual-role
semantics (boxes_required sees the call; bounds-of-expr returns body),
but typed for our purpose. Strip every inline_marker unconditionally
in a single pass right after BoundsInference finishes; by then the
marker has done its job and the inlined Func has no codegen role.
Teach the bounds-of-expr rule in Bounds.cpp to treat inline_marker
like return_second (collapsed into the same is_intrinsic({...}) check
alongside if_then_else, which already shared the rule).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CSE-hoisted Lets inside a Provide can chain such that each let's RHS
references the previous let's variable. Walking such a chain, the
Variable visitor splices the referenced let's roots into the current
let's roots. With a vector container, multiple Variable uses of the
same prior let duplicate that let's entire roots vector each time, so
chains of "let tk = t_{k-1}*t_{k-1} + t_{k-1}" snowball as 3^N. This
exhausted RAM on a real lookup-table-heavy pipeline.

Switch the let_roots containers to std::set<int>, which is the right
shape anyway — the roots of a let are a set of inlining-graph node ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under -profile every inlined Func gets a per-stage entry in the
BoundsInference stages list so its recompute counters can be tracked.
For a chain of N inlined Funcs with non-trivial bounds two distinct
quadratic costs appeared:

1) The construction of N declare_box_required_at_root intrinsics each
   carried a copy of the bounds chain in its args (no sharing across
   declarations). For a chain of length N each declaration's
   expressions had size O(N-k), so total IR text was O(N^2) — exposing
   any downstream pass (and HL_DEBUG_CODEGEN=2 printing) to O(N^2)
   work even though the underlying Expr DAG was O(N) via refcount.

2) The boxes_required walk ran once per stage in the inference loop.
   For inlined consumers in a chain each walk visited an O(N)-sized
   shared suffix of the inlined IR, accumulating to O(N^2) total
   construction work in BoundsInference itself. Notably the `boxes`
   map produced for inlined consumers was immediately discarded —
   there's a `continue` before the "expand to producers" loop, so
   the per-stage walk was pure waste.

Two changes:

- Stage::define_bounds for inlined Funcs no longer emits the full
  declare_box_required_at_root in place. Instead it stashes the box in
  a side map keyed by Func name and stamps a single-arg marker (the
  intrinsic with just the StringImm name) at the same scope. A
  post-pass RewriteDeferredRootBoxMarkers walks the resulting Stmt,
  finds runs of consecutive markers in each Block, joint-CSEs the
  corresponding box expressions, peels outer Lets into LetStmts that
  wrap the block of declarations, and rewrites each marker in place to
  a full declaration referencing the lifted let-bound subexpressions.
  Net effect: O(N) IR text instead of O(N^2), and the joint CSE itself
  runs on the (linear) shared DAG.

- In the main BoundsInference relationship-computing loop, move the
  `if (consumer.inlined) continue;` check above the per-stage
  boxes_required block. Inlined consumers' producer bounds are picked
  up transitively through the outermost (non-inlined) consumer's walk
  (inline_marker's args[0] carries the original Halide call), so the
  per-inlined-consumer walk is redundant. Skipping it removes the
  O(N) work-per-stage that summed to O(N^2) over the chain.

Together these bring a chain of N=512 inlined Funcs with select-based
bounds from 653 s under -profile to 14 s, and N=128 from 3.6 s to 0.9 s.
The remaining time is mostly LLVM codegen on legitimately N-sized IR
and isn't profile-specific.

Also filter profiler_instances.rungen out of GENERATOR_BUILD_RUNGEN_TESTS
since its aottest provides a test_extern_stage callback that rungen
doesn't link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more places in BoundsInference were doing O(N) work per stage and
summing to O(N^2) across a chain of N inlined Funcs:

- The pure-inlining loop ran inliner.do_inlining() on every stage's
  exprs, even inlined stages whose exprs are never consulted (the
  relationship-computing loop already skips them via continue). Each
  call does mutate() + common_subexpression_elimination(), and the
  CSE alone is O(unique nodes) per call. Skip inlined stages here.

- Inliner::get_qualified_body, called recursively as the chain
  expands, ran CSE on the result at every level. The recursion shape
  means the level-k cache entry includes the fully-inlined chain from
  level k+1 downward; running CSE at each level walks the same shared
  sub-DAG over and over. Defer CSE to the public do_inlining entry
  point — once at the top of the recursion is sufficient. mutate()
  still recurses through visit(Call) into get_qualified_body for
  each inlined sub-call, so the chain still resolves.

Combined with the existing marker-deferral and the consumer.inlined
skip in the relationship loop, this brings the N=512 chain test from
653 s (pre-fixes) to 10 s under -profile. Some residual super-linear
cost remains in the joint-CSE-on-bundle marker rewrite — CSE's
use_map uses IRGraphDeepCompare which is O(structure size) per
comparison, so deep chains still pay there — but it's tolerable now
(~22% of compile time at N=512). That's a separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Describe what the code does without forensic notes about alternatives
or scaling reasoning. The latter belongs in commit messages and PR
descriptions, not the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In SkipStages::visit(Select), the two branches' per-Func .used / .loaded
predicates were combined as `(t_used && cond) || (f_used && !cond)`. When
both branches contributed the same Expr -- which is exactly what
happens when both branches read the same let-stashed FuncInfo from an
outer let -- make_or could not recognise the And nodes as equivalent
(they aren't same_as even when their operands are), so the predicate
roughly doubled in size at every nested Select. A long chain of CSE'd
lets where each let value contains a Select then drove the predicate
size to 2^N, well past the point where allocating the IR is feasible.

Combine the two branches with `select(cond, t, f)` instead, and add a
make_select helper that collapses `select(c, X, X) -> X` and the
constant-cond cases. When both branches contributed the same Expr,
make_select drops the condition immediately and the chain stays linear.

The new correctness test (many_inlined_selects.cpp) constructs a 500-
element CSE'd let chain whose values each carry a Param<bool>-gated
Select, then feeds the chain into a final Select. With the bug present
this test would not terminate -- skip_stages would crash allocating
~2^500 IR nodes long before any reasonable timeout fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an id is only touched on one branch of the Select, the previous
code passed an undefined Expr to a `combine` helper that then turned
`undefined` into const_false and built a `select(cond, X, false)` --
which is just `X && cond` dressed up as a select. Call make_and directly
in those cases and keep make_select for the both-branches case, where
the `select(c, X, X) -> X` collapse is the whole point. Also factor the
"merge into old" body into a small helper to remove the duplication.

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lowercase the Func name, drop the unnecessary top-level select and
output schedule, and make each chain entry depend on chain.back() so
nothing gets eliminated as dead. The test still reproduces the pre-fix
exponential blow-up (verified by reverting the fix: it times out at
30s on a 500-element chain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bounds inference for inlined Funcs can produce root-box bounds whose
arithmetic doesn't fit in int32 -- e.g. an index expression of shape
(c1 - c2) * c3 over a wide interval, where simplify materialises a
signed_integer_overflow intrinsic for the offending product. Those
markers feed the recompute-ratio report and are nice-to-have only;
letting them reach codegen turns into a user_error and breaks the
whole compile for what is otherwise a profiling-only stat.

Add a small pre-pass in inject_profiling that walks the IR with a
Scope of "poisoned" let-binding names (a binding is poisoned if its
value transitively contains a signed_integer_overflow intrinsic or
a reference to another poisoned binding) and rewrites any
declare_box_required_at_root whose args touch the poison set to
make_zero. The subsequent simplify() in lower then drops the now-
dead lets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an inlined Func whose bounds-inferred interval doesn't fit in
int32 -- shape (uint16 - uint16) * c -- and feeds it through a buffer
index. Without the poison-drop pre-pass in inject_profiling the
generator user_errors during codegen; with it the test compiles and
the wide_scaled Func still shows up in the profiler report as an
inlined entry (we just lose the root-box count for table16, which is
the bug we worked around).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
abadams and others added 7 commits May 28, 2026 13:14
CI flagged them as unused after the earlier check pruning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two padding issues that caused bus errors on 32-bit builds:

- kind was a uint8_t followed by an int (buffer_func_id), which inserts
  3 bytes of implicit padding. Use the enum type itself (an int) so the
  field is exactly 4 bytes with no implicit padding.
- With a 4-byte pointer at the start (name), the uint64_t counter region
  would land at a 4-aligned but not 8-aligned offset on 32-bit x86 (the
  i386 ABI allows uint64_t to be 4-aligned). Atomic 64-bit ops on
  cmpxchg8b require 8-byte alignment, so this would bus-error. Move the
  non-counter fields (parent, canonical_id, kind, buffer_func_id) to
  precede `time`, and mark `time` explicitly with HALIDE_ATTRIBUTE_ALIGN(8)
  so the compiler always inserts the needed padding regardless of target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
find_or_create_pipeline took const uint64_t *func_names and indexed it
with an 8-byte stride. Halide's IR-side make_struct of the per-Func
Handle pointers codegens as [N x ptr], so on 32-bit each entry is only
4 bytes — the runtime would read every other entry as garbage past the
first half of the array. Main avoided this by using
Allocate(Handle(), {num_funcs}) + Stores (Halide's Handle type is fixed
at 64 bits regardless of target), but this branch uses make_struct.

Change the runtime signature to const char *const *func_names so the
stride matches the actual pointer width emitted by make_struct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Allocation-group buffers (created by FuseGPUThreadLoops when it fuses
shared/heap allocations) have names that don't match any Func, so the
previous code skipped tracking them — the runtime would have aborted on
func_id == -1, and even if it didn't, the bytes would have been dropped
on the floor.

Mint an allocation-kind entry for those buffers and bill the
memory_allocate/memory_free calls to it. Render the row as the
participating Funcs (e.g. "f1$0.0,f2$0.1.buffer") by splitting on the
"allocgroup__" tag and joining with commas.

The rendering is a placeholder pending a follow-up PR that splits the
size across the participants via the counter machinery; the TODO in
id_for_entry sketches the plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move per-Func num_allocs and memory_total accounting from
halide_profiler_memory_allocate into halide_profiler_update_counters,
so the updates can hoist out of inner loops. For GPU-fused allocations
that have no Allocate node left, emit a declare_allocation intrinsic
at the strip site in FuseGPUThreadLoops so the counters mutator can
still bill the per-Func contribution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@abadams
abadams marked this pull request as draft May 29, 2026 18:54
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.06186% with 89 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.29%. Comparing base (f59c387) to head (8c26a5e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/Profiling.cpp 76.23% 52 Missing and 30 partials ⚠️
src/VectorizeLoops.cpp 0.00% 3 Missing and 1 partial ⚠️
src/InjectHostDevBufferCopies.cpp 92.85% 0 Missing and 1 partial ⚠️
src/Lower.cpp 80.00% 0 Missing and 1 partial ⚠️
src/StorageFolding.cpp 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9164      +/-   ##
==========================================
+ Coverage   70.21%   70.29%   +0.08%     
==========================================
  Files         255      255              
  Lines       78895    79242     +347     
  Branches    18865    18956      +91     
==========================================
+ Hits        55397    55705     +308     
- Misses      17832    17888      +56     
+ Partials     5666     5649      -17     

☔ 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

abadams and others added 20 commits May 29, 2026 15:23
fuse_gpu_thread_loops still hoists shared, heap, and register
allocations and normalizes GPU loop structure, and still computes the
lifetime/offset fusion analysis where the barrier structure is intact.
But instead of erasing the per-Func allocations and rewriting their
loads and stores onto the fused buffer, it now emits a backing
allocation plus one aliasing Allocate per Func, whose new_expr is a new
offset_pointer(backing, offset) intrinsic. Loads and stores keep their
original per-Func names.

A new flatten pass in inject_gpu_offload folds each aliasing
allocation's offset back into its loads and stores and drops the
aliasing nodes, reproducing the flat representation the device backends
expect. It runs after the conceptual stmt is captured, so the profiler
and the conceptual stmt see per-Func allocation names and stores can be
attributed to a specific Func.

Supporting fixes:
- offset_pointer is a non-pure Intrinsic so CSE/LICM won't lift it out
  of new_expr and escape the backing allocation's scope.
- RemoveDeadAllocations traverses new_expr, so a backing allocation
  referenced only through aliasing new_exprs isn't dropped as dead.
- PartitionLoops no longer moves a let inside an Allocate when the let
  is used in the allocation's new_expr or condition (evaluated in the
  outer scope), which previously left a dangling reference for
  dynamically-sized allocations.
- The flatten pass re-derives load/store alignment from the offset,
  rather than keeping the original alignment across a possibly
  misaligned shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LowerWarpShuffles treated any non-shared allocation above a lane loop as
per-lane register storage, striping its size by the warp size and moving
the Allocate inside the lane loop. With GPU allocation fusing now
emitting per-Func aliasing allocations, a heap allocation used inside a
gpu_lanes loop (e.g. apps/iir_blur) reaches this pass as a real Allocate
node and was wrongly striped, which also separated it from its Free and
left a dangling reference. Heap is global memory and is never warp-level
storage, so skip it alongside GPUShared. On main no heap Allocate nodes
reach this pass, so this is a no-op there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-Func names now live on the aliasing allocations, so the backing
allocation no longer needs a name concatenating every fused Func. Name
it unique_name("shared_alloc") or unique_name("global_alloc") instead of
the "allocgroup__f1__f2__..." monster name. The single-shared-allocation
case still names the backing after its Func, since there are no aliasing
wrappers there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ions' into abadams/profile_recompute_with_counters
The block-level shared/heap and register allocation extractors in
fuse_gpu_thread_loops previously disambiguated same-named allocations
(e.g. from unrolling a loop that holds an allocation) by suffixing each
with a counter. That suffix looks like a tuple index and obscures the
Func name the profiler needs for attribution.

Instead, keep each Func's own name and coalesce same-named allocations
with disjoint lifetimes into one allocation sized to the largest, reusing
the storage. Since loads and stores keep their names, the shared/heap
extractor's load/store visitors now only track liveness (plus the
per-thread index striping for allocations inside thread loops), and the
register extractor's load/store visitors are gone entirely. Both
extractors coalesce via an operator() override that runs immediately
after the mutation.

Also drops now-dead code: the AllocGroup::name accumulation, the
write-only register_allocations scope, and a redundant local init.

Adds correctness coverage for the repeated-realization case in both
shared/heap and register memory using tuple-valued Funcs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ions' into abadams/profile_recompute_with_counters
- Profiling.cpp: sum each counter over a closing-out GPU loop symbolically
  instead of collapsing to max-value then multiplying by the full extent.
  For a footprint-guarded contribution like select(guard, k, 0) we clip the
  iteration count to solve_for_outer_interval(guard) (an over-approximation,
  so still a conservative upper bound), which stops the GPU recompute ratios
  from being grossly inflated (e.g. unsharp's gray drops from 3.98 to 2.36).

- Simplify_LT.cpp: move the c0 < select(x, c1, c2) / select(x, c1, c2) < c0
  folds out of the no_overflow block. They do no arithmetic, so they are
  valid and terminating for every type; the counter code above needs them
  for its unsigned (UInt64) counters. Covered by a new simplify test.

- profiler_common.cpp: emit hoist_storage allocation entries before the
  producer entries at each level of the report tree, so a Func's allocation
  row appears ahead of the computation it feeds (matching IR order, which
  compute_with otherwise inverts). Allocation entries are leaves, so the
  tree-art stays correct.

- InjectHostDevBufferCopies.cpp: fix a compiler segfault when a
  0-dimensional device-only allocation is compiled with the profiler on -
  the device-size marker read op->extents[0] out of bounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants