loopty 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. {loopty-0.2.0 → loopty-0.3.0}/CHANGELOG.md +1007 -0
  2. {loopty-0.2.0 → loopty-0.3.0}/PKG-INFO +743 -83
  3. {loopty-0.2.0 → loopty-0.3.0}/README.md +741 -81
  4. loopty-0.3.0/docs/device-runs/demo_gaps.py +107 -0
  5. loopty-0.3.0/docs/device-runs/pocl-cpu-demo-gaps.log +15 -0
  6. loopty-0.3.0/docs/device-runs/pocl-cpu-scatter-atomic.log +43 -0
  7. loopty-0.3.0/docs/device-runs/scatter_atomic.py +143 -0
  8. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs.md +64 -0
  9. loopty-0.3.0/docs/lean-checks.md +538 -0
  10. {loopty-0.2.0 → loopty-0.3.0}/docs/loopy-notes.md +184 -7
  11. {loopty-0.2.0 → loopty-0.3.0}/docs/quickstart.md +146 -74
  12. loopty-0.3.0/examples/README.md +2782 -0
  13. loopty-0.3.0/examples/advection.py +929 -0
  14. loopty-0.3.0/examples/assembly.py +471 -0
  15. loopty-0.3.0/examples/calls.py +291 -0
  16. {loopty-0.2.0 → loopty-0.3.0}/examples/fusion.py +33 -15
  17. loopty-0.3.0/examples/hexahedra.py +630 -0
  18. loopty-0.3.0/examples/l2p.py +549 -0
  19. loopty-0.3.0/examples/l2p_sumpy.json +39 -0
  20. loopty-0.3.0/examples/layer_potential.py +601 -0
  21. loopty-0.3.0/examples/layer_potential_sumpy.json +87 -0
  22. {loopty-0.2.0 → loopty-0.3.0}/examples/p2p.py +9 -13
  23. loopty-0.3.0/examples/quadrature.py +180 -0
  24. loopty-0.3.0/examples/storage_layouts.py +175 -0
  25. loopty-0.3.0/examples/sum_factorization.py +209 -0
  26. loopty-0.3.0/examples/taylor_reconstruction.py +239 -0
  27. {loopty-0.2.0 → loopty-0.3.0}/pyproject.toml +8 -5
  28. loopty-0.3.0/scripts/lean_coverage.py +320 -0
  29. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/__init__.py +15 -1
  30. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/arr.py +11 -0
  31. loopty-0.3.0/src/loopty/calls.py +1671 -0
  32. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/cli.py +128 -1
  33. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/compose.py +531 -37
  34. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/contract.py +227 -17
  35. loopty-0.3.0/src/loopty/dataflow.py +1401 -0
  36. loopty-0.3.0/src/loopty/description.py +632 -0
  37. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/domain.py +24 -8
  38. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/executor.py +106 -8
  39. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/faithful.py +570 -10
  40. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/flow.py +49 -5
  41. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/hypotheses.py +428 -58
  42. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/interpret.py +360 -40
  43. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/kernel.py +513 -24
  44. loopty-0.3.0/src/loopty/layout.py +1268 -0
  45. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/lower.py +405 -11
  46. loopty-0.3.0/src/loopty/math.py +331 -0
  47. loopty-0.3.0/src/loopty/obligations.py +3475 -0
  48. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/oracle.py +126 -7
  49. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/promotion.py +42 -1
  50. loopty-0.3.0/src/loopty/reals.py +457 -0
  51. loopty-0.3.0/src/loopty/reduction.py +70 -0
  52. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/refusal.py +50 -1
  53. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/schedule.py +3218 -215
  54. loopty-0.3.0/src/loopty/source.py +252 -0
  55. loopty-0.3.0/src/loopty/summands.py +721 -0
  56. loopty-0.3.0/src/loopty/table.py +298 -0
  57. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/term.py +160 -1
  58. loopty-0.3.0/src/loopty/tested.py +151 -0
  59. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/tolerance.py +8 -3
  60. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/trace.py +1021 -13
  61. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/typing.py +1335 -34
  62. loopty-0.3.0/src/loopty/unfolding.py +807 -0
  63. loopty-0.3.0/src/loopty/upstream.py +437 -0
  64. loopty-0.3.0/src/loopty/window.py +1058 -0
  65. {loopty-0.2.0 → loopty-0.3.0}/tests/conftest.py +16 -0
  66. loopty-0.3.0/tests/kernels/calls.py +275 -0
  67. loopty-0.3.0/tests/kernels/dataflow.py +575 -0
  68. loopty-0.3.0/tests/kernels/program_domains.py +65 -0
  69. loopty-0.3.0/tests/kernels/program_posts.py +168 -0
  70. loopty-0.3.0/tests/kernels/reals.py +51 -0
  71. loopty-0.3.0/tests/kernels/series.py +95 -0
  72. loopty-0.3.0/tests/kernels/upstream.py +111 -0
  73. loopty-0.3.0/tests/kernels/windows.py +338 -0
  74. loopty-0.3.0/tests/lines.py +24 -0
  75. {loopty-0.2.0 → loopty-0.3.0}/tests/test_adversarial.py +3 -2
  76. {loopty-0.2.0 → loopty-0.3.0}/tests/test_arithmetic.py +2 -2
  77. loopty-0.3.0/tests/test_benchmarks.py +244 -0
  78. {loopty-0.2.0 → loopty-0.3.0}/tests/test_buildable.py +70 -0
  79. loopty-0.3.0/tests/test_calls.py +1531 -0
  80. {loopty-0.2.0 → loopty-0.3.0}/tests/test_cli.py +67 -18
  81. loopty-0.3.0/tests/test_dataflow.py +949 -0
  82. loopty-0.3.0/tests/test_demo_shapes.py +235 -0
  83. {loopty-0.2.0 → loopty-0.3.0}/tests/test_domains.py +15 -0
  84. loopty-0.3.0/tests/test_examples.py +1896 -0
  85. {loopty-0.2.0 → loopty-0.3.0}/tests/test_executor.py +28 -2
  86. {loopty-0.2.0 → loopty-0.3.0}/tests/test_fusion.py +187 -14
  87. {loopty-0.2.0 → loopty-0.3.0}/tests/test_interpret.py +78 -0
  88. {loopty-0.2.0 → loopty-0.3.0}/tests/test_kernel.py +10 -8
  89. loopty-0.3.0/tests/test_layout.py +423 -0
  90. loopty-0.3.0/tests/test_lean_checks.py +1784 -0
  91. {loopty-0.2.0 → loopty-0.3.0}/tests/test_lower.py +31 -0
  92. {loopty-0.2.0 → loopty-0.3.0}/tests/test_lower_traced.py +129 -0
  93. loopty-0.3.0/tests/test_math.py +640 -0
  94. {loopty-0.2.0 → loopty-0.3.0}/tests/test_program.py +15 -9
  95. loopty-0.3.0/tests/test_program_domains.py +243 -0
  96. loopty-0.3.0/tests/test_program_posts.py +406 -0
  97. loopty-0.3.0/tests/test_reals.py +138 -0
  98. loopty-0.3.0/tests/test_scatter.py +952 -0
  99. {loopty-0.2.0 → loopty-0.3.0}/tests/test_schedule.py +279 -30
  100. {loopty-0.2.0 → loopty-0.3.0}/tests/test_smoke.py +1 -1
  101. loopty-0.3.0/tests/test_source.py +104 -0
  102. loopty-0.3.0/tests/test_summands.py +145 -0
  103. loopty-0.3.0/tests/test_table.py +486 -0
  104. loopty-0.3.0/tests/test_tested.py +191 -0
  105. {loopty-0.2.0 → loopty-0.3.0}/tests/test_travel.py +67 -5
  106. {loopty-0.2.0 → loopty-0.3.0}/tests/test_typing.py +76 -3
  107. loopty-0.3.0/tests/test_upstream.py +264 -0
  108. loopty-0.3.0/tests/test_window.py +904 -0
  109. {loopty-0.2.0 → loopty-0.3.0}/tests/test_work_items.py +197 -16
  110. loopty-0.2.0/examples/README.md +0 -925
  111. loopty-0.2.0/src/loopty/reduction.py +0 -27
  112. loopty-0.2.0/tests/test_examples.py +0 -951
  113. {loopty-0.2.0 → loopty-0.3.0}/.gitignore +0 -0
  114. {loopty-0.2.0 → loopty-0.3.0}/LICENSE +0 -0
  115. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/device_demos.py +0 -0
  116. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/h200-cuda-commands.log +0 -0
  117. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/h200-cuda-device-demos.log +0 -0
  118. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/h200-cuda-reduction-probe.log +0 -0
  119. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/pocl-epyc-cpu-commands.log +0 -0
  120. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/pocl-epyc-cpu-device-demos.log +0 -0
  121. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/pocl-epyc-cpu-reduction-probe.log +0 -0
  122. {loopty-0.2.0 → loopty-0.3.0}/docs/device-runs/reduction_probe.py +0 -0
  123. {loopty-0.2.0 → loopty-0.3.0}/examples/composition.py +0 -0
  124. {loopty-0.2.0 → loopty-0.3.0}/examples/pairs.py +0 -0
  125. {loopty-0.2.0 → loopty-0.3.0}/examples/reshape_layouts.py +0 -0
  126. {loopty-0.2.0 → loopty-0.3.0}/examples/spmv.py +0 -0
  127. {loopty-0.2.0 → loopty-0.3.0}/examples/stencil_skew.py +0 -0
  128. {loopty-0.2.0 → loopty-0.3.0}/examples/travel.py +0 -0
  129. {loopty-0.2.0 → loopty-0.3.0}/examples/wavefront_acoustic.py +0 -0
  130. {loopty-0.2.0 → loopty-0.3.0}/scripts/refresh_example_outputs.py +0 -0
  131. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/idx.py +0 -0
  132. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/isl_reading.py +0 -0
  133. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/names.py +0 -0
  134. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/operations.py +0 -0
  135. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/plugin.py +0 -0
  136. {loopty-0.2.0 → loopty-0.3.0}/src/loopty/py.typed +0 -0
  137. {loopty-0.2.0 → loopty-0.3.0}/tests/hand_terms.py +0 -0
  138. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/axpy.py +0 -0
  139. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/domains.py +0 -0
  140. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/out_of_bounds.py +0 -0
  141. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/spmv_min.py +0 -0
  142. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/stencil.py +0 -0
  143. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/travel.py +0 -0
  144. {loopty-0.2.0 → loopty-0.3.0}/tests/kernels/travel_wrong.py +0 -0
  145. {loopty-0.2.0 → loopty-0.3.0}/tests/test_arr.py +0 -0
  146. {loopty-0.2.0 → loopty-0.3.0}/tests/test_contract_bounds.py +0 -0
  147. {loopty-0.2.0 → loopty-0.3.0}/tests/test_contraction.py +0 -0
  148. {loopty-0.2.0 → loopty-0.3.0}/tests/test_declared_layout.py +0 -0
  149. {loopty-0.2.0 → loopty-0.3.0}/tests/test_executor_c.py +0 -0
  150. {loopty-0.2.0 → loopty-0.3.0}/tests/test_faithful.py +0 -0
  151. {loopty-0.2.0 → loopty-0.3.0}/tests/test_flow.py +0 -0
  152. {loopty-0.2.0 → loopty-0.3.0}/tests/test_idx.py +0 -0
  153. {loopty-0.2.0 → loopty-0.3.0}/tests/test_isl_reading.py +0 -0
  154. {loopty-0.2.0 → loopty-0.3.0}/tests/test_names.py +0 -0
  155. {loopty-0.2.0 → loopty-0.3.0}/tests/test_oracle.py +0 -0
  156. {loopty-0.2.0 → loopty-0.3.0}/tests/test_refresh_script.py +0 -0
  157. {loopty-0.2.0 → loopty-0.3.0}/tests/test_storage.py +0 -0
  158. {loopty-0.2.0 → loopty-0.3.0}/tests/test_term.py +0 -0
  159. {loopty-0.2.0 → loopty-0.3.0}/tests/test_trace.py +0 -0
