Skip to content

fix(peagle): refuse a reduced draft vocabulary without a t2d/d2t mapping - #66

Merged
tpx818 merged 1 commit into
verl-project:mainfrom
khazic:khazic/fix/peagle-vocab-mapping-guard
Sep 1, 2026
Merged

fix(peagle): refuse a reduced draft vocabulary without a t2d/d2t mapping#66
tpx818 merged 1 commit into
verl-project:mainfrom
khazic:khazic/fix/peagle-vocab-mapping-guard

Conversation

@khazic

@khazic khazic commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Eagle3TrainerBackend.build_model refuses a reduced draft vocabulary that has no real mapping behind it:

if drafter_module.draft_vocab_size != drafter_module.vocab_size:
    if checkpoint_has_vocab_mapping and self._has_valid_vocab_mapping(drafter_module):
        logger.debug("Using EAGLE3 vocab mapping loaded from draft checkpoint")
    else:
        raise ValueError(
            "EAGLE3 draft_vocab_size differs from target vocab_size, but the draft checkpoint "
            "does not provide valid t2d/d2t vocab mapping buffers"
        )
self._validate_vocab_mapping(drafter_module)

PEagleTrainerBackend overrides build_model entirely and has neither the guard nor the validation call.

Setting peagle_draft_vocab_size below the target vocabulary therefore falls through to the model constructor's default:

t2d = torch.zeros(self.vocab_size, dtype=torch.bool)
t2d[: self.draft_vocab_size] = True

That means "the draft vocabulary is target token ids 0..N-1". On any real tokenizer that is an arbitrary slice with no relation to token frequency, and the draft can never emit anything outside it. Nothing in training complains, because compute_loss restricts the target logits to the very same slice via selected_token_ids(), so the loss and the accuracy metric both look healthy while the draft is structurally incapable of proposing most of the vocabulary.

Fix

Give P-EAGLE the same guard, loading the checkpoint with output_loading_info=True so a supplied mapping is detected exactly the way EAGLE-3 detects it, and run _validate_vocab_mapping afterwards.

The full-vocabulary default (draft_vocab_size == vocab_size, which is what you get when peagle_draft_vocab_size is unset) is unaffected and still needs no mapping file.

_validate_vocab_mapping is inherited by every EAGLE-family backend, so its messages now name the algorithm that actually failed instead of always saying EAGLE3. The label comes from model_type, so it matches the speculative_algorithm value the user configured.

Validation

