Skip to content

feat(smollm3): add native SmolLM3 model family - #1158

Draft
ruiling-smartbear wants to merge 9 commits into
NVIDIA:mainfrom
ruiling-smartbear:feat/smollm3
Draft

feat(smollm3): add native SmolLM3 model family#1158
ruiling-smartbear wants to merge 9 commits into
NVIDIA:mainfrom
ruiling-smartbear:feat/smollm3

Conversation

@ruiling-smartbear

Copy link
Copy Markdown

Background

HuggingFaceTB/SmolLM3-3B is an Apache-2.0 dense decoder that no existing family resolves. Its published configuration declares model_type: "smollm3" and SmolLM3ForCausalLM, so it is not an alias of a Llama or Qwen checkpoint, and one architectural difference keeps it off the dense Llama path: SmolLM3 interleaves NoPE layers among its RoPE layers, published as no_rope_layers (a 1 marks a layer that applies RoPE) or derived from no_rope_layer_interval. For SmolLM3-3B that is layers 3, 7, 11, ... 35 — nine of thirty-six carry no positional encoding at all. Every other dense-contract requirement is already met: GQA 16/4, head_dim 128, SiLU, RMSNorm, no attention or MLP bias, tied embeddings, layer_types uniformly full_attention.

Opened as a draft: the implementation is complete and locally validated, but the engine build and the parity run still need a TensorRT box. Marking it ready once those are in.

Closes #1146.

Exit Criteria

  • trtmc build HuggingFaceTB/SmolLM3-3B produces a family-owned bundle without trust_remote_code, passing continuation parity against the hf_transformers reference at the declared thresholds.
  • The builder applies RoPE on exactly the layers upstream does, and leaves the NoPE layers unrotated.
  • The served chat prompt reproduces the upstream chat template byte for byte in both reasoning modes.
  • The 128k configuration the model card documents (YaRN, factor: 2.0, original_max_position_embeddings: 65536) builds.
  • Out of scope for v1: the native KV graph with scaled RoPE, and any long-context qualification beyond the pinned contract case.

Implementation

  • Python family families/smollm3/. ModelConfig.rope_layer_schedule() resolves the per-layer NoPE schedule from no_rope_layers, falling back to no_rope_layer_interval and finally to all-RoPE. Both decoder builders consult it at their RoPE application site, so NoPE layers reach attention unrotated; the cos/sin tables stay global and are simply unused there. A malformed schedule is rejected during build routing rather than surfacing deep in the graph builder.
  • YaRN scaling. The family's existing RoPE table gained a yarn branch rather than a parallel path, so both builders pick it up unchanged. Upstream folds attention_factor (0.1 * ln(factor) + 1) into cos/sin inside the rotary embedding instead of applying it to attention scores; omitting it leaves every entry ~6.9% short at factor: 2.0, so it is applied to the table and asserted separately.
  • Runtime DSO src/runtime/models/smollm3/ with strategy key smollm3_decoder_kv_cache. SmolLM3's chat template is ChatML-framed but always emits a system block — metadata plus mode-specific custom instructions — even with no system message, and does not close that block with <|im_end|>. It is implemented as its own format, matched ahead of the generic ChatML fallback, with the date injectable so the contract is testable.
  • E2E root tests/e2e/models/smollm3/ with one manifest pinned to revision a07cc9a04f16550a088caea529712d1d335b0ac1, bf16, exclusive GPU, hf_transformers oracle, causal_base_continuation / continuation_parity.
  • The native KV graph continues to decline scaled RoPE other than llama3, matching the other native-KV family; such checkpoints route to the standard decoder rather than failing, and the routing reason now says so.
  • No shared infrastructure changed. The tokenizer needed no work: SmolLM3 carries the Llama-3 style pre-tokenizer regex, which the shared native BPE tokenizer already classifies as its kQwen3 variant with digit group 3 and the \s+(?!\S) rule.

Change categories

  • Model or runtime behavior
  • Public API
  • ABI
  • Bundle or artifact format
  • Dependencies
  • Documentation only
  • CI or developer tooling

Validation

Rebased onto 61ac37f2; all results below were re-run on that base.

Commands and Results

  • python3 tools/model_ci.py validate: passed, 93 registered model families, smollm3 present, no descriptor errors.
  • CMake runtime-manifest parse driven in isolation: smollm3 resolves to libtrtmc_model_smollm3.so, plugin plugin.cpp|register_smollm3_plugin, strategy smollm3_decoder_kv_cache unique repository-wide, all four declared runtime tests present.
  • pytest tests/e2e/models/smollm3/: 58 passed, 13 skipped. The 6 remaining failures are the engine-building cases that require a real TensorRT module; the same six fail identically for the llama family in the same environment.
  • pytest tests/e2e/models/smollm3/test_smollm3_rope_scaling.py: 7 passed — YaRN table against an exact float64 reference, the attention-factor fold, an explicit attention_factor override, three malformed-config rejections, and an unscaled-RoPE regression guard.
  • pytest tests/e2e/models/smollm3/test_smollm3_build_contract.py: 6 passed — pinned revision, family runtime contract, bf16 for the native KV path, exclusive GPU, hf_transformers oracle, no trust_remote_code.
  • pytest tests/e2e/models/smollm3/test_smollm3_family_plugin_weights.py: 3 passed, including tied embeddings (w_out == embedding.T when lm_head.weight is absent, which is the case for this checkpoint).
  • C++ chat-template test built and run: all cases passed, including both reasoning modes byte-for-byte against the captured upstream rendering (297 and 1369 bytes).

Reference cross-checks

  • NoPE schedule. Against transformers, the builder's schedule equals SmolLM3Attention.use_rope on all 36 layers. Cross-checked behaviourally: driving one RoPE layer and one NoPE layer in isolation with identical hidden states but different relative spacing moves the RoPE layer by 1.5e-03 and the NoPE layer by exactly 0. A global position shift is deliberately not used as the probe, since RoPE is a relative encoding and is invariant to it.
  • YaRN table. Against an exact float64 reference, the table sits 5.9e-08 away at positions 0-7 and 5.9e-08 at positions 70000-70007. Hugging Face's own float32 result is 3.2e-07 and 4.1e-03 at those positions, so the difference observed against SmolLM3RotaryEmbedding at long positions is upstream's float32 angle error, not this table's.
  • Tokenizer. Read from the pinned tokenizer.json: Llama-3 style split regex, \p{N}{1,3}, \s+(?!\S) present, ByteLevel with add_prefix_space: false, BPE with ignore_merges: true. The shared pre-tokenizer selects kQwen3 and parses digit group 3, so no family-owned tokenizer work is required.
  • Chat template. Captured from apply_chat_template() at the pinned revision for both reasoning modes and asserted verbatim.
  • Checkpoint mapping. The 36-layer checkpoint carries nine tensors per layer under standard names, no lm_head.weight, no QK-norm, no biases. Running the family mapper over a checkpoint with that exact naming consumes every tensor and produces the tied output projection.

Hardware, Environment, and Revisions

  • Checkpoint: HuggingFaceTB/SmolLM3-3B at a07cc9a04f16550a088caea529712d1d335b0ac1.
  • Base: 61ac37f2.
  • Engine build and E2E proof environment: pending, see below.

Not Run / Remaining Gaps

  • The engine has not been built and no numerical parity run has been executed. The six engine-building tests, the E2E manifest case, and tools.ci model-proof all still need a TensorRT box. This is why the PR is a draft.
  • v1 covers the 65536-token native window. The 128k YaRN configuration builds, but no long-context case is declared and none has been qualified.
  • The native KV graph declines YaRN; those builds route to the standard decoder. That path is not qualified here either.
  • Tensor parallel, quantized builds, FP32-layer overrides, and the -Base checkpoint are untested; v1 is scoped to one pinned contract case.
  • Chat-instruct E2E is not declared: the family ships one validated case, so the contract plugins seeded from the template family that no manifest exercises were removed rather than left dormant.

Notes For Future Readers

  • Read config.py::rope_layer_schedule() first, then its three call sites; that single boolean is the whole architectural delta from the dense Llama contract.
  • The YaRN attention_factor fold is easy to drop when copying a RoPE table from another family. test_attention_factor_is_folded_into_the_table exists to catch exactly that.
  • The chat-template expectations are the captured upstream rendering, not a reading of the Jinja source. Re-capture them if the checkpoint revision moves.

Risk level

  • Low
  • Medium
  • High

