Skip to content

Mapper parallelism and build fixes - #6

Merged
Zador-Pataki merged 8 commits into
cvg:mainfrom
kevintsq:mapper-parallelism-and-build-fixes
Aug 11, 2026
Merged

Mapper parallelism and build fixes#6
Zador-Pataki merged 8 commits into
cvg:mainfrom
kevintsq:mapper-parallelism-and-build-fixes

Conversation

@kevintsq

@kevintsq kevintsq commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Cut the single-core time out of mapping, and make the Ceres backend configurable

Summary

Mapping spent most of its wall clock on one core. Sampling process-tree CPU once a
second across a 617-image run showed 740s of 1218s (61%) running on roughly a single
core
, almost none of it inside the native solvers. This branch removes the bulk of that
serial work, makes the Ceres solver backend configurable, fixes point colors, and repairs
the native build.

The same scene now finishes in 681s with 250s (37%) on one core.

before after
wall clock 1218s 681s
single-core time 740s (61%) 250s (37%)
mean cores (of 32) 3.66 5.66
global positioning, pass 1 123s @ 6.9 cores 105s @ 7.7 cores
global positioning, pass 2 151s @ 6.8 cores 126s @ 7.8 cores
BA problem construction, per round 52–62s @ 1.0 core 6–8s @ 1.0 core
mean reprojection error 1.16164 1.16021
registered images / points 617 / 242576 617 / 242656

What was slow

Every bundle-adjustment round spent 52–62s building the problem in Python before handing
a 35s solve to Ceres — preparation cost more than the solve itself, eight rounds in a row.

The dominant term was small_triangulation_angle_mask, called once per image inside the
per-image loop, each call materializing all 380k track ids into a Python list and then a
set. That work is loop-invariant, and the underlying filter is per-track independent, so
one pass over the problem answers every per-image query.

Two smaller serial costs sat alongside it: per-observation pybind lookups for point
coordinates and track lengths (~1.25M calls per round), and a full rebuild of every
pycolmap point after each positioning pass, which only moves coordinates.

Changes

Fix the native extension build against the installed COLMAP. Rebuilding
vidmap_native currently yields a module that cannot be imported: COLMAP's static
libraries reference Abseil's logging internals without listing Abseil in their exported
configuration, leaving undefined absl::log_internal symbols, and a shared Ceres outside
the loader's search path is not found at import time. Links Abseil when available and
keeps link-time library directories in the installed RPATH.

Extract point colors before writing a reconstruction. Nothing on the mapping path
sampled color, so every points3D.bin carried RGB (0, 0, 0) and COLMAP rendered the
cloud black — only the HTML visualization called extract_colors_for_all_images, which
is why the same run looked colored there and blank in COLMAP. Color is cosmetic, so a
missing image root degrades to a warning rather than discarding a finished
reconstruction.

Cut the single-threaded Python work between native solves. Hoists the small-angle id
computation out of the per-image loop; snapshots point coordinates and track lengths into
one Point3DTable per solve so per-image lookups are a binary search. Rows stay in
reconstruction order because that order reaches Ceres through variable_point3D_ids.
Positioning passes now publish values instead of rebuilding all points, and the replay
track snapshot is only materialized when replay is enabled.

Make the Ceres linear solver and thread counts configurable. The global solves
hard-coded a direct sparse Schur factorization, whose factorization step is
single-threaded; rotation averaging was pinned to one thread with no way to change it.
Adds linear_solver, preconditioner and use_cuda for bundle adjustment and global
positioning, plus num_threads for rotation averaging.

python -m vidmap.map ... \
  mapper.ba.solver_backend.linear_solver=iterative_schur \
  mapper.gp.solver_backend.use_cuda=true \
  mapper.ra.num_threads=8

Every default reproduces current behavior: the direct sparse solve with CUDA off, and one
thread for rotation averaging, which keeps that stage byte-identical as its pinned random
seed intends. Requesting CUDA is rejected with a clear message when Ceres was built
without it. Also drops global positioning's CLUSTER_TRIDIAGONAL preconditioner, which
SPARSE_SCHUR ignores — timing it explicitly confirmed no difference.

Verification

Multi-threaded Ceres is not bit-reproducible here, so results are compared against the
pipeline's own run-to-run spread after removing the similarity gauge:

  • 89-image scene: baseline varies by 0.10% of scene radius between two runs of
    unmodified code; before-versus-after differs by 0.05%. Reprojection error 0.94155
    vs 0.94173, same 89 registered images.
  • 617-image scene: self-noise 0.41%, registered images and reprojection error
    unchanged.

