Skip to content

Commit e6d9992

Browse files
Hardcode84claude
andcommitted
Add sympy-to-ixsimpl migration plan
Documents the incremental strategy for transitioning from sympy to ixsimpl as the primary expression IR, with API gap analysis and a 6-phase plan where phases 1/2/4 can run in parallel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Ivan Butygin <ivan.butygin@gmail.com>
1 parent 927608c commit e6d9992

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

docs/sympy-to-ixsimpl-migration.md

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
# Sympy to ixsimpl Migration Plan
2+
3+
Status: **In Progress** (Phase 1 partially complete)
4+
5+
## Current State
6+
7+
**48 files** under `wave_lang/` import sympy directly. ixsimpl currently serves
8+
as the simplification backend via roundtrip conversion in `symbol_utils.py`.
9+
Everything else -- symbol creation, expression construction, type checking,
10+
substitution, codegen, Piecewise logic, numeric probing -- still goes through
11+
sympy despite ixsimpl having native support for most of these operations.
12+
13+
The integration point is `wave_lang/kernel/wave/utils/symbol_utils.py`:
14+
- `simplify(expr)` tries ixsimpl first, falls back to sympy expand/cancel loop.
15+
- `ixs_simplify(expr)` does the roundtrip, returns original on conversion error.
16+
- All 12 call sites go through these two functions. No code calls ixsimpl directly.
17+
18+
## ixsimpl API Surface
19+
20+
Source: `ixsimpl/_ixsimpl.pyi`, `ixsimpl/__init__.py`, `ixsimpl/sympy_conv.py`.
21+
22+
### Expression types (tag-based dispatch)
23+
`INT`, `RAT`, `SYM`, `ADD`, `MUL`, `FLOOR`, `CEIL`, `MOD`, `PIECEWISE`, `MAX`,
24+
`MIN`, `XOR`, `CMP`, `AND`, `OR`, `NOT`, `TRUE`, `FALSE`
25+
26+
### Expr methods
27+
| Method | Purpose |
28+
|--------|---------|
29+
| `.tag` | Node type discriminator (replaces `isinstance(expr, sympy.X)`) |
30+
| `.nchildren` / `.children` / `.child(i)` | Generic structural access (replaces `.args`) |
31+
| `.sym_name` | Symbol name (replaces `str(sym)`) |
32+
| `.rat_num` / `.rat_den` | Rational numerator/denominator (replaces `.p`/`.q`) |
33+
| `.add_coeff` / `.add_nterms` / `.add_term(i)` / `.add_term_coeff(i)` | Add decomposition |
34+
| `.mul_coeff` / `.mul_nfactors` / `.mul_factor_base(i)` / `.mul_factor_exp(i)` | Mul decomposition |
35+
| `.pw_ncases` / `.pw_value(i)` / `.pw_cond(i)` | Piecewise branch access |
36+
| `.cmp_op` | Comparison operator kind |
37+
| `.subs(target, replacement)` / `.subs(mapping)` | Substitution (replaces `.subs()`/`.xreplace()`) |
38+
| `.simplify(assumptions=)` | Simplification with assumption constraints |
39+
| `.expand()` | Expression expansion |
40+
| `.to_c()` | C code generation |
41+
| `.free_symbols` | Free symbol set (Python-level, on `Expr` subclass) |
42+
| `.is_error` / `.is_parse_error` / `.is_domain_error` | Error detection |
43+
| Arithmetic operators | `+`, `-`, `*`, `/`, `>=`, `>`, `<=`, `<`, `==`, `!=` |
44+
45+
### Context methods
46+
| Method | Purpose |
47+
|--------|---------|
48+
| `ctx.sym(name)` | Create symbol |
49+
| `ctx.int_(val)` / `ctx.rat(p, q)` | Create constants |
50+
| `ctx.true_()` / `ctx.false_()` | Boolean constants |
51+
| `ctx.eq(a, b)` / `ctx.ne(a, b)` | Equality/inequality nodes |
52+
| `ctx.parse(input)` | Parse expression from string |
53+
| `ctx.simplify_batch(exprs, assumptions=)` | Batch simplification |
54+
| `ctx.errors` / `ctx.clear_errors()` | Error reporting |
55+
| `ctx.stats()` / `ctx.stats_reset()` | Performance stats |
56+
57+
### Free functions
58+
`floor`, `ceil`, `mod`, `max_`, `min_`, `xor_`, `and_`, `or_`, `not_`, `pw`,
59+
`same_node`
60+
61+
### Sympy conversion layer (`ixsimpl.sympy_conv`)
62+
`from_sympy(ctx, expr)`, `to_sympy(expr, symbols=, xor_fn=)`,
63+
`extract_assumptions(ctx, expr)`
64+
65+
Handles: all arithmetic, floor/ceil/mod, min/max, xor, piecewise, all
66+
comparisons, and/or/not, true/false. Custom `xor` Function subclass matched by
67+
name. `Abs` is the only notable missing conversion (emulable via piecewise).
68+
69+
## Sympy Usage Categories
70+
71+
### Already migrated to ixsimpl
72+
73+
| Category | Notes |
74+
|----------|-------|
75+
| **Simplification** | `simplify()` and `ixs_simplify()` delegate to ixsimpl. Fallback to sympy `expand`/`cancel` only on conversion failure. |
76+
77+
### Not yet migrated (gap analysis)
78+
79+
| Category | Sympy API Used | ixsimpl Equivalent | Actual Gap |
80+
|----------|---------------|-------------------|------------|
81+
| **Symbol creation** | `sympy.Symbol(name, integer=True, nonneg=True)` | `ctx.sym(name)` + `extract_assumptions` | **Soft.** ixsimpl symbols carry no assumptions intrinsically; assumptions are passed separately to `simplify()`. Wave would need to track assumptions in a side table or always extract from sympy symbols during conversion. |
82+
| **Expression construction** | `Add`, `Mul`, `Pow`, `Integer`, `Rational`, `Mod(evaluate=False)`, `floor`, `ceiling`, `Min`, `Max`, `Abs`, `Piecewise` | Arithmetic ops, `ctx.int_()`, `ctx.rat()`, `floor`, `ceil`, `mod`, `max_`, `min_`, `pw` | **Soft.** Missing: `Abs` (emulate via `pw`), `evaluate=False` (ixsimpl does not eagerly evaluate, so not needed). `Pow` must be expanded to repeated multiplication (already done in `from_sympy`). |
83+
| **Piecewise / conditionals** | `sympy.Piecewise((val, cond), ...)` | `pw(*branches)`, `.pw_ncases`, `.pw_value(i)`, `.pw_cond(i)` | **None.** Full piecewise support. The `piecewise_aware_subs()` workaround in indexing.py exists only because sympy's `.subs()` triggers expensive boolean simplification on Piecewise -- ixsimpl's `.subs()` does not have this problem. |
84+
| **Structural inspection** | `isinstance(expr, sympy.X)`, `.is_number`, `.is_Atom`, `.args`, `.func` | `.tag` (tag-based dispatch), `.nchildren`, `.children`, `.child(i)`, specialized accessors | **Soft.** `isinstance` dispatch maps to `expr.tag == ixsimpl.ADD` etc. Specialized accessors (`.add_nterms`, `.mul_nfactors`) give richer decomposition than sympy's flat `.args`. Missing: `.is_number` (check `tag == INT or tag == RAT`), `.has()` (walk tree or check `free_symbols`). |
85+
| **Substitution** | `.subs()`, `.xreplace()`, `.replace(pred, fn)` | `.subs(target, repl)`, `.subs(mapping)` | **Soft.** Direct substitution is supported. Missing: `.replace(pred, fn)` (predicate-based bottom-up rewrite). Used in `_custom_simplify_once` for 4 transform passes. Would need a Python-level tree walker. |
86+
| **Free symbol inspection** | `.free_symbols`, `.has(sym)` | `.free_symbols` (Python `Expr` class) | **None.** Already implemented as a cached property on the Python `Expr` subclass. `.has()` can be trivially derived. |
87+
| **Expression decomposition** | `.as_ordered_terms()`, `.as_numer_denom()` | `.add_nterms`/`.add_term(i)`/`.add_term_coeff(i)`, `.rat_num`/`.rat_den` | **Soft.** Add decomposition is richer in ixsimpl (coefficient + terms). Numer/denom split is only used for Rational nodes (`.rat_num`/`.rat_den`). |
88+
| **Numeric probing** | `sympy.lambdify()` with custom modules | None | **Hard gap.** `lambdify` compiles sympy expressions to fast Python callables. Would need either an ixsimpl evaluator or a custom tree walker. `.to_c()` could potentially be used with ctypes but that is overkill. |
89+
| **Affine conversion** | Sympy -> MLIR AffineExpr pipeline | None (but `.tag`-based walk is possible) | **Soft.** The converter does a structural walk over the expression tree. This maps directly to ixsimpl's `.tag` + `.child(i)` API. The walk structure would be nearly identical. |
90+
| **Constraint solving** | `sympy.solve()`, `sympy.Eq()` | `ctx.eq()` for construction, no solver | **Hard gap.** `sympy.solve()` used in 2 places (`assumptions.py`, `general_utils.py`). No ixsimpl equivalent. These are niche uses. |
91+
| **Codegen / printing** | `lambdastr()` for grid dim lambdas | `.to_c()` | **Soft.** `.to_c()` generates C code from expressions. `lambdastr()` generates Python code. Different targets, but the need is similar. Grid lambdas could use `.to_c()` + compile, or a Python eval walker. |
92+
| **Type system** | `sympy.Integer`, `sympy.Rational`, `.is_Integer`, `.is_Rational` | `tag == INT`, `tag == RAT`, `.rat_num`, `.rat_den` | **None.** Tag-based dispatch replaces isinstance checks. |
93+
94+
### Summary of gaps
95+
96+
**Hard gaps** (no ixsimpl equivalent):
97+
1. `sympy.solve()` -- constraint solver (2 call sites, niche)
98+
2. `sympy.lambdify()` -- compiled expression evaluator (numeric probing)
99+
3. `.replace(pred, fn)` -- predicate-based rewriting (4 transforms in
100+
`_custom_simplify_once`, but these exist only as fallback when ixsimpl
101+
conversion fails, so they may become dead code as coverage improves)
102+
103+
**Soft gaps** (ixsimpl has the feature, integration work needed):
104+
4. Symbol assumptions -- tracked separately, not on the symbol node
105+
5. `Abs` conversion -- emulate via `pw(x, x >= 0, -x, ctx.true_())`
106+
6. Affine converter -- structural walk needs porting from sympy isinstance to
107+
ixsimpl tag dispatch
108+
7. Grid lambda codegen -- `lambdastr` -> `.to_c()` or Python eval walker
109+
110+
**No gap** (ready to use):
111+
8. Piecewise construction and inspection
112+
9. Free symbol inspection
113+
10. Substitution (direct mapping; predicate-based `.replace` excluded)
114+
11. Structural inspection via `.tag` and accessors
115+
12. Expression decomposition (add terms, mul factors, rational parts)
116+
117+
## Migration Strategy
118+
119+
ixsimpl is not just a simplifier -- it provides symbol creation, expression
120+
construction, structural inspection, substitution, free symbol queries,
121+
piecewise, expansion, and C codegen. The only true hard gaps vs sympy are
122+
`sympy.solve()` (2 call sites) and `lambdify()` (numeric probing).
123+
124+
**Recommended end state: ixsimpl as the primary expression IR, sympy as a
125+
thin compatibility layer retained only for `solve()` and `lambdify()`.**
126+
127+
### Incremental phases
128+
129+
## Phase 1: Complete simplification migration (CURRENT)
130+
131+
**Goal:** All simplification goes through ixsimpl. Zero calls to
132+
`sympy.simplify()`, `sympy.expand()`, `sympy.cancel()` outside the fallback path
133+
in `symbol_utils.simplify()`.
134+
135+
**Status:** Mostly done. Remaining direct sympy simplification calls:
136+
137+
| File | Call | Action |
138+
|------|------|--------|
139+
| `symbol_utils.py:498,502` | `sympy.expand()`, `sympy.cancel()` in fallback | Keep -- intentional fallback for unconvertible expressions. |
140+
| `symbol_utils.py:303` | `sympy.cancel(t / divisor)` in `split_sum_by_divisibility` | Route through ixsimpl if possible, else keep. |
141+
| `index_mapping_simplify.py:177` | `sympy.cancel(numer - mod_arg)` | Same -- targeted cancellation. |
142+
| `read_write.py:118` | `sympy.expand(diff)` for non-Piecewise | Replace with `simplify(diff)`. |
143+
| `schedule.py:622` | `expr.simplify()` (sympy native method) | Replace with `simplify(expr)` from symbol_utils. |
144+
145+
**Work items:**
146+
1. Replace `expr.simplify()` call in `schedule.py` with `simplify(expr)`.
147+
2. Replace `sympy.expand(diff)` in `read_write.py:118` with `simplify(diff)`.
148+
3. Audit: ensure no other file calls sympy simplification directly.
149+
4. Track fallback frequency to identify remaining conversion gaps.
150+
151+
## Phase 2: Centralize sympy imports behind Wave wrappers
152+
153+
**Goal:** No production file imports sympy directly except foundation modules.
154+
155+
**Allowed import sites:**
156+
- `wave_lang/support/indexing.py` -- type aliases, symbol creation
157+
- `wave_lang/kernel/wave/utils/symbol_utils.py` -- simplification, bounds, probing
158+
- `wave_lang/kernel/wave/mlir_converter/attr_type_converter.py` -- affine conversion
159+
- `wave_lang/kernel/wave/water_mlir/.../sympy_to_affine_converter.py` -- affine conversion
160+
- `wave_lang/kernel/compiler/wave_codegen/emitter.py` -- codegen pattern matching
161+
162+
**Strategy:**
163+
Re-export needed sympy names from `indexing.py` and `symbol_utils.py`.
164+
Downstream files import from Wave modules, not sympy.
165+
166+
```python
167+
# indexing.py additions
168+
from sympy import (
169+
Integer, Rational, Mod, Piecewise, Eq,
170+
floor, ceiling, Min, Max,
171+
)
172+
```
173+
174+
**Work items:**
175+
1. Add re-exports to `indexing.py` for expression constructors.
176+
2. Add re-exports to `symbol_utils.py` for analysis utilities.
177+
3. File-by-file: replace `import sympy` with imports from Wave modules.
178+
4. Add a ruff rule or pre-commit check to flag direct `import sympy`.
179+
180+
**Risk:** Low. Mechanical refactor, no behavior change.
181+
182+
## Phase 3: Dual-IR wrapper layer
183+
184+
**Goal:** Introduce a thin `IndexExpr` wrapper that can hold either a sympy
185+
expression or an ixsimpl `Expr`, exposing a unified API. This allows incremental
186+
migration without big-bang rewrites.
187+
188+
**Key insight:** ixsimpl's `.tag`-based dispatch maps cleanly to sympy's
189+
`isinstance` dispatch. The wrapper translates between them:
190+
191+
```python
192+
# Sketch -- not final API
193+
def expr_tag(expr) -> int:
194+
"""Unified tag for both sympy and ixsimpl expressions."""
195+
if isinstance(expr, ixsimpl.Expr):
196+
return expr.tag
197+
if isinstance(expr, sympy.Add):
198+
return ixsimpl.ADD
199+
if isinstance(expr, sympy.Mul):
200+
return ixsimpl.MUL
201+
...
202+
203+
def expr_free_symbols(expr) -> set:
204+
"""Free symbols from either IR."""
205+
return expr.free_symbols # both have this
206+
207+
def expr_subs(expr, mapping: dict):
208+
"""Substitution on either IR."""
209+
if isinstance(expr, ixsimpl.Expr):
210+
return expr.subs(mapping)
211+
return piecewise_aware_subs(expr, mapping)
212+
```
213+
214+
**Work items:**
215+
1. Define wrapper functions in a new `expr_api.py` or extend `symbol_utils.py`.
216+
2. Migrate callers one pass at a time (one PR per compiler pass).
217+
3. Each pass can be tested independently.
218+
219+
**Risk:** Medium. Need to ensure sympy/ixsimpl semantic equivalence at each call
220+
site. The conversion layer (`sympy_conv.py`) already validates this for the
221+
simplification path.
222+
223+
## Phase 4: Port affine converter to ixsimpl
224+
225+
**Goal:** `sympy_to_affine_converter.py` works directly on ixsimpl `Expr` nodes
226+
instead of sympy expressions.
227+
228+
This phase has **no ordering dependency** on Phases 3 or 5. The affine converter
229+
is a self-contained structural tree walk -- it can be ported to ixsimpl tags
230+
while the rest of the compiler still uses sympy. During transition, the converter
231+
can accept ixsimpl `Expr` natively and fall back to `from_sympy()` conversion
232+
for callers still passing sympy expressions.
233+
234+
ixsimpl's `.tag` + accessor API maps 1:1 to the current isinstance dispatch:
235+
236+
| Current (sympy) | New (ixsimpl) |
237+
|-----------------|---------------|
238+
| `isinstance(expr, sympy.Integer)` | `expr.tag == INT` |
239+
| `isinstance(expr, sympy.Rational)` | `expr.tag == RAT` |
240+
| `isinstance(expr, sympy.Symbol)` | `expr.tag == SYM` |
241+
| `isinstance(expr, sympy.Add)` | `expr.tag == ADD` |
242+
| `isinstance(expr, sympy.Mul)` | `expr.tag == MUL` |
243+
| `isinstance(expr, sympy.floor)` | `expr.tag == FLOOR` |
244+
| `isinstance(expr, sympy.Mod)` | `expr.tag == MOD` |
245+
| `isinstance(expr, sympy.Piecewise)` | `expr.tag == PIECEWISE` |
246+
| `expr.args[0]` | `expr.child(0)` |
247+
| `int(expr)` | `int(expr)` |
248+
| `expr.p, expr.q` | `expr.rat_num, expr.rat_den` |
249+
250+
**Work items:**
251+
1. Create `ixsimpl_to_affine_converter.py` alongside the existing converter.
252+
2. Port case-by-case, sharing the `AffineFraction` infrastructure.
253+
3. Add a `from_sympy()` shim at the entry point so callers passing sympy
254+
expressions still work during transition.
255+
4. Test both paths produce identical AffineExpr output.
256+
5. Once validated, swap the default and deprecate the sympy path.
257+
258+
**Risk:** Medium. The converter has subtle Rational/fraction handling that needs
259+
careful porting.
260+
261+
## Phase 5: Port emitter to tag-based dispatch
262+
263+
**Goal:** `emitter.py` pattern-matches on ixsimpl tags instead of sympy types.
264+
265+
The emitter currently does:
266+
```python
267+
match expr:
268+
case sympy.Add(): ...
269+
case sympy.Mul(): ...
270+
case sympy.Mod(): ...
271+
...
272+
```
273+
274+
This becomes:
275+
```python
276+
tag = expr.tag
277+
if tag == ADD:
278+
...
279+
elif tag == MUL:
280+
...
281+
elif tag == MOD:
282+
...
283+
```
284+
285+
With ixsimpl's specialized accessors, the emitter can also be cleaner -- e.g.
286+
`.add_nterms` / `.add_term(i)` instead of iterating `.args` and guessing
287+
structure.
288+
289+
**Work items:**
290+
1. Port emitter dispatch to ixsimpl tags.
291+
2. Use `.add_*` / `.mul_*` / `.pw_*` accessors for structured decomposition.
292+
3. Keep sympy conversion as fallback during transition.
293+
294+
**Risk:** Medium. The emitter is well-tested via LIT tests. Changes should be
295+
caught by FileCheck.
296+
297+
## Phase 6: Remove sympy from hot paths
298+
299+
**Goal:** Compiler passes operate on ixsimpl `Expr` natively. Sympy is only
300+
used for:
301+
- `sympy.solve()` (2 call sites in `assumptions.py`, `general_utils.py`)
302+
- `sympy.lambdify()` (numeric probing in `symbol_utils.py`)
303+
- Legacy test assertions that compare sympy expressions
304+
305+
**What this means:**
306+
- `IndexExpr` type alias changes from `sympy.Expr` to `ixsimpl.Expr`.
307+
- Symbol creation via `ctx.sym()` instead of `sympy.Symbol()`.
308+
- Expression construction via ixsimpl operators and free functions.
309+
- Substitution via `.subs()`.
310+
- No more `piecewise_aware_subs()` workaround.
311+
- No more `evaluate=False` on Mod/floor (ixsimpl does not eagerly evaluate).
312+
313+
**The 2 remaining sympy uses:**
314+
- `sympy.solve()`: Convert ixsimpl -> sympy at the call site, solve, convert
315+
back. This is 2 call sites total.
316+
- `lambdify()`: Either keep the sympy conversion for probing, or write a simple
317+
Python evaluator that walks ixsimpl tags. The latter is ~50 lines.
318+
319+
**Risk:** High but contained. This is the "flip the switch" phase. Every
320+
preceding phase must be complete and validated.
321+
322+
## What NOT to do
323+
324+
1. **Do not try to eliminate sympy in one shot.** The 6 phases exist for a
325+
reason. Each phase is independently testable and revertible.
326+
327+
2. **Do not add ixsimpl calls outside the centralized wrappers** (until Phase 6
328+
flips the default IR). All ixsimpl usage should go through `symbol_utils.py`
329+
so fallback behavior is consistent.
330+
331+
3. **Do not remove the sympy fallback path prematurely.** Until ixsimpl handles
332+
100% of expressions the compiler produces, the fallback is a safety net.
333+
334+
4. **Do not port the emitter and affine converter simultaneously.** These are
335+
independent subsystems; port one at a time with full test coverage between.
336+
337+
## Dependency risks
338+
339+
- **ixsimpl is pinned to a specific git SHA.** API changes require updating the
340+
pin and potentially the conversion layer.
341+
- **Thread safety:** ixsimpl context is not thread-safe (handled via
342+
thread-local storage). Works fine for multiprocessing. For async, verify
343+
context isolation.
344+
- **sympy version sensitivity:** The codebase works around sympy bugs (#28744 Mod
345+
auto-evaluation, floor/ceil evaluation on Max/Min arguments). Moving to ixsimpl
346+
as primary IR eliminates these workarounds entirely.
347+
- **ixsimpl `.subs()` semantics:** Need to verify it matches sympy's substitution
348+
semantics exactly, particularly for Piecewise conditions and nested
349+
replacements. The `piecewise_aware_subs` workaround exists because sympy's
350+
`.subs()` triggers boolean simplification -- if ixsimpl's `.subs()` does not
351+
have this problem, the workaround can be dropped.
352+
353+
## Metrics to track
354+
355+
- Number of files with direct `import sympy` (target: 5 -> 2 -> 0 non-test).
356+
- Fallback rate: how often `simplify()` hits the sympy fallback (indicates
357+
conversion coverage gaps).
358+
- sympy-to-ixsimpl conversion errors by type (identifies which expression
359+
patterns still need `from_sympy` support).
360+
361+
## Priority order
362+
363+
| Phase | Effort | Risk | Depends on | Impact |
364+
|-------|--------|------|------------|--------|
365+
| 1. Complete simplification migration | Low | Low | -- | Eliminates stray `sympy.simplify` calls |
366+
| 2. Centralize imports | Low | Low | -- | Creates migration chokepoint |
367+
| 3. Dual-IR wrapper layer | Medium | Medium | 2 | Enables incremental pass migration |
368+
| 4. Port affine converter | Medium | Medium | -- | Removes sympy from MLIR lowering |
369+
| 5. Port emitter | Medium | Medium | 3 | Removes sympy from codegen |
370+
| 6. Remove sympy from hot paths | High | High | 3, 4, 5 | ixsimpl becomes primary IR |
371+
372+
Phases 1, 2, and 4 can run in parallel. Phase 4 uses the existing sympy
373+
roundtrip (`from_sympy`) as a shim, so it does not need to wait for the rest
374+
of the compiler to migrate.

0 commit comments

Comments
 (0)