Skip to content

Feature/qwen iree kv export - #457

Merged
michalharakal merged 16 commits into
developfrom
feature/qwen-iree-kv-export
Sep 25, 2026
Merged

michalharakal merged 16 commits into
developfrom
feature/qwen-iree-kv-export

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

No description provided.

…models (Qwen)

Part of #411 (Qwen export harness / compiled IREE path). iree_kv_jni.c and IreeKvSpec
handled exactly one model shape (FunctionGemma: nKvHeads == 1, sliding+global layer split,
two RoPE bases, three independent parameter archives). Generalizes both axes a real Qwen
cartridge needs without touching FunctionGemma's behaviour:

- nKvHeads may now be any value with nHeads % nKvHeads == 0 (grouped-query attention --
  Qwen2.5-0.5B nKvHeads=2, Qwen3-0.6B nKvHeads=8). The [1, nKV, rows, headDim] cache layout
  is nKV-major, so a row-range view is a single zero-copy subspan only when nKV == 1;
  sub_rows now gathers the nKV > 1 case with nKV device-to-device copies into a fresh
  buffer. This only runs after a prefill/chunk call (padding removal), never per decode
  step. The attention mask stays the untouched rank-4 [1, nHeads, C, past+C] for every
  model -- verified against SKaiNET's AttentionOperationsConverter, which reshapes Q into
  the GQA group form and broadcasts a rank-4 mask onto it internally via dims=[0,1,3,4], so
  the native side never needs a grouped mask shape.

- globalLayerPeriod == 1 (every layer "global": no sliding/global split at all, the
  qwen-kv-v1 shape) means the traced graph carries no sliding-side RoPE/mask inputs;
  nativeChunk/nativeStep now gate building and counting those tensors on
  hasSliding = period > 1 instead of unconditionally computing and discarding them.

- nativeCreate opens one shared IREE session (graph_alias, ref-counted via
  iree_runtime_session_retain) when the same vmfb+irpa path is passed for all three
  graphs -- the qwen-kv-v1 contract merges prefill/chunk/with-past into one module sharing
  one parameter archive; three independent sessions against the same ~1-1.2 GB bf16
  archive would not fit a 32-bit process. Three distinct paths (FunctionGemma) keep the
  original one-session-per-graph behaviour.

- locate_embedding now mmaps only the page-aligned range covering the embedding table
  instead of the whole archive file -- immaterial for FunctionGemma's per-graph ~0.5 GB
  archives, real for the one shared ~1-1.2 GB Qwen archive.

- jint_field/jfloat_field clear the pending JNI exception GetFieldID leaves on a miss
  instead of leaking it into the next JNI call.

IreeKvSpec needs no new field: nKvHeads > 1 and globalLayerPeriod == 1 with the existing
fields already express both models. Added qwen25_05bInstruct()/qwen3_06b() factories
(architecture facts read off the two GGUFs, tvv workspace NLU-QWEN-TRACKING.md Q0.2) and
documented the two contracts (functiongemma-kv-v1 / qwen-kv-v1) sharing this class.

Verified: cross-compiled for armeabi-v7a (--vulkan) against skainet/iree-android:3.11.0 --
clean build, 278/278 objects, no warnings on iree_kv_jni.c, libskainet_iree_kv.so linked
and copied into jniLibs. IreeKvSpecTest (6 cases: both Qwen factories, nHeads%nKvHeads,
fromManifest round-trip incl. a qwen-kv-v1-shaped manifest, chunk override, missing-field
fallback) plus the pre-existing IreeTaskTopologyTest (3 cases) both green: ./gradlew
:llm-runtime:iree-android:testDebugUnitTest -- 9/9 pass.

Not yet built: arm64-v8a .so (armeabi-v7a is the box's actual ABI; arm64 stays on the old
FunctionGemma-only binary until a Qwen arm64 target is needed). Device bring-up (does the
GQA row-gather and the shared session actually work end to end on Vulkan valhall4) is
SKaiNET-transformers#411's Q1.7, blocked on the export harness producing a real vmfb+irpa.
…h (Qwen)

