Skip to content

Fit the orientations without the strains, on a fused kernel - #19

Merged
CSSFrancis merged 13 commits into
mainfrom
feat/orientation-fit
Oct 27, 2026
Merged

CSSFrancis merged 13 commits into
mainfrom
feat/orientation-fit

Conversation

@CSSFrancis

@CSSFrancis CSSFrancis commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Adds an orientation-only fit and makes it fast enough to run on every pattern.

  • refine_deformation(..., strain=False): the strain fit's Gauss-Newton over the three rotations only; LiveIndexer.index(frames, fit=True) runs it after the stepped refine.
  • On CUDA with CuPy the stepped refine and the fit each run in one launch, one block per pattern: refine_stepped does every refine level, fit_orientations every fit iteration, its line search, the 3x3 solve and the final score, thread 0 doing the pattern's arithmetic between the block's reductions. The fit's normalized products come from 22 raw sums in closed form (the derivative of a normalized pattern), so no (N, 7, pixels) tensor is stored, and its final rotation is the polar part of (I - beta)^-1 in closed form (the turn about w by atan|w|), not an SVD. LiveIndexer.refine uses refine_stepped on the fused path whether or not a fit follows.
  • The stepped refine's levels of 1 degree and up are scored at half the refine's resolution (a quarter of the pixels).
  • prepare runs in one launch, one block per frame: the static and dynamic backgrounds, both shapes and the normalization, the same rows to float32 rounding.
  • The fused fit is a schedule of Gauss-Newton passes (FIT_SCHEDULE, default full, half, half, half, full resolution) with no line search and no final pass: every pass's sums score where it stands, and the best-scored full-resolution point is kept. Its step limit is FIT_MAX_STEP. An empty schedule keeps the line-searched fit.
  • The fit's Jacobian comes from the master's gradient: the bilinear lookup of the master's own column and row derivatives (smooth, not the bilinear facets' slope), through the Lambert map and the normalization, in one sample a pixel rather than seven. ANALYTIC_JACOBIAN switches back to central differences.
  • On the fused path index(fit=True) takes 1024 patterns a batch (FUSED_BATCH); so does index_with_strain when the fused strain fit runs (below).

Measured on a customer Zenith's real Cu scan (64 x 64, 512 px patterns, 15 kV), frames on the GPU, Titan X, in its single crystal (rows 36 and below) as the misorientation between neighbouring points:

patterns/s, batch 256 batch 1024 90% 99%
index, stepped refine only (before) ~14,000 1.70° 2.91°
index_with_strain ~2,100 0.35° 0.57°
index(fit=True), torch arithmetic 6,170 8,390 0.20° 0.31°
index(fit=True), fused 29,330 37,800 0.17° 0.25°

