Repository navigation
feat(qwen4): detect the line and validate its geometry - #1130
Conversation
0820991 to
400d9d4
Compare
Code audit — round 2 (head
|
| ID | Sev | Finding | Evidence |
|---|---|---|---|
| N1 | M | The probe comment still says the in-tree gated norm "hardcodes silu … until that lands" — #1129 (this PR's own base) landed the sigmoid entry point | probe.rs:228-232 vs pegainfer-kernels/csrc/shared/norm.cu:7-9,83 at base |
| N2 | M | Same staleness in the doc: "has to become selectable … that change is a separate PR (#1129)" — #1129 is merged in base | bring-up.md:107-113 |
| N3 | M | NgramTable::derive hardcodes ple_layer_index = 0; the reference primes head i of PLE layer L at global index L * ngram_heads + i, so a multi-PLE-layer checkpoint passes the probe and derives wrong geometry for layer ≥ 1 |
config.rs:282-287 vs modeling_qwen4_exp.py:1037-1039 |
| N4 | M | Probe omits the reference's hc_count > 1 rule (a single-branch residual stream is refused upstream) |
probe.rs:284-292 vs configuration_qwen4_exp.py:196 |
| N5 | M | Probe omits three reference QSA rules: indexer_kv_heads == 1, indexer_budget % indexer_compress_ratio == 0, rotary_dim <= indexer_head_dim |
probe.rs:241-253, :417-426 vs configuration_qwen4_exp.py:221,223,227 |
| N7 | M | rope_theta is read by the reference from the same nested object, by direct index (rope_parameters["rope_theta"]), yet is neither probed nor carried — while RopeParameters' doc claims to be "the only canonical copy" of "every rope value". A slice-B rope consumer would have to re-read config.json: the exact second-source shape R3 was fixed for |
config.rs:42-53 vs modeling_qwen4_exp.py:108 |
| N6 | L | Probe omits "PLE only on linear_attention layers" |
probe.rs:298-317 vs configuration_qwen4_exp.py:248-254 |
| N8 | L | Labeler name/globs say qwen4 while the line/crate/feature/docs-dir are all qwen38-flash-next; kernel files named after the line (*qwen38*) would not get labelled |
.github/labeler.yml:18-25 |
| N9 | L | no_other_lines_identity_is_accepted's docstring says the other half ("no existing line claims qwen4_exp") is pinned by the server's hint tests — hint tests pin the hint table; identity uniqueness rests on each line's own hard gate plus the registry's Conflict |
probe.rs:471-477 vs pegainfer-qwen35/src/model_line.rs:85 |
Report-only (no code change proposed): rotary_dim()'s bail is unreachable after probe_rope (defensive D1, fine); ngram_vocab_size_base has no upper bound so a pathological config could make the sieve allocate ~4.3 GB (the reference is equally unbounded and config.json is operator-controlled); the CI hint substring filter is naming-fragile (its "every test in that module" comment is true today — checked); smaller_widths_still_pass exercises the probe but not Config::from_json; the reference also requires eos_token_id when PLE is on (configuration:255-256) and rewrites full_attention → qwen_sparse_attention in layer_types (:181-185) — both are slice-B/#1105 territory; the PR body's "Qwen3.5 probe hard-requires qwen3_5" is actually outer-or-text identity.
Round-1 receipts (R1–R13)
| ID | Status |
|---|---|
| R1 | Fixed — PINNED_TRANSFORMERS = "5.16.0" + the two-facts test. Re-verified independently: qwen4_exp/ is 404 at tags v5.8.0 and v5.15.0, present at v5.16.0 |
| R2 | Fixed (better than the prescription) — nested rope_parameters is canonical, disagreement refused, dedicated test |
| R3 | Fixed — num_experts_per_tok carried; the cited configuration_qwen4_exp.py:200 line number is accurate (checked) |
| R4 | Fixed — pointer comment in the probe, no vacuous assertion, as prescribed |
| R5 | Fixed — CUDA matrix row + "both ways" test step; the 5-on/7-off counts re-derived by hand and correct |
| R6 | Documented bound (correct to keep) — allow(dead_code) with its removal condition; slice B must delete it |
| R7 | Fixed — the empty-feature comment in the crate's Cargo.toml |
| R8 | Fixed — CLAUDE.md row now says "Foundation only (probe, config)" |
| R9 | Fixed-then-staled — the fix pre-dated #1129 merging; the rebase onto c8a09c9d inverted its tense ⇒ re-fixed as N1/N2 |
| R10–R13 | Fixed — layer-count cross-check, hint test coverage, prime lower bound (double: max(1) + base >= 2 probe rule), zero width/vocab refusals; each has its dedicated test |
Byte-level verification performed this round
- fixture ≡ upstream
config.json@de4b8e4d(diff= identical; sha256889658f2…=FROZEN_CONFIG_SHA256= upstream file hash) model.safetensors.index.json: 1658 keys, 131 files, total_size 359,999,963,128 B = 360.0 GB — freeze table fully confirmedlm_head.weight [248320, 2560]lives alone inmodel-00131;embed_tokensinmodel-00130— untied ✓; the only model-level norm ishyper_connection_mixer.hc_norm.weight— "no final-norm tensor" ✓- PLE tensors exist only under
layers.1— the 1-indexed[2]trap confirmed against the real layout ✓ ngram_embedding.shard_0…shard_127— exactly 128 named shards ✓;shard_127.weight = [2500012, 160] BF16✓- stored
ngram_heads_vocab_sizes=[20000003, 20000023, …, 20000171]andngram_heads_offsets= the derived prefix sums — byte-exact against an independent recomputation ✓ ple.conv1d.weight = [10240, 1, 4], same shape as the GDN conv — the "identical shape, dilated anyway" trap confirmed; referenceconv_dilation = ngram_size, state(4-1)*3 = 9(modeling:1134-1135) matchesple_conv_state_len()✓_find_nth_prime_after(modeling:1009-1015) semantics +_is_prime(<2) = False— the port'sstart.max(1)floor is equivalent on {0,1}; all 7 pinned values recomputed ✓; padding formula ≡modeling:1052-1053✓norm_topk_prob: bool = True/seed: int = 1234upstream defaults (configuration:156,163), consumed by the router (modeling:903,912), absent from the fixture ✓- mrope degeneration argument holds: 2-D positions
expand(3, …)(modeling:123-124) then the interleaved overwrite copies identical axes onto themselves (:140-156) = plain 1-D rope ✓ - GQA group 12 ∉
SUPPORTED_GQA_GROUP_SIZES = [1,2,3,4,8](pegainfer-kernels/src/ops/attention.rs:18) ✓; gemma4cfg(any(feature, test))precedent ✓;load_shard_info_fixedexists (pegainfer-core/src/weight_loader.rs:1055) ✓;reasoning_effortarms onoutput_gate_type(pegainfer-frontend/src/vllm/reasoning_effort.rs:16,94) ✓ - referenced issues all exist and match: [Model] Qwen3.8-Flash-Next roadmap #1103 roadmap / [Model] Qwen3.8-Flash-Next: line skeleton & bring-up #1104 slice A / [Model] Qwen3.8-Flash-Next: QSA sparse attention #1105 QSA / [Model] Qwen3.8-Flash-Next: text-graph assembly & decode step #1127 slice B / [Model] Qwen3.8-Flash-Next: multi-GPU EP serving & envelope #1128 slice C ✓
cargo test --release -p pegainfer-qwen38-flash-next --lib→ 44 passed locally ✓
Verdict
Detection, fail-closed refusals, geometry derivation and the registration/hint surface are all correct for the frozen checkpoint and for the tested variants. The real gap is that the probe is looser than the reference's own validation in five places (N3–N7): today none of them is reachable from the only checkpoint that exists, but each one turns "known-but-uncompiled" into "known, detected, and silently mis-derived" for a future qwen4_exp variant — which is precisely the class of failure this crate exists to prevent. All nine findings are mechanical to close; a fix commit follows on this branch.
Fix commit for the round-2 findings:
|
| Finding | Fix | Guard |
|---|---|---|
| N1 (M) stale "hardcodes silu" comment | probe.rs output-gate comment rewritten: #1129 made the shared kernel selectable (rms_norm_gated_sigmoid_cuda beside Qwen3.5's SiLU); what remains is this line's call site. The sigmoid pin stays fail-closed — no silu-gated qwen4_exp variant has golden fixtures here |
existing a_non_sigmoid_output_gate_bails (rationale now accurate) |
| N2 (M) stale doc trap | bring-up.md Traps now reads "became selectable in #1129 … merged before this line's detection PR, so what remains is this line's call site" |
— (doc; tense anchored to a merged commit) |
| N3 (M) multi-PLE-layer mis-derivation | probe_ple refuses any ple_layer_ids but a single entry, naming the reference's per-layer prime offset (global_head_idx = L * ngram_heads + i, modeling:1037-1039); NgramTable's doc now states the single-layer assumption |
a_multi_layer_ple_bails ([2, 6] refused) |
N4 (M) hc_count > 1 missing |
probe_hyper_connections mirrors configuration:196 — a single-branch residual stream has nothing to mix |
a_single_branch_residual_stream_bails |
| N5 (M) QSA rules missing | probe_attention mirrors indexer_kv_heads == 1 (:221) and budget a whole multiple of the compress ratio (:223); probe_rope mirrors rotary ≤ indexer_head_dim (:227) |
an_indexer_kv_heads_above_one_bails, an_indexer_budget_not_divisible_by_the_compress_ratio_bails, a_rotary_width_past_the_indexer_head_bails |
| N6 (L) PLE-on-linear rule missing | probe_ple mirrors configuration:248-254 (safe subscript: probe_layer_types runs first and pins layer_types length to num_hidden_layers) |
ple_on_a_full_attention_layer_bails ([4] is full under interval 4) |
N7 (M) rope_theta unmodelled |
Carried as a required RopeParameters field (serde-required ⇒ absence refuses at parse), probed positive-and-finite (the reference direct-indexes it, modeling:108) — no consumer can need a second read of config.json |
a_missing_rope_theta_bails + bit-exact pin 10_000_000.0 in the_frozen_revision_pins_the_attention_and_moe_widths |
| N8 (L) labeler naming gap | Added *qwen38* kernel globs beside *qwen4*, so files named after the line label the same as files named after the model_type |
— (labeler only) |
| N9 (L) docstring evidence pointer | Reworded: identity uniqueness rests on each line's own hard gate + the registry's Conflict; the server's hint tests pin that the hint table does not cross-map |
— (docstring) |
Report-only items stay report-only by design: rotary_dim()'s defensive bail (unreachable after the probe, kept), the unbounded ngram_vocab_size_base sieve window (the reference is equally unbounded and config.json is operator-controlled), the CI hint filter's naming fragility (its comment is true today), smaller_widths_still_pass covering the probe but not from_json, eos_token_id and the qwen_sparse_attention rename (slice B / #1105 territory), and the allow(dead_code) bound (R6, comes off with the first runtime caller).
Verification
cargo test --release --locked -p pegainfer-qwen38-flash-next --lib→ 51 passed (44 + 7 new; the description's "44 tests" and the doc's count are updated where they live — the description itself can't be edited, so noting it here)cargo clippy --release --locked -p pegainfer-qwen38-flash-next --all-targets -- -D warnings→ clean (exit 0; the only output is the pre-existing third-partyproc-macro-error2future-incompat notice)cargo fmt --all --check→ clean- The frozen config still passes end-to-end (
the_frozen_revision_passes,the_registry_entry_detects_the_frozen_revision), andsmaller_widths_still_passstill holds — the new rules mirror the reference's own refusals, so no config the reference accepts is rejected here - Server-side hint tests are untouched by this commit (no
main.rschange)
Round-1 receipts stand as tabulated in the audit comment: R1–R5, R7–R8, R10–R13 fixed; R6 a correctly documented bound; R9 fixed-then-staled-by-rebase and now re-fixed as N1/N2.
8e0ec01 to
6294278
Compare
|
This pull request has merge conflicts with main. Please rebase it so it can be reviewed and merged. |
6294278 to
4ea9ef4
Compare
| /// The `count`-th prime strictly greater than `start`. Mirrors the reference's | ||
| /// `_find_nth_prime_after`; the search window is tiny (a few hundred candidates | ||
| /// for a base near 2e7), so trial division by sieved small primes is enough. | ||
| fn nth_prime_after(start: usize, count: usize) -> usize { |
There was a problem hiding this comment.
每次call这个函数都得重算线性筛,是否太耗时?如果调用次数少,是否应该打表;如果次数多;是否应该预处理?
Round 3: the registration follow-ups, and a prime-sieve fixTwo commits on the branch since the last round (both on top of the rebase onto
|
| ID | Finding | Fix |
|---|---|---|
| G1 | launch's refusal framed the GDN gated-norm activation as an open kernel dependency, but #1129 already made the sigmoid entry point selectable. |
The message now says the missing piece is the call site, which lands with the text graph in slice B. pegainfer-qwen38-flash-next/src/model_line.rs:27-34 |
| G2 | feature_gate_hint is identity-only — it cannot run a compiled-out probe — so it also fires for the FP8 sibling and the tied/YaRN variants of qwen4_exp, which the probe refuses. That was undocumented. |
Written down on the family table (pegainfer-server/src/main.rs:54-67) and in bring-up.md. |
| G3 | The hint table is a hand-mirror of each line's identity, and nothing bound it to the probe. | families() is now reachable from a test, and a feature-gated test asserts the qwen38-flash-next row equals the line crate's MODEL_TYPE / TEXT_MODEL_TYPE (main.rs:386-402). Those constants move the identity strings to one place (probe.rs:26-35, re-exported by lib.rs:31-32). |
346f6445 — nth_prime_after re-sieved on every candidate
The lazy re-sieve compared the candidate's square root against the largest prime in the current sieve, not against the sieve's limit. Near the frozen base that root is 4473 = 3²·7·71 — composite — so the test held for every candidate.
Measured (x86_64, -O) on the derivation one Config::from_json performs — heads = (ngram_size − 1) × heads_per_ngram = 16 calls of nth_prime_after:
| variant | per Config::from_json |
|---|---|
| before | 6.26 ms (the last call alone re-sieved 172 times) |
| after (compare against the sieve limit) | 175 µs |
| single linear sweep for all 16 primes (rejected) | 17.5 µs |
Derived values are unchanged; nth_prime_after_matches_the_reference and the n-gram geometry tests pin them.
Why neither a precomputed table nor a shared pre-pass:
NgramTableis derived on purpose — the checkpoint stores its ownngram_heads_vocab_sizes, and deriving is exactly what lets the manifest reject a partition that disagrees with its own config before any bytes move.ngram_vocab_size_baseandheadsare config values the probe deliberately does not pin (ngram_vocab_size_base ≥ 2, any integer). A hard-coded table would be a second source of truth that cannot enumerate a base it permits.- The remaining 10× (175 µs → 17.5 µs) would cost the 1:1 correspondence with the reference's
_find_nth_prime_after, which the unit test reads directly. Not worth it on a once-per-load cold path.
Verification
- Local:
cargo fmt --all --check✓, 51 line-crate tests ✓,cargo clippy -D warningson the crate ✓. - CI run
37944431030(head61dd6779): 16/16 jobs green, including the Flash-Next row's both-ways step — 6 hint tests with the feature on (the new binding test among them) and 7 with it off. - CI on
346f6445is running.
|
@codex review please. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 346f6445d3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
|
|
||
| fn probe(&self, config: &serde_json::Value) -> Result<(), String> { | ||
| crate::probe::probe_config_json(config).map_err(|error| error.to_string()) |
There was a problem hiding this comment.
Run geometry derivation from the registered probe
For a qwen4_exp config with positive but inconsistent derived geometry (for example, ple_embed_dim = 2561 or split_ngram_parts = 3), this production method calls only probe_config_json, which accepts it; repo-wide, Config::from_json is called only by tests, where NgramTable::derive rejects those cases. Consequently ModelLineRegistry::detect claims the config and launch says it was validated even though the advertised geometry validation is dead scaffolding; construct Config here (or move its checks into the probe) so the config layer participates in the production invariant.
AGENTS.md reference: AGENTS.md:L128-L128
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f1d2699: ModelLine::probe now builds the whole Config, so detect runs the n-gram/head-group derivations too, not just the structural probe. Guarded by the_probe_rejects_geometry_that_does_not_derive (ple_embed_dim = 2561).
| cargo test --release --locked -p pegainfer-server \ | ||
| --features qwen38-flash-next --bin pegainfer hint | ||
| cargo test --release --locked -p pegainfer-server --bin pegainfer hint |
There was a problem hiding this comment.
Exercise the line gate with a production E2E
In the inspected cuda-clippy job, the new registration step only runs the hint unit-test filter with the feature on and off, and a repo-wide qwen4_exp search finds no test that invokes the built server. This feature changes production detection and the intentional launch-refusal path, so these pure tests do not provide the repository-required production E2E or cover the CLI/registry/startup wiring as shipped; add a release-binary test against a model directory that asserts both the stock-build feature hint and the enabled-build refusal.
AGENTS.md reference: AGENTS.md:L128-L128
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f1d2699: pegainfer-server/tests/detect_gate.rs runs the shipped binary against a model dir holding the frozen config, both ways — the stock build must name --features qwen38-flash-next, the enabled build must detect and refuse. Wired into the CUDA row; both cfgs green.
| for field in ["num_attention_heads", "num_key_value_heads"] { | ||
| let v = text_config.get(field).and_then(serde_json::Value::as_u64); | ||
| if v.is_none_or(|v| v == 0) { | ||
| bail!("qwen4_exp: {field} must be a positive integer, got {v:?}"); | ||
| } |
There was a problem hiding this comment.
Reject non-integral full-attention head groups
For a smaller checkpoint with positive but non-divisible head counts (for example, 10 query heads and 3 KV heads), this loop accepts the config even though grouped attention needs an integral query/KV ratio. The analogous Qwen3.5 validation rejects this at pegainfer-qwen35/src/config/model.rs:265-271, and the shared attention dispatch operates on that derived group, so this probe can claim a geometry the planned attention path cannot represent; require num_attention_heads to be a multiple of num_key_value_heads.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f1d2699: the probe now rejects a non-integral query/KV ratio, mirroring qwen35's AttentionHeadDivisibility. Test: a_non_integral_gqa_group_bails (10 query / 3 KV heads).
| for field in ["linear_num_key_heads", "linear_num_value_heads"] { | ||
| let v = text_config.get(field).and_then(serde_json::Value::as_u64); | ||
| if v.is_none_or(|v| v == 0) { | ||
| bail!("qwen4_exp: {field} must be a positive integer, got {v:?}"); | ||
| } |
There was a problem hiding this comment.
Reject non-integral GDN head groups
For a smaller checkpoint whose positive linear value-head count is not a multiple of its key-head count, this loop accepts the config even though Gated DeltaNet maps value heads in whole groups over key heads. The reused Qwen3.5 geometry enforces this exact invariant at pegainfer-qwen35/src/config/model.rs:255-263; without the same check here, detection can validate a shape the shared GDN path cannot represent, so reject unless linear_num_value_heads is divisible by linear_num_key_heads.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f1d2699: the probe now requires linear_num_value_heads % linear_num_key_heads == 0, mirroring qwen35's LinearHeadDivisibility. Test: a_non_integral_gdn_head_group_bails (48 value / 10 key heads).
|
This pull request has merge conflicts with main. Please rebase it so it can be reviewed and merged. |
Qwen3.8-Flash-Next is a new line, not a Qwen3.5 variant: its model_type is qwen4_exp with an inner qwen4_exp_text, so the Qwen3.5 probe refuses it by construction and an unguarded default build would report an unknown model rather than naming the feature that serves it. Land the foundation the rest of the line stands on: a fail-closed probe, a config layer that validates the geometry, and the registration that lets the server detect the checkpoint and refuse to launch it. There is no text graph, so launch bails with the reason instead of serving a partial model. The probe pins structure rather than widths, following the Gemma 4 pattern that lets one crate serve several sizes. Pinned are the things an alternative would need a kernel or serving path this line does not have: the GDN head dims (they are the Triton AOT specialization keys), head_dim 256, the conv kernel ceiling, rope_type default, output_gate_type sigmoid, mamba_ssm_dtype float32, dtype bfloat16 so the FP8 sibling fails closed rather than loading as BF16, and untied embeddings. Deliberately unpinned are expert count, hidden size, layer count, head counts and hc_count, so a smaller qwen4_exp checkpoint reuses this probe; smaller_widths_still_pass keeps that promise honest. Two fields the reference implementation reads are absent from config.json and come from upstream defaults, so they are modelled explicitly: norm_topk_prob decides whether the router renormalizes its top-k, and a port that reads only config.json silently drops that. This is pegainfer-project#1068's mirror image. The crate carries no CUDA dependency at all, so its 38 tests need no device, no weights and no Triton-equipped build, and CI runs them on a CPU runner rather than in the per-model CUDA matrix. The server binary hand-mirrors every line's probe identities in a hint descriptor its own comment says cannot be derived away, so the mirror is tested and not just the probe. Refs pegainfer-project#1104 Signed-off-by: bohaotu <bohaotu@pony.ai>
The freeze table read the checkpoint's declared `5.8.0.dev0` as the release that carries the reference implementation. It is not: `src/transformers/models/qwen4_exp/` is absent from tags v5.8.0 through v5.15.0 and present from v5.16.0, so 5.16.0 is the first release that can serve as a reference. The tokenizer row gets the same correction for a different reason: the checkpoint's own tokenizer is `Qwen2Tokenizer`, which is much older, but the fixtures are compared against the reference implementation. Signed-off-by: bohaotu <bohaotu@pony.ai>
An audit of this PR found four issues that matter and nine worth tightening, all in the gap between what the gate claims and what it guards. - The reference implementation first ships in transformers **5.16.0**, not the `5.8.0` the constant pinned: the same conflation of the checkpoint's declared `5.8.0.dev0` with the release that carries the reference that the doc fix corrected. Repointed, and pinned by a test that holds both facts apart. - `partial_rotary_factor` had two sources: the probe validated the nested `rope_parameters` copy while `TextConfig` read the legacy top-level one. The reference reads the nested object and nothing reads the top-level copy, so the config now models the nested one, and the probe refuses a checkpoint whose two copies disagree rather than letting readers pick differently. - `num_experts_per_tok` was validated by the probe but never carried, so a router would have had to re-read `config.json` — a second source of truth. It is a `TextConfig` field now, and the check has a consumer. - The registration path had no CI coverage at all: `pegainfer-server` gates its registry entry on `qwen38-flash-next`, no matrix row enabled that feature, so the entry never compiled and the hint tests never ran. The line gets its own row, plus a step that runs those tests with the feature on and off. Also tightened: `num_hidden_layers` must agree with `layer_types`, `hidden_size`/`vocab_size` must be positive, and `ngram_vocab_size_base` must leave room for a prime search — the helper accepted 1 as prime when searching from 0. The probe now records why `mrope_interleaved`/`mrope_section` are accepted rather than refused: a text-only sequence is their degenerate case in the reference, which expands one position across all three axes before interleaving. The decision is tracked in pegainfer-project#1105. Signed-off-by: bohaotu <bohaotu@pony.ai>
…e probe A round-2 audit of this branch found the probe looser than the reference implementation's own validate_architecture, plus two notes that went stale when the branch was rebased onto the pegainfer-project#1129 merge: - The reference primes head i of PLE layer L at the global index L * ngram_heads + i (modeling_qwen4_exp.py:1037-1039), while NgramTable::derive fixes the layer index at 0; a multi-PLE-layer checkpoint would pass detection and derive the wrong per-layer geometry, so the probe now refuses any ple_layer_ids but a single entry until per-layer tables are modelled. - The reference refuses hc_count <= 1 (configuration_qwen4_exp.py:196), indexer_kv_heads != 1 (:221), a budget that is not a whole multiple of the compress ratio (:223), a rotary width past the index head (:227), and PLE on a non-linear-attention layer (:248-254); the probe now mirrors all five, since a config the reference refuses cannot be served by any port of it. - The reference indexes rope_parameters["rope_theta"] directly (modeling_qwen4_exp.py:108), but TextConfig did not carry it, leaving a future rope consumer to re-read config.json — the second-source shape the previous audit removed for num_experts_per_tok. It is now modelled, probed, and pinned bit-exactly. - pegainfer-project#1129 merged before this branch's base, so "the in-tree gated norm hardcodes silu until that lands" went stale in the probe comment and in the bring-up doc; both now record that the kernel is selectable and only this line's call site is outstanding. - The labeler now also matches kernels named after the line (*qwen38*), not only after the model_type (*qwen4*). - The identity-uniqueness test's docstring credited the server's hint tests with pinning other lines' probes; reworded to name the actual guards (each line's hard identity gate plus the registry's Conflict). Seven new probe tests and one new config pin: 44 -> 51, all passing on CPU. The derivation was re-verified against real checkpoint bytes: the stored ngram_heads_vocab_sizes / ngram_heads_offsets tensors (model-00037-of-00131) match the derived prime run byte for byte, and shard_127 is [2500012, 160] BF16 exactly as derived. Signed-off-by: bohaotu <bohaotu@pony.ai>
…d against The row's "Run the registration tests both ways" step is the first thing in this repo's CI that links a CUDA-linked binary: `cargo clippy` never links, so the row passed until the PR left draft, and then the test binary failed with `rust-lld: error: unable to find library -lcuda` — pegainfer-kernels' build script asks for it unconditionally, and a GitHub runner has no driver. Install `driver-dev` in the shared CUDA toolkit step and point the linker at its stub through LIBRARY_PATH, the exact pairing `.github/workflows/release.yml` already uses to build the server on a driver-less runner, plus a copied `libcuda.so.1` on LD_LIBRARY_PATH so the loader can resolve the stub's SONAME when the test binary runs. The hint tests never call into the driver, so the stub is sufficient and no root is needed. Signed-off-by: bohaotu <bohaotu@pony.ai>
…ale note Three follow-ups from the line's review: - `launch`'s refusal no longer frames the GDN gated-norm activation as an open kernel dependency: pegainfer-project#1129 made the sigmoid entry point selectable, so the missing piece is the call site, which lands with the text graph. - `feature_gate_hint`'s identity table is documented as identity-only — it cannot run a compiled-out probe, so it also fires for the FP8/tied/YaRN variants the probe refuses. - `families()` becomes reachable from a test, and a feature-gated test binds its `qwen38-flash-next` row to the line crate's `MODEL_TYPE` / `TEXT_MODEL_TYPE`, so the hand-mirror cannot drift from the probe. Signed-off-by: bohaotu <bohaotu@pony.ai>
`nth_prime_after` re-sieved whenever the candidate's square root exceeded the *largest prime in* the current sieve. Near the frozen base that root is 4473 = 3^2 * 7 * 71, composite, so the test held for every candidate: the 16-prime derivation for one `Config::from_json` re-sieved ~170 times, 6.26 ms, against 175 us when the comparison is against the sieve's limit instead. Derived values are unchanged; the existing reference test pins them. Signed-off-by: bohaotu <bohaotu@pony.ai>
… gate Three findings against the shipped gate, all reproduced: - `ModelLine::probe` called `probe_config_json` alone, so `ModelLineRegistry::detect` claimed a config whose *derived* geometry cannot be laid out — `ple_embed_dim = 2561` or `split_ngram_parts = 3` pass the structural probe and only `Config::from_json` rejects them, and that was test-only. `probe` now builds the `Config`, so the n-gram and head-group derivations are on the detection path the line actually runs. - The probe accepted a non-integral grouped-attention ratio (10 query / 3 KV heads) and a non-integral GatedDeltaNet head group (48 value / 10 key heads). Both are invariants the shared Qwen3.5 geometry enforces (`AttentionHeadDivisibility`, `LinearHeadDivisibility`) and the dispatch is built on; reject them here as well. - Nothing drove the shipped binary, so `main`'s CLI -> registry -> hint -> `launch` wiring had no coverage. `pegainfer-server/tests/detect_gate.rs` runs it against a model directory holding the frozen config, both ways: the stock build must name the feature to rebuild with, the enabled build must detect the line and refuse to serve. Neither outcome touches CUDA, and the CUDA row now runs the test alongside the hint tests. Signed-off-by: bohaotu <bohaotu@pony.ai>
f1d2699 to
1eaae79
Compare
The enabled build reaches the serve path before `launch` bails, and the frontend refuses to start without a tokenizer, so the test saw "failed to create chat/text backends" instead of the refusal. The model directory now also carries the repo's minimal tokenizer (`pegainfer-frontend/examples/echo-model`, the fixture `examples/echo-server.rs` serves); the engine failure then cancels the server and the refusal is what reaches stderr. Signed-off-by: bohaotu <bohaotu@pony.ai>
|
@codex review please. |
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
Signed-off-by: Feathbow <feathbow@gmail.com>
Signed-off-by: Feathbow <feathbow@gmail.com>
Signed-off-by: Feathbow <feathbow@gmail.com>
Signed-off-by: Feathbow <feathbow@gmail.com>
Signed-off-by: Feathbow <feathbow@gmail.com>
Signed-off-by: Feathbow <feathbow@gmail.com>
Signed-off-by: Feathbow <feathbow@gmail.com>
Commit attribution noticeThis pull request contains commits attributed to an account other than the PR author @sysuTobo without a verified signature from that account:
If you are mentioned above and did not take part in this pull request, please say so here. Attributing a commit to someone else normally requires that person to be involved; maintainers should treat unacknowledged attribution as a review blocker. This check never fails the build. |
|
We pushed seven commits on top of yours.
Checked on one sm_89 box.
#1104, #1127 and #1128 still use the old name, and a branch stacked on this one needs a rebase over the rename. Tell us if anything here should go another way. |
An audit of this PR found two issues that matter and six worth tightening; all eight sit in the gap between what the contract claims and what it makes verifiable. None of them changes a derived number. - The ignored gate printed how many tensors it opened but not how many of them the contract requires, so the "446 opened / 111 required" figure in the bring-up doc could not be reproduced from the gate's own output. It prints the required/skipped split now. - The fault message named "Qwen3.8-Flash-Next" while this line's probe and config deliberately serve *any* smaller `qwen4_exp` checkpoint, so a failure on a second checkpoint would name the wrong model. It names the architecture (`MODEL_TYPE`) now. - `dtype_size` existed twice and the two disagreed: the production copy returned `0` for an unknown dtype, silently shrinking `weight_bytes`, while the test copy panicked. There is one copy in `manifest/mod.rs` now, and it panics for both. - `schema.rs` guarded duplicate derivations with `debug_assert!`, which the `--release` profile these tests run under compiles out. It is an `assert!` now. - The three `dead_code` allows still said "the tensor contract is the first reader", which this PR makes stale; they name the actual first reader (slice B's loader, and the contract test for `bf16_bytes`). - Two doc sentences over-claimed: "every probe, config and manifest test runs against it" (two of them build their own input) and "three that read real shard bytes" (one reads the index). Not addressed, by decision: the line is not wired into CI (pegainfer-project#1130 kept it out until slice B), so these tests still run by hand only. Recorded in the PR description as a known bound rather than fixed here. Signed-off-by: bohaotu <bohaotu@pony.ai>
The detection PR (pegainfer-project#1130) landed the probe and the validated config, so the checkpoint's tensors were implied by the geometry but never named. This lands the contract that says exactly which tensors the line must read, and holds the checkpoint's own headers against it before any H2D. Ported from `pegainfer-gemma4/src/manifest/`, with three deltas this checkpoint forces. The LM head is **untied**, so both ends are required and there is no final-norm tensor — the readout is the hyper-connection mixer. Shapes include **rank-3** tensors: the depthwise conv and the fused expert stacks, which carry no `.weight` suffix. And the skip set (`model.visual.`, `mtp.`) is mandatory rather than optional, because all 333 vision tensors share a shard with 16 text tensors, so a loader reading whole shards must classify per tensor. `read.rs` has no Gemma 4 counterpart: that line's checkpoint is small enough to hold whole, while this one is 360 GB across 131 shards, so the reader reports the shards it never opened and refuses to call their tensors missing, while still holding every opened tensor to its shape and dtype. It also cross-checks each shard's header against the names the index attributes to it, so a stale index is named instead of surfacing as a wall of spurious "missing". The contract derives exactly the frozen checkpoint's text set — 1658 keys less 333 `model.visual.*` and 31 `mtp.*` = 1294 tensors, `lm_head.weight` the only one outside the text prefix — and has been held against eight real, sha256-verified shards (446 tensors opened, 111 of them required text tensors): every shape and dtype matched. The n-gram partition is confirmed **by value** against the two `I64[16]` vectors the checkpoint stores, not only by shape. CPU only, like the rest of the crate: 67 tests run with no device, no weights and no Triton-equipped build, and three more read real shard bytes on demand. Refs pegainfer-project#1104 Signed-off-by: bohaotu <bohaotu@pony.ai>
An audit of this PR found two issues that matter and six worth tightening; all eight sit in the gap between what the contract claims and what it makes verifiable. None of them changes a derived number. - The ignored gate printed how many tensors it opened but not how many of them the contract requires, so the "446 opened / 111 required" figure in the bring-up doc could not be reproduced from the gate's own output. It prints the required/skipped split now. - The fault message named "Qwen3.8-Flash-Next" while this line's probe and config deliberately serve *any* smaller `qwen4_exp` checkpoint, so a failure on a second checkpoint would name the wrong model. It names the architecture (`MODEL_TYPE`) now. - `dtype_size` existed twice and the two disagreed: the production copy returned `0` for an unknown dtype, silently shrinking `weight_bytes`, while the test copy panicked. There is one copy in `manifest/mod.rs` now, and it panics for both. - `schema.rs` guarded duplicate derivations with `debug_assert!`, which the `--release` profile these tests run under compiles out. It is an `assert!` now. - The three `dead_code` allows still said "the tensor contract is the first reader", which this PR makes stale; they name the actual first reader (slice B's loader, and the contract test for `bf16_bytes`). - Two doc sentences over-claimed: "every probe, config and manifest test runs against it" (two of them build their own input) and "three that read real shard bytes" (one reads the index). Not addressed, by decision: the line is not wired into CI (pegainfer-project#1130 kept it out until slice B), so these tests still run by hand only. Recorded in the PR description as a known bound rather than fixed here. Signed-off-by: bohaotu <bohaotu@pony.ai>
* feat(qwen4): enforce the tensor contract before any device work The detection PR (#1130) landed the probe and the validated config, so the checkpoint's tensors were implied by the geometry but never named. This lands the contract that says exactly which tensors the line must read, and holds the checkpoint's own headers against it before any H2D. Ported from `pegainfer-gemma4/src/manifest/`, with three deltas this checkpoint forces. The LM head is **untied**, so both ends are required and there is no final-norm tensor — the readout is the hyper-connection mixer. Shapes include **rank-3** tensors: the depthwise conv and the fused expert stacks, which carry no `.weight` suffix. And the skip set (`model.visual.`, `mtp.`) is mandatory rather than optional, because all 333 vision tensors share a shard with 16 text tensors, so a loader reading whole shards must classify per tensor. `read.rs` has no Gemma 4 counterpart: that line's checkpoint is small enough to hold whole, while this one is 360 GB across 131 shards, so the reader reports the shards it never opened and refuses to call their tensors missing, while still holding every opened tensor to its shape and dtype. It also cross-checks each shard's header against the names the index attributes to it, so a stale index is named instead of surfacing as a wall of spurious "missing". The contract derives exactly the frozen checkpoint's text set — 1658 keys less 333 `model.visual.*` and 31 `mtp.*` = 1294 tensors, `lm_head.weight` the only one outside the text prefix — and has been held against eight real, sha256-verified shards (446 tensors opened, 111 of them required text tensors): every shape and dtype matched. The n-gram partition is confirmed **by value** against the two `I64[16]` vectors the checkpoint stores, not only by shape. CPU only, like the rest of the crate: 67 tests run with no device, no weights and no Triton-equipped build, and three more read real shard bytes on demand. Refs #1104 Signed-off-by: bohaotu <bohaotu@pony.ai> * fix(qwen4): harden the tensor contract after its self-review An audit of this PR found two issues that matter and six worth tightening; all eight sit in the gap between what the contract claims and what it makes verifiable. None of them changes a derived number. - The ignored gate printed how many tensors it opened but not how many of them the contract requires, so the "446 opened / 111 required" figure in the bring-up doc could not be reproduced from the gate's own output. It prints the required/skipped split now. - The fault message named "Qwen3.8-Flash-Next" while this line's probe and config deliberately serve *any* smaller `qwen4_exp` checkpoint, so a failure on a second checkpoint would name the wrong model. It names the architecture (`MODEL_TYPE`) now. - `dtype_size` existed twice and the two disagreed: the production copy returned `0` for an unknown dtype, silently shrinking `weight_bytes`, while the test copy panicked. There is one copy in `manifest/mod.rs` now, and it panics for both. - `schema.rs` guarded duplicate derivations with `debug_assert!`, which the `--release` profile these tests run under compiles out. It is an `assert!` now. - The three `dead_code` allows still said "the tensor contract is the first reader", which this PR makes stale; they name the actual first reader (slice B's loader, and the contract test for `bf16_bytes`). - Two doc sentences over-claimed: "every probe, config and manifest test runs against it" (two of them build their own input) and "three that read real shard bytes" (one reads the index). Not addressed, by decision: the line is not wired into CI (#1130 kept it out until slice B), so these tests still run by hand only. Recorded in the PR description as a known bound rather than fixed here. Signed-off-by: bohaotu <bohaotu@pony.ai> * fix(qwen4): name an indexed shard that is not a regular file The contract audit left one case uncovered: an indexed path that exists but is not a regular file was counted as "not downloaded", so a download tool that left a directory (or any other non-file) behind passed as an ordinary partial fetch. On a 360 GB checkpoint fetched shard by shard that is a plausible accident. - The scan gains `unreadable_shards`, filled only when the indexed path exists and is not a file. `absent_shards` keeps its narrower meaning. - `check` names it as its own fault class, and forgives `missing` for it as well: a shard we cannot open is one we cannot testify about, so its tensors must not be reported as a wall of "missing" either. - The ignored real-shard gate counts it in its denominator, so "opened X of Y" stays honest. - The two ignored tests that read the real checkpoint index now use `read::INDEX_FILE` instead of repeating the literal, so the published filename has one source. New test: 68 passed, 3 ignored; clippy `-D warnings` and `fmt --check` clean. Signed-off-by: bohaotu <bohaotu@pony.ai> * docs(qwen4): refresh the compiled-GQA trap and trim the comments Review feedback on the GQA trap was right: `SUPPORTED_GQA_GROUP_SIZES` is `[1, 2, 3, 4, 6, 8]` on `main` now — #1145 routed group 6 — so Qwen3.5-27B is no longer "in the same corner". Group 12 is still absent, so the conclusion holds; the bullet now states the set and the mechanism (it grows when a group earns a compiled kernel) instead of the stale comparison. Also, per review: - The verification section recorded the internal provenance of the run — which shards, how much was fetched, the checksum procedure, the tensor counts. That belongs in the local working notes, not the repository: it keeps the claim and the two derived conclusions, and the ignored gate still prints what it opened. - The ignored n-gram test no longer names a specific shard of the frozen revision, and the comments across the module are cut to a line or two where they ran to paragraphs. Comments and docs only: no derived number, name, shape, test or behaviour changed. Signed-off-by: bohaotu <bohaotu@pony.ai> * fix(qwen4): hold the contract against a snapshot of every shard header Signed-off-by: Feathbow <feathbow@gmail.com> * test(qwen4): drop the tests the header snapshot already covers Signed-off-by: Feathbow <feathbow@gmail.com> * docs(qwen4): describe the contract as tested and leave decode capture to slice B Signed-off-by: Feathbow <feathbow@gmail.com> --------- Signed-off-by: bohaotu <bohaotu@pony.ai> Signed-off-by: Feathbow <feathbow@gmail.com> Co-authored-by: Feathbow <feathbow@gmail.com>
Description
Refs #1104 — one of three PRs delivering slice A of the #1103 Qwen3.8-Flash-Next bring-up, so this does not close the issue. The tensor contract that enforces this config against real checkpoint bytes follows as a separate PR stacked on this one; the gated-norm activation this line needs is #1129, which is independent of both.
Qwen/Qwen3.8-Flash-Nextis a new line, not a Qwen3.5 variant:model_type: qwen4_expwith an innerqwen4_exp_text, against Qwen3.5'sqwen3_5. The Qwen3.5 probe hard-requiresmodel_type == qwen3_5, so today this checkpoint surfaces as an unknown model instead of a known-but-uncompiled one, and the server cannot name the feature that would serve it.Production invariant changed:
ModelLineRegistry::detectgains a claimant, andfeature_gate_hintin the server binary gains a family. Registration is more thanmodel_line.rs— the server hand-mirrors every line's probe identities in a hint descriptor its own comment says cannot be derived away, so the mirror is tested and not just the probe.detectrequires a unique claimant, which is why the routing regression below matters more than it looks.Detection succeeds and
launchrefuses with the reason. There is no text graph, no weights loader and no step, so the alternative would be serving a partial model.Probe
Fail-closed identity plus structure, following the Gemma 4 pattern of pinning structure rather than widths.
Pinned, because an alternative would need a kernel or serving path this line does not have: the GDN head dims (they are the Triton AOT specialization keys),
head_dim256, the conv kernel ceiling,rope_type: default(no YaRN path),output_gate_type: sigmoid,mamba_ssm_dtype: float32,dtype: bfloat16— so the FP8 sibling fails closed rather than loading as BF16 — and untied embeddings.Deliberately not pinned: expert count, hidden size, layer count, head counts,
hc_count. A smallerqwen4_expcheckpoint should reuse this probe, which is what the L20 small-model deployment case in #1103 needs.smaller_widths_still_passkeeps that promise honest rather than aspirational.Alongside the pinned values the probe refuses the shapes that are unservable rather than merely different: a
num_hidden_layersthat disagrees withlayer_types, a zerohidden_sizeorvocab_size, and anngram_vocab_size_basewith no room for a prime search.mrope_interleavedandmrope_sectionare accepted, not refused: a text-only sequence is their degenerate case in the reference itself, which expands one position per token across all three axes before interleaving, so every rotary pair takes the same position with its own frequency. The decision and the fixtures that would tell it apart are tracked in #1105.Config
Two fields the reference implementation reads are absent from
config.jsonand come from upstream defaults, so both are modelled explicitly with those defaults:norm_topk_prob(true, and it decides whether the router renormalizes its top-k) andseed. A port that reads onlyconfig.jsonsilently drops the router renormalization — this is #1068's mirror image.The rope values come from the nested object the reference reads (
modeling_qwen4_exp.pytakesconfig.rope_parameters["rope_theta"]and[..., "partial_rotary_factor"]), soTextConfigmodels that copy. The checkpoint also carries a legacy top-levelpartial_rotary_factorthat nothing upstream reads; the probe refuses a checkpoint where the two disagree rather than letting readers pick differently.num_experts_per_tokis carried as well — the probe validates0 < k <= num_expertsexactly as the reference's config does, and a router that re-readconfig.jsonon its own would be a second source of truth.The n-gram table's geometry is derived rather than read, because
config.jsonstates none of it:102.4 GB, not the ~51 GB vLLM and SGLang quote — that figure is the fp8 footprint and this checkpoint ships BF16. Every host-RAM budget in the issue set was 2× under until it said which dtype it meant.
The derivation lands here rather than with its first consumer because #1106 needs the geometry independently of any tensor contract, and because it is pure arithmetic over
config.json;nth_prime_after_matches_the_referencepins the prime search against the reference's_find_nth_prime_after, including its lower bound (searching from 0 must not call 1 prime).Crate shape
No CUDA dependency at all, so its 44 tests need no device, no weights and no Triton-equipped build. CI runs them on a CPU runner (
cpu-unit-tests,cpu-clippy). The per-model CUDA matrix also has a row for this line, for the opposite reason — to compile the server side, which is the one part of this PR that cannot be checked on a CPU-only box (see Evidence).Preserving that split is the point of the crate's shape: when slice B adds the device side,
configandprobemust move behind#[cfg(any(feature, test))]the waypegainfer-gemma4/src/lib.rsdoes, not behind a whole-crate#![cfg(feature)]the way Qwen3.5 does — the latter would make even pure-CPU config tests require a Triton build.Nothing outside the test suite consumes the geometry yet, so the crate carries
#![cfg_attr(not(test), allow(dead_code))], with a comment saying it comes off when the first runtime caller lands. Test builds keep the lint, which is how the width-pinning test below came to exist rather than a blanket allow.Evidence
no_other_lines_identity_is_acceptedwalks 13 foreignmodel_typevalues;qwen35_is_not_mis_routed_herepins the specific confusion this line exists to avoid; three#[cfg(not(feature))]tests pin the server's hint mirror (both identities, and that the two lines do not cross-hint), one#[cfg(feature)]test pins that it goes silent once the line is compiled in, and one that it stays silent for an unknown family.tests/frozen_config.jsonagainstFROZEN_CONFIG_SHA256at revisionde4b8e4d, so the fixture cannot drift from the revision it claims to be. The reference implementation itself first ships in transformers 5.16.0 — the checkpoint's declared5.8.0.dev0is what it was authored against, not a release carryingqwen4_exp— andPINNED_TRANSFORMERSnames that, with a test holding the two facts apart.SUPPORTED_GQA_GROUP_SIZESis[1, 2, 3, 4, 8]. Sodecode_group_is_compiled()is false here and captured-graph decode is off the table — the same corner Qwen3.5-27B is in at group 6. Recorded in the doc's Traps rather than left for slice B to discover.cuda-clippymatrix row —feature: qwen38-flash-next,extra_packages: -p pegainfer-server,needs_triton: false— which is what compiles the registry entry and the hint family, plus a step that runs those tests with the feature both ways. Before that row existed the claim rested on a hand-run on L20 / sm_89:cargo check --release --locked -p pegainfer-server --features qwen38-flash-next --all-targets→ exit 0, and the hint tests → 5 passed with the feature on, 7 passed with it off. Note the row only runs once this PR leaves draft:ci.ymlgates the heavy jobs ondraft == false.cargo fmt --all --check,cargo clippy --release --locked -p pegainfer-build -p pegainfer-frontend -p pegainfer-sim -p pegainfer-qwen38-flash-next -p kvbm-logical --all-targets -- -D warnings,cargo test --release --locked -p pegainfer-qwen38-flash-next --lib→ 44 passed.Docs:
docs/models/qwen38-flash-next/bring-up.md(freeze table with file hashes, the measured traps, what is still open) plus itsdocs/index.mdrow.What the pre-submission audit changed
An audit of this branch at
d3bb63ccfound four issues worth fixing and nine worth tightening;0820991bcloses them:5.8.0, which carries noqwen4_exp— repointed to5.16.0, pinned by a test;partial_rotary_factorhad two sources: the probe validated the nested copy whileTextConfigread the legacy top-level one — one source now, and a disagreement is refused;num_experts_per_tokwas validated by the probe but never carried — it is aTextConfigfield now;mropereasoning written into the probe. The sigmoidoutput_gate_typenote inlaunchand in the doc now names feat(kernels): make the gated RMSNorm activation selectable #1129, so neither goes stale when that PR merges.Type of Change
Checklist
docs/conventions/coding-style.md).CLAUDE.md).Test plan
pegainfer-qwen38-flash-next:cpu-unit-tests,cpu-clippy, and the newQwen3.8-Flash-Next CUDA Clippyrow (the last one needs this PR out of draft).cargo run --release -- --model-path <Qwen3.8-Flash-Next>on a stock build should print the--features qwen38-flash-nexthint rather than reporting an unknown model.--features qwen38-flash-nextshould detect the line and then refuse to launch, naming slice B as the reason.cargo check --release --locked -p pegainfer-server --features qwen38-flash-next --all-targets→ exit 0, andcargo test --release --locked -p pegainfer-server --features qwen38-flash-next --bin pegainfer hint→ 5 passed; the same without the feature → 7 passed.