Skip to content

[FEA] Support FTRL optimizer in DynamicEmb - #487

Open
jiashuy wants to merge 14 commits into
NVIDIA:mainfrom
jiashuy:feat/dynamicemb-ftrl
Open

jiashuy wants to merge 14 commits into
NVIDIA:mainfrom
jiashuy:feat/dynamicemb-ftrl

Conversation

@jiashuy

@jiashuy jiashuy commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Description

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

jiashuy and others added 11 commits September 15, 2026 13:10
Seeding a row's optimizer state was the storage's job: every call site
filled the whole state region with one scalar, `initial_accumulator_value`,
taken from a copy cached on DynamicEmbTableState. That is wrong twice over.

Adam does not have an `initial_accumulator_value`. Neither torch.optim.Adam
nor FBGEMM's TBE exposes a way to seed its moments -- both start them at
zero -- because the bias correction 1/(1-beta^t) is derived assuming it, and
a non-zero first moment steers the early steps by a phantom momentum instead
of the gradient (a negative gradient still moves the weight down). Configure
Adam together with that Adagrad-family option today and its m and v are both
silently set to it. Adam now reports zero and warns that the option is
ignored.

The cached scalar also went stale: set_opt_args() runs on checkpoint load and
rewrites initial_accumulator_value, but DynamicEmbTableState captured it at
table construction, so keys inserted after a load were seeded with the value
the table was built with. Every reader now asks the optimizer, and the cached
field is gone -- the optimizer is a single instance shared by the cache, both
HybridStorage tiers and backward, so there is one source of truth to ask.

Callers now go through BaseDynamicEmbeddingOptimizer.reset_optimizer_states(),
which owns the layout of the region it writes. This also gives an optimizer
whose state regions do not all start at the same value somewhere to say so,
which a single scalar cannot express. Its `indices` argument keeps the fused
value buffer intact: the state region has to be sliced off first, since
`values[rows, max_emb_dim:]` is a copy a write would be lost to, while
`values[:, max_emb_dim:]` is a view.

Storage keeps init_optimizer_state() for callers that want the value itself
rather than a reset; the benchmark scales random data by it. It no longer
forwards the reset, so the interface lives in one place.

Also renames get/set_initial_optim_states to get/set_initial_optimizer_state:
it returns one scalar, not a collection, and every other public name in the
file spells out "optimizer".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements FTRL-Proximal, Algorithm 1 of McMahan et al., "Ad Click
Prediction: a View from the Trenches" (KDD 2013). Per coordinate:

    new_accum = accum + grad^2
    linear   += grad - (new_accum^-p - accum^-p) / lr * weight
    weight    = 0                                     if |linear| <= l1_reg
              = (sign(linear)*l1_reg - linear)
                / ((ftrl_beta + new_accum^-p) / lr + l2_reg)
    accum     = new_accum

`learning_rate`, `ftrl_beta`, `l1_reg` and `l2_reg` are the paper's alpha,
beta, lambda1 and lambda2. The paper fixes the learning rate at
alpha / (beta + sqrt(n)); `learning_rate_power` generalizes the exponent the
way TensorFlow's FtrlOptimizer does, and its default of -0.5 recovers the
paper exactly. Since that default is also the overwhelmingly common case, it
takes a sqrt path rather than a general pow, chosen on the host because the
predicate is launch-uniform.

Verified two ways without a GPU. Lifting update_one out of the header and
compiling it standalone reproduces a closed form worked out by hand -- with
l1 = l2 = beta = 0 the update collapses to
w*(1 - n^0.5/n_next^0.5) - lr*g/n_next^0.5, which for w=1, lr=0.1, n=0.1 and
g=1 gives 0.6031424. And a differential test against a literal transcription
of Algorithm 1, over 1.44M updates across three exponents and four
(beta, l1, l2) combinations, agrees to 2.6e-6 absolute with no disagreement
about which side of the l1 threshold a coordinate falls on. That residual is
float against the reference's double; float is deliberate, since every other
optimizer here computes in float and double is 1/2 to 1/64 throughput on GPU.

FBGEMM's EmbOptimType has no FTRL member and a Python enum with members
cannot be extended, so dynamicemb carries its own `DynamicEmbOptimType`. The
two enums never compare equal, which is the behaviour we want: an
`== EmbOptimType.X` test elsewhere must not match FTRL.