The fused rows are from a refine stopped at 1 degree (Autopilot's setting with this fit). The fused fit's orientations are a median 0.2° from the torch fit's on the small grains, none over 5° anywhere in the scan; with central differences and an empty schedule the fused fit is the torch fit to float32 rounding. At 1024 a batch, per 256 patterns (CUDA events): prepare 1.1 ms, search 1.9, refine 1.3, fit 2.6. A 24 px search was tried and left out: 2% faster end to end, but 6.6% of the small grains' points seeded elsewhere and moved more than 5°. A hexagonal phase's dictionary (60,261 entries) costs only its search.

Tests: test_the_orientation_only_fit_refines_the_orientation_and_no_strain; test_the_fused_prepare_is_the_torch_one, test_the_fused_stepped_refine_is_the_torch_one, and test_the_fused_orientation_fit_is_the_torch_one for both Jacobians (CUDA only). At that commit the whole suite passed (397) apart from test_distribution_identity, which fails on a stale local install's metadata (0.1.0 against 0.2.1), unrelated.

The strain fit on the same kernel

  • fit_passes is now one CUDA template over the first M trace-free numbers: M = 3 is the orientation fit as before, M = 8 (fit_strain_passes) fits the five deviatoric strains with the three rotations. Each strain basis matrix is one more fixed direction derivative, dv/dp_k = -g B_k (map d), so a pass is still one master sample a pixel. The 72 per-pattern sums (S, S², J[8], sJ[8], the 36 of JJ, Jr[8], Jg[8], s·r, s·g) are reduced by warp shuffles, the 8x8 normal equations (same relative damping as the torch path) are solved by Cholesky on thread 0 in double (a non-positive pivot takes no step), and the kernel returns the best point's eight numbers; polar_parts is then one batched torch op on the host, as the torch path does it. The orientation fit now shares the reduction and the Cholesky solve (its 3x3 adjugate is gone); its real-scan numbers are unchanged (0.17°/0.25°, 30,060/40,570 patterns/s at 256/1024).
  • refine_deformation(..., strain=True) runs it when indexer.fused and STRAIN_SCHEDULE is non-empty (default: ten full-resolution passes, step cap STRAIN_MAX_STEP = FIT_MAX_STEP = 3e-2, 64 threads a block, FIT_STRAIN_THREADS). CPU, MPS and an empty schedule keep the torch path. Like the orientation fit it keeps its best-scored full-resolution point, so only a NaN score is refused (strain NaN, g and score the seed's).
  • The strain fit's Jacobian is the slope of the bilinear master itself, not the orientation fit's smooth gradient. With the smooth gradient the fused fit matched the torch fit to 1e-6 on noise-free synthetic patterns but, on the real Cu scan (a 301-texel master), settled about 1e-3 short of the converged torch strain however many passes it took, and scored lower (0.5395 against 0.5411, median over 512 single-crystal patterns): on real patterns, where the residual is large, a Jacobian that is not the derivative of the scored function moves the Gauss-Newton fixed point. With the facets' slope the fused fit converges to the torch fit's point (16 passes: 2.9e-4 away, about the torch fit's own spread between difference steps of 1e-3 and 2e-3, 2.4e-4).
  • Coarse passes were dropped for the strain: rows averaged to half resolution are not the coarse projection of the same strain, which biases it (1e-4 on noise-free synthetic patterns at 128 x 128, 3.4e-4 at 64 x 64), and on the real scan every schedule with coarse passes stopped further from the converged fit. A step cap of 1e-2 or 5e-3 instead of 3e-2 also fell further short in the same passes.

Accuracy

Synthetic (test_the_fused_strain_fit_is_the_torch_one, new; at the indexer's pattern center and with a center map per pattern, a ghost, seeds 0.2° off, 2e-3 strain): fused and torch both recover the strain to 2e-4 and the orientation to 0.02°, and agree with each other to 1e-4 in strain and score. In a 303-orientation sweep at 128 x 128 with 3e-3 strain: noise-free, both 4.5e-5 median error against the truth, fused - torch 7e-7; blurred, both 1.6e-3, fused - torch 1.9e-5; blurred + noise, both 2.1e-3, fused - torch 6.3e-5. test_strain_is_refined_with_the_orientation passes unchanged (it runs on the CPU, torch path).

Real Cu scan, single crystal (rows 36+, recrystallized, 1792 points), the same seeds for every fit. Strain in 1e-4: each component's median, then (IQR, nb) where nb is the median difference between neighbouring points, the map's point-to-point scatter. "converged" is the torch fit at 20 iterations (from the two seed sets below it lands within a few 1e-4 of itself).

Seeds from the full stepped refine (2, 1, 0.5, 0.25, 0.12°):

fit e11 e22 e33 e12 e13 e23 from converged (median / 90%) neighbours 90% / 99% score
torch, converged (20 it) +14.4 (22.9, 11.9) -3.4 (7.9, 3.6) -21.8 (24.1, 12.6) +44.9 (31.0, 7.5) +6.8 (16.1, 3.3) +23.7 (37.5, 7.4) 0 0.252° / 0.406° 0.5240
torch, 3 it (shipped) +40.3 (26.5, 14.0) +8.3 (16.4, 3.9) -60.3 (32.4, 15.2) +96.3 (74.7, 14.0) +34.9 (25.8, 6.6) +50.4 (55.6, 9.9) 25.9 / 60.7 0.351° / 0.575° 0.5195
fused, 10 passes +12.1 (23.8, 11.3) -2.2 (8.0, 3.8) -20.3 (24.5, 11.5) +43.4 (31.5, 8.0) +8.2 (15.6, 3.9) +22.7 (36.7, 7.8) 3.3 / 12.7 0.254° / 0.437° 0.5239

Seeds from a refine stopped at 1° (Autopilot's setting with the fused fit):

fit e11 e22 e33 e12 e13 e23 from converged (median / 90%) neighbours 90% / 99% score
torch, converged (20 it) +10.6 (21.8, 11.9) -7.1 (8.8, 3.9) -23.9 (23.7, 12.3) +44.2 (30.4, 7.4) +7.8 (15.6, 3.1) +24.4 (36.9, 7.6) 0 0.252° / 0.408° 0.5240
torch, 3 it (shipped) +6.5 (23.9, 10.5) +34.4 (14.3, 4.8) -51.5 (28.0, 12.2) +96.1 (38.1, 10.9) +78.2 (23.9, 6.6) +82.7 (25.4, 8.8) 38.3 / 78.7 0.475° / 0.917° 0.5122
fused, 10 passes +8.9 (23.2, 11.7) -5.6 (9.0, 4.1) -23.8 (25.0, 11.8) +42.3 (28.8, 8.1) +10.0 (15.5, 3.7) +23.5 (37.5, 7.8) 3.6 / 13.1 0.260° / 0.425° 0.5239

Median |fused - shipped torch| per component (1e-4), full-refine seeds: e11 26.9, e22 10.1, e33 36.2, e12 50.7, e13 27.3, e23 24.6; 1° seeds: 13.9, 40.4, 26.2, 44.0, 70.5, 53.1. Those differences are the three-iteration torch fit not having converged: its strain moves by 26-38e-4 between iterations 3 and 20 and depends on the seed (e13 +34.9 against +78.2), where the fused fit's and the converged fit's do not. Distance from converged by pass count (median, 1° seeds): 6 passes 12.4e-4, 8 passes 5.1e-4, 10 passes 3.6e-4, 12 passes 3.2e-4; the torch fit at 5 iterations 7.6e-4, at 8 iterations about 0.

What this does not show. The strain here is not small: e12 is about 4.4e-3 in every fit, converged or not, which in a recrystallized crystal is the pattern center (and master) error that a single pattern cannot tell from strain, so none of these maps is a validated absolute strain. The spread and the neighbour scatter are the usable precision numbers, and the fused fit's match the converged fit's (and are lower than the shipped fit's in most components). DEFORMATION_ITERATIONS's docstring records that, on a 15 kV Si indent against CrossCourt, three iterations matched HR-EBSD better than iterating to convergence (rms 3.4-5.4e-4 against 5.0-6.5e-4). The fused fit behaves like the converged one, so if that finding holds generally it would favour the old three-iteration fit; the indent data was not available here and the fused fit has not been checked against an HR-EBSD reference.

Speed

index_with_strain on all 4096 frames (on the GPU), Titan X, CUDA events, best of three (range in brackets), and the strain fit alone per 256 patterns. The GPU was shared, but nvidia-smi showed 0-3% utilization around the runs and the repeats agreed to within 3%, so these look uncontaminated.

refine steps fit batch 256 batch 1024 fit alone, 256 / 1024 a batch peak GPU memory at 1024 (4.0 GiB of it is the frames)
2, 1° (Autopilot) torch 4,709/s (870-884 ms) 5,479/s (748-759 ms) 47.8 / 41.9 ms 9.07 GiB
2, 1° (Autopilot) fused 14,078/s (291-299 ms) 18,745/s (219-221 ms) 12.7 / 9.2 ms 4.38 GiB
2 ... 0.12° (default) torch 4,340/s (944-953 ms) 5,079/s (806-829 ms) 48.2 / 42.9 ms 9.07 GiB
2 ... 0.12° (default) fused 10,804/s (379-385 ms) 14,808/s (277-279 ms) 12.7 / 9.0 ms 4.38 GiB

The torch rows are faster than the ~2,100/s quoted for index_with_strain above, which was measured differently; the fused/torch ratio is the comparable number: the fit 3.8-4.7x faster, index_with_strain 2.5-3.7x. Threads a block for the strain fit, at 1024 a batch: 64 took 9.4 ms per 256, 128 took 10.6, 256 took 13.8.

Tests: test_the_fused_strain_fit_is_the_torch_one (CUDA only, two cases; flipping the sign of one strain derivative in the kernel makes it fail). The whole suite passes (399, 3 skipped) apart from test_distribution_identity, as before; ruff check and ruff format --check pass.

Open

  • The orientation-only fit still uses the smooth gradient with coarse passes. The same fixed-point bias may apply to it, but it was not re-measured here.
  • On the fused strain path, refine_deformation's iterations, line_search and step do not apply.

…alse), index(fit=True)

The refine's steps stop a little short on noisy patterns: on a customer
Zenith's Cu scan one neighbour in ten in its single crystal differed by
1.7 degrees. The same Gauss-Newton fit as the strains' but over the three
rotations only (7 projections an iteration, not 17) takes the 90th percentile
to 0.20 degrees, below the 0.35 of fitting the strains too, at 5,500 patterns
a second on a Titan X against 14,400 for the refine alone.
The orientation-only fit projected its 7 candidates into (N, 7, pixels) and
reduced them through some 130 torch passes: 34 of a batch's 53 ms on a Titan X.
fit_moments projects them and reduces each pattern to 22 raw sums in one pass,
the normalized products the Gauss-Newton step needs following in closed form
(the derivative of a normalized pattern), and score_ghost scores the line
search and the final fit with the ghost term. The 3x3 step is solved by its
adjugate, with no batched solver. On the fused path index() takes 1024
patterns a batch (FUSED_BATCH), which stores no per-pixel intermediate.

On a customer Zenith's real Cu scan: 14,800 patterns a second against 6,200,
the same orientations as the torch fit (median difference 0.000 degrees).
@CSSFrancis CSSFrancis changed the title Fit the orientations without the strains Fit the orientations without the strains, on a fused kernel Oct 27, 2026
…block per pattern

refine_stepped runs every refine level of a pattern, and fit_orientations
every fit iteration, line search, 3x3 solve and the final score, in one
block, thread 0 doing the pattern's arithmetic between the block's
reductions. They replace fit_moments, score_ghost and the torch arithmetic
between them (some 400 small operations a batch). The fit's rotation is the
polar part of (I - beta)^-1 in closed form, the turn about w by atan|w|,
not an SVD.

On a customer Zenith's real Cu scan, frames on the GPU, Titan X: index(fit=
True) at 256 patterns a batch 6,100 -> 13,400 a second, at 1024 14,800 ->
16,200; the same orientations as the torch fit (median difference 0.000
degrees).
Steps of a degree and up are scored against the patterns averaged to half the
refine shape, a quarter of the pixels: they move the bands by several of its
pixels. On a customer Zenith's Cu scan the refine went from 3.9 to 2.5 ms per
256 patterns, index(fit=True) at 1024 a batch from 16,200 to 17,700 a second,
with the orientations the fit reached unchanged (median difference 0.000
degrees, none over 5).
fit_orientations samples the master and its gradient once a pixel -- through
the bilinear lookup of the master's own column and row derivatives (smooth,
not the bilinear facets' slope), the Lambert map and the normalization --
in place of six more samples for central differences. On a customer Zenith's
real Cu scan the fit went from 10.2 to 6.1 ms per 256 patterns, index(fit=True)
at 1024 a batch from 17,700 to 19,300 a second; its single crystal's
neighbours 0.22/0.34 degrees at the 90th/99th percentile against 0.21/0.33,
no orientation over 5 degrees from the central-difference fit's.
ANALYTIC_JACOBIAN switches back.
fit_passes runs the analytic-Jacobian fit as FIT_SCHEDULE's passes, each
scored at the refine's resolution or half it and moved by its own multiple of
the Gauss-Newton step, capped at FIT_MAX_STEP. Every pass's sums score where it
stands for nothing, so there is no line search and no final pass: the
best-scored full-resolution point visited is kept. On a customer Zenith's Cu
scan (full, half, half, full) fitted the single crystal's neighbours to
0.19/0.29 degrees at the 90th/99th percentile in 2.5 ms per 256 patterns, where
the line-searched fit took 6.1 for 0.22/0.34; index(fit=True) at 1024 a batch
19,300 -> 27,300 a second, at 256 16,300 -> 21,200. An empty FIT_SCHEDULE
keeps the line-searched fit.
FIT_SCHEDULE is (full, half, half, half, full): from a refine stopped at 1
degree, on a customer Zenith's Cu scan, its single crystal's neighbours came to
0.17/0.25 degrees at the 90th/99th percentile, the fit 2.5 ms per 256 patterns
and index(fit=True) 33,600 a second at 1024 a batch, where a refine on to 0.5
degrees and four passes gave 0.19/0.29 at 28,800.
prepare averages each frame to the refine shape, divides it by the static
background, takes off its dynamic background (averaged to a quarter, blurred
with reflected edges and drawn back up as the torch path does), averages it
to the search shape and normalizes both, in place of some 29 small operations.
The same rows to float32 rounding; on a customer Zenith's 512 px Cu frames 2.2
-> 1.1 ms per 256. With the rest, index(fit=True) runs at 37,800 patterns a
second at 1024 a batch, 29,300 at 256.
…slope

fit_passes is now one template over the first M trace-free numbers: M = 3
is the orientation fit as before, M = 8 (fit_strain_passes) fits the five
deviatoric strains with the rotations. Each strain basis matrix is one more
fixed direction derivative, so a pass is still one master sample a pixel;
the 72 per-pattern sums are reduced by warp shuffles, the 8x8 normal
equations are solved by Cholesky on thread 0 in double, and the best
point's polar parts are taken on the host as the torch path takes them.

The strain fit takes its Jacobian from the bilinear master's own (facet)
slope, not the smooth gradient the orientation fit uses: on the real Cu
scan the smooth gradient settled about 1e-3 short of the converged torch
strain whatever the number of passes, scoring lower, while the facet
slope converges to it. Ten full-resolution passes (STRAIN_SCHEDULE) came
to 3.3-3.6e-4 of the converged torch fit, against 2.6-3.8e-3 for the
torch fit's three iterations, in 9.0-9.2 ms per 256 patterns against
42 ms on a Titan X. index_with_strain takes FUSED_BATCH patterns a batch
when the fused fit runs.
@CSSFrancis
CSSFrancis merged commit 7deb32f into main Oct 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant