[bugfix] apply enable_global_average_loss to the list-wise rank loss - #678
tiankongdeguiji merged 2 commits into
Conversation
| losses = {} | ||
| global_average = self._model_config.enable_global_average_loss | ||
| request_avg_weight = None | ||
| if global_average and self._has_listwise_loss: |
There was a problem hiding this comment.
Major — the refactor is not behavior-preserving: it activates a rescale that was dead code on master.
The removed block in RankModel._loss_impl gated the request-count factor on getattr(self._base_model_config, "enable_global_average_loss", False), but _base_model_config is the outer ModelConfig envelope (model.py:66), and model.proto does not define enable_global_average_loss on that message — it exists only on the three HSTU messages in multi_task_rank.proto. Protobuf raises AttributeError for undefined fields, so the getattr default always yielded False: on master the listwise term was never rescaled (the in-module factor was always None, and the caller's loss_weight was nulled).
Reading the flag from self._model_config here activates it. In multi-rank training and eval with a listwise_rank_loss configured and the proto default (enable_global_average_loss = true), the listwise term now gets multiplied by local_requests / avg_requests (≠ 1.0 on ragged batches) and one extra all_reduce runs per loss() call — loss values and the listwise/pointwise gradient balance shift relative to master. The new test corroborates the delta: on the base commit the listwise ratio would be 1.0, not 0.5, so test_global_average_loss_rescales_each_term_by_its_own_denominator would fail there.
The activated math itself is correct — per-rank S_r/N_r weighted by N_r/N̄ makes DDP's averaged gradient the unbiased global request mean Σ S_r / Σ N_r — and it finally makes the proto's documented promise ("enables loss averaging computation globally across all ranks") true for the listwise term too. So the change looks right; the framing is the issue. Suggest confirming the activation is intended and updating the PR/commit message: "Loss values are unchanged" holds only single-process, and per AGENTS.md this half is really a [bugfix] (root cause: the flag was read from the wrong config object) rather than a [refactor], so users upgrading mid-training know the listwise loss scale will change.
| # The caller's per-candidate loss_weight does not apply here: it | ||
| # is sized off the candidate count, not the request count. | ||
| loss_weight = None | ||
| losses[loss_name] = self._loss_modules[loss_name](pred, label, lengths) |
There was a problem hiding this comment.
Minor — the contract behind the deleted guard is now undocumented.
The removed loss_weight = None carried a comment explaining why ("it is sized off the candidate count, not the request count"). After this change the shared tail applies whatever loss_weight the caller passes to the request-level scalar. Nothing can go wrong today — only DlrmHSTU.loss reaches this branch (only the HSTU family publishes TARGET_REPEAT_INTERLEAVE_KEY) and it passes only None or the request-count scalar — but a future model that publishes the key and reuses RankModel.loss/MultiTaskRank.loss with per-sample weights would silently broadcast a candidate-sized vector against the request-level scalar instead of failing. A one-line # NOTE: at this branch preserving the deleted rationale would keep the invariant stated where it's enforced, at no structural cost.
| # Emulate one peer rank holding 6 requests / 2 candidates against | ||
| # this rank's 2 / 6, so the two ratios differ and neither is 1.0. | ||
| peer_of = {float(len(_NUM_TARGETS)): 6.0, float(_TOTAL_TARGETS): 2.0} | ||
| with mock.patch.object(fx_util, "dist") as dist_mock: |
There was a problem hiding this comment.
Minor / optional — two cheap hardenings for the new gate:
- The mock only exercises the flag-on model. If the
global_averageguard inDlrmHSTU.losswere dropped, every single-process factor still collapses to 1.0, so nothing in the suite would catch that regression. Recomputingbase.lossinside the same mock and asserting it equalsbase_losses(or assertingdist_mock.all_reduce.call_count == 4) would pin the gate. _has_listwise_lossis anany()over all tasks' losses, but the listwise loss is wired only intois_click(as in every listwise test in this file). A variant with the listwise term on a later task — or on two tasks, which would also exercise the singlerequest_avg_weightbeing reused across multiple listwise terms — would cover the actual scan.
Review summaryFive parallel review passes (code quality, performance, test coverage, documentation accuracy, multi-process safety) plus manual cross-checks; all five completed. The refactor itself is well executed: One major finding (inline on Minor (inline): the request-level-weight contract in Optional follow-ups (non-blocking, in the spirit of the PR body's own defused perf question):
|
6f1c9ab to
74af26a
Compare
`RankModel._loss_impl` gated the list-wise request-count rescale on `getattr(self._base_model_config, "enable_global_average_loss", False)`, but `_base_model_config` is the outer `ModelConfig` envelope and the flag is defined only on the `DlrmHSTU` messages. Protobuf raises `AttributeError` for a field a message does not define, so the getattr always fell back to `False` and the rescale was dead code: under DDP the list-wise term averaged over each rank's own request count, leaving the cross-rank gradient average biased whenever those counts differ, which cost-based batching (`batch_cost_size`) makes routine. The root cause is a generic base class reaching for a model-specific config field, so the fix removes the reach rather than correcting the message name. `DlrmHSTU.loss` already loops over `loss_cfg`, so it now picks the request-count factor for the list-wise term and the candidate-count factor for the point-wise ones and passes the right one through the existing `loss_weight` argument. The list-wise loss reduces to a scalar, so the shared tail's `torch.mean(scalar * loss_weight)` is the same multiply `ListwiseRankLoss` was doing internally; its `loss_weight` parameter goes away and the branch ends up shaped like its neighbours. Both counts come off the candidate lengths the model already publishes, so `fx_avg_batch_size` becomes `fx_avg_counts` and reduces them together: one collective per `loss()` call instead of the request axis plus one duplicate candidate reduction per task. Multi-rank training and eval with a list-wise term and the proto default `enable_global_average_loss = true` will see the list-wise loss scale change. Single-process runs are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSiYGTLEYMo8mq8hthZGJS
74af26a to
6f87112
Compare
…lobal-avg-weight # Conflicts: # tzrec/utils/fx_util_test.py
The bug
RankModel._loss_implgated the list-wise request-count rescale on:_base_model_configis the outerModelConfigenvelope (model.py:66), and the flag is defined only on the three HSTU messages inmulti_task_rank.proto. Protobuf raisesAttributeErrorfor a field a message does not define, so thegetattrdefault always yieldedFalse. Verified on a live model:So the rescale was dead code. Under DDP the list-wise term averaged over each rank's own request count and the cross-rank gradient average came out biased whenever those counts differ — which cost-based batching (
sample_cost_field/batch_cost_size) makes routine, since it slices on cumulative cost rather than row count.The fix
The root cause is a generic base class reaching for a model-specific config field, so the fix removes the reach rather than correcting the message name — that way the same mistake cannot recur in the branch.
DlrmHSTU.lossalready loops overloss_cfg, so it picks the factor matching each loss's denominator — request count for the list-wise term, candidate count for the point-wise ones — and passes it through the existingloss_weightargument. Since the list-wise loss reduces to a scalar, the shared tail'storch.mean(scalar * loss_weight)is bit-identical to the multiplyListwiseRankLosswas doing internally, so the module'sloss_weightparameter goes away (it now matches the other loss modules, which take no weights) and the branch in_loss_implends up shaped like its neighbours.The activated math is the standard one: per-rank
S_r/N_rweighted byN_r/N̄makes DDP's averaged gradient the unbiased global request meanΣ S_r / Σ N_r.Compatibility
Multi-rank training and eval with a
listwise_rank_losswill see the list-wise loss scale change, sinceenable_global_average_lossdefaults totrue. One extraall_reduceperloss()call is added, gated on a task actually configuring a list-wise term so models without one pay nothing. Single-process runs are unaffected (every factor is exactly 1.0). The point-wise path is untouched — it always read the flag from the correct message.Alternatives considered
Correcting the read to
self._model_configwould be a one-liner, butRankModel's inner config isMultiTower,DeepFM, … for most models, none of which define the flag — so it would still be agetattrprobe in a base class for a field only some models own, i.e. the same bug class one rename away. Moving the decision to the model that owns the flag closes it.Adding explicit
sample_avg_weight/request_avg_weightparameters to_loss_implwas the other option, but it changes a signature shared by four callers to express what the existingloss_weightslot already carries.Collective fusion
The rescale factors used to cost one all-reduce for the request axis plus one per task for the candidate axis — and every one of the per-task reductions carried the same number, because each task's jagged label spans the same candidates. Measured on the 3-task OneRank fixture:
Both counts are already available from the candidate lengths the model publishes (
lengths.size(0)requests,lengths.sum()candidates), sofx_avg_batch_sizebecomesfx_avg_countsand reduces the pair together:The saving is rank synchronizations, not bandwidth — a scalar collective costs a sync point, and three of them per step were buying nothing. It also removes the need to gate the request-axis reduction on whether any task configures a list-wise loss: one collective serves every loss of every task, so a task whose losses are all list-wise no longer pays for a candidate factor it never reads.
This leans on
lengths.sum() == label.size(0)for every task. That is already required for the point-wise losses to run at all —logits_<task>and the jagged label are indexed against the same candidate rows, and a mismatch raises fromBCEWithLogitsLoss(or from the list-wise segment ops) long before the weighting matters — so the invariant is enforced, not newly assumed.Test Plan
Added
test_global_average_loss_rescales_each_term_by_its_own_denominator(dlrm_hstu_onerank_test.py). Runsloss()against a mocked peer rank holding 6 requests / 2 candidates versus this batch's 2 / 6, so the request ratio (0.5) and candidate ratio (1.5) differ and neither is 1.0, then asserts the list-wise term takes the former and the point-wise terms the latter. It also re-runs the flag-off model inside the same mock and asserts nothing moves, pinning theglobal_averagegate.This closes a real hole: the
enable_global_average_losssweeps indlrm_hstu_test.py,dlrm_hstu_onerank_test.pyandultra_hstu_test.pyall run single-process, where both factors collapse to exactly1.0, so nothing could observe the dead code. The test fails on the base commit, as a regression test for this bug should.It also asserts
all_reduce.call_count == 1, which pins the fusion.Verified by mutation, three times: swapping the two factors in
DlrmHSTU.lossfails the ratio assertions; dropping theglobal_averagegate fails the flag-off assertion; splitting the request axis back into its own reduction fails the count assertion with2 != 1. All pass after reverting.Rewrote
FxAvgBatchSizeTestasFxAvgCountsTest, keeping the AVG-vs-SUM check and adding one that both axes share a single collective.Removed
test_loss_weight_is_a_scalar_multiplier— it covered only the deleted module parameter. Every other test in that file, and the reference check indlrm_hstu_onerank_test.py, already called the module with three arguments.Run on 4x A10:
🤖 Generated with Claude Code
https://claude.ai/code/session_01YSiYGTLEYMo8mq8hthZGJS