Part of #411 (Qwen export harness / compiled IREE path). GemmaModel carries the only
existing KV-cache with-past forwards (forwardPrefillAt / forwardPrefillWithPast /
forwardWithPast), hand-written for Gemma's specific shape: sandwich post-norms, PLE,
sliding+global layer split with two RoPE bases, and expandKV before SDPA. Qwen needs the
same three-graph contract over a plain decoderTransformerNetwork module (Llama-shaped:
one RMSNorm/MHA/Residual/RMSNorm/SwiGLU/Residual block, one RoPE base, every layer
causal full attention) instead of copying and stripping down GemmaModel's version.

DecoderKvModel resolves its block/embedding/norm/lm-head references from any module
decoderTransformerNetwork() builds (EmbeddingAdapter + HybridTransformerBlock x N +
RMSNormalization + VoidDense at the top level; each block's own 2 RMSNorm + MHA + SwiGLU
by filterIsInstance, mirroring HybridTransformerBlock's own reference resolution) and
mirrors GemmaModel's with-past math with two real differences:

- GQA-native SDPA, no expandKV: K/V stay at their native nKVHeads and go straight into
  ops.scaledDotProductAttention. This isn't a simplification for Qwen's sake -- it's
  already how MultiHeadAttention.attentionImpl itself calls SDPA (the "Grouped-query
  attention is native to SDPA (SKEEP-005 phase 2)" comment at its own call site), and how
  the StableHLO AttentionOperationsConverter lowers it (Q reshaped to [b,nKV,nRep,Sq,hd],
  a rank-4 mask broadcast onto it via dims=[0,1,3,4]) -- expandKV first would only add
  dead narrow+concat nodes to both the eager path and the export trace.
- Adds the Q/K/V/O projection bias when MultiHeadAttention.bias is set. Qwen2.5 (arch
  qwen2) GGUFs carry real attention biases; GemmaModel never needed this since Gemma has
  none.

One RoPE base / one mask per call throughout (no sliding-vs-global branching --
decoderTransformerNetwork never sets MultiHeadAttention.slidingWindow), no PLE, no
final-logit softcapping, no embedding scale (Gemma-only: embed_scale = hidden_size**0.5).

Verified: DecoderKvModelTest builds a tiny GQA network (dim=8, nHeads=4, nKVHeads=2,
2 layers, real random weights via decoderTransformerNetwork + WeightMapper -- no
dependency on the qwen module, which sits one layer above llm-core) and checks
forwardPrefillAt + forwardWithPast reproduce OptimizedLLMRuntime's ordinary eager forward
EXACTLY (within 1e-4), token by token, across 4 prompt tokens plus 2 further decode
steps, including that the returned K/V cache stays at nKVHeads (never expands) and grows
by exactly one row per step. Caught one real bug along the way -- not in DecoderKvModel
but in the test itself: sharing one model instance between the eager runtime and
DecoderKvModel corrupts the eager side, because forwardPrefillAt's stripCaches() nulls
every block's mha.kvCache (needed for its own stateless prefill, same as GemmaModel's
stripCaches) -- fixed by giving each path its own model instance built from the same
weights. ./gradlew :llm-core:jvmTest -- 21/21 pass (2 new).

The chunk path (forwardPrefillWithPast) needs a host-built RoPE-range table and causal
mask that only the export harness (#411's next step) and the native iree_kv_jni.c runtime
build today; it gets its correctness gate from the export dump test and vmfb parity test
against a real compiled graph instead of a unit test here.
…commonMain)

Part of #411. Pure-Kotlin, no I/O -- the arg/output order and manifest.json emission the
export harness (next commit) and iree_kv_jni.c's native runtime must agree on, following
the FunctionGemmaContract pattern with two real adaptations:

- QwenKvArch derives the architecture facts (nLayers, headDim, nKvHeads, nHeads, GQA,
  QK-norm, attention bias, RoPE base) instead of hardcoding one checkpoint the way
  FunctionGemmaSpec does -- one QwenKvArch instance describes whichever of the two Qwen
  models (Qwen2.5-0.5B-Instruct, arch qwen2, GQA 14/2, bias, no QK-norm; Qwen3-0.6B, arch
  qwen3, GQA 16/8, QK-norm, no bias) the harness loaded. Factories for both, matching
  IreeKvSpec's own qwen25_05bInstruct()/qwen3_06b() numbers in llm-runtime/iree-android.

- Every arg/output list is written out explicitly against iree_kv_jni.c's actual ins[]/
  outs[] assembly (nativePrefill/nativeChunk/nativeStep, as generalized in the prior
  commit) rather than copied from FunctionGemmaContract's -- that contract predates the
  host-gather addendum and omits the embedding tensor those graphs actually take (see its
  own docstring); this one lists `emb` explicitly so the harness's trace and the native
  runtime's calls check against the same source of truth. No sliding/global split (every
  layer "global", QwenKvArch has one RoPE base, not two) means the arg lists are shorter
  than FunctionGemma's: cos/sin/mask introduced once before/after the first layer, not
  per layer-type. manifestJson always reports slidingWindow=0, globalLayerPeriod=1 --
  what makes IreeKvSession build no sliding-side tensors at all -- and carries every key
  IreeKvSpec.fromManifest reads, plus qkNorm/attnBias/eos/eot/ropeBase/embeddingKey and
  the merged-module contract's parameters.shared archive name.

Verified: QwenKvContractTest (commonTest, no checkpoint) checks every arg/output list's
length and order against the exact native-side formula for both models (e.g.
prefillWithPastArgs must be [tokens, emb, cos, sin, l0.k, l0.v, mask, l1.k, l1.v, ...,
select], length 6+2*nLayers, matching nativeChunk's nIn with hasSliding=false), that the
mask appears exactly once, that manifestJson carries every IreeKvSpec.fromManifest key
and reflects each model's qkNorm/attnBias correctly, and that QwenKvArch rejects
nHeads % nKvHeads != 0. ./gradlew :llm-inference:qwen:jvmTest -- 8/8 new, whole-module
suite green.
…ort (jvmMain)

Closes the QwenExportHarness half of #411. Three graphs, following FunctionGemmaExportHarness's
proven structure over DecoderKvModel instead of a hand-written model class:

- exportPrefillAt (qwen_prefill_at): the catalog-prefix prefill, LM head on one position
  via a one-hot select row.
- exportPrefillWithPast (qwen_prefill_with_past): a fixed chunk against the incoming cache.
- exportWithPast (qwen_with_past): one autoregressive step, true-dynamic past (Dim.DYNAMIC).

QwenKvArch is derived from the loaded GGUF's metadata and tensor presence (hasQkNorm/
hasAttnBias, the same structural scan QwenNetworkLoader.applyWeightsToNetwork already
uses) instead of hardcoded per model, so one harness serves both Qwen2.5-0.5B-Instruct
and Qwen3-0.6B. writeSafetensors/writeHandle/rewriteGlobalsToBf16 are copied verbatim
from FunctionGemmaExportHarness (also duplicated in SmolLm2ExportHarness) -- established
pattern in this codebase, each export module owns its own copy of this generic machinery.

Scope, both decided deliberately and verified by tracing real checkpoints rather than
assumed:

1. Per-graph archives, not the merged single-module/shared-archive design qwen-kv-v1 calls
   for. FunctionGemma's per-trace parameter numbering means merging needs the traces
   combined into one module with stable, shared keys first -- real further work, tracked
   as a follow-up. Until it lands, IreeKvSession.nativeCreate's non-shared path (three
   independent sessions, already built and tested in the prior commit) is what a Qwen
   cartridge uses, at the same three-archive memory cost FunctionGemma already pays.

