Skip to content

ENH: Add per-filter WorkUnitToThreadRatio and set it where it helps - #7107

Open
dzenanz wants to merge 8 commits into
InsightSoftwareConsortium:mainfrom
dzenanz:workUnitToThreadRatio
Open

dzenanz wants to merge 8 commits into
InsightSoftwareConsortium:mainfrom
dzenanz:workUnitToThreadRatio

Conversation

@dzenanz

@dzenanz dzenanz commented Nov 3, 2026

Copy link
Copy Markdown
Member

Add ProcessObject::SetWorkUnitToThreadRatio so a filter can override its threader's default work units per thread, and set it in 37 classes that are faster with fewer work units. Closes #950.

Design
  • Ratio 0 (default) keeps the multithreader's own default: Platform 1, Pool 4, TBB 16 work units per thread.
  • A non-zero ratio sets the default NumberOfWorkUnits to ratio × GetMultiThreader()->GetMaximumNumberOfThreads(), clamped to [1, ITK_MAX_THREADS]. Filters set it in their constructor.
  • An explicitly set NumberOfWorkUnits is kept. Changing the multithreader keeps the existing SetMultiThreader rule.
  • The ratio-derived count goes through the virtual SetNumberOfWorkUnits, so composite filters that forward it to internal filters (e.g. GrayscaleErodeImageFilter) see it.
  • Composites that forward their own count to an inner ratio-limited filter on every update get the inner filter's ratio, otherwise they would override it.
Classes changed
Ratio Classes Per-work-unit cost
1 BoxMeanImageFilter, BoxSigmaImageFilter pad and accumulate region + radius
1 AnchorErodeDilateImageFilter, AnchorOpenCloseImageFilter, VanHerkGilWermanErodeDilateImageFilter (and their Erode/Dilate/Open/Close subclasses) copy and filter region + kernel radius
1 BSplineScatteredDataPointSetToImageFilter allocate, zero, reduce whole lattices
1 BinaryImageToLabelMapFilter, ConnectedComponentImageFilter a line per boundary in the serial equivalence pass
1 10 binary label-map composites (BinaryFillhole, BinaryGrindPeak, BinaryShape/StatisticsOpening, BinaryShape/StatisticsKeepNObjects, BinaryImageToShape/StatisticsLabelMap, BinaryReconstructionByDilation/Erosion), BinaryOpening/ClosingByReconstructionImageFilter forward to the above
1 BlockMatchingImageFilter last work unit gets all remaining points
1 VariationalRegistrationCurvatureRegularizer, VariationalRegistrationElasticRegularizer work-unit count is the FFT thread count
2 ImageToHistogramFilter (and MaskedImageToHistogramFilter) allocate, fill, merge a whole histogram
2 LabelStatisticsImageFilter per-label statistics and histograms, merged
2 LabelImageToLabelMapFilter and 8 label composites (LabelShape/StatisticsOpening, LabelShape/StatisticsKeepNObjects, Shape/StatisticsRelabel, LabelImageToShape/StatisticsLabelMap) one label map per work unit, merged serially
2 FFTWForward1DFFTImageFilter, FFTWInverse1DFFTImageFilter, FFTWComplexToComplex1DFFTImageFilter one FFTW plan and two line buffers per work unit
Benchmarks (24-thread Ryzen 9 5900, MSVC 14.51 Release, Windows)

Median of 5-15 runs, ms, threader default → new default.

