Same photos, same SfM, same DepthMap parameters; only the GPU stage differs.
- Photos: alicevision/dataset_monstree
(
mini6= 6 images,full= 41 images), copied tohouse-pc:/data/scans/monstree-*. - CUDA reference: Meshroom 2023.3.0
meshroom_batchon house-pc (GTX 1080 Ti, CUDA 11.3) withFeatureExtraction:describerTypes=sift FeatureExtraction:forceCpuExtraction=False DepthMap:downscale=2(the node'sstandardpreset). The whole cache throughDepthMapFilteris copied todata/ref/<dataset>/(tar over ssh). - HIP run:
scripts\run-depthmap.cmd <dataset>feeds the referencesfm.abcandPrepareDenseSceneimages to the Windows/HIPaliceVision_depthMapEstimationwith the exact command line Meshroom recorded inDepthMap/*/0.status, then runsscripts/compare_depthmaps.py(per-view valid-mask agreement, relative depth error distribution, simMap MAD, side-by-side PNGs).
Pass criterion (initial): >= 98 % of jointly-valid pixels within 1 % relative depth and
= 95 % valid-mask agreement per view. The algorithms are deterministic, so differences should come only from fast-math / FMA contraction and texture-filter precision.
| dataset | node | DepthMap wall time | notes |
|---|---|---|---|
| monstree-mini6 | GTX 1080 Ti, CUDA 11.3 | 31.9 s (6 views, 6 tiles/view, --rangeSize 12) |
data/ref/monstree-mini6/DepthMap/820d.../0.status |
| monstree-mini6 | RX 9070, HIP (Cheshire) | 21.2 s first validated build; 17.4 s after tuning (docs/05) | build/run-mini6.log |
| monstree-full (41 views) | GTX 1080 Ti, CUDA 11.3 | 379.0 s = 4 Meshroom chunks of 12 views (105.4 + 111.1 + 106.3 + 56.2 s); listed as 105.5 s (chunk 0 only) until 2026-09-15 | data/ref/monstree-full/DepthMap/*/[0-3].log |
| monstree-full (41 views) | RX 9070, HIP (Cheshire) | 154.7 s first validated build; 124.4 s after tuning (docs/05) | build/run-full.log |
view valid ref valid tst agree meanRel medRel p95Rel <0.5% <1% <5% simMAD
1136735892 0.972 0.972 1.000 0.0056 0.0000 0.0008 0.985 0.989 0.994 0.2759
1175796198 0.972 0.972 1.000 0.0019 0.0000 0.0007 0.984 0.988 0.993 0.1261
1178536846 0.972 0.972 1.000 0.0107 0.0000 0.0012 0.978 0.984 0.990 0.2315
1302207262 0.972 0.972 1.000 0.0088 0.0000 0.0010 0.980 0.985 0.990 0.2505
1383319223 0.968 0.968 1.000 0.0039 0.0000 0.0009 0.983 0.987 0.992 0.1410
677904057 0.972 0.972 1.000 0.0019 0.0000 0.0007 0.986 0.990 0.994 0.1368
RESULT: PASS
Valid masks identical, median relative depth error 0, p95 about 0.1 %, 98-99 % of pixels within 1 %. The residual 1-2 % of pixels off by more than 1 % and the simMap MAD are consistent with two different AliceVision versions (Meshroom 2023.3 = AliceVision 3.1 on the node vs the 2026 development tree here) plus FMA/texture-filter differences; a same-version CUDA reference would tighten this, and the 41-image set will show whether it holds at scale.
The same-version CUDA reference now exists, and it settles this (2026-09-20, see
docs/18-cuda-build.md): this tree built with CUDA 12.9 is byte-identical to the Meshroom
2023.3 / CUDA 11.3 reference on all 41 views, depth maps and sim maps alike. So the version
drift contributes nothing - AliceVision 3.1 vs 3.4-dev and CUDA 11.3 vs 12.9 are both
exactly zero here. The whole residual is AMD hardware and compiler float behaviour. The
sentence above was a reasonable guess; half of it was wrong.
What it took to get from "runs" to "PASS": the first HIP run produced all-invalid maps
because HIP on Windows samples half4 (16-bit float) texture arrays as zeros
(hip/tests/half_tex.hip isolates it; float4 arrays are fine). AliceVision's camera
mipmaps use half4 by default; the HIP build selected the float4 path here (2x camera-image
VRAM).
Superseded the same day (docs/05-performance.md): the real HIP-Windows defect is
surf2Dwrite stores into 16-bit float arrays, not texture reads. Mip levels are computed
into device memory and copied with hipMemcpy2DToArray instead, and every shipped build -
HIP and CUDA - uses half4, the same format as the CUDA reference. CHESHIRE_TEXTURE_FLOAT4
survives only as an escape hatch that no build defines; read the paragraph above as history,
not as what ships.
Full per-view table, CSV/JSON and downscaled best/median/worst panels: docs/validation/monstree-full/index.md (mini6: docs/validation/monstree-mini6/index.md).
- mask agreement >= 95 % on 41/41 views (40 of them exactly 1.000; view 1227295871 at 0.971)
- per-view median relative depth error is 0.0000 on all 41 views
- fraction within 1 %: median over views 0.988; 34 views >= 0.97; worst view 0.917 (1430763847), then 0.925 (528863451) and 0.928 (1317225462); largest per-view p95 = 7.1 %
- wall time 154.7 s on the RX 9070 vs 379.0 s on the 1080 Ti across its four Meshroom chunks (the 105.5 s quoted here until 2026-09-15 was chunk 0 alone; mini6 was 21 s vs 32 s); the 41-view run is not yet profiled - float4 textures double image bandwidth and the planner's tile parallelism has not been looked at
The strict bar (every view >= 98 % within 1 %) is not met on the large set. Looking at the
worst view (docs/validation/monstree-full/worst_*.png), the two maps are the same map;
differences are scattered speckle in low-texture regions with no tile or stripe structure,
which is the signature of different AliceVision versions (3.1 on the node vs 2026 dev tree)
and FMA/texture-filter precision, not of a broken kernel. To settle it properly the plan is a
same-version CUDA reference (build this AliceVision tree with CUDA on a NVIDIA box, or run the
CUDA backend of this exact tree on the node) - until then the numbers above are the honest
statement: identical validity masks, zero median error, ~1-8 % of pixels per view differing
by more than 1 %.
Same-machine CUDA reference (1080 Ti, 31.9 s) vs HIP on an RDNA1 card (60.7 s): masks identical on all views, median error 0, 96-98 % within 1 %. HIP-vs-HIP across GPUs (RX 5500 XT Linux vs RX 9070 Windows, identical build) shows the same spread, so ~2 % of pixels differing by >1 % is the cross-GPU noise floor. Details and panels: docs/validation/monstree-mini6-rx5500xt-linux/index.md.
447.7 s vs 379.0 s CUDA (four chunks) on the same host; masks identical on 41/41, median error 0, median view 98.1 % within 1 % (worst 92.3 %). Same envelope as the RX 9070. Details: docs/validation/monstree-full-rx5500xt-linux/index.md.
Same node, the bundle rebuilt with the packed-slot mipmap sampler: 6 views 52.4 s (was 60.7 s), 41 views 396.9 s (was 447.7 s), outputs byte-identical to the earlier Linux runs, same statistics against CUDA. The RX 6750 XT, back in the same node later that morning: 28.0 s / 205.7 s (was 31.0 s / 226.1 s), 41 / 41 byte-identical to its 2026-09-03 run. Page: docs/validation/monstree-full-rx5500xt-linux-v0.2.3/index.md.
CUDA and HIP on identical host hardware for the first time: 40.0 s / 287.1 s on the 1080 Ti (14 tiles) against 28.1 s / 190.3 s on the RX 6750 XT. And CUDA against CUDA: this run is bit-identical to the Linux CUDA 11.3 reference on 37 of 41 views, with one view off by 7.2 % of pixels, so the reference disagrees with itself across builds by as much as HIP disagrees with it. Page: docs/validation/monstree-full-gtx1080ti-windows-cuda/index.md.
6 views 31.0 s (CUDA 1080 Ti 31.9 s, same host), 41 views 226.1 s (CUDA 379.0 s in four chunks; the HIP build inside a real four-chunk Meshroom job on this node: 352 s); masks identical on every view, median error 0, 97.5 % / 98.1 % median-view within 1 %. The RX 6750 XT and the RX 5500 XT produce identical statistics on both sets (RDNA1 and RDNA2 agree bit-for-bit, see the compare in this session). Pages: 6 views, 41 views.
The same card under Windows through AMD's HIP 6 runtime (v0.2.2 gfx1031 package, /arch:AVX
build on an FX-8120): 6 views 28.1 s, 41 views 190.3 s in one process (24 simultaneous tiles, no
spills); masks identical on every view, median error 0, 98.7 % / 98.7 % median-view within 1 %.
Against its own Linux / HIP 7.2 output on the 6-view set: identical masks, median 0, 97.1-98.7 %
within 1 %, not bit-identical (different compiler and runtime on the same silicon). With mipmap
emulation forced on, the same package takes 232.3 s on the 41 views and is bit-identical to the
native-mipmap run (41 / 41), which puts it within 3 % of the Linux bundle: the Linux/Windows gap
on this card is the mipmap path, nothing else. The packed-slot sampler that followed (see the
README's mipmap bullet) takes the same package to 208.1 s, again bit-identical. Pages:
6 views,
41 views.
The bridge v2 work changed the allocator, the planner and the mip-level storage. Regression against the v0.1.0 outputs (which this document validates against CUDA):
- 41 views, RX 9070, final native build, default settings: 123.0 s, depth maps bit-identical to the v0.1.0 output.
- 6 views: every configuration in the bridge matrices (
docs/validation/bridge-v2/, 40+ runs across the RX 9070 and the RX 6750 XT, VRAM caps down to 500 MB, every class forced to system RAM) is bit-identical to the uncapped run. - The strict per-view criterion (>= 98 % of jointly valid pixels within 1 %) is at the noise floor of the RX 6750 XT (97.5 % median view), so the matrix runner reports the number rather than PASS/FAIL against CUDA; bit-identity to the validated output is the regression gate.
The like-for-like comparison with the CUDA reference (which ran the same way):
| GPU | DepthMap chunks | total | textured mesh vertices |
|---|---|---|---|
| GTX 1080 Ti, CUDA (Meshroom 2023.3 as shipped) | 105.4 + 111.1 + 106.3 + 56.2 | 379 s | 1,156,930 |
| RX 6750 XT, HIP (paired into the same Meshroom 2023.3) | 99.2 + 103.7 + 95.0 + 54.0 | 352 s | 1,143,582 |
| RX 5500 XT, HIP (same) | 165.4 + 163.9 + 171.2 + 93.2 | 594 s | 1,136,840 |
Feature extraction ran on the CPU for the HIP jobs (PopSift is CUDA-only), so the SfM inputs differ slightly from the CUDA job's; the vertex counts are within 2 % of each other.
Everything above asks whether the depth maps are right. It does not ask whether the package runs, and those are not the same question: v0.2.17 shipped with GPU SIFT crashing on the first photograph while its depth maps were bit-identical across two platforms. Three checks answer the second question, and all three found something the first time they were run.
scripts/verify_bundle_stages.py (HIP) and scripts\windows\verify-cuda-stages.ps1 (CUDA) run
every GPU-bearing stage from the package and require each one to print its own port's line. Neither
had ever been executed.
RX 9070, v0.2.18 bundle (rocm7.2 gfx12-generic) |
GTX 1050 Ti, Windows CUDA package | |
|---|---|---|
| FeatureExtraction (GPU SIFT) | ok, 9 s | ok, 3.8 s |
| FeatureMatching (GPU matcher) | ok, 2 s | ok, 11.4 s |
| DepthMap | ok, 12 s | ok, 89.5 s |
| DepthMapFilter (GPU filter) | ok, 1 s | ok, 3.4 s |
| Meshing (GPU votes) | ok, 18 s | ok, 50.4 s |
| Texturing (GPU) | ok, 18 s | ok, 47.6 s |
Five markers were wrong when first run, in two families, and both families produce a silent wrong answer:
- three matched lines the port only emits under a debug variable (
filter votes GPU,knn check: identical,cheshire texturing profile), so a healthy GPU run reported as a failure; - two matched the port's own DISABLED message as well as its success one (
GPU brute-forcealso matches "GPU brute-force disabled by CHESHIRE_GPU_MATCHER=0";(?i)popsift|gpumatches any path containinggpu, and bench-pc has aC:\cheshire\gpusift), so a CPU run reported as a GPU one.
The rule that catches both: take the marker from the success branch of the port's available(),
and include enough of it that the disabled branch cannot match.
Both gates also fed DepthMapFilter the filtered maps as its input, so the stage ran, produced output and reported ok while never seeing the input it gets in a pipeline.
scripts/verify_end_to_end.py pairs the package into Meshroom and runs whole graphs, requiring both
a textured mesh and a port line from every paired node - a pipeline that fell back to Meshroom's own
binaries produces a perfectly good mesh, and counting files cannot tell the difference.
Before this, no complete Meshroom graph had ever run on a Cheshire package on Windows, and none
could have: meshroom-pair.cmd required <pkg>\bin\ and rejected the bundle, which has been the
only Windows download since v0.2.17. The reference caches under build/meshroom look like
end-to-end runs and are not - only their DepthMap node carries a [cheshire] line; every other node
in them ran stock Meshroom.
Six photographs (monstree mini6), all seven nodes paired, 6/6 ports in every row:
| config | what it varies | RX 9070 | GTX 1050 Ti |
|---|---|---|---|
base |
defaults | 59 s | 186 s |
tiles |
512 px tile buffers - 24 tiles against 6 | 59 s | 180 s |
coarse |
downscale 4, one depth list, maxPoints=300000 |
41 s | 106 s |
texbig |
8192 atlas at full resolution | 59 s | 179 s |
cpufallback |
every CHESHIRE_GPU_* switch at 0 |
69 s | 233 s |
The settings demonstrably bit rather than re-running the same work five times: coarse produced a
10 MB mesh against 38 MB, texbig an 84 MB texture against 27 MB, tiles 24 tiles against 6.
cpufallback is the one that inverts the test - each port must log its DISABLED line instead - and
it matters more than it looks: a package whose CPU fallback is broken passes every other check here
and fails on the first machine without a supported card. All four ports fell back and the pipeline
still produced a 38 MB mesh and a 26 MB texture, 10 s slower than the GPU run on the RX 9070 at this
scene size.
The Windows CUDA package had to be re-laid-out with bin\ before it could be paired at all. Per
node it made no difference: FeatureExtraction 4.7 vs 4.8 s, FeatureMatching 2.5 vs 2.5, DepthMap
89.0 vs 87.9, DepthMapFilter 3.5 vs 3.5, Meshing 27.5 vs 27.4, Texturing 31.0 vs 30.7.
verify_end_to_end.py now runs on both systems - there was no need for a second script, since both
platforms pair the same way and take the same two arguments. Building it found two things.
The first Linux pass was false. It reported 6/6 with FeatureExtraction not paired at all.
meshroom-pair.sh gates GPU SIFT on -f "$BUNDLE/lib/libpopsift.so", and the Linux CUDA bundle
ships only the versioned soname libpopsift.so.0.10.0 - the HIP bundle has the plain name - so the
script declined to pair the node. Meshroom's own binary ran instead, and because Meshroom 2023.3's
featureExtraction is itself a CUDA PopSIFT, on an NVIDIA box it printed the very "Choosing device 0"
line the gate was looking for. So v0.3.0's Linux CUDA package never pairs GPU SIFT, and the check
built to catch that class of failure was fooled by it.
The gate now requires PROVENANCE before it looks at any port: both launchers announce the binary they run, and a node that is not paired is reported apart from a paired node whose port fell silent, because those mean different things. A port marker was never proof - it says a GPU path ran, not whose.
Linux + AMD, first complete runs. house-pc (i3-4330 + RX 6750 XT, RDNA2), the shipped v0.3.0 Linux HIP bundle, six photographs, all seven nodes paired, 6/6 ports every row:
| config | RX 6750 XT (Linux) |
|---|---|
base |
86 s |
tiles |
85 s |
coarse |
57 s |
texbig |
80 s |
cpufallback |
135 s |
cpufallback logged all four disabled lines and still produced a textured mesh; the GPU rows name
the card ("ray marching on AMD Radeon RX 6750 XT"). Before this, the Linux AMD artifact had only
ever been checked stage by stage.
v0.3.0 shipped without the clamp for a VRAM cap set above the card's total memory, because it
landed after the binaries were built. Rebuilding is cheaper than it looks: only five DLLs differ per
GPU target, so build_targets.py reuses each family's tree - both rocm7.2 targets took 11.5 minutes
together, and nine hip6.2 targets 31 minutes.
Verified on the RX 9070 by overlaying the rebuilt payload onto the shipped bundle:
- no regression - 4/4 depth and sim maps byte-identical to the shipped bundle's own output from the same inputs on the same card;
- the clamp works - with
CHESHIRE_BRIDGE_VRAM_MB=32000on a 16304 MB card, v0.3.0 reportsvram cap 32000 MBand plans a 25600 MB budget it cannot have, while the rebuild reportsrequested vram cap 32000 MB exceeds this device (16304 MB total); clamping to 14537 MBand re-plans from 12 full cameras to 5 plus 2 tiles. First hardware demonstration of that fix on AMD; the commit that made it had only measured it on a GTX 1080 Ti.
Three of the nine hip6.2 targets failed the first time with "Access is denied" while linking a different auxiliary executable each - exportAlembic, exportMeshroomMaya, imageSegmentation. That is a real-time antivirus scan holding a freshly linked exe while ninja replaces it; which one gets hit is random, and none are GPU payload DLLs. Re-running is the fix.
Both AMD bundles rebuilt with the VRAM-cap clamp; both CUDA packages carried over byte-identical, having had it already. Every artifact validated as the file a user downloads.
| Windows AMD zip, RX 9070 | Linux AMD tarball, RX 6750 XT | |
|---|---|---|
| stage gate | 6/6 | (Windows-only harness) |
base |
60 s | 86 s |
tiles |
57 s | 86 s |
coarse |
38 s | 57 s |
texbig |
56 s | 80 s |
cpufallback |
68 s | 136 s |
All 6/6 ports, every node proving which binary ran. On the Windows artifact the clamp was run
directly (clamping to 14537 MB, 5 cameras + 2 tiles) and its gfx12-generic payload is
byte-identical to the verified payload directory.
The first Linux bundle of the day passed every packaging check and saw no GPU: libamd_comgr.so.3.0.0
was a symlink to itself (docs/06). The gate reported ports=0/6, DepthMap exit 1. The second, from
the fixed script, is the one in the table.
The clamp on the Linux artifact, RX 6750 XT (12272 MB), CHESHIRE_BRIDGE_VRAM_MB=32000, one view:
v0.3.0 reports vram cap 32000 MB and plans 12 full cameras against a 25600 MB budget; v0.3.1
reports requested vram cap 32000 MB exceeds this device (12272 MB total); clamping to 11014 MB
and plans 4 against 8811 MB.
The binaries read 57 CHESHIRE_* settings. Before this review the end-to-end gate set five of
them, and the phrase "every GPU port switched off" described a run in which three of seven were
still on. What the inventory found, in order of consequence:
- Three ports are silent on success. Max-flow prints only under its verbose flag; the sim blur
prints only when it is not used; the visibility knn's
GPU knn index: N pointsis behindCHESHIRE_GPU_VIS_LOG/_CHECK. None can be shown to have run on a default run, which is the situation the marker rule exists to forbid. They do run - the knn's unconditional fallback warning is absent, and underverifyits self-check reports identical answers on 8,770,375 queries - but proving it needs an unconditional announce on both paths in each, which is a source change, a rebuild of every payload and the Linux bundle, and a full re-gate. Scheduled for 0.3.2. - The in-process self-checks were never on in any gate.
CHESHIRE_FILTER_CHECK,MAXFLOW_CHECK,GPU_VIS_CHECK,SEGMENT_CHECK,GPU_TEDGE_CHECKand the facet-weight comparison underGPU_VOTE_LOGeach run the CPU reference alongside the port and say so - the strongest correctness tests the project has. The gate'sverifyconfig now turns all of them on and requires each verdict in the port's own words. Max-flow's verdict is the labelling ("cells labelled differently: 0 of N"); its float flow totals are never equal and are documented as such, and the first version of the assertion required them equal and failed a perfect run. - End-to-end runs are not byte-reproducible, and the reason is benign. GPU SIFT finds the
identical keypoint set run to run (sorted, the
.featfiles differ in 0 lines; unsorted, in 24,500) in a different order; SfM consumes them in order, so everything downstream differs. The first version of the matrix asserted byte-identical depth maps across configs with the same DepthMap parameters and failed every pair for this reason. Byte identity across bridge caps - the bridge's standing criterion - is asserted in the stage gate on a fixed SfM instead: uncapped, 1500 MB, 500 MB and bridge-off must produce the same bytes. A stable sort after extraction would make whole pipelines reproducible; a candidate for 0.3.2. - Planner v2 absorbs a 1.5 GB cap on the six-view set with no spill - budget 1200 MB, 0 full cameras + 2 tiles - exactly as docs/02 says. The matrix's first bridge config asserted "must spill" at 1.5 GB, a planner-v1 fact three versions stale. 500 MB is below one tile and spills.
- Eleven settings a user might reach for were undocumented,
CHESHIRE_BRIDGE_HOST_MBamong them. USING.md now lists them.
The matrix grew from five configs to nine (verify, bridgecap, bridgespill, bridgeoff), the
fallback run switches all seven ports off and says which two it cannot prove, and the stage gate
gained the fixed-SfM bridge pass.
Run on the v0.3.1 Windows bundle, RX 9070, 2026-09-20, after the corrections above:
- stage gate 10/10 - six stages plus the fixed-SfM bridge pass, where uncapped, 1500 MB, 500 MB
and bridge-off produced byte-identical depth and sim maps (digest
7c9f1102f0141e58); - end to end 9/9:
base59 s,tiles57,coarse38,texbig56,cpufallback76 (all seven switches off, the two unprovable ones labelled),verify80 (all six self-checks satisfied: max-flow labelling 0 of 1,676,527 cells different, knn identical on 8,770,375 queries),bridgecap91 (planner absorbed 1.5 GB),bridgespill122 (spilled at 500 MB),bridgeoff57.
Seventy-five 4272x2848 photographs of a skull on a turntable, a set no Cheshire build had seen.
Two configs on the v0.3.1 Windows bundle, RX 9070: verify passed 6/6 (623 s, 73 poses, 66,294
landmarks, every self-check satisfied); base failed at StructureFromMotion, exit 1, with a Ceres
CHECK - Manifold::PlusJacobian computation failed for x: 0 0 0 0 ... - a pose whose rotation is
all zeros reaching bundle adjustment after the 68th resection.
What settled the attribution, in order:
- The node is not a paired one. The launcher pairs seven binaries; incrementalSfM is not among
them, and the node's log has no
[cheshire]provenance line. The binary that crashed is Meshroom 2023.3.0's ownaliceVision_incrementalSfM.exe(145,920 bytes, dated 2023-12-07). - Replaying its exact command line on the same features and matches reproduces the CHECK with that stock binary (this time after the 55th resection - RANSAC inside SfM is not seeded either), so the failure is a property of those inputs, not of a flaky run.
- What differs between the passing and failing inputs is keypoint order only. Same 1,083,238
features, same 74 candidate pairs, match files four lines apart; GPU SIFT orders its keypoints
differently each run (docs/04, capability review), which changed the automatic initial pair:
822551637, 1010062824crashes,861327282, 1489813614reconstructs. A secondbaserun drew the second pair and went through.
So the finding is an upstream fragility of incremental SfM on turntable data (a static background rotating against the object gives inconsistent geometry; the usual answer is masking or a turntable-aware pipeline), exposed roughly one run in two by keypoint order. Meshroom's own PopSIFT has the same order nondeterminism, so stock users see the same rate. Two consequences for this project: the end-to-end report now names the failed node and whether it was a paired binary, so this is read off the summary rather than dug out of a log; and the stable keypoint sort already listed for 0.3.2 would make which initial pair a dataset draws a fixed fact rather than a coin toss.
The GTX 1050 Ti's full-resolution engine-bay run was two hours in, 24 depth maps written at 480 s
a chunk, when the DepthMap log's first line was read: [cheshire] aliceVision_depthMapEstimation: Meshroom's own binary. Every node before it had done the same. The launcher beside the package on
bench-pc (sha 3ce7c1e6, built 2026-09-19 16:56) predates CHESHIRE_BACKEND (f50f8df, 19:04 the
next day): it read only CHESHIRE_DEPTHMAP, saw an NVIDIA card, and handed every node back to
Meshroom, ignoring the cheshire the harness set. And the shipped v0.3.0 CUDA Windows zip has no
launcher or pairing script at all - the CUDA packager never staged them - so a user following the
README could not have paired it either.
Fixes: package-cuda.ps1 stages both files and checks the launcher by substance (the wide-string
CHESHIRE_BACKEND must be present; the old launcher is refused, verified); the 0.3.1 CUDA zip is
repacked, 484 entries byte-identical plus the two files. The run was restarted with the current
launcher, all seven nodes announcing the Cheshire build. (The first relaunch was found dead a
few minutes later and blamed on the ssh session ending; the event log later showed bench-pc had
bugchecked at 23:38 - see the next section - so that diagnosis was wrong and is withdrawn.)
Two lessons for the gates. The end-to-end harness would have caught this at the end of the run, which is the wrong end of a two-hour job: it should check the first paired node's provenance line as soon as that node's log exists and abort. And a file's name and date are not its version - the launcher was checked by the pairing script's "exists" test and nothing else.
107 photographs, DepthMap:downscale=1, the whole graph through Meshroom, both configs passing
6/6 ports with every node announcing the Cheshire build:
| config | total | SfM | Meshing | Texturing | depth maps | spills |
|---|---|---|---|---|---|---|
ds1 |
2863 s | 140 s | 310 s | 176 s | 107, 9 chunks | 0 in every chunk |
blast (every opt-in path + profile logs) |
2919 s | 152 s | 434 s | 175 s | 107, 9 chunks | 0 |
The bridge on a 12 GB card at full resolution: cap 11014 MB, budget 8811 MB, 1 full R camera +
7 tiles, then 15 tiles per view - it never spills; 12 GB is simply enough. Peak VRAM by stage,
from the allocator summaries: depth map filter 593 MB, meshing 3.4 GB, texturing 9.8 GB -
the hungriest stage at full resolution is texturing, not DepthMap, which is what an 8 GB card
will meet first. blast is not faster than ds1: its extra 124 s in Meshing is the facet-weight
comparison CHESHIRE_GPU_VOTE_LOG runs alongside the port, and the two opt-ins it turns on
(CHESHIRE_QR_NULLSPACE, CHESHIRE_FILTER_CACHE_MB) announce nothing in any log - two more for
the 0.3.2 silent-ports list. Profile lines that did appear: meshing votes 19.8 M rays / 25.6 M
cells, vote kernels 6.2 s + tedge 5.2 s; knn index 21.3 M points in 7.5 s; texturing image loads
25.9 s of a stage dominated by I/O. Depth-map digests differ between the two runs, as they must
(GPU SIFT order → different SfM).
The paired 1050 Ti run stopped at 00:30:32, mid-chunk, 48 of 107 depth maps done, 0 spills in the
three finished chunks, with no error in any log and no process left: bench-pc had rebooted from
bugcheck 0x1A (MEMORY_MANAGEMENT, subcode 0x403) at 00:31. The System log has the same
bugcheck at 17:59 (the run that "died at 17:58" earlier that day, then attributed to an ssh
session) and at 23:38 (the first relaunch, attributed to Start-Process), plus 0x1A/0x411 on
09-16 - four kernel memory-management crashes in five days, all under heavy runs, on a box with
mixed DIMMs (2 x 4 GB 800 MHz + 2 x 4 GB 667 MHz). The minidumps name no third-party driver.
User-mode code cannot bugcheck a machine; this is RAM or a driver (NVIDIA 581.57), and a memory
test is the next step. Every earlier "the run died" in this document was this.
The run was resumed rather than restarted: the harness gained CHESHIRE_E2E_RESUME=1, which
keeps the cache so Meshroom skips the SUCCESS chunks (three of nine) and redoes the one it was
killed in.
The resumed run passed - ds1 6/6 ports, mesh + 6 texture atlases, every node announcing the
Cheshire build, no further bugcheck (uptime held from 00:31). Chunk times, sum over chunks:
DepthMap 4837 s (9 chunks, ~9 min per 12 views), DepthMapFilter 196 s, Meshing 602 s, Texturing
593 s; SfM 280 s. Wall clock 4695 s from the resume, with three DepthMap chunks carried over.
What 4 GB did to each stage:
- DepthMap: 0 spills in all 9 chunks. The bridge capped at 3021 MB and budgeted 2417 MB - 0 full R cameras + 2 tiles resident, 15 tiles per view - and that fits without ever spilling. So the "spill for sure" premise of this run was wrong for the CUDA path at this resolution: the planner's tile-only regime is enough, and the bridge's host-spill path stayed untested here. Spilling needs a cap below one tile (the stage gate's 500 MB) or a larger image.
- Meshing ran the CPU-side fusion (kd-trees of 34.5 M, 18 M, 10 M, 7.8 M points) and the GPU ports within 4 GB; no fallback line, no error.
- Texturing ran
pyramid + rasterisation on NVIDIA GeForce GTX 1050 Tiwith an atlas pyramid of 3072 MB andmemoryPerAtlas: 3488- one atlas at a time inside the card's 4096 MB, where the RX 6750 XT's run peaked at 9.8 GB by holding more at once. The CUDA build prints no allocator summary (vram: N allocs, peak M MBis HIP-side), so the CUDA peaks are not measured here.
This is the strongest package result so far: the smallest card the project has, at the largest per-view working set, through Meshroom end to end on the download as shipped (plus the pairing files 0.3.1 adds).
Postscript, 08:22 the same morning: the blast config on the same card died the same way -
bugcheck 0x1A (0x403) with 44 of 107 depth maps done, 0 spills in the three finished chunks, no
error in any log - the fifth in six days. bench-pc is retired from hours-long runs until its
memory is tested; the ds1 result above stands (it completed, and its every stage is in its
logs), and blast on 4 GB is recorded as not run.
Everything below is a source change, so it rides one rebuild of all eleven Windows payloads, the Linux bundle and both CUDA packages, followed by the full set of gates. Collected here because the items were scattered through the sections above.
- Unconditional announce lines for the silent ports - max-flow, sim blur, visibility knn, QR nullspace, filter cache. Each must print one line on the GPU path and one on the fallback, long enough that the gate's marker rule can tell them apart. Without it the gate cannot prove five of the ports ran (capability review above).
- Stable keypoint sort after GPU SIFT. Same keypoint set every run, different order; a sort makes SfM, and so whole pipelines, byte-reproducible, and turns the skull's initial-pair coin toss into a fixed draw.
- Texturing: padding on the GPU and a direct textured-OBJ writer. On the engine bay (RX 6750
XT, 2026-09-21) the ported stage is about 6 s of a 175 s node: uploads 2.1 + pyramids 0.6 +
rasterisation 2.6 + finish 0.6. The rest is upstream CPU: per-atlas edge padding + downscale +
EXR write ~10 s x 6 atlases, UV generation ~53 s, mesh load and textured-mesh save through
Assimp ~20-25 s, image loads 26 s (I/O). Padding is a dilate on an atlas that is already in VRAM
at rasterisation time - do it before the download, not after. The save is patch step 4n again
(
Mesh::save's direct writer, docs/11) withvt/f v/vtlines and the MTL; the Assimp load stays, since the node accepts non-OBJ input.Texturing::loadWithAtlasandsaveAshave no Cheshire code today. - CUDA allocator peak summary.
vram: N allocs, peak M MBis HIP-side only; the 1050 Ti run reported no peaks. The CUDA build should print the same line so both backends' memory is measured the same way. - Camera mipmaps through the bridge on CUDA. They bypass it today, so on a small NVIDIA card there is memory the planner cannot see; the 1050 Ti absorbed the engine bay, a larger set may not.
- Announce lines in the end-to-end gate for items 1 and 4, and the 8 GB HIP texturing run (RX 5500 XT) recorded before the release, whatever it shows.
Not source, and not waiting for 0.3.2: memtest on bench-pc; the RX 5500 XT gate on the Windows hip6.2 gfx1012/gfx1031 payloads (closes the one inference in 0.3.1's notes).
Per-node wall clock of the engine bay at full resolution on the RX 6750 XT (ds1, 2863 s), from
Meshroom's status timestamps:
| node | wall | share | today |
|---|---|---|---|
| DepthMap | 1612 s | 56 % | ported |
| Meshing | 310 s | 11 % | ported; CPU fusion remains |
| FeatureMatching | 255 s | 9 % | GPU 2-NN ported; the rest is geometric filtering |
| Texturing | 176 s | 6 % | ported; padding, atlas writes and Assimp are 0.3.2 item 3 |
| DepthMapFilter | 168 s | 6 % | ported |
| StructureFromMotion | 140 s | 4.9 % | stock, not a paired node |
| PrepareDenseScene | 133 s | 4.6 % | stock: image conversion to EXR |
| MeshFiltering | 37 s | 1.3 % | stock |
| FeatureExtraction | 31 s | 1 % | GPU SIFT |
| ImageMatching | ~0 s | - | stock |
The order for 0.3.3, by what the numbers say rather than by which node sounds most algorithmic:
- Geometric filtering in FeatureMatching. Already Cheshire code, already deterministic, and
the byte-identity gate exists;
CHESHIRE_QR_NULLSPACEshowed 1.86x on this phase at a measured cost (docs/17). The 7-point solver's remaining time is the same kind of allocation and vectorisation work that carried the AC-RANSAC and meshing CPU wins. - PrepareDenseScene. 133 s of reading photographs and writing EXR, embarrassingly parallel, no algorithm in it and nothing to change in the output: a throughput problem.
- StructureFromMotion, profiled first. The split between Ceres and resectioning is not known;
our vcpkg Ceres already links CHOLMOD, SPQR, METIS and OpenBLAS, so "a better sparse backend" is
not a lever - the profile decides between Ceres assembly overhead and the PnP/RANSAC loops.
Two costs specific to this node: it would be the eighth paired binary on both platforms, and
nothing counts until the launcher carries it. One thing 0.3.2 gives for free: with keypoints in
stable order SfM becomes deterministic, so a resectioning optimisation can be gated on a
byte-identical
sfm.abc. Global SfM (rotation + translation averaging) is a different product - it changes the geometry - and stays out.
Not targets: MeshFiltering (37 s, not the 2-5 s a generic estimate gives, but 1.3 %) and ImageMatching (below the resolution of the timestamps on 107 photographs; only a 1000-photo scan would move it).
Built for gfx1201 only (the release rebuild of all payloads comes at the end), packaged flat, gated
on the RX 9070 with mini6: base 65 s, cpufallback 90 s, blast 181 s, 3/3, 6/6 ports each.
Item 1, announce lines. Every port now prints on both paths, and the logs show it:
max-flow: GPU push-relabel on AMD Radeon RX 9070 / disabled by CHESHIRE_GPU_MAXFLOW=0, Boykov-Kolmogorov; sim blur: Gaussian on the GPU (...) / disabled by CHESHIRE_GPU_BLUR=0, OIIO; visibility knn on the GPU / disabled by CHESHIRE_GPU_VIS=0, nanoflann; 7-point nullspace: SVD (default...) in base and Householder QR (CHESHIRE_QR_NULLSPACE=1) in blast;
depth map filter cache: cap 4096 MB / cap 8192 MB. The gate requires all of them, and the
fallback run now proves the max-flow and visibility CPU paths instead of noting that it cannot.
Item 2, keypoint order. Generator step 5c sorts PopSIFT's keypoints by (x, y, scale,
orientation, descriptor bytes). Two base runs on the same package: 12 of 12 feature and
descriptor files byte-identical, 0.matches.txt byte-identical - extraction and matching are now
a function of the images. SfM is not: same 6 poses and 9433 landmarks both times, but
cameras.sfm differs from the fourth significant digit (focal 3.98899 vs 3.98876), which is
upstream incremental SfM's own run-to-run variation (unseeded RANSAC, threaded bundle adjustment)
and was there before Cheshire. The skull's initial-pair coin toss is settled by this - pair choice
reads the matches - while byte-identical pipelines end at SfM. Seeding SfM is a 0.3.3 question.
One bug found by its absence. The sort's announce line did not print, and the DLL did not
contain the literal though it contained the getenv string beside it. ALICEVISION_LOG_INFO(a)
expands to stream << a without parentheses, so stream << on ? "A" : "B" parsed as
(stream << on) ? "A" : "B": it logged the bool and both strings were dead. Any ternary handed to
that macro does this; the fix is an if/else, and the check that caught it - search the binary for
the literal - is now the habit for every new line.
0.3.2 progress: item 3 landed - texturing padding on the GPU, direct textured-OBJ writer (2026-09-21)
Padding. writeTexture's "dilate gutter" is two sequential sweeps over the atlas in which a
texel reads neighbours already updated in the same sweep. That dependency is only on the left and
up neighbour (forward) or right and down (backward), so every anti-diagonal is independent once
the previous one is done: the port runs the same sweeps as one kernel launch per diagonal, in
place, on the device, before the atlas is downloaded - 16,000 launches of up to 8,190 threads for
an 8192^2 atlas, 0.36 s including the download. CHESHIRE_GPU_PAD_CHECK=1 runs upstream's loops
on the host copy and compares: 0 of 67,108,864 texels differ, colours and count values both.
The CPU fallback path still pads on the host; writeTexture logs which one ran.
The writer. Texturing::saveAs built an Assimp scene (every (vertex, uv) pair through a
std::map, positions copied into aiMesh arrays) and exported through Assimp's OBJ writer. The
direct writer emits every mesh vertex, every uv, then per atlas usemtl and f v/vt v/vt v/vt,
and the MTL; OBJ only, and only without normal/bump/displacement maps. Content is asserted, not
assumed: CHESHIRE_OBJ_CHECK=1 writes Assimp's file beside it and scripts/check_textured_obj.py
resolves every face of both to its (position, uv) corners and compares the multisets per material.
First run: 20,409 of 499,080 faces differed by one float ulp in a uv - the mesh holds doubles,
Assimp stores floats, and printing a double to 9 digits then reading it back as float
double-rounds differently from casting first. The writer casts to float before printing, as
Assimp's numbers are; second run 0 faces differ either way (249,933 v / 269,826 vt / 499,517
faces, Assimp 269,934 v by its own deduplication). Save 0.4 s direct against 2.8 s Assimp on that
mesh; the engine bay's 14 s should scale the same way.
Texturing on mini6: 11.7 s to 10.8 s. What the timeline shows is left per atlas: the Lanczos
downscale (imageAlgo::resizeImage, 4.1 s at 8192 to 4096), the EXR write 0.5 s. The downscale
is the next texturing target and is not part of this item.
Gate: base and texcheck both 6/6; texcheck asserts the padding verdict, the "done on the
GPU" line, the writer's line and the OBJ content comparison.
Item 4. The seven GPU ports allocated with plain cudaMalloc, which on HIP the force-included
cuda_to_hip.h turned into bridge calls and on CUDA reached the driver directly: the CUDA
summaries had no other line and a CUDA card's VRAM had a population the planner could not see.
The ports now call cheshire::devMalloc / devFree (cheshire/devalloc.h, the bridge on either
backend); 53 call sites, none left.
Item 5. The camera mipmaps were never counted on CUDA - cudaMallocMipmappedArray is driver
memory - and, it turned out, not on the RX 9070 either: the Windows HIP build passes
-DCHESHIRE_NATIVE_MIPMAP, so its mipmaps are hipMallocMipmappedArray, equally invisible. The
9070's summaries had never had an image line. The bridge gained noteExternal / forgetExternal
(count bytes it does not own; free() never sees the keys), the depth-map code notes every array
after creation and forgets it before freeing, guarded on CHESHIRE_EMULATE_MIPMAP so the Linux
RDNA1 emulation, which allocates its levels through the bridge, is untouched; the emulation's
array-storage mode counts its levels too. The estimate sums every level at the build's texel size
and is generous next to upstream's own "single mipmap image size" figure (62 MB against 35 MB per
image on mini6); it errs on the side of a smaller budget, which is the side to err on.
Gate on the rebuilt gfx1201, RX 9070, mini6: base 65 s, cpufallback 80 s, blast 160 s,
texcheck 65 s, 4/4, 6/6 ports each. Under blast the summaries now read: DepthMap map 1179 MB
/ volume 7485 MB / image 372 MB (6 allocs); DepthMapFilter other 1186 MB; Meshing other
853 MB; Texturing other 3847 MB; FeatureMatching other 6 MB. The blast config asserts the
image and other lines from here on, so a build that loses either fails the gate. The CUDA side
of both items is proven on bench-pc once the CUDA package is rebuilt.
One bug on the way: upstream's if (_mipmappedArray != nullptr) has no braces, so the first
inserted forgetExternal became the if-body and the free ran unconditionally on a null handle -
"invalid argument" from every destructor. Braced now; a patch that inserts a statement after an
unbraced if is a pattern to look for.
The bundle packed from the WSL build (123 MB, ad5e5984, every pack check green) ran the full
matrix on house-pc: base 85 s, tiles 85, coarse 60, texbig 80, cpufallback 155,
bridgecap 90, bridgespill 180, bridgeoff 90, texcheck 90 (padding 0 of 67,108,864 texels
differ; direct OBJ written), ds1 200, blast 225 - all 6/6 ports - and under blast the
bridge classes read DepthMap map 825 / volume 4990 / image 744 MB (48 allocs: the
emulation's six images x eight levels), filter other 790, meshing other 846, texturing
other 3846 MB. Items 1-5 hold on Linux.
verify failed on one assertion: the knn self-check printed 0 of 8,801,169 queries name a different vertex, 1,639,701 a different distance where the gate demanded "identical to nanoflann
on all". The 0.3.1 bundle on the same card, run for the comparison, prints the same line (0 of
8,687,477 vertices, 1,621,566 distances) - this predates 0.3.2 and had never been seen because the
Linux matrix of 0.3.1 did not include verify. The RX 9070 reports "identical". The vertex is the
verdict that matters (visibilities are per vertex; the distance is an intermediate the GPU rounds
differently from nanoflann on this card), so the gate accepts either wording, and the difference
itself is recorded here as a card-dependent rounding, not a defect.
Every artifact gated as the file a user downloads, all five items in, source 8f48532 + gate fixes.
| artifact | hardware | stage gate | end to end |
|---|---|---|---|
cheshire-alicevision-windows-x64.zip (AMD, 11 payloads) |
RX 9070, Windows | 10/10, depth maps byte-identical across uncapped / 1500 MB / 500 MB / bridge-off (42abe20a879c546f) |
12/12 |
cheshire-alicevision-hip-linux-x64-rocm7.2.tar.gz |
RX 6750 XT, Linux | - | 12/12 (knn verdict by vertex) |
cheshire-alicevision-cuda-linux-x64-cuda12.9.tar.gz |
GTX 1050 Ti 4 GB, Linux | - | 12/12 |
cheshire-alicevision-cuda-windows-x64-cuda12.9.zip |
(no Windows NVIDIA box: bench-pc's RAM) | literals checked in the DLLs | not run on hardware |
The CUDA matrix is the proof of items 4 and 5 on NVIDIA. Under blast on the 1050 Ti the
bridge summary reads DepthMap map 163 / volume 874 / image 372 MB (6 allocs) - the
camera mipmaps counted for the first time on CUDA - and other for the filter (790 MB), meshing
(846), texturing (3506) and matcher (6), where 0.3.1's CUDA logs had no such lines at all. The
planner on 4 GB budgets 2860 MB (cap 3574) as two resident tiles, bridgespill spilled at 500 MB,
verify satisfied all six self-checks, texcheck padded 0 of 67,108,864 texels differently and
wrote the direct OBJ. ds1 430 s and blast 455 s on mini6 are the card, not the code.
Gate corrections found by the run, all committed: the stage gate's FeatureMatching asked for
dspsift regions since it was written while every mini6 cache and its own extraction stage are
sift (it had passed 10/10 for 0.3.1 on a cache since replaced); the reference cache's image
paths are JSON-escaped Linux paths, so a local copy with them rewritten (build/stage-cache-mini6)
is what the gate consumes on Windows; the knn self-check verdict is the vertex, not the distance.
Not done: the Windows CUDA zip has not run on NVIDIA hardware under Windows. Its binaries come from the same source as the Linux CUDA bundle that passed 12/12, its package layout and pairing files are the ones 0.3.1 validated, and the new literals are present in its DLLs; that is inference, stated here. bench-pc (the only Windows NVIDIA box) bugchecks under load until its RAM is sorted.
CHESHIRE_PDS_PROFILE=1 splits the node into read (JPEG decode + sRGB→linear), exposure + mask,
undistort and EXR write, summed over threads. Engine bay, 107 views (4032x2268), RX 9070 box (6
cores / 12 threads), 34.5 s wall: read 134, undistort 153, write 80 thread-seconds, exposure
0. Threads were all busy; the lever is the work.
The undistortion evaluated the camera model per output pixel for every view - ima2cam, the
radial polynomial, cam2ima - while all 107 views share one intrinsic. Generator step 5h computes
the distorted source coordinate once per (intrinsic parameters, output size, principal-point
correction) with exactly the expression the loop used, keeps it (139 MB for this size, capped by
CHESHIRE_UNDISTORT_MAP_MB, default 2048; CHESHIRE_UNDISTORT_MAP=0 disables), and the per-view
work is the bilinear sample alone. 107 of 107 EXRs byte-identical to the reference run;
undistort 153 → 79 thread-seconds.
Wall clock on this box: unchanged, 34.1 s against 34.5. Where the saved 75 thread-seconds went:
| threads | map | wall | read | undistort | write | sum |
|---|---|---|---|---|---|---|
| 12 | on | 33.9 s | 171 | 80 | 108 | 359 |
| 12 | on, output on the other SSD | 33.8 s | 177 | 87 | 101 | 365 |
| 6 | on | 35.2 s | 89 | 56 | 55 | 200 |
| 6 | off | 36.6 s | 66 | 106 | 37 | 209 |
| 3 | on | 48.7 s | 43 | 32 | 66 | 141 |
Six threads run as fast as twelve, and which SSD the output goes to makes no difference, so the node is not disk-bound and the SMT threads add nothing: it is bound by the six cores and the memory they share. With the map, the undistortion turns from arithmetic into a gather - 16 bytes of map plus a bilinear read of the source per pixel - and the read and write phases of the other views slow by about what the undistortion gained, which is the signature of memory bandwidth, not CPU. On this machine PrepareDenseScene is a memory-bound 34 s and the map moves work, not time. On a CPU-bound box - house-pc's four slow threads at 133 s - the saved CPU should be wall time, and the next Linux build carries the change to measure it. What remains here is the read phase (JPEG decode and the OCIO colour conversion, now 47 % of thread time), which is upstream OIIO/OCIO work.
CHESHIRE_BA_PROFILE=1 prints Ceres' own timers per solve. Engine bay, 107 views, dspsift, 12
threads, RX 9070 box, SfM wall 92 s: 140 bundle-adjustment solves, 1095 iterations, 50.9 s =
Jacobians 25.1 s (49 %) + linear solver 13.1 s (26 %) + other 11.0 s (22 %: per-solve problem
construction and trust-region bookkeeping) + residuals 1.7 s. Solvers used: SPARSE_SCHUR
(SuiteSparse), DENSE_SCHUR for the small local problems, DENSE_QR for the tiny ones; 12 threads
throughout. The largest solves are the global ones: 9.0 s for 15 iterations over 325 k residual
blocks, 7.2 s for 8 over 415 k. Final scene 141,077 landmarks.
So the lever inside SfM is the Jacobian evaluation - Ceres autodiff over every observation's
reprojection functor - not the Schur solve. Analytic Jacobians for the pinhole + radial models
(AliceVision's intrinsics already expose getDerivative*WrtParams) would cut that phase by
perhaps 2-3x, about 17 s of the 92, at the cost of iterates that differ in rounding from
autodiff's; SfM is already non-reproducible run to run, so that is a change within the existing
band, not a new one. Worth doing, but it is an 18 % win on a 5 % node: below the texturing
Lanczos downscale (25 s of a 175 s node on the 6750 XT, portable exactly, docs/04 item 3 of 0.3.2)
in the queue. Recorded, not started.
After padding moved to the device (0.3.2), the largest piece left in Texturing was
imageAlgo::resizeImage on the finished atlas: 4.1 s per 8192^2 atlas on the RX 9070 box. That
is ImageBufAlgo::resize with an empty filter name, and OIIO 3.0.9 - the version in the
dependency tree - picks lanczos3, width 6 for downsizing and runs its separable path (both
confirmed in that release's source). The tap weights depend on the destination column or row
alone, so they are computed on the host with OIIO's own expressions - FilterLanczos3_1D::lanczos3
verbatim, the C runtime's sinf, contraction off - and uploaded; the device does the inner loop in
OIIO's order (rows outer, taps inner, w = wy * xfilt[i], zero weights skipped, clamped reads,
float sums). finish() now returns the downscaled atlas beside the full one, and writeTexture
uses it. CHESHIRE_GPU_RESIZE_CHECK=1 runs OIIO on the host as well: 0 of 50,331,648 texel
channels differ (4096^2 x 3). CHESHIRE_GPU_RESIZE=0 keeps the host path.
mini6, RX 9070: Texturing 9.1 s -> 7.1 s; texcheck (padding, resize, OBJ) and base 2/2, 6/6.
On the 6750 XT's six engine-bay atlases this is the ~25 s the 0.3.2 notes pointed at, to be
measured on the next Linux build. What remains in the node: image loads (I/O), UV generation,
the EXR write, and Assimp's load on the way in.
bench-pc came back with a matched 2 x 8 GB pair in place of the mixed 667/800 MHz set that had
bugchecked five times in six days. The zip published as v0.3.2 ran the full matrix on the RX 5500
XT: 12 of 12, 6/6 ports each - base 166 s, tiles 151, coarse 106, texbig 146,
cpufallback 211, verify 211 (all six self-checks), bridgecap 196, bridgespill 422, bridgeoff
156, texcheck 176 (padding 0 of 67 M, OBJ identical), ds1 382, blast 422 - with no bugcheck
across the 55 minutes, including the memory-heaviest configs the old RAM never survived. The
payload picked was hip6.2/gfx1012; the bridge capped at 7232 MB and budgeted 5786 MB as 14
tiles per view; and under blast the summary carries image vram: 6 allocs, peak 372 MB - the
camera mipmaps counted on Windows HIP as well, the RDNA1 half of 0.3.2's item 5.
This closes the one inference both the v0.3.1 and v0.3.2 notes had to state: the rebuilt Windows hip6.2 payloads for RDNA1 now have a hardware run behind them. What remains unrun on hardware is the Windows CUDA zip (no Windows NVIDIA machine; the 1050 Ti went into house-pc for the Linux CUDA matrix and out again).
The one artifact the v0.3.2 notes could not vouch for has now run on Windows NVIDIA hardware: the
1080 Ti went into bench-pc (driver 581.57, matched DIMMs) and the zip published as v0.3.2 ran the
full matrix. 0 of 12. Every config finished (exit=0, mesh and texture present, 0 bugchecks
over the 45 minutes) and five of the six ports announced themselves - DepthMap capped at 9180 MB and
planned 12 tiles per view with images resident, DepthMapFilter, Meshing and Texturing all on the
device, image vram: 6 allocs, peak 372 MB in the blast summary - but FeatureExtraction was
"paired but silent" twelve times: the Cheshire binary ran and every view came out [cpu].
The cause is in the build script, not the code. build-alicevision-cuda.cmd defaulted
CHESHIRE_POPSIFT to OFF, the 09-21 rebuild ran in a shell that had not set it, and CMake
configured ALICEVISION_USE_POPSIFT=OFF; package-cuda.ps1 still staged popsift.dll from its
own install tree, so the package looked complete while aliceVision_feature.dll imported nothing
from it. The Linux CUDA script defaults PopSIFT on, which is why the 1050 Ti bundle was fine. Two
fixes (4ad97eb): the Windows script defaults to ON whenever the PopSift install exists and echoes
the choice, and the packager reads the staged feature library and throws if a popsift.dll is
shipped that it does not import. A package that says GPU SIFT and runs the CPU extractor can no
longer come out of the packager.
The zip was rebuilt from the v0.3.2 tag (e5a7cea) with PopSIFT on, through the new guard
(feature.dll imports popsift: True), sha256 2832289c…, 104 MB, and staged at
build/release/0.3.2-fix/. Its matrix on the 1080 Ti is recorded below.
The rebuilt zip on the same card: 12 of 12, every config 6/6 ports, no bugcheck across the
40 minutes - base 126 s (151 on the defective zip), tiles 131, coarse 80, texbig 121,
cpufallback 196, verify 186 (all six self-checks), bridgecap 156, bridgespill 271,
bridgeoff 126, texcheck 136, ds1 301, blast 346. FeatureExtraction now reads
Choosing device 0: NVIDIA GeForce GTX 1080 Ti followed by the stable-order line; the bridge
summaries match the defective run to the allocation (DepthMap volume peak 5244 MB, image 372 MB
with the mipmaps counted, Texturing 3850 MB), which is what one expects when only the extractor
changed. With this every one of the four v0.3.2 packages has run its full matrix on its own
hardware - RX 9070, RX 5500 XT, RX 6750 XT, GTX 1050 Ti and now the GTX 1080 Ti under Windows -
and the published Windows CUDA asset is the only one that is not the build validated here.
The Linux HIP bundle built from main after the undistortion map and the GPU downscale ran the
engine bay (107 photos, ds1) through Meshroom on house-pc: ok, 6/6 ports, 2794 s against
2863 s for 0.3.2 on the same box. By node, against the 0.3.2 run:
| node | 0.3.2 | 0.3.3-dev |
|---|---|---|
| PrepareDenseScene | 133 s | 127 s |
| Texturing | 176 s | 124 s |
| StructureFromMotion | 140 s | 143 s |
| Meshing | 310 s | 314 s |
Texturing is the win: edge padding and the 2x downscale both report "done on the GPU", and the node lost 52 s. PrepareDenseScene shows the map computed once for the one intrinsic (4032x2268, 139 MB, 1 cached) but the profile line says where the node's time goes on this box - thread-seconds read 50.6, undistort 36.5, write 94.7 - so it is bound on the JPEG read and the EXR write, not on the arithmetic the map removed; the 6 s it lost matches the 9070 measurement. SfM and Meshing are untouched by 0.3.3 so far and moved within noise (SfM sees a different landmark set every run, 152,179 here). The node app on house-pc is paired to this build.
Published (2026-09-21 evening). The Windows CUDA asset on the v0.3.2 GitHub release is now the
rebuild (sha 2832289c…), SHA256SUMS carries the new hash, and the release body says what was
replaced and why; both were downloaded back and checked. Anyone whose copy hashes to 14ecee08…
has the CPU-extractor zip. The Forgejo release carries notes only, nothing to swap there.
The first large set. The False Door of Ptahshepses (British Museum, Daniel Pett, CC BY-NC-SA:
884 JPEGs at 6000x3376 from a Sony A6000 over three mornings) ran through Meshroom on the RX
9070 with the 0.3.3 tree: FeatureExtraction 8 min for all 884 views on GPU SIFT, FeatureMatching
10 min, then StructureFromMotion died at view 826 of 884 with [fatal] invalid map<K, T> key,
two seconds after "Bundle adjustment start". Meshroom's own binary, not a paired node. A resume
(cached features and matches kept, SfM rerun; the initial pair varies) died at 834 the same way,
and a third run of Meshroom's own incrementalSfM with --verboseLevel debug died at 833. Three of
three. Meshroom issue #2344 reports the same signature (map::at during bundle adjustment,
18,000 images, 21 hours in, open). Not memory: 5 GB in use with 43 GB free, and the message is a
map lookup, not an allocation.
The mechanism, from the debug log and the source. The main loop resects views in groups and
runs a bundle adjustment every ten resected views; a group below that count continues and the
views accumulate as "reconstructed since the last BA". findNextBestViews returns false when no
remaining candidate reaches its score threshold, and the while exits there - with the
accumulated views still pending. Nothing after the loop bundle-adjusts them, and nothing hands
them to LocalBundleAdjustmentGraph::updateGraphWithNewViews. The outer pass then re-reads
prevReconstructedViews = getValidViews(), so from then on they are old. In the debug run nine
such views (resected at 650-656 in single-image groups, each "succeed") never appear in the
graph; every later bundle adjustment prints The pose #... does not exist in the '_mapDistancePerPoseId' for them. They keep their resection pose, they observe landmarks, and
getNewEdges proposes an edge from every new view that shares landmarks with them - to a view
that has no node. _graph.addEdge(_nodePerViewId.at(a), _nodePerViewId.at(b)) throws. It takes a
set where the candidate search runs dry mid-way, which is why 107 photographs never showed it and
884 did three times, and why upstream's 18,000-image report is the same crash.
The fix, generator step 5k, and the eighth paired node: after the resection loop, the views
resected since the last bundle adjustment are triangulated and bundle-adjusted the way a full
group is (same calls, same order, registerChanges included), before the next pass; and the
graph skips an edge whose endpoint it does not hold, with a warning, instead of throwing. Both
announce - the fix at the start of the reconstruction, the guard if it ever fires - the switch is
named in --help so the pairing scripts gate on it, and CHESHIRE_SFM_PENDING_BA=0 restores
upstream's loop for comparison. Everything the fix runs is upstream code; the change is that it
runs.
Verified on the same features and matches, local BA on (upstream's default), with the fixed
binary from the 0.3.3 stage tree: completed. 830 poses and 1,354,686 landmarks in 3 passes
(64 resection groups), against 839 poses and 1,351,101 landmarks from the useLocalBA=False
workaround run that also got through (2436 s, every bundle adjustment global). The fix fired
three times - 5, 7 and 9 views pending at the end of a pass - which is exactly the count of
resected-but-orphaned views the debug run showed, and the graph guard never had to fire: with
the engine handing every view over, no edge reaches a view without a node. Three of three
upstream runs died at 830-834; the fixed binary went past that point in every pass and finished.
The pipeline, end to end, on the RX 9070 (the useLocalBA=False workaround run, started before
the fix existed; the fixed-SfM run with every self-check on follows): ok, 6/6 ports, 21,139 s
wall from the resume. Per node: FeatureExtraction 558 s (884 views, GPU SIFT), FeatureMatching
824 s, StructureFromMotion 2436 s (839 poses), PrepareDenseScene 817 s, DepthMap 11,979 s (839
depth maps at downscale 2 on 20-megapixel frames - 53 % of the 6.25 h of compute), DepthMapFilter
460 s, Meshing 320 s, MeshFiltering 80 s, Texturing 5022 s (51 atlases of 8192^2, thirteen passes
over the 839 cameras). The textured mesh is 4,055,488 vertices and 8,104,312 faces, 679 MB as OBJ.
The two nodes that had never seen this many views, Meshing and Texturing, went through on the
first attempt; Texturing's cost is the atlas count times the camera count, which is the thing to
look at next for large sets.
With incrementalSfM paired the mini6 matrix reads seven ports per config, the seventh being the
SfM fix announcing itself. All twelve configs passed - base 60 s through blast - including
verify (all six Meshing self-checks) and texcheck (padding, resize and the direct OBJ writer
against Assimp). One trap on the way: the first launch reported DepthMap "NOT PAIRED" in every
config with no paired: line for it, while the same script paired all eight nodes by hand a
minute later and on the relaunch. Corrected the same afternoon: this was not a scanner race.
meshroom-pair.cmd had become LF-only (see "The package that could not pair DepthMap" below), and
cmd.exe's label search failed the first call :pair; converting the file to CRLF before the relaunch
is what made it pass.
The Mill 19 Rubble set (Mega-NeRF, 1678 drone photographs at 4608x3456) through Meshroom on
house-pc with the 0.3.3-dev bundle: GPU SIFT 10 min for all views, ImageMatching 44,377 pairs,
FeatureMatching 40 min, StructureFromMotion 4265 s to 1591 poses under Meshroom's own binary
(no crash: this set's candidate search never ran dry mid-pass), PrepareDenseScene 1590 EXRs,
then DepthMap at about 3 views a minute, 3 full R cameras + 5 tiles per view on the 12 GB card.
It stopped at 1054 of 1590 after 9.3 hours: Can't write output image file ... Failed OpenEXR write followed by a segfault in the HIP runtime on the error path. Not a GPU fault - the run's
cache sat under the home directory on house-pc's 219 GB system disk, not the data disk, and 124 GB
of depth maps filled it. The cache was moved to the data disk, symlinked back, and the run resumed
from its 1054 finished chunks (CHESHIRE_E2E_RESUME=1 clears the SUBMITTED and RUNNING statuses).
The rule for this box: large-set outputs go under /data.
The same 884 photographs through the harness with the 0.3.3 stage tree's incrementalSfM paired,
upstream's default local bundle adjustment, and every self-check switched on: ok, 7/7 ports,
23,054 s wall, 6.40 h of compute. StructureFromMotion took 1636 s to 833 poses and 1,355,362
landmarks - the fix fired twice, 5 and 6 views pending at the end of a pass, and the run went past
the 830-834 range where upstream's binary died three times without a fatal. Its inputs were the
crash run's: all 44 match files byte-identical, and 1,767 of 1,768 feature files (one descriptor file,
view 216823232, differs in a few bytes and changes no match), so its 833 poses against the replay's
830 are incremental SfM's own run-to-run variation, not different input. That is 800 s faster
than the global-BA workaround (2436 s), which is the point of local BA. Per node: FeatureExtraction
560 s, FeatureMatching 796 s, PrepareDenseScene 721 s, DepthMap 12,010 s, DepthMapFilter 521 s,
Meshing 852 s (with five self-checks running alongside), MeshFiltering 83 s, Texturing 5847 s (52
atlases, with the padding and resize checks). Textured mesh: 4,071,685 vertices, 8,136,271 faces,
679 MB. Corrected 2026-09-22: this entry first gave "320 s without" for Meshing and "5022 s
without" for Texturing. Both came from the earlier useLocalBA=False workaround run (839 poses,
51 atlases), a different reconstruction, not this one without its checks. This run's Meshing,
rerun from the same cache with no self-checks through the 0.3.3 release package, takes 479 s with
the depth maps read cold from disk and 489 s warm (see the fusion profile below).
Every self-check, at 884 views, zero: filterByPixSize identical to single-threaded upstream on all 52,305,736 slots and on each of the four later passes; GPU knn identical to nanoflann on all 3,124,680,410 queries, twice; max-flow 0 of 29,080,924 cells labelled differently (the two flow totals differ as documented, docs/12); segmentFullOrFree identical on all 29,080,924 cells, both passes; tedge 175,896 cells on both sides, 0 beyond 1e-3 relative; GPU padding 0 of 67,108,864 texels differing on each of 52 atlases; GPU resize 0 of 50,331,648 channels differing on each of 52. VRAM peaks: DepthMap image vram: 99 allocs, peak 1133 MB;map vram: 848 allocs, peak 1146 MB;volume vram: 96 allocs, peak 7734 MB; DepthMapFilter other vram: 120 allocs, peak 946 MB;other vram: 120 allocs, peak 965 MB;other vram: 120 allocs, peak 985 MB;other vram: 85 allocs, peak 985 MB; Meshing 3910 MB, Texturing 14,162 MB on the 16 GB card - the largest allocation of the whole run, as on the engine bay. Planner: 70 planner 1: VRAM budget 11630.2 MB, 3 full R cameras + 7 tiles.
The 0.3.3 Windows CUDA package on the GTX 1080 Ti: 11 of 12, and what the twelfth found (2026-09-22)
The CUDA package built from the fixed tree (SuiteSparse still in, see the next section) ran the
matrix on bench-pc: 11 of 12, 7/7 ports everywhere, 0 bugchecks. The one failure is texcheck:
padding 0 of 67,108,864 texels as on every card, OBJ identical, but GPU resize 7,772,469 of
50,331,648 channels differing from OpenImageIO, where every HIP card reports 0. The cause is the
compiler, not the kernel: every port file keeps FMA contraction off with #pragma clang fp contract(off), so a*b+c rounds twice as the CPU reference does; nvcc does not honour that pragma
and contracts by default (--fmad=true). The resize check is the first float-exact self-check that
has ever run on the CUDA build - the Meshing checks compare labels, counts and vertex choices -
which is why nothing said so before. Generator step 5l gives the port sources --fmad=false on
the CUDA backend; upstream's own depth-map kernels are left as they were built, since byte-identity
with the CUDA reference depends on it. The rebuilt package's matrix follows.
A second disk incident on the same run (2026-09-22). During the 124 GB cache move, one SATA
write to house-pc's data disk timed out (sd 5:0:0:0: [sdb] FAILED Result: hostbyte=DID_TIME_OUT,
31 s command age) and ext4 reported "potential data loss" for one inode: PrepareDenseScene's
undistorted image for view 1387742125. SMART is clean on both disks (0 reallocated, 0 pending, 0
CRC errors) and nothing has recurred. The file decodes as damaged - EXR_ERR_CORRUPT_CHUNK on every
channel with the OpenEXR reader - yet Texturing read it three times with "contributions to 3 texture
files" and no error, so OpenImageIO tolerated the bad chunk and that camera contributed whatever
came out of it to three atlases. One camera of 1590; the mesh and counts are unaffected, the
texture of the affected patches is suspect, and the run stands as a scale test with that caveat.
The lesson is the harness's: a resumed run has no check that its cached inputs still decode.
Ceres 2.2.0 rebuilt without SuiteSparse (scripts/windows/build-ceres-nosuitesparse.cmd: Eigen's
sparse backend with METIS ordering, LAPACK from the tree's OpenBLAS, the same MSVC, Eigen 3.4.1,
glog 0.7.1 and gflags 2.3.0 as the prebuilt vcpkg tree), spliced into tools/vcpkg-deps
(splice_ceres.py, the old files in build/ceres-vcpkg-backup). The new ceres.dll imports glog,
METIS and LAPACK only. Both packagers now refuse a stage carrying libspqr.dll or libcholmod.dll,
and the guard earned its keep twice on the first day: the CUDA install tree and then the CUDA
build's output folder each still held the old DLLs from the previous build's applocal deployment,
and the packager staged them until they were removed. The packager also deletes its previous zip
before staging, after a guard failure left the day's earlier zip in place and it travelled to the
bench as if it were new.
- RX 9070, HIP package (
test033c-gfx1201): 12 of 12, 7/7 ports; the packager reported no GPL library. Timings of five configs are inflated (coarse451 s,cpufallback456 s,verify467 s) because the CUDA rebuild compiled on the same CPU alongside them. - GTX 1080 Ti, CUDA package (sha
861ec66c…): 12 of 12, 7/7 ports, 0 bugchecks, no GPL library in the extracted package, andtexchecknow passes: GPU padding 0 of 67,108,864 texels, GPU resize 0 of 50,331,648 channels - step 5l's--fmad=falseon the port sources is what turned the 7,772,469 of the morning into 0. Depth-map digests differ from the SuiteSparse package's run as they must: Eigen's sparse solver rounds the bundle adjustment differently, the poses move in the last digits, and the depth maps follow; within the run every bridge configuration still matchesbasebit for bit.
Meshroom 2023.3's own Windows release ships libspqr.dll and libcholmod.dll as well, and so does
AliceVision's 2026.09.01 prebuilt vcpkg zip this tree came from, so 0.3.0-0.3.2 matched upstream's
practice; the maintainers' ruling in discussion #2116 (no SPQR in pre-built binaries) is the one
0.3.3 follows. The Linux bundle's Ceres was built without SuiteSparse from the start.
The resumed run finished: ok, 6/6 ports (the b033 bundle predates the SfM pairing), 16.0 h of compute over the two sessions. Per node: FeatureExtraction 756 s, ImageMatching 37 s, FeatureMatching 2265 s, StructureFromMotion 4291 s (1591 poses, upstream's binary), PrepareDenseScene 3363 s, DepthMap 34,267 s (1590 maps at downscale 2, 3 full R cameras + 5 tiles per view on the 12 GB card), DepthMapFilter 1544 s, Meshing 1020 s (8 GB RAM peak on 14.6 GB, no swap), MeshFiltering 37 s, Texturing 10,013 s (34 atlases of 8192^2). The textured mesh is 1,679,493 vertices and 3,350,047 faces, 265 MB; the cache is 164 GB, 98 GB of it PrepareDenseScene's EXRs. The i3-4330's CPU-only nodes - the SfM, the AC-RANSAC part of matching (the split was not measured on this box) and PrepareDenseScene's decode, undistortion and EXR write (3363 s) - are about 2.5 of the 16 hours; Meshing's host-side phases and MeshFiltering add a few hundred seconds, and the GPU nodes decode their images on the two cores. Two disk incidents along the way (the system disk filling, one write timeout that damaged one undistorted image) are recorded above; the result stands with the one-camera texture caveat.
The bundle-adjustment cost of leaving SuiteSparse, measured. Two replays of the False Door's
StructureFromMotion on the same cached features and matches, upstream's default local BA,
CHESHIRE_BA_PROFILE=1, on an idle RX 9070 box (a first attempt overlapped the CUDA rebuild and
was discarded: its Jacobian time, which does not depend on the sparse backend, was 903 s against
421 s):
| Ceres | SfM wall | BA solves | BA total | Jacobians | linear solver | poses / landmarks |
|---|---|---|---|---|---|---|
| SuiteSparse (CHOLMOD/SPQR) | 1944 s | 938 | 1069 s | 597 s | 323 s | 815 / 1,342,489 |
| Eigen sparse + METIS (0.3.3) | 1498 s | 904 | 733 s | 421 s | 304 s | 831 / 1,354,886 |
The linear solver, the only part the backend touches, is 304 s against 323 s: no penalty at this
size. The reduced camera system of a few hundred poses is small enough that Eigen's simplicial
factorisation with METIS ordering keeps up with CHOLMOD's supernodal one; the rest of the gap is
the two runs' different trajectories (incremental SfM's initial pair and resection order vary run
to run, 815 against 831 poses), not the solver. Upstream's ALICEVISION_REQUIRE_CERES_WITH_SUITESPARSE
default is OFF and the build accepted the new Ceres without a change.
Linux, RX 6750 XT, the fixed bundle (b033b, pre-release tree): 12 of 12 through the mini6
matrix on house-pc, verify and texcheck included - scored at 6/6 ports because the harness
copy on that box predated the eighth node; the pairing script did pair aliceVision_incrementalSfM.
The release bundle's run below is scored at 7/7 with the current harness.
The damaged EXR was regenerated after the run: PrepareDenseScene's chunks index every view in the scene (1678, by view id), not the posed ones, so the view sat in chunk 26 rather than the 25 a posed-only count gave; rerunning that one chunk with the bundle's binary rewrote the file, which now decodes on every channel. The Rubble texture caveat stands for the run as it was scored; the cache is clean for what comes next.
Linux HIP (cheshire-alicevision-hip-linux-x64-rocm7.2.tar.gz, sha 2ab6d229…) on the RX 6750 XT:
12 of 12, 7/7 ports with the release harness, aliceVision_incrementalSfM paired and announcing
the fix. base 80 s, tiles 80, coarse 55, texbig 85, cpufallback 155, verify 110,
bridgecap 85, bridgespill 175, bridgeoff 80, texcheck 90, ds1 195, blast 220. Self-checks:
max-flow 0 of 1,673,871 cells labelled differently, knn 0 of 8,791,919 queries naming a different
vertex, segments identical on all 1,673,871 cells, padding 0 of 67,108,864 texels, resize 0 of
50,331,648 channels. house-pc's node app is paired with this bundle.
Linux CUDA (cheshire-alicevision-cuda-linux-x64-cuda12.9.tar.gz, sha 2809619a…): packaging
checks all pass (no libcuda, libcudart 12.9.79, PopSIFT a CUDA build linked by the feature library,
no HIP artefacts, every dependency resolved from the bundle); all seven port kernels compiled with
--fmad=false; the SfM binary names CHESHIRE_SFM_PENDING_BA in its help. Its matrix waits for an
NVIDIA card in house-pc.
Windows: the build that the app restart interrupted. The release build's hip6.2 family lost five
of nine targets without a source error: two links refused by the virus scanner holding a freshly
linked executable ("being used by another process", "Access is denied."), and three steps that
could not start (0xC0000142) once the Claude session that had launched the build went away. The
payload builder now retries build steps whose own output carries one of those signatures, the
resumed build runs detached from the session, and both HIP build folders turned out to hold the
old libspqr.dll/libcholmod.dll from earlier applocal deployments (moved out before the resume,
as the CUDA tree's had been).
Linux CUDA (cheshire-alicevision-cuda-linux-x64-cuda12.9.tar.gz, sha 2809619a…) on a GTX 1080 Ti
in house-pc: 12 of 12, 7/7 ports. base 85 s, tiles 80, coarse 50, texbig 85, cpufallback
160, verify 115, bridgecap 105, bridgespill 210, bridgeoff 85, texcheck 95, ds1 210, blast
235. Self-checks: max-flow 0 of 1,671,337 cells labelled differently, knn identical to nanoflann on
all 8,790,341 queries, segments identical on all 1,671,337 cells, padding 0 of 67,108,864 texels,
resize 0 of 50,331,648 channels - the resize check now exact on CUDA because the port kernels are
built with --fmad=false. Bridge peaks under blast: DepthMap maps 553 MB and camera mipmaps
372 MB, DepthMapFilter 790 MB, Meshing 845 MB, Texturing 4038 MB.
The first run of the bundled Windows package failed every configuration on both cards at 3/7 ports:
DepthMap ran Meshroom's own CUDA binary ("This program needs a CUDA-Enabled GPU") and the three
nodes after it never ran. Pairing by hand showed why: The system cannot find the batch label specified - pair on the first call :pair, while the seven later calls to the same label worked.
meshroom-pair.cmd in the package was LF-only - the earlier CR count had come from Git Bash's
grep, which misreports it; Python reads zero CRs - and cmd.exe's label search is unreliable in
LF-only batch files, depending on where the label falls against its 512-byte reads.
The file became LF-only in this checkout. It is the Windows checkout, written by Windows git with
core.autocrlf=true, and scripts/linux/wsl-bundle.sh had been run on it from WSL: the script
stashes local changes and hard-resets to origin/main, and Linux git, with no autocrlf, writes every
text file with LF as it does. At 11:53 that rewrote meshroom-pair.cmd; the morning's first
stage-gate failure had the same cause through the previous evening's build, not a scanner race. The
stashes held nothing but line-ending churn and one regenerated patch export.
Fixed four ways (04b30fe): .gitattributes pins *.cmd, *.bat and *.ps1 to CRLF and *.sh to LF
for whichever git writes the checkout; all three Windows packagers rewrite every staged batch file
with CRLF and verify it; wsl-bundle.sh refuses a checkout under /mnt; and the bundle was rebuilt
from the unchanged payloads, after which all eight nodes pair by hand, DepthMap first. The Windows
CUDA zip built at 10:45 was unaffected: its copy had CRLF endings.
The release CUDA package against upstream's own CUDA, on the GTX 1080 Ti in house-pc. The 41-view
depth maps (scripts/linux/run-depthmap.sh, Meshroom's standard DepthMap parameters) from the Linux
CUDA release bundle, compared with the CUDA 11.3 reference computed by upstream's binary on this
card in September: 82 of 82 files byte-identical - all 41 depth maps and all 41 similarity maps,
whole-file SHA-256 - in 213 s against the reference's 379 s. The bridge planned 2 full R cameras +
2 tiles on the 11 GB card with images resident. Step 5l's --fmad=false applies only to the ports,
so this is also the check that it left upstream's depth-map kernels as they were.
The bundle with CRLF batch files (cheshire-alicevision-windows-x64.zip, 192,889,989 bytes, sha
eadce479…), all eight nodes paired, DepthMap first:
- RX 9070 (rocm7.2 gfx12-generic payload): 12 of 12, 7/7 ports.
base70 s,tiles65,coarse40,texbig65,cpufallback85,verify90,bridgecap100,bridgespill141,bridgeoff65,texcheck75,ds1146,blast171. Self-checks: max-flow 0 of 1,669,809 cells labelled differently, knn identical to nanoflann on all 8,770,744 queries, segments identical on all 1,669,809 cells, neighbour table and facet weights 0 differing, filterByPixSize identical on all 18,289,152 slots, padding 0 of 67,108,864 texels, resize 0 of 50,331,648 channels. - RX 5500 XT on bench-pc (hip6.2 gfx1012 payload): 12 of 12, 7/7 ports, 0 bugchecks.
base161 s,tiles146,coarse100,texbig151,cpufallback217,verify206,bridgecap191,bridgespill412,bridgeoff151,texcheck171,ds1372,blast417. Self-checks: max-flow 0 of 1,699,013 cells, knn 0 of 8,802,911 queries naming a different vertex (1,635,610 report a different distance in the last bits, the RDNA1 behaviour the vertex verdict exists for), segments identical on all 1,699,013 cells, neighbour table and facet weights 0 differing, filterByPixSize identical on all 18,289,152 slots, padding 0 of 67,108,864 texels, resize 0 of 50,331,648 channels.
The RDNA2 payloads (hip6.2 gfx1030-1036) come from the same source and build as the gfx1012 one and were not run on hardware this round; the RX 6750 XT is in house-pc under Linux.
Every artifact gated as the file a user downloads, with the harness that scores seven ports (StructureFromMotion paired as the eighth node, checked by its announce line).
| artifact | hardware | end to end | beyond the matrix |
|---|---|---|---|
cheshire-alicevision-windows-x64.zip (AMD, 11 payloads) |
RX 9070, Windows | 12/12 at 7/7 | stage tree 12/12 at 7/7 before bundling |
| same | RX 5500 XT, Windows (bench-pc) | 12/12 at 7/7, 0 bugchecks | - |
cheshire-alicevision-hip-linux-x64-rocm7.2.tar.gz |
RX 6750 XT, Linux | 12/12 at 7/7 | - |
cheshire-alicevision-cuda-linux-x64-cuda12.9.tar.gz |
GTX 1080 Ti, Linux | 12/12 at 7/7 | 41-view depth maps 82/82 byte-identical to upstream's CUDA 11.3 |
cheshire-alicevision-cuda-windows-x64-cuda12.9.zip |
GTX 1080 Ti, Windows (bench-pc) | 12/12 at 7/7, 0 bugchecks | no GPL library; resize exact with --fmad=false |
The Windows packages carry no SuiteSparse (both packagers refuse one that does), and every batch file in them is CRLF (all three packagers rewrite and verify it). Outside the matrix, the fixed StructureFromMotion carried the 884-view False Door through Meshroom with every self-check on and zero differences, and the 1678-view Rubble ran to a textured mesh on a 2013 dual-core.
The False Door fix run's Meshing, rerun from its kept cache with no self-checks through the 0.3.3
release package (cheshire-run.cmd aliceVision_meshing, the node's own command line, Ryzen 5 5600X +
RX 9070): 479 s with the depth maps read cold from disk, 489 s warm with
CHESHIRE_GPU_VIS_LOG=1. fuseFromDepthMaps is about 290 s of it:
| phase | cold run | warm run, profiled |
|---|---|---|
| load depth maps (12 threads, gaussian on the GPU) | 111 s | 43 s |
filterByPixSize x5 (kd-trees on the CPU, index-order rounds) |
~16 s | ~16 s |
| removeInvalidPoints, margin setup | ~9 s | ~9 s |
| visibility pass 1 (9,937,481 points, 831 cameras) | 84 s | 72 s |
| visibility pass 2 (4,699,910 points) | 66 s | 133 s |
The visibility accounting (pass 1): votes 38.3 s, backproject 23.3 s, maps 4.7 s, index 3.2 s, device wait 1.7 s; the device's own kernel time, 38.7 s, overlaps the host work. So the two visibility passes are bound by host work - collecting votes and backprojecting pixels, camera by camera - not by the knn kernels and not by the disk. The kd-tree filter that was left on the CPU after step 4p is about 16 s of 480. After fusion: tetrahedralisation 25 s (geogram), votes, graph and cut about 60 s, post-cut processing and cleaning about 95 s, writes about 20 s. Pass 2's 133 s in the profiled run against 66 s in the cold one is not explained yet; other work was running on the box during the second run.
The first steps of the plan in docs/roadmap.md ("Fusion: the visibility passes on the GPU"), measured
with scripts/meshbench.py (the Meshing node's own command line on the mini6 cache, the 41-view
reference with its filtered maps generated by the 0.3.3 DepthMapFilter into build/ref/monstree-41-dmf,
and the kept 833-view False Door cache) on an idle 5600X + RX 9070.
Step 0. The development tree (build/av-gfx1201-popsift, the release's gfx12-generic payload tree)
reproduces the release package exactly: tetrahedralisation input checksums 7d875321... (6 views),
cdd23710... (41) and 64b36ee4... (833, the same as the False Door run of this morning). Idle, the
two visibility passes at 833 views are 61 + 50 s (votes 28.5 + 20.3 s, backprojection 20.9 + 22.0 s,
device kernel 21.5 + 17.2 s); the 133 s pass 2 of the first profile was load on the box. The Linux
bundle's nanoflann header is byte-identical to Windows's (1.9.0), and hipCUB is present in both
Windows toolchains and in the WSL build distro (house-pc needs none: it is header-only).
Step 2 (7213bfa), no output change. A per-pass FNV-1a digest of every vertex (coordinates, nrc, camera list in order, pixSize) on every path, stable across runs and identical at one OpenMP thread; CHECK now also redoes the votes on shadow copies from the host's own nanoflann answers and compares every vertex. Found on the way: 0 NaN depths in all three sets (the row count now uses the fill's test anyway), tree depth 28-35, and upstream's loop-position quirk at 833 views - the loop visits camera ids 0..830, among them 86 and 217 that are not in the camera list, and never visits the listed 831 and 832 (reproduced exactly; worth reporting upstream).
Step 3 (ebc33f0), bucketed host votes. Every thread used to scan all of a camera's answers and
apply only its own vertex range; a camera's votes fall in a narrow range, so one or two threads did
the work. The decisions are now made in parallel and the voting queries scattered stably into
buckets of 4096 vertices, applied in parallel in pixel order. Digests identical to step 2 on all three
sets, at one thread, and with CHESHIRE_GPU_VIS_BUCKETS=0; CHECK identical to the ordered reference on
all 9,937,481 and 4,699,910 vertices of the 833-view passes (3.1 billion queries each).
| 833 views | step 2 | step 3 |
|---|---|---|
| votes, pass 1 / pass 2 | 28.5 / 20.3 s | 16.6 / 12.1 s |
| visibility passes | 110.5 s | 94.5 s |
| Meshing node | 357-439 s | 340 s, twice |
Pass 1 now runs at 62 ms per camera, the device-bound figure the design predicted. Not explained yet: the device's own kernel time rose once it ran back to back (21.5 -> 36.5 s in pass 1, 0.64 -> 2.26 s at 41 views) - clocks under sustained load or the event timing; it is the floor from here, so it is measured before any device work.
The user's DJI Phantom 4 set (FC330, 4000x3000, 444 photos over a 400 m x 400 m grid, shots about
15 m apart, GPS in every EXIF) through house-pc's node app, Standard preset, CHESHIRE_QR_NULLSPACE=1.
First run: SfM placed 49 of 444 views (94,238 landmarks), and the rest of the pipeline built a
5.5 M-face mesh of one 267 m x 134 m patch in 28 minutes.
Not the matcher and not QR: one matching chunk rerun with QR on and off kept the same 29 of 1051
pairs, 40,236 against 40,344 matches. The pairs themselves were wrong. Above minNbImages=200
Meshroom's ImageMatching stops comparing every pair and takes ~50 partners per photo from the
vocabulary tree; on uniform ground the partners it chose were a median 214 m away, only 20 % of each
shot's 8 GPS-nearest neighbours were ever proposed (95 % of those verified), and 419 of 10,922
proposed pairs survived geometric verification (3.8 %). The 150-250-photo captures the app was
built for never see this: below 200 photos the pairing is exhaustive.
Rerun with ImageMatching:method=Exhaustive (the app's new "Image pairing" option, everything
else unchanged): 98,346 pairs, 2377 verified, 444 of 444 views placed, 829,908 landmarks,
6,455,085 faces, 26 atlases, 3.66 h on the i3-4330 + RX 6750 XT. Per node: FeatureExtraction 175 s,
FeatureMatching 2542 s (23 chunks), StructureFromMotion 871 s, PrepareDenseScene 717 s, DepthMap
6219 s, DepthMapFilter 291 s, Meshing 348 s, MeshFiltering 81 s, Texturing 1917 s.
What GPS pairing would have done, from the rerun's verified pairs and the photos' coordinates: the real pairs sit a median 29 m apart, 90 % within 39 m, 99 % within 56 m, one at 302 m (almost certainly a false match).
| radius | pairs proposed | share of exhaustive | verified pairs caught |
|---|---|---|---|
| 40 m | 2,584 | 2.6 % | 2,169 of 2,377 (91.2 %) |
| 50 m | 3,593 | 3.7 % | 2,330 (98.0 %) |
| 60 m | 5,674 | 5.8 % | 2,360 (99.3 %) |
| 80 m | 9,071 | 9.2 % | 2,376 (100 % but the outlier) |
| 100 m | 13,687 | 13.9 % | 2,376 |
An 80 m radius (5.4x the shot spacing) finds every real pair with a tenth of the exhaustive matching, about 4 minutes instead of 42 on this box; that is the target for the GPS pairing item in docs/roadmap.md (0.3.5). The first run's folder was deleted after the rerun completed; the rerun's photos are hard links and keep their data.
Step 1 of the fusion plan added #pragma clang fp contract(off) to knnGPU.cu (5d6045b) on the
reading that HIP fused the "unfused" metric and the initial bounding-box distances, which would
explain the ~20 % of last-bit distance differences the Linux knn check reports. Tested on house-pc
(RX 6750 XT, Linux) on the 41-view job's own Meshing cache, the 0.3.3 bundle against a rebuild at
72f9c14: the check reports exactly the same counts - 17,383,299 and 18,404,248 of 87,354,192
queries with a different distance, 0 naming a different vertex - so the pragma changed nothing and
the cause is elsewhere. The two bundles are otherwise identical on this box (votes 79,843,309 and
73,069,905, tetrahedralisation input 4fab73b4bdbd49 in both), the bucketed votes check
("identical to the ordered host reference") passes on Linux, and the Windows results were unchanged
by the pragma (docs above). Next for the distances: dump a handful of differing (query, vertex)
pairs from the check and recompute the metric on the host in both forms to see which arithmetic
the device is actually doing.
The roadmap item was "analytic Jacobians for bundle adjustment", on the profile above (Jacobians
49 % of BA on the engine bay, 421 s of 733 s on the False Door). Reading upstream's cost function
changed the shape of the work. ProjectionSimpleErrorFunctor (sfm/bundle/costfunctions/projection.hpp)
is a ceres::DynamicAutoDiffCostFunction whose functor moves the point into the camera frame with
Jets and then calls CostIntrinsicsProject through DynamicCostFunctionToFunctorTmp.
CostIntrinsicsProject is already analytic: it returns the projection and its derivatives with
respect to the intrinsics, the distortion and the camera-frame point. The cost is in how Ceres
drives it: a dynamic autodiff functor is evaluated in passes of Stride (4) derivative
components, and every pass calls CostIntrinsicsProject::Evaluate with all three Jacobian blocks
again. With the point (3), the pose (6) and the intrinsics (4 + distortion) free, that is three to
five full analytic evaluations per residual block per Jacobian, each with the Eigen temporaries
those derivative methods allocate, plus the Jet arithmetic and one heap allocation per pass
(dynamic_cost_function_to_functor.h:127-131).
Step 5m (hip/port/sfm_ba/projectionCheshire.hpp) gives CHESHIRE_BA_JACOBIANS three settings:
autodiff: upstream, unchanged.stride: upstream's functor instantiated withStride32, so one pass. A derivative component is computed by the same operations whatever the stride, so the numbers cannot change; only the number of times the inner function runs does. The default.analytic: oneCostIntrinsicsProject::Evaluate, then the chain rule by hand through the pose (angle-axis and centre) and, for rigs, the sub-pose. The rotation's derivative comes from Ceres'AngleAxisRotatePointon 3-component Jets, i.e. the arithmetic autodiff itself uses for it, so the only difference from upstream is the order of the chain-rule sums. Opt-in until the quality gate of 0.3.5 exists, as the roadmap item said.
CHESHIRE_BA_CHECK=1 evaluates a reference cost function next to the selected one on every call
(upstream's autodiff, or the analytic one when autodiff is selected) and prints, after each solve,
how many residual and Jacobian values differed and by how much. scripts/sfmbench.py runs
aliceVision_incrementalSfM from a prepared feature-and-match cache with Meshroom 2023.3's
default node options and CHESHIRE_BA_PROFILE=1, and records one JSON line per run.
41 views (monstree full, dspsift), RX 9070 box, 12 threads, idle. Two runs per setting, one check run each; every run ended with 41 poses and 68 bundle-adjustment solves:
| Jacobians | SfM wall | BA total | Jacobians | linear solver | residuals | landmarks | RMSE |
|---|---|---|---|---|---|---|---|
| autodiff (upstream) | 67.0 s, 66.5 s | 26.9 s, 26.4 s | 13.0 s, 12.8 s | 6.4 s, 6.2 s | 0.8 s, 0.7 s | 80,808; 80,795 | 1.2359; 1.2324 |
| stride 32 | 60.6 s, 60.6 s | 20.6 s, 20.3 s | 6.9 s, 6.8 s | 6.2 s, 6.1 s | 0.7 s, 0.7 s | 80,811; 80,793 | 1.2334; 1.2322 |
| analytic | 56.6 s, 57.0 s | 17.9 s, 18.0 s | 4.3 s, 4.4 s | 6.2 s, 6.2 s | 0.6 s, 0.6 s | 80,803; 80,802 | 1.2329; 1.2338 |
The check runs (build/sfmbench/41/runs/stride-check, analytic-check), 3.54 million
evaluations of which 2.12 million with Jacobians, over the same 68 solves:
- stride against autodiff: 0 of 7,089,684 residual values and 0 of 67,949,760 Jacobian values differ. Bit-identical, as the argument says it must be.
- analytic against autodiff: residuals 0 of 7,089,912 differ; Jacobians 12,700,913 of 67,952,448 (18.7 %) differ, by at most 9.09e-13 absolute and 4.38e-10 relative. Rounding of the chain-rule order, nothing else.
So the Jacobian phase is 1.9x faster at the same numbers, and 3.0x faster at rounding-level
differences; bundle adjustment as a whole goes from 26.7 s to 20.5 s and 18.0 s, the node from
66.8 s to 60.6 s and 56.8 s. The landmark counts (80,793 to 80,811) and RMSE (1.2322 to 1.2359)
spread the same way within a setting as across them: that is incremental SfM's run-to-run
variation (unseeded RANSAC across threads, docs/17), not the Jacobians. What is left in BA after
the change is the linear solver (6.2 s) and the per-solve problem construction and trust-region
bookkeeping ("other", 6.7 s in every setting); the 4.3 s of analytic Jacobians is one inner
evaluation per block, with the Eigen dynamic-size temporaries getDerivativeTransformProjectWrt*
return - the next slice, and the shape the device version of 0.3.5 will need anyway.
Engine bay (107 photos, dspsift), same box. One run per setting and one check run; 107 poses in every run, 140 solves (141 in the analytic runs: the trajectory differs by rounding, so the solve count can too):
| Jacobians | SfM wall | BA total | Jacobians | linear solver | residuals | landmarks | RMSE |
|---|---|---|---|---|---|---|---|
| autodiff (upstream) | 69.2 s | 34.7 s | 18.7 s | 8.1 s | 1.2 s | 140,636 | 1.6851 |
| stride 32 | 60.0 s | 25.5 s | 9.9 s | 7.8 s | 1.2 s | 140,387 | 1.6890 |
| analytic | 58.6 s | 24.2 s | 6.8 s | 8.7 s | 1.2 s | 140,647 | 1.6839 |
The analytic check on this set (929,238 evaluations, 421,505 with Jacobians): residuals 0 of 1,858,476 values differ; Jacobians 2,847,747 of 7,036,470 differ, by at most 4.55e-13 absolute and 5.45e-11 relative. Jacobians 1.9x and 2.75x, BA 34.7 s to 25.5 s and 24.2 s, the node 69 s to 60 s and 59 s; the landmark spread (140,387 to 140,647) is the run-to-run kind again.
False Door (884 views, sift), the kept cache of the 0.3.3 end-to-end run, one run per setting,
same box, idle (the residual-evaluation cost per unit of work, the same code in all three, is
45-52 ns in every run above and below, which is the check that nothing else was running). Here
the wall clock says nothing about the Jacobians, because incremental SfM's trajectory varies run
to run far more than the change does: the three runs did 1.42, 0.57 and 1.54 billion
residual-block-iterations of bundle adjustment (960, 922 and 936 solves; 48, 36 and 39 of them
global, over 300 k blocks), ended at 825, 831 and 828 poses, and took 2368 s, 1302 s and 1667 s.
The two earlier autodiff replays of the same cache (the CHOLMOD table above) spread the same way,
1069 s against 733 s of BA. What is comparable is the cost per residual-block-iteration, which
CHESHIRE_BA_PROFILE gives per solve:
| Jacobians | Jacobians, ns per residual-block-iteration (global solves) | same, all solves | residuals | SfM wall | poses / landmarks |
|---|---|---|---|---|---|
| autodiff (upstream) | 709 | 711 | 51 | 2368 s | 825 / 1,345,755 |
| stride 32 | 440 (1.6x) | 443 | 52 | 1302 s | 831 / 1,355,046 |
| analytic | 251 (2.8x) | 251 | 45 | 1667 s | 828 / 1,350,277 |
The same measure on the small sets: 41 views 865-899 / 453-461 / 293-322 ns, engine bay 701 /
380 / 216 ns (global solves), so the per-work gain is the same at every size and the small sets'
wall-clock ratios are the honest ones there because their trajectories barely vary. Two things
follow. A bundle-adjustment change at 884 views cannot be judged by wall clock until SfM is
seeded per task (the 0.3.4 determinism item), and the per-work numbers are what
scripts/sfmbench.py's logs should be read for at that size. And the stride mode's 1.6x here
against 1.9x on the small sets is the intrinsics: at 884 views one intrinsic block is shared by
every camera and locked most of the time, so the active parameter count is 9 and upstream needs
three passes, not five.
The roadmap item said: seed SfM per task instead of per process, start with a one-thread rerun.
The one-thread rerun was the surprise. Two runs of aliceVision_incrementalSfM on the 6-view set
with --maxCoresAvailable 1 (one OpenMP thread, one Ceres thread, the default seed 5489) ended
with the same 9290 landmarks and the same RMSE to six digits, and poses that differed in the last
bits (0.075924026963859248 against ...373), so threads were not the whole story. The rest is
Ceres: ParameterBlockOrdering is std::map<int, std::set<double*>>, so within an elimination
group the parameter blocks are ordered by address; upstream keeps them in std::map nodes, and
the Windows heap hands out node addresses in an order that changes from run to run. A different
elimination order changes the rounding of the reduced camera system, and the trajectory drifts
from there. Step 5n (hip/port/sfm_ba/deterministic.hpp) does three things, always on:
- A generator per task. Resection and LO-RANSAC triangulation drew from the engine's one
std::mt19937inside OpenMP loops (a data race as well as a scheduling dependence). Each view's resection and each track's triangulation now derive their own generator from (seed, task kind, task id, resection pass), so a task draws the same samples on any thread at any time (CHESHIRE_SFM_TASK_SEED=0restores the shared generator). - Key order, not address order, in Ceres. Landmark blocks live in one contiguous array in key
order (
OrderedBlocks), and every pose, rig sub-pose, intrinsic and distortion block gets its own ordering group numbered by key (poses from 1, rig sub-poses from 1e6, intrinsics from 2e6, distortions from 3e6); the Schur solvers only need the first group to be the points, so the groups above it are pure order. - A total order in the next-best-views ranking, where
std::sorton the score alone broke ties by the order threads finished in.
What that leaves is Ceres' own multi-threading: with num_threads > 1 the Schur eliminator adds
each chunk's contribution to the reduced matrix in arrival order. CHESHIRE_SFM_DETERMINISTIC=1
therefore runs bundle adjustment on one Ceres thread (CHESHIRE_BA_THREADS=n sets it
explicitly) while SfM's own OpenMP loops keep every core, because the three points above make them
order-independent.
The ladder, 6 views, --json output (the Alembic file carries the date it was written, the JSON
does not), SHA-256 of sfm.sfm and cameras.sfm:
| runs | sfm.sfm | cameras.sfm | landmarks |
|---|---|---|---|
| all single-threaded, twice | c468440e1cf81fc3, c468440e1cf81fc3 |
2dc4f6b3b9ed671d, same |
9294, 9294 |
CHESHIRE_SFM_DETERMINISTIC=1, 12 OpenMP threads, twice |
c468440e1cf81fc3, c468440e1cf81fc3 |
2dc4f6b3b9ed671d, same |
9294, 9294 |
| default (12 threads everywhere), twice | 760d02d78b7a64e5, 0ea1f5a1f1af676a |
differ | 9294, 9294 |
shared generator (CHESHIRE_SFM_TASK_SEED=0), Ceres 1 thread, 12 OpenMP threads, twice |
0cf9f2c162b6ab89, 6404929cef7fb28e |
differ | 9298, 9292 |
Byte-identical across the two single-threaded runs (the address-order fix), byte-identical across the two deterministic multithreaded runs, and identical between the two rows - the parallel loops now produce exactly the single-thread result. The default differs only by Ceres' threads, and the shared generator is the row that changes the landmark count, so it was the larger of the two.
41 views:
| runs | sfm.sfm | cameras.sfm | landmarks / RMSE | SfM (the node's own "took") | BA total |
|---|---|---|---|---|---|
CHESHIRE_SFM_DETERMINISTIC=1, 12 OpenMP threads, twice |
1321c85863ce62fe, 1321c85863ce62fe |
b45f6f46859ae678, same |
80,808 / 1.23415 | 115.3 s, 111.9 s | 73.6 s, 71.2 s |
all single-threaded (--maxCoresAvailable 1) |
1321c85863ce62fe |
b45f6f46859ae678 |
80,808 / 1.23415 | 163.0 s | 70.2 s |
| default (12 threads everywhere) | a55815d5fc24375f |
d0952dddaef546ad |
80,808 / 1.23415 | 64.3 s | 23.2 s |
Identical again, and again identical to the single-threaded result. The default run reached the
same landmark count and RMSE to six digits - Ceres' thread order moves the last bits, nothing a
user would see - and it is the reference for the cost: the deterministic mode is bundle adjustment
on one Ceres thread, 72 s against 23 s here, so the node takes 1.8x as long (112-115 s against
64 s); fully single-threaded it is 163 s. The JSON export the comparison needs costs 17.5 s at
this size against 0.2 s for Alembic (cameras.sfm, always written and small, plus the landmark
count is the cheap first check). The per-task generators and the key-ordered blocks are on in
every mode, and cost nothing measurable: two default runs took 64.9 s and 66.1 s of SfM against
69.8 s and 70.4 s for two runs with the shared generator restored (CHESHIRE_SFM_TASK_SEED=0),
on a box that was getting busier as the four ran (residual evaluation, the same code in all of
them, drifted from 55 to 61 ns per residual-block-iteration; the 57.9 s runs before step 5n were
on the idle box at 49 ns). If anything the shared generator is the slower one - twelve threads
writing the same 2.5 KB of generator state - but the drift is of the same size as the difference.
scripts/sfmbench.py run <set> --tag X --json CHESHIRE_SFM_DETERMINISTIC=1, twice, and equal
digests, is the gate for every later SfM change; it replaces the n=10 repeats docs/17 needed.
The 5i profile is Ceres' own clock and stops at Solve. Step 5o times the rest of
BundleAdjustmentCeres::adjust (CHESHIRE_BA_PROFILE=1 now prints a second line per solve,
cheshire: BA adjust: build ... solve ... update ... destroy), and the first run said where the
"other" was. 41 views, 68 solves, 4.42 million residual blocks in all, the box busy (residual
evaluation 62 ns per residual-block-iteration against 49 idle):
| phase | total | per residual block | what it is |
|---|---|---|---|
| build | 9.2 s | 2090 ns | createProblem: a cost function, a std::vector of four pointers, nine std::map lookups, four ordering inserts and Ceres' AddResidualBlock per observation |
| log-only evaluations | (skipped) | upstream evaluates every landmark residual before and after the solve, single-threaded, for the two landmarksBlocks cost log lines; off unless CHESHIRE_BA_LOG_COST=1 |
|
| Ceres preprocessor | 5.5 s | 1253 ns | program reordering and the evaluator's block structure, per Solve |
| Ceres minimizer | 20.4 s | residuals 0.9 s, Jacobians 8.8 s, linear solver 7.9 s, trust-region bookkeeping the rest | |
| update | 0.2 s | the solution back into the SfMData | |
| destroy | 2.1 s | 476 ns | the Problem's destructor: every cost function and residual block freed |
So building and tearing down the problem cost more than evaluating its Jacobians. Two steps on it:
- 5p: a block enters the Ceres ordering once (upstream did four
AddElementToGroupcalls per observation, each astd::mapfind over every block, nearly all on a block already there). No measurable change (2160 ns on a busier box): the ordering was never the cost. - 5q: the per-view part of the lookup - view, pose state, the three block pointers, the
intrinsic object, the ordering - is done once per view per solve through an
unordered_mapfilled on first use; the landmark enters the ordering once; the four pointers go through Ceres' array overload instead of a heap-allocated vector; and the problem is built withdisable_all_safety_checks(the sort and duplicate scan of the pointers per residual block; the problem is well-formed by construction).
| Jacobians | build, before | build, after 5q | box (residual ns) |
|---|---|---|---|
| stride (default) | 2160 ns per block (9.55 s) | 1486 ns (6.57 s) | 68 -> 58 |
| analytic | 1811 ns (8.01 s) | 1177 ns (5.20 s) | 56 -> 49 |
Corrected for the box drift that is 19 % and 27 % off the build. What is left, 1.2-1.5 us per
residual block, is Ceres' AddResidualBlock (its own allocations and hash lookups) and the cost
function's allocations - the analytic cost function is one object holding the intrinsic part by
value, the autodiff one is a functor, an inner cost function and a wrapper - and the destroy is
their mirror image. Only a Problem that lives across solves would remove those, and Ceres would
still run its preprocessor per Solve, so the ceiling of that redesign is build + destroy, about
1.9 of the 7.5 us every residual block costs per solve here (a quarter of bundle adjustment, a
tenth of the node). It is on the roadmap with that number, not in the code.
The deterministic gate after these steps: the 6-view and 41-view runs with
CHESHIRE_SFM_DETERMINISTIC=1 reproduce the digests recorded above exactly (41 views:
1321c85863ce62fe / b45f6f46859ae678), so none of 5o-5q changed a number.
Step 5r (hip/port/sfm_ba/persistent.inc). The engine keeps one BundleAdjustmentCeres for the
whole reconstruction (ReconstructionEngine_sequentialSfM::_cheshireBA), and each adjust()
applies the delta to a ceres::Problem that is never torn down: a merge walk of the scene's
landmarks against the records adds residual blocks for new landmarks and observations, removes
them for gone ones, copies values in, toggles constant/variable; ignored landmarks lose their
residual blocks and ignored poses go constant (upstream leaves both out of a fresh problem; Ceres
drops blocks without residuals and residuals whose blocks are all constant from the reduced
program, so the solver sees the same problem). Rigs, survey points, 2D and point constraints,
rotation priors, temporal smoothness, depth observations and mesh-referenced landmarks fall back
to the rebuild; CHESHIRE_BA_PERSIST=0 restores it.
Two upstream traps on the way, both invisible while the problem was rebuilt every solve. The
intrinsic block vector was assigned intrinsicPtr->getParameters(), a temporary, so it was
move-assigned a fresh buffer every solve under the pointer the Problem held: one extra parameter
block per intrinsic per solve, then residuals evaluating freed memory (an access violation, or
Ceres' "Map key not found" when the stale block's index no longer existed). It is copied in place
now. And a kept observation whose view had lost its pose since the residual was created would
dangle once the pose block went; kept observations are re-validated.
The invariant, checked. CHESHIRE_BA_PERSIST_CHECK=1 recomputes after every sync the set of
(landmark, view) pairs the scene says should have a residual block - the non-ignored landmarks'
observations from posed, non-ignored views - and compares it with the records, and asks Ceres for
the residual ids it holds per landmark block and compares those with the records too; before
each sync it checks the ids again, so a change between solves would show. Consistent on every
solve: 13 of 13 checks on 6 views, 96 of 96 on 41, and 1036 of 1036 on the 884-view False
Door - the first set above 100 poses, so the first with local bundle adjustment and its
ignored/constant states (961 solves, 815 poses; the landmark slab, reserved at 1 M, overflowed
once at 1.34 M landmarks and the problem was rebuilt, so the minimum is 2 M now). The
deterministic gate: two runs byte-identical on 6 views (40d4bbb85c8d521f) and on 41
(50759f7d5b5e2eb3); the digests differ from the rebuild's, since the residual and landmark-block
order is creation order rather than key order, which is the order of floating-point sums.
Cost, 41 views, rebuild against persistent back to back, twice each (the per-solve line of
CHESHIRE_BA_PROFILE=1; the second persistent run overlapped the start of a Linux build):
| build | destroy | Ceres preprocessor | update | SfM wall | |
|---|---|---|---|---|---|
rebuild (CHESHIRE_BA_PERSIST=0) |
6.54 s, 6.43 s | 1.87 s, 1.71 s | 5.97 s, 5.84 s | 0.16 s | 65.3 s, 64.5 s |
| persistent | 2.40 s, 2.31 s | 0.09 s, 0.08 s | 8.25 s, 7.40 s | 0.29 s | 63.8 s, 60.0 s |
Build 6.5 s to 2.4 s (the sync still walks every landmark and adds the new observations) and
destroy 1.8 s to 0.1 s, against Ceres' preprocessor 5.9 s to 7.8 s: it reorders and scans the
residual blocks every Solve, and in a persistent problem those objects were allocated at
different times and shuffled by removals, so its scans miss cache more. Net about 4 s of 65 s at
41 views, 6 % of the node - the ceiling of a quarter of BA was optimistic, as the preprocessor
does not shrink with the build. The False Door's build and preprocessor per residual block are
not measured yet without the check (the check is O(observations) per solve and sat inside the
build figure); the rebuild-against-persistent pair there is queued.
The False Door pair without the check, persistent then rebuild, one run each (box slower during the persistent run: residual evaluation 58 against 49 ns per residual-block-iteration):
| main solves | active residual blocks, summed | build | Ceres preprocessor | destroy | |
|---|---|---|---|---|---|
| persistent | 419 | 70.8 M | 177 s (2492 ns per active block) | 146 s (2066 ns) | 2 s |
| rebuild | 418 | 78.9 M | 177 s (2249 ns) | 121 s (1536 ns) | 45 s |
Box-corrected the build is a wash and the preprocessor 12 % worse; only the destroy is a clear
win. The reason is the local strategy: the ignored region moves with the frontier every solve, so
the sync added 23.6 million residual blocks and removed 18.4 million over those 419 solves
against 70.8 million active in all - most of the active set is torn down and rebuilt anyway - and
Ceres' preprocessor scans every residual block in the Problem per Solve, not the active ones.
Both paths also pay about the same to walk every landmark each solve (1.35 million, mostly
ignored, per solve), which is why the rebuild's build is 2.2 us per active block here against
1.2 us at 41 views; that walk is a shared inefficiency, noted on the roadmap. So the engine sets
CeresOptions::cheshirePersist to "local strategy off": the Problem lives across solves while
every landmark is active (up to 100 poses, the 6 % above) and is dropped when the strategy
switches on; the engine bay, 107 poses, crosses that line mid-run and its check run stayed
consistent through the switch (168 of 168). The sync also keeps each landmark's block pointer in
its record now - a std::map lookup per landmark per solve was most of its cost at this size.
CostIntrinsicsProject computed the projection and its three Jacobian blocks by four separate
walks of the same chain - project(), then the derivative with respect to the intrinsics, the
distortion and the point - each recomputing P = X/z and the distortion polynomial through virtual
calls, and three of them returning dynamic Eigen matrices, i.e. heap allocations per residual
block per Jacobian. CheshireIntrinsicsProject (in hip/port/sfm_ba/projectionCheshire.hpp)
walks the chain once for a pinhole with no distortion or a radial K1 / K3 / Brown one - what
incremental SfM sees - straight into Ceres' row-major blocks, with upstream's formulas in
upstream's order of operations where it has one, and contraction off (#pragma clang fp contract(off), as in every port): with contraction on, 20 % of the residuals differed from
upstream by up to 3e-13; with it off, under 1 % differ, by up to 6e-13, a different association
somewhere in Eigen's evaluation rather than a formula. Fisheye, 3DE and undistortion models keep
CostIntrinsicsProject, as does CHESHIRE_BA_FUSED_PROJECTION=0.
41 views, same box, same day: Jacobians 5.5 s to 3.2-3.5 s, bundle adjustment 24 s to 19-21 s,
the node 59 s to 50-52 s. The check against upstream's autodiff over 3.18 million evaluations:
Jacobians differ by at most 1.4e-12 absolute (4.5e-11 relative on the small sets), as before the
fusion; the deterministic pair is byte-identical (15618f943864c8ba).
0.3.4: the passes after every bundle adjustment, restricted to what the solve touched - and a retraction (2026-09-23)
Upstream follows each bundle adjustment with removeOutliers() - the pixel-residual test over
every observation of every landmark, then the angle test over every landmark - and with
eraseUnstablePosesAndObservations(), a recount of every observation per pose. At 884 views that
is 64 such passes (one per resection group) over a scene that reaches 1.35 million landmarks, for
solves that under the local strategy moved about a hundred poses.
Step 5s (hip/port/sfm_ba/postAdjust.inc) restricts them, exactly. The tests depend only on
(pose, intrinsics, landmark position, observation), so an observation whose inputs did not change
since its last test keeps its verdict. After a solve the changed ones are the observations of the
landmarks seen by a REFINED pose - which covers every refined landmark (a landmark is refined only
when a refined camera sees it), everything triangulation added since the last pass (it hangs off
the new, refined, views), and the landmarks the local strategy ignores because a refined and an
ignored camera both see them (their refined observer moved) - and the landmarks observed through
a REFINED intrinsic, whose parameters apply to every view that uses it, active or not. The
candidates come from those views through the tracks-per-view index, never from walking the
landmarks. A pose can only have become unstable by losing observations, so only the poses of views
that lost one, and the poses resected in the group, are recounted, through the same index; if a
pose does go, upstream's full pass finishes the iteration. Without the local strategy every pose
is refined and upstream's passes run unchanged.
That intrinsic source matters more than it sounds. Upstream's local strategy turns an intrinsic constant only once its focal length has been stable across a window of 25 posed views, so an intrinsic with fewer views than that stays refined for the whole reconstruction. The False Door has five: the 820-view one settles at 113 poses, the other four (1, 13, 23 and 27 views) never do. The first cut of this step took a full pass whenever an intrinsic was refined - on this set that was every pass, and the deterministic run said so (0 restricted passes by view 315) - so the rule became the one above: a refined intrinsic's views are candidate sources like a refined pose's.
Exactness, measured with the deterministic gate (same input, one Ceres thread, digests of
sfm.sfm and cameras.sfm; the before-digests were taken with the kept pre-5s install, the
after-digests with the restricted passes on). The engine bay, 107 poses, crosses into the local
strategy for its last three solves, which ran restricted - 137,867, 29,238 and 28,013 candidates
of about 138,000 landmarks, 37, 6 and 10 poses recounted, 0 refined intrinsics - and ended with
the same digests as the full passes (a76946037b3860e6 / 38450e663421b3e4, 140,490
landmarks). The 41-view set never enters the local strategy and is unchanged
(15618f943864c8ba). The 884-view set, 833 poses and 1,355,427 landmarks, 64 passes of which 34 ran restricted
(4 refined intrinsics throughout; 25 % to 60 % of the landmarks each, 6 to 30 poses recounted):
the same digests as upstream's passes, 60b2841c14e21182 / e0984f42c3f2512e, RMSE 1.36595.
What it is worth: a retraction. The first version of this section, and the commit message
that introduced the step, said these passes were 605 s of a 1908 s node at 884 views. That
figure summed the log's silences after every adjust end, and most of it was a single silence
of 494 s - the triangulation phase of one resection group, while a build on the same box was
starving the run - not the passes. Measured directly, upstream's full passes cost about 40 s over
the whole 884-view run (0.6 s at 300,000 landmarks, 1.2 s at 590,000, under 3 s at 1.35 million)
and the restricted ones 43 s (34 passes of 25 % to 60 % of the landmarks each: at this size the
refined frontier is still a large share of the scene, and a candidate list costs more per
landmark than a sequential walk). So at 884 views the step is neutral, and it ships opt-in,
CHESHIRE_SFM_LOCAL_PASSES=1. Its case is scale - a full pass grows with the scene, a restricted
one with the frontier, and the local strategy exists for reconstructions of thousands of views -
which is unmeasured here (the 1678-view set is no longer on disk). What the mining did settle,
on a quiet box, is where the time outside Ceres goes at 884 views: nowhere in particular. The
largest silences are the solves themselves; the per-solve walk over every landmark inside the
bundle adjustment's build - 177 s over the run, the roadmap item this step was mistaken for -
remains the open one.
The 884-view job on house-pc (i3-4330, RX 6750 XT) spent 6.5 h in DepthMap at 28 s per view, and a minute of the card's busy counter read 0 for 55 of its 60 seconds. Splitting a 12-view chunk by its log timestamps and the output files' write times:
| per 12-view chunk | house-pc | RX 9070 box |
|---|---|---|
| chunk setup | 19 s | 27 s |
| image loading, first batch | 48 s | 30 s |
| image loading, each later batch of 3 views | 64 s | 34 s |
| GPU tiles (SGM + refine), per view | 4.3 s | 4.6 s |
| writing the depth and sim maps, per batch | 2 s | 1 s |
| whole chunk | 325 s | 185 s |
The kernels take about 4.5 s per view on both cards. Everything else is the CPU getting the 6000x3376 half-float EXRs that PrepareDenseScene wrote, 73 MB each, into the host image cache - and the disks are not the limit (both are SATA SSDs at 250-385 MB/s; a batch's 2.2 GB reads in under 10 s). It took four wrong explanations to find the right one; they are kept here because each was plausible from the outside, and the method that settled it was not inference but a per-file profile inside the node.
1. A chunk's cameras were unrelated. The camera index order is the SfM view-id order, which
is hash-like, so a 12-view chunk held 12 cameras from all over the scene: the debug run loaded
117 images for 12 views, 114 of them distinct. Nothing was shared between consecutive R cameras
or consecutive batches, and the device cache, sized for one batch, never had a hit. Step 5u orders
the cameras as a nearest-neighbour tour over their centres (greedy, ties by index, so the tour is
the same on every machine) and a chunk is a slice of the tour; the estimator's batch slots and
its write loop now go by position in the tile list, since upstream assumed a batch's cameras are
consecutive indices (the first tour run wrote 514 maps for a 12-view chunk through that
assumption, and two cameras of a batch could share a slot). Outputs are per view id and do not
depend on the grouping. CHESHIRE_DEPTHMAP_ORDER=0 restores the index order.
2. Every camera of a batch was loaded, whether or not the device had it. The prefetch loaded
the R and all T cameras of the batch, including the ones the device cache still held from the
previous batch. Step 5t touches the resident cameras first, so the device LRU keeps them, then
loads only the missing ones in parallel, in groups no larger than the host cache (61 images at
the working resolution: 5000 MB over 81 MB each), uploading group by group. One line per batch
says what happened: cheshire: depth map batch 2/3: 23 cameras, 10 decoded and uploaded (13 already on the device). On the same chunk in tour order: 42 loads instead of 117. (An earlier
draft of this section said the host cache held 16 images and thrashed; that used the full
resolution, and it was wrong - the cache is sized after the downscale below.)
3. The reader. OpenImageIO's ImageBuf::read of one of these files takes 1.8-2.0 s on the RX
9070 box whatever its thread setting; the same file through Imf::InputFile with the OpenEXR
thread pool takes 0.3 s, and the pixels are identical (half to float is exact; a full-image
check: max difference 0). Step 5v reads an EXR straight through OpenEXR when it is what these
nodes read - R, G, B and optionally A, half or float, full data window, requested as float RGB or
RGBA in the colour space it is stored in - and leaves everything else to OpenImageIO. The reader
is its own translation unit of the image library because main_cameraInit.cpp includes io.cpp
directly and cannot see OpenEXR's headers; inside a parallel loop it decodes each file in its own
thread, in blocks of 64 scanlines, and a lone read keeps the pool. CHESHIRE_EXR_DIRECT=0
restores the OpenImageIO path; CHESHIRE_EXR_PROFILE=1 prints one line per file with the open
time, the read time and the thread's CPU time. That profile is what found the real cost:
4. The host downscale after every read. With the node's downscale 2, loadImage resizes
each image on the host from 6000x3376 to 3000x1688 through OpenImageIO's resize right after
reading it, inside the prefetch's parallel loop. The reader took 0.7 s per file and ran twelve
wide; the resize took the rest of the 10-12 s per batch, because each of the twelve threads
handed OpenImageIO its own twelve workers and 144 threads thrashed - 100 s of CPU for 11 s of
wall, no less wall than a single thread. Step 5w gives resize one thread when the caller is
already inside a parallel region; the result does not depend on the thread count - and it
changed nothing, which says the resize is expensive by itself, not through its threading: OpenImageIO
evaluates a 25 x 25-tap footprint per output pixel for a 2x lanczos3 downscale (169 non-zero taps),
about 7-9 s of CPU per 20-megapixel image. Twelve images at a time or twelve threads on one image,
the CPU is the same. The next step is an exact port of that resize for the integer-downscale case,
same weights and summation order, with the footprint's weights computed once instead of per pixel;
until then the depth-map node's loading is bounded by it. (Three explanations tried before the
profile - a serialised page-fault path, the shared OpenEXR pool,
and the runtime environment of the node - were each refuted by a standalone reader that decoded
the node's own 16 files in 0.83 s with the same libraries, and by a benchmark that stayed fast
with the HIP runtime and pinned memory in the process.)
5. The downscale itself, exact and 18x cheaper. ImageBufAlgo::resize with the default
filter is lanczos3 with a width of 6 destination pixels; for a 2x downscale that is a 13 x 13
footprint per output pixel, evaluated tap by tap with the filter function called per tap, which
is where the 7.3 s of CPU per 6000x3376 image went (single-threaded; the same total across
threads). Step 5x (hip/port/sgm_fused/cheshireResize.hpp.txt) computes the same thing with
the same arithmetic in the same order - OpenImageIO's coordinate mapping, its lanczos3 from
libutil/filter.cpp, the per-column and per-row normalised weight tables, rows outer and
columns inner, a multiply then an add per tap with contraction off, the same clamping at the
borders - with each column's and row's clamped tap indices and non-zero weights computed once
instead of per pixel, four output pixels' add chains interleaved, and the channels added with
SSE. Checked against ImageBufAlgo::resize itself in build/exrbench/exrresize.cpp: 0 of
20,256,000 values differ on a real image, 0 on odd-sized random images at 2x and 3x. On the RX
9070 box: 7.32 s to 0.41 s on one thread, 0.096 s with the rows in parallel. It applies to float
images of 1, 3 or 4 channels downscaled with the default filter; anything else keeps the
OpenImageIO call, as does CHESHIRE_RESIZE_EXACT=0.
Measured on False Door chunk 0 (12 views) on the RX 9070 box:
| loads | image loading | chunk wall | |
|---|---|---|---|
| before (index order, old loader, OpenImageIO) | 117 | 25-34 s per batch | 214 s |
| tour order + once-per-batch loader (OpenImageIO reader) | 42 | 16, 8, 10 s per batch | 142 s |
| + direct OpenEXR reader | 42 | 15, 8, 10 s per batch | 142 s |
| + resize single-threaded inside the loop | 42 | 16, 8, 9 s per batch (unchanged: the resize is the cost, not its threading) | 146 s |
| + the downscale computed directly (5x) | 42 | 4.7, 2.4, 2.8 s per batch | 94 s |
Exactness. The 12 maps of the index-order chunk are byte-identical to the reference cache with the new loader; the tour-order chunk's 12 views, compared by view id, are byte-identical to the reference (24 of 24 files) with the direct reader; the mini6 depth maps with the reader off and on are byte-identical (12 of 12); the mini6 texturing output with the reader off and on differs in 522 texels by at most 0.000977, which is the node's own run-to-run variation (two runs with the reader off differ in 556 texels by the same amount; the OBJ is identical).
6. What PrepareDenseScene writes. The writer's default for the undistorted images is ZIPS,
one zlib stream per scanline - 3,376 of them per 6000x3376 image - and every later read inflates
them: the depth-map node loads each image about 3.5 times over a job, texturing once per atlas
sheet. Step 5y has PrepareDenseScene write ZIP, sixteen scanlines per block: the same pixels, a
slightly smaller file, and a cheaper write and read. On the local mini6 job (6 images, 4032x3024):
283,096,208 bytes with ZIPS, 278,680,274 (1.6 % smaller) with ZIP; PrepareDenseScene 1.4 s against 1.3 s (the node is not where the time goes at six images); the decoded
pixels are identical in all 6 files (channels compared value by value); the depth maps computed from each set are byte-identical (12 of 12 depth and sim maps); the direct reader spends 16 % less CPU on the ZIP files (1.22 against 1.45 s for the six).
CHESHIRE_PDS_EXR_COMPRESSION names another method (the lossy ones change the values and are for
experiments only). Uncompressed was considered and rejected: 162 MB per image instead of 73, a
143 GB cache for the 884-view job on a SATA SSD, and the depth-map node's reads would be bound by
the disk instead of scaling across the threads that inflate.
7. Fewer chunks. Each Meshroom chunk is a separate process: it loads the SfM data with its
1.35 million landmarks, probes the device and starts with a cold image cache - 19-27 s before
its first tile at 884 views, plus a cold first batch - and Meshroom's block of 12 views makes 74
of them. Step 5z makes it 48, so 19. For Meshroom 2025 pairings that is the block size in the
AliceVision-provided node; for Meshroom 2023.3, whose nodes are compiled .pyc files, the
package carries share/cheshire/meshroom-overrides/DepthMap.py, a module that loads the
compiled node and re-declares it with the larger block, and the pairing scripts copy it beside
the .pyc (Python prefers the .py); --unpair removes it. Same attributes, same command line,
same UID, so an existing cache stays valid. CHESHIRE_DEPTHMAP_BLOCK sets another size for the
2023.3 override, 0 for Meshroom's 12.
The 884-view job on house-pc, before and after (2026-09-24). The same photographs, the same standard preset, on the i3-4330 with the RX 6750 XT: the s1 bundle (the 0.3.3 release plus the knn pragma) against the s7 bundle (everything above, steps 5m through 5z).
| stage | s1 | s7 |
|---|---|---|
| FeatureExtraction | 7.9 min | 9 min |
| FeatureMatching | 21.0 min | 21 min |
| StructureFromMotion | 77.9 min | 32 min |
| PrepareDenseScene | 37.5 min | 40 min |
| DepthMap | 395.0 min | 143 min (19 chunks) |
| DepthMapFilter | 16.2 min | 17 min |
| Meshing | 12.6 min | 12 min |
| Texturing | 220.6 min | 250 min (17 passes of 14.5 min) |
| whole job | 13 h 11 min | 8 h 46 min |
SfM did better than the local numbers predicted, since the four-thread box was even more Jacobian-bound. PrepareDenseScene did not gain: ZIP is a read-side change, and the sixteen-line blocks cost the i3 slightly more to write. Texturing's pace did not move either, which says a pass's time is not in the per-camera reads there; that node's profile is the next measurement.
What is left in the node after this is the first batch of each chunk, which is cold by construction, and the downscale, now a fraction of the read: the level stored in the EXR at PrepareDenseScene time would remove it from the node entirely with the values unchanged, and a 2x2 average on the device would remove the read bytes too, at the price of changed values and a new reference.
0.3.4: texturing was loading images too, and the depth-map downscale moves to the device (2026-09-24)
Texturing is load-bound. The same per-phase view that found the depth-map cost, on the False
Door's texturing (884 views, RX 9070 box, CHESHIRE_GPU_TEX_LOG=1): every pass re-reads the
cameras that contribute to its atlases, and the loads were 443 s of the first 486 s pass and
600-640 s of each later 11-12 minute pass, against 30-90 s for the upload, pyramids and
rasterisation together. Step 6a adds CHESHIRE_LOAD_PROFILE=1, one line per image load with its
parts. A load averaged 2.01 s: the read 1.79 s, the conversion to sRGB 0.18 s (OpenImageIO resolves
"linear" and "srgb" through OpenColorIO's built-in config, whose fast approximation matches the
textbook formula in only 1 % of values, so it stays), the exposure multiply 0.04 s and the second
open for the metadata 4 ms. The read was 0.1-0.25 s for the first loads and about 3 s once four
read-ahead threads were decoding at once: the texturing node reads ahead through std::async,
outside any OpenMP region, so every read went through OpenEXR's one shared pool, which serialises
concurrent files - the depth-map finding of 5v again.
Step 6c marks the image cache's read-ahead threads (a thread-local ConcurrentLoadScope) and the
direct reader decodes on such a thread instead of the pool. First pass 486 s to 243 s, loads 443 s
to 204 s, a read 1.79 s to 0.88 s. Step 6e reads ahead on every hardware thread but one, within the
image cache's slots. It did not speed this box up (257 s with eleven reads in flight): a pass reads
829 images of 73 MB, about 60 GB, and 60 GB in 214 s is 283 MB/s, the read rate of the SATA SSD the
cache sits on, so texturing here is now disk-bound. It matters on house-pc, whose available RAM
missed the 4 GB margin of the read-ahead rule and so read one camera ahead, with the image cache's
20 default slots allocated anyway; it now reads three ahead at no extra memory. Gate: the mini6
texture differs from the pre-6c run in 557-569 texels by at most one half-float step, the same as
two runs of the same build (556); the OBJ is identical.
The depth-map process downscale on the device (6d). Even exact and 18x cheaper (5x), the host
lanczos3 downscale was the larger half of a depth-map load's CPU, and loads were about half of each
chunk on house-pc's i3. With 6d the image cache keeps full-resolution floats, DeviceCache:: addMipmapImage uploads them, and imageProcessing/cheshireDownscale.cu applies the tap tables
computed on the host by the same code, in the host's order, then the host's half(value x 255)
conversion. The first build differed from the host path in every file of the gate, at the rounding
level (99.7-99.9 % of pixels within 0.5 %), and a check mode found two fusions the source did not
ask for. __fmul_rn and __fadd_rn on HIP come from the device library's bitcode with the
contraction flag set, so the multiply-adds became FMAs: 32 % of the accumulated floats differed.
And the GPU fused value * 255 and the conversion to half into one mixed-precision instruction
with a single rounding of the exact product, where the host rounds to float first: at a half-float
tie such as 0.417279422 x 255 = 106.40625261 the host gets the tie 106.40625 and rounds to even
(106.375), the device 106.4375 - 524 of 3 million texels. An empty asm volatile on each product
is a barrier neither fusion can cross. With it: device floats and texels 0 of 12.2 million and 0 of
3 million different from the host path on mini6, the 884-view chunk's 24 files byte-identical to
the reference, mini6 identical with the switch off and on. On this box the batch loads drop by
about 40 % (4.4, 2.0, 1.6 s against 5.7, 3.0, 3.6 s) but the chunk barely moves (116 against
120 s): DepthMap here is now GPU-bound. It is for the i3. HIP builds only; the CUDA backend keeps
the host path. CHESHIRE_DEPTHMAP_DEVICE_DOWNSCALE=0 restores the host path and
CHESHIRE_DEPTHMAP_DEVICE_DOWNSCALE_CHECK=1 compares every image with it.
Smaller items in the same batch. The direct reader takes one-channel EXRs (depth and similarity
maps; DepthMapFilter and Meshing read them through OpenImageIO before): DepthMapFilter's twelve
mini6 files identical with the reader off and on, and Meshing's tetrahedralization input identical
(265,271 points, the same checksum) - the cells then differ as they do between any two runs
(geogram's numbering, a parked item). On house-pc the depth-map load was the largest single step of
the 884-view Meshing, 120 s of 716. Step 6b computes F from E once per model in
RelativePoseKernel_K::errors() instead of once per correspondence (SfM's initial pair): the
deterministic digests are unchanged on the 41-view set and the engine bay. And step 5f counted the
native camera mipmaps at half their size - CudaRGBA is already the 8-byte half4 - so mini6's image
peak reads 186 MB instead of 93, with the maps unchanged.
Where the 884-view Meshing goes on house-pc. The s7 run's Meshing (i3-4330 with 4 threads, RX 6750 XT, 819 of 884 views registered) took 716 s, split from its log:
| phase | s |
|---|---|
| depth-map load, first filters, first kd-tree | 131 |
| visibility pass 1 | 153 |
| max observation angle per point | 48 |
| angle and similarity filter, pixel-size filter rounds | 23 |
| visibility pass 2 | 143 |
| tetrahedralization | 29 |
| s-t weights (GPU votes) | 32 |
| facet weights and graph | 33 |
| CSR layout and GPU cut | 13 |
| post-cut processing | 24 |
| mesh cleaning | 58 |
| mesh save | 19 |
The two visibility passes are 296 s, 41 % of the node, at 187 ms per camera where the RX 9070 box
runs 62 ms: the i3 backprojects each camera's 3.76 million pixels and applies its votes on four
threads, and only the knn search overlaps them (the next item). The rest of this section takes the
host phases that are pure CPU and can be made exact: each is proven in-process against upstream's
own computation, and at scale by the tetrahedralization input checksum of the kept False Door
cache (4,701,419 points, 64b36ee445e30d38).
6f. PrepareDenseScene at ZIP level 1. Step 5y's ZIP was written at OpenEXR's default zlib level,
4. Level 1 on the mini6 set: 282,699,910 bytes against 278,680,274 (1.4 % larger), the write's
thread-seconds 4.0 against 5.7, the decoded pixels identical in all 6 files. Inflate costs the same
at either level, so every later reader is unchanged. CHESHIRE_PDS_EXR_COMPRESSION now takes
method[:level]; a tree patched before this step is upgraded in place.
6g. The max observation angle over pairs of cameras. For every fused point, upstream takes the largest angle between any two of its cameras, over ordered pairs: two normalisations and an acos per pair, twice per pair. The angle is symmetric bit for bit (two separate normalisations and a dot product of the same products in the same order), it is never NaN (0 is returned instead), and the angles are non-negative, so the maximum over unordered pairs is the same value. Each camera's direction from the point is normalised once with the same expression, and the angle uses angleBetwV1andV2's: half the acos calls and k normalisations instead of 2k(k-1). Locally the loop went from 5.5 s to 3.4 s with the pairs alone and to 1.9 s with the directions too; the tetrahedralization input is unchanged on mini6 and at 884 views. Upstream's count of the points this filter removes is a plain int incremented inside the OpenMP loop, so it loses increments at random (225,329 and 225,314 in two runs); the count of points actually removed, 225,360, is the one to compare.
6h. Facet weights once per interior facet. Before the cut, every cell's four entries computed
both directed weights of their facet with getFaceWeight, and each weight takes the circumsphere
centres of the facet's two cells: 16 centre computations per cell, every interior facet twice. The two
weights of a facet need the same two centres and the mirror's entry is the same pair of values
swapped, so each interior facet is now computed once, from its lower-numbered cell, which computes its
own centre once: about 3 centres per cell. cheshireFaceWeight is getFaceWeight's expressions with the
centres passed in. The existing check (CHESHIRE_GPU_VOTE_LOG=1) compares every entry with upstream's
per-facet computation: 0 of 6,689,240 facets differ on mini6 and 0 of 116,323,696 at 884 views. The phase
went from 7.7 s to 5.3 s locally (4.6 s of it the weights, the rest the serial edge recording); on
house-pc, where four threads compute it, it was 33 s. CHESHIRE_FACET_PAIRS=0 restores
upstream's loop.
6i. MeshClean with a parallel pre-screen. Mesh cleaning splits the vertices whose triangle fan is
not a single disc, one point after another in index order, and repeats until a pass splits nothing:
four passes of 8.4 s over every point on house-pc, and a first pass of 19 s. A point's pass reads only
its own triangle list, those triangles and the edge entries between it and its one-ring, and a split
rewrites only those of the split point, its one-ring and the new points (the edge index stays sorted
with unique keys, so appending to it does not move a lookup). So each pass now runs upstream's read-only
path::isWrongPt on the candidates in parallel, upstream's deployAll in parallel on the candidates that
would not split (they write only their own entries), and upstream's deployAll in index order on the
points that split and on every point an earlier split of the pass touched. After the first pass the
candidates are the points a split touched in the previous one; the others would write the same values
again. The first pass was slow for another reason: the arrays a split appends to grew by a fixed 1,000
or 3,000 entries, so the 24-million-entry edge array was copied every few hundred splits; they now grow
by an eighth. CHESHIRE_MESHCLEAN_CHECK=1 runs upstream's passes from the same state and compares every
structure (points, triangles, colours, the triangle and neighbour lists, the boundary flags, the edge
index, the new points' origins): identical on mini6 (3 passes, 250,207 points) and at 884 views (3 passes,
4,071,677 points, 8,136,361 triangles). mini6's passes: 110, 5.4 and 0.4 ms. At 884 views the passes take 2.5, 0.11 and 0.01 s where
upstream's took 10.0, 8.5 and 8.7 s in the same process, and the cleaning step went from 44.3 s to
14.0 s, 10.5 s of it now the setup's sorts. The mesh itself differs between runs as it always has
(geogram numbers the cells differently), so a run may take a fourth pass: the check compares within one.
CHESHIRE_MESHCLEAN_PRESCREEN=0 restores upstream's passes.
6j. removeInvalidPoints moves the camera lists. The variant with the vertex attributes copied each surviving vertex's camera list into the new array, a heap allocation and a free per vertex, five times per Meshing; it now moves them. Same contents; the checksum is unchanged.
The device downscale on house-pc (6d, Linux). The s8 bundle, built from 919eba8, ran 4 views of
the s7 job's first DepthMap chunk with CHESHIRE_DEPTHMAP_DEVICE_DOWNSCALE_CHECK=1 on the RX 6750 XT:
every image 0 of 20,256,000 floats and 0 texels different from the host path, and the 8 maps
byte-identical to the s7 job's (73 s for the 4 views with the check).
0.3.4: the visibility passes' queries built on the device, and MeshClean's setup by counting (2026-09-24)
6k. The backprojection on the device. On house-pc the two visibility passes are 296 of the
884-view Meshing's 716 s: per camera the i3 backprojects 3.76 million pixels and applies the votes
on four threads, and only the knn search overlaps them. Now the host counts each row's valid pixels
and stages the depth map, and the device builds the queries: backprojectKernel in knnGPU.cu, one
block per row, the valid pixels numbered by a block scan so query k is the host's k, and
MultiViewParams::backproject and getCamPixelSize replayed operation by operation. The host build's
fused forms follow the same rule as the knn metric: clang-cl under /arch:AVX2 contracts within an
expression (fma(m11, x, m12y) + m13, fma(z, z, fma(x, x, yy)), fma(a.y, b.z, -(a.z*b.y)), the
4-term rows likewise), a generic x86-64 build (the Linux bundle) fuses nothing. The device answers the
queries in place and returns them with their pixel sizes, and the host votes as before.
The first build did not match: 63 % of the queries differed from the host's in the last bit, by the
same count whether the device computed the fused or the plain forms, so the device was fusing on
its own. HIP's __dadd_rn, __dsub_rn and __dmul_rn are plain operators defined in its math
header, which the compiler includes before a source file's first line; they are compiled with device
code's default contraction, not under the file's #pragma clang fp contract(off), so a product
feeding a sum fuses into an FMA once inlined. The arithmetic now goes through three helpers defined
in the file under the pragma (division, sqrt and explicit __fma_rn cannot be contracted and stay).
The knn kernel used the same intrinsics for its plain metric and the bounding-box sums, which is the
likely cause of the Linux knn distance differences of 2026-09-22 that the pragma alone did not change
(docs above); the next Linux bundle will show. No other port uses them.
Gate (CHESHIRE_GPU_VIS_CHECK=1: the host backprojects every camera again and compares every query
and pixel size bit for bit, and its reference votes use its own queries): identical to
MultiViewParams on all 8,808,856 queries of each mini6 pass and all 88,267,121 of each 41-view pass;
knn and votes checks identical; the pass digests equal step 3's on all three sets (mini6
806ef990.../51779edd..., 41 4d536979.../db94ece3..., 884 views c572bc16.../f0ff15ea...), as do the
tetrahedralization inputs (7d875321..., cdd23710..., 64b36ee4...). Negative control:
CHESHIRE_GPU_VIS_BP_FMA=0 on this build reports 8,792,017 of 8,808,856 queries different. Under CHECK
the first mismatches of a pass are printed bit for bit with their pixel and the camera, which is how
the fusion was found.
On the RX 9070 box, back to back at 884 views, idle:
| host backprojection | device backprojection | |
|---|---|---|
| pass 1 | 57.4 s (backproject 24.0, votes 19.2) | 49.3 s (count and stage 2.8, device wait 17.5, votes 17.9) |
| pass 2 | 48.1 s | 39.9 s |
| Meshing node | 290.9 s | 278.9 s |
Here the passes were already near device-bound (62 ms per camera after step 3); the step is for
four-thread hosts, where the backprojection was more than half of the host's 187 ms per camera. The
device's own event split (upload, backprojection, kernel, download) is not reliable on HIP - copies
run on the DMA engine and the markers can land out of order, down to negative uploads - so the pass
totals are the measure. CHESHIRE_GPU_VIS_BACKPROJECT=0 keeps the host's backprojection;
CHESHIRE_GPU_VIS_BP_FMA=0|1 overrides the form.
6l. MeshClean's setup by counting. After 6i the cleaning step was mostly its setup: two qsorts
over three entries per triangle (the per-point triangle lists and the edge index) and a sort of every
list, about 10 s at 884 views. Their result is fixed: the lists ascending and the edge entries in
lexicographic (larger point, smaller point, triangle) order, whatever qsort does with equal keys
(its comparator never returns 0). Counting by point and by larger point, then sorting each small
bucket, gives the same arrays with the same capacities: 0.86-0.91 s at 884 views, 0.24 s at 41,
51 ms on mini6. CHESHIRE_MESHCLEAN_CHECK=1 now also runs upstream's setup and compares: identical
on mini6, at 41 views (1,157,308 lists, 7,034,610 edge entries) and at 884 (4,053,812 lists,
24,409,083 edge entries), and the cleaning passes after it identical as before.
CHESHIRE_MESHCLEAN_SETUP=0 restores upstream's.
On Linux, the RX 6750 XT (house-pc), 2026-09-24. The s9 bundle (built from 39e8490 in WSL, gcc
host, so the plain arithmetic forms) on the 41-view set: DepthMap and DepthMapFilter with the engine
bay job's own node parameters through Meshroom's paired wrappers, then Meshing with
CHESHIRE_GPU_VIS_CHECK=1, CHESHIRE_GPU_VOTE_LOG=1 and CHESHIRE_MESHCLEAN_CHECK=1:
| check | pass 1 | pass 2 |
|---|---|---|
| GPU backprojection | identical on all 88,257,069 queries | identical on all 88,257,069 |
| GPU knn against nanoflann | identical on all | identical on all |
| visibility votes against the ordered host reference | identical on all 4,139,042 vertices | identical on all 1,861,491 |
and the facet weights 0 of 47,100,904 different, MeshClean's setup and its 4 passes identical to
upstream's. The knn line is the news: the 0.3.3 and step-1 bundles on the same card reported
17,383,299 and 18,404,248 of 87,354,192 distances different (docs above, "The Linux knn distances are
not a contraction"). It was the HIP intrinsics fusing despite the pragma, as 6k found; the gate
(scripts/verify_end_to_end.py) now requires "identical to nanoflann" in both passes, and the
backprojection, votes and MeshClean verdicts per pass. On the 107-photo engine bay the s9 bundle's
Meshing is 126 s against s8's 159 s on house-pc (visibility passes 24.3 to 18.1 s, max angle 4.5 to
1.1 s, facet weights and graph 23.7 to 12.8 s, cleaning 15.8 to 3.2 s), the job 21.1 to 20.5 minutes.
The plan was CheshireJPG (docs/19) for PrepareDenseScene's reads, so first the read was split.
hip/tests/pdsread/pdsreadbench.cpp times each step image::readImage takes for a JPEG read as
linear RGBA float, one thread per step as the node's image threads run it (8 monstree iPhone photos,
4032x3024, RX 9070 box):
| step | ms per image | share |
|---|---|---|
| JPEG decode to 8-bit | 60 | 11 % |
8-bit to float (the rest of ImageBuf::read(FLOAT)) |
16 | 3 % |
ColorConfig built for the call |
14 | 2.5 % |
colorconvert sRGB to linear |
267 | 48 % |
ImageBufAlgo::channels to RGBA |
152 | 27 % |
get_pixels into the caller's buffer |
44 | 8 % |
| total | 556 |
The decode is the smallest part; the time is OpenImageIO copying full float images around the
colour transform, whose OCIO processor itself takes 102 ms of the conversion's 267 (creating it is
0.1 ms). A 256-entry table per channel cannot replace the conversion: AliceVision's sRGB to
linear is the sRGB curve and then two 3x3 matrices that nearly cancel (Rec.709 to ACES2065-1 and
back), so every channel depends on all three; 98 % of the converted values are not a function of
their own 8-bit value.
6n. colorconvert works per row: a scratch line of RGBA floats (the three channels, alpha 0),
the processor applied to that line, the three channels stored back (OpenImageIO 3.0
colorconvert_impl). The direct path builds the same line straight from the 8-bit pixels through
OpenImageIO's own uint8-to-float values (convert_pixel_values), applies the same processor to it
with the same call, and stores the line in the caller's buffer with alpha 1, which is what the
channels pass would have added. The processor comes from a ColorConfig kept for the process
(upstream builds one per read, 14 ms), resolved as colorconvert resolves it. The path takes only
what it reproduces - three-channel 8-bit JPEG or PNG, read as float RGB or RGBA, no DCP profile,
converted through the AliceVision config or not converted - after the same colour-space decisions
as upstream; anything else, including grayscale reads, takes upstream's path. Rows run on
OpenImageIO's pool unless the caller is already parallel (5w's rule). In the bench: 17 ms to fill and
102 to apply, byte-identical to the node's buffer on 8 of 8 photos, so the read is about 180 ms
with the decode instead of 556.
Gate, engine bay (107 Pixel photos, 4032x2268), aliceVision_prepareDenseScene from the same
sfm.abc with CHESHIRE_READ_DIRECT=0 and with the default: 107 of 107 EXRs byte-identical.
CHESHIRE_READ_DIRECT_CHECK=1, which reads every image both ways and keeps upstream's result: 107 of
107 identical on the engine bay, 6 of 6 on mini6 (iPhone). On the RX 9070 box, 12 threads:
| upstream read | direct read | |
|---|---|---|
| wall | 28.1 s | 19.1 s |
| read (thread-seconds) | 139.3 | 67.4 |
| undistort | 67.9 | 60.8 |
| write | 71.6 | 56.4 |
The undistort and write phases got cheaper too: 0.3.3 item 2 found this node memory-bound on this
box, and the direct read moves four fewer full-size float images per view. On house-pc's four
threads PrepareDenseScene was 133 s on the engine bay and 2,425 s at 884 views; the next Linux
bundle measures it. What remains of the read is the decode and the transform itself, which is where
CheshireJPG comes in. CHESHIRE_READ_DIRECT=0 restores upstream's read.
house-pc's 884-view False Door texturing (51 atlases, 3 atlas slots in the RX 6750 XT's VRAM, so 17 passes over 835 cameras) took 14.7 minutes per pass on the s7 bundle and 32 on s9: 2.2x slower, with the process using about 1.3 of the i3's four threads and the disk nearly idle. Both runs had the same cache: at 6000x3376 an image is 231 MB, the host had 4.1-4.4 GB over the read-ahead's 4 GB margin, not enough for the 5 slots of the deeper read-ahead, so texturing kept upstream's 2 slots and read one camera ahead. What changed between s7 and s9 is step 6c: every image-cache read-ahead decodes on its own thread instead of through OpenEXR's pool. With eleven read-aheads in flight (the 12-thread box after 6e) that is the faster arrangement; with one, it put texturing's only read on a single thread. Step 6e's comment assumed the default cache has about 20 slots; texturing sets 2.
Step 6o counts the read-aheads in flight: one that starts while another is loading still decodes inline, a lone one keeps the pool. Concurrency only, no value can change. The engine bay never showed it: at 4032x2268 the images fit the deeper read-ahead.
Fold-in gate, RX 9070 (2026-09-24): main with PR #2 (CheshireEXR, off by default), 6n and 6o,
packaged flat (package_windows.py): the mini6 end-to-end matrix 12 of 12 at 7 of 7 ports, and the
verify config now also runs CHESHIRE_READ_DIRECT_CHECK=1 and requires its verdict (6 of 6 images
identical to OpenImageIO's path).
The first run of that gate failed every config: FeatureMatching crashed inside amdhip64_7.dll and
DepthMap reported "no kernel image is available". AMD_LOG_LEVEL=3 gave the runtime's own reason:
"KMD failed to setup the trap handler", then "AMD HSA Code Object loading failed". v0.3.3's released
binaries, laid out the same way, fail identically; the same code objects load under the driver's
runtime. The ROCm 7.2.1 amdhip64_7.dll the packages bundle (HIP 7.2.53211) stopped working here
when the box rebooted at 13:16 on 2026-09-22 with a pending Windows update (KB5129195); the 0.3.3
flat test package had passed at 02:01 that day. The driver's own runtime in System32 (7.2.60201)
works. Windows loads a DLL from the executable's folder first and System32 second, so the flat test
packages used the bundled copy and the unified release zip, which keeps it in gpu/rocm7.2 on PATH,
has been using the driver's. For gates on this box a flat package goes without the two runtime DLLs;
before the release, whether to bundle the 7.2.1 runtime at all is an open item.
The fold-in 41-view gate on the RX 9070 passed every self-check but one: CHESHIRE_MAXFLOW_CHECK
reported 2 of 11,773,764 cells labelled differently by the GPU push-relabel cut and upstream's
Boykov-Kolmogorov on the same graph. The graph dumped for hip/tests/maxflow_test
(CHESHIRE_MAXFLOW_DUMP) gave the answer: the two cuts' values, computed in double from each
labelling, were both 218,448,238.9, and Boykov-Kolmogorov's own search left exactly 2 cells gray
(undetermined). A minimum cut need not be unique; each algorithm put a tie on a different side.
Earlier graphs happened to have no ties (0 of 11,417,156 at 41 views on v0.2.10, 0 of 29,080,924
at 884 views on 0.3.3).
The check now also evaluates both labellings on the adjacency-list graph, in double, and says
whether the values are equal (relative difference at most 1e-9, which leaves room only for the
order of a double sum over two different edge sets); the gate's verdict is that, and the cell count
stays in the line for information. On the same 41-view cache: 2 cells, values 218,447,937.91999644
and 218,447,937.91999739 (equal, relative difference 4.4e-15); mini6 verify: 0 cells, values
identical. The cut itself is unchanged.