Ran a before/after repro on both main and this branch. It configures a reduced draft vocabulary (16 of the target's 32 tokens) with no t2d/d2t supplied, prints the fallback mapping the model constructs, and reports whether the backend refuses the configuration.

Before/after repro output
=================== before: main (333b754) ===================
target vocab_size       = 32
peagle_draft_vocab_size = 16
t2d/d2t supplied        = no

[fallback mapping the model constructs]
          draft can emit target ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
          d2t buffer               : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
          this is the first N target ids, not a frequency-derived subset

[does the backend refuse it?]
          EAGLE-3 : yes
          P-EAGLE : accepted the vocab config, then failed later for an unrelated reason: HFValidationError: Repo id must be in the form 'repo_name' or 'namespace/repo_name': '/tmp/none'

[verdict]
          RESULT: P-EAGLE trains on an arbitrary vocab slice (bug present)

=================== after: this branch ===================
target vocab_size       = 32
peagle_draft_vocab_size = 16
t2d/d2t supplied        = no

[fallback mapping the model constructs]
          draft can emit target ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
          d2t buffer               : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
          this is the first N target ids, not a frequency-derived subset

[does the backend refuse it?]
          EAGLE-3 : yes
          P-EAGLE : REFUSED: PEAGLE draft_vocab_size differs from target vocab_size, but the draft checkpoint does not provide valid t2d/d2t vocab mapping buffers

[verdict]
          RESULT: P-EAGLE refuses the unmapped reduced vocab (fixed)

On main the misconfiguration only surfaces as an unrelated failure much later (here, the placeholder target path used by the repro). With a real target path it does not surface at all.

Tests

Three new tests in tests/integration/test_peagle_backend_contract.py:

  • test_peagle_rejects_reduced_draft_vocab_without_mapping covers the guard and its message.
  • test_peagle_identity_vocab_mapping_passes_validation pins that the full-vocabulary default still needs no mapping.
  • test_vocab_mapping_validation_names_the_failing_algorithm covers the label on both EAGLE-3 and P-EAGLE.

Full CPU suite (tests/integration tests/compat tests/config tests/examples) run on both sides:

Test suite before/after
### BASELINE (origin/main, 333b754) ###
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_target_head_sync_defers_for_all_lm_head_drafters[DSPARK-veomni-npu-veomni_lm_head_full]
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_target_head_sync_defers_for_all_lm_head_drafters[DFLASH-veomni-npu-veomni_lm_head_sparse]
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_target_head_sync_defers_for_all_lm_head_drafters[EAGLE3-veomni-cuda-veomni_lm_head_full]
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_target_head_sync_defers_for_all_lm_head_drafters[EAGLE1-fsdp-npu-engine_full_param]
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_target_head_sync_defers_for_all_lm_head_drafters[DOMINO-fsdp2-cuda-engine_full_param]
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_target_head_transfer_waits_after_actor_update
FAILED tests/integration/test_drafter_runtime_control_contract.py::test_async_publish_sets_pending_ref_and_waits_before_next_publish
FAILED tests/integration/test_dspark_trainer_backend.py::test_dspark_checkpoint_preserves_source_config_and_vllm_weight_names
FAILED tests/integration/test_verl_npu_vllm_compat.py::test_factory_fused_moe_survives_verl_npu_patch_import
9 failed, 245 passed, 2 warnings in 33.60s

### THIS BRANCH ###
(same 9 failures)
9 failed, 248 passed, 2 warnings in 21.41s

The same 9 tests fail on main and on this branch. They need optional dependencies (VeOmni, the NPU vLLM stack) that are absent in this environment, so they are pre-existing and unrelated. The +3 on this branch are the new tests above.

The EAGLE-3 backend refuses draft_vocab_size != vocab_size unless the draft
checkpoint supplies valid t2d/d2t buffers, then validates them. P-EAGLE
overrides build_model entirely and does neither.

Setting peagle_draft_vocab_size below the target vocabulary therefore falls
through to the model constructor's default, t2d[:draft_vocab_size] = True, which
means "the draft vocabulary is target token ids 0..N-1". On any real tokenizer
that is an arbitrary slice with no relation to token frequency, and the draft can
never emit anything outside it. Training reports nothing unusual because the loss
restricts the target logits to the same slice.

Give P-EAGLE the same guard, loading the checkpoint with output_loading_info so a
supplied mapping is detected the way EAGLE-3 detects it.

The shared _validate_vocab_mapping is inherited by every EAGLE-family backend, so
its messages now name the algorithm that failed instead of always saying EAGLE3.

Signed-off-by: khazic <khazzz1c@gmail.com>
@khazic

khazic commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

The NPU vLLM eagle3 example failure here is runner port contention, not this change. The job died during engine init with:

torch.distributed.DistNetworkError: The server socket has failed to listen on any local network address. port: 36693, useIpv6: false, code: -98

code: -98 is EADDRINUSE. The same failure hit four PRs in this batch, on four different ports, and the job that failed does not line up with what each PR touches:

PR files changed failing job port
#66 peagle_trainer_backend.py, one error-message string in eagle3_trainer_backend.py eagle3 36693
#67 llama_eagle.py, modeling_peagle.py eagle3 35191
#68 eagle3_trainer_backend.py dspark 38527
#70 peagle_trainer_backend.py dflash 39403

#68 and #70 are the clearest: neither touches the dspark or the dflash code path, yet those are the jobs that failed.

Within a single run the three example jobs are serialized (on #63 they ran 11:25:05 to 11:42:21, 11:42:46 to 12:01:01, 12:01:27 to 12:19:55), so the contention comes from runs of different PRs overlapping. The failures cluster in the window where three PRs had example jobs in flight at once, and every job that started after that queue drained passed. The failed jobs also died in about 12 minutes against roughly 17 for a successful one, consistent with dying at engine init rather than during real work.

CPU unit tests and pre-commit pass on this PR.

Could a maintainer re-run the failed job? I do not have the permission to (gh run rerun returns Must have admin rights to Repository).

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