Case Pool TBB
BoxMean radius 5, 160³ uint16 117 → 52 228 → 49
AnchorOpen box radius 5, 128³ uint8 190 → 74 405 → 71
VanHerkGilWermanDilate box radius 2, 128³ 67 → 30 120 → 24
GrayscaleErode box radius 2 (internal Anchor) 105 → 40 185 → 41
BSplineScatteredData fit, 40³ control points 5550 → 4530 5300 → 4330
ImageToHistogram 3 × 64 bins, 128³ 214 → 166 527 → 146
LabelStatistics 8000 labels, histograms 72 → 46 137 → 57
LabelImageToLabelMap 8000 labels 39 → 33 73 → 39
BinaryImageToLabelMap 128³ 840 → 791 923 → 456
BinaryFillhole 160³ 3400 → 2090 3470 → 2100
ConnectedComponent 160³ 1070 → 920 1160 → 491
BlockMatching 100 points 1.43 → 1.36 8.4 → 1.5
VariationalRegistrationElasticRegularizer 64³, FFTW 12.1 → 11.5 70.3 → 11.1
VariationalRegistrationCurvatureRegularizer 64³, FFTW 18.4 → 11.7 72.2 → 12.7
1-D FFTW filters, 8 cases summed, new filter per update 26.9 → 26.6 46.7 → 27.3
1-D FFTW filters, 8 cases summed, cached plans 7.1 → 6.8 5.5 → 6.6

Each case was also swept over ratios 1, 2, 4 under Platform, Pool, and TBB to pick the ratio.

Classes left at the threader default

About 110 cases covering every module with threaded code, chosen after reading all ~400 threaded source files for per-work-unit cost. Within noise or slower with fewer work units, so not changed:

  • Pixel-wise, cast, rescale, pad, flip, permute, resample, warp, bin shrink.
  • Neighborhood filters: mean, median, gradient, noise, bilateral, zero crossing, Hessian objectness, NeighborhoodOperatorImageFilter, recursive Gaussian.
  • Moving-histogram filters: rank, MovingHistogramDilate, MaskedRank.
  • Reductions with scalar state: Statistics, MinimumMaximum, SimilarityIndex, LabelOverlapMeasures, projections, Otsu.
  • Label-map filters that drain a shared label-object queue, contour filters, SignedMaurerDistanceMap.
  • Noise generators, anisotropic diffusion, curvature flow, Demons, narrow-band level sets, label voting, displacement-field filters, RelabelComponent.
  • N-D FFTW filters (FFTW threads follow GetMaximumNumberOfThreads), PocketFFT 1-D, FFT convolution, VariationalRegistrationDiffusionRegularizer.
  • BSplineControlPointImageFilter: ratio 1 helps only when the lattice is large relative to the output, and is ~10% slower for a small lattice with a large output (the N4 case).
Known trade-offs
  • LabelStatisticsImageFilter with few labels is up to 0.5 ms slower at 160³.
  • Cached-plan 1-D FFTW runs are ~1 ms slower in total under TBB.
  • With ratio 2, PlatformMultiThreader starts two threads per core for those classes.
  • Composites that forward their own count to inner filters on every update still override inner ratios elsewhere; only the label-map and reconstruction cases are covered here.
Testing
  • New GTest itkProcessObjectWorkUnitToThreadRatioGTest (default, ratio from constructor, ratio change, explicit count kept, forwarding through the virtual setter), against Platform, Pool, Single, and TBB.
  • Windows, MSVC Release, TBB as default threader: all 1411 tests pass in ITKCommon, ITKSmoothing, ITKMathematicalMorphology, ITKBinaryMathematicalMorphology, ITKImageStatistics, ITKLabelMap, ITKImageGrid, ITKConnectedComponents, ITKRegistrationCommon, ITKStatistics, and VariationalRegistration.
  • With Module_ITKFFTW, ITK_USE_FFTWD, ITK_USE_FFTWF: all 95 ITKFFT and VariationalRegistration tests pass.
  • pre-commit passes on all changed files.
AI assistance
  • Tool: Claude Code (claude-opus-5-5).
  • Role: implemented the ratio and its GTest, surveyed threaded classes for per-work-unit cost, wrote and ran the benchmark harness, chose ratios from the measurements, and ran the tests listed above.
  • All measurements and tests ran locally on the workstation described above.

🤖 Generated with Claude Code

Default work units per thread differ by multithreader (Platform 1,
Pool 4, TBB 16), and a filter could only override them with a fixed
count. A non-zero ratio sets the default NumberOfWorkUnits to ratio
times the threader's maximum number of threads, so filters with a high
cost per work unit can set a low ratio in their constructor. Zero, the
default, keeps the threader's default. An explicitly set
NumberOfWorkUnits is kept.

