Skip to content

[Optimization] Make CSE per offload rather than whole kernel#790

Draft
hughperkins wants to merge 21 commits into
mainfrom
cse/fix-licm-cache
Draft

[Optimization] Make CSE per offload rather than whole kernel#790
hughperkins wants to merge 21 commits into
mainfrom
cse/fix-licm-cache

Conversation

@hughperkins

Copy link
Copy Markdown
Collaborator

Issue: #

Brief Summary

copilot:summary

Walkthrough

copilot:walkthrough

…M cache

Run CSE per offloaded task, but only once each task is isolated in the codegen
worker pool: the post-offload full_simplify passes (offload_to_executable's
before_lower_access / simplify_IV / scalarize) execute inside compile_task,
which is enqueued per task to the worker pool, so per_task_cse fires there (one
task per worker, inside the simplify fixpoint = full quality, in parallel). CSE
is skipped on the pre-split monolith (the block still holds every task) and
deferred to those parallel per-task passes. Because tasks are optimized
independently, doing all CSE in the workers is value-identical to the serial
variant -- just parallel. cache_loop_invariant_global_vars defaulted off for the
same soundness reason as the serial variant.
…stale break-flag)

cache_loop_invariant_global_vars previously keyed its per-loop cache by GlobalPtrStmt* identity. With per-task CSE
(post-offload) the read and write GlobalPtrStmts to the same address (e.g. the solver's improved[i_b] break flag) are
distinct statements, so pointer-identity keying allocated separate locals -> in-loop reads never saw in-loop writes
-> stale break flag -> solver ran to the iteration cap (the -88% per-offload-CSE regression). find_cache_entry now
also matches an existing entry by definitely_same_address, so read+write share one local. Re-enables the pass
(compile_config default true) which is load-bearing on contact-heavy solves (duck_in_box). QD_LICM_LOG=1 prints
per-access cache decisions for diagnosis.
…able (conditional) accesses

QD_LICM_LOG on anymal_zero showed address-keying alone is insufficient: the pass is unsound whenever a snode
has an access that is cache-eligible (offload-unique, static index, non-atomic) yet not cacheable at its own
site - typically a store inside an if-block that LICM did not hoist (move_loop_invariant_outside_if off). That
store bypasses the cached loop-invariant local while reads still read the local -> stale (the solver break-flag
/ -88% regression). fix_addr could never help because such stores never reach cache_global_to_local.

Phase 1 traverses each task and marks any such snode UNSAFE; phase 2 caches as before but skips UNSAFE snodes,
leaving all their accesses on global (always correct). Same-depth read/write pairs still unify via the
address-aware find_cache_entry, so the pass keeps its optimization on the clean (contact-heavy/duck) snodes.
…lobal_ptrs)

WholeKernelCSE gains a ptrs_only mode that eliminates only Global/External/MatrixPtr statements (no compute CSE,
no if-branch hoisting). Exposed as irpass::merge_global_ptrs and called in compile_to_offloads right before the
first flag_access. This restores the one precondition cache_loop_invariant_global_vars needs on contact-heavy
scenes: each global's read and write pointers become a single shared activate=true pointer, so conditional/in-if
stores become hoistable/cacheable (recovering the duck_in_box optimization) - while the expensive whole-kernel
compute dedup stays deferred to per-task CSE. QD_NO_PTR_MERGE=1 disables for A/B.
…E log

Pointers-only mode now also eliminates pure integer (index) compute, not just Global/External/MatrixPtr. Two
same-address pointers only satisfy definitely_same_address once their index arithmetic collapses to a common base
statement (value_diff_ptr_index/FindDirectValueBaseAndOffset), so merging pointers alone was a near no-op. Loads
and stores remain non-eliminable so soundness is unchanged; float compute stays deferred to per-task CSE.
QD_LICM_LOG=1 prints [PTRMERGE] round/modified counts.
Pre-offload the pass found nothing (PTRMERGE modified=0): offload is what clones each global's address computation
into per-task read/write GlobalPtrStmts. Run the merge on the offloaded IR right before flag_access #2 so the
still-activate=true duplicates unify into one shared pointer before flag_access can stamp the read-only copy
activate=false. This is the split that blocks cache_loop_invariant_global_vars.
…up), drop standalone

Evidence (solve_monolith IR): main's whole_kernel_cse runs in full_simplify's fixpoint at simplify_I, before the
first flag_access, deduping read/write pointers so flag_access never splits them (after_offload: 1 split). The
per-task branch did no CSE pre-offload, so flag_access split ~16 addresses; a single standalone post-offload merge
could not undo it (activate=false read dominates; CSE won't merge a later activate=true write into it) - only
16->13. Running the cheap pointer+integer merge in the fixpoint (like main) fires at simplify_I before the split.
Removed the ineffective standalone post-offload call.
Now that merge_global_ptrs unifies read/write pointers in the full_simplify fixpoint (splits 16->1 like main), the
two-phase UNSAFE exclusion is a redundant workaround that also disables caching for contact-heavy (duck) fields ->
suspected cause of the residual duck regression despite splits being fixed. Toggle to A/B.
…the root-cause fix)

Bench confirms merge_global_ptrs (fixpoint) alone fixes both problems: anymal_zero at full baseline (was -88%) AND
duck_in_box within noise of main (+0.5% / -1.0% / +1.6% / -2.0%, vs -8..-13% with the exclusion on). The two-phase
UNSAFE exclusion recovered anymal but kept duck at pass-off, so it is now off by default. QD_LICM_EXCLUDE=1 re-enables
it as a safety valve.
…ssion)

