Nine pages under LLM/ document the upstream radiance engine at 8da043c: - engine shape, plugin model, RAD2 container, ABI and environment - every serve flag grouped with defaults and the law behind each knob - the HTTP surface, request fields and refusal classes - rad-convert, the recipe language, quantisers, imports, reuse, rad-export, KL scoring - the (arch id, quant) family table and the recipe of record per model - kernel/quantiser plugin authoring, encodings, build and checks - architecture plugin authoring: declare, step, probe, lane and media rules - rad-info/radbench/radtune and the shell harnesses - the container contract, host prerequisites, readiness and restart triage Also completes the dangling Related line on the frank-archon endpoint page.
17 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|
| howto | Radiance Architecture Plugins — declaring a model | Writing a radiance architecture plugin: declaring weights, buffers, KV groups and ops, then issuing the step. |
|
2026-10-30 |
Radiance architecture plugins
An architecture plugin declares a model in two phases: rad_arch_declare names every weight,
buffer, KV group and op once, against the model's metadata; rad_arch_step issues the ops for
one step. The engine picks the plugin by the exact (rad_arch_id, rad_arch_quant) pair — no
fallback — so one family at two weight formats is two .so files, and branching on a quant
descriptor inside one plugin is the anti-pattern the split exists to prevent.
The in-tree plugins are the templates: arch/llama_fp8/llama_fp8.cpp (the smallest complete
decoder), arch/gemma3, arch/qwen35_bf16, and the header-only blocks in arch/common/
(rad_block_attn*.h, rad_block_mlp*.h, rad_block_moe_fp8.h, rad_block_gdn*.h,
rad_block_dflash2.h, rad_block_mtp*.h, rad_block_vit.h, rad_block_ple.h,
rad_block_glm_*.h, rad_qsa.h, …). Include those by <arch/rad_arch.h> — they are installed
beside the ABI and the in-tree plugins include them by that spelling.
Exports
const char* rad_arch_id(void); /* "llama", "qwen35", ... */
const char* rad_arch_quant(void); /* "" unquantised, else the descriptor served */
int rad_arch_serves_imports(void); /* optional: 0 declines an imported quantisation */
int rad_arch_probe(const RadModelMeta* meta, RadArchProbe* out); /* optional */
int rad_arch_declare(RadBuilder* b, const RadModelMeta* meta, const RadBuildCtx* ctx);
void rad_arch_step(struct RadCtx* c, const struct RadBatch* batch);
const RadChatFormat* rad_arch_chat_format(const RadModelMeta* meta); /* optional */
const RadAudioFormat* rad_arch_audio_format(const RadModelMeta* meta); /* optional */
Everything but declare/step is optional. RAD_ARCH_PLUGIN(NS, ID, QUANT, VERSION, DESC) in
arch/common/rad_arch.h emits the whole export block, including rad_plugin_info (kind
RAD_PLUGIN_ARCH). The plugin's name must be unique; the architecture id may repeat.
rad_arch_serves_imports exists because a checkpoint whose quantisation was imported weight by
weight (Quark, ParoQuant, GPTQ, NVFP4) names its producing tool, which no plugin claims: the family's
one quantised plugin serves it, and two are refused by name. A second quantised plugin that reads
only its own containers returns 0.
Declare: the metadata
RadModelMeta is what the loader read from the container's header before any plugin ran: arch_id,
name, quant, n_layers, n_embd, n_head, n_head_kv, head_dim, n_ff, n_vocab,
n_ctx_train, expert counts, rms_eps, rope_theta, and a free-form kv_key/kv_val table for
everything else (rad_meta_geti/getf/gets). Read dimensions from here — one plugin covers every size
in a family.
head_dim is read, never derived. On the Qwen hybrids it is 256 while hidden / heads is 213;
a derived value silently breaks projection widths, KV page size and the attention kernel's band.
RadBuildCtx is the deployment the plugin must declare against:
| field | meaning |
|---|---|
rank, world_size |
declare runs once per rank; dimensions are already divided — the core inserts no collectives |
max_tok, max_seqs, max_ctx, max_spec |
the step bounds: buffer sizing, kernel bands, the scheduler's window |
max_mixed_tok |
the most a multi-sequence step may carry; a wider step is one sequence (prefill span) |
tp_wire_lossy, tp_wire_min_bytes |
invert into all_reduce's exact; below the floor a message must go exact |
kv_dtype, kv_quant |
the attention cache width and, under fp8, its narrow layout (RAD_KVQ_V4, RAD_KVQ_K6V4) |
attn_prefill_fp8 |
RAD_ATTN_FP8_* bits to pass as attn_paged's prefill_fp8 |
kv_resident_blocks, kv_stream_ok |
KV streaming: the ring depth, and permission to declare the streamed-only ops |
state_dtype |
RAD_F16 when --state-cache-dtype f16 is served by this plugin |
is_draft, scope |
a drafter/encoder declare; scope prefixes declared names (draft, vision, audio) |
shape_probe |
a sizing declare at a smaller max_tok; must not touch state the real declare wrote |
max_enc_patches, max_audio_frames |
the encoder pass sizes |
max_out_rows |
logits rows the KL mode needs (0 = the plugin's own rule) |
cvec |
the loaded control vector, or null |
tp_attn_rows |
row-split prefill attention requested |
draft_coupling, draft_tree |
declare the coupled/tree forms |
weight_prefetch_off |
declare no weight prefetch beside collectives |
Declare: weights
static const RadWeightDecl d = {
.dtype = RAD_BF16, .rank = 2, .shape = {n_embd, n_ff},
.access = RAD_ACCESS_PER_TOKEN, .shard = RAD_SHARD_ROW,
.group = grp_layer(l), .optional = 0,
.n_row_parts = 0, .source = nullptr, .planes = nullptr,
};
rad_weight w = rad_decl_weight(b, "blk.0.mlp.gate.weight", &d);
accesstells the placement planner how the weight is read:PER_TOKEN(dense),PER_REQUEST(an encoder tower, read once per request),CONDITIONAL(routed experts),RARE,VOCAB(lm_head/embedding: read every token but huge, and one is a gather).shard:NONE(replicated),ROW(split dim 0),COL(split dim 1, needs a following all-reduce). The plugin says; the core does not infer. Uneven splits userad_weight_shard_span/_parts(a width that does not divide into whole 128-column blocks; the delta net's[q|k|v|z]of unequal part widths).groupis the unit of movement (layer,expert,slot); an expert's gate/up/down share one, because the unit of transfer is an expert, not a tensor.row_partsdescribes what aSHARD_ROWweight stacks along dim 0 ([gate|up],[q|k|v|z]). A tensor-parallel rank owns a slice of each stacked projection, never a contiguous slice of the stack; the parts cannot be inferred from a fused container tensor, and a contiguous slice produces fluent wrong text.source/planesview an existing stored weight: a block-fp8 linear'scodes,scaleare one logical weight taken by a GEMM as two operands. Nullsourceis the weight of its own name; nullplanestakes every plane.- A dtype other than the plane's is legal only as an exact widening (a bf16 norm read as f32).
The name map (rad_decl_name_map) is the plugin's map from checkpoint tensor names to declared
weight names, and it is the only place convert-time fusions live: COPY, CONCAT, WNORM (a
weight-normalised pair folded to the plain weight), ROWS, TRANSPOSE, EMA (an EnCodec codebook),
TAP, COLS. rad-convert reads this map from the plugin, so it needs no knowledge of the model.
RAD_MAP_COPY with several sources means "first present wins" (a tied lm_head); CONCAT requires
all. Ask for a weight's encoding with rad_weight_encoding after its map entry when the graph
depends on the format (fp8 GEMM vs plain, rotated expert kernels).
Declare: buffers and KV groups
rad_decl_buffer names every activation: RAD_BUF_TRANSIENT (liveness-analysed, may share bytes),
RAD_BUF_PERSIST (across steps: a calibration accumulator, a routing histogram), or
RAD_BUF_DERIVED (computed once per step by the core from the batch — derive names the
derivation, e.g. conv_state_index). Buffers live in one arena sized once, not an allocator per
step.
rad_decl_kv_group declares each state kind: RAD_KV_FULL (paged per-token, growing),
RAD_KV_WINDOW (bounded by a window), RAD_KV_LINEAR (per-sequence fixed state, GDN),
RAD_KV_CONV (a rolling window of width-1+num_spec), RAD_KV_RING (window floor set at runtime).
The block size is not declared — it comes from the resolved attention kernel's constraints; read
it back with rad_kv_block_size. rad_bind_layer_kv binds each layer so the block manager knows how
many state instances a sequence owns; rad_kv_group_zero (a rank with no heads),
rad_kv_group_replicated (every rank holds the same bytes), and rad_kv_group_dense_read (read over
the whole history every step, so it cannot stream) are the three refinements.
Declare: ops
rad_op h = RAD_OP(b, "gemm_w4a8_nt",
RAD_PARAMS(RAD_RANGE("M", 1, max_tok), RAD_INT("N", 8192), RAD_INT("K", 5120)),
RAD_WEIGHTS(w));
rad_op_reads (b, h, RAD_BUFS(b_x), n);
rad_op_writes(b, h, RAD_BUFS(b_y), n);
- Parameter names and operand order come from
docs/OPS.md; a new name is allowed only when the vocabulary has none, and the first plugin to declare it fixes its schema. RAD_RANGEon exactly one parameter per op gives the selector its bucket table. The core carries every parameter opaquely exceptRAD_PROLE_SEQ_CHUNK(cut steps on it) andRAD_PROLE_CAPACITY.rad_op_reads/rad_op_writesare the buffer planner's liveness. An undeclared use is assumed live for the whole program: sound but wasteful.- Fusion is selection. Ask for the fused op; if
rad_op_resolvedis false, emit the chain. A fusion'sreplaceshook is a checkable equivalence, not a rewrite. rad_op_declared(b, "op")asks whether any library declares an optional op; use it before emitting a fused form only an optional library defines.- Buffers touched from the second lane go through
rad_buf_concurrent, and an architecture whose lane-1 work lies in one stretch declaresrad_lane1_window_begin/endaround it. The runtime refuses a lane-1 op outside the window; the window lets buffers used only outside it share bytes.
Step output: rad_declare_logits names the buffer the sampler reads (its dim 0 bounds n_out; a
[n_tok, vocab] logits buffer at a large vocabulary is gigabytes — declare the gather +
logits_gemm shape instead). An embedding program declares rad_declare_pooled instead (one row a
sequence, no sampler, no generation). rad_declare_scores opts into host-side scored steps.
Declare: drafters, encoders, generators
rad_declare_drafter(RadDrafterDecl)— depth, block, proposal shape.rad_drafter_shared_kv(a drafter with no cache of its own),rad_drafter_tree(a tree over the proposals),rad_drafter_short_block(a second, shorter pass) are refinements. Declare the drafter last.rad_declare_encoder(RadEncoderDecl)— a vision/audio tower, media preprocessed by the core.rad_declare_generator/rad_declare_audio_generator/rad_declare_video_generator— image, audio and video generation programs; the engine sizes the step from the probe'sgen_*fields before declare.- Optional format exports let a model state what it knows and the server learn nothing new:
rad_arch_chat_format(a data description of reasoning/tool-call markup, replacing template analysis),rad_arch_audio_format(how a speech model hears: clip tokens, prompt, language, transcript start),rad_arch_text_prepare(rewrite request text into training form),rad_arch_video_positions/rad_arch_video_pack(video pass geometry and packing),rad_arch_encode_geom(encoder pass geometry). Each struct grows only at its end behindstruct_size, so adding a field does not move the ABI.
Media preprocessing is the core's, not the plugin's: resizing, patching, placeholder expansion
and positions in six processor families (Qwen-VL, Gemma 4, LFM2-VL, Unlimited-OCR, GLM-5.3, Pixtral
for Nemotron), chosen by vision_config.model_type plus owning-model checks; audio through Gemma 4's
extractor or Qwen3-ASR's Whisper one, chosen by audio_config.model_type. A vision model with another
pipeline needs a core change.
Step: issuing the graph
RadBatch is built once per step and shared by every block: phase (PREFILL/DECODE/MIXED),
n_tok, n_seq, token_ids, positions, cu_seqlens, seq_ids, lora_slot, n_out/out_ids,
q_lens/ctx_lens/max_q_len/max_ctx_len, the KV-group array, the speculative fields
(n_spec, draft_pass, …).
static void step(RadCtx* c, const RadBatch* batch) {
if (batch->draft_pass > 0) { m.ds.step(c, batch); return; } /* query pass */
if (batch->draft_pass < 0) { m.ds.context(c, batch); return; } /* context pass */
RAD_ISSUE(c, m.op_embed, praw(batch->token_ids, RAD_I32, batch->n_tok),
RAD_W(m.w_tok), brows(m.b_x, batch->n_tok));
for (auto& layer : m.layers) { layer.attn.step(c, batch); layer.mlp.step(c, batch); }
/* ...heads; the lm_head only over batch->n_out rows... */
}
rad_issue(c, op, operands…, n)/RAD_ISSUE(n = the batch's ranged extent) /RAD_ISSUE_N(an explicit row count).rad_step_fail(c, what)sets the status helpers return.- A mixed step is the trap. Continuous batching produces steps with a prefill chunk beside decode
rows. A block that issues one path for the whole step sends its decode rows down the chunked path,
which commits recurrent state directly and takes no
num_accepted; a rejected draft cannot be rolled back, and the model stays fluent while drifting. Issue the recurrent path overRadBatch::n_seq_decodesequences and the chunked path over the rest (the core orders steps decode-first, so each half is contiguous;cu_seqlensholds absolute offsets, so passcu + n_seq_decodewith the token operands at their base). - A step issued on lane 1 outside the declared window fails by name. An op the core appends behind a declared one (runtime LoRA) counts at that op's position.
- Zero rows is not a call: issuing an op over zero rows (a zero ranged extent, or all-empty
sequences) returns
RAD_E_SHAPEand writes nothing. The caller skips the issue, as the architectures' decode and prefill halves do. n_outis 0 for a chunk that finishes no sequence — do not run the lm_head.
Probe: what the plugin measured
rad_arch_probe(meta, out) runs before declare. Its fields are the engine's permissions:
| field | effect |
|---|---|
draft_depth, draft_depth_fixed |
the container's spec depth; a block drafter's trained block is the only depth it serves |
shape_probe_ok |
the plugin tolerates a sizing declare, so the activation arena can be lent |
enc_merge, audio_frames |
encoder pass granularity; longest audio clip |
gen_rows, gen_patches, gen_audio, gen_ctx, gen_video |
generator sizing before declare |
kv_quant_ok |
bits of the narrow KV layouts this model's kernels serve |
chain_pays, tree_pays |
the plugin measured chained launches / draft trees faster |
kv_stream_ok |
the architecture can stream a KV group (sparse, block-addressed, with kv_resolve) |
control_vector_ok |
the plugin applies RadBuildCtx::cvec |
tp_attn_rows_ok |
the plugin serves row-split prefill attention |
state_f16_ok |
the plugin declares recurrent state at f16 and its kernels read it |
A flag whose probe bit is 0 is refused at startup with the plugin named (--kv-cache-dtype fp8_v4, --control-vector, --tp-attn-split rows, --state-cache-dtype f16, --kv-resident).
That is the design: a deployment switch the plugin cannot honour is an error, never a silent
substitution.
Worked pattern: the smallest complete plugin
arch/llama_fp8/llama_fp8.cpp builds twice from one source — llama_fp8 (quant fp8_e4m3) and
llama_dense_fp8 (quant ""), differing only in the descriptor and the graph-dump text. It shows
the whole shape:
struct Layer { AttnFP8 attn; MlpFP8 mlp; },struct ModelholdingNames,Geom, buffer and op handles.declare(): name map entries first, then weights (withrad_weight_encodingwhere the head's format decides the kernel), buffers, KV group, the embedding and its all-reduce, then each layer'sdeclare()from the shared blocks, the final norm, gather and lm_head, the drafter last, andrad_notelines explaining the shape.step(): draft passes first (sign selects query/context), then embedding (+ exact all-reduce when needed), each layer'sstep(), the final norm, and the head only overn_out.probe(): readsdraft.block_sizefrom the metadata and refuses any other depth for a block drafter.RAD_ARCH_PLUGIN(...)at the bottom.
Add a new family by copying a neighbour, replacing the name map and geometry, and composing the
blocks you need; add a new format by cloning the plugin with another (id, quant) pair and the
ops that format needs.
Verifying a new plugin
radiance --model m.rad --debug-graph # every op, its resolved kernel, and why
radiance --model m.rad --debug-placement # the plan
rad-convert $SRC -o m.rad --plan-only -v # a convert that plans will boot
rad-kbench -m m.rad --kernels mylib,libr4d,libref --report k.md --bench
rad-tune -m m.rad --tp 2 # only at the shapes you serve
tests/arch_test.cpp drives a plugin's builder the way the core does, including lane-window
liveness, so the pinned graphs and steps are the ones the core plans. A plugin that declares a weight
form no kernel layout accepts is a declare-time error naming the weight, encoding and kernel — the
same check rad-convert runs before writing.
Related
- Radiance Plugins — kernel libraries, encodings, the build path, ABI law.
- Radiance Conversion — how the name map drives a convert, and
--set/descriptor selection. - Radiance Model Families — the existing
(id, quant)pairs and their recipes. - Radiance Tools —
rad-kbench,rad-tune,rad-info --plugins.