From 06d138033ed7e070d3131baa5e34829026c99a3e Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Mon, 19 Oct 2026 18:26:11 +0800 Subject: [PATCH 01/21] specs: plan the configurable base model (RFC phases 2-5) The implementation series for docs/rfc/model-parameter-configuration.md, charter step 3 of the Vela 2.0 order (the 0.3B reference worker landed as 2812144; every native Vela slice waits on this). Five specs on branch model/configurable-base-model: - 01 ModernBERT family IR, source adapters, capability gate (pure Rust crate omni-modernbert, provenance, StructureIdentity) - 02 Laya native migrated onto the core, zero behavior change, plan-fixture bit-identity, task head stays Laya-side - 03 Vela 0.3B as the second ModernBERT profile: model-local weight mapping, YaRN rope tables fixture-grounded, kernel bundle regeneration with the laya ABI 1->2 decision, parity harness vs the pinned package - 04 task-head ownership boundary: final-hidden-at-positions contract, heads stay in the vela crate - 05 Qwen hybrid config/capability migration: shadow-normalizing facade, QwenAbi6 gate as data, PrefixLayout derivation, loader seams (prefix candidates + per-tensor dtype policy) for the hybrid consumer; ABI 6 untouched Stances recorded on the RFC's open items: Rust-internal IR only, explicit adapter selection, frozen ABI-6 schedule set, bundle fields in spec 03. Evidence base: laya/qwen source at 2812144, the pinned 0.3B package copy, research 017/019, the locked charter. --- specs/model-config/01-modernbert-family-ir.md | 182 +++++++++++++++++ .../02-laya-encoder-core-extraction.md | 117 +++++++++++ .../03-vela-03b-modernbert-profile.md | 190 ++++++++++++++++++ .../04-vela-task-heads-boundary.md | 93 +++++++++ .../05-qwen-hybrid-config-capability-gate.md | 130 ++++++++++++ specs/model-config/README.md | 144 +++++++++++++ 6 files changed, 856 insertions(+) create mode 100644 specs/model-config/01-modernbert-family-ir.md create mode 100644 specs/model-config/02-laya-encoder-core-extraction.md create mode 100644 specs/model-config/03-vela-03b-modernbert-profile.md create mode 100644 specs/model-config/04-vela-task-heads-boundary.md create mode 100644 specs/model-config/05-qwen-hybrid-config-capability-gate.md create mode 100644 specs/model-config/README.md diff --git a/specs/model-config/01-modernbert-family-ir.md b/specs/model-config/01-modernbert-family-ir.md new file mode 100644 index 00000000..71ca3200 --- /dev/null +++ b/specs/model-config/01-modernbert-family-ir.md @@ -0,0 +1,182 @@ +# Spec 01 — ModernBERT family IR, source adapters, capability gate + +Status: draft, local-only (branch `model/configurable-base-model`). +Implements the first half of RFC Phase 2: the typed family layer that +today is scattered across `laya/src/config.rs` ensure-blocks, +`model.rs` constants, and `weights.rs` inventory. + +## Problem + +`src/models/laya/src/config.rs:31-90` reads raw JSON, normalizes the +family, and enforces the one supported profile in a single function; +`src/models/laya/src/model.rs:11` (`const D: usize = 1024`), +`Workspace::sizes` (`:137-162`, hardcoded 2624/4096/2048/1028/256) and +`prepare_encoder` (`:303-513`, `0..28`, `i % 3`) repeat the same +constants as code; `weights.rs:95-163` repeats them again as shapes. +RFC §3 problems 1–3: parsing, geometry and naming are coupled, so a +second ModernBERT profile (Vela 0.3B, frozen as RFC Phase 3) can only +be added by copying a hardcoded loader. There is also nowhere to put +a capability decision that is checkable without CUDA. + +## Solution + +A new pure-Rust crate `src/models/modernbert` (package +`omni-modernbert`, registered in the root workspace) containing the +family layer with **no dependency on `omni-cuda`, libloading, or any +device pointer** (RFC §5.1 boundary rule): + +```text +src/models/modernbert/src/ +├── structure.rs # ModernBertStructure + provenance + family invariants +├── adapter.rs # LayaBundleAdapter, ModernBertConfigAdapter +├── geometry.rs # DerivedGeometry: tensor shapes, widths, scratch inputs +├── gate.rs # CapabilityProfile / backend limits / StructureIdentity +└── plan.rs # named-kernel encoder plan builder (pure data) +``` + +No consumer changes in this spec: Laya keeps compiling exactly as +today; the crate lands with its own CPU tests and two recorded +structure fixtures (Laya profile, Vela 0.3B profile). Spec 02 performs +the Laya migration onto it. + +## Design + +### `ModernBertStructure` (structure.rs) + +One type for the whole family; every field carries provenance: + +```rust +pub struct ModernBertStructure { + pub hidden: usize, pub intermediate: usize, pub num_layers: usize, + pub activation: Activation, // Gelu (only family member today) + pub norm_eps: f32, + pub attention: AttentionSpec, // heads; head_dim derived; bias=false + pub schedule: Vec, // normalized Full/Sliding array + pub local_window: usize, + pub rope: PerKindRope, // theta per kind + optional YarnScaling + pub vocab: usize, + pub type_embedding_rows: Option, + pub max_positions: usize, + pub provenance: ProvenanceMap, // field → (source_path | default | derived) +} +``` + +Family invariants enforced at construction (fail with the field and +actual value, never silently): + +- `heads * head_dim == hidden` (holds for both profiles: 16·64=1024, + 12·64=768; `head_dim` is derived unless the source states it); +- `schedule.len() == num_layers`, kinds ∈ {Full, Sliding}; +- `local_window > 0`, `intermediate > 0`, `vocab > 0`, + `norm_eps > 0`, theta > 0 and finite per kind; +- `activation == Gelu`, `attention_bias == mlp_bias == norm_bias == + false` (family constants — a source that sets them is a different + family, rejected here); +- if YarnScaling present: `factor > 1`, `original_max_positions > 0`. + +`mlp.Wi = [2·intermediate, hidden]` is **derived** (RFC §6: the 5248 +in the Laya checkpoint is not a semantic field). + +### Source adapters (adapter.rs) + +The model crate picks its adapter explicitly; each adapter validates +its own source markers (README decision 2): + +- `LayaBundleAdapter` — reads `rl_agent_config.json` (only to reject + on missing/invalid, agent fields stay Laya-side) and + `encoder/config.json`: requires `model_type == "modernbert"`, + `hidden_activation == "gelu"`, bias flags false; reads the explicit + `layer_types` array verbatim into `schedule` (provenance: + `layer_types[i]`); reads both `rope_parameters[kind].rope_theta`. +- `ModernBertConfigAdapter` — reads any HF-ModernBERT-style config + object (the Vela 0.3B case: the `encoder_config` member of the + vela2-unified root; requires inner `model_type == + "modernbert"`): **derives** `schedule` from + `global_attn_every_n_layers` (full at i % n == 0 — provenance marks + it derived with the source field name), reads per-kind thetas + (`global_rope_theta` / `local_rope_theta`), maps `rope_scaling` + (`rope_type == "yarn"`, `factor`, `original_max_position_embeddings`) + into `YarnScaling`, and treats `position_embedding_type == + "sans_pos"` and absent type-vocab as `type_embedding_rows: None`. + +Both record `source.config_files` in the provenance map (RFC §5.2). +Unknown top-level fields are ignored but listed in the provenance +summary (explicit policy, no silent misread — the adapter must never +accept a `max_length`-style recipe limit as a structure field; the +Vela root's `max_length: 8192` is worker config, not encoder +geometry). + +### DerivedGeometry (geometry.rs) + +Pure functions of the structure: `qkv = [3·hidden, hidden]`, +`attn_out = [hidden, hidden]`, `mlp.Wi = [2·intermediate, hidden]`, +`mlp.Wo = [hidden, intermediate]`, `rotary_half = head_dim / 2` (32 +for both profiles), rope table bytes `max_positions_exercised · +rotary_half · 4`, and the encoder scratch size formula parameterized +by (b, l, structure) — replacing `Workspace::sizes`' hardcoded +`m * D * 6` / `m * 2624 * 2` arithmetic with structure-derived terms +(task-specific buffers stay with the model, spec 02). + +### Capability gate (gate.rs) + +Two levels, both CPU-only, both answering with actual-vs-required: + +- `BackendLimits` — what one CUDA bundle supports: instantiated hidden + sizes, `(b, l)` envelope (today: b power-of-two ≤ 16, 16 ≤ l ≤ 512, + l % 16 == 0), rope policy (plain-theta tables only), dtype policy. +- `RegisteredProfile` — a named, frozen structure+limits pair. First + registration: `laya-0.3.20` (the exact constants of + `config.rs:50-79` as data). Acceptance = family invariants ∧ + backend limits ∧ (if a registered profile is requested) structure + equality with a field-diff on mismatch. + +`StructureIdentity` (RFC §8): a digest over `schema_version`, family +tag, canonical structure serialization, and the backend/ABI tag. +Defined here, consumed by cache/identity decisions later; no caller +yet — landing it now is cheap and fixes the format before two +profiles exist. + +### Plan builder (plan.rs) + +`encoder_plan(&structure, opts) -> Vec` reproduces the +encoder half of today's `prepare_encoder` sequence (embed → per-layer +qkv/rope/attn/out/addln/geglu/down/addln → final norm → optional +type) emitting **named kernel names + argument roles** as pure data — +no `omni_cuda::Kernel` resolution, which stays with the consumer +(Laya keeps its `SelectedKernel` resolver). This keeps the core crate +CUDA-free while making the execution order fixture-testable. + +## Evidence & tests (all CPU, in-crate) + +1. Adapter provenance matrix: present/missing/type-error/defaulted + fields for both adapters; derived-schedule case (22 entries, 8 + full) provenance-checked. +2. Invariant rejections: each violated invariant fails with the field + and actual value. +3. Gate matrix: `laya-0.3.20` fixture accepted; each single-field + mutation (hidden 768 against the ABI-1 limits, theta drift, + schedule edit, window 127) rejected with a diff naming both + values; raw-JSON inputs never touch CUDA (crate has no such dep — + structurally guaranteed). +4. `StructureIdentity`: same config → same digest; any one field + changed → different digest. +5. Plan fixtures: `encoder_plan` on the Laya structure produces the + exact step sequence of `prepare_encoder`'s encoder half (recorded + as a golden fixture; spec 02 makes the live comparison). + +## Acceptance + +- `omni-modernbert` compiles with zero device dependencies and lands + in the workspace; no existing crate changes behavior; full existing + test suite stays green. +- The two structure fixtures (Laya, Vela 0.3B) round-trip through + their adapters with complete provenance, and the gate's + accept/reject matrix is recorded as tests. + +## Open items (resolved by later specs, noted here) + +- The regenerated-bundle limits for the 768-hidden profile (spec 03) + register as a second `BackendLimits` + profile entry; the gate API + must make that a data addition, not a code change. +- `schema_version` starts at 1; bump rules recorded in structure.rs + docs when the first bump happens. diff --git a/specs/model-config/02-laya-encoder-core-extraction.md b/specs/model-config/02-laya-encoder-core-extraction.md new file mode 100644 index 00000000..f96ae2cf --- /dev/null +++ b/specs/model-config/02-laya-encoder-core-extraction.md @@ -0,0 +1,117 @@ +# Spec 02 — Laya native on the extracted encoder core + +Status: draft, local-only. Second half of RFC Phase 2: migrate the +Laya native path onto spec 01's core with **zero behavior change**, +keeping Laya as the running proof that the extracted core serves a +real profile (RFC Phase-2 deliverable: "Laya 仍能以原 profile 运行"). + +## Problem + +After spec 01 the family layer exists but Laya still executes its own +copies: `prepare_encoder` hardcodes the 28-layer / i%3 / D=1024 plan +(`model.rs:303-513`), `Workspace::sizes` hardcodes +2624/4096/2048/1028/256 (`:137-162`), `infer` hardcodes vocab 50368 +(`:679`), and `checkpoint_tensors()` hardcodes the inventory +(`weights.rs:95-163`). The Laya agent task head (the `head.layers.0..2` +loop, scorer, act_head) is interleaved with the encoder plan in one +function — exactly the backbone/task coupling the RFC forbids +extending. + +## Solution + +Laya becomes a consumer of `omni-modernbert`; its public surface +(`Config::load`, `Model::load*`, `infer`, `ModelOutput`), its CUDA +bundle, and its ABI stay untouched. Migration is internal: + +1. **Config facade** (`config.rs`): `Config::load(dir)` keeps its + signature and public fields (`AgentConfig`, `EncoderConfig`). + Internally: `LayaBundleAdapter::read(dir)` → `ModernBertStructure` + → `laya-0.3.20` profile validation via the gate (the ensure-set of + `config.rs:46-79` moves into the registered profile) → projection + back onto the old public fields. Agent-config parsing and its + validations (max_len 512 / head_max_len 192 / head_layers 2, + temperatures) stay in the Laya crate verbatim — they are Laya task + bundle, not encoder fields (RFC §2.4). +2. **Plan split** (`model.rs`): `prepare_encoder` decomposes into + - `modernbert::plan::encoder_plan(&structure, rope_variant)` — + the embed → 28 layers → final-norm → type steps, produced from + the IR (spec 01 §plan builder); the `original_rope` flag remains + a Laya serving option passed through as a plan option; + - a Laya-owned `head_plan()` — the `head.layers.{0,1}` loop and + everything after the encoder (type consume, scorer/act_head + launches stay in `infer` as today). + The two plans concatenate into the same `Vec` the executor + consumes; `Step`/`Argument`/`SelectedKernel` stay in Laya (they + are the CUDA-facing resolution layer, not family data). +3. **Workspace**: encoder scratch sizes come from + `geometry.rs` formulas; the task-side buffers (head ffn 4·D, head + rows 2048, action vocab 1028, action hidden 256, marker caps) are + sized by a Laya-side layout function appended to the encoder + layout. The single-`Workspace` struct and the graph-cache/ + eager-fallback logic (`model.rs:198-244, 706-768`) are unchanged. +4. **Weight inventory**: `checkpoint_tensors()` = core-generated + encoder inventory (canonical names + `encoder.embeddings.tok_embeddings.weight`, + `encoder.layers.{i}.{attn.Wqkv,attn.Wo,mlp.Wi,mlp.Wo}.weight`, + norms, `encoder.final_norm.weight`, `type_emb.weight`; shapes from + `DerivedGeometry`) **concatenated with** the Laya task inventory + (head.layers.{0,1}, scorer, act_head, temperature — + `weights.rs:123-161` stays verbatim). `validate_names` still runs + over the union inside Laya's own load path (RFC §7: the model's + loader owns external→internal mapping and missing/extra checks; + Laya's mapping is the identity, which is precisely why it is the + reuse baseline). +5. **Runtime bounds**: `infer`'s `*i < 50368` check reads + `structure.vocab`; the `(b, l)` envelope comes from the profile's + backend limits instead of the inline `ensure!`s (`model.rs:676-709` + keeps its packing checks — they are request validation, not + structure). + +Unchanged on purpose: `storage_dtype` (the per-class f16/bf16/f32 +storage policy is Laya's load policy, owned here), the bundle's rope +`.f32` tables and their 512×32 check (replaced by a +limits-consistency check, same numbers), `LAYA_VERIFY_WEIGHTS` +read-back, dump hooks, `serve.rs`/`executor.rs` — no serving-surface +change of any kind. + +## Evidence & tests + +1. **Plan bit-identity fixture**: serialize the full + `Vec` (kernel name, argument kinds, counts) for the Laya + profile before and after the refactor; assert equality. Recorded + once as a golden fixture so any later core change that alters + Laya's execution order fails CPU CI (seam C). +2. Existing suites stay green unchanged: `tests/laya/{checkpoint, + weights, preprocess, packing, decision, batch, model}.rs` — same + fixtures, same expectations. +3. Device-gated (seam D): `tests/cuda/laya_runtime.rs` and the + graph-capture path re-run on available hardware; parity with the + pre-refactor outputs on the existing fixtures (bit-identical + expected: same kernels, same order, same bytes). +4. Negative tests: the profile gate's reject matrix from spec 01 is + exercised through the real `Config::load` (mutated fixture configs + produce the same refusals as today, now with field-diff errors). + +## Acceptance + +- `cargo test` (CPU) fully green with no fixture edits; the plan + fixture proves execution-order identity; GPU regression recorded + when hardware is available, reported with hardware line per repo + convention. +- `git grep` shows no remaining profile constants in + `src/models/laya/src/` outside the registered-profile data and the + task-head code path (D=1024 survives only where it is genuinely + task geometry, e.g. head ffn, act_head dims — each tagged with the + task bundle in review). +- ABI 1 unchanged; `laya.backend.json` untouched; no rebuild of the + CUDA bundle required by this spec. + +## Risks + +- **Plan reorder by accident** (e.g. weight-pointer resolution + interplay) — mitigated by the plan fixture and unchanged + `upload_order` discipline. +- **Workspace layout drift** changing graph-cache shape keys — the + cache keys on `(b, l)`, and total sizes must equal the old formula + for the Laya profile (asserted in a unit test against the recorded + numbers). diff --git a/specs/model-config/03-vela-03b-modernbert-profile.md b/specs/model-config/03-vela-03b-modernbert-profile.md new file mode 100644 index 00000000..4a5fd8e2 --- /dev/null +++ b/specs/model-config/03-vela-03b-modernbert-profile.md @@ -0,0 +1,190 @@ +# Spec 03 — Vela 0.3B as the second ModernBERT profile + +Status: draft, local-only. RFC Phase 3: wire the pinned +`vllm-sr/Vela-2.0-0.3B@d6f03aa9` encoder into the extracted core as a +**profile, not a new family** (frozen decision: "Vela 0.3B 是 +ModernBERT 的第二个 profile,不是独立 encoder family"). Deliverable +per the RFC: native profile parity with the frozen reference contract +on the encoder surface (final hidden at requested positions). + +## Problem + +The reference worker (commit `2812144`) froze the behavioral contract +but executes the upstream Python package. A native profile needs: the +0.3B structure accepted through the family layer (it differs from +Laya in every dimension — see README table), its all-F32 checkpoint +mapped and validated, YaRN-scaled rotary tables the current bundle +does not produce, and a CUDA bundle whose kernels are generated for +1024-hidden geometry only (`model_ops.cu` hardcodes 1024/1028; +TileLang GEMMs are instantiated per N/K). None of this exists; doing +it inside Laya would recreate the coupling Phase 2 just removed. + +## Solution + +### 1. Consumer crate + +New Rust crate `src/models/vela/native` (package `omni-vela-native`) +depending on `omni-modernbert` and `omni-cuda` (feature-gated like +`omni-laya`). It owns, in this spec: the profile registration, the +model-local weight loading (RFC §7 ownership), rope-table +preparation, and the encoder execution against the regenerated +bundle. Task heads (spec 04), worker/serving surfaces (charter +slices), and the hybrid (Qwen-backboned) members are explicitly not +here. + +### 2. Profile registration and adapter wiring + +`ModernBertConfigAdapter` (spec 01) already parses the 0.3B +`encoder_config`. This spec registers `vela-2.0-0.3b`: structure +validated by the family invariants, then checked against the +**regenerated bundle's** `BackendLimits` (hidden 768 instantiated; +b/l envelope; rope policy = yarn allowed; dtype policy below). The +derived schedule (22 kinds, full at i%3==0) must be cross-checked +once against the pinned package's encoder construction and recorded +as a fixture — `global_attn_every_n_layers` is read as data, never +re-derived from habit. + +### 3. Weight loading (model-local, no WeightContract) + +The pinned docstring fixes the naming discipline: parameter names are +exactly the training checkpoint's, `model.safetensors` strict-loads, +encoder = stock `ModernBertModel` under `encoder.*`. That is the same +canonical ModernBERT naming Laya uses, minus `type_emb`, at 768 +geometry. The vela crate's `Weights::load`: + +- maps `encoder.embeddings.tok_embeddings.weight`, + `encoder.layers.{i}.{attn_norm,attn.Wqkv,attn.Wo,mlp.Wi,mlp.Wo, + mlp_norm}`, `encoder.final_norm.weight` (external == internal for + the encoder half); +- validates shape against `DerivedGeometry` (vocab 256008 · hidden + 768; qkv [2304, 768]; Wi [2304, 768]; Wo [768, 1152]; …) and dtype + (F32 source), with missing/extra/duplicate failure listing both + sets — the laya `validate_names` discipline, re-owned here; +- applies the profile's **storage policy**: GEMM-class tensors cast + to bf16 at upload; norms and embeddings kept f32/f16 per class — + mirroring Laya's `storage_dtype` classes. The F32→bf16 rounding is + a declared numerics choice carried in `StructureIdentity`; its + parity cost is measured, not assumed (below). + +First implementation step before any wiring: census the pinned +checkpoint's tensor inventory and dtypes from the safetensors header +and record it as the fixture this loader must satisfy (names are +evidence, not assumption — the local survey copy carries code+config +only, not weights). + +### 4. Rotary tables (YaRN ×4) + +The only genuinely new encoder math. Served positions stay ≤ 8192 = +`original_max_position_embeddings`, but YaRN still rescales the +frequency basis (ramp between `beta_fast` 32 / `beta_slow` 1, factor +4, truncate beyond 32 768) — the tables are **not** plain +theta-160000 tables even inside the original window. Plan: + +- compute cos/sin (rotary_half = 32 per kind; both kinds share theta + 160000 and the scaling) CPU-side in f32 from the IR's `YarnScaling` + at load time — deterministic, no extra bundle artifact; +- ground them against the pinned package: run the package's own + rotary path on CPU/FP32, dump tables at sampled positions (0, 1, + mid, 8191/8192 boundary, 16 384, 32 767) into fixtures, and gate + the Rust generator on those fixtures. Any attention-scale factor + the package applies with YaRN (mscale-style) must be discovered + from the pinned `transformers` version's rotary implementation and + either baked into the tables or passed to the attention kernel — + recorded as part of the fixture evidence, not guessed. + +### 5. Kernel bundle regeneration + the ABI decision + +The one kernel-scope item in the whole series (everything else is +Rust): + +- **TileLang instantiations** for 768-hidden geometry: gemm + (N, K) ∈ {(2304, 768), (768, 768), (1152, 768)} + geglu F=1152 + + down (768, 1152); attention base kernels already take (b, l) at + launch — verify the 12-head / Dh=64 / window-128 instantiations + accept l up to 8192 (shared-mem tiling bounds), adding bucket + specializations (`attn_full_l8192`-style, resolved like today's + `_b1_l512` variants) if the generic path does not hold; +- **`model_ops.cu` de-hardcoding**: `embed_norm` / `gather_norm` / + `add_type` take D as a runtime argument instead of `1024`; + `laya_linear`'s `n ≤ 1024` guard becomes a limits-derived bound; + `action_features`'s 1028/1024 stays Laya-task-local; +- **ABI**: this changes kernel sources and the exported bundle ⇒ + bump `laya.backend.json` `abi_version` 1 → 2 and rebuild. Blast + radius is the laya backend's consumers only (Laya + this crate) — + unlike the Qwen ABI, no sibling coordination. A separate vela + backend manifest reusing the same sources was considered and + rejected as default: one source tree, one honest ABI ledger. Rust + -only changes elsewhere in this series do not bump anything (RFC + §8 rule, restated in spec 05 for Qwen). + +### 6. Runtime envelope + +First slice mirrors the reference worker's execution shape: +serialized single request, one encoder forward per rendered sequence, +final hidden gathered at the marker/requested positions (the RFC's +position-level readout; no per-layer extraction). Registered limits: +b ∈ {1, 2, 4}, l buckets {512 … 8192} (l % 16), vocab bound from the +structure. Workspace sizing via the core geometry formulas; graph +capture per (b, l) bucket reuses the Laya workspace-cache pattern if +it pays for itself — optional, evidence-gated. + +### 7. Parity harness (seam B) + +Extend the `recipe/vela` harness pattern: drive the **pinned +package** (CPU, FP32, the charter oracle) to dump final hidden at +requested positions for a fixture prompt set (reusing the reference +worker's frozen example/rejection fixtures where they exercise the +encoder); run the native profile over the same token ids; report +max/rms deltas. Tolerance gates are declared at implementation, +anchored to the upstream noise floor (≈2.9e-3 max logit, charter §3) +— the bf16 GEMM storage policy's cost shows up here and is reported, +not averaged away. A device-gated variant runs the native side on +GPU with hardware reported (seam D). + +## Evidence & tests + +1. CPU: adapter→IR→gate accept for `vela-2.0-0.3b`; reject matrix + (hidden 1024 against the 768-instantiated limits, schedule edit, + yarn factor drift) with field diffs. +2. CPU: weight-mapping tests against a synthetic safetensors fixture + built from the recorded census (missing/extra/duplicate/shape/ + dtype each fail loudly); storage-policy class assignment table. +3. CPU: rope-table fixtures vs the pinned package at the sampled + positions (§4). +4. CPU: plan fixture for the vela profile (22 layers, kind sequence, + no type step) — same golden-fixture discipline as spec 02. +5. GPU (device-gated): encoder forward on real weights; parity + report vs the CPU-FP32 oracle per §7; two consecutive runs + identical. + +## Acceptance + +- The 0.3B encoder runs natively with its own profile through the + shared core; Laya still passes everything (spec 02's fixtures + untouched) — the two profiles share the core and nothing else. +- Parity report recorded with tested hardware; failures or + unexercised checks disclosed per repo convention. + +## Out of scope (fences) + +Vela task heads (spec 04 — they only consume the final-hidden +contract), the native worker/serving/frontend slice, ONNX options, +the hybrid members (they ride spec 05 + charter slice 4), any change +to Laya's numerics or bundle behavior beyond the shared-bundle +regeneration (Laya's own instantiations and outputs must remain +bit-identical — verified by spec 02's GPU regression re-running on +the ABI-2 bundle). + +## Risks + +- **YaRN subtleties** (mscale, ramp conventions differing between the + pinned transformers version and our generator) — mitigated by + fixture-grounding against the package, not the HF docs. +- **Generic attention kernel failing at l=8192** — bucket + specializations are the planned fallback; if even those are + unsound, the profile's first slice can register l ≤ 4096 while the + windowing plan (charter §3's ≤1800-token windows) covers the 8192 + budget — a limits decision, not a family change. +- **bf16 storage rounding** moving low-margin answers — measured by + the parity harness before any tolerance is frozen; switching the + policy is a `StructureIdentity` change, visible by construction. diff --git a/specs/model-config/04-vela-task-heads-boundary.md b/specs/model-config/04-vela-task-heads-boundary.md new file mode 100644 index 00000000..6fae0e4d --- /dev/null +++ b/specs/model-config/04-vela-task-heads-boundary.md @@ -0,0 +1,93 @@ +# Spec 04 — Vela task-head ownership boundary + +Status: draft, local-only. RFC Phase 4, scoped as a **boundary spec**: +it fixes what the shared core guarantees to heads and what stays +Vela-owned, and sizes the first head work. The full worker +integration (wire reuse, serving, recipes) is a charter slice that +consumes this; nothing here touches `omni-modernbert`. + +## Problem + +The Vela readouts are the model's value: the 0.3B "both" readout +(cosine + MLP summed, proj dim 256), the word-unit span readout +(normalize(W_t·LN(h[first sub-word])) · normalize(W_e·LN(h[E])) / +τ_span), per-member calibration (τ, τ_span, thresholds from the +member's own `calibration.json`). The RFC froze that none of this +enters the ModernBERT core, and that heads load through the model's +own path. Without a written contract, head work drifts into the core +or quietly redefines the encoder's output obligations. + +## Solution + +### The core→head contract (all the heads may assume) + +1. Final hidden states at **requested token positions** of the last + encoder layer, after final norm, in f32 — nothing else (no + per-layer hidden, no pre-norm states; RFC §5.4). +2. The position set is the head's own declaration (markers, word + first-sub-words, [E] anchor), resolved before the encoder forward; + the encoder does not interpret positions. +3. `StructureIdentity` of the profile (the heads' weights are + geometry-dependent: proj 256 × hidden 768) is available for + load-time compatibility checks. + +### Vela-owned (in `src/models/vela/native`, beside spec 03's loader) + +- **Head tensors** (0.3B: the readout/span parameters inside + `model.safetensors`; hybrids later side-load `broad_head. + safetensors` — that pattern lands with the hybrid slice): loaded + F32 exact-shape in the vela crate's own path, omnijev + `heads.rs`-style (`src/models/omnijev/native/src/heads.rs:121-240` + prior art), with sha256 pinning per file once a second file exists + (cua_s1 manifest pattern). Missing/extra/shape/dtype failures list + both sides — same discipline as the encoder inventory. +- **Readout computation**: F32 on GPU or CPU (first slice: CPU over + the downloaded hiddens — the reference disables autocast inside + heads, matching), cosine+MLP summed logits, τ/τ_span tempering, + span word readout, threshold application (strict `>`, + per-question override echo) — semantics are the frozen reference + contract (`src/models/vela/postprocess.py` + the pinned package); + this spec adds no new semantics, only the native implementation + surface. +- **Calibration/threshold loading**: per-member + `calibration.json`, never cross-copied (charter §2 rule); loaded + beside the checkpoint, validated against the member's identity. + +### Explicitly forbidden (fence the review against) + +- Any head tensor, temperature, threshold, or readout concept in + `omni-modernbert`; any encoder IR field that exists only for a + head; any head reading encoder internals other than contract item + 1; any MultimodalInput-adjacent surface (text/token_ids only). + +## Evidence & tests + +1. CPU: head-tensor inventory tests (synthetic fixture; missing/ + extra/duplicate/shape/dtype rejections). +2. CPU: readout parity on **recorded hidden fixtures** — run the + pinned package on the spec-03 prompt set, dump final hidden + + the package's own head outputs; the native head over the same + hiddens must reproduce the outputs within the same tolerance + discipline (exact where the math is exact; float-tolerance where + the package itself computes in f32 — declared with the fixtures). + This pins head numerics independently of the encoder (valen + seam-C precedent). +3. Negative: a core change that alters the final-hidden contract + (dtype, norm placement, position semantics) must fail these + fixtures — the fence is testable, not aspirational. + +## Acceptance + +- Heads load, compute, and match the package on the hidden fixtures; + `omni-modernbert` diff is empty across the whole head PR. +- The boundary survives review: any core-file touch in the head + implementation is a spec violation by construction. + +## Note on the hybrid members + +The hybrid heads (CandidateHead, SpanHeadV2 ×2, set_bias; F32, +side-file `broad_head.safetensors`) obey this same boundary when the +hybrid native slice lands after spec 05 — same contract against the +Qwen backbone's final hidden at requested positions, same model-local +loading. No forward work is taken here beyond keeping the contract +wording backbone-agnostic. diff --git a/specs/model-config/05-qwen-hybrid-config-capability-gate.md b/specs/model-config/05-qwen-hybrid-config-capability-gate.md new file mode 100644 index 00000000..4774bb83 --- /dev/null +++ b/specs/model-config/05-qwen-hybrid-config-capability-gate.md @@ -0,0 +1,130 @@ +# Spec 05 — Qwen hybrid config/capability migration + +Status: draft, local-only. RFC Phase 5: separate source parsing, +family normalization, and kernel capability inside the existing +`src/models/qwen3_5/native` family crate — the vehicle the Vela +hybrid members' native route rides (charter: "0.8B native rides the +Phase-5 outputs"). **No gate constant, kernel, or ABI change**: the +frozen ABI-6 reject set (RFC §5.3) is expressed as data, not widened. + +## Problem + +`Config::load` (`src/models/qwen3_5/native/src/model.rs:49-180`) +simultaneously reads JSON, normalizes the family, and enforces +kernel capability via inline `ensure!`s (silu, output gate, no bias, +conv kernel 4, head dims 256/128/128, rotary_half 32, interleaved +mrope summing to 32, heads divisibility, hidden % 8) — RFC §2.1's +"固定能力边界". Consequences: capability decisions are not checkable +without constructing a `Config` from a directory; the Vela hybrid +consumer's three known trips (config location `backbone_config`, +tensor prefix `backbone.`, per-tensor mixed dtype — research 019, +gate table C1/W1/W2) have no seam to plug into without forking the +loader; and `PrefixState` sizing (full K/V per full layer, F32 +[H,K,V] GDN state + conv tail, `model.rs:557-588`) is implicit in +`alloc_prefix` rather than derived from structure where it can be +CPU-tested. + +## Solution + +All inside the family crate (no new crate — the family owns its IR): + +1. **`QwenHybridStructure` IR**: today's `Config` public fields plus + the ordered `layer_kinds` (Full/GDN) with provenance, split per + RFC §6 (attention: heads/kv_heads/head_dim/rotary_half/rope_theta/ + mrope_section/output-gate; GDN: lin_k/lin_v heads and dims, conv + kernel; common: hidden/intermediate/layers/eps/max_positions/ + activation). Constructed only by a source adapter. +2. **Source adapter**: exactly today's reading — root or + `text_config` (`model.rs:55`), `layer_types` strings with + length == `num_hidden_layers`. It parses and records provenance; + it does not judge capability. +3. **`QwenAbi6` capability gate**: the complete current ensure-set + as typed checks over the IR, each rejection reporting actual vs + required. Content-frozen: the accepted set after this spec equals + the accepted set before it (fixture-proven below). +4. **`Config::load` facade**: adapter → invariants → gate → project + onto the unchanged public `Config` fields (shadow-normalize, + RFC §8 migration rule). All five consumers (qwen workers, + jev_vl, cua_s1, open_jev, omnijev) compile and behave identically + — public API untouched, ABI 6 untouched, no rebuild required. +5. **`PrefixLayout` derived geometry**: per full-attention layer + (post-prep K/raw V bytes at head_dim 256) and per GDN layer (F32 + [H,K,V] recurrent state + three conv-tail rows at conv kernel 4), + computed from the IR and CPU-testable; `alloc_prefix` consumes + the derived layout (identical numbers, now asserted by tests + instead of implied). The 64-token boundary and `max_positions` + cap stay in `alloc_prefix` as today. +6. **Loader seams for the hybrid consumer** (used by Vela's crate, + charter ticket 030 ownership — nothing Vela-specific lives here): + - `Config::from_structure(ir)` — a constructor accepting an + already-parsed structure, so the Vela source adapter can + extract `backbone_config` from its bundle and feed the family + gate directly; + - `Weights::load` gains optional **prefix candidates** (default: + the current three, `model.rs:273-281`) and a **per-tensor + dtype policy** (default: uniform BF16, today's `ensure!` at + `model.rs:291-295`). The Vela consumer passes the `backbone.` + candidate and the faithful mixed policy (Linear+conv1d BF16, + embed/norms/A_log/dt_bias F32, cast at upload) — per-tensor + faithful loading per the charter; export re-save stays the + recorded fallback. Rust-only, no C symbol or layout change ⇒ + **no ABI bump** (RFC §8 rule). + - `StructureIdentity` (the spec-01 pattern, family tag `qwen3_5`): + normalized schema version, structure digest, weight-mapping + digest, dtype policy, backend, ABI 6. Prefix-cache keys/records + carry it; any change forces re-capture instead of reuse + (jev_vl's cross-request cache is the consumer to audit). + +## Evidence & tests (all CPU unless noted) + +1. **Shadow equivalence**: every existing config fixture — + `tests/qwen3_5/config.rs` + `tests/qwen3_5/data/config.json` and + each consumer's pinned configs — produces byte-equal public + `Config` fields through the facade (recorded before/after). +2. **Gate matrix**: the frozen reject set (head_dim ≠ 256, lin dims + ≠ 128, conv ≠ 4, non-silu, output gate off, bias on, non-default + rope, non-interleaved mrope, sections ≠ 32 sum, bad max positions, + heads % kv, lin_v % lin_k, hidden % 8) each rejected with + actual-vs-required; plus the accept side using the three Vela + hybrid backbone geometries as fixtures (0.8B: 1024/3584/24 layers + 18+6, 8+2 heads, 16/16 GDN; 4B: 2560/9216/32, 16+4, 16/32; 9B: + 4096/12288/32, 16+4, 16/32 — research 019 §2 table, which already + established all three pass every gate). Raw config inputs never + approach CUDA: gate tests construct IR directly (seam C). +3. **PrefixLayout**: derived sizes equal the current implicit + allocations for the fixture geometries (unit-asserted against + recorded numbers per size). +4. **Consumer regression**: jev_vl / cua_s1 / open_jev / omnijev test + suites green; `PrefixState` capture/continue device-gated tests + (existing) re-run where hardware allows (seam D). +5. Loader-seam tests: mixed-policy loading on a synthetic sharded + fixture (BF16 Linear class + F32 norm class) with missing/extra/ + shape/dtype rejections; prefix-candidate selection including the + `backbone.` case excluding in-shard head tensors by prefix. + +## Acceptance + +- Behavior-preserving by construction and by fixtures: no consumer + change, no gate widening, ABI 6 and all kernel contracts untouched. +- The three Vela hybrid geometries pass the gate as pure CPU tests — + the Phase-5 output the hybrid native slice (charter step ④) + consumes with only Vela-side source-adapter and loader work left. + +## Out of scope + +The Vela hybrid consumer crate itself (charter slice 4: execution +plan over `alloc_prefix` capture/continue, windowing, heads), any +arbitrary Full/GDN schedule opening (README decision 3), M-RoPE or +modal behavior changes (opaque per RFC §5.2), prefix-state disk +persistence, and anything touching `src/backends/cuda/qwen3_5`. + +## Risks + +- **Facade drift** (a field quietly re-derived differently) — + shadow-equivalence fixtures are the guard; run them first in the + PR. +- **Co-editing window**: the family crate has live sibling PRs + (charter §"Co-edit surface"); this refactor lands as a pure + internal move with zero API delta to minimize rebase pain, and + coordinates timing if an ABI-window PR is in flight (no ABI + interaction here, but `model.rs` is shared ground). diff --git a/specs/model-config/README.md b/specs/model-config/README.md new file mode 100644 index 00000000..f82d9d06 --- /dev/null +++ b/specs/model-config/README.md @@ -0,0 +1,144 @@ +# Configurable base-model specs (RFC implementation, Phases 2–5) + +Specs for implementing the configurable base model defined by +`docs/rfc/model-parameter-configuration.md` (Final). This is charter +step ③ of the Vela 2.0 integration order: the 0.3B reference worker +(step ①) landed as `2812144`; the RFC implementation is the +prerequisite every native Vela slice waits on — the 0.3B ModernBERT +native profile rides Phases 2–4, the 0.8B/4B/9B Qwen-hybrid native +route rides Phase 5. + +**Local and untracked-of-record** until promoted: this series is +committed on branch `model/configurable-base-model` only; promotion to +tracker issues happens spec by spec after the user approves, same rule +as `specs/valen/`. + +## Index + +| Spec | Title | RFC phase | Depends on | +| --- | --- | --- | --- | +| [01](01-modernbert-family-ir.md) | ModernBERT family IR, source adapters, capability gate | 2 (first half) | — | +| [02](02-laya-encoder-core-extraction.md) | Laya native on the extracted encoder core | 2 (second half) | 01 | +| [03](03-vela-03b-modernbert-profile.md) | Vela 0.3B as the second ModernBERT profile | 3 | 02 | +| [04](04-vela-task-heads-boundary.md) | Vela task-head ownership boundary | 4 | 03 | +| [05](05-qwen-hybrid-config-capability-gate.md) | Qwen hybrid config/capability migration | 5 | 01 (pattern); independent of 02–04 | + +PR order on this branch: 01 → 02 → 03 → 04, with 05 parallel after 01. +RFC Phase 6 (unified facade sweep + release evidence) is a follow-on +after the Vela slices consume these outputs; it is deliberately not +spec'd here. + +## What this series does NOT do + +Frozen-boundary fence (RFC §11, charter §8) — no spec below may: +introduce a WeightContract/WeightResolver (weight mapping stays in each +model's own `load_weight`/`Weights::load`), move task heads or their +tensors into a shared core, extend `MultimodalInput` (text/token_ids +only), promise per-layer hidden extraction (final hidden at requested +positions only, reusing #129/#134), reopen the Laya-baseline or +ModernBERT-profile decisions, or change any Qwen gate constant / +kernel / ABI (ABI 6 stays). + +Also out: the Vela workers themselves (HTTP, frontend, recipes — +charter slices that consume these outputs), ONNX serving options, +quantization, and any Qwen-hybrid native executor changes. + +## Decisions taken on the RFC's "Not yet specified" list + +The RFC left four execution-stage questions open (§"Not yet +specified"); this series takes these stances: + +1. **Normalized schema = Rust-internal IR only.** No operator-facing + manifest file is introduced; `schema_version` stays an internal + field of the IR. Revisit only if a non-Rust consumer appears. +2. **`family` is chosen explicitly by the adapter the model crate + constructs** — never inferred silently from `model_type`. Each + adapter validates its own source markers (`model_type` etc.) and + produces a family IR; a mismatch fails with source evidence. +3. **Qwen keeps the frozen ABI-6 capability set** (RFC §5.3): no + arbitrary Full/GDN schedule opening in this series. The gate + expresses today's reject set as data; widening it is a future, + benchmark-backed decision. +4. **Bundle layout and profile fields** are defined in spec 03 (the + only profile that needs a new bundle); `StructureIdentity` is + defined once in spec 01 and consumed wherever identity matters. + +## The two profiles this series makes configurable (evidence) + +Both are ModernBERT-family encoders with identical naming discipline +(HF ModernBERT tensor names); every number below is read from source, +not assumed: + +| Field | Laya profile (`rl_agent_config.json` + `encoder/config.json`) | Vela 0.3B profile (`encoder_config` in the pinned repo config) | +| --- | --- | --- | +| hidden / intermediate | 1024 / 2624 | 768 / 1152 | +| layers / heads / head_dim | 28 / 16 / 64 (derived) | 22 / 12 / 64 (derived) | +| schedule | explicit `layer_types` array, full at i%3==0 | `global_attn_every_n_layers: 3` (derived array, full at i%3==0 → 8 full / 14 sliding) | +| local window | 128 | 128 | +| RoPE | plain theta, full 160000 / sliding 10000 (two `rope_parameters` entries) | both kinds 160000 + `rope_scaling` yarn (factor 4.0, original_max 8192, truncate) | +| vocab | 50368 | 256008 | +| type embedding | `type_emb.weight` [3, 1024] | none | +| norm eps / activation / bias | 1e-5 / gelu / all false | 1e-5 / gelu / all false | +| max positions exercised | 512 (bundle rope tables, workspace cap) | 8192 served (`max_length`; tables to 32768) | +| checkpoint dtype | mixed per class (f16 embed, bf16 GEMM class, f32 norms) | all F32, single `model.safetensors`, strict load | + +Vela 0.3B facts from the pinned copy at `d6f03aa9` +(`modeling_vela2.py` `_build`: `m.encoder = ModernBertModel(ecfg)`, +stock HF naming under `encoder.*`; parameter names "exactly those of +the training checkpoint, so `model.safetensors` loads with +strict=True"). + +## Why the kernels are in scope (the one real capability gap) + +The shared CUDA bundle (`src/backends/cuda/kernels/`, manifest +`laya.backend.json`, ABI 1) is **generated per geometry**: the +TileLang templates take `N, K` (GEMM) and `B, L, H, Dh, window` +(attention) as compile-time parameters, and `kernels/model_ops.cu` +hardcodes 1024/1028/2048 (`embed_norm` ids[r]*1024, `add_type` 1024, +`action_features` 1028, `laya_linear` guard n≤1024). Laya's 768-hidden +sibling therefore needs bundle regeneration plus de-hardcoding — a +kernel-source change, hence an explicit ABI decision (spec 03), which +is exactly the "configurable ≠ arbitrary" boundary the RFC freezes. +Everything else about the Vela profile is adapter/mapping/planning +work in Rust. + +## Test seams + +Mirrors `specs/valen/` (no new seams): + +- **Seam C — CPU golden fixtures** is the main evidence channel for + 01/02/05: adapter provenance, IR invariants, gate accept/reject + matrices, serialized execution-plan fixtures, derived prefix-layout + sizes — all without a GPU. +- **Seam B — parity harness** (spec 03): native encoder final hidden + at requested positions vs the pinned 0.3B package (CPU, FP32 + oracle), tolerance declared at implementation anchored by the + upstream noise floor (charter §3). +- **Seam D — device-gated GPU regression**: existing + `tests/cuda/laya_runtime.rs` stays green through the refactor; + spec 03 adds a Vela-profile encoder smoke. + +## Standing conventions + +Inherited verbatim from `specs/valen/README.md`: repo-facing docs and +code comments carry no process narrative (no spec numbers, tickets, or +decision-status references in `src/`, `tests/`, `recipe/`); no +trailing `# …` comments; evidence blocks phrased as measured facts. +The specs themselves (this directory) are local planning documents and +may reference the RFC and wayfinder assets. + +## Provenance of the facts embedded in these specs + +Working tree @ `2812144` (branch `model/vela2-reference-serving`): +`src/models/laya/src/{config,model,weights}.rs`, +`src/models/qwen3_5/native/src/model.rs`, +`src/backends/cuda/kernels/{model_ops.cu,laya_tilelang.py}`, +`src/backends/cuda/laya.backend.json`, `tests/laya/*`, +`src/models/omnijev/native/src/heads.rs`. Pinned upstream copies: +`/tmp/vela-survey/Vela-2.0-0.3B` (`d6f03aa9`; config.json, +modeling_vela2.py). Research baselines: `wayfinder/research/017-*` +(upstream survey), `019-*` (hybrid configs vs native gates — the +gate×verdict table and per-size geometry), charter +`wayfinder/drafts/vela-integration-charter.md` (locked order, +ownership, tolerance anchors). Line numbers drift; the facts were +verified at planning time. From 33ecadc21d52c9ae904f95c1e1dc8d2ad54e5a9e Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 01:00:51 +0800 Subject: [PATCH 02/21] modernbert: add the encoder family structure crate A pure-Rust family layer for ModernBERT encoders with no device dependencies, the first half of the model-parameter RFC's phase 2: - structure: one ModernBertStructure with family invariants (heads * head_dim == hidden, schedule length, bias-free gelu, rope/yarn bounds) and per-field provenance; canonical sorted-key JSON for identity digests - adapters: the Laya two-file bundle (explicit layer_types, per-kind rope_parameters) and any single-object HF ModernBERT config (schedule from global_attn_every_n_layers or explicit layer_types, global/local thetas, yarn rope_scaling, optional type vocab); unknown top-level keys are recorded, never guessed - geometry: derived tensor shapes (the 2*intermediate up-projection is derived, not semantic), the canonical encoder tensor inventory, rotary table sizing, and the (batch, len) encoder scratch formula - gate: backend limits and the registered laya-0.3.20 profile admitting structures with field-diff errors, plus StructureIdentity (sha256 over schema version, family, structure, backend, ABI) for future cache keys - plan: the named-kernel encoder plan (embed, per-layer qkv/rope/ attn/out/addln/geglu/down, final norm fused into the last layer, optional type add) with sequence anchors; symbol resolution stays with consuming model crates Fifteen CPU tests: adapter provenance and rejection matrices for both sources, invariant failures naming the field, profile admit/reject with actual-vs-expected diffs, request-envelope bounds, identity stability and sensitivity, inventory counts for the two known profiles (Laya 1024/28-layer and the pinned Vela-2.0-0.3B encoder_config 768/22-layer with yarn factor 4), and plan fixtures for both schedules. No consumer changes; Laya migrates in the next step. Signed-off-by: Mr.Dong <1042542469@qq.com> --- Cargo.lock | 10 + Cargo.toml | 2 +- src/models/modernbert/Cargo.toml | 21 ++ src/models/modernbert/src/adapter.rs | 456 +++++++++++++++++++++++++ src/models/modernbert/src/gate.rs | 301 ++++++++++++++++ src/models/modernbert/src/geometry.rs | 105 ++++++ src/models/modernbert/src/lib.rs | 23 ++ src/models/modernbert/src/plan.rs | 206 +++++++++++ src/models/modernbert/src/structure.rs | 201 +++++++++++ tests/modernbert/plan.rs | 173 ++++++++++ tests/modernbert/structure.rs | 390 +++++++++++++++++++++ 11 files changed, 1887 insertions(+), 1 deletion(-) create mode 100644 src/models/modernbert/Cargo.toml create mode 100644 src/models/modernbert/src/adapter.rs create mode 100644 src/models/modernbert/src/gate.rs create mode 100644 src/models/modernbert/src/geometry.rs create mode 100644 src/models/modernbert/src/lib.rs create mode 100644 src/models/modernbert/src/plan.rs create mode 100644 src/models/modernbert/src/structure.rs create mode 100644 tests/modernbert/plan.rs create mode 100644 tests/modernbert/structure.rs diff --git a/Cargo.lock b/Cargo.lock index 65562b9e..d4fc86d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,6 +1241,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "omni-modernbert" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde_json", + "sha2 0.10.9", + "tempfile", +] + [[package]] name = "omni-omnijev-native" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 580fbee2..a37bf37c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["src/frontend", "src/runtime", "src/models/clm", "src/models/cua_s1/native", "src/models/qwen3_5/native", "src/models/open_jev/native", "src/models/jev_vl/native", "src/models/omnijev/native", "src/models/laya", "src/backends/cuda", "src/models/decider/native"] +members = ["src/frontend", "src/runtime", "src/models/clm", "src/models/cua_s1/native", "src/models/qwen3_5/native", "src/models/open_jev/native", "src/models/jev_vl/native", "src/models/omnijev/native", "src/models/laya", "src/models/modernbert", "src/backends/cuda", "src/models/decider/native"] resolver = "3" diff --git a/src/models/modernbert/Cargo.toml b/src/models/modernbert/Cargo.toml new file mode 100644 index 00000000..2f08f654 --- /dev/null +++ b/src/models/modernbert/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "omni-modernbert" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +anyhow = "1" +serde_json = "1" +sha2 = "0.10" + +[dev-dependencies] +tempfile = "3" + +[[test]] +name = "structure" +path = "../../../tests/modernbert/structure.rs" + +[[test]] +name = "plan" +path = "../../../tests/modernbert/plan.rs" diff --git a/src/models/modernbert/src/adapter.rs b/src/models/modernbert/src/adapter.rs new file mode 100644 index 00000000..f61ce7e8 --- /dev/null +++ b/src/models/modernbert/src/adapter.rs @@ -0,0 +1,456 @@ +use crate::structure::{ + Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, YarnScaling, +}; +use anyhow::{Context, Result, ensure}; +use serde_json::Value as Json; +use std::collections::BTreeMap; +use std::path::Path; + +/// Marker and file evidence for one parsed source. +#[derive(Debug, Clone, PartialEq)] +pub struct SourceMeta { + pub config_files: Vec, + pub model_type: String, + /// Top-level keys the adapters do not consume. Recorded, never a + /// rejection: a checkpoint may carry recipe or training fields beside + /// the structure. + pub unrecognized_keys: Vec, +} + +/// Reads the two-file Laya bundle layout: the encoder half of +/// `rl_agent_config.json` + `encoder/config.json`. Agent fields are task +/// bundle and stay with the Laya crate; this adapter reads +/// `encoder/config.json` only. +pub struct LayaBundleAdapter; + +impl LayaBundleAdapter { + pub fn read(dir: &Path) -> Result<(ModernBertStructure, SourceMeta)> { + let path = dir.join("encoder/config.json"); + let bytes = std::fs::read(&path).with_context(|| format!("{}", path.display()))?; + let config: Json = serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", path.display()))?; + parse_laya_bundle(&config, "encoder/config.json") + } +} + +/// Reads any single-object ModernBERT config: a stock HF +/// `config.json` or a member object such as the Vela bundle's +/// `encoder_config`. +pub struct ModernBertConfigAdapter; + +impl ModernBertConfigAdapter { + pub fn from_value(config: &Json, source_name: &str) -> Result<(ModernBertStructure, SourceMeta)> { + parse_hf_config(config, source_name) + } +} + +fn uint(config: &Json, key: &str) -> Result { + config[key] + .as_u64() + .and_then(|v| usize::try_from(v).ok()) + .with_context(|| format!("{key} is missing or not an unsigned integer")) +} + +fn float(config: &Json, key: &str) -> Result { + config[key] + .as_f64() + .with_context(|| format!("{key} is missing or not a number")) +} + +fn require_modernbert(config: &Json) -> Result<()> { + ensure!( + config["model_type"].as_str() == Some("modernbert"), + "model_type {:?} is not modernbert", + config["model_type"] + ); + ensure!( + config["hidden_activation"].as_str() == Some("gelu"), + "hidden_activation {:?} is not gelu", + config["hidden_activation"] + ); + Ok(()) +} + +fn unrecognized(config: &Json, known: &[&str]) -> Vec { + config + .as_object() + .map(|object| { + object + .keys() + .filter(|k| !known.contains(&k.as_str())) + .cloned() + .collect() + }) + .unwrap_or_default() +} + +fn derive_head_dim( + hidden: usize, + heads: usize, + config: &Json, + provenance: &mut BTreeMap, +) -> Result { + let head_dim = match config.get("head_dim") { + Some(value) => value + .as_u64() + .and_then(|v| usize::try_from(v).ok()) + .context("head_dim is not an unsigned integer")?, + None => { + provenance.insert( + "head_dim".into(), + FieldOrigin::Derived("hidden_size / num_attention_heads".into()), + ); + hidden / heads + } + }; + Ok(head_dim) +} + +fn parse_laya_bundle(config: &Json, source: &str) -> Result<(ModernBertStructure, SourceMeta)> { + require_modernbert(config)?; + for key in ["attention_bias", "mlp_bias", "norm_bias"] { + ensure!( + config[key] == Json::Bool(false), + "{key} must be stated and false" + ); + } + let layer_types = config["layer_types"] + .as_array() + .context("layer_types is missing or not an array")?; + let schedule = layer_types + .iter() + .map(|t| { + t.as_str() + .map(AttentionKind::parse) + .unwrap_or_else(|| Err(anyhow::anyhow!("layer_types entry {t:?} is not a string"))) + }) + .collect::>>()?; + let rope = |kind: &str, provenance: &mut BTreeMap| { + let entry = &config["rope_parameters"][kind]; + ensure!( + entry["rope_type"].as_str() == Some("default"), + "rope_parameters.{kind} rope_type {:?} is not default", + entry["rope_type"] + ); + provenance.insert( + format!("rope_{kind}"), + FieldOrigin::Source(format!("{source}#rope_parameters.{kind}")), + ); + Ok(RopeSpec { + theta: entry["rope_theta"].as_f64().with_context(|| { + format!("rope_parameters.{kind}.rope_theta is missing or not a number") + })?, + scaling: None, + }) + }; + let mut provenance = BTreeMap::new(); + let rope_full = rope("full_attention", &mut provenance)?; + let rope_sliding = rope("sliding_attention", &mut provenance)?; + let hidden = uint(config, "hidden_size")?; + let heads = uint(config, "num_attention_heads")?; + let max_positions = match config.get("max_position_embeddings") { + Some(_) => { + let value = uint(config, "max_position_embeddings")?; + provenance.insert( + "max_positions".into(), + FieldOrigin::Source(format!("{source}#max_position_embeddings")), + ); + value + } + None => { + provenance.insert( + "max_positions".into(), + FieldOrigin::Default("Laya bundle rotary tables exercise 512 positions"), + ); + 512 + } + }; + let head_dim = derive_head_dim(hidden, heads, config, &mut provenance)?; + for key in [ + "hidden_size", + "intermediate_size", + "num_hidden_layers", + "norm_eps", + "num_attention_heads", + "schedule", + "local_window", + "vocab_size", + ] { + let json_key = match key { + "schedule" => "layer_types", + "local_window" => "local_attention", + _ => key, + }; + provenance.insert( + key.into(), + FieldOrigin::Source(format!("{source}#{json_key}")), + ); + } + provenance.insert( + "type_embedding_rows".into(), + FieldOrigin::Default("the Laya bundle checkpoint carries a [3, hidden] type embedding"), + ); + let structure = ModernBertStructure { + hidden_size: hidden, + intermediate_size: uint(config, "intermediate_size")?, + num_hidden_layers: uint(config, "num_hidden_layers")?, + activation: Activation::Gelu, + norm_eps: float(config, "norm_eps")? as f32, + num_attention_heads: heads, + head_dim, + schedule, + local_window: uint(config, "local_attention")?, + rope_full, + rope_sliding, + vocab_size: uint(config, "vocab_size")?, + type_embedding_rows: Some(3), + max_positions, + provenance, + }; + structure.validate()?; + let meta = SourceMeta { + config_files: vec![source.into()], + model_type: "modernbert".into(), + unrecognized_keys: unrecognized( + config, + &[ + "model_type", + "hidden_activation", + "attention_bias", + "mlp_bias", + "norm_bias", + "hidden_size", + "intermediate_size", + "num_attention_heads", + "num_hidden_layers", + "vocab_size", + "norm_eps", + "local_attention", + "layer_types", + "rope_parameters", + "max_position_embeddings", + "head_dim", + ], + ), + }; + Ok((structure, meta)) +} + +fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, SourceMeta)> { + require_modernbert(config)?; + let mut provenance = BTreeMap::new(); + for key in ["attention_bias", "mlp_bias", "norm_bias"] { + match config.get(key) { + None => { + provenance.insert( + key.into(), + FieldOrigin::Default("HF ModernBERT states no bias by default"), + ); + } + Some(value) => ensure!( + value == &Json::Bool(false), + "{key} must be false, got {value}" + ), + } + } + let schedule = if let Some(layer_types) = config["layer_types"].as_array() { + let parsed = layer_types + .iter() + .map(|t| { + t.as_str() + .map(AttentionKind::parse) + .unwrap_or_else(|| { + Err(anyhow::anyhow!("layer_types entry {t:?} is not a string")) + }) + }) + .collect::>>()?; + provenance.insert( + "schedule".into(), + FieldOrigin::Source(format!("{source}#layer_types")), + ); + parsed + } else { + let every = uint(config, "global_attn_every_n_layers") + .context("neither layer_types nor global_attn_every_n_layers is present")?; + provenance.insert( + "schedule".into(), + FieldOrigin::Derived(format!( + "global_attn_every_n_layers={every}, full at layer index % {every} == 0" + )), + ); + (0..uint(config, "num_hidden_layers")?) + .map(|i| { + if i % every == 0 { + AttentionKind::Full + } else { + AttentionKind::Sliding + } + }) + .collect() + }; + let theta = |field: &str, json_key: &str, provenance: &mut BTreeMap| { + match config.get(json_key) { + None => { + provenance.insert( + field.into(), + FieldOrigin::Default("HF ModernBERT rope_theta default"), + ); + Ok(10000.0) + } + Some(_) => { + provenance.insert( + field.into(), + FieldOrigin::Source(format!("{source}#{json_key}")), + ); + float(config, json_key) + } + } + }; + let theta_full = theta("rope_full", "global_rope_theta", &mut provenance)?; + let theta_sliding = theta("rope_sliding", "local_rope_theta", &mut provenance)?; + let scaling = match config.get("rope_scaling") { + None => None, + Some(entry) => { + ensure!( + entry["rope_type"].as_str() == Some("yarn"), + "rope_scaling type {:?} is not yarn", + entry["rope_type"] + ); + ensure!( + entry.get("truncate").map(|v| v == &Json::Bool(true)).unwrap_or(true), + "rope_scaling truncate must be true" + ); + let yarn = YarnScaling { + factor: float(entry, "factor")?, + original_max_positions: uint(entry, "original_max_position_embeddings")?, + beta_fast: match entry.get("beta_fast") { + None => 32.0, + Some(_) => float(entry, "beta_fast")?, + }, + beta_slow: match entry.get("beta_slow") { + None => 1.0, + Some(_) => float(entry, "beta_slow")?, + }, + }; + provenance.insert( + "rope_full.scaling".into(), + FieldOrigin::Source(format!("{source}#rope_scaling")), + ); + provenance.insert( + "rope_sliding.scaling".into(), + FieldOrigin::Source(format!("{source}#rope_scaling")), + ); + Some(yarn) + } + }; + if let Some(position_type) = config.get("position_embedding_type").and_then(|v| v.as_str()) { + ensure!( + position_type == "sans_pos" || position_type == "rope", + "position_embedding_type {position_type:?} is not rotary-only" + ); + } + let eps_key = if config.get("norm_eps").is_some() { + "norm_eps" + } else if config.get("layer_norm_eps").is_some() { + "layer_norm_eps" + } else { + anyhow::bail!("neither norm_eps nor layer_norm_eps is present") + }; + let norm_eps = float(config, eps_key)? as f32; + let hidden = uint(config, "hidden_size")?; + let heads = uint(config, "num_attention_heads")?; + let head_dim = derive_head_dim(hidden, heads, config, &mut provenance)?; + let type_embedding_rows = match config.get("type_vocab_size") { + None => None, + Some(_) => { + let rows = uint(config, "type_vocab_size")?; + provenance.insert( + "type_embedding_rows".into(), + FieldOrigin::Source(format!("{source}#type_vocab_size")), + ); + (rows > 0).then_some(rows) + } + }; + for key in [ + "hidden_size", + "intermediate_size", + "num_hidden_layers", + "num_attention_heads", + "vocab_size", + "local_window", + ] { + let json_key = match key { + "local_window" => "local_attention", + _ => key, + }; + provenance.insert( + key.into(), + FieldOrigin::Source(format!("{source}#{json_key}")), + ); + } + provenance.insert( + "norm_eps".into(), + FieldOrigin::Source(format!("{source}#{eps_key}")), + ); + provenance.insert( + "max_positions".into(), + FieldOrigin::Source(format!("{source}#max_position_embeddings")), + ); + let structure = ModernBertStructure { + hidden_size: hidden, + intermediate_size: uint(config, "intermediate_size")?, + num_hidden_layers: uint(config, "num_hidden_layers")?, + activation: Activation::Gelu, + norm_eps, + num_attention_heads: heads, + head_dim, + schedule, + local_window: uint(config, "local_attention")?, + rope_full: RopeSpec { + theta: theta_full, + scaling, + }, + rope_sliding: RopeSpec { + theta: theta_sliding, + scaling, + }, + vocab_size: uint(config, "vocab_size")?, + type_embedding_rows, + max_positions: uint(config, "max_position_embeddings")?, + provenance, + }; + structure.validate()?; + let meta = SourceMeta { + config_files: vec![source.into()], + model_type: "modernbert".into(), + unrecognized_keys: unrecognized( + config, + &[ + "model_type", + "hidden_activation", + "attention_bias", + "mlp_bias", + "norm_bias", + "hidden_size", + "intermediate_size", + "num_attention_heads", + "num_hidden_layers", + "vocab_size", + "norm_eps", + "layer_norm_eps", + "local_attention", + "layer_types", + "global_attn_every_n_layers", + "global_rope_theta", + "local_rope_theta", + "rope_theta", + "rope_scaling", + "max_position_embeddings", + "head_dim", + "position_embedding_type", + "type_vocab_size", + ], + ), + }; + Ok((structure, meta)) +} diff --git a/src/models/modernbert/src/gate.rs b/src/models/modernbert/src/gate.rs new file mode 100644 index 00000000..9a8ce81c --- /dev/null +++ b/src/models/modernbert/src/gate.rs @@ -0,0 +1,301 @@ +use crate::structure::{ModernBertStructure, SCHEMA_VERSION}; +use anyhow::{Result, ensure}; +use sha2::{Digest, Sha256}; + +/// What one CUDA bundle can execute: the geometry its kernels were +/// generated for and the request envelope it admits. A new bundle +/// registers new limits; the gate code does not change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendLimits { + /// Hidden sizes the bundle's kernels were instantiated for. + pub hidden_sizes: Vec, + /// Batch bound; a request batch must be a power of two up to it. + pub batch_max: usize, + pub len_min: usize, + pub len_max: usize, + pub len_step: usize, + /// Whether the bundle carries YaRN-scaled rotary tables. + pub yarn_scaling: bool, +} + +impl BackendLimits { + pub fn check_request(&self, batch: usize, len: usize) -> Result<()> { + ensure!( + batch.is_power_of_two() && batch <= self.batch_max, + "batch {batch} exceeds the power-of-two bound {}", + self.batch_max + ); + ensure!( + (self.len_min..=self.len_max).contains(&len), + "packed length {len} is outside {}..={}", + self.len_min, + self.len_max + ); + ensure!( + len.is_multiple_of(self.len_step), + "packed length {len} is not a multiple of {}", + self.len_step + ); + Ok(()) + } + + pub fn check_structure(&self, s: &ModernBertStructure) -> Result<()> { + ensure!( + self.hidden_sizes.contains(&s.hidden_size), + "hidden_size {} is not instantiated in this bundle ({:?})", + s.hidden_size, + self.hidden_sizes + ); + ensure!( + self.yarn_scaling + || (s.rope_full.scaling.is_none() && s.rope_sliding.scaling.is_none()), + "rope scaling is not available in this bundle" + ); + Ok(()) + } +} + +/// A named, frozen structure-and-limits pair. Admission checks family +/// invariants, backend limits, and equality with the canonical +/// structure, and returns the identity every cache key must carry. +#[derive(Debug, Clone)] +pub struct RegisteredProfile { + pub name: &'static str, + pub structure: ModernBertStructure, + pub limits: BackendLimits, + pub backend: &'static str, + pub abi_version: usize, +} + +impl RegisteredProfile { + pub fn admit(&self, candidate: &ModernBertStructure) -> Result { + candidate.validate()?; + self.limits.check_structure(candidate)?; + let expected = &self.structure; + let field = |name: &str, actual: String, wanted: String| { + format!("profile {}: {name} {actual}, expected {wanted}", self.name) + }; + ensure!( + candidate.hidden_size == expected.hidden_size, + "{}", + field( + "hidden_size", + candidate.hidden_size.to_string(), + expected.hidden_size.to_string() + ) + ); + ensure!( + candidate.intermediate_size == expected.intermediate_size, + "{}", + field( + "intermediate_size", + candidate.intermediate_size.to_string(), + expected.intermediate_size.to_string() + ) + ); + ensure!( + candidate.num_hidden_layers == expected.num_hidden_layers, + "{}", + field( + "num_hidden_layers", + candidate.num_hidden_layers.to_string(), + expected.num_hidden_layers.to_string() + ) + ); + ensure!( + candidate.num_attention_heads == expected.num_attention_heads, + "{}", + field( + "num_attention_heads", + candidate.num_attention_heads.to_string(), + expected.num_attention_heads.to_string() + ) + ); + ensure!( + candidate.head_dim == expected.head_dim, + "{}", + field( + "head_dim", + candidate.head_dim.to_string(), + expected.head_dim.to_string() + ) + ); + ensure!( + candidate.schedule == expected.schedule, + "{}", + field("schedule", render_schedule(candidate), render_schedule(expected)) + ); + ensure!( + candidate.local_window == expected.local_window, + "{}", + field( + "local_window", + candidate.local_window.to_string(), + expected.local_window.to_string() + ) + ); + ensure!( + candidate.norm_eps.to_bits() == expected.norm_eps.to_bits(), + "{}", + field( + "norm_eps", + candidate.norm_eps.to_string(), + expected.norm_eps.to_string() + ) + ); + ensure!( + candidate.rope_full == expected.rope_full && candidate.rope_sliding == expected.rope_sliding, + "{}", + field( + "rope", + format!("{:?}/{:?}", candidate.rope_full, candidate.rope_sliding), + format!("{:?}/{:?}", expected.rope_full, expected.rope_sliding) + ) + ); + ensure!( + candidate.vocab_size == expected.vocab_size, + "{}", + field( + "vocab_size", + candidate.vocab_size.to_string(), + expected.vocab_size.to_string() + ) + ); + ensure!( + candidate.type_embedding_rows == expected.type_embedding_rows, + "{}", + field( + "type_embedding_rows", + format!("{:?}", candidate.type_embedding_rows), + format!("{:?}", expected.type_embedding_rows) + ) + ); + ensure!( + candidate.max_positions == expected.max_positions, + "{}", + field( + "max_positions", + candidate.max_positions.to_string(), + expected.max_positions.to_string() + ) + ); + Ok(structure_identity(candidate, self.backend, self.abi_version)) + } +} + +fn render_schedule(s: &ModernBertStructure) -> String { + s.schedule + .iter() + .map(|k| k.as_str()) + .collect::>() + .join(",") +} + +/// Stable identity of one structure executing on one backend. Any change +/// to the digest inputs means previously captured runtime state (graphs, +/// caches, prefix state) must not be reused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StructureIdentity { + pub schema_version: usize, + pub family: &'static str, + pub backend: String, + pub abi_version: usize, + pub digest: String, +} + +pub fn structure_identity( + s: &ModernBertStructure, + backend: &str, + abi_version: usize, +) -> StructureIdentity { + let body = serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "family": "modernbert", + "backend": backend, + "abi_version": abi_version, + "structure": s.canonical_json(), + }) + .to_string(); + StructureIdentity { + schema_version: SCHEMA_VERSION, + family: "modernbert", + backend: backend.to_string(), + abi_version, + digest: format!("{:x}", Sha256::digest(body.as_bytes())), + } +} + +/// The one profile the current CUDA bundle serves: the Laya 0.3.20 +/// encoder as shipped, ABI 1. +pub fn laya_0_3_20() -> RegisteredProfile { + RegisteredProfile { + name: "laya-0.3.20", + structure: laya_structure(), + limits: BackendLimits { + hidden_sizes: vec![1024], + batch_max: 16, + len_min: 16, + len_max: 512, + len_step: 16, + yarn_scaling: false, + }, + backend: "laya_cuda", + abi_version: 1, + } +} + +fn laya_structure() -> ModernBertStructure { + let mut provenance = std::collections::BTreeMap::new(); + for field in [ + "hidden_size", + "intermediate_size", + "num_hidden_layers", + "norm_eps", + "num_attention_heads", + "head_dim", + "schedule", + "local_window", + "rope_full", + "rope_sliding", + "vocab_size", + "type_embedding_rows", + "max_positions", + ] { + provenance.insert(field.to_string(), origin_of_profile()); + } + ModernBertStructure { + hidden_size: 1024, + intermediate_size: 2624, + num_hidden_layers: 28, + activation: crate::structure::Activation::Gelu, + norm_eps: 1e-5, + num_attention_heads: 16, + head_dim: 64, + schedule: (0..28) + .map(|i| { + if i % 3 == 0 { + crate::structure::AttentionKind::Full + } else { + crate::structure::AttentionKind::Sliding + } + }) + .collect(), + local_window: 128, + rope_full: crate::structure::RopeSpec { + theta: 160000.0, + scaling: None, + }, + rope_sliding: crate::structure::RopeSpec { + theta: 10000.0, + scaling: None, + }, + vocab_size: 50368, + type_embedding_rows: Some(3), + max_positions: 512, + provenance, + } +} + +fn origin_of_profile() -> crate::structure::FieldOrigin { + crate::structure::FieldOrigin::Source("registered profile laya-0.3.20".into()) +} diff --git a/src/models/modernbert/src/geometry.rs b/src/models/modernbert/src/geometry.rs new file mode 100644 index 00000000..b6d9acfa --- /dev/null +++ b/src/models/modernbert/src/geometry.rs @@ -0,0 +1,105 @@ +use crate::structure::ModernBertStructure; + +/// Every tensor shape below is derived, never read from a checkpoint: +/// the structure is the only geometry input. +pub fn embedding_shape(s: &ModernBertStructure) -> Vec { + vec![s.vocab_size, s.hidden_size] +} + +pub fn qkv_shape(s: &ModernBertStructure) -> Vec { + vec![3 * s.hidden_size, s.hidden_size] +} + +pub fn attn_out_shape(s: &ModernBertStructure) -> Vec { + vec![s.hidden_size, s.hidden_size] +} + +/// The fused gated-MLP up projection; `2 * intermediate` is derived +/// geometry, not a semantic field of its own. +pub fn mlp_in_shape(s: &ModernBertStructure) -> Vec { + vec![2 * s.intermediate_size, s.hidden_size] +} + +pub fn mlp_out_shape(s: &ModernBertStructure) -> Vec { + vec![s.hidden_size, s.intermediate_size] +} + +/// Rotary dimensions per head (rotate-half pairing needs the even half). +pub fn rotary_half(s: &ModernBertStructure) -> usize { + s.head_dim / 2 +} + +/// One cos/sin table covers `max_positions` rows of `rotary_half` f32 +/// columns; a bundle serving shorter requests sizes tables to its own +/// length bound instead. +pub fn rope_table_bytes(s: &ModernBertStructure) -> usize { + s.max_positions * rotary_half(s) * 4 +} + +/// The canonical encoder tensor inventory under family naming. Layer +/// zero's attention norm is the embedding norm (the embedding kernel +/// applies it); every layer owns its post-attention norm, and the last +/// layer's post-MLP norm is the final norm. +pub fn encoder_tensors(s: &ModernBertStructure) -> Vec<(String, Vec)> { + let h = s.hidden_size; + let mut tensors = vec![ + ( + "encoder.embeddings.tok_embeddings.weight".to_string(), + embedding_shape(s), + ), + ("encoder.embeddings.norm.weight".to_string(), vec![h]), + ("encoder.final_norm.weight".to_string(), vec![h]), + ]; + for i in 0..s.num_hidden_layers { + let p = format!("encoder.layers.{i}"); + if i > 0 { + tensors.push((format!("{p}.attn_norm.weight"), vec![h])); + } + tensors.push((format!("{p}.mlp_norm.weight"), vec![h])); + tensors.push((format!("{p}.attn.Wqkv.weight"), qkv_shape(s))); + tensors.push((format!("{p}.attn.Wo.weight"), attn_out_shape(s))); + tensors.push((format!("{p}.mlp.Wi.weight"), mlp_in_shape(s))); + tensors.push((format!("{p}.mlp.Wo.weight"), mlp_out_shape(s))); + } + if let Some(rows) = s.type_embedding_rows { + tensors.push(("type_emb.weight".to_string(), vec![rows, h])); + } + tensors +} + +/// Encoder scratch for one request of `batch` rows packed to `len` +/// tokens, in canonical allocation order. Task-side buffers are sized +/// by the consuming model crate and appended after these. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncoderScratch { + pub ids: usize, + pub lens: usize, + pub x: usize, + pub y: usize, + pub qkv: usize, + pub o: usize, + pub gate: usize, +} + +impl EncoderScratch { + pub fn new(batch: usize, len: usize, s: &ModernBertStructure) -> Self { + let m = batch * len; + Self { + ids: m * 8, + lens: batch * 4, + x: m * s.hidden_size * 4, + y: m * s.hidden_size * 2, + qkv: m * 3 * s.hidden_size * 2, + o: m * s.hidden_size * 2, + gate: m * s.intermediate_size * 2, + } + } + + pub fn sizes(&self) -> Vec { + [self.ids, self.lens, self.x, self.y, self.qkv, self.o, self.gate].into() + } + + pub fn total(&self) -> usize { + self.sizes().iter().sum() + } +} diff --git a/src/models/modernbert/src/lib.rs b/src/models/modernbert/src/lib.rs new file mode 100644 index 00000000..90c1e1b5 --- /dev/null +++ b/src/models/modernbert/src/lib.rs @@ -0,0 +1,23 @@ +//! The ModernBERT encoder family layer: structure parameters parsed from +//! checkpoint configs, the geometry derived from them, the capability +//! profiles deciding whether a CUDA bundle can execute a structure, and +//! the named-kernel execution plan for the encoder stack. +//! +//! This crate owns family data only. It never touches CUDA, device +//! memory, or weight files; loading weights, resolving kernel symbols, +//! and every task head stay with the model crates that consume a +//! profile. + +pub mod adapter; +pub mod gate; +pub mod geometry; +pub mod plan; +pub mod structure; + +pub use adapter::{LayaBundleAdapter, ModernBertConfigAdapter, SourceMeta}; +pub use gate::{BackendLimits, RegisteredProfile, StructureIdentity}; +pub use plan::{EncoderPlanOptions, PlanAnchor, PlanArg, PlanStep, ScratchSlot}; +pub use structure::{ + Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, YarnScaling, + SCHEMA_VERSION, +}; diff --git a/src/models/modernbert/src/plan.rs b/src/models/modernbert/src/plan.rs new file mode 100644 index 00000000..771767fa --- /dev/null +++ b/src/models/modernbert/src/plan.rs @@ -0,0 +1,206 @@ +use crate::structure::ModernBertStructure; + +/// Scratch slots an encoder plan addresses. Allocating them is the +/// consumer's job; `ids`, `lens`, `x`, `y`, `qkv`, `o` and `gate` map to +/// the encoder scratch of [`crate::geometry::EncoderScratch`], and +/// `types` holds the per-row type ids consumed by the type-embedding +/// kernel when the structure carries one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScratchSlot { + Ids, + Lens, + Types, + X, + Y, + Qkv, + O, + Gate, +} + +impl ScratchSlot { + pub fn name(self) -> &'static str { + match self { + Self::Ids => "ids", + Self::Lens => "lens", + Self::Types => "types", + Self::X => "x", + Self::Y => "y", + Self::Qkv => "qkv", + Self::O => "o", + Self::Gate => "g", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlanArg { + /// A weight-buffer name: checkpoint tensors under canonical family + /// naming, or `zeros.` synthetic zero-bias buffers the consumer + /// uploads alongside them. + Weight(String), + Scratch(ScratchSlot), +} + +/// Sequence anchors a consumer can hook instrumentation to; they carry +/// no execution of their own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanAnchor { + AfterEmbed, + LayerEnd(usize), + AfterEncoder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlanStep { + Launch { kernel: String, args: Vec }, + Anchor(PlanAnchor), +} + +impl PlanStep { + /// Canonical one-line rendering used by plan fixtures. + pub fn render(&self) -> String { + match self { + Self::Launch { kernel, args } => { + let mut line = kernel.clone(); + for arg in args { + line.push(' '); + line.push_str(&match arg { + PlanArg::Weight(w) => w.clone(), + PlanArg::Scratch(s) => s.name().to_string(), + }); + } + line + } + Self::Anchor(a) => format!("anchor {a:?}"), + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct EncoderPlanOptions { + /// Select the reference-rotation kernel variant a bundle may carry + /// beside the shipped tables. + pub original_rope: bool, +} + +/// The named-kernel encoder plan: embedding, per-layer attention and +/// gated MLP, the final norm fused into the last layer's post-MLP norm, +/// and the optional type-embedding add. Kernel names and argument roles +/// only — resolving symbols to loaded kernels and names to device +/// pointers stays with the consuming model crate. +pub fn encoder_plan(s: &ModernBertStructure, opts: &EncoderPlanOptions) -> Vec { + let h = s.hidden_size; + let zero_bias = format!("zeros.{h}"); + let weight = |name: String| PlanArg::Weight(name); + let scratch = PlanArg::Scratch; + let mut steps = Vec::new(); + steps.push(PlanStep::Launch { + kernel: "embed".into(), + args: vec![ + scratch(ScratchSlot::Ids), + weight("encoder.embeddings.tok_embeddings.weight".into()), + weight("encoder.embeddings.norm.weight".into()), + scratch(ScratchSlot::X), + scratch(ScratchSlot::Y), + ], + }); + steps.push(PlanStep::Anchor(PlanAnchor::AfterEmbed)); + for (i, kind) in s.schedule.iter().enumerate() { + let p = format!("encoder.layers.{i}"); + let suffix = kind.kernel_suffix(); + steps.push(PlanStep::Launch { + kernel: "qkv".into(), + args: vec![ + scratch(ScratchSlot::Y), + weight(format!("{p}.attn.Wqkv.weight")), + weight(format!("zeros.{}", 3 * h)), + scratch(ScratchSlot::Qkv), + ], + }); + steps.push(PlanStep::Launch { + kernel: if opts.original_rope { + "rope_original".into() + } else { + "rope".into() + }, + args: vec![ + scratch(ScratchSlot::Qkv), + weight(format!("rope_{suffix}_cos")), + weight(format!("rope_{suffix}_sin")), + ], + }); + steps.push(PlanStep::Launch { + kernel: format!("attn_{suffix}"), + args: vec![ + scratch(ScratchSlot::Qkv), + scratch(ScratchSlot::Lens), + scratch(ScratchSlot::O), + ], + }); + steps.push(PlanStep::Launch { + kernel: "out".into(), + args: vec![ + scratch(ScratchSlot::O), + weight(format!("{p}.attn.Wo.weight")), + weight(zero_bias.clone()), + scratch(ScratchSlot::Y), + ], + }); + steps.push(PlanStep::Launch { + kernel: "addln".into(), + args: vec![ + scratch(ScratchSlot::X), + scratch(ScratchSlot::Y), + weight(format!("{p}.mlp_norm.weight")), + weight(zero_bias.clone()), + scratch(ScratchSlot::Y), + ], + }); + steps.push(PlanStep::Launch { + kernel: "geglu".into(), + args: vec![ + scratch(ScratchSlot::Y), + weight(format!("{p}.mlp.Wi.weight")), + scratch(ScratchSlot::Gate), + ], + }); + steps.push(PlanStep::Launch { + kernel: "down".into(), + args: vec![ + scratch(ScratchSlot::Gate), + weight(format!("{p}.mlp.Wo.weight")), + weight(zero_bias.clone()), + scratch(ScratchSlot::Y), + ], + }); + let next_norm = if i + 1 < s.schedule.len() { + format!("encoder.layers.{}.attn_norm.weight", i + 1) + } else { + "encoder.final_norm.weight".into() + }; + steps.push(PlanStep::Launch { + kernel: "addln".into(), + args: vec![ + scratch(ScratchSlot::X), + scratch(ScratchSlot::Y), + weight(next_norm), + weight(zero_bias.clone()), + scratch(ScratchSlot::Y), + ], + }); + steps.push(PlanStep::Anchor(PlanAnchor::LayerEnd(i))); + } + steps.push(PlanStep::Anchor(PlanAnchor::AfterEncoder)); + if s.type_embedding_rows.is_some() { + steps.push(PlanStep::Launch { + kernel: "type".into(), + args: vec![ + scratch(ScratchSlot::Y), + weight("type_emb.weight".into()), + scratch(ScratchSlot::Types), + scratch(ScratchSlot::X), + ], + }); + } + steps +} diff --git a/src/models/modernbert/src/structure.rs b/src/models/modernbert/src/structure.rs new file mode 100644 index 00000000..e02d7828 --- /dev/null +++ b/src/models/modernbert/src/structure.rs @@ -0,0 +1,201 @@ +use anyhow::{Result, ensure}; +use std::collections::BTreeMap; + +/// Bumped when the canonical serialization below changes meaning. +pub const SCHEMA_VERSION: usize = 1; + +/// One encoder layer's attention mixer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttentionKind { + Full, + Sliding, +} + +impl AttentionKind { + /// The name checkpoint configs spell in `layer_types`. + pub fn as_str(self) -> &'static str { + match self { + Self::Full => "full_attention", + Self::Sliding => "sliding_attention", + } + } + + /// The suffix the family CUDA kernels carry per attention kind. + pub fn kernel_suffix(self) -> &'static str { + match self { + Self::Full => "full", + Self::Sliding => "local", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "full_attention" => Ok(Self::Full), + "sliding_attention" => Ok(Self::Sliding), + other => anyhow::bail!("unknown attention kind {other:?}"), + } + } +} + +/// The only activation the family kernels implement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activation { + Gelu, +} + +/// YaRN frequency scaling applied on top of a base theta. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct YarnScaling { + pub factor: f64, + pub original_max_positions: usize, + pub beta_fast: f64, + pub beta_slow: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RopeSpec { + pub theta: f64, + pub scaling: Option, +} + +/// Where one structure field's value came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FieldOrigin { + Source(String), + Default(&'static str), + Derived(String), +} + +/// The ModernBERT encoder structure: family semantics and math geometry +/// with per-field provenance. Weight names, kernels, and buffers are not +/// part of the structure; they are derived in [`crate::geometry`] and +/// [`crate::plan`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ModernBertStructure { + pub hidden_size: usize, + pub intermediate_size: usize, + pub num_hidden_layers: usize, + pub activation: Activation, + pub norm_eps: f32, + pub num_attention_heads: usize, + pub head_dim: usize, + pub schedule: Vec, + pub local_window: usize, + pub rope_full: RopeSpec, + pub rope_sliding: RopeSpec, + pub vocab_size: usize, + pub type_embedding_rows: Option, + pub max_positions: usize, + pub provenance: BTreeMap, +} + +impl ModernBertStructure { + /// Family invariants. Every failure names the field and its value. + pub fn validate(&self) -> Result<()> { + ensure!( + self.num_hidden_layers > 0, + "num_hidden_layers is {}", + self.num_hidden_layers + ); + ensure!( + self.num_attention_heads > 0 + && self.num_attention_heads * self.head_dim == self.hidden_size, + "num_attention_heads {} with head_dim {} does not form hidden_size {}", + self.num_attention_heads, + self.head_dim, + self.hidden_size + ); + ensure!( + self.head_dim.is_multiple_of(2), + "head_dim {} has no rotary half", + self.head_dim + ); + ensure!( + self.schedule.len() == self.num_hidden_layers, + "schedule holds {} entries for {} layers", + self.schedule.len(), + self.num_hidden_layers + ); + ensure!( + self.intermediate_size > 0, + "intermediate_size is {}", + self.intermediate_size + ); + ensure!( + self.local_window > 0, + "local_attention is {}", + self.local_window + ); + ensure!(self.vocab_size > 0, "vocab_size is {}", self.vocab_size); + ensure!( + self.norm_eps > 0.0 && self.norm_eps.is_finite(), + "norm_eps is {}", + self.norm_eps + ); + ensure!( + self.max_positions > 0, + "max_position_embeddings is {}", + self.max_positions + ); + for (name, rope) in [("full", &self.rope_full), ("sliding", &self.rope_sliding)] { + ensure!( + rope.theta.is_finite() && rope.theta > 0.0, + "{name} rope_theta is {}", + rope.theta + ); + if let Some(YarnScaling { + factor, + original_max_positions, + beta_fast, + beta_slow, + }) = rope.scaling + { + ensure!(factor > 1.0, "yarn factor is {factor}"); + ensure!( + original_max_positions > 0, + "yarn original_max_position_embeddings is {original_max_positions}" + ); + ensure!( + beta_slow > 0.0 && beta_fast > beta_slow, + "yarn beta_fast {beta_fast} / beta_slow {beta_slow}" + ); + } + } + Ok(()) + } + + /// Canonical, sorted-key JSON body (provenance excluded) used for + /// identity digests. Same structure always serializes identically. + pub fn canonical_json(&self) -> serde_json::Value { + let rope = |r: &RopeSpec| { + let mut value = serde_json::json!({ "theta": r.theta }); + if let Some(y) = &r.scaling { + value["scaling"] = serde_json::json!({ + "factor": y.factor, + "original_max_positions": y.original_max_positions, + "beta_fast": y.beta_fast, + "beta_slow": y.beta_slow, + }); + } + value + }; + serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "family": "modernbert", + "activation": "gelu", + "hidden_size": self.hidden_size, + "intermediate_size": self.intermediate_size, + "num_hidden_layers": self.num_hidden_layers, + "norm_eps": self.norm_eps, + "num_attention_heads": self.num_attention_heads, + "head_dim": self.head_dim, + "schedule": self.schedule.iter().map(|k| k.as_str()).collect::>(), + "local_window": self.local_window, + "rope_full": rope(&self.rope_full), + "rope_sliding": rope(&self.rope_sliding), + "vocab_size": self.vocab_size, + "type_embedding_rows": self.type_embedding_rows, + "max_positions": self.max_positions, + }) + } +} diff --git a/tests/modernbert/plan.rs b/tests/modernbert/plan.rs new file mode 100644 index 00000000..2e20b88b --- /dev/null +++ b/tests/modernbert/plan.rs @@ -0,0 +1,173 @@ +use omni_modernbert::adapter::{LayaBundleAdapter, ModernBertConfigAdapter}; +use omni_modernbert::plan::{ + self, EncoderPlanOptions, PlanAnchor, PlanArg, PlanStep, ScratchSlot, +}; +use omni_modernbert::structure::ModernBertStructure; +use serde_json::json; +use std::fs; +use tempfile::tempdir; + +fn laya_structure() -> ModernBertStructure { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("encoder")).unwrap(); + fs::write( + dir.path().join("encoder/config.json"), + json!({ + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 1024, "intermediate_size": 2624, + "num_attention_heads": 16, "num_hidden_layers": 28, + "vocab_size": 50368, "norm_eps": 0.00001, "local_attention": 128, + "layer_types": (["full_attention", "sliding_attention", "sliding_attention"] + .repeat(10))[..28], + "rope_parameters": { + "full_attention": {"rope_type": "default", "rope_theta": 160000.0}, + "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0} + } + }) + .to_string(), + ) + .unwrap(); + LayaBundleAdapter::read(dir.path()).unwrap().0 +} + +fn vela_structure() -> ModernBertStructure { + let config = json!({ + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 768, "intermediate_size": 1152, + "num_attention_heads": 12, "num_hidden_layers": 22, + "vocab_size": 256008, "norm_eps": 1e-5, + "local_attention": 128, "global_attn_every_n_layers": 3, + "global_rope_theta": 160000, "local_rope_theta": 160000, + "max_position_embeddings": 32768, + "rope_scaling": { + "rope_type": "yarn", "factor": 4.0, + "original_max_position_embeddings": 8192, "truncate": true + } + }); + ModernBertConfigAdapter::from_value(&config, "config.json#encoder_config") + .unwrap() + .0 +} + +fn launches(steps: &[PlanStep]) -> Vec<&PlanStep> { + steps + .iter() + .filter(|s| matches!(s, PlanStep::Launch { .. })) + .collect() +} + +#[test] +fn laya_plan_reproduces_the_encoder_step_sequence() { + let steps = plan::encoder_plan(&laya_structure(), &EncoderPlanOptions::default()); + // embed + 28 layers x 8 kernels + the type add. + assert_eq!(launches(&steps).len(), 1 + 28 * 8 + 1); + assert_eq!(steps[0].render(), "embed ids encoder.embeddings.tok_embeddings.weight encoder.embeddings.norm.weight x y"); + assert_eq!(steps[1], PlanStep::Anchor(PlanAnchor::AfterEmbed)); + + // Layer 0 runs full attention on the full-kind tables. + assert_eq!( + steps[2].render(), + "qkv y encoder.layers.0.attn.Wqkv.weight zeros.3072 qkv" + ); + assert_eq!( + steps[3].render(), + "rope qkv rope_full_cos rope_full_sin" + ); + assert_eq!(steps[4].render(), "attn_full qkv lens o"); + assert_eq!( + steps[5].render(), + "out o encoder.layers.0.attn.Wo.weight zeros.1024 y" + ); + assert_eq!( + steps[6].render(), + "addln x y encoder.layers.0.mlp_norm.weight zeros.1024 y" + ); + assert_eq!( + steps[7].render(), + "geglu y encoder.layers.0.mlp.Wi.weight g" + ); + assert_eq!( + steps[8].render(), + "down g encoder.layers.0.mlp.Wo.weight zeros.1024 y" + ); + // Layer 0's post-MLP norm is layer 1's attention norm. + assert_eq!( + steps[9].render(), + "addln x y encoder.layers.1.attn_norm.weight zeros.1024 y" + ); + + // Layer 1 is sliding (layer 0 ends with its anchor step at index 10). + assert!(steps[12].render().starts_with("rope qkv rope_local")); + assert_eq!(steps[13].render(), "attn_local qkv lens o"); + + // The last layer's post-MLP norm is the final norm. + let last_layer_addln = steps + .iter() + .position(|s| s.render().contains("encoder.final_norm.weight")) + .unwrap(); + assert_eq!( + steps[last_layer_addln].render(), + "addln x y encoder.final_norm.weight zeros.1024 y" + ); + + // The type add closes the encoder plan. + let last = launches(&steps).last().unwrap().render(); + assert_eq!(last, "type y type_emb.weight types x"); + assert_eq!( + steps.last(), + Some(&PlanStep::Launch { + kernel: "type".into(), + args: vec![ + PlanArg::Scratch(ScratchSlot::Y), + PlanArg::Weight("type_emb.weight".into()), + PlanArg::Scratch(ScratchSlot::Types), + PlanArg::Scratch(ScratchSlot::X), + ] + }) + ); +} + +#[test] +fn vela_plan_runs_its_own_schedule_without_a_type_add() { + let steps = plan::encoder_plan(&vela_structure(), &EncoderPlanOptions::default()); + assert_eq!(launches(&steps).len(), 1 + 22 * 8); + assert!(!steps.iter().any(|s| s.render().contains("type_emb"))); + // Layer 21 is the last full-attention layer; zeros follow its width. + assert!(steps.iter().any(|s| s.render() + == "qkv y encoder.layers.21.attn.Wqkv.weight zeros.2304 qkv")); + assert!(steps.iter().any(|s| s.render() == "attn_full qkv lens o")); + assert_eq!( + steps.iter().filter(|s| s.render() == "attn_local qkv lens o").count(), + 14 + ); + assert!(steps.contains(&PlanStep::Anchor(PlanAnchor::LayerEnd(21)))); + assert_eq!( + steps.iter().filter(|s| matches!(s, PlanStep::Anchor(PlanAnchor::LayerEnd(_)))).count(), + 22 + ); +} + +#[test] +fn original_rope_option_selects_the_reference_variant() { + let steps = plan::encoder_plan( + &laya_structure(), + &EncoderPlanOptions { + original_rope: true, + }, + ); + assert!(steps.iter().any(|s| s.render() + == "rope_original qkv rope_full_cos rope_full_sin")); +} + +#[test] +fn plan_rendering_is_deterministic() { + let once = plan::encoder_plan(&laya_structure(), &EncoderPlanOptions::default()); + let twice = plan::encoder_plan(&laya_structure(), &EncoderPlanOptions::default()); + let render = |steps: &[PlanStep]| { + steps.iter().map(PlanStep::render).collect::>() + }; + assert_eq!(render(&once), render(&twice)); + assert_eq!(render(&once).len(), once.len()); +} diff --git a/tests/modernbert/structure.rs b/tests/modernbert/structure.rs new file mode 100644 index 00000000..ba8d8491 --- /dev/null +++ b/tests/modernbert/structure.rs @@ -0,0 +1,390 @@ +use omni_modernbert::adapter::{LayaBundleAdapter, ModernBertConfigAdapter}; +use omni_modernbert::gate::{self, structure_identity}; +use omni_modernbert::geometry::{self, EncoderScratch}; +use omni_modernbert::structure::{AttentionKind, FieldOrigin}; +use serde_json::{Value, json}; +use std::fs; +use tempfile::{TempDir, tempdir}; + +fn laya_encoder_config() -> Value { + json!({ + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 1024, "intermediate_size": 2624, + "num_attention_heads": 16, "num_hidden_layers": 28, + "vocab_size": 50368, "norm_eps": 0.00001, "local_attention": 128, + "layer_types": (["full_attention", "sliding_attention", "sliding_attention"] + .repeat(10))[..28], + "rope_parameters": { + "full_attention": {"rope_type": "default", "rope_theta": 160000.0}, + "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0} + } + }) +} + +/// The pinned vllm-sr/Vela-2.0-0.3B@d6f03aa9 `encoder_config` member. +fn vela_encoder_config() -> Value { + json!({ + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 768, "intermediate_size": 1152, + "num_attention_heads": 12, "num_hidden_layers": 22, + "vocab_size": 256008, "norm_eps": 1e-5, "layer_norm_eps": 1e-5, + "local_attention": 128, "global_attn_every_n_layers": 3, + "global_rope_theta": 160000, "local_rope_theta": 160000, + "max_position_embeddings": 32768, "position_embedding_type": "sans_pos", + "rope_scaling": { + "rope_type": "yarn", "factor": 4.0, + "original_max_position_embeddings": 8192, + "beta_fast": 32.0, "beta_slow": 1.0, "truncate": true + }, + "cls_token_id": 1, "pad_token_id": 0 + }) +} + +fn laya_bundle(encoder: &Value) -> (TempDir, std::path::PathBuf) { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("encoder")).unwrap(); + fs::write( + dir.path().join("encoder/config.json"), + encoder.to_string(), + ) + .unwrap(); + let path = dir.path().to_path_buf(); + (dir, path) +} + +fn parse_laya(encoder: &Value) -> omni_modernbert::structure::ModernBertStructure { + let (_dir, path) = laya_bundle(encoder); + LayaBundleAdapter::read(&path).unwrap().0 +} + +fn parse_hf(config: &Value) -> omni_modernbert::structure::ModernBertStructure { + ModernBertConfigAdapter::from_value(config, "config.json#encoder_config") + .unwrap() + .0 +} + +#[test] +fn laya_bundle_adapter_parses_the_profile_with_provenance() { + let (_dir, path) = laya_bundle(&laya_encoder_config()); + let (structure, meta) = LayaBundleAdapter::read(&path).unwrap(); + assert_eq!(structure.hidden_size, 1024); + assert_eq!(structure.intermediate_size, 2624); + assert_eq!(structure.num_hidden_layers, 28); + assert_eq!(structure.num_attention_heads, 16); + assert_eq!(structure.head_dim, 64); + assert_eq!(structure.local_window, 128); + assert_eq!(structure.vocab_size, 50368); + assert_eq!(structure.type_embedding_rows, Some(3)); + assert_eq!(structure.rope_full.theta, 160000.0); + assert_eq!(structure.rope_sliding.theta, 10000.0); + assert_eq!(structure.max_positions, 512); + assert_eq!(meta.model_type, "modernbert"); + assert_eq!(meta.config_files, ["encoder/config.json"]); + assert_eq!( + structure.provenance.get("schedule"), + Some(&FieldOrigin::Source( + "encoder/config.json#layer_types".into() + )) + ); + assert_eq!( + structure.provenance.get("head_dim"), + Some(&FieldOrigin::Derived( + "hidden_size / num_attention_heads".into() + )) + ); + assert!(matches!( + structure.provenance.get("max_positions"), + Some(FieldOrigin::Default(_)) + )); +} + +#[test] +fn laya_bundle_adapter_rejects_marker_and_family_violations() { + for (pointer, value) in [ + ("/model_type", json!("bert")), + ("/hidden_activation", json!("relu")), + ("/attention_bias", json!(true)), + ("/mlp_bias", json!(true)), + ("/norm_bias", json!(true)), + ("/layer_types/0", json!("sparse_attention")), + ("/rope_parameters/full_attention/rope_type", json!("linear")), + ("/rope_parameters/sliding_attention/rope_theta", json!(null)), + ] { + let mut bad = laya_encoder_config(); + *bad.pointer_mut(pointer).unwrap() = value; + let (_dir, path) = laya_bundle(&bad); + assert!( + LayaBundleAdapter::read(&path).is_err(), + "accepted {pointer}" + ); + } + let mut missing = laya_encoder_config(); + missing.as_object_mut().unwrap().remove("layer_types"); + let (_dir, path) = laya_bundle(&missing); + assert!(LayaBundleAdapter::read(&path).is_err()); + let mut short = laya_encoder_config(); + short["layer_types"] = json!(["full_attention"]); + let (_dir, path) = laya_bundle(&short); + assert!(LayaBundleAdapter::read(&path).is_err()); +} + +#[test] +fn hf_adapter_parses_the_vela_profile_with_derived_schedule() { + let (structure, meta) = + ModernBertConfigAdapter::from_value(&vela_encoder_config(), "config.json#encoder_config") + .unwrap(); + assert_eq!(structure.hidden_size, 768); + assert_eq!(structure.intermediate_size, 1152); + assert_eq!(structure.num_hidden_layers, 22); + assert_eq!(structure.num_attention_heads, 12); + assert_eq!(structure.head_dim, 64); + assert_eq!(structure.vocab_size, 256008); + assert_eq!(structure.type_embedding_rows, None); + assert_eq!(structure.max_positions, 32768); + let full_layers: Vec<_> = structure + .schedule + .iter() + .enumerate() + .filter(|(_, k)| **k == AttentionKind::Full) + .map(|(i, _)| i) + .collect(); + assert_eq!(full_layers, [0, 3, 6, 9, 12, 15, 18, 21]); + assert_eq!(structure.rope_full.theta, 160000.0); + assert_eq!(structure.rope_sliding.theta, 160000.0); + let yarn = structure.rope_full.scaling.unwrap(); + assert_eq!(yarn.factor, 4.0); + assert_eq!(yarn.original_max_positions, 8192); + assert_eq!(structure.rope_full.scaling, structure.rope_sliding.scaling); + assert_eq!(meta.unrecognized_keys, ["cls_token_id", "pad_token_id"]); + match structure.provenance.get("schedule") { + Some(FieldOrigin::Derived(how)) => { + assert!(how.contains("global_attn_every_n_layers=3"), "{how}") + } + other => panic!("schedule provenance {other:?}"), + } +} + +#[test] +fn hf_adapter_accepts_explicit_layer_types_over_the_interval() { + let mut config = vela_encoder_config(); + config["global_attn_every_n_layers"] = json!(2); + config["layer_types"] = json!((vec!["sliding_attention"; 22])); + let structure = parse_hf(&config); + assert!(structure.schedule.iter().all(|k| *k == AttentionKind::Sliding)); + assert!(matches!( + structure.provenance.get("schedule"), + Some(FieldOrigin::Source(_)) + )); +} + +#[test] +fn hf_adapter_rejects_unknown_variants() { + for (name, config) in [ + ("model_type", { + let mut c = vela_encoder_config(); + c["model_type"] = json!("vela2-unified"); + c + }), + ("no schedule source", { + let mut c = vela_encoder_config(); + c.as_object_mut().unwrap().remove("global_attn_every_n_layers"); + c + }), + ("scaling type", { + let mut c = vela_encoder_config(); + c["rope_scaling"]["rope_type"] = json!("linear"); + c + }), + ("scaling truncate", { + let mut c = vela_encoder_config(); + c["rope_scaling"]["truncate"] = json!(false); + c + }), + ("position embedding", { + let mut c = vela_encoder_config(); + c["position_embedding_type"] = json!("absolute"); + c + }), + ("norm eps", { + let mut c = vela_encoder_config(); + c.as_object_mut().unwrap().remove("norm_eps"); + c.as_object_mut().unwrap().remove("layer_norm_eps"); + c + }), + ("yarn factor", { + let mut c = vela_encoder_config(); + c["rope_scaling"]["factor"] = json!(1.0); + c + }), + ] { + assert!( + ModernBertConfigAdapter::from_value(&config, "config.json#encoder_config").is_err(), + "accepted {name}" + ); + } +} + +#[test] +fn structure_invariants_name_the_violated_field() { + let mut structure = parse_laya(&laya_encoder_config()); + structure.num_attention_heads = 15; + assert!(structure.validate().unwrap_err().to_string().contains("15")); + + let mut structure = parse_laya(&laya_encoder_config()); + structure.schedule.pop(); + let message = structure.validate().unwrap_err().to_string(); + assert!(message.contains("schedule"), "{message}"); + + for mutate in [ + ("local_window", 0), + ("intermediate_size", 0), + ("vocab_size", 0), + ("num_hidden_layers", 0), + ] { + let mut structure = parse_laya(&laya_encoder_config()); + match mutate.0 { + "local_window" => structure.local_window = mutate.1, + "intermediate_size" => structure.intermediate_size = mutate.1, + "vocab_size" => structure.vocab_size = mutate.1, + _ => structure.num_hidden_layers = mutate.1, + } + assert!( + structure.validate().is_err(), + "accepted {} = 0", + mutate.0 + ); + } + let mut structure = parse_laya(&laya_encoder_config()); + structure.rope_full.theta = 0.0; + assert!(structure.validate().is_err()); +} + +#[test] +fn laya_profile_admits_its_structure_and_rejects_field_drift() { + let profile = gate::laya_0_3_20(); + let structure = parse_laya(&laya_encoder_config()); + let identity = profile.admit(&structure).unwrap(); + assert_eq!(identity.schema_version, 1); + assert_eq!(identity.family, "modernbert"); + assert_eq!(identity.backend, "laya_cuda"); + assert_eq!(identity.abi_version, 1); + + let vela = parse_hf(&vela_encoder_config()); + let message = profile.admit(&vela).unwrap_err().to_string(); + assert!(message.contains("hidden_size 768"), "{message}"); + assert!(message.contains("1024"), "{message}"); + + let mut drifted = parse_laya(&laya_encoder_config()); + drifted.rope_sliding.theta = 160000.0; + assert!(profile.admit(&drifted).is_err()); + + let mut drifted = parse_laya(&laya_encoder_config()); + drifted.schedule[1] = AttentionKind::Full; + assert!(profile.admit(&drifted).is_err()); + + let mut drifted = parse_laya(&laya_encoder_config()); + drifted.type_embedding_rows = None; + assert!(profile.admit(&drifted).is_err()); +} + +#[test] +fn backend_limits_check_the_request_envelope() { + let limits = &gate::laya_0_3_20().limits; + limits.check_request(16, 512).unwrap(); + limits.check_request(1, 16).unwrap(); + for (batch, len) in [(32, 512), (3, 512), (1, 8), (1, 513), (1, 520)] { + assert!( + limits.check_request(batch, len).is_err(), + "accepted batch {batch} len {len}" + ); + } +} + +#[test] +fn structure_identity_is_stable_and_sensitive() { + let laya = parse_laya(&laya_encoder_config()); + let again = parse_laya(&laya_encoder_config()); + assert_eq!( + structure_identity(&laya, "laya_cuda", 1), + structure_identity(&again, "laya_cuda", 1) + ); + let mut drifted = laya.clone(); + drifted.rope_sliding.theta = 160000.0; + assert_ne!( + structure_identity(&laya, "laya_cuda", 1), + structure_identity(&drifted, "laya_cuda", 1) + ); + let vela = parse_hf(&vela_encoder_config()); + assert_ne!( + structure_identity(&laya, "laya_cuda", 1), + structure_identity(&vela, "laya_cuda", 1) + ); + assert_ne!( + structure_identity(&laya, "laya_cuda", 1), + structure_identity(&laya, "laya_cuda", 2) + ); + // Provenance is not part of identity: same semantics, same digest. + let mut reworded = laya.clone(); + reworded.provenance.clear(); + assert_eq!( + structure_identity(&laya, "laya_cuda", 1), + structure_identity(&reworded, "laya_cuda", 1) + ); +} + +#[test] +fn derived_geometry_matches_the_laya_checkpoint_inventory() { + let laya = parse_laya(&laya_encoder_config()); + let tensors = geometry::encoder_tensors(&laya); + assert_eq!(tensors.len(), 171); + assert_eq!(tensors.len(), 3 + 28 * 5 + 27 + 1); + let find = |name: &str| tensors.iter().find(|(n, _)| n == name).unwrap(); + assert_eq!( + find("encoder.embeddings.tok_embeddings.weight").1, + vec![50368, 1024] + ); + assert_eq!(find("encoder.layers.0.mlp_norm.weight").1, vec![1024]); + assert_eq!( + find("encoder.layers.0.mlp.Wi.weight").1, + vec![5248, 1024] + ); + let attn_norm_count = tensors + .iter() + .filter(|(name, _)| name.ends_with("attn_norm.weight")) + .count(); + assert_eq!(attn_norm_count, 27); + assert_eq!(tensors.last().unwrap().0, "type_emb.weight"); + assert_eq!(tensors.last().unwrap().1, vec![3, 1024]); + + let vela = parse_hf(&vela_encoder_config()); + let vela_tensors = geometry::encoder_tensors(&vela); + assert_eq!(vela_tensors.len(), 3 + 22 * 5 + 21); + let find_vela = |name: &str| vela_tensors.iter().find(|(n, _)| n == name).unwrap(); + assert_eq!( + find_vela("encoder.layers.0.mlp.Wi.weight").1, + vec![2304, 768] + ); + assert!(vela_tensors.iter().all(|(name, _)| name != "type_emb.weight")); + + assert_eq!(geometry::rotary_half(&laya), 32); + assert_eq!(geometry::rope_table_bytes(&laya), 512 * 32 * 4); +} + +#[test] +fn encoder_scratch_sizes_follow_the_structure() { + let laya = parse_laya(&laya_encoder_config()); + let scratch = EncoderScratch::new(1, 512, &laya); + assert_eq!( + scratch.sizes(), + vec![512 * 8, 4, 512 * 1024 * 4, 512 * 1024 * 2, 512 * 3072 * 2, 512 * 1024 * 2, 512 * 2624 * 2] + ); + let total: usize = scratch.sizes().iter().sum(); + assert_eq!(scratch.total(), total); + let vela = parse_hf(&vela_encoder_config()); + assert_eq!( + EncoderScratch::new(1, 512, &vela).qkv, + 512 * 3 * 768 * 2 + ); +} From 1d2d956281fd4836114c40350b592da12c624a01 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 01:03:53 +0800 Subject: [PATCH 03/21] laya: split the execution plan into a pure builder The encoder-plus-head plan moves from prepare_encoder into plan::plan_encoder, a pure function of the original_rope flag that emits kernel names and buffer roles with no CUDA or weight pointers. prepare_encoder becomes a resolver that maps each planned step to a loaded kernel symbol and device pointer, preserving the exact step sequence. The full sequence for both rope variants is recorded as golden fixtures (251 steps: embed, 28 layers with full attention every third layer, type add, two head layers, and the debug dumps) and pinned by a CPU test, so any later change to the plan construction has to survive a byte comparison against today's behavior. No execution change: same kernels, same order, same arguments. Signed-off-by: Mr.Dong <1042542469@qq.com> --- src/models/laya/Cargo.toml | 4 + src/models/laya/src/lib.rs | 1 + src/models/laya/src/model.rs | 255 +++------------- src/models/laya/src/plan.rs | 288 ++++++++++++++++++ .../laya/data/encoder-plan-original-rope.txt | 251 +++++++++++++++ tests/laya/data/encoder-plan.txt | 251 +++++++++++++++ tests/laya/plan.rs | 37 +++ 7 files changed, 869 insertions(+), 218 deletions(-) create mode 100644 src/models/laya/src/plan.rs create mode 100644 tests/laya/data/encoder-plan-original-rope.txt create mode 100644 tests/laya/data/encoder-plan.txt create mode 100644 tests/laya/plan.rs diff --git a/src/models/laya/Cargo.toml b/src/models/laya/Cargo.toml index d8a16ff5..8ce8895d 100644 --- a/src/models/laya/Cargo.toml +++ b/src/models/laya/Cargo.toml @@ -48,3 +48,7 @@ path = "../../../tests/laya/decision.rs" [[test]] name = "batch" path = "../../../tests/laya/batch.rs" + +[[test]] +name = "plan" +path = "../../../tests/laya/plan.rs" diff --git a/src/models/laya/src/lib.rs b/src/models/laya/src/lib.rs index 7db9d9ad..f99c42dd 100644 --- a/src/models/laya/src/lib.rs +++ b/src/models/laya/src/lib.rs @@ -2,6 +2,7 @@ pub mod artifacts; pub mod config; pub mod decision; pub mod packing; +pub mod plan; pub mod preprocess; pub mod processing; pub mod weights; diff --git a/src/models/laya/src/model.rs b/src/models/laya/src/model.rs index 54f6aa67..e24d7fca 100644 --- a/src/models/laya/src/model.rs +++ b/src/models/laya/src/model.rs @@ -1,5 +1,8 @@ //! Single-GPU Laya executor. All CUDA buffers and graphs remain on the owning worker thread. -use crate::{config::Config, packing::Batch, weights::Weights}; +use crate::config::Config; +use crate::packing::Batch; +use crate::plan::{PlannedArg, PlannedStep, Scratch, plan_encoder}; +use crate::weights::Weights; use anyhow::{Context, Result, ensure}; use half::bf16; use omni_cuda::{Buffer, Cuda, Graph, Kernel, Ptr}; @@ -212,18 +215,6 @@ impl Default for CacheConfig { } } -#[derive(Clone, Copy)] -enum Scratch { - Ids, - Lens, - Types, - X, - Y, - Qkv, - O, - G, - Ff, -} impl Scratch { fn buffer(self, s: &Workspace) -> &Buffer { match self { @@ -300,216 +291,44 @@ enum Step { }, } +fn resolve_step( + cuda: &Cuda, + weights: &HashMap, + planned: &PlannedStep, +) -> Result { + match planned { + PlannedStep::Launch { kernel, args } => { + ensure!(args.len() <= 5, "encoder argument count"); + let mut resolved = [Argument::Weight(std::ptr::null_mut()); 5]; + for (slot, arg) in resolved.iter_mut().zip(args) { + *slot = match arg { + PlannedArg::Weight(name) => Argument::Weight(weights[name].ptr()), + PlannedArg::Scratch(s) => Argument::Scratch(*s), + }; + } + Ok(Step::Launch { + kernel: SelectedKernel::resolve(cuda, kernel)?, + args: resolved, + count: args.len(), + }) + } + PlannedStep::Dump { name, slot, bf } => Ok(Step::Dump { + name: name.clone(), + slot: *slot, + bf: *bf, + }), + } +} + fn prepare_encoder( cuda: &Cuda, weights: &HashMap, original_rope: bool, ) -> Result> { - let steps = std::cell::RefCell::new(Vec::new()); - let weight = |name: &str| Argument::Weight(weights[name].ptr()); - let call = |name: &str, args: &[Argument]| -> Result<()> { - ensure!(args.len() <= 5, "encoder argument count"); - let mut resolved = [Argument::Weight(std::ptr::null_mut()); 5]; - resolved[..args.len()].copy_from_slice(args); - steps.borrow_mut().push(Step::Launch { - kernel: SelectedKernel::resolve(cuda, name)?, - args: resolved, - count: args.len(), - }); - Ok(()) - }; - let dump = |name: &str, slot: Scratch, bf: bool| { - steps.borrow_mut().push(Step::Dump { - name: name.to_owned(), - slot, - bf, - }); - Ok::<_, anyhow::Error>(()) - }; - let z = weight("zeros.1024"); - let attention = |label: &str| format!("attn_{label}"); - call( - "embed", - &[ - Argument::Scratch(Scratch::Ids), - weight("encoder.embeddings.tok_embeddings.weight"), - weight("encoder.embeddings.norm.weight"), - Argument::Scratch(Scratch::X), - Argument::Scratch(Scratch::Y), - ], - )?; - dump("embedding", Scratch::X, false)?; - for i in 0..28 { - let p = format!("encoder.layers.{i}"); - let w = |n: &str| weight(&format!("{p}.{n}")); - call( - "qkv", - &[ - Argument::Scratch(Scratch::Y), - w("attn.Wqkv.weight"), - weight("zeros.3072"), - Argument::Scratch(Scratch::Qkv), - ], - )?; - let kind = if i % 3 == 0 { "full" } else { "local" }; - call( - if original_rope { - "rope_original" - } else { - "rope" - }, - &[ - Argument::Scratch(Scratch::Qkv), - weight(&format!("rope_{kind}_cos")), - weight(&format!("rope_{kind}_sin")), - ], - )?; - call( - &attention(if i % 3 == 0 { "full" } else { "local" }), - &[ - Argument::Scratch(Scratch::Qkv), - Argument::Scratch(Scratch::Lens), - Argument::Scratch(Scratch::O), - ], - )?; - call( - "out", - &[ - Argument::Scratch(Scratch::O), - w("attn.Wo.weight"), - z, - Argument::Scratch(Scratch::Y), - ], - )?; - call( - "addln", - &[ - Argument::Scratch(Scratch::X), - Argument::Scratch(Scratch::Y), - w("mlp_norm.weight"), - z, - Argument::Scratch(Scratch::Y), - ], - )?; - call( - "geglu", - &[ - Argument::Scratch(Scratch::Y), - w("mlp.Wi.weight"), - Argument::Scratch(Scratch::G), - ], - )?; - call( - "down", - &[ - Argument::Scratch(Scratch::G), - w("mlp.Wo.weight"), - z, - Argument::Scratch(Scratch::Y), - ], - )?; - let next = if i < 27 { - weight(&format!("encoder.layers.{}.attn_norm.weight", i + 1)) - } else { - weight("encoder.final_norm.weight") - }; - call( - "addln", - &[ - Argument::Scratch(Scratch::X), - Argument::Scratch(Scratch::Y), - next, - z, - Argument::Scratch(Scratch::Y), - ], - )?; - if [0, 1, 2, 27].contains(&i) { - dump(&format!("encoder{i}_residual"), Scratch::X, false)?; - dump(&format!("encoder{i}_normalized"), Scratch::Y, true)?; - } - } - call( - "type", - &[ - Argument::Scratch(Scratch::Y), - weight("type_emb.weight"), - Argument::Scratch(Scratch::Types), - Argument::Scratch(Scratch::X), - ], - )?; - for i in 0..2 { - let p = format!("head.layers.{i}"); - let w = |n: &str| weight(&format!("{p}.{n}")); - call( - "ln_bias", - &[ - Argument::Scratch(Scratch::X), - Argument::Scratch(Scratch::Y), - w("norm1.weight"), - w("norm1.bias"), - Argument::Scratch(Scratch::Y), - ], - )?; - call( - "head_in", - &[ - Argument::Scratch(Scratch::Y), - w("self_attn.in_proj_weight"), - w("self_attn.in_proj_bias"), - Argument::Scratch(Scratch::Qkv), - ], - )?; - call( - &attention("full"), - &[ - Argument::Scratch(Scratch::Qkv), - Argument::Scratch(Scratch::Lens), - Argument::Scratch(Scratch::O), - ], - )?; - call( - "head_out", - &[ - Argument::Scratch(Scratch::O), - w("self_attn.out_proj.weight"), - w("self_attn.out_proj.bias"), - Argument::Scratch(Scratch::Y), - ], - )?; - call( - "addln_bias", - &[ - Argument::Scratch(Scratch::X), - Argument::Scratch(Scratch::Y), - w("norm2.weight"), - w("norm2.bias"), - Argument::Scratch(Scratch::Y), - ], - )?; - call( - "ffn1", - &[ - Argument::Scratch(Scratch::Y), - w("linear1.weight"), - w("linear1.bias"), - Argument::Scratch(Scratch::Ff), - ], - )?; - call( - "ffn2", - &[ - Argument::Scratch(Scratch::Ff), - w("linear2.weight"), - w("linear2.bias"), - Argument::Scratch(Scratch::Y), - ], - )?; - call( - "residual", - &[Argument::Scratch(Scratch::X), Argument::Scratch(Scratch::Y)], - )?; - } - Ok(steps.into_inner()) + plan_encoder(original_rope) + .iter() + .map(|planned| resolve_step(cuda, weights, planned)) + .collect() } pub struct Model { diff --git a/src/models/laya/src/plan.rs b/src/models/laya/src/plan.rs new file mode 100644 index 00000000..b0f4545f --- /dev/null +++ b/src/models/laya/src/plan.rs @@ -0,0 +1,288 @@ +//! The encoder-plus-head execution plan as pure data: kernel names and +//! buffer roles, before any CUDA symbol or weight pointer is resolved. +//! Keeping the plan pure lets CPU fixtures pin the exact step sequence. + +/// Scratch buffers the plan addresses; allocation lives in the executor. +#[derive(Clone, Copy)] +pub enum Scratch { + Ids, + Lens, + Types, + X, + Y, + Qkv, + O, + G, + Ff, +} + +impl Scratch { + pub fn name(self) -> &'static str { + match self { + Self::Ids => "ids", + Self::Lens => "lens", + Self::Types => "types", + Self::X => "x", + Self::Y => "y", + Self::Qkv => "qkv", + Self::O => "o", + Self::G => "g", + Self::Ff => "ff", + } + } +} + +#[derive(Clone)] +pub enum PlannedArg { + Weight(String), + Scratch(Scratch), +} + +#[derive(Clone)] +pub enum PlannedStep { + Launch { + kernel: String, + args: Vec, + }, + Dump { + name: String, + slot: Scratch, + bf: bool, + }, +} + +impl PlannedStep { + /// One-line canonical rendering used by the plan fixtures. + pub fn render(&self) -> String { + match self { + Self::Launch { kernel, args } => { + let mut line = format!("launch {kernel}"); + for arg in args { + line.push(' '); + line.push_str(&match arg { + PlannedArg::Weight(name) => name.clone(), + PlannedArg::Scratch(slot) => slot.name().to_string(), + }); + } + line + } + Self::Dump { name, slot, bf } => { + format!("dump {name} {} {}", slot.name(), u8::from(*bf)) + } + } + } +} + +/// The single supported Laya encoder: 28 layers with full attention +/// every third layer, the task head's two transformer layers, and the +/// readout tail left to eager launches at inference time. +pub fn plan_encoder(original_rope: bool) -> Vec { + let steps = std::cell::RefCell::new(Vec::new()); + let weight = |name: &str| PlannedArg::Weight(name.to_string()); + let scratch = |slot: Scratch| PlannedArg::Scratch(slot); + let call = |kernel: &str, args: &[PlannedArg]| { + steps.borrow_mut().push(PlannedStep::Launch { + kernel: kernel.to_string(), + args: args.to_vec(), + }); + }; + let dump = |name: &str, slot: Scratch, bf: bool| { + steps.borrow_mut().push(PlannedStep::Dump { + name: name.to_string(), + slot, + bf, + }); + }; + let attention = |label: &str| format!("attn_{label}"); + call( + "embed", + &[ + scratch(Scratch::Ids), + weight("encoder.embeddings.tok_embeddings.weight"), + weight("encoder.embeddings.norm.weight"), + scratch(Scratch::X), + scratch(Scratch::Y), + ], + ); + dump("embedding", Scratch::X, false); + for i in 0..28 { + let p = format!("encoder.layers.{i}"); + let w = |n: &str| weight(&format!("{p}.{n}")); + call( + "qkv", + &[ + scratch(Scratch::Y), + w("attn.Wqkv.weight"), + weight("zeros.3072"), + scratch(Scratch::Qkv), + ], + ); + let kind = if i % 3 == 0 { "full" } else { "local" }; + call( + if original_rope { + "rope_original" + } else { + "rope" + }, + &[ + scratch(Scratch::Qkv), + weight(&format!("rope_{kind}_cos")), + weight(&format!("rope_{kind}_sin")), + ], + ); + call( + &attention(if i % 3 == 0 { "full" } else { "local" }), + &[ + scratch(Scratch::Qkv), + scratch(Scratch::Lens), + scratch(Scratch::O), + ], + ); + call( + "out", + &[ + scratch(Scratch::O), + w("attn.Wo.weight"), + weight("zeros.1024"), + scratch(Scratch::Y), + ], + ); + call( + "addln", + &[ + scratch(Scratch::X), + scratch(Scratch::Y), + w("mlp_norm.weight"), + weight("zeros.1024"), + scratch(Scratch::Y), + ], + ); + call( + "geglu", + &[ + scratch(Scratch::Y), + w("mlp.Wi.weight"), + scratch(Scratch::G), + ], + ); + call( + "down", + &[ + scratch(Scratch::G), + w("mlp.Wo.weight"), + weight("zeros.1024"), + scratch(Scratch::Y), + ], + ); + let next = if i < 27 { + weight(&format!("encoder.layers.{}.attn_norm.weight", i + 1)) + } else { + weight("encoder.final_norm.weight") + }; + call( + "addln", + &[ + scratch(Scratch::X), + scratch(Scratch::Y), + next, + weight("zeros.1024"), + scratch(Scratch::Y), + ], + ); + if [0, 1, 2, 27].contains(&i) { + dump(&format!("encoder{i}_residual"), Scratch::X, false); + dump(&format!("encoder{i}_normalized"), Scratch::Y, true); + } + } + call( + "type", + &[ + scratch(Scratch::Y), + weight("type_emb.weight"), + scratch(Scratch::Types), + scratch(Scratch::X), + ], + ); + for i in 0..2 { + let p = format!("head.layers.{i}"); + let w = |n: &str| weight(&format!("{p}.{n}")); + call( + "ln_bias", + &[ + scratch(Scratch::X), + scratch(Scratch::Y), + w("norm1.weight"), + w("norm1.bias"), + scratch(Scratch::Y), + ], + ); + call( + "head_in", + &[ + scratch(Scratch::Y), + w("self_attn.in_proj_weight"), + w("self_attn.in_proj_bias"), + scratch(Scratch::Qkv), + ], + ); + call( + &attention("full"), + &[ + scratch(Scratch::Qkv), + scratch(Scratch::Lens), + scratch(Scratch::O), + ], + ); + call( + "head_out", + &[ + scratch(Scratch::O), + w("self_attn.out_proj.weight"), + w("self_attn.out_proj.bias"), + scratch(Scratch::Y), + ], + ); + call( + "addln_bias", + &[ + scratch(Scratch::X), + scratch(Scratch::Y), + w("norm2.weight"), + w("norm2.bias"), + scratch(Scratch::Y), + ], + ); + call( + "ffn1", + &[ + scratch(Scratch::Y), + w("linear1.weight"), + w("linear1.bias"), + scratch(Scratch::Ff), + ], + ); + call( + "ffn2", + &[ + scratch(Scratch::Ff), + w("linear2.weight"), + w("linear2.bias"), + scratch(Scratch::Y), + ], + ); + call( + "residual", + &[scratch(Scratch::X), scratch(Scratch::Y)], + ); + } + steps.into_inner() +} + +/// Renders a full plan, one step per line, for fixture comparison. +pub fn render_plan(plan: &[PlannedStep]) -> String { + plan.iter() + .map(PlannedStep::render) + .collect::>() + .join("\n") + + "\n" +} diff --git a/tests/laya/data/encoder-plan-original-rope.txt b/tests/laya/data/encoder-plan-original-rope.txt new file mode 100644 index 00000000..eec8a07e --- /dev/null +++ b/tests/laya/data/encoder-plan-original-rope.txt @@ -0,0 +1,251 @@ +launch embed ids encoder.embeddings.tok_embeddings.weight encoder.embeddings.norm.weight x y +dump embedding x 0 +launch qkv y encoder.layers.0.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.0.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.0.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.0.mlp.Wi.weight g +launch down g encoder.layers.0.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.1.attn_norm.weight zeros.1024 y +dump encoder0_residual x 0 +dump encoder0_normalized y 1 +launch qkv y encoder.layers.1.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.1.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.1.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.1.mlp.Wi.weight g +launch down g encoder.layers.1.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.2.attn_norm.weight zeros.1024 y +dump encoder1_residual x 0 +dump encoder1_normalized y 1 +launch qkv y encoder.layers.2.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.2.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.2.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.2.mlp.Wi.weight g +launch down g encoder.layers.2.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.3.attn_norm.weight zeros.1024 y +dump encoder2_residual x 0 +dump encoder2_normalized y 1 +launch qkv y encoder.layers.3.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.3.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.3.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.3.mlp.Wi.weight g +launch down g encoder.layers.3.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.4.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.4.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.4.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.4.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.4.mlp.Wi.weight g +launch down g encoder.layers.4.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.5.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.5.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.5.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.5.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.5.mlp.Wi.weight g +launch down g encoder.layers.5.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.6.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.6.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.6.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.6.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.6.mlp.Wi.weight g +launch down g encoder.layers.6.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.7.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.7.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.7.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.7.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.7.mlp.Wi.weight g +launch down g encoder.layers.7.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.8.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.8.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.8.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.8.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.8.mlp.Wi.weight g +launch down g encoder.layers.8.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.9.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.9.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.9.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.9.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.9.mlp.Wi.weight g +launch down g encoder.layers.9.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.10.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.10.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.10.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.10.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.10.mlp.Wi.weight g +launch down g encoder.layers.10.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.11.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.11.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.11.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.11.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.11.mlp.Wi.weight g +launch down g encoder.layers.11.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.12.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.12.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.12.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.12.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.12.mlp.Wi.weight g +launch down g encoder.layers.12.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.13.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.13.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.13.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.13.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.13.mlp.Wi.weight g +launch down g encoder.layers.13.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.14.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.14.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.14.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.14.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.14.mlp.Wi.weight g +launch down g encoder.layers.14.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.15.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.15.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.15.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.15.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.15.mlp.Wi.weight g +launch down g encoder.layers.15.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.16.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.16.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.16.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.16.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.16.mlp.Wi.weight g +launch down g encoder.layers.16.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.17.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.17.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.17.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.17.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.17.mlp.Wi.weight g +launch down g encoder.layers.17.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.18.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.18.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.18.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.18.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.18.mlp.Wi.weight g +launch down g encoder.layers.18.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.19.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.19.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.19.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.19.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.19.mlp.Wi.weight g +launch down g encoder.layers.19.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.20.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.20.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.20.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.20.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.20.mlp.Wi.weight g +launch down g encoder.layers.20.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.21.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.21.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.21.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.21.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.21.mlp.Wi.weight g +launch down g encoder.layers.21.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.22.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.22.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.22.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.22.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.22.mlp.Wi.weight g +launch down g encoder.layers.22.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.23.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.23.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.23.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.23.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.23.mlp.Wi.weight g +launch down g encoder.layers.23.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.24.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.24.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.24.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.24.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.24.mlp.Wi.weight g +launch down g encoder.layers.24.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.25.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.25.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.25.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.25.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.25.mlp.Wi.weight g +launch down g encoder.layers.25.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.26.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.26.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.26.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.26.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.26.mlp.Wi.weight g +launch down g encoder.layers.26.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.27.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.27.attn.Wqkv.weight zeros.3072 qkv +launch rope_original qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.27.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.27.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.27.mlp.Wi.weight g +launch down g encoder.layers.27.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.final_norm.weight zeros.1024 y +dump encoder27_residual x 0 +dump encoder27_normalized y 1 +launch type y type_emb.weight types x +launch ln_bias x y head.layers.0.norm1.weight head.layers.0.norm1.bias y +launch head_in y head.layers.0.self_attn.in_proj_weight head.layers.0.self_attn.in_proj_bias qkv +launch attn_full qkv lens o +launch head_out o head.layers.0.self_attn.out_proj.weight head.layers.0.self_attn.out_proj.bias y +launch addln_bias x y head.layers.0.norm2.weight head.layers.0.norm2.bias y +launch ffn1 y head.layers.0.linear1.weight head.layers.0.linear1.bias ff +launch ffn2 ff head.layers.0.linear2.weight head.layers.0.linear2.bias y +launch residual x y +launch ln_bias x y head.layers.1.norm1.weight head.layers.1.norm1.bias y +launch head_in y head.layers.1.self_attn.in_proj_weight head.layers.1.self_attn.in_proj_bias qkv +launch attn_full qkv lens o +launch head_out o head.layers.1.self_attn.out_proj.weight head.layers.1.self_attn.out_proj.bias y +launch addln_bias x y head.layers.1.norm2.weight head.layers.1.norm2.bias y +launch ffn1 y head.layers.1.linear1.weight head.layers.1.linear1.bias ff +launch ffn2 ff head.layers.1.linear2.weight head.layers.1.linear2.bias y +launch residual x y diff --git a/tests/laya/data/encoder-plan.txt b/tests/laya/data/encoder-plan.txt new file mode 100644 index 00000000..4f1df250 --- /dev/null +++ b/tests/laya/data/encoder-plan.txt @@ -0,0 +1,251 @@ +launch embed ids encoder.embeddings.tok_embeddings.weight encoder.embeddings.norm.weight x y +dump embedding x 0 +launch qkv y encoder.layers.0.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.0.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.0.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.0.mlp.Wi.weight g +launch down g encoder.layers.0.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.1.attn_norm.weight zeros.1024 y +dump encoder0_residual x 0 +dump encoder0_normalized y 1 +launch qkv y encoder.layers.1.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.1.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.1.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.1.mlp.Wi.weight g +launch down g encoder.layers.1.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.2.attn_norm.weight zeros.1024 y +dump encoder1_residual x 0 +dump encoder1_normalized y 1 +launch qkv y encoder.layers.2.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.2.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.2.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.2.mlp.Wi.weight g +launch down g encoder.layers.2.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.3.attn_norm.weight zeros.1024 y +dump encoder2_residual x 0 +dump encoder2_normalized y 1 +launch qkv y encoder.layers.3.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.3.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.3.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.3.mlp.Wi.weight g +launch down g encoder.layers.3.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.4.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.4.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.4.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.4.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.4.mlp.Wi.weight g +launch down g encoder.layers.4.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.5.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.5.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.5.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.5.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.5.mlp.Wi.weight g +launch down g encoder.layers.5.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.6.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.6.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.6.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.6.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.6.mlp.Wi.weight g +launch down g encoder.layers.6.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.7.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.7.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.7.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.7.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.7.mlp.Wi.weight g +launch down g encoder.layers.7.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.8.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.8.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.8.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.8.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.8.mlp.Wi.weight g +launch down g encoder.layers.8.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.9.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.9.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.9.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.9.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.9.mlp.Wi.weight g +launch down g encoder.layers.9.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.10.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.10.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.10.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.10.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.10.mlp.Wi.weight g +launch down g encoder.layers.10.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.11.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.11.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.11.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.11.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.11.mlp.Wi.weight g +launch down g encoder.layers.11.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.12.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.12.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.12.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.12.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.12.mlp.Wi.weight g +launch down g encoder.layers.12.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.13.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.13.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.13.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.13.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.13.mlp.Wi.weight g +launch down g encoder.layers.13.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.14.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.14.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.14.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.14.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.14.mlp.Wi.weight g +launch down g encoder.layers.14.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.15.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.15.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.15.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.15.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.15.mlp.Wi.weight g +launch down g encoder.layers.15.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.16.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.16.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.16.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.16.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.16.mlp.Wi.weight g +launch down g encoder.layers.16.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.17.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.17.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.17.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.17.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.17.mlp.Wi.weight g +launch down g encoder.layers.17.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.18.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.18.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.18.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.18.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.18.mlp.Wi.weight g +launch down g encoder.layers.18.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.19.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.19.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.19.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.19.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.19.mlp.Wi.weight g +launch down g encoder.layers.19.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.20.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.20.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.20.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.20.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.20.mlp.Wi.weight g +launch down g encoder.layers.20.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.21.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.21.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.21.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.21.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.21.mlp.Wi.weight g +launch down g encoder.layers.21.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.22.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.22.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.22.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.22.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.22.mlp.Wi.weight g +launch down g encoder.layers.22.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.23.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.23.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.23.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.23.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.23.mlp.Wi.weight g +launch down g encoder.layers.23.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.24.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.24.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.24.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.24.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.24.mlp.Wi.weight g +launch down g encoder.layers.24.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.25.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.25.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.25.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.25.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.25.mlp.Wi.weight g +launch down g encoder.layers.25.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.26.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.26.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_local_cos rope_local_sin +launch attn_local qkv lens o +launch out o encoder.layers.26.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.26.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.26.mlp.Wi.weight g +launch down g encoder.layers.26.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.layers.27.attn_norm.weight zeros.1024 y +launch qkv y encoder.layers.27.attn.Wqkv.weight zeros.3072 qkv +launch rope qkv rope_full_cos rope_full_sin +launch attn_full qkv lens o +launch out o encoder.layers.27.attn.Wo.weight zeros.1024 y +launch addln x y encoder.layers.27.mlp_norm.weight zeros.1024 y +launch geglu y encoder.layers.27.mlp.Wi.weight g +launch down g encoder.layers.27.mlp.Wo.weight zeros.1024 y +launch addln x y encoder.final_norm.weight zeros.1024 y +dump encoder27_residual x 0 +dump encoder27_normalized y 1 +launch type y type_emb.weight types x +launch ln_bias x y head.layers.0.norm1.weight head.layers.0.norm1.bias y +launch head_in y head.layers.0.self_attn.in_proj_weight head.layers.0.self_attn.in_proj_bias qkv +launch attn_full qkv lens o +launch head_out o head.layers.0.self_attn.out_proj.weight head.layers.0.self_attn.out_proj.bias y +launch addln_bias x y head.layers.0.norm2.weight head.layers.0.norm2.bias y +launch ffn1 y head.layers.0.linear1.weight head.layers.0.linear1.bias ff +launch ffn2 ff head.layers.0.linear2.weight head.layers.0.linear2.bias y +launch residual x y +launch ln_bias x y head.layers.1.norm1.weight head.layers.1.norm1.bias y +launch head_in y head.layers.1.self_attn.in_proj_weight head.layers.1.self_attn.in_proj_bias qkv +launch attn_full qkv lens o +launch head_out o head.layers.1.self_attn.out_proj.weight head.layers.1.self_attn.out_proj.bias y +launch addln_bias x y head.layers.1.norm2.weight head.layers.1.norm2.bias y +launch ffn1 y head.layers.1.linear1.weight head.layers.1.linear1.bias ff +launch ffn2 ff head.layers.1.linear2.weight head.layers.1.linear2.bias y +launch residual x y diff --git a/tests/laya/plan.rs b/tests/laya/plan.rs new file mode 100644 index 00000000..47dfbec3 --- /dev/null +++ b/tests/laya/plan.rs @@ -0,0 +1,37 @@ +use omni_laya::plan::{plan_encoder, render_plan}; + +#[test] +fn encoder_plan_matches_the_recorded_sequence() { + assert_eq!( + render_plan(&plan_encoder(false)), + include_str!("data/encoder-plan.txt") + ); + assert_eq!( + render_plan(&plan_encoder(true)), + include_str!("data/encoder-plan-original-rope.txt") + ); +} + +#[test] +fn encoder_plan_shape_is_the_supported_profile() { + let plan = plan_encoder(false); + let launches = plan + .iter() + .filter(|s| matches!(s, omni_laya::plan::PlannedStep::Launch { .. })) + .count(); + // embed + 28 layers x 8 kernels + type add + 2 head layers x 8 kernels. + assert_eq!(launches, 1 + 28 * 8 + 1 + 2 * 8); + let rendered = render_plan(&plan); + assert_eq!(rendered.matches("attn_full").count(), 12); + assert_eq!(rendered.matches("attn_local").count(), 18); + assert!(rendered.contains("addln x y encoder.final_norm.weight zeros.1024 y")); + assert!(rendered.ends_with("residual x y\n")); + // The two rope variants differ in exactly the 28 rope kernel names. + let original = render_plan(&plan_encoder(true)); + let differences = rendered + .lines() + .zip(original.lines()) + .filter(|(a, b)| a != b) + .count(); + assert_eq!(differences, 28); +} From 0e6eaea24d6f2ccbc9a4ab3c713afcc9d94702c5 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 01:09:22 +0800 Subject: [PATCH 04/21] laya: run the encoder on the modernbert family core The native executor consumes the family layer added two commits ago; behavior is unchanged and every existing suite stays green. - config: Config::load becomes a facade over the Laya bundle adapter and the registered laya-0.3.20 profile, projecting the admitted structure onto the public EncoderConfig fields; the agent bundle checks stay in the Laya crate. Config carries the admitted structure for everything downstream. - plan: plan_encoder now generates the encoder half from the family core (structure-driven layer count, schedule and widths), hooks this bundle's debug dumps to the sequence anchors, and appends the Laya-owned task-head plan. The recorded golden fixtures still match byte for byte, so the executed sequence is provably identical. - weights: the encoder tensor inventory comes from the family geometry; the task head keeps its own inventory (head layers, scorer, action head, legacy temperature buffer). - model: workspace scratch sizes derive from the structure (ids, lens, x, y, qkv, o, geglu gate) with the task-side buffers appended; the rotary table size check and the vocab bound in infer come from the admitted structure instead of literals. - packing: the vocabulary bound is a pack() argument supplied by the processor from the admitted structure; the pad token stays a tokenizer constant. The 1024 hidden width now survives only where it is task geometry (head ffn widths, scorer and action-head dims) or the task inventory. The CUDA bundle, its ABI and the storage-dtype policy are untouched. Signed-off-by: Mr.Dong <1042542469@qq.com> --- Cargo.lock | 1 + src/models/laya/Cargo.toml | 1 + src/models/laya/src/config.rs | 72 +++++------ src/models/laya/src/model.rs | 48 ++++---- src/models/laya/src/packing.rs | 4 +- src/models/laya/src/plan.rs | 190 +++++++++++------------------- src/models/laya/src/processing.rs | 7 +- src/models/laya/src/weights.rs | 51 +++----- tests/laya/batch.rs | 12 +- tests/laya/model.rs | 36 ++++++ tests/laya/model_cache.rs | 22 ++-- tests/laya/model_dispatch.rs | 12 +- tests/laya/model_fixture.rs | 5 +- tests/laya/plan.rs | 12 +- 14 files changed, 228 insertions(+), 245 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4fc86d2..9791fc1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,6 +1231,7 @@ dependencies = [ "half", "memmap2", "omni-cuda", + "omni-modernbert", "omni-runtime", "safetensors 0.6.2", "serde", diff --git a/src/models/laya/Cargo.toml b/src/models/laya/Cargo.toml index 8ce8895d..c9db17e2 100644 --- a/src/models/laya/Cargo.toml +++ b/src/models/laya/Cargo.toml @@ -14,6 +14,7 @@ serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision tokenizers = { version = "0.23.2", default-features = false, features = ["fancy-regex"] } sha2 = "0.10" omni-cuda = { path = "../../backends/cuda", optional = true } +omni-modernbert = { path = "../modernbert" } omni-runtime = { path = "../../runtime", optional = true } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync"], optional = true } axum = { version = "0.8", optional = true } diff --git a/src/models/laya/src/config.rs b/src/models/laya/src/config.rs index 8b85c393..9055bf12 100644 --- a/src/models/laya/src/config.rs +++ b/src/models/laya/src/config.rs @@ -1,4 +1,6 @@ use anyhow::{Context, Result, ensure}; +use omni_modernbert::gate; +use omni_modernbert::structure::ModernBertStructure; use serde::Deserialize; use std::{collections::HashMap, fs, path::Path}; @@ -24,59 +26,41 @@ pub struct EncoderConfig { pub rope_parameters: serde_json::Value, } +impl From for EncoderConfig { + fn from(s: ModernBertStructure) -> Self { + Self { + hidden_size: s.hidden_size, + intermediate_size: s.intermediate_size, + num_attention_heads: s.num_attention_heads, + num_hidden_layers: s.num_hidden_layers, + vocab_size: s.vocab_size, + norm_eps: s.norm_eps, + local_attention: s.local_window, + layer_types: s.schedule.iter().map(|k| k.as_str().to_string()).collect(), + rope_parameters: serde_json::json!({ + "full_attention": {"rope_type": "default", "rope_theta": s.rope_full.theta}, + "sliding_attention": {"rope_type": "default", "rope_theta": s.rope_sliding.theta}, + }), + } + } +} + pub struct Config { pub agent: AgentConfig, pub encoder: EncoderConfig, + /// The admitted family structure; geometry and plans derive from it. + pub structure: ModernBertStructure, } impl Config { pub fn load(dir: &Path) -> Result { let read = |name| fs::read(dir.join(name)).with_context(|| format!("read {name}")); let agent: AgentConfig = serde_json::from_slice(&read("rl_agent_config.json")?)?; - let encoder_bytes = read("encoder/config.json")?; - let raw: serde_json::Value = serde_json::from_slice(&encoder_bytes)?; - ensure!( - raw["model_type"] == "modernbert" - && raw["hidden_activation"] == "gelu" - && raw["attention_bias"] == false - && raw["mlp_bias"] == false - && raw["norm_bias"] == false, - "unsupported encoder activation, bias or model type" - ); - let encoder: EncoderConfig = serde_json::from_slice(&encoder_bytes)?; + let (structure, _meta) = omni_modernbert::LayaBundleAdapter::read(dir)?; + gate::laya_0_3_20().admit(&structure)?; ensure!( agent.max_len == 512 && agent.head_max_len == 192 && agent.head_layers == 2, "native Laya supports max_len=512, head_max_len=192, head_layers=2" ); - ensure!( - encoder.hidden_size == 1024 - && encoder.intermediate_size == 2624 - && encoder.num_attention_heads == 16 - && encoder.num_hidden_layers == 28 - && encoder.vocab_size == 50368 - && encoder.local_attention == 128 - && encoder.norm_eps == 1e-5, - "unsupported encoder configuration" - ); - let expected: Vec<_> = (0..28) - .map(|i| { - if i % 3 == 0 { - "full_attention" - } else { - "sliding_attention" - } - }) - .collect(); - ensure!( - encoder.layer_types == expected, - "unsupported attention schedule" - ); - for (kind, theta) in [("full_attention", 160000.0), ("sliding_attention", 10000.0)] { - let r = &encoder.rope_parameters[kind]; - ensure!( - r["rope_type"] == "default" && r["rope_theta"].as_f64() == Some(theta), - "unsupported RoPE configuration" - ); - } ensure!( agent.temperature.len() == 3 && agent @@ -86,6 +70,10 @@ impl Config { .all(|t| t.is_finite() && *t > 0.0), "invalid temperatures" ); - Ok(Self { agent, encoder }) + Ok(Self { + encoder: EncoderConfig::from(structure.clone()), + agent, + structure, + }) } } diff --git a/src/models/laya/src/model.rs b/src/models/laya/src/model.rs index e24d7fca..31871f3e 100644 --- a/src/models/laya/src/model.rs +++ b/src/models/laya/src/model.rs @@ -2,6 +2,8 @@ use crate::config::Config; use crate::packing::Batch; use crate::plan::{PlannedArg, PlannedStep, Scratch, plan_encoder}; +use omni_modernbert::geometry::EncoderScratch; +use omni_modernbert::structure::ModernBertStructure; use crate::weights::Weights; use anyhow::{Context, Result, ensure}; use half::bf16; @@ -137,22 +139,23 @@ struct Workspace { actions: Buffer, } impl Workspace { - fn sizes(b: usize, l: usize) -> Result<[usize; 17]> { + fn sizes(b: usize, l: usize, structure: &ModernBertStructure) -> Result<[usize; 17]> { ensure!( b.is_power_of_two() && b <= 16 && (16..=512).contains(&l) && l.is_multiple_of(16), "invalid workspace shape" ); let m = b * l; + let encoder = EncoderScratch::new(b, l, structure); Ok([ - m * 8, - b * 4, + encoder.ids, + encoder.lens, b * 8, - m * D * 4, - m * D * 2, - m * D * 6, - m * D * 2, - m * 2624 * 2, - m * 4096 * 2, + encoder.x, + encoder.y, + encoder.qkv, + encoder.o, + encoder.gate, + m * 4 * D * 2, 2048 * 4, 17 * 4, 2048 * D * 2, @@ -163,11 +166,11 @@ impl Workspace { 16 * 2 * 2, ]) } - fn required_bytes(b: usize, l: usize) -> Result { - Ok(Self::sizes(b, l)?.iter().sum()) + fn required_bytes(b: usize, l: usize, structure: &ModernBertStructure) -> Result { + Ok(Self::sizes(b, l, structure)?.iter().sum()) } - fn new(c: &Cuda, b: usize, l: usize) -> Result { - let sizes = Self::sizes(b, l)?; + fn new(c: &Cuda, b: usize, l: usize, structure: &ModernBertStructure) -> Result { + let sizes = Self::sizes(b, l, structure)?; let bytes = sizes.iter().sum(); let mut sizes = sizes.into_iter(); let mut alloc = || c.alloc(sizes.next().expect("workspace buffer layout")); @@ -323,9 +326,10 @@ fn resolve_step( fn prepare_encoder( cuda: &Cuda, weights: &HashMap, + structure: &ModernBertStructure, original_rope: bool, ) -> Result> { - plan_encoder(original_rope) + plan_encoder(structure, original_rope) .iter() .map(|planned| resolve_step(cuda, weights, planned)) .collect() @@ -420,11 +424,12 @@ impl Model { let key = format!("zeros.{n}"); weights.insert(key.clone(), upload(&key, &vec![0; n * 4])?); } + let table_bytes = omni_modernbert::geometry::rope_table_bytes(&config.structure); for kind in ["full", "local"] { for part in ["cos", "sin"] { let key = format!("rope_{kind}_{part}"); let data = fs::read(bundle.join(format!("{key}.f32")))?; - ensure!(data.len() == 512 * 32 * 4, "invalid rotary table size"); + ensure!(data.len() == table_bytes, "invalid rotary table size"); weights.insert(key.clone(), upload(&key, &data)?); } } @@ -434,7 +439,7 @@ impl Model { weights.len() ); } - let plan = prepare_encoder(&cuda, &weights, original_rope)?; + let plan = prepare_encoder(&cuda, &weights, &config.structure, original_rope)?; Ok(Self { config, cuda, @@ -495,7 +500,10 @@ impl Model { ensure!( batch.b <= 16 && batch.l <= 512 - && batch.input_ids.iter().all(|i| *i >= 0 && *i < 50368), + && batch + .input_ids + .iter() + .all(|i| *i >= 0 && (*i as usize) < self.config.structure.vocab_size), "invalid packed input" ); let n = batch.markers.len(); @@ -526,7 +534,7 @@ impl Model { batch.markers.iter().map(Vec::len).sum::() <= 2048, "too many markers" ); - let bytes = Workspace::required_bytes(batch.b, batch.l)?; + let bytes = Workspace::required_bytes(batch.b, batch.l, &self.config.structure)?; let cacheable = self.cache_config.max_shapes > 0 && bytes <= self.cache_config.max_bytes; let mut s = if cacheable { self.eager = None; @@ -548,7 +556,7 @@ impl Model { self.cached_bytes -= evicted.bytes; drop(evicted); } - Workspace::new(&self.cuda, batch.b, batch.l)? + Workspace::new(&self.cuda, batch.b, batch.l, &self.config.structure)? } } else { let eager = self @@ -558,7 +566,7 @@ impl Model { // A different fallback shape is dropped before replacement allocation. match eager { Some(workspace) => workspace, - None => Workspace::new(&self.cuda, batch.b, batch.l)?, + None => Workspace::new(&self.cuda, batch.b, batch.l, &self.config.structure)?, } }; let (ids, rest) = s.staging.split_at_mut(batch.input_ids.len() * 8); diff --git a/src/models/laya/src/packing.rs b/src/models/laya/src/packing.rs index 68a0601b..276aa95e 100644 --- a/src/models/laya/src/packing.rs +++ b/src/models/laya/src/packing.rs @@ -14,7 +14,7 @@ pub struct Batch { pub markers: Vec>, } -pub fn pack(prepared: &Prepared) -> Result { +pub fn pack(prepared: &Prepared, vocab: usize) -> Result { let n = prepared.questions.len(); ensure!(n <= 16, "at most 16 questions per CUDA request"); ensure!( @@ -47,7 +47,7 @@ pub fn pack(prepared: &Prepared) -> Result { for (i, q) in prepared.questions.iter().enumerate() { ensure!( !q.ids.is_empty() - && q.ids.iter().all(|id| *id < 50368) + && q.ids.iter().all(|id| (*id as usize) < vocab) && (0..=2).contains(&q.qtype) && !q.markers.is_empty() && q.markers.iter().all(|m| *m < q.ids.len()), diff --git a/src/models/laya/src/plan.rs b/src/models/laya/src/plan.rs index b0f4545f..d4bbff1b 100644 --- a/src/models/laya/src/plan.rs +++ b/src/models/laya/src/plan.rs @@ -1,6 +1,14 @@ //! The encoder-plus-head execution plan as pure data: kernel names and //! buffer roles, before any CUDA symbol or weight pointer is resolved. -//! Keeping the plan pure lets CPU fixtures pin the exact step sequence. +//! The encoder half is generated by the family core from the admitted +//! structure; the task-head half stays here. Keeping the plan pure lets +//! CPU fixtures pin the exact step sequence. + +use omni_modernbert::plan::{ + EncoderPlanOptions, PlanAnchor, PlanArg, PlanStep, ScratchSlot, + encoder_plan as family_encoder_plan, +}; +use omni_modernbert::structure::ModernBertStructure; /// Scratch buffers the plan addresses; allocation lives in the executor. #[derive(Clone, Copy)] @@ -73,10 +81,66 @@ impl PlannedStep { } } -/// The single supported Laya encoder: 28 layers with full attention -/// every third layer, the task head's two transformer layers, and the -/// readout tail left to eager launches at inference time. -pub fn plan_encoder(original_rope: bool) -> Vec { +fn map_arg(arg: PlanArg) -> PlannedArg { + match arg { + PlanArg::Weight(name) => PlannedArg::Weight(name), + PlanArg::Scratch(slot) => PlannedArg::Scratch(match slot { + ScratchSlot::Ids => Scratch::Ids, + ScratchSlot::Lens => Scratch::Lens, + ScratchSlot::Types => Scratch::Types, + ScratchSlot::X => Scratch::X, + ScratchSlot::Y => Scratch::Y, + ScratchSlot::Qkv => Scratch::Qkv, + ScratchSlot::O => Scratch::O, + ScratchSlot::Gate => Scratch::G, + }), + } +} + +/// The execution plan for the admitted Laya profile: the family encoder +/// plan with this bundle's debug dumps hooked to the sequence anchors, +/// followed by the task head's two transformer layers. The readout tail +/// stays with eager launches at inference time. +pub fn plan_encoder(structure: &ModernBertStructure, original_rope: bool) -> Vec { + let mut steps = Vec::new(); + for step in family_encoder_plan( + structure, + &EncoderPlanOptions { + original_rope, + }, + ) { + match step { + PlanStep::Launch { kernel, args } => steps.push(PlannedStep::Launch { + kernel, + args: args.into_iter().map(map_arg).collect(), + }), + PlanStep::Anchor(PlanAnchor::AfterEmbed) => steps.push(PlannedStep::Dump { + name: "embedding".to_string(), + slot: Scratch::X, + bf: false, + }), + PlanStep::Anchor(PlanAnchor::LayerEnd(i)) => { + if [0, 1, 2, 27].contains(&i) { + steps.push(PlannedStep::Dump { + name: format!("encoder{i}_residual"), + slot: Scratch::X, + bf: false, + }); + steps.push(PlannedStep::Dump { + name: format!("encoder{i}_normalized"), + slot: Scratch::Y, + bf: true, + }); + } + } + PlanStep::Anchor(PlanAnchor::AfterEncoder) => {} + } + } + steps.extend(head_plan()); + steps +} + +fn head_plan() -> Vec { let steps = std::cell::RefCell::new(Vec::new()); let weight = |name: &str| PlannedArg::Weight(name.to_string()); let scratch = |slot: Scratch| PlannedArg::Scratch(slot); @@ -86,123 +150,7 @@ pub fn plan_encoder(original_rope: bool) -> Vec { args: args.to_vec(), }); }; - let dump = |name: &str, slot: Scratch, bf: bool| { - steps.borrow_mut().push(PlannedStep::Dump { - name: name.to_string(), - slot, - bf, - }); - }; let attention = |label: &str| format!("attn_{label}"); - call( - "embed", - &[ - scratch(Scratch::Ids), - weight("encoder.embeddings.tok_embeddings.weight"), - weight("encoder.embeddings.norm.weight"), - scratch(Scratch::X), - scratch(Scratch::Y), - ], - ); - dump("embedding", Scratch::X, false); - for i in 0..28 { - let p = format!("encoder.layers.{i}"); - let w = |n: &str| weight(&format!("{p}.{n}")); - call( - "qkv", - &[ - scratch(Scratch::Y), - w("attn.Wqkv.weight"), - weight("zeros.3072"), - scratch(Scratch::Qkv), - ], - ); - let kind = if i % 3 == 0 { "full" } else { "local" }; - call( - if original_rope { - "rope_original" - } else { - "rope" - }, - &[ - scratch(Scratch::Qkv), - weight(&format!("rope_{kind}_cos")), - weight(&format!("rope_{kind}_sin")), - ], - ); - call( - &attention(if i % 3 == 0 { "full" } else { "local" }), - &[ - scratch(Scratch::Qkv), - scratch(Scratch::Lens), - scratch(Scratch::O), - ], - ); - call( - "out", - &[ - scratch(Scratch::O), - w("attn.Wo.weight"), - weight("zeros.1024"), - scratch(Scratch::Y), - ], - ); - call( - "addln", - &[ - scratch(Scratch::X), - scratch(Scratch::Y), - w("mlp_norm.weight"), - weight("zeros.1024"), - scratch(Scratch::Y), - ], - ); - call( - "geglu", - &[ - scratch(Scratch::Y), - w("mlp.Wi.weight"), - scratch(Scratch::G), - ], - ); - call( - "down", - &[ - scratch(Scratch::G), - w("mlp.Wo.weight"), - weight("zeros.1024"), - scratch(Scratch::Y), - ], - ); - let next = if i < 27 { - weight(&format!("encoder.layers.{}.attn_norm.weight", i + 1)) - } else { - weight("encoder.final_norm.weight") - }; - call( - "addln", - &[ - scratch(Scratch::X), - scratch(Scratch::Y), - next, - weight("zeros.1024"), - scratch(Scratch::Y), - ], - ); - if [0, 1, 2, 27].contains(&i) { - dump(&format!("encoder{i}_residual"), Scratch::X, false); - dump(&format!("encoder{i}_normalized"), Scratch::Y, true); - } - } - call( - "type", - &[ - scratch(Scratch::Y), - weight("type_emb.weight"), - scratch(Scratch::Types), - scratch(Scratch::X), - ], - ); for i in 0..2 { let p = format!("head.layers.{i}"); let w = |n: &str| weight(&format!("{p}.{n}")); diff --git a/src/models/laya/src/processing.rs b/src/models/laya/src/processing.rs index a05c7737..21b62b23 100644 --- a/src/models/laya/src/processing.rs +++ b/src/models/laya/src/processing.rs @@ -14,6 +14,7 @@ use crate::{ pub struct Processor { preprocessor: Preprocessor, config: Arc, + vocab: usize, } pub struct PreparedRequest { @@ -28,9 +29,11 @@ pub struct ResponseContext { impl Processor { pub fn load(checkpoint: &Path) -> Result { + let config = Config::load(checkpoint)?; Ok(Self { preprocessor: Preprocessor::load(&checkpoint.join("tokenizer/tokenizer.json"))?, - config: Arc::new(Config::load(checkpoint)?.agent), + vocab: config.structure.vocab_size, + config: Arc::new(config.agent), }) } @@ -38,7 +41,7 @@ impl Processor { let request = Request::from_json(std::str::from_utf8(raw)?)?; let prepared = self.preprocessor.prepare(&request)?; Ok(PreparedRequest { - inputs: packing::pack(&prepared)?, + inputs: packing::pack(&prepared, self.vocab)?, context: ResponseContext { prepared, config: self.config.clone(), diff --git a/src/models/laya/src/weights.rs b/src/models/laya/src/weights.rs index d3e5a1f8..5c82ccc2 100644 --- a/src/models/laya/src/weights.rs +++ b/src/models/laya/src/weights.rs @@ -1,6 +1,7 @@ use anyhow::{Result, ensure}; use half::{bf16, f16}; use memmap2::Mmap; +use omni_modernbert::geometry; use safetensors::{Dtype, SafeTensors}; use std::{fs::File, path::Path}; @@ -93,37 +94,21 @@ pub struct TensorSpec { pub shape: Vec, } pub fn checkpoint_tensors() -> Vec { - const D: usize = 1024; - let mut tensors = Vec::new(); - let mut add = |name: &str, shape: &[usize]| { - tensors.push(TensorSpec { - name: name.into(), - shape: shape.into(), - }); + // Encoder tensors come from the family structure; the task head keeps + // its own inventory. + let structure = omni_modernbert::gate::laya_0_3_20().structure; + let mut tensors: Vec<_> = geometry::encoder_tensors(&structure) + .into_iter() + .map(|(name, shape)| TensorSpec { name, shape }) + .collect(); + let mut add = |name: String, shape: Vec| { + tensors.push(TensorSpec { name, shape }); }; - add("encoder.embeddings.tok_embeddings.weight", &[50368, D]); - add("encoder.embeddings.norm.weight", &[D]); - add("encoder.final_norm.weight", &[D]); - for i in 0..28 { - let p = format!("encoder.layers.{i}"); - if i > 0 { - add(&format!("{p}.attn_norm.weight"), &[D]); - } - add(&format!("{p}.mlp_norm.weight"), &[D]); - for (name, shape) in [ - ("attn.Wqkv.weight", vec![3 * D, D]), - ("attn.Wo.weight", vec![D, D]), - ("mlp.Wi.weight", vec![5248, D]), - ("mlp.Wo.weight", vec![D, 2624]), - ] { - add(&format!("{p}.{name}"), &shape); - } - } - add("type_emb.weight", &[3, D]); + const D: usize = 1024; for i in 0..2 { let p = format!("head.layers.{i}"); for n in ["norm1.weight", "norm1.bias", "norm2.weight", "norm2.bias"] { - add(&format!("{p}.{n}"), &[D]); + add(format!("{p}.{n}"), vec![D]); } for (n, rows, cols) in [ ("self_attn.in_proj_weight", 3 * D, D), @@ -131,7 +116,7 @@ pub fn checkpoint_tensors() -> Vec { ("linear1.weight", 4 * D, D), ("linear2.weight", D, 4 * D), ] { - add(&format!("{p}.{n}"), &[rows, cols]); + add(format!("{p}.{n}"), vec![rows, cols]); } for (n, len) in [ ("self_attn.in_proj_bias", 3 * D), @@ -139,11 +124,11 @@ pub fn checkpoint_tensors() -> Vec { ("linear1.bias", 4 * D), ("linear2.bias", D), ] { - add(&format!("{p}.{n}"), &[len]); + add(format!("{p}.{n}"), vec![len]); } } for n in ["scorer.0.weight", "scorer.0.bias"] { - add(n, &[D]); + add(n.into(), vec![D]); } for (p, n, k) in [ ("scorer.1", D, D), @@ -151,13 +136,13 @@ pub fn checkpoint_tensors() -> Vec { ("act_head.0", 256, 1028), ("act_head.2", 2, 256), ] { - add(&format!("{p}.weight"), &[n, k]); - add(&format!("{p}.bias"), &[n]); + add(format!("{p}.weight"), vec![n, k]); + add(format!("{p}.bias"), vec![n]); } // Laya 0.3.20 common.py registers this legacy buffer but forward does not // consume it. Agent decoding uses fitted config temperatures instead. // Keep it in the inventory for complete checkpoint accounting. - add("temperature", &[3]); + add("temperature".into(), vec![3]); tensors } diff --git a/tests/laya/batch.rs b/tests/laya/batch.rs index 92faf922..821649e8 100644 --- a/tests/laya/batch.rs +++ b/tests/laya/batch.rs @@ -23,7 +23,7 @@ fn prepared(n: usize) -> Prepared { #[test] fn padding_preserves_order_lengths_and_markers() { let prepared = prepared(3); - let batch = pack(&prepared).unwrap(); + let batch = pack(&prepared, 50368).unwrap(); assert_eq!((batch.b, batch.l), (4, 16)); assert_eq!(batch.lens, [3, 3, 3, 0]); assert_eq!(batch.qtypes, [2, 2, 2, 0]); @@ -37,17 +37,17 @@ fn padding_preserves_order_lengths_and_markers() { #[test] fn rejects_executor_capacity_and_invalid_markers() { - assert!(pack(&prepared(17)).is_err()); + assert!(pack(&prepared(17), 50368).is_err()); let mut input = prepared(1); input.questions[0].markers.push(3); - assert!(pack(&input).is_err()); + assert!(pack(&input, 50368).is_err()); input.questions[0].markers = vec![1; 2049]; - assert!(pack(&input).is_err()); + assert!(pack(&input, 50368).is_err()); } #[test] fn empty_request_needs_no_device_rows() { - let batch = pack(&prepared(0)).unwrap(); + let batch = pack(&prepared(0), 50368).unwrap(); assert_eq!((batch.b, batch.l), (0, 0)); assert!(batch.markers.is_empty()); } @@ -56,7 +56,7 @@ fn empty_request_needs_no_device_rows() { fn padding_matches_native_sequence_alignment() { let mut input = prepared(3); input.questions[1].ids = vec![7; 257]; - let batch = pack(&input).unwrap(); + let batch = pack(&input, 50368).unwrap(); assert_eq!((batch.b, batch.l), (4, 320)); assert_eq!(batch.lens, [3, 257, 3, 0]); assert!(batch.input_ids[3..257].iter().all(|id| *id == 50283)); diff --git a/tests/laya/model.rs b/tests/laya/model.rs index 922bbaee..48319673 100644 --- a/tests/laya/model.rs +++ b/tests/laya/model.rs @@ -51,3 +51,39 @@ mod grouped; #[cfg(unix)] #[path = "original_encode.rs"] mod original_encode; + +#[test] +fn workspace_sizes_match_the_recorded_profile_layout() { + let structure = gate_structure(); + for (b, l) in [(1usize, 16usize), (1, 512), (16, 512), (4, 128)] { + let m = b * l; + let sizes = Workspace::sizes(b, l, &structure).unwrap(); + assert_eq!( + sizes, + [ + m * 8, + b * 4, + b * 8, + m * 1024 * 4, + m * 1024 * 2, + m * 1024 * 6, + m * 1024 * 2, + m * 2624 * 2, + m * 4096 * 2, + 2048 * 4, + 17 * 4, + 2048 * 1024 * 2, + 2048 * 1024 * 2, + 2048 * 2, + 16 * 1028 * 2, + 16 * 256 * 2, + 16 * 2 * 2, + ], + "b={b} l={l}" + ); + } +} + +fn gate_structure() -> omni_modernbert::structure::ModernBertStructure { + omni_modernbert::gate::laya_0_3_20().structure +} diff --git a/tests/laya/model_cache.rs b/tests/laya/model_cache.rs index 776656c5..ad981ed6 100644 --- a/tests/laya/model_cache.rs +++ b/tests/laya/model_cache.rs @@ -1,5 +1,9 @@ use super::*; +fn structure() -> omni_modernbert::structure::ModernBertStructure { + omni_modernbert::gate::laya_0_3_20().structure +} + fn batch_for(b: usize, l: usize, value: i64) -> Batch { Batch { b, @@ -16,8 +20,8 @@ fn cache_limits_evict_before_allocation_and_allow_exact_fits() { let fixture = fixture::Fixture::new(&[]); let mut model = fixture_model_at(false, fixture.path()); model.graphs = true; - let a = Workspace::required_bytes(1, 16).unwrap(); - let b = Workspace::required_bytes(1, 32).unwrap(); + let a = Workspace::required_bytes(1, 16, &structure()).unwrap(); + let b = Workspace::required_bytes(1, 32, &structure()).unwrap(); model.cache_config = CacheConfig { max_shapes: 1, max_bytes: a.max(b), @@ -73,8 +77,8 @@ fn byte_budget_lru_and_disabled_or_oversized_fallback_reuse() { let fixture = fixture::Fixture::new(&[]); let mut model = fixture_model_at(false, fixture.path()); model.graphs = true; - let a = Workspace::required_bytes(1, 16).unwrap(); - let b = Workspace::required_bytes(1, 32).unwrap(); + let a = Workspace::required_bytes(1, 16, &structure()).unwrap(); + let b = Workspace::required_bytes(1, 32, &structure()).unwrap(); model.cache_config = CacheConfig { max_shapes: 8, max_bytes: a + b, @@ -141,10 +145,10 @@ fn workspace_estimate_matches_allocation_for_all_supported_shapes() { let weights = unsafe { live() }; for b in [1, 2, 4, 8, 16] { for l in (16..=512).step_by(16) { - let workspace = Workspace::new(&model.cuda, b, l).unwrap(); + let workspace = Workspace::new(&model.cuda, b, l, &structure()).unwrap(); let allocated = unsafe { live() } - weights; assert_eq!( - Workspace::required_bytes(b, l).unwrap(), + Workspace::required_bytes(b, l, &structure()).unwrap(), allocated, "B={b}, L={l}" ); @@ -154,7 +158,7 @@ fn workspace_estimate_matches_allocation_for_all_supported_shapes() { } } for (b, l) in [(0, 16), (3, 16), (32, 16), (1, 0), (1, 17), (1, 528)] { - assert!(Workspace::required_bytes(b, l).is_err()); + assert!(Workspace::required_bytes(b, l, &structure()).is_err()); } } @@ -165,7 +169,7 @@ fn clear_releases_graph_and_eager_workspaces_and_allows_reuse() { model.graphs = true; model.cache_config = CacheConfig { max_shapes: 1, - max_bytes: Workspace::required_bytes(1, 16).unwrap(), + max_bytes: Workspace::required_bytes(1, 16, &structure()).unwrap(), }; let live = unsafe { model @@ -222,6 +226,6 @@ fn failed_capture_admission_releases_partial_workspace_and_can_retry() { assert_eq!(model.cache.len(), 1); assert_eq!( model.cached_bytes, - Workspace::required_bytes(1, 16).unwrap() + Workspace::required_bytes(1, 16, &structure()).unwrap() ); } diff --git a/tests/laya/model_dispatch.rs b/tests/laya/model_dispatch.rs index d2feb506..581d6f05 100644 --- a/tests/laya/model_dispatch.rs +++ b/tests/laya/model_dispatch.rs @@ -1,5 +1,9 @@ use super::*; +fn structure() -> omni_modernbert::structure::ModernBertStructure { + omni_modernbert::gate::laya_0_3_20().structure +} + #[test] fn prepared_plan_preserves_original_launch_trace_for_all_shape_selectors() { let fixture = fixture::Fixture::new(&[]); @@ -16,7 +20,7 @@ fn prepared_plan_preserves_original_launch_trace_for_all_shape_selectors() { let model = fixture_model_at(original_rope, fixture.path()); for b in [1, 2, 4, 8, 16] { for l in [16, 512] { - let s = Workspace::new(&model.cuda, b, l).unwrap(); + let s = Workspace::new(&model.cuda, b, l, &structure()).unwrap(); unsafe { reset() }; model.encode_original(&s, original_rope).unwrap(); let expected = unsafe { std::ffi::CStr::from_ptr(trace()) } @@ -41,12 +45,12 @@ fn prepared_plan_preserves_original_launch_trace_for_all_shape_selectors() { fn missing_specialized_attention_only_rejects_its_shape() { let fixture = fixture::Fixture::new(&["OMIT_SPECIALIZED_ATTN"]); let model = fixture_model_at(false, fixture.path()); - let short = Workspace::new(&model.cuda, 1, 16).unwrap(); + let short = Workspace::new(&model.cuda, 1, 16, &structure()).unwrap(); model.encode(&short).unwrap(); - let generic_long = Workspace::new(&model.cuda, 2, 512).unwrap(); + let generic_long = Workspace::new(&model.cuda, 2, 512, &structure()).unwrap(); model.encode(&generic_long).unwrap(); for b in [1, 4] { - let specialized = Workspace::new(&model.cuda, b, 512).unwrap(); + let specialized = Workspace::new(&model.cuda, b, 512, &structure()).unwrap(); assert!( model .encode(&specialized) diff --git a/tests/laya/model_fixture.rs b/tests/laya/model_fixture.rs index 09c9e62c..477ec27b 100644 --- a/tests/laya/model_fixture.rs +++ b/tests/laya/model_fixture.rs @@ -57,9 +57,10 @@ pub(super) fn fixture_model_at(original_rope: bool, path: &Path) -> Model { } } let (encoder, agent) = configs(); - let plan = prepare_encoder(&cuda, &weights, original_rope).unwrap(); + let config = load_config(&encoder, &agent).unwrap(); + let plan = prepare_encoder(&cuda, &weights, &config.structure, original_rope).unwrap(); Model { - config: load_config(&encoder, &agent).unwrap(), + config, blas: Blas::new(&cuda).unwrap(), cuda, weights, diff --git a/tests/laya/plan.rs b/tests/laya/plan.rs index 47dfbec3..48f30fcd 100644 --- a/tests/laya/plan.rs +++ b/tests/laya/plan.rs @@ -1,20 +1,24 @@ use omni_laya::plan::{plan_encoder, render_plan}; +fn laya_structure() -> omni_modernbert::structure::ModernBertStructure { + omni_modernbert::gate::laya_0_3_20().structure +} + #[test] fn encoder_plan_matches_the_recorded_sequence() { assert_eq!( - render_plan(&plan_encoder(false)), + render_plan(&plan_encoder(&laya_structure(), false)), include_str!("data/encoder-plan.txt") ); assert_eq!( - render_plan(&plan_encoder(true)), + render_plan(&plan_encoder(&laya_structure(), true)), include_str!("data/encoder-plan-original-rope.txt") ); } #[test] fn encoder_plan_shape_is_the_supported_profile() { - let plan = plan_encoder(false); + let plan = plan_encoder(&laya_structure(), false); let launches = plan .iter() .filter(|s| matches!(s, omni_laya::plan::PlannedStep::Launch { .. })) @@ -27,7 +31,7 @@ fn encoder_plan_shape_is_the_supported_profile() { assert!(rendered.contains("addln x y encoder.final_norm.weight zeros.1024 y")); assert!(rendered.ends_with("residual x y\n")); // The two rope variants differ in exactly the 28 rope kernel names. - let original = render_plan(&plan_encoder(true)); + let original = render_plan(&plan_encoder(&laya_structure(), true)); let differences = rendered .lines() .zip(original.lines()) From 48863a49c5202ded0e8d637055c18dfc507927d3 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 01:16:23 +0800 Subject: [PATCH 05/21] qwen3_5: split structure parsing from the ABI-6 capability gate The family crate gains a structure layer that separates what a checkpoint declares from what the kernels can run, the qwen-side half of the model-parameter RFC's phase 5. No gate constant, kernel, or ABI changes; the accepted structure set is frozen and fixture-proven identical. - structure::read_structure parses config.json (root or text_config) into QwenHybridStructure with per-field provenance, including the source-declared behavior fields the gate pins (hidden_act, output gate, attention bias, rope type, conv kernel, mrope interleave); parsing judges nothing - structure::check_abi6 is the frozen kernel capability set as typed checks with the original error surface: silu, output gate, no bias, default rope, conv kernel 4, head dims 256/128/128, 32 rotary dims, interleaved mrope summing to 32, position bounds, head divisibility, hidden % 8 - Config::load becomes a facade over adapter plus gate and projects the admitted structure onto its unchanged public fields; the new Config::from_structure is the seam a consumer with its own bundle layout (a Vela hybrid member whose Qwen fields live at backbone_config) uses instead of reading a directory - structure::PrefixLayout derives the per-layer prefix-state byte sizes (full-attention K/V rows, GDN float32 [H,K,V] states, conv tails) from the structure, CPU-checkable against recorded numbers - structure::structure_identity digests schema version, family, backend, ABI 6 and the canonical structure; prefix-cache records must carry it and re-capture on any change Tests: adapter provenance and broken-source rejections, the frozen reject matrix (15 single-field violations), the three Vela hybrid backbone geometries (1024/3584/24, 2560/9216/32, 4096/12288/32) passing the gate as pure CPU fixtures, the open-jev 27B checkpoint passing unchanged through the facade, prefix-layout numbers, and identity stability. The weight-loader dtype/prefix seams for the Vela consumer stay with that consumer's slice; nothing here changes the arena or the ABI. Signed-off-by: Mr.Dong <1042542469@qq.com> --- Cargo.lock | 2 + src/models/qwen3_5/native/Cargo.toml | 8 + src/models/qwen3_5/native/src/lib.rs | 1 + src/models/qwen3_5/native/src/model.rs | 161 ++------ src/models/qwen3_5/native/src/structure.rs | 434 +++++++++++++++++++++ tests/qwen3_5/structure.rs | 254 ++++++++++++ 6 files changed, 731 insertions(+), 129 deletions(-) create mode 100644 src/models/qwen3_5/native/src/structure.rs create mode 100644 tests/qwen3_5/structure.rs diff --git a/Cargo.lock b/Cargo.lock index 9791fc1d..6941e627 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,6 +1291,8 @@ dependencies = [ "safetensors 0.8.0", "serde", "serde_json", + "sha2 0.10.9", + "tempfile", ] [[package]] diff --git a/src/models/qwen3_5/native/Cargo.toml b/src/models/qwen3_5/native/Cargo.toml index 57c2428f..8439939c 100644 --- a/src/models/qwen3_5/native/Cargo.toml +++ b/src/models/qwen3_5/native/Cargo.toml @@ -11,6 +11,7 @@ half = "2.7.1" libloading = "0.8" memmap2 = "0.9.9" safetensors = "0.8.0" +sha2 = "0.10" serde = "1" serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order", "raw_value"] } @@ -21,3 +22,10 @@ path = "../../../../tests/qwen3_5/config.rs" [[test]] name = "kernels" path = "../../../../tests/qwen3_5/kernels.rs" + +[dev-dependencies] +tempfile = "3" + +[[test]] +name = "structure" +path = "../../../../tests/qwen3_5/structure.rs" diff --git a/src/models/qwen3_5/native/src/lib.rs b/src/models/qwen3_5/native/src/lib.rs index 04b1f87f..fae818ca 100644 --- a/src/models/qwen3_5/native/src/lib.rs +++ b/src/models/qwen3_5/native/src/lib.rs @@ -3,5 +3,6 @@ pub mod cuda; pub mod json; pub mod model; +pub mod structure; pub mod inputs; diff --git a/src/models/qwen3_5/native/src/model.rs b/src/models/qwen3_5/native/src/model.rs index 1e65fdb8..9fb40308 100644 --- a/src/models/qwen3_5/native/src/model.rs +++ b/src/models/qwen3_5/native/src/model.rs @@ -14,7 +14,7 @@ use std::ffi::c_void; use std::path::Path; use std::sync::Arc; -use anyhow::{Context, Result, bail, ensure}; +use anyhow::{Context, Result, ensure}; use serde_json::Value as Json; use crate::cuda::{self, DeviceBuffer, Stream, check}; @@ -48,135 +48,38 @@ pub struct Config { impl Config { pub fn load(dir: &Path) -> Result { - let path = dir.join("config.json"); - let root: Json = serde_json::from_str( - &std::fs::read_to_string(&path).with_context(|| format!("{}", path.display()))?, - )?; - let c = root.get("text_config").unwrap_or(&root); - let int = |k: &str| { - c[k].as_u64() - .map(|v| v as usize) - .with_context(|| format!("config.json: `{k}` is missing")) - }; - let rope = &c["rope_parameters"]; - let partial = rope["partial_rotary_factor"] - .as_f64() - .or(c["partial_rotary_factor"].as_f64()) - .unwrap_or(1.0); - let head_dim = int("head_dim")?; - let full_attention = c["layer_types"] - .as_array() - .context("config.json: `layer_types` is missing")? - .iter() - .map(|t| match t.as_str() { - Some("full_attention") => Ok(true), - Some("linear_attention") => Ok(false), - other => bail!("unknown layer type {other:?}"), - }) - .collect::>>()?; - let sections = rope - .get("mrope_section") - .cloned() - .unwrap_or(serde_json::json!([11, 11, 10])); - let sections = sections - .as_array() - .context("mrope_section must be an array")? - .iter() - .map(|v| { - v.as_u64() - .and_then(|n| usize::try_from(n).ok()) - .context("mrope_section must contain integers") - }) - .collect::>>()?; - let mrope_section: [usize; 3] = sections - .try_into() - .map_err(|_| anyhow::anyhow!("mrope_section must have three entries"))?; - let image_token_id = root - .get("image_token_id") - .map(|v| { - v.as_u64() - .and_then(|id| u32::try_from(id).ok()) - .context("image_token_id must be a u32") - }) - .transpose()?; - let cfg = Config { - hidden: int("hidden_size")?, - intermediate: int("intermediate_size")?, - eps: c["rms_norm_eps"].as_f64().context("rms_norm_eps")? as f32, - heads: int("num_attention_heads")?, - kv_heads: int("num_key_value_heads")?, - head_dim, - rotary_half: (head_dim as f64 * partial) as usize / 2, - rope_theta: rope["rope_theta"] - .as_f64() - .or(c["rope_theta"].as_f64()) - .context("rope_theta")?, - mrope_section, - max_positions: int("max_position_embeddings")?, - image_token_id, - lin_k_heads: int("linear_num_key_heads")?, - lin_v_heads: int("linear_num_value_heads")?, - lin_k_dim: int("linear_key_head_dim")?, - lin_v_dim: int("linear_value_head_dim")?, - full_attention, - }; - // What the kernels implement. - ensure!( - cfg.full_attention.len() == int("num_hidden_layers")?, - "layer_types does not match num_hidden_layers" - ); - ensure!(c["hidden_act"] == "silu", "hidden_act is not silu"); - ensure!( - c["attn_output_gate"].as_bool().unwrap_or(true), - "attention without the output gate" - ); - ensure!( - c["attention_bias"].as_bool() != Some(true), - "attention with bias" - ); - ensure!( - rope["rope_type"].as_str().unwrap_or("default") == "default", - "rope type {}", - rope["rope_type"] - ); - ensure!(int("linear_conv_kernel_dim")? == 4, "conv kernel is not 4"); - ensure!( - cfg.head_dim == 256 && cfg.lin_k_dim == 128 && cfg.lin_v_dim == 128, - "head dims {} / {} / {}", - cfg.head_dim, - cfg.lin_k_dim, - cfg.lin_v_dim - ); - ensure!(cfg.rotary_half == 32, "{} rotary dims", 2 * cfg.rotary_half); - ensure!( - cfg.rope_theta.is_finite() && cfg.rope_theta > 0.0, - "invalid rope_theta" - ); - ensure!( - rope["mrope_interleaved"].as_bool() != Some(false), - "non-interleaved mrope is unsupported" - ); - ensure!( - cfg.mrope_section + let structure = crate::structure::read_structure(dir)?; + crate::structure::check_abi6(&structure)?; + Ok(Self::from_structure(structure)) + } + + /// Build the runtime config from an already-validated structure; the + /// seam a consumer with its own bundle layout (for example a Vela + /// hybrid member whose Qwen fields live at `backbone_config`) uses + /// instead of reading a checkpoint directory. + pub fn from_structure(s: crate::structure::QwenHybridStructure) -> Self { + Self { + hidden: s.hidden, + intermediate: s.intermediate, + eps: s.eps, + full_attention: s + .layer_kinds .iter() - .try_fold(0usize, |sum, &x| sum.checked_add(x)) - == Some(cfg.rotary_half), - "mrope sections must sum to rotary_half" - ); - ensure!( - cfg.max_positions > 0 && cfg.max_positions <= (1 << 24), - "unsupported max_position_embeddings" - ); - ensure!( - cfg.kv_heads > 0 && cfg.heads.is_multiple_of(cfg.kv_heads), - "attention heads" - ); - ensure!( - cfg.lin_k_heads > 0 && cfg.lin_v_heads.is_multiple_of(cfg.lin_k_heads), - "linear attention heads" - ); - ensure!(cfg.hidden.is_multiple_of(8), "hidden size"); - Ok(cfg) + .map(|k| *k == crate::structure::LayerKind::FullAttention) + .collect(), + heads: s.heads, + kv_heads: s.kv_heads, + head_dim: s.head_dim, + rotary_half: s.rotary_half, + rope_theta: s.rope_theta, + mrope_section: s.mrope_section, + max_positions: s.max_positions, + image_token_id: s.image_token_id, + lin_k_heads: s.lin_k_heads, + lin_v_heads: s.lin_v_heads, + lin_k_dim: s.lin_k_dim, + lin_v_dim: s.lin_v_dim, + } } fn key_dim(&self) -> usize { diff --git a/src/models/qwen3_5/native/src/structure.rs b/src/models/qwen3_5/native/src/structure.rs new file mode 100644 index 00000000..8110e3c8 --- /dev/null +++ b/src/models/qwen3_5/native/src/structure.rs @@ -0,0 +1,434 @@ +//! The Qwen3.5 hybrid family layer: the source adapter that reads a +//! checkpoint config, the structure it produces, the frozen ABI-6 +//! capability set the CUDA kernels implement, and the prefix-state +//! layout derived from a structure. Parsing and capability live apart +//! so both are checkable without a device or a checkpoint. + +use anyhow::{Context, Result, bail, ensure}; +use serde_json::Value as Json; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; + +pub const SCHEMA_VERSION: usize = 1; +pub const FAMILY: &str = "qwen3_5_hybrid"; +pub const BACKEND: &str = "qwen3_5_cuda"; +pub const ABI_VERSION: usize = 6; + +const BF16: usize = 2; +const F32: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayerKind { + FullAttention, + GatedDeltaNet, +} + +/// The hybrid structure: family semantics and math geometry with +/// per-field provenance. The multimodal behavior around it (image +/// token, M-RoPE positions) stays opaque and is carried, not +/// parameterized. +#[derive(Debug, Clone, PartialEq)] +pub struct QwenHybridStructure { + pub hidden: usize, + pub intermediate: usize, + pub eps: f32, + /// Ordered per-layer mixer kinds; index 0 is the first layer. + pub layer_kinds: Vec, + pub heads: usize, + pub kv_heads: usize, + pub head_dim: usize, + /// Half the number of rotary dims (rotate_half pairs i with i + half). + pub rotary_half: usize, + pub rope_theta: f64, + pub mrope_section: [usize; 3], + pub max_positions: usize, + pub image_token_id: Option, + pub lin_k_heads: usize, + pub lin_v_heads: usize, + pub lin_k_dim: usize, + pub lin_v_dim: usize, + /// Source-declared family behavior the kernels pin; the gate + /// rejects anything but the frozen combination. + pub hidden_act: Option, + pub attn_output_gate: bool, + pub attention_bias: bool, + pub rope_type: String, + pub conv_kernel_dim: usize, + pub mrope_interleaved: bool, + pub provenance: BTreeMap, +} + +impl QwenHybridStructure { + pub fn full_attention_layers(&self) -> usize { + self.layer_kinds + .iter() + .filter(|k| **k == LayerKind::FullAttention) + .count() + } + + pub fn gdn_layers(&self) -> usize { + self.layer_kinds.len() - self.full_attention_layers() + } + + pub fn key_dim(&self) -> usize { + self.lin_k_heads * self.lin_k_dim + } + + pub fn value_dim(&self) -> usize { + self.lin_v_heads * self.lin_v_dim + } + + /// Width of the fused Gated DeltaNet input projection (conv q/k/v, + /// gate/value and beta rows stacked). + pub fn gdn_in_width(&self) -> usize { + 2 * self.key_dim() + self.value_dim() + self.value_dim() + 2 * self.lin_v_heads + } + + fn canonical_json(&self) -> Json { + let num = |v: usize| Json::String(v.to_string()); + let mut object = serde_json::Map::new(); + object.insert("schema_version".into(), num(SCHEMA_VERSION)); + object.insert("family".into(), Json::String(FAMILY.into())); + object.insert("hidden".into(), num(self.hidden)); + object.insert("intermediate".into(), num(self.intermediate)); + object.insert("eps".into(), Json::String(self.eps.to_string())); + object.insert( + "layer_kinds".into(), + Json::Array( + self.layer_kinds + .iter() + .map(|k| { + Json::String( + match k { + LayerKind::FullAttention => "full_attention", + LayerKind::GatedDeltaNet => "linear_attention", + } + .into(), + ) + }) + .collect(), + ), + ); + object.insert("heads".into(), num(self.heads)); + object.insert("kv_heads".into(), num(self.kv_heads)); + object.insert("head_dim".into(), num(self.head_dim)); + object.insert("rotary_half".into(), num(self.rotary_half)); + object.insert("rope_theta".into(), Json::String(self.rope_theta.to_string())); + object.insert( + "mrope_section".into(), + Json::Array( + self.mrope_section + .iter() + .map(|s| Json::String(s.to_string())) + .collect(), + ), + ); + object.insert("max_positions".into(), num(self.max_positions)); + object.insert( + "image_token_id".into(), + self.image_token_id + .map(|id| Json::String(id.to_string())) + .unwrap_or(Json::Null), + ); + object.insert("lin_k_heads".into(), num(self.lin_k_heads)); + object.insert("lin_v_heads".into(), num(self.lin_v_heads)); + object.insert("lin_k_dim".into(), num(self.lin_k_dim)); + object.insert("lin_v_dim".into(), num(self.lin_v_dim)); + object.insert( + "hidden_act".into(), + self.hidden_act + .clone() + .map(Json::String) + .unwrap_or(Json::Null), + ); + object.insert( + "attn_output_gate".into(), + Json::String(self.attn_output_gate.to_string()), + ); + object.insert( + "attention_bias".into(), + Json::String(self.attention_bias.to_string()), + ); + object.insert("rope_type".into(), Json::String(self.rope_type.clone())); + object.insert( + "conv_kernel_dim".into(), + num(self.conv_kernel_dim), + ); + object.insert( + "mrope_interleaved".into(), + Json::String(self.mrope_interleaved.to_string()), + ); + Json::Object(object) + } +} + +/// The source adapter: reads `config.json` from a checkpoint directory, +/// preferring `text_config`, exactly as the loader always has. No +/// capability judgment happens here. +pub fn read_structure(dir: &Path) -> Result { + let path = dir.join("config.json"); + let root: Json = serde_json::from_str( + &std::fs::read_to_string(&path).with_context(|| format!("{}", path.display()))?, + )?; + let c = root.get("text_config").unwrap_or(&root); + let int = |k: &str| { + c[k].as_u64() + .map(|v| v as usize) + .with_context(|| format!("config.json: `{k}` is missing")) + }; + let rope = &c["rope_parameters"]; + let partial = rope["partial_rotary_factor"] + .as_f64() + .or(c["partial_rotary_factor"].as_f64()) + .unwrap_or(1.0); + let head_dim = int("head_dim")?; + let layer_kinds = c["layer_types"] + .as_array() + .context("config.json: `layer_types` is missing")? + .iter() + .map(|t| match t.as_str() { + Some("full_attention") => Ok(LayerKind::FullAttention), + Some("linear_attention") => Ok(LayerKind::GatedDeltaNet), + other => bail!("unknown layer type {other:?}"), + }) + .collect::>>()?; + let sections = rope + .get("mrope_section") + .cloned() + .unwrap_or(serde_json::json!([11, 11, 10])); + let sections = sections + .as_array() + .context("mrope_section must be an array")? + .iter() + .map(|v| { + v.as_u64() + .and_then(|n| usize::try_from(n).ok()) + .context("mrope_section must contain integers") + }) + .collect::>>()?; + let mrope_section: [usize; 3] = sections + .try_into() + .map_err(|_| anyhow::anyhow!("mrope_section must have three entries"))?; + let image_token_id = root + .get("image_token_id") + .map(|v| { + v.as_u64() + .and_then(|id| u32::try_from(id).ok()) + .context("image_token_id must be a u32") + }) + .transpose()?; + let num_hidden_layers = int("num_hidden_layers")?; + ensure!( + layer_kinds.len() == num_hidden_layers, + "layer_types does not match num_hidden_layers" + ); + let mut provenance = BTreeMap::new(); + for field in [ + "hidden", + "intermediate", + "eps", + "layer_kinds", + "heads", + "kv_heads", + "head_dim", + "rotary_half", + "rope_theta", + "mrope_section", + "max_positions", + "lin_k_heads", + "lin_v_heads", + "lin_k_dim", + "lin_v_dim", + ] { + let json_key = match field { + "hidden" => "hidden_size", + "intermediate" => "intermediate_size", + "eps" => "rms_norm_eps", + "layer_kinds" => "layer_types", + "heads" => "num_attention_heads", + "kv_heads" => "num_key_value_heads", + "max_positions" => "max_position_embeddings", + "lin_k_heads" => "linear_num_key_heads", + "lin_v_heads" => "linear_num_value_heads", + "lin_k_dim" => "linear_key_head_dim", + "lin_v_dim" => "linear_value_head_dim", + "rotary_half" => "rope_parameters.partial_rotary_factor", + _ => field, + }; + provenance.insert( + field.to_string(), + format!( + "config.json#{}{json_key}", + if root.get("text_config").is_some() { + "text_config." + } else { + "" + } + ), + ); + } + let hidden_act = c["hidden_act"].as_str().map(str::to_string); + let attn_output_gate = c["attn_output_gate"].as_bool().unwrap_or(true); + let attention_bias = c["attention_bias"].as_bool().unwrap_or(false); + let rope_type = rope["rope_type"] + .as_str() + .unwrap_or("default") + .to_string(); + let mrope_interleaved = rope["mrope_interleaved"].as_bool().unwrap_or(true); + Ok(QwenHybridStructure { + hidden_act, + attn_output_gate, + attention_bias, + rope_type, + conv_kernel_dim: int("linear_conv_kernel_dim")?, + mrope_interleaved, + hidden: int("hidden_size")?, + intermediate: int("intermediate_size")?, + eps: c["rms_norm_eps"].as_f64().context("rms_norm_eps")? as f32, + heads: int("num_attention_heads")?, + kv_heads: int("num_key_value_heads")?, + head_dim, + rotary_half: (head_dim as f64 * partial) as usize / 2, + rope_theta: rope["rope_theta"] + .as_f64() + .or(c["rope_theta"].as_f64()) + .context("rope_theta")?, + mrope_section, + max_positions: int("max_position_embeddings")?, + image_token_id, + lin_k_heads: int("linear_num_key_heads")?, + lin_v_heads: int("linear_num_value_heads")?, + lin_k_dim: int("linear_key_head_dim")?, + lin_v_dim: int("linear_value_head_dim")?, + layer_kinds, + provenance, + }) +} + +/// The frozen ABI-6 kernel capability set: exactly what the CUDA +/// kernels implement, no more. Widening anything here is a kernel/ABI +/// decision, not a loader change. Every rejection reports the actual +/// value. +pub fn check_abi6(s: &QwenHybridStructure) -> Result<()> { + ensure!( + s.hidden_act.as_deref() == Some("silu"), + "hidden_act is not silu" + ); + ensure!(s.attn_output_gate, "attention without the output gate"); + ensure!(!s.attention_bias, "attention with bias"); + ensure!( + s.rope_type == "default", + "rope type {}", + s.rope_type + ); + ensure!( + s.conv_kernel_dim == 4, + "conv kernel is not 4" + ); + ensure!( + s.head_dim == 256 && s.lin_k_dim == 128 && s.lin_v_dim == 128, + "head dims {} / {} / {}", + s.head_dim, + s.lin_k_dim, + s.lin_v_dim + ); + ensure!( + s.rotary_half == 32, + "{} rotary dims", + 2 * s.rotary_half + ); + ensure!( + s.rope_theta.is_finite() && s.rope_theta > 0.0, + "invalid rope_theta" + ); + ensure!(s.mrope_interleaved, "non-interleaved mrope is unsupported"); + ensure!( + s.mrope_section + .iter() + .try_fold(0usize, |sum, &x| sum.checked_add(x)) + == Some(s.rotary_half), + "mrope sections must sum to rotary_half" + ); + ensure!( + s.max_positions > 0 && s.max_positions <= (1 << 24), + "unsupported max_position_embeddings" + ); + ensure!( + s.kv_heads > 0 && s.heads.is_multiple_of(s.kv_heads), + "attention heads" + ); + ensure!( + s.lin_k_heads > 0 && s.lin_v_heads.is_multiple_of(s.lin_k_heads), + "linear attention heads" + ); + ensure!(s.hidden.is_multiple_of(8), "hidden size"); + Ok(()) +} + +/// Per-layer prefix-state byte layout for one token length, derived +/// from the structure: per full-attention layer the post-prep K and +/// raw V rows, per Gated DeltaNet layer the float32 recurrent state +/// and the three pre-conv projection columns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrefixLayout { + pub len: usize, + pub full_layers: usize, + pub gdn_layers: usize, + /// One full-attention layer's K-plus-V bytes (`2 * len * kv row`). + pub full_kv_bytes: usize, + /// One GDN layer's float32 `[H, K, V]` state bytes. + pub gdn_state_bytes: usize, + /// One GDN layer's three conv-tail columns. + pub conv_tail_bytes: usize, +} + +impl PrefixLayout { + pub fn of(s: &QwenHybridStructure, len: usize) -> Self { + let kvrow = s.kv_heads * s.head_dim * BF16; + let state_floats = s.lin_v_heads * s.lin_k_dim * s.lin_v_dim; + Self { + len, + full_layers: s.full_attention_layers(), + gdn_layers: s.gdn_layers(), + full_kv_bytes: 2 * len * kvrow, + gdn_state_bytes: state_floats * F32, + conv_tail_bytes: 3 * s.gdn_in_width() * BF16, + } + } + + /// Total device bytes of one prefix of `len` tokens. + pub fn total_bytes(&self) -> usize { + self.full_layers * self.full_kv_bytes + + self.gdn_layers * (self.gdn_state_bytes + self.conv_tail_bytes) + } +} + +/// Stable identity of one structure executing on the ABI-6 backend. +/// Any prefix-cache record must carry it; a change means the state +/// must be re-captured, never reused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QwenIdentity { + pub schema_version: usize, + pub family: &'static str, + pub backend: &'static str, + pub abi_version: usize, + pub digest: String, +} + +pub fn structure_identity(s: &QwenHybridStructure) -> QwenIdentity { + let mut body = serde_json::Map::new(); + body.insert("schema_version".into(), Json::String(SCHEMA_VERSION.to_string())); + body.insert("family".into(), Json::String(FAMILY.into())); + body.insert("backend".into(), Json::String(BACKEND.into())); + body.insert("abi_version".into(), Json::String(ABI_VERSION.to_string())); + body.insert("structure".into(), s.canonical_json()); + let body = Json::Object(body).to_string(); + QwenIdentity { + schema_version: SCHEMA_VERSION, + family: FAMILY, + backend: BACKEND, + abi_version: ABI_VERSION, + digest: format!("{:x}", Sha256::digest(body.as_bytes())), + } +} diff --git a/tests/qwen3_5/structure.rs b/tests/qwen3_5/structure.rs new file mode 100644 index 00000000..03b7aa5d --- /dev/null +++ b/tests/qwen3_5/structure.rs @@ -0,0 +1,254 @@ +use omni_qwen3_5_native::model::Config; +use omni_qwen3_5_native::structure::{ + self, LayerKind, PrefixLayout, QwenHybridStructure, +}; +use serde_json::json; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +/// A minimal gate-passing structure; every test mutates from here. +fn hybrid( + hidden: usize, + intermediate: usize, + layers: usize, + heads: usize, + kv_heads: usize, + lin_k_heads: usize, + lin_v_heads: usize, +) -> QwenHybridStructure { + let mut s = structure::read_structure(&fixture_dir(default_config())).unwrap(); + s.hidden = hidden; + s.intermediate = intermediate; + s.heads = heads; + s.kv_heads = kv_heads; + s.lin_k_heads = lin_k_heads; + s.lin_v_heads = lin_v_heads; + s.layer_kinds = (0..layers) + .map(|i| { + if i % 4 == 3 { + LayerKind::FullAttention + } else { + LayerKind::GatedDeltaNet + } + }) + .collect(); + s +} + +fn vela_08b() -> QwenHybridStructure { + hybrid(1024, 3584, 24, 8, 2, 16, 16) +} + +fn vela_4b() -> QwenHybridStructure { + hybrid(2560, 9216, 32, 16, 4, 16, 32) +} + +fn vela_9b() -> QwenHybridStructure { + hybrid(4096, 12288, 32, 16, 4, 16, 32) +} + +fn default_config() -> serde_json::Value { + json!({ + "model_type": "qwen3_5_text", + "hidden_act": "silu", "attn_output_gate": true, "attention_bias": false, + "hidden_size": 5120, "intermediate_size": 17408, + "num_attention_heads": 24, "num_key_value_heads": 4, "head_dim": 256, + "num_hidden_layers": 4, + "layer_types": ["linear_attention", "linear_attention", "linear_attention", "full_attention"], + "rms_norm_eps": 1e-6, + "linear_num_key_heads": 16, "linear_num_value_heads": 48, + "linear_key_head_dim": 128, "linear_value_head_dim": 128, + "linear_conv_kernel_dim": 4, + "max_position_embeddings": 262144, + "rope_parameters": { + "rope_type": "default", "rope_theta": 10000000.0, + "partial_rotary_factor": 0.25, "mrope_interleaved": true, + "mrope_section": [11, 11, 10] + } + }) +} + +fn fixture_dir(config: serde_json::Value) -> std::path::PathBuf { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); + let path = dir.path().to_path_buf(); + // Keep the directory alive for the test body. + std::mem::forget(dir); + path +} + +#[test] +fn adapter_reads_the_checkpoint_and_records_provenance() { + let s = structure::read_structure(&fixture_dir(default_config())).unwrap(); + assert_eq!(s.hidden, 5120); + assert_eq!(s.head_dim, 256); + assert_eq!(s.rotary_half, 32); + assert_eq!(s.image_token_id, None); + assert_eq!( + s.layer_kinds, + [ + LayerKind::GatedDeltaNet, + LayerKind::GatedDeltaNet, + LayerKind::GatedDeltaNet, + LayerKind::FullAttention + ] + ); + assert_eq!( + s.provenance.get("hidden"), + Some(&"config.json#hidden_size".to_string()) + ); + assert_eq!(s.hidden_act.as_deref(), Some("silu")); + assert_eq!(s.conv_kernel_dim, 4); + structure::check_abi6(&s).unwrap(); +} + +#[test] +fn adapter_rejects_broken_sources() { + for (name, config) in [ + ("missing layer_types", { + let mut c = default_config(); + c.as_object_mut().unwrap().remove("layer_types"); + c + }), + ("unknown kind", { + let mut c = default_config(); + c["layer_types"][0] = json!("sparse_attention"); + c + }), + ("length mismatch", { + let mut c = default_config(); + c["layer_types"] = json!(["linear_attention"]); + c + }), + ("missing conv", { + let mut c = default_config(); + c.as_object_mut().unwrap().remove("linear_conv_kernel_dim"); + c + }), + ] { + assert!( + structure::read_structure(&fixture_dir(config)).is_err(), + "accepted {name}" + ); + } +} + +#[test] +fn the_frozen_gate_accepts_the_three_vela_hybrid_backbones() { + for (name, s) in [ + ("0.8B", vela_08b()), + ("4B", vela_4b()), + ("9B", vela_9b()), + ] { + structure::check_abi6(&s).unwrap_or_else(|e| panic!("{name} rejected: {e}")); + } +} + +#[test] +fn the_frozen_gate_rejects_every_capability_violation() { + type Mutation = Box; + let rejects: Vec<(&str, Mutation)> = vec![ + ("head_dim", Box::new(|s: &mut QwenHybridStructure| s.head_dim = 128)), + ("lin_k_dim", Box::new(|s| s.lin_k_dim = 64)), + ("lin_v_dim", Box::new(|s| s.lin_v_dim = 64)), + ("conv", Box::new(|s| s.conv_kernel_dim = 5)), + ("hidden_act", Box::new(|s| s.hidden_act = Some("relu".into()))), + ("output gate", Box::new(|s| s.attn_output_gate = false)), + ("attention bias", Box::new(|s| s.attention_bias = true)), + ("rope type", Box::new(|s| s.rope_type = "linear".into())), + ("mrope", Box::new(|s| s.mrope_interleaved = false)), + ( + "mrope sum", + Box::new(|s| s.mrope_section = [11, 11, 11]), + ), + ( + "rotary", + Box::new(|s| s.rotary_half = 16), + ), + ( + "max positions", + Box::new(|s| s.max_positions = 1 << 25), + ), + ( + "heads divisibility", + Box::new(|s: &mut QwenHybridStructure| { + s.heads = 9; + s.kv_heads = 2; + }), + ), + ( + "linear heads divisibility", + Box::new(|s: &mut QwenHybridStructure| { + s.lin_v_heads = 17; + }), + ), + ("hidden alignment", Box::new(|s| s.hidden = 1025)), + ]; + assert_eq!(rejects.len(), 15); + for (name, mutate) in rejects { + let mut s = structure::read_structure(&fixture_dir(default_config())).unwrap(); + mutate(&mut s); + assert!( + structure::check_abi6(&s).is_err(), + "gate accepted {name}" + ); + } +} + +#[test] +fn the_open_jev_27b_checkpoint_passes_the_gate_unchanged() { + let dir = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../../tests/qwen3_5/data" + )); + let s = structure::read_structure(dir).unwrap(); + structure::check_abi6(&s).unwrap(); + assert_eq!(s.hidden, 5120); + assert_eq!(s.layer_kinds.len(), 64); + assert_eq!(s.full_attention_layers(), 16); + assert_eq!(s.gdn_layers(), 48); + // The facade projects the same values it always did. + let cfg = Config::load(dir).unwrap(); + assert_eq!(cfg.hidden, s.hidden); + assert_eq!(cfg.full_attention.len(), s.layer_kinds.len()); + assert_eq!( + cfg.full_attention.iter().filter(|&&f| f).count(), + s.full_attention_layers() + ); +} + +#[test] +fn prefix_layout_sizes_derive_from_the_structure() { + let layout = PrefixLayout::of(&vela_08b(), 64); + assert_eq!(layout.full_layers, 6); + assert_eq!(layout.gdn_layers, 18); + assert_eq!(layout.full_kv_bytes, 2 * 64 * 2 * 256 * 2); + assert_eq!(layout.gdn_state_bytes, 16 * 128 * 128 * 4); + assert_eq!(layout.conv_tail_bytes, 3 * 8224 * 2); + assert_eq!( + layout.total_bytes(), + 6 * layout.full_kv_bytes + 18 * (layout.gdn_state_bytes + layout.conv_tail_bytes) + ); + // Grows linearly in tokens through the full-attention rows only. + let longer = PrefixLayout::of(&vela_08b(), 128); + assert_eq!(longer.full_kv_bytes, 2 * layout.full_kv_bytes); + assert_eq!(longer.gdn_state_bytes, layout.gdn_state_bytes); +} + +#[test] +fn identity_is_stable_and_sensitive() { + let a = structure::structure_identity(&vela_08b()); + let b = structure::structure_identity(&vela_08b()); + assert_eq!(a, b); + assert_eq!(a.abi_version, 6); + assert_eq!(a.family, "qwen3_5_hybrid"); + assert_ne!(a, structure::structure_identity(&vela_4b())); + let mut drifted = vela_08b(); + drifted.rope_theta = 5e6; + assert_ne!(a, structure::structure_identity(&drifted)); + // Provenance is not part of identity. + let mut reworded = vela_08b(); + reworded.provenance.clear(); + assert_eq!(a, structure::structure_identity(&reworded)); +} From 0397892d04421715fc896d61d1faf254d63c42d1 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 01:35:33 +0800 Subject: [PATCH 06/21] vela: add the 2.0 0.3B ModernBERT profile crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.3B becomes the second ModernBERT profile on the family core, the CPU-verifiable half of the RFC's phase 3. The checkpoint's own files are the evidence base throughout. - config: reads the pinned vela2-unified bundle root and parses its encoder_config through the family adapter; foreign roots or encoder model types are rejected before any structure exists - profile: the registered vela-2.0-0.3b structure (768/1152, 22 layers with full attention every third, 12 heads, both rope thetas 160000, yarn factor 4 from 8192, vocab 256008, no type embedding, 32768 positions) against the regenerated-bundle target limits — hidden 768, yarn tables, batches to 4, lengths to 8192 - weights: whole-checkpoint accounting against the structure — the encoder inventory comes from the family geometry and the 18 head-side tensors (readout projections, classifier MLP, calibration scalars) are recorded from the pinned safetensors header census, which is committed as a fixture; missing, extra, shape and dtype drift all fail naming both sides. Storage classes mirror the serving conventions (embedding f16, norms f32, projections bf16) - rope: the YaRN inverse frequencies and attention scaling exactly as the reference runtime computes them (transformers 5.19.0 yarn initialization, f64 internally, narrowed to f32), with the mscale baked into the cos/sin tables the way the reference does. A fixture dumped from the pinned transformers over the pinned encoder_config gates the generator: inverse frequencies to 1e-7, the scaling to 1e-12, cos/sin to 1e-4 inside the served 8192 window and 5e-4 over the full 32768-extension (f32-power ulp noise amplified by position; measured, not assumed) Census equality is asserted against the family geometry: the pinned checkpoint's 152 tensors are exactly the 134 derived encoder tensors plus the 18 recorded head tensors, names and shapes matching one for one. The encoder execution, kernel bundle regeneration and the laya ABI bump stay fenced until a TileLang toolchain can build the bundle; no kernel or ABI change is in this commit. Signed-off-by: Mr.Dong <1042542469@qq.com> --- Cargo.lock | 12 + Cargo.toml | 2 +- specs/model-config/README.md | 9 + src/models/vela/native/Cargo.toml | 28 + src/models/vela/native/src/config.rs | 30 + src/models/vela/native/src/lib.rs | 15 + src/models/vela/native/src/profile.rs | 88 ++ src/models/vela/native/src/rope.rs | 87 ++ src/models/vela/native/src/weights.rs | 124 ++ .../native/data/pinned-checkpoint-census.json | 1006 ++++++++++++++++ tests/vela/native/data/yarn-reference.json | 1010 +++++++++++++++++ tests/vela/native/profile.rs | 122 ++ tests/vela/native/rope.rs | 96 ++ tests/vela/native/weights.rs | 160 +++ 14 files changed, 2788 insertions(+), 1 deletion(-) create mode 100644 src/models/vela/native/Cargo.toml create mode 100644 src/models/vela/native/src/config.rs create mode 100644 src/models/vela/native/src/lib.rs create mode 100644 src/models/vela/native/src/profile.rs create mode 100644 src/models/vela/native/src/rope.rs create mode 100644 src/models/vela/native/src/weights.rs create mode 100644 tests/vela/native/data/pinned-checkpoint-census.json create mode 100644 tests/vela/native/data/yarn-reference.json create mode 100644 tests/vela/native/profile.rs create mode 100644 tests/vela/native/rope.rs create mode 100644 tests/vela/native/weights.rs diff --git a/Cargo.lock b/Cargo.lock index 6941e627..44781801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1303,6 +1303,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "omni-vela-native" +version = "0.1.0" +dependencies = [ + "anyhow", + "memmap2", + "omni-modernbert", + "safetensors 0.6.2", + "serde_json", + "tempfile", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/Cargo.toml b/Cargo.toml index a37bf37c..3ac49fe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["src/frontend", "src/runtime", "src/models/clm", "src/models/cua_s1/native", "src/models/qwen3_5/native", "src/models/open_jev/native", "src/models/jev_vl/native", "src/models/omnijev/native", "src/models/laya", "src/models/modernbert", "src/backends/cuda", "src/models/decider/native"] +members = ["src/frontend", "src/runtime", "src/models/clm", "src/models/cua_s1/native", "src/models/qwen3_5/native", "src/models/open_jev/native", "src/models/jev_vl/native", "src/models/omnijev/native", "src/models/laya", "src/models/modernbert", "src/models/vela/native", "src/backends/cuda", "src/models/decider/native"] resolver = "3" diff --git a/specs/model-config/README.md b/specs/model-config/README.md index f82d9d06..4cd5cbc6 100644 --- a/specs/model-config/README.md +++ b/specs/model-config/README.md @@ -28,6 +28,15 @@ RFC Phase 6 (unified facade sweep + release evidence) is a follow-on after the Vela slices consume these outputs; it is deliberately not spec'd here. +Implementation status on this branch: 01 and 02 complete (`705c23a`, +`d5d9373`, `df5b8e1`); 05's structure/gate/layout/identity core +complete with the loader seams deferred to the consumer slice +(`0f30094`); 03's CPU half complete — census fixture, profile +registration, weight accounting, YaRN tables fixture-grounded against +the pinned transformers — with the CUDA bundle regeneration (and the +laya ABI 1→2 bump) fenced until a TileLang toolchain is available; 04 +remains boundary-only until the encoder executes. + ## What this series does NOT do Frozen-boundary fence (RFC §11, charter §8) — no spec below may: diff --git a/src/models/vela/native/Cargo.toml b/src/models/vela/native/Cargo.toml new file mode 100644 index 00000000..703c0132 --- /dev/null +++ b/src/models/vela/native/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "omni-vela-native" +version = "0.1.0" +edition = "2024" +publish = false +description = "Vela 2.0 0.3B ModernBERT profile on the family core" + +[dependencies] +anyhow = "1" +memmap2 = "0.9" +omni-modernbert = { path = "../../modernbert" } +safetensors = "0.6" +serde_json = "1" + +[dev-dependencies] +tempfile = "3" + +[[test]] +name = "vela_profile" +path = "../../../../tests/vela/native/profile.rs" + +[[test]] +name = "vela_weights" +path = "../../../../tests/vela/native/weights.rs" + +[[test]] +name = "vela_rope" +path = "../../../../tests/vela/native/rope.rs" diff --git a/src/models/vela/native/src/config.rs b/src/models/vela/native/src/config.rs new file mode 100644 index 00000000..36cb9f2f --- /dev/null +++ b/src/models/vela/native/src/config.rs @@ -0,0 +1,30 @@ +use anyhow::{Context, Result, ensure}; +use omni_modernbert::structure::ModernBertStructure; +use omni_modernbert::{ModernBertConfigAdapter, SourceMeta}; +use serde_json::Value as Json; +use std::path::Path; + +/// Reads the pinned bundle layout: one `config.json` whose root is the +/// `vela2-unified` model description with the ModernBERT fields under +/// `encoder_config`. The root's recipe fields (`max_length`, markers, +/// calibration pointers) are worker-side and never structure input. +pub fn read_structure(dir: &Path) -> Result<(ModernBertStructure, SourceMeta)> { + let path = dir.join("config.json"); + let bytes = std::fs::read(&path).with_context(|| format!("{}", path.display()))?; + let root: Json = serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", path.display()))?; + ensure!( + root["model_type"].as_str() == Some("vela2-unified"), + "model_type {:?} is not the vela2-unified 0.3B bundle", + root["model_type"] + ); + let encoder = root + .get("encoder_config") + .context("encoder_config is missing")?; + ensure!( + encoder["model_type"].as_str() == Some("modernbert"), + "encoder_config model_type {:?} is not modernbert", + encoder["model_type"] + ); + ModernBertConfigAdapter::from_value(encoder, "config.json#encoder_config") +} diff --git a/src/models/vela/native/src/lib.rs b/src/models/vela/native/src/lib.rs new file mode 100644 index 00000000..3298db86 --- /dev/null +++ b/src/models/vela/native/src/lib.rs @@ -0,0 +1,15 @@ +//! The Vela 2.0 0.3B model as a ModernBERT profile on the family core: +//! bundle config parsing, profile admission, the model-local checkpoint +//! inventory, and the YaRN-scaled rotary tables. Weight upload and +//! encoder execution land with the regenerated CUDA bundle; everything +//! here is CPU-verifiable against the pinned checkpoint's own files. + +pub mod config; +pub mod profile; +pub mod rope; +pub mod weights; + +pub use config::read_structure; +pub use profile::profile; +pub use rope::{YarnTables, cos_sin_tables, yarn}; +pub use weights::{Weights, head_tensors, storage_dtype}; diff --git a/src/models/vela/native/src/profile.rs b/src/models/vela/native/src/profile.rs new file mode 100644 index 00000000..3a42ae15 --- /dev/null +++ b/src/models/vela/native/src/profile.rs @@ -0,0 +1,88 @@ +use omni_modernbert::gate::{BackendLimits, RegisteredProfile}; +use omni_modernbert::structure::{ + Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, YarnScaling, +}; + +/// The registered `vela-2.0-0.3b` profile: the pinned checkpoint's +/// structure (read from `encoder_config`, cross-checked against the +/// recorded tensor census) against the limits of the regenerated CUDA +/// bundle this profile targets — hidden 768 instantiations, YaRN rotary +/// tables, requests packed to 8 192 tokens. The bundle's own ABI is +/// checked when it is loaded; the executor slice lands with it. +pub fn profile() -> RegisteredProfile { + RegisteredProfile { + name: "vela-2.0-0.3b", + structure: canonical_structure(), + limits: BackendLimits { + hidden_sizes: vec![768], + batch_max: 4, + len_min: 16, + len_max: 8192, + len_step: 16, + yarn_scaling: true, + }, + backend: "laya_cuda", + abi_version: 2, + } +} + +fn canonical_structure() -> ModernBertStructure { + let mut provenance = std::collections::BTreeMap::new(); + for field in [ + "hidden_size", + "intermediate_size", + "num_hidden_layers", + "norm_eps", + "num_attention_heads", + "head_dim", + "schedule", + "local_window", + "rope_full", + "rope_sliding", + "vocab_size", + "type_embedding_rows", + "max_positions", + ] { + provenance.insert( + field.to_string(), + FieldOrigin::Source("registered profile vela-2.0-0.3b".into()), + ); + } + let yarn = YarnScaling { + factor: 4.0, + original_max_positions: 8192, + beta_fast: 32.0, + beta_slow: 1.0, + }; + ModernBertStructure { + hidden_size: 768, + intermediate_size: 1152, + num_hidden_layers: 22, + activation: Activation::Gelu, + norm_eps: 1e-5, + num_attention_heads: 12, + head_dim: 64, + schedule: (0..22) + .map(|i| { + if i % 3 == 0 { + AttentionKind::Full + } else { + AttentionKind::Sliding + } + }) + .collect(), + local_window: 128, + rope_full: RopeSpec { + theta: 160000.0, + scaling: Some(yarn), + }, + rope_sliding: RopeSpec { + theta: 160000.0, + scaling: Some(yarn), + }, + vocab_size: 256008, + type_embedding_rows: None, + max_positions: 32768, + provenance, + } +} diff --git a/src/models/vela/native/src/rope.rs b/src/models/vela/native/src/rope.rs new file mode 100644 index 00000000..e7f450dc --- /dev/null +++ b/src/models/vela/native/src/rope.rs @@ -0,0 +1,87 @@ +use anyhow::{Context, Result, ensure}; +use omni_modernbert::structure::{ModernBertStructure, RopeSpec, YarnScaling}; + +/// YaRN-derived rotary parameters: the interpolated inverse frequencies +/// and the attention scaling the reference applies beside them. Both +/// attention kinds share one set (the pinned config states one theta +/// and one rope_scaling). +#[derive(Debug, Clone, PartialEq)] +pub struct YarnTables { + /// `rotary_half` entries; row `p` of a cos/sin table is + /// `p * inv_freq[j]` passed through cos/sin. + pub inv_freq: Vec, + /// `1 + 0.1 * ln(factor)` in the reference's default-mscale path. + pub attention_scaling: f64, +} + +/// Computes the YaRN inverse frequencies exactly as the reference +/// runtime does (transformers' yarn initialization): extrapolation and +/// interpolation frequencies blended by a linear ramp whose bounds come +/// from the correction-dimension formula, floored/ceiled under +/// `truncate`, and the default attention scaling. Internals run in +/// f64, outputs narrow to f32. +pub fn yarn(rope: &RopeSpec, rotary_half: usize) -> Result { + let YarnScaling { + factor, + original_max_positions, + beta_fast, + beta_slow, + } = rope + .scaling + .as_ref() + .context("rope has no yarn scaling")?; + let base = rope.theta; + let half = rotary_half as f64; + let correction_dim = |rotations: f64| { + (2.0 * half * (*original_max_positions as f64 / (rotations * std::f64::consts::TAU)).ln()) + / (2.0 * base.ln()) + }; + let low = correction_dim(*beta_fast).floor().max(0.0); + let high = correction_dim(*beta_slow).ceil().min(rotary_half as f64 - 1.0); + ensure!(high > low, "degenerate yarn ramp bounds {low}..{high}"); + let ramp = |j: f64| ((j - low) / (high - low)).clamp(0.0, 1.0); + let inv_freq = (0..rotary_half) + .map(|j| { + let j = j as f64; + let pos_freq = base.powf(j / half); + let interpolation = 1.0 / (factor * pos_freq); + let extrapolation = 1.0 / pos_freq; + let ramp = ramp(j); + (interpolation * ramp + extrapolation * (1.0 - ramp)) as f32 + }) + .collect(); + Ok(YarnTables { + inv_freq, + attention_scaling: 1.0 + 0.1 * factor.ln(), + }) +} + +/// Full cos/sin tables for positions `[0, len)` in f32, row-major +/// `[len, rotary_half]` each — the layout the family rope kernel +/// consumes. The reference runtime multiplies the YaRN attention +/// scaling into its tables, so these do too; the kernel's own +/// attention scale stays unchanged and must not apply it twice. +pub fn cos_sin_tables( + structure: &ModernBertStructure, + len: usize, +) -> Result<(Vec, Vec, YarnTables)> { + ensure!(len > 0 && len <= structure.max_positions, "table length {len}"); + let rope = &structure.rope_full; + ensure!( + structure.rope_full == structure.rope_sliding, + "the two attention kinds must share one rope" + ); + let tables = yarn(rope, omni_modernbert::geometry::rotary_half(structure))?; + let half = tables.inv_freq.len(); + let mut cos = Vec::with_capacity(len * half); + let mut sin = Vec::with_capacity(len * half); + let scaling = tables.attention_scaling as f32; + for pos in 0..len { + for &f in &tables.inv_freq { + let angle = pos as f32 * f; + cos.push(angle.cos() * scaling); + sin.push(angle.sin() * scaling); + } + } + Ok((cos, sin, tables)) +} diff --git a/src/models/vela/native/src/weights.rs b/src/models/vela/native/src/weights.rs new file mode 100644 index 00000000..ebd64407 --- /dev/null +++ b/src/models/vela/native/src/weights.rs @@ -0,0 +1,124 @@ +use anyhow::{Context, Result, ensure}; +use memmap2::Mmap; +use omni_modernbert::structure::ModernBertStructure; +use safetensors::{Dtype, SafeTensors}; +use std::{collections::BTreeMap, fs::File, path::Path}; + +/// Storage class per tensor name, mirroring the bundle's serving +/// conventions: the embedding uploads as f16, every norm stays f32, and +/// the GEMM-class projections narrow to bf16 at upload. The pinned +/// checkpoint stores everything f32; the narrowing is a declared +/// numerics choice carried in the profile identity. +pub fn storage_dtype(name: &str) -> &'static str { + if name == "encoder.embeddings.tok_embeddings.weight" { + "f16" + } else if name.contains("norm") { + "f32" + } else { + "bf16" + } +} + +/// The head-side tensors of the pinned checkpoint, recorded from its +/// safetensors header. They belong to the task heads (the readout +/// projections, the classifier MLP, the calibration scalars) and load +/// in that slice; the checkpoint accounting here covers them so every +/// tensor in the file is named exactly once. +pub fn head_tensors() -> Vec<(String, Vec)> { + let h = 768; + let mut tensors = vec![ + ("cls_mlp.0.weight".to_string(), vec![1536, h]), + ("cls_mlp.0.bias".to_string(), vec![1536]), + ("cls_mlp.3.weight".to_string(), vec![1, 1536]), + ("cls_mlp.3.bias".to_string(), vec![1]), + ("norm.weight".to_string(), vec![h]), + ("norm.bias".to_string(), vec![h]), + ]; + for name in ["w_e", "w_o", "w_p", "w_q", "w_t"] { + tensors.push((format!("{name}.weight"), vec![256, h])); + tensors.push((format!("{name}.bias"), vec![256])); + } + tensors.push(("log_tau".to_string(), vec![])); + tensors.push(("log_tau_span".to_string(), vec![])); + tensors +} + +pub struct Weights { + data: Mmap, +} + +impl Weights { + /// The checkpoint must remain immutable while the mapping exists. + pub fn open(path: &Path) -> Result { + let file = File::open(path)?; + // SAFETY: model files are read-only inputs; no mutable mapping is created. + let data = unsafe { Mmap::map(&file)? }; + SafeTensors::deserialize(&data)?; + Ok(Self { data }) + } + + /// Validate the whole checkpoint against the admitted structure: + /// every encoder tensor must appear with the derived shape and f32 + /// storage, the head inventory must cover the rest exactly, and any + /// missing, extra or duplicate name fails listing both sides. + pub fn validate(&self, structure: &ModernBertStructure) -> Result { + let tensors = SafeTensors::deserialize(&self.data)?; + let mut expected: BTreeMap> = BTreeMap::new(); + for (name, shape) in omni_modernbert::geometry::encoder_tensors(structure) { + ensure!(expected.insert(name.clone(), shape).is_none(), "duplicate {name}"); + } + for (name, shape) in head_tensors() { + ensure!(expected.insert(name.clone(), shape).is_none(), "duplicate {name}"); + } + let actual: std::collections::BTreeSet = tensors + .names() + .into_iter() + .map(String::from) + .collect(); + let expected_names: std::collections::BTreeSet = + expected.keys().cloned().collect(); + if actual != expected_names { + let missing: Vec<_> = expected_names.difference(&actual).collect(); + let extra: Vec<_> = actual.difference(&expected_names).collect(); + anyhow::bail!( + "checkpoint inventory does not match the profile: missing {missing:?}, unexpected {extra:?}" + ); + } + for (name, shape) in &expected { + let view = tensors.tensor(name)?; + ensure!( + view.shape() == shape, + "{name}: expected {shape:?}, got {:?}", + view.shape() + ); + ensure!( + view.dtype() == Dtype::F32, + "{name} is {:?}, the pinned checkpoint stores f32", + view.dtype() + ); + } + Ok(expected.len()) + } + + /// Read one f32 tensor with its expected shape. Upload-time storage + /// conversion (f16/bf16 narrowing per [`storage_dtype`]) happens in + /// the executor slice. + pub fn f32(&self, name: &str, shape: &[usize]) -> Result> { + let tensors = SafeTensors::deserialize(&self.data)?; + let t = tensors + .tensor(name) + .with_context(|| format!("{name} is missing"))?; + ensure!( + t.shape() == shape, + "{name}: expected {shape:?}, got {:?}", + t.shape() + ); + ensure!(t.dtype() == Dtype::F32, "{name} is not f32"); + Ok(t.data() + .as_chunks::<4>() + .0 + .iter() + .map(|b| f32::from_le_bytes(*b)) + .collect()) + } +} diff --git a/tests/vela/native/data/pinned-checkpoint-census.json b/tests/vela/native/data/pinned-checkpoint-census.json new file mode 100644 index 00000000..620b7351 --- /dev/null +++ b/tests/vela/native/data/pinned-checkpoint-census.json @@ -0,0 +1,1006 @@ +{ +"cls_mlp.0.bias": { +"dtype": "F32", +"shape": [ +1536 +] +}, +"cls_mlp.0.weight": { +"dtype": "F32", +"shape": [ +1536, +768 +] +}, +"cls_mlp.3.bias": { +"dtype": "F32", +"shape": [ +1 +] +}, +"cls_mlp.3.weight": { +"dtype": "F32", +"shape": [ +1, +1536 +] +}, +"encoder.embeddings.norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.embeddings.tok_embeddings.weight": { +"dtype": "F32", +"shape": [ +256008, +768 +] +}, +"encoder.final_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.0.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.0.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.0.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.0.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.0.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.1.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.1.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.1.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.1.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.1.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.1.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.10.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.10.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.10.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.10.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.10.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.10.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.11.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.11.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.11.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.11.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.11.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.11.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.12.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.12.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.12.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.12.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.12.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.12.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.13.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.13.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.13.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.13.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.13.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.13.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.14.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.14.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.14.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.14.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.14.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.14.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.15.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.15.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.15.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.15.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.15.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.15.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.16.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.16.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.16.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.16.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.16.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.16.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.17.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.17.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.17.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.17.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.17.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.17.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.18.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.18.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.18.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.18.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.18.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.18.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.19.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.19.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.19.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.19.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.19.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.19.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.2.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.2.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.2.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.2.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.2.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.2.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.20.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.20.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.20.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.20.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.20.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.20.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.21.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.21.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.21.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.21.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.21.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.21.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.3.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.3.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.3.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.3.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.3.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.3.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.4.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.4.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.4.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.4.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.4.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.4.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.5.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.5.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.5.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.5.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.5.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.5.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.6.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.6.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.6.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.6.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.6.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.6.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.7.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.7.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.7.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.7.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.7.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.7.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.8.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.8.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.8.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.8.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.8.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.8.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.9.attn.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +768 +] +}, +"encoder.layers.9.attn.Wqkv.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.9.attn_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"encoder.layers.9.mlp.Wi.weight": { +"dtype": "F32", +"shape": [ +2304, +768 +] +}, +"encoder.layers.9.mlp.Wo.weight": { +"dtype": "F32", +"shape": [ +768, +1152 +] +}, +"encoder.layers.9.mlp_norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"log_tau": { +"dtype": "F32", +"shape": [] +}, +"log_tau_span": { +"dtype": "F32", +"shape": [] +}, +"norm.bias": { +"dtype": "F32", +"shape": [ +768 +] +}, +"norm.weight": { +"dtype": "F32", +"shape": [ +768 +] +}, +"w_e.bias": { +"dtype": "F32", +"shape": [ +256 +] +}, +"w_e.weight": { +"dtype": "F32", +"shape": [ +256, +768 +] +}, +"w_o.bias": { +"dtype": "F32", +"shape": [ +256 +] +}, +"w_o.weight": { +"dtype": "F32", +"shape": [ +256, +768 +] +}, +"w_p.bias": { +"dtype": "F32", +"shape": [ +256 +] +}, +"w_p.weight": { +"dtype": "F32", +"shape": [ +256, +768 +] +}, +"w_q.bias": { +"dtype": "F32", +"shape": [ +256 +] +}, +"w_q.weight": { +"dtype": "F32", +"shape": [ +256, +768 +] +}, +"w_t.bias": { +"dtype": "F32", +"shape": [ +256 +] +}, +"w_t.weight": { +"dtype": "F32", +"shape": [ +256, +768 +] +} +} \ No newline at end of file diff --git a/tests/vela/native/data/yarn-reference.json b/tests/vela/native/data/yarn-reference.json new file mode 100644 index 00000000..e8a26adf --- /dev/null +++ b/tests/vela/native/data/yarn-reference.json @@ -0,0 +1,1010 @@ +{ +"source": "transformers 5.19.0 ModernBertRotaryEmbedding, ModernBertConfig(Vela-2.0-0.3B@d6f03aa9 encoder_config), CPU float32", +"positions": [ +0, +1, +2, +63, +64, +1024, +4095, +8191, +8192, +8193, +12288, +16384, +24576, +32767 +], +"attention_scaling": 1.138629436111989, +"inv_freq": [ +1.0, +0.687656044960022, +0.472870796918869, +0.32517245411872864, +0.22360679507255554, +0.15376456081867218, +0.1057371199131012, +0.07271077483892441, +0.05000000074505806, +0.03438280150294304, +0.022031478583812714, +0.0140415383502841, +0.0088934525847435, +0.005591438151896, +0.003484518965706229, +0.002148272702470422, +0.0013068180996924639, +0.0007814273121766746, +0.0004567502182908356, +0.0002586598857305944, +0.00013975425099488348, +9.610284905647859e-05, +6.608570402022451e-05, +4.5444234274327755e-05, +3.125000148429535e-05, +2.148924977518618e-05, +1.4777213436900638e-05, +1.0161638783756644e-05, +6.987712822592584e-06, +4.8051424528239295e-06, +3.3042852010112256e-06, +2.272211759191123e-06 +], +"cos_half": [ +[ +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292 +], +[ +0.6152041554450989, +0.8798598647117615, +1.0136812925338745, +1.0789602994918823, +1.110282063484192, +1.1251952648162842, +1.1322702169418335, +1.1356208324432373, +1.1372064352035522, +1.1379565000534058, +1.1383531093597412, +1.138517141342163, +1.1385844945907593, +1.1386116743087769, +1.138622522354126, +1.1386268138885498, +1.1386284828186035, +1.1386290788650513, +1.1386293172836304, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292 +], +[ +-0.4738370478153229, +0.22116917371749878, +0.6662595272064209, +0.9062067270278931, +1.0266516208648682, +1.0852099657058716, +1.1132636070251465, +1.1266111135482788, +1.1329410076141357, +1.1359384059906006, +1.1375242471694946, +1.1381804943084717, +1.1384493112564087, +1.138558268547058, +1.1386017799377441, +1.1386189460754395, +1.1386256217956543, +1.1386281251907349, +1.1386289596557617, +1.1386293172836304, +1.1386293172836304, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292, +1.13862943649292 +], +[ +1.1225708723068237, +0.8995316624641418, +-0.061762042343616486, +-0.07454098761081696, +0.056838519871234894, +-1.0996575355529785, +1.0581409931182861, +-0.14942322671413422, +-1.1385891437530518, +-0.6385136246681213, +0.20699888467788696, +0.7214195728302002, +0.964535653591156, +1.0687121152877808, +1.1113035678863525, +1.128217101097107, +1.134772777557373, +1.1372499465942383, +1.1381580829620361, +1.1384782791137695, +1.1385853290557861, +1.1386085748672485, +1.1386195421218872, +1.1386247873306274, +1.138627290725708, +1.138628363609314, +1.1386289596557617, +1.1386291980743408, +1.1386293172836304, +1.1386293172836304, +1.13862943649292, +1.13862943649292 +], +[ +0.44618019461631775, +1.1381930112838745, +0.46283289790153503, +-0.4336148500442505, +-0.1967509239912033, +-1.0414478778839111, +1.00785231590271, +-0.0670258104801178, +-1.1366877555847168, +-0.6705442667007446, +0.18228302896022797, +0.7089793086051941, +0.9591161608695984, +1.0664987564086914, +1.1104328632354736, +1.1278843879699707, +1.134649395942688, +1.137205719947815, +1.1381429433822632, +1.138473391532898, +1.1385838985443115, +1.1386079788208008, +1.138619303703308, +1.138624668121338, +1.1386271715164185, +1.138628363609314, +1.1386289596557617, +1.1386291980743408, +1.1386293172836304, +1.1386293172836304, +1.13862943649292, +1.13862943649292 +], +[ +1.124229907989502, +1.0286998748779297, +1.042241096496582, +1.138038158416748, +-1.064469814300537, +1.0593875646591187, +0.12515044212341309, +0.6693502068519592, +0.6765803694725037, +-0.906120777130127, +-0.9591747522354126, +-0.2721683084964752, +-1.0815833806991577, +0.9661869406700134, +-1.0366042852401733, +-0.6699293255805969, +0.262479692697525, +0.7931424975395203, +1.0163429975509644, +1.0989222526550293, +1.126989722251892, +1.1331204175949097, +1.1360232830047607, +1.1373968124389648, +1.1380465030670166, +1.138353705406189, +1.1384990215301514, +1.1385678052902222, +1.1386003494262695, +1.138615608215332, +1.1386228799819946, +1.1386263370513916 +], +[ +-0.07512220740318298, +0.5322462320327759, +0.4286642074584961, +1.0232322216033936, +-0.11872357875108719, +0.25229644775390625, +0.9729129076004028, +-0.8703641891479492, +-0.9727998971939087, +-0.9560906291007996, +-0.7190672159194946, +0.6609950661659241, +0.3260018825531006, +-0.7026075720787048, +-0.14979302883148193, +-0.9216501116752625, +0.6790989637374878, +-1.1366914510726929, +-0.3360483646392822, +0.5574265122413635, +0.9572014212608337, +1.0515891313552856, +1.09718918800354, +1.118970274925232, +1.1293190717697144, +1.1342236995697021, +1.1365453004837036, +1.137643814086914, +1.1381633281707764, +1.1384090185165405, +1.1385252475738525, +1.1385802030563354 +], +[ +-0.7359992265701294, +-1.0927605628967285, +-1.088085412979126, +0.9505313038825989, +-1.1385061740875244, +-1.0900436639785767, +0.6277983784675598, +0.273050993680954, +0.4724070727825165, +0.5024028420448303, +-0.2057957947254181, +-0.38627222180366516, +-0.946363627910614, +-0.2777009606361389, +-1.0981756448745728, +0.35573408007621765, +-0.32715022563934326, +1.1307802200317383, +-0.9399774074554443, +-0.5930944681167603, +0.47058945894241333, +0.803697943687439, +0.9758458137512207, +1.0606529712677002, +1.1015311479568481, +1.121036171913147, +1.1302987337112427, +1.1346875429153442, +1.1367648839950562, +1.1377476453781128, +1.1382124423980713, +1.138432264328003 +], +[ +0.33339276909828186, +-1.0475496053695679, +-1.1214704513549805, +1.1009937524795532, +-1.1064395904541016, +-1.1275886297225952, +0.724525511264801, +0.352614164352417, +0.42005088925361633, +0.5372417569160461, +-0.18107281625270844, +-0.40126997232437134, +-0.9406938552856445, +-0.2838718891143799, +-1.097120761871338, +0.35805627703666687, +-0.3257249891757965, +1.1306756734848022, +-0.9396838545799255, +-0.5933458209037781, +0.47044458985328674, +0.8036203980445862, +0.9758070707321167, +1.0606341361999512, +1.1015220880508423, +1.1210318803787231, +1.1302967071533203, +1.1346865892410278, +1.136764407157898, +1.1377474069595337, +1.1382123231887817, +1.138432264328003 +], +[ +1.0962649583816528, +-0.5264052152633667, +-0.9086950421333313, +1.1360465288162231, +-1.0192683935165405, +-1.138510823249817, +0.8131633996963501, +0.4303148686885834, +0.36664533615112305, +0.5714452266693115, +-0.15626192092895508, +-0.41618862748146057, +-0.9349496364593506, +-0.2900339663028717, +-1.0960525274276733, +0.3603768050670624, +-0.32429924607276917, +1.1305702924728394, +-0.9393899440765381, +-0.5935972332954407, +0.47029972076416016, +0.8035429120063782, +0.9757682085037231, +1.0606153011322021, +1.101513147354126, +1.1210275888442993, +1.130294680595398, +1.1346856355667114, +1.1367639303207397, +1.1377471685409546, +1.1382122039794922, +1.1384321451187134 +], +[ +-0.37935808300971985, +0.6471661329269409, +0.29339906573295593, +1.0545871257781982, +-0.39833804965019226, +-0.23624123632907867, +0.2807825803756714, +0.3507510721683502, +0.24699416756629944, +0.05504527688026428, +0.9731765389442444, +-1.1045781373977661, +-0.8903589248657227, +1.0454181432724, +0.44996723532676697, +0.34253257513046265, +-1.069512128829956, +-1.1207594871520996, +0.8920308947563171, +-1.1378576755523682, +-0.16621750593185425, +0.43277212977409363, +0.7833817601203918, +0.965664803981781, +1.0557070970535278, +1.0991625785827637, +1.119909405708313, +1.129764437675476, +1.1344345808029175, +1.1366451978683472, +1.1376910209655762, +1.1381856203079224 +], +[ +-0.9433934092521667, +0.7888809442520142, +1.0705108642578125, +0.9905748963356018, +1.0116900205612183, +1.0946804285049438, +-0.2165784388780594, +-0.9202321767807007, +-0.8287081718444824, +-0.6316537857055664, +-1.081038475036621, +-0.8558024168014526, +0.4157041013240814, +-0.9970850944519043, +0.9756210446357727, +-0.9134389162063599, +-0.9522706866264343, +1.106925368309021, +0.4123680293560028, +-0.5202381610870361, +-0.7498846650123596, +-0.004272983875125647, +0.5339065790176392, +0.8373335599899292, +0.9926188588142395, +1.0687830448150635, +1.105420470237732, +1.1228853464126587, +1.1311755180358887, +1.13510262966156, +1.1369612216949463, +1.1378405094146729 +], +[ +-0.8858473896980286, +-0.40296605229377747, +-0.9874248504638672, +0.8148664236068726, +-0.85992032289505, +-1.0405994653701782, +-1.000149130821228, +-0.9225339889526367, +-1.0314723253250122, +-1.1333072185516357, +0.5249013900756836, +1.0044609308242798, +0.2538149356842041, +0.781045138835907, +-0.7829904556274414, +-0.932542085647583, +0.8705511689186096, +1.067710518836975, +0.2590492367744446, +1.1355434656143188, +-1.0901004076004028, +-0.8096519708633423, +-0.06068970635533333, +0.49931955337524414, +0.8190178871154785, +0.9834980964660645, +1.0643649101257324, +1.1033074855804443, +1.1218808889389038, +1.1306992769241333, +1.1348772048950195, +1.1368545293807983 +], +[ +1.118433952331543, +0.6868389248847961, +1.1105233430862427, +0.24214112758636475, +0.8486597537994385, +0.862530529499054, +-1.0054718255996704, +0.4266851544380188, +0.010710631497204304, +-0.4013756811618805, +0.8989120125770569, +0.16365931928157806, +-0.8281826376914978, +0.6130115389823914, +0.5367735028266907, +0.32928648591041565, +0.45283031463623047, +1.013983964920044, +-0.839590311050415, +-0.6629980802536011, +-0.15106093883514404, +-1.1385982036590576, +-0.6378665566444397, +0.09295102953910828, +0.5920644998550415, +0.867828905582428, +1.007738709449768, +1.0760924816131592, +1.1089129447937012, +1.1245449781417847, +1.1319620609283447, +1.1354750394821167 +] +], +"sin_half": [ +[ +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0, +0.0 +], +[ +0.9581236243247986, +0.7227196097373962, +0.5185818672180176, +0.36376047134399414, +0.2524888515472412, +0.1743917465209961, +0.12017118185758591, +0.08271769434213638, +0.05690775439143181, +0.03914155811071396, +0.02508366107940674, +0.015987584367394447, +0.010126213543117046, +0.006366542540490627, +0.003967567812651396, +0.002446084748953581, +0.0014879811787977815, +0.000889756076503545, +0.0005200692103244364, +0.0002945177548099309, +0.0001591283071320504, +0.00010942553490167484, +7.524713146267459e-05, +5.174414400244132e-05, +3.558217213139869e-05, +2.4468292394885793e-05, +1.6825770217110403e-05, +1.1570341484912205e-05, +7.95641517470358e-06, +5.471276836033212e-06, +3.762356300285319e-06, +2.587207291071536e-06 +], +[ +1.0353528261184692, +1.1169427633285522, +0.9233499765396118, +0.6893956661224365, +0.49240580201148987, +0.34466835856437683, +0.23900005221366882, +0.16499827802181244, +0.11367326974868774, +0.07823684811592102, +0.0501551479101181, +0.0319720134139061, +0.020251624286174774, +0.012732886709272861, +0.007935087196528912, +0.004892158322036266, +0.002975959563627839, +0.001779511570930481, +0.001040138304233551, +0.0005890355096198618, +0.0003182566142641008, +0.00021885106980334967, +0.00015049426292534918, +0.00010348828800488263, +7.116434426279739e-05, +4.893658478977159e-05, +3.3651540434220806e-05, +2.314068296982441e-05, +1.591283034940716e-05, +1.0942553672066424e-05, +7.524712600570638e-06, +5.174414582143072e-06 +], +[ +0.1905561238527298, +-0.6980829238891602, +-1.1369531154632568, +1.1361868381500244, +1.1372098922729492, +-0.2953476309776306, +0.4204932153224945, +-1.1287823915481567, +-0.009572848677635193, +0.9427499175071716, +1.1196554899215698, +0.8809261322021484, +0.6051015853881836, +0.39285045862197876, +0.24795448780059814, +0.15363341569900513, +0.09363697469234467, +0.05603199824690819, +0.03275984153151512, +0.018553797155618668, +0.01002495363354683, +0.0068937670439481735, +0.004740555305033922, +0.003259876277297735, +0.0022416755091398954, +0.0015415019588544965, +0.0010600233217701316, +0.000728931394405663, +0.0005012541660107672, +0.0003446904302109033, +0.00023702844919171184, +0.0001629940525162965 +], +[ +1.0475687980651855, +0.03152431175112724, +-1.040318489074707, +1.052832007408142, +1.1215016841888428, +-0.4602859914302826, +0.5298212766647339, +-1.1366549730300903, +-0.06646657735109329, +0.9202430844306946, +1.1239439249038696, +0.8909687399864197, +0.6136556267738342, +0.3988199234008789, +0.25182533264160156, +0.15605679154396057, +0.09511983394622803, +0.05692065507173538, +0.03327969089150429, +0.018848275765776634, +0.010184075683355331, +0.007003189995884895, +0.004815801978111267, +0.0033116203267127275, +0.0022772573865950108, +0.0015659701311960816, +0.001076849177479744, +0.0007405017386190593, +0.0005092105711810291, +0.00035016171750612557, +0.0002407908032182604, +0.0001655812666285783 +], +[ +-0.1805107742547989, +0.488112211227417, +0.4584871828556061, +-0.036689698696136475, +0.4042043685913086, +0.41734281182289124, +1.1317306756973267, +-0.9211119413375854, +0.9158143997192383, +-0.6895085573196411, +-0.6135640740394592, +1.10562264919281, +0.3558853268623352, +-0.6024614572525024, +-0.4710928201675415, +0.9206908941268921, +1.107962727546692, +0.8169467449188232, +0.5133458375930786, +0.2980722486972809, +0.1623917520046234, +0.11187097430229187, +0.07699426263570786, +0.0529668815433979, +0.03642992675304413, +0.025053510442376137, +0.017228931188583374, +0.011847815476357937, +0.008147300221025944, +0.005602564662694931, +0.0038526456337422132, +0.0026492977049201727 +], +[ +-1.1361485719680786, +1.0065739154815674, +1.0548573732376099, +-0.49947258830070496, +-1.132422924041748, +1.110325813293457, +-0.5915381908416748, +0.7341274619102478, +-0.5917240381240845, +0.6183589100837708, +0.8828473091125488, +0.9271259307861328, +-1.0909627676010132, +-0.8960019946098328, +1.1287333965301514, +0.6686090230941772, +-0.9139483571052551, +-0.06640370190143585, +1.0879101753234863, +0.9928507804870605, +0.6166380643844604, +0.43662029504776, +0.3043896555900574, +0.21067138016223907, +0.1453116238117218, +0.10006839036941528, +0.06885948032140732, +0.047366876155138016, +0.03257707878947258, +0.022403430193662643, +0.015406379476189613, +0.010594460181891918 +], +[ +-0.8687819838523865, +0.31992387771606445, +0.3354804813861847, +-0.6268709897994995, +-0.0167547594755888, +0.3290621042251587, +-0.9499190449714661, +-1.1054049730300903, +1.036005973815918, +-1.021796703338623, +-1.1198772192001343, +1.071107268333435, +-0.6331452131271362, +1.1042460203170776, +-0.30081096291542053, +-1.0816330909729004, +-1.0906189680099487, +0.13346514105796814, +-0.6425880193710327, +0.9719650149345398, +1.0368329286575317, +0.8065646290779114, +0.5866872668266296, +0.4141162037849426, +0.28828129172325134, +0.19938646256923676, +0.1374835968017578, +0.09466326981782913, +0.0651354193687439, +0.04480365663766861, +0.03081369958817959, +0.021190589293837547 +], +[ +-1.0887268781661987, +-0.44622525572776794, +-0.1969287246465683, +-0.29032671451568604, +-0.2688279449939728, +0.1581796556711197, +-0.878373384475708, +-1.0826542377471924, +1.058316707611084, +-1.0039165019989014, +-1.1241395473480225, +1.0655794143676758, +-0.6415387988090515, +1.1026756763458252, +-0.3046359717845917, +-1.0808666944503784, +-1.0910454988479614, +0.13434885442256927, +-0.6430173516273499, +0.9718115329742432, +1.0368987321853638, +0.8066418766975403, +0.5867518186569214, +0.4141644239425659, +0.2883157432079315, +0.1994105577468872, +0.13750030100345612, +0.09467480331659317, +0.06514336168766022, +0.04480912163853645, +0.030817458406090736, +0.02119317837059498 +], +[ +-0.30770131945610046, +-1.0096408128738403, +-0.6861124634742737, +0.07665055245161057, +-0.5075125694274902, +-0.01643751561641693, +-0.7970208525657654, +-1.0541850328445435, +1.0779833793640137, +-0.9848489165306091, +-1.1278560161590576, +1.059841513633728, +-0.6498815417289734, +1.1010710000991821, +-0.30845728516578674, +-1.0800951719284058, +-1.0914701223373413, +0.13523246347904205, +-0.6434465050697327, +0.9716580510139465, +1.0369644165039062, +0.8067191243171692, +0.5868162512779236, +0.4142126142978668, +0.2883501648902893, +0.19943465292453766, +0.13751700520515442, +0.09468633681535721, +0.06515131145715714, +0.044814590364694595, +0.030821220949292183, +0.02119576558470726 +], +[ +-1.0735756158828735, +-0.9368313550949097, +-1.1001790761947632, +-0.4293286204338074, +1.0666788816452026, +-1.1138523817062378, +-1.1034663915634155, +1.0832593441009521, +-1.111517310142517, +1.1372981071472168, +0.5911043882369995, +0.27637675404548645, +0.7097449898719788, +-0.45119622349739075, +-1.0459476709365845, +1.085886001586914, +-0.39066722989082336, +-0.2009357362985611, +-0.7076425552368164, +-0.041914887726306915, +1.1264318227767944, +1.0531786680221558, +0.8263110518455505, +0.6032980680465698, +0.4265671968460083, +0.29718443751335144, +0.20562073588371277, +0.1418071836233139, +0.09764833748340607, +0.06719198822975159, +0.04621913284063339, +0.03178746998310089 +], +[ +-0.6375624537467957, +0.8210626840591431, +0.387922078371048, +-0.5614608526229858, +0.5224559903144836, +-0.3132917284965515, +-1.1178419589996338, +-0.670559287071228, +0.7808455228805542, +-0.9473597407341003, +0.35753706097602844, +-0.7510520815849304, +1.0600316524505615, +-0.5498165488243103, +0.5870609283447266, +-0.6797841191291809, +0.6242254972457886, +0.2668207287788391, +1.0613338947296143, +-1.0128322839736938, +0.8568255305290222, +1.13862144947052, +1.0056941509246826, +0.7715888619422913, +0.5578393340110779, +0.3926573097705841, +0.27298811078071594, +0.18869392573833466, +0.13007332384586334, +0.08954882621765137, +0.061612337827682495, +0.04237901419401169 +], +[ +0.7153680324554443, +-1.0649391412734985, +-0.5669825673103333, +-0.7952796220779419, +-0.7463337182998657, +0.4622010588645935, +-0.5442230105400085, +0.6673889756202698, +-0.4822259247303009, +0.10996182262897491, +1.0104234218597412, +-0.5362231135368347, +-1.1099797487258911, +-0.8285199999809265, +-0.826681911945343, +0.6533316969871521, +0.7339057326316833, +0.39556437730789185, +-1.1087697744369507, +0.08377296477556229, +-0.32887381315231323, +0.8005877137184143, +1.1370108127593994, +1.0233068466186523, +0.7910036444664001, +0.5737669467926025, +0.40448033809661865, +0.2814062237739563, +0.1945771872997284, +0.13414978981018066, +0.09236208349466324, +0.06355016678571701 +], +[ +0.2135004848241806, +0.9081460237503052, +0.251426100730896, +-1.1125847101211548, +0.7591137290000916, +-0.7433156371116638, +0.5343251824378967, +1.0556594133377075, +-1.1385791301727295, +1.0655395984649658, +-0.6988806128501892, +1.126806378364563, +0.7814028859138489, +0.9595279097557068, +1.0041669607162476, +1.0899759531021118, +-1.0447112321853638, +0.5179899334907532, +0.769132673740387, +0.9256946444511414, +-1.1285643577575684, +-0.008436508476734161, +0.9431877732276917, +1.1348291635513306, +0.9725927114486694, +0.7371227741241455, +0.530037522315979, +0.3721587061882019, +0.2584358751773834, +0.178537517786026, +0.12304040789604187, +0.08469671756029129 +] +] +} \ No newline at end of file diff --git a/tests/vela/native/profile.rs b/tests/vela/native/profile.rs new file mode 100644 index 00000000..adec9990 --- /dev/null +++ b/tests/vela/native/profile.rs @@ -0,0 +1,122 @@ +use omni_modernbert::plan::{self, EncoderPlanOptions, PlanStep}; +use omni_vela_native::{profile, read_structure}; +use serde_json::json; +use std::fs; +use tempfile::tempdir; + +/// The pinned vllm-sr/Vela-2.0-0.3B@d6f03aa9 root config, structure-relevant +/// members only; recipe fields stay worker-side. +fn bundle_dir() -> std::path::PathBuf { + let config = json!({ + "model_type": "vela2-unified", "format_version": 1, + "encoder_config": { + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 768, "intermediate_size": 1152, + "num_attention_heads": 12, "num_hidden_layers": 22, + "vocab_size": 256008, "norm_eps": 1e-5, + "local_attention": 128, "global_attn_every_n_layers": 3, + "global_rope_theta": 160000, "local_rope_theta": 160000, + "max_position_embeddings": 32768, + "position_embedding_type": "sans_pos", + "rope_scaling": { + "rope_type": "yarn", "factor": 4.0, + "original_max_position_embeddings": 8192, + "beta_fast": 32.0, "beta_slow": 1.0, "truncate": true + } + }, + "max_length": 8192, "proj_dim": 256 + }); + let dir = tempdir().unwrap(); + fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); + let path = dir.path().to_path_buf(); + std::mem::forget(dir); + path +} + +#[test] +fn the_pinned_bundle_parses_into_the_registered_profile() { + let (structure, meta) = read_structure(&bundle_dir()).unwrap(); + let identity = profile().admit(&structure).unwrap(); + assert_eq!(identity.backend, "laya_cuda"); + assert_eq!(identity.abi_version, 2); + assert_eq!(meta.model_type, "modernbert"); + let registered = profile().structure; + assert_eq!(structure.hidden_size, registered.hidden_size); + assert_eq!(structure.schedule, registered.schedule); + assert_eq!(structure.rope_full, registered.rope_full); + assert_eq!(structure.type_embedding_rows, None); +} + +#[test] +fn the_bundle_rejects_foreign_roots_and_encoders() { + for (name, mutate) in [ + ("root class", "vela2-decoder"), + ("encoder class", "bert"), + ] { + let dir = tempdir().unwrap(); + let mut config = json!({ + "model_type": "vela2-unified", + "encoder_config": { + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 768, "intermediate_size": 1152, + "num_attention_heads": 12, "num_hidden_layers": 22, + "vocab_size": 256008, "norm_eps": 1e-5, "local_attention": 128, + "global_attn_every_n_layers": 3, + "global_rope_theta": 160000, "local_rope_theta": 160000, + "max_position_embeddings": 32768 + } + }); + match name { + "root class" => config["model_type"] = json!(mutate), + _ => config["encoder_config"]["model_type"] = json!(mutate), + } + fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); + assert!(read_structure(dir.path()).is_err(), "accepted {name}"); + } + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("config.json"), + json!({"model_type": "vela2-unified"}).to_string(), + ) + .unwrap(); + assert!(read_structure(dir.path()).is_err()); +} + +#[test] +fn profile_drift_is_rejected_with_field_diffs() { + let (mut structure, _) = read_structure(&bundle_dir()).unwrap(); + structure.vocab_size = 50368; + let message = profile().admit(&structure).unwrap_err().to_string(); + assert!(message.contains("vocab_size 50368"), "{message}"); + + let (mut structure, _) = read_structure(&bundle_dir()).unwrap(); + structure.schedule[3] = omni_modernbert::AttentionKind::Sliding; + assert!(profile().admit(&structure).is_err()); + + let (mut structure, _) = read_structure(&bundle_dir()).unwrap(); + structure.rope_full.scaling = None; + assert!(profile().admit(&structure).is_err()); +} + +#[test] +fn the_encoder_plan_runs_the_profile_schedule_without_a_type_add() { + let (structure, _) = read_structure(&bundle_dir()).unwrap(); + let steps = plan::encoder_plan(&structure, &EncoderPlanOptions::default()); + let launches = steps + .iter() + .filter(|s| matches!(s, PlanStep::Launch { .. })) + .count(); + assert_eq!(launches, 1 + 22 * 8); + assert!(!steps.iter().any(|s| s.render().contains("type_emb"))); + let limits = &profile().limits; + limits.check_request(1, 8192).unwrap(); + limits.check_request(4, 512).unwrap(); + for (batch, len) in [(8, 512), (1, 8208), (1, 8190)] { + assert!( + limits.check_request(batch, len).is_err(), + "accepted batch {batch} len {len}" + ); + } +} diff --git a/tests/vela/native/rope.rs b/tests/vela/native/rope.rs new file mode 100644 index 00000000..3f9b4e3e --- /dev/null +++ b/tests/vela/native/rope.rs @@ -0,0 +1,96 @@ +use omni_vela_native::{cos_sin_tables, yarn, profile}; +use serde_json::Value; + +/// Reference tables dumped from the pinned runtime itself: +/// transformers 5.19.0's ModernBertRotaryEmbedding over a +/// ModernBertConfig built from the pinned encoder_config, CPU float32. +fn reference_value() -> Value { + serde_json::from_str(include_str!("data/yarn-reference.json")).unwrap() +} + +fn rope() -> omni_modernbert::structure::RopeSpec { + profile().structure.rope_full +} + +#[test] +fn inverse_frequencies_match_the_reference_runtime() { + let tables = yarn(&rope(), 32).unwrap(); + let expected: Vec = reference_value()["inv_freq"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap()) + .collect(); + assert_eq!(tables.inv_freq.len(), expected.len()); + let mut worst = 0.0f64; + for (actual, wanted) in tables.inv_freq.iter().zip(&expected) { + let diff = (*actual as f64 - wanted).abs(); + worst = worst.max(diff); + } + assert!(worst < 1e-7, "max inverse-frequency delta {worst}"); +} + +#[test] +fn the_attention_scaling_is_the_reference_mscale() { + let tables = yarn(&rope(), 32).unwrap(); + let expected = reference_value()["attention_scaling"].as_f64().unwrap(); + assert!((tables.attention_scaling - expected).abs() < 1e-12); + assert_eq!(tables.attention_scaling, 1.0 + 0.1 * 4.0f64.ln()); +} + +#[test] +fn cos_sin_tables_reproduce_the_reference_rows() { + let structure = &profile().structure; + let (cos, sin, tables) = cos_sin_tables(structure, 32768).unwrap(); + let half = tables.inv_freq.len(); + assert_eq!(cos.len(), 32768 * half); + let reference = reference_value(); + let positions: Vec = reference_value()["positions"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as usize) + .collect(); + let mut worst_served = 0.0f64; + let mut worst_full = 0.0f64; + for (row, pos) in positions.iter().enumerate() { + for j in 0..half { + let cos_ref = reference["cos_half"][row][j].as_f64().unwrap(); + let sin_ref = reference["sin_half"][row][j].as_f64().unwrap(); + let cos_actual = cos[pos * half + j] as f64; + let sin_actual = sin[pos * half + j] as f64; + let delta = (cos_actual - cos_ref) + .abs() + .max((sin_actual - sin_ref).abs()); + if *pos <= 8192 { + worst_served = worst_served.max(delta); + } + worst_full = worst_full.max(delta); + } + } + // The reference computes f32 powers and f32 angle products; this + // generator runs f64 and narrows once. The residual is f32-ulp + // frequency noise amplified by the position, so the served window + // (at most 8 192 tokens) holds a tight gate and the extension + // range a documented looser one. + assert!(worst_served < 1e-4, "served-window delta {worst_served}"); + assert!(worst_full < 5e-4, "full-range delta {worst_full}"); +} + +#[test] +fn table_lengths_follow_the_structure_bounds() { + let structure = &profile().structure; + assert!(cos_sin_tables(structure, 0).is_err()); + assert!(cos_sin_tables(structure, 32769).is_err()); + let (cos, sin, _) = cos_sin_tables(structure, 8192).unwrap(); + assert_eq!(cos.len(), 8192 * 32); + assert_eq!(sin.len(), cos.len()); + // Inside the ramp band the frequency is a blend of extrapolation + // and interpolation, so it sits strictly between plain theta + // rope and the fully interpolated (factor-divided) variant. + let tables = yarn(&rope(), 32).unwrap(); + let plain = 160000.0f64.powf(-(15.0 / 32.0)); + let blended = tables.inv_freq[15] as f64; + assert!(blended < plain, "{blended} vs {plain}"); + assert!(blended > plain / 4.0, "{blended} vs {}", plain / 4.0); +} diff --git a/tests/vela/native/weights.rs b/tests/vela/native/weights.rs new file mode 100644 index 00000000..793cf595 --- /dev/null +++ b/tests/vela/native/weights.rs @@ -0,0 +1,160 @@ +use omni_vela_native::{Weights, head_tensors, read_structure, storage_dtype}; +use serde_json::{Value, json}; +use std::fs; +use tempfile::{TempDir, tempdir}; + +/// The recorded census of the pinned checkpoint's safetensors header +/// (vllm-sr/Vela-2.0-0.3B@d6f03aa9, model.safetensors): every tensor +/// name with dtype and shape, fetched from the first bytes of the file. +fn census() -> Value { + serde_json::from_str(include_str!("data/pinned-checkpoint-census.json")).unwrap() +} + +/// Builds a synthetic checkpoint holding exactly the given tensors. +fn checkpoint(tensors: &Value) -> (TempDir, std::path::PathBuf) { + let dir = tempdir().unwrap(); + let path = dir.path().join("model.safetensors"); + let mut entries: Vec<(String, Vec, &str, Vec)> = Vec::new(); + for (name, spec) in tensors.as_object().unwrap() { + let shape: Vec = spec["shape"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as usize) + .collect(); + let dtype = spec["dtype"].as_str().unwrap(); + let width = if dtype == "F32" { 4 } else { 2 }; + let bytes: Vec = vec![0; shape.iter().product::() * width]; + entries.push((name.clone(), shape, dtype, bytes)); + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut offsets = 0usize; + let mut header = serde_json::Map::new(); + let mut blob: Vec = Vec::new(); + for (name, shape, dtype, bytes) in &entries { + let end = offsets + bytes.len(); + header.insert( + name.clone(), + json!({"dtype": dtype, "shape": shape, "data_offsets": [offsets, end]}), + ); + offsets = end; + blob.extend_from_slice(bytes); + } + let mut header_text = Value::Object(header).to_string(); + header_text.extend(std::iter::repeat_n(' ', (8 - header_text.len() % 8) % 8)); + let mut file = (header_text.len() as u64).to_le_bytes().to_vec(); + file.extend_from_slice(header_text.as_bytes()); + file.extend_from_slice(&blob); + fs::write(&path, file).unwrap(); + (dir, path) +} + +fn structure() -> omni_modernbert::structure::ModernBertStructure { + let (structure, _) = read_structure(&profile_fixture_dir()).unwrap(); + structure +} + +fn profile_fixture_dir() -> std::path::PathBuf { + let config = json!({ + "model_type": "vela2-unified", + "encoder_config": { + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 768, "intermediate_size": 1152, + "num_attention_heads": 12, "num_hidden_layers": 22, + "vocab_size": 256008, "norm_eps": 1e-5, "local_attention": 128, + "global_attn_every_n_layers": 3, + "global_rope_theta": 160000, "local_rope_theta": 160000, + "max_position_embeddings": 32768, + "rope_scaling": { + "rope_type": "yarn", "factor": 4.0, + "original_max_position_embeddings": 8192, "truncate": true + } + } + }); + let dir = tempdir().unwrap(); + fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); + let path = dir.path().to_path_buf(); + std::mem::forget(dir); + path +} + +#[test] +fn the_geometry_inventory_matches_the_pinned_census_exactly() { + let expected = omni_modernbert::geometry::encoder_tensors(&structure()); + let heads = head_tensors(); + assert_eq!(expected.len(), 134); + assert_eq!(heads.len(), 18); + for (name, shape) in expected.iter().chain(heads.iter()) { + let entry = &census()[name]; + assert!(entry.is_object(), "{name} missing from the census"); + let census_shape: Vec = entry["shape"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as usize) + .collect(); + assert_eq!(&census_shape, shape, "{name}"); + assert_eq!(entry["dtype"], "F32", "{name}"); + } + // And the census holds nothing beyond these two inventories. + assert_eq!(census().as_object().unwrap().len(), 134 + 18); +} + +#[test] +fn a_checkpoint_matching_the_profile_validates_completely() { + let (_dir, path) = checkpoint(&census()); + let weights = Weights::open(&path).unwrap(); + assert_eq!(weights.validate(&structure()).unwrap(), 152); + // Storage classes mirror the serving conventions. + assert_eq!(storage_dtype("encoder.embeddings.tok_embeddings.weight"), "f16"); + assert_eq!(storage_dtype("encoder.layers.0.attn_norm.weight"), "f32"); + assert_eq!(storage_dtype("encoder.layers.0.attn.Wqkv.weight"), "bf16"); + assert_eq!(storage_dtype("norm.weight"), "f32"); + let scalar = weights.f32("log_tau", &[]).unwrap(); + assert_eq!(scalar.len(), 1); +} + +#[test] +fn inventory_drift_fails_naming_both_sides() { + for (name, mutate) in [ + ("missing", { + let mut c = census(); + c.as_object_mut() + .unwrap() + .remove("encoder.layers.5.attn.Wqkv.weight"); + c + }), + ("extra", { + let mut c = census(); + c.as_object_mut().unwrap().insert( + "encoder.layers.22.attn.Wqkv.weight".into(), + json!({"dtype": "F32", "shape": [2304, 768]}), + ); + c + }), + ("shape", { + let mut c = census(); + c["encoder.layers.0.mlp.Wi.weight"]["shape"] = json!([2303, 768]); + c + }), + ("dtype", { + let mut c = census(); + c["encoder.layers.0.attn.Wo.weight"]["dtype"] = json!("F16"); + c + }), + ] { + let (_dir, path) = checkpoint(&mutate); + let weights = Weights::open(&path).unwrap(); + let result = weights.validate(&structure()); + assert!(result.is_err(), "accepted {name}"); + let message = result.unwrap_err().to_string(); + match name { + "missing" => assert!(message.contains("missing"), "{message}"), + "extra" => assert!(message.contains("unexpected"), "{message}"), + "shape" => assert!(message.contains("expected"), "{message}"), + "dtype" => assert!(message.contains("f32"), "{message}"), + _ => {} + } + } +} From d27fa128af87e9ea48a084b84311b4d5b8628d5b Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 15:26:03 +0800 Subject: [PATCH 07/21] cuda: add the 768-hidden ModernBERT profile to the laya bundle, ABI 2 The regenerated bundle serves two profiles of the encoder family in one library: the existing 1024-hidden Laya kernels under their unchanged names, and the Vela 2.0 0.3B geometry under _h768-suffixed symbols (qkv 2304x768, out 768x768, geglu 1152, down 768x1152, addln, full and sliding attention at 12 heads, rope at 12 heads). The 768 entries carry their own wrapper length bound of 8192 tokens; the Laya symbols keep their 512 bound exactly, so the existing profile's acceptance surface does not move. - model_ops.cu: the embedding LayerNorm becomes a width template with 1024 and 768 instantiations; the 1024 lowering keeps the pinned PyTorch-matching reduction order. laya_embed_h768 joins laya_embed - export.py: per-entry wrapper guards and the vela instantiation set; the GEGLU register patch stays sha-pinned for the Laya shape and removes the calls only when the 768 lowering emits them in the same accepted shape - abi_version 2 across laya.backend.json, build.py and export_tables (new symbols and a regenerated library); the Rust-side bundle validation and artifacts fixtures follow - omni-cuda: the Rust launch envelope widens to 8192 tokens; the per-symbol C guards remain the real capability check Verified locally without a Hopper device (this machine is sm_120 and cannot execute sm_90a code): TileLang lowering of all 22 kernels with the strict lowering checks passing, nvcc sm_90a compilation and linking of the full library, both embed_norm instantiations present in SASS, every _h768 symbol exported, the contract checker clean, and the CPU test suites green. Execution on Hopper and the Laya bit-identity regression run when the bundle is rebuilt there; the export must run with TILELANG_DEFAULT_TARGET set to cuda sm_90a on any non-Hopper host until the in-flight target pinning lands. Signed-off-by: Mr.Dong <1042542469@qq.com> --- src/backends/cuda/kernels/model_ops.cu | 28 +++++++++------ src/backends/cuda/laya.backend.json | 2 +- src/backends/cuda/src/lib.rs | 4 +-- src/backends/cuda/tools/build.py | 2 +- src/backends/cuda/tools/export.py | 45 +++++++++++++++++------- src/backends/cuda/tools/export_tables.py | 2 +- src/models/laya/src/artifacts.rs | 4 +-- tests/laya/artifacts.rs | 4 +-- 8 files changed, 60 insertions(+), 31 deletions(-) diff --git a/src/backends/cuda/kernels/model_ops.cu b/src/backends/cuda/kernels/model_ops.cu index fad23b1d..e73bf3e4 100644 --- a/src/backends/cuda/kernels/model_ops.cu +++ b/src/backends/cuda/kernels/model_ops.cu @@ -22,11 +22,11 @@ __device__ Stats combine(Stats b, Stats a) { return {0, 0, 0}; } -template +template __device__ Stats stats(Load load, float* buf) { int lane = threadIdx.x, warp = threadIdx.y, t = lane + warp * 32; Stats wd{0, 0, 0}; - for (int i = t; i < 256; i += 128) { + for (int i = t; i < D / 4; i += 128) { #pragma unroll for (int j = 0; j < 4; j++) { float v = load(4 * i + j), delta = v - wd.mean, count = wd.count + 1.f, @@ -56,7 +56,7 @@ __device__ Stats stats(Load load, float* buf) { } if (lane == 0 && warp == 0) { buf[0] = wd.mean; - buf[1] = wd.var / 1024.f; + buf[1] = wd.var / float(D); } __syncthreads(); return {buf[0], buf[1], 0}; @@ -76,20 +76,21 @@ struct FloatLoad { } }; +template __global__ void embed_norm(const int64_t* ids, const half* w, const float* gamma, float* x, BF* y) { int r = blockIdx.x, t = threadIdx.x + threadIdx.y * 32; __shared__ float buf[6]; - HalfLoad load{w + ids[r] * 1024}; - Stats wd = stats(load, buf); + HalfLoad load{w + ids[r] * D}; + Stats wd = stats(load, buf); float inv = rsqrtf(wd.var + 1e-5f); - for (int i = t; i < 256; i += 128) { + for (int i = t; i < D / 4; i += 128) { #pragma unroll for (int k = 0; k < 4; k++) { int j = 4 * i + k; float z = gamma[j] * (inv * (load(j) - wd.mean)); - x[r * 1024 + j] = z; - y[r * 1024 + j] = __float2bfloat16_rn(z); + x[r * D + j] = z; + y[r * D + j] = __float2bfloat16_rn(z); } } } @@ -113,7 +114,7 @@ __global__ void gather_norm(const float* h, const int32_t* indices, int r = blockIdx.x, t = threadIdx.x + threadIdx.y * 32; __shared__ float buf[6]; FloatLoad load{h + indices[r] * 1024}; - Stats wd = stats(load, buf); + Stats wd = stats(load, buf); float inv = rsqrtf(wd.var + 1e-5f); for (int i = t; i < 256; i += 128) { #pragma unroll @@ -178,7 +179,14 @@ __global__ void action_features(const float* h, const BF* logits, extern "C" { int laya_embed(void** p, int B, int L, int M, cudaStream_t s) { - embed_norm<<>>( + embed_norm<1024><<>>( + (int64_t*)p[0], (half*)p[1], (float*)p[2], (float*)p[3], (BF*)p[4]); + return cudaGetLastError(); +} + +// The 768-hidden ModernBERT profile (Vela 2.0 0.3B) on the same glue op. +int laya_embed_h768(void** p, int B, int L, int M, cudaStream_t s) { + embed_norm<768><<>>( (int64_t*)p[0], (half*)p[1], (float*)p[2], (float*)p[3], (BF*)p[4]); return cudaGetLastError(); } diff --git a/src/backends/cuda/laya.backend.json b/src/backends/cuda/laya.backend.json index 3d0452c2..290b83a3 100644 --- a/src/backends/cuda/laya.backend.json +++ b/src/backends/cuda/laya.backend.json @@ -1,6 +1,6 @@ { "name": "laya", - "abi_version": 1, + "abi_version": 2, "status": "experimental", "sources": [ "kernels/runtime.cu", diff --git a/src/backends/cuda/src/lib.rs b/src/backends/cuda/src/lib.rs index a72f008e..828d05de 100644 --- a/src/backends/cuda/src/lib.rs +++ b/src/backends/cuda/src/lib.rs @@ -156,7 +156,7 @@ impl Cuda { /// Buffers must belong to this context and stay alive until synchronization or graph destruction. pub unsafe fn launch(&self, name: &str, args: &[Ptr], b: usize, l: usize) -> Result<()> { ensure!( - b > 0 && b <= 16 && l > 0 && l <= 512 && l.is_multiple_of(16), + b > 0 && b <= 16 && l > 0 && l <= 8192 && l.is_multiple_of(16), "invalid CUDA shape" ); let k = self @@ -354,7 +354,7 @@ impl Kernel { /// or destruction of any graph that captures the launch. pub unsafe fn launch(&self, args: &[Ptr], b: usize, l: usize) -> Result<()> { ensure!( - b > 0 && b <= 16 && l > 0 && l <= 512 && l.is_multiple_of(16), + b > 0 && b <= 16 && l > 0 && l <= 8192 && l.is_multiple_of(16), "invalid CUDA shape" ); self.ctx.check(unsafe { diff --git a/src/backends/cuda/tools/build.py b/src/backends/cuda/tools/build.py index 10424d27..4dbbf23f 100644 --- a/src/backends/cuda/tools/build.py +++ b/src/backends/cuda/tools/build.py @@ -59,7 +59,7 @@ def main(): (args.bundle / "build-command.json").write_text(json.dumps(commands, indent=2)) build_manifest = { - "abi": 1, + "abi": 2, "arch": "sm_90a", "nvcc": subprocess.check_output([nvcc, "--version"], text=True), "sources": sources, diff --git a/src/backends/cuda/tools/export.py b/src/backends/cuda/tools/export.py index b1c0460d..19be95f2 100644 --- a/src/backends/cuda/tools/export.py +++ b/src/backends/cuda/tools/export.py @@ -62,22 +62,27 @@ def integer(value): return int(value) -def patch_geglu_static_registers(body): +def patch_geglu_static_registers(body, pinned=True): """Remove fixed register reservations only from the accepted BN32 lowering.""" - expected = "1ed96990542c07b58dca419dafcc45c6163bb9b6fff04ffca5ecedb2a34e921c" - if hashlib.sha256(body.encode()).hexdigest() != expected: - raise ValueError("long GEGLU BN32 lowering changed") + if pinned: + expected = "1ed96990542c07b58dca419dafcc45c6163bb9b6fff04ffca5ecedb2a34e921c" + if hashlib.sha256(body.encode()).hexdigest() != expected: + raise ValueError("long GEGLU BN32 lowering changed") for call in ( " tl::warpgroup_reg_dealloc<24>();\n", " tl::warpgroup_reg_alloc<240>();\n", ): - if body.count(call) != 1: + count = body.count(call) + if pinned and count != 1: raise ValueError("long GEGLU register calls changed") - body = body.replace(call, "", 1) + if count == 1: + body = body.replace(call, "", 1) + elif count != 0: + raise ValueError("unexpected GEGLU register call count") return body -def export(name, kernel): +def export(name, kernel, l_max=512): source = kernel.get_kernel_source() signature = re.search(r"void main_kernel\((.*?)\);", source, re.S)[1] params = [param.strip() for param in signature.split(",")] @@ -160,8 +165,8 @@ def export(name, kernel): body = source[source.index('extern "C" __global__') :].replace( "main_kernel", symbol ) - if name == "geglu": - body = patch_geglu_static_registers(body) + if name.startswith("geglu"): + body = patch_geglu_static_registers(body, pinned=name == "geglu") # TileLang reuses Q_s for O_s. Its generated wait is inside the key loop, # so zero-key rows can overwrite Q_s while the asynchronous Q load is live. # Wait before entering producer/consumer branches, including zero iterations. @@ -197,7 +202,7 @@ def export(name, kernel): dispatch = " if(B==1 && M<=64)return laya_geglu_short(p,B,L,M,stream);\n" wrapper_name = {"qkv": "qkv_full", "out": "out_full"}.get(name, name) wrapper = f"""extern "C" int laya_{wrapper_name}(void** p,int B,int L,int M,cudaStream_t stream) {{ - if(B<1 || B>16 || L<16 || L>512 || L%16 || M!=B*L)return -1; + if(B<1 || B>16 || L<16 || L>{l_max} || L%16 || M!=B*L)return -1; {dispatch}{chr(10).join(bindings)} {chr(10).join(descriptors)} {symbol}<<>>({','.join(args)}); @@ -243,6 +248,19 @@ def main(): "qkv_short": kernels.gemm_kernel(3072, 1024, bn=64), "attn_full": kernels.attn_kernel(None, None, 16, 64), } + # The second ModernBERT profile (Vela 2.0 0.3B, hidden 768, 12 heads, + # 1152 intermediate, requests packed to 8192 tokens). Its symbols carry + # an _h768 suffix so both profiles coexist in one library. + vela_exports = { + "qkv_h768": kernels.gemm_kernel(2304, 768), + "out_h768": kernels.gemm_kernel(768, 768), + "geglu_h768": kernels.gemm_geglu_kernel(1152, 768, bn=32), + "down_h768": kernels.gemm_kernel(768, 1152, bm=64, bn=64), + "addln_h768": kernels.add_ln_kernel(768), + "attn_full_h768": kernels.attn_kernel(None, None, 12, 64), + "attn_local_h768": kernels.attn_kernel(None, None, 12, 64, window=64), + "rope_h768": rope.build(12, 64, 1, 8), + } if not args.probe_only: exports.update( { @@ -271,8 +289,11 @@ def main(): bodies = [] inits = [] metadata = [] - for name, kernel in exports.items(): - preamble, body, init, details = export(name, kernel) + entries = [(name, kernel, 512) for name, kernel in exports.items()] + if not args.probe_only: + entries += [(name, kernel, 8192) for name, kernel in vela_exports.items()] + for name, kernel, l_max in entries: + preamble, body, init, details = export(name, kernel, l_max) preambles.append(preamble) bodies.append(body) inits.append(init) diff --git a/src/backends/cuda/tools/export_tables.py b/src/backends/cuda/tools/export_tables.py index 95bbb1b9..1fe85ec6 100644 --- a/src/backends/cuda/tools/export_tables.py +++ b/src/backends/cuda/tools/export_tables.py @@ -47,7 +47,7 @@ def main(): files[name] = hashlib.sha256(data).hexdigest() metadata = { - "abi": 1, + "abi": 2, "laya": "0.3.20", "hidden_size": 1024, "head_dim": 64, diff --git a/src/models/laya/src/artifacts.rs b/src/models/laya/src/artifacts.rs index 70185dc6..ba3d101f 100644 --- a/src/models/laya/src/artifacts.rs +++ b/src/models/laya/src/artifacts.rs @@ -32,12 +32,12 @@ pub fn validate_bundle(checkpoint: &Path, bundle: &Path) -> Result<()> { let build: serde_json::Value = serde_json::from_slice(&fs::read(bundle.join("build-manifest.json"))?)?; ensure!( - tables["abi"] == 1 + tables["abi"] == 2 && tables["laya"] == "0.3.20" && tables["hidden_size"] == 1024 && tables["head_dim"] == 64 && tables["max_len"] == 512 - && build["abi"] == 1 + && build["abi"] == 2 && build["arch"] == "sm_90a", "unsupported CUDA bundle" ); diff --git a/tests/laya/artifacts.rs b/tests/laya/artifacts.rs index 16e261d9..78a2cbbe 100644 --- a/tests/laya/artifacts.rs +++ b/tests/laya/artifacts.rs @@ -49,7 +49,7 @@ impl Fixture { ); } let table_manifest = json!({ - "abi": 1, + "abi": 2, "laya": "0.3.20", "hidden_size": 1024, "head_dim": 64, @@ -64,7 +64,7 @@ impl Fixture { .unwrap(); fs::write(bundle.join("liblaya_cuda.so"), b"not loaded in CPU test").unwrap(); let build_manifest = json!({ - "abi": 1, + "abi": 2, "arch": "sm_90a", "library_sha256": format!("{:x}", Sha256::digest(b"not loaded in CPU test")), }); From d57cbe5bae85ea3251de6344cc65becf6362637e Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 15:26:15 +0800 Subject: [PATCH 08/21] vela: execute the 0.3B encoder on the regenerated bundle The executor (cuda feature) completes the profile slice: it admits the structure, loads the pinned checkpoint with the whole-file inventory check, uploads every encoder tensor under the serving storage classes (embedding f16, norms f32, projections bf16), uploads the zero-bias buffers and the YaRN cos/sin tables the rope generator produces, and resolves the family core plan against the bundle's _h768 symbols with the per-request encoder scratch from the family geometry. One sequence per call; the final hidden of the last layer is read from the post-final-norm working buffer in the bundle's bf16 precision, gathered at the caller's requested positions. A device-gated test runs the encoder end to end only when VELA_NATIVE_CHECKPOINT and VELA_NATIVE_LIBRARY point at a bundle and checkpoint on hardware the sm_90a library can execute on; it skips with a note everywhere else. Parity against the pinned CPU-FP32 reference, with the tolerance anchored to the upstream noise floor, runs with that same harness on a Hopper host. Signed-off-by: Mr.Dong <1042542469@qq.com> --- Cargo.lock | 4 +- src/models/vela/native/Cargo.toml | 12 +- src/models/vela/native/src/executor.rs | 233 +++++++++++++++++++++++++ src/models/vela/native/src/lib.rs | 3 + tests/vela/native/executor.rs | 33 ++++ 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/models/vela/native/src/executor.rs create mode 100644 tests/vela/native/executor.rs diff --git a/Cargo.lock b/Cargo.lock index 44781801..ac2b4a99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1308,9 +1308,11 @@ name = "omni-vela-native" version = "0.1.0" dependencies = [ "anyhow", + "half", "memmap2", + "omni-cuda", "omni-modernbert", - "safetensors 0.6.2", + "safetensors 0.8.0", "serde_json", "tempfile", ] diff --git a/src/models/vela/native/Cargo.toml b/src/models/vela/native/Cargo.toml index 703c0132..6f0b03ca 100644 --- a/src/models/vela/native/Cargo.toml +++ b/src/models/vela/native/Cargo.toml @@ -7,11 +7,16 @@ description = "Vela 2.0 0.3B ModernBERT profile on the family core" [dependencies] anyhow = "1" +half = { version = "2", optional = true } memmap2 = "0.9" +omni-cuda = { path = "../../../backends/cuda", optional = true } omni-modernbert = { path = "../../modernbert" } -safetensors = "0.6" +safetensors = "0.8.0" serde_json = "1" +[features] +cuda = ["dep:omni-cuda", "dep:half"] + [dev-dependencies] tempfile = "3" @@ -26,3 +31,8 @@ path = "../../../../tests/vela/native/weights.rs" [[test]] name = "vela_rope" path = "../../../../tests/vela/native/rope.rs" + +[[test]] +name = "vela_executor" +path = "../../../../tests/vela/native/executor.rs" +required-features = ["cuda"] diff --git a/src/models/vela/native/src/executor.rs b/src/models/vela/native/src/executor.rs new file mode 100644 index 00000000..395b8aaf --- /dev/null +++ b/src/models/vela/native/src/executor.rs @@ -0,0 +1,233 @@ +//! Encoder execution for the admitted 0.3B profile against the +//! regenerated CUDA bundle. Weight upload, the YaRN rotary tables and +//! the workspace follow the family conventions; the executed step +//! sequence is the family core plan with this profile's `_h768` +//! kernel symbols. The final hidden of the last layer lives in the +//! bundle's bf16 working precision after the final norm; readouts +//! gather requested positions from it. + +use anyhow::{Context, Result, ensure}; +use half::bf16; +use omni_cuda::{Buffer, Cuda, Kernel, Ptr}; +use omni_modernbert::geometry::EncoderScratch; +use omni_modernbert::plan::{self, EncoderPlanOptions, PlanArg, PlanStep, ScratchSlot}; +use omni_modernbert::structure::ModernBertStructure; +use std::{collections::HashMap, path::Path}; + +use crate::config::read_structure; +use crate::profile; +use crate::rope::cos_sin_tables; +use crate::weights::Weights; + +/// Plan kernel name → bundle symbol for the 768-hidden profile. +fn symbol(kernel: &str) -> &str { + match kernel { + "embed" => "embed_h768", + "qkv" => "qkv_h768", + "rope" => "rope_h768", + "attn_full" => "attn_full_h768", + "attn_local" => "attn_local_h768", + "out" => "out_h768", + "addln" => "addln_h768", + "geglu" => "geglu_h768", + "down" => "down_h768", + other => other, + } +} + +#[derive(Clone, Copy)] +enum Arg { + Weight(Ptr), + Scratch(ScratchSlot), +} + +struct Step { + kernel: Kernel, + args: [Arg; 5], + count: usize, +} + +struct Scratch { + ids: Buffer, + lens: Buffer, + x: Buffer, + y: Buffer, + qkv: Buffer, + o: Buffer, + gate: Buffer, +} + +impl Scratch { + fn new(cuda: &Cuda, structure: &ModernBertStructure, batch: usize, len: usize) -> Result { + let sizes = EncoderScratch::new(batch, len, structure); + let mut values = sizes.sizes().into_iter(); + let mut alloc = || { + cuda.alloc(values.next().expect("scratch layout")) + .context("allocate encoder scratch") + }; + Ok(Self { + ids: alloc()?, + lens: alloc()?, + x: alloc()?, + y: alloc()?, + qkv: alloc()?, + o: alloc()?, + gate: alloc()?, + }) + } + + fn buffer(&self, slot: ScratchSlot) -> &Buffer { + match slot { + ScratchSlot::Ids => &self.ids, + ScratchSlot::Lens => &self.lens, + ScratchSlot::Types => &self.ids, + ScratchSlot::X => &self.x, + ScratchSlot::Y => &self.y, + ScratchSlot::Qkv => &self.qkv, + ScratchSlot::O => &self.o, + ScratchSlot::Gate => &self.gate, + } + } +} + +pub struct Encoder { + pub structure: ModernBertStructure, + cuda: Cuda, + /// Owns every uploaded tensor; the plan holds raw pointers into it. + _device_tensors: HashMap, + plan: Vec, +} + +fn bytes16(values: &[u16]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +fn bytes32(values: &[f32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +impl Encoder { + /// Load the pinned bundle (config.json + model.safetensors) and the + /// regenerated CUDA library, admitting the structure first. + /// + /// # Safety + /// The library must be a trusted build artifact supplied by the + /// operator; it is loaded into the process. + pub unsafe fn load(checkpoint: &Path, library: &Path) -> Result { + let (structure, _meta) = read_structure(checkpoint)?; + profile().admit(&structure)?; + let limits = &profile().limits; + let cuda = unsafe { Cuda::load(library)? }; + let source = Weights::open(&checkpoint.join("model.safetensors"))?; + source.validate(&structure)?; + let mut weights = HashMap::new(); + for (name, shape) in omni_modernbert::geometry::encoder_tensors(&structure) { + let values = source.f32(&name, &shape)?; + let data = match crate::weights::storage_dtype(&name) { + "f32" => bytes32(&values), + "f16" => bytes16( + &values + .iter() + .map(|v| half::f16::from_f32(*v).to_bits()) + .collect::>(), + ), + _ => bytes16( + &values + .iter() + .map(|v| bf16::from_f32(*v).to_bits()) + .collect::>(), + ), + }; + let buffer = cuda + .upload(&data) + .with_context(|| format!("upload {name}"))?; + weights.insert(name, buffer); + } + for width in [structure.hidden_size, 3 * structure.hidden_size] { + let name = format!("zeros.{width}"); + weights.insert(name.clone(), cuda.upload(&vec![0; width * 4])?); + } + // Both attention kinds share one theta and one yarn scaling, so + // one table set serves the plan's per-kind names. + let len = limits.len_max.min(structure.max_positions); + let (cos, sin, _tables) = cos_sin_tables(&structure, len)?; + for kind in ["full", "local"] { + weights.insert(format!("rope_{kind}_cos"), cuda.upload(&bytes32(&cos))?); + weights.insert(format!("rope_{kind}_sin"), cuda.upload(&bytes32(&sin))?); + } + let mut plan = Vec::new(); + for step in plan::encoder_plan(&structure, &EncoderPlanOptions::default()) { + let PlanStep::Launch { kernel, args } = step else { + continue; + }; + ensure!(args.len() <= 5, "encoder argument count"); + let mut resolved = [Arg::Weight(std::ptr::null_mut()); 5]; + for (slot, arg) in resolved.iter_mut().zip(&args) { + *slot = match arg { + PlanArg::Weight(name) => { + Arg::Weight(weights[name].ptr()) + } + PlanArg::Scratch(s) => Arg::Scratch(*s), + }; + } + plan.push(Step { + kernel: cuda.resolve(symbol(&kernel))?, + args: resolved, + count: args.len(), + }); + } + Ok(Self { + structure, + cuda, + _device_tensors: weights, + plan, + }) + } + + /// Run one sequence and return the final hidden at the requested + /// positions, decoded from the bundle's bf16 working precision. + pub fn encode(&self, ids: &[i64], positions: &[usize]) -> Result>> { + let limits = &profile().limits; + ensure!(!ids.is_empty(), "empty sequence"); + let len = ids.len().next_multiple_of(limits.len_step); + limits.check_request(1, len)?; + ensure!( + positions.iter().all(|&p| p < ids.len()), + "position outside the sequence" + ); + let scratch = Scratch::new(&self.cuda, &self.structure, 1, len)?; + let mut staging = vec![0i64; len]; + staging[..ids.len()].copy_from_slice(ids); + scratch.ids.write( + &staging.iter().flat_map(|v| v.to_le_bytes()).collect::>(), + )?; + scratch + .lens + .write(&vec![ids.len() as i32; 1][..].iter().flat_map(|v| v.to_le_bytes()).collect::>())?; + for step in &self.plan { + let pointers: Vec = step.args[..step.count] + .iter() + .map(|arg| match arg { + Arg::Weight(p) => *p, + Arg::Scratch(slot) => scratch.buffer(*slot).ptr(), + }) + .collect(); + // SAFETY: buffers are sized by the family geometry for this + // admitted structure and stay alive through the sync below. + unsafe { step.kernel.launch(&pointers, 1, len)? }; + } + self.cuda.sync()?; + let hidden = scratch.y.read(len * self.structure.hidden_size * 2)?; + let decoded: Vec = hidden + .as_chunks::<2>() + .0 + .iter() + .map(|b| bf16::from_bits(u16::from_le_bytes([b[0], b[1]])).to_f32()) + .collect(); + let width = self.structure.hidden_size; + Ok(positions + .iter() + .map(|&p| decoded[p * width..(p + 1) * width].to_vec()) + .collect()) + } +} diff --git a/src/models/vela/native/src/lib.rs b/src/models/vela/native/src/lib.rs index 3298db86..febd30f1 100644 --- a/src/models/vela/native/src/lib.rs +++ b/src/models/vela/native/src/lib.rs @@ -9,6 +9,9 @@ pub mod profile; pub mod rope; pub mod weights; +#[cfg(feature = "cuda")] +pub mod executor; + pub use config::read_structure; pub use profile::profile; pub use rope::{YarnTables, cos_sin_tables, yarn}; diff --git a/tests/vela/native/executor.rs b/tests/vela/native/executor.rs new file mode 100644 index 00000000..82237c11 --- /dev/null +++ b/tests/vela/native/executor.rs @@ -0,0 +1,33 @@ +//! Device-gated encoder execution: runs only when a checkpoint bundle +//! and the regenerated CUDA library are provided, on the hardware the +//! bundle was built for. +//! +//! VELA_NATIVE_CHECKPOINT= +//! VELA_NATIVE_LIBRARY= + +use omni_vela_native::executor::Encoder; +use std::path::PathBuf; + +fn gated(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from) +} + +#[test] +fn encoder_runs_and_produces_finite_hidden() { + let (Some(checkpoint), Some(library)) = ( + gated("VELA_NATIVE_CHECKPOINT"), + gated("VELA_NATIVE_LIBRARY"), + ) else { + eprintln!("skipping: set VELA_NATIVE_CHECKPOINT and VELA_NATIVE_LIBRARY"); + return; + }; + let encoder = unsafe { Encoder::load(&checkpoint, &library) }.unwrap(); + assert_eq!(encoder.structure.hidden_size, 768); + let ids: Vec = (1..=97).collect(); + let hidden = encoder.encode(&ids, &[0, 47, 96]).unwrap(); + assert_eq!(hidden.len(), 3); + for row in &hidden { + assert_eq!(row.len(), 768); + assert!(row.iter().all(|v| v.is_finite()), "non-finite hidden"); + } +} From b08c13674e1429dd06fa5bfb73e4fbe98a9b0155 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 20:14:26 +0800 Subject: [PATCH 09/21] specs: drop the local model-config planning series The durable record is the code, its tests and the commit messages; the local planning docs stay out of the repository. Signed-off-by: Mr.Dong <1042542469@qq.com> --- specs/model-config/01-modernbert-family-ir.md | 182 ----------------- .../02-laya-encoder-core-extraction.md | 117 ----------- .../03-vela-03b-modernbert-profile.md | 190 ------------------ .../04-vela-task-heads-boundary.md | 93 --------- .../05-qwen-hybrid-config-capability-gate.md | 130 ------------ specs/model-config/README.md | 153 -------------- 6 files changed, 865 deletions(-) delete mode 100644 specs/model-config/01-modernbert-family-ir.md delete mode 100644 specs/model-config/02-laya-encoder-core-extraction.md delete mode 100644 specs/model-config/03-vela-03b-modernbert-profile.md delete mode 100644 specs/model-config/04-vela-task-heads-boundary.md delete mode 100644 specs/model-config/05-qwen-hybrid-config-capability-gate.md delete mode 100644 specs/model-config/README.md diff --git a/specs/model-config/01-modernbert-family-ir.md b/specs/model-config/01-modernbert-family-ir.md deleted file mode 100644 index 71ca3200..00000000 --- a/specs/model-config/01-modernbert-family-ir.md +++ /dev/null @@ -1,182 +0,0 @@ -# Spec 01 — ModernBERT family IR, source adapters, capability gate - -Status: draft, local-only (branch `model/configurable-base-model`). -Implements the first half of RFC Phase 2: the typed family layer that -today is scattered across `laya/src/config.rs` ensure-blocks, -`model.rs` constants, and `weights.rs` inventory. - -## Problem - -`src/models/laya/src/config.rs:31-90` reads raw JSON, normalizes the -family, and enforces the one supported profile in a single function; -`src/models/laya/src/model.rs:11` (`const D: usize = 1024`), -`Workspace::sizes` (`:137-162`, hardcoded 2624/4096/2048/1028/256) and -`prepare_encoder` (`:303-513`, `0..28`, `i % 3`) repeat the same -constants as code; `weights.rs:95-163` repeats them again as shapes. -RFC §3 problems 1–3: parsing, geometry and naming are coupled, so a -second ModernBERT profile (Vela 0.3B, frozen as RFC Phase 3) can only -be added by copying a hardcoded loader. There is also nowhere to put -a capability decision that is checkable without CUDA. - -## Solution - -A new pure-Rust crate `src/models/modernbert` (package -`omni-modernbert`, registered in the root workspace) containing the -family layer with **no dependency on `omni-cuda`, libloading, or any -device pointer** (RFC §5.1 boundary rule): - -```text -src/models/modernbert/src/ -├── structure.rs # ModernBertStructure + provenance + family invariants -├── adapter.rs # LayaBundleAdapter, ModernBertConfigAdapter -├── geometry.rs # DerivedGeometry: tensor shapes, widths, scratch inputs -├── gate.rs # CapabilityProfile / backend limits / StructureIdentity -└── plan.rs # named-kernel encoder plan builder (pure data) -``` - -No consumer changes in this spec: Laya keeps compiling exactly as -today; the crate lands with its own CPU tests and two recorded -structure fixtures (Laya profile, Vela 0.3B profile). Spec 02 performs -the Laya migration onto it. - -## Design - -### `ModernBertStructure` (structure.rs) - -One type for the whole family; every field carries provenance: - -```rust -pub struct ModernBertStructure { - pub hidden: usize, pub intermediate: usize, pub num_layers: usize, - pub activation: Activation, // Gelu (only family member today) - pub norm_eps: f32, - pub attention: AttentionSpec, // heads; head_dim derived; bias=false - pub schedule: Vec, // normalized Full/Sliding array - pub local_window: usize, - pub rope: PerKindRope, // theta per kind + optional YarnScaling - pub vocab: usize, - pub type_embedding_rows: Option, - pub max_positions: usize, - pub provenance: ProvenanceMap, // field → (source_path | default | derived) -} -``` - -Family invariants enforced at construction (fail with the field and -actual value, never silently): - -- `heads * head_dim == hidden` (holds for both profiles: 16·64=1024, - 12·64=768; `head_dim` is derived unless the source states it); -- `schedule.len() == num_layers`, kinds ∈ {Full, Sliding}; -- `local_window > 0`, `intermediate > 0`, `vocab > 0`, - `norm_eps > 0`, theta > 0 and finite per kind; -- `activation == Gelu`, `attention_bias == mlp_bias == norm_bias == - false` (family constants — a source that sets them is a different - family, rejected here); -- if YarnScaling present: `factor > 1`, `original_max_positions > 0`. - -`mlp.Wi = [2·intermediate, hidden]` is **derived** (RFC §6: the 5248 -in the Laya checkpoint is not a semantic field). - -### Source adapters (adapter.rs) - -The model crate picks its adapter explicitly; each adapter validates -its own source markers (README decision 2): - -- `LayaBundleAdapter` — reads `rl_agent_config.json` (only to reject - on missing/invalid, agent fields stay Laya-side) and - `encoder/config.json`: requires `model_type == "modernbert"`, - `hidden_activation == "gelu"`, bias flags false; reads the explicit - `layer_types` array verbatim into `schedule` (provenance: - `layer_types[i]`); reads both `rope_parameters[kind].rope_theta`. -- `ModernBertConfigAdapter` — reads any HF-ModernBERT-style config - object (the Vela 0.3B case: the `encoder_config` member of the - vela2-unified root; requires inner `model_type == - "modernbert"`): **derives** `schedule` from - `global_attn_every_n_layers` (full at i % n == 0 — provenance marks - it derived with the source field name), reads per-kind thetas - (`global_rope_theta` / `local_rope_theta`), maps `rope_scaling` - (`rope_type == "yarn"`, `factor`, `original_max_position_embeddings`) - into `YarnScaling`, and treats `position_embedding_type == - "sans_pos"` and absent type-vocab as `type_embedding_rows: None`. - -Both record `source.config_files` in the provenance map (RFC §5.2). -Unknown top-level fields are ignored but listed in the provenance -summary (explicit policy, no silent misread — the adapter must never -accept a `max_length`-style recipe limit as a structure field; the -Vela root's `max_length: 8192` is worker config, not encoder -geometry). - -### DerivedGeometry (geometry.rs) - -Pure functions of the structure: `qkv = [3·hidden, hidden]`, -`attn_out = [hidden, hidden]`, `mlp.Wi = [2·intermediate, hidden]`, -`mlp.Wo = [hidden, intermediate]`, `rotary_half = head_dim / 2` (32 -for both profiles), rope table bytes `max_positions_exercised · -rotary_half · 4`, and the encoder scratch size formula parameterized -by (b, l, structure) — replacing `Workspace::sizes`' hardcoded -`m * D * 6` / `m * 2624 * 2` arithmetic with structure-derived terms -(task-specific buffers stay with the model, spec 02). - -### Capability gate (gate.rs) - -Two levels, both CPU-only, both answering with actual-vs-required: - -- `BackendLimits` — what one CUDA bundle supports: instantiated hidden - sizes, `(b, l)` envelope (today: b power-of-two ≤ 16, 16 ≤ l ≤ 512, - l % 16 == 0), rope policy (plain-theta tables only), dtype policy. -- `RegisteredProfile` — a named, frozen structure+limits pair. First - registration: `laya-0.3.20` (the exact constants of - `config.rs:50-79` as data). Acceptance = family invariants ∧ - backend limits ∧ (if a registered profile is requested) structure - equality with a field-diff on mismatch. - -`StructureIdentity` (RFC §8): a digest over `schema_version`, family -tag, canonical structure serialization, and the backend/ABI tag. -Defined here, consumed by cache/identity decisions later; no caller -yet — landing it now is cheap and fixes the format before two -profiles exist. - -### Plan builder (plan.rs) - -`encoder_plan(&structure, opts) -> Vec` reproduces the -encoder half of today's `prepare_encoder` sequence (embed → per-layer -qkv/rope/attn/out/addln/geglu/down/addln → final norm → optional -type) emitting **named kernel names + argument roles** as pure data — -no `omni_cuda::Kernel` resolution, which stays with the consumer -(Laya keeps its `SelectedKernel` resolver). This keeps the core crate -CUDA-free while making the execution order fixture-testable. - -## Evidence & tests (all CPU, in-crate) - -1. Adapter provenance matrix: present/missing/type-error/defaulted - fields for both adapters; derived-schedule case (22 entries, 8 - full) provenance-checked. -2. Invariant rejections: each violated invariant fails with the field - and actual value. -3. Gate matrix: `laya-0.3.20` fixture accepted; each single-field - mutation (hidden 768 against the ABI-1 limits, theta drift, - schedule edit, window 127) rejected with a diff naming both - values; raw-JSON inputs never touch CUDA (crate has no such dep — - structurally guaranteed). -4. `StructureIdentity`: same config → same digest; any one field - changed → different digest. -5. Plan fixtures: `encoder_plan` on the Laya structure produces the - exact step sequence of `prepare_encoder`'s encoder half (recorded - as a golden fixture; spec 02 makes the live comparison). - -## Acceptance - -- `omni-modernbert` compiles with zero device dependencies and lands - in the workspace; no existing crate changes behavior; full existing - test suite stays green. -- The two structure fixtures (Laya, Vela 0.3B) round-trip through - their adapters with complete provenance, and the gate's - accept/reject matrix is recorded as tests. - -## Open items (resolved by later specs, noted here) - -- The regenerated-bundle limits for the 768-hidden profile (spec 03) - register as a second `BackendLimits` + profile entry; the gate API - must make that a data addition, not a code change. -- `schema_version` starts at 1; bump rules recorded in structure.rs - docs when the first bump happens. diff --git a/specs/model-config/02-laya-encoder-core-extraction.md b/specs/model-config/02-laya-encoder-core-extraction.md deleted file mode 100644 index f96ae2cf..00000000 --- a/specs/model-config/02-laya-encoder-core-extraction.md +++ /dev/null @@ -1,117 +0,0 @@ -# Spec 02 — Laya native on the extracted encoder core - -Status: draft, local-only. Second half of RFC Phase 2: migrate the -Laya native path onto spec 01's core with **zero behavior change**, -keeping Laya as the running proof that the extracted core serves a -real profile (RFC Phase-2 deliverable: "Laya 仍能以原 profile 运行"). - -## Problem - -After spec 01 the family layer exists but Laya still executes its own -copies: `prepare_encoder` hardcodes the 28-layer / i%3 / D=1024 plan -(`model.rs:303-513`), `Workspace::sizes` hardcodes -2624/4096/2048/1028/256 (`:137-162`), `infer` hardcodes vocab 50368 -(`:679`), and `checkpoint_tensors()` hardcodes the inventory -(`weights.rs:95-163`). The Laya agent task head (the `head.layers.0..2` -loop, scorer, act_head) is interleaved with the encoder plan in one -function — exactly the backbone/task coupling the RFC forbids -extending. - -## Solution - -Laya becomes a consumer of `omni-modernbert`; its public surface -(`Config::load`, `Model::load*`, `infer`, `ModelOutput`), its CUDA -bundle, and its ABI stay untouched. Migration is internal: - -1. **Config facade** (`config.rs`): `Config::load(dir)` keeps its - signature and public fields (`AgentConfig`, `EncoderConfig`). - Internally: `LayaBundleAdapter::read(dir)` → `ModernBertStructure` - → `laya-0.3.20` profile validation via the gate (the ensure-set of - `config.rs:46-79` moves into the registered profile) → projection - back onto the old public fields. Agent-config parsing and its - validations (max_len 512 / head_max_len 192 / head_layers 2, - temperatures) stay in the Laya crate verbatim — they are Laya task - bundle, not encoder fields (RFC §2.4). -2. **Plan split** (`model.rs`): `prepare_encoder` decomposes into - - `modernbert::plan::encoder_plan(&structure, rope_variant)` — - the embed → 28 layers → final-norm → type steps, produced from - the IR (spec 01 §plan builder); the `original_rope` flag remains - a Laya serving option passed through as a plan option; - - a Laya-owned `head_plan()` — the `head.layers.{0,1}` loop and - everything after the encoder (type consume, scorer/act_head - launches stay in `infer` as today). - The two plans concatenate into the same `Vec` the executor - consumes; `Step`/`Argument`/`SelectedKernel` stay in Laya (they - are the CUDA-facing resolution layer, not family data). -3. **Workspace**: encoder scratch sizes come from - `geometry.rs` formulas; the task-side buffers (head ffn 4·D, head - rows 2048, action vocab 1028, action hidden 256, marker caps) are - sized by a Laya-side layout function appended to the encoder - layout. The single-`Workspace` struct and the graph-cache/ - eager-fallback logic (`model.rs:198-244, 706-768`) are unchanged. -4. **Weight inventory**: `checkpoint_tensors()` = core-generated - encoder inventory (canonical names - `encoder.embeddings.tok_embeddings.weight`, - `encoder.layers.{i}.{attn.Wqkv,attn.Wo,mlp.Wi,mlp.Wo}.weight`, - norms, `encoder.final_norm.weight`, `type_emb.weight`; shapes from - `DerivedGeometry`) **concatenated with** the Laya task inventory - (head.layers.{0,1}, scorer, act_head, temperature — - `weights.rs:123-161` stays verbatim). `validate_names` still runs - over the union inside Laya's own load path (RFC §7: the model's - loader owns external→internal mapping and missing/extra checks; - Laya's mapping is the identity, which is precisely why it is the - reuse baseline). -5. **Runtime bounds**: `infer`'s `*i < 50368` check reads - `structure.vocab`; the `(b, l)` envelope comes from the profile's - backend limits instead of the inline `ensure!`s (`model.rs:676-709` - keeps its packing checks — they are request validation, not - structure). - -Unchanged on purpose: `storage_dtype` (the per-class f16/bf16/f32 -storage policy is Laya's load policy, owned here), the bundle's rope -`.f32` tables and their 512×32 check (replaced by a -limits-consistency check, same numbers), `LAYA_VERIFY_WEIGHTS` -read-back, dump hooks, `serve.rs`/`executor.rs` — no serving-surface -change of any kind. - -## Evidence & tests - -1. **Plan bit-identity fixture**: serialize the full - `Vec` (kernel name, argument kinds, counts) for the Laya - profile before and after the refactor; assert equality. Recorded - once as a golden fixture so any later core change that alters - Laya's execution order fails CPU CI (seam C). -2. Existing suites stay green unchanged: `tests/laya/{checkpoint, - weights, preprocess, packing, decision, batch, model}.rs` — same - fixtures, same expectations. -3. Device-gated (seam D): `tests/cuda/laya_runtime.rs` and the - graph-capture path re-run on available hardware; parity with the - pre-refactor outputs on the existing fixtures (bit-identical - expected: same kernels, same order, same bytes). -4. Negative tests: the profile gate's reject matrix from spec 01 is - exercised through the real `Config::load` (mutated fixture configs - produce the same refusals as today, now with field-diff errors). - -## Acceptance - -- `cargo test` (CPU) fully green with no fixture edits; the plan - fixture proves execution-order identity; GPU regression recorded - when hardware is available, reported with hardware line per repo - convention. -- `git grep` shows no remaining profile constants in - `src/models/laya/src/` outside the registered-profile data and the - task-head code path (D=1024 survives only where it is genuinely - task geometry, e.g. head ffn, act_head dims — each tagged with the - task bundle in review). -- ABI 1 unchanged; `laya.backend.json` untouched; no rebuild of the - CUDA bundle required by this spec. - -## Risks - -- **Plan reorder by accident** (e.g. weight-pointer resolution - interplay) — mitigated by the plan fixture and unchanged - `upload_order` discipline. -- **Workspace layout drift** changing graph-cache shape keys — the - cache keys on `(b, l)`, and total sizes must equal the old formula - for the Laya profile (asserted in a unit test against the recorded - numbers). diff --git a/specs/model-config/03-vela-03b-modernbert-profile.md b/specs/model-config/03-vela-03b-modernbert-profile.md deleted file mode 100644 index 4a5fd8e2..00000000 --- a/specs/model-config/03-vela-03b-modernbert-profile.md +++ /dev/null @@ -1,190 +0,0 @@ -# Spec 03 — Vela 0.3B as the second ModernBERT profile - -Status: draft, local-only. RFC Phase 3: wire the pinned -`vllm-sr/Vela-2.0-0.3B@d6f03aa9` encoder into the extracted core as a -**profile, not a new family** (frozen decision: "Vela 0.3B 是 -ModernBERT 的第二个 profile,不是独立 encoder family"). Deliverable -per the RFC: native profile parity with the frozen reference contract -on the encoder surface (final hidden at requested positions). - -## Problem - -The reference worker (commit `2812144`) froze the behavioral contract -but executes the upstream Python package. A native profile needs: the -0.3B structure accepted through the family layer (it differs from -Laya in every dimension — see README table), its all-F32 checkpoint -mapped and validated, YaRN-scaled rotary tables the current bundle -does not produce, and a CUDA bundle whose kernels are generated for -1024-hidden geometry only (`model_ops.cu` hardcodes 1024/1028; -TileLang GEMMs are instantiated per N/K). None of this exists; doing -it inside Laya would recreate the coupling Phase 2 just removed. - -## Solution - -### 1. Consumer crate - -New Rust crate `src/models/vela/native` (package `omni-vela-native`) -depending on `omni-modernbert` and `omni-cuda` (feature-gated like -`omni-laya`). It owns, in this spec: the profile registration, the -model-local weight loading (RFC §7 ownership), rope-table -preparation, and the encoder execution against the regenerated -bundle. Task heads (spec 04), worker/serving surfaces (charter -slices), and the hybrid (Qwen-backboned) members are explicitly not -here. - -### 2. Profile registration and adapter wiring - -`ModernBertConfigAdapter` (spec 01) already parses the 0.3B -`encoder_config`. This spec registers `vela-2.0-0.3b`: structure -validated by the family invariants, then checked against the -**regenerated bundle's** `BackendLimits` (hidden 768 instantiated; -b/l envelope; rope policy = yarn allowed; dtype policy below). The -derived schedule (22 kinds, full at i%3==0) must be cross-checked -once against the pinned package's encoder construction and recorded -as a fixture — `global_attn_every_n_layers` is read as data, never -re-derived from habit. - -### 3. Weight loading (model-local, no WeightContract) - -The pinned docstring fixes the naming discipline: parameter names are -exactly the training checkpoint's, `model.safetensors` strict-loads, -encoder = stock `ModernBertModel` under `encoder.*`. That is the same -canonical ModernBERT naming Laya uses, minus `type_emb`, at 768 -geometry. The vela crate's `Weights::load`: - -- maps `encoder.embeddings.tok_embeddings.weight`, - `encoder.layers.{i}.{attn_norm,attn.Wqkv,attn.Wo,mlp.Wi,mlp.Wo, - mlp_norm}`, `encoder.final_norm.weight` (external == internal for - the encoder half); -- validates shape against `DerivedGeometry` (vocab 256008 · hidden - 768; qkv [2304, 768]; Wi [2304, 768]; Wo [768, 1152]; …) and dtype - (F32 source), with missing/extra/duplicate failure listing both - sets — the laya `validate_names` discipline, re-owned here; -- applies the profile's **storage policy**: GEMM-class tensors cast - to bf16 at upload; norms and embeddings kept f32/f16 per class — - mirroring Laya's `storage_dtype` classes. The F32→bf16 rounding is - a declared numerics choice carried in `StructureIdentity`; its - parity cost is measured, not assumed (below). - -First implementation step before any wiring: census the pinned -checkpoint's tensor inventory and dtypes from the safetensors header -and record it as the fixture this loader must satisfy (names are -evidence, not assumption — the local survey copy carries code+config -only, not weights). - -### 4. Rotary tables (YaRN ×4) - -The only genuinely new encoder math. Served positions stay ≤ 8192 = -`original_max_position_embeddings`, but YaRN still rescales the -frequency basis (ramp between `beta_fast` 32 / `beta_slow` 1, factor -4, truncate beyond 32 768) — the tables are **not** plain -theta-160000 tables even inside the original window. Plan: - -- compute cos/sin (rotary_half = 32 per kind; both kinds share theta - 160000 and the scaling) CPU-side in f32 from the IR's `YarnScaling` - at load time — deterministic, no extra bundle artifact; -- ground them against the pinned package: run the package's own - rotary path on CPU/FP32, dump tables at sampled positions (0, 1, - mid, 8191/8192 boundary, 16 384, 32 767) into fixtures, and gate - the Rust generator on those fixtures. Any attention-scale factor - the package applies with YaRN (mscale-style) must be discovered - from the pinned `transformers` version's rotary implementation and - either baked into the tables or passed to the attention kernel — - recorded as part of the fixture evidence, not guessed. - -### 5. Kernel bundle regeneration + the ABI decision - -The one kernel-scope item in the whole series (everything else is -Rust): - -- **TileLang instantiations** for 768-hidden geometry: gemm - (N, K) ∈ {(2304, 768), (768, 768), (1152, 768)} + geglu F=1152 + - down (768, 1152); attention base kernels already take (b, l) at - launch — verify the 12-head / Dh=64 / window-128 instantiations - accept l up to 8192 (shared-mem tiling bounds), adding bucket - specializations (`attn_full_l8192`-style, resolved like today's - `_b1_l512` variants) if the generic path does not hold; -- **`model_ops.cu` de-hardcoding**: `embed_norm` / `gather_norm` / - `add_type` take D as a runtime argument instead of `1024`; - `laya_linear`'s `n ≤ 1024` guard becomes a limits-derived bound; - `action_features`'s 1028/1024 stays Laya-task-local; -- **ABI**: this changes kernel sources and the exported bundle ⇒ - bump `laya.backend.json` `abi_version` 1 → 2 and rebuild. Blast - radius is the laya backend's consumers only (Laya + this crate) — - unlike the Qwen ABI, no sibling coordination. A separate vela - backend manifest reusing the same sources was considered and - rejected as default: one source tree, one honest ABI ledger. Rust - -only changes elsewhere in this series do not bump anything (RFC - §8 rule, restated in spec 05 for Qwen). - -### 6. Runtime envelope - -First slice mirrors the reference worker's execution shape: -serialized single request, one encoder forward per rendered sequence, -final hidden gathered at the marker/requested positions (the RFC's -position-level readout; no per-layer extraction). Registered limits: -b ∈ {1, 2, 4}, l buckets {512 … 8192} (l % 16), vocab bound from the -structure. Workspace sizing via the core geometry formulas; graph -capture per (b, l) bucket reuses the Laya workspace-cache pattern if -it pays for itself — optional, evidence-gated. - -### 7. Parity harness (seam B) - -Extend the `recipe/vela` harness pattern: drive the **pinned -package** (CPU, FP32, the charter oracle) to dump final hidden at -requested positions for a fixture prompt set (reusing the reference -worker's frozen example/rejection fixtures where they exercise the -encoder); run the native profile over the same token ids; report -max/rms deltas. Tolerance gates are declared at implementation, -anchored to the upstream noise floor (≈2.9e-3 max logit, charter §3) -— the bf16 GEMM storage policy's cost shows up here and is reported, -not averaged away. A device-gated variant runs the native side on -GPU with hardware reported (seam D). - -## Evidence & tests - -1. CPU: adapter→IR→gate accept for `vela-2.0-0.3b`; reject matrix - (hidden 1024 against the 768-instantiated limits, schedule edit, - yarn factor drift) with field diffs. -2. CPU: weight-mapping tests against a synthetic safetensors fixture - built from the recorded census (missing/extra/duplicate/shape/ - dtype each fail loudly); storage-policy class assignment table. -3. CPU: rope-table fixtures vs the pinned package at the sampled - positions (§4). -4. CPU: plan fixture for the vela profile (22 layers, kind sequence, - no type step) — same golden-fixture discipline as spec 02. -5. GPU (device-gated): encoder forward on real weights; parity - report vs the CPU-FP32 oracle per §7; two consecutive runs - identical. - -## Acceptance - -- The 0.3B encoder runs natively with its own profile through the - shared core; Laya still passes everything (spec 02's fixtures - untouched) — the two profiles share the core and nothing else. -- Parity report recorded with tested hardware; failures or - unexercised checks disclosed per repo convention. - -## Out of scope (fences) - -Vela task heads (spec 04 — they only consume the final-hidden -contract), the native worker/serving/frontend slice, ONNX options, -the hybrid members (they ride spec 05 + charter slice 4), any change -to Laya's numerics or bundle behavior beyond the shared-bundle -regeneration (Laya's own instantiations and outputs must remain -bit-identical — verified by spec 02's GPU regression re-running on -the ABI-2 bundle). - -## Risks - -- **YaRN subtleties** (mscale, ramp conventions differing between the - pinned transformers version and our generator) — mitigated by - fixture-grounding against the package, not the HF docs. -- **Generic attention kernel failing at l=8192** — bucket - specializations are the planned fallback; if even those are - unsound, the profile's first slice can register l ≤ 4096 while the - windowing plan (charter §3's ≤1800-token windows) covers the 8192 - budget — a limits decision, not a family change. -- **bf16 storage rounding** moving low-margin answers — measured by - the parity harness before any tolerance is frozen; switching the - policy is a `StructureIdentity` change, visible by construction. diff --git a/specs/model-config/04-vela-task-heads-boundary.md b/specs/model-config/04-vela-task-heads-boundary.md deleted file mode 100644 index 6fae0e4d..00000000 --- a/specs/model-config/04-vela-task-heads-boundary.md +++ /dev/null @@ -1,93 +0,0 @@ -# Spec 04 — Vela task-head ownership boundary - -Status: draft, local-only. RFC Phase 4, scoped as a **boundary spec**: -it fixes what the shared core guarantees to heads and what stays -Vela-owned, and sizes the first head work. The full worker -integration (wire reuse, serving, recipes) is a charter slice that -consumes this; nothing here touches `omni-modernbert`. - -## Problem - -The Vela readouts are the model's value: the 0.3B "both" readout -(cosine + MLP summed, proj dim 256), the word-unit span readout -(normalize(W_t·LN(h[first sub-word])) · normalize(W_e·LN(h[E])) / -τ_span), per-member calibration (τ, τ_span, thresholds from the -member's own `calibration.json`). The RFC froze that none of this -enters the ModernBERT core, and that heads load through the model's -own path. Without a written contract, head work drifts into the core -or quietly redefines the encoder's output obligations. - -## Solution - -### The core→head contract (all the heads may assume) - -1. Final hidden states at **requested token positions** of the last - encoder layer, after final norm, in f32 — nothing else (no - per-layer hidden, no pre-norm states; RFC §5.4). -2. The position set is the head's own declaration (markers, word - first-sub-words, [E] anchor), resolved before the encoder forward; - the encoder does not interpret positions. -3. `StructureIdentity` of the profile (the heads' weights are - geometry-dependent: proj 256 × hidden 768) is available for - load-time compatibility checks. - -### Vela-owned (in `src/models/vela/native`, beside spec 03's loader) - -- **Head tensors** (0.3B: the readout/span parameters inside - `model.safetensors`; hybrids later side-load `broad_head. - safetensors` — that pattern lands with the hybrid slice): loaded - F32 exact-shape in the vela crate's own path, omnijev - `heads.rs`-style (`src/models/omnijev/native/src/heads.rs:121-240` - prior art), with sha256 pinning per file once a second file exists - (cua_s1 manifest pattern). Missing/extra/shape/dtype failures list - both sides — same discipline as the encoder inventory. -- **Readout computation**: F32 on GPU or CPU (first slice: CPU over - the downloaded hiddens — the reference disables autocast inside - heads, matching), cosine+MLP summed logits, τ/τ_span tempering, - span word readout, threshold application (strict `>`, - per-question override echo) — semantics are the frozen reference - contract (`src/models/vela/postprocess.py` + the pinned package); - this spec adds no new semantics, only the native implementation - surface. -- **Calibration/threshold loading**: per-member - `calibration.json`, never cross-copied (charter §2 rule); loaded - beside the checkpoint, validated against the member's identity. - -### Explicitly forbidden (fence the review against) - -- Any head tensor, temperature, threshold, or readout concept in - `omni-modernbert`; any encoder IR field that exists only for a - head; any head reading encoder internals other than contract item - 1; any MultimodalInput-adjacent surface (text/token_ids only). - -## Evidence & tests - -1. CPU: head-tensor inventory tests (synthetic fixture; missing/ - extra/duplicate/shape/dtype rejections). -2. CPU: readout parity on **recorded hidden fixtures** — run the - pinned package on the spec-03 prompt set, dump final hidden + - the package's own head outputs; the native head over the same - hiddens must reproduce the outputs within the same tolerance - discipline (exact where the math is exact; float-tolerance where - the package itself computes in f32 — declared with the fixtures). - This pins head numerics independently of the encoder (valen - seam-C precedent). -3. Negative: a core change that alters the final-hidden contract - (dtype, norm placement, position semantics) must fail these - fixtures — the fence is testable, not aspirational. - -## Acceptance - -- Heads load, compute, and match the package on the hidden fixtures; - `omni-modernbert` diff is empty across the whole head PR. -- The boundary survives review: any core-file touch in the head - implementation is a spec violation by construction. - -## Note on the hybrid members - -The hybrid heads (CandidateHead, SpanHeadV2 ×2, set_bias; F32, -side-file `broad_head.safetensors`) obey this same boundary when the -hybrid native slice lands after spec 05 — same contract against the -Qwen backbone's final hidden at requested positions, same model-local -loading. No forward work is taken here beyond keeping the contract -wording backbone-agnostic. diff --git a/specs/model-config/05-qwen-hybrid-config-capability-gate.md b/specs/model-config/05-qwen-hybrid-config-capability-gate.md deleted file mode 100644 index 4774bb83..00000000 --- a/specs/model-config/05-qwen-hybrid-config-capability-gate.md +++ /dev/null @@ -1,130 +0,0 @@ -# Spec 05 — Qwen hybrid config/capability migration - -Status: draft, local-only. RFC Phase 5: separate source parsing, -family normalization, and kernel capability inside the existing -`src/models/qwen3_5/native` family crate — the vehicle the Vela -hybrid members' native route rides (charter: "0.8B native rides the -Phase-5 outputs"). **No gate constant, kernel, or ABI change**: the -frozen ABI-6 reject set (RFC §5.3) is expressed as data, not widened. - -## Problem - -`Config::load` (`src/models/qwen3_5/native/src/model.rs:49-180`) -simultaneously reads JSON, normalizes the family, and enforces -kernel capability via inline `ensure!`s (silu, output gate, no bias, -conv kernel 4, head dims 256/128/128, rotary_half 32, interleaved -mrope summing to 32, heads divisibility, hidden % 8) — RFC §2.1's -"固定能力边界". Consequences: capability decisions are not checkable -without constructing a `Config` from a directory; the Vela hybrid -consumer's three known trips (config location `backbone_config`, -tensor prefix `backbone.`, per-tensor mixed dtype — research 019, -gate table C1/W1/W2) have no seam to plug into without forking the -loader; and `PrefixState` sizing (full K/V per full layer, F32 -[H,K,V] GDN state + conv tail, `model.rs:557-588`) is implicit in -`alloc_prefix` rather than derived from structure where it can be -CPU-tested. - -## Solution - -All inside the family crate (no new crate — the family owns its IR): - -1. **`QwenHybridStructure` IR**: today's `Config` public fields plus - the ordered `layer_kinds` (Full/GDN) with provenance, split per - RFC §6 (attention: heads/kv_heads/head_dim/rotary_half/rope_theta/ - mrope_section/output-gate; GDN: lin_k/lin_v heads and dims, conv - kernel; common: hidden/intermediate/layers/eps/max_positions/ - activation). Constructed only by a source adapter. -2. **Source adapter**: exactly today's reading — root or - `text_config` (`model.rs:55`), `layer_types` strings with - length == `num_hidden_layers`. It parses and records provenance; - it does not judge capability. -3. **`QwenAbi6` capability gate**: the complete current ensure-set - as typed checks over the IR, each rejection reporting actual vs - required. Content-frozen: the accepted set after this spec equals - the accepted set before it (fixture-proven below). -4. **`Config::load` facade**: adapter → invariants → gate → project - onto the unchanged public `Config` fields (shadow-normalize, - RFC §8 migration rule). All five consumers (qwen workers, - jev_vl, cua_s1, open_jev, omnijev) compile and behave identically - — public API untouched, ABI 6 untouched, no rebuild required. -5. **`PrefixLayout` derived geometry**: per full-attention layer - (post-prep K/raw V bytes at head_dim 256) and per GDN layer (F32 - [H,K,V] recurrent state + three conv-tail rows at conv kernel 4), - computed from the IR and CPU-testable; `alloc_prefix` consumes - the derived layout (identical numbers, now asserted by tests - instead of implied). The 64-token boundary and `max_positions` - cap stay in `alloc_prefix` as today. -6. **Loader seams for the hybrid consumer** (used by Vela's crate, - charter ticket 030 ownership — nothing Vela-specific lives here): - - `Config::from_structure(ir)` — a constructor accepting an - already-parsed structure, so the Vela source adapter can - extract `backbone_config` from its bundle and feed the family - gate directly; - - `Weights::load` gains optional **prefix candidates** (default: - the current three, `model.rs:273-281`) and a **per-tensor - dtype policy** (default: uniform BF16, today's `ensure!` at - `model.rs:291-295`). The Vela consumer passes the `backbone.` - candidate and the faithful mixed policy (Linear+conv1d BF16, - embed/norms/A_log/dt_bias F32, cast at upload) — per-tensor - faithful loading per the charter; export re-save stays the - recorded fallback. Rust-only, no C symbol or layout change ⇒ - **no ABI bump** (RFC §8 rule). - - `StructureIdentity` (the spec-01 pattern, family tag `qwen3_5`): - normalized schema version, structure digest, weight-mapping - digest, dtype policy, backend, ABI 6. Prefix-cache keys/records - carry it; any change forces re-capture instead of reuse - (jev_vl's cross-request cache is the consumer to audit). - -## Evidence & tests (all CPU unless noted) - -1. **Shadow equivalence**: every existing config fixture — - `tests/qwen3_5/config.rs` + `tests/qwen3_5/data/config.json` and - each consumer's pinned configs — produces byte-equal public - `Config` fields through the facade (recorded before/after). -2. **Gate matrix**: the frozen reject set (head_dim ≠ 256, lin dims - ≠ 128, conv ≠ 4, non-silu, output gate off, bias on, non-default - rope, non-interleaved mrope, sections ≠ 32 sum, bad max positions, - heads % kv, lin_v % lin_k, hidden % 8) each rejected with - actual-vs-required; plus the accept side using the three Vela - hybrid backbone geometries as fixtures (0.8B: 1024/3584/24 layers - 18+6, 8+2 heads, 16/16 GDN; 4B: 2560/9216/32, 16+4, 16/32; 9B: - 4096/12288/32, 16+4, 16/32 — research 019 §2 table, which already - established all three pass every gate). Raw config inputs never - approach CUDA: gate tests construct IR directly (seam C). -3. **PrefixLayout**: derived sizes equal the current implicit - allocations for the fixture geometries (unit-asserted against - recorded numbers per size). -4. **Consumer regression**: jev_vl / cua_s1 / open_jev / omnijev test - suites green; `PrefixState` capture/continue device-gated tests - (existing) re-run where hardware allows (seam D). -5. Loader-seam tests: mixed-policy loading on a synthetic sharded - fixture (BF16 Linear class + F32 norm class) with missing/extra/ - shape/dtype rejections; prefix-candidate selection including the - `backbone.` case excluding in-shard head tensors by prefix. - -## Acceptance - -- Behavior-preserving by construction and by fixtures: no consumer - change, no gate widening, ABI 6 and all kernel contracts untouched. -- The three Vela hybrid geometries pass the gate as pure CPU tests — - the Phase-5 output the hybrid native slice (charter step ④) - consumes with only Vela-side source-adapter and loader work left. - -## Out of scope - -The Vela hybrid consumer crate itself (charter slice 4: execution -plan over `alloc_prefix` capture/continue, windowing, heads), any -arbitrary Full/GDN schedule opening (README decision 3), M-RoPE or -modal behavior changes (opaque per RFC §5.2), prefix-state disk -persistence, and anything touching `src/backends/cuda/qwen3_5`. - -## Risks - -- **Facade drift** (a field quietly re-derived differently) — - shadow-equivalence fixtures are the guard; run them first in the - PR. -- **Co-editing window**: the family crate has live sibling PRs - (charter §"Co-edit surface"); this refactor lands as a pure - internal move with zero API delta to minimize rebase pain, and - coordinates timing if an ABI-window PR is in flight (no ABI - interaction here, but `model.rs` is shared ground). diff --git a/specs/model-config/README.md b/specs/model-config/README.md deleted file mode 100644 index 4cd5cbc6..00000000 --- a/specs/model-config/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Configurable base-model specs (RFC implementation, Phases 2–5) - -Specs for implementing the configurable base model defined by -`docs/rfc/model-parameter-configuration.md` (Final). This is charter -step ③ of the Vela 2.0 integration order: the 0.3B reference worker -(step ①) landed as `2812144`; the RFC implementation is the -prerequisite every native Vela slice waits on — the 0.3B ModernBERT -native profile rides Phases 2–4, the 0.8B/4B/9B Qwen-hybrid native -route rides Phase 5. - -**Local and untracked-of-record** until promoted: this series is -committed on branch `model/configurable-base-model` only; promotion to -tracker issues happens spec by spec after the user approves, same rule -as `specs/valen/`. - -## Index - -| Spec | Title | RFC phase | Depends on | -| --- | --- | --- | --- | -| [01](01-modernbert-family-ir.md) | ModernBERT family IR, source adapters, capability gate | 2 (first half) | — | -| [02](02-laya-encoder-core-extraction.md) | Laya native on the extracted encoder core | 2 (second half) | 01 | -| [03](03-vela-03b-modernbert-profile.md) | Vela 0.3B as the second ModernBERT profile | 3 | 02 | -| [04](04-vela-task-heads-boundary.md) | Vela task-head ownership boundary | 4 | 03 | -| [05](05-qwen-hybrid-config-capability-gate.md) | Qwen hybrid config/capability migration | 5 | 01 (pattern); independent of 02–04 | - -PR order on this branch: 01 → 02 → 03 → 04, with 05 parallel after 01. -RFC Phase 6 (unified facade sweep + release evidence) is a follow-on -after the Vela slices consume these outputs; it is deliberately not -spec'd here. - -Implementation status on this branch: 01 and 02 complete (`705c23a`, -`d5d9373`, `df5b8e1`); 05's structure/gate/layout/identity core -complete with the loader seams deferred to the consumer slice -(`0f30094`); 03's CPU half complete — census fixture, profile -registration, weight accounting, YaRN tables fixture-grounded against -the pinned transformers — with the CUDA bundle regeneration (and the -laya ABI 1→2 bump) fenced until a TileLang toolchain is available; 04 -remains boundary-only until the encoder executes. - -## What this series does NOT do - -Frozen-boundary fence (RFC §11, charter §8) — no spec below may: -introduce a WeightContract/WeightResolver (weight mapping stays in each -model's own `load_weight`/`Weights::load`), move task heads or their -tensors into a shared core, extend `MultimodalInput` (text/token_ids -only), promise per-layer hidden extraction (final hidden at requested -positions only, reusing #129/#134), reopen the Laya-baseline or -ModernBERT-profile decisions, or change any Qwen gate constant / -kernel / ABI (ABI 6 stays). - -Also out: the Vela workers themselves (HTTP, frontend, recipes — -charter slices that consume these outputs), ONNX serving options, -quantization, and any Qwen-hybrid native executor changes. - -## Decisions taken on the RFC's "Not yet specified" list - -The RFC left four execution-stage questions open (§"Not yet -specified"); this series takes these stances: - -1. **Normalized schema = Rust-internal IR only.** No operator-facing - manifest file is introduced; `schema_version` stays an internal - field of the IR. Revisit only if a non-Rust consumer appears. -2. **`family` is chosen explicitly by the adapter the model crate - constructs** — never inferred silently from `model_type`. Each - adapter validates its own source markers (`model_type` etc.) and - produces a family IR; a mismatch fails with source evidence. -3. **Qwen keeps the frozen ABI-6 capability set** (RFC §5.3): no - arbitrary Full/GDN schedule opening in this series. The gate - expresses today's reject set as data; widening it is a future, - benchmark-backed decision. -4. **Bundle layout and profile fields** are defined in spec 03 (the - only profile that needs a new bundle); `StructureIdentity` is - defined once in spec 01 and consumed wherever identity matters. - -## The two profiles this series makes configurable (evidence) - -Both are ModernBERT-family encoders with identical naming discipline -(HF ModernBERT tensor names); every number below is read from source, -not assumed: - -| Field | Laya profile (`rl_agent_config.json` + `encoder/config.json`) | Vela 0.3B profile (`encoder_config` in the pinned repo config) | -| --- | --- | --- | -| hidden / intermediate | 1024 / 2624 | 768 / 1152 | -| layers / heads / head_dim | 28 / 16 / 64 (derived) | 22 / 12 / 64 (derived) | -| schedule | explicit `layer_types` array, full at i%3==0 | `global_attn_every_n_layers: 3` (derived array, full at i%3==0 → 8 full / 14 sliding) | -| local window | 128 | 128 | -| RoPE | plain theta, full 160000 / sliding 10000 (two `rope_parameters` entries) | both kinds 160000 + `rope_scaling` yarn (factor 4.0, original_max 8192, truncate) | -| vocab | 50368 | 256008 | -| type embedding | `type_emb.weight` [3, 1024] | none | -| norm eps / activation / bias | 1e-5 / gelu / all false | 1e-5 / gelu / all false | -| max positions exercised | 512 (bundle rope tables, workspace cap) | 8192 served (`max_length`; tables to 32768) | -| checkpoint dtype | mixed per class (f16 embed, bf16 GEMM class, f32 norms) | all F32, single `model.safetensors`, strict load | - -Vela 0.3B facts from the pinned copy at `d6f03aa9` -(`modeling_vela2.py` `_build`: `m.encoder = ModernBertModel(ecfg)`, -stock HF naming under `encoder.*`; parameter names "exactly those of -the training checkpoint, so `model.safetensors` loads with -strict=True"). - -## Why the kernels are in scope (the one real capability gap) - -The shared CUDA bundle (`src/backends/cuda/kernels/`, manifest -`laya.backend.json`, ABI 1) is **generated per geometry**: the -TileLang templates take `N, K` (GEMM) and `B, L, H, Dh, window` -(attention) as compile-time parameters, and `kernels/model_ops.cu` -hardcodes 1024/1028/2048 (`embed_norm` ids[r]*1024, `add_type` 1024, -`action_features` 1028, `laya_linear` guard n≤1024). Laya's 768-hidden -sibling therefore needs bundle regeneration plus de-hardcoding — a -kernel-source change, hence an explicit ABI decision (spec 03), which -is exactly the "configurable ≠ arbitrary" boundary the RFC freezes. -Everything else about the Vela profile is adapter/mapping/planning -work in Rust. - -## Test seams - -Mirrors `specs/valen/` (no new seams): - -- **Seam C — CPU golden fixtures** is the main evidence channel for - 01/02/05: adapter provenance, IR invariants, gate accept/reject - matrices, serialized execution-plan fixtures, derived prefix-layout - sizes — all without a GPU. -- **Seam B — parity harness** (spec 03): native encoder final hidden - at requested positions vs the pinned 0.3B package (CPU, FP32 - oracle), tolerance declared at implementation anchored by the - upstream noise floor (charter §3). -- **Seam D — device-gated GPU regression**: existing - `tests/cuda/laya_runtime.rs` stays green through the refactor; - spec 03 adds a Vela-profile encoder smoke. - -## Standing conventions - -Inherited verbatim from `specs/valen/README.md`: repo-facing docs and -code comments carry no process narrative (no spec numbers, tickets, or -decision-status references in `src/`, `tests/`, `recipe/`); no -trailing `# …` comments; evidence blocks phrased as measured facts. -The specs themselves (this directory) are local planning documents and -may reference the RFC and wayfinder assets. - -## Provenance of the facts embedded in these specs - -Working tree @ `2812144` (branch `model/vela2-reference-serving`): -`src/models/laya/src/{config,model,weights}.rs`, -`src/models/qwen3_5/native/src/model.rs`, -`src/backends/cuda/kernels/{model_ops.cu,laya_tilelang.py}`, -`src/backends/cuda/laya.backend.json`, `tests/laya/*`, -`src/models/omnijev/native/src/heads.rs`. Pinned upstream copies: -`/tmp/vela-survey/Vela-2.0-0.3B` (`d6f03aa9`; config.json, -modeling_vela2.py). Research baselines: `wayfinder/research/017-*` -(upstream survey), `019-*` (hybrid configs vs native gates — the -gate×verdict table and per-size geometry), charter -`wayfinder/drafts/vela-integration-charter.md` (locked order, -ownership, tolerance anchors). Line numbers drift; the facts were -verified at planning time. From 889958b1e9eb9bffa9c8b475b1fa2e435633f53b Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 20:14:33 +0800 Subject: [PATCH 10/21] vela: freeze oracle fixtures and compare endpoints with the reference The package gate now refuses any directory carrying importable code, not just stray .py files, closing the namespace-package shadowing path into the pinned runtime. compare_reference.py compares the worker's verbatim /health, /ready, /v1/models and 401 bodies against the live reference app, runs an eight-case default comparison (the three recipe examples plus five synthetic rejections), and --export freezes a passing run's request, response and predict-level answers with provenance as the oracle fixture for the later native slices. Worker tests, the recipe docs and the support-matrix row follow. Signed-off-by: Mr.Dong <1042542469@qq.com> --- docs/supported-models.md | 2 +- recipe/vela/compare_reference.py | 92 ++++++++++++++++++++++++--- recipe/vela/reference.md | 16 +++-- recipe/vela/test_compare_reference.py | 72 ++++++++++++++++++--- src/frontend/README.md | 4 +- src/frontend/vela.py | 8 ++- src/models/vela/README.md | 16 +++-- src/models/vela/engine.py | 59 ++++++++--------- src/models/vela/postprocess.py | 40 ++++++++++-- src/models/vela/protocol.py | 2 +- tests/vela/test_engine_manifest.py | 64 ++++++++----------- tests/vela/test_oracle_fixtures.py | 51 +++++++++++++++ tests/vela/test_postprocess.py | 16 ++++- tests/vela/test_worker_server.py | 18 ++++++ 14 files changed, 353 insertions(+), 107 deletions(-) create mode 100644 tests/vela/test_oracle_fixtures.py diff --git a/docs/supported-models.md b/docs/supported-models.md index 2f5967d9..d49c6aa6 100644 --- a/docs/supported-models.md +++ b/docs/supported-models.md @@ -24,7 +24,7 @@ Models that are being added are also tracked in issues labeled [new model](https | CLM-v0.1-8B | [Native Rust engine](../src/models/clm/README.md), `clm-run`, in front of a Qwen3-8B `/v1/embeddings` server | Engine-only agreement **4.5e-06** on identical vectors ([#29](https://github.com/ThinkFlowLab/system1-omni/pull/29), [validation](../recipe/clm/native/VALIDATION.md)); contract checks documented ([#23](https://github.com/ThinkFlowLab/system1-omni/pull/23)) | **Validated on compute capability 8.9** ([#29](https://github.com/ThinkFlowLab/system1-omni/pull/29), [validation](../recipe/clm/native/VALIDATION.md)) | Unverified | Rust to build and a separate Qwen3-8B `/v1/embeddings` server for the encoder; the engine itself has no device-specific code | | OmniJev-4B v1.1 | None yet: [checkpoint export and CPU contract](../recipe/omnijev/README.md); the native worker is in progress ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) | Request contract, preparation, heads and finishing checked against the reference ([#120](https://github.com/ThinkFlowLab/system1-omni/pull/120)); no inference | Not yet | Not supported | Python 3.10 reference environment for the export and fixtures | | D3 (Decision 3.0) | [Python reference worker](../recipe/d3/reference.md) running the checkpoint's pinned runtime | Unverified | Author-run on NVIDIA A100 (sm_80): three-kind serving smoke with byte-identical frontend forwarding and reference parity (exact decisions, max delta 0.0, 348 == 348 tokens); evidence in the integration PR. Owner: @MrDongsls. | Unverified | Python 3.12, the [pinned runtime requirements](../recipe/d3/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/d3) (~52 GB) | -| Vela 2.0 0.3B | [Python reference worker](../recipe/vela/reference.md) running the checkpoint's pinned runtime | **CPU FP32 validated** (author-run, x86 WSL2, 24 cores): startup with all-kind warmup, the four example requests plus five synthetic rejections, and exact-match reference parity against the pinned upstream server on all nine (four 200s byte-equal incl. key order, agreed 422/413); byte-identical frontend forwarding; evidence in the integration PR. Owner: @MrDongsls. | Unverified (bf16-autocast is an untested deployment option) | Not supported | Python 3.12, the [pinned requirements](../recipe/vela/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/Vela-2.0-0.3B) (1.24 GB FP32 weights; ~4.4 GB with ONNX/assets) | +| Vela 2.0 0.3B | [Python reference worker](../recipe/vela/reference.md) running the checkpoint's pinned runtime | Author-run on x86 WSL2 (24 cores), CPU FP32: startup with all-kind warmup, the eight-case default comparison (the three recipe examples plus five synthetic rejections), and exact-match reference parity against the pinned upstream server on all eight (three 200s byte-equal incl. key order; four agreed 422s and the agreed 413 overflow); byte-identical frontend forwarding; evidence in the integration PR. Owner: @MrDongsls. | Unverified (bf16-autocast is an untested deployment option) | Not supported | Python 3.12, the [pinned requirements](../recipe/vela/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/Vela-2.0-0.3B) (1.24 GB FP32 weights; ~4.4 GB with ONNX/assets) | - **Validated:** covered by the recipe on `main` or by the checks in the linked merged pull request. - **Author-run:** recorded by the recipe author on the stated hardware and not independently reproduced; the row names its owner. diff --git a/recipe/vela/compare_reference.py b/recipe/vela/compare_reference.py index 58c7eeee..ed434c4a 100644 --- a/recipe/vela/compare_reference.py +++ b/recipe/vela/compare_reference.py @@ -13,14 +13,17 @@ finishing). Both paths execute the same verified runtime, and the worker delegates -response construction to the same pinned code — so the criterion is the -charter's reference parity: exact match, including JSON key order, with +response construction to the same pinned code — so the criterion is +reference parity: exact match, including JSON key order, with rejections agreeing on status and body (``{"detail": [{"loc", "msg", -"type"}]}``). ``--export`` additionally freezes the oracle fixture — -request, SystemOne response and the predict()-level answers — for the -future native encoder slices, and writes it only after a passing, -non-rejected comparison. Requires the pinned package, the reference -dependencies, and fastapi+uvicorn+httpx; see recipe/vela/reference.md. +"type"}]}``). The run also compares the worker's verbatim endpoint +bodies (``/health``, ``/ready``, ``/v1/models`` and the unauthorized +401) against the live reference app. ``--export`` additionally freezes +the oracle fixture — request, SystemOne response, the predict()-level +answers and the generating run's provenance — for the future native +encoder slices, and writes it only after a passing, non-rejected +comparison. Requires the pinned package, the reference dependencies, +and fastapi+uvicorn+httpx; see recipe/vela/reference.md. The default fixture set is the recipe's four example requests plus five synthetic rejections: an invalid span question (whole-request @@ -221,17 +224,56 @@ def compare_case(name, worker, reference): return lines, False +def endpoint_parity(client, gated, worker_endpoints, unauthorized_body, probe_body): + """Compare the worker's verbatim endpoint bodies with the live reference app. + + ``worker_endpoints`` maps the reference app's GET routes to the exact + bodies the worker serves; ``gated`` is a reference app built with an + API key, so an unauthorized POST must answer the worker's 401 body. + A drift in either direction fails the run. Returns report lines and + the failure count. + """ + + lines = [] + failures = 0 + for path, expected in worker_endpoints: + response = client.get(path) + observed = response.json() + if response.status_code != 200 or observed != expected: + failures += 1 + lines.append( + f"endpoint {path}: FAIL: HTTP {response.status_code} " + f"{json.dumps(observed, ensure_ascii=False)[:160]} != the worker's " + f"{json.dumps(expected, ensure_ascii=False)[:160]}" + ) + else: + lines.append(f"endpoint {path}: HTTP 200, exact body match: PASS") + response = gated.post("/v1/systemone", json=probe_body) + observed = response.json() + if response.status_code != 401 or observed != unauthorized_body: + failures += 1 + lines.append( + f"unauthorized POST: FAIL: HTTP {response.status_code} " + f"{json.dumps(observed, ensure_ascii=False)[:160]} != the worker's " + f"{json.dumps(unauthorized_body, ensure_ascii=False)[:160]}" + ) + else: + lines.append("unauthorized POST: 401, exact body match: PASS") + return lines, failures + + def export_allowed(ok, worker): """The oracle freezes served 200 responses, never a rejection.""" return ok and worker[0] == 200 -def export_oracle(name, body, response, predict, path): +def export_oracle(name, body, response, predict, path, source): """Freeze one passing case for the future native encoder slices.""" oracle = { "case": name, + "source": source, "request": body, "system_one": response, "predict": predict, @@ -252,12 +294,44 @@ def main(argv=None): import vela2_inference import vela2_serve + from frontend.vela import HEALTH_BODY, MODELS_ENTRY, READY_BODY, UNAUTHORIZED_BODY + app = vela2_serve.create_app(executor.engine, model_name=executor.model_name, api_key=None) client = TestClient(app) + gated = TestClient( + vela2_serve.create_app(executor.engine, model_name=executor.model_name, api_key="parity-key") + ) + source = { + "model": MODEL_NAME, + "device": args.device, + "generator": "recipe/vela/compare_reference.py --export", + } + # A well-formed probe body: any answer other than the worker's 401 + # means the reference's key gate drifted, not the probe itself. + unauthorized_probe = { + "model": MODEL_NAME, + "state": "short", + "questions": {"q": {"type": "noul", "instructions": "any?"}}, + } exported = 0 failed = 0 try: + endpoint_lines, endpoint_failures = endpoint_parity( + client, + gated, + [ + ("/health", HEALTH_BODY), + ("/ready", READY_BODY), + ("/v1/models", {"models": [{**MODELS_ENTRY, "name": executor.model_name}]}), + ], + UNAUTHORIZED_BODY, + unauthorized_probe, + ) + print("endpoint parity:") + print("\n".join(endpoint_lines)) + print() + failed += endpoint_failures for name, body in load_cases(args.fixture): raw = json.dumps(body).encode("utf-8") worker = worker_response(raw, executor, decide) @@ -271,7 +345,7 @@ def main(argv=None): target = Path(args.export) if target.is_dir(): target = target / f"oracle-{name}.json" - export_oracle(name, body, worker[1], executor.engine.predict(req), target) + export_oracle(name, body, worker[1], executor.engine.predict(req), target, source) exported += 1 print() finally: diff --git a/recipe/vela/reference.md b/recipe/vela/reference.md index 4e77c04d..287dc69c 100644 --- a/recipe/vela/reference.md +++ b/recipe/vela/reference.md @@ -30,7 +30,7 @@ The slice is: Out of scope for this slice: the hybrid members (0.8B/4B/9B — their reference workers are a separate slice), the ONNX backend, any native -Rust/CUDA executor (the frozen RFC's ModernBERT-profile route), and +Rust/CUDA executor (a future ModernBERT-profile route), and `noul_calibration` (a hybrid-only feature that does not exist on 0.3B). ## Architecture and ownership @@ -228,13 +228,17 @@ invalid-span 422, an over-limit 413, and the missing-field / extra-field / wrong-model envelope 422s) — through two paths that share one loaded engine: the pinned `vela2_serve.py` app via FastAPI's test client, and the production -`frontend.vela.decide` pipeline. The criterion is the charter's -reference parity: **exact match** including JSON key order for 200s, +`frontend.vela.decide` pipeline. The criterion is reference parity: +**exact match** including JSON key order for 200s, and agreement on status and body for rejections (needs `pip install -fastapi uvicorn httpx` on top of the requirements file). +fastapi uvicorn httpx` on top of the requirements file). The run also +compares the worker's verbatim endpoint bodies — `/health`, `/ready`, +`/v1/models` and the unauthorized 401 — against the live reference app, +so a drift in either direction fails the run. `--export tests/vela/fixtures/oracle-.json` freezes each passing -200 case — request, SystemOne response and predict()-level answers — as -the frozen comparison baseline for the frozen RFC's native encoder +200 case — request, SystemOne response, predict()-level answers and the +generating run's provenance — as +the frozen comparison baseline for the future native encoder slices, and writes it only after a passing comparison. ~~~sh diff --git a/recipe/vela/test_compare_reference.py b/recipe/vela/test_compare_reference.py index d97aaa45..e203da67 100644 --- a/recipe/vela/test_compare_reference.py +++ b/recipe/vela/test_compare_reference.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """Regression tests for recipe/vela/compare_reference.py. -Checks the pure comparison, case-loading and export helpers without the -pinned package, weights, torch or fastapi: exact-match verdicts -including key order, rejection agreement, the default fixture set's -shape (the invalid-span 422 and over-limit 413 synthetic cases), the -oracle export gate, and the oracle export structure. +Checks the pure comparison, case-loading, endpoint-parity and export +helpers without the pinned package, weights, torch or fastapi: +exact-match verdicts including key order, rejection agreement, the +default fixture set's shape (the invalid-span 422 and over-limit 413 +synthetic cases), the oracle export gate, and the oracle export +structure. python recipe/vela/test_compare_reference.py """ @@ -86,7 +87,7 @@ def check(name: str, condition: bool, detail: str = "") -> None: lines, ok = tool.compare_case("fixture", (200, response()), (422, rejection)) check("worker answering a rejected request fails", not ok, "\n".join(lines)) - # 3. The default case set carries the charter's fixture list shape. + # 3. The default case set carries the documented fixture list shape. cases = tool.default_cases() names = [name for name, _ in cases] check( @@ -139,18 +140,73 @@ def check(name: str, condition: bool, detail: str = "") -> None: json.dumps(over)[:120], ) - # 4. The oracle export freezes the full native-comparison surface. + # 4. Endpoint parity fails only on a real body or status drift. + class FakeResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + + def json(self): + return self._body + + class FakeClient: + def __init__(self, gets, post=None): + self._gets = gets + self._post = post + + def get(self, path): + return self._gets[path] + + def post(self, path, json=None): + return self._post + + health = {"status": "ok"} + models = {"models": [{"name": tool.MODEL_NAME}]} + endpoints = [("/health", health), ("/v1/models", models)] + unauthorized = {"detail": "Missing or invalid API key"} + probe = {"model": tool.MODEL_NAME, "state": "s", "questions": {}} + + same = FakeClient( + {"/health": FakeResponse(200, dict(health)), "/v1/models": FakeResponse(200, {"models": [{"name": tool.MODEL_NAME}]})}, + post=FakeResponse(401, dict(unauthorized)), + ) + lines, failures = tool.endpoint_parity(same, same, endpoints, unauthorized, probe) + check( + "endpoint parity passes on exact bodies", + failures == 0 and lines[0].endswith("PASS") and lines[1].endswith("PASS") + and lines[2].endswith("PASS"), + "\n".join(lines), + ) + + drifted = FakeClient( + {"/health": FakeResponse(200, {"status": "degraded"}), "/v1/models": FakeResponse(200, {"models": [{"name": "other"}]})}, + post=FakeResponse(401, {"detail": "different"}), + ) + lines, failures = tool.endpoint_parity(drifted, drifted, endpoints, unauthorized, probe) + check("endpoint body drift fails", failures == 3, "\n".join(lines)) + + wrong_status = FakeClient( + {"/health": FakeResponse(503, dict(health)), "/v1/models": FakeResponse(200, {"models": [{"name": tool.MODEL_NAME}]})}, + post=FakeResponse(401, dict(unauthorized)), + ) + lines, failures = tool.endpoint_parity(wrong_status, wrong_status, endpoints, unauthorized, probe) + check("endpoint status drift fails", failures == 1, "\n".join(lines)) + + # 5. The oracle export freezes the full native-comparison surface. body = {"model": tool.MODEL_NAME, "state": "s", "questions": {"q": {"type": "noul", "instructions": "x"}}} + source = {"model": tool.MODEL_NAME, "device": "cpu", + "generator": "recipe/vela/compare_reference.py --export"} with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "oracle.json" oracle = tool.export_oracle( - "case", body, response(), {"answers": [{"id": "q", "type": "choice"}]}, path + "case", body, response(), {"answers": [{"id": "q", "type": "choice"}]}, path, source ) reread = json.loads(path.read_text(encoding="utf-8")) check( "oracle export round-trips", oracle == reread + and oracle["source"] == source and oracle["request"] == body and oracle["system_one"]["answers"]["q"]["noul"] == 0.75 and oracle["predict"]["answers"][0]["id"] == "q", diff --git a/src/frontend/README.md b/src/frontend/README.md index 4acb3a5e..e52599cb 100644 --- a/src/frontend/README.md +++ b/src/frontend/README.md @@ -38,7 +38,9 @@ The health timeout and response limit must be positive integers. ## HTTP interface - `POST /v1/systemone` forwards the `model`, `state` and `questions` envelope - unchanged. Workers return `choice`, `score` or `noul` decisions; see the + unchanged. Workers return `choice`, `score`, `noul`, `set` and `span` + decisions (the Vela 2.0 worker adds the `set` and `span` kinds with + their Noul views); see the [Jev API reference](https://docs.typesafe.ai/api). - `GET /health` returns the worker's health response, including unhealthy status codes. - Authorization and other end-to-end headers are forwarded. Response status, diff --git a/src/frontend/vela.py b/src/frontend/vela.py index 87e56cc1..67906d23 100644 --- a/src/frontend/vela.py +++ b/src/frontend/vela.py @@ -46,6 +46,10 @@ ), } UNAUTHORIZED_BODY = {"detail": "Missing or invalid API key"} +# Upstream verbatim endpoint bodies; compare_reference.py pins them +# against the live reference app. +HEALTH_BODY = {"status": "ok"} +READY_BODY = {"ready": True} def decide(raw: bytes, executor: Vela2Executor) -> dict[str, Any]: @@ -126,9 +130,9 @@ def do_GET(self): if path == "/health": # Upstream verbatim; the worker binds the port only after # loading and warmup, so answering at all means ready. - self.send_json(200, {"status": "ok"}) + self.send_json(200, HEALTH_BODY) elif path == "/ready": - self.send_json(200, {"ready": True}) + self.send_json(200, READY_BODY) elif path == "/v1/models": if self._unauthorized(): return diff --git a/src/models/vela/README.md b/src/models/vela/README.md index 21888d2c..cba98dbc 100644 --- a/src/models/vela/README.md +++ b/src/models/vela/README.md @@ -5,11 +5,13 @@ This directory contains the System1-Omni integration slice for (`vela2-unified`). It is a reference-serving boundary only: a Python worker that runs the checkpoint's own pinned runtime for text state and all five typed question kinds (`choice`, `noul`, `score`, `span`, `set`) -on `/v1/systemone`. Native execution is not part of this slice; per the -frozen RFC this worker is the Phase-1 entry that freezes the -text/token_ids input, the position-level final-hidden readout contract, -the error/length boundaries and the parity baseline for later -ModernBERT-profile work. +on `/v1/systemone`. Native execution is not part of this slice; this +worker freezes the text/token_ids input, the position-level final-hidden +readout contract, the error/length boundaries and the parity baseline +for later ModernBERT-profile work. The CPU half of that native route — +the 0.3B ModernBERT profile registration, the checkpoint census and the +YaRN tables — lives in [`native/`](native/README.md), whose +`cuda`-gated executor runs the encoder on the regenerated bundle. The shared Vela 2.0 wire module is deliberately shaped for the family: the envelope admission (`protocol`), the pinned-runtime error mapping @@ -27,7 +29,7 @@ this slice ships the 0.3B pinned executor (`engine`). The hybrid members | Runtime | the checkpoint's own flat files: `vela2_inference.py`, `modeling_vela2.py`, `configuration_vela2.py`, `vela2_serve.py` | | Calibration | the package's own `calibration.json` (never cross-copied between members) | | License | Apache-2.0; the inherited tokenizer material carries Gemma Terms of Use | -| Runtime dependencies | [recipe/vela/requirements.txt](../../recipe/vela/requirements.txt) (torch, transformers, safetensors, tokenizers, numpy) | +| Runtime dependencies | [recipe/vela/requirements.txt](../../../recipe/vela/requirements.txt) (torch, transformers, safetensors, tokenizers, numpy) | The package ships no manifest, so the executor's startup gate checks the config's structural identity (model type, architecture, `max_length` @@ -195,4 +197,4 @@ the shared worker-contract issue, and nothing is posted upstream. flash-linear-attention) is the oracle setting; GPU bf16-autocast is a deployment option outside this slice's evidence. - The serving procedure, hardware requirements and the recorded - evidence pointer are [recipe/vela/reference.md](../../recipe/vela/reference.md). + evidence pointer are [recipe/vela/reference.md](../../../recipe/vela/reference.md). diff --git a/src/models/vela/engine.py b/src/models/vela/engine.py index ee8fc7c6..eaf2c9b9 100644 --- a/src/models/vela/engine.py +++ b/src/models/vela/engine.py @@ -25,7 +25,6 @@ import json import sys import threading -from importlib.machinery import EXTENSION_SUFFIXES, SOURCE_SUFFIXES from pathlib import Path from typing import Any @@ -54,9 +53,6 @@ # import of any of them from another package directory would silently # mix two runtimes once this package's root goes first on ``sys.path``. RUNTIME_MODULES = tuple(name[:-3] for name in PINNED_FILES_SHA256 if name.endswith(".py")) -# Every module-file form importable from a plain sys.path entry: source, -# sourceless and CPython extension suffixes. -IMPORTABLE_FILE_SUFFIXES = frozenset(SOURCE_SUFFIXES) | {".pyc"} | frozenset(EXTENSION_SUFFIXES) # A warmup that touches every question kind's readout path (cosine + MLP # option heads, the span head and the span decoder, the set sigmoid and # threshold echo). Model text only; the result is discarded. @@ -133,11 +129,17 @@ def verify_files(root: Path, table: dict[str, str] | None = None) -> list[str]: files this process is about to execute or read as calibration, so there is no size shortcut. It also refuses importable entries the table does not name — the package root is about to go first on - ``sys.path``, where an extra module file (source, sourceless or - extension) or ``__init__.py`` package would shadow one of the pinned - runtime's dependencies. + ``sys.path``, where an extra ``*.py`` file or ``__init__.py`` + package would shadow one of the pinned runtime's dependencies. A + directory without ``__init__.py`` can shadow the same way as a + namespace package, so any directory carrying importable code at + any depth is refused too; a directory of pure data assets cannot + execute and is left alone. """ + def carries_code(entry: Path) -> bool: + return any(entry.rglob("*.py")) + table = dict(table or PINNED_FILES_SHA256) problems: list[str] = [] for name, digest in sorted(table.items()): @@ -152,10 +154,15 @@ def verify_files(root: Path, table: dict[str, str] | None = None) -> list[str]: for entry in sorted(Path(root).iterdir()): if entry.name in table: continue - if entry.is_file() and entry.suffix in IMPORTABLE_FILE_SUFFIXES: + if entry.is_file() and entry.suffix == ".py": problems.append(f"unexpected importable file {entry.name} in the package root") elif entry.is_dir() and (entry / "__init__.py").is_file(): problems.append(f"unexpected importable package {entry.name}/ in the package root") + elif entry.is_dir() and carries_code(entry): + problems.append( + f"unexpected namespace package {entry.name}/ in the package root: " + "it carries importable code the pin does not name" + ) if problems: raise ValueError("package verification failed: " + "; ".join(problems[:8])) return sorted(table) @@ -185,28 +192,19 @@ def verify_no_foreign_runtime(root: Path) -> None: for name in RUNTIME_MODULES: loaded = sys.modules.get(name) - if loaded is not None and Path(loaded.__file__).resolve().parent != Path(root).resolve(): + if loaded is None: + continue + origin = getattr(loaded, "__file__", None) + if origin is None or Path(origin).resolve().parent != Path(root).resolve(): + # A module without a file is a namespace package or a + # builtin; either way it is not this package's flat module. raise ValueError( - f"a {name} module from another package directory is already " - "imported in this process; serve one package per process" + f"a {name} module from another package directory (or a namespace " + "package) is already imported in this process; " + "serve one package per process" ) -def put_first_on_sys_path(root: Path) -> str: - """Put the resolved package root at the front of ``sys.path``. - - Equivalent entries are dropped, not reused: a ``PYTHONPATH`` copy of - the root listed later would leave foreign directories ahead of it, - and ``verify_no_foreign_runtime`` only guards ``sys.modules``, which - is empty before the first import. - """ - - resolved = Path(root).resolve() - sys.path[:] = [entry for entry in sys.path if Path(entry or ".").resolve() != resolved] - sys.path.insert(0, str(resolved)) - return str(resolved) - - def verify_package(root: Path, table: dict[str, str] | None = None) -> None: """The full pre-import gate for one package directory.""" @@ -225,7 +223,6 @@ def __init__( device: str = "cpu", model_name: str | None = None, batch_size: int | None = None, - token_budget: int | None = None, ) -> None: root = Path(package) if not root.is_dir(): @@ -239,7 +236,12 @@ def __init__( # imported from another directory would silently be the wrong # runtime. verify_no_foreign_runtime(root) - put_first_on_sys_path(root) + root_str = str(root) + # Promote to the front even when already on the path: an earlier + # entry would otherwise win the flat-module import. + if root_str in sys.path: + sys.path.remove(root_str) + sys.path.insert(0, root_str) import vela2_inference self.SystemOneError = vela2_inference.SystemOneError @@ -247,7 +249,6 @@ def __init__( self.engine = vela2_inference.Vela2.from_pretrained( root, backend="torch", device=device, **({"batch_size": batch_size} if batch_size is not None else {}), - **({"token_budget": token_budget} if token_budget is not None else {}), ) self.model_name = model_name or vela2_inference.DEFAULT_MODEL_NAME self.max_length = int(self.engine.max_len) diff --git a/src/models/vela/postprocess.py b/src/models/vela/postprocess.py index 141b6938..abebf79a 100644 --- a/src/models/vela/postprocess.py +++ b/src/models/vela/postprocess.py @@ -24,6 +24,17 @@ # Emitted by the hybrid members only; a 0.3B response carrying it means # the wrong member's runtime answered. HYBRID_ONLY_FIELDS = {"span_heads"} +# The exact answer field sets of the pinned runtime's +# ``system_one_response``: equality, not subset — a missing field is as +# broken as a foreign one. Set and span questions answer through their +# top-level ``sets``/``spans``/``thresholds`` payloads plus Noul views. +ANSWER_FIELDS = { + "noul": {"type", "noul"}, + "choice": {"type", "choice", "confidence", "probabilities"}, + "score": {"type", "score", "confidence", "legend", "probabilities"}, +} +SET_FIELDS = {"selected", "probabilities"} +SPAN_FIELDS = {"label", "start", "end", "text", "probability"} def choice_confidence(probabilities: Sequence[float]) -> float: @@ -97,7 +108,8 @@ def finish_response(questions: Mapping[str, Any], response: dict[str, Any]) -> d The pinned runtime owns every value in this response; these checks only refuse a response that could not be reconstructed for the request that produced it (wrong key set or order, a hybrid-only - field, broken usage accounting, an answer of unexpected shape). + field, broken usage accounting, an answer view that is not an + object or carries fields its kind does not allow). """ if not isinstance(response, dict): @@ -126,6 +138,20 @@ def finish_response(questions: Mapping[str, Any], response: dict[str, Any]) -> d raise RuntimeError(f"{field} do not match the request's {field} questions") elif field in response: raise RuntimeError(f"the pinned runtime returned {field} without {field} questions") + for qid, payload in (response.get("sets") or {}).items(): + if not isinstance(payload, dict) or set(payload) != SET_FIELDS: + raise RuntimeError(f"set {qid!r} does not carry exactly {sorted(SET_FIELDS)}") + for qid, spans in (response.get("spans") or {}).items(): + if not isinstance(spans, list): + raise RuntimeError(f"spans {qid!r} is not a list") + for span in spans: + if not isinstance(span, dict) or set(span) != SPAN_FIELDS: + raise RuntimeError( + f"a span of {qid!r} does not carry exactly {sorted(SPAN_FIELDS)}" + ) + for qid, threshold in (response.get("thresholds") or {}).items(): + if isinstance(threshold, bool) or not isinstance(threshold, (int, float)): + raise RuntimeError(f"threshold {qid!r} is not a number") usage = response["usage"] if ( not isinstance(usage, dict) @@ -138,7 +164,13 @@ def finish_response(questions: Mapping[str, Any], response: dict[str, Any]) -> d for name, answer in answers.items(): if not isinstance(answer, dict): raise RuntimeError(f"answer {name!r} is not an object") - shape = {"type", "noul"} if answer.get("type") == "noul" else None - if shape is not None and not set(answer) <= shape: - raise RuntimeError(f"noul view {name!r} carries unexpected fields") + kind = answer.get("type") + shape = ANSWER_FIELDS.get(kind) if isinstance(kind, str) else None + if shape is None: + raise RuntimeError(f"answer {name!r} has unknown type {kind!r}") + if set(answer) != shape: + raise RuntimeError( + f"answer {name!r} does not carry exactly {sorted(shape)} " + f"(got {sorted(answer)})" + ) return response diff --git a/src/models/vela/protocol.py b/src/models/vela/protocol.py index de5dd2db..93cbfe34 100644 --- a/src/models/vela/protocol.py +++ b/src/models/vela/protocol.py @@ -83,7 +83,7 @@ def parse_body(raw: bytes) -> dict[str, Any]: """Decode a bounded JSON body and reject ambiguous JSON values.""" if len(raw) > MAX_BODY: - raise RequestError("request body exceeds 8 MiB", 413) + raise RequestError("request exceeds the 8 MiB body limit", 413) try: value = json.loads( raw.decode("utf-8"), diff --git a/tests/vela/test_engine_manifest.py b/tests/vela/test_engine_manifest.py index d58b3e3e..526046ee 100644 --- a/tests/vela/test_engine_manifest.py +++ b/tests/vela/test_engine_manifest.py @@ -10,6 +10,7 @@ from __future__ import annotations +import types import hashlib import json import re @@ -27,7 +28,6 @@ PINNED_FILES_SHA256, REFERENCE_REPO, REFERENCE_REVISION, - put_first_on_sys_path, verify_config, verify_files, verify_no_foreign_runtime, @@ -61,14 +61,6 @@ def build_package(root: Path, files: dict[str, bytes], config: dict | None = Non return table_of(files) -def sparse_weights(root: Path, size: int = EXPECTED_WEIGHTS_BYTES) -> None: - """The gate reads ``stat().st_size`` only, so a sparse hole stands - in for the 1.2 GB file instead of a full heap allocation.""" - - with (root / "model.safetensors").open("wb") as stream: - stream.truncate(size) - - def small_files(): return { "vela2_serve.py": b"upstream server stub\n", @@ -97,7 +89,7 @@ def test_verification_accepts_a_package_matching_its_table(tmp_path): assert verify_files(tmp_path, table) == sorted(table) verify_config(tmp_path) # verify_weights needs the exact pinned size; the stub above is short. - sparse_weights(tmp_path) + (tmp_path / "model.safetensors").write_bytes(b"0" * EXPECTED_WEIGHTS_BYTES) verify_package(tmp_path, table) @@ -163,17 +155,6 @@ def test_extra_importable_files_in_the_package_root_are_refused(tmp_path): verify_files(tmp_path, table) -@pytest.mark.parametrize( - "name", - ["torch.pyc", "torch.abi3.so", "_vela.cpython-312-x86_64-linux-gnu.so"], -) -def test_sourceless_and_extension_module_files_are_refused(tmp_path, name): - table = build_package(tmp_path, small_files()) - (tmp_path / name).write_bytes(b"module bytes\n") - with pytest.raises(ValueError, match=f"unexpected importable file {re.escape(name)}"): - verify_files(tmp_path, table) - - def test_extra_package_directories_in_the_package_root_are_refused(tmp_path): table = build_package(tmp_path, small_files()) fla = tmp_path / "fla" @@ -183,6 +164,18 @@ def test_extra_package_directories_in_the_package_root_are_refused(tmp_path): verify_files(tmp_path, table) +def test_namespace_package_directories_with_code_are_refused(tmp_path): + # A directory without __init__.py still shadows a dependency as a + # namespace package once it carries importable code at any depth; + # the pinned package's own data directories (no .py) still pass. + table = build_package(tmp_path, small_files()) + numpy = tmp_path / "numpy" + (numpy / "core").mkdir(parents=True) + (numpy / "core" / "__init__.py").write_text("shadowing namespace code\n") + with pytest.raises(ValueError, match="unexpected namespace package numpy"): + verify_files(tmp_path, table) + + def test_unimportable_package_root_entries_still_pass(tmp_path): # The pinned package's own layout shape: onnx/, assets/, LICENSES/ and # figures/ directories without __init__.py, markdown and license files, @@ -225,32 +218,27 @@ def test_a_runtime_module_from_another_directory_is_refused(tmp_path): sys.modules.pop("modeling_vela2", None) -def test_the_package_root_is_forced_to_the_front_of_sys_path(tmp_path): - # A PYTHONPATH copy of the root listed later must not leave a foreign - # directory ahead of it: verify_no_foreign_runtime cannot catch this, - # because sys.modules is empty before the first import. +def test_a_runtime_module_without_a_file_is_refused(tmp_path): + # A namespace package or builtin resolves with __file__ = None; it + # cannot be this package's flat module, so the gate fails closed + # instead of crashing on the None path. package = tmp_path / "pkg" - shadow = tmp_path / "shadow" package.mkdir() - shadow.mkdir() - resolved = str(package.resolve()) - sys.path.insert(0, str(shadow)) - sys.path.append(str(package)) + build_package(package, small_files()) + sys.modules["vela2_inference"] = types.ModuleType("vela2_inference") try: - assert put_first_on_sys_path(package) == resolved - assert sys.path[0] == resolved - assert resolved not in sys.path[1:] - assert str(shadow) in sys.path + with pytest.raises(ValueError, match="namespace"): + verify_no_foreign_runtime(package) finally: - sys.path[:] = [entry for entry in sys.path if entry not in (str(shadow), resolved)] + sys.modules.pop("vela2_inference", None) def test_weights_are_checked_by_exact_pinned_size(tmp_path): - build_package(tmp_path, small_files()) - sparse_weights(tmp_path) + table = build_package(tmp_path, small_files()) + (tmp_path / "model.safetensors").write_bytes(b"0" * EXPECTED_WEIGHTS_BYTES) verify_weights(tmp_path) - sparse_weights(tmp_path, EXPECTED_WEIGHTS_BYTES - 1) + (tmp_path / "model.safetensors").write_bytes(b"0" * (EXPECTED_WEIGHTS_BYTES - 1)) with pytest.raises(ValueError, match="is 1236473251 bytes"): verify_weights(tmp_path) diff --git a/tests/vela/test_oracle_fixtures.py b/tests/vela/test_oracle_fixtures.py new file mode 100644 index 00000000..8d914f34 --- /dev/null +++ b/tests/vela/test_oracle_fixtures.py @@ -0,0 +1,51 @@ +"""Ties the frozen oracle fixtures to the recipe examples they came from. + +The three oracle fixtures under ``tests/vela/fixtures`` are the frozen +comparison baseline for the later native encoder slices: each one's +``request`` is the recipe's example request, and its recorded +``system_one`` response passes the worker's own response gate. This +keeps the tie mechanical instead of by-convention. No weights, no +torch. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from models.vela.postprocess import finish_response +from models.vela.protocol import MODEL_NAME + +TESTS = Path(__file__).parent +RECIPE = TESTS.parent.parent / "recipe" / "vela" + +CASES = [ + ("oracle-example-request", "example-request.json"), + ("oracle-example-request-cjk", "example-request-cjk.json"), + ("oracle-example-request-set-threshold", "example-request-set-threshold.json"), +] + + +def oracle(case: str) -> dict: + return json.loads((TESTS / "fixtures" / f"{case}.json").read_text()) + + +@pytest.mark.parametrize("case,example", CASES) +def test_the_oracle_request_is_the_recipe_example(case: str, example: str): + recorded = oracle(case) + served = json.loads((RECIPE / example).read_text()) + assert recorded["request"] == served + # Same insertion order, so byte-for-byte serialization holds too. + assert list(recorded["request"]) == list(served) + assert recorded["request"]["model"] == MODEL_NAME + + +@pytest.mark.parametrize("case,example", CASES) +def test_the_recorded_response_passes_the_worker_gate(case: str, example: str): + recorded = oracle(case) + response = recorded["system_one"] + assert finish_response(recorded["request"]["questions"], response) is response + # The frozen case label still names the recipe file it belongs to. + assert recorded["case"] == example.removesuffix(".json") diff --git a/tests/vela/test_postprocess.py b/tests/vela/test_postprocess.py index 9f0fdbf8..e67f1642 100644 --- a/tests/vela/test_postprocess.py +++ b/tests/vela/test_postprocess.py @@ -140,8 +140,22 @@ def test_finish_response_matches_span_and_set_key_sets(): lambda r: r.update(usage={"input_tokens": 5}), lambda r: r.update(usage={"input_tokens": -1, "output_tokens": 0}), lambda r: r.update(usage={"input_tokens": 10, "output_tokens": 3}), - # Answer-shape violations. + # Answer-shape violations: foreign fields, missing fields, and + # unknown kinds — exact key sets per answer kind. lambda r: r["answers"]["billing"].update(probabilities={"yes": 0.75}), + lambda r: r["answers"]["billing"].pop("noul"), + lambda r: r["answers"]["area"].pop("probabilities"), + lambda r: r["answers"]["area"].update(extra=1), + lambda r: r["answers"]["severity"].pop("legend"), + lambda r: r["answers"]["severity"].update(confidence2=0.5), + lambda r: r["answers"].update({"billing": {"type": "abstain"}}), + # Payload-shape violations. + lambda r: r["sets"]["issues"].update(threshold=0.3), + lambda r: r["sets"]["issues"].pop("selected"), + lambda r: r["spans"]["pii"].append({"label": "PERSON", "start": 0}), + lambda r: r["spans"]["pii"][0].update(weight=1.0), + lambda r: r["thresholds"].update({"pii": "0.75"}), + lambda r: r["thresholds"].update({"pii": True}), ], ) def test_finish_response_rejects_contract_violations(mutate): diff --git a/tests/vela/test_worker_server.py b/tests/vela/test_worker_server.py index 4f7a5780..8d7f29df 100644 --- a/tests/vela/test_worker_server.py +++ b/tests/vela/test_worker_server.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import socket import threading from http.client import HTTPConnection @@ -118,6 +119,23 @@ def test_models_lists_the_served_member_upstream_verbatim(server): } +def test_body_short_of_content_length_answers_400(server): + payload = json.dumps(five_kind_body()).encode("utf-8") + conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=10) + conn.putrequest("POST", "/v1/systemone", skip_accept_encoding=True) + conn.putheader("Content-Type", "application/json") + conn.putheader("Content-Length", str(len(payload) + 64)) + conn.endheaders(payload) + # Half-close: the client will never send the promised remaining bytes. + conn.sock.shutdown(socket.SHUT_WR) + response = conn.getresponse() + raw = response.read() + status = response.status + conn.close() + assert status == 400 + assert json.loads(raw) == {"detail": "incomplete body"} + + def test_valid_five_kind_decision_returns_200(server): status, ctype, raw = post(server, json.dumps(five_kind_body()).encode("utf-8")) assert status == 200 and ctype == "application/json" From f598f17696acfa863d85542726b6297668265a66 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 20:14:39 +0800 Subject: [PATCH 11/21] modernbert: own the bundle dialects in the model crates The family crate keeps only the generic single-object config adapter; the Laya two-file dialect and its registered laya-0.3.20 profile move into omni-laya, matching the model-owned loading rule. HF-side keys the Laya worker never consumed (max_position_embeddings, head_dim) are recorded as unrecognized instead of silently gated. The vela crate keeps its registered profile beside its census inventory, narrows the storage classes so the learned scalars and head biases stay f32 (rounding them to bf16 would surface as unexplained parity drift), and shares one pinned config fixture across its tests. Crate READMEs and the workspace member list follow. Signed-off-by: Mr.Dong <1042542469@qq.com> --- README.md | 3 +- src/models/laya/Cargo.toml | 4 + src/models/laya/src/config.rs | 14 +- src/models/laya/src/lib.rs | 1 + src/models/laya/src/model.rs | 65 +++-- src/models/laya/src/plan.rs | 12 +- src/models/laya/src/profile.rs | 280 +++++++++++++++++++++ src/models/laya/src/weights.rs | 2 +- src/models/modernbert/README.md | 23 ++ src/models/modernbert/src/adapter.rs | 182 ++------------ src/models/modernbert/src/gate.rs | 99 ++------ src/models/modernbert/src/geometry.rs | 5 +- src/models/modernbert/src/lib.rs | 6 +- src/models/vela/native/README.md | 23 ++ src/models/vela/native/src/config.rs | 4 +- src/models/vela/native/src/lib.rs | 6 +- src/models/vela/native/src/profile.rs | 2 +- src/models/vela/native/src/rope.rs | 33 ++- src/models/vela/native/src/weights.rs | 56 +++-- tests/laya/model.rs | 7 +- tests/laya/model_cache.rs | 23 +- tests/laya/model_dispatch.rs | 13 +- tests/laya/plan.rs | 8 +- tests/laya/profile.rs | 180 ++++++++++++++ tests/modernbert/plan.rs | 84 +++---- tests/modernbert/structure.rs | 309 ++++++++++++------------ tests/vela/native/common/mod.rs | 22 ++ tests/vela/native/data/vela-config.json | 33 +++ tests/vela/native/profile.rs | 54 ++--- tests/vela/native/rope.rs | 38 ++- tests/vela/native/weights.rs | 44 ++-- 31 files changed, 1054 insertions(+), 581 deletions(-) create mode 100644 src/models/laya/src/profile.rs create mode 100644 src/models/modernbert/README.md create mode 100644 src/models/vela/native/README.md create mode 100644 tests/laya/profile.rs create mode 100644 tests/vela/native/common/mod.rs create mode 100644 tests/vela/native/data/vela-config.json diff --git a/README.md b/README.md index b2327ce0..f3cf9d65 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ repository root. | [`docs/`](docs/) | Project documentation and architecture assets. | The frontend, native runtime, the native workers, their shared Qwen3.5/3.8 -prefill implementation and the Laya CUDA backend are Cargo workspace members. +prefill implementation, the ModernBERT family structure crate, the Vela native +profile crate and the Laya CUDA backend are Cargo workspace members. Each model and backend documents its implementation and process boundary. ## Getting Started diff --git a/src/models/laya/Cargo.toml b/src/models/laya/Cargo.toml index c9db17e2..af36ab50 100644 --- a/src/models/laya/Cargo.toml +++ b/src/models/laya/Cargo.toml @@ -53,3 +53,7 @@ path = "../../../tests/laya/batch.rs" [[test]] name = "plan" path = "../../../tests/laya/plan.rs" + +[[test]] +name = "profile" +path = "../../../tests/laya/profile.rs" diff --git a/src/models/laya/src/config.rs b/src/models/laya/src/config.rs index 9055bf12..1b9d05da 100644 --- a/src/models/laya/src/config.rs +++ b/src/models/laya/src/config.rs @@ -1,9 +1,11 @@ use anyhow::{Context, Result, ensure}; -use omni_modernbert::gate; +use omni_modernbert::gate::BackendLimits; use omni_modernbert::structure::ModernBertStructure; use serde::Deserialize; use std::{collections::HashMap, fs, path::Path}; +use crate::profile::{LayaBundleAdapter, laya_0_3_20}; + #[derive(Debug, Deserialize)] pub struct AgentConfig { pub max_len: usize, @@ -50,13 +52,18 @@ pub struct Config { pub encoder: EncoderConfig, /// The admitted family structure; geometry and plans derive from it. pub structure: ModernBertStructure, + /// The registered profile's request envelope, admitted with the + /// structure; the workspace bounds come from here, not inline + /// literals. + pub limits: BackendLimits, } impl Config { pub fn load(dir: &Path) -> Result { let read = |name| fs::read(dir.join(name)).with_context(|| format!("read {name}")); let agent: AgentConfig = serde_json::from_slice(&read("rl_agent_config.json")?)?; - let (structure, _meta) = omni_modernbert::LayaBundleAdapter::read(dir)?; - gate::laya_0_3_20().admit(&structure)?; + let (structure, _meta) = LayaBundleAdapter::read(dir)?; + let profile = laya_0_3_20(); + profile.admit(&structure)?; ensure!( agent.max_len == 512 && agent.head_max_len == 192 && agent.head_layers == 2, "native Laya supports max_len=512, head_max_len=192, head_layers=2" @@ -74,6 +81,7 @@ impl Config { encoder: EncoderConfig::from(structure.clone()), agent, structure, + limits: profile.limits, }) } } diff --git a/src/models/laya/src/lib.rs b/src/models/laya/src/lib.rs index f99c42dd..b0f42e35 100644 --- a/src/models/laya/src/lib.rs +++ b/src/models/laya/src/lib.rs @@ -5,6 +5,7 @@ pub mod packing; pub mod plan; pub mod preprocess; pub mod processing; +pub mod profile; pub mod weights; #[cfg(feature = "serve")] diff --git a/src/models/laya/src/model.rs b/src/models/laya/src/model.rs index 31871f3e..8212c525 100644 --- a/src/models/laya/src/model.rs +++ b/src/models/laya/src/model.rs @@ -2,12 +2,13 @@ use crate::config::Config; use crate::packing::Batch; use crate::plan::{PlannedArg, PlannedStep, Scratch, plan_encoder}; -use omni_modernbert::geometry::EncoderScratch; -use omni_modernbert::structure::ModernBertStructure; use crate::weights::Weights; use anyhow::{Context, Result, ensure}; use half::bf16; use omni_cuda::{Buffer, Cuda, Graph, Kernel, Ptr}; +use omni_modernbert::gate::BackendLimits; +use omni_modernbert::geometry::EncoderScratch; +use omni_modernbert::structure::ModernBertStructure; use std::{ collections::{HashMap, VecDeque}, fs, @@ -139,11 +140,13 @@ struct Workspace { actions: Buffer, } impl Workspace { - fn sizes(b: usize, l: usize, structure: &ModernBertStructure) -> Result<[usize; 17]> { - ensure!( - b.is_power_of_two() && b <= 16 && (16..=512).contains(&l) && l.is_multiple_of(16), - "invalid workspace shape" - ); + fn sizes( + b: usize, + l: usize, + structure: &ModernBertStructure, + limits: &BackendLimits, + ) -> Result<[usize; 17]> { + limits.check_request(b, l)?; let m = b * l; let encoder = EncoderScratch::new(b, l, structure); Ok([ @@ -166,11 +169,22 @@ impl Workspace { 16 * 2 * 2, ]) } - fn required_bytes(b: usize, l: usize, structure: &ModernBertStructure) -> Result { - Ok(Self::sizes(b, l, structure)?.iter().sum()) + fn required_bytes( + b: usize, + l: usize, + structure: &ModernBertStructure, + limits: &BackendLimits, + ) -> Result { + Ok(Self::sizes(b, l, structure, limits)?.iter().sum()) } - fn new(c: &Cuda, b: usize, l: usize, structure: &ModernBertStructure) -> Result { - let sizes = Self::sizes(b, l, structure)?; + fn new( + c: &Cuda, + b: usize, + l: usize, + structure: &ModernBertStructure, + limits: &BackendLimits, + ) -> Result { + let sizes = Self::sizes(b, l, structure, limits)?; let bytes = sizes.iter().sum(); let mut sizes = sizes.into_iter(); let mut alloc = || c.alloc(sizes.next().expect("workspace buffer layout")); @@ -501,9 +515,9 @@ impl Model { batch.b <= 16 && batch.l <= 512 && batch - .input_ids - .iter() - .all(|i| *i >= 0 && (*i as usize) < self.config.structure.vocab_size), + .input_ids + .iter() + .all(|i| *i >= 0 && (*i as usize) < self.config.structure.vocab_size), "invalid packed input" ); let n = batch.markers.len(); @@ -534,7 +548,12 @@ impl Model { batch.markers.iter().map(Vec::len).sum::() <= 2048, "too many markers" ); - let bytes = Workspace::required_bytes(batch.b, batch.l, &self.config.structure)?; + let bytes = Workspace::required_bytes( + batch.b, + batch.l, + &self.config.structure, + &self.config.limits, + )?; let cacheable = self.cache_config.max_shapes > 0 && bytes <= self.cache_config.max_bytes; let mut s = if cacheable { self.eager = None; @@ -556,7 +575,13 @@ impl Model { self.cached_bytes -= evicted.bytes; drop(evicted); } - Workspace::new(&self.cuda, batch.b, batch.l, &self.config.structure)? + Workspace::new( + &self.cuda, + batch.b, + batch.l, + &self.config.structure, + &self.config.limits, + )? } } else { let eager = self @@ -566,7 +591,13 @@ impl Model { // A different fallback shape is dropped before replacement allocation. match eager { Some(workspace) => workspace, - None => Workspace::new(&self.cuda, batch.b, batch.l, &self.config.structure)?, + None => Workspace::new( + &self.cuda, + batch.b, + batch.l, + &self.config.structure, + &self.config.limits, + )?, } }; let (ids, rest) = s.staging.split_at_mut(batch.input_ids.len() * 8); diff --git a/src/models/laya/src/plan.rs b/src/models/laya/src/plan.rs index d4bbff1b..5263d89f 100644 --- a/src/models/laya/src/plan.rs +++ b/src/models/laya/src/plan.rs @@ -103,12 +103,7 @@ fn map_arg(arg: PlanArg) -> PlannedArg { /// stays with eager launches at inference time. pub fn plan_encoder(structure: &ModernBertStructure, original_rope: bool) -> Vec { let mut steps = Vec::new(); - for step in family_encoder_plan( - structure, - &EncoderPlanOptions { - original_rope, - }, - ) { + for step in family_encoder_plan(structure, &EncoderPlanOptions { original_rope }) { match step { PlanStep::Launch { kernel, args } => steps.push(PlannedStep::Launch { kernel, @@ -218,10 +213,7 @@ fn head_plan() -> Vec { scratch(Scratch::Y), ], ); - call( - "residual", - &[scratch(Scratch::X), scratch(Scratch::Y)], - ); + call("residual", &[scratch(Scratch::X), scratch(Scratch::Y)]); } steps.into_inner() } diff --git a/src/models/laya/src/profile.rs b/src/models/laya/src/profile.rs new file mode 100644 index 00000000..9a79bd85 --- /dev/null +++ b/src/models/laya/src/profile.rs @@ -0,0 +1,280 @@ +//! The Laya bundle dialect and its one registered family profile. +//! +//! The two-file recipe bundle (`rl_agent_config.json` + +//! `encoder/config.json`) is a Laya checkpoint format, so it is parsed +//! here rather than in the family crate. The dialect reads exactly the +//! keys the native worker has always admitted: dimensions, explicit +//! `layer_types` schedule, `local_attention`, the two-entry +//! `rope_parameters`, `norm_eps`, `vocab_size`. HF-side keys the +//! worker does not consume (`max_position_embeddings`, `head_dim`) are +//! recorded as unrecognized instead of gated — the bundle's rotary +//! tables exercise 512 positions and the head dim is hidden/heads, as +//! before the family split. + +use anyhow::{Context, Result, ensure}; +use omni_modernbert::gate::{BackendLimits, RegisteredProfile}; +use omni_modernbert::structure::{ + Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, +}; +use serde_json::Value as Json; +use std::collections::BTreeMap; +use std::path::Path; + +/// Reads the two-file Laya bundle layout: the encoder half of +/// `rl_agent_config.json` + `encoder/config.json`. Agent fields are +/// task bundle and stay in the Laya crate; this adapter reads +/// `encoder/config.json` only. +pub struct LayaBundleAdapter; + +impl LayaBundleAdapter { + pub fn read(dir: &Path) -> Result<(ModernBertStructure, SourceMeta)> { + let path = dir.join("encoder/config.json"); + let bytes = std::fs::read(&path).with_context(|| format!("{}", path.display()))?; + let config: Json = + serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?; + parse_laya_bundle(&config, "encoder/config.json") + } +} + +/// Marker and file evidence for one parsed source, mirroring the family +/// adapter's metadata. +#[derive(Debug, Clone, PartialEq)] +pub struct SourceMeta { + pub config_files: Vec, + pub model_type: String, + /// Top-level keys the dialect does not consume. Recorded, never a + /// rejection. + pub unrecognized_keys: Vec, +} + +fn uint(config: &Json, key: &str) -> Result { + config[key] + .as_u64() + .and_then(|v| usize::try_from(v).ok()) + .with_context(|| format!("{key} is missing or not an unsigned integer")) +} + +fn float(config: &Json, key: &str) -> Result { + config[key] + .as_f64() + .with_context(|| format!("{key} is missing or not a number")) +} + +fn require_modernbert(config: &Json) -> Result<()> { + ensure!( + config["model_type"].as_str() == Some("modernbert"), + "model_type {:?} is not modernbert", + config["model_type"] + ); + ensure!( + config["hidden_activation"].as_str() == Some("gelu"), + "hidden_activation {:?} is not gelu", + config["hidden_activation"] + ); + Ok(()) +} + +fn unrecognized(config: &Json, known: &[&str]) -> Vec { + config + .as_object() + .map(|object| { + object + .keys() + .filter(|k| !known.contains(&k.as_str())) + .cloned() + .collect() + }) + .unwrap_or_default() +} + +fn parse_laya_bundle(config: &Json, source: &str) -> Result<(ModernBertStructure, SourceMeta)> { + require_modernbert(config)?; + for key in ["attention_bias", "mlp_bias", "norm_bias"] { + ensure!( + config[key] == Json::Bool(false), + "{key} must be stated and false" + ); + } + let layer_types = config["layer_types"] + .as_array() + .context("layer_types is missing or not an array")?; + let schedule = layer_types + .iter() + .map(|t| { + t.as_str() + .map(AttentionKind::parse) + .unwrap_or_else(|| Err(anyhow::anyhow!("layer_types entry {t:?} is not a string"))) + }) + .collect::>>()?; + let rope = |kind: &str, provenance: &mut BTreeMap| { + let entry = &config["rope_parameters"][kind]; + ensure!( + entry["rope_type"].as_str() == Some("default"), + "rope_parameters.{kind} rope_type {:?} is not default", + entry["rope_type"] + ); + provenance.insert( + format!("rope_{kind}"), + FieldOrigin::Source(format!("{source}#rope_parameters.{kind}")), + ); + Ok(RopeSpec { + theta: entry["rope_theta"].as_f64().with_context(|| { + format!("rope_parameters.{kind}.rope_theta is missing or not a number") + })?, + scaling: None, + }) + }; + let mut provenance = BTreeMap::new(); + let rope_full = rope("full_attention", &mut provenance)?; + let rope_sliding = rope("sliding_attention", &mut provenance)?; + let hidden = uint(config, "hidden_size")?; + let heads = uint(config, "num_attention_heads")?; + provenance.insert( + "max_positions".into(), + FieldOrigin::Default("Laya bundle rotary tables exercise 512 positions"), + ); + provenance.insert( + "head_dim".into(), + FieldOrigin::Derived("hidden_size / num_attention_heads".into()), + ); + for key in [ + "hidden_size", + "intermediate_size", + "num_hidden_layers", + "norm_eps", + "num_attention_heads", + "schedule", + "local_window", + "vocab_size", + ] { + let json_key = match key { + "schedule" => "layer_types", + "local_window" => "local_attention", + _ => key, + }; + provenance.insert( + key.into(), + FieldOrigin::Source(format!("{source}#{json_key}")), + ); + } + provenance.insert( + "type_embedding_rows".into(), + FieldOrigin::Default("the Laya bundle checkpoint carries a [3, hidden] type embedding"), + ); + let structure = ModernBertStructure { + hidden_size: hidden, + intermediate_size: uint(config, "intermediate_size")?, + num_hidden_layers: uint(config, "num_hidden_layers")?, + activation: Activation::Gelu, + norm_eps: float(config, "norm_eps")? as f32, + num_attention_heads: heads, + head_dim: hidden / heads, + schedule, + local_window: uint(config, "local_attention")?, + rope_full, + rope_sliding, + vocab_size: uint(config, "vocab_size")?, + type_embedding_rows: Some(3), + max_positions: 512, + provenance, + }; + structure.validate()?; + let meta = SourceMeta { + config_files: vec![source.into()], + model_type: "modernbert".into(), + unrecognized_keys: unrecognized( + config, + &[ + "model_type", + "hidden_activation", + "attention_bias", + "mlp_bias", + "norm_bias", + "hidden_size", + "intermediate_size", + "num_attention_heads", + "num_hidden_layers", + "vocab_size", + "norm_eps", + "local_attention", + "layer_types", + "rope_parameters", + ], + ), + }; + Ok((structure, meta)) +} + +/// The Laya 0.3.20 encoder as shipped, ABI 1. +pub fn laya_0_3_20() -> RegisteredProfile { + RegisteredProfile { + name: "laya-0.3.20", + structure: laya_structure(), + limits: BackendLimits { + hidden_sizes: vec![1024], + batch_max: 16, + len_min: 16, + len_max: 512, + len_step: 16, + yarn_scaling: false, + }, + backend: "laya_cuda", + abi_version: 1, + } +} + +fn laya_structure() -> ModernBertStructure { + let mut provenance = BTreeMap::new(); + for field in [ + "hidden_size", + "intermediate_size", + "num_hidden_layers", + "norm_eps", + "num_attention_heads", + "head_dim", + "schedule", + "local_window", + "rope_full", + "rope_sliding", + "vocab_size", + "type_embedding_rows", + "max_positions", + ] { + provenance.insert(field.to_string(), origin_of_profile()); + } + ModernBertStructure { + hidden_size: 1024, + intermediate_size: 2624, + num_hidden_layers: 28, + activation: Activation::Gelu, + norm_eps: 1e-5, + num_attention_heads: 16, + head_dim: 64, + schedule: (0..28) + .map(|i| { + if i % 3 == 0 { + AttentionKind::Full + } else { + AttentionKind::Sliding + } + }) + .collect(), + local_window: 128, + rope_full: RopeSpec { + theta: 160000.0, + scaling: None, + }, + rope_sliding: RopeSpec { + theta: 10000.0, + scaling: None, + }, + vocab_size: 50368, + type_embedding_rows: Some(3), + max_positions: 512, + provenance, + } +} + +fn origin_of_profile() -> FieldOrigin { + FieldOrigin::Source("registered profile laya-0.3.20".into()) +} diff --git a/src/models/laya/src/weights.rs b/src/models/laya/src/weights.rs index 5c82ccc2..19858b20 100644 --- a/src/models/laya/src/weights.rs +++ b/src/models/laya/src/weights.rs @@ -96,7 +96,7 @@ pub struct TensorSpec { pub fn checkpoint_tensors() -> Vec { // Encoder tensors come from the family structure; the task head keeps // its own inventory. - let structure = omni_modernbert::gate::laya_0_3_20().structure; + let structure = crate::profile::laya_0_3_20().structure; let mut tensors: Vec<_> = geometry::encoder_tensors(&structure) .into_iter() .map(|(name, shape)| TensorSpec { name, shape }) diff --git a/src/models/modernbert/README.md b/src/models/modernbert/README.md new file mode 100644 index 00000000..a41b1b8d --- /dev/null +++ b/src/models/modernbert/README.md @@ -0,0 +1,23 @@ +# ModernBERT encoder family core + +`omni-modernbert` owns the family data shared by the ModernBERT-based +encoders: structure parameters parsed from checkpoint configs with +per-field provenance ([`structure`](src/structure.rs)), the tensor +geometry derived from a structure ([`geometry`](src/geometry.rs)), +registered capability profiles that decide whether a CUDA bundle may +execute a structure ([`gate`](src/gate.rs)), and the named-kernel +execution plan for the encoder stack ([`plan`](src/plan.rs)). + +The crate is family data only. It never touches CUDA, device memory, or +weight files: loading weights, resolving kernel symbols to device +pointers, and every task head stay with the model crates that consume a +profile. The Laya native worker runs its encoder on this core +([`src/models/laya`](../laya/README.md)), and the Vela 2.0 0.3B native +profile is the second registered family member +([`src/models/vela/native`](../vela/native/README.md)). + +Structure parsing and capability admission are CPU-verifiable without a +checkpoint or device: the tests under +[`tests/modernbert/`](../../../tests/modernbert/) pin adapter +provenance, family invariants, the frozen gate's accept/reject set, and +the serialized execution plans as golden fixtures. diff --git a/src/models/modernbert/src/adapter.rs b/src/models/modernbert/src/adapter.rs index f61ce7e8..f5ac01e2 100644 --- a/src/models/modernbert/src/adapter.rs +++ b/src/models/modernbert/src/adapter.rs @@ -4,7 +4,6 @@ use crate::structure::{ use anyhow::{Context, Result, ensure}; use serde_json::Value as Json; use std::collections::BTreeMap; -use std::path::Path; /// Marker and file evidence for one parsed source. #[derive(Debug, Clone, PartialEq)] @@ -17,29 +16,17 @@ pub struct SourceMeta { pub unrecognized_keys: Vec, } -/// Reads the two-file Laya bundle layout: the encoder half of -/// `rl_agent_config.json` + `encoder/config.json`. Agent fields are task -/// bundle and stay with the Laya crate; this adapter reads -/// `encoder/config.json` only. -pub struct LayaBundleAdapter; - -impl LayaBundleAdapter { - pub fn read(dir: &Path) -> Result<(ModernBertStructure, SourceMeta)> { - let path = dir.join("encoder/config.json"); - let bytes = std::fs::read(&path).with_context(|| format!("{}", path.display()))?; - let config: Json = serde_json::from_slice(&bytes) - .with_context(|| format!("parse {}", path.display()))?; - parse_laya_bundle(&config, "encoder/config.json") - } -} - /// Reads any single-object ModernBERT config: a stock HF /// `config.json` or a member object such as the Vela bundle's -/// `encoder_config`. +/// `encoder_config`. Dialect bundle layouts (for example the Laya +/// two-file recipe) are parsed by the model crates that ship them. pub struct ModernBertConfigAdapter; impl ModernBertConfigAdapter { - pub fn from_value(config: &Json, source_name: &str) -> Result<(ModernBertStructure, SourceMeta)> { + pub fn from_value( + config: &Json, + source_name: &str, + ) -> Result<(ModernBertStructure, SourceMeta)> { parse_hf_config(config, source_name) } } @@ -106,136 +93,6 @@ fn derive_head_dim( Ok(head_dim) } -fn parse_laya_bundle(config: &Json, source: &str) -> Result<(ModernBertStructure, SourceMeta)> { - require_modernbert(config)?; - for key in ["attention_bias", "mlp_bias", "norm_bias"] { - ensure!( - config[key] == Json::Bool(false), - "{key} must be stated and false" - ); - } - let layer_types = config["layer_types"] - .as_array() - .context("layer_types is missing or not an array")?; - let schedule = layer_types - .iter() - .map(|t| { - t.as_str() - .map(AttentionKind::parse) - .unwrap_or_else(|| Err(anyhow::anyhow!("layer_types entry {t:?} is not a string"))) - }) - .collect::>>()?; - let rope = |kind: &str, provenance: &mut BTreeMap| { - let entry = &config["rope_parameters"][kind]; - ensure!( - entry["rope_type"].as_str() == Some("default"), - "rope_parameters.{kind} rope_type {:?} is not default", - entry["rope_type"] - ); - provenance.insert( - format!("rope_{kind}"), - FieldOrigin::Source(format!("{source}#rope_parameters.{kind}")), - ); - Ok(RopeSpec { - theta: entry["rope_theta"].as_f64().with_context(|| { - format!("rope_parameters.{kind}.rope_theta is missing or not a number") - })?, - scaling: None, - }) - }; - let mut provenance = BTreeMap::new(); - let rope_full = rope("full_attention", &mut provenance)?; - let rope_sliding = rope("sliding_attention", &mut provenance)?; - let hidden = uint(config, "hidden_size")?; - let heads = uint(config, "num_attention_heads")?; - let max_positions = match config.get("max_position_embeddings") { - Some(_) => { - let value = uint(config, "max_position_embeddings")?; - provenance.insert( - "max_positions".into(), - FieldOrigin::Source(format!("{source}#max_position_embeddings")), - ); - value - } - None => { - provenance.insert( - "max_positions".into(), - FieldOrigin::Default("Laya bundle rotary tables exercise 512 positions"), - ); - 512 - } - }; - let head_dim = derive_head_dim(hidden, heads, config, &mut provenance)?; - for key in [ - "hidden_size", - "intermediate_size", - "num_hidden_layers", - "norm_eps", - "num_attention_heads", - "schedule", - "local_window", - "vocab_size", - ] { - let json_key = match key { - "schedule" => "layer_types", - "local_window" => "local_attention", - _ => key, - }; - provenance.insert( - key.into(), - FieldOrigin::Source(format!("{source}#{json_key}")), - ); - } - provenance.insert( - "type_embedding_rows".into(), - FieldOrigin::Default("the Laya bundle checkpoint carries a [3, hidden] type embedding"), - ); - let structure = ModernBertStructure { - hidden_size: hidden, - intermediate_size: uint(config, "intermediate_size")?, - num_hidden_layers: uint(config, "num_hidden_layers")?, - activation: Activation::Gelu, - norm_eps: float(config, "norm_eps")? as f32, - num_attention_heads: heads, - head_dim, - schedule, - local_window: uint(config, "local_attention")?, - rope_full, - rope_sliding, - vocab_size: uint(config, "vocab_size")?, - type_embedding_rows: Some(3), - max_positions, - provenance, - }; - structure.validate()?; - let meta = SourceMeta { - config_files: vec![source.into()], - model_type: "modernbert".into(), - unrecognized_keys: unrecognized( - config, - &[ - "model_type", - "hidden_activation", - "attention_bias", - "mlp_bias", - "norm_bias", - "hidden_size", - "intermediate_size", - "num_attention_heads", - "num_hidden_layers", - "vocab_size", - "norm_eps", - "local_attention", - "layer_types", - "rope_parameters", - "max_position_embeddings", - "head_dim", - ], - ), - }; - Ok((structure, meta)) -} - fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, SourceMeta)> { require_modernbert(config)?; let mut provenance = BTreeMap::new(); @@ -257,11 +114,9 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, let parsed = layer_types .iter() .map(|t| { - t.as_str() - .map(AttentionKind::parse) - .unwrap_or_else(|| { - Err(anyhow::anyhow!("layer_types entry {t:?} is not a string")) - }) + t.as_str().map(AttentionKind::parse).unwrap_or_else(|| { + Err(anyhow::anyhow!("layer_types entry {t:?} is not a string")) + }) }) .collect::>>()?; provenance.insert( @@ -288,8 +143,10 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, }) .collect() }; - let theta = |field: &str, json_key: &str, provenance: &mut BTreeMap| { - match config.get(json_key) { + let theta = + |field: &str, json_key: &str, provenance: &mut BTreeMap| match config + .get(json_key) + { None => { provenance.insert( field.into(), @@ -304,8 +161,7 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, ); float(config, json_key) } - } - }; + }; let theta_full = theta("rope_full", "global_rope_theta", &mut provenance)?; let theta_sliding = theta("rope_sliding", "local_rope_theta", &mut provenance)?; let scaling = match config.get("rope_scaling") { @@ -317,7 +173,10 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, entry["rope_type"] ); ensure!( - entry.get("truncate").map(|v| v == &Json::Bool(true)).unwrap_or(true), + entry + .get("truncate") + .map(|v| v == &Json::Bool(true)) + .unwrap_or(true), "rope_scaling truncate must be true" ); let yarn = YarnScaling { @@ -343,7 +202,10 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, Some(yarn) } }; - if let Some(position_type) = config.get("position_embedding_type").and_then(|v| v.as_str()) { + if let Some(position_type) = config + .get("position_embedding_type") + .and_then(|v| v.as_str()) + { ensure!( position_type == "sans_pos" || position_type == "rope", "position_embedding_type {position_type:?} is not rotary-only" diff --git a/src/models/modernbert/src/gate.rs b/src/models/modernbert/src/gate.rs index 9a8ce81c..f6aa9fc8 100644 --- a/src/models/modernbert/src/gate.rs +++ b/src/models/modernbert/src/gate.rs @@ -123,7 +123,11 @@ impl RegisteredProfile { ensure!( candidate.schedule == expected.schedule, "{}", - field("schedule", render_schedule(candidate), render_schedule(expected)) + field( + "schedule", + render_schedule(candidate), + render_schedule(expected) + ) ); ensure!( candidate.local_window == expected.local_window, @@ -144,7 +148,17 @@ impl RegisteredProfile { ) ); ensure!( - candidate.rope_full == expected.rope_full && candidate.rope_sliding == expected.rope_sliding, + candidate.activation == expected.activation, + "{}", + field( + "activation", + format!("{:?}", candidate.activation), + format!("{:?}", expected.activation) + ) + ); + ensure!( + candidate.rope_full == expected.rope_full + && candidate.rope_sliding == expected.rope_sliding, "{}", field( "rope", @@ -179,7 +193,11 @@ impl RegisteredProfile { expected.max_positions.to_string() ) ); - Ok(structure_identity(candidate, self.backend, self.abi_version)) + Ok(structure_identity( + candidate, + self.backend, + self.abi_version, + )) } } @@ -224,78 +242,3 @@ pub fn structure_identity( digest: format!("{:x}", Sha256::digest(body.as_bytes())), } } - -/// The one profile the current CUDA bundle serves: the Laya 0.3.20 -/// encoder as shipped, ABI 1. -pub fn laya_0_3_20() -> RegisteredProfile { - RegisteredProfile { - name: "laya-0.3.20", - structure: laya_structure(), - limits: BackendLimits { - hidden_sizes: vec![1024], - batch_max: 16, - len_min: 16, - len_max: 512, - len_step: 16, - yarn_scaling: false, - }, - backend: "laya_cuda", - abi_version: 1, - } -} - -fn laya_structure() -> ModernBertStructure { - let mut provenance = std::collections::BTreeMap::new(); - for field in [ - "hidden_size", - "intermediate_size", - "num_hidden_layers", - "norm_eps", - "num_attention_heads", - "head_dim", - "schedule", - "local_window", - "rope_full", - "rope_sliding", - "vocab_size", - "type_embedding_rows", - "max_positions", - ] { - provenance.insert(field.to_string(), origin_of_profile()); - } - ModernBertStructure { - hidden_size: 1024, - intermediate_size: 2624, - num_hidden_layers: 28, - activation: crate::structure::Activation::Gelu, - norm_eps: 1e-5, - num_attention_heads: 16, - head_dim: 64, - schedule: (0..28) - .map(|i| { - if i % 3 == 0 { - crate::structure::AttentionKind::Full - } else { - crate::structure::AttentionKind::Sliding - } - }) - .collect(), - local_window: 128, - rope_full: crate::structure::RopeSpec { - theta: 160000.0, - scaling: None, - }, - rope_sliding: crate::structure::RopeSpec { - theta: 10000.0, - scaling: None, - }, - vocab_size: 50368, - type_embedding_rows: Some(3), - max_positions: 512, - provenance, - } -} - -fn origin_of_profile() -> crate::structure::FieldOrigin { - crate::structure::FieldOrigin::Source("registered profile laya-0.3.20".into()) -} diff --git a/src/models/modernbert/src/geometry.rs b/src/models/modernbert/src/geometry.rs index b6d9acfa..0e6a9cf9 100644 --- a/src/models/modernbert/src/geometry.rs +++ b/src/models/modernbert/src/geometry.rs @@ -96,7 +96,10 @@ impl EncoderScratch { } pub fn sizes(&self) -> Vec { - [self.ids, self.lens, self.x, self.y, self.qkv, self.o, self.gate].into() + [ + self.ids, self.lens, self.x, self.y, self.qkv, self.o, self.gate, + ] + .into() } pub fn total(&self) -> usize { diff --git a/src/models/modernbert/src/lib.rs b/src/models/modernbert/src/lib.rs index 90c1e1b5..48daa370 100644 --- a/src/models/modernbert/src/lib.rs +++ b/src/models/modernbert/src/lib.rs @@ -14,10 +14,10 @@ pub mod geometry; pub mod plan; pub mod structure; -pub use adapter::{LayaBundleAdapter, ModernBertConfigAdapter, SourceMeta}; +pub use adapter::{ModernBertConfigAdapter, SourceMeta}; pub use gate::{BackendLimits, RegisteredProfile, StructureIdentity}; pub use plan::{EncoderPlanOptions, PlanAnchor, PlanArg, PlanStep, ScratchSlot}; pub use structure::{ - Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, YarnScaling, - SCHEMA_VERSION, + Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, SCHEMA_VERSION, + YarnScaling, }; diff --git a/src/models/vela/native/README.md b/src/models/vela/native/README.md new file mode 100644 index 00000000..1556fa9e --- /dev/null +++ b/src/models/vela/native/README.md @@ -0,0 +1,23 @@ +# Vela 2.0 0.3B native profile + +`omni-vela-native` registers the Vela 2.0 0.3B encoder as the second +ModernBERT family profile and owns its CPU-verifiable half: bundle +config parsing (`config`), the registered profile and its bundle +limits (`profile`), the YaRN-scaled rotary tables (`rope`), and the +model-local checkpoint inventory with strict f32 accounting (`weights`). + +Weight upload and encoder execution live behind the crate's `cuda` +feature (`executor`): the admitted structure runs as the family core +plan against the regenerated shared bundle, whose ABI-2 kernels serve +both family profiles — the Laya 1024-hidden geometry under its +unchanged symbols and this profile's 768-hidden geometry under +`_h768`-suffixed names. Everything CPU-side is validated against the +pinned checkpoint's own files; see the tests under +[`tests/vela/native/`](../../../../tests/vela/native/) and the pinned +census and rotary-reference fixtures they consume. + +The serving path today is the Python reference worker documented in +[`src/models/vela`](../README.md) and +[`recipe/vela/reference.md`](../../../../recipe/vela/reference.md); +its frozen parity fixtures are the comparison baseline this native +route will be judged against. diff --git a/src/models/vela/native/src/config.rs b/src/models/vela/native/src/config.rs index 36cb9f2f..21361caa 100644 --- a/src/models/vela/native/src/config.rs +++ b/src/models/vela/native/src/config.rs @@ -11,8 +11,8 @@ use std::path::Path; pub fn read_structure(dir: &Path) -> Result<(ModernBertStructure, SourceMeta)> { let path = dir.join("config.json"); let bytes = std::fs::read(&path).with_context(|| format!("{}", path.display()))?; - let root: Json = serde_json::from_slice(&bytes) - .with_context(|| format!("parse {}", path.display()))?; + let root: Json = + serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?; ensure!( root["model_type"].as_str() == Some("vela2-unified"), "model_type {:?} is not the vela2-unified 0.3B bundle", diff --git a/src/models/vela/native/src/lib.rs b/src/models/vela/native/src/lib.rs index febd30f1..cf85fbf5 100644 --- a/src/models/vela/native/src/lib.rs +++ b/src/models/vela/native/src/lib.rs @@ -1,8 +1,8 @@ //! The Vela 2.0 0.3B model as a ModernBERT profile on the family core: //! bundle config parsing, profile admission, the model-local checkpoint -//! inventory, and the YaRN-scaled rotary tables. Weight upload and -//! encoder execution land with the regenerated CUDA bundle; everything -//! here is CPU-verifiable against the pinned checkpoint's own files. +//! inventory, and the YaRN-scaled rotary tables, all CPU-verifiable +//! against the pinned checkpoint's own files. The `cuda` feature adds +//! weight upload and encoder execution against the regenerated bundle. pub mod config; pub mod profile; diff --git a/src/models/vela/native/src/profile.rs b/src/models/vela/native/src/profile.rs index 3a42ae15..bf9309e8 100644 --- a/src/models/vela/native/src/profile.rs +++ b/src/models/vela/native/src/profile.rs @@ -8,7 +8,7 @@ use omni_modernbert::structure::{ /// recorded tensor census) against the limits of the regenerated CUDA /// bundle this profile targets — hidden 768 instantiations, YaRN rotary /// tables, requests packed to 8 192 tokens. The bundle's own ABI is -/// checked when it is loaded; the executor slice lands with it. +/// checked when it is loaded. pub fn profile() -> RegisteredProfile { RegisteredProfile { name: "vela-2.0-0.3b", diff --git a/src/models/vela/native/src/rope.rs b/src/models/vela/native/src/rope.rs index e7f450dc..24f3d33c 100644 --- a/src/models/vela/native/src/rope.rs +++ b/src/models/vela/native/src/rope.rs @@ -26,10 +26,7 @@ pub fn yarn(rope: &RopeSpec, rotary_half: usize) -> Result { original_max_positions, beta_fast, beta_slow, - } = rope - .scaling - .as_ref() - .context("rope has no yarn scaling")?; + } = rope.scaling.as_ref().context("rope has no yarn scaling")?; let base = rope.theta; let half = rotary_half as f64; let correction_dim = |rotations: f64| { @@ -37,7 +34,14 @@ pub fn yarn(rope: &RopeSpec, rotary_half: usize) -> Result { / (2.0 * base.ln()) }; let low = correction_dim(*beta_fast).floor().max(0.0); - let high = correction_dim(*beta_slow).ceil().min(rotary_half as f64 - 1.0); + // The reference clamps the ramp's upper bound to the head dimension + // (2 * rotary_half) - 1, not to the ramp array's last index: with + // `truncate`, a bound above the array saturates the mask instead of + // rescaling the in-band ramp. Clamping to rotary_half - 1 would + // shrink (high - low) and change every in-band blend. + let high = correction_dim(*beta_slow) + .ceil() + .min(2.0 * rotary_half as f64 - 1.0); ensure!(high > low, "degenerate yarn ramp bounds {low}..{high}"); let ramp = |j: f64| ((j - low) / (high - low)).clamp(0.0, 1.0); let inv_freq = (0..rotary_half) @@ -65,7 +69,10 @@ pub fn cos_sin_tables( structure: &ModernBertStructure, len: usize, ) -> Result<(Vec, Vec, YarnTables)> { - ensure!(len > 0 && len <= structure.max_positions, "table length {len}"); + ensure!( + len > 0 && len <= structure.max_positions, + "table length {len}" + ); let rope = &structure.rope_full; ensure!( structure.rope_full == structure.rope_sliding, @@ -73,8 +80,18 @@ pub fn cos_sin_tables( ); let tables = yarn(rope, omni_modernbert::geometry::rotary_half(structure))?; let half = tables.inv_freq.len(); - let mut cos = Vec::with_capacity(len * half); - let mut sin = Vec::with_capacity(len * half); + // Fail closed on a corrupt structure instead of attempting an + // oversized allocation: `max_positions` is untrusted input until a + // profile admits it. + let elements = len + .checked_mul(half) + .with_context(|| format!("table length {len} overflows the table size"))?; + let mut cos = Vec::new(); + let mut sin = Vec::new(); + cos.try_reserve(elements) + .with_context(|| format!("cannot allocate a {len}x{half} cosine table"))?; + sin.try_reserve(elements) + .with_context(|| format!("cannot allocate a {len}x{half} sine table"))?; let scaling = tables.attention_scaling as f32; for pos in 0..len { for &f in &tables.inv_freq { diff --git a/src/models/vela/native/src/weights.rs b/src/models/vela/native/src/weights.rs index ebd64407..554f379d 100644 --- a/src/models/vela/native/src/weights.rs +++ b/src/models/vela/native/src/weights.rs @@ -4,21 +4,37 @@ use omni_modernbert::structure::ModernBertStructure; use safetensors::{Dtype, SafeTensors}; use std::{collections::BTreeMap, fs::File, path::Path}; -/// Storage class per tensor name, mirroring the bundle's serving -/// conventions: the embedding uploads as f16, every norm stays f32, and -/// the GEMM-class projections narrow to bf16 at upload. The pinned -/// checkpoint stores everything f32; the narrowing is a declared -/// numerics choice carried in the profile identity. +/// Storage class per tensor name, mirroring the Laya baseline's +/// serving classes: the embedding uploads as f16, every norm, the two +/// calibration scalars and the head projection biases stay f32, and +/// only the GEMM-class projections narrow to bf16 at upload. The +/// pinned checkpoint stores everything f32; the narrowing is a +/// declared numerics choice carried in the profile identity — a +/// learned scalar (`log_tau`) or bias is not GEMM-class, and rounding +/// it to bf16 would surface as unexplained drift in the later parity +/// harness. pub fn storage_dtype(name: &str) -> &'static str { if name == "encoder.embeddings.tok_embeddings.weight" { "f16" - } else if name.contains("norm") { + } else if is_f32_class(name) { "f32" } else { "bf16" } } +/// Norms, the two calibration scalars and the head projection biases +/// stay f32 — the Laya baseline keeps its learned temperature and head +/// biases f32 too; a learned scalar or bias is not GEMM-class, and +/// rounding it to bf16 would surface as unexplained drift in the later +/// parity harness. +fn is_f32_class(name: &str) -> bool { + name.contains("norm") + || name == "log_tau" + || name == "log_tau_span" + || (!name.starts_with("encoder.") && name.ends_with(".bias")) +} + /// The head-side tensors of the pinned checkpoint, recorded from its /// safetensors header. They belong to the task heads (the readout /// projections, the classifier MLP, the calibration scalars) and load @@ -50,10 +66,12 @@ pub struct Weights { impl Weights { /// The checkpoint must remain immutable while the mapping exists. pub fn open(path: &Path) -> Result { - let file = File::open(path)?; + let file = + File::open(path).with_context(|| format!("open weights file {}", path.display()))?; // SAFETY: model files are read-only inputs; no mutable mapping is created. - let data = unsafe { Mmap::map(&file)? }; - SafeTensors::deserialize(&data)?; + let data = unsafe { Mmap::map(&file) } + .with_context(|| format!("map weights file {}", path.display()))?; + SafeTensors::deserialize(&data).with_context(|| format!("parse {}", path.display()))?; Ok(Self { data }) } @@ -65,18 +83,20 @@ impl Weights { let tensors = SafeTensors::deserialize(&self.data)?; let mut expected: BTreeMap> = BTreeMap::new(); for (name, shape) in omni_modernbert::geometry::encoder_tensors(structure) { - ensure!(expected.insert(name.clone(), shape).is_none(), "duplicate {name}"); + ensure!( + expected.insert(name.clone(), shape).is_none(), + "duplicate {name}" + ); } for (name, shape) in head_tensors() { - ensure!(expected.insert(name.clone(), shape).is_none(), "duplicate {name}"); + ensure!( + expected.insert(name.clone(), shape).is_none(), + "duplicate {name}" + ); } - let actual: std::collections::BTreeSet = tensors - .names() - .into_iter() - .map(String::from) - .collect(); - let expected_names: std::collections::BTreeSet = - expected.keys().cloned().collect(); + let actual: std::collections::BTreeSet = + tensors.names().into_iter().map(String::from).collect(); + let expected_names: std::collections::BTreeSet = expected.keys().cloned().collect(); if actual != expected_names { let missing: Vec<_> = expected_names.difference(&actual).collect(); let extra: Vec<_> = actual.difference(&expected_names).collect(); diff --git a/tests/laya/model.rs b/tests/laya/model.rs index 48319673..95328fda 100644 --- a/tests/laya/model.rs +++ b/tests/laya/model.rs @@ -57,7 +57,7 @@ fn workspace_sizes_match_the_recorded_profile_layout() { let structure = gate_structure(); for (b, l) in [(1usize, 16usize), (1, 512), (16, 512), (4, 128)] { let m = b * l; - let sizes = Workspace::sizes(b, l, &structure).unwrap(); + let sizes = Workspace::sizes(b, l, &structure, &limits()).unwrap(); assert_eq!( sizes, [ @@ -85,5 +85,8 @@ fn workspace_sizes_match_the_recorded_profile_layout() { } fn gate_structure() -> omni_modernbert::structure::ModernBertStructure { - omni_modernbert::gate::laya_0_3_20().structure + crate::profile::laya_0_3_20().structure +} +fn limits() -> omni_modernbert::gate::BackendLimits { + crate::profile::laya_0_3_20().limits } diff --git a/tests/laya/model_cache.rs b/tests/laya/model_cache.rs index ad981ed6..ecf8268d 100644 --- a/tests/laya/model_cache.rs +++ b/tests/laya/model_cache.rs @@ -1,7 +1,10 @@ use super::*; fn structure() -> omni_modernbert::structure::ModernBertStructure { - omni_modernbert::gate::laya_0_3_20().structure + crate::profile::laya_0_3_20().structure +} +fn limits() -> omni_modernbert::gate::BackendLimits { + crate::profile::laya_0_3_20().limits } fn batch_for(b: usize, l: usize, value: i64) -> Batch { @@ -20,8 +23,8 @@ fn cache_limits_evict_before_allocation_and_allow_exact_fits() { let fixture = fixture::Fixture::new(&[]); let mut model = fixture_model_at(false, fixture.path()); model.graphs = true; - let a = Workspace::required_bytes(1, 16, &structure()).unwrap(); - let b = Workspace::required_bytes(1, 32, &structure()).unwrap(); + let a = Workspace::required_bytes(1, 16, &structure(), &limits()).unwrap(); + let b = Workspace::required_bytes(1, 32, &structure(), &limits()).unwrap(); model.cache_config = CacheConfig { max_shapes: 1, max_bytes: a.max(b), @@ -77,8 +80,8 @@ fn byte_budget_lru_and_disabled_or_oversized_fallback_reuse() { let fixture = fixture::Fixture::new(&[]); let mut model = fixture_model_at(false, fixture.path()); model.graphs = true; - let a = Workspace::required_bytes(1, 16, &structure()).unwrap(); - let b = Workspace::required_bytes(1, 32, &structure()).unwrap(); + let a = Workspace::required_bytes(1, 16, &structure(), &limits()).unwrap(); + let b = Workspace::required_bytes(1, 32, &structure(), &limits()).unwrap(); model.cache_config = CacheConfig { max_shapes: 8, max_bytes: a + b, @@ -145,10 +148,10 @@ fn workspace_estimate_matches_allocation_for_all_supported_shapes() { let weights = unsafe { live() }; for b in [1, 2, 4, 8, 16] { for l in (16..=512).step_by(16) { - let workspace = Workspace::new(&model.cuda, b, l, &structure()).unwrap(); + let workspace = Workspace::new(&model.cuda, b, l, &structure(), &limits()).unwrap(); let allocated = unsafe { live() } - weights; assert_eq!( - Workspace::required_bytes(b, l, &structure()).unwrap(), + Workspace::required_bytes(b, l, &structure(), &limits()).unwrap(), allocated, "B={b}, L={l}" ); @@ -158,7 +161,7 @@ fn workspace_estimate_matches_allocation_for_all_supported_shapes() { } } for (b, l) in [(0, 16), (3, 16), (32, 16), (1, 0), (1, 17), (1, 528)] { - assert!(Workspace::required_bytes(b, l, &structure()).is_err()); + assert!(Workspace::required_bytes(b, l, &structure(), &limits()).is_err()); } } @@ -169,7 +172,7 @@ fn clear_releases_graph_and_eager_workspaces_and_allows_reuse() { model.graphs = true; model.cache_config = CacheConfig { max_shapes: 1, - max_bytes: Workspace::required_bytes(1, 16, &structure()).unwrap(), + max_bytes: Workspace::required_bytes(1, 16, &structure(), &limits()).unwrap(), }; let live = unsafe { model @@ -226,6 +229,6 @@ fn failed_capture_admission_releases_partial_workspace_and_can_retry() { assert_eq!(model.cache.len(), 1); assert_eq!( model.cached_bytes, - Workspace::required_bytes(1, 16, &structure()).unwrap() + Workspace::required_bytes(1, 16, &structure(), &limits()).unwrap() ); } diff --git a/tests/laya/model_dispatch.rs b/tests/laya/model_dispatch.rs index 581d6f05..eb721958 100644 --- a/tests/laya/model_dispatch.rs +++ b/tests/laya/model_dispatch.rs @@ -1,7 +1,10 @@ use super::*; fn structure() -> omni_modernbert::structure::ModernBertStructure { - omni_modernbert::gate::laya_0_3_20().structure + crate::profile::laya_0_3_20().structure +} +fn limits() -> omni_modernbert::gate::BackendLimits { + crate::profile::laya_0_3_20().limits } #[test] @@ -20,7 +23,7 @@ fn prepared_plan_preserves_original_launch_trace_for_all_shape_selectors() { let model = fixture_model_at(original_rope, fixture.path()); for b in [1, 2, 4, 8, 16] { for l in [16, 512] { - let s = Workspace::new(&model.cuda, b, l, &structure()).unwrap(); + let s = Workspace::new(&model.cuda, b, l, &structure(), &limits()).unwrap(); unsafe { reset() }; model.encode_original(&s, original_rope).unwrap(); let expected = unsafe { std::ffi::CStr::from_ptr(trace()) } @@ -45,12 +48,12 @@ fn prepared_plan_preserves_original_launch_trace_for_all_shape_selectors() { fn missing_specialized_attention_only_rejects_its_shape() { let fixture = fixture::Fixture::new(&["OMIT_SPECIALIZED_ATTN"]); let model = fixture_model_at(false, fixture.path()); - let short = Workspace::new(&model.cuda, 1, 16, &structure()).unwrap(); + let short = Workspace::new(&model.cuda, 1, 16, &structure(), &limits()).unwrap(); model.encode(&short).unwrap(); - let generic_long = Workspace::new(&model.cuda, 2, 512, &structure()).unwrap(); + let generic_long = Workspace::new(&model.cuda, 2, 512, &structure(), &limits()).unwrap(); model.encode(&generic_long).unwrap(); for b in [1, 4] { - let specialized = Workspace::new(&model.cuda, b, 512, &structure()).unwrap(); + let specialized = Workspace::new(&model.cuda, b, 512, &structure(), &limits()).unwrap(); assert!( model .encode(&specialized) diff --git a/tests/laya/plan.rs b/tests/laya/plan.rs index 48f30fcd..b59ff74f 100644 --- a/tests/laya/plan.rs +++ b/tests/laya/plan.rs @@ -1,7 +1,13 @@ use omni_laya::plan::{plan_encoder, render_plan}; +// The golden fixtures are the rendered encoder plans of the registered +// Laya 0.3.20 profile: `encoder-plan.txt` drives the rope-table kernel, +// `encoder-plan-original-rope.txt` its `rope_original` reference +// variant. The dispatch trace test in model_dispatch.rs cross-checks +// the same plans against the frozen CUDA-ABI launch sequence. + fn laya_structure() -> omni_modernbert::structure::ModernBertStructure { - omni_modernbert::gate::laya_0_3_20().structure + omni_laya::profile::laya_0_3_20().structure } #[test] diff --git a/tests/laya/profile.rs b/tests/laya/profile.rs new file mode 100644 index 00000000..739f60f3 --- /dev/null +++ b/tests/laya/profile.rs @@ -0,0 +1,180 @@ +use omni_laya::profile::{LayaBundleAdapter, laya_0_3_20}; +use omni_modernbert::structure::{AttentionKind, FieldOrigin}; +use serde_json::{Value, json}; +use std::fs; +use std::path::PathBuf; +use tempfile::{TempDir, tempdir}; + +/// The Laya 0.3.20 bundle's `encoder/config.json` as shipped: the keys +/// the native worker consumes. The HF-side members the dialect ignores +/// (`max_position_embeddings`, `head_dim`) are added by name where a +/// test exercises that behavior. +fn laya_encoder_config() -> Value { + json!({ + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 1024, "intermediate_size": 2624, + "num_attention_heads": 16, "num_hidden_layers": 28, + "vocab_size": 50368, "norm_eps": 0.00001, "local_attention": 128, + "layer_types": (["full_attention", "sliding_attention", "sliding_attention"] + .repeat(10))[..28], + "rope_parameters": { + "full_attention": {"rope_type": "default", "rope_theta": 160000.0}, + "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0} + } + }) +} + +fn laya_bundle(encoder: &Value) -> (TempDir, PathBuf) { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("encoder")).unwrap(); + fs::write(dir.path().join("encoder/config.json"), encoder.to_string()).unwrap(); + let path = dir.path().to_path_buf(); + (dir, path) +} + +fn parse_laya(encoder: &Value) -> omni_modernbert::structure::ModernBertStructure { + let (_dir, path) = laya_bundle(encoder); + LayaBundleAdapter::read(&path).unwrap().0 +} + +#[test] +fn bundle_adapter_parses_the_profile_with_provenance() { + let (_dir, path) = laya_bundle(&laya_encoder_config()); + let (structure, meta) = LayaBundleAdapter::read(&path).unwrap(); + assert_eq!(structure.hidden_size, 1024); + assert_eq!(structure.intermediate_size, 2624); + assert_eq!(structure.num_hidden_layers, 28); + assert_eq!(structure.num_attention_heads, 16); + assert_eq!(structure.head_dim, 64); + assert_eq!(structure.local_window, 128); + assert_eq!(structure.vocab_size, 50368); + assert_eq!(structure.type_embedding_rows, Some(3)); + assert_eq!(structure.rope_full.theta, 160000.0); + assert_eq!(structure.rope_sliding.theta, 10000.0); + assert_eq!(structure.max_positions, 512); + assert_eq!(meta.model_type, "modernbert"); + assert_eq!(meta.config_files, ["encoder/config.json"]); + assert_eq!(meta.unrecognized_keys, Vec::::new()); + assert_eq!( + structure.provenance.get("schedule"), + Some(&FieldOrigin::Source( + "encoder/config.json#layer_types".into() + )) + ); + assert_eq!( + structure.provenance.get("head_dim"), + Some(&FieldOrigin::Derived( + "hidden_size / num_attention_heads".into() + )) + ); + assert!(matches!( + structure.provenance.get("max_positions"), + Some(FieldOrigin::Default(_)) + )); +} + +/// The dialect ignores the HF position and head-dim members the native +/// worker never consumed: a bundle declaring them (as stock HF +/// ModernBERT configs do) still admits with the bundle's own 512 +/// exercised positions and derived head dim, and the keys are recorded +/// as unrecognized instead of gated. +#[test] +fn bundle_adapter_ignores_hf_position_and_head_dim_members() { + let mut config = laya_encoder_config(); + config["max_position_embeddings"] = json!(8192); + config["head_dim"] = json!(32); + let structure = parse_laya(&config); + assert_eq!(structure.max_positions, 512); + assert_eq!(structure.head_dim, 64); + let (_dir, path) = laya_bundle(&config); + let (_, meta) = LayaBundleAdapter::read(&path).unwrap(); + let mut keys = meta.unrecognized_keys.clone(); + keys.sort(); + assert_eq!(keys, ["head_dim", "max_position_embeddings"]); + laya_0_3_20().admit(&structure).unwrap(); +} + +#[test] +fn bundle_adapter_rejects_marker_and_family_violations() { + for (pointer, value) in [ + ("/model_type", json!("bert")), + ("/hidden_activation", json!("relu")), + ("/attention_bias", json!(true)), + ("/mlp_bias", json!(true)), + ("/norm_bias", json!(true)), + ("/layer_types/0", json!("sparse_attention")), + ("/rope_parameters/full_attention/rope_type", json!("linear")), + ("/rope_parameters/sliding_attention/rope_theta", json!(null)), + ] { + let mut bad = laya_encoder_config(); + *bad.pointer_mut(pointer).unwrap() = value; + let (_dir, path) = laya_bundle(&bad); + assert!( + LayaBundleAdapter::read(&path).is_err(), + "accepted {pointer}" + ); + } + let mut missing = laya_encoder_config(); + missing.as_object_mut().unwrap().remove("layer_types"); + let (_dir, path) = laya_bundle(&missing); + assert!(LayaBundleAdapter::read(&path).is_err()); + let mut short = laya_encoder_config(); + short["layer_types"] = json!(["full_attention"]); + let (_dir, path) = laya_bundle(&short); + assert!(LayaBundleAdapter::read(&path).is_err()); +} + +#[test] +fn the_registered_profile_admits_its_structure_and_rejects_field_drift() { + let profile = laya_0_3_20(); + let structure = parse_laya(&laya_encoder_config()); + let identity = profile.admit(&structure).unwrap(); + assert_eq!(identity.schema_version, 1); + assert_eq!(identity.family, "modernbert"); + assert_eq!(identity.backend, "laya_cuda"); + assert_eq!(identity.abi_version, 1); + + for mutate in [ + ("hidden_size", { + let mut s = parse_laya(&laya_encoder_config()); + s.hidden_size = 768; + s + }), + ("rope", { + let mut s = parse_laya(&laya_encoder_config()); + s.rope_sliding.theta = 160000.0; + s + }), + ("schedule", { + let mut s = parse_laya(&laya_encoder_config()); + s.schedule[1] = AttentionKind::Full; + s + }), + ("type_embedding_rows", { + let mut s = parse_laya(&laya_encoder_config()); + s.type_embedding_rows = None; + s + }), + ("max_positions", { + let mut s = parse_laya(&laya_encoder_config()); + s.max_positions = 8192; + s + }), + ] { + assert!(profile.admit(&mutate.1).is_err(), "accepted {}", mutate.0); + } +} + +#[test] +fn the_registered_limits_check_the_request_envelope() { + let limits = &laya_0_3_20().limits; + limits.check_request(16, 512).unwrap(); + limits.check_request(1, 16).unwrap(); + for (batch, len) in [(32, 512), (3, 512), (1, 8), (1, 513), (1, 520)] { + assert!( + limits.check_request(batch, len).is_err(), + "accepted batch {batch} len {len}" + ); + } +} diff --git a/tests/modernbert/plan.rs b/tests/modernbert/plan.rs index 2e20b88b..03b9d2b9 100644 --- a/tests/modernbert/plan.rs +++ b/tests/modernbert/plan.rs @@ -1,34 +1,26 @@ -use omni_modernbert::adapter::{LayaBundleAdapter, ModernBertConfigAdapter}; -use omni_modernbert::plan::{ - self, EncoderPlanOptions, PlanAnchor, PlanArg, PlanStep, ScratchSlot, -}; +use omni_modernbert::adapter::ModernBertConfigAdapter; +use omni_modernbert::plan::{self, EncoderPlanOptions, PlanAnchor, PlanArg, PlanStep, ScratchSlot}; use omni_modernbert::structure::ModernBertStructure; use serde_json::json; -use std::fs; -use tempfile::tempdir; +/// A 1024-hidden member with a type embedding and plain rotary tables, +/// parsed as a stock HF config (the same geometry the Laya bundle +/// dialect produces), so the family plan tests stay model-agnostic. fn laya_structure() -> ModernBertStructure { - let dir = tempdir().unwrap(); - fs::create_dir(dir.path().join("encoder")).unwrap(); - fs::write( - dir.path().join("encoder/config.json"), - json!({ - "model_type": "modernbert", "hidden_activation": "gelu", - "attention_bias": false, "mlp_bias": false, "norm_bias": false, - "hidden_size": 1024, "intermediate_size": 2624, - "num_attention_heads": 16, "num_hidden_layers": 28, - "vocab_size": 50368, "norm_eps": 0.00001, "local_attention": 128, - "layer_types": (["full_attention", "sliding_attention", "sliding_attention"] - .repeat(10))[..28], - "rope_parameters": { - "full_attention": {"rope_type": "default", "rope_theta": 160000.0}, - "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0} - } - }) - .to_string(), - ) - .unwrap(); - LayaBundleAdapter::read(dir.path()).unwrap().0 + let config = json!({ + "model_type": "modernbert", "hidden_activation": "gelu", + "attention_bias": false, "mlp_bias": false, "norm_bias": false, + "hidden_size": 1024, "intermediate_size": 2624, + "num_attention_heads": 16, "num_hidden_layers": 28, + "vocab_size": 50368, "norm_eps": 0.00001, "local_attention": 128, + "type_vocab_size": 3, "max_position_embeddings": 512, + "layer_types": (["full_attention", "sliding_attention", "sliding_attention"] + .repeat(10))[..28], + "global_rope_theta": 160000.0, "local_rope_theta": 10000.0 + }); + ModernBertConfigAdapter::from_value(&config, "config.json") + .unwrap() + .0 } fn vela_structure() -> ModernBertStructure { @@ -63,7 +55,10 @@ fn laya_plan_reproduces_the_encoder_step_sequence() { let steps = plan::encoder_plan(&laya_structure(), &EncoderPlanOptions::default()); // embed + 28 layers x 8 kernels + the type add. assert_eq!(launches(&steps).len(), 1 + 28 * 8 + 1); - assert_eq!(steps[0].render(), "embed ids encoder.embeddings.tok_embeddings.weight encoder.embeddings.norm.weight x y"); + assert_eq!( + steps[0].render(), + "embed ids encoder.embeddings.tok_embeddings.weight encoder.embeddings.norm.weight x y" + ); assert_eq!(steps[1], PlanStep::Anchor(PlanAnchor::AfterEmbed)); // Layer 0 runs full attention on the full-kind tables. @@ -71,10 +66,7 @@ fn laya_plan_reproduces_the_encoder_step_sequence() { steps[2].render(), "qkv y encoder.layers.0.attn.Wqkv.weight zeros.3072 qkv" ); - assert_eq!( - steps[3].render(), - "rope qkv rope_full_cos rope_full_sin" - ); + assert_eq!(steps[3].render(), "rope qkv rope_full_cos rope_full_sin"); assert_eq!(steps[4].render(), "attn_full qkv lens o"); assert_eq!( steps[5].render(), @@ -135,16 +127,25 @@ fn vela_plan_runs_its_own_schedule_without_a_type_add() { assert_eq!(launches(&steps).len(), 1 + 22 * 8); assert!(!steps.iter().any(|s| s.render().contains("type_emb"))); // Layer 21 is the last full-attention layer; zeros follow its width. - assert!(steps.iter().any(|s| s.render() - == "qkv y encoder.layers.21.attn.Wqkv.weight zeros.2304 qkv")); + assert!( + steps + .iter() + .any(|s| s.render() == "qkv y encoder.layers.21.attn.Wqkv.weight zeros.2304 qkv") + ); assert!(steps.iter().any(|s| s.render() == "attn_full qkv lens o")); assert_eq!( - steps.iter().filter(|s| s.render() == "attn_local qkv lens o").count(), + steps + .iter() + .filter(|s| s.render() == "attn_local qkv lens o") + .count(), 14 ); assert!(steps.contains(&PlanStep::Anchor(PlanAnchor::LayerEnd(21)))); assert_eq!( - steps.iter().filter(|s| matches!(s, PlanStep::Anchor(PlanAnchor::LayerEnd(_)))).count(), + steps + .iter() + .filter(|s| matches!(s, PlanStep::Anchor(PlanAnchor::LayerEnd(_)))) + .count(), 22 ); } @@ -157,17 +158,18 @@ fn original_rope_option_selects_the_reference_variant() { original_rope: true, }, ); - assert!(steps.iter().any(|s| s.render() - == "rope_original qkv rope_full_cos rope_full_sin")); + assert!( + steps + .iter() + .any(|s| s.render() == "rope_original qkv rope_full_cos rope_full_sin") + ); } #[test] fn plan_rendering_is_deterministic() { let once = plan::encoder_plan(&laya_structure(), &EncoderPlanOptions::default()); let twice = plan::encoder_plan(&laya_structure(), &EncoderPlanOptions::default()); - let render = |steps: &[PlanStep]| { - steps.iter().map(PlanStep::render).collect::>() - }; + let render = |steps: &[PlanStep]| steps.iter().map(PlanStep::render).collect::>(); assert_eq!(render(&once), render(&twice)); assert_eq!(render(&once).len(), once.len()); } diff --git a/tests/modernbert/structure.rs b/tests/modernbert/structure.rs index ba8d8491..b7d94343 100644 --- a/tests/modernbert/structure.rs +++ b/tests/modernbert/structure.rs @@ -1,24 +1,26 @@ -use omni_modernbert::adapter::{LayaBundleAdapter, ModernBertConfigAdapter}; +use omni_modernbert::adapter::ModernBertConfigAdapter; use omni_modernbert::gate::{self, structure_identity}; use omni_modernbert::geometry::{self, EncoderScratch}; -use omni_modernbert::structure::{AttentionKind, FieldOrigin}; +use omni_modernbert::structure::{ + Activation, AttentionKind, FieldOrigin, ModernBertStructure, RopeSpec, +}; use serde_json::{Value, json}; -use std::fs; -use tempfile::{TempDir, tempdir}; -fn laya_encoder_config() -> Value { +/// A 1024-hidden family member with an explicit schedule, a type +/// embedding and plain rotary tables — the same geometry the Laya +/// bundle dialect produces, expressed as a stock HF config so the +/// family tests stay model-agnostic. +fn family_config() -> Value { json!({ "model_type": "modernbert", "hidden_activation": "gelu", "attention_bias": false, "mlp_bias": false, "norm_bias": false, "hidden_size": 1024, "intermediate_size": 2624, "num_attention_heads": 16, "num_hidden_layers": 28, "vocab_size": 50368, "norm_eps": 0.00001, "local_attention": 128, + "type_vocab_size": 3, "max_position_embeddings": 512, "layer_types": (["full_attention", "sliding_attention", "sliding_attention"] .repeat(10))[..28], - "rope_parameters": { - "full_attention": {"rope_type": "default", "rope_theta": 160000.0}, - "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0} - } + "global_rope_theta": 160000.0, "local_rope_theta": 10000.0 }) } @@ -42,94 +44,12 @@ fn vela_encoder_config() -> Value { }) } -fn laya_bundle(encoder: &Value) -> (TempDir, std::path::PathBuf) { - let dir = tempdir().unwrap(); - fs::create_dir(dir.path().join("encoder")).unwrap(); - fs::write( - dir.path().join("encoder/config.json"), - encoder.to_string(), - ) - .unwrap(); - let path = dir.path().to_path_buf(); - (dir, path) -} - -fn parse_laya(encoder: &Value) -> omni_modernbert::structure::ModernBertStructure { - let (_dir, path) = laya_bundle(encoder); - LayaBundleAdapter::read(&path).unwrap().0 -} - -fn parse_hf(config: &Value) -> omni_modernbert::structure::ModernBertStructure { - ModernBertConfigAdapter::from_value(config, "config.json#encoder_config") +fn parse(config: &Value) -> ModernBertStructure { + ModernBertConfigAdapter::from_value(config, "config.json") .unwrap() .0 } -#[test] -fn laya_bundle_adapter_parses_the_profile_with_provenance() { - let (_dir, path) = laya_bundle(&laya_encoder_config()); - let (structure, meta) = LayaBundleAdapter::read(&path).unwrap(); - assert_eq!(structure.hidden_size, 1024); - assert_eq!(structure.intermediate_size, 2624); - assert_eq!(structure.num_hidden_layers, 28); - assert_eq!(structure.num_attention_heads, 16); - assert_eq!(structure.head_dim, 64); - assert_eq!(structure.local_window, 128); - assert_eq!(structure.vocab_size, 50368); - assert_eq!(structure.type_embedding_rows, Some(3)); - assert_eq!(structure.rope_full.theta, 160000.0); - assert_eq!(structure.rope_sliding.theta, 10000.0); - assert_eq!(structure.max_positions, 512); - assert_eq!(meta.model_type, "modernbert"); - assert_eq!(meta.config_files, ["encoder/config.json"]); - assert_eq!( - structure.provenance.get("schedule"), - Some(&FieldOrigin::Source( - "encoder/config.json#layer_types".into() - )) - ); - assert_eq!( - structure.provenance.get("head_dim"), - Some(&FieldOrigin::Derived( - "hidden_size / num_attention_heads".into() - )) - ); - assert!(matches!( - structure.provenance.get("max_positions"), - Some(FieldOrigin::Default(_)) - )); -} - -#[test] -fn laya_bundle_adapter_rejects_marker_and_family_violations() { - for (pointer, value) in [ - ("/model_type", json!("bert")), - ("/hidden_activation", json!("relu")), - ("/attention_bias", json!(true)), - ("/mlp_bias", json!(true)), - ("/norm_bias", json!(true)), - ("/layer_types/0", json!("sparse_attention")), - ("/rope_parameters/full_attention/rope_type", json!("linear")), - ("/rope_parameters/sliding_attention/rope_theta", json!(null)), - ] { - let mut bad = laya_encoder_config(); - *bad.pointer_mut(pointer).unwrap() = value; - let (_dir, path) = laya_bundle(&bad); - assert!( - LayaBundleAdapter::read(&path).is_err(), - "accepted {pointer}" - ); - } - let mut missing = laya_encoder_config(); - missing.as_object_mut().unwrap().remove("layer_types"); - let (_dir, path) = laya_bundle(&missing); - assert!(LayaBundleAdapter::read(&path).is_err()); - let mut short = laya_encoder_config(); - short["layer_types"] = json!(["full_attention"]); - let (_dir, path) = laya_bundle(&short); - assert!(LayaBundleAdapter::read(&path).is_err()); -} - #[test] fn hf_adapter_parses_the_vela_profile_with_derived_schedule() { let (structure, meta) = @@ -171,14 +91,44 @@ fn hf_adapter_accepts_explicit_layer_types_over_the_interval() { let mut config = vela_encoder_config(); config["global_attn_every_n_layers"] = json!(2); config["layer_types"] = json!((vec!["sliding_attention"; 22])); - let structure = parse_hf(&config); - assert!(structure.schedule.iter().all(|k| *k == AttentionKind::Sliding)); + let structure = parse(&config); + assert!( + structure + .schedule + .iter() + .all(|k| *k == AttentionKind::Sliding) + ); assert!(matches!( structure.provenance.get("schedule"), Some(FieldOrigin::Source(_)) )); } +#[test] +fn hf_adapter_reads_type_vocab_and_position_members() { + let (structure, meta) = + ModernBertConfigAdapter::from_value(&family_config(), "encoder/config.json").unwrap(); + assert_eq!(structure.hidden_size, 1024); + assert_eq!(structure.head_dim, 64); + assert_eq!(structure.type_embedding_rows, Some(3)); + assert_eq!(structure.max_positions, 512); + assert_eq!(structure.rope_full.theta, 160000.0); + assert_eq!(structure.rope_sliding.theta, 10000.0); + assert_eq!(meta.unrecognized_keys, Vec::::new()); + assert_eq!( + structure.provenance.get("type_embedding_rows"), + Some(&FieldOrigin::Source( + "encoder/config.json#type_vocab_size".into() + )) + ); + assert_eq!( + structure.provenance.get("head_dim"), + Some(&FieldOrigin::Derived( + "hidden_size / num_attention_heads".into() + )) + ); +} + #[test] fn hf_adapter_rejects_unknown_variants() { for (name, config) in [ @@ -189,7 +139,9 @@ fn hf_adapter_rejects_unknown_variants() { }), ("no schedule source", { let mut c = vela_encoder_config(); - c.as_object_mut().unwrap().remove("global_attn_every_n_layers"); + c.as_object_mut() + .unwrap() + .remove("global_attn_every_n_layers"); c }), ("scaling type", { @@ -228,11 +180,11 @@ fn hf_adapter_rejects_unknown_variants() { #[test] fn structure_invariants_name_the_violated_field() { - let mut structure = parse_laya(&laya_encoder_config()); + let mut structure = parse(&family_config()); structure.num_attention_heads = 15; assert!(structure.validate().unwrap_err().to_string().contains("15")); - let mut structure = parse_laya(&laya_encoder_config()); + let mut structure = parse(&family_config()); structure.schedule.pop(); let message = structure.validate().unwrap_err().to_string(); assert!(message.contains("schedule"), "{message}"); @@ -243,55 +195,90 @@ fn structure_invariants_name_the_violated_field() { ("vocab_size", 0), ("num_hidden_layers", 0), ] { - let mut structure = parse_laya(&laya_encoder_config()); + let mut structure = parse(&family_config()); match mutate.0 { "local_window" => structure.local_window = mutate.1, "intermediate_size" => structure.intermediate_size = mutate.1, "vocab_size" => structure.vocab_size = mutate.1, _ => structure.num_hidden_layers = mutate.1, } - assert!( - structure.validate().is_err(), - "accepted {} = 0", - mutate.0 - ); + assert!(structure.validate().is_err(), "accepted {} = 0", mutate.0); } - let mut structure = parse_laya(&laya_encoder_config()); + let mut structure = parse(&family_config()); structure.rope_full.theta = 0.0; assert!(structure.validate().is_err()); } +/// A test-local registered profile: the gate is family machinery, so +/// its accept/reject set is pinned here against a synthetic profile, +/// independent of any model crate's registered constants. +fn test_profile() -> gate::RegisteredProfile { + let structure = parse(&family_config()); + gate::RegisteredProfile { + name: "test-profile", + structure, + limits: gate::BackendLimits { + hidden_sizes: vec![1024], + batch_max: 16, + len_min: 16, + len_max: 512, + len_step: 16, + yarn_scaling: false, + }, + backend: "test_backend", + abi_version: 1, + } +} + #[test] -fn laya_profile_admits_its_structure_and_rejects_field_drift() { - let profile = gate::laya_0_3_20(); - let structure = parse_laya(&laya_encoder_config()); +fn admission_accepts_the_registered_structure_and_rejects_drift() { + let profile = test_profile(); + let structure = parse(&family_config()); let identity = profile.admit(&structure).unwrap(); assert_eq!(identity.schema_version, 1); assert_eq!(identity.family, "modernbert"); - assert_eq!(identity.backend, "laya_cuda"); + assert_eq!(identity.backend, "test_backend"); assert_eq!(identity.abi_version, 1); - let vela = parse_hf(&vela_encoder_config()); + let vela = parse(&vela_encoder_config()); let message = profile.admit(&vela).unwrap_err().to_string(); assert!(message.contains("hidden_size 768"), "{message}"); assert!(message.contains("1024"), "{message}"); - let mut drifted = parse_laya(&laya_encoder_config()); - drifted.rope_sliding.theta = 160000.0; - assert!(profile.admit(&drifted).is_err()); - - let mut drifted = parse_laya(&laya_encoder_config()); - drifted.schedule[1] = AttentionKind::Full; - assert!(profile.admit(&drifted).is_err()); - - let mut drifted = parse_laya(&laya_encoder_config()); - drifted.type_embedding_rows = None; - assert!(profile.admit(&drifted).is_err()); + for mutate in [ + ("rope", { + let mut s = parse(&family_config()); + s.rope_sliding.theta = 160000.0; + s + }), + ("schedule", { + let mut s = parse(&family_config()); + s.schedule[1] = AttentionKind::Full; + s + }), + ("type_embedding_rows", { + let mut s = parse(&family_config()); + s.type_embedding_rows = None; + s + }), + ("norm_eps", { + let mut s = parse(&family_config()); + s.norm_eps = 1e-6; + s + }), + ("max_positions", { + let mut s = parse(&family_config()); + s.max_positions = 8192; + s + }), + ] { + assert!(profile.admit(&mutate.1).is_err(), "accepted {}", mutate.0); + } } #[test] fn backend_limits_check_the_request_envelope() { - let limits = &gate::laya_0_3_20().limits; + let limits = &test_profile().limits; limits.check_request(16, 512).unwrap(); limits.check_request(1, 16).unwrap(); for (batch, len) in [(32, 512), (3, 512), (1, 8), (1, 513), (1, 520)] { @@ -304,40 +291,40 @@ fn backend_limits_check_the_request_envelope() { #[test] fn structure_identity_is_stable_and_sensitive() { - let laya = parse_laya(&laya_encoder_config()); - let again = parse_laya(&laya_encoder_config()); + let member = parse(&family_config()); + let again = parse(&family_config()); assert_eq!( - structure_identity(&laya, "laya_cuda", 1), + structure_identity(&member, "laya_cuda", 1), structure_identity(&again, "laya_cuda", 1) ); - let mut drifted = laya.clone(); + let mut drifted = member.clone(); drifted.rope_sliding.theta = 160000.0; assert_ne!( - structure_identity(&laya, "laya_cuda", 1), + structure_identity(&member, "laya_cuda", 1), structure_identity(&drifted, "laya_cuda", 1) ); - let vela = parse_hf(&vela_encoder_config()); + let vela = parse(&vela_encoder_config()); assert_ne!( - structure_identity(&laya, "laya_cuda", 1), + structure_identity(&member, "laya_cuda", 1), structure_identity(&vela, "laya_cuda", 1) ); assert_ne!( - structure_identity(&laya, "laya_cuda", 1), - structure_identity(&laya, "laya_cuda", 2) + structure_identity(&member, "laya_cuda", 1), + structure_identity(&member, "laya_cuda", 2) ); // Provenance is not part of identity: same semantics, same digest. - let mut reworded = laya.clone(); + let mut reworded = member.clone(); reworded.provenance.clear(); assert_eq!( - structure_identity(&laya, "laya_cuda", 1), + structure_identity(&member, "laya_cuda", 1), structure_identity(&reworded, "laya_cuda", 1) ); } #[test] -fn derived_geometry_matches_the_laya_checkpoint_inventory() { - let laya = parse_laya(&laya_encoder_config()); - let tensors = geometry::encoder_tensors(&laya); +fn derived_geometry_matches_the_1024_member_inventory() { + let member = parse(&family_config()); + let tensors = geometry::encoder_tensors(&member); assert_eq!(tensors.len(), 171); assert_eq!(tensors.len(), 3 + 28 * 5 + 27 + 1); let find = |name: &str| tensors.iter().find(|(n, _)| n == name).unwrap(); @@ -346,10 +333,7 @@ fn derived_geometry_matches_the_laya_checkpoint_inventory() { vec![50368, 1024] ); assert_eq!(find("encoder.layers.0.mlp_norm.weight").1, vec![1024]); - assert_eq!( - find("encoder.layers.0.mlp.Wi.weight").1, - vec![5248, 1024] - ); + assert_eq!(find("encoder.layers.0.mlp.Wi.weight").1, vec![5248, 1024]); let attn_norm_count = tensors .iter() .filter(|(name, _)| name.ends_with("attn_norm.weight")) @@ -358,7 +342,7 @@ fn derived_geometry_matches_the_laya_checkpoint_inventory() { assert_eq!(tensors.last().unwrap().0, "type_emb.weight"); assert_eq!(tensors.last().unwrap().1, vec![3, 1024]); - let vela = parse_hf(&vela_encoder_config()); + let vela = parse(&vela_encoder_config()); let vela_tensors = geometry::encoder_tensors(&vela); assert_eq!(vela_tensors.len(), 3 + 22 * 5 + 21); let find_vela = |name: &str| vela_tensors.iter().find(|(n, _)| n == name).unwrap(); @@ -366,25 +350,50 @@ fn derived_geometry_matches_the_laya_checkpoint_inventory() { find_vela("encoder.layers.0.mlp.Wi.weight").1, vec![2304, 768] ); - assert!(vela_tensors.iter().all(|(name, _)| name != "type_emb.weight")); + assert!( + vela_tensors + .iter() + .all(|(name, _)| name != "type_emb.weight") + ); - assert_eq!(geometry::rotary_half(&laya), 32); - assert_eq!(geometry::rope_table_bytes(&laya), 512 * 32 * 4); + assert_eq!(geometry::rotary_half(&member), 32); + assert_eq!(geometry::rope_table_bytes(&member), 512 * 32 * 4); } #[test] fn encoder_scratch_sizes_follow_the_structure() { - let laya = parse_laya(&laya_encoder_config()); - let scratch = EncoderScratch::new(1, 512, &laya); + let member = parse(&family_config()); + let scratch = EncoderScratch::new(1, 512, &member); assert_eq!( scratch.sizes(), - vec![512 * 8, 4, 512 * 1024 * 4, 512 * 1024 * 2, 512 * 3072 * 2, 512 * 1024 * 2, 512 * 2624 * 2] + vec![ + 512 * 8, + 4, + 512 * 1024 * 4, + 512 * 1024 * 2, + 512 * 3072 * 2, + 512 * 1024 * 2, + 512 * 2624 * 2 + ] ); let total: usize = scratch.sizes().iter().sum(); assert_eq!(scratch.total(), total); - let vela = parse_hf(&vela_encoder_config()); + let vela = parse(&vela_encoder_config()); + assert_eq!(EncoderScratch::new(1, 512, &vela).qkv, 512 * 3 * 768 * 2); +} + +#[test] +fn admitted_structures_carry_the_family_activation() { + // The frozen-equality check includes the activation; today the + // family admits gelu only, and both adapters reject anything else + // at the source, so this pins the field's presence in the IR. + let member = parse(&family_config()); + assert_eq!(member.activation, Activation::Gelu); assert_eq!( - EncoderScratch::new(1, 512, &vela).qkv, - 512 * 3 * 768 * 2 + member.rope_full, + RopeSpec { + theta: 160000.0, + scaling: None + } ); } diff --git a/tests/vela/native/common/mod.rs b/tests/vela/native/common/mod.rs new file mode 100644 index 00000000..16404f1f --- /dev/null +++ b/tests/vela/native/common/mod.rs @@ -0,0 +1,22 @@ +//! Shared fixtures for the vela-native test targets. The root config is +//! the pinned vllm-sr/Vela-2.0-0.3B@d6f03aa9 config, structure-relevant +//! members only; recipe fields stay worker-side. Regenerate it together +//! with the census and yarn references when the pinned revision moves. + +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use tempfile::{TempDir, tempdir}; + +pub fn pinned_config() -> Value { + serde_json::from_str(include_str!("../data/vela-config.json")).unwrap() +} + +/// Writes the pinned root config into a temp bundle dir. Keep the +/// returned `TempDir` alive for as long as the path is read. +pub fn bundle_dir() -> (TempDir, PathBuf) { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("config.json"), pinned_config().to_string()).unwrap(); + let path = dir.path().to_path_buf(); + (dir, path) +} diff --git a/tests/vela/native/data/vela-config.json b/tests/vela/native/data/vela-config.json new file mode 100644 index 00000000..c747b765 --- /dev/null +++ b/tests/vela/native/data/vela-config.json @@ -0,0 +1,33 @@ +{ + "model_type": "vela2-unified", + "format_version": 1, + "encoder_config": { + "model_type": "modernbert", + "hidden_activation": "gelu", + "attention_bias": false, + "mlp_bias": false, + "norm_bias": false, + "hidden_size": 768, + "intermediate_size": 1152, + "num_attention_heads": 12, + "num_hidden_layers": 22, + "vocab_size": 256008, + "norm_eps": 1e-5, + "local_attention": 128, + "global_attn_every_n_layers": 3, + "global_rope_theta": 160000, + "local_rope_theta": 160000, + "max_position_embeddings": 32768, + "position_embedding_type": "sans_pos", + "rope_scaling": { + "rope_type": "yarn", + "factor": 4.0, + "original_max_position_embeddings": 8192, + "beta_fast": 32.0, + "beta_slow": 1.0, + "truncate": true + } + }, + "max_length": 8192, + "proj_dim": 256 +} diff --git a/tests/vela/native/profile.rs b/tests/vela/native/profile.rs index adec9990..fdf3c66b 100644 --- a/tests/vela/native/profile.rs +++ b/tests/vela/native/profile.rs @@ -1,42 +1,21 @@ +mod common; + use omni_modernbert::plan::{self, EncoderPlanOptions, PlanStep}; use omni_vela_native::{profile, read_structure}; use serde_json::json; use std::fs; use tempfile::tempdir; -/// The pinned vllm-sr/Vela-2.0-0.3B@d6f03aa9 root config, structure-relevant -/// members only; recipe fields stay worker-side. -fn bundle_dir() -> std::path::PathBuf { - let config = json!({ - "model_type": "vela2-unified", "format_version": 1, - "encoder_config": { - "model_type": "modernbert", "hidden_activation": "gelu", - "attention_bias": false, "mlp_bias": false, "norm_bias": false, - "hidden_size": 768, "intermediate_size": 1152, - "num_attention_heads": 12, "num_hidden_layers": 22, - "vocab_size": 256008, "norm_eps": 1e-5, - "local_attention": 128, "global_attn_every_n_layers": 3, - "global_rope_theta": 160000, "local_rope_theta": 160000, - "max_position_embeddings": 32768, - "position_embedding_type": "sans_pos", - "rope_scaling": { - "rope_type": "yarn", "factor": 4.0, - "original_max_position_embeddings": 8192, - "beta_fast": 32.0, "beta_slow": 1.0, "truncate": true - } - }, - "max_length": 8192, "proj_dim": 256 - }); - let dir = tempdir().unwrap(); - fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); - let path = dir.path().to_path_buf(); - std::mem::forget(dir); - path +use tempfile::TempDir; + +fn bundle_dir() -> (TempDir, std::path::PathBuf) { + common::bundle_dir() } #[test] fn the_pinned_bundle_parses_into_the_registered_profile() { - let (structure, meta) = read_structure(&bundle_dir()).unwrap(); + let (_keep, path) = bundle_dir(); + let (structure, meta) = read_structure(&path).unwrap(); let identity = profile().admit(&structure).unwrap(); assert_eq!(identity.backend, "laya_cuda"); assert_eq!(identity.abi_version, 2); @@ -50,10 +29,7 @@ fn the_pinned_bundle_parses_into_the_registered_profile() { #[test] fn the_bundle_rejects_foreign_roots_and_encoders() { - for (name, mutate) in [ - ("root class", "vela2-decoder"), - ("encoder class", "bert"), - ] { + for (name, mutate) in [("root class", "vela2-decoder"), ("encoder class", "bert")] { let dir = tempdir().unwrap(); let mut config = json!({ "model_type": "vela2-unified", @@ -86,23 +62,27 @@ fn the_bundle_rejects_foreign_roots_and_encoders() { #[test] fn profile_drift_is_rejected_with_field_diffs() { - let (mut structure, _) = read_structure(&bundle_dir()).unwrap(); + let (_keep, path) = bundle_dir(); + let (mut structure, _) = read_structure(&path).unwrap(); structure.vocab_size = 50368; let message = profile().admit(&structure).unwrap_err().to_string(); assert!(message.contains("vocab_size 50368"), "{message}"); - let (mut structure, _) = read_structure(&bundle_dir()).unwrap(); + let (_keep, path) = bundle_dir(); + let (mut structure, _) = read_structure(&path).unwrap(); structure.schedule[3] = omni_modernbert::AttentionKind::Sliding; assert!(profile().admit(&structure).is_err()); - let (mut structure, _) = read_structure(&bundle_dir()).unwrap(); + let (_keep, path) = bundle_dir(); + let (mut structure, _) = read_structure(&path).unwrap(); structure.rope_full.scaling = None; assert!(profile().admit(&structure).is_err()); } #[test] fn the_encoder_plan_runs_the_profile_schedule_without_a_type_add() { - let (structure, _) = read_structure(&bundle_dir()).unwrap(); + let (_keep, path) = bundle_dir(); + let (structure, _) = read_structure(&path).unwrap(); let steps = plan::encoder_plan(&structure, &EncoderPlanOptions::default()); let launches = steps .iter() diff --git a/tests/vela/native/rope.rs b/tests/vela/native/rope.rs index 3f9b4e3e..147f9286 100644 --- a/tests/vela/native/rope.rs +++ b/tests/vela/native/rope.rs @@ -1,4 +1,4 @@ -use omni_vela_native::{cos_sin_tables, yarn, profile}; +use omni_vela_native::{cos_sin_tables, profile, yarn}; use serde_json::Value; /// Reference tables dumped from the pinned runtime itself: @@ -77,6 +77,42 @@ fn cos_sin_tables_reproduce_the_reference_rows() { assert!(worst_full < 5e-4, "full-range delta {worst_full}"); } +#[test] +fn the_ramp_upper_bound_clamps_to_the_head_dimension_like_the_reference() { + // theta=1000, original_max=8192, beta_slow=1 puts the unclamped + // correction range above the ramp array (high = 34 > 31): the + // reference clamps to head_dim - 1 = 63 and lets the mask saturate + // beyond the array, so the in-band blend denominator stays + // 34 - low. Clamping to rotary_half - 1 would rescale every + // in-band blend. + let spec = omni_modernbert::structure::RopeSpec { + theta: 1000.0, + scaling: Some(omni_modernbert::structure::YarnScaling { + factor: 4.0, + original_max_positions: 8192, + beta_fast: 32.0, + beta_slow: 1.0, + }), + }; + let tables = yarn(&spec, 32).unwrap(); + let correction = |rotations: f64| { + (2.0 * 32.0 * (8192.0f64 / (rotations * std::f64::consts::TAU)).ln()) / (2.0 * 1000f64.ln()) + }; + let low = correction(32.0).floor().max(0.0); + let high = correction(1.0).ceil().min(2.0 * 32.0 - 1.0); + assert!(high > 31.0, "the case must exceed the ramp array ({high})"); + for (j, actual) in tables.inv_freq.iter().enumerate() { + let j = j as f64; + let pos_freq = 1000.0f64.powf(j / 32.0); + let ramp = ((j - low) / (high - low)).clamp(0.0, 1.0); + let expected = 1.0 / (4.0 * pos_freq) * ramp + 1.0 / pos_freq * (1.0 - ramp); + assert!( + (*actual as f64 - expected).abs() < 1e-6, + "inv_freq[{j}] = {actual}, expected {expected}" + ); + } +} + #[test] fn table_lengths_follow_the_structure_bounds() { let structure = &profile().structure; diff --git a/tests/vela/native/weights.rs b/tests/vela/native/weights.rs index 793cf595..feb0dd74 100644 --- a/tests/vela/native/weights.rs +++ b/tests/vela/native/weights.rs @@ -49,36 +49,14 @@ fn checkpoint(tensors: &Value) -> (TempDir, std::path::PathBuf) { (dir, path) } +mod common; + fn structure() -> omni_modernbert::structure::ModernBertStructure { - let (structure, _) = read_structure(&profile_fixture_dir()).unwrap(); + let (_keep, path) = common::bundle_dir(); + let (structure, _) = read_structure(&path).unwrap(); structure } -fn profile_fixture_dir() -> std::path::PathBuf { - let config = json!({ - "model_type": "vela2-unified", - "encoder_config": { - "model_type": "modernbert", "hidden_activation": "gelu", - "attention_bias": false, "mlp_bias": false, "norm_bias": false, - "hidden_size": 768, "intermediate_size": 1152, - "num_attention_heads": 12, "num_hidden_layers": 22, - "vocab_size": 256008, "norm_eps": 1e-5, "local_attention": 128, - "global_attn_every_n_layers": 3, - "global_rope_theta": 160000, "local_rope_theta": 160000, - "max_position_embeddings": 32768, - "rope_scaling": { - "rope_type": "yarn", "factor": 4.0, - "original_max_position_embeddings": 8192, "truncate": true - } - } - }); - let dir = tempdir().unwrap(); - fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); - let path = dir.path().to_path_buf(); - std::mem::forget(dir); - path -} - #[test] fn the_geometry_inventory_matches_the_pinned_census_exactly() { let expected = omni_modernbert::geometry::encoder_tensors(&structure()); @@ -106,11 +84,21 @@ fn a_checkpoint_matching_the_profile_validates_completely() { let (_dir, path) = checkpoint(&census()); let weights = Weights::open(&path).unwrap(); assert_eq!(weights.validate(&structure()).unwrap(), 152); - // Storage classes mirror the serving conventions. - assert_eq!(storage_dtype("encoder.embeddings.tok_embeddings.weight"), "f16"); + // Storage classes mirror the Laya baseline: embedding f16, norms, + // calibration scalars and head biases f32, GEMM-class bf16. + assert_eq!( + storage_dtype("encoder.embeddings.tok_embeddings.weight"), + "f16" + ); assert_eq!(storage_dtype("encoder.layers.0.attn_norm.weight"), "f32"); assert_eq!(storage_dtype("encoder.layers.0.attn.Wqkv.weight"), "bf16"); assert_eq!(storage_dtype("norm.weight"), "f32"); + assert_eq!(storage_dtype("log_tau"), "f32"); + assert_eq!(storage_dtype("log_tau_span"), "f32"); + assert_eq!(storage_dtype("cls_mlp.3.bias"), "f32"); + assert_eq!(storage_dtype("w_e.bias"), "f32"); + assert_eq!(storage_dtype("cls_mlp.0.weight"), "bf16"); + assert_eq!(storage_dtype("w_o.weight"), "bf16"); let scalar = weights.f32("log_tau", &[]).unwrap(); assert_eq!(scalar.len(), 1); } From 34268935690e249715b9ec780978dc419a306ae4 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 20:14:44 +0800 Subject: [PATCH 12/21] qwen3_5: assert the structure ABI matches the loaded library cuda.rs pins its ABI_VERSION to structure::ABI_VERSION at compile time, so the identity records written from the structure side cannot silently disagree with the bundle the loader accepts. Signed-off-by: Mr.Dong <1042542469@qq.com> --- src/models/qwen3_5/native/src/cuda.rs | 4 ++ src/models/qwen3_5/native/src/structure.rs | 52 +++++++-------- tests/qwen3_5/structure.rs | 76 +++++++++++++--------- 3 files changed, 71 insertions(+), 61 deletions(-) diff --git a/src/models/qwen3_5/native/src/cuda.rs b/src/models/qwen3_5/native/src/cuda.rs index 5e8141de..6232536e 100644 --- a/src/models/qwen3_5/native/src/cuda.rs +++ b/src/models/qwen3_5/native/src/cuda.rs @@ -10,6 +10,10 @@ use anyhow::{Context, Result, bail, ensure}; /// `CS1_ABI_VERSION` in ops.h. const ABI_VERSION: u32 = 6; +// The structure side records the same version in identity records; if +// one bumps without the other, runtime state keyed by structure +// identity would silently disagree with the loaded bundle. +const _: () = assert!(ABI_VERSION == crate::structure::ABI_VERSION as u32); pub const LIBRARY: &str = "libqwen3_5_cuda.so"; /// A `cudaStream_t`. diff --git a/src/models/qwen3_5/native/src/structure.rs b/src/models/qwen3_5/native/src/structure.rs index 8110e3c8..62d70a17 100644 --- a/src/models/qwen3_5/native/src/structure.rs +++ b/src/models/qwen3_5/native/src/structure.rs @@ -114,7 +114,10 @@ impl QwenHybridStructure { object.insert("kv_heads".into(), num(self.kv_heads)); object.insert("head_dim".into(), num(self.head_dim)); object.insert("rotary_half".into(), num(self.rotary_half)); - object.insert("rope_theta".into(), Json::String(self.rope_theta.to_string())); + object.insert( + "rope_theta".into(), + Json::String(self.rope_theta.to_string()), + ); object.insert( "mrope_section".into(), Json::Array( @@ -151,10 +154,7 @@ impl QwenHybridStructure { Json::String(self.attention_bias.to_string()), ); object.insert("rope_type".into(), Json::String(self.rope_type.clone())); - object.insert( - "conv_kernel_dim".into(), - num(self.conv_kernel_dim), - ); + object.insert("conv_kernel_dim".into(), num(self.conv_kernel_dim)); object.insert( "mrope_interleaved".into(), Json::String(self.mrope_interleaved.to_string()), @@ -219,10 +219,6 @@ pub fn read_structure(dir: &Path) -> Result { }) .transpose()?; let num_hidden_layers = int("num_hidden_layers")?; - ensure!( - layer_kinds.len() == num_hidden_layers, - "layer_types does not match num_hidden_layers" - ); let mut provenance = BTreeMap::new(); for field in [ "hidden", @@ -271,17 +267,23 @@ pub fn read_structure(dir: &Path) -> Result { let hidden_act = c["hidden_act"].as_str().map(str::to_string); let attn_output_gate = c["attn_output_gate"].as_bool().unwrap_or(true); let attention_bias = c["attention_bias"].as_bool().unwrap_or(false); - let rope_type = rope["rope_type"] - .as_str() - .unwrap_or("default") - .to_string(); + let rope_type = rope["rope_type"].as_str().unwrap_or("default").to_string(); let mrope_interleaved = rope["mrope_interleaved"].as_bool().unwrap_or(true); + // The layer-count check keeps its base position: after every field + // extraction, before the gate's other checks. linear_conv_kernel_dim + // is read after it, as in the base loader where the gate extracted + // it at its own step. + ensure!( + layer_kinds.len() == num_hidden_layers, + "layer_types does not match num_hidden_layers" + ); + let conv_kernel_dim = int("linear_conv_kernel_dim")?; Ok(QwenHybridStructure { hidden_act, attn_output_gate, attention_bias, rope_type, - conv_kernel_dim: int("linear_conv_kernel_dim")?, + conv_kernel_dim, mrope_interleaved, hidden: int("hidden_size")?, intermediate: int("intermediate_size")?, @@ -317,15 +319,8 @@ pub fn check_abi6(s: &QwenHybridStructure) -> Result<()> { ); ensure!(s.attn_output_gate, "attention without the output gate"); ensure!(!s.attention_bias, "attention with bias"); - ensure!( - s.rope_type == "default", - "rope type {}", - s.rope_type - ); - ensure!( - s.conv_kernel_dim == 4, - "conv kernel is not 4" - ); + ensure!(s.rope_type == "default", "rope type {:?}", s.rope_type); + ensure!(s.conv_kernel_dim == 4, "conv kernel is not 4"); ensure!( s.head_dim == 256 && s.lin_k_dim == 128 && s.lin_v_dim == 128, "head dims {} / {} / {}", @@ -333,11 +328,7 @@ pub fn check_abi6(s: &QwenHybridStructure) -> Result<()> { s.lin_k_dim, s.lin_v_dim ); - ensure!( - s.rotary_half == 32, - "{} rotary dims", - 2 * s.rotary_half - ); + ensure!(s.rotary_half == 32, "{} rotary dims", 2 * s.rotary_half); ensure!( s.rope_theta.is_finite() && s.rope_theta > 0.0, "invalid rope_theta" @@ -418,7 +409,10 @@ pub struct QwenIdentity { pub fn structure_identity(s: &QwenHybridStructure) -> QwenIdentity { let mut body = serde_json::Map::new(); - body.insert("schema_version".into(), Json::String(SCHEMA_VERSION.to_string())); + body.insert( + "schema_version".into(), + Json::String(SCHEMA_VERSION.to_string()), + ); body.insert("family".into(), Json::String(FAMILY.into())); body.insert("backend".into(), Json::String(BACKEND.into())); body.insert("abi_version".into(), Json::String(ABI_VERSION.to_string())); diff --git a/tests/qwen3_5/structure.rs b/tests/qwen3_5/structure.rs index 03b7aa5d..6a3bbe34 100644 --- a/tests/qwen3_5/structure.rs +++ b/tests/qwen3_5/structure.rs @@ -1,7 +1,5 @@ use omni_qwen3_5_native::model::Config; -use omni_qwen3_5_native::structure::{ - self, LayerKind, PrefixLayout, QwenHybridStructure, -}; +use omni_qwen3_5_native::structure::{self, LayerKind, PrefixLayout, QwenHybridStructure}; use serde_json::json; use std::fs; use std::path::Path; @@ -17,7 +15,8 @@ fn hybrid( lin_k_heads: usize, lin_v_heads: usize, ) -> QwenHybridStructure { - let mut s = structure::read_structure(&fixture_dir(default_config())).unwrap(); + let (_keep, path) = fixture_dir(default_config()); + let mut s = structure::read_structure(&path).unwrap(); s.hidden = hidden; s.intermediate = intermediate; s.heads = heads; @@ -69,18 +68,17 @@ fn default_config() -> serde_json::Value { }) } -fn fixture_dir(config: serde_json::Value) -> std::path::PathBuf { +fn fixture_dir(config: serde_json::Value) -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempdir().unwrap(); fs::write(dir.path().join("config.json"), config.to_string()).unwrap(); let path = dir.path().to_path_buf(); - // Keep the directory alive for the test body. - std::mem::forget(dir); - path + (dir, path) } #[test] fn adapter_reads_the_checkpoint_and_records_provenance() { - let s = structure::read_structure(&fixture_dir(default_config())).unwrap(); + let (_keep, path) = fixture_dir(default_config()); + let s = structure::read_structure(&path).unwrap(); assert_eq!(s.hidden, 5120); assert_eq!(s.head_dim, 256); assert_eq!(s.rotary_half, 32); @@ -128,7 +126,10 @@ fn adapter_rejects_broken_sources() { }), ] { assert!( - structure::read_structure(&fixture_dir(config)).is_err(), + { + let (_keep, path) = fixture_dir(config); + structure::read_structure(&path).is_err() + }, "accepted {name}" ); } @@ -136,11 +137,7 @@ fn adapter_rejects_broken_sources() { #[test] fn the_frozen_gate_accepts_the_three_vela_hybrid_backbones() { - for (name, s) in [ - ("0.8B", vela_08b()), - ("4B", vela_4b()), - ("9B", vela_9b()), - ] { + for (name, s) in [("0.8B", vela_08b()), ("4B", vela_4b()), ("9B", vela_9b())] { structure::check_abi6(&s).unwrap_or_else(|e| panic!("{name} rejected: {e}")); } } @@ -149,27 +146,31 @@ fn the_frozen_gate_accepts_the_three_vela_hybrid_backbones() { fn the_frozen_gate_rejects_every_capability_violation() { type Mutation = Box; let rejects: Vec<(&str, Mutation)> = vec![ - ("head_dim", Box::new(|s: &mut QwenHybridStructure| s.head_dim = 128)), + ( + "head_dim", + Box::new(|s: &mut QwenHybridStructure| s.head_dim = 128), + ), ("lin_k_dim", Box::new(|s| s.lin_k_dim = 64)), ("lin_v_dim", Box::new(|s| s.lin_v_dim = 64)), ("conv", Box::new(|s| s.conv_kernel_dim = 5)), - ("hidden_act", Box::new(|s| s.hidden_act = Some("relu".into()))), + ( + "hidden_act", + Box::new(|s| s.hidden_act = Some("relu".into())), + ), ("output gate", Box::new(|s| s.attn_output_gate = false)), ("attention bias", Box::new(|s| s.attention_bias = true)), ("rope type", Box::new(|s| s.rope_type = "linear".into())), ("mrope", Box::new(|s| s.mrope_interleaved = false)), + ("mrope sum", Box::new(|s| s.mrope_section = [11, 11, 11])), + ("rotary", Box::new(|s| s.rotary_half = 16)), ( - "mrope sum", - Box::new(|s| s.mrope_section = [11, 11, 11]), - ), - ( - "rotary", - Box::new(|s| s.rotary_half = 16), - ), - ( - "max positions", + "max positions upper", Box::new(|s| s.max_positions = 1 << 25), ), + ("max positions zero", Box::new(|s| s.max_positions = 0)), + ("rope theta zero", Box::new(|s| s.rope_theta = 0.0)), + ("rope theta negative", Box::new(|s| s.rope_theta = -1.0)), + ("rope theta nan", Box::new(|s| s.rope_theta = f64::NAN)), ( "heads divisibility", Box::new(|s: &mut QwenHybridStructure| { @@ -185,15 +186,26 @@ fn the_frozen_gate_rejects_every_capability_violation() { ), ("hidden alignment", Box::new(|s| s.hidden = 1025)), ]; - assert_eq!(rejects.len(), 15); + assert_eq!(rejects.len(), 19); for (name, mutate) in rejects { - let mut s = structure::read_structure(&fixture_dir(default_config())).unwrap(); + let (_keep, path) = fixture_dir(default_config()); + let mut s = structure::read_structure(&path).unwrap(); mutate(&mut s); - assert!( - structure::check_abi6(&s).is_err(), - "gate accepted {name}" - ); + assert!(structure::check_abi6(&s).is_err(), "gate accepted {name}"); } + let (_keep, path) = fixture_dir(default_config()); + let mut s = structure::read_structure(&path).unwrap(); + s.rope_type = "linear".into(); + let message = structure::check_abi6(&s).unwrap_err().to_string(); + assert!(message.contains("\"linear\""), "{message}"); +} + +#[test] +fn config_load_rejects_a_defective_checkpoint_end_to_end() { + let mut config = default_config(); + config["hidden_act"] = json!("relu"); + let (_keep, path) = fixture_dir(config); + assert!(Config::load(&path).is_err()); } #[test] From 93624ff4e9b3f4fab920776b61c13decd7ba6a4e Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 21:01:07 +0800 Subject: [PATCH 13/21] vela: rustfmt the executor buffer writes Signed-off-by: Mr.Dong <1042542469@qq.com> --- src/models/vela/native/src/executor.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/models/vela/native/src/executor.rs b/src/models/vela/native/src/executor.rs index 395b8aaf..8665fdf5 100644 --- a/src/models/vela/native/src/executor.rs +++ b/src/models/vela/native/src/executor.rs @@ -164,9 +164,7 @@ impl Encoder { let mut resolved = [Arg::Weight(std::ptr::null_mut()); 5]; for (slot, arg) in resolved.iter_mut().zip(&args) { *slot = match arg { - PlanArg::Weight(name) => { - Arg::Weight(weights[name].ptr()) - } + PlanArg::Weight(name) => Arg::Weight(weights[name].ptr()), PlanArg::Scratch(s) => Arg::Scratch(*s), }; } @@ -199,11 +197,17 @@ impl Encoder { let mut staging = vec![0i64; len]; staging[..ids.len()].copy_from_slice(ids); scratch.ids.write( - &staging.iter().flat_map(|v| v.to_le_bytes()).collect::>(), + &staging + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + )?; + scratch.lens.write( + &vec![ids.len() as i32; 1][..] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), )?; - scratch - .lens - .write(&vec![ids.len() as i32; 1][..].iter().flat_map(|v| v.to_le_bytes()).collect::>())?; for step in &self.plan { let pointers: Vec = step.args[..step.count] .iter() From e8b058a6d4e74053fc280db9d1b8cf50075761b8 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 21:48:23 +0800 Subject: [PATCH 14/21] vela: refuse every importable suffix in package verification --- src/models/vela/engine.py | 28 ++++++++++++++++++++-------- tests/vela/test_engine_manifest.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/models/vela/engine.py b/src/models/vela/engine.py index eaf2c9b9..af6355b2 100644 --- a/src/models/vela/engine.py +++ b/src/models/vela/engine.py @@ -25,6 +25,7 @@ import json import sys import threading +from importlib.machinery import BYTECODE_SUFFIXES, EXTENSION_SUFFIXES, SOURCE_SUFFIXES from pathlib import Path from typing import Any @@ -53,6 +54,10 @@ # import of any of them from another package directory would silently # mix two runtimes once this package's root goes first on ``sys.path``. RUNTIME_MODULES = tuple(name[:-3] for name in PINNED_FILES_SHA256 if name.endswith(".py")) +# Every suffix CPython's source, bytecode and extension loaders import +# from a ``sys.path`` entry; an unlisted file or directory entry wearing +# one of them can shadow a dependency once the package root goes first. +IMPORTABLE_SUFFIXES = frozenset(SOURCE_SUFFIXES + BYTECODE_SUFFIXES + EXTENSION_SUFFIXES) # A warmup that touches every question kind's readout path (cosine + MLP # option heads, the span head and the span decoder, the set sigmoid and # threshold echo). Model text only; the result is discarded. @@ -129,16 +134,23 @@ def verify_files(root: Path, table: dict[str, str] | None = None) -> list[str]: files this process is about to execute or read as calibration, so there is no size shortcut. It also refuses importable entries the table does not name — the package root is about to go first on - ``sys.path``, where an extra ``*.py`` file or ``__init__.py`` - package would shadow one of the pinned runtime's dependencies. A - directory without ``__init__.py`` can shadow the same way as a - namespace package, so any directory carrying importable code at - any depth is refused too; a directory of pure data assets cannot - execute and is left alone. + ``sys.path``, where an extra source, bytecode or extension module + (not just ``*.py``) or an ``__init__.py`` package would shadow one + of the pinned runtime's dependencies. A directory without + ``__init__.py`` can shadow the same way as a namespace package, so + any directory carrying importable code at any depth is refused too; + ``__pycache__`` bytecode is the interpreter's cache for the sources + beside it, never reached by ``import`` by name, and a directory of + pure data assets cannot execute; both are left alone. """ def carries_code(entry: Path) -> bool: - return any(entry.rglob("*.py")) + return any( + path.is_file() + and path.suffix in IMPORTABLE_SUFFIXES + and "__pycache__" not in path.parts + for path in entry.rglob("*") + ) table = dict(table or PINNED_FILES_SHA256) problems: list[str] = [] @@ -154,7 +166,7 @@ def carries_code(entry: Path) -> bool: for entry in sorted(Path(root).iterdir()): if entry.name in table: continue - if entry.is_file() and entry.suffix == ".py": + if entry.is_file() and entry.suffix in IMPORTABLE_SUFFIXES: problems.append(f"unexpected importable file {entry.name} in the package root") elif entry.is_dir() and (entry / "__init__.py").is_file(): problems.append(f"unexpected importable package {entry.name}/ in the package root") diff --git a/tests/vela/test_engine_manifest.py b/tests/vela/test_engine_manifest.py index 526046ee..2efdf949 100644 --- a/tests/vela/test_engine_manifest.py +++ b/tests/vela/test_engine_manifest.py @@ -148,10 +148,21 @@ def test_unsafe_pinned_names_are_rejected_before_any_file_access(tmp_path): verify_files(tmp_path, {"../escape.py": "0" * 64}) -def test_extra_importable_files_in_the_package_root_are_refused(tmp_path): +@pytest.mark.parametrize( + "shadow", + [ + "tokenizers.py", + "torch.pyc", + "torch.cpython-312-x86_64-linux-gnu.so", + ], +) +def test_extra_importable_files_in_the_package_root_are_refused(tmp_path, shadow): + # Sourceless bytecode and extension modules import from a sys.path + # entry exactly like source files, so every loader suffix is + # refused, not just .py. table = build_package(tmp_path, small_files()) - (tmp_path / "tokenizers.py").write_text("shadowing module\n") - with pytest.raises(ValueError, match="unexpected importable file tokenizers.py"): + (tmp_path / shadow).write_bytes(b"shadowing module\n") + with pytest.raises(ValueError, match=f"unexpected importable file {re.escape(shadow)}"): verify_files(tmp_path, table) @@ -167,7 +178,7 @@ def test_extra_package_directories_in_the_package_root_are_refused(tmp_path): def test_namespace_package_directories_with_code_are_refused(tmp_path): # A directory without __init__.py still shadows a dependency as a # namespace package once it carries importable code at any depth; - # the pinned package's own data directories (no .py) still pass. + # the pinned package's own data directories (no importables) pass. table = build_package(tmp_path, small_files()) numpy = tmp_path / "numpy" (numpy / "core").mkdir(parents=True) @@ -176,6 +187,17 @@ def test_namespace_package_directories_with_code_are_refused(tmp_path): verify_files(tmp_path, table) +def test_namespace_directories_with_extension_modules_are_refused(tmp_path): + # Deep extension modules import as pkg.mod the same as deep .py + # files, so the namespace-package refusal covers them too. + table = build_package(tmp_path, small_files()) + numpy = tmp_path / "numpy" + (numpy / "core").mkdir(parents=True) + (numpy / "core" / "_multiarray_umath.cpython-312-x86_64-linux-gnu.so").write_bytes(b"elf") + with pytest.raises(ValueError, match="unexpected namespace package numpy"): + verify_files(tmp_path, table) + + def test_unimportable_package_root_entries_still_pass(tmp_path): # The pinned package's own layout shape: onnx/, assets/, LICENSES/ and # figures/ directories without __init__.py, markdown and license files, From 7227fc8d8494a36fb45ff3e450c29cfba2e84346 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 21:48:23 +0800 Subject: [PATCH 15/21] vela: validate token ids before the embedding launch --- src/models/vela/native/src/executor.rs | 8 ++++++++ tests/vela/native/executor.rs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/src/models/vela/native/src/executor.rs b/src/models/vela/native/src/executor.rs index 8665fdf5..3cb98bd0 100644 --- a/src/models/vela/native/src/executor.rs +++ b/src/models/vela/native/src/executor.rs @@ -187,6 +187,14 @@ impl Encoder { pub fn encode(&self, ids: &[i64], positions: &[usize]) -> Result>> { let limits = &profile().limits; ensure!(!ids.is_empty(), "empty sequence"); + // The embedding kernel indexes the token table unchecked, so an + // id outside [0, vocab_size) would fault on device; refuse it + // here before anything is allocated or launched. + ensure!( + ids.iter() + .all(|id| (0..self.structure.vocab_size as i64).contains(id)), + "token id outside the vocabulary" + ); let len = ids.len().next_multiple_of(limits.len_step); limits.check_request(1, len)?; ensure!( diff --git a/tests/vela/native/executor.rs b/tests/vela/native/executor.rs index 82237c11..561cb8f5 100644 --- a/tests/vela/native/executor.rs +++ b/tests/vela/native/executor.rs @@ -23,6 +23,12 @@ fn encoder_runs_and_produces_finite_hidden() { }; let encoder = unsafe { Encoder::load(&checkpoint, &library) }.unwrap(); assert_eq!(encoder.structure.hidden_size, 768); + // The embedding kernel indexes the token table unchecked, so ids + // outside the vocabulary must fail on the host before any launch. + let vocab = encoder.structure.vocab_size as i64; + for bad in [-1, vocab] { + assert!(encoder.encode(&[bad], &[0]).is_err(), "accepted id {bad}"); + } let ids: Vec = (1..=97).collect(); let hidden = encoder.encode(&ids, &[0, 47, 96]).unwrap(); assert_eq!(hidden.len(), 3); From f828d1781543dad87856a9409d0c64e7dc09cf4f Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 20 Oct 2026 21:48:23 +0800 Subject: [PATCH 16/21] modernbert: reject a zero global attention interval --- src/models/modernbert/src/adapter.rs | 6 ++++++ tests/modernbert/structure.rs | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/models/modernbert/src/adapter.rs b/src/models/modernbert/src/adapter.rs index f5ac01e2..86401192 100644 --- a/src/models/modernbert/src/adapter.rs +++ b/src/models/modernbert/src/adapter.rs @@ -127,6 +127,12 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, } else { let every = uint(config, "global_attn_every_n_layers") .context("neither layer_types nor global_attn_every_n_layers is present")?; + // The schedule below tests i % every; a checkpoint config is an + // external startup input, so refuse the zero before it panics. + ensure!( + every > 0, + "global_attn_every_n_layers must be positive, got 0" + ); provenance.insert( "schedule".into(), FieldOrigin::Derived(format!( diff --git a/tests/modernbert/structure.rs b/tests/modernbert/structure.rs index b7d94343..12f910cd 100644 --- a/tests/modernbert/structure.rs +++ b/tests/modernbert/structure.rs @@ -144,6 +144,13 @@ fn hf_adapter_rejects_unknown_variants() { .remove("global_attn_every_n_layers"); c }), + ("zero interval", { + // The derived schedule tests i % every; zero must be an + // error, not a division-by-zero panic. + let mut c = vela_encoder_config(); + c["global_attn_every_n_layers"] = json!(0); + c + }), ("scaling type", { let mut c = vela_encoder_config(); c["rope_scaling"]["rope_type"] = json!("linear"); From f01351263feec4bf44fdd5568023a73ab0721ea6 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 21 Oct 2026 00:27:43 +0800 Subject: [PATCH 17/21] vela: reconcile the worker with the #151 review head The rebase onto model/vela2-reference-serving kept this branch's side of the divergence (the every-importable-suffix package gate with namespace directories, and the endpoint-parity comparison); this commit restores what the reference worker's own review fixes added so nothing from that head is silently reverted: put_first_on_sys_path returns as the resolve-based sys.path promotion that drops equivalent entries, the token_budget executor option comes back, the manifest tests stand in for the 1.2 GB weights with sparse files again, the forced-to-front sys.path test returns, and the score example the #151 head added (four recipe examples, nine default comparison cases) is wired into the oracle fixture test and the support-matrix row. Signed-off-by: Mr.Dong <1042542469@qq.com> --- docs/supported-models.md | 2 +- src/models/vela/engine.py | 24 +++++++++++++++----- tests/vela/test_engine_manifest.py | 35 +++++++++++++++++++++++++++--- tests/vela/test_oracle_fixtures.py | 3 ++- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/docs/supported-models.md b/docs/supported-models.md index d49c6aa6..0e708e63 100644 --- a/docs/supported-models.md +++ b/docs/supported-models.md @@ -24,7 +24,7 @@ Models that are being added are also tracked in issues labeled [new model](https | CLM-v0.1-8B | [Native Rust engine](../src/models/clm/README.md), `clm-run`, in front of a Qwen3-8B `/v1/embeddings` server | Engine-only agreement **4.5e-06** on identical vectors ([#29](https://github.com/ThinkFlowLab/system1-omni/pull/29), [validation](../recipe/clm/native/VALIDATION.md)); contract checks documented ([#23](https://github.com/ThinkFlowLab/system1-omni/pull/23)) | **Validated on compute capability 8.9** ([#29](https://github.com/ThinkFlowLab/system1-omni/pull/29), [validation](../recipe/clm/native/VALIDATION.md)) | Unverified | Rust to build and a separate Qwen3-8B `/v1/embeddings` server for the encoder; the engine itself has no device-specific code | | OmniJev-4B v1.1 | None yet: [checkpoint export and CPU contract](../recipe/omnijev/README.md); the native worker is in progress ([#114](https://github.com/ThinkFlowLab/system1-omni/issues/114)) | Request contract, preparation, heads and finishing checked against the reference ([#120](https://github.com/ThinkFlowLab/system1-omni/pull/120)); no inference | Not yet | Not supported | Python 3.10 reference environment for the export and fixtures | | D3 (Decision 3.0) | [Python reference worker](../recipe/d3/reference.md) running the checkpoint's pinned runtime | Unverified | Author-run on NVIDIA A100 (sm_80): three-kind serving smoke with byte-identical frontend forwarding and reference parity (exact decisions, max delta 0.0, 348 == 348 tokens); evidence in the integration PR. Owner: @MrDongsls. | Unverified | Python 3.12, the [pinned runtime requirements](../recipe/d3/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/d3) (~52 GB) | -| Vela 2.0 0.3B | [Python reference worker](../recipe/vela/reference.md) running the checkpoint's pinned runtime | Author-run on x86 WSL2 (24 cores), CPU FP32: startup with all-kind warmup, the eight-case default comparison (the three recipe examples plus five synthetic rejections), and exact-match reference parity against the pinned upstream server on all eight (three 200s byte-equal incl. key order; four agreed 422s and the agreed 413 overflow); byte-identical frontend forwarding; evidence in the integration PR. Owner: @MrDongsls. | Unverified (bf16-autocast is an untested deployment option) | Not supported | Python 3.12, the [pinned requirements](../recipe/vela/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/Vela-2.0-0.3B) (1.24 GB FP32 weights; ~4.4 GB with ONNX/assets) | +| Vela 2.0 0.3B | [Python reference worker](../recipe/vela/reference.md) running the checkpoint's pinned runtime | Author-run on x86 WSL2 (24 cores), CPU FP32: startup with all-kind warmup, the nine-case default comparison (the four recipe examples plus five synthetic rejections), and exact-match reference parity against the pinned upstream server on all nine (four 200s byte-equal incl. key order; four agreed 422s and the agreed 413 overflow); byte-identical frontend forwarding; evidence in the integration PR. Owner: @MrDongsls. | Unverified (bf16-autocast is an untested deployment option) | Not supported | Python 3.12, the [pinned requirements](../recipe/vela/requirements.txt), the [pinned package](https://huggingface.co/vllm-sr/Vela-2.0-0.3B) (1.24 GB FP32 weights; ~4.4 GB with ONNX/assets) | - **Validated:** covered by the recipe on `main` or by the checks in the linked merged pull request. - **Author-run:** recorded by the recipe author on the stated hardware and not independently reproduced; the row names its owner. diff --git a/src/models/vela/engine.py b/src/models/vela/engine.py index af6355b2..5d8ca552 100644 --- a/src/models/vela/engine.py +++ b/src/models/vela/engine.py @@ -217,6 +217,21 @@ def verify_no_foreign_runtime(root: Path) -> None: ) +def put_first_on_sys_path(root: Path) -> str: + """Put the resolved package root at the front of ``sys.path``. + + Equivalent entries are dropped, not reused: a ``PYTHONPATH`` copy of + the root listed later would leave foreign directories ahead of it, + and ``verify_no_foreign_runtime`` only guards ``sys.modules``, which + is empty before the first import. + """ + + resolved = Path(root).resolve() + sys.path[:] = [entry for entry in sys.path if Path(entry or ".").resolve() != resolved] + sys.path.insert(0, str(resolved)) + return str(resolved) + + def verify_package(root: Path, table: dict[str, str] | None = None) -> None: """The full pre-import gate for one package directory.""" @@ -235,6 +250,7 @@ def __init__( device: str = "cpu", model_name: str | None = None, batch_size: int | None = None, + token_budget: int | None = None, ) -> None: root = Path(package) if not root.is_dir(): @@ -248,12 +264,7 @@ def __init__( # imported from another directory would silently be the wrong # runtime. verify_no_foreign_runtime(root) - root_str = str(root) - # Promote to the front even when already on the path: an earlier - # entry would otherwise win the flat-module import. - if root_str in sys.path: - sys.path.remove(root_str) - sys.path.insert(0, root_str) + put_first_on_sys_path(root) import vela2_inference self.SystemOneError = vela2_inference.SystemOneError @@ -261,6 +272,7 @@ def __init__( self.engine = vela2_inference.Vela2.from_pretrained( root, backend="torch", device=device, **({"batch_size": batch_size} if batch_size is not None else {}), + **({"token_budget": token_budget} if token_budget is not None else {}), ) self.model_name = model_name or vela2_inference.DEFAULT_MODEL_NAME self.max_length = int(self.engine.max_len) diff --git a/tests/vela/test_engine_manifest.py b/tests/vela/test_engine_manifest.py index 2efdf949..a53dbc84 100644 --- a/tests/vela/test_engine_manifest.py +++ b/tests/vela/test_engine_manifest.py @@ -28,6 +28,7 @@ PINNED_FILES_SHA256, REFERENCE_REPO, REFERENCE_REVISION, + put_first_on_sys_path, verify_config, verify_files, verify_no_foreign_runtime, @@ -61,6 +62,14 @@ def build_package(root: Path, files: dict[str, bytes], config: dict | None = Non return table_of(files) +def sparse_weights(root: Path, size: int = EXPECTED_WEIGHTS_BYTES) -> None: + """The gate reads ``stat().st_size`` only, so a sparse hole stands + in for the 1.2 GB file instead of a full heap allocation.""" + + with (root / "model.safetensors").open("wb") as stream: + stream.truncate(size) + + def small_files(): return { "vela2_serve.py": b"upstream server stub\n", @@ -89,7 +98,7 @@ def test_verification_accepts_a_package_matching_its_table(tmp_path): assert verify_files(tmp_path, table) == sorted(table) verify_config(tmp_path) # verify_weights needs the exact pinned size; the stub above is short. - (tmp_path / "model.safetensors").write_bytes(b"0" * EXPECTED_WEIGHTS_BYTES) + sparse_weights(tmp_path) verify_package(tmp_path, table) @@ -255,12 +264,32 @@ def test_a_runtime_module_without_a_file_is_refused(tmp_path): sys.modules.pop("vela2_inference", None) +def test_the_package_root_is_forced_to_the_front_of_sys_path(tmp_path): + # A PYTHONPATH copy of the root listed later must not leave a foreign + # directory ahead of it: verify_no_foreign_runtime cannot catch this, + # because sys.modules is empty before the first import. + package = tmp_path / "pkg" + shadow = tmp_path / "shadow" + package.mkdir() + shadow.mkdir() + resolved = str(package.resolve()) + sys.path.insert(0, str(shadow)) + sys.path.append(str(package)) + try: + assert put_first_on_sys_path(package) == resolved + assert sys.path[0] == resolved + assert resolved not in sys.path[1:] + assert str(shadow) in sys.path + finally: + sys.path[:] = [entry for entry in sys.path if entry not in (str(shadow), resolved)] + + def test_weights_are_checked_by_exact_pinned_size(tmp_path): table = build_package(tmp_path, small_files()) - (tmp_path / "model.safetensors").write_bytes(b"0" * EXPECTED_WEIGHTS_BYTES) + sparse_weights(tmp_path) verify_weights(tmp_path) - (tmp_path / "model.safetensors").write_bytes(b"0" * (EXPECTED_WEIGHTS_BYTES - 1)) + sparse_weights(tmp_path, EXPECTED_WEIGHTS_BYTES - 1) with pytest.raises(ValueError, match="is 1236473251 bytes"): verify_weights(tmp_path) diff --git a/tests/vela/test_oracle_fixtures.py b/tests/vela/test_oracle_fixtures.py index 8d914f34..54163334 100644 --- a/tests/vela/test_oracle_fixtures.py +++ b/tests/vela/test_oracle_fixtures.py @@ -1,6 +1,6 @@ """Ties the frozen oracle fixtures to the recipe examples they came from. -The three oracle fixtures under ``tests/vela/fixtures`` are the frozen +The four oracle fixtures under ``tests/vela/fixtures`` are the frozen comparison baseline for the later native encoder slices: each one's ``request`` is the recipe's example request, and its recorded ``system_one`` response passes the worker's own response gate. This @@ -25,6 +25,7 @@ ("oracle-example-request", "example-request.json"), ("oracle-example-request-cjk", "example-request-cjk.json"), ("oracle-example-request-set-threshold", "example-request-set-threshold.json"), + ("oracle-example-request-score", "example-request-score.json"), ] From 6bfbd717b237bcaa83b69428b3a0995a00ed0a5a Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 21 Oct 2026 00:31:58 +0800 Subject: [PATCH 18/21] modernbert: fall back to the generic rope_theta key A stock HF ModernBERT config may carry its theta only as the generic rope_theta; the adapter listed that key as consumed but never read it, so such a config parsed as the 10000.0 default while provenance claimed a default, and the profile gate then rejected the structure with a misleading diff. The generic key now fills both kinds when the per-kind key is absent, with source provenance naming rope_theta; the per-kind keys keep precedence. Signed-off-by: Mr.Dong <1042542469@qq.com> --- src/models/modernbert/src/adapter.rs | 51 +++++++++++++++++----------- tests/modernbert/structure.rs | 33 ++++++++++++++++++ 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/models/modernbert/src/adapter.rs b/src/models/modernbert/src/adapter.rs index 86401192..87e18bc5 100644 --- a/src/models/modernbert/src/adapter.rs +++ b/src/models/modernbert/src/adapter.rs @@ -149,27 +149,38 @@ fn parse_hf_config(config: &Json, source: &str) -> Result<(ModernBertStructure, }) .collect() }; - let theta = - |field: &str, json_key: &str, provenance: &mut BTreeMap| match config - .get(json_key) - { - None => { - provenance.insert( - field.into(), - FieldOrigin::Default("HF ModernBERT rope_theta default"), - ); - Ok(10000.0) - } - Some(_) => { - provenance.insert( - field.into(), - FieldOrigin::Source(format!("{source}#{json_key}")), - ); - float(config, json_key) - } + // ModernBERT configs carry per-kind thetas; a stock HF config may + // carry only the generic rope_theta, which then fills both kinds. + let theta = |field: &str, + json_key: &str, + generic_key: &str, + provenance: &mut BTreeMap| { + let key = if config.get(json_key).is_some() { + json_key + } else if config.get(generic_key).is_some() { + generic_key + } else { + provenance.insert( + field.into(), + FieldOrigin::Default("HF ModernBERT rope_theta default"), + ); + return Ok(10000.0); }; - let theta_full = theta("rope_full", "global_rope_theta", &mut provenance)?; - let theta_sliding = theta("rope_sliding", "local_rope_theta", &mut provenance)?; + provenance.insert(field.into(), FieldOrigin::Source(format!("{source}#{key}"))); + float(config, key) + }; + let theta_full = theta( + "rope_full", + "global_rope_theta", + "rope_theta", + &mut provenance, + )?; + let theta_sliding = theta( + "rope_sliding", + "local_rope_theta", + "rope_theta", + &mut provenance, + )?; let scaling = match config.get("rope_scaling") { None => None, Some(entry) => { diff --git a/tests/modernbert/structure.rs b/tests/modernbert/structure.rs index 12f910cd..f075b661 100644 --- a/tests/modernbert/structure.rs +++ b/tests/modernbert/structure.rs @@ -104,6 +104,39 @@ fn hf_adapter_accepts_explicit_layer_types_over_the_interval() { )); } +#[test] +fn hf_adapter_falls_back_to_the_generic_rope_theta() { + let mut config = family_config(); + let object = config.as_object_mut().unwrap(); + object.remove("global_rope_theta"); + object.remove("local_rope_theta"); + object.insert("rope_theta".into(), json!(500000.0)); + let structure = parse(&config); + assert_eq!(structure.rope_full.theta, 500000.0); + assert_eq!(structure.rope_sliding.theta, 500000.0); + for field in ["rope_full", "rope_sliding"] { + match structure.provenance.get(field) { + Some(FieldOrigin::Source(key)) => assert_eq!(key, "config.json#rope_theta"), + other => panic!("{field} provenance {other:?}"), + } + } +} + +#[test] +fn hf_adapter_prefers_the_per_kind_theta_over_the_generic_key() { + let mut config = family_config(); + let object = config.as_object_mut().unwrap(); + object.remove("global_rope_theta"); + object.insert("rope_theta".into(), json!(500000.0)); + let structure = parse(&config); + assert_eq!(structure.rope_full.theta, 500000.0); + assert_eq!(structure.rope_sliding.theta, 10000.0); + assert!(matches!( + structure.provenance.get("rope_sliding"), + Some(FieldOrigin::Source(_)) + )); +} + #[test] fn hf_adapter_reads_type_vocab_and_position_members() { let (structure, meta) = From dcc6dac03d5ea29df3a9e61961172096fff90d70 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 21 Oct 2026 00:31:58 +0800 Subject: [PATCH 19/21] vela: refuse the type scratch slot instead of aliasing the ids buffer The executor mapped the Types scratch slot onto the token-id buffer. No admitted profile emits the type step today (the 0.3B profile pins type_embedding_rows to None), but a future type-carrying member on this executor would have silently fed token ids where type ids belong; the slot now fails loudly as a plan/profile mismatch. Signed-off-by: Mr.Dong <1042542469@qq.com> --- src/models/vela/native/src/executor.rs | 31 +++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/models/vela/native/src/executor.rs b/src/models/vela/native/src/executor.rs index 3cb98bd0..606bb796 100644 --- a/src/models/vela/native/src/executor.rs +++ b/src/models/vela/native/src/executor.rs @@ -6,7 +6,7 @@ //! bundle's bf16 working precision after the final norm; readouts //! gather requested positions from it. -use anyhow::{Context, Result, ensure}; +use anyhow::{Context, Result, bail, ensure}; use half::bf16; use omni_cuda::{Buffer, Cuda, Kernel, Ptr}; use omni_modernbert::geometry::EncoderScratch; @@ -76,16 +76,21 @@ impl Scratch { }) } - fn buffer(&self, slot: ScratchSlot) -> &Buffer { + fn buffer(&self, slot: ScratchSlot) -> Result<&Buffer> { match slot { - ScratchSlot::Ids => &self.ids, - ScratchSlot::Lens => &self.lens, - ScratchSlot::Types => &self.ids, - ScratchSlot::X => &self.x, - ScratchSlot::Y => &self.y, - ScratchSlot::Qkv => &self.qkv, - ScratchSlot::O => &self.o, - ScratchSlot::Gate => &self.gate, + ScratchSlot::Ids => Ok(&self.ids), + ScratchSlot::Lens => Ok(&self.lens), + // No admitted profile carries a type embedding; a plan + // asking for that slot is a plan/profile mismatch to fail + // loudly, not a mapping onto the token-id buffer. + ScratchSlot::Types => { + bail!("type-embedding scratch is not supported by the admitted profile") + } + ScratchSlot::X => Ok(&self.x), + ScratchSlot::Y => Ok(&self.y), + ScratchSlot::Qkv => Ok(&self.qkv), + ScratchSlot::O => Ok(&self.o), + ScratchSlot::Gate => Ok(&self.gate), } } } @@ -220,10 +225,10 @@ impl Encoder { let pointers: Vec = step.args[..step.count] .iter() .map(|arg| match arg { - Arg::Weight(p) => *p, - Arg::Scratch(slot) => scratch.buffer(*slot).ptr(), + Arg::Weight(p) => Ok(*p), + Arg::Scratch(slot) => Ok(scratch.buffer(*slot)?.ptr()), }) - .collect(); + .collect::>>()?; // SAFETY: buffers are sized by the family geometry for this // admitted structure and stay alive through the sync below. unsafe { step.kernel.launch(&pointers, 1, len)? }; From 28187c804f436e54b4fda4edd879181aa39aef20 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sat, 24 Oct 2026 02:06:58 +0800 Subject: [PATCH 20/21] vela: refuse existing bytecode caches at the package gate --- recipe/vela/reference.md | 13 ++++++------ src/models/vela/README.md | 9 +++++---- src/models/vela/engine.py | 26 +++++++++++++++++++----- tests/vela/test_engine_manifest.py | 32 +++++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/recipe/vela/reference.md b/recipe/vela/reference.md index 287dc69c..d358c0a2 100644 --- a/recipe/vela/reference.md +++ b/recipe/vela/reference.md @@ -65,7 +65,7 @@ The ownership boundary follows | --- | --- | | Package | [vllm-sr/Vela-2.0-0.3B](https://huggingface.co/vllm-sr/Vela-2.0-0.3B) | | Pinned revision | `d6f03aa9baca4f9017fa8f8e9c2764d3bed3f409` | -| Package gate | config structural identity + a 7-file SHA-256 table pinned per revision in `engine.py`, full hash before import, no unlisted importables in the package root, exact 1,236,473,252-byte weights check | +| Package gate | config structural identity + a 7-file SHA-256 table pinned per revision in `engine.py`, full hash before import, no unlisted importables in the package root, no existing `__pycache__` bytecode cache, exact 1,236,473,252-byte weights check | | License | Apache-2.0; tokenizer material under Gemma Terms of Use | The gate's file table is checked in (not read from the package, which @@ -97,9 +97,9 @@ tokenizer, the ONNX exports and doc assets); serving needs roughly 2 GB of RAM for the FP32 model plus working memory for the batch rows. The worker refuses to start when the config is not the pinned member's, when any of the 7 gated files fails its hash, when the package root -holds an importable entry the table does not name, or when the weights -file is not the pinned size — that gate runs before any package code is -imported. +holds an importable entry the table does not name or a `__pycache__` +bytecode cache, or when the weights file is not the pinned size — that +gate runs before any package code is imported. ## Start the worker @@ -256,8 +256,9 @@ GPU bf16-autocast numbers for the baseline. ## Troubleshooting and limits - The package directory must be the pinned revision's download; the - worker refuses foreign configs, hash mismatches, unlisted importables - and wrong weights sizes at startup, before importing anything. + worker refuses foreign configs, hash mismatches, unlisted importables, + bytecode caches and wrong weights sizes at startup, before importing + anything. - The 8 MiB body cap is slice-scoped (upstream sets no total-body limit); a valid request bounded by the 8,192-token window is far below it. diff --git a/src/models/vela/README.md b/src/models/vela/README.md index cba98dbc..e8cc0715 100644 --- a/src/models/vela/README.md +++ b/src/models/vela/README.md @@ -37,10 +37,11 @@ config's structural identity (model type, architecture, `max_length` or executes — the four runtime modules, `config.json`, `calibration.json` and `tokenizer.json` — against the table checked in for the pinned revision, before anything is imported, and refuses -importable entries the table does not name (`model.safetensors` is -checked by exact size; the runtime's strict safetensors load rejects -truncated or structurally mismatched weights, but not same-size -bit-level corruption). +importable entries the table does not name as well as any existing +`__pycache__` bytecode cache, which cannot be verified against the pin +(`model.safetensors` is checked by exact size; the runtime's strict +safetensors load rejects truncated or structurally mismatched weights, +but not same-size bit-level corruption). A later self-consistent snapshot rewrites its own files and fails the checked-in pin. diff --git a/src/models/vela/engine.py b/src/models/vela/engine.py index 5d8ca552..fb20f2fc 100644 --- a/src/models/vela/engine.py +++ b/src/models/vela/engine.py @@ -139,9 +139,11 @@ def verify_files(root: Path, table: dict[str, str] | None = None) -> list[str]: of the pinned runtime's dependencies. A directory without ``__init__.py`` can shadow the same way as a namespace package, so any directory carrying importable code at any depth is refused too; - ``__pycache__`` bytecode is the interpreter's cache for the sources - beside it, never reached by ``import`` by name, and a directory of - pure data assets cannot execute; both are left alone. + a ``__pycache__`` entry is refused as well — an existing bytecode + cache cannot be verified against the pin, and an unchecked-hash + cache is imported without consulting the source it was compiled + from. A directory of pure data assets cannot execute and is left + alone. """ def carries_code(entry: Path) -> bool: @@ -166,7 +168,12 @@ def carries_code(entry: Path) -> bool: for entry in sorted(Path(root).iterdir()): if entry.name in table: continue - if entry.is_file() and entry.suffix in IMPORTABLE_SUFFIXES: + if entry.name == "__pycache__": + problems.append( + "unexpected __pycache__ in the package root: " + "an existing bytecode cache is not verifiable against the pin" + ) + elif entry.is_file() and entry.suffix in IMPORTABLE_SUFFIXES: problems.append(f"unexpected importable file {entry.name} in the package root") elif entry.is_dir() and (entry / "__init__.py").is_file(): problems.append(f"unexpected importable package {entry.name}/ in the package root") @@ -265,7 +272,16 @@ def __init__( # runtime. verify_no_foreign_runtime(root) put_first_on_sys_path(root) - import vela2_inference + # Bytecode writing is off for the import itself: a __pycache__ + # written beside the just-verified sources could not be verified + # against the pin, and the gate above would refuse it on every + # later run against this same package. + dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + import vela2_inference + finally: + sys.dont_write_bytecode = dont_write_bytecode self.SystemOneError = vela2_inference.SystemOneError self._system_one_request = vela2_inference.system_one_request diff --git a/tests/vela/test_engine_manifest.py b/tests/vela/test_engine_manifest.py index a53dbc84..73f9d3e2 100644 --- a/tests/vela/test_engine_manifest.py +++ b/tests/vela/test_engine_manifest.py @@ -13,6 +13,7 @@ import types import hashlib import json +import py_compile import re import sys from pathlib import Path @@ -207,19 +208,40 @@ def test_namespace_directories_with_extension_modules_are_refused(tmp_path): verify_files(tmp_path, table) +def test_an_existing_bytecode_cache_cannot_bypass_the_gate(tmp_path): + # A __pycache__ entry with an unchecked hash is imported without + # consulting the source it claims to be compiled from, so a poisoned + # cache would run even beside sources that hash to the table; the + # gate refuses the cache itself, as bytecode cannot be verified + # against the pin. + table = build_package(tmp_path, small_files()) + assert verify_files(tmp_path, table) == sorted(table) + + victim = tmp_path / "vela2_inference.py" + real = victim.read_bytes() + victim.write_bytes(b"import os\nos.system('poisoned by cache')\n") + cache_dir = tmp_path / "__pycache__" + cfile = cache_dir / f"vela2_inference.{sys.implementation.cache_tag}.pyc" + py_compile.compile( + str(victim), + cfile=str(cfile), + invalidation_mode=py_compile.PycInvalidationMode.UNCHECKED_HASH, + ) + victim.write_bytes(real) + + with pytest.raises(ValueError, match="__pycache__"): + verify_files(tmp_path, table) + + def test_unimportable_package_root_entries_still_pass(tmp_path): # The pinned package's own layout shape: onnx/, assets/, LICENSES/ and - # figures/ directories without __init__.py, markdown and license files, - # and the __pycache__ Python writes next to the imported runtime. + # figures/ directories without __init__.py, markdown and license files. table = build_package(tmp_path, small_files()) for directory in ("onnx", "assets", "LICENSES", "figures"): (tmp_path / directory).mkdir() (tmp_path / directory / "file.bin").write_bytes(b"not a python package\n") (tmp_path / "README.md").write_text("# Vela 2.0 0.3B\n") (tmp_path / "LICENSE").write_text("Apache-2.0\n") - pycache = tmp_path / "__pycache__" - pycache.mkdir() - (pycache / "vela2_inference.cpython-312.pyc").write_bytes(b"bytecode\n") assert verify_files(tmp_path, table) == sorted(table) From 8d7be88ac271d4a983ef46fe2999dc31a71a6861 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sat, 24 Oct 2026 13:57:44 +0800 Subject: [PATCH 21/21] vela: keep bytecode writing off for the recipe's runtime imports --- recipe/vela/compare_reference.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/recipe/vela/compare_reference.py b/recipe/vela/compare_reference.py index ed434c4a..c876a664 100644 --- a/recipe/vela/compare_reference.py +++ b/recipe/vela/compare_reference.py @@ -291,8 +291,16 @@ def main(argv=None): from models.vela.engine import Vela2Executor executor = Vela2Executor(args.model, device=args.device) - import vela2_inference - import vela2_serve + # Both runtime imports stay inside a no-bytecode window: the package + # gate refuses existing caches, and vela2_serve is a fresh import that + # would otherwise write the __pycache__ the next worker start rejects. + dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + import vela2_inference + import vela2_serve + finally: + sys.dont_write_bytecode = dont_write_bytecode from frontend.vela import HEALTH_BODY, MODELS_ENTRY, READY_BODY, UNAUTHORIZED_BODY