Skip to content

Commit 628550b

Browse files
michalharakalclaude
andcommitted
docs: add Antora documentation site following Divio standard
AsciiDoc documentation in Antora site format with four Divio categories: Tutorials: - Getting started with the skainet CLI - Tool calling with any model via ChatSession - Running smoke tests How-to Guides: - Add a new model architecture (DSL vs hand-coded) - Add a compute backend - Add a custom tool - Use the unified CLI Reference: - Architecture overview and module structure - Inference pipeline stages - Tokenizer API and TokenizerFactory - ChatSession API - Model Registry and UnifiedModelLoader - CLI reference (skainet + model-specific CLIs) Explanation: - Pipeline design decisions (why stages are separated) - DSL networks vs hand-coded runtimes (trade-offs) - Tokenizer internals (SentencePiece, BPE, WordPiece) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34eda36 commit 628550b

19 files changed

Lines changed: 1517 additions & 0 deletions

docs/antora.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
name: skainet-transformers
2+
title: SKaiNET Transformers
3+
version: ~
4+
nav:
5+
- modules/ROOT/nav.adoc

docs/modules/ROOT/nav.adoc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
* xref:index.adoc[Overview]
2+
3+
.Tutorials
4+
* xref:tutorials/getting-started.adoc[Getting Started]
5+
* xref:tutorials/tool-calling.adoc[Tool Calling with Any Model]
6+
* xref:tutorials/smoke-tests.adoc[Running Smoke Tests]
7+
8+
.How-to Guides
9+
* xref:how-to/add-model.adoc[Add a New Model Architecture]
10+
* xref:how-to/add-compute-backend.adoc[Add a Compute Backend]
11+
* xref:how-to/add-tool.adoc[Add a Custom Tool]
12+
* xref:how-to/run-unified-cli.adoc[Use the Unified CLI]
13+
14+
.Reference
15+
* xref:reference/architecture.adoc[Architecture Overview]
16+
* xref:reference/pipeline.adoc[Inference Pipeline]
17+
* xref:reference/tokenizer-api.adoc[Tokenizer API]
18+
* xref:reference/chat-session-api.adoc[ChatSession API]
19+
* xref:reference/model-registry.adoc[Model Registry]
20+
* xref:reference/cli-reference.adoc[CLI Reference]
21+
22+
.Explanation
23+
* xref:explanation/pipeline-design.adoc[Pipeline Design Decisions]
24+
* xref:explanation/dsl-vs-handcoded.adoc[DSL Networks vs Hand-Coded Runtimes]
25+
* xref:explanation/tokenizer-internals.adoc[Tokenizer Internals]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
= DSL Networks vs Hand-Coded Runtimes
2+
:description: Why DSL network definitions replace hand-coded runtimes.
3+
4+
== Two Approaches to Model Definition
5+
6+
SKaiNET Transformers supports two ways to define a model's forward pass:
7+
8+
=== Hand-Coded Runtime (Legacy)
9+
10+
A class that extends `DecoderRuntime` and implements each layer explicitly:
11+
12+
[source,kotlin]
13+
----
14+
class LlamaRuntime<T>(/* ... */) : DecoderRuntime<T>(ctx, dtype) {
15+
override fun runLayer(layerIdx: Int, x: Tensor<T, Float>): Tensor<T, Float> {
16+
val normed = rmsNorm(x, weights.attnNorm[layerIdx])
17+
val q = matmul(normed, weights.wq[layerIdx])
18+
val k = matmul(normed, weights.wk[layerIdx])
19+
// ... 50+ lines of attention + FFN
20+
}
21+
}
22+
----
23+
24+
=== DSL Network Definition (Current)
25+
26+
A pure function that declares the architecture using the network DSL:
27+
28+
[source,kotlin]
29+
----
30+
fun <T : DType, V> llamaNetwork(metadata: LlamaModelMetadata): Module<T, V> {
31+
return sequential<T, V> {
32+
embedding(vocabSize, dim, id = "token_embd")
33+
for (layer in 0 until nLayers) {
34+
rmsNorm(dim, eps, id = "attn_norm")
35+
multiHeadAttention(dim, nHeads, nKVHeads, causal = true) {
36+
rope(headDim, seqLen)
37+
kvCache(seqLen, nKVHeads, headDim)
38+
}
39+
residual()
40+
rmsNorm(dim, eps, id = "ffn_norm")
41+
swiGluFFN(dim, ffnDim)
42+
residual()
43+
}
44+
rmsNorm(dim, eps, id = "output_norm")
45+
}
46+
}
47+
----
48+
49+
== Why DSL is Preferred
50+
51+
=== Compute Graph Optimization
52+
53+
DSL networks can be traced into a ComputeGraph (DAG) and optimized:
54+
55+
* *TransposeEliminationPass* -- folds weight transposes into matmul, eliminating O(n^2) copies
56+
* *LLMFusionPass* -- fuses RMSNorm (7 ops -> 1), SwiGLU FFN (5 ops -> 1), QKV projections (3 -> 1)
57+
* *DeadCodeEliminationPass* -- removes unused intermediate tensors
58+
59+
Hand-coded runtimes cannot benefit from these optimizations because operations are imperative, not declarative.
60+
61+
=== Weight Loading is Automatic
62+
63+
DSL modules have named parameters (e.g., `"blk.0/attn/q_proj"`).
64+
`WeightMapper` matches these to GGUF tensor names via `WeightNameResolver`.
65+
No manual weight loading code needed.
66+
67+
=== Multiple Execution Modes
68+
69+
The same DSL definition supports:
70+
71+
`DIRECT`:: Execute the module tree directly (debugging, correctness testing)
72+
`HYBRID`:: Compile compute-heavy subgraphs, run attention imperatively (best balance)
73+
`OPTIMIZED`:: Full DAG compilation and execution (maximum performance)
74+
75+
=== Adding New Architectures is Simpler
76+
77+
A new architecture is a single function, not a 500-line class.
78+
If the architecture uses standard building blocks (MHA, RMSNorm, FFN), the DSL already has them.
79+
80+
== When Hand-Coded Runtimes Are Needed
81+
82+
Some architectures have components the DSL cannot express:
83+
84+
* *Qwen3.5 DeltaNet* -- hybrid DeltaNet (linear attention + SSM) layers with causal 1D convolution
85+
* *Gemma3n* -- variable FFN dimensions per layer (MatFormer), per-layer embeddings
86+
* *Voxtral* -- ODE flow matching for audio codec
87+
88+
These use `DecoderRuntime` directly.
89+
The goal is to extend the DSL to support these patterns over time.
90+
91+
== Current Status
92+
93+
[cols="1,1,1"]
94+
|===
95+
|Model |DSL |Status
96+
97+
|LLaMA/Mistral
98+
|`llamaNetwork()`
99+
|`LlamaRuntime` deprecated
100+
101+
|Qwen2/3
102+
|`qwenNetwork()`
103+
|Delegates to `llamaNetwork()`
104+
105+
|Apertus
106+
|`apertusNetwork()`
107+
|`ApertusRuntime` deprecated
108+
109+
|BERT
110+
|`bertNetwork()`
111+
|`BertRuntime` deprecated
112+
113+
|Voxtral
114+
|`voxtralBackboneNetwork()`
115+
|Partial DSL
116+
117+
|Gemma3n
118+
|_none_
119+
|Hand-coded only
120+
121+
|Qwen3.5
122+
|_none_
123+
|Hand-coded (DeltaNet)
124+
|===
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
= Pipeline Design Decisions
2+
:description: Why the inference pipeline is structured as separate stages.
3+
4+
== The Problem
5+
6+
Early SKaiNET had a monolithic approach: each model family (LLaMA, Gemma, Apertus) had its own hand-coded runtime that handled everything -- weight loading, forward pass, KV cache, tokenization, and generation.
7+
This led to:
8+
9+
* *Duplicated logic* -- each runtime reimplemented `generate()`, `sample()`, `forward()`.
10+
* *Tight coupling* -- tool calling only worked with kllama because `ToolCallingDemo` depended on `GGUFTokenizer`, a kllama-specific class.
11+
* *No optimization* -- hand-coded runtimes couldn't benefit from compute graph optimization passes.
12+
13+
== The Solution: Separated Pipeline Stages
14+
15+
The pipeline is split into stages that are independently replaceable:
16+
17+
[horizontal]
18+
Weight Loading:: Parse GGUF/SafeTensors into typed tensor maps. Model-format concern, not architecture concern.
19+
Network Definition:: Pure functions (`llamaNetwork()`, `apertusNetwork()`) that return a `Module` tree. Architecture concern only.
20+
Graph Compilation:: Trace the module tree into a DAG, apply optimization passes. Framework concern.
21+
Inference Runtime:: `forward(tokenId)` and `generate()`. Pure inference, no I/O.
22+
Tokenization:: `encode()`/`decode()`. Completely independent of model architecture.
23+
Chat Pipeline:: `ChatSession`, `AgentLoop`, `ChatTemplate`. Independent of both model and tokenizer implementation.
24+
25+
== Key Design Decisions
26+
27+
=== Tokenizer Interface with Metadata
28+
29+
The `Tokenizer` interface includes `eosTokenId`, `bosTokenId`, and `vocabSize`.
30+
This eliminated the need for the `GGUFTokenizer` downcast that previously coupled tool calling to kllama.
31+
Any tokenizer implementation works with `ChatSession` and `AgentLoop`.
32+
33+
=== ChatSession as the Composition Root
34+
35+
Rather than having each CLI wire up `InferenceRuntime` + `Tokenizer` + `ChatTemplate` + `ToolRegistry` individually, `ChatSession` bundles them.
36+
A runner creates one `ChatSession` and gets chat, agent, and demo modes for free.
37+
38+
=== ModelRegistry for Auto-Detection
39+
40+
Instead of if/else chains in each CLI to determine which loader to use, `ModelRegistry.detect(architecture)` returns a `ModelFamily` enum with capabilities (tool calling support, chat template family).
41+
The unified `skainet` CLI uses this to load any GGUF model without architecture-specific flags.
42+
43+
=== GGUFTokenizer in llm-core
44+
45+
Moving `GGUFTokenizer` from `kllama` to `llm-core` was essential.
46+
Every runner needed it, but depending on `kllama` just for the tokenizer created circular dependency pressure.
47+
The `TokenizerFactory` in `llm-core` provides a clean entry point.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
= Tokenizer Internals
2+
:description: How tokenization works across different model families.
3+
4+
== Tokenizer Types
5+
6+
Different model families use different tokenization strategies.
7+
The `GGUFTokenizer` auto-detects the type from GGUF metadata.
8+
9+
=== SentencePiece (LLaMA, Gemma)
10+
11+
* Space is encoded as `\u2581` (lower one eighth block)
12+
* Subword units are learned from training data
13+
* GGUF metadata field: `tokenizer.ggml.model = "llama"` or `"sentencepiece"`
14+
15+
=== BPE (Qwen, Mistral, GPT-2)
16+
17+
* Byte-level BPE: text is converted to UTF-8 bytes, each byte mapped to a Unicode character
18+
* The byte-to-Unicode mapping avoids control characters (bytes 0-32 map to U+0100+)
19+
* Space is represented as `\u0120` (Latin capital G with dot above)
20+
* GGUF metadata field: `tokenizer.ggml.model = "gpt2"` or `"bpe"`
21+
22+
=== WordPiece (BERT)
23+
24+
* Subwords prefixed with `##` (e.g., "playing" -> `["play", "##ing"]`)
25+
* Uses `[CLS]`, `[SEP]`, `[UNK]`, `[PAD]` special tokens
26+
27+
== Special Token Handling
28+
29+
Chat templates use special tokens like `<|im_start|>` and `<|im_end|>` to delimit messages.
30+
These must be encoded as single tokens, not character-split.
31+
32+
The `GGUFTokenizer` collects special tokens from the vocabulary (tokens matching `<|...|>`) and splits text around them before applying BPE.
33+
This ensures `<|im_start|>system` encodes as `[151644, 8948]` (two tokens), not as individual characters.
34+
35+
== GGUF Tokenizer Fields
36+
37+
[cols="2,3"]
38+
|===
39+
|Field |Description
40+
41+
|`tokenizer.ggml.model`
42+
|Tokenizer type: `"llama"`, `"gpt2"`, `"bert"`
43+
44+
|`tokenizer.ggml.tokens`
45+
|Vocabulary as string array
46+
47+
|`tokenizer.ggml.scores`
48+
|BPE merge scores (SentencePiece)
49+
50+
|`tokenizer.ggml.merges`
51+
|BPE merge pairs (GPT-2 style)
52+
53+
|`tokenizer.ggml.bos_token_id`
54+
|Beginning-of-sequence token ID
55+
56+
|`tokenizer.ggml.eos_token_id`
57+
|End-of-sequence token ID
58+
59+
|`tokenizer.ggml.token_type`
60+
|Per-token type (normal, control, unknown, byte)
61+
|===
62+
63+
== TokenizerFactory
64+
65+
`TokenizerFactory` in `llm-core` provides a unified entry point.
66+
It delegates to `GGUFTokenizer` or `HuggingFaceBPETokenizer` based on the source format.
67+
68+
The factory is the recommended way to create tokenizers -- callers don't need to know which implementation is used.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
= Add a Compute Backend
2+
:description: Implement and register a new compute backend (Metal, CUDA, etc.).
3+
4+
Compute backends provide tensor operations for a specific hardware target.
5+
The system auto-selects the best available backend at startup.
6+
7+
== 1. Implement BackendProvider
8+
9+
[source,kotlin]
10+
----
11+
class MetalBackendProvider : BackendProvider {
12+
override val name: String = "metal"
13+
override val displayName: String = "Apple Metal GPU"
14+
override val priority: Int = 100 // higher = preferred
15+
16+
override fun isAvailable(): Boolean {
17+
// Check if Metal is available on this platform
18+
}
19+
20+
override fun createOps(): CpuOps {
21+
// Return Metal-accelerated tensor operations
22+
}
23+
}
24+
----
25+
26+
== 2. Register via ServiceLoader (JVM)
27+
28+
Create `META-INF/services/sk.ainet.lang.backend.BackendProvider`:
29+
30+
[source]
31+
----
32+
com.example.MetalBackendProvider
33+
----
34+
35+
== 3. Register Manually (Native/JS/WASM)
36+
37+
[source,kotlin]
38+
----
39+
BackendRegistry.register(MetalBackendProvider())
40+
----
41+
42+
== 4. Verify
43+
44+
[source,bash]
45+
----
46+
./gradlew :llm-apps:skainet-cli:run \
47+
--args="-m model.gguf --list-backends"
48+
----
49+
50+
Output:
51+
52+
----
53+
Available backends:
54+
metal Apple Metal GPU (priority=100, available)
55+
cpu CPU (SIMD) (priority=0, available)
56+
----
57+
58+
The backend with the highest priority that is available is auto-selected.
59+
Override with `--backend=cpu` to force a specific backend.

0 commit comments

Comments
 (0)