Closes InsightSoftwareConsortium#950.
Each work unit allocates, fills, and merges a whole histogram, which
dominates run time for multi-component histograms under Pool (4 work
units per thread) and TBB (16).

24 threads, ms, 128^3 / 256^3, threader default -> two per thread:
3 x 64 bins:   Pool 214 -> 166 / 364 -> 292, TBB 527 -> 146 / 683 -> 267
2 x 256 bins:  Pool 53 -> 45 / 164 -> 156,   TBB 120 -> 43 / 225 -> 162
1 x 256 bins:  Pool 8.9 -> 10.5 / 64 -> 75,  TBB 7.9 -> 9.3 / 64 -> 78
SetWorkUnitToThreadRatio assigned m_NumberOfWorkUnits directly, so
composite filters that override SetNumberOfWorkUnits to configure
their internal filters, such as GrayscaleErodeImageFilter, kept the
old count. The default is still stored unclamped, as SetMultiThreader
does.
BinaryImageToLabelMapFilter adds a line per work-unit boundary to its
serial label-equivalence pass; LabelImageToLabelMapFilter builds one
label map per work unit and merges them serially. Use one and two work
units per thread. The composites forward their count to these filters,
so they get the same ratio. LabelMapFilter subclasses take label
objects from a shared queue, so their count does not matter.

24 threads, ms, threader default -> new, Pool / TBB:
BinaryImageToLabelMap, 128^3        840 -> 791  / 923 -> 456
LabelImageToLabelMap, 8000 labels    39 -> 33   / 73 -> 39
BinaryFillhole, 160^3              3400 -> 2090 / 3470 -> 2100
LabelShapeOpening, 8000 labels       53 -> 42   / 51 -> 42
These classes do work per work unit that grows with the kernel,
lattice, or label count: box mean and sigma, anchor and van
Herk/Gil-Werman morphology recompute a kernel-radius halo, the B-spline
scattered data fit allocates and reduces whole lattices, and label
statistics merge per-label results. Binary opening and closing by
reconstruction forward their count to the binary reconstruction filters.
Other Filtering classes were within noise or slower with fewer units.

24 threads, ms, threader default -> new, Pool / TBB:
BoxMean radius 5, 160^3              117 -> 52   / 228 -> 49
AnchorOpen box radius 5, 128^3       190 -> 74   / 405 -> 71
BSpline fit, 40^3 control points    5550 -> 4530 / 5300 -> 4330
LabelStatistics, 8000 labels, hist    72 -> 46   / 137 -> 57
ConnectedComponentImageFilter adds a line per work-unit boundary to its
serial label-equivalence pass. BlockMatchingImageFilter gives the last
work unit all remaining feature points, so with fewer points than work
units it runs on one thread. The curvature and elastic
VariationalRegistration regularizers also use the count as their FFT
thread count. Level set, PDE deformable registration, label voting,
displacement field, Core, and Numerics classes were within noise.

24 threads, ms, threader default -> new, Pool / TBB:
ConnectedComponent, 160^3        1070 -> 920  / 1160 -> 491
BlockMatching, 100 points        1.43 -> 1.36 / 8.4 -> 1.5
ElasticRegularizer, 64^3         14.3 -> 12.0 / 17.2 -> 12.6
The 1-D FFTW filters create an FFTW plan and two line buffers for each
work unit when plans are first computed or the line length changes.
The N-D FFTW filters size FFTW threads by the maximum number of
threads, so their work-unit count does not matter.

24 threads, 8 cases of 128^3 and 1024^2 images, summed ms, threader
default -> two per thread:
New filter per Update:         Pool 26.9 -> 26.6, TBB 46.7 -> 27.3
Reused filter, cached plans:   Pool 7.1 -> 6.8,   TBB 5.5 -> 6.6
@github-actions github-actions Bot added type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots type:Enhancement Improvement of existing methods or implementation type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct area:Core Issues affecting the Core module area:Filtering Issues affecting the Filtering module area:Registration Issues affecting the Registration module area:Segmentation Issues affecting the Segmentation module area:Numerics Issues affecting the Numerics module labels Nov 3, 2026
@blowekamp

Copy link
Copy Markdown
Member

Threading is complicated. And this looks like extensive work that does identify some key filters that do not scale with will numerous work units.

I am in favor of adding a per filter option to set the default WorkUnitToThreadRatio, or WorkUnitMultiplier. I also thinks that there should be some kind of "GlobalDefault" settable value for this as well, or perhaps it's per multithreaded. I think this is easier to do.

However to set the defaults for these (the globals) is going to be harder. It will require the community setting clear criteria for the defaults, and then discussion and evaluation of the purposes set out. For example getting frequent progress updates may come at the expense of performance.

@dzenanz

dzenanz commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

Good idea. As the multi-threaders already have this, it would make more sense to have this per multi-threader. We would only have to make the current thing user-settable. Changing the defaults would benefit from extensive benchmarking on multiple machines and workloads, as well as other kinds of community input and discussion. Both can be done as follow-ups to this PR, especially the discussion about defaults.

@github-actions github-actions Bot added the area:Documentation Issues affecting the Documentation module label Nov 4, 2026
@dzenanz
dzenanz marked this pull request as ready for review November 4, 2026 19:11
@greptile-apps

greptile-apps Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 2/5 Tier: apex

[Medium impact] The PR changes default scheduling for selected filters across several ITK workflows.

Fix the ratio setter, the test build without Pool, and the required includes before merging.

Findings

  1. P1 Updates break ratio defaults ▶
  2. P1 Explicit counts get overwritten ▶
  3. P1 Builds without Pool fail ▶
  4. P2 Large ratios skip work ▶
  5. P2 Required integer includes are missing ▶

Summary

Adds per-filter work-unit ratios and configurable defaults for Pool and TBB.

  • Filters can set work-unit counts to match their own workload.
  • Box and morphology filters use fewer work units for costly region work.
  • Histogram and label-statistics filters use fewer work units for local data.
  • Spline and registration filters limit work units for costly operations.
  • One-dimensional FFTW filters limit work units that allocate plans and buffers.
  • Label-map and connected-component filters use fewer work units for merging.

Acknowledged by dzenanz: ratio two starts extra Platform threads; small-label statistics and cached-plan TBB FFTW cases can be slower. Other enclosing filters that override inner ratios are deferred.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["SetWorkUnitToThreadRatio"] --> B["Read old default and store ratio"]
  B --> C{"Count equals old default?"}
  C -->|Yes| D["Compute new count"]
  D --> E["Call virtual SetNumberOfWorkUnits"]
  E --> F["Forward to child filters"]
  C -->|No| G["Keep current count"]
  F --> H["Mark filter changed"]
  G --> H
Loading

Reviews (1) · Last reviewed commit: "ENH: Add DefaultWorkUnitPerThreadRatio t..." · Reviewed by Greptile

PoolMultiThreader used 4 work units per thread and TBBMultiThreader 16,
both hard-coded. SetDefaultWorkUnitPerThreadRatio lets applications tune
these defaults for all filters at once; zero restores the usual value.
MultiThreaderBase declares the interface and ignores the setter, which
Platform and Single inherit; Pool and TBB keep a global ratio each.

ProcessObject now records the threader's defaults in SetMultiThreader,
because executing a filter changes the threader's live work-unit and
thread counts. A flag records explicit SetNumberOfWorkUnits calls, so a
ratio change keeps them even when they equal the default.
@dzenanz
dzenanz force-pushed the workUnitToThreadRatio branch from bf6488a to b8ec2b7 Compare November 4, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Core Issues affecting the Core module area:Documentation Issues affecting the Documentation module area:Filtering Issues affecting the Filtering module area:Numerics Issues affecting the Numerics module area:Registration Issues affecting the Registration module area:Segmentation Issues affecting the Segmentation module type:Enhancement Improvement of existing methods or implementation type:Infrastructure Infrastructure/ecosystem related changes, such as CMake or buildbots type:Testing Ensure that the purpose of a class is met/the results on a wide set of test cases are correct

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement workUnitToThreadRatio

2 participants