Speed up eflomal training: hot-loop hashing, allocation, and threading#14
Speed up eflomal training: hot-loop hashing, allocation, and threading#14johnml1135 wants to merge 1 commit into
Conversation
7c9fb12 to
fef8a9b
Compare
Investigates and improves eflomal Gibbs-sampler training performance across
two rounds: whether multithreading actually scales, whether allocator/memory
behavior was limiting it, and what's left for a hypothetical Rust rewrite;
then a follow-up round targeting cache locality, remaining copies, further
threading opportunities, and Rust-style ownership discipline. Full
investigation logs, methodology, and per-change benchmark numbers are in
the PR description.
Headline finding (round 1): at default settings (deterministic=false),
training already scales close to ideal (~2.8x on 3 threads, ~84% efficiency
at samplers=8,threads=4) on a real corpus (7,952 verse-aligned eBible NT
sentence pairs). Allocator contention was tested directly with a verified,
active mimalloc override and found not to be a bottleneck. The real win came
from reducing redundant hashing in sampleSweep's hot loop, not from
memory-layout changes.
Round 1 changes:
- Parallelize chain init in startTraining (chains are fully independent).
- Hoist the phi scratch buffer out of sampleSweep's and
computeFertilityCounts's per-sentence loops, reused via assign() instead
of reallocating every sentence, every sweep.
- Flatten fertCounts/fertRatioSampled from vector<vector<double>> to a
single strided vector<double>.
- Reuse the target word's hash across the remove/candidate-loop/add
tsl::robin_map operations in sampleSweep instead of rehashing the same
fixed key up to slen+3 times per token. -26.4% wall-clock at
samplers=1/threads=1.
- Off-by-default CMake diagnostic (THOT_BENCH_MIMALLOC) and new tests
supporting the above.
A second attempted optimization (saturating jump-bucket running counters)
was implemented, benchmarked, then found incorrect by a dedicated code
review: the counter doesn't stay pinned correctly when it starts a
candidate loop already at a saturation boundary, silently corrupting
jump-sampling weights for sentences longer than jumpWindow. Reverted before
landing; jumpBucketSaturationBoundary was added as regression coverage (see
CI fix below for a follow-up to that same test).
Also demonstrated (not a library change): training the direct and inverse
models of a symmetrized-alignment workflow concurrently via std::async
gives 1.74x at a moderate numSamplers=3.
Round 2 changes:
A follow-up four-angle review (data layout/copies, cache hierarchy,
threading headroom, Rust-style ownership discipline) produced 13
candidates, each tested individually with its own correctness gate and
same-session A/B benchmark (see the PR description for the full
scorecard and methodology, including two lessons learned along the way:
session-level thermal/system drift is large enough to invert a conclusion
if compared against a stale baseline, and three separate "this should
obviously help the compiler" ideas failed under measurement on this
toolchain).
Kept:
- Fold accumLexCounts into lexCounts ({live, accum} value instead of a
second map) -- -7.6% / -4.9%.
- Cache decode-path jumpLin/fertRatio per chain instead of rebuilding them
from scratch on every decodeMarginalFromTables call -- -18.0% on
computeTrainingAlignments. Caught and fixed a real regression along the
way: the cached table is sized to the vocab as of last training, but
vocab can grow afterward via getBestAlignment's auto-interning of new
words, which would have indexed the cache out of bounds. Fixed with a
bounds check; added getBestAlignmentWithPostTrainingNewWord as permanent
coverage.
- Persistent lexDenomInv member instead of a fresh per-sweep allocation --
-4.4%.
- Concurrency-contract documentation -- investigated renaming
getSrcSent/getTrgSent and hoisting vocab-size getters, found both were
solving problems that didn't exist (the naming is an established
codebase-wide convention; the getters have no hot-path cost), and instead
documented the actual gap: an unstated invariant that vocab is frozen
after buildCorpus, which is what makes several omp parallel for regions
safe.
- Parallelize loglikelihoodForPairRange -- the strongest result of this
round: split into a serial vocab pre-conversion pass followed by a
race-free omp parallel for reduction. 5.83x speedup at 8 threads, with
serial and parallel runs producing the exact same log-likelihood value. A
previously completely-serial function, not a refinement of already-
parallel code.
Tried and reverted after honest measurement (each still bit-identical, just
not beneficial): a chainAcc allocate-then-merge hoist (no measurable
benefit), deduping repeated source words in the candidate loop (measured
+24% regression -- the O(n^2) duplicate-detection scan costs more than the
hash lookups it saves), a single-pass cumulative-weights fill (measured
+8-16% regression with no clear mechanistic explanation), and hoisting
sweep-scope consts for TBAA (genuinely inconclusive even at 5 reps/side).
Deferred, not attempted: an RNG swap (mt19937_64 -> xoshiro256++/PCG64) and
blocked/AD-LDA-style within-chain Gibbs sampling -- both are output-changing
and this repo has no gold word-alignment data to validate against;
documented as follow-up work for a pass that starts by building that
infrastructure.
CI fix:
The initial push (round 1) failed CI on Linux and both macOS runners --
jumpBucketSaturationBoundary pinned an exact expected alignment from a
scenario deliberately engineered to stress the jump-bucket saturation
boundary (jumpWindow=2 on a 14-token sentence, 6 sweeps). That scenario is
legitimately platform-sensitive: removing most of the jump-distance prior's
constraining power lets tiny libm (exp/log) and
std::uniform_real_distribution implementation differences compound over the
sampling chain into a different (but equally valid) final alignment per
platform -- confirmed empirically, since Windows/MSVC, Linux/glibc, and
macOS/libc++ each produced a different alignment for the same input. This
is exactly the tradeoff SamplingUtils.h already documents. Fixed by
replacing the non-portable exact-value check with a structural validity
check plus a same-platform determinism cross-check; the three short-
sentence assertions in the same test were unaffected (confirmed stable on
all three platforms) and remain pinned. This trades away the regression
test for round 1's reverted saturating-counter bug, which is documented as
a known gap in the PR description for future work (extracting the
candidate-scoring arithmetic into a pure, RNG-free unit-testable function).
Verified: full 116-test suite passes on Windows/MSVC; compiles clean under
g++ -Wall -Werror -fopenmp -std=c++11 (Linux CI proxy); full CI matrix
(ubuntu-22.04, macos-15, macos-15-intel, windows x86/x64/arm64, wheels,
sdist, nuget) is green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fef8a9b to
ddcf836
Compare
|
How do these changes impact memory usage? Is the increased performance only realized when |
|
I'll check. |
Independent re-benchmark: time and memory, before/afterRan a fresh, independent before/after comparison to answer two questions the PR description doesn't directly cover: does end-to-end wall-clock actually improve outside the isolated hot-loop microbenchmark, and does the new per-chain caching (round 2's Method: git worktree at Time (full
Consistently faster, but more modest than the −26.4% headline — that number is the isolated hash-reuse microbenchmark inside One asymmetry: decode/alignment time ( Memory — does it explode? No, it's slightly lower after, in every config tested:
The new per-chain caches (decode Happy to share the corpus-prep/benchmark scripts if useful for turning this into a permanent (non-disabled) CI benchmark gate. |
Summary
Investigates and improves eflomal Gibbs-sampler training performance across two rounds: whether multithreading actually scales, whether allocator/memory-management behavior was limiting it, what's left for a hypothetical Rust rewrite, and then a follow-up round targeting cache locality, remaining copies, further threading opportunities, and Rust-style ownership discipline. All investigation logs, methodology, and per-change benchmark numbers are folded into this description below (no separate planning docs are included in the diff).
Headline finding (round 1): at default settings (
deterministic=false), training already scales close to ideal (~2.8x on 3 threads, ~84% efficiency atsamplers=8,threads=4) on a real corpus (7,952 verse-aligned eBible NT sentence pairs). Allocator contention was tested directly with a verified, active mimalloc override and found not to be a bottleneck. The real win came from reducing redundant hashing insampleSweep's hot loop, not from memory-layout changes.Top-line results
All numbers below are wall-clock, measured on the same real corpus (7,952 verse-aligned eBible NT sentence pairs, English/Spanish), min-of-N reps per cell (N=2 or 3; see per-change notes for methodology). Only shipped (kept) changes are listed — see "Tried and reverted" further down for attempts that regressed and were backed out.
sampleSweep's hot loop + supporting round-1 changes (chain-init parallelization,phibuffer hoist, flattened fertility arrays)samplers=1,threads=1samplers=8,threads=8std::async(usage pattern demo, not a library code change)numSamplers=3numSamplers=8accumLexCountsintolexCounts(round 2, item 1)samplers=1,t=1/samplers=8,t=8jumpLin/fertRatioper chain (round 2, item 2)computeTrainingAlignmentslexDenomInvmember (round 2, item 5)samplers=1,threads=1loglikelihoodForPairRange(round 2, item 11)threads=1vsthreads=8-859351.89both, not just statistically close)What "bit-identical" means here: with
deterministic=true(single-threaded, fixed-seed Gibbs sampler), every change marked "Yes" produces trained probability tables and decoded alignments that are byte-for-byte identical to the pre-change code on the same input — not merely similar or statistically close. Every such change is a pure performance/memory-layout change with no change to the sampling algorithm, and this is its actual correctness gate: the existing golden fixed-alignment tests pin exact expected alignments (any arithmetic or ordering drift breaks them immediately), plus dedicated new regression tests (initializeChainParallelIsReproducible,loglikelihoodForPairRangeEdgeCasesAndDeterminism) specifically exercise the newly-parallel code paths for race conditions. Rows marked N/A are usage-pattern demonstrations (no code in this repo changed) rather than correctness-gated optimizations.Round 1 changes
startTraining— the per-chain setup loop was serial despite chains being fully independent.phiscratch buffer out ofsampleSweep's andcomputeFertilityCounts's per-sentence loops, reusing it viaassign()instead of reallocating every sentence, every sweep.fertCounts/fertRatioSampledfromvector<vector<double>>to a single stridedvector<double>.tsl::robin_mapoperations insampleSweep, instead of rehashing the same fixed key up toslen+3times per token.THOT_BENCH_MIMALLOC) and new tests supporting the above.A second attempted optimization (saturating jump-bucket running counters) was implemented, benchmarked, then found incorrect by a dedicated code review: the counter doesn't stay pinned correctly when it starts a candidate loop already at a saturation boundary, silently corrupting jump-sampling weights for sentences longer than
jumpWindow. Reverted before landing;jumpBucketSaturationBoundarywas added as regression coverage (see CI fix below for a follow-up to that same test).Round 2 changes
A follow-up four-angle review (data layout/copies, cache hierarchy, threading headroom, Rust-style ownership discipline) produced 13 candidates, each tested individually with its own correctness gate and same-session A/B benchmark — including two real lessons learned along the way: session-level thermal/system drift is large enough to invert a conclusion if you compare against a stale baseline, and three separate "this should obviously help the compiler" ideas failed under measurement on this toolchain.
Kept (see the top-line table above for numbers):
accumLexCountsintolexCounts({live, accum}value instead of a second map).jumpLin/fertRatioper chain instead of rebuilding them from scratch on everydecodeMarginalFromTablescall. Caught and fixed a real regression along the way: the cached table is sized to the vocab as of last training, but vocab can grow afterward viagetBestAlignment's auto-interning of new words, which would have indexed the cache out of bounds. Fixed with a bounds check; addedgetBestAlignmentWithPostTrainingNewWordas permanent coverage.lexDenomInvmember instead of a fresh per-sweep allocation.getSrcSent/getTrgSentand hoisting vocab-size getters, found both were solving problems that didn't exist (the naming is an established codebase-wide convention; the getters have no hot-path cost), and instead documented the actual gap: an unstated invariant that vocab is frozen afterbuildCorpus, which is what makes severalomp parallel forregions safe.loglikelihoodForPairRange— the strongest result of this round: split into a serial vocab pre-conversion pass followed by a race-freeomp parallel for reduction. A previously completely-serial function, not a refinement of already-parallel code.Tried and reverted after honest measurement (each still bit-identical, just not beneficial): a
chainAccallocate-then-merge hoist (no measurable benefit), deduping repeated source words in the candidate loop (measured +24% regression — the O(n²) duplicate-detection scan costs more than the hash lookups it saves), a single-pass cumulative-weights fill (measured +8-16% regression with no clear mechanistic explanation), and hoisting sweep-scope consts for TBAA (genuinely inconclusive even at 5 reps/side).Deferred, not attempted: an RNG swap (
mt19937_64→ xoshiro256++/PCG64) and blocked/AD-LDA-style within-chain Gibbs sampling — both are output-changing and this repo has no gold word-alignment data to validate against; left as follow-up work for a pass that starts by building that infrastructure.CI fix
The initial push (round 1) failed CI on Linux and both macOS runners —
jumpBucketSaturationBoundarypinned an exact expected alignment from a scenario deliberately engineered to stress the jump-bucket saturation boundary (jumpWindow=2on a 14-token sentence, 6 sweeps). That scenario is legitimately platform-sensitive: removing most of the jump-distance prior's constraining power lets tiny libm (exp/log) andstd::uniform_real_distributionimplementation differences compound over the sampling chain into a different (but equally valid) final alignment per platform — confirmed empirically, since Windows/MSVC, Linux/glibc, and macOS/libc++ each produced a different alignment for the same input. This is exactly the tradeoffSamplingUtils.halready documents. Fixed by replacing the non-portable exact-value check with a structural validity check plus a same-platform determinism cross-check; the three short-sentence assertions in the same test were unaffected (confirmed stable on all three platforms) and remain pinned.Known gap left by this fix: the pinned value that was removed was, incidentally, the regression test for round 1's reverted "saturating jump-bucket running counter" bug — the most serious correctness bug found in this effort. The replacement structural/determinism check would not catch a reintroduction of that specific bug. Restoring equivalent coverage portably would mean extracting the candidate-scoring/jump-factor arithmetic into a pure, RNG-free function that can be unit-tested with hand-crafted inputs; left as follow-up work rather than attempted here.
Test plan
g++ -Wall -Werror(matching Linux CI's flags) and with GCC 15 more broadly, since local development was Windows-onlyinitializeChainParallelIsReproducible,loglikelihoodForPairRangeEdgeCasesAndDeterminismverify the new parallel regions are race-freegetBestAlignmentWithPostTrainingNewWordguards the OOV bounds-check fix in the decode cacheHistory is a single commit for review clarity.
🤖 Generated with Claude Code