Notes for reviewers

  • iterative_schur and use_cuda are wired but off by default and not yet recommended.
    On the 89-image scene iterative_schur was slower (3m43 vs 1m30) and converged to a
    materially different solution (scale off by 2x), so it needs tuning before it is
    useful.
  • Two Ceres installs can be present, and CMake may prefer a shared build with
    EigenSparse only over the SuiteSparse build COLMAP was compiled against. That choice
    is worth 2x on global positioning (212s @ 3.8 cores vs 105s @ 7.7 cores), so watch
    the -- Found Ceres version: configure line and pass Ceres_DIR if it picks the wrong
    one.
  • This branch also carries the earlier Expose full BA diagnostics and tidy descriptor loading commit.

🤖 Generated with Claude Code

kevintsq and others added 5 commits August 9, 2026 04:08
Bind the rest of BundleAdjustmentDiagnostics. Every field is populated in
PopulateResult, but only three were exposed, so the BA failure path in
adjuster.py raised AttributeError on termination_type instead of logging the
warning and restoring the last valid reconstruction.

Keep load_mapping_runtime() ahead of every torch import: libtorch_cpu.so exports
its own statically linked BLAS/LAPACK, and when torch loads first those symbols
win global resolution for SuiteSparse/Ceres, so CHOLMOD reports "matrix not
positive definite" and bundle adjustment fails.

Read retrieval descriptors with the h5py dataset spelling instead of calling
Dataset.__array__() by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilding vidmap_native currently produces a module that fails to import.
COLMAP's static libraries reference Abseil's logging internals but its exported
configuration does not list Abseil, so the module is left with undefined
absl::log_internal symbols; and when Ceres resolves to a shared build outside the
loader's search path, libceres cannot be found at import time.

Link Abseil explicitly when it is available and keep the link-time library
directories in the installed RPATH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing on the mapping path ever sampled point color, so every written
points3D.bin carried RGB (0, 0, 0) and COLMAP rendered the cloud black. Only the
HTML visualization called extract_colors_for_all_images, which is why the same
run looked colored there and blank in COLMAP.

Sample colors from the run's recorded RGB root just before write. Color is
cosmetic, so a missing or unreadable image root degrades to a warning rather than
discarding a finished reconstruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sampling process-tree CPU once a second over a 617-image mapping run showed 740s
of its 1218s wall clock running on roughly one core, almost all of it in the
Python that builds each bundle-adjustment problem: every BA round spent 52-62s
preparing before a 35s native solve.

The dominant cost was small_triangulation_angle_mask, called once per image while
rebuilding a set over all 380k track ids on every call. The filter is per-track
independent, so compute the small-angle ids once per solve and mask per image.
Snapshot point coordinates and track lengths into one Point3DTable per solve as
well, so per-image lookups are a binary search instead of a pybind call per
observation; rows stay in reconstruction order because that order reaches Ceres
through variable_point3D_ids.

Also stop rebuilding every pycolmap point after a positioning pass, which only
moves coordinates, and stop materializing all track records for a replay summary
that is disabled by default.

BA preparation drops to 6-8s per round; the run finishes in 681s with 250s on one
core. Reconstructions stay inside the pipeline's own run-to-run spread: on an
89-image scene the baseline varies by 0.10% of scene radius between repeats, and
before-versus-after differs by 0.05%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The large global solves hard-coded a direct sparse Schur factorization, whose
factorization runs on one thread; the measured solves reach only 6-8 of 32 cores.
Rotation averaging was pinned to a single thread with no way to change it.

Expose linear_solver, preconditioner and use_cuda for bundle adjustment and
global positioning, and num_threads for rotation averaging. Every default
reproduces today's behavior: the direct sparse solve with CUDA off, and one
thread for rotation averaging, which keeps that stage byte-identical as its
pinned random seed intends.

Drop global positioning's CLUSTER_TRIDIAGONAL preconditioner, which SPARSE_SCHUR
ignores; timing it explicitly confirms no difference. Requesting CUDA is rejected
with a clear message when Ceres was built without it, and sparse CUDA only exists
from Ceres 2.2 on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zador-Pataki
Zador-Pataki marked this pull request as draft August 10, 2026 16:51
@Zador-Pataki

Copy link
Copy Markdown
Collaborator

Thanks @kevintsq. This was great. I added a small maintainer follow-up after testing the PR. I plan to squash-merge it, but I’ll leave it open briefly in case you’d like to review the final diff. Otherwise, I’ll merge it soon.

@Zador-Pataki
Zador-Pataki marked this pull request as ready for review August 10, 2026 22:20
@kevintsq

Copy link
Copy Markdown
Contributor Author

You're welcome! Looks good to me. Feel free to merge soon.

@Zador-Pataki
Zador-Pataki merged commit f3f3131 into cvg:main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants