[feat] support ftrl sparse optimizer for dynamicemb - #682
tiankongdeguiji merged 3 commits into
Conversation
dynamicemb gained FTRL-Proximal in NVIDIA/recsys-examples#487, under its own `DynamicEmbOptimType` because FBGEMM has no FTRL kernel and so `EmbOptimType` has no member for it. Exposes it as a `ftrl_optimizer` sparse optimizer and bumps the dynamicemb pin to the wheel that carries it. torchrec derives an embedding table's fused `optimizer` param from the in-backward optimizer class, and on the EmbeddingCollection path it does so unconditionally, so naming the type in the optimizer kwargs would be overwritten for sequence features. A placeholder `FTRL` class registered in torchrec's class-to-type table serves both paths instead. FTRL keeps `linear` and `accum` per element, so it also needs an optimizer-state multiplier of 2 when sizing dynamicemb values; torchrec's table maps unknown classes to 1. Tables that are not dynamicemb-backed cannot run FTRL at all, so the plan rejects them by name rather than letting a fused TBE fail on an unexpected kwarg; data_parallel tables are exempt because the dense kernel ignores the sparse optimizer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk8SFFzLveowmKz7fePXL1
39ed6dd to
f639827
Compare
| optional bool gradient_clipping = 6 [default = false]; | ||
| optional float max_gradient = 7 [default = 1.0]; |
There was a problem hiding this comment.
gradient_clipping / max_gradient look like dead knobs for this message. ftrl_optimizer can only drive dynamicemb tables (enforced at plan time in dynamicemb_util._to_sharding_plan), but dynamicemb's FTRL path never reads either key: FTRLDynamicEmbeddingOptimizer.get_opt_args/set_opt_args handle exactly {opt_type, lr, learning_rate_power, ftrl_beta, initial_accumulator_value, l1_reg, l2_reg}, and neither the update wrappers nor the CUDA kernels (ftrl_update_for_padded_buffer / ftrl_update_for_flat_table) take clipping args — the base class's clip_gradient/need_gradient_clipping helpers are never called by any optimizer.
Unlike the sibling Fused*Optimizer messages, where these fields reach FBGEMM's OptimizerArgs for plain tables, here BatchedDynamicEmbeddingTablesV2.__init__ silently absorbs them, so gradient_clipping: true is a silent no-op on every table this optimizer can ever drive.
Since the message is new in this PR, consider dropping both fields, or keeping them with a comment marking them unsupported and warning in create_sparse_optimizer when they're set to non-defaults.
| **Note**: `adagrad_optimizer`和`rowwise_adagrad_optimizer`的`initial_accumulator_value`对齐TensorFlow Adagrad的同名参数(TF默认0.1,TorchEasyRec默认0.0),对普通Embedding表和[dynamicemb](../feature/dynamicemb.md)表同时生效:普通Embedding表在建表时把整个accumulator初始化为该值,dynamicemb表则在key首次写入时把该key的accumulator初始化为该值 | ||
| **Note**: `adagrad_optimizer`和`rowwise_adagrad_optimizer`的`initial_accumulator_value`对齐TensorFlow Adagrad的同名参数(TF默认0.1,TorchEasyRec默认0.0),对普通Embedding表和[dynamicemb](../feature/dynamicemb.md)表同时生效:普通Embedding表在建表时把整个accumulator初始化为该值,dynamicemb表则在key首次写入时把该key的accumulator初始化为该值。`ftrl_optimizer`也用该字段初始化它的accumulator | ||
|
|
||
| **Note**: `ftrl_optimizer`(FTRL-Proximal,McMahan et al. 2013)**只支持[dynamicemb](../feature/dynamicemb.md)表**,FBGEMM没有FTRL的embedding kernel,模型中只要还有一张非dynamicemb的sparse表,训练会在plan阶段直接报错并给出表名。可配置`dynamicemb`的特征类型见[特征文档](../feature/feature.md),配置了`boundaries`的`raw_feature`等不支持dynamicemb的特征,无法与`ftrl_optimizer`一起使用。被分片为`data_parallel`的表不受此限制(由dense_optimizer更新) |
There was a problem hiding this comment.
The cross-reference here doesn't land: feature.md does not document which feature types support dynamicemb — its only mention of dynamicemb in the whole file is the negative note that CombineFeature does not support it (line 247). The authoritative list lives in feature.proto (IdFeature / ComboFeature / LookupFeature / MatchFeature / RegexReplaceFeature / CustomFeature / BoolMaskFeature carry the dynamicemb field; RawFeature / ExprFeature / OverlapFeature / TokenizeFeature / CombineFeature do not).
A reader deciding whether their features can use ftrl_optimizer will follow this link and find nothing — consider listing the supported feature types in this note (or adding a positive dynamicemb list to feature.md).
| in ``dynamicemb_util``'s plan-time fused params, so it must be set before | ||
| planning; FBGEMM TBE has no such kwarg, hence this module-level switch. | ||
| planning; FBGEMM TBE has no such kwarg, hence this module-level switch. Used | ||
| by Adagrad and, on dynamicemb tables, by FTRL for its squared-gradient |
There was a problem hiding this comment.
Nit: this setter docstring now says the switch is used "by Adagrad and, on dynamicemb tables, by FTRL", but the paired getter sparse_init_accumulator_value() (line 141) still reads "Sparse Adagrad accumulator initial value". Worth syncing the one-liner.
| max_capacity: 200000 | ||
| score_strategy: "TIMESTAMP" | ||
| init_capacity_per_rank: 16384 | ||
| } |
There was a problem hiding this comment.
The eviction coverage this config aims for (per the PR description) is coincidental rather than by design. For dynamicemb features the mock generator draws ids from [0, max_capacity) (IdMockInput(num_ids=feature.num_embeddings), and num_embeddings = max_capacity for dynamicemb — id_feature.py:76-77), and test_train_eval produces batch_size x num_parts x 4 = 131,072 rows over TEST_NPROC_PER_NODE=2 ranks:
id_1(TIMESTAMP, 200k keyspace): ~56k-96k distinct keys vs 100k capacity per rank — eviction can never fire.id_2(STEP, 65,536),id_3(LFU, 32,768),id_4_emb(NO_EVICTION, 20,480): the 50-id sequence column saturates the keyspace, so distinct keys ~=max_capacityand per-shard occupancy lands exactly at capacity — whether eviction (or NO_EVICTION insert rejection) actually happens comes down to hash-distribution luck and the RNG seed.
delta_embedding_dump_test.py already has the deterministic pattern: deliberately shrink max_capacity (e.g. 1024-4096) on one evicting table so capacity < distinct keys on every run. Consider sizing at least one table that way so the eviction paths are exercised reliably. (Frequency admission on id_2 is fine — ~50 occurrences per key clears threshold: 2 deterministically.)
| feature_configs { | ||
| id_feature { | ||
| feature_name: "id_1" | ||
| num_buckets: 1000000 |
There was a problem hiding this comment.
Minor: num_buckets is dead config on dynamicemb features — the table keyspace comes from max_capacity (id_feature.py:76-77) and mock ids are drawn from [0, max_capacity). The existing multi_tower_din_fg_dynamicemb_mock.config omits num_buckets on its dynamicemb feature; keeping num_buckets: 1000000 next to max_capacity: 200000 here is misleading (same for the other four dynamicemb features).
| ), | ||
| constant_learning_rate=optimizer_pb2.ConstantLR(), | ||
| ) | ||
| optim_cls, kwargs = optimizer_builder.create_sparse_optimizer(optimizer_config) |
There was a problem hiding this comment.
Minor test hygiene: this call runs register_ftrl_emb_opt_type(), which permanently inserts FTRL into torchrec's global _OPTIMIZER_CLASS_TO_EMB_OPT_TYPE. The class tearDown resets the accumulator switch but not this map, and tzrec/tests/run.py executes the whole suite in one process — so for every later in-process test FTRL appears "registered", which would mask a missing register_ftrl_emb_opt_type() call in a future test. Consider _OPTIMIZER_CLASS_TO_EMB_OPT_TYPE.pop(FTRL, None) in tearDown.
| if ( | ||
| getattr(sharding_option.tensor, "_optimizer_classes", [None])[0] | ||
| is FTRL | ||
| and sharding_option.compute_kernel | ||
| != EmbeddingComputeKernel.DENSE.value | ||
| ): |
There was a problem hiding this comment.
The exemption is keyed on the compute kernel, but its rationale (the comment here and the new optimizer.md note) is scoped to data_parallel — and DENSE != data_parallel. plan_util._filter_compute_kernels (lines 1208-1212) only drops DENSE when FUSED is also allowed, so non-DP DENSE-kernel options survive on CPU topologies and whenever the user pins compute_kernels: ["dense"] via embedding constraints. An FTRL-configured table planned that way passes with no error or warning, and since the DENSE kernel's params carry no in-backward optimizer, in_backward_optimizer_filter (main.py:931) hands them to the dense optimizer — the table silently trains with a different update rule and LR schedule, which is exactly the class of quiet failure this check was added to prevent, and it contradicts the new doc's promise that any non-dynamicemb sparse table errors at plan time.
Consider keying the exemption on sharding_option.sharding_type == ShardingType.DATA_PARALLEL.value instead (DP always implies the DENSE kernel, so the intended exemption is unchanged) — then CPU / constrained dense-kernel FTRL plans hit the intended, actionable ValueError.
Code review summaryStatic review across five areas (code quality, performance, test coverage, documentation accuracy, security), cross-checked against the installed torchrec 1.8.0 sources and upstream dynamicemb ( The core design checks out. Verified independently:
Findings posted inline (2 medium, rest low/minor):
Nothing blocking; items 1, 2 and 4 are the ones worth addressing before merge. Automated review (5 specialized reviewers + orchestrator verification). All five review areas completed. |
Keys the plan-time rejection on `sharding_type != DATA_PARALLEL` rather than on the DENSE compute kernel. The two are equivalent today -- torchrec's sharders offer DENSE only for data_parallel -- but the sharding type is what the rationale is actually about, so the check no longer leans on a third-party sharder invariant, and the error now names both. Also drops `num_buckets` from the ftrl mock config: a dynamicemb feature takes the `max_capacity` branch for `num_embeddings` and the `MAX_HASH_BUCKET_SIZE` branch for its FG config, so the field was never read. Points the optimizer doc's "which features support dynamicemb" link at the dynamicemb doc and adds the list there, since neither it nor the feature doc stated it. Syncs the accumulator getter's docstring with its setter, and unregisters FTRL from torchrec's optimizer-class map in tearDown so the single-process suite cannot leave it registered for later tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qk8SFFzLveowmKz7fePXL1
dynamicemb gained FTRL-Proximal in NVIDIA/recsys-examples#487. It lives in dynamicemb's own
DynamicEmbOptimTyperather than FBGEMM'sEmbOptimType, because FBGEMM has no FTRL embedding kernel. This exposes it astrain_config.sparse_optimizer.ftrl_optimizer. The wheel that carries it (0.1.0+20260920.9643985) is already pinned on master as of #680, so this PR no longer touches the requirements.FTRL is a common choice for the sparse part of CTR models: it re-solves each weight from
(linear, accum)every step instead of nudging it, which is what letsl1_regpin coordinates to exactly zero.Notes on the approach
Why a placeholder class instead of naming the type in the optimizer kwargs. torchrec derives a table's fused
optimizerparam from the in-backward optimizer class.embeddingbag.pyskips that when the caller already put anoptimizerkey in the kwargs, butembedding.pyoverwrites it unconditionally, so injecting the type would have worked for pooled features and raisedCannot cast ... to an EmbOptimTypefor sequence features. Registering a placeholderFTRLclass in torchrec's class-to-type table serves both paths from one mechanism. The class never steps -- the dynamicemb kernel replaces the in-backward optimizer withEmptyFusedOptimizer-- so it mirrors torchrec's own placeholders intorchrec/optim/optimizers.py.Optimizer-state multiplier. FTRL keeps
linearandaccumper element, i.e.2 * embedding_dimper row like Adam. torchrec's_get_optimizer_multiplermaps any class it does not know to1, which would have undersizedlocal_hbm_for_valuesand the planner's HBM/DDR estimate by one embedding width per row, sodynamicemb_utilwraps it.Rejecting non-dynamicemb tables.
ftrl_optimizercannot drive a fused TBE at all. Left alone, the failure is an FBGEMM assertion duringDistributedModelParallelconstruction that names neither dynamicemb nor the offending table. The plan now raises with the table name instead.data_paralleltables are exempt -- they are replicated and updated bydense_optimizer, so the sparse optimizer never reaches them.initial_accumulator_valueneeded no new plumbing --create_sparse_optimizeralready pops it into the module-level switch, andTZRecDynamicEmbParameterSharding.get_additional_fused_paramsalready re-injects it per table.Test Plan
All run in a conda env matching
requirements/runtime.txt(torch 2.13.0+cu130) with the new dynamicemb wheel installed.tzrec/optim/optimizer_builder_test.py::test_create_sparse_optimizer_ftrl-- the config yields theFTRLclass, torchrec resolves it toDynamicEmbOptimType.FTRL, every knob survives into the kwargs,initial_accumulator_valuelands in the module-level switch, and the class constructs with those kwargs the wayapply_optimizer_in_backwarddoes.tzrec/utils/dynamicemb_util_test.py::OptimizerMultiplerTest-- FTRL is 2, and SGD / Adam / RowWiseAdagrad / untrained still delegate to torchrec unchanged.tzrec/utils/plan_util_test.py::test_sharding_plan_sizes_ftrl_optimizer_state-- pins the four new kwargs onBatchedDynamicEmbeddingTablesV2.__init__, then checks the plannedlocal_hbm_for_valuesmatches the multiplier-2 sizing (it differs from the multiplier-1 value, so the assertion is load-bearing).tzrec/utils/plan_util_test.py::test_sharding_plan_rejects_ftrl_on_non_dynamicemb_table-- aFUSEDtable raises, aDENSE(data_parallel) one does not.tzrec/tests/rank_integration_test.py::test_multi_tower_din_with_dynamicemb_ftrl_train_eval-- new GPU-scoped train + eval on a DIN config whose id features are all dynamicemb-backed, covering both the EmbeddingBagCollection and EmbeddingCollection paths (the latter being where the kwargs approach would have broken). The tables deliberately differ inmax_capacity,score_strategy(TIMESTAMP/STEP/LFU/NO_EVICTION) and whether they setinit_capacity_per_rank, and one carries afrequency_admission_strategy, so FTRL runs against several distinct table setups, all four score-assignment strategies and a deterministically-firing admission path rather than one uniform configuration. (Capacity-driven eviction is not reachable from a static config in this harness: mock ids are drawn from[0, max_capacity), so the distinct-key count can never exceed capacity.)FTRLDynamicEmbeddingOptimizerinside the table with{'opt_type': 'ftrl', 'lr': 0.01, 'learning_rate_power': -0.5, 'ftrl_beta': 1.0, 'initial_accumulator_value': 0.1, 'l1_reg': 0.01, 'l2_reg': 0.02}and a per-row state dim of2 * embedding_dim.python tzrec/tests/run.py --scope gpu(261 tests), plus the dynamicemb-adjacent unit suites (checkpoint_util,delta_embedding_dump,export_util,modules.embedding,plan_util,dynamicemb_util,optim).dynamicemb_util_testand both dynamicemb integration tests pass.pre-commit run -aandpyrefly checkclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01Qk8SFFzLveowmKz7fePXL1