2. The harness's raw MLIR is pre-host-gather (an in-graph stablehlo.gather against the
   embedding table, not a separate `emb` function argument) -- exactly like
   FunctionGemmaExportHarness's own output. QwenKvContract's arg lists (which DO list
   `emb`, matching iree_kv_jni.c's native runtime) describe the contract AFTER the
   blueprint plugin's host-gather rewrite, a later pipeline stage this harness doesn't
   perform, same as FunctionGemma's. Verified by tracing: qwen_with_past for Qwen3-0.6B
   is exactly `3 + 2*nLayers` args (token, cos, sin, per-layer K/V), qwen_prefill_at is
   `tokens, select`, qwen_prefill_with_past is `3 + 2*nLayers + mask + select`.

Known gap found by tracing Qwen2.5-0.5B-Instruct (attnBias=true) for real: the Q/K/V/O
projection bias tensors do not externalize as util.global parameters the way weight
matrices do -- they leak into the function signature as genuine runtime arguments instead
(verified: one extra tensor<{dim}xf32> arg per layer, never backed by a util.global.load,
absent from the safetensors). Qwen3-0.6B (attnBias=false) is unaffected. This is a
SKaiNET core tracer/converter gap (ModuleParameter.BiasParameter isn't recognized as an
externalizable constant the way WeightParameter is) -- no existing harness in this
codebase has ever traced a bias-bearing attention layer before this one, since Gemma/
SmolLM2/Gemma3n have none. Not fixed here; tracked in NLU-QWEN-TRACKING.md. Qwen2.5-0.5B
export is blocked on it, Qwen3-0.6B is not.

Verified end to end against the real checkpoints (not synthetic fixtures):
- Qwen2.5-0.5B-Instruct qwen_with_past: traced, compiled the graph, safetensors written
  (1.26 GB bf16, 291 externals) -- surfaced the bias gap above.
- Qwen3-0.6B: all three graphs traced successfully (7-19s each on this machine), real
  safetensors written (291-310 externals per graph, ~1.2 GB bf16 each, tied embeddings
  detected and logged), MLIR inspected directly (arg/result counts match the raw-trace
  formula exactly, dynamic KV-cache dims present as `x?x` where expected, no leaked args).
- QwenExportDumpTest (new, env-gated on QWEN3_GGUF, real checkpoint): runs exportAll end
  to end and asserts the above -- all green. QwenKvContractTest, QwenDslPipelineTest,
  QwenConfigParserTest, QwenDslQuantizedTest all still green.
  ./gradlew :llm-inference:qwen:jvmTest -PqwenTestMaxHeap=24g -- 18/18 pass, 0 skipped
  (QWEN3_GGUF pointed at the real checkpoint).

build.gradle.kts: new jvmMain source set (compile.hlo/compile.dag/backend.cpu, mirroring
:llm-inference:functiongemma's shape), exportQwen Gradle task (QWEN_GGUF/QWEN_OUT_DIR/
QWEN_GRAPH/QWEN_SEQ/QWEN_CHUNK/QWEN_DTYPE env contract), bumped test/JavaExec heap to
match FunctionGemma's real-checkpoint numbers scaled for Qwen's larger vocab (151936 vs
262144, but two model sizes instead of one -- 24g direct memory is comfortably safe on
this machine, verified by actually running the export at that setting).
…template

Closes #409. QwenChatTemplate is faithful to Qwen3's official chat_template but wrong
for Qwen2.5 in two ways the host harness found (tvv/nlu-llm-harness, P0.3): no default
persona when the caller supplies no system message (Qwen2.5's official template still
opens with "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.";
Qwen3's dropped it), and QwenChatTemplate's enableThinking=false mode pre-fills an empty
<think></think> block to suppress Qwen3's native thinking -- exactly the wrong move for
Qwen2.5, which predates the thinking-mode convention entirely and never saw a <think>
token, empty or not, in training. Measured impact on the host: 0/8 and 1/8 golden-8
under generic ChatML / QwenChatTemplate vs 4-5/8 with the correct template.

Reuses QwenChatTemplate's proven multi-turn logic unchanged (tool listing one JSON
object per line, consecutive tool results merged into one user turn, assistant tool
calls replayed from the structured ChatMessage.toolCalls) rather than the narrower
single-turn version the harness's own Qwen25ToolTemplate.kt got away with -- that
harness only ever scores one utterance -> one call, so it never needed multi-turn
correctness. parseThinkingBlocks/stripThinking are no-ops: there is nothing to find or
strip.

Verified against the REAL official template, not memory or the harness's port: fetched
Qwen/Qwen2.5-0.5B-Instruct's tokenizer_config.json from huggingface.co directly and
rendered its chat_template with a real Jinja2 engine for four cases (no system message,
caller system message, no tools, and a full multi-turn tool-call-then-response
exchange). This caught a real discrepancy along the way -- the GGUF's embedded copy of
the same template has a llama.cpp-conversion brace-escaping artifact
(`{{"name": ...}}` doubled braces) that isn't in the authoritative HF source
(`{"name": ...}`, single braces, confirmed by diffing both); the harness's own port
already had this right, this class matches it. The one deliberate, documented departure
from literal HF byte output: tool-call JSON uses kotlinx.serialization's default compact
separators (`{"k":"v"}`, verified against the library source: prettyPrint=false, no
spaces written) instead of Python json.dumps's spaced default (`{"k": "v"}`) -- matching
QwenChatTemplate's own established, already-shipped convention; semantically identical,
never affects what a model reads.

Qwen25ChatTemplateTest pins all four rendered golden strings byte-for-byte (plus
no-generation-prompt and no-thinking-mode checks, 6 cases total).
./gradlew :llm-agent:jvmTest -- 6/6 new, whole-module suite green.
Summarizes the four prior commits (native iree-android generalization, DecoderKvModel,
QwenKvArch/QwenKvContract/QwenExportHarness, Qwen25ChatTemplate) plus a real finding from
compiling the traced Qwen3-0.6B qwen_with_past graph against skainet/iree-compiler:3.11.0
today: llvm-cpu (host and arm32, the box's CPU flavour) compiles cleanly; vulkan-spirv
valhall4 crashes the compiler outright (internal stack dump, no diagnostic) on the
GQA-native attention lowering. CPU is viable today; Vulkan -- the box's primary, faster
target -- is not, until an IREE fix lands or a DecoderKvModel expandKV fallback is added
for that target specifically. Recorded here and in NLU-QWEN-TRACKING.md (OQ6).
…heses tested and refuted

Follow-up to dd286f4. Actually reproduced the compile with --mlir-disable-threading
--mlir-pass-pipeline-crash-reproducer and tested both candidate causes the first entry
guessed at: stripping the in-graph argmax tail (still crashes, now on an ordinary FFN
matmul) and an isolated/chained matmul of the flagged shape (compiles fine standalone,
28x). Neither reproduces the crash alone -- it needs the full graph's context. Corrects
the record rather than leaving a wrong root-cause guess in the changelog.
…the API dumps

The commits that added DecoderKvModel (llm-core) and Qwen25ChatTemplate (llm-agent) on this
branch never ran apiDump, so jvmApiCheck failed for both modules. Additions only, no existing
declaration changes.

Tests: ./gradlew :llm-core:apiCheck :llm-agent:apiCheck green.
iree-compile 3.11.0 crashed compiling the traced Qwen3-0.6B graphs for vulkan-spirv valhall4
(SPIRVInitialVectorLoweringPass: arith.constant vector<512xf32> not legalized) while llvm-cpu
compiled them. Bisected with real exports, fp32 and bf16, graph by graph. bf16 is not a cause.
Three independent causes, each fixed by a post-emit rewrite in QwenExportHarness:

1. argMax over the 151936 vocab. The SPIR-V backend lowers the fused max/compare/iota/select/min
   reduction only when its extent is a multiple of 2048: 151936 and 152064 fail; 153600, 155648,
   163840, 196608 and 262144 compile. FunctionGemma's 262144 vocab is 128 x 2048, which is why it
   never hit this. rewriteArgMaxPadded pads the logits row with -3e38 (finite, no NaN from the EQ
   compare) to QwenKvContract.paddedArgMaxExtent(vocab) = 153600 before the argMax. Real logits
   and the returned id are unchanged; a padded column can never be the maximum.

2. The in-graph token-embedding gather. On the decode step the row lookup fuses into the first
   residual add's vecmat dispatch and fails there; on the fixed-length prefill the gather fails
   on a vector.step vector<1024xindex>. rewriteHostGather is the blueprint plugin's host-gather
   rewrite applied to every function of the module. Opt-in via hostGather / QWEN_HOST_GATHER=1
   so the raw export stays the plugin's HostGatherTask input.

3. The chunk graph's mask under grouped-query attention. SKaiNET 0.56.0's SDPA converter emits
   broadcast_in_dim %mask, dims = [0, 1, 3, 4] onto the grouped scores [1, nKV, nRep, C, ?]. With
   the dynamic key length that op is invalid on every backend, so qwen_prefill_with_past compiled
   nowhere, not even llvm-cpu. IREE 3.11 legalizes neither dynamic_reshape of a per-head mask nor
   dynamic_broadcast_in_dim without expansion hints; it does legalize a head-shared [1, 1, C, ?]
   mask broadcast with known_expanding_dimensions/known_nonexpanding_dimensions set, which is
   what rewriteGqaMaskBroadcast emits. The chunk mask rows never depended on the head, so the
   contract now exports it head-shared (QwenKvContract.MASK_HEADS = 1, manifest "maskHeads": 1)
   and iree_kv_jni.c builds it with maskHeads rows instead of nHeads. IreeKvSpec gains an
   optional maskHeads (0 = nHeads, the default, FunctionGemma unchanged; the Qwen factories set
   1; fromManifest reads it); the 11-argument constructor stays through @jvmoverloads. The
   converter itself should emit this; the rewrite stands in until a SKaiNET release does.

Result on the real Qwen3-0.6B export (bf16, host-gather, seq 1024, chunk 32): qwen_prefill_at,
qwen_prefill_with_past and qwen_with_past all compile for vulkan-spirv valhall4 and llvm-cpu
arm32 with skainet/iree-compiler:3.11.0. The qwen API dump now records the export harness,
contract and CLI, which earlier commits on this branch had not dumped.

Tests: QwenExportRewriteTest 10/10 (new), QwenKvContractTest 8/8, IreeKvSpecTest 7/7, whole
:llm-inference:qwen:jvmTest green with QWEN3_GGUF set (QwenExportDumpTest against the real
checkpoint), :llm-inference:qwen:apiCheck green. The armeabi-v7a libskainet_iree_kv.so is not
rebuilt in this commit.
Rebuilds armeabi-v7a and arm64-v8a (--vulkan) from iree_kv_jni.c as of fbdb9e9, so the native
session builds the one-head chunk mask the qwen-kv-v1 chunk graph now takes. The previous
binaries predate maskHeads and would pass a [1, nHeads, C, past+C] mask to a graph expecting
[1, 1, C, past+C]. FunctionGemma is unaffected: maskHeads defaults to nHeads.

Built with skainet/iree-android:3.11.0 (IREE 3.11.0 runtime, NDK r27c, API 29). On an
Apple-silicon host that image must be built for linux/amd64: the NDK ships x86-64 host tools,
and an arm64 image fails at configure time. A build cache left by an arm64 image also has to
be cleared, since its host flatcc tool is aarch64.

Verified: both libraries build with no warnings in iree_kv_jni.c and contain the maskHeads
field lookup.
…k fix

DecoderKvModel's class doc and ChunkContext doc, and iree_kv_jni.c's header, still said the
attention converter broadcasts a per-head [b, H, Sq, Sk] mask onto the grouped scores with
dims = [0, 1, 3, 4] and that the mask is always [1, nHeads, C, past+C]. That broadcast was the
bug reported as SKaiNET#1302 (fixed there in PR #1303). The docs now say the mask is
[1, maskH, C, past+C]: nHeads for functiongemma-kv-v1, which expands K/V so the mask equals
the scores shape, and 1 for qwen-kv-v1, the only form IREE 3.11 lowers under GQA with a
dynamic key length. Comments only; the native libraries are unchanged.
…e/qwen-iree-kv-export

# Conflicts:
#	llm-runtime/iree-android/src/test/kotlin/sk/ainet/transformers/iree/android/IreeKvSpecTest.kt
…self

rewriteGqaMaskBroadcast turned SKaiNET 0.56.0's invalid static broadcast_in_dim of the chunk mask
into a hinted dynamic_broadcast_in_dim. SKaiNET 0.57.0 fixes that in the attention converter
(SKaiNET#1302), so the rewrite's pattern no longer occurs and it is removed with its tests. The
head-shared mask (QwenKvContract.MASK_HEADS = 1, maskHeads in the manifest and the native session)
stays: a per-head mask with a dynamic key length needs dynamic_reshape, which IREE 3.11 does not
lower. The argMax padding and host-gather rewrites stay; they work around IREE limits, not an
engine bug. Docs in QwenExportHarness, QwenKvContract, DecoderKvModel and the CHANGELOG now say so.

Verified on the real Qwen3-0.6B export (bf16, QWEN_HOST_GATHER=1, seq 1024, chunk 32) on engine
0.57.0: the chunk graph's mask reaches every layer as the engine-emitted
"stablehlo.dynamic_broadcast_in_dim"(... known_expanding_dimensions = [1] ...) (28 of 28 layers),
and qwen_prefill_at, qwen_prefill_with_past and qwen_with_past compile for vulkan-spirv valhall4
and llvm-cpu arm32 with skainet/iree-compiler:3.11.0, byte-for-byte the same vmfb sizes as with
the rewrite.

Tests: :llm-inference:qwen:jvmTest 24/24 with QWEN3_GGUF set (incl. QwenExportDumpTest),
:llm-core:jvmTest 133/133, apiCheck green.
Replaces references to a private evaluation harness and its tracking notes in the Qwen export
harness, the export CLI, Qwen25ChatTemplate and the CHANGELOG with the facts they stood for (an
8-utterance zero-shot tool-calling set; a 1024-token prefill that fits a ~1000-token tool-catalog
prefix; device deployment and the native runtime). Qwen25ChatTemplateTest now uses a generic
weather-forecast tool instead of a domain-specific one.

Verified: the four golden strings in Qwen25ChatTemplateTest were re-rendered from
Qwen/Qwen2.5-0.5B-Instruct's official chat_template with Jinja2 (compact tojson separators, the
test's documented adjustment) for the new tool and messages, and match byte-for-byte;
:llm-agent:jvmTest --tests Qwen25ChatTemplateTest 6/6.
…kens)

QwenVmfbParityTest exports the three qwen-kv-v1 graphs exactly as they ship (bf16 weights,
host-gather emb argument, padded argMax, head-shared chunk mask), converts each archive to .irpa,
compiles for llvm-cpu host with skainet/iree-compiler:3.11.0 and drives them through
iree-run-module the way iree_kv_jni.c does: qwen_prefill_at over the first 3 prompt tokens
(padded to 16, K/V sliced to the real rows), one qwen_prefill_with_past call over the remaining 3
tokens (padded to the 32-token chunk, split-half RoPE tables and the [1,1,C,past+C] mask built with
the native session's formulas), then qwen_with_past steps. Embedding rows come from the exported
archive, bf16-widened like the device reads them.

The oracle is a new fixture for the checkpoint that actually ships, qwen3-06b/golden-greedy-06b.txt:
mainline llama.cpp (0.3.0 build 10621), prompt "The first ten prime numbers are", 32 greedy tokens,
smallest top-1/top-2 gap 2.35 nats, so a mismatch is a defect rather than cross-implementation
noise. The fixture records the build, the checkpoint digest and the exact commands.

Result: all 32 tokens equal the oracle, including the chunk call's first token. The run writes an
evidence record; this commit keeps it as llm-inference/qwen/validation/L3-qwen3-06b-host-vmfb.json.
The graphs return the argmax token only, so logit drift is not recorded yet; that needs a
logits-returning export variant.

Gated: skips without QWEN3_06B_GGUF or without docker and the toolchain image.
Tests: QwenVmfbParityTest 1/1 against the real checkpoint.
@michalharakal
michalharakal merged commit 1effa52 into develop Sep 25, 2026
2 checks passed
@michalharakal michalharakal mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant