[bugfix] avoid tensor-sized temporaries when trunc_normal_ inits large embedding tables#601
Conversation
…e embedding tables torch>=2.12 rewrote nn.init.trunc_normal_ with rejection sampling (pytorch/pytorch#174997), which materializes several temporaries as large as the tensor itself (up to three bool masks per check, plus fp32 resampling buffers). When torchrec applies a trunc_normal_ init_fn to a very large embedding table at DMP sharding time, e.g. a 150M-row x 64-dim ZCH table whose first bool mask alone is 8.94 GiB, the init OOMs GPUs that ran the same config fine on torch<=2.11, where the implementation was fully in-place. Route config init_fn expressions through init_util.create_init_fn, which swaps nn.init.trunc_normal_ for a size-aware wrapper that falls back to the in-place inverse-CDF implementation of torch<=2.11 for params above 2^27 elements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lu52Dws7J8GLgr7Hzviisn
|
|
||
| def test_trunc_normal_large_statistics(self): | ||
| t = torch.empty(1000, 1000) | ||
| with mock.patch.object(init_util, "_INPLACE_NUMEL_THRESHOLD", 0): |
There was a problem hiding this comment.
Minor test gap: the in-place path's add_(mean) step is only exercised with mean=0.0, so a transcription error in the mean handling wouldn't be caught. Since the whole point of _inplace_trunc_normal_ is to faithfully reproduce the torch<=2.11 result, consider asserting the mean of a nonzero-mean fill (e.g. mean=0.5) here.
| return (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0 | ||
|
|
||
| with torch.no_grad(): | ||
| low = norm_cdf((a - mean) / std) |
There was a problem hiding this comment.
Low priority: norm_cdf((a - mean) / std) divides by std with no guard, so a degenerate config (std=0, or a >= b) silently fills a large table with NaNs on this path. It's a malformed-config edge case (trusted config), but a std > 0 / a < b assertion would fail loudly instead of poisoning the embedding, and keep behavior consistent with the small-tensor path.
Review summaryClean, well-scoped bugfix. The root cause (torch>=2.12's rejection-sampling
Left two low-priority inline nits (a nonzero-mean test assertion, and a degenerate-config One thing worth a quick self-check before merge: the docstring/PR-body reference to |
| with torch.no_grad(): | ||
| low = norm_cdf((a - mean) / std) | ||
| up = norm_cdf((b - mean) / std) | ||
| tensor.uniform_(2 * low - 1, 2 * up - 1, generator=generator) |
There was a problem hiding this comment.
low/up round to 0/1 for the motivating std=0.0125, so uniform_ can produce a representable endpoint; erfinv_ makes it infinite and the final clamp turns it into an exact ±2 (160σ) outlier. On 9.6B FP32 elements, hundreds of such hits are likely, and FP16/BF16 are worse—this is the numerical defect pytorch/pytorch#174997 fixed. Please retain native sampling in bounded chunks (or explicitly exclude dtype endpoints) and add extreme-tail/low-precision coverage.
| Returns: | ||
| the input tensor. | ||
| """ | ||
| if tensor.numel() > _INPLACE_NUMEL_THRESHOLD: |
There was a problem hiding this comment.
This cutoff can still permit the same OOM class: at 2**27 FP32 elements the bounds expression alone can peak at ~384 MiB of masks, and a resample/narrow-truncation step adds about 1 GiB of full-size buffers. A shard at or below the cutoff can therefore fail with tight HBM headroom. Please use bounded chunks, or derive dispatch from an explicit transient-byte budget and dtype/parameters instead of a device-agnostic element count.
|
Static review summary The init-function centralization and narrower eval namespace are clean improvements. Focused review covered code quality, performance, test coverage, documentation accuracy, and security. I left two inline concerns:
No additional noteworthy findings. Tests and builds were not run, per request. |
…RMAL Review found the numel-threshold dispatch unsound in both directions: a table just under the cutoff can still allocate ~1.4 GiB of native temporaries under tight HBM headroom, while above it every user silently inherits the torch<=2.11 endpoint-clamp defect (a uniform_ sample landing exactly on an interval endpoint clamps to a/b after erfinv_, measured at ~2^-24 per element, i.e. ~450 exact -2.0 outliers on a 150M x 64 table). Replace the threshold with an explicit opt-in: trunc_normal_ defers to native nn.init.trunc_normal_ by default, and initializes with the in-place torch<=2.11 implementation only when USE_INPLACE_TRUNC_NORMAL=1 is set, restoring for memory-constrained huge-table jobs exactly the behavior their configs trained with on torch<=2.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lu52Dws7J8GLgr7Hzviisn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lu52Dws7J8GLgr7Hzviisn
|
Addressed the review findings in daf9e27:
🤖 Generated with Claude Code |
Problem
nn.init.trunc_normal_in torch>=2.12 was rewritten with rejection sampling (pytorch/pytorch#174997). The new implementation materializes several temporaries as large as the tensor itself: the bounds checkmask = (result < lo) | (result > hi)alone allocates up to three tensor-sized bool masks, and any resample iteration adds two fp32 buffers of full tensor size.torchrec applies a table's
init_fnto the full local shard at DMP sharding time (ShardedEmbeddingBagCollection._initialize_torch_state -> reset_parameters). For a very large table this transient allocation is huge — e.g. a 150M-row x dim-64 ZCH table with afused_uvm_cachingkernel needs an 8.94 GiB first mask (and ~27 GiB peak at that line) — and it OOMs GPUs that ran the exact same config fine on torch<=2.11, wheretrunc_normal_was fully in-place (uniform_ -> erfinv_ -> mul_ -> add_ -> clamp_, ~0 extra bytes).Fix
tzrec/utils/init_util.py:create_init_fn(expr)centralizes the eval of configinit_fnexpressions (previously inlineeval(f"partial({expr})")at three sites) with an explicit{partial, nn, torch}namespace, and swapsnn.init.trunc_normal_for an env-switchabletrunc_normal_.trunc_normal_defers to nativenn.init.trunc_normal_by default. SettingUSE_INPLACE_TRUNC_NORMAL=1initializes with the in-place inverse-CDF implementation of torch<=2.11 (~0 transient HBM), restoring for memory-constrained huge-table jobs exactly the initialization their configs trained with on torch<=2.11.zch.threshold_filtering_funceval infeature.pypreviously resolvedpartial/nnfrom module globals kept alive by# NOQAimports; it now gets an explicit namespace (partial,nn,torch, and the three*_threshold_filterhelpers) instead of leaking module globals into config eval.An earlier revision dispatched on a
numel > 2**27threshold; review correctly noted that a fixed element cutoff neither bounds native temporaries just below it (~1.4 GiB possible) nor avoids silently reintroducing the torch<=2.11 endpoint-clamp tail defect above it (auniform_sample landing exactly on an interval endpoint clamps to a/b aftererfinv_; measured ~2^-24 per element, ~450 exact -2.0 outliers on a 150M x 64 table). The explicit opt-in keeps native statistics for everyone by default and makes the memory/statistics trade a deliberate per-job choice.Test Plan
tzrec/utils/init_util_test.py(7 tests): expression parsing andtrunc_normal_swap (kwargs preserved, non-trunc-normal init fns untouched), default path calls nativenn.init.trunc_normal_with exact argument forwarding,USE_INPLACE_TRUNC_NORMAL=1path calls the in-place implementation with exact argument forwarding, statistics through the public API with nonzero mean (mean=0.5, std=0.0125), truncation at ±1σ, and generator reproducibility.tzrec/features/id_feature_test.py: added apartial(dynamic_threshold_filter, ...)case forzch.threshold_filtering_functo guard the explicit eval namespace.tzrec.utils.init_util_test(7),tzrec.features.id_feature_test(58),tzrec.modules.embedding_test(52),tzrec.models.wide_and_deep_test(3, coverswide_init_fn) — all pass;pre-commitclean.IdFeaturewithinit_fn: "nn.init.trunc_normal_,mean=0.0,std=0.0125"and verified the config-created init_fn calls native torch by default and the in-place path withUSE_INPLACE_TRUNC_NORMAL=1, with correct statistics on both.🤖 Generated with Claude Code
https://claude.ai/code/session_01Lu52Dws7J8GLgr7Hzviisn