The pass is only load-bearing pre-offload (unify read/write pointers before the first flag_access). Post-offload,
per_task_cse already merges pointers within each task, so running merge_global_ptrs in every post-offload
full_simplify fixpoint iteration (per task, in the codegen workers) was pure redundant work -> ~3.5% compile
regression across the suite. Now skips as soon as any top-level OffloadedStmt is present. Pre-offload behavior
(the correctness fix) is unchanged.
…plify fixpoint

The pre-offload pointer merge only needs to happen ONCE before the first flag_access (compile_to_offloads),
to unify a global's read/write pointers before flag_access stamps the read-only copy activate=false. Running a
whole-kernel pointer CSE in every fixpoint iteration of every pre-offload phase (simplify_I/II + autodiff) was a
+12-22s compile regression on collision-heavy scenes (franka/duck/box_pyramid; anymal ~0) for zero extra benefit
-- same-build A/B: franka_random default 120.8s vs no_merge 108.5s. Now a single irpass::merge_global_ptrs(ir)
right before flag_access #1; arithmetic is already canonical after simplify_I so one pass suffices. Correctness
path (splits->1, -88% bug + duck optimization) unchanged.
@hughperkins

Copy link
Copy Markdown
Collaborator Author

Genesis benchmarks:

20260717_cse_1127

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

…desired defaults

The PR now ships the intended behavior with no runtime toggles:
- merge_global_ptrs always runs (drop QD_NO_PTR_MERGE gate)
- ndarray two-phase exclusion always honored; field/SNode exclusion removed
  entirely (fields are handled upstream by merge_global_ptrs), dropping QD_LICM_EXCLUDE
- drop QD_LICM_LOG debug logging (licm_log/describe_dest/[PTRMERGE])
Also drops now-unused snode-exclusion machinery and includes.
@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@hughperkins

Copy link
Copy Markdown
Collaborator Author

Genesis unit tests on ndarray passing:

Screenshot 2026-07-20 at 14 14 13

@hughperkins

Copy link
Copy Markdown
Collaborator Author

Genesis benchmarks ok-ish:

20260720_cse_1311

@github-actions

Copy link
Copy Markdown

…loop_invariant

Recovers the runtime regression the ndarray-exclusion introduced on contact-heavy GJK
scenes (duck_in_box_hard gjk=True: -10.47%). Root cause: cache_loop_invariant runs in
offload_to_executable before any pointer-merging CSE (monolith simplify_III no-ops
per-task CSE; offload_to_executable's own full_simplify runs after it). Pre-offload
merge_global_ptrs can't reach ndarrays (not ExternalPtr yet pre-offload).

Two changes:
1. operand_hash: special-case ExternalPtrStmt like GlobalPtrStmt so same-address ndarray
   pointers bucket together and merge via definitely_same_address even when their index
   compute statements are distinct (previously they only merged if operands were identical
   objects, which required full whole-kernel CSE -- what per-task CSE skips).
2. merge_offloaded_ptrs: run the cheap ptrs-only CSE per offloaded task right before
   cache_loop_invariant, unifying each address's read/write pointers (fields + ndarrays).

cache_loop then caches soundly with one shared pointer, so the ndarray exclusion becomes
inert (kept as a safety net) and the optimization is restored. Preserves per-task CSE.
@github-actions

Copy link
Copy Markdown

…ag_access #2)

Placing it before cache_loop_invariant was too late: flag_access #2 and simplify_III's
LICM already split/hoisted the ndarray break-flag's read pointer by then, so cache_loop
still couldn't cache it (duck_in_box_hard gjk=True stayed ~-12% vs main on same-node
bench). Move the per-task pointer merge to immediately after offload -- the post-offload
analog of the pre-offload merge_global_ptrs at flag_access #1 -- so the read/write
ExternalPtrs are unified before any split/hoist and cache_loop can cache soundly.
@github-actions

Copy link
Copy Markdown

…pstream

The prior approach (ptrs-only merge + address-keyed cache_loop + two-phase ndarray
exclusion) preserved correctness but left cache_loop unable to cache the ndarray
break-flag, so duck_in_box_hard gjk=True stayed ~-12% vs main (same-node bench:
main 2.81M / branch 2.47M FPS). Root cause: cache_loop needs a global's read and write
pointers unified, which upstream gets from whole_kernel_cse running inside the
post-offload full_simplify; per-task CSE defers to the codegen workers, which run after
cache_loop.

Fix: run full per-task CSE (cse_offloaded_tasks) on the monolith right after offload,
before flag_access #2 and simplify_III's LICM -- the post-offload analog of the
pre-offload merge_global_ptrs. This unifies each task's read/write pointers (crucially
ndarray ExternalPtrs, which only exist post-offload and need full CSE to merge, since an
ExternalPtr merges only once its index-compute is merged). With pointers unified,
cache_loop_invariant_global_vars is sound exactly as upstream, so it is reverted verbatim
to main -- dropping the address-keyed find_cache_entry AND the two-phase ndarray exclusion.

Also special-case ExternalPtrStmt in operand_hash (like GlobalPtrStmt) so same-address
ndarray pointers bucket together and merge via definitely_same_address.

Same-node result: duck_in_box_hard gjk=True 2.85M FPS (>= main 2.81M), compile ~33s
(< main 43s); ndarray detection hang test passes; field + ndarray regression test added.
@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@hughperkins
hughperkins deployed to publish_pypi July 21, 2026 18:45 — with GitHub Actions Active
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.

1 participant