Risk rationale: a new self-contained family. No public API, ABI, bundle format, or dependency change. Every changed file sits under the four smollm3 roots — the Python family, the runtime DSO, the E2E root, and the C++ runtime tests — so nothing shared is touched and no existing family changes behavior.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

SmolLM3-3B (model_type smollm3, SmolLM3ForCausalLM) is a dense decoder that
matches the existing dense text contract except for one architectural
difference: it interleaves NoPE layers among RoPE layers, marked by
no_rope_layers (1 = the layer applies RoPE) or derived from
no_rope_layer_interval.

Resolve that schedule in the family config and consult it at each per-layer
RoPE application site, so NoPE layers reach attention unrotated. The cos/sin
tables stay global; only the per-layer application is gated.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
SmolLM3's chat template is ChatML-framed but always emits a system block -
Metadata (knowledge cutoff, current date, reasoning mode) plus mode-specific
Custom Instructions - even when the caller supplies no system message. The
generic ChatML path emitted none of it, and closed the no-think prefill with
two newlines after </think> where upstream emits one.

Add a smollm3 format that reproduces the upstream output byte for byte, detect
it ahead of the generic ChatML fallback, and take the date as an injectable
parameter so the contract is testable. Covered by three new cases in the
model-owned chat-template test.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
Verified against apply_chat_template() for HuggingFaceTB/SmolLM3-3B at the
pinned revision: the system block runs straight into the user turn, so the
earlier <|im_end|> after the instructions was wrong. Both reasoning modes now
reproduce upstream byte for byte, and the tests assert the captured rendering
(297 and 1369 bytes) rather than a reading of the Jinja source.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
The family root was seeded from llama, so it carried contracts for checkpoints
SmolLM3 does not ship: Llama-3.1 RoPE scaling (SmolLM3 declares rope_scaling
null), and a Minitron chunked-prefill regression whose manifest is not part of
this family. Those tests referenced manifests that do not exist here and failed
on collection.

Drop them, along with the two contract plugins and the invariant_only reference
backend that only the removed cases exercised, so every plugin left is reachable
from a declared manifest - the arrangement the other families keep. Rewrite the
build contract against the SmolLM3-3B manifest: pinned revision, bf16 for the
native KV path, exclusive GPU, the hf_transformers oracle, and no reliance on
trust_remote_code.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
…128k

SmolLM3-3B ships rope_scaling null and a 65536-token window; the model card
reaches 128k by raising max_position_embeddings to 131072 and adding a YaRN
block. The family rejected that outright, so those checkpoints did not build.

Extend the family's existing RoPE table with a yarn branch rather than adding a
parallel path, so both decoder builders pick it up unchanged. Upstream folds
attention_factor (0.1 * ln(factor) + 1) into cos/sin inside the rotary embedding
instead of applying it to attention scores; omitting it leaves every entry ~6.9%
short at factor=2.0, so it is applied to the table here and asserted separately.

The native KV graph still declines scaled RoPE other than llama3, matching the
other native-KV family, and now says so accurately: such checkpoints route to
the standard decoder rather than failing.

Verified against transformers' SmolLM3RotaryEmbedding: at positions 0-7 and at
70000-70007 the table sits 5.9e-08 from an exact float64 reference, closer than
Hugging Face's own float32 result (3.2e-07 and 4.1e-03).

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
…racts

test_model_plugin_encapsulation_static.py derives a family's C++ prefix as
"".join(part.capitalize() for part in model.split("_")), so model_type
"smollm3" requires Smollm3, not SmolLM3. Rename the runtime and test
identifiers. The model name stays as written wherever it is data rather than
an identifier: the chat-template system prompt and the comments quoting
upstream still read SmolLM3.

smollm3_detect_chat_template_format carried CCN 12 against the limit of 10.
The family format is the only one needing two markers, and that `&&` cost two
decision points on top of the ten the inherited chain already spent. An
ordered table of {first, second, require_both, format} rules scanned in
precedence order drops it to 7 and keeps smollm3-before-chatml explicit.

clang-format then reflows the renamed lines and splits the two custom
instruction constants into adjacent literals. The rendered prompts are
unchanged; test_smollm3_chat_template.cpp still asserts both reasoning modes
byte for byte against the captured upstream output.

