Repository navigation
feat: support Ling-3.0 (Bailing V3) draft training - #870
Conversation
Ling-3.0 keeps Ling-2.0's role headers but always emits a think block, so reusing the ling-flash-2.0 template supervises template boilerplate instead of the answer. Register ling-3.0 / ling-3.0-thinking with the think tags folded into the assistant header, add a LingParser that pins the Bailing V3 thinking option so training and serving share one system prefix, and add a DSpark draft config and offline recipe for Ling-3.0-tiny.
There was a problem hiding this comment.
🟢 Approval recommended
The changes are well-scoped with strong regression coverage and only minor maintainability nits identified.
Pull request overview
Adds first-class SpecForge support for Ling-3.0 (Bailing V3) draft training by introducing dedicated chat templates (thinking + non-thinking), a parser path to explicitly control the thinking option, and a ready-to-run DSpark offline recipe with regression tests and tokenizer-level reference snapshots.
Changes:
- Register new
ling-3.0andling-3.0-thinkingtemplates so loss masks exclude template boilerplate and correctly supervise answers/reasoning. - Add
LingParserand preprocessing dispatch to explicitly passenable_thinking=Falsefor Bailing V3’s default-on behavior. - Add DSpark draft config + offline recipe for
inclusionAI/Ling-3.0-tiny, plus new tests and reference snapshots.
File summaries
| File | Description |
|---|---|
specforge/data/template.py |
Registers the two Ling-3.0 templates with headers that account for the mandatory <think> block. |
specforge/data/parse.py |
Adds LingParser to force enable_thinking=False when rendering Ling-3.0 non-thinking prompts. |
specforge/data/preprocessing.py |
Routes parser_type: "ling" templates through the new LingParser. |
configs/ling-3.0-tiny-dspark.json |
Introduces a DSpark draft-model config for Ling-3.0-tiny (block size 8, target layers, vocab, etc.). |
examples/configs/offline/colocated/ling-3.0-tiny-dspark-offline.yaml |
Adds an offline colocated training recipe wired to the new config/template. |
tests/test_data/test_parsers.py |
Adds regression tests that compare tokenizer output against reference snapshots for both templates. |
tests/test_data/test_ling3_template.py |
Adds focused unit tests for header registration and loss-mask spans, including a “zero supervised tokens” guard. |
tests/test_data/test_references/ling-3.0_standard_ref.json |
Reference snapshot for non-thinking Ling-3.0 rendering + loss mask. |
tests/test_data/test_references/ling-3.0-thinking_reasoning-multi-turn_ref.json |
Reference snapshot for thinking Ling-3.0 rendering + loss mask on multi-turn reasoning data. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ead of pinning template wording
|
Two follow-ups. Review nits addressed in the latest commit: CI: both workflows are sitting at An offer. As noted in the description, the offline capture path cannot reach Ling-3.0 with the pinned
If SpecForge wants to track SGLang main — or is planning to bump the pin — I am happy to send those as a separate PR, guarded so that 0.5.18 keeps working. Let me know which shape you would prefer, and whether you would rather have this PR wait for that one. |
|
Using synthetic data alone is insufficient to demonstrate the draft model's effectiveness. Could you provide some training and evaluation metrics on real-world datasets? |
|
Real-dataset metrics, as requested. I ran the recipe in this PR end to end on real data: 2,000 conversations from Ling-3.0-tiny's own regenerations over perfect-blend prompts (non-thinking), offline DSpark capture at Training (real features): Evaluation: acceptance length measured by serving the exported draft in SGLang, greedy, n=60 per set.
The right-hand column is a draft of identical shape (same layers, hidden size, block size, target layers) that I trained earlier outside SpecForge on roughly 11x more data. So this run reaches about 60% of it with 1/11 of the token budget, and beats NGRAM on every set — the remaining gap is training budget, not the recipe. Two things worth flagging from the run, both fixable and both independent of the template change:
A larger run on 5,000 conversations of the same data is already captured and its training is queued behind a GPU outage on my side; I will post those numbers in this thread once it lands. |
|
Follow-up with the larger run, as promised. Same pipeline, 5,000 conversations (2.5x the data), 10 epochs = 49,840 steps on one RTX 4090. Training: draft/target top-1 agreement
2.5x the data buys a consistent ~20% across all three sets, and the draft now reaches 60–71% of the reference that saw 11.4k conversations for 20 epochs. So the gap really is training budget rather than the recipe — I can push it further if you would like a specific number before merging. For reproducibility, the training data is |
|
The on the self-hosted GPU runner. The same error hit the Could you re-run once the runner is back? Happy to rebase if needed. |
|
@bighead-liat 5k run looks good. One blocker: serving. The raw export fails in SGLang (markov_rank=0), but no new code needed - scripts/gates/normalize_dflash_export.py --block-size 8 should fix it. The step is just buried in scripts/gates/README.md. Please run it, confirm serving works end to end. Compat: separate PR is fine, but make them shims that keep 0.5.18 working, not straight patches. |
|
Serving gate done — the normalizer is all it takes, no new code. On a fresh copy of the raw exits 0 and rewrites the config to Served end to end in SGLang (main @ de0fa171, one RTX 4090): The draft loads under the
identical to the numbers I posted above, which had used a hand-edited config — so the documented step reproduces the hand fix exactly. I'll add a one-line pointer to the normalizer in the Ling recipe doc in this PR so the next person does not miss it. On compat: understood — the sglang-main drift will go in a separate PR as shims that keep 0.5.18 working, not patches. (Serving above is on sglang main because Ling-3.0's |
…ore serving The raw HF export fails to load in SGLang (markov_rank=0) until scripts/gates/normalize_dflash_export.py --block-size 8 promotes the DSpark fields; the step was only documented in scripts/gates/README.md.
|
The sglang-main compat work is up as a separate PR, as shims rather than patches: #876. It removes the last hand-applied edits from the Ling-3.0 capture run described above; the pinned 0.5.18 path is unchanged. |
|
The CI failure is caused by an infra issue on GPU runner. It's unrelated to your code, will re-run once the runner is fixed. |
test_dspark_configs_are_qwen3_gqa_only asserted attention_mode == "gqa" for every checked-in DSpark config, which forbids the mha configs the library itself asks for: DSparkDraftModel rejects gqa when num_key_value_heads >= num_attention_heads and tells the author to set dflash_config.attention_mode='mha', and validate_dflash_attention_config enforces equal query/KV head counts for that mode. Branch the per-config assertions on attention_mode: gqa keeps num_key_value_heads < num_attention_heads, mha requires equality, and both keep model_type == "qwen3" and head divisibility. mla stays rejected so a future MLA DSpark config has to extend the guard on purpose. Add configs/ling-3.0-tiny-dspark.json (12/12 heads, mha) to the allowlist and rename the test to test_dspark_configs_are_qwen3_family. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137qZGKYCauF55ULD8pkrW8
|
CI is green on 479adf7 — My 2026-09-12 That guard asserted If you would rather not relax that guard here, say so and I will drop
I also refreshed the body so the record lives there rather than only in this thread: the SpecForge-trained 5k acceptance numbers and the serving gate are in the Accuracy Test section, the capture note now says capture completes (the trap was needing a CUDA 13 toolkit for SGLang main's JIT, not SpecForge), #876 and #533 are linked, and the test guard above is listed under Modifications. Both Copilot review threads are answered and resolved — each was fixed in 2733af4. One correction while I was in there: the out-of-SpecForge reference draft I quoted on 2026-09-12 as 3.22 / 4.27 / 4.74 is 2.97 / 4.23 / 4.76 in my own ledger, and the body now carries the ledger values. One ask: #876 has never run CI — all four runs on that branch have sat at |
|
Can you address the comment above? I think Ling use the similar chat template as Qwen. |
|
Thanks for taking a look. You're right about the lineage: Bailing V3's Jinja shares Qwen's structure (same
The one place Ling needs its own parser path is the system line. Bailing V3 writes If the comment you mean is one of Copilot's two, both should be resolved as of 2733af4: |
Motivation
Ling-3.0 (inclusionAI,
BailingMoeV3/model_type: bailing_hybrid) has no chat template and no draft recipe in SpecForge, and the existingling-flash-2.0entry does not carry over to it.Ling-3.0 keeps Ling-2.0's
<role>headers, but the Bailing V3 template always emits a think block on every assistant turn:The opening tag — and, with thinking off, the whole empty block — is part of the generation prompt, so it is never model output. Reusing
ling-flash-2.0, whoseassistant_headerstops at</role>, therefore supervises template boilerplate instead of the answer. Measured with the real tokenizer:ling-flash-2.0(existing)<role>ASSISTANT</role>\n<think></think>It is 4.<|role_end|>← boilerplate supervisedling-3.0(this PR)<role>ASSISTANT</role>\n<think></think>It is 4.<|role_end|>ling-3.0-thinking(this PR)<role>ASSISTANT</role>\n<think>{reasoning}</think>It is 4.<|role_end|>The two Ling-3.0 templates have to stay separate: applying the non-thinking one to data that carries reasoning matches nothing and yields zero supervised tokens, silently.
tests/test_data/test_ling3_template.pypins that.Modifications
specforge/data/template.py: registerling-3.0andling-3.0-thinking.specforge/data/parse.py: addLingParser, mirroring the existingGLMParser, so the Bailing V3 thinking option is passed explicitly. Without it the template defaults todetailed thinking onin the system line while the data carries empty think blocks, and serving with thinking off rendersdetailed thinking off— the draft would train on a prefix it never sees.specforge/data/preprocessing.pydispatchesparser_type: "ling".configs/ling-3.0-tiny-dspark.jsonandexamples/configs/offline/colocated/ling-3.0-tiny-dspark-offline.yaml: a DSpark draft config and offline recipe for Ling-3.0-tiny (24 target layers, hidden 1536, vocab 157184, block size 8,target_layer_ids [1, 6, 13, 17, 20]).num_anchorsis 128 rather than 512: with a 157k vocabulary the anchor loss is the peak allocation of the step, and 128 keeps it inside a 24 GB card.tests/test_runtime/test_package_architecture.py: the repo-wide DSpark guard assertedattention_mode == "gqa"for every checked-in DSpark config. Ling-3.0-tiny is a 12/12-head draft, which the library itself requires to bemha:specforge/modeling/draft/dspark.pyrejectsgqawhennum_key_value_heads >= num_attention_headsand says to setdflash_config.attention_mode='mha', andvalidate_dflash_attention_configindflash.pythen requireskv == qfor that mode. The per-config assertions now branch onattention_mode—gqakeepskv < q,mharequires equality, both keepmodel_type == "qwen3"and head divisibility,mlais still rejected — and the test is renamedtest_dspark_configs_are_qwen3_family.tests/test_data/test_ling3_template.py(registration contract + loss-mask spans + the zero-mask regression guard), plustest_ling_3_0/test_ling_3_0_thinkingintests/test_data/test_parsers.pywith reference snapshots generated under the pinnedtransformers==5.12.1.Related Issues
sglang==0.5.18, but the offline capture step needs SGLang main, because 0.5.18 has nobailing_moe_v3.LingParserprevents is one concrete instance of it.Accuracy Test
Loss masks were measured against the real
inclusionAI/Ling-3.0-tinytokenizer (the table above is that output, not a design sketch); the committed reference snapshots are the token-level form of it.Trainer side, on one RTX 4090:
specforge trainwith the recipe in this PR runs 5 optimizer steps, initialisesDSparkDraftModelfrom the new config (block size 8 gives the expected 8 supervised positionsce_position_0..7, confidence and Markov heads active), loadsmodel.word_embeddings.weightandlm_head.weightfrom the Ling-3.0-tiny checkpoint, and fits in 24 GB atnum_anchors: 128. Those steps ran on synthetic offline features (random tensors shaped to the DSpark contract), so they validate the config/recipe wiring only — no convergence or acceptance claim comes from them.Since then the recipe has been trained through SpecForge end to end on features captured by it, on one RTX 4090 at
num_anchors: 128, for 10 epochs rather than the 20 the recipe ships (reproduce with--override training.num_epochs=10). Greedy acceptance length, 60 prompts per set, against NGRAM on the same target:2.5x the data buys about 20% on every set, so data volume is still the binding constraint at this scale. The export serves in SGLang after
scripts/gates/normalize_dflash_export.py --block-size 8with no code change, and the numbers above are from that served path.For scale, a DSpark draft I trained for Ling-3.0-tiny outside SpecForge (11.4k conversations, 20 epochs, same shape and same target layers) on the same three sets. This is one serving run reported two ways, because the prompt set matters for the comparison:
The first row is the like-for-like number; the second is that draft given every prompt.
Note: offline capture needs an SGLang newer than the pinned one
sglang==0.5.18, the pinned dependency, has nobailing_moe_v3at all, so Ling-3.0 can neither be served nor captured with it. Ling-3.0 support — includingset_dspark_layers_to_capture, which is whatspecforge/offline_capture/sglang_backend/capture.pyneeds for this strategy — is on SGLang main.Running
scripts/prepare_hidden_states.pyagainst main needed four adaptations, which I can send as a separate PR if SpecForge wants to track main:ModelRunnernow asserts a published runtime context:publish(server_args, role="scheduler")(sglang.srt.runtime_context) must be called before it is constructed.ServerArgs.deviceis leftNonewhenServerArgsis built directly, andinitialize_dp_attentiondoestorch.device(server_args.device).page_size=1a hybrid target lands on an allocator withoutalloc_extend(NotImplementedError: alloc_extend is only for paged allocator); a paged size works.sglang.srt.utils.require_mlp_sync()no longer takesserver_args.Past those, capture runs end to end. The one local trap was the toolchain rather than SpecForge: SGLang main's JIT needs a CUDA 13 toolkit, so
CUDA_HOMEhas to point at one instead of at a system CUDA 11nvcc. The acceptance numbers above come from that path.Benchmark & Profiling
Not applicable — this PR adds data-side templates, a draft config and a recipe; it changes no kernels or model code.
Checklist
pre-commit run --all-filesclean).