Per row the state is `linear` then `accum`, each emb_dim wide, the same shape
of layout Adam uses for m and v. `linear` starts at zero and `accum` at
`initial_accumulator_value`, so reset_optimizer_states gains `emb_dims` to
locate the boundary -- a padded buffer reserves the widest table's state for
every row, so the block can be wider than a given row's own state and the
width alone cannot be divided up. Optimizers whose state is uniform ignore it.

Note that seeding `accum` is not free here the way it is in the linear
regression FTRL was written for, whose weights start at zero. It leaves the
state inconsistent with the weight already in the row, and the first update
reconciles them by shrinking the weight by sqrt(n0 / (n0 + g^2)) -- for the
small gradients typical of embeddings, nearly all of it. `ftrl_beta` bounds
the early steps without that cost, since beta sits outside the accumulator;
DynamicEmb_APIs.md tabulates the difference.

construct_twin_module now takes every width of a value row from the table
rather than mixing in the twin's own `dim`, and says so if they disagree.
FTRL has no FBGEMM counterpart, so a twin cannot be built for it anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s own methods

truncate_optimizer_states_for_checkpoint and
pad_optimizer_states_from_checkpoint both took the optimizer as their first
argument and asked it for the widths they convert between, so they were
methods already. They are now states_for_checkpoint / states_from_checkpoint
on BaseDynamicEmbeddingOptimizer -- named for what they hand back rather
than for how they get there, since truncating and padding turned out to be
one optimizer's business rather than everyone's.

That is the other half of the change. The conversion only ever applied to
rowwise Adagrad, the one optimizer whose get_ckpt_state_dim differs from its
get_state_dim, because it widens its single accumulator to a fixed 16 bytes
for alignment in the fused value row. Every other optimizer checkpoints
exactly what it runs with. So the narrowing and widening moves into an
override on RowWiseAdaGradDynamicEmbeddingOptimizer and the base does the
common thing: hand the state back for a checkpoint, and change only its
precision on the way in.

It also drops a redundant parameter: `pad` asked its caller for the scalar
to fill the columns a narrow checkpoint does not cover, and all four callers
passed optimizer.get_initial_optimizer_state() -- a value the method can
reach itself. Those columns now go through reset_optimizer_states, which
seeds them the way a fresh row is seeded instead of assuming one scalar
covers the whole region.

Behaviour change: every one of the four paths now rejects a block that is
not the width it keeps, where the old pair quietly truncated a too-wide one
and padded a too-narrow one. Each direction has exactly one legal width --
runtime going out, checkpoint coming in -- so any other width is a
checkpoint that does not belong to this table, and reshaping it into
something that loads only defers the damage to training.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FtrlVecOptimizer described its exponent through two indirections, neither of
which survived reading.

It held -learning_rate_power under the name `neg_lr_power`, so the member did
not match the option it comes from and following the kernel meant carrying a
sign flip in your head. It now holds `learning_rate_power` as given and
negates at the single place the exponent is applied, where the negate folds
away because the value is uniform.

And the flag selecting the fast path was called `lr_power_is_half`, which
names the wrong number: learning_rate_power is -0.5, and what is a half is
the exponent applied to the accumulator. It is `use_sqrt` now, after what it
chooses -- sqrtf over a general powf.

The test for that flag is exact rather than within 1e-6. -0.5 is
representable in float and arrives that way from Python, so the tolerance
never rescued a value that meant to be a square root; it only stood ready to
swallow one that did not, silently computing a different exponent than the
caller asked for. TensorFlow compares exactly here for the same reason.

Both paths were checked to agree: forcing a learning_rate_power of -0.5 down
the powf branch instead of the sqrtf one moves the weight by at most 3e-7
over 800k updates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The accumulator is raised to -learning_rate_power, so a positive value moves
it into the denominator of the learning rate: the rate then grows as the
gradients accumulate instead of decaying, and training walks off. Zero is
meaningful -- accum**0 is 1, a fixed learning rate -- so the bound is <= 0,
which is the same one Keras enforces on its Ftrl.

Checked in the constructor and again in set_opt_args, since that takes a
checkpoint's meta and nothing else vets it. The learning-rate check that was
already there moves alongside it so both entry points share one rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FTRLVecOptimizer and SGDVecOptimizer, to read the same way as the
FTRLDynamicEmbeddingOptimizer and SGDDynamicEmbeddingOptimizer they
implement. Adam, AdaGrad and RowWiseAdaGrad keep their spelling -- those are
names rather than initialisms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ayout

