[feat] prompt-native generative recommendation - #625
Conversation
- tzrec/models/generative_rec_lm.py, qwen2_rec_lm.py - tzrec/protos/models/generative_model.proto + model.proto oneof entry - tzrec/optim: lr_scheduler additions; optimizer.proto grad-accum/grad-clip - tzrec/tools/export_genreclm_to_hf.py (DCP -> HF export) Example scripts and design notes intentionally excluded (to be refactored). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GenerativeRecLM (base): architecture-agnostic plumbing — vocab extension, _tokenize_sids (SID->token-id offset map), _sid_token_rows (jagged read + tokenize-once + split, with data-boundary answer-width validation), device property, loss/metrics; _build_prompt_tokens / predict are abstract hooks. - Qwen2RecLM (subclass): ChatML template, causal-LM splice, decoder-only forward. _splice_input_ids builds input_ids/mask via pad_sequence and labels in one vectorized write (fixed answer width = len(codebook) levels). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- generative_rec_lm_test: registry dispatch, abstract-hook errors, device property, _tokenize_sids offset map, _sid_token_rows split/cast/(N,1)-squeeze and answer-width validation (ok + violation). - qwen2_rec_lm_test: splice layout + label masking, left-padding/varied lengths, mask keeps trailing eos when pad==eos, _min_first_non_neg_index, _build_prompt_tokens buffer registration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rence beam search
predict() dispatches on the TER inference flag (BaseModule.is_inference, set by
main.py's set_is_inference before the predict/export wrappers):
- Branch 1 (not is_inference -> train/eval): existing teacher-forced forward +
suffix-slice + CE loss (moved to _predict_train).
- Branch 2 (is_inference -> inference): _generate beam-searches the SID answer
from an answer-less prompt (_splice_prompt_ids), emitting num_levels tokens/beam
and mapping them back to raw SID indices -> {"generated_sids": (B, num_return, L)}.
Beam params (_num_beams/_num_return) default to algr's 50/50 (optional proto
fields). Tests cover prompt splice, is_inference routing, and token->SID map.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…name dispatch Each LLM family is its own model_config oneof entry whose message-type name resolves straight to the same-named class (qwen2_rec_lm -> Qwen2RecLM), so GenerativeRecLM.__new__ and the class_name field are gone. Proto split: - GenerativeRecLMConfig = architecture-AGNOSTIC config (codebook, vocab pad, feature names, ignore_index, beam params), embedded as `common`. - The backbone `hf_model_id` is OWNED by the family message (Qwen2RecLM, default "Qwen/Qwen2.5-0.5B"), NOT `common` — the registered family IS the architecture commitment, so a backbone in the shared block could contradict it. (common field 1 reserved.) - Family-specific chat-template knobs also live on the family message. Base __init__ reads cfg.common.* (shared) and cfg.hf_model_id (family-owned). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k VRAM imbalance One GPU reserved ~25GB more than the rest during 8-GPU training: the CUDA caching allocator stranded a whole segment generation on whichever rank drew its shortest batch first (variable seq-len + native allocator never shrinks reserved). Pre-size the pool up front so it never has to grow mid-run. - Qwen2RecLM warms the activation pool with a one-shot dummy fwd+bwd at the worst-case (batch_size, T_max) on the first training step (earliest the HF backbone is on-GPU); T_max = template frame + sequence_length + num_levels. - Pool length reuses the user_sequence feature's sequence_length (GenerativeRecLM._input_sequence_length); _sid_token_rows enforces it with a recency-preserving, item-aligned tail clip (keep newest items, drop oldest) so it is a guaranteed bound under FG_NONE, which does not truncate. - Thread data_config.batch_size into _create_model; move num_beams/num_return from base to subclass. Verified on 8xGPU: per-card spread 25GB -> 2.2GB, no OOM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…=300 Under FG_NONE the reader does not truncate, so the model enforces this length via the recency-preserving clip in _sid_token_rows and uses it to pre-size the activation pool. 300 = AL-GR-Tiny's realistic max history (100 items x 3 codes); the prior loose 1056 (algr max_length) would oversize the warm-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fix export tool schema
Intermediate training checkpoints were only written in TER's DCP format, which
isn't directly from_pretrained-loadable and reduced confidence when validating
results. Now save an HF copy at each periodic checkpoint.
- GenerativeRecLM.export_hf(dir): save self.lm + the rebuilt extended tokenizer
(base + C0..C{sum-1}) straight from the live model.
- main._train_and_evaluate: after each periodic ckpt_manager.save, rank-0 calls
_model.export_hf(model_dir/hf_ckpt-{step}) when available (duck-typed; other
models unaffected).
- export_genreclm_to_hf: fix three new-schema bugs (class_name -> which_msg
resolution, abstract GenerativeRecLM -> resolved family class + register
Qwen2RecLM, grl_cfg.codebook -> grl_cfg.common.codebook). The codebook bug
had exported weights without the tokenizer, breaking predict.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…line tool The standalone export tool duplicated the backbone+tokenizer save that GenerativeRecLM.export_hf now owns. Make the tool do only the DCP overlay then call model.export_hf, so offline and in-training HF exports are one code path (byte-identical) and there is a single source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… standalone tool The standalone tzrec/tools/export_genreclm_to_hf.py re-implemented the model build + checkpoint restore that TER's export() already does. Delete it and add a GenerativeRecLM branch directly in main.export: after the existing checkpoint resolution, overlay the DCP shards and call model.export_hf (the same single save path used by the in-training checkpoint hook). No separate tool, no duplicated save; `python -m tzrec.export` now produces the HF dir for GenerativeRecLM models. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-step pad pre-sizing Import: __init__ builds the empty extended arch (AutoConfig + from_config, no weight download); new init_from_pretrained() is the sole from_pretrained, invoked by the pipeline on cold start via a no-op BaseModel hook (no hasattr duck-typing). Restore/eval/export load weights from DCP, skipping the ~1GB download. Export: training writes DCP only; each model.ckpt-N/ co-locates HF config+tokenizer (no weights), gated by export_config.export_format == HF and owned by CheckpointManager. tzrec.export converts via a standalone dcp_to_hf (recorded backbone-prefix recorded as data + suffix-match self-heal + strict 1:1 validation, never a silent partial load). Adds ExportFormat to export.proto; TORCHSCRIPT path untouched. Training: replace _warmup_alloc -- a separate unscaled forward+backward that corrupted the first optimizer step (the lr5e-5 HR 2.6-2.9->1.14-flat regression) -- with first-step left-padding to the worst-case length in _predict_train / _splice_input_ids / _left_pad. The pad rides the real step (positions masked + labelled -100, so loss/grad are identical) while pre-sizing the activation pool to (B, T_max), keeping per-rank reservations uniform. Tests: rewrite the warmup unit tests to assert the first-step padding contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… comments __init__ was ~105 lines of mixed concerns + heavy narration. Extract three behavior-preserving private helpers and condense comments to the load-bearing rationale only: - _read_common_config(common) -> sid_atoms: proto knobs + codebook guard. - _build_backbone() -> module: empty bf16 arch (no weight download) + empty-id guard. - _build_extended_tokenizer(sid_atoms) -> (tokenizer, base): add C0.. atoms, resize self.lm, the added==sid_atoms and C0-at-base guards. __init__ now reads as super().__init__ -> read config -> build backbone -> build tokenizer -> pad/prompt/debug tail. All instance attributes, guard messages, and effect order are unchanged; no behavior change. 22 model unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in upstream's SidRqkmeans (FAISS residual K-Means SID) feature + the maybe_save / event-time checkpoint refactor + on_train_end hook (3 commits, ad083a7..3d4d5a8). Conflicts resolved: - tzrec/protos/model.proto: both sides added a oneof field at 601. Kept upstream's `sid_rqkmeans = 601`; moved our `qwen2_rec_lm` to 700 (a clear 100-block for the generative-LM family) and dropped our `reserved 600` in favor of upstream's planned SidRqvae=600. (TER configs are text-format / use field names, so the renumber is wire-irrelevant.) gen_proto verified: 27 oneof fields, all numbers unique. - tzrec/main.py: adopted upstream's `ckpt_manager.maybe_save(...)` + `run_eval` dispatch, dropping our explicit step/epoch/final save blocks. Our HF-asset co-location rides along unchanged: maybe_save -> CheckpointManager.save -> the export_format==HF gate. Auto-merged cleanly: models/model.py (our init_from_pretrained hook + upstream's on_train_end coexist), utils/checkpoint_util.py (our HF save-gate + upstream's maybe_save coexist). Verified: hot files compile, protos regenerate, genrec unit tests pass (22). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1e-5 collapse Root cause (overnight investigation): the LM was built in bf16 params with mixed_precision unset, so the optimizer updated bf16 weights directly with no fp32 master. Adam's small updates at lr=1e-5 (~1e-5) fall below the bf16 ULP of the weights and round to zero -> weights freeze -> training collapses (eval ce hard-plateaus ~5.76, HR ~0). lr=5e-5 masked it (5x larger updates clear the ULP). ALGR's HF-Trainer bf16:true keeps an fp32 master, so it trains fine at lr1e-5. Fix: build the LM in fp32 in BOTH paths (`_build_backbone` from_config and `init_from_pretrained` from_pretrained) so the optimizer keeps fp32 master weights. Set `mixed_precision:"BF16"` in the run config for bf16 *compute* speed (autocast) on the fp32 master — the standard AMP pattern, mirroring ALGR. Proven by a single-variable A/B (only precision changed): bf16-params: lr1e-5 = 0.02 flat (collapse) | lr5e-5 = 2.71 rising fp32-master: lr1e-5 = 1.17 rising (1ep) | lr5e-5 = 2.68 rising The fix recovers lr1e-5 ~60x without regressing lr5e-5 (2.68 ~= 2.71). Tradeoff: fp32 master -> fp32 DCP checkpoint (~2x) + ~64GB/GPU (vs 54). Old bf16 checkpoints still restore (upcast to fp32). Details in ai_report/MASTER_EXPERIMENT_REPORT.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Layer A — _generate now validates each beam against the per-level SID bands; malformed candidates (early EOS / non-SID / wrong-level atom) collapse to a -1 sentinel that can never match a real item, and the fixed-width canvas removes the reshape crash when beams stop early. The gate + token<->SID inversion live in the base GenerativeRecLM (_validate_sid_candidates, _sid_level_bands) as one source of truth, reused by every family. Code-review findings: - alibaba#2 (perf): cache self._suffix_keep; _forward_loss slices a constant suffix instead of recomputing it per step -> drops 2 GPU->CPU syncs/step. - alibaba#5: isolate the rank-0 write_hf_assets call (try/except + log) so an asset write error can't abort before the next collective and hang other ranks. - alibaba#7: make the dense-only contract explicit (embedding_group = None); remove the never-called init_input (calling it would wrongly build an unused sharded table for the SEQUENCE feature, which flows as raw token ids). - alibaba#8: drop the dead batch_size plumbing from _create_model + call sites. - alibaba#9: _resolve_pad_token_id asserts pad/eos present (clear error, not int(None)). - dtype: single _PARAM_DTYPE source of truth (both builders fp32-master). Also: streamline comments, pin transformers==4.51.2 (<5.0). Tests: 25 genrec unit tests (Layer-A valid/malformed/narrow-tail, _suffix_keep equivalence, pad resolution). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the explicit canvas alloc + min/slice-copy + boolean-index assignment with F.pad (fixed-width -1 padding) and masked_fill (row invalidation): 6 logic lines -> 4, no in-place indexing, clearer intent. Behavior is unchanged — verified value-identical to the previous form on 4 edge cases (valid, narrow tails w=1/2, all-invalid) + 2000 random fuzz batches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Condense the multi-line inline rationale comments (CE-suffix width, one-shot pool pre-sizing, fp32-master build, SID-band gate, beam-order reshape) and the two longest docstrings (init_from_pretrained, _validate_sid_candidates) to terse 1-3 liners, keeping the load-bearing why (fp32-master underflow, -1 can't match a real item, suffix-slice OOM, pad==eos mask). Comments only — no behavior change; ruff clean, 25 genrec tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SID->token offset now has both directions as named helpers next to each other: _tokenize_sids (sid -> token) and its inverse _detokenize_sids (token -> sid), used by _validate_sid_candidates instead of the inline `new_tokens - (base_vocab - 1)`. One owner for the offset constant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The data reader/DataParser does NOT truncate sequences under FG_NONE (only pyfg does, in FG_NORMAL/FG_DAG; the native EmbeddingGroup does via to_padded_dense at forward time). GenerativeRecLM uses FG_NONE and no EmbeddingGroup, so the history cap is enforced model-side in _sid_token_rows (item-aligned to whole num_levels items). Correct _read_common_config + _input_sequence_length to say so instead of "the reader caps every row". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port ALGR's dynamic_beams schedule (beam width doubles per SID level 50->100->200->400, returns num_beams*2**num_levels candidates) as a torch-only KV-cached kernel; faithful to ALGR's escalating beam and exploits the fixed-length, EOS-free SID answer. - tzrec/models/escalating_beam.py: escalating_beam_search kernel - qwen2_rec_lm.py: _dynamic_beam_search delegates; _generate dispatch on the dynamic_beam flag - generative_model.proto: dynamic_beam flag - examples/generative_rec_lm_predict.py: --dynamic_beam / --codebook - tests: exhaustive==brute-force top-k + validity/left-pad (19 pass) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ding Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t (JAGGED_SEQUENCE)
Route the genrec-LM's SID retrieval through the framework's standard
init_input/build_input + EmbeddingGroup path (the HSTU idiom) instead of
reaching into batch.sequence_dense_features directly.
- proto: add GenerativeRecLMConfig.history_group_name / label_group_name
(group-name knobs, defaults "user_seq"/"answer"), keyed by GROUP name like
HSTU, decoupled from feature names.
- generative_rec_lm: init_input builds the param-free raw-passthrough
EmbeddingGroup; build_input reads "{group}.sequence"/".sequence_length" and
tokenizes; _sid_token_rows now takes (values, lengths); group knobs read in
_read_common_config.
- qwen2_rec_lm: _predict_train / _generate consume build_input.
- example config: one SEQUENCE group -> two single-feature JAGGED_SEQUENCE groups.
- tests updated (+ build_input coverage). Integration-verified that a top-level
sequence_raw_feature passes raw through a JAGGED_SEQUENCE EmbeddingGroup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-configurable
Promote the two GenerativeRecLM class constants to GenerativeRecLMConfig knobs,
keeping their previous values as defaults:
- generated_sids_key (default "generated_sids") -> self._generated_sids_key,
used by _generate's output dict.
- param_dtype (default "float32") -> self._param_dtype via _DTYPE_BY_NAME
{float32, bfloat16, float16}; used by _build_backbone / init_from_pretrained.
An unknown value raises a clear ValueError.
Both read in _read_common_config; tests cover defaults + dtype mapping + validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onstant
The value now lives in the GenerativeRecLMConfig.generated_sids_key proto default
("generated_sids"); _generate already emits self._generated_sids_key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ne .get() /simplify cleanup: membership-check + dict-index -> single .get() + None-guard (same ValueError). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(HSTU-style) Promote the model's history budget to GenerativeRecLMConfig.max_sequence_length (field 14), mirroring HSTU's DlrmHSTU.max_seq_len — a model knob distinct from the user_sequence feature's sequence_length. _max_seq_length now reads common.max_sequence_length, falling back to the feature's sequence_length when 0 (backward-compatible). It drives the recency-preserving truncation cap (_sid_token_rows) + the activation-pool pre-size. Example config sets it in common; tests cover the model-knob and the fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ce_length is the sole source common.max_sequence_length is now the single source for the model's history budget; remove the feature-derived fallback (_input_sequence_length) and its test. 0 = off (no cap / no activation-pool pre-allocation). Proto + example-config comments updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s2 example config max_sequence_length is now a REQUIRED GenerativeRecLMConfig field (like HSTU's DlrmHSTU.max_seq_len) — every genrec config must set it (0 = explicitly off). Migrate examples/generative_rec_lm_s2pretrained.config to the new format to keep it valid + consistent: SEQUENCE group -> two JAGGED_SEQUENCE groups (user_seq / answer) + max_sequence_length: 1056. Both example configs verified to parse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t a feature Move the answer/target SID stream out of feature_configs into data_config.label_fields (a list<int64> column -> batch.jagged_labels[label]). build_input now reads HISTORY from the user_seq JAGGED_SEQUENCE group (EmbeddingGroup) and the ANSWER from batch.jagged_labels[self._label_name]. Drop the now-unused label_group_name proto knob (reserved 11). This is semantically correct (the answer is the target, not an input feature) and lets the label be absent at inference without the EmbeddingGroup requiring it. Example configs (s1/s2) migrated; tests updated. (Also wrapped the hf_backbone/hf_tokenizer export-only docstrings.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Projected holes were emitted sample-major while projection outputs are occurrence-major, and prediction still parsed the supervised response. Align assembly with embedding order, keep response out of inference batches, and validate the effective decode contracts.
_build_backbone casts the LM to lm_parameter_dtype, but _build_projections builds nn.Linear/MLP at the default fp32 and never casts them. index_copy requires self and source to share a dtype, so build_input raised on the first forward for any BF16 or FP16 config with a projected slot -- both dtypes the proto advertises. The spliced values now take embeds.dtype. Casting the projections themselves would put their weights in bf16, which is the Adam underflow the fp32 master-weight default exists to avoid; reading embeds.dtype also adapts under autocast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… token_format
Three prompt_configs compiled cleanly and then misbehaved.
No response left response_segments empty, so logits_suffix_len collapsed
to 0 + 1 and the loss ran over a window holding one ignored position:
nan, with no diagnostic. No sid_space produced a CompiledPrompt no model
can consume, since every prompt-native model needs one to extend its
embedding table and to decode. Both are now required at compile, each
reporting its own field rather than a downstream symptom.
A token_format with no {i} rendered every SID token as the same string,
so add_special_tokens deduplicated them and the tokenizer gained one row
where the bands assume sum(codebook) -- every SID above the first then
addressed a token id that was never assigned.
Requiring sid_space made two checks unreachable: the unbounded-response
guard, because an INLINE response slot now always derives STATIC from the
codebook, and the response-width check, which compared num_levels against
a value just set from num_levels. Both are removed, along with the cfg
and sid_space parameters they left unused on _validate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plan_hash passed sorted(projection_plan.projections) to the digest, and iterating a dict yields its keys, so only the projection module ids were covered. Changing an MLP body while keeping its projection_name produced an identical hash: a slot whose projection went from hidden_units [16] to [256, 128] restored against an old checkpoint with no warning, though the parameter shapes differ. sorted(...) is kept for a stable order across runs. The projection body is the one part of the plan that determines tensor shapes, so it is exactly what the restore guard exists to catch, and the docstring already claimed plan_hash covered it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| """Strip the recorded prefix; None unless it yields an EXACT match.""" | ||
| if not prefix: | ||
| return None | ||
| out = {k[len(prefix) :]: v for k, v in state.items() if k.startswith(prefix)} |
There was a problem hiding this comment.
[P1] Preserve or reject projected-slot state during HF export. This prefix filter intentionally keeps only backbone keys, so learned embedding_group and projections parameters are silently dropped while projected prompts are still exportable. The result loads as an HF LM but cannot reproduce checkpoint inference; either serialize/load the non-backbone state or fail export when projected slots exist.
| ) | ||
| break | ||
| variable_slot_seen = True | ||
| if not plan.response_segments: |
There was a problem hiding this comment.
[P1] Make the accepted response plan match fixed banded decode. _generate() immediately forces one SID token per level, but this validator accepts static-only responses, prefixes such as Answer: {{answer}}, and multiple slots. Training then supervises a first token/sequence inference can never emit; restrict the response to the supported INLINE SID shape or make decode walk the compiled response plan.
| projected_occurrence_index, | ||
| len(jagged_token_ids), | ||
| ) | ||
| response_lengths.append(len(sample_token_ids) - prompt_len) |
There was a problem hiding this comment.
[P1] Enforce the compiled response width per row. Response slots are planned as exactly num_levels, but _inline_tokens() accepts zero or any whole multiple; an empty answer can yield an all-ignored/NaN loss, while two items exceed logits_suffix_len and silently train only the tail. Validate the actual response length against the plan here.
| projection_plan=projection_plan, | ||
| tokenizer_dir=tokenizer_dir, | ||
| vocab_hash=_hash(sid_space, tok.to_str()), | ||
| plan_hash=_hash( |
There was a problem hiding this comment.
[P1] Include projection routing in the persisted hash. slot_to_module is omitted, so swapping two slots' projection_name values when their bodies/shapes match leaves this hash unchanged; checkpoint keys still load, but each slot receives the other's learned weights. Hash the stable mapping and treat routing mismatches as incompatible.
| out = self.assemble( | ||
| inline_values, | ||
| projected_lengths, | ||
| batch_size=batch_size if batch_size is not None else 0, |
There was a problem hiding this comment.
[P1] Preserve batch size for static-only prediction prompts. When the body has no slot and the answer exists only in the omitted response, there is no included feature to infer from, so a nonempty input batch is assembled as zero rows (cu_seqlens == [0]). Pass the raw record-batch row count explicitly instead of defaulting to zero.
| cfg.hf_model_name_or_path, cfg.common.lm_parameter_dtype | ||
| ) | ||
| # Every run replaces this initialization from pretrained or DCP weights. | ||
| self.lm.resize_token_embeddings( |
There was a problem hiding this comment.
[P1] Validate the base tokenizer against the backbone before resizing. base_vocab_size defines every SID/sentinel id, but nothing checks it equals the pretrained LM vocabulary size; with a mismatched tokenizer those ids can overlap existing text rows and silently use the wrong embeddings/bands. Fail before resize (and verify tokenizer identity where available).
| materializing a second full-vocabulary log-probability tensor. | ||
| """ | ||
| band_lo, band_hi = bands[level] | ||
| log_z = torch.logsumexp(logits.float(), dim=-1, keepdim=True) |
There was a problem hiding this comment.
[P1] Avoid materializing full-vocabulary FP32 logits for every live beam. At later levels there are batch * beam_width rows; for a 152K-token Qwen, batch 32/width 100 is about 486M logits, plus this FP32 copy, before slicing a small SID band. Preserve global-vocabulary scoring with chunked/fused lm_head + log-sum-exp, or enforce a memory-safe schedule.
| seq = (band_idx + bands[0][0]).reshape(-1, 1) | ||
| beam_scores = beam_scores.reshape(-1) | ||
| row_starts = torch.arange(batch_size, device=device) | ||
| cache.reorder_cache(row_starts.repeat_interleave(capped_widths[0])) |
There was a problem hiding this comment.
[P1] Do not physically duplicate the whole prompt KV cache for every first-level beam. With the documented 100+ widths, cache memory and copy bandwidth scale as batch * width * prompt_length * layers, which can OOM before subsequent levels run. Use prefix-sharing/paged cache, or a chunked and explicitly memory-bounded beam strategy.
| """ | ||
| infos = batch.additional_infos | ||
| cu_seqlens = infos[PROMPT_CU_SEQLENS] | ||
| max_seqlen = int(infos[PROMPT_MAX_SEQLEN]) |
There was a problem hiding this comment.
[P2] Keep max_seqlen on the host. Batch.to() moves every additional_infos tensor to CUDA, so converting this scalar to int performs a device-to-host read and synchronizes every forward, defeating sparse-pipeline copy/compute overlap. Carry it as host/Python metadata that Batch.to() does not migrate.
| The loss when training, the decoded SIDs otherwise. | ||
| """ | ||
| if self.is_inference: | ||
| return {self._generated_sids_key: _fx_wrapped_generate(self, batch)} |
There was a problem hiding this comment.
[P2] Keep projected embedding lookup visible to the inference tracer. Wrapping _generate(self, batch) also hides its build_input() call, so TorchRec sees no sharded-module node and projected-slot distribution/lookup runs synchronously inside the leaf. Mirror the loss path: build embeddings before the wrapper and wrap only padding/decode.
| """ | ||
| if compiled_prompt is None: | ||
| return | ||
| recorded = read_prompt_hashes(ckpt_dir) |
There was a problem hiding this comment.
[P1] Do not fail open when a prompt checkpoint lacks hashes. CheckpointManager.save() swallows rank-0 asset-write failures, and this branch then restores with only a warning, disabling the vocabulary guard and allowing plausible but incorrect SID bands. Propagate asset-write status after required collectives and make missing prompt assets fatal, with any legacy bypass explicit.
| f"number of {levels}-level items." | ||
| ) | ||
| by_level = values.reshape(-1, levels) | ||
| if np.any(by_level < self._flat_lo) or np.any(by_level >= self._flat_hi): |
There was a problem hiding this comment.
[P2] Validate finite integer-valued SID inputs before band checks. INLINE raw features are commonly floating-point; fractional values pass these comparisons and are truncated by astype(int64), while NaN makes both comparisons false and converts to an invalid id. Reject non-finite or non-integral values before conversion.
|
Static review complete. The compiler/assembler/model separation is clear, and I left inline comments only for issues that affect correctness, export fidelity, or realistic decode performance. One general gap: the PR body references docs/source/models/prompt_generative_qwen.md, but that guide is absent and the new public model/HF export mode is not indexed or documented. Before merge, please add the Chinese operator guide and HF export documentation covering the required response/sid_space, offset-SID input contract, tokenizer/backbone pairing, projected-slot export limitations, and beam-memory constraints. Per review request, I reviewed statically and did not run tests or builds. |
plan_hash passed sorted(projection_plan.projections) to the digest, which iterates the dict and so covered only the module ids. Adding items() in the previous commit covered the bodies but left slot_to_module out, so two slots could swap projection_name with matching bodies and still hash the same: keys load, and each slot receives the other's learned weights. Both mappings are now hashed. check_prompt_assets treated a checkpoint with no recorded hashes as a warning and restored anyway. CheckpointManager.save swallows rank-0 asset-write failures, so that combination silently disables the vocabulary guard in exactly the case it exists for -- decode bands addressing rows the weights never learned. Absent assets are now fatal. No legacy bypass: this stack has never been released, so no checkpoint predates the assets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
predict built the input embeddings outside the leaf on the training path so TrainPipelineSparseDist could see the sharded embedding module and prefetch it, but the inference path passed the whole batch into _fx_wrapped_generate, which hid its build_input call. PredictPipelineSparseDist therefore saw no sharded-module node and the projected-slot lookup ran synchronously inside the leaf. Both paths now build embeddings before the wrapper and pass them in, so only the padding and the LM -- the parts that branch on host ints -- stay hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — this was a useful pass. Triage of all 12 comments below: 3 fixed, 6 deferred, 3 not taken. Where the behaviour was checkable I verified it against the code rather than judging by reading; the evidence is in each item below. Fixed
Deferred, with reasons
Not takenThese three each guard a state the pipeline does not produce:
The first two rest on one upstream guarantee — the |
AGENTS.md asks for one short line of non-obvious "why"; several of the comments added over the review round ran to two or three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the SID-feature-based generative stack with a prompt-native one: a new
prompt_configowns the template, slots, SID space and tokenizer, andmodel_configkeeps only what belongs to the LM.Why
The per-level SID offset used to be applied twice — once in the SID tool's
sid_offset_codes, once again inSidFeature._parse— with no hash covering thepair. The collision tool now emits an
offset_codebookcolumn, so the offsetarrives already applied and that second implementation can go.
What remains inside tzrec is a uniform
+ base_vocab, which is notposition-dependent and therefore needs no bespoke feature type.
SidFeatureisdeleted; an INLINE slot is an ordinary
sequence_raw_featureand a PROJECTED oneis a
sequence_id_featurewith an injectednum_buckets.The offset form is also self-validating: a raw code
100is legal under codebook4096 and 8192 alike, so a tool/config mismatch passes a range check, whereas
offset codes carry
cumsum(codebook[:l])inside the value and throw every levelWhat is here
tzrec/protos/prompt.protoPromptConfig,PromptSlot,PromptProjection,SidSpacetzrec/prompt/compile.pyResolvedSidSpace, aPromptPlan, aProjectionPlanand an extended tokenizertzrec/prompt/assembler.pytzrec/prompt/persist.pytzrec/models/prompt_generative_qwen.pyinputs_embedsforward,index_copyscatter, band-constrained decodetzrec/modules/prompt_projection.pydocs/source/models/prompt_generative_qwen.mdRemoved:
sid_feature.py,generative_model.py,generative_qwen.py,generative_model.protoand their tests. Nothing is kept for compatibility.Two properties worth reviewing specifically:
in_dim/out_dim;the model resolves both at
__init__fromgroup_total_dimand the backboneconfig, so one compiled plan is valid across model sizes.
max_seqlenon the host, so the model never callslengths.max(). It iscarried in
additional_infos, whichBatch.to()migrates, so reading it backdoes cost one host sync per step — a known performance item, not a correctness
one, tracked in the review triage.
Test Plan
Unit: 55 tests across compile, assemble, projection, persistence and the model.
pre-commit run -aandpython scripts/pyre_check.pyboth clean.End to end, on a two-layer Qwen saved locally (no download), both an INLINE-only
prompt and one with an added PROJECTED slot:
tzrec.train_evalce_loss 4.79againstln(128) = 4.85— near-random from a fresh init, as expectedce_losscomputed and written totrain_eval_result_v2.txtmodel.ckpt-N/prompt/carriessid_space.json,prompt_plan.json,prompt_hashes.json,tokenizer/--continue_train8,8,8is refused at restoretzrec.predictgenerated_sidsof shape(num_return, num_levels); every code inside[0, codebook), so the bands held and detokenize inverted both shiftstzrec.export(HF)AutoModelForCausalLM.from_pretrained; export dir carries the prompt contractEight defects were found by running the pipeline rather than by unit tests —
four missed
_create_modelcall sites, a(total, value_dim)shape theassembler mis-read, the absent loss/metric hooks, an FX
Proxyin decode, andthe un-derived feature groups a projected slot needs. All are fixed and covered.
Review round
codex-reviewraised 12 comments; all are triaged. Fixed:plan_hashnowcovers projection bodies and routing (
slot_to_module), a checkpoint with norecorded prompt assets is fatal rather than a warning, and
predictbuildsembeddings outside both FX leaves so the inference tracer sees the sharded
lookup. Earlier in the round:
sid_spaceandresponseare required at compile,a
token_formatwith no{i}is rejected, spliced projections take the LMdtype, and two unreachable validation branches were removed.
Deferred with reasons, not dismissed: the incomplete HF export of a
projected-slot model, SID placement relative to the backbone's vocabulary (a
migration — it changes
vocab_hash), the per-step host sync above, and twobeam-search memory items. Three were declined as guards for states the pipeline
cannot produce. The full reasoning is in the PR comment.
Not done
--nproc-per-node=1, sosharding, DMP and the planner have not been exercised. The projected config is
the one with a sparse table to shard.
cache_ids(§11 of the design). Withoutcache_ids, a stock radix prefix cache keyed on token ids will reuse KV acrossusers at a sentinel position — a correctness bug, so it must land before any
projected slot is served.
export_format: HF;the model's input is a stream the dataloader assembles, which an export-time
dummy batch cannot supply.
Open design question
design_v2.md§8.1 has an "INLINE text" branch, butTokenizeFeatureextendsIdFeatureand is therefore always sparse, hence always PROJECTED. No existingtype can be an INLINE text slot. I implemented INLINE as unconditionally meaning
SID. Either the branch goes, or
TokenizeFeaturegains a no-embedding mode.