kirchcig 0.2.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.
- kirchcig-0.2.0/LICENSE +21 -0
- kirchcig-0.2.0/PKG-INFO +303 -0
- kirchcig-0.2.0/README.md +265 -0
- kirchcig-0.2.0/kirchcig/__init__.py +35 -0
- kirchcig-0.2.0/kirchcig/_engine_cuda.py +357 -0
- kirchcig-0.2.0/kirchcig/_engine_numpy.py +297 -0
- kirchcig-0.2.0/kirchcig/_halfderiv.py +107 -0
- kirchcig-0.2.0/kirchcig/_kernels.py +434 -0
- kirchcig-0.2.0/kirchcig/_operator.py +716 -0
- kirchcig-0.2.0/kirchcig/_traveltime.py +231 -0
- kirchcig-0.2.0/kirchcig/_version.py +1 -0
- kirchcig-0.2.0/kirchcig/torch.py +110 -0
- kirchcig-0.2.0/kirchcig.egg-info/PKG-INFO +303 -0
- kirchcig-0.2.0/kirchcig.egg-info/SOURCES.txt +18 -0
- kirchcig-0.2.0/kirchcig.egg-info/dependency_links.txt +1 -0
- kirchcig-0.2.0/kirchcig.egg-info/requires.txt +18 -0
- kirchcig-0.2.0/kirchcig.egg-info/top_level.txt +1 -0
- kirchcig-0.2.0/pyproject.toml +51 -0
- kirchcig-0.2.0/setup.cfg +4 -0
- kirchcig-0.2.0/tests/test_kirchcig.py +696 -0
kirchcig-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kirchcig contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kirchcig-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kirchcig
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: GPU Kirchhoff migration to common-image gathers, and its exact adjoint.
|
|
5
|
+
Author: kirchcig contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zzzzswh/kirchcig
|
|
8
|
+
Project-URL: Repository, https://github.com/zzzzswh/kirchcig
|
|
9
|
+
Project-URL: Issues, https://github.com/zzzzswh/kirchcig/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/zzzzswh/kirchcig/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: seismic,kirchhoff,migration,common-image-gather,cuda,cupy,adjoint,least-squares-migration
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Classifier: Environment :: GPU :: NVIDIA CUDA
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.22
|
|
25
|
+
Provides-Extra: eikonal
|
|
26
|
+
Requires-Dist: scikit-fmm; extra == "eikonal"
|
|
27
|
+
Provides-Extra: cuda12
|
|
28
|
+
Requires-Dist: cupy-cuda12x; extra == "cuda12"
|
|
29
|
+
Provides-Extra: cuda11
|
|
30
|
+
Requires-Dist: cupy-cuda11x; extra == "cuda11"
|
|
31
|
+
Provides-Extra: torch
|
|
32
|
+
Requires-Dist: torch; extra == "torch"
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest; extra == "test"
|
|
35
|
+
Requires-Dist: scipy; extra == "test"
|
|
36
|
+
Requires-Dist: scikit-fmm; extra == "test"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# kirchcig
|
|
40
|
+
|
|
41
|
+
**GPU Kirchhoff migration to common-image gathers (CIGs), with an exact adjoint.**
|
|
42
|
+
|
|
43
|
+
English | [简体中文](https://github.com/zzzzswh/kirchcig/blob/main/README.zh-CN.md)
|
|
44
|
+
|
|
45
|
+
Hand-written CUDA kernels, compiled at runtime by NVRTC through CuPy. PyTorch is an optional zero-copy autograd adapter, not part of the compute path.
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "kirchcig[cuda12]" # GPU, CUDA 12.x
|
|
53
|
+
pip install "kirchcig[cuda11]" # GPU, CUDA 11.x
|
|
54
|
+
pip install kirchcig # CPU only, NumPy reference engine
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
No compiler and no nvcc needed; kernels are built at runtime by NVRTC.
|
|
58
|
+
|
|
59
|
+
| Extra | Pulls in | Gives you |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| *(none)* | `numpy` | `engine="numpy"`, the reference engine. Runs anywhere, slow. |
|
|
62
|
+
| `cuda12` / `cuda11` | `cupy-cuda12x` / `cupy-cuda11x` | `engine="cuda"`, the GPU engine. Pick the one matching your driver. |
|
|
63
|
+
| `eikonal` | `scikit-fmm` | Traveltimes for a non-constant velocity model. |
|
|
64
|
+
| `torch` | `torch` | `kirchcig.torch`, the autograd wrapper. |
|
|
65
|
+
| `test` | `pytest`, `scipy`, `scikit-fmm` | Test suite, and `op.to_scipy()`. |
|
|
66
|
+
|
|
67
|
+
Combine them: `pip install "kirchcig[cuda12,eikonal,torch]"`.
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import numpy as np
|
|
73
|
+
from kirchcig import migrate
|
|
74
|
+
|
|
75
|
+
# data (ns, nr, nt) prestack traces
|
|
76
|
+
# vel (nx, nz) smooth migration velocity [m/s]
|
|
77
|
+
# srcs (2, ns) source positions, rows are (x, z) [m]
|
|
78
|
+
# recs (2, nr) receiver positions, rows are (x, z) [m]
|
|
79
|
+
|
|
80
|
+
cig = migrate(
|
|
81
|
+
data, vel, srcs, recs,
|
|
82
|
+
dt=0.004, dx=10.0, dz=10.0,
|
|
83
|
+
nh=32, hmax=2000.0, # 32 half-offset bins out to 2000 m
|
|
84
|
+
)
|
|
85
|
+
# cig -> (32, nx, nz)
|
|
86
|
+
|
|
87
|
+
image = cig.sum(0) # (nx, nz) stacked image; nh=1 gives it directly
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
No data at hand? This runs out of the box:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from kirchcig import KirchhoffCIG
|
|
94
|
+
op = KirchhoffCIG.demo() # constant velocity, one point scatterer
|
|
95
|
+
assert op.dot_test()
|
|
96
|
+
cig = op.adjoint(op.demo_data())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### The operator pair
|
|
102
|
+
|
|
103
|
+
For inversion you want the operator, not the one-shot function:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from kirchcig import KirchhoffCIG
|
|
107
|
+
|
|
108
|
+
op = KirchhoffCIG(
|
|
109
|
+
nx=401, nz=201, dx=10.0, dz=10.0,
|
|
110
|
+
srcs=srcs, recs=recs,
|
|
111
|
+
nt=1500, dt=0.004,
|
|
112
|
+
vel=vel,
|
|
113
|
+
nh=32, hmax=2000.0,
|
|
114
|
+
domain="offset", # or "angle"
|
|
115
|
+
engine="cuda", # or "numpy", "auto"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
cig = op.adjoint(data) # (ns, nr, nt) -> (nh, nx, nz) migration
|
|
119
|
+
data = op.forward(cig) # (nh, nx, nz) -> (ns, nr, nt) demigration
|
|
120
|
+
op.dot_test() # True
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`forward` and `adjoint` are exact transposes to accumulator precision, so they drop into any least-squares solver:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import scipy.sparse.linalg as spla
|
|
127
|
+
cig_lsm = spla.lsqr(op.to_scipy(), data.ravel(), iter_lim=20)[0].reshape(op.shape_model)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Angle-domain gathers
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
op = KirchhoffCIG(..., domain="angle", nh=30, hmax=60.0) # 30 bins, 0-60 deg
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`hmax` is the maximum half opening angle in degrees. Bin indices come from source- and receiver-side emergence angles, computed from the traveltime gradients.
|
|
137
|
+
|
|
138
|
+
### Anti-alias filtering
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
op = KirchhoffCIG(..., aa=True) # default aa_factor=1.0, aa_max=32
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Kirchhoff summation aliases wherever the operator's moveout between neighbouring traces exceeds half a period of the highest frequency present. It shows up as steeply dipping, criss-crossing arcs at large offsets and shallow depths; on coarsely sampled data they dominate the image. `aa=True` reads every contribution through a triangle filter whose half-width follows the local operator dip, the standard remedy of Lumley, Claerbout and Bevc (1994) as used by Claerbout's `trimo` and Madagascar's `sfmig2`.
|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+
<sub>One shot, a band-limited spike in every trace. Left: 31 receivers at 100 m, plain summation. Middle: the same data with `aa=True`. Right: 301 receivers at 10 m, no filter. `python examples/antialias.py`.</sub>
|
|
149
|
+
|
|
150
|
+
- `aa_factor` scales the filter width and is the `antialias=` parameter of `trimo` and `sfmig2` (all default to 1.0). 2.0 puts the triangle's first spectral null exactly on the alias frequency, at the cost of resolution on steep dips.
|
|
151
|
+
- `aa_max` caps the half-width in samples (default 32).
|
|
152
|
+
- `aa_stretch` (default on) also covers the time footprint of the image cell, `|dtau/dx| dx + |dtau/dz| dz`. An image sample stands for a `dx * dz` cell; when the depth grid is coarser than the time sampling (`2 dz / v > dt`) each depth sample of a plain summation lands several time samples apart and a demigrated reflector is a comb of interpolated spikes. This is Madagascar's `aastretch`. The source and receiver gradients are summed before the absolute value, so the footprint is only the depth term at the specular point, and the two widths combine as a root mean square. The gradient rides in the traveltime-table element (16 bytes per image point in the offset domain, 32 with angles or weights); `aa_stretch=False` gives the pure trace-axis criterion of `sfmig2`.
|
|
153
|
+
- Operator dips come from differencing the traveltime tables along the trace axis, so it works for eikonal and user-supplied tables alike. The source and receiver axes must be sorted along the line; the constructor warns otherwise.
|
|
154
|
+
- The operator pair stays an exact transpose; `dot_test()` passes with the filter on.
|
|
155
|
+
- Cost: the filter is applied through a three-tap identity on the double integral of the traces, so the price does not depend on the filter width. It does need a float64 copy of the data, `ns * nr * (nt + 2*aa_max + 1) * 8` bytes, and the adjoint reads three float64 taps per contribution instead of one float32 sample. On a V100 that is 1.8x on the adjoint and 2.8x on the forward (see Performance); `python benchmarks/bench.py --aa` measures it on yours.
|
|
156
|
+
|
|
157
|
+
### Migration aperture
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
op = KirchhoffCIG(..., aperture=60.0) # cone half-angle from the vertical [deg]
|
|
161
|
+
op = KirchhoffCIG(..., apt=3000.0) # or a lateral distance [m]; both may be combined
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A trace contributes to an image point only if the point lies inside the aperture of *both* its source and its receiver: within `aperture` degrees of the vertical below them (`sfkirmig`'s `aperture=`), or within `apt` metres laterally (`sfmig2`'s `apt=`, in metres here). This suppresses far-aperture swing noise and skips the dropped contributions' work. On the README geometry a 60-degree cone keeps 47% of the trace-image-point pairs (45 degrees: 23%); on the V100 that cut the forward from 25.5 to 17.2 ms while the adjoint did not speed up at all, because its blocks are depth columns that straddle the cone edge, so masked lanes idle while their warp-mates gather and the coalesced table read is still made for every pair. Treat the aperture as an imaging control that comes with a forward speed-up. The cut is hard, as in Madagascar; `op.aperture_masks()` returns the two boolean masks, `python benchmarks/bench.py --aperture 60` prints the kept fraction and the timing.
|
|
165
|
+
|
|
166
|
+
The aperture is applied on the host by pushing the masked traveltime-table entries past the end of the trace, where the kernels already skip. No kernel changes, both engines drop exactly the same contributions, and the pair stays an exact transpose.
|
|
167
|
+
|
|
168
|
+
### Amplitude weights
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
op = KirchhoffCIG(..., weight="obliquity") # sqrt(cos theta) per side
|
|
172
|
+
op = KirchhoffCIG(..., weight=["obliquity", "spreading"]) # times 1/sqrt(t) per side
|
|
173
|
+
op = KirchhoffCIG(..., weight=lambda t, theta, dt: np.cos(theta) ** 2)
|
|
174
|
+
op = KirchhoffCIG(..., weight=(w_srcs, w_recs)) # (ns, nx, nz), (nr, nx, nz)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Every contribution is multiplied by `w_s(src, x, z) * w_r(rec, x, z)`, the product of a source-side and a receiver-side table. Presets are `"obliquity"` (`sqrt(cos theta)`, the product is the geometric mean of the two emergence cosines, the obliquity factor of Kirchhoff modelling; `sfkirmod` uses the arithmetic mean, which agrees to second order) and `"spreading"` (`1 / sqrt(t)`, the 2D Green's function amplitude of one leg up to the velocity); a list multiplies them; a callable is evaluated on each side's traveltime and emergence-angle tables; a pair of arrays is used as is. Both kernels multiply by the same float32 product, so `forward` is `A W`, `adjoint` is `W A^T` and the pair stays an exact transpose. The weight rides in the traveltime-table element, so it costs no extra memory transaction. These cover the obliquity and spreading factors; the full true-amplitude (Bleistein) weights are not separable into two sides and are not provided.
|
|
178
|
+
|
|
179
|
+
### Half-derivative (rho) filter
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
op = KirchhoffCIG(..., halfderiv=True)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Kirchhoff demigration in 2D needs a half-order time derivative. Spreading each image point along its traveltime curve and summing the spread points over a reflector leaves the stationary-phase factor of the one lateral integral behind: a 45-degree phase rotation and an `|omega|^-1/2` spectral tilt. A demigrated horizontal reflector then does not return the wavelet it was built with, and a migrated one carries the rotation the other way. `halfderiv=True` applies `H(omega) = sqrt(1 - rho e^{-i omega})`, the half of the backward difference, to every trace on the way out of `forward` and its exact transpose to the data on the way into `adjoint`. It is the filter Madagascar's `sf_halfint` implements and `sfmig2`, `sfkirchnew` and `sfkirmod` apply, with the same default leak `rho = 1 - 1/nt`; `halfderiv_rho` changes it.
|
|
186
|
+
|
|
187
|
+
It costs one float64 FFT per trace, runs on the engine's device, and is independent of the kernels. `dot_test()` passes with it on. Two properties to know: the discrete filter delays by a quarter sample (its phase is `pi/4 - omega/4`), so migrated reflectors sit `dt/4` shallower in two-way time and round trips are unshifted; and it does nothing about the depth-grid comb (`2 dz / v > dt`), which is what `aa=True` handles.
|
|
188
|
+
|
|
189
|
+
### PyTorch
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from kirchcig.torch import TorchKirchhoffCIG
|
|
193
|
+
|
|
194
|
+
top = TorchKirchhoffCIG(op)
|
|
195
|
+
cig = top.adjoint(data) # differentiable w.r.t. data
|
|
196
|
+
res = top.forward(cig) - data
|
|
197
|
+
res.pow(2).sum().backward()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Tensors stay on the GPU. The operator is linear, so the backward of `forward` is `adjoint` and vice versa; the backward pass is itself recorded, so second-order derivatives work.
|
|
201
|
+
|
|
202
|
+
### Custom traveltimes
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
op = KirchhoffCIG(..., trav=(trav_srcs, trav_recs))
|
|
206
|
+
# trav_srcs (ns, nx, nz) source-to-image-point traveltimes [s]
|
|
207
|
+
# trav_recs (nr, nx, nz) image-point-to-receiver traveltimes [s]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Otherwise traveltimes come from an eikonal solve (`scikit-fmm`), or analytically for constant velocity. Note the layout: the source/receiver axis comes first, transposed relative to some other libraries, which is what keeps the adjoint reads coalesced.
|
|
211
|
+
|
|
212
|
+
### Shapes
|
|
213
|
+
|
|
214
|
+
| | Shape | Notes |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| data | `(ns, nr, nt)` | float32 |
|
|
217
|
+
| model (CIG) | `(nh, nx, nz)` | gather axis outermost |
|
|
218
|
+
| `srcs`, `recs` | `(2, ns)`, `(2, nr)` | rows are `(x, z)` in metres |
|
|
219
|
+
| velocity | `(nx, nz)` or scalar | m/s |
|
|
220
|
+
|
|
221
|
+
## Examples
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
python examples/plot_cig.py # the cover figure
|
|
225
|
+
python examples/vel_analysis.py # the figure below
|
|
226
|
+
python examples/antialias.py # the anti-aliasing figure above
|
|
227
|
+
python examples/lsqr_migration.py # least-squares migration with SciPy LSQR
|
|
228
|
+
python examples/torch_deep_prior.py # deep-prior LSM
|
|
229
|
+
python benchmarks/bench.py # timings
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+

|
|
233
|
+
|
|
234
|
+
<sub>The same data migrated with three velocities. Flat gathers mean the velocity is right; curvature along the offset axis is what migration velocity analysis measures, and stacking destroys it.</sub>
|
|
235
|
+
|
|
236
|
+
## Performance
|
|
237
|
+
|
|
238
|
+
Single **Tesla V100-PCIE-32GB** (driver 580.178.04), `nx=401, nz=201, ns=100, nr=200, nt=1500, nh=32`, offset domain. Each operator application evaluates 1.6e9 trace-image-point pairs.
|
|
239
|
+
|
|
240
|
+
| Accumulator | adjoint (migration) | forward (demigration) | dot-test relative error |
|
|
241
|
+
|---|---|---|---|
|
|
242
|
+
| `float64` (default) | 33.9 ms — 47.5 G pair-evals/s | 25.6 ms — 63.0 G pair-evals/s | 9.5e-08 |
|
|
243
|
+
| `float32` * | 33.5 ms — 48.2 G pair-evals/s | 18.8 ms — 85.8 G pair-evals/s | 1.1e-07 |
|
|
244
|
+
| `float64`, `aa=True, aa_stretch=False` | 72.7 ms — 22.2 G pair-evals/s | 67.3 ms — 23.9 G pair-evals/s | 2.0e-08 |
|
|
245
|
+
| `float64`, `aa=True` (with `aa_stretch`) | 62.0 ms — 26.0 G pair-evals/s | 71.2 ms — 22.6 G pair-evals/s | 2.3e-08 |
|
|
246
|
+
|
|
247
|
+
<sub>* the `float32` row has not been re-timed since the adjoint started chunking its source loop, which moved every other adjoint number here; the `float64` rows are the current ones.</sub>
|
|
248
|
+
|
|
249
|
+
Building the operator, including traveltime tables and the one-off NVRTC compile, takes about 1.4 s, 2.1 s with `aa=True`, and 2.6 s with `aa_stretch` as well (the extra work is differencing the tables for the operator dips). Anti-aliasing costs 1.8x on the adjoint (three float64 taps per contribution instead of one float32 sample) and 2.8x on the forward (six shared-memory atomics instead of two, plus the float64 output and its reverse integration). Timings on this machine vary by up to 25% between sessions (the `aa_stretch=False` row measured 98.5 ms on another day), so compare rows measured together. `forward` used to be about twice as fast as `adjoint` — it accumulates one trace per block in shared memory and writes it out once, while `adjoint` does an irregular gather along the traveltime curves — but chunking the adjoint's source loop closed most of that gap, and under `aa=True` the forward is now the slower of the two.
|
|
250
|
+
|
|
251
|
+
`--schunk` sets how many sources the adjoint keeps in registers, so that each receiver-table element is read once per chunk instead of once per source. Sweeping the last row within one session: 118.1 ms at `--schunk 1`, 62.0 ms at the default 4, 59.5 ms at 8, with the forward untouched at 71.1 ms. That 1.9x is most of what took this row from the 128.8 ms it cost in 0.2.0; packing the gradients into the traveltime element is the smaller part of the change (128.8 to 118.1 ms at `--schunk 1`, measured a session apart). One result here is not explained: with both in place, `aa_stretch` comes out *faster* than the plain trace-axis filter — 62.0 against 72.7 ms — although its table element is twice as wide, and the `aa_stretch=False` row has not itself been swept over `schunk`. Until it is, read the two `aa=True` adjoint numbers as about equal rather than as a measured cost of the stretch term.
|
|
252
|
+
|
|
253
|
+
The angle domain pads the element to its widest, 8 floats: traveltime, emergence angle, operator dip and both gradient components. `--domain angle --aa` costs 91.1 ms on the adjoint and 113.7 ms on the forward in the same session, with a dot-test error of 8.4e-08.
|
|
254
|
+
|
|
255
|
+
float64 accumulation is close to free on Volta and other data-centre cards (1:2 FP64:FP32) and buys bit-identical agreement with the NumPy reference engine. On consumer GeForce parts the ratio is about 1:64, so `acc="float32"` is the sensible default there; it costs roughly 1e-7 of relative accuracy.
|
|
256
|
+
|
|
257
|
+
Reproduce with `python benchmarks/bench.py`; `--acc float32`, `--nh`, `--domain`, `--aa`, `--no_aa_stretch`, `--aperture`, `--halfderiv`, `--weight`, `--schunk` and `--engine numpy` are accepted. The first two rows are without anti-aliasing. The operator runs on one device; select it with `cupy.cuda.Device`, or from the PyTorch wrapper by the tensor's device.
|
|
258
|
+
|
|
259
|
+
## How it works
|
|
260
|
+
|
|
261
|
+
The kernels live as a CUDA C++ string in `kirchcig/_kernels.py`, compiled by `cupy.RawKernel(..., backend="nvrtc")` on first use and cached by CuPy afterwards. CuPy only allocates memory, compiles and launches; all arithmetic is in the kernels.
|
|
262
|
+
|
|
263
|
+
- **Why hand-written.** Kirchhoff migration is an irregular gather along traveltime curves, not a matmul or a convolution. No tensor op expresses it without blowing up memory traffic, and writing the kernel directly is what makes the rest of this list possible.
|
|
264
|
+
- **No atomics in the adjoint.** One thread per image point owns the whole gather axis, so every write is exclusive. Accumulators sit in shared memory as `[nh][block]`, bank-conflict free for any per-thread bin index. Block size is chosen automatically as the largest of {256, 128, 64, 32} that keeps the accumulators within 32 KB; for large `nh` it drops to 32 threads and opts in to the device limit.
|
|
265
|
+
- **Forward: one block per trace**, accumulated in shared memory with cheap shared-memory atomics and written out once.
|
|
266
|
+
- **Exact adjointness by construction.** Both kernels compute the sample index and interpolation weights with the *same* float32 expression, so the pair is the transpose of one sparse matrix; only the summation order differs.
|
|
267
|
+
- **float64 accumulators by default.** A migrated sample sums 10^4 to 10^5 terms. With float64 accumulation the CUDA engine is bit-identical to the NumPy reference; float32 accumulation costs about 1e-7 relative error, and used to buy roughly 1.5x on the adjoint (not re-timed since the source chunking).
|
|
268
|
+
- **One table element per (side, image point).** Traveltime, emergence angle, operator dip, amplitude weight and traveltime gradient are packed in a fixed order into 1, 2, 4 or 8 floats, so whatever the options, a contribution costs one or two aligned 16-byte loads per side. The receiver table is the dominant memory stream of the adjoint (it does not fit in L2 and is re-read for every source), so the adjoint processes sources in register chunks of `schunk` (default 4) and loads each receiver element once per chunk.
|
|
269
|
+
- **Model layout `(nh, nx, nz)`** keeps both the adjoint writeback and the forward model reads coalesced.
|
|
270
|
+
- **Compile-time specialisation.** `nh`, block size, accumulator type and the offset/angle switch are `-D` flags, so `nh` is a true compile-time constant. Changing it costs about a second of NVRTC, once.
|
|
271
|
+
- **Anti-aliasing costs three taps, not a filter loop.** A triangle of half-width `n` applied to a trace `d` equals `(D[i+n-1] - 2 D[i-1] + D[i-n-1]) / n^2` with `D` the double cumulative sum of `d`, so a contribution filtered by any width is still three interpolated reads. The adjoint kernel reads a float64 `D` that CuPy prepares with two cumulative sums; the forward kernel scatters the six transposed taps and CuPy reverse-integrates the result. `D` grows like `nt^2` and the second difference cancels almost all of it, which is why it is float64 and why the forward's trace accumulator is float64 under `aa=True` regardless of `acc`. The operator dip is packed next to the traveltime in the table element, so the extra input is one wider coalesced load, not a second table read.
|
|
272
|
+
- **PyTorch does no numerical work.** `kirchcig.torch` exchanges GPU buffers with CuPy through DLPack (nothing leaves the device; non-default streams are honoured) and registers the pair as `autograd.Function`s. Remove torch and the CUDA engine is unaffected.
|
|
273
|
+
|
|
274
|
+
Large problems are handled by chunking the time axis and splitting the source axis; both are exact, and the test suite checks that a split result equals an unsplit one.
|
|
275
|
+
|
|
276
|
+
## Limitations
|
|
277
|
+
|
|
278
|
+
- **No true-amplitude weights.** `weight=` covers separable factors (obliquity, spreading, anything of the form `w_s * w_r`); the Bleistein/Schleicher weights that make the migration an inverse rather than an adjoint are not.
|
|
279
|
+
- **Without `aa=True`, the forward does not anti-alias the depth-to-time stretch.** With `2 dz / v > dt` a plain demigrated trace is a comb of interpolated spikes; either choose `dz <= v dt / 2` or turn on `aa` (its `aa_stretch` term handles it).
|
|
280
|
+
- **2D only.** The traveltime tables are the obstacle, not the kernels.
|
|
281
|
+
- **Offset binning uses absolute half-offset**, so positive and negative offsets are not distinguished.
|
|
282
|
+
|
|
283
|
+
## Related
|
|
284
|
+
|
|
285
|
+
SEG-Y I/O: [segyio](https://github.com/equinor/segyio). Operator algebra and solvers: [PyLops](https://github.com/PyLops/pylops), which kirchcig plugs into via `to_scipy()`. Wave-equation modelling and RTM: [Deepwave](https://github.com/ar4/deepwave).
|
|
286
|
+
|
|
287
|
+
## Requirements
|
|
288
|
+
|
|
289
|
+
Python >= 3.10 and `numpy`. Optional: `cupy` matching your CUDA version (GPU engine), `scikit-fmm` (eikonal traveltimes), `scipy` (`to_scipy()`), `torch` (autograd wrapper only, not used for compute).
|
|
290
|
+
|
|
291
|
+
Contributions welcome. `pytest -q` runs the dot-product tests under both engines; CUDA tests skip when no GPU is visible.
|
|
292
|
+
|
|
293
|
+
## Citing
|
|
294
|
+
|
|
295
|
+
<TODO: Zenodo DOI>
|
|
296
|
+
|
|
297
|
+
## License
|
|
298
|
+
|
|
299
|
+
MIT
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
<sub>Keywords: Kirchhoff migration, prestack depth migration, common-image gather, CIG, angle gather, offset gather, GPU seismic imaging, CUDA, CuPy, least-squares migration, LSM, exact adjoint, demigration, migration velocity analysis, MVA, AVO, AVA, PyTorch, seismic inversion.</sub>
|
kirchcig-0.2.0/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# kirchcig
|
|
2
|
+
|
|
3
|
+
**GPU Kirchhoff migration to common-image gathers (CIGs), with an exact adjoint.**
|
|
4
|
+
|
|
5
|
+
English | [简体中文](https://github.com/zzzzswh/kirchcig/blob/main/README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
Hand-written CUDA kernels, compiled at runtime by NVRTC through CuPy. PyTorch is an optional zero-copy autograd adapter, not part of the compute path.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install "kirchcig[cuda12]" # GPU, CUDA 12.x
|
|
15
|
+
pip install "kirchcig[cuda11]" # GPU, CUDA 11.x
|
|
16
|
+
pip install kirchcig # CPU only, NumPy reference engine
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No compiler and no nvcc needed; kernels are built at runtime by NVRTC.
|
|
20
|
+
|
|
21
|
+
| Extra | Pulls in | Gives you |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| *(none)* | `numpy` | `engine="numpy"`, the reference engine. Runs anywhere, slow. |
|
|
24
|
+
| `cuda12` / `cuda11` | `cupy-cuda12x` / `cupy-cuda11x` | `engine="cuda"`, the GPU engine. Pick the one matching your driver. |
|
|
25
|
+
| `eikonal` | `scikit-fmm` | Traveltimes for a non-constant velocity model. |
|
|
26
|
+
| `torch` | `torch` | `kirchcig.torch`, the autograd wrapper. |
|
|
27
|
+
| `test` | `pytest`, `scipy`, `scikit-fmm` | Test suite, and `op.to_scipy()`. |
|
|
28
|
+
|
|
29
|
+
Combine them: `pip install "kirchcig[cuda12,eikonal,torch]"`.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import numpy as np
|
|
35
|
+
from kirchcig import migrate
|
|
36
|
+
|
|
37
|
+
# data (ns, nr, nt) prestack traces
|
|
38
|
+
# vel (nx, nz) smooth migration velocity [m/s]
|
|
39
|
+
# srcs (2, ns) source positions, rows are (x, z) [m]
|
|
40
|
+
# recs (2, nr) receiver positions, rows are (x, z) [m]
|
|
41
|
+
|
|
42
|
+
cig = migrate(
|
|
43
|
+
data, vel, srcs, recs,
|
|
44
|
+
dt=0.004, dx=10.0, dz=10.0,
|
|
45
|
+
nh=32, hmax=2000.0, # 32 half-offset bins out to 2000 m
|
|
46
|
+
)
|
|
47
|
+
# cig -> (32, nx, nz)
|
|
48
|
+
|
|
49
|
+
image = cig.sum(0) # (nx, nz) stacked image; nh=1 gives it directly
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
No data at hand? This runs out of the box:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from kirchcig import KirchhoffCIG
|
|
56
|
+
op = KirchhoffCIG.demo() # constant velocity, one point scatterer
|
|
57
|
+
assert op.dot_test()
|
|
58
|
+
cig = op.adjoint(op.demo_data())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
### The operator pair
|
|
64
|
+
|
|
65
|
+
For inversion you want the operator, not the one-shot function:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from kirchcig import KirchhoffCIG
|
|
69
|
+
|
|
70
|
+
op = KirchhoffCIG(
|
|
71
|
+
nx=401, nz=201, dx=10.0, dz=10.0,
|
|
72
|
+
srcs=srcs, recs=recs,
|
|
73
|
+
nt=1500, dt=0.004,
|
|
74
|
+
vel=vel,
|
|
75
|
+
nh=32, hmax=2000.0,
|
|
76
|
+
domain="offset", # or "angle"
|
|
77
|
+
engine="cuda", # or "numpy", "auto"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
cig = op.adjoint(data) # (ns, nr, nt) -> (nh, nx, nz) migration
|
|
81
|
+
data = op.forward(cig) # (nh, nx, nz) -> (ns, nr, nt) demigration
|
|
82
|
+
op.dot_test() # True
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`forward` and `adjoint` are exact transposes to accumulator precision, so they drop into any least-squares solver:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import scipy.sparse.linalg as spla
|
|
89
|
+
cig_lsm = spla.lsqr(op.to_scipy(), data.ravel(), iter_lim=20)[0].reshape(op.shape_model)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Angle-domain gathers
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
op = KirchhoffCIG(..., domain="angle", nh=30, hmax=60.0) # 30 bins, 0-60 deg
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`hmax` is the maximum half opening angle in degrees. Bin indices come from source- and receiver-side emergence angles, computed from the traveltime gradients.
|
|
99
|
+
|
|
100
|
+
### Anti-alias filtering
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
op = KirchhoffCIG(..., aa=True) # default aa_factor=1.0, aa_max=32
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Kirchhoff summation aliases wherever the operator's moveout between neighbouring traces exceeds half a period of the highest frequency present. It shows up as steeply dipping, criss-crossing arcs at large offsets and shallow depths; on coarsely sampled data they dominate the image. `aa=True` reads every contribution through a triangle filter whose half-width follows the local operator dip, the standard remedy of Lumley, Claerbout and Bevc (1994) as used by Claerbout's `trimo` and Madagascar's `sfmig2`.
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
<sub>One shot, a band-limited spike in every trace. Left: 31 receivers at 100 m, plain summation. Middle: the same data with `aa=True`. Right: 301 receivers at 10 m, no filter. `python examples/antialias.py`.</sub>
|
|
111
|
+
|
|
112
|
+
- `aa_factor` scales the filter width and is the `antialias=` parameter of `trimo` and `sfmig2` (all default to 1.0). 2.0 puts the triangle's first spectral null exactly on the alias frequency, at the cost of resolution on steep dips.
|
|
113
|
+
- `aa_max` caps the half-width in samples (default 32).
|
|
114
|
+
- `aa_stretch` (default on) also covers the time footprint of the image cell, `|dtau/dx| dx + |dtau/dz| dz`. An image sample stands for a `dx * dz` cell; when the depth grid is coarser than the time sampling (`2 dz / v > dt`) each depth sample of a plain summation lands several time samples apart and a demigrated reflector is a comb of interpolated spikes. This is Madagascar's `aastretch`. The source and receiver gradients are summed before the absolute value, so the footprint is only the depth term at the specular point, and the two widths combine as a root mean square. The gradient rides in the traveltime-table element (16 bytes per image point in the offset domain, 32 with angles or weights); `aa_stretch=False` gives the pure trace-axis criterion of `sfmig2`.
|
|
115
|
+
- Operator dips come from differencing the traveltime tables along the trace axis, so it works for eikonal and user-supplied tables alike. The source and receiver axes must be sorted along the line; the constructor warns otherwise.
|
|
116
|
+
- The operator pair stays an exact transpose; `dot_test()` passes with the filter on.
|
|
117
|
+
- Cost: the filter is applied through a three-tap identity on the double integral of the traces, so the price does not depend on the filter width. It does need a float64 copy of the data, `ns * nr * (nt + 2*aa_max + 1) * 8` bytes, and the adjoint reads three float64 taps per contribution instead of one float32 sample. On a V100 that is 1.8x on the adjoint and 2.8x on the forward (see Performance); `python benchmarks/bench.py --aa` measures it on yours.
|
|
118
|
+
|
|
119
|
+
### Migration aperture
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
op = KirchhoffCIG(..., aperture=60.0) # cone half-angle from the vertical [deg]
|
|
123
|
+
op = KirchhoffCIG(..., apt=3000.0) # or a lateral distance [m]; both may be combined
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A trace contributes to an image point only if the point lies inside the aperture of *both* its source and its receiver: within `aperture` degrees of the vertical below them (`sfkirmig`'s `aperture=`), or within `apt` metres laterally (`sfmig2`'s `apt=`, in metres here). This suppresses far-aperture swing noise and skips the dropped contributions' work. On the README geometry a 60-degree cone keeps 47% of the trace-image-point pairs (45 degrees: 23%); on the V100 that cut the forward from 25.5 to 17.2 ms while the adjoint did not speed up at all, because its blocks are depth columns that straddle the cone edge, so masked lanes idle while their warp-mates gather and the coalesced table read is still made for every pair. Treat the aperture as an imaging control that comes with a forward speed-up. The cut is hard, as in Madagascar; `op.aperture_masks()` returns the two boolean masks, `python benchmarks/bench.py --aperture 60` prints the kept fraction and the timing.
|
|
127
|
+
|
|
128
|
+
The aperture is applied on the host by pushing the masked traveltime-table entries past the end of the trace, where the kernels already skip. No kernel changes, both engines drop exactly the same contributions, and the pair stays an exact transpose.
|
|
129
|
+
|
|
130
|
+
### Amplitude weights
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
op = KirchhoffCIG(..., weight="obliquity") # sqrt(cos theta) per side
|
|
134
|
+
op = KirchhoffCIG(..., weight=["obliquity", "spreading"]) # times 1/sqrt(t) per side
|
|
135
|
+
op = KirchhoffCIG(..., weight=lambda t, theta, dt: np.cos(theta) ** 2)
|
|
136
|
+
op = KirchhoffCIG(..., weight=(w_srcs, w_recs)) # (ns, nx, nz), (nr, nx, nz)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Every contribution is multiplied by `w_s(src, x, z) * w_r(rec, x, z)`, the product of a source-side and a receiver-side table. Presets are `"obliquity"` (`sqrt(cos theta)`, the product is the geometric mean of the two emergence cosines, the obliquity factor of Kirchhoff modelling; `sfkirmod` uses the arithmetic mean, which agrees to second order) and `"spreading"` (`1 / sqrt(t)`, the 2D Green's function amplitude of one leg up to the velocity); a list multiplies them; a callable is evaluated on each side's traveltime and emergence-angle tables; a pair of arrays is used as is. Both kernels multiply by the same float32 product, so `forward` is `A W`, `adjoint` is `W A^T` and the pair stays an exact transpose. The weight rides in the traveltime-table element, so it costs no extra memory transaction. These cover the obliquity and spreading factors; the full true-amplitude (Bleistein) weights are not separable into two sides and are not provided.
|
|
140
|
+
|
|
141
|
+
### Half-derivative (rho) filter
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
op = KirchhoffCIG(..., halfderiv=True)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Kirchhoff demigration in 2D needs a half-order time derivative. Spreading each image point along its traveltime curve and summing the spread points over a reflector leaves the stationary-phase factor of the one lateral integral behind: a 45-degree phase rotation and an `|omega|^-1/2` spectral tilt. A demigrated horizontal reflector then does not return the wavelet it was built with, and a migrated one carries the rotation the other way. `halfderiv=True` applies `H(omega) = sqrt(1 - rho e^{-i omega})`, the half of the backward difference, to every trace on the way out of `forward` and its exact transpose to the data on the way into `adjoint`. It is the filter Madagascar's `sf_halfint` implements and `sfmig2`, `sfkirchnew` and `sfkirmod` apply, with the same default leak `rho = 1 - 1/nt`; `halfderiv_rho` changes it.
|
|
148
|
+
|
|
149
|
+
It costs one float64 FFT per trace, runs on the engine's device, and is independent of the kernels. `dot_test()` passes with it on. Two properties to know: the discrete filter delays by a quarter sample (its phase is `pi/4 - omega/4`), so migrated reflectors sit `dt/4` shallower in two-way time and round trips are unshifted; and it does nothing about the depth-grid comb (`2 dz / v > dt`), which is what `aa=True` handles.
|
|
150
|
+
|
|
151
|
+
### PyTorch
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from kirchcig.torch import TorchKirchhoffCIG
|
|
155
|
+
|
|
156
|
+
top = TorchKirchhoffCIG(op)
|
|
157
|
+
cig = top.adjoint(data) # differentiable w.r.t. data
|
|
158
|
+
res = top.forward(cig) - data
|
|
159
|
+
res.pow(2).sum().backward()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Tensors stay on the GPU. The operator is linear, so the backward of `forward` is `adjoint` and vice versa; the backward pass is itself recorded, so second-order derivatives work.
|
|
163
|
+
|
|
164
|
+
### Custom traveltimes
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
op = KirchhoffCIG(..., trav=(trav_srcs, trav_recs))
|
|
168
|
+
# trav_srcs (ns, nx, nz) source-to-image-point traveltimes [s]
|
|
169
|
+
# trav_recs (nr, nx, nz) image-point-to-receiver traveltimes [s]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Otherwise traveltimes come from an eikonal solve (`scikit-fmm`), or analytically for constant velocity. Note the layout: the source/receiver axis comes first, transposed relative to some other libraries, which is what keeps the adjoint reads coalesced.
|
|
173
|
+
|
|
174
|
+
### Shapes
|
|
175
|
+
|
|
176
|
+
| | Shape | Notes |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| data | `(ns, nr, nt)` | float32 |
|
|
179
|
+
| model (CIG) | `(nh, nx, nz)` | gather axis outermost |
|
|
180
|
+
| `srcs`, `recs` | `(2, ns)`, `(2, nr)` | rows are `(x, z)` in metres |
|
|
181
|
+
| velocity | `(nx, nz)` or scalar | m/s |
|
|
182
|
+
|
|
183
|
+
## Examples
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python examples/plot_cig.py # the cover figure
|
|
187
|
+
python examples/vel_analysis.py # the figure below
|
|
188
|
+
python examples/antialias.py # the anti-aliasing figure above
|
|
189
|
+
python examples/lsqr_migration.py # least-squares migration with SciPy LSQR
|
|
190
|
+
python examples/torch_deep_prior.py # deep-prior LSM
|
|
191
|
+
python benchmarks/bench.py # timings
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+

|
|
195
|
+
|
|
196
|
+
<sub>The same data migrated with three velocities. Flat gathers mean the velocity is right; curvature along the offset axis is what migration velocity analysis measures, and stacking destroys it.</sub>
|
|
197
|
+
|
|
198
|
+
## Performance
|
|
199
|
+
|
|
200
|
+
Single **Tesla V100-PCIE-32GB** (driver 580.178.04), `nx=401, nz=201, ns=100, nr=200, nt=1500, nh=32`, offset domain. Each operator application evaluates 1.6e9 trace-image-point pairs.
|
|
201
|
+
|
|
202
|
+
| Accumulator | adjoint (migration) | forward (demigration) | dot-test relative error |
|
|
203
|
+
|---|---|---|---|
|
|
204
|
+
| `float64` (default) | 33.9 ms — 47.5 G pair-evals/s | 25.6 ms — 63.0 G pair-evals/s | 9.5e-08 |
|
|
205
|
+
| `float32` * | 33.5 ms — 48.2 G pair-evals/s | 18.8 ms — 85.8 G pair-evals/s | 1.1e-07 |
|
|
206
|
+
| `float64`, `aa=True, aa_stretch=False` | 72.7 ms — 22.2 G pair-evals/s | 67.3 ms — 23.9 G pair-evals/s | 2.0e-08 |
|
|
207
|
+
| `float64`, `aa=True` (with `aa_stretch`) | 62.0 ms — 26.0 G pair-evals/s | 71.2 ms — 22.6 G pair-evals/s | 2.3e-08 |
|
|
208
|
+
|
|
209
|
+
<sub>* the `float32` row has not been re-timed since the adjoint started chunking its source loop, which moved every other adjoint number here; the `float64` rows are the current ones.</sub>
|
|
210
|
+
|
|
211
|
+
Building the operator, including traveltime tables and the one-off NVRTC compile, takes about 1.4 s, 2.1 s with `aa=True`, and 2.6 s with `aa_stretch` as well (the extra work is differencing the tables for the operator dips). Anti-aliasing costs 1.8x on the adjoint (three float64 taps per contribution instead of one float32 sample) and 2.8x on the forward (six shared-memory atomics instead of two, plus the float64 output and its reverse integration). Timings on this machine vary by up to 25% between sessions (the `aa_stretch=False` row measured 98.5 ms on another day), so compare rows measured together. `forward` used to be about twice as fast as `adjoint` — it accumulates one trace per block in shared memory and writes it out once, while `adjoint` does an irregular gather along the traveltime curves — but chunking the adjoint's source loop closed most of that gap, and under `aa=True` the forward is now the slower of the two.
|
|
212
|
+
|
|
213
|
+
`--schunk` sets how many sources the adjoint keeps in registers, so that each receiver-table element is read once per chunk instead of once per source. Sweeping the last row within one session: 118.1 ms at `--schunk 1`, 62.0 ms at the default 4, 59.5 ms at 8, with the forward untouched at 71.1 ms. That 1.9x is most of what took this row from the 128.8 ms it cost in 0.2.0; packing the gradients into the traveltime element is the smaller part of the change (128.8 to 118.1 ms at `--schunk 1`, measured a session apart). One result here is not explained: with both in place, `aa_stretch` comes out *faster* than the plain trace-axis filter — 62.0 against 72.7 ms — although its table element is twice as wide, and the `aa_stretch=False` row has not itself been swept over `schunk`. Until it is, read the two `aa=True` adjoint numbers as about equal rather than as a measured cost of the stretch term.
|
|
214
|
+
|
|
215
|
+
The angle domain pads the element to its widest, 8 floats: traveltime, emergence angle, operator dip and both gradient components. `--domain angle --aa` costs 91.1 ms on the adjoint and 113.7 ms on the forward in the same session, with a dot-test error of 8.4e-08.
|
|
216
|
+
|
|
217
|
+
float64 accumulation is close to free on Volta and other data-centre cards (1:2 FP64:FP32) and buys bit-identical agreement with the NumPy reference engine. On consumer GeForce parts the ratio is about 1:64, so `acc="float32"` is the sensible default there; it costs roughly 1e-7 of relative accuracy.
|
|
218
|
+
|
|
219
|
+
Reproduce with `python benchmarks/bench.py`; `--acc float32`, `--nh`, `--domain`, `--aa`, `--no_aa_stretch`, `--aperture`, `--halfderiv`, `--weight`, `--schunk` and `--engine numpy` are accepted. The first two rows are without anti-aliasing. The operator runs on one device; select it with `cupy.cuda.Device`, or from the PyTorch wrapper by the tensor's device.
|
|
220
|
+
|
|
221
|
+
## How it works
|
|
222
|
+
|
|
223
|
+
The kernels live as a CUDA C++ string in `kirchcig/_kernels.py`, compiled by `cupy.RawKernel(..., backend="nvrtc")` on first use and cached by CuPy afterwards. CuPy only allocates memory, compiles and launches; all arithmetic is in the kernels.
|
|
224
|
+
|
|
225
|
+
- **Why hand-written.** Kirchhoff migration is an irregular gather along traveltime curves, not a matmul or a convolution. No tensor op expresses it without blowing up memory traffic, and writing the kernel directly is what makes the rest of this list possible.
|
|
226
|
+
- **No atomics in the adjoint.** One thread per image point owns the whole gather axis, so every write is exclusive. Accumulators sit in shared memory as `[nh][block]`, bank-conflict free for any per-thread bin index. Block size is chosen automatically as the largest of {256, 128, 64, 32} that keeps the accumulators within 32 KB; for large `nh` it drops to 32 threads and opts in to the device limit.
|
|
227
|
+
- **Forward: one block per trace**, accumulated in shared memory with cheap shared-memory atomics and written out once.
|
|
228
|
+
- **Exact adjointness by construction.** Both kernels compute the sample index and interpolation weights with the *same* float32 expression, so the pair is the transpose of one sparse matrix; only the summation order differs.
|
|
229
|
+
- **float64 accumulators by default.** A migrated sample sums 10^4 to 10^5 terms. With float64 accumulation the CUDA engine is bit-identical to the NumPy reference; float32 accumulation costs about 1e-7 relative error, and used to buy roughly 1.5x on the adjoint (not re-timed since the source chunking).
|
|
230
|
+
- **One table element per (side, image point).** Traveltime, emergence angle, operator dip, amplitude weight and traveltime gradient are packed in a fixed order into 1, 2, 4 or 8 floats, so whatever the options, a contribution costs one or two aligned 16-byte loads per side. The receiver table is the dominant memory stream of the adjoint (it does not fit in L2 and is re-read for every source), so the adjoint processes sources in register chunks of `schunk` (default 4) and loads each receiver element once per chunk.
|
|
231
|
+
- **Model layout `(nh, nx, nz)`** keeps both the adjoint writeback and the forward model reads coalesced.
|
|
232
|
+
- **Compile-time specialisation.** `nh`, block size, accumulator type and the offset/angle switch are `-D` flags, so `nh` is a true compile-time constant. Changing it costs about a second of NVRTC, once.
|
|
233
|
+
- **Anti-aliasing costs three taps, not a filter loop.** A triangle of half-width `n` applied to a trace `d` equals `(D[i+n-1] - 2 D[i-1] + D[i-n-1]) / n^2` with `D` the double cumulative sum of `d`, so a contribution filtered by any width is still three interpolated reads. The adjoint kernel reads a float64 `D` that CuPy prepares with two cumulative sums; the forward kernel scatters the six transposed taps and CuPy reverse-integrates the result. `D` grows like `nt^2` and the second difference cancels almost all of it, which is why it is float64 and why the forward's trace accumulator is float64 under `aa=True` regardless of `acc`. The operator dip is packed next to the traveltime in the table element, so the extra input is one wider coalesced load, not a second table read.
|
|
234
|
+
- **PyTorch does no numerical work.** `kirchcig.torch` exchanges GPU buffers with CuPy through DLPack (nothing leaves the device; non-default streams are honoured) and registers the pair as `autograd.Function`s. Remove torch and the CUDA engine is unaffected.
|
|
235
|
+
|
|
236
|
+
Large problems are handled by chunking the time axis and splitting the source axis; both are exact, and the test suite checks that a split result equals an unsplit one.
|
|
237
|
+
|
|
238
|
+
## Limitations
|
|
239
|
+
|
|
240
|
+
- **No true-amplitude weights.** `weight=` covers separable factors (obliquity, spreading, anything of the form `w_s * w_r`); the Bleistein/Schleicher weights that make the migration an inverse rather than an adjoint are not.
|
|
241
|
+
- **Without `aa=True`, the forward does not anti-alias the depth-to-time stretch.** With `2 dz / v > dt` a plain demigrated trace is a comb of interpolated spikes; either choose `dz <= v dt / 2` or turn on `aa` (its `aa_stretch` term handles it).
|
|
242
|
+
- **2D only.** The traveltime tables are the obstacle, not the kernels.
|
|
243
|
+
- **Offset binning uses absolute half-offset**, so positive and negative offsets are not distinguished.
|
|
244
|
+
|
|
245
|
+
## Related
|
|
246
|
+
|
|
247
|
+
SEG-Y I/O: [segyio](https://github.com/equinor/segyio). Operator algebra and solvers: [PyLops](https://github.com/PyLops/pylops), which kirchcig plugs into via `to_scipy()`. Wave-equation modelling and RTM: [Deepwave](https://github.com/ar4/deepwave).
|
|
248
|
+
|
|
249
|
+
## Requirements
|
|
250
|
+
|
|
251
|
+
Python >= 3.10 and `numpy`. Optional: `cupy` matching your CUDA version (GPU engine), `scikit-fmm` (eikonal traveltimes), `scipy` (`to_scipy()`), `torch` (autograd wrapper only, not used for compute).
|
|
252
|
+
|
|
253
|
+
Contributions welcome. `pytest -q` runs the dot-product tests under both engines; CUDA tests skip when no GPU is visible.
|
|
254
|
+
|
|
255
|
+
## Citing
|
|
256
|
+
|
|
257
|
+
<TODO: Zenodo DOI>
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
<sub>Keywords: Kirchhoff migration, prestack depth migration, common-image gather, CIG, angle gather, offset gather, GPU seismic imaging, CUDA, CuPy, least-squares migration, LSM, exact adjoint, demigration, migration velocity analysis, MVA, AVO, AVA, PyTorch, seismic inversion.</sub>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""kirchcig: GPU Kirchhoff migration to common-image gathers, and its exact adjoint.
|
|
2
|
+
|
|
3
|
+
from kirchcig import migrate, KirchhoffCIG
|
|
4
|
+
|
|
5
|
+
``migrate`` is the one-shot function; ``KirchhoffCIG`` is the operator pair for
|
|
6
|
+
inversion. The PyTorch wrapper lives in ``kirchcig.torch``.
|
|
7
|
+
"""
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from ._operator import KirchhoffCIG, aperture_mask, migrate, weight_tables
|
|
10
|
+
from ._traveltime import (analytic_traveltime, eikonal_traveltime,
|
|
11
|
+
emergence_angles, traveltime_tables)
|
|
12
|
+
from ._engine_numpy import NumpyEngine
|
|
13
|
+
from ._halfderiv import HalfDerivative
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cuda_available() -> bool:
|
|
17
|
+
"""True when the ``cuda`` engine can be used (CuPy + a visible GPU)."""
|
|
18
|
+
from ._engine_cuda import cuda_available as _avail
|
|
19
|
+
return _avail()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"KirchhoffCIG",
|
|
25
|
+
"migrate",
|
|
26
|
+
"aperture_mask",
|
|
27
|
+
"weight_tables",
|
|
28
|
+
"cuda_available",
|
|
29
|
+
"NumpyEngine",
|
|
30
|
+
"HalfDerivative",
|
|
31
|
+
"traveltime_tables",
|
|
32
|
+
"analytic_traveltime",
|
|
33
|
+
"eikonal_traveltime",
|
|
34
|
+
"emergence_angles",
|
|
35
|
+
]
|