test/unit_test.sh names the files it runs one by one, so test_ftrl_optimizer.py
was never going to execute. Added to the fwd_bwd list.

test_padded_buffer_optimizer.py covers the corruption path that issue NVIDIA#419
found in Adam: the padded buffer reserves the widest table's state for every
row, so a narrower table's second state region begins at max_emb_dim + edim
rather than at 2 * max_emb_dim. FTRL has exactly the same exposure with
linear and accum, and had no test for it. The new case runs two tables of
different dims through ftrl_update_for_padded_buffer -- across both the vec4
and the scalar path, as the Adam cases do -- and checks the narrow table's
accum advanced by g^2 in its own slot, that linear was computed from that
accum rather than from a zero read out of the padding, and that nothing past
the row's own state was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_alignment_cache_storage_shapes checks that a table's value row is
embedding_dim + get_optimizer_state_dim wide once sharding and bucketing have
had their say. FTRL is the one optimizer whose state is two regions rather
than one, so its row is 3 * dim where every other swept optimizer's is 1x or
2x -- exactly the kind of width this test exists to pin down, and it was not
being swept.

Nothing else on the path needed widening: the planner does not read the
optimizer, and the sharder passes fused_params through untouched, so
DynamicEmbOptimType reaches the table factory as-is. The two optimizer_type
annotations become OptimType to admit it.

This grows the case count by a third (1080 -> 1440).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FTRL had a file to itself because nothing in the suite could serve as its
reference: the batched-table tests check optimizer arithmetic against a
SplitTableBatchedEmbeddingBagsCodegen built with the same hyperparameters, and
FBGEMM has no FTRL. A second dynamicemb storage is no substitute -- PyDictStorage
takes the very optimizer under test, so a wrong formula comes out wrong on both
sides and the comparison passes.

So give the suite the reference it was missing. optimizer_reference.py
transcribes Algorithm 1 in float64, outside any test_ module so pytest leaves it
alone, and test_ftrl_backward_matches_reference drives a real
BatchedDynamicEmbeddingTablesV2 against it over four iterations -- sequence
pooling with one unique key per bag and loss.sum(), so every row is touched once
and each gradient is exactly 1. It sweeps beta, L1, L2, the exponent and a
seeded accumulator, across a vec4 and a non-vec4 dim, and checks all three of
weight, linear and accum. That subsumes the closed-form anchor, the float64
comparison and the exact-zero check that lived in the standalone file.

The two cases that were never module-level behaviour move to
test_padded_buffer_optimizer.py, which is already where the kernels' own
contracts are pinned: that the flat-table and padded-buffer kernels agree, and
that a row whose index is -1 is left alone.

Dropped: the state-width case, now covered twice over by test_alignment and
test_dump_optimizer_states_ckpt_width, along with the reset-semantics and
hyperparameter-validation cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test/unit_tests/optimizer/ now holds both: test_padded_buffer_optimizer.py
moves in unchanged, and test_ftrl_optimizer.py joins it carrying the three
tests that are about FTRL rather than about a buffer layout -- the two fused
kernels agreeing with each other, the -1 row-addressing contract, and a real
BatchedDynamicEmbeddingTablesV2 landing where the paper says it should.

ftrl_step comes along as the reference those last tests need, so the separate
optimizer_reference.py module is gone; a reference with one caller belongs
next to it. test_ftrl_padded_buffer_mixed_dims_accum stays behind, since what
it pins is the padded buffer's layout rather than FTRL's arithmetic, and it
reads alongside the two Adam cases that cover the same ground.

Also restores a @cuda marker that the previous move dropped from
test_ftrl_flat_table_matches_padded_buffer, which would have made it error
rather than skip on a machine without a GPU.

Both files are named in unit_test.sh at their new paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_ftrl_backward_matches_reference walked export_keys_values_iter and
checked each batch against the full reference. With sixteen keys and a default
batch size of 65536 there is only ever one batch, so it passed -- but by
coincidence, not by construction: a larger table or a smaller batch size would
have compared a subset against the whole and failed on shape. The per-batch
argsort had the same flaw, ordering only within a batch when the reference is
indexed by key across all of them.

The keys are 0..num_keys-1 and the reference is indexed the same way, so a
batch can just look itself up. That drops the sort and the concatenation, and
stops the test caring what order the export walks the table in or how it
divides the rows. A running count still has to reach num_keys, so a short
export cannot pass either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiashuy jiashuy changed the title Feat/dynamicemb ftrl [FEA] Support FTRL optimizer in DynamicEmb Sep 16, 2026
@jiashuy

jiashuy commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

/build

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with both previous findings fixed and no new actionable issues in the latest change.

Summary

Adds FTRL-Proximal support to DynamicEmb, including optimizer configuration, CUDA updates, state initialization, checkpoint handling, and regression coverage.

  • Supports both flat-table and padded-buffer updates with per-row linear and accum state.
  • Moves state initialization and checkpoint conversion behind optimizer-specific methods.
  • The latest change fixes the seeded-accumulator reference test by excluding initialization-only configuration from step arguments.
  • No new actionable issues were identified in the changes since the previous review.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[FTRL configuration] --> B[FTRL optimizer]
    B --> C[Initialize linear and accum]
    C --> D[Embedding rows]
    D --> E[Backward gradients]
    E --> F{Storage layout}
    F --> G[Flat-table CUDA update]
    F --> H[Padded-buffer CUDA update]
    G --> I[Updated weights and state]
    H --> I
    I --> J[Checkpoint export and restore]
Loading

Reviews (3) · Last reviewed commit: "fix(dynamicemb): stop handing the FTRL r..."

Comment thread corelib/dynamicemb/dynamicemb/batched_dynamicemb_tables.py
Comment thread corelib/dynamicemb/DynamicEmb_APIs.md Outdated
@JacoCheung

JacoCheung commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Pipeline #68097371 -- failed

Job Status Log
pre_check ❌ failed view
train_build_x86 ✅ success view
train_build_arm64 ✅ success view
prepare-jet-b200-smoke ✅ success view
prepare-jet-b200-inference ✅ success view
prepare-jet-cw-dfw-e2e-benchmark ✅ success view
build_whl ✅ success view
dynamicemb_test_fwd_bwd_8gpus ❌ failed view
dynamicemb_test_load_dump_8gpus ❌ failed view
unit_test_1gpu_a100 ❌ failed view
unit_test_1gpu_h100 ❌ failed view
unit_test_4gpu ✅ success view
unit_test_tp_4gpu ❌ failed view
L20_unit_test_1gpu ✅ success view
inference_unit_test_1gpu ✅ success view
inference_test_1gpu ❌ failed view
inference_test_nve_2605_1gpu ❌ failed view

Result: 9/17 jobs passed

View full pipeline

jiashuy and others added 2 commits September 16, 2026 12:02
_create_optimizer builds OptimizerArgs from four names it never receives --
learning_rate_power, ftrl_beta, l1_reg and l2_reg are parameters of __init__,
and the method is a separate scope. That construction happens before the
optimizer type is dispatched on, so every configuration hit it, not just FTRL:
building a BatchedDynamicEmbeddingTablesV2 with SGD, Adam or Adagrad raised
NameError too.

Declare the four on _create_optimizer and forward them from the call site.

Also drops three imports left unused when the code that wanted them went away.
Both this and the NameError are what pyflakes reports and py_compile does not;
the suite now runs clean against main on the files this branch touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…izer

Three errors, all in the direction of making a non-zero seed look safer than
it is.

The exponent convention contradicted itself: the formula block defines
p = learning_rate_power and raises the accumulator to n^(-p), so the paper's
square root is p = -0.5, not the p = 0.5 the prose claimed -- a value the
implementation rejects outright.

The retention table was wrong by two orders of magnitude at the small end. For
n0 = 0.1 and g = 0.0001 the initializer keeps 0.000005%, not the 0.0005%
printed.

And the recommendation was backwards. ftrl_beta was offered as the safe way to
bound the early steps, on the grounds that it sits outside the accumulator.
It does, but it discounts the initializer just the same: with n0 = 0 the
retained fraction is |g| / (ftrl_beta + |g|), which for beta = 1 and g = 0.01
is under one percent.

Both knobs are now given by one formula,
(sqrt(n1) - sqrt(n0)) / (ftrl_beta + sqrt(n1)), the table covers their four
combinations, and the text says plainly that only leaving both at zero keeps
the initializer whole -- and what that costs in turn, a first step of
w - lr*sign(g) whatever the gradient's size. The optimizer docstring says the
same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiashuy

jiashuy commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

/build

@JacoCheung

JacoCheung commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Pipeline #68127510 -- failed