The three dropped imports in the E2E contract module lost their only consumer
when the chunked-prefill regression plugin was removed.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
collectModelSupportInventory() resolves every E2E manifest's hf_id against
website/data/hf-model-metadata.json and throws when an entry is missing, so
the Docs stage failed on this family's manifest before the site could build.

Add the checkpoint with the revision the manifest pins. The inventory also
asserts that the two revisions agree, and that a non-empty architectures list
carries both metadata_file and architecture_source, so model_type and
architectures are taken from config.json at
a07cc9a04f16550a088caea529712d1d335b0ac1 rather than the model card.
revision_source is declared, matching the other manifest-pinned checkpoints.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
A manifest with status ready is bound by two catalogs the family did not yet
appear in, so the CPU unit stage failed on repository contracts rather than on
anything under the family roots.

trtmc_validate audits the validation workload catalog against every ready
manifest and rejects an unlisted model. smollm3-3b declares user_contract
continuation_parity, which is what mmlu_continuation_parity evaluates: a base
completion model whose bundle and HF reference continue the same prompt
greedily, compared token-level. The five-shot MMLU suite is the
multiple_choice_qa contract and does not apply here.

The release performance suite requires every non-L0 ready profile to be either
a case or an explicitly excused profile. This change qualifies the family
functionally and against the reference, but adds no performance workload or
receipt, so the profile is excused for that reason, matching how the other
dense family without a release-performance workload is recorded.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
@Moviw

Moviw commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Hi @ruiling-smartbear — thanks for the thorough implementation. I pulled your feat/smollm3 branch onto an RTX 3090 (TensorRT 11.2.1.2) to help with the engine-build gap, and hit a blocking issue before any engine can actually be built.

Repro

python3 tools/model_ci.py validate                                            # passes: 93 families, smollm3 present
pytest tests/e2e/models/smollm3/test_smollm3_standard_decoder.py -q           # 11 failed, 1 passed
pytest tests/e2e/models/smollm3/test_smollm3_builder_engine.py -q             # 6 failed, 10 passed
pytest tests/e2e/models/llama/test_llama_standard_decoder.py -q               # 12 passed (control, same box)

Every failure shares the same traceback:

AttributeError: 'ModelConfig' object has no attribute 'rope_layer_schedule'
  python/tensorrt_model_connect/families/smollm3/standard_decoder_builder.py:430
  python/tensorrt_model_connect/families/smollm3/dual_profile_decoder_builder.py:616

Root cause

rope_layer_schedule() is defined on the family-local ModelConfig in families/smollm3/config.py, but the production build path (engine_builder.py / build_cli.py) constructs the config through the shared tensorrt_model_connect.config.ModelConfig, which has no such method. The two builders call it directly, so they raise; build_routing.py uses getattr(config, "rope_layer_schedule", None) and therefore silently no-ops (which also means the malformed-schedule validation is skipped in production).

Suggested fix

Resolve the schedule from config.raw in a module-level helper (works on the shared ModelConfig too), and call that from both builders and from build_routing.py, instead of a family-local method. I have this working locally if it helps.

Want me to push a small PR against feat/smollm3 with the fix, or would you rather fold it in yourself?

… contract

test_every_fixed_kv_owner_uses_shared_explicit_attention scans every family
module for add_kv_cache_update and asserts the discovered set equals a
hardcoded roster, so a family that builds a fixed-capacity native KV graph
fails the contract until it is listed. This family's graph_ops.py carries that
call and was therefore in the discovered set but not the roster.

List it. The two properties the contract exists to enforce already hold:
graph_ops.py routes attention through
add_explicit_masked_grouped_query_attention and never calls add_attention_v2,
with the same occurrence counts as the qwen and llama modules it now sits
beside. The companion contract that no family assigns
IAttention.key_value_lengths was already satisfied.

Signed-off-by: Ruilin Gao <ruiling@andrew.cmu.edu>
@Moviw

Moviw commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Hi @ruiling-smartbear — one small attribution request on the fix I posted.

If you'd like to fold the rope_layer_schedule fix into your branch, please merge it in a way that keeps the commit's author (Moviw xvzimo@gmail.com) or adds a Co-authored-by: Moviw xvzimo@gmail.com trailer — happy to help you get to a real TensorRT box to finish the engine build + parity run, too. Thanks again for the family work.

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.

Feature: Add HuggingFaceTB/SmolLM3-3B native TensorRT support

2 participants