@@ -6,6 +6,1013 @@ All notable changes to loopty are recorded here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-15
10
+
11
+ The third release, on lanky 0.3.0. Five demos, each written after a kernel
12
+ of sumpy, pytential, grudge or firedrake: sumpy's L2P (`examples/l2p.py`),
13
+ grudge's weak gradient and divergence on hexahedra (`examples/hexahedra.py`)
14
+ and its advection right-hand side (`examples/advection.py`), pytential's
15
+ direct QBX (`examples/layer_potential.py`) and PyOP2's mass assembly
16
+ (`examples/assembly.py`). Each states its facts, cites the theorems it
17
+ computes, and agrees with the upstream code it mirrors (`@upstream`). isl
18
+ decides each fact, and Lean proves it again from loopty's own description of
19
+ the kernel, with the hints isl found. New in the language and the schedule:
20
+ theorems a kernel cites (`uses=`), a program's postcondition, constant
21
+ tables, `loopty.math`, kernels called inside a loop, a program's arrays over
22
+ a domain, updates and `Schedule.atomic`, window contraction, checked layout
23
+ maps at every size, fusion on chosen outer loops and `Schedule.assume`. The
24
+ demos write their theorems with lanky 0.3.0's families of several indices
25
+ and complex parts, so loopty requires it.
26
+
27
+ ### Added
28
+
29
+ - **What fusing and contracting S1's passes buys on a CPU**,
30
+ `benchmarks/hexahedra.py c`: the weak gradient's three programs of
31
+ `examples/hexahedra.py`, the passes one after another, `fused`, and
32
+ `contracted`, on the C target, each checked against numpy and then timed
33
+ in turn on one thread. Every program is built with loopty's flags and the
34
+ same `--cflags`, which the CSV records, and `report` tables the rows as it
35
+ does the device's.
36
+ - **sumpy's GPU schedule in the flagship's ledger** (`examples/l2p.py`):
37
+ `on_a_gpu = fused.retarget("opencl").tag(i="g.0")`, `E2PFromSingleBox`'s
38
+ own, each box a work group, written with the file's names. Its tag's two
39
+ facts are rows of `lanky check` (the fusions' are `fused`'s), the python
40
+ run lists them, and `loopty run` runs it on a device where pyopencl is
41
+ installed and compares it with the native run; on a GPU and on a CPU
42
+ through PoCL the two agreed exactly, up to 4,096 boxes. The same with each
43
+ box's windows, `on_a_device()`, is checked by isl in the python run; Lean
44
+ does not state a cast on windows each work item keeps of its own yet
45
+ (#295).
46
+ - **A schedule for a device, checked where pyopencl is not installed**
47
+ (`loopty.lower.CheckedOpenCLTarget`). A schedule for `"opencl"` is built
48
+ on loopy's plain OpenCL target there, which has the same kernel and
49
+ imports nothing, so a file keeps its device schedules at module level and
50
+ `python`, `lanky check` and `loopty run` read them on any machine.
51
+ Generating or running their code is refused with the reason
52
+ (`ImportError: generating and running OpenCL code needs pyopencl, ...`),
53
+ and `loopty run` says `not run here: running on a device needs pyopencl,
54
+ which is not installed; its facts are checked all the same`, which is no
55
+ failure. `--target opencl` without pyopencl is refused by name, as before.
56
+ - **A window of an array over a domain, of several writers and of a
57
+ recurrence** (`Schedule.contract`, #244, #213). A placement over an array
58
+ over a domain takes the domain's binders, and the window is the box its
59
+ slots span: sumpy's L2P, fused box by box, keeps one box's triangle,
60
+ `contract("R")` finds `lambda i, a, b: (a, b)`, 121 cells for the 66 of a
61
+ triangle of order 10, as plain loopy's temporary of the box. The array may
62
+ be stored by several statements, and read by one of them: the copy of
63
+ the stored rows and the recurrence `R[i, a, b] = -R[i, a - 2, b + 2]`. The
64
+ `definedness` fact asks that every read is of a cell one of them stored
65
+ before it, and the `no-clobber` fact is asked of every pair of them,
66
+ decided by isl and proved by Lean; a window too small, one row of the
67
+ triangle, is refused with the read the recurrence makes, the cell and the
68
+ instance that overwrote its slot. A window of a number of cells is each
69
+ work item's own, as loopy keeps a private temporary, so two of its
70
+ accesses on two work items need no order, and a read on a work item its
71
+ cell's store is not on is refused with the pair; the boxes on `g.0`,
72
+ sumpy's own schedule, each keep their triangle. A window of a size,
73
+ `f_window[n]`, is still one for every work item. A recurrence along a
74
+ loop, `f[j] = f[j - 1] + u[j]`, is a shift register too
75
+ (`registers=True`, the default on OpenCL): its store is the newest
76
+ register and its read the one before. `examples/l2p.py` now
77
+ runs `contracted`, its fused program with the triangle and the table kept
78
+ one box at a time, refuses the row with its read, and checks sumpy's
79
+ schedule on a device; `benchmarks/l2p.py --variant windowed` (and
80
+ `unrolled_windowed`) times it.
81
+ - **A layout at every size** (`loopty.layout`, #263). A layout's body may
82
+ compute with a size its annotations name, so grudge's DOFArray is one
83
+ layout for every order, `n**3 * e + (i + n * j + n**2 * k)` over
84
+ `Fin[ne] x Fin[n]**3`, and so is loopy's `dim_tags` `N3,N0,N1,N2` of it,
85
+ each stride the product of the extents nested inside it. isl cannot state
86
+ `n * j`, so it decides each fact at `n` from 1 to 6 and the fact reads
87
+ `tested`, saying why; Lean proves it for every `n`, given the steps
88
+ `omega` lacks (lanky's products, quotients and multiplying out): the
89
+ products that bound each digit's term, a case split on the first digit
90
+ two cells differ in, and an address's digits by division as the cell it
91
+ stores. Two layouts the same is a polynomial identity Lean multiplies
92
+ out. All in core Lean. A refuted one names the size isl found it at,
93
+ `padded stores (i=0, j=1) and (i=0, j=0) both at 0 (at m=2, n=1)`. A call
94
+ gives the size by name, `dof_array(e, i, j, k, n=4)`.
95
+ `examples/hexahedra.py` declares its three layouts at a symbolic order, so
96
+ its claim that the DOFArray, #418's `dim_tags` and the kernels' row-major
97
+ arrays are one layout holds for every order, where it held at order 3 and
98
+ `as_dim_tags` read `x = x` in Lean.
99
+ - **A device cast proved whole** (#188). A cast that puts a loop on a
100
+ hardware axis claims besides its order that no dependence joins two work
101
+ items, which was stated for isl only, so the cast stayed `decided` where
102
+ Lean is installed. Lean is now asked it too: for each dependence class,
103
+ that the two instances run on one work item of every axis, each one's loop
104
+ counted from where loopy starts it, and for a statement's sum that reads
105
+ what its own instruction writes, that the two never touch one cell where
106
+ they run apart. S1's three device launches, sumpy's tile in S2, Kulkarni's
107
+ split in S3 and the advection right-hand side's launches of every split
108
+ read `proved` by Lean, and the four demo drivers offer their casts to Lean
109
+ where it is installed (`python examples/hexahedra.py`: `along_s: tag(...):
110
+ 2 facts, all proved by lean`).
111
+ - **A program's postcondition** (`loopty.unfolding`,
112
+ `loopty.dataflow.program_stores`). A `@program`'s return annotation is a
113
+ claim about what it leaves in its parameters, written in their names and
114
+ the sizes its kernels name, and it is a row of the ledger, keyed as a
115
+ kernel's is:
116
+ - `tested` on the program's native runs, an equation that reads an output
117
+ compared at that output's class, `approx` for reals, as the compiled run
118
+ is compared with the native one: the calls sum in an order of their own,
119
+ and agree with the claim as reals and as floats to rounding. A run that
120
+ leaves it false refutes it, naming the cell and the two values;
121
+ - `decided` by isl where what the calls store decides it, as a kernel's
122
+ postcondition is: each call that stores no cell of an array twice, and
123
+ reads none before its store, has a `single-assignment` fact of the
124
+ program's, and each of its statements is an equation about what the
125
+ program leaves, until a later call writes an array it names;
126
+ - otherwise stated for Lean, a part of Mathlib's for a claim about reals:
127
+ what the calls store unfolded into the claim a pass at a time, each
128
+ theorem the program cites (`@program(uses=[...])`) rewritten where one of
129
+ its sides is, its families at the sections of the arrays they stand
130
+ for, `Fr (3 * x + 0)` and `w (3 * x + 0) e`, then the sums over a literal
131
+ range written out and `ring`. Python finds the steps, and Lean checks
132
+ them; a wrong one is no proof. Proved, the fact rests on the
133
+ `single-assignment` facts and the theorems it used.
134
+ In `examples/hexahedra.py`, `weak_grad` and `weak_div` claim that `grad`
135
+ and `div` are the dense Kronecker operators of the three directions
136
+ applied to the field times its metric terms, citing `sum_factorization`,
137
+ and with Mathlib both rows read `proved lean`: the theorem proved for
138
+ every order now says something about the programs that compute it.
139
+ A claim that names anything but the program's parameters and sizes is
140
+ `assumed`, and says what it names.
141
+ - **A theorem tied to the terms a kernel adds** (`loopty.summands`, #252). A
142
+ cited theorem that concludes `all(c(n) == E for n in Nat if ...)` gives a
143
+ `by-theorem` row where a sum in the kernel's term adds `E` at whole
144
+ numbers `n`, with the theorem's variables bound to the kernel's
145
+ expressions of the sorts it takes them at (a theorem about reals says
146
+ nothing of complex numbers), and an identity the kernel cites, `L == R`,
147
+ rewrites `R` in `E` into the kernel's `L` first; the row names the
148
+ identity's premises that are not the theorem's. `examples/layer_potential.py`'s `single_layer` cites
149
+ `line_taylor` and the new `by_the_conjugate`, `ratio(alpha, beta) == -beta
150
+ / alpha`, the kernels' ratio by the conjugate, and its ledger reads
151
+ `single_layer adds line_taylor's c(n) for n in 1..5, for every c with away,
152
+ first and ode, read over the complex numbers`: `decided` by `theorem`,
153
+ resting on both theorems, and proved by Lean with Mathlib, the identity
154
+ rewritten and the theorem applied a term at a time. A theorem's annotation
155
+ that calls `loopty.math`'s `conj`, `real`, `exp` and the rest, itself or
156
+ through a helper the kernel calls too, reads them as lanky's functions
157
+ (needs lanky with `real`, `imag` and `conj`), and a kernel's postcondition
158
+ keeps the calls its trace makes (`loopty.math.as_traced`, `as_stated`).
159
+ - **A postcondition's equations of reals at their class** (`loopty.reals`,
160
+ #254). An equation that rounds is compared after each native run within
161
+ the tolerance of the class of what it reads, as the compiled run is with
162
+ the native one, `|a - b| <= 1e-6 (|b| + 1)` for `approx`, and the
163
+ statement says so (`..., to the approx tolerance`); one that rounds
164
+ nothing, one of integers and one of an `exact` class are compared bit for
165
+ bit as before. Where its cells are stored once, by statements whose sums
166
+ run over concrete bounds, Lean proves it with Mathlib over the reals from
167
+ what they store, the tables at the values they hold, resting on the
168
+ `single-assignment` facts and on the `trace-faithful` fact.
169
+ `examples/assembly.py`'s `mass_ffc` now promises the element's mass,
170
+ `|J| (1 + delta_jk) / 24`, in place of a symmetric matrix: `tested` on its
171
+ runs, `proved` with Mathlib, and restated of every cell after its call.
172
+ - **`@upstream(kernel, within=1e-12)`** (#268): the agreement with the code a
173
+ kernel mirrors, compared within the stated fraction of the largest value
174
+ of each output computed in floating point, and the row says so (`... in
175
+ out within 1e-12 of the largest value, on its example inputs`); an `exact`
176
+ output is still compared bit for bit. `examples/layer_potential.py`'s two
177
+ rows state it, where the bound was in a test only.
178
+ - **Claims settled by running them** (`loopty.tested`): `@tested` on a
179
+ function that takes nothing and returns whether its claim held, alone or
180
+ with what it measured, puts the claim in the ledger as a `tested` row,
181
+ decided by `run`, the docstring's first line its statement; `refuted` with
182
+ the reason when it returns false, raises, or returns something that is not
183
+ a verdict. A run is evidence about its inputs, so nothing here reads more
184
+ than `tested`. `examples/layer_potential.py` measures pytential's Green
185
+ identity and the two jump relations this way.
186
+ - **S2 of the demonstration** (`examples/layer_potential.py`): pytential's
187
+ QBX on its own curve. The line-Taylor theorem, for every order, in the same
188
+ file as the kernels that add its closed form, `coefficient(ratio, k)`;
189
+ pytential's single and double layer on the five-armed starfish over complex
190
+ points; sumpy's CPU tile (`sumpy_tile()`), its casts decided, with the
191
+ write sumpy silences (`write_race(write_lpot*)`) the fact `distinct
192
+ instances of S0 write distinct cells of out`, proved by Lean where it is
193
+ installed; each kernel's agreement with what sumpy's own `LayerPotential`
194
+ returned on the example's inputs (recorded at sumpy's commit,
195
+ `layer_potential_sumpy.json`), an `upstream` row; and, as tested rows,
196
+ pytential's identity test case and the jump relations, the errors falling
197
+ faster than the QBX order less 1.6.
198
+ `benchmarks/qbx_layer_potential.py` times the kernel against plain loopy
199
+ and sumpy's own on one OpenCL device, records sumpy's results, and runs
200
+ the kernels on the arguments pytential itself hands sumpy.
201
+ - **S1 of the demonstration** (`examples/hexahedra.py`, #253): grudge
202
+ #418's sum-factorized weak gradient and weak divergence on hexahedra, the
203
+ kernels written for `n` nodes per axis, so that their facts hold at every
204
+ order. The metric product (`metric`, or `contravariant`, which sums the
205
+ metric terms first, three operator applications where grudge makes
206
+ nine), a pass per reference axis (`along_r`, `along_s`) and the last pass
207
+ with the three directions summed (`gradient_along_t`,
208
+ `divergence_along_t`), chained by the programs `weak_grad` and
209
+ `weak_div`. The Kronecker theorem the passes rest on,
210
+ `sum_factorization`, for every `n`, proved by Lean with Mathlib and tested
211
+ without it; the DOFArray's layout, at order 3, the same as #418's
212
+ `dim_tags` `N3,N0,N1,N2` and as the kernels' row-major arrays, each a
213
+ fact. The passes fused as deep as each pair allows, one loop deeper
214
+ refused with its pair and the fusion of fewer loops named, and what
215
+ passes between them kept a row (`w`) and a slab (`v`) at a time; on a
216
+ device, three launches, every node on a work item
217
+ (`passes_on_a_device`). `benchmarks/hexahedra.py` times the variants
218
+ against the same kernels in plain loopy and against grudge on one device,
219
+ and compares them with #418's own `weak_local_grad` and `weak_local_div`
220
+ (`agree`): on a warped mesh at orders 3 to 6 they agree to `3e-15` to
221
+ `5e-15`, natively, compiled, fused and contracted, and as the device
222
+ launches.
223
+ - **The demo's flagship, `examples/l2p.py`**: sumpy's L2P for the 2-D
224
+ Laplace kernel (`E2PFromSingleBox` with the compressed Taylor expansion),
225
+ in one file and one ledger. The claim sumpy's reconstruction rests on,
226
+ for every order (lanky#86), and its form box by box on the triangle `a + b
227
+ <= p`, both proved by Lean with Mathlib; sumpy's storage index as a
228
+ checked layout; `unpack`, whose postcondition, decided from what it
229
+ stores, is sumpy's storage order, `reconstruct`, whose postcondition is
230
+ decided from what it stores and is the theorem's hypotheses, so the
231
+ theorem applies, and `evaluate`, composed by the program `l2p` and fused
232
+ box by box; the interchange of the triangle's loops refused with isl's
233
+ pair, and each box's triangle kept in a window (above). The program agrees with sumpy's own kernel on the file's inputs to
234
+ round-off (`upstream`, below), and with the charges' potential to `4e-13`
235
+ at order 10.
236
+ - **Agreement with the code a kernel mirrors**, `@upstream(kernel)`
237
+ (`loopty.upstream`). The decorated function computes, on the file's
238
+ example inputs, what the code the kernel is written after computes there;
239
+ `lanky check` runs the kernel natively on the same inputs and compares,
240
+ output by output, at the accuracy the kernel's types claim, a dense
241
+ output in its own shape. The row is `tested` by `upstream`, `refuted`
242
+ with the cell and both values (or both shapes), or `assumed` with why
243
+ where the function cannot run: its package is not installed, or its
244
+ recording is not there.
245
+ - **`loopty.math.ipow(x, n)`**, `x` to an integer power multiplied out as
246
+ loopy multiplies one, by squaring, rounded at every product, in both runs:
247
+ natively by the same loop (`loopty.math.integer_power`), compiled as
248
+ loopy's `loopy_pow_<base>_<exponent>`. The two runs agree bit for bit, in
249
+ single precision too. An integer base or a real power is refused, natively
250
+ from the values and traced from the operands' types, and so is the
251
+ smallest power of the type the compiled kernel holds the power in
252
+ (`-2**31`, `-2**63`), whose negation loopy's helper cannot take. `x ** n` of a real `x` stays numpy's power, the C
253
+ library's `pow` in both runs; at the flagship's Taylor sum `ipow` is an
254
+ order of magnitude faster on the C target (`benchmarks/l2p.py`).
255
+ - **`benchmarks/l2p.py`**, which times sumpy's own `E2PFromSingleBox`, the
256
+ same algorithm written directly in plain loopy (loopy #970's L2P
257
+ microkernel with the Laplace step put back, and variants that make
258
+ loopty's choices), and loopty's schedules of `examples/l2p.py`, on one
259
+ tree and one device, and compares their potentials with each other and
260
+ with the direct sum. On the C target plain loopy is built with the flags
261
+ loopty builds its kernels with (`-fwrapv`; `--no-wrapv` for loopy's
262
+ alone), and each record names the flags it was built with. It also
263
+ records sumpy's potential on the example's inputs, for the `upstream`
264
+ row.
265
+ - **The S3 demo: firedrake's PyOP2 assembly loop** (`examples/assembly.py`).
266
+ FFC's P1 mass kernel of firedrake's PyOP2 tests, `mass_ffc`, with its
267
+ 15-digit tables as constant tables, is a kernel of its own, called inside
268
+ the loop over the cells as PyOP2 calls it, `mass_ffc(coords[cell[c]],
269
+ Ae[c])`, and the cell's matrix applied to `u` is added into the nodes,
270
+ `y[cell[c, a]] += ...`. The three identities the loop rests on are lanky
271
+ theorems in the same file, proved where Mathlib is installed: the 3-point
272
+ rule integrates every quadratic exactly, a cell's mass matrix is `|J| (1 +
273
+ delta_jk) / 24`, and the scatter keeps the total on every mesh. PyOP2's GPU
274
+ split is refused with the node its two test triangles share and accepted
275
+ once the updates are atomic, and both PyOP2 branches' schedules take the
276
+ assumptions their transforms make (`assume("nc >= 1")`, `assume("nc % 4
277
+ == 0")`). `benchmarks/assembly.py` times loopty's schedules against a
278
+ plain-loopy transcription of PyOP2's wrapper with Kulkarni's and Sun et
279
+ al.'s transforms applied, and with `--nested` against the transcription
280
+ given loopty's nesting of a cell's loops under the cell loop, which is
281
+ what decides where loopy puts the `ilp` loop of Sun et al.'s split
282
+ (interleaving the four cells, or running each whole); and
283
+ `benchmarks/assembly_agreement.py` compares
284
+ the example with firedrake's own assembly and PyOP2's own `par_loop`.
285
+
286
+ - **The advection right-hand side on hexahedra** (`examples/advection.py`),
287
+ the stretch of the S1 demo: grudge #418's `WeakAdvectionOperator` with the
288
+ upwind flux, from grudge's own geometry, as kernels for every order. The
289
+ flux reads the neighbor's value through the face maps, in bounds by type;
290
+ the inverse mass is pushed inward, as grudge #362 rewrites it, and the
291
+ same kernels take the GLL quadrature's tables. Three lanky theorems, proved
292
+ where Mathlib is installed: the two rearrangements, and that the lift onto
293
+ the face nodes is the transpose of the face gather, so it keeps the
294
+ element's total. The kernels fuse on the element, one loop deeper is
295
+ refused with its pair, and the fluxes are kept an element at a time;
296
+ libParanumal's lift onto the face nodes without its barriers is refused
297
+ with the edge node two faces update, and accepted atomic. Five splits on a
298
+ device (libParanumal's volume and surface, the fluxes first, three
299
+ launches, one, one from the metric terms), each launch a checked schedule;
300
+ `benchmarks/advection.py` times them in five shapes beside the same
301
+ launches in plain loopy and grudge's own right-hand side, and compares
302
+ them with grudge's on a warped periodic mesh.
303
+
304
+ - **A kernel's postcondition decided from what its statements store**
305
+ (`loopty.dataflow`, #113). Where no cell of an array a kernel writes is
306
+ stored twice, or read before its store, a `single-assignment` fact says
307
+ so, decided by isl, and what each statement stores is what the kernel
308
+ leaves: its value equation, `off[r + 1] == off[r] + cnt[r]` at every `r`
309
+ of `S1`'s loop. isl decides the postcondition under the equations, each
310
+ taken at the cells the claim reads, so `scan`'s reads `decided isl`,
311
+ resting on the `single-assignment` fact of `off`, and with Lean `proved
312
+ lean`, where it read `tested native`. An equation of reals isl cannot read
313
+ is an atom, one per equation as written, its cells canonical and its sides
314
+ in either order (`discharge(..., atoms=True)`), so `R[a + 2, b] == -R[a, b
315
+ + 2]` is decided as what the recurrence stores at `(a + 2, b)`; Lean
316
+ states it with Mathlib's reals and proves it by `linarith`. The native
317
+ runs still test the postcondition (`native` in its provenance), and one
318
+ that refutes it refutes it; one the equations do not decide keeps the
319
+ runs' status, with why (`undecided`): a cell stored twice, a read before
320
+ its store (the instance that stores a cell and reads it, however its
321
+ index is spelled, `x[i] = 2 * x[i + 1 - 1] - 5`, reads it first), a write
322
+ under a guard isl cannot state, an update (`y[i] += v`, or a scatter-add
323
+ through a map, `y[cell[c, k]] += v`), whose cells hold what they held on
324
+ entry plus every update: `S0 updates y[cell[c, k]] in place, so a cell of
325
+ y holds what it held on entry plus every update, not what one statement
326
+ stores`.
327
+ - **Theorems a kernel cites**, `@kernel(uses=[on_the_triangle])`. A theorem
328
+ whose hypotheses are conjuncts of the postcondition, up to the names of
329
+ their binders, with a family `R(a)(b)` matching the cell `R[a, b]`, gives
330
+ its conclusion at the kernel's arrays: a `by-theorem` fact, `decided` by
331
+ `theorem` and resting on the theorem and the postcondition, whose
332
+ unbound variables stay universal and whose unmatched hypotheses are its
333
+ own, named in the row. For `examples/taylor_reconstruction.py` it reads
334
+ `by on_the_triangle, for every D with pde and stored: ... R[a, b] ==
335
+ D(a)(b)`. Lean checks the application, the theorem as it was proved
336
+ applied with the arrays loopty matched, so a wrong match is no proof. A
337
+ theorem whose conclusion is a conjunct of the postcondition decides that
338
+ conjunct instead. A match is made only where it says something of what
339
+ the kernel computes: every hypothesis about an array the kernel writes is
340
+ a conjunct of the postcondition (a kernel whose recurrence is off by one
341
+ gets nothing), and a conjunct about reals is matched only where nothing
342
+ in it rounds, copies and sign changes, since the theorem reads it as
343
+ reals. A family into `Nat` or a `Fin` sort stands for an array the kernel
344
+ does not write; the cells it writes take a theorem over `Int`. The
345
+ theorem's variables are put in wherever they are, every sort included: a
346
+ `D: Fn[Fin[n], Real]` nothing binds, with `n` matched to the kernel's `p`,
347
+ ranges over `Fin[p]` (#239), and `m` in `m**2` is the `m` the match bound,
348
+ never a kernel name of the same spelling. A family is applied as `R(a)(b)`,
349
+ `R(a, b)` or `R[a, b]`, as lanky reads them.
350
+
351
+ ```python
352
+ @kernel(uses=[on_the_triangle])
353
+ def reconstruct(
354
+ c: Arr[Where[a : Fin[2], b : Fin[p + 1], a + b <= p], Real],
355
+ R: Arr[Where[a : Fin[p + 1], b : Fin[p + 1], a + b <= p], Real],
356
+ ) -> copies & recurrence: # the two conjuncts, written out in the example
357
+ ...
358
+ ```
359
+
360
+ ```console
361
+ $ lanky check examples/taylor_reconstruction.py
362
+ ...
363
+ decided under on_the_triangle assumed theorem taylor_reconstruction.py:116 reconstruct by on_the_triangle, for every D with pde and stored: forall a in Fin(...
364
+ ...
365
+ ```
366
+ - **`lanky check` lists a file's schedules** (#191). The casts of every
367
+ schedule a file holds at module level are rows of its ledger, under the
368
+ kernel or program they schedule, decided by isl as each step was taken
369
+ and proved by Lean where it is installed; two schedules that share their
370
+ first steps share those facts, which are listed once.
371
+ - `examples/taylor_reconstruction.py`: sumpy's compressed Taylor
372
+ reconstruction from the theorem to the kernel to the schedule, in one
373
+ file and one ledger, the seed of the demo's flagship.
374
+ - A matched theorem's family of families applied one index at a time,
375
+ `R(a)(b)`, stands for an array indexed at once, `R[a, b]`, for a program's
376
+ cited theorems too.
377
+ - **Window contraction** (`Schedule.contract`,
378
+ [#132](https://github.com/xywei/loopty/issues/132)): an array a program
379
+ makes, kept in the cells live at once. Fused one step behind, the Burgers
380
+ divergence reads `f[j]` and `f[j - 2]` at step `j`, so
381
+
382
+ ```python
383
+ fused = Schedule(burgers_rhs).fuse("flux", "divergence", shift=1)
384
+ fused.contract("f") # lambda j: j % 3, double f_window[3]
385
+ ```
386
+
387
+ keeps three cells and computes each flux once, where `substitute("f")`
388
+ computes it twice. A placement is a Python function from a cell's indices
389
+ to its slot, quasi-affine, given (`contract("f", lambda j: j % 3)`) or
390
+ found, axis by axis, outermost first, along the order as it stands (one
391
+ row of the sum-factorized passes' `v`, `lambda e, a, b: b`). The step is
392
+ legal when every read is of a cell the producer stored before it (the
393
+ strict `definedness` fact a substitution asks) and no read sees its slot
394
+ overwritten (a new `no-clobber` fact, decided by isl over the schedule's
395
+ order, refuted with the read, the cell it reads and the instance that
396
+ overwrote its slot, `contract('f', lambda j: j % 2) illegal: instance
397
+ divergence.S0[i=14] reads f[13], which flux.S0[j=13] stored in slot 1,
398
+ after flux.S0[j=15] stored f[15] there`). From then on every pair of
399
+ accesses to one slot is a dependence of the schedule, in the order they
400
+ ran, so a later step that breaks the window, or puts its slots on two work
401
+ items, is refused with its pair. Two lowerings: modulo addressing,
402
+ `f_window[j % 3]`, and a shift register (`registers=True`, the default on
403
+ OpenCL) whose every access has a constant index, the window shifted down
404
+ by one at each step of its loop, which a device keeps in registers. The
405
+ zeros the program made the array with are dropped, as a substitution
406
+ drops them. Lean states `no-clobber` from loopty's description: three
407
+ instances through every step's map, the placement with `%` by a number,
408
+ and the time vectors of the order. `examples/fusion.py` and
409
+ `examples/sum_factorization.py` show it; an array its producer reads, or
410
+ that two statements store, and a window per work item are not done (#213,
411
+ #214). A refused window names the fix, `contract('f') finds the smallest
412
+ window that keeps every read, lambda j: j % 3`, and a step that breaks one
413
+ the instance that would now overwrite a slot first. A window and a
414
+ substitution of an array computed from it are refused together, in either
415
+ order (#229): the substitution moves the window's reads. Every window of the
416
+ tests computes the bits the array stored in full does, on C and on an
417
+ OpenCL device, and a shift register inside an outer loop shifts in each of
418
+ its steps.
419
+ - **Checked layout maps** (`loopty.layout`): a layout is a Python function
420
+ from a cell's indices to its address, whose annotations say which cells
421
+ (one `Fin` per axis, or one parameter over a `Where`) and which buffer
422
+ (the return annotation), and `layout(first, second)` is a layout of
423
+ pieces:
424
+
425
+ ```python
426
+ @layout
427
+ def dof_array(e: Fin[ne], a: Fin[N], b: Fin[N], c: Fin[N]) -> Fin[ne * N**3]:
428
+ return N**3 * e + (a + N * b + N**2 * c)
429
+
430
+ as_dim_tags = dof_array.same_as("N3,N0,N1,N2") # loopy's dim_tags
431
+ ```
432
+
433
+ `lanky check` asks three facts of each layout, every cell at an address of
434
+ its buffer, no two cells at one address, and a cell at every address, and
435
+ one of a `same_as` claim, every cell stored where the other layout, or
436
+ loopy's `dim_tags`, stores it; isl decides each and Lean proves it, the
437
+ third with isl's inverse of the layout, in pieces with `//` and `%`, as
438
+ its witness. A layout of one piece is called as its function, so a kernel
439
+ reads through it; a call whose address is not the one the facts are about
440
+ (a constant or a helper the function computes with changed since it was
441
+ declared) is refused, with both addresses.
442
+ `examples/storage_layouts.py` checks sumpy's
443
+ compressed-Taylor storage index in two pieces, and grudge #418's
444
+ `N3,N0,N1,N2` against modepy's F-order reshape of the DOFArray; the
445
+ reshape in C order, `N3,N2,N1,N0`, is refuted with a cell, in words:
446
+ `dof_array stores (e=0, a=1, b=0, c=2) at 19, and dim_tags N3,N2,N1,N0 at
447
+ 11`; so are two cells at one address and an address with none. A buffer of
448
+ several axes, and an argument that names its layout, are not done (#216).
449
+ - **A kernel called inside another kernel's loop** (`loopty.calls`). A
450
+ kernel body calls a kernel as Python calls a function. A kernel whose
451
+ return annotation is a sort is a rule: it returns its value and writes
452
+ none of its parameters, and a call of it is that value; a kernel that
453
+ writes its parameters is called as a statement and writes through what it
454
+ is given:
455
+
456
+ ```python
457
+ @kernel
458
+ def dot(a: Arr[Fin[n], Real], b: Arr[Fin[n], Real]) -> Real:
459
+ return reduce_sum(a[k] * b[k] for k in a.dom)
460
+
461
+ @kernel
462
+ def matvec(A: Arr[Fin[m], Fin[n], Real], x: Arr[Fin[n], Real],
463
+ y: Arr[Fin[m], Real]):
464
+ for i in y.dom:
465
+ y[i] = dot(A[i], x)
466
+ ```
467
+
468
+ `A[i]` is the row of `A` and `x[col[r]]` the cells of `x` a row of
469
+ indices picks, as in numpy, so a sparse product is `y[r] = dot(val[r],
470
+ x[col[r]])`; a row of a ragged array, of a table, or a whole array over a
471
+ domain may be passed too. A parameter of a function sort, `f: Fn[Fin[3],
472
+ Real]`, takes a `lambda` or another rule, so that a quadrature rule is
473
+ applied to an integrand, `integrate(lambda q: PHI[q, j] * PHI[q, k])`.
474
+ Traced, the call is inlined: the callee's body runs again in the caller's
475
+ trace against views of the caller's arrays, its loops and sums nested in
476
+ the loops around the call, a binder named like a name of the caller's
477
+ given one of its own (`k_0`), which is loopy's substitution rule with a
478
+ domain of its own (loopy #1044) expanded where it is used, and every fact
479
+ of the caller is about the expansion. The shapes are unified at the call,
480
+ size by size, and a call whose sizes nothing says agree is refused, naming
481
+ the axis. What the callee's contract checks of each call natively, the
482
+ caller owes at every point it calls, as facts isl decides and Lean states
483
+ from loopty's own description of the call: each row passed in bounds
484
+ (`A[i], the row passed to dot as a, is in bounds wherever dot is called`),
485
+ the cells a gather picks in bounds by type, an integral scalar in its sort
486
+ (kind `argument`), two rows of one array apart where the callee writes
487
+ one (kind `distinct-arguments`); and after each call the callee's
488
+ postcondition, restated of what the call passed and resting on the
489
+ callee's own fact (kind `postcondition-in-scope`). An argument is
490
+ evaluated once, when the call is made, as in Python: every cell read for
491
+ the value of a scalar argument, or for the indices of a row (`p[i]` of
492
+ `A[p[i]]`), is owed in bounds at every point of the call, whether the
493
+ callee uses the parameter or not, and a call whose
494
+ callee writes an array an argument reads (`scale(A[i], A[i, 0])`) is
495
+ refused, naming both; so is a function given whose default or captured
496
+ name holds a value read from such an array (`lambda q, saved=x[0]:
497
+ saved`), which natively is the value when the function was made. A
498
+ scalar argument is converted into the sort the callee declares, as its
499
+ contract converts it natively (#232): a double given for `s:
500
+ np.float32` is `np.float32(x[i])` in the caller's term, which the
501
+ interpreter computes as numpy does and the lowering writes as C's cast,
502
+ and a loop variable given for `Int` is a numpy `int64` beside a
503
+ `float32`, while an index or a comparison of it reads as the caller's
504
+ own, `A[i, i]`. What the contract refuses is refused where it is passed:
505
+ a real or a truth value for an integral sort, a number for `Bool`, a
506
+ complex value for a real sort (naming `real(z[i])`), a literal numpy
507
+ does not convert (`300` for `np.int8`), and, when the compiled run is
508
+ asked for, loop arithmetic a narrow integer does not hold where the call
509
+ is made. A callee's positional-only parameters are passed in order
510
+ (#233). A rule may be applied to a function that applies it again, `integrate(lambda q: integrate(lambda p: ...))`; a kernel that
511
+ calls itself, directly or through the functions it is passed, is
512
+ refused. A rule on its own is the kernel that
513
+ writes its value into a one-cell array, `result[0]`, which `loopty run`
514
+ compiles and compares with the native call; one that takes a function is
515
+ compiled where it is called. Natively, a call inside a `when` whose guard
516
+ is false is not made, and a rule's value there is `0`, so the value of a
517
+ rule called inside a `when` block is refused where it is used after the
518
+ block. `examples/calls.py` has `dot` in a matrix-vector
519
+ and a sparse product, and firedrake's P1 quadrature rule applied per
520
+ element. A callee's update, `y[cell[k]] += yl[k]`, is an update of the
521
+ caller, of the class of its sum and owing no distinct writes, as written
522
+ inline: a step that runs two at once is refused with a pair of the
523
+ caller's loop and the callee's read off the example inputs (`S0[c=0, k=2]
524
+ and S0[c=1, k=1] both update y[3]`), and `atomic('y')` on the caller
525
+ lifts it. An integer a callee is given for an `Int`, `np.int64(c)`,
526
+ keeps such an update exact. A window (`Schedule.contract`) is taken over
527
+ the statements of the kernels a program's kernels call, and its
528
+ `no-clobber` fact is about them; a callee reads through a declared
529
+ layout, which is checked against its facts where it is called.
530
+ - **A program's arrays over a domain** (#74). A program's callee may take
531
+ an array over a `Where`, `Sigma` or union domain: the domain's sizes are
532
+ unified across the calls, binder by binder, and two calls given one array
533
+ have to agree on its points, compared as isl sets. A program may make an
534
+ array over a domain, like an argument over one, `Arr.zeros_like(L)`, or
535
+ of a declared shape, whose sizes it reads off its arrays as it does
536
+ natively:
537
+
538
+ ```python
539
+ @program
540
+ def pair_sums(x, e):
541
+ f = Arr.zeros(TRIANGLE, n=x.dom.size)
542
+ fill(x, f)
543
+ rowsum(f, e)
544
+ ```
545
+
546
+ It is a temporary of the lowered kernel, stored boxed and zeroed over its
547
+ points where the program makes it, its edges have definedness facts as a
548
+ dense temporary's do, and a schedule fuses through it; `Schedule.pack`
549
+ stores a program's argument over a domain packed. A declared dense shape
550
+ is `Arr.zeros((x.dom.size, 3))`, and a run whose sizes make one of its
551
+ lengths negative is refused compiled, as numpy refuses it natively.
552
+ `Schedule.contract` may keep a dense one in a window; one over a domain is
553
+ refused there, naming the fix, keep it whole or `substitute` it
554
+ ([#244](https://github.com/xywei/loopty/issues/244)).
555
+
556
+ - **Constant tables** (`loopty.table`): quadrature weights, a basis
557
+ tabulated at the quadrature points, a differentiation matrix, declared
558
+ next to the kernel as the numpy array they are, `W = table([1 / 6, 1 / 6,
559
+ 1 / 6])`, and read by the body as an array is, `W[q]`, `W.dom`. Natively a
560
+ table is a read-only copy of its values. Traced, it is recorded in the
561
+ term (`Term.tables`) under the name the body reads it by, and each read is
562
+ an access whose in-bounds fact isl decides over the table's shape. Lowered,
563
+ it is a read-only loopy temporary with the values as its initializer, which
564
+ loopy writes once with every digit, `static double W[3] = {
565
+ 0.16666666666666666, ... };` on the C target and `__constant` on OpenCL, so
566
+ both runs read the same numbers; the interpreter reads them too. Complex,
567
+ single precision and integer tables are read in their own dtype. A table
568
+ read in a helper of the body is the kernel's, named as it is declared, a
569
+ program holds one table for every call that reads it, and a table named
570
+ like a parameter or a size of the kernel is named apart, `y_table`.
571
+ Writing one is a `TypeError` in both runs. Natively a table is read as an
572
+ array is: an index is never counted from the end, and a read out of range
573
+ under a false `when` answers zero. A table holds finite numbers (#193), in
574
+ a type the compiled kernel has (not `float16` or `longdouble`),
575
+ is no argument (an array of its values is,
576
+ `Arr.from_numpy(W.values.copy())`), and is read in a statement's values,
577
+ not in a loop's bound (#194); each of these is refused with the fix named.
578
+ A compiled run refuses a read-only array for a parameter it writes, such
579
+ as a table's `W.values`, which it wrote into before failing. On OpenCL a
580
+ complex table is an array of the kernel's own, since pyopencl writes its
581
+ values as calls, which a `__constant` array cannot be initialized by.
582
+ - **`loopty.math`**: `sqrt`, `exp`, `log`, `sin`, `cos`, `real`, `imag` and
583
+ `conj` for kernel bodies. Each is numpy's natively and a call the
584
+ trace records, which the interpreter evaluates with the same numpy function
585
+ and the lowering writes as the target's library function, `csqrt` and
586
+ `creal` of a complex number on the C target and pyopencl's `cdouble_sqrt`
587
+ and `cdouble_real` on OpenCL, in the type numpy computes it in: `sqrt` of
588
+ an integer is a double (`sqrt((double) (k[i]))`), where loopy refused it on
589
+ the C target and took it for a `float` on OpenCL, and `real`, `imag` and
590
+ `conj` of a number that is not complex are the number, `0` and the number,
591
+ where loopy wrote `creal` of a double. A function numpy computes in half
592
+ precision, `sqrt` of an `int8`, is a `TraceError` naming `sqrt(1.0 *
593
+ k[i])`. pytential's line-Taylor single layer over complex points,
594
+ `real(log(c[t] - y[s]) - ...)`, runs, checks and compiles as any kernel
595
+ (`examples/layer_potential.py`). A postcondition may call them too, `->
596
+ all(y[i] == sqrt(x[i]) for i in Fin[n] if x[i] >= 0)`, and is tested after
597
+ each native run with numpy's, where it was left `assumed`, not evaluated.
598
+ `examples/p2p.py` calls `loopty.math.sqrt` where it built the call by hand.
599
+ A value of one compares and combines as any traced value does,
600
+ `when(sqrt(x[i]) > 1.5)`. numpy's own functions, Python's `math` and
601
+ `x[i].real` take numbers: a body that calls them on a traced value is
602
+ refused at its line, with the `loopty.math` function to write (#195),
603
+ where it failed far from the body (numpy's "loop of ufunc does not support
604
+ argument 0 of type Subscript", pymbolic's `UnknownVariableError`).
605
+ - **A sum's loop ordered outside a loop of its statement**
606
+ (`Schedule.prioritize("s_outer", "t_inner")`). sumpy's CPU tile of
607
+ a layer potential splits the targets by 16 onto a group and its work
608
+ items, splits the sources by 256, and runs the sources' outer loop
609
+ outside the work items' loop; `prioritize` and `interchange` refused the
610
+ sum's loop as no loop of the kernel. A loop of a sum named before loops
611
+ of its statement now runs outside them, each sum still adding its terms
612
+ in its own order, so nothing is reassociated; a loop the sum runs outside
613
+ of orders none of the instances in it, so a dependence between two of
614
+ them is refused with its pair, as one across an `ilp` loop is. loopy runs
615
+ the order only where it keeps an accumulator per instance, on a hardware
616
+ axis or an `ilp` loop (`acc[16]` on the C target); on a loop that runs in
617
+ sequence it keeps one and drops the order, and the schedule's `buildable`
618
+ fact is refuted with that reason and the tag that fixes it. Two loops of
619
+ one sum are never reordered, and an inner one never runs outside a loop
620
+ its outer one does not.
621
+ - **Fusion on chosen outer loops** (`Schedule.fuse(..., loops=1)`). A
622
+ fusion took every loop both sides have, so two passes over elements, the
623
+ second of which reads every cell of the element the first wrote (a
624
+ sum-factorized operator, one reference axis per pass), could not be fused
625
+ at all: fused on the element and the row, the second pass reads a cell of
626
+ the element before the first stores it. `loops` fuses only that many of
627
+ the outer loops and leaves the loops inside them one after the other, the
628
+ producer's first, checked as every fusion is. A refused fusion now names
629
+ each fusion it checked that is accepted: the least shift at the same
630
+ depth, as before, and the deepest fusion of fewer loops, `fuse('along',
631
+ 'along@2', loops=1)`.
632
+
633
+ - **isl decides, Lean checks** (`loopty.description`, `loopty.obligations`).
634
+ Every fact isl decides about a kernel, a program or a schedule's step is
635
+ also stated as lanky terms for lanky's Lean oracle, which proves it with
636
+ `omega`; where Lean is installed such a fact reads `proved` by `lean`, and
637
+ where it is not, nothing changes. The statements come from loopty's own
638
+ description of the kernel and never from a set isl computed: every set
639
+ loopty builds keeps the text it was written from, on the set itself
640
+ (`description_of`), with each call's renaming, and every schedule step a
641
+ record of its maps as written (`Schedule._records`, `loopty.schedule.Step`).
642
+ In bounds, distinct writes, element sorts, the source order, a cast's
643
+ bijectivity and its legality (one part per dependence class), and what a
644
+ program's call may assume (requirements and flat accesses under the
645
+ hypotheses isl used) are stated. isl's work is used as hints Lean checks:
646
+ its functional form of a step's map as the witness that the step is total,
647
+ its `lexmax` pieces as the witness of a definedness, the instances it took
648
+ of a universal hypothesis, and its counterexample to a refuted fact, which
649
+ Lean proves false. A refuted fact is offered to Lean only as that
650
+ counterexample, and one with none Lean can check (a refused bijection, say)
651
+ is not offered at all, so that a proof never reads `proved` where isl
652
+ refused. A map given to `Schedule.affine` as isl writes it (`2 t + i`,
653
+ `floor(i / 4)`) is read back too. Facts loopty states for isl alone (a
654
+ rewritten layout, the casts after a substitution, a cast with a hardware
655
+ axis) say so. `docs/lean-checks.md` explains it, and
656
+ `scripts/lean_coverage.py` reports what Lean proves of every example and
657
+ test kernel.
658
+ - `loopty run` asks Lean about each fact isl decided that it reports (the
659
+ casts, a layout's facts), names what Lean did not prove under the table
660
+ (with `--verbose`, also what Lean was not asked about, and why), and prints
661
+ the Lean with `--show-lean [TEXT]`; `loopty check --show-lean` passes it to
662
+ `lanky check`.
663
+ - **Definedness inside one kernel.** An array a kernel reads only at cells
664
+ an earlier instance of its own wrote, as `scan` reads `off[r]`, gets a
665
+ `definedness` fact where isl shows it, stated for Lean as a ∀∃ with isl's
666
+ last writer as the witness. A kernel that reads what an array held when it
667
+ was called gets none, and a write under a guard isl cannot state
668
+ (`with when(x[i] > 0)`) counts for nothing, since its domain is wider than
669
+ where it stores. A program's definedness of an edge is stated the same
670
+ way.
671
+ - **Mixed subscripts.** An access whose indirect axes are indexed by a value
672
+ of their own sort and whose other axes are affine, `X[cell[c, k], d]`, is in
673
+ bounds by type on the first and decided by isl on the rest, where it was
674
+ `assumed`; Lean states the whole access with the type as a hypothesis.
675
+ - **`Schedule.assume(condition)`**: a condition of the sizes, `"n % 4 == 0"`,
676
+ taken as given, as loopy's `assume`. loopy is told, every later cast is
677
+ decided and stated where it holds and rests on an `assumption` fact, and a
678
+ call whose sizes break it is refused before anything is compiled.
679
+ - **Updates, a scatter-add: `y[cell[c, k]] += yl[c, k]`.** A statement
680
+ that adds to the cell it writes a value that reads nothing else of the
681
+ array is an update (`Stmt.exactness`). Its sum has the class of what it
682
+ adds, as a reduction's has: `approx` for `Real`, `exact` for integers. Two
683
+ updates of one cell give the same sum in either order, so they are no
684
+ dependence of each other (`loopty.flow.dependences`), and a schedule may
685
+ reorder them, which it records as a reassociation, as `realize` does. An
686
+ update whose instances can add to one cell owes the class of its sum, `the
687
+ accumulation into y[cell[c, k]] over c, k is approx`, instead of distinct
688
+ writes, which were refuted before: a correct assembly loop or row sum
689
+ written with `+=` now checks clean. An update of a cell of its own at
690
+ every instance, `y[i] += a * x[i]`, still owes distinct writes.
691
+ - **`Schedule.atomic("y")`.** Two updates of one cell that a step runs at
692
+ the same time, on two work items or under `ilp` or `vec`, can lose one of
693
+ the two values. Such a step is refused with the pair and the fix:
694
+ `tag(c_outer='g.0', c_inner='l.0') illegal: S1[c=0, k=2] and S1[c=1, k=1]
695
+ both update y[3] on example_inputs() (nc=2, nv=4), where cell[0, 2] = 3
696
+ and cell[1, 1] = 3, ...; make the updates atomic, with atomic('y') before
697
+ this step`. After `atomic("y")` the step is accepted, it records `atomic
698
+ updates of y run at the same time after tag(...)`, and `y` is compared at
699
+ `reassoc`. On OpenCL each update is loopy's `AtomicUpdate`, a
700
+ compare-and-swap loop, and every other access to `y` is atomic too, as
701
+ loopy requires. loopy has atomic updates of `int32`, `int64`, `float32`
702
+ and `float64` only; any other dtype is a `buildable` fact refuted. On the
703
+ C target, which runs in one thread, the code is unchanged. `atomic`
704
+ refuses an `exact` accumulation, as `realize` refuses a tree of one
705
+ (#200), and an array nothing updates, naming what each statement that
706
+ writes it does instead. `examples/assembly.py` is PyOP2's P1 mass action:
707
+ its GPU split refused with the node PyOP2's two triangles share, accepted
708
+ with atomics, and run on the PoCL CPU device (`docs/device-runs.md`).
709
+ An update may read a window (`Schedule.contract`), `y[node[i]] += f[i +
710
+ 1] - f[i - 1]`, and `atomic` does not lift the order of the window's
711
+ slots; a window of an array that is updated is refused, since the
712
+ updates add to the zeros a window drops, with the fix: `contract('f'):
713
+ added_flux.S0 updates f[j] in place, ...; store each cell once, f[j] =
714
+ ... (a reduce_sum where the updates run over a loop), or keep f whole`.
715
+ - **Refusals name a pair from the data.** isl cannot follow a cell read
716
+ from an array, `y[cell[c, k]]`: it takes it to be any cell, so the pair it
717
+ picks need not share one. A refused cast through such a cell now names two
718
+ instances that touch one cell on an input, with what the subscripts read
719
+ there. The inputs are tried in turn: the schedule's own example inputs,
720
+ the module's `example_inputs()`, then drawn ones. Where none has such a
721
+ pair, the message says the pair is isl's, naming the subscript isl cannot
722
+ follow; so it does for a read a substitution moves (`substitute('f')
723
+ reads y where f was read, which no run of an input shows`), which no
724
+ input's run makes. `loopty.interpret.interpret(..., log=...)` records the
725
+ cells each instance touches.
726
+
727
+ ### Changed
728
+
729
+ - **The README opens with a front page**: what loopty is to someone who
730
+ writes loopy kernels, the flagship `examples/l2p.py` in its three layers
731
+ with its refusals and six rows of its ledger with Lean and Mathlib, the
732
+ five demos in a table (what each is written after, what its ledger has,
733
+ how it compares), how to run them, and where the limits are.
734
+ The reference below it is as it was, the spmv walk-through under its own
735
+ heading and the paragraphs the front page now says shortened. Run it and
736
+ Install install the 0.3.0 releases from PyPI and clone the `v0.3.0` tag
737
+ for the examples.
738
+ - **Requires lanky 0.3.0** (`lanky>=0.3.0`, was `>=0.2.0`). Its Lean oracle
739
+ proves a plugin's parts with the solver's hints, which is how loopty
740
+ states each fact isl decides, and its families take several indices,
741
+ `D[a, b]`, and complex numbers apart, which the demos' theorems are written
742
+ with. The tests that skipped on lanky 0.2.0 (the demos `l2p`, `hexahedra`,
743
+ `advection` and `layer_potential`, the benchmark driver's test, the
744
+ statements for Lean, rewrites by an identity) no longer skip, so CI's job
745
+ against the released lanky runs them. `[tool.uv.sources]` still resolves
746
+ lanky from `../lanky` in a checkout, and the built distributions ask for it
747
+ from PyPI.
748
+ - **The interpreter finds a sum's points once, not at every instance**
749
+ (lanky #125). The faithful check runs each kernel's term through
750
+ `loopty.interpret`, and a reduction asked isl for its points at every
751
+ statement instance: it fixed the loop variables, the domain's parameters
752
+ and enumerated. Now a reduction's domain is enumerated once per run at
753
+ the values of its parameters, its points kept by the loop variables each
754
+ instance fixes, and where it is the loops' points times the sum's (a sum
755
+ over `Fin[n]` in a loop nest), as its two factors. Each instance reads its
756
+ own points, in the same order, and the work limit is checked on them by
757
+ their bounding box as before. The rule for a node is found once per type.
758
+ `lanky check examples/hexahedra.py` spends 7 s in the interpreter where it
759
+ spent 31, and takes 12 s of CPU where it took 38 without Lean; every
760
+ example's ledger, JSON included, is what it was.
761
+ - **`loopty run` says what a row lacks to be proved**, with lanky's
762
+ `WITHOUT LEAN` and `WITHOUT MATHLIB` lines under its table, and sizes the
763
+ table's statement column to the terminal, as `lanky check` does, where the
764
+ lanky it runs with has them. The transcripts show each statement whole,
765
+ wrapped under its row.
766
+ - A refusal names each instance of its pair by the statement as the source
767
+ writes it, the line it is on, and its coordinates by the names the source
768
+ gives its loops (#260). The flagship's interchange reads `R[i, a, b] =
769
+ C[i, a, b] (l2p.py:276) at i=0, a=1, b=9 writes R[0, 1, 9], read by R[i,
770
+ a, b] = -R[i, a - 2, b + 2] (l2p.py:278) at i=0, a=3, b=7, scheduled
771
+ earlier`, where it read `instance S0[i=0, a=1, b=9] writes R[0, 1, 9]
772
+ read by S1[i=0, a=3, b=7] scheduled earlier`; S3's race, `y[cell[c, a]]
773
+ += reduce_sum(...) (assembly.py:269) at c=0, a=2 and at c=1, a=1 both
774
+ update y[3]`. A long statement keeps what it writes into and shortens the
775
+ calls on its right, `reduce_sum(...)`; an instance of the statement just
776
+ named is `the same statement at t=1, i=7`. The same holds for every
777
+ refusal that names a pair (of `tile`, `interchange`, `skew`, `affine`,
778
+ `fuse`, `tag`, `contract`, `substitute`), and for a disjoint-writes fact
779
+ refuted on an input, and the fix each names is kept. A pair on two work
780
+ items says where each access runs by what it does, `the write is in a
781
+ sum, on every work item of l.0`, where it named the statement a second
782
+ time. A fact's `witness` keeps the ids, `S0[t=0, i=8]`, and so does a
783
+ term written by hand, which has no source.
784
+ - A schedule's facts are at the line of the step that made them, the
785
+ `.split(...)` or the `.assume(...)` of a chain, and its run at the line
786
+ the schedule is finished, its last step (#259). They were all at the
787
+ kernel's first statement, which for S3's `mass_action`, whose first
788
+ statement is the one of `mass_ffc` it calls, was a line in `mass_ffc`.
789
+ The run of a kernel the file does not schedule is at the kernel's
790
+ definition.
791
+ - A sum over a `Fin` of a number, which the trace adds up term by term, has
792
+ the facts of its terms' accesses stated once, over its binder, as the
793
+ source writes the access (#181, #223): S1's `contravariant` reads
794
+ `G[3*x + d, e, k, j, i] is in bounds for every x in Fin(3) and every
795
+ instance of S0`, where it listed `G[0 + d, ...]`, `G[3 + d, ...]` and `G[6 +
796
+ d, ...]`, and the three sums over `c` of `gradient_along_t` are one
797
+ exactness row, `for every d in Fin(3)`. The term, and the code, still
798
+ add the terms one by one. S1's ledger is 80 facts where it was 96, with
799
+ the same verdicts.
800
+ - **A cast's fact id leaves out the target.** What a step means is the same
801
+ on every target, so the id of a cast's facts is the kernel's definition and
802
+ the steps (`cast:spmv.spmv@102:split('j', 2, ...):bijective`), and a
803
+ schedule retargeted (`fused.retarget("opencl").tag(i="g.0")`) shares the
804
+ facts of the steps it replays with the one it came from: a ledger lists
805
+ each fusion once. A `buildable` fact and a run's agreement keep the
806
+ target (`agreement:...:[c].split(...)`).
807
+ - **A substitution through an array computed from inputs alone.**
808
+ `Schedule.substitute` computes an array where it is read from what its
809
+ producer read, and refused one whose producer reads an array an earlier
810
+ substitution computes where it is read, since the dependences of that
811
+ array's own reads were carried to a statement that no longer runs. Where
812
+ that array is computed from arrays nothing in the program writes (its
813
+ inputs), there are none, and the chain is substituted: grudge's `v.n`
814
+ from the normals, read by the upwind flux, read by the lift
815
+ (`examples/advection.py`). A chain through an array something writes is
816
+ refused as before.
817
+
818
+ - `contract` of a program's array over a domain names `substitute` as the
819
+ other fix only where one statement stores the array, which is what a
820
+ substitution computes again; the flagship's triangles, which two
821
+ statements store, are refused with `keep it whole`.
822
+
823
+ - A kernel's postcondition that its statements' stores decide reads
824
+ `decided isl` where it read `tested native`, after a new `single-assignment`
825
+ row, and the restatements of it in programs are worth `decided`; the
826
+ ledger of a file with schedules at module level lists their casts, a
827
+ window's `no-clobber` and an atomic step's reassociation among them. The
828
+ `definedness` fact a substitution or a window asks of a schedule leads
829
+ with its step, `for substitute('f'), every cell of f that burgers_rhs
830
+ reads, flux.S0 has stored by the time it is read`, so that the two rows of
831
+ `examples/fusion.py`, one per step after one fusion, read apart. The
832
+ transcripts in the documentation are regenerated.
833
+ - `loopty.hypotheses.discharge` names a universal hypothesis's binders apart
834
+ from the claim's own names before taking it at the claim's cells: an
835
+ instance at the claim's `r` of a hypothesis over `r` was left out, which
836
+ lost a verdict.
837
+ - Where Lean is installed, a rewritten layout's facts are proved
838
+ ([#186](https://github.com/xywei/loopty/issues/186)): one decided by the
839
+ row starts is stated as the induction step, each write of the offsets
840
+ storing the value its cell holds where they start at 0 and step by the
841
+ counts, as the contract checks on entry; one decided by induction over the
842
+ run, as each write's question under the order before it. And a
843
+ substitution's `definedness` fact and the casts after a substitution are
844
+ proved ([#187](https://github.com/xywei/loopty/issues/187)): the reads a
845
+ substitution computes again are stated as reads of what the producer read,
846
+ at the instance that stored the cell, ordered against every write of it,
847
+ and the statements it took out are in no class. So every fact isl decides
848
+ about the examples is now stated for Lean, and the coverage report in
849
+ `docs/lean-checks.md` is rerun. A claim decided under hypotheses is given
850
+ up to 32 of isl's instances of them, where it was given 16.
851
+ - `substitute` refuses an array kept in a window, and `contract` one
852
+ computed where it is read.
853
+ - The coverage report in `docs/lean-checks.md` covers the demos' examples
854
+ (`l2p.py`, the flagship, `assembly.py`, `hexahedra.py`,
855
+ `layer_potential.py`, `advection.py`, `quadrature.py`,
856
+ `sum_factorization.py`), `storage_layouts.py`, `taylor_reconstruction.py`,
857
+ the calls in loops (`examples/calls.py`, `tests/kernels/calls.py`,
858
+ `tests/kernels/program_domains.py`), the upstream test kernels
859
+ (`tests/kernels/upstream.py`), the test kernels of programs'
860
+ postconditions, of reals at their class and of series
861
+ (`tests/kernels/program_posts.py`, `reals.py`, `series.py`), and the
862
+ windows, updates, layouts, postconditions decided from what kernels store
863
+ and cited theorems above: of 1584 facts isl decides in 34 files, Lean
864
+ proves 1521 and confirms the counterexample of 37 of the 38 refuted ones;
865
+ the other, two updates of one cell at the same time in `advection.py`, is
866
+ stated for isl only. The 25 it leaves decided are claims about reals,
867
+ which core Lean has no words for; with Mathlib it proves them too, 1546 in
868
+ all, and none stays decided. `examples/layer_potential.py` has 32, all
869
+ proved with Mathlib. `examples/l2p.py` has 48: 46 proved with Mathlib, and
870
+ its refused interchange and window of one row, whose negations Lean
871
+ proves.
872
+ - A ledger gains a `definedness` row for each array a kernel reads only where
873
+ it wrote it before (`scan`'s `off`, `p2p`'s `term`, `pairs`' `f`), and an
874
+ access with mixed subscripts is decided where it was assumed; the
875
+ transcripts in the documentation are regenerated.
876
+ - The questions isl answers (`Empty`, `Subset`, `Bijective`, `Monotone`)
877
+ carry a `lean` field, how the fact is stated for Lean, and a schedule's
878
+ cast facts have a `Cast` as their term where they had none (a
879
+ substitution's definedness fact an `IslOnly`, which says why Lean is not
880
+ asked).
881
+ - The tests run with lanky's Lean oracle off, as CI runs them, but the ones
882
+ marked `lean` (`tests/test_lean_checks.py`), so that the suite passes where
883
+ Lean is installed and proves what the other tests read as `decided isl`.
884
+ Those hold the printer to its safeguards: non-vacuity of every part of
885
+ every example and test kernel, arrays given as tables; a mutation matrix of
886
+ the stencil's subscripts, guard and schedules, where Lean has to fail on
887
+ exactly the dependence classes isl finds reversed; and a differential of
888
+ every domain and every in-bounds goal's cells against isl's membership.
889
+ - A cell set's dimensions are named apart from the loops of the access it is
890
+ built for, so that a ragged row's bound written in a loop called `a0`
891
+ reads back as that loop.
892
+ - A step that runs two updates of one cell at the same time is refused with
893
+ a `disjoint-writes` fact of the step, where a `monotone` fact named the
894
+ pair before. That fact is stated for isl only, with the reason
895
+ (`docs/lean-checks.md`, #201). A `monotone` cast is stated for Lean without
896
+ the pairs of updates of one cell, which need no order; a cast with no pair
897
+ left is one trivial part.
898
+ - Fewer indices than an array has axes, `A[i]`, are a row of it in a kernel
899
+ body, and an array of indices, `u[cell[c]]`, the cells it picks: a view a
900
+ called kernel takes, natively the row as an `Arr` sharing the buffer and
901
+ the gathered cells as a read-only copy, where both were refused as an
902
+ operation on the whole array. Used as a value, a view is still refused,
903
+ with the loop nest to write. A row of the cells a gather picks,
904
+ `u[cell][c]`, is `u[cell[c]]`, as numpy reads it. A table's row, `PHI[q]`,
905
+ reads natively as a read-only array, and as zeros out of range under a
906
+ false guard, as a cell does.
907
+ - In a program's body, `x.dom.size` (and `x.dom[0].size`) is the size of an
908
+ axis of the argument, for the arrays it makes; iterating `x.dom` is still
909
+ refused, as a loop whose trip count is an argument. A kernel's contract
910
+ reads a size of its declared domain off the bounds of an argument's
911
+ binders, where the argument was built over a domain of other names.
912
+ - A kernel that returns a value without declaring its sort, `-> Real`, is
913
+ refused when traced, naming the sort to declare; its value was dropped by
914
+ the compiled kernel.
915
+ - Two array parameters a kernel only reads may share storage, `dot(x, x)`
916
+ or the diagonal of a Gram matrix: reads do not race, and both runs read
917
+ the same values. The contract still refuses two that share storage where
918
+ the kernel writes either; a native call traces the body to know which
919
+ it writes, only when two of its arguments share storage.
920
+ - A schedule step that names no loop of the kernel lists its loops, and
921
+ names a called kernel's loop that was renamed when it was inlined:
922
+ `fill's loop n, inlined at ..., is n_0 here`.
923
+
924
+ ### Fixed
925
+
926
+ - **An `ilp` loop around a sum runs its lanes in one loop of the sum
927
+ (#264).** A sum's domain named every loop around its statement, and loopy
928
+ opens a sum's loop inside every loop its domain names, so
929
+ `split("q", 3).tag(q_inner="ilp")` around `reduce_sum(... for b in
930
+ Fs.dom[q, j])` gave each lane a loop over `b` of its own, and the lanes
931
+ ran one after another. The domain now names only the loops its bounds
932
+ read: over a box, the loop over `b` holds the three lanes' updates, as
933
+ plain loopy writes it, while a triangle's `j < i + 1` keeps its sum in the
934
+ loop it reads, and a ragged row's length keeps every loop. The facts are
935
+ the term's and do not change. The launches of S1's "fields three at a
936
+ time" shape are now plain loopy's code up to loop names, and every other
937
+ shape's code is what it was; sumpy's tile on C in
938
+ `examples/layer_potential.py` runs the sources' loop once around its 16
939
+ targets, where it ran it once per target. Note 32 in
940
+ `docs/loopy-notes.md` has the details.
941
+ - **A program's zeros on a device** (#255, #261). A program zeroes each array
942
+ it makes in a statement of its own, in loops the lowering names (`i_2`,
943
+ `i_5`), which ran on every work item: `fused.tag(i="g.0")` was refused at
944
+ `C.zeros`, and the schedule that was timed tagged `i_2` and `i_5`, names the
945
+ file never wrote. The zeros' loop along an axis of the array now goes on
946
+ the axis of the loop that every statement storing the array stores it
947
+ along, so each work item zeroes the cells it stores, before it stores them,
948
+ and the work-item check asks about the zeros as about any statement.
949
+ Where a crossing is a call's and the zeros', the call's is named. Zeros
950
+ that cannot follow (the array stored along a split loop, or along loops on
951
+ two axes) stay on every work item, and the refusal says why and that
952
+ `contract` or `substitute` drops them (#289). grudge's weak gradient in one launch,
953
+ every node on a work item of its own, is now refused for the row the pass
954
+ along `r` reads from other work items, not for the zeros of `w`.
955
+ - On OpenCL, a complex number to a natural power below 100 is the products
956
+ numpy's power by an integer takes (`z * ((z * z) * (z * z))` for 5), never
957
+ pyopencl's `cdouble_powr`. loopy writes only the powers by 2, 3 and 4 as
958
+ products, and by 1 or 5, which a written-out series has, `exp(n log z)`:
959
+ a logarithm and an exponential for each, and not exact for `z**1`.
960
+ - A schedule's `ilp` or `vec` tag no longer has loopy warn, on every build,
961
+ that it falls back to its first scheduler, which it does for every kernel
962
+ with such a loop: the tag silences `v1_scheduler_fallback` on the kernel
963
+ it makes. The warning opened `python examples/assembly.py` and
964
+ `python examples/layer_potential.py` on a fresh checkout.
965
+ - An array of several axes read only through an index array,
966
+ `coords[cell[c, k], d]` with `nv` in no loop bound, is declared with its
967
+ strides, `stride:2,stride:1`, so that loopy indexes its rows; it was
968
+ declared without a shape, and loopy refused the read.
969
+ - An access with mixed subscripts that a statement reads twice over one
970
+ domain, `x[0, 0]` read for two entries of an inlined Jacobian, is one
971
+ place, decided by isl; it was `assumed`.
972
+ - A schedule's assumption is named `assume('nc % 4 == 0')` in the status of
973
+ the casts that rest on it (with lanky's `label`), where its whole id was.
974
+ - **Two statements' atomic updates of one cell asked loopy for a global
975
+ barrier.** `y[p] += a[p]` and `y[p + 1] += a[p]` with `p` on `l.0` race
976
+ on `y[p + 1]`, and after `atomic("y")` the schedule is legal and its
977
+ `buildable` fact holds, but the lowered kernel kept the source's order of
978
+ the two, so loopy refused to generate it (`MissingBarrierError`). Once an
979
+ array's updates are atomic, the order is drawn again from the dependences
980
+ the casts are checked against, and two updates no dependence joins need
981
+ no order (loopy's `no_sync_with`): libParanumal's lift onto the face nodes
982
+ (`examples/advection.py`).
983
+
984
+ - A write through a cell read from an array, `y[perm[i]] = x[i]`, no longer
985
+ has its distinct writes refuted by isl with two instances that write no
986
+ common cell. The fact is decided on the kernel's native runs instead. It is
987
+ refuted with two instances that write one cell on an input, the example
988
+ inputs first: `S0[c=0, k=2] and S0[c=1, k=1] both write y[3] on
989
+ example_inputs() (nc=2, nv=4), where cell[0, 2] = 3 and cell[1, 1] = 3`.
990
+ Where no input has such a pair, as for `y[i, p[i]]`, it is `tested`.
991
+ - The first run of a kernel with an array of four axes over one size, or a
992
+ triangle boxed over two, no longer prints loopy's `ParameterFinderWarning:
993
+ Unable to generate code to automatically find 'm' from 'u': nonlinear
994
+ expression`, a warning about an equation of the strides loopy's invoker
995
+ could not solve, while it found the size from the shape (note 28 in
996
+ `docs/loopy-notes.md`). Five constructs kernels written like sumpy's L2P
997
+ and grudge's sum-factorized passes need, a
998
+ `Where` array beside a ragged family, a `Where` of three binders, `**` with
999
+ a loop variable as the exponent in a sum of two binders and in a sum
1000
+ nested in another, four dense axes and `when(a + b == 0)`, were run three
1001
+ ways and work; `tests/test_demo_shapes.py` keeps them so.
1002
+ - A split sum adds its terms in order. `split("s", 256)` of a sum's loop
1003
+ left loopy to choose the order of the two loops it makes, and loopy ran
1004
+ the inner one outside, `for s_inner ... for s_outer`, adding every 256th
1005
+ term first: another sum in floating point, `2**53` where the native run
1006
+ adds `1 + 1 + 2**53` to `2**53 + 2`. Every schedule now gives loopy a sum's
1007
+ loops in order, outermost first, as it gives it each statement's nest.
1008
+ - `fuse` after a `split` or a `tile` of the producer's outer loop is no
1009
+ longer refused as unbuildable. The producer's domain is cut after the fused
1010
+ loops by name, not by position: loopy 2025.2 puts a split loop's two after
1011
+ the others, `[j, i_outer, i_inner]`, so
1012
+ `Schedule(squared_rows).split(i, 2).fuse("square2", "row_total")` read
1013
+ `buildable` refuted though both casts were decided
1014
+ ([#192](https://github.com/xywei/loopty/issues/192)).
1015
+
9
1016
  ## [0.2.0] - 2026-10-11
10
1017
 
11
1018
  The second release, on lanky 0.2.0, which checks each theorem's reading of