Job Status Log
pre_check ❌ failed view
train_build_x86 ❌ failed view
train_build_arm64 ✅ success view
prepare-jet-b200-smoke ✅ success view
prepare-jet-b200-inference ✅ success view
prepare-jet-cw-dfw-e2e-benchmark ⏩ skipped view
build_whl ⏩ skipped view
dynamicemb_test_fwd_bwd_8gpus ⏩ skipped view
dynamicemb_test_load_dump_8gpus ⏩ skipped view
unit_test_1gpu_a100 ⏩ skipped view
unit_test_1gpu_h100 ⏩ skipped view
unit_test_4gpu ⏩ skipped view
unit_test_tp_4gpu ⏩ skipped view
L20_unit_test_1gpu ⏩ skipped view
inference_unit_test_1gpu ⏩ skipped view
inference_test_1gpu ⏩ skipped view
inference_test_nve_2605_1gpu ⏩ skipped view

Result: 3/5 jobs passed

View full pipeline

@jiashuy

jiashuy commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

/build

@JacoCheung

JacoCheung commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Pipeline #68295263 -- failed

Job Status Log
pre_check ❌ failed view
train_build_x86 ✅ success view
train_build_arm64 ✅ success view
prepare-jet-b200-smoke ✅ success view
prepare-jet-b200-inference ✅ success view
prepare-jet-cw-dfw-e2e-benchmark ✅ success view
build_whl ✅ success view
dynamicemb_test_fwd_bwd_8gpus ❌ failed view
dynamicemb_test_load_dump_8gpus ✅ success view
unit_test_1gpu_a100 ✅ success view
unit_test_1gpu_h100 ✅ success view
unit_test_4gpu ✅ success view
unit_test_tp_4gpu ❌ failed view
L20_unit_test_1gpu ✅ success view
inference_unit_test_1gpu ✅ success view
inference_test_1gpu ✅ success view
inference_test_nve_2605_1gpu ✅ success view

Result: 14/17 jobs passed

View full pipeline

test_ftrl_backward_matches_reference splats one opt_params dict into two
places, and they do not take the same keys. The table constructor accepts
initial_accumulator_value; ftrl_step does not, because seeding the accumulator
is not part of a step -- the reference already starts from the seeded value,
having read ref_accum out of the table after reset_optimizer_states laid it
down. The seeded_accum case was the only one carrying that key, so it was the
only one that raised TypeError.

Split the step's own arguments out before the call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiashuy

jiashuy commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

/build

@JacoCheung

JacoCheung commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Pipeline #68348321 -- failed

Job Status Log
pre_check ❌ failed view
train_build_x86 ❌ failed view
train_build_arm64 ✅ success view
prepare-jet-b200-smoke ✅ success view
prepare-jet-b200-inference ✅ success view
prepare-jet-cw-dfw-e2e-benchmark ⏩ skipped view
build_whl ⏩ skipped view
dynamicemb_test_fwd_bwd_8gpus ⏩ skipped view
dynamicemb_test_load_dump_8gpus ⏩ skipped view
unit_test_1gpu_a100 ⏩ skipped view
unit_test_1gpu_h100 ⏩ skipped view
unit_test_4gpu ⏩ skipped view
unit_test_tp_4gpu ⏩ skipped view
L20_unit_test_1gpu ⏩ skipped view
inference_unit_test_1gpu ⏩ skipped view
inference_test_1gpu ⏩ skipped view
inference_test_nve_2605_1gpu ⏩ skipped view

Result: 3/5 jobs passed

View full pipeline

@jiashuy

jiashuy commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

/build

@JacoCheung

JacoCheung commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Pipeline #68373272 -- failed

Job Status Log
pre_check ❌ failed view
train_build_x86 ✅ success view
train_build_arm64 ✅ success view
prepare-jet-b200-smoke ✅ success view
prepare-jet-b200-inference ✅ success view
prepare-jet-cw-dfw-e2e-benchmark ✅ success view
build_whl ✅ success view
dynamicemb_test_fwd_bwd_8gpus ❌ failed view
dynamicemb_test_load_dump_8gpus ✅ success view
unit_test_1gpu_a100 ✅ success view
unit_test_1gpu_h100 ✅ success view
unit_test_4gpu ✅ success view
unit_test_tp_4gpu ❌ failed view
L20_unit_test_1gpu ✅ success view
inference_unit_test_1gpu ✅ success view
inference_test_1gpu ✅ success view
inference_test_nve_2605_1gpu ✅ success view

Result: 14/17 jobs passed

View full pipeline

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